Skip to content
1 change: 1 addition & 0 deletions packages/opencode/src/effect/runtime-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export class Service extends ConfigService.Service<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"),
Expand Down
80 changes: 80 additions & 0 deletions packages/opencode/src/kilocode/mcp/apps.ts
Original file line number Diff line number Diff line change
@@ -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 ?? {} }),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[WARNING]: Widget-initiated MCP tool calls skip permission, sandbox, and timeout controls

Session MCP tool execution goes through SandboxPolicy.executeMcp and ctx.ask before client.callTool, and McpCatalog.convertTool always passes the configured timeout, abort signal, and CallToolResultSchema. This HTTP path invokes client.callTool with only a name and arguments, so an authenticated client (or an unsecured kilo serve) can run any connected MCP tool with no permission prompt, no sandbox network check, and no timeout. Consider routing through the same execution path as session/code-mode tool calls, or at least applying permission + timeout here.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

).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 } }
}
}
6 changes: 5 additions & 1 deletion packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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)
Expand All @@ -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
}),
)
8 changes: 8 additions & 0 deletions packages/opencode/src/session/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -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 = {
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/test/server/httpapi-mcp-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
)

Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/test/tool/code-mode-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ async function buildTool() {
const listed = (await client.listTools()).tools as MCPToolDef[]
const mcpTools: Record<string, MCP.McpTool> = {}
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(
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/test/tool/code-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ function mcpTool(
client: {
callTool: async (params: { arguments?: Record<string, unknown> }) => handler(params.arguments ?? {}),
} as unknown as MCP.McpTool["client"],
clientName: name.split("_")[0] ?? name, // kilocode_change
}
}

Expand Down Expand Up @@ -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", () => "", {
Expand Down
2 changes: 2 additions & 0 deletions packages/opencode/test/tool/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }),
Expand Down Expand Up @@ -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"] }),
Expand Down
Loading