Skip to content
Open
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Activation requires all three pieces:
- Runtime flag: `--runtime-plugins-enabled true`
- Redis config key: `ContextForgeGatewayRuntimePluginConfig`

The plugin kind is `validator/secrets-detection`. The dataplane wires CMF hooks
The plugin kind is `cpex_secrets_detection.SecretsDetectionPlugin`. The dataplane wires CMF hooks
for tool calls, prompt fetches, and resource reads.

Example run command:
Expand Down
102 changes: 85 additions & 17 deletions _context/wiki/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ the dataplane after removing a key if that cache must be cleared immediately.
```text
UserConfig
virtual_hosts: HashMap<String, VirtualHost>
user_email: String | null ← optional control-plane user name

VirtualHost
backends: HashMap<String, BackendMCPGateway> ← backend key, not a parsed prefix
Expand All @@ -160,6 +161,13 @@ BackendMCPGateway
remove_headers: Vec<String> ← defaults to []
completion: HashMap<String, String> ← defaults to {}; completion is not implemented
tool_schemas: HashMap<String, JsonObject> ← defaults to {}; upstream tool name → schema
tool_policy_contexts: HashMap<String, ToolPolicyContext> ← upstream tool name → identity/policy

ToolPolicyContext
id: String ← canonical tool ID
name: String ← canonical gateway tool name
team_id: String | null ← owning team
context_id: String ← key in plugin document contexts
```

The virtual-host object maps default to empty. A backend must be referenced by
Expand Down Expand Up @@ -219,16 +227,69 @@ cargo run -p contextforge-data-plane-apis

```text
RuntimePluginConfigDocument
version: 1
cpex: CpexConfig
enabled: bool
global: CpexConfig | null
contexts: HashMap<String, CpexConfig>
settings: runtime execution settings
```

Supported: tool, prompt, and resource pre/post CMF hooks.
Rejected: routing-based selection, routes, plugin directories, global policies/defaults,
`plugin_settings.fail_on_plugin_error`, plugin conditions, and unsupported hooks
(including LLM hooks).
Config validation and `CmfPluginFactory` registration must agree on that list: a hook accepted by validation but not registered leaves the plugin loaded and silently inert.
Reload watcher: 10-minute interval. Invalid reload → runtime marked failed.
The unversioned document is published by the control plane in MessagePack;
JSON is also accepted. Python `plugins: null` means an empty plugin list.
Native hook names such as `tool_pre_invoke` are mapped to CPEX CMF handlers.
The secrets-detection factory uses the publisher's
`cpex_secrets_detection.SecretsDetectionPlugin` kind.

Tools select the resolved configuration using
`backend.tool_policy_contexts[upstream_name].context_id`. The entry also carries
the tool's canonical `id`, gateway `name`, and owning `team_id`. Aliases select
the same upstream tool context. With plugins enabled, a missing tool identity or
policy context fails the call before backend I/O; it never falls back to global
policy. Prompts and resources use `global`, matching the built-in
resolver, which only applies database bindings to team/tool context keys.
All targets use the same policy resolver and execution engine. Only `enabled: false` explicitly
bypasses the published policies.

The existing watcher checks Redis every 30 seconds, matching the built-in
manager's default cache lifetime. Publication adds its own interval. A reload
builds all policies before swapping the active set; invalid, expired or missing
configuration fails new requests. Each MCP operation pins its selected policy, so a reload cannot change it
halfway through. Operations retain their selected
runtime, matched pre/post hooks, CPEX local/shared state, and permitted extension
updates, including post-only hooks and tool progress/logging events.

Plugin extensions expose the server-generated request ID, active trace/span,
canonical target, tool schema/identity, gateway and virtual-server IDs, HTTP
request data, and verified subject claims. `UserConfig.user_email` supplies the
control-plane user name when present; otherwise the verified principal is used.
Target policy scope is stored in `meta.scope`, separately from subject claims.
CPEX's condition matcher uses that scope, canonical target, authenticated user,
server and request content type. Client arguments and plugin state cannot select
a policy or establish identity.

