@centrali-io/centrali-mcp 4.4.5 → 4.4.6

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,1051 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { CentraliSDK } from "@centrali-io/centrali-sdk";
3
+ import axios, { AxiosInstance } from "axios";
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * Ensures the SDK has a valid token by making a lightweight SDK call if needed.
8
+ */
9
+ async function ensureToken(sdk: CentraliSDK): Promise<string | null> {
10
+ let token = sdk.getToken();
11
+ if (token) return token;
12
+ try {
13
+ await sdk.functions.list({ limit: 1 });
14
+ } catch {
15
+ // Ignore — we only need the token refresh side effect
16
+ }
17
+ return sdk.getToken();
18
+ }
19
+
20
+ /**
21
+ * Creates an axios instance for the IAM service account API.
22
+ */
23
+ function createIamClient(sdk: CentraliSDK, centraliUrl: string, workspaceId: string, baseSuffix: string): AxiosInstance {
24
+ const url = new URL(centraliUrl);
25
+ const hostname = url.hostname.startsWith("api.")
26
+ ? url.hostname
27
+ : `api.${url.hostname}`;
28
+ const baseURL = `${url.protocol}//${hostname}/iam/workspace/${workspaceId}/api/v1/${baseSuffix}`;
29
+
30
+ const client = axios.create({ baseURL });
31
+
32
+ client.interceptors.request.use(async (config) => {
33
+ const token = await ensureToken(sdk);
34
+ if (token) {
35
+ config.headers.Authorization = `Bearer ${token}`;
36
+ }
37
+ return config;
38
+ });
39
+
40
+ client.interceptors.response.use(
41
+ (response) => response,
42
+ async (error) => {
43
+ const originalRequest = error.config;
44
+ const isAuthError = error.response?.status === 401 || error.response?.status === 403;
45
+
46
+ if (isAuthError && !originalRequest._hasRetried) {
47
+ originalRequest._hasRetried = true;
48
+ try {
49
+ await sdk.functions.list({ limit: 1 });
50
+ } catch { /* token refresh side effect */ }
51
+
52
+ const token = sdk.getToken();
53
+ if (token) {
54
+ originalRequest.headers.Authorization = `Bearer ${token}`;
55
+ return client.request(originalRequest);
56
+ }
57
+ }
58
+ return Promise.reject(error);
59
+ }
60
+ );
61
+
62
+ return client;
63
+ }
64
+
65
+ function formatError(error: unknown, context: string): string {
66
+ if (error && typeof error === "object") {
67
+ const e = error as Record<string, any>;
68
+ if (e.response?.data) {
69
+ const d = e.response.data;
70
+ const code = d.code ?? d.error?.code ?? e.response.status ?? "ERROR";
71
+ const message = d.message ?? d.error?.message ?? JSON.stringify(d);
72
+ return `Error ${context}: [${code}] ${message}`;
73
+ }
74
+ if ("message" in e) {
75
+ return `Error ${context}: ${e.message}`;
76
+ }
77
+ }
78
+ return `Error ${context}: ${error instanceof Error ? error.message : String(error)}`;
79
+ }
80
+
81
+ export function registerServiceAccountTools(server: McpServer, sdk: CentraliSDK, centraliUrl: string, workspaceId: string, ownClientId?: string) {
82
+ const getSaClient = () => createIamClient(sdk, centraliUrl, workspaceId, "service-accounts");
83
+ const getRolesClient = () => createIamClient(sdk, centraliUrl, workspaceId, "roles");
84
+ const getGroupsClient = () => createIamClient(sdk, centraliUrl, workspaceId, "groups");
85
+
86
+ // ── Identity ─────────────────────────────────────────────────────
87
+
88
+ server.tool(
89
+ "get_current_identity",
90
+ "Get the current MCP service account's identity — its numeric ID, clientId, name, roles, and groups. Call this first when you need to check or fix your own permissions (e.g., after a 403 error). Returns the SA details needed for scan_service_account_permissions and generate_remediation.",
91
+ {},
92
+ async () => {
93
+ try {
94
+ const result = await getSaClient().get("/", { params: { pageSize: 100 } });
95
+ const accounts = result.data?.data ?? result.data ?? [];
96
+ const me = Array.isArray(accounts)
97
+ ? accounts.find((sa: any) => sa.clientId === ownClientId)
98
+ : null;
99
+
100
+ if (!me) {
101
+ return {
102
+ content: [{
103
+ type: "text",
104
+ text: JSON.stringify({
105
+ error: "Could not identify current service account",
106
+ clientId: ownClientId,
107
+ hint: "The service account may not have permission to list service accounts, or the clientId doesn't match any SA in this workspace.",
108
+ }, null, 2),
109
+ }],
110
+ isError: true,
111
+ };
112
+ }
113
+
114
+ return {
115
+ content: [{
116
+ type: "text",
117
+ text: JSON.stringify({
118
+ id: me.id,
119
+ clientId: me.clientId,
120
+ name: me.name,
121
+ description: me.description,
122
+ revoked: me.revoked,
123
+ _hint: "Use this 'id' (numeric) with scan_service_account_permissions, simulate_service_account_permission, and generate_remediation to check or fix your own permissions.",
124
+ }, null, 2),
125
+ }],
126
+ };
127
+ } catch (error: unknown) {
128
+ return {
129
+ content: [{ type: "text", text: formatError(error, "getting current identity") }],
130
+ isError: true,
131
+ };
132
+ }
133
+ }
134
+ );
135
+
136
+ // ── Service Account CRUD ─────────────────────────────────────────
137
+
138
+ server.tool(
139
+ "list_service_accounts",
140
+ "List all service accounts in the workspace. Service accounts are machine identities used for backend-to-backend API access (client_credentials flow).",
141
+ {
142
+ page: z.number().optional().describe("Page number (default: 1)"),
143
+ pageSize: z.number().optional().describe("Results per page (default: 20)"),
144
+ },
145
+ async ({ page, pageSize }) => {
146
+ try {
147
+ const params: Record<string, any> = {};
148
+ if (page !== undefined) params.page = page;
149
+ if (pageSize !== undefined) params.pageSize = pageSize;
150
+ const result = await getSaClient().get("/", { params });
151
+ return {
152
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
153
+ };
154
+ } catch (error: unknown) {
155
+ return {
156
+ content: [{ type: "text", text: formatError(error, "listing service accounts") }],
157
+ isError: true,
158
+ };
159
+ }
160
+ }
161
+ );
162
+
163
+ server.tool(
164
+ "get_service_account",
165
+ "Get details of a specific service account by its numeric ID. Returns name, clientId, description, and revocation status. Does NOT return the clientSecret (it's only shown once at creation time).",
166
+ {
167
+ serviceAccountId: z.number().describe("The service account numeric ID"),
168
+ },
169
+ async ({ serviceAccountId }) => {
170
+ try {
171
+ const result = await getSaClient().get(`/${serviceAccountId}`);
172
+ return {
173
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
174
+ };
175
+ } catch (error: unknown) {
176
+ return {
177
+ content: [{ type: "text", text: formatError(error, `getting service account '${serviceAccountId}'`) }],
178
+ isError: true,
179
+ };
180
+ }
181
+ }
182
+ );
183
+
184
+ server.tool(
185
+ "create_service_account",
186
+ "Create a new service account (machine identity). Returns the clientId and clientSecret — the secret is ONLY shown once, so store it securely. Use the credentials with OAuth2 client_credentials flow to get access tokens.",
187
+ {
188
+ name: z.string().describe("Display name for the service account (e.g., 'CI/CD Pipeline', 'Analytics Worker')"),
189
+ description: z.string().optional().describe("Optional description of what this service account is used for"),
190
+ },
191
+ async ({ name, description }) => {
192
+ try {
193
+ const input: Record<string, any> = { name };
194
+ if (description !== undefined) input.description = description;
195
+ const result = await getSaClient().post("/", input);
196
+ return {
197
+ content: [{
198
+ type: "text",
199
+ text: JSON.stringify(
200
+ {
201
+ ...result.data,
202
+ _warning: "IMPORTANT: The clientSecret is only shown once. Store it securely now — it cannot be retrieved later. Use rotate_service_account_secret to generate a new one if lost.",
203
+ },
204
+ null,
205
+ 2
206
+ ),
207
+ }],
208
+ };
209
+ } catch (error: unknown) {
210
+ return {
211
+ content: [{ type: "text", text: formatError(error, `creating service account '${name}'`) }],
212
+ isError: true,
213
+ };
214
+ }
215
+ }
216
+ );
217
+
218
+ server.tool(
219
+ "update_service_account_name",
220
+ "Update the display name of a service account.",
221
+ {
222
+ serviceAccountId: z.number().describe("The service account numeric ID"),
223
+ name: z.string().describe("New display name"),
224
+ },
225
+ async ({ serviceAccountId, name }) => {
226
+ try {
227
+ const result = await getSaClient().put(`/${serviceAccountId}/name`, { name });
228
+ return {
229
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
230
+ };
231
+ } catch (error: unknown) {
232
+ return {
233
+ content: [{ type: "text", text: formatError(error, `updating service account '${serviceAccountId}' name`) }],
234
+ isError: true,
235
+ };
236
+ }
237
+ }
238
+ );
239
+
240
+ server.tool(
241
+ "update_service_account_description",
242
+ "Update the description of a service account.",
243
+ {
244
+ serviceAccountId: z.number().describe("The service account numeric ID"),
245
+ description: z.string().nullable().describe("New description (or null to clear)"),
246
+ },
247
+ async ({ serviceAccountId, description }) => {
248
+ try {
249
+ const result = await getSaClient().put(`/${serviceAccountId}/description`, { description });
250
+ return {
251
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
252
+ };
253
+ } catch (error: unknown) {
254
+ return {
255
+ content: [{ type: "text", text: formatError(error, `updating service account '${serviceAccountId}' description`) }],
256
+ isError: true,
257
+ };
258
+ }
259
+ }
260
+ );
261
+
262
+ server.tool(
263
+ "delete_service_account",
264
+ "Permanently delete a service account. This is irreversible — all tokens are invalidated immediately.",
265
+ {
266
+ serviceAccountId: z.number().describe("The service account numeric ID to delete"),
267
+ },
268
+ async ({ serviceAccountId }) => {
269
+ try {
270
+ await getSaClient().delete(`/${serviceAccountId}`);
271
+ return {
272
+ content: [{ type: "text", text: `Service account '${serviceAccountId}' deleted successfully.` }],
273
+ };
274
+ } catch (error: unknown) {
275
+ return {
276
+ content: [{ type: "text", text: formatError(error, `deleting service account '${serviceAccountId}'`) }],
277
+ isError: true,
278
+ };
279
+ }
280
+ }
281
+ );
282
+
283
+ // ── Secret Rotation & Revocation ─────────────────────────────────
284
+
285
+ server.tool(
286
+ "rotate_service_account_secret",
287
+ "Rotate the client secret of a service account. The old secret is immediately invalidated. Returns the new clientSecret — store it securely, it's only shown once.",
288
+ {
289
+ serviceAccountId: z.number().describe("The service account numeric ID"),
290
+ },
291
+ async ({ serviceAccountId }) => {
292
+ try {
293
+ const result = await getSaClient().post(`/${serviceAccountId}/rotate`);
294
+ return {
295
+ content: [{
296
+ type: "text",
297
+ text: JSON.stringify(
298
+ {
299
+ ...result.data,
300
+ _warning: "The old secret is now invalid. Store the new clientSecret securely — it cannot be retrieved later.",
301
+ },
302
+ null,
303
+ 2
304
+ ),
305
+ }],
306
+ };
307
+ } catch (error: unknown) {
308
+ return {
309
+ content: [{ type: "text", text: formatError(error, `rotating secret for service account '${serviceAccountId}'`) }],
310
+ isError: true,
311
+ };
312
+ }
313
+ }
314
+ );
315
+
316
+ server.tool(
317
+ "revoke_service_account",
318
+ "Revoke a service account. All existing tokens are invalidated and no new tokens can be issued. This cannot be undone.",
319
+ {
320
+ serviceAccountId: z.number().describe("The service account numeric ID to revoke"),
321
+ },
322
+ async ({ serviceAccountId }) => {
323
+ try {
324
+ const result = await getSaClient().post(`/${serviceAccountId}/revoke`);
325
+ return {
326
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
327
+ };
328
+ } catch (error: unknown) {
329
+ return {
330
+ content: [{ type: "text", text: formatError(error, `revoking service account '${serviceAccountId}'`) }],
331
+ isError: true,
332
+ };
333
+ }
334
+ }
335
+ );
336
+
337
+ // ── Dev Token Generation ─────────────────────────────────────────
338
+
339
+ server.tool(
340
+ "generate_dev_token",
341
+ "Generate a short-lived development token for a service account. Useful for testing and local development without the full OAuth2 client_credentials flow. The token has limited TTL.",
342
+ {
343
+ serviceAccountId: z.number().describe("The service account numeric ID"),
344
+ ttlSeconds: z.number().describe("Token time-to-live in seconds (valid options depend on server config, typically 3600, 86400, 604800)"),
345
+ },
346
+ async ({ serviceAccountId, ttlSeconds }) => {
347
+ try {
348
+ const result = await getSaClient().post(`/${serviceAccountId}/dev-token`, { ttlSeconds });
349
+ return {
350
+ content: [{
351
+ type: "text",
352
+ text: JSON.stringify(
353
+ {
354
+ ...result.data,
355
+ _note: "This is a short-lived token for development. For production, use the OAuth2 client_credentials flow with the clientId and clientSecret.",
356
+ },
357
+ null,
358
+ 2
359
+ ),
360
+ }],
361
+ };
362
+ } catch (error: unknown) {
363
+ return {
364
+ content: [{ type: "text", text: formatError(error, `generating dev token for service account '${serviceAccountId}'`) }],
365
+ isError: true,
366
+ };
367
+ }
368
+ }
369
+ );
370
+
371
+ // ── Permission Introspection ─────────────────────────────────────
372
+
373
+ server.tool(
374
+ "scan_service_account_permissions",
375
+ "Scan all permissions for a service account. Returns a full access matrix showing every resource and action with Allow/Deny decisions and reasons. Use this to audit what a service account can and cannot do.",
376
+ {
377
+ serviceAccountId: z.number().describe("The service account numeric ID"),
378
+ filter: z.enum(["all", "allowed", "denied"]).optional().describe("Filter results: 'all' (default), 'allowed' (only granted), 'denied' (only missing)"),
379
+ resourceCategory: z.string().optional().describe("Filter by resource category (e.g., 'workspace')"),
380
+ },
381
+ async ({ serviceAccountId, filter, resourceCategory }) => {
382
+ try {
383
+ const params: Record<string, any> = {};
384
+ if (filter) params.filter = filter;
385
+ if (resourceCategory) params.resourceCategory = resourceCategory;
386
+ const result = await getSaClient().get(`/${serviceAccountId}/permissions/scan`, { params });
387
+ return {
388
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
389
+ };
390
+ } catch (error: unknown) {
391
+ return {
392
+ content: [{ type: "text", text: formatError(error, `scanning permissions for service account '${serviceAccountId}'`) }],
393
+ isError: true,
394
+ };
395
+ }
396
+ }
397
+ );
398
+
399
+ server.tool(
400
+ "simulate_service_account_permission",
401
+ "Simulate an authorization check for a service account against a specific resource and action. Returns the decision (Allow/Deny), evaluation trace, and suggestions for granting access if denied.",
402
+ {
403
+ serviceAccountId: z.number().describe("The service account numeric ID"),
404
+ resource: z.string().describe("Resource identifier (e.g., 'workspace::records', 'workspace::compute-functions')"),
405
+ resourceCategory: z.string().describe("Resource category (e.g., 'workspace')"),
406
+ action: z.string().describe("Action to check (e.g., 'create', 'retrieve', 'update', 'delete', 'list', 'execute', 'manage')"),
407
+ },
408
+ async ({ serviceAccountId, resource, resourceCategory, action }) => {
409
+ try {
410
+ const result = await getSaClient().post(`/${serviceAccountId}/permissions/simulate`, {
411
+ resource,
412
+ resourceCategory,
413
+ action,
414
+ });
415
+ return {
416
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
417
+ };
418
+ } catch (error: unknown) {
419
+ return {
420
+ content: [{ type: "text", text: formatError(error, `simulating permission for service account '${serviceAccountId}'`) }],
421
+ isError: true,
422
+ };
423
+ }
424
+ }
425
+ );
426
+
427
+ // ── Permission Remediation ────────────────────────────────────────
428
+
429
+ const getAccessClient = () => createIamClient(sdk, centraliUrl, workspaceId, "access/permissions");
430
+
431
+ const RemediationOptionZod = z.object({
432
+ optionId: z.string(),
433
+ type: z.enum(["role_assignment", "group_assignment", "create_new"]),
434
+ description: z.string(),
435
+ effort: z.enum(["low", "medium", "high"]),
436
+ recommended: z.boolean().optional(),
437
+ steps: z.array(z.object({
438
+ step: z.number(),
439
+ action: z.enum(["assign_role", "assign_group", "create_policy", "create_permission"]),
440
+ details: z.record(z.string(), z.unknown()),
441
+ humanReadable: z.string(),
442
+ })),
443
+ sideEffects: z.array(z.object({
444
+ type: z.enum(["grants_additional_access", "adds_to_group", "creates_resource"]),
445
+ description: z.string(),
446
+ details: z.record(z.string(), z.unknown()).optional(),
447
+ })),
448
+ }).describe("The remediation option object from generate_remediation — pass the full option object exactly as returned");
449
+
450
+ server.tool(
451
+ "generate_remediation",
452
+ "Generate remediation options for granting a service account access to a specific resource and actions. Returns multiple options: assign an existing role, join a group, or create a minimal new policy. Use after scan_service_account_permissions or simulate_service_account_permission shows Deny.",
453
+ {
454
+ serviceAccountId: z.number().describe("The service account numeric ID"),
455
+ resource: z.string().describe("Resource identifier (e.g., 'workspace::records', 'workspace::compute-functions')"),
456
+ resourceCategory: z.string().describe("Resource category (e.g., 'workspace')"),
457
+ actions: z.array(z.string()).describe("Actions to grant (e.g., ['create', 'retrieve', 'list'])"),
458
+ },
459
+ async ({ serviceAccountId, resource, resourceCategory, actions }) => {
460
+ try {
461
+ const result = await getAccessClient().post("/remediation/generate", {
462
+ targetType: "service_account",
463
+ targetId: serviceAccountId,
464
+ resource,
465
+ resourceCategory,
466
+ actions,
467
+ });
468
+ return {
469
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
470
+ };
471
+ } catch (error: unknown) {
472
+ return {
473
+ content: [{ type: "text", text: formatError(error, `generating remediation for service account '${serviceAccountId}'`) }],
474
+ isError: true,
475
+ };
476
+ }
477
+ }
478
+ );
479
+
480
+ server.tool(
481
+ "preview_remediation",
482
+ "Preview what changes would be made by applying a specific remediation option. Shows what would be created or modified without actually making changes. Call generate_remediation first to get the options.",
483
+ {
484
+ serviceAccountId: z.number().describe("The service account numeric ID"),
485
+ resource: z.string().describe("Resource identifier (same as used in generate_remediation)"),
486
+ resourceCategory: z.string().describe("Resource category (same as used in generate_remediation)"),
487
+ actions: z.array(z.string()).describe("Actions (same as used in generate_remediation)"),
488
+ option: RemediationOptionZod,
489
+ },
490
+ async ({ serviceAccountId, resource, resourceCategory, actions, option }) => {
491
+ try {
492
+ const result = await getAccessClient().post("/remediation/preview", {
493
+ targetType: "service_account",
494
+ targetId: serviceAccountId,
495
+ resource,
496
+ resourceCategory,
497
+ actions,
498
+ optionId: option.optionId,
499
+ option,
500
+ });
501
+ return {
502
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
503
+ };
504
+ } catch (error: unknown) {
505
+ return {
506
+ content: [{ type: "text", text: formatError(error, `previewing remediation for service account '${serviceAccountId}'`) }],
507
+ isError: true,
508
+ };
509
+ }
510
+ }
511
+ );
512
+
513
+ server.tool(
514
+ "apply_remediation",
515
+ "Apply a remediation option to actually grant access. Creates roles, policies, or group assignments as needed. After applying, the service account will have the requested permissions. The response includes a verification check confirming the access was granted.",
516
+ {
517
+ serviceAccountId: z.number().describe("The service account numeric ID"),
518
+ resource: z.string().describe("Resource identifier (same as used in generate_remediation)"),
519
+ resourceCategory: z.string().describe("Resource category (same as used in generate_remediation)"),
520
+ actions: z.array(z.string()).describe("Actions (same as used in generate_remediation)"),
521
+ option: RemediationOptionZod,
522
+ },
523
+ async ({ serviceAccountId, resource, resourceCategory, actions, option }) => {
524
+ try {
525
+ const result = await getAccessClient().post("/remediation/apply", {
526
+ targetType: "service_account",
527
+ targetId: serviceAccountId,
528
+ resource,
529
+ resourceCategory,
530
+ actions,
531
+ optionId: option.optionId,
532
+ option,
533
+ dryRun: false,
534
+ });
535
+ return {
536
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
537
+ };
538
+ } catch (error: unknown) {
539
+ return {
540
+ content: [{ type: "text", text: formatError(error, `applying remediation for service account '${serviceAccountId}'`) }],
541
+ isError: true,
542
+ };
543
+ }
544
+ }
545
+ );
546
+
547
+ // ── Service Account ↔ Roles ──────────────────────────────────────
548
+
549
+ server.tool(
550
+ "list_service_account_roles",
551
+ "List all roles assigned to a service account.",
552
+ {
553
+ serviceAccountId: z.number().describe("The service account numeric ID"),
554
+ },
555
+ async ({ serviceAccountId }) => {
556
+ try {
557
+ const result = await getSaClient().get(`/${serviceAccountId}/roles`);
558
+ return {
559
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
560
+ };
561
+ } catch (error: unknown) {
562
+ return {
563
+ content: [{ type: "text", text: formatError(error, `listing roles for service account '${serviceAccountId}'`) }],
564
+ isError: true,
565
+ };
566
+ }
567
+ }
568
+ );
569
+
570
+ server.tool(
571
+ "assign_role_to_service_account",
572
+ "Assign a role to a service account. The service account inherits all permissions defined in the role.",
573
+ {
574
+ serviceAccountId: z.number().describe("The service account numeric ID"),
575
+ roleId: z.string().describe("The role ID (UUID) to assign"),
576
+ },
577
+ async ({ serviceAccountId, roleId }) => {
578
+ try {
579
+ const result = await getSaClient().post(`/${serviceAccountId}/roles/${roleId}`);
580
+ return {
581
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
582
+ };
583
+ } catch (error: unknown) {
584
+ return {
585
+ content: [{ type: "text", text: formatError(error, `assigning role '${roleId}' to service account '${serviceAccountId}'`) }],
586
+ isError: true,
587
+ };
588
+ }
589
+ }
590
+ );
591
+
592
+ server.tool(
593
+ "remove_role_from_service_account",
594
+ "Remove a role from a service account. The service account loses all permissions from this role.",
595
+ {
596
+ serviceAccountId: z.number().describe("The service account numeric ID"),
597
+ roleId: z.string().describe("The role ID (UUID) to remove"),
598
+ },
599
+ async ({ serviceAccountId, roleId }) => {
600
+ try {
601
+ await getSaClient().delete(`/${serviceAccountId}/roles/${roleId}`);
602
+ return {
603
+ content: [{ type: "text", text: `Role '${roleId}' removed from service account '${serviceAccountId}'.` }],
604
+ };
605
+ } catch (error: unknown) {
606
+ return {
607
+ content: [{ type: "text", text: formatError(error, `removing role '${roleId}' from service account '${serviceAccountId}'`) }],
608
+ isError: true,
609
+ };
610
+ }
611
+ }
612
+ );
613
+
614
+ // ── Service Account ↔ Groups ─────────────────────────────────────
615
+
616
+ server.tool(
617
+ "list_service_account_groups",
618
+ "List all groups a service account belongs to.",
619
+ {
620
+ serviceAccountId: z.number().describe("The service account numeric ID"),
621
+ },
622
+ async ({ serviceAccountId }) => {
623
+ try {
624
+ const result = await getSaClient().get(`/${serviceAccountId}/groups`);
625
+ return {
626
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
627
+ };
628
+ } catch (error: unknown) {
629
+ return {
630
+ content: [{ type: "text", text: formatError(error, `listing groups for service account '${serviceAccountId}'`) }],
631
+ isError: true,
632
+ };
633
+ }
634
+ }
635
+ );
636
+
637
+ server.tool(
638
+ "add_service_account_to_group",
639
+ "Add a service account to a group. The service account inherits all roles assigned to the group.",
640
+ {
641
+ serviceAccountId: z.number().describe("The service account numeric ID"),
642
+ groupId: z.string().describe("The group ID (UUID) to add the service account to"),
643
+ },
644
+ async ({ serviceAccountId, groupId }) => {
645
+ try {
646
+ const result = await getSaClient().post(`/${serviceAccountId}/groups/${groupId}`);
647
+ return {
648
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
649
+ };
650
+ } catch (error: unknown) {
651
+ return {
652
+ content: [{ type: "text", text: formatError(error, `adding service account '${serviceAccountId}' to group '${groupId}'`) }],
653
+ isError: true,
654
+ };
655
+ }
656
+ }
657
+ );
658
+
659
+ server.tool(
660
+ "remove_service_account_from_group",
661
+ "Remove a service account from a group. The service account loses all permissions inherited through this group.",
662
+ {
663
+ serviceAccountId: z.number().describe("The service account numeric ID"),
664
+ groupId: z.string().describe("The group ID (UUID) to remove the service account from"),
665
+ },
666
+ async ({ serviceAccountId, groupId }) => {
667
+ try {
668
+ await getSaClient().delete(`/${serviceAccountId}/groups/${groupId}`);
669
+ return {
670
+ content: [{ type: "text", text: `Service account '${serviceAccountId}' removed from group '${groupId}'.` }],
671
+ };
672
+ } catch (error: unknown) {
673
+ return {
674
+ content: [{ type: "text", text: formatError(error, `removing service account '${serviceAccountId}' from group '${groupId}'`) }],
675
+ isError: true,
676
+ };
677
+ }
678
+ }
679
+ );
680
+
681
+ // ── Role CRUD ────────────────────────────────────────────────────
682
+
683
+ server.tool(
684
+ "list_roles",
685
+ "List all roles in the workspace. Roles bundle permissions that can be assigned to service accounts or groups.",
686
+ {
687
+ page: z.number().optional().describe("Page number (default: 1)"),
688
+ pageSize: z.number().optional().describe("Results per page (default: 20)"),
689
+ },
690
+ async ({ page, pageSize }) => {
691
+ try {
692
+ const params: Record<string, any> = {};
693
+ if (page !== undefined) params.page = page;
694
+ if (pageSize !== undefined) params.pageSize = pageSize;
695
+ const result = await getRolesClient().get("/", { params });
696
+ return {
697
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
698
+ };
699
+ } catch (error: unknown) {
700
+ return {
701
+ content: [{ type: "text", text: formatError(error, "listing roles") }],
702
+ isError: true,
703
+ };
704
+ }
705
+ }
706
+ );
707
+
708
+ server.tool(
709
+ "get_role",
710
+ "Get details of a role including its permissions.",
711
+ {
712
+ roleId: z.string().describe("The role ID (UUID)"),
713
+ },
714
+ async ({ roleId }) => {
715
+ try {
716
+ const result = await getRolesClient().get(`/${roleId}`);
717
+ return {
718
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
719
+ };
720
+ } catch (error: unknown) {
721
+ return {
722
+ content: [{ type: "text", text: formatError(error, `getting role '${roleId}'`) }],
723
+ isError: true,
724
+ };
725
+ }
726
+ }
727
+ );
728
+
729
+ server.tool(
730
+ "create_role",
731
+ "Create a new role with a set of permissions. Permissions are UUIDs from the IAM permission registry. Use scan_service_account_permissions to discover available resource/action pairs.",
732
+ {
733
+ name: z.string().describe("Role name (e.g., 'Data Reader', 'Compute Admin')"),
734
+ description: z.string().optional().describe("Optional description of the role's purpose"),
735
+ permissions: z.array(z.string()).optional().describe("Array of permission UUIDs to include in this role"),
736
+ },
737
+ async ({ name, description, permissions }) => {
738
+ try {
739
+ const input: Record<string, any> = { name };
740
+ if (description !== undefined) input.description = description;
741
+ if (permissions !== undefined) input.permissions = permissions;
742
+ const result = await getRolesClient().post("/", input);
743
+ return {
744
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
745
+ };
746
+ } catch (error: unknown) {
747
+ return {
748
+ content: [{ type: "text", text: formatError(error, `creating role '${name}'`) }],
749
+ isError: true,
750
+ };
751
+ }
752
+ }
753
+ );
754
+
755
+ server.tool(
756
+ "update_role",
757
+ "Update a role's name, description, or permissions.",
758
+ {
759
+ roleId: z.string().describe("The role ID (UUID) to update"),
760
+ name: z.string().optional().describe("Updated name"),
761
+ description: z.string().optional().describe("Updated description"),
762
+ permissions: z.array(z.string()).optional().describe("Updated permission UUIDs (replaces all existing)"),
763
+ },
764
+ async ({ roleId, name, description, permissions }) => {
765
+ try {
766
+ const input: Record<string, any> = {};
767
+ if (name !== undefined) input.name = name;
768
+ if (description !== undefined) input.description = description;
769
+ if (permissions !== undefined) input.permissions = permissions;
770
+ const result = await getRolesClient().put(`/${roleId}`, input);
771
+ return {
772
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
773
+ };
774
+ } catch (error: unknown) {
775
+ return {
776
+ content: [{ type: "text", text: formatError(error, `updating role '${roleId}'`) }],
777
+ isError: true,
778
+ };
779
+ }
780
+ }
781
+ );
782
+
783
+ server.tool(
784
+ "delete_role",
785
+ "Delete a role. Service accounts and groups that had this role lose its permissions.",
786
+ {
787
+ roleId: z.string().describe("The role ID (UUID) to delete"),
788
+ },
789
+ async ({ roleId }) => {
790
+ try {
791
+ await getRolesClient().delete(`/${roleId}`);
792
+ return {
793
+ content: [{ type: "text", text: `Role '${roleId}' deleted successfully.` }],
794
+ };
795
+ } catch (error: unknown) {
796
+ return {
797
+ content: [{ type: "text", text: formatError(error, `deleting role '${roleId}'`) }],
798
+ isError: true,
799
+ };
800
+ }
801
+ }
802
+ );
803
+
804
+ // ── Group CRUD ───────────────────────────────────────────────────
805
+
806
+ server.tool(
807
+ "list_groups",
808
+ "List all groups in the workspace. Groups bundle service accounts together and can have roles assigned to them.",
809
+ {
810
+ page: z.number().optional().describe("Page number (default: 1)"),
811
+ pageSize: z.number().optional().describe("Results per page (default: 20)"),
812
+ },
813
+ async ({ page, pageSize }) => {
814
+ try {
815
+ const params: Record<string, any> = {};
816
+ if (page !== undefined) params.page = page;
817
+ if (pageSize !== undefined) params.pageSize = pageSize;
818
+ const result = await getGroupsClient().get("/", { params });
819
+ return {
820
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
821
+ };
822
+ } catch (error: unknown) {
823
+ return {
824
+ content: [{ type: "text", text: formatError(error, "listing groups") }],
825
+ isError: true,
826
+ };
827
+ }
828
+ }
829
+ );
830
+
831
+ server.tool(
832
+ "get_group",
833
+ "Get details of a group.",
834
+ {
835
+ groupId: z.string().describe("The group ID (UUID)"),
836
+ },
837
+ async ({ groupId }) => {
838
+ try {
839
+ const result = await getGroupsClient().get(`/${groupId}`);
840
+ return {
841
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
842
+ };
843
+ } catch (error: unknown) {
844
+ return {
845
+ content: [{ type: "text", text: formatError(error, `getting group '${groupId}'`) }],
846
+ isError: true,
847
+ };
848
+ }
849
+ }
850
+ );
851
+
852
+ server.tool(
853
+ "create_group",
854
+ "Create a new group. Groups let you assign roles to multiple service accounts at once.",
855
+ {
856
+ name: z.string().describe("Group name (e.g., 'Backend Services', 'Analytics Pipeline')"),
857
+ description: z.string().optional().describe("Optional description"),
858
+ },
859
+ async ({ name, description }) => {
860
+ try {
861
+ const input: Record<string, any> = { name };
862
+ if (description !== undefined) input.description = description;
863
+ const result = await getGroupsClient().post("/", input);
864
+ return {
865
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
866
+ };
867
+ } catch (error: unknown) {
868
+ return {
869
+ content: [{ type: "text", text: formatError(error, `creating group '${name}'`) }],
870
+ isError: true,
871
+ };
872
+ }
873
+ }
874
+ );
875
+
876
+ server.tool(
877
+ "update_group",
878
+ "Update a group's name or description.",
879
+ {
880
+ groupId: z.string().describe("The group ID (UUID) to update"),
881
+ name: z.string().optional().describe("Updated name"),
882
+ description: z.string().optional().describe("Updated description"),
883
+ },
884
+ async ({ groupId, name, description }) => {
885
+ try {
886
+ const input: Record<string, any> = {};
887
+ if (name !== undefined) input.name = name;
888
+ if (description !== undefined) input.description = description;
889
+ const result = await getGroupsClient().put(`/${groupId}`, input);
890
+ return {
891
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
892
+ };
893
+ } catch (error: unknown) {
894
+ return {
895
+ content: [{ type: "text", text: formatError(error, `updating group '${groupId}'`) }],
896
+ isError: true,
897
+ };
898
+ }
899
+ }
900
+ );
901
+
902
+ server.tool(
903
+ "delete_group",
904
+ "Delete a group. Service accounts in this group lose permissions inherited through it.",
905
+ {
906
+ groupId: z.string().describe("The group ID (UUID) to delete"),
907
+ },
908
+ async ({ groupId }) => {
909
+ try {
910
+ await getGroupsClient().delete(`/${groupId}`);
911
+ return {
912
+ content: [{ type: "text", text: `Group '${groupId}' deleted successfully.` }],
913
+ };
914
+ } catch (error: unknown) {
915
+ return {
916
+ content: [{ type: "text", text: formatError(error, `deleting group '${groupId}'`) }],
917
+ isError: true,
918
+ };
919
+ }
920
+ }
921
+ );
922
+
923
+ // ── Publishable Keys ─────────────────────────────────────────────
924
+
925
+ const getPkClient = () => createIamClient(sdk, centraliUrl, workspaceId, "publishable-keys");
926
+
927
+ server.tool(
928
+ "list_publishable_keys",
929
+ "List all publishable keys in the workspace. Publishable keys are frontend-safe API keys for browser/client-side apps — they grant scoped, read-mostly access to specific collections, records, triggers, and files.",
930
+ {
931
+ page: z.number().optional().describe("Page number (default: 1)"),
932
+ pageSize: z.number().optional().describe("Results per page (default: 20)"),
933
+ },
934
+ async ({ page, pageSize }) => {
935
+ try {
936
+ const params: Record<string, any> = {};
937
+ if (page !== undefined) params.page = page;
938
+ if (pageSize !== undefined) params.pageSize = pageSize;
939
+ const result = await getPkClient().get("/", { params });
940
+ return {
941
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
942
+ };
943
+ } catch (error: unknown) {
944
+ return {
945
+ content: [{ type: "text", text: formatError(error, "listing publishable keys") }],
946
+ isError: true,
947
+ };
948
+ }
949
+ }
950
+ );
951
+
952
+ server.tool(
953
+ "get_publishable_key",
954
+ "Get details of a publishable key including its scopes and usage stats.",
955
+ {
956
+ keyId: z.string().describe("The publishable key ID (UUID)"),
957
+ },
958
+ async ({ keyId }) => {
959
+ try {
960
+ const result = await getPkClient().get(`/${keyId}`);
961
+ return {
962
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
963
+ };
964
+ } catch (error: unknown) {
965
+ return {
966
+ content: [{ type: "text", text: formatError(error, `getting publishable key '${keyId}'`) }],
967
+ isError: true,
968
+ };
969
+ }
970
+ }
971
+ );
972
+
973
+ server.tool(
974
+ "create_publishable_key",
975
+ "Create a publishable key for frontend/client-side use. Returns the full key value (pk_live_...) — it's safe to embed in client code but only shown in full once. Scopes control what the key can access. Always use least-privilege: only grant the specific collections, actions, and triggers the frontend needs.",
976
+ {
977
+ label: z.string().describe("Display label (e.g., 'React Dashboard', 'Marketing Site')"),
978
+ scopes: z.array(z.string()).describe("Scopes defining what this key can access. Format: 'resource:action:target'. Examples: 'records:list:products' (list products), 'records:retrieve:*' (read any collection), 'records:create:orders' (create orders), 'triggers:execute:send-email' (invoke a trigger), 'files:retrieve' (read files), 'collections:list' (list collection schemas). Write actions (create, execute) require explicit targets — no wildcards."),
979
+ },
980
+ async ({ label, scopes }) => {
981
+ try {
982
+ const result = await getPkClient().post("/", { label, scopes });
983
+ return {
984
+ content: [{
985
+ type: "text",
986
+ text: JSON.stringify(
987
+ {
988
+ ...result.data,
989
+ _note: "The full key (pk_live_...) is safe to use in client-side code. It's scoped and rate-limited. Store it in your app's environment config.",
990
+ },
991
+ null,
992
+ 2
993
+ ),
994
+ }],
995
+ };
996
+ } catch (error: unknown) {
997
+ return {
998
+ content: [{ type: "text", text: formatError(error, `creating publishable key '${label}'`) }],
999
+ isError: true,
1000
+ };
1001
+ }
1002
+ }
1003
+ );
1004
+
1005
+ server.tool(
1006
+ "update_publishable_key",
1007
+ "Update a publishable key's label or scopes. When updating scopes, the new scopes replace all existing ones.",
1008
+ {
1009
+ keyId: z.string().describe("The publishable key ID (UUID) to update"),
1010
+ label: z.string().optional().describe("Updated label"),
1011
+ scopes: z.array(z.string()).optional().describe("Updated scopes (replaces all existing). Same format as create_publishable_key."),
1012
+ },
1013
+ async ({ keyId, label, scopes }) => {
1014
+ try {
1015
+ const input: Record<string, any> = {};
1016
+ if (label !== undefined) input.label = label;
1017
+ if (scopes !== undefined) input.scopes = scopes;
1018
+ const result = await getPkClient().patch(`/${keyId}`, input);
1019
+ return {
1020
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
1021
+ };
1022
+ } catch (error: unknown) {
1023
+ return {
1024
+ content: [{ type: "text", text: formatError(error, `updating publishable key '${keyId}'`) }],
1025
+ isError: true,
1026
+ };
1027
+ }
1028
+ }
1029
+ );
1030
+
1031
+ server.tool(
1032
+ "revoke_publishable_key",
1033
+ "Revoke a publishable key. The key immediately stops working. This cannot be undone — create a new key if needed.",
1034
+ {
1035
+ keyId: z.string().describe("The publishable key ID (UUID) to revoke"),
1036
+ },
1037
+ async ({ keyId }) => {
1038
+ try {
1039
+ const result = await getPkClient().delete(`/${keyId}`);
1040
+ return {
1041
+ content: [{ type: "text", text: JSON.stringify(result.data, null, 2) }],
1042
+ };
1043
+ } catch (error: unknown) {
1044
+ return {
1045
+ content: [{ type: "text", text: formatError(error, `revoking publishable key '${keyId}'`) }],
1046
+ isError: true,
1047
+ };
1048
+ }
1049
+ }
1050
+ );
1051
+ }