Skip to main content

persona_wire_mcp/
lib.rs

1//! persona-wire MCP server library — the transport layer that wraps
2//! [`persona_wire_core`] for consumption by MCP clients (Claude Code,
3//! `mcp://` peers, etc.).
4//!
5//! This crate exposes [`serve_stdio`] for the unified
6//! `persona-wire mcp` subcommand to dispatch into. Transport is rmcp
7//! stdio (see [`ServiceExt`] plumbing); the tool surface is defined
8//! by the [`WireServer`] struct, whose methods are annotated with
9//! `#[rmcp::tool]` and enumerated into a [`ToolRouter`] at boot.
10//!
11//! [`WireServer::new`] constructs a persistent [`SqliteStorage`] +
12//! [`PluginRegistry`] pair once at startup. The registry combines
13//! core defaults (FileAdapter + HandlebarsEngine + StaticProjection)
14//! with the fifteen external adapter crates
15//! (`persona-wire-adapter-{mini-app, sqlite-x, obsidian,
16//! persona-pack, mcp, rss, github, matrix, mastodon, todoist, notion, slack,
17//! apple-notes, activitypub, bluesky}`), so every scheme-tagged URI a caller
18//! passes to `wire_prompt_context`, `wire_render`, or `wire_workflow_fire`
19//! resolves through the same pipeline.
20//!
21//! No CLI parsing / entry-point code lives here — the `persona-wire`
22//! binary in the sibling crate is the only intended caller, and its
23//! `mcp` subcommand's job is to build a [`SqliteStorage`] pointing at
24//! the operator's on-disk DB and hand it to `serve_stdio`.
25
26use 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/// MCP server wrapping persona-wire-core.
71#[derive(Clone)]
72pub struct WireServer {
73    storage: Arc<Mutex<SqliteStorage>>,
74    /// P3a Phase 2 (b) / P3b — Plugin Registry built once at boot. Core defaults
75    /// = FileAdapter + HandlebarsEngine + StaticProjection; `MiniAppAdapter`
76    /// (mini-app schema-aware), `SqliteAdapter` (raw SQLite, suited for
77    /// Fly.io / single-binary self-hosting), and `PersonaPackAdapter`
78    /// (persona-pack overlay ACL Facade, scheme `persona-pack://`) are injected
79    /// from external crates. Additional plugins (e.g. `wire-adapter-pg`) can be
80    /// injected by replacing the `new()` constructor with a builder-aware one
81    /// in a future Phase.
82    registry: Arc<PluginRegistry>,
83    /// Consumed indirectly by `#[tool_handler]`-generated code.
84    #[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        // McpAdapter: graph-backed endpoint resolution. Every `mcp://<alias>/...`
94        // fetch reads node `<alias>` from the shared SqliteStorage; the node
95        // must have `type = "mcp_server"` and `metadata.endpoint = <ServerEndpoint>`.
96        // See `wire-guide://onboarding` for the registration recipe.
97        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                    // Tank adapter (tank://) — reads the observation item log
119                    // wire_materialize persists into the shared store.
120                    .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// ---------------------------------------------------------------------------
130// Tool parameter schemas
131// ---------------------------------------------------------------------------
132
133#[derive(Debug, Deserialize, JsonSchema)]
134pub struct WireInitParams {
135    /// Persona id for which the context bundle is rendered.
136    pub persona_id: String,
137}
138
139#[derive(Debug, Deserialize, JsonSchema)]
140pub struct WireCloseParams {
141    /// Persona id for which the lifecycle scan is reported.
142    pub persona_id: String,
143}
144
145/// Normalize a metadata argument that may arrive as a JSON-encoded string.
146///
147/// Some MCP clients (and the rmcp serde path the unified `Parameters<T>`
148/// wrapper goes through for top-level fields) stringify `serde_json::Value`
149/// inputs before deserialization. Without normalization, an object payload
150/// like `{ "persona": "alpha" }` ends up stored as `Value::String("{ … }")`,
151/// which breaks every downstream `MetadataEq` query.
152///
153/// The batch tools (`wire_nodes_create_batch` / `wire_edges_create_batch`)
154/// avoid the issue because rmcp deserializes the outer `Vec<…>` first and
155/// recursively unwraps each element. Single-row tools need to apply the
156/// same recovery explicitly.
157///
158/// Rules:
159/// - `None` / `Value::Null` → empty object `{}` (legacy default).
160/// - `Value::String(s)` where `s` parses as JSON → parsed value.
161/// - `Value::String(s)` where `s` is not JSON → kept as-is (caller intent).
162/// - Anything else → returned unchanged.
163fn 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    /// Human-readable label for the node (e.g. "alpha.workflow.review_close").
176    /// Not required to be unique — the server mints a fresh ULID as the
177    /// opaque `id` and returns it in the response. Subsequent operations
178    /// (update / delete / get / src/tgt) accept either the ULID or this
179    /// `name` via the `id_or_name` resolver.
180    pub name: String,
181    /// Node type — must be in type_registry (e.g. "persona", "outline_node").
182    #[serde(rename = "type")]
183    pub type_: String,
184    /// Optional SoT ref like "pp://alpha".
185    #[serde(default)]
186    pub sot_ref: Option<String>,
187    /// Optional metadata object (JSON), defaults to `{}`.
188    #[serde(default)]
189    pub metadata: Option<serde_json::Value>,
190}
191
192#[derive(Debug, Deserialize, JsonSchema)]
193pub struct WireEdgeCreateParams {
194    /// Optional human-readable label for the edge. Server mints the opaque
195    /// ULID `id` regardless and returns it. Omit for edges that have no
196    /// natural caller-facing name (e.g. ad-hoc `routes_to` links).
197    #[serde(default)]
198    pub name: Option<String>,
199    /// Source endpoint — accepts either the node's ULID or its `name`.
200    pub src: String,
201    /// Target endpoint — accepts either the node's ULID or its `name`.
202    pub tgt: String,
203    /// Edge kind — must be in type_registry (e.g. "routes_to", "cites").
204    pub kind: String,
205    /// Optional severity {hard|soft|advisory}, only for triggers_review_of.
206    #[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    /// Array of node entries; each entry mirrors `wire_node_create` params.
215    pub nodes: Vec<WireNodeCreateParams>,
216}
217
218#[derive(Debug, Deserialize, JsonSchema)]
219pub struct WireEdgesCreateBatchParams {
220    /// Array of edge entries; each entry mirrors `wire_edge_create` params.
221    pub edges: Vec<WireEdgeCreateParams>,
222}
223
224#[derive(Debug, Deserialize, JsonSchema)]
225pub struct WireRenderParams {
226    /// Name of a registered NamedProjection to evaluate + render.
227    pub projection_ref: String,
228}
229
230#[derive(Debug, Deserialize, JsonSchema)]
231pub struct WireQueryParams {
232    /// Inline Specification body (JSON-serialised). Mutually exclusive with `spec_ref`.
233    /// Example: `{"TypeIs":"persona"}` or `{"And":[...]}`.
234    #[serde(default)]
235    pub spec: Option<String>,
236    /// Name of a previously registered Specification. Mutually exclusive with `spec`.
237    #[serde(default)]
238    pub spec_ref: Option<String>,
239    /// Maximum number of matched nodes to return. Omit for unlimited.
240    #[serde(default)]
241    pub limit: Option<usize>,
242    /// Number of leading matched nodes to skip. Omit for 0.
243    #[serde(default)]
244    pub offset: Option<usize>,
245}
246
247#[derive(Debug, Deserialize, JsonSchema)]
248pub struct WireSpecRegisterParams {
249    pub name: String,
250    /// JSON body of a Specification (e.g. `{"TypeIs":"persona"}`).
251    pub json: String,
252}
253
254#[derive(Debug, Deserialize, JsonSchema)]
255pub struct WireProjectionRegisterParams {
256    pub name: String,
257    /// Name of a previously registered Specification.
258    pub spec_ref: String,
259    /// Mustache-like template (e.g. `"Personas: {{count}}"`).
260    pub template: String,
261    /// One of prompt | markdown | json | ascii.
262    pub target_form: String,
263}
264
265#[derive(Debug, Deserialize, JsonSchema)]
266pub struct WireDeleteParams {
267    /// Node id (for wire_node_delete / wire_edge_delete) or registered name
268    /// (for wire_spec_delete / wire_projection_delete). Also reused by
269    /// wire_spec_get / wire_projection_get (same id-or-name resolution).
270    pub id_or_name: String,
271}
272
273#[derive(Debug, Deserialize, JsonSchema)]
274pub struct WireListPageParams {
275    /// Max rows to return. Defaults to 100, capped at 1000.
276    #[serde(default)]
277    pub limit: Option<u32>,
278    /// Number of leading rows to skip. Defaults to 0.
279    #[serde(default)]
280    pub offset: Option<u32>,
281}
282
283#[derive(Debug, Deserialize, JsonSchema)]
284pub struct WireNodeUpdateParams {
285    /// Node id to update. NotFound error if the row does not exist.
286    pub id: String,
287    /// JSON object whose top-level keys patch the existing node metadata.
288    /// In `merge` mode (default), `null` values DELETE the matching key
289    /// (RFC 7396); other values overwrite. In `replace` mode the existing
290    /// metadata is fully replaced by this object.
291    pub metadata_patch: serde_json::Value,
292    /// One of `"merge"` (default) or `"replace"`.
293    #[serde(default)]
294    pub mode: Option<String>,
295}
296
297#[derive(Debug, Deserialize, JsonSchema)]
298pub struct WirePromptContextParams {
299    pub persona_id: String,
300    /// Optional subset of slot names to render (e.g. `["active", "ng"]`).
301    /// `None` (omitted) = render all slots registered in persona-pack
302    /// `[extra.persona_wire.sections]`.
303    #[serde(default)]
304    pub projection_names: Option<Vec<String>>,
305    /// Optional subset of slot names to exclude (e.g. `["mail", "news"]`).
306    /// Combines with `projection_names` as AND NOT: `include \ exclude`.
307    /// `None` (omitted) = no exclusion. Unknown names are ignored.
308    #[serde(default)]
309    pub projection_exclude_names: Option<Vec<String>>,
310}
311
312#[derive(Debug, Deserialize, JsonSchema)]
313pub struct WireContextGetParams {
314    /// The persona whose `ContextWiring` consistency boundary to read.
315    pub persona_id: String,
316}
317
318#[derive(Debug, Deserialize, JsonSchema)]
319pub struct WireSlotRegisterParams {
320    /// Persona the slot belongs to (e.g. `"alpha"`).
321    pub persona_id: String,
322    /// Slot name (no dots — e.g. `"mailbox"`, `"gh_issues"`).
323    pub slot: String,
324    /// Scheme-tagged Source URI the slot fetches at render time
325    /// (e.g. `"mini-app://mailbox?alias=for_alpha"`, `"file:~/notes.md"`).
326    pub source_uri: String,
327    /// Handlebars template rendered against the wire_prompt_context data
328    /// shape — iterate `{{#each entries}}{{this.fetched_data...}}{{/each}}`.
329    /// Preview the fetched_data shape via `wire_fetch` first.
330    pub template: String,
331    /// One of prompt | markdown | json | ascii. Defaults to `"markdown"`.
332    #[serde(default)]
333    pub target_form: Option<String>,
334    /// Optional session-maintenance opt-out flag (`metadata.maintenance_exempt`).
335    /// Omitted = leave an existing value alone (create default: unset).
336    #[serde(default)]
337    pub maintenance_exempt: Option<bool>,
338    /// Optional credential reference key (never a secret) — stored as
339    /// `metadata.auth` and merged into the fetch URI as `?auth=<key>`.
340    #[serde(default)]
341    pub auth: Option<String>,
342}
343
344#[derive(Debug, Deserialize, JsonSchema)]
345pub struct WireSlotDeleteParams {
346    /// Persona the slot belongs to.
347    pub persona_id: String,
348    /// Slot name registered via `wire_slot_register` (or the granular tools).
349    pub slot: String,
350}
351
352#[derive(Debug, Deserialize, JsonSchema)]
353pub struct WireFetchParams {
354    /// Raw scheme-tagged URI to fetch (e.g. `"file:~/notes.md"`,
355    /// `"mini-app://mailbox?alias=for_alice"`). Mutually exclusive with
356    /// `persona_id` + `slot`.
357    #[serde(default)]
358    pub source_uri: Option<String>,
359    /// Resolve an existing wiring entry's source_uri (applies its
360    /// `metadata.auth` merge, matching what the render path fetches).
361    /// Requires `slot`; mutually exclusive with `source_uri`.
362    #[serde(default)]
363    pub persona_id: Option<String>,
364    /// Slot name of the wiring entry to preview. Requires `persona_id`.
365    #[serde(default)]
366    pub slot: Option<String>,
367}
368
369#[derive(Debug, Deserialize, JsonSchema)]
370pub struct WireMaterializeParams {
371    /// Persona owning the wiring entry to materialize (`<persona>.<slot>`).
372    pub persona_id: String,
373    /// Slot of the wiring entry to fetch + persist.
374    pub slot: String,
375    /// JSON Pointer (RFC 6901) to the item array in the fetch result (e.g.
376    /// `"/items"`). Omit to treat the whole response as a single item.
377    /// Persisted on the SnapshotRegistry node and reused on later calls.
378    #[serde(default)]
379    pub item_path: Option<String>,
380    /// Item field carrying a stable identity for dedup (e.g. `"id"` / `"guid"`).
381    /// Items missing it fall back to a content hash. Omit to content-hash every
382    /// item. Persisted on the SnapshotRegistry node and reused on later calls.
383    #[serde(default)]
384    pub item_id_key: Option<String>,
385}
386
387#[derive(Debug, Deserialize, JsonSchema)]
388pub struct WireDoctorParams {
389    /// Optional persona scope. `None` → Full mode (全 persona 横串)。
390    /// `Some(id)` → Persona-scoped mode (当該 persona に紐づく Finding のみ列挙)。
391    #[serde(default)]
392    pub persona_id: Option<String>,
393}
394
395#[derive(Debug, Deserialize, JsonSchema)]
396pub struct WireWorkflowRegisterParams {
397    /// Node id for the workflow (e.g. `"alpha.workflow.review_close"`).
398    pub id: String,
399    /// Optional persona scope (stored in `metadata.persona`).
400    #[serde(default)]
401    pub persona_id: Option<String>,
402    /// Trigger descriptor as JSON string — `{"kind":"on_demand"}` or
403    /// `{"kind":"on_event","event":"<name>"}`. String form mirrors
404    /// `wire_spec_register.json` for transport-friendly schema derivation.
405    pub trigger: String,
406    /// Action descriptor as JSON string — `{"kind":"no_op"}` or
407    /// `{"kind":"emit_projection","projection_names":["..."]}`.
408    pub action: String,
409    /// Defaults to `true`.
410    #[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    /// Filter by `trigger.kind` (e.g. `"on_demand"` / `"on_event"`).
419    #[serde(default)]
420    pub trigger_kind: Option<String>,
421    /// Defaults to `true` (= exclude disabled).
422    #[serde(default)]
423    pub enabled_only: Option<bool>,
424}
425
426// ---- Bundle params --------------------------------------------------------
427
428#[derive(Debug, Deserialize, JsonSchema)]
429pub struct WireBundleRegisterParams {
430    /// TOML body of the bundle. Must include a `[bundle]` table with
431    /// `name = "<unique>"` and `version = "<semver>"`. Section arrays
432    /// (`[[specs]]` / `[[projections]]` / `[[nodes]]` / `[[edges]]` /
433    /// `[[wirings]]` / `[[workflows]]`) are optional.
434    pub body: String,
435}
436
437#[derive(Debug, Deserialize, JsonSchema)]
438pub struct WireBundleRefParams {
439    /// Bundle reference — accepts either a 26-char ULID `id` or the
440    /// `name` value of a registered bundle. ULID is tried first; name
441    /// fallback resolves through `bundles.name UNIQUE`.
442    pub r#ref: String,
443}
444
445#[derive(Debug, Deserialize, JsonSchema)]
446pub struct WireBundleInstallParams {
447    /// Bundle reference — ULID or `name`.
448    pub r#ref: String,
449    /// Conflict resolution mode for entity name collisions.
450    /// `"increment"` (default, non-destructive auto-suffix) /
451    /// `"skip"` (leave existing rows alone) / `"error"` (abort whole
452    /// install on first collision). `"force"` (= overwrite) is not
453    /// implemented in v1 — see Bundle v1 design docs §7.
454    #[serde(default)]
455    pub mode: Option<String>,
456}
457
458#[derive(Debug, Deserialize, JsonSchema)]
459pub struct WireWorkflowFireParams {
460    /// Fire a single workflow by id. Mutually exclusive with `event`.
461    #[serde(default)]
462    pub id: Option<String>,
463    /// Event-name fan-out (matches every enabled `on_event` workflow whose
464    /// `trigger.event` equals this value). Mutually exclusive with `id`.
465    #[serde(default)]
466    pub event: Option<String>,
467    /// Optional persona scope for the event fan-out (matches `metadata.persona`).
468    #[serde(default)]
469    pub persona_id: Option<String>,
470    /// Defaults to `false`. When `true`, resolved fires are returned but no
471    /// action is dispatched (= rendered output omitted).
472    #[serde(default)]
473    pub dry_run: Option<bool>,
474}
475
476// ---------------------------------------------------------------------------
477// Tool implementations
478// ---------------------------------------------------------------------------
479
480#[tool_router]
481impl WireServer {
482    /// Render every registered NamedProjection as a Context bundle.
483    #[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    /// Run lifecycle scan (orphan + totals).
513    #[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    /// 2-axis integrated health report (graph connectivity + workflow coverage).
533    #[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    /// Insert a node.
548    #[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    /// Bulk-insert a batch of nodes (1-row-at-a-time loop, stops on first error).
576    #[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    /// Bulk-insert a batch of edges (1-row-at-a-time loop, stops on first error).
619    #[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    /// Insert an edge.
677    #[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    /// Render a single registered NamedProjection by name (counterpart to wire_init).
720    #[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    /// Ad-hoc query: run a Specification against the graph and return matched nodes.
746    #[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    /// Register a Specification (dynamic / composable selector).
785    #[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    /// Register a NamedProjection (fixed / named view: spec + template + form).
803    #[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        // P3a Phase 2 (a) — MCP wire_projection_register surface does not yet
814        // accept the 3 Plugin hint fields; Phase 2 (c) will extend
815        // `WireProjectionRegisterParams` to expose `PluginDispatch::Custom`.
816        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    /// List registered Specifications in created_at-descending order.
831    #[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    /// Get a Specification by name or id, including the raw JSON body.
862    #[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    /// List registered NamedProjections in created_at-descending order.
886    #[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    /// Get a NamedProjection by name or id.
919    #[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    /// Patch a node's metadata in place (merge or replace). Use for tuning
945    /// wiring entries (e.g. appending `&limit=10` to `metadata.source_uri`)
946    /// without delete + re-create cycles that would lose the node id.
947    #[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        // rmcp harness が top-level Value field を文字列化する挙動を吸収
958        // (2026-06-14 Finding 1 sibling — `normalize_metadata` 経由で recover)。
959        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    /// Delete a node by id. Edges referencing the node are cascade-deleted
979    /// in the same storage Tx (edges FK is NOT-NULL).
980    #[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    /// Delete an edge by id.
1005    #[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    /// Delete a Specification by name. Projections referencing this spec via
1030    /// `spec_ref` will start returning dangling-spec errors at render time.
1031    #[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    /// One-shot entry: discover persona-scoped wiring entries, fetch each slot
1056    /// via the Layer 6 Adapter, render with the registered NamedProjection
1057    /// (optionally merged with a persona-pack overlay), and concatenate the
1058    /// rendered blocks into a single PromptContext.
1059    #[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    /// One-shot structured read of a persona's `ContextWiring` boundary —
1093    /// returns the `Wiring` + `Workflow` set as summary DTOs in a single
1094    /// call (no rendering). Counterpart to `wire_prompt_context` (which
1095    /// returns rendered text).
1096    #[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    /// One-shot slot setup: wiring node + boilerplate spec + projection.
1146    #[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    /// Remove the three artifacts a slot registration created.
1181    #[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    /// Raw adapter preview — see the exact fetched_data a template receives.
1210    #[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    /// Fetch a wiring entry and persist its shredded, deduped items into the Tank.
1238    #[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    /// Delete a NamedProjection by name.
1272    #[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    // ---- wire_workflow_* (P5-a seed) ---------------------------------------
1297
1298    /// Register a Workflow as a `workflow_def` Node (declarative trigger + action).
1299    #[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    /// List registered Workflows (= `workflow_def` Nodes).
1327    #[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    /// Delete a Workflow by id (thin alias of `wire_node_delete` for caller clarity).
1363    #[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    /// Fire one or more Workflows. For `action.kind = emit_projection`,
1388    /// invokes `wire_prompt_context` for each fired workflow and includes the
1389    /// rendered output in the response (unless `dry_run = true`).
1390    #[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        // Phase 1: resolve under lock (sync core).
1399        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        // Phase 2: dispatch async action per fire (currently emit_projection / no_op).
1414        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    // ---- Bundle tools -----------------------------------------------------
1471
1472    /// Register a Bundle by TOML literal. Returns `{id, name, version}`.
1473    #[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    /// List registered Bundles in name-ascending order.
1499    #[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    /// Get a Bundle by name or id, including the full TOML body.
1523    #[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    /// Install a Bundle. Returns the structured report.
1552    #[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    /// Delete a Bundle by name or id. Install history is preserved.
1578    #[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
1598/// Pull `[bundle].name` / `version` / optional `description` out of a TOML
1599/// body without committing the full manifest schema. Used by
1600/// `wire_bundle_register` so a malformed install-time section (e.g.
1601/// `[[specs]].spec` shape) does not block the register call.
1602fn 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
1626// ---------------------------------------------------------------------------
1627// Onboarding guide — exposed both as a Rust constant (for embedding in tests
1628// or other crates) and as an MCP resource at `wire-guide://onboarding`.
1629
1630/// Full end-to-end onboarding guide bundled with the MCP server.
1631///
1632/// # Sync policy (must not edit only one side)
1633///
1634/// - **Canonical**: `docs/onboarding.md` (workspace root, human-navigable
1635///   from project root, cross-referenced by other docs / READMEs).
1636/// - **Bundled copy**: `crates/persona-wire-mcp/onboarding.md` (= the file
1637///   `include_str!`-ed below). This copy exists because `cargo publish`
1638///   only packages files within the crate's own directory tree —
1639///   `include_str!("../../../docs/onboarding.md")` worked for local builds
1640///   but broke `cargo publish --dry-run` (= file outside the packaged
1641///   tarball). Hence the in-crate mirror.
1642///
1643/// **Editing rule**: always edit the canonical workspace copy
1644/// (`docs/onboarding.md`), then run `cp docs/onboarding.md
1645/// crates/persona-wire-mcp/onboarding.md` to refresh the bundled copy.
1646///
1647/// **Safety nets enforcing this rule**:
1648/// 1. `include_str!("../onboarding.md")` here → cargo build / publish
1649///    error out if the bundled copy is missing.
1650/// 2. `crates/persona-wire-mcp/build.rs` byte-compares the two copies on
1651///    every dev build and `panic!`s with a one-line fix command if they
1652///    diverge. Published-tarball builds (= workspace doc absent) skip
1653///    this check — they ship only the bundled copy.
1654///
1655/// Background: introduced in commit `37e7cec` with the original
1656/// `../../../docs/onboarding.md` path; the workspace-relative path silently
1657/// broke `cargo publish` until P5-a' work surfaced it via publish-checker
1658/// invoke (see `docs/wire-workflow-spec.md` §10).
1659pub 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
1743/// Run the MCP server over stdio against the given SQLite db path. Caller
1744/// (typically the unified `persona-wire mcp` subcommand) owns tokio runtime
1745/// setup and tracing init.
1746pub 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        // This is the core Finding 1 fix: a stringified JSON object payload
1788        // must round-trip back into an Object so downstream `MetadataEq`
1789        // queries against `metadata.persona` etc. continue to match.
1790        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        // A genuine string payload (not JSON-encoded) is kept as-is so the
1806        // caller's intent is not silently mutated.
1807        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        // Arrays survive the same way as objects.
1815        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}