@topolo/mcp 0.4.2 → 0.4.4

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/README.md CHANGED
@@ -79,7 +79,7 @@ ready-made agent guide that covers both the CLI and this MCP.
79
79
  | `TOPOLO_API_KEY` | Platform API key (preferred) |
80
80
  | `TOPOLO_ACCESS_TOKEN` | Short-lived JWT (dev/testing) |
81
81
  | `TOPOLO_AGENT_NAME` | Human-readable agent label for audit logs |
82
- | `TOPOLO_SERVICE_URL_<ID>` | Override a service base URL (e.g. `_AUTH`, `_CRM`) |
82
+ | `TOPOLO_SERVICE_URL_<ID>` | Override a service base URL (e.g. `_AUTH`, `_MAIL`) |
83
83
 
84
84
  Exactly one credential var must be set. If both are present, `TOPOLO_API_KEY`
85
85
  wins.
@@ -88,8 +88,10 @@ wins.
88
88
 
89
89
  1. Resolve the credential from env. **Refuse to start** if none is set.
90
90
  2. Call `GET /api/me` to load the user's granted scopes + role.
91
- 3. Filter `TOOLS` by those scopes — agents never see a tool they cannot use.
92
- 4. Connect the stdio transport and begin serving MCP requests.
91
+ 3. Connect the stdio transport and begin serving MCP requests.
92
+ 4. On tool listing and tool calls, filter static tools by those scopes and load
93
+ the live action catalog for the credential. Agents never see an action tool
94
+ they cannot use.
93
95
 
94
96
  Startup is synchronous and fast (< 500 ms typical). If the credential is
95
97
  rejected, the process logs the error to stderr and exits non-zero — host
@@ -105,10 +107,17 @@ clients surface that as a failed server launch.
105
107
  | `topolo_get_application` | (none) | no |
106
108
  | `topolo_list_application_requirements` | (none) | no |
107
109
  | `topolo_audit_applications` | (none) | no |
108
- | `topolo_crm_list_contacts` | `crm.contacts:read` | no |
109
- | `topolo_crm_get_contact` | `crm.contacts:read` | no |
110
+ | `topolo_list_actions` | (credential-scoped) | no |
111
+ | Dynamic action tools | action permission | action-defined |
110
112
  | `topolo_api_call` | (service-enforced) | yes |
111
113
 
114
+ Applications publish machine-action definitions through Topolo Developers.
115
+ TopoloAuth projects those into a credential-scoped action catalog using the
116
+ same service permissions already used for API keys and application access. MCP
117
+ turns each visible catalog entry into a tool named by the action catalog, such
118
+ as `topolo_mail_messages_send`, with the action's input schema and destructive
119
+ hint.
120
+
112
121
  `topolo_api_call` is the escape hatch for endpoints without typed MCP tools
113
122
  yet. GET requests work without confirmation; mutating HTTP methods must pass
114
123
  `confirm: true` or the SDK rejects the call. Backing services still enforce
@@ -116,7 +125,8 @@ permissions for every request. The tool schema intentionally does not embed a
116
125
  large service enum; agents should call `topolo_list_services` first, then pass a
117
126
  service ID/slug from that credential-scoped result. New services become callable
118
127
  without an MCP release once Auth grants the organization and credential the
119
- relevant permissions.
128
+ relevant permissions, and new actions become typed tools without an MCP release
129
+ once the application publishes them.
120
130
 
121
131
  ## Safety rails
122
132
 
@@ -128,11 +138,13 @@ relevant permissions.
128
138
  - **Credential-scoped catalog discovery.** `topolo_list_services` and
129
139
  `topolo_list_applications` use TopoloAuth's live organization/permission
130
140
  catalog. They do not expose the global generated platform catalog.
141
+ - **Credential-scoped action discovery.** Dynamic action tools are loaded from
142
+ the live Auth action catalog and filtered by existing service permissions.
131
143
  - **Audit headers.** Every request sends `X-Topolo-Client: topolo-mcp/<ver>`,
132
144
  `X-Topolo-Agent: <label>`, `X-Topolo-Request-Id: <uuid>`.
