@topolo/mcp 0.4.1 → 0.4.2

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
@@ -114,8 +114,9 @@ yet. GET requests work without confirmation; mutating HTTP methods must pass
114
114
  `confirm: true` or the SDK rejects the call. Backing services still enforce
115
115
  permissions for every request. The tool schema intentionally does not embed a
116
116
  large service enum; agents should call `topolo_list_services` first, then pass a
117
- service ID/slug. The SDK can resolve live Auth-catalog API/technical service
118
- surfaces for newly registered services before the next MCP package release.
117
+ service ID/slug from that credential-scoped result. New services become callable
118
+ without an MCP release once Auth grants the organization and credential the
119
+ relevant permissions.
119
120
 
120
121
  ## Safety rails
121
122
 
@@ -124,6 +125,9 @@ surfaces for newly registered services before the next MCP package release.
124
125
  to see Org B's data.
125
126
  - **Scope-gated tool advertisement.** Agents never see tools they can't use —
126
127
  fewer wasted attempts and less prompt noise.
128
+ - **Credential-scoped catalog discovery.** `topolo_list_services` and
129
+ `topolo_list_applications` use TopoloAuth's live organization/permission
130
+ catalog. They do not expose the global generated platform catalog.
127
131
  - **Audit headers.** Every request sends `X-Topolo-Client: topolo-mcp/<ver>`,
128
132
  `X-Topolo-Agent: <label>`, `X-Topolo-Request-Id: <uuid>`.
129
133
  - **Write-action confirmation.** The SDK refuses mutating HTTP methods unless
