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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
40 changes: 39 additions & 1 deletion src/lib/server/mcp/server.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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.`;
Expand All @@ -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);
Expand All @@ -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.",
Expand Down Expand Up @@ -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":
Expand Down
220 changes: 220 additions & 0 deletions src/lib/server/mcp/tools/api-download.ts
Original file line number Diff line number Diff line change
@@ -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<string, DownloadedImageResource>();

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<DownloadedImageResource, "createdAt">) {
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.",
},
],
};
}
13 changes: 11 additions & 2 deletions src/lib/server/services/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {};
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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
13 changes: 13 additions & 0 deletions src/routes/api/skill/+server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
Loading
Loading