diff --git a/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts b/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts index c400b4020..43b107e2c 100644 --- a/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts +++ b/apps/nextjs/src/app/.well-known/oauth-authorization-server/route.ts @@ -22,6 +22,7 @@ export async function GET() { code_challenge_methods_supported: ["S256"], token_endpoint_auth_methods_supported: ["none"], scopes_supported: ["mcp:tools"], + resource_indicators_supported: true, }, { headers: corsHeaders }, ); diff --git a/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts b/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts index ca66ee1a3..eb5f22e5a 100644 --- a/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts +++ b/apps/nextjs/src/app/.well-known/oauth-protected-resource/route.ts @@ -13,7 +13,7 @@ export async function GET() { return Response.json( { - resource: baseUrl, + resource: `${baseUrl}/api/mcp`, authorization_servers: [baseUrl], }, { headers: corsHeaders }, diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx index f3ac4ea6d..4d2375536 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/components/mcp-instructions.tsx @@ -63,7 +63,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct await revalidatePathActionAsync("/manage/tools/api"); }, }); - const mcpUrl = `${baseUrl}/api/mcp/mcp`; + const mcpUrl = `${baseUrl}/api/mcp`; const streamableHttpConfig = JSON.stringify( { @@ -93,7 +93,7 @@ export function McpInstructions({ baseUrl, hasApiKeys, toolGroups }: McpInstruct 2, ); - const testCommand = `curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream" -H "ApiKey: " -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}' ${mcpUrl}`; + const testCommand = `curl -s -X POST -H "Content-Type: application/json" -H "Accept: application/json" -H "ApiKey: " -H "Mcp-Method: server/discover" -H "Mcp-Name: homarr" -d '{"jsonrpc":"2.0","method":"server/discover","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0"},"io.modelcontextprotocol/clientCapabilities":{}}},"id":1}' ${mcpUrl}`; return ( diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts index 1fdb4e536..2944b02aa 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.spec.ts @@ -31,6 +31,8 @@ vi.mock("@homarr/custom-widgets/authoring-prompt", () => ({ buildCustomWidgetMcp vi.mock("@homarr/custom-widgets/authoring-resources", () => ({ getCustomWidgetComponent: mocks.getComponent, getCustomWidgetComponentCatalog: () => ({ components: [{ name: "Text Input" }] }), + getCustomWidgetExample: (name: string) => + name === "status-card" ? { id: "status-card", title: "Status card", template: "Status" } : null, getCustomWidgetSkillContent: () => "# Custom Widget Skill", })); vi.mock("@homarr/custom-widgets/core", () => ({ @@ -220,13 +222,16 @@ describe("non-admin MCP discovery", () => { ]); const prompts = await callMcp("prompts/list"); - expect(prompts.body.result).toEqual({ prompts: [] }); + expect(prompts.body.result).toBeUndefined(); + expect(prompts.body.error).toBeDefined(); const resources = await callMcp("resources/list"); - expect(resources.body.result).toEqual({ resources: [] }); + expect(resources.body.result).toBeUndefined(); + expect(resources.body.error).toBeDefined(); const templates = await callMcp("resources/templates/list"); - expect(templates.body.result).toEqual({ resourceTemplates: [] }); + expect(templates.body.result).toBeUndefined(); + expect(templates.body.error).toBeDefined(); const prompt = await callMcp("prompts/get", { name: "homarr-custom-widget-author" }); expect(prompt.body.result).toBeUndefined(); diff --git a/apps/nextjs/src/app/api/mcp/[transport]/route.ts b/apps/nextjs/src/app/api/mcp/[transport]/route.ts index 048bdcbef..4aa40bffe 100644 --- a/apps/nextjs/src/app/api/mcp/[transport]/route.ts +++ b/apps/nextjs/src/app/api/mcp/[transport]/route.ts @@ -1,443 +1,3 @@ -import { AsyncLocalStorage } from "async_hooks"; -import type { NextRequest } from "next/server"; -import { userAgent } from "next/server"; -import { createMcpHandler } from "mcp-handler"; -import type { McpTool } from "trpc-to-mcp"; -import { - CallToolRequestSchema, - GetPromptRequestSchema, - ListPromptsRequestSchema, - ListResourcesRequestSchema, - ListResourceTemplatesRequestSchema, - ListToolsRequestSchema, - ReadResourceRequestSchema, -} from "@modelcontextprotocol/sdk/types.js"; +import { handleMcpRequest } from "../_handler"; -import { createTRPCContext, mcpRouter } from "@homarr/api/mcp"; -import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key"; -import { extractBaseUrlFromHeaders } from "@homarr/common"; -import { ipAddressFromHeaders } from "@homarr/common/server"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt"; -import { - getCustomWidgetComponent, - getCustomWidgetComponentCatalog, - getCustomWidgetSkillContent, -} from "@homarr/custom-widgets/authoring-resources"; -import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core"; -import { db } from "@homarr/db"; - -import { getPackageVersion } from "~/versions/package-reader"; -import { extractMcpTools } from "../_extract-tools"; - -const logger = createLogger({ module: "mcpRoute" }); - -const AUTH_RATE_WINDOW_MS = 60_000; -const AUTH_MAX_FAILURES = 10; - -interface RateLimitStore { - authFailures: Map; - cleanupTimer: ReturnType | null; -} - -declare global { - var mcpRateLimit: RateLimitStore | undefined; -} - -const rateLimitStore = - globalThis.mcpRateLimit ?? - (globalThis.mcpRateLimit = { - authFailures: new Map(), - cleanupTimer: null, - }); -const authFailures = rateLimitStore.authFailures; - -if (!rateLimitStore.cleanupTimer) { - rateLimitStore.cleanupTimer = setInterval(() => { - const now = Date.now(); - for (const [key, entry] of authFailures) { - if (entry.resetAt < now) authFailures.delete(key); - } - }, AUTH_RATE_WINDOW_MS); - rateLimitStore.cleanupTimer.unref(); -} - -function checkAuthRateLimit(ip: string | null): boolean { - const key = ip ?? "unknown"; - const now = Date.now(); - const entry = authFailures.get(key); - if (!entry || entry.resetAt < now) return true; - return entry.count < AUTH_MAX_FAILURES; -} - -function recordAuthFailure(ip: string | null) { - const key = ip ?? "unknown"; - const now = Date.now(); - const entry = authFailures.get(key); - if (!entry || entry.resetAt < now) { - authFailures.set(key, { count: 1, resetAt: now + AUTH_RATE_WINDOW_MS }); - return; - } - entry.count++; -} - -function clearAuthFailures(ip: string | null) { - authFailures.delete(ip ?? "unknown"); -} - -interface McpCallerContext { - caller: ReturnType; - isAdmin: boolean; -} - -const callerStorage = new AsyncLocalStorage(); - -const emptyObjectSchema = { - type: "object" as const, - properties: {}, - required: [] as string[], -}; - -let toolsCache: McpTool[] | null = null; - -function normalizeSchema(schema: McpTool["inputSchema"]) { - if (!schema) return emptyObjectSchema; - const properties = (schema.properties ?? {}) as Record>; - const required = ((schema.required as string[]) ?? []).filter((key) => !("default" in (properties[key] ?? {}))); - return { type: schema.type ?? ("object" as const), properties, required }; -} - -function getTools() { - if (!toolsCache) { - toolsCache = extractMcpTools().map((tool) => ({ - ...tool, - inputSchema: normalizeSchema(tool.inputSchema), - })); - logger.info(`Extracted ${toolsCache.length} MCP tools`); - } - return toolsCache; -} - -function getVisibleTools() { - const tools = getTools(); - if (!isCustomWidgetMcpEnabled()) { - return tools.filter((tool) => !tool.name.startsWith("customWidget_")); - } - return tools; -} - -function requireCustomWidgetMcpAdmin() { - if (!isCustomWidgetMcpEnabled()) throw new Error("Resource not found"); -} - -const isCustomWidgetMcpEnabled = () => Boolean(callerStorage.getStore()?.isAdmin); - -const SAFE_ERROR_MESSAGES: Record = { - UNAUTHORIZED: "You do not have permission to perform this action", - FORBIDDEN: "Access denied", - NOT_FOUND: "The requested resource was not found", - BAD_REQUEST: "Invalid request parameters", - INTERNAL_SERVER_ERROR: "An internal error occurred", - TIMEOUT: "The operation timed out", - TOO_MANY_REQUESTS: "Rate limit exceeded, try again later", - CONFLICT: "A conflict occurred with the current state", - PRECONDITION_FAILED: "A precondition for this operation was not met", -}; - -function sanitizeErrorMessage(error: unknown): string { - if (error instanceof Error && "code" in error) { - const trpcCode = (error as Error & { code: string }).code; - const safeMessage = SAFE_ERROR_MESSAGES[trpcCode]; - if (safeMessage) return safeMessage; - } - if (error instanceof Error && error.message.length < 200 && !error.message.includes("/")) { - return error.message; - } - return "Tool execution failed"; -} - -const SERVER_INSTRUCTIONS = `You are connected to Homarr, a self-hosted homelab dashboard. - -## Key Concepts - -**Integrations** are connections to external self-hosted services (Sonarr, Radarr, Plex, Overseerr, Pi-hole, Home Assistant, etc.). Each integration has an \`id\` (integrationId) and a \`kind\` (e.g. "sonarr", "radarr", "overseerr"). Use \`integration_all\` to list them. Many tools require an \`integrationId\` — always fetch it from \`integration_all\` or \`integration_search\` first. - -**Boards** are customizable dashboards containing widgets that display data from integrations. Users can have multiple boards. - -**Apps** are links/bookmarks to self-hosted services displayed on boards. They are separate from integrations — an app is a visual shortcut, an integration is a data connection. - -**Permissions**: Each integration result includes a \`permissions\` object: -- \`hasUseAccess: true\` means you can read data from this integration -- \`hasInteractAccess: true\` means you can perform actions (start/stop, request media, etc.) -- \`hasFullAccess: true\` means you can modify/delete the integration config -If \`hasUseAccess\` is false, the API key owner lacks permission for that integration — this is normal, not an error. Simply skip integrations where you lack the required permission level. - -## Common Workflows - -**"What's coming this week?"** → Use \`calendar_findAllEvents\` to get upcoming media releases from Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books). - -**"Search and request a movie/show"** → Use \`integration_searchMediaRequests\` to search across Overseerr/Jellyseerr, then \`integration_requestMedia\` to submit a request. Use \`integration_getMediaRequestOptions\` first for TV shows to pick seasons. - -**"What's streaming right now?"** → Use \`mediaServer_getCurrentStreams\` to see active Plex/Jellyfin/Emby streams. - -**"Check server health"** → Use \`healthMonitoring_getSystemHealthStatus\` for NAS/server metrics or \`healthMonitoring_getClusterHealthStatus\` for Proxmox clusters. - -**"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`). - -**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs. - -**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation. - -**"Block/unblock ads"** → Use \`dnsHole_summary\` for Pi-hole/AdGuard stats, \`dnsHole_disable\` to temporarily disable blocking, \`dnsHole_enable\` to re-enable. - -**"Check downloads"** → Use \`downloads_getJobsAndStatuses\` for queue status, \`downloads_pause\`/\`downloads_resume\` to control queues. - -**"Pending media requests"** → Use \`mediaRequests_getLatestRequests\` to see pending/approved/declined requests, \`mediaRequests_answerRequest\` to approve or decline. - -## Tips -- Always call \`integration_all\` first to discover available integrations and their IDs before using integration-dependent tools. -- The \`integrationId\` parameter in widget tools accepts the ID from \`integration_all\`, not the integration name or kind. -- If a tool returns empty results, the user may not have the required integration configured. -- Docker and Kubernetes tools work only if the Homarr instance has access to the Docker socket or Kubernetes cluster.`; - -const mcpHandler = createMcpHandler( - (server) => { - server.server.setRequestHandler(ListPromptsRequestSchema, () => ({ - prompts: isCustomWidgetMcpEnabled() - ? [ - { - name: "homarr-custom-widget-author", - description: "Author and iterate on one Homarr Custom JSX v2 widget.", - arguments: [ - { name: "request", description: "The widget the user wants.", required: false }, - { name: "documentationUrl", description: "External API documentation URL.", required: false }, - ], - }, - ] - : [], - })); - - server.server.setRequestHandler(GetPromptRequestSchema, (request) => { - requireCustomWidgetMcpAdmin(); - if (request.params.name !== "homarr-custom-widget-author") throw new Error("Prompt not found"); - return { - description: "Current Homarr Custom Widget authoring workflow.", - messages: [ - { - role: "user" as const, - content: { - type: "text" as const, - text: buildCustomWidgetMcpPrompt( - request.params.arguments?.request, - request.params.arguments?.documentationUrl, - ), - }, - }, - ], - }; - }); - - server.server.setRequestHandler(ListResourcesRequestSchema, () => ({ - resources: isCustomWidgetMcpEnabled() - ? [ - { - uri: "homarr://custom-widgets/schema", - name: "Custom Widget schema", - mimeType: "application/schema+json", - }, - { - uri: "homarr://custom-widgets/components", - name: "Custom Widget components", - mimeType: "application/json", - }, - { uri: "homarr://custom-widgets/skill", name: "Custom Widget skill", mimeType: "text/markdown" }, - ...customJsxExamples.map((example) => ({ - uri: `homarr://custom-widgets/examples/${example.id}`, - name: `Custom Widget example: ${example.title}`, - mimeType: "application/json", - })), - ] - : [], - })); - - server.server.setRequestHandler(ListResourceTemplatesRequestSchema, () => ({ - resourceTemplates: isCustomWidgetMcpEnabled() - ? [ - { - uriTemplate: "homarr://custom-widgets/components/{name}", - name: "Custom Widget component", - description: "Metadata for one installed Custom Widget component.", - mimeType: "application/json", - }, - { - uriTemplate: "homarr://custom-widgets/examples/{name}", - name: "Custom Widget example", - description: "One canonical Custom Widget example.", - mimeType: "application/json", - }, - ] - : [], - })); - - server.server.setRequestHandler(ReadResourceRequestSchema, (request) => { - requireCustomWidgetMcpAdmin(); - const { uri } = request.params; - let text: string; - let mimeType = "application/json"; - if (uri === "homarr://custom-widgets/schema") { - text = JSON.stringify(getCustomWidgetJsonSchema()); - mimeType = "application/schema+json"; - } else if (uri === "homarr://custom-widgets/components") text = JSON.stringify(getCustomWidgetComponentCatalog()); - else if (uri === "homarr://custom-widgets/skill") { - text = getCustomWidgetSkillContent(); - mimeType = "text/markdown"; - } else if (uri.startsWith("homarr://custom-widgets/components/")) { - const name = decodeURIComponent(uri.slice("homarr://custom-widgets/components/".length)); - const component = getCustomWidgetComponent(name); - if (!component) throw new Error("Component resource not found"); - text = JSON.stringify(component); - } else if (uri.startsWith("homarr://custom-widgets/examples/")) { - const name = decodeURIComponent(uri.slice("homarr://custom-widgets/examples/".length)); - const example = customJsxExamples.find((candidate) => candidate.id === name); - if (!example) throw new Error("Example resource not found"); - text = JSON.stringify(example); - } else throw new Error("Resource not found"); - return { contents: [{ uri, mimeType, text }] }; - }); - - server.server.setRequestHandler(ListToolsRequestSchema, () => ({ - tools: getVisibleTools(), - })); - - server.server.setRequestHandler(CallToolRequestSchema, async (request) => { - const { name, arguments: args } = request.params; - const tools = getVisibleTools(); - const tool = tools.find((t) => t.name === name); - - if (!tool) { - return { - content: [{ type: "text" as const, text: `Tool "${name}" not found` }], - isError: true, - }; - } - - const callerContext = callerStorage.getStore(); - if (!callerContext) { - return { - content: [{ type: "text" as const, text: "Authentication context not available" }], - isError: true, - }; - } - - try { - const procedure = tool.pathInRouter.reduce((acc, part) => acc?.[part], callerContext.caller); - if (typeof procedure !== "function") { - return { - content: [{ type: "text" as const, text: `Tool "${name}" is not callable` }], - isError: true, - }; - } - const input = args && Object.keys(args).length > 0 ? args : undefined; - const result = await procedure(input); - return { - content: [{ type: "text" as const, text: JSON.stringify(result) }], - }; - } catch (error) { - const message = sanitizeErrorMessage(error); - logger.warn("MCP tool execution failed", { - tool: name, - errorName: error instanceof Error ? error.name : "UnknownError", - errorCode: - error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined, - }); - return { - content: [{ type: "text" as const, text: JSON.stringify({ error: message }) }], - isError: true, - }; - } - }); - }, - { - capabilities: { tools: {}, prompts: {}, resources: {} }, - instructions: SERVER_INSTRUCTIONS, - serverInfo: { - name: "homarr", - version: getPackageVersion(), - }, - }, - { - basePath: "/api/mcp", - maxDuration: 60, - }, -); - -function extractApiKeyValue(req: NextRequest): string | null { - const apiKeyHeader = req.headers.get(API_KEY_HEADER_NAME); - if (apiKeyHeader) return apiKeyHeader; - - const authHeader = req.headers.get("Authorization"); - if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7); - - return null; -} - -function jsonErrorResponse(status: number, body: Record, extraHeaders: Record = {}) { - return new Response(JSON.stringify(body), { - status, - headers: { "Content-Type": "application/json", ...extraHeaders }, - }); -} - -const handler = async (req: NextRequest) => { - const apiKeyValue = extractApiKeyValue(req); - const ipAddress = ipAddressFromHeaders(req.headers); - const { ua } = userAgent(req); - - if (!checkAuthRateLimit(ipAddress)) { - return jsonErrorResponse( - 429, - { error: "rate_limited", hint: "Too many failed authentication attempts. Try again later." }, - { "Retry-After": "60" }, - ); - } - - if (!apiKeyValue) { - recordAuthFailure(ipAddress); - const baseUrl = extractBaseUrlFromHeaders(req.headers); - return jsonErrorResponse( - 401, - { - error: "unauthorized", - hint: "Authenticate with an ApiKey header or via OAuth at /.well-known/oauth-authorization-server", - }, - { "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` }, - ); - } - - if (!apiKeyValue.includes(".")) { - recordAuthFailure(ipAddress); - return jsonErrorResponse(401, { error: "invalid_token", hint: "The token must be in the format '.'." }); - } - - const session = await getSessionFromApiKeyAsync(db, apiKeyValue, ipAddress, ua); - - if (!session) { - recordAuthFailure(ipAddress); - return jsonErrorResponse(401, { - error: "invalid_token", - hint: "The API key was not found or the token is incorrect.", - }); - } - - clearAuthFailures(ipAddress); - logger.info("MCP request authenticated", { userId: session.user.id }); - - const ctx = createTRPCContext({ session, headers: req.headers }); - const caller = mcpRouter.createCaller(ctx); - - return callerStorage.run({ caller, isAdmin: session.user.permissions.includes("admin") }, () => - mcpHandler(req as unknown as Request), - ); -}; - -export { handler as GET, handler as POST, handler as DELETE }; +export { handleMcpRequest as DELETE, handleMcpRequest as GET, handleMcpRequest as POST }; diff --git a/apps/nextjs/src/app/api/mcp/_extract-tools.ts b/apps/nextjs/src/app/api/mcp/_extract-tools.ts index 431ab9116..5b90d2963 100644 --- a/apps/nextjs/src/app/api/mcp/_extract-tools.ts +++ b/apps/nextjs/src/app/api/mcp/_extract-tools.ts @@ -1,23 +1,11 @@ -import { extractToolsFromProcedures } from "trpc-to-mcp"; +import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp"; -import { mcpRouter } from "@homarr/api/mcp"; - -let cache: ReturnType | null = null; +let cache: ReturnType | null = null; export function extractMcpTools() { if (cache) return cache; - const originalWarn = console.warn; - console.warn = (...args: unknown[]) => { - if (typeof args[0] === "string" && args[0].includes("[TRPC-TO-MCP]")) return; - originalWarn.apply(console, args); - }; - - try { - cache = extractToolsFromProcedures(mcpRouter); - } finally { - console.warn = originalWarn; - } + cache = extractMcpToolsFromProcedures(mcpRouter); return cache; } diff --git a/apps/nextjs/src/app/api/mcp/_handler.ts b/apps/nextjs/src/app/api/mcp/_handler.ts new file mode 100644 index 000000000..ffb3bd73d --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/_handler.ts @@ -0,0 +1,360 @@ +import type { NextRequest } from "next/server"; +import { userAgent } from "next/server"; +import { ResourceTemplate, type McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod/v4"; + +import type { McpTool } from "@homarr/api/mcp"; +import { createTRPCContext, mcpRouter } from "@homarr/api/mcp"; +import { API_KEY_HEADER_NAME, getSessionFromApiKeyAsync } from "@homarr/auth/api-key"; +import { extractBaseUrlFromHeaders } from "@homarr/common"; +import { ipAddressFromHeaders } from "@homarr/common/server"; +import { createLogger } from "@homarr/core/infrastructure/logs"; +import { buildCustomWidgetMcpPrompt } from "@homarr/custom-widgets/authoring-prompt"; +import { + getCustomWidgetComponent, + getCustomWidgetComponentCatalog, + getCustomWidgetExample, + getCustomWidgetSkillContent, +} from "@homarr/custom-widgets/authoring-resources"; +import { customJsxExamples, getCustomWidgetJsonSchema } from "@homarr/custom-widgets/core"; +import { db } from "@homarr/db"; + +import { getPackageVersion } from "~/versions/package-reader"; +import { extractMcpTools } from "./_extract-tools"; +import { createMcpProtocolHandler } from "./_protocol"; + +const logger = createLogger({ module: "mcpRoute" }); + +const AUTH_RATE_WINDOW_MS = 60_000; +const AUTH_MAX_FAILURES = 10; + +interface RateLimitStore { + authFailures: Map; + cleanupTimer: ReturnType | null; +} + +declare global { + var mcpRateLimit: RateLimitStore | undefined; +} + +const rateLimitStore = + globalThis.mcpRateLimit ?? + (globalThis.mcpRateLimit = { + authFailures: new Map(), + cleanupTimer: null, + }); +const authFailures = rateLimitStore.authFailures; + +if (!rateLimitStore.cleanupTimer) { + rateLimitStore.cleanupTimer = setInterval(() => { + const now = Date.now(); + for (const [key, entry] of authFailures) { + if (entry.resetAt < now) authFailures.delete(key); + } + }, AUTH_RATE_WINDOW_MS); + rateLimitStore.cleanupTimer.unref(); +} + +function checkAuthRateLimit(ip: string | null): boolean { + const key = ip ?? "unknown"; + const now = Date.now(); + const entry = authFailures.get(key); + if (!entry || entry.resetAt < now) return true; + return entry.count < AUTH_MAX_FAILURES; +} + +function recordAuthFailure(ip: string | null) { + const key = ip ?? "unknown"; + const now = Date.now(); + const entry = authFailures.get(key); + if (!entry || entry.resetAt < now) { + authFailures.set(key, { count: 1, resetAt: now + AUTH_RATE_WINDOW_MS }); + return; + } + entry.count++; +} + +function clearAuthFailures(ip: string | null) { + authFailures.delete(ip ?? "unknown"); +} + +let toolsCache: McpTool[] | null = null; + +function getTools() { + if (!toolsCache) { + toolsCache = extractMcpTools(); + logger.info(`Extracted ${toolsCache.length} MCP tools`); + } + return toolsCache; +} + +const getVisibleTools = (isAdmin: boolean) => + isAdmin ? getTools() : getTools().filter((tool) => !tool.name.startsWith("customWidget_")); + +function registerCustomWidgetAuthoring(server: McpServer) { + server.registerPrompt( + "homarr-custom-widget-author", + { + description: "Author and iterate on one Homarr Custom JSX v2 widget.", + argsSchema: z.object({ + request: z.string().optional().describe("The widget the user wants."), + documentationUrl: z.string().optional().describe("External API documentation URL."), + }), + }, + ({ request, documentationUrl }) => ({ + description: "Current Homarr Custom Widget authoring workflow.", + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: buildCustomWidgetMcpPrompt(request, documentationUrl), + }, + }, + ], + }), + ); + + server.registerResource( + "Custom Widget schema", + "homarr://custom-widgets/schema", + { mimeType: "application/schema+json" }, + (uri) => ({ + contents: [ + { + uri: uri.href, + mimeType: "application/schema+json", + text: JSON.stringify(getCustomWidgetJsonSchema()), + }, + ], + }), + ); + server.registerResource( + "Custom Widget components", + "homarr://custom-widgets/components", + { mimeType: "application/json" }, + (uri) => ({ + contents: [ + { uri: uri.href, mimeType: "application/json", text: JSON.stringify(getCustomWidgetComponentCatalog()) }, + ], + }), + ); + server.registerResource( + "Custom Widget skill", + "homarr://custom-widgets/skill", + { mimeType: "text/markdown" }, + (uri) => ({ + contents: [{ uri: uri.href, mimeType: "text/markdown", text: getCustomWidgetSkillContent() }], + }), + ); + + for (const example of customJsxExamples) { + server.registerResource( + `Custom Widget example: ${example.title}`, + `homarr://custom-widgets/examples/${example.id}`, + { mimeType: "application/json" }, + (uri) => ({ + contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(example) }], + }), + ); + } + + server.registerResource( + "Custom Widget component", + new ResourceTemplate("homarr://custom-widgets/components/{name}", { list: undefined }), + { + description: "Metadata for one installed Custom Widget component.", + mimeType: "application/json", + }, + (uri, { name }) => { + const component = getCustomWidgetComponent(decodeURIComponent(String(name))); + if (!component) throw new Error("Component resource not found"); + return { + contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(component) }], + }; + }, + ); + server.registerResource( + "Custom Widget example", + new ResourceTemplate("homarr://custom-widgets/examples/{name}", { list: undefined }), + { + description: "One canonical Custom Widget example.", + mimeType: "application/json", + }, + (uri, { name }) => { + const example = getCustomWidgetExample(decodeURIComponent(String(name))); + if (!example) throw new Error("Example resource not found"); + return { + contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(example) }], + }; + }, + ); +} + +const SAFE_ERROR_MESSAGES: Record = { + UNAUTHORIZED: "You do not have permission to perform this action", + FORBIDDEN: "Access denied", + NOT_FOUND: "The requested resource was not found", + BAD_REQUEST: "Invalid request parameters", + INTERNAL_SERVER_ERROR: "An internal error occurred", + TIMEOUT: "The operation timed out", + TOO_MANY_REQUESTS: "Rate limit exceeded, try again later", + CONFLICT: "A conflict occurred with the current state", + PRECONDITION_FAILED: "A precondition for this operation was not met", +}; + +function sanitizeErrorMessage(error: unknown): string { + if (error instanceof Error && "code" in error) { + const trpcCode = (error as Error & { code: string }).code; + const safeMessage = SAFE_ERROR_MESSAGES[trpcCode]; + if (safeMessage) return safeMessage; + } + if (error instanceof Error && error.message.length < 200 && !error.message.includes("/")) { + return error.message; + } + return "Tool execution failed"; +} + +const SERVER_INSTRUCTIONS = `You are connected to Homarr, a self-hosted homelab dashboard. + +## Key Concepts + +**Integrations** are connections to external self-hosted services (Sonarr, Radarr, Plex, Overseerr, Pi-hole, Home Assistant, etc.). Each integration has an \`id\` (integrationId) and a \`kind\` (e.g. "sonarr", "radarr", "overseerr"). Use \`integration_all\` to list them. Many tools require an \`integrationId\` — always fetch it from \`integration_all\` or \`integration_search\` first. + +**Boards** are customizable dashboards containing widgets that display data from integrations. Users can have multiple boards. + +**Apps** are links/bookmarks to self-hosted services displayed on boards. They are separate from integrations — an app is a visual shortcut, an integration is a data connection. + +**Permissions**: Each integration result includes a \`permissions\` object: +- \`hasUseAccess: true\` means you can read data from this integration +- \`hasInteractAccess: true\` means you can perform actions (start/stop, request media, etc.) +- \`hasFullAccess: true\` means you can modify/delete the integration config +If \`hasUseAccess\` is false, the API key owner lacks permission for that integration — this is normal, not an error. Simply skip integrations where you lack the required permission level. + +## Common Workflows + +**"What's coming this week?"** → Use \`calendar_findAllEvents\` to get upcoming media releases from Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books). + +**"Search and request a movie/show"** → Use \`integration_searchMediaRequests\` to search across Overseerr/Jellyseerr, then \`integration_requestMedia\` to submit a request. Use \`integration_getMediaRequestOptions\` first for TV shows to pick seasons. + +**"What's streaming right now?"** → Use \`mediaServer_getCurrentStreams\` to see active Plex/Jellyfin/Emby streams. + +**"Check server health"** → Use \`healthMonitoring_getSystemHealthStatus\` for NAS/server metrics or \`healthMonitoring_getClusterHealthStatus\` for Proxmox clusters. + +**"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`). + +**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs. + +**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation. + +**"Block/unblock ads"** → Use \`dnsHole_summary\` for Pi-hole/AdGuard stats, \`dnsHole_disable\` to temporarily disable blocking, \`dnsHole_enable\` to re-enable. + +**"Check downloads"** → Use \`downloads_getJobsAndStatuses\` for queue status, \`downloads_pause\`/\`downloads_resume\` to control queues. + +**"Pending media requests"** → Use \`mediaRequests_getLatestRequests\` to see pending/approved/declined requests, \`mediaRequests_answerRequest\` to approve or decline. + +## Tips +- Always call \`integration_all\` first to discover available integrations and their IDs before using integration-dependent tools. +- The \`integrationId\` parameter in widget tools accepts the ID from \`integration_all\`, not the integration name or kind. +- If a tool returns empty results, the user may not have the required integration configured. +- Docker and Kubernetes tools work only if the Homarr instance has access to the Docker socket or Kubernetes cluster.`; + +function extractApiKeyValue(req: NextRequest): string | null { + const apiKeyHeader = req.headers.get(API_KEY_HEADER_NAME); + if (apiKeyHeader) return apiKeyHeader; + + const authHeader = req.headers.get("Authorization"); + if (authHeader?.startsWith("Bearer ")) return authHeader.slice(7); + + return null; +} + +function jsonErrorResponse(status: number, body: Record, extraHeaders: Record = {}) { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json", ...extraHeaders }, + }); +} + +export const handleMcpRequest = async (req: NextRequest) => { + const pathname = new URL(req.url).pathname; + if (pathname.endsWith("/sse") || pathname.endsWith("/message")) { + return jsonErrorResponse(410, { + error: "transport_removed", + hint: "Legacy HTTP+SSE was removed. Connect to the stateless Streamable HTTP endpoint at /api/mcp.", + }); + } + + const apiKeyValue = extractApiKeyValue(req); + const ipAddress = ipAddressFromHeaders(req.headers); + const { ua } = userAgent(req); + + if (!checkAuthRateLimit(ipAddress)) { + return jsonErrorResponse( + 429, + { error: "rate_limited", hint: "Too many failed authentication attempts. Try again later." }, + { "Retry-After": "60" }, + ); + } + + if (!apiKeyValue) { + recordAuthFailure(ipAddress); + const baseUrl = extractBaseUrlFromHeaders(req.headers); + return jsonErrorResponse( + 401, + { + error: "unauthorized", + hint: "Authenticate with an ApiKey header or via OAuth at /.well-known/oauth-authorization-server", + }, + { "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource"` }, + ); + } + + if (!apiKeyValue.includes(".")) { + recordAuthFailure(ipAddress); + return jsonErrorResponse(401, { error: "invalid_token", hint: "The token must be in the format '.'." }); + } + + const session = await getSessionFromApiKeyAsync(db, apiKeyValue, ipAddress, ua); + + if (!session) { + recordAuthFailure(ipAddress); + return jsonErrorResponse(401, { + error: "invalid_token", + hint: "The API key was not found or the token is incorrect.", + }); + } + + clearAuthFailures(ipAddress); + logger.info("MCP request authenticated", { userId: session.user.id }); + + const ctx = createTRPCContext({ session, headers: req.headers }); + const caller = mcpRouter.createCaller(ctx); + const isAdmin = session.user.permissions.includes("admin"); + const protocolHandler = createMcpProtocolHandler({ + caller, + tools: getVisibleTools(isAdmin), + version: getPackageVersion(), + instructions: SERVER_INSTRUCTIONS, + configureServer: isAdmin ? registerCustomWidgetAuthoring : undefined, + formatToolError: sanitizeErrorMessage, + onToolError: (tool, error) => { + logger.warn("MCP tool execution failed", { + tool, + errorName: error instanceof Error ? error.name : "UnknownError", + errorCode: error instanceof Error && "code" in error && typeof error.code === "string" ? error.code : undefined, + }); + }, + onProtocolError: (error) => { + logger.warn("MCP protocol request failed", { error: error.message }); + }, + }); + + return protocolHandler.fetch(req, { + authInfo: { + token: apiKeyValue, + clientId: session.user.id, + scopes: ["mcp:tools"], + }, + }); +}; diff --git a/apps/nextjs/src/app/api/mcp/_protocol.spec.ts b/apps/nextjs/src/app/api/mcp/_protocol.spec.ts new file mode 100644 index 000000000..3259afb0f --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/_protocol.spec.ts @@ -0,0 +1,140 @@ +// @vitest-environment node + +import { describe, expect, test, vi } from "vitest"; +import { z } from "zod/v4"; + +import type { McpTool } from "@homarr/api/mcp"; + +import { createMcpProtocolHandler } from "./_protocol"; + +const modernMetadata = { + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": { name: "homarr-test", version: "1.0.0" }, + "io.modelcontextprotocol/clientCapabilities": {}, +}; + +const createRequest = (method: string, params: Record = {}, name = "homarr") => + new Request("http://homarr.test/api/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + "Mcp-Method": method, + "Mcp-Name": name, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method, + params: { ...params, _meta: modernMetadata }, + }), + }); + +const parseResponse = async (response: Response) => { + expect(response.status).toBe(200); + const body = await response.text(); + const json = response.headers.get("Content-Type")?.includes("text/event-stream") + ? body + .split("\n") + .find((line) => line.startsWith("data: ")) + ?.slice(6) + : body; + expect(json).toBeTruthy(); + + return JSON.parse(json ?? "") as { + result: Record; + }; +}; + +const echoValidator = z.object({ message: z.string() }); +const echoTool: McpTool = { + name: "echo", + description: "Echo a message", + pathInRouter: ["echo"], + inputValidator: echoValidator, + inputSchema: z.toJSONSchema(echoValidator), +}; + +const createTestHandler = () => { + const echo = vi.fn(async (input: unknown) => input); + const handler = createMcpProtocolHandler({ + caller: { echo }, + tools: [echoTool], + version: "1.0.0", + instructions: "Test server", + formatToolError: () => "Tool failed", + }); + + return { echo, handler }; +}; + +describe("MCP v2 protocol", () => { + test("discovers the stateless 2026-07-28 server", async () => { + const { handler } = createTestHandler(); + const response = await parseResponse(await handler.fetch(createRequest("server/discover"))); + + expect(response.result.supportedVersions).toEqual(["2026-07-28"]); + expect(response.result).toMatchObject({ resultType: "complete", cacheScope: "private" }); + }); + + test("returns a deterministic, privately cacheable tool catalog", async () => { + const { handler } = createTestHandler(); + const response = await parseResponse(await handler.fetch(createRequest("tools/list"))); + + expect(response.result).toMatchObject({ + resultType: "complete", + ttlMs: 300_000, + cacheScope: "private", + tools: [{ name: "echo", description: "Echo a message" }], + }); + }); + + test("validates and calls Homarr procedures without a protocol session", async () => { + const { echo, handler } = createTestHandler(); + const response = await parseResponse( + await handler.fetch(createRequest("tools/call", { name: "echo", arguments: { message: "hello" } }, "echo")), + ); + + expect(echo).toHaveBeenCalledWith({ message: "hello" }); + expect(response.result.content).toEqual([{ type: "text", text: '{"message":"hello"}' }]); + }); + + test("keeps stateless compatibility with 2025 Streamable HTTP clients", async () => { + const { handler } = createTestHandler(); + const request = new Request("http://homarr.test/api/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "legacy-test", version: "1.0.0" }, + }, + }), + }); + + const initializeResponse = await handler.fetch(request); + expect(initializeResponse.headers.has("Mcp-Session-Id")).toBe(false); + const response = await parseResponse(initializeResponse); + expect(response.result.protocolVersion).toBe("2025-03-26"); + + const listResponse = await handler.fetch( + new Request("http://homarr.test/api/mcp", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "tools/list" }), + }), + ); + expect(listResponse.headers.has("Mcp-Session-Id")).toBe(false); + expect((await parseResponse(listResponse)).result.tools).toEqual([expect.objectContaining({ name: "echo" })]); + }); +}); diff --git a/apps/nextjs/src/app/api/mcp/_protocol.ts b/apps/nextjs/src/app/api/mcp/_protocol.ts new file mode 100644 index 000000000..fc17aba4e --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/_protocol.ts @@ -0,0 +1,89 @@ +import { createMcpHandler, McpServer } from "@modelcontextprotocol/server"; + +import type { McpTool } from "@homarr/api/mcp"; + +interface CreateMcpProtocolHandlerOptions { + caller: unknown; + tools: McpTool[]; + version: string; + instructions: string; + configureServer?: (server: McpServer) => void; + formatToolError: (error: unknown) => string; + onToolError?: (toolName: string, error: unknown) => void; + onProtocolError?: (error: Error) => void; +} + +const resolveProcedure = (caller: unknown, path: string[]) => + path.reduce( + (current, segment) => + (typeof current === "object" || typeof current === "function") && current !== null + ? (current as Record)[segment] + : undefined, + caller, + ); + +export const createMcpProtocolHandler = ({ + caller, + tools, + version, + instructions, + configureServer, + formatToolError, + onToolError, + onProtocolError, +}: CreateMcpProtocolHandlerOptions) => + createMcpHandler( + () => { + const server = new McpServer( + { name: "homarr", version }, + { + capabilities: { tools: {} }, + instructions, + cacheHints: { + "tools/list": { ttlMs: 300_000, cacheScope: "private" }, + }, + }, + ); + + for (const tool of tools) { + server.registerTool( + tool.name, + { + description: tool.description, + inputSchema: tool.inputValidator, + }, + async (args) => { + try { + const procedure = resolveProcedure(caller, tool.pathInRouter); + if (typeof procedure !== "function") { + return { + content: [{ type: "text" as const, text: `Tool "${tool.name}" is not callable` }], + isError: true, + }; + } + + const input = Object.keys(args).length > 0 ? args : undefined; + const result = await (procedure as (value: unknown) => Promise)(input); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + }; + } catch (error) { + onToolError?.(tool.name, error); + return { + content: [{ type: "text" as const, text: JSON.stringify({ error: formatToolError(error) }) }], + isError: true, + }; + } + }, + ); + } + + configureServer?.(server); + + return server; + }, + { + legacy: "stateless", + onerror: onProtocolError, + }, + ); diff --git a/apps/nextjs/src/app/api/mcp/oauth/_store.ts b/apps/nextjs/src/app/api/mcp/oauth/_store.ts index 14352a41b..4cbd61def 100644 --- a/apps/nextjs/src/app/api/mcp/oauth/_store.ts +++ b/apps/nextjs/src/app/api/mcp/oauth/_store.ts @@ -15,6 +15,7 @@ interface AuthCode { codeChallenge: string; codeChallengeMethod: string; redirectUri: string; + resource: string | null; expiresAt: number; } @@ -24,6 +25,7 @@ interface PendingAuth { codeChallenge: string; codeChallengeMethod: string; state: string | null; + resource: string | null; expiresAt: number; } @@ -102,6 +104,7 @@ export function createAuthCode( codeChallenge: string, codeChallengeMethod: string, redirectUri: string, + resource: string | null, ) { if (authCodes.size >= MAX_AUTH_CODES) { return null; @@ -115,6 +118,7 @@ export function createAuthCode( codeChallenge, codeChallengeMethod, redirectUri, + resource, expiresAt: Date.now() + AUTH_CODE_TTL_MS, }); return code; diff --git a/apps/nextjs/src/app/api/mcp/oauth/authorize/route.ts b/apps/nextjs/src/app/api/mcp/oauth/authorize/route.ts index 927cdf60f..ec9974cb5 100644 --- a/apps/nextjs/src/app/api/mcp/oauth/authorize/route.ts +++ b/apps/nextjs/src/app/api/mcp/oauth/authorize/route.ts @@ -1,6 +1,7 @@ import type { NextRequest } from "next/server"; import { auth } from "@homarr/auth/next"; +import { extractBaseUrlFromHeaders } from "@homarr/common"; import { consumePendingAuth, createAuthCode, getClient, storePendingAuth } from "../_store"; @@ -37,6 +38,7 @@ export async function GET(req: NextRequest) { pending.codeChallenge, pending.codeChallengeMethod, pending.redirectUri, + pending.resource, ); if (!code) { return Response.json( @@ -59,6 +61,7 @@ export async function GET(req: NextRequest) { const codeChallenge = url.searchParams.get("code_challenge"); const codeChallengeMethod = url.searchParams.get("code_challenge_method") ?? "S256"; const state = url.searchParams.get("state"); + const resource = url.searchParams.get("resource"); if (!clientId || !redirectUri || !codeChallenge) { return Response.json( @@ -80,6 +83,17 @@ export async function GET(req: NextRequest) { ); } + const expectedResource = `${extractBaseUrlFromHeaders(req.headers)}/api/mcp`; + if (resource && resource !== expectedResource) { + return Response.json( + { + error: "invalid_target", + error_description: "The resource must be the canonical Homarr MCP endpoint", + }, + { status: 400 }, + ); + } + const client = getClient(clientId); if (!client) { return Response.json( @@ -103,7 +117,7 @@ export async function GET(req: NextRequest) { } if (session?.user) { - const code = createAuthCode(clientId, session.user.id, codeChallenge, codeChallengeMethod, redirectUri); + const code = createAuthCode(clientId, session.user.id, codeChallenge, codeChallengeMethod, redirectUri, resource); if (!code) { return Response.json( { @@ -126,6 +140,7 @@ export async function GET(req: NextRequest) { codeChallenge, codeChallengeMethod, state, + resource, }); if (!id) { return Response.json( diff --git a/apps/nextjs/src/app/api/mcp/oauth/token/route.ts b/apps/nextjs/src/app/api/mcp/oauth/token/route.ts index 1122e1950..895ec9520 100644 --- a/apps/nextjs/src/app/api/mcp/oauth/token/route.ts +++ b/apps/nextjs/src/app/api/mcp/oauth/token/route.ts @@ -80,7 +80,7 @@ export async function POST(req: Request) { ); } - const { grant_type, code, code_verifier, redirect_uri } = params; + const { grant_type, code, code_verifier, redirect_uri, resource } = params; if (grant_type !== "authorization_code") { return Response.json({ error: "unsupported_grant_type" }, { status: 400 }); @@ -111,6 +111,11 @@ export async function POST(req: Request) { return Response.json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, { status: 400 }); } + if (authCode.resource && resource !== authCode.resource) { + recordTokenFailure(ip); + return Response.json({ error: "invalid_target", error_description: "resource mismatch" }, { status: 400 }); + } + if (!verifyPkce(code_verifier, authCode.codeChallenge, authCode.codeChallengeMethod)) { recordTokenFailure(ip); return Response.json({ error: "invalid_grant", error_description: "PKCE verification failed" }, { status: 400 }); diff --git a/apps/nextjs/src/app/api/mcp/route.ts b/apps/nextjs/src/app/api/mcp/route.ts new file mode 100644 index 000000000..edc0a0d9d --- /dev/null +++ b/apps/nextjs/src/app/api/mcp/route.ts @@ -0,0 +1 @@ +export { handleMcpRequest as POST } from "./_handler"; diff --git a/packages/api/src/mcp-tools.ts b/packages/api/src/mcp-tools.ts new file mode 100644 index 000000000..b3e2d058e --- /dev/null +++ b/packages/api/src/mcp-tools.ts @@ -0,0 +1,83 @@ +import type { AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import"; +import { z } from "zod/v4"; + +export interface McpMeta { + mcp?: { + enabled: boolean; + name?: string; + description?: string; + }; +} + +export interface McpTool { + name: string; + description: string; + pathInRouter: string[]; + inputSchema: z.core.JSONSchema.JSONSchema; + inputValidator: z.ZodObject; +} + +const emptyInput = z.object({}); + +const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput); + +const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => { + const properties = (schema.properties ?? {}) as Record>; + const required = ((schema.required as string[] | undefined) ?? []).filter( + (key) => !("default" in (properties[key] ?? {})), + ); + + return { + ...schema, + type: "object", + properties, + required, + }; +}; + +export function extractMcpToolsFromProcedures( + router: Router, +): McpTool[] { + const tools: McpTool[] = []; + + for (const [path, procedure] of Object.entries(router["_def"].procedures)) { + const definition = procedure["_def"] as unknown as { + inputs?: z.ZodType[]; + meta?: McpMeta; + }; + const metadata = definition.meta?.mcp; + if (!metadata?.enabled) continue; + + const inputs = (definition.inputs ?? []).filter((input): input is z.ZodType => input !== undefined); + const procedureInput = + inputs.length === 0 + ? emptyInput + : inputs.length === 1 + ? (inputs[0] ?? emptyInput) + : inputs.every((input): input is z.ZodObject => input instanceof z.ZodObject) + ? mergeInputs(inputs) + : null; + + if ( + procedureInput === null || + (!(procedureInput instanceof z.ZodObject) && !procedureInput.safeParse(undefined).success) + ) { + console.warn(`[MCP] Procedure ${path} does not use an object input schema; using an empty schema`); + } + const inputValidator = procedureInput instanceof z.ZodObject ? procedureInput : emptyInput; + + tools.push({ + name: metadata.name ?? path.replaceAll(".", "_"), + description: metadata.description ?? "", + pathInRouter: path.split("."), + inputValidator, + inputSchema: normalizeJsonSchema( + z.toJSONSchema(inputValidator, { + unrepresentable: "any", + }), + ), + }); + } + + return tools.toSorted((left, right) => left.name.localeCompare(right.name)); +} diff --git a/packages/api/src/mcp.ts b/packages/api/src/mcp.ts index 4892ada81..2f1a81822 100644 --- a/packages/api/src/mcp.ts +++ b/packages/api/src/mcp.ts @@ -1,4 +1,6 @@ export { createTRPCContext } from "./trpc"; +export { extractMcpToolsFromProcedures } from "./mcp-tools"; +export type { McpTool } from "./mcp-tools"; import { createTRPCRouter } from "./trpc"; import { appRouter as appRouterForApps } from "./router/app"; diff --git a/packages/api/src/trpc.ts b/packages/api/src/trpc.ts index b3fe7ae4d..2401a4650 100644 --- a/packages/api/src/trpc.ts +++ b/packages/api/src/trpc.ts @@ -8,7 +8,6 @@ */ import { initTRPC, TRPCError } from "@trpc/server"; import superjson from "superjson"; -import type { McpMeta } from "trpc-to-mcp"; import type { OpenApiMeta } from "trpc-to-openapi"; import { ZodError } from "zod/v4"; @@ -22,6 +21,7 @@ import type { GroupPermissionKey, OnboardingStep } from "@homarr/definitions"; import { env } from "./env"; import { getOnboardingOrFallbackAsync } from "./router/onboard/onboard-queries"; +import type { McpMeta } from "./mcp-tools"; const logger = createLogger({ module: "trpc" }); diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f7f9d9847..5a2b0ee61 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -34,7 +34,7 @@ catalog: "@immich/sdk": ^3.0.2 "@jellyfin/sdk": ^0.13.0 "@kubernetes/client-node": ^1.4.0 - "@modelcontextprotocol/sdk": 1.29.0 + "@modelcontextprotocol/server": 2.0.0 "@mantine/charts": ^9.4.1 "@mantine/colors-generator": ^9.4.1 "@mantine/core": ^9.4.1 @@ -165,7 +165,6 @@ catalog: mantine-datatable: ^9.3.1 mantine-react-table: npm:mantine-react-table-open@9.0.3 mermaid: ^11.15.0 - mcp-handler: ^1.1.0 maria2: ^0.4.1 mysql2: 3.22.6 next: 16.3.0-preview.9 @@ -197,7 +196,6 @@ catalog: svgson: ^5.3.1 testcontainers: ^12.0.4 timezones-ical-library: ^2.2.1 - trpc-to-mcp: ^1.3.2 trpc-to-openapi: ^3.3.0 tsdav: ^2.3.0 tsx: 4.20.4