feat(agent)!: agent-native MCP wave — bridges, core surface, instance registry, devframe connect - #145
Conversation
|
Follow-up commit
Gates rerun: 905 unit tests, typecheck, lint, and the three connector e2e specs all green. (tsnapi flagged the removed bridge methods as a narrowing — allowed deliberately since they only ever existed in this unmerged stack.) |
|
Follow-up commit
Gates rerun: 905 unit tests (incl. new |
b4b9bb0 to
ef2598e
Compare
✅ Deploy Preview for devfra ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
|
Rebased onto Conflicts resolved during the rebase (
Full gate rerun clean post-rebase: 911 unit tests, typecheck, lint, build, and 17/17 Playwright e2e. |
|
Follow-up commit cac only auto-prints help for an explicit 4 new unit tests cover bare invocation, |
…ommands bridge, git agent tools - createMcpFetchHandler: framework-agnostic web-standard MCP endpoint extracted from mountMcpHttp (now a thin h3 wrapper); exported from devframe/adapters/mcp for custom hosts (Next App Router, etc.) - built-in read_state(key?) MCP tool over shared state, honoring the exposeSharedState filter alongside the resource projection - hub commands gain opt-in agent exposure: agent field (description, safety, valibot args) projects handler-bearing commands into ctx.agent; DF8404 rejects agent exposure on group-only commands - valibot→JSON-Schema conversion moved to devframe/utils/valibot-json-schema (public) so SDK-free hosts can convert schemas - rpc: schema-typed handlers may be async — Thenable<InferReturnType<RS>> in the schema-typed definition branch - git plugin: status/log/show/branches/diff agent-flagged with valibot args/returns schemas (read-only surface; writes stay private) - docs: hub commands-as-tools, read_state, custom-host mounting; DF8404 page
…connect, in-process Next MCP - instance registry: registerDevframeInstance/readDevframeInstances/ probeDevframeInstance/listLiveDevframeInstances in devframe/node — atomic same-dir writes, prune-on-read, ghost dedup per (port, basePath), dialable-origin adoption for family-ambiguous localhost binds; createDevServer registers automatically and unregisters on close; DEVFRAME_INSTANCES_DIR / DEVFRAME_DISABLE_INSTANCE_REGISTRY overrides - first devframe bin: `devframe connect` runs the stdio MCP connector — devframe_index (discover instances + their tools, funnel hints for MCP-less servers) and devframe_call (proxy one tool call over Streamable-HTTP); errors carry actionable fix payloads; missing SDK peer throws coded DF0043 - @devframes/next: DevframeNextHost.mountMcp serves MCP in-process on the Next app's own origin (the /_next/mcp shape); hub example wires it, advertises it in connection meta, registers the instance, and agent-flags its ping command; catch-all route exports POST/DELETE - mcp adapter: drop non-object outputSchema projections (MCP requires type object; a v.void() returns schema broke SDK clients) - e2e: devframe-connect (files-inspector round-trip incl. gateway tool) and minimal-next-devframe-hub (in-process discovery + command call); hermetic per-suite registries; vitest keeps unit runs out of the global registry - diagnostics DF0042/DF0043 + docs pages; connect/registry docs in the MCP adapter page
- ctx.agent.registerToolProvider(() => AgentToolInput[]): a lazy tool source queried at list/getTool/invoke time — the same on-demand projection applied to agent-flagged RPCs; earlier sources win on id collision; handle.notifyChanged() drives tools/list_changed - hub commands host derives its agent projection through one provider: the commands map is the single source of truth, replacing the registerAgentTools/unregisterAgentTools mirror and its handle map - built-in and connector tool names follow the devframe:<area>:<fn> convention: read_state -> devframe:state:read, devframe_index -> devframe:connect:list-instances, devframe_call -> devframe:connect:call-tool
- drop the devframe/utils/valibot-json-schema subpath: AgentToolInput gains valibot args (the same shape RPC definitions carry); the agent host derives the JSON-Schema input internally, and the hub passes schemas through untouched — conversion is an implementation detail again - devframe/node exports only registerDevframeInstance (+ its two types) from the instance registry; the read/probe/prune helpers stay internal to the connector
…space main renamed examples/minimal-next-devframe-hub -> examples/next-devframe-hub and its RPC/command/instance ids to the example:next-devframe-hub convention (#143); this wave's additions (MCP serverName, registry instance id, e2e spec file + assertions, playwright cwd) now match. Same fix for files-inspector's example:files-inspector id/namespace. Also regenerates the recipes/common-rpc-functions + recipes/open-helpers dts snapshots for phase 2's Thenable<InferReturnType<RS>> handler-return widening (non-breaking — allowed to update without --allow-breaking).
Bare `devframe` (no subcommand) silently exited 0 — cac only shows help automatically for an explicit -h/--help flag, not an unmatched command. Check matchedCommand after parse() and fall back to outputHelp(), guarding against the already-handled --help case so it doesn't print twice.
main landed two breaking changes this branch didn't know about: - deps!: migrate MCP adapter to @modelcontextprotocol/sdk v2 (#156) — the monolithic package split into @modelcontextprotocol/server + @modelcontextprotocol/client; setRequestHandler moved from imported schema constants to method-string form. - feat(rpc)!: support Standard Schema for RPC definitions (#157) — RpcArgsSchema/RpcReturnSchema key off StandardSchemaV1 instead of valibot's GenericSchema; @valibot/to-json-schema dropped; single-arg JSON-Schema unwrapping removed (always arg0/arg1 now). Adaptations: - connect.ts: import from @modelcontextprotocol/server(+/stdio) and @modelcontextprotocol/client (dynamic, still peer-optional); handler registration uses 'tools/list'/'tools/call' method strings. - devframe's package.json: @modelcontextprotocol/client added as an optional peer (connect.ts uses it to dial discovered instances) and bundled correctly via tsdown's onlyBundle (client pulls in @modelcontextprotocol/core, pkce-challenge, eventsource[-parser], jose — all now declared). - Renumbered DF0042 (registry write failure) and DF0043 (missing MCP SDK) to DF0045/DF0046 — both collided with codes main allocated to unrelated diagnostics (capabilities.build:false; RPC arg/return validation) while this branch was in flight. - AgentTool/AgentToolInput.args and DevframeCommandAgentOptions.args retyped from valibot's GenericSchema[] to StandardSchemaV1[]. host-agent.ts no longer eagerly converts args to inputSchema (that module is gone); a kind: 'tool' entry now carries args raw, mirroring how an RPC-backed tool defers to ctx.rpc.definitions — the MCP adapter's computeInputSchema converts either on demand. - hub's commands→agent bridge: coercePositionalArgs no longer detects a single-object schema to unwrap (that convention is gone project- wide); always maps arg0/arg1/... positionally, matching RPC-backed tool coercion. - Tests updated for the new args-carried-raw contract, plus a new end-to-end MCP-adapter test proving Standard Schema args convert to JSON Schema over the real wire (arg0-keyed, not unwrapped). - Removed the now-fully-redundant devframe/utils/valibot-json-schema module and its registrations (superseded by the upstream to-json-schema.ts, which already covers every validator via ~standard.jsonSchema, degrading to a permissive object schema for validators without one — e.g. valibot). Verified: 1020 unit tests, typecheck (21/21), lint, full build, and 17/17 Playwright e2e (incl. both connector round-trips through the real stdio/HTTP MCP v2 pipeline) all green.
158eb99 to
2549d78
Compare
|
Rebased onto
Both required real adaptation, not just textual conflict resolution — see
Verified: 1020 unit tests, typecheck (21/21), lint, full build, and all 17 Playwright e2e specs green — including both connector round-trips running the real stdio↔HTTP MCP v2 pipeline end-to-end, not just mocked. |
registerDevframeInstance builds its file path with pathe's join, which always normalizes to forward slashes; the test compared that against node:path's join, which uses the platform-native separator — a real mismatch on Windows (backslash vs forward slash), not a flake. All three windows-latest CI jobs failed on this exact assertion. Switch the test to pathe's join too, matching the implementation.
… findings - Auto tool-name convention: internal ids stay colon-namespaced (devframe:<area>:<fn>, devframes:plugin:<slug>:<fn>, command ids); the MCP boundary derives the wire name via toAgentToolName (chars outside [a-zA-Z0-9_-] -> '_', <=128) so every client's tool-name pattern is satisfied. Calls resolve back to ids; collisions keep the first registration and warn (DF0047). Documented in the agent-native guide; exported from devframe/node. - Validator neutrality: the git plugin's agent schemas now use devframe/utils/simple-schema; valibot leaves its runtime deps. - Coded diagnostics for the remaining ad-hoc throws: DF0048 (unknown shared-state key), DF0049-DF0051 (connector call errors) — each with a docs page; the connector projects Diagnostic code/fix/docs into its structured error payload. - Dedupe: one __connection.json probe primitive (probeDevframeOrigin) behind registry liveness checks and the connector's --port probe; one shared coerceAgentPositionalArgs behind the agent host's RPC bridge and the hub's command tools (explicit wrap/drop fallback). - connect.ts: IndexedInstance derives from DevframeInstanceRecord; the lazy SDK seam is typed (ConnectSdk). - Plan 031 accuracy: record carries origin (not host), actual DF codes, wire-name note; plans/README.md row updated.
Resolves conflicts from #158 (knip integration): - packages/hub/package.json: keep our @standard-schema/spec addition, take main's removal of the (knip-flagged unused) birpc dependency - pnpm-lock.yaml: regenerated via `pnpm install --lockfile-only` Also fixes two knip findings the merge exposed in this branch's own new surface (unchecked by knip before it landed on main): - instance-registry.ts: DEVFRAME_INSTANCES_DIR_ENV / DEVFRAME_DISABLE_INSTANCE_REGISTRY_ENV / resolveInstancesDir / probeDevframeInstance drop `export` — module-internal per the barrel's existing "read/probe/prune helpers stay internal" comment, nothing outside the file used them - packages/next: DevframeNextHostMcpOptions (the `mountMcp` options type) now re-exported from the package barrel, alongside its sibling option types — a real gap, not a knip false positive
…pect plugin display BREAKING CHANGE: MCP tool names are now the sanitized wire name derived from each tool's colon-namespaced id (toAgentToolName), not the id itself — devframe:state:read ships as devframe_state_read, devframe:connect:list-instances as devframe_connect_list-instances, devframes:plugin:git:status as devframes_plugin_git_status, etc. Anything hardcoding the colon form as a literal MCP tool name needs the sanitized name instead. Marked and explained in the PR title/description since this renames every tool this wave ships. - Move `toAgentToolName` from devframe/node to devframe/utils/agent-tool-name: it's a plain string transform with no node dependency, so browser-side UIs can import it too — not node-specific. Wires the package.json exports map, tsdown client entries + check-client-dist list, and the tsconfig.base.json / alias.ts cross-package path aliases the same way every other devframe/utils/* entry already is. - Split its test file accordingly: toAgentToolName tests move to utils/agent-tool-name.test.ts; coerceAgentPositionalArgs tests move to node/__tests__/agent-args.test.ts. - inspect plugin's AgentView now reflects the real tool name: the agent view was displaying each tool's internal (colon-namespaced) id as if it were what an MCP client calls. It now shows the sanitized wire name as the primary label, with the internal id noted underneath only when they differ (storybook fixture updated to demonstrate the distinction).
Why
The full agent-native MCP wave (plan 031). Vercel's
next-devtools-mcp0.4.0 validated an architecture devframe was already positioned for: the real MCP endpoint lives inside the framework, and a thin external connector just discovers and proxies it. This wave builds both halves — the embedded surface and the connector — proven end-to-end, including the literal/_next/mcpshape on devframe primitives via@devframes/next.Supersedes #142 (phase 1) and #144 (phase 2), both folded in here.
Breaking change
MCP tool names are now sanitized wire names, not the raw colon-namespaced ids. MCP clients constrain tool names to
^[a-zA-Z0-9_-]{1,128}$; every tool this wave ships uses a colon-namespaced id (devframe:<area>:<fn>,devframes:plugin:<slug>:<fn>), which several strict clients (including the Anthropic API) reject outright. The MCP boundary now derives a wire-safe name automatically (toAgentToolName— every run of characters outside[a-zA-Z0-9_-]becomes_) and resolves calls back to the id:devframe:state:readdevframe:state:readdevframe_state_readdevframe:connect:list-instancesdevframe:connect:list-instancesdevframe_connect_list-instancesdevframe:connect:call-tooldevframe:connect:call-tooldevframe_connect_call-tooldevframes:plugin:git:status(and the other 4 git tools)devframes:plugin:git:statusdevframes_plugin_git_statusAnything hardcoding the colon form as a literal MCP tool name (an agent client's tool allow-list, a saved prompt, a test) needs the sanitized name instead — this PR ships before any of these names went out in a release, but the rename is breaking relative to earlier previews of this branch (#142/#144) and worth flagging for anyone who already wired against them. See tool ids and wire names in the agent-native guide.
Phase 1 — bridges & structured errors
viteDevBridgeand@devframes/next'screateDevframeNextHandlergain anmcpoption (forwarded tocreateDevServer) and advertise the side-car endpoint in their__connection.json—ConnectionMeta['mcp']gains an optionalportfor side-car origins. Shared resolverresolveMcpConnectionMeta.formatMcpErroremits{ error: { code, message, fix?, docs? } }for nosticsDiagnostics, so agents get the actionable next step and docs URL instead of a flattened string.Phase 2 — core surface
createMcpFetchHandler(ctx, options)— the MCP Streamable-HTTP endpoint as a framework-agnostic web-standardRequest → Responsehandler;mountMcpHttpis a thin h3 wrapper over it.devframe:state:readtool — tool-shaped shared-state access (no key → key list, key → JSON value), honoring theexposeSharedStatefilter alongside the resource projection.ctx.agent.registerToolProvider(() => AgentToolInput[]): a tool source queried atlist/getTool/invoketime, the same on-demand projection applied toagent-flagged RPCs. The commands host registers one provider deriving tools from its commands map — single source of truth, nothing mirrored. Commands opt in via anagentfield (description,safety, optional valibotargs);agenton a handler-less group throws DF8404; the field never crosses the wire.Thenable<InferReturnType<RS>>.status/log/show/branches/diffagent-flagged with args/returns schemas (safety: 'read'); writes stay agent-invisible. Cross-refs plan 029.Phase 3 — instance registry, connector, in-process Next MCP
devframe/node):registerDevframeInstancewrites an atomic record to~/.devframe/instances/<pid>-<port>.json; readers prune dead records on failed__connection.jsonprobes, dedup same-port ghosts (newest wins), and adopt a dialable origin for family-ambiguouslocalhostbinds.createDevServerregisters automatically and unregisters on close; in-process hosts call it explicitly.DEVFRAME_INSTANCES_DIR/DEVFRAME_DISABLE_INSTANCE_REGISTRYoverride. Failures degrade to coded warnings (DF0042).devframebin +connect— the package's first bin.devframe connectruns a stdio MCP connector exposing two gateway tools:devframe_connect_list-instances(discover live instances + list their MCP tools; MCP-less instances carry a restart-with---mcpfunnel hint) anddevframe_connect_call-tool(proxy one tool call over Streamable-HTTP). Errors carry{ code, message, fix, docs }(coded diagnostics DF0049–DF0051); a missing SDK peer throws DF0046.--portprobes beside the registry.DevframeNextHost.mountMcp(ctx, path)serves the fetch handler on the Next app's own origin (the/_next/mcpshape). The hub example mounts/__hub/__mcp, advertises it, registers the instance, and agent-flags its ping command.outputSchemaprojections are dropped (av.void()returns schema producedtype: null, which SDK clients reject).Tool ids vs wire names
Internal tool ids follow the
devframe:<area>:<fn>/devframes:plugin:<slug>:<fn>convention. MCP clients constrain tool names to^[a-zA-Z0-9_-]{1,128}$, so the MCP boundary now derives the wire name automatically (toAgentToolName, fromdevframe/utils/agent-tool-name— a plain string transform, safe to import client-side too): runs of unsafe characters become_—devframe:state:readships asdevframe_state_read,devframes:plugin:git:statusasdevframes_plugin_git_status. Calls resolve back to ids at the boundary; sanitize-collisions keep the first registration and warn (DF0047). The convention applies uniformly to agent-flagged RPCs, registered tools, providers, and hub-command tools, and is documented in the agent-native guide.API surface
The public surface is deliberately minimal — only members with a consumer outside their own package are exported (
createMcpFetchHandler,registerToolProvider,registerDevframeInstance+ its two types,resolveMcpConnectionMeta,toAgentToolName(devframe/utils/agent-tool-name),coerceAgentPositionalArgs, and the feature options). valibot→JSON-Schema conversion and the registry read/probe/prune helpers stay internal.Verification
Full gate:
pnpm lint && pnpm test && pnpm typecheck && pnpm build— 1031 unit tests and 17/17 Playwright e2e, including two connector gates: files-inspector (discovery → gateway-tool round-trip → actionable errors) and minimal-next-devframe-hub (connector discovers the hub inside the Next dev server and calls its agent-flagged command through the in-process endpoint).Post-review hardening
A two-axis review (standards + spec) surfaced findings that are now addressed:
valibotdependency to the built-indevframe/utils/simple-schema.throw new Errors (connector call errors, unknown shared-state key) became DF0048–DF0051 with docs pages; the connector projectscode/fix/docsinto its structured error payload.__connection.jsonprobe primitive shared by registry liveness checks and the--portprobe; onecoerceAgentPositionalArgs(exported) shared by the agent host's RPC bridge and the hub's command tools.IndexedInstancederives fromDevframeInstanceRecord; the connector's lazy SDK seam is typed.originfield, the actual DF codes, and the wire-name convention; theplans/README.mdrow is updated.toAgentToolName) as the primary label, with the internal id noted underneath only when they differ.This PR was created with the help of an agent.