@vobase/core 0.9.0 → 0.11.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/package.json +9 -10
- package/src/__tests__/drizzle-introspection.test.ts +77 -0
- package/src/__tests__/e2e.test.ts +225 -0
- package/src/__tests__/permissions.test.ts +157 -0
- package/src/__tests__/rpc-types.test.ts +92 -0
- package/src/app.test.ts +99 -0
- package/src/app.ts +178 -0
- package/src/audit.test.ts +126 -0
- package/src/auth.test.ts +74 -0
- package/src/contracts/auth.ts +37 -0
- package/{dist/contracts/module.d.ts → src/contracts/module.ts} +6 -6
- package/src/contracts/notify.ts +47 -0
- package/src/contracts/permissions.ts +10 -0
- package/src/contracts/storage.ts +61 -0
- package/src/ctx.test.ts +162 -0
- package/src/ctx.ts +64 -0
- package/src/db/client.test.ts +75 -0
- package/src/db/client.ts +15 -0
- package/src/db/helpers.test.ts +147 -0
- package/src/db/helpers.ts +51 -0
- package/src/db/index.ts +8 -0
- package/{dist/index.d.ts → src/index.ts} +105 -6
- package/src/infra/circuit-breaker.test.ts +74 -0
- package/src/infra/circuit-breaker.ts +57 -0
- package/src/infra/errors.test.ts +175 -0
- package/src/infra/errors.ts +64 -0
- package/src/infra/http-client.test.ts +482 -0
- package/src/infra/http-client.ts +221 -0
- package/src/infra/index.ts +35 -0
- package/src/infra/job.test.ts +85 -0
- package/src/infra/job.ts +94 -0
- package/src/infra/logger.test.ts +65 -0
- package/src/infra/logger.ts +18 -0
- package/src/infra/queue.test.ts +46 -0
- package/src/infra/queue.ts +147 -0
- package/src/infra/throw-proxy.test.ts +34 -0
- package/src/infra/throw-proxy.ts +17 -0
- package/src/infra/webhooks-schema.ts +17 -0
- package/src/infra/webhooks.test.ts +364 -0
- package/src/infra/webhooks.ts +146 -0
- package/src/mcp/auth.test.ts +129 -0
- package/src/mcp/crud.test.ts +128 -0
- package/src/mcp/crud.ts +171 -0
- package/{dist/mcp/index.d.ts → src/mcp/index.ts} +0 -1
- package/src/mcp/server.test.ts +153 -0
- package/src/mcp/server.ts +178 -0
- package/src/middleware/audit.test.ts +169 -0
- package/src/module-registry.ts +18 -0
- package/src/module.test.ts +168 -0
- package/src/module.ts +111 -0
- package/src/modules/audit/index.ts +18 -0
- package/src/modules/audit/middleware.ts +33 -0
- package/src/modules/audit/schema.ts +35 -0
- package/src/modules/audit/track-changes.ts +70 -0
- package/src/modules/auth/audit-hooks.ts +74 -0
- package/src/modules/auth/index.ts +101 -0
- package/src/modules/auth/middleware.ts +51 -0
- package/src/modules/auth/permissions.ts +46 -0
- package/src/modules/auth/schema.ts +184 -0
- package/src/modules/credentials/encrypt.ts +95 -0
- package/src/modules/credentials/index.ts +15 -0
- package/src/modules/credentials/schema.ts +10 -0
- package/src/modules/notify/index.ts +90 -0
- package/src/modules/notify/notify.test.ts +145 -0
- package/src/modules/notify/providers/resend.ts +47 -0
- package/src/modules/notify/providers/smtp.ts +117 -0
- package/src/modules/notify/providers/waba.ts +82 -0
- package/src/modules/notify/schema.ts +27 -0
- package/src/modules/notify/service.ts +93 -0
- package/src/modules/sequences/index.ts +15 -0
- package/src/modules/sequences/next-sequence.ts +48 -0
- package/src/modules/sequences/schema.ts +12 -0
- package/src/modules/storage/index.ts +44 -0
- package/src/modules/storage/providers/local.ts +124 -0
- package/src/modules/storage/providers/s3.ts +83 -0
- package/src/modules/storage/routes.ts +76 -0
- package/src/modules/storage/schema.ts +26 -0
- package/src/modules/storage/service.ts +202 -0
- package/src/modules/storage/storage.test.ts +225 -0
- package/src/schemas.test.ts +44 -0
- package/src/schemas.ts +63 -0
- package/src/sequence.test.ts +56 -0
- package/dist/app.d.ts +0 -37
- package/dist/app.d.ts.map +0 -1
- package/dist/contracts/auth.d.ts +0 -35
- package/dist/contracts/auth.d.ts.map +0 -1
- package/dist/contracts/module.d.ts.map +0 -1
- package/dist/contracts/notify.d.ts +0 -46
- package/dist/contracts/notify.d.ts.map +0 -1
- package/dist/contracts/permissions.d.ts +0 -10
- package/dist/contracts/permissions.d.ts.map +0 -1
- package/dist/contracts/storage.d.ts +0 -54
- package/dist/contracts/storage.d.ts.map +0 -1
- package/dist/ctx.d.ts +0 -40
- package/dist/ctx.d.ts.map +0 -1
- package/dist/db/client.d.ts +0 -4
- package/dist/db/client.d.ts.map +0 -1
- package/dist/db/helpers.d.ts +0 -26
- package/dist/db/helpers.d.ts.map +0 -1
- package/dist/db/index.d.ts +0 -3
- package/dist/db/index.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js +0 -89026
- package/dist/infra/circuit-breaker.d.ts +0 -17
- package/dist/infra/circuit-breaker.d.ts.map +0 -1
- package/dist/infra/errors.d.ts +0 -26
- package/dist/infra/errors.d.ts.map +0 -1
- package/dist/infra/http-client.d.ts +0 -31
- package/dist/infra/http-client.d.ts.map +0 -1
- package/dist/infra/index.d.ts +0 -11
- package/dist/infra/index.d.ts.map +0 -1
- package/dist/infra/job.d.ts +0 -14
- package/dist/infra/job.d.ts.map +0 -1
- package/dist/infra/logger.d.ts +0 -7
- package/dist/infra/logger.d.ts.map +0 -1
- package/dist/infra/queue.d.ts +0 -18
- package/dist/infra/queue.d.ts.map +0 -1
- package/dist/infra/throw-proxy.d.ts +0 -7
- package/dist/infra/throw-proxy.d.ts.map +0 -1
- package/dist/infra/webhooks-schema.d.ts +0 -60
- package/dist/infra/webhooks-schema.d.ts.map +0 -1
- package/dist/infra/webhooks.d.ts +0 -46
- package/dist/infra/webhooks.d.ts.map +0 -1
- package/dist/mcp/crud.d.ts +0 -12
- package/dist/mcp/crud.d.ts.map +0 -1
- package/dist/mcp/index.d.ts.map +0 -1
- package/dist/mcp/server.d.ts +0 -10
- package/dist/mcp/server.d.ts.map +0 -1
- package/dist/module-registry.d.ts +0 -3
- package/dist/module-registry.d.ts.map +0 -1
- package/dist/module.d.ts +0 -29
- package/dist/module.d.ts.map +0 -1
- package/dist/modules/audit/index.d.ts +0 -5
- package/dist/modules/audit/index.d.ts.map +0 -1
- package/dist/modules/audit/middleware.d.ts +0 -3
- package/dist/modules/audit/middleware.d.ts.map +0 -1
- package/dist/modules/audit/schema.d.ts +0 -247
- package/dist/modules/audit/schema.d.ts.map +0 -1
- package/dist/modules/audit/track-changes.d.ts +0 -3
- package/dist/modules/audit/track-changes.d.ts.map +0 -1
- package/dist/modules/auth/audit-hooks.d.ts +0 -6
- package/dist/modules/auth/audit-hooks.d.ts.map +0 -1
- package/dist/modules/auth/index.d.ts +0 -17
- package/dist/modules/auth/index.d.ts.map +0 -1
- package/dist/modules/auth/middleware.d.ts +0 -15
- package/dist/modules/auth/middleware.d.ts.map +0 -1
- package/dist/modules/auth/permissions.d.ts +0 -5
- package/dist/modules/auth/permissions.d.ts.map +0 -1
- package/dist/modules/auth/schema.d.ts +0 -2519
- package/dist/modules/auth/schema.d.ts.map +0 -1
- package/dist/modules/credentials/encrypt.d.ts +0 -12
- package/dist/modules/credentials/encrypt.d.ts.map +0 -1
- package/dist/modules/credentials/index.d.ts +0 -4
- package/dist/modules/credentials/index.d.ts.map +0 -1
- package/dist/modules/credentials/schema.d.ts +0 -56
- package/dist/modules/credentials/schema.d.ts.map +0 -1
- package/dist/modules/notify/index.d.ts +0 -36
- package/dist/modules/notify/index.d.ts.map +0 -1
- package/dist/modules/notify/providers/resend.d.ts +0 -7
- package/dist/modules/notify/providers/resend.d.ts.map +0 -1
- package/dist/modules/notify/providers/smtp.d.ts +0 -18
- package/dist/modules/notify/providers/smtp.d.ts.map +0 -1
- package/dist/modules/notify/providers/waba.d.ts +0 -12
- package/dist/modules/notify/providers/waba.d.ts.map +0 -1
- package/dist/modules/notify/schema.d.ts +0 -337
- package/dist/modules/notify/schema.d.ts.map +0 -1
- package/dist/modules/notify/service.d.ts +0 -22
- package/dist/modules/notify/service.d.ts.map +0 -1
- package/dist/modules/sequences/index.d.ts +0 -4
- package/dist/modules/sequences/index.d.ts.map +0 -1
- package/dist/modules/sequences/next-sequence.d.ts +0 -8
- package/dist/modules/sequences/next-sequence.d.ts.map +0 -1
- package/dist/modules/sequences/schema.d.ts +0 -72
- package/dist/modules/sequences/schema.d.ts.map +0 -1
- package/dist/modules/storage/index.d.ts +0 -24
- package/dist/modules/storage/index.d.ts.map +0 -1
- package/dist/modules/storage/providers/local.d.ts +0 -3
- package/dist/modules/storage/providers/local.d.ts.map +0 -1
- package/dist/modules/storage/providers/s3.d.ts +0 -3
- package/dist/modules/storage/providers/s3.d.ts.map +0 -1
- package/dist/modules/storage/routes.d.ts +0 -4
- package/dist/modules/storage/routes.d.ts.map +0 -1
- package/dist/modules/storage/schema.d.ts +0 -273
- package/dist/modules/storage/schema.d.ts.map +0 -1
- package/dist/modules/storage/service.d.ts +0 -35
- package/dist/modules/storage/service.d.ts.map +0 -1
- package/dist/schemas.d.ts +0 -19
- package/dist/schemas.d.ts.map +0 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
|
|
5
|
+
import { createDatabase } from '../db/client';
|
|
6
|
+
import { auditLog } from '../modules/audit/schema';
|
|
7
|
+
import { registerCrudTools } from './crud';
|
|
8
|
+
import type { VobaseModule } from '../module';
|
|
9
|
+
|
|
10
|
+
function createTestDb() {
|
|
11
|
+
const db = createDatabase(':memory:');
|
|
12
|
+
db.$client.run(`
|
|
13
|
+
CREATE TABLE IF NOT EXISTS _audit_log (
|
|
14
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
15
|
+
event TEXT NOT NULL,
|
|
16
|
+
actor_id TEXT,
|
|
17
|
+
actor_email TEXT,
|
|
18
|
+
ip TEXT,
|
|
19
|
+
details TEXT,
|
|
20
|
+
created_at INTEGER NOT NULL
|
|
21
|
+
)
|
|
22
|
+
`);
|
|
23
|
+
return db;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('registerCrudTools', () => {
|
|
27
|
+
it('registers 5 CRUD tools per real Drizzle table', () => {
|
|
28
|
+
const server = new McpServer({ name: 'test', version: '0.1.0' });
|
|
29
|
+
const db = createTestDb();
|
|
30
|
+
|
|
31
|
+
const modules: VobaseModule[] = [
|
|
32
|
+
{
|
|
33
|
+
name: 'audit',
|
|
34
|
+
schema: { auditLog },
|
|
35
|
+
routes: new Hono(),
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
registerCrudTools(server, modules, {
|
|
40
|
+
db,
|
|
41
|
+
user: { id: 'u1', email: 'a@b.com', name: 'Test', role: 'admin' },
|
|
42
|
+
organizationEnabled: false,
|
|
43
|
+
}, new Map());
|
|
44
|
+
|
|
45
|
+
// Access the registered tools via the server's internal state
|
|
46
|
+
const tools = (server as any)._registeredTools;
|
|
47
|
+
const toolNames = Object.keys(tools);
|
|
48
|
+
|
|
49
|
+
expect(toolNames).toContain('list_audit_log');
|
|
50
|
+
expect(toolNames).toContain('get_audit_log');
|
|
51
|
+
expect(toolNames).toContain('create_audit_log');
|
|
52
|
+
expect(toolNames).toContain('update_audit_log');
|
|
53
|
+
expect(toolNames).toContain('delete_audit_log');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('skips non-Drizzle schema entries gracefully', () => {
|
|
57
|
+
const server = new McpServer({ name: 'test', version: '0.1.0' });
|
|
58
|
+
const db = createTestDb();
|
|
59
|
+
|
|
60
|
+
const modules: VobaseModule[] = [
|
|
61
|
+
{
|
|
62
|
+
name: 'mock',
|
|
63
|
+
schema: { fakeThing: {} },
|
|
64
|
+
routes: new Hono(),
|
|
65
|
+
},
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// Should not throw
|
|
69
|
+
registerCrudTools(server, modules, {
|
|
70
|
+
db,
|
|
71
|
+
user: null,
|
|
72
|
+
organizationEnabled: false,
|
|
73
|
+
}, new Map());
|
|
74
|
+
|
|
75
|
+
const tools = (server as any)._registeredTools;
|
|
76
|
+
const toolNames = Object.keys(tools);
|
|
77
|
+
// No CRUD tools registered for non-Drizzle schema
|
|
78
|
+
expect(toolNames.filter((n: string) => n.includes('fakeThing'))).toEqual([]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('respects exclude map', () => {
|
|
82
|
+
const server = new McpServer({ name: 'test', version: '0.1.0' });
|
|
83
|
+
const db = createTestDb();
|
|
84
|
+
|
|
85
|
+
const modules: VobaseModule[] = [
|
|
86
|
+
{
|
|
87
|
+
name: 'audit',
|
|
88
|
+
schema: { auditLog },
|
|
89
|
+
routes: new Hono(),
|
|
90
|
+
},
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
const excludeMap = new Map([['audit', new Set(['auditLog'])]]);
|
|
94
|
+
|
|
95
|
+
registerCrudTools(server, modules, {
|
|
96
|
+
db,
|
|
97
|
+
user: { id: 'u1', email: 'a@b.com', name: 'Test', role: 'admin' },
|
|
98
|
+
organizationEnabled: false,
|
|
99
|
+
}, excludeMap);
|
|
100
|
+
|
|
101
|
+
const tools = (server as any)._registeredTools;
|
|
102
|
+
const toolNames = Object.keys(tools);
|
|
103
|
+
expect(toolNames.filter((n: string) => n.includes('audit_log'))).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('write tools check admin role when org is disabled', () => {
|
|
107
|
+
const server = new McpServer({ name: 'test', version: '0.1.0' });
|
|
108
|
+
const db = createTestDb();
|
|
109
|
+
|
|
110
|
+
const modules: VobaseModule[] = [
|
|
111
|
+
{
|
|
112
|
+
name: 'audit',
|
|
113
|
+
schema: { auditLog },
|
|
114
|
+
routes: new Hono(),
|
|
115
|
+
},
|
|
116
|
+
];
|
|
117
|
+
|
|
118
|
+
// User with non-admin role
|
|
119
|
+
registerCrudTools(server, modules, {
|
|
120
|
+
db,
|
|
121
|
+
user: { id: 'u1', email: 'a@b.com', name: 'Test', role: 'user' },
|
|
122
|
+
organizationEnabled: false,
|
|
123
|
+
}, new Map());
|
|
124
|
+
|
|
125
|
+
const tools = (server as any)._registeredTools;
|
|
126
|
+
expect(tools.create_audit_log).toBeDefined();
|
|
127
|
+
});
|
|
128
|
+
});
|
package/src/mcp/crud.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { eq, getTableColumns, getTableName } from 'drizzle-orm';
|
|
2
|
+
import type { SQLiteTable } from 'drizzle-orm/sqlite-core';
|
|
3
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
|
|
6
|
+
import type { AuthUser } from '../contracts/auth';
|
|
7
|
+
import type { VobaseDb } from '../db';
|
|
8
|
+
import type { VobaseModule } from '../module';
|
|
9
|
+
|
|
10
|
+
interface CrudContext {
|
|
11
|
+
db: VobaseDb;
|
|
12
|
+
user: AuthUser | null;
|
|
13
|
+
organizationEnabled: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function checkWritePermission(ctx: CrudContext): string | null {
|
|
17
|
+
if (!ctx.user) return 'Authentication required';
|
|
18
|
+
if (ctx.organizationEnabled) {
|
|
19
|
+
// When org is enabled, any authenticated user can write
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
// Without org, require admin role for writes
|
|
23
|
+
if (ctx.user.role !== 'admin') return 'Forbidden: admin role required for write operations';
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function errorResult(message: string) {
|
|
28
|
+
return { content: [{ type: 'text' as const, text: JSON.stringify({ error: message }) }], isError: true as const };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function jsonResult(data: Record<string, unknown>) {
|
|
32
|
+
return {
|
|
33
|
+
content: [{ type: 'text' as const, text: JSON.stringify(data) }],
|
|
34
|
+
structuredContent: data,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function registerCrudTools(
|
|
39
|
+
server: McpServer,
|
|
40
|
+
modules: VobaseModule[],
|
|
41
|
+
ctx: CrudContext,
|
|
42
|
+
excludeMap: Map<string, Set<string>>,
|
|
43
|
+
) {
|
|
44
|
+
for (const mod of modules) {
|
|
45
|
+
if (!mod.schema || Object.keys(mod.schema).length === 0) continue;
|
|
46
|
+
|
|
47
|
+
const moduleExcludes = excludeMap.get(mod.name) ?? new Set();
|
|
48
|
+
|
|
49
|
+
for (const [schemaKey, tableObj] of Object.entries(mod.schema)) {
|
|
50
|
+
if (moduleExcludes.has(schemaKey)) continue;
|
|
51
|
+
|
|
52
|
+
// Verify it's a Drizzle table
|
|
53
|
+
let tableName: string;
|
|
54
|
+
try {
|
|
55
|
+
tableName = getTableName(tableObj as any);
|
|
56
|
+
} catch {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const table = tableObj as SQLiteTable;
|
|
61
|
+
let columns: ReturnType<typeof getTableColumns>;
|
|
62
|
+
try {
|
|
63
|
+
columns = getTableColumns(table);
|
|
64
|
+
} catch {
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (!columns) continue;
|
|
68
|
+
|
|
69
|
+
// Find primary key column
|
|
70
|
+
const pkEntry = Object.entries(columns).find(([, col]) => (col as any).primary);
|
|
71
|
+
if (!pkEntry) continue;
|
|
72
|
+
const [pkKey] = pkEntry;
|
|
73
|
+
const pkCol = columns[pkKey]!;
|
|
74
|
+
const pkZod = (pkCol as any).dataType === 'number' ? z.number() : z.string();
|
|
75
|
+
|
|
76
|
+
// Clean name for tools (strip _ prefix from built-in tables)
|
|
77
|
+
const cleanName = tableName.replace(/^_/, '');
|
|
78
|
+
|
|
79
|
+
// LIST
|
|
80
|
+
server.registerTool(
|
|
81
|
+
`list_${cleanName}`,
|
|
82
|
+
{
|
|
83
|
+
description: `List rows from ${tableName} table.`,
|
|
84
|
+
inputSchema: z.object({
|
|
85
|
+
limit: z.number().int().positive().max(100).optional(),
|
|
86
|
+
offset: z.number().int().nonnegative().optional(),
|
|
87
|
+
}),
|
|
88
|
+
annotations: { readOnlyHint: true },
|
|
89
|
+
},
|
|
90
|
+
async ({ limit, offset }) => {
|
|
91
|
+
const rows = ctx.db.select().from(table).limit(limit ?? 50).offset(offset ?? 0).all();
|
|
92
|
+
return jsonResult({ rows, count: rows.length });
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// GET
|
|
97
|
+
server.registerTool(
|
|
98
|
+
`get_${cleanName}`,
|
|
99
|
+
{
|
|
100
|
+
description: `Get a single row from ${tableName} by ID.`,
|
|
101
|
+
inputSchema: z.object({ id: pkZod }),
|
|
102
|
+
annotations: { readOnlyHint: true },
|
|
103
|
+
},
|
|
104
|
+
async ({ id }) => {
|
|
105
|
+
const row = ctx.db.select().from(table).where(eq(pkCol, id)).get();
|
|
106
|
+
if (!row) return errorResult('Not found');
|
|
107
|
+
return jsonResult(row as Record<string, unknown>);
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
// CREATE
|
|
112
|
+
server.registerTool(
|
|
113
|
+
`create_${cleanName}`,
|
|
114
|
+
{
|
|
115
|
+
description: `Insert a new row into ${tableName}.`,
|
|
116
|
+
inputSchema: z.object({ data: z.record(z.string(), z.unknown()) }),
|
|
117
|
+
},
|
|
118
|
+
async ({ data }) => {
|
|
119
|
+
const permError = checkWritePermission(ctx);
|
|
120
|
+
if (permError) return errorResult(permError);
|
|
121
|
+
try {
|
|
122
|
+
const result = ctx.db.insert(table).values(data as any).returning().get();
|
|
123
|
+
return jsonResult(result as Record<string, unknown>);
|
|
124
|
+
} catch (e: any) {
|
|
125
|
+
return errorResult(e.message);
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// UPDATE
|
|
131
|
+
server.registerTool(
|
|
132
|
+
`update_${cleanName}`,
|
|
133
|
+
{
|
|
134
|
+
description: `Update a row in ${tableName} by ID.`,
|
|
135
|
+
inputSchema: z.object({ id: pkZod, data: z.record(z.string(), z.unknown()) }),
|
|
136
|
+
},
|
|
137
|
+
async ({ id, data }) => {
|
|
138
|
+
const permError = checkWritePermission(ctx);
|
|
139
|
+
if (permError) return errorResult(permError);
|
|
140
|
+
try {
|
|
141
|
+
const result = ctx.db.update(table).set(data as any).where(eq(pkCol, id)).returning().get();
|
|
142
|
+
if (!result) return errorResult('Not found');
|
|
143
|
+
return jsonResult(result as Record<string, unknown>);
|
|
144
|
+
} catch (e: any) {
|
|
145
|
+
return errorResult(e.message);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// DELETE
|
|
151
|
+
server.registerTool(
|
|
152
|
+
`delete_${cleanName}`,
|
|
153
|
+
{
|
|
154
|
+
description: `Delete a row from ${tableName} by ID.`,
|
|
155
|
+
inputSchema: z.object({ id: pkZod }),
|
|
156
|
+
},
|
|
157
|
+
async ({ id }) => {
|
|
158
|
+
const permError = checkWritePermission(ctx);
|
|
159
|
+
if (permError) return errorResult(permError);
|
|
160
|
+
try {
|
|
161
|
+
const result = ctx.db.delete(table).where(eq(pkCol, id)).returning().get();
|
|
162
|
+
if (!result) return errorResult('Not found');
|
|
163
|
+
return jsonResult({ deleted: true });
|
|
164
|
+
} catch (e: any) {
|
|
165
|
+
return errorResult(e.message);
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
|
|
4
|
+
import { createDatabase } from '../db/client';
|
|
5
|
+
import { createMcpHandler } from './server';
|
|
6
|
+
import type { VobaseModule } from '../module';
|
|
7
|
+
|
|
8
|
+
const MODULES: VobaseModule[] = [
|
|
9
|
+
{
|
|
10
|
+
name: 'billing',
|
|
11
|
+
schema: { customers: {}, invoices: {} },
|
|
12
|
+
routes: new Hono(),
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: 'catalog',
|
|
16
|
+
schema: { invoices: {}, products: {} },
|
|
17
|
+
routes: new Hono(),
|
|
18
|
+
},
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
function createTestDb() {
|
|
22
|
+
const db = createDatabase(':memory:');
|
|
23
|
+
db.$client.run(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS _audit_log (
|
|
25
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
26
|
+
event TEXT NOT NULL,
|
|
27
|
+
actor_id TEXT,
|
|
28
|
+
actor_email TEXT,
|
|
29
|
+
ip TEXT,
|
|
30
|
+
details TEXT,
|
|
31
|
+
created_at INTEGER NOT NULL
|
|
32
|
+
)
|
|
33
|
+
`);
|
|
34
|
+
return db;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function postMcp(
|
|
38
|
+
handler: (req: Request) => Promise<Response>,
|
|
39
|
+
payload: Record<string, unknown>,
|
|
40
|
+
): Promise<{ response: Response; body: Record<string, unknown> }> {
|
|
41
|
+
const request = new Request('http://localhost/mcp', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
headers: {
|
|
44
|
+
'content-type': 'application/json',
|
|
45
|
+
accept: 'application/json, text/event-stream',
|
|
46
|
+
},
|
|
47
|
+
body: JSON.stringify(payload),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const response = await handler(request);
|
|
51
|
+
const body = (await response.json()) as Record<string, unknown>;
|
|
52
|
+
return { response, body };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseStructuredToolResult(
|
|
56
|
+
body: Record<string, unknown>,
|
|
57
|
+
): Record<string, unknown> {
|
|
58
|
+
const result = body.result as {
|
|
59
|
+
structuredContent?: Record<string, unknown>;
|
|
60
|
+
content?: Array<{ type: string; text?: string }>;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
if (result.structuredContent) {
|
|
64
|
+
return result.structuredContent;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const text = result.content?.find((item) => item.type === 'text')?.text;
|
|
68
|
+
return text ? (JSON.parse(text) as Record<string, unknown>) : {};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('createMcpHandler()', () => {
|
|
72
|
+
it('creates a request handler function', () => {
|
|
73
|
+
const handler = createMcpHandler({ db: createTestDb(), modules: MODULES });
|
|
74
|
+
expect(typeof handler).toBe('function');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns all four read-only tools for tools/list', async () => {
|
|
78
|
+
const handler = createMcpHandler({ db: createTestDb(), modules: MODULES });
|
|
79
|
+
|
|
80
|
+
const { response, body } = await postMcp(handler, {
|
|
81
|
+
jsonrpc: '2.0',
|
|
82
|
+
id: 1,
|
|
83
|
+
method: 'tools/list',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const tools = (
|
|
87
|
+
(body.result as { tools?: Array<{ name: string }> }).tools ?? []
|
|
88
|
+
).map((tool) => tool.name);
|
|
89
|
+
|
|
90
|
+
expect(response.status).toBe(200);
|
|
91
|
+
expect(tools).toEqual([
|
|
92
|
+
'list_modules',
|
|
93
|
+
'read_module',
|
|
94
|
+
'get_schema',
|
|
95
|
+
'view_logs',
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('list_modules returns registered module names', async () => {
|
|
100
|
+
const handler = createMcpHandler({ db: createTestDb(), modules: MODULES });
|
|
101
|
+
|
|
102
|
+
const { response, body } = await postMcp(handler, {
|
|
103
|
+
jsonrpc: '2.0',
|
|
104
|
+
id: 2,
|
|
105
|
+
method: 'tools/call',
|
|
106
|
+
params: {
|
|
107
|
+
name: 'list_modules',
|
|
108
|
+
arguments: {},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(response.status).toBe(200);
|
|
113
|
+
expect(parseStructuredToolResult(body)).toEqual({
|
|
114
|
+
modules: [{ name: 'billing' }, { name: 'catalog' }],
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('get_schema returns table names across modules', async () => {
|
|
119
|
+
const handler = createMcpHandler({ db: createTestDb(), modules: MODULES });
|
|
120
|
+
|
|
121
|
+
const { response, body } = await postMcp(handler, {
|
|
122
|
+
jsonrpc: '2.0',
|
|
123
|
+
id: 3,
|
|
124
|
+
method: 'tools/call',
|
|
125
|
+
params: {
|
|
126
|
+
name: 'get_schema',
|
|
127
|
+
arguments: {},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(response.status).toBe(200);
|
|
132
|
+
expect(parseStructuredToolResult(body)).toEqual({
|
|
133
|
+
tables: ['customers', 'invoices', 'products'],
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('does not expose write tools', async () => {
|
|
138
|
+
const handler = createMcpHandler({ db: createTestDb(), modules: MODULES });
|
|
139
|
+
|
|
140
|
+
const { body } = await postMcp(handler, {
|
|
141
|
+
jsonrpc: '2.0',
|
|
142
|
+
id: 4,
|
|
143
|
+
method: 'tools/list',
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const toolNames = (
|
|
147
|
+
(body.result as { tools?: Array<{ name: string }> }).tools ?? []
|
|
148
|
+
).map((tool) => tool.name);
|
|
149
|
+
|
|
150
|
+
expect(toolNames).not.toContain('deploy_module');
|
|
151
|
+
expect(toolNames).not.toContain('install_package');
|
|
152
|
+
});
|
|
153
|
+
});
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { desc } from 'drizzle-orm';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
|
|
5
|
+
import type { AuthUser } from '../contracts/auth';
|
|
6
|
+
import type { VobaseDb } from '../db';
|
|
7
|
+
import { auditLog } from '../modules/audit/schema';
|
|
8
|
+
import type { VobaseModule } from '../module';
|
|
9
|
+
import { registerCrudTools } from './crud';
|
|
10
|
+
|
|
11
|
+
const DEFAULT_LOG_LIMIT = 50;
|
|
12
|
+
const MAX_LOG_LIMIT = 100;
|
|
13
|
+
|
|
14
|
+
export interface McpDeps {
|
|
15
|
+
db: VobaseDb;
|
|
16
|
+
modules: VobaseModule[];
|
|
17
|
+
/** Validate an API key. Returns user ID if valid, null if invalid. */
|
|
18
|
+
verifyApiKey?: (key: string) => Promise<{ userId: string } | null>;
|
|
19
|
+
/** Whether the organization plugin is enabled (affects CRUD authorization). */
|
|
20
|
+
organizationEnabled?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function toToolResult(payload: Record<string, unknown>) {
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: 'text' as const, text: JSON.stringify(payload) }],
|
|
26
|
+
structuredContent: payload,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getSortedTableNames(schema: Record<string, unknown>): string[] {
|
|
31
|
+
return Object.keys(schema).sort((left, right) => left.localeCompare(right));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getSchemaTableNames(modules: VobaseModule[]): string[] {
|
|
35
|
+
return Array.from(
|
|
36
|
+
new Set(modules.flatMap((module) => getSortedTableNames(module.schema))),
|
|
37
|
+
).sort((left, right) => left.localeCompare(right));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function createMcpServer(deps: McpDeps): Promise<McpServer> {
|
|
41
|
+
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
|
|
42
|
+
const server = new McpServer({ name: 'vobase', version: '0.1.0' });
|
|
43
|
+
|
|
44
|
+
server.registerTool(
|
|
45
|
+
'list_modules',
|
|
46
|
+
{
|
|
47
|
+
description: 'List registered vobase modules.',
|
|
48
|
+
annotations: { readOnlyHint: true },
|
|
49
|
+
},
|
|
50
|
+
async () => {
|
|
51
|
+
return toToolResult({
|
|
52
|
+
modules: deps.modules.map((module) => ({ name: module.name })),
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
server.registerTool(
|
|
58
|
+
'read_module',
|
|
59
|
+
{
|
|
60
|
+
description: 'Read table names from one module schema.',
|
|
61
|
+
inputSchema: z.object({ name: z.string().min(1) }),
|
|
62
|
+
annotations: { readOnlyHint: true },
|
|
63
|
+
},
|
|
64
|
+
async ({ name }) => {
|
|
65
|
+
const selectedModule = deps.modules.find(
|
|
66
|
+
(module) => module.name === name,
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
return toToolResult({
|
|
70
|
+
name,
|
|
71
|
+
tables: selectedModule
|
|
72
|
+
? getSortedTableNames(selectedModule.schema)
|
|
73
|
+
: [],
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
server.registerTool(
|
|
79
|
+
'get_schema',
|
|
80
|
+
{
|
|
81
|
+
description: 'List all table names across every module schema.',
|
|
82
|
+
annotations: { readOnlyHint: true },
|
|
83
|
+
},
|
|
84
|
+
async () => {
|
|
85
|
+
return toToolResult({ tables: getSchemaTableNames(deps.modules) });
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
server.registerTool(
|
|
90
|
+
'view_logs',
|
|
91
|
+
{
|
|
92
|
+
description: 'Return recent entries from _audit_log.',
|
|
93
|
+
inputSchema: z.object({
|
|
94
|
+
limit: z.number().int().positive().max(MAX_LOG_LIMIT).optional(),
|
|
95
|
+
}),
|
|
96
|
+
annotations: { readOnlyHint: true },
|
|
97
|
+
},
|
|
98
|
+
async ({ limit }) => {
|
|
99
|
+
const effectiveLimit = limit ?? DEFAULT_LOG_LIMIT;
|
|
100
|
+
const entries = deps.db
|
|
101
|
+
.select()
|
|
102
|
+
.from(auditLog)
|
|
103
|
+
.orderBy(desc(auditLog.createdAt))
|
|
104
|
+
.limit(effectiveLimit)
|
|
105
|
+
.all()
|
|
106
|
+
.map((entry) => ({
|
|
107
|
+
id: entry.id,
|
|
108
|
+
event: entry.event,
|
|
109
|
+
actorId: entry.actorId,
|
|
110
|
+
actorEmail: entry.actorEmail,
|
|
111
|
+
ip: entry.ip,
|
|
112
|
+
details: entry.details,
|
|
113
|
+
createdAt:
|
|
114
|
+
entry.createdAt instanceof Date
|
|
115
|
+
? entry.createdAt.toISOString()
|
|
116
|
+
: String(entry.createdAt),
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
return toToolResult({ entries });
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return server;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function createMcpHandler(
|
|
127
|
+
deps: McpDeps,
|
|
128
|
+
): (req: Request) => Promise<Response> {
|
|
129
|
+
return async (req: Request) => {
|
|
130
|
+
// API key authentication (optional — CRUD tools require it, discovery tools don't)
|
|
131
|
+
let authenticatedUser: AuthUser | null = null;
|
|
132
|
+
|
|
133
|
+
if (deps.verifyApiKey) {
|
|
134
|
+
const authHeader = req.headers.get('authorization');
|
|
135
|
+
const key = authHeader?.startsWith('Bearer ')
|
|
136
|
+
? authHeader.slice(7)
|
|
137
|
+
: null;
|
|
138
|
+
|
|
139
|
+
if (key) {
|
|
140
|
+
const result = await deps.verifyApiKey(key);
|
|
141
|
+
if (result) {
|
|
142
|
+
authenticatedUser = {
|
|
143
|
+
id: result.userId,
|
|
144
|
+
email: '',
|
|
145
|
+
name: '',
|
|
146
|
+
role: 'admin', // API key holders get admin role for CRUD
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const server = await createMcpServer(deps);
|
|
153
|
+
|
|
154
|
+
// Register CRUD tools only when authenticated via API key
|
|
155
|
+
if (authenticatedUser) {
|
|
156
|
+
const excludeMap = new Map<string, Set<string>>();
|
|
157
|
+
registerCrudTools(server, deps.modules, {
|
|
158
|
+
db: deps.db,
|
|
159
|
+
user: authenticatedUser,
|
|
160
|
+
organizationEnabled: deps.organizationEnabled ?? false,
|
|
161
|
+
}, excludeMap);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const { WebStandardStreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js');
|
|
165
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
166
|
+
sessionIdGenerator: undefined,
|
|
167
|
+
enableJsonResponse: true,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await server.connect(transport);
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
return await transport.handleRequest(req);
|
|
174
|
+
} finally {
|
|
175
|
+
await server.close();
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
}
|