Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions _context/wiki/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,11 +129,14 @@ BackendMCPGateway
add_headers: HashMap<String, String> ← injected after passthrough
remove_headers: Vec<String> ← stripped after add
tool_name_aliases: HashMap<String, String> ← downstream_alias → upstream_original
allowed_tool_names: Vec<String> ← model exists, NOT currently enforced
allowed_resource_names: Vec<String> ← model exists, NOT currently enforced
allowed_prompt_names: Vec<String> ← model exists, NOT currently enforced
allowed_tool_names: Vec<String> ← enforced against resolved upstream tool names
allowed_resource_names: Vec<String> ← display/catalog names; not sufficient for read authorization
allowed_resource_uris: Vec<String> ← enforced against resolved upstream resource URIs
allowed_prompt_names: Vec<String> ← enforced against resolved upstream prompt names
```

An empty effective allowlist denies every targeted call in that object category. The control-plane publisher must populate `allowed_resource_uris` before this schema is deployed; its current `allowed_resource_names` values cannot authorize URI-based `resources/read` requests.

**Header apply order:** `passthrough_headers` → `add_headers` (override passthrough) → `remove_headers` (applied last).

**`passthrough_headers` is session-scoped.** Values are snapshotted from the `initialize` request and baked into the backend transport for the session lifetime. Post-`initialize` calls (tool calls, list calls) reuse those headers. Request-scoped propagation requires per-request transport reconstruction (future work).
Expand Down
1 change: 1 addition & 0 deletions _context/wiki/failure-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
| --- | --- |
| Prefixed name doesn't start with backend name + `-` | Internal error |
| No backend entry matches split name | Internal error (`got no responses from backends`) |
| Resolved tool, prompt, or resource URI absent from its effective allowlist | `-32602 Invalid params`; rejected before plugins or backend connection |
| Backend entry exists but no running service | Internal error (backend failed during initialize) |
| More than one backend entry matches | `INVALID_REQUEST`; session backend entries cleaned up |
| Undecodable pagination cursor | `-32602 Invalid params` |
Expand Down
12 changes: 12 additions & 0 deletions _context/wiki/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,18 @@ gateway-oneincrement → rejected (no - separator)

Methods using the same conditional routing: `read_resource`, `subscribe`, `unsubscribe`, `get_prompt`, `complete`.

## Effective Object Allowlists

After alias or prefix resolution, targeted calls check the backend-local identifier against the selected backend's effective allowlist:

| Method | Effective allowlist |
| --- | --- |
| `tools/call` | `allowed_tool_names` |
| `resources/read` | `allowed_resource_uris` |
| `prompts/get` | `allowed_prompt_names` |

Missing entries return `INVALID_PARAMS` with the same not-found response as an unroutable object. Rejection happens before plugin hooks and backend connection setup, and an empty allowlist denies every object in that category. The control-plane publisher must supply resource URIs; resource display names are not valid authorization keys for `resources/read`.

## Federated Pagination

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.
Expand Down
8 changes: 4 additions & 4 deletions _context/wiki/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
| --- | --- |
| ContextForge control plane | Owns login/SSO, users, teams, IAM, API-token issuance and revocation, and external-dataplane configuration publication. `dataplane_publisher.py` writes visibility-filtered `UserConfig` snapshots to Redis by user email. |
| ContextForge built-in dataplane | Owns the Python repository's MCP request routes, including old/new protocol and stateful/stateless behavior. |
| ContextForge external dataplane | Has no IAM or user database. It currently verifies modern MCP bearer JWTs locally, loads `UserConfig` by `sub`, and requires the requested virtual host to exist. No runtime control-plane call occurs. |
| ContextForge external dataplane | Has no IAM or user database. It verifies modern MCP bearer JWTs locally, loads `UserConfig` by `sub`, requires the requested virtual host to exist, and enforces effective object allowlists for targeted calls. No runtime control-plane call occurs. |

External-dataplane request path: control-plane API token (`sub` = email) → Origin check →
`claims_layer` → Redis config lookup → virtual-host check → RMCP Host check →
Expand All @@ -29,9 +29,9 @@ external-dataplane contract.
are required fields; `token_use`, `iat`, `teams`, and `scopes` are optional.
- Failures: bad/missing JWT → `401`; no user config → `400`; unavailable virtual
host → `404`.
- Authorization is currently coarse: valid JWT plus published virtual host.
JWT scopes/teams and object allowlists are not enforced; publishing a backend
exposes all objects returned by it.
- Authorization remains coarse for JWT scopes and teams, but targeted tool,
prompt, and resource-read calls are denied unless the resolved backend-local
identifier appears in the published effective object allowlist.
- This coarse current behavior does not meet the tentative Phase 3 target. The
target requires principal- and isolation-bound snapshots, per-request scope
and compiled-RBAC enforcement, and default denial for missing or unauthorized
Expand Down
2 changes: 1 addition & 1 deletion _context/wiki/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Protocol-sensitive tests and fixtures must cover MCP `2026-07-28` and `2025-11-2
| `gateway_list_tools.rs` | List fanout, prefixing, and merged output. |
| `gateway_prompts.rs` | Prompt listing and prefixed `get_prompt` routing. |
| `gateway_resource_templates.rs` | Template fanout with prefixed names and URI templates, plus `read_resource` round-trips. |
| `gateway_plugins.rs` | CPEX pre/post tool hooks around `call_tool` and stream events, and prompt hooks around `get_prompt`. |
| `gateway_plugins.rs` | Effective object allowlist rejection before backend connection, CPEX hooks, and request-scoped progress. |

