18 KiB
HTTP-MCP Contract v1
Goal
A versioned HTTP API contract for lean-ctx Context OS, defining the REST + SSE surface
that sits alongside the Streamable HTTP MCP transport. All endpoints listed below are
served by the same axum server that handles MCP protocol messages via fallback routing.
- workspace-aware: every request is scoped to a
(workspace_id, channel_id)pair. - observable: tool calls, session mutations, and graph builds emit events to an SSE bus.
- redaction-safe: event payloads are stripped by default; full payloads require Audit scope.
- bounded: SSE replay is capped at 1 000 events; rate + concurrency limits protect the server.
Version (SSOT)
- Runtime (local):
rust/src/http_server/mod.rs - Runtime (team):
rust/src/http_server/team.rs - Events:
rust/src/core/context_os/context_bus.rs - Metrics:
rust/src/core/context_os/metrics.rs - Redaction:
rust/src/core/context_os/redaction.rs
Endpoints
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | /health |
none | Liveness probe (200 ok) |
| GET | /v1/manifest |
bearer | Full MCP manifest |
| GET | /v1/capabilities |
bearer | Instance capabilities discovery (contract) |
| GET | /v1/openapi.json |
bearer | OpenAPI 3.0 spec for this surface |
| GET | /v1/tools |
bearer | Paginated tool list |
| POST | /v1/tools/call |
bearer | Execute a single tool |
| GET | /v1/events |
bearer + Events scope |
SSE stream with replay |
| GET | /v1/context/summary |
bearer | Materialized workspace/channel summary |
| GET | /v1/events/search |
bearer | Full-text search over event payloads (FTS5) |
| GET | /v1/events/lineage |
bearer | Causal lineage chain for an event |
| GET | /v1/metrics |
bearer + Audit scope |
JSON metrics snapshot |
| POST (fallback) | /* |
bearer | Streamable HTTP MCP transport |
Error Responses
Every REST endpoint returns errors as a JSON envelope with Content-Type: application/json:
{ "error": "invalid bearer token", "error_code": "unauthorized" }
| Field | Type | Description |
|---|---|---|
error |
string | Human-readable message — for logs/UI, not for branching |
error_code |
string | Stable machine code clients switch on |
Codes
error_code |
HTTP | Raised when |
|---|---|---|
unauthorized |
401 | Missing/malformed Authorization header, wrong scheme, or invalid bearer token |
scope_denied |
403 | Valid token, but its scopes do not grant the requested endpoint/tool (team server) |
unknown_workspace |
400 | x-leanctx-workspace / body workspaceId names a workspace the server does not serve (team server) |
invalid_arguments |
400 | Tool arguments is not a JSON object (team server) |
invalid_request |
400 | Request body could not be read/parsed (team server) |
tool_error |
400 | The tool ran but returned an error |
request_timeout |
504 | The tool call exceeded request_timeout_ms |
GET /health is exempt — it is a plain-text liveness probe (200 ok), never the JSON envelope.
The A2A JSON-RPC surface keeps the standard JSON-RPC error: { code, message } shape instead.
Workspaces and Channels
Every HTTP request is associated with a (workspace_id, channel_id) pair that determines session isolation and event routing.
Tool Call Requests
Include workspaceId and channelId in the JSON request body of POST /v1/tools/call:
{
"name": "ctx_read",
"arguments": { "path": "src/main.rs" },
"workspaceId": "backend-team",
"channelId": "feature-auth"
}
Both fields default to "default" when omitted. Sessions are shared per unique
(workspace_id, channel_id) pair — two requests with the same pair share caches,
scratchpad, and knowledge state.
Workspace Header (Team Server)
The team server supports workspace routing via the x-leanctx-workspace HTTP header:
x-leanctx-workspace: backend-team
The header is resolved during authentication. If the header is absent, the
defaultWorkspaceId from the team server configuration is used. An unknown workspace
returns 400 Bad Request.
Precedence
| Source | Applies to | Priority |
|---|---|---|
workspaceId in JSON body |
POST /v1/tools/call |
highest |
x-leanctx-workspace header |
all endpoints (team server) | fallback |
defaultWorkspaceId config |
team server default | lowest |
Events API (SSE)
Endpoint
GET /v1/events?workspaceId=<ws>&channelId=<ch>&since=<cursor>&limit=<n>
| Parameter | Type | Default | Description |
|---|---|---|---|
workspaceId |
string | "default" |
Filter events by workspace |
channelId |
string | "default" |
Filter events by channel |
since |
i64 | 0 |
Cursor — replay events with id > since |
limit |
usize | 200 |
Max events to replay (capped at 1 000) |
Protocol
Server-Sent Events (SSE) stream with full replay support. The connection starts by replaying persisted events matching the filter, then switches to live broadcast.
HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
id: 42
event: tool_call_recorded
data: {"id":42,"workspaceId":"ws1","channelId":"ch1","kind":"tool_call_recorded","actor":"agent","timestamp":"2026-05-05T13:00:00Z","payload":{...}}
id: 43
event: session_mutated
data: {"id":43,"workspaceId":"ws1","channelId":"ch1","kind":"session_mutated","actor":"agent","timestamp":"2026-05-05T13:00:01Z","payload":{...}}
Event Types
| Kind | Trigger |
|---|---|
tool_call_recorded |
Any MCP tool invocation completes |
session_mutated |
Shared session state is modified |
knowledge_remembered |
Knowledge store entry written |
artifact_stored |
Artifact persisted to proof store |
graph_built |
Dependency/call graph index built or updated |
proof_added |
Evidence ledger entry appended |
Event Schema (ContextEventV1)
{
"id": 42,
"workspaceId": "ws1",
"channelId": "ch1",
"kind": "tool_call_recorded",
"actor": "agent-a",
"timestamp": "2026-05-05T13:00:00.000Z",
"version": 17,
"parentId": null,
"consistencyLevel": "local",
"payload": {
"tool": "ctx_read",
"action": null,
"path": "src/main.rs",
"reasoning": "Reading entry point for auth refactor"
}
}
| Field | Type | Description |
|---|---|---|
id |
i64 | Monotonically increasing event ID (SQLite autoincrement) |
workspaceId |
string | Workspace that produced the event |
channelId |
string | Channel within the workspace |
kind |
string | One of the event types above |
actor |
string | null | Identifier of the agent/user that triggered the event |
timestamp |
RFC 3339 | Server-side UTC timestamp |
version |
i64 | Monotonic counter per (workspace, channel) pair |
parentId |
i64 | null | Causal link to the triggering event (enables lineage graphs) |
consistencyLevel |
string | "local", "eventual", or "strong" (see below) |
payload |
object | Event-specific data (subject to redaction) |
Consistency Levels
Each event is classified by how it should be treated in multi-agent coordination:
| Level | Meaning | Event Kinds |
|---|---|---|
local |
Agent-local, informational — never requires sync | tool_call_recorded, graph_built |
eventual |
Shared, eventually consistent — broadcast via bus | knowledge_remembered, artifact_stored |
strong |
Shared, critical — other agents should sync before proceeding | session_mutated, proof_added |
Enriched Payloads
Event payloads include contextual metadata when available:
| Field | Included When | Description |
|---|---|---|
tool |
always | Tool name that triggered the event |
action |
tool has action param | Tool action (e.g., "remember", "save") |
path |
file-related tools | File path involved |
category |
knowledge tools | Knowledge category |
key |
knowledge tools | Knowledge key |
reasoning |
session has active task | Current task description from session state |
Staleness Guard
When an agent in shared mode has fallen behind by more than K events (default: 10),
the server injects a [CONTEXT STALE] prefix into tool responses:
[CONTEXT STALE] 15 events happened since your last read. Use ctx_session(action="status") to sync.
Knowledge Conflict Detection
When ctx_knowledge(action="remember") writes a fact and another agent recently wrote to
the same category/key, a [CONFLICT] warning is injected:
[CONFLICT] Agent 'agent-b' recently wrote to the same knowledge key 'architecture/auth-strategy'. Review before proceeding.
SSE Backfill on Lag
When a broadcast subscriber falls behind (channel buffer overflow), the server automatically
backfills missed events from SQLite instead of silently dropping them. Clients may
receive a synthetic event: backfill SSE message indicating the recovery.
Reconnect
Use since=<lastEventId> to resume from the last received cursor. Events are persisted
in SQLite and survive server restarts. The SSE id: field matches ContextEventV1.id.
GET /v1/events?workspaceId=ws1&channelId=ch1&since=42
Heartbeat
The server sends a keep-alive comment every 15 seconds to prevent proxy/client timeouts:
: keep-alive
Context Summary
Endpoint
GET /v1/context/summary?workspaceId=<ws>&channelId=<ch>&limit=<n>
Returns a materialized view of the workspace/channel state: active agents, recent decisions, knowledge delta, conflict alerts, and event counts by kind.
Response Schema
{
"workspaceId": "ws1",
"channelId": "ch1",
"totalEvents": 142,
"latestVersion": 142,
"activeAgents": ["agent-a", "agent-b"],
"recentDecisions": [
{
"agent": "agent-b",
"tool": "ctx_knowledge",
"action": "remember",
"reasoning": "JWT preferred for scaling",
"timestamp": "2026-05-05T13:00:01Z"
}
],
"knowledgeDelta": [...],
"conflictAlerts": [
{ "category": "architecture", "key": "auth-strategy", "agents": ["agent-a", "agent-b"] }
],
"eventCountsByKind": {
"tool_call_recorded": 120,
"session_mutated": 10,
"knowledge_remembered": 8,
"artifact_stored": 3,
"graph_built": 1,
"proof_added": 0
}
}
Event Search (FTS5)
Endpoint
GET /v1/events/search?q=<query>&workspaceId=<ws>&limit=<n>
Full-text search over event payloads using SQLite FTS5. Returns matching events ranked by relevance.
| Parameter | Type | Default | Description |
|---|---|---|---|
q |
string | required | FTS5 search query |
workspaceId |
string | "default" |
Filter by workspace |
limit |
usize | 20 |
Max results (capped at 100) |
Event Lineage
Endpoint
GET /v1/events/lineage?id=<eventId>&depth=<n>
Traces the causal chain of an event by following parentId links. Returns the event
and all ancestors up to depth (default 20, max 50).
Response Schema
{
"eventId": 42,
"chain": [ /* ContextEventV1[] from child to root */ ],
"depth": 3
}
Metrics
Endpoint
GET /v1/metrics
GET /v1/metrics?format=prometheus
Returns a JSON snapshot of Context OS process-level counters. Requires Audit scope on
the team server. With format=prometheus the response switches to Prometheus
text exposition (text/plain; version=0.0.4) containing the leanctx_team_*
SLO series — additive, introduced for the hosted-index SLO gate (GL #391).
Response Schema (MetricsSnapshot)
{
"eventsAppended": 1234,
"eventsBroadcast": 1200,
"eventsReplayed": 560,
"sseConnectionsActive": 3,
"sseConnectionsTotal": 47,
"sharedSessionsLoaded": 12,
"sharedSessionsPersisted": 8,
"activeWorkspaceCount": 2,
"slo": {
"requests_total": 1234,
"errors_total": 2,
"window_len": 1024,
"p50_ms": 18.0,
"p95_ms": 142.0,
"p99_ms": 305.0,
"availability_pct": 99.84,
"index_lag_seconds": 41.0,
"uptime_seconds": 86400
}
}
The slo block (additive since GL #391) carries the team server's rolling SLO
signals: nearest-rank latency percentiles over the last 4096 /v1 requests,
availability as the non-5xx share of that window, and seconds since the last
successful Index-scoped tool call (index_lag_seconds is null/absent until
one happened; uptime_seconds is absent outside a serving process).
| Field | Type | Description |
|---|---|---|
eventsAppended |
u64 | Total events written to the SQLite event log |
eventsBroadcast |
u64 | Total events pushed to live SSE subscribers |
eventsReplayed |
u64 | Total events served via replay (since queries) |
sseConnectionsActive |
u64 | Currently open SSE connections (opened − closed) |
sseConnectionsTotal |
u64 | Lifetime SSE connections opened |
sharedSessionsLoaded |
u64 | Shared sessions loaded from disk |
sharedSessionsPersisted |
u64 | Shared sessions persisted to disk |
activeWorkspaceCount |
usize | Distinct workspace IDs seen since process start |
Redaction
Event payloads delivered via SSE are redacted by default to prevent leaking file contents, session data, or tool arguments to observers.
Redaction Levels
| Level | Default | Exposed Fields | Requires |
|---|---|---|---|
refs_only |
yes | tool, kind, event_kind, workspace_id, channel_id, id + "redacted": true |
— |
summary |
no | All metadata preserved; sensitive content fields (content, file_content, result, output, session_data, knowledge_value, arguments) replaced with [redacted] |
— |
full |
no | Complete payload, no redaction | Audit scope |
Example: refs_only (default)
{
"tool": "ctx_read",
"kind": "tool_call_recorded",
"workspace_id": "ws1",
"redacted": true
}
Example: summary
{
"tool": "ctx_read",
"kind": "tool_call_recorded",
"workspace_id": "ws1",
"content": "[redacted]",
"arguments": "[redacted]"
}
Example: full
{
"tool": "ctx_read",
"kind": "tool_call_recorded",
"workspace_id": "ws1",
"content": "use std::sync::Arc;\n...",
"arguments": { "path": "src/main.rs", "mode": "full" }
}
Auth / Scopes (Team Server)
The team server enforces scope-based authorization per bearer token. Tokens are configured in the team server JSON config with SHA-256 hashes.
Token Configuration
{
"tokens": [
{
"id": "ci-readonly",
"sha256Hex": "<lowercase hex of SHA-256(token)>",
"scopes": ["search", "graph"]
},
{
"id": "admin",
"sha256Hex": "<lowercase hex of SHA-256(token)>",
"scopes": ["search", "graph", "artifacts", "index", "events", "sessionMutations", "knowledge", "audit"]
}
]
}
Scopes
| Scope | Grants Access To |
|---|---|
search |
ctx_read, ctx_multi_read, ctx_smart_read, ctx_search, ctx_tree, ctx_outline, ctx_expand, ctx_delta, ctx_dedup, ctx_prefetch, ctx_preload, ctx_review, ctx_response, ctx_task, ctx_overview, ctx_pack (+ graph), ctx_semantic_search |
graph |
ctx_graph, ctx_impact, ctx_callgraph, ctx_refactor, ctx_routes, ctx_pack (+ search) |
artifacts |
ctx_semantic_search with artifacts=true |
index |
ctx_graph with action=index-build*, ctx_semantic_search with action=reindex |
events |
GET /v1/events SSE stream |
sessionMutations |
Shared session write operations |
knowledge |
Knowledge store read/write |
audit |
GET /v1/metrics, full-payload event access, audit log reads |
Blocked Tools
The following tools are never allowed on the team server (no scope grants access):
ctx_shell/ctx_execute— arbitrary command executionctx_edit— file modification
Scope Enforcement
- Endpoint-level:
/v1/eventsrequiresevents,/v1/metricsrequiresaudit. - Tool-level: each tool call is mapped to required scopes via
required_scopes(). The request is allowed only ifrequired_scopes ⊆ token_scopes.ctx_session(mutating actions) →sessionMutationsctx_knowledge,ctx_knowledge_relations(mutating actions) →knowledgectx_artifacts(mutating actions) →artifactsctx_proof,ctx_verify(mutating actions) →search
- MCP fallback:
tools/callJSON-RPC requests on the MCP transport are also scope-checked by parsing the request body in the auth middleware.
Audit Log
Every tool call and endpoint access is logged to the configured auditLogPath as
newline-delimited JSON:
{
"ts": "2026-05-05T13:00:00+02:00",
"tokenId": "ci-readonly",
"workspaceId": "ws1",
"tool": "ctx_read",
"method": "/v1/tools/call",
"allowed": true,
"deniedReason": null,
"argumentsMd5": "d41d8cd98f00b204e9800998ecf8427e"
}
Server Configuration
Local Server (HttpServerConfig)
| Field | Type | Default | Description |
|---|---|---|---|
host |
string | 127.0.0.1 |
Bind address |
port |
u16 | 8080 |
Bind port |
auth_token |
string | null | none | Bearer token (required for non-loopback) |
stateful_mode |
bool | false |
MCP stateful session mode |
max_body_bytes |
usize | 2 MiB |
Max request body size |
max_concurrency |
usize | 32 |
Max concurrent requests (semaphore) |
max_rps |
u32 | 50 |
Token-bucket rate limit (requests/sec) |
rate_burst |
u32 | 100 |
Token-bucket burst capacity |
request_timeout_ms |
u64 | 30 000 |
Per-request timeout |
Team Server (TeamServerConfig)
Extends the local server with multi-workspace support, token-based auth, and audit logging.
See rust/src/http_server/team.rs for the full config schema.
Security
- Non-loopback binding requires
--auth-token(local server) or configured tokens (team server). - Bearer tokens are compared in constant time to prevent timing attacks.
- Team server tokens are stored as SHA-256 hashes — raw tokens never touch disk.
- Rate limiting and concurrency guards protect against resource exhaustion.
- Host header validation follows rmcp defaults (loopback-only) unless explicitly overridden.