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.
Files changed (37) hide show
  1. package/.env.example +18 -0
  2. package/.idea/jsLibraryMappings.xml +6 -0
  3. package/.idea/mcp_engine.iml +8 -0
  4. package/.idea/modules.xml +8 -0
  5. package/.idea/php.xml +19 -0
  6. package/.idea/vcs.xml +6 -0
  7. package/README.md +587 -0
  8. package/examples/basic/package.json +26 -0
  9. package/examples/basic/src/index.ts +107 -0
  10. package/examples/basic/tsconfig.json +15 -0
  11. package/package.json +106 -0
  12. package/packages/core/README.md +7 -0
  13. package/packages/core/package.json +38 -0
  14. package/packages/core/src/agent.test.ts +213 -0
  15. package/packages/core/src/agent.ts +373 -0
  16. package/packages/core/src/errors.ts +73 -0
  17. package/packages/core/src/index.ts +78 -0
  18. package/packages/core/src/permissions.ts +71 -0
  19. package/packages/core/src/query-safety.ts +125 -0
  20. package/packages/core/src/schema-safety.ts +118 -0
  21. package/packages/core/src/tenant.ts +58 -0
  22. package/packages/core/src/tool-registry.ts +61 -0
  23. package/packages/core/src/types.ts +250 -0
  24. package/packages/core/tsconfig.json +10 -0
  25. package/packages/mcp/README.md +7 -0
  26. package/packages/mcp/package.json +46 -0
  27. package/packages/mcp/src/index.ts +15 -0
  28. package/packages/mcp/src/server.ts +170 -0
  29. package/packages/mcp/tsconfig.json +11 -0
  30. package/packages/postgres/README.md +7 -0
  31. package/packages/postgres/package.json +46 -0
  32. package/packages/postgres/src/adapter.ts +358 -0
  33. package/packages/postgres/src/index.ts +19 -0
  34. package/packages/postgres/tsconfig.json +11 -0
  35. package/tsconfig.base.json +22 -0
  36. package/tsconfig.json +8 -0
  37. package/vitest.config.ts +14 -0
