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,89 @@
1
+ import { z } from 'zod';
2
+ const PARAM_DESCRIPTIONS = {
3
+ tenant: 'Tenant ID (number) or slug (string)',
4
+ status: 'Association status filter: "active" (default), "deleted" (soft-deleted only), or "all"',
5
+ limit: 'Results per page, range 1-100 (default 30)',
6
+ offset: 'Pagination offset (default 0)',
7
+ };
8
+ const ListTenantServersSchema = z.object({
9
+ tenant: z.union([z.number(), z.string()]).describe(PARAM_DESCRIPTIONS.tenant),
10
+ status: z.enum(['active', 'deleted', 'all']).optional().describe(PARAM_DESCRIPTIONS.status),
11
+ limit: z.number().int().min(1).max(100).optional().describe(PARAM_DESCRIPTIONS.limit),
12
+ offset: z.number().int().min(0).optional().describe(PARAM_DESCRIPTIONS.offset),
13
+ });
14
+ export function listTenantServers(_server, clientFactory) {
15
+ return {
16
+ name: 'list_tenant_servers',
17
+ description: `List a tenant's recommended MCP server associations (TenantsMcpServer rows).
18
+
19
+ Use this to inspect which public MCP servers are currently part of a tenant's recommendation list, before/after calling add_servers_to_tenant or remove_servers_from_tenant.
20
+
21
+ Each association row includes:
22
+ - id: TenantsMcpServer association ID (use this for restore_association_ids in add_servers_to_tenant)
23
+ - mcp_server_id / mcp_server_slug: the underlying public MCP server
24
+ - status: "active" or "deleted" (soft-deleted)
25
+ - server_json_selection: which server.json variant the tenant pins (always "unofficial" for entries created via these tools)
26
+ - touched / first_touched_at: whether the tenant's customization touched this association
27
+
28
+ Use cases:
29
+ - Audit a tenant's recommendation list
30
+ - Find soft-deleted associations to restore
31
+ - Verify the result of a recent add/remove call`,
32
+ inputSchema: {
33
+ type: 'object',
34
+ properties: {
35
+ tenant: {
36
+ oneOf: [{ type: 'number' }, { type: 'string' }],
37
+ description: PARAM_DESCRIPTIONS.tenant,
38
+ },
39
+ status: {
40
+ type: 'string',
41
+ enum: ['active', 'deleted', 'all'],
42
+ description: PARAM_DESCRIPTIONS.status,
43
+ },
44
+ limit: { type: 'number', description: PARAM_DESCRIPTIONS.limit },
45
+ offset: { type: 'number', description: PARAM_DESCRIPTIONS.offset },
46
+ },
47
+ required: ['tenant'],
48
+ },
49
+ handler: async (args) => {
50
+ try {
51
+ const validatedArgs = ListTenantServersSchema.parse(args);
52
+ const client = clientFactory();
53
+ const response = await client.listTenantServers(validatedArgs.tenant, {
54
+ status: validatedArgs.status,
55
+ limit: validatedArgs.limit,
56
+ offset: validatedArgs.offset,
57
+ });
58
+ const { data, pagination } = response;
59
+ const statusLabel = validatedArgs.status || 'active';
60
+ let content = `**Tenant Server Associations** (status=${statusLabel})\n\n`;
61
+ content += `Showing ${data.length} of ${pagination.total_count} associations (page ${pagination.current_page} of ${pagination.total_pages}).\n\n`;
62
+ if (data.length === 0) {
63
+ content += `_No associations matching this filter._\n`;
64
+ }
65
+ else {
66
+ for (const row of data) {
67
+ content += `- **${row.mcp_server_slug}** (server_id=${row.mcp_server_id}, association_id=${row.id})\n`;
68
+ content += ` status=${row.status}, server_json_selection=${row.server_json_selection}, touched=${row.touched}\n`;
69
+ if (row.first_touched_at) {
70
+ content += ` first_touched_at=${row.first_touched_at}\n`;
71
+ }
72
+ }
73
+ }
74
+ return { content: [{ type: 'text', text: content }] };
75
+ }
76
+ catch (error) {
77
+ return {
78
+ content: [
79
+ {
80
+ type: 'text',
81
+ text: `Error listing tenant servers: ${error instanceof Error ? error.message : String(error)}`,
82
+ },
83
+ ],
84
+ isError: true,
85
+ };
86
+ }
87
+ },
88
+ };
89
+ }
@@ -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
  }
