History

Fix Slack Connect image reads (#1705)

Rhys Sullivan committed 2 months ago

Commit e9920c31 parent 2bdbedf4 files · +521 −1Browse files

Unified · Split

4 files
  1. packages/plugins/mcp/src/sdk/plugin.ts +15 −1
  2. packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts +182 −0
  3. packages/plugins/mcp/src/sdk/slack-connect-file.test.ts +171 −0
  4. packages/plugins/mcp/src/sdk/slack-connect-file.ts +153 −0
packages/plugins/mcp/src/sdk/plugin.ts+15−1
@@ -52,6 +52,7 @@ import { invokeMcpTool, isUnknownToolMessage } from "./invoke"; · 51 unmodified lines
52import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest";52import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest";
53import { mcpPresets } from "./presets";53import { mcpPresets } from "./presets";
54import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape";54import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape";
55import { recoverSlackConnectFile } from "./slack-connect-file";
55import {56import {
56 McpAuthMethodInput,57 McpAuthMethodInput,
57 McpAuthShorthand,58 McpAuthShorthand,
@@ -1296,12 +1297,13 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 1238 unmodified lines
1296 }1297 }
1297 }1298 }
1298 1299
1300 const invokeHttpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
1299 const connectorInput = yield* buildConnectorInput(1301 const connectorInput = yield* buildConnectorInput(
1300 parsed,1302 parsed,
1301 credential.values,1303 credential.values,
1302 String(credential.template),1304 String(credential.template),
1303 allowStdio,1305 allowStdio,
1304 options?.httpClientLayer ?? ctx.httpClientLayer,1306 invokeHttpClientLayer,
1305 );1307 );
1306 const connector: McpConnector = createMcpConnector(connectorInput);1308 const connector: McpConnector = createMcpConnector(connectorInput);
1307 const poolKey =1309 const poolKey =
@@ -1353,6 +1355,18 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 45 unmodified lines
1353 .markToolsStale(connectionRef)1355 .markToolsStale(connectionRef)
1354 .pipe(Effect.ignore, Effect.as(unknownToolFailure(String(toolRow.name), credential)));1356 .pipe(Effect.ignore, Effect.as(unknownToolFailure(String(toolRow.name), credential)));
1355 }1357 }
1358 if (parsed.transport === "remote") {
1359 const recoveredSlackConnectFile = yield* recoverSlackConnectFile({
1360 endpoint: parsed.endpoint,
1361 toolName: stamp.toolName,
1362 args,
1363 accessToken: credential.values[TOKEN_VARIABLE],
1364 upstreamErrorMessage: errorMessage,
1365 }).pipe(Effect.provide(invokeHttpClientLayer));
1366 if (Option.isSome(recoveredSlackConnectFile)) {
1367 return ToolResult.ok(recoveredSlackConnectFile.value);
1368 }
1369 }
1356 return ToolResult.fail({1370 return ToolResult.fail({
1357 code: "mcp_tool_error",1371 code: "mcp_tool_error",
1358 message: errorMessage,1372 message: errorMessage,
packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts+182−0
@@ -0,0 +1,182 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Layer, Option, Schema } from "effect";
3import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
5import {
6 AuthTemplateSlug,
7 ConnectionName,
8 IntegrationSlug,
9 ToolAddress,
10 createExecutor,
11} from "@executor-js/sdk";
12import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
13
14import { mcpPlugin } from "./plugin";
15
16const FILE_ID = "F012ABC3456";
17const IMAGE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
18const seenRpcMethods: string[] = [];
19
20const JsonRpcRequest = Schema.Struct({
21 id: Schema.optional(Schema.Union([Schema.String, Schema.Number, Schema.Null])),
22 method: Schema.String,
23});
24const decodeJsonRpcRequest = Schema.decodeUnknownOption(Schema.fromJsonString(JsonRpcRequest));
25
26const jsonRpcResponse = (request: typeof JsonRpcRequest.Type, result: unknown): Response =>
27 Response.json({ jsonrpc: "2.0", id: request.id ?? null, result });
28
29const slackFallbackHttpClientLayer = Layer.succeed(HttpClient.HttpClient)(
30 HttpClient.make((request: HttpClientRequest.HttpClientRequest) =>
31 Effect.gen(function* () {
32 const webRequest = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie);
33 const url = new URL(webRequest.url);
34
35 if (url.hostname === "slack.com" && url.pathname === "/api/files.info") {
36 return HttpClientResponse.fromWeb(
37 request,
38 Response.json({
39 ok: true,
40 file: {
41 id: FILE_ID,
42 name: "external-screenshot.png",
43 mimetype: "image/png",
44 size: IMAGE_BYTES.byteLength,
45 url_private_download: `https://files.slack.com/files-pri/T000-${FILE_ID}/download/external-screenshot.png`,
46 },
47 }),
48 );
49 }
50
51 if (url.hostname === "files.slack.com") {
52 return HttpClientResponse.fromWeb(
53 request,
54 new Response(IMAGE_BYTES, { status: 200, headers: { "content-type": "image/png" } }),
55 );
56 }
57
58 if (url.hostname !== "mcp.slack.com") {
59 return HttpClientResponse.fromWeb(
60 request,
61 new Response("unexpected host", { status: 500 }),
62 );
63 }
64 if (webRequest.method === "GET") {
65 return HttpClientResponse.fromWeb(request, new Response("SSE disabled", { status: 405 }));
66 }
67
68 const rpc = Option.getOrUndefined(
69 decodeJsonRpcRequest(yield* Effect.promise(() => webRequest.text())),
70 );
71 if (rpc === undefined) {
72 return HttpClientResponse.fromWeb(
73 request,
74 new Response("invalid JSON-RPC", { status: 400 }),
75 );
76 }
77 seenRpcMethods.push(rpc.method);
78 if (rpc.method === "initialize") {
79 return HttpClientResponse.fromWeb(
80 request,
81 jsonRpcResponse(rpc, {
82 protocolVersion: "2025-06-18",
83 capabilities: { tools: {} },
84 serverInfo: { name: "Slack", version: "1.0.0" },
85 }),
86 );
87 }
88 if (rpc.method === "notifications/initialized") {
89 return HttpClientResponse.fromWeb(request, new Response("", { status: 202 }));
90 }
91 if (rpc.method === "tools/list") {
92 return HttpClientResponse.fromWeb(
93 request,
94 jsonRpcResponse(rpc, {
95 tools: [
96 {
97 name: "slack_read_file",
98 inputSchema: {
99 type: "object",
100 properties: { file_id: { type: "string" } },
101 required: ["file_id"],
102 },
103 },
104 ],
105 }),
106 );
107 }
108 if (rpc.method === "tools/call") {
109 return HttpClientResponse.fromWeb(
110 request,
111 jsonRpcResponse(rpc, {
112 isError: true,
113 content: [{ type: "text", text: "execution_failed: file_not_found" }],
114 }),
115 );
116 }
117 return HttpClientResponse.fromWeb(
118 request,
119 new Response("unexpected method", { status: 400 }),
120 );
121 }),
122 ),
123);
124
125describe("Slack Connect file fallback", () => {
126 it.effect("recovers the image through the caller-visible MCP tool", () =>
127 Effect.scoped(
128 Effect.gen(function* () {
129 const config = {
130 ...makeTestConfig({
131 plugins: [
132 memoryCredentialsPlugin(),
133 mcpPlugin({ httpClientLayer: slackFallbackHttpClientLayer }),
134 ] as const,
135 }),
136 httpClientLayer: slackFallbackHttpClientLayer,
137 };
138 const executor = yield* Effect.acquireRelease(createExecutor(config), (executor) =>
139 Effect.gen(function* () {
140 yield* executor.close().pipe(Effect.ignore);
141 yield* Effect.promise(() => config.testDb.close()).pipe(Effect.ignore);
142 }),
143 );
144
145 yield* executor.mcp.addServer({
146 name: "Slack",
147 endpoint: "https://mcp.slack.com/mcp",
148 slug: "slack_connect_fixture",
149 remoteTransport: "streamable-http",
150 auth: { kind: "oauth2" },
151 });
152 yield* executor.connections.create({
153 owner: "org",
154 name: ConnectionName.make("main"),
155 integration: IntegrationSlug.make("slack_connect_fixture"),
156 template: AuthTemplateSlug.make("oauth2"),
157 value: "xoxp-test-token",
158 });
159
160 const toolAddresses = (yield* executor.tools.list()).map((tool) => String(tool.address));
161 expect(seenRpcMethods).toContain("tools/list");
162 expect(toolAddresses).toContain("tools.slack_connect_fixture.org.main.slack_read_file");
163
164 const result = yield* executor.execute(
165 ToolAddress.make("tools.slack_connect_fixture.org.main.slack_read_file"),
166 { file_id: FILE_ID },
167 { onElicitation: "accept-all" },
168 );
169
170 expect(result).toMatchObject({
171 ok: true,
172 data: {
173 content: [
174 { type: "text", text: expect.stringContaining(FILE_ID) },
175 { type: "image", mimeType: "image/png" },
176 ],
177 },
178 });
179 }),
180 ),
181 );
182});
packages/plugins/mcp/src/sdk/slack-connect-file.test.ts+171−0
@@ -0,0 +1,171 @@
1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Encoding, Layer, Option } from "effect";
3import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4
5import { recoverSlackConnectFile } from "./slack-connect-file";
6
7const ACCESS_TOKEN = "xoxp-test-token";
8const FILE_ID = "F012ABC3456";
9const IMAGE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
10
11const httpClientLayer = (
12 respond: (request: Request) => Response,
13): Layer.Layer<HttpClient.HttpClient> =>
14 Layer.succeed(HttpClient.HttpClient)(
15 HttpClient.make((request: HttpClientRequest.HttpClientRequest) => {
16 const url = new URL(request.url);
17 for (const [name, value] of request.urlParams) url.searchParams.append(name, value);
18 return Effect.succeed(
19 HttpClientResponse.fromWeb(
20 request,
21 respond(new Request(url, { method: request.method, headers: request.headers })),
22 ),
23 );
24 }),
25 );
26
27const recover = (
28 layer: Layer.Layer<HttpClient.HttpClient>,
29 overrides: Partial<Parameters<typeof recoverSlackConnectFile>[0]> = {},
30) =>
31 recoverSlackConnectFile({
32 endpoint: "https://mcp.slack.com/mcp",
33 toolName: "slack_read_file",
34 args: { file_id: FILE_ID },
35 accessToken: ACCESS_TOKEN,
36 upstreamErrorMessage: "execution_failed: file_not_found",
37 ...overrides,
38 }).pipe(Effect.provide(layer));
39
40describe("recoverSlackConnectFile", () => {
41 it.effect("resolves and downloads a Slack Connect image with the existing OAuth token", () =>
42 Effect.gen(function* () {
43 const requests: Request[] = [];
44 const layer = httpClientLayer((request) => {
45 requests.push(request);
46 const url = new URL(request.url);
47 if (url.hostname === "slack.com") {
48 return Response.json({
49 ok: true,
50 file: {
51 id: FILE_ID,
52 name: "screenshot.png",
53 title: "screenshot.png",
54 mimetype: "image/png",
55 size: IMAGE_BYTES.byteLength,
56 url_private_download: `https://files.slack.com/files-pri/T000-${FILE_ID}/download/screenshot.png`,
57 },
58 });
59 }
60 return new Response(IMAGE_BYTES, {
61 status: 200,
62 headers: { "content-type": "image/png" },
63 });
64 });
65
66 const result = yield* recover(layer);
67
68 expect(Option.isSome(result)).toBe(true);
69 const recovered = Option.getOrThrow(result);
70 expect(recovered.content).toEqual([
71 {
72 type: "text",
73 text: `File ID: ${FILE_ID}\nTitle: screenshot.png\nMIME Type: image/png\nSize: 8 bytes\n`,
74 },
75 {
76 type: "image",
77 data: Encoding.encodeBase64(IMAGE_BYTES),
78 mimeType: "image/png",
79 },
80 ]);
81 expect(requests).toHaveLength(2);
82 expect(requests.map((request) => new URL(request.url).searchParams.get("file"))).toEqual([
83 FILE_ID,
84 null,
85 ]);
86 expect(requests.map((request) => request.headers.get("authorization"))).toEqual([
87 `Bearer ${ACCESS_TOKEN}`,
88 `Bearer ${ACCESS_TOKEN}`,
89 ]);
90 }),
91 );
92
93 it.effect("does not call Slack for unrelated MCP failures", () =>
94 Effect.gen(function* () {
95 let requestCount = 0;
96 const layer = httpClientLayer(() => {
97 requestCount += 1;
98 return new Response("unexpected", { status: 500 });
99 });
100
101 const results = yield* Effect.all([
102 recover(layer, { endpoint: "https://example.com/mcp" }),
103 recover(layer, { toolName: "another_tool" }),
104 recover(layer, { upstreamErrorMessage: "execution_failed: permission_denied" }),
105 recover(layer, { accessToken: null }),
106 recover(layer, { args: { file_id: "../not-a-file-id" } }),
107 ]);
108
109 expect(results.every(Option.isNone)).toBe(true);
110 expect(requestCount).toBe(0);
111 }),
112 );
113
114 it.effect("rejects non-image and untrusted download responses", () =>
115 Effect.gen(function* () {
116 const nonImage = yield* recover(
117 httpClientLayer(() =>
118 Response.json({
119 ok: true,
120 file: {
121 id: FILE_ID,
122 name: "notes.txt",
123 mimetype: "text/plain",
124 size: 10,
125 url_private_download: "https://files.slack.com/files-pri/file",
126 },
127 }),
128 ),
129 );
130 const untrusted = yield* recover(
131 httpClientLayer(() =>
132 Response.json({
133 ok: true,
134 file: {
135 id: FILE_ID,
136 name: "screenshot.png",
137 mimetype: "image/png",
138 size: 10,
139 url_private_download: "https://example.com/screenshot.png",
140 },
141 }),
142 ),
143 );
144 let requestCount = 0;
145 const wrongResponseType = yield* recover(
146 httpClientLayer(() => {
147 requestCount += 1;
148 return requestCount === 1
149 ? Response.json({
150 ok: true,
151 file: {
152 id: FILE_ID,
153 name: "screenshot.png",
154 mimetype: "image/png",
155 size: 10,
156 url_private_download: "https://files.slack.com/files-pri/file",
157 },
158 })
159 : new Response("not an image", {
160 status: 200,
161 headers: { "content-type": "text/html" },
162 });
163 }),
164 );
165
166 expect(Option.isNone(nonImage)).toBe(true);
167 expect(Option.isNone(untrusted)).toBe(true);
168 expect(Option.isNone(wrongResponseType)).toBe(true);
169 }),
170 );
171});
packages/plugins/mcp/src/sdk/slack-connect-file.ts+153−0
@@ -0,0 +1,153 @@
1import { Effect, Encoding, Option, Schema } from "effect";
2import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3
4const SLACK_MCP_HOST = "mcp.slack.com";
5const SLACK_MCP_PATH = "/mcp";
6const SLACK_FILE_INFO_URL = "https://slack.com/api/files.info";
7const SLACK_FILE_HOST = "files.slack.com";
8const MAX_SLACK_FILE_BYTES = 10 * 1024 * 1024;
9
10const SlackReadFileArgs = Schema.Struct({ file_id: Schema.String });
11const decodeSlackReadFileArgs = Schema.decodeUnknownOption(SlackReadFileArgs);
12
13const SlackFileInfo = Schema.Struct({
14 id: Schema.String,
15 name: Schema.optional(Schema.String),
16 title: Schema.optional(Schema.String),
17 mimetype: Schema.String,
18 size: Schema.Number,
19 url_private: Schema.optional(Schema.String),
20 url_private_download: Schema.optional(Schema.String),
21});
22
23const SlackFileInfoSuccess = Schema.Struct({
24 ok: Schema.Literal(true),
25 file: SlackFileInfo,
26});
27const decodeSlackFileInfoSuccess = Schema.decodeUnknownOption(SlackFileInfoSuccess);
28
29const parseUrl = Option.liftThrowable((value: string) => new URL(value));
30
31const isSlackMcpEndpoint = (value: string): boolean =>
32 Option.match(parseUrl(value), {
33 onNone: () => false,
34 onSome: (url) =>
35 url.protocol === "https:" &&
36 url.hostname === SLACK_MCP_HOST &&
37 url.pathname.replace(/\/$/, "") === SLACK_MCP_PATH,
38 });
39
40const trustedSlackFileUrl = (value: string): URL | null =>
41 Option.match(parseUrl(value), {
42 onNone: () => null,
43 onSome: (url) => (url.protocol === "https:" && url.hostname === SLACK_FILE_HOST ? url : null),
44 });
45
46const bearerHeaders = (accessToken: string): Readonly<Record<string, string>> => ({
47 Authorization: `Bearer ${accessToken}`,
48});
49
50const safeTitle = (value: string): string => value.replace(/[\r\n]/g, " ");
51
52type SlackImageToolResult = {
53 readonly content: readonly [
54 { readonly type: "text"; readonly text: string },
55 { readonly type: "image"; readonly data: string; readonly mimeType: string },
56 ];
57};
58
59interface RecoverSlackConnectFileInput {
60 readonly endpoint: string;
61 readonly toolName: string;
62 readonly args: unknown;
63 readonly accessToken: string | null;
64 readonly upstreamErrorMessage: string;
65}
66
67/**
68 * Recovers Slack Connect images that Slack's hosted MCP server reports as
69 * `file_not_found` by resolving the file through Slack's Web API with the same
70 * user OAuth grant. Returns `None` for non-Slack calls and for any fallback
71 * failure so the caller can preserve the original upstream error.
72 */
73export const recoverSlackConnectFile = (
74 input: RecoverSlackConnectFileInput,
75): Effect.Effect<Option.Option<SlackImageToolResult>, never, HttpClient.HttpClient> => {
76 if (
77 input.toolName !== "slack_read_file" ||
78 !input.upstreamErrorMessage.includes("file_not_found") ||
79 !isSlackMcpEndpoint(input.endpoint) ||
80 input.accessToken === null ||
81 input.accessToken.length === 0
82 ) {
83 return Effect.succeed(Option.none());
84 }
85 const accessToken = input.accessToken;
86
87 const args = Option.getOrUndefined(decodeSlackReadFileArgs(input.args));
88 const fileId = args?.file_id.trim();
89 if (fileId === undefined || !/^F[A-Z0-9]+$/.test(fileId)) {
90 return Effect.succeed(Option.none());
91 }
92
93 return Effect.gen(function* () {
94 const client = yield* HttpClient.HttpClient;
95 const infoUrl = new URL(SLACK_FILE_INFO_URL);
96 infoUrl.searchParams.set("file", fileId);
97
98 const infoResponse = yield* client.execute(
99 HttpClientRequest.get(infoUrl, { headers: bearerHeaders(accessToken) }),
100 );
101 if (infoResponse.status < 200 || infoResponse.status >= 300) return Option.none();
102
103 const info = Option.getOrUndefined(decodeSlackFileInfoSuccess(yield* infoResponse.json));
104 if (
105 info === undefined ||
106 !info.file.mimetype.startsWith("image/") ||
107 !Number.isSafeInteger(info.file.size) ||
108 info.file.size < 0 ||
109 info.file.size > MAX_SLACK_FILE_BYTES
110 ) {
111 return Option.none();
112 }
113
114 const downloadUrl = trustedSlackFileUrl(
115 info.file.url_private_download ?? info.file.url_private ?? "",
116 );
117 if (downloadUrl === null) return Option.none();
118
119 const downloadResponse = yield* client.execute(
120 HttpClientRequest.get(downloadUrl, { headers: bearerHeaders(accessToken) }),
121 );
122 if (downloadResponse.status < 200 || downloadResponse.status >= 300) return Option.none();
123 const responseMimeType = downloadResponse.headers["content-type"]
124 ?.split(";", 1)[0]
125 ?.trim()
126 .toLowerCase();
127 if (responseMimeType !== undefined && !responseMimeType.startsWith("image/")) {
128 return Option.none();
129 }
130
131 const bytes = new Uint8Array(yield* downloadResponse.arrayBuffer);
132 if (bytes.byteLength > MAX_SLACK_FILE_BYTES) return Option.none();
133
134 const title = safeTitle(info.file.title ?? info.file.name ?? info.file.id);
135 const recovered: SlackImageToolResult = {
136 content: [
137 {
138 type: "text",
139 text: `File ID: ${info.file.id}\nTitle: ${title}\nMIME Type: ${info.file.mimetype}\nSize: ${info.file.size} bytes\n`,
140 },
141 {
142 type: "image",
143 data: Encoding.encodeBase64(bytes),
144 mimeType: info.file.mimetype,
145 },
146 ],
147 };
148 return Option.some(recovered);
149 }).pipe(
150 Effect.catch(() => Effect.succeed(Option.none())),
151 Effect.withSpan("plugin.mcp.slack_connect_file_fallback"),
152 );
153};