fix(runtime): scope MCP Apps requests through the released middleware

Consume upstream MCP Apps 0.1.0, retain trusted server credentials, and
reject proxy requests outside the selected agent scope. Ordinary runs
without selected servers do not attach middleware.
This commit is contained in:
Mike Ryan
2026-09-10 14:34:26 -07:00
committed by Maximiliano Korp
parent 288070063a
commit 84549992ab
9 changed files with 481 additions and 8 deletions
+1
View File
@@ -11,6 +11,7 @@ minimum-release-age-exclude[]=@ag-ui/client
minimum-release-age-exclude[]=@ag-ui/encoder
minimum-release-age-exclude[]=@ag-ui/proto
minimum-release-age-exclude[]=@ag-ui/langgraph
minimum-release-age-exclude[]=@ag-ui/mcp-apps-middleware
minimum-release-age-exclude[]=@ag-ui/a2ui-middleware
minimum-release-age-exclude[]=@ag-ui/a2ui-toolkit
minimum-release-age-exclude[]=@ag-ui/mcp-middleware
+1 -1
View File
@@ -82,7 +82,7 @@
"@ag-ui/core": "0.0.59",
"@ag-ui/encoder": "0.0.59",
"@ag-ui/langgraph": "0.0.43",
"@ag-ui/mcp-apps-middleware": "0.0.3",
"@ag-ui/mcp-apps-middleware": "^0.1.0",
"@ag-ui/mcp-middleware": "0.0.2",
"@ai-sdk/anthropic": "^3.0.49",
"@ai-sdk/google": "^3.0.33",
@@ -0,0 +1,53 @@
import { HttpAgent } from "@ag-ui/client";
import { MCPAppsMiddleware } from "@ag-ui/mcp-apps-middleware";
import { expect, test, vi } from "vitest";
import { CopilotIntelligenceRuntime } from "../core/runtime";
import { CopilotKitIntelligence } from "../intelligence-platform/client";
import { configureAgentForRequest } from "../handlers/shared/agent-utils";
/** Configure an agent without making platform or MCP requests. */
function setup(scopedElsewhere: boolean) {
const agent = new HttpAgent({ url: "http://localhost:9999/agent" });
const runtime = new CopilotIntelligenceRuntime({
intelligence: new CopilotKitIntelligence({ apiKey: "fixture-key" }),
identifyUser: () => ({ id: "alice", name: "Alice" }),
agents: { default: agent },
generateThreadNames: false,
mcpApps: {
servers: scopedElsewhere
? [
{
type: "http",
url: "http://localhost:9999/mcp",
agentId: "another-agent",
},
]
: [],
},
});
return {
use: vi.spyOn(agent, "use"),
params: {
runtime,
agent,
agentId: "default",
request: new Request("http://localhost/run"),
},
};
}
test.each([false, true])(
"ordinary runs add no MCP middleware when no server is selected (scoped=%s)",
(scoped) => {
const { use, params } = setup(scoped);
configureAgentForRequest(params);
expect(use).not.toHaveBeenCalled();
},
);
test("an MCP proxy request retains the upstream empty-server guard", () => {
const { use, params } = setup(true);
const proxyParams = { ...params, isMcpProxyRequest: true };
configureAgentForRequest(proxyParams);
expect(use).toHaveBeenCalledExactlyOnceWith(expect.any(MCPAppsMiddleware));
});
@@ -71,6 +71,8 @@ interface BaseCopilotRuntimeMiddlewareOptions {
/** Per-server tool policy belongs to the external middleware and is unsupported at its pinned 0.0.3 release. */
export type McpAppsServerConfig = MCPClientConfig & {
/** Intelligence-only HTTP/SSE credentials from trusted server config, never iframe input. */
headers?: Record<string, string>;
/** Agent to bind this server to. If omitted, the server is available to all agents. */
agentId?: string;
};
@@ -67,6 +67,10 @@ export async function handleRunAgent({
agentId,
agent,
providerA2UIHasCatalog,
isMcpProxyRequest: Object.prototype.hasOwnProperty.call(
input.forwardedProps ?? {},
"__proxiedMCPRequest",
),
});
const memoryResponse = await attachIntelligenceEnterpriseLearning({
runtime,
@@ -90,8 +90,16 @@ export function configureAgentForRequest(params: {
* has to also set `a2ui.injectA2UITool` on the runtime.
*/
providerA2UIHasCatalog?: boolean;
/** Retain proxy rejection even when no server is available to this agent. */
isMcpProxyRequest?: boolean;
}): void {
const { runtime, request, agentId, providerA2UIHasCatalog } = params;
const {
runtime,
request,
agentId,
providerA2UIHasCatalog,
isMcpProxyRequest,
} = params;
const agent = params.agent as MiddlewareCapableAgent;
// A2UI is on when the runtime explicitly enables it, OR when the provider
@@ -124,7 +132,19 @@ export function configureAgentForRequest(params: {
}
}
if (runtime.mcpApps?.servers?.length) {
if (isIntelligenceRuntime(runtime) && typeof agent.use === "function") {
// Ordinary runs need no middleware without selected servers. Proxy requests
// still need the upstream guard so they cannot fall through to the model.
const mcpServers = resolveMcpAppsServers(
runtime.mcpApps?.servers ?? [],
agentId,
);
if (mcpServers.length > 0 || isMcpProxyRequest) {
agent.use(
new MCPAppsMiddleware({ mcpServers, discoveryFailureMode: "throw" }),
);
}
} else if (runtime.mcpApps?.servers?.length) {
const mcpServers = resolveMcpAppsServers(runtime.mcpApps.servers, agentId);
if (mcpServers.length > 0 && typeof agent.use === "function") {
@@ -0,0 +1,391 @@
import { MCPAppsMiddleware, getServerHash } from "@ag-ui/mcp-apps-middleware";
import { expect, test, vi } from "vitest";
import { createServer } from "node:http";
import { once } from "node:events";
import { AbstractAgent, EventType } from "@ag-ui/client";
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
import { Observable, firstValueFrom, toArray } from "rxjs";
import { MCPMock } from "@copilotkit/aimock";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
/** Build one protocol input without a platform dependency. */
function input(forwardedProps: Record<string, unknown> = {}): RunAgentInput {
return {
threadId: "thread",
runId: "run",
messages: [],
state: {},
tools: [],
context: [],
forwardedProps,
};
}
/** Emit a UI tool call only when discovery made the tool available. */
class TestAgent extends AbstractAgent {
readonly called = vi.fn();
run(value: RunAgentInput): Observable<BaseEvent> {
this.called(value);
return new Observable((subscriber) => {
subscriber.next({
type: EventType.RUN_STARTED,
threadId: value.threadId,
runId: value.runId,
});
if (value.tools.some((tool) => tool.name === "card")) {
subscriber.next({
type: EventType.TOOL_CALL_START,
toolCallId: "call",
toolCallName: "card",
});
subscriber.next({
type: EventType.TOOL_CALL_ARGS,
toolCallId: "call",
delta: '{"title":"Hello"}',
});
subscriber.next({ type: EventType.TOOL_CALL_END, toolCallId: "call" });
}
subscriber.next({
type: EventType.RUN_FINISHED,
threadId: value.threadId,
runId: value.runId,
});
subscriber.complete();
});
}
}
/** Expose an authenticated real Streamable HTTP fixture and record session cleanup. */
async function server() {
const mock = new MCPMock({ port: 0 });
const tool = {
name: "card",
description: "Card",
inputSchema: {
type: "object" as const,
properties: { title: { type: "string" } },
},
_meta: { "ui/resourceUri": "ui://card" },
};
mock.addTool(tool);
mock.onToolCall("card", () => "rendered card");
mock.addResource(
{ uri: "ui://card", name: "Card", mimeType: "text/html+mcp" },
{ text: "<h1>Card</h1>", mimeType: "text/html+mcp" },
);
const upstream = await mock.start();
const requests: Array<{
method: string;
authorization?: string;
session?: string;
}> = [];
const proxy = createServer(async (request, response) => {
requests.push({
method: request.method!,
authorization: request.headers.authorization,
session: request.headers["mcp-session-id"] as string | undefined,
});
if (request.headers.authorization !== "Bearer fixture-secret") {
response.writeHead(401).end();
return;
}
const chunks: Buffer[] = [];
for await (const chunk of request) chunks.push(Buffer.from(chunk));
const headers = new Headers({
accept: "application/json, text/event-stream",
"content-type": "application/json",
});
for (const key of ["mcp-session-id", "mcp-protocol-version"])
if (request.headers[key]) headers.set(key, String(request.headers[key]));
const result = await fetch(upstream, {
method: request.method,
headers,
...(chunks.length ? { body: Buffer.concat(chunks) } : {}),
});
response.writeHead(
result.status,
Object.fromEntries(
[...result.headers].filter(([key]) =>
["content-type", "mcp-session-id"].includes(key),
),
),
);
response.end(Buffer.from(await result.arrayBuffer()));
});
proxy.listen(0, "127.0.0.1");
await once(proxy, "listening");
const address = proxy.address();
if (!address || typeof address === "string")
throw new Error("No fixture address");
return {
url: `http://127.0.0.1:${address.port}`,
requests,
async teardown(): Promise<void> {
await new Promise<void>((resolve) => {
proxy.closeAllConnections();
proxy.close(() => resolve());
});
await mock.stop();
},
};
}
test("authenticated HTTP discovery executes UI tools and deletes successful sessions", async () => {
const fixture = await server();
try {
const middleware = new MCPAppsMiddleware({
discoveryFailureMode: "throw",
mcpServers: [
{
type: "http",
url: fixture.url,
serverId: "cards",
headers: { Authorization: "Bearer fixture-secret" },
},
],
});
const agent = new TestAgent();
const events = await firstValueFrom(
middleware.run(input(), agent).pipe(toArray()),
);
expect(agent.called).toHaveBeenCalledOnce();
expect(
events.some(
(event) =>
event.type === EventType.ACTIVITY_SNAPSHOT &&
event.activityType === "mcp-apps",
),
).toBe(true);
expect(events.at(-1)?.type).toBe(EventType.RUN_FINISHED);
expect(
fixture.requests.filter((request) => request.method === "DELETE").length,
).toBeGreaterThanOrEqual(2);
expect(
fixture.requests.every(
(request) => request.authorization === "Bearer fixture-secret",
),
).toBe(true);
} finally {
await fixture.teardown();
}
});
test("unknown and disallowed proxy requests never reach the network or agent", async () => {
const fixture = await server();
try {
const agent = new TestAgent();
const middleware = new MCPAppsMiddleware({
discoveryFailureMode: "throw",
mcpServers: [{ type: "http", url: fixture.url, serverId: "cards" }],
});
for (const request of [
{ serverId: "unknown", method: "resources/read" },
{ serverId: "cards", method: "tools/list" },
]) {
const events = await firstValueFrom(
middleware
.run(input({ __proxiedMCPRequest: request }), agent)
.pipe(toArray()),
);
expect(events.at(-1)).toMatchObject({
type: EventType.RUN_FINISHED,
result: { error: expect.any(String) },
});
}
expect(agent.called).not.toHaveBeenCalled();
expect(fixture.requests).toHaveLength(0);
} finally {
await fixture.teardown();
}
});
test("browser proxy fields cannot replace configured headers or URL", async () => {
const fixture = await server();
try {
const agent = new TestAgent();
const middleware = new MCPAppsMiddleware({
discoveryFailureMode: "throw",
mcpServers: [
{
type: "http",
url: fixture.url,
serverId: "cards",
headers: { Authorization: "Bearer fixture-secret" },
},
],
});
const events = await firstValueFrom(
middleware
.run(
input({
__proxiedMCPRequest: {
serverId: "cards",
method: "resources/read",
params: { uri: "ui://card" },
url: "http://127.0.0.1:1",
headers: { Authorization: "Bearer attacker" },
},
}),
agent,
)
.pipe(toArray()),
);
expect(events.at(-1)).toMatchObject({
type: EventType.RUN_FINISHED,
result: { contents: [{ text: "<h1>Card</h1>" }] },
});
expect(agent.called).not.toHaveBeenCalled();
} finally {
await fixture.teardown();
}
});
test("failed MCP authentication is isolated from the agent and exposes no raw error", async () => {
const fixture = await server();
try {
const agent = new TestAgent();
const middleware = new MCPAppsMiddleware({
discoveryFailureMode: "throw",
mcpServers: [
{
type: "http",
url: fixture.url,
headers: { Authorization: "Bearer private-wrong-secret" },
},
],
});
await expect(
firstValueFrom(middleware.run(input(), agent).pipe(toArray())),
).rejects.toThrow("MCP tool discovery failed");
expect(agent.called).not.toHaveBeenCalled();
expect(fixture.requests.length).toBeGreaterThan(0);
} finally {
await fixture.teardown();
}
});
test("legacy SSE reentry keeps trusted authentication on GET and POST", async () => {
const requests: Array<{ method?: string; authorization?: string }> = [];
const sdkServer = new Server(
{ name: "legacy-fixture", version: "1.0.0" },
{ capabilities: {} },
);
let transport: SSEServerTransport | undefined;
const httpServer = createServer(async (request, response) => {
requests.push({
method: request.method,
authorization: request.headers.authorization,
});
if (request.headers.authorization !== "Bearer legacy-secret") {
response.writeHead(401).end();
return;
}
if (request.method === "GET") {
transport = new SSEServerTransport("/messages", response);
await sdkServer.connect(transport);
} else if (transport) {
await transport.handlePostMessage(request, response);
} else {
response.writeHead(404).end();
}
});
httpServer.listen(0, "127.0.0.1");
await once(httpServer, "listening");
try {
const address = httpServer.address();
if (!address || typeof address === "string")
throw new Error("No fixture address");
const agent = new TestAgent();
const middleware = new MCPAppsMiddleware({
discoveryFailureMode: "throw",
mcpServers: [
{
type: "sse",
url: `http://127.0.0.1:${address.port}/sse`,
serverId: "legacy",
headers: { Authorization: "Bearer legacy-secret" },
},
],
});
const events = await firstValueFrom(
middleware
.run(
input({
__proxiedMCPRequest: { serverId: "legacy", method: "ping" },
}),
agent,
)
.pipe(toArray()),
);
expect(events.at(-1)).toMatchObject({
type: EventType.RUN_FINISHED,
result: {},
});
expect(agent.called).not.toHaveBeenCalled();
expect(requests.some((request) => request.method === "GET")).toBe(true);
expect(requests.some((request) => request.method === "POST")).toBe(true);
expect(
requests.every(
(request) => request.authorization === "Bearer legacy-secret",
),
).toBe(true);
} finally {
await sdkServer.close();
await new Promise<void>((resolve) => {
httpServer.closeAllConnections();
httpServer.close(() => resolve());
});
}
});
test("activity hashes exclude credentials and remain valid for proxy selection", async () => {
const fixture = await server();
try {
const config = {
type: "http" as const,
url: fixture.url,
headers: { Authorization: "Bearer fixture-secret" },
};
const middleware = new MCPAppsMiddleware({
discoveryFailureMode: "throw",
mcpServers: [config],
});
const events = await firstValueFrom(
middleware.run(input(), new TestAgent()).pipe(toArray()),
);
const activity = events.find(
(event) =>
event.type === EventType.ACTIVITY_SNAPSHOT &&
event.activityType === "mcp-apps",
);
expect(activity).toMatchObject({
content: {
serverHash: getServerHash({ type: "http", url: fixture.url }),
},
});
const proxied = await firstValueFrom(
middleware
.run(
input({
__proxiedMCPRequest: {
serverHash: getServerHash({ type: "http", url: fixture.url }),
method: "resources/read",
params: { uri: "ui://card" },
},
}),
new TestAgent(),
)
.pipe(toArray()),
);
expect(proxied.at(-1)).toMatchObject({
result: { contents: [expect.objectContaining({ uri: "ui://card" })] },
});
} finally {
await fixture.teardown();
}
});
+6 -5
View File
@@ -3118,8 +3118,8 @@ importers:
specifier: 0.0.43
version: 0.0.43(@ag-ui/client@0.0.59)(@ag-ui/core@0.0.59)(@opentelemetry/api@1.9.0)(openai@4.104.0(encoding@0.1.13)(ws@8.19.0)(zod@3.25.76))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vue@3.5.34(typescript@5.9.3))(ws@8.19.0)(zod-to-json-schema@3.25.2(zod@3.25.76))
'@ag-ui/mcp-apps-middleware':
specifier: 0.0.3
version: 0.0.3(@ag-ui/client@0.0.59)(@cfworker/json-schema@4.1.1)(zod@3.25.76)
specifier: ^0.1.0
version: 0.1.0(@ag-ui/client@0.0.59)(@cfworker/json-schema@4.1.1)(rxjs@7.8.1)(zod@3.25.76)
'@ag-ui/mcp-middleware':
specifier: 0.0.2
version: 0.0.2(@ag-ui/client@0.0.59)(@cfworker/json-schema@4.1.1)(rxjs@7.8.1)(zod@3.25.76)
@@ -4146,10 +4146,11 @@ packages:
'@ag-ui/client': '>=0.0.40'
rxjs: 7.8.1
'@ag-ui/mcp-apps-middleware@0.0.3':
resolution: {integrity: sha512-Z+NZQXj4J+Y/2PLNsiyhNzRWFtTT2sAoC5dztoAlIsdfzvLvYEa52YGdHesNTN85JJqaIrYxQ8e31IBpKjrnoA==}
'@ag-ui/mcp-apps-middleware@0.1.0':
resolution: {integrity: sha512-q5t5aEwa0lbkOAeRnZ0Mzg4AgQx+vUPBf6+wo+cVT5Cyx1QZfAlZ6ikwW5kcwazQ4it2dlHFkfyXbXb2XtXBMQ==}
peerDependencies:
'@ag-ui/client': '>=0.0.40'
rxjs: 7.8.1
'@ag-ui/mcp-middleware@0.0.2':
resolution: {integrity: sha512-+CwY9SUjXTvk1h77/nqpXEPj79i/Gt0G7ZEcZLlYsJHJkYYC8IG9yN/tiR++HS1Qb1R46btomrgoBfpBvpGwDw==}
@@ -27591,7 +27592,7 @@ snapshots:
- supports-color
- zod
'@ag-ui/mcp-apps-middleware@0.0.3(@ag-ui/client@0.0.59)(@cfworker/json-schema@4.1.1)(zod@3.25.76)':
'@ag-ui/mcp-apps-middleware@0.1.0(@ag-ui/client@0.0.59)(@cfworker/json-schema@4.1.1)(rxjs@7.8.1)(zod@3.25.76)':
dependencies:
'@ag-ui/client': 0.0.59
'@modelcontextprotocol/sdk': 1.29.0(@cfworker/json-schema@4.1.1)(zod@3.25.76)
+1
View File
@@ -51,6 +51,7 @@ export const RELEASE_AGE_EXCLUDE = [
"@ag-ui/encoder",
"@ag-ui/proto",
"@ag-ui/langgraph",
"@ag-ui/mcp-apps-middleware",
"@ag-ui/a2ui-middleware",
"@ag-ui/a2ui-toolkit",
"@ag-ui/mcp-middleware",