@extrovert.dev/mcp 0.1.0-pre.4 → 0.1.0-pre.6

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/tools.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * an org-wide master key. `redeem_enrollment` lets an agent exchange a
13
13
  * single-use enrollment token for that scoped key at runtime.
14
14
  */
15
+ import { createHash } from "node:crypto";
15
16
  import { z } from "zod/v4";
16
17
  import { ExtrovertApiError } from "./client.js";
17
18
  import { isTerminalReviewEvent } from "./types.js";
@@ -436,6 +437,7 @@ const redeemEnrollment = defineTool({
436
437
  agent_handle: args.agent_handle,
437
438
  client_id: args.client_id,
438
439
  });
440
+ const persistence = client.credentialPersistenceStatus();
439
441
  const text = [
440
442
  `Enrollment redeemed. Agent ${result.agent_id} is ready.`,
441
443
  `agent_key (shown once): ${result.agent_key}`,
@@ -444,7 +446,7 @@ const redeemEnrollment = defineTool({
444
446
  // whoami call — consistent with whoami's text.
445
447
  `org: ${result.org_id || "(none)"} · project: ${result.project_id || "(none)"} (fixed — bound to this key)`,
446
448
  `scopes: ${result.scopes.join(", ") || "(none)"}`,
447
- "This MCP session will use the returned key. Store the complete returned agent_key as EXTROVERT_API_KEY for future sessions; it is not retrievable again.",
449
+ credentialPersistenceMessage(persistence),
448
450
  ].join("\n");
449
451
  return ok(text, result);
450
452
  },
@@ -453,9 +455,12 @@ const signUp = defineTool({
453
455
  name: "sign_up",
454
456
  title: "Sign up for a free account",
455
457
  description: "Grab a free Extrovert account in one call (no enrollment token needed). Provisions a tenant and a first inbox, then " +
456
- "emails a one-time verification code to your human_email. Returns a LIMITED (read-only) agent key that this " +
457
- "MCP session keeps for verify_signup; store it as EXTROVERT_API_KEY for future sessions. Re-calling with the same " +
458
- "human_email rotates the key and resends the code.",
458
+ "emails a one-time verification code to your human_email. Returns a LIMITED verification-only agent key that " +
459
+ "cannot read or send mail and that this " +
460
+ "MCP session keeps only through verify_signup; it expires with the code and is revoked after verification. " +
461
+ "Re-calling with the same human_email rotates the bootstrap key and resends the code. Free signup may be " +
462
+ "temporarily paused; in that state the tool " +
463
+ "returns signup_disabled and creates nothing. Enrollment tokens remain available.",
459
464
  inputSchema: {
460
465
  human_email: emailAddress.describe("Your email — receives the one-time verification code."),
461
466
  username: z
@@ -473,7 +478,9 @@ const signUp = defineTool({
473
478
  `inbox: ${res.address}`,
474
479
  `agent_key (limited, shown once): ${res.agent_key}`,
475
480
  `scopes: ${res.scopes.join(", ")}`,
476
- `This MCP session will use the limited key. Call verify_signup with the code to unlock full scopes; store the key as EXTROVERT_API_KEY for future sessions if needed.`,
481
+ `expires: ${res.otp_expires_at}`,
482
+ `This MCP session will use the limited key until verify_signup atomically exchanges it for the durable key. Do not store this bootstrap key for long-term use.`,
483
+ `After verification, verify_signup repeats the inbox and returns exact read_messages / get_message / wait_for_email calls so mailbox use can continue without raw HTTP.`,
477
484
  ].join("\n");
478
485
  return ok(text, res);
479
486
  },
@@ -481,22 +488,43 @@ const signUp = defineTool({
481
488
  const verifySignup = defineTool({
482
489
  name: "verify_signup",
483
490
  title: "Verify signup code",
484
- description: "Confirm the one-time code emailed by sign_up. On success you receive a NEW full-scope agent key (create/read/send) " +
485
- "this MCP session switches to it automatically. Store it as EXTROVERT_API_KEY for future sessions.",
491
+ description: "Confirm the one-time code emailed by sign_up. On success the bootstrap key is revoked and you receive a NEW " +
492
+ "full-scope agent key (create/read/send/webhooks). This MCP session switches to it automatically. The packaged " +
493
+ "local stdio server also stores it in its permission-restricted credential file for future sessions. Pending " +
494
+ "signup verification returns signup_disabled while free signup is temporarily paused; keep the limited key and " +
495
+ "retry after reopening.",
486
496
  inputSchema: {
487
497
  otp: z.string().min(4).max(12).describe("The verification code from your signup email."),
488
498
  },
489
499
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
490
500
  handler: async (args, { client }) => {
491
501
  const res = await client.verify({ otp: args.otp });
502
+ const quick = res.mailbox_quickstart;
503
+ const persistence = client.credentialPersistenceStatus();
492
504
  const text = [
493
505
  `Verified. ${res.message}`,
494
506
  `agent_key (full, shown once): ${res.agent_key}`,
495
507
  `scopes: ${res.scopes.join(", ")}`,
508
+ credentialPersistenceMessage(persistence),
509
+ `mailbox ready: ${res.address}`,
510
+ `First call: ${quick.list_mail.tool} ${JSON.stringify(quick.list_mail.arguments)}`,
511
+ `Read one result: ${quick.read_message.tool} ${JSON.stringify(quick.read_message.arguments)}`,
512
+ `Wait for new mail: ${quick.wait_for_mail.tool} ${JSON.stringify(quick.wait_for_mail.arguments)}`,
513
+ `These tools return readable text plus structured message fields; do not download raw responses or invoke jq for ordinary mailbox work.`,
514
+ `For outbound mail, use send_email or reply_email through the review workflow; read get_inbox first to see the effective review policy.`,
496
515
  ].join("\n");
497
516
  return ok(text, res);
498
517
  },
499
518
  });
519
+ function credentialPersistenceMessage(status) {
520
+ if (status.persisted) {
521
+ return `Credential saved for future local sessions${status.location ? ` at ${status.location}` : ""}.`;
522
+ }
523
+ if (status.attempted) {
524
+ return `Automatic credential storage failed${status.error ? `: ${status.error}` : ""}. Store the complete returned agent_key now; it is not retrievable again.`;
525
+ }
526
+ return "This host did not provide durable credential storage. Store the complete returned agent_key now; it is not retrievable again.";
527
+ }
500
528
  const whoami = defineTool({
501
529
  name: "whoami",
502
530
  title: "Who am I",
@@ -659,6 +687,7 @@ const exportEmailConfig = defineTool({
659
687
  name: "export_email_config",
660
688
  title: "Export email client config",
661
689
  description: "Export an inbox's IMAP/SMTP server settings + login so you can configure a real mail client (e.g. Himalaya). " +
690
+ "Requires the dedicated mailbox:credentials scope and a paid plan; free accounts cannot export raw credentials. " +
662
691
  "Direct SMTP is an explicit unreviewed delivery path: it bypasses Extrovert approval/review, suppression and " +
663
692
  "contact-list enforcement, List-Unsubscribe injection, and Extrovert billing/accounting. API/MCP sends keep those " +
664
693
  "controls. Returns a ready-to-use config; use format=json for raw connection fields.",
@@ -759,6 +788,7 @@ const sendEmail = defineTool({
759
788
  .describe("Your confidence (0..1) in the category match. Feeds the min_confidence auto-send gate ONLY; " +
760
789
  "the server never scores. Below the threshold (or omitted when one is set) the would-be auto-send " +
761
790
  "routes to needs_review (gate_outcome held:low_confidence)."),
791
+ composition_token: z.string().min(1).optional().describe("Token returned by the fresh get_rules call used to compose this message. The MCP refreshes it when omitted for compatibility."),
762
792
  client_id: z
763
793
  .string()
764
794
  .min(1)
@@ -768,6 +798,9 @@ const sendEmail = defineTool({
768
798
  },
769
799
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
770
800
  handler: async (args, { client }) => {
801
+ const compositionToken = args.composition_token ?? (await client.getRules({ category_id: args.category_id })).composition_token;
802
+ if (!compositionToken)
803
+ throw new ExtrovertApiError("Call get_rules without a scope filter before composing.", 422, "composition_token_required");
771
804
  // Review Loop overload: any of mode/intent/category_id opts into the richer
772
805
  // discriminated response. It does NOT decide whether a human sees the message —
773
806
  // the policy does that either way; this only shapes what comes back.
@@ -791,6 +824,7 @@ const sendEmail = defineTool({
791
824
  intent: args.intent,
792
825
  category_id: args.category_id,
793
826
  category_confidence: args.category_confidence,
827
+ composition_token: compositionToken,
794
828
  client_id: args.client_id,
795
829
  });
796
830
  return ok(renderSubmitResult(result), result);
@@ -806,6 +840,7 @@ const sendEmail = defineTool({
806
840
  reply_to: args.reply_to,
807
841
  headers: args.headers,
808
842
  attachments: args.attachments,
843
+ composition_token: compositionToken,
809
844
  client_id: args.client_id,
810
845
  });
811
846
  return ok(renderSendOutcome("Sent", result), result);
@@ -856,6 +891,7 @@ const replyEmail = defineTool({
856
891
  .max(1)
857
892
  .optional()
858
893
  .describe("Your confidence (0..1) in the category match. Feeds the min_confidence auto-send gate only."),
894
+ composition_token: z.string().min(1).optional().describe("Token returned by the fresh get_rules call used to compose this reply. The MCP refreshes it when omitted for compatibility."),
859
895
  client_id: z
860
896
  .string()
861
897
  .min(1)
@@ -868,6 +904,9 @@ const replyEmail = defineTool({
868
904
  if (!args.thread_id && !args.message_id) {
869
905
  throw new ExtrovertApiError("Provide thread_id or message_id to reply.", 400, "invalid_argument");
870
906
  }
907
+ const compositionToken = args.composition_token ?? (await client.getRules({ category_id: args.category_id })).composition_token;
908
+ if (!compositionToken)
909
+ throw new ExtrovertApiError("Call get_rules without a scope filter before composing.", 422, "composition_token_required");
871
910
  // Review Loop overload: any of mode/intent/category_id opts into review.
872
911
  if (args.mode !== undefined || args.intent !== undefined || args.category_id !== undefined) {
873
912
  const result = await client.submitReplyForReview({
@@ -886,6 +925,7 @@ const replyEmail = defineTool({
886
925
  intent: args.intent,
887
926
  category_id: args.category_id,
888
927
  category_confidence: args.category_confidence,
928
+ composition_token: compositionToken,
889
929
  client_id: args.client_id,
890
930
  });
891
931
  return ok(renderSubmitResult(result), result);
@@ -902,6 +942,7 @@ const replyEmail = defineTool({
902
942
  headers: args.headers,
903
943
  reply_all: args.reply_all,
904
944
  attachments: args.attachments,
945
+ composition_token: compositionToken,
905
946
  client_id: args.client_id,
906
947
  });
907
948
  return ok(renderSendOutcome("Replied", res), res);
@@ -946,6 +987,7 @@ const forwardEmail = defineTool({
946
987
  .max(1)
947
988
  .optional()
948
989
  .describe("Your confidence (0..1) in the category match. Feeds the min_confidence auto-send gate only."),
990
+ composition_token: z.string().min(1).optional().describe("Token returned by the fresh get_rules call used to compose this forward. The MCP refreshes it when omitted for compatibility."),
949
991
  client_id: z
950
992
  .string()
951
993
  .min(1)
@@ -955,6 +997,9 @@ const forwardEmail = defineTool({
955
997
  },
956
998
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
957
999
  handler: async (args, { client }) => {
1000
+ const compositionToken = args.composition_token ?? (await client.getRules({ category_id: args.category_id })).composition_token;
1001
+ if (!compositionToken)
1002
+ throw new ExtrovertApiError("Call get_rules without a scope filter before composing.", 422, "composition_token_required");
958
1003
  // Same opt-in predicate as send/reply: mode/intent/category_id select the
959
1004
  // richer discriminated response; the policy governs the routing regardless.
960
1005
  if (args.mode !== undefined || args.intent !== undefined || args.category_id !== undefined) {
@@ -970,6 +1015,7 @@ const forwardEmail = defineTool({
970
1015
  intent: args.intent,
971
1016
  category_id: args.category_id,
972
1017
  category_confidence: args.category_confidence,
1018
+ composition_token: compositionToken,
973
1019
  client_id: args.client_id,
974
1020
  });
975
1021
  return ok(renderSubmitResult(result), result);
@@ -982,6 +1028,7 @@ const forwardEmail = defineTool({
982
1028
  bcc: args.bcc,
983
1029
  text: args.text,
984
1030
  html: args.html,
1031
+ composition_token: compositionToken,
985
1032
  client_id: args.client_id,
986
1033
  });
987
1034
  return ok(renderSendOutcome("Forwarded", res), res);
@@ -1215,6 +1262,7 @@ const submitRevision = defineTool({
1215
1262
  "redraft could never restore a file the human reviewed the message with."),
1216
1263
  built_at: z.string().optional().describe("When you built this draft (informational)."),
1217
1264
  rules_version_seen: z.number().int().optional().describe("Rule high-water this draft was composed against (born-stale basis)."),
1265
+ composition_token: z.string().min(1).optional().describe("Token returned by the fresh get_rules call used to compose this revision. The MCP refreshes it when omitted for compatibility."),
1218
1266
  client_id: z
1219
1267
  .string()
1220
1268
  .min(1)
@@ -1224,6 +1272,10 @@ const submitRevision = defineTool({
1224
1272
  },
1225
1273
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1226
1274
  handler: async (args, { client }) => {
1275
+ const currentReview = await client.getReview(args.id);
1276
+ const compositionToken = args.composition_token ?? (await client.getRules({ category_id: currentReview.category_id })).composition_token;
1277
+ if (!compositionToken)
1278
+ throw new ExtrovertApiError("Call get_rules without a scope filter before redrafting.", 422, "composition_token_required");
1227
1279
  const review = await client.submitRevision({
1228
1280
  id: args.id,
1229
1281
  parent_revision: args.parent_revision,
@@ -1235,6 +1287,7 @@ const submitRevision = defineTool({
1235
1287
  attachments: args.attachments,
1236
1288
  built_at: args.built_at,
1237
1289
  rules_version_seen: args.rules_version_seen,
1290
+ composition_token: compositionToken,
1238
1291
  client_id: args.client_id,
1239
1292
  });
1240
1293
  return ok(`Revised.\n${renderReview(review)}`, review);
@@ -1534,6 +1587,11 @@ const getRules = defineTool({
1534
1587
  return ok(`${res.items.length} rule(s), highest precedence first.\n\n${text}`, {
1535
1588
  items: res.items,
1536
1589
  total: res.total,
1590
+ house_style_version: res.house_style_version,
1591
+ category_rules_version: res.category_rules_version,
1592
+ rule_high_water: res.rule_high_water,
1593
+ composition_token: res.composition_token,
1594
+ composition_token_expires_at: res.composition_token_expires_at,
1537
1595
  });
1538
1596
  },
1539
1597
  });
@@ -1548,6 +1606,7 @@ const saveRule = defineTool({
1548
1606
  "lineage, the prior superseded). Use this AFTER you judge a diff/comment is a generalizable rule (the judgment is " +
1549
1607
  "yours; we never run an LLM). Returns the new active rule (with its rule_layer/org_id/project_id).",
1550
1608
  inputSchema: {
1609
+ client_id: z.string().min(1).max(128).optional().describe("Stable retry id for this exact rule save; reuse it after a timeout. The MCP derives one when omitted."),
1551
1610
  rule_text: z.string().min(1).describe("The rule body, e.g. 'no em-dashes' or 'be more pushy, we need MRR'."),
1552
1611
  category_id: z.string().optional().describe("Category id (cat_…). Empty = house-style/general (D2)."),
1553
1612
  scope: z.enum(RULE_SCOPES).optional().describe("Defaults from category_id (general iff empty)."),
@@ -1569,9 +1628,11 @@ const saveRule = defineTool({
1569
1628
  .optional()
1570
1629
  .describe("Override the propagate batch (0 = base 3, bounded by rework_batch_max). Never fans one nudge to the whole queue."),
1571
1630
  },
1572
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
1631
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
1573
1632
  handler: async (args, { client }) => {
1633
+ const derivedClientID = `rule:${createHash("sha256").update(JSON.stringify(args)).digest("hex")}`;
1574
1634
  const rule = await client.saveRule({
1635
+ client_id: args.client_id ?? derivedClientID,
1575
1636
  rule_text: args.rule_text,
1576
1637
  category_id: args.category_id,
1577
1638
  scope: args.scope,