jmd-mcp-sql 0.4__py3-none-any.whl
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.
- jmd_mcp_sql/__init__.py +4 -0
- jmd_mcp_sql/schema.py +125 -0
- jmd_mcp_sql/server.py +355 -0
- jmd_mcp_sql/translator.py +1625 -0
- jmd_mcp_sql-0.4.dist-info/METADATA +345 -0
- jmd_mcp_sql-0.4.dist-info/RECORD +10 -0
- jmd_mcp_sql-0.4.dist-info/WHEEL +5 -0
- jmd_mcp_sql-0.4.dist-info/entry_points.txt +2 -0
- jmd_mcp_sql-0.4.dist-info/licenses/LICENSE +21 -0
- jmd_mcp_sql-0.4.dist-info/top_level.txt +1 -0
jmd_mcp_sql/__init__.py
ADDED
jmd_mcp_sql/schema.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""SQLite schema introspection — table and column metadata.
|
|
2
|
+
|
|
3
|
+
This module is intentionally kept separate from the SQL-translation layer
|
|
4
|
+
so that schema changes (e.g. tables created at runtime) can be picked up
|
|
5
|
+
by constructing a fresh SchemaInspector without touching translation logic.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sqlite3
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ColumnInfo:
|
|
15
|
+
"""Metadata for a single column in a SQLite table or view."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
type: str # SQLite declared type, e.g. "TEXT", "INTEGER", "REAL"
|
|
19
|
+
primary_key: bool
|
|
20
|
+
nullable: bool # True when the column has no NOT NULL constraint
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class TableInfo:
|
|
25
|
+
"""Metadata for a single table or view."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
is_view: bool = False
|
|
29
|
+
columns: list[ColumnInfo] = field(default_factory=list)
|
|
30
|
+
|
|
31
|
+
@property
|
|
32
|
+
def primary_keys(self) -> list[str]:
|
|
33
|
+
"""Return the names of all primary-key columns."""
|
|
34
|
+
return [c.name for c in self.columns if c.primary_key]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SchemaInspector:
|
|
38
|
+
"""Lazily loads and caches the database schema.
|
|
39
|
+
|
|
40
|
+
The cache is invalidated (replaced) whenever the server modifies the
|
|
41
|
+
schema (CREATE TABLE, ALTER TABLE, DROP TABLE). Pass a fresh
|
|
42
|
+
SchemaInspector instance to SQLTranslator after any DDL operation.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, conn: sqlite3.Connection) -> None:
|
|
46
|
+
"""Store the connection and initialise the lazy cache."""
|
|
47
|
+
self._conn = conn
|
|
48
|
+
# None means "not yet loaded"; an empty dict means "loaded, no tables".
|
|
49
|
+
self._cache: dict[str, TableInfo] | None = None
|
|
50
|
+
|
|
51
|
+
def tables(self) -> dict[str, TableInfo]:
|
|
52
|
+
"""Return all user tables and views, keyed by exact name."""
|
|
53
|
+
if self._cache is None:
|
|
54
|
+
self._cache = self._load()
|
|
55
|
+
return self._cache
|
|
56
|
+
|
|
57
|
+
def resolve(self, label: str) -> TableInfo | None:
|
|
58
|
+
"""Map a JMD document label to a table, case-insensitively.
|
|
59
|
+
|
|
60
|
+
JMD labels are written by an LLM which may use any capitalisation
|
|
61
|
+
or pluralisation. This method tries several candidate spellings so
|
|
62
|
+
that ``# Order``, ``# Orders``, and ``# orders`` all resolve to the
|
|
63
|
+
same underlying table.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
label: The heading label from the JMD document, e.g. ``"Order"``.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The matching TableInfo, or None if no table matches.
|
|
70
|
+
"""
|
|
71
|
+
tables = self.tables()
|
|
72
|
+
|
|
73
|
+
# Build candidate spellings: original, lower-case, with/without a
|
|
74
|
+
# trailing "s". This covers the most common singular/plural mismatch
|
|
75
|
+
# between LLM-generated labels and actual table names.
|
|
76
|
+
stripped = (
|
|
77
|
+
label[:-1] if label.endswith("s") and len(label) > 1 else None
|
|
78
|
+
)
|
|
79
|
+
stripped_lower = (
|
|
80
|
+
label.lower()[:-1]
|
|
81
|
+
if label.lower().endswith("s") and len(label) > 1
|
|
82
|
+
else None
|
|
83
|
+
)
|
|
84
|
+
candidates = [
|
|
85
|
+
label,
|
|
86
|
+
label.lower(),
|
|
87
|
+
label + "s",
|
|
88
|
+
label.lower() + "s",
|
|
89
|
+
*(([stripped, stripped_lower]) if stripped else []),
|
|
90
|
+
]
|
|
91
|
+
for name in candidates:
|
|
92
|
+
if name in tables:
|
|
93
|
+
return tables[name]
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
def _load(self) -> dict[str, TableInfo]:
|
|
97
|
+
"""Query sqlite_master and PRAGMA table_info for every object."""
|
|
98
|
+
cur = self._conn.cursor()
|
|
99
|
+
|
|
100
|
+
# Fetch all user-defined tables and views; skip SQLite internals.
|
|
101
|
+
cur.execute("""
|
|
102
|
+
SELECT name, type FROM sqlite_master
|
|
103
|
+
WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'
|
|
104
|
+
ORDER BY name
|
|
105
|
+
""")
|
|
106
|
+
result = {}
|
|
107
|
+
for (table_name, obj_type) in cur.fetchall():
|
|
108
|
+
# PRAGMA table_info returns one row per column:
|
|
109
|
+
# (cid, name, type, notnull, dflt_value, pk)
|
|
110
|
+
cur.execute(f'PRAGMA table_info("{table_name}")')
|
|
111
|
+
columns = [
|
|
112
|
+
ColumnInfo(
|
|
113
|
+
name=row[1],
|
|
114
|
+
type=row[2],
|
|
115
|
+
primary_key=bool(row[5]), # pk > 0 means part of PK
|
|
116
|
+
nullable=not row[3], # notnull=1 → not nullable
|
|
117
|
+
)
|
|
118
|
+
for row in cur.fetchall()
|
|
119
|
+
]
|
|
120
|
+
result[table_name] = TableInfo(
|
|
121
|
+
name=table_name,
|
|
122
|
+
is_view=(obj_type == "view"),
|
|
123
|
+
columns=columns,
|
|
124
|
+
)
|
|
125
|
+
return result
|
jmd_mcp_sql/server.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""JMD MCP server for SQLite.
|
|
2
|
+
|
|
3
|
+
This module wires together the MCP framework (FastMCP), the SQL translator,
|
|
4
|
+
and the database connection. It exposes three tools that an LLM can call:
|
|
5
|
+
|
|
6
|
+
read — query records, filter with QBE, or describe table schemas
|
|
7
|
+
write — insert/replace records or create/extend tables
|
|
8
|
+
delete — remove records or drop tables
|
|
9
|
+
|
|
10
|
+
The server can be started against any SQLite database file by passing its
|
|
11
|
+
path as a command-line argument. When no argument is given, it uses the
|
|
12
|
+
bundled Northwind demo database, creating it automatically from the SQL
|
|
13
|
+
dump (``northwind.sql``) on the first run.
|
|
14
|
+
|
|
15
|
+
Usage::
|
|
16
|
+
|
|
17
|
+
# Against a custom database
|
|
18
|
+
python -m jmd_mcp_sql.server /path/to/mydb.db
|
|
19
|
+
|
|
20
|
+
# Against the Northwind demo
|
|
21
|
+
python -m jmd_mcp_sql.server
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import sqlite3
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from jmd import serialize
|
|
30
|
+
from mcp.server.fastmcp import FastMCP
|
|
31
|
+
|
|
32
|
+
from .translator import SQLTranslator
|
|
33
|
+
|
|
34
|
+
_INSTRUCTIONS = """
|
|
35
|
+
This server exposes a SQLite database through three tools —
|
|
36
|
+
read, write, delete — using JMD (JSON Markdown) as the data format.
|
|
37
|
+
|
|
38
|
+
## JMD document syntax
|
|
39
|
+
|
|
40
|
+
Every document starts with a heading line that sets the document type
|
|
41
|
+
and table name, followed by key: value pairs (one per line):
|
|
42
|
+
|
|
43
|
+
# Product → data document (exact lookup / insert-or-replace)
|
|
44
|
+
#? Product → query document (filter / list)
|
|
45
|
+
#! Product → schema document (describe / create / drop table)
|
|
46
|
+
#- Product → delete document (delete matching records)
|
|
47
|
+
|
|
48
|
+
key: value → string, integer, or float — inferred automatically
|
|
49
|
+
key: true/false → boolean
|
|
50
|
+
|
|
51
|
+
## Discovering the database
|
|
52
|
+
|
|
53
|
+
To see which tables exist, read each table's schema:
|
|
54
|
+
|
|
55
|
+
read("#! Customers")
|
|
56
|
+
|
|
57
|
+
This returns a #! document with column names, JMD types, and modifiers
|
|
58
|
+
(readonly = primary key, optional = nullable).
|
|
59
|
+
|
|
60
|
+
## Typical workflows
|
|
61
|
+
|
|
62
|
+
**List all rows (small tables only):**
|
|
63
|
+
read("#? Orders")
|
|
64
|
+
|
|
65
|
+
**Filter rows — equality:**
|
|
66
|
+
read("#? Orders\nstatus: shipped")
|
|
67
|
+
|
|
68
|
+
**Filter rows — comparison:**
|
|
69
|
+
read("#? Orders\nFreight: > 50")
|
|
70
|
+
|
|
71
|
+
**Filter rows — alternation (OR):**
|
|
72
|
+
read("#? Orders\nShipCountry: Germany|France|UK")
|
|
73
|
+
|
|
74
|
+
**Filter rows — contains (case-insensitive substring):**
|
|
75
|
+
read("#? Customers\nCompanyName: ~Corp")
|
|
76
|
+
|
|
77
|
+
**Filter rows — regex pattern:**
|
|
78
|
+
read("#? Products\nProductName: ^Chai.*")
|
|
79
|
+
|
|
80
|
+
**Filter rows — negation (composes with any operator):**
|
|
81
|
+
read("#? Orders\nShipCountry: !Germany")
|
|
82
|
+
read("#? Products\nProductName: !^LEGACY.*")
|
|
83
|
+
|
|
84
|
+
**Look up one record:**
|
|
85
|
+
read("# Customers\nid: 42")
|
|
86
|
+
|
|
87
|
+
**Insert or replace a record:**
|
|
88
|
+
write("# Orders\nid: 1\nstatus: pending\ntotal: 99.90")
|
|
89
|
+
|
|
90
|
+
**Create a table:**
|
|
91
|
+
write("#! Products\nid: integer readonly\nname: string\n"
|
|
92
|
+
"price: float optional")
|
|
93
|
+
|
|
94
|
+
**Delete a record:**
|
|
95
|
+
delete("#- Orders\nid: 1")
|
|
96
|
+
|
|
97
|
+
**Drop a table:**
|
|
98
|
+
delete("#! OldTable")
|
|
99
|
+
|
|
100
|
+
## Pagination
|
|
101
|
+
|
|
102
|
+
IMPORTANT: Always use pagination when querying tables that may contain many
|
|
103
|
+
rows. Without pagination, large result sets will exceed your context window.
|
|
104
|
+
|
|
105
|
+
Use frontmatter fields before the #? heading to control pagination:
|
|
106
|
+
|
|
107
|
+
read("page-size: 50\npage: 1\n\n#? Orders")
|
|
108
|
+
|
|
109
|
+
The response carries pagination metadata as frontmatter —
|
|
110
|
+
before the root heading:
|
|
111
|
+
|
|
112
|
+
total: 830
|
|
113
|
+
page: 1
|
|
114
|
+
pages: 17
|
|
115
|
+
page-size: 50
|
|
116
|
+
|
|
117
|
+
# Orders
|
|
118
|
+
## data[]
|
|
119
|
+
- OrderID: 10248
|
|
120
|
+
...
|
|
121
|
+
|
|
122
|
+
Use `total` and `pages` to determine whether to fetch more pages.
|
|
123
|
+
|
|
124
|
+
**Count only** (no rows returned):
|
|
125
|
+
read("count: true\n\n#? Orders")
|
|
126
|
+
|
|
127
|
+
Returns: `count: 830\n\n# Orders`
|
|
128
|
+
|
|
129
|
+
**Rule of thumb:** Use `page-size: 50` for any table you haven't inspected before.
|
|
130
|
+
For tables with fewer than ~20 rows (e.g. Categories, Shippers) pagination is
|
|
131
|
+
optional.
|
|
132
|
+
|
|
133
|
+
## Field projection
|
|
134
|
+
|
|
135
|
+
Use `select:` frontmatter to return only specific columns — reduces response
|
|
136
|
+
size and keeps context windows clean.
|
|
137
|
+
|
|
138
|
+
select: OrderID, EmployeeID
|
|
139
|
+
|
|
140
|
+
#? Orders
|
|
141
|
+
|
|
142
|
+
Works with `#` (data) and `#?` (query) documents, including aggregation
|
|
143
|
+
(where `select:` filters the result columns after the GROUP BY).
|
|
144
|
+
|
|
145
|
+
## Joins
|
|
146
|
+
|
|
147
|
+
Use `join:` frontmatter to query across multiple tables in one call.
|
|
148
|
+
The value is `<TableName> on <JoinColumn>` (INNER JOIN, equi-join).
|
|
149
|
+
|
|
150
|
+
join: Order Details on OrderID
|
|
151
|
+
sum: UnitPrice * Quantity * (1 - Discount) as revenue
|
|
152
|
+
group: EmployeeID
|
|
153
|
+
sort: revenue desc
|
|
154
|
+
|
|
155
|
+
#? Orders
|
|
156
|
+
|
|
157
|
+
Multiple joins: comma-separated in a single `join:` value.
|
|
158
|
+
|
|
159
|
+
join: Order Details on OrderID, Employees on EmployeeID
|
|
160
|
+
|
|
161
|
+
**Expression syntax in aggregation with joins:**
|
|
162
|
+
Use `<expression> as <alias>` to compute derived values:
|
|
163
|
+
|
|
164
|
+
sum: UnitPrice * Quantity * (1 - Discount) as revenue
|
|
165
|
+
|
|
166
|
+
The alias becomes the result column name. Without `as`, the default alias
|
|
167
|
+
`<func>_<field>` applies (e.g. `sum_Freight`).
|
|
168
|
+
|
|
169
|
+
Only column names, numeric literals, and arithmetic operators
|
|
170
|
+
(`+`, `-`, `*`, `/`) are allowed in expressions — no subqueries.
|
|
171
|
+
|
|
172
|
+
## Aggregation
|
|
173
|
+
|
|
174
|
+
Aggregation is expressed as frontmatter before the #? heading.
|
|
175
|
+
QBE filter fields narrow rows *before* aggregation (SQL WHERE).
|
|
176
|
+
The `having:` key filters *after* aggregation (SQL HAVING).
|
|
177
|
+
|
|
178
|
+
| Key | SQL | Result column name |
|
|
179
|
+
| group: f1, f2 | GROUP BY | (grouping keys pass through) |
|
|
180
|
+
| sum: field | SUM(field) | sum_field |
|
|
181
|
+
| avg: field | AVG(field) | avg_field |
|
|
182
|
+
| min: field | MIN(field) | min_field |
|
|
183
|
+
| max: field | MAX(field) | max_field |
|
|
184
|
+
| count | COUNT(*) | count |
|
|
185
|
+
|
|
186
|
+
Multiple fields per function: `sum: Freight, Total`
|
|
187
|
+
→ `sum_Freight`, `sum_Total`.
|
|
188
|
+
|
|
189
|
+
sort: sum_revenue desc, EmployeeID asc → ORDER BY (multiple columns, mixed)
|
|
190
|
+
having: count > 5 → HAVING COUNT(*) > 5
|
|
191
|
+
having: sum_Freight > 1000, count > 2 → HAVING ... AND ... (comma = AND)
|
|
192
|
+
|
|
193
|
+
`having:` supports: >, >=, <, <=, =
|
|
194
|
+
`sort:` references any result column — grouping keys or aggregate aliases.
|
|
195
|
+
`page-size:` and `page:` apply to the aggregated result set.
|
|
196
|
+
|
|
197
|
+
**Example — top 3 employees by revenue:**
|
|
198
|
+
read("group: EmployeeID\nsum: revenue\nsort: sum_revenue desc\n"
|
|
199
|
+
"page-size: 3\n\n#? OrderDetails")
|
|
200
|
+
|
|
201
|
+
## Error handling
|
|
202
|
+
|
|
203
|
+
All tools return a `# Error` document on failure:
|
|
204
|
+
|
|
205
|
+
# Error
|
|
206
|
+
status: 400
|
|
207
|
+
code: not_found
|
|
208
|
+
message: No records found in Orders
|
|
209
|
+
|
|
210
|
+
Check the `code` field to decide how to proceed.
|
|
211
|
+
"""
|
|
212
|
+
|
|
213
|
+
# Global FastMCP instance. Tool functions are registered via @mcp.tool()
|
|
214
|
+
# decorators below and become available to the MCP host on connection.
|
|
215
|
+
mcp = FastMCP("jmd-mcp-sql", instructions=_INSTRUCTIONS)
|
|
216
|
+
|
|
217
|
+
# Set by main() before mcp.run(); None while the module is imported without
|
|
218
|
+
# a running server (e.g. during tests or type-checking).
|
|
219
|
+
_translator: SQLTranslator | None = None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _t() -> SQLTranslator:
|
|
223
|
+
"""Return the active SQLTranslator, raising if the server is not running."""
|
|
224
|
+
if _translator is None:
|
|
225
|
+
raise RuntimeError("Server not initialized — call main() first")
|
|
226
|
+
return _translator
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@mcp.tool()
|
|
230
|
+
def read(document: str) -> str:
|
|
231
|
+
"""Read records or table schema using a JMD document.
|
|
232
|
+
|
|
233
|
+
Data document (# Label): look up records by exact field values.
|
|
234
|
+
Returns a single record if exactly one matches, a list otherwise.
|
|
235
|
+
|
|
236
|
+
# Order
|
|
237
|
+
id: 42
|
|
238
|
+
|
|
239
|
+
Query-by-Example document (#? Label): filter records by field values.
|
|
240
|
+
Omitted fields match any value. Always returns a list.
|
|
241
|
+
|
|
242
|
+
#? Order
|
|
243
|
+
status: pending
|
|
244
|
+
|
|
245
|
+
Schema document (#! Label): describe the table structure.
|
|
246
|
+
Returns a #! document with column names, types, and modifiers.
|
|
247
|
+
|
|
248
|
+
#! Order
|
|
249
|
+
"""
|
|
250
|
+
try:
|
|
251
|
+
return _t().read(document)
|
|
252
|
+
except Exception as e:
|
|
253
|
+
return serialize(
|
|
254
|
+
{"status": 400, "code": "read_failed", "message": str(e)},
|
|
255
|
+
label="Error",
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@mcp.tool()
|
|
260
|
+
def write(document: str) -> str:
|
|
261
|
+
"""Write a record or define a table schema using a JMD document.
|
|
262
|
+
|
|
263
|
+
Data document (# Label): insert or replace a record.
|
|
264
|
+
If a record with the same primary key exists, it is replaced.
|
|
265
|
+
Returns the written record as confirmed by the database.
|
|
266
|
+
|
|
267
|
+
# Order
|
|
268
|
+
id: 42
|
|
269
|
+
status: shipped
|
|
270
|
+
total: 149.99
|
|
271
|
+
|
|
272
|
+
Schema document (#! Label): create or extend a table.
|
|
273
|
+
If the table does not exist, it is created. If it exists, new
|
|
274
|
+
columns are added. Existing columns are never modified or removed.
|
|
275
|
+
|
|
276
|
+
#! Order
|
|
277
|
+
id: integer readonly
|
|
278
|
+
status: string
|
|
279
|
+
total: float optional
|
|
280
|
+
"""
|
|
281
|
+
try:
|
|
282
|
+
return _t().write(document)
|
|
283
|
+
except Exception as e:
|
|
284
|
+
return serialize(
|
|
285
|
+
{"status": 400, "code": "write_failed", "message": str(e)},
|
|
286
|
+
label="Error",
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@mcp.tool()
|
|
291
|
+
def delete(document: str) -> str:
|
|
292
|
+
"""Delete records or drop a table using a JMD document.
|
|
293
|
+
|
|
294
|
+
Delete document (#- Label): delete matching records.
|
|
295
|
+
All fields act as filters. At least one field is required.
|
|
296
|
+
Returns the number of deleted records.
|
|
297
|
+
|
|
298
|
+
#- Order
|
|
299
|
+
id: 42
|
|
300
|
+
|
|
301
|
+
Schema document (#! Label): drop the entire table.
|
|
302
|
+
|
|
303
|
+
#! Order
|
|
304
|
+
"""
|
|
305
|
+
try:
|
|
306
|
+
return _t().delete(document)
|
|
307
|
+
except Exception as e:
|
|
308
|
+
return serialize(
|
|
309
|
+
{"status": 400, "code": "delete_failed", "message": str(e)},
|
|
310
|
+
label="Error",
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def main() -> None:
|
|
315
|
+
"""Entry point: parse arguments, open the database, and start the server."""
|
|
316
|
+
parser = argparse.ArgumentParser(description="JMD MCP server for SQLite")
|
|
317
|
+
parser.add_argument(
|
|
318
|
+
"db",
|
|
319
|
+
nargs="?",
|
|
320
|
+
default=None,
|
|
321
|
+
help="Path to SQLite database file (default: Northwind demo)",
|
|
322
|
+
)
|
|
323
|
+
args = parser.parse_args()
|
|
324
|
+
|
|
325
|
+
if args.db:
|
|
326
|
+
db_path = Path(args.db)
|
|
327
|
+
if not db_path.exists():
|
|
328
|
+
raise SystemExit(f"Database not found: {db_path}")
|
|
329
|
+
else:
|
|
330
|
+
db_path = Path(__file__).parent / "northwind.db"
|
|
331
|
+
if not db_path.exists():
|
|
332
|
+
# The demo database ships as a plain-text SQL dump. Create the
|
|
333
|
+
# binary .db from it on first run and reuse it on subsequent runs.
|
|
334
|
+
sql_path = Path(__file__).parent / "northwind.sql"
|
|
335
|
+
if not sql_path.exists():
|
|
336
|
+
raise SystemExit(
|
|
337
|
+
"Northwind demo database not found. "
|
|
338
|
+
"northwind.sql is missing from the "
|
|
339
|
+
"jmd_mcp_sql/ package directory."
|
|
340
|
+
)
|
|
341
|
+
conn = sqlite3.connect(str(db_path))
|
|
342
|
+
conn.executescript(sql_path.read_text(encoding="utf-8"))
|
|
343
|
+
conn.close()
|
|
344
|
+
|
|
345
|
+
global _translator
|
|
346
|
+
# check_same_thread=False is safe here because FastMCP processes one
|
|
347
|
+
# request at a time over stdio; there is no concurrent access.
|
|
348
|
+
conn = sqlite3.connect(str(db_path), check_same_thread=False)
|
|
349
|
+
_translator = SQLTranslator(conn)
|
|
350
|
+
|
|
351
|
+
mcp.run()
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
if __name__ == "__main__":
|
|
355
|
+
main()
|