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,84 @@
1
+ /**
2
+ * Transport wrapper that keeps renamed MCP tools working on the stdio package
3
+ * without registering the old names with McpServer (so they never appear in
4
+ * tools/list).
5
+ *
6
+ * Incoming: a tools/call whose name is an alias is rewritten BEFORE McpServer
7
+ * sees it: params.name becomes the target tool and params.arguments become
8
+ * the translated arguments, so the SDK validates them against the canonical
9
+ * schema. The request id is remembered with the alias details.
10
+ *
11
+ * Handlers read that per-request info through getCallInfo(extra.requestId):
12
+ * the called name (for the X-Posterly-Tool telemetry header) and whether the
13
+ * alias may still confirm in one step. Nothing is stored in the arguments.
14
+ *
15
+ * Outgoing: the response for that id gets the rename note APPENDED as a final
16
+ * text item (content[0] is never touched) plus _meta["posterly/deprecation"],
17
+ * and the remembered entry is deleted. A notifications/cancelled for that id
18
+ * also deletes it (the SDK sends no response for a cancelled request).
19
+ *
20
+ * Legacy input schemas: when a legacyValidator is given, an alias call's
21
+ * arguments are first checked against the OLD tool's exact input schema, so a
22
+ * call the old tool rejected gets the same SDK validation error text (and a
23
+ * call it accepted gets the same parsed arguments, defaults included) before
24
+ * translation. The npm package passes the old zod schemas here.
25
+ *
26
+ * Tool sets: every tool stays registered (so a call to any tool always
27
+ * works); when a visible set is given, only the outgoing tools/list result is
28
+ * filtered to it.
29
+ *
30
+ * Only the public Transport interface is used; no SDK private fields.
31
+ */
32
+ import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js';
33
+ import type { JSONRPCMessage, MessageExtraInfo, RequestId } from '@modelcontextprotocol/sdk/types.js';
34
+ import { type ResolvedAlias, type ToolRegistry } from './tool-registry.js';
35
+ export interface AliasCallInfo {
36
+ /** The name the client called (the alias). */
37
+ calledName: string;
38
+ /** The canonical tool the call was routed to. */
39
+ target: string;
40
+ /** The alias may confirm with confirm:true alone until its sunset date. */
41
+ legacyConfirm: boolean;
42
+ alias: ResolvedAlias;
43
+ }
44
+ /**
45
+ * Checks an alias call's raw arguments against the old tool's input schema.
46
+ * Return null when the alias has no legacy schema (no check), the parsed
47
+ * arguments when they are valid, or the exact error text the old tool gave.
48
+ */
49
+ export type LegacyAliasValidator = (aliasName: string, args: unknown) => {
50
+ ok: true;
51
+ args: Record<string, unknown>;
52
+ } | {
53
+ ok: false;
54
+ message: string;
55
+ } | null;
56
+ export declare class AliasTransport implements Transport {
57
+ private readonly inner;
58
+ private readonly registry;
59
+ private readonly visibleTools;
60
+ private readonly legacyValidator;
61
+ onclose?: () => void;
62
+ onerror?: (error: Error) => void;
63
+ onmessage?: <T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void;
64
+ private readonly calls;
65
+ /** In-flight tools/list request ids, so only their responses are filtered. */
66
+ private readonly listRequests;
67
+ /**
68
+ * @param visibleTools names to list in tools/list, or null to list every
69
+ * registered tool unchanged (the default: every set).
70
+ */
71
+ constructor(inner: Transport, registry: ToolRegistry, visibleTools?: Set<string> | null, legacyValidator?: LegacyAliasValidator | null);
72
+ get sessionId(): string | undefined;
73
+ setProtocolVersion(version: string): void;
74
+ start(): Promise<void>;
75
+ close(): Promise<void>;
76
+ /** Alias details for an in-flight tools/call, or undefined for a canonical call. */
77
+ getCallInfo(requestId: RequestId | undefined): AliasCallInfo | undefined;
78
+ send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void>;
79
+ private handleIncoming;
80
+ /** Answers an alias call directly with a plain isError result; send() appends the rename note. */
81
+ private respondWithError;
82
+ private filterToolsList;
83
+ private decorateResponse;
84
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Transport wrapper that keeps renamed MCP tools working on the stdio package
3
+ * without registering the old names with McpServer (so they never appear in
4
+ * tools/list).
5
+ *
6
+ * Incoming: a tools/call whose name is an alias is rewritten BEFORE McpServer
7
+ * sees it: params.name becomes the target tool and params.arguments become
8
+ * the translated arguments, so the SDK validates them against the canonical
9
+ * schema. The request id is remembered with the alias details.
10
+ *
11
+ * Handlers read that per-request info through getCallInfo(extra.requestId):
12
+ * the called name (for the X-Posterly-Tool telemetry header) and whether the
13
+ * alias may still confirm in one step. Nothing is stored in the arguments.
14
+ *
15
+ * Outgoing: the response for that id gets the rename note APPENDED as a final
16
+ * text item (content[0] is never touched) plus _meta["posterly/deprecation"],
17
+ * and the remembered entry is deleted. A notifications/cancelled for that id
18
+ * also deletes it (the SDK sends no response for a cancelled request).
19
+ *
20
+ * Legacy input schemas: when a legacyValidator is given, an alias call's
21
+ * arguments are first checked against the OLD tool's exact input schema, so a
22
+ * call the old tool rejected gets the same SDK validation error text (and a
23
+ * call it accepted gets the same parsed arguments, defaults included) before
24
+ * translation. The npm package passes the old zod schemas here.
25
+ *
26
+ * Tool sets: every tool stays registered (so a call to any tool always
27
+ * works); when a visible set is given, only the outgoing tools/list result is
28
+ * filtered to it.
29
+ *
30
+ * Only the public Transport interface is used; no SDK private fields.
31
+ */
32
+ import { DEPRECATION_META_KEY, deprecationNote, resolveAlias, translateArgs, validateMode, } from './tool-registry.js';
33
+ function requestKey(id) {
34
+ return `${typeof id}:${String(id)}`;
35
+ }
36
+ function isObject(value) {
37
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
38
+ }
39
+ export class AliasTransport {
40
+ inner;
41
+ registry;
42
+ visibleTools;
43
+ legacyValidator;
44
+ onclose;
45
+ onerror;
46
+ onmessage;
47
+ calls = new Map();
48
+ /** In-flight tools/list request ids, so only their responses are filtered. */
49
+ listRequests = new Set();
50
+ /**
51
+ * @param visibleTools names to list in tools/list, or null to list every
52
+ * registered tool unchanged (the default: every set).
53
+ */
54
+ constructor(inner, registry, visibleTools = null, legacyValidator = null) {
55
+ this.inner = inner;
56
+ this.registry = registry;
57
+ this.visibleTools = visibleTools;
58
+ this.legacyValidator = legacyValidator;
59
+ inner.onmessage = (message, extra) => this.handleIncoming(message, extra);
60
+ inner.onclose = () => this.onclose?.();
61
+ inner.onerror = (error) => this.onerror?.(error);
62
+ }
63
+ get sessionId() {
64
+ return this.inner.sessionId;
65
+ }
66
+ setProtocolVersion(version) {
67
+ this.inner.setProtocolVersion?.(version);
68
+ }
69
+ start() {
70
+ return this.inner.start();
71
+ }
72
+ close() {
73
+ this.calls.clear();
74
+ this.listRequests.clear();
75
+ return this.inner.close();
76
+ }
77
+ /** Alias details for an in-flight tools/call, or undefined for a canonical call. */
78
+ getCallInfo(requestId) {
79
+ if (requestId === undefined || requestId === null)
80
+ return undefined;
81
+ return this.calls.get(requestKey(requestId));
82
+ }
83
+ async send(message, options) {
84
+ const outgoing = this.filterToolsList(this.decorateResponse(message));
85
+ return this.inner.send(outgoing, options);
86
+ }
87
+ handleIncoming(message, extra) {
88
+ const request = message;
89
+ if (request.method === 'notifications/cancelled') {
90
+ const cancelled = isObject(request.params) ? request.params.requestId : undefined;
91
+ if (typeof cancelled === 'string' || typeof cancelled === 'number') {
92
+ this.calls.delete(requestKey(cancelled));
93
+ this.listRequests.delete(requestKey(cancelled));
94
+ }
95
+ this.onmessage?.(message, extra);
96
+ return;
97
+ }
98
+ if (request.method === 'tools/list' && this.visibleTools && request.id !== undefined && request.id !== null) {
99
+ this.listRequests.add(requestKey(request.id));
100
+ this.onmessage?.(message, extra);
101
+ return;
102
+ }
103
+ if (request.method !== 'tools/call' || request.id === undefined || request.id === null || !isObject(request.params)) {
104
+ this.onmessage?.(message, extra);
105
+ return;
106
+ }
107
+ const name = request.params.name;
108
+ const alias = typeof name === 'string' ? resolveAlias(this.registry, name) : null;
109
+ if (!alias) {
110
+ this.onmessage?.(message, extra);
111
+ return;
112
+ }
113
+ const info = {
114
+ calledName: alias.name,
115
+ target: alias.target,
116
+ legacyConfirm: alias.legacyConfirm === true,
117
+ alias,
118
+ };
119
+ this.calls.set(requestKey(request.id), info);
120
+ let callerArgs = request.params.arguments;
121
+ const legacy = this.legacyValidator ? this.legacyValidator(alias.name, callerArgs) : null;
122
+ if (legacy && !legacy.ok) {
123
+ this.respondWithError(request.id, legacy.message);
124
+ return;
125
+ }
126
+ if (legacy?.ok)
127
+ callerArgs = legacy.args;
128
+ // alias.mode pins a param-driven target to the old tool's mode.
129
+ const validated = validateMode(this.registry, alias.target, translateArgs(alias, callerArgs), alias.mode);
130
+ if (!validated.ok) {
131
+ // A plain error before any request, same as the hosted endpoint; the
132
+ // rename note is still appended by send() so the caller learns the new
133
+ // name either way.
134
+ this.respondWithError(request.id, validated.error);
135
+ return;
136
+ }
137
+ const rewritten = {
138
+ ...message,
139
+ params: { ...request.params, name: alias.target, arguments: validated.args },
140
+ };
141
+ this.onmessage?.(rewritten, extra);
142
+ }
143
+ /** Answers an alias call directly with a plain isError result; send() appends the rename note. */
144
+ respondWithError(id, text) {
145
+ void this.send({
146
+ jsonrpc: '2.0',
147
+ id,
148
+ result: { content: [{ type: 'text', text }], isError: true },
149
+ }).catch((error) => this.onerror?.(error instanceof Error ? error : new Error(String(error))));
150
+ }
151
+ filterToolsList(message) {
152
+ const response = message;
153
+ if (!this.visibleTools || response.method !== undefined || response.id === undefined || response.id === null)
154
+ return message;
155
+ const key = requestKey(response.id);
156
+ if (!this.listRequests.has(key))
157
+ return message;
158
+ this.listRequests.delete(key);
159
+ if (!isObject(response.result) || !Array.isArray(response.result.tools))
160
+ return message;
161
+ const visible = this.visibleTools;
162
+ const filtered = {
163
+ ...message,
164
+ result: {
165
+ ...response.result,
166
+ tools: response.result.tools.filter((tool) => typeof tool?.name === 'string' && visible.has(tool.name)),
167
+ },
168
+ };
169
+ return filtered;
170
+ }
171
+ decorateResponse(message) {
172
+ const response = message;
173
+ if (response.method !== undefined || response.id === undefined || response.id === null)
174
+ return message;
175
+ const key = requestKey(response.id);
176
+ const info = this.calls.get(key);
177
+ if (!info)
178
+ return message;
179
+ this.calls.delete(key);
180
+ if (!isObject(response.result) || !Array.isArray(response.result.content))
181
+ return message;
182
+ const note = deprecationNote(info.alias);
183
+ const decorated = {
184
+ ...message,
185
+ result: {
186
+ ...response.result,
187
+ content: [...response.result.content, { type: 'text', text: note.text }],
188
+ _meta: { ...(isObject(response.result._meta) ? response.result._meta : {}), [DEPRECATION_META_KEY]: note.meta },
189
+ },
190
+ };
191
+ return decorated;
192
+ }
193
+ }
@@ -1,3 +1,14 @@
1
+ export declare function setMcpClientNameResolver(resolver: () => string | undefined): void;
2
+ /**
3
+ * HTTP header values must be printable Latin-1 with no control characters;
4
+ * Node's fetch throws (not returns an error, THROWS) on a header value
5
+ * containing anything else, so an MCP client reporting a non-ASCII name
6
+ * (e.g. "通义灵码") or one with an en/em dash would otherwise break every
7
+ * single tool call. Strips anything outside printable ASCII rather than
8
+ * encoding it, since this is just a display label, not data that needs to
9
+ * round-trip losslessly.
10
+ */
11
+ export declare function sanitizeClientHeaderValue(value: string): string;
1
12
  export interface Account {
2
13
  id: string;
3
14
  platform: string;
@@ -1049,6 +1060,13 @@ export declare class PosterlyClient {
1049
1060
  constructor(apiKey?: string, baseUrl?: string);
1050
1061
  hasApiKey(): boolean;
1051
1062
  getBaseUrl(): string;
1063
+ /**
1064
+ * Runs `fn` with a fresh per-call tool name/id scoped via AsyncLocalStorage,
1065
+ * so any request() calls made inside it (directly or nested) are tagged
1066
+ * with X-Posterly-Tool / X-Posterly-Tool-Call. Called once per tool
1067
+ * invocation from index.ts's server.tool() handlers.
1068
+ */
1069
+ runToolCall<T>(toolName: string, fn: () => Promise<T>): Promise<T>;
1052
1070
  probeWhoami(timeoutMs?: number): Promise<{
1053
1071
  ok: boolean;
1054
1072
  status?: number;
@@ -1,5 +1,41 @@
1
1
  import { readFile } from 'fs/promises';
2
2
  import { resolve } from 'path';
3
+ import { AsyncLocalStorage } from 'node:async_hooks';
4
+ import { randomUUID } from 'node:crypto';
5
+ /**
6
+ * Scopes the current tool name/call id to the async execution of a single
7
+ * tool call, so request() can tag its headers without a mutable "current
8
+ * tool" field on the shared PosterlyClient singleton (index.ts registers one
9
+ * client instance for every tool). AsyncLocalStorage keeps overlapping calls
10
+ * from clobbering each other's context if the stdio transport ever
11
+ * dispatches tool calls concurrently.
12
+ */
13
+ const toolCallStorage = new AsyncLocalStorage();
14
+ /**
15
+ * Resolves the connected MCP client's name (e.g. "claude-ai", "chatgpt"), set
16
+ * once by index.ts after the server is constructed. A resolver function
17
+ * rather than a static value because the SDK only learns the client's
18
+ * identity once the initialize handshake completes, which happens after this
19
+ * module is first loaded; reading through the resolver on every request
20
+ * always gets the current value instead of whatever was available at import
21
+ * time (undefined).
22
+ */
23
+ let clientNameResolver = null;
24
+ export function setMcpClientNameResolver(resolver) {
25
+ clientNameResolver = resolver;
26
+ }
27
+ /**
28
+ * HTTP header values must be printable Latin-1 with no control characters;
29
+ * Node's fetch throws (not returns an error, THROWS) on a header value
30
+ * containing anything else, so an MCP client reporting a non-ASCII name
31
+ * (e.g. "通义灵码") or one with an en/em dash would otherwise break every
32
+ * single tool call. Strips anything outside printable ASCII rather than
33
+ * encoding it, since this is just a display label, not data that needs to
34
+ * round-trip losslessly.
35
+ */
36
+ export function sanitizeClientHeaderValue(value) {
37
+ return value.replace(/[^\x20-\x7e]/g, '').trim().slice(0, 200);
38
+ }
3
39
  export class PosterlyClient {
4
40
  baseUrl;
5
41
  apiKey;
@@ -16,6 +52,15 @@ export class PosterlyClient {
16
52
  getBaseUrl() {
17
53
  return this.baseUrl;
18
54
  }
55
+ /**
56
+ * Runs `fn` with a fresh per-call tool name/id scoped via AsyncLocalStorage,
57
+ * so any request() calls made inside it (directly or nested) are tagged
58
+ * with X-Posterly-Tool / X-Posterly-Tool-Call. Called once per tool
59
+ * invocation from index.ts's server.tool() handlers.
60
+ */
61
+ async runToolCall(toolName, fn) {
62
+ return toolCallStorage.run({ toolName, toolCallId: randomUUID() }, fn);
63
+ }
19
64
  async probeWhoami(timeoutMs = 2500) {
20
65
  const startedAt = Date.now();
21
66
  if (!this.apiKey) {
@@ -78,6 +123,16 @@ export class PosterlyClient {
78
123
  Authorization: `Bearer ${this.apiKey}`,
79
124
  'X-Posterly-Source': 'mcp_stdio',
80
125
  };
126
+ const toolCall = toolCallStorage.getStore();
127
+ if (toolCall) {
128
+ headers['X-Posterly-Tool'] = toolCall.toolName;
129
+ headers['X-Posterly-Tool-Call'] = toolCall.toolCallId;
130
+ }
131
+ const clientName = clientNameResolver?.();
132
+ const clientLabel = clientName ? sanitizeClientHeaderValue(clientName) : '';
133
+ if (clientLabel) {
134
+ headers['X-Posterly-Client'] = clientLabel;
135
+ }
81
136
  const init = { method, headers };
82
137
  if (body instanceof FormData) {
83
138
  init.body = body;
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Preview-then-confirm tokens for MCP write actions, shared by both surfaces.
3
+ * Depends on node:crypto only.
4
+ *
5
+ * Format: pv1.<exp>.<nonce>.<mac>
6
+ * exp unix seconds when the preview stops being confirmable
7
+ * nonce random, base64url; also the one-time-use key for nonce actions
8
+ * mac base64url HMAC-SHA256(key,
9
+ * "posterly-mcp-preview-v1|" + exp + "|" + nonce + "|" + userId
10
+ * + "|" + tool + "|" + action + "|" + canonicalJson(args))
11
+ *
12
+ * The token binds the preview to one user, one tool, one action, and the
13
+ * exact (normalised) arguments, so a confirm call with different args, from a
14
+ * different user, or after expiry is refused. It is UX protection that a
15
+ * human saw what will change, not authorization: REST scopes and ownership
16
+ * checks still decide what is allowed.
17
+ *
18
+ * Callers own the key (hosted: derived from a server secret; stdio: random
19
+ * per process) and the one-time-use store (hosted: Redis; stdio: memory).
20
+ */
21
+ export declare const PREVIEW_TOKEN_VERSION = "pv1";
22
+ export interface PreviewBinding {
23
+ userId: string;
24
+ tool: string;
25
+ action: string;
26
+ /** Arguments AFTER validation/normalisation (defaults applied, numbers coerced). */
27
+ args: unknown;
28
+ }
29
+ export type VerifyPreviewResult = {
30
+ ok: true;
31
+ exp: number;
32
+ nonce: string;
33
+ remainingSeconds: number;
34
+ } | {
35
+ ok: false;
36
+ reason: 'malformed' | 'expired' | 'mismatch';
37
+ };
38
+ /**
39
+ * Canonical JSON of a call's arguments: `confirm` and `preview_id` removed
40
+ * (top level), undefined dropped, keys sorted recursively, arrays kept in
41
+ * order, no whitespace. Same rules as canonicalArgs in tool-registry.ts.
42
+ */
43
+ export declare function canonicalJson(args: unknown): string;
44
+ /** Derives the MAC key from a server secret with a domain prefix, so the raw secret is never the key. */
45
+ export declare function derivePreviewKey(secret: string | Buffer, domain?: string): Buffer;
46
+ export declare function createPreviewToken(key: Buffer, binding: PreviewBinding, options: {
47
+ ttlSeconds: number;
48
+ nowMs?: number;
49
+ nonce?: string;
50
+ }): string;
51
+ export declare function parsePreviewToken(token: unknown): {
52
+ exp: number;
53
+ nonce: string;
54
+ mac: string;
55
+ } | null;
56
+ export declare function verifyPreviewToken(key: Buffer, token: unknown, binding: PreviewBinding, options?: {
57
+ nowMs?: number;
58
+ }): VerifyPreviewResult;
59
+ /** Stable fingerprint of a token for one-time-use bookkeeping (never store the raw token). */
60
+ export declare function previewTokenFingerprint(token: string): string;
61
+ /** Plain-language refusal for each verification failure. */
62
+ export declare function previewFailureMessage(reason: 'malformed' | 'expired' | 'mismatch' | 'used' | 'missing' | 'unavailable'): string;
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Preview-then-confirm tokens for MCP write actions, shared by both surfaces.
3
+ * Depends on node:crypto only.
4
+ *
5
+ * Format: pv1.<exp>.<nonce>.<mac>
6
+ * exp unix seconds when the preview stops being confirmable
7
+ * nonce random, base64url; also the one-time-use key for nonce actions
8
+ * mac base64url HMAC-SHA256(key,
9
+ * "posterly-mcp-preview-v1|" + exp + "|" + nonce + "|" + userId
10
+ * + "|" + tool + "|" + action + "|" + canonicalJson(args))
11
+ *
12
+ * The token binds the preview to one user, one tool, one action, and the
13
+ * exact (normalised) arguments, so a confirm call with different args, from a
14
+ * different user, or after expiry is refused. It is UX protection that a
15
+ * human saw what will change, not authorization: REST scopes and ownership
16
+ * checks still decide what is allowed.
17
+ *
18
+ * Callers own the key (hosted: derived from a server secret; stdio: random
19
+ * per process) and the one-time-use store (hosted: Redis; stdio: memory).
20
+ */
21
+ import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
22
+ export const PREVIEW_TOKEN_VERSION = 'pv1';
23
+ const MAC_DOMAIN = 'posterly-mcp-preview-v1';
24
+ function isPlainObject(value) {
25
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
26
+ }
27
+ function sortKeysDeep(value) {
28
+ if (Array.isArray(value))
29
+ return value.map((item) => (item === undefined ? null : sortKeysDeep(item)));
30
+ if (isPlainObject(value)) {
31
+ const out = {};
32
+ for (const key of Object.keys(value).sort()) {
33
+ const item = value[key];
34
+ if (item === undefined)
35
+ continue;
36
+ out[key] = sortKeysDeep(item);
37
+ }
38
+ return out;
39
+ }
40
+ return value;
41
+ }
42
+ /**
43
+ * Canonical JSON of a call's arguments: `confirm` and `preview_id` removed
44
+ * (top level), undefined dropped, keys sorted recursively, arrays kept in
45
+ * order, no whitespace. Same rules as canonicalArgs in tool-registry.ts.
46
+ */
47
+ export function canonicalJson(args) {
48
+ const source = isPlainObject(args) ? args : {};
49
+ const stripped = {};
50
+ for (const [key, value] of Object.entries(source)) {
51
+ if (key === 'confirm' || key === 'preview_id')
52
+ continue;
53
+ stripped[key] = value;
54
+ }
55
+ return JSON.stringify(sortKeysDeep(stripped));
56
+ }
57
+ /** Derives the MAC key from a server secret with a domain prefix, so the raw secret is never the key. */
58
+ export function derivePreviewKey(secret, domain = 'posterly-mcp-preview-key-v1') {
59
+ return createHmac('sha256', secret).update(domain).digest();
60
+ }
61
+ function field(value) {
62
+ // Pipes separate fields; ids never contain them, but never let one shift a
63
+ // boundary. Percent is escaped first so the encoding is injective: a value
64
+ // that already contains "%7C" cannot collide with one that contains "|".
65
+ return value.replace(/%/g, '%25').replace(/\|/g, '%7C');
66
+ }
67
+ function computeMac(key, exp, nonce, binding) {
68
+ const payload = [
69
+ MAC_DOMAIN,
70
+ String(exp),
71
+ nonce,
72
+ field(binding.userId),
73
+ field(binding.tool),
74
+ field(binding.action),
75
+ canonicalJson(binding.args),
76
+ ].join('|');
77
+ return createHmac('sha256', key).update(payload).digest('base64url');
78
+ }
79
+ export function createPreviewToken(key, binding, options) {
80
+ const now = Math.floor((options.nowMs ?? Date.now()) / 1000);
81
+ const exp = now + Math.max(1, Math.floor(options.ttlSeconds));
82
+ const nonce = options.nonce ?? randomBytes(16).toString('base64url');
83
+ const mac = computeMac(key, exp, nonce, binding);
84
+ return `${PREVIEW_TOKEN_VERSION}.${exp}.${nonce}.${mac}`;
85
+ }
86
+ export function parsePreviewToken(token) {
87
+ if (typeof token !== 'string')
88
+ return null;
89
+ const parts = token.split('.');
90
+ if (parts.length !== 4 || parts[0] !== PREVIEW_TOKEN_VERSION)
91
+ return null;
92
+ const [, expRaw, nonce, mac] = parts;
93
+ if (!/^\d{1,12}$/.test(expRaw) || !/^[A-Za-z0-9_-]{8,64}$/.test(nonce) || !/^[A-Za-z0-9_-]{20,100}$/.test(mac))
94
+ return null;
95
+ return { exp: Number(expRaw), nonce, mac };
96
+ }
97
+ export function verifyPreviewToken(key, token, binding, options = {}) {
98
+ const parsed = parsePreviewToken(token);
99
+ if (!parsed)
100
+ return { ok: false, reason: 'malformed' };
101
+ const expected = Buffer.from(computeMac(key, parsed.exp, parsed.nonce, binding));
102
+ const actual = Buffer.from(parsed.mac);
103
+ if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
104
+ return { ok: false, reason: 'mismatch' };
105
+ }
106
+ const now = Math.floor((options.nowMs ?? Date.now()) / 1000);
107
+ if (parsed.exp <= now)
108
+ return { ok: false, reason: 'expired' };
109
+ return { ok: true, exp: parsed.exp, nonce: parsed.nonce, remainingSeconds: parsed.exp - now };
110
+ }
111
+ /** Stable fingerprint of a token for one-time-use bookkeeping (never store the raw token). */
112
+ export function previewTokenFingerprint(token) {
113
+ return createHash('sha256').update(token).digest('hex');
114
+ }
115
+ /** Plain-language refusal for each verification failure. */
116
+ export function previewFailureMessage(reason) {
117
+ switch (reason) {
118
+ case 'missing':
119
+ return 'confirm: true needs the preview_id from a preview call. Call once without confirm, show the preview to the user, then confirm with its preview_id.';
120
+ case 'expired':
121
+ return 'This preview expired, so nothing was changed. Call again without confirm to get a fresh preview.';
122
+ case 'mismatch':
123
+ return 'This preview does not match these arguments, so nothing was changed. Call again without confirm to get a fresh preview for exactly what should happen.';
124
+ case 'used':
125
+ return 'This preview was already used, so nothing was changed. Call again without confirm to get a fresh preview.';
126
+ case 'unavailable':
127
+ return 'posterly could not check this preview right now, so nothing was changed. Please retry in a moment.';
128
+ default:
129
+ return 'preview_id is not valid, so nothing was changed. Call again without confirm to get a fresh preview.';
130
+ }
131
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Stdio (npm package) preview tokens: a random key per process and an
3
+ * in-memory one-time-use set. A preview only survives as long as the local
4
+ * MCP process does, which matches how a local client uses it (preview and
5
+ * confirm happen in the same session). This is UX protection only: REST
6
+ * authorization on the posterly API is unchanged.
7
+ */
8
+ import { type PreviewBinding, type VerifyPreviewResult } from './preview-token-core.js';
9
+ export declare const STDIO_PREVIEW_TTL_SECONDS: number;
10
+ export declare class StdioPreviewTokens {
11
+ private readonly ttlSeconds;
12
+ private readonly key;
13
+ private readonly used;
14
+ constructor(key?: Buffer, ttlSeconds?: number);
15
+ create(binding: PreviewBinding, nowMs?: number): string;
16
+ verify(token: unknown, binding: PreviewBinding, nowMs?: number): VerifyPreviewResult;
17
+ /**
18
+ * Marks a verified token as used. Returns false when it was already used
19
+ * (a second confirm with the same preview_id). Call only after verify().
20
+ */
21
+ claimOnce(token: string, expSeconds: number, nowMs?: number): boolean;
22
+ private sweep;
23
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Stdio (npm package) preview tokens: a random key per process and an
3
+ * in-memory one-time-use set. A preview only survives as long as the local
4
+ * MCP process does, which matches how a local client uses it (preview and
5
+ * confirm happen in the same session). This is UX protection only: REST
6
+ * authorization on the posterly API is unchanged.
7
+ */
8
+ import { randomBytes } from 'node:crypto';
9
+ import { createPreviewToken, previewTokenFingerprint, verifyPreviewToken, } from './preview-token-core.js';
10
+ export const STDIO_PREVIEW_TTL_SECONDS = 10 * 60;
11
+ export class StdioPreviewTokens {
12
+ ttlSeconds;
13
+ key;
14
+ used = new Map();
15
+ constructor(key = randomBytes(32), ttlSeconds = STDIO_PREVIEW_TTL_SECONDS) {
16
+ this.ttlSeconds = ttlSeconds;
17
+ this.key = key;
18
+ }
19
+ create(binding, nowMs) {
20
+ return createPreviewToken(this.key, binding, { ttlSeconds: this.ttlSeconds, nowMs });
21
+ }
22
+ verify(token, binding, nowMs) {
23
+ return verifyPreviewToken(this.key, token, binding, { nowMs });
24
+ }
25
+ /**
26
+ * Marks a verified token as used. Returns false when it was already used
27
+ * (a second confirm with the same preview_id). Call only after verify().
28
+ */
29
+ claimOnce(token, expSeconds, nowMs = Date.now()) {
30
+ this.sweep(nowMs);
31
+ const fingerprint = previewTokenFingerprint(token);
32
+ if (this.used.has(fingerprint))
33
+ return false;
34
+ this.used.set(fingerprint, expSeconds);
35
+ return true;
36
+ }
37
+ sweep(nowMs) {
38
+ const now = Math.floor(nowMs / 1000);
39
+ for (const [fingerprint, exp] of this.used) {
40
+ if (exp <= now)
41
+ this.used.delete(fingerprint);
42
+ }
43
+ }
44
+ }