Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
767fe71
feat(tinymcp-bus): expose agent_tools module publicly
senamakel Sep 29, 2026
04543a9
feat(tinymcp-bus): add sha2 dependency for unique action tool naming
senamakel Sep 29, 2026
78f756d
chore(tinymcp-bus): update test module to use consistent import style
senamakel Sep 29, 2026
60736bd
fix(version): correct version comparison for pre-release identifiers
senamakel Sep 29, 2026
975e83c
docs(readme): add documentation for tinymcp-bus crate
senamakel Sep 29, 2026
9f78c10
fix(agent_tools): correct test assertion for tool call result
senamakel Sep 29, 2026
ac1c6a1
fix(registry): handle missing error module in ops test
senamakel Sep 29, 2026
44dca37
feat(registry): normalize tool arguments before forwarding
senamakel Sep 29, 2026
527feac
feat(registry): add config document support for tool registration
senamakel Sep 29, 2026
95c5d27
feat(error): add ConfigDoc error variant for mcp.json validation
senamakel Sep 29, 2026
7d603ab
fix(registry): replace match with if-let for transport handling
senamakel Sep 29, 2026
cfe8424
fix(registry/supervisor): correct test assertion for supervisor state
senamakel Sep 29, 2026
633e092
fix(registry): correct supervisor type references and add missing re-…
senamakel Sep 29, 2026
da6826d
chore: files changed crates/tinymcp-bus/src/lib.rs,crates/tinymcp-bus…
senamakel Sep 29, 2026
46b62d1
feat(lib): expose the server module publicly
senamakel Sep 29, 2026
418b9d2
fix(server): remove unused import of `HashMap` from types module
senamakel Sep 29, 2026
6c3f3f6
fix(server): handle concurrent requests with proper state isolation
senamakel Sep 29, 2026
00d1465
feat(server): expose the args module publicly
senamakel Sep 29, 2026
d528e98
fix(server): remove unused args module
senamakel Sep 29, 2026
c310116
feat(server): expose ClientSession from the server module
senamakel Sep 29, 2026
a10445d
fix(session): handle concurrent session access with shared state
senamakel Sep 29, 2026
44123ad
chore: files changed crates/tinymcp/src/server/mod.rs,crates/tinymcp/…
senamakel Sep 29, 2026
c6f6661
fix(test): use raw string literal to avoid escape issues in assertion
senamakel Sep 29, 2026
cd03a5f
fix(protocol): handle empty JSON-RPC request body gracefully
senamakel Sep 29, 2026
c32edcd
refactor(protocol): replace external helper with local sorted_keys fu…
senamakel Sep 29, 2026
e2f95e9
fix(protocol): hoist is_error computation out of tracing macro
senamakel Sep 29, 2026
36b09ad
chore: files changed crates/tinymcp/src/error/test.rs,crates/tinymcp/…
senamakel Sep 29, 2026
b2f7d25
feat(server): add stdio transport for MCP server
senamakel Sep 29, 2026
2d8cd78
feat(tinymcp): add Streamable HTTP server transport
senamakel Sep 29, 2026
b5280c5
feat(server): add conditional HTTP server module
senamakel Sep 29, 2026
c46d518
feat(error): add public constructor for ServerIo error variant
senamakel Sep 29, 2026
3e4f9d2
fix(server/http): handle missing Content-Type header in POST requests
senamakel Sep 29, 2026
8ca534c
fix(test): correct SSE field order and add session-id redaction test
senamakel Sep 29, 2026
b3c5c06
feat(tinymcp): expose server types and HTTP entry points at the crate…
senamakel Sep 29, 2026
9f55f22
chore: files changed docs/specs/mcp-extraction.md,docs/specs/mcp-serv…
senamakel Sep 29, 2026
408cbe0
chore: reformat code and update documentation
senamakel Sep 29, 2026
308a495
refactor(server): replace Result-based session check with Option and …
senamakel Sep 29, 2026
6eae335
test(session): add test for object_keys sorting and empty handling
senamakel Sep 29, 2026
a3bdac0
Merge origin/main (tools feature, #27, #24) into mcp-nested-args-norm…
senamakel Sep 29, 2026
1ce6691
feat(bus): add agent tool execution support
senamakel Sep 29, 2026
c797467
chore(tinymcp-bus): remove unused sha2 dependency and stale doc refer…
senamakel Sep 29, 2026
df1edc5
feat(tinymcp): add argument normalization tests for tool invocations
senamakel Sep 29, 2026
1a5868f
feat(tools): add markdown_formatted field to Recording tool result
senamakel Sep 29, 2026
367ead1
fix(tinymcp): normalize MCP tool arguments before execution
senamakel Sep 29, 2026
66ac4d6
fix: reorder imports to follow convention
senamakel Sep 29, 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
13 changes: 13 additions & 0 deletions Cargo.lock

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

20 changes: 13 additions & 7 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -106,22 +106,28 @@ rusqlite = { version = "0.40", features = ["bundled"] }
# Locating a user's home directory, to find the version-manager shim
# directories a desktop launch's stripped PATH leaves out.
dirs = "7"
# PKCE code challenges. The authorization-code flow hashes its verifier with
# SHA-256 and nothing else in the tree needs a digest.
# SHA-256: PKCE code challenges in the authorization-code flow, and the digest
# in an action tool's name (`tinymcp-bus`'s `agent_tools::searchable_name`).
sha2 = "0.11"
# Install identifiers.
uuid = { version = "1", features = ["v4"] }

# Test-only: the transport suites stand up a real HTTP server on a loopback
# port and dial it, so the client is exercised over an actual socket rather
# than against a hand-rolled mock of `reqwest`. That is what makes assertions
# about headers, redirects, session expiry and SSE framing mean anything.
# The Streamable HTTP *server* (`tinymcp/server-http`), and the loopback
# servers the client transport suites dial — so the client is exercised over
# an actual socket rather than against a hand-rolled mock of `reqwest`. That is
# what makes assertions about headers, redirects, session expiry and SSE
# framing mean anything. The minimum a server needs, so a host enabling
# `server-http` gains no axum feature it did not ask for; the test-only `form`
# extractor is added where the tests take it. 0.8 matches OpenHuman's, so the
# host's lockfile carries one axum.
axum = { version = "0.8", default-features = false, features = [
"http1",
"json",
"form",
"tokio",
] }
# The server's SSE stream is a `tokio::sync::broadcast` receiver adapted into a
# `Stream`; `sync` is the feature carrying `BroadcastStream`.
tokio-stream = { version = "0.1", default-features = false, features = ["sync"] }

# Test-only: temporary directories for the spawn-environment suite, which
# creates executable and non-executable files and resolves against them.
Expand Down
1 change: 1 addition & 0 deletions crates/tinymcp-bus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +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 |
| `version` | `CONTRACT_VERSION` and the bind rule a host applies to it |

Two dependencies, both pure Rust: `serde` and `serde_json`.
Expand Down
100 changes: 100 additions & 0 deletions crates/tinymcp-bus/src/agent_tools/arguments.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! Reading a tool call's `arguments` as the object MCP requires.
//!
//! MCP's `tools/call` carries `arguments` as a JSON object. Models do not
//! always send one: some providers JSON-encode a nested object-typed field, so
//! `{"arguments": {}}` arrives as `{"arguments": "{}"}`, sometimes wrapped in a
//! markdown code fence. Rejecting that fails a call whose intent is
//! unambiguous, and a model that retries the same shape can burn a whole turn
//! on it. [`normalize_tool_arguments`] accepts every spelling that carries an
//! object and refuses the rest with a message naming what actually arrived.
//!
//! This is tolerance at execution, not a schema change: the advertised schema
//! still says `object`, so a well-behaved model keeps sending one.

use serde_json::{Map, Value};

use super::types::ArgsError;

/// The opening of a markdown code fence.
const FENCE: &str = "```";

/// Reads tool-call arguments as a JSON object.
///
/// - absent or `null` → an empty object, because a tool with no parameters is
/// legitimately called with nothing;
/// - an object → itself;
/// - a string that decodes to an object, optionally inside a markdown code
/// fence (` ```json … ``` `) → the decoded object.
///
/// Takes `impl Into<Option<Value>>` so a caller holding a `Value` and one
/// holding `Option<Value>` (a map lookup) can both call it directly.
///
/// # Errors
///
/// Returns [`ArgsError::NotAnObject`] for a boolean, number, or array, and
/// [`ArgsError::StringNotAnObject`] for a string that is not JSON or that
/// decodes to something other than an object. Both name what arrived, so the
/// message a model reads tells it what to change.
///
/// # Examples
///
/// ```
/// # use serde_json::json;
/// # use tinymcp_bus::agent_tools::normalize_tool_arguments;
/// let decoded = normalize_tool_arguments(json!("{\"city\":\"Paris\"}")).unwrap();
/// assert_eq!(decoded["city"], "Paris");
/// assert!(normalize_tool_arguments(None).unwrap().is_empty());
/// assert!(normalize_tool_arguments(json!([1])).is_err());
/// ```
pub fn normalize_tool_arguments(
arguments: impl Into<Option<Value>>,
) -> Result<Map<String, Value>, ArgsError> {
match arguments.into() {
None | Some(Value::Null) => Ok(Map::new()),
Some(Value::Object(map)) => Ok(map),
Some(Value::String(text)) => decode_object(&text),
Some(other) => Err(ArgsError::NotAnObject {
actual: json_type_name(&other),
}),
}
}

/// Decodes a string that should hold a JSON object.
fn decode_object(text: &str) -> Result<Map<String, Value>, ArgsError> {
match serde_json::from_str::<Value>(strip_fence(text)) {
Ok(Value::Object(map)) => Ok(map),
Ok(other) => Err(ArgsError::StringNotAnObject {
decoded: Some(json_type_name(&other)),
}),
Err(_) => Err(ArgsError::StringNotAnObject { decoded: None }),
}
}

/// Removes surrounding whitespace and one markdown code fence, if present.
///
/// The fence's language tag is dropped only when it is `json`, in any case;
/// anything else after the opening fence is left for the decoder to refuse.
fn strip_fence(text: &str) -> &str {
let trimmed = text.trim();
let Some(inner) = trimmed.strip_prefix(FENCE) else {
return trimmed;
};
let inner = inner.strip_suffix(FENCE).unwrap_or(inner);
let inner = match inner.get(..4) {
Some(tag) if tag.eq_ignore_ascii_case("json") => inner.get(4..).unwrap_or_default(),
_ => inner,
};
inner.trim()
}

/// The JSON type of `value`, with its article, as a message reads it.
fn json_type_name(value: &Value) -> &'static str {
match value {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
}
}
37 changes: 37 additions & 0 deletions crates/tinymcp-bus/src/agent_tools/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//! What a host needs to expose MCP to a model as tools.
//!
//! A host that puts MCP in front of a model gives it two kinds of tool: the
//! fixed registry tools ([`RegistryTool`]) — browse the catalog, connect,
//! call. They are described here as [`AgentToolSpec`]s: the name, description
//! and schema a model reads, plus what calling the tool can do
//! ([`AgentToolEffect`]). The per-action tools a connected server advertises
//! are adapted by `tinymcp::tools` (the `tools` feature), not here.
//!
//! [`normalize_tool_arguments`] is the other half: models do not always send
//! `arguments` as the object MCP requires, and every path that forwards a call
//! reads them through it.
//!
//! # Why this is in the contract crate
//!
//! A tool's name, description and schema are prompt-cache and transcript
//! identity. Two hosts — or one host and this module's own adapter — that
//! described the same tool differently would each invalidate the other's cached
//! prompts, and a hand-kept copy drifts. The specs are pure data and the
//! normalization is pure `serde_json`, so they cost this crate nothing.
//!
//! # What is not here
//!
//! **Execution and policy.** Running a tool, mapping [`AgentToolEffect`] onto
//! a permission model, approvals, deciding which remote tools pass a
//! prompt-injection scan, and whether a tool is shown at all are the host's.

