diff --git a/apps/skills/src/app/[toolName]/route.test.ts b/apps/skills/src/app/[toolName]/route.test.ts new file mode 100644 index 000000000..4c07a1125 --- /dev/null +++ b/apps/skills/src/app/[toolName]/route.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +import { HEAD } from "./route"; + +describe("skill-site MCP tool route", () => { + it("does not call MCP tools for HEAD requests but validates the route", async () => { + const previousFetch = globalThis.fetch; + const previousHexclaveMcpBaseUrl = process.env.HEXCLAVE_MCP_BASE_URL; + process.env.HEXCLAVE_MCP_BASE_URL = "https://mcp.hexclave.com/mcp"; + + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : null; + expect(body?.method).toBe("tools/list"); + + return new Response(`data: ${JSON.stringify({ + result: { + tools: [{ name: "ask_hexclave", inputSchema: null }], + }, + jsonrpc: "2.0", + id: 1, + })}`); + }); + + globalThis.fetch = fetchMock; + + try { + const found = await HEAD(new Request("https://skill.hexclave.com/ask", { method: "HEAD" })); + expect(found.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + + fetchMock.mockClear(); + const notFound = await HEAD(new Request("https://skill.hexclave.com/nonexistent", { method: "HEAD" })); + expect(notFound.status).toBe(404); + } finally { + globalThis.fetch = previousFetch; + if (previousHexclaveMcpBaseUrl == null) { + delete process.env.HEXCLAVE_MCP_BASE_URL; + } else { + process.env.HEXCLAVE_MCP_BASE_URL = previousHexclaveMcpBaseUrl; + } + } + }); +}); diff --git a/apps/skills/src/app/[toolName]/route.ts b/apps/skills/src/app/[toolName]/route.ts new file mode 100644 index 000000000..aacb3b4c7 --- /dev/null +++ b/apps/skills/src/app/[toolName]/route.ts @@ -0,0 +1,15 @@ +import { handleMcpToolOptions, handleMcpToolRoute } from "@/mcp-wrapper"; + +export const dynamic = "force-dynamic"; + +export async function GET(req: Request) { + return await handleMcpToolRoute(req); +} + +export async function HEAD(req: Request) { + return await handleMcpToolRoute(req); +} + +export function OPTIONS() { + return handleMcpToolOptions(); +} diff --git a/apps/skills/src/mcp-wrapper.test.ts b/apps/skills/src/mcp-wrapper.test.ts new file mode 100644 index 000000000..f19ddea09 --- /dev/null +++ b/apps/skills/src/mcp-wrapper.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it, vi } from "vitest"; + +import { buildMcpToolArguments, getAvailableRouteNames, getMcpEndpointUrl, handleMcpToolRoute, resolveMcpToolRoute } from "./mcp-wrapper"; + +function restoreEnvVariable(name: string, value: string | undefined) { + if (value == null) { + delete process.env[name]; + } else { + process.env[name] = value; + } +} + +describe("skill-site MCP wrapper", () => { + const askTool = { + name: "ask_hexclave", + inputSchema: { + type: "object", + properties: { + question: { type: "string" }, + reason: { type: "string" }, + userPrompt: { type: "string" }, + conversationId: { type: "string" }, + }, + required: ["question", "reason", "userPrompt"], + }, + }; + + it("resolves exact tool names and public Hexclave aliases", () => { + const tools = [ + askTool, + { name: "inspect_project", inputSchema: null }, + ]; + + expect(resolveMcpToolRoute(tools, "ask_hexclave")?.name).toBe("ask_hexclave"); + expect(resolveMcpToolRoute(tools, "ask")?.name).toBe("ask_hexclave"); + expect(resolveMcpToolRoute(tools, "inspect_project")?.name).toBe("inspect_project"); + expect(resolveMcpToolRoute(tools, "missing")).toBeNull(); + expect(getAvailableRouteNames(tools)).toMatchInlineSnapshot(` + [ + "ask", + "ask_hexclave", + "inspect_project", + ] + `); + }); + + it("maps query to question for the ask route while preserving MCP parameters", () => { + const params = new URLSearchParams({ + query: "How do I add OAuth?", + conversationId: "conversation-123", + }); + + expect(buildMcpToolArguments(askTool, params)).toMatchInlineSnapshot(` + { + "conversationId": "conversation-123", + "question": "How do I add OAuth?", + "reason": "skill-site MCP tool route", + "userPrompt": "How do I add OAuth?", + } + `); + }); + + it("preserves explicit ask metadata when the caller provides it", () => { + const params = new URLSearchParams({ + query: "How do I add OAuth?", + reason: "User asked about OAuth setup", + userPrompt: "Original user words", + }); + + expect(buildMcpToolArguments(askTool, params)).toMatchInlineSnapshot(` + { + "question": "How do I add OAuth?", + "reason": "User asked about OAuth setup", + "userPrompt": "Original user words", + } + `); + }); + + it("coerces query values from a tool JSON schema", () => { + const tool = { + name: "search", + inputSchema: { + type: "object", + properties: { + limit: { type: "integer" }, + includeDrafts: { type: "boolean" }, + filters: { type: "object" }, + tags: { type: "array" }, + }, + }, + }; + const params = new URLSearchParams(); + params.set("limit", "10"); + params.set("includeDrafts", "true"); + params.set("filters", "{\"kind\":\"guide\"}"); + params.append("tags", "sdk"); + params.append("tags", "oauth"); + + expect(buildMcpToolArguments(tool, params)).toMatchInlineSnapshot(` + { + "filters": { + "kind": "guide", + }, + "includeDrafts": true, + "limit": 10, + "tags": [ + "sdk", + "oauth", + ], + } + `); + }); + + it("rejects arrays for object query parameters", () => { + const tool = { + name: "search", + inputSchema: { + type: "object", + properties: { + filters: { type: "object" }, + }, + }, + }; + const params = new URLSearchParams({ + filters: "[]", + }); + + expect(() => buildMcpToolArguments(tool, params)).toThrow("must be a JSON object"); + }); + + it("infers the sibling MCP endpoint from local and production skill URLs", () => { + const previousHexclaveMcpBaseUrl = process.env.HEXCLAVE_MCP_BASE_URL; + const previousStackMcpBaseUrl = process.env.STACK_MCP_BASE_URL; + delete process.env.HEXCLAVE_MCP_BASE_URL; + delete process.env.STACK_MCP_BASE_URL; + + try { + expect(getMcpEndpointUrl(new Request("http://localhost:8145/ask")).toString()).toBe("http://localhost:8144/mcp"); + expect(getMcpEndpointUrl(new Request("https://skill.hexclave.com/ask")).toString()).toBe("https://mcp.hexclave.com/mcp"); + expect(() => getMcpEndpointUrl(new Request("https://skill.evil.example/ask"))).toThrow("Unable to derive MCP endpoint URL"); + } finally { + restoreEnvVariable("HEXCLAVE_MCP_BASE_URL", previousHexclaveMcpBaseUrl); + restoreEnvVariable("STACK_MCP_BASE_URL", previousStackMcpBaseUrl); + } + }); + + it("does not call MCP tools for HEAD requests", async () => { + const previousFetch = globalThis.fetch; + const previousHexclaveMcpBaseUrl = process.env.HEXCLAVE_MCP_BASE_URL; + process.env.HEXCLAVE_MCP_BASE_URL = "https://mcp.hexclave.com/mcp"; + + const fetchMock = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = typeof init?.body === "string" ? JSON.parse(init.body) : null; + expect(body?.method).toBe("tools/list"); + + return new Response(`data: ${JSON.stringify({ + result: { + tools: [askTool], + }, + jsonrpc: "2.0", + id: 1, + })}`); + }); + + globalThis.fetch = fetchMock; + + try { + const response = await handleMcpToolRoute(new Request("https://skill.hexclave.com/ask", { method: "HEAD" })); + expect(response.status).toBe(200); + expect(fetchMock).toHaveBeenCalledTimes(1); + } finally { + globalThis.fetch = previousFetch; + restoreEnvVariable("HEXCLAVE_MCP_BASE_URL", previousHexclaveMcpBaseUrl); + } + }); + + it("returns 404 for HEAD requests to unknown tool routes", async () => { + const previousFetch = globalThis.fetch; + const previousHexclaveMcpBaseUrl = process.env.HEXCLAVE_MCP_BASE_URL; + process.env.HEXCLAVE_MCP_BASE_URL = "https://mcp.hexclave.com/mcp"; + + const fetchMock = vi.fn(async () => { + return new Response(`data: ${JSON.stringify({ + result: { + tools: [askTool], + }, + jsonrpc: "2.0", + id: 1, + })}`); + }); + + globalThis.fetch = fetchMock; + + try { + const response = await handleMcpToolRoute(new Request("https://skill.hexclave.com/nonexistent", { method: "HEAD" })); + expect(response.status).toBe(404); + } finally { + globalThis.fetch = previousFetch; + restoreEnvVariable("HEXCLAVE_MCP_BASE_URL", previousHexclaveMcpBaseUrl); + } + }); +}); diff --git a/apps/skills/src/mcp-wrapper.ts b/apps/skills/src/mcp-wrapper.ts new file mode 100644 index 000000000..d5cb8d06a --- /dev/null +++ b/apps/skills/src/mcp-wrapper.ts @@ -0,0 +1,458 @@ +const MCP_RPC_HEADERS = { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", +}; + +const TOOL_ROUTE_HEADERS = { + "Cache-Control": "private, no-store", + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", + "Access-Control-Allow-Headers": "*", +}; + +const MCP_RPC_TIMEOUT_MS = 15_000; + +type JsonRecord = Record; + +type McpTool = { + name: string, + inputSchema: JsonRecord | null, +}; + +class QueryArgumentError extends Error { + constructor(message: string) { + super(message); + this.name = "QueryArgumentError"; + } +} + +class McpHttpError extends Error { + status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "McpHttpError"; + this.status = status; + } +} + +class McpJsonRpcError extends Error { + code: number; + + constructor(code: number, message: string) { + super(message); + this.name = "McpJsonRpcError"; + this.code = code; + } +} + +function isRecord(value: unknown): value is JsonRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseJsonFromMcpBody(body: string): unknown { + const dataLine = body + .split("\n") + .find((line) => line.startsWith("data: ")); + + return JSON.parse(dataLine == null ? body : dataLine.slice("data: ".length)); +} + +function getMcpPathname(pathname: string): string { + if (pathname === "" || pathname === "/") { + return "/mcp"; + } + return pathname; +} + +function normalizeMcpEndpointUrl(url: URL): URL { + const normalized = new URL(url); + normalized.pathname = getMcpPathname(normalized.pathname); + normalized.search = ""; + normalized.hash = ""; + return normalized; +} + +function getConfiguredMcpEndpointUrl(): URL | null { + const configured = + process.env.HEXCLAVE_MCP_BASE_URL ?? + process.env.STACK_MCP_BASE_URL; + + if (configured == null || configured.trim() === "") { + return null; + } + + return normalizeMcpEndpointUrl(new URL(configured)); +} + +function isLocalHostname(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1" || hostname.endsWith(".localhost"); +} + +function getSiblingMcpUrl(req: Request): URL { + const url = new URL(req.url); + const sibling = new URL(url); + + if (sibling.hostname === "skill.hexclave.com") { + sibling.hostname = "mcp.hexclave.com"; + } else if (isLocalHostname(sibling.hostname) && sibling.port.endsWith("45")) { + sibling.port = `${sibling.port.slice(0, -2)}44`; + } else { + throw new QueryArgumentError("Unable to derive MCP endpoint URL for this skill host."); + } + + sibling.pathname = "/mcp"; + sibling.search = ""; + sibling.hash = ""; + return sibling; +} + +export function getMcpEndpointUrl(req: Request): URL { + return getConfiguredMcpEndpointUrl() ?? getSiblingMcpUrl(req); +} + +async function mcpJsonRpc(endpointUrl: URL, method: string, params?: unknown): Promise { + const body = params == null + ? { jsonrpc: "2.0", id: 1, method } + : { jsonrpc: "2.0", id: 1, method, params }; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), MCP_RPC_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(endpointUrl, { + method: "POST", + headers: MCP_RPC_HEADERS, + body: JSON.stringify(body), + signal: controller.signal, + }); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new McpHttpError(504, `MCP HTTP timeout after ${MCP_RPC_TIMEOUT_MS}ms`); + } + throw error; + } finally { + clearTimeout(timeout); + } + + const text = await response.text(); + if (!response.ok) { + throw new McpHttpError(response.status, `MCP HTTP error ${response.status}`); + } + + const parsed = parseJsonFromMcpBody(text); + if (isRecord(parsed) && isRecord(parsed.error)) { + const code = typeof parsed.error.code === "number" ? parsed.error.code : -1; + const message = typeof parsed.error.message === "string" ? parsed.error.message : JSON.stringify(parsed.error); + throw new McpJsonRpcError(code, message); + } + + return parsed; +} + +function parseToolsListResponse(value: unknown): McpTool[] { + if (!isRecord(value) || !isRecord(value.result) || !Array.isArray(value.result.tools)) { + return []; + } + + return value.result.tools.flatMap((tool) => { + if (!isRecord(tool) || typeof tool.name !== "string") { + return []; + } + + return [{ + name: tool.name, + inputSchema: isRecord(tool.inputSchema) ? tool.inputSchema : null, + }]; + }); +} + +export async function listMcpTools(endpointUrl: URL): Promise { + return parseToolsListResponse(await mcpJsonRpc(endpointUrl, "tools/list")); +} + +function getPublicRouteNames(toolName: string): string[] { + const routeNames = new Set(); + routeNames.add(toolName); + + const hexclaveSuffix = "_hexclave"; + if (toolName.endsWith(hexclaveSuffix) && toolName.length > hexclaveSuffix.length) { + routeNames.add(toolName.slice(0, -hexclaveSuffix.length)); + } + + return [...routeNames]; +} + +export function resolveMcpToolRoute(tools: McpTool[], routeName: string): McpTool | null { + const exactTool = tools.find((tool) => tool.name === routeName); + if (exactTool != null) { + return exactTool; + } + + let matchedTool: McpTool | null = null; + for (const tool of tools) { + if (!getPublicRouteNames(tool.name).includes(routeName)) { + continue; + } + + if (matchedTool != null) { + throw new QueryArgumentError(`Route /${routeName} is ambiguous between MCP tools ${matchedTool.name} and ${tool.name}. Use the exact tool name instead.`); + } + + matchedTool = tool; + } + + return matchedTool; +} + +export function getAvailableRouteNames(tools: McpTool[]): string[] { + return [...new Set(tools.flatMap((tool) => getPublicRouteNames(tool.name)))].sort(); +} + +function getSchemaProperties(inputSchema: JsonRecord | null): Map { + if (inputSchema == null || !isRecord(inputSchema.properties)) { + return new Map(); + } + + return new Map(Object.entries(inputSchema.properties)); +} + +function getSchemaType(schema: unknown): string | null { + if (!isRecord(schema)) { + return null; + } + + if (typeof schema.type === "string") { + return schema.type; + } + + if (Array.isArray(schema.type)) { + const stringType = schema.type.find((item) => typeof item === "string" && item !== "null"); + return typeof stringType === "string" ? stringType : null; + } + + return null; +} + +function parseJsonQueryValue(parameterName: string, value: string): unknown { + try { + return JSON.parse(value); + } catch (error) { + if (error instanceof SyntaxError) { + throw new QueryArgumentError(`Query parameter "${parameterName}" must be valid JSON.`); + } + throw error; + } +} + +function coerceQueryValue(parameterName: string, values: string[], schema: unknown): unknown { + const schemaType = getSchemaType(schema); + const value = values.length === 0 ? "" : values[values.length - 1]; + + if (schemaType === "array") { + if (values.length === 1 && value.trim().startsWith("[")) { + return parseJsonQueryValue(parameterName, value); + } + return values; + } + + if (schemaType === "object") { + const parsed = parseJsonQueryValue(parameterName, value); + if (!isRecord(parsed)) { + throw new QueryArgumentError(`Query parameter "${parameterName}" must be a JSON object.`); + } + return parsed; + } + + if (schemaType === "number" || schemaType === "integer") { + const parsed = Number(value); + if (!Number.isFinite(parsed) || (schemaType === "integer" && !Number.isInteger(parsed))) { + throw new QueryArgumentError(`Query parameter "${parameterName}" must be a ${schemaType}.`); + } + return parsed; + } + + if (schemaType === "boolean") { + if (value === "true" || value === "1") { + return true; + } + if (value === "false" || value === "0") { + return false; + } + throw new QueryArgumentError(`Query parameter "${parameterName}" must be a boolean.`); + } + + if (values.length > 1) { + return values; + } + + return value; +} + +function getQueryParameterValues(searchParams: URLSearchParams): Map { + const values = new Map(); + + for (const [key, value] of searchParams.entries()) { + const current = values.get(key); + if (current == null) { + values.set(key, [value]); + } else { + current.push(value); + } + } + + return values; +} + +function applyQuestionAlias(values: Map, properties: Map): Map { + const copiedValues = new Map(values); + + if ( + properties.has("question") && + !properties.has("query") && + !copiedValues.has("question") && + copiedValues.has("query") + ) { + const queryValues = copiedValues.get("query"); + if (queryValues != null) { + copiedValues.set("question", queryValues); + copiedValues.delete("query"); + } + } + + const questionValues = copiedValues.get("question"); + if (questionValues != null && properties.has("reason") && !copiedValues.has("reason")) { + copiedValues.set("reason", ["skill-site MCP tool route"]); + } + + // Public URL calls do not have an original agent prompt distinct from the + // question, so use the question text for ask-style tools unless overridden. + if (questionValues != null && properties.has("userPrompt") && !copiedValues.has("userPrompt")) { + copiedValues.set("userPrompt", questionValues); + } + + return copiedValues; +} + +export function buildMcpToolArguments(tool: McpTool, searchParams: URLSearchParams): JsonRecord { + const properties = getSchemaProperties(tool.inputSchema); + const queryValues = applyQuestionAlias(getQueryParameterValues(searchParams), properties); + const args: JsonRecord = Object.create(null); + + for (const [parameterName, values] of queryValues.entries()) { + args[parameterName] = coerceQueryValue(parameterName, values, properties.get(parameterName)); + } + + return args; +} + +function getToolResponseText(callResponse: unknown): { text: string, isError: boolean } { + if (!isRecord(callResponse) || !isRecord(callResponse.result)) { + return { text: JSON.stringify(callResponse, null, 2), isError: false }; + } + + const isError = callResponse.result.isError === true; + if (!Array.isArray(callResponse.result.content)) { + return { text: JSON.stringify(callResponse.result, null, 2), isError }; + } + + const text = callResponse.result.content.flatMap((contentItem) => { + if (!isRecord(contentItem) || contentItem.type !== "text" || typeof contentItem.text !== "string") { + return []; + } + + return [contentItem.text]; + }).join("\n\n"); + + return { text: text.length > 0 ? text : "(empty response)", isError }; +} + +export async function callMcpTool(endpointUrl: URL, tool: McpTool, searchParams: URLSearchParams): Promise<{ text: string, isError: boolean }> { + const response = await mcpJsonRpc(endpointUrl, "tools/call", { + name: tool.name, + arguments: buildMcpToolArguments(tool, searchParams), + }); + + return getToolResponseText(response); +} + +function getToolNameFromRequest(req: Request): string { + const pathname = new URL(req.url).pathname; + const routeName = pathname.split("/").filter((part) => part.length > 0).at(-1); + if (routeName == null) { + throw new QueryArgumentError("Missing MCP tool route name."); + } + try { + return decodeURIComponent(routeName); + } catch (error) { + if (error instanceof URIError) { + throw new QueryArgumentError("Malformed MCP tool route name encoding."); + } + throw error; + } +} + +function textResponse(text: string, status = 200): Response { + return new Response(text, { + status, + headers: { + ...TOOL_ROUTE_HEADERS, + "Content-Type": "text/plain; charset=utf-8", + }, + }); +} + +function errorStatusForMcpError(error: McpJsonRpcError): number { + if (error.code === -32601) { + return 404; + } + + if (error.code === -32602) { + return 400; + } + + return 502; +} + +export async function handleMcpToolRoute(req: Request): Promise { + try { + const endpointUrl = getMcpEndpointUrl(req); + const tools = await listMcpTools(endpointUrl); + const routeName = getToolNameFromRequest(req); + const tool = resolveMcpToolRoute(tools, routeName); + + if (tool == null) { + return textResponse(`Unknown MCP tool route "/${routeName}". Available routes: ${getAvailableRouteNames(tools).join(", ")}`, 404); + } + + if (req.method === "HEAD") { + return textResponse(""); + } + + const response = await callMcpTool(endpointUrl, tool, new URL(req.url).searchParams); + return textResponse(response.text, response.isError ? 502 : 200); + } catch (error) { + if (error instanceof QueryArgumentError) { + return textResponse(error.message, 400); + } + + if (error instanceof McpJsonRpcError) { + return textResponse(`MCP JSON-RPC error ${error.code}`, errorStatusForMcpError(error)); + } + + if (error instanceof McpHttpError) { + return textResponse(error.message, 502); + } + + throw error; + } +} + +export function handleMcpToolOptions(): Response { + return new Response(null, { + status: 204, + headers: TOOL_ROUTE_HEADERS, + }); +} + + diff --git a/apps/skills/vitest.config.ts b/apps/skills/vitest.config.ts new file mode 100644 index 000000000..4c4357183 --- /dev/null +++ b/apps/skills/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig, mergeConfig } from "vitest/config"; + +import sharedConfig from "../../vitest.shared"; + +export default mergeConfig( + sharedConfig, + defineConfig({}), +);