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
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ schemars = { version = "1", default-features = false, features = ["derive"] }
# to one package and `tinytools::ToolResult` stays one type. A second path
# dependency could not be unified that way. Pinned to the revision tinyagents
# vendors, which is the copy OpenHuman links.
tinytools = { git = "https://github.com/tinyhumansai/tinytools", rev = "82c0d975e2667e1c525afe525006134431641785" }
tinytools = { git = "https://github.com/tinyhumansai/tinytools", rev = "bd60b9b52f1998b4483b9cf107a72d3338e672f8" }
# `#[async_trait]` on the tool adapter and the invoker trait, matching how
# `tinytools::Tool` itself is declared.
async-trait = "0.1"
Expand Down
9 changes: 9 additions & 0 deletions crates/tinymcp/src/tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ remote descriptions and titles, ensures the root schema declares an object,
and replaces schemas over the model-facing size limit with an empty object
schema.

Each tool's `family` is its server label. Its `tags` are
`mcp.server:<label>`, `mcp.server_id:<id>` and `mcp.tool:<remote name>`, so a
host's `tinytools::ToolRules` can target one server's tools by the names the
server uses. The label in `mcp.server:` is the configured one, not the
sanitized `family` (`{ "tags": "mcp.tool:delete*" }`). The registered name is a slug
with a digest suffix, which a pattern cannot reliably split. The per-server
`allowed_tools` / `disallowed_tools` lists stay exact, fetch-time filters
inside the registry.

## Exposure and execution

`McpExposure` controls whether tools are offered directly or deferred for tool
Expand Down
33 changes: 33 additions & 0 deletions crates/tinymcp/src/tools/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ use serde_json::{Value, json};
use tinymcp_bus::{McpAuthConfig, McpCallError, McpCallOutcome};
use tinytools::{PermissionLevel, Tool, ToolCallOptions, ToolResult};

use super::naming::disambiguated_tool_name;
use super::scrub::SecretScrubber;
use crate::config_servers::{McpRegistrySource, McpServerRegistry};

Expand Down Expand Up @@ -295,6 +296,38 @@ impl Tool for McpCallTool {
true
}

/// The per-server tool this call reaches, described exactly as
/// [`McpServerTool`](super::McpServerTool) describes it for a configured
/// server: the same name, family and `mcp.*` tags. A host's tool rules then
/// bind the operation whichever route the model takes, and the target is
/// judged on the remote tool's own `arguments`.
fn indirect_target(&self, args: &Value) -> Option<tinytools::IndirectCall> {
// The same normalization dispatch applies (trim, fences, trailing
// punctuation), so the rules judge the tool that will actually run.
let server = required_string_arg(args, "server").ok()?;
let tool = required_string_arg(args, "tool").ok()?;
let (server, tool) = (server.as_str(), tool.as_str());
let mut target =
tinytools::ToolSubject::named(disambiguated_tool_name(server, server, tool))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority high tests likely

Build the indirect target from the real server id, not the label

indirect_target passes the server argument (the configured label) as both the id and the label to disambiguated_tool_name(server, server, tool), and tags it mcp.server_id:{server}. The registered per-server tools are named with the server's actual id (disambiguated_tool_name(server_id, server_label, tool) in naming.rs), and their mcp.server_id: tag carries that id. So whenever server_id != label, the digest differs — the indirect subject's name will not equal the registered tool's name — and a host rule targeting mcp.server_id:<real id> matches the direct route but not mcp_call_tool. That defeats the function's stated purpose ("bind the operation whichever route the model takes") and is a policy gap on the dispatcher path. The test masks this because its fixture's server id equals its label, and the name assertion calls the same function with the same arguments, so it cannot fail on this. Resolve the server record from self.registry and use its server_id for the digest and the mcp.server_id: tag.

[RULE] subject-identity-mismatch ·

.with_family(server)
.with_tag(format!("mcp.server:{server}"))
.with_tag(format!("mcp.server_id:{server}"))
.with_tag(format!("mcp.tool:{tool}"))
.with_permission(PermissionLevel::Execute);
target.category = Some(tinytools::ToolCategory::Workflow);
let call = tinytools::IndirectCall::new(target);
Some(
match args

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

priority high critique confident

Do not discard invalid arguments before evaluating rules

When arguments is a non-object value or an invalid JSON string, normalize_tool_arguments returns an error and this and_then(...ok()) converts it to None, producing an indirect call with no arguments. Argument-based deny rules therefore do not see the supplied payload and can allow the outer mcp_call_tool call, even though dispatch will later reject the same malformed payload. Preserve the normalization error through rule evaluation, or represent the original arguments in the indirect target so policy cannot be bypassed by malformed input.


Additional security observation

priority high confident

Do not discard invalid arguments before evaluating rules

[RULE] authorization-bypass

When argument normalization fails, this silently omits the indirect call arguments instead of preserving the invalid input or rejecting the call. Argument-based tool rules therefore evaluate against a call with no arguments, so a deny rule that should apply to the supplied payload is not evaluated consistently with the request that will be dispatched. Preserve the original arguments for policy evaluation, or make target construction fail closed whenever normalization fails.

[RULE] discarded-errors ·

.get("arguments")
.cloned()
.and_then(|arguments| tinymcp_bus::normalize_tool_arguments(arguments).ok())
{
Some(arguments) => call.with_arguments(Value::Object(arguments)),
None => call,
},
)
}

