@gainable.dev/mcp-server 0.1.6 → 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.
@@ -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
  */
@@ -29,32 +33,50 @@ export async function preWarmSchemaCache(appName, allowedDatasets) {
29
33
  return;
30
34
  try {
31
35
  const collections = await schemaDb.listCollections().toArray();
36
+ const sizeBefore = schemaCache.size;
37
+ let matched = 0;
32
38
  for (const col of collections) {
33
39
  const name = col.name;
34
40
  // App's custom collections
35
41
  if (name.startsWith(`${appName}_`)) {
36
42
  const cleanName = name.slice(appName.length + 1);
37
43
  await getCollectionSchema(name, cleanName);
44
+ matched++;
38
45
  continue;
39
46
  }
40
47
  // Allowed data collections
41
48
  for (const datasetId of allowedDatasets) {
42
49
  if (name === `data_${datasetId}`) {
43
50
  await getCollectionSchema(name, datasetId);
51
+ matched++;
44
52
  break;
45
53
  }
46
54
  }
47
55
  // Core collections (e.g. users)
48
56
  if (CORE_COLLECTIONS.includes(name)) {
49
57
  await getCollectionSchema(name, name);
58
+ matched++;
50
59
  }
51
60
  }
52
- 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)` : ''}`);
53
64
  }
54
65
  catch (err) {
55
66
  console.warn('[Schema] Pre-warm failed:', err);
56
67
  }
57
68
  }
69
+ /**
70
+ * Lazy per-app schema warming. Only warms once per app per process lifetime.
71
+ * Called from createServerForRequest() so schemas are ready before the first query.
72
+ */
73
+ export async function ensureSchemaCacheWarmed(appName, allowedDatasets) {
74
+ const key = `${appName}:${allowedDatasets.sort().join(',')}`;
75
+ if (warmedApps.has(key))
76
+ return;
77
+ await preWarmSchemaCache(appName, allowedDatasets);
78
+ warmedApps.add(key);
79
+ }
58
80
  /**
59
81
  * Infer schema from a collection by sampling documents.
60
82
  * 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,21 @@
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
+ * 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
- constructor(config: ConstructorParameters<typeof StreamableHttpRunner>[0], scopingConfig: ScopingConfig);
12
+ constructor(config: ConstructorParameters<typeof StreamableHttpRunner>[0], baseConfig: BaseConfig);
13
+ private getClient;
14
+ /**
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"]).
17
+ */
18
+ private getAppDatasets;
14
19
  /**
15
20
  * Look up an agent's scopes from MongoDB.
16
21
  * Returns the scopes array if the agent has restrictions, or undefined if unrestricted.
@@ -1,79 +1,108 @@
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
+ * Attached data collections are read from `app_config` in the account DB per request.
10
10
  */
11
11
  export class GainableHttpRunner extends StreamableHttpRunner {
12
12
  baseConfig;
13
13
  scopeClient = null;
14
- constructor(config, scopingConfig) {
14
+ constructor(config, baseConfig) {
15
15
  super(config);
16
- this.baseConfig = scopingConfig;
16
+ this.baseConfig = baseConfig;
17
17
  }
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) {
18
+ async getClient() {
23
19
  if (!this.scopeClient) {
24
20
  this.scopeClient = new MongoClient(this.baseConfig.mongodbUri);
25
21
  await this.scopeClient.connect();
26
22
  }
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 } });
23
+ return this.scopeClient;
24
+ }
25
+ /**
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"]).
28
+ */
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 || [];
34
+ }
35
+ /**
36
+ * Look up an agent's scopes from MongoDB.
37
+ * Returns the scopes array if the agent has restrictions, or undefined if unrestricted.
38
+ */
39
+ async getAgentScopes(appName, agentUid) {
40
+ const client = await this.getClient();
41
+ const db = client.db(this.baseConfig.accountId);
42
+ const agent = await db.collection('agents').findOne({ appId: appName, uid: agentUid }, { projection: { scopes: 1 } });
29
43
  if (!agent?.scopes || agent.scopes.length === 0)
30
44
  return undefined;
31
45
  // Normalize scopes: strip app prefix if present (e.g. "my-app_deals" → "deals")
32
- const appPrefix = `${this.baseConfig.appName}_`;
46
+ const appPrefix = `${appName}_`;
33
47
  return agent.scopes.map((s) => s.startsWith(appPrefix) ? s.slice(appPrefix.length) : s);
34
48
  }
35
49
  async createServerForRequest({ request, serverOptions, sessionOptions, }) {
36
- const agentId = extractAgentId(request);
37
- let agentCollections;
38
- if (agentId) {
39
- agentCollections = await this.getAgentScopes(agentId);
50
+ // Both app and agent are required
51
+ const appName = extractQueryParam(request, 'app');
52
+ const agentId = extractQueryParam(request, 'agent');
53
+ if (!appName) {
54
+ throw new Error('Missing required query parameter: app');
55
+ }
56
+ if (!agentId) {
57
+ throw new Error('Missing required query parameter: agent');
40
58
  }
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 = {
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);
63
+ // Lazy schema warming for this app
64
+ await ensureSchemaCacheWarmed(appName, allowedDatasets);
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)}` : ''}`);
66
+ // Build per-request scoping config
67
+ const scopingConfig = {
44
68
  ...this.baseConfig,
69
+ appName,
70
+ allowedDatasets,
45
71
  agentCollections,
46
72
  };
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)({
73
+ // Create a per-session connection manager with request-specific scoping
74
+ const connectionManager = await createScopedConnectionManagerFactory(scopingConfig)({
50
75
  logger: this.logger,
51
76
  deviceId: this.deviceId,
52
77
  userConfig: this.userConfig,
53
78
  });
54
- console.log('[MCP Session] Connection manager created');
55
- console.log('[MCP Session] Creating server...');
56
79
  const server = await this.createServer({
57
80
  userConfig: this.userConfig,
58
81
  logger: undefined,
59
82
  serverOptions: {
60
83
  tools: this.tools,
61
84
  ...serverOptions,
85
+ toolContext: {
86
+ appName,
87
+ accountId: this.baseConfig.accountId,
88
+ allowedDatasets,
89
+ },
62
90
  },
63
91
  sessionOptions: {
64
92
  ...sessionOptions,
65
93
  connectionManager,
66
94
  },
67
95
  });
68
- console.log('[MCP Session] Server created successfully');
69
96
  return server;
70
97
  }
71
98
  }
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];
99
+ function extractQueryParam(request, param) {
100
+ if (!param || !request?.query)
101
+ return undefined;
102
+ const value = request.query[param];
103
+ if (typeof value === 'string')
104
+ return value;
105
+ if (Array.isArray(value))
106
+ return value[0];
78
107
  return undefined;
79
108
  }
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=`);
@@ -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
  }
@@ -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.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",