package/dist/dispatch.js CHANGED
@@ -70,7 +70,7 @@ var TOOLS = [
70
70
  {
71
71
  name: "topolo_list_services",
72
72
  title: "List every Topolo service the agent can reach",
73
- description: "Returns the service registry the SDK knows about: one entry per platform service with its stable service ID and current base URL. Use this to discover what `topolo_api_call` can address \u2014 the service IDs returned here are the exact values accepted by that tool. Pure metadata: no credential introspection, no permission check, no external call.",
73
+ description: "Returns the credential-scoped service registry: one entry per platform service the current credential can use in its organization, with granted permissions and base URL. Use this to discover what `topolo_api_call` can address. This is not the global platform catalog.",
74
74
  requiredScopes: [],
75
75
  destructive: false,
76
76
  inputSchema: {
@@ -78,12 +78,21 @@ var TOOLS = [
78
78
  properties: {},
79
79
  additionalProperties: false
80
80
  },
81
- handler: async () => {
82
- const services = Object.keys(DEFAULT_SERVICE_URLS).sort().map((id) => ({
83
- id,
84
- defaultUrl: DEFAULT_SERVICE_URLS[id],
85
- effectiveUrl: resolveServiceUrl(id)
86
- }));
81
+ handler: async (topolo) => {
82
+ const services = (await topolo.client.listServices()).map((entry) => {
83
+ const id = displayServiceId(entry);
84
+ return {
85
+ id,
86
+ serviceId: entry.serviceId,
87
+ slug: entry.slug,
88
+ name: entry.name,
89
+ baseUrl: entry.baseUrl,
90
+ effectiveUrl: resolveServiceUrl(id) || entry.baseUrl,
91
+ surfaceKind: entry.surfaceKind,
92
+ launchable: entry.launchable,
93
+ permissions: [...entry.permissions]
94
+ };
95
+ }).sort((left, right) => left.id.localeCompare(right.id));
87
96
  return { services };
88
97
  }
89
98
  },
@@ -375,6 +384,27 @@ function statusesAtOrWorse(threshold) {
375
384
  if (threshold === "needs_review") return ["missing", "needs_review"];
376
385
  return ["missing", "needs_review", "partial"];
377
386
  }
387
+ function displayServiceId(entry) {
388
+ for (const value of [entry.slug, entry.name, entry.serviceId]) {
389
+ if (typeof value !== "string" || !value.trim()) continue;
390
+ for (const candidate of serviceIdCandidates(value)) {
391
+ if (Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, candidate)) {
392
+ return candidate;
393
+ }
394
+ }
395
+ }
396
+ return serviceIdCandidates(entry.slug || entry.serviceId)[0] || entry.serviceId;
397
+ }
398
+ function serviceIdCandidates(value) {
399
+ const normalized = value.trim().toLowerCase().replace(/\s+/g, "-");
400
+ const withoutTopolo = normalized.startsWith("topolo-") ? normalized.slice("topolo-".length) : normalized;
401
+ return [.../* @__PURE__ */ new Set([
402
+ normalized,
403
+ normalized.replace(/-/g, "_"),
404
+ withoutTopolo,
405
+ withoutTopolo.replace(/-/g, "_")
406
+ ])].filter(Boolean);
407
+ }
378
408
  function formatKnownValues(values, label) {
379
409
  const sorted = [...values].sort();
380
410
  const preview = sorted.slice(0, 25).join(", ");
package/dist/index.js CHANGED
@@ -2197,12 +2197,16 @@ var TopoloClient = class _TopoloClient {
2197
2197
  if (!serviceKey) {
2198
2198
  throw new TopoloSdkError("unknown_service", "service is required");
2199
2199
  }
2200
- const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
2201
- if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
2202
- const staticUrl = DEFAULT_SERVICE_URLS[serviceKey];
2203
- if (staticUrl) return { serviceKey, baseUrl: staticUrl };
2200
+ if (serviceKey === "auth") {
2201
+ const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
2202
+ if (overrideUrl) return { serviceKey, baseUrl: overrideUrl };
2203
+ return { serviceKey, baseUrl: DEFAULT_SERVICE_URLS.auth };
2204
+ }
2204
2205
  const live = await this.resolveLiveCallableService(serviceKey);
2205
- if (live) return live;
2206
+ if (live) {
2207
+ const overrideUrl = resolveConfiguredServiceUrl(serviceKey, this.serviceUrls);
2208
+ return overrideUrl ? { ...live, baseUrl: overrideUrl } : live;
2209
+ }
2206
2210
  throw new TopoloSdkError(
2207
2211
  "unknown_service",
2208
2212
  `Unknown Topolo service "${service}". Run service discovery first and pass a callable service id or slug.`
@@ -2212,10 +2216,7 @@ var TopoloClient = class _TopoloClient {
2212
2216
  const catalog = await this.listServices();
2213
2217
  const match = catalog.find((entry) => catalogEntryMatches(entry, serviceKey));
2214
2218
  if (!match?.baseUrl || !isCallableCatalogEntry(match)) return null;
2215
- return {
2216
- serviceKey: normalizeLookupKey(match.slug || match.name || match.serviceId) || serviceKey,
2217
- baseUrl: match.baseUrl
2218
- };
2219
+ return { serviceKey, baseUrl: match.baseUrl };
2219
2220
  }
2220
2221
  /**
2221
2222
  * Resolve a service-slug → opaque service-id via caller config or TopoloAuth.
@@ -2277,53 +2278,55 @@ var TopoloClient = class _TopoloClient {
2277
2278
  /**
2278
2279
  * List the platform service catalog from the live registry
2279
2280
  * (`GET /api/services/catalog` on TopoloAuth). Credential-aware: the
2280
- * caller's credential is attached, so an authenticated caller in an
2281
- * entitled org also receives the `developer_only` services its role
2282
- * permits; an unauthenticated/agent caller receives `active` only.
2281
+ * caller's credential is attached, so the result only includes services the
2282
+ * credential's organization and effective permissions can use.
2283
2283
  *
2284
- * Resilient: on any network/HTTP failure this falls back to the
2285
- * build-time generated `APPLICATIONS` snapshot so offline/degraded
2286
- * callers still get a usable (if static) catalog. The live result is
2287
- * authoritative; the snapshot is a floor, not a cap.
2284
+ * Security boundary: this never falls back to the generated global snapshot.
2285
+ * The live Auth catalog is the authority because it is scoped to the caller's
2286
+ * organization and effective permissions.
2288
2287
  */
2289
2288
  async listServices() {
2290
2289
  const now = Date.now();
2291
2290
  if (this.serviceCatalogCache && this.serviceCatalogCache.expiresAt > now) {
2292
2291
  return this.serviceCatalogCache.entries;
2293
2292
  }
2294
- try {
2295
- const res = await this.request({ service: "auth", method: "GET", path: "/api/services/catalog" });
2296
- const raw = res.data?.services ?? res.services;
2297
- if (Array.isArray(raw) && raw.length > 0) {
2298
- const entries = [];
2299
- for (const item of raw) {
2300
- const s = item;
2301
- const serviceId = typeof s.service_id === "string" ? s.service_id : typeof s.id === "string" ? s.id : null;
2302
- if (!serviceId) continue;
2303
- entries.push({
2304
- serviceId,
2305
- slug: typeof s.app_slug === "string" ? s.app_slug : null,
2306
- name: typeof s.name === "string" ? s.name : null,
2307
- description: typeof s.description === "string" ? s.description : null,
2308
- baseUrl: typeof s.base_url === "string" ? s.base_url : null,
2309
- status: typeof s.status === "string" ? s.status : null,
2310
- surfaceKind: typeof s.surface_kind === "string" ? s.surface_kind : null,
2311
- launchable: s.launchable === true
2312
- });
2313
- }
2314
- if (entries.length > 0) {
2315
- this.serviceCatalogCache = {
2316
- entries,
2317
- expiresAt: now + CATALOG_CACHE_TTL_MS
2318
- };
2319
- return entries;
2320
- }
2321
- }
2322
- } catch {
2293
+ const res = await this.request({ service: "auth", method: "GET", path: "/api/services/catalog" });
2294
+ const raw = res.data?.services ?? res.services;
2295
+ if (!Array.isArray(raw)) {
2296
+ throw new TopoloSdkError(
2297
+ "service_catalog_unavailable",
2298
+ "TopoloAuth did not return a service catalog for the current credential."
2299
+ );
2300
+ }
2301
+ const entries = [];
2302
+ for (const item of raw) {
2303
+ const s = item;
2304
+ const serviceId = typeof s.service_id === "string" ? s.service_id : typeof s.id === "string" ? s.id : null;
2305
+ if (!serviceId) continue;
2306
+ const permissions = Array.isArray(s.granted_permissions) ? s.granted_permissions.filter((permission) => typeof permission === "string" && permission.trim().length > 0) : [];
2307
+ entries.push({
2308
+ serviceId,
2309
+ slug: typeof s.app_slug === "string" ? s.app_slug : null,
2310
+ name: typeof s.name === "string" ? s.name : null,
2311
+ description: typeof s.description === "string" ? s.description : null,
2312
+ baseUrl: typeof s.base_url === "string" ? s.base_url : null,
2313
+ status: typeof s.status === "string" ? s.status : null,
2314
+ surfaceKind: typeof s.surface_kind === "string" ? s.surface_kind : null,
2315
+ launchable: s.launchable === true,
2316
+ permissions
2317
+ });
2323
2318
  }
2324
- return this.generatedCatalogFallback();
2319
+ this.serviceCatalogCache = {
2320
+ entries,
2321
+ expiresAt: now + CATALOG_CACHE_TTL_MS
2322
+ };
2323
+ return entries;
2325
2324
  }
2326
- generatedCatalogFallback() {
2325
+ /**
2326
+ * Bundled generated registry for tooling that audits platform packaging, not
2327
+ * for deciding what the current credential may use.
2328
+ */
2329
+ listBundledServices() {
2327
2330
  const apps = Object.values(APPLICATIONS);
2328
2331
  return apps.map((app) => ({
2329
2332
  serviceId: app.id,
@@ -2331,9 +2334,10 @@ var TopoloClient = class _TopoloClient {
2331
2334
  name: app.name ?? app.id,
2332
2335
  description: null,
2333
2336
  baseUrl: app.productionUrl ?? null,
2334
- status: "active",
2337
+ status: "bundled",
2335
2338
  surfaceKind: "application",
2336
- launchable: Boolean(app.productionUrl)
2339
+ launchable: Boolean(app.productionUrl),
2340
+ permissions: []
2337
2341
  }));
2338
2342
  }
2339
2343
  /**
@@ -2403,7 +2407,7 @@ function serviceKeyCandidates(slug) {
2403
2407
  ])];
2404
2408
  }
2405
2409
  function normalizeLookupKey(value) {
2406
- return String(value || "").trim().toLowerCase();
2410
+ return String(value || "").trim().toLowerCase().replace(/\s+/g, "-");
2407
2411
  }
2408
2412
  function catalogEntryMatches(entry, needle) {
2409
2413
  return catalogEntryLookupKeys(entry).includes(needle);
@@ -2414,12 +2418,8 @@ function catalogEntryLookupKeys(entry) {
2414
2418
  for (const value of values) {
2415
2419
  const normalized = normalizeLookupKey(value);
2416
2420
  if (!normalized) continue;
2417
- keys.add(normalized);
2418
- keys.add(normalized.replace(/-/g, "_"));
2419
- if (normalized.startsWith("topolo-")) {
2420
- const withoutTopolo = normalized.slice("topolo-".length);
2421
- keys.add(withoutTopolo);
2422
- keys.add(withoutTopolo.replace(/-/g, "_"));
2421
+ for (const candidate of serviceKeyCandidates(normalized)) {
2422
+ keys.add(candidate);
2423
2423
  }
2424
2424
  }
2425
2425
  return [...keys];
@@ -2427,7 +2427,9 @@ function catalogEntryLookupKeys(entry) {
2427
2427
  function isCallableCatalogEntry(entry) {
2428
2428
  const kind = normalizeLookupKey(entry.surfaceKind || "");
2429
2429
  if (kind === "api" || kind === "technical_service" || kind === "service") return true;
2430
- return Boolean(entry.slug && Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, catalogEntryLookupKeys(entry)[0] ?? ""));
2430
+ return Boolean(
2431
+ entry.baseUrl && catalogEntryLookupKeys(entry).some((key) => Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, key))
2432
+ );
2431
2433
  }
2432
2434
  function describeError(body, fallback) {
2433
2435
  if (body && typeof body === "object") {
@@ -2612,7 +2614,7 @@ var TOOLS = [
2612
2614
  {
2613
2615
  name: "topolo_list_services",
2614
2616
  title: "List every Topolo service the agent can reach",
2615
- description: "Returns the service registry the SDK knows about: one entry per platform service with its stable service ID and current base URL. Use this to discover what `topolo_api_call` can address \u2014 the service IDs returned here are the exact values accepted by that tool. Pure metadata: no credential introspection, no permission check, no external call.",
2617
+ description: "Returns the credential-scoped service registry: one entry per platform service the current credential can use in its organization, with granted permissions and base URL. Use this to discover what `topolo_api_call` can address. This is not the global platform catalog.",
2616
2618
  requiredScopes: [],
2617
2619
  destructive: false,
2618
2620
  inputSchema: {
@@ -2620,12 +2622,21 @@ var TOOLS = [
2620
2622
  properties: {},
2621
2623
  additionalProperties: false
2622
2624
  },
2623
- handler: async () => {
2624
- const services = Object.keys(DEFAULT_SERVICE_URLS).sort().map((id) => ({
2625
- id,
2626
- defaultUrl: DEFAULT_SERVICE_URLS[id],
2627
- effectiveUrl: resolveServiceUrl(id)
2628
- }));
2625
+ handler: async (topolo) => {
2626
+ const services = (await topolo.client.listServices()).map((entry) => {
2627
+ const id = displayServiceId(entry);
2628
+ return {
2629
+ id,
2630
+ serviceId: entry.serviceId,
2631
+ slug: entry.slug,
2632
+ name: entry.name,
2633
+ baseUrl: entry.baseUrl,
2634
+ effectiveUrl: resolveServiceUrl(id) || entry.baseUrl,
2635
+ surfaceKind: entry.surfaceKind,
2636
+ launchable: entry.launchable,
2637
+ permissions: [...entry.permissions]
2638
+ };
2639
+ }).sort((left, right) => left.id.localeCompare(right.id));
2629
2640
  return { services };
2630
2641
  }
2631
2642
  },
@@ -2917,6 +2928,27 @@ function statusesAtOrWorse(threshold) {
2917
2928
  if (threshold === "needs_review") return ["missing", "needs_review"];
2918
2929
  return ["missing", "needs_review", "partial"];
2919
2930
  }
2931
+ function displayServiceId(entry) {
2932
+ for (const value of [entry.slug, entry.name, entry.serviceId]) {
2933
+ if (typeof value !== "string" || !value.trim()) continue;
2934
+ for (const candidate of serviceIdCandidates(value)) {
2935
+ if (Object.prototype.hasOwnProperty.call(DEFAULT_SERVICE_URLS, candidate)) {
2936
+ return candidate;
2937
+ }
2938
+ }
2939
+ }
2940
+ return serviceIdCandidates(entry.slug || entry.serviceId)[0] || entry.serviceId;
2941
+ }
2942
+ function serviceIdCandidates(value) {
2943
+ const normalized = value.trim().toLowerCase().replace(/\s+/g, "-");
2944
+ const withoutTopolo = normalized.startsWith("topolo-") ? normalized.slice("topolo-".length) : normalized;
2945
+ return [.../* @__PURE__ */ new Set([
2946
+ normalized,
2947
+ normalized.replace(/-/g, "_"),
2948
+ withoutTopolo,
2949
+ withoutTopolo.replace(/-/g, "_")
2950
+ ])].filter(Boolean);
2951
+ }
2920
2952
  function formatKnownValues(values, label) {
2921
2953
  const sorted = [...values].sort();
2922
2954
  const preview = sorted.slice(0, 25).join(", ");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topolo/mcp",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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": {