@topolo/mcp 0.2.4 → 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.
- package/dist/dispatch.d.ts +89 -0
- package/dist/dispatch.js +444 -0
- package/dist/index.js +850 -157
- package/package.json +8 -2
|
@@ -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 };
|
package/dist/dispatch.js
ADDED
|
@@ -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
|
+
};
|