Skip to main content

nexql_tools/
resources.rs

1//! MCP resources backed by the on-disk schema index (dbindex).
2//!
3//! Port of `pro/src/mcp/McpResourceProvider.ts`. Disk-only — no DB connections.
4//!
5//! URIs:
6//! - `nexql://{profile}/{database}/manifest`
7//! - `nexql://{profile}/{database}/joingraph`
8//! - `nexql://{profile}/{database}/object/{schema}/{name}`
9
10use 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
18/// Matches TS `PAGE_SIZE`.
19pub const PAGE_SIZE: usize = 200;
20
21/// JSON-RPC / MCP error codes used by resources.
22pub 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    /// Index into `list_indexed_databases()` ordering.
89    db: usize,
90    /// Offset into the flattened resource list of that database.
91    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
108/// Disk-backed MCP resource provider.
109pub 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        // Stable order for cursor pagination (HashMap iteration is not).
304        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
343/// Merge join-graph overrides (matches TS `IndexStore.readJoinGraph`).
344fn 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
378/// Encode pagination cursor `{db, offset}` as base64 JSON.
379pub fn encode_cursor_state(db: usize, offset: usize) -> String {
380    encode_cursor_inner(&CursorState { db, offset })
381}
382
383/// Decode pagination cursor; missing/empty → `(0, 0)`.
384pub 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
410/// Parse a `nexql://…` resource URI.
411pub 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
471/// `encodeURIComponent`-compatible encoding for URI path segments.
472pub 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}