Within each execution mode, hooks run in ascending numeric priority (1 before
90). Equal priorities retain configuration order. This order is compiled into
each policy snapshot and applies to all MCP pre/post hooks.

CPEX filters each plugin's view by its declared capabilities. The gateway guards
every extension write-back before the next plugin executes: identity remains
host-owned, hidden fields remain intact, labels are append-only with
`append_labels`, and HTTP writes require `write_headers`. Authentication headers
remain unchanged unless the published setting permits them. Permitted custom
state and extension updates survive pre/post execution; upstream actions remain
with the separate actions integration. Published hook payload policies control
whether the existing gateway projection accepts argument, result, URI or content
edits. `settings.plugin_timeout` sets the maximum duration of each plugin hook invocation
in seconds (default: 30). For example, `"settings": {"plugin_timeout": 5}` allows
five seconds per plugin in both pre and post hooks. CPEX enforces this with
`tokio::time::timeout`; expiry drops the handler future and follows the plugin's
`on_error` policy. This does not bound backend work or the total time for a chain
of plugins. Like other asynchronous timeouts, handlers must yield to be cancelled.

Supported gateway hooks are tool, prompt and resource pre/post hooks. Unsupported
hooks, route-based CPEX configuration, plugin directories and missing factories
fail configuration loading. Redis policy cannot load Python modules or add new
Rust factories. Authentication-resolution/permission hooks and the broader
execution-pool and plugin-load-error parity work remain outside this adapter.

Compile bundled factories with `--features plugins` and enable execution with
`--runtime-plugins-enabled true`. A valid document must exist before startup;
Expand Down Expand Up @@ -281,19 +342,26 @@ configuration; run it before starting the ContextForge external dataplane:
```bash
docker compose -f docker/docker-compose-local.yaml exec -T redis \
redis-cli SET ContextForgeGatewayRuntimePluginConfig '{
"version": 1,
"cpex": {
"plugins": [
{
"name": "payload-marker",
"kind": "contextforge/payload-marker",
"hooks": ["cmf.tool_post_invoke"]
}
]
"enabled": true,
"global": {"plugins": []},
"contexts": {
"quickstart-counter": {
"plugins": [
{
"name": "payload-marker",
"kind": "contextforge/payload-marker",
"hooks": ["cmf.tool_post_invoke"]
}
]
}
}
}'
```

This example uses the `quickstart-counter` policy key from the quick-start
routes. For other tools, publish a matching `contexts[context_id]` entry;
`global` alone applies to prompt and resource hooks.

For local testing only, build and run with demo factories, `with_tools` helpers,
and runtime execution enabled:

Expand Down
4 changes: 2 additions & 2 deletions _context/wiki/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ configuration-cache freshness and dependency availability.
- A missing entry or Redis GET error currently produces HTTP `400`; undecodable
configuration produces `500`. See [Failure Modes](failure-modes.md).
- Enabled CPEX also needs its initial plugin document and checks for reloads
every ten minutes. An invalid reload fails new plugin calls closed.
every 30 seconds. An invalid reload fails new plugin calls closed.

## Builds and Images

Expand Down Expand Up @@ -113,7 +113,7 @@ publisher interval + user-config cache expiry + publication/read latency
The Rust cache defaults to 60 seconds; check the deployed publisher's actual
interval. For functional tests, shorten publication and use cache expiry `0`.
For benchmarks, report both values and keep them consistent between runs.
CPEX reloads use a separate ten-minute interval.
CPEX reloads use a separate 30-second interval.

## Security Posture

Expand Down
5 changes: 3 additions & 2 deletions _context/wiki/failure-modes.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ modern routing. A configured backend alone does not make its objects callable.

