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

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 (81) 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/set-known-missing-init-tools-list.js +42 -0
  12. package/build/shared/src/pulsemcp-admin-client/lib/unified-mcp-server-mapper.js +3 -0
  13. package/build/shared/src/pulsemcp-admin-client/lib/update-unified-mcp-server.js +14 -0
  14. package/build/shared/src/pulsemcp-admin-client/pulsemcp-admin-client.integration-mock.js +39 -0
  15. package/build/shared/src/server.js +20 -0
  16. package/build/shared/src/tools/add-servers-to-tenant.js +133 -0
  17. package/build/shared/src/tools/delete-api-key.js +119 -0
  18. package/build/shared/src/tools/delete-tenant.js +129 -0
  19. package/build/shared/src/tools/get-mcp-server.js +26 -8
  20. package/build/shared/src/tools/list-mcp-servers.js +3 -0
  21. package/build/shared/src/tools/list-tenant-servers.js +89 -0
  22. package/build/shared/src/tools/remove-servers-from-tenant.js +92 -0
  23. package/build/shared/src/tools/revoke-api-key.js +119 -0
  24. package/build/shared/src/tools/save-mcp-implementation.js +89 -2
  25. package/build/shared/src/tools/set-known-missing-init-tools-list.js +75 -0
  26. package/build/shared/src/tools/update-mcp-server.js +19 -0
  27. package/build/shared/src/tools.js +30 -1
  28. package/node_modules/@pulsemcp/mcp-elicitation/build/config.d.ts +15 -0
  29. package/node_modules/@pulsemcp/mcp-elicitation/build/config.js +41 -0
  30. package/node_modules/@pulsemcp/mcp-elicitation/build/elicitation.d.ts +24 -0
  31. package/node_modules/@pulsemcp/mcp-elicitation/build/elicitation.js +175 -0
  32. package/node_modules/@pulsemcp/mcp-elicitation/build/index.d.ts +3 -0
  33. package/node_modules/@pulsemcp/mcp-elicitation/build/index.js +2 -0
  34. package/node_modules/@pulsemcp/mcp-elicitation/build/types.d.ts +114 -0
  35. package/node_modules/@pulsemcp/mcp-elicitation/build/types.js +1 -0
  36. package/node_modules/@pulsemcp/mcp-elicitation/package.json +28 -0
  37. package/package.json +7 -1
  38. package/shared/elicitation-config.d.ts +61 -0
  39. package/shared/elicitation-config.js +69 -0
  40. package/shared/pulsemcp-admin-client/lib/bulk-update-tenant-servers.d.ts +3 -0
  41. package/shared/pulsemcp-admin-client/lib/bulk-update-tenant-servers.js +41 -0
  42. package/shared/pulsemcp-admin-client/lib/create-mcp-implementation.js +7 -0
  43. package/shared/pulsemcp-admin-client/lib/delete-api-key.d.ts +3 -0
  44. package/shared/pulsemcp-admin-client/lib/delete-api-key.js +25 -0
  45. package/shared/pulsemcp-admin-client/lib/delete-tenant.d.ts +3 -0
  46. package/shared/pulsemcp-admin-client/lib/delete-tenant.js +31 -0
  47. package/shared/pulsemcp-admin-client/lib/get-unified-mcp-server.js +5 -1
  48. package/shared/pulsemcp-admin-client/lib/list-tenant-servers.d.ts +7 -0
  49. package/shared/pulsemcp-admin-client/lib/list-tenant-servers.js +57 -0
  50. package/shared/pulsemcp-admin-client/lib/save-mcp-implementation.js +8 -0
  51. package/shared/pulsemcp-admin-client/lib/set-known-missing-init-tools-list.d.ts +3 -0
  52. package/shared/pulsemcp-admin-client/lib/set-known-missing-init-tools-list.js +42 -0
  53. package/shared/pulsemcp-admin-client/lib/unified-mcp-server-mapper.d.ts +2 -0
  54. package/shared/pulsemcp-admin-client/lib/unified-mcp-server-mapper.js +3 -0
  55. package/shared/pulsemcp-admin-client/lib/update-unified-mcp-server.js +14 -0
  56. package/shared/pulsemcp-admin-client/pulsemcp-admin-client.integration-mock.js +39 -0
  57. package/shared/server.d.ts +19 -1
  58. package/shared/server.js +20 -0
  59. package/shared/tools/add-servers-to-tenant.d.ts +57 -0
  60. package/shared/tools/add-servers-to-tenant.js +133 -0
  61. package/shared/tools/delete-api-key.d.ts +30 -0
  62. package/shared/tools/delete-api-key.js +119 -0
  63. package/shared/tools/delete-tenant.d.ts +36 -0
  64. package/shared/tools/delete-tenant.js +129 -0
  65. package/shared/tools/get-mcp-server.js +26 -8
  66. package/shared/tools/list-mcp-servers.js +3 -0
  67. package/shared/tools/list-tenant-servers.d.ts +45 -0
  68. package/shared/tools/list-tenant-servers.js +89 -0
  69. package/shared/tools/remove-servers-from-tenant.d.ts +42 -0
  70. package/shared/tools/remove-servers-from-tenant.js +92 -0
  71. package/shared/tools/revoke-api-key.d.ts +30 -0
  72. package/shared/tools/revoke-api-key.js +119 -0
  73. package/shared/tools/save-mcp-implementation.d.ts +9 -1
  74. package/shared/tools/save-mcp-implementation.js +89 -2
  75. package/shared/tools/set-known-missing-init-tools-list.d.ts +38 -0
  76. package/shared/tools/set-known-missing-init-tools-list.js +75 -0
  77. package/shared/tools/update-mcp-server.d.ts +6 -0
  78. package/shared/tools/update-mcp-server.js +19 -0
  79. package/shared/tools.d.ts +5 -3
  80. package/shared/tools.js +30 -1
  81. package/shared/types.d.ts +89 -0
