Skip to content

Commit ed44f2b

Browse files
authored
Merge pull request #41 from oxoxDev/feat/40-call-outcome
feat: structured MCP call outcome, guarded OAuth refresh and mcp.json host extension (#40)
2 parents 4fa4f62 + 8b92a99 commit ed44f2b

28 files changed

Lines changed: 1896 additions & 88 deletions

‎README.md‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,16 +179,51 @@ Enable the `tools` feature to expose each server tool as a
179179
- **The generic bridge** is three tools over one configured-server registry:
180180
`McpListServersTool`, `McpListToolsTool` and `McpCallTool`
181181
(`mcp_list_servers`, `mcp_list_tools`, `mcp_call_tool`). `McpCallTool` asks a
182-
host-supplied `ActGate` before it sends anything.
182+
host-supplied `ActGate` before it sends anything. Server and tool names lose
183+
the backticks and asterisks a model wraps them in (`` `docs` ``, `**docs**`,
184+
`` `docs`. ``); `_`, `.` and other characters are kept.
185+
- **The call outcome.** Every `mcp_call_tool` result for a call the act gate
186+
allowed and that names a server, a tool and `arguments` carries a
187+
`tinymcp_bus::McpCallOutcome` in `ToolResult::metadata`, with
188+
`kind` set to `MCP_CALL_RESULT_KIND` (`"mcp_call"`): the server and tool,
189+
`ok` when the server answered, and otherwise the error's wire name and
190+
whether it was a 401 that advertised OAuth. It is host-only — never rendered
191+
to the model — so a host meters answered calls and surfaces failures from it
192+
instead of parsing text. A call missing one of those fields, or refused by
193+
the gate, fails before a result exists and carries no outcome. The server and
194+
tool are reported as the caller named them, unscrubbed. Read it back with
195+
`McpCallOutcome::from_metadata`; decoding rejects a wrong `kind` or an `ok`
196+
that disagrees with `error`.
183197
- **`SecretScrubber`** removes a server's own credentials (tokens, basic-auth
184198
pairs, header and query values, URL userinfo) from whatever it echoes back;
185199
every bridge tool applies it, and a host wraps its own invokers with it.
200+
`with_secrets` adds values the host keeps outside the server's config, each
201+
redacted as a strict credential: anywhere in the text, even when short.
186202
- **`tool_result`** maps a rendered MCP result onto `tinytools::ToolResult`,
187203
bounding oversized blocks.
188204

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

208+
## `mcp.json` and OAuth for hosts with their own store
209+
210+
`registry::config_doc` reads and writes the `{ "mcpServers": { … } }` document.
211+
`parse` and `render` are the install store's reading and projection.
212+
A host that keeps declarations itself uses `parse_with` and `render_declared`
213+
instead: `parse_with` also reads `allowedTools`, `disallowedTools` and
214+
`timeoutSecs`, carries the fields the host registers in
215+
`ParseOptions::host_fields` through verbatim in `Declared::host_fields`, and
216+
with `lenient` set drops a bad entry into `ParseReport::rejected` rather than
217+
refusing the document. Credentials are write-only in both.
218+
219+
`registry::OAuthFlow::refresh` refreshes an expired token from a host's
220+
`OAuthCredentialStore`, re-checking the token endpoint when
221+
`require_public_endpoints()` is set; the free `refresh_if_expired` does not
222+
check. `registry::OAuthBundle` is the stored refresh bundle's shape.
223+
`OAuthFlow::with_client_name` sets the name dynamic registration sends, which
224+
the authorization server shows on its consent screen; it defaults to
225+
`DEFAULT_CLIENT_NAME` (`TinyMCP`).
226+
192227
## Static linking
193228

194229
Enable the `static-link` feature when compiling this module into a Rust host. It

‎ROADMAP.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ out of scope. A roadmap that lists everything is a roadmap nobody trusts.
1414
supply-chain checks
1515
- a manual release workflow that versions, tags, publishes to crates.io, and
1616
creates a GitHub release with crate and TinyBus runtime/module assets
17+
- a structured `mcp_call` outcome on every bridge call that reaches a result,
18+
for host metering and failure surfacing (contract 1.3)
19+
- `mcp.json` reading for hosts with their own store (`parse_with`), and a
20+
guarded OAuth refresh on the flow
1721

