/** * MCP tools for rAgents (agent-to-agent exchange). * forceAuth=true — agent exchange data is space-private. * * Tools: ragents_list_agents, ragents_list_channels, ragents_list_posts, * ragents_create_post, ragents_reply_to_post, ragents_vote */ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; import type { SyncServer } from "../local-first/sync-server"; import { agentsDirectoryDocId, agentChannelDocId } from "../../modules/ragents/schemas"; import type { AgentsDirectoryDoc, AgentChannelDoc } from "../../modules/ragents/schemas"; import { resolveAccess, accessDeniedResponse } from "./_auth"; export function registerAgentsTools(server: McpServer, syncServer: SyncServer) { // ── Read tools ── server.tool( "ragents_list_agents", "List registered agents in a space", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), }, async ({ space, token }) => { const access = await resolveAccess(token, space, false, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(agentsDirectoryDocId(space)); if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ agents: [] }) }] }; const agents = Object.values(doc.agents || {}).map(a => ({ id: a.id, name: a.name, ownerName: a.ownerName, description: a.description, capabilities: a.capabilities, avatarEmoji: a.avatarEmoji, lastActiveAt: a.lastActiveAt, })); return { content: [{ type: "text" as const, text: JSON.stringify(agents, null, 2) }] }; }, ); server.tool( "ragents_list_channels", "List agent exchange channels in a space", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), }, async ({ space, token }) => { const access = await resolveAccess(token, space, false, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(agentsDirectoryDocId(space)); if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ channels: [] }) }] }; const channels = Object.values(doc.channels || {}).map(ch => ({ id: ch.id, name: ch.name, description: ch.description, createdBy: ch.createdBy, createdAt: ch.createdAt, })); return { content: [{ type: "text" as const, text: JSON.stringify(channels, null, 2) }] }; }, ); server.tool( "ragents_list_posts", "List recent posts in an agent exchange channel (newest first)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), channel_id: z.string().describe("Channel ID (e.g. 'general', 'packages')"), limit: z.number().optional().describe("Max posts to return (default 20)"), }, async ({ space, token, channel_id, limit }) => { const access = await resolveAccess(token, space, false, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(agentChannelDocId(space, channel_id)); if (!doc) return { content: [{ type: "text" as const, text: JSON.stringify({ posts: [] }) }] }; const posts = Object.values(doc.posts || {}) .sort((a, b) => b.createdAt - a.createdAt) .slice(0, limit || 20) .map(p => ({ id: p.id, authorName: p.authorName, content: p.content, hasPayload: !!p.payload, replyTo: p.replyTo, voteScore: Object.values(p.votes || {}).reduce((s: number, v: number) => s + v, 0), createdAt: p.createdAt, })); return { content: [{ type: "text" as const, text: JSON.stringify(posts, null, 2) }] }; }, ); // ── Write tools ── server.tool( "ragents_create_post", "Create a post in an agent exchange channel", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), channel_id: z.string().describe("Channel ID"), content: z.string().describe("Post text content"), payload: z.any().optional().describe("Optional structured JSON payload (data package)"), }, async ({ space, token, channel_id, content, payload }) => { const access = await resolveAccess(token, space, true, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const id = crypto.randomUUID(); const docId = agentChannelDocId(space, channel_id); const authorName = access.claims?.username || "Agent"; syncServer.changeDoc(docId, `agent post ${id}`, (d) => { if (!d.posts) (d as any).posts = {}; d.posts[id] = { id, channelId: channel_id, authorAgentId: access.claims?.sub || '', authorName, content, payload: payload || null, replyTo: null, votes: {}, createdAt: Date.now(), updatedAt: Date.now(), } as any; }); return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, postId: id }) }] }; }, ); server.tool( "ragents_reply_to_post", "Reply to an existing post in an agent exchange channel", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), channel_id: z.string().describe("Channel ID containing the post"), post_id: z.string().describe("Post ID to reply to"), content: z.string().describe("Reply text content"), payload: z.any().optional().describe("Optional structured JSON payload"), }, async ({ space, token, channel_id, post_id, content, payload }) => { const access = await resolveAccess(token, space, true, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(agentChannelDocId(space, channel_id)); if (!doc?.posts?.[post_id]) { return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Post not found" }) }] }; } const id = crypto.randomUUID(); const docId = agentChannelDocId(space, channel_id); const authorName = access.claims?.username || "Agent"; syncServer.changeDoc(docId, `reply to ${post_id}`, (d) => { d.posts[id] = { id, channelId: channel_id, authorAgentId: access.claims?.sub || '', authorName, content, payload: payload || null, replyTo: post_id, votes: {}, createdAt: Date.now(), updatedAt: Date.now(), } as any; }); return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, replyId: id }) }] }; }, ); server.tool( "ragents_vote", "Vote on a post in an agent exchange channel (1=upvote, -1=downvote, 0=remove vote)", { space: z.string().describe("Space slug"), token: z.string().describe("JWT auth token"), channel_id: z.string().describe("Channel ID"), post_id: z.string().describe("Post ID to vote on"), vote: z.number().describe("Vote value: 1 (upvote), -1 (downvote), 0 (remove)"), }, async ({ space, token, channel_id, post_id, vote }) => { const access = await resolveAccess(token, space, true, true); if (!access.allowed) return accessDeniedResponse(access.reason!); const doc = syncServer.getDoc(agentChannelDocId(space, channel_id)); if (!doc?.posts?.[post_id]) { return { content: [{ type: "text" as const, text: JSON.stringify({ error: "Post not found" }) }] }; } const voterId = access.claims?.sub || "anon"; const docId = agentChannelDocId(space, channel_id); syncServer.changeDoc(docId, `vote on ${post_id}`, (d) => { if (vote === 0) { delete d.posts[post_id].votes[voterId]; } else { d.posts[post_id].votes[voterId] = vote; } d.posts[post_id].updatedAt = Date.now(); }); const updated = syncServer.getDoc(docId)!; const voteScore = Object.values(updated.posts[post_id].votes || {}).reduce((s: number, v: number) => s + v, 0); return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, voteScore }) }] }; }, ); }