pulsemcp-cms-admin-mcp-server 0.9.17 → 0.9.24

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.
Files changed (75) hide show
  1. package/README.md +93 -74
  2. package/build/index.js +53 -1
  3. package/build/shared/src/elicitation-config.js +69 -0
  4. package/build/shared/src/pulsemcp-admin-client/lib/bulk-update-tenant-servers.js +41 -0
  5. package/build/shared/src/pulsemcp-admin-client/lib/create-mcp-implementation.js +7 -0
  6. package/build/shared/src/pulsemcp-admin-client/lib/delete-api-key.js +25 -0
  7. package/build/shared/src/pulsemcp-admin-client/lib/delete-tenant.js +31 -0
  8. package/build/shared/src/pulsemcp-admin-client/lib/get-unified-mcp-server.js +5 -1
  9. package/build/shared/src/pulsemcp-admin-client/lib/list-tenant-servers.js +57 -0
  10. package/build/shared/src/pulsemcp-admin-client/lib/save-mcp-implementation.js +8 -0
  11. package/build/shared/src/pulsemcp-admin-client/lib/unified-mcp-server-mapper.js +3 -0
  12. package/build/shared/src/pulsemcp-admin-client/lib/update-unified-mcp-server.js +14 -0
  13. package/build/shared/src/pulsemcp-admin-client/pulsemcp-admin-client.integration-mock.js +29 -0
  14. package/build/shared/src/server.js +16 -0
  15. package/build/shared/src/tools/add-servers-to-tenant.js +133 -0
  16. package/build/shared/src/tools/delete-api-key.js +119 -0
  17. package/build/shared/src/tools/delete-tenant.js +129 -0
  18. package/build/shared/src/tools/get-mcp-server.js +26 -8
  19. package/build/shared/src/tools/list-mcp-servers.js +3 -0
  20. package/build/shared/src/tools/list-tenant-servers.js +89 -0
  21. package/build/shared/src/tools/remove-servers-from-tenant.js +92 -0
  22. package/build/shared/src/tools/revoke-api-key.js +119 -0
  23. package/build/shared/src/tools/save-mcp-implementation.js +89 -2
  24. package/build/shared/src/tools/update-mcp-server.js +19 -0
  25. package/build/shared/src/tools.js +22 -1
  26. package/node_modules/@pulsemcp/mcp-elicitation/build/config.d.ts +15 -0
  27. package/node_modules/@pulsemcp/mcp-elicitation/build/config.js +41 -0
  28. package/node_modules/@pulsemcp/mcp-elicitation/build/elicitation.d.ts +24 -0
  29. package/node_modules/@pulsemcp/mcp-elicitation/build/elicitation.js +175 -0
  30. package/node_modules/@pulsemcp/mcp-elicitation/build/index.d.ts +3 -0
  31. package/node_modules/@pulsemcp/mcp-elicitation/build/index.js +2 -0
  32. package/node_modules/@pulsemcp/mcp-elicitation/build/types.d.ts +114 -0
  33. package/node_modules/@pulsemcp/mcp-elicitation/build/types.js +1 -0
  34. package/node_modules/@pulsemcp/mcp-elicitation/package.json +28 -0
  35. package/package.json +7 -1
  36. package/shared/elicitation-config.d.ts +61 -0
  37. package/shared/elicitation-config.js +69 -0
  38. package/shared/pulsemcp-admin-client/lib/bulk-update-tenant-servers.d.ts +3 -0
  39. package/shared/pulsemcp-admin-client/lib/bulk-update-tenant-servers.js +41 -0
  40. package/shared/pulsemcp-admin-client/lib/create-mcp-implementation.js +7 -0
  41. package/shared/pulsemcp-admin-client/lib/delete-api-key.d.ts +3 -0
  42. package/shared/pulsemcp-admin-client/lib/delete-api-key.js +25 -0
  43. package/shared/pulsemcp-admin-client/lib/delete-tenant.d.ts +3 -0
  44. package/shared/pulsemcp-admin-client/lib/delete-tenant.js +31 -0
  45. package/shared/pulsemcp-admin-client/lib/get-unified-mcp-server.js +5 -1
  46. package/shared/pulsemcp-admin-client/lib/list-tenant-servers.d.ts +7 -0
  47. package/shared/pulsemcp-admin-client/lib/list-tenant-servers.js +57 -0
  48. package/shared/pulsemcp-admin-client/lib/save-mcp-implementation.js +8 -0
  49. package/shared/pulsemcp-admin-client/lib/unified-mcp-server-mapper.d.ts +2 -0
  50. package/shared/pulsemcp-admin-client/lib/unified-mcp-server-mapper.js +3 -0
  51. package/shared/pulsemcp-admin-client/lib/update-unified-mcp-server.js +14 -0
  52. package/shared/pulsemcp-admin-client/pulsemcp-admin-client.integration-mock.js +29 -0
  53. package/shared/server.d.ts +17 -1
  54. package/shared/server.js +16 -0
  55. package/shared/tools/add-servers-to-tenant.d.ts +57 -0
  56. package/shared/tools/add-servers-to-tenant.js +133 -0
  57. package/shared/tools/delete-api-key.d.ts +30 -0
  58. package/shared/tools/delete-api-key.js +119 -0
  59. package/shared/tools/delete-tenant.d.ts +36 -0
  60. package/shared/tools/delete-tenant.js +129 -0
  61. package/shared/tools/get-mcp-server.js +26 -8
  62. package/shared/tools/list-mcp-servers.js +3 -0
  63. package/shared/tools/list-tenant-servers.d.ts +45 -0
  64. package/shared/tools/list-tenant-servers.js +89 -0
  65. package/shared/tools/remove-servers-from-tenant.d.ts +42 -0
  66. package/shared/tools/remove-servers-from-tenant.js +92 -0
  67. package/shared/tools/revoke-api-key.d.ts +30 -0
  68. package/shared/tools/revoke-api-key.js +119 -0
  69. package/shared/tools/save-mcp-implementation.d.ts +9 -1
  70. package/shared/tools/save-mcp-implementation.js +89 -2
  71. package/shared/tools/update-mcp-server.d.ts +6 -0
  72. package/shared/tools/update-mcp-server.js +19 -0
  73. package/shared/tools.d.ts +5 -3
  74. package/shared/tools.js +22 -1
  75. package/shared/types.d.ts +83 -0
