Unified · Split
| @@ -52,6 +52,7 @@ import { invokeMcpTool, isUnknownToolMessage } from "./invoke"; · 51 unmodified lines | |||
| 52 | import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest"; | 52 | import { deriveMcpNamespace, type McpToolManifestEntry } from "./manifest"; |
| 53 | import { mcpPresets } from "./presets"; | 53 | import { mcpPresets } from "./presets"; |
| 54 | import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; | 54 | import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; |
| 55 | import { recoverSlackConnectFile } from "./slack-connect-file"; | ||
| 55 | import { | 56 | import { |
| 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, |
| @@ -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 | }; | ||