posterly-mcp-server 0.44.0 → 0.45.0

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 (155) hide show
  1. package/README.md +310 -111
  2. package/dist/index.js +190 -894
  3. package/dist/lib/alias-transport.d.ts +84 -0
  4. package/dist/lib/alias-transport.js +193 -0
  5. package/dist/lib/api-client.d.ts +18 -0
  6. package/dist/lib/api-client.js +55 -0
  7. package/dist/lib/preview-token-core.d.ts +62 -0
  8. package/dist/lib/preview-token-core.js +131 -0
  9. package/dist/lib/preview-token.d.ts +23 -0
  10. package/dist/lib/preview-token.js +44 -0
  11. package/dist/lib/tool-registry.d.ts +246 -0
  12. package/dist/lib/tool-registry.js +394 -0
  13. package/dist/lib/tool-runtime.d.ts +68 -0
  14. package/dist/lib/tool-runtime.js +85 -0
  15. package/dist/lib/version.d.ts +1 -1
  16. package/dist/lib/version.js +1 -1
  17. package/dist/tools/audit-google-business-profile.js +1 -1
  18. package/dist/tools/connect-account.d.ts +4 -4
  19. package/dist/tools/connect-account.js +2 -2
  20. package/dist/tools/create-connect-session.d.ts +2 -2
  21. package/dist/tools/create-connect-session.js +2 -2
  22. package/dist/tools/create-post.d.ts +6 -6
  23. package/dist/tools/create-post.js +3 -3
  24. package/dist/tools/create-posts-batch.d.ts +4 -4
  25. package/dist/tools/create-webhook.d.ts +2 -2
  26. package/dist/tools/delete-post.d.ts +34 -10
  27. package/dist/tools/delete-post.js +52 -16
  28. package/dist/tools/generate-captions.d.ts +2 -2
  29. package/dist/tools/generate-image.d.ts +4 -4
  30. package/dist/tools/generate-image.js +2 -2
  31. package/dist/tools/generate-video.d.ts +6 -6
  32. package/dist/tools/generate-video.js +1 -1
  33. package/dist/tools/get-agent-signup-info.js +2 -2
  34. package/dist/tools/get-post.d.ts +16 -1
  35. package/dist/tools/get-post.js +35 -19
  36. package/dist/tools/list-accounts.d.ts +49 -3
  37. package/dist/tools/list-accounts.js +148 -33
  38. package/dist/tools/list-analytics.d.ts +111 -0
  39. package/dist/tools/list-analytics.js +393 -0
  40. package/dist/tools/list-brands.d.ts +38 -3
  41. package/dist/tools/list-brands.js +138 -20
  42. package/dist/tools/list-comments.d.ts +29 -9
  43. package/dist/tools/list-comments.js +75 -34
  44. package/dist/tools/list-conversations.d.ts +32 -9
  45. package/dist/tools/list-conversations.js +63 -27
  46. package/dist/tools/list-google-business-media.js +1 -1
  47. package/dist/tools/list-google-business-reviews.d.ts +32 -9
  48. package/dist/tools/list-google-business-reviews.js +77 -52
  49. package/dist/tools/list-jobs.d.ts +60 -0
  50. package/dist/tools/list-jobs.js +81 -0
  51. package/dist/tools/list-platforms.d.ts +62 -3
  52. package/dist/tools/list-platforms.js +118 -9
  53. package/dist/tools/list-posts.d.ts +2 -2
  54. package/dist/tools/manage-comment.d.ts +102 -0
  55. package/dist/tools/manage-comment.js +125 -0
  56. package/dist/tools/manage-conversation.d.ts +79 -0
  57. package/dist/tools/manage-conversation.js +75 -0
  58. package/dist/tools/manage-google-business-media.d.ts +103 -0
  59. package/dist/tools/manage-google-business-media.js +126 -0
  60. package/dist/tools/manage-google-business-review.d.ts +146 -0
  61. package/dist/tools/manage-google-business-review.js +115 -0
  62. package/dist/tools/manage-oauth-client.d.ts +121 -0
  63. package/dist/tools/manage-oauth-client.js +94 -0
  64. package/dist/tools/manage-webhook.d.ts +99 -0
  65. package/dist/tools/manage-webhook.js +87 -0
  66. package/dist/tools/start-signup.d.ts +2 -2
  67. package/dist/tools/submit-agent-feedback.d.ts +4 -4
  68. package/dist/tools/trigger-platform-helper.d.ts +2 -2
  69. package/dist/tools/update-post.d.ts +62 -11
  70. package/dist/tools/update-post.js +79 -26
  71. package/dist/tools/upload-media.d.ts +32 -9
  72. package/dist/tools/upload-media.js +54 -18
  73. package/dist/tools/validate-post.d.ts +4 -4
  74. package/dist/tools/whoami.d.ts +15 -2
  75. package/dist/tools/whoami.js +221 -25
  76. package/package.json +5 -3
  77. package/scripts/test-alias-transport.mjs +368 -0
  78. package/scripts/test-client-header-sanitization.mjs +78 -0
  79. package/scripts/test-tool-annotations.mjs +38 -17
  80. package/server.json +9 -2
  81. package/src/index.ts +227 -1443
  82. package/src/lib/alias-transport.ts +233 -0
  83. package/src/lib/api-client.ts +65 -0
  84. package/src/lib/preview-token-core.ts +157 -0
  85. package/src/lib/preview-token.ts +54 -0
  86. package/src/lib/tool-registry.ts +538 -0
  87. package/src/lib/tool-runtime.ts +117 -0
  88. package/src/lib/version.ts +1 -1
  89. package/src/tools/audit-google-business-profile.ts +1 -1
  90. package/src/tools/connect-account.ts +2 -2
  91. package/src/tools/create-connect-session.ts +2 -2
  92. package/src/tools/create-post.ts +3 -3
  93. package/src/tools/delete-post.ts +60 -19
  94. package/src/tools/generate-image.ts +2 -2
  95. package/src/tools/generate-video.ts +1 -1
  96. package/src/tools/get-agent-signup-info.ts +2 -2
  97. package/src/tools/get-post.ts +35 -21
  98. package/src/tools/list-accounts.ts +187 -39
  99. package/src/tools/list-analytics.ts +444 -0
  100. package/src/tools/list-brands.ts +162 -26
  101. package/src/tools/list-comments.ts +86 -43
  102. package/src/tools/list-conversations.ts +81 -39
  103. package/src/tools/list-google-business-media.ts +1 -1
  104. package/src/tools/list-google-business-reviews.ts +93 -64
  105. package/src/tools/list-jobs.ts +91 -0
  106. package/src/tools/list-platforms.ts +135 -11
  107. package/src/tools/manage-comment.ts +142 -0
  108. package/src/tools/manage-conversation.ts +89 -0
  109. package/src/tools/manage-google-business-media.ts +147 -0
  110. package/src/tools/manage-google-business-review.ts +139 -0
  111. package/src/tools/manage-oauth-client.ts +116 -0
  112. package/src/tools/manage-webhook.ts +103 -0
  113. package/src/tools/update-post.ts +106 -39
  114. package/src/tools/upload-media.ts +76 -28
  115. package/src/tools/whoami.ts +253 -30
  116. package/tool-annotations.json +76 -149
  117. package/tool-registry.json +2085 -0
  118. package/src/tools/add-google-business-media.ts +0 -50
  119. package/src/tools/create-oauth-client.ts +0 -36
  120. package/src/tools/delete-comment.ts +0 -29
  121. package/src/tools/delete-google-business-media.ts +0 -30
  122. package/src/tools/delete-google-business-review-reply.ts +0 -30
  123. package/src/tools/delete-oauth-client.ts +0 -23
  124. package/src/tools/delete-post-group.ts +0 -17
  125. package/src/tools/delete-webhook.ts +0 -17
  126. package/src/tools/get-account-analytics.ts +0 -171
  127. package/src/tools/get-brand-profile.ts +0 -86
  128. package/src/tools/get-brand.ts +0 -34
  129. package/src/tools/get-comment.ts +0 -47
  130. package/src/tools/get-connect-link.ts +0 -61
  131. package/src/tools/get-connect-session.ts +0 -63
  132. package/src/tools/get-conversation.ts +0 -44
  133. package/src/tools/get-google-business-review-link.ts +0 -30
  134. package/src/tools/get-image-job.ts +0 -36
  135. package/src/tools/get-learned-voice.ts +0 -31
  136. package/src/tools/get-mcp-status.ts +0 -212
  137. package/src/tools/get-performance-profile.ts +0 -47
  138. package/src/tools/get-platform-schema.ts +0 -56
  139. package/src/tools/get-post-analytics.ts +0 -243
  140. package/src/tools/get-post-insights.ts +0 -60
  141. package/src/tools/get-post-missing.ts +0 -16
  142. package/src/tools/get-video-job.ts +0 -36
  143. package/src/tools/list-brand-accounts.ts +0 -36
  144. package/src/tools/reply-google-business-review.ts +0 -32
  145. package/src/tools/reply-to-comment.ts +0 -30
  146. package/src/tools/send-message.ts +0 -28
  147. package/src/tools/suggest-google-business-review-reply.ts +0 -44
  148. package/src/tools/sync-inbox.ts +0 -28
  149. package/src/tools/test-webhook.ts +0 -18
  150. package/src/tools/update-comment.ts +0 -31
  151. package/src/tools/update-oauth-client.ts +0 -38
  152. package/src/tools/update-post-release-id.ts +0 -25
  153. package/src/tools/update-post-status.ts +0 -31
  154. package/src/tools/update-webhook.ts +0 -36
  155. package/src/tools/upload-media-from-url.ts +0 -33
