Skip to main content

nexql_tools/
resources.rs

1// SPDX-License-Identifier: GPL-3.0-only
2// Copyright (C) 2026 NexQL-OSS Team
3
4//! MCP resources backed by the on-disk schema index (dbindex).
5//!
6//! Port of `pro/src/mcp/McpResourceProvider.ts`. Disk-only — no DB connections.
7//!
8//! URIs:
9//! - `nexql://{profile}/{database}/manifest`
10//! - `nexql://{profile}/{database}/joingraph`
11//! - `nexql://{profile}/{database}/object/{schema}/{name}`
12
13use 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
21/// Matches TS `PAGE_SIZE`.
22pub const PAGE_SIZE: usize = 200;
23
24/// JSON-RPC / MCP error codes used by resources.
25pub 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    /// Index into `list_indexed_databases()` ordering.
92    db: usize,
93    /// Offset into the flattened resource list of that database.
94    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
111/// Disk-backed MCP resource provider.
112pub 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        // Stable order for cursor pagination (HashMap iteration is not).
307        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
346/// Merge join-graph overrides (matches TS `IndexStore.readJoinGraph`).
347fn 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
381/// Encode pagination cursor `{db, offset}` as base64 JSON.
382pub fn encode_cursor_state(db: usize, offset: usize) -> String {
383    encode_cursor_inner(&CursorState { db, offset })
384}
385
386/// Decode pagination cursor; missing/empty → `(0, 0)`.
387pub 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
413/// Parse a `nexql://…` resource URI.
414pub 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
474/// `encodeURIComponent`-compatible encoding for URI path segments.
475pub 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}