@@ -0,0 +1,42 @@
1
+ import { adminFetch } from './admin-fetch.js';
2
+ export async function setKnownMissingInitToolsList(apiKey, baseUrl, id, knownMissingInitToolsList, knownMissingInitToolsListFilterTo) {
3
+ const url = new URL(`/api/mcp_servers/${id}/known_missing_init_tools_list`, baseUrl);
4
+ const body = {
5
+ known_missing_init_tools_list: knownMissingInitToolsList,
6
+ };
7
+ if (knownMissingInitToolsListFilterTo !== undefined) {
8
+ // null is sent as JSON null; the Rails controller treats nil/blank as "clear".
9
+ body.known_missing_init_tools_list_filter_to = knownMissingInitToolsListFilterTo;
10
+ }
11
+ const response = await adminFetch(url.toString(), {
12
+ method: 'POST',
13
+ headers: {
14
+ 'X-API-Key': apiKey,
15
+ Accept: 'application/json',
16
+ 'Content-Type': 'application/json',
17
+ },
18
+ body: JSON.stringify(body),
19
+ });
20
+ if (!response.ok) {
21
+ if (response.status === 401) {
22
+ throw new Error('Invalid API key');
23
+ }
24
+ if (response.status === 403) {
25
+ throw new Error('User lacks write privileges');
26
+ }
27
+ if (response.status === 404) {
28
+ throw new Error(`MCP server not found: ${id}`);
29
+ }
30
+ if (response.status === 400) {
31
+ const errBody = (await response.json().catch(() => ({})));
32
+ throw new Error(errBody.error ?? 'Bad request');
33
+ }
34
+ if (response.status === 422) {
35
+ const errBody = (await response.json().catch(() => ({})));
36
+ const detailStr = errBody.details?.length ? ` (${errBody.details.join(', ')})` : '';
37
+ throw new Error(`${errBody.error ?? 'Validation failed'}${detailStr}`);
38
+ }
39
+ throw new Error(`Failed to set known_missing_init_tools_list: ${response.status} ${response.statusText}`);
40
+ }
41
+ return (await response.json());
42
+ }
@@ -74,6 +74,9 @@ export function mapToUnifiedServer(impl) {
74
74
  downloads_estimate_total: mcpServer.downloads_estimate_total,
75
75
  // Internal notes
76
76
  internal_notes: impl.internal_notes,
77
+ // Owner tenant
78
+ owner_tenant_id: mcpServer.owner_tenant_id,
79
+ owner_tenant_slug: mcpServer.owner_tenant_slug,
77
80
  // Timestamps (use implementation timestamps as they're more relevant)
78
81
  created_at: impl.created_at,
79
82
  updated_at: impl.updated_at,
@@ -54,6 +54,20 @@ export async function updateUnifiedMCPServer(apiKey, baseUrl, implementationId,
54
54
  // Date overrides
55
55
  if (params.created_on_override !== undefined)
56
56
  implParams.created_on_override = params.created_on_override;
57
+ // Owner tenant: route to slug or id depending on the value type.
58
+ // String → slug, number → id, null → clear (sent as empty owner_tenant_id).
59
+ if (params.owner_tenant !== undefined) {
60
+ if (typeof params.owner_tenant === 'string') {
61
+ implParams.owner_tenant_slug = params.owner_tenant;
62
+ }
63
+ else if (typeof params.owner_tenant === 'number') {
64
+ implParams.owner_tenant_id = params.owner_tenant;
65
+ }
66
+ else {
67
+ // null — clear the link
68
+ implParams.owner_tenant_id = null;
69
+ }
70
+ }
57
71
  // Tags
58
72
  if (params.tags !== undefined)
59
73
  implParams.tags = params.tags;
@@ -1045,10 +1045,49 @@ export function createMockPulseMCPAdminClient(mockData) {
1045
1045
  created_at: '2024-01-01T00:00:00Z',
1046
1046
  };
1047
1047
  },
1048
+ async deleteTenant() {
1049
+ return { success: true, message: 'Tenant deleted' };
1050
+ },
1051
+ async deleteApiKey() {
1052
+ return { success: true, message: 'API key revoked' };
1053
+ },
1054
+ async listTenantServers() {
1055
+ return {
1056
+ data: [],
1057
+ pagination: {
1058
+ current_page: 1,
1059
+ total_pages: 0,
1060
+ total_count: 0,
1061
+ has_next: false,
1062
+ limit: 30,
1063
+ },
1064
+ };
1065
+ },
1066
+ async bulkUpdateTenantServers(idOrSlug) {
1067
+ return {
1068
+ status: 'success',
1069
+ tenant: { id: typeof idOrSlug === 'number' ? idOrSlug : 1, slug: String(idOrSlug) },
1070
+ added: [],
1071
+ removed: [],
1072
+ restored: [],
1073
+ skipped: [],
1074
+ unresolved_identifiers: [],
1075
+ };
1076
+ },
1048
1077
  async recacheMCPServer(slug) {
1049
1078
  return {
1050
1079
  message: `Cache successfully refreshed for ${slug}.`,
1051
1080
  };
1052
1081
  },
1082
+ async setKnownMissingInitToolsList(id, knownMissingInitToolsList, knownMissingInitToolsListFilterTo) {
1083
+ return {
1084
+ id,
1085
+ slug: `mock-server-${id}`,
1086
+ known_missing_init_tools_list: knownMissingInitToolsList,
1087
+ known_missing_init_tools_list_filter_to: knownMissingInitToolsListFilterTo === undefined
1088
+ ? null
1089
+ : (knownMissingInitToolsListFilterTo ?? null),
1090
+ };
1091
+ },
1053
1092
  };
