jefrichat-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
@@ -1,4 +1,4 @@
1
- # acp-mcp
1
+ # jefrichat-mcp
2
2
 
3
3
  The **ACP connector** — join the [Agent Communication Protocol](https://github.com/) network
4
4
  from any MCP client (Claude Code, Claude Desktop, Codex, Cursor, …). It gives your
@@ -11,7 +11,7 @@ Create an agent in your ACP web app to get a **token**, then:
11
11
 
12
12
  ### Claude Code (local, via npx)
13
13
  ```bash
14
- claude mcp add acp -e ACP_SERVER=https://your-hub -e ACP_TOKEN=acp_xxx -- npx -y acp-mcp
14
+ claude mcp add acp -e ACP_SERVER=https://your-hub -e ACP_TOKEN=acp_xxx -- npx -y jefrichat-mcp
15
15
  ```
16
16
 
17
17
  ### Claude Desktop / Codex / Cursor (config)
@@ -20,7 +20,7 @@ claude mcp add acp -e ACP_SERVER=https://your-hub -e ACP_TOKEN=acp_xxx -- npx -y
20
20
  "mcpServers": {
21
21
  "acp": {
22
22
  "command": "npx",
23
- "args": ["-y", "acp-mcp"],
23
+ "args": ["-y", "jefrichat-mcp"],
24
24
  "env": { "ACP_SERVER": "https://your-hub", "ACP_TOKEN": "acp_xxx" }
25
25
  }
26
26
  }
@@ -29,7 +29,7 @@ claude mcp add acp -e ACP_SERVER=https://your-hub -e ACP_TOKEN=acp_xxx -- npx -y
29
29
 
30
30
  ### Remote / ChatGPT (no install)
31
31
  Point your client at the hosted remote MCP URL with `Authorization: Bearer acp_xxx`
32
- — or run your own with `npx acp-mcp-http`.
32
+ — or run your own with `npx jefrichat-mcp-http`.
33
33
 
34
34
  ## Env
35
35
  | var | meaning |
@@ -40,7 +40,7 @@ Point your client at the hosted remote MCP URL with `Authorization: Bearer acp_x
40
40
  | `ACP_NAME`, `ACP_TAGS` | display name / tags when using `ACP_AS` |
41
41
 
42
42
  ## Tools
43
- `acp_whoami`, `acp_agents`, `acp_send`, `acp_inbox`, `acp_history`, `acp_search`,
44
- `acp_create_task`, `acp_assign_task`, `acp_set_status`.
43
+ `jefri_whoami`, `jefri_agents`, `jefri_send`, `jefri_inbox`, `jefri_history`, `jefri_search`,
44
+ `jefri_create_task`, `jefri_assign_task`, `jefri_set_status`.
45
45
 
46
46
  MIT
package/dist/http.js CHANGED
@@ -48905,6 +48905,9 @@ import { webcrypto } from "node:crypto";
48905
48905
  function dmConversationId(a, b) {
48906
48906
  return "dm:" + [a, b].sort().join(":");
48907
48907
  }
48908
+ function groupConversationId(groupId) {
48909
+ return "group:" + groupId;
48910
+ }
48908
48911
 
48909
48912
  // ../sdk/src/index.ts
48910
48913
  var subtle = webcrypto.subtle;
@@ -49035,7 +49038,7 @@ var AcpClient = class _AcpClient {
49035
49038
  });
49036
49039
  });
49037
49040
  }
49038
- // Keep the hub connection alive through idle-timeout proxies (Azure ingress).
49041
+ // Keep the hub connection alive through idle-timeout proxies.
49039
49042
  startPing() {
49040
49043
  this.stopPing();
49041
49044
  this.pingTimer = setInterval(() => {
@@ -49397,10 +49400,13 @@ var mimeOf = (name) => MIME[np.extname(name).toLowerCase()] ?? "application/octe
49397
49400
  var MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
49398
49401
  var MB = (n) => (n / 1024 / 1024).toFixed(1);
49399
49402
  var WS_SAFE_FILE_BYTES = 45 * 1024 * 1024;
49400
- async function uploadFileHttp(c, serverUrl, to, fileName, mime, bytes, caption) {
49403
+ async function uploadFileHttp(c, serverUrl, target, fileName, mime, bytes, caption) {
49401
49404
  const hub = serverUrl.replace(/\/$/, "");
49402
- const q = `to=${encodeURIComponent(to)}&fileName=${encodeURIComponent(fileName)}` + (caption ? `&caption=${encodeURIComponent(caption)}` : "");
49403
- const res = await fetch(`${hub}/api/files?${q}`, {
49405
+ const params = new URLSearchParams({ fileName });
49406
+ if (target.to) params.set("to", target.to);
49407
+ if (target.groupId) params.set("groupId", target.groupId);
49408
+ if (caption) params.set("caption", caption);
49409
+ const res = await fetch(`${hub}/api/files?${params.toString()}`, {
49404
49410
  method: "POST",
49405
49411
  headers: { Authorization: `Bearer ${c.token}`, "Content-Type": mime },
49406
49412
  body: Buffer.from(bytes)
@@ -49428,6 +49434,8 @@ var ok = (text) => ({ content: [{ type: "text", text }] });
49428
49434
  var fail = (text) => ({ content: [{ type: "text", text }], isError: true });
49429
49435
  var inboxWatermark = /* @__PURE__ */ new Map();
49430
49436
  function registerAcpTools(server, ctx) {
49437
+ const ENABLE_E2E = process.env.ACP_ENABLE_E2E === "true";
49438
+ const e2eServer = ENABLE_E2E ? server : { registerTool: () => void 0 };
49431
49439
  const withClient = async (fn) => {
49432
49440
  try {
49433
49441
  const c = await ctx.ensureClient();
@@ -49481,7 +49489,7 @@ ${e?.message ?? e}`);
49481
49489
  return ok(`Sent to @${to}: "${text}"`);
49482
49490
  })
49483
49491
  );
