mcp-database 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,3 @@
1
+ """mcp-database — MCP server for multi-database access."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m mcp_database"""
2
+
3
+ from mcp_database.server import main
4
+
5
+ main()
@@ -0,0 +1,6 @@
1
+ """Database adapters for mcp-database."""
2
+
3
+ from mcp_database.adapters.base import DatabaseAdapter
4
+ from mcp_database.adapters.sqlite import SQLiteAdapter
5
+
6
+ __all__ = ["DatabaseAdapter", "SQLiteAdapter"]
@@ -0,0 +1,108 @@
1
+ """Base adapter interface for database connections."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from dataclasses import dataclass
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class QueryResult:
10
+ """Result of a SQL query."""
11
+
12
+ columns: list[str]
13
+ rows: list[list[Any]]
14
+ row_count: int
15
+ truncated: bool = False
16
+
17
+ def to_table(self, max_rows: int = 100) -> str:
18
+ """Format result as an aligned text table."""
19
+ if not self.columns:
20
+ return "No results."
21
+
22
+ rows = self.rows[:max_rows]
23
+ # Calculate column widths
24
+ widths = [len(c) for c in self.columns]
25
+ for row in rows:
26
+ for i, val in enumerate(row):
27
+ if i < len(widths):
28
+ widths[i] = max(widths[i], len(str(val)))
29
+
30
+ # Header
31
+ header = " | ".join(c.ljust(widths[i]) for i, c in enumerate(self.columns))
32
+ separator = "-+-".join("-" * w for w in widths)
33
+ lines = [header, separator]
34
+
35
+ # Rows
36
+ for row in rows:
37
+ line = " | ".join(str(row[i]).ljust(widths[i]) if i < len(row) else "" for i in range(len(self.columns)))
38
+ lines.append(line)
39
+
40
+ result = "\n".join(lines)
41
+ if self.truncated:
42
+ result += f"\n... ({self.row_count} total rows, showing first {max_rows})"
43
+ return result
44
+
45
+
46
+ @dataclass
47
+ class TableInfo:
48
+ """Information about a database table."""
49
+
50
+ name: str
51
+ columns: list[dict[str, Any]]
52
+ row_count: int | None = None
53
+ create_sql: str | None = None
54
+
55
+
56
+ class DatabaseAdapter(ABC):
57
+ """Abstract base class for database adapters."""
58
+
59
+ @abstractmethod
60
+ def connect(self) -> None:
61
+ """Establish connection to the database."""
62
+
63
+ @abstractmethod
64
+ def disconnect(self) -> None:
65
+ """Close the database connection."""
66
+
67
+ @abstractmethod
68
+ def test_connection(self) -> bool:
69
+ """Test if the connection is alive."""
70
+
71
+ @abstractmethod
72
+ def list_databases(self) -> list[str]:
73
+ """List available databases (if applicable)."""
74
+
75
+ @abstractmethod
76
+ def list_tables(self, database: str | None = None) -> list[str]:
77
+ """List all tables in the database."""
78
+
79
+ @abstractmethod
80
+ def get_table_info(self, table: str, database: str | None = None) -> TableInfo:
81
+ """Get detailed information about a table."""
82
+
83
+ @abstractmethod
84
+ def get_schema(self, database: str | None = None) -> str:
85
+ """Get the full database schema as formatted text."""
86
+
87
+ @abstractmethod
88
+ def execute_query(self, sql: str, database: str | None = None, max_rows: int = 100) -> QueryResult:
89
+ """Execute a read-only SQL query."""
90
+
91
+ def execute_write(self, sql: str, database: str | None = None) -> int:
92
+ """Execute a write SQL query (INSERT/UPDATE/DELETE). Returns affected rows.
93
+
94
+ Raises NotImplementedError if the adapter is read-only.
95
+ """
96
+ raise NotImplementedError("This adapter is read-only. Use execute_query() for SELECT statements.")
97
+
98
+ @property
99
+ @abstractmethod
100
+ def db_type(self) -> str:
101
+ """Return the database type name (e.g., 'sqlite', 'postgresql', 'mysql')."""
102
+
103
+ def _is_read_only_query(self, sql: str) -> bool:
104
+ """Check if a query is read-only (SELECT, SHOW, DESCRIBE, EXPLAIN)."""
105
+ normalized = sql.strip().upper()
106
+ # Allow SELECT, SHOW, DESCRIBE, EXPLAIN, WITH (CTE leading to SELECT)
107
+ read_only_prefixes = ("SELECT", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "WITH", "PRAGMA")
108
+ return any(normalized.startswith(prefix) for prefix in read_only_prefixes)
@@ -0,0 +1,173 @@
1
+ """MySQL database adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from mcp_database.adapters.base import DatabaseAdapter, QueryResult, TableInfo
8
+
9
+
10
+ class MySQLAdapter(DatabaseAdapter):
11
+ """Adapter for MySQL databases."""
12
+
13
+ def __init__(
14
+ self,
15
+ host: str = "localhost",
16
+ port: int = 3306,
17
+ user: str = "root",
18
+ password: str = "",
19
+ database: str = "",
20
+ read_only: bool = True,
21
+ ):
22
+ self.host = host
23
+ self.port = port
24
+ self.user = user
25
+ self.password = password
26
+ self.default_database = database
27
+ self.read_only = read_only
28
+ self._conn: Any = None
29
+
30
+ def connect(self) -> None:
31
+ try:
32
+ import pymysql
33
+ except ImportError:
34
+ raise ImportError(
35
+ "pymysql is required for MySQL. Install with: pip install 'mcp-database[mysql]'"
36
+ )
37
+ self._conn = pymysql.connect(
38
+ host=self.host,
39
+ port=self.port,
40
+ user=self.user,
41
+ password=self.password,
42
+ database=self.default_database or None,
43
+ charset="utf8mb4",
44
+ cursorclass=pymysql.cursors.DictCursor,
45
+ )
46
+ if self.read_only:
47
+ cur = self._conn.cursor()
48
+ cur.execute("SET SESSION TRANSACTION READ ONLY")
49
+ cur.close()
50
+
51
+ def disconnect(self) -> None:
52
+ if self._conn:
53
+ self._conn.close()
54
+ self._conn = None
55
+
56
+ def test_connection(self) -> bool:
57
+ try:
58
+ if not self._conn:
59
+ return False
60
+ cur = self._conn.cursor()
61
+ cur.execute("SELECT 1")
62
+ cur.close()
63
+ return True
64
+ except Exception:
65
+ return False
66
+
67
+ def list_databases(self) -> list[str]:
68
+ cur = self._get_conn().cursor()
69
+ cur.execute("SHOW DATABASES")
70
+ rows = cur.fetchall()
71
+ cur.close()
72
+ return [r["Database"] for r in rows]
73
+
74
+ def list_tables(self, database: str | None = None) -> list[str]:
75
+ conn = self._get_conn()
76
+ cur = conn.cursor()
77
+ if database:
78
+ cur.execute(f"SHOW TABLES FROM `{database}`")
79
+ else:
80
+ cur.execute("SHOW TABLES")
81
+ rows = cur.fetchall()
82
+ cur.close()
83
+ # The column name varies: "Tables_in_{database}" or "Tables_in_{current_db}"
84
+ return [list(r.values())[0] for r in rows]
85
+
86
+ def get_table_info(self, table: str, database: str | None = None) -> TableInfo:
87
+ conn = self._get_conn()
88
+ cur = conn.cursor()
89
+
90
+ table_ref = f"`{database}`.`{table}`" if database else f"`{table}`"
91
+
92
+ # Get columns
93
+ cur.execute(f"DESCRIBE {table_ref}")
94
+ col_rows = cur.fetchall()
95
+ columns = [
96
+ {
97
+ "name": r["Field"],
98
+ "type": r["Type"],
99
+ "nullable": r["Null"] == "YES",
100
+ "default": r["Default"],
101
+ "primary_key": r["Key"] == "PRI",
102
+ }
103
+ for r in col_rows
104
+ ]
105
+
106
+ # Get row count
107
+ cur.execute(f"SELECT COUNT(*) as cnt FROM {table_ref}")
108
+ row_count = cur.fetchone()["cnt"]
109
+
110
+ # Get create SQL
111
+ cur.execute(f"SHOW CREATE TABLE {table_ref}")
112
+ create_row = cur.fetchone()
113
+ create_sql = create_row.get("Create Table") if create_row else None
114
+
115
+ cur.close()
116
+ return TableInfo(name=table, columns=columns, row_count=row_count, create_sql=create_sql)
117
+
118
+ def get_schema(self, database: str | None = None) -> str:
119
+ tables = self.list_tables(database)
120
+ parts = []
121
+ for table in tables:
122
+ info = self.get_table_info(table, database)
123
+ if info.create_sql:
124
+ parts.append(info.create_sql)
125
+ else:
126
+ col_lines = []
127
+ for col in info.columns:
128
+ parts_str = [col["type"]]
129
+ if col["primary_key"]:
130
+ parts_str.append("PRIMARY KEY")
131
+ if not col["nullable"]:
132
+ parts_str.append("NOT NULL")
133
+ if col["default"] is not None:
134
+ parts_str.append(f"DEFAULT {col['default']}")
135
+ col_lines.append(f" {col['name']} {' '.join(parts_str)}")
136
+ parts.append(f"CREATE TABLE {table} (\n" + ",\n".join(col_lines) + "\n);")
137
+ return "\n\n".join(parts) if parts else "No tables found."
138
+
139
+ def execute_query(self, sql: str, database: str | None = None, max_rows: int = 100) -> QueryResult:
140
+ conn = self._get_conn()
141
+ cur = conn.cursor()
142
+ if database:
143
+ cur.execute(f"USE `{database}`")
144
+ cur.execute(sql)
145
+ columns = [desc[0] for desc in cur.description] if cur.description else []
146
+ all_rows = cur.fetchall()
147
+ row_count = len(all_rows)
148
+ truncated = row_count > max_rows
149
+ rows = [[row.get(c) for c in columns] for row in all_rows[:max_rows]]
150
+ cur.close()
151
+ return QueryResult(columns=columns, rows=rows, row_count=row_count, truncated=truncated)
152
+
153
+ def execute_write(self, sql: str, database: str | None = None) -> int:
154
+ if self.read_only:
155
+ raise NotImplementedError("MySQL adapter is in read-only mode. Set read_only=False to enable writes.")
156
+ conn = self._get_conn()
157
+ cur = conn.cursor()
158
+ if database:
159
+ cur.execute(f"USE `{database}`")
160
+ cur.execute(sql)
161
+ affected = cur.rowcount
162
+ conn.commit()
163
+ cur.close()
164
+ return affected
165
+
166
+ @property
167
+ def db_type(self) -> str:
168
+ return "mysql"
169
+
170
+ def _get_conn(self) -> Any:
171
+ if not self._conn:
172
+ raise RuntimeError("Not connected. Call connect() first.")
173
+ return self._conn
@@ -0,0 +1,174 @@
1
+ """PostgreSQL database adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from mcp_database.adapters.base import DatabaseAdapter, QueryResult, TableInfo
8
+
9
+
10
+ class PostgreSQLAdapter(DatabaseAdapter):
11
+ """Adapter for PostgreSQL databases."""
12
+
13
+ def __init__(
14
+ self,
15
+ host: str = "localhost",
16
+ port: int = 5432,
17
+ user: str = "postgres",
18
+ password: str = "",
19
+ database: str = "postgres",
20
+ read_only: bool = True,
21
+ ):
22
+ self.host = host
23
+ self.port = port
24
+ self.user = user
25
+ self.password = password
26
+ self.default_database = database
27
+ self.read_only = read_only
28
+ self._conn: Any = None
29
+
30
+ def connect(self) -> None:
31
+ try:
32
+ import psycopg2
33
+ except ImportError:
34
+ raise ImportError(
35
+ "psycopg2 is required for PostgreSQL. Install with: pip install 'mcp-database[postgres]'"
36
+ )
37
+ self._conn = psycopg2.connect(
38
+ host=self.host,
39
+ port=self.port,
40
+ user=self.user,
41
+ password=self.password,
42
+ dbname=self.default_database,
43
+ )
44
+ if self.read_only:
45
+ self._conn.set_session(readonly=True)
46
+
47
+ def disconnect(self) -> None:
48
+ if self._conn:
49
+ self._conn.close()
50
+ self._conn = None
51
+
52
+ def test_connection(self) -> bool:
53
+ try:
54
+ if not self._conn:
55
+ return False
56
+ cur = self._conn.cursor()
57
+ cur.execute("SELECT 1")
58
+ cur.close()
59
+ return True
60
+ except Exception:
61
+ return False
62
+
63
+ def list_databases(self) -> list[str]:
64
+ cur = self._get_conn().cursor()
65
+ cur.execute("SELECT datname FROM pg_database WHERE datistemplate = false ORDER BY datname")
66
+ rows = cur.fetchall()
67
+ cur.close()
68
+ return [r[0] for r in rows]
69
+
70
+ def list_tables(self, database: str | None = None) -> list[str]:
71
+ cur = self._get_conn().cursor()
72
+ cur.execute(
73
+ "SELECT table_name FROM information_schema.tables "
74
+ "WHERE table_schema = 'public' ORDER BY table_name"
75
+ )
76
+ rows = cur.fetchall()
77
+ cur.close()
78
+ return [r[0] for r in rows]
79
+
80
+ def get_table_info(self, table: str, database: str | None = None) -> TableInfo:
81
+ conn = self._get_conn()
82
+ cur = conn.cursor()
83
+
84
+ # Get columns
85
+ cur.execute(
86
+ "SELECT column_name, data_type, is_nullable, column_default "
87
+ "FROM information_schema.columns "
88
+ "WHERE table_schema = 'public' AND table_name = %s "
89
+ "ORDER BY ordinal_position",
90
+ (table,),
91
+ )
92
+ col_rows = cur.fetchall()
93
+ columns = [
94
+ {
95
+ "name": r[0],
96
+ "type": r[1],
97
+ "nullable": r[2] == "YES",
98
+ "default": r[3],
99
+ "primary_key": False, # determined below
100
+ }
101
+ for r in col_rows
102
+ ]
103
+
104
+ # Get primary keys
105
+ cur.execute(
106
+ "SELECT a.attname FROM pg_index i "
107
+ "JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) "
108
+ "WHERE i.indrelid = %s::regclass AND i.indisprimary",
109
+ (table,),
110
+ )
111
+ pk_cols = {r[0] for r in cur.fetchall()}
112
+ for col in columns:
113
+ if col["name"] in pk_cols:
114
+ col["primary_key"] = True
115
+
116
+ # Get row count (estimate for large tables)
117
+ cur.execute(f"SELECT COUNT(*) FROM \"{table}\"")
118
+ row_count = cur.fetchone()[0]
119
+
120
+ # Get create SQL (PostgreSQL doesn't have a single CREATE TABLE statement)
121
+ create_sql = None
122
+
123
+ cur.close()
124
+ return TableInfo(name=table, columns=columns, row_count=row_count, create_sql=create_sql)
125
+
126
+ def get_schema(self, database: str | None = None) -> str:
127
+ tables = self.list_tables(database)
128
+ parts = []
129
+ for table in tables:
130
+ info = self.get_table_info(table, database)
131
+ col_lines = []
132
+ for col in info.columns:
133
+ parts_str = [col["type"]]
134
+ if col["primary_key"]:
135
+ parts_str.append("PRIMARY KEY")
136
+ if not col["nullable"]:
137
+ parts_str.append("NOT NULL")
138
+ if col["default"] is not None:
139
+ parts_str.append(f"DEFAULT {col['default']}")
140
+ col_lines.append(f" {col['name']} {' '.join(parts_str)}")
141
+ parts.append(f"CREATE TABLE {table} (\n" + ",\n".join(col_lines) + "\n);")
142
+ return "\n\n".join(parts) if parts else "No tables found."
143
+
144
+ def execute_query(self, sql: str, database: str | None = None, max_rows: int = 100) -> QueryResult:
145
+ conn = self._get_conn()
146
+ cur = conn.cursor()
147
+ cur.execute(sql)
148
+ columns = [desc[0] for desc in cur.description] if cur.description else []
149
+ all_rows = cur.fetchall()
150
+ row_count = len(all_rows)
151
+ truncated = row_count > max_rows
152
+ rows = [list(row) for row in all_rows[:max_rows]]
153
+ cur.close()
154
+ return QueryResult(columns=columns, rows=rows, row_count=row_count, truncated=truncated)
155
+
156
+ def execute_write(self, sql: str, database: str | None = None) -> int:
157
+ if self.read_only:
158
+ raise NotImplementedError("PostgreSQL adapter is in read-only mode. Set read_only=False to enable writes.")
159
+ conn = self._get_conn()
160
+ cur = conn.cursor()
161
+ cur.execute(sql)
162
+ affected = cur.rowcount
163
+ conn.commit()
164
+ cur.close()
165
+ return affected
166
+
167
+ @property
168
+ def db_type(self) -> str:
169
+ return "postgresql"
170
+
171
+ def _get_conn(self) -> Any:
172
+ if not self._conn:
173
+ raise RuntimeError("Not connected. Call connect() first.")
174
+ return self._conn
@@ -0,0 +1,120 @@
1
+ """SQLite database adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sqlite3
6
+ from typing import Any
7
+
8
+ from mcp_database.adapters.base import DatabaseAdapter, QueryResult, TableInfo
9
+
10
+
11
+ class SQLiteAdapter(DatabaseAdapter):
12
+ """Adapter for SQLite databases."""
13
+
14
+ def __init__(self, database_path: str, read_only: bool = True):
15
+ self.database_path = database_path
16
+ self.read_only = read_only
17
+ self._conn: sqlite3.Connection | None = None
18
+
19
+ def connect(self) -> None:
20
+ uri = f"file:{self.database_path}"
21
+ if self.read_only:
22
+ uri += "?mode=ro"
23
+ self._conn = sqlite3.connect(uri, uri=True)
24
+ self._conn.row_factory = sqlite3.Row
25
+
26
+ def disconnect(self) -> None:
27
+ if self._conn:
28
+ self._conn.close()
29
+ self._conn = None
30
+
31
+ def test_connection(self) -> bool:
32
+ try:
33
+ if not self._conn:
34
+ return False
35
+ self._conn.execute("SELECT 1")
36
+ return True
37
+ except Exception:
38
+ return False
39
+
40
+ def list_databases(self) -> list[str]:
41
+ return ["main"]
42
+
43
+ def list_tables(self, database: str | None = None) -> list[str]:
44
+ conn = self._get_conn()
45
+ rows = conn.execute(
46
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
47
+ ).fetchall()
48
+ return [r["name"] for r in rows]
49
+
50
+ def get_table_info(self, table: str, database: str | None = None) -> TableInfo:
51
+ conn = self._get_conn()
52
+ # Get column info
53
+ columns_raw = conn.execute(f"PRAGMA table_info('{table}')").fetchall()
54
+ columns = [
55
+ {
56
+ "name": r["name"],
57
+ "type": r["type"],
58
+ "nullable": not r["notnull"],
59
+ "default": r["dflt_value"],
60
+ "primary_key": bool(r["pk"]),
61
+ }
62
+ for r in columns_raw
63
+ ]
64
+
65
+ # Get row count
66
+ row_count = conn.execute(f"SELECT COUNT(*) as cnt FROM '{table}'").fetchone()["cnt"]
67
+
68
+ # Get create SQL
69
+ create_row = conn.execute(
70
+ "SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table,)
71
+ ).fetchone()
72
+ create_sql = create_row["sql"] if create_row else None
73
+
74
+ return TableInfo(name=table, columns=columns, row_count=row_count, create_sql=create_sql)
75
+
76
+ def get_schema(self, database: str | None = None) -> str:
77
+ conn = self._get_conn()
78
+ tables = self.list_tables()
79
+ parts = []
80
+ for table in tables:
81
+ info = self.get_table_info(table)
82
+ col_lines = []
83
+ for col in info.columns:
84
+ parts_str = [col["type"]]
85
+ if col["primary_key"]:
86
+ parts_str.append("PRIMARY KEY")
87
+ if not col["nullable"]:
88
+ parts_str.append("NOT NULL")
89
+ if col["default"] is not None:
90
+ parts_str.append(f"DEFAULT {col['default']}")
91
+ col_lines.append(f" {col['name']} {' '.join(parts_str)}")
92
+ parts.append(f"CREATE TABLE {table} (\n" + ",\n".join(col_lines) + "\n);")
93
+ return "\n\n".join(parts) if parts else "No tables found."
94
+
95
+ def execute_query(self, sql: str, database: str | None = None, max_rows: int = 100) -> QueryResult:
96
+ conn = self._get_conn()
97
+ cursor = conn.execute(sql)
98
+ columns = [desc[0] for desc in cursor.description] if cursor.description else []
99
+ all_rows = cursor.fetchall()
100
+ row_count = len(all_rows)
101
+ truncated = row_count > max_rows
102
+ rows = [list(row) for row in all_rows[:max_rows]]
103
+ return QueryResult(columns=columns, rows=rows, row_count=row_count, truncated=truncated)
104
+
105
+ def execute_write(self, sql: str, database: str | None = None) -> int:
106
+ if self.read_only:
107
+ raise NotImplementedError("SQLite adapter is in read-only mode. Set read_only=False to enable writes.")
108
+ conn = self._get_conn()
109
+ cursor = conn.execute(sql)
110
+ conn.commit()
111
+ return cursor.rowcount
112
+
113
+ @property
114
+ def db_type(self) -> str:
115
+ return "sqlite"
116
+
117
+ def _get_conn(self) -> sqlite3.Connection:
118
+ if not self._conn:
119
+ raise RuntimeError("Not connected. Call connect() first.")
120
+ return self._conn
mcp_database/config.py ADDED
@@ -0,0 +1,120 @@
1
+ """Configuration for mcp-database server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ @dataclass
10
+ class DatabaseConfig:
11
+ """Configuration for a single database connection."""
12
+
13
+ name: str
14
+ type: str # sqlite, postgresql, mysql
15
+ # SQLite
16
+ path: str = ""
17
+ # PostgreSQL / MySQL
18
+ host: str = "localhost"
19
+ port: int = 0
20
+ user: str = ""
21
+ password: str = ""
22
+ database: str = ""
23
+ # Options
24
+ read_only: bool = True
25
+
26
+ def __post_init__(self) -> None:
27
+ if self.type == "sqlite" and not self.path:
28
+ raise ValueError(f"Database '{self.name}': 'path' is required for SQLite")
29
+ if self.type in ("postgresql", "mysql") and not self.host:
30
+ raise ValueError(f"Database '{self.name}': 'host' is required for {self.type}")
31
+
32
+
33
+ @dataclass
34
+ class ServerConfig:
35
+ """Server-level configuration."""
36
+
37
+ databases: list[DatabaseConfig] = field(default_factory=list)
38
+ max_rows: int = 100
39
+ allow_writes: bool = False
40
+
41
+
42
+ def load_config_from_env() -> ServerConfig:
43
+ """Load configuration from environment variables.
44
+
45
+ Supports:
46
+ MCP_DATABASE_URL - single database URL (sqlite:///path or postgres://...)
47
+ MCP_DATABASE_TYPE - database type (default: sqlite)
48
+ MCP_DATABASE_READ_ONLY - read-only mode (default: true)
49
+ MCP_MAX_ROWS - max rows to return (default: 100)
50
+ """
51
+ db_url = os.environ.get("MCP_DATABASE_URL", "")
52
+ db_type = os.environ.get("MCP_DATABASE_TYPE", "sqlite")
53
+ read_only = os.environ.get("MCP_DATABASE_READ_ONLY", "true").lower() in ("true", "1", "yes")
54
+ max_rows = int(os.environ.get("MCP_MAX_ROWS", "100"))
55
+
56
+ if db_url:
57
+ config = _parse_url_to_config(db_url, db_type, read_only)
58
+ return ServerConfig(databases=[config], max_rows=max_rows, allow_writes=not read_only)
59
+
60
+ # Default: in-memory SQLite for demo/testing
61
+ return ServerConfig(
62
+ databases=[
63
+ DatabaseConfig(name="demo", type="sqlite", path=":memory:", read_only=False)
64
+ ],
65
+ max_rows=max_rows,
66
+ )
67
+
68
+
69
+ def load_config_from_dict(data: dict) -> ServerConfig:
70
+ """Load configuration from a dictionary (e.g., parsed YAML)."""
71
+ databases = []
72
+ for db_data in data.get("databases", []):
73
+ databases.append(DatabaseConfig(**db_data))
74
+ return ServerConfig(
75
+ databases=databases,
76
+ max_rows=data.get("max_rows", 100),
77
+ allow_writes=data.get("allow_writes", False),
78
+ )
79
+
80
+
81
+ def _parse_url_to_config(url: str, db_type: str, read_only: bool) -> DatabaseConfig:
82
+ """Parse a database URL into a DatabaseConfig."""
83
+ if db_type == "sqlite":
84
+ # sqlite:///path/to/db.sqlite or sqlite:///:memory:
85
+ path = url.replace("sqlite:///", "").replace("sqlite://", "")
86
+ return DatabaseConfig(name="main", type="sqlite", path=path, read_only=read_only)
87
+
88
+ if db_type == "postgresql":
89
+ # postgres://user:password@host:port/database
90
+ from urllib.parse import urlparse
91
+
92
+ parsed = urlparse(url)
93
+ return DatabaseConfig(
94
+ name="main",
95
+ type="postgresql",
96
+ host=parsed.hostname or "localhost",
97
+ port=parsed.port or 5432,
98
+ user=parsed.username or "postgres",
99
+ password=parsed.password or "",
100
+ database=parsed.path.lstrip("/") or "postgres",
101
+ read_only=read_only,
102
+ )
103
+
104
+ if db_type == "mysql":
105
+ # mysql://user:password@host:port/database
106
+ from urllib.parse import urlparse
107
+
108
+ parsed = urlparse(url)
109
+ return DatabaseConfig(
110
+ name="main",
111
+ type="mysql",
112
+ host=parsed.hostname or "localhost",
113
+ port=parsed.port or 3306,
114
+ user=parsed.username or "root",
115
+ password=parsed.password or "",
116
+ database=parsed.path.lstrip("/") or "",
117
+ read_only=read_only,
118
+ )
119
+
120
+ raise ValueError(f"Unsupported database type: {db_type}")
mcp_database/server.py ADDED
@@ -0,0 +1,285 @@
1
+ """mcp-database — MCP server for multi-database access.
2
+
3
+ Provides tools for Claude to query, inspect, and manage databases
4
+ (SQLite, PostgreSQL, MySQL) through the Model Context Protocol.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections.abc import AsyncIterator
11
+ from contextlib import asynccontextmanager
12
+ from dataclasses import dataclass, field
13
+
14
+ from mcp.server.fastmcp import Context, FastMCP
15
+
16
+ from mcp_database.adapters.base import DatabaseAdapter
17
+ from mcp_database.adapters.sqlite import SQLiteAdapter
18
+ from mcp_database.config import ServerConfig, load_config_from_env
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # Adapter factory
25
+ # ---------------------------------------------------------------------------
26
+
27
+ def _create_adapter(config) -> DatabaseAdapter:
28
+ """Create a database adapter from config."""
29
+ if config.type == "sqlite":
30
+ return SQLiteAdapter(database_path=config.path, read_only=config.read_only)
31
+ if config.type == "postgresql":
32
+ from mcp_database.adapters.postgres import PostgreSQLAdapter
33
+
34
+ return PostgreSQLAdapter(
35
+ host=config.host,
36
+ port=config.port or 5432,
37
+ user=config.user,
38
+ password=config.password,
39
+ database=config.database,
40
+ read_only=config.read_only,
41
+ )
42
+ if config.type == "mysql":
43
+ from mcp_database.adapters.mysql import MySQLAdapter
44
+
45
+ return MySQLAdapter(
46
+ host=config.host,
47
+ port=config.port or 3306,
48
+ user=config.user,
49
+ password=config.password,
50
+ database=config.database,
51
+ read_only=config.read_only,
52
+ )
53
+ raise ValueError(f"Unsupported database type: {config.type}")
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Lifespan context
58
+ # ---------------------------------------------------------------------------
59
+
60
+ @dataclass
61
+ class AppContext:
62
+ adapters: dict[str, DatabaseAdapter] = field(default_factory=dict)
63
+ config: ServerConfig = field(default_factory=ServerConfig)
64
+
65
+
66
+ @asynccontextmanager
67
+ async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]:
68
+ """Manage database connections on server startup/shutdown."""
69
+ config = load_config_from_env()
70
+ adapters: dict[str, DatabaseAdapter] = {}
71
+
72
+ for db_config in config.databases:
73
+ adapter = _create_adapter(db_config)
74
+ try:
75
+ adapter.connect()
76
+ adapters[db_config.name] = adapter
77
+ logger.info("Connected to %s (%s)", db_config.name, db_config.type)
78
+ except Exception:
79
+ logger.exception("Failed to connect to %s", db_config.name)
80
+
81
+ try:
82
+ yield AppContext(adapters=adapters, config=config)
83
+ finally:
84
+ for name, adapter in adapters.items():
85
+ try:
86
+ adapter.disconnect()
87
+ logger.info("Disconnected from %s", name)
88
+ except Exception:
89
+ logger.exception("Error disconnecting from %s", name)
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # MCP Server
94
+ # ---------------------------------------------------------------------------
95
+
96
+ mcp = FastMCP(
97
+ "Database Server",
98
+ version="0.1.0",
99
+ lifespan=app_lifespan,
100
+ )
101
+
102
+
103
+ def _get_adapter(ctx: Context, name: str | None = None) -> DatabaseAdapter:
104
+ """Get a database adapter by name, or the first one if name is None."""
105
+ app_ctx: AppContext = ctx.request_context.lifespan_context
106
+ if not app_ctx.adapters:
107
+ raise RuntimeError("No databases configured.")
108
+ if name:
109
+ if name not in app_ctx.adapters:
110
+ available = ", ".join(app_ctx.adapters.keys())
111
+ raise ValueError(f"Database '{name}' not found. Available: {available}")
112
+ return app_ctx.adapters[name]
113
+ return next(iter(app_ctx.adapters.values()))
114
+
115
+
116
+ # ---------------------------------------------------------------------------
117
+ # Tools
118
+ # ---------------------------------------------------------------------------
119
+
120
+ @mcp.tool()
121
+ def list_databases(ctx: Context) -> str:
122
+ """List all configured database connections and their types."""
123
+ app_ctx: AppContext = ctx.request_context.lifespan_context
124
+ lines = []
125
+ for name, adapter in app_ctx.adapters.items():
126
+ status = "connected" if adapter.test_connection() else "disconnected"
127
+ lines.append(f"{name} ({adapter.db_type}) — {status}")
128
+ return "\n".join(lines) if lines else "No databases configured."
129
+
130
+
131
+ @mcp.tool()
132
+ def list_tables(database: str | None = None, ctx: Context = None) -> str:
133
+ """List all tables in a database.
134
+
135
+ Args:
136
+ database: Name of the database connection (optional if only one is configured).
137
+ """
138
+ adapter = _get_adapter(ctx, database)
139
+ tables = adapter.list_tables()
140
+ if not tables:
141
+ return f"No tables found in {database or adapter.db_type}."
142
+ return "\n".join(tables)
143
+
144
+
145
+ @mcp.tool()
146
+ def get_table_info(table: str, database: str | None = None, ctx: Context = None) -> str:
147
+ """Get detailed information about a table: columns, types, row count.
148
+
149
+ Args:
150
+ table: Table name.
151
+ database: Name of the database connection (optional if only one is configured).
152
+ """
153
+ adapter = _get_adapter(ctx, database)
154
+ info = adapter.get_table_info(table)
155
+
156
+ lines = [f"Table: {info.name}", f"Rows: {info.row_count}", "", "Columns:"]
157
+ for col in info.columns:
158
+ parts = [col["name"], col["type"]]
159
+ if col["primary_key"]:
160
+ parts.append("PK")
161
+ if not col["nullable"]:
162
+ parts.append("NOT NULL")
163
+ if col["default"] is not None:
164
+ parts.append(f"DEFAULT {col['default']}")
165
+ lines.append(f" {' '.join(parts)}")
166
+
167
+ if info.create_sql:
168
+ lines.extend(["", "CREATE SQL:", info.create_sql])
169
+
170
+ return "\n".join(lines)
171
+
172
+
173
+ @mcp.tool()
174
+ def get_schema(database: str | None = None, ctx: Context = None) -> str:
175
+ """Get the full database schema (CREATE TABLE statements for all tables).
176
+
177
+ Args:
178
+ database: Name of the database connection (optional if only one is configured).
179
+ """
180
+ adapter = _get_adapter(ctx, database)
181
+ return adapter.get_schema()
182
+
183
+
184
+ @mcp.tool()
185
+ def query(sql: str, database: str | None = None, max_rows: int = 100, ctx: Context = None) -> str:
186
+ """Execute a read-only SQL query (SELECT, SHOW, DESCRIBE, EXPLAIN) and return results.
187
+
188
+ Args:
189
+ sql: SQL query to execute.
190
+ database: Name of the database connection (optional if only one is configured).
191
+ max_rows: Maximum number of rows to return (default: 100).
192
+ """
193
+ adapter = _get_adapter(ctx, database)
194
+ app_ctx: AppContext = ctx.request_context.lifespan_context
195
+ max_rows = min(max_rows, app_ctx.config.max_rows)
196
+
197
+ result = adapter.execute_query(sql, max_rows=max_rows)
198
+ return result.to_table(max_rows=max_rows)
199
+
200
+
201
+ @mcp.tool()
202
+ def execute(sql: str, database: str | None = None, ctx: Context = None) -> str:
203
+ """Execute a write SQL statement (INSERT, UPDATE, DELETE). Only works if writes are enabled.
204
+
205
+ Args:
206
+ sql: SQL statement to execute.
207
+ database: Name of the database connection (optional if only one is configured).
208
+ """
209
+ adapter = _get_adapter(ctx, database)
210
+ app_ctx: AppContext = ctx.request_context.lifespan_context
211
+
212
+ if not app_ctx.config.allow_writes:
213
+ return "Error: Write operations are disabled. Set allow_writes=True in config to enable."
214
+
215
+ if adapter._is_read_only_query(sql):
216
+ return "Error: Use the 'query' tool for SELECT statements."
217
+
218
+ affected = adapter.execute_write(sql)
219
+ return f"OK. {affected} row(s) affected."
220
+
221
+
222
+ @mcp.tool()
223
+ def sample_rows(table: str, limit: int = 5, database: str | None = None, ctx: Context = None) -> str:
224
+ """Get a sample of rows from a table to understand its data.
225
+
226
+ Args:
227
+ table: Table name.
228
+ limit: Number of rows to sample (default: 5, max: 20).
229
+ database: Name of the database connection (optional if only one is configured).
230
+ """
231
+ adapter = _get_adapter(ctx, database)
232
+ limit = min(limit, 20)
233
+ result = adapter.execute_query(f"SELECT * FROM \"{table}\" LIMIT {limit}", max_rows=limit)
234
+ return result.to_table(max_rows=limit)
235
+
236
+
237
+ @mcp.tool()
238
+ def search_tables(keyword: str, database: str | None = None, ctx: Context = None) -> str:
239
+ """Search for tables or columns matching a keyword.
240
+
241
+ Args:
242
+ keyword: Keyword to search for in table and column names.
243
+ database: Name of the database connection (optional if only one is configured).
244
+ """
245
+ adapter = _get_adapter(ctx, database)
246
+ keyword_lower = keyword.lower()
247
+ matches = []
248
+
249
+ for table_name in adapter.list_tables():
250
+ if keyword_lower in table_name.lower():
251
+ matches.append(f"Table: {table_name}")
252
+
253
+ info = adapter.get_table_info(table_name)
254
+ for col in info.columns:
255
+ if keyword_lower in col["name"].lower():
256
+ matches.append(f" {table_name}.{col['name']} ({col['type']})")
257
+
258
+ return "\n".join(matches) if matches else f"No matches for '{keyword}'."
259
+
260
+
261
+ # ---------------------------------------------------------------------------
262
+ # Resources
263
+ # ---------------------------------------------------------------------------
264
+
265
+ @mcp.resource("db://databases")
266
+ def resource_databases(ctx: Context) -> str:
267
+ """List all configured databases."""
268
+ app_ctx: AppContext = ctx.request_context.lifespan_context
269
+ lines = []
270
+ for name, adapter in app_ctx.adapters.items():
271
+ lines.append(f"- {name} ({adapter.db_type})")
272
+ return "\n".join(lines) if lines else "No databases configured."
273
+
274
+
275
+ # ---------------------------------------------------------------------------
276
+ # Entry point
277
+ # ---------------------------------------------------------------------------
278
+
279
+ def main():
280
+ """Run the MCP server."""
281
+ mcp.run()
282
+
283
+
284
+ if __name__ == "__main__":
285
+ main()
@@ -0,0 +1,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: mcp-database
3
+ Version: 0.1.0
4
+ Summary: MCP server for multi-database access — SQLite, PostgreSQL, MySQL. Query, inspect schema, and manage databases through Claude.
5
+ Author: Mergewall Team
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Keywords: claude,database,mcp,model-context-protocol,mysql,postgresql,sqlite
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: Database
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Requires-Python: >=3.10
19
+ Requires-Dist: mcp>=1.0.0
20
+ Requires-Dist: pyyaml>=6.0
21
+ Provides-Extra: all
22
+ Requires-Dist: psycopg2-binary>=2.9.0; extra == 'all'
23
+ Requires-Dist: pymysql>=1.1.0; extra == 'all'
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
26
+ Requires-Dist: pytest>=8.0; extra == 'dev'
27
+ Requires-Dist: ruff>=0.4.0; extra == 'dev'
28
+ Provides-Extra: mysql
29
+ Requires-Dist: pymysql>=1.1.0; extra == 'mysql'
30
+ Provides-Extra: postgres
31
+ Requires-Dist: psycopg2-binary>=2.9.0; extra == 'postgres'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # mcp-database
35
+
36
+ [![Python](https://img.shields.io/badge/python-3.10%2B-blue)](https://www.python.org/)
37
+ [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
38
+ [![MCP](https://img.shields.io/badge/MCP-compatible-orange)](https://modelcontextprotocol.io)
39
+
40
+ **MCP server for multi-database access — query, inspect schema, and manage SQLite, PostgreSQL, and MySQL databases through Claude.**
41
+
42
+ ## Why mcp-database?
43
+
44
+ | Problem | Solution |
45
+ |---------|----------|
46
+ | Need to query a database from Claude Code / Claude Desktop | One MCP server, multiple database support |
47
+ | Existing database MCP servers are JS/Go only | Pure Python, uses official `mcp` SDK |
48
+ | Worried about accidental writes | Read-only by default, writes opt-in |
49
+ | Don't know the schema | Built-in schema inspection, table info, search |
50
+
51
+ ## Quick Start
52
+
53
+ ```bash
54
+ # Install
55
+ pip install mcp-database
56
+
57
+ # Run with a SQLite database
58
+ MCP_DATABASE_URL=sqlite:///path/to/your.db mcp-database
59
+ ```
60
+
61
+ ### Claude Code Integration
62
+
63
+ ```bash
64
+ # Add to Claude Code
65
+ claude mcp add mcp-database -- mcp-database
66
+
67
+ # Or with a specific database
68
+ claude mcp add mcp-database -e MCP_DATABASE_URL=sqlite:///path/to/db.sqlite -- mcp-database
69
+ ```
70
+
71
+ ### Claude Desktop Integration
72
+
73
+ Add to your `claude_desktop_config.json`:
74
+
75
+ ```json
76
+ {
77
+ "mcpServers": {
78
+ "database": {
79
+ "command": "mcp-database",
80
+ "env": {
81
+ "MCP_DATABASE_URL": "sqlite:///path/to/your.db"
82
+ }
83
+ }
84
+ }
85
+ }
86
+ ```
87
+
88
+ ## Supported Databases
89
+
90
+ | Database | Status | Install |
91
+ |----------|--------|---------|
92
+ | **SQLite** | Built-in | `pip install mcp-database` |
93
+ | **PostgreSQL** | Optional | `pip install 'mcp-database[postgres]'` |
94
+ | **MySQL** | Optional | `pip install 'mcp-database[mysql]'` |
95
+ | **All** | Optional | `pip install 'mcp-database[all]'` |
96
+
97
+ ## Configuration
98
+
99
+ ### Environment Variables
100
+
101
+ | Variable | Default | Description |
102
+ |----------|---------|-------------|
103
+ | `MCP_DATABASE_URL` | `sqlite:///:memory:` | Database connection URL |
104
+ | `MCP_DATABASE_TYPE` | `sqlite` | Database type: `sqlite`, `postgresql`, `mysql` |
105
+ | `MCP_DATABASE_READ_ONLY` | `true` | Enable read-only mode |
106
+ | `MCP_MAX_ROWS` | `100` | Maximum rows returned per query |
107
+
108
+ ### Connection URLs
109
+
110
+ ```bash
111
+ # SQLite
112
+ MCP_DATABASE_URL=sqlite:///path/to/db.sqlite
113
+ MCP_DATABASE_URL=sqlite:///:memory:
114
+
115
+ # PostgreSQL
116
+ MCP_DATABASE_URL=postgres://user:password@localhost:5432/mydb
117
+ MCP_DATABASE_TYPE=postgresql
118
+
119
+ # MySQL
120
+ MCP_DATABASE_URL=mysql://user:password@localhost:3306/mydb
121
+ MCP_DATABASE_TYPE=mysql
122
+ ```
123
+
124
+ ## Available Tools
125
+
126
+ Once connected, Claude can use these tools:
127
+
128
+ | Tool | Description |
129
+ |------|-------------|
130
+ | `list_databases` | List all configured database connections |
131
+ | `list_tables` | List all tables in a database |
132
+ | `get_table_info` | Get detailed table info (columns, types, row count) |
133
+ | `get_schema` | Get full database schema (CREATE TABLE statements) |
134
+ | `query` | Execute a read-only SQL query (SELECT, SHOW, DESCRIBE) |
135
+ | `execute` | Execute a write statement (INSERT, UPDATE, DELETE) — opt-in only |
136
+ | `sample_rows` | Get sample rows from a table |
137
+ | `search_tables` | Search for tables or columns by keyword |
138
+
139
+ ## Examples
140
+
141
+ Ask Claude things like:
142
+
143
+ - "What tables are in my database?"
144
+ - "Show me the schema for the users table"
145
+ - "Query the top 10 orders by amount"
146
+ - "Find all columns related to 'email'"
147
+ - "Sample some rows from the products table"
148
+
149
+ ## Security
150
+
151
+ - **Read-only by default** — queries are safe, no data modification
152
+ - **Write opt-in** — set `allow_writes=True` and `MCP_DATABASE_READ_ONLY=false` to enable
153
+ - **Read-only detection** — write tool rejects SELECT statements (use `query` instead)
154
+ - **Row limits** — configurable max rows to prevent accidental large result sets
155
+
156
+ ## Development
157
+
158
+ ```bash
159
+ # Clone and install
160
+ git clone https://github.com/Jansen003/mcp-database.git
161
+ cd mcp-database
162
+ pip install -e ".[dev]"
163
+
164
+ # Run tests
165
+ pytest
166
+
167
+ # Run with Inspector UI
168
+ mcp dev src/mcp_database/server.py
169
+ ```
170
+
171
+ ## License
172
+
173
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,14 @@
1
+ mcp_database/__init__.py,sha256=bwWl9Arr7k4fU1mwQjneoOjKiGSilKzXUGEmbdIVV_4,84
2
+ mcp_database/__main__.py,sha256=5KjDHbkEGU5KzjggDpdI0sFDzr5zBtKOZB8EEesZl5Y,93
3
+ mcp_database/config.py,sha256=hxwqzYm_uuQS3q9_bxTGQAaeefFjNFWeBsP3-p3ogKg,3967
4
+ mcp_database/server.py,sha256=VIgUz_AzESxXCejMxXgfqL6E21nmxzhjJExkVqerZaM,9974
5
+ mcp_database/adapters/__init__.py,sha256=Rjo79cBgm6nk20FZlT-L1ShN-74C4CJQtWlNFHRz_kE,201
6
+ mcp_database/adapters/base.py,sha256=Ga2dykLUskYcdmERSboy6d52cusBjEszhGgbaf7AG2s,3562
7
+ mcp_database/adapters/mysql.py,sha256=gM7MxvFOAqTahdl9MqqdPuL-IgK27QMzfNNQoKixLfE,5821
8
+ mcp_database/adapters/postgres.py,sha256=C0DUm90Q2tAeVTLBqWWfcE7z1AqbBWV9ycHPneXFjZQ,5892
9
+ mcp_database/adapters/sqlite.py,sha256=OLuV9HPMy4jhZRwb70FwLb416v7kCwU6I55a3pe3Z2s,4339
10
+ mcp_database-0.1.0.dist-info/METADATA,sha256=3r47W2LP_OD8Vyh-2PTrjn2Ttmy_JMlMtgdRq8o8E3k,5320
11
+ mcp_database-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
12
+ mcp_database-0.1.0.dist-info/entry_points.txt,sha256=b3-EncwQwV8PjQeHSs2bt6EAbiCgDTUE_9vhonSgyX4,58
13
+ mcp_database-0.1.0.dist-info/licenses/LICENSE,sha256=UGM1IQ7xoSg6FMJ8S97NOZNSUCXK_n8S2CGQZZgyYNs,1071
14
+ mcp_database-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mcp-database = mcp_database.server:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mergewall Team
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.