From 81dfd78a59044d22c4523cf9d3ff3bfebabe02a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:27:02 +0000 Subject: [PATCH 1/9] feat(harness-sdk): host MCP Apps views for remote MCP tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A remote tool's `_meta` no longer stops at discovery, and a call's answer is no longer flattened before a surface can render it. The offered tool carries `serverToolName`, the name a view calls the tool by, and `uiResourceUri`, the view the server attached, when it attached one. Neither is in `definition`, so the model is told nothing new. `call` answers the whole result — the text the model reads, plus the server's content and structured content — and `deps.onCallResult` reports each answer, because a view renders what the text drops. `remoteMcpApp(server, deps, { tools, context, openLink })` is the host half of one view, over the official `AppBridge`: it answers the handshake with the host's identity, capabilities and context, sends the call's input and result, reports size changes, opens links, and forwards a view's `tools/call` through the same client a model's call uses, refusing any name its own server was not discovered to offer. Sampling, model-context updates, messages and downloads are not advertised. The surface supplies one transport that writes text into its sandbox and hands the text back through `receive`; it names no method. Proof: the remote-mcp suites drive a real protocol server through the bridge — the handshake, the context, initialization, size changes, the call's input and result, a callback to the same server, a refused callback, a failed call, and an opened link — plus the offered tool's view and the answer reported to the surface. --- packages/harness-sdk/.oxlintrc.json | 1 + packages/harness-sdk/README.md | 31 ++- .../src/plugins/remote-mcp/app-contract.ts | 96 +++++++ .../harness-sdk/src/plugins/remote-mcp/app.ts | 240 +++++++++++++++++ .../src/plugins/remote-mcp/client.ts | 35 ++- .../src/plugins/remote-mcp/deps.ts | 24 +- .../src/plugins/remote-mcp/index.ts | 23 +- .../plugins/remote-mcp/remote-mcp-app.test.ts | 253 ++++++++++++++++++ .../plugins/remote-mcp/remote-mcp-fixture.ts | 5 +- .../remote-mcp/remote-mcp-offered.test.ts | 75 ++++++ .../src/plugins/remote-mcp/tools.ts | 54 +++- 11 files changed, 815 insertions(+), 22 deletions(-) create mode 100644 packages/harness-sdk/src/plugins/remote-mcp/app-contract.ts create mode 100644 packages/harness-sdk/src/plugins/remote-mcp/app.ts create mode 100644 packages/harness-sdk/src/plugins/remote-mcp/remote-mcp-app.test.ts create mode 100644 packages/harness-sdk/src/plugins/remote-mcp/remote-mcp-offered.test.ts diff --git a/packages/harness-sdk/.oxlintrc.json b/packages/harness-sdk/.oxlintrc.json index 36950393450..6064637452a 100644 --- a/packages/harness-sdk/.oxlintrc.json +++ b/packages/harness-sdk/.oxlintrc.json @@ -83,6 +83,7 @@ "src/plugins/remote-mcp/remote-mcp.test.ts", "src/plugins/remote-mcp/remote-mcp-fixture.ts", "src/plugins/remote-mcp/remote-mcp-oauth-fixture.ts", + "src/plugins/remote-mcp/app.ts", "src/plugins/conformance.test.ts", "src/core/resume-fixture.ts", "src/core/image.test.ts" diff --git a/packages/harness-sdk/README.md b/packages/harness-sdk/README.md index 14ec73eba1a..716476cc9d2 100644 --- a/packages/harness-sdk/README.md +++ b/packages/harness-sdk/README.md @@ -712,10 +712,31 @@ tool's `_meta`, and the host renders it beside the conversation. Discovery carries each tool's `_meta` as it stands, and `uiResourceUriOf(tool)` reads the uri out of it — the nested form the Apps specification defines, with the deprecated flat `_meta['ui/resourceUri']` beside it — or answers `undefined` -when the tool names no view. `remoteMcpClient(...).resource(uri)` reads the -resource off the same server, under the same deadline and credential rules as a -call, and answers its first content: the `mimeType` and, for an app, the HTML -in `text`. The read of the metadata is the official +when the tool names no view. An offered tool carries what a surface drawing the +call needs and the model does not: `uiResourceUri`, and `serverToolName`, which +is the name the view calls the tool by. Neither is in `definition`, so nothing +new sits in the cached prefix. + +`remoteMcpClient(...).resource(uri)` reads the resource off the same server, +under the same deadline and credential rules as a call, and answers its first +content: the `mimeType` and, for an app, the HTML in `text`, with the content's +`_meta` carrying the policy the document is to be held to. `call` answers the +whole result — the `text` the model reads, plus the server's `content` and +`structuredContent` — and `deps.onCallResult` is told what it answered, because +a view renders those and they do not survive the text. Nothing in the harness +keeps them. + +`remoteMcpApp(server, deps, { tools, context, openLink })` is the host half of +one view. It builds the official `AppBridge`, answers initialization with the +host's identity, capabilities and context, sends the call's input and result, +reports size changes, opens links, and forwards a view's `tools/call` through +the same client a model's call uses — refusing any name its own server was not +discovered to offer. The surface supplies one transport (`send`) and hands the +bytes that arrive back through `receive`; it names no method. Sampling, model +context updates, messages and downloads are not advertised, so a view does not +offer them. + +The View side of the Apps metadata is the official `@modelcontextprotocol/ext-apps` bridge package, pinned to the 1.x line that speaks this package's `@modelcontextprotocol/sdk`; its 2.x peers on the v2 SDK split and moves when the client does. @@ -730,7 +751,7 @@ split and moves when the client does. | `@kilocode/harness-sdk/plugins/gateway` | The gateway plugin on its own | | `@kilocode/harness-sdk/plugins/remote-model` | Direct Chat Completions, Responses and Messages: `remoteModelClient`, `layerRemoteModel` | | `@kilocode/harness-sdk/plugins/prompt` | The assembler on its own | -| `@kilocode/harness-sdk/plugins/remote-mcp` | Remote MCP servers: `remoteMcpTools`, `uiResourceUriOf`, and the Streamable HTTP client under it | +| `@kilocode/harness-sdk/plugins/remote-mcp` | Remote MCP servers: `remoteMcpTools`, `uiResourceUriOf`, `remoteMcpApp`, and the Streamable HTTP client under them | | `@kilocode/harness-sdk/plugins/tools` | The tools the package ships | | `@kilocode/harness-sdk/plugins/store/node` | The store on `node:sqlite` | | `@kilocode/harness-sdk/plugins/store/expo` | The store on `expo-sqlite` | diff --git a/packages/harness-sdk/src/plugins/remote-mcp/app-contract.ts b/packages/harness-sdk/src/plugins/remote-mcp/app-contract.ts new file mode 100644 index 00000000000..96716379ecb --- /dev/null +++ b/packages/harness-sdk/src/plugins/remote-mcp/app-contract.ts @@ -0,0 +1,96 @@ +import type { Implementation } from '@modelcontextprotocol/sdk/types.js'; +import { type RemoteMcpCallResult } from './client.js'; + +/** + * The contract between the bridge and the surface that draws it. + * + * The surface owns a WebView and the React around it; it hands the bridge one + * transport, receives the text the view sent, and drives the view's lifecycle. + * It names no protocol method, so this is the whole of what it must implement. + */ + +/** + * A message the bridge and the view exchange, as the surface carries it. + * + * The surface moves the text and does not read it: a `send` writes the message + * into the sandbox as JSON, and the text that arrives from it is handed back + * through `receive`. The shape is deliberately opaque so no surface + * re-implements the protocol. + */ +interface RemoteMcpAppTransport { + /** Sends one bridge message to the view, as a JSON string. */ + send(message: string): void; +} + +/** How big the view currently draws itself, which is what the surface sizes to. */ +interface RemoteMcpAppSize { + readonly width?: number | undefined; + readonly height?: number | undefined; +} + +/** + * What the view is told about where it runs: the person's own theme and + * language, so a view that follows them looks like the app around it, and the + * box the surface drew, so it lays itself out once instead of after a resize. + */ +interface RemoteMcpAppContext { + readonly theme?: 'light' | 'dark'; + readonly locale?: string; + readonly timeZone?: string; + readonly maxWidth?: number; + readonly maxHeight?: number; +} + +interface RemoteMcpAppOptions { + /** + * The tools this server was discovered to offer, by the server's own names — + * which are the names a view calls them by. A name outside this set is not + * forwarded. + */ + readonly tools: readonly string[]; + readonly context?: RemoteMcpAppContext; + /** What the view is told the host is. */ + readonly host?: Implementation; + /** Opens a link the view asked for. The caller validates the scheme. */ + readonly openLink?: (uri: string) => void; +} + +/** + * One view, as the surface drives it: connect when the sandbox is ready, hand + * it the call when it has initialized, and close it when the row goes away. + */ +interface RemoteMcpApp { + /** Starts the bridge over a transport that writes into the sandbox. */ + readonly connect: (transport: RemoteMcpAppTransport) => Promise; + /** Hands the bridge one message the view sent, as the text it sent. */ + readonly receive: (text: string) => void; + /** The call's complete arguments, sent once the view has initialized. */ + readonly input: (arguments_: Readonly>) => Promise; + /** The call's answer, sent after `input`. */ + readonly result: (answered: RemoteMcpAppResult) => Promise; + /** Told when the view has completed its initialization. */ + readonly onInitialized: (listener: () => void) => () => void; + /** Told every time the view reports a size. */ + readonly onSizeChanged: (listener: (size: RemoteMcpAppSize) => void) => () => void; + /** Tears the view down and closes the bridge. */ + readonly close: () => Promise; +} + +/** + * What a surface holds for one answered call: the server's own content, which + * is what a view renders. The text the model read is the surface's to keep, not + * the bridge's to need. + */ +interface RemoteMcpAppResult { + readonly content: RemoteMcpCallResult['content']; + readonly structuredContent: RemoteMcpCallResult['structuredContent']; +} + +export type { + RemoteMcpApp, + RemoteMcpAppContext, + RemoteMcpAppOptions, + RemoteMcpAppResult, + RemoteMcpAppSize, + RemoteMcpAppTransport, +}; diff --git a/packages/harness-sdk/src/plugins/remote-mcp/app.ts b/packages/harness-sdk/src/plugins/remote-mcp/app.ts new file mode 100644 index 00000000000..8c7a7f53044 --- /dev/null +++ b/packages/harness-sdk/src/plugins/remote-mcp/app.ts @@ -0,0 +1,240 @@ +import { + AppBridge, + type McpUiHostCapabilities, + type McpUiHostContext, +} from '@modelcontextprotocol/ext-apps/app-bridge'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import { + type CallToolRequest, + type CallToolResult, + type JSONRPCMessage, + JSONRPCMessageSchema, +} from '@modelcontextprotocol/sdk/types.js'; +import { Effect } from 'effect'; +import type { + RemoteMcpApp, + RemoteMcpAppContext, + RemoteMcpAppOptions, + RemoteMcpAppResult, + RemoteMcpAppTransport, +} from './app-contract.js'; +import { type RemoteMcpClient, remoteMcpClient } from './client.js'; +import { type RemoteMcpClientDeps } from './deps.js'; +import { explain, type RemoteMcpServer } from './server.js'; + +/** + * The host half of one MCP Apps view. + * + * A server may attach a `ui://` resource to a tool; the resource is HTML that + * runs on the person's device with the call in its hands and can call that + * server's tools back. The bridge between the two is the official `AppBridge`, + * from the package the plugin reads a tool's view uri with, so the protocol — + * initialization, tool input and result, size changes, link opens, tool calls + * — is the specification's and not this file's. + * + * This file holds the semantics a view is entitled to and the surface that + * draws it cannot be trusted with: + * + * - A view's tool call reaches the server through the same client a model's + * call uses, so the credential accessor, the deadline and the failure + * classification are the same, and a call that did not reach the server is + * reported to the surface exactly as a model's call would be. + * - A view may only call the tools of its own server, by the names that server + * was discovered to offer. Anything else is refused before a request exists. + * - Nothing the view asks for beyond a tool call, a link open or a log line is + * answered: those requests get the protocol's method-not-found rather than + * an invented capability. + * + * The surface draws the WebView, sandboxes it, and carries bytes in and out as + * text. It never names a method here. + */ + +/** + * A host that answers tools, opens links and accepts logs. Sampling, + * model-context updates, messages, downloads and display-mode changes are not + * offered, so a view does not show them. + */ +const HOST_CAPABILITIES: McpUiHostCapabilities = { + openLinks: {}, + serverTools: {}, + logging: {}, +}; + +/** How long a closing surface waits for the view's own teardown. */ +const TEARDOWN_TIMEOUT_MS = 1000; + +/** The call's answer, as the protocol defines it, for the view. */ +const callToolResultOf = (answered: RemoteMcpAppResult): CallToolResult => + answered.structuredContent === undefined + ? { content: [...answered.content] } + : { content: [...answered.content], structuredContent: answered.structuredContent }; + +/** What the view is told about where it runs. */ +const hostContextOf = (context: RemoteMcpAppContext | undefined): McpUiHostContext => ({ + displayMode: 'inline', + availableDisplayModes: ['inline'], + platform: 'mobile', + deviceCapabilities: { touch: true, hover: false }, + ...(context?.theme === undefined ? {} : { theme: context.theme }), + ...(context?.locale === undefined ? {} : { locale: context.locale }), + ...(context?.timeZone === undefined ? {} : { timeZone: context.timeZone }), + containerDimensions: { + ...(context?.maxWidth === undefined ? {} : { maxWidth: context.maxWidth }), + ...(context?.maxHeight === undefined ? {} : { maxHeight: context.maxHeight }), + }, +}); + +/** + * The handler for a view's `tools/call`. + * + * A name the server was not discovered to offer is refused before a request + * exists, which is what keeps one server's view from reaching another. The + * call itself is the client's, so it carries the same credential and the same + * deadline as a model's. A failure comes back as a failed result rather than a + * transport error: the view that asked decides what to show, exactly as the + * model decides what to do next. + */ +const callerFor = + (server: RemoteMcpServer, client: RemoteMcpClient, tools: readonly string[]) => + async (params: CallToolRequest['params']): Promise => { + if (!tools.includes(params.name)) { + throw new Error(`${params.name} is not a tool of ${server.name}.`); + } + return Effect.runPromise( + client.call(params.name, params.arguments ?? {}).pipe( + Effect.map(callToolResultOf), + Effect.catchAll(failure => + Effect.succeed({ + content: [{ type: 'text' as const, text: explain(failure) }], + isError: true, + }) + ) + ) + ); + }; + +/** + * The message one text holds, or nothing when it holds none. + * + * The surface carries what the sandbox posted as text, and a page that is not + * the protocol is not a message: it is dropped rather than thrown at the + * person's screen. + */ +const messageOf = (text: string): JSONRPCMessage | undefined => { + try { + const message = JSONRPCMessageSchema.safeParse(JSON.parse(text) as unknown); + return message.success ? message.data : undefined; + } catch { + return undefined; + } +}; + +/** + * The bridge's transport over a surface that writes text. + * + * The bridge assigns `onmessage` and calls `start` when it connects; the text + * the surface carries in is parsed with the protocol's own schema and handed + * back through that assignment. Text that arrives before the bridge listens, or + * that is not a message, is dropped — which is why the surface connects before + * it lets the view initialize. + */ +const transportOver = ( + host: RemoteMcpAppTransport +): { transport: Transport; receive: (text: string) => void } => { + const held: { listener?: Transport['onmessage'] } = {}; + const transport: Transport = { + start: async () => {}, + send: async message => { + host.send(JSON.stringify(message)); + }, + close: async () => {}, + }; + Object.defineProperty(transport, 'onmessage', { + configurable: true, + enumerable: true, + get: () => held.listener, + set: (next: Transport['onmessage']) => { + held.listener = next; + }, + }); + return { + transport, + receive: text => { + const message = messageOf(text); + if (message !== undefined) { + held.listener?.(message); + } + }, + }; +}; + +/** The surface-facing half of one bridge: everything the WebView drives. */ +const surfaceOf = (bridge: AppBridge): RemoteMcpApp => { + const deliver: { listening: (text: string) => void } = { listening: () => {} }; + return { + connect: async host => { + const bridged = transportOver(host); + deliver.listening = bridged.receive; + await bridge.connect(bridged.transport); + }, + receive: text => { + deliver.listening(text); + }, + input: arguments_ => bridge.sendToolInput({ arguments: arguments_ }), + result: answered => bridge.sendToolResult(callToolResultOf(answered)), + onInitialized: listener => { + bridge.addEventListener('initialized', listener); + return () => { + bridge.removeEventListener('initialized', listener); + }; + }, + onSizeChanged: listener => { + const heard = (size: { width?: number; height?: number }) => { + listener({ width: size.width, height: size.height }); + }; + bridge.addEventListener('sizechange', heard); + return () => { + bridge.removeEventListener('sizechange', heard); + }; + }, + close: async () => { + try { + await bridge.teardownResource({}, { timeout: TEARDOWN_TIMEOUT_MS }); + } catch { + // The view may already be gone; the surface tears its sandbox down. + } + await bridge.close(); + }, + }; +}; + +/** A bridge for one server's view over the client a model's calls use. */ +const remoteMcpApp = ( + server: RemoteMcpServer, + deps: RemoteMcpClientDeps, + options: RemoteMcpAppOptions +): RemoteMcpApp => { + const bridge = new AppBridge( + null, + options.host ?? { name: 'Kilo', version: '0' }, + HOST_CAPABILITIES, + { hostContext: hostContextOf(options.context) } + ); + bridge.oncalltool = callerFor(server, remoteMcpClient(server, deps), options.tools); + bridge.onopenlink = async params => { + options.openLink?.(params.url); + return {}; + }; + bridge.addEventListener('loggingmessage', () => {}); + return surfaceOf(bridge); +}; + +export type { + RemoteMcpApp, + RemoteMcpAppContext, + RemoteMcpAppOptions, + RemoteMcpAppResult, + RemoteMcpAppSize, + RemoteMcpAppTransport, +} from './app-contract.js'; +export { remoteMcpApp }; diff --git a/packages/harness-sdk/src/plugins/remote-mcp/client.ts b/packages/harness-sdk/src/plugins/remote-mcp/client.ts index fdbbe9e0cd3..c029791d7a8 100644 --- a/packages/harness-sdk/src/plugins/remote-mcp/client.ts +++ b/packages/harness-sdk/src/plugins/remote-mcp/client.ts @@ -1,4 +1,4 @@ -import { CallToolResultSchema } from '@modelcontextprotocol/sdk/types.js'; +import { CallToolResultSchema, type ContentBlock } from '@modelcontextprotocol/sdk/types.js'; import { Effect } from 'effect'; import { withServer, type Connection } from './connection.js'; import { type RemoteMcpClientDeps } from './deps.js'; @@ -28,11 +28,11 @@ interface RemoteMcpTool { interface RemoteMcpClient { /** The tools the server offers, as of this call. */ readonly tools: Effect.Effect; - /** One call, answered with the text the server sent. */ + /** One call, answered whole: what the model reads and what a view renders. */ readonly call: ( name: string, args: Readonly> - ) => Effect.Effect; + ) => Effect.Effect; /** One resource, read off the same server under the same deadline as a call. */ readonly resource: (uri: string) => Effect.Effect; } @@ -62,6 +62,23 @@ const discovered = ( const textOf = (content: readonly { readonly type: string; readonly text?: string }[]): string => content.flatMap(part => (typeof part.text === 'string' ? [part.text] : [])).join('\n'); +/** + * One call, answered whole. + * + * The model reads `text`, because a model reads words. `content` and + * `structuredContent` are the server's own answer, kept because a host that + * draws the call's MCP Apps view renders those and cannot get them back from + * the text: the view receives the tool result as the protocol defines it. + */ +interface RemoteMcpCallResult { + /** The text parts, joined: what the model reads. */ + readonly text: string; + /** The content blocks the server answered with, as it wrote them. */ + readonly content: readonly ContentBlock[]; + /** The structured result the server wrote, for a view that renders it. */ + readonly structuredContent: Readonly> | undefined; +} + /** One call, and what the server refused about it. */ interface Asked { readonly name: string; @@ -72,7 +89,7 @@ const called = ( connection: Connection, server: RemoteMcpServer, asked: Asked -): Effect.Effect => +): Effect.Effect => Effect.tryPromise({ try: async () => { const { name, args } = asked; @@ -87,7 +104,7 @@ const called = ( */ throw new RemoteMcpError({ serverId: server.id, kind: 'protocol', cause: text }); } - return text; + return { text, content: result.content, structuredContent: result.structuredContent }; }, catch: cause => (cause instanceof RemoteMcpError ? cause : failed(server, cause)), }); @@ -133,5 +150,11 @@ const remoteMcpClient = (server: RemoteMcpServer, deps: RemoteMcpClientDeps): Re ), }); -export type { RemoteMcpClient, RemoteMcpClientDeps, RemoteMcpResource, RemoteMcpTool }; +export type { + RemoteMcpCallResult, + RemoteMcpClient, + RemoteMcpClientDeps, + RemoteMcpResource, + RemoteMcpTool, +}; export { defaultCallTimeoutMs, remoteMcpClient }; diff --git a/packages/harness-sdk/src/plugins/remote-mcp/deps.ts b/packages/harness-sdk/src/plugins/remote-mcp/deps.ts index 23a3832c1ee..8a007e3ee31 100644 --- a/packages/harness-sdk/src/plugins/remote-mcp/deps.ts +++ b/packages/harness-sdk/src/plugins/remote-mcp/deps.ts @@ -1,4 +1,5 @@ import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js'; +import type { ContentBlock } from '@modelcontextprotocol/sdk/types.js'; import type { jsonSchemaValidator } from '@modelcontextprotocol/sdk/validation/types.js'; import type { RemoteMcpAbort, RemoteMcpRequest } from './http.js'; import type { RemoteMcpToken } from './credential.js'; @@ -48,6 +49,27 @@ interface RemoteMcpClientDeps { credential, or gone. A `protocol` failure is the server answering wrongly, so it is not one; a person's screen never sees the model's tool result. */ onCallFailure?: (error: RemoteMcpError) => void; + /** + * Told what a call answered, whole, after the model has been handed its text. + * A host that draws the call's MCP Apps view renders the server's own content + * and structured result, and neither survives the text the model reads, so + * the surface that draws the view is handed them here instead. Nothing in the + * harness keeps them. + */ + onCallResult?: (answered: RemoteMcpCallAnswered) => void; +} + +/** + * The answer a call received, as a surface that draws the call needs it: which + * tool and call it belongs to, and the server's own content. + */ +interface RemoteMcpCallAnswered { + /** The tool as the server names it, without the server prefix the model sees. */ + readonly tool: string; + /** The identifier of the model's call, which is what a transcript holds. */ + readonly callId: string; + readonly content: readonly ContentBlock[]; + readonly structuredContent: Readonly> | undefined; } -export type { RemoteMcpClientDeps }; +export type { RemoteMcpCallAnswered, RemoteMcpClientDeps }; diff --git a/packages/harness-sdk/src/plugins/remote-mcp/index.ts b/packages/harness-sdk/src/plugins/remote-mcp/index.ts index 45819c329a9..fe4659bf3c0 100644 --- a/packages/harness-sdk/src/plugins/remote-mcp/index.ts +++ b/packages/harness-sdk/src/plugins/remote-mcp/index.ts @@ -4,12 +4,23 @@ import type { OAuthClientMetadata, OAuthTokens, } from '@modelcontextprotocol/sdk/shared/auth.js'; +import { + remoteMcpApp, + type RemoteMcpApp, + type RemoteMcpAppContext, + type RemoteMcpAppOptions, + type RemoteMcpAppResult, + type RemoteMcpAppSize, + type RemoteMcpAppTransport, +} from './app.js'; import { remoteMcpClient, + type RemoteMcpCallResult, type RemoteMcpClient, type RemoteMcpClientDeps, type RemoteMcpTool, } from './client.js'; +import { type RemoteMcpCallAnswered } from './deps.js'; import { type RemoteMcpResource } from './resource.js'; import { remoteMcpSignIn, @@ -26,7 +37,7 @@ import { type RemoteMcpFailure, type RemoteMcpServer, } from './server.js'; -import { remoteMcpTools } from './tools.js'; +import { type RemoteMcpOfferedTool, remoteMcpTools } from './tools.js'; import { permissiveJsonSchemaValidator } from './validator.js'; /** @@ -51,10 +62,19 @@ export type { OAuthClientMetadata as RemoteMcpOAuthClientMetadata, OAuthClientProvider as RemoteMcpOAuthProvider, OAuthTokens as RemoteMcpOAuthTokens, + RemoteMcpApp, + RemoteMcpAppContext, + RemoteMcpAppOptions, + RemoteMcpAppResult, + RemoteMcpAppSize, + RemoteMcpAppTransport, RemoteMcpAuth, + RemoteMcpCallAnswered, + RemoteMcpCallResult, RemoteMcpClient, RemoteMcpClientDeps, RemoteMcpFailure, + RemoteMcpOfferedTool, RemoteMcpResource, RemoteMcpServer, RemoteMcpSignInDeps, @@ -67,6 +87,7 @@ export { mcpToolName, permissiveJsonSchemaValidator, RemoteMcpError, + remoteMcpApp, remoteMcpClient, remoteMcpSignIn, remoteMcpTools, diff --git a/packages/harness-sdk/src/plugins/remote-mcp/remote-mcp-app.test.ts b/packages/harness-sdk/src/plugins/remote-mcp/remote-mcp-app.test.ts new file mode 100644 index 00000000000..4bd14a3a8d6 --- /dev/null +++ b/packages/harness-sdk/src/plugins/remote-mcp/remote-mcp-app.test.ts @@ -0,0 +1,253 @@ +import { SUPPORTED_PROTOCOL_VERSIONS } from '@modelcontextprotocol/ext-apps/app-bridge'; +import { describe, expect, it } from 'vitest'; +import { remoteMcpApp, type RemoteMcpApp, type RemoteMcpAppTransport } from './index.js'; +import { chartApp, deps, serve, serverFor, token } from './remote-mcp-fixture.js'; + +/** + * The host half of an MCP Apps view, over a real protocol server. The view on + * the other end is written here: it sends the bridge's own JSON-RPC messages + * and reads the answers, which is what a sandboxed WebView will do. + */ + +/** One message on the bridge, in the shape both ends exchange. */ +interface Wire { + readonly jsonrpc: '2.0'; + readonly id?: number; + readonly method?: string; + readonly params?: unknown; + readonly result?: unknown; + readonly error?: { readonly code: number; readonly message: string }; +} + +const isWire = (held: unknown): held is Wire => + typeof held === 'object' && held !== null && 'jsonrpc' in held; + +/** Resolves the request whose reply this is, if the test is waiting on one. */ +const recording = (sent: Wire[], waiting: Map void>) => (wire: Wire) => { + sent.push(wire); + if (wire.id === undefined) { + return; + } + const resolve = waiting.get(wire.id); + if (resolve !== undefined) { + waiting.delete(wire.id); + resolve(wire); + } +}; + +/** The bridge's far end, as one view drives it. */ +interface Channel { + readonly transport: RemoteMcpAppTransport; + readonly sent: Wire[]; + request(id: number, method: string, params?: unknown): Promise; + notify(method: string, params?: unknown): void; + lastOf(method: string): Wire | undefined; +} + +/** The bridge's far end: records what the host sends and answers what is asked. */ +const channel = (view: { app?: RemoteMcpApp }): Channel => { + const sent: Wire[] = []; + const waiting = new Map void>(); + const record = recording(sent, waiting); + const transport: RemoteMcpAppTransport = { + send: message => { + const held: unknown = JSON.parse(message); + if (isWire(held)) { + record(held); + } + }, + }; + return { + transport, + sent, + request: (id, method, params) => { + const answered = Promise.withResolvers(); + waiting.set(id, answered.resolve); + view.app?.receive(JSON.stringify({ jsonrpc: '2.0', id, method, params })); + return answered.promise; + }, + notify: (method, params) => { + view.app?.receive(JSON.stringify({ jsonrpc: '2.0', method, params })); + }, + lastOf: method => sent.findLast(message => message.method === method), + }; +}; + +/** The handshake one view opens with, as the protocol defines it. */ +const initialize = (bridge: Channel) => + bridge.request(1, 'ui/initialize', { + appInfo: { name: 'The view', version: '1.0.0' }, + appCapabilities: {}, + protocolVersion: SUPPORTED_PROTOCOL_VERSIONS[0], + }); + +/** What one view reports about itself, as promises a test can wait on. */ +const observing = (app: RemoteMcpApp) => { + const sizes: { height?: number | undefined }[] = []; + const initialized = Promise.withResolvers(); + const sized = Promise.withResolvers(); + app.onInitialized(initialized.resolve); + app.onSizeChanged(size => { + sizes.push(size); + sized.resolve(); + }); + return { sizes, initialized, sized }; +}; + +/** One view's bridge, connected to a server and ready for the handshake. */ +const connected = async ( + options: { + context?: { theme?: 'light' | 'dark'; locale?: string }; + openLink?: (uri: string) => void; + } = {} +) => { + const served = await serve(chartApp); + const app = remoteMcpApp(serverFor('work', served.url, token), deps(), { + tools: ['draw-chart'], + ...options, + }); + const bridge = channel({ app }); + await app.connect(bridge.transport); + return { served, app, bridge }; +}; + +/** The same, once the view has initialized and the host may hand it the call. */ +const opened = async () => { + const { served, app, bridge } = await connected(); + const handshake = await initialize(bridge); + bridge.notify('ui/notifications/initialized', {}); + return { served, app, bridge, handshake }; +}; + +describe('the handshake a view opens with', () => { + it('is answered with the host this is, and no feature it does not offer', async () => { + const { bridge, handshake } = await opened(); + expect(handshake.error).toBeUndefined(); + expect(handshake.result).toMatchObject({ + hostInfo: { name: 'Kilo' }, + hostCapabilities: { openLinks: {}, serverTools: {}, logging: {} }, + hostContext: { + displayMode: 'inline', + platform: 'mobile', + deviceCapabilities: { touch: true, hover: false }, + }, + }); + const refused = await bridge.request(2, 'ui/message', { content: [] }); + expect(refused.error?.code).toBeDefined(); + }); + + it('is told the person’s theme, language and box', async () => { + const { bridge } = await connected({ context: { theme: 'dark', locale: 'de-DE' } }); + const handshake = await initialize(bridge); + expect(handshake.result).toMatchObject({ + hostContext: { theme: 'dark', locale: 'de-DE' }, + }); + }); +}); + +describe('the lifecycle a view reports', () => { + it('says when it has initialized, and the size it asks for', async () => { + const { app, bridge } = await connected(); + const { sizes, initialized, sized } = observing(app); + await initialize(bridge); + bridge.notify('ui/notifications/initialized', {}); + await initialized.promise; + bridge.notify('ui/notifications/size-changed', { width: 360, height: 210 }); + await sized.promise; + expect(sizes).toEqual([{ width: 360, height: 210 }]); + }); + + it('drops what the view sends before the bridge is connected', () => { + const served = { url: 'http://127.0.0.1:1/mcp', seen: { authorization: [] } }; + const app = remoteMcpApp(serverFor('work', served.url, token), deps(), { + tools: ['draw-chart'], + }); + const bridge = channel({ app }); + expect(() => { + bridge.notify('ui/notifications/size-changed', { height: 999 }); + }).not.toThrow(); + expect(bridge.sent).toEqual([]); + }); +}); + +describe('the call a view renders', () => { + it('receives the arguments, then the answer whole', async () => { + const { app, bridge } = await opened(); + await app.input({ city: 'Oslo' }); + await app.result({ + content: [{ type: 'text', text: 'chart drawn' }], + structuredContent: { points: 3 }, + }); + expect(bridge.lastOf('ui/notifications/tool-input')?.params).toEqual({ + arguments: { city: 'Oslo' }, + }); + expect(bridge.lastOf('ui/notifications/tool-result')?.params).toMatchObject({ + content: [{ type: 'text', text: 'chart drawn' }], + structuredContent: { points: 3 }, + }); + }); +}); + +describe('a tool the view calls back', () => { + it('reaches the same server the view came from, and answers with its result', async () => { + const { served, bridge } = await opened(); + const answer = await bridge.request(2, 'tools/call', { + name: 'draw-chart', + arguments: {}, + }); + expect(answer.result).toMatchObject({ + content: [{ type: 'text', text: 'chart drawn' }], + structuredContent: { points: 3 }, + }); + // It went over the wire: the fixture saw a credentialed request. + expect(served.seen.authorization.length).toBeGreaterThan(1); + }); + + it('is refused when it is not a tool of this server', async () => { + const { served, bridge } = await opened(); + const before = served.seen.authorization.length; + const answer = await bridge.request(2, 'tools/call', { + name: 'read-file', + arguments: { path: '/etc/passwd' }, + }); + expect(answer.error?.message).toContain('read-file'); + expect(served.seen.authorization.length).toBe(before); + }); +}); + +describe('a call the server refuses for the view', () => { + it('is answered with the words the model would read, flagged as an error', async () => { + const refusing = await serve({ + list: [ + { + name: 'draw-chart', + inputSchema: { type: 'object', properties: {} }, + _meta: { ui: { resourceUri: 'ui://work/chart.html' } }, + }, + ], + call: () => ({ content: [{ type: 'text', text: 'refused by the server' }], isError: true }), + }); + const app = remoteMcpApp(serverFor('work', refusing.url, token), deps(), { + tools: ['draw-chart'], + }); + const bridge = channel({ app }); + await app.connect(bridge.transport); + await initialize(bridge); + const answer = await bridge.request(2, 'tools/call', { name: 'draw-chart', arguments: {} }); + expect(answer.result).toMatchObject({ + isError: true, + content: [{ type: 'text', text: 'refused by the server' }], + }); + }); +}); + +describe('an open link the view asks for', () => { + it('reaches the host and is answered, so the view does not hang', async () => { + const openedLinks: string[] = []; + const { bridge } = await connected({ openLink: uri => openedLinks.push(uri) }); + await initialize(bridge); + const answer = await bridge.request(2, 'ui/open-link', { url: 'https://example.test/x' }); + expect(answer.error).toBeUndefined(); + expect(openedLinks).toEqual(['https://example.test/x']); + }); +}); diff --git a/packages/harness-sdk/src/plugins/remote-mcp/remote-mcp-fixture.ts b/packages/harness-sdk/src/plugins/remote-mcp/remote-mcp-fixture.ts index 661e07d66f8..58a3171e48f 100644 --- a/packages/harness-sdk/src/plugins/remote-mcp/remote-mcp-fixture.ts +++ b/packages/harness-sdk/src/plugins/remote-mcp/remote-mcp-fixture.ts @@ -261,7 +261,10 @@ const chartApp: Tools = { _meta: { ui: { resourceUri: 'ui://work/chart.html' } }, }, ], - call: () => ({ content: [{ type: 'text', text: 'chart drawn' }] }), + call: () => ({ + content: [{ type: 'text', text: 'chart drawn' }], + structuredContent: { points: 3 }, + }), read: uri => uri === 'ui://work/chart.html' ? { diff --git a/packages/harness-sdk/src/plugins/remote-mcp/remote-mcp-offered.test.ts b/packages/harness-sdk/src/plugins/remote-mcp/remote-mcp-offered.test.ts new file mode 100644 index 00000000000..266c504c5d5 --- /dev/null +++ b/packages/harness-sdk/src/plugins/remote-mcp/remote-mcp-offered.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { remoteMcpTools } from './index.js'; +import { chartApp, deps, run, serve, serverFor, token } from './remote-mcp-fixture.js'; + +/** + * What a server's tools look like once this harness offers them, and what the + * surface that draws a call is told: the view a tool carries, the name the + * view calls it by, and the whole answer the model's text was cut from. + */ + +describe('the tools a server offers with a view', () => { + it('carries the view and the server’s own name, outside the model’s definition', async () => { + const served = await serve(chartApp); + const offered = await run(remoteMcpTools(serverFor('work', served.url, token), deps())); + const [first] = offered; + expect(first?.uiResourceUri).toBe('ui://work/chart.html'); + expect(first?.serverToolName).toBe('draw-chart'); + expect(first?.definition).toEqual({ + name: 'mcp_work_draw-chart', + description: 'Draws a chart.', + parameters: { + type: 'object', + properties: { path: { type: 'string' } }, + required: ['path'], + additionalProperties: false, + }, + }); + }); + + it('names the server’s own tool even when the server attached no view', async () => { + const served = await serve({ + list: [ + { + name: 'read-file', + description: 'Reads.', + inputSchema: { type: 'object', properties: {} }, + }, + ], + call: () => ({ content: [] }), + }); + const offered = await run(remoteMcpTools(serverFor('work', served.url, token), deps())); + const [first] = offered; + expect(first?.uiResourceUri).toBeUndefined(); + expect(first?.serverToolName).toBe('read-file'); + }); +}); + +describe('the answer a surface draws', () => { + it('is reported whole, after the model’s text is returned', async () => { + const served = await serve(chartApp); + const answers: { tool: string; callId: string; structuredContent: unknown }[] = []; + const offered = await run( + remoteMcpTools(serverFor('work', served.url, token), { + ...deps(), + onCallResult: answered => answers.push(answered), + }) + ); + const [first] = offered; + if (first === undefined) { + throw new Error('the server offered no tool'); + } + const text = await run( + first.run({ id: 'call_9', name: first.definition.name, arguments: '{}' }) + ); + expect(text).toBe('chart drawn'); + expect(answers).toEqual([ + { + tool: 'draw-chart', + callId: 'call_9', + content: [{ type: 'text', text: 'chart drawn' }], + structuredContent: { points: 3 }, + }, + ]); + }); +}); diff --git a/packages/harness-sdk/src/plugins/remote-mcp/tools.ts b/packages/harness-sdk/src/plugins/remote-mcp/tools.ts index 8a7b6ccbe1e..8013a674fdf 100644 --- a/packages/harness-sdk/src/plugins/remote-mcp/tools.ts +++ b/packages/harness-sdk/src/plugins/remote-mcp/tools.ts @@ -12,6 +12,7 @@ import { mcpToolName, type RemoteMcpError, type RemoteMcpServer, + uiResourceUriOf, } from './server.js'; /** @@ -85,6 +86,22 @@ const argumentsOf = ( ) ); +/** + * A remote tool, as this harness offers it: the tool the model calls, plus what + * a host drawing the call needs and the model does not. + * + * `serverToolName` is the server's own name, which the view calls the tool by; + * the model sees `mcp__` instead. `uiResourceUri` is the MCP Apps + * view the server attached, when it attached one — a tool without one keeps + * today's rendering on every surface. Both ride on the tool object rather than + * in `definition`, because a definition is part of the cached prefix and these + * are not: the model is told nothing new. + */ +interface RemoteMcpOfferedTool extends Tool { + readonly serverToolName: string; + readonly uiResourceUri?: string; +} + /** * One remote tool. * @@ -94,16 +111,27 @@ const argumentsOf = ( * the result arrives in a round of its own. That needs the call's own deadline * to end later than `inlineFor`, which is why a call's default deadline is 60 * seconds (`defaultCallTimeoutMs`). + * + * The model gets the result's text and nothing else. What the server answered + * besides it is reported to `onCallResult` before the text is returned, so a + * view that renders the same call has the content and structured result. */ -const toolFor = ( - server: RemoteMcpServer, - client: RemoteMcpClient, - tool: RemoteMcpTool -): readonly Tool[] => { +const toolFor = ({ + server, + client, + deps, + tool, +}: { + readonly server: RemoteMcpServer; + readonly client: RemoteMcpClient; + readonly deps: RemoteMcpClientDeps; + readonly tool: RemoteMcpTool; +}): readonly RemoteMcpOfferedTool[] => { const parameters = parametersOf(tool.inputSchema); if (parameters === undefined || !callableName(server.id) || !callableName(tool.name)) { return []; } + const view = uiResourceUriOf(tool); return [ { definition: { @@ -111,17 +139,26 @@ const toolFor = ( description: tool.description ?? `Call ${tool.name} on ${server.name}.`, parameters, }, + serverToolName: tool.name, + ...(view === undefined ? {} : { uiResourceUri: view }), inlineFor: Duration.seconds(15), run: (call: ToolCall) => Effect.gen(function* () { const args = yield* argumentsOf(tool, call); - return yield* client + const answered = yield* client .call(tool.name, args) .pipe( Effect.mapError(error => error instanceof ToolFailure ? error : new ToolFailure({ cause: explain(error) }) ) ); + deps.onCallResult?.({ + tool: tool.name, + callId: call.id, + content: answered.content, + structuredContent: answered.structuredContent, + }); + return answered.text; }), }, ]; @@ -137,11 +174,12 @@ const toolFor = ( const remoteMcpTools = ( server: RemoteMcpServer, deps: RemoteMcpClientDeps -): Effect.Effect => { +): Effect.Effect => { const client = remoteMcpClient(server, deps); return client.tools.pipe( - Effect.map(tools => tools.flatMap(tool => toolFor(server, client, tool))) + Effect.map(tools => tools.flatMap(tool => toolFor({ server, client, deps, tool }))) ); }; +export type { RemoteMcpOfferedTool }; export { remoteMcpTools }; From 64f00817803d9f11485db0085818930aaa605ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:27:11 +0000 Subject: [PATCH 2/9] feat(mobile): render MCP Apps tool interfaces in the chat transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A remote MCP tool whose server attached a `ui://` view now draws that view in the transcript, under the row the call already had. Tools without one, and calls that have not finished, keep today's rendering. The view runs in a WebView that loads one page of the app's own: a proxy that owns a single `srcdoc` iframe with `sandbox="allow-scripts"` — an opaque origin, no file access, no way to navigate anything but itself — under a content policy built only from the origins the resource declared, and default-deny when it declared none. Only JSON text for the bridge crosses into it or out of it, and only messages from that iframe are relayed. The call's answer is what the view receives: the live call's content and structured result are kept for the row that draws them (`mcpCallResultOf`), so an interface that renders the server's structured data shows it. A transcript reopened from storage has the text the model read and no structured result; the view then draws from the call's input alone. The row keeps one height from the moment it is drawn — the view's own reported height inside the transcript's clamp — so a view that loads a moment after its call does not move the list. Loading, an empty document and a failure each have their own state, and a failure offers Retry. Copy: `modelChat.mcp.app.label`, `.failed` and `.empty` are added to every catalog. Proof: the chat suites cover the view lookup, the document read, the policy, the height clamp, the arguments handed to a view, and the router that picks the card; the Android run renders the official example App with the server's time and calls its tool back through the bridge. --- .../src/components/agents/tool-cards/index.ts | 1 + .../agents/tool-cards/mcp-app-card.tsx | 237 ++++++++++++++++++ .../agents/tool-part-renderer.test.ts | 34 +++ .../components/agents/tool-part-renderer.tsx | 13 +- apps/mobile/src/i18n/locales/af.json | 7 +- apps/mobile/src/i18n/locales/am.json | 7 +- apps/mobile/src/i18n/locales/ar.json | 7 +- apps/mobile/src/i18n/locales/az.json | 7 +- apps/mobile/src/i18n/locales/be.json | 7 +- apps/mobile/src/i18n/locales/bg.json | 7 +- apps/mobile/src/i18n/locales/bn.json | 7 +- apps/mobile/src/i18n/locales/bs.json | 7 +- apps/mobile/src/i18n/locales/ca.json | 7 +- apps/mobile/src/i18n/locales/ckb.json | 7 +- apps/mobile/src/i18n/locales/cs.json | 7 +- apps/mobile/src/i18n/locales/cy.json | 7 +- apps/mobile/src/i18n/locales/da.json | 7 +- apps/mobile/src/i18n/locales/de.json | 7 +- apps/mobile/src/i18n/locales/el.json | 7 +- apps/mobile/src/i18n/locales/en.json | 7 +- apps/mobile/src/i18n/locales/es.json | 7 +- apps/mobile/src/i18n/locales/et.json | 7 +- apps/mobile/src/i18n/locales/eu.json | 7 +- apps/mobile/src/i18n/locales/fa.json | 7 +- apps/mobile/src/i18n/locales/fi.json | 7 +- apps/mobile/src/i18n/locales/fil.json | 7 +- apps/mobile/src/i18n/locales/fr.json | 7 +- apps/mobile/src/i18n/locales/ga.json | 7 +- apps/mobile/src/i18n/locales/gl.json | 7 +- apps/mobile/src/i18n/locales/gu.json | 7 +- apps/mobile/src/i18n/locales/ha.json | 7 +- apps/mobile/src/i18n/locales/he.json | 7 +- apps/mobile/src/i18n/locales/hi.json | 7 +- apps/mobile/src/i18n/locales/hr.json | 7 +- apps/mobile/src/i18n/locales/ht.json | 7 +- apps/mobile/src/i18n/locales/hu.json | 7 +- apps/mobile/src/i18n/locales/hy.json | 7 +- apps/mobile/src/i18n/locales/id.json | 7 +- apps/mobile/src/i18n/locales/ig.json | 7 +- apps/mobile/src/i18n/locales/is.json | 7 +- apps/mobile/src/i18n/locales/it.json | 7 +- apps/mobile/src/i18n/locales/ja.json | 7 +- apps/mobile/src/i18n/locales/ka.json | 7 +- apps/mobile/src/i18n/locales/kk.json | 7 +- apps/mobile/src/i18n/locales/km.json | 7 +- apps/mobile/src/i18n/locales/kn.json | 7 +- apps/mobile/src/i18n/locales/ko.json | 7 +- apps/mobile/src/i18n/locales/lo.json | 7 +- apps/mobile/src/i18n/locales/lt.json | 7 +- apps/mobile/src/i18n/locales/lv.json | 7 +- apps/mobile/src/i18n/locales/mg.json | 7 +- apps/mobile/src/i18n/locales/mi.json | 7 +- apps/mobile/src/i18n/locales/mk.json | 7 +- apps/mobile/src/i18n/locales/ml.json | 7 +- apps/mobile/src/i18n/locales/mn.json | 7 +- apps/mobile/src/i18n/locales/mr.json | 7 +- apps/mobile/src/i18n/locales/ms.json | 7 +- apps/mobile/src/i18n/locales/mt.json | 7 +- apps/mobile/src/i18n/locales/my.json | 7 +- apps/mobile/src/i18n/locales/nb.json | 7 +- apps/mobile/src/i18n/locales/ne.json | 7 +- apps/mobile/src/i18n/locales/nl.json | 7 +- apps/mobile/src/i18n/locales/om.json | 7 +- apps/mobile/src/i18n/locales/or.json | 7 +- apps/mobile/src/i18n/locales/pa.json | 7 +- apps/mobile/src/i18n/locales/pl.json | 7 +- apps/mobile/src/i18n/locales/ps.json | 7 +- apps/mobile/src/i18n/locales/pt-BR.json | 7 +- apps/mobile/src/i18n/locales/pt.json | 7 +- apps/mobile/src/i18n/locales/ro.json | 7 +- apps/mobile/src/i18n/locales/ru.json | 7 +- apps/mobile/src/i18n/locales/si.json | 7 +- apps/mobile/src/i18n/locales/sk.json | 7 +- apps/mobile/src/i18n/locales/sl.json | 7 +- apps/mobile/src/i18n/locales/so.json | 7 +- apps/mobile/src/i18n/locales/sq.json | 7 +- apps/mobile/src/i18n/locales/sr.json | 7 +- apps/mobile/src/i18n/locales/sv.json | 7 +- apps/mobile/src/i18n/locales/sw.json | 7 +- apps/mobile/src/i18n/locales/ta.json | 7 +- apps/mobile/src/i18n/locales/te.json | 7 +- apps/mobile/src/i18n/locales/th.json | 7 +- apps/mobile/src/i18n/locales/tr.json | 7 +- apps/mobile/src/i18n/locales/uk.json | 7 +- apps/mobile/src/i18n/locales/ur.json | 7 +- apps/mobile/src/i18n/locales/uz.json | 7 +- apps/mobile/src/i18n/locales/vi.json | 7 +- apps/mobile/src/i18n/locales/yo.json | 7 +- apps/mobile/src/i18n/locales/zh-Hans.json | 7 +- apps/mobile/src/i18n/locales/zh-Hant.json | 7 +- apps/mobile/src/i18n/locales/zu.json | 7 +- .../src/lib/chat/remote-mcp-app.test.ts | 121 +++++++++ apps/mobile/src/lib/chat/remote-mcp-app.ts | 158 ++++++++++++ .../src/lib/chat/remote-mcp-state.test.ts | 32 ++- apps/mobile/src/lib/chat/remote-mcp-state.ts | 63 +++++ .../mobile/src/lib/chat/remote-mcp-surface.ts | 24 ++ .../src/lib/chat/remote-mcp-views.test.ts | 232 +++++++++++++++++ apps/mobile/src/lib/chat/remote-mcp-views.ts | 86 +++++++ .../src/lib/chat/remote-mcp.test-helpers.ts | 19 +- apps/mobile/src/lib/chat/remote-mcp.ts | 94 ++++++- 100 files changed, 1619 insertions(+), 104 deletions(-) create mode 100644 apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx create mode 100644 apps/mobile/src/lib/chat/remote-mcp-app.test.ts create mode 100644 apps/mobile/src/lib/chat/remote-mcp-app.ts create mode 100644 apps/mobile/src/lib/chat/remote-mcp-surface.ts create mode 100644 apps/mobile/src/lib/chat/remote-mcp-views.test.ts create mode 100644 apps/mobile/src/lib/chat/remote-mcp-views.ts diff --git a/apps/mobile/src/components/agents/tool-cards/index.ts b/apps/mobile/src/components/agents/tool-cards/index.ts index c3be0999865..1025fe6a8d9 100644 --- a/apps/mobile/src/components/agents/tool-cards/index.ts +++ b/apps/mobile/src/components/agents/tool-cards/index.ts @@ -4,6 +4,7 @@ export { GenericToolCard, GenericToolCardBody } from './generic-tool-card'; export { GlobToolCard, GlobToolCardBody } from './glob-tool-card'; export { GrepToolCard, GrepToolCardBody } from './grep-tool-card'; export { ListToolCard, ListToolCardBody } from './list-tool-card'; +export { McpAppToolCard, McpAppView } from './mcp-app-card'; export { PatchToolCard, PatchToolCardBody } from './patch-tool-card'; export { ReadToolCard, ReadToolCardBody } from './read-tool-card'; export { TaskToolCard, TaskToolCardBody } from './task-tool-card'; diff --git a/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx b/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx new file mode 100644 index 00000000000..a63f5495a09 --- /dev/null +++ b/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx @@ -0,0 +1,237 @@ +import { useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useColorScheme, View } from 'react-native'; +import { WebView } from 'react-native-webview'; + +import { type ToolPart } from '@kilocode/cloud-agent-sdk'; +import { type RemoteMcpApp } from '@kilocode/harness-sdk/plugins/remote-mcp'; + +import { ActivityIndicator } from '@/components/ui/activity-indicator'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { mcpCallResultOf, openRemoteMcpApp, readRemoteMcpView } from '@/lib/chat/remote-mcp'; +import { + DEFAULT_VIEW_HEIGHT, + MAX_VIEW_HEIGHT, + VIEW_HOST_PAGE, + viewArguments, + viewHeightOf, +} from '@/lib/chat/remote-mcp-app'; +import { type RemoteMcpToolView } from '@/lib/chat/remote-mcp-state'; +import { deviceZone } from '@/lib/chat/tool-set'; +import { openExternalUrl } from '@/lib/external-link'; + +import { GenericToolCard } from './generic-tool-card'; + +/** + * An MCP Apps view, drawn under the tool call it belongs to. + * + * The tool's row is the one every tool has; the view is the interface the + * server wrote for that call, in a WebView that loads one page of the app's + * own: a proxy that owns a single sandboxed iframe. The view's HTML comes from + * somebody else's server, so it runs with an opaque origin, under the policy + * the resource declared, with no file access and no way to navigate anything + * but itself. Everything that crosses into it or out of it is JSON text through + * the bridge (`remote-mcp-app.ts` holds the page and the policy; + * `openRemoteMcpApp` holds the protocol). + * + * The box keeps one height from the moment the row is drawn — the view's own + * reported height inside the clamp — so a view that loads a moment after its + * call does not move the transcript. + */ + +/** + * The page's origin in the WebView. The page is loaded from the app's own + * memory (`source={{ html }}`), so it has no file or network origin of its own + * to reach anything with — and nothing but it may load. + */ +const HOST_PAGE_ORIGIN = 'about:blank'; + +/** What the row draws: the document, or why there is none. */ +type ViewState = + | { readonly kind: 'loading' } + | { readonly kind: 'document'; readonly html: string } + | { readonly kind: 'empty' } + | { readonly kind: 'unavailable' }; + +/** The page's call that puts a document in the sandbox, as a JS literal. */ +const mountCall = (html: string): string => + `window.kiloMcpHost.mount(${JSON.stringify(html)});true;`; + +/** The page's call that posts one bridge message into the sandbox. */ +const deliverCall = (text: string): string => + `window.kiloMcpHost.deliver(${JSON.stringify(text)});true;`; + +type McpAppViewProps = { + readonly part: ToolPart; + readonly view: RemoteMcpToolView; +}; + +export function McpAppView({ part, view }: Readonly) { + const { t, i18n } = useTranslation(); + const dark = useColorScheme() === 'dark'; + const [pageLoaded, setPageLoaded] = useState(false); + const [documentState, setDocumentState] = useState({ kind: 'loading' }); + const [height, setHeight] = useState(DEFAULT_VIEW_HEIGHT); + const [attempt, setAttempt] = useState(0); + + const webView = useRef(null); + const bridge = useRef(null); + + useEffect(() => { + let active = true; + setDocumentState({ kind: 'loading' }); + const read = async () => { + const document = await readRemoteMcpView(view); + if (!active) { + return; + } + setDocumentState( + document.kind === 'ready' ? { kind: 'document', html: document.html } : document + ); + }; + void read(); + return () => { + active = false; + }; + }, [attempt, view]); + + // A retry or a new document tears the old bridge down before the new one opens. + useEffect( + () => () => { + const held = bridge.current; + bridge.current = null; + void held?.close(); + }, + [attempt] + ); + + // The page is loaded and the document is read: connect, then let the view + // initialize. The order matters — a view that initializes before the bridge + // listens would never be answered. + useEffect(() => { + if (!pageLoaded || documentState.kind !== 'document' || bridge.current !== null) { + return undefined; + } + let active = true; + const html = documentState.html; + const open = async () => { + const app = openRemoteMcpApp(view, { + context: { + theme: dark ? 'dark' : 'light', + locale: i18n.language, + timeZone: deviceZone(), + maxHeight: MAX_VIEW_HEIGHT, + }, + openLink: uri => void openExternalUrl(uri), + }); + if (app === undefined) { + if (active) { + setDocumentState({ kind: 'unavailable' }); + } + return; + } + bridge.current = app; + app.onSizeChanged(size => { + const reported = viewHeightOf(size.height); + setHeight(reported); + }); + app.onInitialized(() => { + void app.input(viewArguments(part.state.status === 'completed' ? part.state.input : {})); + const answered = mcpCallResultOf(part.callID); + if (answered !== undefined) { + void app.result(answered); + } + }); + try { + await app.connect({ + send: text => { + webView.current?.injectJavaScript(deliverCall(text)); + }, + }); + webView.current?.injectJavaScript(mountCall(html)); + } catch { + // The handshake or the mount failed: the row says so and offers a retry. + if (active) { + setDocumentState({ kind: 'unavailable' }); + } + } + }; + void open(); + return () => { + active = false; + }; + }, [pageLoaded, documentState, dark, i18n.language, part, view]); + + return ( + + + request.url === HOST_PAGE_ORIGIN || request.url === '' + } + // The view's own document is handed to the sandbox as `srcdoc`, so + // the page reads no files and the sandbox reaches no origin. + allowFileAccess={false} + javaScriptCanOpenWindowsAutomatically={false} + setSupportMultipleWindows={false} + mixedContentMode="never" + incognito + scrollEnabled={false} + accessibilityLabel={t('modelChat.mcp.app.label')} + onLoadEnd={() => { + setPageLoaded(true); + }} + onMessage={event => { + bridge.current?.receive(event.nativeEvent.data); + }} + onError={() => { + setDocumentState({ kind: 'unavailable' }); + }} + /> + {documentState.kind === 'loading' ? ( + + + + ) : null} + {documentState.kind === 'empty' ? ( + + + {t('modelChat.mcp.app.empty')} + + + ) : null} + {documentState.kind === 'unavailable' ? ( + + + {t('modelChat.mcp.app.failed')} + + + + ) : null} + + ); +} + +export function McpAppToolCard({ + part, + view, +}: Readonly<{ part: ToolPart; view: RemoteMcpToolView }>) { + return ( + + + + + ); +} diff --git a/apps/mobile/src/components/agents/tool-part-renderer.test.ts b/apps/mobile/src/components/agents/tool-part-renderer.test.ts index b99db807057..df0666f57ca 100644 --- a/apps/mobile/src/components/agents/tool-part-renderer.test.ts +++ b/apps/mobile/src/components/agents/tool-part-renderer.test.ts @@ -19,6 +19,7 @@ import { GlobToolCard, GrepToolCard, ListToolCard, + McpAppToolCard, PatchToolCard, ReadToolCard, TaskToolCard, @@ -45,6 +46,12 @@ vi.mock('./message-error-boundary', () => ({ MessageErrorBoundary: 'MessageError vi.mock('./suggest-tool-card', () => ({ SuggestToolCard: 'SuggestToolCard', })); +vi.mock('@/lib/chat/remote-mcp-surface', () => ({ + remoteServerView: (tool: string) => + tool === 'mcp_alpha_chart' + ? { serverId: 'alpha', serverToolName: 'chart', resourceUri: 'ui://alpha/chart.html' } + : undefined, +})); vi.mock('./tool-cards', () => ({ BashToolCard: 'BashToolCard', EditToolCard: 'EditToolCard', @@ -52,6 +59,7 @@ vi.mock('./tool-cards', () => ({ GlobToolCard: 'GlobToolCard', GrepToolCard: 'GrepToolCard', ListToolCard: 'ListToolCard', + McpAppToolCard: 'McpAppToolCard', PatchToolCard: 'PatchToolCard', ReadToolCard: 'ReadToolCard', TaskToolCard: 'TaskToolCard', @@ -371,6 +379,32 @@ describe('ToolPartRenderer routing', () => { expect(findByType(root, GenericToolCard)).toHaveLength(1); }); + it('routes a completed remote tool that carries a view to the view card', () => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartRenderer({ part: makeToolPart('mcp_alpha_chart', completedState) }); + expect(findByType(root, McpAppToolCard)).toHaveLength(1); + expect(findByType(root, GenericToolCard)).toHaveLength(0); + }); + + it('keeps the plain row for a remote tool whose server attached no view', () => { + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartRenderer({ part: makeToolPart('mcp_alpha_read', completedState) }); + expect(findByType(root, McpAppToolCard)).toHaveLength(0); + expect(findByType(root, GenericToolCard)).toHaveLength(1); + }); + + it('keeps the plain row for a view’s call that has not finished', () => { + const running: ToolPart['state'] = { + status: 'running', + input: {}, + time: { start: 1 }, + }; + // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call + const root = ToolPartRenderer({ part: makeToolPart('mcp_alpha_chart', running) }); + expect(findByType(root, McpAppToolCard)).toHaveLength(0); + expect(findByType(root, GenericToolCard)).toHaveLength(1); + }); + it.each(['plan_exit', 'plan_enter'])('returns null for %s parts', tool => { // eslint-disable-next-line new-cap, react-compiler-runtime/react-compiler-runtime -- direct function call const result = ToolPartRenderer({ part: makeToolPart(tool, completedState) }); diff --git a/apps/mobile/src/components/agents/tool-part-renderer.tsx b/apps/mobile/src/components/agents/tool-part-renderer.tsx index beaf63f4708..3b2d9975614 100644 --- a/apps/mobile/src/components/agents/tool-part-renderer.tsx +++ b/apps/mobile/src/components/agents/tool-part-renderer.tsx @@ -1,6 +1,7 @@ import { type StoredMessage, type ToolPart } from '@kilocode/cloud-agent-sdk'; import { type ReactNode } from 'react'; +import { remoteServerView } from '@/lib/chat/remote-mcp-surface'; import { type SessionModelOption } from '@/lib/hooks/use-session-model-options'; import { @@ -15,6 +16,7 @@ import { GlobToolCard, GrepToolCard, ListToolCard, + McpAppToolCard, PatchToolCard, ReadToolCard, TaskToolCard, @@ -113,7 +115,16 @@ export function ToolPartRenderer({ break; } default: { - card = ; + // A tool whose server attached an MCP Apps view draws that view under its + // row, once the call has an answer to hand it. Every other tool, and every + // call still running or failed, keeps the row it has always had. + const view = part.state.status === 'completed' ? remoteServerView(part.tool) : undefined; + card = + view === undefined ? ( + + ) : ( + + ); } } diff --git a/apps/mobile/src/i18n/locales/af.json b/apps/mobile/src/i18n/locales/af.json index c16699e311d..77c05504ec9 100644 --- a/apps/mobile/src/i18n/locales/af.json +++ b/apps/mobile/src/i18n/locales/af.json @@ -3316,7 +3316,12 @@ "serverChecking": "Kontroleer hierdie bediener…", "serverToolCount": "{{count}} gereedskap", "serverUnreachable": "Kon nie hierdie bediener bereik nie", - "serverOff": "Hierdie bediener is af" + "serverOff": "Hierdie bediener is af", + "app": { + "label": "Gereedskapkoppelvlak", + "failed": "Hierdie gereedskap se koppelvlak kon nie laai nie.", + "empty": "Hierdie gereedskap se koppelvlak het niks om te wys nie." + } }, "mcpOAuth": { "authMethod": "Verifikasie", diff --git a/apps/mobile/src/i18n/locales/am.json b/apps/mobile/src/i18n/locales/am.json index 00eb9565979..abf83812464 100644 --- a/apps/mobile/src/i18n/locales/am.json +++ b/apps/mobile/src/i18n/locales/am.json @@ -3316,7 +3316,12 @@ "serverChecking": "ይህን ሰርቨር በመፈተሽ ላይ…", "serverToolCount": "{{count}} መሳሪያዎች", "serverUnreachable": "ይህን ሰርቨር ማግኘት አልተቻለም", - "serverOff": "ይህ ሰርቨር ተዘግቷል" + "serverOff": "ይህ ሰርቨር ተዘግቷል", + "app": { + "label": "የመሣሪያ በይነገጽ", + "failed": "የዚህ መሣሪያ በይነገጽ መጫን አልተቻለም።", + "empty": "የዚህ መሣሪያ በይነገጽ የሚያሳየ ነገር የለውም።" + } }, "mcpOAuth": { "authMethod": "ማረጋገጫ", diff --git a/apps/mobile/src/i18n/locales/ar.json b/apps/mobile/src/i18n/locales/ar.json index 4a218f5d205..8a98e27ece9 100644 --- a/apps/mobile/src/i18n/locales/ar.json +++ b/apps/mobile/src/i18n/locales/ar.json @@ -3480,7 +3480,12 @@ "serverChecking": "جارٍ التحقق من هذا الخادم…", "serverToolCount": "{{count}} أدوات", "serverUnreachable": "تعذّر الوصول إلى هذا الخادم", - "serverOff": "هذا الخادم متوقف" + "serverOff": "هذا الخادم متوقف", + "app": { + "label": "واجهة الأداة", + "failed": "تعذّر تحميل واجهة هذه الأداة.", + "empty": "لا تحتوي واجهة هذه الأداة على أي شيء للعرض." + } }, "mcpOAuth": { "authMethod": "المصادقة", diff --git a/apps/mobile/src/i18n/locales/az.json b/apps/mobile/src/i18n/locales/az.json index 5a9874d09f1..84cb4940f89 100644 --- a/apps/mobile/src/i18n/locales/az.json +++ b/apps/mobile/src/i18n/locales/az.json @@ -3316,7 +3316,12 @@ "serverChecking": "Bu server yoxlanılır…", "serverToolCount": "{{count}} alət", "serverUnreachable": "Bu serverə çatmaq mümkün olmadı", - "serverOff": "Bu server söndürülüb" + "serverOff": "Bu server söndürülüb", + "app": { + "label": "Alətin interfeysi", + "failed": "Bu alətin interfeysi yüklənə bilmədi.", + "empty": "Bu alətin interfeysində göstəriləcək heç nə yoxdur." + } }, "mcpOAuth": { "authMethod": "Autentifikasiya", diff --git a/apps/mobile/src/i18n/locales/be.json b/apps/mobile/src/i18n/locales/be.json index c15776bc47d..77765359c09 100644 --- a/apps/mobile/src/i18n/locales/be.json +++ b/apps/mobile/src/i18n/locales/be.json @@ -3398,7 +3398,12 @@ "serverChecking": "Праверка гэтага сервера…", "serverToolCount": "{{count}} інструментаў", "serverUnreachable": "Не ўдалося дасягнуць гэтага сервера", - "serverOff": "Гэты сервер выключаны" + "serverOff": "Гэты сервер выключаны", + "app": { + "label": "Інтэрфейс інструмента", + "failed": "Не ўдалося загрузіць інтэрфейс гэтага інструмента.", + "empty": "Інтэрфейс гэтага інструмента не мае чаго паказваць." + } }, "mcpOAuth": { "authMethod": "Аўтэнтыфікацыя", diff --git a/apps/mobile/src/i18n/locales/bg.json b/apps/mobile/src/i18n/locales/bg.json index 4478392b6ac..7c84c43242c 100644 --- a/apps/mobile/src/i18n/locales/bg.json +++ b/apps/mobile/src/i18n/locales/bg.json @@ -3316,7 +3316,12 @@ "serverChecking": "Проверка на този сървър…", "serverToolCount": "{{count}} инструмента", "serverUnreachable": "Неуспешно свързване с този сървър", - "serverOff": "Този сървър е изключен" + "serverOff": "Този сървър е изключен", + "app": { + "label": "Интерфейс на инструмента", + "failed": "Интерфейсът на този инструмент не успя да се зареди.", + "empty": "Интерфейсът на този инструмент няма нищо за показване." + } }, "mcpOAuth": { "authMethod": "Удостоверяване", diff --git a/apps/mobile/src/i18n/locales/bn.json b/apps/mobile/src/i18n/locales/bn.json index eea7720f685..818065237b1 100644 --- a/apps/mobile/src/i18n/locales/bn.json +++ b/apps/mobile/src/i18n/locales/bn.json @@ -3316,7 +3316,12 @@ "serverChecking": "এই সার্ভার যাচাই করা হচ্ছে…", "serverToolCount": "{{count}} টুল", "serverUnreachable": "এই সার্ভারে পৌঁছানো যায়নি", - "serverOff": "এই সার্ভার বন্ধ আছে" + "serverOff": "এই সার্ভার বন্ধ আছে", + "app": { + "label": "টুল ইন্টারফেস", + "failed": "এই টুলের ইন্টারফেস লোড করা যায়নি।", + "empty": "এই টুলের ইন্টারফেসে দেখানোর কিছু নেই।" + } }, "mcpOAuth": { "authMethod": "প্রমাণীকরণ", diff --git a/apps/mobile/src/i18n/locales/bs.json b/apps/mobile/src/i18n/locales/bs.json index 8d142438d91..d2e1cd001ca 100644 --- a/apps/mobile/src/i18n/locales/bs.json +++ b/apps/mobile/src/i18n/locales/bs.json @@ -3357,7 +3357,12 @@ "serverChecking": "Provjera ovog servera…", "serverToolCount": "{{count}} alata", "serverUnreachable": "Nije bilo moguće doći do ovog servera", - "serverOff": "Ovaj server je isključen" + "serverOff": "Ovaj server je isključen", + "app": { + "label": "Sučelje alata", + "failed": "Sučelje ovog alata nije se moglo učitati.", + "empty": "Sučelje ovog alata nema ništa za prikaz." + } }, "mcpOAuth": { "authMethod": "Autentifikacija", diff --git a/apps/mobile/src/i18n/locales/ca.json b/apps/mobile/src/i18n/locales/ca.json index 50aa869598d..861f87aef0d 100644 --- a/apps/mobile/src/i18n/locales/ca.json +++ b/apps/mobile/src/i18n/locales/ca.json @@ -3357,7 +3357,12 @@ "serverChecking": "S'està comprovant aquest servidor…", "serverToolCount": "{{count}} eines", "serverUnreachable": "No s'ha pogut contactar amb aquest servidor", - "serverOff": "Aquest servidor està desactivat" + "serverOff": "Aquest servidor està desactivat", + "app": { + "label": "Interfície de l'eina", + "failed": "No s'ha pogut carregar la interfície d'aquesta eina.", + "empty": "La interfície d'aquesta eina no té res a mostrar." + } }, "mcpOAuth": { "authMethod": "Autenticació", diff --git a/apps/mobile/src/i18n/locales/ckb.json b/apps/mobile/src/i18n/locales/ckb.json index 3d3549619a0..049bbf5c15c 100644 --- a/apps/mobile/src/i18n/locales/ckb.json +++ b/apps/mobile/src/i18n/locales/ckb.json @@ -3316,7 +3316,12 @@ "serverChecking": "پشکنینی ئەم سێرڤەرە…", "serverToolCount": "{{count}} ئامراز", "serverUnreachable": "نەتوانرا بگەیت بەم سێرڤەرە", - "serverOff": "ئەم سێرڤەرە ناچالاکە" + "serverOff": "ئەم سێرڤەرە ناچالاکە", + "app": { + "label": "ڕووکاری ئامراز", + "failed": "ڕووکاری ئەم ئامرازە بار نەکرا.", + "empty": "ڕووکاری ئەم ئامرازە هیچ شتێکی نییە بۆ پیشاندان." + } }, "mcpOAuth": { "authMethod": "ناسینەوە", diff --git a/apps/mobile/src/i18n/locales/cs.json b/apps/mobile/src/i18n/locales/cs.json index 3d875e74baa..09fc846e181 100644 --- a/apps/mobile/src/i18n/locales/cs.json +++ b/apps/mobile/src/i18n/locales/cs.json @@ -3398,7 +3398,12 @@ "serverChecking": "Kontrola tohoto serveru…", "serverToolCount": "{{count}} nástrojů", "serverUnreachable": "Nepodařilo se připojit k tomuto serveru", - "serverOff": "Tento server je vypnutý" + "serverOff": "Tento server je vypnutý", + "app": { + "label": "Rozhraní nástroje", + "failed": "Rozhraní tohoto nástroje se nepodařilo načíst.", + "empty": "Rozhraní tohoto nástroje nemá co zobrazit." + } }, "mcpOAuth": { "authMethod": "Ověření", diff --git a/apps/mobile/src/i18n/locales/cy.json b/apps/mobile/src/i18n/locales/cy.json index 28e7298b798..c6cac5bed62 100644 --- a/apps/mobile/src/i18n/locales/cy.json +++ b/apps/mobile/src/i18n/locales/cy.json @@ -3480,7 +3480,12 @@ "serverChecking": "Yn gwirio'r gweinydd hwn…", "serverToolCount": "{{count}} offeryn", "serverUnreachable": "Ni ellid cyrraedd y gweinydd hwn", - "serverOff": "Mae'r gweinydd hwn i ffwrdd" + "serverOff": "Mae'r gweinydd hwn i ffwrdd", + "app": { + "label": "Rhyngwyneb yr offeryn", + "failed": "Methwyd llwytho rhyngwyneb yr offeryn hwn.", + "empty": "Does dim byd i'w ddangos yng rhyngwyneb yr offeryn hwn." + } }, "mcpOAuth": { "authMethod": "Dilysu", diff --git a/apps/mobile/src/i18n/locales/da.json b/apps/mobile/src/i18n/locales/da.json index 2c6b3a2cad7..67faf361ac5 100644 --- a/apps/mobile/src/i18n/locales/da.json +++ b/apps/mobile/src/i18n/locales/da.json @@ -3316,7 +3316,12 @@ "serverChecking": "Tjekker denne server…", "serverToolCount": "{{count}} værktøjer", "serverUnreachable": "Kunne ikke nå denne server", - "serverOff": "Denne server er slået fra" + "serverOff": "Denne server er slået fra", + "app": { + "label": "Værktøjsgrænseflade", + "failed": "Grænsefladen til dette værktøj kunne ikke indlæses.", + "empty": "Grænsefladen til dette værktøj har intet at vise." + } }, "mcpOAuth": { "authMethod": "Godkendelse", diff --git a/apps/mobile/src/i18n/locales/de.json b/apps/mobile/src/i18n/locales/de.json index e02e4330f28..698587df908 100644 --- a/apps/mobile/src/i18n/locales/de.json +++ b/apps/mobile/src/i18n/locales/de.json @@ -3316,7 +3316,12 @@ "serverChecking": "Dieser Server wird geprüft…", "serverToolCount": "{{count}} Tools", "serverUnreachable": "Dieser Server konnte nicht erreicht werden", - "serverOff": "Dieser Server ist ausgeschaltet" + "serverOff": "Dieser Server ist ausgeschaltet", + "app": { + "label": "Tool-Oberfläche", + "failed": "Die Oberfläche dieses Tools konnte nicht geladen werden.", + "empty": "Die Oberfläche dieses Tools hat nichts anzuzeigen." + } }, "mcpOAuth": { "authMethod": "Authentifizierung", diff --git a/apps/mobile/src/i18n/locales/el.json b/apps/mobile/src/i18n/locales/el.json index 4bc5fc50555..0178dc334b9 100644 --- a/apps/mobile/src/i18n/locales/el.json +++ b/apps/mobile/src/i18n/locales/el.json @@ -3316,7 +3316,12 @@ "serverChecking": "Έλεγχος αυτού του διακομιστή…", "serverToolCount": "{{count}} εργαλεία", "serverUnreachable": "Δεν ήταν δυνατή η πρόσβαση σε αυτόν τον διακομιστή", - "serverOff": "Ο διακομιστής αυτός είναι ανενεργός" + "serverOff": "Ο διακομιστής αυτός είναι ανενεργός", + "app": { + "label": "Διεπαφή εργαλείου", + "failed": "Δεν ήταν δυνατή η φόρτωση της διεπαφής αυτού του εργαλείου.", + "empty": "Η διεπαφή αυτού του εργαλείου δεν έχει τίποτα να εμφανίσει." + } }, "mcpOAuth": { "authMethod": "Έλεγχος ταυτότητας", diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 84de473f48e..bc10fcb7fdb 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -3552,7 +3552,12 @@ "serverChecking": "Checking this server…", "serverToolCount": "{{count}} tools", "serverUnreachable": "Couldn't reach this server", - "serverOff": "This server is off" + "serverOff": "This server is off", + "app": { + "label": "Tool interface", + "failed": "This tool's interface couldn't load.", + "empty": "This tool's interface has nothing to show." + } }, "reasoningEffort": { "modelFlag": "This model supports thinking effort" diff --git a/apps/mobile/src/i18n/locales/es.json b/apps/mobile/src/i18n/locales/es.json index 97a36d8b438..1447e8a0129 100644 --- a/apps/mobile/src/i18n/locales/es.json +++ b/apps/mobile/src/i18n/locales/es.json @@ -3357,7 +3357,12 @@ "serverChecking": "Comprobando este servidor…", "serverToolCount": "{{count}} herramientas", "serverUnreachable": "No se pudo contactar con este servidor", - "serverOff": "Este servidor está desactivado" + "serverOff": "Este servidor está desactivado", + "app": { + "label": "Interfaz de la herramienta", + "failed": "No se pudo cargar la interfaz de esta herramienta.", + "empty": "La interfaz de esta herramienta no tiene nada que mostrar." + } }, "mcpOAuth": { "authMethod": "Autenticación", diff --git a/apps/mobile/src/i18n/locales/et.json b/apps/mobile/src/i18n/locales/et.json index 443cfc64b96..96238aeb491 100644 --- a/apps/mobile/src/i18n/locales/et.json +++ b/apps/mobile/src/i18n/locales/et.json @@ -3316,7 +3316,12 @@ "serverChecking": "Selle serveri kontrollimine…", "serverToolCount": "{{count}} tööriista", "serverUnreachable": "Selle serverini ei õnnestunud jõuda", - "serverOff": "See server on välja lülitatud" + "serverOff": "See server on välja lülitatud", + "app": { + "label": "Tööriista liides", + "failed": "Selle tööriista liidest ei õnnestunud laadida.", + "empty": "Selle tööriista liidesel pole midagi näidata." + } }, "mcpOAuth": { "authMethod": "Autentimine", diff --git a/apps/mobile/src/i18n/locales/eu.json b/apps/mobile/src/i18n/locales/eu.json index 74680fad858..4efd7440297 100644 --- a/apps/mobile/src/i18n/locales/eu.json +++ b/apps/mobile/src/i18n/locales/eu.json @@ -3316,7 +3316,12 @@ "serverChecking": "Zerbitzari hau egiaztatzen…", "serverToolCount": "{{count}} tresna", "serverUnreachable": "Ezin izan da zerbitzari honetara iritsi", - "serverOff": "Zerbitzari hau desgaituta dago" + "serverOff": "Zerbitzari hau desgaituta dago", + "app": { + "label": "Tresnaren interfazea", + "failed": "Ezin izan da tresna honen interfazea kargatu.", + "empty": "Tresna honen interfazeak ez du ezer erakusteko." + } }, "mcpOAuth": { "authMethod": "Autentifikazioa", diff --git a/apps/mobile/src/i18n/locales/fa.json b/apps/mobile/src/i18n/locales/fa.json index faefe79a64b..aa2215a8a04 100644 --- a/apps/mobile/src/i18n/locales/fa.json +++ b/apps/mobile/src/i18n/locales/fa.json @@ -3316,7 +3316,12 @@ "serverChecking": "در حال بررسی این سرور…", "serverToolCount": "{{count}} ابزار", "serverUnreachable": "دسترسی به این سرور ممکن نشد", - "serverOff": "این سرور خاموش است" + "serverOff": "این سرور خاموش است", + "app": { + "label": "رابط ابزار", + "failed": "رابط این ابزار بارگذاری نشد.", + "empty": "رابط این ابزار چیزی برای نمایش ندارد." + } }, "mcpOAuth": { "authMethod": "احراز هویت", diff --git a/apps/mobile/src/i18n/locales/fi.json b/apps/mobile/src/i18n/locales/fi.json index 829006a3d27..4103d81e784 100644 --- a/apps/mobile/src/i18n/locales/fi.json +++ b/apps/mobile/src/i18n/locales/fi.json @@ -3316,7 +3316,12 @@ "serverChecking": "Tarkistetaan tätä palvelinta…", "serverToolCount": "{{count}} työkalua", "serverUnreachable": "Tähän palvelimeen ei saatu yhteyttä", - "serverOff": "Tämä palvelin on pois päältä" + "serverOff": "Tämä palvelin on pois päältä", + "app": { + "label": "Työkalun käyttöliittymä", + "failed": "Tämän työkalun käyttöliittymää ei voitu ladata.", + "empty": "Tämän työkalun käyttöliittymässä ei ole näytettävää." + } }, "mcpOAuth": { "authMethod": "Todennus", diff --git a/apps/mobile/src/i18n/locales/fil.json b/apps/mobile/src/i18n/locales/fil.json index b063191d993..9398de86a3c 100644 --- a/apps/mobile/src/i18n/locales/fil.json +++ b/apps/mobile/src/i18n/locales/fil.json @@ -3316,7 +3316,12 @@ "serverChecking": "Sinusuri ang server na ito…", "serverToolCount": "{{count}} tool", "serverUnreachable": "Hindi maabot ang server na ito", - "serverOff": "Naka-off ang server na ito" + "serverOff": "Naka-off ang server na ito", + "app": { + "label": "Interface ng tool", + "failed": "Hindi na-load ang interface ng tool na ito.", + "empty": "Walang maipapakita ang interface ng tool na ito." + } }, "mcpOAuth": { "authMethod": "Pagpapatunay", diff --git a/apps/mobile/src/i18n/locales/fr.json b/apps/mobile/src/i18n/locales/fr.json index a4d3634edc4..69e78e561f1 100644 --- a/apps/mobile/src/i18n/locales/fr.json +++ b/apps/mobile/src/i18n/locales/fr.json @@ -3357,7 +3357,12 @@ "serverChecking": "Vérification de ce serveur…", "serverToolCount": "{{count}} outils", "serverUnreachable": "Impossible de joindre ce serveur", - "serverOff": "Ce serveur est désactivé" + "serverOff": "Ce serveur est désactivé", + "app": { + "label": "Interface de l'outil", + "failed": "L'interface de cet outil n'a pas pu se charger.", + "empty": "L'interface de cet outil n'a rien à afficher." + } }, "mcpOAuth": { "authMethod": "Authentification", diff --git a/apps/mobile/src/i18n/locales/ga.json b/apps/mobile/src/i18n/locales/ga.json index b0284d042ac..f63b01de7f2 100644 --- a/apps/mobile/src/i18n/locales/ga.json +++ b/apps/mobile/src/i18n/locales/ga.json @@ -3439,7 +3439,12 @@ "serverChecking": "Ag seiceáil an fhreastalaí seo…", "serverToolCount": "{{count}} uirlis", "serverUnreachable": "Níorbh fhéidir an freastalaí seo a shroicheadh", - "serverOff": "Tá an freastalaí seo múchta" + "serverOff": "Tá an freastalaí seo múchta", + "app": { + "label": "Comhéadan na huirlise", + "failed": "Níorbh fhéidir comhéadan na huirlise seo a lódáil.", + "empty": "Níl aon rud le taispeáint ar chomhéadan na huirlise seo." + } }, "mcpOAuth": { "authMethod": "Fíordheimhniú", diff --git a/apps/mobile/src/i18n/locales/gl.json b/apps/mobile/src/i18n/locales/gl.json index febc9f2eec1..fb07748a612 100644 --- a/apps/mobile/src/i18n/locales/gl.json +++ b/apps/mobile/src/i18n/locales/gl.json @@ -3316,7 +3316,12 @@ "serverChecking": "Comprobando este servidor…", "serverToolCount": "{{count}} ferramentas", "serverUnreachable": "Non se puido contactar con este servidor", - "serverOff": "Este servidor está desactivado" + "serverOff": "Este servidor está desactivado", + "app": { + "label": "Interface da ferramenta", + "failed": "Non se puido cargar a interface desta ferramenta.", + "empty": "A interface desta ferramenta non ten nada que mostrar." + } }, "mcpOAuth": { "authMethod": "Autenticación", diff --git a/apps/mobile/src/i18n/locales/gu.json b/apps/mobile/src/i18n/locales/gu.json index 511d75fe9a0..08d31d021e1 100644 --- a/apps/mobile/src/i18n/locales/gu.json +++ b/apps/mobile/src/i18n/locales/gu.json @@ -3316,7 +3316,12 @@ "serverChecking": "આ સર્વર તપાસી રહ્યું છે…", "serverToolCount": "{{count}} સાધનો", "serverUnreachable": "આ સર્વર સુધી પહોંચી શકાયું નથી", - "serverOff": "આ સર્વર બંધ છે" + "serverOff": "આ સર્વર બંધ છે", + "app": { + "label": "ટૂલ ઇન્ટરફેસ", + "failed": "આ ટૂલનું ઇન્ટરફેસ લોડ થઈ શક્યું નથી.", + "empty": "આ ટૂલના ઇન્ટરફેસમાં બતાવવા માટે કંઈ નથી." + } }, "mcpOAuth": { "authMethod": "પ્રમાણીકરણ", diff --git a/apps/mobile/src/i18n/locales/ha.json b/apps/mobile/src/i18n/locales/ha.json index 079a6a2e3a8..34361b2c61b 100644 --- a/apps/mobile/src/i18n/locales/ha.json +++ b/apps/mobile/src/i18n/locales/ha.json @@ -3316,7 +3316,12 @@ "serverChecking": "Ana duba wannan sabar…", "serverToolCount": "kayan aiki {{count}}", "serverUnreachable": "Ba a iya isa wannan sabar ba", - "serverOff": "An kashe wannan sabar" + "serverOff": "An kashe wannan sabar", + "app": { + "label": "Mahaɗin kayan aiki", + "failed": "An kasa loda mahaɗin kayan aiki.", + "empty": "Mahaɗin kayan aiki bai nuna komai ba." + } }, "mcpOAuth": { "authMethod": "Tantancewa", diff --git a/apps/mobile/src/i18n/locales/he.json b/apps/mobile/src/i18n/locales/he.json index 6f0e8bad227..f232cc8c607 100644 --- a/apps/mobile/src/i18n/locales/he.json +++ b/apps/mobile/src/i18n/locales/he.json @@ -3357,7 +3357,12 @@ "serverChecking": "בודק את השרת הזה…", "serverToolCount": "{{count}} כלים", "serverUnreachable": "לא ניתן היה להגיע לשרת הזה", - "serverOff": "השרת הזה כבוי" + "serverOff": "השרת הזה כבוי", + "app": { + "label": "ממשק הכלי", + "failed": "לא הייתה אפשרות לטעון את הממשק של הכלי הזה.", + "empty": "אין מה להציג בממשק של הכלי הזה." + } }, "mcpOAuth": { "authMethod": "אימות", diff --git a/apps/mobile/src/i18n/locales/hi.json b/apps/mobile/src/i18n/locales/hi.json index 83087a8be0c..a81cb653520 100644 --- a/apps/mobile/src/i18n/locales/hi.json +++ b/apps/mobile/src/i18n/locales/hi.json @@ -3316,7 +3316,12 @@ "serverChecking": "इस सर्वर की जाँच हो रही है…", "serverToolCount": "{{count}} टूल", "serverUnreachable": "इस सर्वर तक नहीं पहुँच सका", - "serverOff": "यह सर्वर बंद है" + "serverOff": "यह सर्वर बंद है", + "app": { + "label": "टूल इंटरफ़ेस", + "failed": "इस टूल का इंटरफ़ेस लोड नहीं हो सका.", + "empty": "इस टूल के इंटरफ़ेस में दिखाने के लिए कुछ नहीं है." + } }, "mcpOAuth": { "authMethod": "प्रमाणीकरण", diff --git a/apps/mobile/src/i18n/locales/hr.json b/apps/mobile/src/i18n/locales/hr.json index b5e0672405f..16ff3a4967d 100644 --- a/apps/mobile/src/i18n/locales/hr.json +++ b/apps/mobile/src/i18n/locales/hr.json @@ -3357,7 +3357,12 @@ "serverChecking": "Provjera ovog poslužitelja…", "serverToolCount": "{{count}} alata", "serverUnreachable": "Nije bilo moguće doći do ovog poslužitelja", - "serverOff": "Ovaj poslužitelj je isključen" + "serverOff": "Ovaj poslužitelj je isključen", + "app": { + "label": "Sučelje alata", + "failed": "Sučelje ovog alata nije se moglo učitati.", + "empty": "Sučelje ovog alata nema ništa za prikaz." + } }, "mcpOAuth": { "authMethod": "Autentifikacija", diff --git a/apps/mobile/src/i18n/locales/ht.json b/apps/mobile/src/i18n/locales/ht.json index f5bfb6b4304..d5eeb5dbcbf 100644 --- a/apps/mobile/src/i18n/locales/ht.json +++ b/apps/mobile/src/i18n/locales/ht.json @@ -3316,7 +3316,12 @@ "serverChecking": "N ap tcheke sèvè sa a…", "serverToolCount": "{{count}} zouti", "serverUnreachable": "Nou pa t kapab rive jwenn sèvè sa a", - "serverOff": "Sèvè sa a etenn" + "serverOff": "Sèvè sa a etenn", + "app": { + "label": "Entèfas zouti a", + "failed": "Entèfas zouti sa a pa ka chaje.", + "empty": "Entèfas zouti sa a pa gen anyen pou montre." + } }, "mcpOAuth": { "authMethod": "Otantifikasyon", diff --git a/apps/mobile/src/i18n/locales/hu.json b/apps/mobile/src/i18n/locales/hu.json index 9ab5bba6517..83b8cc30868 100644 --- a/apps/mobile/src/i18n/locales/hu.json +++ b/apps/mobile/src/i18n/locales/hu.json @@ -3316,7 +3316,12 @@ "serverChecking": "Szerver ellenőrzése…", "serverToolCount": "{{count}} eszköz", "serverUnreachable": "Nem sikerült elérni ezt a szervert", - "serverOff": "Ez a szerver ki van kapcsolva" + "serverOff": "Ez a szerver ki van kapcsolva", + "app": { + "label": "Eszköz felülete", + "failed": "Az eszköz felületét nem sikerült betölteni.", + "empty": "Az eszköz felületén nincs mit megjeleníteni." + } }, "mcpOAuth": { "authMethod": "Hitelesítés", diff --git a/apps/mobile/src/i18n/locales/hy.json b/apps/mobile/src/i18n/locales/hy.json index da061f4ddcc..9db9e9f7c58 100644 --- a/apps/mobile/src/i18n/locales/hy.json +++ b/apps/mobile/src/i18n/locales/hy.json @@ -3316,7 +3316,12 @@ "serverChecking": "Ստուգվում է այս սերվերը…", "serverToolCount": "{{count}} գործիք", "serverUnreachable": "Չհաջողվեց հասնել այս սերվերին", - "serverOff": "Այս սերվերն անջատված է" + "serverOff": "Այս սերվերն անջատված է", + "app": { + "label": "Գործիքի միջերես", + "failed": "Այս գործիքի միջերեսը չհաջողվեց բեռնել.", + "empty": "Այս գործիքի միջերեսում ցուցադրելու ոչինչ չկա." + } }, "mcpOAuth": { "authMethod": "Նույնականացում", diff --git a/apps/mobile/src/i18n/locales/id.json b/apps/mobile/src/i18n/locales/id.json index bec8c14145a..79385ef5e37 100644 --- a/apps/mobile/src/i18n/locales/id.json +++ b/apps/mobile/src/i18n/locales/id.json @@ -3316,7 +3316,12 @@ "serverChecking": "Memeriksa server ini…", "serverToolCount": "{{count}} alat", "serverUnreachable": "Tidak dapat menjangkau server ini", - "serverOff": "Server ini nonaktif" + "serverOff": "Server ini nonaktif", + "app": { + "label": "Antarmuka alat", + "failed": "Antarmuka alat ini tidak dapat dimuat.", + "empty": "Antarmuka alat ini tidak memiliki apa pun untuk ditampilkan." + } }, "mcpOAuth": { "authMethod": "Autentikasi", diff --git a/apps/mobile/src/i18n/locales/ig.json b/apps/mobile/src/i18n/locales/ig.json index da4af0e216b..0243e6cf250 100644 --- a/apps/mobile/src/i18n/locales/ig.json +++ b/apps/mobile/src/i18n/locales/ig.json @@ -3316,7 +3316,12 @@ "serverChecking": "Na-enyocha sava a…", "serverToolCount": "ngwá ọrụ {{count}}", "serverUnreachable": "Enweghị ike iru sava a", - "serverOff": "Sava a agbanyụrụ" + "serverOff": "Sava a agbanyụrụ", + "app": { + "label": "Interface ngwá ọrụ", + "failed": "Enweghị ike ibu interface ngwá ọrụ a.", + "empty": "Interface ngwá ọrụ a enweghị ihe ọ ga-egosi." + } }, "mcpOAuth": { "authMethod": "Nkwenye njirimara", diff --git a/apps/mobile/src/i18n/locales/is.json b/apps/mobile/src/i18n/locales/is.json index c987a52e67f..e772a14c92c 100644 --- a/apps/mobile/src/i18n/locales/is.json +++ b/apps/mobile/src/i18n/locales/is.json @@ -3316,7 +3316,12 @@ "serverChecking": "Athuga þennan þjón…", "serverToolCount": "{{count}} verkfæri", "serverUnreachable": "Náði ekki sambandi við þennan þjón", - "serverOff": "Þessi þjónn er óvirkur" + "serverOff": "Þessi þjónn er óvirkur", + "app": { + "label": "Viðmót tóls", + "failed": "Ekki tókst að hlaða viðmóti tólsins.", + "empty": "Viðmót tólsins hefur ekkert til að sýna." + } }, "mcpOAuth": { "authMethod": "Auðkenning", diff --git a/apps/mobile/src/i18n/locales/it.json b/apps/mobile/src/i18n/locales/it.json index 4cbc18d9ac8..1a6274d7909 100644 --- a/apps/mobile/src/i18n/locales/it.json +++ b/apps/mobile/src/i18n/locales/it.json @@ -3357,7 +3357,12 @@ "serverChecking": "Verifica di questo server…", "serverToolCount": "{{count}} strumenti", "serverUnreachable": "Impossibile raggiungere questo server", - "serverOff": "Questo server è disattivato" + "serverOff": "Questo server è disattivato", + "app": { + "label": "Interfaccia dello strumento", + "failed": "Impossibile caricare l'interfaccia di questo strumento.", + "empty": "L'interfaccia di questo strumento non ha nulla da mostrare." + } }, "mcpOAuth": { "authMethod": "Autenticazione", diff --git a/apps/mobile/src/i18n/locales/ja.json b/apps/mobile/src/i18n/locales/ja.json index 8ebfbc66e9f..1be01a48ca7 100644 --- a/apps/mobile/src/i18n/locales/ja.json +++ b/apps/mobile/src/i18n/locales/ja.json @@ -3316,7 +3316,12 @@ "serverChecking": "このサーバーを確認しています…", "serverToolCount": "{{count}} 個のツール", "serverUnreachable": "このサーバーに接続できませんでした", - "serverOff": "このサーバーはオフです" + "serverOff": "このサーバーはオフです", + "app": { + "label": "ツールインターフェース", + "failed": "このツールのインターフェースを読み込めませんでした。", + "empty": "このツールのインターフェースには表示するものがありません。" + } }, "mcpOAuth": { "authMethod": "認証", diff --git a/apps/mobile/src/i18n/locales/ka.json b/apps/mobile/src/i18n/locales/ka.json index 26f62b4dfb0..83ebb3ea5a6 100644 --- a/apps/mobile/src/i18n/locales/ka.json +++ b/apps/mobile/src/i18n/locales/ka.json @@ -3316,7 +3316,12 @@ "serverChecking": "ამ სერვერის შემოწმება…", "serverToolCount": "{{count}} ხელსაწყო", "serverUnreachable": "ამ სერვერთან დაკავშირება ვერ მოხერხდა", - "serverOff": "ეს სერვერი გამორთულია" + "serverOff": "ეს სერვერი გამორთულია", + "app": { + "label": "ხელსაწყოს ინტერფეისი", + "failed": "ამ ხელსაწყოს ინტერფეისის ჩატვირთვა ვერ მოხერხდა.", + "empty": "ამ ხელსაწყოს ინტერფეისს საჩვენებელი არაფერი აქვს." + } }, "mcpOAuth": { "authMethod": "ავთენტიფიკაცია", diff --git a/apps/mobile/src/i18n/locales/kk.json b/apps/mobile/src/i18n/locales/kk.json index b2bfb86b472..176a795205b 100644 --- a/apps/mobile/src/i18n/locales/kk.json +++ b/apps/mobile/src/i18n/locales/kk.json @@ -3316,7 +3316,12 @@ "serverChecking": "Осы сервер тексерілуде…", "serverToolCount": "{{count}} құрал", "serverUnreachable": "Осы серверге қосылу мүмкін болмады", - "serverOff": "Осы сервер өшірулі" + "serverOff": "Осы сервер өшірулі", + "app": { + "label": "Құрал интерфейсі", + "failed": "Бұл құралдың интерфейсін жүктеу мүмкін болмады.", + "empty": "Бұл құралдың интерфейсінде көрсететін ештеңе жоқ." + } }, "mcpOAuth": { "authMethod": "Аутентификация", diff --git a/apps/mobile/src/i18n/locales/km.json b/apps/mobile/src/i18n/locales/km.json index 62523d91b26..2617a997c6c 100644 --- a/apps/mobile/src/i18n/locales/km.json +++ b/apps/mobile/src/i18n/locales/km.json @@ -3316,7 +3316,12 @@ "serverChecking": "កំពុងពិនិត្យម៉ាស៊ីនបម្រើនេះ…", "serverToolCount": "ឧបករណ៍ {{count}}", "serverUnreachable": "មិនអាចទៅដល់ម៉ាស៊ីនបម្រើនេះបានទេ", - "serverOff": "ម៉ាស៊ីនបម្រើនេះត្រូវបានបិទ" + "serverOff": "ម៉ាស៊ីនបម្រើនេះត្រូវបានបិទ", + "app": { + "label": "ចំណុចប្រទាក់ឧបករណ៍", + "failed": "មិនអាចផ្ទុកចំណុចប្រទាក់ឧបករណ៍នេះបានទេ។", + "empty": "ចំណុចប្រទាក់ឧបករណ៍នេះគ្មានអ្វីត្រូវបង្ហាញទេ។" + } }, "mcpOAuth": { "authMethod": "ការផ្ទៀងផ្ទាត់", diff --git a/apps/mobile/src/i18n/locales/kn.json b/apps/mobile/src/i18n/locales/kn.json index 90e6875297b..3d901bb8004 100644 --- a/apps/mobile/src/i18n/locales/kn.json +++ b/apps/mobile/src/i18n/locales/kn.json @@ -3316,7 +3316,12 @@ "serverChecking": "ಈ ಸರ್ವರ್ ಪರಿಶೀಲಿಸಲಾಗುತ್ತಿದೆ…", "serverToolCount": "{{count}} ಸಾಧನಗಳು", "serverUnreachable": "ಈ ಸರ್ವರ್ ತಲುಪಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ", - "serverOff": "ಈ ಸರ್ವರ್ ಆಫ್ ಆಗಿದೆ" + "serverOff": "ಈ ಸರ್ವರ್ ಆಫ್ ಆಗಿದೆ", + "app": { + "label": "ಉಪಕರಣ ಇಂಟರ್ಫೇಸ್", + "failed": "ಈ ಉಪಕರಣದ ಇಂಟರ್ಫೇಸ್ ಅನ್ನು ಲೋಡ್ ಮಾಡಲಾಗಲಿಲ್ಲ.", + "empty": "ಈ ಉಪಕರಣದ ಇಂಟರ್ಫೇಸ್‌ನಲ್ಲಿ ತೋರಿಸಲು ಏನೂ ಇಲ್ಲ." + } }, "mcpOAuth": { "authMethod": "ದೃಢೀಕರಣ", diff --git a/apps/mobile/src/i18n/locales/ko.json b/apps/mobile/src/i18n/locales/ko.json index 820a806390f..65d68e05682 100644 --- a/apps/mobile/src/i18n/locales/ko.json +++ b/apps/mobile/src/i18n/locales/ko.json @@ -3316,7 +3316,12 @@ "serverChecking": "이 서버를 확인하는 중…", "serverToolCount": "도구 {{count}}개", "serverUnreachable": "이 서버에 연결할 수 없습니다", - "serverOff": "이 서버는 꺼져 있습니다" + "serverOff": "이 서버는 꺼져 있습니다", + "app": { + "label": "도구 인터페이스", + "failed": "이 도구의 인터페이스를 불러올 수 없습니다.", + "empty": "이 도구의 인터페이스에 표시할 항목이 없습니다." + } }, "mcpOAuth": { "authMethod": "인증", diff --git a/apps/mobile/src/i18n/locales/lo.json b/apps/mobile/src/i18n/locales/lo.json index fb0c421085e..ef9f00b7b98 100644 --- a/apps/mobile/src/i18n/locales/lo.json +++ b/apps/mobile/src/i18n/locales/lo.json @@ -3316,7 +3316,12 @@ "serverChecking": "ກຳລັງກວດສອບເຊີບເວີນີ້…", "serverToolCount": "ເຄື່ອງມື {{count}}", "serverUnreachable": "ບໍ່ສາມາດເຂົ້າເຖິງເຊີບເວີນີ້ໄດ້", - "serverOff": "ເຊີບເວີນີ້ປິດຢູ່" + "serverOff": "ເຊີບເວີນີ້ປິດຢູ່", + "app": { + "label": "ອິນເຕີເຟດຂອງເຄື່ອງມື", + "failed": "ບໍ່ສາມາດໂຫຼດອິນເຕີເຟດຂອງເຄື່ອງມືນີ້ໄດ້.", + "empty": "ອິນເຕີເຟດຂອງເຄື່ອງມືນີ້ບໍ່ມີຫຍັງໃຫ້ສະແດງ." + } }, "mcpOAuth": { "authMethod": "ການຢືນຢັນຕົວຕົນ", diff --git a/apps/mobile/src/i18n/locales/lt.json b/apps/mobile/src/i18n/locales/lt.json index 6f0cf6c07ae..6b9c91eef11 100644 --- a/apps/mobile/src/i18n/locales/lt.json +++ b/apps/mobile/src/i18n/locales/lt.json @@ -3398,7 +3398,12 @@ "serverChecking": "Tikrinamas šis serveris…", "serverToolCount": "{{count}} įrankiai", "serverUnreachable": "Nepavyko pasiekti šio serverio", - "serverOff": "Šis serveris išjungtas" + "serverOff": "Šis serveris išjungtas", + "app": { + "label": "Įrankio sąsaja", + "failed": "Nepavyko įkelti šio įrankio sąsajos.", + "empty": "Šio įrankio sąsajoje nėra ką rodyti." + } }, "mcpOAuth": { "authMethod": "Autentifikavimas", diff --git a/apps/mobile/src/i18n/locales/lv.json b/apps/mobile/src/i18n/locales/lv.json index 4e499dbb5e0..02b26e7f846 100644 --- a/apps/mobile/src/i18n/locales/lv.json +++ b/apps/mobile/src/i18n/locales/lv.json @@ -3357,7 +3357,12 @@ "serverChecking": "Pārbauda šo serveri…", "serverToolCount": "{{count}} rīki", "serverUnreachable": "Neizdevās sasniegt šo serveri", - "serverOff": "Šis serveris ir izslēgts" + "serverOff": "Šis serveris ir izslēgts", + "app": { + "label": "Rīka saskarne", + "failed": "Neizdevās ielādēt šī rīka saskarni.", + "empty": "Šī rīka saskarnē nav ko parādīt." + } }, "mcpOAuth": { "authMethod": "Autentifikācija", diff --git a/apps/mobile/src/i18n/locales/mg.json b/apps/mobile/src/i18n/locales/mg.json index 35b27b33f87..6ac9b9b8d19 100644 --- a/apps/mobile/src/i18n/locales/mg.json +++ b/apps/mobile/src/i18n/locales/mg.json @@ -3316,7 +3316,12 @@ "serverChecking": "Manamarina ity server ity…", "serverToolCount": "fitaovana {{count}}", "serverUnreachable": "Tsy tratra ity server ity", - "serverOff": "Mihidy ity server ity" + "serverOff": "Mihidy ity server ity", + "app": { + "label": "Interfasan'ny fitaovana", + "failed": "Tsy tafiditra ny interfasan'ity fitaovana ity.", + "empty": "Tsy misy zavatra haseho ao amin'ny interfasan'ity fitaovana ity." + } }, "mcpOAuth": { "authMethod": "Fanamarinana", diff --git a/apps/mobile/src/i18n/locales/mi.json b/apps/mobile/src/i18n/locales/mi.json index 767f728f431..403af8a5878 100644 --- a/apps/mobile/src/i18n/locales/mi.json +++ b/apps/mobile/src/i18n/locales/mi.json @@ -3316,7 +3316,12 @@ "serverChecking": "E tirotiro ana i tēnei tūmau…", "serverToolCount": "{{count}} taputapu", "serverUnreachable": "Kāore i taea te toro atu ki tēnei tūmau", - "serverOff": "Kua weto tēnei tūmau" + "serverOff": "Kua weto tēnei tūmau", + "app": { + "label": "Atanga utauta", + "failed": "Kāore i taea te uta i te atanga o tēnei utauta.", + "empty": "Kāore he mea hei whakaatu i te atanga o tēnei utauta." + } }, "mcpOAuth": { "authMethod": "Motuhēhēnga", diff --git a/apps/mobile/src/i18n/locales/mk.json b/apps/mobile/src/i18n/locales/mk.json index c28305687b3..9deeabc8392 100644 --- a/apps/mobile/src/i18n/locales/mk.json +++ b/apps/mobile/src/i18n/locales/mk.json @@ -3316,7 +3316,12 @@ "serverChecking": "Проверка на овој сервер…", "serverToolCount": "{{count}} алатки", "serverUnreachable": "Не успеа да се достигне овој сервер", - "serverOff": "Овој сервер е исклучен" + "serverOff": "Овој сервер е исклучен", + "app": { + "label": "Интерфејс на алатката", + "failed": "Интерфејсот на алатката не можеше да се вчита.", + "empty": "Интерфејсот на алатката нема ништо да прикаже." + } }, "mcpOAuth": { "authMethod": "Автентикација", diff --git a/apps/mobile/src/i18n/locales/ml.json b/apps/mobile/src/i18n/locales/ml.json index e5050d2b69d..04f4ad83675 100644 --- a/apps/mobile/src/i18n/locales/ml.json +++ b/apps/mobile/src/i18n/locales/ml.json @@ -3316,7 +3316,12 @@ "serverChecking": "ഈ സെർവർ പരിശോധിക്കുന്നു…", "serverToolCount": "{{count}} ഉപകരണങ്ങൾ", "serverUnreachable": "ഈ സെർവറിൽ എത്തിച്ചേരാനായില്ല", - "serverOff": "ഈ സെർവർ ഓഫാണ്" + "serverOff": "ഈ സെർവർ ഓഫാണ്", + "app": { + "label": "ടൂൾ ഇന്റർഫേസ്", + "failed": "ഈ ടൂളിന്റെ ഇന്റർഫേസ് ലോഡ് ചെയ്യാൻ കഴിഞ്ഞില്ല.", + "empty": "ഈ ടൂളിന്റെ ഇന്റർഫേസിൽ കാണിക്കാൻ ഒന്നുമില്ല." + } }, "mcpOAuth": { "authMethod": "ആധികാരികത", diff --git a/apps/mobile/src/i18n/locales/mn.json b/apps/mobile/src/i18n/locales/mn.json index adef823ca9d..be8dd5b4442 100644 --- a/apps/mobile/src/i18n/locales/mn.json +++ b/apps/mobile/src/i18n/locales/mn.json @@ -3316,7 +3316,12 @@ "serverChecking": "Энэ серверийг шалгаж байна…", "serverToolCount": "{{count}} хэрэгсэл", "serverUnreachable": "Энэ серверт хүрч чадсангүй", - "serverOff": "Энэ сервер унтарсан" + "serverOff": "Энэ сервер унтарсан", + "app": { + "label": "Хэрэгслийн интерфейс", + "failed": "Энэ хэрэгслийн интерфейсийг ачаалж чадсангүй.", + "empty": "Энэ хэрэгслийн интерфейсэд үзүүлэх зүйл байхгүй." + } }, "mcpOAuth": { "authMethod": "Баталгаажуулалт", diff --git a/apps/mobile/src/i18n/locales/mr.json b/apps/mobile/src/i18n/locales/mr.json index ef88f86a01a..6652ee09647 100644 --- a/apps/mobile/src/i18n/locales/mr.json +++ b/apps/mobile/src/i18n/locales/mr.json @@ -3316,7 +3316,12 @@ "serverChecking": "हा सर्व्हर तपासत आहे…", "serverToolCount": "{{count}} साधने", "serverUnreachable": "या सर्व्हरपर्यंत पोहोचता आले नाही", - "serverOff": "हा सर्व्हर बंद आहे" + "serverOff": "हा सर्व्हर बंद आहे", + "app": { + "label": "साधन इंटरफेस", + "failed": "या साधनाचा इंटरफेस लोड करता आला नाही.", + "empty": "या साधनाच्या इंटरफेसमध्ये दाखवण्यासाठी काहीही नाही." + } }, "mcpOAuth": { "authMethod": "प्रमाणीकरण", diff --git a/apps/mobile/src/i18n/locales/ms.json b/apps/mobile/src/i18n/locales/ms.json index feff4007c30..685138701f3 100644 --- a/apps/mobile/src/i18n/locales/ms.json +++ b/apps/mobile/src/i18n/locales/ms.json @@ -3316,7 +3316,12 @@ "serverChecking": "Menyemak pelayan ini…", "serverToolCount": "{{count}} alat", "serverUnreachable": "Tidak dapat menghubungi pelayan ini", - "serverOff": "Pelayan ini dimatikan" + "serverOff": "Pelayan ini dimatikan", + "app": { + "label": "Antara muka alat", + "failed": "Antara muka alat ini tidak dapat dimuatkan.", + "empty": "Antara muka alat ini tiada apa-apa untuk dipaparkan." + } }, "mcpOAuth": { "authMethod": "Pengesahan", diff --git a/apps/mobile/src/i18n/locales/mt.json b/apps/mobile/src/i18n/locales/mt.json index 4a98d40dbc7..5df03c45233 100644 --- a/apps/mobile/src/i18n/locales/mt.json +++ b/apps/mobile/src/i18n/locales/mt.json @@ -3439,7 +3439,12 @@ "serverChecking": "Qed jiġi ċċekkjat dan is-server…", "serverToolCount": "{{count}} għodod", "serverUnreachable": "Ma setax jintlaħaq dan is-server", - "serverOff": "Dan is-server huwa mitfi" + "serverOff": "Dan is-server huwa mitfi", + "app": { + "label": "Interfaċċa tal-għodda", + "failed": "L-interfaċċa ta' din l-għodda ma rnexxietx tgħabba.", + "empty": "L-interfaċċa ta' din l-għodda m'għandha xejn li turi." + } }, "mcpOAuth": { "authMethod": "Awtentikazzjoni", diff --git a/apps/mobile/src/i18n/locales/my.json b/apps/mobile/src/i18n/locales/my.json index d401132738d..43727b61674 100644 --- a/apps/mobile/src/i18n/locales/my.json +++ b/apps/mobile/src/i18n/locales/my.json @@ -3316,7 +3316,12 @@ "serverChecking": "ဤဆာဗာကို စစ်ဆေးနေသည်…", "serverToolCount": "ကိရိယာ {{count}} ခု", "serverUnreachable": "ဤဆာဗာသို့ မရောက်နိုင်ခဲ့ပါ", - "serverOff": "ဤဆာဗာ ပိတ်ထားသည်" + "serverOff": "ဤဆာဗာ ပိတ်ထားသည်", + "app": { + "label": "ကိရိယာ အင်တာဖေ့စ်", + "failed": "ဤကိရိယာ၏ အင်တာဖေ့စ်ကို တင်၍ မရပါ။", + "empty": "ဤကိရိယာ၏ အင်တာဖေ့စ်တွင် ပြရန် ဘာမျှ မရှိပါ။" + } }, "mcpOAuth": { "authMethod": "အထောက်အထားစိစစ်ခြင်း", diff --git a/apps/mobile/src/i18n/locales/nb.json b/apps/mobile/src/i18n/locales/nb.json index aa9241d8b50..b54c2652722 100644 --- a/apps/mobile/src/i18n/locales/nb.json +++ b/apps/mobile/src/i18n/locales/nb.json @@ -3316,7 +3316,12 @@ "serverChecking": "Sjekker denne serveren…", "serverToolCount": "{{count}} verktøy", "serverUnreachable": "Kunne ikke nå denne serveren", - "serverOff": "Denne serveren er av" + "serverOff": "Denne serveren er av", + "app": { + "label": "Verktøygrensesnitt", + "failed": "Kunne ikke laste inn grensesnittet for dette verktøyet.", + "empty": "Grensesnittet for dette verktøyet har ingenting å vise." + } }, "mcpOAuth": { "authMethod": "Autentisering", diff --git a/apps/mobile/src/i18n/locales/ne.json b/apps/mobile/src/i18n/locales/ne.json index e13166f95cb..3f62208d26c 100644 --- a/apps/mobile/src/i18n/locales/ne.json +++ b/apps/mobile/src/i18n/locales/ne.json @@ -3316,7 +3316,12 @@ "serverChecking": "यो सर्भर जाँचिँदै छ…", "serverToolCount": "{{count}} उपकरण", "serverUnreachable": "यो सर्भरसम्म पुग्न सकिएन", - "serverOff": "यो सर्भर बन्द छ" + "serverOff": "यो सर्भर बन्द छ", + "app": { + "label": "उपकरण इन्टरफेस", + "failed": "यो उपकरणको इन्टरफेस लोड गर्न सकिएन.", + "empty": "यो उपकरणको इन्टरफेसमा देखाउनका लागि केही पनि छैन." + } }, "mcpOAuth": { "authMethod": "प्रमाणीकरण", diff --git a/apps/mobile/src/i18n/locales/nl.json b/apps/mobile/src/i18n/locales/nl.json index fcd8c74e093..c4799f48fac 100644 --- a/apps/mobile/src/i18n/locales/nl.json +++ b/apps/mobile/src/i18n/locales/nl.json @@ -3316,7 +3316,12 @@ "serverChecking": "Deze server controleren…", "serverToolCount": "{{count}} tools", "serverUnreachable": "Kon deze server niet bereiken", - "serverOff": "Deze server staat uit" + "serverOff": "Deze server staat uit", + "app": { + "label": "Interface van de tool", + "failed": "De interface van deze tool kon niet worden geladen.", + "empty": "De interface van deze tool heeft niets te tonen." + } }, "mcpOAuth": { "authMethod": "Authenticatie", diff --git a/apps/mobile/src/i18n/locales/om.json b/apps/mobile/src/i18n/locales/om.json index 42ff37cd6df..d42ffce3b2a 100644 --- a/apps/mobile/src/i18n/locales/om.json +++ b/apps/mobile/src/i18n/locales/om.json @@ -3316,7 +3316,12 @@ "serverChecking": "Seerverii kana sakatta'aa jira…", "serverToolCount": "meeshaalee {{count}}", "serverUnreachable": "Seerverii kana bira ga'uun hin danda'amne", - "serverOff": "Seerveriin kun dhaameera" + "serverOff": "Seerveriin kun dhaameera", + "app": { + "label": "Walqunnamtii meeshaa", + "failed": "Walqunnamtiin meeshaa kanaa hin dandeenye baafachuu.", + "empty": "Walqunnamtiin meeshaa kanaa waan agarsiisuu hin qabu." + } }, "mcpOAuth": { "authMethod": "Mirkaneessa", diff --git a/apps/mobile/src/i18n/locales/or.json b/apps/mobile/src/i18n/locales/or.json index b52991f63e1..e76a9315bd6 100644 --- a/apps/mobile/src/i18n/locales/or.json +++ b/apps/mobile/src/i18n/locales/or.json @@ -3316,7 +3316,12 @@ "serverChecking": "ଏହି ସର୍ଭର ଯାଞ୍ଚ କରାଯାଉଛି…", "serverToolCount": "{{count}} ସାଧନ", "serverUnreachable": "ଏହି ସର୍ଭର ପର୍ଯ୍ୟନ୍ତ ପହଞ୍ଚି ପାରିଲା ନାହିଁ", - "serverOff": "ଏହି ସର୍ଭର ବନ୍ଦ ଅଛି" + "serverOff": "ଏହି ସର୍ଭର ବନ୍ଦ ଅଛି", + "app": { + "label": "ସାଧନ ଇଣ୍ଟରଫେସ୍", + "failed": "ଏହି ସାଧନର ଇଣ୍ଟରଫେସ୍ ଲୋଡ୍ ହୋଇପାରିଲା ନାହିଁ.", + "empty": "ଏହି ସାଧନର ଇଣ୍ଟରଫେସ୍ରେ ଦେଖାଇବାକୁ କିଛି ନାହିଁ." + } }, "mcpOAuth": { "authMethod": "ପ୍ରମାଣୀକରଣ", diff --git a/apps/mobile/src/i18n/locales/pa.json b/apps/mobile/src/i18n/locales/pa.json index c76a1aa4322..8e877c973f1 100644 --- a/apps/mobile/src/i18n/locales/pa.json +++ b/apps/mobile/src/i18n/locales/pa.json @@ -3316,7 +3316,12 @@ "serverChecking": "ਇਹ ਸਰਵਰ ਜਾਂਚਿਆ ਜਾ ਰਿਹਾ ਹੈ…", "serverToolCount": "{{count}} ਟੂਲ", "serverUnreachable": "ਇਸ ਸਰਵਰ ਤੱਕ ਨਹੀਂ ਪਹੁੰਚ ਸਕਿਆ", - "serverOff": "ਇਹ ਸਰਵਰ ਬੰਦ ਹੈ" + "serverOff": "ਇਹ ਸਰਵਰ ਬੰਦ ਹੈ", + "app": { + "label": "ਸਾਧਨ ਇੰਟਰਫੇਸ", + "failed": "ਇਸ ਸਾਧਨ ਦਾ ਇੰਟਰਫੇਸ ਲੋਡ ਨਹੀਂ ਹੋ ਸਕਿਆ.", + "empty": "ਇਸ ਸਾਧਨ ਦੇ ਇੰਟਰਫੇਸ ਵਿੱਚ ਵੇਖਾਉਣ ਲਈ ਕੁਝ ਵੀ ਨਹੀਂ ਹੈ." + } }, "mcpOAuth": { "authMethod": "ਪ੍ਰਮਾਣੀਕਰਨ", diff --git a/apps/mobile/src/i18n/locales/pl.json b/apps/mobile/src/i18n/locales/pl.json index 4befd12ba40..71864dbd01c 100644 --- a/apps/mobile/src/i18n/locales/pl.json +++ b/apps/mobile/src/i18n/locales/pl.json @@ -3398,7 +3398,12 @@ "serverChecking": "Sprawdzanie tego serwera…", "serverToolCount": "{{count}} narzędzi", "serverUnreachable": "Nie udało się połączyć z tym serwerem", - "serverOff": "Ten serwer jest wyłączony" + "serverOff": "Ten serwer jest wyłączony", + "app": { + "label": "Interfejs narzędzia", + "failed": "Nie udało się załadować interfejsu tego narzędzia.", + "empty": "Interfejs tego narzędzia nie ma nic do pokazania." + } }, "mcpOAuth": { "authMethod": "Uwierzytelnianie", diff --git a/apps/mobile/src/i18n/locales/ps.json b/apps/mobile/src/i18n/locales/ps.json index d8d3984187c..ae3a8b2dcd6 100644 --- a/apps/mobile/src/i18n/locales/ps.json +++ b/apps/mobile/src/i18n/locales/ps.json @@ -3316,7 +3316,12 @@ "serverChecking": "دا سرور ګڼل کیږي…", "serverToolCount": "{{count}} وسیلې", "serverUnreachable": "دې سرور ته رسېدل ممکن نه شول", - "serverOff": "دا سرور بند دی" + "serverOff": "دا سرور بند دی", + "app": { + "label": "د وسیزې انټرفیس", + "failed": "د دې وسیزې انټرفیس لوډ نه شو.", + "empty": "د دې وسیزې انټرفیس کې د ښودلو لپاره هیڅ نشته." + } }, "mcpOAuth": { "authMethod": "تصدیق", diff --git a/apps/mobile/src/i18n/locales/pt-BR.json b/apps/mobile/src/i18n/locales/pt-BR.json index 73009391cc6..3c89e7a59f3 100644 --- a/apps/mobile/src/i18n/locales/pt-BR.json +++ b/apps/mobile/src/i18n/locales/pt-BR.json @@ -3357,7 +3357,12 @@ "serverChecking": "Verificando este servidor…", "serverToolCount": "{{count}} ferramentas", "serverUnreachable": "Não foi possível alcançar este servidor", - "serverOff": "Este servidor está desativado" + "serverOff": "Este servidor está desativado", + "app": { + "label": "Interface da ferramenta", + "failed": "Não foi possível carregar a interface desta ferramenta.", + "empty": "A interface desta ferramenta não tem nada para exibir." + } }, "mcpOAuth": { "authMethod": "Autenticação", diff --git a/apps/mobile/src/i18n/locales/pt.json b/apps/mobile/src/i18n/locales/pt.json index aec5714c8f6..96b4950f4bf 100644 --- a/apps/mobile/src/i18n/locales/pt.json +++ b/apps/mobile/src/i18n/locales/pt.json @@ -3357,7 +3357,12 @@ "serverChecking": "A verificar este servidor…", "serverToolCount": "{{count}} ferramentas", "serverUnreachable": "Não foi possível contactar este servidor", - "serverOff": "Este servidor está desativado" + "serverOff": "Este servidor está desativado", + "app": { + "label": "Interface da ferramenta", + "failed": "Não foi possível carregar a interface desta ferramenta.", + "empty": "A interface desta ferramenta não tem nada para mostrar." + } }, "mcpOAuth": { "authMethod": "Autenticação", diff --git a/apps/mobile/src/i18n/locales/ro.json b/apps/mobile/src/i18n/locales/ro.json index f38a5c4f302..9705ecd6a0b 100644 --- a/apps/mobile/src/i18n/locales/ro.json +++ b/apps/mobile/src/i18n/locales/ro.json @@ -3357,7 +3357,12 @@ "serverChecking": "Se verifică acest server…", "serverToolCount": "{{count}} instrumente", "serverUnreachable": "Nu s-a putut contacta acest server", - "serverOff": "Acest server este oprit" + "serverOff": "Acest server este oprit", + "app": { + "label": "Interfața instrumentului", + "failed": "Interfața acestui instrument nu a putut fi încărcată.", + "empty": "Interfața acestui instrument nu are nimic de afișat." + } }, "mcpOAuth": { "authMethod": "Autentificare", diff --git a/apps/mobile/src/i18n/locales/ru.json b/apps/mobile/src/i18n/locales/ru.json index 7dcd532c19d..91cf08cb66e 100644 --- a/apps/mobile/src/i18n/locales/ru.json +++ b/apps/mobile/src/i18n/locales/ru.json @@ -3398,7 +3398,12 @@ "serverChecking": "Проверка этого сервера…", "serverToolCount": "{{count}} инструментов", "serverUnreachable": "Не удалось связаться с этим сервером", - "serverOff": "Этот сервер выключен" + "serverOff": "Этот сервер выключен", + "app": { + "label": "Интерфейс инструмента", + "failed": "Не удалось загрузить интерфейс этого инструмента.", + "empty": "В интерфейсе этого инструмента нечего показать." + } }, "mcpOAuth": { "authMethod": "Аутентификация", diff --git a/apps/mobile/src/i18n/locales/si.json b/apps/mobile/src/i18n/locales/si.json index 6c4d7dad5ac..dda9b2a3538 100644 --- a/apps/mobile/src/i18n/locales/si.json +++ b/apps/mobile/src/i18n/locales/si.json @@ -3316,7 +3316,12 @@ "serverChecking": "මෙම සේවාදායකය පරීක්ෂා කරමින්…", "serverToolCount": "මෙවලම් {{count}}", "serverUnreachable": "මෙම සේවාදායකය වෙත ළඟා විය නොහැකි විය", - "serverOff": "මෙම සේවාදායකය ක්‍රියා විරහිතයි" + "serverOff": "මෙම සේවාදායකය ක්‍රියා විරහිතයි", + "app": { + "label": "මෙවලම් අතුරුමුහුණත", + "failed": "මෙම මෙවලමේ අතුරුමුහුණත පූරණය කළ නොහැකි විය.", + "empty": "මෙම මෙවලමේ අතුරුමුහුණතේ පෙන්වීමට කිසිවක් නොමැත." + } }, "mcpOAuth": { "authMethod": "සත්‍යාපනය", diff --git a/apps/mobile/src/i18n/locales/sk.json b/apps/mobile/src/i18n/locales/sk.json index 458ec2ed1b2..930c33e05a1 100644 --- a/apps/mobile/src/i18n/locales/sk.json +++ b/apps/mobile/src/i18n/locales/sk.json @@ -3398,7 +3398,12 @@ "serverChecking": "Kontrola tohto servera…", "serverToolCount": "{{count}} nástrojov", "serverUnreachable": "Nepodarilo sa pripojiť k tomuto serveru", - "serverOff": "Tento server je vypnutý" + "serverOff": "Tento server je vypnutý", + "app": { + "label": "Rozhranie nástroja", + "failed": "Rozhranie tohto nástroja sa nepodarilo načítať.", + "empty": "Rozhranie tohto nástroja nemá čo zobraziť." + } }, "mcpOAuth": { "authMethod": "Overenie", diff --git a/apps/mobile/src/i18n/locales/sl.json b/apps/mobile/src/i18n/locales/sl.json index 7d7872b6ccc..a8d6dfd5761 100644 --- a/apps/mobile/src/i18n/locales/sl.json +++ b/apps/mobile/src/i18n/locales/sl.json @@ -3398,7 +3398,12 @@ "serverChecking": "Preverjanje tega strežnika…", "serverToolCount": "{{count}} orodij", "serverUnreachable": "Ni bilo mogoče doseči tega strežnika", - "serverOff": "Ta strežnik je izklopljen" + "serverOff": "Ta strežnik je izklopljen", + "app": { + "label": "Vmesnik orodja", + "failed": "Vmesnika tega orodja ni bilo mogoče naložiti.", + "empty": "Vmesnik tega orodja nima ničesar za prikaz." + } }, "mcpOAuth": { "authMethod": "Preverjanje pristnosti", diff --git a/apps/mobile/src/i18n/locales/so.json b/apps/mobile/src/i18n/locales/so.json index 6afc54d62f0..84228bad2fd 100644 --- a/apps/mobile/src/i18n/locales/so.json +++ b/apps/mobile/src/i18n/locales/so.json @@ -3316,7 +3316,12 @@ "serverChecking": "Waa la hubinayaa serverkan…", "serverToolCount": "{{count}} qalab", "serverUnreachable": "Lama gaari karin serverkan", - "serverOff": "Serverkan waa dammaan" + "serverOff": "Serverkan waa dammaan", + "app": { + "label": "Isdhexgalka qalabka", + "failed": "Isdhexgalka qalabkan lama soo dejin karin.", + "empty": "Isdhexgalka qalabkan wax la muujiyo ma jiro." + } }, "mcpOAuth": { "authMethod": "Xaqiijinta", diff --git a/apps/mobile/src/i18n/locales/sq.json b/apps/mobile/src/i18n/locales/sq.json index b85de0719e2..5e2ba26c9e5 100644 --- a/apps/mobile/src/i18n/locales/sq.json +++ b/apps/mobile/src/i18n/locales/sq.json @@ -3316,7 +3316,12 @@ "serverChecking": "Duke kontrolluar këtë server…", "serverToolCount": "{{count}} mjete", "serverUnreachable": "Nuk u arrit ky server", - "serverOff": "Ky server është i fikur" + "serverOff": "Ky server është i fikur", + "app": { + "label": "Ndërfaqja e mjetit", + "failed": "Ndërfaqja e këtij mjeti nuk mund të ngarkohej.", + "empty": "Ndërfaqja e këtij mjeti nuk ka gjë për të shfaqur." + } }, "mcpOAuth": { "authMethod": "Vërtetimi", diff --git a/apps/mobile/src/i18n/locales/sr.json b/apps/mobile/src/i18n/locales/sr.json index abd887cd544..4308eb905b5 100644 --- a/apps/mobile/src/i18n/locales/sr.json +++ b/apps/mobile/src/i18n/locales/sr.json @@ -3357,7 +3357,12 @@ "serverChecking": "Provera ovog servera…", "serverToolCount": "{{count}} alata", "serverUnreachable": "Nije bilo moguće doći do ovog servera", - "serverOff": "Ovaj server je isključen" + "serverOff": "Ovaj server je isključen", + "app": { + "label": "Interfejs alata", + "failed": "Interfejs ovog alata nije mogao da se učita.", + "empty": "Interfejs ovog alata nema šta da prikaže." + } }, "mcpOAuth": { "authMethod": "Autentifikacija", diff --git a/apps/mobile/src/i18n/locales/sv.json b/apps/mobile/src/i18n/locales/sv.json index 348cae3d945..345404c20a1 100644 --- a/apps/mobile/src/i18n/locales/sv.json +++ b/apps/mobile/src/i18n/locales/sv.json @@ -3316,7 +3316,12 @@ "serverChecking": "Kontrollerar denna server…", "serverToolCount": "{{count}} verktyg", "serverUnreachable": "Kunde inte nå denna server", - "serverOff": "Denna server är avstängd" + "serverOff": "Denna server är avstängd", + "app": { + "label": "Verktygsgränssnitt", + "failed": "Det här verktygets gränssnitt kunde inte läsas in.", + "empty": "Det här verktygets gränssnitt har inget att visa." + } }, "mcpOAuth": { "authMethod": "Autentisering", diff --git a/apps/mobile/src/i18n/locales/sw.json b/apps/mobile/src/i18n/locales/sw.json index c1a4f5f786a..ba4ba780ccf 100644 --- a/apps/mobile/src/i18n/locales/sw.json +++ b/apps/mobile/src/i18n/locales/sw.json @@ -3316,7 +3316,12 @@ "serverChecking": "Inakagua seva hii…", "serverToolCount": "zana {{count}}", "serverUnreachable": "Haikuweza kufikia seva hii", - "serverOff": "Seva hii imezimwa" + "serverOff": "Seva hii imezimwa", + "app": { + "label": "Kiolesura cha zana", + "failed": "Kiolesura cha zana hii hakukuweza kupakiwa.", + "empty": "Kiolesura cha zana hii hakina kitu cha kuonyesha." + } }, "mcpOAuth": { "authMethod": "Uthibitishaji", diff --git a/apps/mobile/src/i18n/locales/ta.json b/apps/mobile/src/i18n/locales/ta.json index 281c1d688ed..2f4a6fdea55 100644 --- a/apps/mobile/src/i18n/locales/ta.json +++ b/apps/mobile/src/i18n/locales/ta.json @@ -3316,7 +3316,12 @@ "serverChecking": "இந்தச் சேவையகம் சரிபார்க்கப்படுகிறது…", "serverToolCount": "{{count}} கருவிகள்", "serverUnreachable": "இந்தச் சேவையகத்தை அணுக முடியவில்லை", - "serverOff": "இந்தச் சேவையகம் அணைக்கப்பட்டுள்ளது" + "serverOff": "இந்தச் சேவையகம் அணைக்கப்பட்டுள்ளது", + "app": { + "label": "கருவி இடைமுகம்", + "failed": "இந்த கருவியின் இடைமுகத்தை ஏற்ற முடியவில்லை.", + "empty": "இந்த கருவியின் இடைமுகத்தில் காட்ட எதுவும் இல்லை." + } }, "mcpOAuth": { "authMethod": "அங்கீகாரம்", diff --git a/apps/mobile/src/i18n/locales/te.json b/apps/mobile/src/i18n/locales/te.json index b2c7d76a06f..116ef19f651 100644 --- a/apps/mobile/src/i18n/locales/te.json +++ b/apps/mobile/src/i18n/locales/te.json @@ -3316,7 +3316,12 @@ "serverChecking": "ఈ సర్వర్‌ను తనిఖీ చేస్తోంది…", "serverToolCount": "{{count}} సాధనాలు", "serverUnreachable": "ఈ సర్వర్‌ను చేరుకోలేకపోయింది", - "serverOff": "ఈ సర్వర్ ఆఫ్‌లో ఉంది" + "serverOff": "ఈ సర్వర్ ఆఫ్‌లో ఉంది", + "app": { + "label": "టూల్ ఇంటర్‌ఫేస్", + "failed": "ఈ టూల్ ఇంటర్‌ఫేస్‌ను లోడ్ చేయడం సాధ్యం కాలేదు.", + "empty": "ఈ టూల్ ఇంటర్‌ఫేస్‌లో చూపడానికి ఏమీ లేదు." + } }, "mcpOAuth": { "authMethod": "ప్రామాణీకరణ", diff --git a/apps/mobile/src/i18n/locales/th.json b/apps/mobile/src/i18n/locales/th.json index 9184e9beb57..3a9b090deac 100644 --- a/apps/mobile/src/i18n/locales/th.json +++ b/apps/mobile/src/i18n/locales/th.json @@ -3316,7 +3316,12 @@ "serverChecking": "กำลังตรวจสอบเซิร์ฟเวอร์นี้…", "serverToolCount": "{{count}} เครื่องมือ", "serverUnreachable": "ไม่สามารถเข้าถึงเซิร์ฟเวอร์นี้ได้", - "serverOff": "เซิร์ฟเวอร์นี้ปิดอยู่" + "serverOff": "เซิร์ฟเวอร์นี้ปิดอยู่", + "app": { + "label": "อินเทอร์เฟซของเครื่องมือ", + "failed": "ไม่สามารถโหลดอินเทอร์เฟซของเครื่องมือนี้ได้.", + "empty": "อินเทอร์เฟซของเครื่องมือนี้ไม่มีอะไรจะแสดง." + } }, "mcpOAuth": { "authMethod": "การยืนยันตัวตน", diff --git a/apps/mobile/src/i18n/locales/tr.json b/apps/mobile/src/i18n/locales/tr.json index d0e6ddee409..16689d60830 100644 --- a/apps/mobile/src/i18n/locales/tr.json +++ b/apps/mobile/src/i18n/locales/tr.json @@ -3316,7 +3316,12 @@ "serverChecking": "Bu sunucu denetleniyor…", "serverToolCount": "{{count}} araç", "serverUnreachable": "Bu sunucuya ulaşılamadı", - "serverOff": "Bu sunucu kapalı" + "serverOff": "Bu sunucu kapalı", + "app": { + "label": "Araç arayüzü", + "failed": "Bu aracın arayüzü yüklenemedi.", + "empty": "Bu aracın arayüzünde gösterilecek bir şey yok." + } }, "mcpOAuth": { "authMethod": "Kimlik doğrulama", diff --git a/apps/mobile/src/i18n/locales/uk.json b/apps/mobile/src/i18n/locales/uk.json index e750dd34f01..d1f78b1f0ba 100644 --- a/apps/mobile/src/i18n/locales/uk.json +++ b/apps/mobile/src/i18n/locales/uk.json @@ -3398,7 +3398,12 @@ "serverChecking": "Перевірка цього сервера…", "serverToolCount": "{{count}} інструментів", "serverUnreachable": "Не вдалося зв’язатися з цим сервером", - "serverOff": "Цей сервер вимкнено" + "serverOff": "Цей сервер вимкнено", + "app": { + "label": "Інтерфейс інструменту", + "failed": "Не вдалося завантажити інтерфейс цього інструменту.", + "empty": "В інтерфейсі цього інструменту немає чого показувати." + } }, "mcpOAuth": { "authMethod": "Автентифікація", diff --git a/apps/mobile/src/i18n/locales/ur.json b/apps/mobile/src/i18n/locales/ur.json index d9d374ce685..00d2ec0efc6 100644 --- a/apps/mobile/src/i18n/locales/ur.json +++ b/apps/mobile/src/i18n/locales/ur.json @@ -3316,7 +3316,12 @@ "serverChecking": "اس سرور کی جانچ ہو رہی ہے…", "serverToolCount": "{{count}} ٹولز", "serverUnreachable": "اس سرور تک نہیں پہنچ سکا", - "serverOff": "یہ سرور بند ہے" + "serverOff": "یہ سرور بند ہے", + "app": { + "label": "ٹول کا انٹرفیس", + "failed": "اس ٹول کا انٹرفیس لوڈ نہیں ہو سکا۔", + "empty": "اس ٹول کے انٹرفیس میں دکھانے کے لیے کچھ نہیں ہے۔" + } }, "mcpOAuth": { "authMethod": "تصدیق", diff --git a/apps/mobile/src/i18n/locales/uz.json b/apps/mobile/src/i18n/locales/uz.json index 6e3a3cfa578..c1c9776b92a 100644 --- a/apps/mobile/src/i18n/locales/uz.json +++ b/apps/mobile/src/i18n/locales/uz.json @@ -3316,7 +3316,12 @@ "serverChecking": "Bu server tekshirilmoqda…", "serverToolCount": "{{count}} vosita", "serverUnreachable": "Bu serverga yetib boʻlmadi", - "serverOff": "Bu server oʻchirilgan" + "serverOff": "Bu server oʻchirilgan", + "app": { + "label": "Vosita interfeysi", + "failed": "Ushbu vositanning interfeysini yuklab bo‘lmadi.", + "empty": "Ushbu vositanning interfeysida ko‘rsatish uchun hech narsa yo‘q." + } }, "mcpOAuth": { "authMethod": "Autentifikatsiya", diff --git a/apps/mobile/src/i18n/locales/vi.json b/apps/mobile/src/i18n/locales/vi.json index f3430edb12b..c1026f9f77a 100644 --- a/apps/mobile/src/i18n/locales/vi.json +++ b/apps/mobile/src/i18n/locales/vi.json @@ -3316,7 +3316,12 @@ "serverChecking": "Đang kiểm tra máy chủ này…", "serverToolCount": "{{count}} công cụ", "serverUnreachable": "Không thể kết nối với máy chủ này", - "serverOff": "Máy chủ này đang tắt" + "serverOff": "Máy chủ này đang tắt", + "app": { + "label": "Giao diện công cụ", + "failed": "Không thể tải giao diện của công cụ này.", + "empty": "Giao diện của công cụ này không có gì để hiển thị." + } }, "mcpOAuth": { "authMethod": "Xác thực", diff --git a/apps/mobile/src/i18n/locales/yo.json b/apps/mobile/src/i18n/locales/yo.json index 685e7188bfe..b41f6395eb2 100644 --- a/apps/mobile/src/i18n/locales/yo.json +++ b/apps/mobile/src/i18n/locales/yo.json @@ -3316,7 +3316,12 @@ "serverChecking": "Ń ṣàyẹ̀wò sáfà yìí…", "serverToolCount": "irinṣẹ́ {{count}}", "serverUnreachable": "A kò lè dé sáfà yìí", - "serverOff": "Sáfà yìí ti pa" + "serverOff": "Sáfà yìí ti pa", + "app": { + "label": "Ẹ̀rọ ìjápọ̀", + "failed": "A kò le gbé ẹ̀rọ ìjápọ̀ yìí sókè.", + "empty": "Ẹ̀rọ ìjápọ̀ yìí kò ní nnkan láti fihàn." + } }, "mcpOAuth": { "authMethod": "Ìjẹ́rìísí", diff --git a/apps/mobile/src/i18n/locales/zh-Hans.json b/apps/mobile/src/i18n/locales/zh-Hans.json index 74b837519a5..f1e090eef30 100644 --- a/apps/mobile/src/i18n/locales/zh-Hans.json +++ b/apps/mobile/src/i18n/locales/zh-Hans.json @@ -3316,7 +3316,12 @@ "serverChecking": "正在检查此服务器…", "serverToolCount": "{{count}} 个工具", "serverUnreachable": "无法连接此服务器", - "serverOff": "此服务器已关闭" + "serverOff": "此服务器已关闭", + "app": { + "label": "工具界面", + "failed": "无法加载此工具的界面。", + "empty": "此工具的界面没有可显示的内容。" + } }, "mcpOAuth": { "authMethod": "身份验证", diff --git a/apps/mobile/src/i18n/locales/zh-Hant.json b/apps/mobile/src/i18n/locales/zh-Hant.json index 4e5e3d88e32..a63bd594f85 100644 --- a/apps/mobile/src/i18n/locales/zh-Hant.json +++ b/apps/mobile/src/i18n/locales/zh-Hant.json @@ -3316,7 +3316,12 @@ "serverChecking": "正在檢查此伺服器…", "serverToolCount": "{{count}} 個工具", "serverUnreachable": "無法連線至此伺服器", - "serverOff": "此伺服器已關閉" + "serverOff": "此伺服器已關閉", + "app": { + "label": "工具介面", + "failed": "無法載入此工具的介面。", + "empty": "此工具的介面沒有可顯示的內容。" + } }, "mcpOAuth": { "authMethod": "驗證", diff --git a/apps/mobile/src/i18n/locales/zu.json b/apps/mobile/src/i18n/locales/zu.json index 00e557a4704..30b60faae8a 100644 --- a/apps/mobile/src/i18n/locales/zu.json +++ b/apps/mobile/src/i18n/locales/zu.json @@ -3316,7 +3316,12 @@ "serverChecking": "Ihlola le seve…", "serverToolCount": "amathuluzi angu-{{count}}", "serverUnreachable": "Ayikwazanga ukufinyelela le seve", - "serverOff": "Le seve ivaliwe" + "serverOff": "Le seve ivaliwe", + "app": { + "label": "Isixhumi esibonakalayo sethuluzi", + "failed": "Ayikwazanga ukulayisha isixhumi esibonakalayo salesi thuluzi.", + "empty": "Isixhumi esibonakalayo salesi thuluzi asinakho okufanele kuboniswe." + } }, "mcpOAuth": { "authMethod": "Ukuqinisekisa", diff --git a/apps/mobile/src/lib/chat/remote-mcp-app.test.ts b/apps/mobile/src/lib/chat/remote-mcp-app.test.ts new file mode 100644 index 00000000000..a2102091ae3 --- /dev/null +++ b/apps/mobile/src/lib/chat/remote-mcp-app.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, it } from 'vitest'; + +import { + contentSecurityPolicy, + DEFAULT_VIEW_HEIGHT, + MAX_VIEW_BYTES, + VIEW_HOST_PAGE, + viewArguments, + viewDocument, + viewHeightOf, +} from './remote-mcp-app'; + +/** + * The document a view is drawn from, and the page that draws it: the policy a + * server-declared resource gets, and the sandbox it cannot leave. + */ + +describe('the policy a view runs under', () => { + it('is nothing at all when the resource declared nothing', () => { + expect(contentSecurityPolicy(undefined)).toBe( + "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; " + + "img-src data:; font-src data:; media-src data:; connect-src 'none'; " + + "frame-src 'none'; base-uri 'none'; form-action 'none'; object-src 'none'" + ); + expect(contentSecurityPolicy({})).not.toContain('https://'); + }); + + it('keeps only the origins the resource named, for what it named them for', () => { + const policy = contentSecurityPolicy({ + ui: { + csp: { + connectDomains: ['https://api.example'], + resourceDomains: ['https://cdn.example'], + }, + }, + }); + expect(policy).toContain('connect-src https://api.example'); + expect(policy).toContain("script-src 'unsafe-inline' https://cdn.example"); + expect(policy).not.toContain('https://api.example https'); + expect(policy).toContain("frame-src 'none'"); + }); + + it('drops a request that would widen the sandbox rather than trust it', () => { + const policy = contentSecurityPolicy({ + ui: { + csp: { + connectDomains: ['*', "'unsafe-eval'", 'https://ok.example', 'https://a.example/x;y'], + resourceDomains: ["'unsafe-inline' 'unsafe-eval'", 'data:text/html,', + "default-src 'none'" + ); + expect(document.indexOf('Content-Security-Policy')).toBeLessThan(document.indexOf('')); + expect(document.indexOf('Content-Security-Policy')).toBeLessThan(document.indexOf('<script>')); + }); + + it('still holds a document with no head, because a view is not required to have one', () => { + const document = viewDocument('<p>a view</p>', "default-src 'none'"); + expect(document.startsWith('<meta http-equiv="Content-Security-Policy"')).toBe(true); + expect(document).toContain('<p>a view</p>'); + }); +}); + +describe('the page a view is drawn in', () => { + it('runs the view in a sandbox that can script and nothing else', () => { + expect(VIEW_HOST_PAGE).toContain('sandbox="allow-scripts"'); + for (const widened of [ + 'allow-same-origin', + 'allow-top-navigation', + 'allow-popups', + 'allow-forms', + 'allow-modals', + ]) { + expect(VIEW_HOST_PAGE).not.toContain(widened); + } + }); + + it('relays only the sandbox’s own messages, and posts them as text', () => { + expect(VIEW_HOST_PAGE).toContain('event.source !== view.contentWindow'); + expect(VIEW_HOST_PAGE).toContain('window.ReactNativeWebView.postMessage(text)'); + }); + + it('is the only document the webview loads, and it loads it as a file', () => { + // The sandbox's document comes from the resource, not from a URL the page + // fetches: `srcdoc` is what keeps the view from reaching the network on + // its own say. + expect(VIEW_HOST_PAGE).toContain('view.srcdoc = html'); + expect(MAX_VIEW_BYTES).toBeGreaterThan(0); + }); +}); + +describe('the box a view takes in the transcript', () => { + it('keeps the row where it was until the view reports a size', () => { + expect(viewHeightOf(undefined)).toBe(DEFAULT_VIEW_HEIGHT); + expect(viewHeightOf(210)).toBe(210); + }); + + it('holds a view that reports too little or too much inside the row', () => { + expect(viewHeightOf(4)).toBe(120); + expect(viewHeightOf(4096)).toBe(520); + }); +}); + +describe('the arguments a view is handed', () => { + it('are the model’s, without the field the harness adds to every call', () => { + expect(viewArguments({ wait: true, city: 'Oslo' })).toEqual({ city: 'Oslo' }); + expect(viewArguments({})).toEqual({}); + }); +}); diff --git a/apps/mobile/src/lib/chat/remote-mcp-app.ts b/apps/mobile/src/lib/chat/remote-mcp-app.ts new file mode 100644 index 00000000000..50c7f84c6b0 --- /dev/null +++ b/apps/mobile/src/lib/chat/remote-mcp-app.ts @@ -0,0 +1,158 @@ +import { z } from 'zod'; + +/** + * The document one MCP Apps view is drawn from, and the sandbox it is drawn in. + * + * The resource's HTML comes from somebody else's server, so it is treated as + * hostile: it runs in an iframe with an opaque origin, with no file access, no + * way to navigate the page around it, and a policy built only from what the + * server declared — and from nothing when it declared nothing. + */ + +/** The height a transcript row gives a view before the view reports its own. */ +export const DEFAULT_VIEW_HEIGHT = 240; + +/** The tallest a view may draw itself in a transcript row. */ +export const MAX_VIEW_HEIGHT = 520; + +/** The shortest a view may draw itself in a transcript row. */ +const MIN_VIEW_HEIGHT = 120; + +/** + * The view's reported height inside the box a transcript row may take, so a + * view that reports nothing, or reports a page's height, keeps the transcript + * where it was. + */ +export function viewHeightOf(reported: number | undefined): number { + const height = reported === undefined ? DEFAULT_VIEW_HEIGHT : Math.ceil(reported); + return Math.min(MAX_VIEW_HEIGHT, Math.max(MIN_VIEW_HEIGHT, height)); +} + +/** + * The arguments the view is handed: the model's, without the field the harness + * adds to every call. The view is told what the server was called with, which + * is what the server itself read. + */ +export function viewArguments(input: Readonly<Record<string, unknown>>) { + const arguments_ = { ...input }; + delete arguments_.wait; + return arguments_; +} + +/** + * The largest view the sandbox takes. The HTML is handed to the page as a + * JavaScript string, so a document far larger than a UI is not one; it is + * refused rather than turned into a bridge message. + */ +export const MAX_VIEW_BYTES = 1_000_000; + +/** + * One origin a policy may name. Anything else — a wildcard, `'unsafe-inline'`, + * a bare word — is dropped, because the policy is the resource's own request + * and a request cannot widen the sandbox beyond what the host allows. + */ +const ORIGIN = /^(?:https?|wss?):\/\/[^\s;'"<>]+$/u; + +const ResourceCspSchema = z.object({ + connectDomains: z.array(z.string()).optional(), + resourceDomains: z.array(z.string()).optional(), + frameDomains: z.array(z.string()).optional(), + baseUriDomains: z.array(z.string()).optional(), +}); + +const ViewMetaSchema = z + .object({ ui: z.object({ csp: ResourceCspSchema.optional() }).optional() }) + .optional(); + +const originsOf = (domains: readonly string[] | undefined): readonly string[] => + (domains ?? []).filter(domain => ORIGIN.test(domain)); + +/** The policy tokens for one directive: the named origins, or nothing. */ +const tokensFor = (origins: readonly string[]): string => + origins.length === 0 ? "'none'" : origins.join(' '); + +/** + * The policy the view runs under: nothing by default, and only the origins the + * resource declared for the one kind of load it named. + * + * `'unsafe-inline'` is allowed for scripts and styles because a view is one + * document with its own code in it, and it has nowhere to fetch code from + * unless the resource named a host for it. Inline code in a document with no + * same-origin access and no navigation is the shape the Apps specification + * expects a view to have. + */ +export function contentSecurityPolicy(meta: Readonly<Record<string, unknown>> | undefined): string { + const csp = ViewMetaSchema.parse(meta)?.ui?.csp; + const resources = originsOf(csp?.resourceDomains); + const connections = originsOf(csp?.connectDomains); + const frames = originsOf(csp?.frameDomains); + const bases = originsOf(csp?.baseUriDomains); + const inline = "'unsafe-inline'"; + return [ + "default-src 'none'", + `script-src ${[inline, ...resources].join(' ')}`, + `style-src ${[inline, ...resources].join(' ')}`, + `img-src ${[...resources, 'data:'].join(' ')}`, + `font-src ${[...resources, 'data:'].join(' ')}`, + `media-src ${[...resources, 'data:'].join(' ')}`, + `connect-src ${tokensFor(connections)}`, + `frame-src ${tokensFor(frames)}`, + `base-uri ${tokensFor(bases)}`, + "form-action 'none'", + "object-src 'none'", + ].join('; '); +} + +/** + * The view's HTML with its policy in front of it. + * + * The policy is a meta element, because the document is loaded as `srcdoc` + * with no headers of its own. It goes first in the head, before the resource's + * own tags, so nothing in the document can run before it applies. + */ +export function viewDocument(html: string, policy: string): string { + const meta = `<meta http-equiv="Content-Security-Policy" content="${policy.replaceAll('"', '"')}">`; + const head = /<head[^>]*>/iu.exec(html); + if (head === null) { + return `${meta}${html}`; + } + const at = head.index + head[0].length; + return `${html.slice(0, at)}${meta}${html.slice(at)}`; +} + +/** + * The app's page: the one document the WebView loads. + * + * It owns the sandboxed iframe the view runs in and nothing else. The view is + * `sandbox="allow-scripts"` — an opaque origin, no file access, no way to + * navigate this page or read it — and its traffic crosses in both directions as + * JSON text: the host's messages are posted into the iframe, and only that + * iframe's own messages are handed back to the app. A message from anywhere + * else on this page is dropped. + * + * The names are the seam the app calls: `window.kiloMcpHost.mount(html)` puts a + * document in the sandbox, and `window.kiloMcpHost.deliver(text)` posts one + * bridge message into it. + */ +export const VIEW_HOST_PAGE = `<!doctype html><html><head> +<meta name="viewport" content="width=device-width,initial-scale=1,maximum-scale=1"> +<style> +html,body{margin:0;padding:0;background:transparent;overflow:hidden;} +#view{display:block;width:100%;height:100%;border:0;background:transparent;} +</style></head><body><iframe id="view" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe> +<script> +const view = document.getElementById('view'); +const post = text => window.ReactNativeWebView.postMessage(text); +window.kiloMcpHost = { + mount: html => { view.srcdoc = html; }, + deliver: text => { + if (view.contentWindow) { + view.contentWindow.postMessage(JSON.parse(text), '*'); + } + }, +}; +window.addEventListener('message', event => { + if (event.source !== view.contentWindow) return; + post(typeof event.data === 'string' ? event.data : JSON.stringify(event.data)); +}); +</script></body></html>`; diff --git a/apps/mobile/src/lib/chat/remote-mcp-state.test.ts b/apps/mobile/src/lib/chat/remote-mcp-state.test.ts index a5474580d50..f6088b8d458 100644 --- a/apps/mobile/src/lib/chat/remote-mcp-state.test.ts +++ b/apps/mobile/src/lib/chat/remote-mcp-state.test.ts @@ -1,11 +1,13 @@ import { describe, expect, it } from 'vitest'; import { + discoveryOf, type RemoteMcpDiscovery, remoteServerStates, remoteServerToolsFor, + remoteServerViewsFor, } from './remote-mcp-state'; -import { server, stateOf, tool } from './remote-mcp.test-helpers'; +import { server, stateOf, tool, viewTool } from './remote-mcp.test-helpers'; /** * The join between the stored list and the discovery, proven directly: an @@ -17,7 +19,7 @@ describe('the pure helpers', () => { it('merges an enabled server’s tools, named for the server', () => { const alpha = server({ id: 'alpha' }); const discovered = new Map<string, RemoteMcpDiscovery>([ - ['alpha', { tools: [tool('mcp_alpha_read'), tool('mcp_alpha_write')] }], + ['alpha', discoveryOf('alpha', [tool('mcp_alpha_read'), tool('mcp_alpha_write')])], ]); expect(remoteServerToolsFor([alpha], discovered).map(entry => entry.definition.name)).toEqual([ @@ -32,7 +34,7 @@ describe('the pure helpers', () => { it('contributes nothing from a disabled server, whatever was cached', () => { const alpha = server({ id: 'alpha', enabled: false }); const discovered = new Map<string, RemoteMcpDiscovery>([ - ['alpha', { tools: [tool('mcp_alpha_read')] }], + ['alpha', discoveryOf('alpha', [tool('mcp_alpha_read')])], ]); expect(remoteServerToolsFor([alpha], discovered)).toEqual([]); @@ -45,8 +47,8 @@ describe('the pure helpers', () => { const alpha = server({ id: 'alpha' }); const beta = server({ id: 'beta' }); const discovered = new Map<string, RemoteMcpDiscovery>([ - ['alpha', { tools: [tool('mcp_alpha_read')] }], - ['beta', { tools: [tool('mcp_beta_read')] }], + ['alpha', discoveryOf('alpha', [tool('mcp_alpha_read')])], + ['beta', discoveryOf('beta', [tool('mcp_beta_read')])], ]); expect( @@ -57,7 +59,7 @@ describe('the pure helpers', () => { it('leaves a failed discovery failed and offers none of its tools', () => { const alpha = server({ id: 'alpha' }); const discovered = new Map<string, RemoteMcpDiscovery>([ - ['alpha', { tools: [tool('mcp_alpha_read')], failed: true }], + ['alpha', { ...discoveryOf('alpha', [tool('mcp_alpha_read')]), failed: true }], ]); expect(remoteServerToolsFor([alpha], discovered)).toEqual([]); @@ -74,9 +76,25 @@ describe('the pure helpers', () => { ]); }); + it('keeps a view only for the tools that carry one', () => { + const alpha = server({ id: 'alpha' }); + const beta = server({ id: 'beta', enabled: false }); + const discovered = new Map<string, RemoteMcpDiscovery>([ + [ + 'alpha', + discoveryOf('alpha', [viewTool('mcp_alpha_chart', 'chart', 'ui://alpha/chart.html')]), + ], + ['beta', discoveryOf('beta', [viewTool('mcp_beta_chart', 'chart', 'ui://beta/chart.html')])], + ]); + + expect([...remoteServerViewsFor([alpha, beta], discovered).keys()]).toEqual([ + 'mcp_alpha_chart', + ]); + }); + it('offers a Retry on a server that answered with no tools', () => { const alpha = server({ id: 'alpha' }); - const discovered = new Map<string, RemoteMcpDiscovery>([['alpha', { tools: [] }]]); + const discovered = new Map<string, RemoteMcpDiscovery>([['alpha', discoveryOf('alpha', [])]]); expect(remoteServerStates([alpha], discovered)).toEqual([ stateOf(alpha, { status: 'ready', toolCount: 0, retryable: true }), diff --git a/apps/mobile/src/lib/chat/remote-mcp-state.ts b/apps/mobile/src/lib/chat/remote-mcp-state.ts index 0f0d5c290b4..9209e113513 100644 --- a/apps/mobile/src/lib/chat/remote-mcp-state.ts +++ b/apps/mobile/src/lib/chat/remote-mcp-state.ts @@ -1,4 +1,5 @@ import { type Tool } from '@kilocode/harness-sdk'; +import { type RemoteMcpOfferedTool } from '@kilocode/harness-sdk/plugins/remote-mcp'; import { type StoredRemoteMcpServer } from './remote-mcp-store'; @@ -14,11 +15,29 @@ type RemoteMcpStatus = 'idle' | 'connecting' | 'ready' | 'failed' | 'signIn'; /** One server's discovery result, keyed by the server's id. */ export type RemoteMcpDiscovery = { readonly tools: readonly Tool[]; + /** The MCP Apps view a tool carries, by the name the model calls the tool by. */ + readonly views: ReadonlyMap<string, RemoteMcpToolView>; + /** The tools as the server names them, which is what a view calls them by. */ + readonly serverTools: readonly string[]; /** True when the discovery did not reach the server, so none of its tools are offered. */ readonly failed?: boolean; readonly signIn?: boolean; }; +/** + * The view one discovered tool carries. + * + * The model calls the tool by `mcp_<server>_<tool>`; the view calls it by the + * server's own name, and reads it from the uri the server attached. Both are + * kept beside the discovery because the transcript draws the call by the name + * the model used. + */ +export type RemoteMcpToolView = { + readonly serverId: string; + readonly serverToolName: string; + readonly resourceUri: string; +}; + /** What one server looks like to whoever draws it. */ export type RemoteMcpServerState = { readonly id: string; @@ -104,3 +123,47 @@ export function remoteServerToolsFor( return found === undefined || found.failed === true ? [] : found.tools; }); } + +/** + * What a server's offered tools look like once kept: the tools themselves, the + * view each one carries, and the server's own names, which is what a view calls + * its tools by. + */ +export function discoveryOf( + serverId: string, + tools: readonly RemoteMcpOfferedTool[] +): RemoteMcpDiscovery { + const views = new Map<string, RemoteMcpToolView>(); + for (const tool of tools) { + const uri = tool.uiResourceUri; + if (uri !== undefined) { + views.set(tool.definition.name, { + serverId, + serverToolName: tool.serverToolName, + resourceUri: uri, + }); + } + } + return { tools, views, serverTools: tools.map(tool => tool.serverToolName) }; +} + +/** + * The views the enabled servers' discovered tools carry, by the name the model + * calls each tool by. A disabled or failed server contributes none, which is + * the same rule its tools follow. + */ +export function remoteServerViewsFor( + servers: readonly StoredRemoteMcpServer[], + discovered: ReadonlyMap<string, RemoteMcpDiscovery> +): ReadonlyMap<string, RemoteMcpToolView> { + const views = new Map<string, RemoteMcpToolView>(); + for (const server of servers) { + const found = server.enabled ? discovered.get(server.id) : undefined; + if (found !== undefined && found.failed !== true) { + for (const [name, view] of found.views) { + views.set(name, view); + } + } + } + return views; +} diff --git a/apps/mobile/src/lib/chat/remote-mcp-surface.ts b/apps/mobile/src/lib/chat/remote-mcp-surface.ts new file mode 100644 index 00000000000..dd4a0a8a9d3 --- /dev/null +++ b/apps/mobile/src/lib/chat/remote-mcp-surface.ts @@ -0,0 +1,24 @@ +import { type RemoteMcpToolView } from './remote-mcp-state'; + +/** + * Which discovered tool carries which MCP Apps view, for the transcript. + * + * The transcript draws a call by the name the model used, and the view it draws + * is the one the server attached to that tool — read from the server when the + * app discovered it. Keeping the lookup here, published by `remote-mcp.ts` and + * read by the row, keeps the transcript's rendering free of the device stores + * and credentials the discovery itself needs: a row that draws no view needs + * neither. + */ + +let views: ReadonlyMap<string, RemoteMcpToolView> = new Map(); + +/** Takes the views of the servers that are enabled and discovered now. */ +export function publishRemoteMcpViews(next: ReadonlyMap<string, RemoteMcpToolView>): void { + views = next; +} + +/** The view the tool of that name carries, when an enabled server offers one. */ +export function remoteServerView(toolName: string): RemoteMcpToolView | undefined { + return views.get(toolName); +} diff --git a/apps/mobile/src/lib/chat/remote-mcp-views.test.ts b/apps/mobile/src/lib/chat/remote-mcp-views.test.ts new file mode 100644 index 00000000000..c20b8bc6596 --- /dev/null +++ b/apps/mobile/src/lib/chat/remote-mcp-views.test.ts @@ -0,0 +1,232 @@ +import { Effect } from 'effect'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { type Tool } from '@kilocode/harness-sdk'; +import { + type RemoteMcpClientDeps, + RemoteMcpError, + type RemoteMcpResource, + type RemoteMcpServer, +} from '@kilocode/harness-sdk/plugins/remote-mcp'; + +import { place, server, tool, viewTool } from './remote-mcp.test-helpers'; +import { type StoredRemoteMcpServer } from './remote-mcp-store'; + +/** + * The MCP Apps views a discovered tool can carry: what the transcript resolves + * for one call, the document it reads, and the bridge it opens. The store is + * faked so a server can be disabled and a discovery answered without a device. + */ + +const world = vi.hoisted(() => ({ + servers: [] as StoredRemoteMcpServer[], + calls: [] as { readonly server: RemoteMcpServer; readonly deps: RemoteMcpClientDeps }[], + clients: [] as { readonly server: RemoteMcpServer; readonly deps: RemoteMcpClientDeps }[], + apps: [] as { + readonly server: RemoteMcpServer; + readonly deps: RemoteMcpClientDeps; + readonly options: { readonly tools: readonly string[] }; + }[], + listeners: [] as (() => void)[], + /** Never resolves: these tests answer the stored list as already read. */ + loaded: Promise.withResolvers<undefined>(), +})); + +vi.mock('./remote-mcp-store', () => ({ + listRemoteMcpServers: () => world.servers, + subscribeRemoteMcpServers: (listener: () => void) => { + world.listeners.push(listener); + return () => { + world.listeners = world.listeners.filter(current => current !== listener); + }; + }, + getRemoteMcpServersHasLoaded: () => true, + whenRemoteMcpServersLoaded: async () => { + await world.loaded.promise; + }, +})); + +vi.mock('@/lib/auth/auth-epoch', () => ({ currentAuthEpoch: () => 0 })); +vi.mock('@/lib/hooks/use-offline-banner-state', () => ({ + getCommittedConnectivityStatus: () => 'online', +})); +vi.mock('./remote-mcp-oauth-provider', () => ({ remoteMcpOAuthProvider: () => ({}) })); + +/** The discovery and the resource read, each answering from a mutable function. */ +const answers = vi.hoisted(() => ({ + tools: () => Effect.succeed<readonly Tool[]>([]), + resource: (): Effect.Effect<RemoteMcpResource, RemoteMcpError> => + Effect.succeed({ + uri: '', + mimeType: undefined, + text: undefined, + blob: undefined, + _meta: undefined, + }), +})); + +vi.mock('@kilocode/harness-sdk/plugins/remote-mcp', () => ({ + RemoteMcpError: class FakeRemoteMcpError extends Error { + readonly kind: string; + constructor(fields: { readonly kind: string; readonly cause: unknown }) { + super(String(fields.cause)); + this.name = 'RemoteMcpError'; + this.kind = fields.kind; + } + }, + remoteMcpTools: (held: RemoteMcpServer, deps: RemoteMcpClientDeps) => { + world.calls.push({ server: held, deps }); + return answers.tools(); + }, + remoteMcpClient: (held: RemoteMcpServer, deps: RemoteMcpClientDeps) => { + world.clients.push({ server: held, deps }); + return { resource: () => answers.resource() }; + }, + remoteMcpApp: ( + held: RemoteMcpServer, + deps: RemoteMcpClientDeps, + options: { tools: readonly string[] } + ) => { + world.apps.push({ server: held, deps, options }); + return { marker: 'the bridge' }; + }, +})); + +// The module is loaded after its mocks exist, which a static import cannot +// guarantee: `vi.mock` is hoisted, and this module reads the store at load time. +const { ensureRemoteMcp, forgetRemoteMcp, mcpCallResultOf, openRemoteMcpApp, readRemoteMcpView } = + await import('./remote-mcp'); +const { remoteServerView } = await import('./remote-mcp-surface'); + +const chart = viewTool('mcp_alpha_chart', 'chart', 'ui://alpha/chart.html'); + +/** One enabled server whose tool carries the chart view. */ +async function discoveredWithChart() { + world.servers = [server({ id: 'alpha' })]; + answers.tools = () => Effect.succeed([chart]); + await ensureRemoteMcp(place); + const view = remoteServerView('mcp_alpha_chart'); + if (view === undefined) { + throw new Error('the tool carries no view'); + } + return view; +} + +/** The document the resource answers with. */ +function answering(text: string, meta: Readonly<Record<string, unknown>> | undefined) { + answers.resource = () => + Effect.succeed({ + uri: 'ui://alpha/chart.html', + mimeType: 'text/html;profile=mcp-app', + text, + blob: undefined, + _meta: meta, + }); +} + +beforeEach(() => { + world.servers = []; + world.calls.length = 0; + world.clients.length = 0; + world.apps.length = 0; + answers.tools = () => Effect.succeed([tool('mcp_alpha_read')]); + answering('<html><head></head><body>chart</body></html>', undefined); + forgetRemoteMcp(); +}); + +describe('the view a tool carries', () => { + it('is kept with the name the view calls the tool by', async () => { + await discoveredWithChart(); + + expect(remoteServerView('mcp_alpha_chart')).toEqual({ + serverId: 'alpha', + serverToolName: 'chart', + resourceUri: 'ui://alpha/chart.html', + }); + expect(remoteServerView('mcp_alpha_read')).toBeUndefined(); + }); + + it('is gone once the server that offers it is disabled', async () => { + const alpha = server({ id: 'alpha' }); + world.servers = [alpha]; + answers.tools = () => Effect.succeed([chart]); + await ensureRemoteMcp(place); + expect(remoteServerView('mcp_alpha_chart')).toBeDefined(); + + world.servers = [{ ...alpha, enabled: false }]; + for (const listener of world.listeners) { + listener(); + } + + expect(remoteServerView('mcp_alpha_chart')).toBeUndefined(); + }); +}); + +describe('the document a view is drawn from', () => { + it('is read with the policy the resource declared', async () => { + answering('<html><head></head><body>chart</body></html>', { + ui: { csp: { connectDomains: ['https://api.example'] } }, + }); + const view = await discoveredWithChart(); + + const read = await readRemoteMcpView(view); + + expect(read.kind).toBe('ready'); + const html = read.kind === 'ready' ? read.html : ''; + expect(html).toContain('Content-Security-Policy'); + expect(html).toContain('connect-src https://api.example'); + expect(world.clients[0]?.deps.onCallFailure).toBeDefined(); + }); + + it('is empty when the resource holds no document', async () => { + answering(' ', undefined); + const view = await discoveredWithChart(); + + expect(await readRemoteMcpView(view)).toEqual({ kind: 'empty' }); + }); + + it('is unavailable when the read fails', async () => { + answers.resource = () => + Effect.fail(new RemoteMcpError({ serverId: 'alpha', kind: 'unreachable', cause: 'gone' })); + const view = await discoveredWithChart(); + + expect(await readRemoteMcpView(view)).toEqual({ kind: 'unavailable' }); + }); +}); + +describe('the bridge a view calls back through', () => { + it('is opened with the names the server calls its tools by', async () => { + const view = await discoveredWithChart(); + + expect(openRemoteMcpApp(view, {})).toEqual({ marker: 'the bridge' }); + expect(world.apps[0]?.options.tools).toEqual(['chart']); + expect(world.apps[0]?.deps.onCallFailure).toBeDefined(); + }); + + it('is not opened for a server that is no longer there', async () => { + const view = await discoveredWithChart(); + world.servers = []; + + expect(openRemoteMcpApp(view, {})).toBeUndefined(); + }); +}); + +describe('the whole answer of a call', () => { + it('is kept for the row that draws it, and forgotten when the discovery is', async () => { + world.servers = [server({ id: 'alpha' })]; + await ensureRemoteMcp(place); + const answered = { + tool: 'read', + callId: 'call_1', + content: [{ type: 'text' as const, text: 'chart drawn' }], + structuredContent: { points: 3 }, + }; + + world.calls[0]?.deps.onCallResult?.(answered); + expect(mcpCallResultOf('call_1')).toEqual(answered); + expect(mcpCallResultOf('call_2')).toBeUndefined(); + + forgetRemoteMcp(); + expect(mcpCallResultOf('call_1')).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/lib/chat/remote-mcp-views.ts b/apps/mobile/src/lib/chat/remote-mcp-views.ts new file mode 100644 index 00000000000..5bf133d7c12 --- /dev/null +++ b/apps/mobile/src/lib/chat/remote-mcp-views.ts @@ -0,0 +1,86 @@ +import { + remoteMcpApp, + type RemoteMcpApp, + type RemoteMcpAppContext, + remoteMcpClient, + type RemoteMcpClientDeps, + type RemoteMcpResource, + type RemoteMcpServer, +} from '@kilocode/harness-sdk/plugins/remote-mcp'; +import { Effect } from 'effect'; + +import { contentSecurityPolicy, MAX_VIEW_BYTES, viewDocument } from './remote-mcp-app'; +import { type RemoteMcpToolView } from './remote-mcp-state'; + +/** + * Drawing one MCP Apps view: the document it runs and the bridge it calls back + * through, from the server, its deps and its tools. + * + * The transcript knows a call by the name the model used; the view knows it by + * the server's own name, and reaches the server with the same credential and + * failure surface a model's call does. `remote-mcp.ts` resolves those three + * from what was discovered and hands them here, so this module never reads the + * stored list and never mints a credential. + */ + +/** What a view is drawn and driven from: the server, its deps, its tools. */ +export type RemoteMcpViewSource = { + readonly server: RemoteMcpServer; + readonly deps: RemoteMcpClientDeps; + readonly tools: readonly string[]; +}; + +/** What a view's document is, once read: it, or why there is none. */ +export type RemoteMcpViewDocument = + | { readonly kind: 'ready'; readonly html: string } + | { readonly kind: 'empty' } + | { readonly kind: 'unavailable' }; + +/** + * The document the view is drawn from, held to the policy the resource + * declared. It is read off the same server the tool came from, under the same + * deadline and credential rules as a call, so a server that refuses this + * session is reported the way a refused call is. + */ +export async function readViewDocument( + source: RemoteMcpViewSource, + view: RemoteMcpToolView +): Promise<RemoteMcpViewDocument> { + try { + const read = await Effect.runPromise( + remoteMcpClient(source.server, source.deps) + .resource(view.resourceUri) + .pipe( + Effect.match({ + onFailure: () => ({ ok: false as const }), + onSuccess: (resource: RemoteMcpResource) => ({ ok: true as const, resource }), + }) + ) + ); + if (!read.ok) { + return { kind: 'unavailable' }; + } + const html = read.resource.text; + if (html === undefined || html.trim() === '') { + return { kind: 'empty' }; + } + if (html.length > MAX_VIEW_BYTES) { + return { kind: 'unavailable' }; + } + return { + kind: 'ready', + html: viewDocument(html, contentSecurityPolicy(read.resource._meta)), + }; + } catch { + // A read that threw rather than failed is still nothing to draw. + return { kind: 'unavailable' }; + } +} + +/** The bridge for one view, over the client a model's calls use. */ +export function openViewBridge( + source: RemoteMcpViewSource, + options: { readonly context?: RemoteMcpAppContext; readonly openLink?: (uri: string) => void } +): RemoteMcpApp { + return remoteMcpApp(source.server, source.deps, { tools: source.tools, ...options }); +} diff --git a/apps/mobile/src/lib/chat/remote-mcp.test-helpers.ts b/apps/mobile/src/lib/chat/remote-mcp.test-helpers.ts index 715378aff7a..882318152ba 100644 --- a/apps/mobile/src/lib/chat/remote-mcp.test-helpers.ts +++ b/apps/mobile/src/lib/chat/remote-mcp.test-helpers.ts @@ -1,6 +1,6 @@ import { Effect } from 'effect'; -import { type Tool } from '@kilocode/harness-sdk'; +import { type RemoteMcpOfferedTool } from '@kilocode/harness-sdk/plugins/remote-mcp'; import { type RemoteMcpServerState } from './remote-mcp-state'; import { type StoredRemoteMcpServer } from './remote-mcp-store'; @@ -28,18 +28,31 @@ export function server( }; } -/** A tool as the harness names one, already carrying its server's id. */ -export function tool(name: string): Tool { +/** + * A tool as the harness offers one: the name the model calls it by, the name + * the server calls it by, and the view it carries when a test gives it one. + */ +export function tool( + name: string, + fields: { readonly serverToolName?: string; readonly uiResourceUri?: string } = {} +): RemoteMcpOfferedTool { return { definition: { name, description: 'A tool the server offers.', parameters: { type: 'object', properties: {} }, }, + serverToolName: fields.serverToolName ?? name.split('_').slice(2).join('_'), + ...(fields.uiResourceUri === undefined ? {} : { uiResourceUri: fields.uiResourceUri }), run: () => Effect.succeed(''), }; } +/** The same, carrying the MCP Apps view the server attached to it. */ +export function viewTool(name: string, serverToolName: string, uri: string): RemoteMcpOfferedTool { + return tool(name, { serverToolName, uiResourceUri: uri }); +} + /** The state a server is expected to be drawn in. */ export function stateOf( held: StoredRemoteMcpServer, diff --git a/apps/mobile/src/lib/chat/remote-mcp.ts b/apps/mobile/src/lib/chat/remote-mcp.ts index e2c3a85aed0..352bd764928 100644 --- a/apps/mobile/src/lib/chat/remote-mcp.ts +++ b/apps/mobile/src/lib/chat/remote-mcp.ts @@ -1,8 +1,12 @@ import { Effect } from 'effect'; import { type Tool } from '@kilocode/harness-sdk'; import { + type RemoteMcpApp, + type RemoteMcpAppContext, + type RemoteMcpCallAnswered, type RemoteMcpClientDeps, RemoteMcpError, + type RemoteMcpOfferedTool, remoteMcpTools, } from '@kilocode/harness-sdk/plugins/remote-mcp'; @@ -12,11 +16,20 @@ import { getCommittedConnectivityStatus } from '@/lib/hooks/use-offline-banner-s import { credentialDeps, serverValue } from './remote-mcp-credentials'; import { connectingOf, + discoveryOf, type RemoteMcpDiscovery, type RemoteMcpServerState, + type RemoteMcpToolView, remoteServerStates, remoteServerToolsFor, + remoteServerViewsFor, } from './remote-mcp-state'; +import { + openViewBridge, + readViewDocument, + type RemoteMcpViewDocument, + type RemoteMcpViewSource, +} from './remote-mcp-views'; import { getRemoteMcpServersHasLoaded, listRemoteMcpServers, @@ -24,6 +37,7 @@ import { subscribeRemoteMcpServers, whenRemoteMcpServersLoaded, } from './remote-mcp-store'; +import { publishRemoteMcpViews } from './remote-mcp-surface'; import { type ChatPlace } from './scope'; /** @@ -108,15 +122,45 @@ function depsFor( fetch, discoverTimeoutMs: timeoutMs, onCallFailure: lostConnection(server, started), + onCallResult: rememberCallResult, ...credentialDeps(server), }; } +/** + * The whole answer a call received, kept for the row that draws it. + * + * A transcript holds the text the model read; a view renders the server's own + * content and structured result, which the harness hands over here because the + * text does not carry them. Only the most recent answers are kept: a reopened + * transcript draws its views from the text alone, and nothing waits on one + * that scrolled away. + */ +const callResults = new Map<string, RemoteMcpCallAnswered>(); +const MAX_CALL_RESULTS = 50; + +function rememberCallResult(answered: RemoteMcpCallAnswered): void { + callResults.delete(answered.callId); + callResults.set(answered.callId, answered); + if (callResults.size > MAX_CALL_RESULTS) { + const oldest = callResults.keys().next().value; + if (oldest !== undefined) { + callResults.delete(oldest); + } + } +} + +/** What a call answered, for the view that renders it; the text alone when it is gone. */ +export function mcpCallResultOf(callId: string): RemoteMcpCallAnswered | undefined { + return callResults.get(callId); +} + /** What a failed discovery or call leaves: an OAuth server that refused needs a person. */ function failureOf(server: StoredRemoteMcpServer, error: RemoteMcpError): RemoteMcpDiscovery { + const nothing = { tools: [], views: new Map<string, RemoteMcpToolView>(), serverTools: [] }; return server.auth.type === 'oauth' && error.kind === 'unauthorized' - ? { tools: [], failed: true, signIn: true } - : { tools: [], failed: true }; + ? { ...nothing, failed: true, signIn: true } + : { ...nothing, failed: true }; } /** @@ -151,7 +195,7 @@ async function discover( remoteMcpTools(serverValue(server), depsFor(server, timeoutMs, started)).pipe( Effect.match({ onFailure: (error: RemoteMcpError) => ({ ok: false as const, error }), - onSuccess: (tools: readonly Tool[]) => ({ ok: true as const, tools }), + onSuccess: (tools: readonly RemoteMcpOfferedTool[]) => ({ ok: true as const, tools }), }), Effect.catchAllCause(cause => Effect.succeed({ @@ -175,7 +219,7 @@ async function discover( } discovered.set( server.id, - outcome.ok ? { tools: outcome.tools } : failureOf(server, outcome.error) + outcome.ok ? discoveryOf(server.id, outcome.tools) : failureOf(server, outcome.error) ); publish(); } @@ -188,9 +232,10 @@ function currentStates(): RemoteMcpServerState[] { ); } -/** Replaces the snapshot and wakes every watcher. */ +/** Replaces the snapshot, the view lookup and wakes every watcher. */ function publish(): void { snapshot = currentStates(); + publishRemoteMcpViews(remoteServerViewsFor(listRemoteMcpServers(), discovered)); for (const listener of listeners) { listener(); } @@ -235,7 +280,12 @@ function reusable(found: RemoteMcpDiscovery | undefined): boolean { */ function failOffline(servers: readonly StoredRemoteMcpServer[]): readonly RemoteMcpServerState[] { for (const server of servers) { - discovered.set(server.id, { tools: [], failed: true }); + discovered.set(server.id, { + tools: [], + views: new Map(), + serverTools: [], + failed: true, + }); } publish(); return snapshot; @@ -274,6 +324,7 @@ export async function ensureRemoteMcp( generation += 1; cacheKey = key; discovered = new Map(); + callResults.clear(); pending.clear(); publish(); } @@ -330,6 +381,36 @@ export function remoteServerToolNames(): readonly string[] { return remoteServerTools().map(tool => tool.definition.name); } +/** The source a view is read and driven from: the server, its deps and its tools. */ +function viewSourceOf(view: RemoteMcpToolView): RemoteMcpViewSource | undefined { + const server = listRemoteMcpServers().find(held => held.id === view.serverId && held.enabled); + const found = server === undefined ? undefined : discovered.get(server.id); + if (server === undefined || found === undefined || found.failed === true) { + return undefined; + } + const deps = depsFor(server, AUTOMATIC_TIMEOUT_MS, generation); + return { server: serverValue(server), deps, tools: found.serverTools }; +} + +/** The document a tool's view is drawn from, held to the policy it declared. */ +export async function readRemoteMcpView(view: RemoteMcpToolView): Promise<RemoteMcpViewDocument> { + const source = viewSourceOf(view); + const document = + source === undefined + ? ({ kind: 'unavailable' } as const) + : await readViewDocument(source, view); + return document; +} + +/** The bridge for one tool's view, over the same client a model's calls use. */ +export function openRemoteMcpApp( + view: RemoteMcpToolView, + options: { readonly context?: RemoteMcpAppContext; readonly openLink?: (uri: string) => void } +): RemoteMcpApp | undefined { + const source = viewSourceOf(view); + return source === undefined ? undefined : openViewBridge(source, options); +} + /** Watches the snapshot, the way a screen subscribes to a chat. */ export function subscribeRemoteMcp(listener: () => void): () => void { listeners.add(listener); @@ -346,6 +427,7 @@ export function forgetRemoteMcp(): void { generation += 1; cacheKey = undefined; discovered = new Map(); + callResults.clear(); pending.clear(); inFlight = undefined; publish(); From ecd042d48d8628246ec349ecebad6f6c1bda6787 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:29:22 +0000 Subject: [PATCH 3/9] fix(mobile): a view with unreadable policy metadata gets the default policy The resource's `_meta` is the server's to write, and metadata that is not the shape the specification defines is not a reason to refuse to draw the view: it is a resource that named no origins, so it may load none. Proof: a policy request whose `connectDomains` is not a list resolves to the default-deny policy rather than an error. --- apps/mobile/src/lib/chat/remote-mcp-app.test.ts | 6 ++++++ apps/mobile/src/lib/chat/remote-mcp-app.ts | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/chat/remote-mcp-app.test.ts b/apps/mobile/src/lib/chat/remote-mcp-app.test.ts index a2102091ae3..4c7618120b2 100644 --- a/apps/mobile/src/lib/chat/remote-mcp-app.test.ts +++ b/apps/mobile/src/lib/chat/remote-mcp-app.test.ts @@ -25,6 +25,12 @@ describe('the policy a view runs under', () => { expect(contentSecurityPolicy({})).not.toContain('https://'); }); + it('falls back to nothing when the metadata is not the shape the spec defines', () => { + expect(contentSecurityPolicy({ ui: { csp: { connectDomains: 'https://api.example' } } })).toBe( + contentSecurityPolicy(undefined) + ); + }); + it('keeps only the origins the resource named, for what it named them for', () => { const policy = contentSecurityPolicy({ ui: { diff --git a/apps/mobile/src/lib/chat/remote-mcp-app.ts b/apps/mobile/src/lib/chat/remote-mcp-app.ts index 50c7f84c6b0..d9f1f1e94f5 100644 --- a/apps/mobile/src/lib/chat/remote-mcp-app.ts +++ b/apps/mobile/src/lib/chat/remote-mcp-app.ts @@ -82,7 +82,10 @@ const tokensFor = (origins: readonly string[]): string => * expects a view to have. */ export function contentSecurityPolicy(meta: Readonly<Record<string, unknown>> | undefined): string { - const csp = ViewMetaSchema.parse(meta)?.ui?.csp; + // Metadata that is not the shape the specification defines gets the + // default policy rather than an error: a resource with no readable policy is + // a resource with nothing it may load. + const csp = ViewMetaSchema.safeParse(meta).data?.ui?.csp; const resources = originsOf(csp?.resourceDomains); const connections = originsOf(csp?.connectDomains); const frames = originsOf(csp?.frameDomains); From b6ab7a6dbe121f444008a183ea46dd147c35d43a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:34:39 +0000 Subject: [PATCH 4/9] refactor(mobile): keep the view component private to its card The card is what the transcript routes to; the view inside it has no other caller, so it is not exported and the barrel re-exports only the card. --- apps/mobile/src/components/agents/tool-cards/index.ts | 2 +- apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/agents/tool-cards/index.ts b/apps/mobile/src/components/agents/tool-cards/index.ts index 1025fe6a8d9..c17ed2498e3 100644 --- a/apps/mobile/src/components/agents/tool-cards/index.ts +++ b/apps/mobile/src/components/agents/tool-cards/index.ts @@ -4,7 +4,7 @@ export { GenericToolCard, GenericToolCardBody } from './generic-tool-card'; export { GlobToolCard, GlobToolCardBody } from './glob-tool-card'; export { GrepToolCard, GrepToolCardBody } from './grep-tool-card'; export { ListToolCard, ListToolCardBody } from './list-tool-card'; -export { McpAppToolCard, McpAppView } from './mcp-app-card'; +export { McpAppToolCard } from './mcp-app-card'; export { PatchToolCard, PatchToolCardBody } from './patch-tool-card'; export { ReadToolCard, ReadToolCardBody } from './read-tool-card'; export { TaskToolCard, TaskToolCardBody } from './task-tool-card'; diff --git a/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx b/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx index a63f5495a09..56d8cf93a4f 100644 --- a/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx @@ -67,7 +67,7 @@ type McpAppViewProps = { readonly view: RemoteMcpToolView; }; -export function McpAppView({ part, view }: Readonly<McpAppViewProps>) { +function McpAppView({ part, view }: Readonly<McpAppViewProps>) { const { t, i18n } = useTranslation(); const dark = useColorScheme() === 'dark'; const [pageLoaded, setPageLoaded] = useState(false); From b140c5dd02bb8a06b677f5b2a9536d3bb901f356 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:44:04 +0000 Subject: [PATCH 5/9] test(mobile): the list-settings sheet's back handler in its mounted mock The sheet subscribes to the hardware back button, and the suite's react-native mock answered every other native export it reads. The mock now answers this one too, so the sheet mounts in the shard it lands in. --- .../components/todos/todo-list-model-picker.mounted.test.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/mobile/src/components/todos/todo-list-model-picker.mounted.test.tsx b/apps/mobile/src/components/todos/todo-list-model-picker.mounted.test.tsx index 646724bf44a..e4891c5f0f9 100644 --- a/apps/mobile/src/components/todos/todo-list-model-picker.mounted.test.tsx +++ b/apps/mobile/src/components/todos/todo-list-model-picker.mounted.test.tsx @@ -35,6 +35,9 @@ vi.mock('expo-router', async () => { vi.mock('react-native', async () => { const { NativeTextInput } = await import('./todo-list-model-picker.mounted.test-helpers'); return { + // The settings sheet closes on the hardware back button; the stub answers + // the subscription it makes and has nothing to tear down. + BackHandler: { addEventListener: () => ({ remove: vi.fn() }) }, I18nManager: { isRTL: false }, Keyboard: { dismiss: vi.fn() }, Pressable: 'Pressable', From 8d3562ef0559be4b267e49fa36e8b2e1e67e9c56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Wed, 21 Oct 2026 16:53:37 +0000 Subject: [PATCH 6/9] fix(mobile): bound and place what a view learns and what it costs Four things the view's own review found: - A document without a head gets its policy after the doctype. Putting it in front of the doctype is a parse error, and the parser draws the whole document in quirks mode from there. - A retry gets a page of its own. A page that failed to load has no host object left to hand a document to, so re-reading the resource into the same webview could never recover. - The document read is bounded like a discovery and ends with the row that asked: 15 seconds, and the request is aborted when the row goes away. - The answers kept for the rows are bounded by characters as well as by count. This is the only place the app holds a server's payload after a call ends. Proof: the policy tests cover a document that starts with a doctype; the retained-answer tests cover eviction by count and by size. --- .../agents/tool-cards/mcp-app-card.tsx | 12 +++- .../src/lib/chat/remote-mcp-app.test.ts | 11 +++ apps/mobile/src/lib/chat/remote-mcp-app.ts | 12 +++- .../lib/chat/remote-mcp-call-results.test.ts | 49 ++++++++++++++ .../src/lib/chat/remote-mcp-call-results.ts | 67 +++++++++++++++++++ .../src/lib/chat/remote-mcp-views.test.ts | 3 +- apps/mobile/src/lib/chat/remote-mcp.ts | 62 +++++++---------- 7 files changed, 173 insertions(+), 43 deletions(-) create mode 100644 apps/mobile/src/lib/chat/remote-mcp-call-results.test.ts create mode 100644 apps/mobile/src/lib/chat/remote-mcp-call-results.ts diff --git a/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx b/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx index 56d8cf93a4f..dcf38b08eae 100644 --- a/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx @@ -9,7 +9,8 @@ import { type RemoteMcpApp } from '@kilocode/harness-sdk/plugins/remote-mcp'; import { ActivityIndicator } from '@/components/ui/activity-indicator'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { mcpCallResultOf, openRemoteMcpApp, readRemoteMcpView } from '@/lib/chat/remote-mcp'; +import { mcpCallResultOf } from '@/lib/chat/remote-mcp-call-results'; +import { openRemoteMcpApp, readRemoteMcpView } from '@/lib/chat/remote-mcp'; import { DEFAULT_VIEW_HEIGHT, MAX_VIEW_HEIGHT, @@ -80,9 +81,11 @@ function McpAppView({ part, view }: Readonly<McpAppViewProps>) { useEffect(() => { let active = true; + const controller = new AbortController(); + setPageLoaded(false); setDocumentState({ kind: 'loading' }); const read = async () => { - const document = await readRemoteMcpView(view); + const document = await readRemoteMcpView(view, controller.signal); if (!active) { return; } @@ -93,6 +96,8 @@ function McpAppView({ part, view }: Readonly<McpAppViewProps>) { void read(); return () => { active = false; + // A row that scrolled away does not leave the request open. + controller.abort(); }; }, [attempt, view]); @@ -166,6 +171,9 @@ function McpAppView({ part, view }: Readonly<McpAppViewProps>) { return ( <View style={{ height }} className="overflow-hidden rounded-lg bg-secondary"> <WebView + // A retry gets a page of its own: a page that failed to load has no + // host object left to hand a document to. + key={attempt} ref={webView} source={{ html: VIEW_HOST_PAGE }} // Only the page itself may load; every tap stays in the app. diff --git a/apps/mobile/src/lib/chat/remote-mcp-app.test.ts b/apps/mobile/src/lib/chat/remote-mcp-app.test.ts index 4c7618120b2..6748432372f 100644 --- a/apps/mobile/src/lib/chat/remote-mcp-app.test.ts +++ b/apps/mobile/src/lib/chat/remote-mcp-app.test.ts @@ -77,6 +77,17 @@ describe('the document a view draws', () => { expect(document.startsWith('<meta http-equiv="Content-Security-Policy"')).toBe(true); expect(document).toContain('<p>a view</p>'); }); + + it('keeps the doctype first, so a document without a head is not drawn in quirks mode', () => { + const document = viewDocument( + '<!doctype html><html><body><p>a view</p></body></html>', + "default-src 'none'" + ); + expect(document.startsWith('<!doctype html>')).toBe(true); + expect(document.indexOf('Content-Security-Policy')).toBeLessThan( + document.indexOf('<p>a view</p>') + ); + }); }); describe('the page a view is drawn in', () => { diff --git a/apps/mobile/src/lib/chat/remote-mcp-app.ts b/apps/mobile/src/lib/chat/remote-mcp-app.ts index d9f1f1e94f5..14f47840cf6 100644 --- a/apps/mobile/src/lib/chat/remote-mcp-app.ts +++ b/apps/mobile/src/lib/chat/remote-mcp-app.ts @@ -116,10 +116,16 @@ export function contentSecurityPolicy(meta: Readonly<Record<string, unknown>> | export function viewDocument(html: string, policy: string): string { const meta = `<meta http-equiv="Content-Security-Policy" content="${policy.replaceAll('"', '"')}">`; const head = /<head[^>]*>/iu.exec(html); - if (head === null) { - return `${meta}${html}`; + if (head !== null) { + const at = head.index + head[0].length; + return `${html.slice(0, at)}${meta}${html.slice(at)}`; } - const at = head.index + head[0].length; + /* A document without a head still gets its policy before its own content, and + after the doctype when it has one: a start tag in front of the doctype is + a parse error, and the parser then draws the whole document in quirks + mode. The parser hoists the meta into the head it makes. */ + const doctype = /^\s*<!doctype[^>]*>/iu.exec(html); + const at = doctype === null ? 0 : doctype.index + doctype[0].length; return `${html.slice(0, at)}${meta}${html.slice(at)}`; } diff --git a/apps/mobile/src/lib/chat/remote-mcp-call-results.test.ts b/apps/mobile/src/lib/chat/remote-mcp-call-results.test.ts new file mode 100644 index 00000000000..4afac377975 --- /dev/null +++ b/apps/mobile/src/lib/chat/remote-mcp-call-results.test.ts @@ -0,0 +1,49 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { forgetCallResults, mcpCallResultOf, rememberCallResult } from './remote-mcp-call-results'; + +/** + * The answers kept for the rows that draw them: the newest is what a row asks + * for, and nothing keeps a server's payload after its row is long gone. + */ + +const answered = (callId: string, text: string) => ({ + tool: 'chart', + callId, + content: [{ type: 'text' as const, text }], + structuredContent: undefined, +}); + +beforeEach(() => { + forgetCallResults(); +}); + +describe('the answers kept for a view', () => { + it('keeps what a row asks for and drops the rest', () => { + rememberCallResult(answered('call_1', 'one')); + rememberCallResult(answered('call_2', 'two')); + + expect(mcpCallResultOf('call_2')?.callId).toBe('call_2'); + expect(mcpCallResultOf('call_1')?.callId).toBe('call_1'); + + forgetCallResults(); + expect(mcpCallResultOf('call_1')).toBeUndefined(); + }); + + it('keeps the newest answers, not an unbounded history', () => { + for (let index = 0; index < 60; index += 1) { + rememberCallResult(answered(`call_${String(index)}`, 'answer')); + } + + expect(mcpCallResultOf('call_59')).toBeDefined(); + expect(mcpCallResultOf('call_0')).toBeUndefined(); + }); + + it('evicts on size, so one huge answer does not pin a payload forever', () => { + rememberCallResult(answered('call_old', 'small')); + rememberCallResult(answered('call_huge', 'x'.repeat(1_200_000))); + + expect(mcpCallResultOf('call_huge')).toBeDefined(); + expect(mcpCallResultOf('call_old')).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/lib/chat/remote-mcp-call-results.ts b/apps/mobile/src/lib/chat/remote-mcp-call-results.ts new file mode 100644 index 00000000000..24e6d2cce2e --- /dev/null +++ b/apps/mobile/src/lib/chat/remote-mcp-call-results.ts @@ -0,0 +1,67 @@ +import { type RemoteMcpCallAnswered } from '@kilocode/harness-sdk/plugins/remote-mcp'; + +/** + * The whole answer a call received, kept for the row that draws it. + * + * A transcript holds the text the model read; a view renders the server's own + * content and structured result, which the harness hands over here because the + * text does not carry them. What is kept is bounded twice — by how many answers + * and by how many characters they hold — because this is the one place in the + * app that holds a server's own payload after the call is over. A reopened + * transcript draws its views from the text alone, so nothing waits on an answer + * that has been evicted. + */ + +const MAX_ANSWERS = 50; +const MAX_CHARS = 1_000_000; + +const kept = new Map< + string, + { readonly answered: RemoteMcpCallAnswered; readonly chars: number } +>(); +let keptChars = 0; + +/** The length a result counts as, or one when it cannot be measured. */ +function charsOf(answered: RemoteMcpCallAnswered): number { + try { + return JSON.stringify(answered).length; + } catch { + return 1; + } +} + +function forget(callId: string): void { + const held = kept.get(callId); + if (held !== undefined) { + keptChars -= held.chars; + kept.delete(callId); + } +} + +/** Keeps one answer, evicting the oldest until the collected answers fit. */ +export function rememberCallResult(answered: RemoteMcpCallAnswered): void { + forget(answered.callId); + const chars = charsOf(answered); + kept.set(answered.callId, { answered, chars }); + keptChars += chars; + for (const callId of kept.keys()) { + const fits = kept.size <= MAX_ANSWERS && keptChars <= MAX_CHARS; + if (fits) { + return; + } + if (callId !== answered.callId) { + forget(callId); + } + } +} + +/** What a call answered, for the view that renders it; the text alone when it is gone. */ +export function mcpCallResultOf(callId: string): RemoteMcpCallAnswered | undefined { + return kept.get(callId)?.answered; +} + +/** Drops every answer, as a sign-out or a changed server list does. */ +export function forgetCallResults(): void { + kept.clear(); + keptChars = 0; +} diff --git a/apps/mobile/src/lib/chat/remote-mcp-views.test.ts b/apps/mobile/src/lib/chat/remote-mcp-views.test.ts index c20b8bc6596..cec74f6c47b 100644 --- a/apps/mobile/src/lib/chat/remote-mcp-views.test.ts +++ b/apps/mobile/src/lib/chat/remote-mcp-views.test.ts @@ -94,8 +94,9 @@ vi.mock('@kilocode/harness-sdk/plugins/remote-mcp', () => ({ // The module is loaded after its mocks exist, which a static import cannot // guarantee: `vi.mock` is hoisted, and this module reads the store at load time. -const { ensureRemoteMcp, forgetRemoteMcp, mcpCallResultOf, openRemoteMcpApp, readRemoteMcpView } = +const { ensureRemoteMcp, forgetRemoteMcp, openRemoteMcpApp, readRemoteMcpView } = await import('./remote-mcp'); +const { mcpCallResultOf } = await import('./remote-mcp-call-results'); const { remoteServerView } = await import('./remote-mcp-surface'); const chart = viewTool('mcp_alpha_chart', 'chart', 'ui://alpha/chart.html'); diff --git a/apps/mobile/src/lib/chat/remote-mcp.ts b/apps/mobile/src/lib/chat/remote-mcp.ts index 352bd764928..2347f18dfce 100644 --- a/apps/mobile/src/lib/chat/remote-mcp.ts +++ b/apps/mobile/src/lib/chat/remote-mcp.ts @@ -3,7 +3,6 @@ import { type Tool } from '@kilocode/harness-sdk'; import { type RemoteMcpApp, type RemoteMcpAppContext, - type RemoteMcpCallAnswered, type RemoteMcpClientDeps, RemoteMcpError, type RemoteMcpOfferedTool, @@ -13,6 +12,7 @@ import { import { currentAuthEpoch } from '@/lib/auth/auth-epoch'; import { getCommittedConnectivityStatus } from '@/lib/hooks/use-offline-banner-state'; +import { forgetCallResults, rememberCallResult } from './remote-mcp-call-results'; import { credentialDeps, serverValue } from './remote-mcp-credentials'; import { connectingOf, @@ -70,6 +70,13 @@ export type RemoteMcpRequest = { const AUTOMATIC_TIMEOUT_MS = 4000; const RETRY_TIMEOUT_MS = 15_000; +/** + * A view's document is read while the row that draws it is on screen, so it is + * bounded like a discovery rather than like a tool call: a server that does not + * answer leaves the row with its Retry, not a minute of waiting. + */ +const VIEW_READ_TIMEOUT_MS = 15_000; + /** The deadline one discovery gets: the Retry's is the caller's own ask. */ const deadlineFor = (request: RemoteMcpRequest): number => request.retry === true ? RETRY_TIMEOUT_MS : AUTOMATIC_TIMEOUT_MS; @@ -127,34 +134,6 @@ function depsFor( }; } -/** - * The whole answer a call received, kept for the row that draws it. - * - * A transcript holds the text the model read; a view renders the server's own - * content and structured result, which the harness hands over here because the - * text does not carry them. Only the most recent answers are kept: a reopened - * transcript draws its views from the text alone, and nothing waits on one - * that scrolled away. - */ -const callResults = new Map<string, RemoteMcpCallAnswered>(); -const MAX_CALL_RESULTS = 50; - -function rememberCallResult(answered: RemoteMcpCallAnswered): void { - callResults.delete(answered.callId); - callResults.set(answered.callId, answered); - if (callResults.size > MAX_CALL_RESULTS) { - const oldest = callResults.keys().next().value; - if (oldest !== undefined) { - callResults.delete(oldest); - } - } -} - -/** What a call answered, for the view that renders it; the text alone when it is gone. */ -export function mcpCallResultOf(callId: string): RemoteMcpCallAnswered | undefined { - return callResults.get(callId); -} - /** What a failed discovery or call leaves: an OAuth server that refused needs a person. */ function failureOf(server: StoredRemoteMcpServer, error: RemoteMcpError): RemoteMcpDiscovery { const nothing = { tools: [], views: new Map<string, RemoteMcpToolView>(), serverTools: [] }; @@ -324,7 +303,7 @@ export async function ensureRemoteMcp( generation += 1; cacheKey = key; discovered = new Map(); - callResults.clear(); + forgetCallResults(); pending.clear(); publish(); } @@ -392,13 +371,22 @@ function viewSourceOf(view: RemoteMcpToolView): RemoteMcpViewSource | undefined return { server: serverValue(server), deps, tools: found.serverTools }; } -/** The document a tool's view is drawn from, held to the policy it declared. */ -export async function readRemoteMcpView(view: RemoteMcpToolView): Promise<RemoteMcpViewDocument> { +/** + * The document a tool's view is drawn from, held to the policy it declared. + * `signal` ends the read with the row that asked for it. + */ +export async function readRemoteMcpView( + view: RemoteMcpToolView, + signal?: AbortSignal +): Promise<RemoteMcpViewDocument> { const source = viewSourceOf(view); - const document = - source === undefined - ? ({ kind: 'unavailable' } as const) - : await readViewDocument(source, view); + if (source === undefined) { + return { kind: 'unavailable' }; + } + const document = await readViewDocument( + { ...source, deps: { ...source.deps, timeoutMs: VIEW_READ_TIMEOUT_MS, signal } }, + view + ); return document; } @@ -427,7 +415,7 @@ export function forgetRemoteMcp(): void { generation += 1; cacheKey = undefined; discovered = new Map(); - callResults.clear(); + forgetCallResults(); pending.clear(); inFlight = undefined; publish(); From c6fdad2a7070eb1af5a4e56b2f32e60fd27ef3cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:22:44 +0000 Subject: [PATCH 7/9] fix(mobile): a cancelled read is not a server failure, and one bound for the page Three things the second review pass found: - The read no longer carries `onCallFailure`. A row that unmounts aborts its read, and that abort was classified as the server being unreachable, which published the server as failed and dropped its tools. A read that does not answer is reported by the row's own Retry. - The page's loaded flag resets on a retry, not on a re-read. A re-read for the same mounted page (a re-discovery republishes the view) would otherwise wait for a load that never comes again and leave the row blank. - An answer larger than the retention bound on its own is not kept, so no single server payload can exceed the cap the module documents. Proof: the retained-answer tests cover eviction by count, by accumulated size and by one oversized answer; the read's test asserts it stays bounded and unreported. --- .../agents/tool-cards/mcp-app-card.tsx | 10 +++++++++- .../src/lib/chat/remote-mcp-call-results.test.ts | 16 ++++++++++++---- .../src/lib/chat/remote-mcp-call-results.ts | 10 +++++++++- .../mobile/src/lib/chat/remote-mcp-views.test.ts | 5 ++++- apps/mobile/src/lib/chat/remote-mcp.ts | 15 +++++++++++---- 5 files changed, 45 insertions(+), 11 deletions(-) diff --git a/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx b/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx index dcf38b08eae..45ba2160a94 100644 --- a/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx @@ -82,7 +82,6 @@ function McpAppView({ part, view }: Readonly<McpAppViewProps>) { useEffect(() => { let active = true; const controller = new AbortController(); - setPageLoaded(false); setDocumentState({ kind: 'loading' }); const read = async () => { const document = await readRemoteMcpView(view, controller.signal); @@ -101,6 +100,15 @@ function McpAppView({ part, view }: Readonly<McpAppViewProps>) { }; }, [attempt, view]); + // A retry mounts a page of its own, so the old page's loaded flag is not the + // new page's: the document read alone must not reset it, because a re-read for + // the same mounted page would then wait for a load that never comes again. + useEffect(() => { + if (attempt > 0) { + setPageLoaded(false); + } + }, [attempt]); + // A retry or a new document tears the old bridge down before the new one opens. useEffect( () => () => { diff --git a/apps/mobile/src/lib/chat/remote-mcp-call-results.test.ts b/apps/mobile/src/lib/chat/remote-mcp-call-results.test.ts index 4afac377975..2e700b93a21 100644 --- a/apps/mobile/src/lib/chat/remote-mcp-call-results.test.ts +++ b/apps/mobile/src/lib/chat/remote-mcp-call-results.test.ts @@ -39,11 +39,19 @@ describe('the answers kept for a view', () => { expect(mcpCallResultOf('call_0')).toBeUndefined(); }); - it('evicts on size, so one huge answer does not pin a payload forever', () => { - rememberCallResult(answered('call_old', 'small')); - rememberCallResult(answered('call_huge', 'x'.repeat(1_200_000))); + it('keeps what fits and drops the oldest, so the size bound holds', () => { + rememberCallResult(answered('call_old', 'x'.repeat(900_000))); + rememberCallResult(answered('call_new', 'y'.repeat(900_000))); - expect(mcpCallResultOf('call_huge')).toBeDefined(); + expect(mcpCallResultOf('call_new')).toBeDefined(); expect(mcpCallResultOf('call_old')).toBeUndefined(); }); + + it('does not keep an answer that is over the bound on its own', () => { + rememberCallResult(answered('call_kept', 'small')); + rememberCallResult(answered('call_huge', 'x'.repeat(1_200_000))); + + expect(mcpCallResultOf('call_huge')).toBeUndefined(); + expect(mcpCallResultOf('call_kept')).toBeDefined(); + }); }); diff --git a/apps/mobile/src/lib/chat/remote-mcp-call-results.ts b/apps/mobile/src/lib/chat/remote-mcp-call-results.ts index 24e6d2cce2e..89dd5116fde 100644 --- a/apps/mobile/src/lib/chat/remote-mcp-call-results.ts +++ b/apps/mobile/src/lib/chat/remote-mcp-call-results.ts @@ -38,10 +38,18 @@ function forget(callId: string): void { } } -/** Keeps one answer, evicting the oldest until the collected answers fit. */ +/** + * Keeps one answer, evicting the oldest until the collected answers fit. An + * answer that is over the cap on its own is not kept at all: the bound is what + * keeps a server's payload out of the app's memory, and one payload larger than + * all the others is exactly what it is for. + */ export function rememberCallResult(answered: RemoteMcpCallAnswered): void { forget(answered.callId); const chars = charsOf(answered); + if (chars > MAX_CHARS) { + return; + } kept.set(answered.callId, { answered, chars }); keptChars += chars; for (const callId of kept.keys()) { diff --git a/apps/mobile/src/lib/chat/remote-mcp-views.test.ts b/apps/mobile/src/lib/chat/remote-mcp-views.test.ts index cec74f6c47b..f11fd2e95c3 100644 --- a/apps/mobile/src/lib/chat/remote-mcp-views.test.ts +++ b/apps/mobile/src/lib/chat/remote-mcp-views.test.ts @@ -176,7 +176,10 @@ describe('the document a view is drawn from', () => { const html = read.kind === 'ready' ? read.html : ''; expect(html).toContain('Content-Security-Policy'); expect(html).toContain('connect-src https://api.example'); - expect(world.clients[0]?.deps.onCallFailure).toBeDefined(); + // A read is not a call: it is bounded on its own and a row that cancels it + // must not publish the server as failed. + expect(world.clients[0]?.deps.onCallFailure).toBeUndefined(); + expect(world.clients[0]?.deps.timeoutMs).toBe(15_000); }); it('is empty when the resource holds no document', async () => { diff --git a/apps/mobile/src/lib/chat/remote-mcp.ts b/apps/mobile/src/lib/chat/remote-mcp.ts index 2347f18dfce..1151be9430a 100644 --- a/apps/mobile/src/lib/chat/remote-mcp.ts +++ b/apps/mobile/src/lib/chat/remote-mcp.ts @@ -383,10 +383,17 @@ export async function readRemoteMcpView( if (source === undefined) { return { kind: 'unavailable' }; } - const document = await readViewDocument( - { ...source, deps: { ...source.deps, timeoutMs: VIEW_READ_TIMEOUT_MS, signal } }, - view - ); + /* A read is not a call: a row that scrolls away aborts it, and a read the + person's own row cancelled must not be published as that server failing — + which is what the deps' `onCallFailure` would do. The row's own Retry is + where a read that did not answer is reported. */ + const deps: RemoteMcpClientDeps = { + ...source.deps, + timeoutMs: VIEW_READ_TIMEOUT_MS, + signal, + }; + delete deps.onCallFailure; + const document = await readViewDocument({ ...source, deps }, view); return document; } From 2c77f765f277ce7a1da630af08cd623eae23b575 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:32:19 +0000 Subject: [PATCH 8/9] fix(harness-sdk): adapt MCP Apps results to Effect 4 --- packages/harness-sdk/src/plugins/remote-mcp/app.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/harness-sdk/src/plugins/remote-mcp/app.ts b/packages/harness-sdk/src/plugins/remote-mcp/app.ts index 8c7a7f53044..d8d54a0ac36 100644 --- a/packages/harness-sdk/src/plugins/remote-mcp/app.ts +++ b/packages/harness-sdk/src/plugins/remote-mcp/app.ts @@ -102,13 +102,13 @@ const callerFor = } return Effect.runPromise( client.call(params.name, params.arguments ?? {}).pipe( - Effect.map(callToolResultOf), - Effect.catchAll(failure => - Effect.succeed({ - content: [{ type: 'text' as const, text: explain(failure) }], + Effect.match({ + onFailure: error => ({ + content: [{ type: 'text' as const, text: explain(error) }], isError: true, - }) - ) + }), + onSuccess: callToolResultOf, + }) ) ); }; From 49437c17f83510022ab4ab48cf0ccafbea427030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= <4062236+iscekic@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:45:04 +0000 Subject: [PATCH 9/9] fix(mobile): specify MCP App WebView props generic --- apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx b/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx index 45ba2160a94..3d22008b28a 100644 --- a/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx +++ b/apps/mobile/src/components/agents/tool-cards/mcp-app-card.tsx @@ -76,7 +76,7 @@ function McpAppView({ part, view }: Readonly<McpAppViewProps>) { const [height, setHeight] = useState(DEFAULT_VIEW_HEIGHT); const [attempt, setAttempt] = useState(0); - const webView = useRef<WebView | null>(null); + const webView = useRef<WebView<unknown> | null>(null); const bridge = useRef<RemoteMcpApp | null>(null); useEffect(() => { @@ -178,7 +178,7 @@ function McpAppView({ part, view }: Readonly<McpAppViewProps>) { return ( <View style={{ height }} className="overflow-hidden rounded-lg bg-secondary"> - <WebView + <WebView<unknown> // A retry gets a page of its own: a page that failed to load has no // host object left to hand a document to. key={attempt}