mod arguments;
mod registry_tools;
mod types;

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

#[cfg(test)]
mod test;
189 changes: 189 additions & 0 deletions crates/tinymcp-bus/src/agent_tools/registry_tools.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
//! The tools that expose the installed-server registry to a model.
//!
//! Search the catalog, inspect a server, list installs and their status,
//! connect and disconnect, list and call a connected server's tools, and
//! uninstall. There is deliberately no install tool: a server is declared by
//! the user, never installed by a model from a catalog listing.

use serde_json::json;

use super::types::{AgentToolEffect, AgentToolSpec};

/// One of the registry's agent tools.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum RegistryTool {
/// `mcp_registry_search` — search the catalog.
Search,
/// `mcp_registry_get` — one catalog server's detail.
Get,
/// `mcp_registry_installed_list` — the installed servers.
InstalledList,
/// `mcp_registry_status` — each install's connection status.
Status,
/// `mcp_registry_list_tools` — a connected server's tools.
ListTools,
/// `mcp_registry_connect` — connect an install.
Connect,
/// `mcp_registry_disconnect` — disconnect an install.
Disconnect,
/// `mcp_registry_tool_call` — call a tool on a connected server.
ToolCall,
/// `mcp_registry_uninstall` — remove an install.
Uninstall,
}

