mcp-data-agent 0.1.0
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.
- package/.env.example +18 -0
- package/.idea/jsLibraryMappings.xml +6 -0
- package/.idea/mcp_engine.iml +8 -0
- package/.idea/modules.xml +8 -0
- package/.idea/php.xml +19 -0
- package/.idea/vcs.xml +6 -0
- package/README.md +587 -0
- package/examples/basic/package.json +26 -0
- package/examples/basic/src/index.ts +107 -0
- package/examples/basic/tsconfig.json +15 -0
- package/package.json +106 -0
- package/packages/core/README.md +7 -0
- package/packages/core/package.json +38 -0
- package/packages/core/src/agent.test.ts +213 -0
- package/packages/core/src/agent.ts +373 -0
- package/packages/core/src/errors.ts +73 -0
- package/packages/core/src/index.ts +78 -0
- package/packages/core/src/permissions.ts +71 -0
- package/packages/core/src/query-safety.ts +125 -0
- package/packages/core/src/schema-safety.ts +118 -0
- package/packages/core/src/tenant.ts +58 -0
- package/packages/core/src/tool-registry.ts +61 -0
- package/packages/core/src/types.ts +250 -0
- package/packages/core/tsconfig.json +10 -0
- package/packages/mcp/README.md +7 -0
- package/packages/mcp/package.json +46 -0
- package/packages/mcp/src/index.ts +15 -0
- package/packages/mcp/src/server.ts +170 -0
- package/packages/mcp/tsconfig.json +11 -0
- package/packages/postgres/README.md +7 -0
- package/packages/postgres/package.json +46 -0
- package/packages/postgres/src/adapter.ts +358 -0
- package/packages/postgres/src/index.ts +19 -0
- package/packages/postgres/tsconfig.json +11 -0
- package/tsconfig.base.json +22 -0
- package/tsconfig.json +8 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Data Agent — @mcp-data-agent/mcp
|
|
3
|
+
* Created by Arslan Habib
|
|
4
|
+
*
|
|
5
|
+
* ---------------------------------------------------------------------------
|
|
6
|
+
* FILE: server.ts
|
|
7
|
+
* PURPOSE:
|
|
8
|
+
* Bridges MCPDataAgent tools to the Model Context Protocol so AI assistants
|
|
9
|
+
* (Cursor, Claude Desktop, custom MCP clients) can call them.
|
|
10
|
+
*
|
|
11
|
+
* WHAT IT DOES:
|
|
12
|
+
* - Creates an McpServer (official TypeScript SDK v2)
|
|
13
|
+
* - Registers database_schema and database_query MCP tools
|
|
14
|
+
* - Optionally mirrors custom agent tools (dots → underscores in names)
|
|
15
|
+
* - Returns JSON text content (or structured errors) to the client
|
|
16
|
+
*
|
|
17
|
+
* AUTH / TENANT:
|
|
18
|
+
* Still resolved by agent.resolveContext on every invoke — MCP args never
|
|
19
|
+
* supply tenantId.
|
|
20
|
+
* ---------------------------------------------------------------------------
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { MCPDataAgent } from "@mcp-data-agent/core";
|
|
24
|
+
import { McpDataAgentError } from "@mcp-data-agent/core";
|
|
25
|
+
import { McpServer } from "@modelcontextprotocol/server";
|
|
26
|
+
import * as z from "zod/v4";
|
|
27
|
+
|
|
28
|
+
/** Options for createMcpDataAgentServer(). */
|
|
29
|
+
export interface CreateMcpServerOptions {
|
|
30
|
+
/** Fully configured MCPDataAgent instance. */
|
|
31
|
+
agent: MCPDataAgent;
|
|
32
|
+
/** MCP server name shown to clients. */
|
|
33
|
+
name?: string;
|
|
34
|
+
/** MCP server version shown to clients. */
|
|
35
|
+
version?: string;
|
|
36
|
+
/**
|
|
37
|
+
* When true, register every custom tool currently on the agent.
|
|
38
|
+
* Register custom tools BEFORE calling this factory.
|
|
39
|
+
*/
|
|
40
|
+
includeCustomTools?: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Wrap successful tool output as MCP text content (JSON). */
|
|
44
|
+
function toTextResult(data: unknown) {
|
|
45
|
+
return {
|
|
46
|
+
content: [
|
|
47
|
+
{
|
|
48
|
+
type: "text" as const,
|
|
49
|
+
text: JSON.stringify(data, null, 2),
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Wrap failures as MCP error content with a stable error code when possible. */
|
|
56
|
+
function toErrorResult(error: unknown) {
|
|
57
|
+
const message =
|
|
58
|
+
error instanceof Error ? error.message : "Unknown tool execution error";
|
|
59
|
+
const code =
|
|
60
|
+
error instanceof McpDataAgentError ? error.code : "TOOL_EXECUTION_ERROR";
|
|
61
|
+
return {
|
|
62
|
+
isError: true as const,
|
|
63
|
+
content: [
|
|
64
|
+
{
|
|
65
|
+
type: "text" as const,
|
|
66
|
+
text: JSON.stringify({ error: code, message }, null, 2),
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build an MCP server that exposes MCP Data Agent tools to AI assistants.
|
|
74
|
+
* Connect it with StdioServerTransport or an HTTP transport in the host app.
|
|
75
|
+
*/
|
|
76
|
+
export function createMcpDataAgentServer(
|
|
77
|
+
options: CreateMcpServerOptions,
|
|
78
|
+
): McpServer {
|
|
79
|
+
const {
|
|
80
|
+
agent,
|
|
81
|
+
name = "mcp-data-agent",
|
|
82
|
+
version = "0.1.0",
|
|
83
|
+
includeCustomTools = true,
|
|
84
|
+
} = options;
|
|
85
|
+
|
|
86
|
+
const server = new McpServer({ name, version });
|
|
87
|
+
|
|
88
|
+
// --- Built-in: safe schema for the authenticated user ---
|
|
89
|
+
server.registerTool(
|
|
90
|
+
"database_schema",
|
|
91
|
+
{
|
|
92
|
+
description:
|
|
93
|
+
"Return a safe, redacted database schema and business entities for the authenticated user.",
|
|
94
|
+
inputSchema: z.object({}),
|
|
95
|
+
},
|
|
96
|
+
async () => {
|
|
97
|
+
try {
|
|
98
|
+
const result = await agent.invokeTool("database.schema", {});
|
|
99
|
+
return toTextResult(result);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return toErrorResult(error);
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// --- Built-in: validated read-only SQL ---
|
|
107
|
+
server.registerTool(
|
|
108
|
+
"database_query",
|
|
109
|
+
{
|
|
110
|
+
description:
|
|
111
|
+
"Execute a validated read-only SQL SELECT. Tenant isolation and permissions are enforced by the host application session — do not pass tenantId.",
|
|
112
|
+
inputSchema: z.object({
|
|
113
|
+
sql: z.string().describe("Read-only SELECT (or WITH … SELECT) SQL"),
|
|
114
|
+
params: z
|
|
115
|
+
.array(z.unknown())
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("Bound parameters for the query"),
|
|
118
|
+
limit: z
|
|
119
|
+
.number()
|
|
120
|
+
.int()
|
|
121
|
+
.positive()
|
|
122
|
+
.max(1000)
|
|
123
|
+
.optional()
|
|
124
|
+
.describe("Maximum rows to return"),
|
|
125
|
+
}),
|
|
126
|
+
},
|
|
127
|
+
async ({ sql, params, limit }) => {
|
|
128
|
+
try {
|
|
129
|
+
const result = await agent.invokeTool("database.query", {
|
|
130
|
+
sql,
|
|
131
|
+
params,
|
|
132
|
+
limit,
|
|
133
|
+
});
|
|
134
|
+
return toTextResult(result);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return toErrorResult(error);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
// --- Custom tools registered on the agent (e.g. members.statistics) ---
|
|
142
|
+
if (includeCustomTools) {
|
|
143
|
+
for (const tool of agent.listTools()) {
|
|
144
|
+
// Skip built-ins already registered above under MCP-friendly names
|
|
145
|
+
if (tool.name === "database.schema" || tool.name === "database.query") {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// MCP tool names typically use underscores: members.statistics → members_statistics
|
|
149
|
+
const mcpName = tool.name.replace(/\./g, "_");
|
|
150
|
+
server.registerTool(
|
|
151
|
+
mcpName,
|
|
152
|
+
{
|
|
153
|
+
description: tool.description,
|
|
154
|
+
// Passthrough object: domain tools define their own shapes
|
|
155
|
+
inputSchema: z.object({}).passthrough(),
|
|
156
|
+
},
|
|
157
|
+
async (args) => {
|
|
158
|
+
try {
|
|
159
|
+
const result = await agent.invokeTool(tool.name, args);
|
|
160
|
+
return toTextResult(result);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
return toErrorResult(error);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return server;
|
|
170
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mcp-data-agent/postgres",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "PostgreSQL adapter for MCP Data Agent. Created by Arslan Habib.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Arslan Habib"
|
|
7
|
+
},
|
|
8
|
+
"creator": "Arslan Habib",
|
|
9
|
+
"license": "UNLICENSED",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"types": "./dist/index.d.ts",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc -p tsconfig.json",
|
|
25
|
+
"clean": "rm -rf dist *.tsbuildinfo",
|
|
26
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@mcp-data-agent/core": "0.1.0",
|
|
33
|
+
"pg": "^8.13.0"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@mcp-data-agent/core": "*",
|
|
37
|
+
"@types/pg": "^8.11.11",
|
|
38
|
+
"pg": "^8.14.1"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"mcp-data-agent",
|
|
42
|
+
"postgresql",
|
|
43
|
+
"postgres",
|
|
44
|
+
"database-adapter"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Data Agent — @mcp-data-agent/postgres
|
|
3
|
+
* Created by Arslan Habib
|
|
4
|
+
*
|
|
5
|
+
* ---------------------------------------------------------------------------
|
|
6
|
+
* FILE: adapter.ts
|
|
7
|
+
* PURPOSE:
|
|
8
|
+
* PostgreSQL implementation of DatabaseAdapter. Responsibilities:
|
|
9
|
+
* 1) Inspect information_schema → normalized DatabaseSchema
|
|
10
|
+
* 2) Run validated read-only queries inside a READ ONLY transaction
|
|
11
|
+
* 3) Set session GUCs (app.tenant_id, app.user_id) for Postgres RLS
|
|
12
|
+
*
|
|
13
|
+
* SECURITY:
|
|
14
|
+
* Prefer a read-only DB role. Enable RLS policies that read
|
|
15
|
+
* current_setting('app.tenant_id', true) so tenant isolation is enforced
|
|
16
|
+
* at the database layer, not only in application code.
|
|
17
|
+
* ---------------------------------------------------------------------------
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type {
|
|
21
|
+
ColumnDataType,
|
|
22
|
+
DatabaseAdapter,
|
|
23
|
+
DatabaseSchema,
|
|
24
|
+
QueryRequest,
|
|
25
|
+
QueryResult,
|
|
26
|
+
RequestContext,
|
|
27
|
+
TableSchema,
|
|
28
|
+
} from "@mcp-data-agent/core";
|
|
29
|
+
import {
|
|
30
|
+
ConfigurationError,
|
|
31
|
+
getTenantColumn,
|
|
32
|
+
TenantIsolationError,
|
|
33
|
+
} from "@mcp-data-agent/core";
|
|
34
|
+
import pg from "pg";
|
|
35
|
+
|
|
36
|
+
const { Pool } = pg;
|
|
37
|
+
|
|
38
|
+
/** Configuration for createPostgresAdapter / PostgresAdapter. */
|
|
39
|
+
export interface PostgresAdapterOptions {
|
|
40
|
+
/** Connection string. Prefer env vars (DATABASE_URL) in production. */
|
|
41
|
+
connectionString?: string;
|
|
42
|
+
/** Reuse an existing pg Pool owned by the host app. */
|
|
43
|
+
pool?: pg.Pool;
|
|
44
|
+
/** Passed to `new Pool(...)` when connectionString is not set. */
|
|
45
|
+
poolConfig?: pg.PoolConfig;
|
|
46
|
+
/** Schemas to inspect. Default: ["public"]. */
|
|
47
|
+
schemas?: string[];
|
|
48
|
+
/**
|
|
49
|
+
* When true (default), require tenant context and set app.tenant_id /
|
|
50
|
+
* app.user_id session variables for RLS policies.
|
|
51
|
+
*/
|
|
52
|
+
enforceTenant?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Maps PostgreSQL udt_name values to the framework's normalized ColumnDataType.
|
|
57
|
+
* Keeps AI-facing schema DB-agnostic.
|
|
58
|
+
*/
|
|
59
|
+
function mapDataType(udtName: string): ColumnDataType {
|
|
60
|
+
switch (udtName) {
|
|
61
|
+
case "int2":
|
|
62
|
+
case "int4":
|
|
63
|
+
case "int8":
|
|
64
|
+
case "numeric":
|
|
65
|
+
case "float4":
|
|
66
|
+
case "float8":
|
|
67
|
+
case "money":
|
|
68
|
+
return "number";
|
|
69
|
+
case "bool":
|
|
70
|
+
return "boolean";
|
|
71
|
+
case "date":
|
|
72
|
+
return "date";
|
|
73
|
+
case "timestamp":
|
|
74
|
+
case "timestamptz":
|
|
75
|
+
case "time":
|
|
76
|
+
case "timetz":
|
|
77
|
+
return "datetime";
|
|
78
|
+
case "json":
|
|
79
|
+
case "jsonb":
|
|
80
|
+
return "json";
|
|
81
|
+
case "bytea":
|
|
82
|
+
return "binary";
|
|
83
|
+
case "text":
|
|
84
|
+
case "varchar":
|
|
85
|
+
case "bpchar":
|
|
86
|
+
case "uuid":
|
|
87
|
+
case "citext":
|
|
88
|
+
return "string";
|
|
89
|
+
default:
|
|
90
|
+
return "unknown";
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* PostgreSQL DatabaseAdapter.
|
|
96
|
+
* Implements getSchema() and query() for @mcp-data-agent/core.
|
|
97
|
+
*/
|
|
98
|
+
export class PostgresAdapter implements DatabaseAdapter {
|
|
99
|
+
readonly name = "postgres";
|
|
100
|
+
private readonly pool: pg.Pool;
|
|
101
|
+
private readonly schemas: string[];
|
|
102
|
+
private readonly enforceTenant: boolean;
|
|
103
|
+
/** True when this adapter created the pool and must close it. */
|
|
104
|
+
private readonly ownsPool: boolean;
|
|
105
|
+
|
|
106
|
+
constructor(options: PostgresAdapterOptions = {}) {
|
|
107
|
+
// Prefer an injected pool (host apps often share one across services)
|
|
108
|
+
if (options.pool) {
|
|
109
|
+
this.pool = options.pool;
|
|
110
|
+
this.ownsPool = false;
|
|
111
|
+
} else if (options.connectionString || options.poolConfig) {
|
|
112
|
+
this.pool = new Pool(
|
|
113
|
+
options.connectionString
|
|
114
|
+
? { connectionString: options.connectionString }
|
|
115
|
+
: options.poolConfig,
|
|
116
|
+
);
|
|
117
|
+
this.ownsPool = true;
|
|
118
|
+
} else {
|
|
119
|
+
throw new ConfigurationError(
|
|
120
|
+
"PostgresAdapter requires connectionString, poolConfig, or an existing pool",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
this.schemas = options.schemas ?? ["public"];
|
|
124
|
+
this.enforceTenant = options.enforceTenant ?? true;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Reads tables, columns, primary keys, and foreign keys from
|
|
129
|
+
* information_schema and returns a normalized DatabaseSchema.
|
|
130
|
+
*/
|
|
131
|
+
async getSchema(): Promise<DatabaseSchema> {
|
|
132
|
+
const client = await this.pool.connect();
|
|
133
|
+
try {
|
|
134
|
+
// --- Base tables in configured schemas ---
|
|
135
|
+
const tablesResult = await client.query<{
|
|
136
|
+
table_schema: string;
|
|
137
|
+
table_name: string;
|
|
138
|
+
}>(
|
|
139
|
+
`
|
|
140
|
+
SELECT table_schema, table_name
|
|
141
|
+
FROM information_schema.tables
|
|
142
|
+
WHERE table_type = 'BASE TABLE'
|
|
143
|
+
AND table_schema = ANY($1::text[])
|
|
144
|
+
ORDER BY table_schema, table_name
|
|
145
|
+
`,
|
|
146
|
+
[this.schemas],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
// --- Columns with native Postgres types ---
|
|
150
|
+
const columnsResult = await client.query<{
|
|
151
|
+
table_schema: string;
|
|
152
|
+
table_name: string;
|
|
153
|
+
column_name: string;
|
|
154
|
+
is_nullable: string;
|
|
155
|
+
udt_name: string;
|
|
156
|
+
ordinal_position: number;
|
|
157
|
+
}>(
|
|
158
|
+
`
|
|
159
|
+
SELECT table_schema, table_name, column_name, is_nullable, udt_name, ordinal_position
|
|
160
|
+
FROM information_schema.columns
|
|
161
|
+
WHERE table_schema = ANY($1::text[])
|
|
162
|
+
ORDER BY table_schema, table_name, ordinal_position
|
|
163
|
+
`,
|
|
164
|
+
[this.schemas],
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
// --- Primary key columns ---
|
|
168
|
+
const pkResult = await client.query<{
|
|
169
|
+
table_schema: string;
|
|
170
|
+
table_name: string;
|
|
171
|
+
column_name: string;
|
|
172
|
+
}>(
|
|
173
|
+
`
|
|
174
|
+
SELECT tc.table_schema, tc.table_name, kcu.column_name
|
|
175
|
+
FROM information_schema.table_constraints tc
|
|
176
|
+
JOIN information_schema.key_column_usage kcu
|
|
177
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
178
|
+
AND tc.table_schema = kcu.table_schema
|
|
179
|
+
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
180
|
+
AND tc.table_schema = ANY($1::text[])
|
|
181
|
+
`,
|
|
182
|
+
[this.schemas],
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// --- Foreign key relationships ---
|
|
186
|
+
const fkResult = await client.query<{
|
|
187
|
+
table_schema: string;
|
|
188
|
+
table_name: string;
|
|
189
|
+
column_name: string;
|
|
190
|
+
foreign_table_name: string;
|
|
191
|
+
foreign_column_name: string;
|
|
192
|
+
}>(
|
|
193
|
+
`
|
|
194
|
+
SELECT
|
|
195
|
+
tc.table_schema,
|
|
196
|
+
tc.table_name,
|
|
197
|
+
kcu.column_name,
|
|
198
|
+
ccu.table_name AS foreign_table_name,
|
|
199
|
+
ccu.column_name AS foreign_column_name
|
|
200
|
+
FROM information_schema.table_constraints tc
|
|
201
|
+
JOIN information_schema.key_column_usage kcu
|
|
202
|
+
ON tc.constraint_name = kcu.constraint_name
|
|
203
|
+
AND tc.table_schema = kcu.table_schema
|
|
204
|
+
JOIN information_schema.constraint_column_usage ccu
|
|
205
|
+
ON ccu.constraint_name = tc.constraint_name
|
|
206
|
+
AND ccu.table_schema = tc.table_schema
|
|
207
|
+
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
208
|
+
AND tc.table_schema = ANY($1::text[])
|
|
209
|
+
`,
|
|
210
|
+
[this.schemas],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// Index PK/FK for fast lookup while assembling ColumnSchema objects
|
|
214
|
+
const pkSet = new Set(
|
|
215
|
+
pkResult.rows.map(
|
|
216
|
+
(r) => `${r.table_schema}.${r.table_name}.${r.column_name}`,
|
|
217
|
+
),
|
|
218
|
+
);
|
|
219
|
+
const fkMap = new Map(
|
|
220
|
+
fkResult.rows.map((r) => [
|
|
221
|
+
`${r.table_schema}.${r.table_name}.${r.column_name}`,
|
|
222
|
+
{ table: r.foreign_table_name, column: r.foreign_column_name },
|
|
223
|
+
]),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Group columns by table
|
|
227
|
+
const columnsByTable = new Map<string, TableSchema["columns"]>();
|
|
228
|
+
for (const col of columnsResult.rows) {
|
|
229
|
+
const key = `${col.table_schema}.${col.table_name}`;
|
|
230
|
+
const list = columnsByTable.get(key) ?? [];
|
|
231
|
+
const full = `${col.table_schema}.${col.table_name}.${col.column_name}`;
|
|
232
|
+
const fk = fkMap.get(full);
|
|
233
|
+
list.push({
|
|
234
|
+
name: col.column_name,
|
|
235
|
+
dataType: mapDataType(col.udt_name),
|
|
236
|
+
nativeType: col.udt_name,
|
|
237
|
+
nullable: col.is_nullable === "YES",
|
|
238
|
+
isPrimaryKey: pkSet.has(full),
|
|
239
|
+
isForeignKey: Boolean(fk),
|
|
240
|
+
...(fk ? { references: fk } : {}),
|
|
241
|
+
});
|
|
242
|
+
columnsByTable.set(key, list);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const tables: TableSchema[] = tablesResult.rows.map((t) => {
|
|
246
|
+
const key = `${t.table_schema}.${t.table_name}`;
|
|
247
|
+
const columns = columnsByTable.get(key) ?? [];
|
|
248
|
+
const primaryKey = columns.filter((c) => c.isPrimaryKey).map((c) => c.name);
|
|
249
|
+
return {
|
|
250
|
+
name: t.table_name,
|
|
251
|
+
schema: t.table_schema,
|
|
252
|
+
columns,
|
|
253
|
+
...(primaryKey.length ? { primaryKey } : {}),
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
dialect: "postgresql",
|
|
259
|
+
inspectedAt: new Date().toISOString(),
|
|
260
|
+
tables,
|
|
261
|
+
};
|
|
262
|
+
} finally {
|
|
263
|
+
// Always return the client to the pool
|
|
264
|
+
client.release();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Executes a (already validated) read-only query.
|
|
270
|
+
* Opens BEGIN READ ONLY, sets tenant/user GUCs, applies statement_timeout,
|
|
271
|
+
* runs the query with bound params, then commits.
|
|
272
|
+
*/
|
|
273
|
+
async query(
|
|
274
|
+
request: QueryRequest,
|
|
275
|
+
context: RequestContext,
|
|
276
|
+
): Promise<QueryResult> {
|
|
277
|
+
if (this.enforceTenant) {
|
|
278
|
+
if (!context.tenant?.tenantId) {
|
|
279
|
+
throw new TenantIsolationError();
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const client = await this.pool.connect();
|
|
284
|
+
const started = Date.now();
|
|
285
|
+
try {
|
|
286
|
+
// READ ONLY transaction: DB rejects writes even if SQL slipped through
|
|
287
|
+
await client.query("BEGIN READ ONLY");
|
|
288
|
+
|
|
289
|
+
if (this.enforceTenant) {
|
|
290
|
+
// Local-to-transaction GUCs for RLS:
|
|
291
|
+
// current_setting('app.tenant_id', true)
|
|
292
|
+
await client.query(`SELECT set_config('app.tenant_id', $1, true)`, [
|
|
293
|
+
context.tenant.tenantId,
|
|
294
|
+
]);
|
|
295
|
+
await client.query(`SELECT set_config('app.user_id', $1, true)`, [
|
|
296
|
+
context.auth.userId,
|
|
297
|
+
]);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (request.timeoutMs) {
|
|
301
|
+
await client.query(`SET LOCAL statement_timeout = $1`, [
|
|
302
|
+
request.timeoutMs,
|
|
303
|
+
]);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Always use bound parameters — never interpolate AI text into SQL
|
|
307
|
+
const result = await client.query(request.sql, request.params ?? []);
|
|
308
|
+
await client.query("COMMIT");
|
|
309
|
+
|
|
310
|
+
// Apply row limit in memory as a second safety net
|
|
311
|
+
const limit = request.limit ?? result.rowCount ?? 0;
|
|
312
|
+
const rows = result.rows.slice(0, limit).map((row) => {
|
|
313
|
+
const record: Record<string, unknown> = {};
|
|
314
|
+
for (const [key, value] of Object.entries(row)) {
|
|
315
|
+
record[key] = value;
|
|
316
|
+
}
|
|
317
|
+
return record;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
return {
|
|
321
|
+
columns: result.fields.map((f) => ({
|
|
322
|
+
name: f.name,
|
|
323
|
+
dataType: "unknown" as const,
|
|
324
|
+
})),
|
|
325
|
+
rows,
|
|
326
|
+
rowCount: rows.length,
|
|
327
|
+
truncated: (result.rowCount ?? 0) > rows.length,
|
|
328
|
+
executionMs: Date.now() - started,
|
|
329
|
+
};
|
|
330
|
+
} catch (error) {
|
|
331
|
+
try {
|
|
332
|
+
await client.query("ROLLBACK");
|
|
333
|
+
} catch {
|
|
334
|
+
// Ignore rollback errors; rethrow the original failure
|
|
335
|
+
}
|
|
336
|
+
throw error;
|
|
337
|
+
} finally {
|
|
338
|
+
client.release();
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Closes the pool only if this adapter created it. */
|
|
343
|
+
async close(): Promise<void> {
|
|
344
|
+
if (this.ownsPool) {
|
|
345
|
+
await this.pool.end();
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Convenience factory: createPostgresAdapter({ connectionString }). */
|
|
351
|
+
export function createPostgresAdapter(
|
|
352
|
+
options: PostgresAdapterOptions,
|
|
353
|
+
): PostgresAdapter {
|
|
354
|
+
return new PostgresAdapter(options);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Re-export for host apps writing RLS policies. */
|
|
358
|
+
export { getTenantColumn };
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Data Agent — @mcp-data-agent/postgres
|
|
3
|
+
* Created by Arslan Habib
|
|
4
|
+
*
|
|
5
|
+
* ---------------------------------------------------------------------------
|
|
6
|
+
* FILE: index.ts
|
|
7
|
+
* PURPOSE:
|
|
8
|
+
* Public entry point for the PostgreSQL adapter package.
|
|
9
|
+
*
|
|
10
|
+
* import { createPostgresAdapter } from "@mcp-data-agent/postgres";
|
|
11
|
+
* ---------------------------------------------------------------------------
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
createPostgresAdapter,
|
|
16
|
+
getTenantColumn,
|
|
17
|
+
PostgresAdapter,
|
|
18
|
+
} from "./adapter.js";
|
|
19
|
+
export type { PostgresAdapterOptions } from "./adapter.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"lib": ["ES2022"],
|
|
7
|
+
"types": ["node"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noImplicitOverride": true,
|
|
10
|
+
"noUncheckedIndexedAccess": true,
|
|
11
|
+
"exactOptionalPropertyTypes": true,
|
|
12
|
+
"forceConsistentCasingInFileNames": true,
|
|
13
|
+
"esModuleInterop": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"declaration": true,
|
|
16
|
+
"declarationMap": true,
|
|
17
|
+
"sourceMap": true,
|
|
18
|
+
"composite": true,
|
|
19
|
+
"rootDir": "src",
|
|
20
|
+
"outDir": "dist"
|
|
21
|
+
}
|
|
22
|
+
}
|
package/tsconfig.json
ADDED
package/vitest.config.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// MCP Data Agent — Created by Arslan Habib
|
|
2
|
+
//
|
|
3
|
+
// FILE: vitest.config.ts
|
|
4
|
+
// PURPOSE: Vitest config for package unit tests (colocated *.test.ts files).
|
|
5
|
+
|
|
6
|
+
import { defineConfig } from "vitest/config";
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
test: {
|
|
10
|
+
// Pick up tests colocated with package source
|
|
11
|
+
include: ["packages/*/src/**/*.test.ts"],
|
|
12
|
+
environment: "node",
|
|
13
|
+
},
|
|
14
|
+
});
|