@@ -0,0 +1,368 @@
1
+ // Tests the stdio AliasTransport (src/lib/alias-transport.ts) over an
2
+ // in-memory transport pair with a real McpServer and Client, using a small
3
+ // fake registry that has aliases (the shipped registry has none yet):
4
+ // - aliases never appear in tools/list
5
+ // - an alias call is rewritten onto its target with translated args
6
+ // (rename + inject, inject wins) before the SDK validates it
7
+ // - handlers read the called name / legacyConfirm via extra.requestId
8
+ // - the response keeps content[0] untouched and gets exactly one appended
9
+ // rename note plus _meta["posterly/deprecation"]
10
+ // - canonical calls are untouched, including when run concurrently with an
11
+ // alias call (legacyConfirm never leaks across calls)
12
+ // - an alias call that fails per-mode validation returns a plain error
13
+ // (plus the note) without reaching the handler
14
+ // - per-request entries are cleaned up after the response, and after a
15
+ // notifications/cancelled (the SDK sends no response for those)
16
+ // - a visible set filters ONLY tools/list: hidden tools stay callable,
17
+ // including in the real built package with POSTERLY_TOOLSETS=core
18
+ // - an alias to a param-driven tool is pinned to the old tool's mode, and a
19
+ // legacy validator reproduces the old tool's exact schema error text
20
+ // Run: npm run test:alias-transport (builds first)
21
+ import assert from 'node:assert/strict';
22
+ import { z } from 'zod';
23
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
24
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
25
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
26
+ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
27
+ import { dirname, resolve } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { AliasTransport } from '../dist/lib/alias-transport.js';
30
+
31
+ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
32
+
33
+ const SUNSET = '2027-01-12';
34
+
35
+ const registry = {
36
+ version: 1,
37
+ designRules: 'off',
38
+ budgets: { maxTotal: 70, maxCore: 30 },
39
+ sunset: SUNSET,
40
+ toolsets: { core: { label: 'Core', description: 'Core tools.' } },
41
+ tools: {
42
+ manage_thing: {
43
+ set: 'core',
44
+ label: 'Manage a thing',
45
+ selector: 'action',
46
+ actions: {
47
+ rename: {
48
+ label: 'Rename a thing',
49
+ destructive: false,
50
+ idempotent: true,
51
+ openWorld: false,
52
+ confirm: 'confirm',
53
+ params: {
54
+ thing_id: { type: 'string', required: true },
55
+ title: { type: 'string', required: true },
56
+ },
57
+ },
58
+ delete: { label: 'Delete a thing', destructive: true, idempotent: true, openWorld: false, confirm: 'preview' },
59
+ },
60
+ },
61
+ slow_thing: { set: 'core', label: 'Slow thing', readOnly: true, destructive: false, idempotent: true, openWorld: false, confirm: 'none' },
62
+ list_things: {
63
+ set: 'core',
64
+ label: 'Things',
65
+ selector: 'view',
66
+ defaultMode: 'all',
67
+ views: {
68
+ all: { label: 'All things' },
69
+ one: { label: 'One thing', params: { thing_id: { type: 'string', required: true }, limit: { type: 'integer', min: 1, max: 50, default: 10 } } },
70
+ },
71
+ },
72
+ },
73
+ aliases: {
74
+ old_slow: {
75
+ target: 'slow_thing',
76
+ added: '2026-10-01',
77
+ sunset: SUNSET,
78
+ },
79
+ delete_thing: {
80
+ target: 'manage_thing',
81
+ inject: { action: 'delete' },
82
+ rename: { id: 'thing_id' },
83
+ legacyConfirm: true,
84
+ added: '2026-10-01',
85
+ sunset: SUNSET,
86
+ },
87
+ rename_thing: {
88
+ target: 'manage_thing',
89
+ inject: { action: 'rename' },
90
+ added: '2026-10-01',
91
+ sunset: SUNSET,
92
+ },
93
+ get_thing: {
94
+ target: 'list_things',
95
+ inject: { view: 'one' },
96
+ added: '2026-10-01',
97
+ sunset: SUNSET,
98
+ },
99
+ },
100
+ };
101
+
102
+ const server = new McpServer({ name: 'alias-transport-test', version: '0.0.0' });
103
+ const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
104
+ const transport = new AliasTransport(serverSide, registry);
105
+
106
+ const seen = [];
107
+ server.tool(
108
+ 'manage_thing',
109
+ 'Rename or delete a thing.',
110
+ {
111
+ action: z.enum(['rename', 'delete']),
112
+ thing_id: z.string(),
113
+ title: z.string().optional(),
114
+ confirm: z.boolean().optional(),
115
+ },
116
+ async (input, extra) => {
117
+ const info = transport.getCallInfo(extra.requestId);
118
+ seen.push({ tool: 'manage_thing', input, calledName: info?.calledName ?? null, legacyConfirm: info?.legacyConfirm ?? false });
119
+ // Give a concurrent call the chance to interleave.
120
+ await new Promise((resolve) => setTimeout(resolve, 5));
121
+ return { content: [{ type: 'text', text: `did ${input.action} on ${input.thing_id}` }] };
122
+ },
123
+ );
124
+ server.tool(
125
+ 'list_things',
126
+ 'List things.',
127
+ { view: z.enum(['all', 'one']).optional(), thing_id: z.string().optional(), limit: z.number().optional() },
128
+ async (input, extra) => {
129
+ const info = transport.getCallInfo(extra.requestId);
130
+ seen.push({ tool: 'list_things', input, calledName: info?.calledName ?? null, legacyConfirm: info?.legacyConfirm ?? false });
131
+ return { content: [{ type: 'text', text: `view ${input.view} ${input.thing_id ?? ''} ${input.limit ?? ''}`.trim() }] };
132
+ },
133
+ );
134
+
135
+ let slowRequestId;
136
+ server.tool('slow_thing', 'Takes a while.', {}, async (_input, extra) => {
137
+ slowRequestId = extra.requestId;
138
+ await new Promise((resolve) => {
139
+ const timer = setTimeout(resolve, 500);
140
+ extra.signal.addEventListener('abort', () => {
141
+ clearTimeout(timer);
142
+ resolve();
143
+ });
144
+ });
145
+ return { content: [{ type: 'text', text: 'slow done' }] };
146
+ });
147
+
148
+ const client = new Client({ name: 'alias-transport-test-client', version: '0.0.0' });
149
+ await server.connect(transport);
150
+ await client.connect(clientSide);
151
+
152
+ try {
153
+ // 1. Aliases never appear in tools/list.
154
+ const { tools } = await client.listTools();
155
+ assert.deepEqual(tools.map((tool) => tool.name).sort(), ['list_things', 'manage_thing', 'slow_thing']);
156
+
157
+ // 2. Alias call: rename + inject (inject wins over a caller-supplied action).
158
+ seen.length = 0;
159
+ const aliased = await client.callTool({ name: 'delete_thing', arguments: { id: 't_1', action: 'rename', confirm: true } });
160
+ assert.equal(seen.length, 1);
161
+ assert.deepEqual(seen[0].input, { action: 'delete', thing_id: 't_1', confirm: true });
162
+ assert.equal(seen[0].calledName, 'delete_thing', 'handler reads the called (alias) name via extra.requestId');
163
+ assert.equal(seen[0].legacyConfirm, true);
164
+ assert.equal(aliased.content.length, 2, 'exactly one appended deprecation item');
165
+ assert.deepEqual(aliased.content[0], { type: 'text', text: 'did delete on t_1' }, 'content[0] is untouched');
166
+ assert.equal(
167
+ aliased.content[1].text,
168
+ `Renamed: delete_thing is now manage_thing with action=delete. The old name may stop working after ${SUNSET}.`,
169
+ );
170
+ assert.deepEqual(aliased._meta?.['posterly/deprecation'], {
171
+ old: 'delete_thing',
172
+ new: 'manage_thing',
173
+ action: 'delete',
174
+ sunset: SUNSET,
175
+ });
176
+
177
+ // 3. Canonical call: untouched, no note, no _meta, no alias info.
178
+ seen.length = 0;
179
+ const canonical = await client.callTool({ name: 'manage_thing', arguments: { action: 'rename', thing_id: 't_2', title: 'New' } });
180
+ assert.equal(canonical.content.length, 1);
181
+ assert.equal(canonical._meta, undefined);
182
+ assert.equal(seen[0].calledName, null);
183
+ assert.equal(seen[0].legacyConfirm, false);
184
+
185
+ // 4. Concurrent alias + canonical calls: legacyConfirm never leaks.
186
+ seen.length = 0;
187
+ const [a, b] = await Promise.all([
188
+ client.callTool({ name: 'delete_thing', arguments: { id: 't_3', confirm: true } }),
189
+ client.callTool({ name: 'manage_thing', arguments: { action: 'delete', thing_id: 't_4' } }),
190
+ ]);
191
+ const byThing = Object.fromEntries(seen.map((entry) => [entry.input.thing_id, entry]));
192
+ assert.equal(byThing.t_3.legacyConfirm, true);
193
+ assert.equal(byThing.t_3.calledName, 'delete_thing');
194
+ assert.equal(byThing.t_4.legacyConfirm, false);
195
+ assert.equal(byThing.t_4.calledName, null);
196
+ assert.equal(a.content.length, 2);
197
+ assert.equal(b.content.length, 1);
198
+
199
+ // 5. Per-mode validation (registry params) normalises before the SDK sees it:
200
+ // the default limit is applied.
201
+ seen.length = 0;
202
+ const viewed = await client.callTool({ name: 'get_thing', arguments: { thing_id: 't_5' } });
203
+ assert.deepEqual(seen[0].input, { view: 'one', thing_id: 't_5', limit: 10 });
204
+ assert.equal(viewed.content[0].text, 'view one t_5 10');
205
+ assert.equal(viewed._meta?.['posterly/deprecation']?.action, 'one');
206
+
207
+ // 6. A validation failure is a plain error plus the note; the handler never runs.
208
+ seen.length = 0;
209
+ const invalid = await client.callTool({ name: 'rename_thing', arguments: { thing_id: 't_6' } });
210
+ assert.equal(seen.length, 0, 'handler must not run when validation fails');
211
+ assert.equal(invalid.isError, true);
212
+ assert.equal(invalid.content[0].text, 'title is required with action=rename.');
213
+ assert.equal(invalid.content.length, 2);
214
+ const extra = await client.callTool({ name: 'rename_thing', arguments: { thing_id: 't_6', title: 'x', bogus: 1 } });
215
+ assert.equal(extra.isError, true);
216
+ assert.equal(extra.content[0].text, 'bogus is not allowed with action=rename.');
217
+
218
+ // 7. Entries are cleaned up after each response.
219
+ for (let id = 0; id < 50; id += 1) {
220
+ assert.equal(transport.getCallInfo(id), undefined, `request ${id} still tracked after its response`);
221
+ }
222
+
223
+ // 8. A cancelled alias call (no response is ever sent) is still cleaned up.
224
+ const controller = new AbortController();
225
+ const pendingSlow = client.callTool({ name: 'old_slow', arguments: {} }, undefined, { signal: controller.signal });
226
+ await new Promise((resolve) => setTimeout(resolve, 30));
227
+ assert.notEqual(slowRequestId, undefined, 'slow handler started');
228
+ assert.equal(transport.getCallInfo(slowRequestId)?.calledName, 'old_slow', 'tracked while in flight');
229
+ controller.abort();
230
+ await assert.rejects(pendingSlow);
231
+ await new Promise((resolve) => setTimeout(resolve, 30));
232
+ assert.equal(transport.getCallInfo(slowRequestId), undefined, 'notifications/cancelled clears the entry');
233
+ } finally {
234
+ await client.close();
235
+ await server.close();
236
+ }
237
+
238
+ // 9. A visible set filters tools/list only; hidden tools stay callable.
239
+ {
240
+ const filteredServer = new McpServer({ name: 'alias-transport-filter-test', version: '0.0.0' });
241
+ const [filteredClientSide, filteredServerSide] = InMemoryTransport.createLinkedPair();
242
+ const filteredTransport = new AliasTransport(filteredServerSide, registry, new Set(['list_things']));
243
+ filteredServer.tool('list_things', 'List things.', {}, async () => ({ content: [{ type: 'text', text: 'listed' }] }));
244
+ filteredServer.tool('manage_thing', 'Manage a thing.', { action: z.string(), thing_id: z.string() }, async (input) => ({
245
+ content: [{ type: 'text', text: `managed ${input.thing_id}` }],
246
+ }));
247
+ const filteredClient = new Client({ name: 'alias-transport-filter-client', version: '0.0.0' });
248
+ await filteredServer.connect(filteredTransport);
249
+ await filteredClient.connect(filteredClientSide);
250
+ try {
251
+ const { tools: listed } = await filteredClient.listTools();
252
+ assert.deepEqual(listed.map((tool) => tool.name), ['list_things'], 'hidden tool is not listed');
253
+ const hiddenCall = await filteredClient.callTool({ name: 'manage_thing', arguments: { action: 'delete', thing_id: 't_9' } });
254
+ assert.equal(hiddenCall.isError, undefined);
255
+ assert.equal(hiddenCall.content[0].text, 'managed t_9', 'hidden tool is still callable');
256
+ const aliasToHidden = await filteredClient.callTool({ name: 'delete_thing', arguments: { id: 't_10' } });
257
+ assert.equal(aliasToHidden.content[0].text, 'managed t_10', 'an alias to a hidden tool still works');
258
+ } finally {
259
+ await filteredClient.close();
260
+ await filteredServer.close();
261
+ }
262
+ }
263
+
264
+ // 10. The real built package: POSTERLY_TOOLSETS=core hides get_subscription
265
+ // (billing) from tools/list, but calling it still reaches the tool. With
266
+ // no API key the tool answers its own "not installed" error before any
267
+ // network request, which proves it ran (an unregistered tool would give
268
+ // the SDK's "Tool get_subscription not found").
269
+ {
270
+ const stdioClient = new Client({ name: 'alias-transport-toolsets-client', version: '0.0.0' });
271
+ const stdioTransport = new StdioClientTransport({
272
+ command: process.execPath,
273
+ args: [resolve(packageRoot, 'dist/index.js')],
274
+ env: { POSTERLY_TOOLSETS: 'core' },
275
+ stderr: 'ignore',
276
+ });
277
+ await stdioClient.connect(stdioTransport);
278
+ try {
279
+ const { tools: coreTools } = await stdioClient.listTools();
280
+ const names = coreTools.map((tool) => tool.name);
281
+ assert(!names.includes('get_subscription'), 'billing tool hidden from tools/list under core');
282
+ assert(names.includes('create_post'), 'core tool listed');
283
+ assert(names.includes('start_signup'), 'signup tools are always listed on stdio');
284
+ const hidden = await stdioClient.callTool({ name: 'get_subscription', arguments: {} });
285
+ assert.equal(hidden.isError, true);
286
+ assert.match(hidden.content[0].text, /posterly access is not installed yet/, 'the hidden tool itself answered');
287
+ } finally {
288
+ await stdioClient.close();
289
+ }
290
+ }
291
+
292
+ // 11. Param-driven targets and legacy schemas (mcp 0.45.0): an alias with
293
+ // `mode` is validated in the old tool's mode (a missing id is rejected,
294
+ // never silently widened into a list), and a legacy validator reproduces
295
+ // the old tool's exact error text before any translation.
296
+ {
297
+ const modeRegistry = {
298
+ ...registry,
299
+ tools: {
300
+ list_notes: {
301
+ set: 'core',
302
+ label: 'Notes',
303
+ selector: 'params',
304
+ defaultMode: 'list',
305
+ views: {
306
+ list: { label: 'List notes', params: { limit: { type: 'integer', min: 1, max: 50 } } },
307
+ note: { label: 'One note', when: ['note_id'], params: { note_id: { type: 'string', required: true } } },
308
+ },
309
+ },
310
+ },
311
+ aliases: {
312
+ get_note: { target: 'list_notes', mode: 'note', added: '2026-10-01', sunset: SUNSET },
313
+ },
314
+ };
315
+ const legacyValidator = (aliasName, args) => {
316
+ if (aliasName !== 'get_note') return null;
317
+ if (!args || typeof args.note_id !== 'string') return { ok: false, message: 'MCP error -32602: old get_note schema error' };
318
+ return { ok: true, args: { note_id: args.note_id } };
319
+ };
320
+ const modeServer = new McpServer({ name: 'alias-transport-mode-test', version: '0.0.0' });
321
+ const [modeClientSide, modeServerSide] = InMemoryTransport.createLinkedPair();
322
+ const modeTransport = new AliasTransport(modeServerSide, modeRegistry, null, legacyValidator);
323
+ const noteCalls = [];
324
+ modeServer.tool('list_notes', 'Notes.', { note_id: z.string().optional(), limit: z.number().optional() }, async (input) => {
325
+ noteCalls.push(input);
326
+ return { content: [{ type: 'text', text: input.note_id ? `note ${input.note_id}` : 'all notes' }] };
327
+ });
328
+ const modeClient = new Client({ name: 'alias-transport-mode-client', version: '0.0.0' });
329
+ await modeServer.connect(modeTransport);
330
+ await modeClient.connect(modeClientSide);
331
+ try {
332
+ const legacyError = await modeClient.callTool({ name: 'get_note', arguments: {} });
333
+ assert.equal(legacyError.isError, true);
334
+ assert.equal(legacyError.content[0].text, 'MCP error -32602: old get_note schema error', 'the old schema error text is kept');
335
+ assert.equal(legacyError.content.length, 2, 'the rename note is still appended');
336
+ assert.equal(noteCalls.length, 0, 'the handler never ran');
337
+ const one = await modeClient.callTool({ name: 'get_note', arguments: { note_id: 'n_1', extra: 'dropped' } });
338
+ assert.equal(one.content[0].text, 'note n_1');
339
+ assert.deepEqual(noteCalls, [{ note_id: 'n_1' }], 'the legacy-parsed args (unknown keys dropped) reach the handler');
340
+ } finally {
341
+ await modeClient.close();
342
+ await modeServer.close();
343
+ }
344
+
345
+ // Without a legacy validator, the pinned mode still rejects a missing id.
346
+ const pinServer = new McpServer({ name: 'alias-transport-pin-test', version: '0.0.0' });
347
+ const [pinClientSide, pinServerSide] = InMemoryTransport.createLinkedPair();
348
+ const pinTransport = new AliasTransport(pinServerSide, modeRegistry);
349
+ let pinHandlerRan = false;
350
+ pinServer.tool('list_notes', 'Notes.', { note_id: z.string().optional() }, async () => {
351
+ pinHandlerRan = true;
352
+ return { content: [{ type: 'text', text: 'all notes' }] };
353
+ });
354
+ const pinClient = new Client({ name: 'alias-transport-pin-client', version: '0.0.0' });
355
+ await pinServer.connect(pinTransport);
356
+ await pinClient.connect(pinClientSide);
357
+ try {
358
+ const pinned = await pinClient.callTool({ name: 'get_note', arguments: {} });
359
+ assert.equal(pinned.isError, true);
360
+ assert.equal(pinned.content[0].text, 'note_id is required.');
361
+ assert.equal(pinHandlerRan, false, 'get_note without note_id never becomes a list call');
362
+ } finally {
363
+ await pinClient.close();
364
+ await pinServer.close();
365
+ }
366
+ }
367
+
368
+ console.log('OK alias transport: hidden aliases, translated args, appended note, no leaks, tool sets filter tools/list only, pinned modes, legacy schemas.');
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Node's fetch (undici) THROWS when a header value contains anything outside
3
+ * printable Latin-1 with no control characters, not just rejects it. The
4
+ * X-Posterly-Client header carries the connected MCP client's self-reported
5
+ * name straight through, so a client identifying itself as e.g. "通义灵码" or
6
+ * with an en/em dash in its name would otherwise break every single tool
7
+ * call this package makes. sanitizeClientHeaderValue strips anything outside
8
+ * printable ASCII before the header is ever built.
9
+ *
10
+ * These tests prove the raw value would have thrown building a real fetch
11
+ * Headers object, and that the sanitized value never does.
12
+ */
13
+
14
+ import assert from 'node:assert/strict';
15
+ import { sanitizeClientHeaderValue } from '../dist/lib/api-client.js';
16
+
17
+ let failures = 0;
18
+ function check(name, fn) {
19
+ try {
20
+ fn();
21
+ console.log(`PASS ${name}`);
22
+ } catch (error) {
23
+ failures += 1;
24
+ console.error(`FAIL ${name}\n ${error.message}`);
25
+ }
26
+ }
27
+
28
+ function throwsBuildingHeaders(value) {
29
+ try {
30
+ new Headers({ 'X-Posterly-Client': value });
31
+ return false;
32
+ } catch {
33
+ return true;
34
+ }
35
+ }
36
+
37
+ check('plain ASCII name passes through unchanged', () => {
38
+ assert.equal(sanitizeClientHeaderValue('claude-ai'), 'claude-ai');
39
+ });
40
+
41
+ check('non-ASCII client name is stripped to printable ASCII', () => {
42
+ const raw = '通义灵码';
43
+ assert.equal(throwsBuildingHeaders(raw), true, 'the raw value should be unsafe to use as a header value directly');
44
+ assert.equal(sanitizeClientHeaderValue(raw), '', 'nothing printable-ASCII remains');
45
+ });
46
+
47
+ check('mixed name with an en/em dash keeps the ASCII letters and drops the dash', () => {
48
+ const raw = 'Claude\u2014Code';
49
+ assert.equal(throwsBuildingHeaders(raw), true, 'an em dash is outside Latin-1 header-safe range and is unsafe raw');
50
+ const cleaned = sanitizeClientHeaderValue(raw);
51
+ assert.equal(cleaned, 'ClaudeCode');
52
+ assert.equal(throwsBuildingHeaders(cleaned), false, 'the cleaned value must be safe to use as a header value');
53
+ });
54
+
55
+ check('newline in a client name is stripped, not passed through', () => {
56
+ const raw = 'chatgpt\ndesktop';
57
+ assert.equal(throwsBuildingHeaders(raw), true, 'a raw newline is unsafe as a header value');
58
+ const cleaned = sanitizeClientHeaderValue(raw);
59
+ assert.equal(cleaned, 'chatgptdesktop');
60
+ assert.equal(throwsBuildingHeaders(cleaned), false);
61
+ });
62
+
63
+ check('a name that is entirely non-ASCII sanitizes to empty (caller omits the header)', () => {
64
+ assert.equal(sanitizeClientHeaderValue('通义灵码'), '');
65
+ assert.equal(sanitizeClientHeaderValue(' '), '', 'whitespace-only also sanitizes to empty after trim');
66
+ });
67
+
68
+ check('over-length values are truncated to 200 characters', () => {
69
+ const cleaned = sanitizeClientHeaderValue('a'.repeat(250));
70
+ assert.equal(cleaned.length, 200);
71
+ assert.equal(throwsBuildingHeaders(cleaned), false);
72
+ });
73
+
74
+ if (failures > 0) {
75
+ console.error(`\n${failures} client-header-sanitization test(s) failed.`);
76
+ process.exit(1);
77
+ }
78
+ console.log('\nOK X-Posterly-Client is always safe to send as a header value.');
@@ -7,24 +7,29 @@ import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
7
7
 
