1use 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#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum Caller {
36 Local { uid: Option<u32> },
38 Access(Identity),
40 Unauthenticated { addr: SocketAddr },
43 User {
46 principal: Arc<crate::auth::Principal>,
47 },
48 Superadmin(Arc<crate::auth::Superadmin>),
51}
52
53impl Caller {
54 pub fn is_trusted(&self) -> bool {
57 matches!(self, Caller::Local { .. } | Caller::Superadmin(_))
58 }
59
60 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 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 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#[derive(Clone)]
130pub struct Tool {
131 pub name: String,
132 pub title: Option<String>,
133 pub description: String,
134 pub input_schema: Value,
135 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#[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 pub fn instructions(mut self, text: impl Into<String>) -> Self {
203 self.instructions = Some(text.into());
204 self
205 }
206
207 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#[derive(Debug, Clone, Default, PartialEq, Eq)]
238pub struct ToolPolicy {
239 pub allow: Vec<String>,
240 pub deny: Vec<String>,
241}
242
243impl ToolPolicy {
244 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 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 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
325pub 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
358pub 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
387pub type Authn = Arc<dyn Fn(&Request, Option<&Identity>) -> Authenticated + Send + Sync>;
390
391pub enum Authenticated {
392 None,
394 User(Arc<crate::auth::Principal>),
395 Refused,
397 Superadmin(Arc<crate::auth::Superadmin>),
400}
401
402fn 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
411pub type Authorize = Arc<
415 dyn Fn(&Caller, &Tool, Value, Option<&crate::org::OrgId>) -> crate::Result<Value> + Send + Sync,
416>;
417
418pub type Listed = Arc<dyn Fn(&Caller, &str, Option<&crate::org::OrgId>) -> bool + Send + Sync>;
420
421pub type Events = Arc<dyn Fn(&Caller, u64) -> crate::Result<super::http::StreamFn> + Send + Sync>;
424
425pub struct Audited<'a> {
428 pub caller: &'a Caller,
429 pub action: &'a str,
431 pub tool: Option<&'a Tool>,
434 pub args: &'a Value,
437 pub outcome: std::result::Result<(), &'a Error>,
438 pub origin: &'a crate::audit::Origin,
439}
440
441pub type Audit = Arc<dyn Fn(&Audited) + Send + Sync>;
443
444pub type Route = Arc<
448 dyn Fn(&Tool, &Value, &Caller, &crate::audit::Origin) -> Option<crate::Result<Value>>
449 + Send
450 + Sync,
451>;
452
453#[derive(Clone, Default)]
455pub struct Hooks {
456 pub authn: Option<Authn>,
457 pub authorize: Option<Authorize>,
458 pub events: Option<Events>,
459 pub terminal: Option<super::terminal::Terminal>,
461 pub ssh: Option<super::ssh::Ssh>,
463 pub audit: Option<Audit>,
465 pub route: Option<Route>,
467 pub listed: Option<Listed>,
469 pub refuse_anonymous: bool,
474}
475
476#[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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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#[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
1229struct 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 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
1287fn 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
1314fn 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
1333fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 ("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 assert_eq!(
2037 get(&ep, "/orgs/beta/api/v1/terminal", "app=web", &ws).status,
2038 403
2039 );
2040 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 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 assert_eq!(
2063 get(&ep, "/orgs/alpha/api/v1/terminal", "instance=box", &ws).status,
2064 101
2065 );
2066 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 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 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 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 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 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 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 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}