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/620-web-launch-followup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Resolve the dev `/web/<server>/<app>` launch from the projections the artifact manifest declares instead of hardcoding portable (#620 review follow-up): validate an explicit `?target=` (invalid is an error, never a fallback), open unprompted when declared projections share one normalized launch descriptor, answer 409 (`AB8023`) naming the choices when they differ materially, and require no portable projection or `mcp.json` for a Claude- or Codex-only build. Cache web sessions by epoch, server, and resolved launch identity, retiring them only when a rebuild publishes a new epoch — a session pages still lease stays valid and closes at its last release, and a failed rebuild retires nothing. Run a non-read-only opening tool once per session, tool, App, and input (concurrent first loads share one call; `readOnlyHint: true` runs on every load) and rebind refreshes to the retained result. Move `<plugin> web` per-server state out of the installed artifact to `~/.agent-bundle/web-data/<plugin>-<digest>/<server>` so a read-only install launches. (#628)
2 changes: 1 addition & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -1911,7 +1911,7 @@ foreground server accepts.
| `AB8020` | 400 / 404 | `MCP App route path is not valid.` — an App route whose binding id or operation segment is missing or does not decode, or an unknown operation under `/api/mcp/apps/<binding>/`. `agent-bundle serve-app` answers unknown paths with `Not found.` (404) under the same code. | Use the App routes the Workbench MCP page issues. |
| `AB8021` | 400 | `MCP App request has an invalid shape.` — the request body does not match the operation's expected fields. | Send the fields the operation defines. |
| `AB8022` | 404 / 410 / 503 | `MCP App routes are not available.` — 404 without the preview service, 503 after shutdown; `MCP App preview is not available.` (404) — the binding id is unknown; `Runtime MCP App preview was revoked.` (410) — the runtime binding has been revoked. `agent-bundle serve-app` reports `MCP App host is not ready.` (503) before its host finishes starting. | Re-open the App preview; after 410 the page must create a new binding. |
| `AB8023` | 413 / 502 | `MCP App operation could not be completed.` (502) — an unmapped service failure; `Runtime MCP App operation exceeded its 30 second deadline.` (502); `Runtime MCP App operation response could not be encoded.` (502) or `… exceeds its transport bound.` (413) — the result of a runtime App operation could not cross the bounded host-to-App channel. | Read the dev-server log; shrink or split the App operation result if the bound was hit. |
| `AB8023` | 404 / 409 / 413 / 502 | `MCP App operation could not be completed.` (502) — an unmapped service failure; `Runtime MCP App operation exceeded its 30 second deadline.` (502); `Runtime MCP App operation response could not be encoded.` (502) or `… exceeds its transport bound.` (413) — the result of a runtime App operation could not cross the bounded host-to-App channel. On `/web/<server>/<app>`: `MCP App could not be opened.` (502) — the launch, opening call, or page render failed; `Target "…" is not a declared projection that launches MCP server …` (404) — an invalid `?target=`, never a fallback; `No declared projection of this artifact launches MCP server …` (404); `The declared projections launch MCP server … differently; pick one explicitly with ?target=<…>.` (409). | Read the dev-server log; shrink or split the App operation result if the bound was hit; on `/web`, pass a `?target=` the message names. |

### Hook playground (`/api/hooks/**`)

Expand Down
22 changes: 22 additions & 0 deletions docs/entry-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -1572,6 +1572,28 @@ gains a `web` section and the bin exists even without authored CLI commands.
for Apps not listed in `web.apps`). There is no `web/` directory in the
artifact.

`--profile` (and the dev page's preview profile) is browser presentation
only; it never selects a host artifact. The dev `/web` route resolves the
server's launch from the projections the artifact manifest declares: an
explicit `?target=<projection>` is validated against the declared
projections that launch the server (invalid is an error, never a fallback);
without one, every candidate's normalized launch descriptor (command,
arguments, cwd, declared env, runtime binding) is compared, materially
identical launches proceed unprompted whatever the host order, and
materially different ones answer 409 naming the choices. No portable
projection or `mcp.json` is required — a Claude- or Codex-only build opens
`/web/<server>/<app>` from its own projection. Web sessions are cached by
epoch, server, and resolved launch identity; a successful rebuild retires
unused sessions of older epochs (pages still leasing one keep it until
their last lease releases), and a failed rebuild retires nothing. Opening an App page is not an unbounded
mutation: an opening tool annotated `readOnlyHint: true` runs on every page
load, while any other opening tool runs once per session, tool, App, and
input, and a refresh rebinds that retained result. `<plugin> web` keeps the
installed artifact immutable: framework-owned per-server web state
(`${PLUGIN_DATA}` in declared env) lives under the user's home
(`~/.agent-bundle/web-data/<plugin>-<digest>/<server>`), never inside the
plugin root, so a read-only install still launches.

