OXYGENHUB

Coverage / crates/spectra-mcp/src/http.rs 77.8%

112 of 144 lines run, 24 of 56 functions · run #8 on main · run missed

1
//! Tuner over HTTP (spec §15.1, §8.5, §21.1): `POST /mcp` on the Unix
2
//! socket and the loopback port, every request signed and checked before
3
//! anything else; the peer's uid read from the socket; `Origin` checked
4
//! against DNS rebinding.
5
6
use crate::protocol::{self, Headers, Reply};
7
use crate::tools::Ctx;
8
use axum::body::Bytes;
9
use axum::extract::connect_info::{ConnectInfo, Connected};
10
use axum::extract::State;
11
use axum::http::{HeaderMap, StatusCode};
12
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
13
use axum::response::{IntoResponse, Response};
14
use axum::routing::post;
15
use axum::Router;
16
use futures::stream::{self, StreamExt};
17
use serde_json::{json, Value as Json};
18
use spectra_core::authn::Caller;
19
use spectra_core::daemon::{Daemon, Peer, WireEvent};
20
use spectra_core::principals::Principal;
21
use spectra_core::proof::Proof;
22
use std::convert::Infallible;
23
use std::sync::Arc;
24
use std::time::Duration;
25
26
/// Who is on the other end of a connection: a Unix peer, or TCP.
27
#[derive(Clone, Copy, Debug)]
28
pub struct PeerInfo(pub Option<Peer>);
29
30
impl Connected<axum::serve::IncomingStream<'_, tokio::net::UnixListener>> for PeerInfo {
3120×
    fn connect_info(stream: axum::serve::IncomingStream<'_, tokio::net::UnixListener>) -> Self {
3220×
        let cred = stream.io().peer_cred().ok();
3320×
        PeerInfo(cred.map(|c| Peer { uid: c.uid(), pid: c.pid() }))
3420×
    }
35
}
36
37
impl Connected<axum::serve::IncomingStream<'_, tokio::net::TcpListener>> for PeerInfo {
380×
    fn connect_info(_: axum::serve::IncomingStream<'_, tokio::net::TcpListener>) -> Self {
390×
        PeerInfo(None)
400×
    }
41
}
42
43
/// The app state: the daemon, and the origins a browser may send from.
44
#[derive(Clone)]
45
pub struct AppState {
46
    pub daemon: Arc<Daemon>,
47
    pub origins: Arc<Vec<String>>,
48
}
49
50
/// Tuner's routes; the binary adds the API and Radar beside them.
513×
pub fn router(state: AppState) -> Router {
523×
    Router::new()
533×
        .route("/mcp", post(mcp).get(not_allowed).delete(not_allowed))
543×
        .route("/api/v1/ledger/events", post(owner_events))
553×
        .with_state(state)
563×
}
57
581×
async fn not_allowed() -> StatusCode {
591×
    StatusCode::METHOD_NOT_ALLOWED
601×
}
61
62
/// The proof the signature headers carry, for `method_path` and `body`.
6313×
pub fn proof_from(headers: &HeaderMap, method_path: &str, body: &[u8]) -> Option<Proof> {
6449×
    let get = |k: &str| headers.get(k).and_then(|v| v.to_str().ok());
6513×
    let key = get("spectra-key")?;
6612×
    let time_ms = get("spectra-time")?.parse().ok()?;
6712×
    let nonce: [u8; 16] = hex::decode(get("spectra-nonce")?).ok()?.try_into().ok()?;
6812×
    let sig_bytes = hex::decode(get("spectra-signature")?).ok()?;
6912×
    let sig = oxy_core::cbor::decode(&sig_bytes).ok().and_then(|v| oxy_core::identity::Signature::from_value(&v).ok())?;
7012×
    if sig.key.to_hex() != key {
710×
        return None;
7212×
    }
7312×
    Some(Proof { sig, method: method_path.into(), body_hash: *spectra_core::event::hash_body_plain(body), time_ms, nonce })
7413×
}
75
76
/// Refuse a browser request from a page that is not ours (spec §21.1).
7714×
pub fn origin_ok(headers: &HeaderMap, allowed: &[String]) -> bool {
7814×
    match headers.get("origin").and_then(|v| v.to_str().ok()) {
7913×
        None => true,
803×
        Some(o) => allowed.iter().any(|a| a == o),
81
    }
8214×
}
83
84
/// Authenticate a request, or the response that refuses it.
8514×
pub async fn authenticate(
8614×
    state: &AppState,
8714×
    peer: PeerInfo,
8814×
    headers: &HeaderMap,
8914×
    method_path: &str,
9014×
    body: &[u8],
9114×
) -> Result<Caller, Response> {
9214×
    if !origin_ok(headers, &state.origins) {
931×
        return Err((
941×
            StatusCode::FORBIDDEN,
951×
            axum::Json(protocol::error(&Json::Null, protocol::INVALID_REQUEST, "this origin may not call Spectra")),
961×
        )
971×
            .into_response());
9813×
    }
9913×
    let proof = proof_from(headers, method_path, body);
10013×
    state.daemon.authenticate(proof.as_ref(), method_path, body, peer.0).await.map_err(|why| {
1013×
        tracing::info!(%why, "refused a request");
102
        // The reason concerns the caller's own key, so the caller is told it:
103
        // an agent whose delegation expired must be able to say so.
1043×
        (
1053×
            StatusCode::UNAUTHORIZED,
1063×
            axum::Json(protocol::error(&Json::Null, protocol::UNAUTHENTICATED, &format!("not authenticated: {why}"))),
1073×
        )
1083×
            .into_response()
1093×
    })
11014×
}
111
11212×
async fn mcp(State(state): State<AppState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, body: Bytes) -> Response {
11312×
    let caller = match authenticate(&state, peer, &headers, "POST /mcp", &body).await {
1148×
        Ok(c) => c,
1154×
        Err(r) => return r,
116
    };
1178×
    let req: Json = match serde_json::from_slice(&body) {
1188×
        Ok(v) => v,
1190×
        Err(e) => {
1200×
            return (StatusCode::BAD_REQUEST, axum::Json(protocol::error(&Json::Null, protocol::PARSE_ERROR, &format!("not JSON: {e}"))))
1210×
                .into_response()
122
        }
123
    };
1248×
    let new_session = req.pointer("/params/arguments/new_session").and_then(Json::as_bool).unwrap_or(false)
1250×
        && req.pointer("/params/name").and_then(Json::as_str) == Some("brief");
1268×
    let session = state.daemon.touch(&caller.principal, new_session).await;
1278×
    let h = Headers {
1288×
        version: headers.get("mcp-protocol-version").and_then(|v| v.to_str().ok()),
1298×
        method: headers.get("mcp-method").and_then(|v| v.to_str().ok()),
1308×
        name: headers.get("mcp-name").and_then(|v| v.to_str().ok()),
131
    };
1328×
    let version = h.version.unwrap_or("2025-03-26").to_string();
1338×
    let ctx = Ctx { daemon: state.daemon.clone(), caller, session };
1348×
    let meta = meta(&ctx).await;
1358×
    match protocol::handle(&ctx, &h, req, meta.clone()).await {
1367×
        Reply::Json(status, v) => (StatusCode::from_u16(status).unwrap_or(StatusCode::OK), axum::Json(v)).into_response(),
1371×
        Reply::Accepted => StatusCode::ACCEPTED.into_response(),
1380×
        Reply::Stream(id, tool, args) => {
1390×
            let fut = async move {
1400×
                let out = (tool.run)(&ctx, args).await;
1410×
                let meta = crate::http::meta(&ctx).await;
1420×
                let msg = protocol::stream_result(&id, &version, &out, meta);
1430×
                Ok::<_, Infallible>(SseEvent::default().event("message").data(msg.to_string()))
1440×
            };
1450×
            sse(stream::once(fut).boxed())
146
        }
1470×
        Reply::Listen(id, sub) => {
1480×
            let ack = json!({ "jsonrpc": "2.0", "method": "notifications/subscriptions/acknowledged", "params": { "_meta": { "io.modelcontextprotocol/subscriptionId": sub } } });
1490×
            let _ = id;
1500×
            let first = stream::once(async move { Ok::<_, Infallible>(SseEvent::default().event("message").data(ack.to_string())) });
151
            // Spectra's tool list never changes while it runs: the stream
152
            // stays open, quiet but for keep-alives, until the client closes it.
1530×
            sse(first.chain(stream::pending()).boxed())
154
        }
155
    }
15612×
}
157
1580×
fn sse(s: futures::stream::BoxStream<'static, Result<SseEvent, Infallible>>) -> Response {
1590×
    let mut r = Sse::new(s).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)).text("")).into_response();
