OXYGENHUB
◆ main spectra / crates / spectra-mcp / src / http.rs
11.4 KiB · Rust · last changed Remove Radar: Spectra serves only signed requests (spec Draft 0.7) 1 day ago source blame history raw
571572f5 Andy McCall ↶ 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 {
31
    fn connect_info(stream: axum::serve::IncomingStream<'_, tokio::net::UnixListener>) -> Self {
32
        let cred = stream.io().peer_cred().ok();
33
        PeerInfo(cred.map(|c| Peer { uid: c.uid(), pid: c.pid() }))
34
    }
35
}
36
37
impl Connected<axum::serve::IncomingStream<'_, tokio::net::TcpListener>> for PeerInfo {
38
    fn connect_info(_: axum::serve::IncomingStream<'_, tokio::net::TcpListener>) -> Self {
39
        PeerInfo(None)
40
    }
41
}
42
b576d05c Andy McCall ↶ 43
impl Connected<axum::serve::IncomingStream<'_, crate::tls::TlsListener>> for PeerInfo {
44
    fn connect_info(_: axum::serve::IncomingStream<'_, crate::tls::TlsListener>) -> Self {
45
        PeerInfo(None)
46
    }
47
}
48
571572f5 Andy McCall ↶ 49
/// The app state: the daemon, and the origins a browser may send from.
50
#[derive(Clone)]
51
pub struct AppState {
52
    pub daemon: Arc<Daemon>,
53
    pub origins: Arc<Vec<String>>,
54
}
55
56
/// Tuner's routes; the binary adds the API and Radar beside them.
57
pub fn router(state: AppState) -> Router {
58
    Router::new()
59
        .route("/mcp", post(mcp).get(not_allowed).delete(not_allowed))
60
        .route("/api/v1/ledger/events", post(owner_events))
b576d05c Andy McCall ↶ 61
        .route("/api/v1/instance", axum::routing::get(instance))
77df591d Andy McCall ↶ 62
        .route("/api/v1/claim", post(claim))
571572f5 Andy McCall ↶ 63
        .with_state(state)
b576d05c Andy McCall ↶ 64
}
65
66
/// `GET /api/v1/instance`, unauthenticated (spec §8.6): who this Spectra
67
/// is, whose it is, and the instance key's word for its TLS certificate, so
68
/// a client can decide whether to trust it before it signs anything.
69
async fn instance(State(state): State<AppState>) -> Response {
70
    let (instance, owner) = {
71
        let p = state.daemon.principals.read().await;
72
        (p.instance().cloned(), p.owner().map(|o| o.hash().to_hex()))
73
    };
eca5af92 Andy McCall ↶ 74
    let owner_missing = owner.is_none();
b576d05c Andy McCall ↶ 75
    let Some(instance) = instance else {
76
        return (StatusCode::SERVICE_UNAVAILABLE, axum::Json(json!({ "title": "this Spectra has no instance key" }))).into_response();
77
    };
78
    let tls = match crate::instance::latest_tls(&state.daemon.ledger).await {
79
        Ok(Some(e)) => json!({ "payload": e.json(), "proof": e.auth.proof.as_ref().map(hex::encode) }),
80
        _ => Json::Null,
81
    };
82
    axum::Json(json!({
83
        "name": instance.name,
84
        "instance": spectra_core::principals::identity_payload(&instance),
85
        "owner": owner,
eca5af92 Andy McCall ↶ 86
        // Until claimed: the KeyId `spectra init --owner` named.
87
        "named_owner": if owner_missing { state.daemon.named_owner() } else { None },
b576d05c Andy McCall ↶ 88
        "tls": tls,
89
    }))
90
    .into_response()
571572f5 Andy McCall ↶ 91
}
92
77df591d Andy McCall ↶ 93
/// `POST /api/v1/claim`: the owner named by `spectra init --owner` records
94
/// their identity (spec §8.6). The event proves itself: it is signed by
95
/// the identity it records, over its payload.
96
async fn claim(State(state): State<AppState>, body: Bytes) -> Response {
eca5af92 Andy McCall ↶ 97
    let events: Vec<WireEvent> = match serde_json::from_slice(&body) {
77df591d Andy McCall ↶ 98
        Ok(v) => v,
eca5af92 Andy McCall ↶ 99
        Err(e) => return (StatusCode::BAD_REQUEST, axum::Json(json!({ "title": format!("not a list of events: {e}") }))).into_response(),
77df591d Andy McCall ↶ 100
    };
eca5af92 Andy McCall ↶ 101
    match state.daemon.claim(events).await {
102
        Ok(seqs) => axum::Json(json!({ "seqs": seqs })).into_response(),
77df591d Andy McCall ↶ 103
        Err(e) => (StatusCode::FORBIDDEN, axum::Json(json!({ "title": format!("{e:#}") }))).into_response(),
104
    }
105
}
106
571572f5 Andy McCall ↶ 107
async fn not_allowed() -> StatusCode {
108
    StatusCode::METHOD_NOT_ALLOWED
109
}
110
111
/// The proof the signature headers carry, for `method_path` and `body`.
112
pub fn proof_from(headers: &HeaderMap, method_path: &str, body: &[u8]) -> Option<Proof> {
113
    let get = |k: &str| headers.get(k).and_then(|v| v.to_str().ok());
114
    let key = get("spectra-key")?;
115
    let time_ms = get("spectra-time")?.parse().ok()?;
116
    let nonce: [u8; 16] = hex::decode(get("spectra-nonce")?).ok()?.try_into().ok()?;
117
    let sig_bytes = hex::decode(get("spectra-signature")?).ok()?;
98a0fb9f Andy McCall ↶ 118
    let sig = spectra_core::cbor::decode(&sig_bytes).ok().and_then(|v| spectra_core::identity::Signature::from_value(&v).ok())?;
571572f5 Andy McCall ↶ 119
    if sig.key.to_hex() != key {
120
        return None;
121
    }
122
    Some(Proof { sig, method: method_path.into(), body_hash: *spectra_core::event::hash_body_plain(body), time_ms, nonce })
123
}
124
125
/// Refuse a browser request from a page that is not ours (spec §21.1).
126
pub fn origin_ok(headers: &HeaderMap, allowed: &[String]) -> bool {
127
    match headers.get("origin").and_then(|v| v.to_str().ok()) {
128
        None => true,
129
        Some(o) => allowed.iter().any(|a| a == o),
130
    }
131
}
132
77df591d Andy McCall ↶ 133
/// Authenticate a request, or the status and reason that refuse it.
134
pub async fn authenticate_why(
571572f5 Andy McCall ↶ 135
    state: &AppState,
136
    peer: PeerInfo,
137
    headers: &HeaderMap,
138
    method_path: &str,
139
    body: &[u8],
77df591d Andy McCall ↶ 140
) -> Result<Caller, (StatusCode, String)> {
571572f5 Andy McCall ↶ 141
    if !origin_ok(headers, &state.origins) {
77df591d Andy McCall ↶ 142
        return Err((StatusCode::FORBIDDEN, "this origin may not call Spectra".into()));
571572f5 Andy McCall ↶ 143
    }
144
    let proof = proof_from(headers, method_path, body);
145
    state.daemon.authenticate(proof.as_ref(), method_path, body, peer.0).await.map_err(|why| {
146
        tracing::info!(%why, "refused a request");
147
        // The reason concerns the caller's own key, so the caller is told it:
148
        // an agent whose delegation expired must be able to say so.
77df591d Andy McCall ↶ 149
        (StatusCode::UNAUTHORIZED, why.to_string())
150
    })
151
}
152
153
/// Authenticate a request, or the JSON-RPC response that refuses it.
154
pub async fn authenticate(
155
    state: &AppState,
156
    peer: PeerInfo,
157
    headers: &HeaderMap,
158
    method_path: &str,
159
    body: &[u8],
160
) -> Result<Caller, Response> {
161
    authenticate_why(state, peer, headers, method_path, body).await.map_err(|(status, why)| {
162
        let (code, msg) = if status == StatusCode::FORBIDDEN {
163
            (protocol::INVALID_REQUEST, why)
164
        } else {
165
            (protocol::UNAUTHENTICATED, format!("not authenticated: {why}"))
166
        };
167
        (status, axum::Json(protocol::error(&Json::Null, code, &msg))).into_response()
571572f5 Andy McCall ↶ 168
    })
169
}
170
171
async fn mcp(State(state): State<AppState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, body: Bytes) -> Response {
172
    let caller = match authenticate(&state, peer, &headers, "POST /mcp", &body).await {
173
        Ok(c) => c,
174
        Err(r) => return r,
175
    };
176
    let req: Json = match serde_json::from_slice(&body) {
177
        Ok(v) => v,
178
        Err(e) => {
179
            return (StatusCode::BAD_REQUEST, axum::Json(protocol::error(&Json::Null, protocol::PARSE_ERROR, &format!("not JSON: {e}"))))
180
                .into_response()
181
        }
182
    };
183
    let new_session = req.pointer("/params/arguments/new_session").and_then(Json::as_bool).unwrap_or(false)
184
        && req.pointer("/params/name").and_then(Json::as_str) == Some("brief");
185
    let session = state.daemon.touch(&caller.principal, new_session).await;
186
    let h = Headers {
187
        version: headers.get("mcp-protocol-version").and_then(|v| v.to_str().ok()),
188
        method: headers.get("mcp-method").and_then(|v| v.to_str().ok()),
189
        name: headers.get("mcp-name").and_then(|v| v.to_str().ok()),
190
    };
191
    let version = h.version.unwrap_or("2025-03-26").to_string();
192
    let ctx = Ctx { daemon: state.daemon.clone(), caller, session };
193
    let meta = meta(&ctx).await;
194
    match protocol::handle(&ctx, &h, req, meta.clone()).await {
195
        Reply::Json(status, v) => (StatusCode::from_u16(status).unwrap_or(StatusCode::OK), axum::Json(v)).into_response(),
196
        Reply::Accepted => StatusCode::ACCEPTED.into_response(),
197
        Reply::Stream(id, tool, args) => {
198
            let fut = async move {
037c45d8 Andy McCall ↶ 199
                let out = crate::tools::invoke(&ctx, tool, args).await;
571572f5 Andy McCall ↶ 200
                let meta = crate::http::meta(&ctx).await;
201
                let msg = protocol::stream_result(&id, &version, &out, meta);
202
                Ok::<_, Infallible>(SseEvent::default().event("message").data(msg.to_string()))
203
            };
204
            sse(stream::once(fut).boxed())
205
        }
206
        Reply::Listen(id, sub) => {
207
            let ack = json!({ "jsonrpc": "2.0", "method": "notifications/subscriptions/acknowledged", "params": { "_meta": { "io.modelcontextprotocol/subscriptionId": sub } } });
208
            let _ = id;
209
            let first = stream::once(async move { Ok::<_, Infallible>(SseEvent::default().event("message").data(ack.to_string())) });
210
            // Spectra's tool list never changes while it runs: the stream
211
            // stays open, quiet but for keep-alives, until the client closes it.
212
            sse(first.chain(stream::pending()).boxed())
213
        }
214
    }
215
}
216
217
fn sse(s: futures::stream::BoxStream<'static, Result<SseEvent, Infallible>>) -> Response {
218
    let mut r = Sse::new(s).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)).text("")).into_response();
219
    r.headers_mut().insert("x-accel-buffering", "no".parse().unwrap());
220
    r
221
}
222
037c45d8 Andy McCall ↶ 223
/// `_meta` on every result (spec §15.2): the rules version, the session and
224
/// the unread count.
571572f5 Andy McCall ↶ 225
pub async fn meta(ctx: &Ctx) -> Json {
226
    let mut m = serde_json::Map::new();
b044d909 Andy McCall ↶ 227
    if let Ok(r) = spectra_core::rules::compile(ctx.daemon.ledger.pool(), crate::tools::project_of(&ctx.caller.principal)).await {
037c45d8 Andy McCall ↶ 228
        m.insert("spectra/rules_version".into(), json!(r.version));
229
    }
571572f5 Andy McCall ↶ 230
    if let Some(s) = &ctx.session {
231
        m.insert("spectra/session".into(), json!(s));
232
    }
233
    if let Ok(n) = spectra_core::conversation::unread(&ctx.daemon.ledger, &ctx.caller.principal.actor()).await {
234
        m.insert("spectra/inbox_unread".into(), json!(n));
235
    }
236
    Json::Object(m)
237
}
238
239
/// `POST /api/v1/ledger/events`: the owner's CLI hands over events it
240
/// signed over their payload, so `agent add` and `project add` work while
241
/// the daemon holds the ledger (spec §8.5, "One writer").
242
async fn owner_events(
243
    State(state): State<AppState>,
244
    ConnectInfo(peer): ConnectInfo<PeerInfo>,
245
    headers: HeaderMap,
246
    body: Bytes,
247
) -> Response {
248
    let caller = match authenticate(&state, peer, &headers, "POST /api/v1/ledger/events", &body).await {
249
        Ok(c) => c,
250
        Err(r) => return r,
251
    };
252
    if !matches!(caller.principal, Principal::Owner { .. }) {
253
        return (StatusCode::FORBIDDEN, axum::Json(json!({ "title": "only the owner hands events to the daemon" }))).into_response();
254
    }
255
    let events: Vec<WireEvent> = match serde_json::from_slice(&body) {
256
        Ok(v) => v,
257
        Err(e) => return (StatusCode::BAD_REQUEST, axum::Json(json!({ "title": format!("not a list of events: {e}") }))).into_response(),
258
    };
259
    match state.daemon.accept_owner_events(events).await {
260
        Ok(seqs) => axum::Json(json!({ "appended": seqs })).into_response(),
261
        Err(e) => (StatusCode::UNPROCESSABLE_ENTITY, axum::Json(json!({ "title": e.to_string() }))).into_response(),
262
    }
263
}