Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9cdca55
feat(agent_tools): add McpCallOutcome and bump contract to 1.3 (#40)
oxoxDev Oct 6, 2026
0858553
feat(tools): attach McpCallOutcome metadata to mcp_call_tool (#40)
oxoxDev Oct 6, 2026
e6271d2
feat(tools): strip markdown fences from bridge identifiers (#40)
oxoxDev Oct 6, 2026
a8d0833
feat(tools): add SecretScrubber::with_secrets (#40)
oxoxDev Oct 6, 2026
d3eabe6
feat(oauth): public OAuthBundle and guarded OAuthFlow::refresh (#40)
oxoxDev Oct 6, 2026
cf2e0fb
feat(config_doc): parse_with host extension and render_declared (#40)
oxoxDev Oct 6, 2026
bae14c4
docs: describe the call outcome and the mcp.json host extension (#40)
oxoxDev Oct 6, 2026
8fb9af9
test(config_doc): compare empty collections with assert_eq for clippy…
oxoxDev Oct 6, 2026
e62c5fa
fix(oauth): redact secrets from OAuthBundle Debug (#40)
oxoxDev Oct 6, 2026
9d5399f
fix(agent_tools): validate McpCallOutcome kind and ok/error pairing o…
oxoxDev Oct 6, 2026
c1e3b14
fix(tools): strip only fence characters from bridge identifiers (#40)
oxoxDev Oct 6, 2026
586c4e2
fix(tools): report caller-named server and tool in call outcomes unsc…
oxoxDev Oct 6, 2026
28b5d17
fix(tools): redact host-injected secrets as strict credentials (#40)
oxoxDev Oct 6, 2026
9b90a77
fix(lib): keep McpAuthChallenge in the crate-root re-exports (#40)
oxoxDev Oct 6, 2026
05a94ad
fix(oauth): drop a refresh superseded by a newer sign-in (#40)
oxoxDev Oct 6, 2026
edcb972
docs: qualify the call-outcome guarantee and document identifier hand…
oxoxDev Oct 6, 2026
2719f88
fix(tools): redact secrets from SecretScrubber Debug (#40)
oxoxDev Oct 6, 2026
60db2d0
fix(agent_tools): reject advertises_oauth without unauthorized on dec…
oxoxDev Oct 6, 2026
c9e542e
fix(oauth): send no refresh request without a refresh token (#40)
oxoxDev Oct 6, 2026
621ab2b
feat(oauth): let the host name the client it registers (#40)
oxoxDev Oct 6, 2026
8b92a99
docs(readme): describe OAuthFlow::with_client_name (#40)
oxoxDev Oct 6, 2026
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
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,16 +179,51 @@ Enable the `tools` feature to expose each server tool as a
- **The generic bridge** is three tools over one configured-server registry:
`McpListServersTool`, `McpListToolsTool` and `McpCallTool`
(`mcp_list_servers`, `mcp_list_tools`, `mcp_call_tool`). `McpCallTool` asks a
host-supplied `ActGate` before it sends anything.
host-supplied `ActGate` before it sends anything. Server and tool names lose
the backticks and asterisks a model wraps them in (`` `docs` ``, `**docs**`,
`` `docs`. ``); `_`, `.` and other characters are kept.
- **The call outcome.** Every `mcp_call_tool` result for a call the act gate
allowed and that names a server, a tool and `arguments` carries a
`tinymcp_bus::McpCallOutcome` in `ToolResult::metadata`, with
`kind` set to `MCP_CALL_RESULT_KIND` (`"mcp_call"`): the server and tool,
`ok` when the server answered, and otherwise the error's wire name and
whether it was a 401 that advertised OAuth. It is host-only — never rendered
to the model — so a host meters answered calls and surfaces failures from it
instead of parsing text. A call missing one of those fields, or refused by
the gate, fails before a result exists and carries no outcome. The server and
tool are reported as the caller named them, unscrubbed. Read it back with
`McpCallOutcome::from_metadata`; decoding rejects a wrong `kind` or an `ok`
that disagrees with `error`.
- **`SecretScrubber`** removes a server's own credentials (tokens, basic-auth
pairs, header and query values, URL userinfo) from whatever it echoes back;
every bridge tool applies it, and a host wraps its own invokers with it.
`with_secrets` adds values the host keeps outside the server's config, each
redacted as a strict credential: anywhere in the text, even when short.
- **`tool_result`** maps a rendered MCP result onto `tinytools::ToolResult`,
bounding oversized blocks.

`tinytools` is a git dependency so a host that links another checkout of it can
`[patch]` the two into one package.

## `mcp.json` and OAuth for hosts with their own store

`registry::config_doc` reads and writes the `{ "mcpServers": { … } }` document.
`parse` and `render` are the install store's reading and projection.
A host that keeps declarations itself uses `parse_with` and `render_declared`
instead: `parse_with` also reads `allowedTools`, `disallowedTools` and
`timeoutSecs`, carries the fields the host registers in
`ParseOptions::host_fields` through verbatim in `Declared::host_fields`, and
with `lenient` set drops a bad entry into `ParseReport::rejected` rather than
refusing the document. Credentials are write-only in both.

`registry::OAuthFlow::refresh` refreshes an expired token from a host's
`OAuthCredentialStore`, re-checking the token endpoint when
`require_public_endpoints()` is set; the free `refresh_if_expired` does not
check. `registry::OAuthBundle` is the stored refresh bundle's shape.
`OAuthFlow::with_client_name` sets the name dynamic registration sends, which
the authorization server shows on its consent screen; it defaults to
`DEFAULT_CLIENT_NAME` (`TinyMCP`).

## Static linking

Enable the `static-link` feature when compiling this module into a Rust host. It
Expand Down
4 changes: 4 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ out of scope. A roadmap that lists everything is a roadmap nobody trusts.
supply-chain checks
- a manual release workflow that versions, tags, publishes to crates.io, and
creates a GitHub release with crate and TinyBus runtime/module assets
- a structured `mcp_call` outcome on every bridge call that reaches a result,
for host metering and failure surfacing (contract 1.3)
- `mcp.json` reading for hosts with their own store (`parse_with`), and a
guarded OAuth refresh on the flow

## Next

Expand Down
2 changes: 1 addition & 1 deletion crates/tinymcp-bus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ vocabulary has to be published as an ordinary library. This is it.
| ---------- | ------------------------------------------------------------ |
| `names` | interface name, object path, one constant per member |
| `greeting` | the value vocabulary: the `Greet` request and response |
| `agent_tools` | tool specs a host exposes to a model, and argument normalization |
| `agent_tools` | tool specs a host exposes to a model, argument normalization, and the structured call outcome |
| `version` | `CONTRACT_VERSION` and the bind rule a host applies to it |

Two dependencies, both pure Rust: `serde` and `serde_json`.
Expand Down
9 changes: 8 additions & 1 deletion crates/tinymcp-bus/src/agent_tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@
//! `arguments` as the object MCP requires, and every path that forwards a call
//! reads them through it.
//!
//! [`McpCallOutcome`] is what a forwarded call reports back to the host, beside
//! the text the model reads: which server and tool, whether the server
//! answered, and the classified error when it did not. It travels as a tool
//! result's metadata tagged with [`MCP_CALL_RESULT_KIND`].
//!
//! # Why this is in the contract crate
//!
//! A tool's name, description and schema are prompt-cache and transcript
Expand All @@ -31,7 +36,9 @@ mod types;

pub use arguments::normalize_tool_arguments;
pub use registry_tools::{RegistryTool, registry_tool_specs};
pub use types::{AgentToolEffect, AgentToolSpec, ArgsError};
pub use types::{
AgentToolEffect, AgentToolSpec, ArgsError, MCP_CALL_RESULT_KIND, McpCallError, McpCallOutcome,
};

#[cfg(test)]
#[path = "mod_tests.rs"]
Expand Down
143 changes: 143 additions & 0 deletions crates/tinymcp-bus/src/agent_tools/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -309,3 +309,146 @@ fn effects_serialize_in_snake_case() {
assert_eq!(serde_json::to_value(effect).unwrap(), json!(wire));
}
}

// ---------------------------------------------------------------------------
// Call outcome
// ---------------------------------------------------------------------------

#[test]
fn the_call_outcome_kind_is_pinned() {
assert_eq!(super::MCP_CALL_RESULT_KIND, "mcp_call");
}

#[test]
fn an_answered_outcome_pins_its_wire_form_and_omits_the_error() {
let outcome = super::McpCallOutcome::answered("docs", "search");
let wire = serde_json::to_value(&outcome).unwrap();
assert_eq!(
wire,
json!({ "kind": "mcp_call", "server": "docs", "tool": "search", "ok": true })
);
assert_eq!(
serde_json::from_value::<super::McpCallOutcome>(wire).unwrap(),
outcome
);
}

#[test]
fn a_failed_outcome_pins_its_wire_form_and_round_trips() {
let outcome = super::McpCallOutcome::failed(
"docs",
"search",
super::McpCallError {
code: crate::errors::UNAUTHORIZED.into(),
unauthorized: true,
advertises_oauth: true,
},
);
let wire = serde_json::to_value(&outcome).unwrap();
assert_eq!(
wire,
json!({
"kind": "mcp_call",
"server": "docs",
"tool": "search",
"ok": false,
"error": {
"code": "ai.tinyhumans.tinymcp.Error.Unauthorized",
"unauthorized": true,
"advertises_oauth": true,
},
})
);
assert_eq!(
serde_json::from_value::<super::McpCallOutcome>(wire).unwrap(),
outcome
);
}

#[test]
fn a_plain_error_carries_no_authorization_signal() {
let error = super::McpCallError::new(crate::errors::TRANSPORT);
assert_eq!(error.code, crate::errors::TRANSPORT);
assert!(!error.unauthorized);
assert!(!error.advertises_oauth);
}

#[test]
fn an_outcome_is_read_back_only_from_metadata_of_its_kind() {
let outcome = super::McpCallOutcome::answered("docs", "search");
let metadata = serde_json::to_value(&outcome).unwrap();
assert_eq!(
super::McpCallOutcome::from_metadata(&metadata),
Some(outcome)
);

assert_eq!(
super::McpCallOutcome::from_metadata(&json!({ "kind": "web_search" })),
None
);
assert_eq!(super::McpCallOutcome::from_metadata(&json!([1, 2])), None);
assert_eq!(
super::McpCallOutcome::from_metadata(&json!({ "kind": "mcp_call", "ok": "yes" })),
None
);
}

#[test]
fn decoding_an_outcome_rejects_what_the_constructors_never_produce() {
let decode = |value: serde_json::Value| serde_json::from_value::<super::McpCallOutcome>(value);
let error = json!({ "code": crate::errors::TRANSPORT, "unauthorized": false, "advertises_oauth": false });
let base =
|ok: bool| json!({ "kind": "mcp_call", "server": "docs", "tool": "search", "ok": ok });

let mut answered_with_error = base(true);
answered_with_error["error"] = error.clone();
assert!(decode(answered_with_error).is_err());

assert!(decode(base(false)).is_err());
let mut failed_null_error = base(false);
failed_null_error["error"] = serde_json::Value::Null;
assert!(decode(failed_null_error).is_err());

let mut missing_kind = base(true);
missing_kind.as_object_mut().unwrap().remove("kind");
assert!(decode(missing_kind).is_err());
let mut wrong_kind = base(true);
wrong_kind["kind"] = json!("web_search");
assert!(decode(wrong_kind).is_err());

assert_eq!(
decode(base(true)).unwrap(),
super::McpCallOutcome::answered("docs", "search")
);
let mut failed = base(false);
failed["error"] = error;
assert_eq!(
decode(failed).unwrap(),
super::McpCallOutcome::failed(
"docs",
"search",
super::McpCallError::new(crate::errors::TRANSPORT)
)
);
}

#[test]
fn decoding_an_error_rejects_an_oauth_advert_without_a_401() {
let decode = |unauthorized: bool, advertises_oauth: bool| {
serde_json::from_value::<super::McpCallError>(json!({
"code": "x",
"unauthorized": unauthorized,
"advertises_oauth": advertises_oauth,
}))
};
assert!(decode(false, true).is_err());
assert!(decode(false, false).is_ok());
assert!(decode(true, false).is_ok());
assert!(decode(true, true).is_ok());

let nested = json!({
"kind": "mcp_call", "server": "docs", "tool": "t", "ok": false,
"error": { "code": "x", "unauthorized": false, "advertises_oauth": true },
});
assert_eq!(super::McpCallOutcome::from_metadata(&nested), None);
}
Loading
Loading