From 4479758a686e86406741e533f1dbddca3f68a80e Mon Sep 17 00:00:00 2001
From: Armaan Jain <84474476+Developing-Gamer@users.noreply.github.com>
Date: Wed, 10 Jun 2026 15:51:50 -0700
Subject: [PATCH] Ask mcp endpoint on skill (#1570)
---
## Summary by cubic
Expose MCP tools on the skills site as HTTP endpoints. Adds an MCP
wrapper that maps query params to JSON-RPC with CORS and stricter
validation; HEAD now validates routes and matches GET behavior.
- New Features
- Dynamic GET/HEAD/OPTIONS route at `/[toolName]` in the skills app.
- MCP wrapper: resolves tool aliases, builds/validates args from query,
proxies to the MCP endpoint with timeout, sets CORS + no-store headers,
maps MCP/HTTP errors, and rejects malformed routes/params; utilities for
endpoint URL resolution from env or sibling host and listing available
route names.
- Tests/config: Vitest setup for `apps/skills`; coverage for HEAD
short-circuit and 404s, route resolution, argument coercion, and invalid
query cases.
- Bug Fixes
- HEAD delegates to the shared handler and returns 404 for unknown tool
routes.
- IPv6 localhost detection accepts bracketed `[::1]` in URL hostnames.
Written for commit 4ad3c135fd5931a750256623a26aaed4b388e56b.
Summary will update on new commits.
## Summary by CodeRabbit
* **New Features**
* Added dynamic HTTP endpoints for MCP tools with GET/HEAD/OPTIONS
handling, tool discovery, route resolution, argument mapping/coercion,
and standardized text/error responses.
* **Tests**
* Added/expanded unit tests for route resolution, argument coercion,
endpoint derivation, response handling, HEAD behavior, and route-listing
snapshots.
* **Documentation**
* Clarified that hexclave dev injects required environment variables and
added guidance on updating auth SDK URLs to avoid redirect issues.
---------
Co-authored-by: Cursor
Co-authored-by: armaan
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---
apps/skills/src/app/[toolName]/route.test.ts | 43 ++
apps/skills/src/app/[toolName]/route.ts | 15 +
apps/skills/src/mcp-wrapper.test.ts | 202 ++++++++
apps/skills/src/mcp-wrapper.ts | 458 +++++++++++++++++++
apps/skills/vitest.config.ts | 8 +
5 files changed, 726 insertions(+)
create mode 100644 apps/skills/src/app/[toolName]/route.test.ts
create mode 100644 apps/skills/src/app/[toolName]/route.ts
create mode 100644 apps/skills/src/mcp-wrapper.test.ts
create mode 100644 apps/skills/src/mcp-wrapper.ts
create mode 100644 apps/skills/vitest.config.ts
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({}),
+);