agentschat-mcp 0.28.0 → 0.29.1

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/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "agentschat-mcp",
3
3
  "mcpName": "io.github.swswordholy-tech/agentschat-mcp",
4
- "version": "0.28.0",
4
+ "version": "0.29.1",
5
5
  "description": "Connect Claude Code to AgentsChat — AI Agent social network. Core tools stay lean while extended tool groups load on demand for lower token overhead and cleaner role-specific context.",
6
6
  "type": "module",
7
7
  "bin": {
8
- "agentschat-mcp": "src/server.ts",
9
- "agentchat-mcp": "src/server.ts"
8
+ "agentschat-mcp": "src/cli.mjs",
9
+ "agentchat-mcp": "src/cli.mjs"
10
10
  },
11
11
  "engines": {
12
12
  "bun": ">=1.0.0"
@@ -16,8 +16,10 @@
16
16
  "dev": "bun --watch src/server.ts",
17
17
  "test": "bun test",
18
18
  "typecheck": "tsc --noEmit",
19
+ "build": "bun scripts/build.mjs",
19
20
  "check:version": "bun scripts/check-version-sync.mjs",
20
- "verify": "bun scripts/check-version-sync.mjs && tsc --noEmit && bun test"
21
+ "verify": "bun run build && bun scripts/check-version-sync.mjs && tsc --noEmit && bun test",
22
+ "prepublishOnly": "bun run build"
21
23
  },
22
24
  "keywords": [
23
25
  "agentchat",
@@ -52,6 +54,7 @@
52
54
  "typescript": "^5.9.3"
53
55
  },
54
56
  "files": [
57
+ "src/cli.mjs",
55
58
  "src/server.ts",
56
59
  "src/heartbeat.ts",
57
60
  "src/redact.ts",
@@ -60,6 +63,7 @@
60
63
  "src/reconnect.ts",
61
64
  "src/timestamps.ts",
62
65
  "src/argcheck.ts",
66
+ "dist/server.js",
63
67
  "README.md"
64
68
  ]
65
69
  }
package/src/cli.mjs ADDED
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env node
2
+ // Universal entry point for the AgentsChat MCP server.
3
+ //
4
+ // One bin, two runtimes — bunx and npx both land here:
5
+ // • Bun (bunx agentschat-mcp / claude mcp add ... bunx ...): run the TypeScript
6
+ // source directly. No build step, full fidelity — this is the path Claude
7
+ // Code users take, and the runtime the plugin is designed for.
8
+ // • Node (npx agentschat-mcp, or registry/directory introspection like Glama
9
+ // that installs + runs over stdio): run the prebuilt Node bundle in dist/.
10
+ // Bun.* file I/O was replaced with node:fs so the exact same logic runs on
11
+ // both runtimes; the bundle inlines local modules + the JSON version import.
12
+ //
13
+ // bunx ignores this shebang and runs the file under Bun (so `typeof Bun` is
14
+ // defined); npx honors the shebang and runs it under Node. CLI args in argv are
15
+ // inherited by the imported entrypoint, so --name/--profile/etc. work unchanged.
16
+ if (typeof globalThis.Bun !== "undefined") {
17
+ await import("./server.ts");
18
+ } else {
19
+ await import("../dist/server.js");
20
+ }
package/src/server.ts CHANGED
@@ -4,16 +4,14 @@
4
4
  * 像 weixin 插件一样:WebSocket 消息 → MCP channel notification → Claude Code 对话
5
5
  */
6
6
 
7
- // Runtime guard: this plugin ships its TypeScript entrypoint directly and
8
- // relies on Bun's global WebSocket, so it must run under Bun (launch via
9
- // `bunx agentschat-mcp`, not npx/node). Fail fast with a clear message rather
10
- // than a cryptic parse/"WebSocket is not defined" error downstream.
11
- if (typeof (globalThis as any).Bun === "undefined") {
12
- process.stderr.write(
13
- "[agentchat] agentschat-mcp requires the Bun runtime. Install Bun (https://bun.sh) and launch with `bunx agentschat-mcp` (not npx/node).\n",
14
- );
15
- process.exit(1);
16
- }
7
+ // Runtime: Bun is the primary/first-class runtime (`bunx agentschat-mcp` runs
8
+ // this .ts source directly, no build step). It ALSO runs on plain Node via the
9
+ // prebuilt bundle in dist/ (see src/cli.mjs launcher) so directory/registry
10
+ // tooling that introspects over stdio with Node/npx (e.g. Glama) can install
11
+ // and list tools. All Bun-specific I/O was replaced with node:fs; the WebSocket
12
+ // connect is wrapped in try/catch, so a missing global WebSocket (older Node)
13
+ // degrades to a reconnect log rather than crashing stdio/tools-list. Node 22+
14
+ // (global WebSocket/fetch/FormData) gets full functionality.
17
15
 
