posterly-mcp-server 0.50.3 → 0.50.5

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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +10 -2
  3. package/dist/index.js +8 -3
  4. package/dist/lib/alias-transport.d.ts +2 -1
  5. package/dist/lib/alias-transport.js +17 -4
  6. package/dist/lib/claude-directory-profile.d.ts +5 -0
  7. package/dist/lib/claude-directory-profile.js +95 -0
  8. package/dist/lib/version.d.ts +1 -1
  9. package/dist/lib/version.js +1 -1
  10. package/package.json +11 -4
  11. package/server.json +4 -5
  12. package/RELEASING.md +0 -115
  13. package/SUBMISSIONS.md +0 -109
  14. package/scripts/test-alias-transport.mjs +0 -368
  15. package/scripts/test-client-header-sanitization.mjs +0 -78
  16. package/scripts/test-payload-builders.mjs +0 -210
  17. package/scripts/test-tool-annotations.mjs +0 -68
  18. package/src/generated/platform-manifest.ts +0 -753
  19. package/src/index.ts +0 -286
  20. package/src/lib/alias-transport.ts +0 -233
  21. package/src/lib/api-client.ts +0 -2638
  22. package/src/lib/format.ts +0 -446
  23. package/src/lib/preview-token-core.ts +0 -157
  24. package/src/lib/preview-token.ts +0 -54
  25. package/src/lib/tool-annotations.ts +0 -47
  26. package/src/lib/tool-registry.ts +0 -538
  27. package/src/lib/tool-runtime.ts +0 -117
  28. package/src/lib/version.ts +0 -8
  29. package/src/tools/ask-support.ts +0 -55
  30. package/src/tools/audit-google-business-profile.ts +0 -57
  31. package/src/tools/cancel-subscription.ts +0 -50
  32. package/src/tools/connect-account.ts +0 -91
  33. package/src/tools/create-api-key.ts +0 -41
  34. package/src/tools/create-connect-session.ts +0 -55
  35. package/src/tools/create-media-drop.ts +0 -41
  36. package/src/tools/create-post.ts +0 -430
  37. package/src/tools/create-posts-batch.ts +0 -80
  38. package/src/tools/create-signed-upload.ts +0 -29
  39. package/src/tools/create-webhook.ts +0 -34
  40. package/src/tools/delete-api-key.ts +0 -31
  41. package/src/tools/delete-post.ts +0 -86
  42. package/src/tools/disconnect-account.ts +0 -30
  43. package/src/tools/dismiss-suggestion.ts +0 -22
  44. package/src/tools/downgrade-subscription.ts +0 -29
  45. package/src/tools/find-slot.ts +0 -101
  46. package/src/tools/generate-captions.ts +0 -77
  47. package/src/tools/generate-image.ts +0 -108
  48. package/src/tools/generate-video.ts +0 -45
  49. package/src/tools/get-agent-signup-info.ts +0 -47
  50. package/src/tools/get-credits.ts +0 -38
  51. package/src/tools/get-post.ts +0 -49
  52. package/src/tools/get-signup-session.ts +0 -100
  53. package/src/tools/get-subscription.ts +0 -31
  54. package/src/tools/get-updates.ts +0 -55
  55. package/src/tools/get-video-options.ts +0 -30
  56. package/src/tools/get-x-posting-quota.ts +0 -25
  57. package/src/tools/list-accounts.ts +0 -202
  58. package/src/tools/list-activity.ts +0 -65
  59. package/src/tools/list-analytics.ts +0 -447
  60. package/src/tools/list-brands.ts +0 -175
  61. package/src/tools/list-comments.ts +0 -125
  62. package/src/tools/list-conversations.ts +0 -119
  63. package/src/tools/list-google-business-media.ts +0 -33
  64. package/src/tools/list-google-business-reviews.ts +0 -165
  65. package/src/tools/list-jobs.ts +0 -91
  66. package/src/tools/list-media.ts +0 -69
  67. package/src/tools/list-oauth-clients.ts +0 -18
  68. package/src/tools/list-platforms.ts +0 -149
  69. package/src/tools/list-post-suggestions.ts +0 -69
  70. package/src/tools/list-posts.ts +0 -120
  71. package/src/tools/list-publishing-pauses.ts +0 -98
  72. package/src/tools/list-webhooks.ts +0 -29
  73. package/src/tools/list-workspace-members.ts +0 -75
  74. package/src/tools/manage-comment.ts +0 -142
  75. package/src/tools/manage-conversation.ts +0 -89
  76. package/src/tools/manage-google-business-media.ts +0 -147
  77. package/src/tools/manage-google-business-review.ts +0 -139
  78. package/src/tools/manage-oauth-client.ts +0 -116
  79. package/src/tools/manage-publishing-pause.ts +0 -62
  80. package/src/tools/manage-webhook.ts +0 -103
  81. package/src/tools/manage-workspace-member.ts +0 -163
  82. package/src/tools/pause-subscription.ts +0 -26
  83. package/src/tools/resume-subscription.ts +0 -15
  84. package/src/tools/run-video-function.ts +0 -32
  85. package/src/tools/start-signup.ts +0 -150
  86. package/src/tools/submit-agent-feedback.ts +0 -47
  87. package/src/tools/submit-product-feedback.ts +0 -56
  88. package/src/tools/trigger-platform-helper.ts +0 -34
  89. package/src/tools/update-post.ts +0 -334
  90. package/src/tools/upload-media.ts +0 -107
  91. package/src/tools/validate-post.ts +0 -42
  92. package/src/tools/webhook-events.ts +0 -20
  93. package/src/tools/whoami.ts +0 -262
  94. package/tsconfig.json +0 -14
