Skip to content

Commit 1a81e68

Browse files
committed
feat(057): /mcp/p/<slug> profile routing + 4-surface filter + tests (T008-T022)
- profileMiddleware in server.go: resolves slug from config snapshot, builds ProfileScope, injects into context, 404s on missing/unknown profile - /mcp/p/ and /mcp/p routes registered after mcpAuthMiddleware (auth->profile) - retrieve_tools filter (T010): profile.Allows() independent of enforceAgentScope - call_tool_* filter (T011): profile error before token-scope check, names profile - upstream_servers list filter (T011a): excludes out-of-profile servers - code_execution filter (T011b): profile servers intersected into AllowedServers so empty caller list at a profile URL does not mean 'allow all' - ProfileScope.AllowedServerNames() helper for T011b intersection - withProfileMeta() injects metadata["profile"] at activity emit sites (T019) - Integration tests: 404-no-profiles, 404-unknown-slug, retrieve_tools isolation, full /mcp union unchanged, call_tool_* out-of-profile rejection, upstream_servers list filtering, policy intersection, per-server disabled_tools guard, activity metadata smoke-test, backward-compat endpoint reachability (T012-T018/T020) - docs/features/profiles.md Related #55
1 parent f91d0a6 commit 1a81e68

6 files changed

Lines changed: 774 additions & 1 deletion

File tree

docs/features/profiles.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# In-Proxy Profiles (Spec 057)
2+
3+
Profiles are named, stateless subsets of upstream servers addressable as permanent URLs at `/mcp/p/<slug>`.
4+
5+
## Quick start
6+
7+
```json
8+
{
9+
"profiles": [
10+
{ "name": "research", "servers": ["arxiv", "wikipedia"] },
11+
{ "name": "deploy", "servers": ["k8s", "github"] }
12+
]
13+
}
14+
```
15+
16+
Each profile gets a permanent MCP endpoint:
17+
18+
| URL | Effective servers |
19+
|-----|------------------|
20+
| `/mcp` | All configured servers (unchanged) |
21+
| `/mcp/p/research` | `arxiv`, `wikipedia` only |
22+
| `/mcp/p/deploy` | `k8s`, `github` only |
23+
24+
## Rules
25+
26+
- **Slug format**: `^[a-z0-9][a-z0-9_-]{0,62}$` (max 63 chars, lowercase)
27+
- **Reserved slugs**: `all`, `code`, `call`, `p` — cannot be used as profile names
28+
- **Duplicate names**: rejected at load time (fatal validation error)
29+
- **Unknown server references**: warn-and-skip (non-fatal); the server is excluded from the effective set
30+
- **Empty server list**: legal "deny everything" profile (no tools exposed)
31+
32+
## Behaviour
33+
34+
- `retrieve_tools` returns only tools from the profile's servers
35+
- `call_tool_read/write/destructive` into an out-of-profile server is rejected with:
36+
`server '<name>' is not in profile '<slug>'`
37+
- `upstream_servers list` at a profile URL excludes out-of-profile servers
38+
- `code_execution` at a profile URL runs with the profile-intersected server set
39+
- Per-server `enabled_tools`/`disabled_tools` continue to apply inside a profile (no profile-level tool overrides)
40+
41+
## Scope composition
42+
43+
Profile filtering is independent of agent-token scope. An unauthenticated connection at `/mcp/p/<slug>` is still profile-filtered. When both a profile and an agent token are present, the effective server set is their intersection.
44+
45+
Error attribution:
46+
- Out-of-profile server → `server '<s>' is not in profile '<slug>'`
47+
- Out-of-token server → `Server '<s>' is not in scope for this agent token`
48+
49+
## Activity logging
50+
51+
Tool-call activity records from profile URLs carry `metadata["profile"] = "<slug>"`. Records from `/mcp` omit this field.
52+
53+
## Hot reload
54+
55+
Profile changes take effect for new connections on the next config reload. In-flight sessions keep their snapshot.
56+
57+
## 404 responses
58+
59+
| Condition | Body |
60+
|-----------|------|
61+
| No profiles configured | `{"error":"no profiles configured"}` |
62+
| Unknown slug | `{"error":"unknown profile '<slug>'","available":["research","deploy"]}` |

