@webhooks-cc/mcp 1.13.0 → 1.15.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.
package/dist/bin/mcp.js CHANGED
@@ -418,28 +418,75 @@ var methodSchema = import_zod2.z.enum(HTTP_METHODS).default("POST").describe("HT
418
418
  var kindSchema = import_zod2.z.enum(["http", "email"]).optional().describe('Only HTTP requests ("http") or only captured emails ("email")');
419
419
  var EMAIL_SCAN_LIMIT = 100;
420
420
  var durationOrTimestampSchema = import_zod2.z.union([import_zod2.z.string(), import_zod2.z.number()]);
421
- var ruleConditionSchema = import_zod2.z.object({
422
- field: import_zod2.z.enum(["method", "path", "header", "body_contains", "body_path", "query"]).describe("Request field to match against"),
423
- op: import_zod2.z.enum(["eq", "contains", "starts_with", "matches", "exists"]).describe("Comparison operator"),
424
- value: import_zod2.z.string().max(import_sdk.MAX_CONDITION_VALUE_LEN).optional().describe(
425
- `Value to compare against (required unless op is "exists", max ${import_sdk.MAX_CONDITION_VALUE_LEN} chars)`
421
+ var CONDITION_FIELD_NAMES = Object.keys(import_sdk.CONDITION_FIELDS);
422
+ var CONDITION_OP_NAMES = import_sdk.CONDITION_OPS;
423
+ function conditionSchema(use) {
424
+ const fields = use === "response" ? CONDITION_FIELD_NAMES.filter((field) => import_sdk.CONDITION_FIELDS[field].response) : CONDITION_FIELD_NAMES;
425
+ return import_zod2.z.object({
426
+ field: import_zod2.z.enum(fields).describe(
427
+ use === "response" ? "What to read: method, path, header (name), query (name), body (text), body_path (path into a JSON or form body), content_type, size" : "What to read: method, path, header (name), query (name), body, body_path (path), content_type, size, kind (http or email), email (name: subject, from, to, text or tag), spam_score, email_auth (name: spf, dkim or dmarc), signature (verified, failed or missing)"
428
+ ),
429
+ op: import_zod2.z.enum(CONDITION_OP_NAMES).describe(
430
+ "eq, neq, contains, not_contains, starts_with, ends_with, matches (glob: * one path segment, ** any, ? one character), regex (RE2, searched anywhere; (?i) ignores case), exists, not_exists, gt, gte, lt, lte (numbers)"
431
+ ),
432
+ value: import_zod2.z.string().max(import_sdk.MAX_CONDITION_VALUE_LEN).optional().describe(`What to compare with (not for exists and not_exists)`),
433
+ name: import_zod2.z.string().max(import_sdk.MAX_CONDITION_NAME_LEN).optional().describe("Header or query parameter name; the email part; spf, dkim or dmarc"),
434
+ path: import_zod2.z.string().max(import_sdk.MAX_CONDITION_PATH_LEN).optional().describe('Dot path for body_path, e.g. data.object.status or items.0.sku or a["odd.key"]')
435
+ }).superRefine((condition, ctx) => {
436
+ for (const issue of (0, import_sdk.conditionIssues)(condition, { use })) {
437
+ ctx.addIssue({ code: "custom", message: issue });
438
+ }
439
+ });
440
+ }
441
+ var ruleConditionSchema = conditionSchema("response");
442
+ var destinationFields = {
443
+ name: import_zod2.z.string().max(100).nullable().optional().describe("A name for the list and the log"),
444
+ enabled: import_zod2.z.boolean().optional().describe("Send to it (default true)"),
445
+ http: import_zod2.z.boolean().optional().describe("Takes HTTP requests (default true)"),
446
+ email: import_zod2.z.boolean().optional().describe("Takes emails (default true)"),
447
+ filter: import_zod2.z.object({
448
+ logic: import_zod2.z.enum(["and", "or"]).optional().describe("and (default): every condition; or: any"),
449
+ conditions: import_zod2.z.array(conditionSchema("filter")).max(10)
450
+ }).nullable().optional().describe(
451
+ "Send only captures that match (null: everything it takes). Conditions like response rules plus kind, email (name: subject, from, to, text, tag), spam_score, email_auth (name: spf, dkim, dmarc) and signature (verified, failed, missing)"
426
452
  ),
427
- name: import_zod2.z.string().max(import_sdk.MAX_CONDITION_NAME_LEN).optional().describe(
428
- `Header name or query param name (required for header/query, max ${import_sdk.MAX_CONDITION_NAME_LEN} chars)`
453
+ format: import_zod2.z.enum([
454
+ "auto",
455
+ "as_received",
456
+ "json",
457
+ "template",
458
+ "chat",
459
+ "postmark",
460
+ "sendgrid",
461
+ "mailgun",
462
+ "cloudmailin"
463
+ ]).optional().describe(
464
+ "auto (pick from the URL), as_received (relay HTTP requests unchanged; emails as signed JSON), json (signed JSON envelope), template (your bodyTemplate, signed like json), chat (Slack or Discord message), or an inbound mail provider's shape for emails (postmark, sendgrid, mailgun, cloudmailin; HTTP requests then go as received), to test the inbound handler you wrote for that provider"
429
465
  ),
430
- path: import_zod2.z.string().max(import_sdk.MAX_CONDITION_PATH_LEN).optional().describe(
431
- `JSON dot-notation path (required for body_path, max ${import_sdk.MAX_CONDITION_PATH_LEN} chars)`
432
- )
433
- }).refine((c) => c.op === "exists" || c.value !== void 0 && c.value.length > 0, {
434
- message: 'value is required when op is not "exists"'
435
- }).refine(
436
- (c) => !(c.field === "header" || c.field === "query") || c.name !== void 0 && c.name.length > 0,
437
- { message: "name is required for header and query conditions" }
438
- ).refine((c) => c.field !== "body_path" || c.path !== void 0 && c.path.length > 0, {
439
- message: "path is required for body_path conditions"
440
- }).refine((c) => c.op !== "matches" || !c.value || c.value.length <= import_sdk.MAX_GLOB_PATTERN_LEN, {
441
- message: `matches pattern must be ${import_sdk.MAX_GLOB_PATTERN_LEN} chars or less`
442
- });
466
+ mailgunSigningKey: import_zod2.z.string().max(200).nullable().optional().describe(
467
+ "For format mailgun: your Mailgun HTTP webhook signing key, so the form's signature verifies; null removes it. Write-only."
468
+ ),
469
+ bodyTemplate: import_zod2.z.unknown().optional().describe(
470
+ `For format template: any JSON value whose strings hold {{ placeholders }}, e.g. {"event": "{{ json.type }}", "amount": "{{ json.data.amount }}", "code": "{{ codes.0 }}"}. A string that is only a placeholder keeps the value's type; a missing field becomes null. HTTP requests read method, path, query.x, headers.x, body, json.path, id, receivedAt, signature; emails read the email.received fields (subject, from.address, to, text, codes, links, auth, spam, tag)`
471
+ ),
472
+ messageTemplate: import_zod2.z.string().max(4e3).nullable().optional().describe(
473
+ "For format chat: the message text with {{ placeholders }}; null sends the standard message"
474
+ ),
475
+ headers: import_zod2.z.array(
476
+ import_zod2.z.object({
477
+ name: import_zod2.z.string().describe("Header name, e.g. Authorization"),
478
+ value: import_zod2.z.string().nullable().describe("Value, or null to keep the stored one")
479
+ })
480
+ ).max(10).optional().describe("Your own headers for every delivery (not chat). Replaces the whole list"),
481
+ appendPath: import_zod2.z.boolean().optional().describe("Append the captured path to the URL (as_received)"),
482
+ retrySeconds: import_zod2.z.union([import_zod2.z.literal(0), import_zod2.z.literal(3600), import_zod2.z.literal(86400)]).optional().describe("Retry failed deliveries for 86400 (default), 3600 or 0 seconds"),
483
+ keepOrder: import_zod2.z.boolean().optional().describe("One delivery at a time, in capture order"),
484
+ sentField: import_zod2.z.string().max(128).nullable().optional().describe("JSON body field with the sender's own timestamp"),
485
+ throttleSeconds: import_zod2.z.number().int().min(0).max(3600).optional().describe(
486
+ "At most one delivery every N seconds; the rest are not sent (1 makes a heads-up channel)"
487
+ ),
488
+ delaySeconds: import_zod2.z.number().int().min(0).max(3600).optional().describe("Wait this long after the capture before sending, like a callback")
489
+ };
443
490
  var responseRuleSchema = import_zod2.z.object({
444
491
  name: import_zod2.z.string().max(import_sdk.MAX_RULE_NAME_LEN).optional().describe(`Human-readable rule name (max ${import_sdk.MAX_RULE_NAME_LEN} chars)`),
445
492
  enabled: import_zod2.z.boolean().optional().default(true).describe("Whether this rule is active"),
@@ -450,8 +497,10 @@ var responseRuleSchema = import_zod2.z.object({
450
497
  var responseRulesSchema = import_zod2.z.array(responseRuleSchema).max(50).describe("Ordered conditional response rules (first match wins, max 50)");
451
498
  var mockResponseSchema = import_zod2.z.object({
452
499
  status: import_zod2.z.number().int().min(import_sdk.MOCK_RESPONSE_STATUS_MIN).max(import_sdk.MOCK_RESPONSE_STATUS_MAX).describe(`HTTP status code (${import_sdk.MOCK_RESPONSE_STATUS_MIN}-${import_sdk.MOCK_RESPONSE_STATUS_MAX})`),
453
- body: import_zod2.z.string().default("").describe("Response body string (default: empty)"),
454
- headers: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.string()).default({}).describe("Response headers (default: none)"),
500
+ body: import_zod2.z.string().default("").describe(
501
+ 'Response body (default: empty). {{ placeholders }} are filled in from the request: {{ json.id }} (a JSON or form body field), {{ body }}, {{ headers.user-agent }}, {{ query.page }}, {{ method }}, {{ path }}, {{ id }}, {{ uuid }}, {{ now }}; filters: | default: "x", | json, | lower, | upper, | truncate: 80'
502
+ ),
503
+ headers: import_zod2.z.record(import_zod2.z.string(), import_zod2.z.string()).default({}).describe("Response headers (default: none); values may use {{ placeholders }} too"),
455
504
  delay: import_zod2.z.number().int().min(import_sdk.MOCK_RESPONSE_DELAY_MIN).max(import_sdk.MOCK_RESPONSE_DELAY_MAX).optional().describe(
456
505
  `Response delay in milliseconds (${import_sdk.MOCK_RESPONSE_DELAY_MIN}-${import_sdk.MOCK_RESPONSE_DELAY_MAX}, default: none)`
457
506
  )
@@ -705,7 +754,9 @@ async function waitForMultipleRequests(client, endpointSlug, options) {
705
754
  };
706
755
  }
707
756
  }
708
- await sleep(Math.max(10, pollIntervalMs));
757
+ await sleep(
758
+ Math.min(Math.max(10, pollIntervalMs), Math.max(0, timeoutMs - (Date.now() - startedAt)))
759
+ );
709
760
  }
710
761
  return {
711
762
  requests,
@@ -806,6 +857,8 @@ var ESSENTIAL_DETAIL_FIELDS = [
806
857
  "tag",
807
858
  "subject",
808
859
  "from",
860
+ "fields",
861
+ "replyText",
809
862
  "size",
810
863
  "test",
811
864
  "spam",
@@ -815,6 +868,11 @@ var ESSENTIAL_DETAIL_FIELDS = [
815
868
  "html",
816
869
  "htmlTruncated"
817
870
  ];
871
+ function shrinkRendered(rendered, max) {
872
+ const body = typeof rendered.body === "string" ? rendered.body : "";
873
+ if (body.length <= max - 1024) return rendered;
874
+ return { ...rendered, body: body.slice(0, max - 1024), bodyTruncated: true };
875
+ }
818
876
  function emailDetail(client, email, view, includeHtml) {
819
877
  const { data } = client.emails.toJson(email, {
820
878
  endpoint: view.endpoint,
@@ -856,7 +914,67 @@ function emailDetail(client, email, view, includeHtml) {
856
914
  ]);
857
915
  return detail;
858
916
  }
859
- function registerTools(server, client) {
917
+ var LOCAL_ONLY_TOOLS = /* @__PURE__ */ new Set(["send_to", "replay_request"]);
918
+ var TARGET_URL_TOOLS = /* @__PURE__ */ new Set(["render_email", "test_webhook_flow"]);
919
+ var REMOTE_MAX_WAIT_MS = 5e4;
920
+ var REMOTE_MIN_POLL_MS = 1e3;
921
+ function durationMs(value) {
922
+ if (typeof value === "number") return Number.isFinite(value) && value >= 0 ? value : null;
923
+ if (typeof value !== "string") return null;
924
+ try {
925
+ const ms = (0, import_sdk.parseDuration)(value);
926
+ return Number.isFinite(ms) && ms >= 0 ? ms : null;
927
+ } catch {
928
+ return null;
929
+ }
930
+ }
931
+ function toolTarget(target, remote, handles) {
932
+ return {
933
+ tool(name, description, shape, handler) {
934
+ if (remote && LOCAL_ONLY_TOOLS.has(name)) return { remove() {
935
+ } };
936
+ const wrapped = remote ? async (args2) => {
937
+ const input = args2;
938
+ if (TARGET_URL_TOOLS.has(name) && input.targetUrl !== void 0) {
939
+ return {
940
+ ...textContent(
941
+ "targetUrl needs the local MCP server (npx -y @webhooks-cc/mcp): the hosted server cannot reach your machine. Leave it out here."
942
+ ),
943
+ isError: true
944
+ };
945
+ }
946
+ const bounded = { ...input };
947
+ if ("pollInterval" in shape && input.pollInterval === void 0) {
948
+ bounded.pollInterval = REMOTE_MIN_POLL_MS;
949
+ }
950
+ for (const [key, bound] of [
951
+ ["timeout", (ms) => Math.min(ms, REMOTE_MAX_WAIT_MS)],
952
+ [
953
+ "pollInterval",
954
+ (ms) => Math.min(Math.max(ms, REMOTE_MIN_POLL_MS), REMOTE_MAX_WAIT_MS)
955
+ ]
956
+ ]) {
957
+ if (bounded[key] === void 0) continue;
958
+ const ms = durationMs(bounded[key]);
959
+ if (ms === null) {
960
+ return {
961
+ ...textContent(`${key} must be a duration such as "30s" or milliseconds.`),
962
+ isError: true
963
+ };
964
+ }
965
+ bounded[key] = bound(ms);
966
+ }
967
+ return handler(bounded);
968
+ } : handler;
969
+ const handle = target.tool(name, description, shape, wrapped);
970
+ handles.push(handle);
971
+ return handle;
972
+ }
973
+ };
974
+ }
975
+ function registerTools(target, client, options = {}) {
976
+ const handles = [];
977
+ const server = toolTarget(target, options.remote === true, handles);
860
978
  server.tool(
861
979
  "create_endpoint",
862
980
  "Create a webhook endpoint. Returns the endpoint slug, URL, and metadata.",
@@ -977,7 +1095,7 @@ function registerTools(server, client) {
977
1095
  );
978
1096
  server.tool(
979
1097
  "update_endpoint",
980
- "Update an endpoint name, mock response, conditional response rules, signing configuration, or whether codes and links are picked out of its emails.",
1098
+ "Update an endpoint name, mock response, conditional response rules, signing configuration, whether codes and links are picked out of its emails, or the named fields picked out of them.",
981
1099
  {
982
1100
  slug: import_zod2.z.string().describe("The endpoint slug to update"),
983
1101
  name: import_zod2.z.string().optional().describe("New display name"),
@@ -991,7 +1109,17 @@ function registerTools(server, client) {
991
1109
  signingHeader: import_zod2.z.string().nullable().optional().describe(
992
1110
  "Custom signature header name (only for generic-hmac provider), or null to clear"
993
1111
  ),
994
- showEmailExtracts: import_zod2.z.boolean().optional().describe("Pick codes and links out of emails (dashboard and forwarded JSON). Owner only.")
1112
+ showEmailExtracts: import_zod2.z.boolean().optional().describe("Pick codes and links out of emails (dashboard and forwarded JSON). Owner only."),
1113
+ emailFields: import_zod2.z.array(
1114
+ import_zod2.z.object({
1115
+ name: import_zod2.z.string().describe("Letters, digits and _, starting with a letter: {{ fields.name }}"),
1116
+ from: import_zod2.z.enum(["subject", "text", "html", "from", "to", "header"]).describe("What it reads; html is the HTML as text"),
1117
+ header: import_zod2.z.string().optional().describe('The header name, for from "header"'),
1118
+ pattern: import_zod2.z.string().describe("RE2 regular expression; the first group is the value (no lookarounds)")
1119
+ })
1120
+ ).max(20).nullable().optional().describe(
1121
+ "Named fields picked out of every email: shown with it, `fields` in its JSON, read by forwarding templates and filters (email_field). Replaces the list; null or [] removes them. Owner only."
1122
+ )
995
1123
  },
996
1124
  withErrorHandling(
997
1125
  async ({
@@ -1003,7 +1131,8 @@ function registerTools(server, client) {
1003
1131
  signingProvider,
1004
1132
  signingSecret,
1005
1133
  signingHeader,
1006
- showEmailExtracts
1134
+ showEmailExtracts,
1135
+ emailFields
1007
1136
  }) => {
1008
1137
  const endpoint = await client.endpoints.update(slug, {
1009
1138
  name,
@@ -1013,12 +1142,33 @@ function registerTools(server, client) {
1013
1142
  signingProvider,
1014
1143
  signingSecret,
1015
1144
  signingHeader,
1016
- showEmailExtracts
1145
+ showEmailExtracts,
1146
+ emailFields
1017
1147
  });
1018
1148
  return jsonContent(endpoint);
1019
1149
  }
1020
1150
  )
1021
1151
  );
1152
+ server.tool(
1153
+ "preview_response",
1154
+ "Dry run of an endpoint's responses: which response rule answers a captured request, and the response with its {{ placeholders }} filled in, exactly as the receiver would send it. Nothing is sent. Pass mockResponse or responseRules to try settings before saving them with update_endpoint.",
1155
+ {
1156
+ slug: import_zod2.z.string().describe("The endpoint slug"),
1157
+ requestId: import_zod2.z.string().optional().describe(
1158
+ "A captured request on the endpoint (default: the newest HTTP request, or a sample)"
1159
+ ),
1160
+ mockResponse: mockResponseSchema.nullable().optional().describe("Try this default response instead of the saved one (null: none)"),
1161
+ responseRules: responseRulesSchema.nullable().optional().describe("Try these rules instead of the saved ones (null: none)")
1162
+ },
1163
+ withErrorHandling(async ({ slug, requestId, mockResponse, responseRules }) => {
1164
+ const preview = await client.endpoints.previewResponse(slug, {
1165
+ ...requestId ? { requestId } : {},
1166
+ ...mockResponse !== void 0 ? { mockResponse } : {},
1167
+ ...responseRules !== void 0 ? { responseRules } : {}
1168
+ });
1169
+ return jsonContent(preview);
1170
+ })
1171
+ );
1022
1172
  server.tool(
1023
1173
  "delete_endpoint",
1024
1174
  "Delete a webhook endpoint and all its captured requests.",
@@ -1234,6 +1384,39 @@ function registerTools(server, client) {
1234
1384
  });
1235
1385
  })
1236
1386
  );
1387
+ server.tool(
1388
+ "render_email",
1389
+ "A captured email in the shape an inbound mail provider posts it (postmark, sendgrid, mailgun, cloudmailin), to test the inbound handler you wrote for that provider against real mail. Returns the method, headers and body; with targetUrl (your local handler), posts it there and returns the answer. A Mailgun form is signed when you pass the signing key your handler checks.",
1390
+ {
1391
+ requestId: import_zod2.z.string().describe("The captured email's request ID"),
1392
+ format: import_zod2.z.enum(["postmark", "sendgrid", "mailgun", "cloudmailin", "json"]).describe("Whose shape; json is webhooks.cc's own email.received JSON (unsigned)"),
1393
+ mailgunSigningKey: import_zod2.z.string().optional().describe("For mailgun: the signing key your handler verifies with (a test key)"),
1394
+ targetUrl: httpUrlSchema.optional().describe("Post it here, e.g. http://localhost:3000/api/inbound/postmark")
1395
+ },
1396
+ withErrorHandling(async ({ requestId, format, mailgunSigningKey, targetUrl }) => {
1397
+ const rendered = await client.emails.render(requestId, {
1398
+ format,
1399
+ ...mailgunSigningKey ? { mailgunSigningKey } : {}
1400
+ });
1401
+ if (!targetUrl) {
1402
+ return jsonContent(
1403
+ shrinkRendered(rendered, MAX_BODY_SIZE)
1404
+ );
1405
+ }
1406
+ const response = await fetch(targetUrl, {
1407
+ method: "POST",
1408
+ headers: { "content-type": rendered.headers["content-type"] },
1409
+ body: rendered.body,
1410
+ redirect: "manual"
1411
+ });
1412
+ return jsonContent({
1413
+ format,
1414
+ status: response.status,
1415
+ statusText: response.statusText,
1416
+ body: await readBodyTruncated(response)
1417
+ });
1418
+ })
1419
+ );
1237
1420
  server.tool(
1238
1421
  "compare_requests",
1239
1422
  "Compare two captured requests and show the structured differences.",
@@ -1473,7 +1656,7 @@ function registerTools(server, client) {
1473
1656
  verifySignature: shouldVerify,
1474
1657
  cleanup
1475
1658
  }) => {
1476
- const flow = client.flow().createEndpoint({ expiresIn: "1h" }).waitForCapture({ timeout: "30s" });
1659
+ const flow = client.flow().createEndpoint({ expiresIn: "1h" }).waitForCapture({ timeout: "30s", pollInterval: "1s" });
1477
1660
  if (mockStatus !== void 0) {
1478
1661
  flow.setMock({
1479
1662
  status: mockStatus,
@@ -1598,10 +1781,14 @@ function registerTools(server, client) {
1598
1781
  includeHtml: import_zod2.z.boolean().default(false).describe("Include the HTML part (default: false)")
1599
1782
  },
1600
1783
  withErrorHandling(async ({ endpointSlug, tag, subject, from, timeout, since, includeHtml }) => {
1601
- const [email, view] = await Promise.all([
1602
- client.emails.waitFor(endpointSlug, { tag, subject, from, timeout, since }),
1603
- emailEndpointView(client, endpointSlug)
1604
- ]);
1784
+ const view = await emailEndpointView(client, endpointSlug);
1785
+ const email = await client.emails.waitFor(endpointSlug, {
1786
+ tag,
1787
+ subject,
1788
+ from,
1789
+ timeout,
1790
+ since
1791
+ });
1605
1792
  return jsonContent(emailDetail(client, email, view, includeHtml));
1606
1793
  })
1607
1794
  );
@@ -1617,9 +1804,79 @@ function registerTools(server, client) {
1617
1804
  return jsonContent({ ...result, address: client.emails.address(slug, tag) });
1618
1805
  })
1619
1806
  );
1807
+ server.tool(
1808
+ "list_destinations",
1809
+ "List where an endpoint's captures go: its destinations (up to 10) with their URL, kinds, filter, format, templates and timing, and with summary: true their last 24 hours. Owner only.",
1810
+ {
1811
+ slug: import_zod2.z.string().describe("The endpoint slug"),
1812
+ summary: import_zod2.z.boolean().default(true).describe("Add each destination's last 24 hours")
1813
+ },
1814
+ withErrorHandling(async ({ slug, summary }) => {
1815
+ return jsonContent(await client.destinations.list(slug, { summary }));
1816
+ })
1817
+ );
1818
+ server.tool(
1819
+ "add_destination",
1820
+ "Add a destination for an endpoint's captures: your server (as received or signed JSON), a Slack or Discord channel (chat, optionally your own messageTemplate, throttleSeconds 1 for a heads-up), or a JSON body you shape with bodyTemplate (for Zapier, n8n or your API). filter sends only matching captures; delaySeconds makes it a callback. Each destination gets its own signing secret. Check it with preview_destination before relying on it. Owner only.",
1821
+ {
1822
+ slug: import_zod2.z.string().describe("The endpoint slug"),
1823
+ url: httpUrlSchema.describe("https URL on a public host name, or a Slack or Discord webhook"),
1824
+ ...destinationFields
1825
+ },
1826
+ withErrorHandling(async ({ slug, ...input }) => {
1827
+ return jsonContent(await client.destinations.create(slug, input));
1828
+ })
1829
+ );
1830
+ server.tool(
1831
+ "update_destination",
1832
+ "Change a destination: any of its fields; only the ones you pass change. Owner only.",
1833
+ {
1834
+ slug: import_zod2.z.string().describe("The endpoint slug"),
1835
+ destinationId: import_zod2.z.string().describe("The destination's id (from list_destinations)"),
1836
+ url: httpUrlSchema.optional().describe("A new URL"),
1837
+ ...destinationFields
1838
+ },
1839
+ withErrorHandling(async ({ slug, destinationId, ...input }) => {
1840
+ return jsonContent(await client.destinations.update(slug, destinationId, input));
1841
+ })
1842
+ );
1843
+ server.tool(
1844
+ "remove_destination",
1845
+ "Remove a destination. Deliveries still waiting for it are marked failed; its log stays. Owner only.",
1846
+ {
1847
+ slug: import_zod2.z.string().describe("The endpoint slug"),
1848
+ destinationId: import_zod2.z.string().describe("The destination's id")
1849
+ },
1850
+ withErrorHandling(async ({ slug, destinationId }) => {
1851
+ await client.destinations.delete(slug, destinationId);
1852
+ return textContent(`Destination ${destinationId} removed.`);
1853
+ })
1854
+ );
1855
+ server.tool(
1856
+ "preview_destination",
1857
+ "Dry run of a destination against a captured request: whether its filter lets the request through and the exact request it would send (method, URL, headers, body with placeholders filled in). Nothing is sent. Use destinationId for a saved destination, destination for unsaved fields (on top of the saved ones, or with a url on their own), or both.",
1858
+ {
1859
+ slug: import_zod2.z.string().describe("The endpoint slug"),
1860
+ destinationId: import_zod2.z.string().optional().describe("A saved destination"),
1861
+ destination: import_zod2.z.object({ url: httpUrlSchema.optional(), ...destinationFields }).optional().describe("Unsaved fields to try"),
1862
+ requestId: import_zod2.z.string().optional().describe("A captured request (default: the newest of the kind it takes, or a sample)")
1863
+ },
1864
+ withErrorHandling(async ({ slug, destinationId, destination, requestId }) => {
1865
+ if (!destinationId && !destination?.url) {
1866
+ throw new Error("Pass destinationId, or a destination with a url");
1867
+ }
1868
+ return jsonContent(
1869
+ await client.destinations.preview(slug, {
1870
+ ...destinationId ? { destinationId } : {},
1871
+ ...destination ? { destination } : {},
1872
+ ...requestId ? { requestId } : {}
1873
+ })
1874
+ );
1875
+ })
1876
+ );
1620
1877
  server.tool(
1621
1878
  "configure_forwarding",
1622
- "Change an endpoint's forwarding: the URL, on or off, which kinds (HTTP requests, emails), the format, your own headers, and the advanced options. Only the arguments you pass change. Owner only. Saving the first URL creates the signing secret. A Slack or Discord webhook URL gets a chat message automatically.",
1879
+ "Change an endpoint's primary forwarding destination (the one the endpoint's forward* fields stand for): the URL, on or off, which kinds, the format, your own headers, and the advanced options. Only the arguments you pass change. Owner only. For several destinations, filters, templates, throttling or delays, use add_destination and update_destination.",
1623
1880
  {
1624
1881
  slug: import_zod2.z.string().describe("The endpoint slug"),
1625
1882
  url: httpUrlSchema.nullable().optional().describe("https URL on a public host name, or null to remove it (forwarding must be off)"),
@@ -1644,8 +1901,8 @@ function registerTools(server, client) {
1644
1901
  "JSON body field with the sender's own timestamp, e.g. publishedAt or data.publishedAt; null clears it"
1645
1902
  )
1646
1903
  },
1647
- withErrorHandling(async ({ slug, ...options }) => {
1648
- const endpoint = await client.forwarding.configure(slug, options);
1904
+ withErrorHandling(async ({ slug, ...options2 }) => {
1905
+ const endpoint = await client.forwarding.configure(slug, options2);
1649
1906
  return jsonContent({
1650
1907
  slug: endpoint.slug,
1651
1908
  forwardEnabled: endpoint.forwardEnabled ?? false,
@@ -1667,23 +1924,32 @@ function registerTools(server, client) {
1667
1924
  "Read (or with rotate: true, replace) the whsec_ secret forwarded deliveries are signed with. Owner only. Treat the value as a credential: put it in the handler's environment, not in code.",
1668
1925
  {
1669
1926
  slug: import_zod2.z.string().describe("The endpoint slug"),
1927
+ destinationId: import_zod2.z.string().optional().describe("A destination (default: the endpoint's primary destination)"),
1670
1928
  rotate: import_zod2.z.boolean().default(false).describe("Replace the secret; every delivery from now on uses the new one")
1671
1929
  },
1672
- withErrorHandling(async ({ slug, rotate }) => {
1673
- const secret = rotate ? await client.forwarding.rotateSecret(slug) : await client.forwarding.secret(slug);
1930
+ withErrorHandling(async ({ slug, destinationId, rotate }) => {
1931
+ const secret = destinationId ? rotate ? await client.destinations.rotateSecret(slug, destinationId) : await client.destinations.secret(slug, destinationId) : rotate ? await client.forwarding.rotateSecret(slug) : await client.forwarding.secret(slug);
1674
1932
  return jsonContent({ secret, rotated: rotate });
1675
1933
  })
1676
1934
  );
1677
1935
  server.tool(
1678
1936
  "test_forwarding",
1679
- "Send the endpoint's newest captured request of a kind (or a sample) to its forwarding URL once, exactly as a delivery would go out, and report the answer. Works with forwarding on or off; nothing is retried or logged.",
1937
+ "Send the endpoint's newest captured request of a kind (or a sample, or requestId) to a destination once, exactly as a delivery would go out, and report the answer. Works whether it is on or off, and whatever its filter says; nothing is retried or logged.",
1680
1938
  {
1681
1939
  slug: import_zod2.z.string().describe("The endpoint slug"),
1682
- kind: import_zod2.z.enum(["http", "email"]).optional().describe(
1683
- "Which kind to send (default: http when HTTP requests are forwarded, else email)"
1684
- )
1940
+ destinationId: import_zod2.z.string().optional().describe("A destination (default: the endpoint's primary destination)"),
1941
+ kind: import_zod2.z.enum(["http", "email"]).optional().describe("Which kind to send (default: http when the destination takes HTTP requests)"),
1942
+ requestId: import_zod2.z.string().optional().describe("With destinationId: a captured request to send")
1685
1943
  },
1686
- withErrorHandling(async ({ slug, kind }) => {
1944
+ withErrorHandling(async ({ slug, destinationId, kind, requestId }) => {
1945
+ if (destinationId) {
1946
+ return jsonContent(
1947
+ await client.destinations.test(slug, destinationId, {
1948
+ ...kind ? { kind } : {},
1949
+ ...requestId ? { requestId } : {}
1950
+ })
1951
+ );
1952
+ }
1687
1953
  return jsonContent(await client.forwarding.test(slug, kind ? { kind } : {}));
1688
1954
  })
1689
1955
  );
@@ -1694,17 +1960,25 @@ function registerTools(server, client) {
1694
1960
  endpointSlug: import_zod2.z.string().optional().describe("List the endpoint's delivery log"),
1695
1961
  requestId: import_zod2.z.string().optional().describe("List every delivery and try of this request"),
1696
1962
  limit: import_zod2.z.number().int().min(1).max(100).default(20).describe("With endpointSlug: max rows"),
1697
- status: import_zod2.z.enum(["all", "pending", "failed"]).default("all").describe("With endpointSlug: all, pending (queued or retrying) or failed"),
1963
+ status: import_zod2.z.enum(["all", "pending", "failed", "skipped"]).default("all").describe(
1964
+ "With endpointSlug: all, pending (queued or retrying), failed or skipped (filtered out)"
1965
+ ),
1966
+ destinationId: import_zod2.z.string().optional().describe("With endpointSlug: only this destination"),
1698
1967
  before: import_zod2.z.string().optional().describe("With endpointSlug: a row's cursor, to list older rows")
1699
1968
  },
1700
- withErrorHandling(async ({ endpointSlug, requestId, limit, status, before }) => {
1969
+ withErrorHandling(async ({ endpointSlug, requestId, limit, status, destinationId, before }) => {
1701
1970
  if (requestId === void 0 === (endpointSlug === void 0)) {
1702
1971
  throw new Error("Pass exactly one of endpointSlug or requestId");
1703
1972
  }
1704
1973
  if (requestId) return jsonContent(await client.forwarding.requestDeliveries(requestId));
1705
1974
  const [summary, deliveries] = await Promise.all([
1706
1975
  client.forwarding.summary(endpointSlug),
1707
- client.forwarding.deliveries(endpointSlug, { limit, status, before })
1976
+ client.forwarding.deliveries(endpointSlug, {
1977
+ limit,
1978
+ status,
1979
+ before,
1980
+ ...destinationId ? { destinationId } : {}
1981
+ })
1708
1982
  ]);
1709
1983
  return jsonContent({
1710
1984
  summary,
@@ -1715,17 +1989,19 @@ function registerTools(server, client) {
1715
1989
  );
1716
1990
  server.tool(
1717
1991
  "redeliver",
1718
- "Forward again with the endpoint's current settings: one captured request or email (requestId; it also sends one that was never forwarded), or every request whose latest delivery failed (endpointSlug, oldest first, up to 1,000 at a time). Owner only; fails while forwarding is off.",
1992
+ "Forward again with the current settings: one captured request or email (requestId; to every destination that takes it, or with destinationId to that one whatever its filter says), or every request whose latest delivery failed (endpointSlug, optionally only destinationId, oldest first, up to 1,000 at a time per destination). Owner only.",
1719
1993
  {
1720
1994
  requestId: import_zod2.z.string().optional().describe("The request or email to send again"),
1721
- endpointSlug: import_zod2.z.string().optional().describe("Send every failed delivery of this endpoint again")
1995
+ endpointSlug: import_zod2.z.string().optional().describe("Send every failed delivery of this endpoint again"),
1996
+ destinationId: import_zod2.z.string().optional().describe("Only this destination")
1722
1997
  },
1723
- withErrorHandling(async ({ requestId, endpointSlug }) => {
1998
+ withErrorHandling(async ({ requestId, endpointSlug, destinationId }) => {
1724
1999
  if (requestId === void 0 === (endpointSlug === void 0)) {
1725
2000
  throw new Error("Pass exactly one of requestId or endpointSlug");
1726
2001
  }
2002
+ const options2 = destinationId ? { destinationId } : {};
1727
2003
  return jsonContent(
1728
- requestId ? await client.forwarding.redeliver(requestId) : await client.forwarding.redeliverFailed(endpointSlug)
2004
+ requestId ? await client.forwarding.redeliver(requestId, options2) : await client.forwarding.redeliverFailed(endpointSlug, options2)
1729
2005
  );
1730
2006
  })
1731
2007
  );
@@ -1738,6 +2014,7 @@ function registerTools(server, client) {
1738
2014
  return jsonContent(description);
1739
2015
  })
1740
2016
  );
2017
+ return handles;
1741
2018
  }
1742
2019
 
1743
2020
  // src/sandbox-tools.ts
@@ -1749,7 +2026,6 @@ var SANDBOX = {
1749
2026
  };
1750
2027
  var DEFAULT_WAIT_SECONDS = 45;
1751
2028
  var MAX_WAIT_SECONDS = 600;
1752
- var TOKEN_REFRESH_MARGIN_MS = 6e4;
1753
2029
  var DOCS_URL = "https://webhooks.cc/docs/agents";
1754
2030
  var ACCOUNT_ONLY_OPTIONS = [
1755
2031
  "mockResponse",
@@ -1760,20 +2036,29 @@ var ACCOUNT_ONLY_OPTIONS = [
1760
2036
  "signingHeader"
1761
2037
  ];
1762
2038
  var AgentSession = class {
1763
- constructor(baseUrl, webhookUrl, clientName) {
2039
+ constructor(baseUrl, webhookUrl, clientName, credentialsFile) {
1764
2040
  this.baseUrl = baseUrl;
1765
2041
  this.webhookUrl = webhookUrl;
1766
2042
  this.clientName = clientName;
2043
+ this.credentialsFile = credentialsFile;
1767
2044
  this.sandbox = null;
1768
- this.claim = null;
1769
- this.connected = false;
2045
+ this.connect = null;
2046
+ }
2047
+ sandboxOptions() {
2048
+ return {
2049
+ baseUrl: this.baseUrl,
2050
+ webhookUrl: this.webhookUrl,
2051
+ clientName: this.clientName,
2052
+ credentialsFile: this.credentialsFile
2053
+ };
1770
2054
  }
1771
2055
  /**
1772
- * The sandbox, registered on first use and again after it expired. Tool
1773
- * calls can run in parallel: this.sandbox is read again after every await,
1774
- * and a new sandbox is assigned with no await in between, so parallel
1775
- * calls share one registration. Calls waiting on a registration that fails
1776
- * get its error (a 429 asks them to wait); the next call starts again.
2056
+ * The sandbox: the one saved by an earlier session when it is still live,
2057
+ * else a new registration (and again after it expired). Tool calls can run
2058
+ * in parallel: this.sandbox is read again after every await, and a new
2059
+ * sandbox is assigned with no await in between, so parallel calls share
2060
+ * one registration. Calls waiting on a registration that fails get its
2061
+ * error (a 429 asks them to wait); the next call starts again.
1777
2062
  */
1778
2063
  async sandboxClient() {
1779
2064
  while (this.sandbox) {
@@ -1781,35 +2066,36 @@ var AgentSession = class {
1781
2066
  const sandbox = await pending;
1782
2067
  if (!this.expired(pending, sandbox)) return sandbox;
1783
2068
  }
1784
- const created = import_sdk2.WebhooksCC.sandbox({
1785
- baseUrl: this.baseUrl,
1786
- webhookUrl: this.webhookUrl,
1787
- clientName: this.clientName
1788
- });
2069
+ const created = import_sdk2.WebhooksCC.sandbox(this.sandboxOptions());
1789
2070
  this.sandbox = created;
1790
2071
  created.catch(() => {
1791
2072
  if (this.sandbox === created) this.sandbox = null;
1792
2073
  });
1793
2074
  return created;
1794
2075
  }
1795
- /** The live sandbox, if one was registered; never registers one. */
2076
+ /** The live sandbox, from this session or the credential file; never registers one. */
1796
2077
  async existingSandbox() {
1797
2078
  const pending = this.sandbox;
1798
- if (!pending) return null;
1799
- let sandbox;
1800
- try {
1801
- sandbox = await pending;
1802
- } catch {
1803
- return null;
2079
+ if (pending) {
2080
+ let sandbox;
2081
+ try {
2082
+ sandbox = await pending;
2083
+ } catch {
2084
+ return null;
2085
+ }
2086
+ return this.expired(pending, sandbox) ? null : sandbox;
1804
2087
  }
1805
- return this.expired(pending, sandbox) ? null : sandbox;
2088
+ const resumed = await import_sdk2.SandboxClient.resume(this.sandboxOptions()).catch(() => null);
2089
+ if (!resumed) return null;
2090
+ if (!this.sandbox) this.sandbox = Promise.resolve(resumed);
2091
+ return this.sandbox;
1806
2092
  }
1807
2093
  /** True when the sandbox is past its 24 hours; then it is dropped. */
1808
2094
  expired(pending, sandbox) {
1809
2095
  if (sandbox.expiresAt.getTime() > Date.now()) return false;
1810
2096
  if (this.sandbox === pending) this.sandbox = null;
1811
- if (this.claim?.kind === "sandbox" && this.claim.claimToken === sandbox.registration.claimToken) {
1812
- this.claim = null;
2097
+ if (this.connect?.via === "sandbox" && this.connect.sandbox === sandbox) {
2098
+ this.connect = null;
1813
2099
  }
1814
2100
  return true;
1815
2101
  }
@@ -1820,26 +2106,11 @@ var AgentSession = class {
1820
2106
  }
1821
2107
  return sandbox;
1822
2108
  }
1823
- forgetSandbox() {
2109
+ reset() {
1824
2110
  this.sandbox = null;
2111
+ this.connect = null;
1825
2112
  }
1826
2113
  };
1827
- function claimedTokens(baseUrl, assertion, first) {
1828
- let current = first;
1829
- let pending = null;
1830
- return async (options) => {
1831
- if (!options?.forceRefresh && current.expiresAt.getTime() - Date.now() > TOKEN_REFRESH_MARGIN_MS) {
1832
- return current.accessToken;
1833
- }
1834
- if (!pending) {
1835
- pending = import_sdk2.WebhooksCC.agent.exchange({ baseUrl, assertion }).finally(() => {
1836
- pending = null;
1837
- });
1838
- }
1839
- current = await pending;
1840
- return current.accessToken;
1841
- };
1842
- }
1843
2114
  function minutesLeft(until) {
1844
2115
  return Math.max(1, Math.round((until.getTime() - Date.now()) / 6e4));
1845
2116
  }
@@ -1847,16 +2118,53 @@ function onlyKind(requests, kind) {
1847
2118
  if (!kind) return requests;
1848
2119
  return requests.filter((request) => (request.kind ?? "http") === kind);
1849
2120
  }
2121
+ function savedTo(credentialsFile) {
2122
+ return import_sdk2.AgentCredentialStore.resolve(credentialsFile)?.path ?? null;
2123
+ }
1850
2124
  function registerSandboxTools(server, options = {}) {
1851
2125
  const baseUrl = options.baseUrl ?? process.env.WHK_BASE_URL ?? "https://webhooks.cc";
2126
+ const webhookUrl = options.webhookUrl ?? process.env.WHK_WEBHOOK_URL;
1852
2127
  const session = new AgentSession(
1853
2128
  baseUrl,
1854
- options.webhookUrl ?? process.env.WHK_WEBHOOK_URL,
1855
- options.clientName ?? "webhooks-cc-mcp"
2129
+ webhookUrl,
2130
+ options.clientName ?? "webhooks-cc-mcp",
2131
+ options.credentialsFile
1856
2132
  );
1857
- const registered = [];
2133
+ let handles = [];
2134
+ let shown = 0;
2135
+ const swap = (register) => {
2136
+ const id = ++shown;
2137
+ for (const handle of handles) handle.remove();
2138
+ handles = register(id);
2139
+ };
2140
+ const showSandbox = () => swap(() => sandboxToolSet(server, session, showAccount));
2141
+ const savedAccount = (id) => options.credentialsFile === false ? null : import_sdk2.WebhooksCC.fromSavedConnection({
2142
+ baseUrl,
2143
+ webhookUrl,
2144
+ credentialsFile: options.credentialsFile,
2145
+ // The human disconnected the agent: back to the sandbox, unless a
2146
+ // newer connection is already on display.
2147
+ onRevoked: () => queueMicrotask(() => {
2148
+ if (shown === id) showSandbox();
2149
+ })
2150
+ });
2151
+ function showAccount(client) {
2152
+ swap((id) => registerTools(server, savedAccount(id) ?? client));
2153
+ options.onConnected?.(client);
2154
+ }
2155
+ const saved = savedAccount(1);
2156
+ if (saved) {
2157
+ swap(() => registerTools(server, saved));
2158
+ options.onConnected?.(saved);
2159
+ } else {
2160
+ showSandbox();
2161
+ }
2162
+ }
2163
+ function sandboxToolSet(server, session, showAccount) {
2164
+ const tools = [];
1858
2165
  const limits = `up to ${SANDBOX.maxEndpoints} endpoints at a time, ${SANDBOX.requestsPerEndpoint} captured requests each and ${SANDBOX.requests} in all, deleted ${SANDBOX.lifetimeHours} hours after the sandbox was created`;
1859
- registered.push(
2166
+ const file = savedTo(session.credentialsFile);
2167
+ tools.push(
1860
2168
  server.tool(
1861
2169
  "create_endpoint",
1862
2170
  `Create a temporary webhook capture endpoint in the agent sandbox (no account needed): ${limits}. The first call registers the sandbox, which takes a few seconds of proof of work. Mock responses, notifications and signature verification need an account: see connect_account.`,
@@ -1880,7 +2188,7 @@ function registerSandboxTools(server, options = {}) {
1880
2188
  })
1881
2189
  )
1882
2190
  );
1883
- registered.push(
2191
+ tools.push(
1884
2192
  server.tool(
1885
2193
  "list_endpoints",
1886
2194
  "List this agent's sandbox endpoints. Empty until create_endpoint starts the sandbox.",
@@ -1894,7 +2202,7 @@ function registerSandboxTools(server, options = {}) {
1894
2202
  })
1895
2203
  )
1896
2204
  );
1897
- registered.push(
2205
+ tools.push(
1898
2206
  server.tool(
1899
2207
  "get_endpoint",
1900
2208
  "Get one of this agent's sandbox endpoints by slug, with its sandbox limits and usage.",
@@ -1904,7 +2212,7 @@ function registerSandboxTools(server, options = {}) {
1904
2212
  })
1905
2213
  )
1906
2214
  );
1907
- registered.push(
2215
+ tools.push(
1908
2216
  server.tool(
1909
2217
  "delete_endpoint",
1910
2218
  "Delete one of this agent's sandbox endpoints and its captured requests. Frees a slot; the sandbox's request budget is not refunded.",
@@ -1915,7 +2223,7 @@ function registerSandboxTools(server, options = {}) {
1915
2223
  })
1916
2224
  )
1917
2225
  );
1918
- registered.push(
2226
+ tools.push(
1919
2227
  server.tool(
1920
2228
  "list_requests",
1921
2229
  "List recent requests captured by a sandbox endpoint, newest first.",
@@ -1927,7 +2235,7 @@ function registerSandboxTools(server, options = {}) {
1927
2235
  })
1928
2236
  )
1929
2237
  );
1930
- registered.push(
2238
+ tools.push(
1931
2239
  server.tool(
1932
2240
  "get_request",
1933
2241
  "Get full details for a request captured by a sandbox endpoint.",
@@ -1938,7 +2246,7 @@ function registerSandboxTools(server, options = {}) {
1938
2246
  })
1939
2247
  )
1940
2248
  );
1941
- registered.push(
2249
+ tools.push(
1942
2250
  server.tool(
1943
2251
  "wait_for_request",
1944
2252
  "Wait for a request to arrive at a sandbox endpoint.",
@@ -1950,7 +2258,7 @@ function registerSandboxTools(server, options = {}) {
1950
2258
  })
1951
2259
  )
1952
2260
  );
1953
- registered.push(
2261
+ tools.push(
1954
2262
  server.tool(
1955
2263
  "generate_payload_schema",
1956
2264
  "Generate TypeScript interfaces, Zod schemas, Pydantic models, or JSON Schema from the bodies of requests captured by a sandbox endpoint. Several requests of one event teach which fields are optional or nullable.",
@@ -1962,62 +2270,59 @@ function registerSandboxTools(server, options = {}) {
1962
2270
  })
1963
2271
  )
1964
2272
  );
1965
- registered.push(
2273
+ tools.push(
1966
2274
  server.tool(
1967
2275
  "connect_account",
1968
2276
  "Connect this agent to a human's webhooks.cc account, for the full tool set (mock responses, signatures, email, teams) and to keep the sandbox endpoints. Returns a link and a 6-digit code: show both to the human, who signs in or signs up with that email and enters the code. Then call wait_for_connection. Calling again gives a new code.",
1969
2277
  {
1970
- email: import_zod3.z.string().email({ message: "Invalid email address" }).describe("The human's email address; only someone signed in as it can connect")
2278
+ email: import_zod3.z.string().email({ message: "Invalid email address" }).describe(
2279
+ "The email the human signs in to webhooks.cc with (their GitHub or Google primary email if they sign in that way); only someone signed in as it can connect"
2280
+ )
1971
2281
  },
1972
2282
  withErrorHandling(async ({ email }) => {
1973
- if (session.connected) {
1974
- return jsonContent({ connected: true, message: "Already connected to an account." });
1975
- }
1976
2283
  const normalized = email.trim().toLowerCase();
2284
+ const previous = session.connect;
1977
2285
  const sandbox = await session.existingSandbox();
1978
- let claim;
2286
+ let attempt;
2287
+ let movesEndpoints = false;
1979
2288
  if (sandbox) {
1980
- claim = {
1981
- kind: "sandbox",
1982
- claimToken: sandbox.registration.claimToken,
1983
- email: normalized,
1984
- attempt: await sandbox.claim({ email: normalized }),
1985
- claimExpires: sandbox.expiresAt
1986
- };
1987
- } else if (session.claim?.kind === "service_auth" && session.claim.email === normalized && session.claim.claimExpires.getTime() > Date.now()) {
1988
- const attempt = await import_sdk2.WebhooksCC.agent.startClaim({
1989
- baseUrl,
1990
- claimToken: session.claim.claimToken
1991
- });
1992
- claim = { ...session.claim, attempt };
2289
+ const claim = await sandbox.claim({ email: normalized });
2290
+ session.connect = { via: "sandbox", sandbox, email: normalized, attempt: claim };
2291
+ attempt = claim;
2292
+ movesEndpoints = claim.movesEndpoints;
1993
2293
  } else {
1994
- const registration = await import_sdk2.WebhooksCC.agent.registerServiceAuth({
1995
- baseUrl,
1996
- email: normalized,
1997
- clientName: session.clientName
1998
- });
1999
- claim = {
2000
- kind: "service_auth",
2001
- claimToken: registration.claimToken,
2294
+ let pending = null;
2295
+ if (previous?.via === "connect" && previous.email === normalized) {
2296
+ pending = await previous.pending.restart().catch((error) => {
2297
+ if (error instanceof import_sdk2.AgentAuthError) return null;
2298
+ throw error;
2299
+ });
2300
+ }
2301
+ pending ?? (pending = await import_sdk2.WebhooksCC.connect({
2302
+ baseUrl: session.baseUrl,
2303
+ webhookUrl: session.webhookUrl,
2002
2304
  email: normalized,
2003
- attempt: registration.claim,
2004
- claimExpires: registration.claimTokenExpires
2005
- };
2305
+ clientName: session.clientName,
2306
+ credentialsFile: session.credentialsFile
2307
+ }));
2308
+ session.connect = { via: "connect", pending, email: normalized };
2309
+ attempt = pending;
2006
2310
  }
2007
- session.claim = claim;
2008
- const { verificationUri, userCode, expiresAt } = claim.attempt;
2311
+ const { verificationUri, userCode, expiresAt } = attempt;
2009
2312
  return jsonContent({
2010
2313
  verificationUri,
2011
2314
  userCode,
2012
2315
  expiresAt: expiresAt.toISOString(),
2013
2316
  tellTheHuman: `Open ${verificationUri}, sign in or sign up as ${normalized}, and enter the code ${userCode}.`,
2014
2317
  next: `Show the human that line, then call wait_for_connection. The code works for about ${minutesLeft(expiresAt)} minutes; connect_account gives a new one.`,
2015
- ...claim.kind === "sandbox" ? { endpoints: "The sandbox endpoints move into that account with what they captured." } : {}
2318
+ ...sandbox ? {
2319
+ endpoints: movesEndpoints ? "The sandbox endpoints move into that account with what they captured." : "The sandbox endpoints were made in an earlier session, so they stay in the sandbox until it expires; the account starts without them."
2320
+ } : {}
2016
2321
  });
2017
2322
  })
2018
2323
  )
2019
2324
  );
2020
- registered.push(
2325
+ tools.push(
2021
2326
  server.tool(
2022
2327
  "wait_for_connection",
2023
2328
  `Wait for the human to enter the code from connect_account. On success the full webhooks.cc tools for their account replace these sandbox tools. Returns after timeoutSeconds (default ${DEFAULT_WAIT_SECONDS}) if they have not finished yet; call it again to keep waiting.`,
@@ -2025,28 +2330,22 @@ function registerSandboxTools(server, options = {}) {
2025
2330
  timeoutSeconds: import_zod3.z.number().int().min(1).max(MAX_WAIT_SECONDS).optional().describe(`How long to wait, in seconds (default ${DEFAULT_WAIT_SECONDS})`)
2026
2331
  },
2027
2332
  withErrorHandling(async ({ timeoutSeconds }) => {
2028
- if (session.connected) {
2029
- return jsonContent({ connected: true, message: "Already connected to an account." });
2030
- }
2031
- const claim = session.claim;
2032
- if (!claim) {
2333
+ const pending = session.connect;
2334
+ if (!pending) {
2033
2335
  throw new Error("Nothing to wait for: call connect_account first.");
2034
2336
  }
2035
- let claimed;
2337
+ const timeout = (timeoutSeconds ?? DEFAULT_WAIT_SECONDS) * 1e3;
2338
+ const expiresAt = pending.via === "sandbox" ? pending.attempt.expiresAt : pending.pending.expiresAt;
2339
+ let client;
2036
2340
  try {
2037
- claimed = await import_sdk2.WebhooksCC.agent.waitForClaim({
2038
- baseUrl,
2039
- claimToken: claim.claimToken,
2040
- interval: claim.attempt.interval,
2041
- timeout: (timeoutSeconds ?? DEFAULT_WAIT_SECONDS) * 1e3
2042
- });
2341
+ client = pending.via === "sandbox" ? await pending.sandbox.waitForClaim({ timeout }) : await pending.pending.wait({ timeout });
2043
2342
  } catch (error) {
2044
2343
  if (!(error instanceof import_sdk2.AgentAuthError)) throw error;
2045
2344
  if (error.code === "timeout") {
2046
2345
  return jsonContent({
2047
2346
  connected: false,
2048
2347
  status: "waiting",
2049
- message: `Not connected yet. The code works until ${claim.attempt.expiresAt.toISOString()}; call wait_for_connection again.`
2348
+ message: `Not connected yet. The code works until ${expiresAt.toISOString()}; call wait_for_connection again.`
2050
2349
  });
2051
2350
  }
2052
2351
  if (error.code === "expired_token") {
@@ -2065,34 +2364,25 @@ function registerSandboxTools(server, options = {}) {
2065
2364
  }
2066
2365
  throw error;
2067
2366
  }
2068
- const client = new import_sdk2.WebhooksCC({
2069
- baseUrl,
2070
- webhookUrl: session.webhookUrl,
2071
- getAccessToken: claimedTokens(baseUrl, claimed.identityAssertion, claimed.token)
2072
- });
2073
- session.connected = true;
2074
- session.claim = null;
2075
- session.forgetSandbox();
2076
- for (const tool of registered) tool.remove();
2077
- registerTools(server, client);
2078
- options.onConnected?.(client);
2367
+ const movedEndpoints = pending.via === "sandbox" && pending.attempt.movesEndpoints;
2368
+ session.reset();
2369
+ showAccount(client);
2079
2370
  return jsonContent({
2080
2371
  connected: true,
2081
- email: claim.email,
2082
- scope: claimed.token.scope,
2083
- message: `Connected to the webhooks.cc account of ${claim.email}. The full tool set has replaced the sandbox tools (the tool list changed; if your client does not pick that up, ask the user to restart it).${claim.kind === "sandbox" ? " The sandbox endpoints are now that account's endpoints, with what they captured." : ""} This connection lasts while this MCP server runs; for a lasting setup, the human can create an API key under Account and set WHK_API_KEY.`
2372
+ email: pending.email,
2373
+ message: `Connected to the webhooks.cc account of ${pending.email}. The full tool set has replaced the sandbox tools (the tool list changed; if your client does not pick that up, ask the user to restart it).${movedEndpoints ? " The sandbox endpoints are now that account's endpoints, with what they captured." : ""} ${file ? `The connection is saved in ${file}, so later sessions, the whk CLI and the SDKs start connected; it lasts 90 days, and the human can remove it under Account, Connected agents.` : "This connection lasts while this MCP server runs; for a lasting setup, the human can create an API key under Account and set WHK_API_KEY."}`
2084
2374
  });
2085
2375
  })
2086
2376
  )
2087
2377
  );
2088
- registered.push(
2378
+ tools.push(
2089
2379
  server.tool(
2090
2380
  "about_sandbox",
2091
2381
  "Explain what this agent can do without a webhooks.cc account, the sandbox limits, and how to connect an account. No network call.",
2092
2382
  {},
2093
2383
  withErrorHandling(async () => {
2094
2384
  return jsonContent({
2095
- mode: "sandbox (no WHK_API_KEY)",
2385
+ mode: "sandbox (no WHK_API_KEY, no saved connection)",
2096
2386
  sandbox: {
2097
2387
  maxEndpoints: SANDBOX.maxEndpoints,
2098
2388
  requestsPerEndpoint: SANDBOX.requestsPerEndpoint,
@@ -2103,21 +2393,23 @@ function registerSandboxTools(server, options = {}) {
2103
2393
  notAvailable: "Mock responses, response rules, notifications, signature verification, email capture, forwarding, teams and sending webhooks."
2104
2394
  },
2105
2395
  connectAnAccount: [
2106
- "Call connect_account with the human's email and show them the link and code it returns.",
2396
+ "Call connect_account with the email the human signs in to webhooks.cc with, and show them the link and code it returns.",
2107
2397
  "Call wait_for_connection until it reports connected; the full tools for their account then replace the sandbox tools.",
2108
- "Sandbox endpoints move into the account with their captured requests."
2398
+ "Sandbox endpoints registered by this server process move into the account with their captured requests; a sandbox resumed from an earlier session stays in the sandbox (connect_account says which)."
2109
2399
  ],
2400
+ savedIn: file ? `${file} (shared with the whk CLI and the SDKs): the sandbox and the connection survive restarts.` : "nowhere: this session only.",
2110
2401
  withAnApiKey: "Set WHK_API_KEY to a whcc_ key from the dashboard and restart for the full tools from the start.",
2111
2402
  docs: DOCS_URL,
2112
- protocol: `${baseUrl}/auth.md`
2403
+ protocol: `${session.baseUrl}/auth.md`
2113
2404
  });
2114
2405
  })
2115
2406
  )
2116
2407
  );
2408
+ return tools;
2117
2409
  }
2118
2410
 
2119
2411
  // src/index.ts
2120
- var VERSION = true ? "1.13.0" : "0.0.0-dev";
2412
+ var VERSION = true ? "1.15.0" : "0.0.0-dev";
2121
2413
  function createServer(options = {}) {
2122
2414
  const apiKey = options.apiKey ?? process.env.WHK_API_KEY;
2123
2415
  const webhookUrl = options.webhookUrl ?? process.env.WHK_WEBHOOK_URL;
@@ -2128,7 +2420,7 @@ function createServer(options = {}) {
2128
2420
  { debouncedNotificationMethods: ["notifications/tools/list_changed"] }
2129
2421
  );
2130
2422
  if (!apiKey) {
2131
- registerSandboxTools(server, { baseUrl, webhookUrl });
2423
+ registerSandboxTools(server, { baseUrl, webhookUrl, credentialsFile: options.credentialsFile });
2132
2424
  registerPrompts(server);
2133
2425
  return server;
2134
2426
  }
@@ -2157,13 +2449,16 @@ function mcpServerConfig(apiKey) {
2157
2449
  return {
2158
2450
  command: "npx",
2159
2451
  args: ["-y", "@webhooks-cc/mcp"],
2160
- env: { WHK_API_KEY: apiKey }
2452
+ ...apiKey ? { env: { WHK_API_KEY: apiKey } } : {}
2161
2453
  };
2162
2454
  }
2455
+ function vscodeServerConfig(apiKey) {
2456
+ return { type: "stdio", ...mcpServerConfig(apiKey) };
2457
+ }
2163
2458
  function resolveApiKey(flags) {
2164
2459
  return flags["api-key"] ?? process.env.WHK_API_KEY ?? null;
2165
2460
  }
2166
- function mergeJsonConfig(filePath, serverName, serverConfig) {
2461
+ function mergeJsonConfig(filePath, serverName, serverConfig, key = "mcpServers") {
2167
2462
  let existing = {};
2168
2463
  if ((0, import_fs.existsSync)(filePath)) {
2169
2464
  try {
@@ -2174,9 +2469,9 @@ function mergeJsonConfig(filePath, serverName, serverConfig) {
2174
2469
  (0, import_fs.copyFileSync)(filePath, `${filePath}.bak`);
2175
2470
  }
2176
2471
  }
2177
- const mcpServers = existing.mcpServers ?? {};
2178
- mcpServers[serverName] = serverConfig;
2179
- existing.mcpServers = mcpServers;
2472
+ const servers = existing[key] ?? {};
2473
+ servers[serverName] = serverConfig;
2474
+ existing[key] = servers;
2180
2475
  const dir = (0, import_path.dirname)(filePath);
2181
2476
  if (!(0, import_fs.existsSync)(dir)) (0, import_fs.mkdirSync)(dir, { recursive: true });
2182
2477
  (0, import_fs.writeFileSync)(filePath, JSON.stringify(existing, null, 2) + "\n");
@@ -2213,8 +2508,7 @@ function setupClaudeCode(apiKey) {
2213
2508
  "--transport",
2214
2509
  "stdio",
2215
2510
  "webhooks-cc",
2216
- "-e",
2217
- `WHK_API_KEY=${apiKey}`,
2511
+ ...apiKey ? ["-e", `WHK_API_KEY=${apiKey}`] : [],
2218
2512
  "--",
2219
2513
  "npx",
2220
2514
  "-y",
@@ -2229,7 +2523,7 @@ function setupClaudeCode(apiKey) {
2229
2523
  console.error("Install it: npm install -g @anthropic-ai/claude-code");
2230
2524
  console.error("\nManual alternative \u2014 run this command:");
2231
2525
  console.error(
2232
- ` claude mcp add -s user --transport stdio webhooks-cc -e WHK_API_KEY=<your-key> -- npx -y @webhooks-cc/mcp`
2526
+ ` claude mcp add -s user --transport stdio webhooks-cc${apiKey ? " -e WHK_API_KEY=<your-key>" : ""} -- npx -y @webhooks-cc/mcp`
2233
2527
  );
2234
2528
  process.exit(1);
2235
2529
  }
@@ -2248,7 +2542,7 @@ function setupCursor(apiKey) {
2248
2542
  }
2249
2543
  function setupVSCode(apiKey) {
2250
2544
  const configPath = (0, import_path.join)(process.cwd(), ".vscode", "mcp.json");
2251
- mergeJsonConfig(configPath, "webhooks-cc", mcpServerConfig(apiKey));
2545
+ mergeJsonConfig(configPath, "webhooks-cc", vscodeServerConfig(apiKey), "servers");
2252
2546
  console.log(`Done! Config written to ${configPath}`);
2253
2547
  console.log("Reload VS Code window for changes to take effect.");
2254
2548
  }
@@ -2260,8 +2554,7 @@ function setupCodex(apiKey) {
2260
2554
  "mcp",
2261
2555
  "add",
2262
2556
  "webhooks-cc",
2263
- "-e",
2264
- `WHK_API_KEY=${apiKey}`,
2557
+ ...apiKey ? ["-e", `WHK_API_KEY=${apiKey}`] : [],
2265
2558
  "--",
2266
2559
  "npx",
2267
2560
  "-y",
@@ -2274,13 +2567,13 @@ function setupCodex(apiKey) {
2274
2567
  console.error("\nFailed to run 'codex mcp add'. Is OpenAI Codex CLI installed?");
2275
2568
  console.error("\nManual alternative \u2014 add to ~/.codex/config.toml:");
2276
2569
  console.error(`
2277
- [mcp.webhooks-cc]
2570
+ [mcp_servers.webhooks-cc]
2278
2571
  command = "npx"
2279
2572
  args = ["-y", "@webhooks-cc/mcp"]
2280
-
2281
- [mcp.webhooks-cc.env]
2573
+ ${apiKey ? `
2574
+ [mcp_servers.webhooks-cc.env]
2282
2575
  WHK_API_KEY = "<your-key>"
2283
- `);
2576
+ ` : ""}`);
2284
2577
  process.exit(1);
2285
2578
  }
2286
2579
  }
@@ -2297,6 +2590,9 @@ function printUsage() {
2297
2590
  Usage:
2298
2591
  npx @webhooks-cc/mcp setup <tool> [--api-key <key>]
2299
2592
 
2593
+ Without a key the server starts in the keyless agent sandbox; your agent can
2594
+ connect your account later (connect_account), and the connection is saved.
2595
+
2300
2596
  Tools:
2301
2597
  claude-code Claude Code CLI (runs 'claude mcp add')
2302
2598
  claude-desktop Claude Desktop app
@@ -2306,9 +2602,10 @@ Tools:
2306
2602
  windsurf Windsurf editor
2307
2603
 
2308
2604
  Options:
2309
- --api-key <key> Your webhooks.cc API key (or set WHK_API_KEY env var)
2605
+ --api-key <key> Your webhooks.cc API key (or set WHK_API_KEY env var); optional
2310
2606
 
2311
2607
  Examples:
2608
+ npx @webhooks-cc/mcp setup claude-code
2312
2609
  npx @webhooks-cc/mcp setup claude-code --api-key whcc_abc123
2313
2610
  WHK_API_KEY=whcc_abc123 npx @webhooks-cc/mcp setup cursor
2314
2611
  npx @webhooks-cc/mcp setup vscode --api-key whcc_abc123
@@ -2336,14 +2633,13 @@ function runSetup(args2) {
2336
2633
  process.exit(tool ? 1 : 0);
2337
2634
  }
2338
2635
  const apiKey = resolveApiKey(flags);
2339
- if (!apiKey) {
2340
- console.error("Error: API key required. Pass --api-key or set WHK_API_KEY env var.");
2341
- console.error("Get your API key at https://webhooks.cc/account/api-keys\n");
2342
- process.exit(1);
2343
- }
2344
- validateApiKeyFormat(apiKey);
2636
+ if (apiKey) validateApiKeyFormat(apiKey);
2345
2637
  console.log(`Setting up webhooks-cc MCP server for ${tool}...
2346
2638
  `);
2639
+ if (!apiKey) {
2640
+ console.log("No API key: the server starts in the keyless agent sandbox. Your agent can");
2641
+ console.log("connect your account later with connect_account; the connection is saved.\n");
2642
+ }
2347
2643
  switch (tool) {
2348
2644
  case "claude-code":
2349
2645
  setupClaudeCode(apiKey);
@@ -2381,17 +2677,25 @@ Usage:
2381
2677
  npx @webhooks-cc/mcp --help Show this help
2382
2678
 
2383
2679
  Environment:
2384
- WHK_API_KEY Your webhooks.cc API key (required)
2680
+ WHK_API_KEY Your webhooks.cc API key (optional; without it the server
2681
+ starts connected to the account your agent was connected
2682
+ to before, or in the keyless agent sandbox)
2683
+ WHK_AGENT_FILE Where the sandbox and the connection are saved (default:
2684
+ ~/.config/whk/agent.json, shared with the whk CLI and SDKs)
2385
2685
  WHK_WEBHOOK_URL Custom webhook receiver URL (optional)
2386
2686
  WHK_BASE_URL Custom API base URL (optional)
2387
2687
 
2388
2688
  Setup:
2389
- npx @webhooks-cc/mcp setup claude-code --api-key whcc_...
2390
- npx @webhooks-cc/mcp setup cursor --api-key whcc_...
2391
- npx @webhooks-cc/mcp setup vscode --api-key whcc_...
2392
- npx @webhooks-cc/mcp setup codex --api-key whcc_...
2393
- npx @webhooks-cc/mcp setup windsurf --api-key whcc_...
2394
- npx @webhooks-cc/mcp setup claude-desktop --api-key whcc_...
2689
+ npx @webhooks-cc/mcp setup claude-code
2690
+ npx @webhooks-cc/mcp setup cursor
2691
+ npx @webhooks-cc/mcp setup vscode
2692
+ npx @webhooks-cc/mcp setup codex
2693
+ npx @webhooks-cc/mcp setup windsurf
2694
+ npx @webhooks-cc/mcp setup claude-desktop
2695
+ (add --api-key whcc_... to start with your account's tools)
2696
+
2697
+ Hosted agents (claude.ai, ChatGPT): add https://webhooks.cc/mcp as a remote
2698
+ MCP server instead; you approve it once in your browser.
2395
2699
 
2396
2700
  Get your API key at https://webhooks.cc/account/api-keys
2397
2701
  `);