snowflake-analytics-mcp-server 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,6 @@
1
1
  # snowflake-analytics-mcp-server
2
2
 
3
- A [Model Context Protocol](https://modelcontextprotocol.io) server for **Snowflake**, built for the Analytics Model platform. It connects to Snowflake via the official `snowflake-sdk` driver (pure Node — no client binary needed, unlike the Oracle server) and exposes table-discovery and query tools over stdio. Runnable directly with `npx`.
4
-
5
- The `list_tables` tool returns the platform's exact table-discovery envelope, so it drops straight into the same `fetchMcpTables` flow as the Clio, Dropbox and Shopify servers.
3
+ A [Model Context Protocol](https://modelcontextprotocol.io) server for **Snowflake**. It connects to Snowflake via the official `snowflake-sdk` driver (pure Node — no client binary needed) and exposes table-discovery, SQL query, and Cortex AI tools over stdio. Runnable directly with `npx`.
6
4
 
7
5
  ## Install
8
6
 
@@ -22,7 +20,7 @@ All credentials come from environment variables.
22
20
  | `SNOWFLAKE_USERNAME` | ✅ | — | Login name |
23
21
  | `SNOWFLAKE_PASSWORD` | ✅ (password auth) | — | Password (required unless using key-pair/OAuth) |
24
22
  | `SNOWFLAKE_WAREHOUSE` | — | — | Virtual warehouse to use |
25
- | `SNOWFLAKE_DATABASE` | recommended | — | Default database (used by `list_tables` when no arg is given) |
23
+ | `SNOWFLAKE_DATABASE` | recommended | — | Default database (used by `list_tables`, and as the fallback when a caller passes an inaccessible database) |
26
24
  | `SNOWFLAKE_SCHEMA` | recommended | — | Default schema |
27
25
  | `SNOWFLAKE_ROLE` | — | — | Role to assume |
28
26
  | `SNOWFLAKE_AUTHENTICATOR` | — | `SNOWFLAKE` | `SNOWFLAKE` (password), `SNOWFLAKE_JWT` (key-pair), `OAUTH` |
@@ -33,6 +31,8 @@ All credentials come from environment variables.
33
31
  | `SNOWFLAKE_READ_ONLY` | — | `true` | Blocks writes/DDL in `execute_query`. Set `false` to allow them |
34
32
  | `SNOWFLAKE_ROW_LIMIT` | — | `1000` | Hard cap on returned rows |
35
33
  | `QUERY_TIMEOUT_MS` | — | `60000` | Client-side query timeout |
34
+ | `SNOWFLAKE_CORTEX_ENABLED` | — | `true` | Register the Cortex AI tools. Set `false` to hide them |
35
+ | `SNOWFLAKE_CORTEX_MODEL` | — | `llama3.1-8b` | Default model for Cortex AI_COMPLETE / text-to-SQL |
36
36
 
37
37
  ### Account identifier tip
38
38
 
@@ -78,9 +78,11 @@ Add to `claude_desktop_config.json`:
78
78
 
79
79
  ## Tools
80
80
 
81
+ ### SQL & discovery
82
+
81
83
  | Tool | Args | Description |
82
84
  |---|---|---|
83
- | `list_tables` | `database?`, `schema?`, `include_views?` | **Platform contract.** Returns `{ table_name }[]` in the double-wrapped envelope for `fetchMcpTables` |
85
+ | `list_tables` | `database?`, `schema?`, `include_views?` | Returns `{ table_name }[]` for table discovery |
84
86
  | `test_connection` | — | Returns version, account, user, role, warehouse, database, schema |
85
87
  | `list_databases` | — | Databases visible to the current role |
86
88
  | `list_schemas` | `database?` | Schemas in a database |
@@ -89,21 +91,26 @@ Add to `claude_desktop_config.json`:
89
91
  | `get_table_sample` | `table`, `database?`, `schema?`, `limit?` | Preview rows from a table |
90
92
  | `execute_query` | `query` | Run SQL. Read-only unless `SNOWFLAKE_READ_ONLY=false` |
91
93
 
92
- ## Platform integration note
94
+ ### Cortex AI
93
95
 
94
- `list_tables` matches the confirmed envelope used by the Shopify/Clio servers:
96
+ These are additive generative-AI tools (token-billed, higher latency than the SQL tools). Each is invoked as a `SELECT`, so they work with `SNOWFLAKE_READ_ONLY=true`. Disable them with `SNOWFLAKE_CORTEX_ENABLED=false`.
95
97
 
96
- ```jsonc
97
- {
98
- "is_success": true,
99
- "status_code": 200,
100
- "data": "[{\"table_name\":\"ORDERS\"},{\"table_name\":\"CUSTOMERS\"}]", // double-stringified
101
- "message": "Found 2 table(s).",
102
- "requestedPayload": { "database": "ANALYTICS_DB", "schema": "PUBLIC", "include_views": true }
103
- }
104
- ```
98
+ | Tool | Args | Description |
99
+ |---|---|---|
100
+ | `cortex_ask` | `question`, `database?`, `schema?`, `model?` | Natural-language question → generated read-only SQL → answer. Best for analytics like "what is total revenue" |
101
+ | `cortex_complete` | `prompt`, `model?` | Free-form LLM completion via AI_COMPLETE |
102
+ | `cortex_sentiment` | `text` | Sentiment of a text via AI_SENTIMENT |
103
+ | `cortex_summarize` | `text` | Concise summary of a text |
104
+ | `cortex_classify` | `text`, `categories[]` | Classify text into your categories via AI_CLASSIFY |
105
+ | `cortex_translate` | `text`, `to_language`, `from_language?` | Translate text (source auto-detected when omitted) |
106
+
107
+ `cortex_ask` generates SQL and always restricts it to read-only statements, regardless of `SNOWFLAKE_READ_ONLY`.
108
+
109
+ **Cortex prerequisites:** the account must be in a region that supports Cortex, and the connecting role needs the `SNOWFLAKE.CORTEX_USER` database role plus the `USE AI FUNCTIONS` privilege. If a Cortex call fails on region or privileges, the tool returns a hint explaining what to grant.
110
+
111
+ ## Database resolution
105
112
 
106
- Because Snowflake exposes **arbitrary** user tables (unlike Clio's fixed resource categories, which each had a `list_<resource>` tool), there is no per-table tool. When a user selects a table in the platform, the backend should fetch its rows via `get_table_sample` (preview) or `execute_query` (`SELECT * FROM <table>`), rather than calling a tool named after the table. Confirm this mapping with the backend before wiring the row-fetch step.
113
+ `list_tables`, `describe_table`, `get_table_sample`, `list_schemas`, and `cortex_ask` accept an optional `database`. If the value passed isn't an accessible database, the tool falls back to `SNOWFLAKE_DATABASE` instead of erroring so a stray or wrong database name from an upstream caller doesn't cause a failed lookup.
107
114
 
108
115
  ## License
109
116
 
package/dist/index.js CHANGED
@@ -3,11 +3,8 @@
3
3
  * snowflake-analytics-mcp-server
4
4
  *
5
5
  * A Model Context Protocol server that exposes a Snowflake connection over
6
- * stdio, runnable via `npx`. Built for the Analytics Model platform: the
7
- * `list_tables` tool follows the platform's table-discovery envelope contract,
8
- * while the remaining tools are standard SQL helpers.
9
- *
10
- * Credentials come entirely from environment variables (see README).
6
+ * stdio, runnable via `npx`. Provides table-discovery, SQL query, and Cortex AI
7
+ * tools. Credentials come entirely from environment variables (see README).
11
8
  */
12
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -15,7 +12,7 @@ import { registerTools } from "./tools/index.js";
15
12
  async function main() {
16
13
  const server = new McpServer({
17
14
  name: "snowflake-analytics-mcp-server",
18
- version: "0.1.0",
15
+ version: "0.2.0",
19
16
  title: "Snowflake Analytics MCP Server",
20
17
  });
21
18
  registerTools(server);
@@ -13,6 +13,8 @@ export interface SnowflakeConfig {
13
13
  queryTimeoutMs: number;
14
14
  rowLimit: number;
15
15
  readOnly: boolean;
16
+ cortexEnabled: boolean;
17
+ cortexModel: string;
16
18
  }
17
19
  export declare function loadConfig(): SnowflakeConfig;
18
20
  export declare function getConfig(): SnowflakeConfig;
@@ -33,5 +35,15 @@ export declare function quoteIdent(name: string): string;
33
35
  * Falls back to the connection's configured database/schema when omitted.
34
36
  */
35
37
  export declare function qualify(table: string, database?: string, schema?: string): string;
38
+ export declare function accessibleDatabases(): Promise<Set<string>>;
39
+ /**
40
+ * Resolve the database a tool should use.
41
+ *
42
+ * If `requested` is provided but isn't an accessible database (e.g. an upstream
43
+ * caller sent a stray value that isn't a real database), fall back to the
44
+ * configured SNOWFLAKE_DATABASE instead of erroring — so tools self-correct on
45
+ * the first call rather than taking a failed detour.
46
+ */
47
+ export declare function resolveDatabase(requested?: string): Promise<string | undefined>;
36
48
  export declare function isReadOnlyStatement(sql: string): boolean;
37
49
  //# sourceMappingURL=snowflake.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"snowflake.d.ts","sourceRoot":"","sources":["../src/snowflake.ts"],"names":[],"mappings":"AAuBA,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;CACnB;AAUD,wBAAgB,UAAU,IAAI,eAAe,CAsC5C;AAKD,wBAAgB,SAAS,IAAI,eAAe,CAG3C;AAiCD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAChC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,wBAAsB,QAAQ,CAC5B,OAAO,EAAE,MAAM,EACf,KAAK,GAAE,OAAO,EAAO,GACpB,OAAO,CAAC,WAAW,CAAC,CAsBtB;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAO/C;AAED;;;GAGG;AACH,wBAAgB,OAAO,CACrB,KAAK,EAAE,MAAM,EACb,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,GACd,MAAM,CASR;AAKD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAExD"}
1
+ {"version":3,"file":"snowflake.d.ts","sourceRoot":"","sources":["../src/snowflake.ts"],"names":[],"mappings":"AAyBA,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,cAAc,EAAE,MAAM,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,OAAO,CAAC;IAClB,aAAa,EAAE,OAAO,CAAC;IACvB,WAAW,EAAE,MAAM,CAAC;CACrB;AAUD,wBAAgB,UAAU,IAAI,eAAe,CA0C5C;AAKD,wBAAgB,SAAS,IAAI,eAAe,CAG3C;AAiCD,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAC;IAChC,QAAQ,EAAE,MAAM,CAAC;CAClB;AAED,wBAAsB,QAAQ,CAC5B,OAAO,EAAE,MAAM,EACf,KAAK,GAAE,OAAO,EAAO,GACpB,OAAO,CAAC,WAAW,CAAC,CAoBtB;AAED;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAO/C;AAED;;;GAGG;AACH,wBAAgB,OAAO,CACrB,KAAK,EAAE,MAAM,EACb,QAAQ,CAAC,EAAE,MAAM,EACjB,MAAM,CAAC,EAAE,MAAM,GACd,MAAM,CASR;AAMD,wBAAsB,mBAAmB,IAAI,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAchE;AAED;;;;;;;GAOG;AACH,wBAAsB,eAAe,CAAC,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAWrF;AAKD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAExD"}
package/dist/snowflake.js CHANGED
@@ -7,6 +7,8 @@
7
7
  * tool calls (stdio is single-user, so a connection pool is overkill).
8
8
  * - Reconnect transparently if the session has dropped.
9
9
  * - Provide a promisified query runner and safe identifier quoting.
10
+ * - Resolve a usable database, falling back to SNOWFLAKE_DATABASE when a
11
+ * caller passes one that isn't accessible.
10
12
  *
11
13
  * Auth modes (pick via SNOWFLAKE_AUTHENTICATOR, default = password):
12
14
  * - SNOWFLAKE : username + password
@@ -56,7 +58,11 @@ export function loadConfig() {
56
58
  role: process.env.SNOWFLAKE_ROLE?.trim(),
57
59
  queryTimeoutMs: Number(process.env.QUERY_TIMEOUT_MS || 60000),
58
60
  rowLimit: Number(process.env.SNOWFLAKE_ROW_LIMIT || 1000),
61
+ // Read-only by default. Only an explicit "false" opens up writes/DDL.
59
62
  readOnly: (process.env.SNOWFLAKE_READ_ONLY || "true").toLowerCase() !== "false",
63
+ // Cortex AI tools are on by default; set SNOWFLAKE_CORTEX_ENABLED=false to hide them.
64
+ cortexEnabled: (process.env.SNOWFLAKE_CORTEX_ENABLED || "true").toLowerCase() !== "false",
65
+ cortexModel: (process.env.SNOWFLAKE_CORTEX_MODEL || "llama3.1-8b").trim(),
60
66
  };
61
67
  }
62
68
  let cfg = null;
@@ -110,8 +116,6 @@ export async function runQuery(sqlText, binds = []) {
110
116
  connection.execute({
111
117
  sqlText,
112
118
  binds: binds,
113
- // Cap runaway queries at the configured timeout.
114
- // (SDK-level; server-side statement_timeout can also be set.)
115
119
  complete: (err, _stmt, r) => {
116
120
  if (err)
117
121
  reject(err);
@@ -154,6 +158,50 @@ export function qualify(table, database, schema) {
154
158
  parts.push(quoteIdent(table));
155
159
  return parts.join(".");
156
160
  }
161
+ // ── Database resolution ───────────────────────────────────────────────────────
162
+ // Cached uppercase set of databases the current role can actually see.
163
+ let dbCache = null;
164
+ export async function accessibleDatabases() {
165
+ if (dbCache && dbCache.size)
166
+ return dbCache;
167
+ const names = new Set();
168
+ try {
169
+ const { rows } = await runQuery("SHOW DATABASES");
170
+ for (const r of rows) {
171
+ const n = r.name;
172
+ if (typeof n === "string")
173
+ names.add(n.toUpperCase());
174
+ }
175
+ }
176
+ catch {
177
+ /* ignore — resolveDatabase will trust the caller's input below */
178
+ }
179
+ if (names.size)
180
+ dbCache = names;
181
+ return names;
182
+ }
183
+ /**
184
+ * Resolve the database a tool should use.
185
+ *
186
+ * If `requested` is provided but isn't an accessible database (e.g. an upstream
187
+ * caller sent a stray value that isn't a real database), fall back to the
188
+ * configured SNOWFLAKE_DATABASE instead of erroring — so tools self-correct on
189
+ * the first call rather than taking a failed detour.
190
+ */
191
+ export async function resolveDatabase(requested) {
192
+ const fallback = getConfig().database;
193
+ const wanted = requested?.trim();
194
+ if (!wanted)
195
+ return fallback;
196
+ const dbs = await accessibleDatabases();
197
+ // Couldn't enumerate databases — trust the caller, then the default.
198
+ if (dbs.size === 0)
199
+ return wanted || fallback;
200
+ if (dbs.has(wanted.toUpperCase()))
201
+ return wanted;
202
+ // Requested database isn't accessible — use the configured default.
203
+ return fallback || wanted;
204
+ }
157
205
  const WRITE_RE = /^\s*(INSERT|UPDATE|DELETE|MERGE|CREATE|DROP|ALTER|TRUNCATE|GRANT|REVOKE|CALL|COPY|PUT|REMOVE|USE)\b/i;
158
206
  export function isReadOnlyStatement(sql) {
159
207
  return !WRITE_RE.test(sql);
@@ -0,0 +1,18 @@
1
+ /**
2
+ * cortex.ts — Snowflake Cortex AI tools.
3
+ *
4
+ * These are additive on top of the fast SQL/discovery tools: Cortex functions
5
+ * are generative AI calls (token-billed, higher latency), so they are exposed as
6
+ * their own tools rather than wrapped around metadata operations.
7
+ *
8
+ * Every Cortex function here is invoked as a SELECT, so the tools remain fully
9
+ * compatible with SNOWFLAKE_READ_ONLY=true. The text-to-SQL tool additionally
10
+ * enforces read-only on the *generated* SQL regardless of server settings.
11
+ *
12
+ * Model is configurable via SNOWFLAKE_CORTEX_MODEL (default: llama3.1-8b).
13
+ * Availability depends on the account's region and the role holding the
14
+ * SNOWFLAKE.CORTEX_USER database role — errors are surfaced with a hint.
15
+ */
16
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ export declare function registerCortexTools(server: McpServer): void;
18
+ //# sourceMappingURL=cortex.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cortex.d.ts","sourceRoot":"","sources":["../../src/tools/cortex.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA+EzE,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA6L3D"}
@@ -0,0 +1,216 @@
1
+ import { z } from "zod";
2
+ import { getConfig, runQuery, quoteIdent, resolveDatabase, isReadOnlyStatement, } from "../snowflake.js";
3
+ function jsonText(value) {
4
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
5
+ }
6
+ function cortexErr(err) {
7
+ const raw = err instanceof Error ? err.message : String(err);
8
+ // Surface a helpful hint for the two most common Cortex setup failures.
9
+ const hintable = /cortex|not enabled|unknown function|privilege|not authorized|CORTEX_USER|region/i.test(raw);
10
+ const message = hintable
11
+ ? `${raw}\n\nHint: Cortex AI requires an account in a supported region and a role holding the SNOWFLAKE.CORTEX_USER database role (plus USE AI FUNCTIONS). Confirm both, or set SNOWFLAKE_CORTEX_ENABLED=false to hide these tools.`
12
+ : raw;
13
+ return {
14
+ content: [{ type: "text", text: JSON.stringify({ error: message }, null, 2) }],
15
+ isError: true,
16
+ };
17
+ }
18
+ // A Cortex model identifier is a simple slug — validate before inlining it.
19
+ function safeModel(model) {
20
+ const m = (model || getConfig().cortexModel).trim();
21
+ if (!/^[A-Za-z0-9._-]+$/.test(m)) {
22
+ throw new Error(`Invalid model name "${m}".`);
23
+ }
24
+ return m;
25
+ }
26
+ // Strip markdown fences and trailing semicolons; keep only the first statement.
27
+ function cleanSql(text) {
28
+ let s = text.trim();
29
+ s = s.replace(/^```(?:sql)?\s*/i, "").replace(/\s*```$/i, "").trim();
30
+ // Take up to the first semicolon so we execute a single statement.
31
+ const semi = s.indexOf(";");
32
+ if (semi !== -1)
33
+ s = s.slice(0, semi);
34
+ return s.trim();
35
+ }
36
+ // Build a compact schema description for the configured database/schema.
37
+ async function schemaContext(database, schema) {
38
+ const conditions = ["TABLE_SCHEMA <> 'INFORMATION_SCHEMA'"];
39
+ const binds = [];
40
+ if (schema) {
41
+ conditions[0] = "TABLE_SCHEMA = ?";
42
+ binds.push(schema);
43
+ }
44
+ const { rows } = await runQuery(`SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE ` +
45
+ `FROM ${quoteIdent(database)}.INFORMATION_SCHEMA.COLUMNS ` +
46
+ `WHERE ${conditions.join(" AND ")} ` +
47
+ `ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION ` +
48
+ `LIMIT 800`, binds);
49
+ const tables = new Map();
50
+ for (const r of rows) {
51
+ const o = r;
52
+ const key = `${o.TABLE_SCHEMA}.${o.TABLE_NAME}`;
53
+ if (!tables.has(key))
54
+ tables.set(key, []);
55
+ tables.get(key).push(`${o.COLUMN_NAME} ${o.DATA_TYPE}`);
56
+ }
57
+ const lines = [];
58
+ for (const [tbl, cols] of tables) {
59
+ lines.push(`${tbl} (${cols.join(", ")})`);
60
+ }
61
+ return lines.join("\n");
62
+ }
63
+ export function registerCortexTools(server) {
64
+ // ── cortex_ask: natural language → SQL → answer ─────────────────────────────
65
+ server.registerTool("cortex_ask", {
66
+ title: "Ask (Cortex text-to-SQL)",
67
+ description: "Answer a natural-language question about the data. Reads the schema, uses " +
68
+ "Cortex AI_COMPLETE to generate a read-only SQL query, runs it, and returns " +
69
+ "the rows plus the generated SQL. Best for analytical questions like " +
70
+ "'what is total revenue' or 'top 5 customers by orders'.",
71
+ inputSchema: {
72
+ question: z.string().describe("The natural-language question to answer."),
73
+ database: z.string().optional().describe("Defaults to SNOWFLAKE_DATABASE."),
74
+ schema: z.string().optional().describe("Defaults to SNOWFLAKE_SCHEMA."),
75
+ model: z.string().optional().describe("Cortex model. Defaults to SNOWFLAKE_CORTEX_MODEL."),
76
+ },
77
+ }, async (args) => {
78
+ try {
79
+ const c = getConfig();
80
+ const database = (await resolveDatabase(args.database)) || c.database;
81
+ const schema = args.schema || c.schema;
82
+ if (!database)
83
+ throw new Error("No database. Set SNOWFLAKE_DATABASE or pass `database`.");
84
+ const model = safeModel(args.model);
85
+ const ctx = await schemaContext(database, schema);
86
+ if (!ctx)
87
+ throw new Error(`No tables found in ${database}${schema ? "." + schema : ""}.`);
88
+ const prompt = `You are a Snowflake SQL expert. Using ONLY the schema below, write a single ` +
89
+ `read-only Snowflake SQL query (SELECT or WITH only) that answers the question. ` +
90
+ `Fully qualify tables as ${database}.<schema>.<table>. Return ONLY the SQL with no ` +
91
+ `explanation and no markdown fences.\n\nSchema:\n${ctx}\n\nQuestion: ${args.question}\n\nSQL:`;
92
+ const gen = await runQuery(`SELECT AI_COMPLETE('${model}', ?) AS SQL_TEXT`, [prompt]);
93
+ const generatedSql = cleanSql(String((gen.rows[0]?.SQL_TEXT ?? "")));
94
+ if (!generatedSql)
95
+ throw new Error("Model did not return any SQL.");
96
+ // Generated SQL is ALWAYS restricted to read-only, regardless of server config.
97
+ if (!isReadOnlyStatement(generatedSql)) {
98
+ return jsonText({
99
+ question: args.question,
100
+ generated_sql: generatedSql,
101
+ error: "Generated SQL was not read-only and was blocked. Rephrase the question.",
102
+ });
103
+ }
104
+ const result = await runQuery(generatedSql);
105
+ const capped = result.rows.slice(0, c.rowLimit);
106
+ return jsonText({
107
+ question: args.question,
108
+ generated_sql: generatedSql,
109
+ row_count: result.rowCount,
110
+ returned: capped.length,
111
+ truncated: result.rowCount > capped.length,
112
+ rows: capped,
113
+ });
114
+ }
115
+ catch (err) {
116
+ return cortexErr(err);
117
+ }
118
+ });
119
+ // ── cortex_complete: general LLM completion ─────────────────────────────────
120
+ server.registerTool("cortex_complete", {
121
+ title: "Complete (Cortex AI_COMPLETE)",
122
+ description: "Generate a free-form LLM completion for a prompt using Cortex AI_COMPLETE. " +
123
+ "Use for summaries, explanations, or generation that isn't a SQL query.",
124
+ inputSchema: {
125
+ prompt: z.string().describe("The prompt to send to the model."),
126
+ model: z.string().optional().describe("Cortex model. Defaults to SNOWFLAKE_CORTEX_MODEL."),
127
+ },
128
+ }, async (args) => {
129
+ try {
130
+ const model = safeModel(args.model);
131
+ const { rows } = await runQuery(`SELECT AI_COMPLETE('${model}', ?) AS RESPONSE`, [
132
+ args.prompt,
133
+ ]);
134
+ return jsonText({ model, response: rows[0]?.RESPONSE ?? null });
135
+ }
136
+ catch (err) {
137
+ return cortexErr(err);
138
+ }
139
+ });
140
+ // ── cortex_sentiment: sentiment of a text ───────────────────────────────────
141
+ server.registerTool("cortex_sentiment", {
142
+ title: "Sentiment (Cortex AI_SENTIMENT)",
143
+ description: "Return the sentiment of a text string using Cortex AI_SENTIMENT.",
144
+ inputSchema: { text: z.string().describe("The text to analyze.") },
145
+ }, async (args) => {
146
+ try {
147
+ const { rows } = await runQuery(`SELECT AI_SENTIMENT(?) AS SENTIMENT`, [args.text]);
148
+ return jsonText({ sentiment: rows[0]?.SENTIMENT ?? null });
149
+ }
150
+ catch (err) {
151
+ return cortexErr(err);
152
+ }
153
+ });
154
+ // ── cortex_summarize: summarize a text ──────────────────────────────────────
155
+ server.registerTool("cortex_summarize", {
156
+ title: "Summarize (Cortex SUMMARIZE)",
157
+ description: "Return a concise summary of a text string using Cortex.",
158
+ inputSchema: { text: z.string().describe("The text to summarize.") },
159
+ }, async (args) => {
160
+ try {
161
+ const { rows } = await runQuery(`SELECT SNOWFLAKE.CORTEX.SUMMARIZE(?) AS SUMMARY`, [
162
+ args.text,
163
+ ]);
164
+ return jsonText({ summary: rows[0]?.SUMMARY ?? null });
165
+ }
166
+ catch (err) {
167
+ return cortexErr(err);
168
+ }
169
+ });
170
+ // ── cortex_classify: classify into user categories ──────────────────────────
171
+ server.registerTool("cortex_classify", {
172
+ title: "Classify (Cortex AI_CLASSIFY)",
173
+ description: "Classify a text string into one of the provided categories using Cortex AI_CLASSIFY.",
174
+ inputSchema: {
175
+ text: z.string().describe("The text to classify."),
176
+ categories: z
177
+ .array(z.string())
178
+ .min(2)
179
+ .describe("Two or more category labels to choose from."),
180
+ },
181
+ }, async (args) => {
182
+ try {
183
+ // Build a SQL array literal of escaped string categories; bind the input.
184
+ const cats = args.categories
185
+ .map((x) => `'${String(x).replace(/'/g, "''")}'`)
186
+ .join(", ");
187
+ const { rows } = await runQuery(`SELECT AI_CLASSIFY(?, [${cats}]) AS CLASSIFICATION`, [args.text]);
188
+ return jsonText({ classification: rows[0]?.CLASSIFICATION ?? null });
189
+ }
190
+ catch (err) {
191
+ return cortexErr(err);
192
+ }
193
+ });
194
+ // ── cortex_translate: translate text ────────────────────────────────────────
195
+ server.registerTool("cortex_translate", {
196
+ title: "Translate (Cortex TRANSLATE)",
197
+ description: "Translate text to a target language using Cortex. Source language is " +
198
+ "auto-detected when omitted.",
199
+ inputSchema: {
200
+ text: z.string().describe("The text to translate."),
201
+ to_language: z.string().describe("Target language code, e.g. 'en', 'hi', 'es'."),
202
+ from_language: z
203
+ .string()
204
+ .optional()
205
+ .describe("Source language code. Empty = auto-detect."),
206
+ },
207
+ }, async (args) => {
208
+ try {
209
+ const { rows } = await runQuery(`SELECT SNOWFLAKE.CORTEX.TRANSLATE(?, ?, ?) AS TRANSLATION`, [args.text, args.from_language || "", args.to_language]);
210
+ return jsonText({ translation: rows[0]?.TRANSLATION ?? null });
211
+ }
212
+ catch (err) {
213
+ return cortexErr(err);
214
+ }
215
+ });
216
+ }
@@ -3,9 +3,9 @@
3
3
  *
4
4
  * `list_tables` is registered first and follows the platform's double-wrapped
5
5
  * envelope contract (see listTables.ts). The remaining tools are standard
6
- * SQL-database helpers that return clean JSON rows, mirroring the shape of the
7
- * oracle-sqlplus-mcp server (test_connection, describe_table, get_table_sample,
8
- * execute_query, list_schemas, ...).
6
+ * SQL-database helpers (test_connection, describe_table, get_table_sample,
7
+ * execute_query, list_schemas, ...). Cortex AI tools are registered last and
8
+ * only when enabled.
9
9
  */
10
10
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
11
11
  export declare function registerTools(server: McpServer): void;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/tools/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA+BzE,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAqNrD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/tools/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AAiCzE,wBAAgB,aAAa,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA2NrD"}
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
- import { getConfig, runQuery, quoteIdent, qualify, isReadOnlyStatement, } from "../snowflake.js";
2
+ import { getConfig, runQuery, quoteIdent, qualify, resolveDatabase, isReadOnlyStatement, } from "../snowflake.js";
3
3
  import { registerListTables } from "./listTables.js";
4
+ import { registerCortexTools } from "./cortex.js";
4
5
  function jsonText(value) {
5
6
  return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
6
7
  }
@@ -65,7 +66,7 @@ export function registerTools(server) {
65
66
  },
66
67
  }, async (args) => {
67
68
  try {
68
- const db = args.database || getConfig().database;
69
+ const db = (await resolveDatabase(args.database)) || getConfig().database;
69
70
  if (!db)
70
71
  throw new Error("No database. Set SNOWFLAKE_DATABASE or pass `database`.");
71
72
  const { rows } = await runQuery(`SELECT SCHEMA_NAME FROM ${quoteIdent(db)}.INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME`);
@@ -104,7 +105,7 @@ export function registerTools(server) {
104
105
  }, async (args) => {
105
106
  try {
106
107
  const c = getConfig();
107
- const db = args.database || c.database;
108
+ const db = (await resolveDatabase(args.database)) || c.database;
108
109
  const sc = args.schema || c.schema;
109
110
  if (!db)
110
111
  throw new Error("No database. Set SNOWFLAKE_DATABASE or pass `database`.");
@@ -140,7 +141,8 @@ export function registerTools(server) {
140
141
  try {
141
142
  const c = getConfig();
142
143
  const limit = Math.min(args.limit ?? 10, c.rowLimit);
143
- const fqn = qualify(args.table, args.database, args.schema);
144
+ const db = (await resolveDatabase(args.database)) || c.database;
145
+ const fqn = qualify(args.table, db, args.schema);
144
146
  const { rows } = await runQuery(`SELECT * FROM ${fqn} LIMIT ${limit}`);
145
147
  return jsonText(rows);
146
148
  }
@@ -177,4 +179,8 @@ export function registerTools(server) {
177
179
  return errText(err);
178
180
  }
179
181
  });
182
+ // 9) Cortex AI tools (opt-out via SNOWFLAKE_CORTEX_ENABLED=false).
183
+ if (getConfig().cortexEnabled) {
184
+ registerCortexTools(server);
185
+ }
180
186
  }
@@ -1,20 +1,19 @@
1
1
  /**
2
2
  * listTables.ts — table-discovery tool for the Analytics Model platform.
3
3
  *
4
- * The platform's MCP connector flow (fetchMcpTables) expects every MCP server
5
- * to expose a `list_tables` tool whose result text, once unwrapped, yields a
6
- * flat array of { table_name } objects.
4
+ * The platform's connector flow expects a `list_tables` tool whose result text,
5
+ * once unwrapped, yields a flat array of { table_name } objects.
7
6
  *
8
- * OUTPUT SHAPE — copied byte-for-byte from the working Shopify / Clio servers.
9
- * The platform parser expects:
7
+ * OUTPUT SHAPE — the platform parser expects:
10
8
  * 1. The tool result text is JSON.stringify of an object with a `data` field.
11
9
  * 2. `data` is ITSELF a JSON string of the [{ table_name }] array.
12
10
  * i.e. the array is DOUBLE-stringified. Do not "simplify" this to a raw array
13
11
  * or the backend parse fails and the table dropdown stays empty.
14
12
  *
15
- * Unlike Clio (a REST API with fixed resource categories), Snowflake is a real
16
- * database, so we query INFORMATION_SCHEMA for the live table list scoped to the
17
- * connection's database/schema (overridable via arguments).
13
+ * Snowflake is a real database, so the live table list is read from
14
+ * INFORMATION_SCHEMA, scoped to the connection's database/schema (overridable
15
+ * via arguments). If a caller passes a database that isn't accessible, the tool
16
+ * falls back to the configured SNOWFLAKE_DATABASE.
18
17
  */
19
18
  import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
20
19
  export declare function registerListTables(server: McpServer): void;
@@ -1 +1 @@
1
- {"version":3,"file":"listTables.d.ts","sourceRoot":"","sources":["../../src/tools/listTables.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA0BzE,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CAyF1D"}
1
+ {"version":3,"file":"listTables.d.ts","sourceRoot":"","sources":["../../src/tools/listTables.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AA0BzE,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,SAAS,GAAG,IAAI,CA0F1D"}
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { getConfig, runQuery, quoteIdent } from "../snowflake.js";
2
+ import { getConfig, runQuery, quoteIdent, resolveDatabase } from "../snowflake.js";
3
3
  function envelope(tableNames, requestedPayload, message) {
4
4
  return {
5
5
  is_success: true,
@@ -31,7 +31,8 @@ export function registerListTables(server) {
31
31
  },
32
32
  }, async (args) => {
33
33
  const c = getConfig();
34
- const database = args.database || c.database;
34
+ // Resolve to an accessible database, falling back to SNOWFLAKE_DATABASE.
35
+ const database = (await resolveDatabase(args.database)) || c.database;
35
36
  const schema = args.schema || c.schema;
36
37
  const includeViews = args.include_views !== false;
37
38
  const requestedPayload = { database, schema, include_views: includeViews };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "snowflake-analytics-mcp-server",
3
- "version": "0.1.0",
4
- "description": "Snowflake MCP server for the Analytics Model platform. Exposes list_tables, describe_table, query and schema-discovery tools over stdio, runnable via npx.",
3
+ "version": "0.2.0",
4
+ "description": "Snowflake MCP server for the Analytics Model platform. Table discovery, SQL query, and Cortex AI tools over stdio, runnable via npx.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
7
7
  "bin": {
@@ -35,4 +35,4 @@
35
35
  "@types/snowflake-sdk": "^1.6.24",
36
36
  "typescript": "^7.0.2"
37
37
  }
38
- }
38
+ }