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") {