pulsemcp-cms-admin-mcp-server 0.9.28 → 0.10.1

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 (25) hide show
  1. package/build/local/src/index.integration-with-mock.js +20 -6
  2. package/build/shared/src/pulsemcp-admin-client/lib/create-post.js +5 -1
  3. package/build/shared/src/pulsemcp-admin-client/lib/get-posts.js +1 -1
  4. package/build/shared/src/pulsemcp-admin-client/lib/update-post.js +7 -2
  5. package/build/shared/src/pulsemcp-admin-client/pulsemcp-admin-client.integration-mock.js +8 -5
  6. package/build/shared/src/tools/draft-newsletter-post.js +8 -4
  7. package/build/shared/src/tools/get-newsletter-post.js +21 -13
  8. package/build/shared/src/tools/get-newsletter-posts.js +17 -9
  9. package/build/shared/src/tools/save-mcp-implementation.js +16 -0
  10. package/build/shared/src/tools/update-mcp-server.js +22 -2
  11. package/build/shared/src/tools/update-newsletter-post.js +5 -2
  12. package/package.json +1 -1
  13. package/shared/pulsemcp-admin-client/lib/create-post.js +5 -1
  14. package/shared/pulsemcp-admin-client/lib/get-posts.js +1 -1
  15. package/shared/pulsemcp-admin-client/lib/update-post.js +7 -2
  16. package/shared/pulsemcp-admin-client/pulsemcp-admin-client.integration-mock.js +8 -5
  17. package/shared/tools/draft-newsletter-post.js +8 -4
  18. package/shared/tools/get-newsletter-post.js +21 -13
  19. package/shared/tools/get-newsletter-posts.js +17 -9
  20. package/shared/tools/save-mcp-implementation.d.ts +8 -0
  21. package/shared/tools/save-mcp-implementation.js +16 -0
  22. package/shared/tools/update-mcp-server.d.ts +2 -2
  23. package/shared/tools/update-mcp-server.js +22 -2
  24. package/shared/tools/update-newsletter-post.js +5 -2
  25. package/shared/types.d.ts +4 -4
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * Mock data is passed via the PULSEMCP_MOCK_DATA environment variable.
9
9
  */
10
- import { readFileSync } from 'fs';
10
+ import { existsSync, readFileSync } from 'fs';
11
11
  import { dirname, join } from 'path';
12
12
  import { fileURLToPath } from 'url';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
@@ -15,11 +15,25 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
15
15
  import { createMCPServer } from 'pulsemcp-cms-admin-mcp-server-shared';
16
16
  // Import the mock client factory from the shared module
17
17
  import { createMockPulseMCPAdminClient } from '../../shared/src/pulsemcp-admin-client/pulsemcp-admin-client.integration-mock.js';
