Skip to content

Commit d64b197

Browse files
committed
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.
1 parent 41fe212 commit d64b197

7 files changed

Lines changed: 505 additions & 0 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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+
- Per-server `enabled_tools`/`disabled_tools` continue to apply inside the profile (FR-006); no profile-level tool list.
21+
22+
## Scope composition & error attribution
23+
24+
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):
25+
26+
| Caller | Server | Result |
27+
|--------|--------|--------|
28+
| token `{github,fs,web}` @ `/mcp/p/deploy` (`{github,k8s}`) | `github` | allowed (in both) |
29+
| same | `fs` | rejected — `"server 'fs' is not in profile 'deploy'"` |
30+
| same | `k8s` | rejected — `"Server 'k8s' is not in scope for this agent token"` |
31+
| **unauthenticated** @ `/mcp/p/research` | non-`research` server | **rejected by profile** (regression test — must hold even though unauth ⇒ AdminContext) |
32+
33+
## Activity logging
34+
35+
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.
36+
37+
## Edge cases
38+
39+
- Profile references unknown server → warn at load, server omitted (FR-015).
40+
- Profile references quarantined/disabled server → excluded from effective set while in that state; appears once cleared (no file re-read).
41+
- Config hot-reload changes a profile mid-connection → in-flight session keeps its snapshot; new connections see the change.
42+
- 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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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 the two existing scope sites (`mcp.go:1113` retrieve_tools, `mcp.go:1529` call_tool_*). It MUST NOT ride the `enforceAgentScope` gate, because an unauthenticated `/mcp/p/...` connection gets `AdminContext()` (where `enforceAgentScope == false`). Two independent checks yield the FR-005 intersection and FR-012 distinct errors for free.
13+
- `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).
14+
15+
## Technical Context
16+
17+
**Language/Version**: Go 1.24 (toolchain go1.24.10)
18+
**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**
19+
**Storage**: None new. Config lives in `mcp_config.json`; profiles are config-only. Activity metadata reuses existing BBolt `ActivityRecord.Metadata`.
20+
**Testing**: `go test` (unit), `internal/server` integration tests, `internal/server/e2e_test.go`-style E2E, `./scripts/test-api-e2e.sh`
21+
**Target Platform**: Linux/macOS/Windows server (personal + server editions, no build-tag divergence — FR-013)
22+
**Project Type**: Single Go project (`internal/...`)
23+
**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.
24+
**Constraints**: Zero migration; `profiles` absent ⇒ byte-identical config round-trip (SC-004) and unchanged `/mcp`/`/mcp/code`/`/mcp/call` behaviour (SC-002).
25+
**Scale/Scope**: Server cardinality typically ≤ a few dozen; handful of profiles. 4 files touched + 2 new small files.
26+
27+
## Constitution Check
28+
29+
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
30+
31+
| Principle | Assessment | Verdict |
32+
|-----------|-----------|---------|
33+
| **I. Performance at Scale** | Per-request set-intersection over paginated results; no index reshape; BM25 path untouched. | ✅ PASS |
34+
| **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 |
35+
| **III. Configuration-Driven Architecture** | Pure config feature: top-level `profiles` array, hot-reloaded via existing `configsvc`. No tray state. | ✅ PASS |
36+
| **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 |
37+
| **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 |
38+
| **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 |
39+
40+
**No violations. Complexity Tracking not required.**
41+
42+
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.
43+
44+
## Project Structure
45+
46+
### Documentation (this feature)
47+
48+
```text
49+
specs/057-in-proxy-profiles/
50+
├── plan.md # This file
51+
├── research.md # Phase 0 output — design decisions (mostly pre-resolved in spec)
52+
├── data-model.md # Phase 1 output — ProfileConfig, ProfileScope, Effective Server Set
53+
├── quickstart.md # Phase 1 output — config + curl walkthrough
54+
├── contracts/ # Phase 1 output — config JSON schema + /mcp/p/ route contract
55+
│ ├── profiles-config.schema.json
56+
│ └── mcp-profile-endpoint.md
57+
└── tasks.md # Phase 2 output (/speckit.tasks — separate command)
58+
```
59+
60+
### Source Code (repository root) — verified file:line seams
61+
62+
```text
63+
internal/
64+
├── config/
65+
│ ├── config.go # Config struct (L101); add `Profiles []ProfileConfig` after Servers (L109)
66+
│ ├── loader.go # Validate() (L1521) — call profile validation; SaveConfig() round-trip (L352)
67+
│ └── profiles.go # NEW — ProfileConfig struct, slug regex, reserved-set, dup + unknown-server validation
68+
├── profile/
69+
│ └── context.go # NEW (~30 LOC) — ProfileScope{Allows(server) bool}, WithProfileScope/FromContext (mirrors auth/context.go)
70+
└── server/
71+
├── server.go # Register `/mcp/p/` prefix handler + profileMiddleware after mcpAuthMiddleware (near L1690)
72+
└── mcp.go # Two parallel filter conditions: retrieve_tools (~L1113) + call_tool_* (~L1529); profile metadata write at emitActivity* call sites
73+
```
74+
75+
**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.
76+
77+
## Phase 0 — Research
78+
79+
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).
80+
81+
## Phase 1 — Design & Contracts
82+
83+
- **data-model.md**: `ProfileConfig` (config entity), `ProfileScope` (request entity), and the *Effective Server Set* derivation (profile ∩ token ∩ not-disabled ∩ not-quarantined ∩ per-user-visible).
84+
- **contracts/profiles-config.schema.json**: JSON Schema for the `profiles` array (name slug pattern, reserved set, servers list).
85+
- **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).
86+
- **quickstart.md**: the two-profile config + curl walkthrough from the spec, runnable end-to-end.
87+
- **Agent context**: run `.specify/scripts/bash/update-agent-context.sh claude`.
88+
89+
## Phase 2 — Tasks (separate command)
90+
91+
`/speckit.tasks` will generate `tasks.md` from this plan, ordered TDD-first per the spec's Testing Strategy:
92+
1. Config: `ProfileConfig` + validation (slug/reserved/dup/unknown-server) — unit tests first.
93+
2. `internal/profile` context + `ProfileScope.Allows`.
94+
3. Routing: `/mcp/p/` handler + `profileMiddleware` (auth→profile order).
95+
4. Filter wiring: parallel checks at both `mcp.go` sites + the mandated unauth-at-profile-URL regression test.
96+
5. Activity `metadata["profile"]`.
97+
6. Integration + E2E (two-profile isolation, intersection, 404 paths, reserved slug) + backward-compat E2E (SC-002).
98+
7. Docs (CLAUDE.md, README, docs/).
99+
100+
These tasks are sized for hand-off to Paperclip engineers after the plan is reviewed.

0 commit comments

Comments
 (0)