-
Notifications
You must be signed in to change notification settings - Fork 47
Expand file tree
/
Copy pathhttp-server.mts
More file actions
364 lines (341 loc) · 12.5 KB
/
Copy pathhttp-server.mts
File metadata and controls
364 lines (341 loc) · 12.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
// HTTP transport module: request routing, the post-body size guard, and the
// per-request auth handoff — the cohesive request path that reads
// top-to-bottom. Origin/CORS/Accept-header validation lives in http-origin.mts.
import { toNodeHandler } from '@modelcontextprotocol/node'
import type { NodeMcpRequestHandler } from '@modelcontextprotocol/node'
import { createMcpHandler } from '@modelcontextprotocol/server'
import { errorMessage } from '@socketsecurity/lib/errors/message'
import { createServer } from 'node:http'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createConfiguredServer } from './server.mts'
import { getRequestBaseUrl, getRequestHeaderValue, writeJson } from './http.mts'
import {
patchAcceptHeader,
validateOriginAndHost,
writeCorsHeaders,
} from './http-origin.mts'
import { logger } from './logger.mts'
import {
authenticateRequest,
buildProtectedResourceMetadata,
isOauthEnabled,
loadOAuthMetadata,
OAUTH_PROTECTED_RESOURCE_METADATA_PATH,
} from './oauth.mts'
import type { AuthenticatedRequest } from './oauth.mts'
import { VERSION } from './version.mts'
// Cap the buffered request body. readPostBody accumulates the whole body
// in memory before JSON.parse, so an unbounded body is a single-request
// heap-exhaustion DoS — reachable on the HTTP transport before auth. 4 MB
// is far above any legitimate MCP JSON-RPC frame.
const MAX_POST_BODY_BYTES = 4 * 1024 * 1024
// Thrown by readPostBody when the body exceeds MAX_POST_BODY_BYTES, so the
// caller can answer 413 instead of a generic 500.
export class PayloadTooLargeError extends Error {
constructor(limitBytes: number) {
super(`Request body exceeds the ${limitBytes}-byte limit`)
this.name = 'PayloadTooLargeError'
}
}
// Non-OAuth HTTP mode: forward a client-supplied Socket API key (sent as
// `Authorization: Bearer <token>`) to the tool layer via `req.auth`, so
// per-tenant tools act on the caller's behalf instead of the deploy's static
// key. A missing or malformed header leaves `req.auth` unset, which makes
// per-tenant tools return AUTH_REQUIRED while public tools (depscore) still
// fall back to the static key.
export function applyClientApiKey(req: AuthenticatedRequest): void {
const authHeader = getRequestHeaderValue(req.headers.authorization).trim()
if (!authHeader) {
return
}
// `authHeader` is trimmed and non-empty, so splitting on whitespace always
// yields a non-empty first element.
const { 0: type, 1: token } = authHeader.split(/\s+/u)
if (type!.toLowerCase() !== 'bearer' || !token) {
return
}
req.auth = { token, clientId: 'socket-api-key', scopes: [] }
}
// Build one request's last-resort rejection handler. A `routeRequest`
// rejection would otherwise be unhandled, which hangs the client and ends the
// process under Node's default rejection policy — one request must never take
// the server down. Once headers are out the status is already committed, so
// the only move left is closing the response.
export function createRouteFailureHandler(
res: ServerResponse,
): (error: unknown) => void {
return error => {
logger.error(`Unhandled request failure: ${errorMessage(error)}`)
if (!res.headersSent) {
writeJson(res, 500, {
jsonrpc: '2.0',
error: { code: -32_603, message: 'Internal server error' },
id: undefined,
})
return
}
res.end()
}
}
// The Node adapter's `onerror` sink. Reached when the adapter itself fails
// around an exchange — a mid-response socket abort, a transport-level write
// error — rather than inside a tool.
export function handleMcpAdapterError(error: unknown): void {
logger.error(`MCP adapter failed: ${errorMessage(error)}`)
}
// The MCP handler's `onerror` sink. Reached when serving one exchange throws
// past the per-request handling in `handleMcpRequest`.
export function handleMcpHandlerError(error: unknown): void {
logger.error(`MCP request failed: ${errorMessage(error)}`)
}
// Hand one request to the MCP handler. The adapter reads the request stream
// itself and enforces no size limit of its own, so a body-bearing method is
// buffered here under MAX_POST_BODY_BYTES and handed over pre-parsed — with a
// parsed body the adapter reads nothing from `req`. Per-request auth rides on
// `req.auth`, which the adapter forwards to handlers as `ctx.http.authInfo`.
export async function handleMcpRequest(
mcpHandler: NodeMcpRequestHandler,
req: IncomingMessage,
res: ServerResponse,
): Promise<void> {
let parsedBody: unknown
if (req.method !== 'GET' && req.method !== 'HEAD') {
let body: string
try {
body = await readPostBody(req)
} catch (error) {
if (error instanceof PayloadTooLargeError) {
logger.error(error.message)
if (!res.headersSent) {
// The client is still uploading, so the rest of the body would
// arrive with nobody reading it. Announce the close, write the 413,
// and only then drop the request stream — destroying it any earlier
// kills the socket before the status reaches the client.
writeJson(
res,
413,
{
jsonrpc: '2.0',
error: { code: -32_600, message: 'Request body too large' },
id: undefined,
},
{ Connection: 'close' },
)
res.on('finish', () => {
req.destroy()
})
}
return
}
logger.error(`Error reading request body: ${errorMessage(error)}`)
if (!res.headersSent) {
writeJson(res, 500, {
jsonrpc: '2.0',
error: { code: -32_603, message: 'Internal server error' },
id: undefined,
})
}
return
}
if (body) {
try {
parsedBody = JSON.parse(body)
} catch (error) {
logger.error(`Malformed JSON in request body: ${errorMessage(error)}`)
writeJson(res, 400, {
jsonrpc: '2.0',
error: { code: -32_700, message: 'Parse error' },
id: undefined,
})
return
}
}
}
// The adapter's request shape types `method` and `url` as plain optional
// strings while @types/node types them `string | undefined`, which
// `exactOptionalPropertyTypes` refuses. Node always sets both on a server
// request, so re-state them rather than widen the adapter's contract.
const mcpReq = Object.assign(req, {
method: req.method ?? 'GET',
url: req.url ?? '/',
})
try {
await mcpHandler(mcpReq, res, parsedBody)
} catch (error) {
logger.error(
`Error processing ${req.method} request: ${errorMessage(error)}`,
)
if (!res.headersSent) {
writeJson(res, 500, {
jsonrpc: '2.0',
error: { code: -32_603, message: 'Internal server error' },
id: undefined,
})
}
}
}
// Read and buffer the POST body to a string, capped at MAX_POST_BODY_BYTES.
// Async iteration is modern stream consumption — equivalent to 'data' +
// 'end' without the callback wiring. The running byte count is measured on
// the raw chunks (Buffer.byteLength for the string case) so multibyte
// payloads can't slip past a char-length check; exceeding the cap stops the
// read and throws PayloadTooLargeError before more memory is committed.
//
// `destroyOnReturn: false` keeps the socket alive when the loop breaks early.
// Node's default async iterator destroys the stream on `break`, which tears
// down the connection before the caller can write its 413 — the client would
// see a socket error instead of the status. The caller owns the teardown and
// destroys the request once the response has flushed.
export async function readPostBody(req: IncomingMessage): Promise<string> {
let body = ''
let bytes = 0
let overLimit = false
for await (const chunk of req.iterator({ destroyOnReturn: false })) {
bytes += typeof chunk === 'string' ? Buffer.byteLength(chunk) : chunk.length
if (bytes > MAX_POST_BODY_BYTES) {
overLimit = true
break
}
body += typeof chunk === 'string' ? chunk : chunk.toString()
}
if (overLimit) {
throw new PayloadTooLargeError(MAX_POST_BODY_BYTES)
}
return body
}
// Routes a single request: health endpoint, origin validation, OAuth
// metadata exposure, then dispatch to the MCP handler.
export async function routeRequest(
mcpHandler: NodeMcpRequestHandler,
req: IncomingMessage,
res: ServerResponse,
port: number,
): Promise<void> {
let url: URL
try {
url = new url(/sitelet?url=https%3A%2F%2Fgithub.com%2FSocketDev%2Fsocket-mcp%2Fblob%2Fmain%2Flib%2Freq.url%21%2C%2520%2560http%3A%2F%2Flocalhost%3A%24%257Bport%257D%2560)
} catch (error) {
logger.warn(`Invalid URL in request: ${req.url} - ${errorMessage(error)}`)
writeJson(res, 400, {
jsonrpc: '2.0',
error: { code: -32_000, message: 'Bad Request: Invalid URL' },
id: undefined,
})
return
}
// Health endpoint bypasses origin validation so K8s / Docker probes
// succeed without configuring origins.
if (url.pathname === '/health') {
writeJson(res, 200, {
status: 'healthy',
service: 'socket-mcp',
version: VERSION,
timestamp: new Date().toISOString(),
})
return
}
const origin = getRequestHeaderValue(req.headers.origin).trim()
const host = getRequestHeaderValue(req.headers.host).trim()
if (!validateOriginAndHost(origin, host, port)) {
logger.warn(
`Rejected request from invalid origin: ${origin || 'missing'} (host: ${host})`,
)
writeJson(res, 403, {
jsonrpc: '2.0',
error: { code: -32_000, message: 'Forbidden: Invalid origin' },
id: undefined,
})
return
}
writeCorsHeaders(res, origin)
if (req.method === 'OPTIONS') {
res.writeHead(200)
res.end()
return
}
const baseUrl = getRequestBaseurl(/sitelet?url=https%3A%2F%2Fgithub.com%2FSocketDev%2Fsocket-mcp%2Fblob%2Fmain%2Flib%2Freq%2C%2520port)
if (
isOauthEnabled() &&
url.pathname === OAUTH_PROTECTED_RESOURCE_METADATA_PATH
) {
// Discovery rejects when the issuer is unreachable or serves no usable
// metadata. Answering from the catch keeps a down issuer from becoming an
// unhandled rejection, which would hang the request and take the process
// with it.
try {
await loadOAuthMetadata()
} catch (error) {
logger.error(`OAuth metadata discovery failed: ${errorMessage(error)}`)
writeJson(res, 500, {
error: 'server_error',
error_description: 'OAuth metadata is unavailable',
})
return
}
writeJson(res, 200, buildProtectedResourceMetadata(baseUrl))
return
}
if (url.pathname !== '/') {
res.writeHead(404)
res.end('Not found')
return
}
patchAcceptHeader(req)
// On an OAuth-enabled deployment, OAuth is the only way in: every Bearer
// token goes through introspection, expiry, audience, and scope checks. A
// token prefix is not authentication, so a raw Socket API key is accepted
// only when OAuth is off.
if (isOauthEnabled()) {
const authResult = await authenticateRequest(req, res, baseUrl)
if (!authResult.ok) {
return
}
} else {
applyClientApiKey(req)
}
// GET and DELETE reach the MCP handler too: it answers the 2025-era session
// operations with its own 405 rather than a hand-rolled session table.
if (
req.method === 'DELETE' ||
req.method === 'GET' ||
req.method === 'POST'
) {
await handleMcpRequest(mcpHandler, req, res)
} else {
res.writeHead(405)
res.end('Method not allowed')
}
}
// Boot the HTTP MCP server: build the MCP handler, create the Node HTTP
// server, route requests through `routeRequest`, listen, and log the start
// banner.
export function startHttpServer(port: number): void {
logger.info(`Starting HTTP server on port ${port}`)
// One handler for the process. It calls `createConfiguredServer` per
// exchange and closes the instance afterwards, so the factory — not a
// shared instance — is what gets passed in. Leaving `legacy` unset keeps
// its default stateless serving, so 2025-era clients are served alongside
// 2026-era ones.
const mcpHandler = toNodeHandler(
createMcpHandler(createConfiguredServer, {
onerror: handleMcpHandlerError,
}),
{ onerror: handleMcpAdapterError },
)
let listeningPort = port
const httpServer = createServer((req, res) => {
routeRequest(mcpHandler, req, res, listeningPort).catch(
createRouteFailureHandler(res),
)
})
httpServer.listen(port, () => {
const address = httpServer.address()
if (typeof address === 'object' && address !== null) {
listeningPort = address.port
}
logger.info(
`Socket MCP HTTP server version ${VERSION} started successfully on port ${listeningPort}`,
)
logger.info(`Connect to: http://localhost:${listeningPort}/`)
})
}