Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions packages/core/sdk/src/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
5 changes: 4 additions & 1 deletion packages/core/sdk/src/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2724,7 +2724,9 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
created_at: now,
}));
const definitionMap = new Map(Object.entries(result.definitions ?? {}));
const sourceRevision = yield* connectionToolSourceRevision(integrationRow, existingRow);
const sourceRevision =
result.sourceRevision ??
(yield* connectionToolSourceRevision(integrationRow, existingRow));
const manifestRows = yield* Effect.forEach(
result.tools,
(tool: ToolDef) =>
Expand Down Expand Up @@ -3487,6 +3489,7 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
filter?: ToolListFilter,
): Effect.Effect<readonly ToolSchemaManifest[], StorageFailure> =>
Effect.gen(function* () {
yield* syncStaleConnectionTools;
const rows = yield* core.findMany("tool_schema_manifest", {
where: (b: AnyCb) =>
b.and(
Expand Down
5 changes: 5 additions & 0 deletions packages/core/sdk/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,11 @@ export interface ResolveToolsResult {
readonly tools: readonly ToolDef[];
/** Shared JSON-schema `$defs` reachable from the tools' `$ref`s. */
readonly definitions?: Record<string, unknown>;
/** 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;
}

// ---------------------------------------------------------------------------
Expand Down
10 changes: 9 additions & 1 deletion packages/plugins/mcp/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
IntegrationSlug,
mergeAuthTemplates,
OAuthClientSlug,
sha256Hex,
tool,
ToolResult,
type AuthMethodDescriptor,
Expand Down Expand Up @@ -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) },
Expand Down
7 changes: 6 additions & 1 deletion packages/plugins/openapi/src/sdk/backing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
}
}
Expand All @@ -540,6 +544,7 @@ export const resolveOpenApiBackedTools = ({
return {
tools: openApiToolDefsFromCompiled(compiled),
definitions: compiled.hoistedDefs,
sourceRevision: openApiConfig.specHash,
};
});

Expand Down
Loading