From 01dc11a428492320e91e6468b9ac96108f1518f8 Mon Sep 17 00:00:00 2001 From: fforres Date: Mon, 5 Oct 2026 10:12:43 -0700 Subject: [PATCH] fix(openapi): Slack tools stop asking the caller for the token The published Slack Web API specs declare the credential as an ordinary `token` input on every operation. Each generated tool therefore required the caller to supply a token and sent it in the query or form body, which Slack rejects for current apps, so calls failed with a schema error or invalid_auth even when the connection held a valid credential. A slack-web-api spec format adapter, detected from the apis.guru and slackapi spec URLs, drops the `token` parameter and form field when the spec is loaded. The connection's credential, sent as an Authorization header, is then the only one. Existing integrations pick it up on refresh. --- apps/cloud/executor.config.ts | 3 +- apps/host-cloudflare/executor.config.ts | 3 +- apps/host-cloudflare/src/plugins.ts | 3 +- apps/host-selfhost/executor.config.ts | 3 +- apps/local/executor.config.ts | 3 +- packages/plugins/openapi/package.json | 7 + .../openapi/src/providers/slack/index.ts | 1 + .../slack/spec-format-adapter.test.ts | 134 ++++++++++++++++++ .../providers/slack/spec-format-adapter.ts | 106 ++++++++++++++ packages/plugins/openapi/tsup.config.ts | 1 + 10 files changed, 259 insertions(+), 5 deletions(-) create mode 100644 packages/plugins/openapi/src/providers/slack/index.ts create mode 100644 packages/plugins/openapi/src/providers/slack/spec-format-adapter.test.ts create mode 100644 packages/plugins/openapi/src/providers/slack/spec-format-adapter.ts diff --git a/apps/cloud/executor.config.ts b/apps/cloud/executor.config.ts index 71cd359999..b77b06095c 100644 --- a/apps/cloud/executor.config.ts +++ b/apps/cloud/executor.config.ts @@ -8,6 +8,7 @@ import { microsoftCatalog, microsoftGraphAdapter, } from "@executor-js/plugin-openapi/providers/microsoft"; +import { slackWebApiAdapter } from "@executor-js/plugin-openapi/providers/slack"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { workosVaultPlugin, type WorkOSVaultClient } from "@executor-js/plugin-workos-vault"; @@ -55,7 +56,7 @@ export default defineExecutorConfig({ [ openApiHttpPlugin({ presets: [...googleCatalog, ...microsoftCatalog], - specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter, slackWebApiAdapter], }), mcpHttpPlugin({ dangerouslyAllowStdioMCP: false, diff --git a/apps/host-cloudflare/executor.config.ts b/apps/host-cloudflare/executor.config.ts index 5a47434b54..d474fd3bde 100644 --- a/apps/host-cloudflare/executor.config.ts +++ b/apps/host-cloudflare/executor.config.ts @@ -8,6 +8,7 @@ import { microsoftCatalog, microsoftGraphAdapter, } from "@executor-js/plugin-openapi/providers/microsoft"; +import { slackWebApiAdapter } from "@executor-js/plugin-openapi/providers/slack"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; @@ -27,7 +28,7 @@ export default defineExecutorConfig({ [ openApiHttpPlugin({ presets: [...googleCatalog, ...microsoftCatalog], - specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter, slackWebApiAdapter], }), mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), graphqlHttpPlugin(), diff --git a/apps/host-cloudflare/src/plugins.ts b/apps/host-cloudflare/src/plugins.ts index ddd5f5e018..0029ce730a 100644 --- a/apps/host-cloudflare/src/plugins.ts +++ b/apps/host-cloudflare/src/plugins.ts @@ -7,6 +7,7 @@ import { microsoftCatalog, microsoftGraphAdapter, } from "@executor-js/plugin-openapi/providers/microsoft"; +import { slackWebApiAdapter } from "@executor-js/plugin-openapi/providers/slack"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; @@ -30,7 +31,7 @@ export const makeCloudflarePlugins = ( [ openApiHttpPlugin({ presets: [...googleCatalog, ...microsoftCatalog], - specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter, slackWebApiAdapter], }), mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }), graphqlHttpPlugin(), diff --git a/apps/host-selfhost/executor.config.ts b/apps/host-selfhost/executor.config.ts index c0826b9bcb..6c71099c14 100644 --- a/apps/host-selfhost/executor.config.ts +++ b/apps/host-selfhost/executor.config.ts @@ -8,6 +8,7 @@ import { microsoftCatalog, microsoftGraphAdapter, } from "@executor-js/plugin-openapi/providers/microsoft"; +import { slackWebApiAdapter } from "@executor-js/plugin-openapi/providers/slack"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { encryptedSecretsPlugin } from "@executor-js/plugin-encrypted-secrets"; @@ -37,7 +38,7 @@ export default defineExecutorConfig({ [ openApiHttpPlugin({ presets: [...googleCatalog, ...microsoftCatalog], - specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter, slackWebApiAdapter], }), mcpHttpPlugin({ dangerouslyAllowStdioMCP: process.env.EXECUTOR_ALLOW_STDIO_MCP === "true", diff --git a/apps/local/executor.config.ts b/apps/local/executor.config.ts index fa44b48c72..49abb5107c 100644 --- a/apps/local/executor.config.ts +++ b/apps/local/executor.config.ts @@ -8,6 +8,7 @@ import { microsoftCatalog, microsoftGraphAdapter, } from "@executor-js/plugin-openapi/providers/microsoft"; +import { slackWebApiAdapter } from "@executor-js/plugin-openapi/providers/slack"; import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; import { keychainPlugin } from "@executor-js/plugin-keychain"; @@ -34,7 +35,7 @@ export default defineExecutorConfig({ [ openApiHttpPlugin({ presets: [...googleCatalog, ...microsoftCatalog], - specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter], + specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter, slackWebApiAdapter], }), mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }), graphqlHttpPlugin(), diff --git a/packages/plugins/openapi/package.json b/packages/plugins/openapi/package.json index f9f527f0c9..ffb36b031e 100644 --- a/packages/plugins/openapi/package.json +++ b/packages/plugins/openapi/package.json @@ -24,6 +24,7 @@ "./presets": "./src/sdk/presets.ts", "./providers/google": "./src/providers/google/index.ts", "./providers/microsoft": "./src/providers/microsoft/index.ts", + "./providers/slack": "./src/providers/slack/index.ts", "./testing": "./src/testing/index.ts" }, "publishConfig": { @@ -59,6 +60,12 @@ "default": "./dist/providers/microsoft.js" } }, + "./providers/slack": { + "import": { + "types": "./dist/providers/slack/index.d.ts", + "default": "./dist/providers/slack.js" + } + }, "./testing": { "import": { "types": "./dist/testing/index.d.ts", diff --git a/packages/plugins/openapi/src/providers/slack/index.ts b/packages/plugins/openapi/src/providers/slack/index.ts new file mode 100644 index 0000000000..4223051b17 --- /dev/null +++ b/packages/plugins/openapi/src/providers/slack/index.ts @@ -0,0 +1 @@ +export { slackWebApiAdapter, stripSlackTokenInputs } from "./spec-format-adapter"; diff --git a/packages/plugins/openapi/src/providers/slack/spec-format-adapter.test.ts b/packages/plugins/openapi/src/providers/slack/spec-format-adapter.test.ts new file mode 100644 index 0000000000..d8dafcc833 --- /dev/null +++ b/packages/plugins/openapi/src/providers/slack/spec-format-adapter.test.ts @@ -0,0 +1,134 @@ +import { expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { parseSpecObject } from "../../sdk/parse"; +import { slackWebApiAdapter } from "./spec-format-adapter"; + +const SLACK_SPEC_URL = "https://api.apis.guru/v2/specs/slack.com/1.7.0/openapi.json"; + +const slackFixture = { + openapi: "3.0.0", + info: { title: "Slack Web API", version: "1.7.0" }, + servers: [{ url: "https://slack.com/api" }], + paths: { + "/auth.test": { + get: { + operationId: "auth_test", + parameters: [ + { + name: "token", + in: "query", + required: true, + schema: { type: "string" }, + description: "Authentication token. Requires scope: `none`", + }, + ], + responses: { "200": { description: "OK" } }, + }, + }, + "/conversations.history": { + get: { + operationId: "conversations_history", + parameters: [ + { name: "token", in: "query", schema: { type: "string" } }, + { name: "channel", in: "query", schema: { type: "string" } }, + ], + responses: { "200": { description: "OK" } }, + }, + }, + "/chat.postMessage": { + post: { + operationId: "chat_postMessage", + parameters: [{ name: "token", in: "header", required: true, schema: { type: "string" } }], + requestBody: { + content: { + "application/x-www-form-urlencoded": { + schema: { + type: "object", + required: ["channel", "token"], + properties: { channel: { type: "string" }, token: { type: "string" } }, + }, + }, + }, + }, + responses: { "200": { description: "OK" } }, + }, + }, + "/files.upload": { + post: { + operationId: "files_upload", + requestBody: { + content: { + "multipart/form-data": { + schema: { + type: "object", + required: ["token"], + properties: { token: { type: "string" }, content: { type: "string" } }, + }, + }, + }, + }, + responses: { "200": { description: "OK" } }, + }, + }, + }, +}; + +const slackHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url === SLACK_SPEC_URL + ? new Response(JSON.stringify(slackFixture), { status: 200 }) + : new Response("not found", { status: 404 }), + ), + ), + ), +); + +it("recognizes the Slack Web API spec URLs", () => { + expect(slackWebApiAdapter.detectsUrl?.(SLACK_SPEC_URL)).toBe(true); + expect( + slackWebApiAdapter.detectsUrl?.( + "https://raw.githubusercontent.com/slackapi/slack-api-specs/master/web-api/slack_web_openapi_v2.json", + ), + ).toBe(true); + expect( + slackWebApiAdapter.detectsUrl?.( + "https://api.apis.guru/v2/specs/stripe.com/2022-11-15/openapi.json", + ), + ).toBe(false); +}); + +it.effect("removes the token input so the connection's credential is the only one sent", () => + Effect.gen(function* () { + const converted = yield* slackWebApiAdapter.fetch({ + urls: [SLACK_SPEC_URL], + httpClientLayer: slackHttpClientLayer, + }); + const spec: any = yield* parseSpecObject(converted.specText); + + expect(spec.paths["/auth.test"].get.parameters).toEqual([]); + expect(spec.paths["/conversations.history"].get.parameters).toEqual([ + { name: "channel", in: "query", schema: { type: "string" } }, + ]); + expect(spec.paths["/chat.postMessage"].post.parameters).toEqual([]); + expect( + spec.paths["/chat.postMessage"].post.requestBody.content["application/x-www-form-urlencoded"] + .schema, + ).toEqual({ + type: "object", + required: ["channel"], + properties: { channel: { type: "string" } }, + }); + expect( + spec.paths["/files.upload"].post.requestBody.content["multipart/form-data"].schema, + ).toEqual({ + type: "object", + properties: { content: { type: "string" } }, + }); + expect(converted.specUrl).toBe(SLACK_SPEC_URL); + }), +); diff --git a/packages/plugins/openapi/src/providers/slack/spec-format-adapter.ts b/packages/plugins/openapi/src/providers/slack/spec-format-adapter.ts new file mode 100644 index 0000000000..eb5d303b52 --- /dev/null +++ b/packages/plugins/openapi/src/providers/slack/spec-format-adapter.ts @@ -0,0 +1,106 @@ +import { Effect, Schema } from "effect"; + +import { parseSpecObject, resolveSpecText } from "../../sdk/parse"; +import type { SpecFormatAdapter } from "../../sdk/spec-format"; + +const SLACK_SPEC_URL_PATTERNS = [ + /^https:\/\/api\.apis\.guru\/v2\/specs\/slack\.com\//, + /^https:\/\/raw\.githubusercontent\.com\/slackapi\/slack-api-specs\//, +]; + +const encodeJsonText = Schema.encodeUnknownSync(Schema.UnknownFromJsonString); + +const TOKEN_INPUT = "token"; + +const HTTP_METHODS = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]); + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +const detectsSlackSpecUrl = (url: string): boolean => + SLACK_SPEC_URL_PATTERNS.some((pattern) => pattern.test(url.trim())); + +function withoutTokenParameter(parameters: unknown): unknown { + if (!Array.isArray(parameters)) return parameters; + return parameters.filter((parameter) => !isRecord(parameter) || parameter.name !== TOKEN_INPUT); +} + +function withoutTokenProperty(schema: unknown): unknown { + if (!isRecord(schema) || !isRecord(schema.properties) || !(TOKEN_INPUT in schema.properties)) { + return schema; + } + const { [TOKEN_INPUT]: _token, ...properties } = schema.properties; + const { required, ...rest } = schema; + const remainingRequired = Array.isArray(required) + ? required.filter((name) => name !== TOKEN_INPUT) + : []; + return { + ...rest, + ...(remainingRequired.length > 0 ? { required: remainingRequired } : {}), + properties, + }; +} + +function withoutTokenBody(requestBody: unknown): unknown { + if (!isRecord(requestBody) || !isRecord(requestBody.content)) return requestBody; + const content = Object.fromEntries( + Object.entries(requestBody.content).map(([mediaType, media]) => [ + mediaType, + isRecord(media) ? { ...media, schema: withoutTokenProperty(media.schema) } : media, + ]), + ); + return { ...requestBody, content }; +} + +function withoutTokenOperation(operation: unknown): unknown { + if (!isRecord(operation)) return operation; + return { + ...operation, + ...("parameters" in operation + ? { parameters: withoutTokenParameter(operation.parameters) } + : {}), + ...("requestBody" in operation ? { requestBody: withoutTokenBody(operation.requestBody) } : {}), + }; +} + +/** + * The published Slack specs predate header auth and declare the credential as + * an ordinary `token` input on every operation. Left in, each tool asks the + * caller for the token and sends it in the query or body, where Slack rejects + * it for current apps. Dropping it leaves the connection's credential, sent as + * an `Authorization` header, as the only one. + */ +export function stripSlackTokenInputs(document: unknown): unknown { + if (!isRecord(document) || !isRecord(document.paths)) return document; + const paths = Object.fromEntries( + Object.entries(document.paths).map(([path, pathItem]) => { + if (!isRecord(pathItem)) return [path, pathItem]; + const cleaned = Object.fromEntries( + Object.entries(pathItem).map(([key, value]) => [ + key, + key === "parameters" + ? withoutTokenParameter(value) + : HTTP_METHODS.has(key) + ? withoutTokenOperation(value) + : value, + ]), + ); + return [path, cleaned]; + }), + ); + return { ...document, paths }; +} + +export const slackWebApiAdapter: SpecFormatAdapter = { + id: "slack-web-api", + detectsUrl: detectsSlackSpecUrl, + fetch: (input) => + Effect.gen(function* () { + const url = input.urls[0]!; + const specText = yield* resolveSpecText(url, input.credentials).pipe( + Effect.provide(input.httpClientLayer), + ); + const document = yield* parseSpecObject(specText); + return { specText: encodeJsonText(stripSlackTokenInputs(document)), specUrl: url }; + }), +}; diff --git a/packages/plugins/openapi/tsup.config.ts b/packages/plugins/openapi/tsup.config.ts index 3714f86c14..f84d51829a 100644 --- a/packages/plugins/openapi/tsup.config.ts +++ b/packages/plugins/openapi/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ client: "src/react/plugin-client.tsx", "providers/google": "src/providers/google/index.ts", "providers/microsoft": "src/providers/microsoft/index.ts", + "providers/slack": "src/providers/slack/index.ts", testing: "src/testing/index.ts", }, format: ["esm"],