Skip to main content

aimdb_mcp/
server.rs

1//! MCP server implementation
2//!
3//! Handles MCP protocol lifecycle, tool dispatch, and resource management.
4
5use crate::connection::ConnectionPool;
6use crate::error::{McpError, McpResult};
7use crate::protocol::{
8    InitializeParams, InitializeResult, PromptsCapability, PromptsGetParams, PromptsGetResult,
9    PromptsListResult, ResourceReadParams, ResourceReadResult, ResourcesCapability,
10    ResourcesListResult, ServerCapabilities, ServerInfo, Tool, ToolCallParams, ToolCallResult,
11    ToolContent, ToolsCapability, ToolsListResult, MCP_PROTOCOL_VERSION,
12    SUPPORTED_PROTOCOL_VERSIONS,
13};
14use crate::{prompts, resources, tools};
15use serde_json::json;
16use std::sync::Arc;
17use tokio::sync::Mutex;
18use tracing::debug;
19
20/// Tools exposed in public mode (read-only, safe for untrusted clients).
21const PUBLIC_TOOLS: &[&str] = &["discover_instances", "list_records", "get_record"];
22
23/// MCP server state
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ServerState {
26    /// Server created but not initialized
27    Uninitialized,
28    /// Server initialized and ready to handle requests
29    Ready,
30    /// Server closed
31    Closed,
32}
33
34/// MCP server
35pub struct McpServer {
36    state: Arc<Mutex<ServerState>>,
37    connection_pool: ConnectionPool,
38    /// When true, only PUBLIC_TOOLS are advertised and callable.
39    public_mode: bool,
40}
41
42impl McpServer {
43    /// Create a new MCP server
44    pub fn new() -> Self {
45        Self {
46            state: Arc::new(Mutex::new(ServerState::Uninitialized)),
47            connection_pool: ConnectionPool::new(),
48            public_mode: false,
49        }
50    }
51
52    /// Enable public mode: only read-only tools are available.
53    pub fn with_public_mode(mut self, enabled: bool) -> Self {
54        self.public_mode = enabled;
55        self
56    }
57
58    /// Returns true if the server is in public (restricted) mode.
59    pub fn is_public(&self) -> bool {
60        self.public_mode
61    }
62
63    /// Get the connection pool
64    pub fn connection_pool(&self) -> &ConnectionPool {
65        &self.connection_pool
66    }
67
68    /// Get current server state
69    pub async fn state(&self) -> ServerState {
70        *self.state.lock().await
71    }
72
73    /// Check if server is ready
74    pub async fn is_ready(&self) -> bool {
75        self.state().await == ServerState::Ready
76    }
77
78    /// Set server state (internal use)
79    #[allow(dead_code)]
80    pub(crate) async fn set_state(&self, new_state: ServerState) {
81        *self.state.lock().await = new_state;
82    }
83
84    /// Handle MCP initialize request
85    ///
86    /// This is the first method that must be called by the client.
87    /// It negotiates protocol version and capabilities.
88    pub async fn handle_initialize(&self, params: InitializeParams) -> McpResult<InitializeResult> {
89        // Verify protocol version - support multiple versions for compatibility
90        if !SUPPORTED_PROTOCOL_VERSIONS.contains(&params.protocol_version.as_str()) {
91            return Err(McpError::UnsupportedProtocol(params.protocol_version));
92        }
93
94        // Set server to ready state
95        self.set_state(ServerState::Ready).await;
96
97        // Initialize connection pool for tools (if not already done)
98        tools::init_connection_pool(self.connection_pool.clone());
99
100        // Initialize session store for architecture agent
101        crate::architecture::init_session_store();
102
103        // Build server capabilities — in public mode, only tools are available
104        let capabilities = ServerCapabilities {
105            tools: Some(ToolsCapability {
106                list_changed: Some(false),
107            }),
108            resources: if self.public_mode {
109                None
110            } else {
111                Some(ResourcesCapability {
112                    subscribe: Some(false),
113                })
114            },
115            prompts: if self.public_mode {
116                None
117            } else {
118                Some(PromptsCapability {
119                    list_changed: Some(false),
120                })
121            },
122        };
123
124        // Build server info (version from Cargo.toml)
125        let server_info = ServerInfo {
126            name: "aimdb-mcp".to_string(),
127            version: env!("CARGO_PKG_VERSION").to_string(),
128            metadata: Some(json!({
129                "prompts_available": ["schema-help", "troubleshooting"],
130            })),
131        };
132
133        Ok(InitializeResult {
134            protocol_version: MCP_PROTOCOL_VERSION.to_string(),
135            capabilities,
136            server_info,
137        })
138    }
139
140    /// Handle tools/list request
141    ///
142    /// Returns the list of available tools with their schemas.
143    pub async fn handle_tools_list(&self) -> McpResult<ToolsListResult> {
144        if !self.is_ready().await {
145            return Err(McpError::NotInitialized);
146        }
147
148        debug!("📋 Listing available tools");
149
150        let mut tools = vec![
151            Tool {
152                name: "discover_instances".to_string(),
153                description: "Discover all running AimDB instances on the system. Scans /tmp/*.sock and /var/run/aimdb/*.sock for AimDB servers.".to_string(),
154                input_schema: json!({
155                    "type": "object",
156                    "properties": {},
157                    "additionalProperties": false
158                }),
159            },
160            Tool {
161                name: "list_records".to_string(),
162                description: "List all records from a specific AimDB instance. Returns metadata including buffer type, capacity, producer/consumer counts, and timestamps.".to_string(),
163                input_schema: json!({
164                    "type": "object",
165                    "properties": {
166                        "socket_path": {
167                            "type": "string",
168                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
169                        }
170                    },
171                    "required": [],
172                    "additionalProperties": false
173                }),
174            },
175            Tool {
176                name: "get_record".to_string(),
177                description: "Get the current value of a specific record from an AimDB instance. Returns the record's current JSON value.".to_string(),
178                input_schema: json!({
179                    "type": "object",
180                    "properties": {
181                        "socket_path": {
182                            "type": "string",
183                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
184                        },
185                        "record_name": {
186                            "type": "string",
187                            "description": "Name of the record to retrieve (e.g., server::Temperature)"
188                        }
189                    },
190                    "required": ["record_name"],
191                    "additionalProperties": false
192                }),
193            },
194            Tool {
195                name: "set_record".to_string(),
196                description: "Set the value of a writable record in an AimDB instance. Only works for records with write permissions.".to_string(),
197                input_schema: json!({
198                    "type": "object",
199                    "properties": {
200                        "socket_path": {
201                            "type": "string",
202                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
203                        },
204                        "record_name": {
205                            "type": "string",
206                            "description": "Name of the record to update (must be writable)"
207                        },
208                        "value": {
209                            "description": "New value for the record (must match record's type schema)"
210                        }
211                    },
212                    "required": ["record_name", "value"],
213                    "additionalProperties": false
214                }),
215            },
216            Tool {
217                name: "get_instance_info".to_string(),
218                description: "Get detailed information about a specific AimDB instance. Returns server version, protocol, permissions, and capabilities.".to_string(),
219                input_schema: json!({
220                    "type": "object",
221                    "properties": {
222                        "socket_path": {
223                            "type": "string",
224                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
225                        }
226                    },
227                    "required": [],
228                    "additionalProperties": false
229                }),
230            },
231            Tool {
232                name: "query_schema".to_string(),
233                description: "Get JSON schema and type information for a record.\n\n\
234                    Returns the data structure, field types, and metadata.\n\
235                    Use this before setting record values to understand expected format.\n\n\
236                    Schema is inferred from current value + database metadata.\n\n\
237                    💡 TIP: Field names like 'celsius', 'timestamp', 'sensor_id' carry semantic meaning.\n\
238                    If units or formats are unclear, ask the user for clarification.".to_string(),
239                input_schema: json!({
240                    "type": "object",
241                    "properties": {
242                        "socket_path": {
243                            "type": "string",
244                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
245                        },
246                        "record_name": {
247                            "type": "string",
248                            "description": "Name of the record to query schema for (e.g., server::Temperature)"
249                        },
250                        "include_example": {
251                            "type": "boolean",
252                            "description": "Include current value as example (default: true)",
253                            "default": true
254                        }
255                    },
256                    "required": ["record_name"],
257                    "additionalProperties": false
258                }),
259            },
260            Tool {
261                name: "drain_record".to_string(),
262                description: "Drain all pending values from a record since the last drain call. \
263                    Returns values in chronological order. This is a destructive read — \
264                    drained values won't be returned again. Use this for batch analysis \
265                    of accumulated data (e.g., time-series analysis, trend detection). \
266                    The first drain call creates a reader and returns empty (cold start). \
267                    Subsequent calls return all values accumulated since the previous drain.".to_string(),
268                input_schema: json!({
269                    "type": "object",
270                    "properties": {
271                        "socket_path": {
272                            "type": "string",
273                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
274                        },
275                        "record_name": {
276                            "type": "string",
277                            "description": "Name of the record to drain (e.g., temp.berlin)"
278                        },
279                        "limit": {
280                            "type": "integer",
281                            "description": "Maximum number of values to drain. Optional, defaults to all pending.",
282                            "minimum": 1
283                        }
284                    },
285                    "required": ["record_name"],
286                    "additionalProperties": false
287                }),
288            },
289            Tool {
290                name: "graph_nodes".to_string(),
291                description: "Get all nodes in the dependency graph. Returns metadata for all records as graph nodes, including origin (source/link/transform/passive), buffer configuration, and connection counts. Useful for understanding database topology and data flow.".to_string(),
292                input_schema: json!({
293                    "type": "object",
294                    "properties": {
295                        "socket_path": {
296                            "type": "string",
297                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
298                        }
299                    },
300                    "required": [],
301                    "additionalProperties": false
302                }),
303            },
304            Tool {
305                name: "graph_edges".to_string(),
306                description: "Get all edges in the dependency graph. Returns directed edges representing data flow between records. Shows how data flows from sources through transforms to consumers.".to_string(),
307                input_schema: json!({
308                    "type": "object",
309                    "properties": {
310                        "socket_path": {
311                            "type": "string",
312                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
313                        }
314                    },
315                    "required": [],
316                    "additionalProperties": false
317                }),
318            },
319            Tool {
320                name: "graph_topo_order".to_string(),
321                description: "Get the topological ordering of records in the dependency graph. Returns record keys ordered so all dependencies appear before their dependents. Reflects the spawn/initialization order used by AimDB.".to_string(),
322                input_schema: json!({
323                    "type": "object",
324                    "properties": {
325                        "socket_path": {
326                            "type": "string",
327                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
328                        }
329                    },
330                    "required": [],
331                    "additionalProperties": false
332                }),
333            },
334            // ── Architecture agent tools (M11) ─────────────────────────────
335            Tool {
336                name: "get_architecture".to_string(),
337                description: "Return the current architecture state from .aimdb/state.toml as structured JSON, including record count, validation summary, and decision log length. Run this first when entering an architecture session.".to_string(),
338                input_schema: json!({
339                    "type": "object",
340                    "properties": {
341                        "state_path": {
342                            "type": "string",
343                            "description": "Path to state.toml (default: .aimdb/state.toml)"
344                        }
345                    },
346                    "additionalProperties": false
347                }),
348            },
349            Tool {
350                name: "propose_add_record".to_string(),
351                description: "Propose adding a new record to the architecture. All payload fields are explicit and typed — no guessing required. Present the proposal to the user before calling resolve_proposal.".to_string(),
352                input_schema: json!({
353                    "type": "object",
354                    "properties": {
355                        "name": {
356                            "type": "string",
357                            "description": "PascalCase record name, e.g. \"TemperatureReading\""
358                        },
359                        "description": {
360                            "type": "string",
361                            "description": "Human-readable description of the proposal shown to the user"
362                        },
363                        "buffer": {
364                            "type": "string",
365                            "enum": ["SpmcRing", "SingleLatest", "Mailbox"],
366                            "description": "Buffer semantics: SpmcRing=stream (every value), SingleLatest=state (newest only), Mailbox=command (overwrite)"
367                        },
368                        "capacity": {
369                            "type": "integer",
370                            "description": "Ring buffer capacity — required when buffer=SpmcRing. Use power-of-2, e.g. 256, 512, 1024."
371                        },
372                        "key_prefix": {
373                            "type": "string",
374                            "description": "Optional common key prefix, e.g. \"sensors.temp.\". Default: \"\""
375                        },
376                        "key_variants": {
377                            "type": "array",
378                            "items": { "type": "string" },
379                            "description": "Concrete PascalCase variant names, e.g. [\"Default\"] or [\"Indoor\", \"Outdoor\"]. Default: []"
380                        },
381                        "producers": {
382                            "type": "array",
383                            "items": { "type": "string" },
384                            "description": "Task names that write to this record, e.g. [\"sensor_task\"]."
385                        },
386                        "consumers": {
387                            "type": "array",
388                            "items": { "type": "string" },
389                            "description": "Task names that read from this record, e.g. [\"anomaly_detector\"]."
390                        },
391                        "fields": {
392                            "type": "array",
393                            "description": "Value struct fields",
394                            "items": {
395                                "type": "object",
396                                "properties": {
397                                    "name": { "type": "string", "description": "snake_case field name" },
398                                    "type": { "type": "string", "description": "Rust primitive: f64, f32, u8, u16, u32, u64, i8, i16, i32, i64, bool, String" },
399                                    "description": { "type": "string" }
400                                },
401                                "required": ["name", "type", "description"]
402                            }
403                        },
404                        "connectors": {
405                            "type": "array",
406                            "description": "Connector wiring (MQTT, KNX, etc.)",
407                            "items": {
408                                "type": "object",
409                                "properties": {
410                                    "protocol": { "type": "string", "description": "e.g. mqtt, knx" },
411                                    "direction": { "type": "string", "enum": ["inbound", "outbound"] },
412                                    "url": { "type": "string", "description": "Topic/address template; may contain {variant}" }
413                                },
414                                "required": ["protocol", "direction", "url"]
415                            }
416                        }
417                    },
418                    "required": ["name", "description", "buffer"],
419                    "additionalProperties": false
420                }),
421            },
422            Tool {
423                name: "propose_modify_buffer".to_string(),
424                description: "Propose changing the buffer type (and optionally capacity) of an existing record. Present the proposal to the user before calling resolve_proposal.".to_string(),
425                input_schema: json!({
426                    "type": "object",
427                    "properties": {
428                        "record_name": {
429                            "type": "string",
430                            "description": "PascalCase name of the existing record to modify"
431                        },
432                        "description": {
433                            "type": "string",
434                            "description": "Human-readable description of the proposal shown to the user"
435                        },
436                        "buffer": {
437                            "type": "string",
438                            "enum": ["SpmcRing", "SingleLatest", "Mailbox"],
439                            "description": "New buffer type"
440                        },
441                        "capacity": {
442                            "type": "integer",
443                            "description": "Ring capacity — required when buffer=SpmcRing"
444                        }
445                    },
446                    "required": ["record_name", "description", "buffer"],
447                    "additionalProperties": false
448                }),
449            },
450            Tool {
451                name: "propose_add_connector".to_string(),
452                description: "Propose adding a connector (MQTT, KNX, etc.) to an existing record. Present the proposal to the user before calling resolve_proposal.".to_string(),
453                input_schema: json!({
454                    "type": "object",
455                    "properties": {
456                        "record_name": {
457                            "type": "string",
458                            "description": "PascalCase name of the existing record to wire up"
459                        },
460                        "description": {
461                            "type": "string",
462                            "description": "Human-readable description of the proposal shown to the user"
463                        },
464                        "protocol": {
465                            "type": "string",
466                            "description": "Connector protocol identifier, e.g. \"mqtt\" or \"knx\""
467                        },
468                        "direction": {
469                            "type": "string",
470                            "enum": ["inbound", "outbound"],
471                            "description": "inbound = broker→DB, outbound = DB→broker"
472                        },
473                        "url": {
474                            "type": "string",
475                            "description": "Topic or address template; use {variant} placeholder for key variants, e.g. \"sensors/temp/{variant}\""
476                        }
477                    },
478                    "required": ["record_name", "description", "protocol", "direction", "url"],
479                    "additionalProperties": false
480                }),
481            },
482            Tool {
483                name: "propose_modify_fields".to_string(),
484                description: "Propose replacing the value struct fields of an existing record. This replaces ALL fields — include unchanged fields too. Present the proposal to the user before calling resolve_proposal.".to_string(),
485                input_schema: json!({
486                    "type": "object",
487                    "properties": {
488                        "record_name": {
489                            "type": "string",
490                            "description": "PascalCase name of the existing record to modify"
491                        },
492                        "description": {
493                            "type": "string",
494                            "description": "Human-readable description of the proposal shown to the user"
495                        },
496                        "fields": {
497                            "type": "array",
498                            "description": "Complete replacement field list for the value struct",
499                            "items": {
500                                "type": "object",
501                                "properties": {
502                                    "name": { "type": "string", "description": "snake_case field name" },
503                                    "type": { "type": "string", "description": "f64, f32, u8, u16, u32, u64, i8, i16, i32, i64, bool, String" },
504                                    "description": { "type": "string" }
505                                },
506                                "required": ["name", "type", "description"]
507                            }
508                        }
509                    },
510                    "required": ["record_name", "description", "fields"],
511                    "additionalProperties": false
512                }),
513            },
514            Tool {
515                name: "propose_modify_key_variants".to_string(),
516                description: "Propose updating the key variants of an existing record. Use this when adding a record with no variants (e.g. [\"Default\"]) or expanding a fleet (e.g. adding a new device). Present the proposal to the user before calling resolve_proposal.".to_string(),
517                input_schema: json!({
518                    "type": "object",
519                    "properties": {
520                        "record_name": {
521                            "type": "string",
522                            "description": "PascalCase name of the existing record to modify"
523                        },
524                        "description": {
525                            "type": "string",
526                            "description": "Human-readable description of the proposal shown to the user"
527                        },
528                        "key_variants": {
529                            "type": "array",
530                            "items": { "type": "string" },
531                            "description": "Complete replacement list of PascalCase variant names, e.g. [\"Default\"] or [\"ApiServer\", \"Worker\", \"Db\"]. Replaces prior variant list."
532                        },
533                        "key_prefix": {
534                            "type": "string",
535                            "description": "Optional common key prefix. If omitted the existing prefix is preserved."
536                        }
537                    },
538                    "required": ["record_name", "description", "key_variants"],
539                    "additionalProperties": false
540                }),
541            },
542            Tool {
543                name: "propose_add_task".to_string(),
544                description: "Propose adding a new task definition. Tasks are async functions that produce, transform, or consume record data. Present the proposal to the user before calling resolve_proposal.".to_string(),
545                input_schema: json!({
546                    "type": "object",
547                    "properties": {
548                        "name": {
549                            "type": "string",
550                            "description": "snake_case task function name, e.g. \"sensor_polling_task\""
551                        },
552                        "description": {
553                            "type": "string",
554                            "description": "Human-readable description of the proposal shown to the user"
555                        },
556                        "task_type": {
557                            "type": "string",
558                            "enum": ["transform", "agent", "source", "tap"],
559                            "description": "Functional role: source (autonomous producer writing to a record), transform (reactive derivation from input records to output record), tap (read-only observer, no output records), agent (LLM reasoning loop). Default: transform"
560                        },
561                        "inputs": {
562                            "type": "array",
563                            "items": {
564                                "type": "object",
565                                "properties": {
566                                    "record": { "type": "string", "description": "PascalCase record name to read from" },
567                                    "variants": { "type": "array", "items": { "type": "string" }, "description": "Specific variants to consume (empty = all)" }
568                                },
569                                "required": ["record"]
570                            },
571                            "description": "Records this task reads from"
572                        },
573                        "outputs": {
574                            "type": "array",
575                            "items": {
576                                "type": "object",
577                                "properties": {
578                                    "record": { "type": "string", "description": "PascalCase record name to write to" },
579                                    "variants": { "type": "array", "items": { "type": "string" }, "description": "Specific variants to produce (empty = all)" }
580                                },
581                                "required": ["record"]
582                            },
583                            "description": "Records this task writes to"
584                        }
585                    },
586                    "required": ["name", "description"],
587                    "additionalProperties": false
588                }),
589            },
590            Tool {
591                name: "propose_add_binary".to_string(),
592                description: "Propose adding a new binary definition. Binaries are deployable crates that group tasks together and optionally declare external broker connections. Present the proposal to the user before calling resolve_proposal.".to_string(),
593                input_schema: json!({
594                    "type": "object",
595                    "properties": {
596                        "name": {
597                            "type": "string",
598                            "description": "Crate directory name, e.g. \"weather-sentinel-hub\""
599                        },
600                        "description": {
601                            "type": "string",
602                            "description": "Human-readable description of the proposal shown to the user"
603                        },
604                        "tasks": {
605                            "type": "array",
606                            "items": { "type": "string" },
607                            "description": "Task names belonging to this binary (must match [[tasks]] entries)"
608                        },
609                        "external_connectors": {
610                            "type": "array",
611                            "items": {
612                                "type": "object",
613                                "properties": {
614                                    "protocol": { "type": "string", "description": "Protocol identifier, e.g. \"mqtt\"" },
615                                    "env_var": { "type": "string", "description": "Environment variable for the broker URL" },
616                                    "default": { "type": "string", "description": "Default URL when env var is not set" }
617                                },
618                                "required": ["protocol", "env_var"]
619                            },
620                            "description": "Runtime broker connections needed by this binary"
621                        }
622                    },
623                    "required": ["name", "description"],
624                    "additionalProperties": false
625                }),
626            },
627            Tool {
628                name: "remove_task".to_string(),
629                description: "Propose removal of an existing task. Creates a pending proposal — call resolve_proposal to confirm. Note: removing a task affects binaries that reference it.".to_string(),
630                input_schema: json!({
631                    "type": "object",
632                    "properties": {
633                        "task_name": {
634                            "type": "string",
635                            "description": "snake_case name of the task to remove"
636                        }
637                    },
638                    "required": ["task_name"],
639                    "additionalProperties": false
640                }),
641            },
642            Tool {
643                name: "remove_binary".to_string(),
644                description: "Propose removal of an existing binary. Creates a pending proposal — call resolve_proposal to confirm. Task definitions are preserved; only the binary grouping is removed.".to_string(),
645                input_schema: json!({
646                    "type": "object",
647                    "properties": {
648                        "binary_name": {
649                            "type": "string",
650                            "description": "Name of the binary crate to remove"
651                        }
652                    },
653                    "required": ["binary_name"],
654                    "additionalProperties": false
655                }),
656            },
657            Tool {
658                name: "resolve_proposal".to_string(),
659                description: "Resolve a pending proposal. On confirm: applies the change, writes state.toml, generates Mermaid and Rust artefacts. On reject: discards without changes. On revise: discards with a redirect message.".to_string(),
660                input_schema: json!({
661                    "type": "object",
662                    "properties": {
663                        "proposal_id": {
664                            "type": "string",
665                            "description": "The proposal ID returned by any propose_* tool, remove_record, rename_record, remove_task, or remove_binary"
666                        },
667                        "resolution": {
668                            "type": "string",
669                            "enum": ["confirm", "reject", "revise"],
670                            "description": "User decision: confirm applies the change, reject discards it, revise returns a redirect"
671                        },
672                        "redirect": {
673                            "type": "string",
674                            "description": "Message explaining what to revise (only used when resolution=revise)"
675                        },
676                        "state_path": { "type": "string", "description": "Override state.toml path" },
677                        "mermaid_path": { "type": "string", "description": "Override Mermaid output path" },
678                        "rust_path": { "type": "string", "description": "Override Rust output path" }
679                    },
680                    "required": ["proposal_id", "resolution"],
681                    "additionalProperties": false
682                }),
683            },
684            Tool {
685                name: "remove_record".to_string(),
686                description: "Propose removal of an existing record. Creates a pending proposal — call resolve_proposal to confirm. Note: removing a record breaks generated type aliases.".to_string(),
687                input_schema: json!({
688                    "type": "object",
689                    "properties": {
690                        "record_name": {
691                            "type": "string",
692                            "description": "PascalCase name of the record to remove"
693                        }
694                    },
695                    "required": ["record_name"],
696                    "additionalProperties": false
697                }),
698            },
699            Tool {
700                name: "rename_record".to_string(),
701                description: "Propose renaming a record. Creates a pending proposal — call resolve_proposal to confirm. Note: renames the generated key enum and value struct, breaking existing references.".to_string(),
702                input_schema: json!({
703                    "type": "object",
704                    "properties": {
705                        "old_name": {
706                            "type": "string",
707                            "description": "Current PascalCase record name"
708                        },
709                        "new_name": {
710                            "type": "string",
711                            "description": "New PascalCase record name"
712                        }
713                    },
714                    "required": ["old_name", "new_name"],
715                    "additionalProperties": false
716                }),
717            },
718            Tool {
719                name: "validate_against_instance".to_string(),
720                description: "Compare state.toml against a live AimDB instance and return a conflict report. Detects missing records, buffer type mismatches, capacity differences, and connector mismatches.".to_string(),
721                input_schema: json!({
722                    "type": "object",
723                    "properties": {
724                        "socket_path": {
725                            "type": "string",
726                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
727                        },
728                        "state_path": {
729                            "type": "string",
730                            "description": "Path to state.toml (default: .aimdb/state.toml)"
731                        }
732                    },
733                    "required": [],
734                    "additionalProperties": false
735                }),
736            },
737            Tool {
738                name: "get_buffer_metrics".to_string(),
739                description: "Get live buffer metrics for records matching a key string from a running AimDB instance.".to_string(),
740                input_schema: json!({
741                    "type": "object",
742                    "properties": {
743                        "socket_path": {
744                            "type": "string",
745                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
746                        },
747                        "record_key": {
748                            "type": "string",
749                            "description": "Substring to match against record names (e.g., 'Temperature')"
750                        }
751                    },
752                    "required": ["record_key"],
753                    "additionalProperties": false
754                }),
755            },
756            Tool {
757                name: "get_stage_profiling".to_string(),
758                description: "Get automatic stage profiling (per-`.source()`/`.tap()`/`.link()` callback wall-clock timing) for records matching a key from a running AimDB instance, including the slowest stage ('bottleneck'). Requires the instance to be built with the `profiling` feature.".to_string(),
759                input_schema: json!({
760                    "type": "object",
761                    "properties": {
762                        "socket_path": {
763                            "type": "string",
764                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
765                        },
766                        "record_key": {
767                            "type": "string",
768                            "description": "Substring to match against record names/keys (e.g., 'Temperature')"
769                        }
770                    },
771                    "required": ["record_key"],
772                    "additionalProperties": false
773                }),
774            },
775            Tool {
776                name: "reset_stage_profiling".to_string(),
777                description: "Reset stage profiling counters for every record on a running AimDB instance (requires write permission and the `profiling` feature).".to_string(),
778                input_schema: json!({
779                    "type": "object",
780                    "properties": {
781                        "socket_path": {
782                            "type": "string",
783                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
784                        }
785                    },
786                    "additionalProperties": false
787                }),
788            },
789            Tool {
790                name: "reset_buffer_metrics".to_string(),
791                description: "Reset buffer introspection counters (produced/consumed/dropped/occupancy) for every record on a running AimDB instance (requires write permission and the `metrics` feature).".to_string(),
792                input_schema: json!({
793                    "type": "object",
794                    "properties": {
795                        "socket_path": {
796                            "type": "string",
797                            "description": "Unix socket path to the AimDB instance. Falls back to AIMDB_SOCKET env var if omitted."
798                        }
799                    },
800                    "additionalProperties": false
801                }),
802            },
803            Tool {
804                name: "save_memory".to_string(),
805                description: "Persist ideation context and design rationale to .aimdb/memory.md. \
806                    Call this after every confirmed proposal with a narrative summary of what the user is building, \
807                    the key question asked, the answer received, why the chosen buffer type fits, \
808                    alternatives that were considered and rejected, and any future considerations noted. \
809                    On session start, read aimdb://architecture/memory to restore this context.".to_string(),
810                input_schema: json!({
811                    "type": "object",
812                    "properties": {
813                        "entry": {
814                            "type": "string",
815                            "description": "Markdown text to write. For append mode, structure as a '## RecordName' section with sub-headings: Context, Key question, Answer, Buffer choice & rationale, Alternatives considered, Future considerations."
816                        },
817                        "mode": {
818                            "type": "string",
819                            "enum": ["append", "overwrite"],
820                            "description": "append (default): add a timestamped section to memory.md. overwrite: replace the entire file (use only to correct the whole document)."
821                        },
822                        "memory_path": {
823                            "type": "string",
824                            "description": "Override path (default: .aimdb/memory.md)"
825                        }
826                    },
827                    "required": ["entry"],
828                    "additionalProperties": false
829                }),
830            },
831            Tool {
832                name: "reset_session".to_string(),
833                description: "Reset the architecture agent session, discarding any pending proposals. Use when the user wants to start over or abandon the current ideation cycle.".to_string(),
834                input_schema: json!({
835                    "type": "object",
836                    "properties": {},
837                    "additionalProperties": false
838                }),
839            },
840        ];
841
842        // In public mode, only expose the allowlisted tools
843        if self.public_mode {
844            tools.retain(|t| PUBLIC_TOOLS.contains(&t.name.as_str()));
845        }
846
847        Ok(ToolsListResult { tools })
848    }
849
850    /// Handle tools/call request
851    ///
852    /// Dispatches tool calls to the appropriate handler.
853    pub async fn handle_tools_call(&self, params: ToolCallParams) -> McpResult<ToolCallResult> {
854        if !self.is_ready().await {
855            return Err(McpError::NotInitialized);
856        }
857
858        debug!("🛠️  Calling tool: {}", params.name);
859
860        // Reject non-public tools in public mode (defense in depth)
861        if self.public_mode && !PUBLIC_TOOLS.contains(&params.name.as_str()) {
862            return Err(McpError::MethodNotFound(format!(
863                "Unknown tool: {}",
864                params.name
865            )));
866        }
867
868        // In public mode, strip any client-supplied socket_path so
869        // resolve_socket_path falls back to the server-pinned --socket flag
870        // or the AIMDB_SOCKET env var (never a client-chosen path).
871        // This prevents clients from probing arbitrary Unix sockets on the host.
872        let arguments = if self.public_mode {
873            params.arguments.map(|mut v| {
874                if let Some(obj) = v.as_object_mut() {
875                    obj.remove("socket_path");
876                }
877                v
878            })
879        } else {
880            params.arguments
881        };
882        let params = ToolCallParams {
883            name: params.name,
884            arguments,
885        };
886
887        let result = match params.name.as_str() {
888            "discover_instances" => tools::discover_instances(params.arguments).await?,
889            "list_records" => tools::list_records(params.arguments).await?,
890            "get_record" => tools::get_record(params.arguments).await?,
891            "set_record" => tools::set_record(params.arguments).await?,
892            "get_instance_info" => tools::get_instance_info(params.arguments).await?,
893            "query_schema" => tools::query_schema(params.arguments).await?,
894            "drain_record" => tools::drain_record(params.arguments).await?,
895            "graph_nodes" => tools::graph_nodes(params.arguments).await?,
896            "graph_edges" => tools::graph_edges(params.arguments).await?,
897            "graph_topo_order" => tools::graph_topo_order(params.arguments).await?,
898            // Architecture agent tools (M11)
899            "get_architecture" => tools::get_architecture(params.arguments).await?,
900            "propose_add_record" => tools::propose_add_record(params.arguments).await?,
901            "propose_modify_buffer" => tools::propose_modify_buffer(params.arguments).await?,
902            "propose_add_connector" => tools::propose_add_connector(params.arguments).await?,
903            "propose_modify_fields" => tools::propose_modify_fields(params.arguments).await?,
904            "propose_modify_key_variants" => {
905                tools::propose_modify_key_variants(params.arguments).await?
906            }
907            "propose_add_task" => tools::propose_add_task(params.arguments).await?,
908            "propose_add_binary" => tools::propose_add_binary(params.arguments).await?,
909            "resolve_proposal" => tools::resolve_proposal(params.arguments).await?,
910            "remove_record" => tools::remove_record(params.arguments).await?,
911            "rename_record" => tools::rename_record(params.arguments).await?,
912            "remove_task" => tools::remove_task(params.arguments).await?,
913            "remove_binary" => tools::remove_binary(params.arguments).await?,
914            "validate_against_instance" => {
915                tools::validate_against_instance(params.arguments).await?
916            }
917            "get_buffer_metrics" => tools::get_buffer_metrics(params.arguments).await?,
918            "reset_buffer_metrics" => tools::reset_buffer_metrics(params.arguments).await?,
919            "get_stage_profiling" => tools::get_stage_profiling(params.arguments).await?,
920            "reset_stage_profiling" => tools::reset_stage_profiling(params.arguments).await?,
921            "save_memory" => tools::save_memory(params.arguments).await?,
922            "reset_session" => tools::reset_session(params.arguments).await?,
923            _ => {
924                return Err(McpError::MethodNotFound(format!(
925                    "Unknown tool: {}",
926                    params.name
927                )));
928            }
929        };
930
931        // Wrap result in ToolCallResult
932        let content = vec![ToolContent::Text {
933            text: serde_json::to_string_pretty(&result)?,
934        }];
935
936        Ok(ToolCallResult {
937            content,
938            is_error: Some(false),
939        })
940    }
941
942    /// Handle resources/list request
943    ///
944    /// Returns the list of available resources.
945    pub async fn handle_resources_list(&self) -> McpResult<ResourcesListResult> {
946        if !self.is_ready().await {
947            return Err(McpError::NotInitialized);
948        }
949        if self.public_mode {
950            return Err(McpError::MethodNotFound("resources/list".to_string()));
951        }
952
953        debug!("📋 Handling resources/list");
954        resources::list_resources().await
955    }
956
957    /// Handle resources/read request
958    ///
959    /// Reads the content of a specific resource by URI.
960    pub async fn handle_resources_read(
961        &self,
962        params: ResourceReadParams,
963    ) -> McpResult<ResourceReadResult> {
964        if !self.is_ready().await {
965            return Err(McpError::NotInitialized);
966        }
967        if self.public_mode {
968            return Err(McpError::MethodNotFound("resources/read".to_string()));
969        }
970
971        debug!("📖 Handling resources/read: {}", params.uri);
972        resources::read_resource(&params.uri).await
973    }
974
975    /// Handle prompts/list request
976    ///
977    /// Returns the list of available prompts.
978    pub async fn handle_prompts_list(&self) -> McpResult<PromptsListResult> {
979        if !self.is_ready().await {
980            return Err(McpError::NotInitialized);
981        }
982        if self.public_mode {
983            return Err(McpError::MethodNotFound("prompts/list".to_string()));
984        }
985
986        debug!("📋 Listing available prompts");
987
988        let prompts = prompts::list_prompts();
989
990        Ok(PromptsListResult { prompts })
991    }
992
993    /// Handle prompts/get request
994    ///
995    /// Returns a specific prompt with its messages.
996    pub async fn handle_prompts_get(
997        &self,
998        params: PromptsGetParams,
999    ) -> McpResult<PromptsGetResult> {
1000        if !self.is_ready().await {
1001            return Err(McpError::NotInitialized);
1002        }
1003        if self.public_mode {
1004            return Err(McpError::MethodNotFound("prompts/get".to_string()));
1005        }
1006
1007        debug!("📝 Getting prompt: {}", params.name);
1008
1009        let messages = prompts::get_prompt(&params.name)
1010            .ok_or_else(|| McpError::InvalidParams(format!("Unknown prompt: {}", params.name)))?;
1011
1012        Ok(PromptsGetResult {
1013            description: Some(format!("Prompt: {}", params.name)),
1014            messages,
1015        })
1016    }
1017}
1018
1019impl Default for McpServer {
1020    fn default() -> Self {
1021        Self::new()
1022    }
1023}
1024
1025#[cfg(test)]
1026mod tests {
1027    use super::*;
1028
1029    #[test]
1030    fn public_tools_allowlist_is_valid() {
1031        // Ensure the allowlist only contains tool names that actually exist in the
1032        // dispatch table, catching typos when tools are renamed. This list is a
1033        // snapshot — keep it in sync with the match arms in handle_tools_call().
1034        let known_tools = [
1035            "discover_instances",
1036            "list_records",
1037            "get_record",
1038            "set_record",
1039            "get_instance_info",
1040            "query_schema",
1041            "drain_record",
1042            "graph_nodes",
1043            "graph_edges",
1044            "graph_topo_order",
1045            "get_architecture",
1046            "propose_add_record",
1047            "propose_modify_buffer",
1048            "propose_add_connector",
1049            "propose_modify_fields",
1050            "propose_modify_key_variants",
1051            "propose_add_task",
1052            "propose_add_binary",
1053            "resolve_proposal",
1054            "remove_record",
1055            "rename_record",
1056            "remove_task",
1057            "remove_binary",
1058            "validate_against_instance",
1059            "get_buffer_metrics",
1060            "save_memory",
1061            "reset_session",
1062        ];
1063        for tool in PUBLIC_TOOLS {
1064            assert!(
1065                known_tools.contains(tool),
1066                "PUBLIC_TOOLS contains unknown tool: {tool}"
1067            );
1068        }
1069    }
1070
1071    #[test]
1072    fn public_mode_defaults_to_off() {
1073        let server = McpServer::new();
1074        assert!(!server.is_public());
1075    }
1076
1077    #[test]
1078    fn public_mode_can_be_enabled() {
1079        let server = McpServer::new().with_public_mode(true);
1080        assert!(server.is_public());
1081    }
1082
1083    #[tokio::test]
1084    async fn public_mode_filters_tools_list() {
1085        let server = McpServer::new().with_public_mode(true);
1086        server.set_state(ServerState::Ready).await;
1087        let result = server.handle_tools_list().await.unwrap();
1088        let names: Vec<&str> = result.tools.iter().map(|t| t.name.as_str()).collect();
1089        assert_eq!(names, PUBLIC_TOOLS);
1090    }
1091
1092    #[tokio::test]
1093    async fn public_mode_rejects_non_public_tool() {
1094        let server = McpServer::new().with_public_mode(true);
1095        server.set_state(ServerState::Ready).await;
1096        let params = ToolCallParams {
1097            name: "set_record".to_string(),
1098            arguments: None,
1099        };
1100        let err = server.handle_tools_call(params).await.unwrap_err();
1101        assert!(matches!(err, McpError::MethodNotFound(_)));
1102    }
1103
1104    // Helper: assert that an explicit socket_path is stripped in public mode.
1105    // The stripping is confirmed by getting InvalidParams (no socket configured)
1106    // rather than a connection error to the attacker-supplied path.
1107    async fn assert_socket_path_stripped(tool: &str) {
1108        // Clear env so it doesn't interfere with the expected InvalidParams result.
1109        std::env::remove_var("AIMDB_SOCKET");
1110
1111        let server = McpServer::new().with_public_mode(true);
1112        server.set_state(ServerState::Ready).await;
1113        let params = ToolCallParams {
1114            name: tool.to_string(),
1115            arguments: Some(json!({ "socket_path": "/tmp/evil.sock" })),
1116        };
1117        let err = server.handle_tools_call(params).await.unwrap_err();
1118        assert!(
1119            matches!(err, McpError::InvalidParams(_)),
1120            "expected InvalidParams for {tool}, got: {err:?}"
1121        );
1122    }
1123
1124    #[tokio::test]
1125    async fn public_mode_strips_socket_path_list_records() {
1126        assert_socket_path_stripped("list_records").await;
1127    }
1128
1129    #[tokio::test]
1130    async fn public_mode_strips_socket_path_get_record() {
1131        assert_socket_path_stripped("get_record").await;
1132    }
1133
1134    #[tokio::test]
1135    async fn public_mode_strips_socket_path_discover_instances() {
1136        // discover_instances scans the filesystem directly — it doesn't call
1137        // resolve_socket_path, so stripping socket_path doesn't cause InvalidParams.
1138        // The expected outcome is that the tool runs normally (no instances found in
1139        // the test environment), confirming the evil socket_path was not connected to.
1140        let server = McpServer::new().with_public_mode(true);
1141        server.set_state(ServerState::Ready).await;
1142        let params = ToolCallParams {
1143            name: "discover_instances".to_string(),
1144            arguments: Some(json!({ "socket_path": "/tmp/evil.sock" })),
1145        };
1146        let result = server.handle_tools_call(params).await;
1147        // The tool is allowed (not MethodNotFound) and does not attempt to connect
1148        // to the evil socket. Either Ok or a no-instances error are both acceptable.
1149        assert!(
1150            !matches!(result, Err(McpError::MethodNotFound(_))),
1151            "discover_instances should not be blocked in public mode"
1152        );
1153    }
1154
1155    #[tokio::test]
1156    async fn normal_mode_lists_all_tools() {
1157        let server = McpServer::new();
1158        server.set_state(ServerState::Ready).await;
1159        let result = server.handle_tools_list().await.unwrap();
1160        assert!(result.tools.len() > PUBLIC_TOOLS.len());
1161    }
1162
1163    #[tokio::test]
1164    async fn public_mode_suppresses_resources_and_prompts() {
1165        let server = McpServer::new().with_public_mode(true);
1166        let params = InitializeParams {
1167            protocol_version: MCP_PROTOCOL_VERSION.to_string(),
1168            capabilities: crate::protocol::ClientCapabilities { sampling: None },
1169            client_info: crate::protocol::ClientInfo {
1170                name: "test".to_string(),
1171                version: "0.1".to_string(),
1172            },
1173        };
1174        let result = server.handle_initialize(params).await.unwrap();
1175        assert!(result.capabilities.tools.is_some());
1176        assert!(result.capabilities.resources.is_none());
1177        assert!(result.capabilities.prompts.is_none());
1178    }
1179}