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
3 changes: 3 additions & 0 deletions .github/workflows/package-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,9 @@ jobs:
scripts/verify-macos-binary.sh "release-binaries/${OUTPUT}" "$EXPECTED_ARCH" -- --version
echo "STANDALONE_CLI=$PWD/release-binaries/${OUTPUT}" >> "$GITHUB_ENV"

- name: Verify standalone MCP dispatches each request once
run: node scripts/verify-standalone-mcp-single-dispatch.mjs "$STANDALONE_CLI"
Comment thread
khaliqgant marked this conversation as resolved.

- name: Smoke standalone lifecycle
env:
AGENT_RELAY_STARTUP_DEBUG: 1
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,10 @@ jobs:
fi
echo "OK: ssh2 is bundled (ssh-userauth symbol present)"

- name: Verify standalone MCP dispatches each request once
if: matrix.target == 'bun-linux-x64'
run: node scripts/verify-standalone-mcp-single-dispatch.mjs "release-binaries/${{ matrix.binary_name }}"

- name: Sign macOS binary
if: startsWith(matrix.target, 'bun-darwin-')
run: scripts/sign-macos-binary.sh release-binaries/${{ matrix.binary_name }}
Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@ All notable changes to Agent Relay will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased - Patch]

### Fixed

- `agent-relay mcp` standalone binaries no longer dispatch each tool call twice.
- MCP `send_dm` now forwards idempotency keys so keyed retries do not store duplicate messages.

## [13.0.1] - 2026-10-02

Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/cli/agent-relay-mcp.startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,9 @@ describe('agent-relay-mcp startup helpers', () => {

expect(retry).toEqual(first);
expect(mocks.agentClients.reduce((count, client) => count + client.dm.mock.calls.length, 0)).toBe(1);
expect(mocks.agentClients.flatMap((client) => client.dm.mock.calls)).toEqual([
[input.to, input.text, expect.objectContaining({ idempotencyKey: input.idempotency_key })],
]);
});

