Skip to content

Commit 3a7718e

Browse files
feat(057): In-proxy profiles + permanent URLs (/mcp/p/<slug>)
* feat(057): profile config entity + ProfileScope context (T001-T007) Foundational phase for in-proxy profiles (Spec 057, GH #55). No behaviour change yet — adds the config entity + validation and the request-scoped scope primitive; the /mcp/p/ routing and filter sites come next. - internal/config/profiles.go: ProfileConfig{Name,Servers} + ValidateProfiles (fatal: slug pattern ^[a-z0-9][a-z0-9_-]{0,62}$, reserved {all,code,call,p}, duplicate name; warn-skip: unknown server, empty servers). EffectiveServers helper. (T003) - internal/config/config.go: Config.Profiles []ProfileConfig `json:",omitempty"` after Servers (SC-004 byte-identical when absent); Validate() runs ValidateProfiles, fails on fatal, stashes warnings; ProfileWarnings() accessor for the boot path to log. (T004) - internal/profile/context.go: ProfileScope{Name, servers set} with nil-receiver allow-all Allows(), WithProfileScope/ProfileScopeFromContext (mirrors internal/auth/context.go). (T007) - Tests red-first: profiles_test.go (validation table + round-trip, T002/T005), profile/context_test.go (membership, nil allow-all, empty deny-all, context round-trip, T006). Both editions build; go vet clean. Related #55 Co-Authored-By: Paperclip <noreply@paperclip.ing> * 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 * test(057): fix config-mutation data races in profile integration tests Copy config struct before mutating to avoid -race failures. Related #55 * fix(057): address Codex review of PR #622 (deny-all bypass + FR-011 metadata) Round-1 review fixes for the in-proxy profiles impl (MCP-1281/MCP-1278): 1. code_execution empty-effective-set bypass (MUST-FIX). A deny-all profile (servers: []) or a non-overlapping token∩profile yields an empty effective allow-list, which the JS runtime treated as "allow all" — leaking every server through code_execution. Add ExecutionOptions.RestrictToAllowed: when an active profile is in context, the allow-list is enforced even when empty (empty set = deny all). Set by the profile path in handleCodeExecution. 2. FR-011 metadata written to wrong shape (MUST-FIX). withProfileMeta smuggled the slug into the intent submap, so records landed as metadata.intent.profile and error paths dropped it entirely. Thread an explicit profile slug through EmitActivityToolCallCompleted → metadata["profile"] at top level, on success AND error paths. 3. Spec amendment (no code change): document per-request profile resolution (hot-reload takes effect on the next request) in spec.md + data-model.md, replacing the inaccurate "snapshot-until-reconnect" wording. Per-request re-resolution is kept as the safer behavior. Tests: jsruntime deny-all/non-empty enforcement + backward-compat; Activity service metadata["profile"] (success+error, not nested); code_execution deny-all/non-overlapping rejection at the profile path; upgraded the FR-011 integration test to assert the persisted record's metadata["profile"]. Related #622 --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1 parent 0b8cf2a commit 3a7718e

21 files changed

Lines changed: 1569 additions & 33 deletions

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/config/config.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ type Config struct {
107107
EnableTray bool `json:"enable_tray,omitempty" mapstructure:"tray"`
108108
DebugSearch bool `json:"debug_search" mapstructure:"debug-search"`
109109
Servers []*ServerConfig `json:"mcpServers" mapstructure:"servers"`
110+
// Profiles are optional named, server-scoped views exposed at /mcp/p/<name>
111+
// (Spec 057). Absent/empty is fully supported — /mcp is unchanged and configs
112+
// without this key serialize byte-identically (SC-004).
113+
Profiles []ProfileConfig `json:"profiles,omitempty" mapstructure:"profiles"`
110114
// Deprecated: TopK is superseded by ToolsLimit and has no runtime effect. Kept for backward compatibility.
111115
TopK int `json:"top_k,omitempty" mapstructure:"top-k"`
112116
ToolsLimit int `json:"tools_limit" mapstructure:"tools-limit"`
@@ -140,6 +144,10 @@ type Config struct {
140144
// Internal field to track if API key was explicitly set in config
141145
apiKeyExplicitlySet bool `json:"-"`
142146

147+
// profileWarnings holds non-fatal Spec 057 profile diagnostics (unknown /
148+
// empty servers) captured during Validate(), for the boot path to log.
149+
profileWarnings []string `json:"-"`
150+
143151
// Prompts settings
144152
EnablePrompts bool `json:"enable_prompts" mapstructure:"enable-prompts"`
145153

@@ -1553,6 +1561,15 @@ func (c *Config) Validate() error {
15531561
return fmt.Errorf("%s", errors[0].Error())
15541562
}
15551563

1564+
// Validate profiles (Spec 057): fatal rules (invalid/reserved/duplicate slug)
1565+
// fail the load; soft rules (unknown/empty servers) are stashed as warnings
1566+
// for the boot path to log via its logger (see ProfileWarnings).
1567+
profileWarnings, profileErr := ValidateProfiles(c)
1568+
if profileErr != nil {
1569+
return profileErr
1570+
}
1571+
c.profileWarnings = profileWarnings
1572+
15561573
// Handle API key generation if not configured
15571574
// Empty string means authentication disabled, nil means auto-generate
15581575
if c.APIKey == "" {

internal/config/profiles.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package config
2+
3+
import (
4+
"fmt"
5+
"regexp"
6+
)
7+
8+
// ProfileConfig is a named, stateless view over a subset of the configured
9+
// upstream servers, addressable at /mcp/p/<name> (Spec 057). The Name is used
10+
// verbatim as the URL slug. Servers references mcpServers[].name; unknown names
11+
// warn-and-skip rather than fail (FR-015).
12+
type ProfileConfig struct {
13+
Name string `json:"name"` // URL slug, validated
14+
Servers []string `json:"servers"` // references to mcpServers[].name
15+
}
16+
17+
// profileSlugPattern is the allowed profile-name form (FR-007): lowercase
18+
// alphanumeric start, then up to 62 more of [a-z0-9_-] — max 63 chars total.
19+
var profileSlugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]{0,62}$`)
20+
21+
// reservedProfileSlugs are URL path segments that collide with existing /mcp
22+
// routes (/mcp/code, /mcp/call) or the /mcp/p prefix itself, plus "all"
23+
// reserved for a future explicit all-servers profile (FR-007).
24+
var reservedProfileSlugs = map[string]struct{}{
25+
"all": {},
26+
"code": {},
27+
"call": {},
28+
"p": {},
29+
}
30+
31+
// ValidateProfiles enforces the Spec 057 profile rules (data-model.md). Fatal
32+
// rules (invalid/reserved/duplicate slug) return an error that points at the
33+
// offending entry; soft rules (unknown server, empty server list) return
34+
// human-readable warnings without failing the load. A nil/empty Profiles slice
35+
// is fully valid (returns no warnings, no error) — preserving zero-config
36+
// behaviour (SC-004).
37+
func ValidateProfiles(cfg *Config) (warnings []string, err error) {
38+
if cfg == nil || len(cfg.Profiles) == 0 {
39+
return nil, nil
40+
}
41+
42+
// Build the set of known server names for membership checks.
43+
known := make(map[string]struct{}, len(cfg.Servers))
44+
for _, s := range cfg.Servers {
45+
if s != nil {
46+
known[s.Name] = struct{}{}
47+
}
48+
}
49+
50+
seen := make(map[string]int, len(cfg.Profiles)) // slug -> first index
51+
for i, p := range cfg.Profiles {
52+
// Fatal: slug format.
53+
if !profileSlugPattern.MatchString(p.Name) {
54+
return warnings, fmt.Errorf("profiles[%d]: invalid profile name %q: must match %s (lowercase alphanumeric, '-'/'_', 1-63 chars)", i, p.Name, profileSlugPattern.String())
55+
}
56+
// Fatal: reserved slug.
57+
if _, reserved := reservedProfileSlugs[p.Name]; reserved {
58+
return warnings, fmt.Errorf("profiles[%d]: profile name %q is reserved and cannot be used", i, p.Name)
59+
}
60+
// Fatal: duplicate name (name both occurrences).
61+
if first, dup := seen[p.Name]; dup {
62+
return warnings, fmt.Errorf("profiles[%d]: duplicate profile name %q (already defined at profiles[%d])", i, p.Name, first)
63+
}
64+
seen[p.Name] = i
65+
66+
// Warning: empty server list (legal deny-all).
67+
if len(p.Servers) == 0 {
68+
warnings = append(warnings, fmt.Sprintf("profile %q has no servers; it will expose zero tools (deny-all placeholder)", p.Name))
69+
continue
70+
}
71+
// Warning: unknown server references (warn-and-skip).
72+
for _, srv := range p.Servers {
73+
if _, ok := known[srv]; !ok {
74+
warnings = append(warnings, fmt.Sprintf("profile %q references unknown server %q; it will be skipped", p.Name, srv))
75+
}
76+
}
77+
}
78+
79+
return warnings, nil
80+
}
81+
82+
// ProfileWarnings returns the non-fatal profile diagnostics captured by the most
83+
// recent Validate() call (unknown-server and empty-server warnings). The boot
84+
// path logs these via its logger; an empty result means no warnings.
85+
func (c *Config) ProfileWarnings() []string {
86+
return c.profileWarnings
87+
}
88+
89+
// EffectiveServers returns the profile's server list filtered to those that
90+
// actually exist in cfg (the warn-skip result). Order is preserved and unknown
91+
// names are dropped. Used by the profile middleware to build the request scope.
92+
func (p ProfileConfig) EffectiveServers(cfg *Config) []string {
93+
if cfg == nil {
94+
return nil
95+
}
96+
known := make(map[string]struct{}, len(cfg.Servers))
97+
for _, s := range cfg.Servers {
98+
if s != nil {
99+
known[s.Name] = struct{}{}
100+
}
101+
}
102+
out := make([]string, 0, len(p.Servers))
103+
for _, srv := range p.Servers {
104+
if _, ok := known[srv]; ok {
105+
out = append(out, srv)
106+
}
107+
}
108+
return out
109+
}

internal/config/profiles_test.go

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
package config
2+
3+
import (
4+
"encoding/json"
5+
"strings"
6+
"testing"
7+
)
8+
9+
func anyContains(ss []string, sub string) bool {
10+
for _, s := range ss {
11+
if strings.Contains(s, sub) {
12+
return true
13+
}
14+
}
15+
return false
16+
}
17+
18+
// TestValidateProfiles covers the data-model.md validation table (Spec 057):
19+
// fatal slug/reserved/duplicate rules and warn-skip for unknown/empty servers.
20+
func TestValidateProfiles(t *testing.T) {
21+
mk := func(profiles []ProfileConfig, servers ...string) *Config {
22+
var sc []*ServerConfig
23+
for _, s := range servers {
24+
sc = append(sc, &ServerConfig{Name: s})
25+
}
26+
return &Config{Servers: sc, Profiles: profiles}
27+
}
28+
29+
t.Run("valid profile passes with no error or warning", func(t *testing.T) {
30+
warnings, err := ValidateProfiles(mk([]ProfileConfig{{Name: "research", Servers: []string{"fs", "web"}}}, "fs", "web"))
31+
if err != nil {
32+
t.Fatalf("unexpected error: %v", err)
33+
}
34+
if len(warnings) != 0 {
35+
t.Errorf("expected no warnings, got %v", warnings)
36+
}
37+
})
38+
39+
fatal := []struct {
40+
name string
41+
profiles []ProfileConfig
42+
}{
43+
{"uppercase slug", []ProfileConfig{{Name: "Research", Servers: []string{"fs"}}}},
44+
{"leading dash", []ProfileConfig{{Name: "-x", Servers: []string{"fs"}}}},
45+
{"contains space", []ProfileConfig{{Name: "a b", Servers: []string{"fs"}}}},
46+
{"too long (64)", []ProfileConfig{{Name: strings.Repeat("a", 64), Servers: []string{"fs"}}}},
47+
{"reserved all", []ProfileConfig{{Name: "all", Servers: []string{"fs"}}}},
48+
{"reserved code", []ProfileConfig{{Name: "code", Servers: []string{"fs"}}}},
49+
{"reserved call", []ProfileConfig{{Name: "call", Servers: []string{"fs"}}}},
50+
{"reserved p", []ProfileConfig{{Name: "p", Servers: []string{"fs"}}}},
51+
}
52+
for _, tc := range fatal {
53+
t.Run("fatal: "+tc.name, func(t *testing.T) {
54+
_, err := ValidateProfiles(mk(tc.profiles, "fs"))
55+
if err == nil {
56+
t.Errorf("expected fatal error for %s", tc.name)
57+
}
58+
})
59+
}
60+
61+
t.Run("boundary: 63-char slug is valid", func(t *testing.T) {
62+
_, err := ValidateProfiles(mk([]ProfileConfig{{Name: strings.Repeat("a", 63), Servers: []string{"fs"}}}, "fs"))
63+
if err != nil {
64+
t.Errorf("63-char slug must be valid, got %v", err)
65+
}
66+
})
67+
68+
t.Run("fatal: duplicate name names the slug", func(t *testing.T) {
69+
_, err := ValidateProfiles(mk([]ProfileConfig{
70+
{Name: "dup", Servers: []string{"fs"}},
71+
{Name: "dup", Servers: []string{"web"}},
72+
}, "fs", "web"))
73+
if err == nil || !strings.Contains(err.Error(), "dup") {
74+
t.Errorf("expected duplicate-name error mentioning 'dup', got %v", err)
75+
}
76+
})
77+
78+
t.Run("warn-skip: unknown server warns, does not fail", func(t *testing.T) {
79+
warnings, err := ValidateProfiles(mk([]ProfileConfig{{Name: "x", Servers: []string{"fs", "ghost"}}}, "fs"))
80+
if err != nil {
81+
t.Fatalf("unknown server must warn-and-skip, not fail: %v", err)
82+
}
83+
if !anyContains(warnings, "ghost") {
84+
t.Errorf("expected a warning naming the unknown server 'ghost', got %v", warnings)
85+
}
86+
})
87+
88+
t.Run("warn: empty servers is a legal deny-all", func(t *testing.T) {
89+
warnings, err := ValidateProfiles(mk([]ProfileConfig{{Name: "locked", Servers: nil}}))
90+
if err != nil {
91+
t.Fatalf("empty servers is legal (deny-all): %v", err)
92+
}
93+
if !anyContains(warnings, "locked") {
94+
t.Errorf("expected a warning naming the empty profile 'locked', got %v", warnings)
95+
}
96+
})
97+
}
98+
99+
// TestProfilesRoundTrip covers SC-004: absent profiles serialize away (omitempty)
100+
// so existing configs are byte-identical; present profiles round-trip losslessly.
101+
func TestProfilesRoundTrip(t *testing.T) {
102+
noProfiles := &Config{Listen: "127.0.0.1:8080"}
103+
b, err := json.Marshal(noProfiles)
104+
if err != nil {
105+
t.Fatal(err)
106+
}
107+
if strings.Contains(string(b), "profiles") {
108+
t.Errorf("absent profiles must not appear in JSON (omitempty): %s", b)
109+
}
110+
111+
withProfiles := &Config{Profiles: []ProfileConfig{{Name: "research", Servers: []string{"fs", "web"}}}}
112+
b2, err := json.Marshal(withProfiles)
113+
if err != nil {
114+
t.Fatal(err)
115+
}
116+
var back Config
117+
if err := json.Unmarshal(b2, &back); err != nil {
118+
t.Fatal(err)
119+
}
120+
if len(back.Profiles) != 1 || back.Profiles[0].Name != "research" || len(back.Profiles[0].Servers) != 2 {
121+
t.Errorf("profiles did not round-trip losslessly: %+v", back.Profiles)
122+
}
123+
}

0 commit comments

Comments
 (0)