Skip to content

Commit e9920c3

Browse files
authored
Fix Slack Connect image reads (#1705)
1 parent 2bdbedf commit e9920c3

4 files changed

Lines changed: 521 additions & 1 deletion

File tree

‎packages/plugins/mcp/src/sdk/plugin.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { invokeMcpTool, isUnknownToolMessage } from "./invoke";
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) => {
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) => {
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,
Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
});

0 commit comments

Comments
 (0)