it('parses startup options and helper flags from the environment', async () => {
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/cli/agent-relay-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
} from '@agent-relay/sdk';
import { z } from 'zod';
import { declaredWorkforceMetadata } from './lib/registration-metadata.js';
import { isBundledBunEntrypointPath } from './lib/agent-relay-mcp-command.js';
import {
DEFAULT_AGENT_REGISTRATION_TIMEOUT_MS,
withAgentRegistrationDeadline,
Expand Down Expand Up @@ -304,7 +305,7 @@
return `${VERIFIED_SPAWN_MISSING_READY_MESSAGE} Resolved handler node: ${handler}; invocation: ${invocationId}.`;
}

async function waitForVerifiedSpawn(

Check warning on line 308 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

Async function 'waitForVerifiedSpawn' has a complexity of 21. Maximum allowed is 15
actions: InvocationReader,
ackValue: unknown,
timeoutMs = VERIFIED_SPAWN_TIMEOUT_MS
Expand Down Expand Up @@ -496,6 +497,11 @@
function isEntrypoint(): boolean {
const invocationPath = process.argv[1];
if (!invocationPath) return false;
// `bun build --compile` folds this importable module into the CLI entrypoint,
// and both modules observe the same virtual import.meta.url. Treating this
// module as an entrypoint in that environment starts a second stdio server;
// the CLI's `mcp` command owns startup for the compiled executable.
if (isBundledBunEntrypointPath(invocationPath)) return false;
try {
return fs.realpathSync(invocationPath) === fs.realpathSync(fileURLToPath(import.meta.url));
} catch {
Expand Down Expand Up @@ -619,7 +625,7 @@
});
} catch (err) {
if ((err as { name?: string }).name === 'AbortError') {
throw new Error(`Agent Relay result submission timed out after ${timeoutMs}ms`);

Check warning on line 628 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

There is no `cause` attached to the symptom error being thrown
}
throw err;
} finally {
Expand Down Expand Up @@ -672,7 +678,7 @@
return { agentName, agentToken };
}

export async function registerAgentWithRebind({

Check warning on line 681 in packages/cli/src/cli/agent-relay-mcp.ts

View workflow job for this annotation

GitHub Actions / lint

Async function 'registerAgentWithRebind' has a complexity of 29. Maximum allowed is 15
session,
setSession,
getRelay,
Expand Down
89 changes: 89 additions & 0 deletions packages/cli/src/cli/mcp/messaging-tools.protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { McpRequestReplay } from './request-replay.js';
import { registerMessagingTools } from './messaging-tools.js';

describe('messaging delivery receipts over MCP', () => {
Expand All @@ -14,6 +15,94 @@ describe('messaging delivery receipts over MCP', () => {
vi.unstubAllEnvs();
});

it('reuses keyed receipts across two replay boundaries while unkeyed identical sends stay distinct', async () => {
const rows: { id: string; conversationId: string }[] = [];
const keyed = new Map<string, (typeof rows)[number]>();
const dm = vi.fn(async (_to: string, _text: string, options: { idempotencyKey?: string }) => {
const key = options.idempotencyKey;
if (key && keyed.has(key)) return keyed.get(key)!;
const row = { id: `msg_${rows.length + 1}`, conversationId: 'dm_chief' };
rows.push(row);
if (key) keyed.set(key, row);
return row;
});
async function send(idempotencyKey?: string) {
const server = new McpServer({ name: 'boundary-test', version: '1.0.0' });
registerMessagingTools(
server,
() => ({ dm }) as never,
async () => [{ name: 'chief' }],
new McpRequestReplay()
);
const client = new Client({ name: 'boundary-client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const result = await client.callTool({
name: 'send_dm',
arguments: {
to: 'chief',
text: 'same text',
...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
},
});
expect(result.isError).not.toBe(true);
return result.structuredContent;
} finally {
await client.close();
await server.close();
}
}
const first = await send('logical-send');
const retry = await send('logical-send');
expect(rows).toHaveLength(1);
expect(first).toMatchObject({ id: 'msg_1', conversationId: 'dm_chief' });
expect(retry).toEqual(first);
expect(dm).toHaveBeenCalledTimes(2);
const unkeyedFirst = await send();
const unkeyedSecond = await send();
expect(rows).toHaveLength(3);
expect(unkeyedFirst?.id).not.toBe(unkeyedSecond?.id);
});

it('rejects a whitespace-only idempotency key before sending and forwards it trimmed', async () => {
const dm = vi.fn(async () => ({ id: 'msg_1', conversationId: 'dm_chief' }));
const server = new McpServer({ name: 'idempotency-key-test', version: '1.0.0' });
registerMessagingTools(
server,
() => ({ dm }) as never,
async () => [{ name: 'chief' }],
new McpRequestReplay()
);
const client = new Client({ name: 'idempotency-key-client', version: '1.0.0' });
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
try {
await server.connect(serverTransport);
await client.connect(clientTransport);
const rejected = await client.callTool({
name: 'send_dm',
arguments: { to: 'chief', text: 'same text', idempotency_key: ' ' },
});
expect(rejected.isError).toBe(true);
expect(JSON.stringify(rejected.content)).toContain('idempotency_key');
expect(dm).not.toHaveBeenCalled();
const result = await client.callTool({
name: 'send_dm',
arguments: { to: 'chief', text: 'same text', idempotency_key: ' logical-send ' },
});
expect(result.isError).not.toBe(true);
expect(dm).toHaveBeenCalledWith(
'chief',
'same text',
expect.objectContaining({ idempotencyKey: 'logical-send' })
);
} finally {
await client.close();
await server.close();
}
});

it('exposes enqueue state on send and an explicit signal for an empty reader list', async () => {
const dm = vi.fn(async () => ({ id: 'msg_1', text: 'hello' }));
const readers = vi.fn(async () => []);
Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/cli/mcp/messaging-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,11 +350,15 @@ export function registerMessagingTools(
attachments: z.array(z.string()).optional().describe('File attachment IDs'),
idempotency_key: z
.string()
// Relaycast trims this key upstream, so trim before both the local
// replay cache and the forwarded call to keep one logical send on one
// key; a whitespace-only key would otherwise become an unkeyed send.
.trim()
.min(1)
.max(255)
.optional()
.describe(
'Stable key for retrying this same message after a lost response; use a new key for a new message.'
'Stable key for retrying this same message after a lost response; use a new key for a new message. Surrounding whitespace is trimmed and a whitespace-only key is rejected.'
),
...identityOverrideInputShape,
},
Expand All @@ -371,6 +375,7 @@ export function registerMessagingTools(
const agents = await listAgentsForRecipientResolution?.();
const resolvedRecipient = agents ? resolveExactAgentName(agents, to) : undefined;
const message = await getAgentClient(as).dm(to, text, {
idempotencyKey: idempotency_key,
mode,
attachments,
data: replayMessageMetadata(),
Expand Down
14 changes: 11 additions & 3 deletions packages/sdk/src/__tests__/thin-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,10 +199,15 @@ describe('createAgentClient', () => {
});
expect(instance.as).toHaveBeenCalledWith('at_live_test', { autoHeartbeatMs: false });

const agent = relaycastMocks.agentClients[0] as { send: ReturnType<typeof vi.fn> };
const agent = relaycastMocks.agentClients[0] as {
send: ReturnType<typeof vi.fn>;
dm: ReturnType<typeof vi.fn>;
};
const sent = await client.send('general', 'hello', { mode: 'wait' });
expect(agent.send).toHaveBeenCalledWith('general', 'hello', { mode: 'wait' });
expect(sent).toEqual({ raw: true, args: ['general', 'hello', { mode: 'wait' }] });
await client.dm('chief', 'dm', { idempotencyKey: 'dm-logical-1' });
expect(agent.dm).toHaveBeenCalledWith('chief', 'dm', { idempotencyKey: 'dm-logical-1' });
});

it('honors an explicit heartbeat interval', () => {
Expand Down Expand Up @@ -239,13 +244,16 @@ describe('createAgentClient', () => {

await client.send('general', 'hello');
await client.reply('msg_parent', 'reply');
await client.dm('chief', 'dm');
await client.dm('chief', 'dm', { idempotencyKey: 'dm-logical-1' });
await client.dms.sendMessage('conv_group', 'group');

const replayData = { session_ref: '11111111-1111-4111-8111-111111111111' };
expect(agent.send).toHaveBeenCalledWith('general', 'hello', { data: replayData });
expect(agent.reply).toHaveBeenCalledWith('msg_parent', 'reply', { data: replayData });
expect(agent.dm).toHaveBeenCalledWith('chief', 'dm', { data: replayData });
expect(agent.dm).toHaveBeenCalledWith('chief', 'dm', {
data: replayData,
idempotencyKey: 'dm-logical-1',
});
expect(agent.dms.sendMessage).toHaveBeenCalledWith('conv_group', 'group', { data: replayData });
});

Expand Down
2 changes: 2 additions & 0 deletions packages/sdk/src/messaging/thin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ export interface RelayAgentThinClient {
to: string,
text: string,
options?: {
/** Stable key for retries of one logical send, including across processes. */
idempotencyKey?: string;
mode?: RelayMessageMode;
attachments?: string[];
data?: Record<string, unknown> | null;
Expand Down
Loading
Loading