| Failure | Behavior |
| --- | --- |
| Pre-hook denies | MCP error; no upstream call. |
| Post-hook denies | MCP error; backend operation may already have completed. |
| MCP pre-hook denies | MCP error; no upstream call. |
| MCP post-hook denies | MCP error; backend operation may already have completed. |
| Tool identity or resolved policy context missing | MCP error before backend I/O; no fallback to global policy. |
| Plugin supplies an error code | That code is used; a denial without one defaults to invalid request `-32600`. |
| Soft plugin error | Logged; execution can continue under the runtime's soft-error behavior. |
| Missing or invalid initial plugin config | Runtime initialization fails; gateway startup does not complete. |
Expand Down
38 changes: 28 additions & 10 deletions _context/wiki/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,20 +40,27 @@ secrets detection before and after tool calls, blocking detected secrets:
```bash
docker compose -f docker/docker-compose-local.yaml exec -T redis \
redis-cli SET ContextForgeGatewayRuntimePluginConfig '{
"version": 1,
"cpex": {
"plugins": [{
"name": "secrets-detection",
"kind": "validator/secrets-detection",
"hooks": ["cmf.tool_pre_invoke", "cmf.tool_post_invoke"],
"config": {"block_on_detection": true}
}]
"enabled": true,
"global": {"plugins": []},
"contexts": {
"quickstart-counter": {
"plugins": [{
"name": "secrets-detection",
"kind": "cpex_secrets_detection.SecretsDetectionPlugin",
"hooks": ["cmf.tool_pre_invoke", "cmf.tool_post_invoke"],
"config": {"block_on_detection": true}
}]
}
}
}' NX
```

