icode-mcp-adapter 1.0.5 → 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.5",
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",
@@ -7,16 +7,17 @@ import { resolveSchemas as defaultResolveSchemas } from './SchemaAdapter.js';
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;
@@ -27,22 +28,24 @@ export function createMcpServer(config, db) {
27
28
  *
28
29
  * @param {Object} config - app config with tables
29
30
  * @param {{ query: Function }} db - Pool or SQLService
30
- * @param {{ resolveSchemas?: Function }} [opts]
31
+ * @param {{ resolveSchemas?: Function, identity?: { userId: string } }} [opts]
31
32
  * resolveSchemas: (db, tableConfigs, opts) => Promise<TableSchema[]>
32
33
  * Defaults to INFORMATION_SCHEMA-based resolver.
33
34
  * Inject your own to use icode-server's SchemasService or any other source.
35
+ * identity: caller identity used to scope owner-scoped tables.
34
36
  * @returns {Promise<McpServer>}
35
37
  */
36
38
  export async function createMcpServerAuto(config, db, opts = {}) {
37
39
  const resolveSchemas = opts.resolveSchemas || defaultResolveSchemas;
38
40
 
39
41
  const tables = await resolveSchemas(db, config.tables, {
40
- cacheKey: config.appName,
42
+ cacheKey: `${config.appName}--${config.env || 'prod'}`,
43
+ ownerColumn: config.ownerColumn,
41
44
  });
42
45
 
43
46
  return createMcpServer({
44
47
  appName: config.appName,
45
48
  version: config.version || '1.0.0',
46
49
  tables,
47
- }, db);
50
+ }, db, opts.identity);
48
51
  }
@@ -57,7 +57,8 @@ const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
57
57
  *
58
58
  * @param {{ query: Function }} db - Pool or SQLService
59
59
  * @param {Record<string, TableConfig>} tableConfigs
60
- * @param {{ cacheKey?: string }} [opts]
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
  */
63
64
  export async function resolveSchemas(db, tableConfigs, opts = {}) {
@@ -127,6 +128,11 @@ export async function resolveSchemas(db, tableConfigs, opts = {}) {
127
128
  const hasCreated = rows.some(r => r.COLUMN_NAME === '_created');
128
129
  const defaultOrder = hasCreated ? '`_created` DESC' : undefined;
129
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
+
130
136
  tables.push({
131
137
  name: tableName,
132
138
  singular,
@@ -138,6 +144,7 @@ export async function resolveSchemas(db, tableConfigs, opts = {}) {
138
144
  softDeleteSet,
139
145
  operations: config.operations || ['list', 'get', 'add', 'update'],
140
146
  listOrderBy: config.listOrderBy || defaultOrder,
147
+ ownerColumn,
141
148
  columns,
142
149
  ...(config.customDescriptions && { customDescriptions: config.customDescriptions }),
143
150
  });
@@ -226,6 +233,11 @@ function parseDefault(row) {
226
233
 
227
234
  // ── Helpers ──────────────────────────────────────────────────────────────────
228
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
+
229
241
  function deriveSingular(name) {
230
242
  if (name.endsWith('ies')) return name.slice(0, -3) + 'y';
231
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
@@ -50,17 +71,35 @@ function parseAppParam(param) {
50
71
  * @param {(db, tableConfigs, opts) => Promise<TableSchema[]>} [opts.resolveSchemas]
51
72
  * — Custom schema resolver. Default: INFORMATION_SCHEMA.
52
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).
53
78
  * @param {string} [opts.prefix='/v1/mcp']
54
79
  * @param {boolean} [opts.public=true]
55
80
  */
56
81
  export default async function mcpPlugin(fastify, opts = {}) {
57
82
  const getDb = opts.getDb;
83
+ const getUser = opts.getUser;
58
84
  const resolveSchemas = opts.resolveSchemas;
59
85
  const prefix = opts.prefix || '/v1/mcp';
60
86
  const isPublic = opts.public ?? true;
61
87
 
62
88
  if (!getDb) throw new Error('icode-mcp-adapter: opts.getDb is required');
63
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
+
64
103
  // Build lookup: key = "appName--env" (or "appName--prod" for default)
65
104
  const apps = new Map();
66
105
  for (const config of Object.values(opts.apps || {})) {
@@ -81,25 +120,41 @@ export default async function mcpPlugin(fastify, opts = {}) {
81
120
 
82
121
  const sessionId = req.headers['mcp-session-id'];
83
122
 
84
- // Existing session
123
+ // Existing session — re-check the caller still owns it (revoked sessions drop here)
85
124
  if (sessionId && sessions.has(sessionId)) {
86
- 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);
87
133
  return reply.hijack();
88
134
  }
89
135
 
90
136
  // New session (must be initialize request)
91
137
  if (!sessionId && isInitializeRequest(req.body)) {
138
+ const identity = await identityFor(req, appName, env);
139
+ if (getUser && !identity?.userId) return unauthorized(req, reply);
140
+
92
141
  let db;
93
142
  try {
94
143
  db = await getDb(appName, env);
95
144
  } catch (err) {
96
145
  return reply.code(400).send({ ok: false, error: err.message || err.sqlMessage || 'Failed to get DB connection' });
97
146
  }
98
- const server = await createMcpServerAuto(appConfig, db, { resolveSchemas });
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
+ }
99
154
 
100
155
  const transport = new StreamableHTTPServerTransport({
101
156
  sessionIdGenerator: () => randomUUID(),
102
- onsessioninitialized: (id) => sessions.set(id, { transport, server }),
157
+ onsessioninitialized: (id) => sessions.set(id, { transport, server, userId: identity?.userId }),
103
158
  });
104
159
  transport.onclose = () => {
105
160
  if (transport.sessionId) sessions.delete(transport.sessionId);