1use super::mapping::{self, Vars};
12use super::{KeySeq, PutOutcome, Store, StoreError};
13use crate::config::v2::{StoreMcp, StoreOp};
14use crate::wire::mcp::CallToolResult;
15use serde_json::{Value, json};
16use std::sync::Arc;
17use std::time::Duration;
18
19pub trait McpCall: Send + Sync {
23 fn call(
24 &self,
25 tool: &str,
26 args: Value,
27 meta: Value,
28 timeout: Duration,
29 ) -> Result<CallToolResult, String>;
30 fn server_name(&self) -> String;
31}
32
33impl McpCall for crate::mcp::client::McpClient {
34 fn call(
35 &self,
36 tool: &str,
37 args: Value,
38 meta: Value,
39 timeout: Duration,
40 ) -> Result<CallToolResult, String> {
41 self.call_tool_with_meta_within(tool, Some(args), meta, timeout)
42 .map_err(|e| e.to_string())
43 }
44 fn server_name(&self) -> String {
45 self.name().to_string()
46 }
47}
48
49pub fn default_ops() -> (StoreOp, StoreOp, StoreOp, StoreOp) {
51 let op = |tool: &str, args: &str| StoreOp {
52 tool: tool.into(),
53 args: Some(args.into()),
54 ok: None,
55 conflict: None,
56 value: None,
57 keys: None,
58 };
59 (
60 StoreOp {
61 ok: Some("result.structuredContent.ok".into()),
62 conflict: Some("result.structuredContent.latest".into()),
63 ..op(
64 "state.put",
65 r#"{"key": "{key}", "seq": {seq}, "state": {envelope}}"#,
66 )
67 },
68 StoreOp {
69 value: Some("result.structuredContent.state".into()),
70 ..op("state.get", r#"{"key": "{key}", "seq": {seq}}"#)
71 },
72 StoreOp {
73 keys: Some("result.structuredContent.keys".into()),
74 ..op("state.list", r#"{"prefix": "{prefix}"}"#)
75 },
76 op("state.delete", r#"{"key": "{key}"}"#),
77 )
78}
79
80pub struct McpStore {
81 client: Arc<dyn McpCall>,
82 put: StoreOp,
83 get: StoreOp,
84 list: Option<StoreOp>,
85 delete: Option<StoreOp>,
86 timeout: Duration,
87 prefix_hint: std::sync::Mutex<Option<(String, String)>>,
89}
90
91impl McpStore {
92 pub fn new(client: Arc<dyn McpCall>, cfg: StoreMcp, timeout: Duration) -> McpStore {
93 let (dput, dget, dlist, ddelete) = default_ops();
94 McpStore {
95 client,
96 put: cfg.put.unwrap_or(dput),
97 get: cfg.get.unwrap_or(dget),
98 list: Some(cfg.list.unwrap_or(dlist)),
99 delete: Some(cfg.delete.unwrap_or(ddelete)),
100 timeout,
101 prefix_hint: std::sync::Mutex::new(None),
102 }
103 }
104
105 pub fn with_advertised(mut self, tools: &[String]) -> McpStore {
108 let has = |t: &str| tools.iter().any(|x| x == t);
109 if !self.list.as_ref().is_some_and(|op| has(&op.tool)) {
110 self.list = None;
111 }
112 if !self.delete.as_ref().is_some_and(|op| has(&op.tool)) {
113 self.delete = None;
114 }
115 self
116 }
117
118 fn vars(&self, key: &str, seq: Option<u64>, envelope: Option<&Value>) -> Vars {
119 let mut parts = key.splitn(4, '/');
121 let prefix = parts.next().unwrap_or("");
122 let instance = parts.next().unwrap_or("");
123 let kind = parts.next().unwrap_or("");
124 let id = parts.next().unwrap_or("");
125 if let Ok(mut h) = self.prefix_hint.lock() {
126 *h = Some((prefix.to_string(), instance.to_string()));
127 }
128 mapping::store_vars(key, seq, prefix, instance, envelope, kind, id)
129 }
130
131 fn call(
132 &self,
133 op: &StoreOp,
134 vars: &Vars,
135 key: &str,
136 seq: Option<u64>,
137 ) -> Result<Value, StoreError> {
138 let args = match &op.args {
139 Some(t) => mapping::render_json(t, vars)
140 .map_err(|e| StoreError::Mapping(format!("{}: {e}", op.tool)))?,
141 None => json!({ "key": key }),
142 };
143 let idem = match seq {
144 Some(s) => format!("{key}#{s}"),
145 None => key.to_string(),
146 };
147 let meta = json!({
148 "agent/idempotency_key": idem,
149 "agent/instance": vars.get("instance").cloned().unwrap_or(Value::Null),
150 });
151 let res = self
152 .client
153 .call(&op.tool, args, meta, self.timeout)
154 .map_err(|e| {
155 StoreError::Io(format!(
156 "{} on '{}': {e}",
157 op.tool,
158 self.client.server_name()
159 ))
160 })?;
161 Ok(result_ctx(&res))
162 }
163}
164
165pub fn result_ctx(res: &CallToolResult) -> Value {
168 let text = res.text();
169 let structured = res
170 .structured_content
171 .clone()
172 .or_else(|| serde_json::from_str::<Value>(&text).ok())
173 .unwrap_or(Value::Null);
174 json!({
175 "result": {
176 "structuredContent": structured,
177 "isError": res.is_error(),
178 "text": text,
179 "content": res.content,
180 }
181 })
182}
183
184impl Store for McpStore {
185 fn put(&self, key: &str, seq: u64, envelope: &Value) -> Result<PutOutcome, StoreError> {
186 let vars = self.vars(key, Some(seq), Some(envelope));
187 let ctx = self.call(&self.put, &vars, key, Some(seq))?;
188 let is_error = ctx["result"]["isError"].as_bool().unwrap_or(false);
189 if let Some(okx) = &self.put.ok
190 && let Some(v) = mapping::extract(okx, &ctx).map_err(|e| StoreError::Mapping(e.0))?
191 && mapping::truthy(&v)
192 {
193 return Ok(PutOutcome::Ok);
194 }
195 if let Some(cx) = &self.put.conflict
196 && let Some(v) = mapping::extract(cx, &ctx).map_err(|e| StoreError::Mapping(e.0))?
197 && !v.is_null()
198 {
199 return Ok(PutOutcome::Conflict {
200 latest_seq: v.as_u64(),
201 });
202 }
203 if is_error {
204 return Err(StoreError::Io(format!(
205 "{} failed: {}",
206 self.put.tool, ctx["result"]["text"]
207 )));
208 }
209 if self.put.ok.is_none() {
211 return Ok(PutOutcome::Ok);
212 }
213 Err(StoreError::Io(format!(
214 "{} not acknowledged: {}",
215 self.put.tool, ctx["result"]["text"]
216 )))
217 }
218
219 fn get(&self, key: &str, seq: Option<u64>) -> Result<Option<Value>, StoreError> {
220 let vars = self.vars(key, seq, None);
221 let ctx = self.call(&self.get, &vars, key, None)?;
222 if ctx["result"]["isError"].as_bool().unwrap_or(false) {
223 return Ok(None);
226 }
227 let v = match &self.get.value {
228 Some(x) => mapping::extract(x, &ctx).map_err(|e| StoreError::Mapping(e.0))?,
229 None => Some(ctx["result"]["structuredContent"].clone()),
230 };
231 Ok(v.filter(|v| !v.is_null()))
232 }
233
234 fn list(&self, prefix: &str) -> Result<Vec<KeySeq>, StoreError> {
235 let Some(op) = &self.list else {
236 return Err(StoreError::Unsupported("list"));
237 };
238 let mut vars = self.vars(prefix, None, None);
239 vars.insert("prefix".into(), Value::String(prefix.to_string()));
240 let ctx = self.call(op, &vars, prefix, None)?;
241 if ctx["result"]["isError"].as_bool().unwrap_or(false) {
242 return Err(StoreError::Io(format!(
243 "{} failed: {}",
244 op.tool, ctx["result"]["text"]
245 )));
246 }
247 let keys = match &op.keys {
248 Some(x) => mapping::extract(x, &ctx).map_err(|e| StoreError::Mapping(e.0))?,
249 None => Some(ctx["result"]["structuredContent"]["keys"].clone()),
250 };
251 let mut out = Vec::new();
252 if let Some(Value::Array(items)) = keys {
253 for it in items {
254 match it {
255 Value::String(k) => out.push(KeySeq { key: k, seq: None }),
256 Value::Object(o) => {
257 if let Some(k) = o.get("key").and_then(Value::as_str) {
258 out.push(KeySeq {
259 key: k.to_string(),
260 seq: o.get("seq").and_then(Value::as_u64),
261 });
262 }
263 }
264 _ => {}
265 }
266 }
267 }
268 Ok(out)
269 }
270
271 fn delete(&self, key: &str) -> Result<(), StoreError> {
272 let Some(op) = &self.delete else {
273 return Err(StoreError::Unsupported("delete"));
274 };
275 let vars = self.vars(key, None, None);
276 let ctx = self.call(op, &vars, key, None)?;
277 if ctx["result"]["isError"].as_bool().unwrap_or(false) {
278 return Err(StoreError::Io(format!(
279 "{} failed: {}",
280 op.tool, ctx["result"]["text"]
281 )));
282 }
283 Ok(())
284 }
285
286 fn kind(&self) -> &'static str {
287 "mcp"
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use std::collections::BTreeMap;
295 use std::sync::Mutex;
296
297 #[derive(Default)]
300 struct FakeServer {
301 data: Mutex<BTreeMap<String, BTreeMap<u64, Value>>>,
302 calls: Mutex<Vec<(String, Value, Value)>>,
303 fail: Mutex<bool>,
304 }
305
306 fn ok(v: Value) -> CallToolResult {
307 CallToolResult {
308 content: vec![json!({"type": "text", "text": v.to_string()})],
309 is_error: Some(false),
310 structured_content: None, }
312 }
313 fn err(msg: &str) -> CallToolResult {
314 CallToolResult {
315 content: vec![json!({"type": "text", "text": msg})],
316 is_error: Some(true),
317 structured_content: None,
318 }
319 }
320
321 impl McpCall for FakeServer {
322 fn call(
323 &self,
324 tool: &str,
325 args: Value,
326 meta: Value,
327 _t: Duration,
328 ) -> Result<CallToolResult, String> {
329 self.calls
330 .lock()
331 .unwrap()
332 .push((tool.to_string(), args.clone(), meta));
333 if *self.fail.lock().unwrap() {
334 return Err("connection reset".into());
335 }
336 let key = args["key"].as_str().unwrap_or("").to_string();
337 let mut data = self.data.lock().unwrap();
338 Ok(match tool {
339 "state.put" => {
340 let seq = args["seq"].as_u64().unwrap_or(0);
341 let hist = data.entry(key).or_default();
342 let latest = hist.keys().next_back().copied().unwrap_or(0);
343 if seq <= latest {
344 ok(json!({"ok": false, "latest": latest}))
345 } else {
346 hist.insert(seq, args["state"].clone());
347 ok(json!({"ok": true, "seq": seq}))
348 }
349 }
350 "state.get" => match data.get(&key) {
351 None => err("no such key"),
352 Some(h) => {
353 let picked = match args["seq"].as_u64() {
354 Some(s) => h.get(&s),
355 None => h.values().next_back(),
356 };
357 match picked {
358 Some(v) => ok(json!({"state": v})),
359 None => err("no such seq"),
360 }
361 }
362 },
363 "state.list" => {
364 let prefix = args["prefix"].as_str().unwrap_or("");
365 let keys: Vec<Value> = data
366 .iter()
367 .filter(|(k, _)| k.starts_with(prefix))
368 .map(|(k, h)| json!({"key": k, "seq": h.keys().next_back()}))
369 .collect();
370 ok(json!({"keys": keys}))
371 }
372 "state.delete" => {
373 data.remove(&key);
374 ok(json!({"ok": true}))
375 }
376 _ => err("unknown tool"),
377 })
378 }
379 fn server_name(&self) -> String {
380 "fake".into()
381 }
382 }
383
384 fn store(server: Arc<FakeServer>) -> McpStore {
385 McpStore::new(
386 server,
387 StoreMcp {
388 server: "fake".into(),
389 put: None,
390 get: None,
391 list: None,
392 delete: None,
393 },
394 Duration::from_secs(1),
395 )
396 }
397
398 #[test]
399 fn default_profile_round_trips_with_cas_and_meta() {
400 let srv = Arc::new(FakeServer::default());
401 let s = store(srv.clone());
402 let env = json!({"v": 2, "kind": "run", "id": "1", "seq": 1, "state": {"x": 1}});
403 assert_eq!(s.put("agentd/i/run/1", 1, &env).unwrap(), PutOutcome::Ok);
404 assert_eq!(
405 s.put("agentd/i/run/1", 1, &env).unwrap(),
406 PutOutcome::Conflict {
407 latest_seq: Some(1)
408 }
409 );
410 assert_eq!(s.get("agentd/i/run/1", None).unwrap(), Some(env.clone()));
411 assert_eq!(
412 s.get("agentd/i/run/nope", None).unwrap(),
413 None,
414 "tool error on read = absent"
415 );
416 let l = s.list("agentd/i/").unwrap();
417 assert_eq!(l.len(), 1);
418 assert_eq!(l[0].key, "agentd/i/run/1");
419 assert_eq!(l[0].seq, Some(1));
420 s.delete("agentd/i/run/1").unwrap();
421 assert_eq!(s.get("agentd/i/run/1", None).unwrap(), None);
422 {
424 let calls = srv.calls.lock().unwrap();
425 let (tool, args, meta) = &calls[0];
426 assert_eq!(tool, "state.put");
427 assert_eq!(args["key"], json!("agentd/i/run/1"));
428 assert_eq!(args["seq"], json!(1));
429 assert_eq!(meta["agent/idempotency_key"], json!("agentd/i/run/1#1"));
430 assert_eq!(meta["agent/instance"], json!("i"));
431 }
432 *srv.fail.lock().unwrap() = true;
434 assert!(matches!(
435 s.get("agentd/i/run/1", None),
436 Err(StoreError::Io(_))
437 ));
438 }
439
440 #[test]
441 fn custom_mapping_and_advertised_ops() {
442 struct Kv(Mutex<BTreeMap<String, (u64, Value)>>);
445 impl McpCall for Kv {
446 fn call(
447 &self,
448 tool: &str,
449 args: Value,
450 _m: Value,
451 _t: Duration,
452 ) -> Result<CallToolResult, String> {
453 let mut d = self.0.lock().unwrap();
454 Ok(match tool {
455 "kv.set" => {
456 let k = args["k"].as_str().unwrap().to_string();
457 let v = args["version"].as_u64().unwrap();
458 if d.get(&k).is_some_and(|(cur, _)| *cur >= v) {
459 ok(json!({"stored": false, "current": d[&k].0}))
460 } else {
461 d.insert(k, (v, args["doc"].clone()));
462 ok(json!({"stored": true}))
463 }
464 }
465 "kv.fetch" => match d.get(args["k"].as_str().unwrap()) {
466 Some((_, doc)) => ok(json!({"doc": doc})),
467 None => ok(json!({"doc": null})),
468 },
469 _ => err("nope"),
470 })
471 }
472 fn server_name(&self) -> String {
473 "kv".into()
474 }
475 }
476 let cfg = StoreMcp {
477 server: "kv".into(),
478 put: Some(StoreOp {
479 tool: "kv.set".into(),
480 args: Some(r#"{"k": "{key}", "version": {seq}, "doc": {envelope}}"#.into()),
481 ok: Some("result.structuredContent.stored".into()),
482 conflict: Some("result.structuredContent.current".into()),
483 value: None,
484 keys: None,
485 }),
486 get: Some(StoreOp {
487 tool: "kv.fetch".into(),
488 args: Some(r#"{"k": "{key}"}"#.into()),
489 ok: None,
490 conflict: None,
491 value: Some("result.structuredContent.doc".into()),
492 keys: None,
493 }),
494 list: None,
495 delete: None,
496 };
497 let s = McpStore::new(
498 Arc::new(Kv(Mutex::new(BTreeMap::new()))),
499 cfg,
500 Duration::from_secs(1),
501 )
502 .with_advertised(&["kv.set".into(), "kv.fetch".into()]);
503 assert_eq!(
504 s.put("p/i/k/1", 3, &json!({"a": 1})).unwrap(),
505 PutOutcome::Ok
506 );
507 assert_eq!(
508 s.put("p/i/k/1", 3, &json!({"a": 2})).unwrap(),
509 PutOutcome::Conflict {
510 latest_seq: Some(3)
511 }
512 );
513 assert_eq!(s.get("p/i/k/1", None).unwrap(), Some(json!({"a": 1})));
514 assert_eq!(s.get("p/i/k/2", None).unwrap(), None);
515 assert!(matches!(s.list("p/"), Err(StoreError::Unsupported("list"))));
516 assert!(matches!(
517 s.delete("p/i/k/1"),
518 Err(StoreError::Unsupported("delete"))
519 ));
520 }
521}