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.
@@ -0,0 +1,19 @@
1
+ # Credentials
2
+ .env
3
+ *.env
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .venv/
12
+ venv/
13
+ env/
14
+
15
+ # Output
16
+ output/md/
17
+
18
+ # OS
19
+ .DS_Store
@@ -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())