Trailblaze Decision 038 PR A5: MCP Toolsets Bundled for On-Device¶
Renumbered to PR A5 on 2026-04-20 per the toolset-consolidation amendment (
2026-04-20-scripted-tools-toolset-consolidation.md), unchanged by the Option-2 amendment. This is the only remaining home for QuickJS in scripted tool execution — host-side scripted tools run as bun/node subprocesses per PR A3. The design below (in-process MCP transport,trailblaze/requiresHostfilter) is unchanged and continues to be the target for this phase.
PR A5 takes the same TypeScript source an author writes for PR A3 (subprocess MCP toolsets) and runs it on-device inside QuickJS. One TS codebase, two deployment modes, zero authoring fork.
The key enabler: the MCP SDK itself is mostly pure JavaScript. With a bundler
(esbuild or Zipline’s plugin) targeting a non-Node environment, the SDK + zod
compile to a QuickJS-runnable artifact. The only things that don’t run on-device
are the author’s own calls into Node-only APIs (fetch, fs, child_process,
etc.) — and those are declared explicitly so Trailblaze skips them during
on-device registration.
Scope¶
Authors write the same .ts source documented in PR A3. For tools whose
implementation uses only pure JS + trailblaze.execute(), no additional work —
the same source runs on-device unmodified once bundled.
For tools that need Node-only APIs, the author declares that via the
_meta["trailblaze/requiresHost"]: true key on the tool (see the
conventions devlog). Trailblaze’s on-device registration pass skips
those tools; the rest register normally.
In scope¶
- Build pipeline. Produce a single JS bundle from the author’s TS source +
SDK +
zod, targeting ES2020 / no Node built-ins. Initial path: authors bring their owntsc+esbuildand point Trailblaze at the pre-bundled.js. Longer-term path: Zipline’s Gradle plugin automates this during APK assembly. - Artifact packaging. The bundled JS ships as an Android asset or classpath resource, loaded at session start.
- In-process MCP transport. A custom
Transportimplementation for the SDK’sServerthat hands request messages directly to Kotlin instead of framing them onto stdio. No sockets, no JSON-RPC over the wire — just function calls across the QuickJS↔Kotlin bridge, carrying the same MCP message shapes. - Discovery. On session start: evaluate the bundle in QuickJS, connect the
in-process transport to the author’s
Serverinstance, firetools/list, register the result. - Dispatch. LLM selects tool → Trailblaze routes
CallToolRequestthrough the in-process transport → SDK calls the author’s handler → response flows back. - Capability-based registration filter. Tools tagged with
_meta: { "trailblaze/requiresHost": true }are skipped on on-device runs. Trails that depend on host-only tools fail fast with a clear error rather than silently degrading.
Explicitly out of scope¶
- Automated bundling in the Trailblaze build. PR A5 accepts pre-bundled
.jsartifacts. Automating the tsc + esbuild pipeline as part of./gradlew assembleis a follow-up (probably gated on Zipline adoption, tracked separately in Decision 038’s Open Questions). - npm package support beyond what the SDK + zod need. Authors who want to use arbitrary npm packages need to verify those packages bundle cleanly for a non-Node target (many don’t). Not PR A5’s job to guarantee.
- Hot-reload of bundles. The bundle is baked into the APK / host artifact at build time. Runtime reloading is not in scope.
Design decisions¶
The bundler is the author’s responsibility (initially)¶
For PR A5’s first landing, we don’t ship a Gradle plugin. Authors run their
own tsc + esbuild:
tsc && esbuild dist/tools.js \
--bundle \
--platform=neutral \
--format=iife \
--target=es2020 \
--outfile=tools.bundle.js
And point Trailblaze at tools.bundle.js. This unblocks on-device support
without gating on a build-tooling PR. The Zipline migration (flagged in
Decision 038 Open Questions) automates this later.
This also happens to be the permanent path for binary-release consumers (external users who consume published Trailblaze artifacts rather than rebuilding). They don’t run Trailblaze’s Gradle build — they drop in a pre-bundled artifact. Zipline benefits build-from-source consumers; BYO-bundle is the forever-path for the rest.
Capability gating at registration, not at call time¶
The trailblaze/requiresHost key is checked when the tool gets
registered, not when it’s invoked. Host-only tools never appear in the
on-device tool list. The LLM can’t select them, no runtime check needed, no
silent-failure foot-gun. If someone tries to run a trail that references a
host-only tool on-device, the error is at trail-validation time: “this trail
references fetchUserInfo, which is host-only and not registered in this
on-device session.”
Same MCP SDK, different Transport¶
The SDK’s Server class doesn’t care what transport carries its messages —
that’s the whole point of the Transport interface. On host, PR A3 uses
StdioServerTransport. On-device, PR A5 uses InProcessTransport. The
author’s Server instance and request handlers are identical in both cases.
The InProcessTransport is a small shim: send(message) hands the JSON-RPC
message (as an already-parsed JS object, no serialization round-trip needed)
to Kotlin via a QuickJS-exposed callback. Kotlin dispatches appropriately and
calls back via onmessage with the response. No JSON parse/serialize per
call — we skip the wire format since both sides are in the same process.
What runs, what doesn’t¶
The matrix authors should keep in mind:
| Construct | On-device (QuickJS) |
|---|---|
Strings, numbers, regex, Date, JSON, Math |
✓ ECMAScript core |
async / await, Promises |
✓ Supported |
MCP SDK’s Server, handler registration, dispatch |
✓ Bundles cleanly |
zod schema validation |
✓ Pure JS |
trailblaze.execute(...) calls |
✓ Via in-process binding |
fetch / fs / child_process / http |
✗ Not in QuickJS |
| Random npm package | Depends on whether it bundles cleanly |
Esbuild fails the build loudly if the author’s code imports something that doesn’t resolve in the target environment. That’s a feature: authors know before deployment whether their tool can run on-device.
Open questions¶
- Zipline migration timing. Decision 038 Open Questions flagged “when to
upgrade from bare
quickjs-ktto Zipline” as a follow-up. PR A5 shipping with BYO-bundle is one data point in favor of delaying — authors can ship without Zipline. But Zipline’s typedZiplineServiceinterfaces would replace the hand-written in-process transport with something compiler-checked. Revisit after PR A5 lands. - Multi-toolset composition. If multiple toolsets register on-device and their tools share names, what wins? Error out at registration time with a conflict; authors resolve by renaming.
References¶
docs/devlog/2026-04-20-scripted-tools-execution-model.md— Decision 038, the umbrella roadmap.docs/devlog/2026-04-20-scripted-tools-mcp-subprocess.md— PR A3, the subprocess deployment mode. PR A5 reuses PR A3’s TS authoring source verbatim.docs/devlog/2026-04-20-scripted-tools-a2-sync-execute.md— PR A2, ships thetrailblaze.execute()in-process callback that on-device toolsets use to invoke Trailblaze primitives.docs/devlog/2026-04-20-scripted-tools-mcp-conventions.md— the MCP extension conventions, including_meta["trailblaze/requiresHost"]used here for capability-based filtering.