@gainable.dev/mcp-server 0.1.6 → 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.
@@ -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.
@@ -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
- export interface ScopingConfig {
2
- appName: string;
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
- export declare function loadConfig(): ScopingConfig;
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
- export function loadConfig() {
2
- const appName = process.env.APP_NAME;
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
- const allowedDatasets = (process.env.ALLOWED_DATASETS || '')
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 { ScopingConfig } from './config.js';
2
+ import type { BaseConfig } from './config.js';
3
3
  /**
4
- * Extends StreamableHttpRunner to support per-agent collection scoping.
4
+ * Extends StreamableHttpRunner for per-account MCP.
5
5
  *
6
- * Each Weavy agent connects with a URL like `/mcp?agent=marketing-agent`.
7
- * The agent ID is extracted from the query parameter and used to look up
8
- * which collections that agent is allowed to access (from MongoDB).
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
- constructor(config: ConstructorParameters<typeof StreamableHttpRunner>[0], scopingConfig: ScopingConfig);
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 to support per-agent collection scoping.
6
+ * Extends StreamableHttpRunner for per-account MCP.
6
7
  *
7
- * Each Weavy agent connects with a URL like `/mcp?agent=marketing-agent`.
8
- * The agent ID is extracted from the query parameter and used to look up
9
- * which collections that agent is allowed to access (from MongoDB).
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
- constructor(config, scopingConfig) {
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 = scopingConfig;
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
- const db = this.scopeClient.db(this.baseConfig.accountId);
28
- const agent = await db.collection('agents').findOne({ appId: this.baseConfig.appName, uid: agentUid }, { projection: { scopes: 1 } });
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 = `${this.baseConfig.appName}_`;
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
- const agentId = extractAgentId(request);
37
- let agentCollections;
38
- if (agentId) {
39
- agentCollections = await this.getAgentScopes(agentId);
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
- console.log(`[MCP Session] agent=${agentId ?? '(none)'} | scoped=${agentCollections ? 'yes' : 'no'}${agentCollections ? ` | collections=${JSON.stringify(agentCollections)}` : ''}`);
42
- // Build per-agent scoping config
43
- const agentConfig = {
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 agent-specific scoping
48
- console.log('[MCP Session] Creating connection manager...');
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 extractAgentId(request) {
73
- const agent = request?.query?.agent;
74
- if (typeof agent === 'string')
75
- return agent;
76
- if (Array.isArray(agent))
77
- return agent[0];
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,27 +1,25 @@
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 { loadConfig } from './config.js';
4
+ import { loadBaseConfig } from './config.js';
5
5
  import { GainableHttpRunner } from './gainableHttpRunner.js';
6
- import { unwrapTools, setToolContext } from './stripUntrustedTags.js';
7
- import { initSchemaInference, preWarmSchemaCache } from './collectionSchema.js';
8
- const config = loadConfig();
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
- await preWarmSchemaCache(config.appName, config.allowedDatasets);
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: parseInt(process.env.HTTP_PORT || '3099', 10),
17
- httpHost: '127.0.0.1',
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,
26
24
  // Auth: validate X-Internal-Key header on every request (returns 403 if invalid)
27
25
  httpHeaders: process.env.INTERNAL_API_KEY
@@ -46,14 +44,6 @@ const tools = unwrapTools([
46
44
  ExplainTool,
47
45
  ]);
48
46
  const runner = new GainableHttpRunner({ userConfig, tools }, config);
49
- await runner.start({
50
- serverOptions: {
51
- toolContext: {
52
- appName: config.appName,
53
- accountId: config.accountId,
54
- allowedDatasets: config.allowedDatasets,
55
- },
56
- },
57
- });
58
- console.log(`Gainable MCP Server running on http://127.0.0.1:${process.env.HTTP_PORT || '3099'}/mcp`);
59
- 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
- if (collection && _appName) {
50
+ const appName = this.toolContext?.appName;
51
+ const allowedDatasets = this.toolContext?.allowedDatasets ?? [];
52
+ if (collection && appName) {
54
53
  try {
55
- const resolved = resolveCollection(collection, _appName, _allowedDatasets);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gainable.dev/mcp-server",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
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",