pgsafe-mcp 1.0.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.
- pgsafe/__init__.py +8 -0
- pgsafe/_genesis.py +11 -0
- pgsafe/adapters/base.py +26 -0
- pgsafe/adapters/postgres_adapter.py +72 -0
- pgsafe/adapters/sqlite_adapter.py +57 -0
- pgsafe/cli.py +74 -0
- pgsafe/guards/cost_analyzer.py +41 -0
- pgsafe/guards/readonly.py +57 -0
- pgsafe/server.py +248 -0
- pgsafe_mcp-1.0.0.dist-info/METADATA +186 -0
- pgsafe_mcp-1.0.0.dist-info/RECORD +15 -0
- pgsafe_mcp-1.0.0.dist-info/WHEEL +5 -0
- pgsafe_mcp-1.0.0.dist-info/entry_points.txt +3 -0
- pgsafe_mcp-1.0.0.dist-info/licenses/LICENSE +21 -0
- pgsafe_mcp-1.0.0.dist-info/top_level.txt +1 -0
pgsafe/__init__.py
ADDED
pgsafe/_genesis.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Upgrade notice. Printed to stderr so MCP stdio traffic on stdout is never affected."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
NOTICE = 'PgSafe MCP (open edition). Starter Kit, free: https://johanld.gumroad.com/l/khffw | Enterprise Suite ($19): https://johanld.gumroad.com/l/tzuvz'
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def print_upgrade_notice():
|
|
9
|
+
if os.environ.get("GENESIS_NO_NOTICE") == "1":
|
|
10
|
+
return
|
|
11
|
+
print(NOTICE, file=sys.stderr)
|
pgsafe/adapters/base.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base Database Adapter Interface for PgSafe.
|
|
3
|
+
"""
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import List, Dict, Any, Tuple
|
|
6
|
+
|
|
7
|
+
class BaseAdapter(ABC):
|
|
8
|
+
@abstractmethod
|
|
9
|
+
def connect(self):
|
|
10
|
+
"""Establishes database connection."""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def list_tables(self) -> List[str]:
|
|
15
|
+
"""Returns list of user tables in the database."""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def describe_table(self, table_name: str) -> List[Dict[str, Any]]:
|
|
20
|
+
"""Returns column names, types, and constraints for a table."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def execute_read_only(self, sql: str) -> Tuple[List[str], List[List[Any]]]:
|
|
25
|
+
"""Executes a read-only query and returns (columns, rows)."""
|
|
26
|
+
pass
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PostgreSQL Database Adapter with READ ONLY transaction guarantees.
|
|
3
|
+
"""
|
|
4
|
+
from typing import List, Dict, Any, Tuple, Optional
|
|
5
|
+
from .base import BaseAdapter
|
|
6
|
+
|
|
7
|
+
class PostgresAdapter(BaseAdapter):
|
|
8
|
+
def __init__(self, connection_url: str):
|
|
9
|
+
self.connection_url = connection_url
|
|
10
|
+
self._conn = None
|
|
11
|
+
|
|
12
|
+
def connect(self):
|
|
13
|
+
if self._conn:
|
|
14
|
+
return self._conn
|
|
15
|
+
|
|
16
|
+
try:
|
|
17
|
+
# Attempt psycopg2 or psycopg or asyncpg
|
|
18
|
+
import psycopg
|
|
19
|
+
self._conn = psycopg.connect(self.connection_url)
|
|
20
|
+
# Guarantee read-only session at PostgreSQL engine level
|
|
21
|
+
with self._conn.cursor() as cur:
|
|
22
|
+
cur.execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;")
|
|
23
|
+
return self._conn
|
|
24
|
+
except ImportError:
|
|
25
|
+
try:
|
|
26
|
+
import psycopg2
|
|
27
|
+
self._conn = psycopg2.connect(self.connection_url)
|
|
28
|
+
self._conn.set_session(readonly=True)
|
|
29
|
+
return self._conn
|
|
30
|
+
except ImportError:
|
|
31
|
+
raise RuntimeError(
|
|
32
|
+
"PostgreSQL driver not installed. Install psycopg via: pip install psycopg[binary] "
|
|
33
|
+
"or use SQLite adapter."
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
def list_tables(self) -> List[str]:
|
|
37
|
+
conn = self.connect()
|
|
38
|
+
with conn.cursor() as cur:
|
|
39
|
+
cur.execute("""
|
|
40
|
+
SELECT table_name
|
|
41
|
+
FROM information_schema.tables
|
|
42
|
+
WHERE table_schema = 'public'
|
|
43
|
+
ORDER BY table_name;
|
|
44
|
+
""")
|
|
45
|
+
return [r[0] for r in cur.fetchall()]
|
|
46
|
+
|
|
47
|
+
def describe_table(self, table_name: str) -> List[Dict[str, Any]]:
|
|
48
|
+
conn = self.connect()
|
|
49
|
+
with conn.cursor() as cur:
|
|
50
|
+
cur.execute("""
|
|
51
|
+
SELECT column_name, data_type, is_nullable, column_default
|
|
52
|
+
FROM information_schema.columns
|
|
53
|
+
WHERE table_schema = 'public' AND table_name = %s
|
|
54
|
+
ORDER BY ordinal_position;
|
|
55
|
+
""", (table_name,))
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
"name": r[0],
|
|
59
|
+
"type": r[1],
|
|
60
|
+
"nullable": r[2] == "YES",
|
|
61
|
+
"default": r[3]
|
|
62
|
+
}
|
|
63
|
+
for r in cur.fetchall()
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
def execute_read_only(self, sql: str) -> Tuple[List[str], List[List[Any]]]:
|
|
67
|
+
conn = self.connect()
|
|
68
|
+
with conn.cursor() as cur:
|
|
69
|
+
cur.execute(sql)
|
|
70
|
+
columns = [desc[0] for desc in cur.description] if cur.description else []
|
|
71
|
+
rows = [list(r) for r in cur.fetchall()]
|
|
72
|
+
return columns, rows
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQLite Database Adapter with Read-Only URI enforcement.
|
|
3
|
+
"""
|
|
4
|
+
import sqlite3
|
|
5
|
+
from typing import List, Dict, Any, Tuple
|
|
6
|
+
from .base import BaseAdapter
|
|
7
|
+
|
|
8
|
+
class SQLiteAdapter(BaseAdapter):
|
|
9
|
+
def __init__(self, db_path: str):
|
|
10
|
+
self.db_path = db_path
|
|
11
|
+
self._conn = None
|
|
12
|
+
|
|
13
|
+
def connect(self):
|
|
14
|
+
if not self._conn:
|
|
15
|
+
# Enforce read-only mode via SQLite URI if file exists
|
|
16
|
+
uri = f"file:{self.db_path}?mode=ro"
|
|
17
|
+
try:
|
|
18
|
+
self._conn = sqlite3.connect(uri, uri=True, timeout=10.0)
|
|
19
|
+
except sqlite3.OperationalError:
|
|
20
|
+
# Fallback to standard connect with PRAGMA query_only
|
|
21
|
+
self._conn = sqlite3.connect(self.db_path, timeout=10.0)
|
|
22
|
+
self._conn.execute("PRAGMA query_only = ON;")
|
|
23
|
+
self._conn.row_factory = sqlite3.Row
|
|
24
|
+
return self._conn
|
|
25
|
+
|
|
26
|
+
def list_tables(self) -> List[str]:
|
|
27
|
+
conn = self.connect()
|
|
28
|
+
cursor = conn.cursor()
|
|
29
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name;")
|
|
30
|
+
return [row[0] for row in cursor.fetchall()]
|
|
31
|
+
|
|
32
|
+
def describe_table(self, table_name: str) -> List[Dict[str, Any]]:
|
|
33
|
+
conn = self.connect()
|
|
34
|
+
cursor = conn.cursor()
|
|
35
|
+
# Sanitize table_name to identifier
|
|
36
|
+
safe_name = "".join(c for c in table_name if c.isalnum() or c == "_")
|
|
37
|
+
cursor.execute(f"PRAGMA table_info({safe_name});")
|
|
38
|
+
rows = cursor.fetchall()
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
"cid": r[0],
|
|
42
|
+
"name": r[1],
|
|
43
|
+
"type": r[2],
|
|
44
|
+
"notnull": bool(r[3]),
|
|
45
|
+
"dflt_value": r[4],
|
|
46
|
+
"pk": bool(r[5])
|
|
47
|
+
}
|
|
48
|
+
for r in rows
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
def execute_read_only(self, sql: str) -> Tuple[List[str], List[List[Any]]]:
|
|
52
|
+
conn = self.connect()
|
|
53
|
+
cursor = conn.cursor()
|
|
54
|
+
cursor.execute(sql)
|
|
55
|
+
columns = [desc[0] for desc in cursor.description] if cursor.description else []
|
|
56
|
+
rows = [list(r) for r in cursor.fetchall()]
|
|
57
|
+
return columns, rows
|
pgsafe/cli.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PgSafe CLI inspection tool.
|
|
3
|
+
Usage:
|
|
4
|
+
python -m pgsafe.cli tables --db sqlite:///demo.db
|
|
5
|
+
python -m pgsafe.cli describe users --db sqlite:///demo.db
|
|
6
|
+
python -m pgsafe.cli query "SELECT * FROM users" --db sqlite:///demo.db
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from .adapters.sqlite_adapter import SQLiteAdapter
|
|
13
|
+
from .adapters.postgres_adapter import PostgresAdapter
|
|
14
|
+
from .guards.readonly import ReadOnlyGuard
|
|
15
|
+
from .guards.cost_analyzer import CostAnalyzer
|
|
16
|
+
|
|
17
|
+
def main():
|
|
18
|
+
parser = argparse.ArgumentParser(description="PgSafe CLI database utility")
|
|
19
|
+
parser.add_argument("--db", default="sqlite:///data/nexus.db", help="Database connection URL")
|
|
20
|
+
subparsers = parser.add_subparsers(dest="command", help="Sub-commands")
|
|
21
|
+
|
|
22
|
+
subparsers.add_parser("tables", help="List database tables")
|
|
23
|
+
|
|
24
|
+
desc_parser = subparsers.add_parser("describe", help="Describe table structure")
|
|
25
|
+
desc_parser.add_argument("table", help="Table name")
|
|
26
|
+
|
|
27
|
+
query_parser = subparsers.add_parser("query", help="Execute safe read-only query")
|
|
28
|
+
query_parser.add_argument("sql", help="SQL query")
|
|
29
|
+
query_parser.add_argument("--limit", type=int, default=100, help="Row limit")
|
|
30
|
+
|
|
31
|
+
args = parser.parse_args()
|
|
32
|
+
|
|
33
|
+
url = args.db
|
|
34
|
+
if url.startswith("postgres://") or url.startswith("postgresql://"):
|
|
35
|
+
adapter = PostgresAdapter(url)
|
|
36
|
+
else:
|
|
37
|
+
clean_path = url.replace("sqlite:///", "").replace("sqlite://", "")
|
|
38
|
+
adapter = SQLiteAdapter(clean_path)
|
|
39
|
+
|
|
40
|
+
if args.command == "tables":
|
|
41
|
+
tables = adapter.list_tables()
|
|
42
|
+
print(json.dumps({"tables": tables, "count": len(tables)}, indent=2))
|
|
43
|
+
|
|
44
|
+
elif args.command == "describe":
|
|
45
|
+
cols = adapter.describe_table(args.table)
|
|
46
|
+
print(json.dumps({"table": args.table, "columns": cols}, indent=2))
|
|
47
|
+
|
|
48
|
+
elif args.command == "query":
|
|
49
|
+
is_safe, reason = ReadOnlyGuard.validate(args.sql)
|
|
50
|
+
if not is_safe:
|
|
51
|
+
print(f"[SECURITY BLOCKED] {reason}", file=sys.stderr)
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
safe_sql = CostAnalyzer.enforce_limit(args.sql, default_limit=args.limit)
|
|
55
|
+
cols, rows = adapter.execute_read_only(safe_sql)
|
|
56
|
+
print(json.dumps({"columns": cols, "rows": rows, "count": len(rows)}, indent=2))
|
|
57
|
+
|
|
58
|
+
else:
|
|
59
|
+
parser.print_help()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_main_impl = main
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def main():
|
|
67
|
+
from ._genesis import print_upgrade_notice
|
|
68
|
+
try:
|
|
69
|
+
return _main_impl()
|
|
70
|
+
finally:
|
|
71
|
+
print_upgrade_notice()
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
main()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Query Cost and Performance Safety Guard.
|
|
3
|
+
Enforces query limits and guards against unconstrained table scans.
|
|
4
|
+
"""
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
class CostAnalyzer:
|
|
8
|
+
@staticmethod
|
|
9
|
+
def enforce_limit(sql: str, default_limit: int = 100, max_limit: int = 1000) -> str:
|
|
10
|
+
"""
|
|
11
|
+
Inspects SQL and ensures a LIMIT clause is applied.
|
|
12
|
+
If a LIMIT is present and exceeds max_limit, caps it.
|
|
13
|
+
If no LIMIT is present, appends default_limit.
|
|
14
|
+
"""
|
|
15
|
+
cleaned = sql.strip().rstrip(";")
|
|
16
|
+
limit_match = re.search(r"\bLIMIT\s+(\d+)\b", cleaned, re.IGNORECASE)
|
|
17
|
+
|
|
18
|
+
if limit_match:
|
|
19
|
+
current_limit = int(limit_match.group(1))
|
|
20
|
+
if current_limit > max_limit:
|
|
21
|
+
return re.sub(r"\bLIMIT\s+\d+\b", f"LIMIT {max_limit}", cleaned, flags=re.IGNORECASE)
|
|
22
|
+
return cleaned
|
|
23
|
+
else:
|
|
24
|
+
return f"{cleaned} LIMIT {default_limit}"
|
|
25
|
+
|
|
26
|
+
@staticmethod
|
|
27
|
+
def evaluate_risk(sql: str) -> dict:
|
|
28
|
+
"""Evaluates heuristic risk metrics."""
|
|
29
|
+
warnings = []
|
|
30
|
+
sql_upper = sql.upper()
|
|
31
|
+
|
|
32
|
+
if "WHERE" not in sql_upper and "SELECT" in sql_upper:
|
|
33
|
+
warnings.append("Query lacks a WHERE clause; full table scan may occur.")
|
|
34
|
+
|
|
35
|
+
if "JOIN" in sql_upper and "ON" not in sql_upper and "USING" not in sql_upper:
|
|
36
|
+
warnings.append("JOIN statement without explicit ON/USING condition (possible Cartesian product).")
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
"risk_level": "medium" if warnings else "low",
|
|
40
|
+
"warnings": warnings
|
|
41
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SQL Read-Only Security Guard.
|
|
3
|
+
Inspects SQL statements to guarantee no mutations or destructive statements are executed.
|
|
4
|
+
"""
|
|
5
|
+
import re
|
|
6
|
+
from typing import Tuple
|
|
7
|
+
|
|
8
|
+
BLOCKED_PATTERNS = [
|
|
9
|
+
r"\bDROP\b",
|
|
10
|
+
r"\bTRUNCATE\b",
|
|
11
|
+
r"\bDELETE\b",
|
|
12
|
+
r"\bUPDATE\b",
|
|
13
|
+
r"\bINSERT\b",
|
|
14
|
+
r"\bALTER\b",
|
|
15
|
+
r"\bCREATE\b",
|
|
16
|
+
r"\bGRANT\b",
|
|
17
|
+
r"\bREVOKE\b",
|
|
18
|
+
r"\bEXEC\b",
|
|
19
|
+
r"\bEXECUTE\b",
|
|
20
|
+
r"\bCALL\b",
|
|
21
|
+
r"\bRENAME\b",
|
|
22
|
+
r"\bREPLACE\b"
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
COMPILED_BLOCKED = [re.compile(p, re.IGNORECASE) for p in BLOCKED_PATTERNS]
|
|
26
|
+
|
|
27
|
+
class ReadOnlyGuard:
|
|
28
|
+
@staticmethod
|
|
29
|
+
def validate(sql: str) -> Tuple[bool, str]:
|
|
30
|
+
"""
|
|
31
|
+
Validates whether SQL is strictly read-only.
|
|
32
|
+
Returns (is_safe: bool, reason: str).
|
|
33
|
+
"""
|
|
34
|
+
cleaned = sql.strip()
|
|
35
|
+
if not cleaned:
|
|
36
|
+
return False, "Empty query provided."
|
|
37
|
+
|
|
38
|
+
# Remove SQL comments (-- comment and /* comment */)
|
|
39
|
+
cleaned_no_comments = re.sub(r"--.*?$", "", cleaned, flags=re.MULTILINE)
|
|
40
|
+
cleaned_no_comments = re.sub(r"/\*.*?\*/", "", cleaned_no_comments, flags=re.DOTALL).strip()
|
|
41
|
+
|
|
42
|
+
# Check semicolon chaining (disallow multi-statement injection)
|
|
43
|
+
statements = [s.strip() for s in cleaned_no_comments.split(";") if s.strip()]
|
|
44
|
+
if len(statements) > 1:
|
|
45
|
+
return False, "Multiple SQL statements detected (semicolon chaining disallowed)."
|
|
46
|
+
|
|
47
|
+
first_word = statements[0].split()[0].upper() if statements else ""
|
|
48
|
+
allowed_starts = {"SELECT", "EXPLAIN", "WITH", "PRAGMA", "SHOW", "DESCRIBE"}
|
|
49
|
+
if first_word not in allowed_starts:
|
|
50
|
+
return False, f"Statement type '{first_word}' is disallowed. Only read-only queries are permitted."
|
|
51
|
+
|
|
52
|
+
for pattern in COMPILED_BLOCKED:
|
|
53
|
+
if pattern.search(cleaned_no_comments):
|
|
54
|
+
match = pattern.pattern.replace(r"\b", "")
|
|
55
|
+
return False, f"Blocked keyword detected in query: '{match}'"
|
|
56
|
+
|
|
57
|
+
return True, "Query verified as read-only."
|
pgsafe/server.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PgSafe MCP: Model Context Protocol Server (Lite Edition)
|
|
3
|
+
Implements standard JSON-RPC 2.0 stdio transport with Zero-Trust database access.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Dict, Any, List, Optional
|
|
11
|
+
from .guards.readonly import ReadOnlyGuard
|
|
12
|
+
from .guards.cost_analyzer import CostAnalyzer
|
|
13
|
+
from .adapters.sqlite_adapter import SQLiteAdapter
|
|
14
|
+
from .adapters.postgres_adapter import PostgresAdapter
|
|
15
|
+
|
|
16
|
+
logging.basicConfig(
|
|
17
|
+
level=logging.INFO,
|
|
18
|
+
format="%(asctime)s [%(levelname)s] [PgSafe] %(message)s",
|
|
19
|
+
stream=sys.stderr
|
|
20
|
+
)
|
|
21
|
+
logger = logging.getLogger("PgSafeServer")
|
|
22
|
+
|
|
23
|
+
class PgSafeMCPServer:
|
|
24
|
+
def __init__(self, db_url: Optional[str] = None):
|
|
25
|
+
self.db_url = db_url or os.getenv("DATABASE_URL", "sqlite:///demo.db")
|
|
26
|
+
self.server_name = "pgsafe-mcp"
|
|
27
|
+
self.server_version = "1.0.0"
|
|
28
|
+
self.adapter = self._init_adapter(self.db_url)
|
|
29
|
+
|
|
30
|
+
def _init_adapter(self, url: str):
|
|
31
|
+
if url.startswith("postgres://") or url.startswith("postgresql://"):
|
|
32
|
+
return PostgresAdapter(url)
|
|
33
|
+
else:
|
|
34
|
+
# Assume SQLite path or sqlite:///
|
|
35
|
+
clean_path = url.replace("sqlite:///", "").replace("sqlite://", "")
|
|
36
|
+
return SQLiteAdapter(clean_path)
|
|
37
|
+
|
|
38
|
+
def get_tools_definition(self) -> List[Dict[str, Any]]:
|
|
39
|
+
return [
|
|
40
|
+
{
|
|
41
|
+
"name": "list_tables",
|
|
42
|
+
"description": "List all public database tables available for inspection and querying.",
|
|
43
|
+
"inputSchema": {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {}
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "describe_table",
|
|
50
|
+
"description": "Retrieve column definitions, data types, nullability, and keys for a specific table.",
|
|
51
|
+
"inputSchema": {
|
|
52
|
+
"type": "object",
|
|
53
|
+
"properties": {
|
|
54
|
+
"table_name": {
|
|
55
|
+
"type": "string",
|
|
56
|
+
"description": "Name of the table to inspect"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"required": ["table_name"]
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
"name": "safe_query",
|
|
64
|
+
"description": "Execute a strictly read-only SQL query with automatic LIMIT enforcement and mutation blocking.",
|
|
65
|
+
"inputSchema": {
|
|
66
|
+
"type": "object",
|
|
67
|
+
"properties": {
|
|
68
|
+
"query": {
|
|
69
|
+
"type": "string",
|
|
70
|
+
"description": "SQL query to execute (must be SELECT/EXPLAIN)"
|
|
71
|
+
},
|
|
72
|
+
"limit": {
|
|
73
|
+
"type": "integer",
|
|
74
|
+
"description": "Maximum rows to return (default: 100, max: 1000)"
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
"required": ["query"]
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
"name": "analyze_query",
|
|
82
|
+
"description": "Analyze a SQL query for dangerous operations, unindexed scans, and Cartesian join risks without executing it.",
|
|
83
|
+
"inputSchema": {
|
|
84
|
+
"type": "object",
|
|
85
|
+
"properties": {
|
|
86
|
+
"query": {
|
|
87
|
+
"type": "string",
|
|
88
|
+
"description": "SQL query to inspect"
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
"required": ["query"]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
def handle_tool_call(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
|
97
|
+
if name == "list_tables":
|
|
98
|
+
tables = self.adapter.list_tables()
|
|
99
|
+
return {"success": True, "tables": tables, "count": len(tables)}
|
|
100
|
+
|
|
101
|
+
elif name == "describe_table":
|
|
102
|
+
table_name = arguments.get("table_name", "")
|
|
103
|
+
columns = self.adapter.describe_table(table_name)
|
|
104
|
+
return {"success": True, "table": table_name, "columns": columns}
|
|
105
|
+
|
|
106
|
+
elif name == "safe_query":
|
|
107
|
+
raw_sql = arguments.get("query", "")
|
|
108
|
+
limit = arguments.get("limit", 100)
|
|
109
|
+
|
|
110
|
+
# 1. Read-only validation
|
|
111
|
+
is_safe, reason = ReadOnlyGuard.validate(raw_sql)
|
|
112
|
+
if not is_safe:
|
|
113
|
+
return {
|
|
114
|
+
"success": False,
|
|
115
|
+
"error": f"Security Guard Blocked Query: {reason}",
|
|
116
|
+
"blocked": True
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# 2. Limit enforcement & risk evaluation
|
|
120
|
+
safe_sql = CostAnalyzer.enforce_limit(raw_sql, default_limit=limit, max_limit=1000)
|
|
121
|
+
risk = CostAnalyzer.evaluate_risk(raw_sql)
|
|
122
|
+
|
|
123
|
+
# 3. Execution
|
|
124
|
+
try:
|
|
125
|
+
cols, rows = self.adapter.execute_read_only(safe_sql)
|
|
126
|
+
return {
|
|
127
|
+
"success": True,
|
|
128
|
+
"query_executed": safe_sql,
|
|
129
|
+
"columns": cols,
|
|
130
|
+
"rows": rows,
|
|
131
|
+
"row_count": len(rows),
|
|
132
|
+
"risk_analysis": risk
|
|
133
|
+
}
|
|
134
|
+
except Exception as e:
|
|
135
|
+
return {
|
|
136
|
+
"success": False,
|
|
137
|
+
"error": f"Database execution error: {str(e)}"
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
elif name == "analyze_query":
|
|
141
|
+
raw_sql = arguments.get("query", "")
|
|
142
|
+
is_safe, reason = ReadOnlyGuard.validate(raw_sql)
|
|
143
|
+
risk = CostAnalyzer.evaluate_risk(raw_sql)
|
|
144
|
+
return {
|
|
145
|
+
"success": True,
|
|
146
|
+
"is_read_only": is_safe,
|
|
147
|
+
"security_verdict": reason,
|
|
148
|
+
"performance_risk": risk
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
else:
|
|
152
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
153
|
+
|
|
154
|
+
def handle_request(self, request: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
|
155
|
+
req_id = request.get("id")
|
|
156
|
+
method = request.get("method")
|
|
157
|
+
params = request.get("params", {})
|
|
158
|
+
|
|
159
|
+
if req_id is None:
|
|
160
|
+
if method == "notifications/initialized":
|
|
161
|
+
logger.info("Client completed initialization handshake.")
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
if method == "initialize":
|
|
165
|
+
return {
|
|
166
|
+
"jsonrpc": "2.0",
|
|
167
|
+
"id": req_id,
|
|
168
|
+
"result": {
|
|
169
|
+
"protocolVersion": "2024-11-05",
|
|
170
|
+
"capabilities": {"tools": {}},
|
|
171
|
+
"serverInfo": {
|
|
172
|
+
"name": self.server_name,
|
|
173
|
+
"version": self.server_version
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
elif method == "ping":
|
|
179
|
+
return {"jsonrpc": "2.0", "id": req_id, "result": {}}
|
|
180
|
+
|
|
181
|
+
elif method == "tools/list":
|
|
182
|
+
return {
|
|
183
|
+
"jsonrpc": "2.0",
|
|
184
|
+
"id": req_id,
|
|
185
|
+
"result": {"tools": self.get_tools_definition()}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
elif method == "tools/call":
|
|
189
|
+
tool_name = params.get("name")
|
|
190
|
+
tool_args = params.get("arguments", {})
|
|
191
|
+
try:
|
|
192
|
+
output = self.handle_tool_call(tool_name, tool_args)
|
|
193
|
+
return {
|
|
194
|
+
"jsonrpc": "2.0",
|
|
195
|
+
"id": req_id,
|
|
196
|
+
"result": {
|
|
197
|
+
"content": [
|
|
198
|
+
{
|
|
199
|
+
"type": "text",
|
|
200
|
+
"text": json.dumps(output, indent=2, ensure_ascii=False)
|
|
201
|
+
}
|
|
202
|
+
],
|
|
203
|
+
"isError": not output.get("success", True) if "success" in output else False
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.error(f"Error calling tool {tool_name}: {e}")
|
|
208
|
+
return {
|
|
209
|
+
"jsonrpc": "2.0",
|
|
210
|
+
"id": req_id,
|
|
211
|
+
"error": {"code": -32000, "message": str(e)}
|
|
212
|
+
}
|
|
213
|
+
else:
|
|
214
|
+
return {
|
|
215
|
+
"jsonrpc": "2.0",
|
|
216
|
+
"id": req_id,
|
|
217
|
+
"error": {"code": -32601, "message": f"Method not found: {method}"}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
def run_stdio(self):
|
|
221
|
+
logger.info(f"Starting {self.server_name} v{self.server_version} stdio listener...")
|
|
222
|
+
for line in sys.stdin:
|
|
223
|
+
line = line.strip()
|
|
224
|
+
if not line:
|
|
225
|
+
continue
|
|
226
|
+
try:
|
|
227
|
+
request = json.loads(line)
|
|
228
|
+
response = self.handle_request(request)
|
|
229
|
+
if response is not None:
|
|
230
|
+
sys.stdout.write(json.dumps(response) + "\n")
|
|
231
|
+
sys.stdout.flush()
|
|
232
|
+
except Exception as e:
|
|
233
|
+
logger.error(f"Error processing stdio line: {e}")
|
|
234
|
+
|
|
235
|
+
def main():
|
|
236
|
+
import argparse
|
|
237
|
+
parser = argparse.ArgumentParser(
|
|
238
|
+
prog="pgsafe-mcp",
|
|
239
|
+
description="PgSafe MCP: Zero-Trust Database Inspection & Safe Query Guard for AI Agents"
|
|
240
|
+
)
|
|
241
|
+
parser.add_argument("-v", "--version", action="version", version="%(prog)s 1.0.0")
|
|
242
|
+
parser.add_argument("--db-url", type=str, default=None, help="Database connection URL (Postgres or SQLite). Defaults to DATABASE_URL or sqlite:///demo.db")
|
|
243
|
+
args, unknown = parser.parse_known_args()
|
|
244
|
+
server = PgSafeMCPServer(db_url=args.db_url)
|
|
245
|
+
server.run_stdio()
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
main()
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pgsafe-mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Zero-Trust Database Inspection and Safe Query Guard MCP Server for AI Agents
|
|
5
|
+
Author: Johann Schiffer
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://johannschiffer.github.io
|
|
8
|
+
Project-URL: Repository, https://github.com/johannschiffer/pgsafe-mcp
|
|
9
|
+
Project-URL: Starter Kit, https://johanld.gumroad.com/l/genesis-starter-kit
|
|
10
|
+
Project-URL: Enterprise Suite, https://johanld.gumroad.com/l/genesis-enterprise-suite
|
|
11
|
+
Keywords: mcp,model-context-protocol,postgres,sqlite,database,claude,cursor,security,ai-agent
|
|
12
|
+
Requires-Python: >=3.10
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
17
|
+
Provides-Extra: postgres
|
|
18
|
+
Requires-Dist: psycopg[binary]>=3.1.0; extra == "postgres"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# PgSafe MCP (Lite Edition)
|
|
22
|
+
|
|
23
|
+
<a href="https://glama.ai/mcp/servers/@johannschiffer/pgsafe-mcp">
|
|
24
|
+
<img width="380" height="200" src="https://glama.ai/mcp/servers/@johannschiffer/pgsafe-mcp/badge" alt="pgsafe-mcp MCP server" />
|
|
25
|
+
</a>
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
> **Free:** [Genesis Starter Kit](https://johanld.gumroad.com/l/genesis-starter-kit) (25 `.cursorrules`/`CLAUDE.md` rulesets + MCP configs). Try the [live config builder](https://johannschiffer.github.io).
|
|
29
|
+
> **Full stack:** all six servers in [Genesis Enterprise Suite ($19)](https://johanld.gumroad.com/l/genesis-enterprise-suite).
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
[](https://python.org)
|
|
34
|
+
[](https://modelcontextprotocol.io)
|
|
35
|
+
|
|
36
|
+
**PgSafe MCP** is a Zero-Trust Model Context Protocol server that enables AI agents in **Claude Desktop**, **Cursor**, **Windsurf**, and **Antigravity** to inspect schemas and run analytical SQL queries without risking accidental data loss, destructive mutations (`DROP`, `TRUNCATE`, `DELETE`), or runaway table scans.
|
|
37
|
+
|
|
38
|
+
---
|
|
39
|
+
|
|
40
|
+
## Run without cloning
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uvx --from git+https://github.com/johannschiffer/pgsafe-mcp pgsafe-mcp
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Claude Desktop / Cursor config entry:
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"mcpServers": {
|
|
51
|
+
"pgsafe-mcp": {
|
|
52
|
+
"command": "uvx",
|
|
53
|
+
"args": [
|
|
54
|
+
"--from",
|
|
55
|
+
"git+https://github.com/johannschiffer/pgsafe-mcp",
|
|
56
|
+
"pgsafe-mcp"
|
|
57
|
+
]
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
[](https://github.com/johannschiffer/pgsafe-mcp/actions/workflows/ci.yml)
|
|
64
|
+
|
|
65
|
+
## 🛡️ Why PgSafe MCP?
|
|
66
|
+
|
|
67
|
+
Giving an AI assistant raw access to your database is terrifying:
|
|
68
|
+
1. **Accidental Deletions:** One ambiguous prompt can lead an LLM to generate `DROP TABLE` or `DELETE FROM users;`.
|
|
69
|
+
2. **Production Table Locks:** An unindexed query with a multi-table Cartesian product can consume 100% CPU and lock production tables.
|
|
70
|
+
3. **Multi-Statement Injection:** LLMs can be tricked via prompt injection into chaining malicious SQL commands (`SELECT 1; TRUNCATE audit_logs;`).
|
|
71
|
+
|
|
72
|
+
**PgSafe** acts as a strict guardrail sitting between the AI agent and your database engine.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## ⚡ Features (Lite Edition)
|
|
77
|
+
|
|
78
|
+
- 🔒 **Enforced Read-Only Guard**: Automatically detects and blocks all destructive statements (`DROP`, `TRUNCATE`, `ALTER`, `UPDATE`, `INSERT`, `DELETE`).
|
|
79
|
+
- 🛑 **Anti-Chaining Protection**: Disallows multi-statement execution via semicolons.
|
|
80
|
+
- ⏱️ **Automatic LIMIT Injection**: Ensures every `SELECT` query has a capped `LIMIT` clause (default: 100 rows, max: 1,000) to protect database memory.
|
|
81
|
+
- 📊 **Zero-Cost Schema Reflection**: Tools to explore public tables and columns without executing custom introspection queries.
|
|
82
|
+
- 🪶 **Zero Dependency Core**: Native support for SQLite out of the box; optional PostgreSQL adapter.
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## 🚀 Quick Setup in 2 Minutes
|
|
87
|
+
|
|
88
|
+
### 1. Installation
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
git clone https://github.com/johannschiffer/pgsafe-mcp.git
|
|
92
|
+
cd pgsafe-mcp
|
|
93
|
+
pip install .
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
For PostgreSQL support:
|
|
97
|
+
```bash
|
|
98
|
+
pip install "psycopg[binary]>=3.1.0"
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### 2. Configure Claude Desktop (`claude_desktop_config.json`)
|
|
102
|
+
|
|
103
|
+
On Linux: `~/.config/Claude/claude_desktop_config.json`
|
|
104
|
+
On macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`
|
|
105
|
+
On Windows: `%APPDATA%\Claude\claude_desktop_config.json`
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"mcpServers": {
|
|
110
|
+
"pgsafe": {
|
|
111
|
+
"command": "python",
|
|
112
|
+
"args": ["-m", "pgsafe.server"],
|
|
113
|
+
"cwd": "/path/to/pgsafe-mcp",
|
|
114
|
+
"env": {
|
|
115
|
+
"DATABASE_URL": "postgresql://user:password@localhost:5432/production_db"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
For SQLite, simply point to your `.db` file:
|
|
123
|
+
```json
|
|
124
|
+
{
|
|
125
|
+
"mcpServers": {
|
|
126
|
+
"pgsafe": {
|
|
127
|
+
"command": "python",
|
|
128
|
+
"args": ["-m", "pgsafe.server"],
|
|
129
|
+
"cwd": "/path/to/pgsafe-mcp",
|
|
130
|
+
"env": {
|
|
131
|
+
"DATABASE_URL": "sqlite:///data/nexus.db"
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## 🛠️ MCP Tools Exposed to AI Agents
|
|
141
|
+
|
|
142
|
+
| Tool | Parameters | Description |
|
|
143
|
+
|---|---|---|
|
|
144
|
+
| `list_tables` | *(none)* | Returns list of all user tables in the database |
|
|
145
|
+
| `describe_table` | `table_name` | Returns column names, data types, nullability, and primary keys |
|
|
146
|
+
| `safe_query` | `query`, `limit` | Executes strictly read-only SQL with forced LIMIT enforcement |
|
|
147
|
+
| `analyze_query` | `query` | Inspects SQL for unindexed table scan and Cartesian join risks |
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## 💎 Need Enterprise Data Protection? Upgrade to PgSafe Pro
|
|
152
|
+
|
|
153
|
+
Running against live production workloads with sensitive customer data?
|
|
154
|
+
|
|
155
|
+
| Feature | Lite (Open Source) | Enterprise Suite ($19) |
|
|
156
|
+
|---|:---:|:---:|
|
|
157
|
+
| **Read-Only SQL Validation** | ✅ | ✅ |
|
|
158
|
+
| **Forced LIMIT Injection** | ✅ | ✅ |
|
|
159
|
+
| **PostgreSQL & SQLite** | ✅ | ✅ |
|
|
160
|
+
| **MySQL, MariaDB & ClickHouse** | ❌ | ✅ |
|
|
161
|
+
| **Automatic PII Redaction & Masking** *(Emails, Cards, Hashes)* | ❌ | ✅ |
|
|
162
|
+
| **Dry-Run Mutation Simulation** *(Auto-Rollback Test Harness)* | ❌ | ✅ |
|
|
163
|
+
| **Index Recommendation Advisor** | ❌ | ✅ |
|
|
164
|
+
| **Production Docker & Compose** | ❌ | ✅ |
|
|
165
|
+
| **Full Pytest Suite & Architecture Guide** | ❌ | ✅ |
|
|
166
|
+
|
|
167
|
+
👉 **[Get the Genesis Enterprise Suite ($19)](https://johanld.gumroad.com/l/genesis-enterprise-suite)**
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## 📄 License
|
|
172
|
+
|
|
173
|
+
MIT License. See [LICENSE](LICENSE) for details.
|
|
174
|
+
|
|
175
|
+
## Genesis MCP Ecosystem
|
|
176
|
+
|
|
177
|
+
| Module | What it does | Repo |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| **CrawlEngine MCP** | Anti-bot web scraping, clean Markdown, proxy rotation and TLS evasion | [GitHub](https://github.com/johannschiffer/crawlengine-mcp) |
|
|
180
|
+
| **BrowserBridge MCP** | Deterministic headless browser automation with cryptographic action receipts | [GitHub](https://github.com/johannschiffer/browserbridge-mcp) |
|
|
181
|
+
| **PgSafe MCP** (this repo) | Zero-trust PostgreSQL/SQLite guard, live PII masking, zero-downtime migration checks | [GitHub](https://github.com/johannschiffer/pgsafe-mcp) |
|
|
182
|
+
| **OmniBridge MCP** | Multi-channel notification and webhook bus with a persistent SQLite WAL queue | [GitHub](https://github.com/johannschiffer/omnibridge-mcp) |
|
|
183
|
+
| **SysFlow MCP** | Safe Linux administration plane: systemd, port audit, isolated execution | [GitHub](https://github.com/johannschiffer/sysflow-mcp) |
|
|
184
|
+
| **FastMCP Forge** | Production MCP server scaffolder: stdio, SSE and auth middleware | [GitHub](https://github.com/johannschiffer/fastmcp-forge) |
|
|
185
|
+
|
|
186
|
+
[Live config builder](https://johannschiffer.github.io) | [Starter Kit, free](https://johanld.gumroad.com/l/genesis-starter-kit) | [Enterprise Suite, $19](https://johanld.gumroad.com/l/genesis-enterprise-suite)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
pgsafe/__init__.py,sha256=Q_NyArbT8zp__Hm_aBbcd_35D0ieobWvOQmr1lGEVPc,194
|
|
2
|
+
pgsafe/_genesis.py,sha256=kPHV2Lr__lgG6R1e623tIVnuV8xu_mpuLddMCIuMEMM,398
|
|
3
|
+
pgsafe/cli.py,sha256=2HNm8_Dfv-IjerToqEAt72ZiJJ_P9B-SotX7K5QbvuU,2456
|
|
4
|
+
pgsafe/server.py,sha256=I4oS2j637xvfC8cVlR9laJSv4N6IIcBfnUAe7elmw7A,9320
|
|
5
|
+
pgsafe/adapters/base.py,sha256=gOaFoukdhh802fLGZT2HSF3f4cTp_JKPeoTjg12sv-g,752
|
|
6
|
+
pgsafe/adapters/postgres_adapter.py,sha256=Jc2PFznZh0zKWyOOFGTZyNnRcSVTBEoAvs3IhKqhpR8,2647
|
|
7
|
+
pgsafe/adapters/sqlite_adapter.py,sha256=DbsuiWvtjxPaK0itDdo3ZdvL8JJbgxn6uE84oKY4E30,2104
|
|
8
|
+
pgsafe/guards/cost_analyzer.py,sha256=W9NO0y3GqCliYx93ZeAn-EYvlWxcTey-5xce5h-qQVk,1526
|
|
9
|
+
pgsafe/guards/readonly.py,sha256=oh_XlpXJQU6JHOXxUHsOkGMdxEU07JE0Uf4K_vhmoBM,1972
|
|
10
|
+
pgsafe_mcp-1.0.0.dist-info/licenses/LICENSE,sha256=zLLbcUl3FzNRmjK-IvqP0a4PoChg0sjBic0oWv_gBIE,1080
|
|
11
|
+
pgsafe_mcp-1.0.0.dist-info/METADATA,sha256=bqA036o3w_W6eIiG_Phg4jlEg-9OKY57QO8zIFkG52M,7440
|
|
12
|
+
pgsafe_mcp-1.0.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
13
|
+
pgsafe_mcp-1.0.0.dist-info/entry_points.txt,sha256=9nAz_qrU4TXf4Fj6TnCaaVy1GnZdUq8HE7YMrUPf8Qo,79
|
|
14
|
+
pgsafe_mcp-1.0.0.dist-info/top_level.txt,sha256=PCARi_rb1jjS_uO898jnATj0e72zQc8M5hsrGKv6_VU,7
|
|
15
|
+
pgsafe_mcp-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Johann Schiffer / NEXUS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pgsafe
|