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 crates/tinymcp-bus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ and metadata addresses. The exception is a server whose URL has an origin the
operator listed in `local_oauth_origins`, such as a loopback development server;
each exemption is logged. Token refreshes on connect go through the same guard.

Contract 1.31 adds `McpRegistryAuthConfig::curated_servers`
(`CuratedServerEntry`): curated servers a host adds at runtime, merged with
the module's built-in list. They are listed, searched and described like the
built-in ones but never badged official or promoted, and one naming a built-in
replaces its endpoint and takes its badge away. Empty by default and omitted
from the wire when empty.

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
Expand Down
8 changes: 4 additions & 4 deletions crates/tinymcp-bus/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@
mod types;

pub use types::{
ConfiguredToolCallRequest, HttpHeader, McpAuthConfig, McpClientConfig, McpClientIdentityConfig,
McpProxyConfig, McpRegistryAuthConfig, McpServerConfig, OpenConfiguredRequest,
OpenConfiguredResponse, StaticServerSummary, StdioSandboxConfig, StdioSandboxEnforcement,
TransitionStatus,
ConfiguredToolCallRequest, CuratedEntryAuth, CuratedEntryTransport, CuratedServerEntry,
HttpHeader, McpAuthConfig, McpClientConfig, McpClientIdentityConfig, McpProxyConfig,
McpRegistryAuthConfig, McpServerConfig, OpenConfiguredRequest, OpenConfiguredResponse,
StaticServerSummary, StdioSandboxConfig, StdioSandboxEnforcement, TransitionStatus,
};

#[cfg(test)]
Expand Down
61 changes: 58 additions & 3 deletions crates/tinymcp-bus/src/config/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use super::{
ConfiguredToolCallRequest, McpAuthConfig, McpClientConfig, McpServerConfig,
OpenConfiguredRequest, OpenConfiguredResponse, StdioSandboxConfig, StdioSandboxEnforcement,
TransitionStatus,
ConfiguredToolCallRequest, CuratedEntryAuth, CuratedEntryTransport, CuratedServerEntry,
McpAuthConfig, McpClientConfig, McpRegistryAuthConfig, McpServerConfig, OpenConfiguredRequest,
OpenConfiguredResponse, StdioSandboxConfig, StdioSandboxEnforcement, TransitionStatus,
};
use serde_json::json;

Expand Down Expand Up @@ -173,3 +173,58 @@ fn the_stdio_sandbox_defaults_to_net_subprocess_and_the_isolation_floor() {
serde_json::from_value(json!({ "command": "npx", "operator_trusted": true })).unwrap();
assert!(trusted.operator_trusted);
}

#[test]
fn runtime_curated_servers_have_a_pinned_wire_form() {
let wire = json!({
"curated_servers": [{
"qualified_name": "com.slack/mcp",
"display_name": "Slack",
"description": "Slack's hosted MCP server",
"remote_url": "https://mcp.slack.com/mcp",
"transport": "streamable_http",
"auth": "oauth_preregistered",
}]
});
let auth: McpRegistryAuthConfig = serde_json::from_value(wire.clone()).unwrap();
assert_eq!(
auth.curated_servers,
[CuratedServerEntry {
qualified_name: "com.slack/mcp".into(),
display_name: "Slack".into(),
description: "Slack's hosted MCP server".into(),
remote_url: "https://mcp.slack.com/mcp".into(),
transport: CuratedEntryTransport::StreamableHttp,
auth: CuratedEntryAuth::OauthPreregistered,
icon_url: None,
}]
);
// Only the new member is pinned here; the older optional fields keep their
// existing wire form (`null` when unset).
assert_eq!(
serde_json::to_value(&auth).unwrap()["curated_servers"],
wire["curated_servers"]
);
// Absent by default, and left off the wire when empty.
let empty = McpRegistryAuthConfig::default();
assert_eq!(empty.curated_servers, Vec::new());
assert!(
serde_json::to_value(&empty)
.unwrap()
.get("curated_servers")
.is_none()
);
}

#[test]
fn a_misspelled_curated_server_key_is_refused() {
let wire = json!({
"qualified_name": "com.slack/mcp",
"display_name": "Slack",
"remote_url": "https://mcp.slack.com/mcp",
"transport": "sse",
"auth": "none",
"icon": "https://example.com/slack.png",
});
assert!(serde_json::from_value::<CuratedServerEntry>(wire).is_err());
}
63 changes: 63 additions & 0 deletions crates/tinymcp-bus/src/config/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,69 @@ pub struct McpRegistryAuthConfig {
/// `MCP_OFFICIAL_REGISTRY_TOKEN`.
#[serde(default)]
pub mcp_official_token: Option<String>,
/// Curated servers the host adds at runtime, merged with the built-in
/// list. Not a secret.
///
/// They are listed, searched and described like the built-in ones, but
/// never badged official or promoted: the badge is a claim about software
/// someone vetted, and a configuration line must not be able to make an
/// arbitrary endpoint look like that. An entry naming a built-in server
/// replaces its endpoint and takes its badge away.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub curated_servers: Vec<CuratedServerEntry>,
}

