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
5 changes: 5 additions & 0 deletions .changeset/openapi-update-spec-tool.md
Original file line number Diff line number Diff line change
@@ -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.
38 changes: 33 additions & 5 deletions e2e/scenarios/openapi-update-spec.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down Expand Up @@ -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<string, unknown>;
});

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);
Expand Down Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions packages/plugins/openapi/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
65 changes: 65 additions & 0 deletions packages/plugins/openapi/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand All @@ -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({
Expand Down Expand Up @@ -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.`,
),
),
}),
),
}),
],
},
],
Expand Down
Loading