@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.
package/dist/collectionSchema.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
74
|
+
const key = `${appName}:${allowedDatasets.sort().join(',')}`;
|
|
75
|
+
if (warmedApps.has(key))
|
|
68
76
|
return;
|
|
69
77
|
await preWarmSchemaCache(appName, allowedDatasets);
|
|
70
|
-
warmedApps.add(
|
|
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
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
31
|
-
*
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
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
|
-
//
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
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,
|
package/dist/scopedProvider.js
CHANGED
|
@@ -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
|
-
//
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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