1054
1093
  }
@@ -72,8 +72,13 @@ import { getMozMetrics } from './pulsemcp-admin-client/lib/get-moz-metrics.js';
72
72
  import { getMozBacklinks } from './pulsemcp-admin-client/lib/get-moz-backlinks.js';
73
73
  import { getMozStoredMetrics } from './pulsemcp-admin-client/lib/get-moz-stored-metrics.js';
74
74
  import { recacheMCPServer } from './pulsemcp-admin-client/lib/recache-mcp-server.js';
75
+ import { setKnownMissingInitToolsList } from './pulsemcp-admin-client/lib/set-known-missing-init-tools-list.js';
75
76
  import { createTenant } from './pulsemcp-admin-client/lib/create-tenant.js';
76
77
  import { createApiKey } from './pulsemcp-admin-client/lib/create-api-key.js';
78
+ import { deleteTenant } from './pulsemcp-admin-client/lib/delete-tenant.js';
79
+ import { deleteApiKey } from './pulsemcp-admin-client/lib/delete-api-key.js';
80
+ import { listTenantServers } from './pulsemcp-admin-client/lib/list-tenant-servers.js';
81
+ import { bulkUpdateTenantServers } from './pulsemcp-admin-client/lib/bulk-update-tenant-servers.js';
77
82
  // PulseMCP Admin API client implementation
