Skip to content

Commit 8f21477

Browse files
authored
Merge pull request #165 from QuantGeekDev/feat/health-endpoint
feat: add health endpoint for SSE and HTTP Stream transports
2 parents 1d88ad0 + f165b99 commit 8f21477

File tree

6 files changed

+223
-4
lines changed

6 files changed

+223
-4
lines changed

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export type {
2727
AppToolVisibility,
2828
} from './apps/types.js';
2929

30-
export type { SSETransportConfig } from './transports/sse/types.js';
30+
export type { SSETransportConfig, HealthConfig } from './transports/sse/types.js';
3131
export type { HttpStreamTransportConfig } from './transports/http/types.js';
3232
export { HttpStreamTransport } from './transports/http/server.js';
3333

src/transports/http/server.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export class HttpStreamTransport extends AbstractTransport {
2222
private _enableJsonResponse: boolean = false;
2323
private _config: HttpStreamTransportConfig;
2424
private _oauthMetadata?: ProtectedResourceMetadata;
25+
private _healthPath: string | null;
2526

2627
private _transports: { [sessionId: string]: StreamableHTTPServerTransport } = {};
2728

@@ -33,6 +34,10 @@ export class HttpStreamTransport extends AbstractTransport {
3334
this._endpoint = config.endpoint || '/mcp';
3435
this._enableJsonResponse = config.responseMode === 'batch';
3536

37+
// Health endpoint: enabled by default at /health
38+
const healthEnabled = config.health?.enabled !== false;
39+
this._healthPath = healthEnabled ? (config.health?.path || '/health') : null;
40+
3641
// Initialize OAuth metadata if OAuth provider is configured
3742
this._oauthMetadata = initializeOAuthMetadata(this._config.auth, 'HTTP Stream');
3843

@@ -84,6 +89,12 @@ export class HttpStreamTransport extends AbstractTransport {
8489
return;
8590
}
8691

92+
if (req.method === 'GET' && this._healthPath && url.pathname === this._healthPath) {
93+
const body = JSON.stringify(this._config.health?.response ?? { ok: true });
94+
res.writeHead(200, { 'Content-Type': 'application/json' }).end(body);
95+
return;
96+
}
97+
8798
if (url.pathname === this._endpoint) {
8899
await this.handleMcpRequest(req, res);
89100
} else {

src/transports/http/types.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import {
55
RequestId,
66
} from '@modelcontextprotocol/sdk/types.js';
77
import { AuthConfig } from '../../auth/types.js';
8-
import { CORSConfig } from '../sse/types.js';
8+
import { CORSConfig, HealthConfig } from '../sse/types.js';
99

1010
export { JSONRPCRequest, JSONRPCResponse, JSONRPCMessage, RequestId };
1111

@@ -100,6 +100,12 @@ export interface HttpStreamTransportConfig {
100100
* CORS configuration
101101
*/
102102
cors?: CORSConfig;
103+
104+
/**
105+
* Health endpoint configuration.
106+
* Enabled by default at /health when using HTTP Stream transport.
107+
*/
108+
health?: HealthConfig;
103109
}
104110

105111
export const DEFAULT_SESSION_CONFIG: SessionConfig = {

src/transports/sse/server.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
33
import { SSEServerTransport as SDKSSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
44
import { Server as SDKServer } from "@modelcontextprotocol/sdk/server/index.js";
55
import { AbstractTransport } from "../base.js";
6-
import { DEFAULT_SSE_CONFIG, SSETransportConfig, SSETransportConfigInternal, DEFAULT_CORS_CONFIG, CORSConfig } from "./types.js";
6+
import { DEFAULT_SSE_CONFIG, SSETransportConfig, SSETransportConfigInternal, DEFAULT_CORS_CONFIG, CORSConfig, HealthConfig } from "./types.js";
77
import { logger } from "../../core/Logger.js";
88
import { setResponseHeaders } from "../../utils/headers.js";
99
import { ProtectedResourceMetadata } from "../../auth/metadata/protected-resource.js";
@@ -34,6 +34,7 @@ export class SSEServerTransport extends AbstractTransport {
3434
private _corsHeaders: Record<string, string>
3535
private _corsHeadersWithMaxAge: Record<string, string>
3636
private _serverFactory?: SDKServerFactory
37+
private _healthPath: string | null
3738

3839
constructor(config: SSETransportConfig = {}) {
3940
super()
@@ -42,6 +43,10 @@ export class SSEServerTransport extends AbstractTransport {
4243
...config
4344
}
4445

46+
// Health endpoint: enabled by default at /health
47+
const healthEnabled = config.health?.enabled !== false;
48+
this._healthPath = healthEnabled ? (config.health?.path || '/health') : null;
49+
4550
// Initialize OAuth metadata if OAuth provider is configured
4651
this._oauthMetadata = initializeOAuthMetadata(this._config.auth, 'SSE');
4752

@@ -149,6 +154,12 @@ export class SSEServerTransport extends AbstractTransport {
149154
return;
150155
}
151156

157+
if (req.method === "GET" && this._healthPath && url.pathname === this._healthPath) {
158+
const body = JSON.stringify(this._config.health?.response ?? { ok: true });
159+
res.writeHead(200, { "Content-Type": "application/json" }).end(body);
160+
return;
161+
}
162+
152163
if (req.method === "GET" && url.pathname === this._config.endpoint) {
153164
if (this._config.auth?.endpoints?.sse) {
154165
const isAuthenticated = await handleAuthentication(req, res, this._config.auth, "SSE connection")

src/transports/sse/types.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,28 @@
11
import { AuthConfig } from "../../auth/types.js";
22

3+
/**
4+
* Health endpoint configuration
5+
*/
6+
export interface HealthConfig {
7+
/**
8+
* Whether the health endpoint is enabled
9+
* @default true
10+
*/
11+
enabled?: boolean;
12+
13+
/**
14+
* Path for the health endpoint
15+
* @default "/health"
16+
*/
17+
path?: string;
18+
19+
/**
20+
* Custom response body. When set, this object is returned as JSON.
21+
* @default { ok: true }
22+
*/
23+
response?: Record<string, unknown>;
24+
}
25+
326
/**
427
* CORS configuration options for SSE transport
528
*/
@@ -91,6 +114,12 @@ export interface SSETransportConfig {
91114
*/
92115
auth?: AuthConfig;
93116

117+
/**
118+
* Health endpoint configuration.
119+
* Enabled by default at /health when using SSE transport.
120+
*/
121+
health?: HealthConfig;
122+
94123
/**
95124
* OAuth configuration for authorization callbacks
96125
* This enables OAuth endpoints like /.well-known/oauth-protected-resource
@@ -119,12 +148,13 @@ export interface SSETransportConfig {
119148
/**
120149
* Internal configuration type with required fields except headers
121150
*/
122-
export type SSETransportConfigInternal = Required<Omit<SSETransportConfig, 'headers' | 'auth' | 'cors' | 'oauth' | 'host'>> & {
151+
export type SSETransportConfigInternal = Required<Omit<SSETransportConfig, 'headers' | 'auth' | 'cors' | 'oauth' | 'host' | 'health'>> & {
123152
headers?: Record<string, string>;
124153
auth?: AuthConfig;
125154
cors?: CORSConfig;
126155
oauth?: SSETransportConfig['oauth'];
127156
host?: string;
157+
health?: HealthConfig;
128158
};
129159

130160
/**
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { describe, it, expect, afterEach } from '@jest/globals';
2+
import http from 'node:http';
3+
import { HttpStreamTransport } from '../../src/transports/http/server.js';
4+
import { SSEServerTransport } from '../../src/transports/sse/server.js';
5+
6+
function getPort(): number {
7+
return 10000 + Math.floor(Math.random() * 50000);
8+
}
9+
10+
function httpGet(port: number, path: string): Promise<{ status: number; body: string }> {
11+
return new Promise((resolve, reject) => {
12+
const req = http.get(`http://127.0.0.1:${port}${path}`, (res) => {
13+
let body = '';
14+
res.on('data', (chunk) => (body += chunk));
15+
res.on('end', () => resolve({ status: res.statusCode!, body }));
16+
});
17+
req.on('error', reject);
18+
req.setTimeout(2000, () => {
19+
req.destroy(new Error('timeout'));
20+
});
21+
});
22+
}
23+
24+
describe('Health endpoint – HttpStreamTransport', () => {
25+
let transport: HttpStreamTransport;
26+
27+
afterEach(async () => {
28+
if (transport?.isRunning()) await transport.close();
29+
});
30+
31+
it('serves /health by default with { ok: true }', async () => {
32+
const port = getPort();
33+
transport = new HttpStreamTransport({ port });
34+
await transport.start();
35+
36+
const res = await httpGet(port, '/health');
37+
expect(res.status).toBe(200);
38+
expect(JSON.parse(res.body)).toEqual({ ok: true });
39+
});
40+
41+
it('serves a custom path when configured', async () => {
42+
const port = getPort();
43+
transport = new HttpStreamTransport({
44+
port,
45+
health: { path: '/healthz' },
46+
});
47+
await transport.start();
48+
49+
const res = await httpGet(port, '/healthz');
50+
expect(res.status).toBe(200);
51+
expect(JSON.parse(res.body)).toEqual({ ok: true });
52+
53+
// Default /health should 404
54+
const notFound = await httpGet(port, '/health');
55+
expect(notFound.status).toBe(404);
56+
});
57+
58+
it('serves a custom response body when configured', async () => {
59+
const port = getPort();
60+
const customResponse = { success: true, data: 'ok' };
61+
transport = new HttpStreamTransport({
62+
port,
63+
health: { path: '/healthz', response: customResponse },
64+
});
65+
await transport.start();
66+
67+
const res = await httpGet(port, '/healthz');
68+
expect(res.status).toBe(200);
69+
expect(JSON.parse(res.body)).toEqual(customResponse);
70+
});
71+
72+
it('disables health endpoint when enabled is false', async () => {
73+
const port = getPort();
74+
transport = new HttpStreamTransport({
75+
port,
76+
health: { enabled: false },
77+
});
78+
await transport.start();
79+
80+
const res = await httpGet(port, '/health');
81+
expect(res.status).toBe(404);
82+
});
83+
84+
it('does not respond to POST on health path', async () => {
85+
const port = getPort();
86+
transport = new HttpStreamTransport({ port });
87+
await transport.start();
88+
89+
const res = await new Promise<{ status: number }>((resolve, reject) => {
90+
const req = http.request(
91+
{ hostname: '127.0.0.1', port, path: '/health', method: 'POST' },
92+
(res) => resolve({ status: res.statusCode! }),
93+
);
94+
req.on('error', reject);
95+
req.end();
96+
});
97+
98+
// POST to /health should not match the health route
99+
expect(res.status).not.toBe(200);
100+
});
101+
});
102+
103+
describe('Health endpoint – SSEServerTransport', () => {
104+
let transport: SSEServerTransport;
105+
106+
afterEach(async () => {
107+
if (transport?.isRunning()) await transport.close();
108+
});
109+
110+
it('serves /health by default with { ok: true }', async () => {
111+
const port = getPort();
112+
transport = new SSEServerTransport({ port });
113+
await transport.start();
114+
115+
const res = await httpGet(port, '/health');
116+
expect(res.status).toBe(200);
117+
expect(JSON.parse(res.body)).toEqual({ ok: true });
118+
});
119+
120+
it('serves a custom path when configured', async () => {
121+
const port = getPort();
122+
transport = new SSEServerTransport({
123+
port,
124+
health: { path: '/healthz' },
125+
});
126+
await transport.start();
127+
128+
const res = await httpGet(port, '/healthz');
129+
expect(res.status).toBe(200);
130+
expect(JSON.parse(res.body)).toEqual({ ok: true });
131+
132+
const notFound = await httpGet(port, '/health');
133+
expect(notFound.status).toBe(404);
134+
});
135+
136+
it('serves a custom response body when configured', async () => {
137+
const port = getPort();
138+
const customResponse = { success: true, data: 'ok' };
139+
transport = new SSEServerTransport({
140+
port,
141+
health: { path: '/healthz', response: customResponse },
142+
});
143+
await transport.start();
144+
145+
const res = await httpGet(port, '/healthz');
146+
expect(res.status).toBe(200);
147+
expect(JSON.parse(res.body)).toEqual(customResponse);
148+
});
149+
150+
it('disables health endpoint when enabled is false', async () => {
151+
const port = getPort();
152+
transport = new SSEServerTransport({
153+
port,
154+
health: { enabled: false },
155+
});
156+
await transport.start();
157+
158+
const res = await httpGet(port, '/health');
159+
expect(res.status).toBe(404);
160+
});
161+
});

0 commit comments

Comments
 (0)