Skip to content

Commit 5caec60

Browse files
Multi server router ready
1 parent ab0a5bc commit 5caec60

20 files changed

Lines changed: 2032 additions & 316 deletions

pnpm-lock.yaml

Lines changed: 399 additions & 158 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

server/package.json

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,17 @@
1414
"author": "",
1515
"license": "ISC",
1616
"dependencies": {
17-
"@modelcontextprotocol/sdk": "^1.22.0",
18-
"hono": "^4.10.5",
19-
"preact": "^10.27.2",
17+
"@modelcontextprotocol/sdk": "1.23.1",
18+
"async-lock": "^1.4.1",
19+
"hono": "^4.10.8",
20+
"preact": "^10.28.0",
2021
"preact-iso": "^2.11.0",
21-
"zod": "^4.1.12"
22+
"zod": "^4.1.13"
2223
},
2324
"devDependencies": {
2425
"@preact/preset-vite": "^2.10.2",
26+
"@types/async-lock": "^1.4.2",
2527
"nitro": "npm:nitro-nightly@3.0.1-20251104-223815-3402cc78",
26-
"vite": "^7.1.12"
28+
"vite": "^7.2.7"
2729
}
2830
}

server/routes/mcp/clients/base-client.ts

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {Client} from "@modelcontextprotocol/sdk/client/index.js";
22
import type {Tool} from '@modelcontextprotocol/sdk/types.js';
33
import type {McpClientConfig, McpToolCallResult} from './types';
4-
import {normalizeCallToolResult} from './util';
4+
import {createErrorToolCallResult, normalizeCallToolResult} from './util';
55

