Skip to content

Commit 8e18980

Browse files
authored
Refactoring of mapping between the upstream tool/resource/prompt name (#124)
* Added VirtualHost mapping Signed-off-by: cafalchio <mcafalchio@gmail.com> * Changed call_tool to use mapping Signed-off-by: cafalchio <mcafalchio@gmail.com> * Changed prompts and resources to use mapping Signed-off-by: cafalchio <mcafalchio@gmail.com> * Removed unused code, renamed file, fmt and clippy Signed-off-by: cafalchio <mcafalchio@gmail.com> * Fixed tests Signed-off-by: cafalchio <mcafalchio@gmail.com> * Added empty defaul values, clippy fmt Signed-off-by: cafalchio <mcafalchio@gmail.com> * fixed clippy and e2e test Signed-off-by: cafalchio <mcafalchio@gmail.com> * Created ServiceRoute struct Signed-off-by: cafalchio <mcafalchio@gmail.com> * Added missing resource_tempolates Signed-off-by: cafalchio <mcafalchio@gmail.com> * fixed typo Signed-off-by: cafalchio <mcafalchio@gmail.com> * Updated wiki Signed-off-by: cafalchio <mcafalchio@gmail.com> * Removed NameAlias not needed Signed-off-by: cafalchio <mcafalchio@gmail.com> * removed unused code Signed-off-by: cafalchio <mcafalchio@gmail.com> * Added type alias for clarity Signed-off-by: cafalchio <mcafalchio@gmail.com> * Added missing type alias for backend Signed-off-by: cafalchio <mcafalchio@gmail.com> * Added VirtualHostId Signed-off-by: cafalchio <mcafalchio@gmail.com> * Fixed typo downstream Signed-off-by: cafalchio <mcafalchio@gmail.com> * added DownstreamBackendName UpstreamName to ServiceRoute Signed-off-by: cafalchio <mcafalchio@gmail.com> * Ran secrets Signed-off-by: cafalchio <mcafalchio@gmail.com> * secrets Signed-off-by: cafalchio <mcafalchio@gmail.com> --------- Signed-off-by: cafalchio <mcafalchio@gmail.com>
1 parent 0608067 commit 8e18980

18 files changed

Lines changed: 297 additions & 632 deletions

File tree

.secrets.baseline

Lines changed: 87 additions & 87 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

_context/wiki/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ then follow only the links that are relevant.
1212
| [project.md](project.md) | What the project is, goals, stakeholders, key modules, crate ownership, active work |
1313
| [preferences.md](preferences.md) | Working standards, code style, logging rules, branch naming, AI interaction preferences |
1414
| [architecture.md](architecture.md) | Current middleware stack order, pipeline shape, module boundaries, state ownership, executor shapes |
15-
| [routing.md](routing.md) | Current backend prefix contract, list/routed ops, federated pagination, session state, capability merge |
15+
| [routing.md](routing.md) | Stateless routing model: VirtualHost routing tables, per-request backend lifecycle, method quick reference, header forwarding, plugin hooks |
1616
| [mcp-capability-allocation.md](mcp-capability-allocation.md) | Tentative ContextForge 2.0 target topology, ownership, state model, Phase 1-4 roadmap, and Phase 3 flows |
1717
| [failure-modes.md](failure-modes.md) | HTTP/MCP/routing/backend/plugin failure table — exact HTTP codes and JSON-RPC errors |
1818
| [config.md](config.md) | Key CLI flags, JWT claims, UserConfig shape, plugin config, telemetry debugging, startup validation, local observability stack |

_context/wiki/routing.md

Lines changed: 34 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -1,141 +1,52 @@
11
# MCP Routing Semantics
22

3-
> This page describes the **current transitional routing behavior**. Its live
4-
> upstream fan-out and durable-session assumptions are not the Phase 3 target.
5-
> See [ContextForge 2.0 Target Architecture and Roadmap](mcp-capability-allocation.md)
6-
> for the proposed ownership boundary and migration.
3+
The external dataplane is a **pure stateless router**. No session state, no `BackendTransports`, no sticky-routing requirement.
74

8-
## Backend Prefix Contract
5+
## How a request is routed
96

10-
Backend map keys become public identifiers only for **multi-backend virtual hosts without an explicit tool alias**:
7+
1. `validate_stateless` extracts `VirtualHost` from request extensions (set by `virtual_host_config` layer from the JWT virtual-host ID).
8+
2. Downstream name is looked up in `VirtualHost::tools`, `::resources`, or `::prompts` — an O(1) table lookup.
9+
3. `connect_backend_for_request` opens a fresh `StreamableHttpClientTransport`, runs the call, closes the connection.
1110

12-
```text
13-
backend tool "increment" on backend "gateway-one" → "gateway-one-increment"
14-
backend resource "counter" on backend "gateway-one" → "gateway-one-counter"
15-
```
16-
17-
Single-backend virtual hosts: identifiers pass through **unchanged**.
18-
19-
> **Breaking change rule:** changing a backend map key changes downstream identifiers for multi-backend virtual hosts. Do not rename without updating merge logic, split logic, and tests.
20-
21-
## Tool Aliases
22-
23-
`BackendMCPGateway.tool_name_aliases` maps `{downstream_alias: upstream_original}`. Aliases take precedence over prefix fallback. They are advertised and routed exactly as published (case, dots, underscores preserved).
24-
25-
## List Operations (fan-out)
26-
27-
All four list methods fan out to all connected backends concurrently and merge results:
28-
29-
```text
30-
list_tools / list_resources / list_prompts / list_resource_templates
31-
→ all connected backends → merged sorted output
32-
```
11+
The control plane builds and publishes the routing tables to Redis; the dataplane never derives names at call time.
3312

34-
Failed/unavailable backends are logged and skipped. Single-backend: identifiers unchanged. Multi-backend: prefixed with backend map key.
13+
## Routing table shape
3514

36-
## Routed Operations (single backend)
15+
```rust
16+
VirtualHost { backends: HashMap<String, BackendMCPGateway>,
17+
tools: HashMap<String, ServiceRoute>,
18+
resources: HashMap<String, ServiceRoute>,
19+
resource_templates: HashMap<String, ServiceRoute>,
20+
prompts: HashMap<String, ServiceRoute> }
3721

38-
Calls targeting one object use the inverse rule. The name splitter walks configured backend names and requires a `-` immediately after the backend name:
39-
40-
```text
41-
gateway-one-increment → backend: gateway-one, tool: increment
42-
gateway-oneincrement → rejected (no - separator)
22+
ServiceRoute { backend_name: String, // key into VirtualHost::backends
23+
upstream_name: String } // name/URI forwarded to the backend
4324
```
4425

45-
`call_tool` resolves explicit alias first, then falls back to single/multi-backend logic.
46-
47-
Methods using the same conditional routing: `read_resource`, `subscribe`, `unsubscribe`, `get_prompt`, `complete`.
26+
Source: [`user_store.rs`](../../crates/contextforge-data-plane-apis/src/user_store.rs)
4827

49-
## Federated Pagination
28+
## Method quick reference
5029

51-
The gateway wraps per-backend cursors inside its own opaque token (JSON, treated as opaque by MCP clients). First request: all backends queried. Resume: cursor decoded, exhausted backends skipped. New cursor emitted when any backend has more pages.
30+
| Method | Behavior |
31+
| --- | --- |
32+
| `initialize` (`2026-07-28`) | `INVALID_REQUEST` — not supported by this dataplane. |
33+
| `initialize` (legacy) | Stub `InitializeResult`; no backend fanout. Supports older clients during migration. |
34+
| `list_tools`, `list_resources`, `list_resource_templates`, `list_prompts` | `INVALID_REQUEST` — delegated to control plane. |
35+
| `call_tool` | Lookup in `tools` map → pre-hook → fresh connection → call → post-hook → close. Forwards cancellation; tracks progress tokens. |
36+
| `read_resource` | Lookup in `resources` map → fresh connection → call with upstream URI → close. |
37+
| `get_prompt` | Lookup in `prompts` map → pre-hook → fresh connection → call → post-hook → close. |
38+
| `subscribe`, `unsubscribe`, `complete` | `INVALID_REQUEST` — delegated to control plane. |
39+
| `ping` | Local success; no backend fanout. |
40+
| `DELETE` | RMCP handles; `session_id_layer` removes the `LocalUserSessionStore` entry. No backend state to clean up. |
5241

53-
**Known limitation:** if backend set changes between pages, removed backend's cursor is silently dropped.
42+
## Header forwarding
5443

55-
## Session State (local process)
44+
Applied in order per upstream call: Host (from backend URL, HTTPS only) → passthrough (`BackendMCPGateway::passthrough_headers`) → `Mcp-Param-*` auto-forward → trace context → add (`add_headers`, overrides passthrough) → remove (`remove_headers`, applied last).
5645

57-
Backend RMCP services are stored in `BackendTransports` keyed by:
58-
```text
59-
principal (claims.sub) + backend_name (map key) + downstream_session_id
60-
```
61-
62-
This is **local process state only**. Implications:
63-
- After `initialize`, later requests must reach the same process.
64-
- Sticky routing required for load-balanced deployments.
65-
- Gateway restart → all sessions lost → clients must re-run `initialize`.
66-
- Multi-runtime mode (`--single-runtime false`): each runtime thread has its own `BackendTransports` with no cross-thread affinity.
67-
68-
**Exception: `call_tool` uses per-request backend lifecycle.** Each tool call creates a fresh backend connection, executes the call with plugin hooks, then closes the connection. This bypasses `BackendTransports` entirely and does not require session affinity for tool calls specifically (though other MCP methods still do).
69-
70-
71-
```mermaid
72-
sequenceDiagram
73-
participant C as MCP Client
74-
participant GW as Gateway (RMCP)
75-
participant BT as BackendTransports<br/>(local process state)
76-
participant LU as LocalUserSessionStore<br/>(LRU 50k / 1h)
77-
participant BA as Backend A
78-
participant BB as Backend B
79-
80-
C->>GW: POST initialize (Mcp-Session-Id: S)
81-
GW->>BA: initialize (concurrent)
82-
GW->>BB: initialize (concurrent)
83-
BA-->>GW: InitializeResult
84-
BB-->>GW: InitializeResult
85-
GW->>BT: store RunningService keyed by sub+backend+S
86-
GW->>LU: store session entry for sub+S
87-
GW-->>C: merged InitializeResult
88-
89-
C->>GW: POST call_tool (Mcp-Session-Id: S)
90-
GW->>BT: lookup sub+backend+S → Arc<RunningService>
91-
BT-->>GW: RunningService handle
92-
GW->>BA: call_tool (routed by name prefix)
93-
BA-->>GW: ToolResult
94-
GW-->>C: ToolResult
95-
96-
C->>GW: DELETE (Mcp-Session-Id: S)
97-
GW->>GW: RMCP handles DELETE
98-
GW->>LU: remove sub+S entry
99-
GW->>BT: remove all sub+*+S entries
100-
GW-->>C: 200 OK
101-
```
46+
Protected headers that config can never touch: `Host`, `Content-Length`, `Content-Type`, all RFC 7230 hop-by-hop headers, `Mcp-Session-Id`, `Accept`, `Last-Event-Id`, and all computed MCP standard headers (`Mcp-Method`, `Mcp-Name`, `Mcp-Protocol-Version`, `Mcp-Param-*`).
10247

103-
## Capability Merge
48+
For clients on `≥ 2026-07-28`, `call_tool` validates `Mcp-Param-*` headers against `BackendMCPGateway::tool_schemas` before contacting the backend.
10449

105-
On `initialize`, the gateway builds one downstream `InitializeResult` — not a passthrough of any one backend. The source of truth is each backend's `InitializeResult`; the gateway reads `peer_info().capabilities` from each running service and stores them with the backend transport state.
50+
## Plugin hooks
10651

107-
The merge rule (gateway-aware, not a raw union):
108-
- Enable a top-level capability when ≥1 backend supports it **and** the gateway has a routing story for it.
109-
- `resources.subscribe` preserved if any backend advertises it (the gateway routes subscribe/unsubscribe and forwards resource-update notifications).
110-
- `listChanged` not yet advertised (gateway doesn't emit downstream list-changed notifications when upstream lists change).
111-
- Single-backend passthrough is not a stable contract (`HashMap` iteration order).
112-
- If no backend reports supported capabilities, returns `ServerCapabilities::default()`.
113-
114-
**Do not** initialize the downstream capability from just one backend entry — the gateway fronts multiple backends, `HashMap` iteration is non-deterministic, and list methods already merge across all backends.
115-
116-
## Cleanup
117-
118-
`DELETE` with `Mcp-session-id`:
119-
```text
120-
→ RMCP handles request
121-
→ on success: remove LocalUserSessionStore entry + BackendTransports entries for principal+session
122-
```
123-
If RMCP rejects the delete, local state is untouched.
124-
125-
126-
## MCP Method Quick Reference
127-
128-
| Method | Group | Behavior |
129-
| --- | --- | --- |
130-
| `initialize` | Session | Concurrent fanout to all backends; failure of one backend is non-fatal (stored with no service). Returns merged capability set. Requires `DownstreamSessionId`, `UserConfig`, `VirtualHostId`, `ContextForgeClaims`. |
131-
| `list_tools` | List | Fan-out all connected backends → merged sorted result. Cursor-based pagination across backends. |
132-
| `list_resources` | List | Same as list_tools. |
133-
| `list_prompts` | List | Same as list_tools. |
134-
| `list_resource_templates` | List | Same — both name and URI template get prefixed for multi-backend. |
135-
| `call_tool` | Targeted | **Per-request backend lifecycle:** creates fresh connection via `connect_backend_for_request`, runs pre-hook, executes call, runs post-hook, closes connection. Resolves alias → single/multi-backend fallback. Forwards downstream cancellation to backend. Tracks backend progress tokens: RMCP assigns a new token per backend request; the gateway maps each backend token to the downstream token. Request enqueue and mapping publication are serialized against progress lookup so an immediate backend notification cannot overtake registration. When the notification matches an in-flight token, the gateway restores the downstream token and forwards it to the client. Does not use session-backed `BackendTransports`. |
136-
| `read_resource` | Targeted | Single-backend: URI unchanged. Multi-backend: strips prefix. |
137-
| `subscribe` / `unsubscribe` | Targeted | Same resource-URI routing; forwards/stops resource-update notifications. |
138-
| `get_prompt` | Targeted | Single-backend: name unchanged. Multi-backend: strips prefix. Runs pre/post prompt hooks around the backend call: the pre hook may rewrite arguments or deny, the post hook may rewrite or reject the rendered messages. |
139-
| `complete` | Targeted | Routes on prompt name or resource URI inside `ref`. |
140-
| `ping` | Local | Returns success; no backend fanout. |
141-
| `DELETE` | Session | RMCP handles first; on success `session_id_layer` removes local session + backend transports. |
52+
`call_tool` and `get_prompt` run `before_*/after_*` hooks when a `GatewayPluginRuntimeHandle` is configured. Pre-hook may rewrite arguments or deny; post-hook may rewrite or reject the response. Pre-hook state is passed to the post-hook.
Lines changed: 25 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
use std::collections::{HashMap, HashSet};
1+
use std::collections::HashMap;
22

33
use schemars::JsonSchema;
44
use serde::{Deserialize, Serialize};
55

6+
pub type DownstreamBackendName = String;
7+
pub type DownstreamToolName = String;
8+
pub type DownstreamResourceName = String;
9+
pub type DownstreamResourceTemplateName = String;
10+
pub type DownstreamPromptName = String;
11+
pub type UpstreamName = String;
12+
pub type VirtualHostId = String;
13+
614
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default)]
715
pub enum IntegrationType {
816
#[serde(rename = "REST")]
@@ -12,40 +20,6 @@ pub enum IntegrationType {
1220
Mcp,
1321
}
1422

15-
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, Default, Eq)]
16-
pub struct NameAlias {
17-
downstream_prefixed_name: String,
18-
upstream_name: String,
19-
}
20-
21-
impl PartialEq for NameAlias {
22-
fn eq(&self, other: &Self) -> bool {
23-
self.downstream_prefixed_name == other.downstream_prefixed_name
24-
}
25-
}
26-
27-
impl std::hash::Hash for NameAlias {
28-
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
29-
self.downstream_prefixed_name.hash(state);
30-
}
31-
}
32-
33-
impl NameAlias {
34-
pub fn new(downstream_prefixed_name: String, upstream_name: String) -> Self {
35-
Self { downstream_prefixed_name, upstream_name }
36-
}
37-
pub fn with_downstream_prefixed_name(downstream_prefixed_name: String) -> Self {
38-
NameAlias { downstream_prefixed_name, upstream_name: String::new() }
39-
}
40-
pub fn get_upstream_name(&self) -> &str {
41-
&self.upstream_name
42-
}
43-
44-
pub fn get_downstream_prefixed_name(&self) -> &str {
45-
&self.downstream_prefixed_name
46-
}
47-
}
48-
4923
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
5024
pub struct BackendMCPGateway {
5125
pub name: String,
@@ -60,23 +34,31 @@ pub struct BackendMCPGateway {
6034
#[serde(default)]
6135
pub remove_headers: Vec<String>,
6236
#[serde(default)]
63-
pub tool_name_aliases: HashSet<NameAlias>,
64-
#[serde(default)]
65-
pub resource_uri_aliases: HashSet<NameAlias>,
66-
#[serde(default)]
67-
pub prompt_name_aliases: HashSet<NameAlias>,
68-
#[serde(default)]
6937
pub completion: HashMap<String, String>,
7038
/// Input schemas keyed by the original upstream tool name.
7139
pub tool_schemas: HashMap<String, serde_json::Map<String, serde_json::Value>>,
7240
}
7341

42+
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
43+
pub struct ServiceRoute {
44+
pub backend_name: DownstreamBackendName,
45+
pub upstream_name: UpstreamName,
46+
}
47+
7448
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
7549
pub struct VirtualHost {
76-
pub backends: HashMap<String, BackendMCPGateway>,
50+
pub backends: HashMap<DownstreamBackendName, BackendMCPGateway>,
51+
#[serde(default)]
52+
pub tools: HashMap<DownstreamToolName, ServiceRoute>,
53+
#[serde(default)]
54+
pub resources: HashMap<DownstreamResourceName, ServiceRoute>,
55+
#[serde(default)]
56+
pub resource_templates: HashMap<DownstreamResourceTemplateName, ServiceRoute>,
57+
#[serde(default)]
58+
pub prompts: HashMap<DownstreamPromptName, ServiceRoute>,
7759
}
7860

7961
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
8062
pub struct UserConfig {
81-
pub virtual_hosts: HashMap<String, VirtualHost>,
63+
pub virtual_hosts: HashMap<VirtualHostId, VirtualHost>,
8264
}

0 commit comments

Comments
 (0)