diff --git a/README.md b/README.md index 1abeba8..5b7ef54 100644 --- a/README.md +++ b/README.md @@ -230,6 +230,7 @@ Shellgate exposes all agent-facing functionality as an [MCP (Model Context Proto |---|---| | `discover` | List accessible targets, webhooks, and org skills | | `api_request` | Proxy HTTP requests with automatic credential injection | +| `api_download` | Download authenticated image responses as temporary MCP resources with content-type and size checks | | `ssh_exec` | Execute SSH commands with guard protection | | `webhook_poll` | Poll for incoming webhook events | | `webhook_ack` | Acknowledge processed webhook events | diff --git a/src/lib/server/mcp/server.ts b/src/lib/server/mcp/server.ts index 4a479fd..dd5421a 100644 --- a/src/lib/server/mcp/server.ts +++ b/src/lib/server/mcp/server.ts @@ -1,9 +1,12 @@ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import type { Token } from "$lib/server/db/schema"; import { discover } from "./tools/discover"; import { apiRequest } from "./tools/api-request"; import type { ApiRequestArgs } from "./tools/api-request"; +import { apiDownload, readDownloadedImageResource } from "./tools/api-download"; +import type { ApiDownloadArgs } from "./tools/api-download"; import { sshExec } from "./tools/ssh-exec"; import type { SshExecArgs } from "./tools/ssh-exec"; import { webhookPoll, webhookAck } from "./tools/webhooks"; @@ -18,6 +21,8 @@ Shellgate manages organization-wide skills shared across all agents — these ar Shellgate also provides a wiki for compiled organizational knowledge. Call wiki_list_pages to browse available pages. Use wiki tools for factual knowledge ("what do we know?"), memories for behavioral guidance ("how should I act?"), and skills for procedures ("what steps to follow?"). +When Linear issue descriptions or comments contain \`https://uploads.linear.app/...\` image URLs, do not call those URLs directly. Use the \`linear-uploads\` target with \`api_download\`, read the returned MCP resource as an image/blob, inspect it with vision tooling, and include the visual content in your ticket analysis before drawing conclusions. Do not print, log, comment, or otherwise expose image bytes/base64 as text. + IMPORTANT — Wiki workflow: Proactively store company knowledge in the wiki when you encounter valuable factual information. BEFORE creating or updating any wiki page, you MUST first call org_skill_read for the relevant wiki skill (wiki-create-page, wiki-update-page, or wiki-compile-research) and follow its instructions. These skills define required structure, namespace conventions, source attribution, and validation steps (including wiki_lint_page). Never write to the wiki without reading the skill first. Call vault_search when you need credentials for browser automation — it returns handles for blind-fill, not secret values.`; @@ -27,9 +32,25 @@ export function createMcpServer() { { name: "shellgate", version: "1.0.0" }, { instructions: INSTRUCTIONS } ); + server.registerResource( + "downloaded_image", + new ResourceTemplate("shellgate-download://{id}", { list: undefined }), + { + title: "Downloaded Image", + description: "Temporary binary image downloaded through Shellgate api_download", + }, + (uri) => readDownloadedImageResource(uri.toString()) + ); return server; } +function asToolResult(result: unknown) { + if (result && typeof result === "object" && "content" in result) { + return result as CallToolResult; + } + return { content: [{ type: "text" as const, text: JSON.stringify(result) }] }; +} + export function registerTools(server: McpServer, token: Token) { server.tool("discover", "List all targets, webhook endpoints, and skills accessible to this token", async () => { const result = await discover(token); @@ -53,6 +74,21 @@ export function registerTools(server: McpServer, token: Token) { } ); + server.tool( + "api_download", + "Download an image response from an API target through Shellgate and return a temporary MCP resource link. Use for authenticated binary images such as Linear uploads. Only image/png, image/jpeg, and image/webp are accepted; image bytes/base64 are not returned as text.", + { + target: z.string().describe("Target slug"), + path: z.string().describe("Path appended to target's baseUrl"), + maxBytes: z.number().optional().describe("Maximum response size in bytes, capped at 20 MB"), + approved: z.preprocess(val => val === "true" || val === true, z.boolean()).optional().describe("Set to true after user approves a guarded request"), + }, + async (args) => { + const result = await apiDownload(token, args); + return asToolResult(result); + } + ); + server.tool( "ssh_exec", "Execute a command on an SSH target. If the response has status 'approval_required', present the reason to the user and re-call with approved: true after they confirm.", @@ -296,6 +332,8 @@ export function createMcpToolHandler(token: TokenLike): ToolHandler { return discover(t); case "api_request": return apiRequest(t, args as unknown as ApiRequestArgs); + case "api_download": + return apiDownload(t, args as unknown as ApiDownloadArgs); case "ssh_exec": return sshExec(t, args as unknown as SshExecArgs); case "webhook_poll": diff --git a/src/lib/server/mcp/tools/api-download.ts b/src/lib/server/mcp/tools/api-download.ts new file mode 100644 index 0000000..a53d523 --- /dev/null +++ b/src/lib/server/mcp/tools/api-download.ts @@ -0,0 +1,220 @@ +import type { Token } from "$lib/server/db/schema"; +import { resolveGatewayTarget, proxyToTarget } from "$lib/server/services/gateway"; +import { normalizeApiRequest, checkRequest } from "$lib/server/guard"; +import { logRequest } from "$lib/server/services/audit"; +import { randomUUID } from "node:crypto"; + +const DEFAULT_MAX_BYTES = 20_000_000; +const HARD_MAX_BYTES = 20_000_000; +const RESOURCE_TTL_MS = 10 * 60 * 1000; +const ALLOWED_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp"]); + +interface DownloadedImageResource { + blob: string; + byteLength: number; + contentType: string; + filename: string; + createdAt: number; +} + +const downloadedImages = new Map(); + +export interface ApiDownloadArgs { + target: string; + path: string; + maxBytes?: number; + approved?: boolean; +} + +function sanitizeMaxBytes(maxBytes?: number) { + if (maxBytes === undefined) return DEFAULT_MAX_BYTES; + if (!Number.isFinite(maxBytes) || maxBytes <= 0) return DEFAULT_MAX_BYTES; + return Math.min(Math.floor(maxBytes), HARD_MAX_BYTES); +} + +function contentTypeBase(contentType: string) { + return contentType.split(";")[0].trim().toLowerCase(); +} + +function filenameFromPath(path: string, contentType: string) { + const cleanPath = path.split("?")[0]; + const lastSegment = cleanPath.split("/").filter(Boolean).at(-1); + if (lastSegment && /\.[a-z0-9]+$/i.test(lastSegment)) return lastSegment; + + const ext = contentTypeBase(contentType).replace("image/", "").replace("jpeg", "jpg"); + return `download.${ext || "img"}`; +} + +function storeDownloadedImage(resource: Omit) { + const id = randomUUID(); + downloadedImages.set(id, { ...resource, createdAt: Date.now() }); + + const timeout = setTimeout(() => { + downloadedImages.delete(id); + }, RESOURCE_TTL_MS); + timeout.unref?.(); + + return `shellgate-download://${id}`; +} + +export function readDownloadedImageResource(uri: string) { + const id = uri.replace(/^shellgate-download:\/\//, ""); + const resource = downloadedImages.get(id); + if (!resource) { + throw new Error("Downloaded image resource not found or expired"); + } + + return { + contents: [ + { + uri, + mimeType: resource.contentType, + blob: resource.blob, + _meta: { + filename: resource.filename, + byteLength: resource.byteLength, + createdAt: resource.createdAt, + }, + }, + ], + }; +} + +export async function apiDownload(token: Token, args: ApiDownloadArgs) { + const { target: targetSlug, path, approved = false } = args; + const maxBytes = sanitizeMaxBytes(args.maxBytes); + const method = "GET"; + + const resolved = await resolveGatewayTarget(token, targetSlug); + if ("error" in resolved) { + const errBody = await resolved.error.json().catch(() => ({ error: "unknown error" })); + throw new Error(errBody.error ?? "Failed to resolve target"); + } + + const { target } = resolved; + + if (!approved) { + const guardResult = await checkRequest(normalizeApiRequest(method, path)); + + if (guardResult.action === "block") { + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "gateway", + method, + path, + statusCode: 403, + clientIp: "mcp", + durationMs: null, + guardAction: "block", + guardReason: guardResult.reason, + }); + return { error: "download_failed", reason: guardResult.reason, matched: guardResult.matched }; + } + + if (guardResult.action === "approval_required") { + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "gateway", + method, + path, + statusCode: 202, + clientIp: "mcp", + durationMs: null, + guardAction: "approval_required", + guardReason: guardResult.reason, + }); + return { + status: "approval_required", + reason: guardResult.reason, + matched: guardResult.matched, + request: { target: targetSlug, path, maxBytes }, + next_action: + "STOP. Do NOT re-send this request yet. Present the reason to the user and wait for explicit approval. Only then re-call this SAME tool with approved: true.", + }; + } + } + + const normalizedPath = path.startsWith("/") ? path.slice(1) : path; + const requestUrl = `http://mcp-internal/gateway/${targetSlug}/${normalizedPath}`; + const proxyRequest = new Request(requestUrl, { + method, + headers: new Headers({ Accept: "image/png,image/jpeg,image/webp" }), + }); + + const start = Date.now(); + const response = await proxyToTarget(target, normalizedPath, proxyRequest); + const durationMs = Date.now() - start; + + logRequest({ + tokenId: token.id, + tokenName: token.name, + targetId: target.id, + targetSlug: target.slug, + type: "gateway", + method, + path, + statusCode: response.status, + clientIp: "mcp", + durationMs, + guardAction: approved ? "approved" : "allow", + }); + + if (response.status === 401 || response.status === 403) { + return { error: "unauthorized", status: response.status }; + } + + if (!response.ok) { + return { error: "download_failed", status: response.status }; + } + + const contentType = response.headers.get("content-type") ?? ""; + if (!ALLOWED_IMAGE_TYPES.has(contentTypeBase(contentType))) { + return { error: "not_image", contentType: contentType || null }; + } + + const contentLength = Number(response.headers.get("content-length")); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + return { error: "too_large", maxBytes, contentLength }; + } + + const bytes = Buffer.from(await response.arrayBuffer()); + if (bytes.byteLength > maxBytes) { + return { error: "too_large", maxBytes, contentLength: bytes.byteLength }; + } + + const normalizedContentType = contentTypeBase(contentType); + const filename = filenameFromPath(path, contentType); + const uri = storeDownloadedImage({ + contentType: normalizedContentType, + filename, + byteLength: bytes.byteLength, + blob: bytes.toString("base64"), + }); + const metadata = { + contentType: normalizedContentType, + filename, + byteLength: bytes.byteLength, + uri, + expiresInMs: RESOURCE_TTL_MS, + }; + + return { + content: [ + { type: "text" as const, text: JSON.stringify(metadata) }, + { + type: "resource_link" as const, + uri, + name: filename, + mimeType: normalizedContentType, + size: bytes.byteLength, + description: "Temporary Shellgate download resource. Read it as an MCP resource and pass it to vision tooling as an image input.", + }, + ], + }; +} diff --git a/src/lib/server/services/gateway.ts b/src/lib/server/services/gateway.ts index 4e275a8..d96f89b 100644 --- a/src/lib/server/services/gateway.ts +++ b/src/lib/server/services/gateway.ts @@ -7,6 +7,15 @@ import { hasPermission } from "./permissions"; import { signES256JWT } from "../utils/jwt"; import { getOAuth2AccessToken } from "../utils/oauth2"; +function redactHeaders(headers: Headers) { + const result: Record = {}; + for (const [key, value] of headers.entries()) { + const lower = key.toLowerCase(); + result[key] = lower === "authorization" || lower === "proxy-authorization" ? "[REDACTED]" : value; + } + return result; +} + /** * Resolve and validate a target for gateway proxying. * Returns the target if valid, or a Response error. @@ -180,7 +189,7 @@ export async function proxyToTarget( if (hasBody) headers.set("Content-Type", "application/json"); console.log("[gateway] →", request.method, url.toString()); - console.log("[gateway] → headers:", Object.fromEntries(headers.entries())); + console.log("[gateway] → headers:", redactHeaders(headers)); let upstreamResponse: Response; try { @@ -230,7 +239,7 @@ export async function proxyToTarget( // [DEBUG] Log outgoing upstream request console.log("[gateway] →", request.method, url.toString()); - console.log("[gateway] → headers:", Object.fromEntries(headers.entries())); + console.log("[gateway] → headers:", redactHeaders(headers)); let upstreamResponse: Response; try { diff --git a/src/routes/api/skill/+server.ts b/src/routes/api/skill/+server.ts index 817785b..5334e6a 100644 --- a/src/routes/api/skill/+server.ts +++ b/src/routes/api/skill/+server.ts @@ -36,6 +36,19 @@ curl -s -H "Authorization: Bearer $SHELLGATE_API_KEY" \\ -d '{"model": "gpt-4", "messages": [...]}' \`\`\` +### Authenticated Image Downloads + +When Linear issue descriptions or comments contain \`https://uploads.linear.app/...\` image URLs, do not call those URLs directly. Use a Shellgate target configured like this: + +\`\`\`text +slug: linear-uploads +name: Linear Uploads +baseUrl: https://uploads.linear.app +auth: Bearer +\`\`\` + +Download only the path part through Shellgate, save the binary response locally or read the MCP resource returned by \`api_download\`, and inspect the image before drawing conclusions from the Linear ticket. Accept only \`image/png\`, \`image/jpeg\`, and \`image/webp\`, keep downloads at or below 20 MB, and never print or log image bytes/base64 as text. + ### SSH Targets For targets with type \`ssh\`, execute commands on remote servers: diff --git a/tests/integration/mcp.test.ts b/tests/integration/mcp.test.ts index f61487c..38f4dc5 100644 --- a/tests/integration/mcp.test.ts +++ b/tests/integration/mcp.test.ts @@ -326,4 +326,129 @@ describe("MCP tools", () => { expect(result.next_action).toContain("approved: true"); }); }); + + describe("api_download", () => { + it("downloads an authenticated image response as a temporary resource link", async () => { + const { token: tokenRow } = await createTestToken(); + const target = await createTestTarget("Linear Uploads", "https://uploads.linear.app"); + await createTestAuthMethod(target.id, { credential: "lin-api-token" }); + await grantPermission(tokenRow.id, target.id); + + const fullToken = await getFullToken(tokenRow.id); + const pngBytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); + const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(pngBytes, { + status: 200, + headers: { "content-type": "image/png" }, + }), + ); + + const handler = createMcpToolHandler(fullToken); + const result = await handler("api_download", { + target: target.slug, + path: "/a6d3e145/image-id", + }) as { + content: [ + { type: "text"; text: string }, + { type: "resource_link"; uri: string; name: string; mimeType: string; size: number }, + ]; + }; + + const metadata = JSON.parse(result.content[0].text) as { + contentType: string; + filename: string; + byteLength: number; + uri: string; + }; + + expect(metadata.contentType).toBe("image/png"); + expect(metadata.filename).toBe("download.png"); + expect(metadata.byteLength).toBe(4); + expect(metadata.uri).toMatch(/^shellgate-download:\/\//); + expect(result.content[0].text).not.toContain("base64"); + expect(result.content[0].text).not.toContain(pngBytes.toString("base64")); + expect(result.content[1]).toMatchObject({ + type: "resource_link", + uri: metadata.uri, + name: "download.png", + mimeType: "image/png", + size: 4, + }); + + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe("https://uploads.linear.app/a6d3e145/image-id"); + expect((init!.headers as Headers).get("Authorization")).toBe("Bearer lin-api-token"); + }); + + it("rejects non-image responses", async () => { + const { token: tokenRow } = await createTestToken(); + const target = await createTestTarget("Linear Uploads", "https://uploads.linear.app"); + await createTestAuthMethod(target.id); + await grantPermission(tokenRow.id, target.id); + + const fullToken = await getFullToken(tokenRow.id); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(JSON.stringify({ error: "unauthorized" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const handler = createMcpToolHandler(fullToken); + const result = await handler("api_download", { + target: target.slug, + path: "/not-an-image", + }) as { error: string; contentType: string }; + + expect(result.error).toBe("not_image"); + expect(result.contentType).toBe("application/json"); + }); + + it("rejects image responses larger than maxBytes", async () => { + const { token: tokenRow } = await createTestToken(); + const target = await createTestTarget("Linear Uploads", "https://uploads.linear.app"); + await createTestAuthMethod(target.id); + await grantPermission(tokenRow.id, target.id); + + const fullToken = await getFullToken(tokenRow.id); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(Buffer.from([1, 2, 3, 4]), { + status: 200, + headers: { "content-type": "image/jpeg" }, + }), + ); + + const handler = createMcpToolHandler(fullToken); + const result = await handler("api_download", { + target: target.slug, + path: "/large", + maxBytes: 3, + }) as { error: string; maxBytes: number; contentLength: number }; + + expect(result.error).toBe("too_large"); + expect(result.maxBytes).toBe(3); + expect(result.contentLength).toBe(4); + }); + + it("maps upstream 401/403 responses to unauthorized", async () => { + const { token: tokenRow } = await createTestToken(); + const target = await createTestTarget("Linear Uploads", "https://uploads.linear.app"); + await createTestAuthMethod(target.id); + await grantPermission(tokenRow.id, target.id); + + const fullToken = await getFullToken(tokenRow.id); + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("unauthorized", { status: 401 }), + ); + + const handler = createMcpToolHandler(fullToken); + const result = await handler("api_download", { + target: target.slug, + path: "/private", + }) as { error: string; status: number }; + + expect(result.error).toBe("unauthorized"); + expect(result.status).toBe(401); + }); + }); });