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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ jobs:
- name: Test
run: cargo test --all-features

# Integration tests compile the ordinary library without cfg(test), so
# feature-gated constructors cannot hide behind unit-test-only builds.
- name: Test library feature combinations
run: |
cargo test --package tinymcp --no-default-features --test library_feature_tests
Comment thread
senamakel marked this conversation as resolved.
cargo test --package tinymcp --no-default-features --features tools,ui,server-http --test library_feature_tests

- name: Test default features
run: cargo test

Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

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

6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,9 @@ schemars = { version = "1", default-features = false, features = ["derive"] }
# rather than a path on purpose: a host that already links tinytools through
# another checkout redirects this one with a `[patch]` entry, so both resolve
# 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 = "bd60b9b52f1998b4483b9cf107a72d3338e672f8" }
# dependency could not be unified that way. Pin the reviewed canonical TinyTools
# revision that provides shared lexical helpers; downstream pins migrate separately.
tinytools = { git = "https://github.com/tinyhumansai/tinytools", rev = "fa61666feb5ea978c77c83bf274aeb4ad54363b3", default-features = false }
# `#[async_trait]` on the tool adapter and the invoker trait, matching how
# `tinytools::Tool` itself is declared.
async-trait = "0.1"
Expand Down
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,5 +373,27 @@ fence retired retries. The bridge bounds session counts, batch counts, input and
aggregate replies, and response construction. See [the operation contract](crates/tinymcp/src/server/bridge/README.md).

Existing stdio/HTTP library entrypoints remain available. Compiled-module listener
entrypoints and moving the legacy server declaration/error types into the pure
contract are subsequent migration slices; the new bridge does not add host framing.
entrypoints are a subsequent migration slice; the new bridge does not add host framing.

Contract 1.9 moves shared server declarations, headers and errors into the pure
bus vocabulary and adds four bounded metadata preparation operations. Generic
lexical helpers are shared through TinyTools without its default features.
Library DTO algorithms use extension traits; see
[public API and migration details](docs/specs/pure-vocabulary.md). Host stdio/HTTP
adapters and release integration remain separate work.

Contract 1.10 adds `OpenConfigured`, which opens one data directory with that
host's own `McpClientConfig`. `Open(data_dir)` remains unchanged for callers
using load-time settings. A configured open carries the directory and client
settings together, so separate workspaces can use distinct static servers,
credentials, identities and proxies through one loaded module. Reopening the
same directory with changed settings replaces its service at the same object
path after active calls finish, then stops the old supervisor, protocol
operations and dynamic transports. The response includes a transition id;
hosts poll `TransitionStatus` until cleanup is complete before using a replaced
service. Contract 1.11 adds `Close(data_dir)`, which releases child services or
resets the root to an empty in-memory service, and `TransitionStatus`, a
nonblocking status query. Competing lifecycle mutations fail quickly while a
transition is pending, active reservations remain counted through cleanup, and
object path ids are never reused. Hosts send `OpenConfigured` over TinyBus's
confidential-call path; request Debug output omits the path and client fields.
132 changes: 33 additions & 99 deletions crates/tinymcp-bus/README.md
Original file line number Diff line number Diff line change
@@ -1,101 +1,35 @@
# tinymcp-bus

Every type that crosses the template module's `TinyBus` boundary, and the names
of the members that carry them.

The template ships as a loadable module so a host does not compile the
implementation: `crates/tinymcp` is built as a `cdylib` and exports one object.
A host can load that binary but cannot `use` anything out of it, so the payload
vocabulary has to be published as an ordinary library. This is it.

| module | what it holds |
| ---------- | ------------------------------------------------------------ |
| `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, 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`.

## This crate sits underneath `template`

`template` **depends on this crate and re-exports all of it**. That direction
matters, and it is the opposite of the obvious one.

A *host* needs the payload types and needs nothing else: it loads the module and
makes calls, so it names `GreetRequest` and `GreetResponse` but implements no
behavior and links no transport. Making it depend on the whole module crate — and
through it on `tinybus`, `tokio`, and the module SDK — to spell a payload type
would be the wrong shape.

The alternative, a parallel set of payload types for hosts, is worse: a
`GreetRequest` defined twice is two distinct types, with a conversion at every
call site that nothing checks. One definition, here, at the bottom.

Because the re-export is by module as well as by item, `tinymcp::GreetRequest`,
`tinymcp::names::OBJECT_PATH`, and `tinymcp_bus::greeting::GreetRequest` all
resolve to the same items, not twins.

