@topolo/mcp 0.4.3 → 0.4.5

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
@@ -10,6 +10,7 @@ import {
10
10
  auditAllApplicationRequirements,
11
11
  applicationRequirementScopes,
12
12
  requirementsForApplication,
13
+ resolveCatalogServiceUrl,
13
14
  resolveServiceUrl
14
15
  } from "@topolo/sdk";
15
16
 
@@ -87,7 +88,9 @@ var TOOLS = [
87
88
  slug: entry.slug,
88
89
  name: entry.name,
89
90
  baseUrl: entry.baseUrl,
90
- effectiveUrl: entry.baseUrl || resolveServiceUrl(id),
91
+ apiBaseUrl: entry.apiBaseUrl,
92
+ launchUrl: entry.launchUrl,
93
+ effectiveUrl: resolveCatalogServiceUrl(entry, { serviceKey: id }) || resolveServiceUrl(id),
91
94
  surfaceKind: entry.surfaceKind,
92
95
  launchable: entry.launchable,
93
96
  permissions: [...entry.permissions]
@@ -96,6 +99,28 @@ var TOOLS = [
96
99
  return { services };
97
100
  }
98
101
  },
102
+ {
103
+ name: "topolo_list_actions",
104
+ title: "List every Topolo action the agent can call",
105
+ 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.",
106
+ requiredScopes: [],
107
+ destructive: false,
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ service: {
112
+ type: "string",
113
+ description: "Optional service id, slug, or name filter."
114
+ }
115
+ },
116
+ additionalProperties: false
117
+ },
118
+ handler: async (topolo, args) => ({
119
+ actions: await topolo.client.listActions({
120
+ service: typeof args["service"] === "string" ? args["service"] : void 0
121
+ })
122
+ })
123
+ },
99
124
  {
100
125
  name: "topolo_list_applications",
101
126
  title: "List every Topolo platform application",
@@ -229,29 +254,6 @@ var TOOLS = [
229
254
  return gate ? { ...report, conformanceGate: evaluateApplicationAuditGate(report, gate) } : report;
230
255
  }
231
256
  },
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
257
  {
256
258
  name: "topolo_api_call",
257
259
  title: "Call any Topolo platform service",
@@ -319,35 +321,59 @@ var TOOLS = [
319
321
  confirm: Boolean(args["confirm"])
320
322
  });
321
323
  }
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
324
  }
347
325
  ];
348
326
  function filterToolsByScopes(tools, scopes) {
349
327
  return tools.filter((t) => hasAnyScope(scopes, t.requiredScopes));
350
328
  }
329
+ async function buildTopoloTools(topolo, scopes, tools = TOOLS) {
330
+ const baseTools = filterToolsByScopes(tools, scopes);
331
+ const actions = await topolo.client.listActions().catch(() => []);
332
+ const actionTools = actionsToToolDefs(actions);
333
+ return filterToolsByScopes([...baseTools, ...actionTools], scopes);
334
+ }
335
+ function actionsToToolDefs(actions) {
336
+ return actions.map((action) => ({
337
+ name: action.toolName || toolNameForAction(action),
338
+ title: action.title,
339
+ description: action.description,
340
+ requiredScopes: action.requiredPermission ? [action.requiredPermission] : [],
341
+ destructive: action.destructive || action.requiresConfirmation || action.method !== "GET",
342
+ inputSchema: actionInputSchema(action),
343
+ handler: async (topolo, args) => {
344
+ const confirm = Boolean(args["confirm"]);
345
+ const input = { ...args };
346
+ delete input["confirm"];
347
+ return topolo.client.callAction(action.actionId, input, { confirm });
348
+ }
349
+ }));
350
+ }
351
+ function toolNameForAction(action) {
352
+ const service = (action.serviceSlug || action.serviceId || "service").replace(/^topolo-/, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
353
+ const name = action.name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
354
+ return ["topolo", service, name].filter(Boolean).join("_").slice(0, 96);
355
+ }
356
+ function actionInputSchema(action) {
357
+ const schema = {
358
+ type: "object",
359
+ properties: {},
360
+ additionalProperties: false,
361
+ ...action.inputSchema
362
+ };
363
+ const properties = schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties) ? { ...schema.properties } : {};
364
+ if (action.requiresConfirmation || action.method !== "GET") {
365
+ properties.confirm = {
366
+ type: "boolean",
367
+ description: "Required for mutating actions. Explicit human-in-the-loop acknowledgement."
368
+ };
369
+ }
370
+ return {
371
+ ...schema,
372
+ type: "object",
373
+ properties,
374
+ additionalProperties: schema.additionalProperties ?? false
375
+ };
376
+ }
351
377
  function evaluateApplicationAuditGate(report, threshold) {
352
378
  const counts = {
353
379
  met: 0,
@@ -423,9 +449,19 @@ function listAvailableTopoloTools(scopes, tools = TOOLS) {
423
449
  inputSchema: t.inputSchema
424
450
  }));
