@sellable/mcp 0.1.901 → 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.
@@ -16,7 +16,7 @@ export type WaitForCliLoginInput = {
16
16
  /** Test-only: override poll interval. Not exposed in inputSchema. */
17
17
  pollIntervalMs?: number;
18
18
  };
19
- type CliLoginErrorType = "rate_limited" | "send_failed" | "expired" | "already_consumed" | "timeout" | "tool_timeout_guard" | "network_error" | "not_implemented";
19
+ type CliLoginErrorType = "rate_limited" | "send_failed" | "expired" | "already_consumed" | "timeout" | "tool_timeout_guard" | "network_error" | "managed_agent_service_credential" | "not_implemented";
20
20
  type CliLoginErrorPayload = {
21
21
  ok: false;
22
22
  error: {
@@ -1,4 +1,4 @@
1
- import { writeNewConfig } from "../auth.js";
1
+ import { getConfiguredCredentialIntent, writeNewConfig } from "../auth.js";
2
2
  /**
3
3
  * Sellable CLI login tools.
4
4
  *
@@ -58,6 +58,13 @@ function resolveApiUrl() {
58
58
  process.env.SELLABLE_BASE_URL ??
59
59
  "https://app.sellable.dev");
60
60
  }
61
+ function managedAgentAlreadyAuthenticated() {
62
+ return (process.env.SELLABLE_AGENT_RUNTIME === "1" &&
63
+ getConfiguredCredentialIntent() === "agent_service");
64
+ }
65
+ function managedAgentLoginRefusal() {
66
+ return errorPayload("managed_agent_service_credential", "This managed Agent is already authenticated.", "Use the connected integration directly. A browser login would replace the Agent's scoped service credential and is not allowed.");
67
+ }
61
68
  export const startCliLoginToolDef = {
62
69
  name: "start_cli_login",
63
70
  description: "Start the CLI login handoff. Triggers a magic-link email to the supplied email address. " +
@@ -97,6 +104,9 @@ export const waitForCliLoginToolDef = {
97
104
  },
98
105
  };
99
106
  export async function handleStartCliLogin(args) {
107
+ if (managedAgentAlreadyAuthenticated()) {
108
+ return managedAgentLoginRefusal();
109
+ }
100
110
  const apiUrl = resolveApiUrl();
101
111
  let res;
102
112
  try {
@@ -174,6 +184,9 @@ export async function handleWaitForCliLogin(args) {
174
184
  continue;
175
185
  }
176
186
  if (body.status === "ready") {
187
+ if (managedAgentAlreadyAuthenticated()) {
188
+ return managedAgentLoginRefusal();
189
+ }
177
190
  const { configPath } = writeNewConfig({
178
191
  token: body.token,
179
192
  activeWorkspaceId: body.activeWorkspaceId,
@@ -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
@@ -364,6 +513,9 @@ export function toolError(action, error) {
364
513
  catch {
365
514
  // Plain-text body; fall through to the generic shape below.
366
515
  }
516
+ const structuredRouteRefusal = typeof forwarded.error === "string" ||
517
+ typeof forwarded.outcome === "string" ||
518
+ typeof forwarded.attribution === "string";
367
519
  return envelope({
368
520
  ok: false,
369
521
  outcome: typeof forwarded.outcome === "string" ? forwarded.outcome : "refused",
@@ -377,7 +529,9 @@ export function toolError(action, error) {
377
529
  : `integration_${action}_failed`,
378
530
  guidance: typeof forwarded.guidance === "string"
379
531
  ? forwarded.guidance
380
- : (error.guidance ?? null),
532
+ : structuredRouteRefusal
533
+ ? null
534
+ : (error.guidance ?? null),
381
535
  result: forwarded.detail ?? null,
382
536
  });
383
537
  }
@@ -599,17 +753,20 @@ export async function integrationsCallTool(input = {}, actor, agentEffectId) {
599
753
  return home.envelope;
600
754
  try {
601
755
  const endpointContext = isEndpointIntegrationContext(actor);
756
+ const callArguments = args
757
+ ? await materializeIntegrationLocalPdfAttachments(tool.toolName, args, { allowLocal: !endpointContext })
758
+ : null;
602
759
  const body = endpointContext
603
760
  ? {
604
761
  ...selector.selector,
605
762
  toolName: tool.toolName,
606
- ...(args ? { arguments: args } : {}),
763
+ ...(callArguments ? { arguments: callArguments } : {}),
607
764
  ...trustedIntegrationContextBody(actor, "DISPATCH"),
608
765
  }
609
766
  : {
610
767
  ...selector.selector,
611
768
  toolName: tool.toolName,
612
- ...(args ? { arguments: args } : {}),
769
+ ...(callArguments ? { arguments: callArguments } : {}),
613
770
  ...(typeof input.approvalId === "string" && input.approvalId
614
771
  ? { approvalId: input.approvalId }
615
772
  : {}),
@@ -623,6 +780,9 @@ export async function integrationsCallTool(input = {}, actor, agentEffectId) {
623
780
  return normalizeRouteResult(response);
624
781
  }
625
782
  catch (error) {
783
+ if (error instanceof IntegrationLocalAttachmentError) {
784
+ return localAttachmentRefusal();
785
+ }
626
786
  return toolError("call_tool", error);
627
787
  }
628
788
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sellable/mcp",
3
- "version": "0.1.901",
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",