NetDocuments browse + ids — implementation plan
Status: In progress · Author: Fable (lane FEAT netdocs browse ids) · Date: 2026-09-07
Repo: north-os — packages/connector-netdocs-mcp, packages/agent-runtime, apps/mcp-server, packages/analytics
Related: specs/2026-08-17-netdocuments-mcp-federation-design (§7 layer 2 revised by this lane) · triage docs/reviews/2026-09-07-partner-netdocs-gating-triage lane L5 · PR #216 (source of the cherry-picked pieces, untouched)
1. Why
The partner's session showed the federation's structural limits: no folder browsing, no matter or document ids. Search is keyword-only and the model cannot walk a cabinet → workspace → folder tree or resolve a matter name to a workspace. This lane adds two read-only tools on the existing netdocs_mcp connection, using NetDocuments' REST API with the token the federation already leases (spec §3.2 P3 verified that the MCP-obtained token answers GET /v1/user/cabinets).
2. Scope and cap
- Two tools, both surfaces: product toolbelt (
netdocs_browse, netdocs_workspace_lookup) and MCP catalogue (north__netdocs_browse, north__netdocs_workspace_lookup).
- Read-only means read-only: one HTTP function in the codebase issues NetDocuments REST requests, its method is a literal
GET, pinned by test. No write scope, no token use outside the request, no second credential path (the lease from prepareOperation/runWithRecovery is the only token source).
- Cap: 500 lines of product code across the omp worker's files. No schema change, no new dependency, no new connector, no change to
apps/mcp-server/src/mcp-app.ts (owned by another live lane).
- Out of scope: filing back, watching, bulk ingestion, matter-aware planning (M2), REST connector revival.
3. Design
3.1 REST layer — packages/connector-netdocs-mcp/src/rest.ts
NETDOCS_REST_METHOD = "GET" and one private restGet(context, path): bearer from context.accessToken, context.limiter.consume before the request, per-request timeout (requestTimeoutMs, default 6 s) inside deadlineMs, response cap 1 MB, JSON parse, token redaction of every string reaching the caller (reuse the client's representation list), status mapping 401 → unauthorized, 429 → rate_limited (provider), 404 → invalid_input, other non-2xx / network → unavailable. Off-host paths refused.
- Base URL is an input (
restBaseUrl), read by agent-runtime from NETDOCS_REST_BASE_URL (optional, HTTPS only, default https://api.vault.netvoyage.com).
- Three exported operations, all returning
NetDocsResult<…>: listCabinets (GET /v1/user/cabinets), listContainer (GET /v2/container/{id}?select=standardAttributes&top=100[&skiptoken=…], one page per call, continuation bound with the client's existing bound-page-token helpers), lookupWorkspaces (GET /v1/attributes/{repositoryId}/{wsAttrNum}, entries filtered client-side, capped).
- Id validators cherry-picked from PR #216
ids.ts: cabinet NG-, repository CA-, item ids 12–16 chars trimmed. Container item parsing cherry-picked from walk.ts (readItemType, v1 standardAttributes and v2 DocId/Attributes shapes), hand-parsed like client.ts (the package has no zod).
3.2 Tools — packages/agent-runtime/src/tools/netdocs.ts
Both tools reuse runToolBoundary → prepareOperation → runWithRecovery → settledToolResult exactly like netDocsSearchTool; NetDocsOperation and the analytics netdocs.search_settled.operation enum gain browse and workspace_lookup.
netdocs_browse — input { container_id?, next_page_token? }. No id → cabinets (id, name, repository_id). With an id (cabinet, workspace or folder) → one page of children: id, env_id when present, type (workspace | folder | document), name, and for documents extension, modified, size_bytes; next_page_token when more. One GET per call.
netdocs_workspace_lookup — input { query (1–200 chars), cabinet_id? }. Cabinets → the named cabinet, or every cabinet up to 6 → lookup table per cabinet → entries whose name/description/value contain every query token (case-insensitive) → at most 20 matches (workspace_id, name, cabinet_id). Exactly one match → one extra GET for the workspace's first page of child containers (its folders). Hard cap 10 GETs per invocation (spec §4.4).
- Receipts follow the family: empty results are explicit (“0 workspaces matched … in N cabinets”), never
RESULT: ABSENT; every failure goes through renderFailure.
3.3 MCP surface — apps/mcp-server/src/tools/netdocs-browse.ts, netdocs-workspace-lookup.ts
Same shape as netdocs-search.ts: family: "netdocs", readOnlyHint: true, openWorldHint: true, redactArgs to counts and booleans only, handler = assertMcpToolAccess + invokeNetDocsTool. Registered in NETDOCS_MCP_TOOL_DEFS and the read catalogue; the existing family gating (pilot, posture, kill switch, syncNetDocs) then applies unchanged.
3.4 Prose
The four descriptions live in packages/connector-netdocs-mcp/src/browse-copy.ts (Fable worker), exported constants imported by both surfaces. MCP descriptions must satisfy the catalogue test: mention federation, personal and firm-wide, and end with NETDOCS_UNTRUSTED_DATA_NOTICE.
4. Guard tests (each with its mutation clause)
- Method pin (
test/rest.test.ts): a recording fetchImpl sees every request of all three operations with method === "GET" and no body. Mutation: change the literal to POST → red.
- Single HTTP site (
test/surface-guards.test.ts): source scan of src/*.ts: method: occurs only in rest.ts and only as "GET"; the package's public exports are exactly the approved names (no generic request function leaks). Mutation: export restGet or add method: "POST" anywhere → red.
- Family membership, product surface (
netdocs-capability.test.ts): netDocsTools names include both new tools; toolsWithNetDocsCapability(base, undefined) exposes neither; the default toolbelt never contains them. Mutation: drop one from netDocsTools → red.
- Family membership, MCP surface (
netdocs-tools.test.ts): NETDOCS_MCP_TOOL_NAMES includes north__netdocs_browse and north__netdocs_workspace_lookup; the existing remove/restore, pilot and kill-switch loops then cover both. Mutation: unregister one def → red.
- Sub-call cap: a lookup across 8 cabinets issues at most 10 GETs and reports
truncated. Mutation: raise the cabinet cap → red.
- Token custody: a 401 on a REST read triggers the same refresh path as search (one refresh, then re-mark), asserted through the existing
netdocs.test.ts connectors-api mock. Mutation: bypass runWithRecovery → red.
5. Assumption ledger
| # | Assumption | Status | Falsifier |
| B-1 | The MCP-leased token is accepted by the REST API for cabinets, container and attribute reads | Verified for /v1/user/info and /v1/user/cabinets (spec P3, 2026-08-17); container and attributes not probed | One browse call on preprod after deploy; 401/403 on either path flips it |
| B-2 | /v2/container/{id} returns Results + SkipToken with v1 standardAttributes or v2 DocId/Attributes items (PR #216 walk, verified live there for the walk) | Carried over, UNVERIFIED-LIVE in this repo | Same probe; a shape refusal renders as provider_format_unsupported, never as absence |
| B-3 | The cabinet's wsAttrNum lookup table lists workspaces with an id usable as a container id (PR #216 A-26) | Unverified | Lookup then browse of a returned id; a 404 on browse flips it and the tool degrades to matches without folders |
| B-4 | The document id netdocs_fetch accepts equals the REST item's envId (v1) / EnvId (v2) when present, else its id | Unverified | Browse a folder, fetch one returned id; the description tells the model which to try first |
6. Workers and file boundaries
- omp (code):
packages/connector-netdocs-mcp/src/{rest.ts,types.ts,index.ts}, test/{rest.test.ts,surface-guards.test.ts}; packages/agent-runtime/src/tools/netdocs.ts + tests; packages/analytics/src/index.ts (one union); apps/mcp-server/src/tools/{netdocs-browse.ts,netdocs-workspace-lookup.ts}, src/tools.ts, tests.
- Fable (prose):
packages/connector-netdocs-mcp/src/browse-copy.ts only.
- Orchestrator: this page, spec §7 layer 2, PR, gates, review loop.
6b. Decisions taken during implementation and review (2026-09-07)
Four departures from §3, each made deliberately and each carried through the dual review.
- The workspace lookup does not fetch a single match's folders. §3.2 asked for one extra GET on an exact match. It was cut so the tool's provider budget stays bounded, and the receipt now tells the model to browse the workspace id instead. Both reviewers found the tool descriptions still promising folders; the copy was corrected, not the code.
- The sub-call cap is real, not emergent. The first implementation reached seven GETs only on the happy path: credential recovery re-runs the whole operation up to three times, so a lookup could issue twenty-one. Both reviewers called it a blocker against the federation spec's limit of ten per invocation. An invocation-scoped counter that survives recovery replays now enforces it, and the receipt says when a read was cut short.
- Continuation tokens are bound after all. They were briefly left unbound with a comment saying so; both reviewers objected that it diverges from the search family for no gain, so they now use the same bound-token helpers.
- Container items parse in both provider shapes. Narrowing to the v2 shape alone would have made an unverified assumption load-bearing, so the v1 shape is accepted too, and an unparseable row is skipped and counted rather than failing the whole page.
Size. The finished implementation is about 840 lines of product code against the 500-line cap set before the work. Nothing was cut to fit: the overage is the transport's own safety work (redaction, host containment, deadline and byte caps, error mapping) plus two tools rather than one. Recorded here and in the pull request rather than absorbed silently.
Filed as follow-ups, not fixed here (pre-existing, outside this diff): the provider-catalogue check accepts a tool that merely omits readOnlyHint instead of requiring it to be true; and an already-open MCP session's catalogue is not re-gated on the kill switch or pilot removal, only on authorization posture.
7. Review and gates
Fresh omp (gpt-5.6-sol, high) and fresh Fable reviewers, no implementation context, mutation clause on every assertion the diff adds or touches, deletion audit as its own pass; two rounds then BLOCKED. Gates: root lint and typecheck, suites of connector-netdocs-mcp, agent-runtime, mcp-server, analytics.
Shortcuts named: no separate cross-model plan review (the dispatcher's brief fixed the design; the dual code review is the check); no live NetDocuments probe from this lane (preprod is read-only for lanes and no synthetic NetDocuments account exists), so B-1…B-4 stay open until the first deployed call.