1822
## Next
1923

‎crates/tinymcp-bus/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ vocabulary has to be published as an ordinary library. This is it.
1212
| ---------- | ------------------------------------------------------------ |
1313
| `names` | interface name, object path, one constant per member |
1414
| `greeting` | the value vocabulary: the `Greet` request and response |
15-
| `agent_tools` | tool specs a host exposes to a model, and argument normalization |
15+
| `agent_tools` | tool specs a host exposes to a model, argument normalization, and the structured call outcome |
1616
| `version` | `CONTRACT_VERSION` and the bind rule a host applies to it |
1717

1818
Two dependencies, both pure Rust: `serde` and `serde_json`.

‎crates/tinymcp-bus/src/agent_tools/mod.rs‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@
1111
//! `arguments` as the object MCP requires, and every path that forwards a call
1212
//! reads them through it.
1313
//!
14+
//! [`McpCallOutcome`] is what a forwarded call reports back to the host, beside
15+
//! the text the model reads: which server and tool, whether the server
16+
//! answered, and the classified error when it did not. It travels as a tool
17+
//! result's metadata tagged with [`MCP_CALL_RESULT_KIND`].
18+
//!
1419
//! # Why this is in the contract crate
1520
//!
1621
//! A tool's name, description and schema are prompt-cache and transcript
@@ -31,7 +36,9 @@ mod types;
3136

3237
pub use arguments::normalize_tool_arguments;
3338
pub use registry_tools::{RegistryTool, registry_tool_specs};
34-
pub use types::{AgentToolEffect, AgentToolSpec, ArgsError};
39+
pub use types::{
40+
AgentToolEffect, AgentToolSpec, ArgsError, MCP_CALL_RESULT_KIND, McpCallError, McpCallOutcome,
41+
};
3542

3643
#[cfg(test)]
3744
#[path = "mod_tests.rs"]

