Skip to content

Commit 0b8cf2a

Browse files
authored
docs(057): in-proxy profiles plan + tasks (Related #55) (#621)
* docs(057): add plan, research, data-model, contracts, quickstart, tasks for in-proxy profiles Related #55 Planning artifacts for Spec 057 (In-Proxy Profiles + Permanent URLs), following the spec-first pattern (spec.md already on main; plan + tasks reviewed before implementation). - plan.md: technical approach grounded in verified code seams (single /mcp/p/ prefix handler + profileMiddleware; parallel auth-type-independent filter at the two mcp.go scope sites; reuse retrieve_tools-mode server instance; hot-reload via configsvc snapshot) - research.md: 7 design decisions (all pre-resolved in spec) + seam verification table (all 7 seams MATCH current code as of 2026-06-07) - data-model.md: ProfileConfig, ProfileScope, Effective Server Set - contracts/: profiles-config JSON schema + /mcp/p/ endpoint contract - quickstart.md: two-profile config + curl walkthrough - tasks.md: 23 TDD-first tasks across Foundational + US1/US2/US3 + Polish, sized for hand-off to Paperclip engineers Scope guard: changes confined to internal/{config,profile,server}; no internal/teams overlap with the in-flight Server-edition rename / spec-074 OAuth cluster. * docs(057): cover upstream_servers + code_execution profile boundaries (Codex #621 review) Related #55 Address Codex review of PR #621 (verdict request_changes), two findings: 1. upstream_servers introspection must honour the active profile (FR-004). The list path (mcp.go:2763) applied only the agent-token filter, so a profile URL could enumerate out-of-profile servers. Added T011a + test. 2. code_execution is a profile-boundary escape: the /mcp/p/ route reuses the retrieve-tools server which registers code_execution; the JS runtime treats an empty allowed_servers as ALL servers, so call_tool() inside code-exec could reach servers outside the profile. Intersect ProfileScope into the runtime (or reject out-of-profile calls). Added T011b + test. Updated plan.md (filter surfaces + source tree + phase-2 tasks), tasks.md (T011a/T011b under US1, dependency notes), and the endpoint contract to spell out both boundaries.
1 parent 41fe212 commit 0b8cf2a

7 files changed

Lines changed: 511 additions & 0 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Contract: `/mcp/p/<slug>` profile-scoped MCP endpoint
2+
3+
Stateless, pinned selector. Same MCP protocol surface as `/mcp`; the only difference is the effective server set. Reuses the retrieve_tools-mode MCP server instance; profile resolution is via `profileMiddleware` (runs **after** `mcpAuthMiddleware`).
4+
5+
## Request resolution
6+
7+
| Condition | Response |
8+
|-----------|----------|
9+
| `profiles` absent or empty, any `/mcp/p/<anything>` | **404** JSON `{"error":"no profiles configured"}` (FR-008) |
10+
| `profiles` non-empty, slug matches no profile | **404** JSON `{"error":"unknown profile '<slug>'","available":["research","deploy"]}` (FR-009) |
11+
| slug matches profile `P` | **200** — MCP surface identical to `/mcp`, effective server set restricted to `P.servers` (FR-002) |
12+
| `/mcp`, `/mcp/code`, `/mcp/call` | unchanged — full union, no profile applied (FR-010) |
13+
14+
`/mcp/p/<slug>` is stateless: no global "active profile" is mutated; concurrent requests to different profile URLs from the same client each see only their own profile (FR-003).
15+
16+
## Tool-surface behaviour at `/mcp/p/<slug>`
17+
18+
- `retrieve_tools` returns only tools from servers in the **effective set** (profile ∩ token ∩ enabled ∩ not-quarantined ∩ user-visible). Tools the profile excludes MUST NOT appear (FR-004).
19+
- `call_tool_read` / `call_tool_write` / `call_tool_destructive` into an excluded server are rejected (FR-004).
20+
- `upstream_servers` introspection (e.g. `list`/`get`) MUST exclude servers outside the profile from its result — a profile URL cannot enumerate out-of-profile servers (FR-004). *(Codex #621 finding 1.)*
21+
- `code_execution` (when enabled on the reused retrieve-tools server) MUST run with the profile-intersected effective server set: `call_tool()` invoked from inside a code-execution sandbox at a profile URL is rejected for any server outside the active profile. An empty caller-supplied `allowed_servers` MUST NOT be interpreted as "all servers" at a profile URL. *(Codex #621 finding 2.)*
22+
- Per-server `enabled_tools`/`disabled_tools` continue to apply inside the profile (FR-006); no profile-level tool list.
23+
24+
## Scope composition & error attribution
25+
26+
Effective allowed-servers = **intersection** of profile `servers` and (if an agent token is present) `AgentToken.AllowedServers`. A token wildcard `["*"]` is fully constrained by the profile (FR-005). The two checks are independent so errors name the responsible primitive (FR-012):
27+
28+
| Caller | Server | Result |
29+
|--------|--------|--------|
30+
| token `{github,fs,web}` @ `/mcp/p/deploy` (`{github,k8s}`) | `github` | allowed (in both) |
31+
| same | `fs` | rejected — `"server 'fs' is not in profile 'deploy'"` |
32+
| same | `k8s` | rejected — `"Server 'k8s' is not in scope for this agent token"` |
33+
| **unauthenticated** @ `/mcp/p/research` | non-`research` server | **rejected by profile** (regression test — must hold even though unauth ⇒ AdminContext) |
34+
35+
## Activity logging
36+
37+
Tool-call activity records originating from `/mcp/p/<slug>` carry `metadata["profile"] = "<slug>"` (FR-011) in the existing `ActivityRecord.Metadata` map. Records from `/mcp` omit the field.
38+
39+
## Edge cases
40+
41+
- Profile references unknown server → warn at load, server omitted (FR-015).
42+
- Profile references quarantined/disabled server → excluded from effective set while in that state; appears once cleared (no file re-read).
43+
- Config hot-reload changes a profile mid-connection → in-flight session keeps its snapshot; new connections see the change.
44+
- Reserved slug defined as a profile (`all`/`code`/`call`/`p`) → rejected at load (never reaches routing).
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$id": "https://mcpproxy.app/schemas/057-profiles-config.json",
4+
"title": "MCPProxy profiles config (Spec 057)",
5+
"description": "Optional top-level `profiles` array. Absent/empty is fully supported (zero migration; /mcp unchanged).",
6+
"type": "object",
7+
"properties": {
8+
"profiles": {
9+
"type": "array",
10+
"description": "Named, stateless, server-scoped views addressable at /mcp/p/<name>.",
11+
"items": { "$ref": "#/definitions/profile" }
12+
}
13+
},
14+
"definitions": {
15+
"profile": {
16+
"type": "object",
17+
"required": ["name", "servers"],
18+
"additionalProperties": false,
19+
"properties": {
20+
"name": {
21+
"type": "string",
22+
"description": "URL slug. Used verbatim as /mcp/p/<name>. Must be unique. Reserved slugs are rejected.",
23+
"pattern": "^[a-z0-9][a-z0-9_-]{0,62}$",
24+
"not": { "enum": ["all", "code", "call", "p"] }
25+
},
26+
"servers": {
27+
"type": "array",
28+
"description": "References to mcpServers[].name. Unknown names warn-and-skip (FR-015). Empty list is legal (warns; exposes zero tools).",
29+
"items": { "type": "string" }
30+
}
31+
}
32+
}
33+
},
34+
"examples": [
35+
{
36+
"profiles": [
37+
{ "name": "research", "servers": ["fs", "web"] },
38+
{ "name": "deploy", "servers": ["github", "k8s"] }
39+
]
40+
}
41+
]
42+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# Phase 1 Data Model: In-Proxy Profiles
2+
3+
No persistent storage entities. Two in-memory types + one derived set.
4+
5+
## 1. ProfileConfig (config entity)
6+
7+
Declared in `internal/config/profiles.go`, embedded in `Config.Profiles []ProfileConfig` (after `Servers`, `config.go:109`).
8+
9+
```go
10+
type ProfileConfig struct {
11+
Name string `json:"name"` // URL slug, validated
12+
Servers []string `json:"servers"` // references to mcpServers[].name
13+
}
14+
```
15+
16+
**Validation rules** (run in `Config.Validate()`, `loader.go:1521`):
17+
18+
| Rule | Source | Severity |
19+
|------|--------|----------|
20+
| `Name` matches `^[a-z0-9][a-z0-9_-]{0,62}$` | FR-007 | **fatal** — reject load, point at offending entry |
21+
| `Name` ∉ reserved `{all, code, call, p}` | FR-007 | **fatal** |
22+
| `Name` unique across all profiles | FR-014 | **fatal** — diagnostic names both occurrences |
23+
| each `Servers[i]` exists in `mcpServers[].name` | FR-015 | **warning** — load, log, omit that server |
24+
| `Servers` empty | edge case | **warning** — legal "deny everything" placeholder |
25+
26+
**Round-trip**: `omitempty` on `Config.Profiles` keeps SC-004 (absent ⇒ byte-identical via `json.MarshalIndent` in `SaveConfig`).
27+
28+
## 2. ProfileScope (request entity)
29+
30+
Declared in `internal/profile/context.go` (~30 LOC, peer of `internal/auth/context.go`). Immutable, request-scoped, injected by `profileMiddleware`.
31+
32+
```go
33+
type ProfileScope struct {
34+
Name string // resolved profile slug (for error messages + activity metadata)
35+
servers map[string]struct{} // effective set after warn-skip of unknown servers
36+
}
37+
38+
func (p *ProfileScope) Allows(serverName string) bool // membership test; nil receiver ⇒ no profile ⇒ allow-all (the /mcp path)
39+
40+
func WithProfileScope(ctx context.Context, p *ProfileScope) context.Context
41+
func ProfileScopeFromContext(ctx context.Context) *ProfileScope // nil when request did not enter via /mcp/p/<slug>
42+
```
43+
44+
`Allows` semantics: a **nil** `*ProfileScope` (request came through `/mcp`, `/mcp/code`, `/mcp/call`) means no profile filtering — preserves FR-010. A non-nil scope filters to its `servers` set.
45+
46+
## 3. Effective Server Set (derived, per request)
47+
48+
Computed at each scope site as the intersection of all active scoping primitives:
49+
50+
```
51+
effective(server) =
52+
ProfileScope.Allows(server) // FR-002/004 (nil scope ⇒ true)
53+
AND (!enforceAgentScope OR authCtx.CanAccessServer) // FR-005 Spec 028 token scope
54+
AND server is enabled // edge case: disabled excluded
55+
AND server is not quarantined // edge case: quarantined excluded
56+
AND server visible to this user // FR-013 Spec 029 per-user (server edition)
57+
```
58+
59+
The two new conditions are the `ProfileScope.Allows` checks; the rest already exist. The profile and token checks are **independent** so the rejection error can name the responsible primitive (FR-012):
60+
61+
- profile-blocked: `"server '<s>' is not in profile '<name>'"`
62+
- token-blocked: existing `"Server '<s>' is not in scope for this agent token"`
63+
64+
## State transitions
65+
66+
`ProfileScope` is immutable for a request's lifetime. Profiles change only via config hot-reload (`configsvc.Update`): in-flight sessions keep their resolved snapshot; new connections resolve against the new config. There is no runtime "active profile" mutation in the MVP (deferred — see spec Out of Scope).
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Implementation Plan: In-Proxy Profiles + Permanent URLs
2+
3+
**Branch**: `057-in-proxy-profiles` | **Date**: 2026-06-07 | **Spec**: [spec.md](./spec.md)
4+
**Input**: Feature specification from `/specs/057-in-proxy-profiles/spec.md`
5+
6+
## Summary
7+
8+
Add an optional top-level `profiles` array to the config. Each profile `{name, servers}` is exposed at a stateless, pinned URL `/mcp/p/<name>` whose protocol surface is identical to `/mcp` except the caller's effective server set is restricted to the profile's `servers`. Filtering composes by **intersection** with agent-token scope (Spec 028) and per-user visibility (Spec 029), and reuses the existing per-server `enabled_tools`/`disabled_tools` for tool-level granularity (Spec 049). No storage, index, or token-model changes.
9+
10+
**Technical approach** (verified against current code, 2026-06-07):
11+
- A single `/mcp/p/` prefix handler + `profileMiddleware` (registered after `mcpAuthMiddleware`) strips the slug, resolves the profile from the **current** config snapshot (`runtime.Config()`, lock-free atomic read → hot-reload for free), and injects a `ProfileScope` into the request context. The existing retrieve_tools-mode MCP server instance (`p.server`) is reused — **no per-profile server instances** (http.ServeMux can't deregister routes at runtime).
12+
- Profile filtering runs as a **parallel, auth-type-independent** check at **every server-boundary surface** the profile must cover (FR-004), not just two: `mcp.go:1113` retrieve_tools, `mcp.go:1529` call_tool_*, **`upstream_servers` introspection (`mcp.go:2763` list path)**, and **`code_execution`** (the `/mcp/p/` route reuses the retrieve-tools server, which registers `code_execution` when enabled — the JS runtime must receive the profile-intersected server set, not be allowed to treat an empty `allowed_servers` as "all"). It MUST NOT ride the `enforceAgentScope` gate, because an unauthenticated `/mcp/p/...` connection gets `AdminContext()` (where `enforceAgentScope == false`). Independent checks yield the FR-005 intersection and FR-012 distinct errors for free.
13+
> Both extra surfaces were flagged by Codex review of PR #621 (verdict `request_changes`): `upstream_servers list` currently applies only the agent-token filter, and `handleCodeExecution``jsruntime` treats an empty caller `allowed_servers` as all servers — each is a profile-boundary escape if not closed.
14+
- `metadata["profile"]` is attached to tool-call activity records originating from a profile URL via the existing `Metadata map[string]interface{}` field (no schema change).
15+
16+
## Technical Context
17+
18+
**Language/Version**: Go 1.24 (toolchain go1.24.10)
19+
**Primary Dependencies**: `net/http` (ServeMux), `github.com/mark3labs/mcp-go` (MCP protocol, `NewStreamableHTTPServer`), existing `internal/auth`, `internal/config`, `internal/runtime/configsvc`**no new external dependencies**
20+
**Storage**: None new. Config lives in `mcp_config.json`; profiles are config-only. Activity metadata reuses existing BBolt `ActivityRecord.Metadata`.
21+
**Testing**: `go test` (unit), `internal/server` integration tests, `internal/server/e2e_test.go`-style E2E, `./scripts/test-api-e2e.sh`
22+
**Target Platform**: Linux/macOS/Windows server (personal + server editions, no build-tag divergence — FR-013)
23+
**Project Type**: Single Go project (`internal/...`)
24+
**Performance Goals**: Profile filtering is O(servers-in-profile) set lookup over an already-paginated result; below existing `retrieve_tools` E2E latency budget (SC-006). BM25 <100ms invariant (Constitution I) unaffected — index is **not** partitioned.
25+
**Constraints**: Zero migration; `profiles` absent ⇒ byte-identical config round-trip (SC-004) and unchanged `/mcp`/`/mcp/code`/`/mcp/call` behaviour (SC-002).
26+
**Scale/Scope**: Server cardinality typically ≤ a few dozen; handful of profiles. 4 files touched + 2 new small files.
27+
28+
## Constitution Check
29+
30+
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
31+
32+
| Principle | Assessment | Verdict |
33+
|-----------|-----------|---------|
34+
| **I. Performance at Scale** | Per-request set-intersection over paginated results; no index reshape; BM25 path untouched. | ✅ PASS |
35+
| **II. Actor-Based Concurrency** | No new shared mutable state. Profile resolution reads the lock-free `configsvc` atomic snapshot. `ProfileScope` is immutable per-request, injected via context (mirrors `AuthContext`). In-flight sessions keep their snapshot until reconnect. | ✅ PASS |
36+
| **III. Configuration-Driven Architecture** | Pure config feature: top-level `profiles` array, hot-reloaded via existing `configsvc`. No tray state. | ✅ PASS |
37+
| **IV. Security by Default** | Profiles only **narrow** access; never broaden. Quarantined/disabled servers excluded from a profile's effective set. Composes with agent tokens and per-user visibility by intersection. Unauth-at-profile-URL regression test mandated. | ✅ PASS |
38+
| **V. Test-Driven Development** | Spec mandates unit (config + filter), integration, E2E, and backward-compat tests, written red-first. Named regression test gate before merge. | ✅ PASS |
39+
| **VI. Documentation Hygiene** | Plan updates CLAUDE.md (MCP endpoints table + Built-in tools note), README (profiles section), `docs/`, and `oas/swagger.yaml` if any REST surface added (none in MVP). | ✅ PASS |
40+
41+
**No violations. Complexity Tracking not required.**
42+
43+
The one design choice worth recording (resolved in spec): profile filtering is a **parallel check**, not an `AuthContext.AllowedServers` overwrite. Rationale: an unauthenticated profile connection is `AdminContext()` with `enforceAgentScope=false`; stuffing servers into `AllowedServers` would be bypassed. This is the simpler-of-the-correct options (no `AuthContext`/token-bucket change), so it is not a constitution violation — it is the design that keeps token validation untouched.
44+
45+
## Project Structure
46+
47+
### Documentation (this feature)
48+
49+
```text
50+
specs/057-in-proxy-profiles/
51+
├── plan.md # This file
52+
├── research.md # Phase 0 output — design decisions (mostly pre-resolved in spec)
53+
├── data-model.md # Phase 1 output — ProfileConfig, ProfileScope, Effective Server Set
54+
├── quickstart.md # Phase 1 output — config + curl walkthrough
55+
├── contracts/ # Phase 1 output — config JSON schema + /mcp/p/ route contract
56+
│ ├── profiles-config.schema.json
57+
│ └── mcp-profile-endpoint.md
58+
└── tasks.md # Phase 2 output (/speckit.tasks — separate command)
59+
```
60+
61+
### Source Code (repository root) — verified file:line seams
62+
63+
```text
64+
internal/
65+
├── config/
66+
│ ├── config.go # Config struct (L101); add `Profiles []ProfileConfig` after Servers (L109)
67+
│ ├── loader.go # Validate() (L1521) — call profile validation; SaveConfig() round-trip (L352)
68+
│ └── profiles.go # NEW — ProfileConfig struct, slug regex, reserved-set, dup + unknown-server validation
69+
├── profile/
70+
│ └── context.go # NEW (~30 LOC) — ProfileScope{Allows(server) bool}, WithProfileScope/FromContext (mirrors auth/context.go)
71+
└── server/
72+
├── server.go # Register `/mcp/p/` prefix handler + profileMiddleware after mcpAuthMiddleware (near L1690)
73+
├── mcp.go # Parallel filter conditions: retrieve_tools (~L1113), call_tool_* (~L1529), upstream_servers list (~L2763); profile metadata write at emitActivity* call sites
74+
└── mcp_code_execution.go # Pass profile-intersected server set into the JS runtime (~L181/L190) so call_tool() inside code_execution cannot reach out-of-profile servers
75+
```
76+
77+
**Structure Decision**: Single Go project, existing `internal/` layout. One new sub-package `internal/profile` (request-scoped scope type, peer of `internal/auth`) and one new file `internal/config/profiles.go`. No new top-level dirs, no frontend changes in the MVP (web-UI profile affordances are out of scope), no storage/index packages touched.
78+
79+
## Phase 0 — Research
80+
81+
The spec's *Resolved Design Decisions*, *Assumptions*, and *Implementation Design* sections already retire every open question. `research.md` consolidates them in decision/rationale/alternatives form. No outstanding NEEDS CLARIFICATION. Code-seam verification (2026-06-07) confirmed all 7 referenced seams MATCH current code (`GetMCPServerForMode`, `mcpAuthMiddleware`/`AuthContext`, the two `mcp.go` gates, `Config.Servers`, `ActivityRecord.Metadata`, `configsvc` lock-free snapshot).
82+
83+
## Phase 1 — Design & Contracts
84+
85+
- **data-model.md**: `ProfileConfig` (config entity), `ProfileScope` (request entity), and the *Effective Server Set* derivation (profile ∩ token ∩ not-disabled ∩ not-quarantined ∩ per-user-visible).
86+
- **contracts/profiles-config.schema.json**: JSON Schema for the `profiles` array (name slug pattern, reserved set, servers list).
87+
- **contracts/mcp-profile-endpoint.md**: behavioural contract for `/mcp/p/<slug>` (200 surface identical to `/mcp`; 404 bodies for no-profiles / unknown-slug; intersection + error-attribution rules).
88+
- **quickstart.md**: the two-profile config + curl walkthrough from the spec, runnable end-to-end.
89+
- **Agent context**: run `.specify/scripts/bash/update-agent-context.sh claude`.
90+
91+
## Phase 2 — Tasks (separate command)
92+
93+
`/speckit.tasks` will generate `tasks.md` from this plan, ordered TDD-first per the spec's Testing Strategy:
94+
1. Config: `ProfileConfig` + validation (slug/reserved/dup/unknown-server) — unit tests first.
95+
2. `internal/profile` context + `ProfileScope.Allows`.
96+
3. Routing: `/mcp/p/` handler + `profileMiddleware` (auth→profile order).
97+
4. Filter wiring: parallel checks at **all** profile-boundary surfaces — `retrieve_tools`, `call_tool_*`, `upstream_servers` introspection, and `code_execution` (intersect `ProfileScope` into the JS runtime's server set) + the mandated unauth-at-profile-URL regression test.
98+
5. Activity `metadata["profile"]`.
99+
6. Integration + E2E (two-profile isolation, intersection, 404 paths, reserved slug) + backward-compat E2E (SC-002).
100+
7. Docs (CLAUDE.md, README, docs/).
101+
102+
These tasks are sized for hand-off to Paperclip engineers after the plan is reviewed.

0 commit comments

Comments
 (0)