pulsemcp-cms-admin-mcp-server 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,24 @@
1
+ export async function getProviderById(apiKey, baseUrl, id) {
2
+ const url = new URL(`/api/providers/${id}`, baseUrl);
3
+ const response = await fetch(url.toString(), {
4
+ method: 'GET',
5
+ headers: {
6
+ 'X-API-Key': apiKey,
7
+ Accept: 'application/json',
8
+ },
9
+ });
10
+ if (response.status === 404) {
11
+ return null;
12
+ }
13
+ if (!response.ok) {
14
+ if (response.status === 401) {
15
+ throw new Error('Invalid API key');
16
+ }
17
+ if (response.status === 403) {
18
+ throw new Error('User lacks admin privileges');
19
+ }
20
+ throw new Error(`Failed to fetch provider: ${response.status} ${response.statusText}`);
21
+ }
22
+ const data = (await response.json());
23
+ return data;
24
+ }
@@ -67,50 +67,52 @@ export async function saveMCPImplementation(apiKey, baseUrl, id, params) {
67
67
  formData.append('mcp_implementation[internal_notes]', params.internal_notes);
68
68
  }
69
69
  // Remote endpoints
70
+ // Rails expects nested attributes to use the _attributes suffix for has_many associations
70
71
  if (params.remote !== undefined && params.remote.length > 0) {
71
72
  params.remote.forEach((remote, index) => {
72
73
  if (remote.id !== undefined) {
73
- formData.append(`mcp_implementation[remote][${index}][id]`, remote.id.toString());
74
+ formData.append(`mcp_implementation[remote_attributes][${index}][id]`, remote.id.toString());
74
75
  }
75
76
  if (remote.url_direct !== undefined) {
76
- formData.append(`mcp_implementation[remote][${index}][url_direct]`, remote.url_direct);
77
+ formData.append(`mcp_implementation[remote_attributes][${index}][url_direct]`, remote.url_direct);
77
78
  }
78
79
  if (remote.url_setup !== undefined) {
79
- formData.append(`mcp_implementation[remote][${index}][url_setup]`, remote.url_setup);
80
+ formData.append(`mcp_implementation[remote_attributes][${index}][url_setup]`, remote.url_setup);
80
81
  }
81
82
  if (remote.transport !== undefined) {
82
- formData.append(`mcp_implementation[remote][${index}][transport]`, remote.transport);
83
+ formData.append(`mcp_implementation[remote_attributes][${index}][transport]`, remote.transport);
83
84
  }
84
85
  if (remote.host_platform !== undefined) {
85
- formData.append(`mcp_implementation[remote][${index}][host_platform]`, remote.host_platform);
86
+ formData.append(`mcp_implementation[remote_attributes][${index}][host_platform]`, remote.host_platform);
86
87
  }
87
88
  if (remote.host_infrastructure !== undefined) {
88
- formData.append(`mcp_implementation[remote][${index}][host_infrastructure]`, remote.host_infrastructure);
89
+ formData.append(`mcp_implementation[remote_attributes][${index}][host_infrastructure]`, remote.host_infrastructure);
89
90
  }
90
91
  if (remote.authentication_method !== undefined) {
91
- formData.append(`mcp_implementation[remote][${index}][authentication_method]`, remote.authentication_method);
92
+ formData.append(`mcp_implementation[remote_attributes][${index}][authentication_method]`, remote.authentication_method);
92
93
  }
93
94
  if (remote.cost !== undefined) {
94
- formData.append(`mcp_implementation[remote][${index}][cost]`, remote.cost);
95
+ formData.append(`mcp_implementation[remote_attributes][${index}][cost]`, remote.cost);
95
96
  }
96
97
  if (remote.status !== undefined) {
97
- formData.append(`mcp_implementation[remote][${index}][status]`, remote.status);
98
+ formData.append(`mcp_implementation[remote_attributes][${index}][status]`, remote.status);
98
99
  }
99
100
  if (remote.display_name !== undefined) {
100
- formData.append(`mcp_implementation[remote][${index}][display_name]`, remote.display_name);
101
+ formData.append(`mcp_implementation[remote_attributes][${index}][display_name]`, remote.display_name);
101
102
  }
102
103
  if (remote.internal_notes !== undefined) {
103
- formData.append(`mcp_implementation[remote][${index}][internal_notes]`, remote.internal_notes);
104
+ formData.append(`mcp_implementation[remote_attributes][${index}][internal_notes]`, remote.internal_notes);
104
105
  }
105
106
  });
106
107
  }
107
108
  // Canonical URLs
109
+ // Rails expects nested attributes to use the _attributes suffix for has_many associations
108
110
  if (params.canonical !== undefined && params.canonical.length > 0) {
109
111
  params.canonical.forEach((canonicalUrl, index) => {
110
- formData.append(`mcp_implementation[canonical][${index}][url]`, canonicalUrl.url);
111
- formData.append(`mcp_implementation[canonical][${index}][scope]`, canonicalUrl.scope);
112
+ formData.append(`mcp_implementation[canonical_attributes][${index}][url]`, canonicalUrl.url);
113
+ formData.append(`mcp_implementation[canonical_attributes][${index}][scope]`, canonicalUrl.scope);
112
114
  if (canonicalUrl.note !== undefined) {
113
- formData.append(`mcp_implementation[canonical][${index}][note]`, canonicalUrl.note);
115
+ formData.append(`mcp_implementation[canonical_attributes][${index}][note]`, canonicalUrl.note);
114
116
  }
115
117
  });
116
118
  }
