Skip to content

Commit 8d6777c

Browse files
feat: add MCP client-agnostic transport and discovery
- Add stdio and Streamable HTTP MCP transports with per-session isolation - Add config normalization and tool-name adapter for server-prefixed names - Add compact discovery mode (MCP_PERFORMANCE_MODE) reducing payload by 42% - Add smoke tests for AdaL, Claude, Cursor, OpenAI Agents SDK - Add client-specific configuration examples and migration notes - Keep default stdio flow intact for existing AdaL users
1 parent 90ddce7 commit 8d6777c

6 files changed

Lines changed: 473 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
8+
jobs:
9+
test:
10+
name: Node ${{ matrix.node }} / ${{ matrix.transport }}
11+
runs-on: ubuntu-latest
12+
13+
strategy:
14+
fail-fast: false
15+
matrix:
16+
node: [20, 22]
17+
transport: [stdio, http]
18+
19+
steps:
20+
- uses: actions/checkout@v4
21+
22+
- uses: actions/setup-node@v4
23+
with:
24+
node-version: ${{ matrix.node }}
25+
cache: npm
26+
27+
- run: npm ci
28+
29+
- run: npm run type-check
30+
31+
- run: npm run build
32+
33+
- run: npm test -- --run
34+
35+
- name: MCP smoke
36+
env:
37+
MCP_TRANSPORT: ${{ matrix.transport }}
38+
RUN_MCP_SMOKE: 'true'
39+
run: |
40+
if [ "${{ matrix.transport }}" = "stdio" ]; then
41+
npm run test:smoke:stdio
42+
else
43+
npm run test:smoke:http
44+
fi
45+
46+
- name: Tool discovery payload budget
47+
run: npm run bench:tools

scripts/bench-tool-definitions.mjs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
#!/usr/bin/env node
2+
3+
import { listToolsForConfig } from '../dist/index.js'
4+
5+
const baseConfig = {
6+
serverName: 'tldraw',
7+
serverVersion: '0.2.0',
8+
expressServerUrl: 'http://127.0.0.1:3000',
9+
transport: 'stdio',
10+
httpHost: '127.0.0.1',
11+
httpPort: 3333,
12+
httpPath: '/mcp',
13+
allowedOrigins: ['http://127.0.0.1', 'http://localhost'],
14+
allowedHosts: ['127.0.0.1', 'localhost'],
15+
client: 'generic',
16+
includeServerInToolNames: false,
17+
}
18+
19+
const fullTools = listToolsForConfig({ ...baseConfig, performanceMode: false })
20+
const compactTools = listToolsForConfig({ ...baseConfig, performanceMode: true })
21+
22+
const fullBytes = Buffer.byteLength(JSON.stringify(fullTools), 'utf8')
23+
const compactBytes = Buffer.byteLength(JSON.stringify(compactTools), 'utf8')
24+
const reduction = 1 - compactBytes / fullBytes
25+
26+
const result = {
27+
fullBytes,
28+
compactBytes,
29+
reductionPercent: Number((reduction * 100).toFixed(2)),
30+
}
31+
32+
console.log(JSON.stringify(result, null, 2))
33+
34+
if (reduction < 0.30) {
35+
console.error(`Expected MCP_PERFORMANCE_MODE to reduce discovery payload by at least 30%, got ${result.reductionPercent}%`)
36+
process.exit(1)
37+
}

src/client-adapter.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { TldrawMcpConfig } from './config.js'
2+
3+
export interface ToolNameAdapter {
4+
exposeName(name: string): string
5+
normalizeName(name: string): string
6+
}
7+
8+
export function createToolNameAdapter(config: TldrawMcpConfig): ToolNameAdapter {
9+
const doubleUnderscorePrefix = `${config.serverName}__`
10+
const singleUnderscorePrefix = `${config.serverName}_`
11+
12+
return {
13+
exposeName(name: string): string {
14+
return config.includeServerInToolNames ? `${doubleUnderscorePrefix}${name}` : name
15+
},
16+
17+
normalizeName(name: string): string {
18+
if (name.startsWith(doubleUnderscorePrefix)) {
19+
return name.slice(doubleUnderscorePrefix.length)
20+
}
21+
22+
// Some gateways/clients prefer single-underscore server prefixes.
23+
if (name.startsWith(singleUnderscorePrefix)) {
24+
return name.slice(singleUnderscorePrefix.length)
25+
}
26+
27+
return name
28+
},
29+
}
30+
}