66
/**
77
* Abstract base class for MCP clients.
@@ -10,6 +10,7 @@ import {normalizeCallToolResult} from './util';
1010
* All concrete implementations must extend this class and implement its abstract methods.
1111
*/
1212
export abstract class BaseMcpClient {
13+
public serverName: string;
1314
/**
1415
* Configuration for this client
1516
*/
@@ -36,10 +37,7 @@ export abstract class BaseMcpClient {
3637
protected constructor(config: McpClientConfig) {
3738
this.config = config;
3839
this.initializeClient();
39-
}
40-
41-
public get isConnected(): boolean {
42-
return this.connected;
40+
this.serverName = this.getDisplayName()
4341
}
4442

4543
public get availableTools(): readonly Tool[] {
@@ -100,7 +98,7 @@ export abstract class BaseMcpClient {
10098
*/
10199
public async callTool(toolName: string, args?: Record<string, unknown>): Promise<McpToolCallResult> {
102100
if (!this.mcpClient || !this.connected) {
103-
return this.createErrorResult("MCP Client not connected or configured properly");
101+
return createErrorToolCallResult("MCP Client not connected or configured properly");
104102
}
105103

106104
try {
@@ -115,7 +113,7 @@ export abstract class BaseMcpClient {
115113
};
116114
} catch (error) {
117115
const errorMessage = error instanceof Error ? error.message : String(error);
118-
return this.createErrorResult(errorMessage);
116+
return createErrorToolCallResult(errorMessage);
119117
}
120118
}
121119

@@ -172,25 +170,6 @@ export abstract class BaseMcpClient {
172170
return this.tools.map(t => t.name);
173171
}
174172

175-
/**
176-
* Create a standardized error result.
177-
* Used by all transport types for consistent error handling.
178-
*/
179-
protected createErrorResult(message: string): McpToolCallResult {
180-
return {
181-
success: false,
182-
result: {
183-
content: [
184-
{
185-
type: "text",
186-
text: message
187-
}
188-
],
189-
isError: true
190-
}
191-
};
192-
}
193-
194173
/**
195174
* Clean up resources when disconnecting.
196175
* Subclasses should call this in their disconnectFromServer() implementation.

server/routes/mcp/clients/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export type McpClientConfig =
110110
*/
111111
export type ConnectionState =
112112
| 'disconnected'
113+
| 'disconnecting'
113114
| 'connecting'
114115
| 'connected'
115116
| 'error';
@@ -152,6 +153,11 @@ export interface ServerInfo {
152153
* When the connection was established
153154
*/
154155
connectedAt?: Date;
156+
157+
/**
158+
* Optional metadata about the server
159+
*/
160+
metadata?: Record<string, unknown>;
155161
}
156162

157163
/**

server/routes/mcp/clients/util.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type {CallToolResult} from '@modelcontextprotocol/sdk/types.js';
2-
import type {RawCallToolResult} from "./types";
2+
import type {McpToolCallResult, RawCallToolResult} from "./types";
33

44
/**
55
* Normalizes a raw MCP call tool result to `CallToolResult` format from `@modelcontextprotocol/sdk/types.js`.
@@ -25,3 +25,29 @@ export function normalizeCallToolResult(rawResult: RawCallToolResult): CallToolR
2525
// Already in standard format or has both fields
2626
return rawResult as CallToolResult;
2727
}
28+
29+
/**
30+
* Creates a standardized error result for MCP tool calls.
31+
*
32+
* @param message - Error message to include in the result
33+
* @returns A standardized error result object
34+
*
35+
* @example
36+
* ```typescript
37+
* return createErrorResult("Server not found");
38+
* ```
39+
*/
40+
export function createErrorToolCallResult(message: string): McpToolCallResult {
41+
return {
42+
success: false,
43+
result: {
44+
content: [
45+
{
46+
type: "text",
47+
text: message
48+
}
49+
],
50+
isError: true
51+
}
52+
};
53+
}

server/routes/mcp/index.ts

Lines changed: 70 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,50 @@
11
/**
2-
* MCP Client Module
2+
* MCP Client, Registry, and Router Module
33
*
4-
* Provides a universal API for connecting to MCP servers via different transports:
5-
* - HTTP (Streamable HTTP)
6-
* - SSE (Server-Sent Events)
7-
* - STDIO (Standard Input/Output)
4+
* Provides a complete system for:
5+
* - Connecting to MCP servers via different transports (HTTP, SSE, STDIO)
6+
* - Managing multiple upstream servers with a registry
7+
* - Routing tool calls to appropriate servers
8+
* - Exposing aggregated tools as a unified MCP server
89
*
910
* @example
1011
* ```typescript
11-
* import { McpClientFactory } from './routes/mcp';
12+
* import { McpClientFactory, McpRelayServer } from './routes/mcp';
1213
*
13-
* // Create an HTTP client
14-
* const client = McpClientFactory.create({
15-
* transport: 'http',
16-
* serverUrl: 'http://localhost:3000',
17-
* name: 'My MCP Server'
14+
* // Create a relay server
15+
* const relay = new McpRelayServer({
16+
* name: 'my-relay',
17+
* version: '1.0.0'
1818
* });
1919
*
20-
* await client.connectToServer();
21-
* const tools = client.availableTools;
22-
* const result = await client.callTool('my-tool', { arg: 'value' });
23-
* await client.disconnectFromServer();
20+
* await relay.initialize();
21+
*
22+
* // Register upstream servers
23+
* const registry = relay.getRegistry();
24+
* await registry.registerServer({
25+
* serverId: 'server1',
26+
* clientConfig: {
27+
* transport: 'http',
28+
* serverUrl: 'http://127.0.0.1:3001',
29+
* name: 'Server 1'
30+
* }
31+
* });
32+
*
33+
* // Get MCP server for transport attachment
34+
* const mcpServer = relay.getMcpServer();
2435
* ```
2536
*/
2637

38+
// ===== Client Module =====
39+
2740
// Base client and factory
28-
export {BaseMcpClient} from './clients/base-client';
29-
export {McpClientFactory} from './factory';
41+
export { BaseMcpClient } from './clients/base-client';
42+
export { McpClientFactory } from './factory';
3043

3144
// Concrete implementations
32-
export {StreamableHttpMcpClient} from './clients/http';
45+
export { StreamableHttpMcpClient } from './clients/http';
3346

34-
// Types
47+
// Client types
3548
export type {
3649
TransportType,
3750
BaseMcpClientConfig,
@@ -40,8 +53,43 @@ export type {
4053
StdioMcpClientConfig,
4154
McpClientConfig,
4255
ConnectionState,
43-
ServerInfo
56+
ServerInfo,
57+
McpToolCallResult,
58+
RawCallToolResult,
4459
} from './clients/types';
4560

46-
// Utilities
47-
export type {McpToolCallResult, RawCallToolResult} from './clients/types';
61+
// ===== Registry Module =====
62+
63+
export {
64+
ServerRegistry,
65+
ServerWrapper,
66+
AsyncRWLock,
67+
AsyncMutex,
68+
} from './registry/index';
69+
70+
export type {
71+
AggregatedTool,
72+
ServerRegistrationConfig,
73+
RegistrationResult,
74+
RegistryStats,
75+
} from './registry/index';
76+
77+
// ===== Router Module =====
78+
79+
export {
80+
ToolRouter,
81+
ToolNameParser
82+
} from './router/index';
83+
84+
export type {
85+
ParsedToolName,
86+
} from './router/index';
87+
88+
// ===== Server Module =====
89+
90+
export { McpRelayServer } from './server/index';
91+
92+
export type {
93+
McpRelayServerConfig,
94+
RelayServerInfo,
95+
} from './server/index';
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Server Registry Module
3+
*
4+
* Provides server registration, lifecycle management, and tool aggregation with
5+
* proper concurrency control.
6+
*/
7+
8+
export {AsyncRWLock, AsyncMutex} from './locks';
9+
export {ServerWrapper} from './server-wrapper';
10+
export {ServerRegistry} from './server-registry';
11+
export type {
12+
AggregatedTool,
13+
ServerRegistrationConfig,
14+
RegistrationResult,
15+
RegistryStats
16+
} from './types';
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
/**
2+
* 2 async locks for safe concurrent access to server resources:
3+
*
4+
* 1. AsyncRWLock - Read-Write lock allowing multiple concurrent readers OR a single writer
5+
* 2. AsyncMutex - Mutual exclusion lock allowing only one accessor at a time
6+
*/
7+
8+
import AsyncLock from 'async-lock';
9+
10+
/**
11+
* Read-Write lock that allows multiple concurrent readers but only one writer.
12+
*
13+
* Rules:
14+
* - Multiple readers can hold the lock simultaneously
15+
* - Only one writer can hold the lock at a time
16+
* - Writers are exclusive with both readers and other writers
17+
*
18+
* @example
19+
* ```typescript
20+
* const rwLock = new AsyncRWLock();
21+
*
22+
* // Multiple readers can execute concurrently
23+
* await rwLock.withReadLock(async () => {
24+
* return data.value;
25+
* });
26+
*
27+
* // Writers get exclusive access
28+
* await rwLock.withWriteLock(async () => {
29+
* data.value = newValue;
30+
* });
31+
* ```
32+
*/
33+
export class AsyncRWLock {
34+
private readonly lock: AsyncLock;
35+
private readonly READ_KEY = 'read';
36+
private readonly WRITE_KEY = 'write';
37+
38+
constructor(maxPending: number, timeout: number) {
39+
this.lock = new AsyncLock({
40+
maxPending: maxPending,
41+
timeout: timeout
42+
});
43+
}
44+
45+
/**
46+
* Acquire a read lock and execute the given function.
47+
* Multiple readers can execute concurrently.
48+
*
49+
* @param fn - Async function to execute while holding the read lock
50+
* @returns The result of the function
51+
* @throws Error if lock acquisition times out
52+
*/
53+
async withReadLock<T>(fn: () => Promise<T>): Promise<T> {
54+
return this.lock.acquire(this.READ_KEY, fn);
55+
}
56+
57+
/**
58+
* Acquire a write lock and execute the given function.
59+
* Only one writer can execute at a time, and it's exclusive with all readers.
60+
*
61+
* @param fn - Async function to execute while holding the write lock
62+
* @returns The result of the function
63+
* @throws Error if lock acquisition times out
64+
*/
65+
async withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
66+
// Write locks are exclusive with both reads and other writes
67+
return this.lock.acquire([this.READ_KEY, this.WRITE_KEY], fn);
68+
}
69+
}
70+
71+
/**
72+
* Mutual exclusion lock that allows only one accessor at a time.
73+
*
74+
* Use this for protecting operations that must be atomic or when you need to ensure serial execution of critical sections.
75+
*
76+
* @example
77+
* ```typescript
78+
* const mutex = new AsyncMutex();
79+
*
80+
* // Only one execution at a time
81+
* await mutex.withLock(async () => {
82+
* // Critical section
83+
* await doSomethingImportant();
84+
* });
85+
* ```
86+
*/
87+
export class AsyncMutex {
88+
private readonly lock: AsyncLock;
89+
private readonly KEY = 'mutex';
90+
91+
constructor(maxPending: number, timeout: number) {
92+
this.lock = new AsyncLock({
93+
maxPending: maxPending,
94+
timeout: timeout
95+
});
96+
}
97+
98+
/**
99+
* Acquire the mutex lock and execute the given function.
100+
*
101+
* @param fn - Async function to execute while holding the lock
102+
* @returns The result of the function
103+
* @throws Error if lock acquisition times out
104+
*/
105+
async withLock<T>(fn: () => Promise<T>): Promise<T> {
106+
return this.lock.acquire(this.KEY, fn);
107+
}
108+
}

0 commit comments

Comments
 (0)