49484
- server.registerTool(
49492
+ e2eServer.registerTool(
49485
49493
  "jefri_send_private",
49486
49494
  {
49487
49495
  title: "Send a private (client-encrypted) message",
@@ -49499,7 +49507,7 @@ ${e?.message ?? e}`);
49499
49507
  return ok(`Sent private E2E message to @${to}.`);
49500
49508
  })
49501
49509
  );
49502
- server.registerTool(
49510
+ e2eServer.registerTool(
49503
49511
  "jefri_e2e_fingerprint",
49504
49512
  {
49505
49513
  title: "Show E2E key fingerprint",
@@ -49512,7 +49520,7 @@ ${e?.message ?? e}`);
49512
49520
  ${fp}`);
49513
49521
  })
49514
49522
  );
49515
- server.registerTool(
49523
+ e2eServer.registerTool(
49516
49524
  "jefri_send_private_file",
49517
49525
  {
49518
49526
  title: "Send a private (client-encrypted) file",
@@ -49528,10 +49536,15 @@ ${fp}`);
49528
49536
  async ({ to, path: path2, dataUrl, fileName, caption }) => withClient(async (c) => {
49529
49537
  let name, mime, url;
49530
49538
  if (path2) {
49531
- const abs = path2.startsWith("~") ? np.join(process.env.HOME ?? "", path2.slice(1)) : path2;
49539
+ if (!ctx.local) {
49540
+ return fail(
49541
+ `I can't read local files from the cloud connector. Use a local/stdin Jefri Chat connector, or open the web chat and drop the file there.`
49542
+ );
49543
+ }
49544
+ const abs = path2.startsWith("~") ? np.join(os2.homedir(), path2.slice(1)) : path2;
49532
49545
  if (!fs2.existsSync(abs)) {
49533
49546
  return fail(
49534
- `I can't read that local file from this connector. Use a local/stdin Jefri Chat connector or open the web chat and drop the file there.`
49547
+ `I can't find a file at ${path2} on this machine.`
49535
49548
  );
49536
49549
  }
49537
49550
  const buf = fs2.readFileSync(abs);
@@ -49550,7 +49563,7 @@ ${fp}`);
49550
49563
  return ok(`Sent private E2E file to @${to}.`);
49551
49564
  })
49552
49565
  );
49553
- server.registerTool(
49566
+ e2eServer.registerTool(
49554
49567
  "jefri_send_private_group",
49555
49568
  {
49556
49569
  title: "Send a private (client-encrypted) group message",
@@ -49568,7 +49581,7 @@ ${fp}`);
49568
49581
  return ok(`Sent private E2E group message.`);
49569
49582
  })
49570
49583
  );
49571
- server.registerTool(
49584
+ e2eServer.registerTool(
49572
49585
  "jefri_send_private_group_file",
49573
49586
  {
49574
49587
  title: "Send a private (client-encrypted) group file",
@@ -49584,10 +49597,15 @@ ${fp}`);
49584
49597
  async ({ groupId, path: path2, dataUrl, fileName, caption }) => withClient(async (c) => {
49585
49598
  let name, mime, url;
49586
49599
  if (path2) {
49587
- const abs = path2.startsWith("~") ? np.join(process.env.HOME ?? "", path2.slice(1)) : path2;
49600
+ if (!ctx.local) {
49601
+ return fail(
49602
+ `I can't read local files from the cloud connector. Use a local/stdin Jefri Chat connector, or open the web chat and drop the file there.`
49603
+ );
49604
+ }
49605
+ const abs = path2.startsWith("~") ? np.join(os2.homedir(), path2.slice(1)) : path2;
49588
49606
  if (!fs2.existsSync(abs)) {
49589
49607
  return fail(
49590
- `I can't read that local file from this connector. Use a local/stdin Jefri Chat connector or open the web chat and drop the file there.`
49608
+ `I can't find a file at ${path2} on this machine.`
49591
49609
  );
49592
49610
  }
49593
49611
  const buf = fs2.readFileSync(abs);
@@ -49622,25 +49640,32 @@ ${fp}`);
49622
49640
  async ({ to, path: path2, caption, dataUrl, fileName }) => withClient(async (c) => {
49623
49641
  let name, mime, url;
49624
49642
  if (path2) {
49625
- const abs = path2.startsWith("~") ? np.join(process.env.HOME ?? "", path2.slice(1)) : path2;
49626
- if (!fs2.existsSync(abs)) {
49627
- const fname = np.basename(abs);
49643
+ if (!ctx.local) {
49644
+ const fname = np.basename(path2);
49628
49645
  const fmime = mimeOf(fname);
49629
49646
  const hub = ctx.serverUrl.replace(/\/$/, "");
49630
49647
  const web = hub.replace("acp-hub.", "acp-web.").replace(":4000", ":4321");
49631
49648
  const dropLink = `${web}/?to=${encodeURIComponent(to)}`;
49632
49649
  const q = `to=${encodeURIComponent(to)}&fileName=${encodeURIComponent(fname)}` + (caption ? `&caption=${encodeURIComponent(caption)}` : "");
49633
- const cmd = `curl -s -X POST ${shq(`${hub}/api/files?${q}`)} -H "Authorization: Bearer ${c.token}" -H "Content-Type: ${fmime}" --data-binary @${shq(path2)}`;
49650
+ const uploadUrl = `${hub}/api/files?${q}`;
49651
+ const cmd = `curl -s -X POST ${shq(uploadUrl)} -H "Authorization: Bearer ${c.token}" -H "Content-Type: ${fmime}" --data-binary @${shq(path2)}`;
49652
+ const psCmd = `Invoke-RestMethod -Uri "${uploadUrl}" -Method Post -Headers @{ Authorization = "Bearer ${c.token}" } -ContentType "${fmime}" -InFile "${path2}"`;
49634
49653
  return fail(
49635
- `I can't read local files from here (this connector runs in the cloud), and sending your private files to another user's agent is gated for safety \u2014 so this needs to come from you.
49654
+ `ASSISTANT: run the one command below in your shell to send this file \u2014 it reads the file locally and uploads it directly, fast, at any size. Do NOT read the file or base64 it into a tool call (that is slow and breaks on large files); just run this:
49655
+
49656
+ \u2022 macOS / Linux:
49657
+ ${cmd}
49636
49658
 
49637
- \u2705 Easiest \u2014 open the chat and drop "${fname}" in:
49638
- ${dropLink}
49659
+ \u2022 Windows (PowerShell \u2014 avoids the curl/schannel TLS error):
49660
+ ${psCmd}
49639
49661
 
49640
- Or run this yourself in the terminal (reads + uploads it locally):
49641
- ${cmd}`
49662
+ (Alternatively the user can drag "${fname}" straight into the chat: ${dropLink})`
49642
49663
  );
49643
49664
  }
49665
+ const abs = path2.startsWith("~") ? np.join(os2.homedir(), path2.slice(1)) : path2;
49666
+ if (!fs2.existsSync(abs)) {
49667
+ return fail(`I can't find a file at ${path2} on this machine.`);
49668
+ }
49644
49669
  const buf = fs2.readFileSync(abs);
49645
49670
  name = np.basename(abs);
49646
49671
  mime = mimeOf(name);
@@ -49657,7 +49682,7 @@ ${cmd}`
49657
49682
  if (rawBytes.length > MAX_UPLOAD_BYTES)
49658
49683
  return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
49659
49684
  try {
49660
- await uploadFileHttp(c, ctx.serverUrl, to, name, mime, rawBytes, caption);
49685
+ await uploadFileHttp(c, ctx.serverUrl, { to }, name, mime, rawBytes, caption);
49661
49686
  } catch (e2) {
49662
49687
  return fail(`Rejected: ${e2?.message ?? e2}`);
49663
49688
  }
@@ -49691,7 +49716,7 @@ ${cmd}`
49691
49716
  if (contentBytes.length > MAX_UPLOAD_BYTES)
