diff --git a/README.md b/README.md index df9c7b04..a2996aa0 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,6 @@ Add to your MCP config to give any AI CLI access to Relaycast: "command": "npx", "args": ["@relaycast/mcp"], "env": { - "RELAY_API_KEY": "rk_live_YOUR_KEY", "RELAY_BASE_URL": "https://api.relaycast.dev" } } @@ -66,7 +65,13 @@ Add to your MCP config to give any AI CLI access to Relaycast: } ``` -The agent registers via the `register` MCP tool, then uses `post_message`, `check_inbox`, `search_messages`, etc. Unread messages are automatically piggybacked onto every tool response. +Optional: set `RELAY_API_KEY` to start pre-authenticated for an existing workspace. +If omitted, start keyless and call MCP tools in this order: +1. `create_workspace` (or `set_workspace_key` if you already have one) +2. `register` +3. `post_message`, `check_inbox`, `search_messages`, etc. + +Unread messages are automatically piggybacked onto every tool response. ### TypeScript SDK diff --git a/packages/mcp/src/__tests__/piggyback.test.ts b/packages/mcp/src/__tests__/piggyback.test.ts index b5cc6a51..fcd71878 100644 --- a/packages/mcp/src/__tests__/piggyback.test.ts +++ b/packages/mcp/src/__tests__/piggyback.test.ts @@ -15,7 +15,13 @@ describe('piggyback unread messages', () => { beforeEach(async () => { vi.clearAllMocks(); - session = { agentToken: 'tok_test', agentName: 'bot1' }; + session = { + workspaceKey: 'rk_live_test', + agentToken: 'tok_test', + agentName: 'bot1', + wsBridge: null, + subscriptions: null, + }; mcpServer = new McpServer({ name: 'test', version: '0.1.0' }); enablePiggyback( diff --git a/packages/mcp/src/__tests__/registration-tools.test.ts b/packages/mcp/src/__tests__/registration-tools.test.ts index adfe7555..1a9267f4 100644 --- a/packages/mcp/src/__tests__/registration-tools.test.ts +++ b/packages/mcp/src/__tests__/registration-tools.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'; @@ -11,6 +11,7 @@ describe('registration tools', () => { let mcpServer: McpServer; let client: Client; let session: SessionState; + let originalFetch: typeof global.fetch; const mockRelay = { agents: { @@ -21,6 +22,7 @@ describe('registration tools', () => { beforeEach(async () => { vi.clearAllMocks(); + originalFetch = global.fetch; session = createInitialSession(); mcpServer = new McpServer({ name: 'test', version: '0.1.0' }); @@ -31,6 +33,7 @@ describe('registration tools', () => { (partial) => { Object.assign(session, partial); }, + 'https://api.test.dev', ); client = new Client({ name: 'test-client', version: '0.1.0' }); @@ -42,7 +45,68 @@ describe('registration tools', () => { ]); }); + afterEach(() => { + global.fetch = originalFetch; + }); + + it('create_workspace creates workspace and stores workspace key in session', async () => { + global.fetch = vi.fn(async () => { + return new Response( + JSON.stringify({ + ok: true, + data: { + workspace_id: 'ws_123', + api_key: 'rk_live_created123', + }, + }), + { status: 201, headers: { 'content-type': 'application/json' } }, + ); + }) as unknown as typeof global.fetch; + + const result = await client.callTool({ + name: 'create_workspace', + arguments: { name: 'project-alpha' }, + }); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.test.dev/v1/workspaces', + expect.objectContaining({ + method: 'POST', + }), + ); + expect(session.workspaceKey).toBe('rk_live_created123'); + expect(session.agentToken).toBeNull(); + expect(session.agentName).toBeNull(); + expect(result.content).toBeDefined(); + }); + + it('set_workspace_key stores key and clears agent identity when switching key', async () => { + session.workspaceKey = 'rk_live_old'; + session.agentToken = 'at_live_old'; + session.agentName = 'old-agent'; + + const result = await client.callTool({ + name: 'set_workspace_key', + arguments: { api_key: 'rk_live_new' }, + }); + + expect(result.isError).toBeFalsy(); + expect(session.workspaceKey).toBe('rk_live_new'); + expect(session.agentToken).toBeNull(); + expect(session.agentName).toBeNull(); + }); + + it('register returns error when workspace key is not configured', async () => { + const result = await client.callTool({ + name: 'register', + arguments: { name: 'bot1' }, + }); + expect(result.isError).toBe(true); + expect(mockRelay.agents.register).not.toHaveBeenCalled(); + }); + it('register tool calls relay.agents.register and stores token', async () => { + session.workspaceKey = 'rk_live_test'; mockRelay.agents.register.mockResolvedValue({ agent: { name: 'bot1' }, token: 'tok_abc', @@ -64,6 +128,7 @@ describe('registration tools', () => { }); it('list_agents tool calls relay.agents.list', async () => { + session.workspaceKey = 'rk_live_test'; mockRelay.agents.list.mockResolvedValue([{ name: 'bot1', status: 'online' }]); const result = await client.callTool({ name: 'list_agents', arguments: {} }); @@ -72,6 +137,7 @@ describe('registration tools', () => { }); it('list_agents with status filter', async () => { + session.workspaceKey = 'rk_live_test'; mockRelay.agents.list.mockResolvedValue([]); await client.callTool({ name: 'list_agents', @@ -80,4 +146,3 @@ describe('registration tools', () => { expect(mockRelay.agents.list).toHaveBeenCalledWith({ status: 'online' }); }); }); - diff --git a/packages/mcp/src/__tests__/server.test.ts b/packages/mcp/src/__tests__/server.test.ts index 23ce69a4..58df5546 100644 --- a/packages/mcp/src/__tests__/server.test.ts +++ b/packages/mcp/src/__tests__/server.test.ts @@ -84,9 +84,9 @@ describe('createRelayMcpServer', () => { await Promise.all([client.connect(ct), mcpServer.connect(st)]); }); - it('lists all 35 tools', async () => { + it('lists all 37 tools', async () => { const tools = await client.listTools(); - expect(tools.tools.length).toBe(35); + expect(tools.tools.length).toBe(37); const toolNames = tools.tools.map((t) => t.name).sort(); expect(toolNames).toEqual([ 'add_reaction', @@ -95,6 +95,7 @@ describe('createRelayMcpServer', () => { 'create_channel', 'create_subscription', 'create_webhook', + 'create_workspace', 'delete_command', 'delete_subscription', 'delete_webhook', @@ -122,6 +123,7 @@ describe('createRelayMcpServer', () => { 'send_dm', 'send_group_dm', 'set_channel_topic', + 'set_workspace_key', 'trigger_webhook', 'upload_file', ]); @@ -159,4 +161,18 @@ describe('createRelayMcpServer', () => { }); expect(result.isError).toBe(true); }); + + it('supports keyless startup and bootstrap via set_workspace_key', async () => { + const keylessServer = createRelayMcpServer({}); + const keylessClient = new Client({ name: 'keyless-client', version: '0.1.0' }); + const [ct, st] = InMemoryTransport.createLinkedPair(); + await Promise.all([keylessClient.connect(ct), keylessServer.connect(st)]); + + const result = await keylessClient.callTool({ + name: 'set_workspace_key', + arguments: { api_key: 'rk_live_bootstrap123' }, + }); + + expect(result.isError).toBeFalsy(); + }); }); diff --git a/packages/mcp/src/piggyback.ts b/packages/mcp/src/piggyback.ts index d25503f9..80e54be0 100644 --- a/packages/mcp/src/piggyback.ts +++ b/packages/mcp/src/piggyback.ts @@ -3,7 +3,12 @@ import type { AgentClient } from '@relaycast/sdk'; import type { SessionState } from './types.js'; import type { McpTelemetry } from './telemetry.js'; -const SKIP_PIGGYBACK = new Set(['check_inbox', 'register']); +const SKIP_PIGGYBACK = new Set([ + 'check_inbox', + 'create_workspace', + 'set_workspace_key', + 'register', +]); const MESSAGE_TOOLS = new Set([ 'post_message', 'reply_to_thread', diff --git a/packages/mcp/src/prompts.ts b/packages/mcp/src/prompts.ts index 00241931..fd0c44bb 100644 --- a/packages/mcp/src/prompts.ts +++ b/packages/mcp/src/prompts.ts @@ -3,10 +3,11 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; const DEFAULT_SYSTEM_PROMPT = `You are an AI agent in a collaborative workspace powered by Agent Relay. You can communicate with other agents using the following tools: ## Getting Started -1. Call "register" with your agent name to join the workspace -2. Use "list_channels" to see available channels -3. Use "join_channel" to join channels of interest -4. Use "check_inbox" to see unread messages and mentions +1. If workspace key is not configured, call "create_workspace" or "set_workspace_key" +2. Call "register" with your agent name to join the workspace +3. Use "list_channels" to see available channels +4. Use "join_channel" to join channels of interest +5. Use "check_inbox" to see unread messages and mentions ## Communication - Post messages to channels with "post_message" @@ -44,4 +45,3 @@ export function registerSystemPrompt(server: McpServer): void { } export { DEFAULT_SYSTEM_PROMPT }; - diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 52bccc2e..acbb2156 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -16,15 +16,14 @@ import { createMcpTelemetry, type McpTelemetry } from './telemetry.js'; export const MCP_VERSION = '0.1.2'; export interface McpServerOptions { - apiKey: string; + apiKey?: string; baseUrl?: string; telemetryTransport?: 'stdio' | 'http'; telemetry?: McpTelemetry; } export function createRelayMcpServer(options: McpServerOptions): McpServer { - const relay = new Relay({ apiKey: options.apiKey, baseUrl: options.baseUrl }); - const session: SessionState = createInitialSession(); + const session: SessionState = createInitialSession(options.apiKey ?? null); const telemetry = options.telemetry ?? createMcpTelemetry(MCP_VERSION); const mcpServer = new McpServer( @@ -43,14 +42,35 @@ export function createRelayMcpServer(options: McpServerOptions): McpServer { transport: options.telemetryTransport ?? 'unknown', }); - const getRelay = () => relay; const getSession = () => session; + const getRelay = () => { + const workspaceKey = session.workspaceKey; + if (!workspaceKey) { + throw new Error( + 'Workspace key not configured. Set RELAY_API_KEY at startup, or call "create_workspace" or "set_workspace_key" first.', + ); + } + return new Relay({ apiKey: workspaceKey, baseUrl: options.baseUrl }); + }; const setSession = (partial: Partial) => { - // When an agent token is set, initialize the WebSocket bridge - if (partial.agentToken && !session.wsBridge) { + const nextAgentToken = + partial.agentToken === undefined ? session.agentToken : partial.agentToken; + const nextAgentName = partial.agentName ?? session.agentName ?? null; + const shouldResetBridge = + partial.agentToken !== undefined && partial.agentToken !== session.agentToken; + + if (shouldResetBridge && session.wsBridge) { + session.wsBridge.stop(); + session.subscriptions?.clear(); + session.wsBridge = null; + session.subscriptions = null; + } + + // When an agent token is set, initialize the WebSocket bridge. + if (nextAgentToken && !session.wsBridge) { const subscriptions = new SubscriptionManager(); const wsClient = new WsClient({ - token: partial.agentToken, + token: nextAgentToken, baseUrl: options.baseUrl, }); const wsBridge = new WsBridge( @@ -66,7 +86,7 @@ export function createRelayMcpServer(options: McpServerOptions): McpServer { Object.assign(session, partial, { wsBridge, subscriptions }); telemetry.capture('relaycast_mcp_session_authenticated', { source_surface: 'mcp', - agent_name: partial.agentName ?? session.agentName ?? null, + agent_name: nextAgentName, }); } else { Object.assign(session, partial); @@ -77,7 +97,9 @@ export function createRelayMcpServer(options: McpServerOptions): McpServer { if (!session.agentToken) { throw new Error('Not registered. Call the "register" tool first.'); } - return relay.as(session.agentToken); + return new Relay({ apiKey: session.agentToken, baseUrl: options.baseUrl }).as( + session.agentToken, + ); }; // Enable piggybacking of unread messages on all tool responses @@ -87,7 +109,13 @@ export function createRelayMcpServer(options: McpServerOptions): McpServer { registerResourceDefinitions(mcpServer, getAgentClient, getRelay); // Register all tools - registerRegistrationTools(mcpServer, getRelay, getSession, setSession); + registerRegistrationTools( + mcpServer, + getRelay, + getSession, + setSession, + options.baseUrl, + ); registerChannelTools(mcpServer, getAgentClient); registerMessagingTools(mcpServer, getAgentClient); registerFeatureTools(mcpServer, getAgentClient); diff --git a/packages/mcp/src/stdio.ts b/packages/mcp/src/stdio.ts index d730e880..a76c07f7 100644 --- a/packages/mcp/src/stdio.ts +++ b/packages/mcp/src/stdio.ts @@ -2,10 +2,6 @@ import { startStdio } from './transports.js'; const apiKey = process.env.RELAY_API_KEY; -if (!apiKey) { - console.error('RELAY_API_KEY environment variable is required'); - process.exit(1); -} startStdio({ apiKey, diff --git a/packages/mcp/src/tools/registration.ts b/packages/mcp/src/tools/registration.ts index bcd6cade..f48794ba 100644 --- a/packages/mcp/src/tools/registration.ts +++ b/packages/mcp/src/tools/registration.ts @@ -3,18 +3,125 @@ import { z } from 'zod'; import { Relay } from '@relaycast/sdk'; import type { SessionState } from '../types.js'; +type ApiOk = { ok: true; data: T }; +type ApiErr = { ok: false; error?: { message?: string } }; + +interface CreateWorkspaceResponse { + workspace_id?: string; + workspaceId?: string; + api_key?: string; + apiKey?: string; +} + +const DEFAULT_BASE_URL = 'https://api.relaycast.dev'; + +function normalizeBaseUrl(baseUrl?: string): string { + return (baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); +} + +async function createWorkspace( + name: string, + baseUrl?: string, +): Promise { + const response = await fetch(`${normalizeBaseUrl(baseUrl)}/v1/workspaces`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + + const payload = (await response.json()) as ApiOk | ApiErr; + if (!payload || typeof payload !== 'object' || !('ok' in payload)) { + throw new Error('Invalid response while creating workspace'); + } + if (!payload.ok) { + throw new Error(payload.error?.message ?? 'Failed to create workspace'); + } + return payload.data; +} + +function requireWorkspaceKey(session: SessionState): void { + if (session.workspaceKey) return; + throw new Error( + 'Workspace key not configured. Call "create_workspace" or "set_workspace_key" first.', + ); +} + export function registerRegistrationTools( server: McpServer, getRelay: () => Relay, getSession: () => SessionState, setSession: (state: Partial) => void, + baseUrl?: string, ): void { - // Tool 1: register + // Tool 1: create_workspace + server.registerTool( + 'create_workspace', + { + description: + 'Create a new workspace and store its workspace key in this MCP session for subsequent registration calls.', + inputSchema: { + name: z.string().describe('Workspace name'), + }, + }, + async ({ name }) => { + const workspace = await createWorkspace(name, baseUrl); + const workspaceKey = workspace.api_key ?? workspace.apiKey; + if (!workspaceKey || typeof workspaceKey !== 'string') { + throw new Error('Workspace created, but the response did not include api_key'); + } + + // Switching workspace context invalidates prior agent identity. + setSession({ workspaceKey, agentToken: null, agentName: null }); + + return { + content: [{ type: 'text', text: JSON.stringify(workspace, null, 2) }], + }; + }, + ); + + // Tool 2: set_workspace_key + server.registerTool( + 'set_workspace_key', + { + description: + 'Set the workspace key (rk_live_...) for this MCP session. This enables register and workspace-level tools.', + inputSchema: { + api_key: z.string().describe('Workspace API key (rk_live_...)'), + }, + }, + async ({ api_key }) => { + if (!api_key.startsWith('rk_live_')) { + throw new Error('Workspace key must start with "rk_live_"'); + } + + const session = getSession(); + const switchingWorkspace = session.workspaceKey !== api_key; + if (switchingWorkspace) { + // Switching workspace context invalidates prior agent identity. + setSession({ workspaceKey: api_key, agentToken: null, agentName: null }); + } else { + setSession({ workspaceKey: api_key }); + } + + return { + content: [ + { + type: 'text', + text: switchingWorkspace + ? 'Workspace key set. Previous agent session was cleared; call "register" again.' + : 'Workspace key set.', + }, + ], + }; + }, + ); + + // Tool 3: register server.registerTool( 'register', { description: - 'Register this agent in the workspace and obtain an agent token for subsequent operations.', + 'Register this agent in the current workspace and obtain an agent token for subsequent operations.', inputSchema: { name: z.string().describe('Unique agent name'), type: z.enum(['agent', 'human']).optional().describe('Agent type'), @@ -22,6 +129,7 @@ export function registerRegistrationTools( }, }, async ({ name, type, persona }) => { + requireWorkspaceKey(getSession()); const relay = getRelay(); const result = await relay.agents.register({ name, type, persona }); // Store the agent token in session state @@ -32,7 +140,7 @@ export function registerRegistrationTools( }, ); - // Tool 2: list_agents + // Tool 4: list_agents server.registerTool( 'list_agents', { @@ -45,6 +153,7 @@ export function registerRegistrationTools( }, }, async ({ status }) => { + requireWorkspaceKey(getSession()); const relay = getRelay(); const agents = await relay.agents.list(status ? { status } : undefined); return { @@ -53,4 +162,3 @@ export function registerRegistrationTools( }, ); } - diff --git a/packages/mcp/src/types.ts b/packages/mcp/src/types.ts index ba093dd6..7cf67182 100644 --- a/packages/mcp/src/types.ts +++ b/packages/mcp/src/types.ts @@ -2,13 +2,13 @@ import type { WsBridge } from './resources/ws-bridge.js'; import type { SubscriptionManager } from './resources/subscriptions.js'; export interface SessionState { + workspaceKey: string | null; agentToken: string | null; agentName: string | null; wsBridge: WsBridge | null; subscriptions: SubscriptionManager | null; } -export function createInitialSession(): SessionState { - return { agentToken: null, agentName: null, wsBridge: null, subscriptions: null }; +export function createInitialSession(workspaceKey: string | null = null): SessionState { + return { workspaceKey, agentToken: null, agentName: null, wsBridge: null, subscriptions: null }; } -