snowsyncmd-mcp 1.0.0__tar.gz
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.
- snowsyncmd_mcp-1.0.0/.gitignore +19 -0
- snowsyncmd_mcp-1.0.0/PKG-INFO +137 -0
- snowsyncmd_mcp-1.0.0/README.md +123 -0
- snowsyncmd_mcp-1.0.0/pyproject.toml +28 -0
- snowsyncmd_mcp-1.0.0/snowsyncmd_mcp/__init__.py +13 -0
- snowsyncmd_mcp-1.0.0/snowsyncmd_mcp/client.py +238 -0
- snowsyncmd_mcp-1.0.0/snowsyncmd_mcp/server.py +182 -0
- snowsyncmd_mcp-1.0.0/snowsyncmd_mcp.py +369 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: snowsyncmd-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MCP server for the SnowSyncMD Snowflake Native App — exposes schema docs as Claude tools
|
|
5
|
+
Project-URL: Homepage, https://github.com/your-org/snowsyncmd
|
|
6
|
+
Project-URL: PyPI, https://pypi.org/project/snowsyncmd-mcp
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: claude,documentation,mcp,schema,snowflake
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: mcp>=1.0.0
|
|
11
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
12
|
+
Requires-Dist: snowflake-connector-python>=3.0.0
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# SnowSyncMD MCP Server
|
|
16
|
+
|
|
17
|
+
Connects Claude Code directly to your SnowSyncMD app so Claude can read
|
|
18
|
+
Snowflake schema documentation automatically — no copy-pasting, no manual downloads.
|
|
19
|
+
|
|
20
|
+
## How it works
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
You ask Claude: "Write a query joining ORDERS to CUSTOMERS"
|
|
24
|
+
↓
|
|
25
|
+
Claude calls MCP tool: snowflake_get_schema("MY_DB", "SALES", "ORDERS")
|
|
26
|
+
Claude calls MCP tool: snowflake_get_schema("MY_DB", "SALES", "CUSTOMERS")
|
|
27
|
+
↓
|
|
28
|
+
Claude gets the column list from SnowSyncMD
|
|
29
|
+
↓
|
|
30
|
+
Claude writes the correct query with real column names
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
No live Snowflake queries. No manual schema exports. Claude reads the
|
|
34
|
+
pre-built Markdown files that SnowSyncMD keeps up to date every 10 minutes.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Installation
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install mcp snowflake-connector-python python-dotenv
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Configuration
|
|
47
|
+
|
|
48
|
+
Add to your Claude Code settings (`~/.claude/settings.json`):
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"snowsyncmd": {
|
|
54
|
+
"command": "python3",
|
|
55
|
+
"args": ["/path/to/snowsyncmd_mcp.py"],
|
|
56
|
+
"env": {
|
|
57
|
+
"SNOWFLAKE_ACCOUNT": "your-account-identifier",
|
|
58
|
+
"SNOWFLAKE_USER": "your_username",
|
|
59
|
+
"SNOWFLAKE_PASSWORD": "your_password",
|
|
60
|
+
"SNOWFLAKE_ROLE": "ACCOUNTADMIN",
|
|
61
|
+
"SNOWFLAKE_WAREHOUSE": "COMPUTE_WH",
|
|
62
|
+
"SNOWSYNCMD_APP": "snowsyncmd"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
> **Tip:** Set credentials via a `.env` file in the same directory instead
|
|
70
|
+
> of hardcoding them in settings.json.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Available tools
|
|
75
|
+
|
|
76
|
+
Claude sees these tools and calls them automatically:
|
|
77
|
+
|
|
78
|
+
| Tool | What it does |
|
|
79
|
+
|---|---|
|
|
80
|
+
| `snowflake_get_schema` | Returns the full MD doc for one object (table, view, function…) |
|
|
81
|
+
| `snowflake_search_schema` | Searches by keyword across all schema docs |
|
|
82
|
+
| `snowflake_list_objects` | Lists every tracked object with DB/schema/type |
|
|
83
|
+
| `snowflake_get_status` | Shows sync health, registered databases, last sync time |
|
|
84
|
+
| `snowflake_sync` | Triggers an immediate sync (all DBs or one) |
|
|
85
|
+
|
|
86
|
+
---
|
|
87
|
+
|
|
88
|
+
## Example conversations
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
You: "What columns does the FACT_ORDERS table have?"
|
|
92
|
+
Claude: [calls snowflake_get_schema] → returns column list with types
|
|
93
|
+
Claude: "FACT_ORDERS has 14 columns: ORDER_SK (NUMBER), ORDER_ID (NUMBER), ..."
|
|
94
|
+
|
|
95
|
+
You: "Write a query to show monthly revenue by channel"
|
|
96
|
+
Claude: [calls snowflake_search_schema "revenue"] → finds FACT_ORDERS, V_DAILY_SALES
|
|
97
|
+
Claude: [calls snowflake_get_schema for V_DAILY_SALES]
|
|
98
|
+
Claude: "Here's a query using V_DAILY_SALES which already aggregates by channel: ..."
|
|
99
|
+
|
|
100
|
+
You: "Is the schema documentation up to date?"
|
|
101
|
+
Claude: [calls snowflake_get_status]
|
|
102
|
+
Claude: "Last synced 3 minutes ago. 180 objects tracked across 2 databases."
|
|
103
|
+
|
|
104
|
+
You: "I just added a new table — refresh the docs"
|
|
105
|
+
Claude: [calls snowflake_sync]
|
|
106
|
+
Claude: "Sync complete. 1 new object found and documented."
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Using with Claude Code hooks (optional)
|
|
112
|
+
|
|
113
|
+
Add a `UserPromptSubmit` hook to auto-check sync status before each session:
|
|
114
|
+
|
|
115
|
+
`~/.claude/settings.json`:
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"hooks": {
|
|
119
|
+
"PreToolUse": [{
|
|
120
|
+
"matcher": "Bash",
|
|
121
|
+
"hooks": [{
|
|
122
|
+
"type": "command",
|
|
123
|
+
"command": "echo 'Snowflake schema docs available via snowsyncmd MCP tools'"
|
|
124
|
+
}]
|
|
125
|
+
}]
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
## Requirements
|
|
133
|
+
|
|
134
|
+
- SnowSyncMD installed from Snowflake Marketplace
|
|
135
|
+
- ACCOUNTADMIN or app_admin role on the SnowSyncMD app
|
|
136
|
+
- Python 3.11+
|
|
137
|
+
- `mcp`, `snowflake-connector-python`, `python-dotenv`
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# SnowSyncMD MCP Server
|
|
2
|
+
|
|
3
|
+
Connects Claude Code directly to your SnowSyncMD app so Claude can read
|
|
4
|
+
Snowflake schema documentation automatically — no copy-pasting, no manual downloads.
|
|
5
|
+
|
|
6
|
+
## How it works
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
You ask Claude: "Write a query joining ORDERS to CUSTOMERS"
|
|
10
|
+
↓
|
|
11
|
+
Claude calls MCP tool: snowflake_get_schema("MY_DB", "SALES", "ORDERS")
|
|
12
|
+
Claude calls MCP tool: snowflake_get_schema("MY_DB", "SALES", "CUSTOMERS")
|
|
13
|
+
↓
|
|
14
|
+
Claude gets the column list from SnowSyncMD
|
|
15
|
+
↓
|
|
16
|
+
Claude writes the correct query with real column names
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
No live Snowflake queries. No manual schema exports. Claude reads the
|
|
20
|
+
pre-built Markdown files that SnowSyncMD keeps up to date every 10 minutes.
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install mcp snowflake-connector-python python-dotenv
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## Configuration
|
|
33
|
+
|
|
34
|
+
Add to your Claude Code settings (`~/.claude/settings.json`):
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{
|
|
38
|
+
"mcpServers": {
|
|
39
|
+
"snowsyncmd": {
|
|
40
|
+
"command": "python3",
|
|
41
|
+
"args": ["/path/to/snowsyncmd_mcp.py"],
|
|
42
|
+
"env": {
|
|
43
|
+
"SNOWFLAKE_ACCOUNT": "your-account-identifier",
|
|
44
|
+
"SNOWFLAKE_USER": "your_username",
|
|
45
|
+
"SNOWFLAKE_PASSWORD": "your_password",
|
|
46
|
+
"SNOWFLAKE_ROLE": "ACCOUNTADMIN",
|
|
47
|
+
"SNOWFLAKE_WAREHOUSE": "COMPUTE_WH",
|
|
48
|
+
"SNOWSYNCMD_APP": "snowsyncmd"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
> **Tip:** Set credentials via a `.env` file in the same directory instead
|
|
56
|
+
> of hardcoding them in settings.json.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Available tools
|
|
61
|
+
|
|
62
|
+
Claude sees these tools and calls them automatically:
|
|
63
|
+
|
|
64
|
+
| Tool | What it does |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `snowflake_get_schema` | Returns the full MD doc for one object (table, view, function…) |
|
|
67
|
+
| `snowflake_search_schema` | Searches by keyword across all schema docs |
|
|
68
|
+
| `snowflake_list_objects` | Lists every tracked object with DB/schema/type |
|
|
69
|
+
| `snowflake_get_status` | Shows sync health, registered databases, last sync time |
|
|
70
|
+
| `snowflake_sync` | Triggers an immediate sync (all DBs or one) |
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Example conversations
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
You: "What columns does the FACT_ORDERS table have?"
|
|
78
|
+
Claude: [calls snowflake_get_schema] → returns column list with types
|
|
79
|
+
Claude: "FACT_ORDERS has 14 columns: ORDER_SK (NUMBER), ORDER_ID (NUMBER), ..."
|
|
80
|
+
|
|
81
|
+
You: "Write a query to show monthly revenue by channel"
|
|
82
|
+
Claude: [calls snowflake_search_schema "revenue"] → finds FACT_ORDERS, V_DAILY_SALES
|
|
83
|
+
Claude: [calls snowflake_get_schema for V_DAILY_SALES]
|
|
84
|
+
Claude: "Here's a query using V_DAILY_SALES which already aggregates by channel: ..."
|
|
85
|
+
|
|
86
|
+
You: "Is the schema documentation up to date?"
|
|
87
|
+
Claude: [calls snowflake_get_status]
|
|
88
|
+
Claude: "Last synced 3 minutes ago. 180 objects tracked across 2 databases."
|
|
89
|
+
|
|
90
|
+
You: "I just added a new table — refresh the docs"
|
|
91
|
+
Claude: [calls snowflake_sync]
|
|
92
|
+
Claude: "Sync complete. 1 new object found and documented."
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
---
|
|
96
|
+
|
|
97
|
+
## Using with Claude Code hooks (optional)
|
|
98
|
+
|
|
99
|
+
Add a `UserPromptSubmit` hook to auto-check sync status before each session:
|
|
100
|
+
|
|
101
|
+
`~/.claude/settings.json`:
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"hooks": {
|
|
105
|
+
"PreToolUse": [{
|
|
106
|
+
"matcher": "Bash",
|
|
107
|
+
"hooks": [{
|
|
108
|
+
"type": "command",
|
|
109
|
+
"command": "echo 'Snowflake schema docs available via snowsyncmd MCP tools'"
|
|
110
|
+
}]
|
|
111
|
+
}]
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
## Requirements
|
|
119
|
+
|
|
120
|
+
- SnowSyncMD installed from Snowflake Marketplace
|
|
121
|
+
- ACCOUNTADMIN or app_admin role on the SnowSyncMD app
|
|
122
|
+
- Python 3.11+
|
|
123
|
+
- `mcp`, `snowflake-connector-python`, `python-dotenv`
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "snowsyncmd-mcp"
|
|
3
|
+
version = "1.0.0"
|
|
4
|
+
description = "MCP server for the SnowSyncMD Snowflake Native App — exposes schema docs as Claude tools"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
keywords = ["snowflake", "mcp", "claude", "schema", "documentation"]
|
|
9
|
+
|
|
10
|
+
dependencies = [
|
|
11
|
+
"mcp>=1.0.0",
|
|
12
|
+
"snowflake-connector-python>=3.0.0",
|
|
13
|
+
"python-dotenv>=1.0.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[project.scripts]
|
|
17
|
+
snowsyncmd-mcp = "snowsyncmd_mcp:main"
|
|
18
|
+
|
|
19
|
+
[project.urls]
|
|
20
|
+
Homepage = "https://github.com/your-org/snowsyncmd"
|
|
21
|
+
PyPI = "https://pypi.org/project/snowsyncmd-mcp"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["hatchling"]
|
|
25
|
+
build-backend = "hatchling.build"
|
|
26
|
+
|
|
27
|
+
[tool.hatch.build.targets.wheel]
|
|
28
|
+
packages = ["snowsyncmd_mcp"]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""snowsyncmd-mcp — MCP server for the SnowSyncMD Native App."""
|
|
2
|
+
|
|
3
|
+
from .client import SnowSyncMDClient, SchemaObject, SyncStatus
|
|
4
|
+
from .server import create_server, run
|
|
5
|
+
import asyncio
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def main():
|
|
9
|
+
"""Entry point for the `snowsyncmd-mcp` CLI command."""
|
|
10
|
+
asyncio.run(run())
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
__all__ = ["SnowSyncMDClient", "SchemaObject", "SyncStatus", "create_server", "main"]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""
|
|
2
|
+
client.py
|
|
3
|
+
=========
|
|
4
|
+
SnowSyncMDClient — thin wrapper around the Native App's stored procedures.
|
|
5
|
+
|
|
6
|
+
This is the ONLY class that talks to Snowflake.
|
|
7
|
+
The MCP server (server.py) uses this class exclusively.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
client = SnowSyncMDClient.from_env() # reads from environment
|
|
11
|
+
client = SnowSyncMDClient( # explicit
|
|
12
|
+
account="myaccount",
|
|
13
|
+
user="myuser",
|
|
14
|
+
password="mypassword",
|
|
15
|
+
app_name="snowsyncmd",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
doc = client.get_schema("MY_DB", "SALES", "ORDERS")
|
|
19
|
+
hits = client.search("customer")
|
|
20
|
+
objs = client.list_objects(database="MY_DB")
|
|
21
|
+
st = client.get_status()
|
|
22
|
+
res = client.sync(database="MY_DB") # optional write
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from typing import Optional
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class SchemaObject:
|
|
33
|
+
database: str
|
|
34
|
+
schema: str
|
|
35
|
+
object_name: str
|
|
36
|
+
object_type: str = ""
|
|
37
|
+
size_bytes: int = 0
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def full_name(self) -> str:
|
|
41
|
+
return f"{self.database}.{self.schema}.{self.object_name}"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class SyncStatus:
|
|
46
|
+
task_state: str
|
|
47
|
+
total_objects: int
|
|
48
|
+
md_files_present: int
|
|
49
|
+
dirty_count: int
|
|
50
|
+
last_scan_at: Optional[str]
|
|
51
|
+
databases: list = field(default_factory=list)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SnowSyncMDClient:
|
|
55
|
+
"""
|
|
56
|
+
Wraps all calls to the SnowSyncMD Native App stored procedures.
|
|
57
|
+
|
|
58
|
+
Responsibilities:
|
|
59
|
+
- Manage the Snowflake connection lifecycle
|
|
60
|
+
- Call api.* procedures and parse VARIANT responses
|
|
61
|
+
- Read MD files directly from @core.md_stage
|
|
62
|
+
- Provide typed return values (no raw SQL in server.py)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
account: str,
|
|
68
|
+
user: str,
|
|
69
|
+
password: str,
|
|
70
|
+
app_name: str = "snowsyncmd",
|
|
71
|
+
role: str = "ACCOUNTADMIN",
|
|
72
|
+
warehouse: str = "",
|
|
73
|
+
):
|
|
74
|
+
self._conn_params = dict(
|
|
75
|
+
account=account, user=user, password=password,
|
|
76
|
+
role=role, warehouse=warehouse,
|
|
77
|
+
)
|
|
78
|
+
self.app = app_name
|
|
79
|
+
|
|
80
|
+
# ── constructor helpers ───────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_env(cls) -> "SnowSyncMDClient":
|
|
84
|
+
"""Build a client from standard environment variables."""
|
|
85
|
+
return cls(
|
|
86
|
+
account=os.environ["SNOWFLAKE_ACCOUNT"],
|
|
87
|
+
user=os.environ["SNOWFLAKE_USER"],
|
|
88
|
+
password=os.environ["SNOWFLAKE_PASSWORD"],
|
|
89
|
+
app_name=os.environ.get("SNOWSYNCMD_APP", "snowsyncmd"),
|
|
90
|
+
role=os.environ.get("SNOWFLAKE_ROLE", "ACCOUNTADMIN"),
|
|
91
|
+
warehouse=os.environ.get("SNOWFLAKE_WAREHOUSE", ""),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def _connect(self):
|
|
95
|
+
import snowflake.connector
|
|
96
|
+
return snowflake.connector.connect(**self._conn_params)
|
|
97
|
+
|
|
98
|
+
# ── internal helpers ──────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
def _call(self, proc: str, *args) -> dict | list:
|
|
101
|
+
"""Call a stored procedure and return the parsed JSON response."""
|
|
102
|
+
conn = self._connect()
|
|
103
|
+
try:
|
|
104
|
+
cur = conn.cursor()
|
|
105
|
+
escaped = ", ".join(
|
|
106
|
+
"NULL" if a is None else f"'{str(a).replace(chr(39), chr(39)*2)}'"
|
|
107
|
+
for a in args
|
|
108
|
+
)
|
|
109
|
+
cur.execute(f"CALL {self.app}.api.{proc}({escaped})")
|
|
110
|
+
row = cur.fetchone()
|
|
111
|
+
if not row:
|
|
112
|
+
return {}
|
|
113
|
+
val = row[0]
|
|
114
|
+
if isinstance(val, str):
|
|
115
|
+
try:
|
|
116
|
+
return json.loads(val)
|
|
117
|
+
except Exception:
|
|
118
|
+
return {"raw": val}
|
|
119
|
+
return val or {}
|
|
120
|
+
finally:
|
|
121
|
+
conn.close()
|
|
122
|
+
|
|
123
|
+
def _call_varchar(self, proc: str, *args) -> str | None:
|
|
124
|
+
"""Call a stored procedure that returns VARCHAR (not VARIANT)."""
|
|
125
|
+
conn = self._connect()
|
|
126
|
+
try:
|
|
127
|
+
cur = conn.cursor()
|
|
128
|
+
escaped = ", ".join(
|
|
129
|
+
"NULL" if a is None else f"'{str(a).replace(chr(39), chr(39)*2)}'"
|
|
130
|
+
for a in args
|
|
131
|
+
)
|
|
132
|
+
cur.execute(f"CALL {self.app}.api.{proc}({escaped})")
|
|
133
|
+
row = cur.fetchone()
|
|
134
|
+
return row[0] if row else None
|
|
135
|
+
finally:
|
|
136
|
+
conn.close()
|
|
137
|
+
|
|
138
|
+
def _list_stage(self, database: str | None = None) -> list[SchemaObject]:
|
|
139
|
+
"""LIST the stage and return typed SchemaObject entries."""
|
|
140
|
+
conn = self._connect()
|
|
141
|
+
try:
|
|
142
|
+
cur = conn.cursor()
|
|
143
|
+
prefix = f"@{self.app}.core.md_stage/"
|
|
144
|
+
if database:
|
|
145
|
+
prefix += f"{database.upper()}/"
|
|
146
|
+
cur.execute(f"LIST {prefix}")
|
|
147
|
+
rows = cur.fetchall()
|
|
148
|
+
objects = []
|
|
149
|
+
for r in rows:
|
|
150
|
+
parts = r[0].split("/") # md_stage/DB/SCHEMA/OBJECT.md
|
|
151
|
+
if len(parts) >= 4:
|
|
152
|
+
objects.append(SchemaObject(
|
|
153
|
+
database=parts[1],
|
|
154
|
+
schema=parts[2],
|
|
155
|
+
object_name=parts[3].replace(".md", ""),
|
|
156
|
+
size_bytes=int(r[1]) if r[1] else 0,
|
|
157
|
+
))
|
|
158
|
+
return objects
|
|
159
|
+
finally:
|
|
160
|
+
conn.close()
|
|
161
|
+
|
|
162
|
+
# ── public API ────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
def get_schema(self, database: str, schema: str, object_name: str) -> str | None:
|
|
165
|
+
"""
|
|
166
|
+
Return the full Markdown documentation for one Snowflake object,
|
|
167
|
+
or None if not found / error.
|
|
168
|
+
"""
|
|
169
|
+
result = self._call_varchar(
|
|
170
|
+
"get_schema_doc",
|
|
171
|
+
database.upper(), schema.upper(), object_name.upper(),
|
|
172
|
+
)
|
|
173
|
+
if result and not result.startswith("Error reading") and not result.startswith("No documentation"):
|
|
174
|
+
return result
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
def search(self, query: str, database: str | None = None) -> list[SchemaObject]:
|
|
178
|
+
"""
|
|
179
|
+
Keyword search across all tracked objects.
|
|
180
|
+
Matches on object name, schema name, or database name.
|
|
181
|
+
"""
|
|
182
|
+
q = query.lower()
|
|
183
|
+
objects = self._list_stage(database)
|
|
184
|
+
return [
|
|
185
|
+
obj for obj in objects
|
|
186
|
+
if q in obj.object_name.lower()
|
|
187
|
+
or q in obj.schema.lower()
|
|
188
|
+
or q in obj.database.lower()
|
|
189
|
+
]
|
|
190
|
+
|
|
191
|
+
def list_objects(
|
|
192
|
+
self,
|
|
193
|
+
database: str | None = None,
|
|
194
|
+
object_type: str | None = None,
|
|
195
|
+
) -> list[SchemaObject]:
|
|
196
|
+
"""
|
|
197
|
+
List all tracked objects, optionally filtered by database or type.
|
|
198
|
+
Type filter requires the list_md_files API (has type info).
|
|
199
|
+
"""
|
|
200
|
+
result = self._call("list_md_files", database or "")
|
|
201
|
+
files = result.get("files", []) if isinstance(result, dict) else []
|
|
202
|
+
|
|
203
|
+
objects = []
|
|
204
|
+
for f in files:
|
|
205
|
+
obj = SchemaObject(
|
|
206
|
+
database=f.get("database_name", ""),
|
|
207
|
+
schema=f.get("schema_name", ""),
|
|
208
|
+
object_name=f.get("object_name", ""),
|
|
209
|
+
object_type=f.get("object_type", ""),
|
|
210
|
+
size_bytes=f.get("file_size", 0),
|
|
211
|
+
)
|
|
212
|
+
objects.append(obj)
|
|
213
|
+
|
|
214
|
+
if object_type:
|
|
215
|
+
objects = [o for o in objects if o.object_type.upper() == object_type.upper()]
|
|
216
|
+
|
|
217
|
+
return objects
|
|
218
|
+
|
|
219
|
+
def get_status(self) -> SyncStatus:
|
|
220
|
+
"""Return current sync health as a typed SyncStatus."""
|
|
221
|
+
raw = self._call("get_status")
|
|
222
|
+
return SyncStatus(
|
|
223
|
+
task_state=raw.get("task_state", "UNKNOWN"),
|
|
224
|
+
total_objects=raw.get("total_objects_tracked", 0),
|
|
225
|
+
md_files_present=raw.get("md_files_present", 0),
|
|
226
|
+
dirty_count=raw.get("dirty_count", 0),
|
|
227
|
+
last_scan_at=raw.get("last_scan_at"),
|
|
228
|
+
databases=raw.get("databases", []),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
def sync(self, database: str | None = None) -> dict:
|
|
232
|
+
"""
|
|
233
|
+
Trigger an immediate sync.
|
|
234
|
+
Returns the raw sync result dict from the Native App.
|
|
235
|
+
"""
|
|
236
|
+
if database:
|
|
237
|
+
return self._call("sync_database", database.upper())
|
|
238
|
+
return self._call("sync_now")
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
server.py
|
|
3
|
+
=========
|
|
4
|
+
MCP server — translates Claude's tool calls into SnowSyncMDClient calls.
|
|
5
|
+
|
|
6
|
+
This module knows nothing about Snowflake directly.
|
|
7
|
+
All Snowflake logic lives in client.py.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import mcp.server.stdio
|
|
14
|
+
import mcp.types as types
|
|
15
|
+
from mcp.server import Server
|
|
16
|
+
|
|
17
|
+
from .client import SnowSyncMDClient
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def create_server(client: SnowSyncMDClient) -> Server:
|
|
21
|
+
server = Server("snowsyncmd")
|
|
22
|
+
|
|
23
|
+
# ── tool definitions ──────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
@server.list_tools()
|
|
26
|
+
async def list_tools() -> list[types.Tool]:
|
|
27
|
+
return [
|
|
28
|
+
types.Tool(
|
|
29
|
+
name="snowflake_get_schema",
|
|
30
|
+
description=(
|
|
31
|
+
"Get the full Markdown schema doc for a specific Snowflake object "
|
|
32
|
+
"(table, view, function, procedure, stage, etc.). "
|
|
33
|
+
"Call this before writing SQL to get accurate column names and types."
|
|
34
|
+
),
|
|
35
|
+
inputSchema={
|
|
36
|
+
"type": "object",
|
|
37
|
+
"properties": {
|
|
38
|
+
"database": {"type": "string"},
|
|
39
|
+
"schema": {"type": "string"},
|
|
40
|
+
"object_name": {"type": "string"},
|
|
41
|
+
},
|
|
42
|
+
"required": ["database", "schema", "object_name"],
|
|
43
|
+
},
|
|
44
|
+
),
|
|
45
|
+
types.Tool(
|
|
46
|
+
name="snowflake_search_schema",
|
|
47
|
+
description=(
|
|
48
|
+
"Search all schema docs by keyword. Use when you don't know "
|
|
49
|
+
"the exact table/view name. Returns matching object names."
|
|
50
|
+
),
|
|
51
|
+
inputSchema={
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"query": {"type": "string", "description": "e.g. 'customer', 'order', 'payment'"},
|
|
55
|
+
"database": {"type": "string", "description": "Limit to one database (optional)"},
|
|
56
|
+
},
|
|
57
|
+
"required": ["query"],
|
|
58
|
+
},
|
|
59
|
+
),
|
|
60
|
+
types.Tool(
|
|
61
|
+
name="snowflake_list_objects",
|
|
62
|
+
description="List all tracked Snowflake objects with their database, schema, and type.",
|
|
63
|
+
inputSchema={
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {
|
|
66
|
+
"database": {"type": "string"},
|
|
67
|
+
"object_type": {"type": "string", "description": "TABLE, VIEW, FUNCTION, PROCEDURE, STAGE, etc."},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
),
|
|
71
|
+
types.Tool(
|
|
72
|
+
name="snowflake_get_status",
|
|
73
|
+
description="Check SnowSyncMD sync health: registered databases, object counts, last sync time.",
|
|
74
|
+
inputSchema={"type": "object", "properties": {}},
|
|
75
|
+
),
|
|
76
|
+
types.Tool(
|
|
77
|
+
name="snowflake_sync",
|
|
78
|
+
description="Trigger an immediate schema sync. Use after DDL changes.",
|
|
79
|
+
inputSchema={
|
|
80
|
+
"type": "object",
|
|
81
|
+
"properties": {
|
|
82
|
+
"database": {"type": "string", "description": "Sync one DB only (optional)"},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
),
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
# ── tool handlers ─────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
@server.call_tool()
|
|
91
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[types.TextContent]:
|
|
92
|
+
|
|
93
|
+
if name == "snowflake_get_schema":
|
|
94
|
+
doc = client.get_schema(
|
|
95
|
+
arguments["database"],
|
|
96
|
+
arguments["schema"],
|
|
97
|
+
arguments["object_name"],
|
|
98
|
+
)
|
|
99
|
+
text = doc if doc else (
|
|
100
|
+
f"No documentation found for "
|
|
101
|
+
f"{arguments['database']}.{arguments['schema']}.{arguments['object_name']}. "
|
|
102
|
+
"Run snowflake_sync to regenerate, or verify the object exists."
|
|
103
|
+
)
|
|
104
|
+
return [types.TextContent(type="text", text=text)]
|
|
105
|
+
|
|
106
|
+
if name == "snowflake_search_schema":
|
|
107
|
+
hits = client.search(arguments["query"], arguments.get("database"))
|
|
108
|
+
if not hits:
|
|
109
|
+
return [types.TextContent(
|
|
110
|
+
type="text",
|
|
111
|
+
text=f"No objects matching '{arguments['query']}'.",
|
|
112
|
+
)]
|
|
113
|
+
lines = [f"Found {len(hits)} match(es) for '{arguments['query']}':\n"]
|
|
114
|
+
for h in hits[:20]:
|
|
115
|
+
lines.append(f" • {h.full_name}")
|
|
116
|
+
if len(hits) > 20:
|
|
117
|
+
lines.append(f" … {len(hits) - 20} more")
|
|
118
|
+
lines.append("\nUse snowflake_get_schema to read the full doc for any object.")
|
|
119
|
+
return [types.TextContent(type="text", text="\n".join(lines))]
|
|
120
|
+
|
|
121
|
+
if name == "snowflake_list_objects":
|
|
122
|
+
objs = client.list_objects(
|
|
123
|
+
arguments.get("database"),
|
|
124
|
+
arguments.get("object_type"),
|
|
125
|
+
)
|
|
126
|
+
if not objs:
|
|
127
|
+
return [types.TextContent(
|
|
128
|
+
type="text", text="No objects tracked yet. Run snowflake_sync first."
|
|
129
|
+
)]
|
|
130
|
+
by_db: dict = {}
|
|
131
|
+
for o in objs:
|
|
132
|
+
by_db.setdefault(f"{o.database}.{o.schema}", []).append(o.object_name)
|
|
133
|
+
lines = [f"Tracked objects ({len(objs)} total):\n"]
|
|
134
|
+
for group, names in sorted(by_db.items()):
|
|
135
|
+
lines.append(f"\n📁 {group} ({len(names)} objects)")
|
|
136
|
+
for n in sorted(names):
|
|
137
|
+
lines.append(f" • {n}")
|
|
138
|
+
return [types.TextContent(type="text", text="\n".join(lines))]
|
|
139
|
+
|
|
140
|
+
if name == "snowflake_get_status":
|
|
141
|
+
s = client.get_status()
|
|
142
|
+
lines = [
|
|
143
|
+
"SnowSyncMD Status",
|
|
144
|
+
f" Task: {s.task_state}",
|
|
145
|
+
f" Objects: {s.total_objects}",
|
|
146
|
+
f" MD files: {s.md_files_present}",
|
|
147
|
+
f" Pending: {s.dirty_count}",
|
|
148
|
+
f" Last sync: {s.last_scan_at or 'Never'}",
|
|
149
|
+
"\nDatabases:",
|
|
150
|
+
]
|
|
151
|
+
for db in s.databases:
|
|
152
|
+
icon = "✅" if db.get("is_enabled") else "⛔"
|
|
153
|
+
lines.append(
|
|
154
|
+
f" {icon} {db['database_name']} "
|
|
155
|
+
f"[{db['priority']}] {db['object_count']} objects"
|
|
156
|
+
)
|
|
157
|
+
return [types.TextContent(type="text", text="\n".join(lines))]
|
|
158
|
+
|
|
159
|
+
if name == "snowflake_sync":
|
|
160
|
+
result = client.sync(arguments.get("database"))
|
|
161
|
+
db_label = arguments.get("database", "all databases").upper()
|
|
162
|
+
scan_r = result.get("scan") or {}
|
|
163
|
+
gen_r = result.get("generate") or {}
|
|
164
|
+
text = (
|
|
165
|
+
f"Sync complete for {db_label}.\n"
|
|
166
|
+
f" Scanned: {scan_r.get('objects_scanned', 0)}\n"
|
|
167
|
+
f" Changed: {scan_r.get('objects_changed', 0)}\n"
|
|
168
|
+
f" MD files: {gen_r.get('md_files_written', 0)}\n"
|
|
169
|
+
f" Duration: {result.get('total_duration_seconds', '?')}s"
|
|
170
|
+
)
|
|
171
|
+
return [types.TextContent(type="text", text=text)]
|
|
172
|
+
|
|
173
|
+
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
174
|
+
|
|
175
|
+
return server
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
async def run():
|
|
179
|
+
client = SnowSyncMDClient.from_env()
|
|
180
|
+
server = create_server(client)
|
|
181
|
+
async with mcp.server.stdio.stdio_server() as (read, write):
|
|
182
|
+
await server.run(read, write, server.create_initialization_options())
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
SnowSyncMD MCP Server
|
|
4
|
+
=====================
|
|
5
|
+
Exposes the SnowSyncMD Native App as MCP tools so Claude Code can
|
|
6
|
+
automatically read Snowflake schema documentation without live queries.
|
|
7
|
+
|
|
8
|
+
Claude sees these tools:
|
|
9
|
+
snowflake_get_schema – fetch the MD doc for one object
|
|
10
|
+
snowflake_search_schema – full-text search across all schema docs
|
|
11
|
+
snowflake_list_objects – list every tracked object (filter by DB / type)
|
|
12
|
+
snowflake_get_status – show sync health and registered databases
|
|
13
|
+
snowflake_sync – trigger an immediate sync (optional)
|
|
14
|
+
|
|
15
|
+
Setup (consumer side):
|
|
16
|
+
pip install mcp snowflake-connector-python python-dotenv
|
|
17
|
+
python mcp/snowsyncmd_mcp.py
|
|
18
|
+
|
|
19
|
+
Then add to Claude Code settings (~/.claude/settings.json):
|
|
20
|
+
{
|
|
21
|
+
"mcpServers": {
|
|
22
|
+
"snowsyncmd": {
|
|
23
|
+
"command": "python3",
|
|
24
|
+
"args": ["/path/to/snowsyncmd_mcp.py"],
|
|
25
|
+
"env": {
|
|
26
|
+
"SNOWFLAKE_ACCOUNT": "...",
|
|
27
|
+
"SNOWFLAKE_USER": "...",
|
|
28
|
+
"SNOWFLAKE_PASSWORD": "...",
|
|
29
|
+
"SNOWFLAKE_ROLE": "ACCOUNTADMIN",
|
|
30
|
+
"SNOWSYNCMD_APP": "snowsyncmd"
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
Claude then uses these tools automatically whenever you ask:
|
|
37
|
+
"What columns does ORDERS have?"
|
|
38
|
+
"Write a query joining CUSTOMERS to ORDERS"
|
|
39
|
+
"Which tables track payments?"
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import asyncio
|
|
43
|
+
import json
|
|
44
|
+
import os
|
|
45
|
+
import sys
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
# ── MCP SDK ──────────────────────────────────────────────────────────────────
|
|
49
|
+
try:
|
|
50
|
+
import mcp.server.stdio
|
|
51
|
+
import mcp.types as types
|
|
52
|
+
from mcp.server import Server
|
|
53
|
+
except ImportError:
|
|
54
|
+
print("Install the MCP SDK: pip install mcp", file=sys.stderr)
|
|
55
|
+
sys.exit(1)
|
|
56
|
+
|
|
57
|
+
# ── Snowflake connector ───────────────────────────────────────────────────────
|
|
58
|
+
try:
|
|
59
|
+
import snowflake.connector
|
|
60
|
+
except ImportError:
|
|
61
|
+
print("Install Snowflake connector: pip install snowflake-connector-python",
|
|
62
|
+
file=sys.stderr)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
from dotenv import load_dotenv
|
|
67
|
+
load_dotenv()
|
|
68
|
+
except ImportError:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
73
|
+
# Snowflake helpers
|
|
74
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
APP = os.environ.get("SNOWSYNCMD_APP", "snowsyncmd")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _connect():
|
|
80
|
+
return snowflake.connector.connect(
|
|
81
|
+
account=os.environ["SNOWFLAKE_ACCOUNT"],
|
|
82
|
+
user=os.environ["SNOWFLAKE_USER"],
|
|
83
|
+
password=os.environ["SNOWFLAKE_PASSWORD"],
|
|
84
|
+
warehouse=os.environ.get("SNOWFLAKE_WAREHOUSE", ""),
|
|
85
|
+
role=os.environ.get("SNOWFLAKE_ROLE", "ACCOUNTADMIN"),
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _call_proc(proc: str, *args) -> Any:
|
|
90
|
+
"""Call a SnowSyncMD stored procedure and return parsed JSON."""
|
|
91
|
+
conn = _connect()
|
|
92
|
+
try:
|
|
93
|
+
cur = conn.cursor()
|
|
94
|
+
placeholders = ", ".join(["'%s'" % str(a).replace("'", "''") for a in args])
|
|
95
|
+
sql = f"CALL {APP}.api.{proc}({placeholders})"
|
|
96
|
+
cur.execute(sql)
|
|
97
|
+
row = cur.fetchone()
|
|
98
|
+
if row:
|
|
99
|
+
val = row[0]
|
|
100
|
+
if isinstance(val, str):
|
|
101
|
+
try:
|
|
102
|
+
return json.loads(val)
|
|
103
|
+
except Exception:
|
|
104
|
+
return {"raw": val}
|
|
105
|
+
return val
|
|
106
|
+
return {}
|
|
107
|
+
finally:
|
|
108
|
+
conn.close()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _get_md_file(database: str, schema: str, object_name: str) -> str | None:
|
|
112
|
+
"""Read one MD file directly from the stage."""
|
|
113
|
+
conn = _connect()
|
|
114
|
+
try:
|
|
115
|
+
cur = conn.cursor()
|
|
116
|
+
stage_path = f"@{APP}.core.md_stage/{database}/{schema}/{object_name}.md"
|
|
117
|
+
cur.execute(
|
|
118
|
+
f"SELECT $1 FROM {stage_path} "
|
|
119
|
+
f"(FILE_FORMAT => (TYPE='CSV', FIELD_DELIMITER='NONE', RECORD_DELIMITER='\\n'))"
|
|
120
|
+
)
|
|
121
|
+
lines = [r[0] for r in cur.fetchall() if r[0] is not None]
|
|
122
|
+
return "\n".join(lines) if lines else None
|
|
123
|
+
except Exception:
|
|
124
|
+
return None
|
|
125
|
+
finally:
|
|
126
|
+
conn.close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _list_stage_files(database: str | None = None) -> list[dict]:
|
|
130
|
+
"""List MD files in the stage."""
|
|
131
|
+
conn = _connect()
|
|
132
|
+
try:
|
|
133
|
+
cur = conn.cursor()
|
|
134
|
+
prefix = f"@{APP}.core.md_stage/"
|
|
135
|
+
if database:
|
|
136
|
+
prefix += f"{database.upper()}/"
|
|
137
|
+
cur.execute(f"LIST {prefix}")
|
|
138
|
+
rows = cur.fetchall()
|
|
139
|
+
result = []
|
|
140
|
+
for r in rows:
|
|
141
|
+
name = r[0] # md_stage/DB/SCHEMA/OBJECT.md
|
|
142
|
+
parts = name.split("/")
|
|
143
|
+
if len(parts) >= 4:
|
|
144
|
+
result.append({
|
|
145
|
+
"database": parts[1],
|
|
146
|
+
"schema": parts[2],
|
|
147
|
+
"object_name": parts[3].replace(".md", ""),
|
|
148
|
+
"stage_path": name,
|
|
149
|
+
"size_bytes": r[1],
|
|
150
|
+
})
|
|
151
|
+
return result
|
|
152
|
+
finally:
|
|
153
|
+
conn.close()
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
157
|
+
# MCP Server
|
|
158
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
server = Server("snowsyncmd")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@server.list_tools()
|
|
164
|
+
async def list_tools() -> list[types.Tool]:
|
|
165
|
+
return [
|
|
166
|
+
types.Tool(
|
|
167
|
+
name="snowflake_get_schema",
|
|
168
|
+
description=(
|
|
169
|
+
"Get the full Markdown schema documentation for a specific Snowflake "
|
|
170
|
+
"object (table, view, function, procedure, etc.). "
|
|
171
|
+
"Use this whenever you need column names, data types, or metadata "
|
|
172
|
+
"about a specific object before writing a SQL query."
|
|
173
|
+
),
|
|
174
|
+
inputSchema={
|
|
175
|
+
"type": "object",
|
|
176
|
+
"properties": {
|
|
177
|
+
"database": {"type": "string", "description": "Database name (uppercase)"},
|
|
178
|
+
"schema": {"type": "string", "description": "Schema name (uppercase)"},
|
|
179
|
+
"object_name": {"type": "string", "description": "Object name (uppercase)"},
|
|
180
|
+
},
|
|
181
|
+
"required": ["database", "schema", "object_name"],
|
|
182
|
+
},
|
|
183
|
+
),
|
|
184
|
+
types.Tool(
|
|
185
|
+
name="snowflake_search_schema",
|
|
186
|
+
description=(
|
|
187
|
+
"Search across all SnowSyncMD schema documentation. "
|
|
188
|
+
"Returns a list of objects whose names or descriptions match the query. "
|
|
189
|
+
"Use this to discover tables/views when you don't know the exact name."
|
|
190
|
+
),
|
|
191
|
+
inputSchema={
|
|
192
|
+
"type": "object",
|
|
193
|
+
"properties": {
|
|
194
|
+
"query": {"type": "string", "description": "Search term (e.g. 'customer', 'order', 'payment')"},
|
|
195
|
+
"database": {"type": "string", "description": "Limit to this database (optional)"},
|
|
196
|
+
},
|
|
197
|
+
"required": ["query"],
|
|
198
|
+
},
|
|
199
|
+
),
|
|
200
|
+
types.Tool(
|
|
201
|
+
name="snowflake_list_objects",
|
|
202
|
+
description=(
|
|
203
|
+
"List all Snowflake objects tracked by SnowSyncMD. "
|
|
204
|
+
"Returns database, schema, object name, and object type. "
|
|
205
|
+
"Use this to explore what's available before asking for specific schemas."
|
|
206
|
+
),
|
|
207
|
+
inputSchema={
|
|
208
|
+
"type": "object",
|
|
209
|
+
"properties": {
|
|
210
|
+
"database": {"type": "string", "description": "Filter by database (optional)"},
|
|
211
|
+
"object_type": {"type": "string", "description": "Filter by type: TABLE, VIEW, FUNCTION, PROCEDURE, STAGE, PIPE, SEQUENCE, FILE_FORMAT, TASK, STREAM (optional)"},
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
),
|
|
215
|
+
types.Tool(
|
|
216
|
+
name="snowflake_get_status",
|
|
217
|
+
description=(
|
|
218
|
+
"Get the SnowSyncMD sync status: registered databases, object counts, "
|
|
219
|
+
"last sync time, and task state. Use this to check if documentation "
|
|
220
|
+
"is up to date before answering schema questions."
|
|
221
|
+
),
|
|
222
|
+
inputSchema={"type": "object", "properties": {}},
|
|
223
|
+
),
|
|
224
|
+
types.Tool(
|
|
225
|
+
name="snowflake_sync",
|
|
226
|
+
description=(
|
|
227
|
+
"Trigger an immediate schema sync for one or all databases. "
|
|
228
|
+
"Use this when the user wants fresh documentation after a DDL change."
|
|
229
|
+
),
|
|
230
|
+
inputSchema={
|
|
231
|
+
"type": "object",
|
|
232
|
+
"properties": {
|
|
233
|
+
"database": {"type": "string", "description": "Database to sync (optional — omit to sync all)"},
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
),
|
|
237
|
+
]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@server.call_tool()
|
|
241
|
+
async def call_tool(name: str, arguments: dict) -> list[types.TextContent]:
|
|
242
|
+
|
|
243
|
+
# ── snowflake_get_schema ──────────────────────────────────────────────────
|
|
244
|
+
if name == "snowflake_get_schema":
|
|
245
|
+
db = arguments["database"].upper()
|
|
246
|
+
sc = arguments["schema"].upper()
|
|
247
|
+
obj = arguments["object_name"].upper()
|
|
248
|
+
md = _get_md_file(db, sc, obj)
|
|
249
|
+
if md:
|
|
250
|
+
return [types.TextContent(type="text", text=md)]
|
|
251
|
+
return [types.TextContent(
|
|
252
|
+
type="text",
|
|
253
|
+
text=f"No schema documentation found for {db}.{sc}.{obj}. "
|
|
254
|
+
"Run snowflake_sync to regenerate, or check the object name."
|
|
255
|
+
)]
|
|
256
|
+
|
|
257
|
+
# ── snowflake_search_schema ───────────────────────────────────────────────
|
|
258
|
+
elif name == "snowflake_search_schema":
|
|
259
|
+
query = arguments["query"].lower()
|
|
260
|
+
database = arguments.get("database")
|
|
261
|
+
files = _list_stage_files(database)
|
|
262
|
+
|
|
263
|
+
# Simple keyword match on object name
|
|
264
|
+
matches = [
|
|
265
|
+
f for f in files
|
|
266
|
+
if query in f["object_name"].lower()
|
|
267
|
+
or query in f["schema"].lower()
|
|
268
|
+
or query in f["database"].lower()
|
|
269
|
+
]
|
|
270
|
+
|
|
271
|
+
if not matches:
|
|
272
|
+
return [types.TextContent(
|
|
273
|
+
type="text",
|
|
274
|
+
text=f"No objects matching '{query}' found in schema documentation."
|
|
275
|
+
)]
|
|
276
|
+
|
|
277
|
+
lines = [f"Found {len(matches)} object(s) matching '{query}':\n"]
|
|
278
|
+
for m in matches[:20]: # cap at 20
|
|
279
|
+
lines.append(
|
|
280
|
+
f" • {m['database']}.{m['schema']}.{m['object_name']}"
|
|
281
|
+
)
|
|
282
|
+
if len(matches) > 20:
|
|
283
|
+
lines.append(f" … and {len(matches) - 20} more")
|
|
284
|
+
lines.append(
|
|
285
|
+
"\nUse snowflake_get_schema to read the full documentation for any of these."
|
|
286
|
+
)
|
|
287
|
+
return [types.TextContent(type="text", text="\n".join(lines))]
|
|
288
|
+
|
|
289
|
+
# ── snowflake_list_objects ────────────────────────────────────────────────
|
|
290
|
+
elif name == "snowflake_list_objects":
|
|
291
|
+
database = arguments.get("database")
|
|
292
|
+
object_type = arguments.get("object_type", "").upper()
|
|
293
|
+
|
|
294
|
+
result = _call_proc("list_md_files", database or "")
|
|
295
|
+
files = result.get("files", []) if isinstance(result, dict) else []
|
|
296
|
+
|
|
297
|
+
if object_type:
|
|
298
|
+
# Filter by checking the object snapshot via status
|
|
299
|
+
pass # Simplified: show all, type filtering would need snapshot query
|
|
300
|
+
|
|
301
|
+
if not files:
|
|
302
|
+
return [types.TextContent(type="text", text="No schema documentation available. Run snowflake_sync first.")]
|
|
303
|
+
|
|
304
|
+
by_db: dict = {}
|
|
305
|
+
for f in files:
|
|
306
|
+
key = f"{f.get('database_name','?')}.{f.get('schema_name','?')}"
|
|
307
|
+
by_db.setdefault(key, []).append(f.get("object_name", "?"))
|
|
308
|
+
|
|
309
|
+
lines = [f"Tracked objects ({len(files)} total):\n"]
|
|
310
|
+
for group, objs in sorted(by_db.items()):
|
|
311
|
+
lines.append(f"\n📁 {group} ({len(objs)} objects)")
|
|
312
|
+
for o in sorted(objs):
|
|
313
|
+
lines.append(f" • {o}")
|
|
314
|
+
return [types.TextContent(type="text", text="\n".join(lines))]
|
|
315
|
+
|
|
316
|
+
# ── snowflake_get_status ──────────────────────────────────────────────────
|
|
317
|
+
elif name == "snowflake_get_status":
|
|
318
|
+
status = _call_proc("get_status")
|
|
319
|
+
lines = ["SnowSyncMD Status\n"]
|
|
320
|
+
lines.append(f"Task state: {status.get('task_state', '?')}")
|
|
321
|
+
lines.append(f"Objects tracked: {status.get('total_objects_tracked', 0)}")
|
|
322
|
+
lines.append(f"MD files: {status.get('md_files_present', 0)}")
|
|
323
|
+
lines.append(f"Last scan: {status.get('last_scan_at', 'Never')}")
|
|
324
|
+
lines.append(f"Pending regen: {status.get('dirty_count', 0)}")
|
|
325
|
+
lines.append("\nDatabases:")
|
|
326
|
+
for db in status.get("databases", []):
|
|
327
|
+
enabled = "✅" if db.get("is_enabled") else "⛔"
|
|
328
|
+
lines.append(
|
|
329
|
+
f" {enabled} {db['database_name']} "
|
|
330
|
+
f"priority={db['priority']} "
|
|
331
|
+
f"objects={db['object_count']}"
|
|
332
|
+
)
|
|
333
|
+
return [types.TextContent(type="text", text="\n".join(lines))]
|
|
334
|
+
|
|
335
|
+
# ── snowflake_sync ────────────────────────────────────────────────────────
|
|
336
|
+
elif name == "snowflake_sync":
|
|
337
|
+
database = arguments.get("database")
|
|
338
|
+
if database:
|
|
339
|
+
result = _call_proc("sync_database", database.upper())
|
|
340
|
+
msg = f"Sync complete for {database.upper()}."
|
|
341
|
+
else:
|
|
342
|
+
result = _call_proc("sync_now")
|
|
343
|
+
msg = "Sync complete for all databases."
|
|
344
|
+
|
|
345
|
+
if isinstance(result, dict):
|
|
346
|
+
scan_r = result.get("scan") or {}
|
|
347
|
+
gen_r = result.get("generate") or {}
|
|
348
|
+
msg += (
|
|
349
|
+
f"\n Scanned: {scan_r.get('objects_scanned', 0)}"
|
|
350
|
+
f"\n Changed: {scan_r.get('objects_changed', 0)}"
|
|
351
|
+
f"\n MD files: {gen_r.get('md_files_written', 0)}"
|
|
352
|
+
f"\n Duration: {result.get('total_duration_seconds', '?')}s"
|
|
353
|
+
)
|
|
354
|
+
return [types.TextContent(type="text", text=msg)]
|
|
355
|
+
|
|
356
|
+
return [types.TextContent(type="text", text=f"Unknown tool: {name}")]
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
360
|
+
# Entry point
|
|
361
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
async def main():
|
|
364
|
+
async with mcp.server.stdio.stdio_server() as (read, write):
|
|
365
|
+
await server.run(read, write, server.create_initialization_options())
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
if __name__ == "__main__":
|
|
369
|
+
asyncio.run(main())
|