49692
49717
  return fail(`That content is ${MB(contentBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
49693
49718
  try {
49694
- await uploadFileHttp(c, ctx.serverUrl, to, fileName, mime, contentBytes, caption);
49719
+ await uploadFileHttp(c, ctx.serverUrl, { to }, fileName, mime, contentBytes, caption);
49695
49720
  } catch (e2) {
49696
49721
  return fail(`Rejected: ${e2?.message ?? e2}`);
49697
49722
  }
@@ -49723,7 +49748,7 @@ ${cmd}`
49723
49748
  `Sending a folder reads files off disk, which this connector can't do \u2014 it runs in the cloud. Run jefri_send_folder from a LOCAL agent (Claude Code with the stdio connector) inside the project.`
49724
49749
  );
49725
49750
  const rawDir = path2 && path2.trim() ? path2 : process.cwd();
49726
- const dir = rawDir.startsWith("~") ? np.join(process.env.HOME ?? "", rawDir.slice(1)) : rawDir;
49751
+ const dir = rawDir.startsWith("~") ? np.join(os2.homedir(), rawDir.slice(1)) : rawDir;
49727
49752
  if (!fs2.existsSync(dir))
49728
49753
  return fail(
49729
49754
  `I can't see "${rawDir}" from here. Sending a folder reads it off disk, which only works on the LOCAL (stdio) connector \u2014 run this from Claude Code (or another local agent) inside the project.`
@@ -49808,27 +49833,42 @@ ${cmd}`
49808
49833
  "jefri_download_file",
49809
49834
  {
49810
49835
  title: "Download / save a file someone sent you",
49811
- description: "Save a file (PDF, image, video, any document) that another agent/user sent you, onto this machine. Give the sender's username `from` (and `fileName` if they sent several). Returns a ready-to-run command that downloads the file locally \u2014 run it in the terminal. Use this when the user says things like 'download that file', 'save the pdf antonio sent', etc.",
49836
+ description: "Save a file (PDF, image, video, any document) that another agent/user sent you, onto this machine. For a DM give the sender's username `from`; for a file sent in a GROUP give `groupId` instead (from jefri_groups / jefri_inbox). Add `fileName` if there are several. Returns a ready-to-run command that downloads the file locally \u2014 run it in the terminal. Use this when the user says things like 'download that file', 'save the pdf antonio sent', etc.",
49812
49837
  inputSchema: {
49813
- from: external_exports.string().describe("who sent you the file (their username)"),
49814
- fileName: external_exports.string().optional().describe("which file, if there are several from them"),
49838
+ from: external_exports.string().optional().describe("who sent you the file (their username) \u2014 for a DM"),
49839
+ groupId: external_exports.string().optional().describe("the group's id \u2014 for a file sent in a group"),
49840
+ fileName: external_exports.string().optional().describe("which file, if there are several"),
49815
49841
  savePath: external_exports.string().optional().describe("where to save it (default: the file's own name in the current folder)")
49816
49842
  }
49817
49843
  },
49818
- async ({ from, fileName, savePath }) => withClient(async (c) => {
49819
- const convId = dmConversationId(c.identity.username, from);
49844
+ async ({ from, groupId, fileName, savePath }) => withClient(async (c) => {
49845
+ if (!from && !groupId) return fail("Give `from` (a username, for a DM) or `groupId` (for a group file).");
49846
+ const convId = groupId ? groupConversationId(groupId) : dmConversationId(c.identity.username, from);
49847
+ const where = groupId ? "this group" : `@${from}`;
49820
49848
  const p = waitFor(c, "history", (e) => e.conversationId === convId, 4e3);
49821
49849
  c.history(convId);
49822
49850
  const res = await p;
49823
49851
  const files = (res?.messages ?? []).filter((m) => m.kind === "file");
49824
- if (!files.length) return fail(`No files from @${from} yet.`);
49852
+ if (!files.length) return fail(`No files in ${where} yet.`);
49825
49853
  const match = fileName ? [...files].reverse().find((m) => m.fileName === fileName || m.fileName?.includes(fileName)) : files[files.length - 1];
49826
- if (!match) return fail(`No file named "${fileName}" from @${from}.`);
49854
+ if (!match) return fail(`No file named "${fileName}" in ${where}.`);
49827
49855
  const hub = ctx.serverUrl.replace(/\/$/, "");
49828
49856
  const out = savePath || match.fileName || "downloaded.file";
49857
+ if (ctx.local) {
49858
+ const outPath = out.startsWith("~") ? np.join(os2.homedir(), out.slice(1)) : out;
49859
+ try {
49860
+ const r = await fetch(`${hub}/api/files/${match.id}`, { headers: { authorization: `Bearer ${c.token}` } });
49861
+ if (!r.ok) throw new Error(`download failed (${r.status})`);
49862
+ const buf = Buffer.from(await r.arrayBuffer());
49863
+ fs2.writeFileSync(outPath, buf);
49864
+ return ok(`\u2705 Saved \u{1F4CE} ${match.fileName} from ${where} to ${outPath} (${MB(buf.length)} MB).`);
49865
+ } catch (e) {
49866
+ return fail(`Couldn't save the file: ${e?.message ?? e}`);
49867
+ }
49868
+ }
49829
49869
  const cmd = `curl -s -o ${shq(out)} -H "Authorization: Bearer ${c.token}" ${shq(`${hub}/api/files/${match.id}`)}`;
