Skip to content

Commit 32515c5

Browse files
committed
test: add global setup and simplify web server
1 parent aa44279 commit 32515c5

File tree

7 files changed

+76
-75
lines changed

7 files changed

+76
-75
lines changed

src/services/web.ts

Lines changed: 19 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,41 @@
1-
import { nanoid } from 'nanoid'
21
import express from 'express'
3-
import cors from 'cors'
42
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5-
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'
63
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
7-
import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js'
84
import type { OptionsType } from '@/types'
95

106
export async function webServer(server: McpServer, options: OptionsType) {
117
const app = express()
12-
app.use(cors())
138
app.use(express.json())
149

15-
const transports = {
16-
streamable: {} as Record<string, StreamableHTTPServerTransport>,
17-
sse: {} as Record<string, SSEServerTransport>,
18-
}
1910
app.post('/mcp', async (req, res) => {
20-
const sessionId = req.headers['mcp-session-id'] as string | undefined
21-
let transport: StreamableHTTPServerTransport
22-
23-
if (sessionId && transports.streamable[sessionId]) {
24-
transport = transports.streamable[sessionId]
25-
} else if (!sessionId && isInitializeRequest(req.body)) {
26-
transport = new StreamableHTTPServerTransport({
27-
sessionIdGenerator: () => nanoid(),
28-
onsessioninitialized: sessionId => {
29-
transports.streamable[sessionId] = transport
30-
},
31-
})
11+
const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
12+
sessionIdGenerator: undefined,
13+
})
14+
res.on('close', () => {
15+
transport.close()
16+
server.close()
17+
})
18+
await server.connect(transport)
19+
await transport.handleRequest(req, res, req.body)
20+
})
3221

33-
transport.onclose = () => {
34-
if (transport.sessionId) {
35-
delete transports.streamable[transport.sessionId]
36-
}
37-
}
38-
await server.connect(transport)
39-
} else {
40-
res.status(400).json({
22+
const handleRequest = async (req: express.Request, res: express.Response) => {
23+
res.writeHead(405).end(
24+
JSON.stringify({
4125
jsonrpc: '2.0',
4226
error: {
4327
code: -32000,
44-
message: 'Bad Request: No valid session ID provided',
28+
message: 'Method not allowed.',
4529
},
4630
id: null,
47-
})
48-
return
49-
}
50-
51-
await transport.handleRequest(req, res, req.body)
52-
})
53-
54-
const handleSessionRequest = async (req: express.Request, res: express.Response) => {
55-
const sessionId = req.headers['mcp-session-id'] as string | undefined
56-
if (!sessionId || !transports.streamable[sessionId]) {
57-
res.status(400).send('Invalid or missing session ID')
58-
return
59-
}
60-
61-
const transport = transports.streamable[sessionId]
62-
await transport.handleRequest(req, res)
31+
}),
32+
)
6333
}
6434

65-
app.get('/mcp', handleSessionRequest)
66-
67-
app.delete('/mcp', handleSessionRequest)
35+
app.get('/mcp', handleRequest)
6836

69-
app.get('/sse', async (req, res) => {
70-
const transport = new SSEServerTransport('/messages', res)
71-
transports.sse[transport.sessionId] = transport
72-
73-
res.on('close', () => {
74-
delete transports.sse[transport.sessionId]
75-
})
76-
77-
await server.connect(transport)
78-
})
79-
80-
app.post('/messages', async (req, res) => {
81-
const sessionId = req.query.sessionId as string
82-
const transport = transports.sse[sessionId]
83-
if (transport) {
84-
await transport.handlePostMessage(req, res, req.body)
85-
} else {
86-
res.status(400).send('No transport found for sessionId')
87-
}
88-
})
37+
app.delete('/mcp', handleRequest)
8938

9039
app.listen(options.port)
91-
console.log(`MCP server started on port ${options.port}. SSE endpoint: /sse, streamable endpoint: /mcp`)
40+
console.log(`MCP server started on port ${options.port}, streamable endpoint: /mcp`)
9241
}

tests/tools/watermarkJsPlus.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ describe('watermarkJsPlusDocTool', () => {
1010
})) as { content: unknown[] }
1111

1212
expect(res.content.length).toBeGreaterThan(0)
13-
}, 20000)
13+
})
1414

1515
test('returns a "not found" response for an unrecognized input', async () => {
1616
const res = (await global.client.callTool({
@@ -20,5 +20,5 @@ describe('watermarkJsPlusDocTool', () => {
2020
},
2121
})) as { content: unknown[] }
2222
expect(res.content.length).toEqual(0)
23-
}, 20000)
23+
})
2424
})

tests/utils.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export function waitForValue<T>(
2+
getterFn: () => T | undefined | null,
3+
checkInterval = 100,
4+
timeout = 10000,
5+
): Promise<T> {
6+
return new Promise((resolve, reject) => {
7+
const start = Date.now()
8+
9+
const intervalId = setInterval(() => {
10+
const value = getterFn()
11+
if (value) {
12+
clearInterval(intervalId)
13+
resolve(value)
14+
} else if (Date.now() - start > timeout) {
15+
clearInterval(intervalId)
16+
reject(new Error('Timeout waiting for value'))
17+
}
18+
}, checkInterval)
19+
})
20+
}

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,6 @@
1212
"@/*": ["./src/*"]
1313
}
1414
},
15-
"include": ["src", "tests", "vitest.setup.ts", "vitest.config.ts"],
15+
"include": ["src", "tests", "vitest.setup.ts", "vitest.config.ts", "vitest.global.ts"],
1616
"exclude": ["node_modules"]
1717
}

vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export default defineConfig({
66
'@': '/src',
77
},
88
setupFiles: ['./vitest.setup.ts'],
9+
globalSetup: ['./vitest.global.ts'],
910
coverage: {
1011
include: ['src/**/*.ts'],
1112
},

vitest.global.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { spawn } from 'child_process';
2+
import { waitForValue } from './tests/utils'
3+
4+
export default async function setup() {
5+
const webProcess = spawn('c8', ['--reporter=lcov', '--reporter=text', 'tsx', './src/index.ts', 'web'], {
6+
stdio: 'pipe',
7+
env: {
8+
...process.env,
9+
NODE_V8_COVERAGE: './coverage/tmp',
10+
},
11+
})
12+
let webStarted = false
13+
webProcess.stdout?.on('data', async (data) => {
14+
const output = data.toString()
15+
if (output.includes('MCP server started')) {
16+
webStarted = true
17+
}
18+
});
19+
await waitForValue(() => webStarted)
20+
return () => {
21+
webProcess.kill('SIGINT')
22+
}
23+
}
24+

vitest.setup.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,26 @@
11
import 'dotenv/config'
22
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
3+
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
34
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
45

5-
const serverParams = new StdioClientTransport({
6+
const stdioClient = new StdioClientTransport({
67
command: 'c8',
78
args: ['--reporter=lcov', '--reporter=text', 'tsx', './src/index.ts'],
89
env: {
910
...process.env,
1011
NODE_V8_COVERAGE: './coverage/tmp',
1112
},
1213
})
14+
15+
const baseUrl = new URL('http://localhost:8401/mcp')
16+
const streamableClient = new StreamableHTTPClientTransport(
17+
new URL(baseUrl)
18+
)
1319
const client = new Client({
1420
name: 'doc-mcp-client',
1521
version: '1.0.0',
1622
})
17-
await client.connect(serverParams)
23+
await client.connect(stdioClient)
24+
await client.connect(streamableClient)
1825

1926
global.client = client

0 commit comments

Comments
 (0)