@@ -0,0 +1,45 @@
1
+ export async function searchProviders(apiKey, baseUrl, params) {
2
+ // Endpoint implemented at: /api/providers/search
3
+ // Requires admin authentication via X-API-Key header
4
+ const url = new URL('/api/providers/search', baseUrl);
5
+ // Add query parameters
6
+ url.searchParams.append('q', params.query);
7
+ if (params.limit) {
8
+ url.searchParams.append('limit', params.limit.toString());
9
+ }
10
+ if (params.offset) {
11
+ url.searchParams.append('offset', params.offset.toString());
12
+ }
13
+ const response = await fetch(url.toString(), {
14
+ method: 'GET',
15
+ headers: {
16
+ 'X-API-Key': apiKey,
17
+ Accept: 'application/json',
18
+ },
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 admin privileges');
26
+ }
27
+ if (response.status === 404) {
28
+ throw new Error('Providers search endpoint not found');
29
+ }
30
+ throw new Error(`Failed to search providers: ${response.status} ${response.statusText}`);
31
+ }
32
+ const data = (await response.json());
33
+ return {
34
+ providers: data.data || [],
35
+ pagination: data.meta
36
+ ? {
37
+ current_page: data.meta.current_page,
38
+ total_pages: data.meta.total_pages,
39
+ total_count: data.meta.total_count,
40
+ has_next: data.meta.has_next,
41
+ limit: data.meta.limit,
42
+ }
43
+ : undefined,
44
+ };
45
+ }
@@ -334,5 +334,68 @@ export function createMockPulseMCPAdminClient(mockData) {
334
334
  updated_at: new Date().toISOString(),
335
335
  };
336
336
  },
337
+ async searchProviders(params) {
338
+ if (mockData.errors?.searchProviders) {
339
+ throw mockData.errors.searchProviders;
340
+ }
341
+ if (mockData.providersResponse) {
342
+ return mockData.providersResponse;
343
+ }
344
+ let providers = mockData.providers || [
345
+ {
346
+ id: 1,
347
+ name: 'Test Provider',
348
+ slug: 'test-provider',
349
+ url: 'https://testprovider.com',
350
+ implementations_count: 5,
351
+ created_at: '2024-01-01T00:00:00Z',
352
+ updated_at: '2024-01-01T00:00:00Z',
353
+ },
354
+ ];
355
+ // Apply search filter
356
+ if (params.query) {
357
+ const query = params.query.toLowerCase();
358
+ providers = providers.filter((provider) => provider.name.toLowerCase().includes(query) ||
359
+ provider.slug.toLowerCase().includes(query) ||
360
+ (provider.url && provider.url.toLowerCase().includes(query)));
361
+ }
362
+ // Apply pagination
363
+ const offset = params.offset || 0;
364
+ const limit = params.limit || 30;
365
+ const totalCount = providers.length;
366
+ const paginatedProviders = providers.slice(offset, offset + limit);
367
+ return {
368
+ providers: paginatedProviders,
369
+ pagination: {
370
+ current_page: Math.floor(offset / limit) + 1,
371
+ total_pages: Math.ceil(totalCount / limit),
372
+ total_count: totalCount,
373
+ has_next: offset + limit < totalCount,
374
+ limit: limit,
375
+ },
376
+ };
377
+ },
378
+ async getProviderById(id) {
379
+ if (mockData.errors?.getProviderById) {
380
+ throw mockData.errors.getProviderById;
381
+ }
382
+ // Find provider in mock data by ID
383
+ const providers = mockData.providers || [
384
+ {
385
+ id: 1,
386
+ name: 'Test Provider',
387
+ slug: 'test-provider',
388
+ url: 'https://testprovider.com',
389
+ implementations_count: 5,
390
+ created_at: '2024-01-01T00:00:00Z',
391
+ updated_at: '2024-01-01T00:00:00Z',
392
+ },
393
+ ];
394
+ const found = providers.find((p) => p.id === id);
395
+ if (found)
396
+ return found;
397
+ // Return null if not found
398
+ return null;
399
+ },
337
400
  };
338
401
  }
@@ -75,6 +75,14 @@ export class PulseMCPAdminClient {
75
75
  const { sendEmail } = await import('./pulsemcp-admin-client/lib/send-email.js');
76
76
  return sendEmail(this.apiKey, this.baseUrl, params);
77
77
  }
78
+ async searchProviders(params) {
79
+ const { searchProviders } = await import('./pulsemcp-admin-client/lib/search-providers.js');
80
+ return searchProviders(this.apiKey, this.baseUrl, params);
81
+ }
82
+ async getProviderById(id) {
83
+ const { getProviderById } = await import('./pulsemcp-admin-client/lib/get-provider-by-id.js');
84
+ return getProviderById(this.apiKey, this.baseUrl, id);
85
+ }
78
86
  }