78
83
  export class PulseMCPAdminClient {
79
84
  apiKey;
@@ -200,6 +205,18 @@ export class PulseMCPAdminClient {
200
205
  async createApiKey(params) {
201
206
  return createApiKey(this.apiKey, this.baseUrl, params);
202
207
  }
208
+ async deleteTenant(params) {
209
+ return deleteTenant(this.apiKey, this.baseUrl, params);
210
+ }
211
+ async deleteApiKey(id) {
212
+ return deleteApiKey(this.apiKey, this.baseUrl, id);
213
+ }
214
+ async listTenantServers(idOrSlug, params) {
215
+ return listTenantServers(this.apiKey, this.baseUrl, idOrSlug, params);
216
+ }
217
+ async bulkUpdateTenantServers(idOrSlug, params) {
218
+ return bulkUpdateTenantServers(this.apiKey, this.baseUrl, idOrSlug, params);
219
+ }
203
220
  // MCP JSON REST API methods
204
221
  async getMcpJsons(params) {
205
222
  return getMcpJsons(this.apiKey, this.baseUrl, params);
@@ -229,6 +246,9 @@ export class PulseMCPAdminClient {
229
246
  async recacheMCPServer(slug) {
230
247
  return recacheMCPServer(this.apiKey, this.baseUrl, slug);
231
248
  }
249
+ async setKnownMissingInitToolsList(id, knownMissingInitToolsList, knownMissingInitToolsListFilterTo) {
250
+ return setKnownMissingInitToolsList(this.apiKey, this.baseUrl, id, knownMissingInitToolsList, knownMissingInitToolsListFilterTo);
251
+ }
232
252
  // Redirect REST API methods
233
253
  async getRedirects(params) {
234
254
  return getRedirects(this.apiKey, this.baseUrl, params);
@@ -0,0 +1,133 @@
1
+ import { z } from 'zod';
2
+ const PARAM_DESCRIPTIONS = {
3
+ tenant: 'Tenant ID (number) or slug (string) to add servers to.',
4
+ add_server_identifiers: 'Array of MCP server IDs (numbers) or slugs (strings) to add to the tenant\'s recommendation list. Servers without an unofficial mirror are silently skipped (returned in `skipped` with reason "no_unofficial_mirror"). Already-active associations are skipped (reason "already_active").',
5
+ remove_server_identifiers: 'Optional array of MCP server IDs or slugs to remove from the tenant in the same call. Touched associations are soft-deleted; untouched associations are hard-deleted.',
6
+ restore_association_ids: 'Optional array of TenantsMcpServer association IDs to restore from a soft-deleted state. Use list_tenant_servers with status="deleted" to find these IDs.',
7
+ };
8
+ const AddServersToTenantSchema = z
9
+ .object({
10
+ tenant: z.union([z.number(), z.string()]).describe(PARAM_DESCRIPTIONS.tenant),
11
+ add_server_identifiers: z
12
+ .array(z.union([z.number(), z.string()]))
13
+ .optional()
14
+ .describe(PARAM_DESCRIPTIONS.add_server_identifiers),
15
+ remove_server_identifiers: z
16
+ .array(z.union([z.number(), z.string()]))
17
+ .optional()
18
+ .describe(PARAM_DESCRIPTIONS.remove_server_identifiers),
19
+ restore_association_ids: z
20
+ .array(z.number())
21
+ .optional()
22
+ .describe(PARAM_DESCRIPTIONS.restore_association_ids),
23
+ })
24
+ .refine((val) => (val.add_server_identifiers && val.add_server_identifiers.length > 0) ||
25
+ (val.remove_server_identifiers && val.remove_server_identifiers.length > 0) ||
26
+ (val.restore_association_ids && val.restore_association_ids.length > 0), {
27
+ message: 'Must provide at least one of add_server_identifiers, remove_server_identifiers, or restore_association_ids',
28
+ });
29
+ export function addServersToTenant(_server, clientFactory) {
30
+ return {
31
+ name: 'add_servers_to_tenant',
32
+ description: `Add MCP servers to a tenant's recommendation list. Optionally remove and restore associations in the same transactional call.
33
+
34
+ Servers are added with server_json_selection: "unofficial" — they always pin to the unofficial mirror's server.json. A server without an unofficial mirror cannot be added and will be reported in "skipped" with reason "no_unofficial_mirror".
35
+
36
+ This tool only changes the tenant's recommendation list (TenantsMcpServer rows). The underlying MCP server's public listing is NOT affected — public servers stay public and continue to appear in the public directory.
37
+
38
+ The response includes:
39
+ - added: servers newly attached to the tenant (outcome "created" or "restored_from_deleted")
40
+ - removed: servers detached (outcome "hard_deleted" if untouched, "soft_deleted" if the row had been customized)
41
+ - restored: associations restored from soft-deleted state via restore_association_ids
42
+ - skipped: identifiers that resolved to a server but were skipped with a reason
43
+ - unresolved_identifiers: identifiers that didn't match any existing MCP server
44
+
45
+ Use cases:
46
+ - Curate a tenant's recommended-servers list
47
+ - Bulk-add servers by slug or ID in one call
48
+ - Combine adds and removes atomically (e.g., swap one server for another)`,
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: {
52
+ tenant: {
53
+ oneOf: [{ type: 'number' }, { type: 'string' }],
54
+ description: PARAM_DESCRIPTIONS.tenant,
55
+ },
56
+ add_server_identifiers: {
57
+ type: 'array',
58
+ items: { oneOf: [{ type: 'number' }, { type: 'string' }] },
59
+ description: PARAM_DESCRIPTIONS.add_server_identifiers,
60
+ },
61
+ remove_server_identifiers: {
62
+ type: 'array',
63
+ items: { oneOf: [{ type: 'number' }, { type: 'string' }] },
64
+ description: PARAM_DESCRIPTIONS.remove_server_identifiers,
65
+ },
66
+ restore_association_ids: {
67
+ type: 'array',
68
+ items: { type: 'number' },
69
+ description: PARAM_DESCRIPTIONS.restore_association_ids,
70
+ },
71
+ },
72
+ required: ['tenant'],
73
+ },
74
+ handler: async (args) => {
75
+ try {
76
+ const validatedArgs = AddServersToTenantSchema.parse(args);
77
+ const client = clientFactory();
78
+ const result = await client.bulkUpdateTenantServers(validatedArgs.tenant, {
79
+ add_server_identifiers: validatedArgs.add_server_identifiers,
80
+ remove_server_identifiers: validatedArgs.remove_server_identifiers,
81
+ restore_association_ids: validatedArgs.restore_association_ids,
82
+ });
83
+ let content = `**Tenant servers updated** (tenant=${result.tenant.slug}, id=${result.tenant.id})\n\n`;
84
+ content += `- Added: ${result.added.length}\n`;
85
+ content += `- Removed: ${result.removed.length}\n`;
86
+ content += `- Restored: ${result.restored.length}\n`;
87
+ content += `- Skipped: ${result.skipped.length}\n`;
88
+ content += `- Unresolved identifiers: ${result.unresolved_identifiers.length}\n`;
89
+ if (result.added.length > 0) {
90
+ content += `\n**Added:**\n`;
91
+ for (const item of result.added) {
92
+ content += `- ${item.mcp_server_slug} (server_id=${item.mcp_server_id}, association_id=${item.association_id}, outcome=${item.outcome})\n`;
93
+ }
94
+ }
95
+ if (result.removed.length > 0) {
96
+ content += `\n**Removed:**\n`;
97
+ for (const item of result.removed) {
98
+ const assocPart = item.association_id != null ? `, association_id=${item.association_id}` : '';
99
+ content += `- ${item.mcp_server_slug} (server_id=${item.mcp_server_id}${assocPart}, outcome=${item.outcome})\n`;
100
+ }
101
+ }
102
+ if (result.restored.length > 0) {
103
+ content += `\n**Restored:**\n`;
104
+ for (const item of result.restored) {
105
+ content += `- ${item.mcp_server_slug} (server_id=${item.mcp_server_id}, association_id=${item.association_id})\n`;
106
+ }
107
+ }
108
+ if (result.skipped.length > 0) {
109
+ content += `\n**Skipped:**\n`;
110
+ for (const item of result.skipped) {
111
+ const label = item.mcp_server_slug || item.mcp_server_id || item.association_id;
112
+ content += `- ${label} — reason: ${item.reason}\n`;
113
+ }
114
+ }
115
+ if (result.unresolved_identifiers.length > 0) {
116
+ content += `\n**Unresolved identifiers:** ${result.unresolved_identifiers.join(', ')}\n`;
117
+ }
118
+ return { content: [{ type: 'text', text: content }] };
119
+ }
120
+ catch (error) {
121
+ return {
122
+ content: [
123
+ {
124
+ type: 'text',
125
+ text: `Error updating tenant servers: ${error instanceof Error ? error.message : String(error)}`,
126
+ },
127
+ ],
128
+ isError: true,
129
+ };
130
+ }
131
+ },
132
+ };
133
+ }
@@ -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
+ id: 'The numeric ID of the API key to revoke.',
6
+ };
7
+ const DeleteApiKeySchema = z.object({
8
+ id: z.number().int().positive().describe(PARAM_DESCRIPTIONS.id),
9
+ });
10
+ const TOOL_DESCRIPTION = `Permanently revoke (delete) an API key.
11
+
12
+ **This is a destructive operation.** Revoking an API key immediately invalidates it; any clients still using the key will start receiving 401 Unauthorized. The deletion is idempotent — calling delete on a non-existent or already-deleted key returns success.
13
+
14
+ By default, the request requires explicit user approval via MCP elicitation before being sent to the admin API.
15
+
16
+ **Parameters:**
17
+ - \`id\` (required): Numeric ID of the API key.
18
+
19
+ **Returns:**
20
+ - \`success\`: Boolean indicating whether the request succeeded.
21
+ - \`message\`: Human-readable confirmation message.
22
+
23
+ **Use cases:**
24
+ - Revoke a leaked or compromised API key.
25
+ - Roll a tenant's keys after a permission change.
26
+ - Remove a deprecated key during cleanup.`;
27
+ export function deleteApiKey(server, clientFactory) {
28
+ return {
29
+ name: 'delete_api_key',
30
+ description: TOOL_DESCRIPTION,
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ id: {
35
+ type: 'number',
36
+ description: PARAM_DESCRIPTIONS.id,
37
+ },
38
+ },
39
+ required: ['id'],
40
+ },
41
+ handler: async (args) => {
42
+ try {
43
+ const validatedArgs = DeleteApiKeySchema.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.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': 'delete_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.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
+ }
@@ -0,0 +1,129 @@
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
+ id_or_slug: 'The ID (number) or slug (string) of the tenant to delete.',
6
+ force: 'When true, deletes the tenant and cascades to dependent records (API keys, enrichments, etc.). When false (default), the deletion will fail with a 422 if dependents exist.',
7
+ };
8
+ const DeleteTenantSchema = z.object({
9
+ id_or_slug: z.union([z.number(), z.string()]).describe(PARAM_DESCRIPTIONS.id_or_slug),
10
+ force: z.boolean().optional().describe(PARAM_DESCRIPTIONS.force),
11
+ });
12
+ const TOOL_DESCRIPTION = `Permanently delete a tenant from the PulseMCP admin API.
13
+
14
+ **This is a destructive operation.** Deleting a tenant removes the tenant record and (with \`force: true\`) cascades to dependent records such as API keys and enrichments. By default, the request requires explicit user approval via MCP elicitation before being sent to the admin API.
15
+
16
+ **Parameters:**
17
+ - \`id_or_slug\` (required): Numeric ID or slug of the tenant.
18
+ - \`force\` (optional, default false): Cascade-delete dependents. Without this, deleting a tenant that owns API keys or other resources returns 422.
19
+
20
+ **Returns:**
21
+ - \`success\`: Boolean indicating whether the tenant was deleted.
22
+ - \`message\`: Human-readable confirmation message.
23
+
24
+ **Use cases:**
25
+ - Permanently remove a deprovisioned tenant.
26
+ - Delete a test tenant created during onboarding/QA.
27
+ - Cascading cleanup of a tenant and all of its API keys (\`force: true\`).`;
28
+ export function deleteTenant(server, clientFactory) {
29
+ return {
30
+ name: 'delete_tenant',
31
+ description: TOOL_DESCRIPTION,
32
+ inputSchema: {
33
+ type: 'object',
34
+ properties: {
35
+ id_or_slug: {
36
+ oneOf: [{ type: 'number' }, { type: 'string' }],
37
+ description: PARAM_DESCRIPTIONS.id_or_slug,
38
+ },
39
+ force: {
40
+ type: 'boolean',
41
+ description: PARAM_DESCRIPTIONS.force,
42
+ },
43
+ },
44
+ required: ['id_or_slug'],
45
+ },
46
+ handler: async (args) => {
47
+ try {
48
+ const validatedArgs = DeleteTenantSchema.parse(args);
49
+ const elicitConfig = readCmsAdminElicitationConfig();
50
+ if (elicitConfig.destructiveElicitationEnabled) {
51
+ const lines = [
52
+ 'About to PERMANENTLY DELETE a tenant via the PulseMCP admin API:',
53
+ ` Tenant: ${validatedArgs.id_or_slug}`,
54
+ ` Cascade dependents (force): ${validatedArgs.force ? 'yes' : 'no'}`,
55
+ '',
56
+ 'This action cannot be undone.',
57
+ ];
58
+ const confirmation = await requestConfirmation({
59
+ server,
60
+ message: lines.join('\n') + '\n',
61
+ requestedSchema: createConfirmationSchema('Delete this tenant?', 'Confirm that you want to permanently delete this tenant.'),
62
+ meta: {
63
+ 'com.pulsemcp/tool-name': 'delete_tenant',
64
+ },
65
+ }, elicitConfig.base);
66
+ if (confirmation.action !== 'accept') {
67
+ if (confirmation.action === 'expired') {
68
+ return {
69
+ content: [
70
+ {
71
+ type: 'text',
72
+ text: 'Tenant deletion confirmation expired. Please try again.',
73
+ },
74
+ ],
75
+ isError: true,
76
+ };
77
+ }
78
+ return {
79
+ content: [
80
+ {
81
+ type: 'text',
82
+ text: 'Tenant deletion was cancelled by the user.',
83
+ },
84
+ ],
85
+ };
86
+ }
87
+ // Defense-in-depth: some MCP clients may return action='accept' without the
88
+ // user actually checking the confirmation checkbox.
89
+ if (confirmation.content &&
90
+ 'confirm' in confirmation.content &&
91
+ confirmation.content.confirm === false) {
92
+ return {
93
+ content: [
94
+ {
95
+ type: 'text',
96
+ text: 'Tenant deletion was not confirmed.',
97
+ },
98
+ ],
99
+ };
100
+ }
101
+ }
102
+ const client = clientFactory();
103
+ const result = await client.deleteTenant({
104
+ id_or_slug: validatedArgs.id_or_slug,
105
+ force: validatedArgs.force,
106
+ });
107
+ return {
108
+ content: [
109
+ {
110
+ type: 'text',
111
+ text: `${result.success ? 'Tenant deleted.' : 'Tenant deletion failed.'} ${result.message}`,
112
+ },
113
+ ],
114
+ };
115
+ }
116
+ catch (error) {
117
+ return {
118
+ content: [
119
+ {
120
+ type: 'text',
121
+ text: `Error deleting tenant: ${error instanceof Error ? error.message : String(error)}`,
122
+ },
123
+ ],
124
+ isError: true,
125
+ };
126
+ }
127
+ },
128
+ };
129
+ }
@@ -105,6 +105,16 @@ Example response:
105
105
  if (server.verified_no_remote_canonicals !== undefined) {
106
106
  content += `**Verified No Remote Canonicals:** ${server.verified_no_remote_canonicals ? 'Yes' : 'No'}\n`;
107
107
  }
108
+ if (server.owner_tenant_slug || server.owner_tenant_id) {
109
+ content += `**Owner Tenant:** ${server.owner_tenant_slug ?? '(no slug)'}`;
110
+ if (server.owner_tenant_id) {
111
+ content += ` (id: ${server.owner_tenant_id})`;
112
+ }
113
+ content += '\n';
114
+ }
115
+ else {
116
+ content += `**Owner Tenant:** (none)\n`;
117
+ }
108
118
  if (server.short_description) {
109
119
  content += `\n**Short Description:**\n${server.short_description}\n`;
110
120
  }
@@ -152,10 +162,11 @@ Example response:
152
162
  content += `- **Status:** ${server.source_code.github_status}\n`;
153
163
  }
154
164
  }
155
- // Canonical URLs
156
- if (server.canonical_urls && server.canonical_urls.length > 0) {
157
- content += `\n## Canonical URLs\n`;
158
- for (const canonical of server.canonical_urls) {
165
+ // Canonical URLs — always rendered so callers can see the field is empty vs. missing
166
+ const canonicalUrls = server.canonical_urls ?? [];
167
+ if (canonicalUrls.length > 0) {
168
+ content += `\n## Canonical URLs (${canonicalUrls.length})\n`;
169
+ for (const canonical of canonicalUrls) {
159
170
  content += `- **${canonical.scope}:** ${canonical.url}`;
160
171
  if (canonical.note) {
161
172
  content += ` (${canonical.note})`;
@@ -163,10 +174,14 @@ Example response:
163
174
  content += '\n';
164
175
  }
165
176
  }
166
- // Remote endpoints
167
- if (server.remotes && server.remotes.length > 0) {
168
- content += `\n## Remote Endpoints (${server.remotes.length})\n`;
169
- for (const [idx, remote] of server.remotes.entries()) {
177
+ else {
178
+ content += `\n## Canonical URLs\n(none)\n`;
179
+ }
180
+ // Remote endpoints always rendered so callers can see the field is empty vs. missing
181
+ const remotes = server.remotes ?? [];
182
+ if (remotes.length > 0) {
183
+ content += `\n## Remote Endpoints (${remotes.length})\n`;
184
+ for (const [idx, remote] of remotes.entries()) {
170
185
  content += `\n### ${idx + 1}. ${remote.display_name || 'Endpoint'}`;
171
186
  if (remote.id) {
172
187
  content += ` (ID: ${remote.id})`;
@@ -198,6 +213,9 @@ Example response:
198
213
  }
199
214
  }
200
215
  }
216
+ else {
217
+ content += `\n## Remote Endpoints\n(none)\n`;
218
+ }
201
219
  // Tags
202
220
  if (server.tags && server.tags.length > 0) {
203
221
  content += `\n## Tags\n`;
@@ -123,6 +123,9 @@ Use cases:
123
123
  if (server.remotes && server.remotes.length > 0) {
124
124
  content += ` Remote endpoints: ${server.remotes.length}\n`;
125
125
  }
126
+ if (server.owner_tenant_slug || server.owner_tenant_id) {
127
+ content += ` Owner Tenant: ${server.owner_tenant_slug ?? `id ${server.owner_tenant_id}`}\n`;
128
+ }
126
129
  if (server.downloads_estimate_total) {
127
130
  content += ` Downloads (total): ${server.downloads_estimate_total.toLocaleString()}\n`;
128
131
  }