History

Reduce source artifact bloat for imported tools

Rhys Sullivan committed 7 months ago

Commit fff219b1 parent 203125313 files · +618 −273Browse files

Unified · Split

13 files
  1. packages/platform/sdk/src/runtime/sources/openapi-type-contract.test.ts +123 −0
  2. packages/platform/sdk/src/runtime/sources/source-inspection.test.ts +244 −216
  3. packages/platform/sdk/src/sources/inspection.ts +57 −20
  4. plugins/mcp/sdk/catalog.test.ts +35 −0
  5. plugins/mcp/sdk/catalog.ts +2 −1
  6. plugins/mcp/sdk/executable-binding.test.ts +44 −0
  7. plugins/mcp/sdk/executable-binding.ts +16 −0
  8. plugins/mcp/sdk/index.ts +5 −25
  9. plugins/openapi/sdk/catalog.ts +15 −1
  10. plugins/openapi/sdk/definitions.ts +12 −0
  11. plugins/openapi/sdk/extraction.ts +42 −1
  12. plugins/openapi/sdk/index.ts +12 −9
  13. plugins/openapi/sdk/types.ts +11 −0
packages/platform/sdk/src/runtime/sources/openapi-type-contract.test.ts+123−0
@@ -95,6 +95,76 @@ const noContentSuccessOpenApiFixture = JSON.stringify({ · 94 unmodified lines
95 },95 },
96});96});
97 97
98const sharedRefOpenApiFixture = JSON.stringify({
99 openapi: "3.1.0",
100 info: {
101 title: "Shared Ref API",
102 version: "1.0.0",
103 },
104 servers: [
105 {
106 url: "https://api.example.test",
107 },
108 ],
109 paths: {
110 "/widgets/{widgetId}": {
111 get: {
112 operationId: "getWidget",
113 parameters: [
114 {
115 name: "widgetId",
116 in: "path",
117 required: true,
118 schema: {
119 type: "string",
120 },
121 },
122 ],
123 responses: {
124 "200": {
125 description: "Widget response",
126 content: {
127 "application/json": {
128 schema: {
129 $ref: "#/components/schemas/Widget",
130 },
131 },
132 },
133 },
134 },
135 },
136 },
137 },
138 components: {
139 schemas: {
140 Widget: {
141 type: "object",
142 required: ["id", "name", "owner"],
143 properties: {
144 id: {
145 type: "string",
146 },
147 name: {
148 type: "string",
149 },
150 owner: {
151 $ref: "#/components/schemas/Owner",
152 },
153 },
154 },
155 Owner: {
156 type: "object",
157 required: ["email"],
158 properties: {
159 email: {
160 type: "string",
161 },
162 },
163 },
164 },
165 },
166});
167
98const makeSource = (input: {168const makeSource = (input: {
99 id?: string;169 id?: string;
100 name?: string;170 name?: string;
@@ -290,4 +360,57 @@ describe("openapi type contracts", () => { · 189 unmodified lines
290 expect(contract.output.typeDeclaration).not.toContain("data: unknown");360 expect(contract.output.typeDeclaration).not.toContain("data: unknown");
291 expect(contract.output.typePreview).toContain("data: null");361 expect(contract.output.typePreview).toContain("data: null");
292 }).pipe(Effect.provide(NodeFileSystem.layer)));362 }).pipe(Effect.provide(NodeFileSystem.layer)));
363
364 it.effect("preserves local component refs in IR and keeps executable bindings runtime-minimal", () =>
365 Effect.gen(function* () {
366 const source = makeSource({
367 id: "source_shared_ref_api",
368 name: "Shared Ref API",
369 namespace: "shared-ref-api",
370 });
371 const manifest = yield* extractOpenApiManifest(
372 source.name,
373 sharedRefOpenApiFixture,
374 );
375 const operations = compileOpenApiToolDefinitions(manifest).map(
376 openApiCatalogOperationFromDefinition,
377 );
378 const fragment = createOpenApiCatalogFragment({
379 source,
380 documents: [
381 {
382 documentKind: "openapi",
383 documentKey: "https://example.com/shared-ref-openapi.json",
384 fetchedAt: 0,
385 contentText: sharedRefOpenApiFixture,
386 },
387 ],
388 operations,
389 });
390 const snapshot = snapshotFromSourceCatalogSyncResult(
391 createSourceCatalogSyncResult({
392 fragment,
393 importMetadata: createCatalogImportMetadata({
394 source,
395 pluginKey: "openapi",
396 }),
397 sourceHash: manifest.sourceHash,
398 }),
399 );
400
401 const refShapes = Object.values(snapshot.catalog.symbols).filter(
402 (symbol) => symbol.kind === "shape" && symbol.node.type === "ref",
403 );
404 expect(refShapes.length).toBeGreaterThan(0);
405
406 const executable = Object.values(snapshot.catalog.executables)[0];
407 expect(executable).toBeDefined();
408 expect(executable?.binding).toMatchObject({
409 kind: "openapi",
410 toolId: "widgets.getWidget",
411 });
412 expect((executable?.binding as Record<string, unknown>).responses).toBeUndefined();
413 expect((executable?.binding as Record<string, unknown>).documentation).toBeUndefined();
414 expect(JSON.stringify(executable?.binding).length).toBeLessThan(2_000);
415 }));
293});416});
packages/platform/sdk/src/runtime/sources/source-inspection.test.ts+244−216
@@ -12,238 +12,242 @@ import { · 11 unmodified lines
12 inspectionToolDetailFromTool,12 inspectionToolDetailFromTool,
13} from "../../sources/inspection";13} from "../../sources/inspection";
14 14
15describe("source inspection", () => {15const baseLoadedSourceCatalogTool = {
16 it.effect("keeps the shared TypeScript contract first-class on tool detail", () =>16 path: "linear.administrableTeams",
17 Effect.gen(function* () {17 source: {
18 const detail = yield* inspectionToolDetailFromTool({18 id: "linear",
19 path: "linear.administrableTeams",19 },
20 source: {20 capability: {
21 id: "linear",21 surface: {
22 },22 title: "Administrable Teams",
23 capability: {23 summary: "All teams the user can administrate.",
24 surface: {24 description: "All teams the user can administrate.",
25 title: "Administrable Teams",25 tags: ["query"],
26 summary: "All teams the user can administrate.",26 },
27 description: "All teams the user can administrate.",27 native: [],
28 tags: ["query"],28 },
29 },29 executable: {
30 native: [],30 id: "exec_graphql_administrableTeams",
31 },31 pluginKey: "graphql",
32 executable: {32 bindingVersion: 1,
33 id: "exec_graphql_administrableTeams",33 binding: {
34 pluginKey: "graphql",34 operationKind: "query",
35 bindingVersion: 1,35 rootTypeName: "Query",
36 binding: {36 fieldName: "administrableTeams",
37 operationKind: "query",37 },
38 rootTypeName: "Query",38 projection: {
39 fieldName: "administrableTeams",39 callShapeId: "shape_call",
40 },40 resultDataShapeId: "shape_result_data",
41 projection: {41 resultErrorShapeId: "shape_result_error",
42 callShapeId: "shape_call",42 responseSetId: "response_set_1",
43 resultDataShapeId: "shape_result_data",43 },
44 resultErrorShapeId: "shape_result_error",44 display: {
45 responseSetId: "response_set_1",45 protocol: "graphql",
46 },46 method: "query",
47 display: {47 pathTemplate: "administrableTeams",
48 protocol: "graphql",48 operationId: "administrableTeams",
49 method: "query",49 group: "query",
50 pathTemplate: "administrableTeams",50 leaf: "administrableTeams",
51 operationId: "administrableTeams",51 rawToolId: "administrableTeams",
52 group: "query",52 },
53 leaf: "administrableTeams",53 native: [],
54 rawToolId: "administrableTeams",54 },
55 },55 projectedDescriptor: {
56 native: [],56 toolPath: ["linear", "administrableTeams"],
57 callShapeId: "shape_call",
58 resultShapeId: "shape_result",
59 responseSetId: "response_set_1",
60 },
61 descriptor: {
62 inputTypePreview: "{ input: string }",
63 outputTypePreview: "{ data: string | null; error: unknown | null; headers: Record<string, string>; status: number | null }",
64 inputSchema: {
65 type: "object",
66 properties: {
67 input: { type: "string" },
57 },68 },
58 projectedDescriptor: {69 required: ["input"],
59 toolPath: ["linear", "administrableTeams"],70 },
60 callShapeId: "shape_call",71 outputSchema: {
61 resultShapeId: "shape_result",72 type: "object",
62 responseSetId: "response_set_1",73 properties: {
74 data: { type: ["string", "null"] },
63 },75 },
64 descriptor: {76 required: ["data"],
65 inputTypePreview: "{ input: string }",77 },
66 outputTypePreview: "{ data: string | null; error: unknown | null; headers: Record<string, string>; status: number | null }",78 },
67 inputSchema: {79 projectedCatalog: {
80 version: "ir.v1",
81 documents: {},
82 resources: {},
83 scopes: {},
84 capabilities: {},
85 executables: {},
86 responseSets: {},
87 diagnostics: {},
88 symbols: {
89 shape_call: {
90 id: "shape_call",
91 kind: "shape",
92 resourceId: "res_1",
93 title: "AdministrableTeamsCall",
94 node: {
68 type: "object",95 type: "object",
69 properties: {96 fields: {
70 input: { type: "string" },97 input: {
98 shapeId: "shape_call_input",
99 docs: {
100 description: "The exact input string to filter by.",
101 },
102 },
71 },103 },
72 required: ["input"],104 required: ["input"],
105 additionalProperties: false,
73 },106 },
74 outputSchema: {107 synthetic: false,
75 type: "object",108 provenance: [],
76 properties: {109 },
77 data: { type: ["string", "null"] },110 shape_call_input: {
78 },111 id: "shape_call_input",
79 required: ["data"],112 kind: "shape",
113 resourceId: "res_1",
114 title: "Input",
115 node: {
116 type: "scalar",
117 scalar: "string",
80 },118 },
119 synthetic: false,
120 provenance: [],
81 },121 },
82 projectedCatalog: {122 shape_result: {
83 version: "ir.v1",123 id: "shape_result",
84 documents: {},124 kind: "shape",
85 resources: {},125 resourceId: "res_1",
86 scopes: {},126 title: "AdministrableTeamsResult",
87 capabilities: {},127 node: {
88 executables: {},128 type: "object",
89 responseSets: {},129 fields: {
90 diagnostics: {},130 data: {
91 symbols: {131 shapeId: "shape_result_data_nullable",
92 shape_call: {132 docs: {
93 id: "shape_call",133 description: "Matching team data when present.",
94 kind: "shape",
95 resourceId: "res_1",
96 title: "AdministrableTeamsCall",
97 node: {
98 type: "object",
99 fields: {
100 input: {
101 shapeId: "shape_call_input",
102 docs: {
103 description: "The exact input string to filter by.",
104 },
105 },
106 },
107 required: ["input"],
108 additionalProperties: false,
109 },
110 synthetic: false,
111 provenance: [],
112 },
113 shape_call_input: {
114 id: "shape_call_input",
115 kind: "shape",
116 resourceId: "res_1",
117 title: "Input",
118 node: {
119 type: "scalar",
120 scalar: "string",
121 },
122 synthetic: false,
123 provenance: [],
124 },
125 shape_result: {
126 id: "shape_result",
127 kind: "shape",
128 resourceId: "res_1",
129 title: "AdministrableTeamsResult",
130 node: {
131 type: "object",
132 fields: {
133 data: {
134 shapeId: "shape_result_data_nullable",
135 docs: {
136 description: "Matching team data when present.",
137 },
138 },
139 error: {
140 shapeId: "shape_result_error_nullable",
141 },
142 headers: {
143 shapeId: "shape_result_headers",
144 },
145 status: {
146 shapeId: "shape_result_status_nullable",
147 },
148 },134 },
149 required: ["data", "error", "headers", "status"],
150 additionalProperties: false,
151 },
152 synthetic: true,
153 provenance: [],
154 },
155 shape_result_data_nullable: {
156 id: "shape_result_data_nullable",
157 kind: "shape",
158 resourceId: "res_1",
159 node: {
160 type: "nullable",
161 itemShapeId: "shape_result_data",
162 },
163 synthetic: true,
164 provenance: [],
165 },
166 shape_result_data: {
167 id: "shape_result_data",
168 kind: "shape",
169 resourceId: "res_1",
170 node: {
171 type: "scalar",
172 scalar: "string",
173 },135 },
174 synthetic: false,136 error: {
175 provenance: [],137 shapeId: "shape_result_error_nullable",
176 },
177 shape_result_error_nullable: {
178 id: "shape_result_error_nullable",
179 kind: "shape",
180 resourceId: "res_1",
181 node: {
182 type: "nullable",
183 itemShapeId: "shape_result_error",
184 },
185 synthetic: true,
186 provenance: [],
187 },
188 shape_result_error: {
189 id: "shape_result_error",
190 kind: "shape",
191 resourceId: "res_1",
192 node: {
193 type: "unknown",
194 },
195 synthetic: false,
196 provenance: [],
197 },
198 shape_result_headers: {
199 id: "shape_result_headers",
200 kind: "shape",
201 resourceId: "res_1",
202 node: {
203 type: "object",
204 fields: {},
205 additionalProperties: "shape_result_header_value",
206 },138 },
207 synthetic: true,139 headers: {
208 provenance: [],140 shapeId: "shape_result_headers",
209 },
210 shape_result_header_value: {
211 id: "shape_result_header_value",
212 kind: "shape",
213 resourceId: "res_1",
214 node: {
215 type: "scalar",
216 scalar: "string",
217 },141 },
218 synthetic: true,142 status: {
219 provenance: [],143 shapeId: "shape_result_status_nullable",
220 },
221 shape_result_status_nullable: {
222 id: "shape_result_status_nullable",
223 kind: "shape",
224 resourceId: "res_1",
225 node: {
226 type: "nullable",
227 itemShapeId: "shape_result_status",
228 },144 },
229 synthetic: true,
230 provenance: [],
231 },
232 shape_result_status: {
233 id: "shape_result_status",
234 kind: "shape",
235 resourceId: "res_1",
236 node: {
237 type: "scalar",
238 scalar: "integer",
239 },
240 synthetic: true,
241 provenance: [],
242 },145 },
146 required: ["data", "error", "headers", "status"],
147 additionalProperties: false,
148 },
149 synthetic: true,
150 provenance: [],
151 },
152 shape_result_data_nullable: {
153 id: "shape_result_data_nullable",
154 kind: "shape",
155 resourceId: "res_1",
156 node: {
157 type: "nullable",
158 itemShapeId: "shape_result_data",
159 },
160 synthetic: true,
161 provenance: [],
162 },
163 shape_result_data: {
164 id: "shape_result_data",
165 kind: "shape",
166 resourceId: "res_1",
167 node: {
168 type: "scalar",
169 scalar: "string",
170 },
171 synthetic: false,
172 provenance: [],
173 },
174 shape_result_error_nullable: {
175 id: "shape_result_error_nullable",
176 kind: "shape",
177 resourceId: "res_1",
178 node: {
179 type: "nullable",
180 itemShapeId: "shape_result_error",
181 },
182 synthetic: true,
183 provenance: [],
184 },
185 shape_result_error: {
186 id: "shape_result_error",
187 kind: "shape",
188 resourceId: "res_1",
189 node: {
190 type: "unknown",
191 },
192 synthetic: false,
193 provenance: [],
194 },
195 shape_result_headers: {
196 id: "shape_result_headers",
197 kind: "shape",
198 resourceId: "res_1",
199 node: {
200 type: "object",
201 fields: {},
202 additionalProperties: "shape_result_header_value",
203 },
204 synthetic: true,
205 provenance: [],
206 },
207 shape_result_header_value: {
208 id: "shape_result_header_value",
209 kind: "shape",
210 resourceId: "res_1",
211 node: {
212 type: "scalar",
213 scalar: "string",
214 },
215 synthetic: true,
216 provenance: [],
217 },
218 shape_result_status_nullable: {
219 id: "shape_result_status_nullable",
220 kind: "shape",
221 resourceId: "res_1",
222 node: {
223 type: "nullable",
224 itemShapeId: "shape_result_status",
243 },225 },
226 synthetic: true,
227 provenance: [],
244 },228 },
245 typeProjector: {} as LoadedSourceCatalogTool["typeProjector"],229 shape_result_status: {
246 } as LoadedSourceCatalogTool);230 id: "shape_result_status",
231 kind: "shape",
232 resourceId: "res_1",
233 node: {
234 type: "scalar",
235 scalar: "integer",
236 },
237 synthetic: true,
238 provenance: [],
239 },
240 },
241 },
242 typeProjector: {} as LoadedSourceCatalogTool["typeProjector"],
243} as LoadedSourceCatalogTool;
244
245describe("source inspection", () => {
246 it.effect("keeps the shared TypeScript contract first-class on tool detail", () =>
247 Effect.gen(function* () {
248 const detail = yield* inspectionToolDetailFromTool(
249 structuredClone(baseLoadedSourceCatalogTool),
250 );
247 251
248 expect(detail.summary.path).toBe("linear.administrableTeams");252 expect(detail.summary.path).toBe("linear.administrableTeams");
249 expect(detail.summary.method).toBe("query");253 expect(detail.summary.method).toBe("query");
@@ -275,4 +279,28 @@ describe("source inspection", () => { · 25 unmodified lines
275 ]),279 ]),
276 );280 );
277 }));281 }));
282
283 it.effect("truncates oversized executable sections to keep tool detail payload bounded", () =>
284 Effect.gen(function* () {
285 const tool = structuredClone(baseLoadedSourceCatalogTool);
286 tool.executable.binding = {
287 payload: "x".repeat(250_000),
288 };
289
290 const detail = yield* inspectionToolDetailFromTool(tool);
291 const executableSection = detail.sections.find(
292 (section) => section.kind === "code" && section.title === "Executable",
293 );
294
295 expect(executableSection).toBeDefined();
296 if (!executableSection || executableSection.kind !== "code") {
297 throw new Error("Expected Executable code section");
298 }
299
300 expect(executableSection.body).toContain(
301 "Executable truncated for inspection UI",
302 );
303 expect(executableSection.body.length).toBeLessThanOrEqual(100_128);
304 expect(executableSection.body).toContain("\"payload\":\"");
305 }));
278});306});
packages/platform/sdk/src/sources/inspection.ts+57−20
@@ -39,6 +39,9 @@ const sourceInspectOps = { · 38 unmodified lines
39 discover: operationErrors("sources.inspect.discover"),39 discover: operationErrors("sources.inspect.discover"),
40} as const;40} as const;
41 41
42const PRETTY_JSON_SECTION_MAX_CHARS = 20_000;
43const MAX_CODE_SECTION_CHARS = 100_000;
44
42const tokenize = (value: string): Array<string> =>45const tokenize = (value: string): Array<string> =>
43 value46 value
44 .trim()47 .trim()
@@ -172,18 +175,50 @@ const nativeEncodingLanguage = (encoding: string | undefined): string => · 127 unmodified lines
172 ? encoding175 ? encoding
173 : "json";176 : "json";
174 177
178const truncatedCodeBody = (input: {
179 title: string;
180 body: string;
181}): string =>
182 input.body.length <= MAX_CODE_SECTION_CHARS
183 ? input.body
184 : [
185 `[${input.title} truncated for inspection UI: ${String(input.body.length)} chars total, showing first ${String(MAX_CODE_SECTION_CHARS)}]`,
186 input.body.slice(0, MAX_CODE_SECTION_CHARS),
187 ].join("\n");
188
189const serializedJsonSectionBody = (title: string, value: unknown): string => {
190 const compact = JSON.stringify(value);
191 if (compact.length <= PRETTY_JSON_SECTION_MAX_CHARS) {
192 return JSON.stringify(value, null, 2);
193 }
194
195 return truncatedCodeBody({
196 title,
197 body: compact,
198 });
199};
200
201const codeSection = (
202 title: string,
203 language: string,
204 body: string,
205): SourceInspectionToolDetail["sections"][number] => ({
206 kind: "code",
207 title,
208 language,
209 body: truncatedCodeBody({
210 title,
211 body,
212 }),
213});
214
175const jsonSection = (215const jsonSection = (
176 title: string,216 title: string,
177 value: unknown | null | undefined,217 value: unknown | null | undefined,
178): SourceInspectionToolDetail["sections"][number] | null =>218): SourceInspectionToolDetail["sections"][number] | null =>
179 value === null || value === undefined219 value === null || value === undefined
180 ? null220 ? null
181 : {221 : codeSection(title, "json", serializedJsonSectionBody(title, value));
182 kind: "code",
183 title,
184 language: "json",
185 body: JSON.stringify(value, null, 2),
186 };
187 222
188export const inspectionToolDetailFromTool = (223export const inspectionToolDetailFromTool = (
189 tool: LoadedSourceCatalogTool,224 tool: LoadedSourceCatalogTool,
@@ -220,24 +255,26 @@ export const inspectionToolDetailFromTool = ( · 30 unmodified lines
220 { label: "Response set", value: contract.responseSetId, mono: true },255 { label: "Response set", value: contract.responseSetId, mono: true },
221 ];256 ];
222 const nativeSections = [257 const nativeSections = [
223 ...(tool.capability.native ?? []).map((blob, index) => ({258 ...(tool.capability.native ?? []).map((blob, index) => {
224 kind: "code" as const,259 const title = `Capability native ${String(index + 1)}: ${blob.kind}`;
225 title: `Capability native ${String(index + 1)}: ${blob.kind}`,260 return codeSection(
226 language: nativeEncodingLanguage(blob.encoding),261 title,
227 body:262 nativeEncodingLanguage(blob.encoding),
228 typeof blob.value === "string"263 typeof blob.value === "string"
229 ? blob.value264 ? blob.value
230 : JSON.stringify(blob.value ?? null, null, 2),265 : serializedJsonSectionBody(title, blob.value ?? null),
231 })),266 );
232 ...(tool.executable.native ?? []).map((blob, index) => ({267 }),
233 kind: "code" as const,268 ...(tool.executable.native ?? []).map((blob, index) => {
234 title: `Executable native ${String(index + 1)}: ${blob.kind}`,269 const title = `Executable native ${String(index + 1)}: ${blob.kind}`;
235 language: nativeEncodingLanguage(blob.encoding),270 return codeSection(
236 body:271 title,
272 nativeEncodingLanguage(blob.encoding),
237 typeof blob.value === "string"273 typeof blob.value === "string"
238 ? blob.value274 ? blob.value
239 : JSON.stringify(blob.value ?? null, null, 2),275 : serializedJsonSectionBody(title, blob.value ?? null),
240 })),276 );
277 }),
241 ];278 ];
242 const sections = [279 const sections = [
243 {280 {
plugins/mcp/sdk/catalog.test.ts+35−0
@@ -101,4 +101,39 @@ describe("MCP catalog", () => { · 100 unmodified lines
101 dataVariants.some((shape) => shape?.kind === "shape" && shape.node.type === "unknown"),101 dataVariants.some((shape) => shape?.kind === "shape" && shape.node.type === "unknown"),
102 ).toBe(true);102 ).toBe(true);
103 });103 });
104
105 it("stores lean executable bindings without dropping catalog semantics", () => {
106 const snapshot = createMcpCatalogSnapshot({
107 source: createSource(),
108 documents: [{
109 documentKind: "mcp_manifest",
110 documentKey: "https://mcp.axiom.co/mcp",
111 contentText: JSON.stringify({ version: 2, tools: [] }),
112 }],
113 operations: [createOperation()],
114 });
115
116 const executable = Object.values(snapshot.catalog.executables)[0];
117 if (!executable) {
118 throw new Error("Expected MCP executable");
119 }
120
121 expect(executable.binding).toEqual({
122 toolId: "createdashboard",
123 toolName: "createDashboard",
124 });
125 expect(executable.binding).not.toHaveProperty("rawTool");
126 expect(executable.display?.title).toBe("createDashboard");
127
128 const capability = snapshot.catalog.capabilities[executable.capabilityId];
129 if (!capability) {
130 throw new Error("Expected MCP capability");
131 }
132
133 expect(capability.surface.title).toBe("createDashboard");
134 expect(capability.surface.summary).toBe(
135 "Create a new dashboard from a JSON document.",
136 );
137 expect(capability.interaction.resume.supported).toBe(false);
138 });
104});139});
plugins/mcp/sdk/catalog.ts+2−1
@@ -32,6 +32,7 @@ import { · 31 unmodified lines
32 toolPathSegments,32 toolPathSegments,
33} from "@executor/source-core";33} from "@executor/source-core";
34 34
35import { mcpExecutableBindingFromProviderData } from "./executable-binding";
35import type {36import type {
36 McpServerMetadata,37 McpServerMetadata,
37 McpToolAnnotations,38 McpToolAnnotations,
@@ -192,7 +193,7 @@ const createMcpCapability = (input: { · 154 unmodified lines
192 scopeId: input.serviceScopeId,193 scopeId: input.serviceScopeId,
193 pluginKey: "mcp",194 pluginKey: "mcp",
194 bindingVersion: EXECUTABLE_BINDING_VERSION,195 bindingVersion: EXECUTABLE_BINDING_VERSION,
195 binding: input.operation.providerData,196 binding: mcpExecutableBindingFromProviderData(input.operation.providerData),
196 projection: {197 projection: {
197 responseSetId,198 responseSetId,
198 callShapeId,199 callShapeId,
plugins/mcp/sdk/executable-binding.test.ts+44−0
@@ -0,0 +1,44 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Schema from "effect/Schema";
3
4import { McpExecutableBindingSchema } from "./executable-binding";
5
6describe("MCP executable binding", () => {
7 it("accepts legacy verbose bindings and strips manifest-only metadata", () => {
8 const decode = Schema.decodeUnknownSync(McpExecutableBindingSchema);
9
10 expect(
11 decode({
12 toolId: "read_file",
13 toolName: "Read File",
14 displayTitle: "Read File",
15 title: "Read File",
16 description: "Read a file",
17 annotations: {
18 readOnlyHint: true,
19 },
20 execution: {
21 taskSupport: "optional",
22 },
23 icons: [{
24 src: "https://example.test/icon.png",
25 }],
26 meta: {
27 category: "filesystem",
28 },
29 rawTool: {
30 name: "Read File",
31 },
32 server: {
33 info: {
34 name: "mcp-server",
35 version: "1.0.0",
36 },
37 },
38 }),
39 ).toEqual({
40 toolId: "read_file",
41 toolName: "Read File",
42 });
43 });
44});
plugins/mcp/sdk/executable-binding.ts+16−0
@@ -0,0 +1,16 @@
1import * as Schema from "effect/Schema";
2
3export const McpExecutableBindingSchema = Schema.Struct({
4 toolId: Schema.String,
5 toolName: Schema.String,
6});
7
8export type McpExecutableBinding = typeof McpExecutableBindingSchema.Type;
9
10export const mcpExecutableBindingFromProviderData = (input: {
11 toolId: string;
12 toolName: string;
13}): McpExecutableBinding => ({
14 toolId: input.toolId,
15 toolName: input.toolName,
16});
plugins/mcp/sdk/index.ts+5−25
@@ -74,6 +74,10 @@ import { · 73 unmodified lines
74 createMcpCatalogFragment,74 createMcpCatalogFragment,
75 type McpCatalogOperationInput,75 type McpCatalogOperationInput,
76} from "./catalog";76} from "./catalog";
77import {
78 McpExecutableBindingSchema,
79 type McpExecutableBinding,
80} from "./executable-binding";
77import {81import {
78 detectMcpSource,82 detectMcpSource,
79} from "./discovery";83} from "./discovery";
@@ -107,22 +111,6 @@ const McpCompleteOAuthInputSchema = Schema.Struct({ · 27 unmodified lines
107 errorDescription: Schema.optional(Schema.String),111 errorDescription: Schema.optional(Schema.String),
108});112});
109 113
110const McpExecutableBindingSchema = Schema.Struct({
111 toolId: Schema.String,
112 toolName: Schema.String,
113 displayTitle: Schema.String,
114 title: Schema.NullOr(Schema.String),
115 description: Schema.NullOr(Schema.String),
116 annotations: Schema.NullOr(Schema.Unknown),
117 execution: Schema.NullOr(Schema.Unknown),
118 icons: Schema.NullOr(Schema.Unknown),
119 meta: Schema.NullOr(Schema.Unknown),
120 rawTool: Schema.NullOr(Schema.Unknown),
121 server: Schema.NullOr(Schema.Unknown),
122});
123
124type McpExecutableBinding = typeof McpExecutableBindingSchema.Type;
125
126const decodeProviderData = Schema.decodeUnknownSync(McpExecutableBindingSchema);114const decodeProviderData = Schema.decodeUnknownSync(McpExecutableBindingSchema);
127 115
128export type McpSourceStorage = {116export type McpSourceStorage = {
@@ -824,19 +812,11 @@ export const mcpSdkPlugin = ( · 695 unmodified lines
824 });812 });
825 const manifest = {813 const manifest = {
826 version: 2 as const,814 version: 2 as const,
827 server: providerData.server as McpServerMetadata | null,
828 tools: [815 tools: [
829 {816 {
830 toolId: providerData.toolId,817 toolId: providerData.toolId,
831 toolName: providerData.toolName,818 toolName: providerData.toolName,
832 displayTitle: providerData.displayTitle,819 description: input.descriptor.description ?? null,
833 title: providerData.title,
834 description: providerData.description,
835 annotations: providerData.annotations as McpToolManifestEntry["annotations"],
836 execution: providerData.execution as McpToolManifestEntry["execution"],
837 icons: providerData.icons as McpToolManifestEntry["icons"],
838 meta: providerData.meta,
839 rawTool: providerData.rawTool,
840 inputSchema: input.descriptor.contract?.inputSchema,820 inputSchema: input.descriptor.contract?.inputSchema,
841 outputSchema: input.descriptor.contract?.outputSchema,821 outputSchema: input.descriptor.contract?.outputSchema,
842 },822 },
plugins/openapi/sdk/catalog.ts+15−1
@@ -49,6 +49,7 @@ import type { · 48 unmodified lines
49 OpenApiMediaContent,49 OpenApiMediaContent,
50 OpenApiSecurityRequirement,50 OpenApiSecurityRequirement,
51 OpenApiSecurityScheme,51 OpenApiSecurityScheme,
52 OpenApiExecutableBinding,
52 OpenApiToolProviderData,53 OpenApiToolProviderData,
53 OpenApiToolDefinition,54 OpenApiToolDefinition,
54} from "./types";55} from "./types";
@@ -791,7 +792,20 @@ const createHttpCapabilityFromOpenApi = (input: { · 736 unmodified lines
791 })(),792 })(),
792 pluginKey: "openapi",793 pluginKey: "openapi",
793 bindingVersion: EXECUTABLE_BINDING_VERSION,794 bindingVersion: EXECUTABLE_BINDING_VERSION,
794 binding: input.operation.providerData,795 binding: {
796 kind: "openapi",
797 toolId: input.operation.providerData.toolId,
798 ...(input.operation.providerData.operationId
799 ? { operationId: input.operation.providerData.operationId }
800 : {}),
801 invocation: input.operation.providerData.invocation,
802 ...(input.operation.providerData.documentServers
803 ? { documentServers: input.operation.providerData.documentServers }
804 : {}),
805 ...(input.operation.providerData.servers
806 ? { servers: input.operation.providerData.servers }
807 : {}),
808 } satisfies OpenApiExecutableBinding,
795 projection: {809 projection: {
796 responseSetId,810 responseSetId,
797 callShapeId,811 callShapeId,
plugins/openapi/sdk/definitions.ts+12−0
@@ -1,4 +1,5 @@
1import type {1import type {
2 OpenApiExecutableBinding,
2 OpenApiExtractedTool,3 OpenApiExtractedTool,
3 OpenApiToolDefinition,4 OpenApiToolDefinition,
4 OpenApiToolManifest,5 OpenApiToolManifest,
@@ -247,3 +248,14 @@ export const openApiProviderDataFromDefinition = ( · 242 unmodified lines
247 documentServers: definition.documentServers,248 documentServers: definition.documentServers,
248 servers: definition.servers,249 servers: definition.servers,
249});250});
251
252export const openApiExecutableBindingFromDefinition = (
253 definition: OpenApiToolDefinition,
254): OpenApiExecutableBinding => ({
255 kind: "openapi",
256 toolId: definition.toolId,
257 operationId: definition.operationId,
258 invocation: definition.invocation,
259 documentServers: definition.documentServers,
260 servers: definition.servers,
261});
plugins/openapi/sdk/extraction.ts+42−1
@@ -199,8 +199,15 @@ const loadDereferencedOpenApiDocument = async (input: { · 198 unmodified lines
199 currentDocument: OpenApiJsonObject;199 currentDocument: OpenApiJsonObject;
200 currentDocumentUrl?: string;200 currentDocumentUrl?: string;
201 activeRefs: ReadonlySet<string>;201 activeRefs: ReadonlySet<string>;
202 preserveLocalRefs: boolean;
202 }): Promise<unknown> => {203 }): Promise<unknown> => {
203 const { value, currentDocument, currentDocumentUrl, activeRefs } = inputValue;204 const {
205 value,
206 currentDocument,
207 currentDocumentUrl,
208 activeRefs,
209 preserveLocalRefs,
210 } = inputValue;
204 211
205 if (Array.isArray(value)) {212 if (Array.isArray(value)) {
206 return Promise.all(213 return Promise.all(
@@ -210,6 +217,7 @@ const loadDereferencedOpenApiDocument = async (input: { · 3 unmodified lines
210 currentDocument,217 currentDocument,
211 currentDocumentUrl,218 currentDocumentUrl,
212 activeRefs,219 activeRefs,
220 preserveLocalRefs,
213 }),221 }),
214 ),222 ),
215 );223 );
@@ -227,6 +235,35 @@ const loadDereferencedOpenApiDocument = async (input: { · 11 unmodified lines
227 return value;235 return value;
228 }236 }
229 237
238 const isLocalRef =
239 ref.startsWith("#") &&
240 (target?.documentUrl === undefined || target.documentUrl === currentDocumentUrl);
241 if (isLocalRef && preserveLocalRefs) {
242 const siblingEntries = Object.fromEntries(
243 await Promise.all(
244 Object.entries(object)
245 .filter(([key]) => key !== "$ref")
246 .map(async ([key, entry]) => [
247 key,
248 await dereference({
249 value: entry,
250 currentDocument,
251 currentDocumentUrl,
252 activeRefs,
253 preserveLocalRefs,
254 }),
255 ]),
256 ),
257 );
258
259 return Object.keys(siblingEntries).length > 0
260 ? {
261 $ref: ref,
262 ...siblingEntries,
263 }
264 : value;
265 }
266
230 const activeKey = `${target?.documentUrl ?? currentDocumentUrl ?? "root"}|${target?.pointer ?? ref}`;267 const activeKey = `${target?.documentUrl ?? currentDocumentUrl ?? "root"}|${target?.pointer ?? ref}`;
231 if (activeRefs.has(activeKey)) {268 if (activeRefs.has(activeKey)) {
232 return value;269 return value;
@@ -249,6 +286,7 @@ const loadDereferencedOpenApiDocument = async (input: { · 16 unmodified lines
249 currentDocument: targetDocument,286 currentDocument: targetDocument,
250 currentDocumentUrl: target?.documentUrl ?? currentDocumentUrl,287 currentDocumentUrl: target?.documentUrl ?? currentDocumentUrl,
251 activeRefs: nextActiveRefs,288 activeRefs: nextActiveRefs,
289 preserveLocalRefs: target?.documentUrl ? false : preserveLocalRefs,
252 });290 });
253 const siblingEntries = Object.fromEntries(291 const siblingEntries = Object.fromEntries(
254 await Promise.all(292 await Promise.all(
@@ -261,6 +299,7 @@ const loadDereferencedOpenApiDocument = async (input: { · 6 unmodified lines
261 currentDocument,299 currentDocument,
262 currentDocumentUrl,300 currentDocumentUrl,
263 activeRefs: nextActiveRefs,301 activeRefs: nextActiveRefs,
302 preserveLocalRefs,
264 }),303 }),
265 ]),304 ]),
266 ),305 ),
@@ -281,6 +320,7 @@ const loadDereferencedOpenApiDocument = async (input: { · 14 unmodified lines
281 currentDocument,320 currentDocument,
282 currentDocumentUrl,321 currentDocumentUrl,
283 activeRefs,322 activeRefs,
323 preserveLocalRefs,
284 }),324 }),
285 ]),325 ]),
286 ),326 ),
@@ -292,6 +332,7 @@ const loadDereferencedOpenApiDocument = async (input: { · 5 unmodified lines
292 currentDocument: input.document,332 currentDocument: input.document,
293 currentDocumentUrl: input.documentUrl,333 currentDocumentUrl: input.documentUrl,
294 activeRefs: new Set<string>(),334 activeRefs: new Set<string>(),
335 preserveLocalRefs: true,
295 }) as Promise<OpenApiJsonObject>;336 }) as Promise<OpenApiJsonObject>;
296};337};
297 338
plugins/openapi/sdk/index.ts+12−9
@@ -57,8 +57,8 @@ import { · 56 unmodified lines
57 withSerializedQueryEntries,57 withSerializedQueryEntries,
58} from "./http-serialization";58} from "./http-serialization";
59import {59import {
60 OpenApiToolProviderDataSchema,60 OpenApiExecutableBindingSchema,
61 type OpenApiToolProviderData,61 type OpenApiExecutableBinding,
62} from "./types";62} from "./types";
63 63
64const OpenApiSourceIdInputSchema = Schema.Struct({64const OpenApiSourceIdInputSchema = Schema.Struct({
@@ -206,7 +206,9 @@ const openApiStoredSourceDataFromLocalConfig = (input: { · 141 unmodified lines
206 throw new Error("Unsupported OpenAPI local source config.");206 throw new Error("Unsupported OpenAPI local source config.");
207};207};
208 208
209const decodeProviderData = Schema.decodeUnknownSync(OpenApiToolProviderDataSchema);209const decodeExecutableBinding = Schema.decodeUnknownSync(
210 OpenApiExecutableBindingSchema,
211);
210 212
211const asRecord = (value: unknown): Record<string, unknown> =>213const asRecord = (value: unknown): Record<string, unknown> =>
212 typeof value === "object" && value !== null && !Array.isArray(value)214 typeof value === "object" && value !== null && !Array.isArray(value)
@@ -214,7 +216,8 @@ const asRecord = (value: unknown): Record<string, unknown> => · 1 unmodified line
214 : {};216 : {};
215 217
216type OpenApiToolArgs = Record<string, unknown>;218type OpenApiToolArgs = Record<string, unknown>;
217type OpenApiToolParameter = OpenApiToolProviderData["invocation"]["parameters"][number];219type OpenApiToolParameter =
220 OpenApiExecutableBinding["invocation"]["parameters"][number];
218 221
219const parameterContainerKeys: Record<222const parameterContainerKeys: Record<
220 OpenApiToolParameter["location"],223 OpenApiToolParameter["location"],
@@ -257,7 +260,7 @@ const readParameterValue = ( · 36 unmodified lines
257const replacePathTemplate = (260const replacePathTemplate = (
258 pathTemplate: string,261 pathTemplate: string,
259 args: OpenApiToolArgs,262 args: OpenApiToolArgs,
260 payload: OpenApiToolProviderData["invocation"],263 payload: OpenApiExecutableBinding["invocation"],
261): string => {264): string => {
262 let resolvedPath = pathTemplate;265 let resolvedPath = pathTemplate;
263 266
@@ -297,7 +300,7 @@ const replacePathTemplate = ( · 33 unmodified lines
297 300
298const resolveOpenApiBaseUrl = (input: {301const resolveOpenApiBaseUrl = (input: {
299 stored: OpenApiStoredSourceData;302 stored: OpenApiStoredSourceData;
300 providerData: OpenApiToolProviderData;303 providerData: OpenApiExecutableBinding;
301}): string => {304}): string => {
302 if (input.stored.baseUrl && input.stored.baseUrl.trim().length > 0) {305 if (input.stored.baseUrl && input.stored.baseUrl.trim().length > 0) {
303 return new URL(input.stored.baseUrl).toString();306 return new URL(input.stored.baseUrl).toString();
@@ -641,9 +644,9 @@ export const openApiSdkPlugin = ( · 337 unmodified lines
641 }644 }
642 645
643 const normalizedStored = normalizeStoredSourceData(input.stored);646 const normalizedStored = normalizeStoredSourceData(input.stored);
644 const providerData = decodeProviderData(647 const providerData = decodeExecutableBinding(
645 input.executable.binding,648 input.executable.binding,
646 ) as OpenApiToolProviderData;649 ) as OpenApiExecutableBinding;
647 const args = asRecord(input.args);650 const args = asRecord(input.args);
648 const resolvedPath = replacePathTemplate(651 const resolvedPath = replacePathTemplate(
649 providerData.invocation.pathTemplate,652 providerData.invocation.pathTemplate,
@@ -739,7 +742,7 @@ export const openApiSdkPlugin = ( · 89 unmodified lines
739 const response = yield* Effect.tryPromise({742 const response = yield* Effect.tryPromise({
740 try: () =>743 try: () =>
741 fetch(finalUrl.toString(), {744 fetch(finalUrl.toString(), {
742 method: providerData.method.toUpperCase(),745 method: providerData.invocation.method.toUpperCase(),
743 headers: requestHeaders,746 headers: requestHeaders,
744 ...(body !== undefined747 ...(body !== undefined
745 ? {748 ? {
plugins/openapi/sdk/types.ts+11−0
@@ -215,6 +215,15 @@ export const OpenApiToolProviderDataSchema = Schema.Struct({ · 214 unmodified lines
215 servers: Schema.optional(Schema.Array(OpenApiServerSchema)),215 servers: Schema.optional(Schema.Array(OpenApiServerSchema)),
216});216});
217 217
218export const OpenApiExecutableBindingSchema = Schema.Struct({
219 kind: Schema.Literal("openapi"),
220 toolId: Schema.String,
221 operationId: Schema.optional(Schema.String),
222 invocation: OpenApiInvocationPayloadSchema,
223 documentServers: Schema.optional(Schema.Array(OpenApiServerSchema)),
224 servers: Schema.optional(Schema.Array(OpenApiServerSchema)),
225});
226
218export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type;227export type OpenApiHttpMethod = typeof OpenApiHttpMethodSchema.Type;
219export type OpenApiExample = typeof OpenApiExampleSchema.Type;228export type OpenApiExample = typeof OpenApiExampleSchema.Type;
220export type OpenApiMediaContent = typeof OpenApiMediaContentSchema.Type;229export type OpenApiMediaContent = typeof OpenApiMediaContentSchema.Type;
@@ -237,6 +246,8 @@ export type OpenApiResponseVariant = · 16 unmodified lines
237 typeof OpenApiResponseVariantSchema.Type;246 typeof OpenApiResponseVariantSchema.Type;
238export type OpenApiToolProviderData =247export type OpenApiToolProviderData =
239 typeof OpenApiToolProviderDataSchema.Type;248 typeof OpenApiToolProviderDataSchema.Type;
249export type OpenApiExecutableBinding =
250 typeof OpenApiExecutableBindingSchema.Type;
240 251
241export type OpenApiJsonPrimitive = string | number | boolean | null;252export type OpenApiJsonPrimitive = string | number | boolean | null;
242export type OpenApiJsonValue =253export type OpenApiJsonValue =