@@ -0,0 +1,373 @@
1
+ /**
2
+ * MCP Data Agent — @mcp-data-agent/core
3
+ * Created by Arslan Habib
4
+ *
5
+ * ---------------------------------------------------------------------------
6
+ * FILE: agent.ts
7
+ * PURPOSE:
8
+ * The main runtime. createMCPDataAgent() builds an MCPDataAgent that:
9
+ * 1) Resolves auth + tenant from the host app
10
+ * 2) Checks permissions
11
+ * 3) Runs built-in or custom tools
12
+ * 4) Validates SQL / redacts sensitive data
13
+ * 5) Emits audit events
14
+ *
15
+ * PUBLIC API (host apps typically use):
16
+ * createMCPDataAgent(options)
17
+ * agent.registerTool(...)
18
+ * agent.defineEntity(...)
19
+ * agent.protectField(...)
20
+ * agent.invokeTool(name, input)
21
+ *
22
+ * This file is the "brain" of the framework; adapters and MCP are plugs.
23
+ * ---------------------------------------------------------------------------
24
+ */
25
+
26
+ import { AuthenticationError, ConfigurationError, McpDataAgentError } from "./errors.js";
27
+ import { assertPermission, Permissions } from "./permissions.js";
28
+ import {
29
+ ensureLimit,
30
+ validateReadOnlyQuery,
31
+ } from "./query-safety.js";
32
+ import {
33
+ buildProtectedFieldSet,
34
+ markSensitiveColumns,
35
+ redactResultRows,
36
+ redactSchemaForAi,
37
+ } from "./schema-safety.js";
38
+ import { assertTenantContext, rejectTenantOverride } from "./tenant.js";
39
+ import { ToolRegistry } from "./tool-registry.js";
40
+ import type {
41
+ AgentOptions,
42
+ AuditEvent,
43
+ DatabaseSchema,
44
+ EntityDefinition,
45
+ ProtectFieldOptions,
46
+ QueryResult,
47
+ RequestContext,
48
+ ToolDefinition,
49
+ } from "./types.js";
50
+
51
+ /**
52
+ * Central agent instance.
53
+ * Holds the DB adapter, tool registry, entities, and protected-field set.
54
+ */
55
+ export class MCPDataAgent {
56
+ private readonly options: AgentOptions;
57
+ /** Registry of built-in + custom tools. */
58
+ private readonly tools = new ToolRegistry();
59
+ /** Columns that must never reach the AI (schema.table.column keys). */
60
+ private readonly protectedFields: Set<string>;
61
+ /** Business entities defined by the host app. */
62
+ private readonly entities = new Map<string, EntityDefinition>();
63
+ /** Cached sensitive column names used when redacting query rows. */
64
+ private sensitiveColumnNames = new Set<string>();
65
+
66
+ constructor(options: AgentOptions) {
67
+ // --- Validate required wiring from the host application ---
68
+ if (!options.database) {
69
+ throw new ConfigurationError("database adapter is required");
70
+ }
71
+ if (!options.resolveContext) {
72
+ throw new ConfigurationError("resolveContext is required");
73
+ }
74
+
75
+ // Secure defaults: small result sets, short timeouts, no writes
76
+ this.options = {
77
+ defaultLimit: 100,
78
+ defaultTimeoutMs: 10_000,
79
+ allowMutations: false,
80
+ ...options,
81
+ };
82
+ this.protectedFields = buildProtectedFieldSet(options.protectedFields ?? []);
83
+ this.registerBuiltInTools();
84
+ }
85
+
86
+ /** Register a custom domain tool (e.g. members.search). Chainable. */
87
+ registerTool<TInput = unknown, TOutput = unknown>(
88
+ tool: ToolDefinition<TInput, TOutput>,
89
+ ): this {
90
+ if (tool.mutates && !this.options.allowMutations) {
91
+ throw new ConfigurationError(
92
+ "Mutating tools are disabled. Set allowMutations: true to enable explicit write tools.",
93
+ );
94
+ }
95
+ this.tools.register(tool);
96
+ return this;
97
+ }
98
+
99
+ /** Attach business meaning to a table so the AI understands domain terms. */
100
+ defineEntity(entity: EntityDefinition): this {
101
+ if (!entity.name || !entity.table) {
102
+ throw new ConfigurationError("Entity name and table are required");
103
+ }
104
+ this.entities.set(entity.name, entity);
105
+ return this;
106
+ }
107
+
108
+ /** Explicitly mark a column as sensitive (in addition to name heuristics). */
109
+ protectField(field: ProtectFieldOptions): this {
110
+ this.protectedFields.add(
111
+ `${field.schema ?? "public"}.${field.table}.${field.column}`.toLowerCase(),
112
+ );
113
+ return this;
114
+ }
115
+
116
+ /** List all tools (used by MCP bridge to expose custom tools). */
117
+ listTools(): ToolDefinition[] {
118
+ return this.tools.list();
119
+ }
120
+
121
+ /** List defined business entities. */
122
+ listEntities(): EntityDefinition[] {
123
+ return [...this.entities.values()];
124
+ }
125
+
126
+ /**
127
+ * Resolve and validate the current request context.
128
+ * Ensures userId exists and tenantId is valid.
129
+ */
130
+ async getContext(): Promise<RequestContext> {
131
+ const context = await this.options.resolveContext();
132
+ if (!context.auth?.userId) {
133
+ throw new AuthenticationError();
134
+ }
135
+ assertTenantContext(context.tenant);
136
+ return context;
137
+ }
138
+
139
+ /**
140
+ * Invoke a tool by name with optional input.
141
+ * Pipeline: resolve context → permission check → reject tenant override
142
+ * → run handler → audit success/failure.
143
+ */
144
+ async invokeTool<TOutput = unknown>(
145
+ name: string,
146
+ input: unknown = {},
147
+ ): Promise<TOutput> {
148
+ const tool = this.tools.get(name);
149
+ if (!tool) {
150
+ throw new ConfigurationError(`Unknown tool: ${name}`);
151
+ }
152
+
153
+ const context = await this.getContext();
154
+ const started = Date.now();
155
+
156
+ try {
157
+ // RBAC: user must have the tool's declared permission
158
+ assertPermission(context.auth, tool.permission);
159
+
160
+ // Multi-tenant: block AI from passing tenantId in args
161
+ if (input && typeof input === "object" && !Array.isArray(input)) {
162
+ rejectTenantOverride(input as Record<string, unknown>);
163
+ }
164
+
165
+ const result = (await tool.handler(input, context)) as TOutput;
166
+ const resultMeta = extractResultMeta(result, Date.now() - started);
167
+
168
+ // Audit successful invocation (no secrets — params are sanitized)
169
+ await this.audit({
170
+ timestamp: new Date().toISOString(),
171
+ userId: context.auth.userId,
172
+ tenantId: context.tenant.tenantId,
173
+ tool: name,
174
+ parameters: sanitizeParams(input),
175
+ status: "success",
176
+ resultMeta,
177
+ ...(context.requestId !== undefined
178
+ ? { requestId: context.requestId }
179
+ : {}),
180
+ ...(context.question !== undefined
181
+ ? { question: context.question }
182
+ : {}),
183
+ });
184
+ return result;
185
+ } catch (error) {
186
+ // Distinguish permission denial from other failures in the audit log
187
+ const status =
188
+ error instanceof McpDataAgentError && error.code === "AUTHORIZATION_DENIED"
189
+ ? "denied"
190
+ : "error";
191
+ await this.audit({
192
+ timestamp: new Date().toISOString(),
193
+ userId: context.auth.userId,
194
+ tenantId: context.tenant.tenantId,
195
+ tool: name,
196
+ parameters: sanitizeParams(input),
197
+ status,
198
+ errorMessage: error instanceof Error ? error.message : "Unknown error",
199
+ ...(context.requestId !== undefined
200
+ ? { requestId: context.requestId }
201
+ : {}),
202
+ ...(context.question !== undefined
203
+ ? { question: context.question }
204
+ : {}),
205
+ });
206
+ throw error;
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Registers Phase-1 built-in tools:
212
+ * - database.schema → safe schema + entities for the AI
213
+ * - database.query → validated read-only SELECT
214
+ */
215
+ private registerBuiltInTools(): void {
216
+ this.tools.register({
217
+ name: "database.schema",
218
+ description:
219
+ "Return a safe, redacted database schema for the authenticated tenant context.",
220
+ permission: Permissions.DATABASE_SCHEMA,
221
+ handler: async (_input, _context) => this.getSafeSchema(),
222
+ });
223
+
224
+ this.tools.register({
225
+ name: "database.query",
226
+ description:
227
+ "Execute a validated read-only SQL SELECT against the application database with tenant isolation enforced by the host adapter.",
228
+ permission: Permissions.DATABASE_QUERY,
229
+ inputSchema: {
230
+ type: "object",
231
+ properties: {
232
+ sql: { type: "string", description: "Read-only SELECT SQL" },
233
+ params: {
234
+ type: "array",
235
+ description: "Bound query parameters",
236
+ items: {},
237
+ },
238
+ limit: { type: "number", description: "Max rows to return" },
239
+ },
240
+ required: ["sql"],
241
+ },
242
+ handler: async (input, context) => this.executeSafeQuery(input, context),
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Loads schema from the adapter, marks sensitive columns, caches their
248
+ * names for result redaction, and returns an AI-safe schema + entities.
249
+ */
250
+ private async getSafeSchema(): Promise<{
251
+ schema: DatabaseSchema;
252
+ entities: EntityDefinition[];
253
+ }> {
254
+ const raw = await this.options.database.getSchema({ includeSensitive: true });
255
+ const marked = markSensitiveColumns(raw, this.protectedFields);
256
+ this.sensitiveColumnNames = new Set(
257
+ marked.tables.flatMap((t) =>
258
+ t.columns.filter((c) => c.sensitive).map((c) => c.name.toLowerCase()),
259
+ ),
260
+ );
261
+ return {
262
+ schema: redactSchemaForAi(marked),
263
+ entities: this.listEntities(),
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Validates SQL → ensures LIMIT → runs adapter.query → redacts sensitive
269
+ * columns from rows before returning to the caller/LLM.
270
+ */
271
+ private async executeSafeQuery(
272
+ input: unknown,
273
+ context: RequestContext,
274
+ ): Promise<QueryResult> {
275
+ const body = (input ?? {}) as {
276
+ sql?: string;
277
+ params?: unknown[];
278
+ limit?: number;
279
+ timeoutMs?: number;
280
+ };
281
+
282
+ const validated = validateReadOnlyQuery({
283
+ sql: body.sql ?? "",
284
+ ...(body.limit !== undefined ? { limit: body.limit } : {}),
285
+ ...(body.timeoutMs !== undefined ? { timeoutMs: body.timeoutMs } : {}),
286
+ ...(this.options.defaultLimit !== undefined
287
+ ? { defaultLimit: this.options.defaultLimit }
288
+ : {}),
289
+ ...(this.options.defaultTimeoutMs !== undefined
290
+ ? { defaultTimeoutMs: this.options.defaultTimeoutMs }
291
+ : {}),
292
+ });
293
+
294
+ const sql = ensureLimit(validated.sql, validated.limit);
295
+
296
+ const result = await this.options.database.query(
297
+ {
298
+ sql,
299
+ limit: validated.limit,
300
+ timeoutMs: validated.timeoutMs,
301
+ ...(body.params !== undefined ? { params: body.params } : {}),
302
+ },
303
+ context,
304
+ );
305
+
306
+ // If schema was never fetched, load it so we know which columns to strip
307
+ if (this.sensitiveColumnNames.size === 0) {
308
+ await this.getSafeSchema();
309
+ }
310
+
311
+ const rows = redactResultRows(result.rows, this.sensitiveColumnNames);
312
+ return {
313
+ ...result,
314
+ rows,
315
+ rowCount: rows.length,
316
+ };
317
+ }
318
+
319
+ /** Forwards audit events to the host callback when configured. */
320
+ private async audit(event: AuditEvent): Promise<void> {
321
+ if (!this.options.audit) {
322
+ return;
323
+ }
324
+ await this.options.audit(event);
325
+ }
326
+ }
327
+
328
+ /**
329
+ * Factory used by host applications:
330
+ * const agent = createMCPDataAgent({ database, resolveContext })
331
+ */
332
+ export function createMCPDataAgent(options: AgentOptions): MCPDataAgent {
333
+ return new MCPDataAgent(options);
334
+ }
335
+
336
+ /**
337
+ * Redacts obvious secret-looking keys from audit parameter payloads
338
+ * so logs never store passwords/tokens.
339
+ */
340
+ function sanitizeParams(input: unknown): unknown {
341
+ if (!input || typeof input !== "object") {
342
+ return input;
343
+ }
344
+ const clone: Record<string, unknown> = {};
345
+ for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
346
+ if (/password|secret|token|credential|api[_-]?key/i.test(key)) {
347
+ clone[key] = "[redacted]";
348
+ } else {
349
+ clone[key] = value;
350
+ }
351
+ }
352
+ return clone;
353
+ }
354
+
355
+ /** Builds lightweight result metadata for the audit trail (no row data). */
356
+ function extractResultMeta(
357
+ result: unknown,
358
+ executionMs: number,
359
+ ): NonNullable<AuditEvent["resultMeta"]> {
360
+ if (
361
+ result &&
362
+ typeof result === "object" &&
363
+ "rowCount" in result
364
+ ) {
365
+ const r = result as QueryResult;
366
+ return {
367
+ rowCount: r.rowCount,
368
+ ...(r.truncated !== undefined ? { truncated: r.truncated } : {}),
369
+ executionMs: r.executionMs ?? executionMs,
370
+ };
371
+ }
372
+ return { executionMs };
373
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * MCP Data Agent — @mcp-data-agent/core
3
+ * Created by Arslan Habib
4
+ *
5
+ * ---------------------------------------------------------------------------
6
+ * FILE: errors.ts
7
+ * PURPOSE:
8
+ * Typed error classes used across the framework so callers can distinguish
9
+ * auth failures, permission denials, bad SQL, tenant violations, and
10
+ * misconfiguration — instead of catching a generic Error.
11
+ *
12
+ * HOW TO USE:
13
+ * - Catch McpDataAgentError and inspect `error.code`
14
+ * - MCP bridge maps these into structured tool error responses
15
+ * ---------------------------------------------------------------------------
16
+ */
17
+
18
+ /** Base error for all MCP Data Agent failures. Includes a stable `code`. */
19
+ export class McpDataAgentError extends Error {
20
+ readonly code: string;
21
+
22
+ constructor(code: string, message: string) {
23
+ super(message);
24
+ this.name = "McpDataAgentError";
25
+ this.code = code;
26
+ }
27
+ }
28
+
29
+ /** Thrown when resolveContext() returns no authenticated userId. */
30
+ export class AuthenticationError extends McpDataAgentError {
31
+ constructor(message = "Authentication required") {
32
+ super("AUTHENTICATION_REQUIRED", message);
33
+ this.name = "AuthenticationError";
34
+ }
35
+ }
36
+
37
+ /** Thrown when the user lacks the permission required by a tool. */
38
+ export class AuthorizationError extends McpDataAgentError {
39
+ constructor(permission: string, message?: string) {
40
+ super(
41
+ "AUTHORIZATION_DENIED",
42
+ message ?? `Missing required permission: ${permission}`,
43
+ );
44
+ this.name = "AuthorizationError";
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Thrown when tenant context is missing/invalid, or when tool params try
50
+ * to override tenantId (cross-tenant attack prevention).
51
+ */
52
+ export class TenantIsolationError extends McpDataAgentError {
53
+ constructor(message = "Tenant context is missing or invalid") {
54
+ super("TENANT_ISOLATION", message);
55
+ this.name = "TenantIsolationError";
56
+ }
57
+ }
58
+
59
+ /** Thrown when SQL fails read-only / single-statement validation. */
60
+ export class QueryValidationError extends McpDataAgentError {
61
+ constructor(message: string) {
62
+ super("QUERY_VALIDATION", message);
63
+ this.name = "QueryValidationError";
64
+ }
65
+ }
66
+
67
+ /** Thrown for bad agent setup (missing adapter, duplicate tools, etc.). */
68
+ export class ConfigurationError extends McpDataAgentError {
69
+ constructor(message: string) {
70
+ super("CONFIGURATION", message);
71
+ this.name = "ConfigurationError";
72
+ }
73
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * MCP Data Agent — @mcp-data-agent/core
3
+ * Created by Arslan Habib
4
+ *
5
+ * ---------------------------------------------------------------------------
6
+ * FILE: index.ts
7
+ * PURPOSE:
8
+ * Public entry point for @mcp-data-agent/core.
9
+ * Re-exports the agent factory, errors, helpers, and TypeScript types so
10
+ * consumers can import everything from one place:
11
+ *
12
+ * import { createMCPDataAgent, Permissions } from "@mcp-data-agent/core";
13
+ * ---------------------------------------------------------------------------
14
+ */
15
+
16
+ // --- Agent runtime ---
17
+ export { createMCPDataAgent, MCPDataAgent } from "./agent.js";
18
+
19
+ // --- Typed errors ---
20
+ export {
21
+ AuthenticationError,
22
+ AuthorizationError,
23
+ ConfigurationError,
24
+ McpDataAgentError,
25
+ QueryValidationError,
26
+ TenantIsolationError,
27
+ } from "./errors.js";
28
+
29
+ // --- RBAC ---
30
+ export { assertPermission, hasPermission, Permissions } from "./permissions.js";
31
+
32
+ // --- SQL safety ---
33
+ export {
34
+ ensureLimit,
35
+ validateReadOnlyQuery,
36
+ } from "./query-safety.js";
37
+
38
+ // --- Sensitive-field / schema redaction ---
39
+ export {
40
+ buildProtectedFieldSet,
41
+ isLikelySensitiveColumn,
42
+ markSensitiveColumns,
43
+ redactResultRows,
44
+ redactSchemaForAi,
45
+ } from "./schema-safety.js";
46
+
47
+ // --- Multi-tenancy ---
48
+ export {
49
+ assertTenantContext,
50
+ DEFAULT_TENANT_COLUMN,
51
+ getTenantColumn,
52
+ rejectTenantOverride,
53
+ } from "./tenant.js";
54
+
55
+ // --- Tool registry (advanced / custom integrations) ---
56
+ export { ToolRegistry } from "./tool-registry.js";
57
+
58
+ // --- Shared types ---
59
+ export type {
60
+ AgentOptions,
61
+ AuditEvent,
62
+ AuditLogger,
63
+ AuthContext,
64
+ ColumnDataType,
65
+ ColumnSchema,
66
+ DatabaseAdapter,
67
+ DatabaseSchema,
68
+ EntityDefinition,
69
+ Permission,
70
+ ProtectFieldOptions,
71
+ QueryRequest,
72
+ QueryResult,
73
+ QueryResultColumn,
74
+ RequestContext,
75
+ TableSchema,
76
+ TenantContext,
77
+ ToolDefinition,
78
+ } from "./types.js";
@@ -0,0 +1,71 @@
1
+ /**
2
+ * MCP Data Agent — @mcp-data-agent/core
3
+ * Created by Arslan Habib
4
+ *
5
+ * ---------------------------------------------------------------------------
6
+ * FILE: permissions.ts
7
+ * PURPOSE:
8
+ * Role-based access control (RBAC) helpers. Every tool declares a required
9
+ * permission; these functions check the authenticated user's permission
10
+ * list before the tool handler runs.
11
+ *
12
+ * FLOW:
13
+ * invokeTool() → assertPermission(auth, tool.permission) → handler
14
+ *
15
+ * NOTE:
16
+ * Permission "*" grants all tools (useful for admin/dev only — avoid in
17
+ * production unless intentional).
18
+ * ---------------------------------------------------------------------------
19
+ */
20
+
21
+ import { AuthorizationError } from "./errors.js";
22
+ import type { AuthContext, Permission } from "./types.js";
23
+
24
+ /**
25
+ * Well-known permission strings for built-in tools and Phase-2 report/chart
26
+ * contexts. Host apps can define their own (e.g. "members.read").
27
+ */
28
+ export const Permissions = {
29
+ DATABASE_SCHEMA: "database.schema",
30
+ DATABASE_QUERY: "database.query",
31
+ REPORTS_READ: "reports.read",
32
+ CHARTS_READ: "charts.read",
33
+ } as const;
34
+
35
+ /**
36
+ * Returns true if the user has the given permission (or wildcard "*").
37
+ * Returns false if there is no userId.
38
+ */
39
+ export function hasPermission(
40
+ auth: AuthContext,
41
+ permission: Permission,
42
+ ): boolean {
43
+ if (!auth.userId) {
44
+ return false;
45
+ }
46
+ return auth.permissions.includes(permission) || auth.permissions.includes("*");
47
+ }
48
+
49
+ /**
50
+ * Throws AuthorizationError when the user is missing the required permission.
51
+ * Used by MCPDataAgent.invokeTool before running a tool handler.
52
+ */
53
+ export function assertPermission(
54
+ auth: AuthContext,
55
+ permission: Permission,
56
+ ): void {
57
+ if (!hasPermission(auth, permission)) {
58
+ throw new AuthorizationError(permission);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Returns the subset of `required` permissions that the user actually has.
64
+ * Useful for UIs that show which capabilities are available.
65
+ */
66
+ export function filterPermissions(
67
+ auth: AuthContext,
68
+ required: Permission[],
69
+ ): Permission[] {
70
+ return required.filter((p) => hasPermission(auth, p));
71
+ }