1pub mod protocol;
19pub mod tools;
20
21use crate::auth_catalog::AuthCatalog;
22use protocol::*;
23use serde_json::{Value, json};
24
25pub struct McpContext {
27 pub auth: AuthCatalog,
29 pub allow_mutations: bool,
32 pub allow_config_execution: bool,
44 #[cfg(feature = "templates")]
49 pub templates: Option<crate::templates::TemplateStore>,
50}
51
52impl McpContext {
53 pub fn new(auth: AuthCatalog, allow_mutations: bool) -> Self {
57 Self {
58 auth,
59 allow_mutations,
60 allow_config_execution: true,
61 #[cfg(feature = "templates")]
62 templates: None,
63 }
64 }
65
66 pub fn with_config_execution(mut self, allow: bool) -> Self {
69 self.allow_config_execution = allow;
70 self
71 }
72
73 #[cfg(feature = "templates")]
75 pub fn with_templates(mut self, store: crate::templates::TemplateStore) -> Self {
76 self.templates = Some(store);
77 self
78 }
79}
80
81pub fn install_stderr_tracing(level: &str) {
84 use tracing_subscriber::EnvFilter;
85 let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"));
86 let _ = tracing_subscriber::fmt()
87 .with_env_filter(filter)
88 .with_writer(std::io::stderr)
89 .try_init();
90}
91
92fn server_info() -> Value {
94 json!({ "name": "faucet", "version": env!("CARGO_PKG_VERSION") })
95}
96
97pub async fn handle_message(ctx: &McpContext, raw: &str) -> Option<String> {
101 let req: JsonRpcRequest = match serde_json::from_str(raw) {
102 Ok(r) => r,
103 Err(e) => {
104 return Some(render(error_no_id(
105 PARSE_ERROR,
106 format!("parse error: {e}"),
107 )));
108 }
109 };
110
111 if req.is_notification() {
113 return None;
114 }
115 let id = req.id.clone().unwrap_or(Value::Null);
116
117 let resp = match req.method.as_str() {
118 "initialize" => success(
119 id,
120 json!({
121 "protocolVersion": PROTOCOL_VERSION,
122 "capabilities": { "tools": {}, "resources": {} },
123 "serverInfo": server_info(),
124 }),
125 ),
126 "ping" => success(id, json!({})),
127 "tools/list" => {
128 let defs = tools::tool_defs(ctx);
129 success(id, json!({ "tools": defs }))
130 }
131 "tools/call" => match req.params.get("name").and_then(Value::as_str) {
132 Some(name) => {
133 let empty = json!({});
134 let args = req.params.get("arguments").unwrap_or(&empty);
135 let result = tools::call_tool(ctx, name, args).await;
136 success(id, result)
137 }
138 None => error(id, INVALID_PARAMS, "tools/call requires a 'name' parameter"),
139 },
140 "resources/list" => success(id, json!({ "resources": [] })),
141 "resources/read" => error(
142 id,
143 INVALID_PARAMS,
144 "no readable resources are exposed in this version",
145 ),
146 other => error(id, METHOD_NOT_FOUND, format!("unknown method '{other}'")),
147 };
148 Some(render(resp))
149}
150
151pub async fn serve_stdio<R, W>(ctx: &McpContext, reader: R, writer: &mut W) -> std::io::Result<()>
157where
158 R: tokio::io::AsyncBufRead + Unpin,
159 W: tokio::io::AsyncWrite + Unpin,
160{
161 use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
162 let mut lines = reader.lines();
163 while let Some(line) = lines.next_line().await? {
164 let trimmed = line.trim();
165 if trimmed.is_empty() {
166 continue;
167 }
168 if let Some(response) = handle_message(ctx, trimmed).await {
169 writer.write_all(response.as_bytes()).await?;
170 writer.write_all(b"\n").await?;
171 writer.flush().await?;
172 }
173 }
174 Ok(())
175}
176
177fn render(v: Value) -> String {
178 serde_json::to_string(&v).unwrap_or_else(|_| {
179 r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"failed to serialize response"}}"#.to_string()
180 })
181}
182
183#[cfg(test)]
184mod tests {
185 use super::*;
186
187 fn ctx() -> McpContext {
188 McpContext::new(
189 crate::auth_catalog::build_auth_catalog(None).unwrap(),
190 false,
191 )
192 }
193
194 async fn call(raw: &str) -> Value {
195 let s = handle_message(&ctx(), raw).await.expect("response");
196 serde_json::from_str(&s).unwrap()
197 }
198
199 #[tokio::test]
200 async fn initialize_reports_protocol_and_server() {
201 let v = call(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#).await;
202 assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION);
203 assert_eq!(v["result"]["serverInfo"]["name"], "faucet");
204 assert!(v["result"]["capabilities"]["tools"].is_object());
205 }
206
207 #[tokio::test]
208 async fn ping_ok() {
209 let v = call(r#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#).await;
210 assert!(v["result"].is_object());
211 }
212
213 #[tokio::test]
214 async fn notification_gets_no_response() {
215 let out = handle_message(
216 &ctx(),
217 r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
218 )
219 .await;
220 assert!(out.is_none());
221 }
222
223 #[tokio::test]
224 async fn tools_list_has_readonly_and_hides_mutations() {
225 let v = call(r#"{"jsonrpc":"2.0","id":3,"method":"tools/list"}"#).await;
226 let names: Vec<String> = v["result"]["tools"]
227 .as_array()
228 .unwrap()
229 .iter()
230 .map(|t| t["name"].as_str().unwrap().to_string())
231 .collect();
232 assert!(names.contains(&"list_connectors".to_string()));
233 assert!(names.contains(&"validate_config".to_string()));
234 assert!(!names.contains(&"run_pipeline".to_string()));
235 }
236
237 #[tokio::test]
241 async fn config_executing_tools_are_hidden_and_refused_without_the_scope() {
242 let ctx = McpContext::new(
243 crate::auth_catalog::build_auth_catalog(None).unwrap(),
244 false,
245 )
246 .with_config_execution(false);
247
248 let listed = handle_message(&ctx, r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#)
249 .await
250 .unwrap();
251 assert!(
252 !listed.contains("validate_config") && !listed.contains("\"preview\""),
253 "must not be advertised: {listed}"
254 );
255 assert!(listed.contains("list_connectors"), "{listed}");
257
258 for tool in ["validate_config", "preview"] {
260 let out = tools::call_tool(
261 &ctx,
262 tool,
263 &json!({ "config": "version: 1\npipeline: {}\n" }),
264 )
265 .await;
266 assert_eq!(out["isError"], true, "{tool} must be refused");
267 let text = out["content"][0]["text"].as_str().unwrap();
268 assert!(text.contains("operator"), "{tool}: {text}");
269 }
270 }
271
272 #[tokio::test]
273 async fn tools_list_shows_mutations_when_allowed() {
274 let ctx = McpContext::new(crate::auth_catalog::build_auth_catalog(None).unwrap(), true);
275 let s = handle_message(&ctx, r#"{"jsonrpc":"2.0","id":3,"method":"tools/list"}"#)
276 .await
277 .unwrap();
278 assert!(s.contains("run_pipeline"));
279 }
280
281 #[tokio::test]
282 async fn tools_call_dispatches() {
283 let v = call(
284 r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_connectors","arguments":{"kind":"state"}}}"#,
285 )
286 .await;
287 assert_eq!(v["result"]["isError"], false);
288 assert!(
289 v["result"]["content"][0]["text"]
290 .as_str()
291 .unwrap()
292 .contains("state_stores")
293 );
294 }
295
296 #[tokio::test]
297 async fn tools_call_without_name_is_invalid_params() {
298 let v = call(r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{}}"#).await;
299 assert_eq!(v["error"]["code"], INVALID_PARAMS);
300 }
301
302 #[tokio::test]
303 async fn unknown_method_is_method_not_found() {
304 let v = call(r#"{"jsonrpc":"2.0","id":6,"method":"frobnicate"}"#).await;
305 assert_eq!(v["error"]["code"], METHOD_NOT_FOUND);
306 }
307
308 #[tokio::test]
309 async fn malformed_json_is_parse_error() {
310 let s = handle_message(&ctx(), "not json").await.unwrap();
311 let v: Value = serde_json::from_str(&s).unwrap();
312 assert_eq!(v["error"]["code"], PARSE_ERROR);
313 }
314
315 #[tokio::test]
316 async fn resources_list_is_empty() {
317 let v = call(r#"{"jsonrpc":"2.0","id":7,"method":"resources/list"}"#).await;
318 assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 0);
319 }
320
321 #[tokio::test]
322 async fn resources_read_is_invalid_params() {
323 let v = call(r#"{"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri":"x"}}"#)
324 .await;
325 assert_eq!(v["error"]["code"], INVALID_PARAMS);
326 }
327
328 #[test]
329 fn install_stderr_tracing_is_idempotent() {
330 install_stderr_tracing("info");
333 install_stderr_tracing("debug");
334 }
335
336 #[tokio::test]
337 async fn serve_stdio_processes_lines_and_skips_blanks_and_notifications() {
338 use std::io::Cursor;
339 let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n\
342 \n\
343 {\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n\
344 {\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}\n";
345 let mut output: Vec<u8> = Vec::new();
346 let reader = tokio::io::BufReader::new(Cursor::new(input.as_bytes().to_vec()));
347 serve_stdio(&ctx(), reader, &mut output).await.unwrap();
348 let text = String::from_utf8(output).unwrap();
349 let lines: Vec<&str> = text.lines().collect();
350 assert_eq!(
351 lines.len(),
352 2,
353 "one response per request, none for blank/notification"
354 );
355 let first: Value = serde_json::from_str(lines[0]).unwrap();
356 assert_eq!(first["id"], 1);
357 let second: Value = serde_json::from_str(lines[1]).unwrap();
358 assert!(second["result"]["tools"].is_array());
359 }
360}