Skip to content
11 changes: 11 additions & 0 deletions docs/adr/0009-default-ttl-for-mcp-tool-list-caching.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Default TTL for MCP tool-list caching when servers omit ttlMs

MCP 2026-07-28 says a `tools/list` result without `ttlMs` must be treated as `ttlMs: 0` — i.e. not cached. Almost all remote servers we connect to today speak protocol ≤ 2025-11-25 and emit no `ttlMs`, so a spec-strict client cache would be a no-op and the cost/latency win of #545 (fewer discovery round-trips, stable prompt-cache tool blocks) would not materialize. We deliberately deviate on the client side: `McpManager` applies a configurable default TTL (60s, `OMADIA_MCP_TOOLLIST_TTL_MS`, opt-out via `0`) to list results that carry no `ttlMs`, and clamps server-provided TTLs to 15 minutes.

The spec binds servers' emission, not clients' caching policy, so this is a policy choice, not a protocol violation. Safety valves: Discovery and Rescan always bypass the cache, `notifications/tools/list_changed` purges it immediately, entries are keyed like the connection pool (token-hash for `private`/unknown scope, server id for `public`), and tool *calls* are never gated on a cached list.

Considered and rejected: spec-strict no-cache-without-ttlMs (defeats the purpose of the issue until the external ecosystem catches up to 2026-07-28).

One correction to "until the ecosystem catches up": for `stdio` and `sse` peers it never will. #562 measured that `server/discover` is answered at the HTTP edge only — a non-HTTP peer returns `-32601` regardless of its advertised versions, and pinning fails with `ERA_NEGOTIATION_FAILED` — so those transports stay legacy-era by construction and can never carry `ttlMs`. The default TTL is therefore the permanent path for them, not a transitional one. `http` peers are the only ones from which a server-declared TTL can arrive, and it does: verified end to end that `ttlMs`/`cacheScope` reach the client verbatim even though `listTools` runs with the SDK's `cacheMode: 'bypass'` (that bypass skips the SDK's own response cache, not the hints).

