db-adapter 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.
db_adapter/__init__.py
ADDED
db_adapter/server.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MCP Database Server - Exposes database tools for AI agents via MCP protocol.
|
|
3
|
+
Connects to a PostgreSQL database using individual env vars for host, port,
|
|
4
|
+
user, password, name, and schema.
|
|
5
|
+
Provides: list_tables, describe_table, run_select_query.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncpg
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
from mcp.server import Server
|
|
14
|
+
from mcp.server.stdio import stdio_server
|
|
15
|
+
from mcp.types import Tool, TextContent
|
|
16
|
+
|
|
17
|
+
DB_NAME = os.environ.get("DATABASE_POSTGRESQL_NAME", "")
|
|
18
|
+
DB_HOST = os.environ.get("DATABASE_POSTGRESQL_HOST", "")
|
|
19
|
+
DB_PORT = os.environ.get("DATABASE_POSTGRESQL_PORT", "")
|
|
20
|
+
DB_USER = os.environ.get("DATABASE_POSTGRESQL_USERNAME", "")
|
|
21
|
+
DB_PASS = os.environ.get("DATABASE_POSTGRESQL_PASSWORD", "")
|
|
22
|
+
DB_SCHEMA = os.environ.get("DATABASE_POSTGRESQL_SCHEMA", "public")
|
|
23
|
+
|
|
24
|
+
_IDENTIFIER_RE = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
|
|
25
|
+
|
|
26
|
+
server = Server("mcp-db")
|
|
27
|
+
|
|
28
|
+
_pool = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_safe_identifier(name: str) -> bool:
|
|
32
|
+
return bool(_IDENTIFIER_RE.match(name))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_dsn() -> str:
|
|
36
|
+
if DB_PASS:
|
|
37
|
+
return f"postgresql://{DB_USER}:{DB_PASS}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
|
38
|
+
return f"postgresql://{DB_USER}@{DB_HOST}:{DB_PORT}/{DB_NAME}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
async def _ensure_pool() -> TextContent | None:
|
|
42
|
+
global _pool
|
|
43
|
+
missing = []
|
|
44
|
+
if not DB_NAME:
|
|
45
|
+
missing.append("DATABASE_POSTGRESQL_NAME")
|
|
46
|
+
if not DB_HOST:
|
|
47
|
+
missing.append("DATABASE_POSTGRESQL_HOST")
|
|
48
|
+
if not DB_PORT:
|
|
49
|
+
missing.append("DATABASE_POSTGRESQL_PORT")
|
|
50
|
+
if not DB_USER:
|
|
51
|
+
missing.append("DATABASE_POSTGRESQL_USERNAME")
|
|
52
|
+
if missing:
|
|
53
|
+
return TextContent(
|
|
54
|
+
type="text",
|
|
55
|
+
text=f"Error: Missing required environment variables: {', '.join(missing)}. "
|
|
56
|
+
f"The database is not available.",
|
|
57
|
+
)
|
|
58
|
+
if not _is_safe_identifier(DB_SCHEMA):
|
|
59
|
+
return TextContent(
|
|
60
|
+
type="text",
|
|
61
|
+
text=f"Error: Invalid schema name '{DB_SCHEMA}'. "
|
|
62
|
+
f"Schema name must contain only alphanumeric characters and underscores.",
|
|
63
|
+
)
|
|
64
|
+
if _pool is None:
|
|
65
|
+
try:
|
|
66
|
+
_pool = await asyncpg.create_pool(_build_dsn())
|
|
67
|
+
except Exception as e:
|
|
68
|
+
return TextContent(
|
|
69
|
+
type="text",
|
|
70
|
+
text=f"Error: Failed to connect to database: {str(e)}",
|
|
71
|
+
)
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _release_pool():
|
|
76
|
+
global _pool
|
|
77
|
+
if _pool:
|
|
78
|
+
await _pool.close()
|
|
79
|
+
_pool = None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@server.list_tools()
|
|
83
|
+
async def list_tools() -> list[Tool]:
|
|
84
|
+
return [
|
|
85
|
+
Tool(
|
|
86
|
+
name="list_tables",
|
|
87
|
+
description="List all tables in the database. Returns table names and row counts.",
|
|
88
|
+
inputSchema={
|
|
89
|
+
"type": "object",
|
|
90
|
+
"properties": {},
|
|
91
|
+
"required": [],
|
|
92
|
+
},
|
|
93
|
+
),
|
|
94
|
+
Tool(
|
|
95
|
+
name="describe_table",
|
|
96
|
+
description="Get the schema of a specific table. Returns column names, types, nullable, and default values.",
|
|
97
|
+
inputSchema={
|
|
98
|
+
"type": "object",
|
|
99
|
+
"properties": {
|
|
100
|
+
"table_name": {
|
|
101
|
+
"type": "string",
|
|
102
|
+
"description": "The name of the table to describe.",
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
"required": ["table_name"],
|
|
106
|
+
},
|
|
107
|
+
),
|
|
108
|
+
Tool(
|
|
109
|
+
name="run_select_query",
|
|
110
|
+
description="Execute a read-only SELECT query against the database. Only SELECT statements are allowed for safety.",
|
|
111
|
+
inputSchema={
|
|
112
|
+
"type": "object",
|
|
113
|
+
"properties": {
|
|
114
|
+
"query": {
|
|
115
|
+
"type": "string",
|
|
116
|
+
"description": "The SELECT SQL query to execute.",
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
"required": ["query"],
|
|
120
|
+
},
|
|
121
|
+
),
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@server.call_tool()
|
|
126
|
+
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
|
|
127
|
+
if name == "list_tables":
|
|
128
|
+
return await _list_tables()
|
|
129
|
+
elif name == "describe_table":
|
|
130
|
+
return await _describe_table(arguments["table_name"])
|
|
131
|
+
elif name == "run_select_query":
|
|
132
|
+
return await _run_select_query(arguments["query"])
|
|
133
|
+
else:
|
|
134
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _list_tables() -> list[TextContent]:
|
|
138
|
+
error = await _ensure_pool()
|
|
139
|
+
if error:
|
|
140
|
+
return [error]
|
|
141
|
+
|
|
142
|
+
schema = DB_SCHEMA
|
|
143
|
+
async with _pool.acquire() as conn:
|
|
144
|
+
rows = await conn.fetch(
|
|
145
|
+
f"SELECT table_name FROM information_schema.tables "
|
|
146
|
+
f"WHERE table_schema = '{schema}' AND table_type = 'BASE TABLE' "
|
|
147
|
+
f"ORDER BY table_name"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
if not rows:
|
|
151
|
+
return [TextContent(type="text", text="No tables found in the database.")]
|
|
152
|
+
|
|
153
|
+
lines = []
|
|
154
|
+
for row in rows:
|
|
155
|
+
table_name = row["table_name"]
|
|
156
|
+
count = await conn.fetchval(
|
|
157
|
+
f'SELECT COUNT(*) FROM "{schema}"."{table_name}"'
|
|
158
|
+
)
|
|
159
|
+
lines.append(f"{table_name} ({count} rows)")
|
|
160
|
+
|
|
161
|
+
return [TextContent(type="text", text="\n".join(lines))]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def _describe_table(table_name: str) -> list[TextContent]:
|
|
165
|
+
if not _is_safe_identifier(table_name):
|
|
166
|
+
return [
|
|
167
|
+
TextContent(
|
|
168
|
+
type="text",
|
|
169
|
+
text=f"Error: Invalid table name '{table_name}'. "
|
|
170
|
+
f"Table name must contain only alphanumeric characters and underscores.",
|
|
171
|
+
)
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
error = await _ensure_pool()
|
|
175
|
+
if error:
|
|
176
|
+
return [error]
|
|
177
|
+
|
|
178
|
+
schema = DB_SCHEMA
|
|
179
|
+
async with _pool.acquire() as conn:
|
|
180
|
+
exists = await conn.fetchval(
|
|
181
|
+
f"SELECT COUNT(*) FROM information_schema.tables "
|
|
182
|
+
f"WHERE table_schema = '{schema}' AND table_name = $1",
|
|
183
|
+
table_name,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
if not exists:
|
|
187
|
+
return [
|
|
188
|
+
TextContent(
|
|
189
|
+
type="text",
|
|
190
|
+
text=f"Table '{table_name}' does not exist.",
|
|
191
|
+
)
|
|
192
|
+
]
|
|
193
|
+
|
|
194
|
+
columns = await conn.fetch(
|
|
195
|
+
f"""
|
|
196
|
+
SELECT
|
|
197
|
+
c.column_name,
|
|
198
|
+
c.data_type,
|
|
199
|
+
c.is_nullable,
|
|
200
|
+
c.column_default,
|
|
201
|
+
CASE WHEN pk.column_name IS NOT NULL THEN true ELSE false END AS is_pk
|
|
202
|
+
FROM information_schema.columns c
|
|
203
|
+
LEFT JOIN (
|
|
204
|
+
SELECT kcu.column_name
|
|
205
|
+
FROM information_schema.table_constraints tc
|
|
206
|
+
JOIN information_schema.key_column_usage kcu
|
|
207
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
208
|
+
AND tc.table_schema = kcu.table_schema
|
|
209
|
+
WHERE tc.table_schema = '{schema}'
|
|
210
|
+
AND tc.table_name = $1
|
|
211
|
+
AND tc.constraint_type = 'PRIMARY KEY'
|
|
212
|
+
) pk ON c.column_name = pk.column_name
|
|
213
|
+
WHERE c.table_schema = '{schema}'
|
|
214
|
+
AND c.table_name = $1
|
|
215
|
+
ORDER BY c.ordinal_position
|
|
216
|
+
""",
|
|
217
|
+
table_name,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
fks = await conn.fetch(
|
|
221
|
+
f"""
|
|
222
|
+
SELECT
|
|
223
|
+
kcu.column_name,
|
|
224
|
+
ccu.table_name AS foreign_table_name,
|
|
225
|
+
ccu.column_name AS foreign_column_name
|
|
226
|
+
FROM information_schema.table_constraints tc
|
|
227
|
+
JOIN information_schema.key_column_usage kcu
|
|
228
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
229
|
+
AND tc.table_schema = kcu.table_schema
|
|
230
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
231
|
+
ON tc.constraint_name = ccu.constraint_name
|
|
232
|
+
AND tc.table_schema = ccu.table_schema
|
|
233
|
+
WHERE tc.table_schema = '{schema}'
|
|
234
|
+
AND tc.table_name = $1
|
|
235
|
+
AND tc.constraint_type = 'FOREIGN KEY'
|
|
236
|
+
""",
|
|
237
|
+
table_name,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
indexes = await conn.fetch(
|
|
241
|
+
f"SELECT indexname, indexdef FROM pg_indexes "
|
|
242
|
+
f"WHERE schemaname = '{schema}' AND tablename = $1",
|
|
243
|
+
table_name,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
lines = [f"Table: {table_name}", "", "Columns:"]
|
|
247
|
+
for col in columns:
|
|
248
|
+
pk = " PRIMARY KEY" if col["is_pk"] else ""
|
|
249
|
+
notnull = " NOT NULL" if col["is_nullable"] == "NO" else ""
|
|
250
|
+
default = (
|
|
251
|
+
f" DEFAULT {col['column_default']}" if col["column_default"] else ""
|
|
252
|
+
)
|
|
253
|
+
lines.append(
|
|
254
|
+
f" {col['column_name']} {col['data_type']}{pk}{notnull}{default}"
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
if fks:
|
|
258
|
+
lines.append("")
|
|
259
|
+
lines.append("Foreign Keys:")
|
|
260
|
+
for fk in fks:
|
|
261
|
+
lines.append(
|
|
262
|
+
f" {fk['column_name']} -> {fk['foreign_table_name']}.{fk['foreign_column_name']}"
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
if indexes:
|
|
266
|
+
lines.append("")
|
|
267
|
+
lines.append("Indexes:")
|
|
268
|
+
for idx in indexes:
|
|
269
|
+
unique = "UNIQUE" in (idx["indexdef"] or "")
|
|
270
|
+
lines.append(f" {idx['indexname']} (unique: {unique})")
|
|
271
|
+
|
|
272
|
+
return [TextContent(type="text", text="\n".join(lines))]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def _run_select_query(query: str) -> list[TextContent]:
|
|
276
|
+
stripped = query.strip().upper()
|
|
277
|
+
if not stripped.startswith("SELECT"):
|
|
278
|
+
return [
|
|
279
|
+
TextContent(
|
|
280
|
+
type="text",
|
|
281
|
+
text="Error: Only SELECT queries are allowed for safety.",
|
|
282
|
+
)
|
|
283
|
+
]
|
|
284
|
+
|
|
285
|
+
error = await _ensure_pool()
|
|
286
|
+
if error:
|
|
287
|
+
return [error]
|
|
288
|
+
|
|
289
|
+
async with _pool.acquire() as conn:
|
|
290
|
+
try:
|
|
291
|
+
rows = await conn.fetch(query)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
return [TextContent(type="text", text=f"Error executing query: {str(e)}")]
|
|
294
|
+
|
|
295
|
+
if not rows:
|
|
296
|
+
return [TextContent(type="text", text="Query returned no rows.")]
|
|
297
|
+
|
|
298
|
+
headers = list(rows[0].keys())
|
|
299
|
+
lines = ["| " + " | ".join(headers) + " |"]
|
|
300
|
+
lines.append("|" + "|".join(["---" for _ in headers]) + "|")
|
|
301
|
+
for row in rows:
|
|
302
|
+
values = [str(v) if v is not None else "NULL" for v in row.values()]
|
|
303
|
+
lines.append("| " + " | ".join(values) + " |")
|
|
304
|
+
|
|
305
|
+
output = "\n".join(lines)
|
|
306
|
+
if len(rows) > 100:
|
|
307
|
+
output += f"\n\n({len(rows)} rows returned, showing all)"
|
|
308
|
+
|
|
309
|
+
return [TextContent(type="text", text=output)]
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def main():
|
|
313
|
+
async with stdio_server() as (read, write):
|
|
314
|
+
await server.run(read, write, server.create_initialization_options())
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def cli():
|
|
318
|
+
"""Entry point for the console script."""
|
|
319
|
+
asyncio.run(main())
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: db-adapter
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: MCP server that provides PostgreSQL database access tools for AI agents (read-only)
|
|
5
|
+
Author-email: Husni Robani <v.husni.robani@banksinarmas.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
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
|
+
Requires-Python: >=3.10
|
|
18
|
+
Requires-Dist: asyncpg>=0.29.0
|
|
19
|
+
Requires-Dist: mcp>=1.0.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# MCP Database Server
|
|
23
|
+
|
|
24
|
+
MCP server that provides read-only PostgreSQL database access for AI agents.
|
|
25
|
+
|
|
26
|
+
## Tools
|
|
27
|
+
|
|
28
|
+
| Tool | Description |
|
|
29
|
+
|------|-------------|
|
|
30
|
+
| `list_tables` | List all tables with row counts |
|
|
31
|
+
| `describe_table` | Get schema: columns, types, foreign keys, indexes |
|
|
32
|
+
| `run_select_query` | Execute read-only SELECT queries |
|
|
33
|
+
|
|
34
|
+
INSERT/UPDATE/DELETE are blocked for safety.
|
|
35
|
+
|
|
36
|
+
## Configuration
|
|
37
|
+
|
|
38
|
+
| Variable | Required | Default | Description |
|
|
39
|
+
|---|---|---|---|
|
|
40
|
+
| `DATABASE_POSTGRESQL_HOST` | Yes | — | PostgreSQL host |
|
|
41
|
+
| `DATABASE_POSTGRESQL_PORT` | Yes | — | PostgreSQL port |
|
|
42
|
+
| `DATABASE_POSTGRESQL_USERNAME` | Yes | — | Database user |
|
|
43
|
+
| `DATABASE_POSTGRESQL_PASSWORD` | No | — | Database password |
|
|
44
|
+
| `DATABASE_POSTGRESQL_NAME` | Yes | — | Database name |
|
|
45
|
+
| `DATABASE_POSTGRESQL_SCHEMA` | No | `public` | Schema for table discovery |
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
### Claude Desktop
|
|
50
|
+
|
|
51
|
+
Add to `claude_desktop_config.json`:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"mcpServers": {
|
|
56
|
+
"db": {
|
|
57
|
+
"command": "uvx",
|
|
58
|
+
"args": ["db-adapter"],
|
|
59
|
+
"env": {
|
|
60
|
+
"DATABASE_POSTGRESQL_HOST": "localhost",
|
|
61
|
+
"DATABASE_POSTGRESQL_PORT": "5432",
|
|
62
|
+
"DATABASE_POSTGRESQL_USERNAME": "postgres",
|
|
63
|
+
"DATABASE_POSTGRESQL_PASSWORD": "postgres",
|
|
64
|
+
"DATABASE_POSTGRESQL_NAME": "mydb"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### opencode
|
|
72
|
+
|
|
73
|
+
Add to `opencode.json`:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{
|
|
77
|
+
"mcp": {
|
|
78
|
+
"db": {
|
|
79
|
+
"type": "local",
|
|
80
|
+
"command": ["uvx", "db-adapter"],
|
|
81
|
+
"enabled": true,
|
|
82
|
+
"environment": {
|
|
83
|
+
"DATABASE_POSTGRESQL_HOST": "localhost",
|
|
84
|
+
"DATABASE_POSTGRESQL_PORT": "5432",
|
|
85
|
+
"DATABASE_POSTGRESQL_USERNAME": "postgres",
|
|
86
|
+
"DATABASE_POSTGRESQL_PASSWORD": "postgres",
|
|
87
|
+
"DATABASE_POSTGRESQL_NAME": "mydb"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Environment variables can be omitted to inherit from your shell.
|
|
95
|
+
|
|
96
|
+
## Requirements
|
|
97
|
+
|
|
98
|
+
- Python >= 3.10
|
|
99
|
+
- PostgreSQL database
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
db_adapter/__init__.py,sha256=MZP2AXRuR2ORvEehAXUjCW6_cVAM58U7SfJYSlianAg,141
|
|
2
|
+
db_adapter/server.py,sha256=v6DoD7r53S1mUEwfPxrM0h3Ps96xkVtHMhIlqV41yCs,10504
|
|
3
|
+
db_adapter-1.0.0.dist-info/METADATA,sha256=huTVddf-iwqAUM2LXrsDmb2s5Yo_48RKptfX_u9xixA,2655
|
|
4
|
+
db_adapter-1.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
db_adapter-1.0.0.dist-info/entry_points.txt,sha256=jy4EbWgpHa-KR690GIGDW0NqTk_fKiU1iUGNFUgIsxE,57
|
|
6
|
+
db_adapter-1.0.0.dist-info/licenses/LICENSE,sha256=pN1aMgZDBZaH2uCQa2CwudjuY9H-5bmn4hnAA59vl0Q,1069
|
|
7
|
+
db_adapter-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Husni Robani
|
|
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.
|