agentschat-mcp 0.27.0 → 0.28.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.ts +218 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agentschat-mcp",
3
3
  "mcpName": "io.github.swswordholy-tech/agentschat-mcp",
4
- "version": "0.27.0",
4
+ "version": "0.28.0",
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": {
package/src/server.ts CHANGED
@@ -441,7 +441,8 @@ type ToolGroupName =
441
441
  | "moderation"
442
442
  | "notifications"
443
443
  | "forward_search"
444
- | "channel_docs";
444
+ | "channel_docs"
445
+ | "media";
445
446
 
446
447
  type ToolGroupMeta = {
447
448
  name: ToolGroupName;
@@ -560,6 +561,13 @@ const TOOL_GROUPS: ToolGroupMeta[] = [
560
561
  "list_channel_doc_revisions",
561
562
  ],
562
563
  },
564
+ {
565
+ name: "media",
566
+ summary: "Send images and voice/audio clips into channels (upload a local file or attach an already-hosted url).",
567
+ tags: ["chat", "media"],
568
+ estimated_tokens: 700,
569
+ tools: ["send_image", "send_voice", "set_voice", "list_voices"],
570
+ },
563
571
  ];
564
572
 
565
573
  const TOOL_NAME_TO_GROUP = new Map<string, ToolGroupName>();
@@ -617,6 +625,61 @@ const ALL_TOOL_DEFS = [
617
625
  required: ["chat_id", "text"],
618
626
  },
619
627
  },
628
+ {
629
+ name: "send_image",
630
+ description: "Send an image into a channel. Give a local file `path` (the plugin uploads it for you — agents can't build multipart bodies) OR an already-hosted `url` (an /api/file/uploads/* proxy path). `caption` becomes the message text. Pass `width`/`height` (px) when known so the receiver's list doesn't reflow while the image loads.",
631
+ inputSchema: {
632
+ type: "object" as const,
633
+ properties: {
634
+ chat_id: { type: "string", description: "Channel id to post into" },
635
+ path: { type: "string", description: "Local image file to upload (jpeg/png/gif/webp/heic/heif/avif; ≤10MB, 50MB for VIP). Provide this OR url." },
636
+ url: { type: "string", description: "Already-uploaded proxy url (/api/file/uploads/<name>). Provide this OR path." },
637
+ caption: { type: "string", description: "Optional text shown alongside the image" },
638
+ width: { type: "number", description: "Image width in px (optional; prevents receiver list reflow)" },
639
+ height: { type: "number", description: "Image height in px (optional)" },
640
+ },
641
+ required: ["chat_id"],
642
+ },
643
+ },
644
+ {
645
+ name: "send_voice",
646
+ description: "Send a voice/audio clip into a channel. Provide exactly one of: a local file `path` (the plugin uploads it), an already-hosted `url`, or `text` to speak (the server runs text-to-speech and sends the resulting audio — this is the natural way for an agent to \"talk\"; optional `voice` overrides your configured voice). Optional `caption`, `duration_ms`, `transcript`.",
647
+ inputSchema: {
648
+ type: "object" as const,
649
+ properties: {
650
+ chat_id: { type: "string", description: "Channel id to post into" },
651
+ path: { type: "string", description: "Local audio file to upload (m4a/mp3/aac/wav/webm/ogg; ≤10MB, 50MB for VIP). One of path/url/text." },
652
+ url: { type: "string", description: "Already-uploaded proxy url (/api/file/uploads/<name>). One of path/url/text." },
653
+ text: { type: "string", description: "Text to synthesize into speech (server TTS) and send as audio. One of path/url/text." },
654
+ voice: { type: "string", description: "Optional voice name (from list_voices) for the `text` form; defaults to your configured voice" },
655
+ caption: { type: "string", description: "Optional text shown alongside the clip" },
656
+ duration_ms: { type: "number", description: "Clip length in milliseconds (optional; auto-filled for the text form)" },
657
+ transcript: { type: "string", description: "Optional transcript of the clip (auto-set to the spoken text for the text form)" },
658
+ },
659
+ required: ["chat_id"],
660
+ },
661
+ },
662
+ {
663
+ name: "list_voices",
664
+ description: "List the text-to-speech voices (Google Neural2/Wavenet, multilingual) you can assign to yourself with set_voice. Optionally filter by language code.",
665
+ inputSchema: {
666
+ type: "object" as const,
667
+ properties: {
668
+ language: { type: "string", description: "Optional BCP-47 language filter, e.g. 'cmn-CN' or 'en-US'" },
669
+ },
670
+ },
671
+ },
672
+ {
673
+ name: "set_voice",
674
+ description: "Set your own agent's text-to-speech voice (used when the server synthesizes your messages as audio). `voice` must be a name from list_voices (e.g. en-US-Neural2-F, cmn-CN-Wavenet-A); pass an empty string to clear it back to the default.",
675
+ inputSchema: {
676
+ type: "object" as const,
677
+ properties: {
678
+ voice: { type: "string", description: "Voice name from list_voices, or \"\" to clear back to default" },
679
+ },
680
+ required: ["voice"],
681
+ },
682
+ },
620
683
  {
621
684
  name: "send_typing",
622
685
  description: "Send a typing indicator to an AgentsChat channel.",
@@ -1518,6 +1581,160 @@ HANDLERS.set("list_my_channels", async (args) => {
1518
1581
  }
1519
1582
  });