@@ -0,0 +1,92 @@
1
+ import { z } from 'zod';
2
+ const PARAM_DESCRIPTIONS = {
3
+ tenant: 'Tenant ID (number) or slug (string) to remove servers from.',
4
+ mcp_servers: "Array of MCP server IDs (numbers) or slugs (strings) to remove from the tenant's recommendation list. Touched associations are soft-deleted (preserving the customization history); untouched associations are hard-deleted.",
5
+ };
6
+ const RemoveServersFromTenantSchema = z.object({
7
+ tenant: z.union([z.number(), z.string()]).describe(PARAM_DESCRIPTIONS.tenant),
8
+ mcp_servers: z
9
+ .array(z.union([z.number(), z.string()]))
10
+ .min(1)
11
+ .describe(PARAM_DESCRIPTIONS.mcp_servers),
12
+ });
13
+ export function removeServersFromTenant(_server, clientFactory) {
14
+ return {
15
+ name: 'remove_servers_from_tenant',
16
+ description: `Remove MCP servers from a tenant's recommendation list.
17
+
18
+ This is a convenience inverse of add_servers_to_tenant — it accepts only the tenant and a list of servers to remove. For combined add/remove/restore in a single transactional call, use add_servers_to_tenant.
19
+
20
+ This tool only changes the tenant's recommendation list (TenantsMcpServer rows). The underlying MCP server's public listing is NOT affected.
21
+
22
+ Outcome behavior:
23
+ - Untouched associations: hard-deleted (row removed entirely; outcome "hard_deleted")
24
+ - Touched associations: soft-deleted (status set to "deleted", row preserved; outcome "soft_deleted")
25
+
26
+ The response includes:
27
+ - removed: servers detached, with outcome
28
+ - skipped/unresolved_identifiers: as for add_servers_to_tenant
29
+
30
+ Use cases:
31
+ - Curate a tenant's recommendation list by trimming servers
32
+ - Cleanly remove servers from a tenant without affecting public listings`,
33
+ inputSchema: {
34
+ type: 'object',
35
+ properties: {
36
+ tenant: {
37
+ oneOf: [{ type: 'number' }, { type: 'string' }],
38
+ description: PARAM_DESCRIPTIONS.tenant,
39
+ },
40
+ mcp_servers: {
41
+ type: 'array',
42
+ items: { oneOf: [{ type: 'number' }, { type: 'string' }] },
43
+ description: PARAM_DESCRIPTIONS.mcp_servers,
44
+ minItems: 1,
45
+ },
46
+ },
47
+ required: ['tenant', 'mcp_servers'],
48
+ },
49
+ handler: async (args) => {
50
+ try {
51
+ const validatedArgs = RemoveServersFromTenantSchema.parse(args);
52
+ const client = clientFactory();
53
+ const result = await client.bulkUpdateTenantServers(validatedArgs.tenant, {
54
+ remove_server_identifiers: validatedArgs.mcp_servers,
55
+ });
56
+ let content = `**Tenant servers updated** (tenant=${result.tenant.slug}, id=${result.tenant.id})\n\n`;
57
+ content += `- Removed: ${result.removed.length}\n`;
58
+ content += `- Skipped: ${result.skipped.length}\n`;
59
+ content += `- Unresolved identifiers: ${result.unresolved_identifiers.length}\n`;
60
+ if (result.removed.length > 0) {
61
+ content += `\n**Removed:**\n`;
62
+ for (const item of result.removed) {
63
+ const assocPart = item.association_id != null ? `, association_id=${item.association_id}` : '';
64
+ content += `- ${item.mcp_server_slug} (server_id=${item.mcp_server_id}${assocPart}, outcome=${item.outcome})\n`;
65
+ }
66
+ }
67
+ if (result.skipped.length > 0) {
68
+ content += `\n**Skipped:**\n`;
69
+ for (const item of result.skipped) {
70
+ const label = item.mcp_server_slug || item.mcp_server_id || item.association_id;
71
+ content += `- ${label} — reason: ${item.reason}\n`;
72
+ }
73
+ }
74
+ if (result.unresolved_identifiers.length > 0) {
75
+ content += `\n**Unresolved identifiers:** ${result.unresolved_identifiers.join(', ')}\n`;
76
+ }
77
+ return { content: [{ type: 'text', text: content }] };
78
+ }
79
+ catch (error) {
80
+ return {
81
+ content: [
82
+ {
83
+ type: 'text',
84
+ text: `Error removing tenant servers: ${error instanceof Error ? error.message : String(error)}`,
85
+ },
86
+ ],
87
+ isError: true,
88
+ };
89
+ }
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,119 @@
1
+ import { z } from 'zod';
2
+ import { requestConfirmation, createConfirmationSchema } from '@pulsemcp/mcp-elicitation';
3
+ import { readCmsAdminElicitationConfig } from '../elicitation-config.js';
4
+ const PARAM_DESCRIPTIONS = {
5
+ api_key_id: 'The numeric ID of the API key to revoke (the `id` field returned by `create_api_key`).',
6
+ };
7
+ const RevokeApiKeySchema = z.object({
8
+ api_key_id: z.number().int().positive().describe(PARAM_DESCRIPTIONS.api_key_id),
9
+ });
10
+ const TOOL_DESCRIPTION = `Revoke an API key by ID, immediately invalidating it.
11
+
12
+ **This is a destructive operation.** Any clients still using the key will start receiving 401 Unauthorized. Revocation is idempotent — calling revoke on a non-existent or already-revoked key returns success without error.
13
+
14
+ By default, the request requires explicit user approval via MCP elicitation before being sent to the admin API.
15
+
16
+ **Use cases:**
17
+ - Roll a tenant's keys after re-issuing credentials, so the old key stops working.
18
+ - Revoke a leaked or compromised API key.
19
+ - Remove a deprecated key during cleanup.
20
+
21
+ **Parameters:**
22
+ - \`api_key_id\` (required): Numeric ID of the API key (the \`id\` field returned by \`create_api_key\`).
23
+
24
+ **Returns:**
25
+ - \`success\`: Boolean indicating whether the request succeeded.
26
+ - \`message\`: Human-readable confirmation message.`;
27
+ export function revokeApiKey(server, clientFactory) {
28
+ return {
29
+ name: 'revoke_api_key',
30
+ description: TOOL_DESCRIPTION,
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ api_key_id: {
35
+ type: 'number',
36
+ description: PARAM_DESCRIPTIONS.api_key_id,
37
+ },
38
+ },
39
+ required: ['api_key_id'],
40
+ },
41
+ handler: async (args) => {
42
+ try {
43
+ const validatedArgs = RevokeApiKeySchema.parse(args);
44
+ const elicitConfig = readCmsAdminElicitationConfig();
45
+ if (elicitConfig.destructiveElicitationEnabled) {
46
+ const lines = [
47
+ 'About to PERMANENTLY REVOKE an API key via the PulseMCP admin API:',
48
+ ` API key ID: ${validatedArgs.api_key_id}`,
49
+ '',
50
+ 'Any clients still using this key will start receiving 401 Unauthorized.',
51
+ 'This action cannot be undone.',
52
+ ];
53
+ const confirmation = await requestConfirmation({
54
+ server,
55
+ message: lines.join('\n') + '\n',
56
+ requestedSchema: createConfirmationSchema('Revoke this API key?', 'Confirm that you want to permanently revoke this API key.'),
57
+ meta: {
58
+ 'com.pulsemcp/tool-name': 'revoke_api_key',
59
+ },
60
+ }, elicitConfig.base);
61
+ if (confirmation.action !== 'accept') {
62
+ if (confirmation.action === 'expired') {
63
+ return {
64
+ content: [
65
+ {
66
+ type: 'text',
67
+ text: 'API key revocation confirmation expired. Please try again.',
68
+ },
69
+ ],
70
+ isError: true,
71
+ };
72
+ }
73
+ return {
74
+ content: [
75
+ {
76
+ type: 'text',
77
+ text: 'API key revocation was cancelled by the user.',
78
+ },
79
+ ],
80
+ };
81
+ }
82
+ if (confirmation.content &&
83
+ 'confirm' in confirmation.content &&
84
+ confirmation.content.confirm === false) {
85
+ return {
86
+ content: [
87
+ {
88
+ type: 'text',
89
+ text: 'API key revocation was not confirmed.',
90
+ },
91
+ ],
92
+ };
93
+ }
94
+ }
95
+ const client = clientFactory();
96
+ const result = await client.deleteApiKey(validatedArgs.api_key_id);
97
+ return {
98
+ content: [
99
+ {
100
+ type: 'text',
101
+ text: `${result.success ? 'API key revoked.' : 'API key revocation failed.'} ${result.message}`,
102
+ },
103
+ ],
104
+ };
105
+ }
106
+ catch (error) {
107
+ return {
108
+ content: [
109
+ {
110
+ type: 'text',
111
+ text: `Error revoking API key: ${error instanceof Error ? error.message : String(error)}`,
112
+ },
113
+ ],
114
+ isError: true,
115
+ };
116
+ }
117
+ },
118
+ };
119
+ }
@@ -1,4 +1,29 @@
1
1
  import { z } from 'zod';
2
+ // Hosts that must never appear as canonical URLs. Source-code hosts belong in
3
+ // `source_code_location`; aggregators are not authoritative. Mirrors the rule
4
+ // in agents/skills/server-discovery/identify-remote-canonical-url/SKILL.md.
5
+ export const BLOCKLISTED_CANONICAL_HOSTS = [
6
+ 'github.com',
7
+ 'gitlab.com',
8
+ 'bitbucket.org',
9
+ 'smithery.ai',
10
+ 'glama.ai',
11
+ ];
12
+ export function findBlocklistedCanonicalHost(url) {
13
+ let host;
14
+ try {
15
+ host = new URL(url).hostname.toLowerCase();
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ for (const blocked of BLOCKLISTED_CANONICAL_HOSTS) {
21
+ if (host === blocked || host.endsWith(`.${blocked}`)) {
22
+ return host;
23
+ }
24
+ }
25
+ return null;
26
+ }
2
27
  // Parameter descriptions - single source of truth
3
28
  const PARAM_DESCRIPTIONS = {
4
29
  id: 'The ID of the MCP implementation to update. Omit this field to CREATE a new implementation instead of updating an existing one.',
@@ -26,11 +51,12 @@ const PARAM_DESCRIPTIONS = {
26
51
  // Remote endpoints
27
52
  remote: 'Array of remote endpoint configurations for MCP servers. Providing this replaces ALL existing remotes. Omitting leaves them unchanged. Pass an empty array to delete all. Each remote can have: id (existing remote ID or blank for new), url_direct, url_setup, transport (e.g., "sse"), host_platform (e.g., "smithery"), host_infrastructure (e.g., "cloudflare"), authentication_method (e.g., "open"), cost (e.g., "free"), status (defaults to "live"), display_name, and internal_notes.',
28
53
  // Canonical URLs
29
- canonical: 'Array of canonical URL configurations. Providing this replaces ALL existing canonical URLs. Omitting leaves them unchanged. Pass an empty array to delete all. Each entry must have: url (the canonical URL), scope (one of "domain", "subdomain", or "url"), and optional note for additional context.',
54
+ canonical: 'Array of canonical URL configurations. Providing this replaces ALL existing canonical URLs. Omitting leaves them unchanged. Pass an empty array to delete all. Each entry must have: url (the canonical URL), scope (one of "domain", "subdomain", or "url"), and optional note for additional context. The host must NOT be one of: github.com, gitlab.com, bitbucket.org, smithery.ai, glama.ai (or any subdomain of those) — source-code repos belong in `source_code_location`, and aggregator listings are not authoritative. Calls with blocklisted canonical hosts are rejected with `CANONICAL_BLOCKLISTED_HOST`.',
30
55
  // Flags
31
56
  verified_no_remote_canonicals: 'Mark that this server has been verified to have no remote canonical URLs (true = verified no remote canonicals exist, false = reset/canonicals found)',
32
57
  // Other fields
33
58
  internal_notes: 'Admin-only notes. Not displayed publicly. Used for tracking submission sources, reviewer comments, etc.',
59
+ owner_tenant: 'Owner tenant of this server. Pass a tenant slug (e.g. "gram-recommended") or numeric tenant ID to link, or null to clear the link. Omit to leave unchanged. Slugs are preferred for readability; the Rails backend resolves them to a tenant ID. An unknown slug returns a validation error.',
34
60
  };
35
61
  const SaveMCPImplementationSchema = z.object({
36
62
  id: z.number().optional().describe(PARAM_DESCRIPTIONS.id),
@@ -97,6 +123,10 @@ const SaveMCPImplementationSchema = z.object({
97
123
  .describe(PARAM_DESCRIPTIONS.verified_no_remote_canonicals),
98
124
  // Other fields
99
125
  internal_notes: z.string().optional().describe(PARAM_DESCRIPTIONS.internal_notes),
126
+ owner_tenant: z
127
+ .union([z.string(), z.number(), z.null()])
128
+ .optional()
129
+ .describe(PARAM_DESCRIPTIONS.owner_tenant),
100
130
  });
101
131
  export function saveMCPImplementation(_server, clientFactory) {
102
132
  return {
@@ -314,13 +344,48 @@ Important notes:
314
344
  type: 'string',
315
345
  description: PARAM_DESCRIPTIONS.internal_notes,
316
346
  },
347
+ // Owner tenant
348
+ owner_tenant: {
349
+ oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'null' }],
350
+ description: PARAM_DESCRIPTIONS.owner_tenant,
351
+ },
317
352
  },
318
353
  },
319
354
  handler: async (args) => {
320
355
  const validatedArgs = SaveMCPImplementationSchema.parse(args);
356
+ if (validatedArgs.canonical) {
357
+ for (const entry of validatedArgs.canonical) {
358
+ const blockedHost = findBlocklistedCanonicalHost(entry.url);
359
+ if (blockedHost) {
360
+ return {
361
+ content: [
362
+ {
363
+ type: 'text',
364
+ text: `CANONICAL_BLOCKLISTED_HOST: ${blockedHost} is not a valid canonical URL host. Use the actual project homepage instead.`,
365
+ },
366
+ ],
367
+ isError: true,
368
+ };
369
+ }
370
+ }
371
+ }
321
372
  const client = clientFactory();
322
373
  try {
323
- const { id, type, ...restParams } = validatedArgs;
374
+ const { id, type, owner_tenant, ...restParams } = validatedArgs;
375
+ // Translate owner_tenant (string slug | number id | null clear) into the
376
+ // explicit owner_tenant_slug / owner_tenant_id fields the admin client
377
+ // forwards to Rails.
378
+ if (owner_tenant !== undefined) {
379
+ if (typeof owner_tenant === 'string') {
380
+ restParams.owner_tenant_slug = owner_tenant;
381
+ }
382
+ else if (typeof owner_tenant === 'number') {
383
+ restParams.owner_tenant_id = owner_tenant;
384
+ }
385
+ else {
386
+ restParams.owner_tenant_id = null;
387
+ }
388
+ }
324
389
  // Determine if this is a create or update operation
325
390
  const isCreate = id === undefined;
326
391
  if (isCreate) {
@@ -386,6 +451,15 @@ Important notes:
386
451
  content += ` - ${r.display_name || r.url_direct || `ID ${r.id}`}\n`;
387
452
  });
388
453
  }
454
+ const createOwnerSlug = implementation.mcp_server?.owner_tenant_slug;
455
+ const createOwnerId = implementation.mcp_server?.owner_tenant_id;
456
+ if (createOwnerSlug || createOwnerId) {
457
+ content += `**Owner Tenant:** ${createOwnerSlug ?? '(no slug)'}`;
458
+ if (createOwnerId) {
459
+ content += ` (id: ${createOwnerId})`;
460
+ }
461
+ content += '\n';
462
+ }
389
463
  if (implementation.created_at) {
390
464
  content += `**Created:** ${new Date(implementation.created_at).toLocaleDateString()}\n`;
391
465
  }
@@ -459,6 +533,19 @@ Important notes:
459
533
  content += ` - ${r.display_name || r.url_direct || `ID ${r.id}`}\n`;
460
534
  });
