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,
31}
32
33impl McpContext {
34 pub fn new(auth: AuthCatalog, allow_mutations: bool) -> Self {
35 Self {
36 auth,
37 allow_mutations,
38 }
39 }
40}
41
42pub fn install_stderr_tracing(level: &str) {
45 use tracing_subscriber::EnvFilter;
46 let filter = EnvFilter::try_new(level).unwrap_or_else(|_| EnvFilter::new("info"));
47 let _ = tracing_subscriber::fmt()
48 .with_env_filter(filter)
49 .with_writer(std::io::stderr)
50 .try_init();
51}
52
53fn server_info() -> Value {
55 json!({ "name": "faucet", "version": env!("CARGO_PKG_VERSION") })
56}
57
58pub async fn handle_message(ctx: &McpContext, raw: &str) -> Option<String> {
62 let req: JsonRpcRequest = match serde_json::from_str(raw) {
63 Ok(r) => r,
64 Err(e) => {
65 return Some(render(error_no_id(
66 PARSE_ERROR,
67 format!("parse error: {e}"),
68 )));
69 }
70 };
71
72 if req.is_notification() {
74 return None;
75 }
76 let id = req.id.clone().unwrap_or(Value::Null);
77
78 let resp = match req.method.as_str() {
79 "initialize" => success(
80 id,
81 json!({
82 "protocolVersion": PROTOCOL_VERSION,
83 "capabilities": { "tools": {}, "resources": {} },
84 "serverInfo": server_info(),
85 }),
86 ),
87 "ping" => success(id, json!({})),
88 "tools/list" => {
89 let defs = tools::tool_defs(ctx);
90 success(id, json!({ "tools": defs }))
91 }
92 "tools/call" => match req.params.get("name").and_then(Value::as_str) {
93 Some(name) => {
94 let empty = json!({});
95 let args = req.params.get("arguments").unwrap_or(&empty);
96 let result = tools::call_tool(ctx, name, args).await;
97 success(id, result)
98 }
99 None => error(id, INVALID_PARAMS, "tools/call requires a 'name' parameter"),
100 },
101 "resources/list" => success(id, json!({ "resources": [] })),
102 "resources/read" => error(
103 id,
104 INVALID_PARAMS,
105 "no readable resources are exposed in this version",
106 ),
107 other => error(id, METHOD_NOT_FOUND, format!("unknown method '{other}'")),
108 };
109 Some(render(resp))
110}
111
112pub async fn serve_stdio<R, W>(ctx: &McpContext, reader: R, writer: &mut W) -> std::io::Result<()>
118where
119 R: tokio::io::AsyncBufRead + Unpin,
120 W: tokio::io::AsyncWrite + Unpin,
121{
122 use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
123 let mut lines = reader.lines();
124 while let Some(line) = lines.next_line().await? {
125 let trimmed = line.trim();
126 if trimmed.is_empty() {
127 continue;
128 }
129 if let Some(response) = handle_message(ctx, trimmed).await {
130 writer.write_all(response.as_bytes()).await?;
131 writer.write_all(b"\n").await?;
132 writer.flush().await?;
133 }
134 }
135 Ok(())
136}
137
138fn render(v: Value) -> String {
139 serde_json::to_string(&v).unwrap_or_else(|_| {
140 r#"{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"failed to serialize response"}}"#.to_string()
141 })
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn ctx() -> McpContext {
149 McpContext::new(
150 crate::auth_catalog::build_auth_catalog(None).unwrap(),
151 false,
152 )
153 }
154
155 async fn call(raw: &str) -> Value {
156 let s = handle_message(&ctx(), raw).await.expect("response");
157 serde_json::from_str(&s).unwrap()
158 }
159
160 #[tokio::test]
161 async fn initialize_reports_protocol_and_server() {
162 let v = call(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#).await;
163 assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION);
164 assert_eq!(v["result"]["serverInfo"]["name"], "faucet");
165 assert!(v["result"]["capabilities"]["tools"].is_object());
166 }
167
168 #[tokio::test]
169 async fn ping_ok() {
170 let v = call(r#"{"jsonrpc":"2.0","id":2,"method":"ping"}"#).await;
171 assert!(v["result"].is_object());
172 }
173
174 #[tokio::test]
175 async fn notification_gets_no_response() {
176 let out = handle_message(
177 &ctx(),
178 r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#,
179 )
180 .await;
181 assert!(out.is_none());
182 }
183
184 #[tokio::test]
185 async fn tools_list_has_readonly_and_hides_mutations() {
186 let v = call(r#"{"jsonrpc":"2.0","id":3,"method":"tools/list"}"#).await;
187 let names: Vec<String> = v["result"]["tools"]
188 .as_array()
189 .unwrap()
190 .iter()
191 .map(|t| t["name"].as_str().unwrap().to_string())
192 .collect();
193 assert!(names.contains(&"list_connectors".to_string()));
194 assert!(names.contains(&"validate_config".to_string()));
195 assert!(!names.contains(&"run_pipeline".to_string()));
196 }
197
198 #[tokio::test]
199 async fn tools_list_shows_mutations_when_allowed() {
200 let ctx = McpContext::new(crate::auth_catalog::build_auth_catalog(None).unwrap(), true);
201 let s = handle_message(&ctx, r#"{"jsonrpc":"2.0","id":3,"method":"tools/list"}"#)
202 .await
203 .unwrap();
204 assert!(s.contains("run_pipeline"));
205 }
206
207 #[tokio::test]
208 async fn tools_call_dispatches() {
209 let v = call(
210 r#"{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"list_connectors","arguments":{"kind":"state"}}}"#,
211 )
212 .await;
213 assert_eq!(v["result"]["isError"], false);
214 assert!(
215 v["result"]["content"][0]["text"]
216 .as_str()
217 .unwrap()
218 .contains("state_stores")
219 );
220 }
221
222 #[tokio::test]
223 async fn tools_call_without_name_is_invalid_params() {
224 let v = call(r#"{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{}}"#).await;
225 assert_eq!(v["error"]["code"], INVALID_PARAMS);
226 }
227
228 #[tokio::test]
229 async fn unknown_method_is_method_not_found() {
230 let v = call(r#"{"jsonrpc":"2.0","id":6,"method":"frobnicate"}"#).await;
231 assert_eq!(v["error"]["code"], METHOD_NOT_FOUND);
232 }
233
234 #[tokio::test]
235 async fn malformed_json_is_parse_error() {
236 let s = handle_message(&ctx(), "not json").await.unwrap();
237 let v: Value = serde_json::from_str(&s).unwrap();
238 assert_eq!(v["error"]["code"], PARSE_ERROR);
239 }
240
241 #[tokio::test]
242 async fn resources_list_is_empty() {
243 let v = call(r#"{"jsonrpc":"2.0","id":7,"method":"resources/list"}"#).await;
244 assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 0);
245 }
246
247 #[tokio::test]
248 async fn resources_read_is_invalid_params() {
249 let v = call(r#"{"jsonrpc":"2.0","id":8,"method":"resources/read","params":{"uri":"x"}}"#)
250 .await;
251 assert_eq!(v["error"]["code"], INVALID_PARAMS);
252 }
253
254 #[test]
255 fn install_stderr_tracing_is_idempotent() {
256 install_stderr_tracing("info");
259 install_stderr_tracing("debug");
260 }
261
262 #[tokio::test]
263 async fn serve_stdio_processes_lines_and_skips_blanks_and_notifications() {
264 use std::io::Cursor;
265 let input = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"ping\"}\n\
268 \n\
269 {\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n\
270 {\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}\n";
271 let mut output: Vec<u8> = Vec::new();
272 let reader = tokio::io::BufReader::new(Cursor::new(input.as_bytes().to_vec()));
273 serve_stdio(&ctx(), reader, &mut output).await.unwrap();
274 let text = String::from_utf8(output).unwrap();
275 let lines: Vec<&str> = text.lines().collect();
276 assert_eq!(
277 lines.len(),
278 2,
279 "one response per request, none for blank/notification"
280 );
281 let first: Value = serde_json::from_str(lines[0]).unwrap();
282 assert_eq!(first["id"], 1);
283 let second: Value = serde_json::from_str(lines[1]).unwrap();
284 assert!(second["result"]["tools"].is_array());
285 }
286}