Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
04557ad
test(mcp): require typed stateless tool routing boundary
seonghobae Aug 15, 2026
3431162
style(mcp): apply canonical rustfmt to red contract
seonghobae Aug 15, 2026
afdc7c4
feat(mcp): enforce typed stateless routing boundary
seonghobae Aug 15, 2026
af30802
style(mcp): apply canonical production rustfmt
seonghobae Aug 15, 2026
3ac6855
test(mcp): pin routing policy boundaries
seonghobae Aug 15, 2026
bfed566
refactor(mcp): centralize explicit tool mapping
seonghobae Aug 15, 2026
e5ac16d
style(mcp): apply canonical rustfmt diagnostics
seonghobae Aug 15, 2026
b1585c0
test(mcp): bind routed tool identity to policy action
seonghobae Aug 16, 2026
0808e53
feat(mcp): fail closed on route-action mismatch
seonghobae Aug 16, 2026
50c80ab
style(mcp): apply canonical rustfmt diagnostics
seonghobae Aug 16, 2026
169814e
test(mcp): require deterministic tool catalog
seonghobae Aug 16, 2026
b0a0b30
test(mcp): apply canonical catalog formatting
seonghobae Aug 16, 2026
23a17b5
feat(mcp): expose deterministic reviewed tool catalog
seonghobae Aug 16, 2026
9520ffe
docs(changelog): record deterministic MCP tool catalog
seonghobae Aug 16, 2026
6bd31a5
Merge branch 'main' into feat/mcp-authority-route
seonghobae Aug 16, 2026
1d86d28
test(mcp): reject malformed cross-field tool names before mismatch
seonghobae Aug 17, 2026
9d0f524
fix(mcp): validate each untrusted tool name before comparison
seonghobae Aug 17, 2026
2fb12da
docs(mcp): distinguish active routing from shipped adapter
seonghobae Aug 17, 2026
9bc35d1
docs(mcp): record active routing authority boundary
seonghobae Aug 17, 2026
f9a1fa9
docs(mcp): clarify active routing foundation
seonghobae Aug 17, 2026
abfb261
docs(mcp): add 2026-07-28 primary specification
seonghobae Aug 17, 2026
7d1b610
docs(mcp): trace active routing boundary
seonghobae Aug 17, 2026
908a15b
test(mcp): bound untrusted method metadata
seonghobae Aug 18, 2026
b1a5a32
fix(mcp): bound untrusted method metadata
seonghobae Aug 18, 2026
77b30b1
test(mcp): cover empty method boundary
seonghobae Aug 18, 2026
eb0b3e5
docs(mcp): record bounded method routing contract
seonghobae Aug 18, 2026
ecc01e8
docs(mcp): trace bounded method metadata
seonghobae Aug 18, 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
3 changes: 3 additions & 0 deletions crates/originweave-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ repository.workspace = true
homepage.workspace = true
publish = false

[lib]
path = "src/root.rs"

[dependencies]

[lints]
Expand Down
143 changes: 143 additions & 0 deletions crates/originweave-core/src/mcp.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
//! Fail-closed MCP routing integrity for the external adapter boundary.
//!
//! This module validates only the stateless MCP protocol/method/tool routing
//! envelope and derives an existing [`ActionKind`]. It is deliberately not an
//! authorization decision: callers must independently enforce OriginWeave
//! capability, risk, approval, origin, secret-broker, and evidence policies.
//! No MCP arguments, outputs, credentials, or arbitrary model-visible values
//! are retained by this boundary.

use std::fmt;

use crate::ActionKind;

/// MCP protocol generation accepted by this stateless adapter boundary.
pub const MCP_PROTOCOL_VERSION: &str = "2026-07-28";

/// The only MCP method that can enter the typed action-routing boundary.
pub const MCP_TOOLS_CALL_METHOD: &str = "tools/call";

/// Maximum accepted MCP tool-name length in bytes.
pub const MAX_MCP_TOOL_NAME_BYTES: usize = 128;

/// A deterministic failure while validating untrusted MCP routing metadata.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum McpToolBoundaryError {
/// The request names an MCP protocol generation this boundary does not support.
UnsupportedProtocolVersion,
/// MCP routing metadata disagrees with the method or tool name in the body.
HeaderBodyMismatch,
/// The request method is not the supported `tools/call` operation.
UnsupportedMethod,
/// The tool name violates the bounded ASCII MCP routing syntax.
InvalidToolName,
/// The tool name has no explicit mapping to an OriginWeave typed action.
UnknownTool,
}