internal/profile/context.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ func (p *ProfileScope) Allows(serverName string) bool {
5151
return ok
5252
}
5353

54+
// AllowedServerNames returns the list of server names in this profile scope.
55+
// Returns nil for a nil receiver (allow-all — no restriction list).
56+
// Returns an empty slice for a non-nil scope with no servers (deny-all).
57+
func (p *ProfileScope) AllowedServerNames() []string {
58+
if p == nil {
59+
return nil
60+
}
61+
out := make([]string, 0, len(p.servers))
62+
for s := range p.servers {
63+
out = append(out, s)
64+
}
65+
return out
66+
}
67+
5468
// profileScopeKey is an unexported context key avoiding cross-package collisions.
5569
type profileScopeKey struct{}
5670

internal/server/mcp.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/smart-mcp-proxy/mcpproxy-go/internal/logs"
2424
"github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth"
2525
"github.com/smart-mcp-proxy/mcpproxy-go/internal/outputvalidation"
26+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/profile"
2627
"github.com/smart-mcp-proxy/mcpproxy-go/internal/registries"
2728
"github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext"
2829
"github.com/smart-mcp-proxy/mcpproxy-go/internal/security"
@@ -443,6 +444,19 @@ func (p *MCPProxyServer) emitActivityInternalToolCall(internalToolName, targetSe
443444
}
444445
}
445446