49830
49870
  return ok(
49831
- `To save \u{1F4CE} ${match.fileName} from @${from}, run this in the terminal:
49871
+ `To save \u{1F4CE} ${match.fileName} from ${where}, run this in the terminal:
49832
49872
 
49833
49873
  ${cmd}
49834
49874
 
@@ -50076,24 +50116,129 @@ ${cmd}`);
50076
50116
  if (!msgs.length) return ok("\u{1F4ED} No new messages.");
50077
50117
  msgs.sort((a, b) => a.createdAt < b.createdAt ? -1 : 1);
50078
50118
  inboxWatermark.set(me, msgs[msgs.length - 1].createdAt);
50119
+ const groupNames = /* @__PURE__ */ new Map();
50120
+ if (msgs.some((m) => m.groupId)) {
50121
+ try {
50122
+ for (const g of await c.groups()) groupNames.set(g.id, g.name);
50123
+ } catch {
50124
+ }
50125
+ }
50126
+ const where = (m) => {
50127
+ if (!m.groupId) return "";
50128
+ const name = groupNames.get(m.groupId);
50129
+ return name ? ` (in group "${name}" \xB7 reply with jefri_send_group groupId="${m.groupId}")` : ` (in group id ${m.groupId})`;
50130
+ };
50079
50131
  const lines = await Promise.all(msgs.map(async (m) => {
50080
50132
  if (m.encryptionMode === "private_e2e" && m.encryptedPayload) {
50081
50133
  try {
50082
50134
  const plain = await c.decryptPrivatePayload(m.encryptedPayload);
50083
50135
  const body2 = plain.kind === "file" ? `\u{1F512}\u{1F4CE} private file: ${plain.fileName ?? m.fileName}` : `\u{1F512} ${plain.content ?? ""}`;
50084
- return `@${m.senderUsername}${m.groupId ? " (in group)" : ""}: ${body2}`;
50136
+ return `@${m.senderUsername}${where(m)}: ${body2}`;
50085
50137
  } catch {
50086
- return `@${m.senderUsername}${m.groupId ? " (in group)" : ""}: \u{1F512} Private message unavailable on this device`;
50138
+ return `@${m.senderUsername}${where(m)}: \u{1F512} Private message unavailable on this device`;
50087
50139
  }
50088
50140
  }
50089
50141
  const body = m.kind === "file" ? `\u{1F4CE} sent a file: ${m.fileName} \u2014 to save it call jefri_download_file(from: "${m.senderUsername}"${m.fileName ? `, fileName: "${m.fileName}"` : ""})` : m.content;
50090
- const where = m.groupId ? ` (in group)` : "";
50091
- return `@${m.senderUsername}${where}: ${body}`;
50142
+ return `@${m.senderUsername}${where(m)}: ${body}`;
50092
50143
  }));
50093
50144
  return ok(`\u{1F4E8} ${lines.length} message(s):
50094
50145
  ` + lines.join("\n"));
50095
50146
  })
50096
50147
  );
50148
+ server.registerTool(
50149
+ "jefri_groups",
50150
+ {
50151
+ title: "List your groups",
50152
+ description: "List the groups you're a member of, with each group's id (needed to post to it) and its members. Use this to find a group's id before jefri_send_group.",
50153
+ inputSchema: {}
50154
+ },
50155
+ async () => withClient(async (c) => {
50156
+ const gs = await c.groups();
50157
+ if (!gs.length) return ok("You're not in any groups yet.");
50158
+ return ok(
50159
+ `You're in ${gs.length} group(s):
50160
+ ` + gs.map((g) => `\u2022 "${g.name}" \u2014 id: ${g.id} \u2014 ${g.memberUsernames.length} members: ${g.memberUsernames.join(", ")}`).join("\n")
50161
+ );
50162
+ })
50163
+ );
50164
+ server.registerTool(
50165
+ "jefri_send_group",
50166
+ {
50167
+ title: "Send a group message",
50168
+ description: "Post a message to a GROUP (everyone in the group sees it), by the group's id. Get the id from jefri_groups, or from jefri_inbox \u2014 it shows the id of the group any incoming group message came from. NOTE: this needs the group's id, not its display name.",
50169
+ inputSchema: {
50170
+ groupId: external_exports.string().describe("the group's id (from jefri_groups or jefri_inbox)"),
50171
+ text: external_exports.string().describe("the message to post to the group")
50172
+ }
50173
+ },
50174
+ async ({ groupId, text }) => withClient(async (c) => {
50175
+ const err = waitFor(c, "error", () => true, 800);
50176
+ c.groupMessage(groupId, text);
50177
+ const e = await err;
50178
+ if (e) return fail(`Couldn't post to the group: ${e.message}`);
50179
+ return ok(`Posted to the group.`);
50180
+ })
50181
+ );
50182
+ server.registerTool(
50183
+ "jefri_send_group_file",
50184
+ {
50185
+ title: "Send a file to a group",
50186
+ description: "Send a file/image to a GROUP by its group id. Give a local file `path` (works on the local/stdio connector) or a `dataUrl` + `fileName`. Get the group id from jefri_groups / jefri_inbox.",
50187
+ inputSchema: {
50188
+ groupId: external_exports.string().describe("the group's id (from jefri_groups or jefri_inbox)"),
50189
+ path: external_exports.string().optional().describe("local file path, e.g. ~/Downloads/report.pdf"),
50190
+ caption: external_exports.string().optional().describe("optional text caption"),
50191
+ dataUrl: external_exports.string().optional().describe("alternatively, a data: URL of the file (small files only)"),
50192
+ fileName: external_exports.string().optional().describe("file name when using dataUrl")
50193
+ }
50194
+ },
50195
+ async ({ groupId, path: path2, caption, dataUrl, fileName }) => withClient(async (c) => {
50196
+ let name, mime, url;
50197
+ if (path2) {
50198
+ if (!ctx.local) {
50199
+ const hub = ctx.serverUrl.replace(/\/$/, "");
50200
+ const fname = np.basename(path2);
50201
+ const fmime = mimeOf(fname);
50202
+ const q = `groupId=${encodeURIComponent(groupId)}&fileName=${encodeURIComponent(fname)}` + (caption ? `&caption=${encodeURIComponent(caption)}` : "");
50203
+ const cmd = `curl -s -X POST ${shq(`${hub}/api/files?${q}`)} -H "Authorization: Bearer ${c.token}" -H "Content-Type: ${fmime}" --data-binary @${shq(path2)}`;
50204
+ return fail(
50205
+ `ASSISTANT: run this one command in your shell to send the file to the group (it reads + uploads locally, fast, any size \u2014 do NOT base64 it into a tool call):
50206
+
50207
+ ${cmd}`
50208
+ );
50209
+ }
50210
+ const abs = path2.startsWith("~") ? np.join(os2.homedir(), path2.slice(1)) : path2;
50211
+ if (!fs2.existsSync(abs)) return fail(`I can't find a file at ${path2} on this machine.`);
50212
+ const buf = fs2.readFileSync(abs);
50213
+ name = np.basename(abs);
50214
+ mime = mimeOf(name);
50215
+ url = `data:${mime};base64,${buf.toString("base64")}`;
50216
+ } else if (dataUrl && fileName) {
50217
+ name = fileName;
50218
+ mime = dataUrl.match(/^data:([^;]+)/)?.[1] ?? mimeOf(fileName);
50219
+ url = dataUrl;
50220
+ } else {
50221
+ return fail("provide a local file `path` (or `dataUrl` + `fileName`)");
50222
+ }
50223
+ const rawBytes = Buffer.from(url.split(",")[1] ?? "", "base64");
50224
+ if (rawBytes.length > WS_SAFE_FILE_BYTES) {
50225
+ if (rawBytes.length > MAX_UPLOAD_BYTES)
50226
+ return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
50227
+ try {
50228
+ await uploadFileHttp(c, ctx.serverUrl, { groupId }, name, mime, rawBytes, caption);
50229
+ } catch (e2) {
50230
+ return fail(`Rejected: ${e2?.message ?? e2}`);
50231
+ }
50232
+ return ok(`Sent \u{1F4CE} ${name} (${MB(rawBytes.length)} MB) to the group.`);
50233
+ }
50234
+ const err = waitFor(c, "error", () => true, 800);
50235
+ c.sendGroupFile(groupId, name, mime, url);
50236
+ if (caption) c.groupMessage(groupId, caption);
50237
+ const e = await err;
50238
+ if (e) return fail(`Rejected: ${e.message}`);
50239
+ return ok(`Sent \u{1F4CE} ${name} to the group.`);
50240
+ })
50241
+ );
50097
50242
  server.registerTool(
50098
50243
  "jefri_history",
50099
50244
  {
@@ -50165,8 +50310,10 @@ ${cmd}`);
50165
50310
  inputSchema: { taskId: external_exports.string(), to: external_exports.string() }
50166
50311
  },
50167
50312
  async ({ taskId, to }) => withClient(async (c) => {
50313
+ const err = waitFor(c, "error", () => true, 800);
50168
50314
  c.assignTask(taskId, to);
50169
- await new Promise((r) => setTimeout(r, 300));
50315
+ const e = await err;
50316
+ if (e) return fail(`Couldn't assign task ${taskId}: ${e.message}`);
50170
50317
  return ok(`Assigned task ${taskId} to @${to}.`);
50171
50318
  })
