1use std::collections::HashMap;
11
12use base64::{Engine as _, engine::general_purpose::STANDARD as B64};
13use nexql_index::{IndexOverrides, IndexStore, JoinEdge, JoinGraph, ObjectEntry};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16use thiserror::Error;
17
18pub const PAGE_SIZE: usize = 200;
20
21pub const ERR_INVALID_PARAMS: i32 = -32602;
23pub const ERR_RESOURCE_NOT_FOUND: i32 = -32002;
24
25#[derive(Debug, Error)]
26pub enum ResourceError {
27 #[error("{0}")]
28 InvalidParams(String),
29 #[error("{0}")]
30 NotFound(String),
31 #[error("{0}")]
32 Internal(String),
33}
34
35impl ResourceError {
36 pub fn code(&self) -> i32 {
37 match self {
38 Self::InvalidParams(_) => ERR_INVALID_PARAMS,
39 Self::NotFound(_) => ERR_RESOURCE_NOT_FOUND,
40 Self::Internal(_) => -32603,
41 }
42 }
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct McpResource {
47 pub uri: String,
48 pub name: String,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub description: Option<String>,
51 #[serde(rename = "mimeType")]
52 pub mime_type: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct ResourceListResult {
57 pub resources: Vec<McpResource>,
58 #[serde(rename = "nextCursor", skip_serializing_if = "Option::is_none")]
59 pub next_cursor: Option<String>,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ResourceContents {
64 pub uri: String,
65 #[serde(rename = "mimeType")]
66 pub mime_type: String,
67 pub text: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ResourceReadResult {
72 pub contents: Vec<ResourceContents>,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct ResourceTemplate {
77 #[serde(rename = "uriTemplate")]
78 pub uri_template: String,
79 pub name: String,
80 pub description: String,
81 #[serde(rename = "mimeType")]
82 pub mime_type: String,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86#[serde(rename_all = "camelCase")]
87struct CursorState {
88 db: usize,
90 offset: usize,
92}
93
94#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum ResourceKind {
96 Manifest,
97 JoinGraph,
98 Object { schema: String, name: String },
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct ParsedUri {
103 pub connection_id: String,
104 pub database: String,
105 pub kind: ResourceKind,
106}
107
108pub struct ResourceProvider {
110 store: IndexStore,
111}
112
113impl ResourceProvider {
114 pub fn new(store: IndexStore) -> Self {
115 Self { store }
116 }
117
118 pub fn store(&self) -> &IndexStore {
119 &self.store
120 }
121
122 pub fn list(&self, cursor: Option<&str>) -> Result<ResourceListResult, ResourceError> {
123 let state = decode_cursor(cursor)?;
124 let databases = self
125 .store
126 .list_indexed_databases()
127 .map_err(|e| ResourceError::Internal(e.to_string()))?;
128
129 let mut resources = Vec::new();
130 let mut db_index = state.0;
131 let mut offset = state.1;
132
133 while db_index < databases.len() {
134 let (connection_id, database) = &databases[db_index];
135 let all = self.list_for_database(connection_id, database)?;
136 let take = PAGE_SIZE.saturating_sub(resources.len());
137 let end = (offset + take).min(all.len());
138 let page = &all[offset.min(all.len())..end];
139 resources.extend_from_slice(page);
140
141 if end < all.len() {
142 return Ok(ResourceListResult {
143 resources,
144 next_cursor: Some(encode_cursor(&CursorState {
145 db: db_index,
146 offset: end,
147 })),
148 });
149 }
150 if resources.len() >= PAGE_SIZE && db_index + 1 < databases.len() {
151 return Ok(ResourceListResult {
152 resources,
153 next_cursor: Some(encode_cursor(&CursorState {
154 db: db_index + 1,
155 offset: 0,
156 })),
157 });
158 }
159 db_index += 1;
160 offset = 0;
161 }
162
163 Ok(ResourceListResult {
164 resources,
165 next_cursor: None,
166 })
167 }
168
169 pub fn read(&self, uri: &str) -> Result<ResourceReadResult, ResourceError> {
170 let parsed = parse_uri(uri)?;
171 let base = self.store.base_dir(&parsed.connection_id, &parsed.database);
172 let manifest = self
173 .store
174 .read_manifest(&base)
175 .map_err(|e| ResourceError::Internal(e.to_string()))?
176 .ok_or_else(|| {
177 ResourceError::NotFound(format!(
178 "Resource not found: no index for {}/{}",
179 parsed.connection_id, parsed.database
180 ))
181 })?;
182
183 let payload: Value = match &parsed.kind {
184 ResourceKind::Manifest => serde_json::to_value(&manifest)
185 .map_err(|e| ResourceError::Internal(e.to_string()))?,
186 ResourceKind::JoinGraph => {
187 let graph = self
188 .store
189 .read_join_graph(&base, &manifest)
190 .map_err(|e| ResourceError::Internal(e.to_string()))?
191 .ok_or_else(|| {
192 ResourceError::NotFound(format!(
193 "Resource not found: join graph missing for {}",
194 parsed.database
195 ))
196 })?;
197 let overrides = self
198 .store
199 .read_overrides(&base)
200 .map_err(|e| ResourceError::Internal(e.to_string()))?;
201 let merged = merge_join_graph(graph, overrides.as_ref());
202 serde_json::to_value(&merged).map_err(|e| ResourceError::Internal(e.to_string()))?
203 }
204 ResourceKind::Object { schema, name } => {
205 let entry = self
206 .store
207 .get_object_entry(&base, &manifest, schema, name)
208 .map_err(|e| ResourceError::Internal(e.to_string()))?;
209 match entry {
210 Some(e) if e.excluded != Some(true) => serde_json::to_value(&e)
211 .map_err(|e| ResourceError::Internal(e.to_string()))?,
212 _ => {
213 return Err(ResourceError::NotFound(format!(
214 "Resource not found: {uri}"
215 )));
216 }
217 }
218 }
219 };
220
221 let text = serde_json::to_string_pretty(&payload)
222 .map_err(|e| ResourceError::Internal(e.to_string()))?;
223 Ok(ResourceReadResult {
224 contents: vec![ResourceContents {
225 uri: uri.to_owned(),
226 mime_type: "application/json".into(),
227 text,
228 }],
229 })
230 }
231
232 pub fn list_templates(&self) -> Vec<ResourceTemplate> {
233 vec![ResourceTemplate {
234 uri_template: "nexql://{connectionId}/{database}/object/{schema}/{name}".into(),
235 name: "Database object".into(),
236 description:
237 "Structural card for an indexed table, view, materialized view, or function \
238(columns, keys, indexes, definition). Use the search_schema tool to discover refs."
239 .into(),
240 mime_type: "application/json".into(),
241 }]
242 }
243
244 fn list_for_database(
245 &self,
246 connection_id: &str,
247 database: &str,
248 ) -> Result<Vec<McpResource>, ResourceError> {
249 let base = self.store.base_dir(connection_id, database);
250 let Some(manifest) = self
251 .store
252 .read_manifest(&base)
253 .map_err(|e| ResourceError::Internal(e.to_string()))?
254 else {
255 return Ok(Vec::new());
256 };
257 let overrides = self
258 .store
259 .read_overrides(&base)
260 .map_err(|e| ResourceError::Internal(e.to_string()))?;
261
262 let prefix = format!(
263 "nexql://{}/{}",
264 encode_uri_component(connection_id),
265 encode_uri_component(database)
266 );
267
268 let mut resources = vec![
269 McpResource {
270 uri: format!("{prefix}/manifest"),
271 name: format!("{database} index manifest"),
272 description: Some(format!(
273 "Index metadata for {database} (schemas, counts, fingerprint, build time)."
274 )),
275 mime_type: "application/json".into(),
276 },
277 McpResource {
278 uri: format!("{prefix}/joingraph"),
279 name: format!("{database} join graph"),
280 description: Some(format!(
281 "Declared and inferred foreign-key relationships between tables in {database}."
282 )),
283 mime_type: "application/json".into(),
284 },
285 ];
286
287 let mut objects: Vec<(String, ObjectEntry)> = Vec::new();
288 for shard in &manifest.shards {
289 let Some(entries) = self
290 .store
291 .read_shard_entries(&base, &shard.file)
292 .map_err(|e| ResourceError::Internal(e.to_string()))?
293 else {
294 continue;
295 };
296 for (ref_, entry) in entries {
297 if entry.excluded == Some(true) || object_excluded(overrides.as_ref(), &ref_) {
298 continue;
299 }
300 objects.push((ref_, entry));
301 }
302 }
303 objects.sort_by(|a, b| a.0.cmp(&b.0));
305
306 for (ref_, entry) in objects {
307 let (schema, name) = split_ref(&ref_);
308 let description = match &entry.comment {
309 Some(c) if !c.is_empty() => format!("{} — {c}", entry.kind.as_str()),
310 _ => entry.kind.as_str().to_owned(),
311 };
312 resources.push(McpResource {
313 uri: format!(
314 "{prefix}/object/{}/{}",
315 encode_uri_component(&schema),
316 encode_uri_component(&name)
317 ),
318 name: ref_,
319 description: Some(description),
320 mime_type: "application/json".into(),
321 });
322 }
323
324 Ok(resources)
325 }
326}
327
328fn object_excluded(overrides: Option<&IndexOverrides>, ref_: &str) -> bool {
329 overrides
330 .and_then(|o| o.objects.as_ref())
331 .and_then(|objs| objs.get(ref_))
332 .and_then(|obj| obj.excluded)
333 == Some(true)
334}
335
336fn split_ref(ref_: &str) -> (String, String) {
337 match ref_.split_once('.') {
338 Some((schema, name)) => (schema.to_owned(), name.to_owned()),
339 None => ("public".to_owned(), ref_.to_owned()),
340 }
341}
342
343fn merge_join_graph(mut graph: JoinGraph, overrides: Option<&IndexOverrides>) -> JoinGraph {
345 let Some(joins) = overrides.and_then(|o| o.joins.as_ref()) else {
346 return graph;
347 };
348 if joins.is_empty() {
349 return graph;
350 }
351
352 let mut override_map: HashMap<String, JoinEdge> = HashMap::new();
353 for edge in joins {
354 let key = format!("{}->{}:{}", edge.from, edge.to, edge.via);
355 override_map.insert(key, edge.clone());
356 }
357
358 let mut merged = Vec::new();
359 for base in graph.edges.drain(..) {
360 let key = format!("{}->{}:{}", base.from, base.to, base.via);
361 if let Some(over) = override_map.remove(&key) {
362 if over.disabled != Some(true) {
363 merged.push(over);
364 }
365 } else {
366 merged.push(base);
367 }
368 }
369 for edge in override_map.into_values() {
370 if edge.disabled != Some(true) {
371 merged.push(edge);
372 }
373 }
374 graph.edges = merged;
375 graph
376}
377
378pub fn encode_cursor_state(db: usize, offset: usize) -> String {
380 encode_cursor_inner(&CursorState { db, offset })
381}
382
383pub fn decode_cursor(cursor: Option<&str>) -> Result<(usize, usize), ResourceError> {
385 let state = decode_cursor_inner(cursor)?;
386 Ok((state.db, state.offset))
387}
388
389fn encode_cursor_inner(state: &CursorState) -> String {
390 let json = serde_json::to_string(state).expect("cursor serialize");
391 B64.encode(json.as_bytes())
392}
393
394fn decode_cursor_inner(cursor: Option<&str>) -> Result<CursorState, ResourceError> {
395 let Some(cursor) = cursor.filter(|c| !c.is_empty()) else {
396 return Ok(CursorState { db: 0, offset: 0 });
397 };
398 let bytes = B64
399 .decode(cursor.as_bytes())
400 .map_err(|_| ResourceError::InvalidParams("Invalid cursor".into()))?;
401 let state: CursorState = serde_json::from_slice(&bytes)
402 .map_err(|_| ResourceError::InvalidParams("Invalid cursor".into()))?;
403 Ok(state)
404}
405
406fn encode_cursor(state: &CursorState) -> String {
407 encode_cursor_inner(state)
408}
409
410pub fn parse_uri(uri: &str) -> Result<ParsedUri, ResourceError> {
412 let rest = uri
413 .strip_prefix("nexql://")
414 .ok_or_else(|| ResourceError::NotFound(format!("Resource not found: {uri}")))?;
415 let mut parts = rest.splitn(3, '/');
416 let connection_id = parts.next().filter(|s| !s.is_empty());
417 let database = parts.next().filter(|s| !s.is_empty());
418 let tail = parts.next().filter(|s| !s.is_empty());
419 let (Some(conn_raw), Some(db_raw), Some(tail)) = (connection_id, database, tail) else {
420 return Err(ResourceError::NotFound(format!(
421 "Resource not found: {uri}"
422 )));
423 };
424
425 let connection_id = decode_uri_component(conn_raw)
426 .ok_or_else(|| ResourceError::NotFound(format!("Resource not found: {uri}")))?;
427 let database = decode_uri_component(db_raw)
428 .ok_or_else(|| ResourceError::NotFound(format!("Resource not found: {uri}")))?;
429
430 if tail == "manifest" {
431 return Ok(ParsedUri {
432 connection_id,
433 database,
434 kind: ResourceKind::Manifest,
435 });
436 }
437 if tail == "joingraph" {
438 return Ok(ParsedUri {
439 connection_id,
440 database,
441 kind: ResourceKind::JoinGraph,
442 });
443 }
444
445 let mut obj_parts = tail.splitn(3, '/');
446 let kind = obj_parts.next();
447 let schema_raw = obj_parts.next();
448 let name_raw = obj_parts.next();
449 if kind != Some("object") || schema_raw.is_none() || name_raw.is_none() {
450 return Err(ResourceError::NotFound(format!(
451 "Resource not found: {uri}"
452 )));
453 }
454 let schema = decode_uri_component(schema_raw.unwrap())
455 .ok_or_else(|| ResourceError::NotFound(format!("Resource not found: {uri}")))?;
456 let name = decode_uri_component(name_raw.unwrap())
457 .ok_or_else(|| ResourceError::NotFound(format!("Resource not found: {uri}")))?;
458 if schema.is_empty() || name.is_empty() || name.contains('/') {
459 return Err(ResourceError::NotFound(format!(
460 "Resource not found: {uri}"
461 )));
462 }
463
464 Ok(ParsedUri {
465 connection_id,
466 database,
467 kind: ResourceKind::Object { schema, name },
468 })
469}
470
471pub fn encode_uri_component(s: &str) -> String {
473 let mut out = String::with_capacity(s.len());
474 for b in s.bytes() {
475 match b {
476 b'A'..=b'Z'
477 | b'a'..=b'z'
478 | b'0'..=b'9'
479 | b'-'
480 | b'_'
481 | b'.'
482 | b'!'
483 | b'~'
484 | b'*'
485 | b'\''
486 | b'('
487 | b')' => out.push(b as char),
488 _ => out.push_str(&format!("%{b:02X}")),
489 }
490 }
491 out
492}
493
494pub fn decode_uri_component(s: &str) -> Option<String> {
495 let bytes = s.as_bytes();
496 let mut out = Vec::with_capacity(bytes.len());
497 let mut i = 0;
498 while i < bytes.len() {
499 match bytes[i] {
500 b'%' => {
501 if i + 2 >= bytes.len() {
502 return None;
503 }
504 let hi = from_hex(bytes[i + 1])?;
505 let lo = from_hex(bytes[i + 2])?;
506 out.push((hi << 4) | lo);
507 i += 3;
508 }
509 b'+' => {
510 out.push(b' ');
511 i += 1;
512 }
513 c => {
514 out.push(c);
515 i += 1;
516 }
517 }
518 }
519 String::from_utf8(out).ok()
520}
521
522fn from_hex(b: u8) -> Option<u8> {
523 match b {
524 b'0'..=b'9' => Some(b - b'0'),
525 b'a'..=b'f' => Some(b - b'a' + 10),
526 b'A'..=b'F' => Some(b - b'A' + 10),
527 _ => None,
528 }
529}
530
531#[cfg(test)]
532mod tests {
533 use super::*;
534 use nexql_index::{
535 BuildDepth, BuildMode, ColumnEntry, DbObjectKind, IndexCounts, IndexDerived, IndexManifest,
536 IndexScope, IndexStats, ObjectShard,
537 };
538 use tempfile::TempDir;
539
540 #[test]
541 fn cursor_round_trip() {
542 let encoded = encode_cursor_state(2, 150);
543 let (db, offset) = decode_cursor(Some(&encoded)).unwrap();
544 assert_eq!((db, offset), (2, 150));
545 }
546
547 #[test]
548 fn cursor_default_when_absent() {
549 assert_eq!(decode_cursor(None).unwrap(), (0, 0));
550 assert_eq!(decode_cursor(Some("")).unwrap(), (0, 0));
551 }
552
553 #[test]
554 fn invalid_cursor_is_invalid_params() {
555 let err = decode_cursor(Some("!!!not-base64-json!!!")).unwrap_err();
556 assert_eq!(err.code(), ERR_INVALID_PARAMS);
557 assert!(err.to_string().contains("Invalid cursor"));
558 }
559
560 #[test]
561 fn parse_manifest_joingraph_object_uris() {
562 let m = parse_uri("nexql://prod/appdb/manifest").unwrap();
563 assert_eq!(m.connection_id, "prod");
564 assert_eq!(m.database, "appdb");
565 assert_eq!(m.kind, ResourceKind::Manifest);
566
567 let j = parse_uri("nexql://prod/appdb/joingraph").unwrap();
568 assert_eq!(j.kind, ResourceKind::JoinGraph);
569
570 let o = parse_uri("nexql://prod/appdb/object/public/users").unwrap();
571 assert_eq!(
572 o.kind,
573 ResourceKind::Object {
574 schema: "public".into(),
575 name: "users".into()
576 }
577 );
578 }
579
580 #[test]
581 fn parse_uri_decodes_components() {
582 let o = parse_uri("nexql://my%20conn/db%2F1/object/public/my%20table").unwrap();
583 assert_eq!(o.connection_id, "my conn");
584 assert_eq!(o.database, "db/1");
585 assert_eq!(
586 o.kind,
587 ResourceKind::Object {
588 schema: "public".into(),
589 name: "my table".into()
590 }
591 );
592 }
593
594 #[test]
595 fn unknown_uri_is_not_found() {
596 let err = parse_uri("nexql://a/b/c").unwrap_err();
597 assert_eq!(err.code(), ERR_RESOURCE_NOT_FOUND);
598 let err = parse_uri("http://x").unwrap_err();
599 assert_eq!(err.code(), ERR_RESOURCE_NOT_FOUND);
600 }
601
602 #[test]
603 fn empty_index_lists_nothing() {
604 let tmp = TempDir::new().unwrap();
605 let provider = ResourceProvider::new(IndexStore::new(tmp.path()));
606 let result = provider.list(None).unwrap();
607 assert!(result.resources.is_empty());
608 assert!(result.next_cursor.is_none());
609 }
610
611 #[test]
612 fn list_and_read_manifest_from_fixture() {
613 let tmp = TempDir::new().unwrap();
614 let store = IndexStore::new(tmp.path());
615 let base = store.base_dir("conn1", "db1");
616 let manifest = IndexManifest {
617 format_version: 1,
618 connection_id: "conn1".into(),
619 database: "db1".into(),
620 indexed_at: "2026-01-01T00:00:00Z".into(),
621 build_mode: BuildMode::Guided,
622 build_depth: BuildDepth::Structure,
623 schema_fingerprint: "fp".into(),
624 pg_version: "16".into(),
625 environment: "development".into(),
626 scope: IndexScope {
627 included_schemas: vec!["public".into()],
628 excluded_objects: vec![],
629 pii_excluded_columns: vec![],
630 },
631 counts: IndexCounts {
632 tables: 1,
633 views: 0,
634 functions: 0,
635 enums: 0,
636 },
637 shards: vec![ObjectShard {
638 file: "objects_public.json".into(),
639 schema: "public".into(),
640 objects: 1,
641 bytes: 10,
642 hash: "h".into(),
643 }],
644 derived: IndexDerived {
645 tokens: "tokens.json".into(),
646 join_graph: "joingraph.json".into(),
647 values: None,
648 embeddings: None,
649 embeddings_meta: None,
650 },
651 stats: IndexStats {
652 build_ms: 1,
653 queries_run: 1,
654 warnings: vec![],
655 },
656 };
657 store.write_manifest(&base, &manifest).unwrap();
658 let mut entries = HashMap::new();
659 entries.insert(
660 "public.users".into(),
661 ObjectEntry {
662 kind: DbObjectKind::Table,
663 oid: 1,
664 object_hash: "x".into(),
665 comment: Some("users table".into()),
666 row_estimate: 10.0,
667 size_bytes: 8192,
668 columns: vec![ColumnEntry {
669 name: "id".into(),
670 type_name: "integer".into(),
671 not_null: true,
672 default_value: None,
673 comment: None,
674 ordinal: 1,
675 is_pk: Some(true),
676 profile: None,
677 pii: None,
678 }],
679 primary_key: Some(vec!["id".into()]),
680 foreign_keys: None,
681 indexes: None,
682 checks: None,
683 excluded: None,
684 definition: None,
685 signature: None,
686 language: None,
687 volatility: None,
688 body: None,
689 values: None,
690 base_type: None,
691 constraint: None,
692 },
693 );
694 store
695 .write_shard_entries(&base, "objects_public.json", &entries)
696 .unwrap();
697
698 let provider = ResourceProvider::new(store);
699 let listed = provider.list(None).unwrap();
700 assert_eq!(listed.resources.len(), 3);
701 assert!(
702 listed
703 .resources
704 .iter()
705 .any(|r| r.uri.ends_with("/manifest"))
706 );
707 assert!(
708 listed
709 .resources
710 .iter()
711 .any(|r| r.uri.ends_with("/object/public/users"))
712 );
713
714 let read = provider
715 .read("nexql://conn1/db1/object/public/users")
716 .unwrap();
717 assert_eq!(read.contents.len(), 1);
718 assert!(read.contents[0].text.contains("users table"));
719 }
720}