## `agent-bundle/app` — the App-side bridge client

`agent-bundle/app` (`src/app/index.ts`, #594) is the half of the MCP Apps
Expand Down
20 changes: 19 additions & 1 deletion packages/agent-bundle/src/dev/foreground-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { RouteInvocationRoutes, type RouteInvocationRouteService } from './route
import { RouteManifestRoutes, type RouteManifestRouteService } from './routes/route-manifest-routes.ts';
import { SkillDocumentError, type SkillDocumentService } from './skill-document-service.ts';
import type { Invalidation, ProjectEventMessage, ProjectStatus } from './types.ts';
import { WebHostRoutes, type WebHostEpochSource } from './web-host-routes.ts';
import { WebHostRoutes, type WebHostEpochSource, type WebHostLaunchOptions } from './web-host-routes.ts';
import { isWorkbenchShellPath } from './workbench-shell-paths.ts';
import {
diagnostic,
Expand Down Expand Up @@ -202,6 +202,8 @@ export interface ForegroundServerOptions {
readonly sessionToken?: string;
/** Test-only foreground stream observation; production callers never supply this. */
readonly testing?: ForegroundServerTesting;
/** How the development web host selects the launch of a web-exposed server across declared projections. */
readonly webHostLaunch?: WebHostLaunchOptions;
/**
* Contributor HMR only: browser origins of a separately started Workbench
* Rsbuild dev server that proxies /api here. Loopback http(s) origins only;
Expand Down Expand Up @@ -426,6 +428,7 @@ export class ForegroundServer {
readonly #sockets = new Set<Socket>();
readonly #streamSubscriptions = new Set<ProjectEventSubscription>();
readonly #testing: ForegroundServerTesting | undefined;
readonly #webHostEpochSubscription: ProjectEventSubscription | undefined;
readonly #webHostRoutes: WebHostRoutes;
readonly #workbenchDevOrigins: ReadonlySet<string>;
#closePromise: Promise<void> | undefined;
Expand Down Expand Up @@ -474,11 +477,25 @@ export class ForegroundServer {
this.#webHostRoutes = new WebHostRoutes({
authorize: (request) => this.#assertWebHostNavigation(request),
...(options.epochs === undefined ? {} : { epochs: options.epochs }),
...(options.webHostLaunch === undefined ? {} : { launch: options.webHostLaunch }),
...(options.mcpSessions === undefined ? {} : { mcpSessions: options.mcpSessions }),
...(options.mcpAppPreviews === undefined ? {} : { previews: options.mcpAppPreviews }),
sandboxOrigin: options.mcpAppSandboxOrigin ?? (() => undefined),
sessionToken: this.sessionToken,
});
// Web-host session retirement follows successful epoch publications only:
// a failed rebuild publishes no artifact.available and retires nothing.
// Subscribed only when the web host is functional, so a foreground server
// without it keeps the hub's SSE-only subscription accounting.
this.#webHostEpochSubscription =
options.epochs === undefined || options.mcpSessions === undefined || options.webHostLaunch === undefined
? undefined
: options.eventHub.subscribe(
{ afterSequence: options.eventHub.latestSequence },
(event) => {
if (event.type === 'artifact.available') this.#webHostRoutes.adoptActiveEpoch(event.epochId);
},
);
this.#mcpAppRoutes = new McpAppRoutes({
authorize: (request) => this.#assertMutationSession(request),
openingCall: (sessionId, toolName, opening) => this.#webHostRoutes.openingCall(sessionId, toolName, opening),
Expand Down Expand Up @@ -672,6 +689,7 @@ export class ForegroundServer {
}

async #release(): Promise<readonly ForegroundServerCloseFailure[]> {
this.#webHostEpochSubscription?.unsubscribe();
this.#webHostRoutes.close();
this.#mcpAppRoutes.close();
this.#hostMcpRoutes?.close();
Expand Down
32 changes: 32 additions & 0 deletions packages/agent-bundle/src/dev/mcp-session/mcp-session-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,8 @@ interface ActiveSession {
readonly session: McpSession;
appLeaseCount: number;
closed: boolean;
/** Deferred close armed by {@link McpSessionService.closeSessionWhenUnleased}; fires at the release of the last lease. */
retire: (() => void) | undefined;
}

type McpAppLeaseIdentity = McpAppBridgeSession['identity'] & Readonly<{
Expand Down Expand Up @@ -219,6 +221,11 @@ const createMcpAppSessionLease = (entry: ActiveSession): McpAppSessionLease => {
if (released) return;
released = true;
entry.appLeaseCount = Math.max(0, entry.appLeaseCount - 1);
if (entry.appLeaseCount === 0 && !entry.closed) {
const retire = entry.retire;
entry.retire = undefined;
retire?.();
}
},
session: bridgeSession,
watchSessionClosed: (listener: McpAppSessionCloseListener) => {
Expand Down Expand Up @@ -391,6 +398,7 @@ export class McpSessionService {
appLeaseCount: 0,
closeWatchers: new Set(),
closed: false,
retire: undefined,
session,
});
return session;
Expand Down Expand Up @@ -425,6 +433,30 @@ export class McpSessionService {
return createMcpAppSessionLease(entry);
}

/** Live App leases over one session; 0 for an unknown or closed session. */
appLeaseCount(sessionId: string): number {
const entry = this.#sessions.get(sessionId);
return entry === undefined || entry.closed ? 0 : entry.appLeaseCount;
}

/**
* Closes the session as soon as nothing leases it: immediately when the
* lease count is already zero, otherwise at the release of its last lease.
* Returns whether the close began now.
*/
closeSessionWhenUnleased(id: McpSessionId): boolean {
const entry = this.#sessions.get(id);
if (entry === undefined || entry.closed) return false;
if (entry.appLeaseCount === 0) {
void this.closeSession(id).catch(() => undefined);
return true;
}
entry.retire = () => {
void this.closeSession(id).catch(() => undefined);
};
return false;
}

async closeSession(id: McpSessionId): Promise<boolean> {
const entry = this.#invalidateSession(id, new Error('MCP session control closed.'));
if (entry === undefined) return false;
Expand Down
198 changes: 198 additions & 0 deletions packages/agent-bundle/src/dev/web-host-launch-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { readFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';

import type { TargetRegistry } from '../adapters/registry.ts';
import { digest } from '../core/digest.ts';
import { CodedError } from '../core/errors.ts';
import { assertInside, joinArtifact } from '../core/paths.ts';
import { parseJsonWithoutDuplicateKeys } from '../core/strict-json.ts';
import { resolveMcpPathTokens } from '../services/mcp-path-tokens.ts';
import {
readTargetMcpServer,
type TargetMcpRuntimeContract,
} from '../services/mcp-runtime.ts';

/**
* The effective launch of one web-exposed MCP server (#620 follow-up): the
* browser presentation profile never selects a host artifact, so the launch
* comes from the declared projections the composite root actually ships.
* Exactly one normalized launch may be in effect: an explicit `target` is
* validated against the declared projections that launch the server; without
* one, every candidate projection's launch descriptor is normalized and
* compared, and only materially identical launches proceed unprompted.
* Selection is resolved before any process spawns — candidates are never
* launched to discover which works.
*/

export type WebLaunchSelectionErrorCode =
| 'launch-ambiguous'
| 'launch-missing'
| 'target-not-launchable';

export class WebLaunchSelectionError extends CodedError<WebLaunchSelectionErrorCode> {
/** The declared projections that launch the server, for the caller's message. */
readonly candidates: readonly string[];

constructor(code: WebLaunchSelectionErrorCode, message: string, candidates: readonly string[]) {
super('WebLaunchSelectionError', code, message);
this.candidates = Object.freeze([...candidates]);
}
}

export interface SelectedWebLaunch {
/** Content identity of the normalized launch descriptor the selection resolves to. */
readonly launchId: string;
/** Every candidate projection whose normalized launch equals the selection. */
readonly sharedTargets: readonly string[];
/** The deterministic representative projection the session opens with. */
readonly target: string;
}

export interface SelectWebLaunchOptions {
readonly artifactRoot: string;
/** The projections the artifact manifest declares for this composite root. */
readonly declaredTargets: readonly string[];
readonly registry: TargetRegistry;
/** Explicit projection choice; validated, never a fallback. */
readonly requestedTarget?: string;
readonly serverName: string;
readonly workspaceRoot: string;
}

interface LaunchCandidate {
readonly launchId: string;
readonly target: string;
}

/**
* The normalized-launch runtime view of one projection: env values pass
* through the target's stdio-argument rule after token resolution, exactly
* as `resolveMcpStdioLaunch` normalizes them for `mcp run` — a target that
* serializes the plugin-root anchor as a `./` path (Codex) compares equal to
* one that interpolates a token for the same root.
*/
const identityRuntime = (runtime: TargetMcpRuntimeContract): TargetMcpRuntimeContract => ({
manifestPath: runtime.manifestPath,
readModernServers: (document) => runtime.readModernServers(document),
resolveStdioArgument: (value, roots) => runtime.resolveStdioArgument(value, roots),
resolveValue: (field, roots, value) => {
if (field !== 'env') return runtime.resolveValue(field, roots, value);
const resolution = runtime.resolveValue(field, roots, value);
return { ...resolution, value: runtime.resolveStdioArgument(resolution.value, roots) };
},
});

/**
* The content identity of one projection's launch for the named server, or
* undefined when the projection does not launch it. The plugin-data root is
* a shared placeholder — identity compares descriptors, it allocates no
* state — so two projections binding the same durable-state layout digest
* equally whatever data root a later session mounts.
*/
const launchIdentityOf = async (
options: SelectWebLaunchOptions,
target: string,
): Promise<string | undefined> => {
const { registry } = options;
if (!registry.has(target) || !registry.supports(target, 'mcp')) return undefined;
const runtime = registry.mcpRuntime(target);
if (runtime === undefined) return undefined;
const artifactRoot = resolve(options.artifactRoot);
let document: unknown;
try {
document = parseJsonWithoutDuplicateKeys(await readFile(joinArtifact(artifactRoot, runtime.manifestPath), 'utf8'));
} catch {
return undefined;
}
const result = readTargetMcpServer(runtime, document, options.serverName);
if (result.status !== 'found') return undefined;
try {
const resolved = resolveMcpPathTokens({
roots: {
pluginData: join(artifactRoot, '.web-launch-identity'),
pluginRoot: artifactRoot,
workspaceRoot: resolve(options.workspaceRoot),
},
runtime: identityRuntime(runtime),
server: result.server,
target,
});
if (resolved.kind === 'stdio') {
const cwd = resolved.cwd === undefined
? artifactRoot
: assertInside(artifactRoot, resolve(artifactRoot, resolved.cwd));
return digest({
args: resolved.args,
command: resolved.command,
cwd,
env: resolved.env ?? {},
kind: 'stdio',
});
}
return digest({ headers: resolved.headers ?? {}, kind: 'streamable-http', url: resolved.url });
} catch {
// A projection whose descriptor cannot resolve is not a launch candidate.
return undefined;
}
};

const listOf = (targets: readonly string[]): string => targets.join(', ');

/**
* Resolves the one effective launch of a web-exposed server across the
* artifact's declared projections. Candidate order never matters: targets are
* sorted before grouping, so a selection over reversed host declarations is
* identical. Ambiguity is kept whenever normalized descriptors cannot prove
* equivalence; nothing synthesizes a portable launch.
*/
export const selectWebLaunch = async (options: SelectWebLaunchOptions): Promise<SelectedWebLaunch> => {
const targets = [...new Set(options.declaredTargets)].sort((left, right) => left.localeCompare(right));
const candidates: LaunchCandidate[] = [];
for (const target of targets) {
const launchId = await launchIdentityOf(options, target);
if (launchId !== undefined) candidates.push(Object.freeze({ launchId, target }));
}
const candidateNames = Object.freeze(candidates.map((candidate) => candidate.target));
const requested = options.requestedTarget;
if (requested !== undefined) {
const candidate = candidates.find((entry) => entry.target === requested);
if (candidate === undefined) {
throw new WebLaunchSelectionError(
'target-not-launchable',
`Target ${JSON.stringify(requested)} is not a declared projection that launches MCP server ${JSON.stringify(options.serverName)}` +
`${candidateNames.length === 0 ? '.' : `; declared projections that do: ${listOf(candidateNames)}.`}`,
candidateNames,
);
}
return Object.freeze({
launchId: candidate.launchId,
sharedTargets: Object.freeze(candidates
.filter((entry) => entry.launchId === candidate.launchId)
.map((entry) => entry.target)),
target: candidate.target,
});
}
if (candidates.length === 0) {
throw new WebLaunchSelectionError(
'launch-missing',
`No declared projection of this artifact launches MCP server ${JSON.stringify(options.serverName)}; ` +
'the server has no launch binding to open the App with.',
candidateNames,
);
}
const launchIds = new Set(candidates.map((candidate) => candidate.launchId));
if (launchIds.size > 1) {
throw new WebLaunchSelectionError(
'launch-ambiguous',
`The declared projections launch MCP server ${JSON.stringify(options.serverName)} differently; ` +
`pick one explicitly with ?target=<${listOf(candidateNames)}>.`,
candidateNames,
);
}
const representative = candidates[0]!;
return Object.freeze({
launchId: representative.launchId,
sharedTargets: candidateNames,
target: representative.target,
});
};
Loading
Loading