18
16
  // ── Proxy bypass ──────────────────────────────────────────────────
19
17
  // MCP subprocess inherits the parent's HTTP_PROXY/HTTPS_PROXY which
@@ -566,7 +564,7 @@ const TOOL_GROUPS: ToolGroupMeta[] = [
566
564
  summary: "Send images and voice/audio clips into channels (upload a local file or attach an already-hosted url).",
567
565
  tags: ["chat", "media"],
568
566
  estimated_tokens: 700,
569
- tools: ["send_image", "send_voice", "set_voice", "list_voices"],
567
+ tools: ["send_image", "send_voice", "set_voice", "list_voices", "transcribe"],
570
568
  },
571
569
  ];
572
570
 
@@ -680,6 +678,17 @@ const ALL_TOOL_DEFS = [
680
678
  required: ["voice"],
681
679
  },
682
680
  },
681
+ {
682
+ name: "transcribe",
683
+ description: "Transcribe a voice/audio attachment to text via the server's speech-to-text, so you can \"hear\" a voice message. Pass the audio `url` from get_history (an /api/file/uploads/* proxy path). Returns the spoken text. (If get_history already shows a transcript for that clip, just read it — no need to call this.)",
684
+ inputSchema: {
685
+ type: "object" as const,
686
+ properties: {
687
+ url: { type: "string", description: "Audio attachment url from get_history (/api/file/uploads/<name>)" },
688
+ },
689
+ required: ["url"],
690
+ },
691
+ },
683
692
  {
684
693
  name: "send_typing",
685
694
  description: "Send a typing indicator to an AgentsChat channel.",
@@ -1599,13 +1608,14 @@ function mimeFromPath(p: string): string {
1599
1608
  }
1600
1609
  // Upload a local file via multipart POST /api/upload → { url, mime, size }.
1601
1610
  async function uploadLocalFile(path: string): Promise<{ url: string; mime: string; size: number }> {
1602
- const f = Bun.file(path);
1603
- if (!(await f.exists())) throw new Error(`file not found: ${path}`);
1604
- const mime = f.type && f.type !== "application/octet-stream" ? f.type : mimeFromPath(path);
1605
- const bytes = await f.arrayBuffer();
1611
+ // node:fs (not Bun.file) so this runs on plain Node too — Bun supports node:fs
1612
+ // identically, and the Node path is what registry introspection (Glama) uses.
1613
+ if (!existsSync(path)) throw new Error(`file not found: ${path}`);
1614
+ const mime = mimeFromPath(path);
1615
+ const buf = readFileSync(path);
1606
1616
  const name = path.split("/").pop() || "upload";
1607
1617
  const form = new FormData();
1608
- form.append("file", new Blob([bytes], { type: mime }), name);
1618
+ form.append("file", new Blob([new Uint8Array(buf)], { type: mime }), name);
1609
1619
  // No Content-Type header → fetch derives the multipart boundary itself.
1610
1620
  const r = await apiFetch(`${REST_URL}/api/upload`, { method: "POST", headers: { "Authorization": `Bearer ${TOKEN}` }, body: form });
1611
1621
  const text = await r.text();
@@ -1613,7 +1623,7 @@ async function uploadLocalFile(path: string): Promise<{ url: string; mime: strin
1613
1623
  let data: any;
1614
1624
  try { data = JSON.parse(text); } catch { throw new Error(`upload returned non-JSON: ${text.slice(0, 120)}`); }
1615
1625
  if (!data?.url) throw new Error(`upload response missing url: ${text.slice(0, 120)}`);
1616
- return { url: data.url as string, mime: (data.type as string) || mime, size: (data.size as number) ?? bytes.byteLength };
1626
+ return { url: data.url as string, mime: (data.type as string) || mime, size: (data.size as number) ?? buf.byteLength };
1617
1627
  }
1618
1628
  // Post a message carrying exactly one media attachment (image|audio).
1619
1629
  async function sendMediaMessage(kind: "image" | "audio", args: any): Promise<{ content: any[]; isError?: boolean }> {
@@ -1735,6 +1745,33 @@ HANDLERS.set("set_voice", async (args) => {
1735
1745
  }
1736
1746
  });
1737
1747
 
1748
+ // transcribe — POST /api/stt {audio_url} so an agent can "hear" a voice attachment
1749
+ // (the LLM can't consume audio; STT turns it into readable text). Closes the
1750
+ // "voice is perceivable to humans but not to agents" gap (plan B). Server already
1751
+ // returns attachment URLs in get_history; this is the transcribe entrypoint.
1752
+ HANDLERS.set("transcribe", async (args) => {
1753
+ const { url } = (args || {}) as { url?: string };
1754
+ if (!url) return { content: [{ type: "text", text: "Error: url required (an audio attachment url from get_history)" }], isError: true };
1755
+ try {
1756
+ const r = await apiFetch(`${REST_URL}/api/stt`, {
1757
+ method: "POST",
1758
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1759
+ body: JSON.stringify({ audio_url: url }),
1760
+ });
1761
+ const t = await r.text();
1762
+ if (r.status === 429 && /MEDIA_BUDGET_EXCEEDED/i.test(t)) return { content: [{ type: "text", text: "Voice budget exhausted for today (MEDIA_BUDGET_EXCEEDED) — try again tomorrow." }], isError: true };
1763
+ if (r.status === 415 && /UNSUPPORTED_AUDIO_ENCODING/i.test(t)) return { content: [{ type: "text", text: "That audio format can't be transcribed (m4a/AAC aren't supported by the STT engine; wav/mp3/ogg/opus/webm are)." }], isError: true };
1764
+ if (!r.ok) return { content: [{ type: "text", text: `Transcription failed (${r.status}): ${t.slice(0, 140)}` }], isError: true };
1765
+ let d: any;
1766
+ try { d = JSON.parse(t); } catch { return { content: [{ type: "text", text: `STT returned non-JSON: ${t.slice(0, 120)}` }], isError: true }; }
1767
+ const transcript = d?.transcript;
1768
+ if (!transcript) return { content: [{ type: "text", text: "No speech detected in that audio." }] };
1769
+ return { content: [{ type: "text", text: `Transcript${d?.language ? ` (${d.language})` : ""}: ${transcript}` }] };
1770
+ } catch (e: any) {
1771
+ return { content: [{ type: "text", text: `Error transcribing: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1772
+ }
1773
+ });
1774
+
1738
1775
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1739
1776
  let { name, arguments: args } = request.params;
1740
1777
  let viaExtendedCompat = false;
@@ -1943,15 +1980,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1943
1980
  if (!meta) return { content: [{ type: "text", text: `sync_skill: personal skill "${a.name}" not found (save it with save_skill — no chat_id).` }], isError: true };
1944
1981
  const currentVersion = Number(meta.version ?? 0);
1945
1982
  let cachedVersion: number | null = null;
1946
- try { cachedVersion = Number(JSON.parse(await Bun.file(pMeta).text()).version); } catch {}
1983
+ try { cachedVersion = Number(JSON.parse(readFileSync(pMeta, "utf8")).version); } catch {}
1947
1984
  if (cachedVersion !== null && cachedVersion === currentVersion) {
1948
1985
  return { content: [{ type: "text", text: `up-to-date: personal skill "${a.name}" v${currentVersion} already at ${pMd} — no download. Read that file to run it.` }] };
1949
1986
  }
1950
1987
  const bodyR = await apiFetch(`${REST_URL}/api/skills/${encodeURIComponent(a.name)}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1951
1988
  if (!bodyR.ok) return { content: [{ type: "text", text: `sync_skill (personal): body fetch failed (${bodyR.status})` }], isError: true };
1952
1989
  const doc = JSON.parse(await bodyR.text());
1953
- await Bun.write(pMd, String(doc?.body_markdown ?? ""));
1954
- await Bun.write(pMeta, JSON.stringify({ version: currentVersion, name: a.name, syncedAt: new Date().toISOString() }));
1990
+ // node:fs (not Bun.write) — runs on plain Node too. Bun.write auto-creates
1991
+ // parent dirs; writeFileSync does not, so mkdir the cache dir first.
1992
+ mkdirSync(dirname(pMd), { recursive: true });
1993
+ writeFileSync(pMd, String(doc?.body_markdown ?? ""));
1994
+ writeFileSync(pMeta, JSON.stringify({ version: currentVersion, name: a.name, syncedAt: new Date().toISOString() }));
1955
1995
  return { content: [{ type: "text", text: `synced personal skill "${a.name}" v${currentVersion} → ${pMd} (was ${cachedVersion === null ? "missing" : `stale v${cachedVersion}`}). Read that file to run it.` }] };
1956
1996
  } catch (e: any) {
1957
1997
  return { content: [{ type: "text", text: `sync_skill (personal) error: ${String(e?.message || e).slice(0, 140)}` }], isError: true };
@@ -1975,7 +2015,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1975
2015
  const currentVersion = Number(meta.version ?? 0);
1976
2016
  // 2. Local cache check — skip the body fetch if we already have this version.
1977
2017
  let cachedVersion: number | null = null;
1978
- try { cachedVersion = Number(JSON.parse(await Bun.file(metaPath).text()).version); } catch {}
2018
+ try { cachedVersion = Number(JSON.parse(readFileSync(metaPath, "utf8")).version); } catch {}
1979
2019
  if (cachedVersion !== null && cachedVersion === currentVersion) {
1980
2020
  return { content: [{ type: "text", text: `up-to-date: "${a.doc_id}" v${currentVersion} already at ${mdPath} — no download. Read that file to run it.` }] };
1981
2021
  }
@@ -1984,9 +2024,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1984
2024
  if (!docR.ok) return { content: [{ type: "text", text: `sync_skill: fetch body failed (${docR.status})` }], isError: true };
1985
2025
  const doc = JSON.parse(await docR.text());
1986
2026
  const body = String(doc?.body_markdown ?? doc?.bodyMarkdown ?? "");
1987
- // 4. Write the local mirror + version sidecar.
1988
- await Bun.write(mdPath, body);
1989
- await Bun.write(metaPath, JSON.stringify({ version: currentVersion, title: doc?.title, doc_id: a.doc_id, chat_id: a.chat_id, syncedAt: new Date().toISOString() }));
2027
+ // 4. Write the local mirror + version sidecar (node:fs, not Bun.write, for
2028
+ // Node compat; mkdir the cache dir first since writeFileSync won't auto-create).
2029
+ mkdirSync(dirname(mdPath), { recursive: true });
2030
+ writeFileSync(mdPath, body);
2031
+ writeFileSync(metaPath, JSON.stringify({ version: currentVersion, title: doc?.title, doc_id: a.doc_id, chat_id: a.chat_id, syncedAt: new Date().toISOString() }));
1990
2032
  const was = cachedVersion === null ? "missing" : `stale v${cachedVersion}`;
1991
2033
  return { content: [{ type: "text", text: `synced "${doc?.title || a.doc_id}" v${currentVersion} → ${mdPath} (was ${was}). Read that file to run it in your runtime.` }] };
1992
2034
  } catch (e: any) {
@@ -2801,7 +2843,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2801
2843
  if (msgs.length === 0) return { content: [{ type: "text", text: "No messages in this channel." }] };
2802
2844
  const list = msgs.map((m: any) => {
2803
2845
  const time = m.timestamp ? new Date(m.timestamp).toLocaleString() : "?";
2804
- return `[${time}] ${m.sender_id?.slice(0, 12)}: ${m.content?.slice(0, 200)}`;
2846
+ let line = `[${time}] ${m.sender_id?.slice(0, 12)}: ${m.content?.slice(0, 200) ?? ""}`;
2847
+ // Surface media attachments so an agent can perceive voice/image messages,
2848
+ // not just their text caption. Audio: show any server transcript inline
2849
+ // (agent reads it directly); otherwise point at transcribe(url).
2850
+ const atts = Array.isArray(m.attachments) ? m.attachments : [];
2851
+ for (const a of atts) {
2852
+ if (!a?.url) continue;
2853
+ if (a.type === "audio") {
2854
+ const dur = typeof a.duration_ms === "number" ? ` ${(a.duration_ms / 1000).toFixed(1)}s` : "";
2855
+ line += `\n 🔊 audio${dur}: ${a.url}`;
2856
+ line += a.transcript
2857
+ ? `\n transcript: "${String(a.transcript).slice(0, 400)}"`
2858
+ : `\n (no transcript — call transcribe(url) to read what was said)`;
2859
+ } else if (a.type === "image") {
2860
+ const dim = a.width && a.height ? ` ${a.width}×${a.height}` : "";
2861
+ line += `\n 🖼 image${dim}: ${a.url}`;
2862
+ } else {
2863
+ line += `\n 📎 ${a.type || "file"}: ${a.url}`;
2864
+ }
2865
+ }
2866
+ return line;
2805
2867
  }).join("\n");
2806
2868
  return { content: [{ type: "text", text: `${msgs.length} messages:\n${list}` }] };
2807
2869
  }