8
8
  const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
9
9
  const annotations = JSON.parse(readFileSync(resolve(root, 'tool-annotations.json'), 'utf8'));
10
+ const registry = JSON.parse(readFileSync(resolve(root, 'tool-registry.json'), 'utf8'));
10
11
  const expectedToolCount = annotations.knownTools.length;
12
+ const aliases = new Set(Object.keys(registry.aliases));
11
13
 
12
- const transport = new StdioClientTransport({
13
- command: process.execPath,
14
- args: ['dist/index.js'],
15
- });
16
- const client = new Client({ name: 'posterly-annotation-test', version: '1.0.0' });
17
-
18
- try {
19
- await client.connect(transport);
20
- const { tools } = await client.listTools();
14
+ async function listTools(env) {
15
+ const transport = new StdioClientTransport({
16
+ command: process.execPath,
17
+ args: ['dist/index.js'],
18
+ env: { ...process.env, ...env },
19
+ });
20
+ const client = new Client({ name: 'posterly-annotation-test', version: '1.0.0' });
21
+ try {
22
+ await client.connect(transport);
23
+ const { tools } = await client.listTools();
24
+ return tools;
25
+ } finally {
26
+ await client.close();
27
+ }
28
+ }
21
29
 
22
- assert.equal(
23
- tools.length,
24
- expectedToolCount,
25
- `stdio tool count ${tools.length} must match tool-annotations.json knownTools (${expectedToolCount})`,
26
- );
30
+ function checkAnnotations(tools, label) {
27
31
  for (const tool of tools) {
32
+ assert(!aliases.has(tool.name), `${label}: tools/list must never list the alias ${tool.name}`);
28
33
  assert(tool.annotations, `${tool.name} is missing annotations`);
29
34
  assert(tool.annotations.title, `${tool.name} is missing an annotation title`);
30
35
  assert.equal(typeof tool.annotations.readOnlyHint, 'boolean', `${tool.name} readOnlyHint`);
@@ -36,8 +41,24 @@ try {
36
41
  `${tool.name} cannot be both read-only and destructive`,
37
42
  );
38
43
  }
44
+ }
45
+
46
+ // Default: every tool set.
47
+ const all = await listTools({ POSTERLY_TOOLSETS: '' });
48
+ assert.equal(
49
+ all.length,
50
+ expectedToolCount,
51
+ `stdio tool count ${all.length} must match tool-annotations.json knownTools (${expectedToolCount})`,
52
+ );
53
+ checkAnnotations(all, 'all sets');
39
54
 
40
- console.log(`OK stdio MCP emits complete annotations for ${tools.length} tools.`);
41
- } finally {
42
- await client.close();
55
+ // POSTERLY_TOOLSETS=core lists the core set plus the always-on signup tools.
56
+ const core = await listTools({ POSTERLY_TOOLSETS: 'core' });
57
+ const expectedCore = Object.entries(registry.tools).filter(([, tool]) => tool.set === 'core' || tool.set === 'signup').length;
58
+ assert.equal(core.length, expectedCore, `POSTERLY_TOOLSETS=core should list ${expectedCore} tools, got ${core.length}`);
59
+ for (const tool of core) {
60
+ assert(['core', 'signup'].includes(registry.tools[tool.name]?.set), `${tool.name} is not in the core or signup set`);
43
61
  }
62
+ checkAnnotations(core, 'core set');
63
+
64
+ console.log(`OK stdio MCP emits complete annotations for ${all.length} tools (all sets) and ${core.length} tools (core + signup), and never lists aliases.`);
package/server.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.awpthorp/posterly",
4
4
  "title": "posterly",
5
5
  "description": "Validate, schedule, publish, and analyze social content across 18 platforms with posterly.",
6
- "version": "0.44.0",
6
+ "version": "0.45.0",
7
7
  "websiteUrl": "https://www.poster.ly/mcp",
8
8
  "repository": {
9
9
  "url": "https://github.com/awpthorp/posterly",
@@ -14,7 +14,7 @@
14
14
  {
15
15
  "registryType": "npm",
16
16
  "identifier": "posterly-mcp-server",
17
- "version": "0.44.0",
17
+ "version": "0.45.0",
18
18
  "transport": {
19
19
  "type": "stdio"
20
20
  },
@@ -25,6 +25,13 @@
25
25
  "format": "string",
26
26
  "isRequired": false,
27
27
  "isSecret": true
28
+ },
29
+ {
30
+ "name": "POSTERLY_TOOLSETS",
31
+ "description": "Optional. Comma-separated tool sets to list: core, creative, inbox, google_business, admin, billing (signup tools are always listed). Default: every set. Every tool stays callable by name.",
32
+ "format": "string",
33
+ "isRequired": false,
34
+ "isSecret": false
28
35
  }
29
36
  ]
30
37
  }