447+
// withProfileMeta injects metadata["profile"] = slug into m when the context
448+
// carries an active ProfileScope (Spec 057 FR-011). It returns m (or a new map
449+
// when m is nil) so callers can use it inline. Records from /mcp omit the field.
450+
func withProfileMeta(ctx context.Context, m map[string]interface{}) map[string]interface{} {
451+
if scope := profile.ProfileScopeFromContext(ctx); scope != nil {
452+
if m == nil {
453+
m = make(map[string]interface{})
454+
}
455+
m["profile"] = scope.Name
456+
}
457+
return m
458+
}
459+
446460
// buildCallToolVariantTool constructs the mcp.Tool definition for a single
447461
// call_tool_* variant (read/write/destructive). Factored out so both
448462
// registerTools and schema tests exercise the same builder.
@@ -1111,6 +1125,8 @@ func (p *MCPProxyServer) handleRetrieveToolsWithMode(ctx context.Context, reques
11111125
// want to repeat per result.
11121126
authCtx := auth.AuthContextFromContext(ctx)
11131127
enforceAgentScope := authCtx != nil && !authCtx.IsAdmin()
1128+
// Spec 057: profile scope filter — independent of agent-scope (nil = allow all).
1129+
profileScope := profile.ProfileScopeFromContext(ctx)
11141130

11151131
// Spec 049: opt-in discovery of locked tools. When false (default) the
11161132
// behavior below is byte-for-byte identical to before — disabled tools are
@@ -1142,6 +1158,11 @@ func (p *MCPProxyServer) handleRetrieveToolsWithMode(ctx context.Context, reques
11421158
if enforceAgentScope && !authCtx.CanAccessServer(serverName) {
11431159
continue
11441160
}
1161+
// Spec 057: profile filter — runs independently of agent-scope so that
1162+
// unauthenticated /mcp/p/<slug> connections (AdminContext) are still filtered.
1163+
if !profileScope.Allows(serverName) {
1164+
continue
1165+
}
11451166

11461167
if !p.isToolCallable(serverName, toolName) {
11471168
droppedCount++
@@ -1525,6 +1546,14 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
15251546
return mcp.NewToolResultError(fmt.Sprintf("Invalid tool name format: %s", toolName)), nil
15261547
}
15271548

1549+
// Spec 057: profile filter — runs independently of agent-scope so that
1550+
// unauthenticated /mcp/p/<slug> connections (AdminContext) are still filtered.
1551+
if profileScope := profile.ProfileScopeFromContext(ctx); profileScope != nil && !profileScope.Allows(serverName) {
1552+
errMsg := fmt.Sprintf("server '%s' is not in profile '%s'", serverName, profileScope.Name)
1553+
p.emitActivityPolicyDecision(serverName, actualToolName, getSessionID(), "blocked", errMsg)
1554+
return mcp.NewToolResultError(errMsg), nil
1555+
}
1556+
15281557
// Spec 028: Enforce agent token scope restrictions
15291558
if authCtx := auth.AuthContextFromContext(ctx); authCtx != nil && !authCtx.IsAdmin() {
15301559
// Check server scope
@@ -1888,6 +1917,7 @@ func (p *MCPProxyServer) handleCallToolVariant(ctx context.Context, request mcp.
18881917
if intent != nil {
18891918
intentMap = intent.ToMap()
18901919
}
1920+
intentMap = withProfileMeta(ctx, intentMap) // Spec 057 FR-011
18911921
p.emitActivityToolCallCompleted(serverName, actualToolName, sessionID, requestID, activitySource, "success", "", duration.Milliseconds(), activityArgs, response, responseTruncated, toolVariant, intentMap, contentTrust, activityRequestBytes, activityResponseBytes)
18921922

18931923
// Spec 024: Emit internal tool call event for success
@@ -2777,6 +2807,18 @@ func (p *MCPProxyServer) handleListUpstreams(ctx context.Context) (*mcp.CallTool
27772807
servers = filtered
27782808
}
27792809

2810+
// Spec 057 (FR-004): Filter servers to only those visible in the active profile.
2811+
// Independent of agent-scope so unauthenticated /mcp/p/<slug> connections are filtered.
2812+
if profileScope := profile.ProfileScopeFromContext(ctx); profileScope != nil {
2813+
var filtered []*config.ServerConfig
2814+
for _, s := range servers {
2815+
if profileScope.Allows(s.Name) {
2816+
filtered = append(filtered, s)
2817+
}
2818+
}
2819+
servers = filtered
2820+
}
2821+
27802822
// Check Docker availability only if Docker isolation is globally enabled
27812823
dockerIsolationGlobalEnabled := p.config.DockerIsolation != nil && p.config.DockerIsolation.Enabled
27822824
var dockerAvailable bool

internal/server/mcp_code_execution.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/smart-mcp-proxy/mcpproxy-go/internal/auth"
1111
"github.com/smart-mcp-proxy/mcpproxy-go/internal/contracts"
1212
"github.com/smart-mcp-proxy/mcpproxy-go/internal/jsruntime"
13+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/profile"
1314
"github.com/smart-mcp-proxy/mcpproxy-go/internal/storage"
1415
"github.com/smart-mcp-proxy/mcpproxy-go/internal/upstream"
1516

@@ -190,6 +191,32 @@ func (p *MCPProxyServer) handleCodeExecution(ctx context.Context, request mcp.Ca
190191
options.ToolAnnotationFunc = p.lookupToolPermission
191192
}
192193

194+
// Spec 057 (Codex #621 finding 2): Intersect profile scope into code_execution.
195+
// The jsruntime treats an empty AllowedServers as "allow all"; at a profile URL
196+
// we must restrict to profile servers regardless of what the caller supplied.
197+
if profileScope := profile.ProfileScopeFromContext(ctx); profileScope != nil {
198+
// Build the effective allowed-servers list: profile servers only.
199+
// If the caller also supplied allowed_servers, intersect the two sets.
200+
profileServers := profileScope.AllowedServerNames()
201+
if len(options.AllowedServers) == 0 {
202+
// No caller-supplied restriction: use profile servers as the restriction.
203+
options.AllowedServers = profileServers
204+
} else {
205+
// Intersect caller-supplied list with profile servers.
206+
profileSet := make(map[string]struct{}, len(profileServers))
207+
for _, s := range profileServers {
208+
profileSet[s] = struct{}{}
209+
}
210+
var intersected []string
211+
for _, s := range options.AllowedServers {
212+
if _, ok := profileSet[s]; ok {
213+
intersected = append(intersected, s)
214+
}
215+
}
216+
options.AllowedServers = intersected
217+
}
218+
}
219+
193220
// Execute code
194221
p.logger.Info("executing code",
195222
zap.String("execution_id", options.ExecutionID),

0 commit comments

Comments
 (0)