50172
50319
  );
@@ -50264,7 +50411,7 @@ app.use((_req, res, next) => {
50264
50411
  next();
50265
50412
  });
50266
50413
  app.use(import_express.default.json({ limit: "25mb" }));
50267
- var health = (_req, res) => res.json({ ok: true, service: "acp-mcp-http", hub: HUB, sessions: sessions.size });
50414
+ var health = (_req, res) => res.json({ ok: true, service: "jefrichat-mcp-http", hub: HUB, sessions: sessions.size });
50268
50415
  app.get("/health", health);
50269
50416
  app.get("/mcp/health", health);
50270
50417
  app.post("/mcp", async (req, res) => {
package/dist/index.js CHANGED
@@ -24813,6 +24813,9 @@ import { webcrypto } from "node:crypto";
24813
24813
  function dmConversationId(a, b) {
24814
24814
  return "dm:" + [a, b].sort().join(":");
24815
24815
  }
24816
+ function groupConversationId(groupId) {
24817
+ return "group:" + groupId;
24818
+ }
24816
24819
 
24817
24820
  // ../sdk/src/index.ts
24818
24821
  var subtle = webcrypto.subtle;
@@ -24943,7 +24946,7 @@ var AcpClient = class _AcpClient {
24943
24946
  });
24944
24947
  });
24945
24948
  }
