|
| 1 | +# MCP Client Template Analysis |
| 2 | + |
| 3 | +Analysis of [andrea9293/mcp-client-template](https://github.com/andrea9293/mcp-client-template) patterns, protocol, and behavior. |
| 4 | + |
| 5 | +## Architecture |
| 6 | + |
| 7 | +**Two-tier web app:** |
| 8 | +- **Frontend** (Vite + TypeScript): Web UI for managing servers and sending commands |
| 9 | +- **Backend** (Express + TypeScript): REST API that wraps MCP SDK client operations |
| 10 | + |
| 11 | +**Data persistence:** |
| 12 | +- `data/mcp-servers.json` — Server configurations (command, type, args) |
| 13 | +- `data/auth-tokens.json` — OAuth tokens per server |
| 14 | + |
| 15 | +## Protocol & Transport |
| 16 | + |
| 17 | +### Transport Types Supported |
| 18 | + |
| 19 | +1. **`stdio`** — Newline-delimited JSON over stdin/stdout |
| 20 | + - Uses `StdioClientTransport` from SDK |
| 21 | + - Spawns process: `{ command, args }` |
| 22 | + - Example: `{ type: 'stdio', command: 'node', args: ['server.js'] }` |
| 23 | + |
| 24 | +2. **`httpstream`** — Streamable HTTP (long-polling or streaming) |
| 25 | + - Uses `StreamableHTTPClientTransport` from SDK |
| 26 | + - Command is the server URL |
| 27 | + - Example: `{ type: 'httpstream', command: 'https://mcp-server.example.com' }` |
| 28 | + |
| 29 | +3. **`sse`** — Server-Sent Events |
| 30 | + - Uses `SSEClientTransport` from SDK |
| 31 | + - Command is the server URL |
| 32 | + - Example: `{ type: 'sse', command: 'https://mcp-server.example.com/events' }` |
| 33 | + |
| 34 | +### MCP SDK Usage |
| 35 | + |
| 36 | +```typescript |
| 37 | +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; |
| 38 | +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; |
| 39 | + |
| 40 | +const client = new Client({ name: 'my-client', version: '1.0.0' }); |
| 41 | +const transport = new StdioClientTransport({ command: 'node', args: ['server.js'] }); |
| 42 | +await client.connect(transport); // ← initialize happens here |
| 43 | +// Then: client.request({ method: 'tools/list' }, schema) |
| 44 | +``` |
| 45 | + |
| 46 | +**Key point:** The SDK `Client.connect(transport)` handles the full MCP lifecycle: |
| 47 | +1. Spawns process (for stdio) or opens connection (for HTTP/SSE) |
| 48 | +2. Sends `initialize` request |
| 49 | +3. Waits for `initialize` response |
| 50 | +4. Sends `notifications/initialized` |
| 51 | +5. Client is ready for `tools/list`, `tools/call`, etc. |
| 52 | + |
| 53 | +## Connection Pattern |
| 54 | + |
| 55 | +**Per-request connection:** |
| 56 | +- Each `/mcp` API call creates a new `Client` + `Transport` |
| 57 | +- Connects, makes request(s), then closes |
| 58 | +- No connection pooling or reuse |
| 59 | + |
| 60 | +**Why:** Simpler error handling; each request is isolated. Trade-off: slower (reconnect cost per request). |
| 61 | + |
| 62 | +## OAuth2.1 Flow |
| 63 | + |
| 64 | +1. **First connect attempt** → `UnauthorizedError` thrown |
| 65 | +2. **Extract auth URL** from `OAuthClientProvider.redirectToAuthorization()` |
| 66 | +3. **Return 401 with `authUrl`** to frontend |
| 67 | +4. **User completes OAuth** in browser → redirects to `/oauth/callback` |
| 68 | +5. **Backend calls `transport.finishAuth(code)`** |
| 69 | +6. **Reconnect** with authenticated transport |
| 70 | +7. **Save tokens** to `auth-tokens.json` for future use |
| 71 | + |
| 72 | +**Token reuse:** On subsequent connects, if tokens exist, they're loaded into the OAuth provider before connecting. |
| 73 | + |
| 74 | +## API Endpoints |
| 75 | + |
| 76 | +### `POST /add-server` |
| 77 | +- Adds server config to `mcp-servers.json` |
| 78 | +- Body: `{ id, command, type, args }` |
| 79 | +- Persists immediately |
| 80 | + |
| 81 | +### `GET /servers` |
| 82 | +- Lists all servers from `mcp-servers.json` |
| 83 | +- Returns array of `{ name, command, type, args }` |
| 84 | + |
| 85 | +### `POST /mcp` |
| 86 | +- **Body:** `{ server: string, command: string, args?: any[] }` |
| 87 | +- **Command format:** `"list"` or `"call <toolName> [json-args]"` |
| 88 | +- **Flow:** |
| 89 | + 1. Load server config |
| 90 | + 2. `autoDetectTransport()` → create Client + Transport |
| 91 | + 3. `client.connect()` → initialize handshake |
| 92 | + 4. Parse command: |
| 93 | + - `"list"` → `client.request({ method: 'tools/list' }, ListToolsResultSchema)` |
| 94 | + - `"call <name> [args]"` → `client.request({ method: 'tools/call', params: { name, arguments } }, CallToolResultSchema)` |
| 95 | + 5. Return result as JSON |
| 96 | + 6. Close transport |
| 97 | + |
| 98 | +### `POST /oauth/callback` |
| 99 | +- Receives OAuth code from frontend |
| 100 | +- Calls `transport.finishAuth(code)` |
| 101 | +- Reconnects with authenticated transport |
| 102 | +- Saves tokens |
| 103 | + |
| 104 | +## Key Patterns |
| 105 | + |
| 106 | +### 1. Transport Auto-Detection |
| 107 | + |
| 108 | +```typescript |
| 109 | +async function autoDetectTransport(serverConfig) { |
| 110 | + const client = new Client({ name: serverConfig.name, version: '1.0.0' }); |
| 111 | + const oauthProvider = new InMemoryOAuthProvider(); |
| 112 | + |
| 113 | + if (type === 'stdio') { |
| 114 | + const transport = new StdioClientTransport({ command, args }); |
| 115 | + await client.connect(transport); |
| 116 | + } else if (type === 'httpstream' || type === 'sse') { |
| 117 | + const TransportClass = type === 'httpstream' |
| 118 | + ? StreamableHTTPClientTransport |
| 119 | + : SSEClientTransport; |
| 120 | + const transport = new TransportClass(new URL(command), { authProvider: oauthProvider }); |
| 121 | + await client.connect(transport); |
| 122 | + } |
| 123 | + return { client, transport }; |
| 124 | +} |
| 125 | +``` |
| 126 | + |
| 127 | +**Pattern:** Factory that creates the right transport based on `type`, handles OAuth for HTTP transports. |
| 128 | + |
| 129 | +### 2. Request Schema Validation |
| 130 | + |
| 131 | +Uses Zod schemas from SDK: |
| 132 | +- `ListToolsResultSchema` for `tools/list` |
| 133 | +- `CallToolResultSchema` for `tools/call` |
| 134 | +- `LoggingMessageNotificationSchema` for notifications |
| 135 | + |
| 136 | +**Pattern:** Type-safe request/response validation via SDK schemas. |
| 137 | + |
| 138 | +### 3. Error Handling |
| 139 | + |
| 140 | +- **UnauthorizedError** → Return 401 with `authUrl` for OAuth flow |
| 141 | +- **Other errors** → Return 500 with error details |
| 142 | +- **Connection errors** → Caught and returned as JSON |
| 143 | + |
| 144 | +**Pattern:** HTTP status codes map to MCP/auth errors; frontend handles 401 specially. |
| 145 | + |
| 146 | +### 4. Notification Handling |
| 147 | + |
| 148 | +```typescript |
| 149 | +client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => { |
| 150 | + // Log notification (template doesn't do much here) |
| 151 | +}); |
| 152 | +``` |
| 153 | + |
| 154 | +**Pattern:** Register handlers for server notifications (e.g. `logging/message`); template logs them. |
| 155 | + |
| 156 | +## Comparison with Our LSP Client |
| 157 | + |
| 158 | +| Aspect | MCP Template | Our LSP Client | |
| 159 | +|--------|-------------|----------------| |
| 160 | +| **SDK** | Uses `@modelcontextprotocol/sdk` Client | Custom Content-Length transport | |
| 161 | +| **Framing** | Newline-delimited (SDK default) | Content-Length (LSP-style) | |
| 162 | +| **Lifecycle** | `client.connect()` handles initialize | Manual: `initialize` → `initialized` → requests | |
| 163 | +| **Connection** | Per-request (new client each time) | Shared client (reused across requests) | |
| 164 | +| **Protocol** | MCP (`tools/list`, `tools/call`) | LSP (`textDocument/documentSymbol`) | |
| 165 | +| **Transport** | stdio, HTTP, SSE | stdio only (Content-Length) | |
| 166 | +| **OAuth** | Full OAuth2.1 support | None (LSP doesn't use OAuth) | |
| 167 | + |
| 168 | +## Takeaways for Our Codebase |
| 169 | + |
| 170 | +1. **SDK vs Custom:** We can't use SDK `StdioClientTransport` because sysml-v2-lsp uses Content-Length, not newline-delimited. Our custom transport is correct. |
| 171 | + |
| 172 | +2. **Connection Reuse:** Template creates per-request connections; we reuse a shared client. Our approach is better for performance (no reconnect cost), but we need to handle errors/restarts. |
| 173 | + |
| 174 | +3. **Lifecycle:** Template relies on SDK's `connect()` to do initialize; we do it manually. Both are valid—SDK abstracts it, we have explicit control. |
| 175 | + |
| 176 | +4. **Error Handling:** Template maps MCP errors to HTTP status codes; we throw/return errors directly. Our approach fits our use case (CLI/scripts, not REST API). |
| 177 | + |
| 178 | +5. **OAuth:** Template has full OAuth flow; we don't need it for LSP (LSP doesn't use OAuth). |
| 179 | + |
| 180 | +## What We Should Adopt |
| 181 | + |
| 182 | +1. **Notification handlers:** If the LSP server sends notifications (e.g. `window/logMessage`), we should register handlers instead of ignoring them. |
| 183 | + |
| 184 | +2. **Request timeout:** Template doesn't show explicit timeouts, but we added 30s timeout—good practice. |
| 185 | + |
| 186 | +3. **Error mapping:** Template maps `UnauthorizedError` to 401; we could map LSP errors to more specific error types. |
| 187 | + |
| 188 | +4. **Connection retry:** Template doesn't retry; we could add retry logic for transient failures. |
| 189 | + |
| 190 | +## What We Shouldn't Adopt |
| 191 | + |
| 192 | +1. **Per-request connections:** Too slow for indexing (many files). Our shared client is better. |
| 193 | + |
| 194 | +2. **REST API wrapper:** We're a CLI/library, not a web service. Direct function calls are appropriate. |
| 195 | + |
| 196 | +3. **OAuth:** Not needed for LSP protocol. |
0 commit comments