sealnet-mcp 0.3.0 → 0.4.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/README.md CHANGED
@@ -9,7 +9,7 @@ Model Context Protocol (MCP) server for **SEAL**.
9
9
  When an MCP-connected AI agent (Claude Desktop, Cursor, Cline, etc.) creates a SEAL via this server:
10
10
 
11
11
  - **handoff mode (default)**: the share link (the key is in its fragment, there is no password) is delivered to the human user via **system clipboard** (primary) or **0o600 local file** (fallback when clipboard is unavailable — headless, SSH, WSL without display). The `tool_result` returned to the model contains **only** an opaque handle and a human-readable receipt. The link is never written into the conversation; on the file channel the receipt names the file, and an agent that can read files on that machine can read it.
12
- - **forward mode (explicit opt-in)**: model receives `{ handle, share_url, expires_at, max_reads, mode }` in `tool_result`; the link opens only with the recipient's X25519 key. Requires short TTL (≤30m), `max_reads=1`, mandatory `to` (recipient pubkey). Every forward is audit-logged in the encrypted state file.
12
+ - **forward mode (explicit opt-in)**: model receives `{ handle, share_url, expires_at, max_reads, mode, delivered }` in `tool_result`; the link opens only with the recipient's X25519 key and also lands in the recipient's inbox (`seal_inbox`). Requires short TTL (≤30m), `max_reads=1`, mandatory `to` (recipient address). Every forward is audit-logged in the encrypted state file.
13
13
 
14
14
  ## Tool surface
15
15
 
@@ -17,7 +17,8 @@ When an MCP-connected AI agent (Claude Desktop, Cursor, Cline, etc.) creates a S
17
17
  |------|---------|
18
18
  | `seal_share(path, mode='handoff'\|'forward', expire?, max_reads?, tier?)` / `seal_share(payment)` | Create a SEAL from a local file or folder (sent as one ZIP), streamed from disk. Default: handoff; without `expire`, 5m, or 1d for a handoff over 100 MB. Over 1 GB the user pays once by card (smallest tier that fits, or `tier`): the payment page goes through URL-mode elicitation, or comes back as `payment_url` and a second call with `payment` finishes the upload into the same seal |
19
19
  | `seal_request(what, kind='any'\|'secret'\|'file', where_url?, wait=true)` / `seal_request(request)` | Ask the user for a file or a secret without it passing through the chat. On the user's computer the `seal` CLI draws the system dialog (`seal request --local --json`); otherwise the user answers at `seal.net/i/<id>`, sealed to this server's key, shown with three emoji of that key. Returns the path of a 0600 file, never the value |
20
- | `seal_open(url, mode='metadata'\|'file'\|'inline', dir?)` | Read metadata (default — no download, no counter consumption), save to disk (`file`: streamed, verified before it gets its name, 0600, returns paths) or inline parse (whitelisted MIMEs only, ≤100 KB, refuses secrets, HTML and binary) |
20
+ | `seal_open(url\|item, mode='metadata'\|'file'\|'inline', dir?)` | Read metadata (default — no download, no counter consumption), save to disk (`file`: streamed, verified before it gets its name, 0600, returns paths) or inline parse (whitelisted MIMEs only, ≤100 KB, refuses secrets, HTML and binary). `item` is an inbox handle instead of a link |
21
+ | `seal_inbox(wait?)` | Seals other agents or people sent to this server's key (its address): handles, the sender's three emoji and name, never a link. `seal_share mode=forward to=<address>` puts a link there; `wait` (0 to 300 s) waits for something new |
21
22
  | `seal_list()` | List SEALs created by **this MCP server only** (scope: `state.json`; never reveals user's CLI seals) |
22
23
  | `seal_revoke(handle)` | Revoke by opaque handle (model never learns raw `seal_id`) |
23
24
 
package/dist/cli.cjs CHANGED
@@ -2890,9 +2890,60 @@ var limits_default = {
2890
2890
  INTAKE_TTL_DEFAULT_SECONDS: 900,
2891
2891
  INTAKE_TTL_MAX_SECONDS: 86400,
2892
2892
  INTAKE_WAIT_MAX_SECONDS: 25,
2893
- PAYMENT_WAIT_MAX_SECONDS: 82800
2893
+ PAYMENT_WAIT_MAX_SECONDS: 82800,
2894
+ ADDRESS_PROOF_TTL_SECONDS: 300,
2895
+ INBOX_MAX_ITEMS: 200
2894
2896
  }
2895
2897
  };
