diff --git a/.changeset/openapi-update-spec-tool.md b/.changeset/openapi-update-spec-tool.md new file mode 100644 index 0000000000..7275057f3a --- /dev/null +++ b/.changeset/openapi-update-spec-tool.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-openapi": patch +--- + +Expose OpenAPI spec updates as an approval-gated Executor tool so agents can refresh URL-backed integrations without removing their connections. diff --git a/e2e/scenarios/openapi-update-spec.test.ts b/e2e/scenarios/openapi-update-spec.test.ts index bf697e9aca..c9e37df0c4 100644 --- a/e2e/scenarios/openapi-update-spec.test.ts +++ b/e2e/scenarios/openapi-update-spec.test.ts @@ -19,7 +19,8 @@ import { } from "@executor-js/sdk/shared"; import { scenario } from "../src/scenario"; -import { Api, Target } from "../src/services"; +import { Api, Mcp, Target } from "../src/services"; +import type { McpSession } from "../src/surfaces/mcp"; const api = composePluginApi([openApiHttpPlugin()] as const); @@ -98,15 +99,43 @@ const serveMutableSpec = (initial: string) => (server) => Effect.sync(server.close), ); +const updateSpecCode = (slug: string) => ` +const updated = await tools.executor.openapi.updateSpec({ + slug: ${JSON.stringify(slug)}, +}); +return updated.ok ? { + ok: true, + slug: updated.data.slug, + toolCount: updated.data.toolCount, + addedTools: updated.data.addedTools, + removedTools: updated.data.removedTools, +} : { ok: false, error: updated.error }; +`; + +/** Run the agent tool and approve its catalog-changing action. */ +const executeJson = (session: McpSession, code: string) => + Effect.gen(function* () { + let result = yield* session.call("execute", { code }); + let guard = 0; + while (result.text.includes("executionId:") && guard < 10) { + result = yield* session.approvePaused(result.text); + guard += 1; + } + expect(result.ok, `execute completed (got: ${result.text.slice(0, 400)})`).toBe(true); + return JSON.parse(result.text) as Record; + }); + scenario( "OpenAPI ยท updating the spec rebuilds tools without re-adding the integration", {}, Effect.scoped( Effect.gen(function* () { const target = yield* Target; + const mcp = yield* Mcp; const { client } = yield* Api; const identity = yield* target.newIdentity(); const apiClient = yield* client(api, identity); + const session = mcp.session(identity); const slug = `update-spec-${randomBytes(4).toString("hex")}`; const specServer = yield* serveMutableSpec(specV1); @@ -154,10 +183,9 @@ scenario( // The upstream API ships v2: legacyOp is gone, listWidgets appears. specServer.setBody(specV2); - const updated = yield* apiClient.openapi.updateSpec({ - params: { slug }, - payload: {}, - }); + const updated = yield* executeJson(session, updateSpecCode(slug)); + + expect(updated.ok, "the agent update tool succeeded").toBe(true); expect(updated.addedTools, "the diff names the new tool").toEqual([ "widgets.listWidgets", diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 1617e8b555..7f687b86ef 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -497,6 +497,56 @@ describe("OpenAPI Plugin", () => { }), ); + it.effect("invokes static updateSpec through executor.execute", () => + Effect.scoped( + Effect.gen(function* () { + const specServer = yield* serveMutableOpenApiSpecTestServer({ initialApi: TestApi }); + const executor = yield* createExecutor(makeTestConfig({ plugins: testPlugins() })); + + yield* executor.openapi.addSpec({ + spec: { kind: "url", url: specServer.specUrl }, + slug: "runtime_update", + baseUrl: specServer.baseUrl, + authenticationTemplate: [apiKeyTemplate], + }); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("runtime_update"), + template: AuthTemplateSlug.make("apiKey"), + value: "secret-key-123", + }); + + const EvolvedItems = HttpApiGroup.make("items").add( + HttpApiEndpoint.get("listItems", "/items", { success: Schema.Array(Item) }), + ); + const Widgets = HttpApiGroup.make("widgets").add( + HttpApiEndpoint.get("listWidgets", "/widgets", { success: Schema.Array(Item) }), + ); + yield* specServer.setApi(HttpApi.make("testApi").add(EvolvedItems).add(Widgets)); + + const result = unwrapInvocation( + yield* executor.execute(ToolAddress.make("executor.openapi.updateSpec"), { + slug: "runtime_update", + }), + ).data as { + slug: string; + toolCount: number; + addedTools: readonly string[]; + removedTools: readonly string[]; + }; + + expect(result.slug).toBe("runtime_update"); + expect(result.addedTools).toEqual(["widgets.listWidgets"]); + expect(result.removedTools).toContain("items.queryRows"); + + const toolNames = (yield* executor.tools.list()).map((item) => String(item.name)); + expect(toolNames).toContain("widgets.listWidgets"); + expect(toolNames).not.toContain("items.queryRows"); + }), + ), + ); + it.effect("static previewSpec returns actionable tool failures", () => Effect.gen(function* () { const config = makeTestConfig({ plugins: [openApiPlugin()] as const }); diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index b0c0558236..e6d1d9fdad 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -326,6 +326,19 @@ const AddIntegrationOutputSchema = Schema.Struct({ toolCount: Schema.Number, }); +const UpdateIntegrationInputSchema = Schema.Struct({ + slug: Schema.String, + spec: Schema.optional(OpenApiSpecInputSchema), + specOverrides: Schema.optional(SpecOverridesSchema), +}); + +const UpdateIntegrationOutputSchema = Schema.Struct({ + slug: Schema.String, + toolCount: Schema.Number, + addedTools: Schema.Array(Schema.String), + removedTools: Schema.Array(Schema.String), +}); + const PreviewSpecInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(PreviewSpecInputSchema), ); @@ -338,6 +351,12 @@ const AddIntegrationInputStandardSchema = Schema.toStandardSchemaV1( const AddIntegrationOutputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(AddIntegrationOutputSchema), ); +const UpdateIntegrationInputStandardSchema = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(UpdateIntegrationInputSchema), +); +const UpdateIntegrationOutputStandardSchema = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(UpdateIntegrationOutputSchema), +); const openApiToolFailure = (code: string, message: string, details?: unknown) => ToolResult.fail({ @@ -1290,6 +1309,52 @@ export const openApiPlugin = definePlugin< }), ), }), + tool({ + name: "updateSpec", + description: + "Update an existing OpenAPI integration in place and rebuild its connected tools. Omit `spec` to re-fetch the integration's stored spec URL. Provide `spec` to replace an inline or URL source. Existing connections, credentials, policies, and curated integration metadata are preserved.", + annotations: { + requiresApproval: true, + approvalDescription: "Update an OpenAPI integration", + }, + inputSchema: UpdateIntegrationInputStandardSchema, + outputSchema: UpdateIntegrationOutputStandardSchema, + execute: (input: typeof UpdateIntegrationInputSchema.Type) => + self + .updateSpec(input.slug, { + ...(input.spec === undefined ? {} : { spec: input.spec }), + ...(input.specOverrides === undefined + ? {} + : { specOverrides: input.specOverrides }), + }) + .pipe( + Effect.map((result) => + ToolResult.ok({ + slug: String(result.slug), + toolCount: result.toolCount, + addedTools: [...result.addedTools], + removedTools: [...result.removedTools], + }), + ), + Effect.catchTags({ + OpenApiParseError: ({ message }: OpenApiParseError) => + Effect.succeed(openApiToolFailure("openapi_parse_failed", message)), + OpenApiExtractionError: ({ message }: OpenApiExtractionError) => + Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)), + OpenApiOAuthError: ({ message }: OpenApiOAuthError) => + Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)), + OpenApiSpecOverrideError: ({ message }) => + Effect.succeed(openApiToolFailure("openapi_spec_override_failed", message)), + IntegrationNotFoundError: ({ slug }: IntegrationNotFoundError) => + Effect.succeed( + openApiToolFailure( + "integration_not_found", + `Integration ${slug} was not found.`, + ), + ), + }), + ), + }), ], }, ],