Skip to content

Let a plugin CLI route serve its own MCP App without bundling the framework #558

Description

@ScriptedAlchemy

Problem

#537 (fixes #514) documents serveApp in agent-bundle/api as the programmatic form of agent-bundle serve-app "so a plugin's own routed CLI can offer the command", with a src/cli/dashboard.ts sample whose route body does const { serveApp } = await import('agent-bundle/api'). That shape does not build inside a plugin:

  • Routed CLI bins are self-contained ESM by contract (Routed CLI (src/cli/**) is only emitted into the npm package build, not into host artifacts — artifact scripts/skills cannot invoke it #387): bin/<plugin>.mjs in every host pack and dist/bin/<plugin>.js in the package build. The bundler therefore inlines agent-bundle/dist/api.js — the whole compiler — into the bin and fails on the framework's runtime-relative module references (eventRuntimeModulePath in src/build/entries.ts probes new URL('../events/<module>.ts', import.meta.url)): Module not found: Can't resolve '../events' from dist/8674.js.
  • Both escapes are rejected by artifact validation in src/build/validate-artifact-modules.ts: leaving agent-bundle/api external through the tools hatch is AB6005 Generated JavaScript import from "…" uses unsupported specifier "agent-bundle/api". (a bare specifier is neither a Node built-in nor a relative/file: path that resolves inside the artifact), and hiding the import behind a non-literal import(spec) is AB6005 Generated JavaScript import from "…" has a non-literal dynamic import.

So serveApp is reachable only from processes the framework does not compile — the first-party CLI, tests, the Workbench, a plugin's own package.json scripts or hand-written .mjs run from the checkout — and never from a route inside the artifact. That is a real consequence of the artifact contract, not a defect in either check; but the docs promised the route shape, and the first consumer to try it found out from a bundler error inside a hashed chunk.

Evidence

ScriptedAlchemy/cargo-hauler#83 (merged as 395cee062; agent-bundle pinned at c2ffe5e — nothing under validate-artifact-modules.ts, cli-bins.ts, rslib.ts, serve-app/**, or api.ts has changed on main since). From the PR body:

A route with await import('agent-bundle/api') does not build on this pin: the bundler inlines agent-bundle/dist/api.js into the generated CLI bin and fails on the framework's runtime-relative imports (Module not found: Can't resolve '../events' from dist/8674.js), and the alternatives are rejected by artifact validation — an externalized bare agent-bundle/api import is AB6005 uses unsupported specifier, a non-literal dynamic import is AB6005 has a non-literal dynamic import. […] Worth an upstream note: the docs' hauler dashboard route example is not buildable as a routed command whose bins are validated for self-containment.

What shipped instead — src/cli/dashboard.ts, a plain routed command (zod inputSchema, --help, --json from the framework) that:

  • locates node_modules/agent-bundle by walking up from the plugin root and reads bin out of its package.json (require.resolve is unusable: the package exports declare no require condition and do not expose ./package.json);
  • spawns node <agent-bundle bin> serve-app hauler/dashboard --root … --artifact … --target … --tool hauler_status --allow call-tool --open|--no-open [--port N];
  • relays the child's stdout to stderr so the routed CLI keeps stdout for its JSON result, and parses the MCP App <app> at <url> (…) line for the url field;
  • turns the route signal into the child's SIGTERM and returns the child's exit code.

About 180 lines, of which roughly 120 are plumbing the framework already owns. It is documented as a checkout-only command: it needs agent-bundle under node_modules and the built artifact/ beside the CLI. The npm package deliberately ships no runtime dependencies (cargo-hauler#82) and an installed host pack has no artifact, so both cases return exitCode: 1 with a message naming the missing piece and pointing at hauler_status. The Codex review thread asking to "package the CLI required by hauler dashboard" (bundle the framework) was dismissed for exactly the reasons above.

Constraints

  • serveApp (src/api.tssrc/serve-app/serve-mcp-app.ts) is the Workbench's MCP App host stack without the Workbench, on purpose (feat(cli): serve a built MCP App standalone — agent-bundle serve-app and serveApp in agent-bundle/api (#514) #537: "nothing new is built and no second bridge exists"): Effect-based (makeScopedEffectRuntime, Effect.acquireRelease), McpAppBindingServiceMcpAppPreviewServiceMcpAppRoutes, createMcpAppSandboxProxy, McpAppBridge, the consent authority, and @modelcontextprotocol/client for the stdio session. It cannot become dependency-light without a second implementation of security-relevant code (AB8003/AB8004 token and origin checks, consent, the host page CSP).
  • Every emitted JavaScript module must satisfy AB6005 (validate-artifact-modules.ts): literal specifiers only, each a Node built-in or a relative/file: path resolving to a manifest-listed regular file inside the artifact root. Reserved specifiers (agent-bundle/mcp-entry, agent-bundle/meta, agent-bundle/mcp-apps) additionally cannot be externalized through the tools hatch (rslib.ts reservedSpecifiers plus the post-build residual-import scan).
  • The routed CLI bin is emitted into every host pack, and installed packs have no node_modules. Whatever the bin imports has to be inside the artifact.

Proposed shape

Three options, with their real costs:

(a) An allowlisted external import of agent-bundle/api in routed CLI bins. The compiler keeps agent-bundle/api external in bin/<plugin>.mjs (and dist/bin/<plugin>.js), validate-artifact-modules.ts accepts the allowlisted specifier instead of emitting AB6005, and doctor verifies that agent-bundle is resolvable from wherever the plugin runs. Cost: a per-specifier hole in the self-containment contract of every host pack, for a dev-time capability. An installed pack still cannot resolve it, so every host artifact ships a bin with one command that fails at import time unless the route lazy-imports and catches ERR_MODULE_NOT_FOUND itself — and doctor inspects installed packs, which is precisely where it would always report "unresolvable". The allowlist would also have to be walled off from the tools hatch (today the only way a consumer externalizes anything) or grow into a general externals feature, which is a much larger contract change.

(b) A standalone, bundleable agent-bundle/serve-app runtime entry (no Effect, no Workbench). The only option that could ever work from an installed host pack: the packed server is a sibling mcp/*.mjs, so the bin could spawn it. Cost: a second host — bridge, sandbox proxy, consent, token and origin checks, host page — outside Effect, which is exactly what #537 avoided; every host pack's bin grows by the MCP client SDK plus an HTTP host; two security surfaces to keep in lockstep. No use case asks for it today: the one consumer uses the App in-host (hauler_status) from installed packs and wants the browser tab from the checkout.

(c) Sanction the child-process spawn and ship the plumbing: spawnServeApp() on a dependency-free leaf entry. serveApp stays the host-process API. A helper the bundler can inline — the agent-bundle/launch-env precedent: Node built-ins only, no Effect, no runtime-relative references — that:

  1. resolves the framework CLI from the plugin root the way the framework lays out its own bin/agent-bundle.js, so that knowledge lives in one place instead of in every consumer's node_modules walk;
  2. takes the same options as serveApp (app, artifact, target, tool, input, port, profile, autoApprove, open, envFiles, loadEnvFiles, pluginRoot, root) and lowers them to serve-app argv, unit-tested against the serve-app parser in tests/cli.test.ts so the two cannot drift;
  3. spawns process.execPath, relays the child's stdout to stderr (the routed CLI owns stdout for its result), resolves url from the MCP App <app> at <url> line, wires the route signal to the child's SIGTERM, and returns { url, close, closed } mirroring ServedApp where the shapes agree;
  4. fails with a typed error when agent-bundle is not resolvable from root or the artifact is missing, so consumers stop hand-writing the "this is a checkout command" message.

Cost: the command remains a checkout capability, which the docs must say plainly (an installed host pack cannot offer it); the stdout URL line becomes a documented contract, or serve-app gains a machine-readable ready signal.

Recommendation: (c). It keeps the artifact contract intact (no hole in AB6005, no change to the tools hatch), builds on #537 without a second bridge, is the one shape a consumer has proven works, and deletes the ~120 lines every consumer would otherwise copy. (a) has the same checkout-only limitation as (c) while weakening the contract. (b) is the long-term answer only if "dashboard from an installed pack" becomes a real request, and it can be added later without invalidating (c) — the helper's option surface is serveApp's.

Independently of the option: the failure should be a named diagnostic. A routed CLI or script route whose module graph carries a value import of agent-bundle/api (or agent-bundle) should fail the build with a diagnostic that points at the sanctioned pattern, not with Can't resolve '../events' from a hashed chunk.

Acceptance

  • examples/mcp-app (or a new example) gains a src/cli/dashboard.ts routed command built on the helper; agent-bundle build for all four targets passes artifact validation (no AB6005); node dist/bin/<plugin>.js dashboard --no-open prints the URL and the served page answers 200 with the App title; Ctrl-C / the route signal tears down the child and frees the port; the missing-agent-bundle and missing-artifact cases return the typed failure.
  • Helper argv lowering is unit-tested against the serve-app command parser (tests/cli.test.ts) for every ServeAppOptions key.
  • A value import of agent-bundle/api in a routed CLI or script route fails the build with a named diagnostic (documented in docs/diagnostics.md), not a bundler resolution error.
  • Docs, en + zh: the "Serving an App standalone" sample in guide/authoring/mcp.mdx uses the helper; reference/api.mdx lists the new entry; reference/cli.mdx documents the URL line (or ready signal) as a contract; docs/entry-conventions.md updated; one patch changeset naming the export and any new diagnostic code.
  • Meanwhile the docs stop promising the await import('agent-bundle/api') route shape and show the spawn pattern with this issue linked (separate docs PR).

Related: #514 (feature request), #537 (implementation), #387 (routed CLI in host artifacts — why the bin is self-contained). No open issue covers this (gh issue list --state all --limit 300 checked); #555 (composite artifact) is a different axis.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestmeta-frameworkAgent Bundle compiler-coupled meta-framework

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions