mirror of
https://github.com/CopilotKit/CopilotKit.git
synced 2026-09-14 16:26:20 +08:00
Merge branch 'main' into claude/xenodochial-khayyam-873a16
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -44,6 +44,6 @@
|
||||
"vitest": "^3.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,6 @@
|
||||
"zod": "^3.25.75"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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: [] };
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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>`;
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
+478
@@ -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) {
|
||||
|
||||
@@ -54,6 +54,6 @@
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,6 @@
|
||||
"@copilotkit/runtime": "workspace:*"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +126,6 @@
|
||||
"vue": ">=3.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +80,6 @@
|
||||
"vitest": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,6 @@
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+928
-138
File diff suppressed because it is too large
Load Diff
@@ -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)", () => {
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user