461
535
  }
536
+ const updateOwnerSlug = implementation.mcp_server?.owner_tenant_slug;
537
+ const updateOwnerId = implementation.mcp_server?.owner_tenant_id;
538
+ if (updateOwnerSlug || updateOwnerId) {
539
+ content += `**Owner Tenant:** ${updateOwnerSlug ?? '(no slug)'}`;
540
+ if (updateOwnerId) {
541
+ content += ` (id: ${updateOwnerId})`;
542
+ }
543
+ content += '\n';
544
+ }
545
+ else if (implementation.mcp_server?.owner_tenant_id === null &&
546
+ implementation.mcp_server?.owner_tenant_slug === null) {
547
+ content += `**Owner Tenant:** (none)\n`;
548
+ }
462
549
  if (implementation.updated_at) {
463
550
  content += `**Updated:** ${new Date(implementation.updated_at).toLocaleDateString()}\n`;
464
551
  }
@@ -22,6 +22,7 @@ const PARAM_DESCRIPTIONS = {
22
22
  canonical_urls: 'Authoritative URLs for the server. Providing this replaces ALL existing canonical URLs. Omitting leaves them unchanged. Pass an empty array to delete all.',
23
23
  remotes: 'Remote endpoints for the server. Providing this replaces ALL existing remotes. Omitting leaves them unchanged. Pass an empty array to delete all.',
24
24
  internal_notes: 'Admin-only internal notes',
25
+ owner_tenant: 'Owner tenant of this server. Pass a tenant slug (e.g. "gram-recommended") or numeric tenant ID to link, or null to clear the link. Omit to leave unchanged. Slugs are preferred for readability; the Rails backend resolves them to a tenant ID. An unknown slug returns a validation error.',
25
26
  };
