Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .changeset/olive-donkeys-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@xnetjs/devkit': minor
---

Add `mcpHttpConfigFor` for pointing a coding agent at an already-running MCP
server over Streamable HTTP, alongside the existing `mcpConfigFor` for servers
the agent spawns itself. This is how a host application hands the agent its
tools without shipping a CLI for it to launch: the app serves the workspace from
its own process and passes the URL plus a pairing header.
68 changes: 52 additions & 16 deletions apps/electron/src/main/agent-bridge-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@ import {
cliChatAgent,
cliStreamingChatAgent,
createBridgeServer,
mcpConfigFor,
mcpHttpConfigFor,
NodeCommandRunner,
NodeLineRunner,
type BridgeServerHandle,
type ChatAgent
} from '@xnetjs/devkit'
import { app, ipcMain } from 'electron'
import { startAgentMcpServer, stopAgentMcpServer } from './agent-mcp-server'

export interface AgentBridgeStatus {
running: boolean
Expand All @@ -40,6 +41,12 @@ export interface AgentBridgeStatus {
* pairing code instead. Present only while `running`.
*/
token?: string
/**
* Whether the agent has xNet's workspace tools this run. False means chat
* still works but the agent cannot read or write the workspace; `detail`
* says why.
*/
workspaceTools?: boolean
detail?: string
}

Expand All @@ -66,20 +73,40 @@ export function resolveAllowedOrigins(): string[] {
}

/**
* Opt-in: give the agent xNet's workspace tools by pointing its MCP config at a
* resolvable `xnet mcp serve`. Requires `XNET_BRIDGE_MCP=1` and a CLI entry
* (`XNET_BRIDGE_MCP_CLI`, run via this process's node), because in a packaged
* app `xnet` isn't on PATH. Returns the written config path, or undefined.
* Give the agent xNet's workspace tools, so a chat turn can read the workspace
* and write to it rather than only talk about it.
*
* The server runs in this process (`agent-mcp-server.ts`) and the agent reaches
* it over Streamable HTTP. Set `XNET_BRIDGE_MCP=0` to withhold the tools and
* get a plain, workspace-blind chat agent.
*
* Returns the written config path, or undefined when the tools are withheld or
* the server could not start — in which case the bridge still serves chat, and
* the reason is surfaced in {@link AgentBridgeStatus.detail} rather than
* leaving the agent silently tool-less.
*/
function resolveMcpConfigPath(): string | undefined {
if (!process.env.XNET_BRIDGE_MCP) return undefined
const cli = process.env.XNET_BRIDGE_MCP_CLI
if (!cli) return undefined
const apiUrl = process.env.XNET_BRIDGE_MCP_API_URL ?? 'http://127.0.0.1:31415'
const spec = { command: process.execPath, args: [cli, 'mcp', 'serve', '--api-url', apiUrl] }
const configPath = join(app.getPath('userData'), 'agent-bridge-mcp.json')
writeFileSync(configPath, JSON.stringify(mcpConfigFor(spec)))
return configPath
async function resolveMcpConfigPath(): Promise<{ path?: string; detail?: string }> {
if (process.env.XNET_BRIDGE_MCP === '0') return {}
try {
const mcp = await startAgentMcpServer()
const configPath = join(app.getPath('userData'), 'agent-bridge-mcp.json')
writeFileSync(
configPath,
JSON.stringify(
mcpHttpConfigFor({
url: mcp.endpoint,
headers: { 'x-xnet-pairing': mcp.pairingToken }
})
)
)
return { path: configPath }
} catch (err) {
return { detail: `workspace tools unavailable: ${errorMessage(err)}` }
}
}

function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err)
}

export function getAgentBridgeStatus(): AgentBridgeStatus {
Expand All @@ -104,7 +131,8 @@ export async function startAgentBridge(
return status
}

const mcpConfigPath = resolveMcpConfigPath()
const mcp = await resolveMcpConfigPath()
const mcpConfigPath = mcp.path
let agent: ChatAgent
if (agentCmd === 'claude') {
// Streaming + session continuity (exploration 0391): live deltas over SSE,
Expand Down Expand Up @@ -135,13 +163,21 @@ export async function startAgentBridge(
return status
}
handle = server
status = { running: true, agent: agentCmd, url: server.url, token: server.pairingToken }
status = {
running: true,
agent: agentCmd,
url: server.url,
token: server.pairingToken,
workspaceTools: mcpConfigPath !== undefined,
...(mcp.detail ? { detail: mcp.detail } : {})
}
return status
}

export async function stopAgentBridge(): Promise<void> {
await handle?.stop()
handle = undefined
await stopAgentMcpServer()
status = { ...status, running: false }
}

Expand Down
84 changes: 84 additions & 0 deletions apps/electron/src/main/agent-mcp-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* The xNet MCP server the bridged coding agent talks to.
*
* This is what turns the bridge from a chatbot into something that acts on the
* workspace: `xnet_query` / `xnet_get` to read it, `xnet_create_page` /
* `xnet_create_task` / `xnet_update` to write to it. The write guardrail
* (confirmation for destructive and outward-facing writes, cost budget, audit)
* lives inside `MCPServer`, so it holds regardless of which agent is driving.
*
* It runs **in the main process** over the renderer store proxy, rather than
* spawning `xnet mcp serve` as a child: the CLI is not an Electron dependency
* and would not resolve in a packaged app, and an in-process server also skips
* the local API's HTTP hop and its per-session token. Claude Code reaches it
* over the Streamable-HTTP MCP transport on an ephemeral loopback port.
*/

import {
createMCPServer,
createMcpHttpServer,
type McpHttpServerHandle,
type MCPServer
} from '@xnetjs/plugins/node'
import {
createNodeStoreProxy,
createSchemaRegistryProxy,
setupStoreResponseHandler,
type SchemaRegistryProxy
} from './renderer-store-proxy'

export interface AgentMcpServerHandle {
/** Full JSON-RPC endpoint, e.g. `http://127.0.0.1:52341/mcp`. */
readonly endpoint: string
/** Secret the agent must send as `x-xnet-pairing`. */
readonly pairingToken: string
readonly server: MCPServer
stop(): Promise<void>
}

let handle: AgentMcpServerHandle | undefined

/**
* Start the MCP server, or return the running one.
*
* Binds an ephemeral port (`0`) rather than the transport's 31416 default,
* which the agent bridge daemon already owns. No `allowedOrigins`: the only
* intended client is a spawned CLI, which sends no `Origin` and is gated by the
* pairing token alone.
*/
export async function startAgentMcpServer(): Promise<AgentMcpServerHandle> {
if (handle) return handle

setupStoreResponseHandler()
const schemas: SchemaRegistryProxy = createSchemaRegistryProxy()
// Not awaited: the bridge starts before `createWindow()`, and priming needs
// the renderer. Binding the port is what the bridge's MCP config depends on;
// the cache only has to be warm by the time a chat turn calls a tool.
void schemas.ensurePrimed().catch(() => undefined)

const server = createMCPServer({ store: createNodeStoreProxy(), schemas })
const http = createMcpHttpServer({ server, port: 0 })
await http.start()

handle = {
endpoint: `${http.url}${http.path}`,
pairingToken: http.pairingToken,
server,
stop: () => stopHttp(http)
}
return handle
}

async function stopHttp(http: McpHttpServerHandle): Promise<void> {
await http.stop()
handle = undefined
}

export function getAgentMcpServer(): AgentMcpServerHandle | undefined {
return handle
}

export async function stopAgentMcpServer(): Promise<void> {
await handle?.stop()
handle = undefined
}
Loading
Loading