@gainable.dev/mcp-server 0.1.5 → 0.2.0
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.d.ts +5 -0
- package/dist/collectionSchema.js +14 -0
- package/dist/config.d.ts +18 -4
- package/dist/config.js +6 -9
- package/dist/gainableHttpRunner.d.ts +15 -6
- package/dist/gainableHttpRunner.js +79 -33
- package/dist/index.js +15 -21
- package/dist/stripUntrustedTags.d.ts +3 -1
- package/dist/stripUntrustedTags.js +8 -9
- package/package.json +1 -1
|
@@ -25,6 +25,11 @@ export declare function initSchemaInference(mongoUri: string, dbName: string): P
|
|
|
25
25
|
* This ensures synchronous cache lookups work from the very first query.
|
|
26
26
|
*/
|
|
27
27
|
export declare function preWarmSchemaCache(appName: string, allowedDatasets: string[]): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Lazy per-app schema warming. Only warms once per app per process lifetime.
|
|
30
|
+
* Called from createServerForRequest() so schemas are ready before the first query.
|
|
31
|
+
*/
|
|
32
|
+
export declare function ensureSchemaCacheWarmed(appName: string, allowedDatasets: string[]): Promise<void>;
|
|
28
33
|
/**
|
|
29
34
|
* Infer schema from a collection by sampling documents.
|
|
30
35
|
* Results are cached for the lifetime of the process.
|
package/dist/collectionSchema.js
CHANGED
|
@@ -4,6 +4,10 @@ import { CORE_EXPOSED as CORE_COLLECTIONS } from './scoping/index.js';
|
|
|
4
4
|
* Cache of inferred schemas keyed by real collection name.
|
|
5
5
|
*/
|
|
6
6
|
const schemaCache = new Map();
|
|
7
|
+
/**
|
|
8
|
+
* Track which apps have already been warmed to avoid redundant work.
|
|
9
|
+
*/
|
|
10
|
+
const warmedApps = new Set();
|
|
7
11
|
/**
|
|
8
12
|
* Singleton DB connection for schema inference.
|
|
9
13
|
*/
|
|
@@ -55,6 +59,16 @@ export async function preWarmSchemaCache(appName, allowedDatasets) {
|
|
|
55
59
|
console.warn('[Schema] Pre-warm failed:', err);
|
|
56
60
|
}
|
|
57
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* Lazy per-app schema warming. Only warms once per app per process lifetime.
|
|
64
|
+
* Called from createServerForRequest() so schemas are ready before the first query.
|
|
65
|
+
*/
|
|
66
|
+
export async function ensureSchemaCacheWarmed(appName, allowedDatasets) {
|
|
67
|
+
if (warmedApps.has(appName))
|
|
68
|
+
return;
|
|
69
|
+
await preWarmSchemaCache(appName, allowedDatasets);
|
|
70
|
+
warmedApps.add(appName);
|
|
71
|
+
}
|
|
58
72
|
/**
|
|
59
73
|
* Infer schema from a collection by sampling documents.
|
|
60
74
|
* Results are cached for the lifetime of the process.
|
package/dist/config.d.ts
CHANGED
|
@@ -1,9 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Process-level config: loaded once at startup from env vars.
|
|
3
|
+
* Shared across all requests (one MCP process per account).
|
|
4
|
+
*/
|
|
5
|
+
export interface BaseConfig {
|
|
3
6
|
accountId: string;
|
|
4
|
-
allowedDatasets: string[];
|
|
5
7
|
mongodbUri: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Per-request config: extends BaseConfig with app + agent scoping.
|
|
11
|
+
* Built dynamically for each incoming MCP request.
|
|
12
|
+
*/
|
|
13
|
+
export interface ScopingConfig extends BaseConfig {
|
|
14
|
+
appName: string;
|
|
15
|
+
allowedDatasets: string[];
|
|
6
16
|
/** Per-session field: which collections this agent can access. undefined = unrestricted. */
|
|
7
17
|
agentCollections?: string[];
|
|
8
18
|
}
|
|
9
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Load process-level config from environment variables.
|
|
21
|
+
* APP_NAME is no longer required at startup — it comes from the request query.
|
|
22
|
+
*/
|
|
23
|
+
export declare function loadBaseConfig(): BaseConfig;
|
package/dist/config.js
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Load process-level config from environment variables.
|
|
3
|
+
* APP_NAME is no longer required at startup — it comes from the request query.
|
|
4
|
+
*/
|
|
5
|
+
export function loadBaseConfig() {
|
|
3
6
|
const accountId = process.env.ACCOUNT_ID;
|
|
4
7
|
const mongodbUri = process.env.MONGODB_URI;
|
|
5
|
-
if (!appName)
|
|
6
|
-
throw new Error('APP_NAME is required');
|
|
7
8
|
if (!accountId)
|
|
8
9
|
throw new Error('ACCOUNT_ID is required');
|
|
9
10
|
if (!mongodbUri)
|
|
10
11
|
throw new Error('MONGODB_URI is required');
|
|
11
|
-
|
|
12
|
-
.split(',')
|
|
13
|
-
.map(s => s.trim())
|
|
14
|
-
.filter(Boolean);
|
|
15
|
-
return { appName, accountId, allowedDatasets, mongodbUri };
|
|
12
|
+
return { accountId, mongodbUri };
|
|
16
13
|
}
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
import { StreamableHttpRunner } from 'mongodb-mcp-server';
|
|
2
|
-
import type {
|
|
2
|
+
import type { BaseConfig } from './config.js';
|
|
3
3
|
/**
|
|
4
|
-
* Extends StreamableHttpRunner
|
|
4
|
+
* Extends StreamableHttpRunner for per-account MCP.
|
|
5
5
|
*
|
|
6
|
-
* Each
|
|
7
|
-
* The
|
|
8
|
-
*
|
|
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.
|
|
9
9
|
*/
|
|
10
10
|
export declare class GainableHttpRunner extends StreamableHttpRunner {
|
|
11
11
|
private baseConfig;
|
|
12
12
|
private scopeClient;
|
|
13
|
-
|
|
13
|
+
/** Cached datasets per account (auto-discovered from datamodels collection). TTL 60s. */
|
|
14
|
+
private datasetsCache;
|
|
15
|
+
private static readonly DATASETS_TTL_MS;
|
|
16
|
+
constructor(config: ConstructorParameters<typeof StreamableHttpRunner>[0], baseConfig: BaseConfig);
|
|
17
|
+
private getClient;
|
|
18
|
+
/**
|
|
19
|
+
* Auto-discover allowed datasets from the datamodels collection.
|
|
20
|
+
* Cached for 60s per process (all apps in an account share the same datasets).
|
|
21
|
+
*/
|
|
22
|
+
private discoverDatasets;
|
|
14
23
|
/**
|
|
15
24
|
* Look up an agent's scopes from MongoDB.
|
|
16
25
|
* Returns the scopes array if the agent has restrictions, or undefined if unrestricted.
|
|
@@ -1,79 +1,125 @@
|
|
|
1
1
|
import { MongoClient } from 'mongodb';
|
|
2
2
|
import { StreamableHttpRunner } from 'mongodb-mcp-server';
|
|
3
3
|
import { createScopedConnectionManagerFactory } from './scopedConnectionManager.js';
|
|
4
|
+
import { ensureSchemaCacheWarmed } from './collectionSchema.js';
|
|
4
5
|
/**
|
|
5
|
-
* Extends StreamableHttpRunner
|
|
6
|
+
* Extends StreamableHttpRunner for per-account MCP.
|
|
6
7
|
*
|
|
7
|
-
* Each
|
|
8
|
-
* The
|
|
9
|
-
*
|
|
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.
|
|
10
11
|
*/
|
|
11
12
|
export class GainableHttpRunner extends StreamableHttpRunner {
|
|
12
13
|
baseConfig;
|
|
13
14
|
scopeClient = null;
|
|
14
|
-
|
|
15
|
+
/** Cached datasets per account (auto-discovered from datamodels collection). TTL 60s. */
|
|
16
|
+
datasetsCache = null;
|
|
17
|
+
static DATASETS_TTL_MS = 60_000;
|
|
18
|
+
constructor(config, baseConfig) {
|
|
15
19
|
super(config);
|
|
16
|
-
this.baseConfig =
|
|
20
|
+
this.baseConfig = baseConfig;
|
|
17
21
|
}
|
|
18
|
-
|
|
19
|
-
* Look up an agent's scopes from MongoDB.
|
|
20
|
-
* Returns the scopes array if the agent has restrictions, or undefined if unrestricted.
|
|
21
|
-
*/
|
|
22
|
-
async getAgentScopes(agentUid) {
|
|
22
|
+
async getClient() {
|
|
23
23
|
if (!this.scopeClient) {
|
|
24
24
|
this.scopeClient = new MongoClient(this.baseConfig.mongodbUri);
|
|
25
25
|
await this.scopeClient.connect();
|
|
26
26
|
}
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
return this.scopeClient;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Auto-discover allowed datasets from the datamodels collection.
|
|
31
|
+
* Cached for 60s per process (all apps in an account share the same datasets).
|
|
32
|
+
*/
|
|
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
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Look up an agent's scopes from MongoDB.
|
|
53
|
+
* Returns the scopes array if the agent has restrictions, or undefined if unrestricted.
|
|
54
|
+
*/
|
|
55
|
+
async getAgentScopes(appName, agentUid) {
|
|
56
|
+
const client = await this.getClient();
|
|
57
|
+
const db = client.db(this.baseConfig.accountId);
|
|
58
|
+
const agent = await db.collection('agents').findOne({ appId: appName, uid: agentUid }, { projection: { scopes: 1 } });
|
|
29
59
|
if (!agent?.scopes || agent.scopes.length === 0)
|
|
30
60
|
return undefined;
|
|
31
61
|
// Normalize scopes: strip app prefix if present (e.g. "my-app_deals" → "deals")
|
|
32
|
-
const appPrefix = `${
|
|
62
|
+
const appPrefix = `${appName}_`;
|
|
33
63
|
return agent.scopes.map((s) => s.startsWith(appPrefix) ? s.slice(appPrefix.length) : s);
|
|
34
64
|
}
|
|
35
65
|
async createServerForRequest({ request, serverOptions, sessionOptions, }) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
66
|
+
// Both app and agent are required
|
|
67
|
+
const appName = extractQueryParam(request, 'app');
|
|
68
|
+
const agentId = extractQueryParam(request, 'agent');
|
|
69
|
+
if (!appName) {
|
|
70
|
+
throw new Error('Missing required query parameter: app');
|
|
71
|
+
}
|
|
72
|
+
if (!agentId) {
|
|
73
|
+
throw new Error('Missing required query parameter: agent');
|
|
40
74
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
+
]);
|
|
80
|
+
// Lazy schema warming for this app
|
|
81
|
+
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)}` : ''}`);
|
|
83
|
+
// Build per-request scoping config
|
|
84
|
+
const scopingConfig = {
|
|
44
85
|
...this.baseConfig,
|
|
86
|
+
appName,
|
|
87
|
+
allowedDatasets,
|
|
45
88
|
agentCollections,
|
|
46
89
|
};
|
|
47
|
-
// Create a per-session connection manager with
|
|
48
|
-
|
|
49
|
-
const connectionManager = await createScopedConnectionManagerFactory(agentConfig)({
|
|
90
|
+
// Create a per-session connection manager with request-specific scoping
|
|
91
|
+
const connectionManager = await createScopedConnectionManagerFactory(scopingConfig)({
|
|
50
92
|
logger: this.logger,
|
|
51
93
|
deviceId: this.deviceId,
|
|
52
94
|
userConfig: this.userConfig,
|
|
53
95
|
});
|
|
54
|
-
console.log('[MCP Session] Connection manager created');
|
|
55
|
-
console.log('[MCP Session] Creating server...');
|
|
56
96
|
const server = await this.createServer({
|
|
57
97
|
userConfig: this.userConfig,
|
|
58
98
|
logger: undefined,
|
|
59
99
|
serverOptions: {
|
|
60
100
|
tools: this.tools,
|
|
61
101
|
...serverOptions,
|
|
102
|
+
toolContext: {
|
|
103
|
+
appName,
|
|
104
|
+
accountId: this.baseConfig.accountId,
|
|
105
|
+
allowedDatasets,
|
|
106
|
+
},
|
|
62
107
|
},
|
|
63
108
|
sessionOptions: {
|
|
64
109
|
...sessionOptions,
|
|
65
110
|
connectionManager,
|
|
66
111
|
},
|
|
67
112
|
});
|
|
68
|
-
console.log('[MCP Session] Server created successfully');
|
|
69
113
|
return server;
|
|
70
114
|
}
|
|
71
115
|
}
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
if (
|
|
77
|
-
return
|
|
116
|
+
function extractQueryParam(request, param) {
|
|
117
|
+
if (!param || !request?.query)
|
|
118
|
+
return undefined;
|
|
119
|
+
const value = request.query[param];
|
|
120
|
+
if (typeof value === 'string')
|
|
121
|
+
return value;
|
|
122
|
+
if (Array.isArray(value))
|
|
123
|
+
return value[0];
|
|
78
124
|
return undefined;
|
|
79
125
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,28 +1,30 @@
|
|
|
1
1
|
import 'dotenv/config';
|
|
2
2
|
import { UserConfigSchema } from 'mongodb-mcp-server';
|
|
3
3
|
import { FindTool, AggregateTool, CountTool, ExportTool, InsertManyTool, UpdateManyTool, DeleteManyTool, ListCollectionsTool, CollectionSchemaTool, CollectionIndexesTool, ExplainTool, } from 'mongodb-mcp-server/tools';
|
|
4
|
-
import {
|
|
4
|
+
import { loadBaseConfig } from './config.js';
|
|
5
5
|
import { GainableHttpRunner } from './gainableHttpRunner.js';
|
|
6
|
-
import { unwrapTools
|
|
7
|
-
import { initSchemaInference
|
|
8
|
-
const config =
|
|
6
|
+
import { unwrapTools } from './stripUntrustedTags.js';
|
|
7
|
+
import { initSchemaInference } from './collectionSchema.js';
|
|
8
|
+
const config = loadBaseConfig();
|
|
9
9
|
// Initialize schema inference connection (used for validation + response hints)
|
|
10
|
+
// Schema warming happens lazily per-app in createServerForRequest()
|
|
10
11
|
await initSchemaInference(config.mongodbUri, config.accountId);
|
|
11
|
-
|
|
12
|
-
// Set tool context so response wrappers can resolve collection names
|
|
13
|
-
setToolContext(config.appName, config.allowedDatasets);
|
|
12
|
+
const port = parseInt(process.env.PORT || process.env.HTTP_PORT || '3099', 10);
|
|
14
13
|
const userConfig = UserConfigSchema.parse({
|
|
15
14
|
transport: 'http',
|
|
16
|
-
httpPort:
|
|
17
|
-
httpHost: '
|
|
15
|
+
httpPort: port,
|
|
16
|
+
httpHost: '0.0.0.0',
|
|
18
17
|
connectionString: config.mongodbUri,
|
|
19
18
|
telemetry: 'disabled',
|
|
20
19
|
// 24h idle timeout — sessions are long-lived (Weavy agent conversations)
|
|
21
20
|
idleTimeoutMs: 86400000,
|
|
22
21
|
notificationTimeoutMs: 82800000,
|
|
23
22
|
// Weavy manages session IDs externally — accept any session ID without requiring initialize
|
|
24
|
-
// This prevents 404 "session not found" after sidecar restarts
|
|
25
23
|
externallyManagedSessions: true,
|
|
24
|
+
// Auth: validate X-Internal-Key header on every request (returns 403 if invalid)
|
|
25
|
+
httpHeaders: process.env.INTERNAL_API_KEY
|
|
26
|
+
? { 'x-internal-key': process.env.INTERNAL_API_KEY }
|
|
27
|
+
: {},
|
|
26
28
|
disabledTools: [
|
|
27
29
|
'drop-database', 'drop-collection', 'create-collection',
|
|
28
30
|
'list-databases', 'rename-collection', 'drop-index',
|
|
@@ -42,14 +44,6 @@ const tools = unwrapTools([
|
|
|
42
44
|
ExplainTool,
|
|
43
45
|
]);
|
|
44
46
|
const runner = new GainableHttpRunner({ userConfig, tools }, config);
|
|
45
|
-
await runner.start(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
appName: config.appName,
|
|
49
|
-
accountId: config.accountId,
|
|
50
|
-
allowedDatasets: config.allowedDatasets,
|
|
51
|
-
},
|
|
52
|
-
},
|
|
53
|
-
});
|
|
54
|
-
console.log(`Gainable MCP Server running on http://127.0.0.1:${process.env.HTTP_PORT || '3099'}/mcp`);
|
|
55
|
-
console.log(`App: ${config.appName} | DB: ${config.accountId}`);
|
|
47
|
+
await runner.start();
|
|
48
|
+
console.log(`Gainable MCP Server running on http://0.0.0.0:${port}/mcp`);
|
|
49
|
+
console.log(`Account: ${config.accountId} | Per-request scoping via ?app=&agent=`);
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { ToolClass } from 'mongodb-mcp-server/tools';
|
|
2
|
-
export declare function setToolContext(appName: string, allowedDatasets: string[]): void;
|
|
3
2
|
/**
|
|
4
3
|
* Strips the `<untrusted-user-data-UUID>` wrapper and security warnings
|
|
5
4
|
* from tool response text content.
|
|
@@ -11,6 +10,9 @@ export declare function setToolContext(appName: string, allowedDatasets: string[
|
|
|
11
10
|
*
|
|
12
11
|
* This creates a new tool class that extends the original and post-processes
|
|
13
12
|
* the invoke() result to remove the wrapping.
|
|
13
|
+
*
|
|
14
|
+
* Uses `this.toolContext` (set per-session by the MCP framework) to resolve
|
|
15
|
+
* collection names — no module-level globals needed.
|
|
14
16
|
*/
|
|
15
17
|
export declare function unwrapTool<T extends ToolClass>(ToolCtor: T): T;
|
|
16
18
|
/**
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
import { getCollectionSchema, formatSchemaHint } from './collectionSchema.js';
|
|
2
2
|
import { resolveCollection } from './scoping/index.js';
|
|
3
|
-
/** Set by index.ts at startup so tool wrappers can resolve collection names. */
|
|
4
|
-
let _appName = '';
|
|
5
|
-
let _allowedDatasets = [];
|
|
6
|
-
export function setToolContext(appName, allowedDatasets) {
|
|
7
|
-
_appName = appName;
|
|
8
|
-
_allowedDatasets = allowedDatasets;
|
|
9
|
-
}
|
|
10
3
|
/**
|
|
11
4
|
* Strips the `<untrusted-user-data-UUID>` wrapper and security warnings
|
|
12
5
|
* from tool response text content.
|
|
@@ -18,6 +11,9 @@ export function setToolContext(appName, allowedDatasets) {
|
|
|
18
11
|
*
|
|
19
12
|
* This creates a new tool class that extends the original and post-processes
|
|
20
13
|
* the invoke() result to remove the wrapping.
|
|
14
|
+
*
|
|
15
|
+
* Uses `this.toolContext` (set per-session by the MCP framework) to resolve
|
|
16
|
+
* collection names — no module-level globals needed.
|
|
21
17
|
*/
|
|
22
18
|
export function unwrapTool(ToolCtor) {
|
|
23
19
|
// Create a subclass that overrides invoke to strip tags
|
|
@@ -49,10 +45,13 @@ export function unwrapTool(ToolCtor) {
|
|
|
49
45
|
}
|
|
50
46
|
// Append schema hint for the target collection so the agent
|
|
51
47
|
// knows the correct field names, types, and valid values.
|
|
48
|
+
// Read appName and allowedDatasets from per-session toolContext.
|
|
52
49
|
const collection = args[0]?.collection;
|
|
53
|
-
|
|
50
|
+
const appName = this.toolContext?.appName;
|
|
51
|
+
const allowedDatasets = this.toolContext?.allowedDatasets ?? [];
|
|
52
|
+
if (collection && appName) {
|
|
54
53
|
try {
|
|
55
|
-
const resolved = resolveCollection(collection,
|
|
54
|
+
const resolved = resolveCollection(collection, appName, allowedDatasets);
|
|
56
55
|
const schema = await getCollectionSchema(resolved.realName, collection);
|
|
57
56
|
if (schema) {
|
|
58
57
|
textParts.push(formatSchemaHint(schema));
|
package/package.json
CHANGED