‎crates/tinymcp-bus/src/agent_tools/mod_tests.rs‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,3 +309,146 @@ fn effects_serialize_in_snake_case() {
309309
assert_eq!(serde_json::to_value(effect).unwrap(), json!(wire));
310310
}
311311
}
312+
313+
// ---------------------------------------------------------------------------
314+
// Call outcome
315+
// ---------------------------------------------------------------------------
316+
317+
#[test]
318+
fn the_call_outcome_kind_is_pinned() {
319+
assert_eq!(super::MCP_CALL_RESULT_KIND, "mcp_call");
320+
}
321+
322+
#[test]
323+
fn an_answered_outcome_pins_its_wire_form_and_omits_the_error() {
324+
let outcome = super::McpCallOutcome::answered("docs", "search");
325+
let wire = serde_json::to_value(&outcome).unwrap();
326+
assert_eq!(
327+
wire,
328+
json!({ "kind": "mcp_call", "server": "docs", "tool": "search", "ok": true })
329+
);
330+
assert_eq!(
331+
serde_json::from_value::<super::McpCallOutcome>(wire).unwrap(),
332+
outcome
333+
);
334+
}
335+
336+
#[test]
337+
fn a_failed_outcome_pins_its_wire_form_and_round_trips() {
338+
let outcome = super::McpCallOutcome::failed(
339+
"docs",
340+
"search",
341+
super::McpCallError {
342+
code: crate::errors::UNAUTHORIZED.into(),
343+
unauthorized: true,
344+
advertises_oauth: true,
345+
},
346+
);
347+
let wire = serde_json::to_value(&outcome).unwrap();
348+
assert_eq!(
349+
wire,
350+
json!({
351+
"kind": "mcp_call",
352+
"server": "docs",
353+
"tool": "search",
354+
"ok": false,
355+
"error": {
356+
"code": "ai.tinyhumans.tinymcp.Error.Unauthorized",
357+
"unauthorized": true,
358+
"advertises_oauth": true,
359+
},
360+
})
361+
);
362+
assert_eq!(
363+
serde_json::from_value::<super::McpCallOutcome>(wire).unwrap(),
364+
outcome
365+
);
366+
}
367+
368+
#[test]
369+
fn a_plain_error_carries_no_authorization_signal() {
370+
let error = super::McpCallError::new(crate::errors::TRANSPORT);
371+
assert_eq!(error.code, crate::errors::TRANSPORT);
372+
assert!(!error.unauthorized);
373+
assert!(!error.advertises_oauth);
374+
}
375+
376+
#[test]
377+
fn an_outcome_is_read_back_only_from_metadata_of_its_kind() {
378+
let outcome = super::McpCallOutcome::answered("docs", "search");
379+
let metadata = serde_json::to_value(&outcome).unwrap();
380+
assert_eq!(
381+
super::McpCallOutcome::from_metadata(&metadata),
382+
Some(outcome)
383+
);
384+
385+
assert_eq!(
386+
super::McpCallOutcome::from_metadata(&json!({ "kind": "web_search" })),
387+
None
388+
);
389+
assert_eq!(super::McpCallOutcome::from_metadata(&json!([1, 2])), None);
390+
assert_eq!(
391+
super::McpCallOutcome::from_metadata(&json!({ "kind": "mcp_call", "ok": "yes" })),
392+
None
393+
);
394+
}
395+
396+
#[test]
397+
fn decoding_an_outcome_rejects_what_the_constructors_never_produce() {
398+
let decode = |value: serde_json::Value| serde_json::from_value::<super::McpCallOutcome>(value);
399+
let error = json!({ "code": crate::errors::TRANSPORT, "unauthorized": false, "advertises_oauth": false });
400+
let base =
401+
|ok: bool| json!({ "kind": "mcp_call", "server": "docs", "tool": "search", "ok": ok });
402+
403+
let mut answered_with_error = base(true);
404+
answered_with_error["error"] = error.clone();
405+
assert!(decode(answered_with_error).is_err());
406+
407+
assert!(decode(base(false)).is_err());
408+
let mut failed_null_error = base(false);
409+
failed_null_error["error"] = serde_json::Value::Null;
410+
assert!(decode(failed_null_error).is_err());
411+
412+
let mut missing_kind = base(true);
413+
missing_kind.as_object_mut().unwrap().remove("kind");
414+
assert!(decode(missing_kind).is_err());
415+
let mut wrong_kind = base(true);
416+
wrong_kind["kind"] = json!("web_search");
417+
assert!(decode(wrong_kind).is_err());
418+
419+
assert_eq!(
420+
decode(base(true)).unwrap(),
421+
super::McpCallOutcome::answered("docs", "search")
422+
);
423+
let mut failed = base(false);
424+
failed["error"] = error;
425+
assert_eq!(
426+
decode(failed).unwrap(),
427+
super::McpCallOutcome::failed(
428+
"docs",
429+
"search",
430+
super::McpCallError::new(crate::errors::TRANSPORT)
431+
)
432+
);
433+
}
434+
435+
#[test]
436+
fn decoding_an_error_rejects_an_oauth_advert_without_a_401() {
437+
let decode = |unauthorized: bool, advertises_oauth: bool| {
438+
serde_json::from_value::<super::McpCallError>(json!({
439+
"code": "x",
440+
"unauthorized": unauthorized,
441+
"advertises_oauth": advertises_oauth,
442+
}))
443+
};
444+
assert!(decode(false, true).is_err());
445+
assert!(decode(false, false).is_ok());
446+
assert!(decode(true, false).is_ok());
447+
assert!(decode(true, true).is_ok());
448+
449+
let nested = json!({
450+
"kind": "mcp_call", "server": "docs", "tool": "t", "ok": false,
451+
"error": { "code": "x", "unauthorized": false, "advertises_oauth": true },
452+
});
453+
assert_eq!(super::McpCallOutcome::from_metadata(&nested), None);
454+
}

0 commit comments

Comments
 (0)