mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-19 21:00:40 +08:00
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:
parent
49442cab4b
commit
4479758a68
43
apps/skills/src/app/[toolName]/route.test.ts
Normal file
43
apps/skills/src/app/[toolName]/route.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
15
apps/skills/src/app/[toolName]/route.ts
Normal file
15
apps/skills/src/app/[toolName]/route.ts
Normal 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();
|
||||
}
|
||||
202
apps/skills/src/mcp-wrapper.test.ts
Normal file
202
apps/skills/src/mcp-wrapper.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
458
apps/skills/src/mcp-wrapper.ts
Normal file
458
apps/skills/src/mcp-wrapper.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
8
apps/skills/vitest.config.ts
Normal file
8
apps/skills/vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig, mergeConfig } from "vitest/config";
|
||||
|
||||
import sharedConfig from "../../vitest.shared";
|
||||
|
||||
export default mergeConfig(
|
||||
sharedConfig,
|
||||
defineConfig({}),
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user