Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc0680a18c | ||
|
|
3eb642c71b | ||
|
|
f41b544828 | ||
|
|
a7508eab00 | ||
|
|
8d5166ce0a |
No files matched your search
@@ -8,18 +8,17 @@ tags:
|
|||||||
|
|
||||||
# Model Context Protocol (MCP)
|
# Model Context Protocol (MCP)
|
||||||
|
|
||||||
Homarr exposes a stateless [Model Context Protocol](https://modelcontextprotocol.io) server at `/api/mcp`. It maps the
|
Connect an AI client to Homarr to manage boards, apps, integrations, and other services through MCP tools.
|
||||||
current Homarr API to tools for compatible AI clients.
|
|
||||||
|
Homarr uses MCP v2 (protocol `2026-07-28`) at `/api/mcp`, with compatibility for 2025 Streamable HTTP clients.
|
||||||
|
Requests are stateless: each request authenticates independently, without an MCP session ID.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
The endpoint implements MCP `2026-07-28` and accepts 2025 Streamable HTTP clients through a compatibility path. The old
|
## Connect a client
|
||||||
`/api/mcp/mcp` route remains as an alias; use `/api/mcp` for new clients.
|
|
||||||
|
|
||||||
## Configure a client
|
Create an API key under **Management → Tools → API → Authentication**. Add this configuration to your client,
|
||||||
|
replacing the URL and API key:
|
||||||
Create an API key under **Management → Tools → API → Authentication**, then add the endpoint and header to the client's
|
|
||||||
MCP configuration:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -34,7 +33,10 @@ MCP configuration:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
|
Use the complete key, including the ID, dot, and token. Keep it private and use HTTPS for remote connections.
|
||||||
|
The **AI / MCP** tab on Homarr's API page provides your endpoint, configuration, and available tools.
|
||||||
|
|
||||||
|
For clients that only support STDIO, use `mcp-remote`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -47,29 +49,30 @@ Clients without Streamable HTTP support can use `mcp-remote` over STDIO:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
The **AI / MCP** tab on Homarr's API page shows the current endpoint, configuration, and generated tool catalog. Filter
|
### OAuth
|
||||||
the catalog by namespace, tool name, or description; matching categories expand automatically.
|
|
||||||
|
|
||||||
## Authentication and permissions
|
Clients with OAuth support can authorize through OAuth 2.1 with PKCE instead of an API key.
|
||||||
|
Homarr publishes discovery metadata under `/.well-known/`.
|
||||||
|
|
||||||
MCP supports API keys and OAuth 2.1 with PKCE. API keys are simpler for direct client configuration; OAuth is available
|
If your reverse proxy does not preserve the public host and protocol, set `BASE_URL` to your public origin,
|
||||||
for clients that support dynamic authorization.
|
such as `https://homarr.example.com`, without a path.
|
||||||
|
|
||||||
Copy the complete generated API key, including both parts and the dot: `<id>.<token>`. Sending only the ID or token
|
## Permissions and tools
|
||||||
returns `invalid_token`. Keep the full key private when sharing client configuration or diagnostics.
|
|
||||||
|
|
||||||
When Homarr is behind a reverse proxy that does not preserve the public host and protocol, set `BASE_URL` to the exact
|
Tools use the permissions of the API key owner or OAuth user. Queries read data; mutations change it.
|
||||||
public origin, for example `https://homarr.example.com`. Do not include a path. OAuth clients discover the authorization
|
Inspect the **AI / MCP** tab or your client's tool list for available actions.
|
||||||
server and protected resource through Homarr's `/.well-known/` routes.
|
|
||||||
|
|
||||||
Tools use the permissions of the API key owner or authorized user. Creating a key does not grant additional access.
|
Custom Widget and Workshop authoring tools, prompts, and resources require administrator access.
|
||||||
Custom Widget and Workshop authoring tools, prompts, and resources are visible only to administrators.
|
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
|
||||||
|
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
|
||||||
|
through MCP.
|
||||||
|
|
||||||
Use HTTPS when the endpoint crosses an untrusted network.
|
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
|
||||||
|
before mutations by default.
|
||||||
|
|
||||||
## Verify the endpoint
|
## Verify the connection
|
||||||
|
|
||||||
The 2025 compatibility path accepts a compact `tools/list` request:
|
This request lists tools through the older-client compatibility path. It checks authentication and tool discovery:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -X POST \
|
curl -X POST \
|
||||||
@@ -80,25 +83,14 @@ curl -X POST \
|
|||||||
https://homarr.example.com/api/mcp
|
https://homarr.example.com/api/mcp
|
||||||
```
|
```
|
||||||
|
|
||||||
The removed HTTP+SSE `/sse` and `/message` transports return `410 Gone`.
|
A successful discovery or initialization response alone does not verify that tools can be listed.
|
||||||
|
|
||||||
Successful `server/discover` or `initialize` responses confirm connectivity, but do not validate the tool catalog.
|
## Troubleshooting
|
||||||
Use `tools/list` above to check that the client can discover tools. If it returns
|
|
||||||
`Date cannot be represented in JSON Schema`, the server failed to generate a tool schema; changing the API key will
|
|
||||||
not resolve that error. Update Homarr to a release containing the invitation date schema fix.
|
|
||||||
|
|
||||||
## Tools and resources
|
- **`invalid_token`**: copy the complete API key in `<id>.<token>` format.
|
||||||
|
- **Missing tools**: check the user's permissions. Invalid tool schemas and duplicate names are omitted; check server
|
||||||
The generated catalog includes available board, app, integration, Docker, Kubernetes, media, DNS, download, and other
|
logs for `MCP tool omitted from catalog` and report the diagnostic with your Homarr version. Exclude API keys.
|
||||||
Homarr procedures. Queries are read-only; mutations change state. The exact catalog depends on the release and current
|
- **`Date cannot be represented in JSON Schema`**: update Homarr. Older versions could fail to list all tools because
|
||||||
user permissions, so inspect `tools/list` or the table in Homarr instead of relying on a static list.
|
of an unsupported date schema.
|
||||||
|
- **`410 Gone` on `/sse` or `/message`**: use `/api/mcp` with a Streamable HTTP client. The old `/api/mcp/mcp` URL remains
|
||||||
The `invite_createInvite` tool accepts `expirationDate` as an ISO 8601 timestamp with a timezone, such as
|
an alias.
|
||||||
`2026-12-01T18:00:00Z` or `2026-12-01T19:00:00+01:00`.
|
|
||||||
|
|
||||||
For Custom Widgets, install the official skill and follow the preview-and-evidence workflow described in
|
|
||||||
[Connect an agent](./custom-widgets/agent-authoring). Secret values are configured in Homarr and are never returned
|
|
||||||
through MCP.
|
|
||||||
|
|
||||||
The built-in [Assistant](./assistant) uses the same tool catalog with the current Homarr session and asks for approval
|
|
||||||
before mutations by default.
|
|
||||||
@@ -7,7 +7,7 @@ import { createUIMessageStream, createUIMessageStreamResponse, jsonSchema, stepC
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
import { callMcpTool, createTRPCContext, mcpRouter } from "@homarr/api/mcp";
|
||||||
import {
|
import {
|
||||||
createAssistantGenerationAccessToken,
|
createAssistantGenerationAccessToken,
|
||||||
getAssistantRequestContextEntitiesAsync,
|
getAssistantRequestContextEntitiesAsync,
|
||||||
@@ -547,19 +547,7 @@ export async function POST(request: Request) {
|
|||||||
}
|
}
|
||||||
if (contextRequestKey !== null) loadedCustomWidgetContextRequests.add(contextRequestKey);
|
if (contextRequestKey !== null) loadedCustomWidgetContextRequests.add(contextRequestKey);
|
||||||
try {
|
try {
|
||||||
const procedure = mcpTool.pathInRouter.reduce<unknown>(
|
const result = await callMcpTool(caller, mcpTool, executionInput);
|
||||||
(current, segment) =>
|
|
||||||
(typeof current === "object" || typeof current === "function") && current !== null
|
|
||||||
? (current as Record<string, unknown>)[segment]
|
|
||||||
: undefined,
|
|
||||||
caller,
|
|
||||||
);
|
|
||||||
if (typeof procedure !== "function") {
|
|
||||||
throw new Error("Procedure not callable");
|
|
||||||
}
|
|
||||||
const result = await (procedure as (value: unknown) => Promise<unknown>)(
|
|
||||||
executionInput && Object.keys(executionInput as object).length > 0 ? executionInput : undefined,
|
|
||||||
);
|
|
||||||
customWidgetDiscoveryPhase.observe(mcpTool.name, result);
|
customWidgetDiscoveryPhase.observe(mcpTool.name, result);
|
||||||
return toAssistantToolOutput(result, {
|
return toAssistantToolOutput(result, {
|
||||||
maxCharacters: getAssistantToolOutputMaxCharacters(mcpTool.name),
|
maxCharacters: getAssistantToolOutputMaxCharacters(mcpTool.name),
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
import type * as CatalogModule from "../_extract-tools";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
authenticate: vi.fn(),
|
authenticate: vi.fn(),
|
||||||
|
extractCatalog: vi.fn(),
|
||||||
ipAddress: vi.fn(() => "127.0.0.1"),
|
ipAddress: vi.fn(() => "127.0.0.1"),
|
||||||
toolProcedure: vi.fn(),
|
toolProcedure: vi.fn(),
|
||||||
loggerWarn: vi.fn(),
|
loggerWarn: vi.fn(),
|
||||||
@@ -14,7 +17,9 @@ const mocks = vi.hoisted(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
|
vi.mock("next/server", () => ({ userAgent: () => ({ ua: "MCP route test" }) }));
|
||||||
vi.mock("@homarr/api/mcp", () => ({
|
vi.mock("@homarr/api/mcp", async () => ({
|
||||||
|
callMcpTool: (await import("../../../../../../../packages/api/src/mcp-tools")).callMcpTool,
|
||||||
|
extractMcpToolsFromProcedures: mocks.extractCatalog,
|
||||||
createTRPCContext: vi.fn(() => ({})),
|
createTRPCContext: vi.fn(() => ({})),
|
||||||
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
|
mcpRouter: { createCaller: vi.fn(() => ({ board: { getAllBoards: mocks.toolProcedure } })) },
|
||||||
}));
|
}));
|
||||||
@@ -54,6 +59,7 @@ vi.mock("../_extract-tools", () => ({
|
|||||||
description: "List boards",
|
description: "List boards",
|
||||||
type: "query",
|
type: "query",
|
||||||
pathInRouter: ["board", "getAllBoards"],
|
pathInRouter: ["board", "getAllBoards"],
|
||||||
|
inputMode: "none",
|
||||||
inputSchema: { type: "object", properties: {} },
|
inputSchema: { type: "object", properties: {} },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,6 +67,7 @@ vi.mock("../_extract-tools", () => ({
|
|||||||
description: "List Custom Widgets",
|
description: "List Custom Widgets",
|
||||||
type: "query",
|
type: "query",
|
||||||
pathInRouter: ["customWidget", "list"],
|
pathInRouter: ["customWidget", "list"],
|
||||||
|
inputMode: "none",
|
||||||
inputSchema: { type: "object", properties: {} },
|
inputSchema: { type: "object", properties: {} },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,6 +75,7 @@ vi.mock("../_extract-tools", () => ({
|
|||||||
description: "Search Workshop",
|
description: "Search Workshop",
|
||||||
type: "query",
|
type: "query",
|
||||||
pathInRouter: ["customWidget", "workshopSearch"],
|
pathInRouter: ["customWidget", "workshopSearch"],
|
||||||
|
inputMode: "none",
|
||||||
inputSchema: { type: "object", properties: {} },
|
inputSchema: { type: "object", properties: {} },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -305,3 +313,21 @@ describe("MCP protocol errors", () => {
|
|||||||
expect(metadata).not.toHaveProperty("message");
|
expect(metadata).not.toHaveProperty("message");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("logs catalog omissions only when the shared catalog is first built", async () => {
|
||||||
|
const diagnostic = { name: "broken", path: "broken", reason: "invalid_schema" };
|
||||||
|
const tools = [{ name: "valid" }];
|
||||||
|
mocks.extractCatalog.mockReturnValue({ tools, diagnostics: [diagnostic] });
|
||||||
|
const { extractMcpTools } = await vi.importActual<typeof CatalogModule>("../_extract-tools");
|
||||||
|
expect(extractMcpTools()).toBe(tools);
|
||||||
|
expect(extractMcpTools()).toBe(tools);
|
||||||
|
expect(mocks.extractCatalog).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.loggerWarn).toHaveBeenCalledExactlyOnceWith("MCP tool omitted from catalog", diagnostic);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unauthenticated tool calls before execution", async () => {
|
||||||
|
mocks.authenticate.mockResolvedValue(null);
|
||||||
|
const { response } = await callMcp("tools/call", { name: "board_getAllBoards", arguments: {} });
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(mocks.toolProcedure).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
|
import { extractMcpToolsFromProcedures, mcpRouter } from "@homarr/api/mcp";
|
||||||
|
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||||
|
|
||||||
|
const logger = createLogger({ module: "mcp-catalog" });
|
||||||
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
|
let cache: ReturnType<typeof extractMcpToolsFromProcedures> | null = null;
|
||||||
|
|
||||||
export function extractMcpTools() {
|
export function extractMcpTools() {
|
||||||
if (cache) return cache;
|
if (cache) return cache.tools;
|
||||||
|
|
||||||
cache = extractMcpToolsFromProcedures(mcpRouter);
|
cache = extractMcpToolsFromProcedures(mcpRouter);
|
||||||
|
for (const diagnostic of cache.diagnostics) logger.warn("MCP tool omitted from catalog", diagnostic);
|
||||||
return cache;
|
return cache.tools;
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
|
|
||||||
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import type { McpTool } from "@homarr/api/mcp";
|
import type { McpTool } from "@homarr/api/mcp";
|
||||||
|
import { appManageSchema } from "@homarr/validation/app";
|
||||||
|
import { extractMcpToolsFromProcedures } from "@homarr/api/mcp";
|
||||||
|
import type { McpMeta } from "../../../../../../packages/api/src/mcp-tools";
|
||||||
|
|
||||||
import { createMcpProtocolHandler } from "./_protocol";
|
import { createMcpProtocolHandler } from "./_protocol";
|
||||||
|
|
||||||
|
vi.mock("@homarr/api/mcp", async () => import("../../../../../../packages/api/src/mcp-tools"));
|
||||||
|
|
||||||
const modernMetadata = {
|
const modernMetadata = {
|
||||||
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||||
"io.modelcontextprotocol/clientInfo": { name: "homarr-test", version: "1.0.0" },
|
"io.modelcontextprotocol/clientInfo": { name: "homarr-test", version: "1.0.0" },
|
||||||
@@ -52,7 +58,7 @@ const echoTool: McpTool = {
|
|||||||
description: "Echo a message",
|
description: "Echo a message",
|
||||||
type: "query",
|
type: "query",
|
||||||
pathInRouter: ["echo"],
|
pathInRouter: ["echo"],
|
||||||
inputValidator: echoValidator,
|
inputMode: "object",
|
||||||
inputSchema: z.toJSONSchema(echoValidator),
|
inputSchema: z.toJSONSchema(echoValidator),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -163,3 +169,197 @@ describe("MCP v2 protocol", () => {
|
|||||||
expect((await parseResponse(listResponse)).result.tools).toEqual([expect.objectContaining({ name: "echo" })]);
|
expect((await parseResponse(listResponse)).result.tools).toEqual([expect.objectContaining({ name: "echo" })]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.each([false, true])("MCP argument dispatch (modern=%s)", (modern) => {
|
||||||
|
const trpc = initTRPC.context<{ user: string }>().meta<McpMeta>().create();
|
||||||
|
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
|
||||||
|
|
||||||
|
const call = async (
|
||||||
|
handler: ReturnType<typeof createMcpProtocolHandler>,
|
||||||
|
name: string,
|
||||||
|
argumentsValue: Record<string, unknown>,
|
||||||
|
) => {
|
||||||
|
let request = createRequest("tools/call", { name, arguments: argumentsValue }, name);
|
||||||
|
if (!modern) {
|
||||||
|
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: "tools/call",
|
||||||
|
params: { name, arguments: argumentsValue },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const response = await handler.fetch(request);
|
||||||
|
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
|
||||||
|
return (await parseResponse(response)).result;
|
||||||
|
};
|
||||||
|
|
||||||
|
test("transforms once and does not coerce, insert defaults, or remove properties before tRPC", async () => {
|
||||||
|
const transform = vi.fn((value: string) => `${value}!`);
|
||||||
|
const execution = vi.fn((input: unknown) => input);
|
||||||
|
const router = trpc.router({
|
||||||
|
update: exposed
|
||||||
|
.input(
|
||||||
|
z.looseObject({
|
||||||
|
name: z.string().transform(transform),
|
||||||
|
limit: z.number().default(7),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(({ input }) => execution(input)),
|
||||||
|
});
|
||||||
|
const catalog = extractMcpToolsFromProcedures(router);
|
||||||
|
expect(catalog.diagnostics).toEqual([]);
|
||||||
|
const realCaller = router.createCaller({ user: "one" });
|
||||||
|
const dispatch = vi.fn(realCaller.update);
|
||||||
|
const handler = createMcpProtocolHandler({
|
||||||
|
caller: { update: dispatch },
|
||||||
|
tools: catalog.tools,
|
||||||
|
version: "test",
|
||||||
|
instructions: "test",
|
||||||
|
formatToolError: () => "Invalid input",
|
||||||
|
});
|
||||||
|
const argumentsValue = { name: "hello", extra: "preserved" };
|
||||||
|
const result = await call(handler, "update", argumentsValue);
|
||||||
|
expect(result.isError).not.toBe(true);
|
||||||
|
expect(dispatch).toHaveBeenCalledWith(argumentsValue);
|
||||||
|
expect(transform).toHaveBeenCalledTimes(1);
|
||||||
|
expect(execution).toHaveBeenCalledWith({ name: "hello!", limit: 7, extra: "preserved" });
|
||||||
|
await call(handler, "update", { name: "hello", limit: "7" });
|
||||||
|
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(execution).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retains overlapping chained constraints, refinements, and empty object defaults", async () => {
|
||||||
|
const execution = vi.fn((input: unknown) => input);
|
||||||
|
const router = trpc.router({
|
||||||
|
bounded: exposed
|
||||||
|
.input(z.object({ count: z.number().min(2) }))
|
||||||
|
.input(z.object({ count: z.number().max(5) }))
|
||||||
|
.mutation(({ input }) => execution(input)),
|
||||||
|
refined: exposed
|
||||||
|
.input(z.object({ value: z.string().refine((value) => value !== "blocked") }))
|
||||||
|
.mutation(({ input }) => execution(input)),
|
||||||
|
defaults: exposed.input(z.object({ count: z.number().default(3) })).query(({ input }) => input),
|
||||||
|
none: exposed.query(({ input }) => ({ absent: input === undefined })),
|
||||||
|
});
|
||||||
|
const catalog = extractMcpToolsFromProcedures(router);
|
||||||
|
expect(catalog.diagnostics).toEqual([]);
|
||||||
|
const handler = createMcpProtocolHandler({
|
||||||
|
caller: router.createCaller({ user: "one" }),
|
||||||
|
tools: catalog.tools,
|
||||||
|
version: "test",
|
||||||
|
instructions: "test",
|
||||||
|
formatToolError: () => "Invalid input",
|
||||||
|
});
|
||||||
|
for (const count of [1, 6]) expect((await call(handler, "bounded", { count })).isError).toBe(true);
|
||||||
|
expect((await call(handler, "refined", { value: "blocked" })).isError).toBe(true);
|
||||||
|
expect(execution).not.toHaveBeenCalled();
|
||||||
|
expect((await call(handler, "bounded", { count: 3 })).isError).not.toBe(true);
|
||||||
|
expect(execution).toHaveBeenCalledWith({ count: 3 });
|
||||||
|
expect((await call(handler, "defaults", {})).content).toEqual([{ type: "text", text: '{"count":3}' }]);
|
||||||
|
expect((await call(handler, "none", {})).content).toEqual([{ type: "text", text: '{"absent":true}' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts app inputs that tRPC normalizes before checking constraints", async () => {
|
||||||
|
const mutation = vi.fn((input: unknown) => input);
|
||||||
|
const router = trpc.router({ create: exposed.input(appManageSchema).mutation(({ input }) => mutation(input)) });
|
||||||
|
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
|
||||||
|
expect(diagnostics).toEqual([]);
|
||||||
|
const handler = createMcpProtocolHandler({
|
||||||
|
caller: router.createCaller({ user: "admin" }),
|
||||||
|
tools,
|
||||||
|
version: "test",
|
||||||
|
instructions: "test",
|
||||||
|
formatToolError: () => "Invalid input",
|
||||||
|
});
|
||||||
|
const argumentsValue = {
|
||||||
|
name: `${" ".repeat(64)}App `,
|
||||||
|
description: " description ",
|
||||||
|
iconUrl: " icon ",
|
||||||
|
href: null,
|
||||||
|
pingUrl: " https://example.com ",
|
||||||
|
};
|
||||||
|
expect((await call(handler, "create", argumentsValue)).isError).not.toBe(true);
|
||||||
|
expect(mutation).toHaveBeenCalledExactlyOnceWith({
|
||||||
|
name: "App",
|
||||||
|
description: "description",
|
||||||
|
iconUrl: "icon",
|
||||||
|
href: null,
|
||||||
|
pingUrl: "https://example.com",
|
||||||
|
});
|
||||||
|
expect((await call(handler, "create", { ...argumentsValue, pingUrl: " javascript:alert(1) " })).isError).toBe(true);
|
||||||
|
expect(mutation).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts Zod ISO timestamps without seconds while rejecting invalid dates", async () => {
|
||||||
|
const mutation = vi.fn((input: unknown) => input);
|
||||||
|
const router = trpc.router({
|
||||||
|
invite: exposed
|
||||||
|
.input(z.object({ expirationDate: z.iso.datetime({ offset: true }) }))
|
||||||
|
.mutation(({ input }) => mutation(input)),
|
||||||
|
});
|
||||||
|
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
|
||||||
|
expect(diagnostics).toEqual([]);
|
||||||
|
const handler = createMcpProtocolHandler({
|
||||||
|
caller: router.createCaller({ user: "admin" }),
|
||||||
|
tools,
|
||||||
|
version: "test",
|
||||||
|
instructions: "test",
|
||||||
|
formatToolError: () => "Invalid input",
|
||||||
|
});
|
||||||
|
for (const expirationDate of ["2026-12-01T18:00Z", "2026-12-01T19:00+01:00"]) {
|
||||||
|
expect((await call(handler, "invite", { expirationDate })).isError).not.toBe(true);
|
||||||
|
expect(mutation).toHaveBeenLastCalledWith({ expirationDate });
|
||||||
|
}
|
||||||
|
for (const expirationDate of ["2026-02-30T18:00Z", "not-a-date"]) {
|
||||||
|
expect((await call(handler, "invite", { expirationDate })).isError).toBe(true);
|
||||||
|
}
|
||||||
|
expect(mutation).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enforces tRPC permissions before executing mutations", async () => {
|
||||||
|
const mutation = vi.fn(() => "changed");
|
||||||
|
const router = trpc.router({
|
||||||
|
restricted: exposed
|
||||||
|
.use(({ ctx, next }) => {
|
||||||
|
if (ctx.user !== "admin") throw new TRPCError({ code: "FORBIDDEN" });
|
||||||
|
return next();
|
||||||
|
})
|
||||||
|
.input(z.object({}))
|
||||||
|
.mutation(mutation),
|
||||||
|
});
|
||||||
|
const { tools, diagnostics } = extractMcpToolsFromProcedures(router);
|
||||||
|
expect(diagnostics).toEqual([]);
|
||||||
|
const handler = createMcpProtocolHandler({
|
||||||
|
caller: router.createCaller({ user: "guest" }),
|
||||||
|
tools,
|
||||||
|
version: "test",
|
||||||
|
instructions: "test",
|
||||||
|
formatToolError: () => "Forbidden",
|
||||||
|
});
|
||||||
|
expect((await call(handler, "restricted", {})).isError).toBe(true);
|
||||||
|
expect(mutation).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isolates authenticated callers when reusing catalog schemas", async () => {
|
||||||
|
const router = trpc.router({ who: exposed.query(({ ctx }) => ctx.user) });
|
||||||
|
const { tools } = extractMcpToolsFromProcedures(router);
|
||||||
|
const handlers = ["alice", "bob"].map((user) =>
|
||||||
|
createMcpProtocolHandler({
|
||||||
|
caller: router.createCaller({ user }),
|
||||||
|
tools,
|
||||||
|
version: "test",
|
||||||
|
instructions: "test",
|
||||||
|
formatToolError: () => "Failed",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const results = await Promise.all(handlers.map((handler) => call(handler, "who", {})));
|
||||||
|
expect(results.map((result) => result.content)).toEqual([
|
||||||
|
[{ type: "text", text: '"alice"' }],
|
||||||
|
[{ type: "text", text: '"bob"' }],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createMcpHandler, McpServer } from "@modelcontextprotocol/server";
|
import { createMcpHandler, fromJsonSchema, McpServer } from "@modelcontextprotocol/server";
|
||||||
|
|
||||||
|
import { callMcpTool } from "@homarr/api/mcp";
|
||||||
import type { McpTool } from "@homarr/api/mcp";
|
import type { McpTool } from "@homarr/api/mcp";
|
||||||
|
|
||||||
import { toAssistantToolOutput } from "../assistant/chat/assistant-tool-output";
|
import { toAssistantToolOutput } from "../assistant/chat/assistant-tool-output";
|
||||||
@@ -15,15 +16,6 @@ interface CreateMcpProtocolHandlerOptions {
|
|||||||
onProtocolError?: (error: Error) => void;
|
onProtocolError?: (error: Error) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolveProcedure = (caller: unknown, path: string[]) =>
|
|
||||||
path.reduce<unknown>(
|
|
||||||
(current, segment) =>
|
|
||||||
(typeof current === "object" || typeof current === "function") && current !== null
|
|
||||||
? (current as Record<string, unknown>)[segment]
|
|
||||||
: undefined,
|
|
||||||
caller,
|
|
||||||
);
|
|
||||||
|
|
||||||
export const createMcpProtocolHandler = ({
|
export const createMcpProtocolHandler = ({
|
||||||
caller,
|
caller,
|
||||||
tools,
|
tools,
|
||||||
@@ -52,20 +44,11 @@ export const createMcpProtocolHandler = ({
|
|||||||
tool.name,
|
tool.name,
|
||||||
{
|
{
|
||||||
description: tool.description,
|
description: tool.description,
|
||||||
inputSchema: tool.inputValidator,
|
inputSchema: fromJsonSchema(tool.inputSchema as Parameters<typeof fromJsonSchema>[0]),
|
||||||
},
|
},
|
||||||
async (args) => {
|
async (args) => {
|
||||||
try {
|
try {
|
||||||
const procedure = resolveProcedure(caller, tool.pathInRouter);
|
const result = await callMcpTool(caller, tool, args);
|
||||||
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<unknown>)(input);
|
|
||||||
return {
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,11 +51,13 @@
|
|||||||
"@homarr/validation": "workspace:^0.1.0",
|
"@homarr/validation": "workspace:^0.1.0",
|
||||||
"@homarr/workshop": "workspace:^0.1.0",
|
"@homarr/workshop": "workspace:^0.1.0",
|
||||||
"@kubernetes/client-node": "catalog:",
|
"@kubernetes/client-node": "catalog:",
|
||||||
|
"@modelcontextprotocol/server": "catalog:",
|
||||||
"@tanstack/react-query": "catalog:",
|
"@tanstack/react-query": "catalog:",
|
||||||
"@trpc/client": "catalog:",
|
"@trpc/client": "catalog:",
|
||||||
"@trpc/react-query": "catalog:",
|
"@trpc/react-query": "catalog:",
|
||||||
"@trpc/server": "catalog:",
|
"@trpc/server": "catalog:",
|
||||||
"@trpc/tanstack-react-query": "catalog:",
|
"@trpc/tanstack-react-query": "catalog:",
|
||||||
|
"ajv": "catalog:",
|
||||||
"lodash.clonedeep": "catalog:",
|
"lodash.clonedeep": "catalog:",
|
||||||
"next": "catalog:",
|
"next": "catalog:",
|
||||||
"react": "catalog:",
|
"react": "catalog:",
|
||||||
|
|||||||
+116
-63
@@ -1,3 +1,5 @@
|
|||||||
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||||
|
import { addFormats, AjvJsonSchemaValidator } from "@modelcontextprotocol/server/validators/ajv";
|
||||||
import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
|
import type { AnyProcedure, AnyRootTypes, Router, RouterRecord } from "@trpc/server/unstable-core-do-not-import";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
@@ -15,23 +17,35 @@ export interface McpTool {
|
|||||||
type: "query" | "mutation";
|
type: "query" | "mutation";
|
||||||
pathInRouter: string[];
|
pathInRouter: string[];
|
||||||
inputSchema: z.core.JSONSchema.JSONSchema;
|
inputSchema: z.core.JSONSchema.JSONSchema;
|
||||||
inputValidator: z.ZodObject;
|
inputMode: "none" | "object";
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyInput = z.object({});
|
export interface McpToolDiagnostic {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
reason: "unsupported_input" | "invalid_schema" | "duplicate_name" | "invalid_name";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpToolCatalog {
|
||||||
|
tools: McpTool[];
|
||||||
|
diagnostics: McpToolDiagnostic[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyInput = z.strictObject({});
|
||||||
|
const schemaEngine = new Ajv2020({
|
||||||
|
strict: false,
|
||||||
|
validateSchema: true,
|
||||||
|
coerceTypes: false,
|
||||||
|
useDefaults: false,
|
||||||
|
removeAdditional: false,
|
||||||
|
});
|
||||||
|
addFormats(schemaEngine);
|
||||||
|
const schemaValidator = new AjvJsonSchemaValidator(schemaEngine);
|
||||||
const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function";
|
const isProcedure = (value: AnyProcedure | RouterRecord): value is AnyProcedure => typeof value === "function";
|
||||||
|
|
||||||
const mergeInputs = (inputs: z.ZodObject[]) => inputs.reduce((merged, input) => merged.extend(input.shape), emptyInput);
|
const getObjectInput = (input: unknown): z.ZodObject | null => {
|
||||||
|
|
||||||
const getObjectInput = (input: z.ZodType) => {
|
|
||||||
if (input instanceof z.ZodObject) return input;
|
if (input instanceof z.ZodObject) return input;
|
||||||
|
if (input instanceof z.ZodOptional) return getObjectInput(input.unwrap());
|
||||||
if (input instanceof z.ZodOptional) {
|
|
||||||
const unwrappedInput = input.unwrap();
|
|
||||||
if (unwrappedInput instanceof z.ZodObject) return unwrappedInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -50,71 +64,110 @@ const getMcpMetadata = (meta: unknown): McpMeta["mcp"] => {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getProcedureInput = (inputs: z.ZodType[]) => {
|
|
||||||
if (inputs.length === 0) return emptyInput;
|
|
||||||
if (inputs.length === 1) {
|
|
||||||
const input = inputs[0] ?? emptyInput;
|
|
||||||
return getObjectInput(input) ?? input;
|
|
||||||
}
|
|
||||||
|
|
||||||
const objectInputs = inputs.map(getObjectInput);
|
|
||||||
if (objectInputs.every((input): input is z.ZodObject => input !== null)) return mergeInputs(objectInputs);
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeJsonSchema = (schema: z.core.JSONSchema.JSONSchema): z.core.JSONSchema.JSONSchema => {
|
|
||||||
const properties = schema.properties ?? {};
|
|
||||||
const required = (schema.required ?? []).filter((key) => {
|
|
||||||
const property = properties[key];
|
|
||||||
return !property || typeof property !== "object" || !("default" in property);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
...schema,
|
|
||||||
type: "object",
|
|
||||||
properties,
|
|
||||||
required,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecord extends RouterRecord>(
|
export function extractMcpToolsFromProcedures<TRoot extends AnyRootTypes, TRecord extends RouterRecord>(
|
||||||
router: Router<TRoot, TRecord>,
|
router: Router<TRoot, TRecord>,
|
||||||
): McpTool[] {
|
): McpToolCatalog {
|
||||||
const tools: McpTool[] = [];
|
const tools: McpTool[] = [];
|
||||||
|
const diagnostics: McpToolDiagnostic[] = [];
|
||||||
|
const names = new Map<string, string[]>();
|
||||||
|
|
||||||
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
|
for (const [path, procedure] of Object.entries(router["_def"].procedures)) {
|
||||||
if (!isProcedure(procedure)) continue;
|
if (!isProcedure(procedure)) continue;
|
||||||
const definition = procedure["_def"];
|
const definition = procedure["_def"];
|
||||||
if (definition.type === "subscription") continue;
|
if (definition.type === "subscription") continue;
|
||||||
|
|
||||||
const metadata = getMcpMetadata(definition.meta);
|
const metadata = getMcpMetadata(definition.meta);
|
||||||
if (!metadata?.enabled) continue;
|
if (!metadata?.enabled) continue;
|
||||||
|
|
||||||
const inputs = definition.inputs.filter((input): input is z.ZodType => input instanceof z.ZodType);
|
const name = metadata.name ?? path.replaceAll(".", "_");
|
||||||
const procedureInput = getProcedureInput(inputs);
|
const paths = names.get(name) ?? [];
|
||||||
|
paths.push(path);
|
||||||
if (
|
names.set(name, paths);
|
||||||
procedureInput === null ||
|
if (!/^[a-zA-Z0-9_.-]{1,128}$/.test(name)) {
|
||||||
(!(procedureInput instanceof z.ZodObject) && !procedureInput.safeParse(undefined).success)
|
diagnostics.push({ name, path, reason: "invalid_name" });
|
||||||
) {
|
continue;
|
||||||
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({
|
const inputs = definition.inputs;
|
||||||
name: metadata.name ?? path.replaceAll(".", "_"),
|
let inputMode: McpTool["inputMode"] = "object";
|
||||||
description: metadata.description ?? "",
|
if (inputs.every((input) => input instanceof z.ZodUndefined || input instanceof z.ZodVoid)) inputMode = "none";
|
||||||
type: definition.type,
|
const objectInputs = inputs.map(getObjectInput);
|
||||||
pathInRouter: path.split("."),
|
if (inputMode === "object" && !objectInputs.every((input) => input !== null)) {
|
||||||
inputValidator,
|
diagnostics.push({ name, path, reason: "unsupported_input" });
|
||||||
inputSchema: normalizeJsonSchema(
|
continue;
|
||||||
z.toJSONSchema(inputValidator, {
|
}
|
||||||
io: "input",
|
|
||||||
unrepresentable: "any",
|
try {
|
||||||
}),
|
let input: z.ZodType = emptyInput;
|
||||||
),
|
if (inputMode === "object") {
|
||||||
});
|
const objects = objectInputs.filter((schema): schema is z.ZodObject => schema !== null);
|
||||||
|
input = objects[0] ?? emptyInput;
|
||||||
|
for (const object of objects.slice(1)) input = z.intersection(input, object);
|
||||||
|
}
|
||||||
|
const inputSchema = z.toJSONSchema(input, {
|
||||||
|
io: "input",
|
||||||
|
override: ({ zodSchema, jsonSchema }) => {
|
||||||
|
if (zodSchema["_zod"].def.type !== "string") return;
|
||||||
|
if (zodSchema["_zod"].def.checks?.some((check) => check["_zod"].def.check === "overwrite")) {
|
||||||
|
// trim/case normalization runs before these checks in tRPC. They cannot constrain raw JSON.
|
||||||
|
delete jsonSchema.minLength;
|
||||||
|
delete jsonSchema.maxLength;
|
||||||
|
delete jsonSchema.pattern;
|
||||||
|
delete jsonSchema.allOf;
|
||||||
|
delete jsonSchema.format;
|
||||||
|
}
|
||||||
|
// Zod's ISO regex permits minute precision; JSON Schema date-time requires seconds.
|
||||||
|
if (jsonSchema.format === "date-time" && typeof jsonSchema.pattern === "string") {
|
||||||
|
delete jsonSchema.format;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// Chained tRPC object inputs produce allOf; MCP requires an explicit object root.
|
||||||
|
if (inputSchema.type !== undefined && inputSchema.type !== "object") throw new Error("Invalid root");
|
||||||
|
inputSchema.type = "object";
|
||||||
|
// Zod and the SDK disagree on JSON Schema vocabulary types; compilation checks the actual schema.
|
||||||
|
schemaValidator.getValidator(inputSchema as Parameters<typeof schemaValidator.getValidator>[0]);
|
||||||
|
tools.push({
|
||||||
|
name,
|
||||||
|
description: metadata.description ?? "",
|
||||||
|
type: definition.type,
|
||||||
|
pathInRouter: path.split("."),
|
||||||
|
inputMode,
|
||||||
|
inputSchema,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Conversion errors can contain schema metadata. Only expose a stable, safe reason.
|
||||||
|
diagnostics.push({ name, path, reason: "invalid_schema" });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return tools.toSorted((left, right) => left.name.localeCompare(right.name));
|
const duplicates = new Set<string>();
|
||||||
|
for (const [name, paths] of names) {
|
||||||
|
if (paths.length < 2) continue;
|
||||||
|
duplicates.add(name);
|
||||||
|
for (const path of paths) diagnostics.push({ name, path, reason: "duplicate_name" });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tools: tools
|
||||||
|
.filter((tool) => !duplicates.has(tool.name))
|
||||||
|
.toSorted((left, right) => left.name.localeCompare(right.name)),
|
||||||
|
diagnostics: diagnostics.toSorted(
|
||||||
|
(left, right) => left.path.localeCompare(right.path) || left.reason.localeCompare(right.reason),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dispatch raw JSON through the caller so tRPC owns parsing, transforms, and authorization. */
|
||||||
|
export async function callMcpTool(caller: unknown, tool: McpTool, input: unknown) {
|
||||||
|
let procedure = caller;
|
||||||
|
for (const segment of tool.pathInRouter) {
|
||||||
|
if ((typeof procedure !== "object" && typeof procedure !== "function") || procedure === null) {
|
||||||
|
throw new Error("Tool is not callable");
|
||||||
|
}
|
||||||
|
procedure = (procedure as Record<string, unknown>)[segment];
|
||||||
|
}
|
||||||
|
if (typeof procedure !== "function") throw new Error("Tool is not callable");
|
||||||
|
let procedureInput = input;
|
||||||
|
if (tool.inputMode === "none") procedureInput = undefined;
|
||||||
|
else if (input === undefined) procedureInput = {};
|
||||||
|
return await (procedure as (value: unknown) => Promise<unknown>)(procedureInput);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
export { createTRPCContext } from "./trpc";
|
export { createTRPCContext } from "./trpc";
|
||||||
export { extractMcpToolsFromProcedures } from "./mcp-tools";
|
export { callMcpTool, extractMcpToolsFromProcedures } from "./mcp-tools";
|
||||||
export type { McpTool } from "./mcp-tools";
|
export type { McpTool } from "./mcp-tools";
|
||||||
|
|
||||||
import { createTRPCRouter } from "./trpc";
|
import { createTRPCRouter } from "./trpc";
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { initTRPC } from "@trpc/server";
|
||||||
import type { TRPCError } from "@trpc/server";
|
import type { TRPCError } from "@trpc/server";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
import { mcpRouter } from "../mcp";
|
import { mcpRouter } from "../mcp";
|
||||||
import { extractMcpToolsFromProcedures } from "../mcp-tools";
|
import type { McpMeta } from "../mcp-tools";
|
||||||
|
import { createMcpProtocolHandler } from "../../../../apps/nextjs/src/app/api/mcp/_protocol";
|
||||||
|
import { callMcpTool, extractMcpToolsFromProcedures } from "../mcp-tools";
|
||||||
|
|
||||||
vi.mock("@homarr/auth", () => ({}));
|
vi.mock("@homarr/auth", () => ({}));
|
||||||
|
|
||||||
@@ -157,7 +160,7 @@ function actualToolInventory() {
|
|||||||
{ _def?: { type?: "query" | "mutation" } }
|
{ _def?: { type?: "query" | "mutation" } }
|
||||||
>;
|
>;
|
||||||
return extractMcpToolsFromProcedures(mcpRouter)
|
return extractMcpToolsFromProcedures(mcpRouter)
|
||||||
.map((tool) => {
|
.tools.map((tool) => {
|
||||||
const procedure = procedures[tool.pathInRouter.join(".")];
|
const procedure = procedures[tool.pathInRouter.join(".")];
|
||||||
const type = procedure?.["_def"]?.type;
|
const type = procedure?.["_def"]?.type;
|
||||||
if (type !== "query" && type !== "mutation") throw new Error(`Unable to classify MCP tool '${tool.name}'`);
|
if (type !== "query" && type !== "mutation") throw new Error(`Unable to classify MCP tool '${tool.name}'`);
|
||||||
@@ -175,7 +178,7 @@ describe("production MCP router", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("keeps every secret-bearing tool behind a mutation procedure", () => {
|
test("keeps every secret-bearing tool behind a mutation procedure", () => {
|
||||||
const tools = extractMcpToolsFromProcedures(mcpRouter);
|
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
|
||||||
const procedures = mcpRouter["_def"].procedures as unknown as Record<
|
const procedures = mcpRouter["_def"].procedures as unknown as Record<
|
||||||
string,
|
string,
|
||||||
{ _def?: { type?: "query" | "mutation" } }
|
{ _def?: { type?: "query" | "mutation" } }
|
||||||
@@ -189,7 +192,7 @@ describe("production MCP router", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("gives every production tool a description", () => {
|
test("gives every production tool a description", () => {
|
||||||
const tools = extractMcpToolsFromProcedures(mcpRouter);
|
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
|
||||||
for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
|
for (const tool of tools) expect(tool.description, `Tool ${tool.name} should have a description`).toBeTruthy();
|
||||||
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
|
expect(tools.find((tool) => tool.name === "customWidget_getAuthoringPrompt")?.description).toBe(
|
||||||
"Get the current Custom Widget authoring instructions.",
|
"Get the current Custom Widget authoring instructions.",
|
||||||
@@ -197,16 +200,17 @@ describe("production MCP router", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("MCP tools are deterministically ordered and retain executable schemas", () => {
|
test("MCP tools are deterministically ordered and have valid production schemas", () => {
|
||||||
const tools = extractMcpToolsFromProcedures(mcpRouter);
|
expect(extractMcpToolsFromProcedures(mcpRouter).diagnostics).toEqual([]);
|
||||||
|
const tools = extractMcpToolsFromProcedures(mcpRouter).tools;
|
||||||
const toolNames = tools.map((tool) => tool.name);
|
const toolNames = tools.map((tool) => tool.name);
|
||||||
|
|
||||||
expect(toolNames).toEqual(toolNames.toSorted((left, right) => left.localeCompare(right)));
|
expect(toolNames).toEqual(toolNames.toSorted((left, right) => left.localeCompare(right)));
|
||||||
expect(tools.every((tool) => tool.inputValidator instanceof z.ZodObject)).toBe(true);
|
expect(tools.every((tool) => tool.inputSchema.type === "object")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("publishes both Custom Widget template input formats", () => {
|
test("publishes both Custom Widget template input formats", () => {
|
||||||
const tool = extractMcpToolsFromProcedures(mcpRouter).find(
|
const tool = extractMcpToolsFromProcedures(mcpRouter).tools.find(
|
||||||
(candidate) => candidate.name === "customWidget_validateTemplate",
|
(candidate) => candidate.name === "customWidget_validateTemplate",
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -355,3 +359,119 @@ describe("custom widget authoring procedure access", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("MCP catalog diagnostics", () => {
|
||||||
|
const trpc = initTRPC.meta<McpMeta>().create();
|
||||||
|
const exposed = trpc.procedure.meta({ mcp: { enabled: true } });
|
||||||
|
|
||||||
|
test("omits unsupported parsers and malformed schemas without losing valid tools", () => {
|
||||||
|
const router = trpc.router({
|
||||||
|
good: exposed.input(z.object({ name: z.string() })).query(() => "ok"),
|
||||||
|
date: exposed.input(z.object({ date: z.date() })).query(() => "unused"),
|
||||||
|
scalar: exposed.input(z.string()).query(() => "unused"),
|
||||||
|
parser: exposed.input({ parse: (value: unknown) => value }).query(() => "unused"),
|
||||||
|
malformed: exposed
|
||||||
|
.input(z.object({ value: z.string().meta({ type: "invalid-json-type" }) }))
|
||||||
|
.query(() => "unused"),
|
||||||
|
});
|
||||||
|
const catalog = extractMcpToolsFromProcedures(router);
|
||||||
|
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
|
||||||
|
expect(catalog.diagnostics).toEqual([
|
||||||
|
{ name: "date", path: "date", reason: "invalid_schema" },
|
||||||
|
{ name: "malformed", path: "malformed", reason: "invalid_schema" },
|
||||||
|
{ name: "parser", path: "parser", reason: "unsupported_input" },
|
||||||
|
{ name: "scalar", path: "scalar", reason: "unsupported_input" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("omits all duplicate names even when one procedure has an invalid schema", () => {
|
||||||
|
const shared = trpc.procedure.meta({ mcp: { enabled: true, name: "shared" } });
|
||||||
|
const catalog = extractMcpToolsFromProcedures(
|
||||||
|
trpc.router({
|
||||||
|
first: shared.query(() => "first"),
|
||||||
|
second: shared.input(z.object({ date: z.date() })).query(() => "second"),
|
||||||
|
good: exposed.query(() => "good"),
|
||||||
|
invalidName: trpc.procedure.meta({ mcp: { enabled: true, name: "bad name" } }).query(() => "unused"),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(catalog.tools.map((tool) => tool.name)).toEqual(["good"]);
|
||||||
|
expect(catalog.diagnostics.filter((diagnostic) => diagnostic.reason === "duplicate_name")).toEqual([
|
||||||
|
{ name: "shared", path: "first", reason: "duplicate_name" },
|
||||||
|
{ name: "shared", path: "second", reason: "duplicate_name" },
|
||||||
|
]);
|
||||||
|
expect(catalog.diagnostics).toContainEqual({ name: "bad name", path: "invalidName", reason: "invalid_name" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("distinguishes empty object input from no input for shared Assistant dispatch", async () => {
|
||||||
|
const router = trpc.router({
|
||||||
|
none: exposed.query(({ input }) => input),
|
||||||
|
explicitNone: exposed.input(z.undefined()).query(({ input }) => input),
|
||||||
|
object: exposed.input(z.object({ value: z.string().default("default") })).query(({ input }) => input),
|
||||||
|
optional: exposed
|
||||||
|
.input(z.object({ value: z.string().default("default") }).optional())
|
||||||
|
.query(({ input }) => input),
|
||||||
|
});
|
||||||
|
const catalog = extractMcpToolsFromProcedures(router);
|
||||||
|
expect(catalog.diagnostics).toEqual([]);
|
||||||
|
for (const tool of catalog.tools) {
|
||||||
|
const result = await callMcpTool(router.createCaller({}), tool, {});
|
||||||
|
if (tool.inputMode === "none") expect(result).toBeUndefined();
|
||||||
|
else expect(result).toEqual({ value: "default" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([false, true])(
|
||||||
|
"production catalog registers through the real protocol handler (modern=%s)",
|
||||||
|
async (modern) => {
|
||||||
|
const { tools, diagnostics } = extractMcpToolsFromProcedures(mcpRouter);
|
||||||
|
expect(diagnostics).toEqual([]);
|
||||||
|
const handler = createMcpProtocolHandler({
|
||||||
|
caller: {},
|
||||||
|
tools,
|
||||||
|
version: "test",
|
||||||
|
instructions: "test",
|
||||||
|
formatToolError: () => "Tool failed",
|
||||||
|
});
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json, text/event-stream",
|
||||||
|
};
|
||||||
|
const params: Record<string, unknown> = {};
|
||||||
|
if (modern) {
|
||||||
|
headers["Mcp-Method"] = "tools/list";
|
||||||
|
headers["Mcp-Name"] = "homarr";
|
||||||
|
params["_meta"] = {
|
||||||
|
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
|
||||||
|
"io.modelcontextprotocol/clientInfo": { name: "catalog-test", version: "1" },
|
||||||
|
"io.modelcontextprotocol/clientCapabilities": {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const response = await handler.fetch(
|
||||||
|
new Request("http://homarr.test/api/mcp", {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.has("Mcp-Session-Id")).toBe(false);
|
||||||
|
const body = await response.text();
|
||||||
|
let payload = body;
|
||||||
|
if (response.headers.get("Content-Type")?.includes("text/event-stream")) {
|
||||||
|
payload =
|
||||||
|
body
|
||||||
|
.split("\n")
|
||||||
|
.find((line) => line.startsWith("data: "))
|
||||||
|
?.slice(6) ?? "";
|
||||||
|
}
|
||||||
|
const result = JSON.parse(payload) as {
|
||||||
|
error?: unknown;
|
||||||
|
result: { tools: { name: string; inputSchema: unknown }[] };
|
||||||
|
};
|
||||||
|
expect(result.error).toBeUndefined();
|
||||||
|
expect(result.result.tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema }))).toEqual(
|
||||||
|
tools.map((tool) => ({ name: tool.name, inputSchema: tool.inputSchema })),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
Generated
+9
@@ -366,6 +366,9 @@ catalogs:
|
|||||||
ai:
|
ai:
|
||||||
specifier: ^7.0.56
|
specifier: ^7.0.56
|
||||||
version: 7.0.56
|
version: 7.0.56
|
||||||
|
ajv:
|
||||||
|
specifier: 8.18.0
|
||||||
|
version: 8.18.0
|
||||||
assistant-stream:
|
assistant-stream:
|
||||||
specifier: 0.3.34
|
specifier: 0.3.34
|
||||||
version: 0.3.34
|
version: 0.3.34
|
||||||
@@ -1520,6 +1523,9 @@ importers:
|
|||||||
'@kubernetes/client-node':
|
'@kubernetes/client-node':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 1.4.0(supports-color@10.2.2)
|
version: 1.4.0(supports-color@10.2.2)
|
||||||
|
'@modelcontextprotocol/server':
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 2.0.0
|
||||||
'@tanstack/react-query':
|
'@tanstack/react-query':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 5.101.2(react@19.2.7)
|
version: 5.101.2(react@19.2.7)
|
||||||
@@ -1535,6 +1541,9 @@ importers:
|
|||||||
'@trpc/tanstack-react-query':
|
'@trpc/tanstack-react-query':
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.18.0(typescript@7.0.2))(react@19.2.7)(typescript@7.0.2)
|
version: 11.18.0(@tanstack/react-query@5.101.2(react@19.2.7))(@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@7.0.2))(typescript@7.0.2))(@trpc/server@11.18.0(typescript@7.0.2))(react@19.2.7)(typescript@7.0.2)
|
||||||
|
ajv:
|
||||||
|
specifier: 'catalog:'
|
||||||
|
version: 8.18.0
|
||||||
lodash.clonedeep:
|
lodash.clonedeep:
|
||||||
specifier: 'catalog:'
|
specifier: 'catalog:'
|
||||||
version: 4.5.0
|
version: 4.5.0
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ catalog:
|
|||||||
adm-zip: 0.6.0
|
adm-zip: 0.6.0
|
||||||
acorn: ^8.16.0
|
acorn: ^8.16.0
|
||||||
acorn-jsx: ^5.3.2
|
acorn-jsx: ^5.3.2
|
||||||
|
ajv: 8.18.0
|
||||||
jszip: ^3.10.1
|
jszip: ^3.10.1
|
||||||
sql.js: ^1.14.1
|
sql.js: ^1.14.1
|
||||||
babel-plugin-react-compiler: ^1.0.0
|
babel-plugin-react-compiler: ^1.0.0
|
||||||
|
|||||||
Reference in new issue
Block a user