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 +37 -0
- package/src/engine/McpEngine.js +55 -0
- package/src/engine/SchemaAdapter.js +280 -0
- package/src/engine/ToolBuilder.js +295 -0
- package/src/engine/types.js +46 -0
- package/src/transport/fastifyAdapter.js +112 -0
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
|
+
}
|