Ask mcp endpoint on skill (#1570)

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/hexclave/hexclave/blob/dev/CONTRIBUTING.md

-->

<!-- This is an auto-generated description by cubic. -->
---
## 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.

<sup>Written for commit 4ad3c135fd.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1570?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: armaan <armaan@stack-auth.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Armaan Jain 2026-06-10 15:51:50 -07:00 committed by GitHub
parent 49442cab4b
commit 4479758a68
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 726 additions and 0 deletions

View File

@ -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;
}
}
});
});

View File

@ -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();
}

View File

@ -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);
}
});
});

View File

@ -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<string, unknown>;
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<unknown> {
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<McpTool[]> {
return parseToolsListResponse(await mcpJsonRpc(endpointUrl, "tools/list"));
}
function getPublicRouteNames(toolName: string): string[] {
const routeNames = new Set<string>();
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<string, unknown> {
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<string, string[]> {
const values = new Map<string, string[]>();
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<string, string[]>, properties: Map<string, unknown>): Map<string, string[]> {
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<Response> {
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,
});
}

View File

@ -0,0 +1,8 @@
import { defineConfig, mergeConfig } from "vitest/config";
import sharedConfig from "../../vitest.shared";
export default mergeConfig(
sharedConfig,
defineConfig({}),
);