docs: specify request-scoped multi-user architecture and ADR-012

- Add Section 3 in docs/ARCHITECTURE.md detailing multi-user concurrency and RequestContext

- Add ADR-012 in docs/DECISIONS.md establishing RequestContext pattern over global state

- Update AGENTS.md mandating stateless services and context propagation

- Update docs/TODO.md with src/context.ts implementation task
This commit is contained in:
Lucas Winther
2026-09-05 16:53:42 +02:00
parent 7029ca296a
commit 5886419b06
4 changed files with 128 additions and 37 deletions
+1
View File
@@ -46,6 +46,7 @@ Welcome to the `openproject-mcp` project. This document defines operating princi
### Code Style
- **TypeScript**: Strict type checking enabled (`strict: true` in `tsconfig.json`). Avoid `any` - use `unknown` with type guards or explicit Zod schemas.
- **Modularity**: Separate transport/MCP protocol concerns from OpenProject domain logic and HTTP client handling.
- **Stateless Services & Request Scoping**: Never store API tokens or client instances in global mutable variables. Tool handlers and domain services must retrieve the user's isolated client via `RequestContext` (`AsyncLocalStorage`) to guarantee multi-user concurrency and credential isolation.
- **Naming Conventions**:
- Files and directories: `kebab-case` (e.g., `openproject-client.ts`, `work-packages.ts`).
- Types and Interfaces: `PascalCase` (e.g., `OpenProjectConfig`, `WorkPackageResource`).
+84 -36
View File
@@ -6,38 +6,42 @@
```mermaid
flowchart LR
subgraph Client ["MCP Client"]
Agent["LLM Agent / Assistant"]
subgraph Clients ["MCP Clients (Multi-User)"]
UserA["User A Client\n(Key A)"]
UserB["User B Client\n(Key B)"]
end
subgraph Server ["openproject-mcp Server"]
Transport["Stdio Transport"]
Router["Tool & Resource Router"]
subgraph Transports ["Transport Layer"]
Stdio["Stdio Transport\n(Personal Process Isolation)"]
SSE["SSE / HTTP Transport\n(Shared Central Daemon)"]
end
subgraph DomainServices ["Domain Services"]
ProjSvc["Projects Service"]
WpSvc["Work Packages Service"]
QuerySvc["Queries Service"]
MetaSvc["Metadata & Taxonomies"]
end
subgraph ContextLayer ["Session & Request Context Provider"]
ALS["AsyncLocalStorage /\nSession Context Resolver"]
CtxA["RequestContext A\n(Client A, Key A)"]
CtxB["RequestContext B\n(Client B, Key B)"]
end
ClientCore["OpenProject API Client"]
AuthHandler["Auth & Request Builder\n(Basic Auth: apikey:<token>)"]
HalParser["HAL+JSON Parser & Normalizer"]
subgraph DomainServices ["Domain Services (Stateless)"]
ProjSvc["Projects Service"]
WpSvc["Work Packages Service"]
QuerySvc["Queries Service"]
MetaSvc["Metadata & Taxonomies"]
end
subgraph Remote ["OpenProject Instance"]
ApiV3["REST API v3 (/api/v3)"]
ApiV3["OpenProject REST API v3\n(/api/v3 - RBAC Enforced)"]
end
Agent <-->|MCP JSON-RPC (stdio)| Transport
Transport <--> Router
Router --> DomainServices
DomainServices --> ClientCore
ClientCore --> AuthHandler
ClientCore --> HalParser
AuthHandler -->|HTTPS Requests| ApiV3
ApiV3 -->|HAL+JSON Responses| HalParser
UserA -->|stdio or HTTP headers| Stdio
UserB -->|HTTP headers| SSE
Stdio --> ALS
SSE --> ALS
ALS --> CtxA
ALS --> CtxB
CtxA --> DomainServices
CtxB --> DomainServices
DomainServices -->|Authenticated API Requests| ApiV3
```
---
@@ -82,7 +86,48 @@ The server supports a dedicated read-only operating mode designed for auditing,
---
## 3. Tool Specifications (Browse & Query Scope)
## 3. Multi-User Concurrency & Request-Scoped Architecture
The server is architected from the ground up to support concurrent, multi-user operations safely without cross-user credential leakage, state bleed, or privilege escalation.
### 3.1. Core Concurrency Rules
1. **Zero Global Mutable State**: No global singleton `OpenProjectClient` or static API key variable exists.
2. **Stateless Domain Services**: All domain services (`ProjectsService`, `WorkPackagesService`, etc.) are stateless and operate exclusively on the `RequestContext` resolved for the active execution.
3. **RequestContext Interface**:
```typescript
export interface RequestContext {
client: OpenProjectClient;
isReadOnly: boolean;
sessionId?: string;
userId?: string;
}
```
4. **Context Propagation via AsyncLocalStorage**:
In Bun/TypeScript, request context is propagated down asynchronous call trees using `AsyncLocalStorage<RequestContext>`. Tool handlers access `getRequestContext()` to obtain their session's isolated client and read-only flags cleanly.
### 3.2. Supported Deployment Models
#### Model A: Local Personal Desktop (Stdio Transport - Default)
- **Deployment**: The MCP client (Claude Desktop, Cursor, local CLI) spawns `openproject-mcp` as a dedicated child process communicating via standard input/output.
- **Isolation**: OS-level process and memory boundary.
- **Credentials**: Passed via process environment variables or local MCP client configuration.
- **Security**: Complete isolation. Zero network ports exposed.
#### Model B: Centralized Shared Server (SSE / HTTP Transport)
- **Deployment**: A single shared `openproject-mcp` daemon runs centrally for a team or cluster.
- **Isolation**: Connection and request-level isolation.
- **Credential Delivery via Headers**:
- Each connecting client supplies their OpenProject API token via standard HTTP headers during connection handshake and request dispatch:
- `Authorization: Basic base64(apikey:<USER_API_KEY>)`, or
- `X-OpenProject-API-Key: <USER_API_KEY>`
- Optional: `X-OpenProject-Read-Only: true`
- The transport layer extracts the credentials, instantiates an ephemeral scoped `OpenProjectClient`, and executes tool calls inside `requestContextStorage.run(context, ...)`.
- **RBAC Enforcement**: All calls execute against OpenProject REST API v3 using that specific user's token. OpenProject's internal Role-Based Access Control guarantees users only retrieve projects and work packages they have permission to access.
- **Lifecycle**: Ephemeral client instances and tokens reside only in active session memory and are garbage collected upon connection close.
---
## 4. Tool Specifications (Browse & Query Scope)
| MCP Tool Name | Description | Key Parameters |
| :--- | :--- | :--- |
@@ -99,9 +144,9 @@ The server supports a dedicated read-only operating mode designed for auditing,
---
## 4. OpenProject API v3 Specifics
## 5. OpenProject API v3 Specifics
### 4.1. Authentication Details
### 5.1. Authentication Details
OpenProject API v3 utilizes HTTP Basic Authentication for API tokens:
```http
GET /api/v3/projects HTTP/1.1
@@ -111,10 +156,10 @@ Accept: application/hal+json
```
Where `YXBpa2V5OnlvdXItb3BlbnByb2plY3QtYXBpLWtleQ==` is the Base64 encoding of `apikey:<your-api-key>`.
### 4.2. HAL+JSON Handling
### 5.2. HAL+JSON Handling
API responses contain `_links` linking to related resources (parent project, author, assignee, status, type). The client layer normalizes these into human-readable labels and numeric IDs so the LLM agent can reason over them easily.
### 4.3. Filter Syntax Translation
### 5.3. Filter Syntax Translation
OpenProject filters are passed via a URL-encoded JSON string array. Example:
```json
[
@@ -126,26 +171,29 @@ The client layer provides helper builders so tools can accept friendly parameter
---
## 5. Security & Isolation
## 6. Security & Isolation
1. **Token Protection**: Credentials are read from environment variables or standard config files. Tokens are never echoed in logs or responses.
2. **Permission Boundary**: The MCP server operates with the exact permission scope of the provided API key. No elevated access is granted beyond what the user possesses in OpenProject.
1. **Token Protection**: Credentials are read from environment variables or per-connection HTTP headers. Tokens are never echoed in logs or tool payloads.
2. **Permission Boundary**: The MCP server operates strictly within the permission scope of the provided API key. No elevated access is granted beyond what the user possesses in OpenProject.
3. **Input Sanitization**: All arguments from the LLM are validated via Zod schemas prior to making network requests, preventing request-smuggling or path traversal.
4. **Transport Isolation**: The default stdio transport communicates only through standard input/output with the host client process, with no exposed network ports.
5. **Read-Only Enforcement**: Configurable read-only mode (`OPENPROJECT_READ_ONLY=true`) guarantees zero write side-effects on the OpenProject instance when operating in exploratory or untrusted agent sessions.
6. **Multi-Tenant Memory Isolation**: Ephemeral `RequestContext` instances isolated via `AsyncLocalStorage` guarantee zero token bleed or cross-user contamination in concurrent shared server environments.
---
## 6. Project Directory Structure
## 7. Project Directory Structure
```
openproject-mcp/
├── docs/
│ ├── ARCHITECTURE.md # System architecture and specifications
│ └── DECISIONS.md # Architecture Decision Records (ADR)
│ ├── DECISIONS.md # Architecture Decision Records (ADR)
│ └── TODO.md # Task tracking and roadmap
├── src/
│ ├── index.ts # CLI entry point and startup
│ ├── server.ts # MCP server instance & tool registration
│ ├── context.ts # RequestContext & AsyncLocalStorage
│ ├── client/
│ │ ├── api-client.ts # OpenProject REST API v3 HTTP client
│ │ ├── hal-parser.ts # HAL+JSON parsing and transformation
@@ -174,9 +222,9 @@ openproject-mcp/
---
## 7. Future Roadmap
## 8. Future Roadmap
- **Phase 1 (Current)**: Read/browse capability for projects, work packages, queries, and taxonomies.
- **Phase 1 (Current)**: Read/browse capability for projects, work packages, queries, and taxonomies with request-scoped context.
- **Phase 2**: Mutating operations (create/update work packages, add comments, log time).
- **Phase 3**: Attachment inspection and download resources.
- **Phase 4**: SSE / Stream transport for remote server deployments and multi-user environments.
- **Phase 4**: SSE / Stream transport for remote multi-user server deployments.
+41
View File
@@ -258,5 +258,46 @@ Implement an explicit, configurable **Read-Only Mode**:
- **Negative**:
- Tools must explicitly declare whether they are mutating or read-only during registration.
---
## ADR-012: Request-Scoped Context Architecture for Multi-User Concurrency & Security
### Status
Accepted
### Context
We want multiple users to interact with OpenProject via the MCP server concurrently, each using their own personal API key and receiving responses according to their own OpenProject permissions (RBAC).
Two architectural options were considered:
1. **Global Singleton Client**: Easy to write for single-user CLI scripts, but completely broken for concurrent users (race conditions, token overwriting, cross-user privilege escalation, and token leakage).
2. **Request-Scoped Context Pattern (`RequestContext` + `AsyncLocalStorage`)**: Every incoming request/connection carries or resolves an isolated `RequestContext` containing an ephemeral `OpenProjectClient` configured with that user's specific API credentials.
### Decision
Adopt the **Request-Scoped Context Architecture**:
1. **No Global State**: No singleton `OpenProjectClient` instance or mutable global API token will be maintained.
2. **Context Interface**:
```typescript
export interface RequestContext {
client: OpenProjectClient;
isReadOnly: boolean;
sessionId?: string;
userId?: string;
}
```
3. **Dual-Transport Compatibility**:
- **Local Stdio Mode**: The process context initializes a single `RequestContext` from local environment variables or CLI flags, isolated at the OS process boundary.
- **Shared Server (SSE / HTTP) Mode**: The connection handler extracts the user's API key from HTTP request headers (`Authorization: Basic ...` or `X-OpenProject-API-Key`), builds an ephemeral `RequestContext`, and wraps tool execution inside `requestContextStorage.run(context, fn)`.
4. **Stateless Services**: All domain services (`ProjectsService`, `WorkPackagesService`, etc.) are stateless function collections or classes that resolve the current user's client from `getRequestContext()`.
### Consequences
- **Positive**:
- Eliminates cross-user token leaks and race conditions in concurrent multi-user environments.
- Automatically respects OpenProject's per-user Role-Based Access Control (User A only sees User A's data).
- Unifies local desktop (`stdio`) and remote shared (`sse`) implementations under a single domain codebase.
- Tokens and client instances are ephemeral and garbage-collected with the request lifecycle.
- **Negative**:
- Tool handlers must access their client via `getRequestContext()` rather than directly importing a static instance.
+1
View File
@@ -46,6 +46,7 @@
- [ ] **Task 1: OpenProject REST API v3 Client Layer**
- [ ] Implement `src/config/index.ts` to parse and validate `OPENPROJECT_BASE_URL`, `OPENPROJECT_API_KEY`, and `OPENPROJECT_READ_ONLY` (or `--read-only`) via Zod.
- [ ] Implement `src/context.ts` providing `RequestContext` interface and `AsyncLocalStorage` scoping (`getRequestContext`, `runWithContext`) for concurrent multi-user safety.
- [ ] Implement `src/client/api-client.ts` with native `fetch`, HTTP Basic Auth injection, error normalization, and rate/timeout handling.
- [ ] Implement `src/client/hal-parser.ts` to unpack HAL+JSON (`_links`, `_embedded`) into clean, token-efficient JSON models.
- [ ] Implement `src/client/filter-builder.ts` to translate LLM-friendly filter arguments into OpenProject's JSON filter syntax.