From 0e49c9f108c50e5bb831f552fda7157223e0a28f Mon Sep 17 00:00:00 2001 From: Danyang Chen Date: Wed, 21 Oct 2026 09:26:42 +0800 Subject: [PATCH] Share with a friend degrades cleanly when the server lacks the tool (#563) The production Bot Social answers a missing tool with `MCP error -32602: Tool not found`; isUnknownTool() only knew `unknown tool`/`no tool`/`method not found`/`unrecognized tool`, so tryTool never took the REST fallback and the raw MCP string reached the UI. isUnknownTool now also accepts `Tool not found` and, for -32602, only when the text names a missing tool, so an invalid-params error that is not a missing tool still surfaces. With it, grantBotToFriend tries POST /v1/bots/:id/grants/to-friend and reports { unavailable: true } when that 404s too, which the handler already maps to "Bot Social needs an update to share with a friend. Invite links still work." mapShareFriendError is left alone: once isUnknownTool recognises the wording, that second mapping is unreachable. Fixes #563 --- ...63-share-with-friend-missing-tool.fixed.md | 1 + docs/subsystems/social.md | 2 +- plugins/social/src/mcp-client.js | 7 ++- plugins/social/test/social.test.js | 61 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 changelog.d/563-share-with-friend-missing-tool.fixed.md diff --git a/changelog.d/563-share-with-friend-missing-tool.fixed.md b/changelog.d/563-share-with-friend-missing-tool.fixed.md new file mode 100644 index 000000000..83e1beb9e --- /dev/null +++ b/changelog.d/563-share-with-friend-missing-tool.fixed.md @@ -0,0 +1 @@ +Sharing a Bot with a friend on a Bot Social server that lacks `grant_bot_to_friend` now tries the REST route and, when that is missing too, says *Bot Social needs an update to share with a friend. Invite links still work.* instead of showing the raw `MCP error -32602: Tool grant_bot_to_friend not found` ([#563](https://github.com/ThinkFlowLab/FlyBot/issues/563)). diff --git a/docs/subsystems/social.md b/docs/subsystems/social.md index bc48e2b1d..3389abe63 100644 --- a/docs/subsystems/social.md +++ b/docs/subsystems/social.md @@ -10,7 +10,7 @@ The social plugin talks to a [Bot Social](https://github.com/Birfy/bot-social) s Bot Social plugin for chatting with friends' Bots (send middleware `social` with per-home approval) lives entirely under `plugins/social/`. Inbound friend messages arrive immediately; outbound drafts the server holds (`pending_approval`) are listed on `social/status` and approved in the open chat (`flybot.session.dock`). The social sidebar strip shows a badge and a one-line jump list. The same queue is also attached to a native shared room via `flybot.roomApprovals.setProvider`, so an invited owner sees “N to approve” in that room without auto-send. -Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, after New group chat; `label` / `hint` use the same `t()` map). **Face-to-face room** and **Add friend** are those rows (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. **Face-to-face room** opens the same screen (pick one or more local Bots, six-digit number, live waiting room that shows the code, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **Sharing → Copy invite link** creates a per-Bot grant when the friend accepts; **Share with a friend** (#500) lists current friends who do not have this Bot yet and grants it in one click (Bot Social `grant_bot_to_friend`, companion Birfy/bot-social#34) without an invite link; **People with access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). +Face-to-face rooms (#83, #92) also live here: the primary entry is the sidebar **+** menu (slot `flybot.new-chat.action` on the core new-chat picker, after New group chat; `label` / `hint` use the same `t()` map). **Face-to-face room** and **Add friend** are those rows (copying an invite and your own handle live inside Add friend); the social strip has no **+ Add** button. **Face-to-face room** opens the same screen (pick one or more local Bots, six-digit number, live waiting room that shows the code, **Enter chat**). The pick list and waiting roster draw each Bot with the host `BotAvatar` (team roster look for yours; a friend's Bot uses its published look or a handle-seeded look, plus any avatar face-room `members[]` carries). Waiting-room and picker Bots are a wrapping tile grid (avatar on top, name centered below; yours a tiny accent dot; waiting tiles use the server owner label, e.g. `Chief · BoxB`); **Leave** and **Enter chat** share a fixed footer while a long grid scrolls under the countdown. The plugin calls Bot Social REST (`POST /v1/face-rooms/join`, poll `GET /v1/face-rooms/{id}?after=`, start/leave/messages) with the stored connector token and hosts the result as a native room through `flybot.rooms` (`origin: 'face-to-face'`). Those face-room routes accept a connector token; owner settings do not (`GET`/`PATCH /v1/me` returns 403 `connector_token_rest_forbidden`). Peer text is fenced; `pending_approval` drafts of kind `face_room_message` use the in-chat Approve / Reject path. **Auto-send** for face rooms is one compact checkbox (tooltip) on the open room's overview, via MCP `whoami` (read) and `set_auto_approve { auto_approve_face_rooms }` (write; the owner's click requests and uses `confirm_code` in the same step); default off. A friend's Bot in the room card, waiting grid, **main sidebar row**, and core group overview uses the host `BotAvatar` (published look, optional member `avatar`, else a handle-seeded look). **Sharing → Copy invite link** creates a per-Bot grant when the friend accepts; **Share with a friend** (#500) lists current friends who do not have this Bot yet and grants it in one click (Bot Social `grant_bot_to_friend`, companion Birfy/bot-social#34) without an invite link; a server without that tool tries the REST route `POST /v1/bots/:id/grants/to-friend` and, when it 404s too, says *Bot Social needs an update to share with a friend. Invite links still work.* instead of the raw MCP error (the production `MCP error -32602: Tool … not found` wording counts as an unknown tool, #563); **People with access** lists who has that Bot, in which groups, and **Revoke**. There is no published toggle. Bringing my own Bot by number is an implicit room-scoped grant: if it has no identity yet, join registers it room-only (`add_home_member`) and revokes that identity (`remove_home_member`) when it leaves its last face room. Friends' Bots lists only Bots granted to me. Held replies show in the chat (owner Approve / Reject). **Remove this Bot** declines that grant on Bot Social (`decline_bot_grant`) so it leaves the owner's Manage access; an older server falls back to a local hide. The row drops immediately; room leave and a roster refresh continue in the background. A local hide is forgotten only after two consecutive successful `list_remote_bots` results without that handle, or an explicit re-share / `regranted` join. Hidden, gone, and home-level remotes stay out of `flybot.remote.publish` and the new-chat picker. Friends' Bots paints a cached roster immediately and refreshes in the background (keep-alive HTTP, parallel mount calls). ## Owns diff --git a/plugins/social/src/mcp-client.js b/plugins/social/src/mcp-client.js index a84cf1c83..8b9378b16 100644 --- a/plugins/social/src/mcp-client.js +++ b/plugins/social/src/mcp-client.js @@ -157,7 +157,12 @@ export function isUnknownTool(error) { const code = String(error?.code ?? '') const blob = `${code} ${text}` if (/grant_not_found/i.test(blob)) return false - return code === 'unknown' || /unknown tool|no tool|method not found|unrecognized tool/i.test(text) + if (code === 'unknown' || /unknown tool|no tool|method not found|unrecognized tool|no such tool/i.test(text)) return true + // #563: the production Bot Social answers a missing tool with JSON-RPC -32602 and + // "Tool not found". Without this the raw MCP string reaches the UI and the REST + // fallback never runs. + if (/tool\s+['`"]?[\w.-]+['`"]?\s+(?:is\s+)?not found/i.test(text)) return true + return /-32602\b/.test(text) && /\btool\b/i.test(text) && /not found|no such/i.test(text) } export function isGrantNotFound(error) { diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 9bbb38495..96168f2d8 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -8406,6 +8406,67 @@ test('#500: share-with-friend surfaces a clear error when Bot Social lacks grant assert.match(String(result.error?.message || result.error), /needs an update to share with a friend|Invite links still work/i) }) +test('#563: production "Tool not found" (-32602) is unknown-tool and degrades to the unavailable message', async (t) => { + // The production Bot Social answers a missing tool with JSON-RPC -32602 and + // "Tool not found"; the #500 fallback only knew "unknown tool"/"no tool". + assert.equal(isUnknownTool(Object.assign(new Error('MCP error -32602: Tool grant_bot_to_friend not found'), { code: 'mcp' })), true) + assert.equal(isUnknownTool(new Error('Tool list_x not found')), true) + assert.equal(isUnknownTool(new Error('MCP error -32602: relationship not found')), false, 'invalid params that are not a missing tool stay an error') + { + // The API: the MCP call reports the production wording, so the REST fallback must run and, + // when the route is absent as well (the mock answers 404), report `unavailable`. + const server = mockServer() + const baseFetch = server.fetch + server.fetch = async (url, init) => { + const body = init.body ? JSON.parse(init.body) : {} + if (body?.params?.name === 'grant_bot_to_friend') { + return new Response(JSON.stringify({ + jsonrpc: '2.0', id: body.id, + result: { isError: true, content: [{ type: 'text', text: JSON.stringify({ code: 'mcp', message: 'MCP error -32602: Tool grant_bot_to_friend not found' }) }] }, + }), { status: 200 }) + } + return baseFetch(url, init) + } + const api = createSocialApi({ server: 'https://s', token: 'owner-token', fetch: server.fetch }) + const result = await api.grantBotToFriend({ botId: 'm1', friendHome: '@bob.home' }) + assert.equal(result.unavailable, true) + assert.equal(server.calls.some(c => c.name === 'REST POST /v1/bots/grants/to-friend'), true, 'the REST fallback runs before giving up') + } + { + // The handler: the user sees the actionable message, never the raw MCP string. + const { ctx, home, call, stop } = await team() + t.after(stop) + const server = mockServer() + server.tools.list_friends = () => ({ + friends: [{ relationship_id: 'r1', status: 'active', peer: { handle: '@bob.home', owner_display_name: 'Bob' } }], + pending_incoming: [], pending_outgoing: [], + }) + const baseFetch = server.fetch + server.fetch = async (url, init) => { + const body = init.body ? JSON.parse(init.body) : {} + if (body?.params?.name === 'grant_bot_to_friend') { + return new Response(JSON.stringify({ + jsonrpc: '2.0', id: body.id, + result: { isError: true, content: [{ type: 'text', text: JSON.stringify({ code: 'mcp', message: 'MCP error -32602: Tool grant_bot_to_friend not found' }) }] }, + }), { status: 200 }) + } + return baseFetch(url, init) + } + const social = await startSocial(ctx.flybot, { + server: 'https://social.test', credentialsPath: join(home, 'flybot-social.json'), + fetch: server.fetch, inboxPollMs: 0, + }) + t.after(() => social.dispose()) + await settle() + const chief = ctx.flybot.bot.list().find(b => b.isMain) || ctx.flybot.bot.list()[0] + await call('flybot/social/create-invite', { botId: chief.id }) + const result = await call('flybot/social/share-with-friend', { botId: chief.id, home: '@bob.home' }) + assert.equal(result.ok, false) + assert.match(String(result.error?.message || result.error), /needs an update to share with a friend|Invite links still work/i) + assert.doesNotMatch(String(result.error?.message || result.error), /-32602|MCP error/i) + } +}) + test('#500: Share with a friend lists candidates and shares on click', async () => { const calls = [] const friends = [{ home: '@bob.home', name: 'Bob', ownerId: 'o-bob' }]