Skip to content

Commit c7a0e55

Browse files
authored
feat(profiles): per-agent-token profile_pin + server-side enforcement (Profiles v2 T3, MCP-3242)
Reviewed and accepted by CEO (Claude) and KimiReviewer. All CI passes. MCP-3427 review thread.
1 parent be57406 commit c7a0e55

12 files changed

Lines changed: 457 additions & 17 deletions

File tree

cmd/mcpproxy/token_cmd.go

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ var (
2323
tokenServers string
2424
tokenPermissions string
2525
tokenExpires string
26+
tokenProfilePin string
2627
)
2728

2829
// GetTokenCommand returns the token parent command.
@@ -73,6 +74,7 @@ Examples:
7374
cmd.Flags().StringVar(&tokenServers, "servers", "", "Comma-separated list of allowed server names, or \"*\" for all (required)")
7475
cmd.Flags().StringVar(&tokenPermissions, "permissions", "", "Comma-separated permission tiers: read, write, destructive (required, must include read)")
7576
cmd.Flags().StringVar(&tokenExpires, "expires", "30d", "Token expiry duration (e.g., 7d, 30d, 90d, 365d)")
77+
cmd.Flags().StringVar(&tokenProfilePin, "profile-pin", "", "Pin this token to a profile; it can only operate in that profile (cannot switch via set_profile or /mcp/p/<other>)")
7678
_ = cmd.MarkFlagRequired("name")
7779
_ = cmd.MarkFlagRequired("servers")
7880
_ = cmd.MarkFlagRequired("permissions")
@@ -160,6 +162,9 @@ func runTokenCreate(_ *cobra.Command, _ []string) error {
160162
"permissions": permissions,
161163
"expires_in": tokenExpires,
162164
}
165+
if tokenProfilePin != "" {
166+
body["profile_pin"] = tokenProfilePin
167+
}
163168

