Skip to content

refactor(mcp): build the tool surface from transport providers - #82

Closed
jlrickert wants to merge 9 commits into
mainfrom
refactor/mcp-transport-providers
Closed

refactor(mcp): build the tool surface from transport providers#82
jlrickert wants to merge 9 commits into
mainfrom
refactor/mcp-transport-providers

Conversation

@jlrickert

Copy link
Copy Markdown
Owner

First of a four-PR sequence. Merge in order; each later PR targets the one
before it and retargets to main as its parent lands.

Reworks the MCP surface so the tool set is assembled from transport providers
rather than wired per transport, and moves configuration and flight resolution
onto a single process-wide snapshot with one reload boundary at orientation.

Commits:

  • chore: ignore the repo-local .tapper directory
  • refactor(tapper): resolve configuration from one process-wide snapshot
  • feat(cli): resolve the configured flight at orientation, not per command
  • fix(tapper): require editor authority when import_from_keg leaves stubs
  • refactor(mcp): build the tool surface from transport providers
  • feat(mcp): publish local-path attachment transfers on tap mcp
  • feat(cli): add experimental tap launch for agent harnesses
  • fix(mcp): state the recovery situation in the orientation payload
  • refactor(mcp): disable the deprecated graph tool

Sequence

PR Base
1 this one main
2 bootstrap flight when no flight exists this branch
3 resolve a launched agent's flight on every orientation PR 2

The telemetry back-off fix is independent of all three and targets main
separately.

This repo's own project config records the maintainer's default keg and
flight. Those are per-developer choices, not shared state, so the
directory should never have been commitable in the first place.

Also ignores *.iml alongside the existing .idea/ entry.
ConfigService kept four independently-invalidated caches (user, project,
merged, warnings) behind a `cache bool` threaded through every read. The
flag made each call site restate a question it had no basis to answer,
and the caches could disagree with one another mid-process.

Configuration is now read once and fixed for the life of the process. A
`tap` command runs against one consistent snapshot; a long-lived
`tap mcp` session picks up an external edit at its next orient, which is
the only place Reload is called. Nothing inside a session can write
configuration, so "edit the file, then reorient" is the whole update
story.

The snapshot is immutable once published, so concurrent readers need no
coordination beyond the mutex guarding the pointer. That matters because
the MCP SDK dispatches every call except initialize asynchronously.

Read-modify-write paths that rewrite a config file need the bytes on
disk rather than the snapshot, so they call ReadUserConfigFile /
ReadProjectConfigFile and then Reload once the write lands.

Load() returns warnings alongside the merged config instead of leaving
them on a struct field that only happened to be populated by the last
Config() call.
The root command copied the configured flight into KegTargetOptions on
every invocation, which meant a persisted `flight:` silently narrowed
keg access for direct commands like `tap cat` and `tap edit`. That
contradicted the documented rule that direct CLI commands are governed
by normal keg authorization rather than flight cover, and it made the
configured selection indistinguishable from an explicit --flight once it
reached resolution.

Selection now happens where it is meaningful: ActiveFlightName reads the
snapshot when orientation asks for it, and --flight remains the only way
to put a flight into per-command keg targeting.

A flight persisted in user or project config therefore no longer narrows
direct CLI commands. Pass --flight explicitly to opt a single invocation
into flight cover enforcement.
ImportFromKeg resolved the source keg at viewer regardless of options,
but leave_stubs rewrites source nodes to point at their new home. A
viewer-only flight could therefore mutate a keg it was only authorized
to read.

The source role now tracks what the operation actually does: viewer for
a plain copy, editor when leave_stubs is requested.
NewServer branched on a Surface enum to decide which tools to register,
which meant every capability difference between `tap mcp` and the hub's
/mcp endpoint was encoded as "which enum am I". The enum conflated
things that vary independently — who the caller is, which catalog backs
discovery, and whether the server shares a filesystem with its agent —
so adding a transport meant auditing every branch.

Transport differences are now expressed as four interfaces:
OrientationProvider selects and renders flight authority, FlightProvider
supplies flight CRUD, KegDiscoveryProvider lists reachable kegs, and
IdentityProvider reports who the session is. Nil providers fall back to
local adapters over tap; the hub injects authenticated, catalog-backed
implementations. Registration itself no longer branches.

Two consequences for the published surface:

- auth_status gives way to auth_info, which returns structured
  identities and flight-filtered kegs and is deliberately credential-free.
  Tokens, email, scopes, cookies, expiry, and session data are absent
  from the wire shape by construction.
- keg_list drops its hub selector and filters through the session's
  immutable active-flight cover, so discovery cannot report kegs the
  flight excludes.

A session may now edit or delete its own active flight. The previous
blanket prohibition meant a flight granting manage_flights could
administer every flight except the one it most likely needed to correct.
A successful self-edit adopts the exact returned manifest before the
response is released, so removing manage_flights removes the mutation
tools immediately; a self-delete enters recovery immediately. If the edit
persists but rendering fails, the session enters recovery rather than
retaining stale authority.

Attachment transfers split into two variants behind
ServerOptions.SharedFilesystem, since only stdio shares a filesystem with
its agent host. The hosted variant omits local-path fields from the
published schema rather than refusing them at call time, so a hosted
agent has no vocabulary to name the server's own disk. `tap mcp` opts in
separately.

