11# LFX MCP Server — Architecture
22
3- > ** Current state** as of April 2026.
4-
53The LFX MCP Server is a [ Model Context Protocol] ( https://modelcontextprotocol.io/ ) server that
64exposes 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"}}}%%
1512flowchart 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
7342All 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
9860An additional ` isStaff ` flag (derived from the ` http://lfx.dev/claims/lf_staff ` custom claim)
9961gates 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
11981For 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
146107When 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
161122Service APIs (LFX Lens and Member Onboarding) accept only M2M tokens — they have no per-user
162123authorization 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.
1711324 . 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.
1741345 . 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
177137LFX Lens additionally requires the ` lf_staff ` claim in the caller's MCP JWT (checked at tool
178138registration 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
190146Representative 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
281237Representative 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