/// A curated server supplied by the host at runtime.
///
/// The owned twin of the module's compiled-in `CuratedServer`. Unknown fields
/// are refused, so a misspelled key fails loudly instead of loading an entry
/// with a default in its place.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(deny_unknown_fields)]
pub struct CuratedServerEntry {
/// The exact registry qualified name.
pub qualified_name: String,
/// The vendor's name for it.
pub display_name: String,
/// A short description.
#[serde(default)]
pub description: String,
/// The vendor-hosted endpoint; `https` only.
pub remote_url: String,
/// How the endpoint speaks MCP.
pub transport: CuratedEntryTransport,
/// How the endpoint authenticates a client.
pub auth: CuratedEntryAuth,
/// An icon for it; `https` only.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon_url: Option<String>,
}

/// How a runtime curated server's endpoint speaks MCP.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CuratedEntryTransport {
/// Streamable HTTP.
StreamableHttp,
/// Server-sent events.
Sse,
}

/// How a runtime curated server's endpoint authenticates a client.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(rename_all = "snake_case")]
pub enum CuratedEntryAuth {
/// OAuth, with dynamic client registration.
Oauth,
/// OAuth for clients the vendor registered in advance only.
OauthPreregistered,
/// A static token sent in the `Authorization` header.
Token,
/// No authentication.
None,
}

/// A proxy for outbound HTTP, already resolved by the host.
Expand Down
8 changes: 4 additions & 4 deletions crates/tinymcp-bus/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,10 @@ pub use audit::{
};
pub use auth::{AuthDetection, AuthKind};
pub use config::{
ConfiguredToolCallRequest, HttpHeader, McpAuthConfig, McpClientConfig, McpClientIdentityConfig,
McpProxyConfig, McpRegistryAuthConfig, McpServerConfig, OpenConfiguredRequest,
OpenConfiguredResponse, StaticServerSummary, StdioSandboxConfig, StdioSandboxEnforcement,
TransitionStatus,
ConfiguredToolCallRequest, CuratedEntryAuth, CuratedEntryTransport, CuratedServerEntry,
HttpHeader, McpAuthConfig, McpClientConfig, McpClientIdentityConfig, McpProxyConfig,
McpRegistryAuthConfig, McpServerConfig, OpenConfiguredRequest, OpenConfiguredResponse,
StaticServerSummary, StdioSandboxConfig, StdioSandboxEnforcement, TransitionStatus,
};
pub use config_document::{AppliedServer, ConfigApplyReport, ConfigApplyResponse};
pub use method::{
Expand Down
4 changes: 3 additions & 1 deletion crates/tinymcp-bus/src/version/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@
/// 1.30 adds `McpClientConfig::local_oauth_origins` and turns the OAuth
/// endpoint guard on by default: endpoints a server advertises must be `https`
/// and public unless the operator declared that server local.
pub const CONTRACT_VERSION: (u32, u32) = (1, 30);
/// 1.31 adds `McpRegistryAuthConfig::curated_servers` (`CuratedServerEntry`):
/// host-supplied curated servers, never badged official.
pub const CONTRACT_VERSION: (u32, u32) = (1, 31);

/// Returns whether a host holding [`CONTRACT_VERSION`] can bind to a module
/// reporting `module`.
Expand Down
2 changes: 1 addition & 1 deletion crates/tinymcp-bus/src/version/mod_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ use super::{CONTRACT_VERSION, binds, is_compatible};

#[test]
fn the_shipped_contract_version_is_pinned() {
assert_eq!(CONTRACT_VERSION, (1, 30));
assert_eq!(CONTRACT_VERSION, (1, 31));
}

#[test]
Expand Down
3 changes: 3 additions & 0 deletions crates/tinymcp/src/registry/config_redaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ impl McpRegistryAuthConfigExt for McpRegistryAuthConfig {
smithery_api_key: None,
mcp_official_base: self.mcp_official_base.clone(),
mcp_official_token: None,
// Endpoints and names, not secrets: validation refuses a URL
// carrying credentials.
curated_servers: self.curated_servers.clone(),
},
smithery_set,
official_set,
Expand Down
2 changes: 2 additions & 0 deletions crates/tinymcp/src/registry/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ fn redacting_registry_auth_drops_both_secrets_and_reports_that_they_were_set() {
smithery_api_key: Some("smithery-secret".into()),
mcp_official_base: Some("https://registry.test".into()),
mcp_official_token: Some("official-secret".into()),
..McpRegistryAuthConfig::default()
};

let (redacted, smithery_set, official_set) = auth.redacted();
Expand Down Expand Up @@ -294,6 +295,7 @@ fn a_redacted_registry_auth_never_serializes_a_secret() {
smithery_api_key: Some("smithery-secret".into()),
mcp_official_base: None,
mcp_official_token: Some("official-secret".into()),
..McpRegistryAuthConfig::default()
};
let (redacted, _, _) = auth.redacted();
let encoded = serde_json::to_string(&redacted).expect("redacted auth serializes");
Expand Down
Loading
Loading