So: a module author depends on `template` and gets behavior and vocabulary. A
host depends on `tinymcp-bus` and gets vocabulary alone.

## What is deliberately absent

**No behavior.** `greet` lives in `crates/tinymcp`. A payload type describes
what a frame carries, not what the module does with it. The split is readable
off the path: a name here is data, a name there is an obligation.

**No transport.** This crate does not depend on `tinybus` and holds no
connection, client, or codec. A host already owns its connection — its reconnect
policy, its timeouts, its tracing — and the useful part is the vocabulary.

That is also structural, not just preference: `tinybus` is vendored as a
submodule whose manifest inherits fields from its own nested
`[workspace.package]`. Keeping the contract crate transport-free is what keeps
it down to two dependencies and what lets anything in the workspace — or outside
it — depend on it freely. CI asserts the dependency tree stays that way.

## Making a call

Arguments travel as a positional JSON array — `#[tinybus::interface]` decodes
them into a tuple — and the member name comes from `names`:

```rust,ignore
use tinymcp_bus::{names, GreetRequest, GreetResponse};

let proxy = connection.proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE)?;
let reply: GreetResponse = proxy
.call(names::methods::GREET, (GreetRequest::new("Ferris"),))
.await?;
assert_eq!(reply.greeting, "Hello, Ferris!");
```

Nothing above is a string literal at a call site. Renaming the interface, the
path, or a member is therefore a compile error in every consumer rather than an
`UnknownMethod` discovered at runtime.

## Staying in step with the module

`names::METHODS` lists every member in dispatch order. `crates/tinymcp` asserts
its served members against that list, so a method added to the interface without
an entry here fails that crate's tests rather than surfacing in a host.

## Versioning

`CONTRACT_VERSION` describes *this vocabulary*, not the package. Bump its major
component when a payload's wire form changes incompatibly or a member is removed
or renamed, and its minor component when a member or an optional field is added.
It is deliberately independent of the package version the release workflow owns,
which tracks the shipped artifact.

The payload tests pin the serde representation, because that representation is
the wire form: a host and a module that disagree about a field name fail at
runtime with a decode error, so the shape is asserted rather than assumed.

## Generating a project from the template

Rename the interface, the object path, and the member constants in `names`
together, replace `greeting` with the first real payload family, and reset
`CONTRACT_VERSION` to `(1, 0)` for the new contract. Keep the crate
dependency-light: the moment it links a transport or a runtime, the reason it
exists is gone.
Shared serialized MCP vocabulary, fixed tool declarations, errors, method names
and contract version. The implementation re-exports the same definitions.
Normal/build dependency closures contain only serialization/schema utilities,
including with all features enabled. No optional runtime, transport, native or
TinyTools dependency belongs here.

DTO constructors, fixed catalog/schema declarations and serde helpers are
vocabulary. Protocol routing, transport, argument normalization, dynamic
metadata preparation, query filtering, persisted-value parsing and redaction
execute in the implementation. Generic lexical helpers live in TinyTools.
The sanitize module retains only MCP presentation limits.

Contract 1.9 adds four one-argument operations: TransformText,
NormalizeToolArguments, DisplayRemoteTool and RenderToolOutput. Their requests
and output are capped at 1 MiB. Rendering applies its cap incrementally, before
buffering oversized pretty JSON or joined content. Existing member arities and
wire forms are preserved. Prefer member constants from names::methods.

Contract 1.10 adds the one-argument `OpenConfigured` operation and its
`OpenConfiguredRequest` payload. It pairs a data directory with that host's
MCP client settings; `Open` and its original string argument remain unchanged.
Because those settings may contain credentials, callers send this request using
TinyBus's confidential-call path. Contract 1.11 adds `Close(data_dir)` and the
nonblocking `TransitionStatus(id)` observation. `OpenConfigured` returns an
`OpenConfiguredResponse` carrying the object path and lifecycle transition id;
replacement and old-service cleanup are complete only when the status is
`complete`. The root remains available after close, with no persistent
directory or static client configuration.

Library consumers bring implementation extension traits into scope for moved
DTO methods. Contract-only hosts call the compiled module. See
[the relocation specification](../../docs/specs/pure-vocabulary.md) for public
API compatibility and remaining transport/host integration work.
14 changes: 2 additions & 12 deletions crates/tinymcp-bus/src/agent_tools/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,6 @@
//! ([`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.
//!
//! [`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
Expand All @@ -24,30 +20,24 @@
//! 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.
//! prompts, and a hand-kept copy drifts. These fixed specs are vocabulary.
//! Dynamic argument normalization executes in the implementation.
//!
//! # 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, MCP_CALL_RESULT_KIND, MCP_RESULT_KIND, McpCallError,
McpCallOutcome, McpResultEnvelope,
};

