@topolo/mcp 0.2.5 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,89 @@
1
+ import { Topolo } from '@topolo/sdk';
2
+
3
+ /**
4
+ * Scope-based tool gating.
5
+ *
6
+ * Each MCP tool declares the Topolo platform permissions it requires (same
7
+ * service.resource.action format TopoloAuth uses). On startup the server
8
+ * introspects the credential to get the actual granted permission set, and
9
+ * filters the advertised tool list down to what this caller can actually use.
10
+ *
11
+ * This is defence-in-depth — the backend still enforces permissions on every
12
+ * call — but it also means the agent never sees tools that would 403 on use,
13
+ * which dramatically reduces prompt noise and wasted attempts.
14
+ */
15
+ interface ScopeSet {
16
+ permissions: string[];
17
+ role: string;
18
+ orgSlug?: string;
19
+ }
20
+ declare function hasScope(set: ScopeSet, required: string): boolean;
21
+ declare function hasAnyScope(set: ScopeSet, required: string[]): boolean;
22
+
23
+ /**
24
+ * Tool registry. Each entry declares its MCP schema plus the platform scopes
25
+ * required to use it. The server filters the advertised list by the caller's
26
+ * granted scopes on startup.
27
+ *
28
+ * Tools NEVER take `orgId` as an input — the platform derives it from the
29
+ * credential. Write tools MUST set `destructive: true`; host clients are
30
+ * expected to surface confirmation UI before invoking them.
31
+ *
32
+ * Phase 1 ships read-only tools. Write tools will be added per-domain in
33
+ * subsequent phases alongside the corresponding SDK modules.
34
+ */
35
+ interface ToolDef {
36
+ name: string;
37
+ title: string;
38
+ description: string;
39
+ requiredScopes: string[];
40
+ destructive: boolean;
41
+ inputSchema: Record<string, unknown>;
42
+ handler: (topolo: Topolo, args: Record<string, unknown>) => Promise<unknown>;
43
+ }
44
+ declare const TOOLS: ToolDef[];
45
+ declare function filterToolsByScopes(tools: ToolDef[], scopes: ScopeSet): ToolDef[];
46
+
47
+ /**
48
+ * Transport-agnostic Topolo tool dispatch.
49
+ *
50
+ * The MCP stdio server (server.ts) and any non-MCP host (e.g. the TopoloChat
51
+ * Cloudflare Worker driving a persona agent loop) must NOT reimplement the
52
+ * tool registry — they share `TOOLS` and call `dispatchTopoloTool` so a tool
53
+ * added here lights up everywhere automatically.
54
+ *
55
+ * This module is deliberately free of MCP protocol imports so it bundles
56
+ * cleanly into a Worker. server.ts wraps these primitives with the MCP SDK.
57
+ */
58
+
59
+ interface ToolDispatchInput {
60
+ toolName: string;
61
+ args?: Record<string, unknown>;
62
+ topolo: Topolo;
63
+ scopes: ScopeSet;
64
+ tools?: ToolDef[];
65
+ }
66
+ type ToolDispatchFailureReason = 'not_available' | 'missing_scope' | 'auth' | 'permission' | 'handler';
67
+ type ToolDispatchResult = {
68
+ ok: true;
69
+ data: unknown;
70
+ durationMs: number;
71
+ } | {
72
+ ok: false;
73
+ error: string;
74
+ reason: ToolDispatchFailureReason;
75
+ durationMs: number;
76
+ requiredScopes?: string[];
77
+ };
78
+ interface ToolAdvertisement {
79
+ name: string;
80
+ title: string;
81
+ description: string;
82
+ requiredScopes: string[];
83
+ destructive: boolean;
84
+ inputSchema: Record<string, unknown>;
85
+ }
86
+ declare function listAvailableTopoloTools(scopes: ScopeSet, tools?: ToolDef[]): ToolAdvertisement[];
87
+ declare function dispatchTopoloTool(input: ToolDispatchInput): Promise<ToolDispatchResult>;
88
+
89
+ export { type ScopeSet, TOOLS, type ToolAdvertisement, type ToolDef, type ToolDispatchFailureReason, type ToolDispatchInput, type ToolDispatchResult, dispatchTopoloTool, filterToolsByScopes, hasAnyScope, hasScope, listAvailableTopoloTools };
@@ -0,0 +1,444 @@
1
+ // src/dispatch.ts
2
+ import { TopoloAuthError, TopoloPermissionError } from "@topolo/sdk";
3
+
4
+ // src/tools.ts
5
+ import {
6
+ APPLICATION_REQUIREMENTS,
7
+ APPLICATION_REQUIREMENTS_VERSION,
8
+ APPLICATIONS,
9
+ DEFAULT_SERVICE_URLS,
10
+ auditAllApplicationRequirements,
11
+ applicationRequirementScopes,
12
+ requirementsForApplication,
13
+ resolveServiceUrl
14
+ } from "@topolo/sdk";
15
+
16
+ // src/gating.ts
17
+ function hasPlatformAccess(set) {
18
+ return (set.role === "platform_super_admin" || set.role === "platform_admin") && set.orgSlug === "admin";
19
+ }
20
+ function hasScope(set, required) {
21
+ if (hasPlatformAccess(set)) return true;
22
+ if (set.permissions.includes("*")) return true;
23
+ const [servicePart, actionPart] = splitPermission(required);
24
+ if (!servicePart) return set.permissions.includes(required);
25
+ const [serviceId, resource] = splitService(servicePart);
26
+ const candidates = new Set([
27
+ required,
28
+ "*",
29
+ serviceId ? `${serviceId}.*` : null,
30
+ serviceId && resource && actionPart ? `${serviceId}.${resource}:*` : null,
31
+ serviceId && resource && actionPart ? `${serviceId}.${resource}.${actionPart}` : null,
32
+ resource && actionPart ? `${resource}:*` : null,
33
+ resource && actionPart ? `${resource}.${actionPart}` : null,
34
+ resource && actionPart ? `${resource}:${actionPart}` : null
35
+ ].filter((v) => v !== null));
36
+ return set.permissions.some((granted) => candidates.has(granted));
37
+ }
38
+ function hasAnyScope(set, required) {
39
+ if (required.length === 0) return true;
40
+ return required.some((r) => hasScope(set, r));
41
+ }
42
+ function splitPermission(permission) {
43
+ const idx = permission.indexOf(":");
44
+ if (idx === -1) return [permission, null];
45
+ return [permission.slice(0, idx), permission.slice(idx + 1)];
46
+ }
47
+ function splitService(servicePart) {
48
+ const idx = servicePart.indexOf(".");
49
+ if (idx === -1) return [null, servicePart];
50
+ return [servicePart.slice(0, idx), servicePart.slice(idx + 1)];
51
+ }
52
+
53
+ // src/tools.ts
54
+ var APPLICATION_IDS = Object.keys(APPLICATIONS).sort();
55
+ var SERVICE_IDS = Object.keys(DEFAULT_SERVICE_URLS).sort();
56
+ var HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
57
+ var TOOLS = [
58
+ {
59
+ name: "topolo_whoami",
60
+ title: "Describe the current Topolo credential",
61
+ description: "Returns the user, organization, and granted permissions attached to the current Topolo credential. Useful for confirming which organization the agent is currently acting on behalf of before taking further action. Never targets a different organization.",
62
+ requiredScopes: [],
63
+ destructive: false,
64
+ inputSchema: {
65
+ type: "object",
66
+ properties: {},
67
+ additionalProperties: false
68
+ },
69
+ handler: async (topolo) => topolo.identity.whoami()
70
+ },
71
+ {
72
+ name: "topolo_list_services",
73
+ title: "List every Topolo service the agent can reach",
74
+ 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.",
75
+ requiredScopes: [],
76
+ destructive: false,
77
+ inputSchema: {
78
+ type: "object",
79
+ properties: {},
80
+ additionalProperties: false
81
+ },
82
+ handler: async () => {
83
+ const services = Object.keys(DEFAULT_SERVICE_URLS).sort().map((id) => ({
84
+ id,
85
+ defaultUrl: DEFAULT_SERVICE_URLS[id],
86
+ effectiveUrl: resolveServiceUrl(id)
87
+ }));
88
+ return { services };
89
+ }
90
+ },
91
+ {
92
+ name: "topolo_list_applications",
93
+ title: "List every Topolo platform application",
94
+ description: "Returns the generated Topolo application catalog, including pages-only apps, tooling apps, and API-backed apps. Use this before choosing a service-specific or generic API operation. Pure metadata: no credential introspection, no permission check, no external call.",
95
+ requiredScopes: [],
96
+ destructive: false,
97
+ inputSchema: {
98
+ type: "object",
99
+ properties: {},
100
+ additionalProperties: false
101
+ },
102
+ handler: async () => ({
103
+ applications: APPLICATION_IDS.map((id) => APPLICATIONS[id])
104
+ })
105
+ },
106
+ {
107
+ name: "topolo_get_application",
108
+ title: "Describe one Topolo platform application",
109
+ description: "Returns one application catalog entry with its package name, production URL, deploy targets, and SDK service IDs when the app has callable APIs. Pure metadata: no credential introspection, no permission check, no external call.",
110
+ requiredScopes: [],
111
+ destructive: false,
112
+ inputSchema: {
113
+ type: "object",
114
+ properties: {
115
+ application: {
116
+ type: "string",
117
+ enum: APPLICATION_IDS,
118
+ description: "Application ID from topolo_list_applications."
119
+ }
120
+ },
121
+ required: ["application"],
122
+ additionalProperties: false
123
+ },
124
+ handler: async (_topolo, args) => {
125
+ const application = args["application"];
126
+ if (typeof application !== "string" || !APPLICATION_IDS.includes(application)) {
127
+ throw new Error(
128
+ `Unknown application "${String(application)}". Known: ${APPLICATION_IDS.join(", ")}`
129
+ );
130
+ }
131
+ return { application: APPLICATIONS[application] };
132
+ }
133
+ },
134
+ {
135
+ name: "topolo_list_application_requirements",
136
+ title: "List Topolo application build requirements",
137
+ description: "Returns the current versioned Topolo application-build contract, optionally filtered to one application. Use this before creating or expanding a Topolo app so the agent follows shared metadata, docs, auth, shell, service registration, deployment, observability, and verification requirements.",
138
+ requiredScopes: [],
139
+ destructive: false,
140
+ inputSchema: {
141
+ type: "object",
142
+ properties: {
143
+ application: {
144
+ type: "string",
145
+ enum: APPLICATION_IDS,
146
+ description: "Optional application ID from topolo_list_applications."
147
+ }
148
+ },
149
+ additionalProperties: false
150
+ },
151
+ handler: async (_topolo, args) => {
152
+ const application = args["application"];
153
+ if (application === void 0) {
154
+ return {
155
+ version: APPLICATION_REQUIREMENTS_VERSION,
156
+ application: null,
157
+ scopes: ["all", "browser", "api", "tooling", "agent_surface"],
158
+ requirements: APPLICATION_REQUIREMENTS
159
+ };
160
+ }
161
+ if (typeof application !== "string" || !APPLICATION_IDS.includes(application)) {
162
+ throw new Error(
163
+ `Unknown application "${String(application)}". Known: ${APPLICATION_IDS.join(", ")}`
164
+ );
165
+ }
166
+ const app = APPLICATIONS[application];
167
+ return {
168
+ version: APPLICATION_REQUIREMENTS_VERSION,
169
+ application: app,
170
+ scopes: applicationRequirementScopes(app),
171
+ requirements: requirementsForApplication(app)
172
+ };
173
+ }
174
+ },
175
+ {
176
+ name: "topolo_audit_applications",
177
+ title: "Audit Topolo applications against platform requirements",
178
+ description: "Returns catalog-backed conformance scores and migration-queue items for all Topolo apps, or one application when provided. Use this to see which shared platform requirements need implementation, verification, or deeper review.",
179
+ requiredScopes: [],
180
+ destructive: false,
181
+ inputSchema: {
182
+ type: "object",
183
+ properties: {
184
+ application: {
185
+ type: "string",
186
+ enum: APPLICATION_IDS,
187
+ description: "Optional application ID from topolo_list_applications."
188
+ },
189
+ failOn: {
190
+ type: "string",
191
+ enum: ["missing", "needs_review", "partial"],
192
+ description: "Optional conformance gate. Returns conformanceGate.passed=false when findings at this severity or worse exist."
193
+ }
194
+ },
195
+ additionalProperties: false
196
+ },
197
+ handler: async (_topolo, args) => {
198
+ const application = args["application"];
199
+ const failOn = args["failOn"];
200
+ if (failOn !== void 0 && typeof failOn !== "string") {
201
+ throw new Error("failOn must be one of: missing, needs_review, partial");
202
+ }
203
+ const gate = failOn ? normalizeFailOn(failOn) : null;
204
+ let report;
205
+ if (application === void 0) {
206
+ report = auditAllApplicationRequirements();
207
+ return gate ? { ...report, conformanceGate: evaluateApplicationAuditGate(report, gate) } : report;
208
+ }
209
+ if (typeof application !== "string" || !APPLICATION_IDS.includes(application)) {
210
+ throw new Error(
211
+ `Unknown application "${String(application)}". Known: ${APPLICATION_IDS.join(", ")}`
212
+ );
213
+ }
214
+ report = auditAllApplicationRequirements([application]);
215
+ return gate ? { ...report, conformanceGate: evaluateApplicationAuditGate(report, gate) } : report;
216
+ }
217
+ },
218
+ {
219
+ name: "topolo_crm_list_contacts",
220
+ title: "List CRM contacts",
221
+ 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.",
222
+ requiredScopes: ["crm.contacts:read"],
223
+ destructive: false,
224
+ inputSchema: {
225
+ type: "object",
226
+ properties: {
227
+ query: { type: "string", description: "Full-text search string" },
228
+ page: { type: "integer", minimum: 1, default: 1 },
229
+ pageSize: { type: "integer", minimum: 1, maximum: 200, default: 25 }
230
+ },
231
+ additionalProperties: false
232
+ },
233
+ handler: async (topolo, args) => {
234
+ return topolo.crm.listContacts({
235
+ q: typeof args["query"] === "string" ? args["query"] : void 0,
236
+ page: typeof args["page"] === "number" ? args["page"] : void 0,
237
+ pageSize: typeof args["pageSize"] === "number" ? args["pageSize"] : void 0
238
+ });
239
+ }
240
+ },
241
+ {
242
+ name: "topolo_api_call",
243
+ title: "Call any Topolo platform service",
244
+ description: `Low-level passthrough to any Topolo service. Use when no domain-specific tool exists for the operation you need. The request is signed with the current credential and scoped to the caller's organization \u2014 the target service will return 403 if your scopes do not permit the operation, even though this tool is advertised. Mutating calls (non-GET methods) require confirm:true. Known services: ${SERVICE_IDS.join(", ")}.`,
245
+ requiredScopes: [],
246
+ destructive: true,
247
+ inputSchema: {
248
+ type: "object",
249
+ properties: {
250
+ service: {
251
+ type: "string",
252
+ enum: SERVICE_IDS,
253
+ description: "Target service ID from the platform registry."
254
+ },
255
+ method: {
256
+ type: "string",
257
+ enum: HTTP_METHODS,
258
+ default: "GET",
259
+ description: "HTTP method. Defaults to GET."
260
+ },
261
+ path: {
262
+ type: "string",
263
+ minLength: 1,
264
+ description: 'Request path including any leading slash, e.g. "/api/contacts".'
265
+ },
266
+ body: {
267
+ description: "JSON request body. Ignored for GET."
268
+ },
269
+ query: {
270
+ type: "object",
271
+ additionalProperties: { type: "string" },
272
+ description: "Query string parameters as string values."
273
+ },
274
+ confirm: {
275
+ type: "boolean",
276
+ description: "Required for non-GET methods. Explicit human-in-the-loop acknowledgement for writes."
277
+ }
278
+ },
279
+ required: ["service", "path"],
280
+ additionalProperties: false
281
+ },
282
+ handler: async (topolo, args) => {
283
+ const service = args["service"];
284
+ const path = args["path"];
285
+ if (typeof service !== "string" || !SERVICE_IDS.includes(service)) {
286
+ throw new Error(`Unknown service "${String(service)}". Known: ${SERVICE_IDS.join(", ")}`);
287
+ }
288
+ if (typeof path !== "string" || !path) {
289
+ throw new Error("path is required");
290
+ }
291
+ const methodRaw = args["method"] ?? "GET";
292
+ if (typeof methodRaw !== "string" || !HTTP_METHODS.includes(methodRaw)) {
293
+ throw new Error(`Unknown HTTP method "${String(methodRaw)}"`);
294
+ }
295
+ const method = methodRaw;
296
+ const query = args["query"];
297
+ const normalisedQuery = query && typeof query === "object" && !Array.isArray(query) ? Object.fromEntries(
298
+ Object.entries(query).map(([k, v]) => [k, String(v)])
299
+ ) : void 0;
300
+ return topolo.client.request({
301
+ service,
302
+ method,
303
+ path,
304
+ body: args["body"],
305
+ ...normalisedQuery ? { query: normalisedQuery } : {},
306
+ confirm: Boolean(args["confirm"])
307
+ });
308
+ }
309
+ },
310
+ {
311
+ name: "topolo_crm_get_contact",
312
+ title: "Fetch a single CRM contact",
313
+ description: "Fetch a single contact by id from TopoloCRM, scoped to the caller's organization.",
314
+ requiredScopes: ["crm.contacts:read"],
315
+ destructive: false,
316
+ inputSchema: {
317
+ type: "object",
318
+ properties: {
319
+ contactId: { type: "string", minLength: 1 }
320
+ },
321
+ required: ["contactId"],
322
+ additionalProperties: false
323
+ },
324
+ handler: async (topolo, args) => {
325
+ const contactId = args["contactId"];
326
+ if (typeof contactId !== "string" || !contactId) {
327
+ throw new Error("contactId is required");
328
+ }
329
+ const contact = await topolo.crm.getContact(contactId);
330
+ if (!contact) throw new Error(`Contact not found: ${contactId}`);
331
+ return contact;
332
+ }
333
+ }
334
+ ];
335
+ function filterToolsByScopes(tools, scopes) {
336
+ return tools.filter((t) => hasAnyScope(scopes, t.requiredScopes));
337
+ }
338
+ function evaluateApplicationAuditGate(report, threshold) {
339
+ const counts = {
340
+ met: 0,
341
+ partial: 0,
342
+ missing: 0,
343
+ needs_review: 0
344
+ };
345
+ for (const audit of report.applications) {
346
+ for (const finding of audit.findings) {
347
+ counts[finding.status] += 1;
348
+ }
349
+ }
350
+ const failingFindings = statusesAtOrWorse(threshold).reduce(
351
+ (total, status) => total + counts[status],
352
+ 0
353
+ );
354
+ return {
355
+ threshold,
356
+ passed: failingFindings === 0,
357
+ message: failingFindings === 0 ? `No findings at or above ${threshold}.` : `${failingFindings} finding(s) at or above ${threshold}.`,
358
+ failingFindings,
359
+ counts
360
+ };
361
+ }
362
+ function normalizeFailOn(value) {
363
+ const normalized = value.trim().toLowerCase().replace(/-/g, "_");
364
+ if (normalized === "missing" || normalized === "needs_review" || normalized === "partial") {
365
+ return normalized;
366
+ }
367
+ throw new Error("failOn must be one of: missing, needs_review, partial");
368
+ }
369
+ function statusesAtOrWorse(threshold) {
370
+ if (threshold === "missing") return ["missing"];
371
+ if (threshold === "needs_review") return ["missing", "needs_review"];
372
+ return ["missing", "needs_review", "partial"];
373
+ }
374
+
375
+ // src/dispatch.ts
376
+ function listAvailableTopoloTools(scopes, tools = TOOLS) {
377
+ return filterToolsByScopes(tools, scopes).map((t) => ({
378
+ name: t.name,
379
+ title: t.title,
380
+ description: t.description,
381
+ requiredScopes: t.requiredScopes,
382
+ destructive: t.destructive,
383
+ inputSchema: t.inputSchema
384
+ }));
385
+ }
386
+ async function dispatchTopoloTool(input) {
387
+ const started = Date.now();
388
+ const tools = input.tools ?? TOOLS;
389
+ const available = filterToolsByScopes(tools, input.scopes);
390
+ const tool = available.find((t) => t.name === input.toolName);
391
+ if (!tool) {
392
+ return {
393
+ ok: false,
394
+ reason: "not_available",
395
+ error: `Tool "${input.toolName}" is not available for this credential.`,
396
+ durationMs: Date.now() - started
397
+ };
398
+ }
399
+ if (!hasAnyScope(input.scopes, tool.requiredScopes)) {
400
+ return {
401
+ ok: false,
402
+ reason: "missing_scope",
403
+ error: `Tool "${tool.name}" requires one of: ${tool.requiredScopes.join(", ") || "(none)"}.`,
404
+ requiredScopes: tool.requiredScopes,
405
+ durationMs: Date.now() - started
406
+ };
407
+ }
408
+ try {
409
+ const data = await tool.handler(input.topolo, input.args ?? {});
410
+ return { ok: true, data, durationMs: Date.now() - started };
411
+ } catch (err) {
412
+ if (err instanceof TopoloPermissionError) {
413
+ return {
414
+ ok: false,
415
+ reason: "permission",
416
+ error: `Permission denied. Required: ${err.required.join(", ") || "(unknown)"}`,
417
+ requiredScopes: err.required,
418
+ durationMs: Date.now() - started
419
+ };
420
+ }
421
+ if (err instanceof TopoloAuthError) {
422
+ return {
423
+ ok: false,
424
+ reason: "auth",
425
+ error: `Auth error: ${err.message}`,
426
+ durationMs: Date.now() - started
427
+ };
428
+ }
429
+ return {
430
+ ok: false,
431
+ reason: "handler",
432
+ error: err instanceof Error ? err.message : String(err),
433
+ durationMs: Date.now() - started
434
+ };
435
+ }
436
+ }
437
+ export {
438
+ TOOLS,
439
+ dispatchTopoloTool,
440
+ filterToolsByScopes,
441
+ hasAnyScope,
442
+ hasScope,
443
+ listAvailableTopoloTools
444
+ };