26
27
  const CanonicalUrlSchema = z.object({
27
28
  url: z.string().describe('The canonical URL'),
@@ -95,6 +96,10 @@ const UpdateMCPServerSchema = z.object({
95
96
  .describe(PARAM_DESCRIPTIONS.canonical_urls),
96
97
  remotes: z.array(RemoteEndpointSchema).optional().describe(PARAM_DESCRIPTIONS.remotes),
97
98
  internal_notes: z.string().optional().describe(PARAM_DESCRIPTIONS.internal_notes),
99
+ owner_tenant: z
100
+ .union([z.string(), z.number(), z.null()])
101
+ .optional()
102
+ .describe(PARAM_DESCRIPTIONS.owner_tenant),
98
103
  });
99
104
  export function updateMCPServer(_server, clientFactory) {
100
105
  return {
@@ -284,6 +289,10 @@ Create new provider:
284
289
  description: PARAM_DESCRIPTIONS.remotes,
285
290
  },
286
291
  internal_notes: { type: 'string', description: PARAM_DESCRIPTIONS.internal_notes },
292
+ owner_tenant: {
293
+ oneOf: [{ type: 'string' }, { type: 'number' }, { type: 'null' }],
294
+ description: PARAM_DESCRIPTIONS.owner_tenant,
295
+ },
287
296
  },
288
297
  required: ['implementation_id'],
289
298
  },
@@ -335,6 +344,16 @@ Create new provider:
335
344
  if (server.verified_no_remote_canonicals !== undefined) {
336
345
  content += `**Verified No Remote Canonicals:** ${server.verified_no_remote_canonicals ? 'Yes' : 'No'}\n`;
337
346
  }
347
+ if (server.owner_tenant_slug || server.owner_tenant_id) {
348
+ content += `**Owner Tenant:** ${server.owner_tenant_slug ?? '(no slug)'}`;
349
+ if (server.owner_tenant_id) {
350
+ content += ` (id: ${server.owner_tenant_id})`;
351
+ }
352
+ content += '\n';
353
+ }
354
+ else if (server.owner_tenant_id === null && server.owner_tenant_slug === null) {
355
+ content += `**Owner Tenant:** (none)\n`;
356
+ }
338
357
  if (server.updated_at) {
339
358
  content += `**Updated:** ${server.updated_at}\n`;
340
359
  }
@@ -31,6 +31,12 @@ import { getTenants } from './tools/get-tenants.js';
31
31
  import { getTenant } from './tools/get-tenant.js';
32
32
  import { createTenant } from './tools/create-tenant.js';
33
33
  import { createApiKey } from './tools/create-api-key.js';
34
+ import { revokeApiKey } from './tools/revoke-api-key.js';
35
+ import { deleteTenant } from './tools/delete-tenant.js';
36
+ import { deleteApiKey } from './tools/delete-api-key.js';
37
+ import { listTenantServers } from './tools/list-tenant-servers.js';
38
+ import { addServersToTenant } from './tools/add-servers-to-tenant.js';
39
+ import { removeServersFromTenant } from './tools/remove-servers-from-tenant.js';
34
40
  // MCP JSON tools
35
41
  import { getMcpJsons } from './tools/get-mcp-jsons.js';
36
42
  import { getMcpJson } from './tools/get-mcp-json.js';
@@ -178,6 +184,19 @@ const ALL_TOOLS = [
178
184
  { factory: getTenant, groups: ['tenants'], isWriteOperation: false },
179
185
  { factory: createTenant, groups: ['tenants'], isWriteOperation: true },
180
186
  { factory: createApiKey, groups: ['tenants'], isWriteOperation: true },
187
+ // revoke_api_key lives in the regular `tenants` group (not `tenants_destructive`) so
188
+ // it's exposed to read-write tenant management workflows like sub-registry credential
189
+ // re-issuance. The tool gates each call with MCP elicitation; if the connected client
190
+ // supports neither native elicitation nor an HTTP fallback, the elicitation library
191
+ // throws at runtime rather than silently destroying the key.
192
+ { factory: revokeApiKey, groups: ['tenants'], isWriteOperation: true },
193
+ // Destructive tenant tools — opt-in only, NOT in BASE_TOOL_GROUPS
194
+ { factory: deleteTenant, groups: ['tenants_destructive'], isWriteOperation: true },
195
+ { factory: deleteApiKey, groups: ['tenants_destructive'], isWriteOperation: true },
196
+ // Tenant -> recommended MCP server association tools
197
+ { factory: listTenantServers, groups: ['tenants'], isWriteOperation: false },
198
+ { factory: addServersToTenant, groups: ['tenants'], isWriteOperation: true },
199
+ { factory: removeServersFromTenant, groups: ['tenants'], isWriteOperation: true },
181
200
  // MCP JSON tools (CRUD) (also in server_directory)
182
201
  {
183
202
  factory: getMcpJsons,
@@ -277,6 +296,7 @@ const VALID_TOOL_GROUPS = [
277
296
  'official_mirrors_readonly',
278
297
  'tenants',
279
298
  'tenants_readonly',
299
+ 'tenants_destructive',
280
300
  'mcp_jsons',
281
301
  'mcp_jsons_readonly',
282
302
  'mcp_servers',
@@ -382,8 +402,9 @@ function shouldIncludeTool(toolDef, enabledGroups) {
382
402
  * - unofficial_mirrors: Unofficial mirror CRUD tools (read + write)
383
403
  * - unofficial_mirrors_readonly: Unofficial mirror tools (read only)
384
404
  * - official_mirrors_readonly: Official mirrors REST API tools (read only)
385
- * - tenants: Tenant management tools including API key provisioning (read + write)
405
+ * - tenants: Tenant management tools including API key provisioning + revoke_api_key (read + write). revoke_api_key is gated by MCP elicitation user approval before execution.
386
406
  * - tenants_readonly: Tenant tools (read only)
407
+ * - tenants_destructive: Destructive tenant tools (delete_tenant, delete_api_key). NOT enabled by default; operators must opt in via TOOL_GROUPS. Each tool requires MCP elicitation user approval before execution.
387
408
  * - mcp_jsons: MCP JSON configuration tools (read + write)
388
409
  * - mcp_jsons_readonly: MCP JSON tools (read only)
389
410
  * - mcp_servers: Unified MCP server tools with abstracted interface (read + write)
@@ -0,0 +1,15 @@
1
+ import type { ElicitationConfig } from './types.js';
2
+ /**
3
+ * Reads elicitation configuration from environment variables.
4
+ *
5
+ * Environment variables:
6
+ * ELICITATION_ENABLED - "true" (default) or "false"
7
+ * ELICITATION_REQUEST_URL - POST endpoint for HTTP fallback
8
+ * ELICITATION_POLL_URL - GET endpoint for HTTP fallback polling
9
+ * ELICITATION_TTL_MS - Request TTL in milliseconds (default: 300000)
10
+ * ELICITATION_POLL_INTERVAL_MS - Poll interval in milliseconds (default: 5000, min: 1000)
11
+ * ELICITATION_SESSION_ID - Session identifier for HTTP fallback `_meta`
12
+ * ELICITATION_PREFER_HTTP_FALLBACK - "true" forces HTTP fallback over native elicitation
13
+ * when both are available. Default: "false".
14
+ */
15
+ export declare function readElicitationConfig(env?: Record<string, string | undefined>): ElicitationConfig;
@@ -0,0 +1,41 @@
1
+ const DEFAULT_TTL_MS = 5 * 60 * 1000; // 5 minutes
2
+ const DEFAULT_POLL_INTERVAL_MS = 5 * 1000; // 5 seconds
3
+ const MIN_POLL_INTERVAL_MS = 1000; // 1 second minimum to prevent tight loops
4
+ /**
5
+ * Parses a positive integer from a string, returning the default if invalid.
6
+ */
7
+ function parsePositiveInt(value, defaultValue) {
8
+ if (!value)
9
+ return defaultValue;
10
+ const parsed = parseInt(value, 10);
11
+ return Number.isNaN(parsed) || parsed < 0 ? defaultValue : parsed;
12
+ }
13
+ /**
14
+ * Reads elicitation configuration from environment variables.
15
+ *
16
+ * Environment variables:
17
+ * ELICITATION_ENABLED - "true" (default) or "false"
18
+ * ELICITATION_REQUEST_URL - POST endpoint for HTTP fallback
19
+ * ELICITATION_POLL_URL - GET endpoint for HTTP fallback polling
20
+ * ELICITATION_TTL_MS - Request TTL in milliseconds (default: 300000)
21
+ * ELICITATION_POLL_INTERVAL_MS - Poll interval in milliseconds (default: 5000, min: 1000)
22
+ * ELICITATION_SESSION_ID - Session identifier for HTTP fallback `_meta`
23
+ * ELICITATION_PREFER_HTTP_FALLBACK - "true" forces HTTP fallback over native elicitation
24
+ * when both are available. Default: "false".
25
+ */
26
+ export function readElicitationConfig(env = process.env) {
27
+ const enabledRaw = env.ELICITATION_ENABLED;
28
+ const enabled = enabledRaw === undefined ? true : enabledRaw.toLowerCase() !== 'false';
29
+ const preferHttpFallbackRaw = env.ELICITATION_PREFER_HTTP_FALLBACK;
30
+ const preferHttpFallback = preferHttpFallbackRaw !== undefined && preferHttpFallbackRaw.toLowerCase() === 'true';
31
+ const pollIntervalMs = Math.max(MIN_POLL_INTERVAL_MS, parsePositiveInt(env.ELICITATION_POLL_INTERVAL_MS, DEFAULT_POLL_INTERVAL_MS));
32
+ return {
33
+ enabled,
34
+ requestUrl: env.ELICITATION_REQUEST_URL,
35
+ pollUrl: env.ELICITATION_POLL_URL,
36
+ ttlMs: parsePositiveInt(env.ELICITATION_TTL_MS, DEFAULT_TTL_MS),
37
+ pollIntervalMs,
38
+ sessionId: env.ELICITATION_SESSION_ID,
39
+ preferHttpFallback,
40
+ };
41
+ }
@@ -0,0 +1,24 @@
1
+ import type { ElicitationConfig, ElicitationRequestedSchema, ElicitationResult, RequestConfirmationOptions } from './types.js';
2
+ /**
3
+ * Requests user confirmation through the best available mechanism.
4
+ *
5
+ * Decision tree (default):
6
+ * 1. If elicitation is disabled (`ELICITATION_ENABLED=false`), returns `accept` immediately.
7
+ * 2. If the client supports native elicitation, uses `server.elicitInput()`.
8
+ * 3. If HTTP fallback URLs are configured, posts to the external endpoint and polls.
9
+ * 4. Otherwise, throws an error indicating no elicitation mechanism is available.
10
+ *
11
+ * When `cfg.preferHttpFallback` is true (set via `ELICITATION_PREFER_HTTP_FALLBACK=true`)
12
+ * AND both fallback URLs are configured, tier 3 runs before tier 2. This is intended for
13
+ * headless agent runtimes that falsely advertise elicitation capability but cannot actually
14
+ * surface the prompt to a user.
15
+ *
16
+ * @param options - Configuration for the confirmation request.
17
+ * @param config - Elicitation config (defaults to reading from env vars).
18
+ * @returns The user's response.
19
+ */
20
+ export declare function requestConfirmation(options: RequestConfirmationOptions, config?: ElicitationConfig): Promise<ElicitationResult>;
21
+ /**
22
+ * Creates a simple boolean confirmation schema for common "are you sure?" prompts.
23
+ */
24
+ export declare function createConfirmationSchema(title?: string, description?: string): ElicitationRequestedSchema;