connectpak-core 0.1.0__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,30 @@
1
+ """Core BaseConnector, read-only guard, and MCP server runner."""
2
+
3
+ from connectpak.core.base import (
4
+ BaseConnector,
5
+ Column,
6
+ ForeignKey,
7
+ Index,
8
+ QueryResult,
9
+ TableSchema,
10
+ )
11
+ from connectpak.core.guard import (
12
+ WriteOperationError,
13
+ assert_no_write_keywords,
14
+ assert_read_only,
15
+ )
16
+ from connectpak.core.server import build_server, serve
17
+
18
+ __all__ = [
19
+ "BaseConnector",
20
+ "Column",
21
+ "ForeignKey",
22
+ "Index",
23
+ "QueryResult",
24
+ "TableSchema",
25
+ "WriteOperationError",
26
+ "assert_no_write_keywords",
27
+ "assert_read_only",
28
+ "build_server",
29
+ "serve",
30
+ ]
@@ -0,0 +1,90 @@
1
+ """BaseConnector contract and shared dataclasses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class Column:
12
+ name: str
13
+ type: str
14
+ nullable: bool
15
+ default: str | None = None
16
+ is_primary_key: bool = False
17
+ comment: str | None = None
18
+
19
+
20
+ @dataclass
21
+ class ForeignKey:
22
+ columns: list[str]
23
+ referenced_schema: str | None
24
+ referenced_table: str
25
+ referenced_columns: list[str]
26
+ name: str | None = None
27
+
28
+
29
+ @dataclass
30
+ class Index:
31
+ name: str
32
+ columns: list[str]
33
+ unique: bool = False
34
+
35
+
36
+ @dataclass
37
+ class TableSchema:
38
+ schema: str | None
39
+ name: str
40
+ columns: list[Column]
41
+ primary_key: list[str] = field(default_factory=list)
42
+ foreign_keys: list[ForeignKey] = field(default_factory=list)
43
+ indexes: list[Index] = field(default_factory=list)
44
+ comment: str | None = None
45
+
46
+
47
+ @dataclass
48
+ class QueryResult:
49
+ columns: list[str]
50
+ rows: list[list[Any]]
51
+ row_count: int
52
+ truncated: bool = False
53
+ elapsed_ms: float | None = None
54
+
55
+
56
+ class BaseConnector(ABC):
57
+ """Abstract base class every engine connector implements.
58
+
59
+ Subclasses MUST keep the safety contract intact:
60
+ parser guard + engine-level read-only + statement timeout + row cap.
61
+ """
62
+
63
+ name: str = "base"
64
+ dialect: str | None = None
65
+ allow_write: bool = False
66
+ statement_timeout_ms: int = 30_000
67
+ default_max_rows: int = 1000
68
+
69
+ @abstractmethod
70
+ def list_schemas(self) -> list[str]: ...
71
+
72
+ @abstractmethod
73
+ def list_tables(self, schema: str | None = None) -> list[tuple[str | None, str]]: ...
74
+
75
+ @abstractmethod
76
+ def describe_table(self, name: str, schema: str | None = None) -> TableSchema: ...
77
+
78
+ @abstractmethod
79
+ def sample_table(
80
+ self, name: str, schema: str | None = None, limit: int = 10
81
+ ) -> QueryResult: ...
82
+
83
+ @abstractmethod
84
+ def query(self, sql: str, max_rows: int | None = None) -> QueryResult: ...
85
+
86
+ @abstractmethod
87
+ def explain(self, sql: str) -> str: ...
88
+
89
+ @abstractmethod
90
+ def close(self) -> None: ...
@@ -0,0 +1,150 @@
1
+ """Read-only query guards.
2
+
3
+ Blocks state-mutating statements before they reach the engine. This is the
4
+ first of two layers — the engine-level read-only mode is the second. Both
5
+ must remain intact.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+
12
+ import sqlglot
13
+ from sqlglot import exp
14
+
15
+
16
+ class WriteOperationError(ValueError):
17
+ """Raised when a query contains any state-mutating operation."""
18
+
19
+
20
+ _ALLOWED_TOP_LEVEL: tuple[type[exp.Expression], ...] = (
21
+ exp.Select,
22
+ exp.Union,
23
+ exp.Intersect,
24
+ exp.Except,
25
+ exp.Subquery,
26
+ exp.With,
27
+ exp.Describe,
28
+ exp.Show,
29
+ exp.Pragma,
30
+ )
31
+
32
+ _FORBIDDEN: tuple[type[exp.Expression], ...] = (
33
+ exp.Insert,
34
+ exp.Update,
35
+ exp.Delete,
36
+ exp.Merge,
37
+ exp.Create,
38
+ exp.Drop,
39
+ exp.Alter,
40
+ exp.TruncateTable,
41
+ exp.Command,
42
+ # `SELECT ... INTO new_table` creates a table (T-SQL / Postgres): a write
43
+ # hiding inside an otherwise-allowed Select. exp.Into also covers the
44
+ # nested-subquery form because we walk the whole tree.
45
+ exp.Into,
46
+ # `SELECT ... FOR UPDATE` / `FOR SHARE` acquires row locks — write intent,
47
+ # and it can block other transactions. Not read-only.
48
+ exp.Lock,
49
+ )
50
+
51
+ # SQLite table-valued informational pragmas. sqlglot's sqlite dialect parses
52
+ # both `PRAGMA table_info(users)` and the mutating `PRAGMA journal_mode=WAL`
53
+ # as an EQ node, so we cannot tell an argument-taking read pragma from a
54
+ # setter structurally. These names are always reads regardless of form; any
55
+ # other pragma carrying an assignment (`name = value`) is treated as a setter
56
+ # and rejected.
57
+ _READ_PRAGMAS: frozenset[str] = frozenset(
58
+ {
59
+ "table_info",
60
+ "table_xinfo",
61
+ "table_list",
62
+ "index_info",
63
+ "index_xinfo",
64
+ "index_list",
65
+ "foreign_key_list",
66
+ "foreign_key_check",
67
+ "collation_list",
68
+ "database_list",
69
+ "function_list",
70
+ "module_list",
71
+ "pragma_list",
72
+ "compile_options",
73
+ "integrity_check",
74
+ "quick_check",
75
+ "freelist_count",
76
+ "page_count",
77
+ "data_version",
78
+ }
79
+ )
80
+
81
+
82
+ def _reject_mutating_pragma(pragma: exp.Pragma) -> None:
83
+ """Reject a PRAGMA that assigns a value (a setter), allow read forms.
84
+
85
+ Read forms are: a bare name (`PRAGMA user_version`) or a table-valued
86
+ informational pragma (`PRAGMA table_info(users)`). A setter such as
87
+ `PRAGMA journal_mode = WAL` or `PRAGMA user_version = 5` mutates and is
88
+ rejected.
89
+ """
90
+ inner = pragma.this
91
+ if not isinstance(inner, exp.EQ):
92
+ return # bare name → read form
93
+ name_node = inner.this
94
+ name = (name_node.name if hasattr(name_node, "name") else str(name_node)).lower()
95
+ if name not in _READ_PRAGMAS:
96
+ raise WriteOperationError(f"mutating PRAGMA is not read-only: {name}")
97
+
98
+
99
+ def assert_read_only(sql: str, dialect: str | None = None) -> None:
100
+ """Raise WriteOperationError if the SQL would change state.
101
+
102
+ - Empty / whitespace-only SQL is rejected.
103
+ - Each parsed top-level statement must be one of the allowed read shapes.
104
+ - The full AST is walked; any forbidden node anywhere triggers a reject.
105
+ This catches "WITH x AS (DELETE ...) SELECT ..." style attacks, as well
106
+ as `SELECT ... INTO`, `FOR UPDATE` locks, and mutating PRAGMA setters.
107
+ """
108
+ if sql is None or not sql.strip():
109
+ raise WriteOperationError("empty SQL")
110
+
111
+ try:
112
+ parsed = sqlglot.parse(sql, dialect=dialect)
113
+ except Exception as e:
114
+ raise WriteOperationError(f"could not parse SQL: {e}") from e
115
+
116
+ if not parsed:
117
+ raise WriteOperationError("empty SQL")
118
+
119
+ for stmt in parsed:
120
+ if stmt is None:
121
+ raise WriteOperationError("empty statement")
122
+
123
+ if not isinstance(stmt, _ALLOWED_TOP_LEVEL):
124
+ raise WriteOperationError(
125
+ f"top-level statement {type(stmt).__name__} is not read-only"
126
+ )
127
+
128
+ if isinstance(stmt, exp.Pragma):
129
+ _reject_mutating_pragma(stmt)
130
+
131
+ for node in stmt.walk():
132
+ target = node[0] if isinstance(node, tuple) else node
133
+ if isinstance(target, _FORBIDDEN):
134
+ raise WriteOperationError(
135
+ f"forbidden operation found in SQL: {type(target).__name__}"
136
+ )
137
+
138
+
139
+ def assert_no_write_keywords(text: str, keywords: tuple[str, ...]) -> None:
140
+ """Lightweight write-keyword check for query languages outside SQL.
141
+
142
+ Less robust than `assert_read_only`'s full AST walk: a keyword inside a
143
+ string literal can produce a false positive. Used for Cypher / CQL /
144
+ AQL / Gremlin where sqlglot does not provide a parser.
145
+ """
146
+ if text is None or not text.strip():
147
+ raise WriteOperationError("empty query")
148
+ for kw in keywords:
149
+ if re.search(rf"\b{re.escape(kw)}\b", text, re.IGNORECASE):
150
+ raise WriteOperationError(f"forbidden keyword in query: {kw}")
@@ -0,0 +1,92 @@
1
+ """MCP server runner.
2
+
3
+ Wraps a BaseConnector instance in a FastMCP server exposing six tools.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import asdict, is_dataclass
9
+ from typing import Any
10
+
11
+ from connectpak.core.base import BaseConnector
12
+
13
+
14
+ def _to_jsonable(obj: Any) -> Any:
15
+ if is_dataclass(obj) and not isinstance(obj, type):
16
+ return asdict(obj)
17
+ if isinstance(obj, list):
18
+ return [_to_jsonable(x) for x in obj]
19
+ if isinstance(obj, tuple):
20
+ return [_to_jsonable(x) for x in obj]
21
+ if isinstance(obj, dict):
22
+ return {k: _to_jsonable(v) for k, v in obj.items()}
23
+ return obj
24
+
25
+
26
+ def default_instructions(connector: BaseConnector) -> str:
27
+ """Server instructions telling the client which engine is behind the tools."""
28
+ language = (
29
+ f"SQL in the {connector.dialect} dialect"
30
+ if connector.dialect
31
+ else "the engine's native query format (see its connector docs)"
32
+ )
33
+ return (
34
+ f"connectpak {connector.name} connector. The `query` tool takes {language}. "
35
+ "This connection is read-only: writes are rejected by a parser guard and by "
36
+ "the engine's own read-only mode, and every query has a timeout and a row cap."
37
+ )
38
+
39
+
40
+ def build_server(
41
+ connector: BaseConnector,
42
+ server_name: str | None = None,
43
+ instructions: str | None = None,
44
+ ):
45
+ """Build a FastMCP server registering six tools wrapping the connector."""
46
+ from mcp.server.fastmcp import FastMCP
47
+
48
+ name = server_name or f"connectpak-{connector.name}"
49
+ mcp = FastMCP(name, instructions=instructions or default_instructions(connector))
50
+
51
+ @mcp.tool()
52
+ def list_schemas() -> list[str]:
53
+ """List schemas / namespaces visible to the connector."""
54
+ return connector.list_schemas()
55
+
56
+ @mcp.tool()
57
+ def list_tables(schema: str | None = None) -> list[dict[str, Any]]:
58
+ """List tables (and views) in the given schema, or all schemas if omitted."""
59
+ return [{"schema": s, "name": n} for s, n in connector.list_tables(schema=schema)]
60
+
61
+ @mcp.tool()
62
+ def describe_table(name: str, schema: str | None = None) -> dict[str, Any]:
63
+ """Return columns, primary key, foreign keys, and indexes for a table."""
64
+ return _to_jsonable(connector.describe_table(name=name, schema=schema))
65
+
66
+ @mcp.tool()
67
+ def sample_table(
68
+ name: str, schema: str | None = None, limit: int = 10
69
+ ) -> dict[str, Any]:
70
+ """Sample up to `limit` rows from the given table."""
71
+ return _to_jsonable(connector.sample_table(name=name, schema=schema, limit=limit))
72
+
73
+ @mcp.tool()
74
+ def query(sql: str, max_rows: int | None = None) -> dict[str, Any]:
75
+ """Run a read-only SQL query. Mutating statements are rejected."""
76
+ return _to_jsonable(connector.query(sql=sql, max_rows=max_rows))
77
+
78
+ @mcp.tool()
79
+ def explain(sql: str) -> str:
80
+ """Return the engine's EXPLAIN output for the given query."""
81
+ return connector.explain(sql=sql)
82
+
83
+ return mcp
84
+
85
+
86
+ def serve(connector: BaseConnector) -> None:
87
+ """Build and run the MCP server, closing the connector on shutdown."""
88
+ mcp = build_server(connector)
89
+ try:
90
+ mcp.run()
91
+ finally:
92
+ connector.close()
@@ -0,0 +1,23 @@
1
+ Metadata-Version: 2.5
2
+ Name: connectpak-core
3
+ Version: 0.1.0
4
+ Summary: Core BaseConnector, read-only SQL guard, and MCP server runner for connectpak.
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Requires-Dist: mcp<2,>=1.0
8
+ Requires-Dist: sqlglot>=23.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # connectpak-core
12
+
13
+ Core building blocks shared by every connectpak engine package:
14
+
15
+ - `BaseConnector` — abstract base class defining the connector contract.
16
+ - `assert_read_only` — sqlglot-based parser guard that blocks any
17
+ state-mutating SQL before it ever reaches the engine.
18
+ - `build_server` / `serve` — turns a connector into a Model Context Protocol
19
+ (MCP) server with six tools wrapping the connector methods.
20
+
21
+ You normally do not depend on this package directly — install one of the
22
+ engine packages (e.g. `connectpak-sql-postgres`) and it will pull `core` in
23
+ transitively.
@@ -0,0 +1,7 @@
1
+ connectpak/core/__init__.py,sha256=bwIoekzRtBZaCVP9dKKcEkJJf2r7X-hrt-O8IsUek5M,601
2
+ connectpak/core/base.py,sha256=QcK4vKyhW8eUYukRdFX-xg7fSxQcx7BrC4y5JZIjFZ8,2113
3
+ connectpak/core/guard.py,sha256=1bpOGwPvj7oSxYa6ViskWmvUAkxZ16X-sJTESpc_Lr8,4912
4
+ connectpak/core/server.py,sha256=QRFRLAIR-FojC04-nbcdykwr-8kRMngxKefMsZiFwK8,3221
5
+ connectpak_core-0.1.0.dist-info/METADATA,sha256=Nr2gCLfh-7V2fjsrPbpoMTpXMMGz2fqnN1GA0RLKQHM,871
6
+ connectpak_core-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ connectpak_core-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any