Skip to content
Merged
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
18 changes: 12 additions & 6 deletions crates/aisix-core/src/models/guardrail.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -962,20 +962,26 @@ impl Resource for Guardrail {
/// Which dimension of the request a guardrail attachment is scoped to.
///
/// `Env` applies to every request in the environment. The narrower scopes let
/// operators attach a guardrail to only the models, API keys, or teams that
/// need it.
/// operators attach a guardrail to only the models, MCP servers, API keys, or
/// teams that need it.
///
/// `Model` and `McpServer` select dimensions a request carries only one of: an
/// MCP tool call resolves no model, and an LLM request routes to no MCP server.
/// A `Model`-scoped guardrail therefore never inspects MCP traffic, and an
/// `McpServer`-scoped one never inspects model traffic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GuardrailScopeType {
Env,
Model,
McpServer,
ApiKey,
Team,
}

/// Guardrail attachment that scopes one guardrail to an environment, model,
/// caller API key, or team. AISIX loads attachments with the guardrail
/// definitions and uses `scope_type` plus `scope_id` to decide which
/// MCP server, caller API key, or team. AISIX loads attachments with the
/// guardrail definitions and uses `scope_type` plus `scope_id` to decide which
/// guardrails apply to each request.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct GuardrailAttachment {
Expand All@@ -986,8 +992,8 @@ pub struct GuardrailAttachment {
/// What dimension of the request this attachment is scoped to.
pub scope_type: GuardrailScopeType,

/// The UUID of the specific resource (model / api_key / team).
/// `None` when `scope_type` is `Env` (applies to all requests).
/// The UUID of the specific resource (model / mcp_server / api_key /
/// team). `None` when `scope_type` is `Env` (applies to all requests).
pub scope_id: Option<String>,

/// Higher number = higher precedence. When the same guardrail appears
Expand Down
2 changes: 1 addition & 1 deletion crates/aisix-core/src/models/snapshot.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,7 @@ pub struct AisixSnapshot {
pub guardrails: ResourceTable<Guardrail>,
/// Attachment rows: `/aisix/<env>/guardrail_attachments/<uuid>`.
/// Each row binds a guardrail definition to a scope (env / model /
/// api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// mcp_server / api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// both this table and `guardrails` to build the per-request resolver.
pub guardrail_attachments: ResourceTable<GuardrailAttachment>,
/// Per-env cache policies. Stage 2 honors only the existence of an
Expand Down
64 changes: 64 additions & 0 deletions crates/aisix-guardrails/src/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -995,6 +995,7 @@ pub fn build_index_from_snapshot(
let scope_kind = match attachment.scope_type {
GuardrailScopeType::Env => ScopeKind::Env,
GuardrailScopeType::Model => ScopeKind::Model,
GuardrailScopeType::McpServer => ScopeKind::McpServer,
GuardrailScopeType::ApiKey => ScopeKind::ApiKey,
GuardrailScopeType::Team => ScopeKind::Team,
};
Expand DownExpand Up@@ -1887,6 +1888,7 @@ mod tests {
let index = build_index_from_snapshot(&shuffled_table(), &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -1937,6 +1939,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -1977,6 +1980,7 @@ mod tests {
// Verify the guardrail does not fire (not just that the index is empty).
let ctx = RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
};
Expand DownExpand Up@@ -2025,6 +2029,7 @@ mod tests {
);
let ctx = RequestContext {
model_id: "any",
mcp_server_id: "",
api_key_id: "any",
team_id: None,
};
Expand DownExpand Up@@ -2066,6 +2071,7 @@ mod tests {

let ctx = RequestContext {
model_id: "any-model",
mcp_server_id: "",
api_key_id: "any-key",
team_id: None,
};
Expand DownExpand Up@@ -2140,6 +2146,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2193,6 +2200,7 @@ mod tests {
let live = LiveGuardrailIndex::new(SnapshotHandle::new(AisixSnapshot::new()), None);
let chain = live.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -2259,6 +2267,7 @@ mod tests {
LiveGuardrailIndex::new_with_sink(SnapshotHandle::new(snap), None, Some(sink.clone()));
let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2481,6 +2490,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand All@@ -2495,6 +2505,59 @@ mod tests {
);
}

#[tokio::test]
async fn mcp_server_attachment_builds_into_the_index() {
// The wire `scope_type: "mcp_server"` survives the snapshot build and
// selects on the called server, leaving model traffic alone.
let guardrails: ResourceTable<DomainGuardrail> = ResourceTable::default();
guardrails.insert(entry(
"kw",
"g-1",
parse(
r#"{
"name": "kw",
"kind": "keyword",
"hook_point": "input",
"patterns": [{ "kind": "literal", "value": "AKIA" }]
}"#,
),
));
let attachments: ResourceTable<GuardrailAttachment> = ResourceTable::default();
attachments.insert(attachment_entry(
"a-mcp",
parse_attachment(
r#"{ "guardrail_id": "g-1", "scope_type": "mcp_server", "scope_id": "mcp-A", "priority": 50 }"#,
),
));

let index = build_index_from_snapshot(&guardrails, &attachments, None);
assert_eq!(index.len(), 1, "the attachment must not be skipped");

let matched = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k",
team_id: None,
});
assert_eq!(matched.len(), 1);

let other_server = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-B",
api_key_id: "k",
team_id: None,
});
assert!(other_server.is_empty());

let llm = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
assert!(llm.is_empty(), "model traffic carries no MCP server");
}

#[tokio::test]
async fn resolved_chain_applied_empty_when_no_attachment_matches() {
// A model-scoped attachment that doesn't match the request resolves to
Expand DownExpand Up@@ -2524,6 +2587,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-OTHER",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand Down
103 changes: 96 additions & 7 deletions crates/aisix-guardrails/src/index.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,10 +8,11 @@
//!
//! | `scope_type` | meaning |
//! |---|---|
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `mcp_server` | applies only to MCP tool calls routed to this server UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//!
//! `GuardrailIndex` holds the pre-built runtime guardrails for a snapshot.
//! `resolve(ctx)` filters + deduplicates the entries and returns the chain
Expand All@@ -37,6 +38,7 @@ use crate::{Guardrail, GuardrailChain};
pub enum ScopeKind {
Env,
Model,
McpServer,
ApiKey,
Team,
}
Expand DownExpand Up@@ -79,7 +81,13 @@ impl IndexEntry {
fn applies_to(&self, ctx: &RequestContext<'_>) -> bool {
match self.scope_kind {
ScopeKind::Env => true,
ScopeKind::Model => self.scope_id.as_deref() == Some(ctx.model_id),
// `model_id` / `mcp_server_id` are empty on the requests that have
// no such identity (an MCP tool call resolves no model; an LLM
// request routes to no MCP server), so an attachment whose
// `scope_id` is itself empty must NOT match everything that lacks
// the dimension — compare only non-empty ids.
ScopeKind::Model => matches_id(self.scope_id.as_deref(), ctx.model_id),
ScopeKind::McpServer => matches_id(self.scope_id.as_deref(), ctx.mcp_server_id),
ScopeKind::ApiKey => self.scope_id.as_deref() == Some(ctx.api_key_id),
ScopeKind::Team => ctx
.team_id
Expand All@@ -90,12 +98,22 @@ impl IndexEntry {
}
}

/// Equality for a scope dimension a request may not carry at all: an empty
/// id on either side never matches.
fn matches_id(scope_id: Option<&str>, ctx_id: &str) -> bool {
!ctx_id.is_empty() && scope_id == Some(ctx_id)
}

/// Per-request context used by [`GuardrailIndex::resolve`] to select and
/// deduplicate the applicable guardrails.
#[derive(Debug, Clone, Copy)]
pub struct RequestContext<'a> {
/// UUID of the model the request targets (virtual or concrete).
/// Empty for a request that resolves no model, such as an MCP tool call.
pub model_id: &'a str,
/// UUID of the registered MCP server an MCP tool call is routed to.
/// Empty for every request that is not an MCP tool call.
pub mcp_server_id: &'a str,
/// UUID of the API key used to authenticate the request.
pub api_key_id: &'a str,
/// UUID of the team the API key belongs to. `None` if the key is not
Expand All@@ -115,12 +133,15 @@ pub struct GuardrailIndex {
}

/// Scope specificity rank: higher = more specific → wins dedup on equal priority.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379. `McpServer`
/// shares `Model`'s rank: the two dimensions are mutually exclusive within a
/// request (an MCP tool call resolves no model and an LLM request routes to no
/// MCP server), so their relative order can never decide a deduplication.
fn scope_specificity(k: &ScopeKind) -> u8 {
match k {
ScopeKind::ApiKey => 3,
ScopeKind::Team => 2,
ScopeKind::Model => 1,
ScopeKind::Model | ScopeKind::McpServer => 1,
ScopeKind::Env => 0,
}
}
Expand DownExpand Up@@ -231,6 +252,7 @@ mod tests {
fn ctx<'a>(model: &'a str, apikey: &'a str, team: Option<&'a str>) -> RequestContext<'a> {
RequestContext {
model_id: model,
mcp_server_id: "",
api_key_id: apikey,
team_id: team,
}
Expand DownExpand Up@@ -309,6 +331,72 @@ mod tests {
);
}

// 3b. McpServer-scope attachment applies only to tool calls routed to
// that server — and never to model traffic, which carries no server id.
#[tokio::test]
async fn mcp_server_scope_only_matching_server() {
let g = kw("g1", "secret");
let idx = GuardrailIndex::from_entries(vec![entry(
"g1",
ScopeKind::McpServer,
Some("mcp-A"),
50,
g,
)]);

let mcp_ctx = |server: &'static str| RequestContext {
model_id: "",
mcp_server_id: server,
api_key_id: "k1",
team_id: None,
};

let chain_a = idx.resolve(&mcp_ctx("mcp-A"));
assert!(chain_a.check_input(&req("secret")).await.is_block());

let chain_b = idx.resolve(&mcp_ctx("mcp-B"));
assert_eq!(
chain_b.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

// An LLM request carries no MCP server, so the attachment is inert.
let chain_llm = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
chain_llm.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 3c. A dimension a request does not carry never matches, even against an
// attachment whose own scope_id is empty — the sentinel for "absent" must
// not read as a wildcard.
#[tokio::test]
async fn empty_scope_id_never_matches_an_absent_dimension() {
let idx = GuardrailIndex::from_entries(vec![
entry("g1", ScopeKind::McpServer, Some(""), 50, kw("g1", "secret")),
entry("g2", ScopeKind::Model, Some(""), 50, kw("g2", "secret")),
]);

// An MCP tool call has no model; an LLM request has no MCP server.
let mcp_chain = idx.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k1",
team_id: None,
});
assert_eq!(
mcp_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

let llm_chain = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
llm_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 4. ApiKey-scope attachment applies only to the matching key.
#[tokio::test]
async fn api_key_scope_only_matching_key() {
Expand DownExpand Up@@ -569,6 +657,7 @@ mod tests {
let team = format!("scope-{}", (i + 7) % 10);
let _ = idx.resolve(&RequestContext {
model_id: &model,
mcp_server_id: "",
api_key_id: &key,
team_id: Some(&team),
});
Expand Down
2 changes: 2 additions & 0 deletions crates/aisix-proxy/src/audio.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -577,6 +577,7 @@ async fn multipart_dispatch(
// The transcript RESPONSE is scanned/masked after the upstream call.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand DownExpand Up@@ -1082,6 +1083,7 @@ async fn speech_dispatch(
// RPM slot. (Output is binary audio, not scannable text — no output hook.)
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/chat.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1232,6 +1232,7 @@ async fn dispatch(
// guardrail context.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/completions.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,7 @@ async fn dispatch(
// (matching /v1/chat/completions).
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(mcp): scan structured tool output, block as a tool error, scope guardrails per server by jarvis9443 · Pull Request #979 · api7/aisix · GitHub
Skip to content
Merged
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
18 changes: 12 additions & 6 deletions crates/aisix-core/src/models/guardrail.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -962,20 +962,26 @@ impl Resource for Guardrail {
/// Which dimension of the request a guardrail attachment is scoped to.
///
/// `Env` applies to every request in the environment. The narrower scopes let
/// operators attach a guardrail to only the models, API keys, or teams that
/// need it.
/// operators attach a guardrail to only the models, MCP servers, API keys, or
/// teams that need it.
///
/// `Model` and `McpServer` select dimensions a request carries only one of: an
/// MCP tool call resolves no model, and an LLM request routes to no MCP server.
/// A `Model`-scoped guardrail therefore never inspects MCP traffic, and an
/// `McpServer`-scoped one never inspects model traffic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GuardrailScopeType {
Env,
Model,
McpServer,
ApiKey,
Team,
}

/// Guardrail attachment that scopes one guardrail to an environment, model,
/// caller API key, or team. AISIX loads attachments with the guardrail
/// definitions and uses `scope_type` plus `scope_id` to decide which
/// MCP server, caller API key, or team. AISIX loads attachments with the
/// guardrail definitions and uses `scope_type` plus `scope_id` to decide which
/// guardrails apply to each request.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct GuardrailAttachment {
Expand All@@ -986,8 +992,8 @@ pub struct GuardrailAttachment {
/// What dimension of the request this attachment is scoped to.
pub scope_type: GuardrailScopeType,

/// The UUID of the specific resource (model / api_key / team).
/// `None` when `scope_type` is `Env` (applies to all requests).
/// The UUID of the specific resource (model / mcp_server / api_key /
/// team). `None` when `scope_type` is `Env` (applies to all requests).
pub scope_id: Option<String>,

/// Higher number = higher precedence. When the same guardrail appears
Expand Down
2 changes: 1 addition & 1 deletion crates/aisix-core/src/models/snapshot.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,7 @@ pub struct AisixSnapshot {
pub guardrails: ResourceTable<Guardrail>,
/// Attachment rows: `/aisix/<env>/guardrail_attachments/<uuid>`.
/// Each row binds a guardrail definition to a scope (env / model /
/// api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// mcp_server / api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// both this table and `guardrails` to build the per-request resolver.
pub guardrail_attachments: ResourceTable<GuardrailAttachment>,
/// Per-env cache policies. Stage 2 honors only the existence of an
Expand Down
64 changes: 64 additions & 0 deletions crates/aisix-guardrails/src/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -995,6 +995,7 @@ pub fn build_index_from_snapshot(
let scope_kind = match attachment.scope_type {
GuardrailScopeType::Env => ScopeKind::Env,
GuardrailScopeType::Model => ScopeKind::Model,
GuardrailScopeType::McpServer => ScopeKind::McpServer,
GuardrailScopeType::ApiKey => ScopeKind::ApiKey,
GuardrailScopeType::Team => ScopeKind::Team,
};
Expand DownExpand Up@@ -1887,6 +1888,7 @@ mod tests {
let index = build_index_from_snapshot(&shuffled_table(), &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -1937,6 +1939,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -1977,6 +1980,7 @@ mod tests {
// Verify the guardrail does not fire (not just that the index is empty).
let ctx = RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
};
Expand DownExpand Up@@ -2025,6 +2029,7 @@ mod tests {
);
let ctx = RequestContext {
model_id: "any",
mcp_server_id: "",
api_key_id: "any",
team_id: None,
};
Expand DownExpand Up@@ -2066,6 +2071,7 @@ mod tests {

let ctx = RequestContext {
model_id: "any-model",
mcp_server_id: "",
api_key_id: "any-key",
team_id: None,
};
Expand DownExpand Up@@ -2140,6 +2146,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2193,6 +2200,7 @@ mod tests {
let live = LiveGuardrailIndex::new(SnapshotHandle::new(AisixSnapshot::new()), None);
let chain = live.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -2259,6 +2267,7 @@ mod tests {
LiveGuardrailIndex::new_with_sink(SnapshotHandle::new(snap), None, Some(sink.clone()));
let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2481,6 +2490,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand All@@ -2495,6 +2505,59 @@ mod tests {
);
}

#[tokio::test]
async fn mcp_server_attachment_builds_into_the_index() {
// The wire `scope_type: "mcp_server"` survives the snapshot build and
// selects on the called server, leaving model traffic alone.
let guardrails: ResourceTable<DomainGuardrail> = ResourceTable::default();
guardrails.insert(entry(
"kw",
"g-1",
parse(
r#"{
"name": "kw",
"kind": "keyword",
"hook_point": "input",
"patterns": [{ "kind": "literal", "value": "AKIA" }]
}"#,
),
));
let attachments: ResourceTable<GuardrailAttachment> = ResourceTable::default();
attachments.insert(attachment_entry(
"a-mcp",
parse_attachment(
r#"{ "guardrail_id": "g-1", "scope_type": "mcp_server", "scope_id": "mcp-A", "priority": 50 }"#,
),
));

let index = build_index_from_snapshot(&guardrails, &attachments, None);
assert_eq!(index.len(), 1, "the attachment must not be skipped");

let matched = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k",
team_id: None,
});
assert_eq!(matched.len(), 1);

let other_server = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-B",
api_key_id: "k",
team_id: None,
});
assert!(other_server.is_empty());

let llm = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
assert!(llm.is_empty(), "model traffic carries no MCP server");
}

#[tokio::test]
async fn resolved_chain_applied_empty_when_no_attachment_matches() {
// A model-scoped attachment that doesn't match the request resolves to
Expand DownExpand Up@@ -2524,6 +2587,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-OTHER",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand Down
103 changes: 96 additions & 7 deletions crates/aisix-guardrails/src/index.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,10 +8,11 @@
//!
//! | `scope_type` | meaning |
//! |---|---|
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `mcp_server` | applies only to MCP tool calls routed to this server UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//!
//! `GuardrailIndex` holds the pre-built runtime guardrails for a snapshot.
//! `resolve(ctx)` filters + deduplicates the entries and returns the chain
Expand All@@ -37,6 +38,7 @@ use crate::{Guardrail, GuardrailChain};
pub enum ScopeKind {
Env,
Model,
McpServer,
ApiKey,
Team,
}
Expand DownExpand Up@@ -79,7 +81,13 @@ impl IndexEntry {
fn applies_to(&self, ctx: &RequestContext<'_>) -> bool {
match self.scope_kind {
ScopeKind::Env => true,
ScopeKind::Model => self.scope_id.as_deref() == Some(ctx.model_id),
// `model_id` / `mcp_server_id` are empty on the requests that have
// no such identity (an MCP tool call resolves no model; an LLM
// request routes to no MCP server), so an attachment whose
// `scope_id` is itself empty must NOT match everything that lacks
// the dimension — compare only non-empty ids.
ScopeKind::Model => matches_id(self.scope_id.as_deref(), ctx.model_id),
ScopeKind::McpServer => matches_id(self.scope_id.as_deref(), ctx.mcp_server_id),
ScopeKind::ApiKey => self.scope_id.as_deref() == Some(ctx.api_key_id),
ScopeKind::Team => ctx
.team_id
Expand All@@ -90,12 +98,22 @@ impl IndexEntry {
}
}

/// Equality for a scope dimension a request may not carry at all: an empty
/// id on either side never matches.
fn matches_id(scope_id: Option<&str>, ctx_id: &str) -> bool {
!ctx_id.is_empty() && scope_id == Some(ctx_id)
}

/// Per-request context used by [`GuardrailIndex::resolve`] to select and
/// deduplicate the applicable guardrails.
#[derive(Debug, Clone, Copy)]
pub struct RequestContext<'a> {
/// UUID of the model the request targets (virtual or concrete).
/// Empty for a request that resolves no model, such as an MCP tool call.
pub model_id: &'a str,
/// UUID of the registered MCP server an MCP tool call is routed to.
/// Empty for every request that is not an MCP tool call.
pub mcp_server_id: &'a str,
/// UUID of the API key used to authenticate the request.
pub api_key_id: &'a str,
/// UUID of the team the API key belongs to. `None` if the key is not
Expand All@@ -115,12 +133,15 @@ pub struct GuardrailIndex {
}

/// Scope specificity rank: higher = more specific → wins dedup on equal priority.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379. `McpServer`
/// shares `Model`'s rank: the two dimensions are mutually exclusive within a
/// request (an MCP tool call resolves no model and an LLM request routes to no
/// MCP server), so their relative order can never decide a deduplication.
fn scope_specificity(k: &ScopeKind) -> u8 {
match k {
ScopeKind::ApiKey => 3,
ScopeKind::Team => 2,
ScopeKind::Model => 1,
ScopeKind::Model | ScopeKind::McpServer => 1,
ScopeKind::Env => 0,
}
}
Expand DownExpand Up@@ -231,6 +252,7 @@ mod tests {
fn ctx<'a>(model: &'a str, apikey: &'a str, team: Option<&'a str>) -> RequestContext<'a> {
RequestContext {
model_id: model,
mcp_server_id: "",
api_key_id: apikey,
team_id: team,
}
Expand DownExpand Up@@ -309,6 +331,72 @@ mod tests {
);
}

// 3b. McpServer-scope attachment applies only to tool calls routed to
// that server — and never to model traffic, which carries no server id.
#[tokio::test]
async fn mcp_server_scope_only_matching_server() {
let g = kw("g1", "secret");
let idx = GuardrailIndex::from_entries(vec![entry(
"g1",
ScopeKind::McpServer,
Some("mcp-A"),
50,
g,
)]);

let mcp_ctx = |server: &'static str| RequestContext {
model_id: "",
mcp_server_id: server,
api_key_id: "k1",
team_id: None,
};

let chain_a = idx.resolve(&mcp_ctx("mcp-A"));
assert!(chain_a.check_input(&req("secret")).await.is_block());

let chain_b = idx.resolve(&mcp_ctx("mcp-B"));
assert_eq!(
chain_b.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

// An LLM request carries no MCP server, so the attachment is inert.
let chain_llm = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
chain_llm.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 3c. A dimension a request does not carry never matches, even against an
// attachment whose own scope_id is empty — the sentinel for "absent" must
// not read as a wildcard.
#[tokio::test]
async fn empty_scope_id_never_matches_an_absent_dimension() {
let idx = GuardrailIndex::from_entries(vec![
entry("g1", ScopeKind::McpServer, Some(""), 50, kw("g1", "secret")),
entry("g2", ScopeKind::Model, Some(""), 50, kw("g2", "secret")),
]);

// An MCP tool call has no model; an LLM request has no MCP server.
let mcp_chain = idx.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k1",
team_id: None,
});
assert_eq!(
mcp_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

let llm_chain = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
llm_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 4. ApiKey-scope attachment applies only to the matching key.
#[tokio::test]
async fn api_key_scope_only_matching_key() {
Expand DownExpand Up@@ -569,6 +657,7 @@ mod tests {
let team = format!("scope-{}", (i + 7) % 10);
let _ = idx.resolve(&RequestContext {
model_id: &model,
mcp_server_id: "",
api_key_id: &key,
team_id: Some(&team),
});
Expand Down
2 changes: 2 additions & 0 deletions crates/aisix-proxy/src/audio.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -577,6 +577,7 @@ async fn multipart_dispatch(
// The transcript RESPONSE is scanned/masked after the upstream call.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand DownExpand Up@@ -1082,6 +1083,7 @@ async fn speech_dispatch(
// RPM slot. (Output is binary audio, not scannable text — no output hook.)
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/chat.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1232,6 +1232,7 @@ async fn dispatch(
// guardrail context.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/completions.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,7 @@ async fn dispatch(
// (matching /v1/chat/completions).
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(mcp): scan structured tool output, block as a tool error, scope guardrails per server by jarvis9443 · Pull Request #979 · api7/aisix · GitHub
Skip to content
Merged
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
18 changes: 12 additions & 6 deletions crates/aisix-core/src/models/guardrail.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -962,20 +962,26 @@ impl Resource for Guardrail {
/// Which dimension of the request a guardrail attachment is scoped to.
///
/// `Env` applies to every request in the environment. The narrower scopes let
/// operators attach a guardrail to only the models, API keys, or teams that
/// need it.
/// operators attach a guardrail to only the models, MCP servers, API keys, or
/// teams that need it.
///
/// `Model` and `McpServer` select dimensions a request carries only one of: an
/// MCP tool call resolves no model, and an LLM request routes to no MCP server.
/// A `Model`-scoped guardrail therefore never inspects MCP traffic, and an
/// `McpServer`-scoped one never inspects model traffic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GuardrailScopeType {
Env,
Model,
McpServer,
ApiKey,
Team,
}

/// Guardrail attachment that scopes one guardrail to an environment, model,
/// caller API key, or team. AISIX loads attachments with the guardrail
/// definitions and uses `scope_type` plus `scope_id` to decide which
/// MCP server, caller API key, or team. AISIX loads attachments with the
/// guardrail definitions and uses `scope_type` plus `scope_id` to decide which
/// guardrails apply to each request.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct GuardrailAttachment {
Expand All@@ -986,8 +992,8 @@ pub struct GuardrailAttachment {
/// What dimension of the request this attachment is scoped to.
pub scope_type: GuardrailScopeType,

/// The UUID of the specific resource (model / api_key / team).
/// `None` when `scope_type` is `Env` (applies to all requests).
/// The UUID of the specific resource (model / mcp_server / api_key /
/// team). `None` when `scope_type` is `Env` (applies to all requests).
pub scope_id: Option<String>,

/// Higher number = higher precedence. When the same guardrail appears
Expand Down
2 changes: 1 addition & 1 deletion crates/aisix-core/src/models/snapshot.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,7 @@ pub struct AisixSnapshot {
pub guardrails: ResourceTable<Guardrail>,
/// Attachment rows: `/aisix/<env>/guardrail_attachments/<uuid>`.
/// Each row binds a guardrail definition to a scope (env / model /
/// api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// mcp_server / api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// both this table and `guardrails` to build the per-request resolver.
pub guardrail_attachments: ResourceTable<GuardrailAttachment>,
/// Per-env cache policies. Stage 2 honors only the existence of an
Expand Down
64 changes: 64 additions & 0 deletions crates/aisix-guardrails/src/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -995,6 +995,7 @@ pub fn build_index_from_snapshot(
let scope_kind = match attachment.scope_type {
GuardrailScopeType::Env => ScopeKind::Env,
GuardrailScopeType::Model => ScopeKind::Model,
GuardrailScopeType::McpServer => ScopeKind::McpServer,
GuardrailScopeType::ApiKey => ScopeKind::ApiKey,
GuardrailScopeType::Team => ScopeKind::Team,
};
Expand DownExpand Up@@ -1887,6 +1888,7 @@ mod tests {
let index = build_index_from_snapshot(&shuffled_table(), &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -1937,6 +1939,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -1977,6 +1980,7 @@ mod tests {
// Verify the guardrail does not fire (not just that the index is empty).
let ctx = RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
};
Expand DownExpand Up@@ -2025,6 +2029,7 @@ mod tests {
);
let ctx = RequestContext {
model_id: "any",
mcp_server_id: "",
api_key_id: "any",
team_id: None,
};
Expand DownExpand Up@@ -2066,6 +2071,7 @@ mod tests {

let ctx = RequestContext {
model_id: "any-model",
mcp_server_id: "",
api_key_id: "any-key",
team_id: None,
};
Expand DownExpand Up@@ -2140,6 +2146,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2193,6 +2200,7 @@ mod tests {
let live = LiveGuardrailIndex::new(SnapshotHandle::new(AisixSnapshot::new()), None);
let chain = live.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -2259,6 +2267,7 @@ mod tests {
LiveGuardrailIndex::new_with_sink(SnapshotHandle::new(snap), None, Some(sink.clone()));
let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2481,6 +2490,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand All@@ -2495,6 +2505,59 @@ mod tests {
);
}

#[tokio::test]
async fn mcp_server_attachment_builds_into_the_index() {
// The wire `scope_type: "mcp_server"` survives the snapshot build and
// selects on the called server, leaving model traffic alone.
let guardrails: ResourceTable<DomainGuardrail> = ResourceTable::default();
guardrails.insert(entry(
"kw",
"g-1",
parse(
r#"{
"name": "kw",
"kind": "keyword",
"hook_point": "input",
"patterns": [{ "kind": "literal", "value": "AKIA" }]
}"#,
),
));
let attachments: ResourceTable<GuardrailAttachment> = ResourceTable::default();
attachments.insert(attachment_entry(
"a-mcp",
parse_attachment(
r#"{ "guardrail_id": "g-1", "scope_type": "mcp_server", "scope_id": "mcp-A", "priority": 50 }"#,
),
));

let index = build_index_from_snapshot(&guardrails, &attachments, None);
assert_eq!(index.len(), 1, "the attachment must not be skipped");

let matched = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k",
team_id: None,
});
assert_eq!(matched.len(), 1);

let other_server = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-B",
api_key_id: "k",
team_id: None,
});
assert!(other_server.is_empty());

let llm = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
assert!(llm.is_empty(), "model traffic carries no MCP server");
}

#[tokio::test]
async fn resolved_chain_applied_empty_when_no_attachment_matches() {
// A model-scoped attachment that doesn't match the request resolves to
Expand DownExpand Up@@ -2524,6 +2587,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-OTHER",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand Down
103 changes: 96 additions & 7 deletions crates/aisix-guardrails/src/index.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,10 +8,11 @@
//!
//! | `scope_type` | meaning |
//! |---|---|
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `mcp_server` | applies only to MCP tool calls routed to this server UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//!
//! `GuardrailIndex` holds the pre-built runtime guardrails for a snapshot.
//! `resolve(ctx)` filters + deduplicates the entries and returns the chain
Expand All@@ -37,6 +38,7 @@ use crate::{Guardrail, GuardrailChain};
pub enum ScopeKind {
Env,
Model,
McpServer,
ApiKey,
Team,
}
Expand DownExpand Up@@ -79,7 +81,13 @@ impl IndexEntry {
fn applies_to(&self, ctx: &RequestContext<'_>) -> bool {
match self.scope_kind {
ScopeKind::Env => true,
ScopeKind::Model => self.scope_id.as_deref() == Some(ctx.model_id),
// `model_id` / `mcp_server_id` are empty on the requests that have
// no such identity (an MCP tool call resolves no model; an LLM
// request routes to no MCP server), so an attachment whose
// `scope_id` is itself empty must NOT match everything that lacks
// the dimension — compare only non-empty ids.
ScopeKind::Model => matches_id(self.scope_id.as_deref(), ctx.model_id),
ScopeKind::McpServer => matches_id(self.scope_id.as_deref(), ctx.mcp_server_id),
ScopeKind::ApiKey => self.scope_id.as_deref() == Some(ctx.api_key_id),
ScopeKind::Team => ctx
.team_id
Expand All@@ -90,12 +98,22 @@ impl IndexEntry {
}
}

/// Equality for a scope dimension a request may not carry at all: an empty
/// id on either side never matches.
fn matches_id(scope_id: Option<&str>, ctx_id: &str) -> bool {
!ctx_id.is_empty() && scope_id == Some(ctx_id)
}

/// Per-request context used by [`GuardrailIndex::resolve`] to select and
/// deduplicate the applicable guardrails.
#[derive(Debug, Clone, Copy)]
pub struct RequestContext<'a> {
/// UUID of the model the request targets (virtual or concrete).
/// Empty for a request that resolves no model, such as an MCP tool call.
pub model_id: &'a str,
/// UUID of the registered MCP server an MCP tool call is routed to.
/// Empty for every request that is not an MCP tool call.
pub mcp_server_id: &'a str,
/// UUID of the API key used to authenticate the request.
pub api_key_id: &'a str,
/// UUID of the team the API key belongs to. `None` if the key is not
Expand All@@ -115,12 +133,15 @@ pub struct GuardrailIndex {
}

/// Scope specificity rank: higher = more specific → wins dedup on equal priority.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379. `McpServer`
/// shares `Model`'s rank: the two dimensions are mutually exclusive within a
/// request (an MCP tool call resolves no model and an LLM request routes to no
/// MCP server), so their relative order can never decide a deduplication.
fn scope_specificity(k: &ScopeKind) -> u8 {
match k {
ScopeKind::ApiKey => 3,
ScopeKind::Team => 2,
ScopeKind::Model => 1,
ScopeKind::Model | ScopeKind::McpServer => 1,
ScopeKind::Env => 0,
}
}
Expand DownExpand Up@@ -231,6 +252,7 @@ mod tests {
fn ctx<'a>(model: &'a str, apikey: &'a str, team: Option<&'a str>) -> RequestContext<'a> {
RequestContext {
model_id: model,
mcp_server_id: "",
api_key_id: apikey,
team_id: team,
}
Expand DownExpand Up@@ -309,6 +331,72 @@ mod tests {
);
}

// 3b. McpServer-scope attachment applies only to tool calls routed to
// that server — and never to model traffic, which carries no server id.
#[tokio::test]
async fn mcp_server_scope_only_matching_server() {
let g = kw("g1", "secret");
let idx = GuardrailIndex::from_entries(vec![entry(
"g1",
ScopeKind::McpServer,
Some("mcp-A"),
50,
g,
)]);

let mcp_ctx = |server: &'static str| RequestContext {
model_id: "",
mcp_server_id: server,
api_key_id: "k1",
team_id: None,
};

let chain_a = idx.resolve(&mcp_ctx("mcp-A"));
assert!(chain_a.check_input(&req("secret")).await.is_block());

let chain_b = idx.resolve(&mcp_ctx("mcp-B"));
assert_eq!(
chain_b.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

// An LLM request carries no MCP server, so the attachment is inert.
let chain_llm = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
chain_llm.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 3c. A dimension a request does not carry never matches, even against an
// attachment whose own scope_id is empty — the sentinel for "absent" must
// not read as a wildcard.
#[tokio::test]
async fn empty_scope_id_never_matches_an_absent_dimension() {
let idx = GuardrailIndex::from_entries(vec![
entry("g1", ScopeKind::McpServer, Some(""), 50, kw("g1", "secret")),
entry("g2", ScopeKind::Model, Some(""), 50, kw("g2", "secret")),
]);

// An MCP tool call has no model; an LLM request has no MCP server.
let mcp_chain = idx.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k1",
team_id: None,
});
assert_eq!(
mcp_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

let llm_chain = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
llm_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 4. ApiKey-scope attachment applies only to the matching key.
#[tokio::test]
async fn api_key_scope_only_matching_key() {
Expand DownExpand Up@@ -569,6 +657,7 @@ mod tests {
let team = format!("scope-{}", (i + 7) % 10);
let _ = idx.resolve(&RequestContext {
model_id: &model,
mcp_server_id: "",
api_key_id: &key,
team_id: Some(&team),
});
Expand Down
2 changes: 2 additions & 0 deletions crates/aisix-proxy/src/audio.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -577,6 +577,7 @@ async fn multipart_dispatch(
// The transcript RESPONSE is scanned/masked after the upstream call.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand DownExpand Up@@ -1082,6 +1083,7 @@ async fn speech_dispatch(
// RPM slot. (Output is binary audio, not scannable text — no output hook.)
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/chat.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1232,6 +1232,7 @@ async fn dispatch(
// guardrail context.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/completions.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,7 @@ async fn dispatch(
// (matching /v1/chat/completions).
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(mcp): scan structured tool output, block as a tool error, scope guardrails per server by jarvis9443 · Pull Request #979 · api7/aisix · GitHub
Skip to content
Merged
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
18 changes: 12 additions & 6 deletions crates/aisix-core/src/models/guardrail.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -962,20 +962,26 @@ impl Resource for Guardrail {
/// Which dimension of the request a guardrail attachment is scoped to.
///
/// `Env` applies to every request in the environment. The narrower scopes let
/// operators attach a guardrail to only the models, API keys, or teams that
/// need it.
/// operators attach a guardrail to only the models, MCP servers, API keys, or
/// teams that need it.
///
/// `Model` and `McpServer` select dimensions a request carries only one of: an
/// MCP tool call resolves no model, and an LLM request routes to no MCP server.
/// A `Model`-scoped guardrail therefore never inspects MCP traffic, and an
/// `McpServer`-scoped one never inspects model traffic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GuardrailScopeType {
Env,
Model,
McpServer,
ApiKey,
Team,
}

/// Guardrail attachment that scopes one guardrail to an environment, model,
/// caller API key, or team. AISIX loads attachments with the guardrail
/// definitions and uses `scope_type` plus `scope_id` to decide which
/// MCP server, caller API key, or team. AISIX loads attachments with the
/// guardrail definitions and uses `scope_type` plus `scope_id` to decide which
/// guardrails apply to each request.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct GuardrailAttachment {
Expand All@@ -986,8 +992,8 @@ pub struct GuardrailAttachment {
/// What dimension of the request this attachment is scoped to.
pub scope_type: GuardrailScopeType,

/// The UUID of the specific resource (model / api_key / team).
/// `None` when `scope_type` is `Env` (applies to all requests).
/// The UUID of the specific resource (model / mcp_server / api_key /
/// team). `None` when `scope_type` is `Env` (applies to all requests).
pub scope_id: Option<String>,

/// Higher number = higher precedence. When the same guardrail appears
Expand Down
2 changes: 1 addition & 1 deletion crates/aisix-core/src/models/snapshot.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,7 @@ pub struct AisixSnapshot {
pub guardrails: ResourceTable<Guardrail>,
/// Attachment rows: `/aisix/<env>/guardrail_attachments/<uuid>`.
/// Each row binds a guardrail definition to a scope (env / model /
/// api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// mcp_server / api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// both this table and `guardrails` to build the per-request resolver.
pub guardrail_attachments: ResourceTable<GuardrailAttachment>,
/// Per-env cache policies. Stage 2 honors only the existence of an
Expand Down
64 changes: 64 additions & 0 deletions crates/aisix-guardrails/src/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -995,6 +995,7 @@ pub fn build_index_from_snapshot(
let scope_kind = match attachment.scope_type {
GuardrailScopeType::Env => ScopeKind::Env,
GuardrailScopeType::Model => ScopeKind::Model,
GuardrailScopeType::McpServer => ScopeKind::McpServer,
GuardrailScopeType::ApiKey => ScopeKind::ApiKey,
GuardrailScopeType::Team => ScopeKind::Team,
};
Expand DownExpand Up@@ -1887,6 +1888,7 @@ mod tests {
let index = build_index_from_snapshot(&shuffled_table(), &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -1937,6 +1939,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -1977,6 +1980,7 @@ mod tests {
// Verify the guardrail does not fire (not just that the index is empty).
let ctx = RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
};
Expand DownExpand Up@@ -2025,6 +2029,7 @@ mod tests {
);
let ctx = RequestContext {
model_id: "any",
mcp_server_id: "",
api_key_id: "any",
team_id: None,
};
Expand DownExpand Up@@ -2066,6 +2071,7 @@ mod tests {

let ctx = RequestContext {
model_id: "any-model",
mcp_server_id: "",
api_key_id: "any-key",
team_id: None,
};
Expand DownExpand Up@@ -2140,6 +2146,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2193,6 +2200,7 @@ mod tests {
let live = LiveGuardrailIndex::new(SnapshotHandle::new(AisixSnapshot::new()), None);
let chain = live.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -2259,6 +2267,7 @@ mod tests {
LiveGuardrailIndex::new_with_sink(SnapshotHandle::new(snap), None, Some(sink.clone()));
let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2481,6 +2490,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand All@@ -2495,6 +2505,59 @@ mod tests {
);
}

#[tokio::test]
async fn mcp_server_attachment_builds_into_the_index() {
// The wire `scope_type: "mcp_server"` survives the snapshot build and
// selects on the called server, leaving model traffic alone.
let guardrails: ResourceTable<DomainGuardrail> = ResourceTable::default();
guardrails.insert(entry(
"kw",
"g-1",
parse(
r#"{
"name": "kw",
"kind": "keyword",
"hook_point": "input",
"patterns": [{ "kind": "literal", "value": "AKIA" }]
}"#,
),
));
let attachments: ResourceTable<GuardrailAttachment> = ResourceTable::default();
attachments.insert(attachment_entry(
"a-mcp",
parse_attachment(
r#"{ "guardrail_id": "g-1", "scope_type": "mcp_server", "scope_id": "mcp-A", "priority": 50 }"#,
),
));

let index = build_index_from_snapshot(&guardrails, &attachments, None);
assert_eq!(index.len(), 1, "the attachment must not be skipped");

let matched = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k",
team_id: None,
});
assert_eq!(matched.len(), 1);

let other_server = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-B",
api_key_id: "k",
team_id: None,
});
assert!(other_server.is_empty());

let llm = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
assert!(llm.is_empty(), "model traffic carries no MCP server");
}

#[tokio::test]
async fn resolved_chain_applied_empty_when_no_attachment_matches() {
// A model-scoped attachment that doesn't match the request resolves to
Expand DownExpand Up@@ -2524,6 +2587,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-OTHER",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand Down
103 changes: 96 additions & 7 deletions crates/aisix-guardrails/src/index.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,10 +8,11 @@
//!
//! | `scope_type` | meaning |
//! |---|---|
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `mcp_server` | applies only to MCP tool calls routed to this server UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//!
//! `GuardrailIndex` holds the pre-built runtime guardrails for a snapshot.
//! `resolve(ctx)` filters + deduplicates the entries and returns the chain
Expand All@@ -37,6 +38,7 @@ use crate::{Guardrail, GuardrailChain};
pub enum ScopeKind {
Env,
Model,
McpServer,
ApiKey,
Team,
}
Expand DownExpand Up@@ -79,7 +81,13 @@ impl IndexEntry {
fn applies_to(&self, ctx: &RequestContext<'_>) -> bool {
match self.scope_kind {
ScopeKind::Env => true,
ScopeKind::Model => self.scope_id.as_deref() == Some(ctx.model_id),
// `model_id` / `mcp_server_id` are empty on the requests that have
// no such identity (an MCP tool call resolves no model; an LLM
// request routes to no MCP server), so an attachment whose
// `scope_id` is itself empty must NOT match everything that lacks
// the dimension — compare only non-empty ids.
ScopeKind::Model => matches_id(self.scope_id.as_deref(), ctx.model_id),
ScopeKind::McpServer => matches_id(self.scope_id.as_deref(), ctx.mcp_server_id),
ScopeKind::ApiKey => self.scope_id.as_deref() == Some(ctx.api_key_id),
ScopeKind::Team => ctx
.team_id
Expand All@@ -90,12 +98,22 @@ impl IndexEntry {
}
}

/// Equality for a scope dimension a request may not carry at all: an empty
/// id on either side never matches.
fn matches_id(scope_id: Option<&str>, ctx_id: &str) -> bool {
!ctx_id.is_empty() && scope_id == Some(ctx_id)
}

/// Per-request context used by [`GuardrailIndex::resolve`] to select and
/// deduplicate the applicable guardrails.
#[derive(Debug, Clone, Copy)]
pub struct RequestContext<'a> {
/// UUID of the model the request targets (virtual or concrete).
/// Empty for a request that resolves no model, such as an MCP tool call.
pub model_id: &'a str,
/// UUID of the registered MCP server an MCP tool call is routed to.
/// Empty for every request that is not an MCP tool call.
pub mcp_server_id: &'a str,
/// UUID of the API key used to authenticate the request.
pub api_key_id: &'a str,
/// UUID of the team the API key belongs to. `None` if the key is not
Expand All@@ -115,12 +133,15 @@ pub struct GuardrailIndex {
}

/// Scope specificity rank: higher = more specific → wins dedup on equal priority.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379. `McpServer`
/// shares `Model`'s rank: the two dimensions are mutually exclusive within a
/// request (an MCP tool call resolves no model and an LLM request routes to no
/// MCP server), so their relative order can never decide a deduplication.
fn scope_specificity(k: &ScopeKind) -> u8 {
match k {
ScopeKind::ApiKey => 3,
ScopeKind::Team => 2,
ScopeKind::Model => 1,
ScopeKind::Model | ScopeKind::McpServer => 1,
ScopeKind::Env => 0,
}
}
Expand DownExpand Up@@ -231,6 +252,7 @@ mod tests {
fn ctx<'a>(model: &'a str, apikey: &'a str, team: Option<&'a str>) -> RequestContext<'a> {
RequestContext {
model_id: model,
mcp_server_id: "",
api_key_id: apikey,
team_id: team,
}
Expand DownExpand Up@@ -309,6 +331,72 @@ mod tests {
);
}

// 3b. McpServer-scope attachment applies only to tool calls routed to
// that server — and never to model traffic, which carries no server id.
#[tokio::test]
async fn mcp_server_scope_only_matching_server() {
let g = kw("g1", "secret");
let idx = GuardrailIndex::from_entries(vec![entry(
"g1",
ScopeKind::McpServer,
Some("mcp-A"),
50,
g,
)]);

let mcp_ctx = |server: &'static str| RequestContext {
model_id: "",
mcp_server_id: server,
api_key_id: "k1",
team_id: None,
};

let chain_a = idx.resolve(&mcp_ctx("mcp-A"));
assert!(chain_a.check_input(&req("secret")).await.is_block());

let chain_b = idx.resolve(&mcp_ctx("mcp-B"));
assert_eq!(
chain_b.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

// An LLM request carries no MCP server, so the attachment is inert.
let chain_llm = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
chain_llm.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 3c. A dimension a request does not carry never matches, even against an
// attachment whose own scope_id is empty — the sentinel for "absent" must
// not read as a wildcard.
#[tokio::test]
async fn empty_scope_id_never_matches_an_absent_dimension() {
let idx = GuardrailIndex::from_entries(vec![
entry("g1", ScopeKind::McpServer, Some(""), 50, kw("g1", "secret")),
entry("g2", ScopeKind::Model, Some(""), 50, kw("g2", "secret")),
]);

// An MCP tool call has no model; an LLM request has no MCP server.
let mcp_chain = idx.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k1",
team_id: None,
});
assert_eq!(
mcp_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

let llm_chain = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
llm_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 4. ApiKey-scope attachment applies only to the matching key.
#[tokio::test]
async fn api_key_scope_only_matching_key() {
Expand DownExpand Up@@ -569,6 +657,7 @@ mod tests {
let team = format!("scope-{}", (i + 7) % 10);
let _ = idx.resolve(&RequestContext {
model_id: &model,
mcp_server_id: "",
api_key_id: &key,
team_id: Some(&team),
});
Expand Down
2 changes: 2 additions & 0 deletions crates/aisix-proxy/src/audio.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -577,6 +577,7 @@ async fn multipart_dispatch(
// The transcript RESPONSE is scanned/masked after the upstream call.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand DownExpand Up@@ -1082,6 +1083,7 @@ async fn speech_dispatch(
// RPM slot. (Output is binary audio, not scannable text — no output hook.)
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/chat.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1232,6 +1232,7 @@ async fn dispatch(
// guardrail context.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/completions.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,7 @@ async fn dispatch(
// (matching /v1/chat/completions).
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(mcp): scan structured tool output, block as a tool error, scope guardrails per server by jarvis9443 · Pull Request #979 · api7/aisix · GitHub
Skip to content
Merged
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
18 changes: 12 additions & 6 deletions crates/aisix-core/src/models/guardrail.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -962,20 +962,26 @@ impl Resource for Guardrail {
/// Which dimension of the request a guardrail attachment is scoped to.
///
/// `Env` applies to every request in the environment. The narrower scopes let
/// operators attach a guardrail to only the models, API keys, or teams that
/// need it.
/// operators attach a guardrail to only the models, MCP servers, API keys, or
/// teams that need it.
///
/// `Model` and `McpServer` select dimensions a request carries only one of: an
/// MCP tool call resolves no model, and an LLM request routes to no MCP server.
/// A `Model`-scoped guardrail therefore never inspects MCP traffic, and an
/// `McpServer`-scoped one never inspects model traffic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GuardrailScopeType {
Env,
Model,
McpServer,
ApiKey,
Team,
}

/// Guardrail attachment that scopes one guardrail to an environment, model,
/// caller API key, or team. AISIX loads attachments with the guardrail
/// definitions and uses `scope_type` plus `scope_id` to decide which
/// MCP server, caller API key, or team. AISIX loads attachments with the
/// guardrail definitions and uses `scope_type` plus `scope_id` to decide which
/// guardrails apply to each request.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct GuardrailAttachment {
Expand All@@ -986,8 +992,8 @@ pub struct GuardrailAttachment {
/// What dimension of the request this attachment is scoped to.
pub scope_type: GuardrailScopeType,

/// The UUID of the specific resource (model / api_key / team).
/// `None` when `scope_type` is `Env` (applies to all requests).
/// The UUID of the specific resource (model / mcp_server / api_key /
/// team). `None` when `scope_type` is `Env` (applies to all requests).
pub scope_id: Option<String>,

/// Higher number = higher precedence. When the same guardrail appears
Expand Down
2 changes: 1 addition & 1 deletion crates/aisix-core/src/models/snapshot.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,7 @@ pub struct AisixSnapshot {
pub guardrails: ResourceTable<Guardrail>,
/// Attachment rows: `/aisix/<env>/guardrail_attachments/<uuid>`.
/// Each row binds a guardrail definition to a scope (env / model /
/// api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// mcp_server / api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// both this table and `guardrails` to build the per-request resolver.
pub guardrail_attachments: ResourceTable<GuardrailAttachment>,
/// Per-env cache policies. Stage 2 honors only the existence of an
Expand Down
64 changes: 64 additions & 0 deletions crates/aisix-guardrails/src/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -995,6 +995,7 @@ pub fn build_index_from_snapshot(
let scope_kind = match attachment.scope_type {
GuardrailScopeType::Env => ScopeKind::Env,
GuardrailScopeType::Model => ScopeKind::Model,
GuardrailScopeType::McpServer => ScopeKind::McpServer,
GuardrailScopeType::ApiKey => ScopeKind::ApiKey,
GuardrailScopeType::Team => ScopeKind::Team,
};
Expand DownExpand Up@@ -1887,6 +1888,7 @@ mod tests {
let index = build_index_from_snapshot(&shuffled_table(), &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -1937,6 +1939,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -1977,6 +1980,7 @@ mod tests {
// Verify the guardrail does not fire (not just that the index is empty).
let ctx = RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
};
Expand DownExpand Up@@ -2025,6 +2029,7 @@ mod tests {
);
let ctx = RequestContext {
model_id: "any",
mcp_server_id: "",
api_key_id: "any",
team_id: None,
};
Expand DownExpand Up@@ -2066,6 +2071,7 @@ mod tests {

let ctx = RequestContext {
model_id: "any-model",
mcp_server_id: "",
api_key_id: "any-key",
team_id: None,
};
Expand DownExpand Up@@ -2140,6 +2146,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2193,6 +2200,7 @@ mod tests {
let live = LiveGuardrailIndex::new(SnapshotHandle::new(AisixSnapshot::new()), None);
let chain = live.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -2259,6 +2267,7 @@ mod tests {
LiveGuardrailIndex::new_with_sink(SnapshotHandle::new(snap), None, Some(sink.clone()));
let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2481,6 +2490,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand All@@ -2495,6 +2505,59 @@ mod tests {
);
}

#[tokio::test]
async fn mcp_server_attachment_builds_into_the_index() {
// The wire `scope_type: "mcp_server"` survives the snapshot build and
// selects on the called server, leaving model traffic alone.
let guardrails: ResourceTable<DomainGuardrail> = ResourceTable::default();
guardrails.insert(entry(
"kw",
"g-1",
parse(
r#"{
"name": "kw",
"kind": "keyword",
"hook_point": "input",
"patterns": [{ "kind": "literal", "value": "AKIA" }]
}"#,
),
));
let attachments: ResourceTable<GuardrailAttachment> = ResourceTable::default();
attachments.insert(attachment_entry(
"a-mcp",
parse_attachment(
r#"{ "guardrail_id": "g-1", "scope_type": "mcp_server", "scope_id": "mcp-A", "priority": 50 }"#,
),
));

let index = build_index_from_snapshot(&guardrails, &attachments, None);
assert_eq!(index.len(), 1, "the attachment must not be skipped");

let matched = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k",
team_id: None,
});
assert_eq!(matched.len(), 1);

let other_server = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-B",
api_key_id: "k",
team_id: None,
});
assert!(other_server.is_empty());

let llm = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
assert!(llm.is_empty(), "model traffic carries no MCP server");
}

#[tokio::test]
async fn resolved_chain_applied_empty_when_no_attachment_matches() {
// A model-scoped attachment that doesn't match the request resolves to
Expand DownExpand Up@@ -2524,6 +2587,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-OTHER",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand Down
103 changes: 96 additions & 7 deletions crates/aisix-guardrails/src/index.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,10 +8,11 @@
//!
//! | `scope_type` | meaning |
//! |---|---|
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `mcp_server` | applies only to MCP tool calls routed to this server UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//!
//! `GuardrailIndex` holds the pre-built runtime guardrails for a snapshot.
//! `resolve(ctx)` filters + deduplicates the entries and returns the chain
Expand All@@ -37,6 +38,7 @@ use crate::{Guardrail, GuardrailChain};
pub enum ScopeKind {
Env,
Model,
McpServer,
ApiKey,
Team,
}
Expand DownExpand Up@@ -79,7 +81,13 @@ impl IndexEntry {
fn applies_to(&self, ctx: &RequestContext<'_>) -> bool {
match self.scope_kind {
ScopeKind::Env => true,
ScopeKind::Model => self.scope_id.as_deref() == Some(ctx.model_id),
// `model_id` / `mcp_server_id` are empty on the requests that have
// no such identity (an MCP tool call resolves no model; an LLM
// request routes to no MCP server), so an attachment whose
// `scope_id` is itself empty must NOT match everything that lacks
// the dimension — compare only non-empty ids.
ScopeKind::Model => matches_id(self.scope_id.as_deref(), ctx.model_id),
ScopeKind::McpServer => matches_id(self.scope_id.as_deref(), ctx.mcp_server_id),
ScopeKind::ApiKey => self.scope_id.as_deref() == Some(ctx.api_key_id),
ScopeKind::Team => ctx
.team_id
Expand All@@ -90,12 +98,22 @@ impl IndexEntry {
}
}

/// Equality for a scope dimension a request may not carry at all: an empty
/// id on either side never matches.
fn matches_id(scope_id: Option<&str>, ctx_id: &str) -> bool {
!ctx_id.is_empty() && scope_id == Some(ctx_id)
}

/// Per-request context used by [`GuardrailIndex::resolve`] to select and
/// deduplicate the applicable guardrails.
#[derive(Debug, Clone, Copy)]
pub struct RequestContext<'a> {
/// UUID of the model the request targets (virtual or concrete).
/// Empty for a request that resolves no model, such as an MCP tool call.
pub model_id: &'a str,
/// UUID of the registered MCP server an MCP tool call is routed to.
/// Empty for every request that is not an MCP tool call.
pub mcp_server_id: &'a str,
/// UUID of the API key used to authenticate the request.
pub api_key_id: &'a str,
/// UUID of the team the API key belongs to. `None` if the key is not
Expand All@@ -115,12 +133,15 @@ pub struct GuardrailIndex {
}

/// Scope specificity rank: higher = more specific → wins dedup on equal priority.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379. `McpServer`
/// shares `Model`'s rank: the two dimensions are mutually exclusive within a
/// request (an MCP tool call resolves no model and an LLM request routes to no
/// MCP server), so their relative order can never decide a deduplication.
fn scope_specificity(k: &ScopeKind) -> u8 {
match k {
ScopeKind::ApiKey => 3,
ScopeKind::Team => 2,
ScopeKind::Model => 1,
ScopeKind::Model | ScopeKind::McpServer => 1,
ScopeKind::Env => 0,
}
}
Expand DownExpand Up@@ -231,6 +252,7 @@ mod tests {
fn ctx<'a>(model: &'a str, apikey: &'a str, team: Option<&'a str>) -> RequestContext<'a> {
RequestContext {
model_id: model,
mcp_server_id: "",
api_key_id: apikey,
team_id: team,
}
Expand DownExpand Up@@ -309,6 +331,72 @@ mod tests {
);
}

// 3b. McpServer-scope attachment applies only to tool calls routed to
// that server — and never to model traffic, which carries no server id.
#[tokio::test]
async fn mcp_server_scope_only_matching_server() {
let g = kw("g1", "secret");
let idx = GuardrailIndex::from_entries(vec![entry(
"g1",
ScopeKind::McpServer,
Some("mcp-A"),
50,
g,
)]);

let mcp_ctx = |server: &'static str| RequestContext {
model_id: "",
mcp_server_id: server,
api_key_id: "k1",
team_id: None,
};

let chain_a = idx.resolve(&mcp_ctx("mcp-A"));
assert!(chain_a.check_input(&req("secret")).await.is_block());

let chain_b = idx.resolve(&mcp_ctx("mcp-B"));
assert_eq!(
chain_b.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

// An LLM request carries no MCP server, so the attachment is inert.
let chain_llm = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
chain_llm.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 3c. A dimension a request does not carry never matches, even against an
// attachment whose own scope_id is empty — the sentinel for "absent" must
// not read as a wildcard.
#[tokio::test]
async fn empty_scope_id_never_matches_an_absent_dimension() {
let idx = GuardrailIndex::from_entries(vec![
entry("g1", ScopeKind::McpServer, Some(""), 50, kw("g1", "secret")),
entry("g2", ScopeKind::Model, Some(""), 50, kw("g2", "secret")),
]);

// An MCP tool call has no model; an LLM request has no MCP server.
let mcp_chain = idx.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k1",
team_id: None,
});
assert_eq!(
mcp_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

let llm_chain = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
llm_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 4. ApiKey-scope attachment applies only to the matching key.
#[tokio::test]
async fn api_key_scope_only_matching_key() {
Expand DownExpand Up@@ -569,6 +657,7 @@ mod tests {
let team = format!("scope-{}", (i + 7) % 10);
let _ = idx.resolve(&RequestContext {
model_id: &model,
mcp_server_id: "",
api_key_id: &key,
team_id: Some(&team),
});
Expand Down
2 changes: 2 additions & 0 deletions crates/aisix-proxy/src/audio.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -577,6 +577,7 @@ async fn multipart_dispatch(
// The transcript RESPONSE is scanned/masked after the upstream call.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand DownExpand Up@@ -1082,6 +1083,7 @@ async fn speech_dispatch(
// RPM slot. (Output is binary audio, not scannable text — no output hook.)
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/chat.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1232,6 +1232,7 @@ async fn dispatch(
// guardrail context.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/completions.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,7 @@ async fn dispatch(
// (matching /v1/chat/completions).
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(mcp): scan structured tool output, block as a tool error, scope guardrails per server by jarvis9443 · Pull Request #979 · api7/aisix · GitHub
Skip to content
Merged
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
18 changes: 12 additions & 6 deletions crates/aisix-core/src/models/guardrail.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -962,20 +962,26 @@ impl Resource for Guardrail {
/// Which dimension of the request a guardrail attachment is scoped to.
///
/// `Env` applies to every request in the environment. The narrower scopes let
/// operators attach a guardrail to only the models, API keys, or teams that
/// need it.
/// operators attach a guardrail to only the models, MCP servers, API keys, or
/// teams that need it.
///
/// `Model` and `McpServer` select dimensions a request carries only one of: an
/// MCP tool call resolves no model, and an LLM request routes to no MCP server.
/// A `Model`-scoped guardrail therefore never inspects MCP traffic, and an
/// `McpServer`-scoped one never inspects model traffic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GuardrailScopeType {
Env,
Model,
McpServer,
ApiKey,
Team,
}

/// Guardrail attachment that scopes one guardrail to an environment, model,
/// caller API key, or team. AISIX loads attachments with the guardrail
/// definitions and uses `scope_type` plus `scope_id` to decide which
/// MCP server, caller API key, or team. AISIX loads attachments with the
/// guardrail definitions and uses `scope_type` plus `scope_id` to decide which
/// guardrails apply to each request.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct GuardrailAttachment {
Expand All@@ -986,8 +992,8 @@ pub struct GuardrailAttachment {
/// What dimension of the request this attachment is scoped to.
pub scope_type: GuardrailScopeType,

/// The UUID of the specific resource (model / api_key / team).
/// `None` when `scope_type` is `Env` (applies to all requests).
/// The UUID of the specific resource (model / mcp_server / api_key /
/// team). `None` when `scope_type` is `Env` (applies to all requests).
pub scope_id: Option<String>,

/// Higher number = higher precedence. When the same guardrail appears
Expand Down
2 changes: 1 addition & 1 deletion crates/aisix-core/src/models/snapshot.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,7 @@ pub struct AisixSnapshot {
pub guardrails: ResourceTable<Guardrail>,
/// Attachment rows: `/aisix/<env>/guardrail_attachments/<uuid>`.
/// Each row binds a guardrail definition to a scope (env / model /
/// api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// mcp_server / api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// both this table and `guardrails` to build the per-request resolver.
pub guardrail_attachments: ResourceTable<GuardrailAttachment>,
/// Per-env cache policies. Stage 2 honors only the existence of an
Expand Down
64 changes: 64 additions & 0 deletions crates/aisix-guardrails/src/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -995,6 +995,7 @@ pub fn build_index_from_snapshot(
let scope_kind = match attachment.scope_type {
GuardrailScopeType::Env => ScopeKind::Env,
GuardrailScopeType::Model => ScopeKind::Model,
GuardrailScopeType::McpServer => ScopeKind::McpServer,
GuardrailScopeType::ApiKey => ScopeKind::ApiKey,
GuardrailScopeType::Team => ScopeKind::Team,
};
Expand DownExpand Up@@ -1887,6 +1888,7 @@ mod tests {
let index = build_index_from_snapshot(&shuffled_table(), &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -1937,6 +1939,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -1977,6 +1980,7 @@ mod tests {
// Verify the guardrail does not fire (not just that the index is empty).
let ctx = RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
};
Expand DownExpand Up@@ -2025,6 +2029,7 @@ mod tests {
);
let ctx = RequestContext {
model_id: "any",
mcp_server_id: "",
api_key_id: "any",
team_id: None,
};
Expand DownExpand Up@@ -2066,6 +2071,7 @@ mod tests {

let ctx = RequestContext {
model_id: "any-model",
mcp_server_id: "",
api_key_id: "any-key",
team_id: None,
};
Expand DownExpand Up@@ -2140,6 +2146,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2193,6 +2200,7 @@ mod tests {
let live = LiveGuardrailIndex::new(SnapshotHandle::new(AisixSnapshot::new()), None);
let chain = live.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -2259,6 +2267,7 @@ mod tests {
LiveGuardrailIndex::new_with_sink(SnapshotHandle::new(snap), None, Some(sink.clone()));
let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2481,6 +2490,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand All@@ -2495,6 +2505,59 @@ mod tests {
);
}

#[tokio::test]
async fn mcp_server_attachment_builds_into_the_index() {
// The wire `scope_type: "mcp_server"` survives the snapshot build and
// selects on the called server, leaving model traffic alone.
let guardrails: ResourceTable<DomainGuardrail> = ResourceTable::default();
guardrails.insert(entry(
"kw",
"g-1",
parse(
r#"{
"name": "kw",
"kind": "keyword",
"hook_point": "input",
"patterns": [{ "kind": "literal", "value": "AKIA" }]
}"#,
),
));
let attachments: ResourceTable<GuardrailAttachment> = ResourceTable::default();
attachments.insert(attachment_entry(
"a-mcp",
parse_attachment(
r#"{ "guardrail_id": "g-1", "scope_type": "mcp_server", "scope_id": "mcp-A", "priority": 50 }"#,
),
));

let index = build_index_from_snapshot(&guardrails, &attachments, None);
assert_eq!(index.len(), 1, "the attachment must not be skipped");

let matched = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k",
team_id: None,
});
assert_eq!(matched.len(), 1);

let other_server = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-B",
api_key_id: "k",
team_id: None,
});
assert!(other_server.is_empty());

let llm = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
assert!(llm.is_empty(), "model traffic carries no MCP server");
}

#[tokio::test]
async fn resolved_chain_applied_empty_when_no_attachment_matches() {
// A model-scoped attachment that doesn't match the request resolves to
Expand DownExpand Up@@ -2524,6 +2587,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-OTHER",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand Down
103 changes: 96 additions & 7 deletions crates/aisix-guardrails/src/index.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,10 +8,11 @@
//!
//! | `scope_type` | meaning |
//! |---|---|
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `mcp_server` | applies only to MCP tool calls routed to this server UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//!
//! `GuardrailIndex` holds the pre-built runtime guardrails for a snapshot.
//! `resolve(ctx)` filters + deduplicates the entries and returns the chain
Expand All@@ -37,6 +38,7 @@ use crate::{Guardrail, GuardrailChain};
pub enum ScopeKind {
Env,
Model,
McpServer,
ApiKey,
Team,
}
Expand DownExpand Up@@ -79,7 +81,13 @@ impl IndexEntry {
fn applies_to(&self, ctx: &RequestContext<'_>) -> bool {
match self.scope_kind {
ScopeKind::Env => true,
ScopeKind::Model => self.scope_id.as_deref() == Some(ctx.model_id),
// `model_id` / `mcp_server_id` are empty on the requests that have
// no such identity (an MCP tool call resolves no model; an LLM
// request routes to no MCP server), so an attachment whose
// `scope_id` is itself empty must NOT match everything that lacks
// the dimension — compare only non-empty ids.
ScopeKind::Model => matches_id(self.scope_id.as_deref(), ctx.model_id),
ScopeKind::McpServer => matches_id(self.scope_id.as_deref(), ctx.mcp_server_id),
ScopeKind::ApiKey => self.scope_id.as_deref() == Some(ctx.api_key_id),
ScopeKind::Team => ctx
.team_id
Expand All@@ -90,12 +98,22 @@ impl IndexEntry {
}
}

/// Equality for a scope dimension a request may not carry at all: an empty
/// id on either side never matches.
fn matches_id(scope_id: Option<&str>, ctx_id: &str) -> bool {
!ctx_id.is_empty() && scope_id == Some(ctx_id)
}

/// Per-request context used by [`GuardrailIndex::resolve`] to select and
/// deduplicate the applicable guardrails.
#[derive(Debug, Clone, Copy)]
pub struct RequestContext<'a> {
/// UUID of the model the request targets (virtual or concrete).
/// Empty for a request that resolves no model, such as an MCP tool call.
pub model_id: &'a str,
/// UUID of the registered MCP server an MCP tool call is routed to.
/// Empty for every request that is not an MCP tool call.
pub mcp_server_id: &'a str,
/// UUID of the API key used to authenticate the request.
pub api_key_id: &'a str,
/// UUID of the team the API key belongs to. `None` if the key is not
Expand All@@ -115,12 +133,15 @@ pub struct GuardrailIndex {
}

/// Scope specificity rank: higher = more specific → wins dedup on equal priority.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379. `McpServer`
/// shares `Model`'s rank: the two dimensions are mutually exclusive within a
/// request (an MCP tool call resolves no model and an LLM request routes to no
/// MCP server), so their relative order can never decide a deduplication.
fn scope_specificity(k: &ScopeKind) -> u8 {
match k {
ScopeKind::ApiKey => 3,
ScopeKind::Team => 2,
ScopeKind::Model => 1,
ScopeKind::Model | ScopeKind::McpServer => 1,
ScopeKind::Env => 0,
}
}
Expand DownExpand Up@@ -231,6 +252,7 @@ mod tests {
fn ctx<'a>(model: &'a str, apikey: &'a str, team: Option<&'a str>) -> RequestContext<'a> {
RequestContext {
model_id: model,
mcp_server_id: "",
api_key_id: apikey,
team_id: team,
}
Expand DownExpand Up@@ -309,6 +331,72 @@ mod tests {
);
}

// 3b. McpServer-scope attachment applies only to tool calls routed to
// that server — and never to model traffic, which carries no server id.
#[tokio::test]
async fn mcp_server_scope_only_matching_server() {
let g = kw("g1", "secret");
let idx = GuardrailIndex::from_entries(vec![entry(
"g1",
ScopeKind::McpServer,
Some("mcp-A"),
50,
g,
)]);

let mcp_ctx = |server: &'static str| RequestContext {
model_id: "",
mcp_server_id: server,
api_key_id: "k1",
team_id: None,
};

let chain_a = idx.resolve(&mcp_ctx("mcp-A"));
assert!(chain_a.check_input(&req("secret")).await.is_block());

let chain_b = idx.resolve(&mcp_ctx("mcp-B"));
assert_eq!(
chain_b.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

// An LLM request carries no MCP server, so the attachment is inert.
let chain_llm = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
chain_llm.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 3c. A dimension a request does not carry never matches, even against an
// attachment whose own scope_id is empty — the sentinel for "absent" must
// not read as a wildcard.
#[tokio::test]
async fn empty_scope_id_never_matches_an_absent_dimension() {
let idx = GuardrailIndex::from_entries(vec![
entry("g1", ScopeKind::McpServer, Some(""), 50, kw("g1", "secret")),
entry("g2", ScopeKind::Model, Some(""), 50, kw("g2", "secret")),
]);

// An MCP tool call has no model; an LLM request has no MCP server.
let mcp_chain = idx.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k1",
team_id: None,
});
assert_eq!(
mcp_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

let llm_chain = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
llm_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 4. ApiKey-scope attachment applies only to the matching key.
#[tokio::test]
async fn api_key_scope_only_matching_key() {
Expand DownExpand Up@@ -569,6 +657,7 @@ mod tests {
let team = format!("scope-{}", (i + 7) % 10);
let _ = idx.resolve(&RequestContext {
model_id: &model,
mcp_server_id: "",
api_key_id: &key,
team_id: Some(&team),
});
Expand Down
2 changes: 2 additions & 0 deletions crates/aisix-proxy/src/audio.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -577,6 +577,7 @@ async fn multipart_dispatch(
// The transcript RESPONSE is scanned/masked after the upstream call.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand DownExpand Up@@ -1082,6 +1083,7 @@ async fn speech_dispatch(
// RPM slot. (Output is binary audio, not scannable text — no output hook.)
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/chat.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1232,6 +1232,7 @@ async fn dispatch(
// guardrail context.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/completions.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,7 @@ async fn dispatch(
// (matching /v1/chat/completions).
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(mcp): scan structured tool output, block as a tool error, scope guardrails per server by jarvis9443 · Pull Request #979 · api7/aisix · GitHub
Skip to content
Merged
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
18 changes: 12 additions & 6 deletions crates/aisix-core/src/models/guardrail.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -962,20 +962,26 @@ impl Resource for Guardrail {
/// Which dimension of the request a guardrail attachment is scoped to.
///
/// `Env` applies to every request in the environment. The narrower scopes let
/// operators attach a guardrail to only the models, API keys, or teams that
/// need it.
/// operators attach a guardrail to only the models, MCP servers, API keys, or
/// teams that need it.
///
/// `Model` and `McpServer` select dimensions a request carries only one of: an
/// MCP tool call resolves no model, and an LLM request routes to no MCP server.
/// A `Model`-scoped guardrail therefore never inspects MCP traffic, and an
/// `McpServer`-scoped one never inspects model traffic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GuardrailScopeType {
Env,
Model,
McpServer,
ApiKey,
Team,
}

/// Guardrail attachment that scopes one guardrail to an environment, model,
/// caller API key, or team. AISIX loads attachments with the guardrail
/// definitions and uses `scope_type` plus `scope_id` to decide which
/// MCP server, caller API key, or team. AISIX loads attachments with the
/// guardrail definitions and uses `scope_type` plus `scope_id` to decide which
/// guardrails apply to each request.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct GuardrailAttachment {
Expand All@@ -986,8 +992,8 @@ pub struct GuardrailAttachment {
/// What dimension of the request this attachment is scoped to.
pub scope_type: GuardrailScopeType,

/// The UUID of the specific resource (model / api_key / team).
/// `None` when `scope_type` is `Env` (applies to all requests).
/// The UUID of the specific resource (model / mcp_server / api_key /
/// team). `None` when `scope_type` is `Env` (applies to all requests).
pub scope_id: Option<String>,

/// Higher number = higher precedence. When the same guardrail appears
Expand Down
2 changes: 1 addition & 1 deletion crates/aisix-core/src/models/snapshot.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,7 @@ pub struct AisixSnapshot {
pub guardrails: ResourceTable<Guardrail>,
/// Attachment rows: `/aisix/<env>/guardrail_attachments/<uuid>`.
/// Each row binds a guardrail definition to a scope (env / model /
/// api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// mcp_server / api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// both this table and `guardrails` to build the per-request resolver.
pub guardrail_attachments: ResourceTable<GuardrailAttachment>,
/// Per-env cache policies. Stage 2 honors only the existence of an
Expand Down
64 changes: 64 additions & 0 deletions crates/aisix-guardrails/src/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -995,6 +995,7 @@ pub fn build_index_from_snapshot(
let scope_kind = match attachment.scope_type {
GuardrailScopeType::Env => ScopeKind::Env,
GuardrailScopeType::Model => ScopeKind::Model,
GuardrailScopeType::McpServer => ScopeKind::McpServer,
GuardrailScopeType::ApiKey => ScopeKind::ApiKey,
GuardrailScopeType::Team => ScopeKind::Team,
};
Expand DownExpand Up@@ -1887,6 +1888,7 @@ mod tests {
let index = build_index_from_snapshot(&shuffled_table(), &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -1937,6 +1939,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -1977,6 +1980,7 @@ mod tests {
// Verify the guardrail does not fire (not just that the index is empty).
let ctx = RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
};
Expand DownExpand Up@@ -2025,6 +2029,7 @@ mod tests {
);
let ctx = RequestContext {
model_id: "any",
mcp_server_id: "",
api_key_id: "any",
team_id: None,
};
Expand DownExpand Up@@ -2066,6 +2071,7 @@ mod tests {

let ctx = RequestContext {
model_id: "any-model",
mcp_server_id: "",
api_key_id: "any-key",
team_id: None,
};
Expand DownExpand Up@@ -2140,6 +2146,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2193,6 +2200,7 @@ mod tests {
let live = LiveGuardrailIndex::new(SnapshotHandle::new(AisixSnapshot::new()), None);
let chain = live.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -2259,6 +2267,7 @@ mod tests {
LiveGuardrailIndex::new_with_sink(SnapshotHandle::new(snap), None, Some(sink.clone()));
let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2481,6 +2490,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand All@@ -2495,6 +2505,59 @@ mod tests {
);
}

#[tokio::test]
async fn mcp_server_attachment_builds_into_the_index() {
// The wire `scope_type: "mcp_server"` survives the snapshot build and
// selects on the called server, leaving model traffic alone.
let guardrails: ResourceTable<DomainGuardrail> = ResourceTable::default();
guardrails.insert(entry(
"kw",
"g-1",
parse(
r#"{
"name": "kw",
"kind": "keyword",
"hook_point": "input",
"patterns": [{ "kind": "literal", "value": "AKIA" }]
}"#,
),
));
let attachments: ResourceTable<GuardrailAttachment> = ResourceTable::default();
attachments.insert(attachment_entry(
"a-mcp",
parse_attachment(
r#"{ "guardrail_id": "g-1", "scope_type": "mcp_server", "scope_id": "mcp-A", "priority": 50 }"#,
),
));

let index = build_index_from_snapshot(&guardrails, &attachments, None);
assert_eq!(index.len(), 1, "the attachment must not be skipped");

let matched = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k",
team_id: None,
});
assert_eq!(matched.len(), 1);

let other_server = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-B",
api_key_id: "k",
team_id: None,
});
assert!(other_server.is_empty());

let llm = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
assert!(llm.is_empty(), "model traffic carries no MCP server");
}

#[tokio::test]
async fn resolved_chain_applied_empty_when_no_attachment_matches() {
// A model-scoped attachment that doesn't match the request resolves to
Expand DownExpand Up@@ -2524,6 +2587,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-OTHER",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand Down
103 changes: 96 additions & 7 deletions crates/aisix-guardrails/src/index.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,10 +8,11 @@
//!
//! | `scope_type` | meaning |
//! |---|---|
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `mcp_server` | applies only to MCP tool calls routed to this server UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//!
//! `GuardrailIndex` holds the pre-built runtime guardrails for a snapshot.
//! `resolve(ctx)` filters + deduplicates the entries and returns the chain
Expand All@@ -37,6 +38,7 @@ use crate::{Guardrail, GuardrailChain};
pub enum ScopeKind {
Env,
Model,
McpServer,
ApiKey,
Team,
}
Expand DownExpand Up@@ -79,7 +81,13 @@ impl IndexEntry {
fn applies_to(&self, ctx: &RequestContext<'_>) -> bool {
match self.scope_kind {
ScopeKind::Env => true,
ScopeKind::Model => self.scope_id.as_deref() == Some(ctx.model_id),
// `model_id` / `mcp_server_id` are empty on the requests that have
// no such identity (an MCP tool call resolves no model; an LLM
// request routes to no MCP server), so an attachment whose
// `scope_id` is itself empty must NOT match everything that lacks
// the dimension — compare only non-empty ids.
ScopeKind::Model => matches_id(self.scope_id.as_deref(), ctx.model_id),
ScopeKind::McpServer => matches_id(self.scope_id.as_deref(), ctx.mcp_server_id),
ScopeKind::ApiKey => self.scope_id.as_deref() == Some(ctx.api_key_id),
ScopeKind::Team => ctx
.team_id
Expand All@@ -90,12 +98,22 @@ impl IndexEntry {
}
}

/// Equality for a scope dimension a request may not carry at all: an empty
/// id on either side never matches.
fn matches_id(scope_id: Option<&str>, ctx_id: &str) -> bool {
!ctx_id.is_empty() && scope_id == Some(ctx_id)
}

/// Per-request context used by [`GuardrailIndex::resolve`] to select and
/// deduplicate the applicable guardrails.
#[derive(Debug, Clone, Copy)]
pub struct RequestContext<'a> {
/// UUID of the model the request targets (virtual or concrete).
/// Empty for a request that resolves no model, such as an MCP tool call.
pub model_id: &'a str,
/// UUID of the registered MCP server an MCP tool call is routed to.
/// Empty for every request that is not an MCP tool call.
pub mcp_server_id: &'a str,
/// UUID of the API key used to authenticate the request.
pub api_key_id: &'a str,
/// UUID of the team the API key belongs to. `None` if the key is not
Expand All@@ -115,12 +133,15 @@ pub struct GuardrailIndex {
}

/// Scope specificity rank: higher = more specific → wins dedup on equal priority.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379. `McpServer`
/// shares `Model`'s rank: the two dimensions are mutually exclusive within a
/// request (an MCP tool call resolves no model and an LLM request routes to no
/// MCP server), so their relative order can never decide a deduplication.
fn scope_specificity(k: &ScopeKind) -> u8 {
match k {
ScopeKind::ApiKey => 3,
ScopeKind::Team => 2,
ScopeKind::Model => 1,
ScopeKind::Model | ScopeKind::McpServer => 1,
ScopeKind::Env => 0,
}
}
Expand DownExpand Up@@ -231,6 +252,7 @@ mod tests {
fn ctx<'a>(model: &'a str, apikey: &'a str, team: Option<&'a str>) -> RequestContext<'a> {
RequestContext {
model_id: model,
mcp_server_id: "",
api_key_id: apikey,
team_id: team,
}
Expand DownExpand Up@@ -309,6 +331,72 @@ mod tests {
);
}

// 3b. McpServer-scope attachment applies only to tool calls routed to
// that server — and never to model traffic, which carries no server id.
#[tokio::test]
async fn mcp_server_scope_only_matching_server() {
let g = kw("g1", "secret");
let idx = GuardrailIndex::from_entries(vec![entry(
"g1",
ScopeKind::McpServer,
Some("mcp-A"),
50,
g,
)]);

let mcp_ctx = |server: &'static str| RequestContext {
model_id: "",
mcp_server_id: server,
api_key_id: "k1",
team_id: None,
};

let chain_a = idx.resolve(&mcp_ctx("mcp-A"));
assert!(chain_a.check_input(&req("secret")).await.is_block());

let chain_b = idx.resolve(&mcp_ctx("mcp-B"));
assert_eq!(
chain_b.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

// An LLM request carries no MCP server, so the attachment is inert.
let chain_llm = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
chain_llm.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 3c. A dimension a request does not carry never matches, even against an
// attachment whose own scope_id is empty — the sentinel for "absent" must
// not read as a wildcard.
#[tokio::test]
async fn empty_scope_id_never_matches_an_absent_dimension() {
let idx = GuardrailIndex::from_entries(vec![
entry("g1", ScopeKind::McpServer, Some(""), 50, kw("g1", "secret")),
entry("g2", ScopeKind::Model, Some(""), 50, kw("g2", "secret")),
]);

// An MCP tool call has no model; an LLM request has no MCP server.
let mcp_chain = idx.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k1",
team_id: None,
});
assert_eq!(
mcp_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

let llm_chain = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
llm_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 4. ApiKey-scope attachment applies only to the matching key.
#[tokio::test]
async fn api_key_scope_only_matching_key() {
Expand DownExpand Up@@ -569,6 +657,7 @@ mod tests {
let team = format!("scope-{}", (i + 7) % 10);
let _ = idx.resolve(&RequestContext {
model_id: &model,
mcp_server_id: "",
api_key_id: &key,
team_id: Some(&team),
});
Expand Down
2 changes: 2 additions & 0 deletions crates/aisix-proxy/src/audio.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -577,6 +577,7 @@ async fn multipart_dispatch(
// The transcript RESPONSE is scanned/masked after the upstream call.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand DownExpand Up@@ -1082,6 +1083,7 @@ async fn speech_dispatch(
// RPM slot. (Output is binary audio, not scannable text — no output hook.)
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/chat.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1232,6 +1232,7 @@ async fn dispatch(
// guardrail context.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/completions.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,7 @@ async fn dispatch(
// (matching /v1/chat/completions).
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(mcp): scan structured tool output, block as a tool error, scope guardrails per server by jarvis9443 · Pull Request #979 · api7/aisix · GitHub
Skip to content
Merged
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
18 changes: 12 additions & 6 deletions crates/aisix-core/src/models/guardrail.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -962,20 +962,26 @@ impl Resource for Guardrail {
/// Which dimension of the request a guardrail attachment is scoped to.
///
/// `Env` applies to every request in the environment. The narrower scopes let
/// operators attach a guardrail to only the models, API keys, or teams that
/// need it.
/// operators attach a guardrail to only the models, MCP servers, API keys, or
/// teams that need it.
///
/// `Model` and `McpServer` select dimensions a request carries only one of: an
/// MCP tool call resolves no model, and an LLM request routes to no MCP server.
/// A `Model`-scoped guardrail therefore never inspects MCP traffic, and an
/// `McpServer`-scoped one never inspects model traffic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GuardrailScopeType {
Env,
Model,
McpServer,
ApiKey,
Team,
}

/// Guardrail attachment that scopes one guardrail to an environment, model,
/// caller API key, or team. AISIX loads attachments with the guardrail
/// definitions and uses `scope_type` plus `scope_id` to decide which
/// MCP server, caller API key, or team. AISIX loads attachments with the
/// guardrail definitions and uses `scope_type` plus `scope_id` to decide which
/// guardrails apply to each request.
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema, PartialEq, Eq)]
pub struct GuardrailAttachment {
Expand All@@ -986,8 +992,8 @@ pub struct GuardrailAttachment {
/// What dimension of the request this attachment is scoped to.
pub scope_type: GuardrailScopeType,

/// The UUID of the specific resource (model / api_key / team).
/// `None` when `scope_type` is `Env` (applies to all requests).
/// The UUID of the specific resource (model / mcp_server / api_key /
/// team). `None` when `scope_type` is `Env` (applies to all requests).
pub scope_id: Option<String>,

/// Higher number = higher precedence. When the same guardrail appears
Expand Down
2 changes: 1 addition & 1 deletion crates/aisix-core/src/models/snapshot.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,7 @@ pub struct AisixSnapshot {
pub guardrails: ResourceTable<Guardrail>,
/// Attachment rows: `/aisix/<env>/guardrail_attachments/<uuid>`.
/// Each row binds a guardrail definition to a scope (env / model /
/// api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// mcp_server / api_key / team). `GuardrailIndex::build_from_snapshot` consumes
/// both this table and `guardrails` to build the per-request resolver.
pub guardrail_attachments: ResourceTable<GuardrailAttachment>,
/// Per-env cache policies. Stage 2 honors only the existence of an
Expand Down
64 changes: 64 additions & 0 deletions crates/aisix-guardrails/src/build.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -995,6 +995,7 @@ pub fn build_index_from_snapshot(
let scope_kind = match attachment.scope_type {
GuardrailScopeType::Env => ScopeKind::Env,
GuardrailScopeType::Model => ScopeKind::Model,
GuardrailScopeType::McpServer => ScopeKind::McpServer,
GuardrailScopeType::ApiKey => ScopeKind::ApiKey,
GuardrailScopeType::Team => ScopeKind::Team,
};
Expand DownExpand Up@@ -1887,6 +1888,7 @@ mod tests {
let index = build_index_from_snapshot(&shuffled_table(), &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -1937,6 +1939,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -1977,6 +1980,7 @@ mod tests {
// Verify the guardrail does not fire (not just that the index is empty).
let ctx = RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
};
Expand DownExpand Up@@ -2025,6 +2029,7 @@ mod tests {
);
let ctx = RequestContext {
model_id: "any",
mcp_server_id: "",
api_key_id: "any",
team_id: None,
};
Expand DownExpand Up@@ -2066,6 +2071,7 @@ mod tests {

let ctx = RequestContext {
model_id: "any-model",
mcp_server_id: "",
api_key_id: "any-key",
team_id: None,
};
Expand DownExpand Up@@ -2140,6 +2146,7 @@ mod tests {

let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2193,6 +2200,7 @@ mod tests {
let live = LiveGuardrailIndex::new(SnapshotHandle::new(AisixSnapshot::new()), None);
let chain = live.resolve(&RequestContext {
model_id: "m",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand DownExpand Up@@ -2259,6 +2267,7 @@ mod tests {
LiveGuardrailIndex::new_with_sink(SnapshotHandle::new(snap), None, Some(sink.clone()));
let ctx = RequestContext {
model_id: "m1",
mcp_server_id: "",
api_key_id: "k1",
team_id: None,
};
Expand DownExpand Up@@ -2481,6 +2490,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand All@@ -2495,6 +2505,59 @@ mod tests {
);
}

#[tokio::test]
async fn mcp_server_attachment_builds_into_the_index() {
// The wire `scope_type: "mcp_server"` survives the snapshot build and
// selects on the called server, leaving model traffic alone.
let guardrails: ResourceTable<DomainGuardrail> = ResourceTable::default();
guardrails.insert(entry(
"kw",
"g-1",
parse(
r#"{
"name": "kw",
"kind": "keyword",
"hook_point": "input",
"patterns": [{ "kind": "literal", "value": "AKIA" }]
}"#,
),
));
let attachments: ResourceTable<GuardrailAttachment> = ResourceTable::default();
attachments.insert(attachment_entry(
"a-mcp",
parse_attachment(
r#"{ "guardrail_id": "g-1", "scope_type": "mcp_server", "scope_id": "mcp-A", "priority": 50 }"#,
),
));

let index = build_index_from_snapshot(&guardrails, &attachments, None);
assert_eq!(index.len(), 1, "the attachment must not be skipped");

let matched = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k",
team_id: None,
});
assert_eq!(matched.len(), 1);

let other_server = index.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-B",
api_key_id: "k",
team_id: None,
});
assert!(other_server.is_empty());

let llm = index.resolve(&RequestContext {
model_id: "m-A",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
assert!(llm.is_empty(), "model traffic carries no MCP server");
}

#[tokio::test]
async fn resolved_chain_applied_empty_when_no_attachment_matches() {
// A model-scoped attachment that doesn't match the request resolves to
Expand DownExpand Up@@ -2524,6 +2587,7 @@ mod tests {
let index = build_index_from_snapshot(&guardrails, &attachments, None);
let chain = index.resolve(&RequestContext {
model_id: "m-OTHER",
mcp_server_id: "",
api_key_id: "k",
team_id: None,
});
Expand Down
103 changes: 96 additions & 7 deletions crates/aisix-guardrails/src/index.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,10 +8,11 @@
//!
//! | `scope_type` | meaning |
//! |---|---|
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//! | `env` | applies to every request in the environment |
//! | `model` | applies only when the request targets this model UUID |
//! | `mcp_server` | applies only to MCP tool calls routed to this server UUID |
//! | `api_key` | applies only when authenticated with this API-key UUID |
//! | `team` | applies only when the API key belongs to this team UUID |
//!
//! `GuardrailIndex` holds the pre-built runtime guardrails for a snapshot.
//! `resolve(ctx)` filters + deduplicates the entries and returns the chain
Expand All@@ -37,6 +38,7 @@ use crate::{Guardrail, GuardrailChain};
pub enum ScopeKind {
Env,
Model,
McpServer,
ApiKey,
Team,
}
Expand DownExpand Up@@ -79,7 +81,13 @@ impl IndexEntry {
fn applies_to(&self, ctx: &RequestContext<'_>) -> bool {
match self.scope_kind {
ScopeKind::Env => true,
ScopeKind::Model => self.scope_id.as_deref() == Some(ctx.model_id),
// `model_id` / `mcp_server_id` are empty on the requests that have
// no such identity (an MCP tool call resolves no model; an LLM
// request routes to no MCP server), so an attachment whose
// `scope_id` is itself empty must NOT match everything that lacks
// the dimension — compare only non-empty ids.
ScopeKind::Model => matches_id(self.scope_id.as_deref(), ctx.model_id),
ScopeKind::McpServer => matches_id(self.scope_id.as_deref(), ctx.mcp_server_id),
ScopeKind::ApiKey => self.scope_id.as_deref() == Some(ctx.api_key_id),
ScopeKind::Team => ctx
.team_id
Expand All@@ -90,12 +98,22 @@ impl IndexEntry {
}
}

/// Equality for a scope dimension a request may not carry at all: an empty
/// id on either side never matches.
fn matches_id(scope_id: Option<&str>, ctx_id: &str) -> bool {
!ctx_id.is_empty() && scope_id == Some(ctx_id)
}

/// Per-request context used by [`GuardrailIndex::resolve`] to select and
/// deduplicate the applicable guardrails.
#[derive(Debug, Clone, Copy)]
pub struct RequestContext<'a> {
/// UUID of the model the request targets (virtual or concrete).
/// Empty for a request that resolves no model, such as an MCP tool call.
pub model_id: &'a str,
/// UUID of the registered MCP server an MCP tool call is routed to.
/// Empty for every request that is not an MCP tool call.
pub mcp_server_id: &'a str,
/// UUID of the API key used to authenticate the request.
pub api_key_id: &'a str,
/// UUID of the team the API key belongs to. `None` if the key is not
Expand All@@ -115,12 +133,15 @@ pub struct GuardrailIndex {
}

/// Scope specificity rank: higher = more specific → wins dedup on equal priority.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379.
/// ApiKey > Team > Model > Env, matching the P0c spec in #379. `McpServer`
/// shares `Model`'s rank: the two dimensions are mutually exclusive within a
/// request (an MCP tool call resolves no model and an LLM request routes to no
/// MCP server), so their relative order can never decide a deduplication.
fn scope_specificity(k: &ScopeKind) -> u8 {
match k {
ScopeKind::ApiKey => 3,
ScopeKind::Team => 2,
ScopeKind::Model => 1,
ScopeKind::Model | ScopeKind::McpServer => 1,
ScopeKind::Env => 0,
}
}
Expand DownExpand Up@@ -231,6 +252,7 @@ mod tests {
fn ctx<'a>(model: &'a str, apikey: &'a str, team: Option<&'a str>) -> RequestContext<'a> {
RequestContext {
model_id: model,
mcp_server_id: "",
api_key_id: apikey,
team_id: team,
}
Expand DownExpand Up@@ -309,6 +331,72 @@ mod tests {
);
}

// 3b. McpServer-scope attachment applies only to tool calls routed to
// that server — and never to model traffic, which carries no server id.
#[tokio::test]
async fn mcp_server_scope_only_matching_server() {
let g = kw("g1", "secret");
let idx = GuardrailIndex::from_entries(vec![entry(
"g1",
ScopeKind::McpServer,
Some("mcp-A"),
50,
g,
)]);

let mcp_ctx = |server: &'static str| RequestContext {
model_id: "",
mcp_server_id: server,
api_key_id: "k1",
team_id: None,
};

let chain_a = idx.resolve(&mcp_ctx("mcp-A"));
assert!(chain_a.check_input(&req("secret")).await.is_block());

let chain_b = idx.resolve(&mcp_ctx("mcp-B"));
assert_eq!(
chain_b.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

// An LLM request carries no MCP server, so the attachment is inert.
let chain_llm = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
chain_llm.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 3c. A dimension a request does not carry never matches, even against an
// attachment whose own scope_id is empty — the sentinel for "absent" must
// not read as a wildcard.
#[tokio::test]
async fn empty_scope_id_never_matches_an_absent_dimension() {
let idx = GuardrailIndex::from_entries(vec![
entry("g1", ScopeKind::McpServer, Some(""), 50, kw("g1", "secret")),
entry("g2", ScopeKind::Model, Some(""), 50, kw("g2", "secret")),
]);

// An MCP tool call has no model; an LLM request has no MCP server.
let mcp_chain = idx.resolve(&RequestContext {
model_id: "",
mcp_server_id: "mcp-A",
api_key_id: "k1",
team_id: None,
});
assert_eq!(
mcp_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);

let llm_chain = idx.resolve(&ctx("model-A", "k1", None));
assert_eq!(
llm_chain.check_input(&req("secret")).await,
GuardrailVerdict::Allow
);
}

// 4. ApiKey-scope attachment applies only to the matching key.
#[tokio::test]
async fn api_key_scope_only_matching_key() {
Expand DownExpand Up@@ -569,6 +657,7 @@ mod tests {
let team = format!("scope-{}", (i + 7) % 10);
let _ = idx.resolve(&RequestContext {
model_id: &model,
mcp_server_id: "",
api_key_id: &key,
team_id: Some(&team),
});
Expand Down
2 changes: 2 additions & 0 deletions crates/aisix-proxy/src/audio.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -577,6 +577,7 @@ async fn multipart_dispatch(
// The transcript RESPONSE is scanned/masked after the upstream call.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand DownExpand Up@@ -1082,6 +1083,7 @@ async fn speech_dispatch(
// RPM slot. (Output is binary audio, not scannable text — no output hook.)
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/chat.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -1232,6 +1232,7 @@ async fn dispatch(
// guardrail context.
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
1 change: 1 addition & 0 deletions crates/aisix-proxy/src/completions.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -294,6 +294,7 @@ async fn dispatch(
// (matching /v1/chat/completions).
let guardrail_ctx = aisix_guardrails::RequestContext {
model_id: &model_entry.id,
mcp_server_id: "",
api_key_id: &auth.entry.id,
team_id: auth.key().team_id.as_deref(),
};
Expand Down
Loading