From 36c57c12ce5f25cb5d10ca7d7e938d5e0e42c2c3 Mon Sep 17 00:00:00 2001 From: rrosa Date: Thu, 20 Aug 2026 07:28:17 -0300 Subject: [PATCH 1/5] feat(cli): add experimental MCP Apps support with resource/tool HTTP endpoints - Add KILO_EXPERIMENTAL_MCP_APPS runtime flag - Expose clientName on McpTool interface and populate it during tool listing - Add POST /experimental/resource/read and POST /experimental/mcp/call-tool HTTP endpoints - Gate both endpoints behind experimentalMcpApps flag - Propagate mcpApp UI metadata (resourceUri, serverId) in tool call results when tools advertise _meta.ui.resourceUri in their definition - Add docs/kilo-headless-embedding.md for headless and embedding capabilities - Fix test fixtures to include clientName in McpTool mocks --- bun.lock | 2 +- docs/kilo-headless-embedding.md | 194 ++++++++++++++++++ packages/opencode/src/effect/runtime-flags.ts | 1 + packages/opencode/src/mcp/index.ts | 4 +- .../routes/instance/httpapi/groups/mcp.ts | 53 +++++ .../routes/instance/httpapi/handlers/mcp.ts | 66 +++++- packages/opencode/src/session/tools.ts | 8 + .../test/server/httpapi-mcp-oauth.test.ts | 4 +- .../test/tool/code-mode-integration.test.ts | 2 +- packages/opencode/test/tool/code-mode.test.ts | 2 + packages/opencode/test/tool/registry.test.ts | 2 + 11 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 docs/kilo-headless-embedding.md diff --git a/bun.lock b/bun.lock index a9b07c5f8e3..e61c405c855 100644 --- a/bun.lock +++ b/bun.lock @@ -359,7 +359,7 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.21", + "version": "7.4.22", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", diff --git a/docs/kilo-headless-embedding.md b/docs/kilo-headless-embedding.md new file mode 100644 index 00000000000..ae8ce7d9f97 --- /dev/null +++ b/docs/kilo-headless-embedding.md @@ -0,0 +1,194 @@ +# Kilo Headless & Embedding Capabilities + +This document describes Kilo's capabilities for headless operation, machine-readable output, and embedding in other applications. + +## Machine-Readable Output (`--format json`) + +The `kilo run` command supports JSON output via the `--format` flag: + +```bash +kilo run --format json "your prompt here" +``` + +When enabled, output is emitted as newline-delimited JSON events to stdout. Each event includes: + +| Field | Description | +|-------|-------------| +| `type` | Event type (see below) | +| `timestamp` | Unix timestamp in milliseconds | +| `sessionID` | Session identifier | +| `...` | Event-specific payload | + +### Event Types + +| Type | Description | +|------|-------------| +| `tool_use` | Tool execution completed or errored | +| `text` | Assistant text response | +| `reasoning` | Model reasoning/thinking block | +| `step_start` | Processing step started | +| `step_finish` | Processing step completed | +| `error` | Session error occurred | + +### Example Output + +```json +{"type":"step_start","timestamp":1720000000000,"sessionID":"sess_abc123","part":{...}} +{"type":"tool_use","timestamp":1720000001000,"sessionID":"sess_abc123","part":{"tool":"Read","state":{"status":"completed"},...}} +{"type":"text","timestamp":1720000002000,"sessionID":"sess_abc123","part":{"type":"text","text":"Here is the result..."}} +``` + +## Local Server Mode (`kilo serve`) + +Kilo exposes a headless HTTP server for programmatic access: + +```bash +kilo serve [--port ] [--hostname ] +``` + +### Configuration + +| Option | Default | Description | +|--------|---------|-------------| +| `--port` | 4096 (or any free port) | Server port | +| `--hostname` | `0.0.0.0` | Bind address | + +### Authentication + +Set `KILO_SERVER_PASSWORD` to secure the server: + +```bash +export KILO_SERVER_PASSWORD="your-secret" +kilo serve +``` + +Without this variable, the server runs unsecured (a warning is printed). + +### API + +The server exposes a REST API with WebSocket support for real-time events. The full OpenAPI specification is available at `packages/sdk/openapi.json`. + +Key endpoints include: +- `POST /session` - Create a session +- `POST /session/{id}/prompt` - Send a prompt +- `POST /session/{id}/command` - Execute a slash command +- `GET /event/subscribe` - WebSocket event stream +- `GET /session/{id}` - Get session details +- `POST /permission/{id}/reply` - Reply to permission requests + +## Non-Interactive / Headless Mode + +By default, `kilo run` (without `--interactive`) operates in headless mode: + +```bash +kilo run "your prompt here" +``` + +### Behavior + +- Sends a single prompt and streams output +- Exits when the session becomes idle +- Automatically denies interactive elements: + - User questions (`permission: question`) + - Plan mode entry/exit + - Interactive terminal requests + +### Permission Handling + +| Flag | Behavior | +|------|----------| +| (default) | Denies permission requests with a warning | +| `--dangerously-skip-permissions` | Auto-approves all permissions (use with caution) | +| `--auto` | Auto-approves permissions for autonomous/pipeline usage | + +### Example: CI/CD Pipeline + +```bash +kilo run --format json --auto "Run the test suite and report failures" 2>/dev/null | \ + jq -r 'select(.type == "text") | .part.text' +``` + +## Remote Attachment Mode + +Connect to a running `kilo serve` instance: + +```bash +kilo run --attach http://localhost:4096 \ + --password "$KILO_SERVER_PASSWORD" \ + --dir /path/to/project \ + "your prompt here" +``` + +This allows: +- Centralized server for multiple clients +- Cross-directory operations via `--dir` +- Session resumption with `--session` or `--continue` + +## Programmatic SDK Usage + +The TypeScript SDK (`@kilocode/sdk/v2`) provides typed access to all server functionality: + +```typescript +import { createKiloClient } from "@kilocode/sdk/v2" + +const client = createKiloClient({ + baseUrl: "http://localhost:4096", + directory: "/path/to/project", + headers: { + Authorization: `Basic ${btoa(`kilo:${process.env.KILO_SERVER_PASSWORD}`)}` + } +}) + +// Create a session +const session = await client.session.create({ + title: "Automated Task", + permission: [ + { permission: "question", action: "deny", pattern: "*" } + ] +}) + +// Subscribe to events +const events = await client.event.subscribe() + +// Send a prompt +await client.session.prompt({ + sessionID: session.data.id, + parts: [{ type: "text", text: "Your prompt here" }] +}) + +// Process events +for await (const event of events.stream) { + if (event.type === "message.part.updated") { + const part = event.properties.part + if (part.type === "text" && part.time?.end) { + console.log(part.text) + } + } + + if (event.type === "session.status" && + event.properties.status.type === "idle") { + break + } +} +``` + +## Summary + +| Capability | Support | Method | +|------------|---------|--------| +| Machine-readable output | Yes | `--format json` | +| Local HTTP server | Yes | `kilo serve` | +| Headless/non-interactive | Yes | `kilo run` (default) | +| WebSocket events | Yes | `/event/subscribe` | +| TypeScript SDK | Yes | `@kilocode/sdk/v2` | +| Permission auto-approval | Yes | `--auto` or `--dangerously-skip-permissions` | +| Remote attachment | Yes | `--attach ` | + +## Related Files + +- CLI entry point: `packages/opencode/src/index.ts` +- Run command: `packages/opencode/src/cli/cmd/run.ts` +- Serve command: `packages/opencode/src/cli/cmd/serve.ts` +- Server implementation: `packages/opencode/src/server/server.ts` +- SDK client: `packages/sdk/js/src/v2/client.ts` +- OpenAPI spec: `packages/sdk/openapi.json` diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 2d4cad143e8..fd35afb8e3e 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -54,6 +54,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalSessionSwitcher: enabledByExperimental("KILO_EXPERIMENTAL_SESSION_SWITCHER"), // kilocode_change experimentalWorkspaces: enabledByExperimental("KILO_EXPERIMENTAL_WORKSPACES"), experimentalIconDiscovery: enabledByExperimental("KILO_EXPERIMENTAL_ICON_DISCOVERY"), + experimentalMcpApps: enabledByExperimental("KILO_EXPERIMENTAL_MCP_APPS"), outputTokenMax: positiveInteger("KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), bashDefaultTimeoutMs: positiveInteger("KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), experimentalNativeLlm: bool("KILO_EXPERIMENTAL_NATIVE_LLM"), diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index fea9717885b..0e507c2e8d7 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -183,6 +183,8 @@ export interface McpTool { /** Shared cached definition; consumers must copy rather than mutate it. */ readonly def: MCPToolDef readonly client: MCPClient + /** The MCP server name this tool belongs to. */ + readonly clientName: string readonly timeout?: number } @@ -715,7 +717,7 @@ const layer = Layer.effect( } const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) for (const def of listed) { - const tool = { def, client, timeout } + const tool = { def, client, clientName, timeout } // kilocode_change start - preserve remote MCP authority on the native entry for every execution path result[McpCatalog.toolName(clientName, def.name)] = entry?.type === "remote" ? SandboxNetwork.remote(tool) : tool diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index a6fb064d73e..45d8eeaf5b2 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -29,6 +29,31 @@ export class UnsupportedOAuthError extends Schema.ErrorClass Effect.gen(function* () { const mcp = yield* MCP.Service + const flags = yield* RuntimeFlags.Service const status = Effect.fn("McpHttpApi.status")(function* () { return yield* mcp.status() @@ -98,6 +107,59 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler return true }) + const readResource = Effect.fn("McpHttpApi.readResource")(function* (ctx: { + payload: typeof ReadResourcePayload.Type + }) { + if (!flags.experimentalMcpApps) { + return yield* Effect.fail(new HttpApiError.NotFound({})) + } + const { uri, server } = ctx.payload + if (!server) { + return yield* Effect.fail(new HttpApiError.BadRequest({})) + } + const result = yield* mcp.readResource(server, uri) + if (!result) { + return yield* Effect.fail(new HttpApiError.NotFound({})) + } + const content = result.contents[0] + if (!content) { + return yield* Effect.fail(new HttpApiError.NotFound({})) + } + return { + uri: content.uri, + ...(content.mimeType ? { mimeType: content.mimeType } : {}), + ...("text" in content && content.text ? { text: content.text } : {}), + ...("blob" in content && content.blob ? { blob: content.blob } : {}), + } + }) + + const callTool = Effect.fn("McpHttpApi.callTool")(function* (ctx: { + payload: typeof CallToolPayload.Type + }) { + if (!flags.experimentalMcpApps) { + return yield* Effect.fail(new HttpApiError.NotFound({})) + } + const { server, name, arguments: args } = ctx.payload + const clients = yield* mcp.clients() + const client = clients[server] + if (!client) { + return yield* Effect.fail(new HttpApiError.NotFound({})) + } + const result = yield* Effect.tryPromise({ + try: () => client.callTool({ name, arguments: args ?? {} }), + catch: (error) => error, + }).pipe( + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) + return { + content: (result as { content: unknown[] }).content ?? [], + ...((result as { isError?: boolean }).isError ? { isError: true } : {}), + ...((result as { structuredContent?: Record }).structuredContent + ? { structuredContent: (result as { structuredContent: Record }).structuredContent } + : {}), + } + }) + return handlers .handle("status", status) .handle("add", add) @@ -107,5 +169,7 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler .handle("authRemove", authRemove) .handle("connect", connect) .handle("disconnect", disconnect) + .handle("readResource", readResource) + .handle("callTool", callTool) }), ) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index bccfa5ed42f..67e6fc682d4 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -466,6 +466,13 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { run.promise( Effect.gen(function* () { const ctx = context(args, opts) + // Propagate MCP App UI metadata from the tool definition so hosts can preload the UI resource + const mcpAppMeta = flags.experimentalMcpApps && (entry.def._meta as { ui?: { resourceUri?: string } } | undefined)?.ui?.resourceUri + ? { mcpApp: { resourceUri: (entry.def._meta as { ui: { resourceUri: string } }).ui.resourceUri, serverId: entry.clientName } } + : undefined + if (mcpAppMeta) { + yield* input.processor.metadata(opts.toolCallId, { metadata: mcpAppMeta }) + } yield* plugin.trigger( "tool.execute.before", { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, @@ -539,6 +546,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { ...result.metadata, truncated: truncated.truncated, ...(truncated.truncated && { outputPath: truncated.outputPath }), + ...mcpAppMeta, } const output = { diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts index d3ca4ae6835..80b2af1284d 100644 --- a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts +++ b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts @@ -27,7 +27,9 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) => .handle("authAuthenticate", () => Effect.die("unexpected MCP authAuthenticate")) .handle("authRemove", () => Effect.die("unexpected MCP authRemove")) .handle("connect", () => Effect.die("unexpected MCP connect")) - .handle("disconnect", () => Effect.die("unexpected MCP disconnect")), + .handle("disconnect", () => Effect.die("unexpected MCP disconnect")) + .handle("readResource", () => Effect.die("unexpected MCP readResource")) + .handle("callTool", () => Effect.die("unexpected MCP callTool")), ), ) diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index 7469fcb1e78..02f2f285011 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -141,7 +141,7 @@ async function buildTool() { const listed = (await client.listTools()).tools as MCPToolDef[] const mcpTools: Record = {} for (const def of listed) { - mcpTools[McpCatalog.toolName(SERVER, def.name)] = { def, client: client as unknown as Client } + mcpTools[McpCatalog.toolName(SERVER, def.name)] = { def, client: client as unknown as Client, clientName: SERVER } } const layer = Layer.mergeAll( diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index 0722f0317f7..6bcf39e0cdd 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -45,6 +45,7 @@ function mcpTool( client: { callTool: async (params: { arguments?: Record }) => handler(params.arguments ?? {}), } as unknown as MCP.McpTool["client"], + clientName: name.split("_")[0] ?? name, } } @@ -239,6 +240,7 @@ describe("code mode execute", () => { inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } }, } as MCPToolDef, client: { callTool: async () => ({ content: [] }) } as unknown as MCP.McpTool["client"], + clientName: "alpha", } } tools["zeta_only_tool"] = mcpTool("only_tool", () => "", { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index ad6feb888f2..213e6d18209 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -102,6 +102,7 @@ const withCodeMode = testEffect( inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, } as MCPToolDef, client: {} as MCP.McpTool["client"], + clientName: "weather", }, }), clients: () => Effect.succeed({ weather: {} as MCP.McpTool["client"] }), @@ -134,6 +135,7 @@ const withRestrictedCodeMode = testEffect( inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, } as MCPToolDef, client: {} as MCP.McpTool["client"], + clientName: "weather", }, }), clients: () => Effect.succeed({ weather: {} as MCP.McpTool["client"] }), From c02134ab43f7d64ded4950148a1d9d156b0f80d6 Mon Sep 17 00:00:00 2001 From: rrosa Date: Thu, 20 Aug 2026 07:39:47 -0300 Subject: [PATCH 2/5] fix(cli): annotate MCP Apps changes, tighten callTool error handling - Add kilocode_change markers to all shared upstream files touched - Log MCP callTool failures instead of silently swallowing them - Remove redundant type casts on the callTool result - Make ReadResourcePayload.server required (schema-level validation) and drop the now-dead BadRequest guard - Remove unused meta field from ReadResourceContent schema --- packages/opencode/src/effect/runtime-flags.ts | 2 +- packages/opencode/src/mcp/index.ts | 4 +- .../routes/instance/httpapi/groups/mcp.ts | 11 ++-- .../routes/instance/httpapi/handlers/mcp.ts | 51 +++++++------------ packages/opencode/src/session/tools.ts | 13 +++-- .../test/server/httpapi-mcp-oauth.test.ts | 6 +-- .../test/tool/code-mode-integration.test.ts | 2 +- packages/opencode/test/tool/code-mode.test.ts | 4 +- packages/opencode/test/tool/registry.test.ts | 4 +- 9 files changed, 45 insertions(+), 52 deletions(-) diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index fd35afb8e3e..9edcd50ac14 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -54,7 +54,7 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalSessionSwitcher: enabledByExperimental("KILO_EXPERIMENTAL_SESSION_SWITCHER"), // kilocode_change experimentalWorkspaces: enabledByExperimental("KILO_EXPERIMENTAL_WORKSPACES"), experimentalIconDiscovery: enabledByExperimental("KILO_EXPERIMENTAL_ICON_DISCOVERY"), - experimentalMcpApps: enabledByExperimental("KILO_EXPERIMENTAL_MCP_APPS"), + experimentalMcpApps: enabledByExperimental("KILO_EXPERIMENTAL_MCP_APPS"), // kilocode_change outputTokenMax: positiveInteger("KILO_EXPERIMENTAL_OUTPUT_TOKEN_MAX"), bashDefaultTimeoutMs: positiveInteger("KILO_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS"), experimentalNativeLlm: bool("KILO_EXPERIMENTAL_NATIVE_LLM"), diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 0e507c2e8d7..3d134e3ade9 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -183,8 +183,10 @@ export interface McpTool { /** Shared cached definition; consumers must copy rather than mutate it. */ readonly def: MCPToolDef readonly client: MCPClient + // kilocode_change start - identifies the owning server for MCP Apps routing /** The MCP server name this tool belongs to. */ readonly clientName: string + // kilocode_change end readonly timeout?: number } @@ -717,7 +719,7 @@ const layer = Layer.effect( } const timeout = requestTimeout(s, clientName, mcpConfig, defaultTimeout) for (const def of listed) { - const tool = { def, client, clientName, timeout } + const tool = { def, client, clientName, timeout } // kilocode_change - clientName for MCP Apps routing // kilocode_change start - preserve remote MCP authority on the native entry for every execution path result[McpCatalog.toolName(clientName, def.name)] = entry?.type === "remote" ? SandboxNetwork.remote(tool) : tool diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index 45d8eeaf5b2..bde155202fa 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -29,9 +29,10 @@ export class UnsupportedOAuthError extends Schema.ErrorClass Effect.gen(function* () { const mcp = yield* MCP.Service - const flags = yield* RuntimeFlags.Service + const flags = yield* RuntimeFlags.Service // kilocode_change const status = Effect.fn("McpHttpApi.status")(function* () { return yield* mcp.status() @@ -107,21 +101,15 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler return true }) + // kilocode_change start - MCP Apps experimental resource/tool endpoints const readResource = Effect.fn("McpHttpApi.readResource")(function* (ctx: { payload: typeof ReadResourcePayload.Type }) { if (!flags.experimentalMcpApps) { return yield* Effect.fail(new HttpApiError.NotFound({})) } - const { uri, server } = ctx.payload - if (!server) { - return yield* Effect.fail(new HttpApiError.BadRequest({})) - } - const result = yield* mcp.readResource(server, uri) - if (!result) { - return yield* Effect.fail(new HttpApiError.NotFound({})) - } - const content = result.contents[0] + const result = yield* mcp.readResource(ctx.payload.server, ctx.payload.uri) + const content = result?.contents[0] if (!content) { return yield* Effect.fail(new HttpApiError.NotFound({})) } @@ -139,26 +127,23 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler if (!flags.experimentalMcpApps) { return yield* Effect.fail(new HttpApiError.NotFound({})) } - const { server, name, arguments: args } = ctx.payload - const clients = yield* mcp.clients() - const client = clients[server] + const client = (yield* mcp.clients())[ctx.payload.server] if (!client) { return yield* Effect.fail(new HttpApiError.NotFound({})) } - const result = yield* Effect.tryPromise({ - try: () => client.callTool({ name, arguments: args ?? {} }), - catch: (error) => error, - }).pipe( + const result = yield* Effect.tryPromise(() => + client.callTool({ name: ctx.payload.name, arguments: ctx.payload.arguments ?? {} }), + ).pipe( + Effect.tapError((err) => Effect.logError("MCP callTool failed", { error: err })), Effect.mapError(() => new HttpApiError.BadRequest({})), ) return { - content: (result as { content: unknown[] }).content ?? [], - ...((result as { isError?: boolean }).isError ? { isError: true } : {}), - ...((result as { structuredContent?: Record }).structuredContent - ? { structuredContent: (result as { structuredContent: Record }).structuredContent } - : {}), + content: result.content ?? [], + ...(result.isError ? { isError: true } : {}), + ...(result.structuredContent ? { structuredContent: result.structuredContent } : {}), } }) + // kilocode_change end return handlers .handle("status", status) @@ -169,7 +154,7 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler .handle("authRemove", authRemove) .handle("connect", connect) .handle("disconnect", disconnect) - .handle("readResource", readResource) - .handle("callTool", callTool) + .handle("readResource", readResource) // kilocode_change + .handle("callTool", callTool) // kilocode_change }), ) diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 67e6fc682d4..bb522394e1c 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -466,13 +466,16 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { run.promise( Effect.gen(function* () { const ctx = context(args, opts) - // Propagate MCP App UI metadata from the tool definition so hosts can preload the UI resource - const mcpAppMeta = flags.experimentalMcpApps && (entry.def._meta as { ui?: { resourceUri?: string } } | undefined)?.ui?.resourceUri - ? { mcpApp: { resourceUri: (entry.def._meta as { ui: { resourceUri: string } }).ui.resourceUri, serverId: entry.clientName } } - : undefined + // kilocode_change start - propagate MCP App UI metadata so hosts can preload the UI resource + const ui = (entry.def._meta as { ui?: { resourceUri?: string } } | undefined)?.ui + const mcpAppMeta = + flags.experimentalMcpApps && ui?.resourceUri + ? { mcpApp: { resourceUri: ui.resourceUri, serverId: entry.clientName } } + : undefined if (mcpAppMeta) { yield* input.processor.metadata(opts.toolCallId, { metadata: mcpAppMeta }) } + // kilocode_change end yield* plugin.trigger( "tool.execute.before", { tool: key, sessionID: ctx.sessionID, callID: opts.toolCallId }, @@ -546,7 +549,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { ...result.metadata, truncated: truncated.truncated, ...(truncated.truncated && { outputPath: truncated.outputPath }), - ...mcpAppMeta, + ...mcpAppMeta, // kilocode_change - MCP App UI metadata } const output = { diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts index 80b2af1284d..f9831fd47df 100644 --- a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts +++ b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts @@ -27,9 +27,9 @@ const testMcpHandlers = HttpApiBuilder.group(TestHttpApi, "mcp", (handlers) => .handle("authAuthenticate", () => Effect.die("unexpected MCP authAuthenticate")) .handle("authRemove", () => Effect.die("unexpected MCP authRemove")) .handle("connect", () => Effect.die("unexpected MCP connect")) - .handle("disconnect", () => Effect.die("unexpected MCP disconnect")) - .handle("readResource", () => Effect.die("unexpected MCP readResource")) - .handle("callTool", () => Effect.die("unexpected MCP callTool")), + .handle("disconnect", () => Effect.die("unexpected MCP disconnect")) // kilocode_change + .handle("readResource", () => Effect.die("unexpected MCP readResource")) // kilocode_change + .handle("callTool", () => Effect.die("unexpected MCP callTool")), // kilocode_change ), ) diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index 02f2f285011..29a4d775c83 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -141,7 +141,7 @@ async function buildTool() { const listed = (await client.listTools()).tools as MCPToolDef[] const mcpTools: Record = {} for (const def of listed) { - mcpTools[McpCatalog.toolName(SERVER, def.name)] = { def, client: client as unknown as Client, clientName: SERVER } + mcpTools[McpCatalog.toolName(SERVER, def.name)] = { def, client: client as unknown as Client, clientName: SERVER } // kilocode_change } const layer = Layer.mergeAll( diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index 6bcf39e0cdd..99800acde43 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -45,7 +45,7 @@ function mcpTool( client: { callTool: async (params: { arguments?: Record }) => handler(params.arguments ?? {}), } as unknown as MCP.McpTool["client"], - clientName: name.split("_")[0] ?? name, + clientName: name.split("_")[0] ?? name, // kilocode_change } } @@ -240,7 +240,7 @@ describe("code mode execute", () => { inputSchema: { type: "object", properties: { value: { type: "string" }, count: { type: "number" } } }, } as MCPToolDef, client: { callTool: async () => ({ content: [] }) } as unknown as MCP.McpTool["client"], - clientName: "alpha", + clientName: "alpha", // kilocode_change } } tools["zeta_only_tool"] = mcpTool("only_tool", () => "", { diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index 213e6d18209..ab9b5a454f3 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -102,7 +102,7 @@ const withCodeMode = testEffect( inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, } as MCPToolDef, client: {} as MCP.McpTool["client"], - clientName: "weather", + clientName: "weather", // kilocode_change }, }), clients: () => Effect.succeed({ weather: {} as MCP.McpTool["client"] }), @@ -135,7 +135,7 @@ const withRestrictedCodeMode = testEffect( inputSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] }, } as MCPToolDef, client: {} as MCP.McpTool["client"], - clientName: "weather", + clientName: "weather", // kilocode_change }, }), clients: () => Effect.succeed({ weather: {} as MCP.McpTool["client"] }), From 7ff184bbf2b12f3ad3b9c06ca53ca0af9b692054 Mon Sep 17 00:00:00 2001 From: rrosa Date: Thu, 20 Aug 2026 10:58:14 -0300 Subject: [PATCH 3/5] chore(cli): remove unrelated docs and bun.lock changes from MCP Apps PR --- bun.lock | 2 +- docs/kilo-headless-embedding.md | 194 -------------------------------- 2 files changed, 1 insertion(+), 195 deletions(-) delete mode 100644 docs/kilo-headless-embedding.md diff --git a/bun.lock b/bun.lock index e61c405c855..a9b07c5f8e3 100644 --- a/bun.lock +++ b/bun.lock @@ -359,7 +359,7 @@ }, "packages/kilo-jetbrains": { "name": "@kilocode/kilo-jetbrains", - "version": "7.4.22", + "version": "7.4.21", }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", diff --git a/docs/kilo-headless-embedding.md b/docs/kilo-headless-embedding.md deleted file mode 100644 index ae8ce7d9f97..00000000000 --- a/docs/kilo-headless-embedding.md +++ /dev/null @@ -1,194 +0,0 @@ -# Kilo Headless & Embedding Capabilities - -This document describes Kilo's capabilities for headless operation, machine-readable output, and embedding in other applications. - -## Machine-Readable Output (`--format json`) - -The `kilo run` command supports JSON output via the `--format` flag: - -```bash -kilo run --format json "your prompt here" -``` - -When enabled, output is emitted as newline-delimited JSON events to stdout. Each event includes: - -| Field | Description | -|-------|-------------| -| `type` | Event type (see below) | -| `timestamp` | Unix timestamp in milliseconds | -| `sessionID` | Session identifier | -| `...` | Event-specific payload | - -### Event Types - -| Type | Description | -|------|-------------| -| `tool_use` | Tool execution completed or errored | -| `text` | Assistant text response | -| `reasoning` | Model reasoning/thinking block | -| `step_start` | Processing step started | -| `step_finish` | Processing step completed | -| `error` | Session error occurred | - -### Example Output - -```json -{"type":"step_start","timestamp":1720000000000,"sessionID":"sess_abc123","part":{...}} -{"type":"tool_use","timestamp":1720000001000,"sessionID":"sess_abc123","part":{"tool":"Read","state":{"status":"completed"},...}} -{"type":"text","timestamp":1720000002000,"sessionID":"sess_abc123","part":{"type":"text","text":"Here is the result..."}} -``` - -## Local Server Mode (`kilo serve`) - -Kilo exposes a headless HTTP server for programmatic access: - -```bash -kilo serve [--port ] [--hostname ] -``` - -### Configuration - -| Option | Default | Description | -|--------|---------|-------------| -| `--port` | 4096 (or any free port) | Server port | -| `--hostname` | `0.0.0.0` | Bind address | - -### Authentication - -Set `KILO_SERVER_PASSWORD` to secure the server: - -```bash -export KILO_SERVER_PASSWORD="your-secret" -kilo serve -``` - -Without this variable, the server runs unsecured (a warning is printed). - -### API - -The server exposes a REST API with WebSocket support for real-time events. The full OpenAPI specification is available at `packages/sdk/openapi.json`. - -Key endpoints include: -- `POST /session` - Create a session -- `POST /session/{id}/prompt` - Send a prompt -- `POST /session/{id}/command` - Execute a slash command -- `GET /event/subscribe` - WebSocket event stream -- `GET /session/{id}` - Get session details -- `POST /permission/{id}/reply` - Reply to permission requests - -## Non-Interactive / Headless Mode - -By default, `kilo run` (without `--interactive`) operates in headless mode: - -```bash -kilo run "your prompt here" -``` - -### Behavior - -- Sends a single prompt and streams output -- Exits when the session becomes idle -- Automatically denies interactive elements: - - User questions (`permission: question`) - - Plan mode entry/exit - - Interactive terminal requests - -### Permission Handling - -| Flag | Behavior | -|------|----------| -| (default) | Denies permission requests with a warning | -| `--dangerously-skip-permissions` | Auto-approves all permissions (use with caution) | -| `--auto` | Auto-approves permissions for autonomous/pipeline usage | - -### Example: CI/CD Pipeline - -```bash -kilo run --format json --auto "Run the test suite and report failures" 2>/dev/null | \ - jq -r 'select(.type == "text") | .part.text' -``` - -## Remote Attachment Mode - -Connect to a running `kilo serve` instance: - -```bash -kilo run --attach http://localhost:4096 \ - --password "$KILO_SERVER_PASSWORD" \ - --dir /path/to/project \ - "your prompt here" -``` - -This allows: -- Centralized server for multiple clients -- Cross-directory operations via `--dir` -- Session resumption with `--session` or `--continue` - -## Programmatic SDK Usage - -The TypeScript SDK (`@kilocode/sdk/v2`) provides typed access to all server functionality: - -```typescript -import { createKiloClient } from "@kilocode/sdk/v2" - -const client = createKiloClient({ - baseUrl: "http://localhost:4096", - directory: "/path/to/project", - headers: { - Authorization: `Basic ${btoa(`kilo:${process.env.KILO_SERVER_PASSWORD}`)}` - } -}) - -// Create a session -const session = await client.session.create({ - title: "Automated Task", - permission: [ - { permission: "question", action: "deny", pattern: "*" } - ] -}) - -// Subscribe to events -const events = await client.event.subscribe() - -// Send a prompt -await client.session.prompt({ - sessionID: session.data.id, - parts: [{ type: "text", text: "Your prompt here" }] -}) - -// Process events -for await (const event of events.stream) { - if (event.type === "message.part.updated") { - const part = event.properties.part - if (part.type === "text" && part.time?.end) { - console.log(part.text) - } - } - - if (event.type === "session.status" && - event.properties.status.type === "idle") { - break - } -} -``` - -## Summary - -| Capability | Support | Method | -|------------|---------|--------| -| Machine-readable output | Yes | `--format json` | -| Local HTTP server | Yes | `kilo serve` | -| Headless/non-interactive | Yes | `kilo run` (default) | -| WebSocket events | Yes | `/event/subscribe` | -| TypeScript SDK | Yes | `@kilocode/sdk/v2` | -| Permission auto-approval | Yes | `--auto` or `--dangerously-skip-permissions` | -| Remote attachment | Yes | `--attach ` | - -## Related Files - -- CLI entry point: `packages/opencode/src/index.ts` -- Run command: `packages/opencode/src/cli/cmd/run.ts` -- Serve command: `packages/opencode/src/cli/cmd/serve.ts` -- Server implementation: `packages/opencode/src/server/server.ts` -- SDK client: `packages/sdk/js/src/v2/client.ts` -- OpenAPI spec: `packages/sdk/openapi.json` From b7069922d9cbec65a19831ed6983d0d6d5c03d2c Mon Sep 17 00:00:00 2001 From: rrosa Date: Thu, 20 Aug 2026 11:08:03 -0300 Subject: [PATCH 4/5] refactor(cli): move MCP Apps logic into Kilo-owned module Extract the experimental MCP Apps schemas, HTTP handler bodies, and the tool-call metadata helper into @/kilocode/mcp/apps so the shared upstream files only carry minimal marked integration points (route registrations, imports, one field). Reduces the long-term diff against upstream opencode. --- packages/opencode/src/kilocode/mcp/apps.ts | 80 +++++++++++++++++++ .../routes/instance/httpapi/groups/mcp.ts | 35 ++------ .../routes/instance/httpapi/handlers/mcp.ts | 47 +---------- packages/opencode/src/session/tools.ts | 7 +- 4 files changed, 91 insertions(+), 78 deletions(-) create mode 100644 packages/opencode/src/kilocode/mcp/apps.ts diff --git a/packages/opencode/src/kilocode/mcp/apps.ts b/packages/opencode/src/kilocode/mcp/apps.ts new file mode 100644 index 00000000000..8fffe6d7298 --- /dev/null +++ b/packages/opencode/src/kilocode/mcp/apps.ts @@ -0,0 +1,80 @@ +import { Effect, Schema } from "effect" +import { HttpApiError } from "effect/unstable/httpapi" +import type { MCP } from "@/mcp" +import type { RuntimeFlags } from "@/effect/runtime-flags" + +/** + * MCP Apps lets MCP servers advertise UI resources alongside their tools. This module owns all the + * Kilo-specific behavior for that experimental feature so the shared upstream files only need to + * register the endpoints and call these helpers. + */ +export namespace McpApps { + export const ReadResourcePayload = Schema.Struct({ + uri: Schema.String, + server: Schema.String, + }) + + export const ReadResourceContent = Schema.Struct({ + uri: Schema.String, + mimeType: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), + blob: Schema.optional(Schema.String), + }) + + export const CallToolPayload = Schema.Struct({ + server: Schema.String, + name: Schema.String, + arguments: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + }) + + export const CallToolResponse = Schema.Struct({ + content: Schema.Array(Schema.Unknown), + isError: Schema.optional(Schema.Boolean), + structuredContent: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + }) + + /** Read a resource from a connected MCP server by URI. Used by MCP Apps to load UI resources. */ + export const readResource = (mcp: MCP.Interface, flags: RuntimeFlags.Info) => + Effect.fn("McpHttpApi.readResource")(function* (ctx: { payload: typeof ReadResourcePayload.Type }) { + if (!flags.experimentalMcpApps) return yield* Effect.fail(new HttpApiError.NotFound({})) + const result = yield* mcp.readResource(ctx.payload.server, ctx.payload.uri) + const content = result?.contents[0] + if (!content) return yield* Effect.fail(new HttpApiError.NotFound({})) + return { + uri: content.uri, + ...(content.mimeType ? { mimeType: content.mimeType } : {}), + ...("text" in content && content.text ? { text: content.text } : {}), + ...("blob" in content && content.blob ? { blob: content.blob } : {}), + } + }) + + /** Call a tool on a connected MCP server. Used by MCP Apps for widget-initiated tool calls. */ + export const callTool = (mcp: MCP.Interface, flags: RuntimeFlags.Info) => + Effect.fn("McpHttpApi.callTool")(function* (ctx: { payload: typeof CallToolPayload.Type }) { + if (!flags.experimentalMcpApps) return yield* Effect.fail(new HttpApiError.NotFound({})) + const client = (yield* mcp.clients())[ctx.payload.server] + if (!client) return yield* Effect.fail(new HttpApiError.NotFound({})) + const result = yield* Effect.tryPromise(() => + client.callTool({ name: ctx.payload.name, arguments: ctx.payload.arguments ?? {} }), + ).pipe( + Effect.tapError((err) => Effect.logError("MCP callTool failed", { error: err })), + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) + return { + content: result.content ?? [], + ...(result.isError ? { isError: true } : {}), + ...(result.structuredContent ? { structuredContent: result.structuredContent } : {}), + } + }) + + /** + * Metadata a tool call should carry so hosts can preload the tool's MCP App UI resource. + * Returns undefined when the feature is disabled or the tool advertises no UI resource. + */ + export const toolMetadata = (entry: MCP.McpTool, flags: RuntimeFlags.Info) => { + if (!flags.experimentalMcpApps) return undefined + const ui = (entry.def._meta as { ui?: { resourceUri?: string } } | undefined)?.ui + if (!ui?.resourceUri) return undefined + return { mcpApp: { resourceUri: ui.resourceUri, serverId: entry.clientName } } + } +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index bde155202fa..dfeeca81a82 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -7,6 +7,7 @@ import { Authorization } from "../middleware/authorization" import { InstanceContextMiddleware } from "../middleware/instance-context" import { WorkspaceRoutingMiddleware, WorkspaceRoutingQuery } from "../middleware/workspace-routing" import { described } from "./metadata" +import { McpApps } from "@/kilocode/mcp/apps" // kilocode_change - MCP Apps schemas live in Kilo-owned code export const AddPayload = Schema.Struct({ name: Schema.String, @@ -29,32 +30,6 @@ export class UnsupportedOAuthError extends Schema.ErrorClass Effect.gen(function* () { @@ -101,48 +101,9 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler return true }) - // kilocode_change start - MCP Apps experimental resource/tool endpoints - const readResource = Effect.fn("McpHttpApi.readResource")(function* (ctx: { - payload: typeof ReadResourcePayload.Type - }) { - if (!flags.experimentalMcpApps) { - return yield* Effect.fail(new HttpApiError.NotFound({})) - } - const result = yield* mcp.readResource(ctx.payload.server, ctx.payload.uri) - const content = result?.contents[0] - if (!content) { - return yield* Effect.fail(new HttpApiError.NotFound({})) - } - return { - uri: content.uri, - ...(content.mimeType ? { mimeType: content.mimeType } : {}), - ...("text" in content && content.text ? { text: content.text } : {}), - ...("blob" in content && content.blob ? { blob: content.blob } : {}), - } - }) - - const callTool = Effect.fn("McpHttpApi.callTool")(function* (ctx: { - payload: typeof CallToolPayload.Type - }) { - if (!flags.experimentalMcpApps) { - return yield* Effect.fail(new HttpApiError.NotFound({})) - } - const client = (yield* mcp.clients())[ctx.payload.server] - if (!client) { - return yield* Effect.fail(new HttpApiError.NotFound({})) - } - const result = yield* Effect.tryPromise(() => - client.callTool({ name: ctx.payload.name, arguments: ctx.payload.arguments ?? {} }), - ).pipe( - Effect.tapError((err) => Effect.logError("MCP callTool failed", { error: err })), - Effect.mapError(() => new HttpApiError.BadRequest({})), - ) - return { - content: result.content ?? [], - ...(result.isError ? { isError: true } : {}), - ...(result.structuredContent ? { structuredContent: result.structuredContent } : {}), - } - }) + // kilocode_change start - MCP Apps experimental resource/tool endpoints; logic lives in @/kilocode/mcp/apps + const readResource = McpApps.readResource(mcp, flags) + const callTool = McpApps.callTool(mcp, flags) // kilocode_change end return handlers diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index bb522394e1c..52208542413 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -27,6 +27,7 @@ import { ModelV2 } from "@opencode-ai/core/model" // kilocode_change start import { Config } from "@/config/config" import { PermissionProvenance } from "@/kilocode/permission/provenance" +import { McpApps } from "@/kilocode/mcp/apps" // kilocode_change end import { isRecord } from "@/util/record" import { RuntimeFlags } from "@/effect/runtime-flags" @@ -467,11 +468,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { Effect.gen(function* () { const ctx = context(args, opts) // kilocode_change start - propagate MCP App UI metadata so hosts can preload the UI resource - const ui = (entry.def._meta as { ui?: { resourceUri?: string } } | undefined)?.ui - const mcpAppMeta = - flags.experimentalMcpApps && ui?.resourceUri - ? { mcpApp: { resourceUri: ui.resourceUri, serverId: entry.clientName } } - : undefined + const mcpAppMeta = McpApps.toolMetadata(entry, flags) if (mcpAppMeta) { yield* input.processor.metadata(opts.toolCallId, { metadata: mcpAppMeta }) } From b29f011689dc0888e6d1a60b0bc9039e17aaae29 Mon Sep 17 00:00:00 2001 From: rrosa Date: Thu, 20 Aug 2026 11:42:06 -0300 Subject: [PATCH 5/5] test(cli): add httpapi exerciser scenarios for MCP Apps endpoints The httpapi coverage gate requires every declared route to have a scenario. Add Kilo-owned scenarios for POST /experimental/resource/read and POST /experimental/mcp/call-tool asserting the flag-gated 404 (the exerciser runs with experimentalMcpApps off). --- packages/opencode/.kilo/tui.json | 1 + .../server/httpapi-exercise-scenarios.ts | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 packages/opencode/.kilo/tui.json diff --git a/packages/opencode/.kilo/tui.json b/packages/opencode/.kilo/tui.json new file mode 100644 index 00000000000..9e26dfeeb6e --- /dev/null +++ b/packages/opencode/.kilo/tui.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index c1fac465263..eaddaa96e66 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -197,6 +197,24 @@ export const kiloScenarios: Scenario[] = [ body: { name: "httpapi-mcp", config: { type: "remote", url: "https://mcp-edit.example.test" } }, })) .json(200, object), + // MCP Apps experimental endpoints are gated behind experimentalMcpApps; the exerciser runs with the + // flag off, so both routes return 404 before touching any MCP client. + http.protected + .post("/experimental/resource/read", "mcp.readResource") + .at((ctx) => ({ + path: "/experimental/resource/read", + headers: ctx.headers(), + body: { server: "httpapi-missing", uri: "ui://httpapi/missing" }, + })) + .status(404), + http.protected + .post("/experimental/mcp/call-tool", "mcp.callTool") + .at((ctx) => ({ + path: "/experimental/mcp/call-tool", + headers: ctx.headers(), + body: { server: "httpapi-missing", name: "noop", arguments: {} }, + })) + .status(404), http.protected.get("/config/sources", "config.sources").json(200, object), http.protected.get("/tui/config", "tui.config.get").json(200, object), http.protected.get("/tui/keybinds", "tui.keybind.list").json(200, object),