icode-mcp-adapter 1.0.4 → 1.0.6
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 +1 -1
- package/src/engine/McpEngine.js +17 -21
- package/src/engine/SchemaAdapter.js +35 -65
- package/src/engine/ToolBuilder.js +62 -28
- package/src/engine/types.js +2 -0
- package/src/transport/fastifyAdapter.js +63 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "icode-mcp-adapter",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
4
4
|
"description": "Dynamic MCP server adapter — auto-generates CRUD tools from schema configs. Plugs into icode-server via Fastify or runs standalone.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/engine/McpEngine.js",
|
package/src/engine/McpEngine.js
CHANGED
|
@@ -1,55 +1,51 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { registerTableTools } from './ToolBuilder.js';
|
|
3
|
-
import { resolveSchemas } from './SchemaAdapter.js';
|
|
3
|
+
import { resolveSchemas as defaultResolveSchemas } from './SchemaAdapter.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Create an MCP server from a FULL app config (tables already defined).
|
|
7
7
|
*
|
|
8
8
|
* @param {import('./types.js').AppConfig} config - with tables[] fully specified
|
|
9
9
|
* @param {{ query: Function }} db
|
|
10
|
+
* @param {{ userId: string }} [identity] - Caller identity; required when a table is owner-scoped
|
|
10
11
|
* @returns {McpServer}
|
|
11
12
|
*/
|
|
12
|
-
export function createMcpServer(config, db) {
|
|
13
|
+
export function createMcpServer(config, db, identity) {
|
|
13
14
|
const server = new McpServer({
|
|
14
15
|
name: `${config.appName}-mcp`,
|
|
15
16
|
version: config.version,
|
|
16
17
|
});
|
|
17
18
|
|
|
18
19
|
for (const table of config.tables) {
|
|
19
|
-
registerTableTools(server, db, table);
|
|
20
|
+
registerTableTools(server, db, table, identity);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
return server;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
|
-
* Create an MCP server from a LIGHTWEIGHT config (auto-resolve columns
|
|
27
|
+
* Create an MCP server from a LIGHTWEIGHT config (auto-resolve columns).
|
|
27
28
|
*
|
|
28
|
-
*
|
|
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
|
|
29
|
+
* @param {Object} config - app config with tables
|
|
32
30
|
* @param {{ query: Function }} db - Pool or SQLService
|
|
31
|
+
* @param {{ resolveSchemas?: Function, identity?: { userId: string } }} [opts]
|
|
32
|
+
* resolveSchemas: (db, tableConfigs, opts) => Promise<TableSchema[]>
|
|
33
|
+
* Defaults to INFORMATION_SCHEMA-based resolver.
|
|
34
|
+
* Inject your own to use icode-server's SchemasService or any other source.
|
|
35
|
+
* identity: caller identity used to scope owner-scoped tables.
|
|
33
36
|
* @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
37
|
*/
|
|
45
|
-
export async function createMcpServerAuto(config, db) {
|
|
38
|
+
export async function createMcpServerAuto(config, db, opts = {}) {
|
|
39
|
+
const resolveSchemas = opts.resolveSchemas || defaultResolveSchemas;
|
|
40
|
+
|
|
46
41
|
const tables = await resolveSchemas(db, config.tables, {
|
|
47
|
-
cacheKey: config.appName
|
|
42
|
+
cacheKey: `${config.appName}--${config.env || 'prod'}`,
|
|
43
|
+
ownerColumn: config.ownerColumn,
|
|
48
44
|
});
|
|
49
45
|
|
|
50
46
|
return createMcpServer({
|
|
51
47
|
appName: config.appName,
|
|
52
48
|
version: config.version || '1.0.0',
|
|
53
49
|
tables,
|
|
54
|
-
}, db);
|
|
50
|
+
}, db, opts.identity);
|
|
55
51
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Auto-derives MCP TableSchema from
|
|
2
|
+
* Auto-derives MCP TableSchema from INFORMATION_SCHEMA.
|
|
3
3
|
*
|
|
4
4
|
* Follows icode-server conventions:
|
|
5
5
|
* - PK: `guid` (string)
|
|
6
6
|
* - Soft delete: `_deleted` (0 = active, 1 = deleted)
|
|
7
7
|
* - Timestamp: `_created`
|
|
8
|
-
* - Meta fields: `_time`, `_by`, `
|
|
8
|
+
* - Meta fields: `_created`, `_createdBy`, `_time`, `_by`, `_deleted`, `pk` → readOnly
|
|
9
9
|
*
|
|
10
10
|
* Works with:
|
|
11
11
|
* - mysql2/promise Pool.query() (standalone)
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
// ── MySQL → MCP type mapping ─────────────────────────────────────────────────
|
|
16
16
|
|
|
17
|
-
const
|
|
17
|
+
const DATATYPE_MAP = {
|
|
18
18
|
varchar: 'string',
|
|
19
19
|
char: 'string',
|
|
20
20
|
tinytext: 'string',
|
|
@@ -25,6 +25,7 @@ const MYSQL_TYPE_MAP = {
|
|
|
25
25
|
bigint: 'number',
|
|
26
26
|
smallint: 'number',
|
|
27
27
|
mediumint: 'number',
|
|
28
|
+
tinyint: 'number',
|
|
28
29
|
float: 'number',
|
|
29
30
|
double: 'number',
|
|
30
31
|
decimal: 'number',
|
|
@@ -35,9 +36,8 @@ const MYSQL_TYPE_MAP = {
|
|
|
35
36
|
enum: 'enum',
|
|
36
37
|
};
|
|
37
38
|
|
|
38
|
-
// ──
|
|
39
|
+
// ── Meta fields (auto-managed by icode-server, always readOnly) ──────────────
|
|
39
40
|
|
|
40
|
-
// Meta fields auto-managed by icode-server — always readOnly
|
|
41
41
|
const META_FIELDS = new Set([
|
|
42
42
|
'_created', // created timestamp (epoch ms)
|
|
43
43
|
'_createdBy', // who created
|
|
@@ -53,21 +53,13 @@ const cache = new Map();
|
|
|
53
53
|
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
|
54
54
|
|
|
55
55
|
/**
|
|
56
|
-
* Resolve full TableSchema[] from
|
|
56
|
+
* Resolve full TableSchema[] from config + INFORMATION_SCHEMA.
|
|
57
57
|
*
|
|
58
58
|
* @param {{ query: Function }} db - Pool or SQLService
|
|
59
|
-
* @param {Record<string, TableConfig>} tableConfigs
|
|
60
|
-
* @param {{ cacheKey?: string }} [opts]
|
|
59
|
+
* @param {Record<string, TableConfig>} tableConfigs
|
|
60
|
+
* @param {{ cacheKey?: string, ownerColumn?: string }} [opts]
|
|
61
|
+
* ownerColumn: app-level default owner column (per-table `ownerColumn` overrides; `null` opts out)
|
|
61
62
|
* @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
63
|
*/
|
|
72
64
|
export async function resolveSchemas(db, tableConfigs, opts = {}) {
|
|
73
65
|
// Check cache
|
|
@@ -78,7 +70,6 @@ export async function resolveSchemas(db, tableConfigs, opts = {}) {
|
|
|
78
70
|
}
|
|
79
71
|
}
|
|
80
72
|
|
|
81
|
-
// Detect current database name
|
|
82
73
|
const [dbRows] = await db.query('SELECT DATABASE() as db');
|
|
83
74
|
const dbName = dbRows[0].db;
|
|
84
75
|
|
|
@@ -99,16 +90,14 @@ export async function resolveSchemas(db, tableConfigs, opts = {}) {
|
|
|
99
90
|
continue;
|
|
100
91
|
}
|
|
101
92
|
|
|
102
|
-
// ── Detect primary key
|
|
103
|
-
// icode-server uses `guid` (string), fallback to `pk` (auto-increment)
|
|
93
|
+
// ── Detect primary key ──────────────────────────────────────────────
|
|
104
94
|
const hasGuid = rows.some(r => r.COLUMN_NAME === 'guid');
|
|
105
95
|
const hasPk = rows.some(r => r.COLUMN_NAME === 'pk');
|
|
106
96
|
const pkName = config.primaryKey || (hasGuid ? 'guid' : (hasPk ? 'pk' : 'guid'));
|
|
107
97
|
const pkActualRow = rows.find(r => r.COLUMN_NAME === pkName);
|
|
108
98
|
const isStringPk = pkActualRow?.DATA_TYPE === 'varchar';
|
|
109
99
|
|
|
110
|
-
// ── Detect soft delete
|
|
111
|
-
// icode-server standard: _deleted = 0 (not deleted), _deleted = 1 (deleted)
|
|
100
|
+
// ── Detect soft delete ──────────────────────────────────────────────
|
|
112
101
|
const hasDeletedCol = rows.some(r => r.COLUMN_NAME === '_deleted');
|
|
113
102
|
|
|
114
103
|
let softDeleteFilter = null;
|
|
@@ -119,25 +108,31 @@ export async function resolveSchemas(db, tableConfigs, opts = {}) {
|
|
|
119
108
|
softDeleteSet = '`_deleted` = 1';
|
|
120
109
|
}
|
|
121
110
|
|
|
122
|
-
// ── Build columns
|
|
111
|
+
// ── Build columns ───────────────────────────────────────────────────
|
|
123
112
|
const columns = rows.map(row => {
|
|
124
113
|
const col = buildColumn(row, pkName);
|
|
125
114
|
|
|
126
|
-
// Apply per-column overrides from config
|
|
115
|
+
// Apply per-column overrides from config (description, filterable)
|
|
127
116
|
if (config.columns?.[row.COLUMN_NAME]) {
|
|
128
|
-
|
|
117
|
+
const override = config.columns[row.COLUMN_NAME];
|
|
118
|
+
if (override.description) col.description = override.description;
|
|
119
|
+
if (override.filterable) col.filterable = true;
|
|
120
|
+
if (override.required !== undefined) col.required = override.required;
|
|
121
|
+
if (override.nullable !== undefined) col.nullable = override.nullable;
|
|
129
122
|
}
|
|
130
123
|
|
|
131
124
|
return col;
|
|
132
125
|
});
|
|
133
126
|
|
|
134
|
-
// ── Derive singular name ──────────────────────────────────────────────
|
|
135
127
|
const singular = config.singular || deriveSingular(tableName);
|
|
136
|
-
|
|
137
|
-
// ── Default ORDER BY ──────────────────────────────────────────────────
|
|
138
128
|
const hasCreated = rows.some(r => r.COLUMN_NAME === '_created');
|
|
139
129
|
const defaultOrder = hasCreated ? '`_created` DESC' : undefined;
|
|
140
130
|
|
|
131
|
+
const ownerColumn = resolveOwnerColumn(config, opts.ownerColumn);
|
|
132
|
+
if (ownerColumn && !rows.some(r => r.COLUMN_NAME === ownerColumn)) {
|
|
133
|
+
throw new Error(`[MCP] Table '${tableName}' is owner-scoped on '${ownerColumn}' but no such column exists in '${dbName}'`);
|
|
134
|
+
}
|
|
135
|
+
|
|
141
136
|
tables.push({
|
|
142
137
|
name: tableName,
|
|
143
138
|
singular,
|
|
@@ -149,6 +144,7 @@ export async function resolveSchemas(db, tableConfigs, opts = {}) {
|
|
|
149
144
|
softDeleteSet,
|
|
150
145
|
operations: config.operations || ['list', 'get', 'add', 'update'],
|
|
151
146
|
listOrderBy: config.listOrderBy || defaultOrder,
|
|
147
|
+
ownerColumn,
|
|
152
148
|
columns,
|
|
153
149
|
...(config.customDescriptions && { customDescriptions: config.customDescriptions }),
|
|
154
150
|
});
|
|
@@ -178,37 +174,16 @@ function buildColumn(row, pkName) {
|
|
|
178
174
|
type: resolveType(row),
|
|
179
175
|
};
|
|
180
176
|
|
|
181
|
-
|
|
182
|
-
if (row.
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
// Auto-increment → readOnly
|
|
187
|
-
if (row.EXTRA?.includes('auto_increment')) {
|
|
188
|
-
col.readOnly = true;
|
|
189
|
-
}
|
|
177
|
+
if (row.COLUMN_NAME === pkName) col.readOnly = true;
|
|
178
|
+
if (row.EXTRA?.includes('auto_increment')) col.readOnly = true;
|
|
179
|
+
if (row.COLUMN_NAME === '_created') col.autoSet = 'created_at';
|
|
180
|
+
if (META_FIELDS.has(row.COLUMN_NAME)) col.readOnly = true;
|
|
181
|
+
if (row.IS_NULLABLE === 'YES') col.nullable = true;
|
|
190
182
|
|
|
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
183
|
if (row.COLUMN_DEFAULT !== null && row.COLUMN_DEFAULT !== undefined) {
|
|
208
184
|
col.default = parseDefault(row);
|
|
209
185
|
}
|
|
210
186
|
|
|
211
|
-
// Required: NOT NULL, no default, not auto-set, not PK, not meta
|
|
212
187
|
if (row.IS_NULLABLE === 'NO'
|
|
213
188
|
&& row.COLUMN_DEFAULT === null
|
|
214
189
|
&& !col.readOnly
|
|
@@ -217,14 +192,12 @@ function buildColumn(row, pkName) {
|
|
|
217
192
|
col.required = true;
|
|
218
193
|
}
|
|
219
194
|
|
|
220
|
-
// Filterable: indexed columns and guid/FK references
|
|
221
195
|
if (row.COLUMN_KEY === 'MUL'
|
|
222
196
|
|| (row.COLUMN_NAME.endsWith('_id') && row.COLUMN_NAME !== pkName)
|
|
223
197
|
|| (row.COLUMN_NAME === 'guid' && row.COLUMN_NAME !== pkName)) {
|
|
224
198
|
col.filterable = true;
|
|
225
199
|
}
|
|
226
200
|
|
|
227
|
-
// Enum values
|
|
228
201
|
if (row.DATA_TYPE === 'enum') {
|
|
229
202
|
col.enumValues = parseEnum(row.COLUMN_TYPE);
|
|
230
203
|
}
|
|
@@ -232,19 +205,11 @@ function buildColumn(row, pkName) {
|
|
|
232
205
|
return col;
|
|
233
206
|
}
|
|
234
207
|
|
|
235
|
-
// ── Type resolution ──────────────────────────────────────────────────────────
|
|
236
|
-
|
|
237
208
|
function resolveType(row) {
|
|
238
|
-
// tinyint(1) → boolean
|
|
239
209
|
if (row.COLUMN_TYPE === 'tinyint(1)') return 'boolean';
|
|
240
|
-
|
|
241
|
-
// bigint for timestamp columns → timestamp (epoch ms)
|
|
242
210
|
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
211
|
if (row.COLUMN_NAME === 'guid') return 'string';
|
|
246
|
-
|
|
247
|
-
return MYSQL_TYPE_MAP[row.DATA_TYPE] || 'string';
|
|
212
|
+
return DATATYPE_MAP[row.DATA_TYPE] || 'string';
|
|
248
213
|
}
|
|
249
214
|
|
|
250
215
|
function parseEnum(columnType) {
|
|
@@ -268,6 +233,11 @@ function parseDefault(row) {
|
|
|
268
233
|
|
|
269
234
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
270
235
|
|
|
236
|
+
/** Owner column for a table: per-table override → app default → none (null). */
|
|
237
|
+
function resolveOwnerColumn(config, appDefault) {
|
|
238
|
+
return config.ownerColumn !== undefined ? config.ownerColumn : (appDefault ?? null);
|
|
239
|
+
}
|
|
240
|
+
|
|
271
241
|
function deriveSingular(name) {
|
|
272
242
|
if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
|
|
273
243
|
if (name.endsWith('ses')) return name.slice(0, -2);
|
|
@@ -37,14 +37,26 @@ function zodFor(col, opts = {}) {
|
|
|
37
37
|
return s;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
/** Get writable columns (exclude readOnly, autoSet, and
|
|
40
|
+
/** Get writable columns (exclude readOnly, autoSet, primaryKey, and the owner column) */
|
|
41
41
|
function writableCols(table) {
|
|
42
|
-
return table.columns.filter(c =>
|
|
42
|
+
return table.columns.filter(c =>
|
|
43
|
+
!c.readOnly && !c.autoSet && c.name !== table.primaryKey && c.name !== table.ownerColumn);
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
/** Get filterable columns */
|
|
46
|
+
/** Get filterable columns (the owner column is implicit, never a caller filter) */
|
|
46
47
|
function filterCols(table) {
|
|
47
|
-
return table.columns.filter(c => c.filterable);
|
|
48
|
+
return table.columns.filter(c => c.filterable && c.name !== table.ownerColumn);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** WHERE clause + params targeting one row by PK, scoped to the owner when set. */
|
|
52
|
+
function pkWhere(table, pkVal, owner) {
|
|
53
|
+
let clause = `${q(table.primaryKey)} = ?`;
|
|
54
|
+
const params = [pkVal];
|
|
55
|
+
if (owner) {
|
|
56
|
+
clause += ` AND ${q(owner.col)} = ?`;
|
|
57
|
+
params.push(owner.id);
|
|
58
|
+
}
|
|
59
|
+
return { clause, params };
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
/** Build PK Zod schema */
|
|
@@ -61,24 +73,35 @@ function pkSchema(table) {
|
|
|
61
73
|
* @param {{ query: (sql: string, params: any[]) => Promise<[any[], any]> }} db
|
|
62
74
|
* — any object with query(sql, params) → [rows, fields]
|
|
63
75
|
* @param {import('./types.js').TableSchema} table
|
|
76
|
+
* @param {{ userId: string }} [identity] - Caller identity; required for owner-scoped tables.
|
|
64
77
|
*/
|
|
65
|
-
export function registerTableTools(server, db, table) {
|
|
78
|
+
export function registerTableTools(server, db, table, identity) {
|
|
66
79
|
const display = table.displayName || table.singular;
|
|
67
80
|
|
|
81
|
+
// Row-level ownership: scope every query to the calling user. Fail closed —
|
|
82
|
+
// an owner-scoped table is never served without an identity.
|
|
83
|
+
let owner = null;
|
|
84
|
+
if (table.ownerColumn) {
|
|
85
|
+
if (!identity?.userId) {
|
|
86
|
+
throw new Error(`[MCP] '${table.name}' is owner-scoped but no user identity was provided`);
|
|
87
|
+
}
|
|
88
|
+
owner = { col: table.ownerColumn, id: identity.userId };
|
|
89
|
+
}
|
|
90
|
+
|
|
68
91
|
for (const op of table.operations) {
|
|
69
92
|
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;
|
|
93
|
+
case 'list': regList(server, db, table, display, owner); break;
|
|
94
|
+
case 'get': regGet(server, db, table, display, owner); break;
|
|
95
|
+
case 'add': regAdd(server, db, table, display, owner); break;
|
|
96
|
+
case 'update': regUpdate(server, db, table, display, owner); break;
|
|
97
|
+
case 'delete': regDelete(server, db, table, display, owner); break;
|
|
75
98
|
}
|
|
76
99
|
}
|
|
77
100
|
}
|
|
78
101
|
|
|
79
102
|
// ── LIST ──────────────────────────────────────────────────────────────────────
|
|
80
103
|
|
|
81
|
-
function regList(server, db, table, display) {
|
|
104
|
+
function regList(server, db, table, display, owner) {
|
|
82
105
|
const filters = filterCols(table);
|
|
83
106
|
const inputSchema = {};
|
|
84
107
|
for (const f of filters) {
|
|
@@ -96,6 +119,10 @@ function regList(server, db, table, display) {
|
|
|
96
119
|
const vals = [];
|
|
97
120
|
|
|
98
121
|
if (table.softDeleteFilter) where.push(table.softDeleteFilter);
|
|
122
|
+
if (owner) {
|
|
123
|
+
where.push(`${q(owner.col)} = ?`);
|
|
124
|
+
vals.push(owner.id);
|
|
125
|
+
}
|
|
99
126
|
|
|
100
127
|
for (const f of filters) {
|
|
101
128
|
if (args[f.name] !== undefined && args[f.name] !== null) {
|
|
@@ -121,7 +148,7 @@ function regList(server, db, table, display) {
|
|
|
121
148
|
|
|
122
149
|
// ── GET ───────────────────────────────────────────────────────────────────────
|
|
123
150
|
|
|
124
|
-
function regGet(server, db, table, display) {
|
|
151
|
+
function regGet(server, db, table, display, owner) {
|
|
125
152
|
server.registerTool(`get_${table.singular}`, {
|
|
126
153
|
title: `Get ${display}`,
|
|
127
154
|
description: table.customDescriptions?.get
|
|
@@ -129,9 +156,10 @@ function regGet(server, db, table, display) {
|
|
|
129
156
|
inputSchema: { [table.primaryKey]: pkSchema(table) },
|
|
130
157
|
}, async (args) => {
|
|
131
158
|
try {
|
|
159
|
+
const { clause, params } = pkWhere(table, args[table.primaryKey], owner);
|
|
132
160
|
const [rows] = await db.query(
|
|
133
|
-
`SELECT * FROM ${q(table.name)} WHERE ${
|
|
134
|
-
|
|
161
|
+
`SELECT * FROM ${q(table.name)} WHERE ${clause} LIMIT 1`,
|
|
162
|
+
params,
|
|
135
163
|
);
|
|
136
164
|
const row = rows[0] || null;
|
|
137
165
|
return {
|
|
@@ -146,7 +174,7 @@ function regGet(server, db, table, display) {
|
|
|
146
174
|
|
|
147
175
|
// ── ADD ───────────────────────────────────────────────────────────────────────
|
|
148
176
|
|
|
149
|
-
function regAdd(server, db, table, display) {
|
|
177
|
+
function regAdd(server, db, table, display, owner) {
|
|
150
178
|
const cols = writableCols(table);
|
|
151
179
|
const inputSchema = {};
|
|
152
180
|
for (const c of cols) {
|
|
@@ -182,6 +210,13 @@ function regAdd(server, db, table, display) {
|
|
|
182
210
|
placeholders.push('?');
|
|
183
211
|
}
|
|
184
212
|
|
|
213
|
+
// Owner scope — force the owning user; never trust client input
|
|
214
|
+
if (owner) {
|
|
215
|
+
insertCols.push(q(owner.col));
|
|
216
|
+
insertVals.push(owner.id);
|
|
217
|
+
placeholders.push('?');
|
|
218
|
+
}
|
|
219
|
+
|
|
185
220
|
// User-provided columns
|
|
186
221
|
for (const c of cols) {
|
|
187
222
|
if (args[c.name] !== undefined) {
|
|
@@ -213,7 +248,7 @@ function regAdd(server, db, table, display) {
|
|
|
213
248
|
|
|
214
249
|
// ── UPDATE ────────────────────────────────────────────────────────────────────
|
|
215
250
|
|
|
216
|
-
function regUpdate(server, db, table, display) {
|
|
251
|
+
function regUpdate(server, db, table, display, owner) {
|
|
217
252
|
const cols = writableCols(table);
|
|
218
253
|
const inputSchema = { [table.primaryKey]: pkSchema(table) };
|
|
219
254
|
for (const c of cols) {
|
|
@@ -227,7 +262,6 @@ function regUpdate(server, db, table, display) {
|
|
|
227
262
|
inputSchema,
|
|
228
263
|
}, async (args) => {
|
|
229
264
|
try {
|
|
230
|
-
const pkVal = args[table.primaryKey];
|
|
231
265
|
const setClauses = [];
|
|
232
266
|
const vals = [];
|
|
233
267
|
|
|
@@ -242,15 +276,15 @@ function regUpdate(server, db, table, display) {
|
|
|
242
276
|
return { content: [{ type: 'text', text: 'No fields to update.' }] };
|
|
243
277
|
}
|
|
244
278
|
|
|
245
|
-
|
|
279
|
+
const target = pkWhere(table, args[table.primaryKey], owner);
|
|
246
280
|
await db.query(
|
|
247
|
-
`UPDATE ${q(table.name)} SET ${setClauses.join(', ')} WHERE ${
|
|
248
|
-
vals,
|
|
281
|
+
`UPDATE ${q(table.name)} SET ${setClauses.join(', ')} WHERE ${target.clause}`,
|
|
282
|
+
[...vals, ...target.params],
|
|
249
283
|
);
|
|
250
284
|
|
|
251
285
|
const [rows] = await db.query(
|
|
252
|
-
`SELECT * FROM ${q(table.name)} WHERE ${
|
|
253
|
-
|
|
286
|
+
`SELECT * FROM ${q(table.name)} WHERE ${target.clause}`,
|
|
287
|
+
target.params,
|
|
254
288
|
);
|
|
255
289
|
|
|
256
290
|
return {
|
|
@@ -265,7 +299,7 @@ function regUpdate(server, db, table, display) {
|
|
|
265
299
|
|
|
266
300
|
// ── DELETE ────────────────────────────────────────────────────────────────────
|
|
267
301
|
|
|
268
|
-
function regDelete(server, db, table, display) {
|
|
302
|
+
function regDelete(server, db, table, display, owner) {
|
|
269
303
|
server.registerTool(`delete_${table.singular}`, {
|
|
270
304
|
title: `Delete ${display}`,
|
|
271
305
|
description: table.customDescriptions?.delete
|
|
@@ -273,17 +307,17 @@ function regDelete(server, db, table, display) {
|
|
|
273
307
|
inputSchema: { [table.primaryKey]: pkSchema(table) },
|
|
274
308
|
}, async (args) => {
|
|
275
309
|
try {
|
|
276
|
-
const
|
|
310
|
+
const { clause, params } = pkWhere(table, args[table.primaryKey], owner);
|
|
277
311
|
|
|
278
312
|
if (table.softDeleteSet) {
|
|
279
313
|
await db.query(
|
|
280
|
-
`UPDATE ${q(table.name)} SET ${table.softDeleteSet} WHERE ${
|
|
281
|
-
|
|
314
|
+
`UPDATE ${q(table.name)} SET ${table.softDeleteSet} WHERE ${clause}`,
|
|
315
|
+
params,
|
|
282
316
|
);
|
|
283
317
|
} else {
|
|
284
318
|
await db.query(
|
|
285
|
-
`DELETE FROM ${q(table.name)} WHERE ${
|
|
286
|
-
|
|
319
|
+
`DELETE FROM ${q(table.name)} WHERE ${clause}`,
|
|
320
|
+
params,
|
|
287
321
|
);
|
|
288
322
|
}
|
|
289
323
|
|
package/src/engine/types.js
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
* @property {ColumnDef[]} columns
|
|
24
24
|
* @property {('list'|'get'|'add'|'update'|'delete')[]} operations
|
|
25
25
|
* @property {string} [listOrderBy] - Default ORDER BY clause (use backticks for reserved words)
|
|
26
|
+
* @property {string|null} [ownerColumn] - Row-level owner column; every query is scoped to the caller on it
|
|
26
27
|
* @property {Object} [customDescriptions] - Override tool descriptions per operation
|
|
27
28
|
*
|
|
28
29
|
* @typedef {Object} DbConfig
|
|
@@ -40,6 +41,7 @@
|
|
|
40
41
|
* @property {string} displayName - Human-readable (e.g., 'PAAAL Coach')
|
|
41
42
|
* @property {string} version
|
|
42
43
|
* @property {DbConfig} db
|
|
44
|
+
* @property {string} [ownerColumn] - App-wide default owner column for row-level scoping (per-table `ownerColumn` overrides)
|
|
43
45
|
* @property {TableSchema[]} tables
|
|
44
46
|
*/
|
|
45
47
|
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* await app.register(mcpPlugin, {
|
|
10
10
|
* apps: { paaal: paaalConfig },
|
|
11
11
|
* getDb: (appName, env) => getAppDbService(appName, env),
|
|
12
|
+
* getUser: (appName, env, sid) => resolveSession(appName, env, sid), // → { userId } | null
|
|
12
13
|
* });
|
|
13
14
|
*
|
|
14
15
|
* Route param format:
|
|
@@ -40,6 +41,26 @@ function parseAppParam(param) {
|
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
/** Read the Bearer token (an icode session id) from the Authorization header. */
|
|
45
|
+
function readBearer(req) {
|
|
46
|
+
const header = req.headers['authorization'];
|
|
47
|
+
if (typeof header !== 'string') return null;
|
|
48
|
+
const [scheme, token] = header.split(/\s+/, 2);
|
|
49
|
+
return /^bearer$/i.test(scheme) && token ? token.trim() : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** MCP-spec 401 — points the client at the protected-resource metadata (Login with icode). */
|
|
53
|
+
function unauthorized(req, reply) {
|
|
54
|
+
const proto = req.headers['x-forwarded-proto'] || req.protocol || 'https';
|
|
55
|
+
const host = req.headers['x-forwarded-host'] || req.headers.host;
|
|
56
|
+
reply.header('WWW-Authenticate', `Bearer resource_metadata="${proto}://${host}/.well-known/oauth-protected-resource"`);
|
|
57
|
+
return reply.code(401).send({
|
|
58
|
+
jsonrpc: '2.0',
|
|
59
|
+
error: { code: -32001, message: 'Authentication required' },
|
|
60
|
+
id: null,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
43
64
|
/**
|
|
44
65
|
* @param {import('fastify').FastifyInstance} fastify
|
|
45
66
|
* @param {Object} opts
|
|
@@ -47,16 +68,38 @@ function parseAppParam(param) {
|
|
|
47
68
|
* — app configs keyed by appName: { paaal: paaalConfig }
|
|
48
69
|
* @param {(appName: string, env: string) => Promise<{ query: Function }>} opts.getDb
|
|
49
70
|
* — DB provider function, e.g. (name, env) => getAppDbService(name, env)
|
|
71
|
+
* @param {(db, tableConfigs, opts) => Promise<TableSchema[]>} [opts.resolveSchemas]
|
|
72
|
+
* — Custom schema resolver. Default: INFORMATION_SCHEMA.
|
|
73
|
+
* — Inject your own to use SchemasService or any other source.
|
|
74
|
+
* @param {(appName: string, env: string, sid: string) => Promise<{ userId: string } | null>} [opts.getUser]
|
|
75
|
+
* — Resolve a Bearer session id to the caller's identity. When provided, the MCP
|
|
76
|
+
* endpoint requires a valid session and scopes data to that user; when omitted,
|
|
77
|
+
* requests are unauthenticated (owner-scoped tables then fail closed).
|
|
50
78
|
* @param {string} [opts.prefix='/v1/mcp']
|
|
51
79
|
* @param {boolean} [opts.public=true]
|
|
52
80
|
*/
|
|
53
81
|
export default async function mcpPlugin(fastify, opts = {}) {
|
|
54
82
|
const getDb = opts.getDb;
|
|
83
|
+
const getUser = opts.getUser;
|
|
84
|
+
const resolveSchemas = opts.resolveSchemas;
|
|
55
85
|
const prefix = opts.prefix || '/v1/mcp';
|
|
56
86
|
const isPublic = opts.public ?? true;
|
|
57
87
|
|
|
58
88
|
if (!getDb) throw new Error('icode-mcp-adapter: opts.getDb is required');
|
|
59
89
|
|
|
90
|
+
// Resolve the request's Bearer session id to { userId } via the injected getUser.
|
|
91
|
+
// Null when no getUser is configured, no token is sent, or the session is invalid.
|
|
92
|
+
async function identityFor(req, appName, env) {
|
|
93
|
+
if (!getUser) return null;
|
|
94
|
+
const sid = readBearer(req);
|
|
95
|
+
if (!sid) return null;
|
|
96
|
+
try {
|
|
97
|
+
return await getUser(appName, env, sid);
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
60
103
|
// Build lookup: key = "appName--env" (or "appName--prod" for default)
|
|
61
104
|
const apps = new Map();
|
|
62
105
|
for (const config of Object.values(opts.apps || {})) {
|
|
@@ -77,25 +120,41 @@ export default async function mcpPlugin(fastify, opts = {}) {
|
|
|
77
120
|
|
|
78
121
|
const sessionId = req.headers['mcp-session-id'];
|
|
79
122
|
|
|
80
|
-
// Existing session
|
|
123
|
+
// Existing session — re-check the caller still owns it (revoked sessions drop here)
|
|
81
124
|
if (sessionId && sessions.has(sessionId)) {
|
|
82
|
-
|
|
125
|
+
const session = sessions.get(sessionId);
|
|
126
|
+
if (getUser) {
|
|
127
|
+
const identity = await identityFor(req, appName, env);
|
|
128
|
+
if (!identity?.userId || identity.userId !== session.userId) {
|
|
129
|
+
return unauthorized(req, reply);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
await session.transport.handleRequest(req.raw, reply.raw, req.body);
|
|
83
133
|
return reply.hijack();
|
|
84
134
|
}
|
|
85
135
|
|
|
86
136
|
// New session (must be initialize request)
|
|
87
137
|
if (!sessionId && isInitializeRequest(req.body)) {
|
|
138
|
+
const identity = await identityFor(req, appName, env);
|
|
139
|
+
if (getUser && !identity?.userId) return unauthorized(req, reply);
|
|
140
|
+
|
|
88
141
|
let db;
|
|
89
142
|
try {
|
|
90
143
|
db = await getDb(appName, env);
|
|
91
144
|
} catch (err) {
|
|
92
145
|
return reply.code(400).send({ ok: false, error: err.message || err.sqlMessage || 'Failed to get DB connection' });
|
|
93
146
|
}
|
|
94
|
-
|
|
147
|
+
|
|
148
|
+
let server;
|
|
149
|
+
try {
|
|
150
|
+
server = await createMcpServerAuto(appConfig, db, { resolveSchemas, identity });
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return reply.code(500).send({ ok: false, error: err.message || 'Failed to initialize MCP server' });
|
|
153
|
+
}
|
|
95
154
|
|
|
96
155
|
const transport = new StreamableHTTPServerTransport({
|
|
97
156
|
sessionIdGenerator: () => randomUUID(),
|
|
98
|
-
onsessioninitialized: (id) => sessions.set(id, { transport, server }),
|
|
157
|
+
onsessioninitialized: (id) => sessions.set(id, { transport, server, userId: identity?.userId }),
|
|
99
158
|
});
|
|
100
159
|
transport.onclose = () => {
|
|
101
160
|
if (transport.sessionId) sessions.delete(transport.sessionId);
|