posterly-mcp-server 0.44.0 → 0.45.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +310 -111
- package/dist/index.js +190 -894
- package/dist/lib/alias-transport.d.ts +84 -0
- package/dist/lib/alias-transport.js +193 -0
- package/dist/lib/api-client.d.ts +18 -0
- package/dist/lib/api-client.js +55 -0
- package/dist/lib/preview-token-core.d.ts +62 -0
- package/dist/lib/preview-token-core.js +131 -0
- package/dist/lib/preview-token.d.ts +23 -0
- package/dist/lib/preview-token.js +44 -0
- package/dist/lib/tool-registry.d.ts +246 -0
- package/dist/lib/tool-registry.js +394 -0
- package/dist/lib/tool-runtime.d.ts +68 -0
- package/dist/lib/tool-runtime.js +85 -0
- package/dist/lib/version.d.ts +1 -1
- package/dist/lib/version.js +1 -1
- package/dist/tools/audit-google-business-profile.js +1 -1
- package/dist/tools/connect-account.d.ts +4 -4
- package/dist/tools/connect-account.js +2 -2
- package/dist/tools/create-connect-session.d.ts +2 -2
- package/dist/tools/create-connect-session.js +2 -2
- package/dist/tools/create-post.d.ts +30 -7
- package/dist/tools/create-post.js +75 -9
- package/dist/tools/create-posts-batch.d.ts +4 -4
- package/dist/tools/create-posts-batch.js +14 -2
- package/dist/tools/create-webhook.d.ts +2 -2
- package/dist/tools/delete-post.d.ts +34 -10
- package/dist/tools/delete-post.js +52 -16
- package/dist/tools/generate-captions.d.ts +2 -2
- package/dist/tools/generate-image.d.ts +4 -4
- package/dist/tools/generate-image.js +2 -2
- package/dist/tools/generate-video.d.ts +6 -6
- package/dist/tools/generate-video.js +1 -1
- package/dist/tools/get-agent-signup-info.js +2 -2
- package/dist/tools/get-post.d.ts +16 -1
- package/dist/tools/get-post.js +35 -19
- package/dist/tools/list-accounts.d.ts +49 -3
- package/dist/tools/list-accounts.js +148 -33
- package/dist/tools/list-analytics.d.ts +111 -0
- package/dist/tools/list-analytics.js +393 -0
- package/dist/tools/list-brands.d.ts +38 -3
- package/dist/tools/list-brands.js +138 -20
- package/dist/tools/list-comments.d.ts +29 -9
- package/dist/tools/list-comments.js +75 -34
- package/dist/tools/list-conversations.d.ts +32 -9
- package/dist/tools/list-conversations.js +63 -27
- package/dist/tools/list-google-business-media.js +1 -1
- package/dist/tools/list-google-business-reviews.d.ts +32 -9
- package/dist/tools/list-google-business-reviews.js +77 -52
- package/dist/tools/list-jobs.d.ts +60 -0
- package/dist/tools/list-jobs.js +81 -0
- package/dist/tools/list-platforms.d.ts +62 -3
- package/dist/tools/list-platforms.js +118 -9
- package/dist/tools/list-posts.d.ts +2 -2
- package/dist/tools/manage-comment.d.ts +102 -0
- package/dist/tools/manage-comment.js +125 -0
- package/dist/tools/manage-conversation.d.ts +79 -0
- package/dist/tools/manage-conversation.js +75 -0
- package/dist/tools/manage-google-business-media.d.ts +103 -0
- package/dist/tools/manage-google-business-media.js +126 -0
- package/dist/tools/manage-google-business-review.d.ts +146 -0
- package/dist/tools/manage-google-business-review.js +115 -0
- package/dist/tools/manage-oauth-client.d.ts +121 -0
- package/dist/tools/manage-oauth-client.js +94 -0
- package/dist/tools/manage-webhook.d.ts +99 -0
- package/dist/tools/manage-webhook.js +87 -0
- package/dist/tools/start-signup.d.ts +2 -2
- package/dist/tools/submit-agent-feedback.d.ts +4 -4
- package/dist/tools/trigger-platform-helper.d.ts +2 -2
- package/dist/tools/update-post.d.ts +62 -11
- package/dist/tools/update-post.js +79 -26
- package/dist/tools/upload-media.d.ts +32 -9
- package/dist/tools/upload-media.js +54 -18
- package/dist/tools/validate-post.d.ts +4 -4
- package/dist/tools/validate-post.js +2 -2
- package/dist/tools/whoami.d.ts +15 -2
- package/dist/tools/whoami.js +221 -25
- package/package.json +5 -3
- package/scripts/test-alias-transport.mjs +368 -0
- package/scripts/test-client-header-sanitization.mjs +78 -0
- package/scripts/test-payload-builders.mjs +143 -20
- package/scripts/test-tool-annotations.mjs +38 -17
- package/server.json +9 -2
- package/src/index.ts +227 -1443
- package/src/lib/alias-transport.ts +233 -0
- package/src/lib/api-client.ts +65 -0
- package/src/lib/preview-token-core.ts +157 -0
- package/src/lib/preview-token.ts +54 -0
- package/src/lib/tool-registry.ts +538 -0
- package/src/lib/tool-runtime.ts +117 -0
- package/src/lib/version.ts +1 -1
- package/src/tools/audit-google-business-profile.ts +1 -1
- package/src/tools/connect-account.ts +2 -2
- package/src/tools/create-connect-session.ts +2 -2
- package/src/tools/create-post.ts +87 -12
- package/src/tools/create-posts-batch.ts +13 -2
- package/src/tools/delete-post.ts +60 -19
- package/src/tools/generate-image.ts +2 -2
- package/src/tools/generate-video.ts +1 -1
- package/src/tools/get-agent-signup-info.ts +2 -2
- package/src/tools/get-post.ts +35 -21
- package/src/tools/list-accounts.ts +187 -39
- package/src/tools/list-analytics.ts +444 -0
- package/src/tools/list-brands.ts +162 -26
- package/src/tools/list-comments.ts +86 -43
- package/src/tools/list-conversations.ts +81 -39
- package/src/tools/list-google-business-media.ts +1 -1
- package/src/tools/list-google-business-reviews.ts +93 -64
- package/src/tools/list-jobs.ts +91 -0
- package/src/tools/list-platforms.ts +135 -11
- package/src/tools/manage-comment.ts +142 -0
- package/src/tools/manage-conversation.ts +89 -0
- package/src/tools/manage-google-business-media.ts +147 -0
- package/src/tools/manage-google-business-review.ts +139 -0
- package/src/tools/manage-oauth-client.ts +116 -0
- package/src/tools/manage-webhook.ts +103 -0
- package/src/tools/update-post.ts +106 -39
- package/src/tools/upload-media.ts +76 -28
- package/src/tools/validate-post.ts +2 -1
- package/src/tools/whoami.ts +253 -30
- package/tool-annotations.json +76 -149
- package/tool-registry.json +2085 -0
- package/dist/tools/add-google-business-media.d.ts +0 -42
- package/dist/tools/add-google-business-media.js +0 -34
- package/dist/tools/create-oauth-client.d.ts +0 -40
- package/dist/tools/create-oauth-client.js +0 -20
- package/dist/tools/delete-comment.d.ts +0 -24
- package/dist/tools/delete-comment.js +0 -22
- package/dist/tools/delete-google-business-media.d.ts +0 -32
- package/dist/tools/delete-google-business-media.js +0 -17
- package/dist/tools/delete-google-business-review-reply.d.ts +0 -32
- package/dist/tools/delete-google-business-review-reply.js +0 -17
- package/dist/tools/delete-oauth-client.d.ts +0 -20
- package/dist/tools/delete-oauth-client.js +0 -13
- package/dist/tools/delete-post-group.d.ts +0 -20
- package/dist/tools/delete-post-group.js +0 -13
- package/dist/tools/delete-webhook.d.ts +0 -20
- package/dist/tools/delete-webhook.js +0 -13
- package/dist/tools/get-account-analytics.d.ts +0 -31
- package/dist/tools/get-account-analytics.js +0 -152
- package/dist/tools/get-brand-profile.d.ts +0 -16
- package/dist/tools/get-brand-profile.js +0 -73
- package/dist/tools/get-brand.d.ts +0 -16
- package/dist/tools/get-brand.js +0 -26
- package/dist/tools/get-comment.d.ts +0 -20
- package/dist/tools/get-comment.js +0 -36
- package/dist/tools/get-connect-link.d.ts +0 -28
- package/dist/tools/get-connect-link.js +0 -51
- package/dist/tools/get-connect-session.d.ts +0 -20
- package/dist/tools/get-connect-session.js +0 -54
- package/dist/tools/get-conversation.d.ts +0 -24
- package/dist/tools/get-conversation.js +0 -31
- package/dist/tools/get-google-business-review-link.d.ts +0 -24
- package/dist/tools/get-google-business-review-link.js +0 -19
- package/dist/tools/get-image-job.d.ts +0 -24
- package/dist/tools/get-image-job.js +0 -31
- package/dist/tools/get-learned-voice.d.ts +0 -16
- package/dist/tools/get-learned-voice.js +0 -22
- package/dist/tools/get-mcp-status.d.ts +0 -8
- package/dist/tools/get-mcp-status.js +0 -182
- package/dist/tools/get-performance-profile.d.ts +0 -16
- package/dist/tools/get-performance-profile.js +0 -39
- package/dist/tools/get-platform-schema.d.ts +0 -24
- package/dist/tools/get-platform-schema.js +0 -48
- package/dist/tools/get-post-analytics.d.ts +0 -39
- package/dist/tools/get-post-analytics.js +0 -222
- package/dist/tools/get-post-insights.d.ts +0 -28
- package/dist/tools/get-post-insights.js +0 -39
- package/dist/tools/get-post-missing.d.ts +0 -16
- package/dist/tools/get-post-missing.js +0 -12
- package/dist/tools/get-video-job.d.ts +0 -24
- package/dist/tools/get-video-job.js +0 -31
- package/dist/tools/list-brand-accounts.d.ts +0 -16
- package/dist/tools/list-brand-accounts.js +0 -24
- package/dist/tools/reply-google-business-review.d.ts +0 -36
- package/dist/tools/reply-google-business-review.js +0 -18
- package/dist/tools/reply-to-comment.d.ts +0 -32
- package/dist/tools/reply-to-comment.js +0 -17
- package/dist/tools/send-message.d.ts +0 -28
- package/dist/tools/send-message.js +0 -16
- package/dist/tools/suggest-google-business-review-reply.d.ts +0 -48
- package/dist/tools/suggest-google-business-review-reply.js +0 -27
- package/dist/tools/sync-inbox.d.ts +0 -24
- package/dist/tools/sync-inbox.js +0 -18
- package/dist/tools/test-webhook.d.ts +0 -20
- package/dist/tools/test-webhook.js +0 -14
- package/dist/tools/update-comment.d.ts +0 -28
- package/dist/tools/update-comment.js +0 -21
- package/dist/tools/update-oauth-client.d.ts +0 -44
- package/dist/tools/update-oauth-client.js +0 -21
- package/dist/tools/update-post-release-id.d.ts +0 -28
- package/dist/tools/update-post-release-id.js +0 -18
- package/dist/tools/update-post-status.d.ts +0 -28
- package/dist/tools/update-post-status.js +0 -23
- package/dist/tools/update-webhook.d.ts +0 -40
- package/dist/tools/update-webhook.js +0 -21
- package/dist/tools/upload-media-from-url.d.ts +0 -24
- package/dist/tools/upload-media-from-url.js +0 -26
- package/src/tools/add-google-business-media.ts +0 -50
- package/src/tools/create-oauth-client.ts +0 -36
- package/src/tools/delete-comment.ts +0 -29
- package/src/tools/delete-google-business-media.ts +0 -30
- package/src/tools/delete-google-business-review-reply.ts +0 -30
- package/src/tools/delete-oauth-client.ts +0 -23
- package/src/tools/delete-post-group.ts +0 -17
- package/src/tools/delete-webhook.ts +0 -17
- package/src/tools/get-account-analytics.ts +0 -171
- package/src/tools/get-brand-profile.ts +0 -86
- package/src/tools/get-brand.ts +0 -34
- package/src/tools/get-comment.ts +0 -47
- package/src/tools/get-connect-link.ts +0 -61
- package/src/tools/get-connect-session.ts +0 -63
- package/src/tools/get-conversation.ts +0 -44
- package/src/tools/get-google-business-review-link.ts +0 -30
- package/src/tools/get-image-job.ts +0 -36
- package/src/tools/get-learned-voice.ts +0 -31
- package/src/tools/get-mcp-status.ts +0 -212
- package/src/tools/get-performance-profile.ts +0 -47
- package/src/tools/get-platform-schema.ts +0 -56
- package/src/tools/get-post-analytics.ts +0 -243
- package/src/tools/get-post-insights.ts +0 -60
- package/src/tools/get-post-missing.ts +0 -16
- package/src/tools/get-video-job.ts +0 -36
- package/src/tools/list-brand-accounts.ts +0 -36
- package/src/tools/reply-google-business-review.ts +0 -32
- package/src/tools/reply-to-comment.ts +0 -30
- package/src/tools/send-message.ts +0 -28
- package/src/tools/suggest-google-business-review-reply.ts +0 -44
- package/src/tools/sync-inbox.ts +0 -28
- package/src/tools/test-webhook.ts +0 -18
- package/src/tools/update-comment.ts +0 -31
- package/src/tools/update-oauth-client.ts +0 -38
- package/src/tools/update-post-release-id.ts +0 -25
- package/src/tools/update-post-status.ts +0 -31
- package/src/tools/update-webhook.ts +0 -36
- package/src/tools/upload-media-from-url.ts +0 -33
|
@@ -0,0 +1,233 @@
|
|
|
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
|
+
|
|
33
|
+
import type { Transport, TransportSendOptions } from '@modelcontextprotocol/sdk/shared/transport.js';
|
|
34
|
+
import type { JSONRPCMessage, MessageExtraInfo, RequestId } from '@modelcontextprotocol/sdk/types.js';
|
|
35
|
+
import {
|
|
36
|
+
DEPRECATION_META_KEY,
|
|
37
|
+
deprecationNote,
|
|
38
|
+
resolveAlias,
|
|
39
|
+
translateArgs,
|
|
40
|
+
validateMode,
|
|
41
|
+
type ResolvedAlias,
|
|
42
|
+
type ToolRegistry,
|
|
43
|
+
} from './tool-registry.js';
|
|
44
|
+
|
|
45
|
+
export interface AliasCallInfo {
|
|
46
|
+
/** The name the client called (the alias). */
|
|
47
|
+
calledName: string;
|
|
48
|
+
/** The canonical tool the call was routed to. */
|
|
49
|
+
target: string;
|
|
50
|
+
/** The alias may confirm with confirm:true alone until its sunset date. */
|
|
51
|
+
legacyConfirm: boolean;
|
|
52
|
+
alias: ResolvedAlias;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Checks an alias call's raw arguments against the old tool's input schema.
|
|
57
|
+
* Return null when the alias has no legacy schema (no check), the parsed
|
|
58
|
+
* arguments when they are valid, or the exact error text the old tool gave.
|
|
59
|
+
*/
|
|
60
|
+
export type LegacyAliasValidator = (
|
|
61
|
+
aliasName: string,
|
|
62
|
+
args: unknown,
|
|
63
|
+
) => { ok: true; args: Record<string, unknown> } | { ok: false; message: string } | null;
|
|
64
|
+
|
|
65
|
+
function requestKey(id: RequestId): string {
|
|
66
|
+
return `${typeof id}:${String(id)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isObject(value: unknown): value is Record<string, any> {
|
|
70
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class AliasTransport implements Transport {
|
|
74
|
+
onclose?: () => void;
|
|
75
|
+
onerror?: (error: Error) => void;
|
|
76
|
+
onmessage?: <T extends JSONRPCMessage>(message: T, extra?: MessageExtraInfo) => void;
|
|
77
|
+
|
|
78
|
+
private readonly calls = new Map<string, AliasCallInfo>();
|
|
79
|
+
/** In-flight tools/list request ids, so only their responses are filtered. */
|
|
80
|
+
private readonly listRequests = new Set<string>();
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param visibleTools names to list in tools/list, or null to list every
|
|
84
|
+
* registered tool unchanged (the default: every set).
|
|
85
|
+
*/
|
|
86
|
+
constructor(
|
|
87
|
+
private readonly inner: Transport,
|
|
88
|
+
private readonly registry: ToolRegistry,
|
|
89
|
+
private readonly visibleTools: Set<string> | null = null,
|
|
90
|
+
private readonly legacyValidator: LegacyAliasValidator | null = null,
|
|
91
|
+
) {
|
|
92
|
+
inner.onmessage = (message, extra) => this.handleIncoming(message as JSONRPCMessage, extra);
|
|
93
|
+
inner.onclose = () => this.onclose?.();
|
|
94
|
+
inner.onerror = (error) => this.onerror?.(error);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
get sessionId(): string | undefined {
|
|
98
|
+
return this.inner.sessionId;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setProtocolVersion(version: string): void {
|
|
102
|
+
this.inner.setProtocolVersion?.(version);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
start(): Promise<void> {
|
|
106
|
+
return this.inner.start();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
close(): Promise<void> {
|
|
110
|
+
this.calls.clear();
|
|
111
|
+
this.listRequests.clear();
|
|
112
|
+
return this.inner.close();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Alias details for an in-flight tools/call, or undefined for a canonical call. */
|
|
116
|
+
getCallInfo(requestId: RequestId | undefined): AliasCallInfo | undefined {
|
|
117
|
+
if (requestId === undefined || requestId === null) return undefined;
|
|
118
|
+
return this.calls.get(requestKey(requestId));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async send(message: JSONRPCMessage, options?: TransportSendOptions): Promise<void> {
|
|
122
|
+
const outgoing = this.filterToolsList(this.decorateResponse(message));
|
|
123
|
+
return this.inner.send(outgoing, options);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private handleIncoming(message: JSONRPCMessage, extra?: MessageExtraInfo): void {
|
|
127
|
+
const request = message as { id?: RequestId; method?: string; params?: any };
|
|
128
|
+
if (request.method === 'notifications/cancelled') {
|
|
129
|
+
const cancelled = isObject(request.params) ? request.params.requestId : undefined;
|
|
130
|
+
if (typeof cancelled === 'string' || typeof cancelled === 'number') {
|
|
131
|
+
this.calls.delete(requestKey(cancelled));
|
|
132
|
+
this.listRequests.delete(requestKey(cancelled));
|
|
133
|
+
}
|
|
134
|
+
this.onmessage?.(message, extra);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (request.method === 'tools/list' && this.visibleTools && request.id !== undefined && request.id !== null) {
|
|
138
|
+
this.listRequests.add(requestKey(request.id));
|
|
139
|
+
this.onmessage?.(message, extra);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (request.method !== 'tools/call' || request.id === undefined || request.id === null || !isObject(request.params)) {
|
|
143
|
+
this.onmessage?.(message, extra);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const name = request.params.name;
|
|
147
|
+
const alias = typeof name === 'string' ? resolveAlias(this.registry, name) : null;
|
|
148
|
+
if (!alias) {
|
|
149
|
+
this.onmessage?.(message, extra);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const info: AliasCallInfo = {
|
|
154
|
+
calledName: alias.name,
|
|
155
|
+
target: alias.target,
|
|
156
|
+
legacyConfirm: alias.legacyConfirm === true,
|
|
157
|
+
alias,
|
|
158
|
+
};
|
|
159
|
+
this.calls.set(requestKey(request.id), info);
|
|
160
|
+
|
|
161
|
+
let callerArgs: unknown = request.params.arguments;
|
|
162
|
+
const legacy = this.legacyValidator ? this.legacyValidator(alias.name, callerArgs) : null;
|
|
163
|
+
if (legacy && !legacy.ok) {
|
|
164
|
+
this.respondWithError(request.id, legacy.message);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
if (legacy?.ok) callerArgs = legacy.args;
|
|
168
|
+
|
|
169
|
+
// alias.mode pins a param-driven target to the old tool's mode.
|
|
170
|
+
const validated = validateMode(this.registry, alias.target, translateArgs(alias, callerArgs), alias.mode);
|
|
171
|
+
if (!validated.ok) {
|
|
172
|
+
// A plain error before any request, same as the hosted endpoint; the
|
|
173
|
+
// rename note is still appended by send() so the caller learns the new
|
|
174
|
+
// name either way.
|
|
175
|
+
this.respondWithError(request.id, validated.error);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const rewritten = {
|
|
180
|
+
...(message as object),
|
|
181
|
+
params: { ...request.params, name: alias.target, arguments: validated.args },
|
|
182
|
+
} as unknown as JSONRPCMessage;
|
|
183
|
+
this.onmessage?.(rewritten, extra);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Answers an alias call directly with a plain isError result; send() appends the rename note. */
|
|
187
|
+
private respondWithError(id: RequestId, text: string): void {
|
|
188
|
+
void this.send({
|
|
189
|
+
jsonrpc: '2.0',
|
|
190
|
+
id,
|
|
191
|
+
result: { content: [{ type: 'text', text }], isError: true },
|
|
192
|
+
} as JSONRPCMessage).catch((error) => this.onerror?.(error instanceof Error ? error : new Error(String(error))));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private filterToolsList(message: JSONRPCMessage): JSONRPCMessage {
|
|
196
|
+
const response = message as { id?: RequestId; result?: any; method?: string };
|
|
197
|
+
if (!this.visibleTools || response.method !== undefined || response.id === undefined || response.id === null) return message;
|
|
198
|
+
const key = requestKey(response.id);
|
|
199
|
+
if (!this.listRequests.has(key)) return message;
|
|
200
|
+
this.listRequests.delete(key);
|
|
201
|
+
if (!isObject(response.result) || !Array.isArray(response.result.tools)) return message;
|
|
202
|
+
const visible = this.visibleTools;
|
|
203
|
+
const filtered = {
|
|
204
|
+
...(message as object),
|
|
205
|
+
result: {
|
|
206
|
+
...response.result,
|
|
207
|
+
tools: response.result.tools.filter((tool: { name?: unknown }) => typeof tool?.name === 'string' && visible.has(tool.name)),
|
|
208
|
+
},
|
|
209
|
+
};
|
|
210
|
+
return filtered as unknown as JSONRPCMessage;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private decorateResponse(message: JSONRPCMessage): JSONRPCMessage {
|
|
214
|
+
const response = message as { id?: RequestId; result?: any; error?: unknown; method?: string };
|
|
215
|
+
if (response.method !== undefined || response.id === undefined || response.id === null) return message;
|
|
216
|
+
const key = requestKey(response.id);
|
|
217
|
+
const info = this.calls.get(key);
|
|
218
|
+
if (!info) return message;
|
|
219
|
+
this.calls.delete(key);
|
|
220
|
+
if (!isObject(response.result) || !Array.isArray(response.result.content)) return message;
|
|
221
|
+
|
|
222
|
+
const note = deprecationNote(info.alias);
|
|
223
|
+
const decorated = {
|
|
224
|
+
...(message as object),
|
|
225
|
+
result: {
|
|
226
|
+
...response.result,
|
|
227
|
+
content: [...response.result.content, { type: 'text', text: note.text }],
|
|
228
|
+
_meta: { ...(isObject(response.result._meta) ? response.result._meta : {}), [DEPRECATION_META_KEY]: note.meta },
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
return decorated as unknown as JSONRPCMessage;
|
|
232
|
+
}
|
|
233
|
+
}
|
package/src/lib/api-client.ts
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
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
|
+
interface ToolCallContext {
|
|
7
|
+
toolName: string;
|
|
8
|
+
toolCallId: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Scopes the current tool name/call id to the async execution of a single
|
|
13
|
+
* tool call, so request() can tag its headers without a mutable "current
|
|
14
|
+
* tool" field on the shared PosterlyClient singleton (index.ts registers one
|
|
15
|
+
* client instance for every tool). AsyncLocalStorage keeps overlapping calls
|
|
16
|
+
* from clobbering each other's context if the stdio transport ever
|
|
17
|
+
* dispatches tool calls concurrently.
|
|
18
|
+
*/
|
|
19
|
+
const toolCallStorage = new AsyncLocalStorage<ToolCallContext>();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolves the connected MCP client's name (e.g. "claude-ai", "chatgpt"), set
|
|
23
|
+
* once by index.ts after the server is constructed. A resolver function
|
|
24
|
+
* rather than a static value because the SDK only learns the client's
|
|
25
|
+
* identity once the initialize handshake completes, which happens after this
|
|
26
|
+
* module is first loaded; reading through the resolver on every request
|
|
27
|
+
* always gets the current value instead of whatever was available at import
|
|
28
|
+
* time (undefined).
|
|
29
|
+
*/
|
|
30
|
+
let clientNameResolver: (() => string | undefined) | null = null;
|
|
31
|
+
|
|
32
|
+
export function setMcpClientNameResolver(resolver: () => string | undefined): void {
|
|
33
|
+
clientNameResolver = resolver;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* HTTP header values must be printable Latin-1 with no control characters;
|
|
38
|
+
* Node's fetch throws (not returns an error, THROWS) on a header value
|
|
39
|
+
* containing anything else, so an MCP client reporting a non-ASCII name
|
|
40
|
+
* (e.g. "通义灵码") or one with an en/em dash would otherwise break every
|
|
41
|
+
* single tool call. Strips anything outside printable ASCII rather than
|
|
42
|
+
* encoding it, since this is just a display label, not data that needs to
|
|
43
|
+
* round-trip losslessly.
|
|
44
|
+
*/
|
|
45
|
+
export function sanitizeClientHeaderValue(value: string): string {
|
|
46
|
+
return value.replace(/[^\x20-\x7e]/g, '').trim().slice(0, 200);
|
|
47
|
+
}
|
|
3
48
|
|
|
4
49
|
export interface Account {
|
|
5
50
|
id: string;
|
|
@@ -1113,6 +1158,16 @@ export class PosterlyClient {
|
|
|
1113
1158
|
return this.baseUrl;
|
|
1114
1159
|
}
|
|
1115
1160
|
|
|
1161
|
+
/**
|
|
1162
|
+
* Runs `fn` with a fresh per-call tool name/id scoped via AsyncLocalStorage,
|
|
1163
|
+
* so any request() calls made inside it (directly or nested) are tagged
|
|
1164
|
+
* with X-Posterly-Tool / X-Posterly-Tool-Call. Called once per tool
|
|
1165
|
+
* invocation from index.ts's server.tool() handlers.
|
|
1166
|
+
*/
|
|
1167
|
+
async runToolCall<T>(toolName: string, fn: () => Promise<T>): Promise<T> {
|
|
1168
|
+
return toolCallStorage.run({ toolName, toolCallId: randomUUID() }, fn);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1116
1171
|
async probeWhoami(timeoutMs = 2500): Promise<{
|
|
1117
1172
|
ok: boolean;
|
|
1118
1173
|
status?: number;
|
|
@@ -1190,6 +1245,16 @@ export class PosterlyClient {
|
|
|
1190
1245
|
Authorization: `Bearer ${this.apiKey}`,
|
|
1191
1246
|
'X-Posterly-Source': 'mcp_stdio',
|
|
1192
1247
|
};
|
|
1248
|
+
const toolCall = toolCallStorage.getStore();
|
|
1249
|
+
if (toolCall) {
|
|
1250
|
+
headers['X-Posterly-Tool'] = toolCall.toolName;
|
|
1251
|
+
headers['X-Posterly-Tool-Call'] = toolCall.toolCallId;
|
|
1252
|
+
}
|
|
1253
|
+
const clientName = clientNameResolver?.();
|
|
1254
|
+
const clientLabel = clientName ? sanitizeClientHeaderValue(clientName) : '';
|
|
1255
|
+
if (clientLabel) {
|
|
1256
|
+
headers['X-Posterly-Client'] = clientLabel;
|
|
1257
|
+
}
|
|
1193
1258
|
|
|
1194
1259
|
const init: RequestInit = { method, headers };
|
|
1195
1260
|
|
|
@@ -0,0 +1,157 @@
|
|
|
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
|
+
|
|
22
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
23
|
+
|
|
24
|
+
export const PREVIEW_TOKEN_VERSION = 'pv1';
|
|
25
|
+
const MAC_DOMAIN = 'posterly-mcp-preview-v1';
|
|
26
|
+
|
|
27
|
+
export interface PreviewBinding {
|
|
28
|
+
userId: string;
|
|
29
|
+
tool: string;
|
|
30
|
+
action: string;
|
|
31
|
+
/** Arguments AFTER validation/normalisation (defaults applied, numbers coerced). */
|
|
32
|
+
args: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type VerifyPreviewResult =
|
|
36
|
+
| { ok: true; exp: number; nonce: string; remainingSeconds: number }
|
|
37
|
+
| { ok: false; reason: 'malformed' | 'expired' | 'mismatch' };
|
|
38
|
+
|
|
39
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
40
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sortKeysDeep(value: unknown): unknown {
|
|
44
|
+
if (Array.isArray(value)) return value.map((item) => (item === undefined ? null : sortKeysDeep(item)));
|
|
45
|
+
if (isPlainObject(value)) {
|
|
46
|
+
const out: Record<string, unknown> = {};
|
|
47
|
+
for (const key of Object.keys(value).sort()) {
|
|
48
|
+
const item = value[key];
|
|
49
|
+
if (item === undefined) continue;
|
|
50
|
+
out[key] = sortKeysDeep(item);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Canonical JSON of a call's arguments: `confirm` and `preview_id` removed
|
|
59
|
+
* (top level), undefined dropped, keys sorted recursively, arrays kept in
|
|
60
|
+
* order, no whitespace. Same rules as canonicalArgs in tool-registry.ts.
|
|
61
|
+
*/
|
|
62
|
+
export function canonicalJson(args: unknown): string {
|
|
63
|
+
const source = isPlainObject(args) ? args : {};
|
|
64
|
+
const stripped: Record<string, unknown> = {};
|
|
65
|
+
for (const [key, value] of Object.entries(source)) {
|
|
66
|
+
if (key === 'confirm' || key === 'preview_id') continue;
|
|
67
|
+
stripped[key] = value;
|
|
68
|
+
}
|
|
69
|
+
return JSON.stringify(sortKeysDeep(stripped));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Derives the MAC key from a server secret with a domain prefix, so the raw secret is never the key. */
|
|
73
|
+
export function derivePreviewKey(secret: string | Buffer, domain = 'posterly-mcp-preview-key-v1'): Buffer {
|
|
74
|
+
return createHmac('sha256', secret).update(domain).digest();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function field(value: string): string {
|
|
78
|
+
// Pipes separate fields; ids never contain them, but never let one shift a
|
|
79
|
+
// boundary. Percent is escaped first so the encoding is injective: a value
|
|
80
|
+
// that already contains "%7C" cannot collide with one that contains "|".
|
|
81
|
+
return value.replace(/%/g, '%25').replace(/\|/g, '%7C');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function computeMac(key: Buffer, exp: number, nonce: string, binding: PreviewBinding): string {
|
|
85
|
+
const payload = [
|
|
86
|
+
MAC_DOMAIN,
|
|
87
|
+
String(exp),
|
|
88
|
+
nonce,
|
|
89
|
+
field(binding.userId),
|
|
90
|
+
field(binding.tool),
|
|
91
|
+
field(binding.action),
|
|
92
|
+
canonicalJson(binding.args),
|
|
93
|
+
].join('|');
|
|
94
|
+
return createHmac('sha256', key).update(payload).digest('base64url');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function createPreviewToken(
|
|
98
|
+
key: Buffer,
|
|
99
|
+
binding: PreviewBinding,
|
|
100
|
+
options: { ttlSeconds: number; nowMs?: number; nonce?: string },
|
|
101
|
+
): string {
|
|
102
|
+
const now = Math.floor((options.nowMs ?? Date.now()) / 1000);
|
|
103
|
+
const exp = now + Math.max(1, Math.floor(options.ttlSeconds));
|
|
104
|
+
const nonce = options.nonce ?? randomBytes(16).toString('base64url');
|
|
105
|
+
const mac = computeMac(key, exp, nonce, binding);
|
|
106
|
+
return `${PREVIEW_TOKEN_VERSION}.${exp}.${nonce}.${mac}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function parsePreviewToken(token: unknown): { exp: number; nonce: string; mac: string } | null {
|
|
110
|
+
if (typeof token !== 'string') return null;
|
|
111
|
+
const parts = token.split('.');
|
|
112
|
+
if (parts.length !== 4 || parts[0] !== PREVIEW_TOKEN_VERSION) return null;
|
|
113
|
+
const [, expRaw, nonce, mac] = parts;
|
|
114
|
+
if (!/^\d{1,12}$/.test(expRaw) || !/^[A-Za-z0-9_-]{8,64}$/.test(nonce) || !/^[A-Za-z0-9_-]{20,100}$/.test(mac)) return null;
|
|
115
|
+
return { exp: Number(expRaw), nonce, mac };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function verifyPreviewToken(
|
|
119
|
+
key: Buffer,
|
|
120
|
+
token: unknown,
|
|
121
|
+
binding: PreviewBinding,
|
|
122
|
+
options: { nowMs?: number } = {},
|
|
123
|
+
): VerifyPreviewResult {
|
|
124
|
+
const parsed = parsePreviewToken(token);
|
|
125
|
+
if (!parsed) return { ok: false, reason: 'malformed' };
|
|
126
|
+
const expected = Buffer.from(computeMac(key, parsed.exp, parsed.nonce, binding));
|
|
127
|
+
const actual = Buffer.from(parsed.mac);
|
|
128
|
+
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
129
|
+
return { ok: false, reason: 'mismatch' };
|
|
130
|
+
}
|
|
131
|
+
const now = Math.floor((options.nowMs ?? Date.now()) / 1000);
|
|
132
|
+
if (parsed.exp <= now) return { ok: false, reason: 'expired' };
|
|
133
|
+
return { ok: true, exp: parsed.exp, nonce: parsed.nonce, remainingSeconds: parsed.exp - now };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Stable fingerprint of a token for one-time-use bookkeeping (never store the raw token). */
|
|
137
|
+
export function previewTokenFingerprint(token: string): string {
|
|
138
|
+
return createHash('sha256').update(token).digest('hex');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Plain-language refusal for each verification failure. */
|
|
142
|
+
export function previewFailureMessage(reason: 'malformed' | 'expired' | 'mismatch' | 'used' | 'missing' | 'unavailable'): string {
|
|
143
|
+
switch (reason) {
|
|
144
|
+
case 'missing':
|
|
145
|
+
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.';
|
|
146
|
+
case 'expired':
|
|
147
|
+
return 'This preview expired, so nothing was changed. Call again without confirm to get a fresh preview.';
|
|
148
|
+
case 'mismatch':
|
|
149
|
+
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.';
|
|
150
|
+
case 'used':
|
|
151
|
+
return 'This preview was already used, so nothing was changed. Call again without confirm to get a fresh preview.';
|
|
152
|
+
case 'unavailable':
|
|
153
|
+
return 'posterly could not check this preview right now, so nothing was changed. Please retry in a moment.';
|
|
154
|
+
default:
|
|
155
|
+
return 'preview_id is not valid, so nothing was changed. Call again without confirm to get a fresh preview.';
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
import {
|
|
11
|
+
createPreviewToken,
|
|
12
|
+
previewTokenFingerprint,
|
|
13
|
+
verifyPreviewToken,
|
|
14
|
+
type PreviewBinding,
|
|
15
|
+
type VerifyPreviewResult,
|
|
16
|
+
} from './preview-token-core.js';
|
|
17
|
+
|
|
18
|
+
export const STDIO_PREVIEW_TTL_SECONDS = 10 * 60;
|
|
19
|
+
|
|
20
|
+
export class StdioPreviewTokens {
|
|
21
|
+
private readonly key: Buffer;
|
|
22
|
+
private readonly used = new Map<string, number>();
|
|
23
|
+
|
|
24
|
+
constructor(key: Buffer = randomBytes(32), private readonly ttlSeconds = STDIO_PREVIEW_TTL_SECONDS) {
|
|
25
|
+
this.key = key;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
create(binding: PreviewBinding, nowMs?: number): string {
|
|
29
|
+
return createPreviewToken(this.key, binding, { ttlSeconds: this.ttlSeconds, nowMs });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
verify(token: unknown, binding: PreviewBinding, nowMs?: number): VerifyPreviewResult {
|
|
33
|
+
return verifyPreviewToken(this.key, token, binding, { nowMs });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Marks a verified token as used. Returns false when it was already used
|
|
38
|
+
* (a second confirm with the same preview_id). Call only after verify().
|
|
39
|
+
*/
|
|
40
|
+
claimOnce(token: string, expSeconds: number, nowMs = Date.now()): boolean {
|
|
41
|
+
this.sweep(nowMs);
|
|
42
|
+
const fingerprint = previewTokenFingerprint(token);
|
|
43
|
+
if (this.used.has(fingerprint)) return false;
|
|
44
|
+
this.used.set(fingerprint, expSeconds);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private sweep(nowMs: number): void {
|
|
49
|
+
const now = Math.floor(nowMs / 1000);
|
|
50
|
+
for (const [fingerprint, exp] of this.used) {
|
|
51
|
+
if (exp <= now) this.used.delete(fingerprint);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|