133
145
  - **Write-action confirmation.** The SDK refuses mutating HTTP methods unless
134
- the call explicitly passes `confirm: true`. Mutating tools (when introduced)
135
- will require the host client to approve before invocation.
146
+ the call explicitly passes `confirm: true`. Mutating action tools require the
147
+ host client to approve before invocation.
136
148
 
137
149
  ## Troubleshooting
138
150
 
@@ -175,6 +187,6 @@ after running the same package checks and `npm pack --dry-run`.
175
187
 
176
188
  ## Phase 2 (planned)
177
189
 
178
- - More typed tools as `@topolo/sdk` modules ship.
190
+ - Hosted action catalog administration in Developers UI.
179
191
  - HTTP transport in addition to stdio, for hosted (non-subprocess) deployments.
180
192
  - Optional `--provenance` on npm publish once the repo is public.
@@ -43,6 +43,7 @@ interface ToolDef {
43
43
  }
44
44
  declare const TOOLS: ToolDef[];
45
45
  declare function filterToolsByScopes(tools: ToolDef[], scopes: ScopeSet): ToolDef[];
46
+ declare function buildTopoloTools(topolo: Topolo, scopes: ScopeSet, tools?: ToolDef[]): Promise<ToolDef[]>;
46
47
 
47
48
  /**
48
49
  * Transport-agnostic Topolo tool dispatch.
@@ -84,6 +85,7 @@ interface ToolAdvertisement {
84
85
  inputSchema: Record<string, unknown>;
85
86
  }
86
87
  declare function listAvailableTopoloTools(scopes: ScopeSet, tools?: ToolDef[]): ToolAdvertisement[];
88
+ declare function listAvailableDynamicTopoloTools(topolo: Topolo, scopes: ScopeSet, tools?: ToolDef[]): Promise<ToolAdvertisement[]>;
87
89
  declare function dispatchTopoloTool(input: ToolDispatchInput): Promise<ToolDispatchResult>;
88
90
 
89
- export { type ScopeSet, TOOLS, type ToolAdvertisement, type ToolDef, type ToolDispatchFailureReason, type ToolDispatchInput, type ToolDispatchResult, dispatchTopoloTool, filterToolsByScopes, hasAnyScope, hasScope, listAvailableTopoloTools };
91
+ export { type ScopeSet, TOOLS, type ToolAdvertisement, type ToolDef, type ToolDispatchFailureReason, type ToolDispatchInput, type ToolDispatchResult, buildTopoloTools, dispatchTopoloTool, filterToolsByScopes, hasAnyScope, hasScope, listAvailableDynamicTopoloTools, listAvailableTopoloTools };
package/dist/dispatch.js CHANGED
@@ -87,7 +87,7 @@ var TOOLS = [
87
87
  slug: entry.slug,
88
88
  name: entry.name,
89
89
  baseUrl: entry.baseUrl,
90
- effectiveUrl: resolveServiceUrl(id) || entry.baseUrl,
90
+ effectiveUrl: entry.baseUrl || resolveServiceUrl(id),
91
91
  surfaceKind: entry.surfaceKind,
92
92
  launchable: entry.launchable,
93
93
  permissions: [...entry.permissions]
@@ -96,6 +96,28 @@ var TOOLS = [
96
96
  return { services };
97
97
  }
98
98
  },
99
+ {
100
+ name: "topolo_list_actions",
101
+ title: "List every Topolo action the agent can call",
102
+ description: "Returns the credential-scoped callable action catalog. Actions are published by applications through TopoloDevelopers and filtered by TopoloAuth against the current organization and effective permissions.",
103
+ requiredScopes: [],
104
+ destructive: false,
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ service: {
109
+ type: "string",
110
+ description: "Optional service id, slug, or name filter."
111
+ }
112
+ },
113
+ additionalProperties: false
114
+ },
115
+ handler: async (topolo, args) => ({
116
+ actions: await topolo.client.listActions({
117
+ service: typeof args["service"] === "string" ? args["service"] : void 0
118
+ })
119
+ })
120
+ },
99
121
  {
100
122
  name: "topolo_list_applications",
101
123
  title: "List every Topolo platform application",
@@ -229,29 +251,6 @@ var TOOLS = [
229
251
  return gate ? { ...report, conformanceGate: evaluateApplicationAuditGate(report, gate) } : report;
230
252
  }
231
253
  },
232
- {
233
- name: "topolo_crm_list_contacts",
234
- title: "List CRM contacts",
235
- description: "List contacts in the authenticated organization from TopoloCRM. Supports full-text search and pagination. All results are scoped to the caller's organization \u2014 there is no way to query contacts from another org.",
236
- requiredScopes: ["crm.contacts:read"],
237
- destructive: false,
238
- inputSchema: {
239
- type: "object",
240
- properties: {
241
- query: { type: "string", description: "Full-text search string" },
242
- page: { type: "integer", minimum: 1, default: 1 },
243
- pageSize: { type: "integer", minimum: 1, maximum: 200, default: 25 }
244
- },
245
- additionalProperties: false
246
- },
247
- handler: async (topolo, args) => {
248
- return topolo.crm.listContacts({
249
- q: typeof args["query"] === "string" ? args["query"] : void 0,
250
- page: typeof args["page"] === "number" ? args["page"] : void 0,
251
- pageSize: typeof args["pageSize"] === "number" ? args["pageSize"] : void 0
252
- });
253
- }
254
- },
255
254
  {
256
255
  name: "topolo_api_call",
257
256
  title: "Call any Topolo platform service",
@@ -319,35 +318,59 @@ var TOOLS = [
319
318
  confirm: Boolean(args["confirm"])
320
319
  });
321
320
  }
322
- },
323
- {
324
- name: "topolo_crm_get_contact",
325
- title: "Fetch a single CRM contact",
326
- description: "Fetch a single contact by id from TopoloCRM, scoped to the caller's organization.",
327
- requiredScopes: ["crm.contacts:read"],
328
- destructive: false,
329
- inputSchema: {
330
- type: "object",
331
- properties: {
332
- contactId: { type: "string", minLength: 1 }
333
- },
334
- required: ["contactId"],
335
- additionalProperties: false
336
- },
337
- handler: async (topolo, args) => {
338
- const contactId = args["contactId"];
339
- if (typeof contactId !== "string" || !contactId) {
340
- throw new Error("contactId is required");
341
- }
342
- const contact = await topolo.crm.getContact(contactId);
343
- if (!contact) throw new Error(`Contact not found: ${contactId}`);
344
- return contact;
345
- }
346
321
  }
347
322
  ];
348
323
  function filterToolsByScopes(tools, scopes) {
349
324
  return tools.filter((t) => hasAnyScope(scopes, t.requiredScopes));
350
325
  }
326
+ async function buildTopoloTools(topolo, scopes, tools = TOOLS) {
327
+ const baseTools = filterToolsByScopes(tools, scopes);
328
+ const actions = await topolo.client.listActions().catch(() => []);
329
+ const actionTools = actionsToToolDefs(actions);
330
+ return filterToolsByScopes([...baseTools, ...actionTools], scopes);
331
+ }
332
+ function actionsToToolDefs(actions) {
333
+ return actions.map((action) => ({
334
+ name: action.toolName || toolNameForAction(action),
335
+ title: action.title,
336
+ description: action.description,
337
+ requiredScopes: action.requiredPermission ? [action.requiredPermission] : [],
338
+ destructive: action.destructive || action.requiresConfirmation || action.method !== "GET",
339
+ inputSchema: actionInputSchema(action),
340
+ handler: async (topolo, args) => {
341
+ const confirm = Boolean(args["confirm"]);
342
+ const input = { ...args };
343
+ delete input["confirm"];
344
+ return topolo.client.callAction(action.actionId, input, { confirm });
345
+ }
346
+ }));
347
+ }
348
+ function toolNameForAction(action) {
349
+ const service = (action.serviceSlug || action.serviceId || "service").replace(/^topolo-/, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
350
+ const name = action.name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
351
+ return ["topolo", service, name].filter(Boolean).join("_").slice(0, 96);
352
+ }
353
+ function actionInputSchema(action) {
354
+ const schema = {
355
+ type: "object",
356
+ properties: {},
357
+ additionalProperties: false,
358
+ ...action.inputSchema
359
+ };
360
+ const properties = schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties) ? { ...schema.properties } : {};
361
+ if (action.requiresConfirmation || action.method !== "GET") {
362
+ properties.confirm = {
363
+ type: "boolean",
364
+ description: "Required for mutating actions. Explicit human-in-the-loop acknowledgement."
365
+ };
366
+ }
367
+ return {
368
+ ...schema,
369
+ type: "object",
370
+ properties,
371
+ additionalProperties: schema.additionalProperties ?? false
372
+ };
373
+ }
351
374
  function evaluateApplicationAuditGate(report, threshold) {
352
375
  const counts = {
353
376
  met: 0,
@@ -423,9 +446,19 @@ function listAvailableTopoloTools(scopes, tools = TOOLS) {
423
446
  inputSchema: t.inputSchema
424
447
  }));
425
448
  }
449
+ async function listAvailableDynamicTopoloTools(topolo, scopes, tools = TOOLS) {
450
+ return (await buildTopoloTools(topolo, scopes, tools)).map((t) => ({
451
+ name: t.name,
452
+ title: t.title,
453
+ description: t.description,
454
+ requiredScopes: t.requiredScopes,
455
+ destructive: t.destructive,
456
+ inputSchema: t.inputSchema
457
+ }));
458
+ }
426
459
  async function dispatchTopoloTool(input) {
427
460
  const started = Date.now();
428
- const tools = input.tools ?? TOOLS;
461
+ const tools = await buildTopoloTools(input.topolo, input.scopes, input.tools ?? TOOLS);
429
462
  const available = filterToolsByScopes(tools, input.scopes);
430
463
  const tool = available.find((t) => t.name === input.toolName);
431
464
  if (!tool) {
@@ -476,9 +509,11 @@ async function dispatchTopoloTool(input) {
476
509
  }
477
510
  export {
478
511
  TOOLS,
512
+ buildTopoloTools,
479
513
  dispatchTopoloTool,
480
514
  filterToolsByScopes,
481
515
  hasAnyScope,
482
516
  hasScope,
517
+ listAvailableDynamicTopoloTools,
483
518
  listAvailableTopoloTools
484
519
  };
package/dist/index.js CHANGED
@@ -2062,6 +2062,7 @@ var TopoloClient = class _TopoloClient {
2062
2062
  fetchImpl;
2063
2063
  debug;
2064
2064
  serviceCatalogCache = null;
2065
+ actionCatalogCache = null;
2065
2066
  /**
2066
2067
  * Process-wide cache for slug → service-id lookups. Keyed by
2067
2068
  * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
@@ -2322,6 +2323,63 @@ var TopoloClient = class _TopoloClient {
2322
2323
  };
2323
2324
  return entries;
2324
2325
  }
2326
+ /**
2327
+ * List callable actions from the live Auth catalog. The response is scoped to
2328
+ * the caller's organization and effective permissions; no bundled/static app
2329
+ * action list is used as an authorization source.
2330
+ */
2331
+ async listActions(options = {}) {
2332
+ const now = Date.now();
2333
+ if (!options.service && this.actionCatalogCache && this.actionCatalogCache.expiresAt > now) {
2334
+ return this.actionCatalogCache.entries;
2335
+ }
2336
+ const res = await this.request({
2337
+ service: "auth",
2338
+ method: "GET",
2339
+ path: "/api/actions/catalog",
2340
+ ...options.service ? { query: { service: options.service } } : {}
2341
+ });
2342
+ const raw = res.data?.actions ?? res.actions;
2343
+ if (!Array.isArray(raw)) {
2344
+ throw new TopoloSdkError(
2345
+ "action_catalog_unavailable",
2346
+ "TopoloAuth did not return an action catalog for the current credential."
2347
+ );
2348
+ }
2349
+ const entries = raw.map((item) => normalizeActionCatalogEntry(item)).filter((entry) => Boolean(entry));
2350
+ if (!options.service) {
2351
+ this.actionCatalogCache = {
2352
+ entries,
2353
+ expiresAt: now + CATALOG_CACHE_TTL_MS
2354
+ };
2355
+ }
2356
+ return entries;
2357
+ }
2358
+ async getAction(action) {
2359
+ const needle = normalizeActionLookupKey(action);
2360
+ const actions = await this.listActions();
2361
+ const match = actions.find((entry) => actionEntryMatches(entry, needle));
2362
+ if (!match) {
2363
+ throw new TopoloSdkError(
2364
+ "unknown_action",
2365
+ `Unknown Topolo action "${action}". Run action discovery first and pass an action id, name, or tool name.`
2366
+ );
2367
+ }
2368
+ return match;
2369
+ }
2370
+ async callAction(action, input = {}, options = {}) {
2371
+ const entry = await this.getAction(action);
2372
+ const mapped = mapActionInput(entry, input);
2373
+ const service = entry.serviceSlug || entry.serviceId;
2374
+ return this.request({
2375
+ service,
2376
+ method: entry.method,
2377
+ path: mapped.path,
2378
+ ...mapped.query ? { query: mapped.query } : {},
2379
+ ...mapped.body !== void 0 ? { body: mapped.body } : {},
2380
+ confirm: options.confirm === true
2381
+ });
2382
+ }
2325
2383
  /**
2326
2384
  * Bundled generated registry for tooling that audits platform packaging, not
2327
2385
  * for deciding what the current credential may use.
@@ -2431,6 +2489,98 @@ function isCallableCatalogEntry(entry) {
2431
2489
  entry.baseUrl && catalogEntryLookupKeys(entry).some((key) => Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, key))
2432
2490
  );
2433
2491
  }
2492
+ function normalizeActionCatalogEntry(item) {
2493
+ if (!item || typeof item !== "object" || Array.isArray(item)) return null;
2494
+ const a = item;
2495
+ const actionId = pickActionString(a.action_id) ?? pickActionString(a.actionId);
2496
+ const name = pickActionString(a.name);
2497
+ const serviceId = pickActionString(a.service_id) ?? pickActionString(a.serviceId);
2498
+ const method = pickActionString(a.method)?.toUpperCase();
2499
+ const path = pickActionString(a.path);
2500
+ if (!actionId || !name || !serviceId || !method || !path || !isHttpMethod(method)) {
2501
+ return null;
2502
+ }
2503
+ return {
2504
+ actionId,
2505
+ name,
2506
+ toolName: pickActionString(a.tool_name) ?? pickActionString(a.toolName) ?? null,
2507
+ title: pickActionString(a.title) ?? name,
2508
+ description: pickActionString(a.description) ?? "",
2509
+ serviceId,
2510
+ serviceSlug: pickActionString(a.service_slug) ?? pickActionString(a.serviceSlug) ?? null,
2511
+ serviceName: pickActionString(a.service_name) ?? pickActionString(a.serviceName) ?? null,
2512
+ method,
2513
+ path,
2514
+ permissionName: pickActionString(a.permission_name) ?? pickActionString(a.permissionName) ?? "",
2515
+ requiredPermission: pickActionString(a.required_permission) ?? pickActionString(a.requiredPermission) ?? "",
2516
+ inputSchema: plainObject(a.input_schema ?? a.inputSchema),
2517
+ outputSchema: plainObject(a.output_schema ?? a.outputSchema),
2518
+ readOnly: Boolean(a.read_only ?? a.readOnly),
2519
+ destructive: Boolean(a.destructive),
2520
+ requiresConfirmation: Boolean(a.requires_confirmation ?? a.requiresConfirmation),
2521
+ status: pickActionString(a.status),
2522
+ sourceRevision: pickActionString(a.source_revision) ?? pickActionString(a.sourceRevision)
2523
+ };
2524
+ }
2525
+ function isHttpMethod(value) {
2526
+ return value === "GET" || value === "POST" || value === "PUT" || value === "PATCH" || value === "DELETE";
2527
+ }
2528
+ function pickActionString(value) {
2529
+ return typeof value === "string" && value.trim() ? value.trim() : null;
2530
+ }
2531
+ function plainObject(value) {
2532
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
2533
+ }
2534
+ function normalizeActionLookupKey(value) {
2535
+ return String(value || "").trim().toLowerCase();
2536
+ }
2537
+ function actionEntryMatches(entry, needle) {
2538
+ if (!needle) return false;
2539
+ return [entry.actionId, entry.name, entry.toolName].filter((value) => typeof value === "string" && value.length > 0).some((value) => value.toLowerCase() === needle);
2540
+ }
2541
+ function mapActionInput(action, input) {
2542
+ const remaining = { ...input || {} };
2543
+ const path = action.path.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key) => {
2544
+ const value = remaining[key];
2545
+ if (value === void 0 || value === null || value === "") {
2546
+ throw new TopoloSdkError(
2547
+ "invalid_action_input",
2548
+ `Action "${action.name}" requires path parameter "${key}".`
2549
+ );
2550
+ }
2551
+ delete remaining[key];
2552
+ return encodeURIComponent(String(value));
2553
+ });
2554
+ const explicitQuery = plainObject(remaining.query);
2555
+ delete remaining.query;
2556
+ const explicitBody = Object.prototype.hasOwnProperty.call(remaining, "body") ? remaining.body : void 0;
2557
+ delete remaining.body;
2558
+ if (action.method === "GET") {
2559
+ return {
2560
+ path,
2561
+ query: objectToQuery({ ...remaining, ...explicitQuery })
2562
+ };
2563
+ }
2564
+ const query = objectToQuery(explicitQuery);
2565
+ const body = explicitBody !== void 0 ? explicitBody : remaining;
2566
+ return {
2567
+ path,
2568
+ ...query ? { query } : {},
2569
+ body
2570
+ };
2571
+ }
2572
+ function objectToQuery(value) {
2573
+ const query = {};
2574
+ for (const [key, raw] of Object.entries(value)) {
2575
+ if (raw === void 0 || raw === null) continue;
2576
+ if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
2577
+ query[key] = raw;
2578
+ } else {
2579
+ query[key] = JSON.stringify(raw);
2580
+ }
2581
+ }
2582
+ return Object.keys(query).length > 0 ? query : void 0;
2583
+ }
2434
2584
  function describeError(body, fallback) {
2435
2585
  if (body && typeof body === "object") {
2436
2586
  const maybe = body;
@@ -2631,7 +2781,7 @@ var TOOLS = [
2631
2781
  slug: entry.slug,
2632
2782
  name: entry.name,
2633
2783
  baseUrl: entry.baseUrl,
2634
- effectiveUrl: resolveServiceUrl(id) || entry.baseUrl,
2784
+ effectiveUrl: entry.baseUrl || resolveServiceUrl(id),
2635
2785
  surfaceKind: entry.surfaceKind,
2636
2786
  launchable: entry.launchable,
2637
2787
  permissions: [...entry.permissions]
@@ -2640,6 +2790,28 @@ var TOOLS = [
2640
2790
  return { services };
2641
2791
  }
2642
2792
  },
2793
+ {
2794
+ name: "topolo_list_actions",
2795
+ title: "List every Topolo action the agent can call",
2796
+ description: "Returns the credential-scoped callable action catalog. Actions are published by applications through TopoloDevelopers and filtered by TopoloAuth against the current organization and effective permissions.",
2797
+ requiredScopes: [],
2798
+ destructive: false,
2799
+ inputSchema: {
2800
+ type: "object",
2801
+ properties: {
2802
+ service: {
2803
+ type: "string",
2804
+ description: "Optional service id, slug, or name filter."
2805
+ }
2806
+ },
2807
+ additionalProperties: false
2808
+ },
2809
+ handler: async (topolo, args) => ({
2810
+ actions: await topolo.client.listActions({
2811
+ service: typeof args["service"] === "string" ? args["service"] : void 0
2812
+ })
2813
+ })
2814
+ },
2643
2815
  {
2644
2816
  name: "topolo_list_applications",
2645
2817
  title: "List every Topolo platform application",
@@ -2773,29 +2945,6 @@ var TOOLS = [
2773
2945
  return gate ? { ...report, conformanceGate: evaluateApplicationAuditGate(report, gate) } : report;
2774
2946
  }
2775
2947
  },
2776
- {
2777
- name: "topolo_crm_list_contacts",
2778
- title: "List CRM contacts",
2779
- description: "List contacts in the authenticated organization from TopoloCRM. Supports full-text search and pagination. All results are scoped to the caller's organization \u2014 there is no way to query contacts from another org.",
2780
- requiredScopes: ["crm.contacts:read"],
2781
- destructive: false,
2782
- inputSchema: {
2783
- type: "object",
2784
- properties: {
2785
- query: { type: "string", description: "Full-text search string" },
2786
- page: { type: "integer", minimum: 1, default: 1 },
2787
- pageSize: { type: "integer", minimum: 1, maximum: 200, default: 25 }
2788
- },
2789
- additionalProperties: false
2790
- },
2791
- handler: async (topolo, args) => {
2792
- return topolo.crm.listContacts({
2793
- q: typeof args["query"] === "string" ? args["query"] : void 0,
2794
- page: typeof args["page"] === "number" ? args["page"] : void 0,
2795
- pageSize: typeof args["pageSize"] === "number" ? args["pageSize"] : void 0
2796
- });
2797
- }
2798
- },
2799
2948
  {
2800
2949
  name: "topolo_api_call",
2801
2950
  title: "Call any Topolo platform service",
@@ -2863,35 +3012,59 @@ var TOOLS = [
2863
3012
  confirm: Boolean(args["confirm"])
2864
3013
  });
2865
3014
  }
2866
- },
2867
- {
2868
- name: "topolo_crm_get_contact",
2869
- title: "Fetch a single CRM contact",
2870
- description: "Fetch a single contact by id from TopoloCRM, scoped to the caller's organization.",
2871
- requiredScopes: ["crm.contacts:read"],
2872
- destructive: false,
2873
- inputSchema: {
2874
- type: "object",
2875
- properties: {
2876
- contactId: { type: "string", minLength: 1 }
2877
- },
2878
- required: ["contactId"],
2879
- additionalProperties: false
2880
- },
2881
- handler: async (topolo, args) => {
2882
- const contactId = args["contactId"];
2883
- if (typeof contactId !== "string" || !contactId) {
2884
- throw new Error("contactId is required");
2885
- }
2886
- const contact = await topolo.crm.getContact(contactId);
2887
- if (!contact) throw new Error(`Contact not found: ${contactId}`);
2888
- return contact;
2889
- }
2890
3015
  }
2891
3016
  ];
2892
3017
  function filterToolsByScopes(tools, scopes) {
2893
3018
  return tools.filter((t) => hasAnyScope(scopes, t.requiredScopes));
2894
3019
  }
3020
+ async function buildTopoloTools(topolo, scopes, tools = TOOLS) {
3021
+ const baseTools = filterToolsByScopes(tools, scopes);
3022
+ const actions = await topolo.client.listActions().catch(() => []);
3023
+ const actionTools = actionsToToolDefs(actions);
3024
+ return filterToolsByScopes([...baseTools, ...actionTools], scopes);
3025
+ }
3026
+ function actionsToToolDefs(actions) {
3027
+ return actions.map((action) => ({
3028
+ name: action.toolName || toolNameForAction(action),
3029
+ title: action.title,
3030
+ description: action.description,
3031
+ requiredScopes: action.requiredPermission ? [action.requiredPermission] : [],
3032
+ destructive: action.destructive || action.requiresConfirmation || action.method !== "GET",
3033
+ inputSchema: actionInputSchema(action),
3034
+ handler: async (topolo, args) => {
3035
+ const confirm = Boolean(args["confirm"]);
3036
+ const input = { ...args };
3037
+ delete input["confirm"];
3038
+ return topolo.client.callAction(action.actionId, input, { confirm });
3039
+ }
3040
+ }));
3041
+ }
3042
+ function toolNameForAction(action) {
3043
+ const service = (action.serviceSlug || action.serviceId || "service").replace(/^topolo-/, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
3044
+ const name = action.name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
3045
+ return ["topolo", service, name].filter(Boolean).join("_").slice(0, 96);
3046
+ }
3047
+ function actionInputSchema(action) {
3048
+ const schema = {
3049
+ type: "object",
3050
+ properties: {},
3051
+ additionalProperties: false,
3052
+ ...action.inputSchema
3053
+ };
3054
+ const properties = schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties) ? { ...schema.properties } : {};
3055
+ if (action.requiresConfirmation || action.method !== "GET") {
3056
+ properties.confirm = {
3057
+ type: "boolean",
3058
+ description: "Required for mutating actions. Explicit human-in-the-loop acknowledgement."
3059
+ };
3060
+ }
3061
+ return {
3062
+ ...schema,
3063
+ type: "object",
3064
+ properties,
3065
+ additionalProperties: schema.additionalProperties ?? false
3066
+ };
3067
+ }
2895
3068
  function evaluateApplicationAuditGate(report, threshold) {
2896
3069
  const counts = {
2897
3070
  met: 0,
@@ -2961,25 +3134,28 @@ var MCP_VERSION = "0.4.1";
2961
3134
 
2962
3135
  // src/server.ts
2963
3136
  function buildServer(topolo, scopes, tools = TOOLS) {
2964
- const availableTools = filterToolsByScopes(tools, scopes);
2965
3137
  const server = new Server(
2966
3138
  { name: "topolo", version: MCP_VERSION },
2967
3139
  { capabilities: { tools: {} } }
2968
3140
  );
2969
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
2970
- tools: availableTools.map((t) => ({
2971
- name: t.name,
2972
- title: t.title,
2973
- description: t.description,
2974
- inputSchema: t.inputSchema,
2975
- annotations: {
2976
- destructiveHint: t.destructive,
2977
- readOnlyHint: !t.destructive
2978
- }
2979
- }))
2980
- }));
3141
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
3142
+ const availableTools = await buildTopoloTools(topolo, scopes, tools);
3143
+ return {
3144
+ tools: availableTools.map((t) => ({
3145
+ name: t.name,
3146
+ title: t.title,
3147
+ description: t.description,
3148
+ inputSchema: t.inputSchema,
3149
+ annotations: {
3150
+ destructiveHint: t.destructive,
3151
+ readOnlyHint: !t.destructive
3152
+ }
3153
+ }))
3154
+ };
3155
+ });
2981
3156
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
2982
3157
  const params = request.params;
3158
+ const availableTools = await buildTopoloTools(topolo, scopes, tools);
2983
3159
  const tool = availableTools.find((t) => t.name === params.name);
2984
3160
  if (!tool) {
2985
3161
  return toolError(
@@ -3008,8 +3184,8 @@ function buildServer(topolo, scopes, tools = TOOLS) {
3008
3184
  });
3009
3185
  return server;
3010
3186
  }
3011
- function advertisedToolNames(scopes, tools = TOOLS) {
3012
- return filterToolsByScopes(tools, scopes).map((t) => t.name);
3187
+ async function advertisedToolNames(topolo, scopes, tools = TOOLS) {
3188
+ return (await buildTopoloTools(topolo, scopes, tools)).map((t) => t.name);
3013
3189
  }
3014
3190
  function toolError(message) {
3015
3191
  return {
@@ -3043,7 +3219,7 @@ async function main() {
3043
3219
  const server = buildServer(topolo, scopes);
3044
3220
  const transport = new StdioServerTransport();
3045
3221
  await server.connect(transport);
3046
- const toolNames = advertisedToolNames(scopes);
3222
+ const toolNames = await advertisedToolNames(topolo, scopes);
3047
3223
  process.stderr.write(
3048
3224
  `TopoloMCP v${MCP_VERSION} connected. Tools available: ${toolNames.join(", ") || "(none)"}
3049
3225
  `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/mcp",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Model Context Protocol server for the Topolo platform. Exposes scope-gated tools that third-party agents (Claude, Codex, etc.) can call natively.",
5
5
  "type": "module",
6
6
  "bin": {