2898
+ var seal_address_v1_default = {
2899
+ version: 1,
2900
+ $comment: "Address of a key: who a seal is for, without a URL (audit/SPEC-ADDRESS.md). Numbers live in limits.json, error codes in error_codes.json.",
2901
+ address: {
2902
+ key: "X25519 public key, 32 bytes",
2903
+ text: "base64url without padding, 43 characters",
2904
+ emoji: "fingerprint_emoji.json over sha256(key)[0..3], the same picture as intake",
2905
+ inbox_hash: "sha256(key), 32 bytes: the server finds an inbox by it"
2906
+ },
2907
+ seed: {
2908
+ env: "SEAL_SEED",
2909
+ text: "base64url without padding of the 32-byte X25519 secret, 43 characters",
2910
+ same_as: [
2911
+ "sealnet-mcp state.identity.x25519_secret_b64",
2912
+ "seal identity.v1.json x25519_secret_b64url",
2913
+ "sealnet (npm) KeyPair.secretKey",
2914
+ "sealnet (PyPI) keygen().secret_key"
2915
+ ]
2916
+ },
2917
+ scopes: {
2918
+ share_link: "seal_share",
2919
+ owner_copy: "seal_owner",
2920
+ address_proof: "seal_address_proof"
2921
+ },
2922
+ proof: {
2923
+ header_address: "X-Seal-Address",
2924
+ header_proof: "X-Seal-Proof",
2925
+ proof_text: "<ts>.<token>: ts is the decimal Unix second from the challenge, token base64url of 32 bytes",
2926
+ challenge: "sealed box of the 32-byte token to the address with scope (address_proof, decimal ts): ephemeral_pub(32) || nonce(12) || ciphertext(48), base64url, 123 characters",
2927
+ token: "opaque to clients: unwrap it and send it back"
2928
+ },
2929
+ items: {
2930
+ delivery: {
2931
+ id: "s.<seal_id>.<share_code>",
2932
+ is: "a share link addressed to this key; fragment = its key wrapped to the address (x25519:, targeted_x25519_v1, scope share_link)"
2933
+ }
2934
+ },
2935
+ request_kinds: ["secret", "file", "any"],
2936
+ vectors: [
2937
+ {
2938
+ comment: "generated once by backend_crypto.sealed_box.seal_key_for_recipient; every client must unwrap it",
2939
+ secret: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc",
2940
+ address: "E75P6uryBMf9M1j8nAByGIHRdCeBKCJ-xnTzf3_pe20",
2941
+ ts: 179e7,
2942
+ challenge: "4Oel-UMfgP_1dBhmf13yZiujhPDouCIQ6sSx1nY1qy-vyzFqDqb9jARbJ0e5A8vv4EDELDS5OPqBQMcsmDA-C_lXO95y_CVUyYtSu-1fltsy5ZihOFDp0T-C9Ro",
2943
+ token: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8"
2944
+ }
2945
+ ]
2946
+ };
2896
2947
  var upload_default = {
2897
2948
  version: 1,
2898
2949
  upload: {
@@ -3224,13 +3275,24 @@ var SealApi = class _SealApi {
3224
3275
  });
3225
3276
  return { etag: resp.headers.get("ETag") };
3226
3277
  }
3227
- /** `POST /seal/{id}/share` — mint a share link, returns its code. */
3278
+ /**
3279
+ * `POST /seal/{id}/share` — mint a share link, returns its code. With an
3280
+ * address the link also lands in that key's inbox, in the same request.
3281
+ */
3228
3282
  async createShareLink(sealId, ownerToken, encryptedBlobB64, opts = {}) {
3283
+ const headers = _SealApi.ownerHeaders(ownerToken, true);
3284
+ const body = { encrypted_blob: encryptedBlobB64 };
3285
+ if ("to" in opts) {
3286
+ body.to = opts.to;
3287
+ body.fragment = opts.fragment;
3288
+ if (opts.label) body.label = opts.label;
3289
+ if (opts.sender) Object.assign(headers, proofHeaders(opts.sender));
3290
+ }
3229
3291
  const resp = await this.request(`/seal/${encode(sealId)}/share`, {
3230
3292
  what: `POST /seal/${sealId}/share`,
3231
3293
  method: "POST",
3232
- headers: _SealApi.ownerHeaders(ownerToken, true),
3233
- body: JSON.stringify({ encrypted_blob: encryptedBlobB64 }),
3294
+ headers,
3295
+ body: JSON.stringify(body),
3234
3296
  signal: opts.signal
3235
3297
  });
3236
3298
  const data = await resp.json();
@@ -3413,8 +3475,57 @@ var SealApi = class _SealApi {
3413
3475
  });
3414
3476
  return await resp.json();
3415
3477
  }