@@ -0,0 +1,75 @@
1
+ import { z } from 'zod';
2
+ const PARAM_DESCRIPTIONS = {
3
+ id: 'The integer id of the MCP server (the "mirror id" used elsewhere in the proctor flow)',
4
+ known_missing_init_tools_list: 'Whether the MCP server is known to be missing the init/tools-list capability. true marks the server as known-missing (proctor will skip the init/tools-list exam); false clears the flag.',
5
+ known_missing_init_tools_list_filter_to: 'Optional. When set, scopes the known-missing flag to a specific entry (e.g., "remotes[0]" or "packages[0]"). Pass an empty string or null to clear the filter. Omit the parameter entirely to leave the existing value untouched.',
6
+ };
7
+ const SetKnownMissingInitToolsListSchema = z.object({
8
+ id: z.number().int().describe(PARAM_DESCRIPTIONS.id),
9
+ known_missing_init_tools_list: z
10
+ .boolean()
11
+ .describe(PARAM_DESCRIPTIONS.known_missing_init_tools_list),
12
+ known_missing_init_tools_list_filter_to: z
13
+ .string()
14
+ .nullable()
15
+ .optional()
16
+ .describe(PARAM_DESCRIPTIONS.known_missing_init_tools_list_filter_to),
17
+ });
18
+ export function setKnownMissingInitToolsList(_server, clientFactory) {
19
+ return {
20
+ name: 'set_known_missing_init_tools_list',
21
+ description: `Update the \`known_missing_init_tools_list\` flag on an MCP server. Optionally also updates the \`known_missing_init_tools_list_filter_to\` scoping value. Identifies the server by integer id (the "mirror id" used elsewhere in the proctor flow).
22
+
23
+ Example request:
24
+ {
25
+ "id": 27765,
26
+ "known_missing_init_tools_list": true,
27
+ "known_missing_init_tools_list_filter_to": "remotes[0]"
28
+ }
29
+
30
+ Use cases:
31
+ - Mark a server as known-missing the init/tools-list capability so proctor skips that exam
32
+ - Clear the known-missing flag once the server starts responding correctly
33
+ - Scope the known-missing flag to a specific remote/package entry via the filter_to value`,
34
+ inputSchema: {
35
+ type: 'object',
36
+ properties: {
37
+ id: { type: 'integer', description: PARAM_DESCRIPTIONS.id },
38
+ known_missing_init_tools_list: {
39
+ type: 'boolean',
40
+ description: PARAM_DESCRIPTIONS.known_missing_init_tools_list,
41
+ },
42
+ known_missing_init_tools_list_filter_to: {
43
+ type: ['string', 'null'],
44
+ description: PARAM_DESCRIPTIONS.known_missing_init_tools_list_filter_to,
45
+ },
46
+ },
47
+ required: ['id', 'known_missing_init_tools_list'],
48
+ },
49
+ handler: async (args) => {
50
+ const validatedArgs = SetKnownMissingInitToolsListSchema.parse(args);
51
+ const client = clientFactory();
52
+ try {
53
+ const result = await client.setKnownMissingInitToolsList(validatedArgs.id, validatedArgs.known_missing_init_tools_list, validatedArgs.known_missing_init_tools_list_filter_to);
54
+ const filterDisplay = result.known_missing_init_tools_list_filter_to === null
55
+ ? '(none)'
56
+ : result.known_missing_init_tools_list_filter_to;
57
+ const text = `Updated MCP server ${result.slug} (id: ${result.id}):
58
+ - known_missing_init_tools_list: ${result.known_missing_init_tools_list}
59
+ - known_missing_init_tools_list_filter_to: ${filterDisplay}`;
60
+ return { content: [{ type: 'text', text }] };
61
+ }
62
+ catch (error) {
63
+ return {
64
+ content: [
65
+ {
66
+ type: 'text',
67
+ text: `Error setting known_missing_init_tools_list: ${error instanceof Error ? error.message : String(error)}`,
68
+ },
69
+ ],
70
+ isError: true,
71
+ };
72
+ }
73
+ },
74
+ };
75
+ }
@@ -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';
@@ -64,6 +70,7 @@ import { runExamForMirror } from './tools/run-exam-for-mirror.js';
64
70
  import { getExamResult } from './tools/get-exam-result.js';
