diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 718929d445b..8f7d4bb467a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -341,6 +341,36 @@ export function Autocomplete(props: { ) }) + const mcpServers = createMemo(() => { + const mcps = Object.entries(sync.data.mcp) + const options = mcps.map( + ([name, status]): AutocompleteOption => ({ + display: "@mcp/" + name, + description: status.status === "connected" ? "Connected" : status.status, + onSelect: () => { + const input = props.input() + const currentCursorOffset = input.cursorOffset + const text = "@mcp/" + name + " " + + input.cursorOffset = store.index + const startCursor = input.logicalCursor + input.cursorOffset = currentCursorOffset + const endCursor = input.logicalCursor + + input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col) + input.insertText(text) + }, + }), + ) + + const max = firstBy(options, [(x) => x.display.length, "desc"])?.display.length + if (!max) return options + return options.map((item) => ({ + ...item, + display: item.display.padEnd(max + 2), + })) + }) + const commands = createMemo((): AutocompleteOption[] => { const results: AutocompleteOption[] = [...command.slashes()] @@ -372,9 +402,12 @@ export function Autocomplete(props: { const filesValue = files() const agentsValue = agents() const commandsValue = commands() + const mcpServersValue = mcpServers() const mixed: AutocompleteOption[] = - store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] + store.visible === "@" + ? [...agentsValue, ...mcpServersValue, ...(filesValue || []), ...mcpResources()] + : [...commandsValue] const currentFilter = filter() diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 392cfb7f121..26f212dde3d 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -70,6 +70,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ mcp_resource: { [key: string]: McpResource } + ephemeral_mcp: { + [sessionID: string]: string[] + } formatter: FormatterStatus[] vcs: VcsInfo | undefined path: Path @@ -97,6 +100,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ lsp: [], mcp: {}, mcp_resource: {}, + ephemeral_mcp: {}, formatter: [], vcs: undefined, path: { state: "", config: "", worktree: "", directory: "" }, @@ -322,6 +326,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ setStore("vcs", { branch: event.properties.branch }) break } + + case "session.ephemeral_mcp": { + setStore("ephemeral_mcp", event.properties.sessionID, event.properties.servers) + break + } } }) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx index ebc7514d723..e857f753f72 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx @@ -19,6 +19,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { const diff = createMemo(() => sync.data.session_diff[props.sessionID] ?? []) const todo = createMemo(() => sync.data.todo[props.sessionID] ?? []) const messages = createMemo(() => sync.data.message[props.sessionID] ?? []) + const ephemeralMcps = createMemo(() => sync.data.ephemeral_mcp[props.sessionID] ?? []) const [expanded, setExpanded] = createStore({ mcp: true, @@ -120,40 +121,46 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) { - {([key, item]) => ( - - - )[item.status], - }} - > - • - - - {key}{" "} - - - Connected - {(val) => {val().error}} - Disabled - Needs auth - - Needs client ID - - - - - - )} + {([key, item]) => { + const isEphemeral = createMemo(() => ephemeralMcps().includes(key)) + return ( + + + )[item.status], + }} + > + • + + + {key}{" "} + + + Ephemeral + Connected + {(val) => {val().error}} + Disabled + Needs auth + + Needs client ID + + + + + + ) + }} diff --git a/packages/opencode/src/session/ephemeral-mcp.ts b/packages/opencode/src/session/ephemeral-mcp.ts new file mode 100644 index 00000000000..77b930b4fbe --- /dev/null +++ b/packages/opencode/src/session/ephemeral-mcp.ts @@ -0,0 +1,37 @@ +import { Instance } from "@/project/instance" +import { BusEvent } from "@/bus/bus-event" +import { Bus } from "@/bus" +import z from "zod" + +export namespace EphemeralMcp { + export const Event = BusEvent.define( + "session.ephemeral_mcp", + z.object({ + sessionID: z.string(), + servers: z.array(z.string()), + }), + ) + + const state = Instance.state(() => { + const data: Record = {} + return data + }) + + export function set(sessionID: string, servers: string[]) { + state()[sessionID] = servers + Bus.publish(Event, { sessionID, servers }) + } + + export function get(sessionID: string): string[] { + return state()[sessionID] ?? [] + } + + export function clear(sessionID: string) { + delete state()[sessionID] + Bus.publish(Event, { sessionID, servers: [] }) + } + + export function list() { + return state() + } +} diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 1029b45ea0d..73ace82581a 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -41,6 +41,7 @@ export namespace LLM { small?: boolean tools: Record retries?: number + toolChoice?: "auto" | "required" | "none" | { type: "tool"; toolName: string } } export type StreamOutput = StreamTextResult @@ -196,6 +197,7 @@ export namespace LLM { activeTools: Object.keys(tools).filter((x) => x !== "invalid" && x !== "_noop"), tools, maxOutputTokens, + toolChoice: input.toolChoice, abortSignal: input.abort, headers: { ...(isCodex diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 9325583acf7..aa0899105a3 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -35,6 +35,8 @@ import { $, fileURLToPath } from "bun" import { ConfigMarkdown } from "../config/markdown" import { SessionSummary } from "./summary" import { NamedError } from "@opencode-ai/util/error" +import { Config } from "../config/config" +import { EphemeralMcp } from "./ephemeral-mcp" import { fn } from "@/util/fn" import { SessionProcessor } from "./processor" import { TaskTool } from "@/tool/task" @@ -255,6 +257,109 @@ export namespace SessionPrompt { return } + function parseEphemeralMcpMentions(text: string): string[] { + const pattern = /@mcp\/([a-z0-9_-]+)/gi + const matches = text.matchAll(pattern) + const servers = new Set() + for (const match of matches) { + servers.add(match[1].toLowerCase()) + } + return Array.from(servers) + } + + async function connectEphemeralMcps(sessionID: string): Promise<{ + connected: string[] + available: string[] + }> { + const messages = await MessageV2.filterCompacted(MessageV2.stream(sessionID)) + const lastUser = messages.findLast((m) => m.info.role === "user") + if (!lastUser) return { connected: [], available: [] } + + const textParts = lastUser.parts.filter((p) => p.type === "text") + const allText = textParts.map((p) => p.text).join(" ") + const mcpServers = parseEphemeralMcpMentions(allText) + if (mcpServers.length === 0) return { connected: [], available: [] } + + const cfg = await Config.get() + const mcpConfig = cfg.mcp ?? {} + const connected: string[] = [] + const available: string[] = [] + const status = await MCP.status() + + for (const serverName of mcpServers) { + const config = mcpConfig[serverName] + if (!config) { + log.warn("ephemeral mcp not found in config", { serverName }) + continue + } + + const isMcpConfigured = (entry: typeof config): entry is Config.Mcp => + typeof entry === "object" && entry !== null && "type" in entry + + if (!isMcpConfigured(config)) { + log.warn("ephemeral mcp config invalid", { serverName }) + continue + } + + if (status[serverName]?.status === "connected") { + available.push(serverName) + continue + } + + const result = await MCP.add(serverName, { ...config, enabled: true }) + if (result.status[serverName]?.status !== "connected") { + continue + } + + connected.push(serverName) + available.push(serverName) + log.info("ephemeral mcp connected", { serverName }) + } + + EphemeralMcp.set(sessionID, connected) + return { connected, available } + } + + async function disconnectEphemeralMcps(servers: string[]): Promise { + for (const serverName of servers) { + await MCP.disconnect(serverName) + log.info("ephemeral mcp disconnected", { serverName }) + } + } + + function injectEphemeralMcpReminder(messages: MessageV2.WithParts[], servers: string[]) { + if (servers.length === 0) return + const lastUser = messages.findLast((m) => m.info.role === "user") + if (!lastUser) return + lastUser.parts.push({ + id: Identifier.ascending("part"), + messageID: lastUser.info.id, + sessionID: lastUser.info.sessionID, + type: "text", + text: `Use MCP tools from: ${servers.join(", ")} for this request. Prefer them when relevant.`, + synthetic: true, + }) + } + + function sanitizeMcpName(name: string) { + return name.replace(/[^a-zA-Z0-9_-]/g, "_") + } + + function filterMcpTools(tools: Record, servers: string[]) { + if (servers.length === 0) return { tools, forced: false } + const prefixes = new Set(servers.map((server) => sanitizeMcpName(server) + "_")) + const filtered = Object.fromEntries( + Object.entries(tools).filter(([name]) => { + for (const prefix of prefixes) { + if (name.startsWith(prefix)) return true + } + return false + }), + ) as Record + if (Object.keys(filtered).length === 0) return { tools, forced: false } + return { tools: filtered, forced: true } + } + export const loop = fn(Identifier.schema("session"), async (sessionID) => { const abort = start(sessionID) if (!abort) { @@ -268,6 +373,11 @@ export namespace SessionPrompt { let step = 0 const session = await Session.get(sessionID) + const { connected: ephemeralMcps, available: ephemeralAvailable } = await connectEphemeralMcps(sessionID) + using _cleanup = defer(async () => { + await disconnectEphemeralMcps(ephemeralMcps) + EphemeralMcp.clear(sessionID) + }) while (true) { SessionStatus.set(sessionID, { type: "busy" }) log.info("loop", { step, sessionID }) @@ -560,6 +670,7 @@ export namespace SessionPrompt { processor, bypassAgentCheck, }) + const forced = filterMcpTools(tools, ephemeralAvailable) if (step === 1) { SessionSummary.summarize({ @@ -569,6 +680,7 @@ export namespace SessionPrompt { } const sessionMessages = clone(msgs) + injectEphemeralMcpReminder(sessionMessages, ephemeralAvailable) // Ephemerally wrap queued user messages with a reminder to stay on track if (step > 1 && lastFinished) { @@ -591,6 +703,7 @@ export namespace SessionPrompt { await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: sessionMessages }) + const requireMcpTool = forced.forced && step === 1 const result = await processor.process({ user: lastUser, agent, @@ -608,8 +721,9 @@ export namespace SessionPrompt { ] : []), ], - tools, + tools: forced.tools, model, + toolChoice: requireMcpTool ? "required" : undefined, }) if (result === "stop") break if (result === "compact") {