3478
+ // ---------------------------------------------------------------------------
3479
+ // Inbox (SPEC-ADDRESS F2): the share links addressed to a key
3480
+ // ---------------------------------------------------------------------------
3481
+ /** `POST /intake/proof` — a challenge sealed to `address`. */
3482
+ async requestAddressProof(address, opts = {}) {
3483
+ const resp = await this.request("/intake/proof", {
3484
+ what: "POST /intake/proof",
3485
+ method: "POST",
3486
+ headers: { "Content-Type": "application/json" },
3487
+ body: JSON.stringify({ address }),
3488
+ signal: opts.signal
3489
+ });
3490
+ return await resp.json();
3491
+ }
3492
+ /**
3493
+ * `GET /intake?to=` — every live item. `waitSeconds` holds the answer
3494
+ * while the inbox is empty, or, with `seen`, while its version is still
3495
+ * `seen`; one call waits at most `INTAKE_WAIT_MAX_SECONDS`, longer waits
3496
+ * are the caller's loop.
3497
+ */
3498
+ async listInbox(proof, opts = {}) {
3499
+ const params = new URLSearchParams({ to: proof.address });
3500
+ if (opts.waitSeconds) {
3501
+ params.set("wait", String(Math.min(opts.waitSeconds, limits_default.limits.INTAKE_WAIT_MAX_SECONDS)));
3502
+ }
3503
+ if (opts.seen) params.set("seen", opts.seen);
3504
+ const resp = await this.request(`/intake?${params.toString()}`, {
3505
+ what: "GET /intake",
3506
+ headers: proofHeaders(proof),
3507
+ signal: opts.signal
3508
+ });
3509
+ return await resp.json();
3510
+ }
3511
+ /** `DELETE /intake?to=&item=` — take one item out; the link keeps working. */
3512
+ async dismissInboxItem(itemId, proof, opts = {}) {
3513
+ const params = new URLSearchParams({ to: proof.address, item: itemId });
3514
+ await this.request(`/intake?${params.toString()}`, {
3515
+ what: "DELETE /intake",
3516
+ method: "DELETE",
3517
+ headers: proofHeaders(proof),
3518
+ signal: opts.signal
3519
+ });
3520
+ }
3416
3521
  };
3417
3522
  var NOT_READY_DEFAULT_RETRY_AFTER_S = 3;
