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