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
5252 import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest";
5353 import { mcpPresets } from "./presets";
5454 import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape";
55+import { recoverSlackConnectFile } from "./slack-connect-file";
5556 import {
5657 McpAuthMethodInput,
5758 McpAuthShorthand,
@@ -1296,12 +1297,13 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 1238 unmodified lines
12961297 }
12971298 }
12981299
1300+ const invokeHttpClientLayer = options?.httpClientLayer ?? ctx.httpClientLayer;
12991301 const connectorInput = yield* buildConnectorInput(
13001302 parsed,
13011303 credential.values,
13021304 String(credential.template),
13031305 allowStdio,
1304− options?.httpClientLayer ?? ctx.httpClientLayer,
1306+ invokeHttpClientLayer,
13051307 );
13061308 const connector: McpConnector = createMcpConnector(connectorInput);
13071309 const poolKey =
@@ -1353,6 +1355,18 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 45 unmodified lines
13531355 .markToolsStale(connectionRef)
13541356 .pipe(Effect.ignore, Effect.as(unknownToolFailure(String(toolRow.name), credential)));
13551357 }
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+ }
13561370 return ToolResult.fail({
13571371 code: "mcp_tool_error",
13581372 message: errorMessage,
packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts+182−0
@@ -0,0 +1,182 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Layer, Option, Schema } from "effect";
3+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4+
5+import {
6+ AuthTemplateSlug,
7+ ConnectionName,
8+ IntegrationSlug,
9+ ToolAddress,
10+ createExecutor,
11+} from "@executor-js/sdk";
12+import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing";
13+
14+import { mcpPlugin } from "./plugin";
15+
16+const FILE_ID = "F012ABC3456";
17+const IMAGE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
18+const seenRpcMethods: string[] = [];
19+
20+const JsonRpcRequest = Schema.Struct({
21+ id: Schema.optional(Schema.Union([Schema.String, Schema.Number, Schema.Null])),
22+ method: Schema.String,
23+});
24+const decodeJsonRpcRequest = Schema.decodeUnknownOption(Schema.fromJsonString(JsonRpcRequest));
25+
26+const jsonRpcResponse = (request: typeof JsonRpcRequest.Type, result: unknown): Response =>
27+ Response.json({ jsonrpc: "2.0", id: request.id ?? null, result });
28+
29+const 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+
125+describe("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 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Encoding, Layer, Option } from "effect";
3+import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
4+
5+import { recoverSlackConnectFile } from "./slack-connect-file";
6+
7+const ACCESS_TOKEN = "xoxp-test-token";
8+const FILE_ID = "F012ABC3456";
9+const IMAGE_BYTES = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
10+
11+const 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+
27+const 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+
40+describe("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 @@
1+import { Effect, Encoding, Option, Schema } from "effect";
2+import { HttpClient, HttpClientRequest } from "effect/unstable/http";
3+
4+const SLACK_MCP_HOST = "mcp.slack.com";
5+const SLACK_MCP_PATH = "/mcp";
6+const SLACK_FILE_INFO_URL = "https://slack.com/api/files.info";
7+const SLACK_FILE_HOST = "files.slack.com";
8+const MAX_SLACK_FILE_BYTES = 10 * 1024 * 1024;
9+
10+const SlackReadFileArgs = Schema.Struct({ file_id: Schema.String });
11+const decodeSlackReadFileArgs = Schema.decodeUnknownOption(SlackReadFileArgs);
12+
13+const 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+
23+const SlackFileInfoSuccess = Schema.Struct({
24+ ok: Schema.Literal(true),
25+ file: SlackFileInfo,
26+});
27+const decodeSlackFileInfoSuccess = Schema.decodeUnknownOption(SlackFileInfoSuccess);
28+
29+const parseUrl = Option.liftThrowable((value: string) => new URL(value));
30+
31+const 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+
40+const 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+
46+const bearerHeaders = (accessToken: string): Readonly<Record<string, string>> => ({
47+ Authorization: `Bearer ${accessToken}`,
48+});
49+
50+const safeTitle = (value: string): string => value.replace(/[\r\n]/g, " ");
51+
52+type 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+
59+interface 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+ */
73+export 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+};