diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx
index 68a3f6b22676..5239fce075dc 100644
--- a/packages/app/src/components/status-popover-body.tsx
+++ b/packages/app/src/components/status-popover-body.tsx
@@ -161,7 +161,7 @@ export function StatusPopoverServerBody() {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
- dialog.show(() => , defaultServer.refresh)
+ void dialog.show(() => , defaultServer.refresh)
})
},
}}
@@ -258,7 +258,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) {
const navigate = useNavigate()
const settings = useSettings()
- const fail = (err: unknown) => {
+ const _fail = (err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
@@ -280,8 +280,10 @@ export function StatusPopoverBody(props: { shown: Accessor }) {
const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
+ const axiNames = createMemo(() => Object.keys(sync().data.axi ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
+ const mcpOrAxiConnected = createMemo(() => mcpConnected() + axiNames().length)
const lspItems = createMemo(() => sync().data.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() =>
@@ -308,7 +310,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) {
)}
- {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
+ {mcpOrAxiConnected() > 0 ? `${mcpOrAxiConnected()} ` : ""}
{language.t("status.popover.tab.mcp")}
@@ -377,7 +379,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
- dialog.show(() => , defaultServer.refresh)
+ void dialog.show(() => , defaultServer.refresh)
})
}}
>
@@ -445,6 +447,24 @@ export function StatusPopoverBody(props: { shown: Accessor }) {
)
}}
+ 0}>
+ AXI
+
+ {(key) => {
+ const axi = () => sync().data.axi?.[key]
+ return (
+
+
+
+
+ {axi()?.name}
+
+
+
+ )
+ }}
+
+
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
index 6462a76993cf..e4a94fc6814d 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/packages/app/src/components/status-popover.tsx
@@ -25,6 +25,7 @@ export function StatusPopover() {
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
+ return undefined
})
const serverHealthy = () => global.servers.health[server.key]?.healthy === true
const healthy = createMemo(() => global.servers.health[server.key]?.healthy === true && !mcpIssue())
@@ -93,6 +94,7 @@ function DirectoryStatusPopover() {
const warn = mcp.some((item) => item.status === "needs_auth")
if (failed) return "critical" as const
if (warn) return "warning" as const
+ return undefined
})
const healthy = createMemo(() => serverHealth() === true && !mcpIssue())
const state = createMemo(() => ({
diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts
index 97f48fb2230f..30a02ece066e 100644
--- a/packages/app/src/context/directory-sync.ts
+++ b/packages/app/src/context/directory-sync.ts
@@ -181,7 +181,7 @@ export const createDirSyncContext = (
type Child = ReturnType<(typeof serverSync)["child"]>
type Setter = Child[1]
- const current = createMemo(() => serverSync.child(directory, { mcp: true }))
+ const current = createMemo(() => serverSync.child(directory, { mcp: true, axi: true }))
const target = (directory?: string) => {
if (!directory || directory === directory) return current()
return serverSync.child(directory)
diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts
index 8393b0474258..6428282e3731 100644
--- a/packages/app/src/context/global-sync/bootstrap.test.ts
+++ b/packages/app/src/context/global-sync/bootstrap.test.ts
@@ -35,6 +35,7 @@ describe("bootstrapDirectory", () => {
question: {},
mcp_ready: true,
mcp: {},
+ axi: {},
lsp_ready: true,
lsp: [],
vcs: undefined,
@@ -48,6 +49,7 @@ describe("bootstrapDirectory", () => {
directory: "/project",
scope: ServerScope.local,
mcp: false,
+ axi: false,
global: {
config: {} satisfies Config,
path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" },
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
index bac9c69280d0..414228647897 100644
--- a/packages/app/src/context/global-sync/bootstrap.ts
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -18,7 +18,7 @@ import type { State, VcsCache } from "./types"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query"
-import { loadMcpQuery } from "../server-sync"
+import { loadMcpQuery, loadAxiQuery } from "../server-sync"
import { NormalizedProviderListResponse } from "@opencode-ai/ui/context"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
@@ -202,6 +202,7 @@ export async function bootstrapDirectory(input: {
directory: string
scope: ServerScope
mcp: boolean
+ axi: boolean
sdk: OpencodeClient
store: Store
setStore: SetStoreFunction
@@ -310,6 +311,7 @@ export async function bootstrapDirectory(input: {
),
() => Promise.resolve(input.loadSessions(input.directory)),
input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))),
+ input.axi && (() => input.queryClient.fetchQuery(loadAxiQuery(input.scope, input.directory, input.sdk))),
() =>
input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => {
const project = getFilename(input.directory)
diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts
index 6d4b14e8c07d..4a5bc1e3f86c 100644
--- a/packages/app/src/context/global-sync/child-store.test.ts
+++ b/packages/app/src/context/global-sync/child-store.test.ts
@@ -34,6 +34,7 @@ const queryOptionsApi = {
}),
agents: (directory: string) => ({ queryKey: [directory, "agents"], queryFn: async () => [] }),
mcp: (directory: string) => ({ queryKey: [directory, "mcp"], queryFn: async () => ({}) }),
+ axi: (directory: string) => ({ queryKey: [directory, "axi"], queryFn: async () => ({}) }),
lsp: (directory: string) => ({ queryKey: [directory, "lsp"], queryFn: async () => [] }),
sessions: (directory: string) => ({ queryKey: [directory, "loadSessions"] as const }),
} as unknown as QueryOptionsApi
@@ -59,6 +60,7 @@ beforeAll(async () => {
get data() {
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
+ if (options().queryKey?.[1] === "axi") return options().enabled ? { demo: { name: "demo", uri: "axi://demo" } } : undefined
if (options().queryKey?.[1] === "lsp") return []
if (options().queryKey?.[1] === "providers") return provider
return undefined
@@ -197,7 +199,7 @@ describe("createChildStoreManager", () => {
try {
if (!manager) throw new Error("manager required")
const [store, setStore] = manager.child("/project", { bootstrap: false })
- expect(querySingles.length - offset).toBe(4)
+ expect(querySingles.length - offset).toBe(5)
const query = querySingles[offset + 1]
if (!query) throw new Error("query required")
expect(query().enabled).toBe(false)
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
index e9ab8fa2a347..6d074fdf9f51 100644
--- a/packages/app/src/context/global-sync/child-store.ts
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -45,6 +45,8 @@ export function createChildStoreManager(input: {
const disposers = new Map void>()
const mcpDirectories = new Set()
const mcpToggles = new Map void>()
+ const axiDirectories = new Set()
+ const axiToggles = new Map void>()
const markKey = (key: DirectoryKey) => {
if (!key) return
@@ -118,6 +120,8 @@ export function createChildStoreManager(input: {
lifecycle.delete(key)
mcpDirectories.delete(key)
mcpToggles.delete(key)
+ axiDirectories.delete(key)
+ axiToggles.delete(key)
const dispose = disposers.get(key)
if (dispose) {
dispose()
@@ -182,9 +186,11 @@ export function createChildStoreManager(input: {
const initialMeta = meta[0].value
const initialIcon = icon[0].value
const [mcpEnabled, setMcpEnabled] = createSignal(false)
+ const [axiEnabled, setAxiEnabled] = createSignal(false)
const pathQuery = useQuery(() => input.queryOptions.path(key))
const mcpQuery = useQuery(() => ({ ...input.queryOptions.mcp(key), enabled: mcpEnabled() }))
+ const axiQuery = useQuery(() => ({ ...input.queryOptions.axi(key), enabled: axiEnabled() }))
const lspQuery = useQuery(() => input.queryOptions.lsp(key))
const providerQuery = useQuery(() => input.queryOptions.providers(key))
@@ -227,6 +233,9 @@ export function createChildStoreManager(input: {
get mcp() {
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
},
+ get axi() {
+ return axiQuery.isLoading ? {} : (axiQuery.data ?? {})
+ },
get lsp_ready() {
return !lspQuery.isLoading
},
@@ -242,6 +251,7 @@ export function createChildStoreManager(input: {
children[key] = child
disposers.set(key, dispose)
mcpToggles.set(key, setMcpEnabled)
+ axiToggles.set(key, setAxiEnabled)
const onPersistedInit = (init: Promise | string | null, run: () => void) => {
if (!(init instanceof Promise)) return
@@ -281,6 +291,7 @@ export function createChildStoreManager(input: {
const childStore = ensureChild(directory)
pinForOwner(key)
if (options.mcp) enableMcp(directory, key, childStore)
+ if (options.axi) enableAxi(directory, key)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
@@ -292,6 +303,7 @@ export function createChildStoreManager(input: {
const key = directoryKey(directory)
const childStore = ensureChild(directory)
if (options.mcp) enableMcp(directory, key, childStore)
+ if (options.axi) enableAxi(directory, key)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory)
@@ -312,6 +324,18 @@ export function createChildStoreManager(input: {
mcpToggles.get(key)?.(false)
}
+ function enableAxi(directory: string, key: DirectoryKey) {
+ if (axiDirectories.has(key)) return
+ axiDirectories.add(key)
+ axiToggles.get(key)?.(true)
+ }
+
+ function disableAxi(directory: string) {
+ const key = directoryKey(directory)
+ if (!axiDirectories.delete(key)) return
+ axiToggles.get(key)?.(false)
+ }
+
function projectMeta(directory: string, patch: ProjectMeta) {
const key = directoryKey(directory)
const [store, setStore] = ensureChild(directory)
@@ -352,7 +376,9 @@ export function createChildStoreManager(input: {
unpin,
pinned,
mcp: (directory: string) => mcpDirectories.has(directoryKey(directory)),
+ axi: (directory: string) => axiDirectories.has(directoryKey(directory)),
disableMcp,
+ disableAxi,
disposeDirectory,
runEviction,
vcsCache,
diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts
index 8db24b904b29..2a296f9fa2fa 100644
--- a/packages/app/src/context/global-sync/types.ts
+++ b/packages/app/src/context/global-sync/types.ts
@@ -63,6 +63,9 @@ export type State = {
mcp: {
[name: string]: McpStatus
}
+ axi: {
+ [name: string]: { name: string; uri: string; description?: string; mimeType?: string }
+ }
lsp_ready: boolean
lsp: LspStatus[]
vcs: VcsInfo | undefined
@@ -99,6 +102,7 @@ export type IconCache = {
export type ChildOptions = {
bootstrap?: boolean
mcp?: boolean
+ axi?: boolean
}
export type DirState = {
diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx
index fc79fdaa8478..0e3eaa7068f7 100644
--- a/packages/app/src/context/server-sync.tsx
+++ b/packages/app/src/context/server-sync.tsx
@@ -58,6 +58,30 @@ export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: Opencod
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}),
})
+export const loadAxiQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
+ queryOptions({
+ queryKey: [scope, directory, "axi"] as const,
+ queryFn: async () => {
+ const axiEntries: Record = {}
+ try {
+ const resources = await sdk.experimental.resource.list().then((r) => r.data ?? {})
+ for (const [key, resource] of Object.entries(resources)) {
+ if (resource.client === "axi") {
+ axiEntries[key] = {
+ name: resource.name,
+ uri: resource.uri,
+ description: resource.description,
+ mimeType: resource.mimeType,
+ }
+ }
+ }
+ } catch {
+ // AXI failures should not break bootstrap — return empty
+ }
+ return axiEntries
+ },
+ })
+
export const loadLspQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) =>
queryOptions({
queryKey: [scope, directory, "lsp"] as const,
@@ -78,6 +102,7 @@ function makeQueryOptionsApi(
loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)),
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)),
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)),
+ axi: (directory: PathKey) => loadAxiQuery(scope, directory, sdkFor(directory)),
lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)),
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
}
@@ -344,6 +369,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
directory,
scope: serverSDK.scope,
mcp: children.mcp(key),
+ axi: children.axi(key),
global: {
config: globalStore.config,
path: globalStore.path,
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 1ba0c3d230a0..e634d5402da6 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -682,7 +682,7 @@ export const dict = {
"status.popover.trigger": "Status",
"status.popover.ariaLabel": "Server configurations",
"status.popover.tab.servers": "Servers",
- "status.popover.tab.mcp": "MCP",
+ "status.popover.tab.mcp": "MCP/AXI",
"status.popover.tab.lsp": "LSP",
"status.popover.tab.plugins": "Plugins",
"status.popover.action.manageServers": "Manage servers",
diff --git a/packages/core/src/plugin/skill/customize-opencode.md b/packages/core/src/plugin/skill/customize-opencode.md
index 6932dbfd54cc..0d60c8bbf9c6 100644
--- a/packages/core/src/plugin/skill/customize-opencode.md
+++ b/packages/core/src/plugin/skill/customize-opencode.md
@@ -389,6 +389,9 @@ disable a server inherited from a parent config. String values such as header
tokens support `{env:VAR}` interpolation (and `{file:path}`); the shell-style
`${VAR}` is not substituted.
+Local servers accept an optional `cwd` field to set the working directory for
+the server process. Relative paths resolve from the workspace directory.
+
## Permissions
```json
diff --git a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx
index 0280982d5074..45b235d44174 100644
--- a/packages/opencode/src/cli/cmd/run/footer.prompt.tsx
+++ b/packages/opencode/src/cli/cmd/run/footer.prompt.tsx
@@ -336,7 +336,7 @@ export function createPromptState(input: PromptInput): PromptState {
const resources = createMemo(() => {
return input.resources().map((item) => ({
kind: "mention",
- display: Locale.truncateMiddle(`@${item.name} (${item.uri})`, width()),
+ display: Locale.truncateMiddle(`${item.client === "axi" ? "✧ " : "◎ "}@${item.name} (${item.uri})`, width()),
value: item.name,
description: item.description,
part: {
diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts
index caed54400524..35ad0380fd9a 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts
@@ -16,6 +16,10 @@ import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
import { InstanceHttpApi } from "../api"
import { ConsoleSwitchPayload, SessionListQuery, ToolListQuery, WorktreeApiError } from "../groups/experimental"
+import { readdir, stat } from "node:fs/promises"
+import { homedir } from "node:os"
+import { join } from "node:path"
+import { McpCatalog } from "@/mcp/catalog"
function mapWorktreeError(self: Effect.Effect) {
return self.pipe(
@@ -170,8 +174,51 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
return promoted.some((job) => job !== undefined)
})
+ /** Scan ~/.local/bin/ for AXI tools (*-axi executables) and return as McpResource entries. */
+ async function scanAxiTools(): Promise<
+ Record
+ > {
+ const axiDir = join(homedir(), ".local", "bin")
+ let files: string[]
+ try {
+ files = await readdir(axiDir)
+ } catch {
+ return {}
+ }
+
+ const result: Record<
+ string,
+ { name: string; uri: string; description?: string; mimeType?: string; client: string }
+ > = {}
+
+ for (const file of files) {
+ if (!file.endsWith("-axi")) continue
+
+ const filePath = join(axiDir, file)
+ try {
+ const stats = await stat(filePath)
+ if (!stats.isFile()) continue
+ } catch {
+ continue
+ }
+
+ const key = McpCatalog.sanitize("axi") + ":" + McpCatalog.sanitize(file)
+ result[key] = {
+ name: file,
+ uri: `axi://${file}`,
+ client: "axi",
+ mimeType: "text/x-axi",
+ }
+ }
+
+ return result
+ }
+
const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
- return yield* mcp.resources()
+ const [mcpResources, axiResources] = yield* Effect.all([mcp.resources(), Effect.promise(() => scanAxiTools())], {
+ concurrency: "unbounded",
+ })
+ return { ...mcpResources, ...axiResources }
})
return handlers
diff --git a/packages/opencode/test/fixture/tui-plugin.ts b/packages/opencode/test/fixture/tui-plugin.ts
index 5d93139f002b..f169007b209e 100644
--- a/packages/opencode/test/fixture/tui-plugin.ts
+++ b/packages/opencode/test/fixture/tui-plugin.ts
@@ -104,6 +104,7 @@ type Opts = {
part?: HostPluginApi["state"]["part"]
lsp?: HostPluginApi["state"]["lsp"]
mcp?: HostPluginApi["state"]["mcp"]
+ mcp_resource?: HostPluginApi["state"]["mcp_resource"]
}
theme?: {
selected?: string
@@ -325,6 +326,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
part: opts.state?.part ?? (() => []),
lsp: opts.state?.lsp ?? (() => []),
mcp: opts.state?.mcp ?? (() => []),
+ mcp_resource: opts.state?.mcp_resource ?? (() => []),
},
theme: {
get current() {
diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts
index e36d91381d5d..250603242d5e 100644
--- a/packages/plugin/src/tui.ts
+++ b/packages/plugin/src/tui.ts
@@ -396,6 +396,7 @@ export type TuiState = {
part: (messageID: string) => ReadonlyArray
lsp: () => ReadonlyArray
mcp: () => ReadonlyArray
+ mcp_resource: () => ReadonlyArray<{ name: string; uri: string; description?: string }>
}
type TuiBindingLookupView = {
diff --git a/packages/tui/src/feature-plugins/sidebar/mcp.tsx b/packages/tui/src/feature-plugins/sidebar/mcp.tsx
index 99d8567ea73c..5ab5acb15736 100644
--- a/packages/tui/src/feature-plugins/sidebar/mcp.tsx
+++ b/packages/tui/src/feature-plugins/sidebar/mcp.tsx
@@ -8,6 +8,7 @@ function View(props: { api: TuiPluginApi }) {
const [open, setOpen] = createSignal(true)
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.mcp())
+ const axiList = createMemo(() => props.api.state.mcp_resource())
const on = createMemo(() => list().filter((item) => item.status === "connected").length)
const bad = createMemo(
() =>
@@ -26,15 +27,17 @@ function View(props: { api: TuiPluginApi }) {
return theme().textMuted
}
+ const total = () => list().length + axiList().length
+
return (
- 0}>
+ 0}>
- list().length > 2 && setOpen((x) => !x)}>
- 2}>
+ total() > 2 && setOpen((x) => !x)}>
+ 2}>
{open() ? "▼" : "▶"}
- MCP
+ MCP/AXI
{" "}
@@ -43,7 +46,7 @@ function View(props: { api: TuiPluginApi }) {
-
+
{(item) => (
@@ -72,6 +75,20 @@ function View(props: { api: TuiPluginApi }) {
)}
+ 0}>
+
+ {(item) => (
+
+
+ •
+
+
+ {item.name}
+
+
+ )}
+
+
diff --git a/packages/tui/src/plugin/adapters.tsx b/packages/tui/src/plugin/adapters.tsx
index 216190a1db45..70dc7c1e5a67 100644
--- a/packages/tui/src/plugin/adapters.tsx
+++ b/packages/tui/src/plugin/adapters.tsx
@@ -158,6 +158,18 @@ function stateApi(sync: ReturnType): TuiPluginApi["state"] {
error: item.status === "failed" ? item.error : undefined,
}))
},
+ mcp_resource() {
+ const data = sync.data.mcp_resource
+ if (!data) return []
+ return Object.entries(data)
+ .filter(([, resource]) => resource.client === "axi")
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([, resource]) => ({
+ name: resource.name,
+ uri: resource.uri,
+ description: resource.description,
+ }))
+ },
}
}