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
3 changes: 2 additions & 1 deletion apps/cloud/executor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -55,7 +56,7 @@ export default defineExecutorConfig({
[
openApiHttpPlugin({
presets: [...googleCatalog, ...microsoftCatalog],
specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter, slackWebApiAdapter],
}),
mcpHttpPlugin({
dangerouslyAllowStdioMCP: false,
Expand Down
3 changes: 2 additions & 1 deletion apps/host-cloudflare/executor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -27,7 +28,7 @@ export default defineExecutorConfig({
[
openApiHttpPlugin({
presets: [...googleCatalog, ...microsoftCatalog],
specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter, slackWebApiAdapter],
}),
mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
graphqlHttpPlugin(),
Expand Down
3 changes: 2 additions & 1 deletion apps/host-cloudflare/src/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -30,7 +31,7 @@ export const makeCloudflarePlugins = (
[
openApiHttpPlugin({
presets: [...googleCatalog, ...microsoftCatalog],
specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter, slackWebApiAdapter],
}),
mcpHttpPlugin({ dangerouslyAllowStdioMCP: false }),
graphqlHttpPlugin(),
Expand Down
3 changes: 2 additions & 1 deletion apps/host-selfhost/executor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 2 additions & 1 deletion apps/local/executor.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -34,7 +35,7 @@ export default defineExecutorConfig({
[
openApiHttpPlugin({
presets: [...googleCatalog, ...microsoftCatalog],
specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter],
specFormats: [googleDiscoveryAdapter, microsoftGraphAdapter, slackWebApiAdapter],
}),
mcpHttpPlugin({ dangerouslyAllowStdioMCP: true }),
graphqlHttpPlugin(),
Expand Down
7 changes: 7 additions & 0 deletions packages/plugins/openapi/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/openapi/src/providers/slack/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { slackWebApiAdapter, stripSlackTokenInputs } from "./spec-format-adapter";
Original file line number Diff line number Diff line change
@@ -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);
}),
);
106 changes: 106 additions & 0 deletions packages/plugins/openapi/src/providers/slack/spec-format-adapter.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> =>
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 };
}),
};
1 change: 1 addition & 0 deletions packages/plugins/openapi/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading