Skip to content

feat(tools)!: carry MCP credentials as profile config references - #355

Merged
drewstone merged 1 commit into
mainfrom
fix/mcp-config-values
Jul 31, 2026
Merged

feat(tools)!: carry MCP credentials as profile config references#355
drewstone merged 1 commit into
mainfrom
fix/mcp-config-values

Conversation

@drewstone

Copy link
Copy Markdown
Contributor

Fixes #354. Breaking — warrants 0.46.0. Do not publish before reading "Ship order" below.

What was wrong

buildHttpMcpServer inlined the capability token as a plain string (Authorization: Bearer <token>). agent-interface retired that shape at 0.38.0: args, env, and headers values must now be AgentProfileConfigValue — either defineAgentProfilePublicConfig(value) or defineAgentProfileSecretRef(key, format?). Profiles are digested, diffed, stored and logged, so a credential riding one as public material is a leak by construction.

Because /tools is consumed by 10 of 11 products, the substrate everyone adopts emitted exactly the shape the contract rejects — and every consumer that bumped had to hand-write an adapter. gtm-agent already did (89 lines) after its production chat went down for ~25 days.

Independent proof the cast was hiding a real break

Baseline origin/main + agent-interface 0.40.0 + zero source changes:

src/sandbox/index.ts(457,20): error TS2352: Conversion of type 'AppToolMcpServer'
to type 'AgentProfileMcpServer' may be a mistake

That is the as AgentProfileMcpServer cast named in the issue. It is removed here.

The design decision that matters

buildHttpMcpServer now takes tokenEnvKey — the name of a box environment variable — never a token value. A secret-ref resolves only inside the private prepared executor, and the value channel onto a box is the create payload's env.

I deliberately did not widen SandboxRuntimeConfig.env to the tagged type, contrary to item 4 in the issue. That would be a category error: the sandbox SDK types the create payload env as Record<string,string> in both 0.15.2 and the 0.38.0-based 0.15.3 prerelease, and assertEnvWithinLimits measures those bytes against the kernel's MAX_ARG_STRLEN. The env map is the private resolution source — tagging it would make it reference itself. Documented on the seam instead.

Because box env is written once and shared across turns, only a deterministic value is referenceable. createCapabilityToken is a bare HMAC, so the value written at creation equals what any later turn mints; a test pins that property.

I did not port gtm's withReferencedAuthorization. It exists solely to rewrite this package's bad output — tagging at the source makes it dead code, and shipping it would preserve a plain-string path. unresolvableSurfaceCredential is contributed down from gtm, because naming the per-(user, resource) credential blocker is genuinely reusable.

Verification

Real validators, real payloads, both versions:

0.36.0  http plain-string          ACCEPTED
0.36.0  http tagged                REJECTED
0.40.0  http plain-string          REJECTED
0.40.0  http tagged                ACCEPTED
0.40.0  http public Authorization  REJECTED  (credential-bearing names require a secret-ref)
0.40.0  stdio plain                REJECTED
0.40.0  stdio tagged               ACCEPTED

Baseline vs branch, both with dist/ built — identical 18-file failing list, no regression:

BASELINE  Tests  157 failed | 3706 passed | 13 skipped (3876)
BRANCH    Tests  157 failed | 3714 passed | 13 skipped (3884)

All 157 pre-existing failures are environmental on this machine (better-sqlite3 native bindings unbuilt; the scaffolder install fails identically on clean origin/main). typecheck clean · test:gates 289 passed · knip exit 0 · build exit 0 · merges cleanly into main.

Break-restore proof (the new tests must be able to fail). Tagging reverted to plain strings with the schema check stubbed:

× accepts what buildAppToolMcpServer emits now
× refuses a credential-bearing custom header name carrying a public value
× refuses a relative base URL the sandbox could never dial
× refuses a token value passed where the box-env key name belongs
     Tests  4 failed | 1 passed

Restored: 5 passed.

A CI regression this introduced, found and fixed

