Skip to main content

isb_server/server/
mcp.rs

1//! MCP over Streamable HTTP, hand-rolled JSON-RPC 2.0.
2//!
3//! Stateless: no `Mcp-Session-Id`, and `tools/call` works without a prior
4//! `initialize`, so the CLI can make one call per connection. Every answer is
5//! plain `application/json`; the server never streams and never initiates, so
6//! `GET /mcp` (the server-to-client SSE stream) is 405.
7
8use std::net::SocketAddr;
9use std::sync::Arc;
10use std::time::Instant;
11
12use serde_json::{Value, json};
13
14use super::Healthz;
15use super::access::{ASSERTION_HEADER, AccessValidator, Identity};
16use super::http::{Peer, Request, Response};
17use crate::error::{Error, Result};
18
19mod ambient;
20mod downscope;
21mod origin;
22pub use ambient::ambient_ok;
23pub use origin::origin;
24
25pub use isb_core::serve_client::PROTOCOL_VERSIONS;
26
27const PARSE_ERROR: i64 = -32700;
28const INVALID_REQUEST: i64 = -32600;
29const METHOD_NOT_FOUND: i64 = -32601;
30const INVALID_PARAMS: i64 = -32602;
31
32/// Who made a call. Handlers use it for audit logs and to hold remote callers
33/// to a stricter policy than the local CLI.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Caller {
36    /// Over the unix socket, where filesystem permissions are the gate.
37    Local { uid: Option<u32> },
38    /// Through Cloudflare Access, with a verified assertion.
39    Access(Identity),
40    /// Loopback TCP with Access validation explicitly turned off. Anyone who
41    /// can reach the port.
42    Unauthenticated { addr: SocketAddr },
43    /// A signed-in isb user: an API token, a session, or an Access identity
44    /// that maps to a user.
45    User {
46        principal: Arc<crate::auth::Principal>,
47    },
48    /// The unix socket's reach over HTTP: a superadmin token, or a tailnet
49    /// identity on the superadmin allow list ([`crate::auth::superadmin`]).
50    Superadmin(Arc<crate::auth::Superadmin>),
51}
52
53impl Caller {
54    /// A superadmin: the unix socket, or an HTTP caller with the socket's
55    /// reach. Every tool, no remote-spec policy, any instance.
56    pub fn is_trusted(&self) -> bool {
57        matches!(self, Caller::Local { .. } | Caller::Superadmin(_))
58    }
59
60    /// Literally the unix socket: the daemon's own user on this host.
61    pub fn is_local(&self) -> bool {
62        matches!(self, Caller::Local { .. })
63    }
64
65    pub fn superadmin(&self) -> Option<&crate::auth::Superadmin> {
66        match self {
67            Caller::Superadmin(s) => Some(s),
68            _ => None,
69        }
70    }
71
72    /// Where a superadmin's power comes from: `socket`, `token:<name>`,
73    /// `tailnet:<login>`; `None` for everyone else.
74    pub fn superadmin_source(&self) -> Option<String> {
75        match self {
76            Caller::Local { .. } => Some("socket".into()),
77            Caller::Superadmin(s) => Some(s.label()),
78            _ => None,
79        }
80    }
81
82    pub fn identity(&self) -> Option<&Identity> {
83        match self {
84            Caller::Access(id) => Some(id),
85            _ => None,
86        }
87    }
88
89    /// A signed-in user's principal. `None` for superadmins too, which
90    /// reach every org: code that filters by a principal's orgs treats them
91    /// as it treats the socket.
92    pub fn principal(&self) -> Option<&crate::auth::Principal> {
93        match self {
94            Caller::User { principal } => Some(principal),
95            _ => None,
96        }
97    }
98}
99
100impl std::fmt::Display for Caller {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        match self {
103            Caller::Local { uid: Some(u) } => write!(f, "local(uid {u})"),
104            Caller::Local { uid: None } => f.write_str("local"),
105            Caller::Access(id) if id.is_service_token() => {
106                write!(f, "service-token {}", id.name())
107            }
108            Caller::Access(id) => f.write_str(id.name()),
109            Caller::Unauthenticated { addr } => write!(f, "unauthenticated {addr}"),
110            Caller::Superadmin(s) => write!(f, "superadmin {}", s.label()),
111            Caller::User { principal } => match &principal.kind {
112                crate::auth::PrincipalKind::ApiToken { .. } => {
113                    write!(f, "{} (token)", principal.user.email)
114                }
115                crate::auth::PrincipalKind::Workspace { .. } => {
116                    f.write_str(crate::auth::WORKSPACE_ACTOR)
117                }
118                _ => f.write_str(&principal.user.email),
119            },
120        }
121    }
122}
123
124pub type ToolHandler = Arc<dyn Fn(Value, &Caller) -> Result<Value> + Send + Sync>;
125
126/// One MCP tool. `handler` gets the call's `arguments` (an object, `{}` when
127/// omitted). An `Err` is reported to the client as a tool result with
128/// `isError: true`, as MCP specifies, not as a protocol error.
129#[derive(Clone)]
130pub struct Tool {
131    pub name: String,
132    pub title: Option<String>,
133    pub description: String,
134    pub input_schema: Value,
135    /// `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`.
136    pub annotations: Option<Value>,
137    pub handler: ToolHandler,
138}
139
140impl std::fmt::Debug for Tool {
141    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
142        f.debug_struct("Tool").field("name", &self.name).finish()
143    }
144}
145
146impl Tool {
147    pub fn new(
148        name: impl Into<String>,
149        description: impl Into<String>,
150        input_schema: Value,
151        handler: impl Fn(Value, &Caller) -> Result<Value> + Send + Sync + 'static,
152    ) -> Self {
153        Tool {
154            name: name.into(),
155            title: None,
156            description: description.into(),
157            input_schema,
158            annotations: None,
159            handler: Arc::new(handler),
160        }
161    }
162
163    pub fn title(mut self, title: impl Into<String>) -> Self {
164        self.title = Some(title.into());
165        self
166    }
167
168    pub fn annotations(mut self, annotations: Value) -> Self {
169        self.annotations = Some(annotations);
170        self
171    }
172
173    fn describe(&self) -> Value {
174        let mut v = json!({
175            "name": self.name,
176            "description": self.description,
177            "inputSchema": self.input_schema,
178        });
179        if let Some(t) = &self.title {
180            v["title"] = json!(t);
181        }
182        if let Some(a) = &self.annotations {
183            v["annotations"] = a.clone();
184        }
185        v
186    }
187}
188
189/// Every tool the server offers, before any listener's policy.
190#[derive(Debug, Clone, Default)]
191pub struct Registry {
192    tools: Vec<Tool>,
193    instructions: Option<String>,
194}
195
196impl Registry {
197    pub fn new() -> Self {
198        Self::default()
199    }
200
201    /// The `instructions` returned from `initialize`.
202    pub fn instructions(mut self, text: impl Into<String>) -> Self {
203        self.instructions = Some(text.into());
204        self
205    }
206
207    /// Add a tool. Names are 1-128 of `[A-Za-z0-9_.-]` and unique.
208    pub fn register(&mut self, tool: Tool) -> Result<()> {
209        let n = &tool.name;
210        if n.is_empty()
211            || n.len() > 128
212            || !n
213                .bytes()
214                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
215        {
216            return Err(Error::invalid(format!("invalid tool name {n:?}")));
217        }
218        if self.get(n).is_some() {
219            return Err(Error::invalid(format!("tool {n:?} registered twice")));
220        }
221        self.tools.push(tool);
222        Ok(())
223    }
224
225    pub fn get(&self, name: &str) -> Option<&Tool> {
226        self.tools.iter().find(|t| t.name == name)
227    }
228
229    pub fn tools(&self) -> &[Tool] {
230        &self.tools
231    }
232}
233
234/// Which tools a listener exposes: the allow list (empty = all), then the deny
235/// list, which always wins. Entries are exact names or shell-style globs
236/// (`*`, `?`, `[a-z]`, `[!x]`).
237#[derive(Debug, Clone, Default, PartialEq, Eq)]
238pub struct ToolPolicy {
239    pub allow: Vec<String>,
240    pub deny: Vec<String>,
241}
242
243impl ToolPolicy {
244    /// From comma-separated lists, as flags and environment variables give them.
245    pub fn from_lists(allow: &str, deny: &str) -> Self {
246        let split = |s: &str| {
247            s.split(',')
248                .map(str::trim)
249                .filter(|p| !p.is_empty())
250                .map(String::from)
251                .collect()
252        };
253        ToolPolicy {
254            allow: split(allow),
255            deny: split(deny),
256        }
257    }
258
259    pub fn allows(&self, name: &str) -> bool {
260        (self.allow.is_empty() || self.allow.iter().any(|p| glob_match(p, name)))
261            && !self.deny.iter().any(|p| glob_match(p, name))
262    }
263}
264
265enum Tok {
266    Star,
267    Any,
268    Lit(char),
269    Class {
270        negated: bool,
271        ranges: Vec<(char, char)>,
272    },
273}
274
275fn tokenize(pattern: &str) -> Vec<Tok> {
276    let cs: Vec<char> = pattern.chars().collect();
277    let mut out = Vec::new();
278    let mut i = 0;
279    while i < cs.len() {
280        match cs[i] {
281            '*' => out.push(Tok::Star),
282            '?' => out.push(Tok::Any),
283            '[' => {
284                let mut j = i + 1;
285                let negated = matches!(cs.get(j), Some('!' | '^'));
286                if negated {
287                    j += 1;
288                }
289                let start = j;
290                // A `]` right after the opening bracket is a literal member.
291                if cs.get(j) == Some(&']') {
292                    j += 1;
293                }
294                while j < cs.len() && cs[j] != ']' {
295                    j += 1;
296                }
297                if j >= cs.len() {
298                    // Unclosed: the bracket is just a character.
299                    out.push(Tok::Lit('['));
300                    i += 1;
301                    continue;
302                }
303                let body = &cs[start..j];
304                let mut ranges = Vec::new();
305                let mut k = 0;
306                while k < body.len() {
307                    if k + 2 < body.len() && body[k + 1] == '-' {
308                        ranges.push((body[k], body[k + 2]));
309                        k += 3;
310                    } else {
311                        ranges.push((body[k], body[k]));
312                        k += 1;
313                    }
314                }
315                out.push(Tok::Class { negated, ranges });
316                i = j;
317            }
318            c => out.push(Tok::Lit(c)),
319        }
320        i += 1;
321    }
322    out
323}
324
325/// Shell-style glob over the whole name. Linear backtracking over the last
326/// `*`, so a hostile pattern cannot blow up.
327pub fn glob_match(pattern: &str, name: &str) -> bool {
328    let p = tokenize(pattern);
329    let s: Vec<char> = name.chars().collect();
330    let one = |t: &Tok, c: char| match t {
331        Tok::Any => true,
332        Tok::Lit(l) => *l == c,
333        Tok::Class { negated, ranges } => {
334            ranges.iter().any(|(a, b)| *a <= c && c <= *b) != *negated
335        }
336        Tok::Star => false,
337    };
338    let (mut pi, mut si) = (0, 0);
339    let mut back: Option<(usize, usize)> = None;
340    while si < s.len() {
341        if pi < p.len() && matches!(p[pi], Tok::Star) {
342            back = Some((pi, si));
343            pi += 1;
344        } else if pi < p.len() && one(&p[pi], s[si]) {
345            pi += 1;
346            si += 1;
347        } else if let Some((bp, bs)) = back {
348            pi = bp + 1;
349            si = bs + 1;
350            back = Some((bp, bs + 1));
351        } else {
352            return false;
353        }
354    }
355    p[pi..].iter().all(|t| matches!(t, Tok::Star))
356}
357
358/// `Origin` of a page on this machine. Without Access, a page on any other
359/// origin is a DNS-rebinding attempt to reach the loopback port.
360pub fn origin_is_local(origin: &str) -> bool {
361    let Some((scheme, rest)) = origin.trim().split_once("://") else {
362        return false;
363    };
364    if !(scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https"))
365        || rest.contains(['/', '?', '#', '@'])
366    {
367        return false;
368    }
369    let (host, port) = if rest.starts_with('[') {
370        match rest.find(']') {
371            Some(i) => (&rest[..=i], &rest[i + 1..]),
372            None => return false,
373        }
374    } else {
375        match rest.find(':') {
376            Some(i) => (&rest[..i], &rest[i..]),
377            None => (rest, ""),
378        }
379    };
380    let port_ok = port.is_empty()
381        || port
382            .strip_prefix(':')
383            .is_some_and(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()));
384    port_ok && (host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "[::1]")
385}
386
387/// Who is calling, from an API token or session (`Authorization: Bearer`, or
388/// the session cookie) or from a verified Access identity.
389pub type Authn = Arc<dyn Fn(&Request, Option<&Identity>) -> Authenticated + Send + Sync>;
390
391pub enum Authenticated {
392    /// No isb credential: fall back to the listener's own notion of caller.
393    None,
394    User(Arc<crate::auth::Principal>),
395    /// A credential was presented and is not valid.
396    Refused,
397    /// The unix socket's reach: a superadmin token or a tailnet identity on
398    /// the allow list.
399    Superadmin(Arc<crate::auth::Superadmin>),
400}
401
402/// Is `path` an MCP endpoint (`/mcp`, `/orgs/<org>/mcp`)?
403fn is_mcp_path(path: &str) -> bool {
404    path == "/mcp"
405        || path
406            .strip_prefix("/orgs/")
407            .and_then(|r| r.strip_suffix("/mcp"))
408            .is_some_and(|o| !o.is_empty() && !o.contains('/'))
409}
410
411/// May `caller` run `tool` with these arguments? Returns the arguments to
412/// use (an org-scoped endpoint pins `org`), or the refusal, reported as a
413/// tool error. `scope` is the org of an `/orgs/<org>/...` endpoint.
414pub type Authorize = Arc<
415    dyn Fn(&Caller, &Tool, Value, Option<&crate::org::OrgId>) -> crate::Result<Value> + Send + Sync,
416>;
417
418/// Is `tool` listed to `caller` here? For `tools/list` only; [`Authorize`] judges calls.
419pub type Listed = Arc<dyn Fn(&Caller, &str, Option<&crate::org::OrgId>) -> bool + Send + Sync>;
420
421/// The body of `GET /api/v1/events`: a stream of server-sent events for
422/// this caller, starting after `since` (from `?since=` or `Last-Event-ID`).
423pub type Events = Arc<dyn Fn(&Caller, u64) -> crate::Result<super::http::StreamFn> + Send + Sync>;
424
425/// What the audit hook hears: a tool call, admitted or refused, or a
426/// terminal opening (`terminal.open`) and closing (`terminal.close`).
427pub struct Audited<'a> {
428    pub caller: &'a Caller,
429    /// The tool's name, or `terminal.open` / `terminal.close`.
430    pub action: &'a str,
431    /// The tool, when the action is one (its annotations say whether it
432    /// only reads).
433    pub tool: Option<&'a Tool>,
434    /// The arguments as authorized (for a refusal, as sent). The hook keeps
435    /// only what it knows is safe to keep.
436    pub args: &'a Value,
437    pub outcome: std::result::Result<(), &'a Error>,
438    pub origin: &'a crate::audit::Origin,
439}
440
441/// Records what happened; it must not fail the call.
442pub type Audit = Arc<dyn Fn(&Audited) + Send + Sync>;
443
444/// What the embedder plugs into every listener.
445#[derive(Clone, Default)]
446pub struct Hooks {
447    pub authn: Option<Authn>,
448    pub authorize: Option<Authorize>,
449    pub events: Option<Events>,
450    /// Opens a terminal for `GET /orgs/<org>/api/v1/terminal` (a websocket).
451    pub terminal: Option<super::terminal::Terminal>,
452    /// Opens an SSH session for `GET /orgs/<org>/api/v1/ssh` (a websocket).
453    pub ssh: Option<super::ssh::Ssh>,
454    /// Hears every tool call on every surface, and terminal sessions.
455    pub audit: Option<Audit>,
456    /// Which tools `tools/list` shows a caller (all, when unset).
457    pub listed: Option<Listed>,
458    /// Answer 401 to a network caller who sent no credential, on every
459    /// authenticated surface (`/mcp`, the tool list and calls, events),
460    /// instead of treating them as anonymous. Off only when the embedder
461    /// runs with anonymous access on.
462    pub refuse_anonymous: bool,
463}
464
465/// One listener's view of the server: its tools, its gate, its health.
466#[doc(hidden)]
467pub struct Endpoint {
468    pub registry: Arc<Registry>,
469    pub policy: ToolPolicy,
470    pub access: Option<Arc<AccessValidator>>,
471    pub healthz: Healthz,
472    pub routes: Option<super::Routes>,
473    pub public_routes: Option<super::Routes>,
474    pub hooks: Hooks,
475}
476
477fn rpc_error(id: Value, code: i64, message: impl Into<String>) -> Value {
478    json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message.into()}})
479}
480
481type RpcResult = std::result::Result<Value, (i64, String)>;
482
483impl Endpoint {
484    #[expect(
485        clippy::excessive_nesting,
486        reason = "predates the lint ratchet; split it when next changed"
487    )]
488    pub fn handle(&self, req: &Request) -> Response {
489        match req.path.as_str() {
490            "/healthz" => match req.method.as_str() {
491                "GET" => {
492                    let (ok, v) = (self.healthz)();
493                    Response::json(if ok { 200 } else { 503 }, &v)
494                }
495                _ => Response::text(405, "method not allowed").header("Allow", "GET"),
496            },
497            "/mcp" => self.mcp(req, None),
498            "/api/v1/openapi.json" => Response::json(200, &self.openapi()),
499            "/api/v1/tools" => self.rest_list(req),
500            "/api/v1/events" => self.events(req),
501            p => {
502                if let Some(r) = self.public_routes.as_ref().and_then(|f| f(req)) {
503                    return r;
504                }
505                if let Some(rest) = p.strip_prefix("/api/v1/tools/") {
506                    return self.rest_call(req, rest, None);
507                }
508                // /orgs/<org>/mcp and /orgs/<org>/api/v1/tools/<tool>
509                if let Some(rest) = p.strip_prefix("/orgs/") {
510                    if let Some((org, tail)) = rest.split_once('/') {
511                        let Ok(org) = crate::org::OrgId::new(org) else {
512                            return Response::text(404, "no such org");
513                        };
514                        if tail == "mcp" {
515                            return self.mcp(req, Some(&org));
516                        }
517                        if let Some(tool) = tail.strip_prefix("api/v1/tools/") {
518                            return self.rest_call(req, tool, Some(&org));
519                        }
520                        if tail == "api/v1/terminal" {
521                            return self.terminal(req, &org);
522                        }
523                        if tail == "api/v1/ssh" {
524                            return self.ssh(req, &org);
525                        }
526                        if let Some(rest) = tail.strip_prefix("api/v1/workspace") {
527                            if rest.is_empty() || rest.starts_with('/') {
528                                return self.workspace_rest(req, rest, &org);
529                            }
530                        }
531                    }
532                }
533                self.extra(req)
534            }
535        }
536    }
537
538    fn mcp(&self, req: &Request, scope: Option<&crate::org::OrgId>) -> Response {
539        let caller = match self.authenticate_in(req, scope) {
540            Ok(c) => c,
541            Err(r) => return r,
542        };
543        match req.method.as_str() {
544            "POST" => self.post(req, &caller, scope, &origin(req, &caller, true)),
545            _ => Response::text(405, "method not allowed").header("Allow", "POST"),
546        }
547    }
548
549    /// The tool, if this listener offers it, with arguments the authorizer
550    /// accepted.
551    fn admit(
552        &self,
553        name: &str,
554        args: Value,
555        caller: &Caller,
556        scope: Option<&crate::org::OrgId>,
557    ) -> std::result::Result<(&Tool, Value), Admit> {
558        let tool = match self.registry.get(name) {
559            Some(t) if self.policy.allows(name) => t,
560            _ => return Err(Admit::Unknown),
561        };
562        let args = match &self.hooks.authorize {
563            Some(a) => a(caller, tool, args, scope).map_err(Admit::Refused)?,
564            None => args,
565        };
566        Ok((tool, args))
567    }
568
569    fn rest_list(&self, req: &Request) -> Response {
570        if req.method != "GET" {
571            return Response::text(405, "method not allowed").header("Allow", "GET");
572        }
573        let caller = match self.authenticate(req) {
574            Ok(c) => c,
575            Err(r) => return r,
576        };
577        // `org_endpoint`: whether an org's `/orgs/<org>/mcp` lists the tool
578        // (host, superadmin and platform tools are on `/mcp` only), so a
579        // page can count what an org connector sees.
580        let org = crate::org::OrgId::default_org();
581        let in_org = |t: &Tool| {
582            let l = &self.hooks.listed;
583            l.as_ref().is_none_or(|l| l(&caller, &t.name, Some(&org)))
584        };
585        let tools: Vec<Value> = self
586            .registry
587            .tools()
588            .iter()
589            .filter(|t| self.policy.allows(&t.name))
590            .map(|t| {
591                let mut v = t.describe();
592                v["org_endpoint"] = json!(in_org(t));
593                v
594            })
595            .collect();
596        Response::json(200, &json!({"tools": tools}))
597    }
598
599    /// `POST /api/v1/tools/<name>` with the arguments as the JSON body:
600    /// `{"result": ...}` on success, `{"error", "message", "data"}` with a
601    /// matching status otherwise.
602    fn rest_call(&self, req: &Request, name: &str, scope: Option<&crate::org::OrgId>) -> Response {
603        if req.method != "POST" {
604            return Response::text(405, "method not allowed").header("Allow", "POST");
605        }
606        self.rest_run(req, name, scope)
607    }
608
609    /// `/orgs/<org>/api/v1/workspace[/ACTION]`: the org's workspace as a
610    /// resource, over the `workspace_*` tools (docs/concepts/workspaces.md). GET
611    /// reads it (`?name=`), POST creates it, PATCH changes it, DELETE
612    /// deletes it (`{"confirm": true}`); POST `/start`, `/stop`,
613    /// `/restart`, `/rebuild`, `/token/rotate`; GET or PATCH `/settings`.
614    fn workspace_rest(&self, req: &Request, rest: &str, org: &crate::org::OrgId) -> Response {
615        let tool = match super::openapi::workspace_tool(rest, &req.method) {
616            Ok(t) => t,
617            Err(allowed) if allowed.is_empty() => {
618                return rest_error(404, "not_found", "no such workspace action");
619            }
620            Err(allowed) => {
621                return Response::text(405, "method not allowed")
622                    .header("Allow", allowed.join(", "));
623            }
624        };
625        if req.method == "GET" {
626            // Arguments from the query string.
627            let mut r = req.clone();
628            let mut args = serde_json::Map::new();
629            if let Some(n) = query_param(req, "name") {
630                args.insert("name".into(), json!(n));
631            }
632            r.body = serde_json::to_vec(&Value::Object(args)).unwrap_or_default();
633            return self.rest_run(&r, tool, Some(org));
634        }
635        self.rest_run(req, tool, Some(org))
636    }
637
638    fn rest_run(&self, req: &Request, name: &str, scope: Option<&crate::org::OrgId>) -> Response {
639        let caller = match self.authenticate_in(req, scope) {
640            Ok(c) => c,
641            Err(r) => return r,
642        };
643        let args: Value = if req.body.is_empty() {
644            json!({})
645        } else {
646            match serde_json::from_slice(&req.body) {
647                Ok(v @ Value::Object(_)) => v,
648                Ok(_) => return rest_error(400, "invalid", "the body must be a JSON object"),
649                Err(e) => return rest_error(400, "invalid", &format!("bad JSON: {e}")),
650            }
651        };
652        let origin = origin(req, &caller, false);
653        match self.call_audited(name, args, &caller, scope, &origin) {
654            Some(Ok(v)) => Response::json(200, &json!({"result": v})),
655            Some(Err(e)) => error_response(&e),
656            None => rest_error(404, "not_found", &format!("unknown tool: {name}")),
657        }
658    }
659
660    /// Admit and run a tool, telling the audit hook how it went. `None`
661    /// when this listener does not offer the tool.
662    fn call_audited(
663        &self,
664        name: &str,
665        args: Value,
666        caller: &Caller,
667        scope: Option<&crate::org::OrgId>,
668        origin: &crate::audit::Origin,
669    ) -> Option<crate::Result<Value>> {
670        // As sent, but in the org an org-bound endpoint acts in, so a
671        // refusal is filed under the org it was aimed at.
672        let sent = self.hooks.audit.as_ref().map(|_| {
673            let mut a = args.clone();
674            if let (Some(o), Some(m)) = (scope, a.as_object_mut()) {
675                m.insert("org".into(), json!(o.as_str()));
676            }
677            a
678        });
679        let (tool, args) = match self.admit(name, args, caller, scope) {
680            Ok(x) => x,
681            Err(Admit::Unknown) => return None,
682            Err(Admit::Refused(e)) => {
683                eprintln!("isb serve: {caller} called {name}: refused: {e}");
684                if let (Some(a), Some(sent)) = (&self.hooks.audit, &sent) {
685                    a(&Audited {
686                        caller,
687                        action: name,
688                        tool: self.registry.get(name),
689                        args: sent,
690                        outcome: Err(&e),
691                        origin,
692                    });
693                }
694                return Some(Err(e));
695            }
696        };
697        let kept = self.hooks.audit.as_ref().map(|_| args.clone());
698        let r = run(tool, args, caller);
699        if let (Some(a), Some(kept)) = (&self.hooks.audit, &kept) {
700            a(&Audited {
701                caller,
702                action: name,
703                tool: Some(tool),
704                args: kept,
705                outcome: r.as_ref().map(|_| ()),
706                origin,
707            });
708        }
709        Some(r)
710    }
711
712    /// `GET /orgs/<org>/api/v1/terminal?app=NAME` (or `?instance=NAME`): a
713    /// websocket to a shell, admitted as `sandbox_exec` in the org would be.
714    fn terminal(&self, req: &Request, org: &crate::org::OrgId) -> Response {
715        use super::terminal::{origin_allowed, term_request, websocket_key};
716        if req.method != "GET" {
717            return Response::text(405, "method not allowed").header("Allow", "GET");
718        }
719        let Some(open) = &self.hooks.terminal else {
720            return Response::text(404, "not found");
721        };
722        let caller = match self.authenticate_in(req, Some(org)) {
723            Ok(c) => c,
724            Err(r) => return r,
725        };
726        if !origin_allowed(req) {
727            eprintln!(
728                "isb serve: refused a terminal from origin {:?}",
729                req.header("origin")
730            );
731            return rest_error(403, "forbidden", "origin not allowed");
732        }
733        let Some(key) = websocket_key(req) else {
734            return rest_error(400, "invalid", "expected a websocket upgrade");
735        };
736        let t = match term_request(req) {
737            Ok(t) => t,
738            Err(m) => return rest_error(400, "invalid", &m),
739        };
740        let args = match &t.instance {
741            Some(i) => json!({"org": org.as_str(), "name": i}),
742            None => json!({"org": org.as_str(), "app": t.app}),
743        };
744        let (open, o) = (open.clone(), org.clone());
745        self.session(
746            req,
747            caller,
748            org,
749            Session::Terminal,
750            args,
751            &key,
752            move |c: &Caller| open(c, &o, &t),
753        )
754    }
755
756    /// `GET /orgs/<org>/api/v1/ssh?instance=NAME`: a websocket carrying an
757    /// SSH connection to the instance's sshd, admitted as `sandbox_exec` in
758    /// the org would be ([`super::ssh`]).
759    fn ssh(&self, req: &Request, org: &crate::org::OrgId) -> Response {
760        use super::ssh::{origin_allowed, ssh_request};
761        if req.method != "GET" {
762            return Response::text(405, "method not allowed").header("Allow", "GET");
763        }
764        let Some(open) = &self.hooks.ssh else {
765            return Response::text(404, "not found");
766        };
767        let caller = match self.authenticate_in(req, Some(org)) {
768            Ok(c) => c,
769            Err(r) => return r,
770        };
771        if !origin_allowed(req) {
772            eprintln!(
773                "isb serve: refused SSH from origin {:?}",
774                req.header("origin")
775            );
776            return rest_error(403, "forbidden", "origin not allowed");
777        }
778        let Some(key) = super::terminal::websocket_key(req) else {
779            return rest_error(400, "invalid", "expected a websocket upgrade");
780        };
781        let s = match ssh_request(req) {
782            Ok(s) => s,
783            Err(m) => return rest_error(400, "invalid", &m),
784        };
785        let args = json!({"org": org.as_str(), "name": s.instance});
786        let (open, o) = (open.clone(), org.clone());
787        self.session(
788            req,
789            caller,
790            org,
791            Session::Ssh,
792            args,
793            &key,
794            move |c: &Caller| open(c, &o, &s),
795        )
796    }
797
798    /// Admit a websocket session as `sandbox_exec` in `org`, then upgrade
799    /// and open it, telling the audit hook when it opens and closes.
800    #[expect(clippy::too_many_arguments)]
801    fn session<F>(
802        &self,
803        req: &Request,
804        caller: Caller,
805        org: &crate::org::OrgId,
806        kind: Session,
807        args: Value,
808        key: &str,
809        open: F,
810    ) -> Response
811    where
812        F: FnOnce(&Caller) -> crate::Result<Box<dyn super::terminal::Pty>> + Send + 'static,
813    {
814        let origin = origin(req, &caller, false);
815        let audit = self.hooks.audit.clone();
816        match self.admit("sandbox_exec", json!({}), &caller, Some(org)) {
817            Ok(_) => {}
818            Err(Admit::Unknown) => {
819                return rest_error(404, "not_found", kind.not_offered());
820            }
821            Err(Admit::Refused(e)) => {
822                if let Some(a) = &audit {
823                    a(&Audited {
824                        caller: &caller,
825                        action: kind.opened(),
826                        tool: None,
827                        args: &args,
828                        outcome: Err(&e),
829                        origin: &origin,
830                    });
831                }
832                return error_response(&e);
833            }
834        }
835        let what = args
836            .get("app")
837            .or_else(|| args.get("name"))
838            .and_then(Value::as_str)
839            .unwrap_or("")
840            .to_string();
841        eprintln!("isb serve: {caller} opened {} to {org}/{what}", kind.noun());
842        super::terminal::upgrade_with(key, kind.limits(), move || {
843            let r = open(&caller);
844            let Some(a) = audit else { return r };
845            let mut args = args;
846            if let (Ok(p), Session::Terminal, Some(_)) = (&r, kind, args.get("app")) {
847                args["replica"] = json!(p.target());
848            }
849            a(&Audited {
850                caller: &caller,
851                action: kind.opened(),
852                tool: None,
853                args: &args,
854                outcome: r.as_ref().map(|_| ()),
855                origin: &origin,
856            });
857            r.map(|inner| {
858                Box::new(AuditedPty {
859                    inner,
860                    started: Instant::now(),
861                    audit: a,
862                    caller,
863                    args,
864                    origin,
865                    closed: kind.closed(),
866                }) as Box<dyn super::terminal::Pty>
867            })
868        })
869    }
870
871    fn events(&self, req: &Request) -> Response {
872        if req.method != "GET" {
873            return Response::text(405, "method not allowed").header("Allow", "GET");
874        }
875        let caller = match self.authenticate(req) {
876            Ok(c) => c,
877            Err(r) => return r,
878        };
879        let Some(ev) = &self.hooks.events else {
880            return Response::text(404, "not found");
881        };
882        let since = req
883            .header("last-event-id")
884            .map(String::from)
885            .or_else(|| query_param(req, "since"))
886            .and_then(|s| s.parse().ok())
887            .unwrap_or(0);
888        match ev(&caller, since) {
889            Ok(f) => Response::stream(200, "text/event-stream", f),
890            Err(e) => error_response(&e),
891        }
892    }
893
894    /// The OpenAPI document of this listener's whole HTTP surface
895    /// ([`super::openapi`]).
896    fn openapi(&self) -> Value {
897        let tools: Vec<&Tool> = self
898            .registry
899            .tools()
900            .iter()
901            .filter(|t| self.policy.allows(&t.name))
902            .collect();
903        super::openapi::document(&tools)
904    }
905
906    /// The embedder's routes. They authenticate their own callers, but sit
907    /// behind Access when it is configured: Access is the front door.
908    fn extra(&self, req: &Request) -> Response {
909        let Some(routes) = &self.routes else {
910            return Response::text(404, "not found");
911        };
912        if let Some(v) = &self.access {
913            let token = req.header(ASSERTION_HEADER).unwrap_or("").trim();
914            if token.is_empty() {
915                return Response::text(401, "missing Cloudflare Access assertion");
916            }
917            if let Err(d) = v.validate(token) {
918                eprintln!("isb serve: refused {:?} {}: {d}", req.peer, req.path);
919                return Response::text(401, "invalid Cloudflare Access assertion");
920            }
921        }
922        routes(req).unwrap_or_else(|| Response::text(404, "not found"))
923    }
924
925    fn authenticate(&self, req: &Request) -> std::result::Result<Caller, Response> {
926        let bearer = req.header("authorization").is_some();
927        let cookie = req.header("cookie").is_some_and(|c| {
928            c.split(';')
929                .any(|p| p.trim_start().starts_with("isb_session="))
930        });
931        // A cookie rides along on cross-site requests; a custom header does
932        // not without a CORS preflight, which isb never grants.
933        let csrf = || {
934            if !bearer && cookie && req.method != "GET" && req.header("x-isb-csrf") != Some("1") {
935                Err(rest_error(403, "forbidden", "missing X-Isb-Csrf header"))
936            } else {
937                Ok(())
938            }
939        };
940        let user = |id: Option<&Identity>| match &self.hooks.authn {
941            Some(a) => a(req, id),
942            None => Authenticated::None,
943        };
944        let superadmin = |s: Arc<crate::auth::Superadmin>| {
945            if s.source.is_ambient() {
946                return self.refuse_ambient(
947                    req,
948                    &format!("superadmin {}", s.label()),
949                    Caller::Superadmin(s.clone()),
950                );
951            }
952            Ok(Caller::Superadmin(s))
953        };
954        // An org's tailnet or Access agent is ambient too; people's own
955        // sessions and tokens are not (a cookie has the CSRF header rule).
956        let signed_in = |p: Arc<crate::auth::Principal>| {
957            csrf()?;
958            if p.is_agent() {
959                return self.refuse_ambient(
960                    req,
961                    &format!("agent {}", p.user.email),
962                    Caller::User { principal: p },
963                );
964            }
965            Ok(Caller::User { principal: p })
966        };
967        if let Some(v) = &self.access {
968            let token = req.header(ASSERTION_HEADER).unwrap_or("").trim();
969            if token.is_empty() {
970                return Err(rest_error(
971                    401,
972                    "unauthorized",
973                    "missing Cloudflare Access assertion",
974                ));
975            }
976            let id = match v.validate(token) {
977                Ok(id) => id,
978                Err(d) => {
979                    eprintln!("isb serve: refused {:?}: {d}", req.peer);
980                    return Err(rest_error(
981                        401,
982                        "unauthorized",
983                        "invalid Cloudflare Access assertion",
984                    ));
985                }
986            };
987            return match user(Some(&id)) {
988                Authenticated::User(p) => signed_in(p),
989                Authenticated::Superadmin(s) => superadmin(s),
990                Authenticated::Refused => {
991                    Err(rest_error(401, "unauthorized", "invalid credentials"))
992                }
993                Authenticated::None => Ok(Caller::Access(id)),
994            };
995        }
996        // Asked even without a credential: a tailnet identity is judged
997        // from the connection itself.
998        match user(None) {
999            Authenticated::User(p) => return signed_in(p),
1000            Authenticated::Superadmin(s) => return superadmin(s),
1001            Authenticated::Refused => {
1002                return Err(rest_error(401, "unauthorized", "invalid credentials"));
1003            }
1004            // No authenticator, or no credential: fall through.
1005            Authenticated::None => {}
1006        }
1007        if let Some(o) = req.header("origin").filter(|o| !origin_is_local(o)) {
1008            eprintln!("isb serve: refused origin {o:?}");
1009            return Err(rest_error(403, "forbidden", "origin not allowed"));
1010        }
1011        match &req.peer {
1012            Peer::Unix { uid } => Ok(Caller::Local { uid: *uid }),
1013            Peer::Tcp(_) if self.hooks.refuse_anonymous => Err(origin::sign_in_required()),
1014            Peer::Tcp(addr) => Ok(Caller::Unauthenticated { addr: *addr }),
1015        }
1016    }
1017
1018    fn post(
1019        &self,
1020        req: &Request,
1021        caller: &Caller,
1022        scope: Option<&crate::org::OrgId>,
1023        origin: &crate::audit::Origin,
1024    ) -> Response {
1025        // Clients vary in what they send here; note oddities, never refuse.
1026        if let Some(ct) = req
1027            .header("content-type")
1028            .filter(|ct| !ct.to_ascii_lowercase().starts_with("application/json"))
1029        {
1030            eprintln!("isb serve: /mcp request with Content-Type {ct:?}");
1031        }
1032        let v: Value = match serde_json::from_slice(&req.body) {
1033            Ok(v) => v,
1034            Err(e) => {
1035                return Response::json(
1036                    400,
1037                    &rpc_error(Value::Null, PARSE_ERROR, format!("parse error: {e}")),
1038                );
1039            }
1040        };
1041        let answer = match v {
1042            Value::Array(items) if items.is_empty() => {
1043                Some(rpc_error(Value::Null, INVALID_REQUEST, "empty batch"))
1044            }
1045            Value::Array(items) => {
1046                let out: Vec<Value> = items
1047                    .into_iter()
1048                    .filter_map(|m| self.message(m, caller, scope, origin))
1049                    .collect();
1050                (!out.is_empty()).then_some(Value::Array(out))
1051            }
1052            m => self.message(m, caller, scope, origin),
1053        };
1054        match answer {
1055            Some(a) => Response::json(200, &a),
1056            None => Response::new(202),
1057        }
1058    }
1059
1060    /// Answer one JSON-RPC message; `None` for notifications and responses.
1061    fn message(
1062        &self,
1063        m: Value,
1064        caller: &Caller,
1065        scope: Option<&crate::org::OrgId>,
1066        origin: &crate::audit::Origin,
1067    ) -> Option<Value> {
1068        let Value::Object(mut o) = m else {
1069            return Some(rpc_error(Value::Null, INVALID_REQUEST, "invalid request"));
1070        };
1071        let id = o.remove("id");
1072        let Some(method) = o.get("method").and_then(Value::as_str).map(String::from) else {
1073            // A response to a request we never send: nothing to say.
1074            if id.is_some() && (o.contains_key("result") || o.contains_key("error")) {
1075                return None;
1076            }
1077            return Some(rpc_error(
1078                id.unwrap_or(Value::Null),
1079                INVALID_REQUEST,
1080                "invalid request",
1081            ));
1082        };
1083        if o.get("jsonrpc").is_some_and(|j| j != "2.0") {
1084            return Some(rpc_error(
1085                id.unwrap_or(Value::Null),
1086                INVALID_REQUEST,
1087                "jsonrpc must be \"2.0\"",
1088            ));
1089        }
1090        // Notifications (initialized, cancelled, ...) need nothing from a
1091        // stateless server.
1092        let id = id?;
1093        if !(id.is_string() || id.is_number()) {
1094            return Some(rpc_error(
1095                Value::Null,
1096                INVALID_REQUEST,
1097                "id must be a string or a number",
1098            ));
1099        }
1100        let params = o.remove("params").unwrap_or(Value::Null);
1101        Some(
1102            match self.dispatch(&method, params, caller, scope, origin) {
1103                Ok(result) => json!({"jsonrpc": "2.0", "id": id, "result": result}),
1104                Err((code, msg)) => rpc_error(id, code, msg),
1105            },
1106        )
1107    }
1108
1109    fn dispatch(
1110        &self,
1111        method: &str,
1112        params: Value,
1113        caller: &Caller,
1114        scope: Option<&crate::org::OrgId>,
1115        origin: &crate::audit::Origin,
1116    ) -> RpcResult {
1117        let bad = |m: &str| Err((INVALID_PARAMS, m.to_string()));
1118        if !(params.is_null() || params.is_object()) {
1119            return bad("params must be an object");
1120        }
1121        match method {
1122            "initialize" => {
1123                let asked = params.get("protocolVersion").and_then(Value::as_str);
1124                let version = asked
1125                    .filter(|v| PROTOCOL_VERSIONS.contains(v))
1126                    .unwrap_or(PROTOCOL_VERSIONS[0]);
1127                let mut r = json!({
1128                    "protocolVersion": version,
1129                    "capabilities": {"tools": {"listChanged": false}},
1130                    "serverInfo": {"name": "isb", "version": env!("CARGO_PKG_VERSION")},
1131                });
1132                if let Some(i) = &self.registry.instructions {
1133                    r["instructions"] = json!(i);
1134                }
1135                Ok(r)
1136            }
1137            "ping" => Ok(json!({})),
1138            "tools/list" => {
1139                // We never paginate, so any cursor just means "everything".
1140                if params.get("cursor").is_some_and(|c| !c.is_string()) {
1141                    return bad("cursor must be a string");
1142                }
1143                Ok(json!({"tools": self.listed_tools(caller, scope)}))
1144            }
1145            "tools/call" => {
1146                let Some(name) = params.get("name").and_then(Value::as_str) else {
1147                    return bad("tools/call needs a tool name");
1148                };
1149                let args = match params.get("arguments") {
1150                    None | Some(Value::Null) => json!({}),
1151                    Some(a @ Value::Object(_)) => a.clone(),
1152                    Some(_) => return bad("arguments must be an object"),
1153                };
1154                // A tool hidden by policy does not exist for this listener.
1155                match self.call_audited(name, args, caller, scope, origin) {
1156                    Some(r) => Ok(tool_result(r)),
1157                    None => Err((INVALID_PARAMS, format!("unknown tool: {name}"))),
1158                }
1159            }
1160            _ => Err((METHOD_NOT_FOUND, format!("method not found: {method}"))),
1161        }
1162    }
1163}
1164
1165enum Admit {
1166    Unknown,
1167    Refused(Error),
1168}
1169
1170/// The kinds of websocket session, and what the audit log calls them.
1171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1172enum Session {
1173    Terminal,
1174    Ssh,
1175}
1176
1177impl Session {
1178    fn opened(self) -> &'static str {
1179        match self {
1180            Session::Terminal => "terminal.open",
1181            Session::Ssh => "ssh.open",
1182        }
1183    }
1184    fn closed(self) -> &'static str {
1185        match self {
1186            Session::Terminal => "terminal.close",
1187            Session::Ssh => "ssh.close",
1188        }
1189    }
1190    fn noun(self) -> &'static str {
1191        match self {
1192            Session::Terminal => "a terminal",
1193            Session::Ssh => "an SSH session",
1194        }
1195    }
1196    fn not_offered(self) -> &'static str {
1197        match self {
1198            Session::Terminal => "terminals are not offered on this listener",
1199            Session::Ssh => "SSH is not offered on this listener",
1200        }
1201    }
1202    fn limits(self) -> &'static super::terminal::Limits {
1203        match self {
1204            Session::Terminal => &super::terminal::TERMINALS,
1205            Session::Ssh => &super::ssh::LIMITS,
1206        }
1207    }
1208}
1209
1210/// A session that tells the audit hook when it ends, and how long it ran.
1211/// Never what was typed.
1212struct AuditedPty {
1213    inner: Box<dyn super::terminal::Pty>,
1214    started: Instant,
1215    audit: Audit,
1216    caller: Caller,
1217    args: Value,
1218    origin: crate::audit::Origin,
1219    /// `terminal.close` or `ssh.close`.
1220    closed: &'static str,
1221}
1222
1223impl super::terminal::Pty for AuditedPty {
1224    fn input(&mut self, data: &[u8]) -> crate::Result<()> {
1225        self.inner.input(data)
1226    }
1227    fn resize(&mut self, cols: u16, rows: u16) {
1228        self.inner.resize(cols, rows)
1229    }
1230    fn output(&mut self, wait: std::time::Duration) -> super::terminal::PtyOutput {
1231        self.inner.output(wait)
1232    }
1233    fn close(&mut self) {
1234        self.inner.close()
1235    }
1236    fn target(&self) -> Option<String> {
1237        self.inner.target()
1238    }
1239    fn details(&self) -> Option<serde_json::Map<String, Value>> {
1240        self.inner.details()
1241    }
1242}
1243
1244impl Drop for AuditedPty {
1245    fn drop(&mut self) {
1246        let mut args = self.args.clone();
1247        if let (Some(d), Some(m)) = (self.inner.details(), args.as_object_mut()) {
1248            for (k, v) in d {
1249                m.entry(k).or_insert(v);
1250            }
1251        }
1252        args["duration_s"] = json!(self.started.elapsed().as_secs());
1253        (self.audit)(&Audited {
1254            caller: &self.caller,
1255            action: self.closed,
1256            tool: None,
1257            args: &args,
1258            outcome: Ok(()),
1259            origin: &self.origin,
1260        });
1261    }
1262}
1263
1264fn rest_error(status: u16, code: &str, message: &str) -> Response {
1265    Response::json(status, &json!({"error": code, "message": message}))
1266}
1267
1268/// An isb error as a REST response, with the status its code implies.
1269fn error_response(e: &Error) -> Response {
1270    let mut v = crate::rpc::error_json(e);
1271    let code = v["code"].as_str().unwrap_or("error").to_string();
1272    let status = match code.as_str() {
1273        "invalid" | "parse" | "interpolation" | "bad_request" => 400,
1274        "forbidden" => 403,
1275        "not_found" => 404,
1276        "already_exists" => 409,
1277        "request_timeout" | "operation_timeout" | "exec_timeout" | "not_ready" => 504,
1278        _ => 500,
1279    };
1280    let message = v["message"].take();
1281    let mut body = json!({"error": code, "message": message});
1282    if let Some(d) = v.get("data").cloned() {
1283        body["data"] = d;
1284    }
1285    Response::json(status, &body)
1286}
1287
1288fn query_param(req: &Request, key: &str) -> Option<String> {
1289    req.query.as_deref()?.split('&').find_map(|kv| {
1290        let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
1291        (k == key).then(|| v.to_string())
1292    })
1293}
1294
1295/// Run a tool, logged as one line without the arguments (which can carry
1296/// secrets).
1297fn run(tool: &Tool, args: Value, caller: &Caller) -> crate::Result<Value> {
1298    let started = Instant::now();
1299    let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1300        (tool.handler)(args, caller)
1301    }))
1302    .unwrap_or_else(|_| Err(Error::Protocol(format!("tool {} panicked", tool.name))));
1303    let ms = started.elapsed().as_millis();
1304    match &r {
1305        Ok(_) => eprintln!("isb serve: {caller} called {}: ok in {ms}ms", tool.name),
1306        Err(e) => eprintln!(
1307            "isb serve: {caller} called {}: error in {ms}ms: {e}",
1308            tool.name
1309        ),
1310    }
1311    r
1312}
1313
1314/// A tool's outcome as an MCP tool result.
1315fn tool_result(r: crate::Result<Value>) -> Value {
1316    match r {
1317        Ok(v) => {
1318            let text = serde_json::to_string_pretty(&v).unwrap_or_default();
1319            let structured = if v.is_object() {
1320                v
1321            } else {
1322                json!({"result": v})
1323            };
1324            json!({
1325                "content": [{"type": "text", "text": text}],
1326                "structuredContent": structured,
1327                "isError": false,
1328            })
1329        }
1330        Err(e) => json!({
1331            "content": [{"type": "text", "text": e.to_string()}],
1332            "structuredContent": crate::rpc::error_json(&e),
1333            "isError": true,
1334        }),
1335    }
1336}
1337
1338#[cfg(test)]
1339#[path = "mcp_agent_tests.rs"]
1340mod agent_tests;
1341
1342#[cfg(test)]
1343#[path = "mcp_anonymous_tests.rs"]
1344mod anonymous_tests;
1345
1346#[cfg(test)]
1347mod tests {
1348    use super::*;
1349    use crate::server::access::tests as at;
1350
1351    fn registry() -> Registry {
1352        let mut r = Registry::new().instructions("use isb");
1353        r.register(
1354            Tool::new(
1355                "echo",
1356                "Echo arguments",
1357                json!({"type": "object"}),
1358                |a, c| Ok(json!({"args": a, "caller": c.to_string(), "trusted": c.is_trusted()})),
1359            )
1360            .title("Echo")
1361            .annotations(json!({"readOnlyHint": true})),
1362        )
1363        .unwrap();
1364        r.register(Tool::new("count", "A scalar", json!({}), |_, _| {
1365            Ok(json!(3))
1366        }))
1367        .unwrap();
1368        r.register(Tool::new("stack_rm", "Fails", json!({}), |_, _| {
1369            Err(Error::NotFound("stack web".into()))
1370        }))
1371        .unwrap();
1372        r.register(Tool::new("boom", "Panics", json!({}), |_, _| panic!("x")))
1373            .unwrap();
1374        r
1375    }
1376
1377    fn endpoint(policy: ToolPolicy, access: Option<AccessValidator>) -> Endpoint {
1378        Endpoint {
1379            registry: Arc::new(registry()),
1380            policy,
1381            access: access.map(Arc::new),
1382            healthz: Arc::new(|| (true, json!({"ok": true}))),
1383            routes: None,
1384            public_routes: None,
1385            hooks: Hooks::default(),
1386        }
1387    }
1388
1389    pub(super) fn req(
1390        method: &str,
1391        path: &str,
1392        headers: &[(&str, &str)],
1393        body: &[u8],
1394        peer: Peer,
1395    ) -> Request {
1396        Request {
1397            method: method.into(),
1398            path: path.into(),
1399            query: None,
1400            headers: headers
1401                .iter()
1402                .map(|(k, v)| (k.to_string(), v.to_string()))
1403                .collect(),
1404            body: body.to_vec(),
1405            peer,
1406        }
1407    }
1408
1409    fn local() -> Peer {
1410        Peer::Unix { uid: Some(1000) }
1411    }
1412
1413    fn post(ep: &Endpoint, body: Value) -> (u16, Value) {
1414        let r = ep.handle(&req(
1415            "POST",
1416            "/mcp",
1417            &[("Content-Type", "application/json")],
1418            &serde_json::to_vec(&body).unwrap(),
1419            local(),
1420        ));
1421        let v = if r.body.is_empty() {
1422            Value::Null
1423        } else {
1424            serde_json::from_slice(&r.body).unwrap()
1425        };
1426        (r.status, v)
1427    }
1428
1429    pub(super) fn rpc(method: &str, params: Value) -> Value {
1430        json!({"jsonrpc": "2.0", "id": 7, "method": method, "params": params})
1431    }
1432
1433    #[test]
1434    fn initialize_negotiates_version() {
1435        let ep = endpoint(ToolPolicy::default(), None);
1436        for (asked, got) in [
1437            ("2025-06-18", "2025-06-18"),
1438            ("2025-03-26", "2025-03-26"),
1439            ("2024-11-05", PROTOCOL_VERSIONS[0]),
1440        ] {
1441            let (s, v) = post(&ep, rpc("initialize", json!({"protocolVersion": asked})));
1442            assert_eq!(s, 200);
1443            assert_eq!(v["id"], 7);
1444            assert_eq!(v["result"]["protocolVersion"], got);
1445        }
1446        let (_, v) = post(&ep, rpc("initialize", json!({})));
1447        let r = &v["result"];
1448        assert_eq!(r["capabilities"], json!({"tools": {"listChanged": false}}));
1449        assert_eq!(r["serverInfo"]["name"], "isb");
1450        assert_eq!(r["instructions"], "use isb");
1451    }
1452
1453    #[test]
1454    fn notifications_and_responses_are_accepted() {
1455        let ep = endpoint(ToolPolicy::default(), None);
1456        let n = json!({"jsonrpc": "2.0", "method": "notifications/initialized"});
1457        assert_eq!(post(&ep, n.clone()).0, 202);
1458        let resp = json!({"jsonrpc": "2.0", "id": 1, "result": {}});
1459        assert_eq!(post(&ep, resp).0, 202);
1460        // A batch of only notifications has nothing to answer either.
1461        assert_eq!(post(&ep, json!([n.clone(), n])).0, 202);
1462    }
1463
1464    #[test]
1465    fn batch_answers_requests_only() {
1466        let ep = endpoint(ToolPolicy::default(), None);
1467        let (s, v) = post(
1468            &ep,
1469            json!([
1470                {"jsonrpc": "2.0", "id": "a", "method": "ping"},
1471                {"jsonrpc": "2.0", "method": "notifications/initialized"},
1472                {"jsonrpc": "2.0", "id": "b", "method": "nope"},
1473            ]),
1474        );
1475        assert_eq!(s, 200);
1476        let a = v.as_array().unwrap();
1477        assert_eq!(a.len(), 2);
1478        assert_eq!(a[0]["id"], "a");
1479        assert_eq!(a[0]["result"], json!({}));
1480        assert_eq!(a[1]["error"]["code"], METHOD_NOT_FOUND);
1481        assert_eq!(post(&ep, json!([])).1["error"]["code"], INVALID_REQUEST);
1482    }
1483
1484    #[test]
1485    fn protocol_errors() {
1486        let ep = endpoint(ToolPolicy::default(), None);
1487        let (s, v) = post(&ep, rpc("resources/list", json!({})));
1488        assert_eq!(
1489            (s, v["error"]["code"].as_i64()),
1490            (200, Some(METHOD_NOT_FOUND))
1491        );
1492        let r = ep.handle(&req("POST", "/mcp", &[], b"{not json", local()));
1493        assert_eq!(r.status, 400);
1494        let v: Value = serde_json::from_slice(&r.body).unwrap();
1495        assert_eq!(v["error"]["code"], PARSE_ERROR);
1496        assert_eq!(post(&ep, json!(42)).1["error"]["code"], INVALID_REQUEST);
1497        assert_eq!(
1498            post(&ep, json!({"jsonrpc": "1.0", "id": 1, "method": "ping"})).1["error"]["code"],
1499            INVALID_REQUEST
1500        );
1501        assert_eq!(
1502            post(&ep, rpc("tools/call", json!({"arguments": {}}))).1["error"]["code"],
1503            INVALID_PARAMS
1504        );
1505        assert_eq!(
1506            post(
1507                &ep,
1508                rpc("tools/call", json!({"name": "echo", "arguments": [1]}))
1509            )
1510            .1["error"]["code"],
1511            INVALID_PARAMS
1512        );
1513        assert_eq!(
1514            post(&ep, rpc("tools/call", json!({"name": "missing"}))).1["error"]["code"],
1515            INVALID_PARAMS
1516        );
1517        assert_eq!(
1518            post(&ep, rpc("ping", json!([1]))).1["error"]["code"],
1519            INVALID_PARAMS
1520        );
1521    }
1522
1523    #[test]
1524    fn tools_list_and_call() {
1525        let ep = endpoint(ToolPolicy::default(), None);
1526        let (_, v) = post(&ep, rpc("tools/list", json!({"cursor": "x"})));
1527        let tools = v["result"]["tools"].as_array().unwrap();
1528        assert_eq!(tools.len(), 4);
1529        assert_eq!(tools[0]["name"], "echo");
1530        assert_eq!(tools[0]["title"], "Echo");
1531        assert_eq!(tools[0]["inputSchema"], json!({"type": "object"}));
1532        assert_eq!(tools[0]["annotations"]["readOnlyHint"], true);
1533
1534        // No initialize first: the server is stateless.
1535        let (_, v) = post(
1536            &ep,
1537            rpc("tools/call", json!({"name": "echo", "arguments": {"x": 1}})),
1538        );
1539        let r = &v["result"];
1540        assert_eq!(r["isError"], false);
1541        assert_eq!(r["structuredContent"]["args"], json!({"x": 1}));
1542        assert_eq!(r["structuredContent"]["caller"], "local(uid 1000)");
1543        assert_eq!(r["structuredContent"]["trusted"], true);
1544        let text: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap();
1545        assert_eq!(text, r["structuredContent"]);
1546
1547        let (_, v) = post(&ep, rpc("tools/call", json!({"name": "count"})));
1548        assert_eq!(v["result"]["structuredContent"], json!({"result": 3}));
1549
1550        let (_, v) = post(&ep, rpc("tools/call", json!({"name": "stack_rm"})));
1551        let r = &v["result"];
1552        assert_eq!(r["isError"], true);
1553        assert_eq!(r["content"][0]["text"], "stack web not found");
1554        assert_eq!(r["structuredContent"]["code"], "not_found");
1555
1556        let (_, v) = post(&ep, rpc("tools/call", json!({"name": "boom"})));
1557        assert_eq!(v["result"]["isError"], true);
1558    }
1559
1560    #[test]
1561    fn policy_filters_list_and_call() {
1562        let ep = endpoint(
1563            ToolPolicy::from_lists("echo, stack_*, count", "stack_rm"),
1564            None,
1565        );
1566        let (_, v) = post(&ep, rpc("tools/list", Value::Null));
1567        let names: Vec<&str> = v["result"]["tools"]
1568            .as_array()
1569            .unwrap()
1570            .iter()
1571            .map(|t| t["name"].as_str().unwrap())
1572            .collect();
1573        assert_eq!(names, ["echo", "count"]);
1574        let (_, v) = post(&ep, rpc("tools/call", json!({"name": "stack_rm"})));
1575        assert_eq!(v["error"]["code"], INVALID_PARAMS);
1576        let (_, v) = post(&ep, rpc("tools/call", json!({"name": "boom"})));
1577        assert_eq!(v["error"]["code"], INVALID_PARAMS);
1578    }
1579
1580    #[test]
1581    fn globs() {
1582        for (p, n, want) in [
1583            ("stack_*", "stack_up", true),
1584            ("stack_*", "stack_", true),
1585            ("stack_*", "stacks", false),
1586            ("*", "anything", true),
1587            ("*_get", "sandbox_get", true),
1588            ("*_get", "sandbox_gets", false),
1589            ("s?ack", "stack", true),
1590            ("s?ack", "sack", false),
1591            ("a*b*c", "aXbYbZc", true),
1592            ("a*b*c", "aXbYbZ", false),
1593            ("[sv]*", "volume_ls", true),
1594            ("[!sv]*", "volume_ls", false),
1595            ("x[a-c]", "xb", true),
1596            ("x[a-c]", "xd", false),
1597            ("x[]]", "x]", true),
1598            ("[unclosed", "[unclosed", true),
1599            ("exact", "exact", true),
1600            ("exact", "exactly", false),
1601            ("", "", true),
1602            ("", "a", false),
1603        ] {
1604            assert_eq!(glob_match(p, n), want, "{p} vs {n}");
1605        }
1606        // Pathological patterns stay fast.
1607        let hostile = "*a".repeat(50);
1608        assert!(!glob_match(&hostile, &"a".repeat(40)));
1609        assert!(glob_match(&hostile, &"a".repeat(60)));
1610        let p = ToolPolicy::from_lists("", " , ");
1611        assert!(p.allow.is_empty() && p.deny.is_empty() && p.allows("x"));
1612    }
1613
1614    #[test]
1615    fn origins() {
1616        for (o, ok) in [
1617            ("http://localhost:5173", true),
1618            ("https://LOCALHOST", true),
1619            ("http://127.0.0.1", true),
1620            ("http://[::1]:8080", true),
1621            ("http://localhost.evil.com", false),
1622            ("http://127.0.0.1.nip.io", false),
1623            ("https://evil.example", false),
1624            ("null", false),
1625            ("http://localhost:abc", false),
1626            ("http://localhost/path", false),
1627            ("file://localhost", false),
1628        ] {
1629            assert_eq!(origin_is_local(o), ok, "{o}");
1630        }
1631        let ep = endpoint(ToolPolicy::default(), None);
1632        let ping = serde_json::to_vec(&rpc("ping", Value::Null)).unwrap();
1633        let tcp = Peer::Tcp("127.0.0.1:1234".parse().unwrap());
1634        let r = ep.handle(&req(
1635            "POST",
1636            "/mcp",
1637            &[("Origin", "https://evil.example")],
1638            &ping,
1639            tcp.clone(),
1640        ));
1641        assert_eq!(r.status, 403);
1642        let r = ep.handle(&req(
1643            "POST",
1644            "/mcp",
1645            &[("Origin", "http://localhost:3000")],
1646            &ping,
1647            tcp.clone(),
1648        ));
1649        assert_eq!(r.status, 200);
1650        // No Origin at all: not a browser.
1651        let r = ep.handle(&req("POST", "/mcp", &[], &ping, tcp));
1652        assert_eq!(r.status, 200);
1653    }
1654
1655    #[test]
1656    fn access_gate() {
1657        let (v, _) = at::validator();
1658        let ep = endpoint(ToolPolicy::default(), Some(v));
1659        let tcp = Peer::Tcp("127.0.0.1:1234".parse().unwrap());
1660        let body = serde_json::to_vec(&rpc("tools/call", json!({"name": "echo"}))).unwrap();
1661        let r = ep.handle(&req("POST", "/mcp", &[], &body, tcp.clone()));
1662        assert_eq!(r.status, 401);
1663        let r = ep.handle(&req(
1664            "POST",
1665            "/mcp",
1666            &[(ASSERTION_HEADER, "a.b.c")],
1667            &body,
1668            tcp.clone(),
1669        ));
1670        assert_eq!(r.status, 401);
1671        let token = at::sign(&at::header(), &at::claims());
1672        // With Access on, the assertion is the gate and Origin is not checked.
1673        let r = ep.handle(&req(
1674            "POST",
1675            "/mcp",
1676            &[
1677                ("cf-access-jwt-assertion", &token),
1678                ("Origin", "https://claude.ai"),
1679            ],
1680            &body,
1681            tcp.clone(),
1682        ));
1683        assert_eq!(r.status, 200);
1684        let v: Value = serde_json::from_slice(&r.body).unwrap();
1685        assert_eq!(
1686            v["result"]["structuredContent"]["caller"],
1687            "alice@example.com"
1688        );
1689        assert_eq!(v["result"]["structuredContent"]["trusted"], false);
1690        // Health never needs the assertion.
1691        let r = ep.handle(&req("GET", "/healthz", &[], b"", tcp));
1692        assert_eq!(r.status, 200);
1693    }
1694
1695    #[test]
1696    fn routes() {
1697        let ep = endpoint(ToolPolicy::default(), None);
1698        assert_eq!(
1699            ep.handle(&req("GET", "/mcp", &[], b"", local())).status,
1700            405
1701        );
1702        assert_eq!(
1703            ep.handle(&req("DELETE", "/mcp", &[], b"", local())).status,
1704            405
1705        );
1706        assert_eq!(ep.handle(&req("GET", "/", &[], b"", local())).status, 404);
1707        assert_eq!(
1708            ep.handle(&req("POST", "/healthz", &[], b"", local()))
1709                .status,
1710            405
1711        );
1712        let sick = Endpoint {
1713            healthz: Arc::new(|| (false, json!({"ok": false}))),
1714            ..endpoint(ToolPolicy::default(), None)
1715        };
1716        let r = sick.handle(&req("GET", "/healthz", &[], b"", local()));
1717        assert_eq!(r.status, 503);
1718        assert_eq!(r.get_header("content-type"), Some("application/json"));
1719    }
1720
1721    #[test]
1722    fn extra_routes_sit_behind_access() {
1723        let routes: super::super::Routes =
1724            Arc::new(|r: &Request| (r.path == "/api/x").then(|| Response::text(200, "extra")));
1725        let tcp = Peer::Tcp("127.0.0.1:1234".parse().unwrap());
1726        // No Access: the routes answer their own paths; the rest is a 404.
1727        let open = Endpoint {
1728            routes: Some(routes.clone()),
1729            public_routes: None,
1730            ..endpoint(ToolPolicy::default(), None)
1731        };
1732        let get = |ep: &Endpoint, path: &str, h: &[(&str, &str)]| {
1733            ep.handle(&req("GET", path, h, b"", tcp.clone())).status
1734        };
1735        assert_eq!(get(&open, "/api/x", &[]), 200);
1736        assert_eq!(get(&open, "/api/y", &[]), 404);
1737        assert_eq!(get(&open, "/healthz", &[]), 200);
1738        // With Access, a route needs the assertion like /mcp does.
1739        let (v, _) = at::validator();
1740        let gated = Endpoint {
1741            routes: Some(routes),
1742            public_routes: None,
1743            ..endpoint(ToolPolicy::default(), Some(v))
1744        };
1745        assert_eq!(get(&gated, "/api/x", &[]), 401);
1746        assert_eq!(get(&gated, "/api/x", &[(ASSERTION_HEADER, "a.b.c")]), 401);
1747        let token = at::sign(&at::header(), &at::claims());
1748        assert_eq!(get(&gated, "/api/x", &[(ASSERTION_HEADER, &token)]), 200);
1749        assert_eq!(get(&gated, "/healthz", &[]), 200);
1750    }
1751
1752    #[test]
1753    fn registry_rejects_bad_and_duplicate_names() {
1754        let mut r = registry();
1755        let t = |n: &str| Tool::new(n, "", json!({}), |_, _| Ok(Value::Null));
1756        assert!(r.register(t("echo")).is_err());
1757        assert!(r.register(t("has space")).is_err());
1758        assert!(r.register(t("")).is_err());
1759        assert!(r.register(t(&"x".repeat(129))).is_err());
1760        assert!(r.register(t("ok.name-2")).is_ok());
1761    }
1762
1763    /// An endpoint whose authorizer pins `org` for scoped calls and refuses
1764    /// any org but "alpha", and whose events hook streams two lines.
1765    pub(super) fn hooked() -> Endpoint {
1766        let mut ep = endpoint(ToolPolicy::default(), None);
1767        ep.hooks = Hooks {
1768            authn: None,
1769            authorize: Some(Arc::new(|_c, _t, mut args, scope| {
1770                if let Some(o) = scope {
1771                    args["org"] = json!(o.as_str());
1772                }
1773                match args.get("org").and_then(Value::as_str) {
1774                    Some("alpha") | None => Ok(args),
1775                    Some(o) => Err(Error::Forbidden(format!("no access to org {o}"))),
1776                }
1777            })),
1778            events: Some(Arc::new(|_c, since| {
1779                Ok(Box::new(move |w: &mut dyn std::io::Write| {
1780                    write!(w, "id: {}\ndata: {{}}\n\n", since + 1)
1781                }))
1782            })),
1783            terminal: Some(Arc::new(|_c, _org, _t| {
1784                Err(Error::NotFound("no such app".into()))
1785            })),
1786            ssh: Some(Arc::new(|_c, _org, _s| {
1787                Err(Error::NotFound("no such instance".into()))
1788            })),
1789            audit: None,
1790            listed: None,
1791            refuse_anonymous: false,
1792        };
1793        ep
1794    }
1795
1796    /// An endpoint whose authn grants superadmin to `source` for any request
1797    /// from a tailnet peer (as the daemon's gate would after its own Host
1798    /// and whois checks), and to a bearer `isb_sa_ok`.
1799    fn superadmin_endpoint(
1800        source: crate::auth::SuperadminSource,
1801        access: Option<AccessValidator>,
1802    ) -> Endpoint {
1803        let mut ep = hooked();
1804        let mut r = registry();
1805        r.register(Tool::new("sandbox_exec", "Exec", json!({}), |_, _| {
1806            Ok(Value::Null)
1807        }))
1808        .unwrap();
1809        ep.registry = Arc::new(r);
1810        ep.access = access.map(Arc::new);
1811        let ambient = Arc::new(crate::auth::Superadmin::synthetic(source));
1812        let token = Arc::new(crate::auth::Superadmin::synthetic(
1813            crate::auth::SuperadminSource::Token {
1814                id: 1,
1815                name: "ci".into(),
1816            },
1817        ));
1818        ep.hooks.authn = Some(Arc::new(move |req: &Request, id: Option<&Identity>| {
1819            if req.header("authorization") == Some("Bearer isb_sa_ok") {
1820                return Authenticated::Superadmin(token.clone());
1821            }
1822            let tailnet =
1823                matches!(&req.peer, Peer::Tcp(a) if crate::server::tailnet::is_tailnet_ip(a.ip()));
1824            if tailnet || id.is_some() {
1825                return Authenticated::Superadmin(ambient.clone());
1826            }
1827            Authenticated::None
1828        }));
1829        ep
1830    }
1831
1832    fn tailnet_peer() -> Peer {
1833        Peer::Tcp("100.64.0.7:5000".parse().unwrap())
1834    }
1835
1836    fn ambient_cases(ep: &Endpoint, peer: Peer, extra: &[(&str, &str)]) {
1837        let body = serde_json::to_vec(&rpc("tools/call", json!({"name": "echo"}))).unwrap();
1838        let call = |path: &str, h: &[(&str, &str)], body: &[u8]| {
1839            let mut all: Vec<(&str, &str)> = extra.to_vec();
1840            all.extend_from_slice(h);
1841            ep.handle(&req("POST", path, &all, body, peer.clone()))
1842        };
1843        let host = ("Host", "100.86.22.100:18995");
1844        let json_ct = ("Content-Type", "application/json");
1845        // /mcp: JSON and no foreign Origin is a superadmin call.
1846        let r = call("/mcp", &[host, json_ct], &body);
1847        assert_eq!(r.status, 200, "{}", String::from_utf8_lossy(&r.body));
1848        let v: Value = serde_json::from_slice(&r.body).unwrap();
1849        assert_eq!(v["result"]["structuredContent"]["trusted"], true);
1850        // A form post (no JSON content type) from a page is refused.
1851        for ct in [
1852            None,
1853            Some("text/plain"),
1854            Some("application/x-www-form-urlencoded"),
1855        ] {
1856            let mut h = vec![host];
1857            if let Some(c) = ct {
1858                h.push(("Content-Type", c));
1859            }
1860            assert_eq!(call("/mcp", &h, &body).status, 403, "{ct:?}");
1861            assert_eq!(call("/orgs/alpha/mcp", &h, &body).status, 403, "{ct:?}");
1862        }
1863        // A foreign Origin is refused; the server's own passes.
1864        assert_eq!(
1865            call(
1866                "/mcp",
1867                &[host, json_ct, ("Origin", "https://evil.example")],
1868                &body
1869            )
1870            .status,
1871            403
1872        );
1873        assert_eq!(
1874            call(
1875                "/mcp",
1876                &[host, json_ct, ("Origin", "http://100.86.22.100:9999")],
1877                &body
1878            )
1879            .status,
1880            403
1881        );
1882        assert_eq!(
1883            call("/mcp", &[host, json_ct, ("Origin", "null")], &body).status,
1884            403
1885        );
1886        assert_eq!(
1887            call(
1888                "/mcp",
1889                &[host, json_ct, ("Origin", "http://100.86.22.100:18995")],
1890                &body
1891            )
1892            .status,
1893            200
1894        );
1895        // REST writes need X-Isb-Csrf, as sessions do.
1896        assert_eq!(
1897            call("/api/v1/tools/echo", &[host, json_ct], b"{}").status,
1898            403
1899        );
1900        let r = call(
1901            "/api/v1/tools/echo",
1902            &[host, json_ct, ("X-Isb-Csrf", "1")],
1903            b"{}",
1904        );
1905        assert_eq!(r.status, 200);
1906        let v: Value = serde_json::from_slice(&r.body).unwrap();
1907        assert_eq!(v["result"]["trusted"], true);
1908        assert_eq!(
1909            call(
1910                "/api/v1/tools/echo",
1911                &[
1912                    host,
1913                    ("X-Isb-Csrf", "1"),
1914                    ("Origin", "https://evil.example")
1915                ],
1916                b"{}"
1917            )
1918            .status,
1919            403
1920        );
1921        // The terminal: an upgrade needs an Origin naming the Host.
1922        let ws = |origin: Option<&str>| {
1923            let mut h: Vec<(&str, &str)> = extra.to_vec();
1924            h.extend([
1925                host,
1926                ("Upgrade", "websocket"),
1927                ("Connection", "Upgrade"),
1928                ("Sec-WebSocket-Version", "13"),
1929                ("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="),
1930            ]);
1931            if let Some(o) = origin {
1932                h.push(("Origin", o));
1933            }
1934            let mut q = req("GET", "/orgs/alpha/api/v1/terminal", &h, b"", peer.clone());
1935            q.query = Some("app=web".into());
1936            ep.handle(&q).status
1937        };
1938        assert_eq!(ws(Some("http://100.86.22.100:18995")), 101);
1939        assert_eq!(ws(Some("https://evil.example")), 403);
1940        assert_eq!(ws(None), 403);
1941    }
1942
1943    #[test]
1944    fn tailnet_superadmins_get_csrf_origin_and_content_type_checks() {
1945        let ep = superadmin_endpoint(
1946            crate::auth::SuperadminSource::Tailnet {
1947                login: "me@example.com".into(),
1948                node: "laptop.t.ts.net".into(),
1949                tags: vec![],
1950            },
1951            None,
1952        );
1953        ambient_cases(&ep, tailnet_peer(), &[]);
1954        // A superadmin token is not ambient: no CSRF header or JSON needed.
1955        let r = ep.handle(&req(
1956            "POST",
1957            "/api/v1/tools/echo",
1958            &[("Authorization", "Bearer isb_sa_ok")],
1959            b"{}",
1960            Peer::Tcp("127.0.0.1:1".parse().unwrap()),
1961        ));
1962        assert_eq!(r.status, 200);
1963        let v: Value = serde_json::from_slice(&r.body).unwrap();
1964        assert_eq!(v["result"]["caller"], "superadmin token:ci");
1965    }
1966
1967    #[test]
1968    fn access_superadmins_get_the_same_checks() {
1969        let (v, _) = at::validator();
1970        let ep = superadmin_endpoint(
1971            crate::auth::SuperadminSource::Access {
1972                name: "alice@example.com".into(),
1973                service_token: false,
1974            },
1975            Some(v),
1976        );
1977        let token = at::sign(&at::header(), &at::claims());
1978        ambient_cases(
1979            &ep,
1980            Peer::Tcp("127.0.0.1:4000".parse().unwrap()),
1981            &[("Cf-Access-Jwt-Assertion", token.as_str())],
1982        );
1983    }
1984
1985    #[test]
1986    fn terminal_upgrades_only_when_admitted() {
1987        let mut ep = hooked();
1988        let mut r = registry();
1989        r.register(Tool::new("sandbox_exec", "Exec", json!({}), |_, _| {
1990            Ok(Value::Null)
1991        }))
1992        .unwrap();
1993        ep.registry = Arc::new(r);
1994        let ws = [
1995            // Unauthenticated test callers pass only from a local page.
1996            ("Host", "localhost:8092"),
1997            ("Origin", "http://localhost:8092"),
1998            ("Upgrade", "websocket"),
1999            ("Connection", "Upgrade"),
2000            ("Sec-WebSocket-Version", "13"),
2001            ("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="),
2002        ];
2003        let get = |ep: &Endpoint, path: &str, query: &str, h: &[(&str, &str)]| {
2004            let mut q = req("GET", path, h, b"", local());
2005            q.query = Some(query.into());
2006            ep.handle(&q)
2007        };
2008        let ok = get(&ep, "/orgs/alpha/api/v1/terminal", "app=web", &ws);
2009        assert_eq!(ok.status, 101);
2010        assert_eq!(
2011            ok.get_header("sec-websocket-accept"),
2012            Some("s3pPLMBiTxaQ9kYGzzhZRbK+xOo=")
2013        );
2014        assert!(ok.upgrade.is_some());
2015        // Another org is refused before any upgrade.
2016        assert_eq!(
2017            get(&ep, "/orgs/beta/api/v1/terminal", "app=web", &ws).status,
2018            403
2019        );
2020        // A cross-site page riding the session cookie is refused.
2021        let mut evil = ws.to_vec();
2022        evil[1] = ("Origin", "http://localhost:9999");
2023        assert_eq!(
2024            get(&ep, "/orgs/alpha/api/v1/terminal", "app=web", &evil).status,
2025            403
2026        );
2027        // Not a websocket, or no app: 400; not GET: 405.
2028        assert_eq!(
2029            get(&ep, "/orgs/alpha/api/v1/terminal", "app=web", &ws[..2]).status,
2030            400
2031        );
2032        assert_eq!(get(&ep, "/orgs/alpha/api/v1/terminal", "", &ws).status, 400);
2033        let r = ep.handle(&req(
2034            "POST",
2035            "/orgs/alpha/api/v1/terminal",
2036            &ws,
2037            b"",
2038            local(),
2039        ));
2040        assert_eq!(r.status, 405);
2041        // An instance of the org instead of an app.
2042        assert_eq!(
2043            get(&ep, "/orgs/alpha/api/v1/terminal", "instance=box", &ws).status,
2044            101
2045        );
2046        // --deny-tools sandbox_exec turns terminals off.
2047        ep.policy = ToolPolicy::from_lists("", "sandbox_exec");
2048        assert_eq!(
2049            get(&ep, "/orgs/alpha/api/v1/terminal", "app=web", &ws).status,
2050            404
2051        );
2052    }
2053
2054    #[test]
2055    #[expect(
2056        clippy::too_many_lines,
2057        reason = "predates the lint ratchet; split it when next changed"
2058    )]
2059    fn ssh_upgrades_only_when_admitted() {
2060        let mut ep = hooked();
2061        let mut r = registry();
2062        r.register(Tool::new("sandbox_exec", "Exec", json!({}), |_, _| {
2063            Ok(Value::Null)
2064        }))
2065        .unwrap();
2066        ep.registry = Arc::new(r);
2067        let upgrade = [
2068            ("Upgrade", "websocket"),
2069            ("Connection", "Upgrade"),
2070            ("Sec-WebSocket-Version", "13"),
2071            ("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="),
2072        ];
2073        let get = |ep: &Endpoint, path: &str, query: &str, h: &[(&str, &str)], peer: Peer| {
2074            let mut hs = upgrade.to_vec();
2075            hs.extend_from_slice(h);
2076            let mut q = req("GET", path, &hs, b"", peer);
2077            q.query = Some(query.into());
2078            ep.handle(&q)
2079        };
2080        let page = [
2081            ("Host", "localhost:8092"),
2082            ("Origin", "http://localhost:8092"),
2083        ];
2084        let ok = get(
2085            &ep,
2086            "/orgs/alpha/api/v1/ssh",
2087            "instance=box",
2088            &page,
2089            local(),
2090        );
2091        assert_eq!(ok.status, 101);
2092        assert!(ok.upgrade.is_some());
2093        // The unix socket needs no Origin.
2094        let unix = Peer::Unix { uid: None };
2095        assert_eq!(
2096            get(
2097                &ep,
2098                "/orgs/alpha/api/v1/ssh",
2099                "instance=box",
2100                &[],
2101                unix.clone()
2102            )
2103            .status,
2104            101
2105        );
2106        // Another org: refused before any upgrade.
2107        assert_eq!(
2108            get(
2109                &ep,
2110                "/orgs/beta/api/v1/ssh",
2111                "instance=box",
2112                &[],
2113                unix.clone()
2114            )
2115            .status,
2116            403
2117        );
2118        // A cross-site page: refused.
2119        let evil = [
2120            ("Host", "localhost:8092"),
2121            ("Origin", "http://localhost:9999"),
2122        ];
2123        assert_eq!(
2124            get(
2125                &ep,
2126                "/orgs/alpha/api/v1/ssh",
2127                "instance=box",
2128                &evil,
2129                local()
2130            )
2131            .status,
2132            403
2133        );
2134        // No instance, a bad one, or not an upgrade: 400.
2135        assert_eq!(
2136            get(&ep, "/orgs/alpha/api/v1/ssh", "", &[], unix.clone()).status,
2137            400
2138        );
2139        assert_eq!(
2140            get(
2141                &ep,
2142                "/orgs/alpha/api/v1/ssh",
2143                "instance=A/b",
2144                &[],
2145                unix.clone()
2146            )
2147            .status,
2148            400
2149        );
2150        let mut q = req("GET", "/orgs/alpha/api/v1/ssh", &[], b"", unix.clone());
2151        q.query = Some("instance=box".into());
2152        assert_eq!(ep.handle(&q).status, 400);
2153        // --deny-tools sandbox_exec turns SSH off with terminals.
2154        ep.policy = ToolPolicy::from_lists("", "sandbox_exec");
2155        assert_eq!(
2156            get(&ep, "/orgs/alpha/api/v1/ssh", "instance=box", &[], unix).status,
2157            404
2158        );
2159        // No hook: no such endpoint.
2160        let mut ep = hooked();
2161        ep.hooks.ssh = None;
2162        assert_eq!(
2163            get(
2164                &ep,
2165                "/orgs/alpha/api/v1/ssh",
2166                "instance=box",
2167                &page,
2168                local()
2169            )
2170            .status,
2171            404
2172        );
2173    }
2174
2175    #[test]
2176    fn rest_calls_and_errors() {
2177        let ep = hooked();
2178        let call = |path: &str, body: &[u8]| {
2179            let r = ep.handle(&req("POST", path, &[], body, local()));
2180            (
2181                r.status,
2182                serde_json::from_slice::<Value>(&r.body).unwrap_or(Value::Null),
2183            )
2184        };
2185        let (st, v) = call("/api/v1/tools/echo", br#"{"org":"alpha","x":1}"#);
2186        assert_eq!(st, 200);
2187        assert_eq!(v["result"]["args"]["x"], 1);
2188        let (st, v) = call("/api/v1/tools/echo", br#"{"org":"beta"}"#);
2189        assert_eq!((st, v["error"].as_str()), (403, Some("forbidden")));
2190        let (st, _) = call("/api/v1/tools/missing", b"{}");
2191        assert_eq!(st, 404);
2192        let (st, v) = call("/api/v1/tools/stack_rm", b"{}");
2193        assert_eq!((st, v["error"].as_str()), (404, Some("not_found")));
2194        let (st, _) = call("/api/v1/tools/echo", b"[1]");
2195        assert_eq!(st, 400);
2196        // A scoped endpoint pins the org into the arguments.
2197        let (st, v) = call("/orgs/alpha/api/v1/tools/echo", b"{}");
2198        assert_eq!(
2199            (st, v["result"]["args"]["org"].as_str()),
2200            (200, Some("alpha"))
2201        );
2202        let (st, _) = call("/orgs/beta/api/v1/tools/echo", b"{}");
2203        assert_eq!(st, 403);
2204        let (st, _) = call("/orgs/Not_An_Org/api/v1/tools/echo", b"{}");
2205        assert_eq!(st, 404);
2206        let r = ep.handle(&req("GET", "/api/v1/openapi.json", &[], b"", local()));
2207        let doc: Value = serde_json::from_slice(&r.body).unwrap();
2208        assert!(doc["paths"]["/api/v1/tools/echo"]["post"].is_object());
2209    }
2210
2211    #[test]
2212    fn scoped_mcp_and_refusals_are_tool_errors() {
2213        let ep = hooked();
2214        let body = |org: Option<&str>| {
2215            let mut args = json!({});
2216            if let Some(o) = org {
2217                args["org"] = json!(o);
2218            }
2219            serde_json::to_vec(&rpc(
2220                "tools/call",
2221                json!({"name": "echo", "arguments": args}),
2222            ))
2223            .unwrap()
2224        };
2225        let r = ep.handle(&req("POST", "/orgs/alpha/mcp", &[], &body(None), local()));
2226        let v: Value = serde_json::from_slice(&r.body).unwrap();
2227        assert_eq!(v["result"]["structuredContent"]["args"]["org"], "alpha");
2228        let r = ep.handle(&req("POST", "/mcp", &[], &body(Some("beta")), local()));
2229        let v: Value = serde_json::from_slice(&r.body).unwrap();
2230        assert_eq!(v["result"]["isError"], true);
2231        assert_eq!(v["result"]["structuredContent"]["code"], "forbidden");
2232    }
2233
2234    #[test]
2235    fn events_stream() {
2236        let ep = hooked();
2237        let r = ep.handle(&req(
2238            "GET",
2239            "/api/v1/events",
2240            &[("Last-Event-ID", "41")],
2241            b"",
2242            local(),
2243        ));
2244        assert_eq!(r.get_header("content-type"), Some("text/event-stream"));
2245        let mut out = Vec::new();
2246        crate::server::http::write_response(&mut out, &r).unwrap();
2247        let text = String::from_utf8(out).unwrap();
2248        assert!(!text.contains("Content-Length"), "{text}");
2249        assert!(text.ends_with("id: 42\ndata: {}\n\n"), "{text}");
2250    }
2251}