diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index 4e09e1162..0c1dd6a59 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -590,6 +590,129 @@ describe("createExecutor", () => { }), ); + it.effect("tools.manifest syncs stale connection catalogs before reading", () => + Effect.gen(function* () { + const staleIntegration = IntegrationSlug.make("stale-manifest"); + const staleConnection = ConnectionName.make("main"); + const staleAddress = (tool: string): ToolAddress => + ToolAddress.make(`tools.${staleIntegration}.org.${staleConnection}.${tool}`); + const versionedPlugin = definePlugin(() => ({ + id: "staleManifest" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + resolveTools: ({ config }) => { + const version = + config && typeof config === "object" && "version" in config + ? String((config as { readonly version?: unknown }).version) + : "unknown"; + return Effect.succeed({ + tools: [ + { + name: ToolName.make("inspect"), + description: `inspect ${version}`, + }, + ], + definitions: {}, + sourceRevision: version, + }); + }, + invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: staleIntegration, + description: "Stale Manifest", + config: { version: "v1" }, + }), + revise: (version: string) => + ctx.core.integrations.update(staleIntegration, { + config: { version }, + }), + }), + }))(); + + const executor = yield* makeTestExecutor({ + plugins: [versionedPlugin] as const, + }); + yield* executor.staleManifest.seed(); + yield* executor.connections.create({ + owner: "org", + name: staleConnection, + integration: staleIntegration, + template: TEMPLATE, + from: { provider: ProviderKey.make("memory"), id: ProviderItemId.make("v") }, + }); + + const before = yield* executor.tools.manifest({ + integration: staleIntegration, + includeBlocked: true, + }); + expect(before).toHaveLength(1); + expect(before[0]).toMatchObject({ + address: staleAddress("inspect"), + description: "inspect v1", + sourceRevision: "v1", + }); + + yield* executor.staleManifest.revise("v2"); + + const after = yield* executor.tools.manifest({ + integration: staleIntegration, + includeBlocked: true, + }); + expect(after).toHaveLength(1); + expect(after[0]).toMatchObject({ + address: staleAddress("inspect"), + description: "inspect v2", + sourceRevision: "v2", + }); + }), + ); + + it.effect("tools.manifest persists plugin-provided source revisions", () => + Effect.gen(function* () { + const sourceRevision = "spec-hash-v1"; + const sourcePlugin = definePlugin(() => ({ + id: "sourceRevision" as const, + credentialProviders: [memoryProvider()], + storage: () => ({}), + resolveTools: () => + Effect.succeed({ + tools: [{ name: ToolName.make("inspect"), description: "inspect" }], + definitions: {}, + sourceRevision, + }), + invokeTool: ({ toolRow }) => Effect.succeed({ ran: toolRow.name }), + extension: (ctx) => ({ + seed: () => + ctx.core.integrations.register({ + slug: INTEG, + description: "Source", + config: { fallback: "would-be-hashed" }, + }), + }), + }))(); + + const executor = yield* makeTestExecutor({ + plugins: [sourcePlugin] as const, + }); + yield* executor.sourceRevision.seed(); + yield* executor.connections.create({ + owner: "org", + name: CONN, + integration: INTEG, + template: TEMPLATE, + from: { provider: ProviderKey.make("memory"), id: ProviderItemId.make("v") }, + }); + + const manifest = (yield* executor.tools.manifest({ + integration: INTEG, + includeBlocked: true, + })).find((entry) => entry.name === "inspect"); + expect(manifest?.sourceRevision).toBe(sourceRevision); + }), + ); + it.effect("execute dispatches a connection-produced tool to the owning plugin", () => Effect.gen(function* () { const executor = yield* makeTestExecutor({ diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 3ab3757dc..923d0bea8 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -2724,7 +2724,9 @@ export const createExecutor = @@ -3487,6 +3489,7 @@ export const createExecutor = => Effect.gen(function* () { + yield* syncStaleConnectionTools; const rows = yield* core.findMany("tool_schema_manifest", { where: (b: AnyCb) => b.and( diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index ea268209a..d375bf3e5 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -232,6 +232,11 @@ export interface ResolveToolsResult { readonly tools: readonly ToolDef[]; /** Shared JSON-schema `$defs` reachable from the tools' `$ref`s. */ readonly definitions?: Record; + /** Optional plugin-owned source revision for the produced catalog. Spec-backed + * plugins should provide their content hash; discovery-backed plugins can + * provide a hash of the discovered manifest. The executor falls back to a + * generic integration/connection revision when absent. */ + readonly sourceRevision?: string; } // --------------------------------------------------------------------------- diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 53d1bc584..bbaa59bf7 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -12,6 +12,7 @@ import { IntegrationSlug, mergeAuthTemplates, OAuthClientSlug, + sha256Hex, tool, ToolResult, type AuthMethodDescriptor, @@ -957,7 +958,14 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { : { ok: false as const, manifest: null }; const entries = manifest.ok && manifest.manifest ? manifest.manifest.tools : []; - return { tools: entries.map(toToolDef) }; + const sourceRevision = + manifest.ok && manifest.manifest + ? yield* sha256Hex(JSON.stringify(manifest.manifest)) + : undefined; + return { + tools: entries.map(toToolDef), + ...(sourceRevision === undefined ? {} : { sourceRevision }), + }; }).pipe( Effect.withSpan("mcp.plugin.resolve_tools", { attributes: { "mcp.connection.name": String(connection.name) }, diff --git a/packages/plugins/openapi/src/sdk/backing.ts b/packages/plugins/openapi/src/sdk/backing.ts index 0018671e9..e7b2f8cc5 100644 --- a/packages/plugins/openapi/src/sdk/backing.ts +++ b/packages/plugins/openapi/src/sdk/backing.ts @@ -527,7 +527,11 @@ export const resolveOpenApiBackedTools = ({ const definitions = Option.getOrNull(decodeDefsJson(defsJson)); if (definitions != null) { const ops = yield* storage.listOperations(String(integration.slug)); - return { tools: ops.map(toolDefFromStoredOperation), definitions }; + return { + tools: ops.map(toolDefFromStoredOperation), + definitions, + sourceRevision: openApiConfig.specHash, + }; } } } @@ -540,6 +544,7 @@ export const resolveOpenApiBackedTools = ({ return { tools: openApiToolDefsFromCompiled(compiled), definitions: compiled.hoistedDefs, + sourceRevision: openApiConfig.specHash, }; });