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/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 3970b9152bc..f6836c24c94 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -58,6 +58,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"), // 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/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/mcp/index.ts b/packages/opencode/src/mcp/index.ts index fea9717885b..3d134e3ade9 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -183,6 +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 } @@ -715,7 +719,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 - 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 a6fb064d73e..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, @@ -36,6 +37,8 @@ export const McpPaths = { authAuthenticate: "/mcp/:name/auth/authenticate", connect: "/mcp/:name/connect", disconnect: "/mcp/:name/disconnect", + readResource: "/experimental/resource/read", // kilocode_change + callTool: "/experimental/mcp/call-tool", // kilocode_change } as const export const McpApi = HttpApi.make("mcp") @@ -136,6 +139,34 @@ export const McpApi = HttpApi.make("mcp") description: "Disconnect an MCP server.", }), ), + // kilocode_change start - MCP Apps experimental endpoints + HttpApiEndpoint.post("readResource", McpPaths.readResource, { + query: WorkspaceRoutingQuery, + payload: McpApps.ReadResourcePayload, + success: described(McpApps.ReadResourceContent, "Resource content"), + error: [HttpApiError.NotFound, HttpApiError.BadRequest], + }).annotateMerge( + OpenApi.annotations({ + identifier: "mcp.readResource", + summary: "Read MCP resource", + description: + "Read a resource from a connected MCP server by URI. Used by MCP Apps to load UI resources.", + }), + ), + HttpApiEndpoint.post("callTool", McpPaths.callTool, { + query: WorkspaceRoutingQuery, + payload: McpApps.CallToolPayload, + success: described(McpApps.CallToolResponse, "Tool call result"), + error: [HttpApiError.NotFound, HttpApiError.BadRequest], + }).annotateMerge( + OpenApi.annotations({ + identifier: "mcp.callTool", + summary: "Call MCP tool", + description: + "Call a tool on a connected MCP server. Used by MCP Apps for widget-initiated tool calls.", + }), + ), + // kilocode_change end ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts index cdf0cc1e70e..a2c10c55a78 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -1,13 +1,16 @@ import { MCP } from "@/mcp" +import { RuntimeFlags } from "@/effect/runtime-flags" // kilocode_change import { Effect, Schema } from "effect" import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { McpServerNotFoundError } from "../errors" import { AddPayload, AuthCallbackPayload, StatusMap, UnsupportedOAuthError } from "../groups/mcp" +import { McpApps } from "@/kilocode/mcp/apps" // kilocode_change export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handlers) => Effect.gen(function* () { const mcp = yield* MCP.Service + const flags = yield* RuntimeFlags.Service // kilocode_change const status = Effect.fn("McpHttpApi.status")(function* () { return yield* mcp.status() @@ -98,6 +101,11 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler return true }) + // 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 .handle("status", status) .handle("add", add) @@ -107,5 +115,7 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler .handle("authRemove", authRemove) .handle("connect", connect) .handle("disconnect", disconnect) + .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 bccfa5ed42f..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" @@ -466,6 +467,12 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { run.promise( Effect.gen(function* () { const ctx = context(args, opts) + // kilocode_change start - propagate MCP App UI metadata so hosts can preload the UI resource + const mcpAppMeta = McpApps.toolMetadata(entry, flags) + 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 }, @@ -539,6 +546,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { ...result.metadata, truncated: truncated.truncated, ...(truncated.truncated && { outputPath: truncated.outputPath }), + ...mcpAppMeta, // kilocode_change - MCP App UI metadata } const output = { diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index 5611a8a7378..371aa0dc51f 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), diff --git a/packages/opencode/test/server/httpapi-mcp-oauth.test.ts b/packages/opencode/test/server/httpapi-mcp-oauth.test.ts index d3ca4ae6835..f9831fd47df 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")) // 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 7469fcb1e78..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 } + 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 0722f0317f7..99800acde43 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, // kilocode_change } } @@ -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", // 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 98a7ae66142..e65461e93cf 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", // kilocode_change }, }), 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", // kilocode_change }, }), clients: () => Effect.succeed({ weather: {} as MCP.McpTool["client"] }),