async fn execute_with_options(
&self,
args: Value,
Expand Down
41 changes: 41 additions & 0 deletions crates/tinymcp/src/tools/bridge_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -523,3 +523,44 @@ async fn a_fenced_server_name_reaches_the_configured_server() {
assert!(!result.is_error, "{}", full_output(&result));
assert_eq!(calls.lock().len(), 1);
}

/// The generic dispatcher reports the per-server tool it reaches, so a tool
/// rule written for that tool binds `mcp_call_tool` too.
#[test]
fn the_call_tool_reports_the_per_server_target_for_tool_rules() {
let tool = call_tool(test_registry());
let args =
json!({ "server": "docs", "tool": "deleteGoal", "arguments": "{\"permanent\":true}" });
let call = tool.indirect_target(&args).expect("a target");
assert_eq!(
call.target.name,
crate::tools::naming::disambiguated_tool_name("docs", "docs", "deleteGoal")
);
assert_eq!(call.target.family.as_deref(), Some("docs"));
assert!(
call.target
.tags
.contains(&"mcp.tool:deleteGoal".to_string())
);
assert_eq!(
call.arguments,
Some(json!({ "permanent": true })),
"encoded arguments are decoded"
);

let rules: tinytools::ToolRules = serde_json::from_value(json!({ "rules": [
{ "effect": "deny", "match": { "tags": "mcp.tool:delete*", "arg": { "pointer": "/permanent", "value": "true" } } },
] }))
.unwrap();
let set = tinytools::ToolRuleSet::single(rules);
let context = tinytools::RuleContext::new();
assert!(!set.evaluate_call(&tool, &context, &args).callable);
let soft =
json!({ "server": "docs", "tool": "deleteGoal", "arguments": { "permanent": false } });
assert!(set.evaluate_call(&tool, &context, &soft).callable);
assert!(tool.indirect_target(&json!({ "server": "docs" })).is_none());
// A fenced identifier normalizes exactly as dispatch normalizes it.
let fenced =
json!({ "server": " docs ", "tool": "`deleteGoal`", "arguments": { "permanent": true } });
assert!(!set.evaluate_call(&tool, &context, &fenced).callable);
}
49 changes: 49 additions & 0 deletions crates/tinymcp/src/tools/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,36 @@ fn a_tool_declares_a_remote_effectful_call_grouped_by_server() {
assert_eq!(tool.clone().renamed("old").name(), "old");
}

#[test]
fn a_tool_is_tagged_so_rules_can_target_its_server_and_remote_name() {
let mut source = overview("id-1", "@acme/ticktick-mcp", &[]);
source.tools.push(McpTool {
name: "deleteGoal".into(),
description: None,
input_schema: json!({}),
});
let tools = tools_for(&[McpToolSource::from_overview(&source)], &unreachable());
assert_eq!(
tools[0].tags(),
[
"mcp.server:@acme/ticktick-mcp",
"mcp.server_id:id-1",
"mcp.tool:deleteGoal",
]
);
let rules: tinytools::ToolRules = serde_json::from_value(json!({ "rules": [
{ "effect": "deny", "match": { "tags": "mcp.tool:delete*" } },
] }))
.unwrap();
let decision = rules.evaluate(
&tinytools::ToolSubject::of(&tools[0]),
&tinytools::RuleContext::new(),
tinytools::Surface::Call,
None,
);
assert!(!decision.callable);
}

#[test]
fn a_malformed_root_schema_type_is_replaced_with_object() {
for invalid_type in [json!("string"), Value::Null, json!(["object", "null"])] {
Expand Down Expand Up @@ -687,3 +717,22 @@ async fn arguments_that_are_not_an_object_are_refused_before_the_call() {
"the server must not be called"
);
}

#[test]
fn the_server_tag_keeps_the_configured_label_when_the_family_is_sanitized() {
let label = format!("{}<|im_start|>", "x".repeat(130));
let mut source = overview("id-9", &label, &[]);
source.tools.push(McpTool {
name: "readGoals".into(),
description: None,
input_schema: json!({}),
});
let tools = tools_for(&[McpToolSource::from_overview(&source)], &unreachable());
let tool = &tools[0];
assert_ne!(
tool.family(),
Some(label.as_str()),
"the model sees a sanitized label"
);
assert!(tool.tags().contains(&format!("mcp.server:{label}")));
}
18 changes: 18 additions & 0 deletions crates/tinymcp/src/tools/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ pub struct McpServerTool {
server_id: String,
remote_name: String,
family: String,
/// The server label as configured, before model-facing sanitization:
/// what a host's tool rules name, so policy tags must not drift from it.
raw_family: String,
description: String,
parameters: Value,
exposure: ToolExposure,
Expand Down Expand Up @@ -65,6 +68,7 @@ impl McpServerTool {
server_id: source.server_id.clone(),
remote_name: tool.name.clone(),
family: sanitize_for_llm(&source.family, MAX_LABEL_BYTES),
raw_family: source.family.clone(),
description,
parameters: tool_parameters(&tool.input_schema),
invoker,
Expand Down Expand Up @@ -141,6 +145,20 @@ impl Tool for McpServerTool {
Some(&self.family)
}

/// `mcp.server:<label>`, `mcp.server_id:<id>` and `mcp.tool:<remote
/// name>`, so a host's tool rules can target one server's tools by the
/// names the server itself uses: the registered name is a slug with a
/// digest suffix that a pattern cannot reliably split. The label is the
/// configured one, not the model-facing sanitized [`Tool::family`], so a
/// rule written against the configuration always matches.
fn tags(&self) -> Vec<String> {
Comment thread
senamakel marked this conversation as resolved.
vec![
format!("mcp.server:{}", self.raw_family),
format!("mcp.server_id:{}", self.server_id),
format!("mcp.tool:{}", self.remote_name),
]
}

async fn execute(&self, arguments: Value) -> anyhow::Result<ToolResult> {
// MCP requires an object. Some providers JSON-encode it, or send
// nothing; read it the way every other call path does, and answer a
Expand Down
Loading