1use 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
20const PUBLIC_TOOLS: &[&str] = &["discover_instances", "list_records", "get_record"];
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum ServerState {
26 Uninitialized,
28 Ready,
30 Closed,
32}
33
34pub struct McpServer {
36 state: Arc<Mutex<ServerState>>,
37 connection_pool: ConnectionPool,
38 public_mode: bool,
40}
41
42impl McpServer {
43 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 pub fn with_public_mode(mut self, enabled: bool) -> Self {
54 self.public_mode = enabled;
55 self
56 }
57
58 pub fn is_public(&self) -> bool {
60 self.public_mode
61 }
62
63 pub fn connection_pool(&self) -> &ConnectionPool {
65 &self.connection_pool
66 }
67
68 pub async fn state(&self) -> ServerState {
70 *self.state.lock().await
71 }
72
73 pub async fn is_ready(&self) -> bool {
75 self.state().await == ServerState::Ready
76 }
77
78 #[allow(dead_code)]
80 pub(crate) async fn set_state(&self, new_state: ServerState) {
81 *self.state.lock().await = new_state;
82 }
83
84 pub async fn handle_initialize(&self, params: InitializeParams) -> McpResult<InitializeResult> {
89 if !SUPPORTED_PROTOCOL_VERSIONS.contains(¶ms.protocol_version.as_str()) {
91 return Err(McpError::UnsupportedProtocol(params.protocol_version));
92 }
93
94 self.set_state(ServerState::Ready).await;
96
97 tools::init_connection_pool(self.connection_pool.clone());
99
100 crate::architecture::init_session_store();
102
103 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 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 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 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 if self.public_mode {
844 tools.retain(|t| PUBLIC_TOOLS.contains(&t.name.as_str()));
845 }
846
847 Ok(ToolsListResult { tools })
848 }
849
850 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 if self.public_mode && !PUBLIC_TOOLS.contains(¶ms.name.as_str()) {
862 return Err(McpError::MethodNotFound(format!(
863 "Unknown tool: {}",
864 params.name
865 )));
866 }
867
868 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 "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 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 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 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(¶ms.uri).await
973 }
974
975 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 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(¶ms.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 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 async fn assert_socket_path_stripped(tool: &str) {
1108 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 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 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}