Unified · Split
| @@ -52,6 +52,7 @@ import { invokeMcpTool, isUnknownToolMessage } from "./invoke"; · 51 unmodified lines | ||
| 52 | 52 | import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest"; |
| 53 | 53 | import { mcpPresets } from "./presets"; |
| 54 | 54 | import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; |
| 55 | +import { recoverSlackConnectFile } from "./slack-connect-file"; | |
| 55 | 56 | import { |
| 56 | 57 | McpAuthMethodInput, |
| 57 | 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 | 1301 | const connectorInput = yield* buildConnectorInput( |
| 1300 | 1302 | parsed, |
| 1301 | 1303 | credential.values, |
| 1302 | 1304 | String(credential.template), |
| 1303 | 1305 | allowStdio, |
| 1304 | − options?.httpClientLayer ?? ctx.httpClientLayer, | |
| 1306 | + invokeHttpClientLayer, | |
| 1305 | 1307 | ); |
| 1306 | 1308 | const connector: McpConnector = createMcpConnector(connectorInput); |
| 1307 | 1309 | const poolKey = |
| @@ -1353,6 +1355,18 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { · 45 unmodified lines | ||
| 1353 | 1355 | .markToolsStale(connectionRef) |
| 1354 | 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 | 1370 | return ToolResult.fail({ |
| 1357 | 1371 | code: "mcp_tool_error", |
| 1358 | 1372 | message: errorMessage, |
| @@ -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 | +}); | |
| @@ -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,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 | +}; | |