Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,15 +58,20 @@ 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"
}
}
}
}
```

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

Expand Down
8 changes: 7 additions & 1 deletion packages/mcp/src/__tests__/piggyback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
69 changes: 67 additions & 2 deletions packages/mcp/src/__tests__/registration-tools.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -11,6 +11,7 @@ describe('registration tools', () => {
let mcpServer: McpServer;
let client: Client;
let session: SessionState;
let originalFetch: typeof global.fetch;

const mockRelay = {
agents: {
Expand All @@ -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' });

Expand All @@ -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' });
Expand All @@ -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',
Expand All @@ -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: {} });
Expand All @@ -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',
Expand All @@ -80,4 +146,3 @@ describe('registration tools', () => {
expect(mockRelay.agents.list).toHaveBeenCalledWith({ status: 'online' });
});
});

20 changes: 18 additions & 2 deletions packages/mcp/src/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -95,6 +95,7 @@ describe('createRelayMcpServer', () => {
'create_channel',
'create_subscription',
'create_webhook',
'create_workspace',
'delete_command',
'delete_subscription',
'delete_webhook',
Expand Down Expand Up @@ -122,6 +123,7 @@ describe('createRelayMcpServer', () => {
'send_dm',
'send_group_dm',
'set_channel_topic',
'set_workspace_key',
'trigger_webhook',
'upload_file',
]);
Expand Down Expand Up @@ -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();
});
});
7 changes: 6 additions & 1 deletion packages/mcp/src/piggyback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@
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',
Expand All @@ -21,10 +26,10 @@
): void {
const original = mcpServer.registerTool.bind(mcpServer);

(mcpServer as any).registerTool = (

Check warning on line 29 in packages/mcp/src/piggyback.ts

View workflow job for this annotation

GitHub Actions / Lint, Build & Test

Unexpected any. Specify a different type
name: string,
config: any,

Check warning on line 31 in packages/mcp/src/piggyback.ts

View workflow job for this annotation

GitHub Actions / Lint, Build & Test

Unexpected any. Specify a different type
handler: any,

Check warning on line 32 in packages/mcp/src/piggyback.ts

View workflow job for this annotation

GitHub Actions / Lint, Build & Test

Unexpected any. Specify a different type
) => {
if (!handler) {
return original(name, config, handler);
Expand All @@ -32,14 +37,14 @@

const shouldPiggybackInbox = !SKIP_PIGGYBACK.has(name);

const wrapped = async (...args: any[]) => {

Check warning on line 40 in packages/mcp/src/piggyback.ts

View workflow job for this annotation

GitHub Actions / Lint, Build & Test

Unexpected any. Specify a different type
const startedAt = Date.now();
telemetry?.capture('relaycast_mcp_tool_invoked', {
source_surface: 'mcp',
tool_name: name,
});

let result: any;

Check warning on line 47 in packages/mcp/src/piggyback.ts

View workflow job for this annotation

GitHub Actions / Lint, Build & Test

Unexpected any. Specify a different type
try {
result = await handler(...args);
} catch (error) {
Expand Down
10 changes: 5 additions & 5 deletions packages/mcp/src/prompts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -44,4 +45,3 @@ export function registerSystemPrompt(server: McpServer): void {
}

export { DEFAULT_SYSTEM_PROMPT };

48 changes: 38 additions & 10 deletions packages/mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<SessionState>) => {
// 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;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 nextAgentName uses ?? instead of explicit null check, failing to clear agent name

On packages/mcp/src/server.ts:58, nextAgentName is computed using ??, which treats explicit null the same as undefined and falls through to the old session value. This is inconsistent with the nextAgentToken computation on line 56-57, which correctly distinguishes between undefined (not provided) and null (explicitly cleared).

Root Cause and Impact

When setSession({ workspaceKey: 'rk_new', agentToken: null, agentName: null }) is called (from create_workspace at packages/mcp/src/tools/registration.ts:74 or set_workspace_key at line 100), the intent is to clear the agent identity. However:

const nextAgentName = partial.agentName ?? session.agentName ?? null;
// partial.agentName is null → nullish → falls back to session.agentName
// Result: 'old-agent' instead of null

Compare with the correct pattern used for nextAgentToken:

const nextAgentToken =
  partial.agentToken === undefined ? session.agentToken : partial.agentToken;
// partial.agentToken is null → not undefined → uses null
// Result: null (correct)

nextAgentName is consumed by the telemetry event at line 89. In current call sites this telemetry line is only reached when nextAgentToken is truthy (bridge initialization), and all current callers that set agentName: null also set agentToken: null, so the stale value isn't emitted yet. However, the calculation is incorrect and any future call site that sets agentName: null with a truthy agentToken would log the wrong agent name.

Suggested change
const nextAgentName = partial.agentName ?? session.agentName ?? null;
const nextAgentName = partial.agentName === undefined ? (session.agentName ?? null) : partial.agentName;
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

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(
Expand All @@ -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);
Expand All @@ -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
Expand All @@ -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);
Expand Down
4 changes: 0 additions & 4 deletions packages/mcp/src/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading