Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()]

Expand Down Expand Up @@ -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()

Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: "" },
Expand Down Expand Up @@ -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
}
}
})

Expand Down
75 changes: 41 additions & 34 deletions packages/opencode/src/cli/cmd/tui/routes/session/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -120,40 +121,46 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
</box>
<Show when={mcpEntries().length <= 2 || expanded.mcp}>
<For each={mcpEntries()}>
{([key, item]) => (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: (
{
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
needs_auth: theme.warning,
needs_client_registration: theme.error,
} as Record<string, typeof theme.success>
)[item.status],
}}
>
</text>
<text fg={theme.text} wrapMode="word">
{key}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch fallback={item.status}>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
<Match when={item.status === "disabled"}>Disabled</Match>
<Match when={(item.status as string) === "needs_auth"}>Needs auth</Match>
<Match when={(item.status as string) === "needs_client_registration"}>
Needs client ID
</Match>
</Switch>
</span>
</text>
</box>
)}
{([key, item]) => {
const isEphemeral = createMemo(() => ephemeralMcps().includes(key))
return (
<box flexDirection="row" gap={1}>
<text
flexShrink={0}
style={{
fg: isEphemeral()
? theme.warning
: (
{
connected: theme.success,
failed: theme.error,
disabled: theme.textMuted,
needs_auth: theme.warning,
needs_client_registration: theme.error,
} as Record<string, typeof theme.success>
)[item.status],
}}
>
</text>
<text fg={theme.text} wrapMode="word">
{key}{" "}
<span style={{ fg: theme.textMuted }}>
<Switch fallback={item.status}>
<Match when={item.status === "connected" && isEphemeral()}>Ephemeral</Match>
<Match when={item.status === "connected"}>Connected</Match>
<Match when={item.status === "failed" && item}>{(val) => <i>{val().error}</i>}</Match>
<Match when={item.status === "disabled"}>Disabled</Match>
<Match when={(item.status as string) === "needs_auth"}>Needs auth</Match>
<Match when={(item.status as string) === "needs_client_registration"}>
Needs client ID
</Match>
</Switch>
</span>
</text>
</box>
)
}}
</For>
</Show>
</box>
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/src/session/ephemeral-mcp.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]> = {}
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()
}
}
2 changes: 2 additions & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export namespace LLM {
small?: boolean
tools: Record<string, Tool>
retries?: number
toolChoice?: "auto" | "required" | "none" | { type: "tool"; toolName: string }
}

export type StreamOutput = StreamTextResult<ToolSet, unknown>
Expand Down Expand Up @@ -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
Expand Down
116 changes: 115 additions & 1 deletion packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string>()
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<void> {
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: `<system-reminder>Use MCP tools from: ${servers.join(", ")} for this request. Prefer them when relevant.</system-reminder>`,
synthetic: true,
})
}

function sanitizeMcpName(name: string) {
return name.replace(/[^a-zA-Z0-9_-]/g, "_")
}

function filterMcpTools(tools: Record<string, AITool>, 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<string, AITool>
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) {
Expand All @@ -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 })
Expand Down Expand Up @@ -560,6 +670,7 @@ export namespace SessionPrompt {
processor,
bypassAgentCheck,
})
const forced = filterMcpTools(tools, ephemeralAvailable)

if (step === 1) {
SessionSummary.summarize({
Expand All @@ -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) {
Expand All @@ -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,
Expand All @@ -608,8 +721,9 @@ export namespace SessionPrompt {
]
: []),
],
tools,
tools: forced.tools,
model,
toolChoice: requireMcpTool ? "required" : undefined,
})
if (result === "stop") break
if (result === "compact") {
Expand Down