These run in `cargo nextest run` with no Docker dependencies.

Expand Down
2 changes: 2 additions & 0 deletions crates/contextforge-data-plane-apis/src/user_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ pub struct BackendMCPGateway {
pub completion: HashMap<String, String>,

pub allowed_resource_names: Vec<String>,
#[serde(default)]
pub allowed_resource_uris: Vec<String>,
pub allowed_prompt_names: Vec<String>,
pub allowed_tool_names: Vec<String>,
}
Expand Down
16 changes: 16 additions & 0 deletions crates/contextforge-data-plane-apis/tests/user_store.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
use contextforge_data_plane_apis::user_store::BackendMCPGateway;

#[test]
fn backend_config_without_resource_uri_allowlist_defaults_to_empty() {
let backend: BackendMCPGateway = serde_json::from_value(serde_json::json!({
"name": "backend",
"url": "https://backend.example/mcp",
"passthrough_headers": [],
"allowed_resource_names": [],
"allowed_prompt_names": [],
"allowed_tool_names": []
}))
.expect("legacy backend config should deserialize");

assert!(backend.allowed_resource_uris.is_empty());
}
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ mod tests {
allowed_tool_names: vec![],
tool_name_aliases: HashMap::new(),
allowed_resource_names: vec![],
allowed_resource_uris: vec![],
allowed_prompt_names: vec![],
resource_name_aliases: HashMap::new(),
prompt_name_aliases: HashMap::new(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ pub(super) async fn get_prompt(
message: "Routing problem... backend not found".into(),
data: None,
})?;
if !backend.allowed_prompt_names.iter().any(|allowed| allowed == &prompt_name) {
return Err(ErrorData {
code: ErrorCode::INVALID_PARAMS,
message: "Routing problem... prompt not found".into(),
data: None,
});
}
let service_name = backend_name.clone();
let pre_result = if let Some(plugin_runtime) = &mcp_service.plugin_runtime {
plugin_runtime.before_get_prompt(&request, &prompt_name, &service_name).await?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ pub(super) async fn read_resource(
message: "Routing problem... backend not found".into(),
data: None,
})?;
if !backend.allowed_resource_uris.iter().any(|allowed| allowed == &resource_uri) {
return Err(ErrorData {
code: ErrorCode::INVALID_PARAMS,
message: "Routing problem... resource not found".into(),
data: None,
});
}

let service_name = backend_name.clone();
let mut backend_service = connect_backend_for_request(mcp_service, &backend_name, backend, &cx).await?;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ pub(super) async fn call_tool(
message: "Routing problem... backend not found".into(),
data: None,
})?;
if !backend.allowed_tool_names.iter().any(|allowed| allowed == &tool_name) {
return Err(ErrorData {
code: ErrorCode::INVALID_PARAMS,
message: "Routing problem... tool not found".into(),
data: None,
});
}
let service_name = backend_name.clone();
let pre_result = if let Some(plugin_runtime) = &mcp_service.plugin_runtime {
plugin_runtime.before_tool_call(&request, &tool_name, &service_name).await?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ fn paginating_backend(port: u16) -> BackendMCPGateway {
allowed_tool_names: Vec::new(),
tool_name_aliases: HashMap::new(),
allowed_resource_names: Vec::new(),
allowed_resource_uris: Vec::new(),
allowed_prompt_names: Vec::new(),
resource_name_aliases: HashMap::new(),
prompt_name_aliases: HashMap::new(),
Expand Down
29 changes: 28 additions & 1 deletion crates/contextforge-data-plane-lib/tests/gateway_plugins.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use rmcp::{
model::{
CallToolRequestParams, CallToolResult, ClientCapabilities, ClientRequest, ContentBlock, ErrorCode,
GetPromptRequestParams, GetPromptResult, Implementation, InitializeRequestParams, ProgressNotificationParam,
Request, ResourceContents, Role as McpRole, ServerResult,
ReadResourceRequestParams, Request, ResourceContents, Role as McpRole, ServerResult,
},
service::{NotificationContext, PeerRequestOptions, RequestHandle, RoleClient, RunningService},
};
Expand Down Expand Up @@ -403,6 +403,33 @@ async fn stateless_tool_error_round_trips() {
assert_eq!(ErrorCode::METHOD_NOT_FOUND, error.code);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn disallowed_objects_are_rejected_before_backend_connection() {
let gateway = start_gateway(TEST_USER_ID, false, Arc::new(CpexRuntimeRegistry::default())).await;
let service = support::connect_modern_client(
gateway.gateway_url(),
support::create_client(TEST_USER_ID),
support::modern_client_info(),
)
.await;

for error in [
service.call_tool(CallToolRequestParams::new("unapproved_tool")).await.unwrap_err(),
service.get_prompt(GetPromptRequestParams::new("unapproved_prompt")).await.unwrap_err(),
service.read_resource(ReadResourceRequestParams::new("file:///unapproved")).await.unwrap_err(),
] {
let rmcp::service::ServiceError::McpError(error) = error else {
panic!("expected MCP allowlist error, got {error:?}");
};
assert_eq!(ErrorCode::INVALID_PARAMS, error.code);
}
assert_eq!(
0,
*gateway.backend_state.connections.lock().expect("backend connections lock poisoned"),
"allowlist rejection must happen before backend connection"
);
}

#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn stateless_alias_and_namespaced_tool_names_route() {
let gateway_port = support::create_ports(1)[0];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,13 +230,14 @@ fn create_backends(ports: &[u16], with_tls: bool) -> HashMap<String, BackendMCPG
passthrough_headers: Vec::new(),
add_headers: HashMap::default(),
remove_headers: Vec::new(),
allowed_tool_names: Vec::new(),
allowed_tool_names: MOCK_COUNTER_TOOL_NAMES.iter().map(|name| (*name).to_owned()).collect(),
tool_name_aliases: MOCK_COUNTER_TOOL_NAMES
.iter()
.map(|tool_name| (format!("backend-{port}.{tool_name}"), (*tool_name).to_owned()))
.collect(),
allowed_resource_names: Vec::new(),
allowed_prompt_names: Vec::new(),
allowed_resource_uris: MOCK_COUNTER_RESOURCE_URIS.iter().map(|uri| (*uri).to_owned()).collect(),
allowed_prompt_names: MOCK_COUNTER_PROMPT_NAMES.iter().map(|name| (*name).to_owned()).collect(),
resource_name_aliases: HashMap::new(),
prompt_name_aliases: HashMap::new(),
completion: HashMap::new(),
Expand Down
24 changes: 21 additions & 3 deletions crates/contextforge-data-plane-lib/tests/support/plugin_gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ pub(crate) struct BackendObservation {

#[derive(Clone, Default)]
pub(crate) struct BackendState {
pub(crate) connections: Arc<StdMutex<usize>>,
pub(crate) calls: Arc<StdMutex<Vec<BackendObservation>>>,
pub(crate) prompts: Arc<StdMutex<Vec<BackendObservation>>>,
pub(crate) cancellations: Arc<StdMutex<Vec<String>>>,
Expand Down Expand Up @@ -312,7 +313,10 @@ async fn start_gateway_with_state(
let backend_service = StreamableHttpService::new(
{
let backend_state = backend_state.clone();
move || Ok(TestBackend { state: backend_state.clone() })
move || {
*backend_state.connections.lock().expect("backend connections lock poisoned") += 1;
Ok(TestBackend { state: backend_state.clone() })
}
},
LocalSessionManager::default().into(),
StreamableHttpServerConfig::default().with_json_response(json_backend_responses),
Expand All @@ -335,10 +339,24 @@ async fn start_gateway_with_state(
passthrough_headers: Vec::new(),
add_headers: HashMap::default(),
remove_headers: Vec::new(),
allowed_tool_names: Vec::new(),
allowed_tool_names: [
"sum",
"progress_sum",
"progress_counter_tokens",
"reflect_text",
"wait_for_cancellation",
"missing_tool",
]
.into_iter()
.map(str::to_owned)
.collect(),
tool_name_aliases: HashMap::new(),
allowed_resource_names: Vec::new(),
allowed_prompt_names: Vec::new(),
allowed_resource_uris: Vec::new(),
allowed_prompt_names: ["review", "review_bundle"]
.into_iter()
.map(str::to_owned)
.collect(),
resource_name_aliases: HashMap::new(),
prompt_name_aliases: HashMap::new(),
completion: HashMap::new(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -379,9 +379,10 @@ async fn write_redis_config(redis_port: u16, backend: &RunningBackend) {
passthrough_headers: Vec::new(),
add_headers: HashMap::new(),
remove_headers: Vec::new(),
allowed_tool_names: Vec::new(),
allowed_tool_names: ["sum", "reflect_text"].into_iter().map(str::to_owned).collect(),
tool_name_aliases: HashMap::new(),
allowed_resource_names: Vec::new(),
allowed_resource_uris: Vec::new(),
allowed_prompt_names: Vec::new(),
resource_name_aliases: HashMap::new(),
prompt_name_aliases: HashMap::new(),
Expand Down
9 changes: 8 additions & 1 deletion schemas/user_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,13 @@
"type": "string"
}
},
"allowed_resource_uris": {
"type": "array",
"items": {
"type": "string"
},
"default": []
},
"allowed_prompt_names": {
"type": "array",
"items": {
Expand All @@ -118,4 +125,4 @@
]
}
}
}
}
Loading