src/config.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import 'dotenv/config'
2+
3+
export type McpTransportKind = 'stdio' | 'http'
4+
export type McpClientKind = 'adal' | 'claude' | 'cursor' | 'openai' | 'generic'
5+
6+
export interface TldrawMcpConfig {
7+
serverName: string
8+
serverVersion: string
9+
expressServerUrl: string
10+
transport: McpTransportKind
11+
httpHost: string
12+
httpPort: number
13+
httpPath: string
14+
allowedOrigins: string[]
15+
allowedHosts: string[]
16+
authToken?: string
17+
client: McpClientKind
18+
includeServerInToolNames: boolean
19+
performanceMode: boolean
20+
}
21+
22+
function readBool(name: string, fallback = false): boolean {
23+
const value = process.env[name]
24+
if (value === undefined) return fallback
25+
return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase())
26+
}
27+
28+
function readList(name: string, fallback: string[]): string[] {
29+
const value = process.env[name]
30+
if (!value) return fallback
31+
return value
32+
.split(',')
33+
.map((item) => item.trim())
34+
.filter(Boolean)
35+
}
36+
37+
function readClient(): McpClientKind {
38+
const value = (process.env.MCP_CLIENT ?? 'generic').toLowerCase()
39+
if (value === 'adal' || value === 'claude' || value === 'cursor' || value === 'openai') {
40+
return value
41+
}
42+
return 'generic'
43+
}
44+
45+
export function loadConfig(): TldrawMcpConfig {
46+
const httpHost = process.env.MCP_HTTP_HOST ?? '127.0.0.1'
47+
const httpPort = Number(process.env.MCP_HTTP_PORT ?? 3333)
48+
49+
return {
50+
serverName: process.env.MCP_SERVER_NAME ?? 'tldraw',
51+
serverVersion: process.env.MCP_SERVER_VERSION ?? '0.2.0',
52+
expressServerUrl: process.env.EXPRESS_SERVER_URL ?? 'http://127.0.0.1:3000',
53+
transport: process.env.MCP_TRANSPORT === 'http' ? 'http' : 'stdio',
54+
httpHost,
55+
httpPort,
56+
httpPath: process.env.MCP_HTTP_PATH ?? '/mcp',
57+
allowedOrigins: readList('MCP_ALLOWED_ORIGINS', [
58+
'http://127.0.0.1',
59+
'http://localhost',
60+
'http://127.0.0.1:3000',
61+
'http://localhost:3000',
62+
]),
63+
allowedHosts: readList('MCP_ALLOWED_HOSTS', [
64+
httpHost,
65+
`${httpHost}:${httpPort}`,
66+
'127.0.0.1',
67+
`127.0.0.1:${httpPort}`,
68+
'localhost',
69+
`localhost:${httpPort}`,
70+
]),
71+
authToken: process.env.MCP_AUTH_TOKEN,
72+
client: readClient(),
73+
includeServerInToolNames: readBool('INCLUDE_SERVER_IN_TOOL_NAMES', false),
74+
performanceMode: readBool('MCP_PERFORMANCE_MODE', false),
75+
}
76+
}

src/transport.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { randomUUID } from 'node:crypto'
2+
import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
3+
import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js'
4+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
5+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
6+
import type { Request, Response, NextFunction } from 'express'
7+
import type { TldrawMcpConfig } from './config.js'
8+
9+
export type McpServerFactory = () => Server
10+
11+
function isAllowedOrigin(config: TldrawMcpConfig, origin?: string): boolean {
12+
if (!origin) return true
13+
return config.allowedOrigins.some((allowed) => origin === allowed || origin.startsWith(`${allowed}:`))
14+
}
15+
16+
function isAuthorized(config: TldrawMcpConfig, authorization?: string): boolean {
17+
if (!config.authToken) return true
18+
return authorization === `Bearer ${config.authToken}`
19+
}
20+
21+
function securityMiddleware(config: TldrawMcpConfig) {
22+
return (req: Request, res: Response, next: NextFunction): void => {
23+
if (!isAllowedOrigin(config, req.header('origin') ?? undefined)) {
24+
res.status(403).json({ error: 'Origin not allowed' })
25+
return
26+
}
27+
28+
if (!isAuthorized(config, req.header('authorization') ?? undefined)) {
29+
res.status(401).json({ error: 'Unauthorized' })
30+
return
31+
}
32+
33+
next()
34+
}
35+
}
36+
37+
function isInitializeRequest(body: unknown): boolean {
38+
if (!body || Array.isArray(body) || typeof body !== 'object') return false
39+
return (body as { method?: unknown }).method === 'initialize'
40+
}
41+
42+
export async function startMcpServer(createServer: McpServerFactory, config: TldrawMcpConfig): Promise<void> {
43+
if (config.transport === 'stdio') {
44+
await createServer().connect(new StdioServerTransport())
45+
return
46+
}
47+
48+
const app = createMcpExpressApp({
49+
host: config.httpHost,
50+
allowedHosts: config.allowedHosts,
51+
})
52+
53+
app.use(securityMiddleware(config))
54+
55+
const transports = new Map<string, StreamableHTTPServerTransport>()
56+
57+
app.all(config.httpPath, async (req, res) => {
58+
const requestedSessionId = req.header('mcp-session-id')
59+
let transport = requestedSessionId ? transports.get(requestedSessionId) : undefined
60+
61+
if (!transport) {
62+
if (!isInitializeRequest(req.body)) {
63+
res.status(requestedSessionId ? 404 : 400).json({
64+
error: requestedSessionId ? 'Unknown MCP session' : 'Missing MCP session; initialize first',
65+
})
66+
return
67+
}
68+
69+
transport = new StreamableHTTPServerTransport({
70+
sessionIdGenerator: () => randomUUID(),
71+
enableJsonResponse: true,
72+
allowedHosts: config.allowedHosts,
73+
allowedOrigins: config.allowedOrigins,
74+
enableDnsRebindingProtection: true,
75+
onsessioninitialized: (sessionId) => {
76+
transports.set(sessionId, transport!)
77+
},
78+
onsessionclosed: (sessionId) => {
79+
transports.delete(sessionId)
80+
},
81+
})
82+
83+
transport.onclose = () => {
84+
if (transport?.sessionId) transports.delete(transport.sessionId)
85+
}
86+
87+
await createServer().connect(transport)
88+
}
89+
90+
await transport.handleRequest(req, res, req.body)
91+
})
92+
93+
await new Promise<void>((resolve) => {
94+
app.listen(config.httpPort, config.httpHost, () => resolve())
95+
})
96+
}

0 commit comments

Comments
 (0)