Skip to content

Commit f91d0a6

Browse files
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>
1 parent 0b8cf2a commit f91d0a6

7 files changed

Lines changed: 403 additions & 1 deletion

File tree

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+
}

internal/profile/context.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Package profile carries request-scoped in-proxy profile filtering (Spec 057).
2+
//
3+
// A profile is a named, stateless view over a subset of the configured upstream
4+
// servers, addressable at /mcp/p/<slug>. A ProfileScope is resolved once by the
5+
// profile middleware from the request URL and injected into the request context;
6+
// the scope filters which servers a request may see/call. It is an independent,
7+
// auth-type-agnostic primitive that composes with (but does not depend on) the
8+
// Spec 028 agent-token scope — an unauthenticated /mcp/p/<slug> connection runs
9+
// as an admin AuthContext yet must still be profile-filtered.
10+
package profile
11+
12+
import "context"
13+
14+
// ProfileScope is the immutable, request-scoped set of servers a profile exposes.
15+
// It is resolved by profileMiddleware from the /mcp/p/<slug> URL and never mutated
16+
// for the lifetime of a request.
17+
type ProfileScope struct {
18+
// Name is the resolved profile slug, used in rejection messages and activity
19+
// metadata (FR-012).
20+
Name string
21+
// servers is the effective set after unknown-server warn-skip. A non-nil but
22+
// empty set is a legal "deny everything" profile.
23+
servers map[string]struct{}
24+
}
25+
26+
// NewProfileScope builds a scope for the named profile over the given effective
27+
// server set. The returned scope is always non-nil (an empty/nil server list is
28+
// a legal profile that allows nothing).
29+
func NewProfileScope(name string, servers []string) *ProfileScope {
30+
set := make(map[string]struct{}, len(servers))
31+
for _, s := range servers {
32+
set[s] = struct{}{}
33+
}
34+
return &ProfileScope{Name: name, servers: set}
35+
}
36+
37+
// Allows reports whether the named server is visible under this scope.
38+
//
39+
// A nil receiver means the request did not enter via /mcp/p/<slug> (it used /mcp,
40+
// /mcp/code, or /mcp/call) and therefore is not profile-filtered — every server
41+
// is allowed (FR-010). A non-nil scope allows only servers in its set; the empty
42+
// server name is never allowed for a real scope.
43+
func (p *ProfileScope) Allows(serverName string) bool {
44+
if p == nil {
45+
return true
46+
}
47+
if serverName == "" {
48+
return false
49+
}
50+
_, ok := p.servers[serverName]
51+
return ok
52+
}
53+
54+
// profileScopeKey is an unexported context key avoiding cross-package collisions.
55+
type profileScopeKey struct{}
56+
57+
// WithProfileScope returns a context carrying the given ProfileScope.
58+
func WithProfileScope(ctx context.Context, p *ProfileScope) context.Context {
59+
return context.WithValue(ctx, profileScopeKey{}, p)
60+
}
61+
62+
// ProfileScopeFromContext extracts the ProfileScope, or nil when the request did
63+
// not enter via a profile URL (no filtering).
64+
func ProfileScopeFromContext(ctx context.Context) *ProfileScope {
65+
p, _ := ctx.Value(profileScopeKey{}).(*ProfileScope)
66+
return p
67+
}

0 commit comments

Comments
 (0)