1600×
    r.headers_mut().insert("x-accel-buffering", "no".parse().unwrap());
1610×
    r
1620×
}
163
164
/// `_meta` on every result (spec §15.2): the session and the unread count;
165
/// the rules version arrives with the rules (#8).
1668×
pub async fn meta(ctx: &Ctx) -> Json {
1678×
    let mut m = serde_json::Map::new();
1688×
    if let Some(s) = &ctx.session {
1698×
        m.insert("spectra/session".into(), json!(s));
1708×
    }
1718×
    if let Ok(n) = spectra_core::conversation::unread(&ctx.daemon.ledger, &ctx.caller.principal.actor()).await {
1728×
        m.insert("spectra/inbox_unread".into(), json!(n));
1738×
    }
1748×
    Json::Object(m)
1758×
}
176
177
/// `POST /api/v1/ledger/events`: the owner's CLI hands over events it
178
/// signed over their payload, so `agent add` and `project add` work while
179
/// the daemon holds the ledger (spec §8.5, "One writer").
1802×
async fn owner_events(
1812×
    State(state): State<AppState>,
1822×
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
1832×
    headers: HeaderMap,
1842×
    body: Bytes,
1852×
) -> Response {
1862×
    let caller = match authenticate(&state, peer, &headers, "POST /api/v1/ledger/events", &body).await {
1872×
        Ok(c) => c,
1880×
        Err(r) => return r,
189
    };
1902×
    if !matches!(caller.principal, Principal::Owner { .. }) {
1910×
        return (StatusCode::FORBIDDEN, axum::Json(json!({ "title": "only the owner hands events to the daemon" }))).into_response();
1922×
    }
1932×
    let events: Vec<WireEvent> = match serde_json::from_slice(&body) {
1942×
        Ok(v) => v,
1950×
        Err(e) => return (StatusCode::BAD_REQUEST, axum::Json(json!({ "title": format!("not a list of events: {e}") }))).into_response(),
196
    };
1972×
    match state.daemon.accept_owner_events(events).await {
1982×
        Ok(seqs) => axum::Json(json!({ "appended": seqs })).into_response(),
1990×
        Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, axum::Json(json!({ "title": e.to_string() }))).into_response(),
200
    }
2012×
}