origin/main builds at CI's 8192 MB heap; this branch OOMs there. Isolated in a throwaway worktree: baseline + 0.40.0 + a one-line cast builds fine at 8192, so the cost is the source change, not the version bumpAgentProfileConfigValue reaches five entry points, so rollup-dts inlines agent-interface's schema graph into each. Measured: 8192 ERR_WORKER_OUT_OF_MEMORY, 10240 green at 9.4 GB peak RSS. Raised the three workflows to 12288, which leaves ~4 GB headroom on a 16 GB runner, with the measurement recorded in the comment.

Peer range: left at >=0.36.0 on purpose

It should be >=0.38.0. Tightening it today makes the graph unsatisfiable: agent-runtime@0.109.2 peers <0.37.0 and agent-profile-materialize@0.9.3 peers <0.38.0.

The gap is covered without a broken install: every builder now runs the resolved agentProfileMcpServerSchema, so a consumer on a pre-0.38.0 agent-interface fails loudly and by name at build time rather than silently pairing.

Ship order — read before publishing

This branch makes agent-app contract-correct. It is not runnable end to end until the platform ships resolution, so publishing 0.46.0 alone would hand consumers a shape the deployed platform currently rejects.

Blocking, in order:

  1. agent-dev-container — the deployed sidecar bundle still enforces the pre-0.38.0 contract even though main is on 0.38.0 (tangle-network/agent-dev-container#4524, with a measured truth table and a 60-second reproduction).
  2. agent-profile-materialize — must widen its peer and implement ref resolution. The published 0.9.3 writes MCP headers verbatim into the harness config, so a ref object would land in the config file as-is. ADC's workspace copy does resolve (sandboxAgentProfileSecretProvider), so this is a publish gap, not a design gap.
  3. agent-runtime — widen the agent-interface peer.
  4. sandbox0.15.3-develop already deps 0.38.0; needs a stable release.

Then agent-app 0.46.0, then consumers.

Unverified

No live sandbox probe from this branch. The claim about what the deployed platform accepts rests on the separate measured reproduction in ADC#4524, not on a request issued from here.

`@tangle-network/agent-interface` 0.38.0 replaced plain strings on an
`AgentProfileMcpServer` with tagged `AgentProfileConfigValue`s. Verified
by running both validators against real payloads:

  payload                        0.36.0     0.40.0
  headers Authorization string   ACCEPTED   REJECTED
  headers Authorization tagged   REJECTED   ACCEPTED
  headers Authorization public   REJECTED   REJECTED (credential-bearing
                                            config names require a secret-ref)

agent-app emitted the old shape, so every consumer that bumped
agent-interface had to hand-write an adapter (gtm-agent wrote 89 lines)
and origin/main does not even COMPILE against 0.38.0+ — the
`as AgentProfileMcpServer` cast at src/sandbox/index.ts:464 becomes
TS2352 the moment the type is real.

`Authorization` now emits `defineAgentProfileSecretRef(tokenEnvKey,
'bearer')`; every other header emits `defineAgentProfilePublicConfig`.
A secret reference resolves ONLY from an environment variable on the box,
so the builders take the box-env variable NAME (`tokenEnvKey`) rather
than a token VALUE — agent-app cannot guess that name, and a reference to
a key nothing places fails at materialization instead of running
credential-less. `SandboxRuntimeConfig.env` stays `Record<string,string>`
on purpose: it is the private resolution SOURCE, the SDK create payload
types it that way, and `assertEnvWithinLimits` measures those bytes.

Every emitted entry is validated against the real
`agentProfileMcpServerSchema` instead of a re-implemented copy of its
rules, which also turns a pre-0.38.0 resolution into a loud named failure
rather than a silent pairing.

`unresolvableSurfaceCredential` is exported for the case the substrate
still cannot serve: a per-(user, resource) token cannot be placed on a
workspace-wide box env, so design-canvas / sequences channels that need
one name that blocker instead of emitting an unresolvable reference.

BREAKING CHANGE: `buildHttpMcpServer`, `buildAppToolMcpServer`,
`buildScopedMcpServerEntry`, `buildAppToolMcpServers`,
`buildDesignCanvasMcpServerEntry` and `buildSequencesMcpServerEntry` take
`tokenEnvKey` (a box-env variable name) instead of `token` (a value), and
`AppToolMcpServer.headers` is now
`Record<string, AgentProfileConfigValue>`. There is deliberately no
plain-string branch: accepting one would re-admit raw credentials into
profile material, which is what the 0.38.0 contract exists to prevent.

The peer range stays `>=0.36.0`: agent-runtime@0.109.2 peers
`>=0.36.0 <0.37.0` and agent-profile-materialize@0.9.3 peers `<0.38.0`,
so tightening it today would make the graph unsatisfiable for the ten
products that install them. The build-time schema check is the guard
until those republish.

CI heap 8192 -> 12288: `AgentProfileConfigValue` reaches five entry
points, so rollup-dts inlines agent-interface's schema graph into each.
Measured here: 8192 ERR_WORKER_OUT_OF_MEMORY, 10240 green at 9.4 GB peak
RSS / 38 s.

@tangletools tangletools left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Auto-approved drewstone PR — 5e2108c7

This PR was opened by the trusted drewstone account.
The full PR reviewer audit still runs separately and will publish findings if it detects issues.

tangletools · auto-approval · reason: drewstone_author · 2026-07-31T06:47:55Z

@tangletools tangletools left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Value Audit — sound

Verdict sound
Concerns 1 (1 low)
Heuristic 0.0s
Duplication 0.0s
Interrogation 125.8s (2 bridge agents)
Total 125.8s

💰 Value — sound

Forced migration: MCP server headers stop inlining Bearer <token> and now carry tagged secret-refs/public-config per agent-interface 0.38.0 — done correctly, reusing the real schema validator and the existing deterministic capability token, with no existing equivalent to duplicate.

  • What it does: buildHttpMcpServer/buildAppToolMcpServer/buildScopedMcpServerEntry/buildAppToolMcpServers and the per-domain wrappers now take tokenEnvKey (the NAME of a box-env variable) instead of token (a value). The emitted Authorization header is defineAgentProfileSecretRef(key,'bearer'); every other header is defineAgentProfilePublicConfig(...). AppToolMcpServer.headers widens from `Reco
  • Goals it achieves: (1) Stop leaking the capability token through profile material — an AgentProfile is digested, diffed, stored, and logged, so Bearer <token> riding it is a credential leak by construction; agent-interface 0.38.0 retired exactly this shape. (2) Make the package actually compile: origin/main does not typecheck against the now-pinned agent-interface 0.40.0 (the removed cast becomes TS2352). (3) Kill
  • Assessment: This is a forced upstream migration, not an optional refactor, and it is executed in the grain of the codebase. The design decisions are all the right ones: it validates against the REAL shipped schema instead of re-implementing its rules (which would drift — 0.36→0.40 in three days, per the comment at src/tools/mcp.ts:94-108); it reuses the existing createCapabilityToken (src/tools/capability.t
  • Better / existing approach: none — this is the right approach. Searched for an existing equivalent: rg defineAgentProfileSecretRef|AgentProfileConfigValue src/ returns matches ONLY in the new src/tools/mcp.ts, so nothing in the codebase already emits tagged MCP config that this could extend instead. The alternative of keeping a plain-string token branch was considered and correctly rejected (it re-admits the leak the ups
  • Model: opencode/zai-coding-plan/glm-5.2
  • Bridge attempts: 2
  • Bridge warning: opencode/kimi-for-coding/k2p7: bridge stream ended without value-audit content

🎯 Usefulness — sound

Forced alignment with agent-interface 0.38.0's tagged-config contract: the three MCP builders now emit secret-refs instead of inlined tokens, validated against the real schema, with the credential channel moved to box-env key names.

  • Integration: Fully reachable — buildHttpMcpServer/buildAppToolMcpServer/buildScopedMcpServerEntry are the SOLE producers of MCP server entries in this package, consumed by buildAppToolMcpServers (src/sandbox/index.ts:482, the main provisioning path), buildSequencesMcpServerEntry (src/sequences/mcp-entry.ts:24), buildDesignCanvasMcpServerEntry (src/design-canvas/mcp-entry.ts:24), and the surface-overlay mechani
  • Fit with existing patterns: Matches the codebase grain exactly. This is forced work: the PR body proves baseline origin/main fails TS2352 against agent-interface 0.40.0 (the removed as AgentProfileMcpServer cast), so the package was emitting a shape the schema rejects. The design validates each entry against the REAL agentProfileMcpServerSchema (src/tools/mcp.ts:109) rather than re-implementing the contract's rules — the a
  • Real-world viability: Robust across error paths. assertSecretEnvKey (mcp.ts:82) catches a token value passed where a key belongs; assertProfileMcpServer (mcp.ts:109) catches version skew, credential-bearing custom header names, relative URLs, and Bearer bytes — all fail-loud at build time. The deterministic-token requirement is documented AND tested: surface-profile.test.ts:86-91 proves createCapabilityToken is idempot
  • Model: opencode/zai-coding-plan/glm-5.2
  • Bridge attempts: 1

🔎 Heuristic Signals

🟡 Cruft: commented out code src/sandbox/index.test.ts

  • // let the pre-0.38.0 plain-string shape ship. Validating the returned map

What this audit checks

It judges the change on its merits — not whether it was tasked out in an issue. Unticketed, fast-moving work is fine; the question is whether the change is good and whether a better or existing approach should be used instead.

Pass What it asks
Heuristic Vague title? Whitespace-only or cruft-bearing diff? (content signals only)
Duplication Do added function/class names already exist elsewhere in the repo?
Value Audit What does it do? What goal does it achieve? Is it good? Better architecture or already-exists?
Usefulness Audit Does it integrate and fit? Will it hold up in real use and actually get used?

Findings are concerns, not blocks — the human reviewer decides what to do with them.

value-audit · 20260731T065102Z

@tangletools

Copy link
Copy Markdown

✅ No Blockers — 5e2108c7

Review health 100/100 · Reviewer score 71/100 · Confidence 95/100 · 8 findings (8 low)

glm: Correctness 71 · Security 71 · Testing 71 · Architecture 71

Reviewer score is advisory once the run is complete and the verdict has no blockers.

Full multi-shot audit completed 8/8 planned shots over 22 changed files. Global verifier still owns final merge decision.

🟡 LOW Dual-instance of @tangle-network/agent-interface introduced (0.36.0 + 0.40.0) — pnpm-lock.yaml

agent-knowledge@6.1.11 hard-pins '@tangle-network/agent-interface': 0.36.0 in its dependencies (lock line 4637). After this PR, agent-app direct + agent-runtime + agent-profile-materialize + sandbox-ui all resolve to 0.40.0, but agent-knowledge still pulls 0.36.0. Result: two copies of agent-interface are installed side-by-side where previously only one existed (verified via git show on base: only 0.36.0 present pre-PR). Any type crossing the knowledge->runtime boundary (e.g. a schema instance, error class, or config object exported from agent-interface) can fail instanceof checks or reject schema equality. The PR's own motivation (per AGENTS.md, agent-interface >= 0.38.0 enabl

🟡 LOW Doc example pairs workspace-id HMAC with a userId-keyed primitive — src/runtime/surface-profile.ts

The new prose says 'an HMAC over the workspace id, e.g. createCapabilityToken in ../tools', but createCapabilityToken (src/tools/capability.ts:107) HMACs user:<userId>, not a workspace id. The 'e.g.' is illustrative of the determinism pattern rather than the exact input, so this is a cosmetic imprecision in a doc-only change. Fix optionally by writing 'an HMAC over a stable id (e.g. createCapabilityToken over the user id)' for precision.

🟡 LOW New schema-acceptance test only asserts success, not the tagged shape — src/sandbox/index.test.ts

The test asserts safeParse(…).success === true, which proves the shape is acceptable but does not positively verify that the emitted Authorization header is a kind:'secret-ref' (the new contract) rather than some other valid form. A stronger assertion would be entries['app-propose'].headers.Authorization.kind === 'secret-ref' — catches a regression where the builder silently emits a different valid shape. Not blocking: the underlying buildHttpMcpServer already constructs the secret-ref explicitly and runs assertProfileMcpServer, so the positive assertion is defense-in-depth only.

🟡 LOW Empty/whitespace tokenEnvKey handling is split across two checks with divergent messages — src/tools/mcp.ts

Line 257 gates on opts.tokenEnvKey.trim().length === 0 and throws 'requires a capability token env key — omit the MCP server…'. A whitespace-padded key like " APP_TOKEN " passes that gate (trim length > 0) and then fails assertSecretEnvKey at line 282 with a different message ('must be an environment-variable NAME…'). The two-step produces confusing UX for the same class of bad input. Note buildHttpMcpServer (line 186) has NO empty-check at

🟡 LOW assertProfileMcpServer runs the full z.union schema on every builder call — src/tools/mcp.ts

agentProfileMcpServerSchema.safeParse(server) runs the z.union([local, remote, disabled]) validator (profile-schema.js:235) on every emitted entry. The shape is already statically guaranteed by AppToolMcpServer and the tag constructors, so for a product that emits a handful of MCP entries per turn the cost is microseconds — negligible in absolute terms. The runtime check is justified for catching version skew against pre-0.38.0 agent-interface (documented at mcp.ts:104-107) and is the right call, but worth noting it is non-zero per-call work that the type system already covers in the steady state.

🟡 LOW registry.test.ts (in-shot) does not exercise the new negative paths or the scoped/no-ctx branch — src/tools/registry.test.ts

The in-shot test file only updates the positive path (token→tokenEnvKey rename + secret-ref shape assertion, lines 112-125) and the pre-existing no-path throw (lines 126-131). The new negative paths — assertSecretEnvKey rejecting a token value, assertProfileMcpServer rejecting a relative URL or credential-bearing custom header, the buildScopedMcpServerEntry no-ctx branch, and unresolvableSurfaceCredential — are NOT covered in this file. They ARE covered in tests/tools.test.ts:320-388 (inside the PR but outside this shot's file

🟡 LOW assertSecretEnvKey not exercised via the sequences entry path — tests/sequences/mcp-server.test.ts

The 'fails closed on a missing token env key' test only passes tokenEnvKey:' ' (whitespace), which is caught by the earlier opts.tokenEnvKey.trim().length === 0 guard in buildScopedMcpServerEntry (src/tools/mcp.ts:257) BEFORE assertSecretEnvKey runs. So the POSIX-name validation (ENV_NAME_PATTERN at src/tools/mcp.ts:73) is never reached through the sequences entry path in this file. A tokenEnvKey like 'has space' or '$invalid' would exercise that guard but is not tested here. This is covered in tests/tools.test.ts (out of scope), so it is a coverage delegation, not a hole. No action required for merge; adding one assertion here would make the sequences entry self-contained.

🟡 LOW Acceptance test swallows the zod rejection reason on failure — tests/tools.test.ts

expect(parsed.success).toBe(true) reports only 'expected true, received false' if a future buildAppToolMcpServer regression breaks the shape — the zod issue list (which names the offending field) is discarded. This is the single most useful diagnostic in the whole calibration suite, because it's the test that ties the builder's output to the live contract; a future failure here without the issue text forces a reproducer. Cheap fix: on failure, throw with parsed.error.issues.map(i => ${i.path}: ${i.message}).join('; ') (the same shape assertProfileMcpServer already formats in mcp.ts:112-114). Non-blocking — the assertion is correct and meaningful today.


tangletools · 2026-07-31T07:07:35Z · trace

@tangletools tangletools left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Approved — 8 non-blocking findings — 5e2108c7

Full multi-shot audit completed 8/8 planned shots over 22 changed files. Global verifier still owns final merge decision.

Full immutable report for this review: trace

Summary comment for this run: full summary


tangletools · 2026-07-31T07:07:35Z · immutable trace

@drewstone
drewstone merged commit af938b2 into main Jul 31, 2026
1 check failed
@drewstone
drewstone deleted the fix/mcp-config-values branch July 31, 2026 07:28
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.

[P0] agent-app emits the pre-0.38.0 MCP shape — every consumer that bumps agent-interface must hand-write an adapter

2 participants