425
451
  }
452
+ async function listAvailableDynamicTopoloTools(topolo, scopes, tools = TOOLS) {
453
+ return (await buildTopoloTools(topolo, scopes, tools)).map((t) => ({
454
+ name: t.name,
455
+ title: t.title,
456
+ description: t.description,
457
+ requiredScopes: t.requiredScopes,
458
+ destructive: t.destructive,
459
+ inputSchema: t.inputSchema
460
+ }));
461
+ }
426
462
  async function dispatchTopoloTool(input) {
427
463
  const started = Date.now();
428
- const tools = input.tools ?? TOOLS;
464
+ const tools = await buildTopoloTools(input.topolo, input.scopes, input.tools ?? TOOLS);
429
465
  const available = filterToolsByScopes(tools, input.scopes);
430
466
  const tool = available.find((t) => t.name === input.toolName);
431
467
  if (!tool) {
@@ -476,9 +512,11 @@ async function dispatchTopoloTool(input) {
476
512
  }
477
513
  export {
478
514
  TOOLS,
515
+ buildTopoloTools,
479
516
  dispatchTopoloTool,
480
517
  filterToolsByScopes,
481
518
  hasAnyScope,
482
519
  hasScope,
520
+ listAvailableDynamicTopoloTools,
483
521
  listAvailableTopoloTools
484
522
  };
package/dist/index.js CHANGED
@@ -2040,15 +2040,88 @@ function statusRank(status) {
2040
2040
  return 2;
2041
2041
  }
2042
2042
  var DEFAULT_SERVICE_URLS = SERVICES;
2043
+ function resolveCatalogServiceUrl(entry, options = {}) {
2044
+ const keys = catalogServiceKeyCandidates(entry, options.serviceKey);
2045
+ const override = resolveServiceUrlOverride(keys, options.overrides);
2046
+ if (override) return override;
2047
+ const apiBaseUrl = firstString(entry.apiBaseUrl, entry.api_base_url);
2048
+ if (apiBaseUrl) return apiBaseUrl;
2049
+ const bundledUrl = firstBundledServiceUrl(keys);
2050
+ const liveUrl = firstString(entry.baseUrl);
2051
+ if (bundledUrl && (!liveUrl || sameUrlEnvironment(bundledUrl, liveUrl))) return bundledUrl;
2052
+ return liveUrl ?? bundledUrl ?? null;
2053
+ }
2043
2054
  function resolveServiceUrl(service, overrides) {
2044
- const override = overrides?.[service];
2055
+ const override = resolveServiceUrlOverride([service], overrides);
2045
2056
  if (override) return override;
2046
- const envKey = `TOPOLO_SERVICE_URL_${service.replace(/\./g, "_").toUpperCase()}`;
2047
- const envValue = typeof process !== "undefined" ? process.env?.[envKey] : void 0;
2048
- if (envValue) return envValue;
2049
2057
  return DEFAULT_SERVICE_URLS[service];
2050
2058
  }
2059
+ function catalogServiceKeyCandidates(entry, preferred) {
2060
+ const values = [preferred, entry.slug, entry.name, entry.serviceId].filter(
2061
+ (value) => typeof value === "string" && value.trim().length > 0
2062
+ );
2063
+ const keys = /* @__PURE__ */ new Set();
2064
+ for (const value of values) {
2065
+ for (const candidate of serviceKeyCandidates(value)) keys.add(candidate);
2066
+ }
2067
+ return [...keys];
2068
+ }
2069
+ function serviceKeyCandidates(value) {
2070
+ const normalized = value.trim().toLowerCase().replace(/\s+/g, "-");
2071
+ const withoutTopolo = normalized.startsWith("topolo-") ? normalized.slice("topolo-".length) : normalized;
2072
+ return [.../* @__PURE__ */ new Set([
2073
+ normalized,
2074
+ normalized.replace(/-/g, "_"),
2075
+ withoutTopolo,
2076
+ withoutTopolo.replace(/-/g, "_")
2077
+ ])].filter(Boolean);
2078
+ }
2079
+ function resolveServiceUrlOverride(keys, overrides) {
2080
+ for (const key of keys) {
2081
+ const direct = overrides?.[key];
2082
+ if (direct) return direct;
2083
+ const envStyleKey = key.replace(/\./g, "_");
2084
+ const envStyle = overrides?.[envStyleKey];
2085
+ if (envStyle) return envStyle;
2086
+ }
2087
+ for (const key of keys) {
2088
+ const envKey = `TOPOLO_SERVICE_URL_${key.replace(/\./g, "_").toUpperCase()}`;
2089
+ const envValue = typeof process !== "undefined" ? process.env?.[envKey] : void 0;
2090
+ if (envValue) return envValue;
2091
+ }
2092
+ return null;
2093
+ }
2094
+ function firstBundledServiceUrl(keys) {
2095
+ for (const key of keys) {
2096
+ const url = DEFAULT_SERVICE_URLS[key];
2097
+ if (url) return url;
2098
+ }
2099
+ return null;
2100
+ }
2101
+ function firstString(...values) {
2102
+ for (const value of values) {
2103
+ if (typeof value === "string" && value.trim()) return value.trim();
2104
+ }
2105
+ return null;
2106
+ }
2107
+ function sameUrlEnvironment(left, right) {
2108
+ return urlEnvironment(left) === urlEnvironment(right);
2109
+ }
2110
+ function urlEnvironment(value) {
2111
+ try {
2112
+ const url = new URL(value);
2113
+ const host = url.hostname.toLowerCase();
2114
+ if (host === "localhost" || host === "127.0.0.1") return "local";
2115
+ if (host.endsWith(".stg.topolo.us") || host.includes("topolo-staging")) return "staging";
2116
+ if (host.endsWith(".topolo.app") || host.endsWith(".topolo.us")) return "production";
2117
+ return "external";
2118
+ } catch {
2119
+ return "external";
2120
+ }
2121
+ }
2051
2122
  var WRITE_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
2123
+ var TOPOLO_ACTION_ID_HEADER = "X-Topolo-Action-Id";
2124
+ var TOPOLO_ACTION_NAME_HEADER = "X-Topolo-Action-Name";
2052
2125
  var POSITIVE_CACHE_TTL_MS = 5 * 60 * 1e3;
2053
2126
  var NEGATIVE_CACHE_TTL_MS = 60 * 1e3;
2054
2127
  var CATALOG_CACHE_TTL_MS = 60 * 1e3;
@@ -2062,6 +2135,7 @@ var TopoloClient = class _TopoloClient {
2062
2135
  fetchImpl;
2063
2136
  debug;
2064
2137
  serviceCatalogCache = null;
2138
+ actionCatalogCache = null;
2065
2139
  /**
2066
2140
  * Process-wide cache for slug → service-id lookups. Keyed by
2067
2141
  * `${authBaseUrl}|${slug}` so multiple `TopoloClient` instances pointing at
@@ -2215,8 +2289,12 @@ var TopoloClient = class _TopoloClient {
2215
2289
  async resolveLiveCallableService(serviceKey) {
2216
2290
  const catalog = await this.listServices();
2217
2291
  const match = catalog.find((entry) => catalogEntryMatches(entry, serviceKey));
2218
- if (!match?.baseUrl || !isCallableCatalogEntry(match)) return null;
2219
- return { serviceKey, baseUrl: match.baseUrl };
2292
+ if (!match || !isCallableCatalogEntry(match)) return null;
2293
+ const baseUrl = resolveCatalogServiceUrl(match, {
2294
+ serviceKey,
2295
+ overrides: this.serviceUrls
2296
+ });
2297
+ return baseUrl ? { serviceKey, baseUrl } : null;
2220
2298
  }
2221
2299
  /**
2222
2300
  * Resolve a service-slug → opaque service-id via caller config or TopoloAuth.
@@ -2310,6 +2388,8 @@ var TopoloClient = class _TopoloClient {
2310
2388
  name: typeof s.name === "string" ? s.name : null,
2311
2389
  description: typeof s.description === "string" ? s.description : null,
2312
2390
  baseUrl: typeof s.base_url === "string" ? s.base_url : null,
2391
+ apiBaseUrl: typeof s.api_base_url === "string" ? s.api_base_url : typeof s.apiBaseUrl === "string" ? s.apiBaseUrl : null,
2392
+ launchUrl: typeof s.launch_url === "string" ? s.launch_url : typeof s.launchUrl === "string" ? s.launchUrl : null,
2313
2393
  status: typeof s.status === "string" ? s.status : null,
2314
2394
  surfaceKind: typeof s.surface_kind === "string" ? s.surface_kind : null,
2315
2395
  launchable: s.launchable === true,
@@ -2322,6 +2402,67 @@ var TopoloClient = class _TopoloClient {
2322
2402
  };
2323
2403
  return entries;
2324
2404
  }
2405
+ /**
2406
+ * List callable actions from the live Auth catalog. The response is scoped to
2407
+ * the caller's organization and effective permissions; no bundled/static app
2408
+ * action list is used as an authorization source.
2409
+ */
2410
+ async listActions(options = {}) {
2411
+ const now = Date.now();
2412
+ if (!options.service && this.actionCatalogCache && this.actionCatalogCache.expiresAt > now) {
2413
+ return this.actionCatalogCache.entries;
2414
+ }
2415
+ const res = await this.request({
2416
+ service: "auth",
2417
+ method: "GET",
2418
+ path: "/api/actions/catalog",
2419
+ ...options.service ? { query: { service: options.service } } : {}
2420
+ });
2421
+ const raw = res.data?.actions ?? res.actions;
2422
+ if (!Array.isArray(raw)) {
2423
+ throw new TopoloSdkError(
2424
+ "action_catalog_unavailable",
2425
+ "TopoloAuth did not return an action catalog for the current credential."
2426
+ );
2427
+ }
2428
+ const entries = raw.map((item) => normalizeActionCatalogEntry(item)).filter((entry) => Boolean(entry));
2429
+ if (!options.service) {
2430
+ this.actionCatalogCache = {
2431
+ entries,
2432
+ expiresAt: now + CATALOG_CACHE_TTL_MS
2433
+ };
2434
+ }
2435
+ return entries;
2436
+ }
2437
+ async getAction(action) {
2438
+ const needle = normalizeActionLookupKey(action);
2439
+ const actions = await this.listActions();
2440
+ const match = actions.find((entry) => actionEntryMatches(entry, needle));
2441
+ if (!match) {
2442
+ throw new TopoloSdkError(
2443
+ "unknown_action",
2444
+ `Unknown Topolo action "${action}". Run action discovery first and pass an action id, name, or tool name.`
2445
+ );
2446
+ }
2447
+ return match;
2448
+ }
2449
+ async callAction(action, input = {}, options = {}) {
2450
+ const entry = await this.getAction(action);
2451
+ const mapped = mapActionInput(entry, input);
2452
+ const service = entry.serviceSlug || entry.serviceId;
2453
+ return this.request({
2454
+ service,
2455
+ method: entry.method,
2456
+ path: mapped.path,
2457
+ ...mapped.query ? { query: mapped.query } : {},
2458
+ ...mapped.body !== void 0 ? { body: mapped.body } : {},
2459
+ headers: {
2460
+ [TOPOLO_ACTION_ID_HEADER]: entry.actionId,
2461
+ [TOPOLO_ACTION_NAME_HEADER]: entry.name
2462
+ },
2463
+ confirm: options.confirm === true
2464
+ });
2465
+ }
2325
2466
  /**
2326
2467
  * Bundled generated registry for tooling that audits platform packaging, not
2327
2468
  * for deciding what the current credential may use.
@@ -2334,6 +2475,8 @@ var TopoloClient = class _TopoloClient {
2334
2475
  name: app.name ?? app.id,
2335
2476
  description: null,
2336
2477
  baseUrl: app.productionUrl ?? null,
2478
+ apiBaseUrl: null,
2479
+ launchUrl: app.productionUrl ?? null,
2337
2480
  status: "bundled",
2338
2481
  surfaceKind: "application",
2339
2482
  launchable: Boolean(app.productionUrl),
@@ -2377,7 +2520,7 @@ var TopoloClient = class _TopoloClient {
2377
2520
  }
2378
2521
  };
2379
2522
  function resolveConfiguredServiceId(slug, serviceIds) {
2380
- for (const serviceKey of serviceKeyCandidates(slug)) {
2523
+ for (const serviceKey of serviceKeyCandidates2(slug)) {
2381
2524
  const configured = serviceIds?.[serviceKey];
2382
2525
  if (typeof configured === "string" && configured.trim()) return configured.trim();
2383
2526
  const envKey = `TOPOLO_SERVICE_ID_${serviceKey.replace(/\./g, "_").toUpperCase()}`;
@@ -2387,7 +2530,7 @@ function resolveConfiguredServiceId(slug, serviceIds) {
2387
2530
  return null;
2388
2531
  }
2389
2532
  function resolveConfiguredServiceUrl(slug, serviceUrls) {
2390
- for (const serviceKey of serviceKeyCandidates(slug)) {
2533
+ for (const serviceKey of serviceKeyCandidates2(slug)) {
2391
2534
  const configured = serviceUrls?.[serviceKey];
2392
2535
  if (typeof configured === "string" && configured.trim()) return configured.trim();
2393
2536
  const envKey = `TOPOLO_SERVICE_URL_${serviceKey.replace(/\./g, "_").toUpperCase()}`;
@@ -2396,7 +2539,7 @@ function resolveConfiguredServiceUrl(slug, serviceUrls) {
2396
2539
  }
2397
2540
  return null;
2398
2541
  }
2399
- function serviceKeyCandidates(slug) {
2542
+ function serviceKeyCandidates2(slug) {
2400
2543
  const normalized = normalizeLookupKey(slug);
2401
2544
  const withoutTopolo = normalized.startsWith("topolo-") ? normalized.slice("topolo-".length) : normalized;
2402
2545
  return [.../* @__PURE__ */ new Set([
@@ -2418,7 +2561,7 @@ function catalogEntryLookupKeys(entry) {
2418
2561
  for (const value of values) {
2419
2562
  const normalized = normalizeLookupKey(value);
2420
2563
  if (!normalized) continue;
2421
- for (const candidate of serviceKeyCandidates(normalized)) {
2564
+ for (const candidate of serviceKeyCandidates2(normalized)) {
2422
2565
  keys.add(candidate);
2423
2566
  }
2424
2567
  }
@@ -2428,9 +2571,101 @@ function isCallableCatalogEntry(entry) {
2428
2571
  const kind = normalizeLookupKey(entry.surfaceKind || "");
2429
2572
  if (kind === "api" || kind === "technical_service" || kind === "service") return true;
2430
2573
  return Boolean(
2431
- entry.baseUrl && catalogEntryLookupKeys(entry).some((key) => Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, key))
2574
+ (entry.baseUrl || entry.apiBaseUrl) && catalogEntryLookupKeys(entry).some((key) => Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, key))
2432
2575
  );
2433
2576
  }
2577
+ function normalizeActionCatalogEntry(item) {
2578
+ if (!item || typeof item !== "object" || Array.isArray(item)) return null;
2579
+ const a = item;
2580
+ const actionId = pickActionString(a.action_id) ?? pickActionString(a.actionId);
2581
+ const name = pickActionString(a.name);
2582
+ const serviceId = pickActionString(a.service_id) ?? pickActionString(a.serviceId);
2583
+ const method = pickActionString(a.method)?.toUpperCase();
2584
+ const path = pickActionString(a.path);
2585
+ if (!actionId || !name || !serviceId || !method || !path || !isHttpMethod(method)) {
2586
+ return null;
2587
+ }
2588
+ return {
2589
+ actionId,
2590
+ name,
2591
+ toolName: pickActionString(a.tool_name) ?? pickActionString(a.toolName) ?? null,
2592
+ title: pickActionString(a.title) ?? name,
2593
+ description: pickActionString(a.description) ?? "",
2594
+ serviceId,
2595
+ serviceSlug: pickActionString(a.service_slug) ?? pickActionString(a.serviceSlug) ?? null,
2596
+ serviceName: pickActionString(a.service_name) ?? pickActionString(a.serviceName) ?? null,
2597
+ method,
2598
+ path,
2599
+ permissionName: pickActionString(a.permission_name) ?? pickActionString(a.permissionName) ?? "",
2600
+ requiredPermission: pickActionString(a.required_permission) ?? pickActionString(a.requiredPermission) ?? "",
2601
+ inputSchema: plainObject(a.input_schema ?? a.inputSchema),
2602
+ outputSchema: plainObject(a.output_schema ?? a.outputSchema),
2603
+ readOnly: Boolean(a.read_only ?? a.readOnly),
2604
+ destructive: Boolean(a.destructive),
2605
+ requiresConfirmation: Boolean(a.requires_confirmation ?? a.requiresConfirmation),
2606
+ status: pickActionString(a.status),
2607
+ sourceRevision: pickActionString(a.source_revision) ?? pickActionString(a.sourceRevision)
2608
+ };
2609
+ }
2610
+ function isHttpMethod(value) {
2611
+ return value === "GET" || value === "POST" || value === "PUT" || value === "PATCH" || value === "DELETE";
2612
+ }
2613
+ function pickActionString(value) {
2614
+ return typeof value === "string" && value.trim() ? value.trim() : null;
2615
+ }
2616
+ function plainObject(value) {
2617
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
2618
+ }
2619
+ function normalizeActionLookupKey(value) {
2620
+ return String(value || "").trim().toLowerCase();
2621
+ }
2622
+ function actionEntryMatches(entry, needle) {
2623
+ if (!needle) return false;
2624
+ return [entry.actionId, entry.name, entry.toolName].filter((value) => typeof value === "string" && value.length > 0).some((value) => value.toLowerCase() === needle);
2625
+ }
2626
+ function mapActionInput(action, input) {
2627
+ const remaining = { ...input || {} };
2628
+ const path = action.path.replace(/\{([a-zA-Z0-9_]+)\}/g, (_match, key) => {
2629
+ const value = remaining[key];
2630
+ if (value === void 0 || value === null || value === "") {
2631
+ throw new TopoloSdkError(
2632
+ "invalid_action_input",
2633
+ `Action "${action.name}" requires path parameter "${key}".`
2634
+ );
2635
+ }
2636
+ delete remaining[key];
2637
+ return encodeURIComponent(String(value));
2638
+ });
2639
+ const explicitQuery = plainObject(remaining.query);
2640
+ delete remaining.query;
2641
+ const explicitBody = Object.prototype.hasOwnProperty.call(remaining, "body") ? remaining.body : void 0;
2642
+ delete remaining.body;
2643
+ if (action.method === "GET") {
2644
+ return {
2645
+ path,
2646
+ query: objectToQuery({ ...remaining, ...explicitQuery })
2647
+ };
2648
+ }
2649
+ const query = objectToQuery(explicitQuery);
2650
+ const body = explicitBody !== void 0 ? explicitBody : remaining;
2651
+ return {
2652
+ path,
2653
+ ...query ? { query } : {},
2654
+ body
2655
+ };
2656
+ }
2657
+ function objectToQuery(value) {
2658
+ const query = {};
2659
+ for (const [key, raw] of Object.entries(value)) {
2660
+ if (raw === void 0 || raw === null) continue;
2661
+ if (typeof raw === "string" || typeof raw === "number" || typeof raw === "boolean") {
2662
+ query[key] = raw;
2663
+ } else {
2664
+ query[key] = JSON.stringify(raw);
2665
+ }
2666
+ }
2667
+ return Object.keys(query).length > 0 ? query : void 0;
2668
+ }
2434
2669
  function describeError(body, fallback) {
2435
2670
  if (body && typeof body === "object") {
2436
2671
  const maybe = body;
@@ -2631,7 +2866,9 @@ var TOOLS = [
2631
2866
  slug: entry.slug,
2632
2867
  name: entry.name,
2633
2868
  baseUrl: entry.baseUrl,
2634
- effectiveUrl: entry.baseUrl || resolveServiceUrl(id),
2869
+ apiBaseUrl: entry.apiBaseUrl,
2870
+ launchUrl: entry.launchUrl,
2871
+ effectiveUrl: resolveCatalogServiceUrl(entry, { serviceKey: id }) || resolveServiceUrl(id),
2635
2872
  surfaceKind: entry.surfaceKind,
2636
2873
  launchable: entry.launchable,
2637
2874
  permissions: [...entry.permissions]
@@ -2640,6 +2877,28 @@ var TOOLS = [
2640
2877
  return { services };
2641
2878
  }
2642
2879
  },
2880
+ {
2881
+ name: "topolo_list_actions",
2882
+ title: "List every Topolo action the agent can call",
2883
+ 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.",
2884
+ requiredScopes: [],
2885
+ destructive: false,
2886
+ inputSchema: {
2887
+ type: "object",
2888
+ properties: {
2889
+ service: {
2890
+ type: "string",
2891
+ description: "Optional service id, slug, or name filter."
2892
+ }
2893
+ },
2894
+ additionalProperties: false
2895
+ },
2896
+ handler: async (topolo, args) => ({
2897
+ actions: await topolo.client.listActions({
2898
+ service: typeof args["service"] === "string" ? args["service"] : void 0
2899
+ })
2900
+ })
2901
+ },
2643
2902
  {
2644
2903
  name: "topolo_list_applications",
2645
2904
  title: "List every Topolo platform application",
@@ -2773,29 +3032,6 @@ var TOOLS = [
2773
3032
  return gate ? { ...report, conformanceGate: evaluateApplicationAuditGate(report, gate) } : report;
2774
3033
  }
2775
3034
  },
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
3035
  {
2800
3036
  name: "topolo_api_call",
2801
3037
  title: "Call any Topolo platform service",
@@ -2863,35 +3099,59 @@ var TOOLS = [
2863
3099
  confirm: Boolean(args["confirm"])
2864
3100
  });
2865
3101
  }
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
3102
  }
2891
3103
  ];
2892
3104
  function filterToolsByScopes(tools, scopes) {
2893
3105
  return tools.filter((t) => hasAnyScope(scopes, t.requiredScopes));
2894
3106
  }
3107
+ async function buildTopoloTools(topolo, scopes, tools = TOOLS) {
3108
+ const baseTools = filterToolsByScopes(tools, scopes);
3109
+ const actions = await topolo.client.listActions().catch(() => []);
3110
+ const actionTools = actionsToToolDefs(actions);
3111
+ return filterToolsByScopes([...baseTools, ...actionTools], scopes);
3112
+ }
3113
+ function actionsToToolDefs(actions) {
3114
+ return actions.map((action) => ({
3115
+ name: action.toolName || toolNameForAction(action),
3116
+ title: action.title,
3117
+ description: action.description,
3118
+ requiredScopes: action.requiredPermission ? [action.requiredPermission] : [],
3119
+ destructive: action.destructive || action.requiresConfirmation || action.method !== "GET",
3120
+ inputSchema: actionInputSchema(action),
3121
+ handler: async (topolo, args) => {
3122
+ const confirm = Boolean(args["confirm"]);
3123
+ const input = { ...args };
3124
+ delete input["confirm"];
3125
+ return topolo.client.callAction(action.actionId, input, { confirm });
3126
+ }
3127
+ }));
3128
+ }
3129
+ function toolNameForAction(action) {
3130
+ const service = (action.serviceSlug || action.serviceId || "service").replace(/^topolo-/, "").toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
3131
+ const name = action.name.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
3132
+ return ["topolo", service, name].filter(Boolean).join("_").slice(0, 96);
3133
+ }
3134
+ function actionInputSchema(action) {
3135
+ const schema = {
3136
+ type: "object",
3137
+ properties: {},
3138
+ additionalProperties: false,
3139
+ ...action.inputSchema
3140
+ };
3141
+ const properties = schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties) ? { ...schema.properties } : {};
3142
+ if (action.requiresConfirmation || action.method !== "GET") {
3143
+ properties.confirm = {
3144
+ type: "boolean",
3145
+ description: "Required for mutating actions. Explicit human-in-the-loop acknowledgement."
3146
+ };
3147
+ }
3148
+ return {
3149
+ ...schema,
3150
+ type: "object",
3151
+ properties,
3152
+ additionalProperties: schema.additionalProperties ?? false
3153
+ };
3154
+ }
2895
3155
  function evaluateApplicationAuditGate(report, threshold) {
2896
3156
  const counts = {
2897
3157
  met: 0,
@@ -2961,25 +3221,28 @@ var MCP_VERSION = "0.4.1";
2961
3221
 
2962
3222
  // src/server.ts
2963
3223
  function buildServer(topolo, scopes, tools = TOOLS) {
2964
- const availableTools = filterToolsByScopes(tools, scopes);
2965
3224
  const server = new Server(
2966
3225
  { name: "topolo", version: MCP_VERSION },
2967
3226
  { capabilities: { tools: {} } }
2968
3227
  );
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
- }));
3228
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
3229
+ const availableTools = await buildTopoloTools(topolo, scopes, tools);
3230
+ return {
3231
+ tools: availableTools.map((t) => ({
3232
+ name: t.name,
3233
+ title: t.title,
3234
+ description: t.description,
3235
+ inputSchema: t.inputSchema,
3236
+ annotations: {
3237
+ destructiveHint: t.destructive,
3238
+ readOnlyHint: !t.destructive
3239
+ }
3240
+ }))
3241
+ };
3242
+ });
2981
3243
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
2982
3244
  const params = request.params;
3245
+ const availableTools = await buildTopoloTools(topolo, scopes, tools);
2983
3246
  const tool = availableTools.find((t) => t.name === params.name);
2984
3247
  if (!tool) {
2985
3248
  return toolError(
@@ -3008,8 +3271,8 @@ function buildServer(topolo, scopes, tools = TOOLS) {
3008
3271
  });
3009
3272
  return server;
3010
3273
  }
3011
- function advertisedToolNames(scopes, tools = TOOLS) {
3012
- return filterToolsByScopes(tools, scopes).map((t) => t.name);
3274
+ async function advertisedToolNames(topolo, scopes, tools = TOOLS) {
3275
+ return (await buildTopoloTools(topolo, scopes, tools)).map((t) => t.name);
3013
3276
  }
3014
3277
  function toolError(message) {
3015
3278
  return {
@@ -3043,7 +3306,7 @@ async function main() {
3043
3306
  const server = buildServer(topolo, scopes);
3044
3307
  const transport = new StdioServerTransport();
3045
3308
  await server.connect(transport);
3046
- const toolNames = advertisedToolNames(scopes);
3309
+ const toolNames = await advertisedToolNames(topolo, scopes);
3047
3310
  process.stderr.write(
3048
3311
  `TopoloMCP v${MCP_VERSION} connected. Tools available: ${toolNames.join(", ") || "(none)"}
3049
3312
  `
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/mcp",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
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": {