@sellable/mcp 0.1.902 → 0.1.903

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.
@@ -77,6 +77,28 @@ export type IntegrationsResultEnvelope = {
77
77
  guidance: string | null;
78
78
  result: unknown;
79
79
  };
80
+ /**
81
+ * A local Fly/Hermes path has no meaning inside Pipedream's cloud action runner.
82
+ * The live Gmail actions hand each `attachments[]` value to Nodemailer as
83
+ * `attachment.path`; Nodemailer accepts a data URI there, so a small PDF can cross
84
+ * the existing JSON call without a public bucket or a durable File Stash.
85
+ *
86
+ * Two MiB keeps the base64 body below the call route's dedicated 3 MiB ceiling.
87
+ * This is intentionally narrower than Gmail's own attachment limit: it is the
88
+ * bounded hot path for receipts and similar one-page PDFs, not a general file
89
+ * transfer service.
90
+ */
91
+ export declare const INTEGRATION_LOCAL_PDF_MAX_BYTES: number;
92
+ /**
93
+ * Materialize only the two live Gmail actions whose file-ref contract is known.
94
+ * Remote URLs/data URIs remain byte-identical. Endpoint-run effects are already
95
+ * authorized against their original argument hash, so they may not introduce a
96
+ * local path that would change after authorization.
97
+ */
98
+ export declare function materializeIntegrationLocalPdfAttachments(toolName: string, args: Record<string, unknown>, options: {
99
+ allowLocal: boolean;
100
+ env?: NodeJS.ProcessEnv;
101
+ }): Promise<Record<string, unknown>>;
80
102
  type IntegrationHomeWorkspacePin = {
81
103
  ok: true;
82
104
  workspaceId?: string;
@@ -1,3 +1,6 @@
1
+ import { lstat, open, realpath } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
1
4
  import { readMcpAgentServiceContext } from "../agent-service-auth.js";
2
5
  import { getApi, SellableApiError } from "../api.js";
3
6
  import { getConfig } from "../auth.js";
@@ -92,6 +95,152 @@ const CALL_PATH = "/api/v3/sellable-agent/integrations/tools/call";
92
95
  */
93
96
  const READ_TIMEOUT_MS = 25_000;
94
97
  const CALL_TIMEOUT_MS = 45_000;
98
+ /**
99
+ * A local Fly/Hermes path has no meaning inside Pipedream's cloud action runner.
100
+ * The live Gmail actions hand each `attachments[]` value to Nodemailer as
101
+ * `attachment.path`; Nodemailer accepts a data URI there, so a small PDF can cross
102
+ * the existing JSON call without a public bucket or a durable File Stash.
103
+ *
104
+ * Two MiB keeps the base64 body below the call route's dedicated 3 MiB ceiling.
105
+ * This is intentionally narrower than Gmail's own attachment limit: it is the
106
+ * bounded hot path for receipts and similar one-page PDFs, not a general file
107
+ * transfer service.
108
+ */
109
+ export const INTEGRATION_LOCAL_PDF_MAX_BYTES = 2 * 1024 * 1024;
110
+ const GMAIL_FILE_ATTACHMENT_TOOLS = new Set([
111
+ "gmail-create-draft",
112
+ "gmail-send-email",
113
+ ]);
114
+ const PDF_MAGIC = Object.freeze([0x25, 0x50, 0x44, 0x46, 0x2d]);
115
+ class IntegrationLocalAttachmentError extends Error {
116
+ constructor() {
117
+ super("integration_local_attachment_rejected");
118
+ this.name = "IntegrationLocalAttachmentError";
119
+ }
120
+ }
121
+ function localAttachmentRefusal() {
122
+ return envelope({
123
+ ok: false,
124
+ outcome: "refused",
125
+ attribution: "configuration",
126
+ error: "integration_local_attachment_rejected",
127
+ guidance: "Attach a regular PDF under the Agent's writable media directory or /tmp; local PDFs are limited to 2 MiB total.",
128
+ });
129
+ }
130
+ function withinRoot(root, candidate) {
131
+ const relative = path.relative(root, candidate);
132
+ return (relative === "" ||
133
+ (relative !== ".." &&
134
+ !relative.startsWith(`..${path.sep}`) &&
135
+ !path.isAbsolute(relative)));
136
+ }
137
+ async function allowedLocalAttachmentRoots(env) {
138
+ const candidates = [env.HERMES_WRITE_SAFE_ROOT?.trim(), os.tmpdir()].filter((value) => Boolean(value && path.isAbsolute(value)));
139
+ const roots = await Promise.all([...new Set(candidates)].map(async (candidate) => {
140
+ try {
141
+ return await realpath(candidate);
142
+ }
143
+ catch {
144
+ return null;
145
+ }
146
+ }));
147
+ return roots.filter((value) => Boolean(value));
148
+ }
149
+ async function localPdfDataUri(localPath, filename, remainingBytes, env) {
150
+ if (!path.isAbsolute(localPath) ||
151
+ filename.length < 1 ||
152
+ filename.length > 160 ||
153
+ path.basename(filename) !== filename ||
154
+ !filename.toLowerCase().endsWith(".pdf") ||
155
+ /[\u0000-\u001f\u007f]/.test(filename)) {
156
+ throw new IntegrationLocalAttachmentError();
157
+ }
158
+ const [entry, resolved, roots] = await Promise.all([
159
+ lstat(localPath),
160
+ realpath(localPath),
161
+ allowedLocalAttachmentRoots(env),
162
+ ]).catch(() => {
163
+ throw new IntegrationLocalAttachmentError();
164
+ });
165
+ if (entry.isSymbolicLink() ||
166
+ !roots.some((root) => withinRoot(root, resolved))) {
167
+ throw new IntegrationLocalAttachmentError();
168
+ }
169
+ const handle = await open(resolved, "r").catch(() => {
170
+ throw new IntegrationLocalAttachmentError();
171
+ });
172
+ let bytes;
173
+ try {
174
+ const opened = await handle.stat();
175
+ if (!entry.isFile() ||
176
+ !opened.isFile() ||
177
+ opened.dev !== entry.dev ||
178
+ opened.ino !== entry.ino ||
179
+ opened.size < PDF_MAGIC.length ||
180
+ opened.size > remainingBytes) {
181
+ throw new IntegrationLocalAttachmentError();
182
+ }
183
+ bytes = new Uint8Array(new ArrayBuffer(opened.size));
184
+ let offset = 0;
185
+ while (offset < opened.size) {
186
+ const chunk = await handle.read(bytes, offset, opened.size - offset, offset);
187
+ if (chunk.bytesRead < 1)
188
+ throw new IntegrationLocalAttachmentError();
189
+ offset += chunk.bytesRead;
190
+ }
191
+ }
192
+ catch (error) {
193
+ if (error instanceof IntegrationLocalAttachmentError)
194
+ throw error;
195
+ throw new IntegrationLocalAttachmentError();
196
+ }
197
+ finally {
198
+ await handle.close().catch(() => undefined);
199
+ }
200
+ if (!PDF_MAGIC.every((byte, index) => bytes[index] === byte)) {
201
+ throw new IntegrationLocalAttachmentError();
202
+ }
203
+ return {
204
+ dataUri: `data:application/pdf;base64,${Buffer.from(bytes).toString("base64")}`,
205
+ bytes: bytes.byteLength,
206
+ };
207
+ }
208
+ /**
209
+ * Materialize only the two live Gmail actions whose file-ref contract is known.
210
+ * Remote URLs/data URIs remain byte-identical. Endpoint-run effects are already
211
+ * authorized against their original argument hash, so they may not introduce a
212
+ * local path that would change after authorization.
213
+ */
214
+ export async function materializeIntegrationLocalPdfAttachments(toolName, args, options) {
215
+ if (!GMAIL_FILE_ATTACHMENT_TOOLS.has(toolName))
216
+ return args;
217
+ const attachments = args.attachments;
218
+ if (!Array.isArray(attachments) || attachments.length === 0)
219
+ return args;
220
+ const filenames = args.attachmentFilenames;
221
+ if (!Array.isArray(filenames) || filenames.length !== attachments.length) {
222
+ throw new IntegrationLocalAttachmentError();
223
+ }
224
+ let remainingBytes = INTEGRATION_LOCAL_PDF_MAX_BYTES;
225
+ const materialized = [];
226
+ for (let index = 0; index < attachments.length; index += 1) {
227
+ const attachment = attachments[index];
228
+ const filename = filenames[index];
229
+ if (typeof attachment !== "string" || typeof filename !== "string") {
230
+ throw new IntegrationLocalAttachmentError();
231
+ }
232
+ if (/^(?:https?:|data:)/i.test(attachment)) {
233
+ materialized.push(attachment);
234
+ continue;
235
+ }
236
+ if (!options.allowLocal)
237
+ throw new IntegrationLocalAttachmentError();
238
+ const converted = await localPdfDataUri(attachment, filename, remainingBytes, options.env ?? process.env);
239
+ materialized.push(converted.dataUri);
240
+ remainingBytes -= converted.bytes;
241
+ }
242
+ return { ...args, attachments: materialized };
243
+ }
95
244
  /**
96
245
  * Mirror the home-workspace consistency invariant owned by
97
246
  * `readMcpAgentServiceContext`. Customer profiles prove home with the customer
@@ -604,17 +753,20 @@ export async function integrationsCallTool(input = {}, actor, agentEffectId) {
604
753
  return home.envelope;
605
754
  try {
606
755
  const endpointContext = isEndpointIntegrationContext(actor);
756
+ const callArguments = args
757
+ ? await materializeIntegrationLocalPdfAttachments(tool.toolName, args, { allowLocal: !endpointContext })
758
+ : null;
607
759
  const body = endpointContext
608
760
  ? {
609
761
  ...selector.selector,
610
762
  toolName: tool.toolName,
611
- ...(args ? { arguments: args } : {}),
763
+ ...(callArguments ? { arguments: callArguments } : {}),
612
764
  ...trustedIntegrationContextBody(actor, "DISPATCH"),
613
765
  }
614
766
  : {
615
767
  ...selector.selector,
616
768
  toolName: tool.toolName,
617
- ...(args ? { arguments: args } : {}),
769
+ ...(callArguments ? { arguments: callArguments } : {}),
618
770
  ...(typeof input.approvalId === "string" && input.approvalId
619
771
  ? { approvalId: input.approvalId }
620
772
  : {}),
@@ -628,6 +780,9 @@ export async function integrationsCallTool(input = {}, actor, agentEffectId) {
628
780
  return normalizeRouteResult(response);
629
781
  }
630
782
  catch (error) {
783
+ if (error instanceof IntegrationLocalAttachmentError) {
784
+ return localAttachmentRefusal();
785
+ }
631
786
  return toolError("call_tool", error);
632
787
  }
633
788
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.902",
3
+ "version": "0.1.903",
4
4
  "type": "module",
5
5
  "description": "Sellable MCP server for Claude Code, Codex, and Hermes campaign workflows",
6
6
  "main": "dist/index.js",