mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
Wire MCP Apps session runtime through the hub [No-op]
This commit is contained in:
@@ -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
@@ -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`
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ export interface CoreRuntimeFeatures {
|
||||
enableSpawnAgent: boolean;
|
||||
enableAgentTeams: boolean;
|
||||
disableMcpSettingsTools?: boolean;
|
||||
mcpApps?: boolean;
|
||||
yolo?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user