impl fmt::Display for McpToolBoundaryError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::UnsupportedProtocolVersion => {
formatter.write_str("unsupported MCP protocol version")
}
Self::HeaderBodyMismatch => {
formatter.write_str("MCP routing headers do not match the request body")
}
Self::UnsupportedMethod => formatter
.write_str("only MCP tools/call requests can enter the typed action boundary"),
Self::InvalidToolName => {
formatter.write_str("MCP tool name violates the bounded ASCII routing syntax")
}
Self::UnknownTool => {
formatter.write_str("MCP tool is not mapped to an OriginWeave typed action")
}
}
}
}

impl std::error::Error for McpToolBoundaryError {}

/// An MCP tool call whose routing envelope has been validated and mapped.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ValidatedMcpToolCall {
tool_name: &'static str,
action_kind: ActionKind,
}

impl ValidatedMcpToolCall {
/// Validate one stateless MCP tool-call routing envelope.
///
/// Routing integrity is intentionally narrower than authorization. A
/// successful value proves only that the untrusted protocol version,
/// routing metadata, body method, and body tool name agree with one
/// explicitly supported mapping.
pub fn new(
protocol_version: &str,
routing_method: &str,
routing_tool_name: &str,
body_method: &str,
body_tool_name: &str,
) -> Result<Self, McpToolBoundaryError> {
if protocol_version != MCP_PROTOCOL_VERSION {
return Err(McpToolBoundaryError::UnsupportedProtocolVersion);
}
if routing_method != body_method || routing_tool_name != body_tool_name {
return Err(McpToolBoundaryError::HeaderBodyMismatch);
}
if routing_method != MCP_TOOLS_CALL_METHOD {
return Err(McpToolBoundaryError::UnsupportedMethod);
}
if !valid_tool_name(routing_tool_name) {
return Err(McpToolBoundaryError::InvalidToolName);
}

let (tool_name, action_kind) = map_tool(routing_tool_name)?;
Ok(Self {
tool_name,
action_kind,
})
}

/// Return the canonical static tool name selected by the explicit mapping.
#[must_use]
pub const fn tool_name(&self) -> &'static str {
self.tool_name
}

/// Return the existing OriginWeave typed action selected by this tool.
#[must_use]
pub const fn action_kind(&self) -> ActionKind {
self.action_kind
}
}

fn valid_tool_name(tool_name: &str) -> bool {
if tool_name.is_empty() || tool_name.len() > MAX_MCP_TOOL_NAME_BYTES {
return false;
}
tool_name
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
}

fn map_tool(tool_name: &str) -> Result<(&'static str, ActionKind), McpToolBoundaryError> {
let mapped = match tool_name {
"originweave.observe" => ("originweave.observe", ActionKind::Observe),
"originweave.extract" => ("originweave.extract", ActionKind::Extract),
"originweave.navigate" => ("originweave.navigate", ActionKind::Navigate),
"originweave.download" => ("originweave.download", ActionKind::Download),
"originweave.draft" => ("originweave.draft", ActionKind::Draft),
"originweave.submit" => ("originweave.submit", ActionKind::Submit),
"originweave.upload" => ("originweave.upload", ActionKind::Upload),
"originweave.fill_secret" => ("originweave.fill_secret", ActionKind::FillSecret),
"originweave.purchase" => ("originweave.purchase", ActionKind::Purchase),
"originweave.delete" => ("originweave.delete", ActionKind::Delete),
"originweave.manage_permission" => (
"originweave.manage_permission",
ActionKind::ManagePermission,
),
_ => return Err(McpToolBoundaryError::UnknownTool),
};
Ok(mapped)
}
15 changes: 15 additions & 0 deletions crates/originweave-core/src/root.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//! Shared security and governance contracts for OriginWeave.
//!
//! The historical core contracts remain source-compatible while adapter-specific
//! boundaries can live in focused modules without changing their authority model.

#![forbid(unsafe_code)]
#![deny(missing_docs)]

#[path = "lib.rs"]
mod contracts;

pub use contracts::*;

/// Stateless MCP routing validation that maps only explicit tools to typed actions.
pub mod mcp;
153 changes: 153 additions & 0 deletions crates/originweave-core/tests/mcp_authority_route.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
use std::error::Error;