@@ -1,368 +0,0 @@
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.');
@@ -1,78 +0,0 @@
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.');
@@ -1,210 +0,0 @@
1
- /**
2
- * Behavioural tests for the payload builders.
3
- *
4
- * scripts/check-mcp-schema-parity.ts compares the JSON Schema each surface
5
- * publishes, which catches a field going missing or an enum narrowing. It does
6
- * NOT catch validation written as code rather than schema, and that is exactly
7
- * how the caption bug shipped: buildCreatePostPayload threw
8
- * "caption is required" for every post without caption text while the schema
9
- * happily declared caption optional. Nothing compared behaviour, so the npm
10
- * package rejected every media-only post (Instagram and Facebook Stories carry
11
- * no caption) for as long as it did.
12
- *
13
- * These tests pin the rules the server actually applies, so a future edit that
14
- * tightens the client past the server fails here.
15
- *
16
- * buildCreatePostPayload also takes an AccountPlatformResolver (built from a
17
- * PosterlyClient by createAccountPlatformResolver) so it can resolve an
18
- * account_id's real platform for thread_posts when the caller did not pass
19
- * `platform` (there is no other way to tell an X thread from a Threads
20
- * thread). fakeClient() below stands in for the real client, and counts
21
- * calls so the "one lookup per workspace_id" batching behaviour can be
22
- * asserted directly.
23
- */
24
-
25
- import assert from 'node:assert/strict';
26
- import { buildCreatePostPayload, createAccountPlatformResolver } from '../dist/tools/create-post.js';
27
-
28
- let failures = 0;
29
- async function check(name, fn) {
30
- try {
31
- await fn();
32
- console.log(`PASS ${name}`);
33
- } catch (error) {
34
- failures += 1;
35
- console.error(`FAIL ${name}\n ${error.message}`);
36
- }
37
- }
38
-
39
- const IMAGE = 'https://assets.poster.ly/users/test/a.jpg';
40
-
41
- /**
42
- * A minimal PosterlyClient stand-in. Throws if listAccounts is called when
43
- * the test does not expect it (accounts === null), or rejects with `fail`
44
- * when a lookup-failure test needs a realistic error. Tracks call count so
45
- * "one lookup per workspace_id" can be asserted.
46
- */
47
- function fakeClient(accounts = null, { fail } = {}) {
48
- let calls = 0;
49
- return {
50
- calls: () => calls,
51
- async listAccounts() {
52
- calls += 1;
53
- if (fail) throw new Error(fail);
54
- if (accounts === null) {
55
- throw new Error('listAccounts should not have been called for this input');
56
- }
57
- return accounts;
58
- },
59
- };
60
- }
61
-
62
- // The server rule (lib/api-v1-post-create.ts) is "provide caption or media".
63
- // Media-only posts are valid and must not be rejected by the client.
64
- await check('media-only post is accepted (Instagram Story shape)', async () => {
65
- const payload = await buildCreatePostPayload({
66
- platform: 'instagram',
67
- media_url: IMAGE,
68
- post_type: 'story',
69
- instagram_settings: { post_type: 'story' },
70
- }, createAccountPlatformResolver(fakeClient()));
71
- assert.equal(typeof payload.caption, 'string', 'caption should be sent as a string');
72
- assert.equal(payload.post_type, 'story');
73
- });
74
-
75
- await check('media-only post is accepted (Facebook Story shape)', async () => {
76
- const payload = await buildCreatePostPayload({
77
- platform: 'facebook',
78
- media_url: IMAGE,
79
- post_type: 'story',
80
- platform_settings: { __type: 'facebook', post_type: 'story' },
81
- }, createAccountPlatformResolver(fakeClient()));
82
- assert.equal(typeof payload.caption, 'string');
83
- });
84
-
85
- await check('media_urls-only post is accepted', async () => {
86
- const payload = await buildCreatePostPayload(
87
- { platform: 'instagram', media_urls: [IMAGE] },
88
- createAccountPlatformResolver(fakeClient()),
89
- );
90
- assert.equal(typeof payload.caption, 'string');
91
- });
92
-
93
- await check('caption-only post is accepted', async () => {
94
- const payload = await buildCreatePostPayload(
95
- { platform: 'linkedin', caption: 'hello' },
96
- createAccountPlatformResolver(fakeClient()),
97
- );
98
- assert.equal(payload.caption, 'hello');
99
- });
100
-
101
- await check('post with neither caption nor media is rejected', async () => {
102
- await assert.rejects(
103
- () => buildCreatePostPayload({ platform: 'instagram' }, createAccountPlatformResolver(fakeClient())),
104
- /caption or media/i,
105
- 'should reject, and the message should tell the caller media is an option',
106
- );
107
- });
108
-
109
- await check('thread_posts still bypasses the caption rule', async () => {
110
- const payload = await buildCreatePostPayload({
111
- platform: 'twitter',
112
- thread_posts: ['one', 'two'],
113
- }, createAccountPlatformResolver(fakeClient()));
114
- assert.equal(payload.caption, 'one');
115
- assert.equal(payload.post_type, 'x_thread');
116
- });
117
-
118
- // Regression: thread_posts with only account_id (no platform) used to always
119
- // guess an X thread, so a Threads account_id built x_thread_tweets and the
120
- // server rejected it with "post_type x_thread requires a Twitter/X account".
121
- await check('thread_posts + account_id resolves to threads_thread for a Threads account', async () => {
122
- const payload = await buildCreatePostPayload({
123
- account_id: 'acct_threads',
124
- thread_posts: ['one', 'two'],
125
- }, createAccountPlatformResolver(fakeClient([{ id: 'acct_threads', platform: 'threads', username: 'brand' }])));
126
- assert.equal(payload.platform, 'threads');
127
- assert.equal(payload.post_type, 'threads_thread');
128
- assert.deepEqual(payload.metadata.threads_thread_posts, ['one', 'two']);
129
- assert.equal(payload.metadata.x_thread_tweets, undefined);
130
- });
131
-
132
- await check('thread_posts + account_id resolves to x_thread for a Twitter/X account', async () => {
133
- const payload = await buildCreatePostPayload({
134
- account_id: 'acct_x',
135
- thread_posts: ['one', 'two'],
136
- }, createAccountPlatformResolver(fakeClient([{ id: 'acct_x', platform: 'twitter', username: 'brand' }])));
137
- assert.equal(payload.platform, 'twitter');
138
- assert.equal(payload.post_type, 'x_thread');
139
- assert.deepEqual(payload.metadata.x_thread_tweets, ['one', 'two']);
140
- assert.equal(payload.metadata.threads_thread_posts, undefined);
141
- });
142
-
143
- await check('thread_posts + explicit platform never looks up the account (unchanged request shape)', async () => {
144
- const payload = await buildCreatePostPayload({
145
- platform: 'threads',
146
- account_id: 'acct_threads',
147
- thread_posts: ['one', 'two'],
148
- }, createAccountPlatformResolver(fakeClient(null)));
149
- assert.equal(payload.platform, 'threads');
150
- assert.equal(payload.post_type, 'threads_thread');
151
- });
152
-
153
- await check('thread_posts + account_id on a non-thread platform is rejected with a clear error', async () => {
154
- await assert.rejects(
155
- () => buildCreatePostPayload({
156
- account_id: 'acct_ig',
157
- thread_posts: ['one', 'two'],
158
- }, createAccountPlatformResolver(fakeClient([{ id: 'acct_ig', platform: 'instagram', username: 'brand' }]))),
159
- /resolved to platform "instagram"/,
160
- );
161
- });
162
-
163
- await check('thread_posts + account_id that cannot be found is rejected with a clear error', async () => {
164
- await assert.rejects(
165
- () => buildCreatePostPayload({
166
- account_id: 'does-not-exist',
167
- thread_posts: ['one', 'two'],
168
- }, createAccountPlatformResolver(fakeClient([{ id: 'acct_threads', platform: 'threads', username: 'brand' }]))),
169
- /that exists in list_accounts/,
170
- );
171
- });
172
-
173
- // The lookup itself can fail (non-2xx, missing accounts:read scope, network
174
- // error): the caller needs that real cause, not just a generic "pass
175
- // platform explicitly" that hides why resolution failed.
176
- await check('thread_posts + account_id surfaces the real lookup failure, not a generic message', async () => {
177
- await assert.rejects(
178
- () => buildCreatePostPayload({
179
- account_id: 'acct_threads',
180
- thread_posts: ['one', 'two'],
181
- }, createAccountPlatformResolver(fakeClient(null, { fail: 'Missing required scope: accounts:read' }))),
182
- /Missing required scope: accounts:read/,
183
- );
184
- });
185
-
186
- // Batching: a batch of items sharing one workspace_id must cost one
187
- // accounts lookup, not one per item. create_posts_batch builds a single
188
- // resolver and reuses it across every buildCreatePostPayload call; this
189
- // pins that the resolver itself (not the caller) is what dedupes.
190
- await check('one resolver shared across a batch makes exactly one accounts lookup per workspace_id', async () => {
191
- const client = fakeClient([
192
- { id: 'acct_threads', platform: 'threads', username: 'brand' },
193
- { id: 'acct_x', platform: 'twitter', username: 'brand2' },
194
- ]);
195
- const resolvePlatform = createAccountPlatformResolver(client);
196
- const [threadsPayload, xPayload, thirdPayload] = await Promise.all([
197
- buildCreatePostPayload({ account_id: 'acct_threads', workspace_id: 'ws_1', thread_posts: ['a', 'b'] }, resolvePlatform),
198
- buildCreatePostPayload({ account_id: 'acct_x', workspace_id: 'ws_1', thread_posts: ['c', 'd'] }, resolvePlatform),
199
- buildCreatePostPayload({ account_id: 'acct_threads', workspace_id: 'ws_1', thread_posts: ['e', 'f'] }, resolvePlatform),
200
- ]);
201
- assert.equal(threadsPayload.platform, 'threads');
202
- assert.equal(xPayload.platform, 'twitter');
203
- assert.equal(thirdPayload.platform, 'threads');
204
- assert.equal(client.calls(), 1, `expected exactly one accounts lookup for one workspace_id, got ${client.calls()}`);
205
- });
206
-
207
- if (failures > 0) {
208
- console.error(`\n${failures} payload-builder test(s) failed.`);
209
- process.exit(1);
210
- }