impl RegistryTool {
/// Every registry tool, in the order a host registers them.
pub const ALL: [Self; 9] = [
Self::Search,
Self::Get,
Self::InstalledList,
Self::Status,
Self::ListTools,
Self::Connect,
Self::Disconnect,
Self::ToolCall,
Self::Uninstall,
];

/// The tool's name.
#[must_use]
pub const fn name(self) -> &'static str {
match self {
Self::Search => "mcp_registry_search",
Self::Get => "mcp_registry_get",
Self::InstalledList => "mcp_registry_installed_list",
Self::Status => "mcp_registry_status",
Self::ListTools => "mcp_registry_list_tools",
Self::Connect => "mcp_registry_connect",
Self::Disconnect => "mcp_registry_disconnect",
Self::ToolCall => "mcp_registry_tool_call",
Self::Uninstall => "mcp_registry_uninstall",
}
}

/// The registry tool called `name`, if there is one.
#[must_use]
pub fn from_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|tool| tool.name() == name)
}

/// The tool's spec.
///
/// The descriptions and schemas are the ones the tools have always
/// shipped with; see [`AgentToolSpec`] on why they must not drift.
#[must_use]
pub fn spec(self) -> AgentToolSpec {
let (description, parameters, effect, deferred) = match self {
Self::Search => (
"Search the MCP server registry catalog by `query`, optionally filtered by \
`transport` (\"stdio\" | \"hosted\" | \"all\"), paginated by `page` / \
`page_size`. Use to discover installable MCP servers.",
json!({
"type": "object",
"properties": {
"query": { "type": "string" },
"transport": { "type": "string", "enum": ["stdio", "hosted", "all"] },
"page": { "type": "integer", "minimum": 1 },
"page_size": { "type": "integer", "minimum": 1 }
}
}),
AgentToolEffect::Read,
true,
),
Self::Get => (
"Get one MCP registry server's detail by `qualified_name`.",
json!({
"type": "object",
"properties": { "qualified_name": { "type": "string" } },
"required": ["qualified_name"]
}),
AgentToolEffect::Read,
true,
),
Self::InstalledList => (
"List the MCP servers currently installed for this user.",
json!({ "type": "object", "properties": {} }),
AgentToolEffect::Read,
true,
),
Self::Status => (
"Report the connection status of installed MCP servers.",
json!({ "type": "object", "properties": {} }),
AgentToolEffect::Read,
false,
),
Self::ListTools => (
"List the tools (name, description, input schema) exposed by a \
connected MCP server, given its `server_id`. Use this to discover \
what a connected server can do before calling `mcp_registry_tool_call`. \
The server must already be connected (see `mcp_registry_status` / \
`mcp_registry_connect`).",
server_id_schema(),
AgentToolEffect::Read,
false,
),
Self::Connect => (
"Connect (spawn + handshake) an installed MCP server by `server_id`, \
returning its tools.",
server_id_schema(),
AgentToolEffect::Execute,
false,
),
Self::Disconnect => (
"Disconnect (stop) a connected MCP server by `server_id`.",
server_id_schema(),
AgentToolEffect::Execute,
false,
),
Self::ToolCall => (
"Invoke a tool on a connected MCP server: `server_id` + `tool_name` + \
`arguments` object.",
json!({
"type": "object",
"properties": {
"server_id": { "type": "string" },
"tool_name": { "type": "string" },
"arguments": { "type": "object" }
},
"required": ["server_id", "tool_name"]
}),
AgentToolEffect::Execute,
false,
),
Self::Uninstall => (
"Uninstall an installed MCP server by `server_id`. Default-OFF (opt-in).",
server_id_schema(),
AgentToolEffect::Write,
false,
),
};

AgentToolSpec {
name: self.name().to_string(),
description: description.to_string(),
parameters,
effect,
deferred,
}
}
}

/// Every registry tool's spec, in registration order.
#[must_use]
pub fn registry_tool_specs() -> Vec<AgentToolSpec> {
RegistryTool::ALL
.into_iter()
.map(RegistryTool::spec)
.collect()
}

/// The schema of a tool addressed by one installed server.
fn server_id_schema() -> serde_json::Value {
json!({
"type": "object",
"properties": { "server_id": { "type": "string" } },
"required": ["server_id"]
})
}
Loading
Loading