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
3 changes: 2 additions & 1 deletion docs/features/agent-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,7 @@ Server-side enforcement (no client cooperation required):
- **`set_profile("other")` is rejected** — a pinned token cannot switch its session to a different profile (switching to its own pinned profile, or clearing, is allowed).
- **`/mcp/p/<other>` returns `403`** — connecting to any profile URL other than the pinned one is forbidden; the pinned profile's own URL works.
- **The pin is the highest-precedence resolver source**, above an explicit `/mcp/p/<slug>` URL scope and above a session `set_profile` selection.
- **Every dispatch surface resolves it** — `retrieve_tools`, `describe_tool`, `call_tool_*`, the `code_execution` sandbox, direct-routing mode (`server__tool`) and [preflight](./tools-preflight.md) all bound themselves by the pin, so no routing mode is a way around it.

Resolution precedence (highest wins):

Expand All @@ -216,7 +217,7 @@ Resolution precedence (highest wins):
4. none (no profile filtering — all allowed servers)
```

**Validation & config changes**: the pinned slug must name a configured profile at creation time (creation is rejected otherwise). If the profile is later removed from the configuration, requests are **warn-skipped** rather than hard-failed the pin still blocks switching away, so the token can never silently widen its scope, but profile filtering falls through to the next precedence tier. Pinning composes with server scoping and permission tiers: a request must satisfy **all** of them.
**Validation & config changes**: the pinned slug must name a configured profile at creation time (creation is rejected otherwise). If the profile is **later removed** from the configuration, the pin resolves to a **deny-all scope**: the token sees no upstream servers and no tools, on the MCP session path and in [preflight](./tools-preflight.md#disclosure-tiers) alike. The request is logged with a warning naming the removed profile, not hard-failed at the transport. The pin is a restriction the operator applied, so losing the profile it names must never hand the token a wider view than it had the day before — re-create the profile, or re-mint the token against a live one, to restore it. Pinning composes with server scoping and permission tiers: a request must satisfy **all** of them.

The pin is shown by `token list` (PROFILE PIN column) and `token show` (Profile Pin field), and is preserved across `token regenerate`.

Expand Down
8 changes: 4 additions & 4 deletions docs/features/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ The `set_profile` MCP tool switches the active profile **inside a live session**
```

