From 99824a81fe3f08c9b9deb71c1fdf4815434c37b9 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Wed, 24 Jun 2026 20:30:38 +0530 Subject: [PATCH] fix(sdk): harden tool manifest revisions Sync stale connection catalogs before reading tool manifests so indexing observes the latest projection. Let plugins provide explicit source revisions for produced tool catalogs, with OpenAPI using spec hashes and MCP using discovered manifest hashes. --- packages/core/sdk/src/executor.test.ts | 123 ++++++++++++++++++++ packages/core/sdk/src/executor.ts | 5 +- packages/core/sdk/src/plugin.ts | 5 + packages/plugins/mcp/src/sdk/plugin.ts | 10 +- packages/plugins/openapi/src/sdk/backing.ts | 7 +- 5 files changed, 147 insertions(+), 3 deletions(-) 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, }; });