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
5 changes: 5 additions & 0 deletions .changeset/live-host-mcp-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": minor
---

Keep development hosts connected across rebuilds with an epoch-aware Streamable HTTP MCP endpoint and stable stdio proxy. New calls use the active artifact while in-flight calls retain their original epoch, and catalog changes reach hosts without reinstalling the plugin.
38 changes: 33 additions & 5 deletions packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,11 +169,39 @@ promote selected durable outcome/assertion evidence into a draft eval case. The
concise events and raw stdout/stderr/protocol streams by producer: normalization, build, diagnostics,
MCP, hook, host trial, and grader.

MCP sessions bind `{ epochId, target, serverName }` when opened and never move to a new epoch
automatically. Use **Restart MCP session** to respawn that generated server on its selected epoch;
open a new session to use a newly published epoch. Compatible MCP Apps preview through the same bound
session. A host may need an explicit MCP reload when a server's catalog changes —
`notifications/tools/list_changed` is not UI HMR.
Workbench MCP sessions bind `{ epochId, target, serverName }` when opened and never move to a new
epoch automatically. Use **Restart MCP session** to respawn that generated server on its selected
epoch; open a new session to use a newly published epoch. Compatible MCP Apps preview through the
same bound session.

### Live host MCP proxy

During development, a host can keep one stdio MCP process connected while `agent-bundle dev`
rebuilds the generated server behind it. Configure the host's MCP server command as:

```json
{
"command": "agent-bundle",
"args": [
"dev",
"proxy",
"--root",
"/absolute/path/to/plugin",
"--server",
"tools"
]
}
```

The proxy discovers the loopback server through the project's development lock and connects to the
stable Streamable HTTP endpoint at `/mcp/host/<serverName>`. `--target` defaults to `portable`;
`--url http://127.0.0.1:<port>` overrides discovery. The endpoint is intentionally unauthenticated
because the development server binds only to loopback and is not exposed beyond the local machine. Successful
rebuilds keep the stdio connection open, route new calls to the active epoch, allow admitted calls
to finish against their original epoch, and forward MCP catalog change notifications. If the epoch
or development server disappears, the proxy fails closed with an MCP error and an `AB8024` or
`AB8025` diagnostic. A generated server that crashes is not silently respawned within the same
epoch; calls remain failed until a successful rebuild swaps in a newly primed epoch session.

### Optional Agent API

Expand Down
24 changes: 24 additions & 0 deletions packages/agent-bundle/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import type {
DoctorReport,
runDoctor,
} from './install/doctor.ts';
import type { runHostMcpProxy } from './dev/host-mcp-proxy.ts';
import { DiagnosticError, type Diagnostic } from './core/diagnostics.ts';
import { projectVersionLabel } from './core/project-context.ts';
import { stableJson } from './core/digest.ts';
Expand All @@ -56,6 +57,7 @@ export interface CliDependencies {
readonly installBundle?: typeof installBundle;
readonly prepack?: typeof prepack;
readonly runDoctor?: typeof runDoctor;
readonly runHostMcpProxy?: typeof runHostMcpProxy;
/** Injectable only to make foreground shutdown behavior deterministic in tests. */
readonly signals?: CliSignalSource;
readonly startDevServer?: typeof startDevServer;
Expand Down Expand Up @@ -131,6 +133,12 @@ interface DevCommandOptions {
readonly root: string;
}

interface DevProxyCommandOptions {
readonly server: string;
readonly target: string;
readonly url?: string;
}

const collect = (value: string, previous: string[]): string[] => [...previous, value];

const port = (value: string): number => {
Expand Down Expand Up @@ -474,6 +482,22 @@ export const runCli = async (
closeForegroundOnSignal(session, dependencies.signals ?? process, stderr);
});

const devProxyCommand = devCommand.command('proxy')
.description('Bridge host stdio MCP traffic to a running development server')
.requiredOption('--server <server>', 'Generated MCP server name')
.option('--target <target>', 'Generated target containing the MCP server', 'portable')
.option('--url <url>', 'Explicit loopback development server origin');
devProxyCommand.action(async (options: DevProxyCommandOptions) => {
const proxy = dependencies.runHostMcpProxy ?? (await import('./dev/host-mcp-proxy.ts')).runHostMcpProxy;
exitCode = await proxy({
projectRoot: devCommand.opts<DevCommandOptions>().root,
serverName: options.server,
target: options.target,
...(options.url === undefined ? {} : { url: options.url }),
writeDiagnostic: (message) => { stderr.write(`${message}\n`); },
});
});

const buildCommand = configureSourceOptions(
program.command('build').description('Build a validated Agent Bundle artifact'),
).option('--output <path>', 'Artifact output path relative to --root (overrides config output.distPath; default dist)');
Expand Down
36 changes: 35 additions & 1 deletion packages/agent-bundle/src/dev/dev-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ export interface DevLockOptions {
readonly storage?: DevLockStorage;
}

export interface DiscoverDevServerOptions {
readonly probeProcess?: (pid: number) => boolean;
readonly projectRoot: string;
readonly storage?: Pick<DevLockStorage, 'readFile'>;
}

export interface DevLockStorage {
readonly link: typeof link;
readonly lstat: typeof lstat;
Expand Down Expand Up @@ -137,7 +143,7 @@ const parseServerUrl = (contents: string, owner: DevLockOwner): string | undefin
};

const readServerUrl = async (
storage: DevLockStorage,
storage: Pick<DevLockStorage, 'readFile'>,
path: string,
owner: DevLockOwner,
): Promise<string | undefined> => {
Expand Down Expand Up @@ -489,3 +495,31 @@ export const acquireDevLock = async (options: DevLockOptions): Promise<DevLock>
},
});
};

