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
16 changes: 15 additions & 1 deletion packages/plugins/mcp/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
Expand Down
182 changes: 182 additions & 0 deletions packages/plugins/mcp/src/sdk/slack-connect-file.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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" },
],
},
});
}),
),
);
});
171 changes: 171 additions & 0 deletions packages/plugins/mcp/src/sdk/slack-connect-file.test.ts
Original file line number Diff line number Diff line change
@@ -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<HttpClient.HttpClient> =>
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<HttpClient.HttpClient>,
overrides: Partial<Parameters<typeof recoverSlackConnectFile>[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);
}),
);
});
Loading
Loading