snowflake-analytics-mcp-server 0.1.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 +110 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/snowflake.d.ts +37 -0
- package/dist/snowflake.d.ts.map +1 -0
- package/dist/snowflake.js +160 -0
- package/dist/tools/index.d.ts +12 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +180 -0
- package/dist/tools/listTables.d.ts +21 -0
- package/dist/tools/listTables.d.ts.map +1 -0
- package/dist/tools/listTables.js +83 -0
- package/package.json +38 -0
package/README.md
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# snowflake-analytics-mcp-server
|
|
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.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g snowflake-analytics-mcp-server
|
|
11
|
+
# or run directly (no install):
|
|
12
|
+
npx -y snowflake-analytics-mcp-server
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Configuration
|
|
16
|
+
|
|
17
|
+
All credentials come from environment variables.
|
|
18
|
+
|
|
19
|
+
| Variable | Required | Default | Description |
|
|
20
|
+
|---|---|---|---|
|
|
21
|
+
| `SNOWFLAKE_ACCOUNT` | ✅ | — | Account identifier, e.g. `xy12345.eu-central-1` or `orgname-account_name` |
|
|
22
|
+
| `SNOWFLAKE_USERNAME` | ✅ | — | Login name |
|
|
23
|
+
| `SNOWFLAKE_PASSWORD` | ✅ (password auth) | — | Password (required unless using key-pair/OAuth) |
|
|
24
|
+
| `SNOWFLAKE_WAREHOUSE` | — | — | Virtual warehouse to use |
|
|
25
|
+
| `SNOWFLAKE_DATABASE` | recommended | — | Default database (used by `list_tables` when no arg is given) |
|
|
26
|
+
| `SNOWFLAKE_SCHEMA` | recommended | — | Default schema |
|
|
27
|
+
| `SNOWFLAKE_ROLE` | — | — | Role to assume |
|
|
28
|
+
| `SNOWFLAKE_AUTHENTICATOR` | — | `SNOWFLAKE` | `SNOWFLAKE` (password), `SNOWFLAKE_JWT` (key-pair), `OAUTH` |
|
|
29
|
+
| `SNOWFLAKE_PRIVATE_KEY` | key-pair | — | PEM private key contents |
|
|
30
|
+
| `SNOWFLAKE_PRIVATE_KEY_PATH` | key-pair | — | Path to a PEM private key file (alternative to above) |
|
|
31
|
+
| `SNOWFLAKE_PRIVATE_KEY_PASSPHRASE` | — | — | Passphrase, if the key is encrypted |
|
|
32
|
+
| `SNOWFLAKE_TOKEN` | OAuth | — | OAuth access token (with `SNOWFLAKE_AUTHENTICATOR=OAUTH`) |
|
|
33
|
+
| `SNOWFLAKE_READ_ONLY` | — | `true` | Blocks writes/DDL in `execute_query`. Set `false` to allow them |
|
|
34
|
+
| `SNOWFLAKE_ROW_LIMIT` | — | `1000` | Hard cap on returned rows |
|
|
35
|
+
| `QUERY_TIMEOUT_MS` | — | `60000` | Client-side query timeout |
|
|
36
|
+
|
|
37
|
+
### Account identifier tip
|
|
38
|
+
|
|
39
|
+
Use hyphens, not underscores, in the account identifier if a client has trouble connecting (e.g. `orgname-account-name`).
|
|
40
|
+
|
|
41
|
+
## Claude Desktop config
|
|
42
|
+
|
|
43
|
+
Add to `claude_desktop_config.json`:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"mcpServers": {
|
|
48
|
+
"snowflake": {
|
|
49
|
+
"command": "npx",
|
|
50
|
+
"args": ["-y", "snowflake-analytics-mcp-server"],
|
|
51
|
+
"env": {
|
|
52
|
+
"SNOWFLAKE_ACCOUNT": "xy12345.eu-central-1",
|
|
53
|
+
"SNOWFLAKE_USERNAME": "ANALYTICS_USER",
|
|
54
|
+
"SNOWFLAKE_PASSWORD": "••••••••",
|
|
55
|
+
"SNOWFLAKE_WAREHOUSE": "COMPUTE_WH",
|
|
56
|
+
"SNOWFLAKE_DATABASE": "ANALYTICS_DB",
|
|
57
|
+
"SNOWFLAKE_SCHEMA": "PUBLIC",
|
|
58
|
+
"SNOWFLAKE_ROLE": "ANALYST"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### Key-pair auth example
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
"env": {
|
|
69
|
+
"SNOWFLAKE_ACCOUNT": "xy12345.eu-central-1",
|
|
70
|
+
"SNOWFLAKE_USERNAME": "ANALYTICS_USER",
|
|
71
|
+
"SNOWFLAKE_AUTHENTICATOR": "SNOWFLAKE_JWT",
|
|
72
|
+
"SNOWFLAKE_PRIVATE_KEY_PATH": "/path/to/rsa_key.p8",
|
|
73
|
+
"SNOWFLAKE_PRIVATE_KEY_PASSPHRASE": "••••••••",
|
|
74
|
+
"SNOWFLAKE_DATABASE": "ANALYTICS_DB",
|
|
75
|
+
"SNOWFLAKE_SCHEMA": "PUBLIC"
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Tools
|
|
80
|
+
|
|
81
|
+
| Tool | Args | Description |
|
|
82
|
+
|---|---|---|
|
|
83
|
+
| `list_tables` | `database?`, `schema?`, `include_views?` | **Platform contract.** Returns `{ table_name }[]` in the double-wrapped envelope for `fetchMcpTables` |
|
|
84
|
+
| `test_connection` | — | Returns version, account, user, role, warehouse, database, schema |
|
|
85
|
+
| `list_databases` | — | Databases visible to the current role |
|
|
86
|
+
| `list_schemas` | `database?` | Schemas in a database |
|
|
87
|
+
| `list_warehouses` | — | Warehouses with size/state |
|
|
88
|
+
| `describe_table` | `table`, `database?`, `schema?` | Columns, types, nullability, defaults |
|
|
89
|
+
| `get_table_sample` | `table`, `database?`, `schema?`, `limit?` | Preview rows from a table |
|
|
90
|
+
| `execute_query` | `query` | Run SQL. Read-only unless `SNOWFLAKE_READ_ONLY=false` |
|
|
91
|
+
|
|
92
|
+
## Platform integration note
|
|
93
|
+
|
|
94
|
+
`list_tables` matches the confirmed envelope used by the Shopify/Clio servers:
|
|
95
|
+
|
|
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
|
+
```
|
|
105
|
+
|
|
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.
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":""}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* snowflake-analytics-mcp-server
|
|
4
|
+
*
|
|
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).
|
|
11
|
+
*/
|
|
12
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
import { registerTools } from "./tools/index.js";
|
|
15
|
+
async function main() {
|
|
16
|
+
const server = new McpServer({
|
|
17
|
+
name: "snowflake-analytics-mcp-server",
|
|
18
|
+
version: "0.1.0",
|
|
19
|
+
title: "Snowflake Analytics MCP Server",
|
|
20
|
+
});
|
|
21
|
+
registerTools(server);
|
|
22
|
+
const transport = new StdioServerTransport();
|
|
23
|
+
await server.connect(transport);
|
|
24
|
+
// Log to stderr only — stdout is reserved for the MCP protocol stream.
|
|
25
|
+
process.stderr.write("snowflake-analytics-mcp-server running on stdio\n");
|
|
26
|
+
}
|
|
27
|
+
main().catch((err) => {
|
|
28
|
+
process.stderr.write(`Fatal: ${err instanceof Error ? err.stack || err.message : String(err)}\n`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export interface SnowflakeConfig {
|
|
2
|
+
account: string;
|
|
3
|
+
username: string;
|
|
4
|
+
password?: string;
|
|
5
|
+
authenticator: string;
|
|
6
|
+
token?: string;
|
|
7
|
+
privateKey?: string;
|
|
8
|
+
privateKeyPass?: string;
|
|
9
|
+
warehouse?: string;
|
|
10
|
+
database?: string;
|
|
11
|
+
schema?: string;
|
|
12
|
+
role?: string;
|
|
13
|
+
queryTimeoutMs: number;
|
|
14
|
+
rowLimit: number;
|
|
15
|
+
readOnly: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare function loadConfig(): SnowflakeConfig;
|
|
18
|
+
export declare function getConfig(): SnowflakeConfig;
|
|
19
|
+
export interface QueryResult {
|
|
20
|
+
rows: Record<string, unknown>[];
|
|
21
|
+
rowCount: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function runQuery(sqlText: string, binds?: unknown[]): Promise<QueryResult>;
|
|
24
|
+
/**
|
|
25
|
+
* Validate and quote a Snowflake identifier (database / schema / table / column).
|
|
26
|
+
* Snowflake identifiers may contain letters, digits, underscores and $ .
|
|
27
|
+
* Anything else is rejected to prevent SQL injection through identifier slots.
|
|
28
|
+
* The result is wrapped in double quotes with internal quotes doubled.
|
|
29
|
+
*/
|
|
30
|
+
export declare function quoteIdent(name: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Build a fully-qualified name from optional database/schema plus a table.
|
|
33
|
+
* Falls back to the connection's configured database/schema when omitted.
|
|
34
|
+
*/
|
|
35
|
+
export declare function qualify(table: string, database?: string, schema?: string): string;
|
|
36
|
+
export declare function isReadOnlyStatement(sql: string): boolean;
|
|
37
|
+
//# sourceMappingURL=snowflake.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* snowflake.ts — thin wrapper around the official `snowflake-sdk` driver.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Read connection config from environment variables.
|
|
6
|
+
* - Maintain a single, lazily-established connection that is reused across
|
|
7
|
+
* tool calls (stdio is single-user, so a connection pool is overkill).
|
|
8
|
+
* - Reconnect transparently if the session has dropped.
|
|
9
|
+
* - Provide a promisified query runner and safe identifier quoting.
|
|
10
|
+
*
|
|
11
|
+
* Auth modes (pick via SNOWFLAKE_AUTHENTICATOR, default = password):
|
|
12
|
+
* - SNOWFLAKE : username + password
|
|
13
|
+
* - SNOWFLAKE_JWT : key-pair (SNOWFLAKE_PRIVATE_KEY or _PATH, optional passphrase)
|
|
14
|
+
* - OAUTH : username + SNOWFLAKE_TOKEN
|
|
15
|
+
* - EXTERNALBROWSER : interactive SSO (not usable under stdio automation)
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import snowflake from "snowflake-sdk";
|
|
19
|
+
// Keep the driver quiet — its default logger writes to stdout, which would
|
|
20
|
+
// corrupt the MCP stdio stream. Route everything to stderr at ERROR level.
|
|
21
|
+
snowflake.configure({ logLevel: "ERROR" });
|
|
22
|
+
function req(name) {
|
|
23
|
+
const v = process.env[name];
|
|
24
|
+
if (!v || !v.trim()) {
|
|
25
|
+
throw new Error(`Missing required environment variable: ${name}`);
|
|
26
|
+
}
|
|
27
|
+
return v.trim();
|
|
28
|
+
}
|
|
29
|
+
export function loadConfig() {
|
|
30
|
+
const authenticator = (process.env.SNOWFLAKE_AUTHENTICATOR || "SNOWFLAKE")
|
|
31
|
+
.trim()
|
|
32
|
+
.toUpperCase();
|
|
33
|
+
let privateKey;
|
|
34
|
+
if (authenticator === "SNOWFLAKE_JWT") {
|
|
35
|
+
if (process.env.SNOWFLAKE_PRIVATE_KEY) {
|
|
36
|
+
privateKey = process.env.SNOWFLAKE_PRIVATE_KEY;
|
|
37
|
+
}
|
|
38
|
+
else if (process.env.SNOWFLAKE_PRIVATE_KEY_PATH) {
|
|
39
|
+
privateKey = readFileSync(process.env.SNOWFLAKE_PRIVATE_KEY_PATH.trim(), "utf8");
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
throw new Error("SNOWFLAKE_AUTHENTICATOR=SNOWFLAKE_JWT requires SNOWFLAKE_PRIVATE_KEY or SNOWFLAKE_PRIVATE_KEY_PATH");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
account: req("SNOWFLAKE_ACCOUNT"),
|
|
47
|
+
username: req("SNOWFLAKE_USERNAME"),
|
|
48
|
+
password: authenticator === "SNOWFLAKE" ? req("SNOWFLAKE_PASSWORD") : process.env.SNOWFLAKE_PASSWORD?.trim(),
|
|
49
|
+
authenticator,
|
|
50
|
+
token: process.env.SNOWFLAKE_TOKEN?.trim(),
|
|
51
|
+
privateKey,
|
|
52
|
+
privateKeyPass: process.env.SNOWFLAKE_PRIVATE_KEY_PASSPHRASE?.trim(),
|
|
53
|
+
warehouse: process.env.SNOWFLAKE_WAREHOUSE?.trim(),
|
|
54
|
+
database: process.env.SNOWFLAKE_DATABASE?.trim(),
|
|
55
|
+
schema: process.env.SNOWFLAKE_SCHEMA?.trim(),
|
|
56
|
+
role: process.env.SNOWFLAKE_ROLE?.trim(),
|
|
57
|
+
queryTimeoutMs: Number(process.env.QUERY_TIMEOUT_MS || 60000),
|
|
58
|
+
rowLimit: Number(process.env.SNOWFLAKE_ROW_LIMIT || 1000),
|
|
59
|
+
readOnly: (process.env.SNOWFLAKE_READ_ONLY || "true").toLowerCase() !== "false",
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
let cfg = null;
|
|
63
|
+
let conn = null;
|
|
64
|
+
export function getConfig() {
|
|
65
|
+
if (!cfg)
|
|
66
|
+
cfg = loadConfig();
|
|
67
|
+
return cfg;
|
|
68
|
+
}
|
|
69
|
+
function buildConnection(c) {
|
|
70
|
+
const opts = {
|
|
71
|
+
account: c.account,
|
|
72
|
+
username: c.username,
|
|
73
|
+
authenticator: c.authenticator,
|
|
74
|
+
clientSessionKeepAlive: true,
|
|
75
|
+
application: "AnalyticsModel_MCP",
|
|
76
|
+
};
|
|
77
|
+
if (c.password)
|
|
78
|
+
opts.password = c.password;
|
|
79
|
+
if (c.token)
|
|
80
|
+
opts.token = c.token;
|
|
81
|
+
if (c.privateKey)
|
|
82
|
+
opts.privateKey = c.privateKey;
|
|
83
|
+
if (c.privateKeyPass)
|
|
84
|
+
opts.privateKeyPass = c.privateKeyPass;
|
|
85
|
+
if (c.warehouse)
|
|
86
|
+
opts.warehouse = c.warehouse;
|
|
87
|
+
if (c.database)
|
|
88
|
+
opts.database = c.database;
|
|
89
|
+
if (c.schema)
|
|
90
|
+
opts.schema = c.schema;
|
|
91
|
+
if (c.role)
|
|
92
|
+
opts.role = c.role;
|
|
93
|
+
return snowflake.createConnection(opts);
|
|
94
|
+
}
|
|
95
|
+
async function ensureConnection() {
|
|
96
|
+
const c = getConfig();
|
|
97
|
+
if (conn && conn.isUp())
|
|
98
|
+
return conn;
|
|
99
|
+
const connection = buildConnection(c);
|
|
100
|
+
await new Promise((resolve, reject) => {
|
|
101
|
+
connection.connect((err) => (err ? reject(err) : resolve()));
|
|
102
|
+
});
|
|
103
|
+
conn = connection;
|
|
104
|
+
return conn;
|
|
105
|
+
}
|
|
106
|
+
export async function runQuery(sqlText, binds = []) {
|
|
107
|
+
const c = getConfig();
|
|
108
|
+
const connection = await ensureConnection();
|
|
109
|
+
const rows = await new Promise((resolve, reject) => {
|
|
110
|
+
connection.execute({
|
|
111
|
+
sqlText,
|
|
112
|
+
binds: binds,
|
|
113
|
+
// Cap runaway queries at the configured timeout.
|
|
114
|
+
// (SDK-level; server-side statement_timeout can also be set.)
|
|
115
|
+
complete: (err, _stmt, r) => {
|
|
116
|
+
if (err)
|
|
117
|
+
reject(err);
|
|
118
|
+
else
|
|
119
|
+
resolve(r || []);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
// Best-effort client-side timeout guard.
|
|
123
|
+
setTimeout(() => {
|
|
124
|
+
/* no-op: snowflake-sdk resolves via complete; this keeps types simple */
|
|
125
|
+
}, c.queryTimeoutMs).unref?.();
|
|
126
|
+
});
|
|
127
|
+
return { rows, rowCount: rows.length };
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Validate and quote a Snowflake identifier (database / schema / table / column).
|
|
131
|
+
* Snowflake identifiers may contain letters, digits, underscores and $ .
|
|
132
|
+
* Anything else is rejected to prevent SQL injection through identifier slots.
|
|
133
|
+
* The result is wrapped in double quotes with internal quotes doubled.
|
|
134
|
+
*/
|
|
135
|
+
export function quoteIdent(name) {
|
|
136
|
+
if (!/^[A-Za-z0-9_$]+$/.test(name)) {
|
|
137
|
+
throw new Error(`Invalid identifier "${name}". Only letters, digits, _ and $ are allowed.`);
|
|
138
|
+
}
|
|
139
|
+
return `"${name.replace(/"/g, '""')}"`;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Build a fully-qualified name from optional database/schema plus a table.
|
|
143
|
+
* Falls back to the connection's configured database/schema when omitted.
|
|
144
|
+
*/
|
|
145
|
+
export function qualify(table, database, schema) {
|
|
146
|
+
const c = getConfig();
|
|
147
|
+
const db = database || c.database;
|
|
148
|
+
const sc = schema || c.schema;
|
|
149
|
+
const parts = [];
|
|
150
|
+
if (db)
|
|
151
|
+
parts.push(quoteIdent(db));
|
|
152
|
+
if (sc)
|
|
153
|
+
parts.push(quoteIdent(sc));
|
|
154
|
+
parts.push(quoteIdent(table));
|
|
155
|
+
return parts.join(".");
|
|
156
|
+
}
|
|
157
|
+
const WRITE_RE = /^\s*(INSERT|UPDATE|DELETE|MERGE|CREATE|DROP|ALTER|TRUNCATE|GRANT|REVOKE|CALL|COPY|PUT|REMOVE|USE)\b/i;
|
|
158
|
+
export function isReadOnlyStatement(sql) {
|
|
159
|
+
return !WRITE_RE.test(sql);
|
|
160
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tools/index.ts — registers every tool on the MCP server.
|
|
3
|
+
*
|
|
4
|
+
* `list_tables` is registered first and follows the platform's double-wrapped
|
|
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, ...).
|
|
9
|
+
*/
|
|
10
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
11
|
+
export declare function registerTools(server: McpServer): void;
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getConfig, runQuery, quoteIdent, qualify, isReadOnlyStatement, } from "../snowflake.js";
|
|
3
|
+
import { registerListTables } from "./listTables.js";
|
|
4
|
+
function jsonText(value) {
|
|
5
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }] };
|
|
6
|
+
}
|
|
7
|
+
function errText(err) {
|
|
8
|
+
return {
|
|
9
|
+
content: [
|
|
10
|
+
{
|
|
11
|
+
type: "text",
|
|
12
|
+
text: JSON.stringify({ error: err instanceof Error ? err.message : String(err) }, null, 2),
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
isError: true,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export function registerTools(server) {
|
|
19
|
+
// 1) Platform-contract table discovery.
|
|
20
|
+
registerListTables(server);
|
|
21
|
+
// 2) Connectivity / context probe.
|
|
22
|
+
server.registerTool("test_connection", {
|
|
23
|
+
title: "Test connection",
|
|
24
|
+
description: "Verify connectivity and return the current Snowflake version, account, user, role, warehouse, database and schema.",
|
|
25
|
+
inputSchema: {},
|
|
26
|
+
}, async () => {
|
|
27
|
+
try {
|
|
28
|
+
const { rows } = await runQuery("SELECT CURRENT_VERSION() AS VERSION, CURRENT_ACCOUNT() AS ACCOUNT, " +
|
|
29
|
+
"CURRENT_USER() AS \"USER\", CURRENT_ROLE() AS ROLE, " +
|
|
30
|
+
"CURRENT_WAREHOUSE() AS WAREHOUSE, CURRENT_DATABASE() AS DATABASE, " +
|
|
31
|
+
"CURRENT_SCHEMA() AS SCHEMA");
|
|
32
|
+
return jsonText({ connected: true, ...(rows[0] || {}) });
|
|
33
|
+
}
|
|
34
|
+
catch (err) {
|
|
35
|
+
return errText(err);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
// 3) List databases.
|
|
39
|
+
server.registerTool("list_databases", {
|
|
40
|
+
title: "List databases",
|
|
41
|
+
description: "List all databases the current role can access.",
|
|
42
|
+
inputSchema: {},
|
|
43
|
+
}, async () => {
|
|
44
|
+
try {
|
|
45
|
+
const { rows } = await runQuery("SELECT DATABASE_NAME FROM SNOWFLAKE.INFORMATION_SCHEMA.DATABASES ORDER BY DATABASE_NAME");
|
|
46
|
+
return jsonText(rows.map((r) => r.DATABASE_NAME ?? r.database_name).filter(Boolean));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Fallback for roles without SNOWFLAKE db access.
|
|
50
|
+
try {
|
|
51
|
+
const { rows } = await runQuery("SHOW DATABASES");
|
|
52
|
+
return jsonText(rows.map((r) => r.name));
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
return errText(err);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
// 4) List schemas.
|
|
60
|
+
server.registerTool("list_schemas", {
|
|
61
|
+
title: "List schemas",
|
|
62
|
+
description: "List schemas in a database. Defaults to SNOWFLAKE_DATABASE when `database` is omitted.",
|
|
63
|
+
inputSchema: {
|
|
64
|
+
database: z.string().optional().describe("Database name. Defaults to SNOWFLAKE_DATABASE."),
|
|
65
|
+
},
|
|
66
|
+
}, async (args) => {
|
|
67
|
+
try {
|
|
68
|
+
const db = args.database || getConfig().database;
|
|
69
|
+
if (!db)
|
|
70
|
+
throw new Error("No database. Set SNOWFLAKE_DATABASE or pass `database`.");
|
|
71
|
+
const { rows } = await runQuery(`SELECT SCHEMA_NAME FROM ${quoteIdent(db)}.INFORMATION_SCHEMA.SCHEMATA ORDER BY SCHEMA_NAME`);
|
|
72
|
+
return jsonText(rows.map((r) => r.SCHEMA_NAME ?? r.schema_name).filter(Boolean));
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
return errText(err);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
// 5) List warehouses.
|
|
79
|
+
server.registerTool("list_warehouses", {
|
|
80
|
+
title: "List warehouses",
|
|
81
|
+
description: "List virtual warehouses the current role can see, with size and state.",
|
|
82
|
+
inputSchema: {},
|
|
83
|
+
}, async () => {
|
|
84
|
+
try {
|
|
85
|
+
const { rows } = await runQuery("SHOW WAREHOUSES");
|
|
86
|
+
return jsonText(rows.map((r) => {
|
|
87
|
+
const o = r;
|
|
88
|
+
return { name: o.name, size: o.size, state: o.state, type: o.type };
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return errText(err);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
// 6) Describe a table's columns.
|
|
96
|
+
server.registerTool("describe_table", {
|
|
97
|
+
title: "Describe table",
|
|
98
|
+
description: "Return column names, data types, nullability and defaults for a table (or view).",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
table: z.string().describe("Table name (unqualified)."),
|
|
101
|
+
database: z.string().optional().describe("Defaults to SNOWFLAKE_DATABASE."),
|
|
102
|
+
schema: z.string().optional().describe("Defaults to SNOWFLAKE_SCHEMA."),
|
|
103
|
+
},
|
|
104
|
+
}, async (args) => {
|
|
105
|
+
try {
|
|
106
|
+
const c = getConfig();
|
|
107
|
+
const db = args.database || c.database;
|
|
108
|
+
const sc = args.schema || c.schema;
|
|
109
|
+
if (!db)
|
|
110
|
+
throw new Error("No database. Set SNOWFLAKE_DATABASE or pass `database`.");
|
|
111
|
+
const conditions = ["TABLE_NAME = ?"];
|
|
112
|
+
const binds = [args.table];
|
|
113
|
+
if (sc) {
|
|
114
|
+
conditions.push("TABLE_SCHEMA = ?");
|
|
115
|
+
binds.push(sc);
|
|
116
|
+
}
|
|
117
|
+
const { rows } = await runQuery(`SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, COLUMN_DEFAULT, ` +
|
|
118
|
+
`CHARACTER_MAXIMUM_LENGTH, NUMERIC_PRECISION, NUMERIC_SCALE ` +
|
|
119
|
+
`FROM ${quoteIdent(db)}.INFORMATION_SCHEMA.COLUMNS ` +
|
|
120
|
+
`WHERE ${conditions.join(" AND ")} ORDER BY ORDINAL_POSITION`, binds);
|
|
121
|
+
if (!rows.length)
|
|
122
|
+
throw new Error(`Table "${args.table}" not found or no columns visible.`);
|
|
123
|
+
return jsonText(rows);
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
return errText(err);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
// 7) Sample rows from a table.
|
|
130
|
+
server.registerTool("get_table_sample", {
|
|
131
|
+
title: "Get table sample",
|
|
132
|
+
description: "Fetch a sample of rows from a table. Use this to preview data for a selected table.",
|
|
133
|
+
inputSchema: {
|
|
134
|
+
table: z.string().describe("Table name (unqualified)."),
|
|
135
|
+
database: z.string().optional().describe("Defaults to SNOWFLAKE_DATABASE."),
|
|
136
|
+
schema: z.string().optional().describe("Defaults to SNOWFLAKE_SCHEMA."),
|
|
137
|
+
limit: z.number().int().positive().optional().describe("Row limit. Default 10."),
|
|
138
|
+
},
|
|
139
|
+
}, async (args) => {
|
|
140
|
+
try {
|
|
141
|
+
const c = getConfig();
|
|
142
|
+
const limit = Math.min(args.limit ?? 10, c.rowLimit);
|
|
143
|
+
const fqn = qualify(args.table, args.database, args.schema);
|
|
144
|
+
const { rows } = await runQuery(`SELECT * FROM ${fqn} LIMIT ${limit}`);
|
|
145
|
+
return jsonText(rows);
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
return errText(err);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
// 8) Run an arbitrary query (read-only unless SNOWFLAKE_READ_ONLY=false).
|
|
152
|
+
server.registerTool("execute_query", {
|
|
153
|
+
title: "Execute query",
|
|
154
|
+
description: "Run a SQL query and return rows as JSON. Read-only by default (SELECT/SHOW/DESCRIBE/WITH). " +
|
|
155
|
+
"Set SNOWFLAKE_READ_ONLY=false to allow writes/DDL.",
|
|
156
|
+
inputSchema: {
|
|
157
|
+
query: z.string().describe("The SQL statement to execute."),
|
|
158
|
+
},
|
|
159
|
+
}, async (args) => {
|
|
160
|
+
try {
|
|
161
|
+
const c = getConfig();
|
|
162
|
+
if (c.readOnly && !isReadOnlyStatement(args.query)) {
|
|
163
|
+
throw new Error("Write/DDL statements are blocked. The server is read-only " +
|
|
164
|
+
"(set SNOWFLAKE_READ_ONLY=false to allow them).");
|
|
165
|
+
}
|
|
166
|
+
const { rows, rowCount } = await runQuery(args.query);
|
|
167
|
+
// Guard against dumping enormous result sets into the context window.
|
|
168
|
+
const capped = rows.slice(0, c.rowLimit);
|
|
169
|
+
return jsonText({
|
|
170
|
+
row_count: rowCount,
|
|
171
|
+
returned: capped.length,
|
|
172
|
+
truncated: rowCount > capped.length,
|
|
173
|
+
rows: capped,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
return errText(err);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* listTables.ts — table-discovery tool for the Analytics Model platform.
|
|
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.
|
|
7
|
+
*
|
|
8
|
+
* OUTPUT SHAPE — copied byte-for-byte from the working Shopify / Clio servers.
|
|
9
|
+
* The platform parser expects:
|
|
10
|
+
* 1. The tool result text is JSON.stringify of an object with a `data` field.
|
|
11
|
+
* 2. `data` is ITSELF a JSON string of the [{ table_name }] array.
|
|
12
|
+
* i.e. the array is DOUBLE-stringified. Do not "simplify" this to a raw array
|
|
13
|
+
* or the backend parse fails and the table dropdown stays empty.
|
|
14
|
+
*
|
|
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).
|
|
18
|
+
*/
|
|
19
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
20
|
+
export declare function registerListTables(server: McpServer): void;
|
|
21
|
+
//# sourceMappingURL=listTables.d.ts.map
|
|
@@ -0,0 +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"}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getConfig, runQuery, quoteIdent } from "../snowflake.js";
|
|
3
|
+
function envelope(tableNames, requestedPayload, message) {
|
|
4
|
+
return {
|
|
5
|
+
is_success: true,
|
|
6
|
+
status_code: 200,
|
|
7
|
+
data: JSON.stringify(tableNames.map((t) => ({ table_name: t }))),
|
|
8
|
+
message,
|
|
9
|
+
requestedPayload,
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function registerListTables(server) {
|
|
13
|
+
server.registerTool("list_tables", {
|
|
14
|
+
title: "List tables",
|
|
15
|
+
description: "List available Snowflake tables (and views) for analytics table discovery. " +
|
|
16
|
+
"Returns a flat array of { table_name } objects. Scoped to the connection's " +
|
|
17
|
+
"database/schema unless overridden.",
|
|
18
|
+
inputSchema: {
|
|
19
|
+
database: z
|
|
20
|
+
.string()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Database to list from. Defaults to SNOWFLAKE_DATABASE."),
|
|
23
|
+
schema: z
|
|
24
|
+
.string()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("Schema to list from. Defaults to SNOWFLAKE_SCHEMA."),
|
|
27
|
+
include_views: z
|
|
28
|
+
.boolean()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe("Include views alongside base tables. Default true."),
|
|
31
|
+
},
|
|
32
|
+
}, async (args) => {
|
|
33
|
+
const c = getConfig();
|
|
34
|
+
const database = args.database || c.database;
|
|
35
|
+
const schema = args.schema || c.schema;
|
|
36
|
+
const includeViews = args.include_views !== false;
|
|
37
|
+
const requestedPayload = { database, schema, include_views: includeViews };
|
|
38
|
+
try {
|
|
39
|
+
if (!database) {
|
|
40
|
+
throw new Error("No database available. Set SNOWFLAKE_DATABASE or pass `database`.");
|
|
41
|
+
}
|
|
42
|
+
// INFORMATION_SCHEMA lives inside each database.
|
|
43
|
+
const infoSchema = `${quoteIdent(database)}.INFORMATION_SCHEMA.TABLES`;
|
|
44
|
+
const conditions = [];
|
|
45
|
+
const binds = [];
|
|
46
|
+
if (schema) {
|
|
47
|
+
conditions.push("TABLE_SCHEMA = ?");
|
|
48
|
+
binds.push(schema);
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
// Exclude Snowflake's own metadata schema for a cleaner list.
|
|
52
|
+
conditions.push("TABLE_SCHEMA <> 'INFORMATION_SCHEMA'");
|
|
53
|
+
}
|
|
54
|
+
if (!includeViews) {
|
|
55
|
+
conditions.push("TABLE_TYPE = 'BASE TABLE'");
|
|
56
|
+
}
|
|
57
|
+
const where = conditions.length
|
|
58
|
+
? `WHERE ${conditions.join(" AND ")}`
|
|
59
|
+
: "";
|
|
60
|
+
const sql = `SELECT TABLE_NAME FROM ${infoSchema} ${where} ORDER BY TABLE_NAME`;
|
|
61
|
+
const { rows } = await runQuery(sql, binds);
|
|
62
|
+
const names = rows
|
|
63
|
+
.map((r) => (r.TABLE_NAME ?? r.table_name))
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
const payload = envelope(names, requestedPayload, `Found ${names.length} table(s).`);
|
|
66
|
+
return { content: [{ type: "text", text: JSON.stringify(payload) }] };
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
// Keep the platform envelope even on failure so the parser doesn't choke.
|
|
70
|
+
const payload = {
|
|
71
|
+
is_success: false,
|
|
72
|
+
status_code: 500,
|
|
73
|
+
data: JSON.stringify([]),
|
|
74
|
+
message: err instanceof Error ? err.message : String(err),
|
|
75
|
+
requestedPayload,
|
|
76
|
+
};
|
|
77
|
+
return {
|
|
78
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
79
|
+
isError: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
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.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"snowflake-analytics-mcp-server": "dist/index.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc && node -e \"require('fs').chmodSync('dist/index.js', 0o755)\"",
|
|
12
|
+
"start": "node dist/index.js",
|
|
13
|
+
"prepublishOnly": "npm run build"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"snowflake",
|
|
22
|
+
"analytics",
|
|
23
|
+
"database",
|
|
24
|
+
"model-context-protocol"
|
|
25
|
+
],
|
|
26
|
+
"author": "sagar1012",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
30
|
+
"snowflake-sdk": "^3.2.0",
|
|
31
|
+
"zod": "^4.4.3"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^26.2.0",
|
|
35
|
+
"@types/snowflake-sdk": "^1.6.24",
|
|
36
|
+
"typescript": "^7.0.2"
|
|
37
|
+
}
|
|
38
|
+
}
|