Skip to content

feat(app): surface AXI tools in MCP status popover#2

Open
davidgut1982 wants to merge 224 commits into
devfrom
feat/axi-tools-resource-list
Open

feat(app): surface AXI tools in MCP status popover#2
davidgut1982 wants to merge 224 commits into
devfrom
feat/axi-tools-resource-list

Conversation

@davidgut1982

Copy link
Copy Markdown
Owner

Intent

Wire AXI CLI tools (gh-axi, npm-axi, chrome-devtools-axi, cluster-ops-axi) into the opencode status popover MCP section. The label should change from 'MCP' to 'MCP/AXI' and the AXI tools should appear alongside MCP servers with an info-blue indicator (not toggle switches, since they are read-only). The backend's scanAxiTools() already returns AXI resources via the experimental resource endpoint — this connects it to the frontend.

What Changed

  • scanAxiTools() on the backend scans PATH for AXI CLI tools (gh-axi, npm-axi, chrome-devtools-axi, cluster-ops-axi) and exposes them via the experimental resource endpoint merged alongside MCP resources
  • The MCP status popover now renders AXI tools with an info-blue indicator under a renamed "MCP/AXI" section header; AXI entries are display-only (no toggle) since they are read-only tools
  • Fixed AXI query activation in child-store.ts (the enableAxi() call was never reached due to a missing axi: true option), removed dead isSymbolicLink() check that stat() always returns false for, corrected AXI fixture shape in tests, and added disableAxi() for symmetric test isolation

Risk Assessment

✅ Low: All round 1 findings are resolved; the remaining concern is a low-probability key collision that only surfaces if a user happens to have an MCP server literally named "axi".

Testing

Full app unit suite (407 pass / 0 fail / 70 files) exercised including the modified child-store.test.ts which adds the axi query fixture and verifies the updated querySingles count. Source diff confirms the MCP→MCP/AXI label, info-blue indicator rendering, scanAxiTools() backend, and disableAxi flag — all user-intent points are satisfied and no regressions were found.

Evidence: MCP→MCP/AXI label diff (en.ts)

BASE e97020e: "status.popover.tab.mcp": "MCP" TARGET d799bc6: "status.popover.tab.mcp": "MCP/AXI"

=== en.ts label at BASE vs TARGET ===
BASE  e97020e:
  "status.popover.tab.mcp": "MCP",
TARGET d799bc6:
  "status.popover.tab.mcp": "MCP/AXI",
Evidence: Full app unit test run (407 pass / 0 fail)

bun test v1.3.14 407 pass 0 fail 1044 expect() calls Ran 407 tests across 70 files. [2.29s]

bun test v1.3.14 (0d9b296a)

src/utils/notification-click.test.ts:
notification-click: navigate function not set, falling back to window.location.assign

 407 pass
 0 fail
 1044 expect() calls
Ran 407 tests across 70 files. [2.29s]
Evidence: scanAxiTools() backend diff
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 caed54400..f879d8556 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<A, R>(self: Effect.Effect<A, Worktree.Error, R>) {
   return self.pipe(
@@ -170,8 +174,47 @@ 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<string, { name: string; uri: string; description?: string; mimeType?: string; client: string }>> {
+      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
Evidence: App-side AXI diff (status-popover, child-store, directory-sync)
diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx
index 68a3f6b22..3a324a2ba 100644
--- a/packages/app/src/components/status-popover-body.tsx
+++ b/packages/app/src/components/status-popover-body.tsx
@@ -280,8 +280,10 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
   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<boolean> }) {
             </Tabs.Trigger>
           )}
           <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
-            {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
+            {mcpOrAxiConnected() > 0 ? `${mcpOrAxiConnected()} ` : ""}
             {language.t("status.popover.tab.mcp")}
           </Tabs.Trigger>
           <Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
@@ -445,6 +447,24 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
                     )
                   }}
                 </For>
+                <Show when={axiNames().length > 0}>
+                  <div class="text-12-medium text-text-weaker px-3 pt-3 pb-1">AXI</div>
+                  <For each={axiNames()}>
+                    {(key) => {
+                      const axi = () => sync().data.axi?.[key]
+                      return (
+                        <div class="flex items-center gap-2 w-full min-h-8 pl-3 pr-2 py-1 rounded-md text-left">
+                          <div class="size-1.5 rounded-full shrink-0 bg-icon-info-base" />
+                          <span class="flex flex-col min-w-0 flex-1">
+                            <span class="flex items-center gap-2 min-w-0">
+                              <span class="text-14-regular text-text-base truncate">{axi()?.name}</span>
+                            </span>
+                          </span>
+                        </div>
+                      )
+                    }}
+                  </For>
+                </Show>
               </Show>
             </div>
           </div>
diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts
index 97f48fb22..30a02ece0 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/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts
index 6d4b14e8c..4a5bc1e3f 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 e9ab8fa2a..6d074fdf9 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<string, () => void>()
   const mcpDirectories = new Set<string>()
   const mcpToggles = new Map<string, (enabled: boolean) => void>()
+  const axiDirectories = new Set<string>()
+  const axiToggles = new Map<string, (enabled: boolean) => 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> | 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,

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⏭️ **Review** - skipped
  • 🚨 packages/app/src/context/directory-sync.ts:184 - AXI loading is never activated. enableAxi() is only called from child() / childReady() when options.axi === true, but the sole call site at directory-sync.ts:184 only passes { mcp: true } and not axi: true. This leaves axiEnabled permanently false, the TanStack Query for AXI is never activated, and sync().data.axi is always {}. The status popover AXI section will silently show nothing regardless of what tools are installed.
  • ℹ️ packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts:195 - stat() follows symlinks, so stats.isSymbolicLink() is always false when using stat() — symlink detection requires lstat(). The branch !stats.isFile() &amp;&amp; !stats.isSymbolicLink() reduces to just !stats.isFile(). Since AXI tools like gh-axi are often symlinks installed by package managers, they are correctly included (symlinks to files return isFile() === true via stat()), so behavior is correct. The isSymbolicLink() check is dead code that should be removed.
  • ℹ️ packages/app/src/context/global-sync/child-store.test.ts:63 - The AXI query mock at line 63 returns { demo: { status: &#39;disabled&#39; } } — the MCP status shape — copy-pasted from the line above. The actual AXI type is { [key: string]: { name: string; uri: string; description?: string; mimeType?: string } }. The test only checks query counts so it still passes, but the fixture is misleading for anyone reading or extending this test.
  • ⚠️ packages/app/src/context/global-sync/child-store.ts:324 - child-store.ts exports disableMcp but has no disableAxi. The axiToggles map is populated but only ever set to true (via enableAxi). If a future caller needs to temporarily suspend AXI loading (e.g. for test isolation or dynamic toggling), there is no API. Likely intentional since AXI tools are global/static unlike MCP servers, but worth noting the asymmetry.

🔧 Fix: fix axi activation, dead symlink check, test fixture shape, add disableAxi
1 warning still open:

  • ⚠️ packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts:200 - AXI resource keys are generated as &#34;axi:&#34; + sanitize(filename) (e.g. axi:gh-axi). MCP resource keys use the same format sanitize(clientName) + &#34;:&#34; + sanitize(resourceName). If any MCP server is named &#34;axi&#34;, its resources will be silently overwritten by AXI entries in { ...mcpResources, ...axiResources }. Consider a dedicated prefix that cannot be confused with an MCP client name (e.g. __axi__: or $axi:), or add an assertion/log when the namespaces collide.
✅ **Test** - passed

✅ No issues found.

  • cd packages/app &amp;&amp; bun test — full 407-test app unit suite (70 files, 2.29s)
  • git diff e97020efa4b4b9fd48905880c1d5e6cba153da0b d799bc6dbd88d51600e5c6762df95e2571fef618 -- packages/app/src/i18n/en.ts — confirmed MCP→MCP/AXI label change
  • git diff e97020e d799bc6 -- packages/opencode/src/server/routes/instance/httpapi/handlers/experimental.ts — confirmed scanAxiTools() implementation and resource() merge
  • git diff e97020e d799bc6 -- packages/app/src/context/global-sync/child-store.test.ts — confirmed axi fixture and querySingles count bump from 4→5
✅ **Document** - passed

✅ No issues found.

⚠️ **Lint** - 2 warnings
  • ⚠️ packages/app/src/components/status-popover-body.tsx:133 - typescript-eslint(unbound-method): platform.getDefaultServer passed as an unbound method reference to useDefaultServerKey. False positive — getDefaultServer is defined as an arrow function and has no this context to lose.
  • ⚠️ packages/app/src/components/status-popover-body.tsx:281 - typescript-eslint(unbound-method): Same false positive on the second useDefaultServerKey(platform.getDefaultServer) call in StatusPopoverBody.
✅ **Push** - passed

✅ No issues found.

thdxr and others added 30 commits June 9, 2026 22:04
kitlangton and others added 30 commits June 20, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.