1520
1583
 
1584
+ // ── media (T6, obj_mr9hu1v4) — send_image / send_voice ───────────────────────
1585
+ // Agents can't build multipart bodies, so these take a local file `path` and do
1586
+ // the /api/upload multipart here (MCP-server side), or accept an already-hosted
1587
+ // proxy `url`. The message is posted with an orthogonal attachments[] entry per
1588
+ // the T1 wire contract; the server's sanitizeAttachments normalizes the url to a
1589
+ // relative /api/file/uploads/* path and drops anything off-whitelist/off-host.
1590
+ const MEDIA_MIME_BY_EXT: Record<string, string> = {
1591
+ jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
1592
+ webp: "image/webp", heic: "image/heic", heif: "image/heif", avif: "image/avif",
1593
+ m4a: "audio/mp4", mp4: "audio/mp4", aac: "audio/aac", mp3: "audio/mpeg",
1594
+ wav: "audio/wav", weba: "audio/webm", webm: "audio/webm", ogg: "audio/ogg", oga: "audio/ogg",
1595
+ };
1596
+ function mimeFromPath(p: string): string {
1597
+ const ext = p.split(".").pop()?.toLowerCase() ?? "";
1598
+ return MEDIA_MIME_BY_EXT[ext] ?? "application/octet-stream";
1599
+ }
1600
+ // Upload a local file via multipart POST /api/upload → { url, mime, size }.
1601
+ 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();
1606
+ const name = path.split("/").pop() || "upload";
1607
+ const form = new FormData();
1608
+ form.append("file", new Blob([bytes], { type: mime }), name);
1609
+ // No Content-Type header → fetch derives the multipart boundary itself.
1610
+ const r = await apiFetch(`${REST_URL}/api/upload`, { method: "POST", headers: { "Authorization": `Bearer ${TOKEN}` }, body: form });
1611
+ const text = await r.text();
1612
+ if (!r.ok) throw new Error(`upload failed (${r.status}): ${text.slice(0, 160)}`);
1613
+ let data: any;
1614
+ try { data = JSON.parse(text); } catch { throw new Error(`upload returned non-JSON: ${text.slice(0, 120)}`); }
1615
+ 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 };
1617
+ }
1618
+ // Post a message carrying exactly one media attachment (image|audio).
1619
+ async function sendMediaMessage(kind: "image" | "audio", args: any): Promise<{ content: any[]; isError?: boolean }> {
1620
+ const { chat_id, path, url, caption } = (args || {}) as { chat_id?: string; path?: string; url?: string; caption?: string };
1621
+ if (!chat_id) return { content: [{ type: "text", text: "Error: chat_id required" }], isError: true };
1622
+ // `text` (speak-via-TTS) is an audio-only third source, alongside path/url.
1623
+ const text = kind === "audio" && typeof args?.text === "string" && args.text.length > 0 ? (args.text as string) : undefined;
1624
+ const sources = [path ? "path" : null, url ? "url" : null, text ? "text" : null].filter(Boolean) as string[];
1625
+ if (sources.length === 0) {
1626
+ const opts = kind === "audio" ? "'path' (local file), 'url' (already-hosted), or 'text' (speak via TTS)" : "'path' (local file to upload) or 'url' (already-hosted /api/file/uploads/*)";
1627
+ return { content: [{ type: "text", text: `Error: provide ${opts}` }], isError: true };
1628
+ }
1629
+ if (sources.length > 1) return { content: [{ type: "text", text: `Error: provide only one of ${sources.join(", ")}, not multiple` }], isError: true };
1630
+ try {
1631
+ let finalUrl: string;
1632
+ let mime: string | undefined;
1633
+ let size: number | undefined;
1634
+ let ttsDuration: number | undefined;
1635
+ if (text) {
1636
+ const voice = typeof args.voice === "string" && args.voice ? args.voice : undefined;
1637
+ const r = await apiFetch(`${REST_URL}/api/tts`, {
1638
+ method: "POST",
1639
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1640
+ body: JSON.stringify({ text, ...(voice ? { voice } : {}) }),
1641
+ });
1642
+ const t = await r.text();
1643
+ 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, or send a recorded clip via path/url." }], isError: true };
1644
+ if (r.status === 400 && /INVALID_VOICE/i.test(t)) return { content: [{ type: "text", text: "Invalid voice for TTS. Call list_voices for valid names, or omit `voice` to use your configured one." }], isError: true };
1645
+ if (!r.ok) return { content: [{ type: "text", text: `TTS failed (${r.status}): ${t.slice(0, 140)}` }], isError: true };
1646
+ let d: any;
1647
+ try { d = JSON.parse(t); } catch { return { content: [{ type: "text", text: `TTS returned non-JSON: ${t.slice(0, 120)}` }], isError: true }; }
1648
+ if (!d?.url) return { content: [{ type: "text", text: `TTS response missing url: ${t.slice(0, 120)}` }], isError: true };
1649
+ finalUrl = d.url as string;
1650
+ mime = (d.mime as string) || "audio/mpeg";
1651
+ ttsDuration = typeof d.duration_ms === "number" ? d.duration_ms : undefined;
1652
+ } else if (path) {
1653
+ const up = await uploadLocalFile(path);
1654
+ finalUrl = up.url; mime = up.mime; size = up.size;
1655
+ } else {
1656
+ finalUrl = url as string;
1657
+ mime = mimeFromPath(finalUrl);
1658
+ }
1659
+ const attachment: Record<string, any> = { type: kind, url: finalUrl };
1660
+ if (mime && mime !== "application/octet-stream") attachment.mime = mime;
1661
+ if (size != null) attachment.size = size;
1662
+ if (kind === "image") {
1663
+ if (typeof args.width === "number") attachment.width = args.width;
1664
+ if (typeof args.height === "number") attachment.height = args.height;
1665
+ } else {
1666
+ const dur = typeof args.duration_ms === "number" ? args.duration_ms : ttsDuration;
1667
+ if (dur != null) attachment.duration_ms = dur;
1668
+ if (typeof args.transcript === "string" && args.transcript) attachment.transcript = args.transcript;
1669
+ else if (text) attachment.transcript = text; // the spoken text is its own transcript
1670
+ }
1671
+ const content = caption ? redactSecrets(await resolveBareMentions(chat_id, caption)) : "";
1672
+ const r = await apiFetch(`${REST_URL}/api/channels/${encodeURIComponent(chat_id)}/messages`, {
1673
+ method: "POST",
1674
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1675
+ body: JSON.stringify({ sender_id: AGENT_ID, content, sender_type: "agent", content_type: "text", attachments: [attachment] }),
1676
+ });
1677
+ if (!r.ok) {
1678
+ const t = await r.text();
1679
+ return { content: [{ type: "text", text: `Failed to send ${kind} (${r.status}): ${t.slice(0, 160)}` }], isError: true };
1680
+ }
1681
+ return { content: [{ type: "text", text: `Sent ${kind} to channel ${chat_id.slice(0, 8)}${text ? " (spoken via TTS)" : path ? ` (uploaded ${finalUrl.split("/").pop()})` : ""}` }] };
1682
+ } catch (e: any) {
1683
+ return { content: [{ type: "text", text: `Error sending ${kind}: ${String(e?.message || e).slice(0, 160)}` }], isError: true };
1684
+ }
1685
+ }
1686
+ HANDLERS.set("send_image", (args) => sendMediaMessage("image", args));
1687
+ HANDLERS.set("send_voice", (args) => sendMediaMessage("audio", args));
1688
+
1689
+ // list_voices — the server's curated TTS voice catalog (GET /api/voices), so an
1690
+ // agent can discover valid names before set_voice. Companion to set_voice; without
1691
+ // it the voice field is un-discoverable.
1692
+ HANDLERS.set("list_voices", async (args) => {
1693
+ const { language } = (args || {}) as { language?: string };
1694
+ try {
1695
+ const q = language ? `?language=${encodeURIComponent(language)}` : "";
1696
+ const r = await apiFetch(`${REST_URL}/api/voices${q}`, { headers: { "Authorization": `Bearer ${TOKEN}` } });
1697
+ if (!r.ok) return { content: [{ type: "text", text: `Failed to list voices (${r.status})` }], isError: true };
1698
+ const data = (await r.json()) as any;
1699
+ const voices = Array.isArray(data) ? data : (data?.voices || []);
1700
+ if (!voices.length) return { content: [{ type: "text", text: language ? `No voices for language ${language}.` : "No voices available." }] };
1701
+ const def = data && !Array.isArray(data) && data.default ? ` (default: ${data.default})` : "";
1702
+ const list = voices.map((v: any) => {
1703
+ const name = typeof v === "string" ? v : v?.name;
1704
+ const langs = v?.language_codes ? (Array.isArray(v.language_codes) ? v.language_codes : [v.language_codes]).join(",") : "";
1705
+ const gender = v?.ssml_gender ? ` ${v.ssml_gender}` : "";
1706
+ return `• ${name}${langs ? ` [${langs}]` : ""}${gender}`;
1707
+ }).join("\n");
1708
+ return { content: [{ type: "text", text: `${voices.length} voices${def}:\n${list}\n\nAssign one with set_voice({ voice: "<name>" }).` }] };
1709
+ } catch (e: any) {
1710
+ return { content: [{ type: "text", text: `Error listing voices: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1711
+ }
1712
+ });
1713
+
1714
+ // set_voice — writes the caller agent's own voice field. Voice must be a name from
1715
+ // list_voices; the server 400s INVALID_VOICE on a bad name (guards against storing a
1716
+ // name that only blows up later at TTS time). "" / null clears back to default.
1717
+ HANDLERS.set("set_voice", async (args) => {
1718
+ const hasVoice = args && typeof args === "object" && "voice" in args;
1719
+ if (!hasVoice) return { content: [{ type: "text", text: "Error: voice required (a name from list_voices; pass \"\" to clear back to default)" }], isError: true };
1720
+ const voice = (args as { voice?: string | null }).voice ?? "";
1721
+ try {
1722
+ const r = await apiFetch(`${REST_URL}/api/agents/${encodeURIComponent(AGENT_ID)}/voice`, {
1723
+ method: "PUT",
1724
+ headers: { "Content-Type": "application/json", "Authorization": `Bearer ${TOKEN}` },
1725
+ body: JSON.stringify({ voice }),
1726
+ });
1727
+ const text = await r.text();
1728
+ if (r.status === 400 && /INVALID_VOICE/i.test(text)) {
1729
+ return { content: [{ type: "text", text: `Invalid voice name "${voice}". Call list_voices to see valid names.` }], isError: true };
1730
+ }
1731
+ if (!r.ok) return { content: [{ type: "text", text: `Failed to set voice (${r.status}): ${text.slice(0, 140)}` }], isError: true };
1732
+ return { content: [{ type: "text", text: voice ? `Voice set to ${voice}.` : "Voice cleared (back to default)." }] };
1733
+ } catch (e: any) {
1734
+ return { content: [{ type: "text", text: `Error setting voice: ${String(e?.message || e).slice(0, 120)}` }], isError: true };
1735
+ }
1736
+ });
1737
+
1521
1738
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
1522
1739
  let { name, arguments: args } = request.params;
1523
1740
  let viaExtendedCompat = false;