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.
@@ -0,0 +1,4 @@
1
+ """JMD MCP server for SQLite."""
2
+ from .server import main
3
+
4
+ __all__ = ["main"]
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()