diff --git a/.changeset/olive-donkeys-smile.md b/.changeset/olive-donkeys-smile.md new file mode 100644 index 000000000..7da7e1519 --- /dev/null +++ b/.changeset/olive-donkeys-smile.md @@ -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. diff --git a/apps/electron/src/main/agent-bridge-manager.ts b/apps/electron/src/main/agent-bridge-manager.ts index c93eefee3..502e4a5ef 100644 --- a/apps/electron/src/main/agent-bridge-manager.ts +++ b/apps/electron/src/main/agent-bridge-manager.ts @@ -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 @@ -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 } @@ -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 { @@ -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, @@ -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 { await handle?.stop() handle = undefined + await stopAgentMcpServer() status = { ...status, running: false } } diff --git a/apps/electron/src/main/agent-mcp-server.ts b/apps/electron/src/main/agent-mcp-server.ts new file mode 100644 index 000000000..5b4f1ebe7 --- /dev/null +++ b/apps/electron/src/main/agent-mcp-server.ts @@ -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 +} + +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 { + 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 { + await http.stop() + handle = undefined +} + +export function getAgentMcpServer(): AgentMcpServerHandle | undefined { + return handle +} + +export async function stopAgentMcpServer(): Promise { + await handle?.stop() + handle = undefined +} diff --git a/apps/electron/src/main/local-api.ts b/apps/electron/src/main/local-api.ts index bfb26ecf1..68d7c1657 100644 --- a/apps/electron/src/main/local-api.ts +++ b/apps/electron/src/main/local-api.ts @@ -9,158 +9,23 @@ */ import crypto from 'node:crypto' -import { - type LocalAPIServer, - createLocalAPI, - type NodeStoreAPI, - type SchemaRegistryAPI, - type NodeData -} from '@xnetjs/plugins/node' -import { ipcMain, BrowserWindow } from 'electron' +import { type LocalAPIServer, createLocalAPI, type NodeStoreAPI } from '@xnetjs/plugins/node' +import { ipcMain } from 'electron' import { DEFAULT_LOCAL_API_PORT, resolveLocalAPIPort } from './local-api-config' +import { + createNodeStoreProxy, + createSchemaRegistryProxy, + setupStoreResponseHandler, + type SchemaRegistryProxy +} from './renderer-store-proxy' // ─── Module State ──────────────────────────────────────────────────────────── let apiServer: LocalAPIServer | null = null let nodeStoreProxy: NodeStoreAPI | null = null -let schemaRegistryProxy: SchemaRegistryAPI | null = null +let schemaRegistryProxy: SchemaRegistryProxy | null = null let configuredPort = DEFAULT_LOCAL_API_PORT -// Pending request callbacks - used to receive responses from renderer -let requestId = 0 -const pendingRequests = new Map< - number, - { resolve: (value: unknown) => void; reject: (error: Error) => void } ->() - -// ─── IPC-based Store Proxy ─────────────────────────────────────────────────── - -/** - * Send a request to the renderer and wait for response. - * SEC-03: All parameters passed as structured data via IPC, no code injection possible. - */ -async function sendStoreRequest(operation: string, params: Record): Promise { - const win = BrowserWindow.getAllWindows()[0] - if (!win) { - throw new Error('No window available') - } - - const id = ++requestId - return new Promise((resolve, reject) => { - // Set timeout to avoid hanging forever - const timeout = setTimeout(() => { - pendingRequests.delete(id) - reject(new Error(`Store request timed out: ${operation}`)) - }, 30000) - - pendingRequests.set(id, { - resolve: (value) => { - clearTimeout(timeout) - pendingRequests.delete(id) - resolve(value as T) - }, - reject: (error) => { - clearTimeout(timeout) - pendingRequests.delete(id) - reject(error) - } - }) - - // Send request to renderer via IPC - win.webContents.send('xnet:localapi:store-request', { id, operation, params }) - }) -} - -/** - * Creates a NodeStoreAPI that proxies calls to the renderer process via IPC. - * SEC-03: Replaces executeJavaScript with structured IPC to prevent code injection. - */ -function createNodeStoreProxy(): NodeStoreAPI { - // Listeners for store changes - will be populated by subscribe() - const listeners = new Set< - (event: { change: { type: string }; node: NodeData | null; isRemote: boolean }) => void - >() - - return { - get: async (id: string) => { - return sendStoreRequest('get', { id }) - }, - - list: async (options?: { schemaId?: string; limit?: number; offset?: number }) => { - return sendStoreRequest('list', { - schemaId: options?.schemaId, - limit: options?.limit ?? 50, - offset: options?.offset ?? 0 - }) - }, - - create: async (options: { schemaId: string; properties: Record }) => { - return sendStoreRequest('create', { - schemaId: options.schemaId, - properties: options.properties - }) - }, - - update: async (id: string, options: { properties: Record }) => { - return sendStoreRequest('update', { - id, - properties: options.properties - }) - }, - - delete: async (id: string) => { - await sendStoreRequest('delete', { id }) - }, - - subscribe: ( - listener: (event: { - change: { type: string } - node: NodeData | null - isRemote: boolean - }) => void - ) => { - listeners.add(listener) - return () => listeners.delete(listener) - } - } -} - -/** - * Creates a SchemaRegistryAPI that returns core schemas. - * In the future, this could also proxy to the renderer for dynamic schemas. - */ -function createSchemaRegistryProxy(): SchemaRegistryAPI { - // Core schemas that are always available - const coreSchemas = new Map([ - ['xnet://xnet.dev/Schema', { iri: 'xnet://xnet.dev/Schema', name: 'Schema', properties: {} }], - [ - 'xnet://xnet.dev/Task', - { - iri: 'xnet://xnet.dev/Task', - name: 'Task', - properties: { title: { type: 'text' }, done: { type: 'checkbox' } } - } - ], - [ - 'xnet://xnet.dev/Project', - { iri: 'xnet://xnet.dev/Project', name: 'Project', properties: { name: { type: 'text' } } } - ], - [ - 'xnet://xnet.dev/Note', - { - iri: 'xnet://xnet.dev/Note', - name: 'Note', - properties: { title: { type: 'text' }, content: { type: 'richtext' } } - } - ] - ]) - - return { - getAllIRIs: () => Array.from(coreSchemas.keys()), - get: async (iri: string) => coreSchemas.get(iri) ?? null - } -} - // ─── API Server Lifecycle ──────────────────────────────────────────────────── // SEC-04: API authentication token @@ -195,9 +60,12 @@ export async function startLocalAPI(): Promise { return } - // Create proxies + // Create proxies over the renderer's real store and schema registry. nodeStoreProxy = createNodeStoreProxy() schemaRegistryProxy = createSchemaRegistryProxy() + // Not awaited: this runs before `createWindow()`, and priming needs the + // renderer. It retries in the background until the window answers. + void schemaRegistryProxy.ensurePrimed().catch(() => undefined) // SEC-04: Enable token authentication by default const token = getOrCreateApiToken() @@ -286,17 +154,5 @@ export function setupLocalAPIIPC(): void { // SEC-03: Handle store operation responses from renderer // This replaces the vulnerable executeJavaScript approach - ipcMain.on( - 'xnet:localapi:store-response', - (_, response: { id: number; result?: unknown; error?: string }) => { - const pending = pendingRequests.get(response.id) - if (!pending) return - - if (response.error) { - pending.reject(new Error(response.error)) - } else { - pending.resolve(response.result) - } - } - ) + setupStoreResponseHandler() } diff --git a/apps/electron/src/main/renderer-store-proxy.test.ts b/apps/electron/src/main/renderer-store-proxy.test.ts new file mode 100644 index 000000000..f9c778a52 --- /dev/null +++ b/apps/electron/src/main/renderer-store-proxy.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createSchemaRegistryProxy, + sendStoreRequest, + setupStoreResponseHandler +} from './renderer-store-proxy' + +/** + * A stand-in renderer. `windows` is mutated per test to model the window not + * existing yet — the state both subsystems actually start in, since they run + * before `createWindow()`. + */ +const windows: Array<{ webContents: { send: (channel: string, payload: StoreRequest) => void } }> = + [] + +interface StoreRequest { + id: number + operation: string + params: Record +} + +/** + * The `xnet:localapi:store-response` listeners captured from `ipcMain.on`. + * Registration is idempotent and module-scoped, so this is filled exactly once + * and must not be reset between tests. + */ +const respond: (( + _: unknown, + response: { id: number; result?: unknown; error?: string } +) => void)[] = [] + +vi.mock('electron', () => ({ + app: { getPath: vi.fn(() => '/mock/user-data') }, + ipcMain: { + handle: vi.fn(), + on: vi.fn((channel: string, listener: never) => { + if (channel === 'xnet:localapi:store-response') respond.push(listener) + }) + }, + BrowserWindow: { getAllWindows: () => windows } +})) + +/** Attach a window that answers every request with `reply(operation)`. */ +function attachWindow(reply: (operation: string) => unknown): void { + windows.push({ + webContents: { + send: (_channel, request) => { + // Answer on a later tick, as real IPC would. + queueMicrotask(() => { + for (const listener of respond) { + listener(null, { id: request.id, result: reply(request.operation) }) + } + }) + } + } + }) +} + +setupStoreResponseHandler() + +beforeEach(() => { + windows.length = 0 +}) + +describe('sendStoreRequest', () => { + it('rejects when there is no window rather than resolving to a placeholder', async () => { + await expect(sendStoreRequest('list', {})).rejects.toThrow('No window available') + }) +}) + +describe('createSchemaRegistryProxy', () => { + it('throws from getAllIRIs before priming, so "not ready" never reads as "no schemas"', () => { + const schemas = createSchemaRegistryProxy() + expect(() => schemas.getAllIRIs()).toThrow(/not primed/) + }) + + it('serves the renderer IRI list once primed', async () => { + attachWindow(() => ['xnet://xnet.fyi/Page@1.0.0', 'xnet://xnet.fyi/Task@1.0.0']) + const schemas = createSchemaRegistryProxy() + + await schemas.ensurePrimed() + + expect(schemas.getAllIRIs()).toEqual([ + 'xnet://xnet.fyi/Page@1.0.0', + 'xnet://xnet.fyi/Task@1.0.0' + ]) + }) + + it('keeps retrying until the window exists, since priming starts before createWindow()', async () => { + const schemas = createSchemaRegistryProxy() + const primed = schemas.ensurePrimed() + + // No window yet: the first attempt fails and the loop backs off. + expect(() => schemas.getAllIRIs()).toThrow(/not primed/) + attachWindow(() => ['xnet://xnet.fyi/Page@1.0.0']) + + await primed + expect(schemas.getAllIRIs()).toEqual(['xnet://xnet.fyi/Page@1.0.0']) + }) + + it('single-flights priming so concurrent callers share one round trip', async () => { + let calls = 0 + attachWindow(() => { + calls += 1 + return [] + }) + const schemas = createSchemaRegistryProxy() + + await Promise.all([schemas.ensurePrimed(), schemas.ensurePrimed(), schemas.ensurePrimed()]) + + expect(calls).toBe(1) + }) + + it('proxies get() to the renderer', async () => { + attachWindow((operation) => + operation === 'schemas.get' ? { iri: 'xnet://xnet.fyi/Page@1.0.0', name: 'Page' } : null + ) + const schemas = createSchemaRegistryProxy() + + await expect(schemas.get('xnet://xnet.fyi/Page@1.0.0')).resolves.toEqual({ + iri: 'xnet://xnet.fyi/Page@1.0.0', + name: 'Page' + }) + }) +}) diff --git a/apps/electron/src/main/renderer-store-proxy.ts b/apps/electron/src/main/renderer-store-proxy.ts new file mode 100644 index 000000000..d6c751e93 --- /dev/null +++ b/apps/electron/src/main/renderer-store-proxy.ts @@ -0,0 +1,189 @@ +/** + * The main process's view of the renderer's live NodeStore and SchemaRegistry. + * + * Only the renderer holds the real store (it owns the `XNetProvider` runtime), + * so anything in the main process that wants workspace data — the local API + * (`local-api.ts`) and the agent MCP server (`agent-mcp-server.ts`) — asks for + * it over one structured IPC channel: `xnet:localapi:store-request` out, + * `xnet:localapi:store-response` back. + * + * SEC-03: parameters travel as structured data and are never interpolated into + * code, so a hostile local API caller cannot inject script into the renderer. + */ + +import type { NodeData, NodeStoreAPI, SchemaData, SchemaRegistryAPI } from '@xnetjs/plugins/node' +import { BrowserWindow, ipcMain } from 'electron' + +/** How long a single renderer round trip may take before we give up on it. */ +const REQUEST_TIMEOUT_MS = 30_000 + +let requestId = 0 +const pendingRequests = new Map< + number, + { resolve: (value: unknown) => void; reject: (error: Error) => void } +>() + +/** + * Send one operation to the renderer and await its reply. + * + * Rejects rather than resolving to a placeholder: a timed-out or window-less + * request means "unreadable", which callers must be able to tell apart from + * "absent". + */ +export async function sendStoreRequest( + operation: string, + params: Record +): Promise { + const win = BrowserWindow.getAllWindows()[0] + if (!win) throw new Error('No window available') + + const id = ++requestId + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + pendingRequests.delete(id) + reject(new Error(`Store request timed out: ${operation}`)) + }, REQUEST_TIMEOUT_MS) + + pendingRequests.set(id, { + resolve: (value) => { + clearTimeout(timeout) + pendingRequests.delete(id) + resolve(value as T) + }, + reject: (error) => { + clearTimeout(timeout) + pendingRequests.delete(id) + reject(error) + } + }) + + win.webContents.send('xnet:localapi:store-request', { id, operation, params }) + }) +} + +let responseHandlerInstalled = false + +/** + * Register the single IPC listener that resolves in-flight store requests. + * + * Idempotent: both the local API and the agent MCP server depend on the + * channel, and either may be the first to need it. + */ +export function setupStoreResponseHandler(): void { + if (responseHandlerInstalled) return + responseHandlerInstalled = true + ipcMain.on( + 'xnet:localapi:store-response', + (_, response: { id: number; result?: unknown; error?: string }) => { + const pending = pendingRequests.get(response.id) + if (!pending) return + if (response.error) pending.reject(new Error(response.error)) + else pending.resolve(response.result) + } + ) +} + +/** A NodeStoreAPI backed by the renderer's real store. */ +export function createNodeStoreProxy(): NodeStoreAPI { + const listeners = new Set< + (event: { change: { type: string }; node: NodeData | null; isRemote: boolean }) => void + >() + + return { + get: (id: string) => sendStoreRequest('get', { id }), + + list: (options?: { schemaId?: string; limit?: number; offset?: number }) => + sendStoreRequest('list', { + schemaId: options?.schemaId, + limit: options?.limit ?? 50, + offset: options?.offset ?? 0 + }), + + create: (options: { schemaId: string; properties: Record }) => + sendStoreRequest('create', { + schemaId: options.schemaId, + properties: options.properties + }), + + update: (id: string, options: { properties: Record }) => + sendStoreRequest('update', { id, properties: options.properties }), + + delete: async (id: string) => { + await sendStoreRequest('delete', { id }) + }, + + subscribe: ( + listener: (event: { + change: { type: string } + node: NodeData | null + isRemote: boolean + }) => void + ) => { + listeners.add(listener) + return () => listeners.delete(listener) + } + } +} + +/** + * A SchemaRegistryAPI backed by the renderer's real `schemaRegistry`. + * + * `getAllIRIs()` is synchronous in the API but the registry lives an IPC hop + * away, so the IRI list is cached. {@link SchemaRegistryProxy.prime} fills the + * cache before the proxy is served to anyone; until then `getAllIRIs()` + * **throws** rather than returning `[]`, because an empty array here would be + * indistinguishable from a workspace that genuinely has no schemas. + */ +export interface SchemaRegistryProxy extends SchemaRegistryAPI { + /** + * Start filling the IRI cache, retrying until the renderer answers. Safe to + * call before the window exists — callers need not await it, and both + * subsystems start well before `createWindow()`. + */ + ensurePrimed(): Promise +} + +/** How long to keep waiting for the renderer before giving up on priming. */ +const PRIME_TIMEOUT_MS = 60_000 +const PRIME_RETRY_MS = 500 + +export function createSchemaRegistryProxy(): SchemaRegistryProxy { + let cachedIris: string[] | null = null + let priming: Promise | undefined + + const primeWithRetry = async (): Promise => { + const deadline = Date.now() + PRIME_TIMEOUT_MS + for (;;) { + try { + cachedIris = await sendStoreRequest('schemas.list', {}) + return + } catch (err) { + // Expected until the window exists and its store handler has mounted. + if (Date.now() >= deadline) throw err + await new Promise((resolve) => setTimeout(resolve, PRIME_RETRY_MS)) + } + } + } + + return { + ensurePrimed() { + priming ??= primeWithRetry() + return priming + }, + + getAllIRIs() { + // Never []: an empty list would be indistinguishable from a workspace + // that genuinely has no schemas, so an unprimed cache fails loudly. + if (cachedIris === null) { + throw new Error( + 'Schema registry not primed: the renderer has not reported its schemas yet.' + ) + } + return cachedIris + }, + + async get(iri: string) { + return sendStoreRequest('schemas.get', { iri }) + } + } +} diff --git a/apps/electron/src/renderer/main.tsx b/apps/electron/src/renderer/main.tsx index 6d3d36bf9..806f26f2e 100644 --- a/apps/electron/src/renderer/main.tsx +++ b/apps/electron/src/renderer/main.tsx @@ -7,7 +7,7 @@ import { type CanvasHandle, type FrameStats } from '@xnetjs/canvas' -import { BlobService, CanvasSchema, PageSchema } from '@xnetjs/data' +import { BlobService, CanvasSchema, PageSchema, schemaRegistry, type SchemaIRI } from '@xnetjs/data' import { XNetDevToolsProvider, useDevTools } from '@xnetjs/devtools' import { BlobProvider } from '@xnetjs/editor/react' import { identityFromPrivateKey } from '@xnetjs/identity' @@ -811,6 +811,37 @@ function LocalAPIStoreHandler() { return undefined } + // The main process has no registry of its own, so the local API and the + // agent MCP server read this one — the real thing, including schemas + // registered at runtime rather than a hardcoded core list. + case 'schemas.list': { + return schemaRegistry.getAllIRIs() + } + + case 'schemas.get': { + // Async `get` (not `getSync`) so built-ins that haven't been touched + // yet are lazily loaded rather than reported as missing. + const defined = await schemaRegistry.get(params.iri as SchemaIRI) + if (!defined) return null + const { schema } = defined + return { + iri: schema['@id'], + name: schema.name, + // Keyed by property name: friendlier for a model to read than the + // positional JSON-LD array, and matches what SchemaData declares. + properties: Object.fromEntries( + schema.properties.map((property) => [ + property.name, + { + type: property.type, + required: property.required, + ...(property.config ? { config: property.config } : {}) + } + ]) + ) + } + } + default: throw new Error(`Unknown Local API store operation: ${operation}`) } diff --git a/packages/devkit/src/agent-launch.test.ts b/packages/devkit/src/agent-launch.test.ts index 3cbd2fc15..a72bca734 100644 --- a/packages/devkit/src/agent-launch.test.ts +++ b/packages/devkit/src/agent-launch.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest' -import { buildAgentArgs, DEFAULT_XNET_ALLOWED_TOOLS, mcpConfigFor } from './agent-launch' +import { + buildAgentArgs, + DEFAULT_XNET_ALLOWED_TOOLS, + mcpConfigFor, + mcpHttpConfigFor +} from './agent-launch' describe('buildAgentArgs', () => { it('drives Claude Code headless with plain-text output by default', () => { @@ -51,3 +56,32 @@ describe('mcpConfigFor', () => { expect(config.mcpServers.workspace.args).not.toBe(args) // defensive copy }) }) + +describe('mcpHttpConfigFor', () => { + it('marks the server as an http transport the agent connects to', () => { + expect(mcpHttpConfigFor({ url: 'http://127.0.0.1:5123/mcp' })).toEqual({ + mcpServers: { xnet: { type: 'http', url: 'http://127.0.0.1:5123/mcp' } } + }) + }) + + it('carries the pairing header the transport gates on', () => { + const config = mcpHttpConfigFor({ + url: 'http://127.0.0.1:5123/mcp', + headers: { 'x-xnet-pairing': 'secret' } + }) + expect(config.mcpServers.xnet.headers).toEqual({ 'x-xnet-pairing': 'secret' }) + }) + + it('supports a custom server name and copies the headers', () => { + const headers = { 'x-xnet-pairing': 'secret' } + const config = mcpHttpConfigFor({ url: 'http://127.0.0.1:5123/mcp', headers }, 'workspace') + expect(Object.keys(config.mcpServers)).toEqual(['workspace']) + expect(config.mcpServers.workspace.headers).not.toBe(headers) // defensive copy + }) + + it('omits headers entirely when none are given', () => { + expect( + mcpHttpConfigFor({ url: 'http://127.0.0.1:5123/mcp' }).mcpServers.xnet + ).not.toHaveProperty('headers') + }) +}) diff --git a/packages/devkit/src/agent-launch.ts b/packages/devkit/src/agent-launch.ts index fe254c916..5446c32f3 100644 --- a/packages/devkit/src/agent-launch.ts +++ b/packages/devkit/src/agent-launch.ts @@ -22,6 +22,34 @@ export function mcpConfigFor( return { mcpServers: { [name]: { command: server.command, args: [...server.args] } } } } +/** + * An already-running MCP server the agent connects to over Streamable HTTP, + * rather than one it spawns. This is how a *host application* hands the agent + * its tools: the xNet desktop app serves the workspace from its own process, so + * there is no CLI to spawn and no second copy of the store. + */ +export interface McpHttpServerSpec { + url: string + /** Sent on every request — carries the transport's `x-xnet-pairing` secret. */ + headers?: Record +} + +/** The `mcpServers` config object pointing an agent at a running HTTP server. */ +export function mcpHttpConfigFor( + server: McpHttpServerSpec, + name = 'xnet' +): { mcpServers: Record } { + return { + mcpServers: { + [name]: { + type: 'http', + url: server.url, + ...(server.headers ? { headers: { ...server.headers } } : {}) + } + } + } +} + export interface AgentLaunchOptions { /** Path to an MCP config JSON file — gives the agent xNet's workspace tools. */ mcpConfigPath?: string diff --git a/packages/devkit/src/index.ts b/packages/devkit/src/index.ts index 97b9b32ff..087f5968e 100644 --- a/packages/devkit/src/index.ts +++ b/packages/devkit/src/index.ts @@ -139,8 +139,10 @@ export { buildAgentArgs, buildStreamingAgentArgs, mcpConfigFor, + mcpHttpConfigFor, DEFAULT_XNET_ALLOWED_TOOLS, XNET_READONLY_ALLOWED_TOOLS, type AgentLaunchOptions, - type McpServerSpec + type McpServerSpec, + type McpHttpServerSpec } from './agent-launch' diff --git a/site/src/data/changelog/2026-07-27-claude-code-can-now-read-and-write-your-.json b/site/src/data/changelog/2026-07-27-claude-code-can-now-read-and-write-your-.json new file mode 100644 index 000000000..c0ab63152 --- /dev/null +++ b/site/src/data/changelog/2026-07-27-claude-code-can-now-read-and-write-your-.json @@ -0,0 +1,10 @@ +{ + "id": "2026-07-27-claude-code-can-now-read-and-write-your-", + "date": "July 27, 2026", + "title": "Claude Code can now read and write your xNet workspace", + "summary": "The desktop app's AI chat now hands your coding agent xNet's own tools, so it can search your workspace and create pages and tasks in it — not just talk about them.", + "highlights": [], + "tags": [ + "ai" + ] +}