Skip to main content

sqlserver_mcp_catalog/services/
api_client.rs

1// SQL Server 2025 - master/msdb/sandbox combined catalog MCP server.
2//
3// Real transport: a pooled TDS connection to SQL Server (via `tiberius`),
4// replacing mcpify's originally-generated `reqwest`-based HTTP client.
5// These OpenAPI operations are synthetic (see
6// docs/sqlserver-eda-openapi-pipeline/README.md's "OpenAPI mapping
7// convention") -- there is no real HTTP endpoint on the other end, so
8// `endpoint.path`/`endpoint.method` don't describe an HTTP request; they
9// encode `/<db>/<schema>/<name>` (set by `merge_openapi.py`) and are always
10// `POST`. `endpoint.description` carries the object's `sys.objects.type_desc`
11// (`VIEW` / `SQL_STORED_PROCEDURE` / `SQL_INLINE_TABLE_VALUED_FUNCTION` /
12// `EXTENDED_STORED_PROCEDURE`), which determines the actual T-SQL shape:
13// a bare `SELECT` for views, `SELECT ... FROM name(args)` for inline
14// table-valued functions (positional args -- no named-parameter call syntax
15// exists for a FROM-clause function call), or `EXEC name @p = v, ...` for
16// stored procedures (named, so param order and optional/defaulted params
17// don't matter).
18
19use serde_json::{Map, Value};
20use tiberius::ToSql;
21
22use crate::auth::auth_manager::AuthManager;
23use crate::core::config_schema::Config;
24use crate::data::store::EndpointRecord;
25use crate::services::sql_pool;
26use crate::services::sql_type::{column_data_to_json, json_to_param};
27use crate::validation::validator::resolved_schemas_for;
28
29/// Parses `endpoint.path`'s `/<db>/<schema>/<name>` shape (set by
30/// `docs/sqlserver-eda-openapi-pipeline/tools/merge_openapi.py`) into its
31/// three parts -- the only place in this generated project that recovers
32/// which database an operation targets, since mcpify's store doesn't carry
33/// the source spec's `x-sql-database` vendor extension through.
34fn parse_path(path: &str) -> anyhow::Result<(&str, &str, &str)> {
35    let mut parts = path.trim_start_matches('/').splitn(3, '/');
36    match (parts.next(), parts.next(), parts.next()) {
37        (Some(db), Some(schema), Some(name))
38            if !db.is_empty() && !schema.is_empty() && !name.is_empty() =>
39        {
40            Ok((
41                validate_ident(db)?,
42                validate_ident(schema)?,
43                validate_ident(name)?,
44            ))
45        }
46        _ => anyhow::bail!(
47            "endpoint path '{path}' is not in the expected /<db>/<schema>/<name> shape"
48        ),
49    }
50}
51
52/// Rejects any identifier containing a character outside `[A-Za-z0-9_]` --
53/// every identifier passed through here (`db`/`schema`/`name`, stored
54/// procedure parameter names) comes from this project's own generated spec
55/// (looked up from the local SQLite store by `operation_id`, never built
56/// from a caller-supplied string), and every name in the curated object
57/// set this pipeline targets is already plain alphanumeric/underscore --
58/// see docs/sqlserver-eda-openapi-pipeline/sql/eda/allowlist.yaml. This is
59/// defense-in-depth on top of `quote_ident`'s bracket-escaping, not a
60/// response to any known injection path: T-SQL has no way to bind an
61/// object/parameter *name* as a query parameter (only values), so an
62/// identifier that ever did contain attacker-influenced text would bypass
63/// the parameterized-value protection `services::sql_type::json_to_param`
64/// provides for every argument *value*. Failing loudly here on an
65/// unexpected character is preferable to silently trusting it.
66fn validate_ident(ident: &str) -> anyhow::Result<&str> {
67    if !ident.is_empty()
68        && ident
69            .bytes()
70            .all(|b| b.is_ascii_alphanumeric() || b == b'_')
71    {
72        Ok(ident)
73    } else {
74        anyhow::bail!("identifier '{ident}' contains characters outside [A-Za-z0-9_]")
75    }
76}
77
78/// Brackets a SQL Server identifier, doubling any embedded `]` -- a second,
79/// independent layer under `validate_ident`'s charset allow-list, kept
80/// rather than dropped now that the allow-list exists, since a future
81/// change loosening the allow-list (e.g. to admit `$`/`#`-prefixed names)
82/// should not silently lose this escaping.
83fn quote_ident(ident: &str) -> String {
84    format!("[{}]", ident.replace(']', "]]"))
85}
86
87struct Param {
88    name: String,
89    ordinal: u64,
90    x_sql_type: String,
91}
92
93/// Reads `properties`/`x-sql-ordinal`/`x-sql-type` off a resolved input
94/// schema (see `tools/generate_openapi.py`'s `build_request_schema`),
95/// sorted by declared ordinal -- required for a table-valued function's
96/// positional call syntax, and used for stored procedures' named syntax
97/// mainly for a deterministic/readable generated statement.
98fn ordered_params(input_schema: &Value) -> Vec<Param> {
99    let mut params: Vec<Param> = input_schema
100        .get("properties")
101        .and_then(Value::as_object)
102        .into_iter()
103        .flatten()
104        .map(|(name, schema)| Param {
105            name: name.clone(),
106            ordinal: schema
107                .get("x-sql-ordinal")
108                .and_then(Value::as_u64)
109                .unwrap_or(0),
110            x_sql_type: schema
111                .get("x-sql-type")
112                .and_then(Value::as_str)
113                .unwrap_or("nvarchar(max)")
114                .to_string(),
115        })
116        .collect();
117    params.sort_by_key(|p| p.ordinal);
118    params
119}
120
121/// Builds the T-SQL text (with `@P1`/`@P2`/... placeholders) and the
122/// matching positionally-ordered bound parameters for one operation call.
123fn build_statement(
124    db: &str,
125    schema: &str,
126    name: &str,
127    kind: Option<&str>,
128    params: &[Param],
129    body: &Map<String, Value>,
130    database_override: Option<&str>,
131) -> anyhow::Result<(String, Vec<Box<dyn ToSql>>)> {
132    // "sandbox" isn't a real database name a production instance is
133    // guaranteed to have -- it's the EDA pipeline's placeholder (see
134    // docs/sqlserver-eda-openapi-pipeline/README.md) for whatever database
135    // the TDS connection is already in (`sql_pool`/this fn's caller never
136    // sets an initial `database` on `tiberius::Config`, so that's always
137    // the login's server-configured default database, i.e. `db_name()`).
138    // Qualifying with a literal `[sandbox].` prefix would send every
139    // sandbox-database call at the wrong (nonexistent, or coincidentally
140    // named) database in any real deployment, so this two-part-qualifies
141    // instead by default and lets the connection's own current-database
142    // context resolve it -- unless the caller passed a top-level
143    // `database` argument (see `ApiClient::execute`), in which case that
144    // name replaces "sandbox" and the call goes out three-part qualified
145    // against the requested database instead. `master`/`msdb` are real,
146    // always-present system database names on every SQL Server instance,
147    // so those always stay three-part qualified and never take the
148    // override -- there's no ambiguity to resolve for them.
149    let qualified = match (db, database_override) {
150        ("sandbox", Some(requested_db)) => format!(
151            "{}.{}.{}",
152            quote_ident(requested_db),
153            quote_ident(schema),
154            quote_ident(name)
155        ),
156        ("sandbox", None) => format!("{}.{}", quote_ident(schema), quote_ident(name)),
157        (_, _) => format!(
158            "{}.{}.{}",
159            quote_ident(db),
160            quote_ident(schema),
161            quote_ident(name)
162        ),
163    };
164
165    let mut bound: Vec<Box<dyn ToSql>> = Vec::with_capacity(params.len());
166    for param in params {
167        let value = body.get(&param.name).cloned().unwrap_or(Value::Null);
168        bound.push(json_to_param(&value, &param.x_sql_type)?);
169    }
170
171    let sql = match kind {
172        Some("VIEW") => format!("SELECT * FROM {qualified}"),
173        Some(k) if k.ends_with("_FUNCTION") || k.contains("TABLE_VALUED_FUNCTION") => {
174            let placeholders = (1..=bound.len())
175                .map(|i| format!("@P{i}"))
176                .collect::<Vec<_>>()
177                .join(", ");
178            format!("SELECT * FROM {qualified}({placeholders})")
179        }
180        // Stored procedures (SQL_STORED_PROCEDURE/EXTENDED_STORED_PROCEDURE)
181        // and the `kind.is_none()` fallback (an older store built before
182        // `description` carried this classification) both take the EXEC
183        // path -- named parameter syntax, so a proc with only optional
184        // parameters and no arguments supplied still works (`bound` empty
185        // produces a bare `EXEC name`).
186        _ => {
187            if bound.is_empty() {
188                format!("EXEC {qualified}")
189            } else {
190                let mut assignments = Vec::with_capacity(params.len());
191                for (i, p) in params.iter().enumerate() {
192                    assignments.push(format!(
193                        "{} = @P{}",
194                        quote_ident(validate_ident(&p.name)?),
195                        i + 1
196                    ));
197                }
198                format!("EXEC {qualified} {}", assignments.join(", "))
199            }
200        }
201    };
202
203    Ok((sql, bound))
204}
205
206/// Maps a `tiberius` error to this project's synthetic 400/403/500 split
207/// (see `docs/sqlserver-eda-openapi-pipeline/README.md`'s "OpenAPI mapping
208/// convention" -- SQL Server severity 11-16 statement errors are `400`,
209/// severity-14 permission errors are `403`, severity 17-25 engine/fatal
210/// errors are `500`), rather than surfacing the raw driver error
211/// undifferentiated. Returned as a JSON value matching
212/// `components.schemas.SqlServerError` shape rather than a bare string, so
213/// callers get the same structured error either way they might have gotten
214/// it from a real synchronous SQL Server error.
215fn classify_tiberius_error(err: tiberius::error::Error) -> anyhow::Error {
216    use tiberius::error::Error as TError;
217    match &err {
218        TError::Server(token) => {
219            let (number, state, class, message, procedure, line) = (
220                token.code(),
221                token.state(),
222                token.class(),
223                token.message(),
224                token.procedure(),
225                token.line(),
226            );
227            let category = if class >= 17 {
228                "fatal/resource error (500-equivalent)"
229            } else if class == 14 {
230                "permission denied (403-equivalent)"
231            } else {
232                "statement/user error (400-equivalent)"
233            };
234            anyhow::anyhow!(
235                "SQL Server error {number} (severity {class}, state {state}) in '{procedure}' line {line}: {message} [{category}]"
236            )
237        }
238        other => anyhow::anyhow!("SQL Server connection/protocol error (500-equivalent): {other}"),
239    }
240}
241
242pub struct ApiClient {
243    config: Config,
244}
245
246impl ApiClient {
247    pub fn new(config: Config) -> Self {
248        Self { config }
249    }
250
251    /// Executes `endpoint` against the configured SQL Server instance.
252    /// `auth_manager` resolves to a `tiberius::AuthMethod` (see
253    /// `auth::auth_manager::AuthManager::resolve_tds_auth`) from the
254    /// server's own configured credentials -- there is no per-request
255    /// credential override; SQL Server auth is always this server's own
256    /// configured identity, not something a caller supplies per call.
257    ///
258    /// A top-level `database` string in `args` (a sibling of `body`, e.g.
259    /// `{"database": "reporting", "body": {...}}`) lets a caller target a
260    /// specific database for a `sandbox`-tagged operation, overriding the
261    /// connection's own current database (see `build_statement`'s doc
262    /// comment for why "sandbox" needs this at all). It's not part of the
263    /// generated/documented input schema -- mcpify's schema wrapper
264    /// doesn't set `additionalProperties: false`, so an extra key here
265    /// passes `validate_input` unnoticed -- and it's a no-op for
266    /// `master`/`msdb` operations, which are always sent against their
267    /// real, literal database regardless of this argument.
268    pub async fn execute(
269        &self,
270        endpoint: &EndpointRecord,
271        args: &Value,
272        auth_manager: &mut AuthManager,
273    ) -> anyhow::Result<Value> {
274        let (db, schema, name) = parse_path(&endpoint.path)?;
275
276        let (input_schema, _output_schema) =
277            resolved_schemas_for(&self.config.api_version, &endpoint.operation_id).ok_or_else(
278                || {
279                    anyhow::anyhow!(
280                        "no resolved schema found for operation '{}' under api_version '{}'",
281                        endpoint.operation_id,
282                        self.config.api_version
283                    )
284                },
285            )?;
286        let params = ordered_params(input_schema);
287
288        let empty = Map::new();
289        let args_map = args.as_object().unwrap_or(&empty);
290        let body = args_map
291            .get("body")
292            .and_then(Value::as_object)
293            .cloned()
294            .unwrap_or_default();
295        let database_override = args_map
296            .get("database")
297            .and_then(Value::as_str)
298            .map(validate_ident)
299            .transpose()?;
300
301        let (sql, bound) = build_statement(
302            db,
303            schema,
304            name,
305            endpoint.description.as_deref(),
306            &params,
307            &body,
308            database_override,
309        )?;
310
311        let auth_method = auth_manager.resolve_tds_auth().await?;
312
313        let (host, port) = self.config.host_and_port();
314        let mut tiberius_config = tiberius::Config::new();
315        tiberius_config.host(host);
316        tiberius_config.port(port);
317        tiberius_config.authentication(auth_method);
318        if self.config.trust_server_cert {
319            // Curated system-catalog objects, not user data -- trusting the
320            // server cert matches this project's primary use case (a local
321            // Docker/dev instance with a self-signed cert; see
322            // docs/sqlserver-eda-openapi-pipeline/docker-compose.yml)
323            // rather than requiring every operator to supply a CA bundle
324            // before the first query works. Set `trust_server_cert: false`
325            // for a production instance with a real CA-signed cert.
326            tiberius_config.trust_cert();
327        }
328
329        let pool_key = format!("{host}:{port}");
330        let pool =
331            sql_pool::cached_pool(&pool_key, tiberius_config, self.config.pool_max_size).await?;
332        let mut conn = pool.get().await.map_err(|err| {
333            anyhow::anyhow!("failed to obtain a pooled SQL Server connection: {err}")
334        })?;
335
336        let bound_refs: Vec<&dyn ToSql> = bound.iter().map(|param| param.as_ref()).collect();
337        let stream = conn
338            .query(&sql, &bound_refs)
339            .await
340            .map_err(classify_tiberius_error)?;
341        let rows = stream
342            .into_first_result()
343            .await
344            .map_err(classify_tiberius_error)?;
345
346        let json_rows: Vec<Value> = rows
347            .iter()
348            .map(|row| {
349                let mut obj = Map::new();
350                for (column, data) in row.cells() {
351                    obj.insert(column.name().to_string(), column_data_to_json(data));
352                }
353                Value::Object(obj)
354            })
355            .collect();
356        Ok(Value::Array(json_rows))
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    #[test]
365    fn parse_path_splits_db_schema_name() {
366        assert_eq!(
367            parse_path("/master/INFORMATION_SCHEMA/COLUMNS").unwrap(),
368            ("master", "INFORMATION_SCHEMA", "COLUMNS")
369        );
370    }
371
372    #[test]
373    fn parse_path_rejects_a_path_with_too_few_segments() {
374        assert!(parse_path("/master/COLUMNS").is_err());
375    }
376
377    #[test]
378    fn quote_ident_doubles_embedded_closing_brackets() {
379        assert_eq!(quote_ident("weird]name"), "[weird]]name]");
380    }
381
382    #[test]
383    fn validate_ident_accepts_alphanumeric_and_underscore() {
384        assert!(validate_ident("sp_who2").is_ok());
385        assert!(validate_ident("INFORMATION_SCHEMA").is_ok());
386    }
387
388    #[test]
389    fn validate_ident_rejects_anything_else() {
390        assert!(validate_ident("").is_err());
391        assert!(validate_ident("robert'; drop table endpoints;--").is_err());
392        assert!(validate_ident("weird]name").is_err());
393        assert!(validate_ident("has space").is_err());
394    }
395
396    #[test]
397    fn parse_path_rejects_a_path_with_an_unsafe_segment() {
398        assert!(parse_path("/master/sys/sp_who; DROP TABLE endpoints--").is_err());
399    }
400
401    #[test]
402    fn build_statement_selects_from_a_view_with_no_parameters() {
403        let (sql, bound) = build_statement(
404            "master",
405            "INFORMATION_SCHEMA",
406            "COLUMNS",
407            Some("VIEW"),
408            &[],
409            &Map::new(),
410            None,
411        )
412        .unwrap();
413        assert_eq!(sql, "SELECT * FROM [master].[INFORMATION_SCHEMA].[COLUMNS]");
414        assert!(bound.is_empty());
415    }
416
417    #[test]
418    fn build_statement_execs_a_proc_with_named_parameters() {
419        let params = vec![
420            Param {
421                name: "objname".to_string(),
422                ordinal: 1,
423                x_sql_type: "nvarchar(1035)".to_string(),
424            },
425            Param {
426                name: "newname".to_string(),
427                ordinal: 2,
428                x_sql_type: "sysname".to_string(),
429            },
430        ];
431        let mut body = Map::new();
432        body.insert("objname".to_string(), Value::String("t1".to_string()));
433        body.insert("newname".to_string(), Value::String("t2".to_string()));
434        let (sql, bound) = build_statement(
435            "master",
436            "sys",
437            "sp_rename",
438            Some("SQL_STORED_PROCEDURE"),
439            &params,
440            &body,
441            None,
442        )
443        .unwrap();
444        assert_eq!(
445            sql,
446            "EXEC [master].[sys].[sp_rename] [objname] = @P1, [newname] = @P2"
447        );
448        assert_eq!(bound.len(), 2);
449    }
450
451    #[test]
452    fn build_statement_selects_from_a_function_with_positional_placeholders() {
453        let params = vec![Param {
454            name: "session_id".to_string(),
455            ordinal: 1,
456            x_sql_type: "smallint".to_string(),
457        }];
458        let mut body = Map::new();
459        body.insert("session_id".to_string(), Value::from(52));
460        let (sql, bound) = build_statement(
461            "master",
462            "sys",
463            "dm_exec_sql_text",
464            Some("SQL_INLINE_TABLE_VALUED_FUNCTION"),
465            &params,
466            &body,
467            None,
468        )
469        .unwrap();
470        assert_eq!(sql, "SELECT * FROM [master].[sys].[dm_exec_sql_text](@P1)");
471        assert_eq!(bound.len(), 1);
472    }
473
474    #[test]
475    fn build_statement_omits_the_database_qualifier_for_sandbox_by_default() {
476        let (sql, bound) = build_statement(
477            "sandbox",
478            "dbo",
479            "widgets",
480            Some("VIEW"),
481            &[],
482            &Map::new(),
483            None,
484        )
485        .unwrap();
486        assert_eq!(sql, "SELECT * FROM [dbo].[widgets]");
487        assert!(bound.is_empty());
488    }
489
490    #[test]
491    fn build_statement_replaces_sandbox_with_a_requested_database_override() {
492        let (sql, bound) = build_statement(
493            "sandbox",
494            "dbo",
495            "widgets",
496            Some("VIEW"),
497            &[],
498            &Map::new(),
499            Some("reporting"),
500        )
501        .unwrap();
502        assert_eq!(sql, "SELECT * FROM [reporting].[dbo].[widgets]");
503        assert!(bound.is_empty());
504    }
505
506    #[test]
507    fn build_statement_ignores_the_database_override_for_master_and_msdb() {
508        let (sql, _bound) = build_statement(
509            "master",
510            "sys",
511            "sp_who",
512            Some("SQL_STORED_PROCEDURE"),
513            &[],
514            &Map::new(),
515            Some("reporting"),
516        )
517        .unwrap();
518        assert_eq!(sql, "EXEC [master].[sys].[sp_who]");
519    }
520
521    #[test]
522    fn build_statement_execs_a_parameterless_proc() {
523        let (sql, bound) = build_statement(
524            "master",
525            "sys",
526            "sp_who",
527            Some("SQL_STORED_PROCEDURE"),
528            &[],
529            &Map::new(),
530            None,
531        )
532        .unwrap();
533        assert_eq!(sql, "EXEC [master].[sys].[sp_who]");
534        assert!(bound.is_empty());
535    }
536
537    #[test]
538    fn ordered_params_follow_the_schema_ordinals() {
539        let schema = serde_json::json!({
540            "properties": {
541                "second": { "x-sql-ordinal": 2, "x-sql-type": "int" },
542                "first": { "x-sql-ordinal": 1, "x-sql-type": "nvarchar(20)" }
543            }
544        });
545
546        let params = ordered_params(&schema);
547        assert_eq!(params.len(), 2);
548        assert_eq!(params[0].name, "first");
549        assert_eq!(params[0].x_sql_type, "nvarchar(20)");
550        assert_eq!(params[1].name, "second");
551    }
552
553    #[test]
554    fn build_statement_rejects_an_unsafe_parameter_name() {
555        let params = vec![Param {
556            name: "unsafe name".to_string(),
557            ordinal: 1,
558            x_sql_type: "int".to_string(),
559        }];
560
561        assert!(
562            build_statement(
563                "master",
564                "sys",
565                "sp_example",
566                Some("SQL_STORED_PROCEDURE"),
567                &params,
568                &Map::new(),
569                None,
570            )
571            .is_err()
572        );
573    }
574
575    #[test]
576    fn protocol_errors_are_classified_as_server_failures() {
577        let error =
578            classify_tiberius_error(tiberius::error::Error::Protocol("invalid packet".into()));
579        assert_eq!(
580            error.to_string(),
581            "SQL Server connection/protocol error (500-equivalent): Protocol error: invalid packet"
582        );
583    }
584}