Skip to content

Commit 15b70e8

Browse files
committed
docs: simplify and update ARCHITECTURE.md
- Remove 'current state as of' line - Replace LFX V2/v2 references with LFX Self Service or LFX - Simplify top-level diagram to a high-level overview - Remove code implementation details (newServer, mcp.Server, etc.) - Remove access-check request/response wire format details - Use 'tlf' as example project slug throughout Assisted-by: github-copilot:claude-sonnet-4.6 Signed-off-by: Eric Searcy <eric@linuxfoundation.org>
1 parent a014088 commit 15b70e8

1 file changed

Lines changed: 74 additions & 118 deletions

File tree

ARCHITECTURE.md

Lines changed: 74 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
# LFX MCP Server — Architecture
22

3-
> **Current state** as of April 2026.
4-
53
The LFX MCP Server is a [Model Context Protocol](https://modelcontextprotocol.io/) server that
64
exposes LFX platform capabilities as MCP tools. It supports two transport modes:
75

@@ -11,89 +9,53 @@ exposes LFX platform capabilities as MCP tools. It supports two transport modes:
119
accepts JSON-RPC 2.0 over HTTP with OAuth2 bearer token authentication.
1210

1311
```mermaid
14-
%%{init: {"flowchart": {"defaultRenderer": "elk"}}}%%
1512
flowchart LR
1613
subgraph Clients
17-
U[End user\nClaude / Cursor / Inspector]
18-
M2MC[M2M client]
19-
AK[Static API key\nstop-gap]
20-
end
21-
22-
subgraph Auth0
23-
JWKS[JWKS endpoint]
24-
TOKE[Token endpoint\nRFC 8693 CTE]
25-
TOKCC[Token endpoint\nclient_credentials]
14+
end_users["Interactive MCP client user"]
15+
m2m_client["Machine user"]
2616
end
2717
28-
subgraph MCP["LFX MCP Server"]
29-
BM[Bearer middleware\nJWT / API-key verify]
30-
NS[newServer factory\ntool registration gate]
31-
CTE[TokenExchangeClient\nCTE → V2 token]
32-
M2MV2[TokenExchangeClient\nclient_credentials → V2 token]
33-
SR[SlugResolver\nslug → UUID]
34-
AC[AccessCheckClient\nOpenFGA]
35-
CCL[ClientCredentialsClient\nper-service M2M token]
18+
subgraph mcp["LFX MCP Server"]
19+
tools["Tool definitions"]
20+
mcp_gating[Read/write/staff\ntool gating]
21+
tools -.->mcp_gating
3622
end
3723
3824
subgraph Upstream
39-
V2[LFX V2 API\nnative pass-through]
40-
LENS[LFX Lens API\nM2M-brokered]
41-
ONB[Member Onboarding API\nM2M-brokered]
25+
lfx_api_gw["LFX Self Service APIs\n(pass-thru auth:\nfine-grained authorization handled by LFX)"]
26+
services["Other APIs\ne.g. LFX Lens\n(MCP-brokered authorization)"]
4227
end
4328
44-
U -->|"Bearer: MCP JWT"| BM
45-
M2MC -->|"Bearer: M2M JWT"| BM
46-
AK -->|"Bearer: api-key"| BM
47-
BM -->|verifies via| JWKS
48-
BM --> NS
49-
50-
NS -->|"end-user tools/call"| CTE
51-
NS -->|"M2M / api-key tools/call"| M2MV2
52-
CTE -->|RFC 8693| TOKE
53-
M2MV2 -->|client_credentials| TOKCC
54-
55-
CTE -->|V2 token| V2
56-
M2MV2 -->|V2 token| V2
57-
58-
CTE -->|V2 token\nslug + access-check| SR
59-
M2MV2 -->|V2 token\nslug + access-check| SR
60-
SR -->|slug → UUID| V2
61-
SR --> AC
62-
AC -->|OpenFGA check| V2
63-
AC -->|authorized| CCL
64-
CCL -->|client_credentials| TOKCC
65-
CCL -->|M2M token| LENS
66-
CCL -->|M2M token| ONB
29+
oauth2_cte["OAuth2 Token Exchange\n(end-user only)"]
30+
31+
end_users --->|authenticated with SSO| mcp_gating
32+
m2m_client --->|authenticated with client credentials| mcp_gating
33+
mcp_gating <-.-> oauth2_cte
34+
mcp_gating --> lfx_api_gw
35+
mcp_gating --> services
6736
```
6837

6938
---
7039

7140
## 1. Client Authentication & Authorization
7241

7342
All inbound calls in HTTP mode pass through a bearer-token middleware before reaching the MCP
74-
protocol layer. The middleware produces a `TokenInfo` struct (scopes + custom claims) that drives
75-
tool registration: `newServer()` is called once per HTTP request and registers only the tools the
76-
caller is permitted to invoke, so `tools/list` always reflects exactly what the caller can use.
43+
protocol layer. The middleware extracts scopes and custom claims from the token, which drive
44+
tool registration: only the tools the caller is permitted to invoke are registered for that
45+
request, so `tools/list` always reflects exactly what the caller can use.
7746

7847
### Stateless HTTP and per-request tool gating
7948

80-
The `StreamableHTTPHandler` runs with `Stateless: true`:
81-
82-
- Each HTTP request gets a fresh `*mcp.Server` from the `newServer()` factory — no MCP-level
83-
session state accumulates across requests.
84-
- Any pod can handle any request; round-robin load balancing works without Kubernetes session
85-
affinity.
86-
- A package-level `schemaCache` is shared across per-request server instances so that
87-
reflection-based JSON schema generation runs once per tool type rather than once per request.
88-
- Client callbacks (`ListRoots`, `CreateMessage`, `Elicit`) are not available in stateless mode
89-
and are not used.
49+
The HTTP server is fully stateless — each request is handled independently, with no session
50+
affinity required. Any pod can handle any request, so round-robin load balancing works without
51+
Kubernetes session affinity.
9052

91-
Tool registration is gated on two boolean values derived from `TokenInfo` at server creation time:
53+
Tool registration is gated on two access levels derived from the caller's token:
9254

93-
| Flag | Condition | Grants access to |
55+
| Level | Condition | Grants access to |
9456
|---|---|---|
95-
| `canRead` | token holds `read:all` **or** `manage:all` | All read-only tools |
96-
| `canManage` | token holds `manage:all` | Read + write/delete tools |
57+
| Read | token holds `read:all` **or** `manage:all` | All read-only tools |
58+
| Manage | token holds `manage:all` | Read + write/delete tools |
9759

9860
An additional `isStaff` flag (derived from the `http://lfx.dev/claims/lf_staff` custom claim)
9961
gates the `query_lfx_lens` tool independently of scopes.
@@ -117,9 +79,8 @@ tokens; the scopes embedded in the M2M JWT determine which tools are registered.
11779
### Static API key (stop-gap)
11880

11981
For MCP clients that cannot complete an OAuth2 flow, static API keys can be configured via
120-
`LFXMCP_API_CREDENTIALS_<KEY>=<secret>` environment variables. The `APIKeyVerifier` is checked
121-
before the JWT path; when a key matches it synthesizes a `TokenInfo` with a fixed scope set so
122-
the rest of the tool-gating logic is identical to the JWT path.
82+
`LFXMCP_API_CREDENTIALS_<KEY>=<secret>` environment variables. A matching key synthesizes a
83+
token with a fixed scope set, so the tool-gating logic is identical to the JWT path.
12384

12485
> **This mechanism is a temporary stop-gap and will be retired once all consumers support OAuth2.**
12586
@@ -133,59 +94,54 @@ natively.
13394

13495
### Custom Token Exchange (CTE) — end-user callers
13596

136-
For end-user callers, the server exchanges the user's MCP JWT for a V2-scoped token that carries
137-
the user's identity. This is a **Custom Token Exchange** per
97+
For end-user callers, the server exchanges the user's MCP JWT for an LFX Self Service token that
98+
carries the user's identity. This is a **Custom Token Exchange** per
13899
[RFC 8693](https://www.rfc-editor.org/rfc/rfc8693): the MCP server's own M2M client
139100
(`LFX MCP Server`) authenticates to Auth0 using a signed JWT client assertion (RS256, RFC 7523)
140-
or client secret, and presents the user's MCP JWT as the `subject_token`. Auth0 issues a
141-
V2-scoped token that carries the user's identity. The exchanged token is cached per user subject
142-
and refreshed automatically on expiry.
101+
or client secret, and presents the user's MCP JWT as the `subject_token`. Auth0 issues an
102+
LFX Self Service token that carries the user's identity. The exchanged token is cached per user
103+
subject and refreshed automatically on expiry.
143104

144-
### MCP-server M2M V2 token — M2M and API-key callers
105+
### MCP-server M2M token — M2M and API-key callers
145106

146107
When the inbound bearer is itself an M2M JWT (Auth0 subjects for M2M tokens end in `@clients`)
147-
or a static API key, there is no user identity to exchange. In this case the server obtains a
148-
V2-scoped token via a standard client credentials grant using the same M2M client — no CTE is
149-
performed. The upstream V2 identity is always the MCP server itself; no user identity is present
150-
in the chain. This token is also cached and shared across all M2M and API-key requests.
108+
or a static API key, there is no user identity to exchange. In this case the server obtains an
109+
LFX Self Service token via a standard client credentials grant using the same M2M client — no
110+
CTE is performed. The upstream identity is always the MCP server itself; no user identity is
111+
present in the chain. This token is also cached and shared across all M2M and API-key requests.
151112

152-
### Native LFX self-service pass-through
113+
### Native LFX Self Service pass-through
153114

154-
V2 API tools (`search_projects`, `get_committee`, member, meeting, mailing list tools, etc.) pass
155-
the V2 token (CTE token for end-user callers; MCP-server M2M V2 token for M2M callers) directly
156-
to V2 API calls. Authorization is handled natively by V2 and its OpenFGA backend; the MCP server
157-
performs no explicit access-check of its own for these tools.
115+
LFX Self Service tools (`search_projects`, `get_committee`, member, meeting, mailing list tools,
116+
etc.) pass the LFX token (CTE token for end-user callers; MCP-server M2M token for M2M callers)
117+
directly to LFX API calls. Authorization is handled natively by LFX and its OpenFGA backend; the
118+
MCP server performs no explicit access-check of its own for these tools.
158119

159120
### MCP-brokered service APIs (OpenFGA gate + per-service M2M token)
160121

161122
Service APIs (LFX Lens and Member Onboarding) accept only M2M tokens — they have no per-user
162123
authorization layer. The MCP server acts as the authorization gateway:
163124

164-
1. Obtain the appropriate V2 token: CTE token (end-user) or MCP-server M2M V2 token (M2M /
125+
1. Obtain the appropriate LFX token: CTE token (end-user) or MCP-server M2M token (M2M /
165126
API-key caller).
166-
2. Resolve the project slug → UUID via the V2 Query Service, authorized with the V2 token from
127+
2. Resolve the project slug → UUID via the LFX Query Service, authorized with the LFX token from
167128
step 1.
168-
3. Call the V2 access-check endpoint (`POST /access-check?v=1`, backed by OpenFGA), authorized
169-
with the same V2 token from step 1 — **not** the service-API M2M token. The check format is
129+
3. Call the LFX access-check endpoint (`POST /access-check?v=1`, backed by OpenFGA), authorized
130+
with the same LFX token from step 1 — **not** the service-API M2M token. The check relation is
170131
`project:{uuid}#auditor` for LFX Lens and `project:{uuid}#writer` for Member Onboarding.
171132
4. Acquire a separate per-service M2M token via a standard client credentials grant (same M2M
172-
client, different `audience`). Each service has its own `ClientCredentialsClient` that caches
173-
the token and refreshes it automatically.
133+
client, different `audience`). Each service token is cached and refreshed automatically.
174134
5. Call the service API with the per-service M2M token. The service only ever sees that M2M
175135
token — no user identity is forwarded.
176136

177137
LFX Lens additionally requires the `lf_staff` claim in the caller's MCP JWT (checked at tool
178138
registration time; the tool is simply not registered for non-staff callers).
179139

180-
The access-check result format uses `#` for the relation and tab-separates the echoed request
181-
from the boolean result. Multiple checks can be batched; results are not guaranteed to be in
182-
request order and are matched by parsing the request prefix from each result string.
183-
184140
---
185141

186142
## 3. End-to-End Flows
187143

188-
### Flow 1: End-user → V2 native pass-through
144+
### Flow 1: End-user → LFX Self Service native pass-through
189145

190146
Representative tool: `get_committee`
191147

@@ -195,7 +151,7 @@ sequenceDiagram
195151
participant Client as MCP Client
196152
participant MCP as MCP Server
197153
participant Auth0
198-
participant V2 as LFX V2 API
154+
participant LFX as LFX Self Service API
199155
200156
User->>Client: open MCP tool
201157
@@ -211,15 +167,15 @@ sequenceDiagram
211167
MCP->>MCP: verify signature, expiry, audience\nextract scopes + lf_staff claim
212168
MCP-->>Client: tools/list (filtered to caller's scopes)
213169
214-
User->>Client: invoke get_committee (slug="pytorch")
170+
User->>Client: invoke get_committee (slug="tlf")
215171
Client->>MCP: tools/call {get_committee}\nAuthorization: Bearer {mcp_jwt}
216172
217173
MCP->>Auth0: token exchange (RFC 8693)\nM2M client assertion + {mcp_jwt}
218174
Auth0-->>MCP: CTE token (carries user identity, cached)
219175
220-
MCP->>V2: GET /committees?projectID=...\nAuthorization: Bearer {cte_token}
221-
V2->>V2: verify token + OpenFGA authz\n(natively, no MCP involvement)
222-
V2-->>MCP: committee data
176+
MCP->>LFX: GET /committees?projectID=...\nAuthorization: Bearer {cte_token}
177+
LFX->>LFX: verify token + OpenFGA authz\n(natively, no MCP involvement)
178+
LFX-->>MCP: committee data
223179
MCP-->>Client: tool result
224180
```
225181

@@ -233,7 +189,7 @@ sequenceDiagram
233189
participant Client as MCP Client
234190
participant MCP as MCP Server
235191
participant Auth0
236-
participant V2 as LFX V2 API\n(Query Svc + Access Check)
192+
participant LFX as LFX Self Service API\n(Query Svc + Access Check)
237193
participant Lens as LFX Lens API
238194
239195
User->>Client: open MCP tool
@@ -251,17 +207,17 @@ sequenceDiagram
251207
Note over MCP: query_lfx_lens registered\nonly when lf_staff=true
252208
MCP-->>Client: tools/list (includes query_lfx_lens)
253209
254-
User->>Client: invoke query_lfx_lens (slug="pytorch")
210+
User->>Client: invoke query_lfx_lens (slug="tlf")
255211
Client->>MCP: tools/call {query_lfx_lens}\nAuthorization: Bearer {mcp_jwt}
256212
257213
MCP->>Auth0: token exchange (RFC 8693)\nM2M client assertion + {mcp_jwt}
258214
Auth0-->>MCP: CTE token (carries user identity, cached)
259215
260-
MCP->>V2: GET /query?filter=slug:pytorch\nAuthorization: Bearer {cte_token}
261-
V2-->>MCP: project UUID (cached)
216+
MCP->>LFX: GET /query?filter=slug:tlf\nAuthorization: Bearer {cte_token}
217+
LFX-->>MCP: project UUID (cached)
262218
263-
MCP->>V2: POST /access-check?v=1\n["project:{uuid}#auditor"]\nAuthorization: Bearer {cte_token}
264-
V2-->>MCP: ["project:{uuid}#auditor@user:...\ttrue"]
219+
MCP->>LFX: POST /access-check?v=1\nproject:{uuid}#auditor\nAuthorization: Bearer {cte_token}
220+
LFX-->>MCP: access granted / denied
265221
266222
alt access denied
267223
MCP-->>Client: error: access denied
@@ -276,7 +232,7 @@ sequenceDiagram
276232
MCP-->>Client: tool result
277233
```
278234

279-
### Flow 3: M2M client → V2 native pass-through
235+
### Flow 3: M2M client → LFX Self Service native pass-through
280236

281237
Representative tool: `search_projects`
282238

@@ -285,7 +241,7 @@ sequenceDiagram
285241
participant Client as M2M Client
286242
participant Auth0
287243
participant MCP as MCP Server
288-
participant V2 as LFX V2 API
244+
participant LFX as LFX Self Service API
289245
290246
Client->>Auth0: client_credentials grant\naudience = MCP API resource server
291247
Auth0-->>Client: M2M JWT (aud: mcp.lfx.dev)
@@ -298,12 +254,12 @@ sequenceDiagram
298254
299255
Client->>MCP: tools/call {search_projects}\nAuthorization: Bearer {m2m_jwt}
300256
301-
MCP->>Auth0: client_credentials grant\naudience = V2 API resource server
302-
Auth0-->>MCP: MCP-server M2M V2 token\n(no user identity, cached)
257+
MCP->>Auth0: client_credentials grant\naudience = LFX Self Service resource server
258+
Auth0-->>MCP: MCP-server M2M token\n(no user identity, cached)
303259
304-
MCP->>V2: GET /projects?...\nAuthorization: Bearer {mcp_m2m_v2_token}
305-
V2->>V2: verify token + OpenFGA authz\n(natively, MCP server identity)
306-
V2-->>MCP: project data
260+
MCP->>LFX: GET /projects?...\nAuthorization: Bearer {mcp_m2m_token}
261+
LFX->>LFX: verify token + OpenFGA authz\n(natively, MCP server identity)
262+
LFX-->>MCP: project data
307263
MCP-->>Client: tool result
308264
```
309265

@@ -316,7 +272,7 @@ sequenceDiagram
316272
participant Client as M2M Client
317273
participant Auth0
318274
participant MCP as MCP Server
319-
participant V2 as LFX V2 API\n(Query Svc + Access Check)
275+
participant LFX as LFX Self Service API\n(Query Svc + Access Check)
320276
participant ONB as Member Onboarding API
321277
322278
Client->>Auth0: client_credentials grant\naudience = MCP API resource server
@@ -330,15 +286,15 @@ sequenceDiagram
330286
331287
Client->>MCP: tools/call {onboarding_list_memberships}\nAuthorization: Bearer {m2m_jwt}
332288
333-
MCP->>Auth0: client_credentials grant\naudience = V2 API resource server
334-
Auth0-->>MCP: MCP-server M2M V2 token\n(no user identity, cached)
289+
MCP->>Auth0: client_credentials grant\naudience = LFX Self Service resource server
290+
Auth0-->>MCP: MCP-server M2M token\n(no user identity, cached)
335291
336-
MCP->>V2: GET /query?filter=slug:pytorch\nAuthorization: Bearer {mcp_m2m_v2_token}
337-
V2-->>MCP: project UUID (cached)
292+
MCP->>LFX: GET /query?filter=slug:tlf\nAuthorization: Bearer {mcp_m2m_token}
293+
LFX-->>MCP: project UUID (cached)
338294
339-
MCP->>V2: POST /access-check?v=1\n["project:{uuid}#writer"]\nAuthorization: Bearer {mcp_m2m_v2_token}
340-
Note over MCP,V2: access-check uses the MCP-server M2M V2 token,\nnot the onboarding service M2M token
341-
V2-->>MCP: ["project:{uuid}#writer@client:...\ttrue"]
295+
MCP->>LFX: POST /access-check?v=1\nproject:{uuid}#writer\nAuthorization: Bearer {mcp_m2m_token}
296+
Note over MCP,LFX: access-check uses the MCP-server M2M token,\nnot the onboarding service M2M token
297+
LFX-->>MCP: access granted / denied
342298
343299
alt access denied
344300
MCP-->>Client: error: access denied

0 commit comments

Comments
 (0)