70
use std::collections::BTreeMap;
71
use std::path::PathBuf;
72
use std::sync::Mutex as StdMutex;
73
-use std::sync::{Arc, OnceLock};
73
+use std::sync::{Arc, OnceLock, RwLock as StdRwLock};
74
use std::time::Duration;
75
76
use ed_mcp::{ClientInfo, Connection, ServerSpec};
179
/// How it was reached, kept so an HTTP server can be reconnected.
180
def: ServerDef,
181
/// The live connection. `None` when the server failed to connect.
182
- conn: RwLock<Option<Arc<Connection>>>,
183
- /// Tools discovered at startup. Empty when the server failed to connect.
184
- tools: Vec<McpTool>,
185
- /// Connection error, if the handshake failed. Surfaced by `/mcp`.
186
- error: Option<String>,
182
+ conn: RwLock<Option<Live>>,
183
+ /// Tools discovered at the last (re)connect. Empty when the server failed
184
+ /// to connect. Replaced when a reconnect finds a different set.
185
+ tools: StdRwLock<Vec<McpTool>>,
186
+ /// Connection error, if the last handshake failed. Surfaced by `/mcp`.
187
+ error: StdRwLock<Option<String>>,
188
+}
189
+
190
+/// A connection and the session token it was opened with, so a changed sign-in
191
+/// can be told apart from a connection that is still current.
192
+struct Live {
193
+ conn: Arc<Connection>,
194
+ token: Option<String>,
195
+}
196
+
197
+impl ServerConn {
198
+ fn tools(&self) -> std::sync::RwLockReadGuard<'_, Vec<McpTool>> {
199
+ self.tools.read().unwrap_or_else(|e| e.into_inner())
200
+ }
201
+
202
+ fn error(&self) -> Option<String> {
203
+ self.error.read().unwrap_or_else(|e| e.into_inner()).clone()
204
+ }
205
}
206
207
/// The process-global MCP state: every configured server.
343
struct ServerDef {
344
name: String,
345
transport: TransportDef,
346
+ /// Whether the server authenticates as the signed-in siGit account. Only
347
+ /// the built-in official server does. Its bearer is read from the stored
348
+ /// credentials on every (re)connect instead of being fixed at startup.
349
+ follows_session: bool,
350
}
351
352
impl ServerDef {
353
/// The `ed-mcp` spec for this server. An `Authorization: Bearer` header is
354
/// passed as the bearer token, which is how `rmcp` wants it.
333
- fn spec(&self) -> ServerSpec {
355
+ /// The stored session token, for a server that follows the session.
356
+ fn session_token(&self) -> Option<String> {
357
+ if self.follows_session {
358
+ crate::credentials::load_token()
359
+ } else {
360
+ None
361
+ }
362
+ }
363
+
364
+ fn spec_with(&self, session_token: Option<&str>) -> ServerSpec {
365
match &self.transport {
366
TransportDef::Http { url, headers } => {
336
- headers
337
- .iter()
338
- .fold(
339
- ServerSpec::http(&self.name, url),
340
- |spec, (key, value)| match value.strip_prefix("Bearer ") {
341
- Some(token) if key.eq_ignore_ascii_case("authorization") => {
342
- spec.with_bearer(token)
343
- }
344
- _ => spec.with_header(key, value),
345
- },
346
- )
367
+ let spec =
368
+ headers
369
+ .iter()
370
+ .fold(
371
+ ServerSpec::http(&self.name, url),
372
+ |spec, (key, value)| match value.strip_prefix("Bearer ") {
373
+ Some(token) if key.eq_ignore_ascii_case("authorization") => {
374
+ spec.with_bearer(token)
375
+ }
376
+ _ => spec.with_header(key, value),
377
+ },
378
+ );
379
+ match session_token {
380
+ Some(token) => spec.with_bearer(token),
381
+ None => spec,
382
+ }
383
}
384
TransportDef::Stdio { command, args, env } => {
385
ServerSpec::stdio(&self.name, command, args.clone(), env.clone())
482
continue;
483
}
484
};
449
- upsert(&mut defs, ServerDef { name, transport });
485
+ upsert(
486
+ &mut defs,
487
+ ServerDef {
488
+ name,
489
+ transport,
490
+ follows_session: false,
491
+ },
492
+ );
493
}
494
}
495
506
// Add the baked-in official server, but never clobber a user-defined entry
507
// named `sigit` — an explicit config (e.g. a custom URL or headers) wins.
508
if include_official && !defs.iter().any(|d| d.name == OFFICIAL_SERVER_NAME) {
466
- let mut headers = Vec::new();
467
- if let Some(token) = crate::credentials::load_token() {
468
- headers.push(("Authorization".to_string(), format!("Bearer {token}")));
469
- }
509
defs.push(ServerDef {
510
name: OFFICIAL_SERVER_NAME.to_string(),
511
transport: TransportDef::Http {
512
url: official_url(),
474
- headers,
513
+ headers: Vec::new(),
514
},
515
+ follows_session: true,
516
});
517
}
518
539
args: vec!["--mcp".to_string()],
540
env: Vec::new(),
541
},
542
+ follows_session: false,
543
});
544
} else {
545
log::debug!("mcp: `{SMBCLOUD_COMMAND}` not on PATH; skipping the smbcloud server");
570
args: vec!["--mcp".to_string()],
571
env: Vec::new(),
572
},
573
+ follows_session: false,
574
});
575
} else {
576
log::debug!("mcp: `{ASCAPI_COMMAND}` not on PATH; skipping the Ascapi server");
630
let servers = futures::future::join_all(load_configs().into_iter().map(connect)).await;
631
632
for server in &servers {
591
- match &server.error {
633
+ match server.error() {
634
Some(error) => log::warn!("mcp: server '{}' unavailable: {error}", server.name),
635
None => log::info!(
636
"mcp: server '{}' ready, {} tool(s)",
637
server.name,
596
- server.tools.len()
638
+ server.tools().len()
639
),
640
}
641
}
652
/// `ServerConn`; failures land in its `error` field rather than propagating.
653
async fn connect(def: ServerDef) -> ServerConn {
654
let endpoint = def.transport.endpoint();
613
- let (conn, tools, error) =
614
- match Connection::connect(&def.spec(), &client_info(), HANDSHAKE_TIMEOUT).await {
615
- Ok(conn) => {
616
- let tools = server_tools(&def.name, &conn);
617
- (Some(Arc::new(conn)), tools, None)
618
- }
619
- Err(error) => (None, Vec::new(), Some(format!("{error:#}"))),
620
- };
655
+ let token = def.session_token();
656
+ let (live, tools, error) = match Connection::connect(
657
+ &def.spec_with(token.as_deref()),
658
+ &client_info(),
659
+ HANDSHAKE_TIMEOUT,
660
+ )
661
+ .await
662
+ {
663
+ Ok(conn) => {
664
+ let tools = server_tools(&def.name, &conn);
665
+ (
666
+ Some(Live {
667
+ conn: Arc::new(conn),
668
+ token,
669
+ }),
670
+ tools,
671
+ None,
672
+ )
673
+ }
674
+ Err(error) => (None, Vec::new(), Some(format!("{error:#}"))),
675
+ };
676
ServerConn {
677
name: def.name.clone(),
678
endpoint,
679
def,
625
- conn: RwLock::new(conn),
626
- tools,
627
- error,
680
+ conn: RwLock::new(live),
681
+ tools: StdRwLock::new(tools),
682
+ error: StdRwLock::new(error),
683
}
684
}
685
799
if name.is_empty() || taken.contains(&name) {
800
clashes.push(ServerConn {
801
endpoint: transport.endpoint(),
747
- error: Some(if name.is_empty() {
802
+ error: StdRwLock::new(Some(if name.is_empty() {
803
"the client sent a server with no name".to_string()
804
} else {
805
format!("another MCP server is already named '{name}'")
751
- }),
806
+ })),
807
def: ServerDef {
808
name: name.clone(),
809
transport,
810
+ follows_session: false,
811
},
812
conn: RwLock::new(None),
757
- tools: Vec::new(),
813
+ tools: StdRwLock::new(Vec::new()),
814
name,
815
});
816
continue;
817
}
818
taken.push(name.clone());
763
- defs.push(ServerDef { name, transport });
819
+ defs.push(ServerDef {
820
+ name,
821
+ transport,
822
+ follows_session: false,
823
+ });
824
}
825
826
let mut connected = futures::future::join_all(defs.into_iter().map(connect)).await;
827
connected.extend(clashes);
828
829
for server in &connected {
770
- match &server.error {
830
+ match server.error() {
831
Some(error) => log::warn!(
832
"mcp: client-supplied server '{}' unavailable: {error}",
833
server.name
835
None => log::info!(
836
"mcp: client-supplied server '{}' ready, {} tool(s)",
837
server.name,
778
- server.tools.len()
838
+ server.tools().len()
839
),
840
}
841
}
869
let startup = MCP.get().map(|mcp| mcp.servers.as_slice()).unwrap_or(&[]);
870
let mut specs = Vec::new();
871
for server in startup.iter().chain(session.0.iter()) {
812
- for tool in &server.tools {
872
+ for tool in server.tools().iter() {
873
specs.push(ToolSpec {
874
name: tool.full_name.clone(),
875
description: tool.description.clone(),
890
return "Error: MCP is not initialized.".to_string();
891
}
892
833
- let Some((server, tool)) = startup.iter().chain(session.0.iter()).find_map(|s| {
834
- s.tools
893
+ let Some((server, remote_name)) = startup.iter().chain(session.0.iter()).find_map(|s| {
894
+ s.tools()
895
.iter()
896
.find(|t| t.full_name == full_name)
837
- .map(|t| (s, t))
897
+ .map(|t| (s, t.remote_name.clone()))
898
}) else {
899
return format!("Error: unknown MCP tool \"{full_name}\".");
900
};
911
}
912
};
913
854
- match call_server(server, &tool.remote_name, args).await {
914
+ match call_server(server, &remote_name, args).await {
915
Ok(text) => truncate(text),
916
Err(error) => format!("Error: {error}"),
917
}
935
} else {
936
"server"
937
};
878
- let Some(conn) = server.conn.read().await.clone() else {
938
+ // Reconnect before sending if the signed-in account changed, so a call is
939
+ // never made with the previous account's bearer.
940
+ if server.session_changed().await
941
+ && let Err(error) = reconnect(server).await
942
+ {
943
+ return Err(format!("{kind} '{}': {error}", server.name));
944
+ }
945
+ let Some(conn) = server
946
+ .conn
947
+ .read()
948
+ .await
949
+ .as_ref()
950
+ .map(|live| live.conn.clone())
951
+ else {
952
return Err(format!("{kind} '{}' is not connected", server.name));
953
};
954
let result = match conn.call_tool(remote_name, args.clone(), timeout).await {
958
}
959
Err(error) => {
960
log::info!("mcp: '{}' failed ({error:#}); reconnecting", server.name);
888
- let fresh = Connection::connect(&server.def.spec(), &client_info(), HANDSHAKE_TIMEOUT)
889
- .await
890
- .map(Arc::new)
891
- .map_err(|again| {
892
- format!(
893
- "server '{}': {error:#}; reconnecting failed: {again:#}",
894
- server.name
895
- )
896
- })?;
897
- *server.conn.write().await = Some(fresh.clone());
961
+ let fresh = reconnect(server).await.map_err(|again| {
962
+ format!(
963
+ "server '{}': {error:#}; reconnecting failed: {again}",
964
+ server.name
965
+ )
966
+ })?;
967
fresh
968
.call_tool(remote_name, args, timeout)
969
.await
973
Ok(render_tool_result(&result))
974
}
975
976
+impl ServerConn {
977
+ /// Whether this server follows the signed-in account and the stored token
978
+ /// is no longer the one its connection was opened with (or it has none).
979
+ async fn session_changed(&self) -> bool {
980
+ if !self.def.follows_session {
981
+ return false;
982
+ }
983
+ let current = crate::credentials::load_token();
984
+ match self.conn.read().await.as_ref() {
985
+ Some(live) => live.token != current,
986
+ None => true,
987
+ }
988
+ }
989
+}
990
+
991
+/// Open a fresh connection with the current spec and swap it in, along with
992
+/// the tools it lists and the handshake outcome.
993
+async fn reconnect(server: &ServerConn) -> Result<Arc<Connection>, String> {
994
+ let token = server.def.session_token();
995
+ let result = Connection::connect(
996
+ &server.def.spec_with(token.as_deref()),
997
+ &client_info(),
998
+ HANDSHAKE_TIMEOUT,
999
+ )
1000
+ .await;
1001
+ match result {
1002
+ Ok(conn) => {
1003
+ let conn = Arc::new(conn);
1004
+ *server.tools.write().unwrap_or_else(|e| e.into_inner()) =
1005
+ server_tools(&server.name, &conn);
1006
+ *server.error.write().unwrap_or_else(|e| e.into_inner()) = None;
1007
+ *server.conn.write().await = Some(Live {
1008
+ conn: conn.clone(),
1009
+ token,
1010
+ });
1011
+ Ok(conn)
1012
+ }
1013
+ Err(error) => {
1014
+ let message = format!("{error:#}");
1015
+ // Drop the old connection: it carries a bearer that no longer
1016
+ // matches the stored account.
1017
+ if server.def.follows_session {
1018
+ *server.conn.write().await = None;
1019
+ server
1020
+ .tools
1021
+ .write()
1022
+ .unwrap_or_else(|e| e.into_inner())
1023
+ .clear();
1024
+ }
1025
+ *server.error.write().unwrap_or_else(|e| e.into_inner()) = Some(message.clone());
1026
+ Err(message)
1027
+ }
1028
+ }
1029
+}
1030
+
1031
+/// Reconnect the official server if the stored sign-in changed since it
1032
+/// connected, so its tool list and `/mcp` entry update right away. No-op when
1033
+/// MCP isn't initialized or the token is unchanged.
1034
+pub async fn refresh_session_auth() {
1035
+ let Some(mcp) = MCP.get() else {
1036
+ return;
1037
+ };
1038
+ for server in mcp.servers.iter().filter(|s| s.def.follows_session) {
1039
+ if server.session_changed().await {
1040
+ log::info!("mcp: '{}' sign-in changed; reconnecting", server.name);
1041
+ if let Err(error) = reconnect(server).await {
1042
+ log::warn!("mcp: server '{}' unavailable: {error}", server.name);
1043
+ }
1044
+ }
1045
+ }
1046
+}
1047
+
1048
/// Flatten an MCP `tools/call` result into text. Joins text content blocks;
1049
/// notes non-text blocks; honors `isError`.
1050
fn render_tool_result(result: &Value) -> String {
1134
let mcp = MCP.get()?;
1135
Some(Overview {
1136
servers: mcp.servers.len(),
996
- tools: mcp.servers.iter().map(|s| s.tools.len()).sum(),
997
- unavailable: mcp.servers.iter().filter(|s| s.error.is_some()).count(),
1137
+ tools: mcp.servers.iter().map(|s| s.tools().len()).sum(),
1138
+ unavailable: mcp.servers.iter().filter(|s| s.error().is_some()).count(),
1139
})
1140
}
1141
1143
/// session's client supplied, which are marked as such.
1144
fn summarize_servers(startup: &[ServerConn], session: &[ServerConn]) -> String {
1145
let all = || startup.iter().chain(session.iter());
1005
- let total_tools: usize = all().map(|s| s.tools.len()).sum();
1146
+ let total_tools: usize = all().map(|s| s.tools().len()).sum();
1147
let mut lines = vec![format!(
1148
"{} MCP server(s), {total_tools} tool(s) available:",
1149
all().count()
1154
} else {
1155
""
1156
};
1016
- match &server.error {
1157
+ match server.error() {
1158
Some(error) => lines.push(format!(
1159
"- {} ({}{origin}) — unavailable: {error}",
1160
server.name, server.endpoint
1164
"- {} ({}{origin}) — {} tool(s)",
1165
server.name,
1166
server.endpoint,
1026
- server.tools.len()
1167
+ server.tools().len()
1168
));
1028
- for tool in &server.tools {
1169
+ for tool in server.tools().iter() {
1170
lines.push(format!(" • {}", tool.full_name));
1171
}
1172
}
1397
url: "u1".into(),
1398
headers: vec![],
1399
},
1400
+ follows_session: false,
1401
}];
1402
upsert(
1403
&mut defs,
1407
url: "u2".into(),
1408
headers: vec![],
1409
},
1410
+ follows_session: false,
1411
},
1412
);
1413
assert_eq!(defs.len(), 1);
1458
// Without init() the global is unset; this must not panic.
1459
assert!(super::tool_specs().is_empty() || MCP.get().is_some());
1460
}
1461
+
1462
+ #[test]
1463
+ fn the_official_server_follows_the_session_without_a_static_header() {
1464
+ let defs = load_configs();
1465
+ let Some(official) = defs.iter().find(|d| d.name == OFFICIAL_SERVER_NAME) else {
1466
+ // SIGIT_MCP or SIGIT_MCP_OFFICIAL is set in this environment.
1467
+ return;
1468
+ };
1469
+ assert!(official.follows_session);
1470
+ match &official.transport {
1471
+ TransportDef::Http { headers, .. } => assert!(
1472
+ !headers
1473
+ .iter()
1474
+ .any(|(key, _)| key.eq_ignore_ascii_case("authorization"))
1475
+ ),
1476
+ TransportDef::Stdio { .. } => panic!("the official server is HTTP"),
1477
+ }
1478
+ }
1479
+
1480
+ #[test]
1481
+ fn a_user_defined_sigit_entry_keeps_its_header_and_does_not_follow() {
1482
+ let file: McpFile = toml::from_str(
1483
+ r#"
1484
+ [[server]]
1485
+ name = "sigit"
1486
+ url = "https://example.com/mcp"
1487
+ headers = { Authorization = "Bearer static" }
1488
+ "#,
1489
+ )
1490
+ .unwrap();
1491
+ let entry = &file.server[0];
1492
+ let mut defs = Vec::new();
1493
+ upsert(
1494
+ &mut defs,
1495
+ ServerDef {
1496
+ name: sanitize(&entry.name),
1497
+ transport: entry.transport_def().unwrap(),
1498
+ follows_session: false,
1499
+ },
1500
+ );
1501
+ assert!(!defs[0].follows_session);
1502
+ assert_eq!(defs[0].session_token(), None);
1503
+ match &defs[0].transport {
1504
+ TransportDef::Http { headers, .. } => {
1505
+ assert_eq!(headers[0].1, "Bearer static");
1506
+ }
1507
+ TransportDef::Stdio { .. } => panic!("expected HTTP"),
1508
+ }
1509
+ }
1510
}