79
87
  export function createMCPServer() {
80
88
  const server = new Server({
@@ -0,0 +1,160 @@
1
+ import { z } from 'zod';
2
+ // Parameter descriptions - single source of truth
3
+ const PARAM_DESCRIPTIONS = {
4
+ id: 'Provider ID to retrieve a specific provider',
5
+ query: 'Search query to find providers by name, URL, or slug',
6
+ limit: 'Maximum number of results to return (1-100, default: 30)',
7
+ offset: 'Number of results to skip for pagination (default: 0)',
8
+ };
9
+ const FindProvidersSchema = z
10
+ .object({
11
+ id: z.number().int().positive().optional().describe(PARAM_DESCRIPTIONS.id),
12
+ query: z.string().min(1).optional().describe(PARAM_DESCRIPTIONS.query),
13
+ limit: z.number().min(1).max(100).optional().describe(PARAM_DESCRIPTIONS.limit),
14
+ offset: z.number().min(0).optional().describe(PARAM_DESCRIPTIONS.offset),
15
+ })
16
+ .refine((data) => data.id !== undefined || data.query !== undefined, {
17
+ message: 'Either id or query must be provided',
18
+ });
19
+ export function findProviders(_server, clientFactory) {
20
+ return {
21
+ name: 'find_providers',
22
+ description: `Find providers (organizations/individuals) in the PulseMCP registry.
23
+
24
+ This tool can operate in two modes:
25
+
26
+ 1. **Find by ID**: Retrieve a specific provider by its numeric ID
27
+ - Returns a single provider with detailed information
28
+ - Returns null if not found
29
+
30
+ 2. **Search by query**: Search for providers by name, URL, or slug
31
+ - Searches across provider name, URL, and slug fields (case-insensitive)
32
+ - Returns a list of matching providers with pagination support
33
+ - Each result includes implementation counts
34
+
35
+ Provider information includes:
36
+ - ID and slug
37
+ - Name and URL
38
+ - Number of associated implementations
39
+ - Creation and update timestamps
40
+
41
+ Use cases:
42
+ - Look up a specific provider by ID
43
+ - Search for providers by name (e.g., "anthropic", "modelcontextprotocol")
44
+ - Find providers by partial name matches
45
+ - Discover all providers for an organization
46
+ - Check how many implementations a provider has published
47
+
48
+ Note: This tool queries the PulseMCP registry API. Results depend on what has been published to the registry.`,
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: {
52
+ id: {
53
+ type: 'number',
54
+ description: PARAM_DESCRIPTIONS.id,
55
+ },
56
+ query: {
57
+ type: 'string',
58
+ description: PARAM_DESCRIPTIONS.query,
59
+ },
60
+ limit: {
61
+ type: 'number',
62
+ description: PARAM_DESCRIPTIONS.limit,
63
+ },
64
+ offset: {
65
+ type: 'number',
66
+ description: PARAM_DESCRIPTIONS.offset,
67
+ },
68
+ },
69
+ },
70
+ handler: async (args) => {
71
+ const validatedArgs = FindProvidersSchema.parse(args);
72
+ const client = clientFactory();
73
+ try {
74
+ // Mode 1: Find by ID
75
+ if (validatedArgs.id !== undefined) {
76
+ const provider = await client.getProviderById(validatedArgs.id);
77
+ if (!provider) {
78
+ return {
79
+ content: [
80
+ {
81
+ type: 'text',
82
+ text: `Provider with ID ${validatedArgs.id} not found.`,
83
+ },
84
+ ],
85
+ };
86
+ }
87
+ let content = `**${provider.name}**\n`;
88
+ content += `ID: ${provider.id}\n`;
89
+ content += `Slug: ${provider.slug}\n`;
90
+ if (provider.url) {
91
+ content += `URL: ${provider.url}\n`;
92
+ }
93
+ if (provider.implementations_count !== undefined) {
94
+ content += `Implementations: ${provider.implementations_count}\n`;
95
+ }
96
+ if (provider.created_at) {
97
+ content += `Created: ${provider.created_at}\n`;
98
+ }
99
+ return {
100
+ content: [
101
+ {
102
+ type: 'text',
103
+ text: content.trim(),
104
+ },
105
+ ],
106
+ };
107
+ }
108
+ // Mode 2: Search by query
109
+ if (validatedArgs.query !== undefined) {
110
+ const response = await client.searchProviders({
111
+ query: validatedArgs.query,
112
+ limit: validatedArgs.limit,
113
+ offset: validatedArgs.offset,
114
+ });
115
+ let content = `Found ${response.providers.length} provider(s) matching "${validatedArgs.query}"`;
116
+ if (response.pagination) {
117
+ content += ` (showing ${response.providers.length} of ${response.pagination.total_count} total)`;
118
+ }
119
+ content += ':\n\n';
120
+ for (const [index, provider] of response.providers.entries()) {
121
+ content += `${index + 1}. **${provider.name}**\n`;
122
+ content += ` ID: ${provider.id} | Slug: ${provider.slug}\n`;
123
+ if (provider.url) {
124
+ content += ` URL: ${provider.url}\n`;
125
+ }
126
+ if (provider.implementations_count !== undefined) {
127
+ content += ` Implementations: ${provider.implementations_count}\n`;
128
+ }
129
+ content += '\n';
130
+ }
131
+ if (response.pagination?.has_next) {
132
+ const nextOffset = (validatedArgs.offset || 0) + (validatedArgs.limit || 30);
133
+ content += `\n---\nMore results available. Use offset=${nextOffset} to see the next page.`;
134
+ }
135
+ return {
136
+ content: [
137
+ {
138
+ type: 'text',
139
+ text: content.trim(),
140
+ },
141
+ ],
142
+ };
143
+ }
144
+ // This should never happen due to zod refinement, but TypeScript needs it
145
+ throw new Error('Either id or query must be provided');
146
+ }
147
+ catch (error) {
148
+ return {
149
+ content: [
150
+ {
151
+ type: 'text',
152
+ text: `Error finding providers: ${error instanceof Error ? error.message : String(error)}`,
153
+ },
154
+ ],
155
+ isError: true,
156
+ };
157
+ }
158
+ },
159
+ };
160
+ }
@@ -38,6 +38,8 @@ Returns a list of matching implementations with their metadata, including:
38
38
  - GitHub stars and popularity metrics
39
39
  - Associated MCP server/client IDs
40
40
  - PulseMCP web URL
41
+ - Remote endpoints (for servers) - hosting platforms, transport methods, authentication
42
+ - Canonical URLs - authoritative sources for the implementation
41
43
 
42
44
  Use cases:
43
45
  - Find existing MCP servers before creating a new one
@@ -96,6 +98,7 @@ Note: This tool queries the PulseMCP registry API. Results depend on what has be
96
98
  content += ':\n\n';
97
99
  for (const [index, impl] of response.implementations.entries()) {
98
100
  content += `${index + 1}. **${impl.name}** (${impl.type})\n`;
101
+ content += ` ID: ${impl.id}\n`;
99
102
  content += ` Slug: ${impl.slug}\n`;
100
103
  content += ` Status: ${impl.status}`;
101
104
  if (impl.classification) {
@@ -124,6 +127,40 @@ Note: This tool queries the PulseMCP registry API. Results depend on what has be
124
127
  if (impl.mcp_client_id) {
125
128
  content += ` MCP Client ID: ${impl.mcp_client_id}\n`;
126
129
  }
130
+ // Display remote endpoints if available
131
+ if (impl.mcp_server?.remotes && impl.mcp_server.remotes.length > 0) {
132
+ content += ` Remotes (${impl.mcp_server.remotes.length}):\n`;
133
+ for (const remote of impl.mcp_server.remotes) {
134
+ let remoteLine = ` - ${remote.display_name || remote.url_direct || remote.url_setup || 'Unnamed'}`;
135
+ const attrs = [];
136
+ if (remote.transport)
137
+ attrs.push(remote.transport);
138
+ if (remote.host_platform)
139
+ attrs.push(remote.host_platform);
140
+ if (remote.authentication_method)
141
+ attrs.push(`auth: ${remote.authentication_method}`);
142
+ if (remote.cost)
143
+ attrs.push(remote.cost);
144
+ if (attrs.length > 0) {
145
+ remoteLine += ` [${attrs.join(', ')}]`;
146
+ }
147
+ content += remoteLine + '\n';
148
+ }
149
+ }
150
+ else if (impl.mcp_server?.mcp_server_remotes_count) {
151
+ content += ` Remotes Count: ${impl.mcp_server.mcp_server_remotes_count}\n`;
152
+ }
153
+ // Display canonical URLs if available
154
+ if (impl.canonical && impl.canonical.length > 0) {
155
+ content += ` Canonical URLs (${impl.canonical.length}):\n`;
156
+ for (const canonical of impl.canonical) {
157
+ let canonicalLine = ` - ${canonical.url} [scope: ${canonical.scope}]`;
158
+ if (canonical.note) {
159
+ canonicalLine += ` - ${canonical.note}`;
160
+ }
161
+ content += canonicalLine + '\n';
162
+ }
163
+ }
127
164
  content += '\n';
128
165
  }
129
166
  if (response.pagination?.has_next) {
@@ -9,6 +9,7 @@ import { searchMCPImplementations } from './tools/search-mcp-implementations.js'
9
9
  import { getDraftMCPImplementations } from './tools/get-draft-mcp-implementations.js';
10
10
  import { saveMCPImplementation } from './tools/save-mcp-implementation.js';
11
11
  import { sendMCPImplementationPostingNotification } from './tools/send-mcp-implementation-posting-notification.js';
12
+ import { findProviders } from './tools/find-providers.js';
12
13
  const ALL_TOOLS = [
13
14
  { factory: getNewsletterPosts, groups: ['newsletter'] },
14
15
  { factory: getNewsletterPost, groups: ['newsletter'] },
@@ -20,6 +21,7 @@ const ALL_TOOLS = [
20
21
  { factory: getDraftMCPImplementations, groups: ['server_queue_readonly', 'server_queue_all'] },
21
22
  { factory: saveMCPImplementation, groups: ['server_queue_all'] },
22
23
  { factory: sendMCPImplementationPostingNotification, groups: ['server_queue_all'] },
24
+ { factory: findProviders, groups: ['server_queue_readonly', 'server_queue_all'] },
23
25
  ];
24
26
  /**
25
27
  * Parse enabled tool groups from environment variable or parameter
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulsemcp-cms-admin-mcp-server",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Local implementation of PulseMCP CMS Admin MCP server",
5
5
  "mcpName": "com.pulsemcp.servers/pulsemcp-cms-admin",
6
6
  "main": "build/index.js",
@@ -0,0 +1,3 @@
1
+ import type { Provider } from '../../types.js';
2
+ export declare function getProviderById(apiKey: string, baseUrl: string, id: number): Promise<Provider | null>;
3
+ //# sourceMappingURL=get-provider-by-id.d.ts.map
@@ -0,0 +1,24 @@
1
+ export async function getProviderById(apiKey, baseUrl, id) {
2
+ const url = new URL(`/api/providers/${id}`, baseUrl);
3
+ const response = await fetch(url.toString(), {
4
+ method: 'GET',
5
+ headers: {
6
+ 'X-API-Key': apiKey,
7
+ Accept: 'application/json',
8
+ },
9
+ });
10
+ if (response.status === 404) {
11
+ return null;
12
+ }
13
+ if (!response.ok) {
14
+ if (response.status === 401) {
15
+ throw new Error('Invalid API key');
16
+ }
17
+ if (response.status === 403) {
18
+ throw new Error('User lacks admin privileges');
19
+ }
20
+ throw new Error(`Failed to fetch provider: ${response.status} ${response.statusText}`);
21
+ }
22
+ const data = (await response.json());
23
+ return data;
24
+ }
@@ -67,50 +67,52 @@ export async function saveMCPImplementation(apiKey, baseUrl, id, params) {
67
67
  formData.append('mcp_implementation[internal_notes]', params.internal_notes);
68
68
  }
69
69
  // Remote endpoints
70
+ // Rails expects nested attributes to use the _attributes suffix for has_many associations
70
71
  if (params.remote !== undefined && params.remote.length > 0) {
71
72
  params.remote.forEach((remote, index) => {
72
73
  if (remote.id !== undefined) {
73
- formData.append(`mcp_implementation[remote][${index}][id]`, remote.id.toString());
74
+ formData.append(`mcp_implementation[remote_attributes][${index}][id]`, remote.id.toString());
74
75
  }
75
76
  if (remote.url_direct !== undefined) {
76
- formData.append(`mcp_implementation[remote][${index}][url_direct]`, remote.url_direct);
77
+ formData.append(`mcp_implementation[remote_attributes][${index}][url_direct]`, remote.url_direct);
77
78
  }
78
79
  if (remote.url_setup !== undefined) {
79
- formData.append(`mcp_implementation[remote][${index}][url_setup]`, remote.url_setup);
80
+ formData.append(`mcp_implementation[remote_attributes][${index}][url_setup]`, remote.url_setup);
80
81
  }
81
82
  if (remote.transport !== undefined) {
82
- formData.append(`mcp_implementation[remote][${index}][transport]`, remote.transport);
83
+ formData.append(`mcp_implementation[remote_attributes][${index}][transport]`, remote.transport);
83
84
  }
84
85
  if (remote.host_platform !== undefined) {
85
- formData.append(`mcp_implementation[remote][${index}][host_platform]`, remote.host_platform);
86
+ formData.append(`mcp_implementation[remote_attributes][${index}][host_platform]`, remote.host_platform);
86
87
  }
87
88
  if (remote.host_infrastructure !== undefined) {
88
- formData.append(`mcp_implementation[remote][${index}][host_infrastructure]`, remote.host_infrastructure);
89
+ formData.append(`mcp_implementation[remote_attributes][${index}][host_infrastructure]`, remote.host_infrastructure);
89
90
  }
90
91
  if (remote.authentication_method !== undefined) {
91
- formData.append(`mcp_implementation[remote][${index}][authentication_method]`, remote.authentication_method);
92
+ formData.append(`mcp_implementation[remote_attributes][${index}][authentication_method]`, remote.authentication_method);
92
93
  }
93
94
  if (remote.cost !== undefined) {
94
- formData.append(`mcp_implementation[remote][${index}][cost]`, remote.cost);
95
+ formData.append(`mcp_implementation[remote_attributes][${index}][cost]`, remote.cost);
95
96
  }
96
97
  if (remote.status !== undefined) {
97
- formData.append(`mcp_implementation[remote][${index}][status]`, remote.status);
98
+ formData.append(`mcp_implementation[remote_attributes][${index}][status]`, remote.status);
98
99
  }
99
100
  if (remote.display_name !== undefined) {
100
- formData.append(`mcp_implementation[remote][${index}][display_name]`, remote.display_name);
101
+ formData.append(`mcp_implementation[remote_attributes][${index}][display_name]`, remote.display_name);
101
102
  }
102
103
  if (remote.internal_notes !== undefined) {
103
- formData.append(`mcp_implementation[remote][${index}][internal_notes]`, remote.internal_notes);
104
+ formData.append(`mcp_implementation[remote_attributes][${index}][internal_notes]`, remote.internal_notes);
104
105
  }
105
106
  });
106
107
  }
107
108
  // Canonical URLs
109
+ // Rails expects nested attributes to use the _attributes suffix for has_many associations
108
110
  if (params.canonical !== undefined && params.canonical.length > 0) {
109
111
  params.canonical.forEach((canonicalUrl, index) => {
110
- formData.append(`mcp_implementation[canonical][${index}][url]`, canonicalUrl.url);
111
- formData.append(`mcp_implementation[canonical][${index}][scope]`, canonicalUrl.scope);
112
+ formData.append(`mcp_implementation[canonical_attributes][${index}][url]`, canonicalUrl.url);
113
+ formData.append(`mcp_implementation[canonical_attributes][${index}][scope]`, canonicalUrl.scope);
112
114
  if (canonicalUrl.note !== undefined) {
113
- formData.append(`mcp_implementation[canonical][${index}][note]`, canonicalUrl.note);
115
+ formData.append(`mcp_implementation[canonical_attributes][${index}][note]`, canonicalUrl.note);
114
116
  }
115
117
  });
116
118
  }
@@ -0,0 +1,7 @@
1
+ import type { ProvidersResponse } from '../../types.js';
2
+ export declare function searchProviders(apiKey: string, baseUrl: string, params: {
3
+ query: string;
4
+ limit?: number;
5
+ offset?: number;
6
+ }): Promise<ProvidersResponse>;
7
+ //# sourceMappingURL=search-providers.d.ts.map
@@ -0,0 +1,45 @@
1
+ export async function searchProviders(apiKey, baseUrl, params) {
2
+ // Endpoint implemented at: /api/providers/search
3
+ // Requires admin authentication via X-API-Key header
4
+ const url = new URL('/api/providers/search', baseUrl);
5
+ // Add query parameters
6
+ url.searchParams.append('q', params.query);
7
+ if (params.limit) {
8
+ url.searchParams.append('limit', params.limit.toString());
9
+ }
10
+ if (params.offset) {
11
+ url.searchParams.append('offset', params.offset.toString());
12
+ }
13
+ const response = await fetch(url.toString(), {
14
+ method: 'GET',
15
+ headers: {
16
+ 'X-API-Key': apiKey,
17
+ Accept: 'application/json',
18
+ },
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 admin privileges');
26
+ }
27
+ if (response.status === 404) {
28
+ throw new Error('Providers search endpoint not found');
29
+ }
30
+ throw new Error(`Failed to search providers: ${response.status} ${response.statusText}`);
31
+ }
32
+ const data = (await response.json());
33
+ return {
34
+ providers: data.data || [],
35
+ pagination: data.meta
36
+ ? {
37
+ current_page: data.meta.current_page,
38
+ total_pages: data.meta.total_pages,
39
+ total_count: data.meta.total_count,
40
+ has_next: data.meta.has_next,
41
+ limit: data.meta.limit,
42
+ }
43
+ : undefined,
44
+ };
45
+ }
@@ -1,5 +1,5 @@
1
1
  import type { IPulseMCPAdminClient } from '../server.js';
2
- import type { Post, ImageUploadResponse, Author, MCPServer, MCPClient, MCPImplementation, MCPImplementationsResponse } from '../types.js';
2
+ import type { Post, ImageUploadResponse, Author, MCPServer, MCPClient, MCPImplementation, MCPImplementationsResponse, Provider, ProvidersResponse } from '../types.js';
3
3
  interface MockData {
4
4
  posts?: Post[];
5
5
  postsBySlug?: Record<string, Post>;
@@ -13,6 +13,8 @@ interface MockData {
13
13
  implementations?: MCPImplementation[];
14
14
  implementationsResponse?: MCPImplementationsResponse;
15
15
  draftImplementationsResponse?: MCPImplementationsResponse;
16
+ providers?: Provider[];
17
+ providersResponse?: ProvidersResponse;
16
18
  errors?: {
17
19
  getPosts?: Error;
18
20
  getPost?: Error;
@@ -27,6 +29,8 @@ interface MockData {
27
29
  getDraftMCPImplementations?: Error;
28
30
  saveMCPImplementation?: Error;
29
31
  sendEmail?: Error;
32
+ searchProviders?: Error;
33
+ getProviderById?: Error;
30
34
  };
31
35
  }
32
36
  export declare function createMockPulseMCPAdminClient(mockData: MockData): IPulseMCPAdminClient;
@@ -334,5 +334,68 @@ export function createMockPulseMCPAdminClient(mockData) {
334
334
  updated_at: new Date().toISOString(),
335
335
  };
336
336
  },
337
+ async searchProviders(params) {
338
+ if (mockData.errors?.searchProviders) {
339
+ throw mockData.errors.searchProviders;
340
+ }
341
+ if (mockData.providersResponse) {
342
+ return mockData.providersResponse;
343
+ }
344
+ let providers = mockData.providers || [
345
+ {
346
+ id: 1,
347
+ name: 'Test Provider',
348
+ slug: 'test-provider',
349
+ url: 'https://testprovider.com',
350
+ implementations_count: 5,
351
+ created_at: '2024-01-01T00:00:00Z',
352
+ updated_at: '2024-01-01T00:00:00Z',
353
+ },
354
+ ];
355
+ // Apply search filter
356
+ if (params.query) {
357
+ const query = params.query.toLowerCase();
358
+ providers = providers.filter((provider) => provider.name.toLowerCase().includes(query) ||
359
+ provider.slug.toLowerCase().includes(query) ||
360
+ (provider.url && provider.url.toLowerCase().includes(query)));
361
+ }
362
+ // Apply pagination
363
+ const offset = params.offset || 0;
364
+ const limit = params.limit || 30;
365
+ const totalCount = providers.length;
366
+ const paginatedProviders = providers.slice(offset, offset + limit);
367
+ return {
368
+ providers: paginatedProviders,
369
+ pagination: {
370
+ current_page: Math.floor(offset / limit) + 1,
371
+ total_pages: Math.ceil(totalCount / limit),
372
+ total_count: totalCount,
373
+ has_next: offset + limit < totalCount,
374
+ limit: limit,
375
+ },
376
+ };
377
+ },
378
+ async getProviderById(id) {
379
+ if (mockData.errors?.getProviderById) {
380
+ throw mockData.errors.getProviderById;
381
+ }
382
+ // Find provider in mock data by ID
383
+ const providers = mockData.providers || [
384
+ {
385
+ id: 1,
386
+ name: 'Test Provider',
387
+ slug: 'test-provider',
388
+ url: 'https://testprovider.com',
389
+ implementations_count: 5,
390
+ created_at: '2024-01-01T00:00:00Z',
391
+ updated_at: '2024-01-01T00:00:00Z',
392
+ },
393
+ ];
394
+ const found = providers.find((p) => p.id === id);
395
+ if (found)
396
+ return found;
397
+ // Return null if not found
398
+ return null;
399
+ },
337
400
  };
338
401
  }
@@ -1,5 +1,5 @@
1
1
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
- import type { Post, PostsResponse, CreatePostParams, UpdatePostParams, ImageUploadResponse, Author, AuthorsResponse, MCPServer, MCPClient, MCPImplementation, MCPImplementationsResponse, SaveMCPImplementationParams } from './types.js';
2
+ import type { Post, PostsResponse, CreatePostParams, UpdatePostParams, ImageUploadResponse, Author, AuthorsResponse, MCPServer, MCPClient, MCPImplementation, MCPImplementationsResponse, SaveMCPImplementationParams, Provider, ProvidersResponse } from './types.js';
3
3
  export interface IPulseMCPAdminClient {
4
4
  getPosts(params?: {
5
5
  search?: string;
@@ -54,6 +54,12 @@ export interface IPulseMCPAdminClient {
54
54
  created_at: string;
55
55
  updated_at: string;
56
56
  }>;
57
+ searchProviders(params: {
58
+ query: string;
59
+ limit?: number;
60
+ offset?: number;
61
+ }): Promise<ProvidersResponse>;
62
+ getProviderById(id: number): Promise<Provider | null>;
57
63
  }
58
64
  export declare class PulseMCPAdminClient implements IPulseMCPAdminClient {
59
65
  private apiKey;
@@ -112,6 +118,12 @@ export declare class PulseMCPAdminClient implements IPulseMCPAdminClient {
112
118
  created_at: string;
113
119
  updated_at: string;
114
120
  }>;
121
+ searchProviders(params: {
122
+ query: string;
123
+ limit?: number;
124
+ offset?: number;
125
+ }): Promise<ProvidersResponse>;
126
+ getProviderById(id: number): Promise<Provider | null>;
115
127
  }
116
128
  export type ClientFactory = () => IPulseMCPAdminClient;
117
129
  export declare function createMCPServer(): {
package/shared/server.js CHANGED
@@ -75,6 +75,14 @@ export class PulseMCPAdminClient {
75
75
  const { sendEmail } = await import('./pulsemcp-admin-client/lib/send-email.js');
76
76
  return sendEmail(this.apiKey, this.baseUrl, params);
77
77
  }
78
+ async searchProviders(params) {
79
+ const { searchProviders } = await import('./pulsemcp-admin-client/lib/search-providers.js');
80
+ return searchProviders(this.apiKey, this.baseUrl, params);
81
+ }
82
+ async getProviderById(id) {
83
+ const { getProviderById } = await import('./pulsemcp-admin-client/lib/get-provider-by-id.js');
84
+ return getProviderById(this.apiKey, this.baseUrl, id);
85
+ }
78
86
  }
79
87
  export function createMCPServer() {
80
88
  const server = new Server({
@@ -0,0 +1,41 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import type { ClientFactory } from '../server.js';
3
+ export declare function findProviders(_server: Server, clientFactory: ClientFactory): {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: {
7
+ type: string;
8
+ properties: {
9
+ id: {
10
+ type: string;
11
+ description: "Provider ID to retrieve a specific provider";
12
+ };
13
+ query: {
14
+ type: string;
15
+ description: "Search query to find providers by name, URL, or slug";
16
+ };
17
+ limit: {
18
+ type: string;
19
+ description: "Maximum number of results to return (1-100, default: 30)";
20
+ };
21
+ offset: {
22
+ type: string;
23
+ description: "Number of results to skip for pagination (default: 0)";
24
+ };
25
+ };
26
+ };
27
+ handler: (args: unknown) => Promise<{
28
+ content: {
29
+ type: string;
30
+ text: string;
31
+ }[];
32
+ isError?: undefined;
33
+ } | {
34
+ content: {
35
+ type: string;
36
+ text: string;
37
+ }[];
38
+ isError: boolean;
39
+ }>;
40
+ };
41
+ //# sourceMappingURL=find-providers.d.ts.map
@@ -0,0 +1,160 @@
1
+ import { z } from 'zod';
2
+ // Parameter descriptions - single source of truth
3
+ const PARAM_DESCRIPTIONS = {
4
+ id: 'Provider ID to retrieve a specific provider',
5
+ query: 'Search query to find providers by name, URL, or slug',
6
+ limit: 'Maximum number of results to return (1-100, default: 30)',
7
+ offset: 'Number of results to skip for pagination (default: 0)',
8
+ };
9
+ const FindProvidersSchema = z
10
+ .object({
11
+ id: z.number().int().positive().optional().describe(PARAM_DESCRIPTIONS.id),
12
+ query: z.string().min(1).optional().describe(PARAM_DESCRIPTIONS.query),
13
+ limit: z.number().min(1).max(100).optional().describe(PARAM_DESCRIPTIONS.limit),
14
+ offset: z.number().min(0).optional().describe(PARAM_DESCRIPTIONS.offset),
15
+ })
16
+ .refine((data) => data.id !== undefined || data.query !== undefined, {
17
+ message: 'Either id or query must be provided',
18
+ });
19
+ export function findProviders(_server, clientFactory) {
20
+ return {
21
+ name: 'find_providers',
22
+ description: `Find providers (organizations/individuals) in the PulseMCP registry.
23
+
24
+ This tool can operate in two modes:
25
+
26
+ 1. **Find by ID**: Retrieve a specific provider by its numeric ID
27
+ - Returns a single provider with detailed information
28
+ - Returns null if not found
29
+
30
+ 2. **Search by query**: Search for providers by name, URL, or slug
31
+ - Searches across provider name, URL, and slug fields (case-insensitive)
32
+ - Returns a list of matching providers with pagination support
33
+ - Each result includes implementation counts
34
+
35
+ Provider information includes:
36
+ - ID and slug
37
+ - Name and URL
38
+ - Number of associated implementations
39
+ - Creation and update timestamps
40
+
41
+ Use cases:
42
+ - Look up a specific provider by ID
43
+ - Search for providers by name (e.g., "anthropic", "modelcontextprotocol")
44
+ - Find providers by partial name matches
45
+ - Discover all providers for an organization
46
+ - Check how many implementations a provider has published
47
+
48
+ Note: This tool queries the PulseMCP registry API. Results depend on what has been published to the registry.`,
49
+ inputSchema: {
50
+ type: 'object',
51
+ properties: {
52
+ id: {
53
+ type: 'number',
54
+ description: PARAM_DESCRIPTIONS.id,
55
+ },
56
+ query: {
57
+ type: 'string',
58
+ description: PARAM_DESCRIPTIONS.query,
59
+ },
60
+ limit: {
61
+ type: 'number',
62
+ description: PARAM_DESCRIPTIONS.limit,
63
+ },
64
+ offset: {
65
+ type: 'number',
66
+ description: PARAM_DESCRIPTIONS.offset,
67
+ },
68
+ },
69
+ },
70
+ handler: async (args) => {
71
+ const validatedArgs = FindProvidersSchema.parse(args);
72
+ const client = clientFactory();
73
+ try {
74
+ // Mode 1: Find by ID
75
+ if (validatedArgs.id !== undefined) {
76
+ const provider = await client.getProviderById(validatedArgs.id);
77
+ if (!provider) {
78
+ return {
79
+ content: [
80
+ {
81
+ type: 'text',
82
+ text: `Provider with ID ${validatedArgs.id} not found.`,
83
+ },
84
+ ],
85
+ };
86
+ }
87
+ let content = `**${provider.name}**\n`;
88
+ content += `ID: ${provider.id}\n`;
89
+ content += `Slug: ${provider.slug}\n`;
90
+ if (provider.url) {
91
+ content += `URL: ${provider.url}\n`;
92
+ }
93
+ if (provider.implementations_count !== undefined) {
94
+ content += `Implementations: ${provider.implementations_count}\n`;
95
+ }
96
+ if (provider.created_at) {
97
+ content += `Created: ${provider.created_at}\n`;
98
+ }
99
+ return {
100
+ content: [
101
+ {
102
+ type: 'text',
103
+ text: content.trim(),
104
+ },
105
+ ],
106
+ };
107
+ }
108
+ // Mode 2: Search by query
109
+ if (validatedArgs.query !== undefined) {
110
+ const response = await client.searchProviders({
111
+ query: validatedArgs.query,
112
+ limit: validatedArgs.limit,
113
+ offset: validatedArgs.offset,
114
+ });
115
+ let content = `Found ${response.providers.length} provider(s) matching "${validatedArgs.query}"`;
116
+ if (response.pagination) {
117
+ content += ` (showing ${response.providers.length} of ${response.pagination.total_count} total)`;
118
+ }
119
+ content += ':\n\n';
120
+ for (const [index, provider] of response.providers.entries()) {
121
+ content += `${index + 1}. **${provider.name}**\n`;
122
+ content += ` ID: ${provider.id} | Slug: ${provider.slug}\n`;
123
+ if (provider.url) {
124
+ content += ` URL: ${provider.url}\n`;
125
+ }
126
+ if (provider.implementations_count !== undefined) {
127
+ content += ` Implementations: ${provider.implementations_count}\n`;
128
+ }
129
+ content += '\n';
130
+ }
131
+ if (response.pagination?.has_next) {
132
+ const nextOffset = (validatedArgs.offset || 0) + (validatedArgs.limit || 30);
133
+ content += `\n---\nMore results available. Use offset=${nextOffset} to see the next page.`;
134
+ }
135
+ return {
136
+ content: [
137
+ {
138
+ type: 'text',
139
+ text: content.trim(),
140
+ },
141
+ ],
142
+ };
143
+ }
144
+ // This should never happen due to zod refinement, but TypeScript needs it
145
+ throw new Error('Either id or query must be provided');
146
+ }
147
+ catch (error) {
148
+ return {
149
+ content: [
150
+ {
151
+ type: 'text',
152
+ text: `Error finding providers: ${error instanceof Error ? error.message : String(error)}`,
153
+ },
154
+ ],
155
+ isError: true,
156
+ };
157
+ }
158
+ },
159
+ };
160
+ }
@@ -38,6 +38,8 @@ Returns a list of matching implementations with their metadata, including:
38
38
  - GitHub stars and popularity metrics
39
39
  - Associated MCP server/client IDs
40
40
  - PulseMCP web URL
41
+ - Remote endpoints (for servers) - hosting platforms, transport methods, authentication
42
+ - Canonical URLs - authoritative sources for the implementation
41
43
 
42
44
  Use cases:
43
45
  - Find existing MCP servers before creating a new one
@@ -96,6 +98,7 @@ Note: This tool queries the PulseMCP registry API. Results depend on what has be
96
98
  content += ':\n\n';
97
99
  for (const [index, impl] of response.implementations.entries()) {
98
100
  content += `${index + 1}. **${impl.name}** (${impl.type})\n`;
101
+ content += ` ID: ${impl.id}\n`;
99
102
  content += ` Slug: ${impl.slug}\n`;
100
103
  content += ` Status: ${impl.status}`;
101
104
  if (impl.classification) {
@@ -124,6 +127,40 @@ Note: This tool queries the PulseMCP registry API. Results depend on what has be
124
127
  if (impl.mcp_client_id) {
125
128
  content += ` MCP Client ID: ${impl.mcp_client_id}\n`;
126
129
  }
130
+ // Display remote endpoints if available
131
+ if (impl.mcp_server?.remotes && impl.mcp_server.remotes.length > 0) {
132
+ content += ` Remotes (${impl.mcp_server.remotes.length}):\n`;
133
+ for (const remote of impl.mcp_server.remotes) {
134
+ let remoteLine = ` - ${remote.display_name || remote.url_direct || remote.url_setup || 'Unnamed'}`;
135
+ const attrs = [];
136
+ if (remote.transport)
137
+ attrs.push(remote.transport);
138
+ if (remote.host_platform)
139
+ attrs.push(remote.host_platform);
140
+ if (remote.authentication_method)
141
+ attrs.push(`auth: ${remote.authentication_method}`);
142
+ if (remote.cost)
143
+ attrs.push(remote.cost);
144
+ if (attrs.length > 0) {
145
+ remoteLine += ` [${attrs.join(', ')}]`;
146
+ }
147
+ content += remoteLine + '\n';
148
+ }
149
+ }
150
+ else if (impl.mcp_server?.mcp_server_remotes_count) {
151
+ content += ` Remotes Count: ${impl.mcp_server.mcp_server_remotes_count}\n`;
152
+ }
153
+ // Display canonical URLs if available
154
+ if (impl.canonical && impl.canonical.length > 0) {
155
+ content += ` Canonical URLs (${impl.canonical.length}):\n`;
156
+ for (const canonical of impl.canonical) {
157
+ let canonicalLine = ` - ${canonical.url} [scope: ${canonical.scope}]`;
158
+ if (canonical.note) {
159
+ canonicalLine += ` - ${canonical.note}`;
160
+ }
161
+ content += canonicalLine + '\n';
162
+ }
163
+ }
127
164
  content += '\n';
128
165
  }
129
166
  if (response.pagination?.has_next) {
package/shared/tools.js CHANGED
@@ -9,6 +9,7 @@ import { searchMCPImplementations } from './tools/search-mcp-implementations.js'
9
9
  import { getDraftMCPImplementations } from './tools/get-draft-mcp-implementations.js';
10
10
  import { saveMCPImplementation } from './tools/save-mcp-implementation.js';
11
11
  import { sendMCPImplementationPostingNotification } from './tools/send-mcp-implementation-posting-notification.js';
12
+ import { findProviders } from './tools/find-providers.js';
12
13
  const ALL_TOOLS = [
13
14
  { factory: getNewsletterPosts, groups: ['newsletter'] },
14
15
  { factory: getNewsletterPost, groups: ['newsletter'] },
@@ -20,6 +21,7 @@ const ALL_TOOLS = [
20
21
  { factory: getDraftMCPImplementations, groups: ['server_queue_readonly', 'server_queue_all'] },
21
22
  { factory: saveMCPImplementation, groups: ['server_queue_all'] },
22
23
  { factory: sendMCPImplementationPostingNotification, groups: ['server_queue_all'] },
24
+ { factory: findProviders, groups: ['server_queue_readonly', 'server_queue_all'] },
23
25
  ];
24
26
  /**
25
27
  * Parse enabled tool groups from environment variable or parameter
package/shared/types.d.ts CHANGED
@@ -164,6 +164,7 @@ export interface MCPImplementation {
164
164
  updated_at?: string;
165
165
  mcp_server?: MCPServer | null;
166
166
  mcp_client?: MCPClient | null;
167
+ canonical?: CanonicalUrlParams[];
167
168
  }
168
169
  export interface MCPImplementationsResponse {
169
170
  implementations: MCPImplementation[];
@@ -199,4 +200,23 @@ export interface SaveMCPImplementationParams {
199
200
  canonical?: CanonicalUrlParams[];
200
201
  internal_notes?: string;
201
202
  }
203
+ export interface Provider {
204
+ id: number;
205
+ name: string;
206
+ slug: string;
207
+ url?: string;
208
+ implementations_count?: number;
209
+ created_at?: string;
210
+ updated_at?: string;
211
+ }
212
+ export interface ProvidersResponse {
213
+ providers: Provider[];
214
+ pagination?: {
215
+ current_page: number;
216
+ total_pages: number;
217
+ total_count: number;
218
+ has_next?: boolean;
219
+ limit?: number;
220
+ };
221
+ }
202
222
  //# sourceMappingURL=types.d.ts.map