164169
bodyJSON, err := json.Marshal(body)
165170
if err != nil {
@@ -209,6 +214,9 @@ func runTokenCreate(_ *cobra.Command, _ []string) error {
209214
printField(" Name: ", result, "name")
210215
printListField(" Servers: ", result, "allowed_servers")
211216
printListField(" Permissions: ", result, "permissions")
217+
if pin := getMapString(result, "profile_pin"); pin != "" {
218+
fmt.Printf(" Profile Pin: %s\n", pin)
219+
}
212220
printField(" Expires: ", result, "expires_at")
213221

214222
return nil
@@ -257,9 +265,9 @@ func runTokenList(_ *cobra.Command, _ []string) error {
257265
}
258266

259267
// Table format
260-
fmt.Printf("%-20s %-14s %-25s %-20s %-8s %-25s\n",
261-
"NAME", "PREFIX", "SERVERS", "PERMISSIONS", "REVOKED", "EXPIRES")
262-
fmt.Println(strings.Repeat("-", 115))
268+
fmt.Printf("%-20s %-14s %-25s %-20s %-8s %-12s %-25s\n",
269+
"NAME", "PREFIX", "SERVERS", "PERMISSIONS", "REVOKED", "PROFILE PIN", "EXPIRES")
270+
fmt.Println(strings.Repeat("-", 128))
263271

264272
for _, t := range tokens {
265273
tok, ok := t.(map[string]interface{})
@@ -276,15 +284,20 @@ func runTokenList(_ *cobra.Command, _ []string) error {
276284
serverList := joinInterfaceSlice(tok, "allowed_servers", 23)
277285
permList := joinInterfaceSlice(tok, "permissions", 0)
278286

287+
pin := getMapString(tok, "profile_pin")
288+
if pin == "" {
289+
pin = "-"
290+
}
291+
279292
expiresAt := getMapString(tok, "expires_at")
280293
if expiresAt != "" {
281294
if t, parseErr := time.Parse(time.RFC3339, expiresAt); parseErr == nil {
282295
expiresAt = t.Format("2006-01-02 15:04")
283296
}
284297
}
285298

286-
fmt.Printf("%-20s %-14s %-25s %-20s %-8s %-25s\n",
287-
name, prefix, serverList, permList, revoked, expiresAt)
299+
fmt.Printf("%-20s %-14s %-25s %-20s %-8s %-12s %-25s\n",
300+
name, prefix, serverList, permList, revoked, pin, expiresAt)
288301
}
289302

290303
return nil
@@ -335,6 +348,9 @@ func runTokenShow(_ *cobra.Command, args []string) error {
335348
printField("Token Prefix: ", result, "token_prefix")
336349
printListField("Servers: ", result, "allowed_servers")
337350
printListField("Permissions: ", result, "permissions")
351+
if pin := getMapString(result, "profile_pin"); pin != "" {
352+
fmt.Printf("Profile Pin: %s\n", pin)
353+
}
338354
if revoked, ok := result["revoked"].(bool); ok {
339355
fmt.Printf("Revoked: %v\n", revoked)
340356
}

docs/features/agent-tokens.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,38 @@ Server scoping is enforced at two levels:
176176
1. **Tool discovery** (`retrieve_tools`) — only returns tools from allowed servers
177177
2. **Tool execution** (`call_tool_*`) — blocks calls to out-of-scope servers
178178

179+
## Profile Pinning
180+
181+
A [profile](./profiles.md) scopes tool discovery and calls to a named subset of upstream servers. With `--profile-pin`, you can **bind a token to a single profile** so it can never operate outside it — regardless of the URL it connects to or any `set_profile` call it makes.
182+
183+
```bash
184+
# This token can ONLY ever see/use the "research" profile
185+
mcpproxy token create \
186+
--name research-agent \
187+
--servers "*" \
188+
--permissions read \
189+
--profile-pin research
190+
```
191+
192+
Server-side enforcement (no client cooperation required):
193+
194+
- **`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).
195+
- **`/mcp/p/<other>` returns `403`** — connecting to any profile URL other than the pinned one is forbidden; the pinned profile's own URL works.
196+
- **The pin is the highest-precedence resolver source**, above an explicit `/mcp/p/<slug>` URL scope and above a session `set_profile` selection.
197+
198+
Resolution precedence (highest wins):
199+
200+
```
201+
1. agent-token profile_pin (server-enforced; this section)
202+
2. /mcp/p/<slug> URL scope (per-request override)
203+
3. set_profile session state (base /mcp endpoint default for the session)
204+
4. none (no profile filtering — all allowed servers)
205+
```
206+
207+
**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.
208+
209+
The pin is shown by `token list` (PROFILE PIN column) and `token show` (Profile Pin field), and is preserved across `token regenerate`.
210+
179211
## Managing Tokens
180212

181213
### List All Tokens
@@ -304,3 +336,4 @@ mcpproxy serve --require-mcp-auth # Enforce /mcp authentication
304336
| `--servers` | Yes || Comma-separated server names or `"*"` |
305337
| `--permissions` | Yes || Comma-separated: `read`, `write`, `destructive` |
306338
| `--expires` | No | `30d` | Expiry duration (e.g., `7d`, `90d`, `365d`) |
339+
| `--profile-pin` | No || Pin the token to a single profile (see [Profile Pinning](#profile-pinning)) |

internal/auth/agent_token.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ type AgentToken struct {
4242
CreatedAt time.Time `json:"created_at"`
4343
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
4444
Revoked bool `json:"revoked"`
45-
UserID string `json:"user_id,omitempty"` // Owner user ID (server edition)
45+
UserID string `json:"user_id,omitempty"` // Owner user ID (server edition)
46+
ProfilePin string `json:"profile_pin,omitempty"` // Profile this token is pinned to (Profiles v2 T3)
4647
}
4748

4849
// IsExpired returns true if the token has passed its expiry time.

internal/auth/context.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type AuthContext struct {
1717
TokenPrefix string // First 12 chars of raw token (empty for admin)
1818
AllowedServers []string // Servers this token can access (nil = all for admin)
1919
Permissions []string // Permission tiers (nil = all for admin)
20+
ProfilePin string // Profile this agent token is pinned to (Profiles v2 T3; empty if unpinned)
2021

2122
// Multi-user OAuth fields (server edition). Empty for non-user auth types.
2223
UserID string // User's unique ULID identifier

internal/httpapi/tokens.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ type createTokenRequest struct {
3838
AllowedServers []string `json:"allowed_servers"`
3939
Permissions []string `json:"permissions"`
4040
ExpiresIn string `json:"expires_in"`
41+
ProfilePin string `json:"profile_pin,omitempty"`
4142
}
4243

4344
// createTokenResponse is the JSON response for POST /api/v1/tokens.
@@ -48,6 +49,7 @@ type createTokenResponse struct {
4849
Permissions []string `json:"permissions"`
4950
ExpiresAt time.Time `json:"expires_at"`
5051
CreatedAt time.Time `json:"created_at"`
52+
ProfilePin string `json:"profile_pin,omitempty"`
5153
}
5254

5355
// tokenInfoResponse is the JSON response for GET endpoints (no secret).
@@ -60,6 +62,7 @@ type tokenInfoResponse struct {
6062
CreatedAt time.Time `json:"created_at"`
6163
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
6264
Revoked bool `json:"revoked"`
65+
ProfilePin string `json:"profile_pin,omitempty"`
6366
}
6467

6568
// regenerateTokenResponse is the JSON response for POST /api/v1/tokens/{name}/regenerate.
@@ -148,6 +151,16 @@ func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
148151
req.AllowedServers = []string{"*"}
149152
}
150153

154+
// Validate profile_pin (Profiles v2 T3): the slug must name a configured
155+
// profile at creation time. Later config changes are warn-skipped at request
156+
// time by the resolver, not hard-failed here.
157+
if req.ProfilePin != "" {
158+
if err := s.validateProfilePin(req.ProfilePin); err != nil {
159+
s.writeError(w, r, http.StatusBadRequest, err.Error())
160+
return
161+
}
162+
}
163+
151164
// Generate token
152165
rawToken, err := auth.GenerateToken()
153166
if err != nil {
@@ -171,6 +184,7 @@ func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
171184
Permissions: req.Permissions,
172185
ExpiresAt: expiresAt,
173186
CreatedAt: now,
187+
ProfilePin: req.ProfilePin,
174188
}
175189

176190
if err := s.tokenStore.CreateAgentToken(agentToken, rawToken, hmacKey); err != nil {
@@ -191,6 +205,7 @@ func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
191205
Permissions: req.Permissions,
192206
ExpiresAt: expiresAt,
193207
CreatedAt: now,
208+
ProfilePin: req.ProfilePin,
194209
}
195210

196211
s.writeJSON(w, http.StatusCreated, contracts.NewSuccessResponse(resp))
@@ -350,6 +365,26 @@ func validateTokenPermissions(perms []string) error {
350365
return auth.ValidatePermissions(perms)
351366
}
352367

368+
// validateProfilePin checks that the given profile slug names a profile in the
369+
// current configuration (Profiles v2 T3). It errors if profiles are unavailable
370+
// or the slug is unknown, so a token cannot be pinned to a non-existent profile.
371+
func (s *Server) validateProfilePin(slug string) error {
372+
cfg, err := s.controller.GetConfig()
373+
if err != nil || cfg == nil {
374+
return fmt.Errorf("cannot validate profile_pin: configuration unavailable")
375+
}
376+
for i := range cfg.Profiles {
377+
if cfg.Profiles[i].Name == slug {
378+
return nil
379+
}
380+
}
381+
available := make([]string, 0, len(cfg.Profiles))
382+
for i := range cfg.Profiles {
383+
available = append(available, cfg.Profiles[i].Name)
384+
}
385+
return fmt.Errorf("unknown profile_pin %q (available: %s)", slug, strings.Join(available, ", "))
386+
}
387+
353388
// parseExpiry parses an expiry duration string and returns the absolute expiry time.
354389
// Accepted formats: "30d" (days), "720h" (hours), or any Go duration string.
355390
// Maximum allowed duration is 365 days. Empty string defaults to 30 days.
@@ -434,5 +469,6 @@ func tokenToInfoResponse(t auth.AgentToken) tokenInfoResponse {
434469
CreatedAt: t.CreatedAt,
435470
LastUsedAt: t.LastUsedAt,
436471
Revoked: t.Revoked,
472+
ProfilePin: t.ProfilePin,
437473
}
438474
}

internal/httpapi/tokens_test.go

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,9 @@ func (m *mockTokenStore) RegenerateAgentToken(name string, _ string, _ []byte) (
115115

116116
type mockTokenController struct {
117117
baseController
118-
apiKey string
119-
servers []string
118+
apiKey string
119+
servers []string
120+
profiles []string
120121
}
121122

122123
func (m *mockTokenController) GetCurrentConfig() interface{} {
@@ -125,6 +126,14 @@ func (m *mockTokenController) GetCurrentConfig() interface{} {
125126
}
126127
}
127128

129+
func (m *mockTokenController) GetConfig() (*config.Config, error) {
130+
cfg := &config.Config{APIKey: m.apiKey}
131+
for _, name := range m.profiles {
132+
cfg.Profiles = append(cfg.Profiles, config.ProfileConfig{Name: name})
133+
}
134+
return cfg, nil
135+
}
136+
128137
func (m *mockTokenController) GetAllServers() ([]map[string]interface{}, error) {
129138
result := make([]map[string]interface{}, 0, len(m.servers))
130139
for _, name := range m.servers {
@@ -153,6 +162,21 @@ func newTestTokenServer(t *testing.T, store *mockTokenStore, servers []string) *
153162
return srv
154163
}
155164

165+
// newTestTokenServerWithProfiles is like newTestTokenServer but also configures
166+
// the controller's known profiles (for profile_pin validation tests).
167+
func newTestTokenServerWithProfiles(t *testing.T, store *mockTokenStore, servers, profiles []string) *Server {
168+
t.Helper()
169+
logger := zap.NewNop().Sugar()
170+
ctrl := &mockTokenController{
171+
apiKey: "test-api-key",
172+
servers: servers,
173+
profiles: profiles,
174+
}
175+
srv := NewServer(ctrl, logger, nil)
176+
srv.SetTokenStore(store, t.TempDir())
177+
return srv
178+
}
179+
156180
func doRequest(t *testing.T, srv *Server, method, path string, body interface{}) *httptest.ResponseRecorder {
157181
t.Helper()
158182
var reqBody *bytes.Reader
@@ -220,6 +244,55 @@ func TestCreateToken_Success(t *testing.T) {
220244
assert.Equal(t, "my-agent", stored.Name)
221245
}
222246

247+
func TestCreateToken_ProfilePin(t *testing.T) {
248+
store := newMockTokenStore()
249+
srv := newTestTokenServerWithProfiles(t, store, []string{"server1"}, []string{"research", "deploy"})
250+
251+
body := createTokenRequest{
252+
Name: "pinned-agent",
253+
AllowedServers: []string{"server1"},
254+
Permissions: []string{"read"},
255+
ProfilePin: "research",
256+
}
257+
258+
w := doRequest(t, srv, http.MethodPost, "/api/v1/tokens", body)
259+
require.Equal(t, http.StatusCreated, w.Code, "expected 201; body=%s", w.Body.String())
260+
261+
var resp createTokenResponse
262+
decodeSuccess(t, w, &resp)
263+
assert.Equal(t, "research", resp.ProfilePin, "profile_pin must be echoed on create")
264+
265+
// Stored record carries the pin.
266+
stored, err := store.GetAgentTokenByName("pinned-agent")
267+
require.NoError(t, err)
268+
require.NotNil(t, stored)
269+
assert.Equal(t, "research", stored.ProfilePin)
270+
271+
// GET surfaces the pin.
272+
g := doRequest(t, srv, http.MethodGet, "/api/v1/tokens/pinned-agent", nil)
273+
require.Equal(t, http.StatusOK, g.Code)
274+
var info tokenInfoResponse
275+
decodeSuccess(t, g, &info)
276+
assert.Equal(t, "research", info.ProfilePin, "profile_pin must be surfaced on read")
277+
}
278+
279+
func TestCreateToken_ProfilePinUnknownRejected(t *testing.T) {
280+
store := newMockTokenStore()
281+
srv := newTestTokenServerWithProfiles(t, store, []string{"server1"}, []string{"research"})
282+
283+
body := createTokenRequest{
284+
Name: "bad-pin",
285+
Permissions: []string{"read"},
286+
ProfilePin: "ghost",
287+
}
288+
289+
w := doRequest(t, srv, http.MethodPost, "/api/v1/tokens", body)
290+
assert.Equal(t, http.StatusBadRequest, w.Code, "unknown profile_pin must be rejected at creation")
291+
292+
_, err := store.GetAgentTokenByName("bad-pin")
293+
require.NoError(t, err)
294+
}
295+
223296
func TestCreateToken_DefaultPermissions(t *testing.T) {
224297
store := newMockTokenStore()
225298
srv := newTestTokenServer(t, store, nil)

0 commit comments

Comments
 (0)