Files
AI-Rendezvous/packages/mcp/src/index.ts
T
a.andreev bb0eb6979e
deploy / deploy (push) Canceled after 0s
Participants report taking the room into work (join)
- POST /api/rooms/:id/join + rendezvous_join MCP tool; creator auto-joined
- joined_at shown to agents, observer (invite not confirmed yet) and used by
  the turn indicator; what_you_should_do_next starts with joining
- fix: participant list order is now stable (insertion order)
2026-09-06 21:35:03 +03:00

209 lines
9.7 KiB
JavaScript

#!/usr/bin/env node
/**
* AI Rendezvous MCP server.
*
* Scope: this is an INTERFACE to the AI Rendezvous room transport, not an
* autonomous participant. It works during your active turn (you call tools,
* the server relays to the room's HTTP API). It cannot and does not wake your
* model when new messages arrive — polling happens when YOU decide to call
* rendezvous_get.
*/
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
import { RendezvousClient } from '@ai-rendezvous/client-sdk';
const BASE_URL = (process.env.RENDEZVOUS_BASE_URL ?? 'http://localhost:3000').replace(/\/$/, '');
const server = new McpServer({
name: 'ai-rendezvous',
version: '0.1.0',
});
function client(token?: string): RendezvousClient {
return new RendezvousClient(BASE_URL, token);
}
const participantSchema = z
.object({
role: z.string().describe('Short machine-readable role of this participant, e.g. "windows-1c"'),
display_name: z.string().optional().describe('Human-readable name'),
knows: z.array(z.string()).describe('Facts/systems this participant has direct access to'),
needs_to_determine: z.array(z.string()).describe('Things this participant must find out'),
instructions: z.string().optional().describe('Extra context/instructions for this participant'),
})
.describe('A negotiating side (usually an AI session on some machine)');
server.tool(
'rendezvous_create',
'Create a temporary negotiation room ("rendezvous") between independent AI sessions (possibly on different machines/harnesses/providers). Returns one SECRET invite URL per participant (token = identity AND authorization) plus observer_url. Use invite_urls[0] as your own; reply to the human with BOTH invite_urls[1] (they forward it once to the other session) AND observer_url (read-only link so the human can follow whose turn it is). Rooms are ephemeral (≤24h, then all data is deleted).',
{
title: z.string().describe('What is being negotiated, e.g. "1C <-> app integration contract"'),
goal: z.string().optional().describe('Success criterion of the negotiation'),
brief: z.string().optional().describe('Background context'),
ttl_hours: z.number().int().min(1).max(24).optional().describe('Hours until the room is deleted (default 24)'),
participants: z.array(participantSchema).min(2).max(8).describe('The negotiating sides; roles must be unique'),
},
async (args) => {
const created = await client().createRoom(args);
return {
content: [
{
type: 'text' as const,
text:
`Room created: ${created.room_id} (expires ${created.expires_at}).\n\n` +
created.invite_urls
.map((u, i) => `Participant "${created.participants[i].role}" invite URL (secret):\n${u}\nAgent-readable state: ${u}.md`)
.join('\n\n') +
`\n\nObserver URL (read-only, for the human to watch whose turn it is):\n${created.observer_url}` +
`\n\nNext: YOU are participant "${created.participants[0].role}" — keep that token. ` +
`Reply to the human with BOTH the other participant's invite URL and the observer URL. ` +
`Then start by stating verified facts from your side.`,
},
],
};
},
);
server.tool(
'rendezvous_get',
'Read the full current state of a rendezvous room: your_role, room_goal, conversation, open questions, current contract draft, room status, and what_you_should_do_next (an explicit instruction computed from negotiation state — follow it). Poll this when you want to check for new messages from the other side.',
{
token: z.string().describe('Your secret participant token (from your invite URL path /r/<room>/<token>)'),
room_id: z.string().describe('Room id'),
},
async ({ token, room_id }) => {
const v = await client(token).getRoom(room_id);
return {
content: [
{
type: 'text' as const,
text: JSON.stringify(
{
your_role: v.your_role,
room_goal: v.room.goal,
room_status: v.room_status,
participants: v.participants,
open_questions: v.open_questions,
current_contract: v.current_contract,
what_you_should_do_next: v.what_you_should_do_next,
available_actions: v.available_actions,
conversation: v.conversation,
},
null,
2,
),
},
],
};
},
);
server.tool(
'rendezvous_join',
'Report that YOU have taken this room into work. Call this ONCE right after receiving your invite URL / at the start of your participation, before other actions. It marks you as joined (with a timestamp) so the other side and the human observer know the task has been picked up. Idempotent.',
{
token: z.string().describe('Your secret participant token'),
room_id: z.string().describe('Room id'),
},
async ({ token, room_id }) => {
const r = await client(token).join(room_id);
const status = r.participants.map((p) => `${p.role}: ${p.joined_at ? `joined ${p.joined_at}` : 'not joined yet'}`).join('; ');
return { content: [{ type: 'text' as const, text: `You are marked as joined. Participants: ${status}` }] };
},
);
server.tool(
'rendezvous_post',
'Post a message to the room stating facts you verified on YOUR machine, answers, or arguments. Append-only: history cannot be edited.',
{
token: z.string().describe('Your secret participant token'),
room_id: z.string().describe('Room id'),
content: z.string().describe('Message text (markdown). Prefer verified facts over assumptions.'),
},
async ({ token, room_id, content }) => {
const r = await client(token).postMessage(room_id, content);
return { content: [{ type: 'text' as const, text: `Message posted: ${JSON.stringify(r)}` }] };
},
);
server.tool(
'rendezvous_ask',
'Open a question in the room — ask the other side to verify a fact on their machine, clarify a contradiction, or challenge a contract item. Use blocking=true when the negotiation cannot proceed until it is answered.',
{
token: z.string().describe('Your secret participant token'),
room_id: z.string().describe('Room id'),
question: z.string().describe('The question, concretely and verifiably'),
blocking: z.boolean().optional().describe('Whether this blocks finalization (default true)'),
addressed_to_participant_id: z.string().optional().describe('Participant id the question is for (from rendezvous_get); omit for "anyone"'),
},
async ({ token, room_id, question, blocking, addressed_to_participant_id }) => {
const r = await client(token).ask(room_id, question, { blocking, addressed_to_participant_id });
return { content: [{ type: 'text' as const, text: `Question opened: ${JSON.stringify(r)}` }] };
},
);
server.tool(
'rendezvous_resolve',
'Mark an open question as resolved. Provide a resolution containing the VERIFIED facts (what you checked, where, what you found) — not just "done". Only the addressee or the author of the question may resolve it.',
{
token: z.string().describe('Your secret participant token'),
room_id: z.string().describe('Room id'),
question_id: z.string().describe('Question id from rendezvous_get'),
resolution: z.string().describe('The verified answer: what was checked, where, and the result'),
},
async ({ token, room_id, question_id, resolution }) => {
const r = await client(token).resolve(room_id, question_id, resolution);
return { content: [{ type: 'text' as const, text: `Question resolved: ${JSON.stringify(r)}` }] };
},
);
server.tool(
'rendezvous_propose_contract',
'Propose or revise the structured Agreed Contract (the separate final artifact — not a chat message). Sections: ## Facts, ## Decisions, ## Interface, ## Schedule, ## Authentication, ## Error handling, ## Unresolved. Each proposal creates a new version all participants must agree to.',
{
token: z.string().describe('Your secret participant token'),
room_id: z.string().describe('Room id'),
markdown: z.string().describe('Full contract markdown (it replaces the previous version)'),
},
async ({ token, room_id, markdown }) => {
const r = await client(token).proposeContract(room_id, markdown);
return {
content: [
{
type: 'text' as const,
text: `Contract proposed as version ${r.version}. The other side must review and agree to this version (rendezvous_finalize).`,
},
],
};
},
);
server.tool(
'rendezvous_finalize',
'Agree to the CURRENT contract version. When every participant has agreed to the same version AND no unresolved blocking questions remain, the room status becomes "agreed" and the final Markdown artifact is available. If you disagree, do not call this — post a message and open a blocking question instead.',
{
token: z.string().describe('Your secret participant token'),
room_id: z.string().describe('Room id'),
},
async ({ token, room_id }) => {
const r = await client(token).agree(room_id);
const extra = r.room_status === 'agreed' ? ` Room finalized. Final artifact: GET ${BASE_URL}/api/rooms/${room_id}/final.md` : '';
return {
content: [
{
type: 'text' as const,
text: `You agreed to contract version ${r.agreed_contract_version}. everyone_agreed=${r.everyone_agreed}, room_status=${r.room_status}.${extra}`,
},
],
};
},
);
// ---------- entry point ----------
if (process.argv[1] && process.argv[1].endsWith('mcp/dist/src/index.js')) {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error(`AI Rendezvous MCP server running (base url: ${BASE_URL})`);
}