Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f4d92bd
perf: fetch context in one call and cache hub API discovery
jlrickert Oct 14, 2026
1eb9d86
fix(mcp): point edit errors at their fix and flag repeated failures
jlrickert Oct 14, 2026
ab0b524
feat(keg): index archives by offset and replace contents on import
jlrickert Oct 14, 2026
6299f59
fix: resolve bare batch placeholder links and scope telemetry spools
jlrickert Oct 17, 2026
32c112a
perf(keg): make touch cheap and batch snapshot and dirty index reads
jlrickert Oct 17, 2026
ce8bff9
fix(mcp): confine local attachment transfers to the working directory
jlrickert Oct 17, 2026
079a98c
refactor(mcp): tidy tool descriptions and drop id_only for format %i
jlrickert Oct 17, 2026
73e3c15
feat(mcp): declare output schemas and scope node_search to the flight
jlrickert Oct 17, 2026
396634f
fix(auth): refresh a shared auth store in one process at a time
jlrickert Oct 17, 2026
0e67387
feat(relay): reconnect on credential_expired and retry token refresh
jlrickert Oct 17, 2026
096859b
fix: harden telemetry spools, search cover, and archive index memory
jlrickert Oct 17, 2026
75cb104
feat(apicontract): adopt API revision 2026-10-17
jlrickert Oct 17, 2026
832cd6e
fix(auth): keep logout and login authoritative over a running refresh
jlrickert Oct 18, 2026
a42abea
fix(orient): describe agent sessions by flight and bound lock waits
jlrickert Oct 18, 2026
ef1bf48
feat(relay): reauth in band with protocol 4 frames and end codes
jlrickert Oct 18, 2026
7c8eb41
fix(mcp): classify every failure and report refusals by kind
jlrickert Oct 18, 2026
c2c9d79
fix(keg): enforce archive limits while staging and narrow node import
jlrickert Oct 19, 2026
0df70b3
feat(flight): let a flight manage its namespace's kegs and flights
jlrickert Oct 21, 2026
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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ __pycache__/

node_modules

graphify-out

# Local scratch task notes — never tracked
task.md
TASK.md
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,11 +220,11 @@ link for the current keg, a node (`42`, `@acme/engineering/42`), a flight
browser. Agents get the same link from the MCP `url` tool. `tap explore kegs|flights|people QUERY`
searches the hub's public directory; your own kegs come from `tap keg list`.

Export a keg for backup or migration:
Export a keg for backup or migration, then restore it on the keg's hub (see
[Backups And Archives](docs/backups-and-archives.md#restore)):

```bash
tap archive export --keg @acme/engineering -o engineering.keg.tar.gz
tap archive import engineering.keg.tar.gz --keg @acme/engineering
```

## Concepts
Expand Down
7 changes: 4 additions & 3 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,9 @@ writing KEG files directly.
`-exclusions`. Each hit prints reference, score, title, and a highlighted
snippet; `--limit` caps a page (the hub chooses by default), the next page's
cursor is printed to stderr for `--cursor`, `--id-only` prints references,
and `--json` prints the hub's response. MCP's `node_search` takes the same
parameters; under a flight it never searches outside the cover.
and `--json` prints the hub's response. MCP's `node_search` takes `kegs`,
`cursor`, and `limit`: an empty `kegs` searches the call's whole flight
cover, and naming a KEG outside the cover is an error that names it.
- `tap node grep PATTERN` - match node content lines in one keg against a
regex (MCP `node_grep`), for exact identifiers and audits.
- `tap tag list [EXPR]` - list tags or query tagged nodes.
Expand Down Expand Up @@ -159,7 +160,7 @@ writing KEG files directly.
- `tap snapshot list NODE_ID` - list node revisions.
- `tap snapshot restore NODE_ID REV --yes` - restore a revision.
- `tap archive export -o out.keg.tar.gz` - export a keg archive.
- `tap archive import out.keg.tar.gz` - import a keg archive.
- Restore an archive on the keg's hub; see [Backups And Archives](backups-and-archives.md#restore).
- `tap keg check` - check keg health.
- `tap index rebuild` - rebuild indexes.

Expand Down
2 changes: 1 addition & 1 deletion docs/ai-coding-agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ native Tapper plugin.
- [Relay](relay.md) — experimental `tap relay`, which offers this machine's
model providers, such as a local Ollama, to your Hub account.
- [Agent Conventions](agent-conventions.md) — tapper invariants every agent
should follow: MCP-first, never edit node files directly, never mix CLI
should follow: MCP-first, never bypass the tools, never mix CLI
writes with a live MCP session, snapshot before destructive edits.

## Quick pick
Expand Down
32 changes: 18 additions & 14 deletions docs/ai-coding-agents/agent-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,20 +23,14 @@ The CLI remains the right tool for interactive editor sessions,
diagnostics that have no MCP equivalent, and shell-integration tasks.
See the safety rules in the bundled [tapper skill](../../integrations/rendered/claude/tapper/skills/tapper/SKILL.md).

## Never read or write node files directly
## Never bypass the tools

A tapper node is a numbered directory:

```
<keg-root>/42/
README.md # node content
meta.yaml # user-facing metadata
stats.json # programmatic stats: hash, timestamps, access count
```

These files are tapper's internal storage format, not an external API.
Reading them bypasses the index. Writing them bypasses per-node
locking and snapshot history.
Every KEG is hosted on a Tapper Hub, so there are no node files on the
agent's machine to read or edit. A node's content (markdown), metadata
(YAML), and stats (hash, timestamps, access count) are reached only through
the Hub, which enforces access, per-node locking, indexing, and snapshot
history on every call. An archive or export written to disk is a copy, not
the live KEG; editing it changes nothing.

Always route through tapper's interfaces:

Expand All @@ -45,7 +39,17 @@ Always route through tapper's interfaces:
- `mcp__tapper__node_edit` to write a node — `content` is the markdown body
and `meta` is the metadata document. They are separate fields, so
`content` must not carry a frontmatter block; send either one alone to
leave the other untouched, or both to replace the whole node.
leave the other untouched, or both to replace the whole node. Each part
needs its token from `node_read`: `content_hash` as
`expected_content_hash`, `meta_hash` as `expected_meta_hash`. A refusal for
a missing token names the exact `node_read` call to make; make it rather
than resending the same arguments.
`schema` is a type name from `schema_list`, such as `task`, never a
description of the note.

A tool error's `action` is the next call to make. A failure carrying
`repeat_count` means the same call has already failed that many times; do not
send it again unchanged.

## CLI and MCP share Hub concurrency rules

Expand Down
140 changes: 133 additions & 7 deletions docs/ai-coding-agents/mcp-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
The `tap mcp` command starts a Model Context Protocol server on stdio, exposing
the same agent-safe tools and resources as Tapper Hub's authenticated `/mcp`
endpoint — the one difference being attachment transfers, where `tap mcp` can
also read and write local paths because it runs on your machine. Both publish
also transfer files inside its working directory because it runs on your machine. Both publish
connection-pinned authority at initialization. Orientation is a read-only view;
`session_refresh` retries only a broken explicit selection. Without a flight,
the server uses normal identity-authorized full access for the connection
Expand Down Expand Up @@ -92,14 +92,98 @@ least-privilege flight, pin it with `tap use --flight @namespace/+slug` (or
narrow the existing connection. Seeing only the six recovery tools means an
explicitly configured root failed to initialize.

### Tool Schemas

Each tool description opens with its category (`Session:`, `Memory:`,
`Explore:`, or `Manage:`). Every authority-bearing tool also takes an
optional `flight` argument, added to its input schema; the property's own
description is the one per-tool explanation of it ("Flight to run this call
under, such as +work or @ns/+work. Omit it to use the connection's flight."),
and the operating rules explain it once per session. Tool descriptions do not
repeat it.

Every tool declares an `outputSchema` for its `structuredContent`: the paging
fields of the listing tools (`lines`, `offset`, `limit`, `next_offset`,
`has_more`, `pagination`), `node_read`'s `nodes`, `node_search`'s `hits` and
`next_cursor`, the write tools' new hashes, and so on. A tool whose result is
only text declares `{message}`, the object every result carries its text in.
The schemas leave additional properties open, so a newer server's extra field
never fails a client that validates results.

An `outputSchema` describes a tool's success. A failed call (`isError: true`)
carries no `structuredContent`: its payload (`code`, `message`, `action`,
`operationPerformed`, and any error-specific fields such as `currentHash` or
`missing`) is the result's first text block, as a JSON object. Clients that
validate `structuredContent` against the schema even on errors, as the
TypeScript SDK does, therefore see the tool's error rather than a JSON-RPC
`-32602`. Relayed tools follow the same rule.

### Failure Results

Every failed tool call has one shape, whichever layer refused it (input
validation, flight authority, the tool, or the Hub). The result has
`isError: true`, no `structuredContent`, and a first text block holding one
JSON object. Non-text attachments, if any, follow that block; no second text
block repeats the message.

```json
{
"code": "CONFLICT",
"message": "flight @me/+work already exists\n\nThe request conflicts with current state. ...",
"action": "The request conflicts with current state. ...",
"operationPerformed": false,
"retryAfterSeconds": 2,
"repeat_count": 3
}
```

| Field | Type | Presence | Meaning |
| --- | --- | --- | --- |
| `code` | string | always | Wire code: the Hub REST code for the same failure (`INVALID`, `NOT_FOUND`, `CONFLICT`, `RATE_LIMITED`, `ORIENTATION_DENIED`, ...), `INVALID_ARGUMENT` for input-schema validation, or `INTERNAL` when the cause is unknown. |
| `message` | string | always | The error text followed by the action, for a reader that shows only this field. |
| `action` | string | always | The next step, naming the tool or field to change. It never restates the error. |
| `operationPerformed` | `false`, `true`, or `null` | always | `false`: refused before anything was written. `true`: it ran, as `action` describes. `null`: unknown, reported only for `INTERNAL` (or a code this version does not know); read the target before retrying. |
| `retryAfterSeconds` | integer | rate limits only | Whole seconds to wait before retrying, rounded up, when the Hub sent one. |
| `repeat_count` | integer | third identical failure on | How many times in a row this session sent this exact call and it failed. `action` then starts with an instruction to stop. |

Some codes add their own fields: `reorientRequired` for authority and
orientation failures, `currentHash`, `currentContentHash`, `currentMetaHash`,
and `currentContent` for a stale-hash `CONFLICT`, `node_id` and `missing` for
`PRECONDITION_REQUIRED`, and `kegv` for a KEG created at another version.
Clients ignore fields they do not know.

Input the caller can fix (a malformed query expression, node id, settings
document, base64 payload, or guide topic) is `INVALID` with
`operationPerformed: false`. A failure in stored state the caller did not send,
such as a KEG settings index query saved before edits were validated, stays
`INTERNAL`. A Hub `CONFLICT`, such as a flight or agent name already taken, is
a refusal with `operationPerformed: false`. A KEG tool called while the
session's configured flight could not be activated is `ORIENTATION_DENIED`, and
its action says to repair the selection and call `session_refresh`, since
naming another flight per call cannot unlock it.

A host that wraps this server, such as a Hub serving MCP, reports its own
classification by setting these fields on the same object, keeping
`repeat_count` and `message`, rather than adding a second block.

Two arguments left the schemas because another one says the same thing.
Calls that still send them keep working; neither is advertised again:

- `id_only` on `node_list`, `node_grep`, `tag_list`, `node_backlinks`, and
`node_links` is `format: "%i"`, which prints the same lines. A true
`id_only` overrides any `format` sent with it, as it always did. The CLI
keeps `--id-only` as shorthand for `--format %i`.
- `keg` on `node_search` is searched as the one KEG named, bare names
included. New calls name KEGs in `kegs`.

### Read

| Tool | Description |
| --- | --- |
| `node_read` | Read content of one or more nodes |
| `node_list` | List nodes with optional query filtering |
| `node_search` | Rank nodes by relevance to a query across every readable KEG, one `keg`, or listed `kegs`; pages with `next_cursor` |
| `node_grep` | Match node content lines against a regex in one KEG |
| `node_search` | Rank nodes by relevance to a query across every KEG the call's flight covers, or only the listed `kegs` (each inside that cover); pages with `next_cursor` |
| `node_grep` | Match node content lines against a PostgreSQL regular expression in one KEG |
| `tag_list` | List tags or find nodes by query |
| `node_backlinks` | Find nodes linking to a node |
| `node_links` | List outgoing links from a node |
Expand All @@ -112,8 +196,8 @@ explicitly configured root failed to initialize.

| Tool | Description |
| --- | --- |
| `node_create` | Atomically create 1–100 nodes from `nodes[]`, each a markdown `content` document plus an optional YAML `meta` document; unique keys support forward/backward `{{node:key}}` content references |
| `node_edit` | Read with `node_read`, then atomically replace `content`, `meta`, or both for 1–100 nodes from `nodes[]`; supplied `content` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`); editing one part never invalidates the other's hash |
| `node_create` | Atomically create 1–100 nodes from `nodes[]`, each a markdown `content` document plus an optional YAML `meta` document; unique keys support forward/backward `{{node:key}}` content references, each replaced with the bare id, so a link is `[Title](../{{node:key}})` (a placeholder that is a whole link target gets the `../` added) |
| `node_edit` | Read with `node_read`, then atomically replace `content` (or apply a `patch`), `meta`, or both for 1–100 nodes from `nodes[]`; supplied `content` or `patch` requires `expected_content_hash` (the read's `content_hash`) and supplied `meta` requires `expected_meta_hash` (its `meta_hash`); editing one part never invalidates the other's hash |
| `node_delete` | Read with `node_read`, then atomically delete 1–100 `nodes[]`; every item requires its own `expected_hash` (the combined `hash` from `node_read`) |
| `node_move` | Read with `node_read`, then move a node using its required `expected_hash` (the combined `hash` from `node_read`) |
| `keg_settings_edit` | Read the full document with `keg_settings_read`, then replace it using its required `expected_hash`; requires admin flight authority and editor/admin KEG access |
Expand Down Expand Up @@ -160,14 +244,40 @@ and, when practical, current content; node conflicts also return
`currentContentHash` and `currentMetaHash`. Merge or refetch, then retry with
the current hash for what you are writing.

A protected mutation sent without its token fails with `PRECONDITION_REQUIRED`
and writes nothing. The message names the missing field and the read that
fills it. For `node_edit`, `node_merge`'s target included, the error lists every
missing token at once in `missing`, carries the `node_id`, and its `action` is
the call to make, such as
`node_read(keg="notes", node_id="12")` then `content_hash as expected_content_hash`.
A `node_edit` item that supplies none of `content`, `patch`, or `meta` fails
with `INVALID` and an action naming the field to add; new nodes go through
`node_create`.

`schema` on `node_create`, `node_edit`, and `node_merge` is a schema type name
from `schema_list`, such as `task`: one word of at most 64 characters, with no
spaces or path separators. The input schema carries that rule as a `pattern`
and `maxLength`, so a sentence is refused with `INVALID_ARGUMENT` before the
call runs, and every backend applies the same rule. A well-formed name the keg
does not define fails with `SCHEMA_INVALID`, and the message lists the types the
keg does define.

A tool call that fails three times in a row with identical arguments in the
same session keeps its original `code` and gains `repeat_count` and an
`action` that says to stop retrying it, followed by the original action: read
the target or change the arguments the error names. Arguments are compared
as the client sent them, `flight` included, independent of key order and
whitespace: the same call under another flight is a different call. A success,
or a call with different arguments, starts that tool's count again.

### Index, Diagnostics, And Safety

| Tool | Description |
| --- | --- |
| `index_rebuild`, `index_list`, `index_read` | Rebuild or inspect indexes |
| `keg_check` | Check only the selected keg's health (not local Tapper configuration) |
| `snapshot_list`, `snapshot_create`, `snapshot_read`, `snapshot_restore` | Manage node snapshots; `snapshot_create` accepts 1–100 nodes atomically |
| `lock_acquire`, `lock_release`, `lock_status`, `lock_force_release` | Coordinate cross-process node locks |
| `lock_acquire`, `lock_release`, `lock_status`, `lock_force_release` | Coordinate Hub-held node locks; `lock_acquire` waits up to `wait_seconds` (default 10, max 60) for a held lock |

### Files And Images

Expand Down Expand Up @@ -240,7 +350,7 @@ can read; an agent with no flight has no KEG access.
| `flight_create`, `flight_edit`, `flight_delete` | Manage Hub-backed flights when the session agent holds the tool and the identity owns/administers the target namespace; edits/deletes require the hash from `flight_read` |

MCP does not expose Tapper configuration, config templates, repository setup,
archive import/export, raw auth status, license text, keg visibility, or
archive export, raw auth status, license text, keg visibility, or
namespace administration. Those remain external CLI, configuration, or Hub UI
operations. To put a node's content in another KEG, read it with `node_read` and
`node_create` it there.
Expand Down Expand Up @@ -377,3 +487,19 @@ Device-login verification URLs must be HTTP(S) and have the same origin
credentials in URLs, and cross-origin verification addresses are refused before
the browser callback. Windows displays the URL for manual opening instead of
passing it through `cmd.exe`.

## Local attachment transfer boundary

Local MCP `source_path` and `file:` upload sources must resolve to regular files
inside the server's working directory. Parent traversal and symlink escapes are
rejected. Download destinations must be explicit paths inside that directory
with an existing parent, and must not already exist; files are created with
exclusive creation and owner-only permissions. Environment expansion cannot
change a validated path on a later filesystem call. The local `image_download`
tool is marked as mutating because a destination writes a file. Hosted tools
continue to exchange attachment bytes without access to host paths.

Start the MCP process in a project directory that contains only the files you
intend it to transfer. The boundary is its working directory, not the selected
KEG or a host-reported roots list. The CLI's explicitly requested local file
operations retain their existing path and overwrite behavior.
7 changes: 7 additions & 0 deletions docs/ai-coding-agents/orient.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ The payload always starts with KEG system context:
5. Canonical Tapper agent guidance: linking, snapshots, tool inventory, and
troubleshooting.

When `TAP_AGENT` names a Hub agent (a `tap launch --agent` session's
`tap mcp`, or `tap orient` run in that environment), the payload names the
agent and says its tool allowlist and flight govern the session. It never
claims the identity's full authority, even with no flight: an agent holds no
credentials of its own, and Hub narrows its requests to the flight `tap mcp`
sends.

Orient is best-effort. If a configured hub is unreachable or a selected flight
cannot be resolved, the payload still renders the KEG system basics and includes
a warning about what was skipped.
Expand Down
17 changes: 17 additions & 0 deletions docs/ai-coding-agents/relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,23 @@ wherever those commands are.
- It runs in the foreground until interrupted and reconnects if the connection
drops. Disconnecting the relay from a Hub's Account → Relay page stops it
for that Hub; run it again to reconnect.
- On relay protocol 4, Hub tells the relay how long its login token has
left, and the relay refreshes the token and presents it on the live
connection (a `reauth` frame) at 80% of that time. The timing counts from
when the relay heard it, not from either machine's clock, so clock skew does
not matter, and requests in flight are not cut at the token's expiry. A Hub
without protocol 4 never sees a `reauth`.
- If the token does expire, Hub ends the connection with
`credential_expired`. The relay forces a refresh and reconnects at once; if
Hub refuses a dial's token, the relay forces one more refresh before giving
up. A failed refresh (the Hub unreachable, say) is retried with backoff.
- When Hub restarts it ends the connection with `going_away`, and the relay
reconnects within a second.
- A revoked sign-in is final: Hub ends the connection with
`credential_revoked` (`disconnected` before protocol 4), or refuses the
next dial, and the relay stops serving that Hub and says to run
`tap auth login`. An end code the relay does not know reconnects with
backoff.

## Several hubs

Expand Down
Loading
Loading