/** Resolves the loopback foreground origin published by the live owner of one project lock. */
export const discoverDevServerUrl = async (options: DiscoverDevServerOptions): Promise<string> => {
const projectRoot = resolve(options.projectRoot);
const path = join(projectRoot, '.agent-bundle', devLockName);
const storage = options.storage ?? defaultStorage;
let contents: string;
try {
contents = await storage.readFile(path, 'utf8');
} catch (error) {
if (isErrno(error, 'ENOENT')) {
throw new DevLockError('DEV_LOCK_INVALID', 'No agent-bundle dev process is running for this project.');
}
throw error;
}
const owner = parseOwner(contents, projectRoot);
if (owner === undefined) {
throw new DevLockError('DEV_LOCK_INVALID', 'The development lock does not contain valid owner metadata.');
}
if (!(options.probeProcess ?? isProcessAlive)(owner.pid)) {
throw new DevLockError('DEV_LOCK_INVALID', 'The agent-bundle dev process recorded for this project is no longer running.');
}
const url = await readServerUrl(storage, path, owner);
if (url === undefined) {
throw new DevLockError('DEV_LOCK_INVALID', 'The running agent-bundle dev process has not published its server URL.');
}
return url;
};
7 changes: 7 additions & 0 deletions packages/agent-bundle/src/dev/foreground-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { ProjectEventHub, ProjectEventSubscription } from './events.ts';
import { InspectorRoutes, type InspectorRouteService } from './inspector-routes.ts';
import { HookPlaygroundRoutes, type HookPlaygroundRouteService } from './playground/hook-playground-routes.ts';
import { HostDiscoveryRoutes, type HostDiscoveryRouteService } from './playground/host-discovery-routes.ts';
import type { HostMcpRoutes } from './host-mcp-routes.ts';
import { LifecycleReplayRoutes, type LifecycleReplayRouteService } from './playground/lifecycle-replay-routes.ts';
import { McpProbeRoutes, type McpProbeRouteService } from './playground/mcp-probe-routes.ts';
import { McpAppRoutes, type McpAppRoutePreviewService } from './mcp-apps/mcp-app-routes.ts';
Expand Down Expand Up @@ -136,6 +137,8 @@ export interface ForegroundServerOptions {
readonly hookPlayground?: HookPlaygroundRouteService;
/** Read-only host probes, install inventory, bundle drift, and runtime endpoint health. */
readonly hostDiscovery?: HostDiscoveryRouteService;
/** Stateful MCP surface used only by stable development host proxies. */
readonly hostMcp?: HostMcpRoutes;
/** User-initiated read-only initialize and tools/list probing over trusted artifact servers. */
readonly mcpProbe?: McpProbeRouteService;
/** Read-only semantic lifecycle replay over the latest valid prepared graph. */
Expand Down Expand Up @@ -442,6 +445,7 @@ export class ForegroundServer {
readonly #eventHub: ProjectEventHub;
readonly #hookPlaygroundRoutes: HookPlaygroundRoutes;
readonly #hostDiscoveryRoutes: HostDiscoveryRoutes;
readonly #hostMcpRoutes: HostMcpRoutes | undefined;
readonly #host: string;
readonly #inspectorRoutes: InspectorRoutes;
readonly #lifecycleReplayRoutes: LifecycleReplayRoutes;
Expand Down Expand Up @@ -487,6 +491,7 @@ export class ForegroundServer {
this.#evalLifecycle = options.evalLifecycle;
this.#eventHub = options.eventHub;
this.#host = host;
this.#hostMcpRoutes = options.hostMcp;
this.instanceId = instanceId;
this.#mcpAppPreviews = options.mcpAppPreviews;
this.#now = options.now ?? (() => new Date());
Expand Down Expand Up @@ -682,6 +687,7 @@ export class ForegroundServer {

async #release(): Promise<readonly ForegroundServerCloseFailure[]> {
this.#mcpAppRoutes.close();
this.#hostMcpRoutes?.close();
this.#mcpSessionRoutes.close();
this.#runtimeMcpRoutes.close();
this.#runtimeRoutes.close();
Expand Down Expand Up @@ -773,6 +779,7 @@ export class ForegroundServer {
}
const pathname = new URL(request.url ?? '/', this.url).pathname;
const method = request.method ?? 'GET';
if (await this.#hostMcpRoutes?.handle(request, response)) return;
if (pathname === '/mcp') {
if (this.#agentApi === undefined) return responseDiagnostic(response, diagnostic('AB8007', 'Route was not found.', 404));
this.#assertAgentApiOrigin(request);
Expand Down
133 changes: 133 additions & 0 deletions packages/agent-bundle/src/dev/host-mcp-proxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
StreamableHTTPClientTransport,
type JSONRPCMessage,
type Transport,
} from '@modelcontextprotocol/client';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';
import { resolve } from 'node:path';

import { isRecord } from '../core/strict-json.ts';
import { discoverDevServerUrl } from './dev-lock.ts';

export const hostMcpUnavailableCode = 'AB8025';

export interface RunHostMcpProxyOptions {
readonly projectRoot: string;
readonly serverName: string;
readonly target?: string;
readonly url?: string;
readonly writeDiagnostic?: (message: string) => void;
}

const unavailableMessage = 'Development MCP server is unavailable.';

const loopbackOrigin = (value: string): string => {
const url = new URL(value);
if (
url.protocol !== 'http:' ||
(url.hostname !== '127.0.0.1' && url.hostname !== '[::1]') ||
url.origin !== value
) {
throw new TypeError('Development MCP proxy URL must be a loopback HTTP origin.');
}
return url.origin;
};

const requestId = (message: JSONRPCMessage): string | number | undefined => {
const value: unknown = message;
if (!isRecord(value) || !Object.hasOwn(value, 'method') || !Object.hasOwn(value, 'id')) return undefined;
const id = value.id;
return typeof id === 'string' || typeof id === 'number' ? id : undefined;
};

const errorResponse = (
id: string | number,
cause: unknown,
): JSONRPCMessage => ({
error: {
code: -32_603,
data: {
code: hostMcpUnavailableCode,
detail: cause instanceof Error ? cause.message : String(cause),
},
message: unavailableMessage,
},
id,
jsonrpc: '2.0',
});

const hostEndpoint = (origin: string, serverName: string, target: string): URL => {
const endpoint = new URL(`/mcp/host/${encodeURIComponent(serverName)}`, origin);
endpoint.searchParams.set('target', target);
return endpoint;
};

/**
* Stable stdio transport bridge used by host MCP configuration. The stdio
* process owns no plugin artifact and remains connected while the foreground
* server swaps epochs behind its stateful HTTP session.
*/
export const runHostMcpProxy = async (options: RunHostMcpProxyOptions): Promise<number> => {
if (options.serverName.trim().length === 0) throw new TypeError('Development MCP proxy server name must be nonempty.');
const target = options.target ?? 'portable';
if (target.trim().length === 0) throw new TypeError('Development MCP proxy target must be nonempty.');
const projectRoot = resolve(options.projectRoot);
const writeDiagnostic = options.writeDiagnostic ?? ((message: string) => {
process.stderr.write(`${message}\n`);
});
const stdio = new StdioServerTransport();
let remote: Transport | undefined;
let failed = false;
let reportedUnavailable = false;
let shuttingDown = false;
const reportUnavailable = (cause: unknown): void => {
failed = true;
if (reportedUnavailable) return;
reportedUnavailable = true;
const detail = cause instanceof Error ? ` ${cause.message}` : '';
writeDiagnostic(`[${hostMcpUnavailableCode}] ${unavailableMessage}${detail}`);
};
const rejectRequest = async (message: JSONRPCMessage, cause: unknown): Promise<void> => {
reportUnavailable(cause);
const id = requestId(message);
if (id !== undefined) await stdio.send(errorResponse(id, cause));
await stdio.close();
};

try {
const origin = loopbackOrigin(options.url ?? await discoverDevServerUrl({ projectRoot }));
const transport = new StreamableHTTPClientTransport(hostEndpoint(origin, options.serverName, target));
remote = transport;
transport.onmessage = (message) => {
void stdio.send(message).catch(reportUnavailable);
};
transport.onerror = reportUnavailable;
transport.onclose = () => {
if (shuttingDown) return;
reportUnavailable(new Error('The foreground HTTP transport closed.'));
void stdio.close();
};
await transport.start();
} catch (error) {
reportUnavailable(error);
}

const closed = Promise.withResolvers<void>();
stdio.onclose = () => {
shuttingDown = true;
void remote?.close().finally(closed.resolve);
if (remote === undefined) closed.resolve();
};
stdio.onerror = reportUnavailable;
stdio.onmessage = (message) => {
const transport = remote;
if (transport === undefined) {
void rejectRequest(message, new Error('No running development server was discovered.'));
return;
}
void transport.send(message).catch((error: unknown) => rejectRequest(message, error));
};
await stdio.start();
await closed.promise;
return failed ? 1 : 0;
};
Loading
Loading