3523
+ function proofHeaders(proof) {
3524
+ return {
3525
+ [seal_address_v1_default.proof.header_address]: proof.address,
3526
+ [seal_address_v1_default.proof.header_proof]: proof.proof
3527
+ };
3528
+ }
3418
3529
  function abortableSleep(ms, signal) {
3419
3530
  return new Promise((resolve5, reject) => {
3420
3531
  if (signal?.aborted) {
@@ -6179,6 +6290,7 @@ function parseUrlType(pathname) {
6179
6290
  var TARGETED_PREFIX_V1 = "x25519:";
6180
6291
  var TARGETED_PREFIX_V2_RESERVED = "x25519v2:";
6181
6292
  var SCOPE_TYPE = "seal_share";
6293
+ var ADDRESS_PROOF_SCOPE = "seal_address_proof";
6182
6294
  var X25519_KEY_BYTES = 32;
6183
6295
  var LINK_KEY_BYTES2 = 32;
6184
6296
  var PACKED_BYTES_V1 = 92;
@@ -6285,6 +6397,11 @@ async function unwrapTargetedFragment(fragment, recipientSecret, sealId) {
6285
6397
  }
6286
6398
  return linkKey;
6287
6399
  }
6400
+ async function proveAddress(secret, challenge, ts) {
6401
+ const wasm3 = await ensureWasm();
6402
+ const token = wasm3.wasm_sealbox_unwrap(base64urlToBytes(challenge), secret, ADDRESS_PROOF_SCOPE, String(ts));
6403
+ return `${ts}.${bytesToBase64url(token)}`;
6404
+ }
6288
6405
  var fingerprint_emoji_default = {
6289
6406
  version: 1,
6290
6407
  name: "FINGERPRINT_EMOJI",
@@ -6744,6 +6861,36 @@ async function waitForIntake(api, intakeId, opts = {}) {
6744
6861
  if (view.status === "fulfilled" && view.share_path) return view.share_path;
6745
6862
  }
6746
6863
  }
6864
+ var PROOF_MARGIN_MS = 3e4;
6865
+ var proofs = /* @__PURE__ */ new Map();
6866
+ async function addressProof(api, secret, opts = {}) {
6867
+ const address = bytesToBase64url(await recipientPublicKey(secret));
6868
+ const key = `${api.baseUrl} ${address}`;
6869
+ const kept = proofs.get(key);
6870
+ if (!opts.fresh && kept && kept.until > Date.now()) return kept.proof;
6871
+ const challenge = await api.requestAddressProof(address, opts);
6872
+ const proof = { address, proof: await proveAddress(secret, challenge.challenge, challenge.ts) };
6873
+ proofs.set(key, { proof, until: Date.parse(challenge.expires_at) - PROOF_MARGIN_MS });
6874
+ return proof;
6875
+ }
6876
+ async function withAddressProof(api, secret, signal, call) {
6877
+ const request = signal ? { signal } : {};
6878
+ try {
6879
+ return await call(await addressProof(api, secret, request));
6880
+ } catch (err2) {
6881
+ if (!(err2 instanceof BackendError && err2.appCode === "intake.proof_required")) throw err2;
6882
+ return call(await addressProof(api, secret, { ...request, fresh: true }));
6883
+ }
6884
+ }
6885
+ async function readInbox(api, secret, opts = {}) {
6886
+ return withAddressProof(api, secret, opts.signal, (proof) => api.listInbox(proof, opts));
6887
+ }
6888
+ async function removeFromInbox(api, secret, itemId, opts = {}) {
6889
+ return withAddressProof(api, secret, opts.signal, (proof) => api.dismissInboxItem(itemId, proof, opts));
6890
+ }
6891
+ function inboxItemUrl(site, item) {
6892
+ return `${site.replace(/\/+$/, "")}${item.path}#${item.fragment}`;
6893
+ }
6747
6894
  async function describeFile(file, fileKeys) {
6748
6895
  const keyB64 = file.sha256_hex ? fileKeys[file.sha256_hex] : void 0;
6749
6896
  const base = { id: file.id, size: file.size, sha256Hex: file.sha256_hex };
@@ -11664,7 +11811,7 @@ var seal_mcp_v1_default = {
11664
11811
  progress: "When a `tools/call` carries `_meta.progressToken`, `seal_share` and `seal_open mode=file` send `notifications/progress` with `progress` = ciphertext bytes done (upload: confirmed by storage) and `total` = ciphertext bytes in all; at most one per second, `progress` strictly increasing, the last one at `progress = total`."
11665
11812
  },
11666
11813
  server_instructions: {
11667
- text: "SEAL moves files and secrets between people and agents without their content entering this conversation. When someone wants a file, folder or secret sent to them, to another machine or to another agent, call seal_share with its path and do not open, print or Read it first. When you need an API key, password, file or folder from the user, or they offer or ask how to give you one, call seal_request right away instead of explaining other ways or asking them to paste or upload it. Open a SEAL link someone gave you with seal_open (mode=file for secrets and large files). Never read or print a SEAL handoff file.",
11814
+ text: "SEAL moves files and secrets between people and agents without their content entering this conversation. When someone wants a file, folder or secret sent to them, to another machine or to another agent, call seal_share with its path and do not open, print or Read it first. When you need an API key, password, file or folder from the user, or they offer or ask how to give you one, call seal_request right away instead of explaining other ways or asking them to paste or upload it. Open a SEAL link someone gave you with seal_open (mode=file for secrets and large files). Files sent to your key arrive in seal_inbox; open them with seal_open item. Never read or print a SEAL handoff file.",
11668
11815
  note: "The `instructions` of the `initialize` result (SPEC-AGENTS \xA78.1). Hosts that defer MCP tools behind a tool search (Claude Code) show the model only tool names until it searches, but put these instructions into the system prompt: this is the one text a model reads before it decides to look for a tool, or reads the file it was asked to send."
11669
11816
  },
11670
11817
  tools: {
@@ -11732,7 +11879,8 @@ var seal_mcp_v1_default = {
11732
11879
  share_url: { type: "string", format: "uri", description: "`targeted_x25519_v1` link: the wrapped key is in the fragment, useless without the recipient's X25519 secret; there is no separate password." },
11733
11880
  expires_at: { type: "string", format: "date-time" },
11734
11881
  max_reads: { type: "integer", const: 1 },
11735
- mode: { type: "string", const: "forward" }
11882
+ mode: { type: "string", const: "forward" },
11883
+ delivered: { type: "boolean", description: "The link also lies in the recipient's inbox (SPEC-ADDRESS F2): the server keeps its wrapped key in the link's row, and the holder of the key finds it with seal_inbox without being handed the URL." }
11736
11884
  },
11737
11885
  policy: {
11738
11886
  max_expire_minutes: 30,
@@ -11800,9 +11948,11 @@ var seal_mcp_v1_default = {
11800
11948
  annotations: { readOnlyHint: false, destructiveHint: false },
11801
11949
  annotations_note: "Set on the tool, not per mode: `inline` and `file` consume an open, so the tool as a whole is not read-only.",
11802
11950
  input: {
11803
- required: ["url"],
11951
+ one_of: ["url", "item"],
11952
+ one_of_note: "Exactly one of `url` and `item`; `server.ts` takes both as optional and the handler refuses anything else with `url_invalid`.",
11804
11953
  properties: {
11805
11954
  url: { type: "string", format: "uri", description: "Full SEAL share URL including fragment." },
11955
+ item: { type: "string", pattern: "^in_", description: "An inbox handle from seal_inbox (`in_\u2026`), instead of a URL." },
11806
11956
  mode: { type: "string", enum: ["metadata", "inline", "file"], default: "metadata", description: "`metadata` reads nothing; `file` saves to disk; `inline` returns small text." },
11807
11957
  dir: { type: "string", description: "mode=file: directory to save into; default ~/Downloads, created if missing. An existing file is never overwritten: the new one becomes `name (1).ext`." }
11808
11958
  }
@@ -11859,6 +12009,40 @@ var seal_mcp_v1_default = {
11859
12009
  _binary_default: "inline_blocked.binary"
11860
12010
  }
11861
12011
  },
12012
+ seal_inbox: {
12013
+ title: "Check the inbox",
12014
+ description: "Seals other agents or people sent to your key (your address). Returns handles; open one with seal_open item=<handle>. `wait`: seconds to wait for something new, 0 to 300.",
12015
+ annotations: { readOnlyHint: true, openWorldHint: true },
12016
+ input: {
12017
+ properties: {
12018
+ wait: { type: "integer", minimum: 0, maximum: 300, default: 0, description: "Seconds to wait for an item this server has not seen yet; 0 returns the inbox now." }
12019
+ }
12020
+ },
12021
+ flow: "`GET /intake?to=<this server's address>` under the proof of the key (seal_address.v1.json proof), cached until 30 s before it expires. The first call in a process returns the list at once; later ones long-poll by 25 s with `seen` = the version of the last answer until an id this process has not seen arrives or `wait` runs out. Handles, rows and the version live in process memory.",
12022
+ output: {
12023
+ type: "object",
12024
+ required: ["address_emoji", "items"],
12025
+ properties: {
12026
+ address_emoji: { type: "string", description: "Three emoji of this server's address (`fingerprint_emoji.json`)." },
12027
+ items: {
12028
+ type: "array",
12029
+ items: {
12030
+ type: "object",
12031
+ required: ["handle", "from", "label", "received_at", "expires_at"],
12032
+ additionalProperties: false,
12033
+ properties: {
12034
+ handle: { type: "string", pattern: "^in_", description: "`in_` + the inbox item id; pass as seal_open `item`." },
12035
+ from: { type: ["string", "null"], description: "Three emoji of the sender's address when the server verified it, else null." },
12036
+ label: { type: ["string", "null"], description: "What the sender called itself (an agent's name, `sealnet`), in the clear: never content." },
12037
+ received_at: { type: "string", format: "date-time" },
12038
+ expires_at: { type: "string", format: "date-time" }
12039
+ }
12040
+ }
12041
+ }
12042
+ },
12043
+ MUST_NOT_contain: ["path", "fragment", "url", "seal_id", "share_code"]
12044
+ }
12045
+ },
11862
12046
  seal_list: {
11863
12047
  title: "List SEALs created via THIS MCP server",
11864
12048
  description: "Seals you created through this server: opaque handles with status and expiry. Seals made with the CLI or the desktop client live elsewhere and are not listed.",
@@ -12182,6 +12366,10 @@ var seal_mcp_v1_default = {
12182
12366
  share_blob_corrupt: "Share blob decoded but a file_key is not valid base64url or has the wrong length (32 bytes expected). Backend bug or share-blob tampering.",
12183
12367
  share_blob_failed: "`encryptShareBlob` threw during seal_share mint (Web Crypto failure or input shape bug). Caller-facing in seal_share path only.",
12184
12368
  share_link_failed: "POST /seal/{id}/share returned an error (network, 5xx, owner_token mismatch).",
12369
+ inbox_proof_failed: "seal_inbox / seal_open item / seal_share forward: the server refused the proof of this server's key (`intake.proof_required`) even after one fresh challenge.",
12370
+ inbox_full: "seal_share forward: the recipient's inbox holds `limits.json::INBOX_MAX_ITEMS` live items from other senders (`intake.inbox_full`). The seal is revoked; retry later.",
12371
+ inbox_item_gone: "seal_open item: the handle is not in this process's inbox and not in a fresh read of it \u2014 the seal or link was revoked or expired, or the item was removed.",
12372
+ inbox_failed: "seal_inbox / seal_open item: reading or cleaning the inbox failed for a reason other than the proof (network, 5xx, rate limit); the message has the status. Safe to retry.",
12185
12373
  targeted_fragment_invalid: "seal_open of `x25519:` URL: fragment is malformed base64url, wrong packed length, or AEAD-failed for a non-secret reason. Distinct from `targeted_decrypt_failed` which is the wrong-recipient case.",
12186
12374
  targeted_decrypt_failed: "seal_open of `x25519:` URL: AEAD verification failed under THIS device's identity. The URL was not addressed to this MCP's pubkey (or was tampered, or seal_id from path-segment was substituted). Recipient should ask the sender to re-mint with `--to <our pubkey>`.",
12187
12375
  auth_required: "Backend returned 401/403 because the seal has OTP / NDA / login gating; interactive auth is not supported in MCP. Open the URL in a browser.",
@@ -12791,6 +12979,72 @@ var ToolError = class extends Error {
12791
12979
  }
12792
12980
  };
12793
12981
 
12982
+ // src/tools/inbox.ts
12983
+ var HANDLE_PREFIX2 = "in_";
12984
+ function inboxError(err2) {
12985
+ if (!(err2 instanceof BackendError)) return err2;
12986
+ if (err2.appCode === "intake.proof_required") {
12987
+ return new ToolError("inbox_proof_failed", `the server refused the proof of this server's key: ${err2.message}`, err2);
12988
+ }
12989
+ return new ToolError("inbox_failed", `the inbox failed (${err2.status}): ${err2.message}`, err2);
12990
+ }
12991
+ function memoryOf(ctx) {
12992
+ if (!ctx.inbox) throw new ToolError("internal_inconsistency", "this server keeps no inbox memory");
12993
+ return ctx.inbox;
12994
+ }
12995
+ async function sealInbox(ctx, input = {}) {
12996
+ const memory = memoryOf(ctx);
12997
+ const deadline = Date.now() + (memory.version === null ? 0 : (input.wait ?? 0) * 1e3);
12998
+ let seen = memory.version ?? void 0;
12999
+ let inbox;
13000
+ for (; ; ) {
13001
+ const left = Math.ceil((deadline - Date.now()) / 1e3);
13002
+ try {
13003
+ inbox = await readInbox(ctx.client, ctx.identity.secret, left > 0 ? { waitSeconds: left, seen } : {});
13004
+ } catch (err2) {
13005
+ throw inboxError(err2);
13006
+ }
13007
+ const arrived = inbox.items.some((item) => !memory.items.has(HANDLE_PREFIX2 + item.id));
13008
+ if (left <= 0 || arrived || Date.now() >= deadline) break;
13009
+ seen = inbox.version;
13010
+ }
13011
+ memory.items.clear();
13012
+ for (const item of inbox.items) memory.items.set(HANDLE_PREFIX2 + item.id, item);
13013
+ memory.version = inbox.version;
13014
+ return {
13015
+ addressEmoji: keyEmoji(ctx.identity.public),
13016
+ items: inbox.items.map((item) => ({
13017
+ handle: HANDLE_PREFIX2 + item.id,
13018
+ from: item.sender === null ? null : keyEmoji(base64urlToBytes(item.sender)),
13019
+ label: item.label,
13020
+ receivedAt: item.at,
13021
+ expiresAt: item.expires_at
13022
+ }))
13023
+ };
13024
+ }
13025
+ async function inboxItem(ctx, handle) {
13026
+ const memory = memoryOf(ctx);
13027
+ if (!memory.items.has(handle)) await sealInbox(ctx, {});
13028
+ const item = memory.items.get(handle);
13029
+ if (!item) {
13030
+ throw new ToolError(
13031
+ "inbox_item_gone",
13032
+ `${handle} is not in the inbox: the seal or link was revoked or expired, or the item was removed`
13033
+ );
13034
+ }
13035
+ return item;
13036
+ }
13037
+ async function forgetInboxItem(ctx, handle) {
13038
+ const item = memoryOf(ctx).items.get(handle);
13039
+ if (!item) return;
13040
+ try {
13041
+ await removeFromInbox(ctx.client, ctx.identity.secret, item.id);
13042
+ } catch (err2) {
13043
+ throw inboxError(err2);
13044
+ }
13045
+ memoryOf(ctx).items.delete(handle);
13046
+ }
13047
+
12794
13048
  // src/tools/list.ts
12795
13049
  var SINGLE_FILE_COUNT = 1;
12796
13050
  async function sealList(ctx, input = {}) {
@@ -12882,11 +13136,28 @@ async function download(ctx, sealId, file) {
12882
13136
  return resp.body;
12883
13137
  }
12884
13138
  async function sealOpen(ctx, input) {
13139
+ if (input.url === void 0 === (input.item === void 0)) {
13140
+ throw new ToolError("url_invalid", "give exactly one of `url` (a SEAL link) and `item` (an inbox handle from seal_inbox)");
13141
+ }
13142
+ const recipient = { recipientSecret: ctx.identity.secret };
13143
+ if (input.url !== void 0) return openSeal(ctx, input, () => openShareLink(ctx.client, input.url, recipient));
13144
+ const handle = input.item;
13145
+ const item = await inboxItem(ctx, handle);
13146
+ try {
13147
+ return await openSeal(ctx, input, () => openShareLink(ctx.client, inboxItemUrl(resolvePublicHost(), item), recipient));
13148
+ } catch (err2) {
13149
+ if (err2 instanceof ToolError && (err2.code === "seal_revoked" || err2.code === "share_link_revoked")) {
13150
+ await forgetInboxItem(ctx, handle).catch(() => void 0);
13151
+ }
13152
+ throw err2;
13153
+ }
13154
+ }
13155
+ async function openSeal(ctx, input, read) {
12885
13156
  const mode = input.mode ?? "metadata";
12886
13157
  const handle = generateHandle();
12887
13158
  let seal;
12888
13159
  try {
12889
- seal = await openShareLink(ctx.client, input.url, { recipientSecret: ctx.identity.secret });
13160
+ seal = await read();
12890
13161
  } catch (err2) {
12891
13162
  throw asToolError(err2);
12892
13163
  }
@@ -13234,7 +13505,9 @@ var limits_default2 = {
13234
13505
  INTAKE_TTL_DEFAULT_SECONDS: 900,
13235
13506
  INTAKE_TTL_MAX_SECONDS: 86400,
13236
13507
  INTAKE_WAIT_MAX_SECONDS: 25,
13237
- PAYMENT_WAIT_MAX_SECONDS: 82800
13508
+ PAYMENT_WAIT_MAX_SECONDS: 82800,
13509
+ ADDRESS_PROOF_TTL_SECONDS: 300,
13510
+ INBOX_MAX_ITEMS: 200
13238
13511
  }
13239
13512
  };
13240
13513
 
@@ -13752,17 +14025,6 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13752
14025
  );
13753
14026
  }
13754
14027
  const shareBlobB64 = bytesToBase64url(shareBlobBytes);
13755
- let shareCode;
13756
- try {
13757
- shareCode = await ctx.client.createShareLink(uploaded.sealId, ownerTokenB64, shareBlobB64);
13758
- } catch (err2) {
13759
- const outcome = await compensateRevoke(ctx, handle, op);
13760
- throw new ToolError(
13761
- "share_link_failed",
13762
- `POST /seal/${uploaded.sealId}/share failed: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
13763
- err2
13764
- );
13765
- }
13766
14028
  let fragment;
13767
14029
  if (mode === "forward") {
13768
14030
  if (recipientPub === null) {
@@ -13792,6 +14054,29 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13792
14054
  } else {
13793
14055
  fragment = bytesToBase64url(shareLinkKey);
13794
14056
  }
14057
+ if (mode === "forward") await journalDelivering(ctx, handle, op);
14058
+ let shareCode;
14059
+ try {
14060
+ shareCode = mode === "forward" && recipientPub !== null ? await withAddressProof(
14061
+ ctx.client,
14062
+ ctx.identity.secret,
14063
+ void 0,
14064
+ (sender) => ctx.client.createShareLink(uploaded.sealId, ownerTokenB64, shareBlobB64, {
14065
+ to: bytesToBase64url(recipientPub),
14066
+ fragment,
14067
+ label: deps.label?.slice(0, 80),
14068
+ sender
14069
+ })
14070
+ ) : await ctx.client.createShareLink(uploaded.sealId, ownerTokenB64, shareBlobB64);
14071
+ } catch (err2) {
14072
+ const outcome = await compensateRevoke(ctx, handle, op);
14073
+ const appCode = err2 instanceof BackendError ? err2.appCode : void 0;
14074
+ throw new ToolError(
14075
+ appCode === "intake.inbox_full" ? "inbox_full" : appCode === "intake.proof_required" ? "inbox_proof_failed" : "share_link_failed",
14076
+ `POST /seal/${uploaded.sealId}/share failed: ${messageOf3(err2)} (${outcome})`,
14077
+ err2
14078
+ );
14079
+ }
13795
14080
  const host = resolvePublicHost(deps.publicHost);
13796
14081
  const shareUrl = `${host}/s/${uploaded.sealId}/${shareCode}#${fragment}`;
13797
14082
  if (mode === "forward") {
@@ -13808,21 +14093,12 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13808
14093
  shareUrl,
13809
14094
  expiresAt,
13810
14095
  maxReads: FORWARD_MAX_READS_FORCED,
13811
- mode: "forward"
14096
+ mode: "forward",
14097
+ delivered: true
13812
14098
  };
13813
14099
  }
13814
- op.step = "delivering";
13815
14100
  op.handoff_channels_attempted = deps.handoff?.disableClipboard ? ["file"] : ["clipboard", "file"];
13816
- try {
13817
- await journal(ctx, handle, op);
13818
- } catch (err2) {
13819
- const outcome = await compensateRevoke(ctx, handle, op);
13820
- throw new ToolError(
13821
- "state_persist_failed",
13822
- `could not journal the delivery attempt before handing off the URL: ${err2 instanceof Error ? err2.message : String(err2)} (${outcome})`,
13823
- err2
13824
- );
13825
- }
14101
+ await journalDelivering(ctx, handle, op);
13826
14102
  let handoff;
13827
14103
  try {
13828
14104
  handoff = await performHandoff({ handle, shareUrl, expireMs }, deps.handoff);
@@ -13857,6 +14133,19 @@ async function deliver(ctx, deps, { mode, recipientPub, expireMs }, { handle, op
13857
14133
  function messageOf3(err2) {
13858
14134
  return err2 instanceof Error ? err2.message : String(err2);
13859
14135
  }
14136
+ async function journalDelivering(ctx, handle, op) {
14137
+ op.step = "delivering";
14138
+ try {
14139
+ await journal(ctx, handle, op);
14140
+ } catch (err2) {
14141
+ const outcome = await compensateRevoke(ctx, handle, op);
14142
+ throw new ToolError(
14143
+ "state_persist_failed",
14144
+ `could not journal the delivery attempt before the link could reach anyone: ${messageOf3(err2)} (${outcome})`,
14145
+ err2
14146
+ );
14147
+ }
14148
+ }
13860
14149
  async function awaitPayment(ctx, deps, prepared, { handle, op, ownerTokenB64, session }) {
13861
14150
  const tier = prepared.tier;
13862
14151
  const sealId = op.seal_id;
@@ -13948,7 +14237,8 @@ function shareOutputToWire(out) {
13948
14237
  share_url: out.shareUrl,
13949
14238
  expires_at: out.expiresAt,
13950
14239
  max_reads: out.maxReads,
13951
- mode: out.mode
14240
+ mode: out.mode,
14241
+ delivered: out.delivered
13952
14242
  };
13953
14243
  }
13954
14244
  const wire = {
@@ -14015,6 +14305,18 @@ function openOutputToWire(out) {
14015
14305
  expires_at: out.expiresAt
14016
14306
  };
14017
14307
  }
14308
+ function inboxOutputToWire(out) {
14309
+ return {
14310
+ address_emoji: out.addressEmoji,
14311
+ items: out.items.map((item) => ({
14312
+ handle: item.handle,
14313
+ from: item.from,
14314
+ label: item.label,
14315
+ received_at: item.receivedAt,
14316
+ expires_at: item.expiresAt
14317
+ }))
14318
+ };
14319
+ }
14018
14320
  function listOutputToWire(out) {
14019
14321
  return {
14020
14322
  entries: out.entries.map((entry) => ({
@@ -14045,7 +14347,7 @@ function proFileGetOutputToWire(out) {
14045
14347
  }
14046
14348
 
14047
14349
  // src/server.ts
14048
- var SERVER_VERSION = "0.3.0";
14350
+ var SERVER_VERSION = "0.4.0";
14049
14351
  var toolMeta = (name) => {
14050
14352
  const { title, description, annotations } = seal_mcp_v1_default.tools[name];
14051
14353
  return { title, description, annotations };
@@ -14053,6 +14355,7 @@ var toolMeta = (name) => {
14053
14355
  var SHARE_INPUT = seal_mcp_v1_default.tools.seal_share.input.properties;
14054
14356
  var REQUEST_INPUT = seal_mcp_v1_default.tools.seal_request.input.properties;
14055
14357
  var OPEN_INPUT = seal_mcp_v1_default.tools.seal_open.input.properties;
14358
+ var INBOX_INPUT = seal_mcp_v1_default.tools.seal_inbox.input.properties;
14056
14359
  var PRO_SECRET_USE = seal_mcp_v1_default.pro_tools.seal_pro_secret_use;
14057
14360
  var PRO_SECRET_REQUEST = seal_mcp_v1_default.pro_tools.seal_pro_secret_request;
14058
14361
  var PRO_FILE_GET = seal_mcp_v1_default.pro_tools.seal_pro_file_get;
@@ -14097,7 +14400,8 @@ async function createServer(options) {
14097
14400
  update,
14098
14401
  ephemeral: disk === null,
14099
14402
  requests: /* @__PURE__ */ new Map(),
14100
- payments: /* @__PURE__ */ new Map()
14403
+ payments: /* @__PURE__ */ new Map(),
14404
+ inbox: { items: /* @__PURE__ */ new Map(), version: null }
14101
14405
  };
14102
14406
  const mcp = new import_mcp.McpServer(
14103
14407
  { name: "sealnet-mcp", version: SERVER_VERSION },
@@ -14144,6 +14448,7 @@ async function createServer(options) {
14144
14448
  },
14145
14449
  {
14146
14450
  publicHost,
14451
+ label: client2.getClientVersion()?.name,
14147
14452
  handoff: disableClipboard ? { disableClipboard: true } : void 0,
14148
14453
  elicitUrl: urlMode ? async (params) => (await client2.elicitInput({ mode: "url", ...params })).action : void 0,
14149
14454
  elicitationDone: urlMode ? (id) => client2.createElicitationCompletionNotifier(id)() : void 0
@@ -14197,19 +14502,37 @@ async function createServer(options) {
14197
14502
  {
14198
14503
  ...toolMeta("seal_open"),
14199
14504
  inputSchema: {
14200
- url: external_exports.string().url().describe(OPEN_INPUT.url.description),
14505
+ // Exactly one of `url` and `item`: the handler refuses anything else (contract `one_of`).
14506
+ url: external_exports.string().url().optional().describe(OPEN_INPUT.url.description),
14507
+ item: external_exports.string().regex(/^in_/).optional().describe(OPEN_INPUT.item.description),
14201
14508
  mode: external_exports.enum(["metadata", "inline", "file"]).default("metadata").describe(OPEN_INPUT.mode.description),
14202
14509
  dir: external_exports.string().optional().describe(OPEN_INPUT.dir.description)
14203
14510
  }
14204
14511
  },
14205
14512
  async (args, extra) => {
14206
14513
  const result = await wrapTool(
14207
- () => sealOpen(ctx, { url: args.url, mode: args.mode, dir: args.dir, onProgress: progressReporter(extra) }),
14514
+ () => sealOpen(ctx, {
14515
+ url: args.url,
14516
+ item: args.item,
14517
+ mode: args.mode,
14518
+ dir: args.dir,
14519
+ onProgress: progressReporter(extra)
14520
+ }),
14208
14521
  openOutputToWire
14209
14522
  );
14210
14523
  return result;
14211
14524
  }
14212
14525
  );
14526
+ mcp.registerTool(
14527
+ "seal_inbox",
14528
+ {
14529
+ ...toolMeta("seal_inbox"),
14530
+ inputSchema: {
14531
+ wait: external_exports.number().int().min(0).max(300).default(0).describe(INBOX_INPUT.wait.description)
14532
+ }
14533
+ },
14534
+ async (args) => wrapTool(() => sealInbox(ctx, { wait: args.wait }), inboxOutputToWire)
14535
+ );
14213
14536
  mcp.registerTool(
14214
14537
  "seal_list",
14215
14538
  {