Skip to main content

origin_mcp_http/
lib.rs

1//! MCP over a local loopback HTTP endpoint (G16).
2//!
3//! The stdio transport covers "the app is not running yet": the client starts the
4//! application as a child process. It does **not** cover the more common case where the
5//! GUI is already open and an MCP-capable client wants to attach to the running
6//! instance. This adapter fills that gap.
7//!
8//! ```text
9//!   CLI / stdio (no GUI running)          HTTP loopback (GUI already running)
10//!   ────────────────────────────          ─────────────────────────────────
11//!   client ──spawn──▶ app ──stdio         client ──POST──▶ 127.0.0.1:<port>/mcp
12//! ```
13//!
14//! The endpoint binds to `127.0.0.1:0` — the same pattern as the OAuth redirect
15//! (ADR-0015): the OS picks a free port, so two instances never collide. The port is
16//! published in a discovery file so a client can find it, and access is gated by a
17//! bearer token (G19).
18
19mod activity;
20mod discovery;
21mod http;
22mod proxy;
23mod transport;
24
25pub use activity::Activity;
26pub use discovery::Discovery;
27pub use http::{HttpRequest, HttpResponse, parse_request};
28pub use proxy::{is_alive, proxy_streams};
29pub use transport::{HttpTransport, handle, post};
30
31/// The path an MCP client posts to. One well-known path keeps the discovery file
32/// trivial.
33pub const MCP_PATH: &str = "/mcp";
34
35/// A bearer token guarding the endpoint (G19).
36///
37/// A locally negotiated secret: the GUI shows a confirmation, then hands the token to
38/// the client. Compared in constant time so a local attacker cannot probe it byte by
39/// byte.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Token(String);
42
43impl Token {
44    /// Generate a fresh random token.
45    pub fn generate() -> Self {
46        // 256 bits from the OS CSPRNG. Preferring `getrandom` over a UUID keeps the
47        // entropy source explicit and the token a fixed width.
48        let mut bytes = [0u8; 32];
49        getrandom::fill(&mut bytes).expect("the OS random source must be available");
50        let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
51        Self(hex)
52    }
53
54    pub fn from_string(value: impl Into<String>) -> Self {
55        Self(value.into())
56    }
57
58    pub fn expose(&self) -> &str {
59        &self.0
60    }
61
62    /// Constant-time equality: no early return on the first differing byte.
63    pub fn matches(&self, candidate: &str) -> bool {
64        let expected = self.0.as_bytes();
65        let candidate = candidate.as_bytes();
66        if expected.len() != candidate.len() {
67            return false;
68        }
69        let mut diff = 0u8;
70        for (a, b) in expected.iter().zip(candidate.iter()) {
71            diff |= a ^ b;
72        }
73        diff == 0
74    }
75}
76
77impl std::fmt::Display for Token {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        f.write_str("Token(***)")
80    }
81}
82
83#[cfg(test)]
84mod tests {
85    use super::*;
86
87    #[test]
88    fn a_generated_token_is_long_and_unpredictable() {
89        let first = Token::generate();
90        let second = Token::generate();
91
92        assert_eq!(first.expose().len(), 64);
93        assert_ne!(first, second);
94    }
95
96    #[test]
97    fn token_equality_is_exact() {
98        let token = Token::from_string("secret-value");
99
100        assert!(token.matches("secret-value"));
101        assert!(!token.matches("secret-valuX"));
102        assert!(!token.matches("secret"));
103        assert!(!token.matches(""));
104    }
105
106    #[test]
107    fn the_display_does_not_leak_the_token() {
108        let token = Token::from_string("super-secret");
109        assert_eq!(token.to_string(), "Token(***)");
110    }
111}