Wire MCP Apps session runtime through the hub [No-op]

This commit is contained in:
BarreiroT
2026-10-22 21:18:35 -03:00
parent 1619168bde
commit 273d859ae6
22 changed files with 1122 additions and 3 deletions
+9
View File
@@ -252,6 +252,15 @@ potentially live command's advertised path takes precedence over guessing that
it exited. A detached client connection alone never changes process ownership or
command execution.
MCP Apps requests follow the same session-scoped command path. A session started
with `mcpApps: true` keeps an MCP Apps runtime on its `BuiltRuntime`, bound to
the session's `InMemoryMcpManager` in the Hub process. A UI host never opens its
own MCP connection for an app. It sends `session.mcp_apps.resolve_tool`,
`session.mcp_apps.read_resource`, or `session.mcp_apps.call_tool`, and the Hub
delegates to the `LocalRuntimeHost`, which serves the request from that runtime.
App tool calls are checked against the tool's `_meta.ui.visibility` there, in the
process that owns the connection, not in the client.
### Generated Media Operation and Event Flow
Model modalities and provider operations are separate facts. Modalities describe
+19
View File
@@ -472,6 +472,25 @@ tool calls on an already connected server. A read that starts while the server
is being disconnected or re-registered can fail with a disconnect error; retry
it.
For sessions, set `mcpApps: true` in the session config (`CoreRuntimeFeatures`).
The runtime builder then advertises the extension to every settings and Agent
Plugin MCP server it connects, and the session gains an MCP Apps runtime that
uses those same connections. Hosts reach it through `ClineCore`:
- `resolveMcpAppTool(sessionId, toolName)` maps an agent tool name (as it
appears in tool events, for example `weather__show_weather`) to
`{ serverName, toolName, resourceUri }`. It returns `undefined` for tools with
no `ui://` resource and for sessions without MCP Apps.
- `readMcpAppResource(sessionId, { serverName, uri })` reads a resource from one
of the session's MCP servers.
- `callMcpAppTool(sessionId, { serverName, toolName, arguments })` calls a tool
on the app's behalf. It rejects tools whose visibility lacks `"app"`, tools
the server does not list, and servers outside the session.
`readMcpAppResource` and `callMcpAppTool` throw when the session was started
without `mcpApps`. Hub-backed hosts send these as the `session.mcp_apps.*`
commands, so the hub daemon serves them from the session's own MCP manager.
## Run stall telemetry
`@cline/core` measures stalls with a `RunHealthObserver`
+21
View File
@@ -36,6 +36,7 @@ import { normalizeRuntimeCapabilities } from "./runtime/capabilities";
import { listSessionHistory } from "./runtime/host/history";
import { createRuntimeHost } from "./runtime/host/host";
import type {
McpAppsRuntimeService,
PendingPromptsServiceApi,
RuntimeHost,
RuntimeHostSubscribeOptions,
@@ -681,4 +682,24 @@ export class ClineCore {
const service = this.host as RuntimeHostServiceExtensions;
return service.updateSessionConnection?.(...args) ?? Promise.resolve();
};
resolveMcpAppTool: McpAppsRuntimeService["resolveMcpAppTool"] = (...args) => {
const service = this.host as RuntimeHostServiceExtensions;
return service.resolveMcpAppTool?.(...args) ?? Promise.resolve(undefined);
};
readMcpAppResource: McpAppsRuntimeService["readMcpAppResource"] = (
...args
) => {
const service = this.host as RuntimeHostServiceExtensions;
return (
service.readMcpAppResource?.(...args) ??
Promise.reject(new Error("This runtime does not support MCP Apps."))
);
};
callMcpAppTool: McpAppsRuntimeService["callMcpAppTool"] = (...args) => {
const service = this.host as RuntimeHostServiceExtensions;
return (
service.callMcpAppTool?.(...args) ??
Promise.reject(new Error("This runtime does not support MCP Apps."))
);
};
}
@@ -1,4 +1,5 @@
import type {
McpAppsRuntimeService,
PendingPromptsRuntimeService,
PendingPromptsServiceApi,
RuntimeHost,
@@ -33,7 +34,8 @@ export type RuntimeHostServiceExtensions = RuntimeHost &
PendingPromptsRuntimeService &
SessionUsageRuntimeService &
SessionConnectionRuntimeService &
SessionModelRuntimeService
SessionModelRuntimeService &
McpAppsRuntimeService
>;
export function createClineCoreSettingsApi(
@@ -69,6 +69,14 @@ export {
createDisabledMcpToolPolicies,
createDisabledMcpToolPolicy,
} from "./policies";
export type {
CreateMcpAppsRuntimeOptions,
McpAppResourceReadRequest,
McpAppsRuntime,
McpAppToolBinding,
McpAppToolCallRequest,
} from "./session-apps";
export { createMcpAppsRuntime } from "./session-apps";
export { augmentMcpTimeoutError } from "./timeout";
export { createMcpTools } from "./tools";
export type {
@@ -0,0 +1,117 @@
import { describe, expect, it, vi } from "vitest";
import { createMcpAppsRuntime } from "./session-apps";
import type { McpToolDescriptor } from "./types";
const weatherTools: McpToolDescriptor[] = [
{
name: "show_weather",
inputSchema: { type: "object" },
_meta: { ui: { resourceUri: "ui://weather/view.html" } },
},
{
name: "refresh_view",
inputSchema: { type: "object" },
_meta: { ui: { visibility: ["app"] } },
},
{
name: "summarize",
inputSchema: { type: "object" },
_meta: { ui: { visibility: ["model"] } },
},
{ name: "plain", inputSchema: { type: "object" } },
];
function createManager() {
return {
listTools: vi.fn(async (serverName: string) => {
if (serverName === "broken") {
throw new Error("offline");
}
return serverName === "weather" ? weatherTools : [];
}),
callTool: vi.fn(async () => ({ content: [] })),
readResource: vi.fn(async () => ({ contents: [] })),
};
}
describe("MCP Apps session runtime", () => {
it("resolves an agent tool name to its server, tool, and UI resource", async () => {
const apps = createMcpAppsRuntime({
manager: createManager(),
serverNames: ["broken", "weather"],
});
await expect(apps.resolveTool("weather__show_weather")).resolves.toEqual({
serverName: "weather",
toolName: "show_weather",
resourceUri: "ui://weather/view.html",
});
await expect(apps.resolveTool("weather__plain")).resolves.toBeUndefined();
await expect(apps.resolveTool("unknown__tool")).resolves.toBeUndefined();
});
it("matches names produced by a custom name transform", async () => {
const apps = createMcpAppsRuntime({
manager: createManager(),
serverNames: ["weather"],
nameTransform: ({ toolName }) => `custom_${toolName}`,
});
await expect(
apps.resolveTool("custom_show_weather"),
).resolves.toMatchObject({ toolName: "show_weather" });
});
it("calls tools the app is allowed to call", async () => {
const manager = createManager();
const apps = createMcpAppsRuntime({ manager, serverNames: ["weather"] });
await apps.callTool({
serverName: "weather",
toolName: "refresh_view",
arguments: { city: "Lisbon" },
});
await apps.callTool({ serverName: "weather", toolName: "plain" });
expect(manager.callTool).toHaveBeenNthCalledWith(1, {
serverName: "weather",
toolName: "refresh_view",
arguments: { city: "Lisbon" },
});
expect(manager.callTool).toHaveBeenCalledTimes(2);
});
it("rejects model-only, missing, and out-of-session tools", async () => {
const manager = createManager();
const apps = createMcpAppsRuntime({ manager, serverNames: ["weather"] });
await expect(
apps.callTool({ serverName: "weather", toolName: "summarize" }),
).rejects.toThrow("cannot be called by an app");
await expect(
apps.callTool({ serverName: "weather", toolName: "missing" }),
).rejects.toThrow("was not found");
await expect(
apps.callTool({ serverName: "other", toolName: "plain" }),
).rejects.toThrow("is not available in this session");
expect(manager.callTool).not.toHaveBeenCalled();
});
it("reads resources only from servers in the session", async () => {
const manager = createManager();
const apps = createMcpAppsRuntime({ manager, serverNames: ["weather"] });
await apps.readResource({
serverName: "weather",
uri: "ui://weather/view.html",
});
await expect(
apps.readResource({ serverName: "other", uri: "ui://other/view.html" }),
).rejects.toThrow("is not available in this session");
expect(manager.readResource).toHaveBeenCalledExactlyOnceWith({
serverName: "weather",
uri: "ui://weather/view.html",
});
});
});
@@ -0,0 +1,123 @@
import { getMcpAppToolMeta, isMcpToolVisibleToApp } from "./apps";
import { defaultMcpToolNameTransform } from "./name-transform";
import type {
McpManager,
McpResourceReadResult,
McpToolCallResult,
McpToolDescriptor,
McpToolNameTransform,
} from "./types";
export interface McpAppToolBinding {
serverName: string;
toolName: string;
resourceUri: string;
}
export interface McpAppResourceReadRequest {
serverName: string;
uri: string;
}
export interface McpAppToolCallRequest {
serverName: string;
toolName: string;
arguments?: Record<string, unknown>;
}
export interface McpAppsRuntime {
resolveTool(agentToolName: string): Promise<McpAppToolBinding | undefined>;
readResource(
request: McpAppResourceReadRequest,
): Promise<McpResourceReadResult>;
callTool(request: McpAppToolCallRequest): Promise<McpToolCallResult>;
}
export interface CreateMcpAppsRuntimeOptions {
manager: Pick<McpManager, "listTools" | "callTool" | "readResource">;
serverNames: readonly string[];
nameTransform?: McpToolNameTransform;
}
export function createMcpAppsRuntime(
options: CreateMcpAppsRuntimeOptions,
): McpAppsRuntime {
const serverNames = new Set(options.serverNames);
const nameTransform = options.nameTransform ?? defaultMcpToolNameTransform;
function requireServer(serverName: string): void {
if (!serverNames.has(serverName)) {
throw new Error(
`MCP server "${serverName}" is not available in this session.`,
);
}
}
async function findDescriptor(
serverName: string,
toolName: string,
): Promise<McpToolDescriptor | undefined> {
const descriptors = await options.manager.listTools(serverName);
return descriptors.find((descriptor) => descriptor.name === toolName);
}
return {
async resolveTool(agentToolName) {
for (const serverName of serverNames) {
let descriptors: readonly McpToolDescriptor[];
try {
descriptors = await options.manager.listTools(serverName);
} catch {
continue;
}
const descriptor = descriptors.find(
(candidate) =>
nameTransform({ serverName, toolName: candidate.name }) ===
agentToolName,
);
if (!descriptor) {
continue;
}
const resourceUri = getMcpAppToolMeta(descriptor)?.resourceUri;
return resourceUri
? { serverName, toolName: descriptor.name, resourceUri }
: undefined;
}
return undefined;
},
async readResource(request) {
requireServer(request.serverName);
if (!options.manager.readResource) {
throw new Error("MCP resource reads are not supported.");
}
return options.manager.readResource({
serverName: request.serverName,
uri: request.uri,
});
},
async callTool(request) {
requireServer(request.serverName);
const descriptor = await findDescriptor(
request.serverName,
request.toolName,
);
if (!descriptor) {
throw new Error(
`MCP tool "${request.toolName}" was not found on server "${request.serverName}".`,
);
}
if (!isMcpToolVisibleToApp(descriptor)) {
throw new Error(
`MCP tool "${request.toolName}" on server "${request.serverName}" cannot be called by an app.`,
);
}
return options.manager.callTool({
serverName: request.serverName,
toolName: request.toolName,
arguments: request.arguments,
});
},
};
}
@@ -1,5 +1,5 @@
import type { AgentToolContext, HubEventEnvelope } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { version as corePackageVersion } from "../../../package.json";
import { createSessionCompactionState } from "../../session/models/session-compaction";
import { SessionSource } from "../../types/common";
@@ -1885,3 +1885,93 @@ it("sends atomic first-prompt steering through the Hub without listing the queue
await host.dispose();
}
});
describe("HubRuntimeHost MCP Apps commands", () => {
beforeEach(() => {
commandMock.mockReset();
});
it("sends MCP Apps requests as session-scoped hub commands", async () => {
const binding = {
serverName: "weather",
toolName: "show_weather",
resourceUri: "ui://weather/view.html",
};
commandMock
.mockResolvedValueOnce({ ok: true, payload: { binding } })
.mockResolvedValueOnce({
ok: true,
payload: { result: { contents: [] } },
})
.mockResolvedValueOnce({
ok: true,
payload: { result: { content: [] } },
});
const { HubRuntimeHost } = await import("./hub-runtime-host");
const host = new HubRuntimeHost({ url: "ws://127.0.0.1:25463/hub" });
await expect(
host.resolveMcpAppTool("sess-1", "weather__show_weather"),
).resolves.toEqual(binding);
await expect(
host.readMcpAppResource("sess-1", {
serverName: "weather",
uri: "ui://weather/view.html",
}),
).resolves.toEqual({ contents: [] });
await expect(
host.callMcpAppTool("sess-1", {
serverName: "weather",
toolName: "refresh_view",
arguments: { city: "Lisbon" },
}),
).resolves.toEqual({ content: [] });
expect(commandMock.mock.calls).toEqual([
[
"session.mcp_apps.resolve_tool",
{ sessionId: "sess-1", toolName: "weather__show_weather" },
"sess-1",
],
[
"session.mcp_apps.read_resource",
{
sessionId: "sess-1",
serverName: "weather",
uri: "ui://weather/view.html",
},
"sess-1",
],
[
"session.mcp_apps.call_tool",
{
sessionId: "sess-1",
serverName: "weather",
toolName: "refresh_view",
arguments: { city: "Lisbon" },
},
"sess-1",
{ timeoutMs: null },
],
]);
});
it("surfaces hub errors from MCP Apps commands", async () => {
commandMock.mockResolvedValue({
ok: false,
error: {
code: "mcp_apps_call_tool_failed",
message: "cannot be called by an app",
},
});
const { HubRuntimeHost } = await import("./hub-runtime-host");
const host = new HubRuntimeHost({ url: "ws://127.0.0.1:25463/hub" });
await expect(
host.callMcpAppTool("sess-1", {
serverName: "weather",
toolName: "summarize",
}),
).rejects.toThrow("cannot be called by an app");
});
});
@@ -27,6 +27,13 @@ import {
isHubToolExecutorName,
} from "@cline/shared";
import { version as corePackageVersion } from "../../../package.json";
import type {
McpAppResourceReadRequest,
McpAppToolBinding,
McpAppToolCallRequest,
McpResourceReadResult,
McpToolCallResult,
} from "../../extensions/mcp";
import type { HookEventPayload } from "../../hooks";
import type { RuntimeCapabilities } from "../../runtime/capabilities";
import { normalizeRuntimeCapabilities } from "../../runtime/capabilities";
@@ -1314,6 +1321,58 @@ export class HubRuntimeHost implements RuntimeHost {
);
}
async resolveMcpAppTool(
sessionId: string,
toolName: string,
): Promise<McpAppToolBinding | undefined> {
const reply = await this.client.command(
"session.mcp_apps.resolve_tool",
{ sessionId, toolName },
sessionId,
);
if (!reply.ok) {
throw new Error(
hubReplyErrorMessage(reply, "session.mcp_apps.resolve_tool"),
);
}
return reply.payload?.binding as McpAppToolBinding | undefined;
}
async readMcpAppResource(
sessionId: string,
request: McpAppResourceReadRequest,
): Promise<McpResourceReadResult> {
const reply = await this.client.command(
"session.mcp_apps.read_resource",
{ sessionId, ...request },
sessionId,
);
if (!reply.ok) {
throw new Error(
hubReplyErrorMessage(reply, "session.mcp_apps.read_resource"),
);
}
return reply.payload?.result as McpResourceReadResult;
}
async callMcpAppTool(
sessionId: string,
request: McpAppToolCallRequest,
): Promise<McpToolCallResult> {
const reply = await this.client.command(
"session.mcp_apps.call_tool",
{ sessionId, ...request },
sessionId,
{ timeoutMs: null },
);
if (!reply.ok) {
throw new Error(
hubReplyErrorMessage(reply, "session.mcp_apps.call_tool"),
);
}
return reply.payload?.result;
}
async proceedWhileRunning(
sessionId: string,
toolCallId?: string,
@@ -13,6 +13,7 @@ import type {
import { createSessionId } from "@cline/shared";
import type {
CommandExecutionRuntimeService,
McpAppsRuntimeService,
PendingPromptsRuntimeService,
RuntimeHost,
SessionConnectionRuntimeService,
@@ -82,7 +83,8 @@ export interface HubTransportContext {
CommandExecutionRuntimeService &
PendingPromptsRuntimeService &
SessionUsageRuntimeService &
SessionConnectionRuntimeService
SessionConnectionRuntimeService &
McpAppsRuntimeService
>;
/**
* While draining, new mutating work (session.create, run.*) is refused
@@ -0,0 +1,214 @@
import type { HubCommandEnvelope } from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
import {
SESSION_NOT_FOUND_ERROR_CODE,
SessionNotFoundError,
} from "../../../runtime/host/runtime-host";
import { buildHubEvent, type HubTransportContext } from "./context";
import {
handleSessionMcpAppsCallTool,
handleSessionMcpAppsReadResource,
handleSessionMcpAppsResolveTool,
} from "./mcp-apps-handlers";
function createContext(
sessionHost: Partial<HubTransportContext["sessionHost"]>,
): HubTransportContext {
return {
sessionSearch: { removeSession: vi.fn() } as never,
clients: new Map(),
sessionState: new Map(),
pendingApprovals: new Map(),
pendingCapabilityRequests: new Map(),
suppressNextTerminalEventBySession: new Map(),
activeRpcTurnCountBySession: new Map(),
sessionHost: sessionHost as HubTransportContext["sessionHost"],
publish: vi.fn(),
buildEvent: buildHubEvent,
requestCapability: vi.fn(),
};
}
function envelope(
command: HubCommandEnvelope["command"],
payload: Record<string, unknown>,
): HubCommandEnvelope {
return { version: "v1", command, requestId: "req-1", payload };
}
describe("MCP Apps hub handlers", () => {
it("resolves an app tool binding", async () => {
const binding = {
serverName: "weather",
toolName: "show_weather",
resourceUri: "ui://weather/view.html",
};
const resolveMcpAppTool = vi.fn().mockResolvedValue(binding);
const ctx = createContext({ resolveMcpAppTool });
await expect(
handleSessionMcpAppsResolveTool(
ctx,
envelope("session.mcp_apps.resolve_tool", {
sessionId: "sess-1",
toolName: "weather__show_weather",
}),
),
).resolves.toMatchObject({ ok: true, payload: { binding } });
expect(resolveMcpAppTool).toHaveBeenCalledWith(
"sess-1",
"weather__show_weather",
);
});
it("replies without a binding for tools that have no app", async () => {
const ctx = createContext({
resolveMcpAppTool: vi.fn().mockResolvedValue(undefined),
});
await expect(
handleSessionMcpAppsResolveTool(
ctx,
envelope("session.mcp_apps.resolve_tool", {
sessionId: "sess-1",
toolName: "read_file",
}),
),
).resolves.toMatchObject({ ok: true, payload: {} });
});
it("forwards resource reads and tool calls", async () => {
const readMcpAppResource = vi
.fn()
.mockResolvedValue({ contents: [{ uri: "ui://weather/view.html" }] });
const callMcpAppTool = vi.fn().mockResolvedValue({ content: [] });
const ctx = createContext({ readMcpAppResource, callMcpAppTool });
await expect(
handleSessionMcpAppsReadResource(
ctx,
envelope("session.mcp_apps.read_resource", {
sessionId: "sess-1",
serverName: "weather",
uri: "ui://weather/view.html",
}),
),
).resolves.toMatchObject({
ok: true,
payload: { result: { contents: [{ uri: "ui://weather/view.html" }] } },
});
await expect(
handleSessionMcpAppsCallTool(
ctx,
envelope("session.mcp_apps.call_tool", {
sessionId: "sess-1",
serverName: "weather",
toolName: "refresh_view",
arguments: { city: "Lisbon" },
}),
),
).resolves.toMatchObject({
ok: true,
payload: { result: { content: [] } },
});
expect(readMcpAppResource).toHaveBeenCalledWith("sess-1", {
serverName: "weather",
uri: "ui://weather/view.html",
});
expect(callMcpAppTool).toHaveBeenCalledWith("sess-1", {
serverName: "weather",
toolName: "refresh_view",
arguments: { city: "Lisbon" },
});
});
it("rejects missing fields and non-object arguments", async () => {
const callMcpAppTool = vi.fn();
const ctx = createContext({ callMcpAppTool });
await expect(
handleSessionMcpAppsReadResource(
ctx,
envelope("session.mcp_apps.read_resource", {
sessionId: "sess-1",
serverName: "weather",
}),
),
).resolves.toMatchObject({
ok: false,
error: { code: "invalid_mcp_apps_read_resource" },
});
await expect(
handleSessionMcpAppsCallTool(
ctx,
envelope("session.mcp_apps.call_tool", {
sessionId: "sess-1",
serverName: "weather",
toolName: "refresh_view",
arguments: ["Lisbon"],
}),
),
).resolves.toMatchObject({
ok: false,
error: { code: "invalid_mcp_apps_call_tool" },
});
expect(callMcpAppTool).not.toHaveBeenCalled();
});
it("reports runtime failures as error replies", async () => {
const ctx = createContext({
callMcpAppTool: vi
.fn()
.mockRejectedValue(new Error("cannot be called by an app")),
readMcpAppResource: vi
.fn()
.mockRejectedValue(new SessionNotFoundError("sess-1")),
});
await expect(
handleSessionMcpAppsCallTool(
ctx,
envelope("session.mcp_apps.call_tool", {
sessionId: "sess-1",
serverName: "weather",
toolName: "summarize",
}),
),
).resolves.toMatchObject({
ok: false,
error: {
code: "mcp_apps_call_tool_failed",
message: "cannot be called by an app",
},
});
await expect(
handleSessionMcpAppsReadResource(
ctx,
envelope("session.mcp_apps.read_resource", {
sessionId: "sess-1",
serverName: "weather",
uri: "ui://weather/view.html",
}),
),
).resolves.toMatchObject({
ok: false,
error: { code: SESSION_NOT_FOUND_ERROR_CODE },
});
});
it("reports unsupported runtimes", async () => {
await expect(
handleSessionMcpAppsResolveTool(
createContext({}),
envelope("session.mcp_apps.resolve_tool", {
sessionId: "sess-1",
toolName: "weather__show_weather",
}),
),
).resolves.toMatchObject({
ok: false,
error: { code: "unsupported_command" },
});
});
});
@@ -0,0 +1,138 @@
import type { HubCommandEnvelope, HubReplyEnvelope } from "@cline/shared";
import {
isSessionNotFoundError,
SESSION_NOT_FOUND_ERROR_CODE,
} from "../../../runtime/host/runtime-host";
import {
asPlainRecord,
errorReply,
extractSessionId,
type HubTransportContext,
okReply,
} from "./context";
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.trim().length > 0
? value.trim()
: undefined;
}
function failureReply(
envelope: HubCommandEnvelope,
code: string,
error: unknown,
): HubReplyEnvelope {
const message = error instanceof Error ? error.message : String(error);
return errorReply(
envelope,
isSessionNotFoundError(error) ? SESSION_NOT_FOUND_ERROR_CODE : code,
message,
);
}
export async function handleSessionMcpAppsResolveTool(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
): Promise<HubReplyEnvelope> {
const sessionId = extractSessionId(envelope);
const toolName = readString(envelope.payload?.toolName);
if (!sessionId || !toolName) {
return errorReply(
envelope,
"invalid_mcp_apps_resolve_tool",
"session.mcp_apps.resolve_tool requires a sessionId and toolName",
);
}
const resolveMcpAppTool = ctx.sessionHost.resolveMcpAppTool;
if (!resolveMcpAppTool) {
return errorReply(
envelope,
"unsupported_command",
"This runtime does not support MCP Apps.",
);
}
try {
const binding = await resolveMcpAppTool.call(
ctx.sessionHost,
sessionId,
toolName,
);
return okReply(envelope, binding ? { binding } : {});
} catch (error) {
return failureReply(envelope, "mcp_apps_resolve_tool_failed", error);
}
}
export async function handleSessionMcpAppsReadResource(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
): Promise<HubReplyEnvelope> {
const sessionId = extractSessionId(envelope);
const serverName = readString(envelope.payload?.serverName);
const uri = readString(envelope.payload?.uri);
if (!sessionId || !serverName || !uri) {
return errorReply(
envelope,
"invalid_mcp_apps_read_resource",
"session.mcp_apps.read_resource requires a sessionId, serverName, and uri",
);
}
const readMcpAppResource = ctx.sessionHost.readMcpAppResource;
if (!readMcpAppResource) {
return errorReply(
envelope,
"unsupported_command",
"This runtime does not support MCP Apps.",
);
}
try {
const result = await readMcpAppResource.call(ctx.sessionHost, sessionId, {
serverName,
uri,
});
return okReply(envelope, { result });
} catch (error) {
return failureReply(envelope, "mcp_apps_read_resource_failed", error);
}
}
export async function handleSessionMcpAppsCallTool(
ctx: HubTransportContext,
envelope: HubCommandEnvelope,
): Promise<HubReplyEnvelope> {
const sessionId = extractSessionId(envelope);
const serverName = readString(envelope.payload?.serverName);
const toolName = readString(envelope.payload?.toolName);
const rawArguments = envelope.payload?.arguments;
const toolArguments = asPlainRecord(rawArguments);
if (
!sessionId ||
!serverName ||
!toolName ||
(rawArguments !== undefined && !toolArguments)
) {
return errorReply(
envelope,
"invalid_mcp_apps_call_tool",
"session.mcp_apps.call_tool requires a sessionId, serverName, toolName, and object arguments",
);
}
const callMcpAppTool = ctx.sessionHost.callMcpAppTool;
if (!callMcpAppTool) {
return errorReply(
envelope,
"unsupported_command",
"This runtime does not support MCP Apps.",
);
}
try {
const result = await callMcpAppTool.call(ctx.sessionHost, sessionId, {
serverName,
toolName,
...(toolArguments ? { arguments: toolArguments } : {}),
});
return okReply(envelope, { result });
} catch (error) {
return failureReply(envelope, "mcp_apps_call_tool_failed", error);
}
}
@@ -79,6 +79,11 @@ import {
type PendingApproval,
type PendingCapabilityRequest,
} from "./handlers/context";
import {
handleSessionMcpAppsCallTool,
handleSessionMcpAppsReadResource,
handleSessionMcpAppsResolveTool,
} from "./handlers/mcp-apps-handlers";
import {
handleRunAbort,
handleRunProceedWhileRunning,
@@ -888,6 +893,12 @@ export class HubServerTransport implements NativeHubTransport {
return await handleSessionUpdate(this.ctx, envelope);
case "session.update_connection":
return await handleSessionUpdateConnection(this.ctx, envelope);
case "session.mcp_apps.resolve_tool":
return await handleSessionMcpAppsResolveTool(this.ctx, envelope);
case "session.mcp_apps.read_resource":
return await handleSessionMcpAppsReadResource(this.ctx, envelope);
case "session.mcp_apps.call_tool":
return await handleSessionMcpAppsCallTool(this.ctx, envelope);
case "session.compaction.update":
return await handleSessionCompactionUpdate(this.ctx, envelope);
case "session.pending_prompts":
+6
View File
@@ -320,11 +320,13 @@ export {
authorizeMcpServerOAuth,
type CreateDisabledMcpToolPoliciesOptions,
type CreateDisabledMcpToolPolicyOptions,
type CreateMcpAppsRuntimeOptions,
type CreateMcpToolsOptions,
createDefaultMcpServerClientFactory,
createDisabledMcpToolPolicies,
createDisabledMcpToolPolicy,
createMcpAppsClientCapabilities,
createMcpAppsRuntime,
createMcpTools,
DEFAULT_MCP_CONNECT_TIMEOUT_MS,
type DefaultMcpServerClientFactoryOptions,
@@ -341,6 +343,10 @@ export {
loadMcpSettingsFile,
MCP_APP_MIME_TYPE,
MCP_APPS_EXTENSION_ID,
type McpAppResourceReadRequest,
type McpAppsRuntime,
type McpAppToolBinding,
type McpAppToolCallRequest,
type McpAppToolMeta,
type McpAppToolVisibility,
type McpConnectionStatus,
@@ -360,6 +360,110 @@ describe("LocalRuntimeHost", () => {
}
});
it("routes MCP Apps requests to the session runtime", async () => {
const mcpApps = {
resolveTool: vi.fn().mockResolvedValue({
serverName: "weather",
toolName: "show_weather",
resourceUri: "ui://weather/view.html",
}),
readResource: vi.fn().mockResolvedValue({ contents: [] }),
callTool: vi.fn().mockResolvedValue({ content: [] }),
};
const runtimeBuilder = {
build: vi
.fn()
.mockReturnValueOnce({
tools: [],
mcpApps,
shutdown: vi.fn().mockResolvedValue(undefined),
})
.mockReturnValueOnce({
tools: [],
shutdown: vi.fn().mockResolvedValue(undefined),
}),
};
const agent = {
run: vi.fn().mockResolvedValue(createResult()),
continue: vi.fn().mockResolvedValue(createResult()),
getMessages: vi.fn().mockReturnValue([]),
getAgentId: vi.fn().mockReturnValue("agent-mcp-apps"),
getConversationId: vi.fn().mockReturnValue("conv-mcp-apps"),
abort: vi.fn(),
subscribeEvents: vi.fn().mockReturnValue(() => {}),
canStartRun: vi.fn().mockReturnValue(true),
shutdown: vi.fn().mockResolvedValue(undefined),
};
const manager = new RuntimeHostUnderTest({
distinctId,
sessionService: new FileSessionService(
join(isolatedHomeDir, "sessions-mcp-apps"),
),
runtimeBuilder: runtimeBuilder as never,
createAgent: () => agent as never,
});
const config = {
providerId: "mock-provider",
modelId: "mock-model",
systemPrompt: "You are a test agent",
cwd: isolatedHomeDir,
enableTools: false,
enableSpawnAgent: false,
enableAgentTeams: false,
};
try {
const withApps = await manager.startSession({
config: { ...config, sessionId: "with-apps", mcpApps: true },
});
const withoutApps = await manager.startSession({
config: { ...config, sessionId: "without-apps" },
});
expect(runtimeBuilder.build).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
config: expect.objectContaining({ mcpApps: true }),
}),
);
await expect(
manager.resolveMcpAppTool(withApps.sessionId, "weather__show_weather"),
).resolves.toMatchObject({ resourceUri: "ui://weather/view.html" });
await manager.readMcpAppResource(withApps.sessionId, {
serverName: "weather",
uri: "ui://weather/view.html",
});
await manager.callMcpAppTool(withApps.sessionId, {
serverName: "weather",
toolName: "refresh_view",
});
expect(mcpApps.resolveTool).toHaveBeenCalledWith("weather__show_weather");
expect(mcpApps.readResource).toHaveBeenCalledWith({
serverName: "weather",
uri: "ui://weather/view.html",
});
expect(mcpApps.callTool).toHaveBeenCalledWith({
serverName: "weather",
toolName: "refresh_view",
});
await expect(
manager.resolveMcpAppTool(
withoutApps.sessionId,
"weather__show_weather",
),
).resolves.toBeUndefined();
await expect(
manager.callMcpAppTool(withoutApps.sessionId, {
serverName: "weather",
toolName: "refresh_view",
}),
).rejects.toThrow("MCP Apps is not enabled");
} finally {
await manager.dispose();
}
});
it("stores git under metadata and refreshes it after an active turn", async () => {
const workspaceRoot = join(isolatedHomeDir, "workspace");
mkdirSync(workspaceRoot, { recursive: true });
@@ -21,6 +21,14 @@ import {
createContextCompactionPrepareTurn,
createImportedHistoryCompactionPrepareTurn,
} from "../../extensions/context/compaction";
import type {
McpAppResourceReadRequest,
McpAppsRuntime,
McpAppToolBinding,
McpAppToolCallRequest,
McpResourceReadResult,
McpToolCallResult,
} from "../../extensions/mcp";
import {
formatSessionPluginIssue,
isPluginCommand,
@@ -1350,6 +1358,37 @@ export class LocalRuntimeHost implements RuntimeHost {
}
}
async resolveMcpAppTool(
sessionId: string,
toolName: string,
): Promise<McpAppToolBinding | undefined> {
return this.getSessionOrThrow(sessionId).runtime.mcpApps?.resolveTool(
toolName,
);
}
async readMcpAppResource(
sessionId: string,
request: McpAppResourceReadRequest,
): Promise<McpResourceReadResult> {
return this.requireMcpApps(sessionId).readResource(request);
}
async callMcpAppTool(
sessionId: string,
request: McpAppToolCallRequest,
): Promise<McpToolCallResult> {
return this.requireMcpApps(sessionId).callTool(request);
}
private requireMcpApps(sessionId: string): McpAppsRuntime {
const mcpApps = this.getSessionOrThrow(sessionId).runtime.mcpApps;
if (!mcpApps) {
throw new Error(`MCP Apps is not enabled for session "${sessionId}".`);
}
return mcpApps;
}
async proceedWhileRunning(
sessionId: string,
toolCallId?: string,
@@ -4,6 +4,13 @@ import type {
AgentResult,
RuntimeConfigExtensionKind,
} from "@cline/shared";
import type {
McpAppResourceReadRequest,
McpAppToolBinding,
McpAppToolCallRequest,
McpResourceReadResult,
McpToolCallResult,
} from "../../extensions/mcp";
import type { HookEventPayload } from "../../hooks";
import type { CheckpointEntry } from "../../hooks/checkpoint-hooks";
import type { ProviderSettings } from "../../services/llms/provider-settings";
@@ -343,6 +350,21 @@ export interface SessionConnectionRuntimeService {
): Promise<void>;
}
export interface McpAppsRuntimeService {
resolveMcpAppTool(
sessionId: string,
toolName: string,
): Promise<McpAppToolBinding | undefined>;
readMcpAppResource(
sessionId: string,
request: McpAppResourceReadRequest,
): Promise<McpResourceReadResult>;
callMcpAppTool(
sessionId: string,
request: McpAppToolCallRequest,
): Promise<McpToolCallResult>;
}
export interface CommandExecutionRuntimeService {
proceedWhileRunning(sessionId: string, toolCallId?: string): Promise<number>;
}
@@ -584,6 +584,113 @@ process.stdin.on("data", (chunk) => {
}
});
it("exposes MCP Apps for configured servers when mcpApps is enabled", async () => {
const tempRoot = mkdtempSync(join(tmpdir(), "runtime-builder-mcp-apps-"));
tempDirs.push(tempRoot);
const serverPath = join(tempRoot, "apps-mcp-server.js");
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
const previousSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
writeFileSync(
serverPath,
`let buffer = "";
let capabilities;
function write(payload) { process.stdout.write(JSON.stringify(payload) + "\\n"); }
process.stdin.on("data", (chunk) => {
buffer += chunk.toString("utf8");
let newline;
while ((newline = buffer.indexOf("\\n")) >= 0) {
const line = buffer.slice(0, newline).trim();
buffer = buffer.slice(newline + 1);
if (!line) continue;
const message = JSON.parse(line);
if (message.method === "notifications/initialized") continue;
if (message.method === "initialize") {
capabilities = message.params.capabilities;
write({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {}, resources: {} }, serverInfo: { name: "apps", version: "1.0.0" } } });
}
if (message.method === "tools/list") write({ jsonrpc: "2.0", id: message.id, result: { tools: [
{ name: "show_weather", inputSchema: { type: "object" }, _meta: { ui: { resourceUri: "ui://weather/view.html" } } },
{ name: "refresh_view", inputSchema: { type: "object" }, _meta: { ui: { visibility: ["app"] } } }
] } });
if (message.method === "tools/call") write({ jsonrpc: "2.0", id: message.id, result: { content: [{ type: "text", text: JSON.stringify({ name: message.params.name, capabilities }) }] } });
if (message.method === "resources/read") write({ jsonrpc: "2.0", id: message.id, result: { contents: [{ uri: message.params.uri, mimeType: "text/html;profile=mcp-app", text: "<html></html>" }] } });
}
});`,
"utf8",
);
writeFileSync(
settingsPath,
JSON.stringify({
mcpServers: {
weather: { command: process.execPath, args: [serverPath] },
},
}),
"utf8",
);
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
let runtime:
| Awaited<ReturnType<DefaultRuntimeBuilder["build"]>>
| undefined;
try {
runtime = await new DefaultRuntimeBuilder().build({
config: makeBaseConfig({ mcpApps: true }),
});
const toolNames = runtime.tools.map((tool) => tool.name);
expect(toolNames).toContain("weather__show_weather");
expect(toolNames).not.toContain("weather__refresh_view");
const mcpApps = runtime.mcpApps;
if (!mcpApps) {
throw new Error("Expected the MCP Apps runtime.");
}
await expect(
mcpApps.resolveTool("weather__show_weather"),
).resolves.toEqual({
serverName: "weather",
toolName: "show_weather",
resourceUri: "ui://weather/view.html",
});
await expect(
mcpApps.readResource({
serverName: "weather",
uri: "ui://weather/view.html",
}),
).resolves.toMatchObject({
contents: [{ mimeType: "text/html;profile=mcp-app" }],
});
const result = (await mcpApps.callTool({
serverName: "weather",
toolName: "refresh_view",
})) as { content: Array<{ text: string }> };
expect(JSON.parse(result.content[0].text)).toEqual({
name: "refresh_view",
capabilities: {
extensions: {
"io.modelcontextprotocol/ui": {
mimeTypes: ["text/html;profile=mcp-app"],
},
},
},
});
} finally {
await runtime?.shutdown("test");
process.env.CLINE_MCP_SETTINGS_PATH = previousSettingsPath;
}
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
try {
runtime = await new DefaultRuntimeBuilder().build({
config: makeBaseConfig(),
});
expect(runtime.mcpApps).toBeUndefined();
} finally {
await runtime?.shutdown("test");
process.env.CLINE_MCP_SETTINGS_PATH = previousSettingsPath;
}
});
it("combines hub-owned Agent Plugin skills and MCP servers with client instructions", async () => {
const tempRoot = realpathSync.native(
mkdtempSync(join(tmpdir(), "runtime-builder-agent-plugin-")),
@@ -20,9 +20,11 @@ import {
} from "../../extensions/config";
import {
createDefaultMcpServerClientFactory,
createMcpAppsRuntime,
createMcpTools,
hasMcpSettingsFile,
InMemoryMcpManager,
type McpAppsRuntime,
registerMcpServersFromSettingsFile,
resolveDefaultMcpSettingsPath,
} from "../../extensions/mcp";
@@ -207,9 +209,11 @@ const SKILLS_PROBE_EXECUTOR = (async () => "") as SkillsExecutorWithMetadata;
async function loadConfiguredMcpTools(options: {
logger?: BasicLogger;
includeSettings: boolean;
mcpApps: boolean;
agentPluginServers?: ReadonlyArray<AgentPluginPackageMcpServer>;
}): Promise<{
tools: AgentTool[];
mcpApps?: McpAppsRuntime;
shutdown?: () => Promise<void>;
}> {
const settingsPath = resolveDefaultMcpSettingsPath();
@@ -221,9 +225,11 @@ async function loadConfiguredMcpTools(options: {
const settingsClientFactory = createDefaultMcpServerClientFactory({
settingsPath,
mcpApps: options.mcpApps,
});
const agentPluginClientFactory = createDefaultMcpServerClientFactory({
restrictConfiguredHeadersToOrigin: true,
mcpApps: options.mcpApps,
});
const manager = new InMemoryMcpManager({
clientFactory: (registration) =>
@@ -289,9 +295,11 @@ async function loadConfiguredMcpTools(options: {
),
);
const tools: AgentTool[] = [];
const loadedServerNames: string[] = [];
for (const [i, result] of results.entries()) {
if (result.status === "fulfilled") {
tools.push(...result.value);
loadedServerNames.push(enabled[i].name);
} else {
const message =
result.reason instanceof Error
@@ -305,6 +313,14 @@ async function loadConfiguredMcpTools(options: {
return {
tools,
...(options.mcpApps
? {
mcpApps: createMcpAppsRuntime({
manager,
serverNames: loadedServerNames,
}),
}
: {}),
shutdown: async () => {
await manager.dispose();
},
@@ -359,6 +375,7 @@ function normalizeConfig(
| "enableSpawnAgent"
| "enableAgentTeams"
| "disableMcpSettingsTools"
| "mcpApps"
| "yolo"
| "missionLogIntervalSteps"
| "missionLogIntervalMs"
@@ -376,6 +393,7 @@ function normalizeConfig(
enableAgentTeams:
config.enableAgentTeams ?? preset.enableAgentTeams ?? true,
disableMcpSettingsTools: config.disableMcpSettingsTools === true,
mcpApps: config.mcpApps === true,
yolo: config.yolo === true,
missionLogIntervalSteps:
typeof config.missionLogIntervalSteps === "number" &&
@@ -463,6 +481,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
const ownedUserInstructionServices: UserInstructionConfigService[] = [];
let userInstructionService = sharedUserInstructionService;
let mcpShutdown: (() => Promise<void>) | undefined;
let mcpApps: McpAppsRuntime | undefined;
for (const error of configuredAgents.errors) {
(logger ?? config.logger)?.log?.(
@@ -590,10 +609,12 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
const mcpRuntime = await loadConfiguredMcpTools({
logger: config.logger,
includeSettings: !normalized.disableMcpSettingsTools,
mcpApps: normalized.mcpApps,
agentPluginServers: agentPluginMcpServers,
});
tools.push(...mcpRuntime.tools);
mcpShutdown = mcpRuntime.shutdown;
mcpApps = mcpRuntime.mcpApps;
}
}
@@ -875,6 +896,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
?.delegatedAgentConfigProvider ?? delegatedAgentConfigProvider,
extensions: runtimeExtensions,
completionPolicy,
mcpApps,
registerLeadAgent: (agent) => {
leadAgentInstance = agent;
if (pendingLeadTeamTools.length > 0) {
@@ -16,6 +16,7 @@ import type {
AgentPluginPackageSkill,
} from "../../extensions/agent-plugin";
import type { UserInstructionConfigService } from "../../extensions/config";
import type { McpAppsRuntime } from "../../extensions/mcp";
import type {
RunCommandExecutionController,
ToolExecutors,
@@ -55,6 +56,7 @@ export interface BuiltRuntime {
extensions?: AgentConfig["extensions"];
completionPolicy?: AgentConfig["completionPolicy"];
registerLeadAgent?: (agent: LeadAgentHandle) => void;
mcpApps?: McpAppsRuntime;
shutdown: (reason: string) => Promise<void> | void;
}
+1
View File
@@ -56,6 +56,7 @@ export interface CoreRuntimeFeatures {
enableSpawnAgent: boolean;
enableAgentTeams: boolean;
disableMcpSettingsTools?: boolean;
mcpApps?: boolean;
yolo?: boolean;
}
+3
View File
@@ -641,6 +641,9 @@ export type HubCommandName =
| "session.delete"
| "session.update"
| "session.update_connection"
| "session.mcp_apps.resolve_tool"
| "session.mcp_apps.read_resource"
| "session.mcp_apps.call_tool"
| "session.compaction.get"
| "session.compaction.update"
| "session.pending_prompts"