Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit bac916e

Browse files
authored
Merge pull request modelcontextprotocol#399 from its-nikhil/FEAT-auth-ctx-streamable-http
[FEAT] added support for AuthInfo in extra for StreamableHTTPServerTransport
2 parents 17caa5a + 4b5c16f commit bac916e

File tree

2 files changed

+165
-6
lines changed

2 files changed

+165
-6
lines changed

src/server/streamableHttp.test.ts

+157-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { EventStore, StreamableHTTPServerTransport, EventId, StreamId } from "./
55
import { McpServer } from "./mcp.js";
66
import { CallToolResult, JSONRPCMessage } from "../types.js";
77
import { z } from "zod";
8+
import { AuthInfo } from "./auth/types.js";
89

910
/**
1011
* Test server configuration for StreamableHTTPServerTransport tests
@@ -70,6 +71,61 @@ async function createTestServer(config: TestServerConfig = { sessionIdGenerator:
7071
return { server, transport, mcpServer, baseUrl };
7172
}
7273

74+
/**
75+
* Helper to create and start authenticated test HTTP server with MCP setup
76+
*/
77+
async function createTestAuthServer(config: TestServerConfig = { sessionIdGenerator: (() => randomUUID()) }): Promise<{
78+
server: Server;
79+
transport: StreamableHTTPServerTransport;
80+
mcpServer: McpServer;
81+
baseUrl: URL;
82+
}> {
83+
const mcpServer = new McpServer(
84+
{ name: "test-server", version: "1.0.0" },
85+
{ capabilities: { logging: {} } }
86+
);
87+
88+
mcpServer.tool(
89+
"profile",
90+
"A user profile data tool",
91+
{ active: z.boolean().describe("Profile status") },
92+
async ({ active }, { authInfo }): Promise<CallToolResult> => {
93+
return { content: [{ type: "text", text: `${active ? 'Active' : 'Inactive'} profile from token: ${authInfo?.token}!` }] };
94+
}
95+
);
96+
97+
const transport = new StreamableHTTPServerTransport({
98+
sessionIdGenerator: config.sessionIdGenerator,
99+
enableJsonResponse: config.enableJsonResponse ?? false,
100+
eventStore: config.eventStore
101+
});
102+
103+
await mcpServer.connect(transport);
104+
105+
const server = createServer(async (req: IncomingMessage & { auth?: AuthInfo }, res) => {
106+
try {
107+
if (config.customRequestHandler) {
108+
await config.customRequestHandler(req, res);
109+
} else {
110+
req.auth = { token: req.headers["authorization"]?.split(" ")[1] } as AuthInfo;
111+
await transport.handleRequest(req, res);
112+
}
113+
} catch (error) {
114+
console.error("Error handling request:", error);
115+
if (!res.headersSent) res.writeHead(500).end();
116+
}
117+
});
118+
119+
const baseUrl = await new Promise<URL>((resolve) => {
120+
server.listen(0, "127.0.0.1", () => {
121+
const addr = server.address() as AddressInfo;
122+
resolve(new URL(`http://127.0.0.1:${addr.port}`));
123+
});
124+
});
125+
126+
return { server, transport, mcpServer, baseUrl };
127+
}
128+
73129
/**
74130
* Helper to stop test server
75131
*/
@@ -120,10 +176,11 @@ async function readSSEEvent(response: Response): Promise<string> {
120176
/**
121177
* Helper to send JSON-RPC request
122178
*/
123-
async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMessage[], sessionId?: string): Promise<Response> {
179+
async function sendPostRequest(baseUrl: URL, message: JSONRPCMessage | JSONRPCMessage[], sessionId?: string, extraHeaders?: Record<string, string>): Promise<Response> {
124180
const headers: Record<string, string> = {
125181
"Content-Type": "application/json",
126182
Accept: "application/json, text/event-stream",
183+
...extraHeaders
127184
};
128185

129186
if (sessionId) {
@@ -673,6 +730,105 @@ describe("StreamableHTTPServerTransport", () => {
673730
});
674731
});
675732

733+
describe("StreamableHTTPServerTransport with AuthInfo", () => {
734+
let server: Server;
735+
let transport: StreamableHTTPServerTransport;
736+
let baseUrl: URL;
737+
let sessionId: string;
738+
739+
beforeEach(async () => {
740+
const result = await createTestAuthServer();
741+
server = result.server;
742+
transport = result.transport;
743+
baseUrl = result.baseUrl;
744+
});
745+
746+
afterEach(async () => {
747+
await stopTestServer({ server, transport });
748+
});
749+
750+
async function initializeServer(): Promise<string> {
751+
const response = await sendPostRequest(baseUrl, TEST_MESSAGES.initialize);
752+
753+
expect(response.status).toBe(200);
754+
const newSessionId = response.headers.get("mcp-session-id");
755+
expect(newSessionId).toBeDefined();
756+
return newSessionId as string;
757+
}
758+
759+
it("should call a tool with authInfo", async () => {
760+
sessionId = await initializeServer();
761+
762+
const toolCallMessage: JSONRPCMessage = {
763+
jsonrpc: "2.0",
764+
method: "tools/call",
765+
params: {
766+
name: "profile",
767+
arguments: {active: true},
768+
},
769+
id: "call-1",
770+
};
771+
772+
const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId, {'authorization': 'Bearer test-token'});
773+
expect(response.status).toBe(200);
774+
775+
const text = await readSSEEvent(response);
776+
const eventLines = text.split("\n");
777+
const dataLine = eventLines.find(line => line.startsWith("data:"));
778+
expect(dataLine).toBeDefined();
779+
780+
const eventData = JSON.parse(dataLine!.substring(5));
781+
expect(eventData).toMatchObject({
782+
jsonrpc: "2.0",
783+
result: {
784+
content: [
785+
{
786+
type: "text",
787+
text: "Active profile from token: test-token!",
788+
},
789+
],
790+
},
791+
id: "call-1",
792+
});
793+
});
794+
795+
it("should calls tool without authInfo when it is optional", async () => {
796+
sessionId = await initializeServer();
797+
798+
const toolCallMessage: JSONRPCMessage = {
799+
jsonrpc: "2.0",
800+
method: "tools/call",
801+
params: {
802+
name: "profile",
803+
arguments: {active: false},
804+
},
805+
id: "call-1",
806+
};
807+
808+
const response = await sendPostRequest(baseUrl, toolCallMessage, sessionId);
809+
expect(response.status).toBe(200);
810+
811+
const text = await readSSEEvent(response);
812+
const eventLines = text.split("\n");
813+
const dataLine = eventLines.find(line => line.startsWith("data:"));
814+
expect(dataLine).toBeDefined();
815+
816+
const eventData = JSON.parse(dataLine!.substring(5));
817+
expect(eventData).toMatchObject({
818+
jsonrpc: "2.0",
819+
result: {
820+
content: [
821+
{
822+
type: "text",
823+
text: "Inactive profile from token: undefined!",
824+
},
825+
],
826+
},
827+
id: "call-1",
828+
});
829+
});
830+
});
831+
676832
// Test JSON Response Mode
677833
describe("StreamableHTTPServerTransport with JSON Response Mode", () => {
678834
let server: Server;

src/server/streamableHttp.ts

+8-5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { isInitializeRequest, isJSONRPCError, isJSONRPCRequest, isJSONRPCRespons
44
import getRawBody from "raw-body";
55
import contentType from "content-type";
66
import { randomUUID } from "node:crypto";
7+
import { AuthInfo } from "./auth/types.js";
78

89
const MAXIMUM_MESSAGE_SIZE = "4mb";
910

@@ -112,7 +113,7 @@ export class StreamableHTTPServerTransport implements Transport {
112113
sessionId?: string | undefined;
113114
onclose?: () => void;
114115
onerror?: (error: Error) => void;
115-
onmessage?: (message: JSONRPCMessage) => void;
116+
onmessage?: (message: JSONRPCMessage, extra?: { authInfo?: AuthInfo }) => void;
116117

117118
constructor(options: StreamableHTTPServerTransportOptions) {
118119
this.sessionIdGenerator = options.sessionIdGenerator;
@@ -135,7 +136,7 @@ export class StreamableHTTPServerTransport implements Transport {
135136
/**
136137
* Handles an incoming HTTP request, whether GET or POST
137138
*/
138-
async handleRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise<void> {
139+
async handleRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise<void> {
139140
if (req.method === "POST") {
140141
await this.handlePostRequest(req, res, parsedBody);
141142
} else if (req.method === "GET") {
@@ -286,7 +287,7 @@ export class StreamableHTTPServerTransport implements Transport {
286287
/**
287288
* Handles POST requests containing JSON-RPC messages
288289
*/
289-
private async handlePostRequest(req: IncomingMessage, res: ServerResponse, parsedBody?: unknown): Promise<void> {
290+
private async handlePostRequest(req: IncomingMessage & { auth?: AuthInfo }, res: ServerResponse, parsedBody?: unknown): Promise<void> {
290291
try {
291292
// Validate the Accept header
292293
const acceptHeader = req.headers.accept;
@@ -316,6 +317,8 @@ export class StreamableHTTPServerTransport implements Transport {
316317
return;
317318
}
318319

320+
const authInfo: AuthInfo | undefined = req.auth;
321+
319322
let rawMessage;
320323
if (parsedBody !== undefined) {
321324
rawMessage = parsedBody;
@@ -392,7 +395,7 @@ export class StreamableHTTPServerTransport implements Transport {
392395

393396
// handle each message
394397
for (const message of messages) {
395-
this.onmessage?.(message);
398+
this.onmessage?.(message, { authInfo });
396399
}
397400
} else if (hasRequests) {
398401
// The default behavior is to use SSE streaming
@@ -427,7 +430,7 @@ export class StreamableHTTPServerTransport implements Transport {
427430

428431
// handle each message
429432
for (const message of messages) {
430-
this.onmessage?.(message);
433+
this.onmessage?.(message, { authInfo });
431434
}
432435
// The server SHOULD NOT close the SSE stream before sending all JSON-RPC responses
433436
// This will be handled by the send() method when responses are ready

0 commit comments

Comments
 (0)