#[cfg(test)]
#[path = "mod_tests.rs"]
mod test;

#[cfg(test)]
#[path = "mod_envelope_tests.rs"]
mod envelope_test;
6 changes: 1 addition & 5 deletions crates/tinymcp-bus/src/audit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
//! server, so an untruncated message turns one bad response into an
//! arbitrarily large row.
//!
//! [`McpWriteListQuery`] carries `resolved_*` accessors that apply those
//! The implementation resolves [`McpWriteListQuery`] and applies those
//! bounds, along with the rule that a blank filter means "no filter" rather
//! than "match nothing". They live on the query rather than in the store
//! because they are part of what the query *means*, and a second caller
Expand All @@ -33,7 +33,3 @@ pub use types::{
DEFAULT_LIST_LIMIT, ERROR_MESSAGE_MAX_BYTES, MAX_LIST_LIMIT, McpWriteListQuery, McpWriteRecord,
NewMcpWriteRecord,
};

#[cfg(test)]
#[path = "mod_tests.rs"]
mod test;
68 changes: 0 additions & 68 deletions crates/tinymcp-bus/src/audit/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,71 +101,3 @@ pub struct McpWriteListQuery {
#[serde(default)]
pub success_only: Option<bool>,
}

impl McpWriteListQuery {
/// The page size this query resolves to, clamped to [`MAX_LIST_LIMIT`].
///
/// # Examples
///
/// ```
/// # use tinymcp_bus::{McpWriteListQuery, MAX_LIST_LIMIT, DEFAULT_LIST_LIMIT};
/// assert_eq!(McpWriteListQuery::default().resolved_limit(), DEFAULT_LIST_LIMIT);
///
/// let mut query = McpWriteListQuery::default();
/// query.limit = Some(10_000);
/// assert_eq!(query.resolved_limit(), MAX_LIST_LIMIT);
/// ```
#[must_use]
pub fn resolved_limit(&self) -> u64 {
self.limit.unwrap_or(DEFAULT_LIST_LIMIT).min(MAX_LIST_LIMIT)
}

/// The offset this query resolves to.
#[must_use]
pub fn resolved_offset(&self) -> u64 {
self.offset.unwrap_or(0)
}

/// The client filter, trimmed, or `None` when it is absent or blank.
///
/// A whitespace-only filter is treated as no filter rather than as a filter
/// that matches nothing — a caller sending an empty text box wants
/// everything, not silence.
///
/// # Examples
///
/// ```
/// # use tinymcp_bus::McpWriteListQuery;
/// let mut query = McpWriteListQuery::default();
/// query.client_filter = Some(" claude ".into());
/// assert_eq!(query.resolved_client_filter(), Some("claude"));
///
/// query.client_filter = Some(" ".into());
/// assert_eq!(query.resolved_client_filter(), None);
/// ```
#[must_use]
pub fn resolved_client_filter(&self) -> Option<&str> {
normalized_filter(self.client_filter.as_deref())
}

/// The tool filter, trimmed, or `None` when it is absent or blank.
///
/// The same rule as [`Self::resolved_client_filter`].
#[must_use]
pub fn resolved_tool_filter(&self) -> Option<&str> {
normalized_filter(self.tool_filter.as_deref())
}

/// Whether to return successful writes only.
///
/// Both `None` and `Some(false)` mean no filter.
#[must_use]
pub fn resolved_success_only(&self) -> bool {
self.success_only.unwrap_or(false)
}
}

/// Trims a filter and treats a blank one as absent.
fn normalized_filter(value: Option<&str>) -> Option<&str> {
value.map(str::trim).filter(|value| !value.is_empty())
}
5 changes: 3 additions & 2 deletions crates/tinymcp-bus/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ mod types;

pub use types::{
HttpHeader, McpAuthConfig, McpClientConfig, McpClientIdentityConfig, McpProxyConfig,
McpRegistryAuthConfig, McpServerConfig,
McpRegistryAuthConfig, McpServerConfig, OpenConfiguredRequest, OpenConfiguredResponse,
TransitionStatus,
};

#[cfg(test)]
#[path = "mod_tests.rs"]
mod test;
mod tests;
Loading
Loading