From 5886419b06fdeb0106b0d3362431ba1ab8cb3ecc Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Sat, 5 Sep 2026 16:53:42 +0200 Subject: [PATCH] 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 --- AGENTS.md | 1 + docs/ARCHITECTURE.md | 122 ++++++++++++++++++++++++++++++------------- docs/DECISIONS.md | 41 +++++++++++++++ docs/TODO.md | 1 + 4 files changed, 128 insertions(+), 37 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e20e37c..d910775 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 2ddf0f6..ead1530 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 DomainServices ["Domain Services"] - ProjSvc["Projects Service"] - WpSvc["Work Packages Service"] - QuerySvc["Queries Service"] - MetaSvc["Metadata & Taxonomies"] - end + subgraph Transports ["Transport Layer"] + Stdio["Stdio Transport\n(Personal Process Isolation)"] + SSE["SSE / HTTP Transport\n(Shared Central Daemon)"] + end - ClientCore["OpenProject API Client"] - AuthHandler["Auth & Request Builder\n(Basic Auth: apikey:)"] - HalParser["HAL+JSON Parser & Normalizer"] + 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 + + 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`. 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:)`, or + - `X-OpenProject-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:`. -### 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. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index f608158..5b6b3c1 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -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. + + diff --git a/docs/TODO.md b/docs/TODO.md index c1454d7..f6ba1b1 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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.