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.
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/dist/index.js +8 -3
- package/dist/lib/alias-transport.d.ts +2 -1
- package/dist/lib/alias-transport.js +17 -4
- package/dist/lib/claude-directory-profile.d.ts +5 -0
- package/dist/lib/claude-directory-profile.js +95 -0
- package/dist/lib/version.d.ts +1 -1
- package/dist/lib/version.js +1 -1
- package/package.json +11 -4
- package/server.json +4 -5
- package/RELEASING.md +0 -115
- package/SUBMISSIONS.md +0 -109
- package/scripts/test-alias-transport.mjs +0 -368
- package/scripts/test-client-header-sanitization.mjs +0 -78
- package/scripts/test-payload-builders.mjs +0 -210
- package/scripts/test-tool-annotations.mjs +0 -68
- package/src/generated/platform-manifest.ts +0 -753
- package/src/index.ts +0 -286
- package/src/lib/alias-transport.ts +0 -233
- package/src/lib/api-client.ts +0 -2638
- package/src/lib/format.ts +0 -446
- package/src/lib/preview-token-core.ts +0 -157
- package/src/lib/preview-token.ts +0 -54
- package/src/lib/tool-annotations.ts +0 -47
- package/src/lib/tool-registry.ts +0 -538
- package/src/lib/tool-runtime.ts +0 -117
- package/src/lib/version.ts +0 -8
- package/src/tools/ask-support.ts +0 -55
- package/src/tools/audit-google-business-profile.ts +0 -57
- package/src/tools/cancel-subscription.ts +0 -50
- package/src/tools/connect-account.ts +0 -91
- package/src/tools/create-api-key.ts +0 -41
- package/src/tools/create-connect-session.ts +0 -55
- package/src/tools/create-media-drop.ts +0 -41
- package/src/tools/create-post.ts +0 -430
- package/src/tools/create-posts-batch.ts +0 -80
- package/src/tools/create-signed-upload.ts +0 -29
- package/src/tools/create-webhook.ts +0 -34
- package/src/tools/delete-api-key.ts +0 -31
- package/src/tools/delete-post.ts +0 -86
- package/src/tools/disconnect-account.ts +0 -30
- package/src/tools/dismiss-suggestion.ts +0 -22
- package/src/tools/downgrade-subscription.ts +0 -29
- package/src/tools/find-slot.ts +0 -101
- package/src/tools/generate-captions.ts +0 -77
- package/src/tools/generate-image.ts +0 -108
- package/src/tools/generate-video.ts +0 -45
- package/src/tools/get-agent-signup-info.ts +0 -47
- package/src/tools/get-credits.ts +0 -38
- package/src/tools/get-post.ts +0 -49
- package/src/tools/get-signup-session.ts +0 -100
- package/src/tools/get-subscription.ts +0 -31
- package/src/tools/get-updates.ts +0 -55
- package/src/tools/get-video-options.ts +0 -30
- package/src/tools/get-x-posting-quota.ts +0 -25
- package/src/tools/list-accounts.ts +0 -202
- package/src/tools/list-activity.ts +0 -65
- package/src/tools/list-analytics.ts +0 -447
- package/src/tools/list-brands.ts +0 -175
- package/src/tools/list-comments.ts +0 -125
- package/src/tools/list-conversations.ts +0 -119
- package/src/tools/list-google-business-media.ts +0 -33
- package/src/tools/list-google-business-reviews.ts +0 -165
- package/src/tools/list-jobs.ts +0 -91
- package/src/tools/list-media.ts +0 -69
- package/src/tools/list-oauth-clients.ts +0 -18
- package/src/tools/list-platforms.ts +0 -149
- package/src/tools/list-post-suggestions.ts +0 -69
- package/src/tools/list-posts.ts +0 -120
- package/src/tools/list-publishing-pauses.ts +0 -98
- package/src/tools/list-webhooks.ts +0 -29
- package/src/tools/list-workspace-members.ts +0 -75
- package/src/tools/manage-comment.ts +0 -142
- package/src/tools/manage-conversation.ts +0 -89
- package/src/tools/manage-google-business-media.ts +0 -147
- package/src/tools/manage-google-business-review.ts +0 -139
- package/src/tools/manage-oauth-client.ts +0 -116
- package/src/tools/manage-publishing-pause.ts +0 -62
- package/src/tools/manage-webhook.ts +0 -103
- package/src/tools/manage-workspace-member.ts +0 -163
- package/src/tools/pause-subscription.ts +0 -26
- package/src/tools/resume-subscription.ts +0 -15
- package/src/tools/run-video-function.ts +0 -32
- package/src/tools/start-signup.ts +0 -150
- package/src/tools/submit-agent-feedback.ts +0 -47
- package/src/tools/submit-product-feedback.ts +0 -56
- package/src/tools/trigger-platform-helper.ts +0 -34
- package/src/tools/update-post.ts +0 -334
- package/src/tools/upload-media.ts +0 -107
- package/src/tools/validate-post.ts +0 -42
- package/src/tools/webhook-events.ts +0 -20
- package/src/tools/whoami.ts +0 -262
- 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
|
-
}
|