icode-mcp-adapter 1.0.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 ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "icode-mcp-adapter",
3
+ "version": "1.0.0",
4
+ "description": "Dynamic MCP server adapter — auto-generates CRUD tools from schema configs. Plugs into icode-server via Fastify or runs standalone.",
5
+ "type": "module",
6
+ "main": "src/engine/McpEngine.js",
7
+ "exports": {
8
+ ".": "./src/engine/McpEngine.js",
9
+ "./engine": "./src/engine/McpEngine.js",
10
+ "./tools": "./src/engine/ToolBuilder.js",
11
+ "./schema": "./src/engine/SchemaAdapter.js",
12
+ "./fastify": "./src/transport/fastifyAdapter.js",
13
+ "./types": "./src/engine/types.js"
14
+ },
15
+ "scripts": {
16
+ "test": "node tests/smoke.js",
17
+ "sandbox": "node tests/sandbox.js",
18
+ "sandbox:dev": "node --watch tests/sandbox.js",
19
+ "inspector": "npx @modelcontextprotocol/inspector node tests/sandbox.js"
20
+ },
21
+ "dependencies": {
22
+ "@modelcontextprotocol/sdk": "^1.12.1",
23
+ "zod": "^3.25.0"
24
+ },
25
+ "peerDependencies": {
26
+ "mysql2": "^3.0.0"
27
+ },
28
+ "devDependencies": {
29
+ "mysql2": "^3.14.1",
30
+ "express": "^5.1.0",
31
+ "dotenv": "^16.5.0"
32
+ },
33
+ "files": [
34
+ "src/engine/",
35
+ "src/transport/fastifyAdapter.js"
36
+ ]
37
+ }
@@ -0,0 +1,55 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { registerTableTools } from './ToolBuilder.js';
3
+ import { resolveSchemas } from './SchemaAdapter.js';
4
+
5
+ /**
6
+ * Create an MCP server from a FULL app config (tables already defined).
7
+ *
8
+ * @param {import('./types.js').AppConfig} config - with tables[] fully specified
9
+ * @param {{ query: Function }} db
10
+ * @returns {McpServer}
11
+ */
12
+ export function createMcpServer(config, db) {
13
+ const server = new McpServer({
14
+ name: `${config.name}-mcp`,
15
+ version: config.version,
16
+ });
17
+
18
+ for (const table of config.tables) {
19
+ registerTableTools(server, db, table);
20
+ }
21
+
22
+ return server;
23
+ }
24
+
25
+ /**
26
+ * Create an MCP server from a LIGHTWEIGHT config (auto-resolve columns from DB).
27
+ *
28
+ * Queries INFORMATION_SCHEMA to discover columns, types, PKs, defaults.
29
+ * Only needs a simple config listing which tables + operations to expose.
30
+ *
31
+ * @param {{ name: string, version?: string, tables: Record<string, import('./SchemaAdapter.js').TableConfig> }} config
32
+ * @param {{ query: Function }} db - Pool or SQLService
33
+ * @returns {Promise<McpServer>}
34
+ *
35
+ * @example
36
+ * const server = await createMcpServerAuto({
37
+ * name: 'paaal',
38
+ * tables: {
39
+ * plans: { operations: ['list','get','update'], pkType: 'uuid' },
40
+ * achievements: { operations: ['list','get','add','update'] },
41
+ * actions: { operations: ['list','get','add','update'] },
42
+ * }
43
+ * }, db);
44
+ */
45
+ export async function createMcpServerAuto(config, db) {
46
+ const tables = await resolveSchemas(db, config.tables, {
47
+ cacheKey: config.name,
48
+ });
49
+
50
+ return createMcpServer({
51
+ name: config.name,
52
+ version: config.version || '1.0.0',
53
+ tables,
54
+ }, db);
55
+ }
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Auto-derives MCP TableSchema from the live database (INFORMATION_SCHEMA).
3
+ *
4
+ * Follows icode-server conventions:
5
+ * - PK: `guid` (string)
6
+ * - Soft delete: `_deleted` (0 = active, 1 = deleted)
7
+ * - Timestamp: `_created`
8
+ * - Meta fields: `_time`, `_by`, `_createdBy`, `pk`, etc. → readOnly
9
+ *
10
+ * Works with:
11
+ * - mysql2/promise Pool.query() (standalone)
12
+ * - icode-server SQLService.query() (integrated)
13
+ */
14
+
15
+ // ── MySQL → MCP type mapping ─────────────────────────────────────────────────
16
+
17
+ const MYSQL_TYPE_MAP = {
18
+ varchar: 'string',
19
+ char: 'string',
20
+ tinytext: 'string',
21
+ text: 'text',
22
+ mediumtext: 'text',
23
+ longtext: 'text',
24
+ int: 'number',
25
+ bigint: 'number',
26
+ smallint: 'number',
27
+ mediumint: 'number',
28
+ float: 'number',
29
+ double: 'number',
30
+ decimal: 'number',
31
+ date: 'date',
32
+ datetime: 'date',
33
+ timestamp: 'date',
34
+ json: 'text',
35
+ enum: 'enum',
36
+ };
37
+
38
+ // ── icode-server meta fields (auto-managed, always readOnly) ─────────────────
39
+
40
+ // Meta fields auto-managed by icode-server — always readOnly
41
+ const META_FIELDS = new Set([
42
+ '_created', // created timestamp (epoch ms)
43
+ '_createdBy', // who created
44
+ '_time', // last modified timestamp
45
+ '_by', // who last modified
46
+ '_deleted', // soft delete flag
47
+ 'pk', // auto-increment secondary key
48
+ ]);
49
+
50
+ // ── Schema cache ─────────────────────────────────────────────────────────────
51
+
52
+ const cache = new Map();
53
+ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
54
+
55
+ /**
56
+ * Resolve full TableSchema[] from a lightweight config + live DB.
57
+ *
58
+ * @param {{ query: Function }} db - Pool or SQLService
59
+ * @param {Record<string, TableConfig>} tableConfigs - lightweight per-table config
60
+ * @param {{ cacheKey?: string }} [opts]
61
+ * @returns {Promise<import('./types.js').TableSchema[]>}
62
+ *
63
+ * @typedef {Object} TableConfig
64
+ * @property {('list'|'get'|'add'|'update'|'delete')[]} operations
65
+ * @property {'auto'|'string'} [pkType] - override PK type detection
66
+ * @property {boolean} [softDelete] - default: auto-detected (_deleted col)
67
+ * @property {string} [singular] - override singular derivation
68
+ * @property {string} [displayName] - override display name
69
+ * @property {string} [listOrderBy] - default: `_created` DESC
70
+ * @property {Record<string, Partial<import('./types.js').ColumnDef>>} [columns] - per-column overrides
71
+ */
72
+ export async function resolveSchemas(db, tableConfigs, opts = {}) {
73
+ // Check cache
74
+ if (opts.cacheKey) {
75
+ const cached = cache.get(opts.cacheKey);
76
+ if (cached && Date.now() - cached.resolvedAt < CACHE_TTL) {
77
+ return cached.tables;
78
+ }
79
+ }
80
+
81
+ // Detect current database name
82
+ const [dbRows] = await db.query('SELECT DATABASE() as db');
83
+ const dbName = dbRows[0].db;
84
+
85
+ const tables = [];
86
+
87
+ for (const [tableName, config] of Object.entries(tableConfigs)) {
88
+ const [rows] = await db.query(
89
+ `SELECT COLUMN_NAME, DATA_TYPE, COLUMN_TYPE, IS_NULLABLE,
90
+ COLUMN_DEFAULT, COLUMN_KEY, EXTRA, CHARACTER_MAXIMUM_LENGTH
91
+ FROM INFORMATION_SCHEMA.COLUMNS
92
+ WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?
93
+ ORDER BY ORDINAL_POSITION`,
94
+ [dbName, tableName],
95
+ );
96
+
97
+ if (!rows.length) {
98
+ console.warn(`[MCP] Table '${tableName}' not found in '${dbName}', skipping`);
99
+ continue;
100
+ }
101
+
102
+ // ── Detect primary key ────────────────────────────────────────────────
103
+ // icode-server uses `guid` (string), fallback to `pk` (auto-increment)
104
+ const hasGuid = rows.some(r => r.COLUMN_NAME === 'guid');
105
+ const hasPk = rows.some(r => r.COLUMN_NAME === 'pk');
106
+ const pkName = config.primaryKey || (hasGuid ? 'guid' : (hasPk ? 'pk' : 'guid'));
107
+ const pkActualRow = rows.find(r => r.COLUMN_NAME === pkName);
108
+ const isStringPk = pkActualRow?.DATA_TYPE === 'varchar';
109
+
110
+ // ── Detect soft delete ────────────────────────────────────────────────
111
+ // icode-server standard: _deleted = 0 (not deleted), _deleted = 1 (deleted)
112
+ const hasDeletedCol = rows.some(r => r.COLUMN_NAME === '_deleted');
113
+
114
+ let softDeleteFilter = null;
115
+ let softDeleteSet = null;
116
+
117
+ if (config.softDelete !== false && hasDeletedCol) {
118
+ softDeleteFilter = '`_deleted` = 0';
119
+ softDeleteSet = '`_deleted` = 1';
120
+ }
121
+
122
+ // ── Build columns ─────────────────────────────────────────────────────
123
+ const columns = rows.map(row => {
124
+ const col = buildColumn(row, pkName);
125
+
126
+ // Apply per-column overrides from config
127
+ if (config.columns?.[row.COLUMN_NAME]) {
128
+ Object.assign(col, config.columns[row.COLUMN_NAME]);
129
+ }
130
+
131
+ return col;
132
+ });
133
+
134
+ // ── Derive singular name ──────────────────────────────────────────────
135
+ const singular = config.singular || deriveSingular(tableName);
136
+
137
+ // ── Default ORDER BY ──────────────────────────────────────────────────
138
+ const hasCreated = rows.some(r => r.COLUMN_NAME === '_created');
139
+ const defaultOrder = hasCreated ? '`_created` DESC' : undefined;
140
+
141
+ tables.push({
142
+ name: tableName,
143
+ singular,
144
+ displayName: config.displayName || capitalize(singular),
145
+ primaryKey: pkName,
146
+ pkType: config.pkType || (isStringPk ? 'string' : 'auto'),
147
+ softDelete: !!softDeleteFilter,
148
+ softDeleteFilter,
149
+ softDeleteSet,
150
+ operations: config.operations || ['list', 'get', 'add', 'update'],
151
+ listOrderBy: config.listOrderBy || defaultOrder,
152
+ columns,
153
+ ...(config.customDescriptions && { customDescriptions: config.customDescriptions }),
154
+ });
155
+ }
156
+
157
+ // Cache result
158
+ if (opts.cacheKey) {
159
+ cache.set(opts.cacheKey, { tables, resolvedAt: Date.now() });
160
+ }
161
+
162
+ return tables;
163
+ }
164
+
165
+ /** Clear cached schemas (e.g., after a migration). */
166
+ export function clearSchemaCache(cacheKey) {
167
+ if (cacheKey) cache.delete(cacheKey);
168
+ else cache.clear();
169
+ }
170
+
171
+ // ── Column builder ───────────────────────────────────────────────────────────
172
+
173
+ const q = (name) => `\`${name}\``;
174
+
175
+ function buildColumn(row, pkName) {
176
+ const col = {
177
+ name: row.COLUMN_NAME,
178
+ type: resolveType(row),
179
+ };
180
+
181
+ // Primary key → readOnly
182
+ if (row.COLUMN_NAME === pkName) {
183
+ col.readOnly = true;
184
+ }
185
+
186
+ // Auto-increment → readOnly
187
+ if (row.EXTRA?.includes('auto_increment')) {
188
+ col.readOnly = true;
189
+ }
190
+
191
+ // _created → autoSet
192
+ if (row.COLUMN_NAME === '_created') {
193
+ col.autoSet = 'created_at';
194
+ }
195
+
196
+ // icode-server meta fields → readOnly (auto-managed by the platform)
197
+ if (META_FIELDS.has(row.COLUMN_NAME)) {
198
+ col.readOnly = true;
199
+ }
200
+
201
+ // Nullable
202
+ if (row.IS_NULLABLE === 'YES') {
203
+ col.nullable = true;
204
+ }
205
+
206
+ // Default value
207
+ if (row.COLUMN_DEFAULT !== null && row.COLUMN_DEFAULT !== undefined) {
208
+ col.default = parseDefault(row);
209
+ }
210
+
211
+ // Required: NOT NULL, no default, not auto-set, not PK, not meta
212
+ if (row.IS_NULLABLE === 'NO'
213
+ && row.COLUMN_DEFAULT === null
214
+ && !col.readOnly
215
+ && !col.autoSet
216
+ && row.COLUMN_NAME !== pkName) {
217
+ col.required = true;
218
+ }
219
+
220
+ // Filterable: indexed columns and guid/FK references
221
+ if (row.COLUMN_KEY === 'MUL'
222
+ || (row.COLUMN_NAME.endsWith('_id') && row.COLUMN_NAME !== pkName)
223
+ || (row.COLUMN_NAME === 'guid' && row.COLUMN_NAME !== pkName)) {
224
+ col.filterable = true;
225
+ }
226
+
227
+ // Enum values
228
+ if (row.DATA_TYPE === 'enum') {
229
+ col.enumValues = parseEnum(row.COLUMN_TYPE);
230
+ }
231
+
232
+ return col;
233
+ }
234
+
235
+ // ── Type resolution ──────────────────────────────────────────────────────────
236
+
237
+ function resolveType(row) {
238
+ // tinyint(1) → boolean
239
+ if (row.COLUMN_TYPE === 'tinyint(1)') return 'boolean';
240
+
241
+ // bigint for timestamp columns → timestamp (epoch ms)
242
+ if (row.DATA_TYPE === 'bigint' && (row.COLUMN_NAME === '_created' || row.COLUMN_NAME === '_time')) return 'timestamp';
243
+
244
+ // varchar(36) named guid → string (not uuid — icode guids aren't always uuid format)
245
+ if (row.COLUMN_NAME === 'guid') return 'string';
246
+
247
+ return MYSQL_TYPE_MAP[row.DATA_TYPE] || 'string';
248
+ }
249
+
250
+ function parseEnum(columnType) {
251
+ const match = columnType.match(/^enum\((.+)\)$/i);
252
+ if (!match) return [];
253
+ return match[1].split(',').map(v => v.trim().replace(/^'|'$/g, ''));
254
+ }
255
+
256
+ function parseDefault(row) {
257
+ if (row.COLUMN_TYPE === 'tinyint(1)') {
258
+ return row.COLUMN_DEFAULT === '1' || row.COLUMN_DEFAULT === 1;
259
+ }
260
+ if (['int', 'bigint', 'smallint', 'mediumint'].includes(row.DATA_TYPE)) {
261
+ return Number(row.COLUMN_DEFAULT);
262
+ }
263
+ if (['float', 'double', 'decimal'].includes(row.DATA_TYPE)) {
264
+ return Number(row.COLUMN_DEFAULT);
265
+ }
266
+ return row.COLUMN_DEFAULT;
267
+ }
268
+
269
+ // ── Helpers ──────────────────────────────────────────────────────────────────
270
+
271
+ function deriveSingular(name) {
272
+ if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
273
+ if (name.endsWith('ses')) return name.slice(0, -2);
274
+ if (name.endsWith('s') && !name.endsWith('ss')) return name.slice(0, -1);
275
+ return name;
276
+ }
277
+
278
+ function capitalize(s) {
279
+ return s.charAt(0).toUpperCase() + s.slice(1);
280
+ }
@@ -0,0 +1,295 @@
1
+ import { z } from 'zod';
2
+ import { randomUUID } from 'node:crypto';
3
+
4
+ /**
5
+ * Dynamic MCP tool generator.
6
+ *
7
+ * Follows icode-server conventions:
8
+ * - PK: `guid` (string), soft delete: `_deleted`, timestamp: `_created`
9
+ *
10
+ * Accepts any db object with a `query(sql, params)` method that returns [rows, fields].
11
+ * - mysql2/promise Pool.query() (standalone)
12
+ * - icode-server SQLService.query() (integrated)
13
+ */
14
+
15
+ /** Backtick-quote a MySQL identifier */
16
+ const q = (name) => `\`${name}\``;
17
+
18
+ /**
19
+ * Build a Zod schema for a column definition.
20
+ * @param {import('./types.js').ColumnDef} col
21
+ * @param {{ forceOptional?: boolean, includeDefault?: boolean }} opts
22
+ */
23
+ function zodFor(col, opts = {}) {
24
+ let s;
25
+ switch (col.type) {
26
+ case 'number': s = z.number(); break;
27
+ case 'boolean': s = z.boolean(); break;
28
+ case 'uuid': s = z.string().uuid(); break;
29
+ case 'timestamp': s = z.number(); break;
30
+ case 'enum': s = z.enum(col.enumValues); break;
31
+ default: s = z.string(); break;
32
+ }
33
+ if (col.description) s = s.describe(col.description);
34
+ if (col.nullable) s = s.nullable();
35
+ if (opts.forceOptional || !col.required) s = s.optional();
36
+ if (opts.includeDefault && col.default !== undefined) s = s.default(col.default);
37
+ return s;
38
+ }
39
+
40
+ /** Get writable columns (exclude readOnly, autoSet, and primaryKey) */
41
+ function writableCols(table) {
42
+ return table.columns.filter(c => !c.readOnly && !c.autoSet && c.name !== table.primaryKey);
43
+ }
44
+
45
+ /** Get filterable columns */
46
+ function filterCols(table) {
47
+ return table.columns.filter(c => c.filterable);
48
+ }
49
+
50
+ /** Build PK Zod schema */
51
+ function pkSchema(table) {
52
+ if (table.pkType === 'string') return z.string().describe(`The ${table.singular} guid`);
53
+ if (table.pkType === 'auto') return z.number().describe(`The ${table.singular} ID`);
54
+ return z.string().describe(`The ${table.singular} guid`);
55
+ }
56
+
57
+ /**
58
+ * Register all CRUD tools for a table on the MCP server.
59
+ *
60
+ * @param {import('@modelcontextprotocol/sdk/server/mcp.js').McpServer} server
61
+ * @param {{ query: (sql: string, params: any[]) => Promise<[any[], any]> }} db
62
+ * — any object with query(sql, params) → [rows, fields]
63
+ * @param {import('./types.js').TableSchema} table
64
+ */
65
+ export function registerTableTools(server, db, table) {
66
+ const display = table.displayName || table.singular;
67
+
68
+ for (const op of table.operations) {
69
+ switch (op) {
70
+ case 'list': regList(server, db, table, display); break;
71
+ case 'get': regGet(server, db, table, display); break;
72
+ case 'add': regAdd(server, db, table, display); break;
73
+ case 'update': regUpdate(server, db, table, display); break;
74
+ case 'delete': regDelete(server, db, table, display); break;
75
+ }
76
+ }
77
+ }
78
+
79
+ // ── LIST ──────────────────────────────────────────────────────────────────────
80
+
81
+ function regList(server, db, table, display) {
82
+ const filters = filterCols(table);
83
+ const inputSchema = {};
84
+ for (const f of filters) {
85
+ inputSchema[f.name] = zodFor(f, { forceOptional: true });
86
+ }
87
+
88
+ server.registerTool(`list_${table.name}`, {
89
+ title: `List ${display}s`,
90
+ description: table.customDescriptions?.list
91
+ || `List all ${table.name} with optional filters.`,
92
+ inputSchema,
93
+ }, async (args) => {
94
+ try {
95
+ const where = [];
96
+ const vals = [];
97
+
98
+ if (table.softDeleteFilter) where.push(table.softDeleteFilter);
99
+
100
+ for (const f of filters) {
101
+ if (args[f.name] !== undefined && args[f.name] !== null) {
102
+ where.push(`${q(f.name)} = ?`);
103
+ vals.push(args[f.name]);
104
+ }
105
+ }
106
+
107
+ let sql = `SELECT * FROM ${q(table.name)}`;
108
+ if (where.length) sql += ` WHERE ${where.join(' AND ')}`;
109
+ if (table.listOrderBy) sql += ` ORDER BY ${table.listOrderBy}`;
110
+
111
+ const [rows] = await db.query(sql, vals);
112
+ return {
113
+ content: [{ type: 'text', text: JSON.stringify({ [table.name]: rows }) }],
114
+ structuredContent: { [table.name]: rows },
115
+ };
116
+ } catch (error) {
117
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
118
+ }
119
+ });
120
+ }
121
+
122
+ // ── GET ───────────────────────────────────────────────────────────────────────
123
+
124
+ function regGet(server, db, table, display) {
125
+ server.registerTool(`get_${table.singular}`, {
126
+ title: `Get ${display}`,
127
+ description: table.customDescriptions?.get
128
+ || `Get a single ${table.singular} by ID.`,
129
+ inputSchema: { [table.primaryKey]: pkSchema(table) },
130
+ }, async (args) => {
131
+ try {
132
+ const [rows] = await db.query(
133
+ `SELECT * FROM ${q(table.name)} WHERE ${q(table.primaryKey)} = ? LIMIT 1`,
134
+ [args[table.primaryKey]],
135
+ );
136
+ const row = rows[0] || null;
137
+ return {
138
+ content: [{ type: 'text', text: JSON.stringify(row) }],
139
+ structuredContent: row,
140
+ };
141
+ } catch (error) {
142
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
143
+ }
144
+ });
145
+ }
146
+
147
+ // ── ADD ───────────────────────────────────────────────────────────────────────
148
+
149
+ function regAdd(server, db, table, display) {
150
+ const cols = writableCols(table);
151
+ const inputSchema = {};
152
+ for (const c of cols) {
153
+ inputSchema[c.name] = zodFor(c, { includeDefault: true });
154
+ }
155
+
156
+ server.registerTool(`add_${table.singular}`, {
157
+ title: `Add ${display}`,
158
+ description: table.customDescriptions?.add
159
+ || `Create a new ${table.singular}.`,
160
+ inputSchema,
161
+ }, async (args) => {
162
+ try {
163
+ const insertCols = [];
164
+ const insertVals = [];
165
+ const placeholders = [];
166
+
167
+ // Auto-set columns (_created)
168
+ for (const c of table.columns) {
169
+ if (c.autoSet === 'created_at') {
170
+ insertCols.push(q(c.name));
171
+ insertVals.push(Date.now());
172
+ placeholders.push('?');
173
+ }
174
+ }
175
+
176
+ // String PK (guid) — generate if not provided by caller
177
+ let generatedGuid;
178
+ if (table.pkType === 'string') {
179
+ generatedGuid = randomUUID();
180
+ insertCols.push(q(table.primaryKey));
181
+ insertVals.push(generatedGuid);
182
+ placeholders.push('?');
183
+ }
184
+
185
+ // User-provided columns
186
+ for (const c of cols) {
187
+ if (args[c.name] !== undefined) {
188
+ insertCols.push(q(c.name));
189
+ insertVals.push(args[c.name]);
190
+ placeholders.push('?');
191
+ }
192
+ }
193
+
194
+ const sql = `INSERT INTO ${q(table.name)} (${insertCols.join(', ')}) VALUES (${placeholders.join(', ')})`;
195
+ const [result] = await db.query(sql, insertVals);
196
+
197
+ // Fetch the inserted row
198
+ const pkVal = table.pkType === 'string' ? generatedGuid : result.insertId;
199
+ const [rows] = await db.query(
200
+ `SELECT * FROM ${q(table.name)} WHERE ${q(table.primaryKey)} = ?`,
201
+ [pkVal],
202
+ );
203
+
204
+ return {
205
+ content: [{ type: 'text', text: JSON.stringify(rows[0]) }],
206
+ structuredContent: rows[0],
207
+ };
208
+ } catch (error) {
209
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
210
+ }
211
+ });
212
+ }
213
+
214
+ // ── UPDATE ────────────────────────────────────────────────────────────────────
215
+
216
+ function regUpdate(server, db, table, display) {
217
+ const cols = writableCols(table);
218
+ const inputSchema = { [table.primaryKey]: pkSchema(table) };
219
+ for (const c of cols) {
220
+ inputSchema[c.name] = zodFor(c, { forceOptional: true });
221
+ }
222
+
223
+ server.registerTool(`update_${table.singular}`, {
224
+ title: `Update ${display}`,
225
+ description: table.customDescriptions?.update
226
+ || `Update fields on an existing ${table.singular}.`,
227
+ inputSchema,
228
+ }, async (args) => {
229
+ try {
230
+ const pkVal = args[table.primaryKey];
231
+ const setClauses = [];
232
+ const vals = [];
233
+
234
+ for (const c of cols) {
235
+ if (args[c.name] !== undefined) {
236
+ setClauses.push(`${q(c.name)} = ?`);
237
+ vals.push(args[c.name]);
238
+ }
239
+ }
240
+
241
+ if (!setClauses.length) {
242
+ return { content: [{ type: 'text', text: 'No fields to update.' }] };
243
+ }
244
+
245
+ vals.push(pkVal);
246
+ await db.query(
247
+ `UPDATE ${q(table.name)} SET ${setClauses.join(', ')} WHERE ${q(table.primaryKey)} = ?`,
248
+ vals,
249
+ );
250
+
251
+ const [rows] = await db.query(
252
+ `SELECT * FROM ${q(table.name)} WHERE ${q(table.primaryKey)} = ?`,
253
+ [pkVal],
254
+ );
255
+
256
+ return {
257
+ content: [{ type: 'text', text: JSON.stringify(rows[0]) }],
258
+ structuredContent: rows[0],
259
+ };
260
+ } catch (error) {
261
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
262
+ }
263
+ });
264
+ }
265
+
266
+ // ── DELETE ────────────────────────────────────────────────────────────────────
267
+
268
+ function regDelete(server, db, table, display) {
269
+ server.registerTool(`delete_${table.singular}`, {
270
+ title: `Delete ${display}`,
271
+ description: table.customDescriptions?.delete
272
+ || `Delete a ${table.singular}.`,
273
+ inputSchema: { [table.primaryKey]: pkSchema(table) },
274
+ }, async (args) => {
275
+ try {
276
+ const pkVal = args[table.primaryKey];
277
+
278
+ if (table.softDeleteSet) {
279
+ await db.query(
280
+ `UPDATE ${q(table.name)} SET ${table.softDeleteSet} WHERE ${q(table.primaryKey)} = ?`,
281
+ [pkVal],
282
+ );
283
+ } else {
284
+ await db.query(
285
+ `DELETE FROM ${q(table.name)} WHERE ${q(table.primaryKey)} = ?`,
286
+ [pkVal],
287
+ );
288
+ }
289
+
290
+ return { content: [{ type: 'text', text: `${display} deleted successfully.` }] };
291
+ } catch (error) {
292
+ return { content: [{ type: 'text', text: `Error: ${error.message}` }], isError: true };
293
+ }
294
+ });
295
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * @typedef {'string'|'number'|'boolean'|'text'|'date'|'enum'|'uuid'|'timestamp'} ColumnType
3
+ *
4
+ * @typedef {Object} ColumnDef
5
+ * @property {string} name - Column name in the database
6
+ * @property {ColumnType} type - Column data type
7
+ * @property {string[]} [enumValues] - Allowed values for enum type
8
+ * @property {boolean} [required] - Required for add operation (default: false)
9
+ * @property {boolean} [nullable] - Allows null values
10
+ * @property {*} [default] - Default value for add (used in Zod schema)
11
+ * @property {string} [description] - Tool parameter description shown to AI
12
+ * @property {'created_at'} [autoSet] - Auto-populated on insert, excluded from inputs
13
+ * @property {boolean} [readOnly] - Excluded from add/update inputs
14
+ * @property {boolean} [filterable] - Available as a list filter parameter
15
+ *
16
+ * @typedef {Object} TableSchema
17
+ * @property {string} name - Table name (e.g., 'achievements')
18
+ * @property {string} singular - Singular form (e.g., 'achievement')
19
+ * @property {string} [displayName] - Human-readable (e.g., 'Achievement')
20
+ * @property {string} primaryKey - Primary key column name
21
+ * @property {'auto'|'uuid'} pkType - Auto-increment or UUID
22
+ * @property {boolean} [softDelete] - Use active=0 instead of DELETE (default: true)
23
+ * @property {ColumnDef[]} columns
24
+ * @property {('list'|'get'|'add'|'update'|'delete')[]} operations
25
+ * @property {string} [listOrderBy] - Default ORDER BY clause (use backticks for reserved words)
26
+ * @property {Object} [customDescriptions] - Override tool descriptions per operation
27
+ *
28
+ * @typedef {Object} DbConfig
29
+ * @property {string} host
30
+ * @property {string} user
31
+ * @property {string} password
32
+ * @property {string} database
33
+ * @property {number} [port]
34
+ * @property {Object} [ssl]
35
+ * @property {string} [ssl.ca] - Base64 encoded CA cert
36
+ * @property {boolean} [ssl.rejectUnauthorized]
37
+ *
38
+ * @typedef {Object} AppConfig
39
+ * @property {string} name - App identifier (e.g., 'paaal')
40
+ * @property {string} displayName - Human-readable (e.g., 'PAAAL Coach')
41
+ * @property {string} version
42
+ * @property {DbConfig} db
43
+ * @property {TableSchema[]} tables
44
+ */
45
+
46
+ export {};
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Fastify plugin for icode-server integration.
3
+ *
4
+ * Usage in instacode.js:
5
+ *
6
+ * import mcpPlugin from 'icode-mcp-adapter/fastify';
7
+ * import paaalConfig from './plugins/mcp/configs/paaal.config.js';
8
+ *
9
+ * await app.register(mcpPlugin, {
10
+ * apps: { paaal: paaalConfig },
11
+ * getDb: (appName, env) => getAppDbService(appName, env),
12
+ * });
13
+ *
14
+ * Routes:
15
+ * POST /v1/mcp/:appName — MCP protocol endpoint
16
+ * DELETE /v1/mcp/:appName — Session termination
17
+ * GET /v1/mcp/:appName/health — Health check
18
+ */
19
+
20
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
21
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
22
+ import { randomUUID } from 'node:crypto';
23
+ import { createMcpServerAuto } from '../engine/McpEngine.js';
24
+
25
+ const sessions = new Map();
26
+
27
+ /**
28
+ * @param {import('fastify').FastifyInstance} fastify
29
+ * @param {Object} opts
30
+ * @param {Record<string, import('../engine/types.js').AppConfig>} opts.apps
31
+ * — app configs keyed by name: { paaal: paaalConfig, myapp: myappConfig }
32
+ * @param {(appName: string, env: string) => Promise<{ query: Function }>} opts.getDb
33
+ * — DB provider function, e.g. (name, env) => getAppDbService(name, env)
34
+ * @param {string} [opts.prefix='/v1/mcp']
35
+ * — route prefix (default: /v1/mcp)
36
+ * @param {boolean} [opts.public=true]
37
+ * — route visibility for AuthGate (default: public)
38
+ */
39
+ export default async function mcpPlugin(fastify, opts = {}) {
40
+ const apps = new Map(Object.entries(opts.apps || {}));
41
+ const getDb = opts.getDb;
42
+ const prefix = opts.prefix || '/v1/mcp';
43
+ const isPublic = opts.public ?? true;
44
+
45
+ if (!getDb) throw new Error('icode-mcp-adapter: opts.getDb is required');
46
+ if (!apps.size) throw new Error('icode-mcp-adapter: opts.apps is required (at least one app)');
47
+
48
+ // ── POST — MCP protocol endpoint ───────────────────────────────────────
49
+ fastify.post(`${prefix}/:appName`, {
50
+ config: { public: isPublic },
51
+ }, async (req, reply) => {
52
+ const { appName } = req.params;
53
+ const appConfig = apps.get(appName);
54
+ if (!appConfig) {
55
+ return reply.code(404).send({ ok: false, error: `App '${appName}' not registered` });
56
+ }
57
+
58
+ const sessionId = req.headers['mcp-session-id'];
59
+
60
+ // Existing session
61
+ if (sessionId && sessions.has(sessionId)) {
62
+ await sessions.get(sessionId).transport.handleRequest(req.raw, reply.raw, req.body);
63
+ return reply.hijack();
64
+ }
65
+
66
+ // New session (must be initialize request)
67
+ if (!sessionId && isInitializeRequest(req.body)) {
68
+ const db = await getDb(appName, appConfig.dbEnv || 'dev');
69
+ const server = await createMcpServerAuto(appConfig, db);
70
+
71
+ const transport = new StreamableHTTPServerTransport({
72
+ sessionIdGenerator: () => randomUUID(),
73
+ onsessioninitialized: (id) => sessions.set(id, { transport, server }),
74
+ });
75
+ transport.onclose = () => {
76
+ if (transport.sessionId) sessions.delete(transport.sessionId);
77
+ };
78
+
79
+ await server.connect(transport);
80
+ await transport.handleRequest(req.raw, reply.raw, req.body);
81
+ return reply.hijack();
82
+ }
83
+
84
+ return reply.code(400).send({
85
+ jsonrpc: '2.0',
86
+ error: { code: -32000, message: 'Invalid session or missing initialization' },
87
+ id: null,
88
+ });
89
+ });
90
+
91
+ // ── DELETE — Session termination ───────────────────────────────────────
92
+ fastify.delete(`${prefix}/:appName`, {
93
+ config: { public: isPublic },
94
+ }, async (req, reply) => {
95
+ const sessionId = req.headers['mcp-session-id'];
96
+ if (sessionId && sessions.has(sessionId)) {
97
+ await sessions.get(sessionId).transport.close();
98
+ sessions.delete(sessionId);
99
+ }
100
+ return reply.code(200).send();
101
+ });
102
+
103
+ // ── GET — Health check ─────────────────────────────────────────────────
104
+ fastify.get(`${prefix}/:appName/health`, {
105
+ config: { public: isPublic },
106
+ }, async (req) => {
107
+ const { appName } = req.params;
108
+ const appConfig = apps.get(appName);
109
+ if (!appConfig) return { ok: false, error: 'Unknown app' };
110
+ return { ok: true, app: appName, tables: Object.keys(appConfig.tables) };
111
+ });
112
+ }