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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "icode-mcp-adapter",
3
- "version": "1.0.4",
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",
@@ -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 from DB).
27
+ * Create an MCP server from a LIGHTWEIGHT config (auto-resolve columns).
27
28
  *
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
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 the live database (INFORMATION_SCHEMA).
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`, `_createdBy`, `pk`, etc. → readOnly
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 MYSQL_TYPE_MAP = {
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
- // ── icode-server meta fields (auto-managed, always readOnly) ─────────────────
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 a lightweight config + live DB.
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 - lightweight per-table config
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
- Object.assign(col, config.columns[row.COLUMN_NAME]);
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
- // 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
- }
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 primaryKey) */
40
+ /** Get writable columns (exclude readOnly, autoSet, primaryKey, and the owner column) */
41
41
  function writableCols(table) {
42
- return table.columns.filter(c => !c.readOnly && !c.autoSet && c.name !== table.primaryKey);
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 ${q(table.primaryKey)} = ? LIMIT 1`,
134
- [args[table.primaryKey]],
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
- vals.push(pkVal);
279
+ const target = pkWhere(table, args[table.primaryKey], owner);
246
280
  await db.query(
247
- `UPDATE ${q(table.name)} SET ${setClauses.join(', ')} WHERE ${q(table.primaryKey)} = ?`,
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 ${q(table.primaryKey)} = ?`,
253
- [pkVal],
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 pkVal = args[table.primaryKey];
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 ${q(table.primaryKey)} = ?`,
281
- [pkVal],
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 ${q(table.primaryKey)} = ?`,
286
- [pkVal],
319
+ `DELETE FROM ${q(table.name)} WHERE ${clause}`,
320
+ params,
287
321
  );
288
322
  }
289
323
 
@@ -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
- await sessions.get(sessionId).transport.handleRequest(req.raw, reply.raw, req.body);
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
- const server = await createMcpServerAuto(appConfig, db);
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);