Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -862,7 +862,7 @@ graph LR
| Web UI + macOS app UX audit | In progress | P0 | — | | |
| Release qualification gate (auto-QA matrix blocks the tag) | In progress | P0 | — | [081-release-qa-gate](./specs/081-release-qa-gate/) | |
| Action log / transparency — info at a glance | In progress | P1 | — | | |
| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | In progress | P1 | 0/109 (0%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | |
| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | In progress | P1 | 12/109 (11%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | |
| Token-efficiency benchmark: measured savings, published results | In progress | P1 | 62/64 (97%) | [103-token-bench](./specs/103-token-bench/) | |
| Telemetry identity & data quality (machine_id + CI-filter hardening) | In progress | P1 | — | | |
| Telemetry v7: honest funnel + churn instrumentation | In progress | P1 | — | [080-telemetry-v7-churn](./specs/080-telemetry-v7-churn/) | |
Expand Down Expand Up @@ -1008,5 +1008,5 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [102-schema-deferred](./specs/102-schema-deferred/) | `shipped` | 89/89 (100%) |
| [103-token-bench](./specs/103-token-bench/) | `shipped` | 62/64 (97%) |
| [104-auto-routing-mode](./specs/104-auto-routing-mode/) | — | — |
| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `drafted` | 0/109 (0%) |
| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 12/109 (11%) |
| [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) |
82 changes: 71 additions & 11 deletions docs/features/agent-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -268,17 +268,77 @@ Server scoping is enforced at three levels:
receive every event unchanged; the stream is rendered per connection.
4. **Cached responses** (`read_cache`) — a truncated response is parked behind
a cache key, and the key is a hash, not a credential. Every entry is stamped
with the authorization that produced it (server scope, permission tier,
profile pin, effective profile, caller kind). `read_cache` refuses, on every
page, any request whose own authorization could not have produced the entry,
so a narrower token sharing the same MCP session cannot page a broader
token's response. An unrestricted admin may read any entry; a token may read
its own entries and those of tokens at least as narrow as itself. Profile
scope is compared as a server set, so deleting or narrowing a profile after
the entry was produced revokes cached access as well (a stale pin resolves to
a deny-all scope and reads nothing). An unauthenticated `/mcp` caller ranks
below an authenticated admin: it cannot page an entry an API-key admin
produced.
with the authorization snapshot that authorized producing it (server scope,
permission tier, profile pin, effective profile, caller kind), captured when
the call was authorized — a profile narrowed while the call was in flight
does not re-stamp the response. `read_cache` — on every MCP surface and on
the REST direct call path (`POST /api/v1/tools/call`) — admits, on every
page, exactly three kinds of request and refuses every other, so a
narrower token sharing the same MCP session cannot page a broader token's
response. Ordered by **caller kind first**: an administrator may read any
entry regardless of its own profile binding (an unauthenticated `/mcp`
caller ranks below an authenticated admin and cannot page an entry an
API-key admin produced); an agent token never reads an administrator's
entry. Between agent entries a reader is admitted when it presents the
**same effective authorization** the entry was produced under — the same
token, server grant, permission tiers, pin and effective profile server
set (compared as sets, so list order and the profile's name do not
matter) — or when it is **unrestricted**: a `*` server grant, no pin, no
effective profile, and every permission tier the entry's producer held. A
token that is wider than the producer but still bounded (an `{a,b}` grant
over an `{a}` entry, a session that left the profile it produced under)
is refused: it re-runs the call under its own credential instead. Profile
scope is compared as a server set, so deleting or narrowing a profile
after the entry was produced revokes cached access as well (a stale pin
resolves to a deny-all scope and reads nothing).

A page that `read_cache` itself has to truncate again is stamped with its
*parent's* snapshot, never the redeemer's, so provenance is monotone down
the chain. For a scoped caller every refusal — an entry it may not read, an
expired entry, an internal entry, a key that never existed — answers with
the same `cache key not found` body, status and timing (a refusal commits
the same stats write a miss does), so a key cannot be probed for
existence. A refusal also never decodes the entry's payload: the gate
reads a small header stored in front of each record, so a multi-megabyte
entry is refused as quickly as a one-line one. An expired entry is refused
like a miss and left for the periodic cleanup sweep to evict, so the
refusing read writes exactly what a miss writes. The header and the record
behind it are two encodings of the same stamp; an entry on which they
disagree (a corrupt or hand-edited database) is treated as unreadable —
refused for every caller, invalidated, never served. The header is
fixed-size and decides the whole verdict by itself: it carries the caller
kind, the permission tiers and a digest of the producer's effective
authorization, and the reader's own digest is compared against it — so a
refusal never loads the producer's snapshot, a pre-upgrade entry is
invalidated without being decoded, and a probe costs what a miss costs on
the first request after a restart as much as on the thousandth, however
many servers the producer's authorization names. Each distinct snapshot
is still stored once, under that digest, for administrator diagnostics;
nothing reads it to decide. The size statistics are reconciled from the
store by the periodic cleanup sweep, which is why an invalidated
pre-upgrade entry can leave `total_size_bytes` over-counting for at most
one sweep interval.

Server-edition OAuth **users** are bounded by the same dispatch gates as
agent tokens (server allowlist, permission tier, effective profile), so a
user's cached entry is stamped with those dimensions as well as the user
id, and redemption requires the same user *with the same* authorization:
a grant changed or a profile changed since the entry was produced revokes
cached access exactly as it does for an agent token.

**Upgrading.** Entries written by any release before this one — including
the immediately preceding one, which stamped a producer but no schema
version — are refused for **every** caller, administrators included, and
are invalidated on the first attempt to read them (a one-time
`cache key not found` on keys minted before the upgrade; re-run the
original tool call). The registry and repository-metadata caches mcpproxy
keeps for itself are stamped internal from this release on: never readable
through `read_cache`, and kept rather than evicted when refused. Registry
and repository-metadata entries persisted *before* the upgrade carry no
stamp, so the first `read_cache` probe of such a key after upgrading
invalidates it once — the next registry search or repository lookup
re-fetches and re-stamps it. The no-eviction guarantee applies to entries
written after the upgrade.

## Administrative Operations Are Admin-Only

Expand Down
2 changes: 1 addition & 1 deletion docs/features/routing-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ The default mode uses BM25 full-text search to help AI agents discover relevant
- `call_tool_read` — Execute read-only tool calls
- `call_tool_write` — Execute write tool calls
- `call_tool_destructive` — Execute destructive tool calls
- `read_cache` — Access paginated responses. Each cached page is stamped with the authorization that produced it (agent-token server scope, permission tier, profile pin, effective profile, caller kind); a request whose own authorization could not have produced the entry is refused on every page, so a narrower token sharing an MCP session cannot read a broader token's response.
- `read_cache` — Access paginated responses. Each cached page is stamped with the authorization that produced it (agent-token server scope, permission tier, profile pin, effective profile, caller kind); a request whose own authorization could not have produced the entry is refused on every page — with the same `cache key not found` body a missing key produces, for scoped callers — so a narrower token sharing an MCP session cannot read a broader token's response or probe for its existence. Administrators read any entry (caller kind first); entries written by any release before this one and mcpproxy's own registry/repository-metadata cache entries are readable by no one (the former are invalidated on first read). See [Agent Tokens](./agent-tokens.md).

**How it works:**
1. AI agent calls `retrieve_tools` with a natural language query
Expand Down
9 changes: 7 additions & 2 deletions internal/appctx/adapters.go
Original file line number Diff line number Diff line change
Expand Up @@ -279,11 +279,16 @@ func (c *CacheManagerAdapter) Get(key string) (interface{}, bool) {
return record.FullContent, true
}

// Set adapts the cache manager to implement our interface
// Set adapts the cache manager to implement our interface. The entry is
// stamped internal (Spec 105 FR-002): it is written on the proxy's own behalf
// and read back through the ungated Get above, so read_cache refuses it for
// every caller without evicting it. An unstamped Store would be legacy
// provenance, invalidated by the first read_cache probe of the key.
func (c *CacheManagerAdapter) Set(key string, value interface{}, _ time.Duration) error {
// The cache manager has a different Store signature, so we adapt it
valueStr := fmt.Sprintf("%v", value)
return c.Store(key, "generic_tool", map[string]interface{}{}, valueStr, "", 0)
return c.StoreAs(key, "generic_tool", map[string]interface{}{}, valueStr, "", 0,
cache.Authorization{CallerKind: cache.CallerKindInternal})
}

// Delete removes a cache entry
Expand Down
49 changes: 49 additions & 0 deletions internal/appctx/cache_adapter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package appctx

import (
"path/filepath"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.etcd.io/bbolt"
"go.uber.org/zap"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/cache"
)

// Spec 105 FR-002 (critique round 2, finding 7): CacheManagerAdapter.Set was
// the last production writer still going through the unstamped Store, so
// anything written through the appctx CacheManager interface landed as
// LEGACY provenance — refused for every read_cache caller and invalidated by
// the first probe of its key. The adapter writes on the proxy's own behalf
// and reads back through the ungated Get, which is the internal kind: kept,
// never redeemable through read_cache.
func TestCacheManagerAdapter_SetStampsInternal(t *testing.T) {
db, err := bbolt.Open(filepath.Join(t.TempDir(), "cache.db"), 0644, &bbolt.Options{Timeout: time.Second})
require.NoError(t, err)
defer db.Close()
base, err := cache.NewManager(db, zap.NewNop())
require.NoError(t, err)
defer base.Close()
adapter := &CacheManagerAdapter{Manager: base}

require.NoError(t, adapter.Set("generic:key", "SENTINEL_VALUE", time.Hour))

rec, ok := base.Peek("generic:key")
require.True(t, ok)
require.NotNil(t, rec.Producer, "an unstamped entry is legacy provenance and would be invalidated on first gated read")
assert.Equal(t, cache.CallerKindInternal, rec.Producer.CallerKind)
assert.True(t, rec.HasCurrentProvenance())

// The adapter's own reader still serves it ...
got, ok := adapter.Get("generic:key")
require.True(t, ok)
assert.Equal(t, "SENTINEL_VALUE", got)
// ... and the gated door refuses it for an administrator WITHOUT evicting.
_, err = base.GetRecordsAs("generic:key", 0, 10, cache.Authorization{CallerKind: cache.CallerKindAdmin})
assert.ErrorIs(t, err, cache.ErrInternalEntry)
_, ok = base.Peek("generic:key")
assert.True(t, ok, "refusing an internal entry must not evict it")
}
Loading
Loading