From 3293ace39298d12f821125d4879399e28017122c Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 1 Aug 2026 10:40:21 -0400 Subject: [PATCH 1/2] fix(runtime): reject unenforceable mcpApps tool policy instead of silently ignoring it --- .changeset/enable-mcp-apps-tool-filters.md | 7 + .../references/wiring-mcp-apps-middleware.md | 5 + .../mcp-apps-middleware-integration.test.ts | 65 ++++++++- .../__tests__/mcp-apps-servers.test.ts | 126 ++++++++++++++++++ .../runtime/src/v2/runtime/core/runtime.ts | 1 + .../v2/runtime/handlers/shared/agent-utils.ts | 9 +- .../handlers/shared/mcp-apps-servers.ts | 44 ++++++ .../content/docs/backend/copilot-runtime.mdx | 2 + .../references/wiring-mcp-apps-middleware.md | 5 + 9 files changed, 251 insertions(+), 13 deletions(-) create mode 100644 .changeset/enable-mcp-apps-tool-filters.md create mode 100644 packages/runtime/src/v2/runtime/__tests__/mcp-apps-servers.test.ts create mode 100644 packages/runtime/src/v2/runtime/handlers/shared/mcp-apps-servers.ts diff --git a/.changeset/enable-mcp-apps-tool-filters.md b/.changeset/enable-mcp-apps-tool-filters.md new file mode 100644 index 0000000000..e1e93e16d6 --- /dev/null +++ b/.changeset/enable-mcp-apps-tool-filters.md @@ -0,0 +1,7 @@ +--- +"@copilotkit/runtime": minor +--- + +fix(runtime): reject unenforceable mcpApps tool policy instead of silently ignoring it + +Reject MCP Apps per-tool policy keys until the external middleware supports them, preventing silent configuration no-ops. diff --git a/packages/runtime/skills/runtime/references/wiring-mcp-apps-middleware.md b/packages/runtime/skills/runtime/references/wiring-mcp-apps-middleware.md index fdb06fabf4..56dc4d70ef 100644 --- a/packages/runtime/skills/runtime/references/wiring-mcp-apps-middleware.md +++ b/packages/runtime/skills/runtime/references/wiring-mcp-apps-middleware.md @@ -49,6 +49,11 @@ export default { fetch: handler }; Each server entry accepts an optional `agentId`. When set, the server's tools are only exposed to that agent. Omit it to expose to all agents. +Per-tool filtering belongs to `@ag-ui/mcp-apps-middleware`. The runtime currently pins +version `0.0.3`, which does not support `includeTools` or `excludeTools`. Supplying either +key raises a configuration error instead of silently ignoring it; use a compatible +middleware release when that upstream policy contract is available. + ## Gotcha — do NOT put MCP under agents ```typescript diff --git a/packages/runtime/src/v2/runtime/__tests__/mcp-apps-middleware-integration.test.ts b/packages/runtime/src/v2/runtime/__tests__/mcp-apps-middleware-integration.test.ts index 5cf2629457..50ba9219ed 100644 --- a/packages/runtime/src/v2/runtime/__tests__/mcp-apps-middleware-integration.test.ts +++ b/packages/runtime/src/v2/runtime/__tests__/mcp-apps-middleware-integration.test.ts @@ -1,13 +1,30 @@ import { describe, it, expect, afterEach, vi } from "vitest"; -import { - AbstractAgent, - RunAgentInput, - BaseEvent, - EventType, -} from "@ag-ui/client"; +import { AbstractAgent, EventType } from "@ag-ui/client"; +import type { BaseEvent, RunAgentInput } from "@ag-ui/client"; import { Observable } from "rxjs"; import { LLMock, MCPMock } from "@copilotkit/aimock"; import { MCPAppsMiddleware, getServerHash } from "@ag-ui/mcp-apps-middleware"; +import type * as MCPAppsMiddlewareModule from "@ag-ui/mcp-apps-middleware"; +import type { McpAppsServerConfig } from "../core/runtime"; +import { CopilotRuntime } from "../core/runtime"; +import { handleRunAgent } from "../handlers/handle-run"; + +const middlewareConstructor = vi.hoisted(() => vi.fn()); + +vi.mock("@ag-ui/mcp-apps-middleware", async (importOriginal) => { + const actual = await importOriginal(); + + class TrackedMCPAppsMiddleware extends actual.MCPAppsMiddleware { + constructor( + ...args: ConstructorParameters + ) { + middlewareConstructor(...args); + super(...args); + } + } + + return { ...actual, MCPAppsMiddleware: TrackedMCPAppsMiddleware }; +}); /** * A minimal next-agent that emits RUN_STARTED and RUN_FINISHED. @@ -74,6 +91,7 @@ describe("MCPAppsMiddleware integration", () => { if (llm) { await llm.stop().catch(() => {}); } + middlewareConstructor.mockClear(); }); async function startMcpServer(): Promise { @@ -115,6 +133,41 @@ describe("MCPAppsMiddleware integration", () => { expect(middleware).toBeInstanceOf(MCPAppsMiddleware); }); + it("rejects unsupported policy before the runtime constructs middleware", async () => { + const server = { + type: "http" as const, + url: "https://mcp.example.com/mcp", + serverId: "weather", + excludeTools: ["delete_account"], + } as unknown as McpAppsServerConfig; + const runtime = new CopilotRuntime({ + agents: { default: new MockNextAgent() }, + mcpApps: { servers: [server] }, + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + try { + const response = await handleRunAgent({ + runtime, + agentId: "default", + request: new Request("https://example.com/agent/default/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(createRunInput()), + }), + }); + + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ + error: "Failed to run agent", + message: expect.stringContaining("excludeTools"), + }); + expect(middlewareConstructor).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); + it("proxies tools/call through to MCPMock and returns results", async () => { const mcpUrl = await startMcpServer(); diff --git a/packages/runtime/src/v2/runtime/__tests__/mcp-apps-servers.test.ts b/packages/runtime/src/v2/runtime/__tests__/mcp-apps-servers.test.ts new file mode 100644 index 0000000000..b604977901 --- /dev/null +++ b/packages/runtime/src/v2/runtime/__tests__/mcp-apps-servers.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import type { MCPClientConfig } from "@ag-ui/mcp-apps-middleware"; +import type { McpAppsServerConfig } from "../core/runtime"; +import { resolveMcpAppsServers } from "../handlers/shared/mcp-apps-servers"; + +type Assert = T; +type ExternalPolicyKeys = Extract< + keyof MCPClientConfig, + "includeTools" | "excludeTools" +>; +type ExternalMiddlewareHasNoPolicyFields = [ExternalPolicyKeys] extends [never] + ? true + : false; +type ExternalMiddlewarePolicyTripwire = + Assert; + +void (undefined as unknown as ExternalMiddlewarePolicyTripwire); + +const baseServer = { + type: "sse" as const, + url: "https://mcp.example.com/sse", + headers: { Authorization: "Bearer token" }, + serverId: "weather", + futureField: { retained: true }, +}; + +function asServer(config: Record): McpAppsServerConfig { + return config as unknown as McpAppsServerConfig; +} + +describe("resolveMcpAppsServers", () => { + it.each([ + ["includeTools", []], + ["includeTools", null], + ["includeTools", "get_weather"], + ["excludeTools", []], + ["excludeTools", null], + ["excludeTools", "delete_account"], + ])("rejects a defined %s value of any shape", (key, value) => { + expect(() => + resolveMcpAppsServers( + [asServer({ ...baseServer, [key]: value })], + "default", + ), + ).toThrow( + new RegExp( + `${key}.*server\\[0\\].*weather.*@ag-ui/mcp-apps-middleware@0\\.0\\.3.*https://github\\.com/CopilotKit/CopilotKit/issues/5930`, + ), + ); + }); + + it("reports every unsupported key before filtering by agent", () => { + expect(() => + resolveMcpAppsServers( + [ + asServer({ + ...baseServer, + serverId: "other", + agentId: "other", + includeTools: [], + }), + asServer({ + ...baseServer, + serverId: "current", + agentId: "current", + excludeTools: null, + }), + ], + "current", + ), + ).toThrow( + /includeTools at server\[0\] \(other\).*excludeTools at server\[1\] \(current\)/, + ); + }); + + it("accepts an explicitly undefined policy value and forwards it unchanged", () => { + const server = { ...baseServer, includeTools: undefined }; + + expect(resolveMcpAppsServers([asServer(server)], "default")).toEqual([ + server, + ]); + }); + + it("filters by agent, strips only agentId, and preserves order and fields", () => { + const globalServer = { ...baseServer, serverId: "global" }; + const otherServer = { + ...baseServer, + serverId: "other", + agentId: "other", + }; + const matchingServer = { + ...baseServer, + serverId: "matching", + agentId: "default", + }; + + const resolved = resolveMcpAppsServers( + [asServer(globalServer), asServer(otherServer), asServer(matchingServer)], + "default", + ); + + expect(resolved).toEqual([ + globalServer, + { + type: "sse", + url: matchingServer.url, + headers: matchingServer.headers, + serverId: "matching", + futureField: matchingServer.futureField, + }, + ]); + expect(Object.prototype.hasOwnProperty.call(resolved[0], "agentId")).toBe( + false, + ); + }); + + it("returns no servers for empty and nonmatching configurations", () => { + expect(resolveMcpAppsServers([], "default")).toEqual([]); + expect( + resolveMcpAppsServers( + [asServer({ ...baseServer, agentId: "other" })], + "default", + ), + ).toEqual([]); + }); +}); diff --git a/packages/runtime/src/v2/runtime/core/runtime.ts b/packages/runtime/src/v2/runtime/core/runtime.ts index 55d47e3c17..3bb11dd95f 100644 --- a/packages/runtime/src/v2/runtime/core/runtime.ts +++ b/packages/runtime/src/v2/runtime/core/runtime.ts @@ -47,6 +47,7 @@ interface BaseCopilotRuntimeMiddlewareOptions { agents?: string[]; } +/** Per-server tool policy belongs to the external middleware and is unsupported at its pinned 0.0.3 release. */ export type McpAppsServerConfig = MCPClientConfig & { /** Agent to bind this server to. If omitted, the server is available to all agents. */ agentId?: string; diff --git a/packages/runtime/src/v2/runtime/handlers/shared/agent-utils.ts b/packages/runtime/src/v2/runtime/handlers/shared/agent-utils.ts index 54b2b4d7b3..e3086308a0 100644 --- a/packages/runtime/src/v2/runtime/handlers/shared/agent-utils.ts +++ b/packages/runtime/src/v2/runtime/handlers/shared/agent-utils.ts @@ -15,6 +15,7 @@ import { mergeForwardableHeaders, resolveForwardHeadersPolicy, } from "../header-utils"; +import { resolveMcpAppsServers } from "./mcp-apps-servers"; import { resolveIntelligenceUser } from "./resolve-intelligence-user"; import { logger } from "@copilotkit/shared"; @@ -119,13 +120,7 @@ export function configureAgentForRequest(params: { } if (runtime.mcpApps?.servers?.length) { - const mcpServers = runtime.mcpApps.servers - .filter((server) => !server.agentId || server.agentId === agentId) - .map((server) => { - const mcpServer = { ...server }; - delete mcpServer.agentId; - return mcpServer; - }); + const mcpServers = resolveMcpAppsServers(runtime.mcpApps.servers, agentId); if (mcpServers.length > 0 && typeof agent.use === "function") { agent.use(new MCPAppsMiddleware({ mcpServers })); diff --git a/packages/runtime/src/v2/runtime/handlers/shared/mcp-apps-servers.ts b/packages/runtime/src/v2/runtime/handlers/shared/mcp-apps-servers.ts new file mode 100644 index 0000000000..544d393bef --- /dev/null +++ b/packages/runtime/src/v2/runtime/handlers/shared/mcp-apps-servers.ts @@ -0,0 +1,44 @@ +import type { MCPClientConfig } from "@ag-ui/mcp-apps-middleware"; +import type { McpAppsServerConfig } from "../../core/runtime"; + +const UNSUPPORTED_POLICY_KEYS = ["includeTools", "excludeTools"] as const; +const MCP_APPS_MIDDLEWARE_VERSION = "@ag-ui/mcp-apps-middleware@0.0.3"; +const MCP_APPS_POLICY_ISSUE = + "https://github.com/CopilotKit/CopilotKit/issues/5930"; + +/** + * Select the servers for an agent and validate the policy boundary owned by + * the installed MCP Apps middleware. + */ +export function resolveMcpAppsServers( + servers: readonly McpAppsServerConfig[], + agentId: string, +): MCPClientConfig[] { + const violations: string[] = []; + + for (const [index, server] of servers.entries()) { + const config = server as unknown as Record; + for (const key of UNSUPPORTED_POLICY_KEYS) { + if ( + Object.prototype.hasOwnProperty.call(config, key) && + config[key] !== undefined + ) { + violations.push( + `${key} at server[${index}] (${server.serverId ?? server.url})`, + ); + } + } + } + + if (violations.length > 0) { + throw new Error( + `Unsupported MCP Apps tool policy: ${violations.join(", ")}. ` + + `${MCP_APPS_MIDDLEWARE_VERSION} owns per-server tool policy and ` + + `does not support these keys; see ${MCP_APPS_POLICY_ISSUE}.`, + ); + } + + return servers + .filter((server) => !server.agentId || server.agentId === agentId) + .map(({ agentId: _agentId, ...server }) => server as MCPClientConfig); +} diff --git a/showcase/shell-docs/src/content/docs/backend/copilot-runtime.mdx b/showcase/shell-docs/src/content/docs/backend/copilot-runtime.mdx index 08469ce8cd..c2f62799b9 100644 --- a/showcase/shell-docs/src/content/docs/backend/copilot-runtime.mdx +++ b/showcase/shell-docs/src/content/docs/backend/copilot-runtime.mdx @@ -182,6 +182,8 @@ const runtime = new CopilotRuntime({ Each server entry optionally accepts an `agentId` field to scope that server to a single agent. Without it, the server is available to all agents. +Per-tool filtering belongs to `@ag-ui/mcp-apps-middleware`. The runtime currently pins version `0.0.3`, which does not support `includeTools` or `excludeTools`. Supplying either key raises a configuration error instead of silently ignoring it; use a compatible middleware release when that upstream policy contract is available. + ## Forwarding request headers to your agent When a request reaches the runtime, some inbound headers are forwarded onto the outgoing call to your agent (the `/run` path that actually dispatches the agent). This is how a token configured by the frontend provider reaches a self-hosted agent — see [Authentication](/auth). diff --git a/skills/runtime/references/wiring-mcp-apps-middleware.md b/skills/runtime/references/wiring-mcp-apps-middleware.md index fdb06fabf4..56dc4d70ef 100644 --- a/skills/runtime/references/wiring-mcp-apps-middleware.md +++ b/skills/runtime/references/wiring-mcp-apps-middleware.md @@ -49,6 +49,11 @@ export default { fetch: handler }; Each server entry accepts an optional `agentId`. When set, the server's tools are only exposed to that agent. Omit it to expose to all agents. +Per-tool filtering belongs to `@ag-ui/mcp-apps-middleware`. The runtime currently pins +version `0.0.3`, which does not support `includeTools` or `excludeTools`. Supplying either +key raises a configuration error instead of silently ignoring it; use a compatible +middleware release when that upstream policy contract is available. + ## Gotcha — do NOT put MCP under agents ```typescript From e77bdb23a18fb63af6bf9cff452feb71ec05d497 Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Sat, 1 Aug 2026 11:05:17 -0400 Subject: [PATCH 2/2] test(runtime): cover MCP Apps adapter attachment states --- .../mcp-apps-middleware-integration.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/packages/runtime/src/v2/runtime/__tests__/mcp-apps-middleware-integration.test.ts b/packages/runtime/src/v2/runtime/__tests__/mcp-apps-middleware-integration.test.ts index 50ba9219ed..9471238b35 100644 --- a/packages/runtime/src/v2/runtime/__tests__/mcp-apps-middleware-integration.test.ts +++ b/packages/runtime/src/v2/runtime/__tests__/mcp-apps-middleware-integration.test.ts @@ -6,7 +6,9 @@ import { LLMock, MCPMock } from "@copilotkit/aimock"; import { MCPAppsMiddleware, getServerHash } from "@ag-ui/mcp-apps-middleware"; import type * as MCPAppsMiddlewareModule from "@ag-ui/mcp-apps-middleware"; import type { McpAppsServerConfig } from "../core/runtime"; +import type { CopilotRuntimeLike } from "../core/runtime"; import { CopilotRuntime } from "../core/runtime"; +import { configureAgentForRequest } from "../handlers/shared/agent-utils"; import { handleRunAgent } from "../handlers/handle-run"; const middlewareConstructor = vi.hoisted(() => vi.fn()); @@ -168,6 +170,105 @@ describe("MCPAppsMiddleware integration", () => { } }); + it("attaches valid MCP Apps servers through the production adapter", () => { + const agent = new MockNextAgent(); + const runtime = { + mcpApps: { + servers: [ + { + type: "http" as const, + url: "https://mcp.example.com/mcp", + serverId: "weather", + agentId: "default", + }, + ], + }, + } as unknown as CopilotRuntimeLike; + + configureAgentForRequest({ + runtime, + request: new Request("https://example.com/run"), + agentId: "default", + agent, + }); + + expect(middlewareConstructor).toHaveBeenCalledOnce(); + const [config] = middlewareConstructor.mock.calls[0] as [ + { mcpServers: Record[] }, + ]; + expect(config.mcpServers).toEqual([ + { + type: "http", + url: "https://mcp.example.com/mcp", + serverId: "weather", + }, + ]); + expect(config.mcpServers[0]).not.toHaveProperty("agentId"); + }); + + it("does not attach MCP Apps when no server matches the agent", () => { + const agent = new MockNextAgent(); + const runtime = { + mcpApps: { + servers: [ + { + type: "http" as const, + url: "https://mcp.example.com/mcp", + agentId: "other", + }, + ], + }, + } as unknown as CopilotRuntimeLike; + + configureAgentForRequest({ + runtime, + request: new Request("https://example.com/run"), + agentId: "default", + agent, + }); + + expect(middlewareConstructor).not.toHaveBeenCalled(); + }); + + it("does not attach MCP Apps for an empty server list", () => { + const agent = new MockNextAgent(); + const runtime = { + mcpApps: { servers: [] }, + } as unknown as CopilotRuntimeLike; + + configureAgentForRequest({ + runtime, + request: new Request("https://example.com/run"), + agentId: "default", + agent, + }); + + expect(middlewareConstructor).not.toHaveBeenCalled(); + }); + + it("does not attach MCP Apps when the agent has no use method", () => { + const agent = { headers: {} } as unknown as AbstractAgent; + const runtime = { + mcpApps: { + servers: [ + { + type: "http" as const, + url: "https://mcp.example.com/mcp", + }, + ], + }, + } as unknown as CopilotRuntimeLike; + + configureAgentForRequest({ + runtime, + request: new Request("https://example.com/run"), + agentId: "default", + agent, + }); + + expect(middlewareConstructor).not.toHaveBeenCalled(); + }); + it("proxies tools/call through to MCPMock and returns results", async () => { const mcpUrl = await startMcpServer();