@gainable.dev/mcp-server 0.2.0 → 0.2.1

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.
@@ -33,27 +33,34 @@ export async function preWarmSchemaCache(appName, allowedDatasets) {
33
33
  return;
34
34
  try {
35
35
  const collections = await schemaDb.listCollections().toArray();
36
+ const sizeBefore = schemaCache.size;
37
+ let matched = 0;
36
38
  for (const col of collections) {
37
39
  const name = col.name;
38
40
  // App's custom collections
39
41
  if (name.startsWith(`${appName}_`)) {
40
42
  const cleanName = name.slice(appName.length + 1);
41
43
  await getCollectionSchema(name, cleanName);
44
+ matched++;
42
45
  continue;
43
46
  }
44
47
  // Allowed data collections
45
48
  for (const datasetId of allowedDatasets) {
46
49
  if (name === `data_${datasetId}`) {
47
50
  await getCollectionSchema(name, datasetId);
51
+ matched++;
48
52
  break;
49
53
  }
50
54
  }
51
55
  // Core collections (e.g. users)
52
56
  if (CORE_COLLECTIONS.includes(name)) {
53
57
  await getCollectionSchema(name, name);
58
+ matched++;
54
59
  }
55
60
  }
56
- console.log(`[Schema] Pre-warmed cache for ${schemaCache.size} collections`);
61
+ const cached = schemaCache.size - sizeBefore;
62
+ const skipped = matched - cached;
63
+ console.log(`[Schema] Pre-warmed schema for ${cached} collections${skipped > 0 ? ` (${skipped} empty collection${skipped > 1 ? 's' : ''} skipped)` : ''}`);
57
64
  }
58
65
  catch (err) {
59
66
  console.warn('[Schema] Pre-warm failed:', err);
@@ -64,10 +71,11 @@ export async function preWarmSchemaCache(appName, allowedDatasets) {
64
71
  * Called from createServerForRequest() so schemas are ready before the first query.
65
72
  */
66
73
  export async function ensureSchemaCacheWarmed(appName, allowedDatasets) {
67
- if (warmedApps.has(appName))
74
+ const key = `${appName}:${allowedDatasets.sort().join(',')}`;
75
+ if (warmedApps.has(key))
68
76
  return;
69
77
  await preWarmSchemaCache(appName, allowedDatasets);
70
- warmedApps.add(appName);
78
+ warmedApps.add(key);
71
79
  }
72
80
  /**
73
81
  * Infer schema from a collection by sampling documents.
@@ -4,22 +4,18 @@ import type { BaseConfig } from './config.js';
4
4
  * Extends StreamableHttpRunner for per-account MCP.
5
5
  *
6
6
  * Each request includes `?app={appName}&agent={agentId}` — both required.
7
- * The runner auto-discovers datasets from the account DB and builds
8
- * per-request scoping config.
7
+ * Attached data collections are read from `app_config` in the account DB per request.
9
8
  */
10
9
  export declare class GainableHttpRunner extends StreamableHttpRunner {
11
10
  private baseConfig;
12
11
  private scopeClient;
13
- /** Cached datasets per account (auto-discovered from datamodels collection). TTL 60s. */
14
- private datasetsCache;
15
- private static readonly DATASETS_TTL_MS;
16
12
  constructor(config: ConstructorParameters<typeof StreamableHttpRunner>[0], baseConfig: BaseConfig);
17
13
  private getClient;
18
14
  /**
19
- * Auto-discover allowed datasets from the datamodels collection.
20
- * Cached for 60s per process (all apps in an account share the same datasets).
15
+ * Look up attached data collections for an app from the app_config collection.
16
+ * Returns array of collection IDs (e.g. ["col_xxx", "col_yyy"]).
21
17
  */
22
- private discoverDatasets;
18
+ private getAppDatasets;
23
19
  /**
24
20
  * Look up an agent's scopes from MongoDB.
25
21
  * Returns the scopes array if the agent has restrictions, or undefined if unrestricted.
@@ -6,15 +6,11 @@ import { ensureSchemaCacheWarmed } from './collectionSchema.js';
6
6
  * Extends StreamableHttpRunner for per-account MCP.
7
7
  *
8
8
  * Each request includes `?app={appName}&agent={agentId}` — both required.
9
- * The runner auto-discovers datasets from the account DB and builds
10
- * per-request scoping config.
9
+ * Attached data collections are read from `app_config` in the account DB per request.
11
10
  */
12
11
  export class GainableHttpRunner extends StreamableHttpRunner {
13
12
  baseConfig;
14
13
  scopeClient = null;
15
- /** Cached datasets per account (auto-discovered from datamodels collection). TTL 60s. */
16
- datasetsCache = null;
17
- static DATASETS_TTL_MS = 60_000;
18
14
  constructor(config, baseConfig) {
19
15
  super(config);
20
16
  this.baseConfig = baseConfig;
@@ -27,26 +23,14 @@ export class GainableHttpRunner extends StreamableHttpRunner {
27
23
  return this.scopeClient;
28
24
  }
29
25
  /**
30
- * Auto-discover allowed datasets from the datamodels collection.
31
- * Cached for 60s per process (all apps in an account share the same datasets).
26
+ * Look up attached data collections for an app from the app_config collection.
27
+ * Returns array of collection IDs (e.g. ["col_xxx", "col_yyy"]).
32
28
  */
33
- async discoverDatasets() {
34
- if (this.datasetsCache && Date.now() - this.datasetsCache.fetchedAt < GainableHttpRunner.DATASETS_TTL_MS) {
35
- return this.datasetsCache.datasets;
36
- }
37
- try {
38
- const client = await this.getClient();
39
- const db = client.db(this.baseConfig.accountId);
40
- const models = await db.collection('datamodels').find({}, { projection: { modelId: 1 } }).toArray();
41
- const datasets = models.map(m => m.modelId).filter(Boolean);
42
- this.datasetsCache = { datasets, fetchedAt: Date.now() };
43
- console.log(`[MCP] Discovered ${datasets.length} datasets for account ${this.baseConfig.accountId}`);
44
- return datasets;
45
- }
46
- catch (err) {
47
- console.warn('[MCP] Failed to discover datasets:', err);
48
- return this.datasetsCache?.datasets ?? [];
49
- }
29
+ async getAppDatasets(appName) {
30
+ const client = await this.getClient();
31
+ const db = client.db(this.baseConfig.accountId);
32
+ const config = await db.collection('app_config').findOne({ appId: appName }, { projection: { attachedCollections: 1 } });
33
+ return config?.attachedCollections || [];
50
34
  }
51
35
  /**
52
36
  * Look up an agent's scopes from MongoDB.
@@ -72,14 +56,13 @@ export class GainableHttpRunner extends StreamableHttpRunner {
72
56
  if (!agentId) {
73
57
  throw new Error('Missing required query parameter: agent');
74
58
  }
75
- // Auto-discover datasets and look up agent scopes in parallel
76
- const [allowedDatasets, agentCollections] = await Promise.all([
77
- this.discoverDatasets(),
78
- this.getAgentScopes(appName, agentId),
79
- ]);
59
+ // Look up attached collections from app_config in account DB
60
+ const allowedDatasets = await this.getAppDatasets(appName);
61
+ // Look up agent scopes
62
+ const agentCollections = await this.getAgentScopes(appName, agentId);
80
63
  // Lazy schema warming for this app
81
64
  await ensureSchemaCacheWarmed(appName, allowedDatasets);
82
- console.log(`[MCP Session] app=${appName} | agent=${agentId} | datasets=${allowedDatasets.length} | scoped=${agentCollections ? 'yes' : 'no'}${agentCollections ? ` | collections=${JSON.stringify(agentCollections)}` : ''}`);
65
+ console.log(`[MCP Session] app=${appName} | agent=${agentId} | datasets=${allowedDatasets.length > 0 ? allowedDatasets.join(',') : '(none)'} | scoped=${agentCollections ? 'yes' : 'no'}${agentCollections ? ` | collections=${JSON.stringify(agentCollections)}` : ''}`);
83
66
  // Build per-request scoping config
84
67
  const scopingConfig = {
85
68
  ...this.baseConfig,
@@ -123,8 +123,9 @@ function filterAndRenameCollections(collections, appName, allowedDatasets, agent
123
123
  // Everything else is hidden
124
124
  }
125
125
  // Agent-level restriction: filter to only allowed collections
126
+ // Core collections (users, userfieldmetadatas) are always included
126
127
  if (agentCollections) {
127
- return results.filter(col => agentCollections.includes(col.name));
128
+ return results.filter(col => col.type === 'core' || agentCollections.includes(col.name));
128
129
  }
129
130
  return results;
130
131
  }
@@ -1,26 +1,27 @@
1
1
  export const CORE_EXPOSED = ['users', 'userfieldmetadatas'];
2
2
  const BLOCKED = ['agents', 'connections', 'emailtemplates', 'emaillogs', 'datamodels'];
3
3
  export function resolveCollection(cleanName, appName, allowedDatasets, agentCollections) {
4
- // 0. Agent-level restriction: if agent has a scoped list, check it first
5
- if (agentCollections && !agentCollections.includes(cleanName)) {
6
- throw new Error(`Collection '${cleanName}' is not accessible`);
7
- }
8
- // 1. Core collections — name stays as-is
4
+ // 1. Core collections always accessible regardless of agent scoping
9
5
  if (CORE_EXPOSED.includes(cleanName)) {
10
6
  return { realName: cleanName, type: 'core', writable: true };
11
7
  }
12
- // 2. Data collections check allowed datasets
8
+ // 2. Agent-level restriction: if agent has a scoped list, check it
9
+ // (core collections above are always allowed)
10
+ if (agentCollections && !agentCollections.includes(cleanName)) {
11
+ throw new Error(`Collection '${cleanName}' is not accessible`);
12
+ }
13
+ // 3. Data collections — check allowed datasets
13
14
  for (const datasetId of allowedDatasets) {
14
15
  const realName = `data_${datasetId}`;
15
16
  if (cleanName === realName || cleanName === datasetId) {
16
17
  return { realName, type: 'data', writable: false };
17
18
  }
18
19
  }
19
- // 3. Blocked core/system collections
20
+ // 4. Blocked core/system collections
20
21
  if (BLOCKED.includes(cleanName)) {
21
22
  throw new Error(`Collection '${cleanName}' is not accessible`);
22
23
  }
23
- // 4. Custom collections — add app prefix
24
+ // 5. Custom collections — add app prefix
24
25
  const prefixed = `${appName}_${cleanName}`;
25
26
  return { realName: prefixed, type: 'custom', writable: true };
26
27
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gainable.dev/mcp-server",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Scoped MCP server for Gainable in-app copilot agents — wraps mongodb-mcp-server with per-app data isolation",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",