- The selection is keyed by the MCP session id (stable per streamable-HTTP / SSE connection) and persists for the lifetime of that session.
- It applies to subsequent `retrieve_tools`, `call_tool_*`, and `code_execution` calls on the base `/mcp` endpoint — `retrieve_tools` searches the profile's per-profile index directly.
- Passing an empty string (`""`) clears the selection and returns to all servers (the result lists every configured server).
- It applies to subsequent `retrieve_tools`, `call_tool_*`, `code_execution` and direct-mode (`server__tool`) calls on the base `/mcp` endpoint — `retrieve_tools` searches the profile's per-profile index directly.
- Passing an empty string (`""`) clears the selection and returns to all servers (the result lists every configured server). A token with a [`profile_pin`](./agent-tokens.md#profile-pinning) keeps its pin — the result then lists the pinned profile's servers, since that is what the session can still reach.
- An unknown slug is rejected: `unknown profile '<slug>' (available: research, deploy)`.
- Session state is cleared automatically on session close.

Expand All @@ -73,12 +73,12 @@ When more than one source could select a profile, the effective profile for a re

| # | Source | Scope |
|---|--------|-------|
| 1 | Agent-token `profile_pin` | Server-enforced, immutable for the connection. *(Hook reserved for Profiles v2 T3; inert until then.)* |
| 1 | Agent-token [`profile_pin`](./agent-tokens.md#profile-pinning) | Server-enforced, immutable for the connection. If the pinned profile has been deleted, the request resolves to a **deny-all** scope rather than falling to the tiers below. |
| 2 | URL `/mcp/p/<slug>` | Explicit and authoritative **for that request** — overrides the session default. |
| 3 | `set_profile` session selection | The default for the base `/mcp` endpoint for the session lifetime. |
| 4 | None | No filtering (admin / all servers). |

So a request that arrives via `/mcp/p/<other>` is scoped to `<other>` even if the session previously ran `set_profile`; a session selection that no longer matches any configured profile is treated as stale and dropped.
So a request that arrives via `/mcp/p/<other>` is scoped to `<other>` even if the session previously ran `set_profile`; a session selection that no longer matches any configured profile is treated as stale and dropped. A stale **token pin** is not dropped the same way — a pin is a restriction an operator applied to a credential, so it fails closed (deny-all) instead of widening back to the token's own scope.

## REST API

Expand Down
2 changes: 1 addition & 1 deletion docs/features/tools-preflight.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ Preflight answers with different candor depending on who is asking:

The agent-token behavior is deliberate **scope-silence**: an out-of-scope probe learns nothing — not even that the server exists. `did_you_mean` suggestions (nearest-name, up to 3) are computed over the caller-visible index only and never name a quarantined server's tools. See [Agent Tokens](./agent-tokens.md) and [Profiles](./profiles.md).

A token's evaluation scope is the intersection of its `allowed_servers`, its `profile_pin`, and any `profile` in the request — so naming another profile can only narrow it. If the pinned profile has since been **deleted**, the scope becomes deny-all and every id answers `not_found`: the pin is a restriction the operator applied, and losing the profile it names must never hand the token a wider view than it had before. Re-mint the token (or re-create the profile) to restore it.
A token's evaluation scope is the intersection of its `allowed_servers`, its `profile_pin`, and any `profile` in the request — so naming another profile can only narrow it. If the pinned profile has since been **deleted**, the scope becomes deny-all and every id answers `not_found`: the pin is a restriction the operator applied, and losing the profile it names must never hand the token a wider view than it had before. The live MCP session path resolves the same way — a preflight's `not_found` for a stale pin is never a false alarm the session would contradict. Re-mint the token (or re-create the profile) to restore it.

## Transparency: every preflight is on the record

Expand Down
50 changes: 35 additions & 15 deletions internal/profile/context.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
// Package profile carries request-scoped in-proxy profile filtering (Spec 057).
//
// A profile is a named, stateless view over a subset of the configured upstream
// servers, addressable at /mcp/p/<slug>. A ProfileScope is resolved once by the
// profile middleware from the request URL and injected into the request context;
// the scope filters which servers a request may see/call. It is an independent,
// auth-type-agnostic primitive that composes with (but does not depend on) the
// Spec 028 agent-token scope — an unauthenticated /mcp/p/<slug> connection runs
// as an admin AuthContext yet must still be profile-filtered.
// servers, addressable at /mcp/p/<slug>. A ProfileScope is resolved once per
// request and filters which servers that request may see/call. The URL tier is
// only one of its sources: the server package's resolver (Profiles v2) also
// builds a scope from an agent token's profile_pin and from a session's
// set_profile selection, in that precedence order. The scope stays an
// independent, auth-type-agnostic primitive that composes with (but does not
// depend on) the Spec 028 agent-token scope — an unauthenticated /mcp/p/<slug>
// connection runs as an admin AuthContext yet must still be profile-filtered.
package profile

import "context"

// ProfileScope is the immutable, request-scoped set of servers a profile exposes.
// It is resolved by profileMiddleware from the /mcp/p/<slug> URL and never mutated
// for the lifetime of a request.
// ProfileScope is the immutable, request-scoped set of servers a profile
// exposes. It is resolved once — from a token pin, a /mcp/p/<slug> URL, or a
// session selection — and never mutated for the lifetime of a request.
type ProfileScope struct {
// Name is the resolved profile slug, used in rejection messages and activity
// metadata (FR-012).
Expand All @@ -36,10 +38,10 @@ func NewProfileScope(name string, servers []string) *ProfileScope {

// Allows reports whether the named server is visible under this scope.
//
// A nil receiver means the request did not enter via /mcp/p/<slug> (it used /mcp,
// /mcp/code, or /mcp/call) and therefore is not profile-filtered every server
// is allowed (FR-010). A non-nil scope allows only servers in its set; the empty
// server name is never allowed for a real scope.
// A nil receiver means no profile is in effect for this request — no URL slug,
// no session selection, no token pin — so it is not profile-filtered and every
// server is allowed (FR-010). A non-nil scope allows only servers in its set;
// the empty server name is never allowed for a real scope.
func (p *ProfileScope) Allows(serverName string) bool {
if p == nil {
return true
Expand All @@ -51,6 +53,18 @@ func (p *ProfileScope) Allows(serverName string) bool {
return ok
}

// DeniesAll reports whether this scope allows nothing at all — an empty
// profile, or the deny-all scope a stale agent-token pin resolves to. A nil
// receiver is "no profile filtering" and therefore never denies all.
//
// It exists so callers can distinguish "filtered to nothing" from "not
// filtered" without allocating an AllowedServerNames slice, notably to avoid
// lazily creating a per-profile Bleve index for a scope that can return no
// results anyway.
func (p *ProfileScope) DeniesAll() bool {
return p != nil && len(p.servers) == 0
}

// AllowedServerNames returns the list of server names in this profile scope.
// Returns nil for a nil receiver (allow-all — no restriction list).
// Returns an empty slice for a non-nil scope with no servers (deny-all).
Expand All @@ -73,8 +87,14 @@ func WithProfileScope(ctx context.Context, p *ProfileScope) context.Context {
return context.WithValue(ctx, profileScopeKey{}, p)
}

// ProfileScopeFromContext extracts the ProfileScope, or nil when the request did
// not enter via a profile URL (no filtering).
// ProfileScopeFromContext extracts the URL-injected ProfileScope, or nil when
// the request did not enter via a profile URL.
//
// This is the URL TIER ONLY. Enforcement code must not call it directly: a
// request can carry a higher-precedence token pin or a session selection that
// never touches the context. Resolve the effective scope through the server
// package's resolveActiveProfile instead — reading this alone is how the
// code_execution sandbox once ran outside a pinned token's profile.
func ProfileScopeFromContext(ctx context.Context) *ProfileScope {
p, _ := ctx.Value(profileScopeKey{}).(*ProfileScope)
return p
Expand Down
9 changes: 8 additions & 1 deletion internal/server/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -1526,9 +1526,16 @@ func (p *MCPProxyServer) handleRetrieveToolsWithMode(ctx context.Context, reques
// servers' tools, so switching costs no re-index. The shared index remains
// the allow-all fallback; profileScope still post-filters as defense in depth
// (and covers the fallback path below).
//
// A deny-all scope (an empty profile, or the scope a stale agent-token pin
// resolves to) deliberately skips ForProfile: that call lazily CREATES and
// caches an on-disk per-profile index, so honoring it would let a request
// that is allowed to see nothing leave a new index directory behind — for a
// profile that may no longer exist. The post-filter below returns the same
// empty result set from the shared index.
profileName, profileScope := p.resolveActiveProfile(ctx)
searchIndex := p.index
if profileName != "" {
if profileName != "" && !profileScope.DeniesAll() {
if pIdx, perr := p.index.ForProfile(profileName); perr == nil && pIdx != nil {
searchIndex = pIdx
} else if perr != nil {
Expand Down
78 changes: 48 additions & 30 deletions internal/server/mcp_code_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/jsruntime"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/profile"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream"
Expand Down Expand Up @@ -247,35 +246,7 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca
}

// Spec 057 (Codex #621 finding 2): Intersect profile scope into code_execution.
// The jsruntime treats an empty AllowedServers as "allow all"; at a profile URL
// we must restrict to profile servers regardless of what the caller supplied.
if profileScope := profile.ProfileScopeFromContext(ctx); profileScope != nil {
// A profile is active: enforce its effective server set even when empty.
// A deny-all profile (servers: []) or a non-overlapping token∩profile
// yields an EMPTY allow-list, which the jsruntime would otherwise treat
// as "allow all" — leaking every server. RestrictToAllowed closes that.
options.RestrictToAllowed = true
// Build the effective allowed-servers list: profile servers only.
// If the caller also supplied allowed_servers, intersect the two sets.
profileServers := profileScope.AllowedServerNames()
if len(options.AllowedServers) == 0 {
// No caller-supplied restriction: use profile servers as the restriction.
options.AllowedServers = profileServers
} else {
// Intersect caller-supplied list with profile servers.
profileSet := make(map[string]struct{}, len(profileServers))
for _, s := range profileServers {
profileSet[s] = struct{}{}
}
var intersected []string
for _, s := range options.AllowedServers {
if _, ok := profileSet[s]; ok {
intersected = append(intersected, s)
}
}
options.AllowedServers = intersected
}
}
p.applyProfileScopeToExecution(ctx, &options)

// Execute code
p.logger.Info("executing code",
Expand Down Expand Up @@ -978,6 +949,53 @@ func (u *upstreamToolCaller) storeToolCallInHistory(serverName, toolName string,
}
}

// applyProfileScopeToExecution intersects the request's ACTIVE profile into the
// sandbox's allow-list (Spec 057, Codex #621 finding 2).
//
// It resolves through resolveActiveProfile — token pin > /mcp/p/<slug> URL >
// session set_profile — rather than reading the URL-injected scope alone. The
// URL-only read was a scope hole: a profile-pinned agent token connected to the
// base /mcp endpoint carried no URL scope, so the sandbox ran under the token's
// full server scope and could call straight past its pin, including a stale pin
// that every other session path now answers deny-all.
//
// The jsruntime treats an empty AllowedServers as "allow all", so an active
// profile ALWAYS sets RestrictToAllowed: a deny-all profile, a stale pin, or a
// non-overlapping token∩profile must yield an empty allow-list that denies
// everything rather than leaking every server.
func (p *MCPProxyServer) applyProfileScopeToExecution(ctx context.Context, options *jsruntime.ExecutionOptions) {
if options == nil {
return
}
_, profileScope := p.resolveActiveProfile(ctx)
if profileScope == nil {
return
}

options.RestrictToAllowed = true
profileServers := profileScope.AllowedServerNames()
if len(options.AllowedServers) == 0 {
// No caller-supplied restriction: the profile is the restriction.
// AllowedServerNames returns a non-nil empty slice for a deny-all
// scope, which RestrictToAllowed then enforces as "nothing".
options.AllowedServers = profileServers
return
}

// Intersect the caller-supplied list with the profile's servers.
profileSet := make(map[string]struct{}, len(profileServers))
for _, s := range profileServers {
profileSet[s] = struct{}{}
}
intersected := make([]string, 0, len(options.AllowedServers))
for _, s := range options.AllowedServers {
if _, ok := profileSet[s]; ok {
intersected = append(intersected, s)
}
}
options.AllowedServers = intersected
}

// lookupToolPermission returns the required permission tier for a tool based on its annotations.
// This is used by the JS runtime to enforce auth context permissions during code_execution.
func (p *MCPProxyServer) lookupToolPermission(serverName, toolName string) string {
Expand Down
23 changes: 21 additions & 2 deletions internal/server/mcp_direct_scope.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,30 @@ func (p *MCPProxyServer) lookupDirectToolPermission(directName string) (string,
return perm, ok
}

// filterDirectModeToolsForAuth filters tools/list for scoped agent tokens.
// filterDirectModeToolsForAuth filters tools/list for scoped agent tokens and
// for any request with an active profile.
//
// Direct mode registers upstream tools globally as server__tool. Without this
// filter, scoped agent tokens prevent execution but still disclose tool names,
// descriptions, and schemas for servers outside their scope. Call-time auth is
// still authoritative; this filter only removes tools that the current token
// could not call from discovery responses.
//
// The profile filter (Spec 057) is applied to EVERY auth type, not just agent
// tokens: an unauthenticated /mcp/p/<slug> connection runs as an admin context
// yet must still be profile-filtered, exactly as it is on the retrieve_tools
// path (see indexedToolVisible). Direct mode previously honored no profile at
// all — a profile-pinned token saw and could call every server in its token
// scope — so the pin was enforced on one routing mode and not the other.
func (p *MCPProxyServer) filterDirectModeToolsForAuth(ctx context.Context, tools []mcp.Tool) []mcp.Tool {
if len(tools) == 0 {
return tools
}

authCtx := auth.AuthContextFromContext(ctx)
if authCtx == nil || authCtx.Type != auth.AuthTypeAgent {
_, profileScope := p.resolveActiveProfile(ctx)
isScopedAgent := authCtx != nil && authCtx.Type == auth.AuthTypeAgent
if !isScopedAgent && profileScope == nil {
return tools
}

Expand All @@ -68,6 +78,15 @@ func (p *MCPProxyServer) filterDirectModeToolsForAuth(ctx context.Context, tools
continue
}

if !profileScope.Allows(serverName) {
continue
}

if !isScopedAgent {
filtered = append(filtered, tool)
continue
}

if !authCtx.CanAccessServer(serverName) {
continue
}
Expand Down
12 changes: 12 additions & 0 deletions internal/server/mcp_routing.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,18 @@ func (p *MCPProxyServer) makeDirectModeHandler(serverName, toolName string, anno
requestID = mintActivityRequestID(serverName, toolName)
}

// Spec 057 / Profiles v2: the active profile (token pin > URL > session
// set_profile) gates direct-mode dispatch exactly as it gates
// call_tool_* (mcp.go handleCallToolVariant). It runs independently of
// the agent-token gates below so an unauthenticated /mcp/p/<slug>
// connection is filtered too, and it runs FIRST so a profile-pinned
// token cannot reach a server outside its pin through this routing mode.
if _, profileScope := p.resolveActiveProfile(ctx); profileScope != nil && !profileScope.Allows(serverName) {
errMsg := fmt.Sprintf("server '%s' is not in profile '%s'", serverName, profileScope.Name)
p.emitActivityPolicyDecision(serverName, toolName, sessionID, requestID, "blocked", errMsg, telemetry.BlockReasonProfileScope)
return mcp.NewToolResultError(errMsg), nil
}

// Check auth context for server access and permissions
authCtx := auth.AuthContextFromContext(ctx)
if authCtx != nil {
Expand Down
Loading
Loading