Merge branch 'main' into claude/xenodochial-khayyam-873a16

This commit is contained in:
Ran Shemtov
2026-09-14 09:40:13 +02:00
committed by GitHub
47 changed files with 3181 additions and 865 deletions
+1
View File
@@ -87,6 +87,7 @@ packages/a2ui-renderer/tsdown.config.ts
packages/agentcore-runner/tsdown.config.ts
packages/core/tsdown.config.ts
packages/demo-agents/tsdown.config.ts
packages/mcp-apps-renderer/tsdown.config.ts
packages/react-core/tsdown.config.ts
packages/react-native/tsdown.config.ts
packages/react-textarea/tsdown.config.ts
+3 -3
View File
@@ -19,7 +19,7 @@ env:
NX_VERBOSE_LOGGING: true
jobs:
# Posts a per-PR comment with per-file gzip diffs for the 10 in-scope packages.
# Posts a per-PR comment with per-file gzip diffs for the 11 in-scope packages.
# Runs preactjs/compressed-size-action, which builds the PR head and the base
# branch, scans the `pattern` glob in each, and diffs the gzip sizes; the
# comment updates in place on subsequent pushes. No hard-fail (Phase 1) — see
@@ -64,9 +64,9 @@ jobs:
repo-token: ${{ secrets.GITHUB_TOKEN }}
# Use the root `build` script (present on both this branch and the base
# branch) so compressed-size-action can build both sides for comparison.
# The `pattern` below restricts measurement to the 10 in-scope packages.
# The `pattern` below restricts measurement to the 11 in-scope packages.
build-script: build
pattern: "packages/{core,shared,react-core,react-ui,react-textarea,runtime-client-gql,web-inspector,voice,a2ui-renderer,react-native}/dist/**/*.{mjs,js,cjs}"
pattern: "packages/{core,shared,react-core,react-ui,react-textarea,runtime-client-gql,web-inspector,voice,a2ui-renderer,react-native,mcp-apps-renderer}/dist/**/*.{mjs,js,cjs}"
# Measures what an app importing { CopilotChat } from
# @copilotkit/react-core/v2 bundles, by driving esbuild over a synthetic entry
+2 -2
View File
@@ -49,9 +49,9 @@ jobs:
- name: Build packages
run: >
npx nx run-many -t build
--projects=@copilotkit/core,@copilotkit/shared,@copilotkit/react-core,@copilotkit/react-ui,@copilotkit/react-textarea,@copilotkit/runtime-client-gql,@copilotkit/web-inspector,@copilotkit/voice,@copilotkit/a2ui-renderer
--projects=@copilotkit/core,@copilotkit/shared,@copilotkit/react-core,@copilotkit/react-ui,@copilotkit/react-textarea,@copilotkit/runtime-client-gql,@copilotkit/web-inspector,@copilotkit/voice,@copilotkit/a2ui-renderer,@copilotkit/mcp-apps-renderer
- name: Run compat-check
run: >
npx nx run-many -t compat-check
--projects=@copilotkit/core,@copilotkit/shared,@copilotkit/react-core,@copilotkit/react-ui,@copilotkit/react-textarea,@copilotkit/runtime-client-gql,@copilotkit/web-inspector,@copilotkit/voice,@copilotkit/a2ui-renderer
--projects=@copilotkit/core,@copilotkit/shared,@copilotkit/react-core,@copilotkit/react-ui,@copilotkit/react-textarea,@copilotkit/runtime-client-gql,@copilotkit/web-inspector,@copilotkit/voice,@copilotkit/a2ui-renderer,@copilotkit/mcp-apps-renderer
@@ -1,6 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// Bundle the SDK's global KaTeX CSS for the Pages Router.
transpilePackages: ["@copilotkit/react-core"],
experimental: {
// react-syntax-highlighter's CommonJS entry loads ESM refractor languages.
esmExternals: "loose",
},
};
export default nextConfig;
+1 -1
View File
@@ -18,7 +18,7 @@
"@google/generative-ai": "^0.11.2",
"@heroicons/react": "^2.0.18",
"clsx": "^1.2.1",
"next": "14.2.35",
"next": "15.5.24",
"openai": "^4.85.1",
"react": "^18",
"react-dom": "^18"
@@ -3,11 +3,9 @@ import {
OpenAIAdapter,
copilotRuntimeNextJSPagesRouterEndpoint,
} from "@copilotkit/runtime";
import { NextApiRequest, NextApiResponse } from "next";
import OpenAI from "openai";
import type { NextApiRequest, NextApiResponse } from "next";
const openai = new OpenAI();
const serviceAdapter = new OpenAIAdapter({ openai });
const serviceAdapter = new OpenAIAdapter();
const runtime = new CopilotRuntime({
actions: [
+1 -1
View File
@@ -15,7 +15,7 @@
"@copilotkit/runtime-client-gql": "workspace:*",
"clsx": "^1.2.1",
"motion": "^11.18.1",
"next": "14.2.35",
"next": "15.5.24",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"reactflow": "^11.11.4",
+1 -1
View File
@@ -33,7 +33,7 @@
"groq-sdk": "^0.5.0",
"leaflet": "^1.9.4",
"lucide-react": "^0.414.0",
"next": "14.2.35",
"next": "15.5.24",
"openai": "^4.85.1",
"react": "18.3.1",
"react-dom": "18.3.1",
+4 -6
View File
@@ -92,7 +92,7 @@
"vitest": "^4.1.3"
},
"engines": {
"node": ">=18"
"node": ">=20"
},
"packageManager": "pnpm@10.33.4",
"pnpm": {
@@ -106,21 +106,20 @@
"react-dom": "*"
}
},
"//": "Next.js overrides keep each supported major on its security floor (GHSA-2xp9-vwfh-vxw4, GHSA-p293-qw3h-jr36). Next 14 examples declare Next 15 explicitly because neither advisory has a Next 14 fix.",
"overrides": {
"streamdown>react": "^19.0.0",
"@types/react": "19.1.8",
"@types/react-dom": "^19.0.2",
"react": "19.2.3",
"react-dom": "19.2.3",
"next@<=15.4.11": "15.4.11",
"next@>=15.5.0 <15.5.15": "15.5.15",
"next@>=15.0.0 <15.5.24": "15.5.24",
"next@>=16.0.0 <16.3.3": "16.3.3",
"send@<=0.19.0": "0.19.0",
"path-to-regexp@<=0.1.12": "0.1.13",
"serve-static@<=1.16.0": "1.16.0",
"prismjs@<=1.30.0": "1.30.0",
"pino@<=10.1.1": "10.1.1",
"@copilotkit/license-verifier": "~0.5.0",
"next": "^16.0.10",
"defu@<=6.1.4": ">=6.1.5",
"minimatch": ">=9.0.6",
"minimatch@>=10.0.0 <10.2.1": ">=10.2.1",
@@ -169,7 +168,6 @@
"file-type": ">=21.3.1",
"@langchain/community": ">=1.1.14",
"langsmith": ">=0.5.18",
"next@>=16.0.0 <16.2.3": "16.2.3",
"validator": ">=13.15.20",
"markdown-it": ">=14.1.1",
"mdast-util-to-hast@>=13.0.0 <13.2.1": ">=13.2.1",
+1 -1
View File
@@ -44,6 +44,6 @@
"vitest": "^3.0.5"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
}
+1 -1
View File
@@ -58,6 +58,6 @@
"zod": "^3.25.75"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
}
+45
View File
@@ -0,0 +1,45 @@
# @copilotkit/mcp-apps-renderer
Framework-agnostic MCP Apps host for CopilotKit: the app↔host protocol on top of
[`@modelcontextprotocol/ext-apps`](https://www.npmjs.com/package/@modelcontextprotocol/ext-apps)
(AppBridge + PostMessage transport, sandbox proxy, per-thread request queue,
`ui/message` / `ui/open-link` / `tools/call` proxy, tool input/result forwarding,
`ui/request-display-mode`). The React / Vue / Angular renderers consume it as thin
adapters: they create the sandbox iframe and wire reactive state, while all
protocol logic lives in `bindMcpApp`.
## Entry points
| Import | Contents | Bundle |
| ---------------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| `@copilotkit/mcp-apps-renderer` | `bindMcpApp` + the full session API | **ESM only** — it wraps the ESM-only ext-apps bridge, and is meant to be loaded lazily via a dynamic `import()`. |
| `@copilotkit/mcp-apps-renderer/activity` | `MCPAppsActivityType`, `MCPAppsActivityContentSchema`, `ɵrunMcpFollowUp` | ESM + CJS. Bridge-free: importing it to register the activity does **not** pull the ext-apps bundle. |
The root is ESM-only on purpose: `@modelcontextprotocol/ext-apps` ships ESM only,
so a CommonJS root would emit a `require()` of an ES module and fail with
`ERR_REQUIRE_ESM`. Consume `bindMcpApp` via a dynamic `import()` (which resolves
ESM from any module system), and import the bridge-free `/activity` surface for
synchronous activity registration.
## Script-tag / UMD usage
This package also ships a UMD build of the bridge-free `/activity` entry:
`dist/activity.umd.js`, which defines the global
`CopilotKitMcpAppsRendererActivity`.
`@copilotkit/react-core`'s UMD build references that global (it externalizes
`@copilotkit/mcp-apps-renderer/activity` to register the built-in MCP Apps
activity). **Script-tag consumers of react-core's UMD must therefore load
`activity.umd.js` before `@copilotkit/react-core`'s UMD bundle**, alongside the
other UMD globals it depends on (React, `CopilotKitCore`,
`CopilotKitA2UIRenderer`, …):
```html
<!-- ...React, @copilotkit/core, @copilotkit/a2ui-renderer, etc. first... -->
<script src="https://unpkg.com/@copilotkit/mcp-apps-renderer/dist/activity.umd.js"></script>
<script src="https://unpkg.com/@copilotkit/react-core/dist/index.umd.js"></script>
```
Only the bridge-free `/activity` surface has a UMD build; the ext-apps bridge
itself (`bindMcpApp`) is loaded lazily via `import()` and is not part of the UMD
graph, so it only loads when an MCP App is actually rendered.
+77
View File
@@ -0,0 +1,77 @@
{
"name": "@copilotkit/mcp-apps-renderer",
"version": "1.71.0",
"private": false,
"description": "Framework-agnostic MCP Apps host for CopilotKit - the app<->host protocol (ext-apps AppBridge), sandbox proxy, and shared renderer consumed by the React, Vue, and Angular frontends",
"keywords": [
"ai",
"copilotkit",
"mcp",
"mcp-apps",
"ext-apps",
"renderer",
"ui"
],
"homepage": "https://github.com/CopilotKit/CopilotKit",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/CopilotKit/CopilotKit.git",
"directory": "packages/mcp-apps-renderer"
},
"files": [
"dist"
],
"type": "module",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"./activity": {
"import": {
"types": "./dist/activity.d.mts",
"default": "./dist/activity.mjs"
},
"require": {
"types": "./dist/activity.d.cts",
"default": "./dist/activity.cjs"
}
},
"./package.json": "./package.json"
},
"publishConfig": {
"access": "public"
},
"scripts": {
"build": "tsdown",
"compat-check": "es-check es2022 --module 'dist/**/!(*.umd).{mjs,cjs,js}' && es-check es2018 'dist/**/*.umd.js'",
"check-types": "tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"test:watch": "vitest",
"publint": "publint .",
"attw": "attw --pack . --profile esm-only --entrypoints . && attw --pack . --profile node16 --exclude-entrypoints ."
},
"dependencies": {
"@copilotkit/shared": "workspace:*",
"@modelcontextprotocol/ext-apps": "^1.7.5",
"@modelcontextprotocol/sdk": "^1.29.0"
},
"peerDependencies": {
"@ag-ui/client": "0.0.59",
"zod": ">=3.25"
},
"devDependencies": {
"@ag-ui/client": "0.0.59",
"@modelcontextprotocol/sdk": "^1.29.0",
"jsdom": "^26.1.0",
"tsdown": "^0.20.3",
"typescript": "5.9.2",
"vitest": "^3.2.4",
"zod": "^3.25.76"
}
}
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/ext-apps";
import { buildSandboxHTML } from "../sandbox";
import {
MCP_OPEN_LINK_BLOCKED_SCHEMES,
MCPAppsActivityType,
} from "../constants";
// MCP_APPS_PROTOCOL_VERSION lives on the bridge side (session) so the bridge-free
// `./constants` / `./activity` entry never pulls the ext-apps bundle.
import { MCP_APPS_PROTOCOL_VERSION } from "../session";
import { MCPAppsActivityContentSchema } from "../content-schema";
describe("buildSandboxHTML", () => {
it("produces a sandbox proxy document that announces sandbox-proxy-ready", () => {
const html = buildSandboxHTML();
expect(html).toContain("<!doctype html>");
expect(html).toContain("ui/notifications/sandbox-proxy-ready");
expect(html).toContain("Content-Security-Policy");
// relays the widget HTML on sandbox-resource-ready
expect(html).toContain("ui/notifications/sandbox-resource-ready");
});
it("appends extra CSP domains to script-src and frame-src", () => {
const html = buildSandboxHTML(["https://example.com"]);
expect(html).toContain("https://example.com");
});
});
describe("constants", () => {
it("exposes the mcp-apps activity type", () => {
expect(MCPAppsActivityType).toBe("mcp-apps");
});
it("sources the protocol version from ext-apps (no hand-maintained literal)", () => {
// MCP_APPS_PROTOCOL_VERSION is a re-export of the bridge's own
// LATEST_PROTOCOL_VERSION, so it can never drift from the spec version.
expect(MCP_APPS_PROTOCOL_VERSION).toBe(LATEST_PROTOCOL_VERSION);
});
it("negotiates the expected current protocol version (2026-01-26 canary)", () => {
// Canary: if ext-apps bumps the version, this fails so we review the change.
expect(MCP_APPS_PROTOCOL_VERSION).toBe("2026-01-26");
});
it("blocks script/HTML-executing schemes for ui/open-link", () => {
for (const scheme of [
"javascript:",
"data:",
"vbscript:",
"blob:",
"file:",
]) {
expect(MCP_OPEN_LINK_BLOCKED_SCHEMES.has(scheme)).toBe(true);
}
expect(MCP_OPEN_LINK_BLOCKED_SCHEMES.has("https:")).toBe(false);
});
});
describe("content schema", () => {
it("validates a well-formed activity content", () => {
const parsed = MCPAppsActivityContentSchema.safeParse({
result: { content: [{ type: "text", text: "ok" }], isError: false },
resourceUri: "ui://server/dashboard",
serverHash: "abc123",
toolInput: { q: "hi" },
});
expect(parsed.success).toBe(true);
});
it("rejects content missing resourceUri", () => {
const parsed = MCPAppsActivityContentSchema.safeParse({
result: {},
serverHash: "abc123",
});
expect(parsed.success).toBe(false);
});
});
@@ -0,0 +1,358 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AbstractAgent } from "@ag-ui/client";
import { bindMcpApp } from "../session";
import type { McpAppSession } from "../session";
import type { MCPAppsActivityContent } from "../content-schema";
// ---------------------------------------------------------------------------
// Minimal agent mock: only what bindMcpApp + the request queue touch.
// ---------------------------------------------------------------------------
function makeAgent(overrides?: Partial<Record<string, unknown>>) {
const addMessageCalls: Array<{ id: string; role: string; content: string }> =
[];
const runAgentCalls: Array<any> = [];
const agent = {
agentId: "test-agent",
threadId: "thread-1",
isRunning: false,
addMessageCalls,
runAgentCalls,
addMessage(msg: { id: string; role: string; content: string }) {
addMessageCalls.push(msg);
},
subscribe() {
return { unsubscribe() {} };
},
async runAgent(input?: any) {
runAgentCalls.push(input);
const req = input?.forwardedProps?.__proxiedMCPRequest;
if (req?.method === "resources/read") {
return {
result: {
contents: [
{
uri: req.params?.uri,
mimeType: "text/html",
text: "<html><body>Widget</body></html>",
},
],
},
newMessages: [],
};
}
if (req?.method === "tools/call") {
return {
result: {
content: [{ type: "text", text: "tool ok" }],
isError: false,
},
newMessages: [],
};
}
return { result: {}, newMessages: [] };
},
...overrides,
};
return agent as unknown as AbstractAgent & {
addMessageCalls: typeof addMessageCalls;
runAgentCalls: typeof runAgentCalls;
};
}
function makeContent(
over?: Partial<MCPAppsActivityContent>,
): MCPAppsActivityContent {
return {
resourceUri: "ui://test/app",
serverHash: "hash-123",
result: { content: [], isError: false },
toolInput: {},
...over,
} as MCPAppsActivityContent;
}
const tick = (ms = 30) => new Promise((r) => setTimeout(r, ms));
/** Dispatch a JSON-RPC message from the iframe (source = its contentWindow). */
function fromIframe(iframe: HTMLIFrameElement, data: unknown) {
window.dispatchEvent(
new MessageEvent("message", {
data,
source: iframe.contentWindow,
origin: "",
}),
);
}
/** Capture messages the host posts back to the iframe. */
function captureOutgoing(iframe: HTMLIFrameElement) {
const captured: any[] = [];
const cw = iframe.contentWindow as Window;
const orig = cw.postMessage.bind(cw);
cw.postMessage = ((message: unknown, ...args: unknown[]) => {
captured.push(message);
return (orig as any)(message, ...args);
}) as typeof cw.postMessage;
return captured;
}
let sessions: McpAppSession[] = [];
let iframes: HTMLIFrameElement[] = [];
function mount() {
const iframe = document.createElement("iframe");
document.body.appendChild(iframe);
iframes.push(iframe);
return iframe;
}
/**
* Bind + wait for setup (resource fetch + connect) + simulate the sandbox proxy
* ready handshake, mirroring the react-core MCP e2e harness.
*/
async function bindAndConnect(
iframe: HTMLIFrameElement,
agent: AbstractAgent,
content = makeContent(),
hooks?: Parameters<typeof bindMcpApp>[0]["hooks"],
) {
const session = bindMcpApp({
iframe,
getContent: () => content,
getAgent: () => agent,
host: { runAgent: async () => ({ result: undefined, newMessages: [] }) },
hooks,
});
sessions.push(session);
await tick(60); // let fetchResource + bridge.connect settle
const captured = captureOutgoing(iframe);
fromIframe(iframe, {
jsonrpc: "2.0",
method: "ui/notifications/sandbox-proxy-ready",
});
await tick(30);
return { session, captured };
}
afterEach(() => {
sessions.forEach((s) => s.teardown());
sessions = [];
iframes.forEach((f) => f.remove());
iframes = [];
vi.restoreAllMocks();
});
describe("bindMcpApp", () => {
it("fetches the resource through the agent and loads the sandbox", async () => {
const agent = makeAgent();
const iframe = mount();
await bindAndConnect(iframe, agent);
const readCall = agent.runAgentCalls.find(
(c) =>
c?.forwardedProps?.__proxiedMCPRequest?.method === "resources/read",
);
expect(readCall).toBeDefined();
expect(iframe.getAttribute("data-testid")).toBe("mcp-app-iframe");
expect(iframe.srcdoc).toContain("sandbox-proxy-ready");
});
it("proxies tools/call through the agent and returns the result to the iframe", async () => {
const agent = makeAgent();
const iframe = mount();
const { captured } = await bindAndConnect(iframe, agent);
const id = "call-1";
fromIframe(iframe, {
jsonrpc: "2.0",
id,
method: "tools/call",
params: { name: "do_thing", arguments: { a: 1 } },
});
await tick(40);
const toolCall = agent.runAgentCalls.find(
(c) => c?.forwardedProps?.__proxiedMCPRequest?.method === "tools/call",
);
expect(toolCall).toBeDefined();
const response = captured.find((m) => m && m.id === id && "result" in m);
expect(response?.result?.content?.[0]?.text).toBe("tool ok");
});
it("opens https links and blocks javascript: links (ui/open-link)", async () => {
const agent = makeAgent();
const iframe = mount();
const { captured } = await bindAndConnect(iframe, agent);
const openSpy = vi.spyOn(window, "open").mockImplementation(() => null);
fromIframe(iframe, {
jsonrpc: "2.0",
id: "ok",
method: "ui/open-link",
params: { url: "https://example.com" },
});
await tick(20);
expect(openSpy).toHaveBeenCalledWith(
"https://example.com",
"_blank",
"noopener,noreferrer",
);
openSpy.mockClear();
fromIframe(iframe, {
jsonrpc: "2.0",
id: "bad",
// eslint-disable-next-line no-script-url
method: "ui/open-link",
params: { url: "javascript:alert(1)" },
});
await tick(20);
expect(openSpy).not.toHaveBeenCalled();
const badResp = captured.find((m) => m && m.id === "bad" && "result" in m);
expect(badResp?.result).toMatchObject({ isError: true });
});
it("adds a ui/message to the agent (role from _meta.copilotkit)", async () => {
const agent = makeAgent();
const iframe = mount();
await bindAndConnect(iframe, agent);
fromIframe(iframe, {
jsonrpc: "2.0",
id: "msg-1",
method: "ui/message",
params: {
content: [{ type: "text", text: "hello from widget" }],
_meta: { copilotkit: { role: "assistant", followUp: false } },
},
});
await tick(30);
const call = agent.addMessageCalls.find(
(c) => c.content === "hello from widget",
);
expect(call).toBeDefined();
expect(call?.role).toBe("assistant");
});
it("fires onInitialized when the widget reports initialized", async () => {
const agent = makeAgent();
const iframe = mount();
const onInitialized = vi.fn();
const { session } = await bindAndConnect(iframe, agent, makeContent(), {
onInitialized,
});
fromIframe(iframe, {
jsonrpc: "2.0",
method: "ui/notifications/initialized",
});
await tick(20);
expect(onInitialized).toHaveBeenCalled();
// tool input pushed after initialize reaches the iframe
session.sendToolInput({ a: 1 });
await tick(10);
});
});
// ---------------------------------------------------------------------------
// ui/initialize is handled entirely by the ext-apps AppBridge that bindMcpApp
// constructs. These tests pin the negotiation contract at the package level (the
// compile-time tie to the spec the extraction argues for), because the host
// setup lives here now:
// - the bridge validates params against the spec schema, so a widget that omits
// the required fields (e.g. appCapabilities) fails initialize with -32603;
// - the host advertises only the latest MCP Apps protocol version, so a widget
// declaring a different version string gets the host version back;
// - the host context seeded at AppBridge construction is advertised at
// initialize (deterministic, not a post-connect race).
// ---------------------------------------------------------------------------
describe("bindMcpApp ui/initialize negotiation", () => {
const LATEST_PROTOCOL_VERSION = "2026-01-26";
it("negotiates and returns the host context for a well-formed initialize", async () => {
const agent = makeAgent();
const iframe = mount();
const { captured } = await bindAndConnect(iframe, agent);
const reqId = "init-ok";
fromIframe(iframe, {
jsonrpc: "2.0",
id: reqId,
method: "ui/initialize",
params: {
appInfo: { name: "test-widget", version: "1.0.0" },
appCapabilities: {},
protocolVersion: LATEST_PROTOCOL_VERSION,
},
});
await tick(30);
const response = captured.find(
(m) => m && m.jsonrpc === "2.0" && m.id === reqId && m.result,
);
expect(response).toBeDefined();
expect(response).not.toHaveProperty("error");
// Protocol version is negotiated to the host's latest.
expect(response.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
// Host context seeded at construction is advertised at initialize.
expect(response.result.hostContext).toMatchObject({
theme: "light",
platform: "web",
});
});
it("rejects an initialize that omits required fields with -32603", async () => {
const agent = makeAgent();
const iframe = mount();
const { captured } = await bindAndConnect(iframe, agent);
// Empty params: no appInfo / appCapabilities / protocolVersion. The bridge
// validates against the spec schema and rejects before any host handler.
const reqId = "init-bad";
fromIframe(iframe, {
jsonrpc: "2.0",
id: reqId,
method: "ui/initialize",
params: {},
});
await tick(30);
const errorResponse = captured.find(
(m) => m && m.jsonrpc === "2.0" && m.id === reqId && m.error,
);
expect(errorResponse).toBeDefined();
expect(errorResponse.error.code).toBe(-32603);
expect(typeof errorResponse.error.message).toBe("string");
expect(errorResponse.error.message.length).toBeGreaterThan(0);
});
it("returns the host protocol version, not the widget's, when they differ", async () => {
const agent = makeAgent();
const iframe = mount();
const { captured } = await bindAndConnect(iframe, agent);
// "2025-06-18" is a base-MCP-protocol version (and what the old hand-rolled
// Vue/Angular hosts hardcode). The bridge supports only its own MCP Apps
// version and returns that, rather than echoing the widget's.
const reqId = "init-version";
fromIframe(iframe, {
jsonrpc: "2.0",
id: reqId,
method: "ui/initialize",
params: {
appInfo: { name: "legacy-widget", version: "1.0.0" },
appCapabilities: {},
protocolVersion: "2025-06-18",
},
});
await tick(30);
const response = captured.find(
(m) => m && m.jsonrpc === "2.0" && m.id === reqId && m.result,
);
expect(response).toBeDefined();
expect(response.result.protocolVersion).toBe(LATEST_PROTOCOL_VERSION);
});
});
@@ -0,0 +1,11 @@
// Lightweight, bridge-free surface for MCP Apps activity registration.
//
// This entry re-exports ONLY the pieces that have no dependency on the ext-apps
// AppBridge (the ~40-50 kB gzipped MCP SDK + zod protocol bundle): the activity
// type + content schema (for the host's activity registry) and the ui/message
// follow-up runner. Frontends import these statically to register the MCP-apps
// activity, then load `bindMcpApp` from the package root (`.`) lazily via a
// dynamic import so a non-MCP app never pays for the bridge.
export * from "./constants";
export * from "./content-schema";
export * from "./follow-up";
@@ -0,0 +1,26 @@
/**
* Activity type for MCP Apps events - must match the middleware's MCPAppsActivityType.
*/
export const MCPAppsActivityType = "mcp-apps";
/**
* URL schemes a widget may NOT open via ui/open-link. The ext-apps schema
* validates `url` as a plain string only (noopener/noreferrer does not restrict
* the scheme), so ui/open-link could otherwise become an XSS vector.
*
* We use a denylist rather than an allowlist on purpose: deep links use
* arbitrary, app-defined schemes (`myapp:`, `whatsapp:`, `slack:`, `spotify:`,
* `sms:`, ...) that an allowlist could never enumerate, and `window.open`ing them
* just hands off to an OS handler - it does not execute script in the page, so
* it is not an XSS risk. Universal links / App Links are plain `https:` URLs and
* pass regardless. What IS dangerous is the small, well-known set of schemes
* that execute script or render attacker HTML in the page context; block those
* and allow everything else (including deep links).
*/
export const MCP_OPEN_LINK_BLOCKED_SCHEMES = new Set([
"javascript:",
"data:",
"vbscript:",
"blob:",
"file:",
]);
@@ -0,0 +1,26 @@
import { z } from "zod";
/**
* Zod schema for MCP Apps activity content (middleware 0.0.2 format). The
* framework activity registries validate the activity content with this before
* handing it to the renderer/session.
*/
export const MCPAppsActivityContentSchema = z.object({
result: z.object({
content: z.array(z.any()).optional(),
structuredContent: z.any().optional(),
isError: z.boolean().optional(),
}),
// Resource URI to fetch (e.g., "ui://server/dashboard")
resourceUri: z.string(),
// MD5 hash of server config (renamed from serverId in 0.0.1)
serverHash: z.string(),
// Optional stable server ID from config (takes precedence over serverHash)
serverId: z.string().optional(),
// Original tool input arguments
toolInput: z.record(z.string(), z.unknown()).optional(),
});
export type MCPAppsActivityContent = z.infer<
typeof MCPAppsActivityContentSchema
>;
@@ -0,0 +1,51 @@
import type { AbstractAgent, RunAgentResult } from "@ag-ui/client";
/**
* The subset of `CopilotKitCore` that {@link ɵrunMcpFollowUp} depends on.
* Declared structurally so the runner can be unit-tested without a full core.
*/
export interface ɵMcpFollowUpHost {
runAgent(params: { agent: AbstractAgent }): Promise<RunAgentResult>;
}
/**
* Run an MCP app `ui/message` follow-up, scoped to the thread it was enqueued
* for (issue #5819).
*
* The MCP request queue delays follow-up work until the agent is idle. There is
* a single shared registry agent per id, and switching threads overwrites its
* `threadId`/`messages` in place. So if the host switches threads while a
* follow-up is queued, running it now would execute against and stream into
* the now-foreground thread.
*
* - **Same thread** (the common case): run on the shared agent, unchanged.
* - **Thread changed**: the shared agent has moved on, so the follow-up can no
* longer run in its originating thread's context. Drop it rather than leak it
* into the current thread. (The MCP app already received its `ui/message` ack
* at enqueue time; only the optional agent turn is skipped.)
*
* @internal exported for testing.
*/
export async function ɵrunMcpFollowUp({
host,
agent,
capturedThreadId,
}: {
host: ɵMcpFollowUpHost;
agent: AbstractAgent;
capturedThreadId: string;
}): Promise<RunAgentResult> {
const currentThreadId = agent.threadId || "default";
const originThreadId = capturedThreadId || "default";
if (currentThreadId === originThreadId) {
return host.runAgent({ agent });
}
console.warn(
"[MCPAppsRenderer] ui/message follow-up dropped: the thread changed " +
`(${originThreadId}${currentThreadId}) between enqueue and execution, ` +
"so running it would leak into the now-foreground thread.",
);
return { result: undefined, newMessages: [] };
}
+14
View File
@@ -0,0 +1,14 @@
// @copilotkit/mcp-apps-renderer - framework-agnostic MCP Apps host.
//
// This package owns the app<->host protocol (ext-apps AppBridge), the sandbox
// proxy, the per-thread request queue, ui/message extensions, and the shared
// content schema. The React/Vue/Angular renderers consume it as thin adapters
// (they only mount the iframe and wire reactive state); ext-apps + the MCP SDK
// are declared here and nowhere else.
export * from "./constants";
export * from "./content-schema";
export * from "./sandbox";
export * from "./request-queue";
export * from "./follow-up";
export * from "./session";
@@ -0,0 +1,131 @@
import type { AbstractAgent, RunAgentResult } from "@ag-ui/client";
/**
* Queue for serializing MCP app requests to an agent.
* Ensures requests wait for the agent to stop running and are processed one at a time.
*/
export class MCPAppsRequestQueue {
private queues = new Map<
string,
Array<{
execute: () => Promise<RunAgentResult>;
resolve: (result: RunAgentResult) => void;
reject: (error: Error) => void;
}>
>();
private processing = new Map<string, boolean>();
/**
* Add a request to the queue for a specific agent thread.
* Returns a promise that resolves when the request completes.
*/
async enqueue(
agent: AbstractAgent,
request: () => Promise<RunAgentResult>,
): Promise<RunAgentResult> {
const threadId = agent.threadId || "default";
return new Promise((resolve, reject) => {
// Get or create queue for this thread
let queue = this.queues.get(threadId);
if (!queue) {
queue = [];
this.queues.set(threadId, queue);
}
// Add request to queue
queue.push({ execute: request, resolve, reject });
// Start processing if not already running
this.processQueue(threadId, agent);
});
}
/**
* Drain a thread's queue one request at a time, waiting for the agent to go
* idle before each. Re-entrant-safe (a single processor per thread) and drops
* the thread's map entries once fully drained to keep the shared queue bounded.
*/
private async processQueue(
threadId: string,
agent: AbstractAgent,
): Promise<void> {
// If already processing this queue, return
if (this.processing.get(threadId)) {
return;
}
this.processing.set(threadId, true);
try {
const queue = this.queues.get(threadId);
if (!queue) return;
while (queue.length > 0) {
const item = queue[0]!;
try {
// Wait for any active run to complete before processing
await this.waitForAgentIdle(agent);
// Execute the request
const result = await item.execute();
item.resolve(result);
} catch (error) {
item.reject(
error instanceof Error ? error : new Error(String(error)),
);
}
// Remove processed item
queue.shift();
}
} finally {
// Drop the drained thread entries from both maps. `mcpAppsRequestQueue` is
// shared for the page lifetime, so retaining an entry per thread id would
// grow unbounded as threads come and go.
const queue = this.queues.get(threadId);
if (!queue || queue.length === 0) {
this.queues.delete(threadId);
this.processing.delete(threadId);
} else {
this.processing.set(threadId, false);
}
}
}
/**
* Resolve once the agent is not running. Subscribes to run-finalized/failed and
* also polls as a fallback for reconnect scenarios where events do not fire.
*/
private waitForAgentIdle(agent: AbstractAgent): Promise<void> {
return new Promise((resolve) => {
if (!agent.isRunning) {
resolve();
return;
}
let done = false;
const finish = () => {
if (done) return;
done = true;
clearInterval(checkInterval);
sub.unsubscribe();
resolve();
};
const sub = agent.subscribe({
onRunFinalized: finish,
onRunFailed: finish,
});
// Fallback for reconnect scenarios where events don't fire
const checkInterval = setInterval(() => {
if (!agent.isRunning) finish();
}, 500);
});
}
}
// Shared per-thread queue instance for all MCP app requests.
export const mcpAppsRequestQueue = new MCPAppsRequestQueue();
+51
View File
@@ -0,0 +1,51 @@
/**
* Build the sandbox-proxy HTML document loaded into the outer iframe.
*
* The proxy relays postMessage between the host and the inner sandboxed widget
* and announces `ui/notifications/sandbox-proxy-ready` once ready. `extraCspDomains`
* (from the resource's `_meta.ui.csp`) are appended to the `script-src`/`frame-src`
* CSP directives so a widget can load its own approved origins.
*
* @param extraCspDomains Optional additional origins allowed by the sandbox CSP.
* @returns The complete sandbox-proxy HTML document as a string.
*/
export function buildSandboxHTML(extraCspDomains?: string[]): string {
const baseScriptSrc =
"'self' 'wasm-unsafe-eval' 'unsafe-inline' 'unsafe-eval' blob: data: http://localhost:* https://localhost:*";
const baseFrameSrc = "* blob: data: http://localhost:* https://localhost:*";
const extra = extraCspDomains?.length ? " " + extraCspDomains.join(" ") : "";
const scriptSrc = baseScriptSrc + extra;
const frameSrc = baseFrameSrc + extra;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data: blob: 'unsafe-inline'; media-src * blob: data:; font-src * blob: data:; script-src ${scriptSrc}; style-src * blob: data: 'unsafe-inline'; connect-src *; frame-src ${frameSrc}; base-uri 'self';" />
<style>html,body{margin:0;padding:0;height:100%;width:100%;overflow:hidden}*{box-sizing:border-box}iframe{background-color:transparent;border:none;padding:0;overflow:hidden;width:100%;height:100%}</style>
</head>
<body>
<script>
if(window.self===window.top){throw new Error("This file must be used in an iframe.")}
const inner=document.createElement("iframe");
inner.style="width:100%;height:100%;border:none;";
inner.setAttribute("sandbox","allow-scripts allow-same-origin allow-forms");
document.body.appendChild(inner);
window.addEventListener("message",async(event)=>{
if(event.source===window.parent){
if(event.data&&event.data.method==="ui/notifications/sandbox-resource-ready"){
const{html,sandbox}=event.data.params;
if(typeof sandbox==="string")inner.setAttribute("sandbox",sandbox);
if(typeof html==="string")inner.srcdoc=html;
}else if(inner&&inner.contentWindow){
inner.contentWindow.postMessage(event.data,"*");
}
}else if(event.source===inner.contentWindow){
window.parent.postMessage(event.data,"*");
}
});
window.parent.postMessage({jsonrpc:"2.0",method:"ui/notifications/sandbox-proxy-ready",params:{}},"*");
</script>
</body>
</html>`;
}
+382
View File
@@ -0,0 +1,382 @@
import {
AppBridge,
LATEST_PROTOCOL_VERSION,
PostMessageTransport,
} from "@modelcontextprotocol/ext-apps/app-bridge";
import type { AbstractAgent } from "@ag-ui/client";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
import { randomUUID } from "@copilotkit/shared";
import { buildSandboxHTML } from "./sandbox";
import { mcpAppsRequestQueue } from "./request-queue";
import { ɵrunMcpFollowUp } from "./follow-up";
import type { ɵMcpFollowUpHost } from "./follow-up";
import { MCP_OPEN_LINK_BLOCKED_SCHEMES } from "./constants";
import type { MCPAppsActivityContent } from "./content-schema";
/**
* The MCP Apps protocol version this host negotiates. Sourced directly from the
* ext-apps bridge (single source of truth, no hand-maintained literal). It lives
* here (a bridge-side module) rather than in the bridge-free `./constants` /
* `./activity` entry so the lightweight activity-registration surface stays free
* of the ext-apps bundle; consumers that need the version import it from the
* package root, which already loads the bridge.
*/
export const MCP_APPS_PROTOCOL_VERSION = LATEST_PROTOCOL_VERSION;
/**
* Permissive `ui/message` schema. ext-apps restricts the request to
* `role: "user"` with no `followUp`, but CopilotKit intentionally extends
* `ui/message` with `role` ("user" | "assistant") and `followUp` (documented
* behavior with dedicated tests). We register our own handler (instead of the
* bridge's strict `onmessage`) so those extensions survive.
*
* Going forward, widgets SHOULD pass the extensions under
* `params._meta.copilotkit`; the top-level `role`/`followUp` fields are the
* legacy channel, kept for backward compatibility and slated for deprecation.
*/
const CopilotKitUiMessageSchema = z.object({
method: z.literal("ui/message"),
params: z
.object({
role: z.string().optional(),
content: z.array(z.any()).optional(),
followUp: z.boolean().optional(),
_meta: z.record(z.string(), z.any()).optional(),
})
.passthrough(),
});
/** A resource fetched from the MCP server via the agent proxy. */
export interface FetchedResource {
uri: string;
mimeType?: string;
text?: string;
blob?: string;
_meta?: {
ui?: {
prefersBorder?: boolean;
csp?: {
connectDomains?: string[];
resourceDomains?: string[];
};
};
};
}
/** Reactive callbacks the framework adapter wires to its own state. */
export interface McpAppSessionHooks {
/** The widget reported a new content size (ui/notifications/size-changed). */
onSizeChanged?(size: { width?: number; height?: number }): void;
/** The widget finished initializing (safe to push tool input/result). */
onInitialized?(): void;
/** The fetched resource metadata (e.g. prefersBorder) is available. */
onResource?(resource: FetchedResource): void;
/** Setup failed (resource fetch, connect, ...). */
onError?(err: Error): void;
}
export interface BindMcpAppOptions {
/**
* The sandbox iframe. The adapter creates and OWNS this element (mounts it in
* its render model, sizes it, removes it on unmount). The session only
* configures the sandbox contract (sandbox attr, testid, srcdoc) and talks to
* it through the bridge - it never creates, moves, or removes the iframe.
*/
iframe: HTMLIFrameElement;
/** Returns the current activity content (resourceUri, serverHash, tool input/result). */
getContent: () => MCPAppsActivityContent;
/** Returns the current agent (may change across renders). */
getAgent: () => AbstractAgent | undefined;
/** CopilotKit host, for ui/message follow-up runs (issue #5819). */
host: ɵMcpFollowUpHost;
hooks?: McpAppSessionHooks;
}
export interface McpAppSession {
/** Forward the tool call input to the widget (host -> app). Buffered until ready. */
sendToolInput(args: Record<string, unknown>): void;
/** Forward the tool result to the widget (host -> app). Buffered until ready. */
sendToolResult(result: CallToolResult): void;
/** Disconnect the bridge and release listeners. Does NOT remove the iframe. */
teardown(): void;
}
/**
* Bind an MCP App to a host-provided sandbox iframe: fetch the widget resource
* through the agent, connect the ext-apps `AppBridge` over a PostMessage
* transport, and wire the app<->host protocol (ui/message, ui/open-link,
* tools/call + resources/read proxy, size, host context). Framework-agnostic:
* the React/Vue/Angular renderers create the iframe and wire reactive state via
* `hooks`, but all protocol logic lives here.
*/
export function bindMcpApp(opts: BindMcpAppOptions): McpAppSession {
const { iframe, getContent, getAgent, host, hooks } = opts;
let disposed = false;
let ready = false;
let bridge: AppBridge | null = null;
let pendingToolInput: Record<string, unknown> | undefined;
let pendingToolResult: CallToolResult | undefined;
/** Flush any buffered tool input/result to the widget once it is initialized. */
const flushPending = () => {
if (!ready || !bridge) return;
if (pendingToolInput !== undefined) {
void bridge.sendToolInput({ arguments: pendingToolInput });
pendingToolInput = undefined;
}
if (pendingToolResult !== undefined) {
void bridge.sendToolResult(pendingToolResult);
pendingToolResult = undefined;
}
};
/** Fetch the widget resource (`resources/read`) through the agent proxy queue. */
const fetchResource = async (): Promise<FetchedResource> => {
const agent = getAgent();
if (!agent) {
throw new Error("No agent available to fetch resource");
}
const { resourceUri, serverHash, serverId } = getContent();
const runResult = await mcpAppsRequestQueue.enqueue(agent, () =>
agent.runAgent({
forwardedProps: {
__proxiedMCPRequest: {
serverHash,
serverId,
method: "resources/read",
params: { uri: resourceUri },
},
},
}),
);
const resultData = runResult.result as
| { contents?: FetchedResource[] }
| undefined;
const resource = resultData?.contents?.[0];
if (!resource) {
throw new Error("No resource content in response");
}
return resource;
};
/**
* Fetch the resource, configure + load the sandbox iframe, construct the
* AppBridge, wire the app->host handlers, and connect the transport.
*/
const setup = async () => {
try {
const resource = await fetchResource();
if (disposed) return;
hooks?.onResource?.(resource);
// Configure the sandbox iframe (contract shared across frontends).
iframe.setAttribute(
"sandbox",
"allow-scripts allow-same-origin allow-forms",
);
// Cross-frontend MCP-apps surface contract: every frontend must expose the
// sandbox iframe under the SAME testid so one shared probe (harness
// `d5-mcp-apps`) and one shared e2e spec can assert the surface mounted
// without per-frontend selectors.
iframe.setAttribute("data-testid", "mcp-app-iframe");
iframe.setAttribute("title", "Interactive MCP application");
const cspDomains = resource._meta?.ui?.csp?.resourceDomains;
iframe.srcdoc = buildSandboxHTML(cspDomains);
const win = iframe.contentWindow;
if (!win) {
throw new Error("Sandbox iframe has no contentWindow");
}
let html: string;
if (resource.text) {
html = resource.text;
} else if (resource.blob) {
html = atob(resource.blob);
} else {
throw new Error("Resource has no text or blob content");
}
bridge = new AppBridge(
null,
{ name: "CopilotKit MCP Apps Host", version: "1.0.0" },
{ openLinks: {}, logging: {}, message: { text: {} } },
// Seed the host context at construction so it is already in place when
// the widget's ui/initialize is handled (deterministic, not a race).
{ hostContext: { theme: "light", platform: "web" } },
);
// Sandbox handshake: on proxy ready, load the widget HTML into the inner
// sandboxed iframe.
bridge.onsandboxready = () => {
void bridge?.sendSandboxResourceReady({ html });
};
// --- App -> host requests ---
// ui/message: custom handler preserving CopilotKit role/followUp extensions
// (via _meta.copilotkit first, then legacy top-level fields).
bridge.setRequestHandler(CopilotKitUiMessageSchema, async (req) => {
const currentAgent = getAgent();
if (!currentAgent) {
console.warn("[MCPAppsRenderer] ui/message: No agent available");
return { isError: false };
}
try {
const params = req.params;
const ck = (params._meta?.copilotkit ?? {}) as {
role?: string;
followUp?: boolean;
};
const role =
(ck.role as "user" | "assistant") ||
(params.role as "user" | "assistant") ||
"user";
const textContent =
(
params.content as
| Array<{ type: string; text?: string }>
| undefined
)
?.filter((c) => c.type === "text" && c.text)
.map((c) => c.text)
.join("\n") || "";
if (textContent) {
currentAgent.addMessage({
id: randomUUID(),
role,
content: textContent,
});
}
const followUp = ck.followUp ?? params.followUp;
const shouldFollowUp = followUp ?? role === "user";
if (shouldFollowUp && textContent) {
const capturedThreadId = currentAgent.threadId || "default";
mcpAppsRequestQueue
.enqueue(currentAgent, () =>
ɵrunMcpFollowUp({
host,
agent: currentAgent,
capturedThreadId,
}),
)
.catch((err) =>
console.error(
"[MCPAppsRenderer] ui/message agent run failed:",
err,
),
);
}
return { isError: false };
} catch (err) {
console.error("[MCPAppsRenderer] ui/message error:", err);
return { isError: true };
}
});
bridge.onopenlink = async ({ url }) => {
// The bridge validates `url` as a string but not the scheme. Block only
// the script-executing / attacker-HTML schemes; everything else
// (https universal links, custom-scheme deep links) is allowed.
let parsed: URL;
try {
parsed = new URL(url);
} catch {
console.warn(
"[MCPAppsRenderer] ui/open-link rejected: unparseable url",
);
return { isError: true };
}
if (MCP_OPEN_LINK_BLOCKED_SCHEMES.has(parsed.protocol)) {
console.warn(
"[MCPAppsRenderer] ui/open-link rejected: blocked scheme",
parsed.protocol,
);
return { isError: true };
}
window.open(url, "_blank", "noopener,noreferrer");
return { isError: false };
};
bridge.oncalltool = async (params) => {
const { serverHash, serverId } = getContent();
const currentAgent = getAgent();
if (!serverHash) {
throw new Error("No server hash available for proxying");
}
if (!currentAgent) {
throw new Error("No agent available for proxying");
}
const runResult = await mcpAppsRequestQueue.enqueue(currentAgent, () =>
currentAgent.runAgent({
forwardedProps: {
__proxiedMCPRequest: {
serverHash,
serverId,
method: "tools/call",
params,
},
},
}),
);
return (runResult.result as CallToolResult) || { content: [] };
};
// --- App -> host notifications ---
bridge.onsizechange = (p) => {
if (disposed) return;
const { width, height } = (p || {}) as {
width?: number;
height?: number;
};
hooks?.onSizeChanged?.({
width: typeof width === "number" ? width : undefined,
height: typeof height === "number" ? height : undefined,
});
};
bridge.oninitialized = () => {
if (disposed) return;
ready = true;
hooks?.onInitialized?.();
flushPending();
};
bridge.onloggingmessage = (p) => {
console.log("[MCPAppsRenderer] App log:", p);
};
const transport = new PostMessageTransport(win, win);
await bridge.connect(transport);
if (disposed) {
await bridge.close();
bridge = null;
return;
}
} catch (err) {
console.error("[MCPAppsRenderer] Setup error:", err);
if (!disposed) {
hooks?.onError?.(err instanceof Error ? err : new Error(String(err)));
}
}
};
void setup();
return {
sendToolInput(args) {
pendingToolInput = args;
flushPending();
},
sendToolResult(result) {
pendingToolResult = result;
flushPending();
},
teardown() {
disposed = true;
const b = bridge;
bridge = null;
void b?.close();
},
};
}
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"composite": false,
"declaration": true,
"declarationMap": true,
"incremental": true,
"forceConsistentCasingInFileNames": true,
"inlineSources": false,
"preserveWatchOutput": true,
"sourceMap": true,
"target": "es2022",
"module": "esnext",
"lib": ["es2023", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"useDefineForClassFields": false,
"rootDir": "src",
"outDir": "dist",
"tsBuildInfoFile": "dist/.tsbuildinfo",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["vitest/globals"]
},
"include": ["src/**/*.ts"]
}
@@ -0,0 +1,69 @@
import { defineConfig } from "tsdown";
const externalPkgs = [
"@modelcontextprotocol/ext-apps",
"@modelcontextprotocol/sdk",
"@ag-ui/client",
"@copilotkit/shared",
"zod",
];
const isExternal = (id: string) =>
externalPkgs.some((pkg) => id === pkg || id.startsWith(pkg + "/"));
export default defineConfig([
// Root entry: ESM only. It re-exports the session, which imports the ext-apps
// AppBridge - and ext-apps 1.7.5 is ESM-only. A CJS root would emit a
// `require()` of that ESM module and fail with ERR_REQUIRE_ESM, so we do not
// advertise a CJS root. Consumers load the bridge via a dynamic `import()`
// (react-core does), which resolves ESM from any context.
{
entry: ["src/index.ts"],
format: ["esm"],
dts: true,
sourcemap: true,
target: "es2022",
outDir: "dist",
unbundle: true,
external: isExternal,
exports: false,
},
// Activity entry: the bridge-free registration surface (activity type, content
// schema, follow-up runner). It has no ext-apps edge, so it is safe as dual
// ESM + CJS - frontends register the activity synchronously from either module
// system without pulling the bridge.
{
entry: ["src/activity.ts"],
format: ["esm", "cjs"],
dts: true,
sourcemap: true,
target: "es2022",
outDir: "dist",
unbundle: true,
external: isExternal,
exports: false,
},
// Activity UMD: a self-contained global for script-tag consumers of
// @copilotkit/react-core's UMD build, which externalizes this entry and maps
// it to `CopilotKitMcpAppsRendererActivity`. Bridge-free, so only zod is
// external (mapped to the shared `Zod` global, matching react-core's UMD).
{
entry: ["src/activity.ts"],
format: ["umd"],
globalName: "CopilotKitMcpAppsRendererActivity",
sourcemap: true,
// es2018 for the script-tag UMD (broad browser reach), matching a2ui-renderer.
target: "es2018",
outDir: "dist",
external: (id: string) => id === "zod" || id.startsWith("zod/"),
// Force a single self-contained bundle (matches a2ui-renderer): without this,
// a sibling chunk emitted next to activity.umd.js would leave the global
// incomplete, and es-check (syntax-only) would not catch it.
codeSplitting: false,
outputOptions(options) {
options.entryFileNames = "[name].umd.js";
options.globals = { zod: "Zod" };
return options;
},
},
]);
@@ -0,0 +1,15 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
include: ["**/__tests__/**/*.{test,spec}.ts"],
passWithNoTests: true,
globals: true,
server: {
deps: {
inline: ["@modelcontextprotocol/ext-apps", "zod"],
},
},
},
});
+1 -3
View File
@@ -78,12 +78,12 @@
"@ag-ui/core": "0.0.59",
"@copilotkit/a2ui-renderer": "workspace:*",
"@copilotkit/core": "workspace:*",
"@copilotkit/mcp-apps-renderer": "workspace:*",
"@copilotkit/runtime-client-gql": "workspace:*",
"@copilotkit/shared": "workspace:*",
"@copilotkit/web-components": "workspace:*",
"@copilotkit/web-inspector": "workspace:*",
"@jetbrains/websandbox": "^1.1.3",
"@modelcontextprotocol/ext-apps": "^1.7.5",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
@@ -108,7 +108,6 @@
"esbuild": "^0.27.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@modelcontextprotocol/sdk": "^1.29.0",
"@tanstack/intent": "^0.0.29",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^16.3.0",
@@ -135,7 +134,6 @@
"zod": ">=3.0.0"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc",
"zod": ">=3.25"
@@ -1,282 +1,38 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { z } from "zod";
import type { AbstractAgent, RunAgentResult } from "@ag-ui/client";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
// Type-only import: the ext-apps bridge is a heavy dependency (it pulls the MCP
// SDK Protocol + zod schemas, ~40-50 kB gzipped). It is loaded lazily via a
// dynamic import() inside Effect 1 so that a `<CopilotKit>` app only pays for it
// when it actually renders an MCP App, not on every mount.
import type { AppBridge } from "@modelcontextprotocol/ext-apps/app-bridge";
import type { AbstractAgent } from "@ag-ui/client";
import { useCopilotKit } from "../providers/CopilotKitProvider";
/**
* The subset of `CopilotKitCore` that {@link ɵrunMcpFollowUp} depends on.
* Declared structurally so the runner can be unit-tested without a full core.
*/
export interface ɵMcpFollowUpHost {
runAgent(params: { agent: AbstractAgent }): Promise<RunAgentResult>;
}
/**
* Run an MCP app `ui/message` follow-up, scoped to the thread it was enqueued
* for (issue #5819).
*
* The MCP request queue delays follow-up work until the agent is idle. There is
* a single shared registry agent per id, and switching threads overwrites its
* `threadId`/`messages` in place. So if the host switches threads while a
* follow-up is queued, running it now would execute against and stream into
* the now-foreground thread.
*
* - **Same thread** (the common case): run on the shared agent, unchanged.
* - **Thread changed**: the shared agent has moved on, so the follow-up can no
* longer run in its originating thread's context. Drop it rather than leak it
* into the current thread. (The MCP app already received its `ui/message` ack
* at enqueue time; only the optional agent turn is skipped.)
*
* @internal exported for testing.
*/
export async function ɵrunMcpFollowUp({
host,
agent,
capturedThreadId,
}: {
host: ɵMcpFollowUpHost;
agent: AbstractAgent;
capturedThreadId: string;
}): Promise<RunAgentResult> {
const currentThreadId = agent.threadId || "default";
const originThreadId = capturedThreadId || "default";
if (currentThreadId === originThreadId) {
return host.runAgent({ agent });
}
console.warn(
"[MCPAppsRenderer] ui/message follow-up dropped: the thread changed " +
`(${originThreadId}${currentThreadId}) between enqueue and execution, ` +
"so running it would leak into the now-foreground thread.",
);
return { result: undefined, newMessages: [] };
}
// Build sandbox proxy HTML with optional extra CSP domains from resource metadata
function buildSandboxHTML(extraCspDomains?: string[]): string {
const baseScriptSrc =
"'self' 'wasm-unsafe-eval' 'unsafe-inline' 'unsafe-eval' blob: data: http://localhost:* https://localhost:*";
const baseFrameSrc = "* blob: data: http://localhost:* https://localhost:*";
const extra = extraCspDomains?.length ? " " + extraCspDomains.join(" ") : "";
const scriptSrc = baseScriptSrc + extra;
const frameSrc = baseFrameSrc + extra;
return `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data: blob: 'unsafe-inline'; media-src * blob: data:; font-src * blob: data:; script-src ${scriptSrc}; style-src * blob: data: 'unsafe-inline'; connect-src *; frame-src ${frameSrc}; base-uri 'self';" />
<style>html,body{margin:0;padding:0;height:100%;width:100%;overflow:hidden}*{box-sizing:border-box}iframe{background-color:transparent;border:none;padding:0;overflow:hidden;width:100%;height:100%}</style>
</head>
<body>
<script>
if(window.self===window.top){throw new Error("This file must be used in an iframe.")}
const inner=document.createElement("iframe");
inner.style="width:100%;height:100%;border:none;";
inner.setAttribute("sandbox","allow-scripts allow-same-origin allow-forms");
document.body.appendChild(inner);
window.addEventListener("message",async(event)=>{
if(event.source===window.parent){
if(event.data&&event.data.method==="ui/notifications/sandbox-resource-ready"){
const{html,sandbox}=event.data.params;
if(typeof sandbox==="string")inner.setAttribute("sandbox",sandbox);
if(typeof html==="string")inner.srcdoc=html;
}else if(inner&&inner.contentWindow){
inner.contentWindow.postMessage(event.data,"*");
}
}else if(event.source===inner.contentWindow){
window.parent.postMessage(event.data,"*");
}
});
window.parent.postMessage({jsonrpc:"2.0",method:"ui/notifications/sandbox-proxy-ready",params:{}},"*");
</script>
</body>
</html>`;
}
/**
* Queue for serializing MCP app requests to an agent.
* Ensures requests wait for the agent to stop running and are processed one at a time.
*/
class MCPAppsRequestQueue {
private queues = new Map<
string,
Array<{
execute: () => Promise<RunAgentResult>;
resolve: (result: RunAgentResult) => void;
reject: (error: Error) => void;
}>
>();
private processing = new Map<string, boolean>();
/**
* Add a request to the queue for a specific agent thread.
* Returns a promise that resolves when the request completes.
*/
async enqueue(
agent: AbstractAgent,
request: () => Promise<RunAgentResult>,
): Promise<RunAgentResult> {
const threadId = agent.threadId || "default";
return new Promise((resolve, reject) => {
// Get or create queue for this thread
let queue = this.queues.get(threadId);
if (!queue) {
queue = [];
this.queues.set(threadId, queue);
}
// Add request to queue
queue.push({ execute: request, resolve, reject });
// Start processing if not already running
this.processQueue(threadId, agent);
});
}
private async processQueue(
threadId: string,
agent: AbstractAgent,
): Promise<void> {
// If already processing this queue, return
if (this.processing.get(threadId)) {
return;
}
this.processing.set(threadId, true);
try {
const queue = this.queues.get(threadId);
if (!queue) return;
while (queue.length > 0) {
const item = queue[0]!;
try {
// Wait for any active run to complete before processing
await this.waitForAgentIdle(agent);
// Execute the request
const result = await item.execute();
item.resolve(result);
} catch (error) {
item.reject(
error instanceof Error ? error : new Error(String(error)),
);
}
// Remove processed item
queue.shift();
}
} finally {
this.processing.set(threadId, false);
}
}
private waitForAgentIdle(agent: AbstractAgent): Promise<void> {
return new Promise((resolve) => {
if (!agent.isRunning) {
resolve();
return;
}
let done = false;
const finish = () => {
if (done) return;
done = true;
clearInterval(checkInterval);
sub.unsubscribe();
resolve();
};
const sub = agent.subscribe({
onRunFinalized: finish,
onRunFailed: finish,
});
// Fallback for reconnect scenarios where events don't fire
const checkInterval = setInterval(() => {
if (!agent.isRunning) finish();
}, 500);
});
}
}
// Global queue instance for all MCP app requests
const mcpAppsRequestQueue = new MCPAppsRequestQueue();
// URL schemes a widget may NOT open via ui/open-link. The ext-apps schema
// validates `url` as a plain string only (noopener/noreferrer does not restrict
// the scheme), so ui/open-link could otherwise become an XSS vector.
// The app<->host protocol (ext-apps AppBridge, sandbox proxy, request queue,
// ui/message + open-link handlers, tool input/result) lives in the shared,
// framework-agnostic package. This file is now a THIN React adapter over it: it
// owns the iframe (create/mount/size/remove) and wires the session's reactive
// hooks to React state; all protocol logic is `bindMcpApp`.
//
// We use a denylist rather than an allowlist on purpose: deep links use
// arbitrary, app-defined schemes (`myapp:`, `whatsapp:`, `slack:`, `spotify:`,
// `sms:`, ...) that an allowlist could never enumerate, and `window.open`ing them
// just hands off to an OS handler — it does not execute script in the page, so
// it is not an XSS risk. Universal links / App Links are plain `https:` URLs and
// pass regardless. What IS dangerous is the small, well-known set of schemes
// that execute script or render attacker HTML in the page context; block those
// and allow everything else (including deep links).
const MCP_OPEN_LINK_BLOCKED_SCHEMES = new Set([
"javascript:",
"data:",
"vbscript:",
"blob:",
"file:",
]);
// The lightweight activity surface (type + content schema + follow-up runner)
// is re-exported from the package's bridge-free `/activity` entry, so importing
// it (for the activity registry) does NOT pull the ext-apps bundle. The bridge
// itself is loaded lazily via a dynamic `import("@copilotkit/mcp-apps-renderer")`
// inside the effect, so a `<CopilotKit>` app only pays for it when it actually
// renders an MCP App.
export {
MCPAppsActivityType,
MCPAppsActivityContentSchema,
ɵrunMcpFollowUp,
} from "@copilotkit/mcp-apps-renderer/activity";
export type {
MCPAppsActivityContent,
ɵMcpFollowUpHost,
} from "@copilotkit/mcp-apps-renderer/activity";
/**
* Activity type for MCP Apps events - must match the middleware's MCPAppsActivityType
*/
export const MCPAppsActivityType = "mcp-apps";
// Zod schema for activity content validation (middleware 0.0.2 format)
export const MCPAppsActivityContentSchema = z.object({
result: z.object({
content: z.array(z.any()).optional(),
structuredContent: z.any().optional(),
isError: z.boolean().optional(),
}),
// Resource URI to fetch (e.g., "ui://server/dashboard")
resourceUri: z.string(),
// MD5 hash of server config (renamed from serverId in 0.0.1)
serverHash: z.string(),
// Optional stable server ID from config (takes precedence over serverHash)
serverId: z.string().optional(),
// Original tool input arguments
toolInput: z.record(z.string(), z.unknown()).optional(),
});
export type MCPAppsActivityContent = z.infer<
typeof MCPAppsActivityContentSchema
>;
// Type for the resource fetched from the server
interface FetchedResource {
uri: string;
mimeType?: string;
text?: string;
blob?: string;
_meta?: {
ui?: {
prefersBorder?: boolean;
csp?: {
connectDomains?: string[];
resourceDomains?: string[];
};
};
};
}
import type { MCPAppsActivityContent } from "@copilotkit/mcp-apps-renderer/activity";
// Type-only imports: erased at build, so they never pull the ext-apps bridge
// into the bundle. Only the dynamic import() below does, and only lazily.
import type {
McpAppSession,
FetchedResource,
} from "@copilotkit/mcp-apps-renderer";
/**
* Props for the activity renderer component
@@ -292,37 +48,15 @@ interface MCPAppsActivityRendererProps {
* MCP Apps Extension Activity Renderer
*
* Renders MCP Apps UI in a sandboxed iframe with full protocol support.
* Fetches resource content on-demand via proxied MCP requests.
* Fetches resource content on-demand via proxied MCP requests. The React shell
* owns the iframe; `bindMcpApp` owns the protocol.
*/
/**
* Permissive `ui/message` schema. ext-apps restricts the request to
* `role: "user"` with no `followUp`, but CopilotKit intentionally extends
* `ui/message` with `role` ("user" | "assistant") and `followUp` (documented
* behavior with dedicated tests). We register our own handler (instead of the
* bridge's strict `onmessage`) so those extensions survive the migration.
*
* Going forward, widgets SHOULD pass the extensions under
* `params._meta.copilotkit`; the top-level `role`/`followUp` fields are the
* legacy channel, kept for backward compatibility and slated for deprecation.
*/
const CopilotKitUiMessageSchema = z.object({
method: z.literal("ui/message"),
params: z
.object({
role: z.string().optional(),
content: z.array(z.any()).optional(),
followUp: z.boolean().optional(),
_meta: z.record(z.string(), z.any()).optional(),
})
.passthrough(),
});
export const MCPAppsActivityRenderer: React.FC<MCPAppsActivityRendererProps> =
function MCPAppsActivityRenderer({ content, agent }) {
const { copilotkit } = useCopilotKit();
const containerRef = useRef<HTMLDivElement>(null);
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [iframeReady, setIframeReady] = useState(false);
const sessionRef = useRef<McpAppSession | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [iframeSize, setIframeSize] = useState<{
@@ -332,392 +66,127 @@ export const MCPAppsActivityRenderer: React.FC<MCPAppsActivityRendererProps> =
const [fetchedResource, setFetchedResource] =
useState<FetchedResource | null>(null);
// Use refs for values that shouldn't trigger re-renders but need latest values
// Latest content/agent for the session's live getters (they must read the
// current values on every proxied request, not the values at bind time).
const contentRef = useRef(content);
contentRef.current = content;
// Store agent in a ref for use in async handlers
const agentRef = useRef(agent);
agentRef.current = agent;
// ext-apps host bridge for this widget instance (owns the app<->host protocol).
const bridgeRef = useRef<AppBridge | null>(null);
// Ref to track fetch state - survives StrictMode remounts
const fetchStateRef = useRef<{
inProgress: boolean;
promise: Promise<FetchedResource | null> | null;
resourceUri: string | null;
}>({ inProgress: false, promise: null, resourceUri: null });
// Effect 0: Fetch the resource content on mount
// Uses ref-based deduplication to handle React StrictMode double-mounting
// Effect 1: create the sandbox iframe and bind the MCP session. Re-binds
// only when the widget identity (resourceUri/serverHash/serverId) or the
// agent/host changes - NOT when tool input/result stream in (those are
// pushed by the effects below without recreating the iframe).
useEffect(() => {
const { resourceUri, serverHash, serverId } = content;
// Check if we already have a fetch in progress for this resource
// This handles StrictMode double-mounting - second mount reuses first mount's promise
if (
fetchStateRef.current.inProgress &&
fetchStateRef.current.resourceUri === resourceUri
) {
// Reuse the existing promise
fetchStateRef.current.promise
?.then((resource) => {
if (resource) {
setFetchedResource(resource);
setIsLoading(false);
}
})
.catch((err) => {
setError(err instanceof Error ? err : new Error(String(err)));
setIsLoading(false);
});
const container = containerRef.current;
if (!container) {
return;
}
if (!agent) {
setError(new Error("No agent available to fetch resource"));
setIsLoading(false);
return;
}
// Mark fetch as in progress
fetchStateRef.current.inProgress = true;
fetchStateRef.current.resourceUri = resourceUri;
// Create the fetch promise using the queue to serialize requests
const fetchPromise = (async (): Promise<FetchedResource | null> => {
try {
// Use queue to wait for agent to be idle and serialize requests
const runResult = await mcpAppsRequestQueue.enqueue(agent, () =>
agent.runAgent({
forwardedProps: {
__proxiedMCPRequest: {
serverHash,
serverId, // optional, takes precedence if provided
method: "resources/read",
params: { uri: resourceUri },
},
},
}),
);
// Extract resource from result
// The response format is: { contents: [{ uri, mimeType, text?, blob?, _meta? }] }
const resultData = runResult.result as
| { contents?: FetchedResource[] }
| undefined;
const resource = resultData?.contents?.[0];
if (!resource) {
throw new Error("No resource content in response");
}
return resource;
} catch (err) {
console.error("[MCPAppsRenderer] Failed to fetch resource:", err);
throw err;
} finally {
// Mark fetch as complete
fetchStateRef.current.inProgress = false;
}
})();
// Store the promise for potential reuse
fetchStateRef.current.promise = fetchPromise;
// Handle the result
fetchPromise
.then((resource) => {
if (resource) {
setFetchedResource(resource);
setIsLoading(false);
}
})
.catch((err) => {
setError(err instanceof Error ? err : new Error(String(err)));
setIsLoading(false);
});
// No cleanup needed - we want the fetch to complete even if StrictMode unmounts
}, [agent, content]);
// Effect 1: connect the ext-apps AppBridge to the sandboxed iframe.
// The bridge owns the app<->host protocol (initialize/capabilities/context,
// requests, notifications, tool input/result) over a PostMessage transport.
useEffect(() => {
if (isLoading || !fetchedResource) {
return;
}
const container = containerRef.current;
if (!container) {
return;
}
let mounted = true;
let bridge: AppBridge | null = null;
let createdIframe: HTMLIFrameElement | null = null;
setIsLoading(true);
setError(null);
// The host owns the iframe: create + mount it here (bindMcpApp only
// configures the sandbox contract + talks to it through the bridge).
const iframe = document.createElement("iframe");
iframe.style.width = "100%";
iframe.style.height = "100px";
iframe.style.border = "none";
iframe.style.backgroundColor = "transparent";
iframe.style.display = "block";
container.appendChild(iframe);
iframeRef.current = iframe;
const setup = async () => {
try {
// Load the ext-apps bridge FIRST, before creating/attaching the iframe.
// The sandbox proxy posts `sandbox-proxy-ready` once, during srcdoc
// execution, and the PostMessageTransport must already be listening
// (via connect()) when that fires. Awaiting the dynamic import after
// the iframe is attached would let a slow import miss that
// notification, leaving the widget blank; doing it here means there is
// no event-loop yield between attaching the iframe and connecting.
// The type-only import at the top of the file keeps this lazy (see that
// note); the try/catch rethrows with an actionable message if the peer
// is missing or version-skewed.
const bridgeModule =
await import("@modelcontextprotocol/ext-apps/app-bridge").catch(
(importErr) => {
throw new Error(
"MCP Apps require '@modelcontextprotocol/ext-apps' and its peer " +
"'@modelcontextprotocol/sdk'. Install them with: npm install " +
"@modelcontextprotocol/ext-apps @modelcontextprotocol/sdk",
{ cause: importErr },
);
// Load the bridge package lazily. The bridge is heavy (it pulls the
// MCP SDK Protocol + zod schemas, ~40-50 kB gzipped); keeping it behind
// a dynamic import() means a non-MCP `<CopilotKit>` app never pays for
// it. The try/catch rethrows with an actionable message if the package
// (or its ext-apps dependency) is missing.
const mod = await import("@copilotkit/mcp-apps-renderer").catch(
(importErr) => {
throw new Error(
"MCP Apps require '@copilotkit/mcp-apps-renderer' and its " +
"'@modelcontextprotocol/ext-apps' dependency. Reinstall your " +
"dependencies if this package is missing.",
{ cause: importErr },
);
},
);
if (!mounted) {
iframe.remove();
return;
}
const session = mod.bindMcpApp({
iframe,
getContent: () => contentRef.current,
getAgent: () => agentRef.current,
host: copilotkit,
hooks: {
onResource: (resource) => {
if (!mounted) return;
setFetchedResource(resource);
setIsLoading(false);
},
);
if (!mounted) {
return;
}
const { AppBridge, PostMessageTransport } = bridgeModule;
// Create the sandbox proxy iframe (the proxy relays postMessage between
// the host and the inner sandboxed widget).
const iframe = document.createElement("iframe");
createdIframe = iframe;
iframe.style.width = "100%";
iframe.style.height = "100px";
iframe.style.border = "none";
iframe.style.backgroundColor = "transparent";
iframe.style.display = "block";
iframe.setAttribute(
"sandbox",
"allow-scripts allow-same-origin allow-forms",
);
// Cross-frontend MCP-apps surface contract: the host-created sandbox
// iframe is the addressable render surface for the MCP app, and every
// frontend must expose it under the SAME testid so one shared probe
// (harness `d5-mcp-apps`) and one shared e2e spec can assert the
// surface mounted without per-frontend selectors. Angular declares
// the same pair on its `copilot-mcp-apps-widget` template iframe;
// Vue's renderer mirrors this block.
iframe.setAttribute("data-testid", "mcp-app-iframe");
iframe.setAttribute("title", "Interactive MCP application");
const cspDomains = fetchedResource._meta?.ui?.csp?.resourceDomains;
iframe.srcdoc = buildSandboxHTML(cspDomains);
iframeRef.current = iframe;
container.appendChild(iframe);
const win = iframe.contentWindow;
if (!win) {
throw new Error("Sandbox iframe has no contentWindow");
}
// Extract the widget HTML from the fetched resource. Done after the
// iframe is mounted so a resource missing text/blob still leaves the
// sandbox surface present (it just never receives content).
let html: string;
if (fetchedResource.text) {
html = fetchedResource.text;
} else if (fetchedResource.blob) {
html = atob(fetchedResource.blob);
} else {
throw new Error("Resource has no text or blob content");
}
bridge = new AppBridge(
null,
{ name: "CopilotKit MCP Apps Host", version: "1.0.0" },
{ openLinks: {}, logging: {}, message: { text: {} } },
// Seed the host context at construction (before connect) so it is
// already in place when the widget's ui/initialize is handled. Doing
// this via setHostContext after connect would only win the race by
// luck (it depends on the notification landing before initialize),
// and #6689 relies on this seam to advertise displayMode /
// availableDisplayModes at initialize.
{ hostContext: { theme: "light", platform: "web" } },
);
// Sandbox handshake: when the proxy is ready, load the widget HTML into
// the inner sandboxed iframe.
bridge.onsandboxready = () => {
void bridge?.sendSandboxResourceReady({ html });
};
// --- App -> host requests ---
// ui/message uses a custom handler (not the bridge's strict onmessage)
// to preserve CopilotKit's role/followUp extensions. Extensions are read
// from params._meta.copilotkit first (preferred), then from the legacy
// top-level params.role / params.followUp (deprecated).
bridge.setRequestHandler(CopilotKitUiMessageSchema, async (req) => {
const currentAgent = agentRef.current;
if (!currentAgent) {
console.warn("[MCPAppsRenderer] ui/message: No agent available");
return { isError: false };
}
try {
const params = req.params;
const ck = (params._meta?.copilotkit ?? {}) as {
role?: string;
followUp?: boolean;
};
const role =
(ck.role as "user" | "assistant") ||
(params.role as "user" | "assistant") ||
"user";
const textContent =
(
params.content as
| Array<{ type: string; text?: string }>
| undefined
)
?.filter((c) => c.type === "text" && c.text)
.map((c) => c.text)
.join("\n") || "";
if (textContent) {
currentAgent.addMessage({
id: crypto.randomUUID(),
role,
content: textContent,
});
}
const followUp = ck.followUp ?? params.followUp;
const shouldFollowUp = followUp ?? role === "user";
if (shouldFollowUp && textContent) {
const capturedThreadId = currentAgent.threadId || "default";
mcpAppsRequestQueue
.enqueue(currentAgent, () =>
ɵrunMcpFollowUp({
host: copilotkit,
agent: currentAgent,
capturedThreadId,
}),
)
.catch((err) =>
console.error(
"[MCPAppsRenderer] ui/message agent run failed:",
err,
),
);
}
return { isError: false };
} catch (err) {
console.error("[MCPAppsRenderer] ui/message error:", err);
return { isError: true };
}
onSizeChanged: (size) => {
if (mounted) setIframeSize(size);
},
onError: (err) => {
if (!mounted) return;
setError(err);
setIsLoading(false);
},
},
});
sessionRef.current = session;
bridge.onopenlink = async ({ url }) => {
// `url` is guaranteed to be a string by the bridge's ui/open-link
// schema, but the schema does not restrict the scheme. Parse it and
// block only the script-executing / attacker-HTML schemes (see
// MCP_OPEN_LINK_BLOCKED_SCHEMES). Everything else is allowed,
// including custom-scheme deep links (`myapp:`, `whatsapp:`, ...) and
// https universal links, which hand off to an OS handler rather than
// executing in the page.
let parsed: URL;
try {
parsed = new URL(url);
} catch {
console.warn(
"[MCPAppsRenderer] ui/open-link rejected: unparseable url",
);
return { isError: true };
}
if (MCP_OPEN_LINK_BLOCKED_SCHEMES.has(parsed.protocol)) {
console.warn(
"[MCPAppsRenderer] ui/open-link rejected: blocked scheme",
parsed.protocol,
);
return { isError: true };
}
window.open(url, "_blank", "noopener,noreferrer");
return { isError: false };
};
bridge.oncalltool = async (params) => {
const { serverHash, serverId } = contentRef.current;
const currentAgent = agentRef.current;
// Keep these two failures distinct: they point at different setup
// problems when debugging the proxy wiring.
if (!serverHash) {
throw new Error("No server hash available for proxying");
}
if (!currentAgent) {
throw new Error("No agent available for proxying");
}
const runResult = await mcpAppsRequestQueue.enqueue(
currentAgent,
() =>
currentAgent.runAgent({
forwardedProps: {
__proxiedMCPRequest: {
serverHash,
serverId,
method: "tools/call",
params,
},
},
}),
);
return (runResult.result as CallToolResult) || { content: [] };
};
// --- App -> host notifications ---
bridge.onsizechange = (p) => {
if (!mounted) return;
const { width, height } = p || {};
setIframeSize({
width: typeof width === "number" ? width : undefined,
height: typeof height === "number" ? height : undefined,
});
};
bridge.oninitialized = () => {
if (mounted) setIframeReady(true);
};
bridge.onloggingmessage = (p) => {
console.log("[MCPAppsRenderer] App log:", p);
};
const transport = new PostMessageTransport(win, win);
await bridge.connect(transport);
if (!mounted) {
await bridge.close();
return;
// Push any tool input/result already present at bind time (the session
// buffers until the widget reports initialized).
const current = contentRef.current;
if (current.toolInput) {
session.sendToolInput(current.toolInput as Record<string, unknown>);
}
if (current.result) {
session.sendToolResult(
current.result as Parameters<McpAppSession["sendToolResult"]>[0],
);
}
// Host context was seeded at construction (see the AppBridge options
// above), so it is already advertised by the time ui/initialize runs.
bridgeRef.current = bridge;
} catch (err) {
console.error("[MCPAppsRenderer] Setup error:", err);
if (mounted) {
setError(err instanceof Error ? err : new Error(String(err)));
setIsLoading(false);
}
}
};
setup();
void setup();
return () => {
mounted = false;
bridgeRef.current = null;
void bridge?.close();
if (createdIframe) {
createdIframe.remove();
createdIframe = null;
}
sessionRef.current?.teardown();
sessionRef.current = null;
iframe.remove();
iframeRef.current = null;
};
}, [isLoading, fetchedResource, copilotkit]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
agent,
copilotkit,
content.resourceUri,
content.serverHash,
content.serverId,
]);
// Effect 2: Update iframe size when it changes
// Effect 2: size the iframe when the widget reports a new content size.
useEffect(() => {
if (iframeRef.current) {
if (iframeSize.width !== undefined) {
@@ -731,23 +200,24 @@ export const MCPAppsActivityRenderer: React.FC<MCPAppsActivityRendererProps> =
}
}, [iframeSize]);
// Effect 3: Send tool input when iframe ready
// Effect 3: forward tool input to the widget (buffered by the session until
// the widget is ready).
useEffect(() => {
if (iframeReady && content.toolInput) {
void bridgeRef.current?.sendToolInput({
arguments: content.toolInput as Record<string, unknown>,
});
}
}, [iframeReady, content.toolInput]);
// Effect 4: Send tool result when iframe ready
useEffect(() => {
if (iframeReady && content.result) {
void bridgeRef.current?.sendToolResult(
content.result as CallToolResult,
if (content.toolInput) {
sessionRef.current?.sendToolInput(
content.toolInput as Record<string, unknown>,
);
}
}, [iframeReady, content.result]);
}, [content.toolInput]);
// Effect 4: forward tool result to the widget.
useEffect(() => {
if (content.result) {
sessionRef.current?.sendToolResult(
content.result as Parameters<McpAppSession["sendToolResult"]>[0],
);
}
}, [content.result]);
// Determine border styling based on prefersBorder metadata from fetched resource
// true = show border/background, false = none, undefined = host decides (we default to none)
+13
View File
@@ -166,6 +166,13 @@ export default defineConfig([
"rxjs",
],
},
// UMD builds. Each externalized @copilotkit/* dep must be loaded as a global
// before this bundle. Note `@copilotkit/mcp-apps-renderer/activity` -> the
// global `CopilotKitMcpAppsRendererActivity`: the provider registers the MCP
// Apps activity from that (bridge-free) entry, so script-tag consumers must
// load `@copilotkit/mcp-apps-renderer/dist/activity.umd.js` before this bundle
// (see that package's README). The ext-apps bridge itself is NOT here; it is
// loaded lazily via dynamic import() only when an MCP App renders.
{
entry: {
index: "src/v1-deprecated-compatibility.ts",
@@ -183,6 +190,7 @@ export default defineConfig([
"@copilotkit/runtime-client-gql",
"@copilotkit/web-inspector",
"@copilotkit/a2ui-renderer",
"@copilotkit/mcp-apps-renderer/activity",
"@ag-ui/client",
"zod",
/\.css$/,
@@ -198,6 +206,8 @@ export default defineConfig([
"@copilotkit/runtime-client-gql": "CopilotKitRuntimeClientGQL",
"@copilotkit/web-inspector": "CopilotKitWebInspector",
"@copilotkit/a2ui-renderer": "CopilotKitA2UIRenderer",
"@copilotkit/mcp-apps-renderer/activity":
"CopilotKitMcpAppsRendererActivity",
"@ag-ui/client": "AgUIClient",
"react-markdown": "ReactMarkdown",
zod: "Zod",
@@ -220,6 +230,7 @@ export default defineConfig([
"@copilotkit/runtime-client-gql",
"@copilotkit/web-inspector",
"@copilotkit/a2ui-renderer",
"@copilotkit/mcp-apps-renderer/activity",
"@ag-ui/client",
"@ag-ui/core",
"zod",
@@ -236,6 +247,8 @@ export default defineConfig([
"@copilotkit/runtime-client-gql": "CopilotKitRuntimeClientGQL",
"@copilotkit/web-inspector": "CopilotKitWebInspector",
"@copilotkit/a2ui-renderer": "CopilotKitA2UIRenderer",
"@copilotkit/mcp-apps-renderer/activity":
"CopilotKitMcpAppsRendererActivity",
"@ag-ui/client": "AgUIClient",
"@ag-ui/core": "AgUICore",
"react-markdown": "ReactMarkdown",
+22
View File
@@ -142,3 +142,25 @@ Or use the `DO_NOT_TRACK` standard:
```bash
export DO_NOT_TRACK=1
```
## Stopping Intelligence runs
Await Stop before sending another message on the same thread. With
`IntelligenceAgentRunner`, `stopped: true` means the gateway acknowledged the
run's terminal events and the runtime completed local cleanup. The gateway
releases only the lock owned by that run.
Stop requests agent cancellation and excludes late agent events from thread
history. Agents that support `detachActiveRun()` also detach their local
subscription. Older agents remain supported. An adapter must honor cancellation
to stop external work; Stop cannot undo tool calls that already took effect.
The HTTP request and response formats are unchanged. Empty-body Stop requests
still stop the current run. Direct runner callers can pass the existing optional
`runId` to stop only that run. A missing, mismatched, or already-requested Stop
returns `false`. Failed terminal delivery rejects Stop; the HTTP handler returns
its existing error response instead of reporting success. The wait is bounded by
the existing 60-second durability window.
No Intelligence upgrade is required. The runtime uses the existing terminal
events and supports both single-event and batched gateway acknowledgments.
+4 -1
View File
@@ -115,7 +115,7 @@
"openai": "^4.85.1 || >=5.0.0",
"partial-json": "^0.1.7",
"phoenix": "^1.8.4",
"pino": "^9.2.0",
"pino": "^10.1.1",
"pino-pretty": "^11.2.1",
"reflect-metadata": "^0.2.2",
"rxjs": "7.8.1",
@@ -196,5 +196,8 @@
]
}
}
},
"engines": {
"node": ">=20"
}
}
@@ -38,10 +38,9 @@ describe("createLogger", () => {
// first path in the array ("invalid path (pid)"). Any redact path fails, not
// just `pid`, so the only safe configuration is to pass none.
//
// This monorepo pins pino to 10 through a pnpm override, and pino 10 swapped
// fast-redact for @pinojs/redact, which uses no code generation. That means
// the failure cannot be reproduced by running a logger here; the assertion
// has to be on the options we hand pino.
// The runtime now declares pino 10, which uses @pinojs/redact without code
// generation. Keep checking the options directly so this regression guard
// does not depend on the installed redaction implementation.
it("passes no redact option to pino, because redact paths need code generation", () => {
createLogger();
@@ -2061,7 +2061,9 @@ describe("IntelligenceAgentRunner", () => {
false,
);
expect(agent.aborted).toBe(false);
expect(await runner.stop({ threadId, runId: "r-current" })).toBe(true);
const stopping = runner.stop({ threadId, runId: "r-current" });
mockChannels[0].triggerJoin("ok");
expect(await stopping).toBe(true);
expect(agent.aborted).toBe(true);
sub.unsubscribe();
});
@@ -2072,7 +2074,9 @@ describe("IntelligenceAgentRunner", () => {
const agent = new MockAgent();
const sub = runner.run({ threadId, agent, input }).subscribe();
const result = await runner.stop({ threadId });
const stopping = runner.stop({ threadId });
mockChannels[0].triggerJoin("ok");
const result = await stopping;
expect(result).toBe(true);
expect(agent.aborted).toBe(true);
@@ -2093,8 +2097,10 @@ describe("IntelligenceAgentRunner", () => {
const agent = new MockAgent();
const sub = runner.run({ threadId, agent, input }).subscribe();
expect(await runner.stop({ threadId })).toBe(true);
const stopping = runner.stop({ threadId });
expect(await runner.stop({ threadId })).toBe(false);
mockChannels[0].triggerJoin("ok");
expect(await stopping).toBe(true);
sub.unsubscribe();
});
});
@@ -0,0 +1,478 @@
import { AbstractAgent, EventType } from "@ag-ui/client";
import type { BaseEvent, RunAgentInput } from "@ag-ui/client";
import { Observable, Subject } from "rxjs";
import { WebSocket, WebSocketServer } from "ws";
import { expect, test, vi } from "vitest";
import { IntelligenceAgentRunner } from "../intelligence";
import { CopilotRuntime } from "../../core/runtime";
import { createCopilotRuntimeHandler } from "../../core/fetch-handler";
type Frame = [string | null, string, string, string, Record<string, unknown>];
/** Keeps producing until the test ends it, like an adapter that ignores abort. */
class ControlledAgent extends AbstractAgent {
readonly output = new Subject<BaseEvent>();
readonly abortRun = vi.fn();
runCount = 0;
run(input: RunAgentInput): Observable<BaseEvent> {
this.runCount++;
return new Observable((subscriber) => {
subscriber.next({
type: EventType.RUN_STARTED,
threadId: input.threadId,
runId: input.runId,
});
return this.output.subscribe(subscriber);
});
}
}
/** Acknowledges a Phoenix request using the same reference and topic. */
function reply(
socket: WebSocket,
frame: Frame,
response: Record<string, unknown> = {},
) {
socket.send(
JSON.stringify([
frame[0],
frame[1],
frame[2],
"phx_reply",
{ status: "ok", response },
]),
);
}
/** Exercises the real Phoenix client against a controllable gateway transport. */
async function setup(batch = false) {
// Node 20 does not expose a global WebSocket. Keep Node 22+ on its native
// transport and supply the same protocol transport for the older CI lane.
const needsWebSocket = typeof globalThis.WebSocket === "undefined";
if (needsWebSocket) vi.stubGlobal("WebSocket", WebSocket);
const server = new WebSocketServer({ port: 0 });
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address();
if (typeof address !== "object" || address === null)
throw new Error("Missing test server address");
const events: BaseEvent[] = [];
const pending: Array<{
socket: WebSocket;
frame: Frame;
events: BaseEvent[];
}> = [];
const locks = new Map<string, string>();
const errors: unknown[] = [];
const subscriptions: Array<{ unsubscribe(): void }> = [];
const agents: ControlledAgent[] = [];
const runner = new IntelligenceAgentRunner({
url: `ws://127.0.0.1:${address.port}/runner`,
});
server.on("connection", (socket) => {
socket.on("message", (data) => {
const frame: Frame = JSON.parse(data.toString());
if (frame[3] === "phx_join") {
reply(
socket,
frame,
batch ? { capabilities: ["runner_event_batch_v1"] } : {},
);
} else if (frame[3] === "event" || frame[3] === "events") {
const received = (
frame[3] === "events" ? frame[4].events : [frame[4]]
) as BaseEvent[];
pending.push({ socket, frame, events: received });
} else {
reply(socket, frame);
}
});
});
/** Models atomic terminal acceptance: only the exact run can release its lock. */
function acceptPending() {
for (const item of pending.splice(0)) {
for (const event of item.events) {
events.push(event);
if (
event.type === EventType.RUN_FINISHED ||
event.type === EventType.RUN_ERROR
) {
const owner = event as BaseEvent & {
threadId: string;
runId: string;
};
if (locks.get(owner.threadId) === owner.runId)
locks.delete(owner.threadId);
}
}
reply(item.socket, item.frame);
}
}
/** Starts a run only when the modeled hosted lock is available. */
function start(runId = "run-1") {
if (locks.has("thread-1")) throw new Error("THREAD_LOCK_FAILED");
const agent = new ControlledAgent();
agents.push(agent);
locks.set("thread-1", runId);
const input: RunAgentInput = {
threadId: "thread-1",
runId,
messages: [],
tools: [],
context: [],
state: {},
forwardedProps: {},
};
subscriptions.push(
runner
.run({ threadId: input.threadId, agent, input })
.subscribe({ error: (error) => errors.push(error) }),
);
return agent;
}
/** Closes test-owned streams, sockets, timers, and server listeners. */
async function teardown() {
subscriptions.forEach((subscription) => subscription.unsubscribe());
agents.forEach((agent) => agent.output.complete());
server.clients.forEach((socket) => socket.terminate());
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve())),
);
if (needsWebSocket) vi.unstubAllGlobals();
}
return {
runner,
start,
pending,
events,
locks,
errors,
acceptPending,
teardown,
};
}
for (const batch of [false, true]) {
test(`Stop waits for terminal acceptance and permits immediate resend (${batch ? "batch" : "legacy"})`, async () => {
const fixture = await setup(batch);
try {
const agent = fixture.start();
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
fixture.acceptPending();
await vi.waitFor(() => expect(agent.isRunning).toBe(true));
let stopResolved = false;
const stopping = fixture.runner
.stop({ threadId: "thread-1" })
.then((result) => {
stopResolved = true;
return result;
});
await vi.waitFor(() =>
expect(
fixture.pending.some((item) =>
item.events.some((event) => event.type === EventType.RUN_FINISHED),
),
).toBe(true),
);
expect(stopResolved).toBe(false);
expect(fixture.locks.get("thread-1")).toBe("run-1");
expect(agent.abortRun).toHaveBeenCalledOnce();
fixture.acceptPending();
expect(await stopping).toBe(true);
expect(await fixture.runner.isRunning({ threadId: "thread-1" })).toBe(
false,
);
expect(fixture.locks.has("thread-1")).toBe(false);
expect(() => fixture.start("run-2")).not.toThrow();
expect(fixture.errors).toEqual([]);
} finally {
await fixture.teardown();
}
});
}
test("Stop detaches the local AG-UI subscription when abort does nothing", async () => {
const fixture = await setup();
try {
const agent = fixture.start();
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
fixture.acceptPending();
expect(agent.output.observed).toBe(true);
const stopping = fixture.runner
.stop({ threadId: "thread-1" })
.catch((error) => error);
await vi.waitFor(() => expect(agent.output.observed).toBe(false));
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
fixture.acceptPending();
expect(await stopping).toBe(true);
} finally {
await fixture.teardown();
}
});
for (const abortThrows of [false, true]) {
test(`Stop fences late events from older agents without detachActiveRun (abort throws: ${abortThrows})`, async () => {
const fixture = await setup();
try {
const agent = fixture.start();
Object.defineProperty(agent, "detachActiveRun", { value: undefined });
if (abortThrows)
agent.abortRun.mockImplementation(() => {
throw new Error("Adapter cannot abort");
});
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
fixture.acceptPending();
const stopping = fixture.runner
.stop({ threadId: "thread-1", runId: "run-1" })
.catch((error) => error);
agent.output.next({
type: EventType.CUSTOM,
name: "late",
value: "discard",
});
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
fixture.acceptPending();
expect(await stopping).toBe(true);
const replacement = fixture.start("run-2");
await vi.waitFor(() => expect(replacement.runCount).toBe(1));
fixture.acceptPending();
agent.output.next({
type: EventType.CUSTOM,
name: "late",
value: "discard again",
});
agent.output.error(new Error("Late producer rejection"));
expect(
await fixture.runner.stop({ threadId: "thread-1", runId: "run-1" }),
).toBe(false);
expect(replacement.abortRun).not.toHaveBeenCalled();
expect(fixture.locks.get("thread-1")).toBe("run-2");
expect(fixture.events.map((event) => event.type)).not.toContain(
EventType.CUSTOM,
);
expect(
fixture.events.filter((event) => event.type === EventType.RUN_FINISHED),
).toHaveLength(1);
expect(fixture.errors).toEqual([]);
} finally {
await fixture.teardown();
}
});
}
test("Stop closes partial text and tool calls before its single terminal event", async () => {
const fixture = await setup(true);
try {
const agent = fixture.start();
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
fixture.acceptPending();
agent.output.next({
type: EventType.TEXT_MESSAGE_START,
messageId: "message-1",
role: "assistant",
});
agent.output.next({
type: EventType.TEXT_MESSAGE_CONTENT,
messageId: "message-1",
delta: "partial",
});
agent.output.next({
type: EventType.TOOL_CALL_START,
toolCallId: "tool-1",
toolCallName: "lookup",
});
agent.output.next({
type: EventType.TOOL_CALL_ARGS,
toolCallId: "tool-1",
delta: "{}",
});
await vi.waitFor(() =>
expect(
fixture.pending
.flatMap((item) => item.events)
.some((event) => event.type === EventType.TOOL_CALL_ARGS),
).toBe(true),
);
fixture.acceptPending();
const stopping = fixture.runner
.stop({ threadId: "thread-1" })
.catch((error) => error);
expect(await fixture.runner.stop({ threadId: "thread-1" })).toBe(false);
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
fixture.acceptPending();
expect(await stopping).toBe(true);
expect(fixture.events.map((event) => event.type)).toEqual([
EventType.RUN_STARTED,
EventType.TEXT_MESSAGE_START,
EventType.TEXT_MESSAGE_CONTENT,
EventType.TOOL_CALL_START,
EventType.TOOL_CALL_ARGS,
EventType.TEXT_MESSAGE_END,
EventType.TOOL_CALL_END,
EventType.TOOL_CALL_RESULT,
EventType.RUN_FINISHED,
]);
expect(fixture.events.at(-1)).toMatchObject({
threadId: "thread-1",
runId: "run-1",
});
} finally {
await fixture.teardown();
}
});
test("Stop before channel join never starts the agent and still delivers a terminal", async () => {
const fixture = await setup();
try {
const agent = fixture.start();
const stopping = fixture.runner
.stop({ threadId: "thread-1" })
.catch((error) => error);
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
fixture.acceptPending();
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
fixture.acceptPending();
expect(await stopping).toBe(true);
expect(agent.runCount).toBe(0);
expect(fixture.events.map((event) => event.type)).toEqual([
EventType.RUN_STARTED,
EventType.RUN_FINISHED,
]);
} finally {
await fixture.teardown();
}
});
test("Stop does not report success if terminal delivery is permanently rejected", async () => {
const fixture = await setup();
try {
fixture.start();
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
fixture.acceptPending();
const stopping = fixture.runner
.stop({ threadId: "thread-1" })
.catch((error) => error);
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
const item = fixture.pending.shift();
if (!item) throw new Error("Missing terminal request");
item.socket.send(
JSON.stringify([
item.frame[0],
item.frame[1],
item.frame[2],
"phx_reply",
{
status: "error",
response: { reason: "active_lock_mismatch", retryable: false },
},
]),
);
expect(await stopping).toBeInstanceOf(Error);
expect(fixture.locks.get("thread-1")).toBe("run-1");
expect(fixture.errors).toHaveLength(1);
expect(fixture.events.map((event) => event.type)).toEqual([
EventType.RUN_STARTED,
]);
} finally {
await fixture.teardown();
}
});
test("Stop rejects after the durability deadline instead of acknowledging an unreleased lock", async () => {
const fixture = await setup();
try {
fixture.start();
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
fixture.acceptPending();
vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
const stopping = fixture.runner
.stop({ threadId: "thread-1" })
.catch((error) => error);
await vi.advanceTimersByTimeAsync(60_000);
expect(await stopping).toBeInstanceOf(Error);
expect(fixture.locks.get("thread-1")).toBe("run-1");
expect(await fixture.runner.isRunning({ threadId: "thread-1" })).toBe(
false,
);
expect(fixture.errors).toHaveLength(1);
} finally {
vi.useRealTimers();
await fixture.teardown();
}
});
for (const mode of ["multi-route", "single-route"] as const) {
test(`The ${mode} Stop endpoint preserves its response and waits for lock release`, async () => {
const fixture = await setup();
try {
const agent = fixture.start();
const runtime = new CopilotRuntime({
agents: { test: agent },
runner: fixture.runner,
});
const handler = createCopilotRuntimeHandler({
runtime,
mode,
activateChannels: false,
});
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
fixture.acceptPending();
let responded = false;
const request =
mode === "multi-route"
? new Request("http://runtime/agent/test/stop/thread-1", {
method: "POST",
})
: new Request("http://runtime", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
method: "agent/stop",
params: { agentId: "test", threadId: "thread-1" },
}),
});
const stopping = handler(request).then((response) => {
responded = true;
return response;
});
await vi.waitFor(() => expect(fixture.pending.length).toBeGreaterThan(0));
expect(responded).toBe(false);
fixture.acceptPending();
const response = await stopping;
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
stopped: true,
interrupt: {
type: EventType.RUN_ERROR,
message: "Run stopped by user",
code: "STOPPED",
},
});
expect(fixture.locks.has("thread-1")).toBe(false);
expect(() => fixture.start("run-2")).not.toThrow();
} finally {
await fixture.teardown();
}
});
}
@@ -12,6 +12,7 @@ import {
finalizeRunEvents,
AG_UI_CHANNEL_EVENT,
phoenixExponentialBackoff,
logger,
} from "@copilotkit/shared";
import type { Channel } from "phoenix";
import { Socket } from "phoenix";
@@ -47,6 +48,11 @@ interface ThreadState {
hasJoined: boolean;
supportsRunnerEventBatch: boolean;
producerFinished: boolean;
cancellation: Promise<void>;
cancelRun: () => void;
completion: Promise<boolean>;
resolveCompletion: (completed: boolean) => void;
stopTimer: ReturnType<typeof setTimeout> | null;
pendingEvents: Map<
string,
{ payload: Record<string, unknown>; queuedAt: number }
@@ -235,6 +241,14 @@ export class IntelligenceAgentRunner extends AgentRunner {
run_id: input.runId,
});
let cancelRun!: () => void;
const cancellation = new Promise<void>((resolve) => {
cancelRun = resolve;
});
let resolveCompletion!: (completed: boolean) => void;
const completion = new Promise<boolean>((resolve) => {
resolveCompletion = resolve;
});
const state: ThreadState = {
threadId,
runId: input.runId,
@@ -249,6 +263,11 @@ export class IntelligenceAgentRunner extends AgentRunner {
hasJoined: false,
supportsRunnerEventBatch: false,
producerFinished: false,
cancellation,
cancelRun,
completion,
resolveCompletion,
stopTimer: null,
pendingEvents: new Map(),
activeEventBatch: null,
nextEventPushAttempt: 0,
@@ -258,7 +277,10 @@ export class IntelligenceAgentRunner extends AgentRunner {
socketReconnectWatchdog: null,
eventRetryAttempt: 0,
completeRun: () => observer.complete(),
failRun: (error) => observer.error(error),
failRun: (error) => {
startupBoundary?.rejectStartup(error);
observer.error(error);
},
};
this.threads.set(threadId, state);
@@ -324,7 +346,12 @@ export class IntelligenceAgentRunner extends AgentRunner {
payload.type === EventType.CUSTOM &&
(payload as BaseEvent & { name?: string }).name === "stop"
) {
this.stop({ threadId, runId: state.runId });
this.stop({ threadId, runId: state.runId }).catch((error) => {
logger.error(
{ err: error, threadId, runId: state.runId },
"Failed to stop Intelligence run",
);
});
}
});
@@ -472,6 +499,7 @@ export class IntelligenceAgentRunner extends AgentRunner {
return Promise.resolve(state?.isRunning ?? false);
}
/** Stops this run and waits until its terminal events have been acknowledged. */
stop(request: AgentRunnerStopRequest): Promise<boolean | undefined> {
const state = this.threads.get(request.threadId);
if (!state || !state.isRunning || state.stopRequested) {
@@ -483,16 +511,51 @@ export class IntelligenceAgentRunner extends AgentRunner {
state.stopRequested = true;
// Fence output before abort: adapters may emit synchronously, throw, or
// ignore cancellation. Finalization must not depend on their cooperation.
state.cancelRun();
state.stopTimer = setTimeout(() => {
this.failThread(
state.threadId,
state,
new Error("Timed out stopping Intelligence run"),
);
}, EVENT_DURABILITY_DEADLINE_MS);
// Direct local abort — the runtime is the authority.
if (state.agent) {
try {
state.agent.abortRun();
} catch {
// Ignore abort errors.
// The local run is still fenced and must deliver its terminal events.
}
// Older AG-UI agents may not expose detachActiveRun. The cancellation
// race still finalizes their run without waiting for the producer.
if (typeof state.agent.detachActiveRun === "function") {
try {
Promise.resolve(state.agent.detachActiveRun()).catch((error) => {
logger.warn(
{ err: error, threadId: state.threadId, runId: state.runId },
"Failed to detach stopped agent",
);
});
} catch (error) {
logger.warn(
{ err: error, threadId: state.threadId, runId: state.runId },
"Failed to detach stopped agent",
);
}
}
}
return Promise.resolve(true);
return state.completion.then((completed) => {
if (!completed) {
throw new Error(
"Intelligence run stopped before terminal events were acknowledged",
);
}
return true;
});
}
private async executeAgentRun(
@@ -566,19 +629,26 @@ export class IntelligenceAgentRunner extends AgentRunner {
};
try {
await request.agent.runAgent(request.input, {
onEvent: ({ event }: { event: BaseEvent }) => {
if (event.type === EventType.RUN_STARTED) {
pushCanonicalEvent(buildRunStartedEvent(event as RunStartedEvent));
return;
}
if (state.stopRequested) return;
await Promise.race([
request.agent.runAgent(request.input, {
onEvent: ({ event }: { event: BaseEvent }) => {
if (state.stopRequested || state.producerFinished) return;
if (event.type === EventType.RUN_STARTED) {
pushCanonicalEvent(
buildRunStartedEvent(event as RunStartedEvent),
);
return;
}
ensureRunStarted();
pushCanonicalEvent(event);
},
});
ensureRunStarted();
pushCanonicalEvent(event);
},
}),
state.cancellation,
]);
} catch (error) {
if (!this.isCurrentThreadState(threadId, state)) {
if (state.stopRequested || !this.isCurrentThreadState(threadId, state)) {
return;
}
ensureRunStarted();
@@ -883,7 +953,7 @@ export class IntelligenceAgentRunner extends AgentRunner {
return;
}
this.removeThread(threadId, state);
this.removeThread(threadId, state, true);
state.completeRun();
}
@@ -1000,7 +1070,11 @@ export class IntelligenceAgentRunner extends AgentRunner {
* Idempotent safe to call multiple times for the same threadId
* (e.g. from join error handlers, finalize, and Observable teardown).
*/
private removeThread(threadId: string, state: ThreadState): void {
private removeThread(
threadId: string,
state: ThreadState,
completed = false,
): void {
if (this.threads.get(threadId) !== state) {
return;
}
@@ -1008,6 +1082,11 @@ export class IntelligenceAgentRunner extends AgentRunner {
// Delete first so concurrent calls see the entry as already removed.
this.threads.delete(threadId);
state.isRunning = false;
state.resolveCompletion(completed);
if (state.stopTimer !== null) {
clearTimeout(state.stopTimer);
state.stopTimer = null;
}
this.clearPendingEventRetry(state);
this.clearPendingEventFlush(state);
if (state.eventDeadlineTimer !== null) {
+1 -1
View File
@@ -54,6 +54,6 @@
}
},
"engines": {
"node": ">=18"
"node": ">=20"
}
}
+1 -1
View File
@@ -50,6 +50,6 @@
"@copilotkit/runtime": "workspace:*"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
}
+1 -1
View File
@@ -126,6 +126,6 @@
"vue": ">=3.3.0"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
}
+1 -1
View File
@@ -80,6 +80,6 @@
"vitest": "^3.2.4"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
}
+1 -1
View File
@@ -61,6 +61,6 @@
"ws": "^8.19.0"
},
"engines": {
"node": ">=18"
"node": ">=20"
}
}
+928 -138
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -6,6 +6,7 @@
"@copilotkit/a2ui-renderer",
"@copilotkit/agentcore-runner",
"@copilotkit/core",
"@copilotkit/mcp-apps-renderer",
"@copilotkit/react-core",
"@copilotkit/react-native",
"@copilotkit/react-textarea",
@@ -145,10 +145,10 @@ describe("resolvePackageCountSafe", () => {
});
it("returns the real package count for the monorepo scope (drift guard)", () => {
// Pins the actual count from release.config.json (16). If the package set
// Pins the actual count from release.config.json (17). If the package set
// drifts, this catches the staleness of the hardcoded "16 packages"
// assertions in build-release-notification.test.ts.
expect(resolvePackageCountSafe("monorepo")).toBe(16);
expect(resolvePackageCountSafe("monorepo")).toBe(17);
});
it("returns the real package count for the shared Channels scope (drift guard)", () => {
+99 -8
View File
@@ -167,7 +167,7 @@
],
"compatibility": {
"engines": {
"node": ">=18"
"node": ">=20"
},
"provenance": [
{
@@ -989,7 +989,7 @@
],
"compatibility": {
"engines": {
"node": ">=18"
"node": ">=20"
},
"provenance": [
{
@@ -1017,6 +1017,90 @@
}
}
},
{
"name": "@copilotkit/mcp-apps-renderer",
"version": "1.71.0",
"sourceDirectory": "packages/mcp-apps-renderer",
"entrypoints": [
{
"importPath": "@copilotkit/mcp-apps-renderer",
"exportKey": ".",
"kind": "code",
"conditions": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
},
"provenance": {
"kind": "package-json",
"path": "packages/mcp-apps-renderer/package.json",
"selector": "exports[\".\"]"
}
},
{
"importPath": "@copilotkit/mcp-apps-renderer/activity",
"exportKey": "./activity",
"kind": "code",
"conditions": {
"import": {
"types": "./dist/activity.d.mts",
"default": "./dist/activity.mjs"
},
"require": {
"types": "./dist/activity.d.cts",
"default": "./dist/activity.cjs"
}
},
"provenance": {
"kind": "package-json",
"path": "packages/mcp-apps-renderer/package.json",
"selector": "exports[\"./activity\"]"
}
},
{
"importPath": "@copilotkit/mcp-apps-renderer/package.json",
"exportKey": "./package.json",
"kind": "metadata",
"conditions": "./package.json",
"provenance": {
"kind": "package-json",
"path": "packages/mcp-apps-renderer/package.json",
"selector": "exports[\"./package.json\"]"
}
}
],
"compatibility": {
"peerDependencies": {
"@ag-ui/client": "0.0.59",
"zod": ">=3.25"
},
"provenance": [
{
"kind": "package-json",
"path": "packages/mcp-apps-renderer/package.json",
"selector": "peerDependencies"
}
]
},
"provenance": {
"name": {
"kind": "package-json",
"path": "packages/mcp-apps-renderer/package.json",
"selector": "name"
},
"version": {
"kind": "package-json",
"path": "packages/mcp-apps-renderer/package.json",
"selector": "version"
},
"releaseScope": {
"kind": "release-config",
"path": "release.config.json",
"selector": "scopes.monorepo.packages"
}
}
},
{
"name": "@copilotkit/react-core",
"version": "1.71.1",
@@ -1103,7 +1187,6 @@
],
"compatibility": {
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc",
"zod": ">=3.25"
@@ -1587,6 +1670,9 @@
}
],
"compatibility": {
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@anthropic-ai/sdk": ">=0.57.0",
"@langchain/aws": ">=0.1.9",
@@ -1611,6 +1697,11 @@
"openai"
],
"provenance": [
{
"kind": "package-json",
"path": "packages/runtime/package.json",
"selector": "engines"
},
{
"kind": "package-json",
"path": "packages/runtime/package.json",
@@ -1917,7 +2008,7 @@
],
"compatibility": {
"engines": {
"node": ">=18"
"node": ">=20"
},
"peerDependencies": {
"better-sqlite3": "^12.2.0"
@@ -1994,7 +2085,7 @@
],
"compatibility": {
"engines": {
"node": ">=18"
"node": ">=20"
},
"peerDependencies": {
"@copilotkit/runtime": "workspace:*"
@@ -2100,7 +2191,7 @@
],
"compatibility": {
"engines": {
"node": ">=18"
"node": ">=20"
},
"peerDependencies": {
"vue": ">=3.3.0"
@@ -2195,7 +2286,7 @@
],
"compatibility": {
"engines": {
"node": ">=18"
"node": ">=20"
},
"peerDependencies": {
"lit": "^3.3.2"
@@ -2264,7 +2355,7 @@
],
"compatibility": {
"engines": {
"node": ">=18"
"node": ">=20"
},
"provenance": [
{
@@ -49,7 +49,7 @@ the current conversation.
## Prerequisites
- **Python 3.12** (required — `oracleagentmemory` ships a cp312 wheel), [`uv`](https://docs.astral.sh/uv/), Node.js 18+
- **Python 3.12** (required — `oracleagentmemory` ships a cp312 wheel), [`uv`](https://docs.astral.sh/uv/), Node.js 20+
- Docker (for the local Oracle AI Database) or your own Oracle AI Database
- `OPENAI_API_KEY` (defaults use OpenAI via litellm)
@@ -26,7 +26,7 @@ The handler code below is the same for both streaming paths; only the event shap
## Function URL with response streaming
Response streaming requires three things: a Function URL with its invoke mode set to `RESPONSE_STREAM`, a handler wrapped in `awslambda.streamifyResponse`, and a Node.js managed runtime (Node.js 18 or later).
Response streaming requires three things: a Function URL with its invoke mode set to `RESPONSE_STREAM`, a handler wrapped in `awslambda.streamifyResponse`, and a Node.js managed runtime of version 20 or later. AWS supports response streaming from Node.js 18, but `@copilotkit/runtime` requires Node.js 20.
### 1. The handler
@@ -24,7 +24,7 @@ import { Tab } from "../components/react/tabs";
```
```bash
# For TypeScript with Node 18 or above
# For TypeScript with Node 20 or above
npx @langchain/langgraph-cli dev --host localhost --port 8000
```