use originweave_core::ActionKind;
use originweave_core::mcp::{
MAX_MCP_TOOL_NAME_BYTES, MCP_PROTOCOL_VERSION, MCP_TOOLS_CALL_METHOD, McpToolBoundaryError,
ValidatedMcpToolCall,
};

fn validate(tool_name: &str) -> Result<ValidatedMcpToolCall, McpToolBoundaryError> {
ValidatedMcpToolCall::new(
MCP_PROTOCOL_VERSION,
MCP_TOOLS_CALL_METHOD,
tool_name,
MCP_TOOLS_CALL_METHOD,
tool_name,
)
}

#[test]
fn supported_mcp_tools_map_to_exact_originweave_actions() -> Result<(), Box<dyn Error>> {
let cases = [
("originweave.observe", ActionKind::Observe),
("originweave.extract", ActionKind::Extract),
("originweave.navigate", ActionKind::Navigate),
("originweave.download", ActionKind::Download),
("originweave.draft", ActionKind::Draft),
("originweave.submit", ActionKind::Submit),
("originweave.upload", ActionKind::Upload),
("originweave.fill_secret", ActionKind::FillSecret),
("originweave.purchase", ActionKind::Purchase),
("originweave.delete", ActionKind::Delete),
(
"originweave.manage_permission",
ActionKind::ManagePermission,
),
];

for (tool_name, expected_action) in cases {
let call = validate(tool_name)?;
assert_eq!(call.tool_name(), tool_name);
assert_eq!(call.action_kind(), expected_action);
assert_eq!(
call.action_kind().required_capability(),
expected_action.required_capability()
);
assert_eq!(
call.action_kind().risk_class(),
expected_action.risk_class()
);
}
Ok(())
}

#[test]
fn mcp_route_rejects_protocol_header_body_and_method_drift() {
assert_eq!(
ValidatedMcpToolCall::new(
"2025-11-25",
MCP_TOOLS_CALL_METHOD,
"originweave.observe",
MCP_TOOLS_CALL_METHOD,
"originweave.observe",
),
Err(McpToolBoundaryError::UnsupportedProtocolVersion)
);
assert_eq!(
ValidatedMcpToolCall::new(
MCP_PROTOCOL_VERSION,
MCP_TOOLS_CALL_METHOD,
"originweave.observe",
"tools/list",
"originweave.observe",
),
Err(McpToolBoundaryError::HeaderBodyMismatch)
);
assert_eq!(
ValidatedMcpToolCall::new(
MCP_PROTOCOL_VERSION,
MCP_TOOLS_CALL_METHOD,
"originweave.observe",
MCP_TOOLS_CALL_METHOD,
"originweave.extract",
),
Err(McpToolBoundaryError::HeaderBodyMismatch)
);
assert_eq!(
ValidatedMcpToolCall::new(
MCP_PROTOCOL_VERSION,
"resources/read",
"originweave.observe",
"resources/read",
"originweave.observe",
),
Err(McpToolBoundaryError::UnsupportedMethod)
);
}

#[test]
fn mcp_route_rejects_unbounded_malformed_and_unmapped_tool_names() {
let oversized = "x".repeat(MAX_MCP_TOOL_NAME_BYTES + 1);
for tool_name in [
"",
"originweave legal",
"originweave/observe",
"originweave.관찰",
&oversized,
] {
assert_eq!(
validate(tool_name),
Err(McpToolBoundaryError::InvalidToolName)
);
}

assert_eq!(
validate("originweave.legal_consent"),
Err(McpToolBoundaryError::UnknownTool)
);
assert_eq!(
validate("third_party.arbitrary_javascript"),
Err(McpToolBoundaryError::UnknownTool)
);
}

#[test]
fn mcp_boundary_errors_are_deterministic_and_do_not_echo_untrusted_values() {
let cases = [
(
McpToolBoundaryError::UnsupportedProtocolVersion,
"unsupported MCP protocol version",
),
(
McpToolBoundaryError::HeaderBodyMismatch,
"MCP routing headers do not match the request body",
),
(
McpToolBoundaryError::UnsupportedMethod,
"only MCP tools/call requests can enter the typed action boundary",
),
(
McpToolBoundaryError::InvalidToolName,
"MCP tool name violates the bounded ASCII routing syntax",
),
(
McpToolBoundaryError::UnknownTool,
"MCP tool is not mapped to an OriginWeave typed action",
),
];

for (error, expected_message) in cases {
assert_eq!(error.to_string(), expected_message);
assert!(error.source().is_none());
}
}
Loading