diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index e3b7a6857..01f5ad7bc 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -52,6 +52,7 @@ import { invokeMcpTool, isUnknownToolMessage } from "./invoke"; import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest"; import { mcpPresets } from "./presets"; import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; +import { recoverSlackConnectFile } from "./slack-connect-file"; import { McpAuthMethodInput, McpAuthShorthand, @@ -1296,12 +1297,13 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { } } + const invokeHttpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer; const connectorInput = yield* buildConnectorInput( parsed, credential.values, String(credential.template), allowStdio, - options?.httpClientLayer ?? ctx.httpClientLayer, + invokeHttpClientLayer, ); const connector: McpConnector = createMcpConnector(connectorInput); const poolKey = @@ -1353,6 +1355,18 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { .markToolsStale(connectionRef) .pipe(Effect.ignore, Effect.as(unknownToolFailure(String(toolRow.name), credential))); } + if (parsed.transport === "remote") { + const recoveredSlackConnectFile = yield* recoverSlackConnectFile({ + endpoint: parsed.endpoint, + toolName: stamp.toolName, + args, + accessToken: credential.values[TOKEN_VARIABLE], + upstreamErrorMessage: errorMessage, + }).pipe(Effect.provide(invokeHttpClientLayer)); + if (Option.isSome(recoveredSlackConnectFile)) { + return ToolResult.ok(recoveredSlackConnectFile.value); + } + } return ToolResult.fail({ code: "mcp_tool_error", message: errorMessage, diff --git a/packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts b/packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts new file mode 100644 index 000000000..696fb12f5 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option, Schema } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + ToolAddress, + createExecutor, +} from "@executor-js/sdk"; +import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; + +import { mcpPlugin } from "./plugin"; + +const FILE_ID = "F012ABC3456"; +const IMAGE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); +const seenRpcMethods: string[] = []; + +const JsonRpcRequest = Schema.Struct({ + id: Schema.optional(Schema.Union([Schema.String, Schema.Number, Schema.Null])), + method: Schema.String, +}); +const decodeJsonRpcRequest = Schema.decodeUnknownOption(Schema.fromJsonString(JsonRpcRequest)); + +const jsonRpcResponse = (request: typeof JsonRpcRequest.Type, result: unknown): Response => + Response.json({ jsonrpc: "2.0", id: request.id ?? null, result }); + +const slackFallbackHttpClientLayer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => + Effect.gen(function* () { + const webRequest = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie); + const url = new URL(webRequest.url); + + if (url.hostname === "slack.com" && url.pathname === "/api/files.info") { + return HttpClientResponse.fromWeb( + request, + Response.json({ + ok: true, + file: { + id: FILE_ID, + name: "external-screenshot.png", + mimetype: "image/png", + size: IMAGE_BYTES.byteLength, + url_private_download: `https://files.slack.com/files-pri/T000-${FILE_ID}/download/external-screenshot.png`, + }, + }), + ); + } + + if (url.hostname === "files.slack.com") { + return HttpClientResponse.fromWeb( + request, + new Response(IMAGE_BYTES, { status: 200, headers: { "content-type": "image/png" } }), + ); + } + + if (url.hostname !== "mcp.slack.com") { + return HttpClientResponse.fromWeb( + request, + new Response("unexpected host", { status: 500 }), + ); + } + if (webRequest.method === "GET") { + return HttpClientResponse.fromWeb(request, new Response("SSE disabled", { status: 405 })); + } + + const rpc = Option.getOrUndefined( + decodeJsonRpcRequest(yield* Effect.promise(() => webRequest.text())), + ); + if (rpc === undefined) { + return HttpClientResponse.fromWeb( + request, + new Response("invalid JSON-RPC", { status: 400 }), + ); + } + seenRpcMethods.push(rpc.method); + if (rpc.method === "initialize") { + return HttpClientResponse.fromWeb( + request, + jsonRpcResponse(rpc, { + protocolVersion: "2025-06-18", + capabilities: { tools: {} }, + serverInfo: { name: "Slack", version: "1.0.0" }, + }), + ); + } + if (rpc.method === "notifications/initialized") { + return HttpClientResponse.fromWeb(request, new Response("", { status: 202 })); + } + if (rpc.method === "tools/list") { + return HttpClientResponse.fromWeb( + request, + jsonRpcResponse(rpc, { + tools: [ + { + name: "slack_read_file", + inputSchema: { + type: "object", + properties: { file_id: { type: "string" } }, + required: ["file_id"], + }, + }, + ], + }), + ); + } + if (rpc.method === "tools/call") { + return HttpClientResponse.fromWeb( + request, + jsonRpcResponse(rpc, { + isError: true, + content: [{ type: "text", text: "execution_failed: file_not_found" }], + }), + ); + } + return HttpClientResponse.fromWeb( + request, + new Response("unexpected method", { status: 400 }), + ); + }), + ), +); + +describe("Slack Connect file fallback", () => { + it.effect("recovers the image through the caller-visible MCP tool", () => + Effect.scoped( + Effect.gen(function* () { + const config = { + ...makeTestConfig({ + plugins: [ + memoryCredentialsPlugin(), + mcpPlugin({ httpClientLayer: slackFallbackHttpClientLayer }), + ] as const, + }), + httpClientLayer: slackFallbackHttpClientLayer, + }; + const executor = yield* Effect.acquireRelease(createExecutor(config), (executor) => + Effect.gen(function* () { + yield* executor.close().pipe(Effect.ignore); + yield* Effect.promise(() => config.testDb.close()).pipe(Effect.ignore); + }), + ); + + yield* executor.mcp.addServer({ + name: "Slack", + endpoint: "https://mcp.slack.com/mcp", + slug: "slack_connect_fixture", + remoteTransport: "streamable-http", + auth: { kind: "oauth2" }, + }); + yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("main"), + integration: IntegrationSlug.make("slack_connect_fixture"), + template: AuthTemplateSlug.make("oauth2"), + value: "xoxp-test-token", + }); + + const toolAddresses = (yield* executor.tools.list()).map((tool) => String(tool.address)); + expect(seenRpcMethods).toContain("tools/list"); + expect(toolAddresses).toContain("tools.slack_connect_fixture.org.main.slack_read_file"); + + const result = yield* executor.execute( + ToolAddress.make("tools.slack_connect_fixture.org.main.slack_read_file"), + { file_id: FILE_ID }, + { onElicitation: "accept-all" }, + ); + + expect(result).toMatchObject({ + ok: true, + data: { + content: [ + { type: "text", text: expect.stringContaining(FILE_ID) }, + { type: "image", mimeType: "image/png" }, + ], + }, + }); + }), + ), + ); +}); diff --git a/packages/plugins/mcp/src/sdk/slack-connect-file.test.ts b/packages/plugins/mcp/src/sdk/slack-connect-file.test.ts new file mode 100644 index 000000000..5eab9dc11 --- /dev/null +++ b/packages/plugins/mcp/src/sdk/slack-connect-file.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Encoding, Layer, Option } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { recoverSlackConnectFile } from "./slack-connect-file"; + +const ACCESS_TOKEN = "xoxp-test-token"; +const FILE_ID = "F012ABC3456"; +const IMAGE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + +const httpClientLayer = ( + respond: (request: Request) => Response, +): Layer.Layer => + Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + const url = new URL(request.url); + for (const [name, value] of request.urlParams) url.searchParams.append(name, value); + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + respond(new Request(url, { method: request.method, headers: request.headers })), + ), + ); + }), + ); + +const recover = ( + layer: Layer.Layer, + overrides: Partial[0]> = {}, +) => + recoverSlackConnectFile({ + endpoint: "https://mcp.slack.com/mcp", + toolName: "slack_read_file", + args: { file_id: FILE_ID }, + accessToken: ACCESS_TOKEN, + upstreamErrorMessage: "execution_failed: file_not_found", + ...overrides, + }).pipe(Effect.provide(layer)); + +describe("recoverSlackConnectFile", () => { + it.effect("resolves and downloads a Slack Connect image with the existing OAuth token", () => + Effect.gen(function* () { + const requests: Request[] = []; + const layer = httpClientLayer((request) => { + requests.push(request); + const url = new URL(request.url); + if (url.hostname === "slack.com") { + return Response.json({ + ok: true, + file: { + id: FILE_ID, + name: "screenshot.png", + title: "screenshot.png", + mimetype: "image/png", + size: IMAGE_BYTES.byteLength, + url_private_download: `https://files.slack.com/files-pri/T000-${FILE_ID}/download/screenshot.png`, + }, + }); + } + return new Response(IMAGE_BYTES, { + status: 200, + headers: { "content-type": "image/png" }, + }); + }); + + const result = yield* recover(layer); + + expect(Option.isSome(result)).toBe(true); + const recovered = Option.getOrThrow(result); + expect(recovered.content).toEqual([ + { + type: "text", + text: `File ID: ${FILE_ID}\nTitle: screenshot.png\nMIME Type: image/png\nSize: 8 bytes\n`, + }, + { + type: "image", + data: Encoding.encodeBase64(IMAGE_BYTES), + mimeType: "image/png", + }, + ]); + expect(requests).toHaveLength(2); + expect(requests.map((request) => new URL(request.url).searchParams.get("file"))).toEqual([ + FILE_ID, + null, + ]); + expect(requests.map((request) => request.headers.get("authorization"))).toEqual([ + `Bearer ${ACCESS_TOKEN}`, + `Bearer ${ACCESS_TOKEN}`, + ]); + }), + ); + + it.effect("does not call Slack for unrelated MCP failures", () => + Effect.gen(function* () { + let requestCount = 0; + const layer = httpClientLayer(() => { + requestCount += 1; + return new Response("unexpected", { status: 500 }); + }); + + const results = yield* Effect.all([ + recover(layer, { endpoint: "https://example.com/mcp" }), + recover(layer, { toolName: "another_tool" }), + recover(layer, { upstreamErrorMessage: "execution_failed: permission_denied" }), + recover(layer, { accessToken: null }), + recover(layer, { args: { file_id: "../not-a-file-id" } }), + ]); + + expect(results.every(Option.isNone)).toBe(true); + expect(requestCount).toBe(0); + }), + ); + + it.effect("rejects non-image and untrusted download responses", () => + Effect.gen(function* () { + const nonImage = yield* recover( + httpClientLayer(() => + Response.json({ + ok: true, + file: { + id: FILE_ID, + name: "notes.txt", + mimetype: "text/plain", + size: 10, + url_private_download: "https://files.slack.com/files-pri/file", + }, + }), + ), + ); + const untrusted = yield* recover( + httpClientLayer(() => + Response.json({ + ok: true, + file: { + id: FILE_ID, + name: "screenshot.png", + mimetype: "image/png", + size: 10, + url_private_download: "https://example.com/screenshot.png", + }, + }), + ), + ); + let requestCount = 0; + const wrongResponseType = yield* recover( + httpClientLayer(() => { + requestCount += 1; + return requestCount === 1 + ? Response.json({ + ok: true, + file: { + id: FILE_ID, + name: "screenshot.png", + mimetype: "image/png", + size: 10, + url_private_download: "https://files.slack.com/files-pri/file", + }, + }) + : new Response("not an image", { + status: 200, + headers: { "content-type": "text/html" }, + }); + }), + ); + + expect(Option.isNone(nonImage)).toBe(true); + expect(Option.isNone(untrusted)).toBe(true); + expect(Option.isNone(wrongResponseType)).toBe(true); + }), + ); +}); diff --git a/packages/plugins/mcp/src/sdk/slack-connect-file.ts b/packages/plugins/mcp/src/sdk/slack-connect-file.ts new file mode 100644 index 000000000..f7159569e --- /dev/null +++ b/packages/plugins/mcp/src/sdk/slack-connect-file.ts @@ -0,0 +1,153 @@ +import { Effect, Encoding, Option, Schema } from "effect"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +const SLACK_MCP_HOST = "mcp.slack.com"; +const SLACK_MCP_PATH = "/mcp"; +const SLACK_FILE_INFO_URL = "https://slack.com/api/files.info"; +const SLACK_FILE_HOST = "files.slack.com"; +const MAX_SLACK_FILE_BYTES = 10 * 1024 * 1024; + +const SlackReadFileArgs = Schema.Struct({ file_id: Schema.String }); +const decodeSlackReadFileArgs = Schema.decodeUnknownOption(SlackReadFileArgs); + +const SlackFileInfo = Schema.Struct({ + id: Schema.String, + name: Schema.optional(Schema.String), + title: Schema.optional(Schema.String), + mimetype: Schema.String, + size: Schema.Number, + url_private: Schema.optional(Schema.String), + url_private_download: Schema.optional(Schema.String), +}); + +const SlackFileInfoSuccess = Schema.Struct({ + ok: Schema.Literal(true), + file: SlackFileInfo, +}); +const decodeSlackFileInfoSuccess = Schema.decodeUnknownOption(SlackFileInfoSuccess); + +const parseUrl = Option.liftThrowable((value: string) => new URL(value)); + +const isSlackMcpEndpoint = (value: string): boolean => + Option.match(parseUrl(value), { + onNone: () => false, + onSome: (url) => + url.protocol === "https:" && + url.hostname === SLACK_MCP_HOST && + url.pathname.replace(/\/$/, "") === SLACK_MCP_PATH, + }); + +const trustedSlackFileUrl = (value: string): URL | null => + Option.match(parseUrl(value), { + onNone: () => null, + onSome: (url) => (url.protocol === "https:" && url.hostname === SLACK_FILE_HOST ? url : null), + }); + +const bearerHeaders = (accessToken: string): Readonly> => ({ + Authorization: `Bearer ${accessToken}`, +}); + +const safeTitle = (value: string): string => value.replace(/[\r\n]/g, " "); + +type SlackImageToolResult = { + readonly content: readonly [ + { readonly type: "text"; readonly text: string }, + { readonly type: "image"; readonly data: string; readonly mimeType: string }, + ]; +}; + +interface RecoverSlackConnectFileInput { + readonly endpoint: string; + readonly toolName: string; + readonly args: unknown; + readonly accessToken: string | null; + readonly upstreamErrorMessage: string; +} + +/** + * Recovers Slack Connect images that Slack's hosted MCP server reports as + * `file_not_found` by resolving the file through Slack's Web API with the same + * user OAuth grant. Returns `None` for non-Slack calls and for any fallback + * failure so the caller can preserve the original upstream error. + */ +export const recoverSlackConnectFile = ( + input: RecoverSlackConnectFileInput, +): Effect.Effect, never, HttpClient.HttpClient> => { + if ( + input.toolName !== "slack_read_file" || + !input.upstreamErrorMessage.includes("file_not_found") || + !isSlackMcpEndpoint(input.endpoint) || + input.accessToken === null || + input.accessToken.length === 0 + ) { + return Effect.succeed(Option.none()); + } + const accessToken = input.accessToken; + + const args = Option.getOrUndefined(decodeSlackReadFileArgs(input.args)); + const fileId = args?.file_id.trim(); + if (fileId === undefined || !/^F[A-Z0-9]+$/.test(fileId)) { + return Effect.succeed(Option.none()); + } + + return Effect.gen(function* () { + const client = yield* HttpClient.HttpClient; + const infoUrl = new URL(SLACK_FILE_INFO_URL); + infoUrl.searchParams.set("file", fileId); + + const infoResponse = yield* client.execute( + HttpClientRequest.get(infoUrl, { headers: bearerHeaders(accessToken) }), + ); + if (infoResponse.status < 200 || infoResponse.status >= 300) return Option.none(); + + const info = Option.getOrUndefined(decodeSlackFileInfoSuccess(yield* infoResponse.json)); + if ( + info === undefined || + !info.file.mimetype.startsWith("image/") || + !Number.isSafeInteger(info.file.size) || + info.file.size < 0 || + info.file.size > MAX_SLACK_FILE_BYTES + ) { + return Option.none(); + } + + const downloadUrl = trustedSlackFileUrl( + info.file.url_private_download ?? info.file.url_private ?? "", + ); + if (downloadUrl === null) return Option.none(); + + const downloadResponse = yield* client.execute( + HttpClientRequest.get(downloadUrl, { headers: bearerHeaders(accessToken) }), + ); + if (downloadResponse.status < 200 || downloadResponse.status >= 300) return Option.none(); + const responseMimeType = downloadResponse.headers["content-type"] + ?.split(";", 1)[0] + ?.trim() + .toLowerCase(); + if (responseMimeType !== undefined && !responseMimeType.startsWith("image/")) { + return Option.none(); + } + + const bytes = new Uint8Array(yield* downloadResponse.arrayBuffer); + if (bytes.byteLength > MAX_SLACK_FILE_BYTES) return Option.none(); + + const title = safeTitle(info.file.title ?? info.file.name ?? info.file.id); + const recovered: SlackImageToolResult = { + content: [ + { + type: "text", + text: `File ID: ${info.file.id}\nTitle: ${title}\nMIME Type: ${info.file.mimetype}\nSize: ${info.file.size} bytes\n`, + }, + { + type: "image", + data: Encoding.encodeBase64(bytes), + mimeType: info.file.mimetype, + }, + ], + }; + return Option.some(recovered); + }).pipe( + Effect.catch(() => Effect.succeed(Option.none())), + Effect.withSpan("plugin.mcp.slack_connect_file_fallback"), + ); +};