See also ADR-0008 (MCP connection lifetime) for the pool this cache is keyed against.
50 changes: 50 additions & 0 deletions docs/middleware-agent-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,42 @@ Verdikt ohne `code`), `test/adminProvidersRoute.test.ts` (DTO trägt
`test/adminSettingsRoute.test.ts` (abgelehnter Wert → `settings.invalid_values`,
unbekannter Key bzw. nicht installiertes Ziel-Plugin → `settings.no_valid_changes`).

### MCP Tool-List-Cache via `ttlMs`/`cacheScope` (issue #545)

MCP 2026-07-28 macht `tools/list`-Results cachebar (`CacheableResult`:
`ttlMs` + `cacheScope`). Umgesetzt auf SDK 1.30.0 — **kein** v2-Bump nötig,
die Felder überleben das loose Result-Parsing (gleiches Muster wie
`resultType`, #544).

- **Client** (`McpManager.listTools`, `packages/harness-orchestrator/src/mcp/
mcpClient.ts`): TTL-Cache, Key via `mcpToolListCacheKey` — `public` ⇒ bare
Server-ID, `private`/unbekannt/fehlend ⇒ Pool-Key (Server-ID + Token-Hash,
Token-Rotation = Cache-Miss). Der Bare-Id-Probe akzeptiert nur als `public`
abgelegte Einträge (`sharedPublic`-Flag): die private Liste eines token-losen
Callers hat denselben Key (Pool-Key ohne Token = Server-ID) und darf nie
über Auth-Kontexte geteilt werden. Rückgaben sind Deep-Copies in beide
Richtungen — Caller-Mutation (Plugins!) erreicht den Cache nicht.
Server-`ttlMs` geclampt auf 15 min
(`MCP_TOOLLIST_MAX_TTL_MS`); fehlt `ttlMs`, greift ein Default von 60 s —
**bewusste Spec-Abweichung** (Spec: fehlend ⇒ nicht cachen), Begründung in
ADR-0009; `OMADIA_MCP_TOOLLIST_TTL_MS=0` stellt spec-strikt zurück.
- **Invalidierung:** `notifications/tools/list_changed` purgt sofort (Handler
wird vor `connect` registriert); `close()`/`closeAll()` purgen mit; Expiry
lazy beim Read (kein Timer, wie `evictIdle`).
- **Bypass:** Discovery (Builder-Route) und der Security-Rescan listen immer
frisch (`fresh: true`) — ein Scan über eine gecachte Liste scannt nichts.
Cache-Nutznießer ist der Plugin-Accessor `ctx.mcp.listTools()`.
- **Eigene Server emittieren:** Loopback `ttlMs: 300000` / `public` (Liste ist
pro Turn-Instanz eingefroren, nicht caller-abhängig); Public-Server
`ttlMs: 60000` / `private` (Liste ist per API-Key gefiltert — `private` ist
Pflicht, sonst leaken fremde Tool-Sets; `tools/call` prüft Bindings weiter
live, Revoke bleibt sofort wirksam). `list_changed`-*Emission* aus eigenen
Servern ist bewusst Folge-Issue.

Tests: `test/mcpToolListCache.test.ts` (pure Regeln + Stdio-/HTTP-Fixtures),
Emission-Asserts in `test/cliBridge/loopbackMcpServer.test.ts` und
`test/publicMcp/publicMcpEndpoint.e2e.test.ts`.

---

## 4. Migration Managed Agents → Lokal
Expand Down Expand Up @@ -1454,6 +1490,20 @@ PORT=3979
`.env.example` ist gepflegt. Leere Strings parsed zod als `""`, nicht
`undefined` — daher muss der Fallback `||` sein, nicht `??`.

### Package-lokale Env-Variablen (`OMADIA_*`, ohne zod-Schema)

Das `harness-orchestrator`-Package importiert `config.ts` **nicht**; seine
Optionen laufen als `OMADIA_*`-Env mit Modul-Konstante als Default und werden
pro Aufruf aufgelöst (Änderung greift ohne Restart):

```
OMADIA_TOOL_DISPATCH_TIMEOUT_MS=240000 # äußere Dispatch-Deadline (W3-A)
OMADIA_MCP_CALL_TIMEOUT_MS=60000 # Idle-Budget pro MCP-Request (W0-2)
OMADIA_MCP_CALL_MAX_TOTAL_TIMEOUT_MS=180000 # absolute Decke inkl. Retry (W0-2)
OMADIA_MCP_TOOLLIST_TTL_MS=60000 # Default-TTL Tool-List-Cache (#545,
# ADR-0009); 0 = spec-strikt aus
```

### Wichtige Gotchas

1. **`??` vs `||`** bei Env-Fallbacks — haben wir einmal gefangen, steht
Expand Down
8 changes: 8 additions & 0 deletions middleware/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,14 @@ DIAGRAM_PUBLIC_BASE_URL=http://localhost:8080
# once PUBLIC_BASE_URL is the middleware's own public host.
# MCP_OAUTH_REDIRECT_URI=http://localhost:3000/bot-api/v1/operator/mcp-oauth/callback

# --- MCP tool-list caching (issue #545, ADR-0009) ----------------------------
# TTL (ms) applied to a cached `tools/list` result when the remote server sends
# no `ttlMs` of its own (MCP 2026-07-28 CacheableResult). Server-provided TTLs
# are honoured but clamped to 15 minutes. `0` = spec-strict: never cache a
# list without an explicit server `ttlMs`. Read per listing — a change applies
# without a restart. Discovery and the security rescan always bypass the cache.
# OMADIA_MCP_TOOLLIST_TTL_MS=60000

# --- In-app "Create Issue" button (operator GitHub device flow) -------------
# Lets an operator connect their OWN GitHub account and file issues to the
# public repo (byte5ai/omadia) as themselves. Uses GitHub's DEVICE FLOW, so
Expand Down
6 changes: 6 additions & 0 deletions middleware/packages/harness-orchestrator/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ export {
// Exported so the no-collateral-invalidation rule of `McpManager.close(id)`
// can be unit tested and reused by ops tooling (issue #563).
mcpPoolScopeMatches,
// #545 — the tool-list cache rules (TTL normalisation + scope keying) are
// the contract, unit-tested as pure functions like `mcpPoolScopeMatches`.
mcpToolListTtlMs,
mcpToolListCacheKey,
MCP_TOOLLIST_DEFAULT_TTL_MS,
MCP_TOOLLIST_MAX_TTL_MS,
mcpToolToLocalSubAgentTool,
mcpToolToNativeSpec,
renderToolResult,
Expand Down
12 changes: 12 additions & 0 deletions middleware/packages/harness-orchestrator/src/loopbackMcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import { sortByToolName } from './toolOrdering.js';

const MAX_REQUEST_BYTES = 8 * 1024 * 1024;

/** #545 — `ttlMs` advertised on `tools/list` (5 minutes). Any value is true
* for as long as this per-turn server exists; 5 minutes comfortably covers a
* turn without pretending the list is immortal across turns. */
const LOOPBACK_TOOLLIST_TTL_MS = 300_000;

class PayloadTooLargeError extends Error {}

export interface LoopbackMcpServerDeps {
Expand Down Expand Up @@ -159,6 +164,13 @@ export class LoopbackMcpServer {
// and nothing here reshapes it.
inputSchema: tool.input_schema as unknown as Tool['inputSchema'],
})),
// #545 — MCP 2026-07-28 `CacheableResult`. Both fields are honest by
// construction: `deps.tools` is readonly and this server lives exactly
// one turn, so the list cannot change while anyone holds it (`ttlMs` can
// afford to be generous), and it is identical for every caller — there
// is one bearer and no per-principal filtering (`public`).
ttlMs: LOOPBACK_TOOLLIST_TTL_MS,
cacheScope: 'public',
}));

mcp.server.setRequestHandler('tools/call', async (request) => {
Expand Down
Loading
Loading