config, config_template, license, repo_init, export, import,
namespace_list, and keg_visibility leave MCP entirely: they operate on
machine-local Tapper state or perform tenant administration. The parity
coverage map records the rationale per method.

Migration notes for embedders: mcp.Surface, SurfaceFull, SurfaceHub,
ServerOptions.Surface, ServerOptions.OrientationLoader, and
ServerOptions.LicenseText are gone; construct provider implementations
instead. The nine tools listed above are no longer registered on any
transport.
`tap mcp` runs on the same machine as the agent driving it, so a path in
a tool argument names the same file for both sides. It now opts into the
shared-filesystem variant and regains the full attachment round-trip:
upload_file and upload_image accept source_path and file: URIs alongside
byte sources, download_file writes to dest_path, and download_image takes
an optional dest_path, still returning MCP image content when omitted.

The option set is a named function rather than an inline literal so the
choice is assertable without standing up a stdio server. Dropping it
would silently cost `tap mcp` these transfers while leaving every other
test green, which is exactly how they were lost.
Exercising the MCP and flight changes by hand means repeatedly starting
Claude Code or Codex against a chosen model with a chosen flight, and
making the flight stick means exporting TAP_FLIGHT or editing config
between runs.

`tap launch HARNESS --agent NAME` collapses that. An agent aliases a
model, a flight, an endpoint, and how to authenticate:

    agents:
      local:
        model: ollama/qwen3.6:35b-mlx
        baseUrl: http://192.168.50.197:11434/v1
        flight: '@homelab/+ecw'
      sub:
        model: anthropic/claude-opus-4
        auth: subscription
      work:
        model: openai/gpt-5
        apiKeyEnv: WORK_OPENAI_KEY

Models are provider-qualified because the provider decides which protocol
the harness must speak; an unqualified model is rejected rather than
guessed at. Ollama serves both the OpenAI API and the Anthropic Messages
API, so it is the one provider every harness can drive — Claude Code
included. Codex against a hosted Anthropic model, and Claude Code against
a hosted OpenAI model, stay refused before anything is spawned.

One baseUrl serves both protocols: the launcher appends /v1 for OpenAI
clients, which add /chat/completions, and strips it for Anthropic ones,
which add /v1/messages themselves.

Auth is explicit because absence cannot express intent — an unset key
means "inherit", which silently prefers an exported API key over a
subscription login. `auth: subscription` therefore removes the inherited
provider key variables from the child environment, which an overlay
cannot do: appending can override a variable but never unset one.
apiKeyEnv names the variable holding a key, never the key itself,
mirroring HubEntry.TokenEnv, so agents hold no secrets and need no
trust-boundary strip in project config.

The agent's flight is exported as TAP_FLIGHT, which needs no new plumbing
— it already outranks project and user config in the flight chain, so a
tap mcp session started inside the harness orients to it.

Launch is ResolveLaunch plus execution, so --dry-run and a real run
cannot drift, and the whole surface is testable without spawning a
harness.

Registered under the existing IncludeIntegrations profile gate, so the
pruned keg binary does not grow the command. Excluded from MCP surface
coverage: spawning processes on the server's host is not an agent
operation.

Experimental and intentionally undocumented — this integrates with
Tapper Hub later and will be redesigned, so docs and the config template
are deliberately untouched.
A session that cannot establish flight authority had almost no way to say
so. The tool list is filtered to the recovery set, so an agent never gets
to call a locked tool and see the error explaining why — leaving an empty
KEG table as the only signal. Models strong enough to reason from an
absence recovered; weaker ones did nothing at all.

The two recovery paths also had their messages crossed. A blank selection
produced the generic orientation document, which never mentions flights
being locked. A selection that failed to resolve produced "no flight is
selected", which is false and sends the reader hunting for missing
configuration instead of the real fault — usually a wrong flight name or
an unreachable hub.

Now the payload carries a Flight section whenever no flight is active,
naming the state and the three steps out of it, and a failed resolution
leads with the actual error. Both stay within the payload's MCP-first
rule of never naming CLI commands.

The orient tool description carries the same imperative, because tool
descriptions are the one thing every model reads; server instructions
alone are not enough for a small local model.
graph rendered a standalone HTML page — DOCTYPE, stylesheet, and an
embedded JavaScript renderer — and returned the whole document as tool
text. An agent cannot display it, and the part it could actually use, the
node and edge JSON, was buried inside a script tag. The static
scaffolding alone ran to roughly 8KB before any nodes, so every call
spent context on markup nobody reads.

The MCP and CLI renderings had also drifted: GraphOptions.BundleJS is set
only by the CLI, so MCP fell through to the inline fallback bundle and
produced a visibly different page from the same data.

Unregistered rather than deleted outright — Tap.Graph and
`tap graph --output` still serve the case that works, writing a file to
open in a browser, until the feature is removed. The tool row is dropped
from the embedded tool inventory too: that text ships inside the
orientation payload, so leaving it would advertise a tool that no longer
exists.
@jlrickert

Copy link
Copy Markdown
Owner Author

Already on main. All nine commits were rebased onto main and pushed directly (b0fc413..798e89d), so the SHAs differ from this branch and GitHub could not detect the merge — hence the phantom conflicts.

Verified identical: git diff ad14c15 798e89d is empty.

#83 has been rebased onto the new main and retargeted to it; #84 follows #83.

@jlrickert jlrickert closed this Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant