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