`NX` preserves an existing plugin document. `OK` means the example was inserted;
an empty reply means an existing document remains in use. Its plugin kinds must
an empty reply means an existing document remains in use. Existing documents must
use the unversioned `enabled`/`global`/`contexts` format and include the tool policy
selected by the routes below; the former `version`/`cpex` format is no longer
accepted. The `quickstart-counter` key is a local example, paired with the
backend's `tool_policy_contexts.get_value.context_id` in step 5. Plugin kinds must
be compiled into the binary. See [Plugin Config](config.md#plugin-config-redis-key-contextforgegatewayruntimepluginconfig)
for configuration and the optional [demo plugins](config.md#demo-plugin-workflow).

Expand Down Expand Up @@ -127,7 +134,15 @@ curl --fail --silent --show-error --request POST \
"name": "gateway-one",
"url": "http://127.0.0.1:5555/mcp",
"mcp_protocol_version": "2026-07-28",
"passthrough_headers": []
"passthrough_headers": [],
"tool_policy_contexts": {
"get_value": {
"id": "counter-get-value",
"name": "counter-get_value",
"team_id": "team_awesome",
"context_id": "quickstart-counter"
}
}
}
},
"tools": {
Expand All @@ -144,6 +159,8 @@ curl --fail --silent --show-error --request POST \
Expect `Added` (HTTP `202`). The `USER_ID` must match the token's `sub`.
The backend protocol version and explicit tool route are required for the tool
call below. The public tool name maps to the backend's original `get_value`.
Its policy context selects the secrets-detection configuration seeded in step 2;
tool calls do not fall back to `global` when that context is missing.

### 6. Discover the server and call the counter

Expand Down Expand Up @@ -219,6 +236,7 @@ tool name directly instead of expecting `tools/list` here.
| `404 {"detail":"Server not found"}` | The URL's virtual-host ID must exist in that user's config. |
| `400` mentioning request metadata | Include the matching MCP protocol header, method/name headers, and per-request `_meta`. |
| MCP error for an unpublished tool | Add the tool's explicit route to the virtual host before calling it. |
| `Runtime plugin tool context is missing` / `Runtime plugin policy context is missing` | Publish the backend's tool identity and a matching entry in the plugin document's `contexts`; `global` alone does not configure tool policies. |
| Backend unavailable | Check `gateway-one` logs, port `5555`, and `--upstream-connection-mode plain-text-or-tls`. |

For JWT diagnostics, restart with `RUST_LOG=debug` and look for
Expand Down
4 changes: 3 additions & 1 deletion _context/wiki/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ validation.
tool/prompt arguments or the resource URI. Resource URI edits must resolve through
the caller's published routes. Post-hooks can rewrite or reject the response.

The handle selects a runtime before backend I/O. It returns typed request state
Tools select their published tool/team policy before backend I/O; prompts and
resources select the global policy. The host supplies verified identity and
canonical route metadata to each hook. It returns typed request state
whose `after_*` method runs the post-hook on that same runtime, even after a reload
or a reload failure. A request that started without post-hooks never gains one
mid-flight. Tool state is shared under a mutex so progress notifications and the
Expand Down
67 changes: 62 additions & 5 deletions crates/contextforge-data-plane-apis/src/runtime_plugin_config.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,68 @@
use std::collections::{HashMap, HashSet};

use cpex::cpex_core::config::CpexConfig;
use serde::{Deserialize, Serialize};
use serde::{Deserialize, Deserializer, Serialize};

pub const RUNTIME_PLUGIN_CONFIG_KEY: &str = "ContextForgeGatewayRuntimePluginConfig";
pub const RUNTIME_PLUGIN_CONFIG_VERSION: u8 = 1;

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct RuntimePluginConfigDocument {
pub version: u8,
pub cpex: CpexConfig,
pub enabled: bool,
#[serde(deserialize_with = "optional_config")]
pub global: Option<CpexConfig>,
#[serde(deserialize_with = "context_configs")]
pub contexts: HashMap<String, CpexConfig>,
#[serde(default)]
pub settings: RuntimePluginSettings,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(default)]
pub struct RuntimePluginSettings {
/// Maximum seconds per plugin hook invocation; defaults to 30.
pub plugin_timeout: u64,
pub fail_on_plugin_error: bool,
pub execution_pool: usize,
pub default_hook_policy: String,
pub hook_policies: HashMap<String, HookPayloadPolicy>,
pub plugins_can_override_rbac: bool,
pub plugins_can_override_auth_headers: bool,
}

impl Default for RuntimePluginSettings {
fn default() -> Self {
Self {
plugin_timeout: 30,
fail_on_plugin_error: false,
execution_pool: 10,
default_hook_policy: "allow".to_owned(),
hook_policies: HashMap::new(),
plugins_can_override_rbac: false,
plugins_can_override_auth_headers: false,
}
}
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HookPayloadPolicy {
pub writable_fields: HashSet<String>,
}

// The publisher serializes Python Config.plugins=None as JSON/MessagePack null.
// CPEX Rust represents the same empty configuration with an empty vector.
fn published_config<E: serde::de::Error>(mut value: serde_json::Value) -> Result<CpexConfig, E> {
if value.get("plugins").is_some_and(serde_json::Value::is_null) {
value["plugins"] = serde_json::json!([]);
}
serde_json::from_value(value).map_err(E::custom)
}

fn optional_config<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<CpexConfig>, D::Error> {
Option::<serde_json::Value>::deserialize(deserializer)?.map(published_config).transpose()
}

fn context_configs<'de, D: Deserializer<'de>>(deserializer: D) -> Result<HashMap<String, CpexConfig>, D::Error> {
HashMap::<String, serde_json::Value>::deserialize(deserializer)?
.into_iter()
.map(|(key, value)| published_config(value).map(|config| (key, config)))
.collect()
}
13 changes: 13 additions & 0 deletions crates/contextforge-data-plane-apis/src/user_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ pub struct BackendMCPGateway {
/// Input schemas keyed by the original upstream tool name.
#[serde(default)]
pub tool_schemas: HashMap<String, serde_json::Map<String, serde_json::Value>>,
/// Canonical tool identity and resolved policy key, indexed by upstream name.
#[serde(default)]
pub tool_policy_contexts: HashMap<String, ToolPolicyContext>,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct ToolPolicyContext {
pub id: String,
pub name: String,
pub team_id: Option<String>,
pub context_id: String,
}

#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
Expand All @@ -62,4 +73,6 @@ pub struct VirtualHost {
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)]
pub struct UserConfig {
pub virtual_hosts: HashMap<VirtualHostId, VirtualHost>,
#[serde(default)]
pub user_email: Option<String>,
}
Loading