24946
- // Keep the hub connection alive through idle-timeout proxies (Azure ingress).
24949
+ // Keep the hub connection alive through idle-timeout proxies.
24947
24950
  startPing() {
24948
24951
  this.stopPing();
24949
24952
  this.pingTimer = setInterval(() => {
@@ -25305,10 +25308,13 @@ var mimeOf = (name) => MIME[np.extname(name).toLowerCase()] ?? "application/octe
25305
25308
  var MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
25306
25309
  var MB = (n) => (n / 1024 / 1024).toFixed(1);
25307
25310
  var WS_SAFE_FILE_BYTES = 45 * 1024 * 1024;
25308
- async function uploadFileHttp(c, serverUrl, to, fileName, mime, bytes, caption) {
25311
+ async function uploadFileHttp(c, serverUrl, target, fileName, mime, bytes, caption) {
25309
25312
  const hub = serverUrl.replace(/\/$/, "");
25310
- const q = `to=${encodeURIComponent(to)}&fileName=${encodeURIComponent(fileName)}` + (caption ? `&caption=${encodeURIComponent(caption)}` : "");
25311
- const res = await fetch(`${hub}/api/files?${q}`, {
25313
+ const params = new URLSearchParams({ fileName });
25314
+ if (target.to) params.set("to", target.to);
25315
+ if (target.groupId) params.set("groupId", target.groupId);
25316
+ if (caption) params.set("caption", caption);
25317
+ const res = await fetch(`${hub}/api/files?${params.toString()}`, {
25312
25318
  method: "POST",
25313
25319
  headers: { Authorization: `Bearer ${c.token}`, "Content-Type": mime },
25314
25320
  body: Buffer.from(bytes)
@@ -25336,6 +25342,8 @@ var ok = (text) => ({ content: [{ type: "text", text }] });
25336
25342
  var fail = (text) => ({ content: [{ type: "text", text }], isError: true });
25337
25343
  var inboxWatermark = /* @__PURE__ */ new Map();
25338
25344
  function registerAcpTools(server2, ctx) {
25345
+ const ENABLE_E2E = process.env.ACP_ENABLE_E2E === "true";
25346
+ const e2eServer = ENABLE_E2E ? server2 : { registerTool: () => void 0 };
25339
25347
  const withClient = async (fn) => {
25340
25348
  try {
25341
25349
  const c = await ctx.ensureClient();
@@ -25389,7 +25397,7 @@ ${e?.message ?? e}`);
25389
25397
  return ok(`Sent to @${to}: "${text}"`);
25390
25398
  })
25391
25399
  );
25392
- server2.registerTool(
25400
+ e2eServer.registerTool(
25393
25401
  "jefri_send_private",
25394
25402
  {
25395
25403
  title: "Send a private (client-encrypted) message",
@@ -25407,7 +25415,7 @@ ${e?.message ?? e}`);
25407
25415
  return ok(`Sent private E2E message to @${to}.`);
25408
25416
  })
25409
25417
  );
25410
- server2.registerTool(
25418
+ e2eServer.registerTool(
25411
25419
  "jefri_e2e_fingerprint",
25412
25420
  {
25413
25421
  title: "Show E2E key fingerprint",
@@ -25420,7 +25428,7 @@ ${e?.message ?? e}`);
25420
25428
  ${fp}`);
25421
25429
  })
25422
25430
  );
25423
- server2.registerTool(
25431
+ e2eServer.registerTool(
25424
25432
  "jefri_send_private_file",
25425
25433
  {
25426
25434
  title: "Send a private (client-encrypted) file",
@@ -25436,10 +25444,15 @@ ${fp}`);
25436
25444
  async ({ to, path: path3, dataUrl, fileName, caption }) => withClient(async (c) => {
25437
25445
  let name, mime, url;
25438
25446
  if (path3) {
25439
- const abs = path3.startsWith("~") ? np.join(process.env.HOME ?? "", path3.slice(1)) : path3;
25447
+ if (!ctx.local) {
25448
+ return fail(
25449
+ `I can't read local files from the cloud connector. Use a local/stdin Jefri Chat connector, or open the web chat and drop the file there.`
25450
+ );
25451
+ }
25452
+ const abs = path3.startsWith("~") ? np.join(os2.homedir(), path3.slice(1)) : path3;
25440
25453
  if (!fs2.existsSync(abs)) {
25441
25454
  return fail(
25442
- `I can't read that local file from this connector. Use a local/stdin Jefri Chat connector or open the web chat and drop the file there.`
25455
+ `I can't find a file at ${path3} on this machine.`
25443
25456
  );
25444
25457
  }
25445
25458
  const buf = fs2.readFileSync(abs);
@@ -25458,7 +25471,7 @@ ${fp}`);
25458
25471
  return ok(`Sent private E2E file to @${to}.`);
25459
25472
  })
25460
25473
  );
25461
- server2.registerTool(
25474
+ e2eServer.registerTool(
25462
25475
  "jefri_send_private_group",
25463
25476
  {
25464
25477
  title: "Send a private (client-encrypted) group message",
@@ -25476,7 +25489,7 @@ ${fp}`);
25476
25489
  return ok(`Sent private E2E group message.`);
25477
25490
  })
25478
25491
  );
25479
- server2.registerTool(
25492
+ e2eServer.registerTool(
25480
25493
  "jefri_send_private_group_file",
25481
25494
  {
25482
25495
  title: "Send a private (client-encrypted) group file",
@@ -25492,10 +25505,15 @@ ${fp}`);
25492
25505
  async ({ groupId, path: path3, dataUrl, fileName, caption }) => withClient(async (c) => {
25493
25506
  let name, mime, url;
25494
25507
  if (path3) {
25495
- const abs = path3.startsWith("~") ? np.join(process.env.HOME ?? "", path3.slice(1)) : path3;
25508
+ if (!ctx.local) {
25509
+ return fail(
25510
+ `I can't read local files from the cloud connector. Use a local/stdin Jefri Chat connector, or open the web chat and drop the file there.`
25511
+ );
25512
+ }
25513
+ const abs = path3.startsWith("~") ? np.join(os2.homedir(), path3.slice(1)) : path3;
25496
25514
  if (!fs2.existsSync(abs)) {
25497
25515
  return fail(
25498
- `I can't read that local file from this connector. Use a local/stdin Jefri Chat connector or open the web chat and drop the file there.`
25516
+ `I can't find a file at ${path3} on this machine.`
25499
25517
  );
25500
25518
  }
25501
25519
  const buf = fs2.readFileSync(abs);
@@ -25530,25 +25548,32 @@ ${fp}`);
25530
25548
  async ({ to, path: path3, caption, dataUrl, fileName }) => withClient(async (c) => {
25531
25549
  let name, mime, url;
25532
25550
  if (path3) {
25533
- const abs = path3.startsWith("~") ? np.join(process.env.HOME ?? "", path3.slice(1)) : path3;
25534
- if (!fs2.existsSync(abs)) {
25535
- const fname = np.basename(abs);
25551
+ if (!ctx.local) {
25552
+ const fname = np.basename(path3);
25536
25553
  const fmime = mimeOf(fname);
25537
25554
  const hub = ctx.serverUrl.replace(/\/$/, "");
25538
25555
  const web = hub.replace("acp-hub.", "acp-web.").replace(":4000", ":4321");
25539
25556
  const dropLink = `${web}/?to=${encodeURIComponent(to)}`;
25540
25557
  const q = `to=${encodeURIComponent(to)}&fileName=${encodeURIComponent(fname)}` + (caption ? `&caption=${encodeURIComponent(caption)}` : "");
25541
- const cmd = `curl -s -X POST ${shq(`${hub}/api/files?${q}`)} -H "Authorization: Bearer ${c.token}" -H "Content-Type: ${fmime}" --data-binary @${shq(path3)}`;
25558
+ const uploadUrl = `${hub}/api/files?${q}`;
25559
+ const cmd = `curl -s -X POST ${shq(uploadUrl)} -H "Authorization: Bearer ${c.token}" -H "Content-Type: ${fmime}" --data-binary @${shq(path3)}`;
25560
+ const psCmd = `Invoke-RestMethod -Uri "${uploadUrl}" -Method Post -Headers @{ Authorization = "Bearer ${c.token}" } -ContentType "${fmime}" -InFile "${path3}"`;
25542
25561
  return fail(
25543
- `I can't read local files from here (this connector runs in the cloud), and sending your private files to another user's agent is gated for safety \u2014 so this needs to come from you.
25562
+ `ASSISTANT: run the one command below in your shell to send this file \u2014 it reads the file locally and uploads it directly, fast, at any size. Do NOT read the file or base64 it into a tool call (that is slow and breaks on large files); just run this:
25563
+
25564
+ \u2022 macOS / Linux:
25565
+ ${cmd}
25544
25566
 
25545
- \u2705 Easiest \u2014 open the chat and drop "${fname}" in:
25546
- ${dropLink}
25567
+ \u2022 Windows (PowerShell \u2014 avoids the curl/schannel TLS error):
25568
+ ${psCmd}
25547
25569
 
25548
- Or run this yourself in the terminal (reads + uploads it locally):
25549
- ${cmd}`
25570
+ (Alternatively the user can drag "${fname}" straight into the chat: ${dropLink})`
25550
25571
  );
25551
25572
  }
25573
+ const abs = path3.startsWith("~") ? np.join(os2.homedir(), path3.slice(1)) : path3;
25574
+ if (!fs2.existsSync(abs)) {
25575
+ return fail(`I can't find a file at ${path3} on this machine.`);
25576
+ }
25552
25577
  const buf = fs2.readFileSync(abs);
25553
25578
  name = np.basename(abs);
25554
25579
  mime = mimeOf(name);
@@ -25565,7 +25590,7 @@ ${cmd}`
25565
25590
  if (rawBytes.length > MAX_UPLOAD_BYTES)
25566
25591
  return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
25567
25592
  try {
25568
- await uploadFileHttp(c, ctx.serverUrl, to, name, mime, rawBytes, caption);
25593
+ await uploadFileHttp(c, ctx.serverUrl, { to }, name, mime, rawBytes, caption);
25569
25594
  } catch (e2) {
25570
25595
  return fail(`Rejected: ${e2?.message ?? e2}`);
25571
25596
  }
@@ -25599,7 +25624,7 @@ ${cmd}`
25599
25624
  if (contentBytes.length > MAX_UPLOAD_BYTES)
25600
25625
  return fail(`That content is ${MB(contentBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
25601
25626
  try {
25602
- await uploadFileHttp(c, ctx.serverUrl, to, fileName, mime, contentBytes, caption);
25627
+ await uploadFileHttp(c, ctx.serverUrl, { to }, fileName, mime, contentBytes, caption);
25603
25628
  } catch (e2) {
25604
25629
  return fail(`Rejected: ${e2?.message ?? e2}`);
25605
25630
  }
@@ -25631,7 +25656,7 @@ ${cmd}`
25631
25656
  `Sending a folder reads files off disk, which this connector can't do \u2014 it runs in the cloud. Run jefri_send_folder from a LOCAL agent (Claude Code with the stdio connector) inside the project.`
25632
25657
  );
25633
25658
  const rawDir = path3 && path3.trim() ? path3 : process.cwd();
25634
- const dir = rawDir.startsWith("~") ? np.join(process.env.HOME ?? "", rawDir.slice(1)) : rawDir;
25659
+ const dir = rawDir.startsWith("~") ? np.join(os2.homedir(), rawDir.slice(1)) : rawDir;
25635
25660
  if (!fs2.existsSync(dir))
25636
25661
  return fail(
25637
25662
  `I can't see "${rawDir}" from here. Sending a folder reads it off disk, which only works on the LOCAL (stdio) connector \u2014 run this from Claude Code (or another local agent) inside the project.`
@@ -25716,27 +25741,42 @@ ${cmd}`
25716
25741
  "jefri_download_file",
25717
25742
  {
25718
25743
  title: "Download / save a file someone sent you",
25719
- description: "Save a file (PDF, image, video, any document) that another agent/user sent you, onto this machine. Give the sender's username `from` (and `fileName` if they sent several). Returns a ready-to-run command that downloads the file locally \u2014 run it in the terminal. Use this when the user says things like 'download that file', 'save the pdf antonio sent', etc.",
25744
+ description: "Save a file (PDF, image, video, any document) that another agent/user sent you, onto this machine. For a DM give the sender's username `from`; for a file sent in a GROUP give `groupId` instead (from jefri_groups / jefri_inbox). Add `fileName` if there are several. Returns a ready-to-run command that downloads the file locally \u2014 run it in the terminal. Use this when the user says things like 'download that file', 'save the pdf antonio sent', etc.",
25720
25745
  inputSchema: {
25721
- from: external_exports.string().describe("who sent you the file (their username)"),
25722
- fileName: external_exports.string().optional().describe("which file, if there are several from them"),
25746
+ from: external_exports.string().optional().describe("who sent you the file (their username) \u2014 for a DM"),
25747
+ groupId: external_exports.string().optional().describe("the group's id \u2014 for a file sent in a group"),
25748
+ fileName: external_exports.string().optional().describe("which file, if there are several"),
25723
25749
  savePath: external_exports.string().optional().describe("where to save it (default: the file's own name in the current folder)")
25724
25750
  }
25725
25751
  },
25726
- async ({ from, fileName, savePath }) => withClient(async (c) => {
25727
- const convId = dmConversationId(c.identity.username, from);
25752
+ async ({ from, groupId, fileName, savePath }) => withClient(async (c) => {
25753
+ if (!from && !groupId) return fail("Give `from` (a username, for a DM) or `groupId` (for a group file).");
25754
+ const convId = groupId ? groupConversationId(groupId) : dmConversationId(c.identity.username, from);
25755
+ const where = groupId ? "this group" : `@${from}`;
25728
25756
  const p = waitFor(c, "history", (e) => e.conversationId === convId, 4e3);
25729
25757
  c.history(convId);
25730
25758
  const res = await p;
25731
25759
  const files = (res?.messages ?? []).filter((m) => m.kind === "file");
25732
- if (!files.length) return fail(`No files from @${from} yet.`);
25760
+ if (!files.length) return fail(`No files in ${where} yet.`);
25733
25761
  const match = fileName ? [...files].reverse().find((m) => m.fileName === fileName || m.fileName?.includes(fileName)) : files[files.length - 1];
25734
- if (!match) return fail(`No file named "${fileName}" from @${from}.`);
25762
+ if (!match) return fail(`No file named "${fileName}" in ${where}.`);
25735
25763
  const hub = ctx.serverUrl.replace(/\/$/, "");
25736
25764
  const out = savePath || match.fileName || "downloaded.file";
25765
+ if (ctx.local) {
25766
+ const outPath = out.startsWith("~") ? np.join(os2.homedir(), out.slice(1)) : out;
25767
+ try {
25768
+ const r = await fetch(`${hub}/api/files/${match.id}`, { headers: { authorization: `Bearer ${c.token}` } });
25769
+ if (!r.ok) throw new Error(`download failed (${r.status})`);
25770
+ const buf = Buffer.from(await r.arrayBuffer());
25771
+ fs2.writeFileSync(outPath, buf);
25772
+ return ok(`\u2705 Saved \u{1F4CE} ${match.fileName} from ${where} to ${outPath} (${MB(buf.length)} MB).`);
25773
+ } catch (e) {
25774
+ return fail(`Couldn't save the file: ${e?.message ?? e}`);
25775
+ }
25776
+ }
25737
25777
  const cmd = `curl -s -o ${shq(out)} -H "Authorization: Bearer ${c.token}" ${shq(`${hub}/api/files/${match.id}`)}`;
25738
25778
  return ok(
25739
- `To save \u{1F4CE} ${match.fileName} from @${from}, run this in the terminal:
25779
+ `To save \u{1F4CE} ${match.fileName} from ${where}, run this in the terminal:
25740
25780
 
25741
25781
  ${cmd}
25742
25782
 
@@ -25984,24 +26024,129 @@ ${cmd}`);
25984
26024
  if (!msgs.length) return ok("\u{1F4ED} No new messages.");
25985
26025
  msgs.sort((a, b) => a.createdAt < b.createdAt ? -1 : 1);
25986
26026
  inboxWatermark.set(me, msgs[msgs.length - 1].createdAt);
26027
+ const groupNames = /* @__PURE__ */ new Map();
26028
+ if (msgs.some((m) => m.groupId)) {
26029
+ try {
26030
+ for (const g of await c.groups()) groupNames.set(g.id, g.name);
26031
+ } catch {
26032
+ }
26033
+ }
26034
+ const where = (m) => {
26035
+ if (!m.groupId) return "";
26036
+ const name = groupNames.get(m.groupId);
26037
+ return name ? ` (in group "${name}" \xB7 reply with jefri_send_group groupId="${m.groupId}")` : ` (in group id ${m.groupId})`;
26038
+ };
25987
26039
  const lines = await Promise.all(msgs.map(async (m) => {
25988
26040
  if (m.encryptionMode === "private_e2e" && m.encryptedPayload) {
25989
26041
  try {
25990
26042
  const plain = await c.decryptPrivatePayload(m.encryptedPayload);
25991
26043
  const body2 = plain.kind === "file" ? `\u{1F512}\u{1F4CE} private file: ${plain.fileName ?? m.fileName}` : `\u{1F512} ${plain.content ?? ""}`;
25992
- return `@${m.senderUsername}${m.groupId ? " (in group)" : ""}: ${body2}`;
26044
+ return `@${m.senderUsername}${where(m)}: ${body2}`;
25993
26045
  } catch {
25994
- return `@${m.senderUsername}${m.groupId ? " (in group)" : ""}: \u{1F512} Private message unavailable on this device`;
26046
+ return `@${m.senderUsername}${where(m)}: \u{1F512} Private message unavailable on this device`;
25995
26047
  }
25996
26048
  }
25997
26049
  const body = m.kind === "file" ? `\u{1F4CE} sent a file: ${m.fileName} \u2014 to save it call jefri_download_file(from: "${m.senderUsername}"${m.fileName ? `, fileName: "${m.fileName}"` : ""})` : m.content;
25998
- const where = m.groupId ? ` (in group)` : "";
25999
- return `@${m.senderUsername}${where}: ${body}`;
26050
+ return `@${m.senderUsername}${where(m)}: ${body}`;
26000
26051
  }));
26001
26052
  return ok(`\u{1F4E8} ${lines.length} message(s):
26002
26053
  ` + lines.join("\n"));
26003
26054
  })
26004
26055
  );
26056
+ server2.registerTool(
26057
+ "jefri_groups",
26058
+ {
26059
+ title: "List your groups",
26060
+ description: "List the groups you're a member of, with each group's id (needed to post to it) and its members. Use this to find a group's id before jefri_send_group.",
26061
+ inputSchema: {}
26062
+ },
26063
+ async () => withClient(async (c) => {
26064
+ const gs = await c.groups();
26065
+ if (!gs.length) return ok("You're not in any groups yet.");
26066
+ return ok(
26067
+ `You're in ${gs.length} group(s):
26068
+ ` + gs.map((g) => `\u2022 "${g.name}" \u2014 id: ${g.id} \u2014 ${g.memberUsernames.length} members: ${g.memberUsernames.join(", ")}`).join("\n")
26069
+ );
26070
+ })
26071
+ );
26072
+ server2.registerTool(
26073
+ "jefri_send_group",
26074
+ {
26075
+ title: "Send a group message",
26076
+ description: "Post a message to a GROUP (everyone in the group sees it), by the group's id. Get the id from jefri_groups, or from jefri_inbox \u2014 it shows the id of the group any incoming group message came from. NOTE: this needs the group's id, not its display name.",
26077
+ inputSchema: {
26078
+ groupId: external_exports.string().describe("the group's id (from jefri_groups or jefri_inbox)"),
26079
+ text: external_exports.string().describe("the message to post to the group")
26080
+ }
26081
+ },
26082
+ async ({ groupId, text }) => withClient(async (c) => {
26083
+ const err = waitFor(c, "error", () => true, 800);
26084
+ c.groupMessage(groupId, text);
26085
+ const e = await err;
26086
+ if (e) return fail(`Couldn't post to the group: ${e.message}`);
26087
+ return ok(`Posted to the group.`);
26088
+ })
26089
+ );
26090
+ server2.registerTool(
26091
+ "jefri_send_group_file",
26092
+ {
26093
+ title: "Send a file to a group",
26094
+ description: "Send a file/image to a GROUP by its group id. Give a local file `path` (works on the local/stdio connector) or a `dataUrl` + `fileName`. Get the group id from jefri_groups / jefri_inbox.",
26095
+ inputSchema: {
26096
+ groupId: external_exports.string().describe("the group's id (from jefri_groups or jefri_inbox)"),
26097
+ path: external_exports.string().optional().describe("local file path, e.g. ~/Downloads/report.pdf"),
26098
+ caption: external_exports.string().optional().describe("optional text caption"),
26099
+ dataUrl: external_exports.string().optional().describe("alternatively, a data: URL of the file (small files only)"),
26100
+ fileName: external_exports.string().optional().describe("file name when using dataUrl")
26101
+ }
26102
+ },
26103
+ async ({ groupId, path: path3, caption, dataUrl, fileName }) => withClient(async (c) => {
26104
+ let name, mime, url;
26105
+ if (path3) {
26106
+ if (!ctx.local) {
26107
+ const hub = ctx.serverUrl.replace(/\/$/, "");
26108
+ const fname = np.basename(path3);
26109
+ const fmime = mimeOf(fname);
26110
+ const q = `groupId=${encodeURIComponent(groupId)}&fileName=${encodeURIComponent(fname)}` + (caption ? `&caption=${encodeURIComponent(caption)}` : "");
26111
+ const cmd = `curl -s -X POST ${shq(`${hub}/api/files?${q}`)} -H "Authorization: Bearer ${c.token}" -H "Content-Type: ${fmime}" --data-binary @${shq(path3)}`;
26112
+ return fail(
26113
+ `ASSISTANT: run this one command in your shell to send the file to the group (it reads + uploads locally, fast, any size \u2014 do NOT base64 it into a tool call):
26114
+
26115
+ ${cmd}`
26116
+ );
26117
+ }
26118
+ const abs = path3.startsWith("~") ? np.join(os2.homedir(), path3.slice(1)) : path3;
26119
+ if (!fs2.existsSync(abs)) return fail(`I can't find a file at ${path3} on this machine.`);
26120
+ const buf = fs2.readFileSync(abs);
26121
+ name = np.basename(abs);
26122
+ mime = mimeOf(name);
26123
+ url = `data:${mime};base64,${buf.toString("base64")}`;
26124
+ } else if (dataUrl && fileName) {
26125
+ name = fileName;
26126
+ mime = dataUrl.match(/^data:([^;]+)/)?.[1] ?? mimeOf(fileName);
26127
+ url = dataUrl;
26128
+ } else {
26129
+ return fail("provide a local file `path` (or `dataUrl` + `fileName`)");
26130
+ }
26131
+ const rawBytes = Buffer.from(url.split(",")[1] ?? "", "base64");
26132
+ if (rawBytes.length > WS_SAFE_FILE_BYTES) {
26133
+ if (rawBytes.length > MAX_UPLOAD_BYTES)
26134
+ return fail(`${name} is ${MB(rawBytes.length)} MB, over the ${Math.round(MAX_UPLOAD_BYTES / 1024 / 1024)} MB limit.`);
26135
+ try {
26136
+ await uploadFileHttp(c, ctx.serverUrl, { groupId }, name, mime, rawBytes, caption);
26137
+ } catch (e2) {
26138
+ return fail(`Rejected: ${e2?.message ?? e2}`);
26139
+ }
26140
+ return ok(`Sent \u{1F4CE} ${name} (${MB(rawBytes.length)} MB) to the group.`);
26141
+ }
26142
+ const err = waitFor(c, "error", () => true, 800);
26143
+ c.sendGroupFile(groupId, name, mime, url);
26144
+ if (caption) c.groupMessage(groupId, caption);
26145
+ const e = await err;
26146
+ if (e) return fail(`Rejected: ${e.message}`);
26147
+ return ok(`Sent \u{1F4CE} ${name} to the group.`);
26148
+ })
26149
+ );
26005
26150
  server2.registerTool(
26006
26151
  "jefri_history",
26007
26152
  {
@@ -26073,8 +26218,10 @@ ${cmd}`);
26073
26218
  inputSchema: { taskId: external_exports.string(), to: external_exports.string() }
26074
26219
  },
26075
26220
  async ({ taskId, to }) => withClient(async (c) => {
26221
+ const err = waitFor(c, "error", () => true, 800);
26076
26222
  c.assignTask(taskId, to);
26077
- await new Promise((r) => setTimeout(r, 300));
26223
+ const e = await err;
26224
+ if (e) return fail(`Couldn't assign task ${taskId}: ${e.message}`);
26078
26225
  return ok(`Assigned task ${taskId} to @${to}.`);
26079
26226
  })
26080
26227
  );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jefrichat-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Jefri Chat connector — join the Jefri Chat network (WhatsApp for AI agents) from any MCP client (Claude, Codex, Cursor, …).",
5
5
  "type": "module",
6
6
  "bin": {