@vobase/core 0.10.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.
Files changed (188) hide show
  1. package/package.json +7 -9
  2. package/src/__tests__/drizzle-introspection.test.ts +77 -0
  3. package/src/__tests__/e2e.test.ts +225 -0
  4. package/src/__tests__/permissions.test.ts +157 -0
  5. package/src/__tests__/rpc-types.test.ts +92 -0
  6. package/src/app.test.ts +99 -0
  7. package/src/app.ts +178 -0
  8. package/src/audit.test.ts +126 -0
  9. package/src/auth.test.ts +74 -0
  10. package/src/contracts/auth.ts +37 -0
  11. package/{dist/contracts/module.d.ts → src/contracts/module.ts} +6 -6
  12. package/src/contracts/notify.ts +47 -0
  13. package/src/contracts/permissions.ts +10 -0
  14. package/src/contracts/storage.ts +61 -0
  15. package/src/ctx.test.ts +162 -0
  16. package/src/ctx.ts +64 -0
  17. package/src/db/client.test.ts +75 -0
  18. package/src/db/client.ts +15 -0
  19. package/src/db/helpers.test.ts +147 -0
  20. package/src/db/helpers.ts +51 -0
  21. package/src/db/index.ts +8 -0
  22. package/{dist/index.d.ts → src/index.ts} +103 -6
  23. package/src/infra/circuit-breaker.test.ts +74 -0
  24. package/src/infra/circuit-breaker.ts +57 -0
  25. package/src/infra/errors.test.ts +175 -0
  26. package/src/infra/errors.ts +64 -0
  27. package/src/infra/http-client.test.ts +482 -0
  28. package/src/infra/http-client.ts +221 -0
  29. package/src/infra/index.ts +35 -0
  30. package/src/infra/job.test.ts +85 -0
  31. package/src/infra/job.ts +94 -0
  32. package/src/infra/logger.test.ts +65 -0
  33. package/src/infra/logger.ts +18 -0
  34. package/src/infra/queue.test.ts +46 -0
  35. package/src/infra/queue.ts +147 -0
  36. package/src/infra/throw-proxy.test.ts +34 -0
  37. package/src/infra/throw-proxy.ts +17 -0
  38. package/src/infra/webhooks-schema.ts +17 -0
  39. package/src/infra/webhooks.test.ts +364 -0
  40. package/src/infra/webhooks.ts +146 -0
  41. package/src/mcp/auth.test.ts +129 -0
  42. package/src/mcp/crud.test.ts +128 -0
  43. package/src/mcp/crud.ts +171 -0
  44. package/{dist/mcp/index.d.ts → src/mcp/index.ts} +0 -1
  45. package/src/mcp/server.test.ts +153 -0
  46. package/src/mcp/server.ts +178 -0
  47. package/src/middleware/audit.test.ts +169 -0
  48. package/src/module-registry.ts +18 -0
  49. package/src/module.test.ts +168 -0
  50. package/src/module.ts +111 -0
  51. package/src/modules/audit/index.ts +18 -0
  52. package/src/modules/audit/middleware.ts +33 -0
  53. package/src/modules/audit/schema.ts +35 -0
  54. package/src/modules/audit/track-changes.ts +70 -0
  55. package/src/modules/auth/audit-hooks.ts +74 -0
  56. package/src/modules/auth/index.ts +101 -0
  57. package/src/modules/auth/middleware.ts +51 -0
  58. package/src/modules/auth/permissions.ts +46 -0
  59. package/src/modules/auth/schema.ts +184 -0
  60. package/src/modules/credentials/encrypt.ts +95 -0
  61. package/src/modules/credentials/index.ts +15 -0
  62. package/src/modules/credentials/schema.ts +10 -0
  63. package/src/modules/notify/index.ts +90 -0
  64. package/src/modules/notify/notify.test.ts +145 -0
  65. package/src/modules/notify/providers/resend.ts +47 -0
  66. package/src/modules/notify/providers/smtp.ts +117 -0
  67. package/src/modules/notify/providers/waba.ts +82 -0
  68. package/src/modules/notify/schema.ts +27 -0
  69. package/src/modules/notify/service.ts +93 -0
  70. package/src/modules/sequences/index.ts +15 -0
  71. package/src/modules/sequences/next-sequence.ts +48 -0
  72. package/src/modules/sequences/schema.ts +12 -0
  73. package/src/modules/storage/index.ts +44 -0
  74. package/src/modules/storage/providers/local.ts +124 -0
  75. package/src/modules/storage/providers/s3.ts +83 -0
  76. package/src/modules/storage/routes.ts +76 -0
  77. package/src/modules/storage/schema.ts +26 -0
  78. package/src/modules/storage/service.ts +202 -0
  79. package/src/modules/storage/storage.test.ts +225 -0
  80. package/src/schemas.test.ts +44 -0
  81. package/src/schemas.ts +63 -0
  82. package/src/sequence.test.ts +56 -0
  83. package/dist/app.d.ts +0 -37
  84. package/dist/app.d.ts.map +0 -1
  85. package/dist/contracts/auth.d.ts +0 -35
  86. package/dist/contracts/auth.d.ts.map +0 -1
  87. package/dist/contracts/module.d.ts.map +0 -1
  88. package/dist/contracts/notify.d.ts +0 -46
  89. package/dist/contracts/notify.d.ts.map +0 -1
  90. package/dist/contracts/permissions.d.ts +0 -10
  91. package/dist/contracts/permissions.d.ts.map +0 -1
  92. package/dist/contracts/storage.d.ts +0 -54
  93. package/dist/contracts/storage.d.ts.map +0 -1
  94. package/dist/ctx.d.ts +0 -40
  95. package/dist/ctx.d.ts.map +0 -1
  96. package/dist/db/client.d.ts +0 -4
  97. package/dist/db/client.d.ts.map +0 -1
  98. package/dist/db/helpers.d.ts +0 -26
  99. package/dist/db/helpers.d.ts.map +0 -1
  100. package/dist/db/index.d.ts +0 -3
  101. package/dist/db/index.d.ts.map +0 -1
  102. package/dist/index.d.ts.map +0 -1
  103. package/dist/index.js +0 -98611
  104. package/dist/infra/circuit-breaker.d.ts +0 -17
  105. package/dist/infra/circuit-breaker.d.ts.map +0 -1
  106. package/dist/infra/errors.d.ts +0 -26
  107. package/dist/infra/errors.d.ts.map +0 -1
  108. package/dist/infra/http-client.d.ts +0 -31
  109. package/dist/infra/http-client.d.ts.map +0 -1
  110. package/dist/infra/index.d.ts +0 -11
  111. package/dist/infra/index.d.ts.map +0 -1
  112. package/dist/infra/job.d.ts +0 -14
  113. package/dist/infra/job.d.ts.map +0 -1
  114. package/dist/infra/logger.d.ts +0 -7
  115. package/dist/infra/logger.d.ts.map +0 -1
  116. package/dist/infra/queue.d.ts +0 -18
  117. package/dist/infra/queue.d.ts.map +0 -1
  118. package/dist/infra/throw-proxy.d.ts +0 -7
  119. package/dist/infra/throw-proxy.d.ts.map +0 -1
  120. package/dist/infra/webhooks-schema.d.ts +0 -60
  121. package/dist/infra/webhooks-schema.d.ts.map +0 -1
  122. package/dist/infra/webhooks.d.ts +0 -46
  123. package/dist/infra/webhooks.d.ts.map +0 -1
  124. package/dist/mcp/crud.d.ts +0 -12
  125. package/dist/mcp/crud.d.ts.map +0 -1
  126. package/dist/mcp/index.d.ts.map +0 -1
  127. package/dist/mcp/server.d.ts +0 -16
  128. package/dist/mcp/server.d.ts.map +0 -1
  129. package/dist/module-registry.d.ts +0 -3
  130. package/dist/module-registry.d.ts.map +0 -1
  131. package/dist/module.d.ts +0 -29
  132. package/dist/module.d.ts.map +0 -1
  133. package/dist/modules/audit/index.d.ts +0 -5
  134. package/dist/modules/audit/index.d.ts.map +0 -1
  135. package/dist/modules/audit/middleware.d.ts +0 -3
  136. package/dist/modules/audit/middleware.d.ts.map +0 -1
  137. package/dist/modules/audit/schema.d.ts +0 -247
  138. package/dist/modules/audit/schema.d.ts.map +0 -1
  139. package/dist/modules/audit/track-changes.d.ts +0 -3
  140. package/dist/modules/audit/track-changes.d.ts.map +0 -1
  141. package/dist/modules/auth/audit-hooks.d.ts +0 -6
  142. package/dist/modules/auth/audit-hooks.d.ts.map +0 -1
  143. package/dist/modules/auth/index.d.ts +0 -25
  144. package/dist/modules/auth/index.d.ts.map +0 -1
  145. package/dist/modules/auth/middleware.d.ts +0 -15
  146. package/dist/modules/auth/middleware.d.ts.map +0 -1
  147. package/dist/modules/auth/permissions.d.ts +0 -5
  148. package/dist/modules/auth/permissions.d.ts.map +0 -1
  149. package/dist/modules/auth/schema.d.ts +0 -2519
  150. package/dist/modules/auth/schema.d.ts.map +0 -1
  151. package/dist/modules/credentials/encrypt.d.ts +0 -12
  152. package/dist/modules/credentials/encrypt.d.ts.map +0 -1
  153. package/dist/modules/credentials/index.d.ts +0 -4
  154. package/dist/modules/credentials/index.d.ts.map +0 -1
  155. package/dist/modules/credentials/schema.d.ts +0 -56
  156. package/dist/modules/credentials/schema.d.ts.map +0 -1
  157. package/dist/modules/notify/index.d.ts +0 -36
  158. package/dist/modules/notify/index.d.ts.map +0 -1
  159. package/dist/modules/notify/providers/resend.d.ts +0 -7
  160. package/dist/modules/notify/providers/resend.d.ts.map +0 -1
  161. package/dist/modules/notify/providers/smtp.d.ts +0 -18
  162. package/dist/modules/notify/providers/smtp.d.ts.map +0 -1
  163. package/dist/modules/notify/providers/waba.d.ts +0 -12
  164. package/dist/modules/notify/providers/waba.d.ts.map +0 -1
  165. package/dist/modules/notify/schema.d.ts +0 -337
  166. package/dist/modules/notify/schema.d.ts.map +0 -1
  167. package/dist/modules/notify/service.d.ts +0 -22
  168. package/dist/modules/notify/service.d.ts.map +0 -1
  169. package/dist/modules/sequences/index.d.ts +0 -4
  170. package/dist/modules/sequences/index.d.ts.map +0 -1
  171. package/dist/modules/sequences/next-sequence.d.ts +0 -8
  172. package/dist/modules/sequences/next-sequence.d.ts.map +0 -1
  173. package/dist/modules/sequences/schema.d.ts +0 -72
  174. package/dist/modules/sequences/schema.d.ts.map +0 -1
  175. package/dist/modules/storage/index.d.ts +0 -24
  176. package/dist/modules/storage/index.d.ts.map +0 -1
  177. package/dist/modules/storage/providers/local.d.ts +0 -3
  178. package/dist/modules/storage/providers/local.d.ts.map +0 -1
  179. package/dist/modules/storage/providers/s3.d.ts +0 -3
  180. package/dist/modules/storage/providers/s3.d.ts.map +0 -1
  181. package/dist/modules/storage/routes.d.ts +0 -4
  182. package/dist/modules/storage/routes.d.ts.map +0 -1
  183. package/dist/modules/storage/schema.d.ts +0 -273
  184. package/dist/modules/storage/schema.d.ts.map +0 -1
  185. package/dist/modules/storage/service.d.ts +0 -35
  186. package/dist/modules/storage/service.d.ts.map +0 -1
  187. package/dist/schemas.d.ts +0 -19
  188. 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
+ });
@@ -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
+ }
@@ -1,3 +1,2 @@
1
1
  export { createMcpServer, createMcpHandler, type McpDeps } from './server';
2
2
  export { registerCrudTools } from './crud';
3
- //# sourceMappingURL=index.d.ts.map
@@ -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
+ }