diff --git a/ts/packages/agentServer/server/src/server.ts b/ts/packages/agentServer/server/src/server.ts index 8c9b40a033..5835a7f167 100644 --- a/ts/packages/agentServer/server/src/server.ts +++ b/ts/packages/agentServer/server/src/server.ts @@ -15,8 +15,7 @@ import { } from "agent-dispatcher/helpers/data"; import { getDefaultAppAgentProviders, - getDefaultAppAgentSource, - getMcpAppAgentSource, + getDefaultAppAgentSources, getIndexingServiceRegistry, getDefaultConstructionProvider, } from "default-agent-provider"; @@ -340,10 +339,9 @@ async function main() { instanceDir, configName, ), - appAgentSources: [ - getDefaultAppAgentSource(instanceDir, { configName }), - getMcpAppAgentSource(instanceDir), - ], + appAgentSources: getDefaultAppAgentSources(instanceDir, { + configName, + }), persistSession: true, storageProvider: getFsStorageProvider(), metrics: true, diff --git a/ts/packages/api/src/webDispatcher.ts b/ts/packages/api/src/webDispatcher.ts index 79497d8e25..040d05be2c 100644 --- a/ts/packages/api/src/webDispatcher.ts +++ b/ts/packages/api/src/webDispatcher.ts @@ -16,8 +16,7 @@ import { createDispatcherRpcServer } from "@typeagent/dispatcher-rpc/dispatcher/ import { createChannelAdapter } from "@typeagent/agent-rpc/channel"; import { getDefaultAppAgentProviders, - getDefaultAppAgentSource, - getMcpAppAgentSource, + getDefaultAppAgentSources, getDefaultConstructionProvider, getIndexingServiceRegistry, } from "default-agent-provider"; @@ -54,12 +53,9 @@ export async function createWebDispatcher( const clientIO = createClientIORpcClient(clientIOChannel.channel); const dispatcher = await createDispatcher("api", { appAgentProviders: getDefaultAppAgentProviders(instanceDir), - appAgentSources: [ - getDefaultAppAgentSource(instanceDir, { - excludePathSources: true, - }), - getMcpAppAgentSource(instanceDir), - ], + appAgentSources: getDefaultAppAgentSources(instanceDir, { + excludePathSources: true, + }), persistSession: true, persistDir: instanceDir, storageProvider: getFsStorageProvider(), diff --git a/ts/packages/defaultAgentProvider/src/defaultAgentProviders.ts b/ts/packages/defaultAgentProvider/src/defaultAgentProviders.ts index d1a4d0c4c4..58a64a6db4 100644 --- a/ts/packages/defaultAgentProvider/src/defaultAgentProviders.ts +++ b/ts/packages/defaultAgentProvider/src/defaultAgentProviders.ts @@ -22,9 +22,11 @@ import { InstallPreview, InstallPreviewMatch, InstallResult, + McpInstallCandidate, UpdateResult, deriveMatchKind, } from "./installSources/config.js"; +import { cleanupOwnedMcpPaths } from "./installSources/mcpRegistryMaterializer.js"; import { createPackageAppAgentProvider, AgentSourceGroup, @@ -32,6 +34,8 @@ import { InstalledAgentInfo, InstalledAgentSourceApi, } from "./installSources/packageAgent.js"; +import type { McpServerSourceApi } from "./mcp/mcpAppAgentSource.js"; +import type { NormalizedMcpServerConfig } from "./mcp/mcpServerConfig.js"; import fs from "node:fs"; import path from "node:path"; @@ -375,6 +379,8 @@ export function createDefaultInstalledAgentSource( sourceFactory?: InstallSourceFactory, /** @internal Test-only store writer for exercising commit failures. */ storeWriter: typeof writeAgentsJson = writeAgentsJson, + /** MCP runtime API shared with this source's per-session @package agent. */ + mcpSource?: McpServerSourceApi, ): InstalledAgentSourceForTest { const instanceConfigs = getInstanceConfigProvider(instanceDir); const installDir = getInstallDir(instanceConfigs); @@ -1265,9 +1271,18 @@ export function createDefaultInstalledAgentSource( registry, recordsUsingSource: (sourceName: string) => { const agents = readAgentsJson(instanceDir)?.agents ?? {}; - return Object.values(agents) + const agentNames = Object.values(agents) .filter((record) => record.source === sourceName) .map((record) => record.name); + const mcpNames = + mcpSource + ?.listServers() + .filter( + (config) => + config.provenance.source === sourceName, + ) + .map((config) => config.name) ?? []; + return [...agentNames, ...mcpNames]; }, }); }, @@ -1442,6 +1457,36 @@ export function createDefaultInstalledAgentSource( matches: result.matches.map(toMatch), }; }, + async resolveMcp( + ref: string, + sourceName?: string, + onStatus?: SourceStatus, + ): Promise { + return registry.resolveMcp(ref, sourceName, undefined, onStatus); + }, + async materializeMcp( + candidate: McpInstallCandidate, + abortSignal?: AbortSignal, + ): Promise { + return candidate.materialize === undefined + ? candidate.config + : candidate.materialize(abortSignal); + }, + cleanupMcp(config: NormalizedMcpServerConfig): void { + const referencedPaths = new Set( + mcpSource + ?.listServers() + .filter((other) => other.id !== config.id) + .flatMap((other) => other.provenance.ownedPaths ?? []) ?? + [], + ); + cleanupOwnedMcpPaths( + resolvedInstallDir, + config.provenance.ownedPaths?.filter( + (ownedPath) => !referencedPaths.has(ownedPath), + ), + ); + }, async refresh(sourceName?: string): Promise { // Refresh cache-backed source metadata; a fetch failure propagates // so the `--refresh` command fails rather than acting on stale data. @@ -1460,6 +1505,7 @@ export function createDefaultInstalledAgentSource( const packageProvider = createPackageAppAgentProvider({ appAgentProviderSetController: controller, source, + ...(mcpSource === undefined ? {} : { mcpSource }), }); // Torn down before the initial set resolved: a connection disposed // while still parked on an in-flight barrier must NOT join the diff --git a/ts/packages/defaultAgentProvider/src/defaultAgentRuntime.ts b/ts/packages/defaultAgentProvider/src/defaultAgentRuntime.ts new file mode 100644 index 0000000000..fa3d0f1c8a --- /dev/null +++ b/ts/packages/defaultAgentProvider/src/defaultAgentRuntime.ts @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { AppAgentSource } from "agent-dispatcher"; +import { + createDefaultInstalledAgentSource, + DefaultAppAgentSourceOptions, +} from "./defaultAgentProviders.js"; +import { createMcpAppAgentSourceForInstance } from "./mcpDefaultAgentProvider.js"; +import { getInstanceConfigProvider } from "./utils/config.js"; +import type { InstalledAgentSourceApi } from "./installSources/packageAgent.js"; +import type { McpServerSourceApi } from "./mcp/mcpAppAgentSource.js"; +import type { McpHostServices } from "./mcp/mcpServerProvider.js"; +import { SessionMcpCredentialStore } from "./mcp/mcpCredentialStore.js"; +import { defaultMcpPolicy } from "./mcp/mcpPolicy.js"; +import { JsonlMcpAuditSink } from "./mcp/mcpAudit.js"; + +export interface DefaultAgentRuntime { + readonly appAgentSources: [AppAgentSource, AppAgentSource]; + readonly installedAgentSourceApi: InstalledAgentSourceApi; + readonly mcpServerSourceApi: McpServerSourceApi; +} + +export function createDefaultAgentRuntime( + instanceDir: string, + options?: DefaultAppAgentSourceOptions, + mcpServices?: Partial, +): DefaultAgentRuntime { + const services: McpHostServices = { + credentialStore: + mcpServices?.credentialStore ?? new SessionMcpCredentialStore(), + policy: mcpServices?.policy ?? defaultMcpPolicy, + audit: mcpServices?.audit ?? new JsonlMcpAuditSink(instanceDir), + ...(mcpServices?.oauthInteraction === undefined + ? {} + : { oauthInteraction: mcpServices.oauthInteraction }), + }; + const mcp = createMcpAppAgentSourceForInstance( + getInstanceConfigProvider(instanceDir), + services, + ); + const installed = createDefaultInstalledAgentSource( + instanceDir, + options, + undefined, + undefined, + mcp.testApi, + ); + const { testApi: installedAgentSourceApi, ...installedSource } = installed; + const { testApi: mcpServerSourceApi, ...mcpSource } = mcp; + return { + appAgentSources: [installedSource, mcpSource], + installedAgentSourceApi, + mcpServerSourceApi, + }; +} + +export function getDefaultAppAgentSources( + instanceDir: string, + options?: DefaultAppAgentSourceOptions, +): [AppAgentSource, AppAgentSource] { + return createDefaultAgentRuntime(instanceDir, options).appAgentSources; +} diff --git a/ts/packages/defaultAgentProvider/src/index.ts b/ts/packages/defaultAgentProvider/src/index.ts index 418018185e..94060278e2 100644 --- a/ts/packages/defaultAgentProvider/src/index.ts +++ b/ts/packages/defaultAgentProvider/src/index.ts @@ -8,7 +8,29 @@ export { getIndexingServiceRegistry, } from "./defaultAgentProviders.js"; export { getMcpAppAgentSource } from "./mcpDefaultAgentProvider.js"; +export { + createDefaultAgentRuntime, + getDefaultAppAgentSources, + type DefaultAgentRuntime, +} from "./defaultAgentRuntime.js"; export { getDefaultConstructionProvider } from "./defaultConstructionProvider.js"; +export { + SessionMcpCredentialStore, + type McpCredentialStore, +} from "./mcp/mcpCredentialStore.js"; +export { + defaultMcpPolicy, + enforceMcpPolicy, + type McpPolicy, +} from "./mcp/mcpPolicy.js"; +export { + JsonlMcpAuditSink, + sanitizeMcpAuditEvent, + type McpAuditEvent, + type McpAuditSink, +} from "./mcp/mcpAudit.js"; +export type { McpOAuthInteraction } from "./mcp/mcpOAuth.js"; +export type { McpHostServices } from "./mcp/mcpServerProvider.js"; export { createOnboardingOnlyDispatcher, type OnboardingDispatcherHandle, diff --git a/ts/packages/defaultAgentProvider/src/installSources/addSource.ts b/ts/packages/defaultAgentProvider/src/installSources/addSource.ts index 2da112094f..3760357bef 100644 --- a/ts/packages/defaultAgentProvider/src/installSources/addSource.ts +++ b/ts/packages/defaultAgentProvider/src/installSources/addSource.ts @@ -14,6 +14,7 @@ import { FeedSourceConfig, McpConfigSourceConfig, PathSourceConfig, + RegistrySourceConfig, } from "./config.js"; import { DefaultInstallSourceRegistry } from "./registry.js"; import { expandHome } from "./paths.js"; @@ -35,11 +36,30 @@ function validateFeedRegistry(url: string): void { } catch { throw new Error(`'${url}' is not a well-formed URL`); } + if (parsed.protocol !== "https:") { throw new Error(`feed registry URL must be https: '${url}'`); } } +function validateRegistryUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`'${url}' is not a well-formed URL`); + } + if (parsed.protocol !== "https:") { + throw new Error(`MCP Registry URL must be https: '${url}'`); + } + parsed.search = ""; + parsed.hash = ""; + if (!parsed.pathname.endsWith("/")) { + parsed.pathname += "/"; + } + return parsed.toString(); +} + function validateCatalogFile(catalog: string): void { try { JSON.parse(fs.readFileSync(catalog, "utf8")); @@ -208,6 +228,7 @@ class McpConfigAddCommandHandler implements CommandHandler { "--file is required for an mcp-config source", ); } + const normalizedFile = normalizeAbsolutePath(file); try { JSON.parse(fs.readFileSync(normalizedFile, "utf8")); @@ -218,6 +239,7 @@ class McpConfigAddCommandHandler implements CommandHandler { `MCP config file '${normalizedFile}' is not accessible: ${err.message}`, ); } + throw new Error( `MCP config '${normalizedFile}' is not valid JSON: ${err.message}`, ); @@ -232,6 +254,53 @@ class McpConfigAddCommandHandler implements CommandHandler { } } +class RegistryAddCommandHandler implements CommandHandler { + public readonly description = "Add an MCP Registry v0.1 install source"; + public readonly parameters = { + args: { + name: { description: "Unique source name", type: "string" }, + }, + flags: { + url: { + description: "MCP Registry base URL (https)", + char: "u", + type: "string", + }, + "cache-ttl": { + description: "Metadata cache TTL in seconds", + type: "number", + optional: true, + }, + }, + } as const; + constructor(private readonly registry: DefaultInstallSourceRegistry) {} + public async run( + context: ActionContext, + params: ParsedCommandParams, + ) { + const url = params.flags.url; + if (url === undefined) { + throw new Error( + "--url is required for a registry source", + ); + } + const ttlSeconds = params.flags["cache-ttl"]; + if (ttlSeconds !== undefined && ttlSeconds <= 0) { + throw new Error("--cache-ttl must be greater than zero"); + } + const config: RegistrySourceConfig = { + kind: "registry", + name: params.args.name, + baseUrl: validateRegistryUrl(url), + ...(ttlSeconds === undefined + ? {} + : { cacheTtlMs: ttlSeconds * 1000 }), + }; + this.registry.add(config); + displayResult(`Added registry source '${params.args.name}'.`, context); + } +} + /** * Build the host's `@package source add` subcommand table * merges this into the `@package source` table via @@ -247,6 +316,7 @@ export function getAddSourceCommandHandlers( catalog: new CatalogAddCommandHandler(registry), path: new PathAddCommandHandler(registry), "mcp-config": new McpConfigAddCommandHandler(registry), + registry: new RegistryAddCommandHandler(registry), }, }; } diff --git a/ts/packages/defaultAgentProvider/src/installSources/config.ts b/ts/packages/defaultAgentProvider/src/installSources/config.ts index 27f7d380ff..bc116b6fcf 100644 --- a/ts/packages/defaultAgentProvider/src/installSources/config.ts +++ b/ts/packages/defaultAgentProvider/src/installSources/config.ts @@ -14,6 +14,8 @@ // core, and lets the host decide how a source is added, listed, ordered, // removed, and persisted, including any future auth UI. +import type { NormalizedMcpServerConfig } from "../mcp/mcpServerConfig.js"; + /** * The result of a source's `find`: which source matched and how the agent * should be acquired. If `find` returns a candidate, `materialize` must @@ -79,6 +81,24 @@ export interface ResolvedCandidate { */ export type ExtensionKind = "agent" | "mcp"; +/** + * A normalized MCP artifact resolved by an install source. MCP candidates stay + * separate from native {@link ResolvedCandidate} records because they are + * persisted by the MCP server store and never pass through native agent + * materialization or agents.json. + */ +export interface McpInstallCandidate { + readonly extensionKind: "mcp"; + readonly source: string; + readonly sourceKind: string; + readonly ref: string; + readonly config: NormalizedMcpServerConfig; + /** Materialize owned local content after user confirmation. */ + readonly materialize?: ( + abortSignal?: AbortSignal, + ) => Promise; +} + /** * One enumerable install target advertised by a source for `@package available` * and install completion. `ref` is the source's internal durable/identity @@ -308,7 +328,12 @@ export type UninstallOutcomeStatus = "uninstalled" | "reverted"; * provider, not an install source (they are never installed/uninstalled/ * updated). Install sources only resolve user-installed agents. */ -export type InstallSourceKind = "path" | "catalog" | "feed" | "mcp-config"; +export type InstallSourceKind = + | "path" + | "catalog" + | "feed" + | "mcp-config" + | "registry"; /** * A `path` source validates a filesystem path the user supplies. `ref` is a @@ -365,7 +390,8 @@ export type InstallSourceConfig = | PathSourceConfig | FeedSourceConfig | CatalogSourceConfig - | McpConfigSourceConfig; + | McpConfigSourceConfig + | RegistrySourceConfig; /** * An `mcp-config` source enumerates MCP servers declared in a local MCP config @@ -383,6 +409,16 @@ export interface McpConfigSourceConfig { file: string; // local filesystem path to the MCP config JSON } +/** A read-only MCP Registry v0.1 discovery and installation source. */ +export interface RegistrySourceConfig { + kind: "registry"; + name: string; + baseUrl: string; + cachePath?: string; + cacheTtlMs?: number; + maxPages?: number; +} + /** * The host-rendered summary of one configured source for `@package source list`. * `kind` and `detail` are display strings the host produces from the config @@ -415,6 +451,14 @@ export interface InstallSource { ref: string, onWarn?: SourceWarning, ): Promise; + /** + * Resolve an MCP artifact by its source-local ref. Sources that do not + * provide MCP artifacts omit this method. + */ + findMcp?( + ref: string, + onWarn?: SourceWarning, + ): Promise; /** * Optional default-agent-name lookup for one-argument install (phase 1). * Matches the package's declared `typeagent.defaultAgentName`. Returning a diff --git a/ts/packages/defaultAgentProvider/src/installSources/mcpConfigSource.ts b/ts/packages/defaultAgentProvider/src/installSources/mcpConfigSource.ts index 50ccfc3981..30baf11f21 100644 --- a/ts/packages/defaultAgentProvider/src/installSources/mcpConfigSource.ts +++ b/ts/packages/defaultAgentProvider/src/installSources/mcpConfigSource.ts @@ -7,6 +7,7 @@ import { InstallSource, McpConfigSourceConfig, MaterializedInstallRecord, + McpInstallCandidate, ResolvedCandidate, SourceWarning, AvailableInstallRow, @@ -63,26 +64,13 @@ function loadFile(file: string): unknown { * and enumerates the normalized servers as `extensionKind: "mcp"` rows for * `@package available --type mcp`. * - * It deliberately does NOT participate in the native-agent resolution walk: - * `find` / `findName` return `undefined` so an MCP server name never resolves - * as an installable npm agent. Actually adding an MCP server routes through the - * MCP server store / {@link ../mcp/mcpAppAgentSource.McpServerSourceApi}, kept as - * a separate store behind the unified `@package` facade per the near-term - * staging plan; `materialize` therefore throws if ever reached. - * - * `getServers` exposes the normalized snapshot so the facade (and tests) can - * pull a named server's config to hand to the MCP source. + * It deliberately does NOT participate in the native-agent resolution walk. + * MCP artifacts resolve through `findMcp`, which returns a normalized + * candidate for the MCP store without materializing an InstalledAgentRecord. */ -export interface McpConfigInstallSource extends InstallSource { - // The normalized configs of every server that imported cleanly, keyed by - // server name. Used by the `@package` facade to route an MCP install to the - // MCP server store. - getServers(): ReadonlyMap; -} - export function createMcpConfigSource( config: McpConfigSourceConfig, -): McpConfigInstallSource { +): InstallSource { function buildSnapshot(): McpConfigSnapshot { const entryWarnings: string[] = []; let parsed: unknown; @@ -108,17 +96,21 @@ export function createMcpConfigSource( return { serversByName, entryWarnings }; } - const snapshot = buildSnapshot(); - - function warnLoad(onWarn?: SourceWarning): void { + function warnLoad( + snapshot: McpConfigSnapshot, + onWarn?: SourceWarning, + ): void { if (snapshot.loadWarning !== undefined) { debug(snapshot.loadWarning); onWarn?.(snapshot.loadWarning); } } - function warnAll(onWarn?: SourceWarning): void { - warnLoad(onWarn); + function warnAll( + snapshot: McpConfigSnapshot, + onWarn?: SourceWarning, + ): void { + warnLoad(snapshot, onWarn); for (const message of snapshot.entryWarnings) { debug(message); onWarn?.(message); @@ -131,15 +123,41 @@ export function createMcpConfigSource( describe(): string { return config.file; }, - getServers(): ReadonlyMap { - return snapshot.serversByName; - }, // An MCP server is not a native npm agent: never resolve one through the // agent resolution walk. The unified `@package` facade routes MCP // installs to the MCP server store instead (see class doc). async find(): Promise { return undefined; }, + async findMcp( + ref: string, + onWarn?: SourceWarning, + ): Promise { + const snapshot = buildSnapshot(); + warnLoad(snapshot, onWarn); + const imported = snapshot.serversByName.get(ref); + if (imported === undefined) { + return undefined; + } + return { + extensionKind: "mcp", + source: config.name, + sourceKind: config.kind, + ref, + config: { + ...imported, + id: `mcp:${encodeURIComponent(config.name)}:${encodeURIComponent(ref)}`, + enabled: false, + trust: "untrusted", + scope: "workspace", + provenance: { + source: config.name, + sourceKind: config.kind, + ref, + }, + }, + }; + }, async materialize(): Promise { throw new Error( `mcp-config source '${config.name}' cannot materialize a native agent; ` + @@ -149,7 +167,8 @@ export function createMcpConfigSource( async listAgents( onWarn?: SourceWarning, ): Promise { - warnAll(onWarn); + const snapshot = buildSnapshot(); + warnAll(snapshot, onWarn); const rows: AvailableInstallRow[] = []; for (const [name, server] of snapshot.serversByName) { const row: AvailableInstallRow = { diff --git a/ts/packages/defaultAgentProvider/src/installSources/mcpRegistryCache.ts b/ts/packages/defaultAgentProvider/src/installSources/mcpRegistryCache.ts new file mode 100644 index 0000000000..d46c155488 --- /dev/null +++ b/ts/packages/defaultAgentProvider/src/installSources/mcpRegistryCache.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import fs from "node:fs"; +import path from "node:path"; +import type { RegistryServerEntry } from "./mcpRegistryClient.js"; + +export interface RegistryCacheData { + fetchedAt: number; + updatedSince: string; + entries: RegistryServerEntry[]; +} + +export interface RegistryCacheStorage { + read(): RegistryCacheData | undefined; + write(data: RegistryCacheData): void; +} + +export function createRegistryCacheStorage( + filePath: string, +): RegistryCacheStorage { + return { + read() { + try { + return JSON.parse( + fs.readFileSync(filePath, "utf8"), + ) as RegistryCacheData; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw error; + } + }, + write(data) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const temp = `${filePath}.${process.pid}.${Date.now()}.tmp`; + try { + fs.writeFileSync(temp, JSON.stringify(data, null, 2)); + fs.renameSync(temp, filePath); + } finally { + fs.rmSync(temp, { force: true }); + } + }, + }; +} + +export function mergeRegistryCache( + previous: RegistryServerEntry[], + updates: RegistryServerEntry[], +): RegistryServerEntry[] { + const byKey = new Map( + previous.map((entry) => [ + `${entry.server.name}\0${entry.server.version}`, + entry, + ]), + ); + for (const entry of updates) { + const key = `${entry.server.name}\0${entry.server.version}`; + if (entry.meta.status === "deleted") { + byKey.delete(key); + } else { + if (entry.meta.isLatest) { + for (const [existingKey, existing] of byKey) { + if ( + existing.server.name === entry.server.name && + existing.meta.isLatest + ) { + byKey.delete(existingKey); + } + } + } + byKey.set(key, entry); + } + } + return [...byKey.values()]; +} diff --git a/ts/packages/defaultAgentProvider/src/installSources/mcpRegistryClient.ts b/ts/packages/defaultAgentProvider/src/installSources/mcpRegistryClient.ts new file mode 100644 index 0000000000..86b45d49df --- /dev/null +++ b/ts/packages/defaultAgentProvider/src/installSources/mcpRegistryClient.ts @@ -0,0 +1,358 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type RegistryStatus = "active" | "deprecated" | "deleted"; + +export interface RegistryInput { + value?: string | undefined; + default?: string | undefined; + isRequired?: boolean | undefined; + isSecret?: boolean | undefined; + format?: string | undefined; +} + +export interface RegistryArgument extends RegistryInput { + type: string; + name?: string | undefined; + valueHint?: string | undefined; + variables?: Record | undefined; +} + +export interface RegistryKeyValue extends RegistryInput { + name: string; + variables?: Record | undefined; +} + +export interface RegistryTransport { + type: string; + url?: string | undefined; + headers?: RegistryKeyValue[] | undefined; + variables?: Record | undefined; +} + +export interface RegistryPackage { + registryType: string; + identifier: string; + version?: string | undefined; + registryBaseUrl?: string | undefined; + fileSha256?: string | undefined; + runtimeHint?: string | undefined; + runtimeArguments?: RegistryArgument[] | undefined; + packageArguments?: RegistryArgument[] | undefined; + environmentVariables?: RegistryKeyValue[] | undefined; + transport: RegistryTransport; +} + +export interface RegistryServer { + name: string; + title?: string | undefined; + description: string; + version: string; + repository?: Record | undefined; + packages?: RegistryPackage[] | undefined; + remotes?: RegistryTransport[] | undefined; + publisher?: Record | undefined; +} + +export interface RegistryServerEntry { + server: RegistryServer; + meta: { + status: RegistryStatus; + statusMessage?: string | undefined; + updatedAt?: string | undefined; + publishedAt: string; + isLatest: boolean; + }; +} + +export interface RegistryListOptions { + search?: string | undefined; + version?: string | undefined; + updatedSince?: string | undefined; + includeDeleted?: boolean | undefined; + limit?: number | undefined; + maxPages?: number | undefined; +} + +export interface McpRegistryClient { + list(options?: RegistryListOptions): Promise; + get( + name: string, + version?: string, + ): Promise; +} + +type FetchFn = typeof fetch; + +function object(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`MCP Registry returned invalid ${label}`); + } + return value as Record; +} + +function string(value: unknown, label: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`MCP Registry returned invalid ${label}`); + } + return value; +} + +function optionalString(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function parseInput(value: unknown): RegistryInput { + const raw = object(value, "input"); + return { + ...(optionalString(raw.value) === undefined + ? {} + : { value: optionalString(raw.value) }), + ...(optionalString(raw.default) === undefined + ? {} + : { default: optionalString(raw.default) }), + ...(typeof raw.isRequired === "boolean" + ? { isRequired: raw.isRequired } + : {}), + ...(typeof raw.isSecret === "boolean" + ? { isSecret: raw.isSecret } + : {}), + ...(optionalString(raw.format) === undefined + ? {} + : { format: optionalString(raw.format) }), + }; +} + +function parseVariables( + value: unknown, +): Record | undefined { + if (value === undefined) { + return undefined; + } + const raw = object(value, "variables"); + return Object.fromEntries( + Object.entries(raw).map(([name, input]) => [name, parseInput(input)]), + ); +} + +function parseKeyValues(value: unknown): RegistryKeyValue[] | undefined { + if (value == null) { + return undefined; + } + if (!Array.isArray(value)) { + throw new Error("MCP Registry returned invalid key/value inputs"); + } + return value.map((entry) => { + const raw = object(entry, "key/value input"); + return { + ...parseInput(raw), + name: string(raw.name, "key/value input name"), + ...(parseVariables(raw.variables) === undefined + ? {} + : { variables: parseVariables(raw.variables) }), + }; + }); +} + +function parseArguments(value: unknown): RegistryArgument[] | undefined { + if (value == null) { + return undefined; + } + if (!Array.isArray(value)) { + throw new Error("MCP Registry returned invalid arguments"); + } + return value.map((entry) => { + const raw = object(entry, "argument"); + return { + ...parseInput(raw), + type: string(raw.type, "argument type"), + ...(optionalString(raw.name) === undefined + ? {} + : { name: optionalString(raw.name) }), + ...(optionalString(raw.valueHint) === undefined + ? {} + : { valueHint: optionalString(raw.valueHint) }), + ...(parseVariables(raw.variables) === undefined + ? {} + : { variables: parseVariables(raw.variables) }), + }; + }); +} + +function parseTransport(value: unknown): RegistryTransport { + const raw = object(value, "transport"); + return { + type: string(raw.type, "transport type"), + ...(optionalString(raw.url) === undefined + ? {} + : { url: optionalString(raw.url) }), + ...(parseKeyValues(raw.headers) === undefined + ? {} + : { headers: parseKeyValues(raw.headers) }), + ...(parseVariables(raw.variables) === undefined + ? {} + : { variables: parseVariables(raw.variables) }), + }; +} + +function parsePackage(value: unknown): RegistryPackage { + const raw = object(value, "package"); + return { + registryType: string(raw.registryType, "package registryType"), + identifier: string(raw.identifier, "package identifier"), + transport: parseTransport(raw.transport), + ...(optionalString(raw.version) === undefined + ? {} + : { version: optionalString(raw.version) }), + ...(optionalString(raw.registryBaseUrl) === undefined + ? {} + : { registryBaseUrl: optionalString(raw.registryBaseUrl) }), + ...(optionalString(raw.fileSha256) === undefined + ? {} + : { fileSha256: optionalString(raw.fileSha256) }), + ...(optionalString(raw.runtimeHint) === undefined + ? {} + : { runtimeHint: optionalString(raw.runtimeHint) }), + ...(parseArguments(raw.runtimeArguments) === undefined + ? {} + : { runtimeArguments: parseArguments(raw.runtimeArguments) }), + ...(parseArguments(raw.packageArguments) === undefined + ? {} + : { packageArguments: parseArguments(raw.packageArguments) }), + ...(parseKeyValues(raw.environmentVariables) === undefined + ? {} + : { + environmentVariables: parseKeyValues( + raw.environmentVariables, + ), + }), + }; +} + +export function parseRegistryEntry(value: unknown): RegistryServerEntry { + const raw = object(value, "server entry"); + const serverRaw = object(raw.server, "server"); + const responseMeta = object(raw._meta, "server metadata"); + const metaRaw = object( + responseMeta["io.modelcontextprotocol.registry/official"], + "official registry metadata", + ); + const status = string(metaRaw.status, "server status"); + if (!["active", "deprecated", "deleted"].includes(status)) { + throw new Error( + `MCP Registry returned unknown server status '${status}'`, + ); + } + const packages = serverRaw.packages; + const remotes = serverRaw.remotes; + return { + server: { + name: string(serverRaw.name, "server name"), + description: string(serverRaw.description, "server description"), + version: string(serverRaw.version, "server version"), + ...(optionalString(serverRaw.title) === undefined + ? {} + : { title: optionalString(serverRaw.title) }), + ...(serverRaw.repository === undefined + ? {} + : { repository: object(serverRaw.repository, "repository") }), + ...(serverRaw._meta === undefined + ? {} + : { + publisher: object(serverRaw._meta, "publisher metadata"), + }), + ...(packages == null + ? {} + : Array.isArray(packages) + ? { packages: packages.map(parsePackage) } + : (() => { + throw new Error( + "MCP Registry returned invalid packages", + ); + })()), + ...(remotes == null + ? {} + : Array.isArray(remotes) + ? { remotes: remotes.map(parseTransport) } + : (() => { + throw new Error( + "MCP Registry returned invalid remotes", + ); + })()), + }, + meta: { + status: status as RegistryStatus, + statusMessage: optionalString(metaRaw.statusMessage), + updatedAt: optionalString(metaRaw.updatedAt), + publishedAt: string(metaRaw.publishedAt, "publishedAt"), + isLatest: metaRaw.isLatest === true, + }, + }; +} + +export function createMcpRegistryClient( + baseUrl: string, + fetchFn: FetchFn = fetch, + defaultMaxPages = 20, +): McpRegistryClient { + const base = new URL(baseUrl); + async function request(url: URL): Promise { + const response = await fetchFn(url, { + headers: { accept: "application/json" }, + }); + if (response.status === 404) { + return undefined; + } + if (!response.ok) { + throw new Error( + `MCP Registry request failed (${response.status} ${response.statusText})`, + ); + } + return response.json(); + } + return { + async list(options = {}) { + const entries: RegistryServerEntry[] = []; + let cursor: string | undefined; + const maxPages = options.maxPages ?? defaultMaxPages; + for (let page = 0; page < maxPages; page++) { + const url = new URL("v0.1/servers", base); + if (options.search !== undefined) + url.searchParams.set("search", options.search); + if (options.version !== undefined) + url.searchParams.set("version", options.version); + if (options.updatedSince !== undefined) + url.searchParams.set("updated_since", options.updatedSince); + if (options.includeDeleted !== undefined) + url.searchParams.set( + "include_deleted", + String(options.includeDeleted), + ); + url.searchParams.set("limit", String(options.limit ?? 100)); + if (cursor !== undefined) + url.searchParams.set("cursor", cursor); + const body = object(await request(url), "list response"); + if (!Array.isArray(body.servers)) { + throw new Error( + "MCP Registry returned invalid servers list", + ); + } + entries.push(...body.servers.map(parseRegistryEntry)); + const metadata = object(body.metadata, "pagination metadata"); + cursor = optionalString(metadata.nextCursor); + if (cursor === undefined) return entries; + } + throw new Error( + `MCP Registry pagination exceeded the ${maxPages}-page limit`, + ); + }, + async get(name, version = "latest") { + const url = new URL( + `v0.1/servers/${encodeURIComponent(name)}/versions/${encodeURIComponent(version)}`, + base, + ); + const body = await request(url); + return body === undefined ? undefined : parseRegistryEntry(body); + }, + }; +} diff --git a/ts/packages/defaultAgentProvider/src/installSources/mcpRegistryDescriptor.ts b/ts/packages/defaultAgentProvider/src/installSources/mcpRegistryDescriptor.ts new file mode 100644 index 0000000000..90d5c84db0 --- /dev/null +++ b/ts/packages/defaultAgentProvider/src/installSources/mcpRegistryDescriptor.ts @@ -0,0 +1,225 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import crypto from "node:crypto"; +import type { + CredentialRef, + EnvValue, + NormalizedMcpServerConfig, +} from "../mcp/mcpServerConfig.js"; +import type { McpInstallCandidate } from "./config.js"; +import type { + RegistryInput, + RegistryKeyValue, + RegistryServerEntry, + RegistryTransport, +} from "./mcpRegistryClient.js"; +import { + materializeRegistryNpmPackage, + type RegistryMaterializerDeps, +} from "./mcpRegistryMaterializer.js"; + +function canonicalJson(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(canonicalJson).join(",")}]`; + } + if (typeof value === "object" && value !== null) { + return `{${Object.entries(value as Record) + .sort(([a], [b]) => a.localeCompare(b)) + .map( + ([key, child]) => + `${JSON.stringify(key)}:${canonicalJson(child)}`, + ) + .join(",")}}`; + } + return JSON.stringify(value); +} + +export function registryDescriptorDigest(entry: RegistryServerEntry): string { + return crypto + .createHash("sha256") + .update(canonicalJson(entry)) + .digest("hex"); +} + +function inputRef(input: RegistryInput, name: string): string | CredentialRef { + const value = input.value ?? input.default; + return value ?? { kind: "input", name }; +} + +function valueFromKeyValue(entry: RegistryKeyValue): EnvValue { + const value = inputRef(entry, entry.name); + if (typeof value !== "string" || entry.variables === undefined) { + return value; + } + return { + value, + variables: Object.fromEntries( + Object.entries(entry.variables).map(([name, input]) => [ + name, + inputRef(input, name), + ]), + ), + }; +} + +function httpTransport(transport: RegistryTransport) { + if (transport.type !== "streamable-http" && transport.type !== "sse") { + throw new Error( + `Unsupported registry remote transport '${transport.type}'`, + ); + } + if (transport.url === undefined) { + throw new Error( + `Registry ${transport.type} transport is missing its URL`, + ); + } + return { + kind: "http" as const, + url: transport.url, + ...(transport.variables === undefined + ? {} + : { + urlVariables: Object.fromEntries( + Object.entries(transport.variables).map( + ([name, input]) => [name, inputRef(input, name)], + ), + ), + }), + ...(transport.headers === undefined + ? {} + : { + headers: Object.fromEntries( + transport.headers.map((entry) => [ + entry.name, + valueFromKeyValue(entry), + ]), + ), + }), + timeoutMs: 30_000, + }; +} + +export function registryEntryToCandidate( + entry: RegistryServerEntry, + sourceName: string, + baseUrl: string, + materializerDeps: RegistryMaterializerDeps, +): McpInstallCandidate { + if (entry.meta.status === "deleted") { + throw new Error( + `Registry server '${entry.server.name}@${entry.server.version}' is deleted`, + ); + } + const digest = registryDescriptorDigest(entry); + const ref = `${entry.server.name}@${entry.server.version}`; + const description = + entry.meta.status === "deprecated" + ? `[DEPRECATED${entry.meta.statusMessage ? `: ${entry.meta.statusMessage}` : ""}] ${entry.server.description}` + : entry.server.description; + const commonProvenance = { + source: sourceName, + sourceKind: "registry", + ref, + version: entry.server.version, + digest, + registryBaseUrl: baseUrl, + canonicalServerName: entry.server.name, + serverVersion: entry.server.version, + ...(entry.server.publisher === undefined + ? {} + : { publisher: entry.server.publisher }), + ...(entry.server.repository === undefined + ? {} + : { repository: entry.server.repository }), + }; + const id = `mcp:${encodeURIComponent(sourceName)}:${encodeURIComponent(entry.server.name)}`; + const remote = entry.server.remotes?.find( + (candidate) => + candidate.type === "streamable-http" || candidate.type === "sse", + ); + if (remote !== undefined) { + return { + extensionKind: "mcp", + source: sourceName, + sourceKind: "registry", + ref, + config: { + id, + name: entry.server.title ?? entry.server.name, + description, + transport: httpTransport(remote), + enabled: false, + trust: "untrusted", + scope: "user", + provenance: { + ...commonProvenance, + transportType: remote.type, + }, + }, + }; + } + const pkg = entry.server.packages?.find( + (candidate) => + candidate.registryType === "npm" && + candidate.transport.type === "stdio", + ); + if (pkg === undefined) { + const advertised = [ + ...(entry.server.remotes ?? []).map( + (candidate) => `remote:${candidate.type}`, + ), + ...(entry.server.packages ?? []).map( + (candidate) => + `package:${candidate.registryType}/${candidate.transport.type}`, + ), + ]; + throw new Error( + `Registry server '${entry.server.name}@${entry.server.version}' has no supported remote or package definition${advertised.length === 0 ? "" : ` (${advertised.join(", ")})`}`, + ); + } + if (pkg.version === undefined) { + throw new Error( + `Registry npm package '${pkg.identifier}' does not specify an exact version`, + ); + } + const config: NormalizedMcpServerConfig = { + id, + name: entry.server.title ?? entry.server.name, + description, + transport: { + kind: "stdio", + command: process.execPath, + args: [], + }, + enabled: false, + trust: "untrusted", + scope: "user", + provenance: { + ...commonProvenance, + packageIdentifier: pkg.identifier, + packageVersion: pkg.version, + npmRegistryUrl: + pkg.registryBaseUrl ?? "https://registry.npmjs.org/", + ...(pkg.fileSha256 === undefined + ? {} + : { packageHash: pkg.fileSha256 }), + transportType: pkg.transport.type, + }, + }; + return { + extensionKind: "mcp", + source: sourceName, + sourceKind: "registry", + ref, + config, + materialize: (signal) => + materializeRegistryNpmPackage( + config, + pkg, + digest, + materializerDeps, + signal, + ), + }; +} diff --git a/ts/packages/defaultAgentProvider/src/installSources/mcpRegistryMaterializer.ts b/ts/packages/defaultAgentProvider/src/installSources/mcpRegistryMaterializer.ts new file mode 100644 index 0000000000..7ad8764024 --- /dev/null +++ b/ts/packages/defaultAgentProvider/src/installSources/mcpRegistryMaterializer.ts @@ -0,0 +1,482 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import type { + EnvValue, + NormalizedMcpServerConfig, +} from "../mcp/mcpServerConfig.js"; +import type { + RegistryArgument, + RegistryInput, + RegistryKeyValue, + RegistryPackage, +} from "./mcpRegistryClient.js"; + +const execFileAsync = promisify(execFile); +export const MCP_INSTALL_ROOTS_SUBDIR = "mcp"; + +export interface RegistryNpmInstallArgs { + spec: string; + cwd: string; + registry: string; + signal?: AbortSignal; +} + +export interface RegistryMaterializerDeps { + installDir: string; + fetchFn?: typeof fetch; + npmInstall?: (args: RegistryNpmInstallArgs) => Promise; + randomId?: () => string; + npxCliPath?: string; +} + +type ValidatedRegistryPackage = RegistryPackage & { + registryType: "npm"; + version: string; +}; + +function findNpxCli(): string { + const candidates = + process.platform === "win32" + ? [ + path.resolve( + path.dirname(process.execPath), + "node_modules", + "npm", + "bin", + "npx-cli.js", + ), + ] + : [ + path.resolve( + path.dirname(process.execPath), + "..", + "lib", + "node_modules", + "npm", + "bin", + "npx-cli.js", + ), + ]; + const found = candidates.find((candidate) => fs.existsSync(candidate)); + if (found === undefined) { + throw new Error( + "Could not locate the npm npx CLI required by registry runtimeHint 'npx'", + ); + } + return found; +} + +function inputValue( + input: RegistryInput, + name: string, +): string | { kind: "input"; name: string } { + const value = input.value ?? input.default; + return value === undefined ? { kind: "input", name } : value; +} + +function convertTemplate( + value: string, + variables: Record | undefined, +): EnvValue { + if (variables === undefined) { + return value; + } + return { + value, + variables: Object.fromEntries( + Object.entries(variables).map(([name, input]) => [ + name, + inputValue(input, name), + ]), + ), + }; +} + +export function registryArgumentsToArgv( + args: RegistryArgument[] | undefined, +): EnvValue[] { + const argv: EnvValue[] = []; + for (const arg of args ?? []) { + const inputName = arg.valueHint ?? arg.name ?? "argument"; + const raw = inputValue(arg, inputName); + const value = + typeof raw === "string" ? convertTemplate(raw, arg.variables) : raw; + if (arg.type === "named") { + if (arg.name === undefined) { + throw new Error("Registry named argument is missing its name"); + } + argv.push(arg.name); + if (!(arg.format === "boolean" && raw === "true")) { + argv.push(value); + } + } else if (arg.type === "positional") { + argv.push(value); + } else { + throw new Error(`Unsupported registry argument type '${arg.type}'`); + } + } + return argv; +} + +export function registryEnvironment( + entries: RegistryKeyValue[] | undefined, +): Record | undefined { + if (entries === undefined) return undefined; + return Object.fromEntries( + entries.map((entry) => { + const raw = inputValue(entry, entry.name); + return [ + entry.name, + typeof raw === "string" + ? convertTemplate(raw, entry.variables) + : raw, + ]; + }), + ); +} + +async function defaultNpmInstall(args: RegistryNpmInstallArgs): Promise { + const npmArgs = [ + "install", + args.spec, + "--save=false", + "--ignore-scripts", + "--registry", + args.registry, + ]; + if (process.platform === "win32") { + const npmCli = path.resolve( + path.dirname(process.execPath), + "node_modules", + "npm", + "bin", + "npm-cli.js", + ); + if (!fs.existsSync(npmCli)) { + throw new Error( + `Could not locate npm CLI at '${npmCli}' for registry package installation`, + ); + } + await execFileAsync(process.execPath, [npmCli, ...npmArgs], { + cwd: args.cwd, + signal: args.signal, + }); + return; + } + await execFileAsync("npm", npmArgs, { + cwd: args.cwd, + signal: args.signal, + }); +} + +function packageUrl(registry: string, identifier: string): URL { + const encoded = identifier.startsWith("@") + ? identifier.replaceAll("/", "%2F") + : encodeURIComponent(identifier); + return new URL(encoded, registry.endsWith("/") ? registry : `${registry}/`); +} + +function safeLeaf(value: string): string { + return value.replace(/[^A-Za-z0-9._-]/g, "_"); +} + +function validateOwnedRoot(installDir: string, candidate: string): string { + const root = path.resolve(installDir, MCP_INSTALL_ROOTS_SUBDIR); + const resolved = path.resolve(candidate); + if (resolved === root || !resolved.startsWith(`${root}${path.sep}`)) { + throw new Error(`Invalid owned MCP install path '${candidate}'`); + } + return resolved; +} + +function readPackageBin( + root: string, + identifier: string, + version: string, +): string { + const packageDir = path.resolve(root, "node_modules", identifier); + const installedPackageJson = JSON.parse( + fs.readFileSync(path.join(packageDir, "package.json"), "utf8"), + ) as { + name?: string; + version?: string; + bin?: string | Record; + }; + if ( + installedPackageJson.name !== undefined && + installedPackageJson.name !== identifier + ) { + throw new Error( + `Installed npm package name '${installedPackageJson.name}' does not match '${identifier}'`, + ); + } + if ( + installedPackageJson.version !== undefined && + installedPackageJson.version !== version + ) { + throw new Error( + `Installed npm package version '${installedPackageJson.version}' does not match '${version}'`, + ); + } + const bin = + typeof installedPackageJson.bin === "string" + ? installedPackageJson.bin + : Object.values(installedPackageJson.bin ?? {})[0]; + if (bin === undefined) { + throw new Error(`npm package '${identifier}' declares no executable`); + } + const binPath = path.resolve(packageDir, bin); + if (!binPath.startsWith(`${packageDir}${path.sep}`)) { + throw new Error(`npm package '${identifier}' has an unsafe bin path`); + } + return binPath; +} + +export function cleanupOwnedMcpPaths( + installDir: string, + ownedPaths: readonly string[] | undefined, +): void { + for (const ownedPath of ownedPaths ?? []) { + fs.rmSync(validateOwnedRoot(installDir, ownedPath), { + recursive: true, + force: true, + }); + } +} + +function validateRegistryNpmPackage( + pkg: RegistryPackage, +): ValidatedRegistryPackage { + if (pkg.registryType !== "npm") { + throw new Error( + `Unsupported registry package type '${pkg.registryType}'`, + ); + } + if (pkg.version === undefined) { + throw new Error( + `Registry npm package '${pkg.identifier}' has no exact version`, + ); + } + if ( + !/^(?:@[A-Za-z0-9][A-Za-z0-9._-]*\/)?[A-Za-z0-9][A-Za-z0-9._-]*$/.test( + pkg.identifier, + ) + ) { + throw new Error( + `Invalid registry npm package name '${pkg.identifier}'`, + ); + } + if (!/^[A-Za-z0-9][A-Za-z0-9._+-]*$/.test(pkg.version)) { + throw new Error( + `Invalid registry npm package version '${pkg.version}'`, + ); + } + return { + ...pkg, + registryType: "npm", + version: pkg.version, + }; +} + +function validateRuntimeHint(pkg: RegistryPackage): "npx" | "node" { + const runtimeHint = pkg.runtimeHint ?? "npx"; + if (runtimeHint !== "npx" && runtimeHint !== "node") { + throw new Error(`Unsupported registry runtime hint '${runtimeHint}'`); + } + return runtimeHint; +} + +async function downloadRegistryPackage( + pkg: ValidatedRegistryPackage, + registry: string, + fetchFn: typeof fetch, + signal?: AbortSignal, +): Promise<{ bytes: Buffer; packageHash: string }> { + const requestOptions = signal === undefined ? {} : { signal }; + const packumentResponse = await fetchFn( + packageUrl(registry, pkg.identifier), + { + headers: { accept: "application/json" }, + ...requestOptions, + }, + ); + if (!packumentResponse.ok) { + throw new Error( + `Could not resolve npm package '${pkg.identifier}@${pkg.version}' (${packumentResponse.status})`, + ); + } + const packument = (await packumentResponse.json()) as { + versions?: Record; + }; + const tarball = packument.versions?.[pkg.version]?.dist?.tarball; + if (typeof tarball !== "string") { + throw new Error( + `npm package '${pkg.identifier}' has no published version '${pkg.version}'`, + ); + } + const tarballResponse = await fetchFn(tarball, requestOptions); + if (!tarballResponse.ok) { + throw new Error( + `Could not download npm package '${pkg.identifier}@${pkg.version}' (${tarballResponse.status})`, + ); + } + const bytes = Buffer.from(await tarballResponse.arrayBuffer()); + const packageHash = crypto.createHash("sha256").update(bytes).digest("hex"); + if ( + pkg.fileSha256 !== undefined && + packageHash.toLowerCase() !== pkg.fileSha256.toLowerCase() + ) { + throw new Error( + `SHA-256 mismatch for '${pkg.identifier}@${pkg.version}': expected ${pkg.fileSha256}, got ${packageHash}`, + ); + } + return { bytes, packageHash }; +} + +async function installRegistryPackage( + pkg: ValidatedRegistryPackage, + bytes: Buffer, + registry: string, + finalRoot: string, + deps: RegistryMaterializerDeps, + signal?: AbortSignal, +): Promise { + const rootsDir = path.dirname(finalRoot); + fs.mkdirSync(rootsDir, { recursive: true }); + const randomId = + deps.randomId?.() ?? + `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`; + const tempRoot = validateOwnedRoot( + deps.installDir, + path.join(rootsDir, `.tmp-${safeLeaf(randomId)}`), + ); + fs.mkdirSync(tempRoot, { recursive: true }); + const tarPath = path.join(tempRoot, "package.tgz"); + try { + fs.writeFileSync( + path.join(tempRoot, "package.json"), + JSON.stringify({ private: true }), + ); + fs.writeFileSync(tarPath, bytes); + await (deps.npmInstall ?? defaultNpmInstall)({ + spec: tarPath, + cwd: tempRoot, + registry, + ...(signal === undefined ? {} : { signal }), + }); + if ( + !fs.existsSync(path.join(tempRoot, "node_modules", pkg.identifier)) + ) { + throw new Error( + `npm install did not materialize '${pkg.identifier}@${pkg.version}'`, + ); + } + readPackageBin(tempRoot, pkg.identifier, pkg.version); + fs.rmSync(tarPath, { force: true }); + if (fs.existsSync(finalRoot)) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } else { + fs.renameSync(tempRoot, finalRoot); + } + } catch (error) { + fs.rmSync(tempRoot, { recursive: true, force: true }); + throw error; + } +} + +export async function materializeRegistryNpmPackage( + config: NormalizedMcpServerConfig, + pkg: RegistryPackage, + descriptorDigest: string, + deps: RegistryMaterializerDeps, + signal?: AbortSignal, +): Promise { + const validatedPackage = validateRegistryNpmPackage(pkg); + const runtimeHint = validateRuntimeHint(validatedPackage); + const runtimeArguments = registryArgumentsToArgv( + validatedPackage.runtimeArguments, + ); + const packageArguments = registryArgumentsToArgv( + validatedPackage.packageArguments, + ); + const environment = registryEnvironment( + validatedPackage.environmentVariables, + ); + const registry = + validatedPackage.registryBaseUrl ?? "https://registry.npmjs.org/"; + const fetchFn = deps.fetchFn ?? fetch; + const { bytes, packageHash } = await downloadRegistryPackage( + validatedPackage, + registry, + fetchFn, + signal, + ); + const rootsDir = path.join(deps.installDir, MCP_INSTALL_ROOTS_SUBDIR); + const leaf = `${safeLeaf(validatedPackage.identifier)}@${safeLeaf(validatedPackage.version)}-${descriptorDigest.slice(0, 16)}`; + const finalRoot = validateOwnedRoot( + deps.installDir, + path.join(rootsDir, leaf), + ); + if ( + !fs.existsSync( + path.join(finalRoot, "node_modules", validatedPackage.identifier), + ) + ) { + await installRegistryPackage( + validatedPackage, + bytes, + registry, + finalRoot, + deps, + signal, + ); + } + const binPath = readPackageBin( + finalRoot, + validatedPackage.identifier, + validatedPackage.version, + ); + const packageDir = path.resolve( + finalRoot, + "node_modules", + validatedPackage.identifier, + ); + const args = + runtimeHint === "npx" + ? [ + deps.npxCliPath ?? findNpxCli(), + ...runtimeArguments, + "--offline", + "--no-install", + validatedPackage.identifier, + ...packageArguments, + ] + : [...runtimeArguments, binPath, ...packageArguments]; + return { + ...config, + transport: { + kind: "stdio", + command: process.execPath, + args, + ...(environment === undefined + ? {} + : { + env: environment, + }), + cwd: packageDir, + }, + provenance: { + ...config.provenance, + ownedPaths: [finalRoot], + packageIdentifier: validatedPackage.identifier, + packageVersion: validatedPackage.version, + packageHash, + }, + }; +} diff --git a/ts/packages/defaultAgentProvider/src/installSources/mcpRegistrySource.ts b/ts/packages/defaultAgentProvider/src/installSources/mcpRegistrySource.ts new file mode 100644 index 0000000000..7af5607c42 --- /dev/null +++ b/ts/packages/defaultAgentProvider/src/installSources/mcpRegistrySource.ts @@ -0,0 +1,192 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import path from "node:path"; +import type { + AvailableInstallRow, + InstallSource, + MaterializedInstallRecord, + RegistrySourceConfig, + ResolvedCandidate, + SourceWarning, +} from "./config.js"; +import { + createMcpRegistryClient, + type McpRegistryClient, + type RegistryServerEntry, +} from "./mcpRegistryClient.js"; +import { + createRegistryCacheStorage, + mergeRegistryCache, + type RegistryCacheData, + type RegistryCacheStorage, +} from "./mcpRegistryCache.js"; +import { registryEntryToCandidate } from "./mcpRegistryDescriptor.js"; +import type { RegistryMaterializerDeps } from "./mcpRegistryMaterializer.js"; + +export interface RegistrySourceDeps extends RegistryMaterializerDeps { + client?: McpRegistryClient; + cacheStorage?: RegistryCacheStorage; + now?: () => number; +} + +function parseRef(ref: string): { name: string; version: string } { + const at = ref.lastIndexOf("@"); + return at > 0 + ? { name: ref.slice(0, at), version: ref.slice(at + 1) } + : { name: ref, version: "latest" }; +} + +export function createMcpRegistrySource( + config: RegistrySourceConfig, + deps: RegistrySourceDeps, +): InstallSource { + const now = deps.now ?? Date.now; + const ttl = config.cacheTtlMs ?? 60 * 60 * 1000; + const parsedBaseUrl = new URL(config.baseUrl); + if (!parsedBaseUrl.pathname.endsWith("/")) { + parsedBaseUrl.pathname += "/"; + } + const baseUrl = parsedBaseUrl.toString(); + const client = + deps.client ?? + createMcpRegistryClient(baseUrl, deps.fetchFn, config.maxPages); + const storage = + deps.cacheStorage ?? + createRegistryCacheStorage( + config.cachePath ?? + path.join( + deps.installDir, + "mcp", + `.registry-cache-${config.name.replace(/[^A-Za-z0-9._-]/g, "_")}.json`, + ), + ); + let memory: RegistryCacheData | undefined; + + function readCache(): RegistryCacheData | undefined { + if (memory === undefined) { + memory = storage.read(); + } + return memory; + } + + async function refreshCache(): Promise { + const previous = readCache(); + const fetchedAt = now(); + const updatedSince = new Date(fetchedAt).toISOString(); + const pageOptions = + previous === undefined + ? { + version: "latest", + ...(config.maxPages === undefined + ? {} + : { maxPages: config.maxPages }), + } + : { + version: "latest", + updatedSince: previous.updatedSince, + includeDeleted: true, + ...(config.maxPages === undefined + ? {} + : { maxPages: config.maxPages }), + }; + const updates = await client.list(pageOptions); + const next: RegistryCacheData = { + fetchedAt, + updatedSince, + entries: + previous === undefined + ? updates.filter((entry) => entry.meta.status !== "deleted") + : mergeRegistryCache(previous.entries, updates), + }; + storage.write(next); + memory = next; + return next; + } + + async function cache(): Promise { + const current = readCache(); + if (current !== undefined && now() - current.fetchedAt <= ttl) { + return current; + } + try { + return await refreshCache(); + } catch (error) { + if (current !== undefined) return current; + throw error; + } + } + + function warnDeprecated( + entry: RegistryServerEntry, + onWarn?: SourceWarning, + ): void { + if (entry.meta.status === "deprecated") { + onWarn?.( + `Registry server '${entry.server.name}@${entry.server.version}' is deprecated${entry.meta.statusMessage ? `: ${entry.meta.statusMessage}` : "."}`, + ); + } + } + + return { + name: config.name, + kind: "registry", + describe: () => baseUrl, + async find(): Promise { + return undefined; + }, + async findMcp(ref, onWarn) { + const { name, version } = parseRef(ref); + const cached = await cache(); + let entry = cached.entries.find( + (candidate) => + candidate.server.name === name && + (version === "latest" + ? candidate.meta.isLatest + : candidate.server.version === version), + ); + if (entry === undefined) { + entry = await client.get(name, version); + } + if (entry === undefined || entry.meta.status === "deleted") { + return undefined; + } + warnDeprecated(entry, onWarn); + return registryEntryToCandidate(entry, config.name, baseUrl, deps); + }, + async listAgents(onWarn): Promise { + const rows: AvailableInstallRow[] = []; + for (const entry of (await cache()).entries) { + if (entry.meta.status === "deleted") continue; + try { + registryEntryToCandidate(entry, config.name, baseUrl, deps); + } catch (error) { + onWarn?.( + `registry source '${config.name}': '${entry.server.name}@${entry.server.version}' unavailable - ${(error as Error).message}`, + ); + continue; + } + warnDeprecated(entry, onWarn); + rows.push({ + source: config.name, + ref: entry.server.name, + defaultAgentName: entry.server.name, + description: + entry.meta.status === "deprecated" + ? `[DEPRECATED] ${entry.server.description}` + : entry.server.description, + extensionKind: "mcp", + }); + } + return rows; + }, + async refresh() { + await refreshCache(); + }, + async materialize(): Promise { + throw new Error( + `registry source '${config.name}' materializes MCP candidates through the MCP transaction`, + ); + }, + }; +} diff --git a/ts/packages/defaultAgentProvider/src/installSources/packageAgent.ts b/ts/packages/defaultAgentProvider/src/installSources/packageAgent.ts index 11fd83432b..5377399281 100644 --- a/ts/packages/defaultAgentProvider/src/installSources/packageAgent.ts +++ b/ts/packages/defaultAgentProvider/src/installSources/packageAgent.ts @@ -25,12 +25,19 @@ import { AppAgentProvider, AppAgentProviderSetController, } from "agent-dispatcher"; +import type { McpServerSourceApi } from "../mcp/mcpAppAgentSource.js"; +import type { + EnvValue, + NormalizedMcpServerConfig, +} from "../mcp/mcpServerConfig.js"; import chalk from "chalk"; +import { enforceMcpPolicy } from "../mcp/mcpPolicy.js"; import { ExtensionKind, InstallMatchKind, InstallPreview, InstallResult, + McpInstallCandidate, deriveMatchKind, SourceStatus, UninstallOutcomeStatus, @@ -108,6 +115,18 @@ export interface InstalledAgentSourceApi { sourceName: string | undefined, onStatus?: SourceStatus, ): Promise; + // Resolve normalized MCP artifacts without native materialization. The + // full match set lets the command reject source ambiguity. + resolveMcp( + ref: string, + sourceName?: string, + onStatus?: SourceStatus, + ): Promise; + materializeMcp?( + candidate: McpInstallCandidate, + abortSignal?: AbortSignal, + ): Promise; + cleanupMcp?(config: NormalizedMcpServerConfig): void; // Refresh cache-backed source metadata (feed descriptor caches) before an // install/preview/listing. When `sourceName` is given, only that source is // refreshed. A fetch failure throws so the `--refresh` command fails rather @@ -169,10 +188,147 @@ export interface InstalledAgentSourceApi { export interface PackageAgentContext { readonly appAgentProviderSetController: AppAgentProviderSetController; readonly source: InstalledAgentSourceApi; + readonly mcpSource?: McpServerSourceApi; } type PackageActionContext = ActionContext; type PackageSessionContext = SessionContext; +type PackageType = ExtensionKind | "all"; + +function parsePackageType( + value: string | undefined, + defaultValue: PackageType, +): PackageType { + const type = value ?? defaultValue; + if (type !== "agent" && type !== "mcp" && type !== "all") { + throw new Error( + `Invalid --type '${type}'. Expected 'agent', 'mcp', or 'all'.`, + ); + } + return type; +} + +function requireMcpSource(context: PackageSessionContext): McpServerSourceApi { + const source = context.agentContext.mcpSource; + if (source === undefined) { + throw new Error("MCP server management is not available on this host."); + } + return source; +} + +function mcpServerNames(context: PackageSessionContext): string[] { + return ( + context.agentContext.mcpSource + ?.listServers() + .map((config) => config.name) + .sort((a, b) => a.localeCompare(b)) ?? [] + ); +} + +function findMcpServer( + mcpSource: McpServerSourceApi, + nameOrId: string, + sourceName?: string, +): NormalizedMcpServerConfig | undefined { + return mcpSource + .listServers() + .find( + (config) => + (config.id === nameOrId || config.name === nameOrId) && + (sourceName === undefined || + config.provenance.source === sourceName), + ); +} + +function materializeMcp( + source: InstalledAgentSourceApi, + candidate: McpInstallCandidate, + signal?: AbortSignal, +): Promise { + return ( + source.materializeMcp?.(candidate, signal) ?? + candidate.materialize?.(signal) ?? + Promise.resolve(candidate.config) + ); +} + +function cleanupMcp( + source: InstalledAgentSourceApi, + config: NormalizedMcpServerConfig, +): void { + source.cleanupMcp?.(config); +} + +function describeEnvValue(value: EnvValue): string { + return typeof value === "string" + ? "" + : "kind" in value + ? `` + : "