18
- // Read version from package.json
19
- const __dirname = dirname(fileURLToPath(import.meta.url));
20
- const packageJsonPath = join(__dirname, '..', 'package.json');
21
- const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
22
- const VERSION = packageJson.version;
18
+ // Read version from package.json. The integration build emits this entry into a
19
+ // nested build/ layout (build/local/src/), so walk up from the module directory
20
+ // to locate the package.json that carries the version rather than assuming a
21
+ // fixed depth.
22
+ function readVersion() {
23
+ let dir = dirname(fileURLToPath(import.meta.url));
24
+ for (let i = 0; i < 6; i++) {
25
+ const candidate = join(dir, 'package.json');
26
+ if (existsSync(candidate)) {
27
+ return JSON.parse(readFileSync(candidate, 'utf-8')).version;
28
+ }
29
+ const parent = dirname(dir);
30
+ if (parent === dir)
31
+ break;
32
+ dir = parent;
33
+ }
34
+ return '0.0.0';
35
+ }
36
+ const VERSION = readVersion();
23
37
  async function main() {
24
38
  const transport = new StdioServerTransport();
25
39
  // Parse mock data from environment variable
@@ -7,7 +7,11 @@ export async function createPost(apiKey, baseUrl, params) {
7
7
  formData.append('post[title]', params.title);
8
8
  formData.append('post[body]', params.body);
9
9
  formData.append('post[slug]', params.slug);
10
- formData.append('post[author_id]', params.author_id.toString());
10
+ // Rails permits only post[author_ids][] — send one entry per author id, in
11
+ // order (index 0 is the primary author).
12
+ params.author_ids.forEach((id) => {
13
+ formData.append('post[author_ids][]', id.toString());
14
+ });
11
15
  // Optional fields
12
16
  if (params.status)
13
17
  formData.append('post[status]', params.status);
@@ -43,7 +43,7 @@ export async function getPosts(apiKey, baseUrl, params) {
43
43
  short_description: post.short_description,
44
44
  category: post.category,
45
45
  status: post.status,
46
- author_id: post.author_id,
46
+ author_ids: post.author_ids,
47
47
  created_at: post.created_at,
48
48
  updated_at: post.updated_at,
49
49
  last_updated: post.last_updated,
@@ -10,8 +10,13 @@ export async function updatePost(apiKey, baseUrl, slug, params) {
10
10
  formData.append('post[body]', params.body);
11
11
  if (params.slug !== undefined)
12
12
  formData.append('post[slug]', params.slug);
13
- if (params.author_id !== undefined)
14
- formData.append('post[author_id]', params.author_id.toString());
13
+ // Rails permits only post[author_ids][] — send one entry per author id, in
14
+ // order (index 0 is the primary author).
15
+ if (params.author_ids !== undefined) {
16
+ params.author_ids.forEach((id) => {
17
+ formData.append('post[author_ids][]', id.toString());
18
+ });
19
+ }
15
20
  if (params.status !== undefined)
16
21
  formData.append('post[status]', params.status);
17
22
  if (params.category !== undefined)
@@ -4,15 +4,18 @@ export function createMockPulseMCPAdminClient(mockData) {
4
4
  title: 'Test Post',
5
5
  body: '<p>Test content</p>',
6
6
  slug: 'test-post',
7
- author_id: 1,
8
7
  status: 'draft',
9
8
  category: 'newsletter',
10
9
  created_at: '2024-01-01T00:00:00Z',
11
10
  updated_at: '2024-01-01T00:00:00Z',
12
- author: {
13
- id: 1,
14
- name: 'Test Author',
15
- },
11
+ // Ordered author ids from the list endpoint (no names).
12
+ author_ids: [1, 2],
13
+ // Ordered authors array (id + name) from the show endpoint; source of truth
14
+ // for reads.
15
+ authors: [
16
+ { id: 1, name: 'Test Author' },
17
+ { id: 2, name: 'Second Author' },
18
+ ],
16
19
  };
17
20
  return {
18
21
  async getPosts(params) {
@@ -166,10 +166,11 @@ Use cases:
166
166
  const { author_slug, featured_mcp_server_slugs, featured_mcp_client_slugs, ...otherParams } = validatedArgs;
167
167
  // Look up author by slug
168
168
  const author = await client.getAuthorBySlug(author_slug);
169
- // Always create as draft
169
+ // Always create as draft. author_ids is an ordered array; this tool sets
170
+ // a single primary author (length 1).
170
171
  const createParams = {
171
172
  ...otherParams,
172
- author_id: author.id,
173
+ author_ids: [author.id],
173
174
  status: 'draft',
174
175
  };
175
176
  // Convert slugs to IDs if provided
@@ -192,8 +193,11 @@ Use cases:
192
193
  content += `**Slug:** ${post.slug}\n`;
193
194
  content += `**Status:** ${post.status}\n`;
194
195
  content += `**Category:** ${post.category}\n`;
195
- if (post.author) {
196
- content += `**Author:** ${post.author.name}\n`;
196
+ // The ordered `authors` array is the source of truth for authorship.
197
+ const responseAuthors = post.authors ?? [];
198
+ if (responseAuthors.length > 0) {
199
+ const label = responseAuthors.length > 1 ? 'Authors' : 'Author';
200
+ content += `**${label}:** ${responseAuthors.map((a) => a.name).join(', ')}\n`;
197
201
  }
198
202
  content += `**Created:** ${new Date(post.created_at).toLocaleDateString()}\n\n`;
199
203
  if (post.short_description) {
@@ -12,7 +12,7 @@ export function getNewsletterPost(_server, clientFactory) {
12
12
  description: `Retrieve complete details for a specific newsletter post by its unique slug identifier. Returns formatted markdown with all post metadata and content.
13
13
 
14
14
  The response is formatted as markdown with sections for:
15
- - Post title and basic metadata (slug, status, category, author, dates)
15
+ - Post title and basic metadata (slug, status, category, author(s), dates)
16
16
  - Summary/short description
17
17
  - Full HTML content (body field) as raw HTML
18
18
  - Complete metadata including all URLs, SEO tags, and featured items
@@ -20,7 +20,7 @@ The response is formatted as markdown with sections for:
20
20
 
21
21
  All available fields from the post are included:
22
22
  - title, slug, status, category
23
- - author (id and name)
23
+ - authors (ordered list; the primary author is listed first)
24
24
  - created_at, updated_at, last_updated
25
25
  - short_title, short_description
26
26
  - body (raw HTML content)
@@ -55,28 +55,36 @@ Use cases:
55
55
  const client = clientFactory();
56
56
  try {
57
57
  const post = await client.getPost(validatedArgs.slug);
58
- // Fetch author details if we have an author_id
59
- let authorSlug;
60
- let authorName;
61
- if (post.author_id) {
58
+ // The supervisor show endpoint returns the ordered `authors` array (id +
59
+ // name), which is the source of truth for authorship.
60
+ const baseAuthors = post.authors ?? [];
61
+ // Enrich each author with its slug (the authors array only carries id +
62
+ // name). Preserve position order so the primary author renders first.
63
+ const authorLines = [];
64
+ for (const a of baseAuthors) {
65
+ let slug;
66
+ let name = a.name;
62
67
  try {
63
- const author = await client.getAuthorById(post.author_id);
64
- if (author) {
65
- authorSlug = author.slug;
66
- authorName = author.name;
68
+ const fullAuthor = await client.getAuthorById(a.id);
69
+ if (fullAuthor) {
70
+ slug = fullAuthor.slug;
71
+ if (!name)
72
+ name = fullAuthor.name;
67
73
  }
68
74
  }
69
75
  catch (error) {
70
- // If we can't fetch author, we'll just skip showing author info
76
+ // If we can't fetch the author, fall back to whatever name we have.
71
77
  console.error('Failed to fetch author details:', error);
72
78
  }
79
+ authorLines.push(slug ? `${name} (${slug}, ID: ${a.id})` : `${name} (ID: ${a.id})`);
73
80
  }
74
81
  // Format the response for MCP
75
82
  let content = `# ${post.title}\n\n`;
76
83
  content += `**Slug:** ${post.slug}\n`;
77
84
  content += `**Status:** ${post.status} | **Category:** ${post.category}\n`;
78
- if (authorSlug && authorName) {
79
- content += `**Author:** ${authorName} (${authorSlug}, ID: ${post.author_id})\n`;
85
+ if (authorLines.length > 0) {
86
+ const label = authorLines.length > 1 ? 'Authors' : 'Author';
87
+ content += `**${label}:** ${authorLines.join(', ')}\n`;
80
88
  }
81
89
  content += `**Created:** ${new Date(post.created_at).toLocaleDateString()}\n`;
82
90
  content += `**Updated:** ${new Date(post.updated_at).toLocaleDateString()}\n`;
@@ -75,17 +75,25 @@ Use cases:
75
75
  for (const [index, post] of response.posts.entries()) {
76
76
  content += `${index + 1}. **${post.title}** (${post.slug})\n`;
77
77
  content += ` Status: ${post.status} | Category: ${post.category}\n`;
78
- // Fetch author details if we have an author_id
79
- if (post.author_id) {
80
- try {
81
- const author = await client.getAuthorById(post.author_id);
82
- if (author) {
83
- content += ` Author: ${author.name} (${author.slug}, ID: ${author.id})\n`;
78
+ // The list endpoint returns ordered author ids (no names); resolve each
79
+ // to a name, preserving order so the primary author renders first.
80
+ if (post.author_ids && post.author_ids.length > 0) {
81
+ const authorInfo = [];
82
+ for (const authorId of post.author_ids) {
83
+ try {
84
+ const author = await client.getAuthorById(authorId);
85
+ if (author) {
86
+ authorInfo.push(`${author.name} (${author.slug}, ID: ${author.id})`);
87
+ }
88
+ }
89
+ catch (error) {
90
+ // Skip showing this author if we can't fetch it
91
+ console.error(`Failed to fetch author ${authorId}:`, error);
84
92
  }
85
93
  }
86
- catch (error) {
87
- // Skip showing author if we can't fetch it
88
- console.error(`Failed to fetch author ${post.author_id}:`, error);
94
+ if (authorInfo.length > 0) {
95
+ const label = authorInfo.length > 1 ? 'Authors' : 'Author';
96
+ content += ` ${label}: ${authorInfo.join(', ')}\n`;
89
97
  }
90
98
  }
91
99
  content += ` Created: ${new Date(post.created_at).toLocaleDateString()}\n`;
@@ -48,6 +48,9 @@ const PARAM_DESCRIPTIONS = {
48
48
  github_owner: 'GitHub organization or username that owns the repository.',
49
49
  github_repo: 'GitHub repository name (without owner prefix).',
50
50
  github_subfolder: 'Subfolder path within the repository, for monorepos. Omit for root-level projects.',
51
+ // Package registry fields (UPDATE only)
52
+ package_registry: "(UPDATE ONLY, SERVER ONLY) Package registry: npm, pypi, cargo, etc. To CLEAR (unlink) the server's registry package, pass an empty string for BOTH `package_registry` and `package_name`. Passing an empty string for only one of the two is rejected with a 422 error.",
53
+ package_name: '(UPDATE ONLY, SERVER ONLY) Package name on the registry (e.g., "@modelcontextprotocol/server-filesystem"). To CLEAR (unlink) the server\'s registry package, pass an empty string for BOTH `package_name` and `package_registry`. Passing an empty string for only one of the two is rejected with a 422 error.',
51
54
  // Remote endpoints
52
55
  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.',
53
56
  // Canonical URLs
@@ -90,6 +93,9 @@ const SaveMCPImplementationSchema = z.object({
90
93
  github_owner: z.string().optional().describe(PARAM_DESCRIPTIONS.github_owner),
91
94
  github_repo: z.string().optional().describe(PARAM_DESCRIPTIONS.github_repo),
92
95
  github_subfolder: z.string().optional().describe(PARAM_DESCRIPTIONS.github_subfolder),
96
+ // Package registry fields (UPDATE only)
97
+ package_registry: z.string().optional().describe(PARAM_DESCRIPTIONS.package_registry),
98
+ package_name: z.string().optional().describe(PARAM_DESCRIPTIONS.package_name),
93
99
  // Remote endpoints
94
100
  remote: z
95
101
  .array(z.object({
@@ -208,6 +214,7 @@ Important notes:
208
214
  - \`classification\` and \`implementation_language\` only apply to servers
209
215
  - \`provider_name\` reuses existing provider if it matches a provider's slug
210
216
  - Setting mcp_server_id or mcp_client_id to null will unlink the association (UPDATE only)
217
+ - Registry package (UPDATE only): pass an empty string for BOTH \`package_registry\` and \`package_name\` to CLEAR (unlink) the link; passing only one as empty is rejected with a 422 error; omitting both leaves the link unchanged
211
218
  - Remote endpoints are for MCP servers only and configure how they can be accessed
212
219
  - Canonical URLs help identify the authoritative source for the implementation
213
220
  - After creating/updating, use \`get_mcp_server\` to verify the full state including remotes and canonical URLs`,
@@ -299,6 +306,15 @@ Important notes:
299
306
  type: 'string',
300
307
  description: PARAM_DESCRIPTIONS.github_subfolder,
301
308
  },
309
+ // Package registry fields (UPDATE only)
310
+ package_registry: {
311
+ type: 'string',
312
+ description: PARAM_DESCRIPTIONS.package_registry,
313
+ },
314
+ package_name: {
315
+ type: 'string',
316
+ description: PARAM_DESCRIPTIONS.package_name,
317
+ },
302
318
  // Remote endpoints
303
319
  remote: {
304
320
  type: 'array',
@@ -13,8 +13,8 @@ const PARAM_DESCRIPTIONS = {
13
13
  provider_slug: 'URL slug for provider (auto-generated from name if omitted)',
14
14
  provider_url: 'Website URL for provider',
15
15
  source_code: 'GitHub repository information',
16
- package_registry: 'Package registry: npm, pypi, cargo, etc.',
17
- package_name: 'Package name on the registry (e.g., "@modelcontextprotocol/server-filesystem")',
16
+ package_registry: "Package registry: npm, pypi, cargo, etc. To CLEAR (unlink) the server's registry package, pass an empty string for BOTH `package_registry` and `package_name`. Passing an empty string for only one of the two is rejected with a 422 error.",
17
+ package_name: 'Package name on the registry (e.g., "@modelcontextprotocol/server-filesystem"). To CLEAR (unlink) the server\'s registry package, pass an empty string for BOTH `package_name` and `package_registry`. Passing an empty string for only one of the two is rejected with a 422 error.',
18
18
  recommended: 'Mark this server as recommended by PulseMCP',
19
19
  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)',
20
20
  created_on_override: 'Override the automatically derived created date (ISO date string, e.g., "2025-01-15")',
@@ -176,6 +176,26 @@ To update an existing remote, include its ID:
176
176
  }
177
177
  \`\`\`
178
178
 
179
+ ## Updating / Clearing the Registry Package Link
180
+ Set the link by providing both fields:
181
+ \`\`\`json
182
+ {
183
+ "implementation_id": 456,
184
+ "package_registry": "npm",
185
+ "package_name": "@modelcontextprotocol/server-filesystem"
186
+ }
187
+ \`\`\`
188
+
189
+ To **clear** (unlink) the registry package, pass an empty string for **both** fields:
190
+ \`\`\`json
191
+ {
192
+ "implementation_id": 456,
193
+ "package_registry": "",
194
+ "package_name": ""
195
+ }
196
+ \`\`\`
197
+ Passing an empty string for only one of the two is rejected with a 422 error ("Package registry and package name must be provided together"); omitting both leaves the link unchanged.
198
+
179
199
  ## Linking/Creating Provider
180
200
  Link existing provider by ID:
181
201
  \`\`\`json
@@ -187,8 +187,11 @@ Use cases:
187
187
  content += `**Slug:** ${post.slug}\n`;
188
188
  content += `**Status:** ${post.status}\n`;
189
189
  content += `**Category:** ${post.category}\n`;
190
- if (post.author) {
191
- content += `**Author:** ${post.author.name}\n`;
190
+ // The ordered `authors` array is the source of truth for authorship.
191
+ const responseAuthors = post.authors ?? [];
192
+ if (responseAuthors.length > 0) {
193
+ const label = responseAuthors.length > 1 ? 'Authors' : 'Author';
194
+ content += `**${label}:** ${responseAuthors.map((a) => a.name).join(', ')}\n`;
192
195
  }
193
196
  content += `**Updated:** ${new Date(post.updated_at).toLocaleDateString()}\n\n`;
194
197
  // Show what was updated
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulsemcp-cms-admin-mcp-server",
3
- "version": "0.9.28",
3
+ "version": "0.10.1",
4
4
  "description": "Local implementation of PulseMCP CMS Admin MCP server",
5
5
  "mcpName": "com.pulsemcp/pulsemcp-cms-admin",
6
6
  "main": "build/index.js",
@@ -7,7 +7,11 @@ export async function createPost(apiKey, baseUrl, params) {
7
7
  formData.append('post[title]', params.title);
8
8
  formData.append('post[body]', params.body);
9
9
  formData.append('post[slug]', params.slug);
10
- formData.append('post[author_id]', params.author_id.toString());
10
+ // Rails permits only post[author_ids][] — send one entry per author id, in
11
+ // order (index 0 is the primary author).
12
+ params.author_ids.forEach((id) => {
13
+ formData.append('post[author_ids][]', id.toString());
14
+ });
11
15
  // Optional fields
12
16
  if (params.status)
13
17
  formData.append('post[status]', params.status);
@@ -43,7 +43,7 @@ export async function getPosts(apiKey, baseUrl, params) {
43
43
  short_description: post.short_description,
44
44
  category: post.category,
45
45
  status: post.status,
46
- author_id: post.author_id,
46
+ author_ids: post.author_ids,
47
47
  created_at: post.created_at,
48
48
  updated_at: post.updated_at,
49
49
  last_updated: post.last_updated,
@@ -10,8 +10,13 @@ export async function updatePost(apiKey, baseUrl, slug, params) {
10
10
  formData.append('post[body]', params.body);
11
11
  if (params.slug !== undefined)
12
12
  formData.append('post[slug]', params.slug);
13
- if (params.author_id !== undefined)
14
- formData.append('post[author_id]', params.author_id.toString());
13
+ // Rails permits only post[author_ids][] — send one entry per author id, in
14
+ // order (index 0 is the primary author).
15
+ if (params.author_ids !== undefined) {
16
+ params.author_ids.forEach((id) => {
17
+ formData.append('post[author_ids][]', id.toString());
18
+ });
19
+ }
15
20
  if (params.status !== undefined)
16
21
  formData.append('post[status]', params.status);
17
22
  if (params.category !== undefined)
@@ -4,15 +4,18 @@ export function createMockPulseMCPAdminClient(mockData) {
4
4
  title: 'Test Post',
5
5
  body: '<p>Test content</p>',
6
6
  slug: 'test-post',
7
- author_id: 1,
8
7
  status: 'draft',
9
8
  category: 'newsletter',
10
9
  created_at: '2024-01-01T00:00:00Z',
11
10
  updated_at: '2024-01-01T00:00:00Z',
12
- author: {
13
- id: 1,
14
- name: 'Test Author',
15
- },
11
+ // Ordered author ids from the list endpoint (no names).
12
+ author_ids: [1, 2],
13
+ // Ordered authors array (id + name) from the show endpoint; source of truth
14
+ // for reads.
15
+ authors: [
16
+ { id: 1, name: 'Test Author' },
17
+ { id: 2, name: 'Second Author' },
18
+ ],
16
19
  };
17
20
  return {
18
21
  async getPosts(params) {
@@ -166,10 +166,11 @@ Use cases:
166
166
  const { author_slug, featured_mcp_server_slugs, featured_mcp_client_slugs, ...otherParams } = validatedArgs;
167
167
  // Look up author by slug
168
168
  const author = await client.getAuthorBySlug(author_slug);
169
- // Always create as draft
169
+ // Always create as draft. author_ids is an ordered array; this tool sets
170
+ // a single primary author (length 1).
170
171
  const createParams = {
171
172
  ...otherParams,
172
- author_id: author.id,
173
+ author_ids: [author.id],
173
174
  status: 'draft',
174
175
  };
175
176
  // Convert slugs to IDs if provided
@@ -192,8 +193,11 @@ Use cases:
192
193
  content += `**Slug:** ${post.slug}\n`;
193
194
  content += `**Status:** ${post.status}\n`;
194
195
  content += `**Category:** ${post.category}\n`;
195
- if (post.author) {
196
- content += `**Author:** ${post.author.name}\n`;
196
+ // The ordered `authors` array is the source of truth for authorship.
197
+ const responseAuthors = post.authors ?? [];
198
+ if (responseAuthors.length > 0) {
199
+ const label = responseAuthors.length > 1 ? 'Authors' : 'Author';
200
+ content += `**${label}:** ${responseAuthors.map((a) => a.name).join(', ')}\n`;
197
201
  }
198
202
  content += `**Created:** ${new Date(post.created_at).toLocaleDateString()}\n\n`;
199
203
  if (post.short_description) {
@@ -12,7 +12,7 @@ export function getNewsletterPost(_server, clientFactory) {
12
12
  description: `Retrieve complete details for a specific newsletter post by its unique slug identifier. Returns formatted markdown with all post metadata and content.
13
13
 
14
14
  The response is formatted as markdown with sections for:
15
- - Post title and basic metadata (slug, status, category, author, dates)
15
+ - Post title and basic metadata (slug, status, category, author(s), dates)
16
16
  - Summary/short description
17
17
  - Full HTML content (body field) as raw HTML
18
18
  - Complete metadata including all URLs, SEO tags, and featured items
@@ -20,7 +20,7 @@ The response is formatted as markdown with sections for:
20
20
 
21
21
  All available fields from the post are included:
22
22
  - title, slug, status, category
23
- - author (id and name)
23
+ - authors (ordered list; the primary author is listed first)
24
24
  - created_at, updated_at, last_updated
25
25
  - short_title, short_description
26
26
  - body (raw HTML content)
@@ -55,28 +55,36 @@ Use cases:
55
55
  const client = clientFactory();
56
56
  try {
57
57
  const post = await client.getPost(validatedArgs.slug);
58
- // Fetch author details if we have an author_id
59
- let authorSlug;
60
- let authorName;
61
- if (post.author_id) {
58
+ // The supervisor show endpoint returns the ordered `authors` array (id +
59
+ // name), which is the source of truth for authorship.
60
+ const baseAuthors = post.authors ?? [];
61
+ // Enrich each author with its slug (the authors array only carries id +
62
+ // name). Preserve position order so the primary author renders first.
63
+ const authorLines = [];
64
+ for (const a of baseAuthors) {
65
+ let slug;
66
+ let name = a.name;
62
67
  try {
63
- const author = await client.getAuthorById(post.author_id);
64
- if (author) {
65
- authorSlug = author.slug;
66
- authorName = author.name;
68
+ const fullAuthor = await client.getAuthorById(a.id);
69
+ if (fullAuthor) {
70
+ slug = fullAuthor.slug;
71
+ if (!name)
72
+ name = fullAuthor.name;
67
73
  }
68
74
  }
69
75
  catch (error) {
70
- // If we can't fetch author, we'll just skip showing author info
76
+ // If we can't fetch the author, fall back to whatever name we have.
71
77
  console.error('Failed to fetch author details:', error);
72
78
  }
79
+ authorLines.push(slug ? `${name} (${slug}, ID: ${a.id})` : `${name} (ID: ${a.id})`);
73
80
  }
74
81
  // Format the response for MCP
75
82
  let content = `# ${post.title}\n\n`;
76
83
  content += `**Slug:** ${post.slug}\n`;
77
84
  content += `**Status:** ${post.status} | **Category:** ${post.category}\n`;
78
- if (authorSlug && authorName) {
79
- content += `**Author:** ${authorName} (${authorSlug}, ID: ${post.author_id})\n`;
85
+ if (authorLines.length > 0) {
86
+ const label = authorLines.length > 1 ? 'Authors' : 'Author';
87
+ content += `**${label}:** ${authorLines.join(', ')}\n`;
80
88
  }
81
89
  content += `**Created:** ${new Date(post.created_at).toLocaleDateString()}\n`;
82
90
  content += `**Updated:** ${new Date(post.updated_at).toLocaleDateString()}\n`;
@@ -75,17 +75,25 @@ Use cases:
75
75
  for (const [index, post] of response.posts.entries()) {
76
76
  content += `${index + 1}. **${post.title}** (${post.slug})\n`;
77
77
  content += ` Status: ${post.status} | Category: ${post.category}\n`;
78
- // Fetch author details if we have an author_id
79
- if (post.author_id) {
80
- try {
81
- const author = await client.getAuthorById(post.author_id);
82
- if (author) {
83
- content += ` Author: ${author.name} (${author.slug}, ID: ${author.id})\n`;
78
+ // The list endpoint returns ordered author ids (no names); resolve each
79
+ // to a name, preserving order so the primary author renders first.
80
+ if (post.author_ids && post.author_ids.length > 0) {
81
+ const authorInfo = [];
82
+ for (const authorId of post.author_ids) {
83
+ try {
84
+ const author = await client.getAuthorById(authorId);
85
+ if (author) {
86
+ authorInfo.push(`${author.name} (${author.slug}, ID: ${author.id})`);
87
+ }
88
+ }
89
+ catch (error) {
90
+ // Skip showing this author if we can't fetch it
91
+ console.error(`Failed to fetch author ${authorId}:`, error);
84
92
  }
85
93
  }
86
- catch (error) {
87
- // Skip showing author if we can't fetch it
88
- console.error(`Failed to fetch author ${post.author_id}:`, error);
94
+ if (authorInfo.length > 0) {
95
+ const label = authorInfo.length > 1 ? 'Authors' : 'Author';
96
+ content += ` ${label}: ${authorInfo.join(', ')}\n`;
89
97
  }
90
98
  }
91
99
  content += ` Created: ${new Date(post.created_at).toLocaleDateString()}\n`;
@@ -91,6 +91,14 @@ export declare function saveMCPImplementation(_server: Server, clientFactory: Cl
91
91
  type: string;
92
92
  description: "Subfolder path within the repository, for monorepos. Omit for root-level projects.";
93
93
  };
94
+ package_registry: {
95
+ type: string;
96
+ description: "(UPDATE ONLY, SERVER ONLY) Package registry: npm, pypi, cargo, etc. To CLEAR (unlink) the server's registry package, pass an empty string for BOTH `package_registry` and `package_name`. Passing an empty string for only one of the two is rejected with a 422 error.";
97
+ };
98
+ package_name: {
99
+ type: string;
100
+ description: "(UPDATE ONLY, SERVER ONLY) Package name on the registry (e.g., \"@modelcontextprotocol/server-filesystem\"). To CLEAR (unlink) the server's registry package, pass an empty string for BOTH `package_name` and `package_registry`. Passing an empty string for only one of the two is rejected with a 422 error.";
101
+ };
94
102
  remote: {
95
103
  type: string;
96
104
  items: {
@@ -48,6 +48,9 @@ const PARAM_DESCRIPTIONS = {
48
48
  github_owner: 'GitHub organization or username that owns the repository.',
49
49
  github_repo: 'GitHub repository name (without owner prefix).',
50
50
  github_subfolder: 'Subfolder path within the repository, for monorepos. Omit for root-level projects.',
51
+ // Package registry fields (UPDATE only)
52
+ package_registry: "(UPDATE ONLY, SERVER ONLY) Package registry: npm, pypi, cargo, etc. To CLEAR (unlink) the server's registry package, pass an empty string for BOTH `package_registry` and `package_name`. Passing an empty string for only one of the two is rejected with a 422 error.",
53
+ package_name: '(UPDATE ONLY, SERVER ONLY) Package name on the registry (e.g., "@modelcontextprotocol/server-filesystem"). To CLEAR (unlink) the server\'s registry package, pass an empty string for BOTH `package_name` and `package_registry`. Passing an empty string for only one of the two is rejected with a 422 error.',
51
54
  // Remote endpoints
52
55
  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.',
53
56
  // Canonical URLs
@@ -90,6 +93,9 @@ const SaveMCPImplementationSchema = z.object({
90
93
  github_owner: z.string().optional().describe(PARAM_DESCRIPTIONS.github_owner),
91
94
  github_repo: z.string().optional().describe(PARAM_DESCRIPTIONS.github_repo),
92
95
  github_subfolder: z.string().optional().describe(PARAM_DESCRIPTIONS.github_subfolder),
96
+ // Package registry fields (UPDATE only)
97
+ package_registry: z.string().optional().describe(PARAM_DESCRIPTIONS.package_registry),
98
+ package_name: z.string().optional().describe(PARAM_DESCRIPTIONS.package_name),
93
99
  // Remote endpoints
94
100
  remote: z
95
101
  .array(z.object({
@@ -208,6 +214,7 @@ Important notes:
208
214
  - \`classification\` and \`implementation_language\` only apply to servers
209
215
  - \`provider_name\` reuses existing provider if it matches a provider's slug
210
216
  - Setting mcp_server_id or mcp_client_id to null will unlink the association (UPDATE only)
217
+ - Registry package (UPDATE only): pass an empty string for BOTH \`package_registry\` and \`package_name\` to CLEAR (unlink) the link; passing only one as empty is rejected with a 422 error; omitting both leaves the link unchanged
211
218
  - Remote endpoints are for MCP servers only and configure how they can be accessed
212
219
  - Canonical URLs help identify the authoritative source for the implementation
213
220
  - After creating/updating, use \`get_mcp_server\` to verify the full state including remotes and canonical URLs`,
@@ -299,6 +306,15 @@ Important notes:
299
306
  type: 'string',
300
307
  description: PARAM_DESCRIPTIONS.github_subfolder,
301
308
  },
309
+ // Package registry fields (UPDATE only)
310
+ package_registry: {
311
+ type: 'string',
312
+ description: PARAM_DESCRIPTIONS.package_registry,
313
+ },
314
+ package_name: {
315
+ type: 'string',
316
+ description: PARAM_DESCRIPTIONS.package_name,
317
+ },
302
318
  // Remote endpoints
303
319
  remote: {
304
320
  type: 'array',
@@ -78,11 +78,11 @@ export declare function updateMCPServer(_server: Server, clientFactory: ClientFa
78
78
  };
79
79
  package_registry: {
80
80
  type: string;
81
- description: "Package registry: npm, pypi, cargo, etc.";
81
+ description: "Package registry: npm, pypi, cargo, etc. To CLEAR (unlink) the server's registry package, pass an empty string for BOTH `package_registry` and `package_name`. Passing an empty string for only one of the two is rejected with a 422 error.";
82
82
  };
83
83
  package_name: {
84
84
  type: string;
85
- description: "Package name on the registry (e.g., \"@modelcontextprotocol/server-filesystem\")";
85
+ description: "Package name on the registry (e.g., \"@modelcontextprotocol/server-filesystem\"). To CLEAR (unlink) the server's registry package, pass an empty string for BOTH `package_name` and `package_registry`. Passing an empty string for only one of the two is rejected with a 422 error.";
86
86
  };
87
87
  recommended: {
88
88
  type: string;
@@ -13,8 +13,8 @@ const PARAM_DESCRIPTIONS = {
13
13
  provider_slug: 'URL slug for provider (auto-generated from name if omitted)',
14
14
  provider_url: 'Website URL for provider',
15
15
  source_code: 'GitHub repository information',
16
- package_registry: 'Package registry: npm, pypi, cargo, etc.',
17
- package_name: 'Package name on the registry (e.g., "@modelcontextprotocol/server-filesystem")',
16
+ package_registry: "Package registry: npm, pypi, cargo, etc. To CLEAR (unlink) the server's registry package, pass an empty string for BOTH `package_registry` and `package_name`. Passing an empty string for only one of the two is rejected with a 422 error.",
17
+ package_name: 'Package name on the registry (e.g., "@modelcontextprotocol/server-filesystem"). To CLEAR (unlink) the server\'s registry package, pass an empty string for BOTH `package_name` and `package_registry`. Passing an empty string for only one of the two is rejected with a 422 error.',
18
18
  recommended: 'Mark this server as recommended by PulseMCP',
19
19
  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)',
20
20
  created_on_override: 'Override the automatically derived created date (ISO date string, e.g., "2025-01-15")',
@@ -176,6 +176,26 @@ To update an existing remote, include its ID:
176
176
  }
177
177
  \`\`\`
178
178
 
179
+ ## Updating / Clearing the Registry Package Link
180
+ Set the link by providing both fields:
181
+ \`\`\`json
182
+ {
183
+ "implementation_id": 456,
184
+ "package_registry": "npm",
185
+ "package_name": "@modelcontextprotocol/server-filesystem"
186
+ }
187
+ \`\`\`
188
+
189
+ To **clear** (unlink) the registry package, pass an empty string for **both** fields:
190
+ \`\`\`json
191
+ {
192
+ "implementation_id": 456,
193
+ "package_registry": "",
194
+ "package_name": ""
195
+ }
196
+ \`\`\`
197
+ Passing an empty string for only one of the two is rejected with a 422 error ("Package registry and package name must be provided together"); omitting both leaves the link unchanged.
198
+
179
199
  ## Linking/Creating Provider
180
200
  Link existing provider by ID:
181
201
  \`\`\`json
@@ -187,8 +187,11 @@ Use cases:
187
187
  content += `**Slug:** ${post.slug}\n`;
188
188
  content += `**Status:** ${post.status}\n`;
189
189
  content += `**Category:** ${post.category}\n`;
190
- if (post.author) {
191
- content += `**Author:** ${post.author.name}\n`;
190
+ // The ordered `authors` array is the source of truth for authorship.
191
+ const responseAuthors = post.authors ?? [];
192
+ if (responseAuthors.length > 0) {
193
+ const label = responseAuthors.length > 1 ? 'Authors' : 'Author';
194
+ content += `**${label}:** ${responseAuthors.map((a) => a.name).join(', ')}\n`;
192
195
  }
193
196
  content += `**Updated:** ${new Date(post.updated_at).toLocaleDateString()}\n\n`;
194
197
  // Show what was updated
package/shared/types.d.ts CHANGED
@@ -3,7 +3,7 @@ export interface Post {
3
3
  title: string;
4
4
  body?: string;
5
5
  slug: string;
6
- author_id: number;
6
+ author_ids?: number[];
7
7
  status: 'draft' | 'live' | string;
8
8
  category: 'newsletter' | 'other' | string;
9
9
  image_url?: string;
@@ -19,10 +19,10 @@ export interface Post {
19
19
  featured_mcp_client_ids?: number[];
20
20
  created_at: string;
21
21
  updated_at: string;
22
- author?: {
22
+ authors?: Array<{
23
23
  id: number;
24
24
  name: string;
25
- };
25
+ }>;
26
26
  }
27
27
  export interface PostsResponse {
28
28
  posts: Post[];
@@ -36,7 +36,7 @@ export interface CreatePostParams {
36
36
  title: string;
37
37
  body: string;
38
38
  slug: string;
39
- author_id: number;
39
+ author_ids: number[];
40
40
  status?: 'draft' | 'live';
41
41
  category?: 'newsletter' | 'other';
42
42
  image_url?: string;