65
71
  import { saveResultsForMirror } from './tools/save-results-for-mirror.js';
66
72
  import { listProctorRuns } from './tools/list-proctor-runs.js';
73
+ import { setKnownMissingInitToolsList } from './tools/set-known-missing-init-tools-list.js';
67
74
  import { getProctorMetadata } from './tools/get-proctor-metadata.js';
68
75
  // Discovered URLs tools
69
76
  import { listDiscoveredUrls } from './tools/list-discovered-urls.js';
@@ -178,6 +185,19 @@ const ALL_TOOLS = [
178
185
  { factory: getTenant, groups: ['tenants'], isWriteOperation: false },
179
186
  { factory: createTenant, groups: ['tenants'], isWriteOperation: true },
180
187
  { factory: createApiKey, groups: ['tenants'], isWriteOperation: true },
188
+ // revoke_api_key lives in the regular `tenants` group (not `tenants_destructive`) so
189
+ // it's exposed to read-write tenant management workflows like sub-registry credential
190
+ // re-issuance. The tool gates each call with MCP elicitation; if the connected client
191
+ // supports neither native elicitation nor an HTTP fallback, the elicitation library
192
+ // throws at runtime rather than silently destroying the key.
193
+ { factory: revokeApiKey, groups: ['tenants'], isWriteOperation: true },
194
+ // Destructive tenant tools — opt-in only, NOT in BASE_TOOL_GROUPS
195
+ { factory: deleteTenant, groups: ['tenants_destructive'], isWriteOperation: true },
196
+ { factory: deleteApiKey, groups: ['tenants_destructive'], isWriteOperation: true },
197
+ // Tenant -> recommended MCP server association tools
198
+ { factory: listTenantServers, groups: ['tenants'], isWriteOperation: false },
199
+ { factory: addServersToTenant, groups: ['tenants'], isWriteOperation: true },
200
+ { factory: removeServersFromTenant, groups: ['tenants'], isWriteOperation: true },
181
201
  // MCP JSON tools (CRUD) (also in server_directory)
182
202
  {
183
203
  factory: getMcpJsons,
@@ -248,6 +268,13 @@ const ALL_TOOLS = [
248
268
  { factory: saveResultsForMirror, groups: ['proctor'], isWriteOperation: true },
249
269
  { factory: listProctorRuns, groups: ['proctor'], isWriteOperation: false },
250
270
  { factory: getProctorMetadata, groups: ['proctor'], isWriteOperation: false },
271
+ // setKnownMissingInitToolsList flips a flag on `mcp_server` records, so it lives in
272
+ // the mcp_servers / server_directory groups (alongside recacheMCPServer), not proctor.
273
+ {
274
+ factory: setKnownMissingInitToolsList,
275
+ groups: ['mcp_servers', 'server_directory'],
276
+ isWriteOperation: true,
277
+ },
251
278
  // Discovered URLs tools
252
279
  { factory: listDiscoveredUrls, groups: ['discovered_urls'], isWriteOperation: false },
253
280
  {
@@ -277,6 +304,7 @@ const VALID_TOOL_GROUPS = [
277
304
  'official_mirrors_readonly',
278
305
  'tenants',
279
306
  'tenants_readonly',
307
+ 'tenants_destructive',
280
308
  'mcp_jsons',
281
309
  'mcp_jsons_readonly',
282
310
  'mcp_servers',
@@ -382,8 +410,9 @@ function shouldIncludeTool(toolDef, enabledGroups) {
382
410
  * - unofficial_mirrors: Unofficial mirror CRUD tools (read + write)
383
411
  * - unofficial_mirrors_readonly: Unofficial mirror tools (read only)
384
412
  * - official_mirrors_readonly: Official mirrors REST API tools (read only)
385
- * - tenants: Tenant management tools including API key provisioning (read + write)
413
+ * - 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
414
  * - tenants_readonly: Tenant tools (read only)
415
+ * - 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
416
  * - mcp_jsons: MCP JSON configuration tools (read + write)
388
417
  * - mcp_jsons_readonly: MCP JSON tools (read only)
389
418
  * - 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;