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