1use std::sync::{Arc, Mutex};
27
28use anyhow::Result;
29use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters};
30use rmcp::{tool, tool_handler, tool_router, ServerHandler, ServiceExt};
31use schemars::JsonSchema;
32use serde::Deserialize;
33
34use persona_wire_adapter_activitypub::ActivityPubAdapter;
35use persona_wire_adapter_apple_notes::AppleNotesAdapter;
36use persona_wire_adapter_bluesky::BlueskyAdapter;
37use persona_wire_adapter_github::GithubAdapter;
38use persona_wire_adapter_mastodon::MastodonAdapter;
39use persona_wire_adapter_matrix::MatrixAdapter;
40use persona_wire_adapter_mcp::{McpAdapter, McpEndpointResolver, SqliteEndpointResolver};
41use persona_wire_adapter_mini_app::MiniAppAdapter;
42use persona_wire_adapter_notion::NotionAdapter;
43use persona_wire_adapter_obsidian::ObsidianAdapter;
44use persona_wire_adapter_persona_pack::PersonaPackAdapter;
45use persona_wire_adapter_rss::RssAdapter;
46use persona_wire_adapter_slack::SlackAdapter;
47use persona_wire_adapter_sqlite_x::SqliteAdapter;
48use persona_wire_adapter_todoist::TodoistAdapter;
49use persona_wire_core::application::plugin_registry::PluginRegistry;
50use persona_wire_core::application::projection_registry::ProjectionRegistry;
51use persona_wire_core::application::spec_registry::SpecRegistry;
52use persona_wire_core::application::use_cases::{
53 wire_close, wire_context_get, wire_doctor, wire_edge_delete, wire_edges_create_batch,
54 wire_fetch, wire_init, wire_materialize, wire_node_delete, wire_node_update,
55 wire_nodes_create_batch, wire_projection_delete, wire_prompt_context, wire_query, wire_render,
56 wire_slot_delete, wire_slot_register, wire_spec_delete, wire_workflow_fire, wire_workflow_list,
57 wire_workflow_register, WireCloseInput, WireContextGetInput, WireDeleteInput,
58 WireEdgesCreateBatchInput, WireFetchInput, WireInitInput, WireMaterializeInput,
59 WireNodeUpdateInput, WireNodeUpdateMode, WireNodesCreateBatchInput, WirePromptContextInput,
60 WireQueryInput, WireRenderInput, WireSlotDeleteInput, WireSlotRegisterInput,
61 WireWorkflowFireInput, WireWorkflowListInput, WireWorkflowRegisterInput,
62};
63use persona_wire_core::domain::entity::projection::{PluginDispatch, Projection};
64use persona_wire_core::domain::entity::TargetForm;
65use persona_wire_core::domain::graph::{Edge, Node, Severity};
66use persona_wire_core::domain::specification::Specification;
67use persona_wire_core::infrastructure::storage::SqliteStorage;
68use persona_wire_core::infrastructure::tank::TankAdapter;
69
70#[derive(Clone)]
72pub struct WireServer {
73 storage: Arc<Mutex<SqliteStorage>>,
74 registry: Arc<PluginRegistry>,
83 #[allow(dead_code)]
85 tool_router: ToolRouter<Self>,
86}
87
88impl WireServer {
89 pub fn new(storage: SqliteStorage) -> Self {
90 let persona_pack =
91 PersonaPackAdapter::from_env().expect("PersonaPackAdapter::from_env (HOME unset?)");
92 let storage_arc = Arc::new(Mutex::new(storage));
93 let mcp_resolver: Arc<dyn McpEndpointResolver> =
98 Arc::new(SqliteEndpointResolver::new(storage_arc.clone()));
99 Self {
100 storage: storage_arc.clone(),
101 registry: Arc::new(
102 PluginRegistry::default_builder_for_wire()
103 .with_adapter(MiniAppAdapter)
104 .with_adapter(SqliteAdapter)
105 .with_adapter(ObsidianAdapter)
106 .with_adapter(persona_pack)
107 .with_adapter(McpAdapter::new(mcp_resolver))
108 .with_adapter(RssAdapter)
109 .with_adapter(GithubAdapter)
110 .with_adapter(MatrixAdapter)
111 .with_adapter(MastodonAdapter)
112 .with_adapter(TodoistAdapter)
113 .with_adapter(NotionAdapter)
114 .with_adapter(SlackAdapter)
115 .with_adapter(AppleNotesAdapter)
116 .with_adapter(ActivityPubAdapter)
117 .with_adapter(BlueskyAdapter)
118 .with_adapter(TankAdapter::new(storage_arc.clone()))
121 .build()
122 .expect("default plugin registry build"),
123 ),
124 tool_router: Self::tool_router(),
125 }
126 }
127}
128
129#[derive(Debug, Deserialize, JsonSchema)]
134pub struct WireInitParams {
135 pub persona_id: String,
137}
138
139#[derive(Debug, Deserialize, JsonSchema)]
140pub struct WireCloseParams {
141 pub persona_id: String,
143}
144
145fn normalize_metadata(raw: Option<serde_json::Value>) -> serde_json::Value {
164 match raw {
165 None | Some(serde_json::Value::Null) => serde_json::Value::Object(serde_json::Map::new()),
166 Some(serde_json::Value::String(s)) => {
167 serde_json::from_str(&s).unwrap_or(serde_json::Value::String(s))
168 }
169 Some(other) => other,
170 }
171}
172
173#[derive(Debug, Deserialize, JsonSchema)]
174pub struct WireNodeCreateParams {
175 pub name: String,
181 #[serde(rename = "type")]
183 pub type_: String,
184 #[serde(default)]
186 pub sot_ref: Option<String>,
187 #[serde(default)]
189 pub metadata: Option<serde_json::Value>,
190}
191
192#[derive(Debug, Deserialize, JsonSchema)]
193pub struct WireEdgeCreateParams {
194 #[serde(default)]
198 pub name: Option<String>,
199 pub src: String,
201 pub tgt: String,
203 pub kind: String,
205 #[serde(default)]
207 pub severity: Option<String>,
208 #[serde(default)]
209 pub metadata: Option<serde_json::Value>,
210}
211
212#[derive(Debug, Deserialize, JsonSchema)]
213pub struct WireNodesCreateBatchParams {
214 pub nodes: Vec<WireNodeCreateParams>,
216}
217
218#[derive(Debug, Deserialize, JsonSchema)]
219pub struct WireEdgesCreateBatchParams {
220 pub edges: Vec<WireEdgeCreateParams>,
222}
223
224#[derive(Debug, Deserialize, JsonSchema)]
225pub struct WireRenderParams {
226 pub projection_ref: String,
228}
229
230#[derive(Debug, Deserialize, JsonSchema)]
231pub struct WireQueryParams {
232 #[serde(default)]
235 pub spec: Option<String>,
236 #[serde(default)]
238 pub spec_ref: Option<String>,
239 #[serde(default)]
241 pub limit: Option<usize>,
242 #[serde(default)]
244 pub offset: Option<usize>,
245}
246
247#[derive(Debug, Deserialize, JsonSchema)]
248pub struct WireSpecRegisterParams {
249 pub name: String,
250 pub json: String,
252}
253
254#[derive(Debug, Deserialize, JsonSchema)]
255pub struct WireProjectionRegisterParams {
256 pub name: String,
257 pub spec_ref: String,
259 pub template: String,
261 pub target_form: String,
263}
264
265#[derive(Debug, Deserialize, JsonSchema)]
266pub struct WireDeleteParams {
267 pub id_or_name: String,
271}
272
273#[derive(Debug, Deserialize, JsonSchema)]
274pub struct WireListPageParams {
275 #[serde(default)]
277 pub limit: Option<u32>,
278 #[serde(default)]
280 pub offset: Option<u32>,
281}
282
283#[derive(Debug, Deserialize, JsonSchema)]
284pub struct WireNodeUpdateParams {
285 pub id: String,
287 pub metadata_patch: serde_json::Value,
292 #[serde(default)]
294 pub mode: Option<String>,
295}
296
297#[derive(Debug, Deserialize, JsonSchema)]
298pub struct WirePromptContextParams {
299 pub persona_id: String,
300 #[serde(default)]
304 pub projection_names: Option<Vec<String>>,
305 #[serde(default)]
309 pub projection_exclude_names: Option<Vec<String>>,
310}
311
312#[derive(Debug, Deserialize, JsonSchema)]
313pub struct WireContextGetParams {
314 pub persona_id: String,
316}
317
318#[derive(Debug, Deserialize, JsonSchema)]
319pub struct WireSlotRegisterParams {
320 pub persona_id: String,
322 pub slot: String,
324 pub source_uri: String,
327 pub template: String,
331 #[serde(default)]
333 pub target_form: Option<String>,
334 #[serde(default)]
337 pub maintenance_exempt: Option<bool>,
338 #[serde(default)]
341 pub auth: Option<String>,
342}
343
344#[derive(Debug, Deserialize, JsonSchema)]
345pub struct WireSlotDeleteParams {
346 pub persona_id: String,
348 pub slot: String,
350}
351
352#[derive(Debug, Deserialize, JsonSchema)]
353pub struct WireFetchParams {
354 #[serde(default)]
358 pub source_uri: Option<String>,
359 #[serde(default)]
363 pub persona_id: Option<String>,
364 #[serde(default)]
366 pub slot: Option<String>,
367}
368
369#[derive(Debug, Deserialize, JsonSchema)]
370pub struct WireMaterializeParams {
371 pub persona_id: String,
373 pub slot: String,
375 #[serde(default)]
379 pub item_path: Option<String>,
380 #[serde(default)]
384 pub item_id_key: Option<String>,
385}
386
387#[derive(Debug, Deserialize, JsonSchema)]
388pub struct WireDoctorParams {
389 #[serde(default)]
392 pub persona_id: Option<String>,
393}
394
395#[derive(Debug, Deserialize, JsonSchema)]
396pub struct WireWorkflowRegisterParams {
397 pub id: String,
399 #[serde(default)]
401 pub persona_id: Option<String>,
402 pub trigger: String,
406 pub action: String,
409 #[serde(default)]
411 pub enabled: Option<bool>,
412}
413
414#[derive(Debug, Deserialize, JsonSchema)]
415pub struct WireWorkflowListParams {
416 #[serde(default)]
417 pub persona_id: Option<String>,
418 #[serde(default)]
420 pub trigger_kind: Option<String>,
421 #[serde(default)]
423 pub enabled_only: Option<bool>,
424}
425
426#[derive(Debug, Deserialize, JsonSchema)]
429pub struct WireBundleRegisterParams {
430 pub body: String,
435}
436
437#[derive(Debug, Deserialize, JsonSchema)]
438pub struct WireBundleRefParams {
439 pub r#ref: String,
443}
444
445#[derive(Debug, Deserialize, JsonSchema)]
446pub struct WireBundleInstallParams {
447 pub r#ref: String,
449 #[serde(default)]
455 pub mode: Option<String>,
456}
457
458#[derive(Debug, Deserialize, JsonSchema)]
459pub struct WireWorkflowFireParams {
460 #[serde(default)]
462 pub id: Option<String>,
463 #[serde(default)]
466 pub event: Option<String>,
467 #[serde(default)]
469 pub persona_id: Option<String>,
470 #[serde(default)]
473 pub dry_run: Option<bool>,
474}
475
476#[tool_router]
481impl WireServer {
482 #[tool(
484 name = "wire_init",
485 description = "Run wire_init: render every registered NamedProjection against the current graph; returns the rendered context bundle (one entry per projection)."
486 )]
487 async fn wire_init_tool(
488 &self,
489 Parameters(p): Parameters<WireInitParams>,
490 ) -> Result<String, String> {
491 let s = self.storage.lock().map_err(|e| e.to_string())?;
492 let out = wire_init(
493 WireInitInput {
494 persona_id: p.persona_id,
495 },
496 &s,
497 &self.registry,
498 )
499 .map_err(|e| e.to_string())?;
500 let json = serde_json::json!({
501 "persona_id": out.persona_id,
502 "projections": out.projections.iter().map(|p| serde_json::json!({
503 "name": p.name,
504 "target_form": p.target_form.as_str(),
505 "rendered": p.rendered,
506 })).collect::<Vec<_>>(),
507 "warnings": out.warnings,
508 });
509 serde_json::to_string_pretty(&json).map_err(|e| e.to_string())
510 }
511
512 #[tool(
514 name = "wire_close",
515 description = "Run wire_close: minimal lifecycle scan reporting total nodes / edges / orphan-node count, in a Markdown report."
516 )]
517 async fn wire_close_tool(
518 &self,
519 Parameters(p): Parameters<WireCloseParams>,
520 ) -> Result<String, String> {
521 let s = self.storage.lock().map_err(|e| e.to_string())?;
522 let out = wire_close(
523 WireCloseInput {
524 persona_id: p.persona_id,
525 },
526 &s,
527 )
528 .map_err(|e| e.to_string())?;
529 Ok(out.report_markdown)
530 }
531
532 #[tool(
534 name = "wire_doctor",
535 description = "Finding-driven 2-axis (graph / workflow) health diagnostic. Returns a Markdown report with verdict (HEALTHY / DEGRADED / BROKEN), per-finding severity (error / warn / info), location (固有名詞), description, and fix template (MCP tool call literal). persona_id=None → Full mode (全 persona 横串); persona_id=Some(id) → Persona-scoped mode.",
536 annotations(read_only_hint = true, idempotent_hint = true)
537 )]
538 async fn wire_doctor_tool(
539 &self,
540 Parameters(p): Parameters<WireDoctorParams>,
541 ) -> Result<String, String> {
542 let s = self.storage.lock().map_err(|e| e.to_string())?;
543 let out = wire_doctor(&s, p.persona_id, &self.registry).map_err(|e| e.to_string())?;
544 Ok(out.report_markdown)
545 }
546
547 #[tool(
549 name = "wire_node_create",
550 description = "Insert a node into the graph. Node `type` must already be registered in type_registry (call wire_type_list to inspect)."
551 )]
552 async fn wire_node_create(
553 &self,
554 Parameters(p): Parameters<WireNodeCreateParams>,
555 ) -> Result<String, String> {
556 let s = self.storage.lock().map_err(|e| e.to_string())?;
557 let id = persona_wire_core::domain::graph::Ulid::new();
558 let node = Node {
559 id,
560 name: p.name.clone(),
561 r#type: p.type_,
562 sot_ref: p.sot_ref,
563 confidence: None,
564 applicability: None,
565 last_verified_at: None,
566 review_due: None,
567 version: 1,
568 prev_id: None,
569 metadata: normalize_metadata(p.metadata),
570 };
571 s.insert_node(&node).map_err(|e| e.to_string())?;
572 Ok(serde_json::json!({ "id": id.to_string(), "name": p.name }).to_string())
573 }
574
575 #[tool(
577 name = "wire_nodes_create_batch",
578 description = "Bulk-insert a batch of nodes. Iterates 1-row-at-a-time (non-atomic); stops on first failure and returns inserted_count + failed_at. Use when constructing a graph from many rows (e.g. mini-app row → Node mapping) to avoid per-row tool-call overhead."
579 )]
580 async fn wire_nodes_create_batch_tool(
581 &self,
582 Parameters(p): Parameters<WireNodesCreateBatchParams>,
583 ) -> Result<String, String> {
584 let s = self.storage.lock().map_err(|e| e.to_string())?;
585 let mut minted: Vec<serde_json::Value> = Vec::with_capacity(p.nodes.len());
586 let nodes: Vec<Node> = p
587 .nodes
588 .into_iter()
589 .map(|np| {
590 let id = persona_wire_core::domain::graph::Ulid::new();
591 minted.push(serde_json::json!({ "id": id.to_string(), "name": np.name }));
592 Node {
593 id,
594 name: np.name,
595 r#type: np.type_,
596 sot_ref: np.sot_ref,
597 confidence: None,
598 applicability: None,
599 last_verified_at: None,
600 review_due: None,
601 version: 1,
602 prev_id: None,
603 metadata: normalize_metadata(np.metadata),
604 }
605 })
606 .collect();
607 let out = wire_nodes_create_batch(WireNodesCreateBatchInput { nodes }, &s)
608 .map_err(|e| e.to_string())?;
609 let json = serde_json::json!({
610 "inserted_count": out.inserted_count,
611 "failed_at": out.failed_at,
612 "error_message": out.error_message,
613 "minted": minted,
614 });
615 serde_json::to_string_pretty(&json).map_err(|e| e.to_string())
616 }
617
618 #[tool(
620 name = "wire_edges_create_batch",
621 description = "Bulk-insert a batch of edges. Same non-atomic semantics as wire_nodes_create_batch: stops on first failure and returns inserted_count + failed_at."
622 )]
623 async fn wire_edges_create_batch_tool(
624 &self,
625 Parameters(p): Parameters<WireEdgesCreateBatchParams>,
626 ) -> Result<String, String> {
627 let s = self.storage.lock().map_err(|e| e.to_string())?;
628 let mut edges = Vec::with_capacity(p.edges.len());
629 let mut minted: Vec<serde_json::Value> = Vec::with_capacity(p.edges.len());
630 for ep in p.edges {
631 let sev = match ep.severity.as_deref() {
632 None => None,
633 Some("hard") => Some(Severity::Hard),
634 Some("soft") => Some(Severity::Soft),
635 Some("advisory") => Some(Severity::Advisory),
636 Some(other) => return Err(format!("unknown severity: {other}")),
637 };
638 let src_id = s
639 .resolve_node_id_or_name(&ep.src)
640 .map_err(|e| e.to_string())?
641 .ok_or_else(|| format!("edge src node not found: {}", ep.src))?;
642 let tgt_id = s
643 .resolve_node_id_or_name(&ep.tgt)
644 .map_err(|e| e.to_string())?
645 .ok_or_else(|| format!("edge tgt node not found: {}", ep.tgt))?;
646 let id = persona_wire_core::domain::graph::Ulid::new();
647 minted.push(serde_json::json!({
648 "id": id.to_string(),
649 "name": ep.name,
650 "src": src_id.to_string(),
651 "tgt": tgt_id.to_string(),
652 }));
653 edges.push(Edge {
654 id,
655 name: ep.name,
656 src_node: src_id,
657 tgt_node: tgt_id,
658 kind: ep.kind,
659 severity: sev,
660 metadata: normalize_metadata(ep.metadata),
661 version: 1,
662 prev_id: None,
663 });
664 }
665 let out = wire_edges_create_batch(WireEdgesCreateBatchInput { edges }, &s)
666 .map_err(|e| e.to_string())?;
667 let json = serde_json::json!({
668 "inserted_count": out.inserted_count,
669 "failed_at": out.failed_at,
670 "error_message": out.error_message,
671 "minted": minted,
672 });
673 serde_json::to_string_pretty(&json).map_err(|e| e.to_string())
674 }
675
676 #[tool(
678 name = "wire_edge_create",
679 description = "Insert an edge into the graph. `kind` must be a registered edge type; `severity` is only valid for triggers_review_of."
680 )]
681 async fn wire_edge_create(
682 &self,
683 Parameters(p): Parameters<WireEdgeCreateParams>,
684 ) -> Result<String, String> {
685 let s = self.storage.lock().map_err(|e| e.to_string())?;
686 let sev = match p.severity.as_deref() {
687 None => None,
688 Some("hard") => Some(Severity::Hard),
689 Some("soft") => Some(Severity::Soft),
690 Some("advisory") => Some(Severity::Advisory),
691 Some(other) => {
692 return Err(format!("unknown severity: {other}"));
693 }
694 };
695 let src_id = s
696 .resolve_node_id_or_name(&p.src)
697 .map_err(|e| e.to_string())?
698 .ok_or_else(|| format!("edge src node not found: {}", p.src))?;
699 let tgt_id = s
700 .resolve_node_id_or_name(&p.tgt)
701 .map_err(|e| e.to_string())?
702 .ok_or_else(|| format!("edge tgt node not found: {}", p.tgt))?;
703 let id = persona_wire_core::domain::graph::Ulid::new();
704 let edge = Edge {
705 id,
706 name: p.name.clone(),
707 src_node: src_id,
708 tgt_node: tgt_id,
709 kind: p.kind,
710 severity: sev,
711 metadata: normalize_metadata(p.metadata),
712 version: 1,
713 prev_id: None,
714 };
715 s.insert_edge(&edge).map_err(|e| e.to_string())?;
716 Ok(serde_json::json!({ "id": id.to_string(), "name": p.name }).to_string())
717 }
718
719 #[tool(
721 name = "wire_render",
722 description = "Render a single registered NamedProjection by name. Counterpart to wire_init (which renders every registered projection at once): use wire_render when you want exactly one rendered context, identified by projection_ref."
723 )]
724 async fn wire_render_tool(
725 &self,
726 Parameters(p): Parameters<WireRenderParams>,
727 ) -> Result<String, String> {
728 let s = self.storage.lock().map_err(|e| e.to_string())?;
729 let out = wire_render(
730 WireRenderInput {
731 projection_ref: p.projection_ref,
732 },
733 &s,
734 &self.registry,
735 )
736 .map_err(|e| e.to_string())?;
737 let json = serde_json::json!({
738 "name": out.name,
739 "target_form": out.target_form.as_str(),
740 "rendered": out.rendered,
741 });
742 serde_json::to_string_pretty(&json).map_err(|e| e.to_string())
743 }
744
745 #[tool(
747 name = "wire_query",
748 description = "Ad-hoc query: evaluate either an inline `spec` (Specification JSON) or a registered `spec_ref` against the graph and return matched nodes (slim form: id + type + metadata). Supports `limit` / `offset` for pagination; both unset = unlimited. Mirrors mini-app `list(table, filter)` semantics on the graph."
749 )]
750 async fn wire_query_tool(
751 &self,
752 Parameters(p): Parameters<WireQueryParams>,
753 ) -> Result<String, String> {
754 let s = self.storage.lock().map_err(|e| e.to_string())?;
755 let spec = match p.spec.as_deref() {
756 Some(body) => Some(
757 serde_json::from_str::<Specification>(body)
758 .map_err(|e| format!("parse spec JSON: {e}"))?,
759 ),
760 None => None,
761 };
762 let out = wire_query(
763 WireQueryInput {
764 spec,
765 spec_ref: p.spec_ref,
766 limit: p.limit,
767 offset: p.offset,
768 },
769 &s,
770 )
771 .map_err(|e| e.to_string())?;
772 let json = serde_json::json!({
773 "matched": out.matched.iter().map(|n| serde_json::json!({
774 "id": n.id,
775 "type": n.r#type,
776 "metadata": n.metadata,
777 })).collect::<Vec<_>>(),
778 "total_count": out.total_count,
779 "returned_count": out.returned_count,
780 });
781 serde_json::to_string_pretty(&json).map_err(|e| e.to_string())
782 }
783
784 #[tool(
786 name = "wire_spec_register",
787 description = "Register a Specification by name. `json` is the serialised Specification body, e.g. `{\"TypeIs\":\"persona\"}` or `{\"And\":[...]}`."
788 )]
789 async fn wire_spec_register(
790 &self,
791 Parameters(p): Parameters<WireSpecRegisterParams>,
792 ) -> Result<String, String> {
793 let s = self.storage.lock().map_err(|e| e.to_string())?;
794 let spec: Specification =
795 serde_json::from_str(&p.json).map_err(|e| format!("parse Specification JSON: {e}"))?;
796 let id = SpecRegistry::new(&s)
797 .register(&p.name, &spec)
798 .map_err(|e| e.to_string())?;
799 Ok(serde_json::json!({ "id": id.to_string(), "name": p.name }).to_string())
800 }
801
802 #[tool(
804 name = "wire_projection_register",
805 description = "Register a NamedProjection. spec_ref must name a previously registered Specification. target_form ∈ {prompt|markdown|json|ascii}."
806 )]
807 async fn wire_projection_register(
808 &self,
809 Parameters(p): Parameters<WireProjectionRegisterParams>,
810 ) -> Result<String, String> {
811 let s = self.storage.lock().map_err(|e| e.to_string())?;
812 let tf = TargetForm::parse(&p.target_form).map_err(|e| e.to_string())?;
813 let entity = Projection::from_parts(
817 p.name.clone(),
818 p.spec_ref,
819 p.template,
820 tf,
821 PluginDispatch::Default,
822 )
823 .map_err(|e| e.to_string())?;
824 let id = ProjectionRegistry::new(&s)
825 .register(&entity)
826 .map_err(|e| e.to_string())?;
827 Ok(serde_json::json!({ "id": id.to_string(), "name": p.name }).to_string())
828 }
829
830 #[tool(
832 name = "wire_spec_list",
833 description = "List registered Specifications in created_at-descending order. Default limit 100 / max 1000. Each row carries id / name / json (raw Specification body) / created_at / updated_at."
834 )]
835 async fn wire_spec_list(
836 &self,
837 Parameters(p): Parameters<WireListPageParams>,
838 ) -> Result<String, String> {
839 let limit = p.limit.unwrap_or(100).min(1000) as i64;
840 let offset = p.offset.unwrap_or(0) as i64;
841 let s = self.storage.lock().map_err(|e| e.to_string())?;
842 let rows = SpecRegistry::new(&s)
843 .list_full(limit, offset)
844 .map_err(|e| e.to_string())?;
845 let out: Vec<serde_json::Value> = rows
846 .into_iter()
847 .map(|r| {
848 serde_json::json!({
849 "id": r.id.to_string(),
850 "name": r.name,
851 "json": r.json,
852 "created_at": r.created_at,
853 "updated_at": r.updated_at,
854 })
855 })
856 .collect();
857 serde_json::to_string_pretty(&serde_json::json!({ "specs": out }))
858 .map_err(|e| e.to_string())
859 }
860
861 #[tool(
863 name = "wire_spec_get",
864 description = "Fetch a registered Specification by name or ULID id. Returns id / name / json (raw Specification body) / created_at / updated_at. Errors with NotFound if absent."
865 )]
866 async fn wire_spec_get(
867 &self,
868 Parameters(p): Parameters<WireDeleteParams>,
869 ) -> Result<String, String> {
870 let s = self.storage.lock().map_err(|e| e.to_string())?;
871 let row = SpecRegistry::new(&s)
872 .get_full_by_ref(&p.id_or_name)
873 .map_err(|e| e.to_string())?
874 .ok_or_else(|| format!("spec not found: {}", p.id_or_name))?;
875 Ok(serde_json::json!({
876 "id": row.id.to_string(),
877 "name": row.name,
878 "json": row.json,
879 "created_at": row.created_at,
880 "updated_at": row.updated_at,
881 })
882 .to_string())
883 }
884
885 #[tool(
887 name = "wire_projection_list",
888 description = "List registered NamedProjections in created_at-descending order. Default limit 100 / max 1000. Each row carries id / name / spec_ref / target_form / template / created_at / updated_at."
889 )]
890 async fn wire_projection_list(
891 &self,
892 Parameters(p): Parameters<WireListPageParams>,
893 ) -> Result<String, String> {
894 let limit = p.limit.unwrap_or(100).min(1000) as i64;
895 let offset = p.offset.unwrap_or(0) as i64;
896 let s = self.storage.lock().map_err(|e| e.to_string())?;
897 let rows = ProjectionRegistry::new(&s)
898 .list_full(limit, offset)
899 .map_err(|e| e.to_string())?;
900 let out: Vec<serde_json::Value> = rows
901 .into_iter()
902 .map(|r| {
903 serde_json::json!({
904 "id": r.id.to_string(),
905 "name": r.name,
906 "spec_ref": r.spec_ref,
907 "target_form": r.target_form.as_str(),
908 "template": r.template,
909 "created_at": r.created_at,
910 "updated_at": r.updated_at,
911 })
912 })
913 .collect();
914 serde_json::to_string_pretty(&serde_json::json!({ "projections": out }))
915 .map_err(|e| e.to_string())
916 }
917
918 #[tool(
920 name = "wire_projection_get",
921 description = "Fetch a registered NamedProjection by name or ULID id. Returns id / name / spec_ref / target_form / template / created_at / updated_at. Errors with NotFound if absent."
922 )]
923 async fn wire_projection_get(
924 &self,
925 Parameters(p): Parameters<WireDeleteParams>,
926 ) -> Result<String, String> {
927 let s = self.storage.lock().map_err(|e| e.to_string())?;
928 let row = ProjectionRegistry::new(&s)
929 .get_full_by_ref(&p.id_or_name)
930 .map_err(|e| e.to_string())?
931 .ok_or_else(|| format!("projection not found: {}", p.id_or_name))?;
932 Ok(serde_json::json!({
933 "id": row.id.to_string(),
934 "name": row.name,
935 "spec_ref": row.spec_ref,
936 "target_form": row.target_form.as_str(),
937 "template": row.template,
938 "created_at": row.created_at,
939 "updated_at": row.updated_at,
940 })
941 .to_string())
942 }
943
944 #[tool(
948 name = "wire_node_update",
949 description = "Patch a node's metadata in place. `mode=\"merge\"` (default) applies an RFC 7396 shallow merge — top-level keys in `metadata_patch` overwrite the existing metadata; `null` values delete the matching key. `mode=\"replace\"` swaps the metadata wholesale. Other node fields (type / sot_ref / lifecycle) are immutable on this path; delete + re-create to change them. Returns {id, mode, metadata}."
950 )]
951 async fn wire_node_update_tool(
952 &self,
953 Parameters(p): Parameters<WireNodeUpdateParams>,
954 ) -> Result<String, String> {
955 let mode_str = p.mode.as_deref().unwrap_or("merge");
956 let mode = WireNodeUpdateMode::parse(mode_str).map_err(|e| e.to_string())?;
957 let patch = normalize_metadata(Some(p.metadata_patch));
960 let s = self.storage.lock().map_err(|e| e.to_string())?;
961 let out = wire_node_update(
962 WireNodeUpdateInput {
963 id: p.id,
964 metadata_patch: patch,
965 mode,
966 },
967 &s,
968 )
969 .map_err(|e| e.to_string())?;
970 let json = serde_json::json!({
971 "id": out.id,
972 "mode": out.mode.as_str(),
973 "metadata": out.metadata,
974 });
975 serde_json::to_string_pretty(&json).map_err(|e| e.to_string())
976 }
977
978 #[tool(
981 name = "wire_node_delete",
982 description = "Delete a node by id. Returns {kind, id_or_name, deleted}. Edges referencing the node (as src or tgt) are cascade-deleted in the same storage transaction — edges table FK is NOT-NULL so dangling state is not representable in normal operation."
983 )]
984 async fn wire_node_delete_tool(
985 &self,
986 Parameters(p): Parameters<WireDeleteParams>,
987 ) -> Result<String, String> {
988 let s = self.storage.lock().map_err(|e| e.to_string())?;
989 let out = wire_node_delete(
990 WireDeleteInput {
991 id_or_name: p.id_or_name,
992 },
993 &s,
994 )
995 .map_err(|e| e.to_string())?;
996 let json = serde_json::json!({
997 "kind": out.kind,
998 "id_or_name": out.id_or_name,
999 "deleted": out.deleted,
1000 });
1001 serde_json::to_string_pretty(&json).map_err(|e| e.to_string())
1002 }
1003
1004 #[tool(
1006 name = "wire_edge_delete",
1007 description = "Delete an edge by id. Returns {kind, id_or_name, deleted}."
1008 )]
1009 async fn wire_edge_delete_tool(
1010 &self,
1011 Parameters(p): Parameters<WireDeleteParams>,
1012 ) -> Result<String, String> {
1013 let s = self.storage.lock().map_err(|e| e.to_string())?;
1014 let out = wire_edge_delete(
1015 WireDeleteInput {
1016 id_or_name: p.id_or_name,
1017 },
1018 &s,
1019 )
1020 .map_err(|e| e.to_string())?;
1021 let json = serde_json::json!({
1022 "kind": out.kind,
1023 "id_or_name": out.id_or_name,
1024 "deleted": out.deleted,
1025 });
1026 serde_json::to_string_pretty(&json).map_err(|e| e.to_string())
1027 }
1028
1029 #[tool(
1032 name = "wire_spec_delete",
1033 description = "Delete a Specification by name. Returns {kind, id_or_name, deleted}. Projections referencing this spec via spec_ref will start returning dangling-spec errors at render time."
1034 )]
1035 async fn wire_spec_delete_tool(
1036 &self,
1037 Parameters(p): Parameters<WireDeleteParams>,
1038 ) -> Result<String, String> {
1039 let s = self.storage.lock().map_err(|e| e.to_string())?;
1040 let out = wire_spec_delete(
1041 WireDeleteInput {
1042 id_or_name: p.id_or_name,
1043 },
1044 &s,
1045 )
1046 .map_err(|e| e.to_string())?;
1047 let json = serde_json::json!({
1048 "kind": out.kind,
1049 "id_or_name": out.id_or_name,
1050 "deleted": out.deleted,
1051 });
1052 serde_json::to_string_pretty(&json).map_err(|e| e.to_string())
1053 }
1054
1055 #[tool(
1060 name = "wire_prompt_context",
1061 description = "Run every registered NamedProjection through the Layer 6 Adapter (mini-app:// / file:// schemes supported) to fresh-fetch each wiring entry's source_uri, render via handlebars, and return the concatenated PromptContext literal in one call. Used as the `/wake` auto-load entry — wire holds wiring metadata only, data lives in the SoT (mini-app / file / outline). Optional `projection_names` (include subset) and `projection_exclude_names` (exclude subset) compose as AND NOT (`include \\ exclude`); exclude wins on intersection, unknown names are ignored."
1062 )]
1063 async fn wire_prompt_context_tool(
1064 &self,
1065 Parameters(p): Parameters<WirePromptContextParams>,
1066 ) -> Result<String, String> {
1067 let storage = self.storage.clone();
1068 let out = wire_prompt_context(
1069 WirePromptContextInput {
1070 persona_id: p.persona_id,
1071 projection_names: p.projection_names,
1072 projection_exclude_names: p.projection_exclude_names,
1073 },
1074 storage,
1075 &self.registry,
1076 )
1077 .await
1078 .map_err(|e| e.to_string())?;
1079 let json = serde_json::json!({
1080 "persona_id": out.persona_id,
1081 "prompt_context": out.prompt_context,
1082 "projections": out.projections.iter().map(|p| serde_json::json!({
1083 "name": p.name,
1084 "target_form": format!("{:?}", p.target_form),
1085 "rendered": p.rendered,
1086 })).collect::<Vec<_>>(),
1087 "warnings": out.warnings,
1088 });
1089 serde_json::to_string_pretty(&json).map_err(|e| e.to_string())
1090 }
1091
1092 #[tool(
1097 name = "wire_context_get",
1098 description = "Return the per-persona ContextWiring read snapshot: {persona_id, wirings: [{slot, source_uri, projection_ref?, maintenance_exempt}], workflows: [{id, persona_id?, trigger, action, enabled}]}. 1-call structured aggregate (no rendering); use wire_prompt_context for the rendered surface."
1099 )]
1100 async fn wire_context_get_tool(
1101 &self,
1102 Parameters(p): Parameters<WireContextGetParams>,
1103 ) -> Result<String, String> {
1104 let s = self.storage.lock().map_err(|e| e.to_string())?;
1105 let out = wire_context_get(
1106 WireContextGetInput {
1107 persona_id: p.persona_id,
1108 },
1109 &s,
1110 )
1111 .map_err(|e| e.to_string())?;
1112 let wirings: Vec<serde_json::Value> = out
1113 .wirings
1114 .into_iter()
1115 .map(|w| {
1116 serde_json::json!({
1117 "slot": w.slot,
1118 "source_uri": w.source_uri,
1119 "projection_ref": w.projection_ref,
1120 "maintenance_exempt": w.maintenance_exempt,
1121 })
1122 })
1123 .collect();
1124 let workflows: Vec<serde_json::Value> = out
1125 .workflows
1126 .into_iter()
1127 .map(|w| {
1128 serde_json::json!({
1129 "id": w.id,
1130 "persona_id": w.persona_id,
1131 "trigger": w.trigger,
1132 "action": w.action,
1133 "enabled": w.enabled,
1134 })
1135 })
1136 .collect();
1137 serde_json::to_string_pretty(&serde_json::json!({
1138 "persona_id": out.persona_id,
1139 "wirings": wirings,
1140 "workflows": workflows,
1141 }))
1142 .map_err(|e| e.to_string())
1143 }
1144
1145 #[tool(
1147 name = "wire_slot_register",
1148 description = "One-shot slot setup — registers the wiring node (`<persona>.<slot>` with metadata persona/axis/source_uri), the boilerplate per-slot Specification (`<persona>.spec.<slot>`), and the NamedProjection (`<persona>.section.<slot>`) in a single call. Upsert semantics: re-invoking with changed values tunes the slot in place (node ULID preserved, passthrough metadata kept). Equivalent to the granular wire_node_create + wire_spec_register + wire_projection_register walkthrough in the onboarding guide. Preview the adapter's fetched_data shape via wire_fetch before writing the template."
1149 )]
1150 async fn wire_slot_register_tool(
1151 &self,
1152 Parameters(p): Parameters<WireSlotRegisterParams>,
1153 ) -> Result<String, String> {
1154 let tf = TargetForm::parse(p.target_form.as_deref().unwrap_or("markdown"))
1155 .map_err(|e| e.to_string())?;
1156 let s = self.storage.lock().map_err(|e| e.to_string())?;
1157 let out = wire_slot_register(
1158 WireSlotRegisterInput {
1159 persona_id: p.persona_id,
1160 slot: p.slot,
1161 source_uri: p.source_uri,
1162 template: p.template,
1163 target_form: tf,
1164 maintenance_exempt: p.maintenance_exempt,
1165 auth: p.auth,
1166 },
1167 &s,
1168 )
1169 .map_err(|e| e.to_string())?;
1170 serde_json::to_string_pretty(&serde_json::json!({
1171 "node_name": out.node_name,
1172 "node_id": out.node_id,
1173 "node_created": out.node_created,
1174 "spec_name": out.spec_name,
1175 "projection_name": out.projection_name,
1176 }))
1177 .map_err(|e| e.to_string())
1178 }
1179
1180 #[tool(
1182 name = "wire_slot_delete",
1183 description = "Counterpart to wire_slot_register — deletes the wiring node (`<persona>.<slot>`), the boilerplate spec (`<persona>.spec.<slot>`), and the projection (`<persona>.section.<slot>`). Idempotent: missing artifacts report deleted=false instead of erroring."
1184 )]
1185 async fn wire_slot_delete_tool(
1186 &self,
1187 Parameters(p): Parameters<WireSlotDeleteParams>,
1188 ) -> Result<String, String> {
1189 let s = self.storage.lock().map_err(|e| e.to_string())?;
1190 let out = wire_slot_delete(
1191 WireSlotDeleteInput {
1192 persona_id: p.persona_id,
1193 slot: p.slot,
1194 },
1195 &s,
1196 )
1197 .map_err(|e| e.to_string())?;
1198 serde_json::to_string_pretty(&serde_json::json!({
1199 "node_name": out.node_name,
1200 "node_deleted": out.node_deleted,
1201 "spec_name": out.spec_name,
1202 "spec_deleted": out.spec_deleted,
1203 "projection_name": out.projection_name,
1204 "projection_deleted": out.projection_deleted,
1205 }))
1206 .map_err(|e| e.to_string())
1207 }
1208
1209 #[tool(
1211 name = "wire_fetch",
1212 description = "Raw adapter preview: routes a scheme-tagged URI through the same PluginRegistry dispatch the render path uses and returns the adapter output verbatim (= exactly what templates see as `entries[].fetched_data`). Supply either `source_uri` alone, or `persona_id` + `slot` to preview an existing wiring entry (applies its metadata.auth merge). Adapter errors fail loud instead of the render path's best-effort Null. Use this to discover field paths before writing a projection template."
1213 )]
1214 async fn wire_fetch_tool(
1215 &self,
1216 Parameters(p): Parameters<WireFetchParams>,
1217 ) -> Result<String, String> {
1218 let storage = self.storage.clone();
1219 let out = wire_fetch(
1220 WireFetchInput {
1221 source_uri: p.source_uri,
1222 persona_id: p.persona_id,
1223 slot: p.slot,
1224 },
1225 storage,
1226 &self.registry,
1227 )
1228 .await
1229 .map_err(|e| e.to_string())?;
1230 serde_json::to_string_pretty(&serde_json::json!({
1231 "source_uri": out.source_uri,
1232 "fetched_data": out.fetched_data,
1233 }))
1234 .map_err(|e| e.to_string())
1235 }
1236
1237 #[tool(
1239 name = "wire_materialize",
1240 description = "Fetch + persist counterpart of wire_fetch (which only previews). Routes the `<persona>.<slot>` wiring entry's source_uri through the same PluginRegistry dispatch, shreds the response into items (via `item_path`, a JSON Pointer to the array, e.g. `/items`), dedups them against the existing timeline (identity = the `item_id_key` field, else a content hash), and appends them to the Tank. Idempotently ensures a SnapshotRegistry node (`<persona>.tank.<slot>`) + `archives` edge so the archive is itself a Source: read it back with `tank://<persona>/<slot>?since=-30d&tail_n=N`. Returns {tank_uri, snapshot_id, item_count, new_item_count, deduped_count, registry_node_id, registry_created}."
1241 )]
1242 async fn wire_materialize_tool(
1243 &self,
1244 Parameters(p): Parameters<WireMaterializeParams>,
1245 ) -> Result<String, String> {
1246 let storage = self.storage.clone();
1247 let out = wire_materialize(
1248 WireMaterializeInput {
1249 persona_id: p.persona_id,
1250 slot: p.slot,
1251 item_path: p.item_path,
1252 item_id_key: p.item_id_key,
1253 },
1254 storage,
1255 &self.registry,
1256 )
1257 .await
1258 .map_err(|e| e.to_string())?;
1259 serde_json::to_string_pretty(&serde_json::json!({
1260 "tank_uri": out.tank_uri,
1261 "snapshot_id": out.snapshot_id,
1262 "item_count": out.item_count,
1263 "new_item_count": out.new_item_count,
1264 "deduped_count": out.deduped_count,
1265 "registry_node_id": out.registry_node_id,
1266 "registry_created": out.registry_created,
1267 }))
1268 .map_err(|e| e.to_string())
1269 }
1270
1271 #[tool(
1273 name = "wire_projection_delete",
1274 description = "Delete a NamedProjection by name. Returns {kind, id_or_name, deleted}."
1275 )]
1276 async fn wire_projection_delete_tool(
1277 &self,
1278 Parameters(p): Parameters<WireDeleteParams>,
1279 ) -> Result<String, String> {
1280 let s = self.storage.lock().map_err(|e| e.to_string())?;
1281 let out = wire_projection_delete(
1282 WireDeleteInput {
1283 id_or_name: p.id_or_name,
1284 },
1285 &s,
1286 )
1287 .map_err(|e| e.to_string())?;
1288 let json = serde_json::json!({
1289 "kind": out.kind,
1290 "id_or_name": out.id_or_name,
1291 "deleted": out.deleted,
1292 });
1293 serde_json::to_string_pretty(&json).map_err(|e| e.to_string())
1294 }
1295
1296 #[tool(
1300 name = "wire_workflow_register",
1301 description = "Register a Workflow as a `workflow_def` Node. trigger.kind ∈ {on_demand, on_event}; action.kind ∈ {no_op, emit_projection}. See docs/wire-workflow-spec.md."
1302 )]
1303 async fn wire_workflow_register_tool(
1304 &self,
1305 Parameters(p): Parameters<WireWorkflowRegisterParams>,
1306 ) -> Result<String, String> {
1307 let trigger: serde_json::Value =
1308 serde_json::from_str(&p.trigger).map_err(|e| format!("parse trigger JSON: {e}"))?;
1309 let action: serde_json::Value =
1310 serde_json::from_str(&p.action).map_err(|e| format!("parse action JSON: {e}"))?;
1311 let s = self.storage.lock().map_err(|e| e.to_string())?;
1312 let out = wire_workflow_register(
1313 WireWorkflowRegisterInput {
1314 id: p.id,
1315 persona_id: p.persona_id,
1316 trigger,
1317 action,
1318 enabled: p.enabled,
1319 },
1320 &s,
1321 )
1322 .map_err(|e| e.to_string())?;
1323 Ok(format!("registered workflow: {}", out.id))
1324 }
1325
1326 #[tool(
1328 name = "wire_workflow_list",
1329 description = "List registered Workflows with optional persona_id / trigger_kind filters (defaults: enabled_only=true)."
1330 )]
1331 async fn wire_workflow_list_tool(
1332 &self,
1333 Parameters(p): Parameters<WireWorkflowListParams>,
1334 ) -> Result<String, String> {
1335 let s = self.storage.lock().map_err(|e| e.to_string())?;
1336 let out = wire_workflow_list(
1337 WireWorkflowListInput {
1338 persona_id: p.persona_id,
1339 trigger_kind: p.trigger_kind,
1340 enabled_only: p.enabled_only,
1341 },
1342 &s,
1343 )
1344 .map_err(|e| e.to_string())?;
1345 let workflows: Vec<serde_json::Value> = out
1346 .workflows
1347 .into_iter()
1348 .map(|w| {
1349 serde_json::json!({
1350 "id": w.id,
1351 "persona_id": w.persona_id,
1352 "enabled": w.enabled,
1353 "trigger": w.trigger,
1354 "action": w.action,
1355 })
1356 })
1357 .collect();
1358 serde_json::to_string_pretty(&serde_json::json!({ "workflows": workflows }))
1359 .map_err(|e| e.to_string())
1360 }
1361
1362 #[tool(
1364 name = "wire_workflow_delete",
1365 description = "Delete a Workflow by id. Returns {kind, id_or_name, deleted}. Equivalent to wire_node_delete for the workflow's Node id."
1366 )]
1367 async fn wire_workflow_delete_tool(
1368 &self,
1369 Parameters(p): Parameters<WireDeleteParams>,
1370 ) -> Result<String, String> {
1371 let s = self.storage.lock().map_err(|e| e.to_string())?;
1372 let out = wire_node_delete(
1373 WireDeleteInput {
1374 id_or_name: p.id_or_name,
1375 },
1376 &s,
1377 )
1378 .map_err(|e| e.to_string())?;
1379 let json = serde_json::json!({
1380 "kind": out.kind,
1381 "id_or_name": out.id_or_name,
1382 "deleted": out.deleted,
1383 });
1384 serde_json::to_string_pretty(&json).map_err(|e| e.to_string())
1385 }
1386
1387 #[tool(
1391 name = "wire_workflow_fire",
1392 description = "Fire a Workflow by `id` (single) or by `event` (fan-out across enabled on_event workflows). Resolves the action; for `emit_projection`, invokes wire_prompt_context and returns the rendered block per fire (unless dry_run=true)."
1393 )]
1394 async fn wire_workflow_fire_tool(
1395 &self,
1396 Parameters(p): Parameters<WireWorkflowFireParams>,
1397 ) -> Result<String, String> {
1398 let resolved = {
1400 let s = self.storage.lock().map_err(|e| e.to_string())?;
1401 wire_workflow_fire(
1402 WireWorkflowFireInput {
1403 id: p.id,
1404 event: p.event,
1405 persona_id: p.persona_id,
1406 dry_run: p.dry_run,
1407 },
1408 &s,
1409 )
1410 .map_err(|e| e.to_string())?
1411 };
1412
1413 let mut fired_out = Vec::with_capacity(resolved.fired.len());
1415 for f in resolved.fired {
1416 let rendered = if f.dry_run {
1417 serde_json::json!(null)
1418 } else if f.action_kind == "emit_projection" {
1419 match f.persona_id.clone() {
1420 None => serde_json::json!({
1421 "error": "emit_projection requires metadata.persona to render",
1422 }),
1423 Some(persona_id) => {
1424 let names = f.action_emit_projection_names.clone().unwrap_or_default();
1425 match wire_prompt_context(
1426 WirePromptContextInput {
1427 persona_id,
1428 projection_names: Some(names),
1429 projection_exclude_names: None,
1430 },
1431 self.storage.clone(),
1432 &self.registry,
1433 )
1434 .await
1435 {
1436 Ok(pc) => serde_json::json!({
1437 "persona_id": pc.persona_id,
1438 "prompt_context": pc.prompt_context,
1439 "warnings": pc.warnings,
1440 }),
1441 Err(e) => serde_json::json!({ "error": e.to_string() }),
1442 }
1443 }
1444 }
1445 } else {
1446 serde_json::json!({ "kind": "no_op" })
1447 };
1448 fired_out.push(serde_json::json!({
1449 "id": f.id,
1450 "persona_id": f.persona_id,
1451 "action_kind": f.action_kind,
1452 "dry_run": f.dry_run,
1453 "result": rendered,
1454 }));
1455 }
1456
1457 let skipped_out: Vec<serde_json::Value> = resolved
1458 .skipped
1459 .into_iter()
1460 .map(|(id, reason)| serde_json::json!({ "id": id, "reason": reason }))
1461 .collect();
1462
1463 serde_json::to_string_pretty(&serde_json::json!({
1464 "fired": fired_out,
1465 "skipped": skipped_out,
1466 }))
1467 .map_err(|e| e.to_string())
1468 }
1469
1470 #[tool(
1474 name = "wire_bundle_register",
1475 description = "Register a Bundle scaffolding template. `body` is a TOML literal containing a [bundle] table (name + version + optional description) and any subset of [[specs]] / [[projections]] / [[nodes]] / [[edges]] / [[wirings]] / [[workflows]] sections. The TOML body is stored verbatim; install-time parsing surfaces per-entity errors. Same-name register overwrites; install conflict resolution lives in `wire_bundle_install`."
1476 )]
1477 async fn wire_bundle_register(
1478 &self,
1479 Parameters(p): Parameters<WireBundleRegisterParams>,
1480 ) -> Result<String, String> {
1481 use persona_wire_core::application::bundle_registry::BundleRegistry;
1482 use persona_wire_core::domain::entity::bundle::{BundleName, BundleVersion};
1483 let (name, version, description) = parse_bundle_header(&p.body)?;
1484 let s = self.storage.lock().map_err(|e| e.to_string())?;
1485 let bn = BundleName::new(name.clone()).map_err(|e| e.to_string())?;
1486 let bv = BundleVersion::new(version.clone()).map_err(|e| e.to_string())?;
1487 let id = BundleRegistry::new(&s)
1488 .register(&bn, &bv, description.as_deref(), &p.body)
1489 .map_err(|e| e.to_string())?;
1490 Ok(serde_json::json!({
1491 "id": id.to_string(),
1492 "name": name,
1493 "version": version,
1494 })
1495 .to_string())
1496 }
1497
1498 #[tool(
1500 name = "wire_bundle_list",
1501 description = "List registered Bundles in name-ascending order. Each row carries id / name / version / description (full TOML body is omitted; fetch via `wire_bundle_get`)."
1502 )]
1503 async fn wire_bundle_list(&self) -> Result<String, String> {
1504 use persona_wire_core::application::bundle_registry::BundleRegistry;
1505 let s = self.storage.lock().map_err(|e| e.to_string())?;
1506 let bundles = BundleRegistry::new(&s).list().map_err(|e| e.to_string())?;
1507 let rows: Vec<serde_json::Value> = bundles
1508 .into_iter()
1509 .map(|b| {
1510 serde_json::json!({
1511 "id": b.id.to_string(),
1512 "name": b.name.as_str(),
1513 "version": b.version.as_str(),
1514 "description": b.description,
1515 })
1516 })
1517 .collect();
1518 serde_json::to_string_pretty(&serde_json::json!({ "bundles": rows }))
1519 .map_err(|e| e.to_string())
1520 }
1521
1522 #[tool(
1524 name = "wire_bundle_get",
1525 description = "Fetch a registered Bundle by name or ULID id. Returns id / name / version / description / body (raw TOML) / created_at / updated_at. Errors with NotFound if absent."
1526 )]
1527 async fn wire_bundle_get(
1528 &self,
1529 Parameters(p): Parameters<WireBundleRefParams>,
1530 ) -> Result<String, String> {
1531 use persona_wire_core::application::bundle_registry::BundleRegistry;
1532 use persona_wire_core::domain::entity::bundle::BundleRef;
1533 let s = self.storage.lock().map_err(|e| e.to_string())?;
1534 let r = BundleRef::parse(&p.r#ref).map_err(|e| e.to_string())?;
1535 let b = BundleRegistry::new(&s)
1536 .resolve(&r)
1537 .map_err(|e| e.to_string())?
1538 .ok_or_else(|| format!("bundle not found: {}", p.r#ref))?;
1539 Ok(serde_json::json!({
1540 "id": b.id.to_string(),
1541 "name": b.name.as_str(),
1542 "version": b.version.as_str(),
1543 "description": b.description,
1544 "body": b.body,
1545 "created_at": b.created_at,
1546 "updated_at": b.updated_at,
1547 })
1548 .to_string())
1549 }
1550
1551 #[tool(
1553 name = "wire_bundle_install",
1554 description = "Install a registered Bundle. `mode` ∈ {increment (default, auto-suffix collisions), skip (leave existing rows alone), error (abort whole install on first collision)}. Returns BundleInstallReport with per-entity installed / skipped / errors rows."
1555 )]
1556 async fn wire_bundle_install(
1557 &self,
1558 Parameters(p): Parameters<WireBundleInstallParams>,
1559 ) -> Result<String, String> {
1560 use persona_wire_core::application::bundle_install::install_bundle;
1561 use persona_wire_core::application::bundle_registry::BundleRegistry;
1562 use persona_wire_core::domain::entity::bundle::{BundleRef, ConflictMode};
1563 let mode = match p.mode.as_deref() {
1564 None => ConflictMode::default(),
1565 Some(s) => ConflictMode::parse(s).map_err(|e| e.to_string())?,
1566 };
1567 let s = self.storage.lock().map_err(|e| e.to_string())?;
1568 let r = BundleRef::parse(&p.r#ref).map_err(|e| e.to_string())?;
1569 let bundle = BundleRegistry::new(&s)
1570 .resolve(&r)
1571 .map_err(|e| e.to_string())?
1572 .ok_or_else(|| format!("bundle not found: {}", p.r#ref))?;
1573 let report = install_bundle(&bundle, mode, &s).map_err(|e| e.to_string())?;
1574 serde_json::to_string_pretty(&report).map_err(|e| e.to_string())
1575 }
1576
1577 #[tool(
1579 name = "wire_bundle_delete",
1580 description = "Delete a Bundle row by name or id. Install history (`bundle_installs`) is intentionally preserved across deletion. Returns {deleted: bool}."
1581 )]
1582 async fn wire_bundle_delete(
1583 &self,
1584 Parameters(p): Parameters<WireBundleRefParams>,
1585 ) -> Result<String, String> {
1586 use persona_wire_core::application::bundle_registry::BundleRegistry;
1587 use persona_wire_core::domain::entity::bundle::BundleRef;
1588 let s = self.storage.lock().map_err(|e| e.to_string())?;
1589 let reg = BundleRegistry::new(&s);
1590 let deleted = match BundleRef::parse(&p.r#ref).map_err(|e| e.to_string())? {
1591 BundleRef::Id(id) => reg.delete_by_id(id).map_err(|e| e.to_string())?,
1592 BundleRef::Name(name) => reg.delete(&name).map_err(|e| e.to_string())?,
1593 };
1594 Ok(serde_json::json!({ "deleted": deleted }).to_string())
1595 }
1596}
1597
1598fn parse_bundle_header(body: &str) -> Result<(String, String, Option<String>), String> {
1603 let value: toml::Value =
1604 toml::from_str(body).map_err(|e| format!("bundle TOML parse: {}", e))?;
1605 let bundle = value
1606 .get("bundle")
1607 .and_then(|v| v.as_table())
1608 .ok_or_else(|| "missing [bundle] table".to_string())?;
1609 let name = bundle
1610 .get("name")
1611 .and_then(|v| v.as_str())
1612 .ok_or_else(|| "missing [bundle].name".to_string())?
1613 .to_string();
1614 let version = bundle
1615 .get("version")
1616 .and_then(|v| v.as_str())
1617 .ok_or_else(|| "missing [bundle].version".to_string())?
1618 .to_string();
1619 let description = bundle
1620 .get("description")
1621 .and_then(|v| v.as_str())
1622 .map(|s| s.to_string());
1623 Ok((name, version, description))
1624}
1625
1626pub const ONBOARDING_GUIDE: &str = include_str!("../onboarding.md");
1660
1661const ONBOARDING_URI: &str = "wire-guide://onboarding";
1662
1663#[tool_handler]
1664impl ServerHandler for WireServer {
1665 fn get_info(&self) -> rmcp::model::ServerInfo {
1666 rmcp::model::ServerInfo::new(
1667 rmcp::model::ServerCapabilities::builder()
1668 .enable_tools()
1669 .enable_resources()
1670 .build(),
1671 )
1672 .with_server_info(rmcp::model::Implementation::new(
1673 "persona-wire-mcp",
1674 env!("CARGO_PKG_VERSION"),
1675 ))
1676 .with_instructions(
1677 "persona-wire MCP server. Graph engine over persona × SoT × workflow \
1678 context routing. Tools: wire_init / wire_close / wire_doctor / \
1679 wire_query / wire_render / wire_prompt_context / wire_fetch / \
1680 wire_slot_register / wire_slot_delete / wire_node_create / \
1681 wire_edge_create / wire_nodes_create_batch / wire_edges_create_batch / \
1682 wire_spec_register / wire_projection_register / wire_node_delete / \
1683 wire_edge_delete / wire_spec_delete / wire_projection_delete / \
1684 wire_workflow_register / wire_workflow_list / wire_workflow_fire / \
1685 wire_workflow_delete. \
1686 Quick slot setup: wire_fetch(source_uri) to preview the adapter's \
1687 fetched_data shape → wire_slot_register(persona_id, slot, source_uri, \
1688 template) → wire_prompt_context(persona_id) to verify. \
1689 For the full end-to-end onboarding walkthrough (setup → wire entries → \
1690 spec / projection → optional persona-pack overlay → wire_prompt_context \
1691 call → Skill / Prompt wiring) read the bundled resource at \
1692 `wire-guide://onboarding` via `read_resource`.",
1693 )
1694 }
1695
1696 async fn list_resources(
1697 &self,
1698 _request: Option<rmcp::model::PaginatedRequestParams>,
1699 _ctx: rmcp::service::RequestContext<rmcp::service::RoleServer>,
1700 ) -> std::result::Result<rmcp::model::ListResourcesResult, rmcp::ErrorData> {
1701 let raw = rmcp::model::RawResource {
1702 uri: ONBOARDING_URI.to_string(),
1703 name: "persona-wire onboarding guide".to_string(),
1704 title: Some("Onboarding — Wiring a new persona end-to-end".to_string()),
1705 description: Some(
1706 "Full walkthrough: install, register wiring entries, register \
1707 Specification + NamedProjection, optional persona-pack overlay, \
1708 smoke-test, and inline the rendered prompt_context into a Skill."
1709 .to_string(),
1710 ),
1711 mime_type: Some("text/markdown".to_string()),
1712 size: Some(ONBOARDING_GUIDE.len() as u32),
1713 icons: None,
1714 meta: None,
1715 };
1716 let resource = rmcp::model::Resource {
1717 raw,
1718 annotations: None,
1719 };
1720 Ok(rmcp::model::ListResourcesResult::with_all_items(vec![
1721 resource,
1722 ]))
1723 }
1724
1725 async fn read_resource(
1726 &self,
1727 request: rmcp::model::ReadResourceRequestParams,
1728 _ctx: rmcp::service::RequestContext<rmcp::service::RoleServer>,
1729 ) -> std::result::Result<rmcp::model::ReadResourceResult, rmcp::ErrorData> {
1730 if request.uri == ONBOARDING_URI {
1731 Ok(rmcp::model::ReadResourceResult::new(vec![
1732 rmcp::model::ResourceContents::text(ONBOARDING_GUIDE, ONBOARDING_URI),
1733 ]))
1734 } else {
1735 Err(rmcp::ErrorData::resource_not_found(
1736 format!("unknown resource uri: {}", request.uri),
1737 None,
1738 ))
1739 }
1740 }
1741}
1742
1743pub async fn serve_stdio(db_path: &str) -> Result<()> {
1747 tracing::info!(db = %db_path, "persona-wire mcp starting");
1748
1749 let storage = SqliteStorage::open(db_path)?;
1750 storage.migrate()?;
1751 storage.seed_default_types()?;
1752
1753 let server = WireServer::new(storage);
1754 let transport = rmcp::transport::io::stdio();
1755 let service = server.serve(transport).await?;
1756 service.waiting().await?;
1757
1758 Ok(())
1759}
1760
1761#[cfg(test)]
1762mod tests {
1763 use super::*;
1764 use serde_json::json;
1765
1766 #[test]
1767 fn normalize_metadata_none_becomes_empty_object() {
1768 let got = normalize_metadata(None);
1769 assert_eq!(got, json!({}));
1770 assert!(got.is_object());
1771 }
1772
1773 #[test]
1774 fn normalize_metadata_null_becomes_empty_object() {
1775 let got = normalize_metadata(Some(serde_json::Value::Null));
1776 assert_eq!(got, json!({}));
1777 }
1778
1779 #[test]
1780 fn normalize_metadata_object_passes_through() {
1781 let got = normalize_metadata(Some(json!({"persona": "alpha", "axis": "active"})));
1782 assert_eq!(got, json!({"persona": "alpha", "axis": "active"}));
1783 }
1784
1785 #[test]
1786 fn normalize_metadata_json_encoded_string_is_recovered_as_object() {
1787 let stringified = r#"{"persona":"alpha","axis":"active","nested":{"k":1}}"#;
1791 let got = normalize_metadata(Some(serde_json::Value::String(stringified.into())));
1792 assert_eq!(
1793 got,
1794 json!({
1795 "persona": "alpha",
1796 "axis": "active",
1797 "nested": {"k": 1}
1798 })
1799 );
1800 assert!(got.is_object());
1801 }
1802
1803 #[test]
1804 fn normalize_metadata_plain_string_is_preserved() {
1805 let got = normalize_metadata(Some(serde_json::Value::String("hello".into())));
1808 assert_eq!(got, json!("hello"));
1809 assert!(got.is_string());
1810 }
1811
1812 #[test]
1813 fn normalize_metadata_json_array_string_is_recovered() {
1814 let got = normalize_metadata(Some(serde_json::Value::String("[1,2,3]".into())));
1816 assert_eq!(got, json!([1, 2, 3]));
1817 assert!(got.is_array());
1818 }
1819}