fastmcp 3.19.2 → 3.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2416,7 +2416,9 @@ test("provides auth to tools", async () => {
2416
2416
  warn: expect.any(Function),
2417
2417
  },
2418
2418
  reportProgress: expect.any(Function),
2419
+ requestId: undefined,
2419
2420
  session: { id: 1 },
2421
+ sessionId: expect.any(String),
2420
2422
  streamContent: expect.any(Function),
2421
2423
  },
2422
2424
  );
@@ -3783,7 +3785,8 @@ test("stateless mode handles authentication function throwing errors", async ()
3783
3785
  expect(response.status).toBe(401);
3784
3786
 
3785
3787
  const body = (await response.json()) as { error?: { message?: string } };
3786
- expect(body.error?.message).toContain("Unauthorized");
3788
+ // The actual error message should be passed through
3789
+ expect(body.error?.message).toContain("JWT validation service is down");
3787
3790
  } finally {
3788
3791
  await server.stop();
3789
3792
  }
@@ -3878,6 +3881,451 @@ test("stateless mode handles concurrent requests with authentication", async ()
3878
3881
  }
3879
3882
  });
3880
3883
 
3884
+ // Tests for GitHub Issue: FastMCP authentication fix
3885
+ // Testing the fix for session creation despite authentication failure
3886
+
3887
+ test("authentication failure handling: should throw error when auth.authenticated is false", async () => {
3888
+ const port = await getRandomPort();
3889
+
3890
+ const server = new FastMCP<{ authenticated: boolean; error?: string }>({
3891
+ authenticate: async () => {
3892
+ // Simulate authentication failure with { authenticated: false }
3893
+ return { authenticated: false, error: "Invalid JWT token" };
3894
+ },
3895
+ name: "Test server",
3896
+ version: "1.0.0",
3897
+ });
3898
+
3899
+ server.addTool({
3900
+ description: "Test tool",
3901
+ execute: async () => {
3902
+ return "pong";
3903
+ },
3904
+ name: "ping",
3905
+ parameters: z.object({}),
3906
+ });
3907
+
3908
+ await server.start({
3909
+ httpStream: {
3910
+ port,
3911
+ stateless: true,
3912
+ },
3913
+ transportType: "httpStream",
3914
+ });
3915
+
3916
+ try {
3917
+ // Send a raw HTTP request that should be rejected
3918
+ const response = await fetch(`http://localhost:${port}/mcp`, {
3919
+ body: JSON.stringify({
3920
+ id: 1,
3921
+ jsonrpc: "2.0",
3922
+ method: "initialize",
3923
+ params: {
3924
+ capabilities: {},
3925
+ clientInfo: { name: "test", version: "1.0" },
3926
+ protocolVersion: "2024-11-05",
3927
+ },
3928
+ }),
3929
+ headers: {
3930
+ Accept: "application/json, text/event-stream",
3931
+ Authorization: "Bearer invalid.jwt.token",
3932
+ "Content-Type": "application/json",
3933
+ },
3934
+ method: "POST",
3935
+ });
3936
+
3937
+ // Should return 401 Unauthorized (handled by mcp-proxy)
3938
+ expect(response.status).toBe(401);
3939
+
3940
+ const body = (await response.json()) as {
3941
+ error?: { code?: number; message?: string };
3942
+ };
3943
+ expect(body.error?.message).toContain("Invalid JWT token");
3944
+ } finally {
3945
+ await server.stop();
3946
+ }
3947
+ });
3948
+
3949
+ test("authentication failure handling: should create session when auth.authenticated is true", async () => {
3950
+ const port = await getRandomPort();
3951
+
3952
+ const server = new FastMCP<{
3953
+ authenticated: boolean;
3954
+ session?: { userId: string };
3955
+ }>({
3956
+ authenticate: async () => {
3957
+ // Simulate successful authentication
3958
+ return { authenticated: true, session: { userId: "123" } };
3959
+ },
3960
+ name: "Test server",
3961
+ version: "1.0.0",
3962
+ });
3963
+
3964
+ server.addTool({
3965
+ description: "Test tool",
3966
+ execute: async (_args, context) => {
3967
+ return `User: ${context.session?.session?.userId}`;
3968
+ },
3969
+ name: "whoami",
3970
+ parameters: z.object({}),
3971
+ });
3972
+
3973
+ await server.start({
3974
+ httpStream: {
3975
+ port,
3976
+ stateless: true,
3977
+ },
3978
+ transportType: "httpStream",
3979
+ });
3980
+
3981
+ try {
3982
+ const client = new Client(
3983
+ {
3984
+ name: "Test client",
3985
+ version: "1.0.0",
3986
+ },
3987
+ {
3988
+ capabilities: {},
3989
+ },
3990
+ );
3991
+
3992
+ const transport = new StreamableHTTPClientTransport(
3993
+ new URL(`http://localhost:${port}/mcp`),
3994
+ );
3995
+
3996
+ await client.connect(transport);
3997
+
3998
+ const result = await client.callTool({
3999
+ arguments: {},
4000
+ name: "whoami",
4001
+ });
4002
+
4003
+ expect(result.content).toEqual([
4004
+ {
4005
+ text: "User: 123",
4006
+ type: "text",
4007
+ },
4008
+ ]);
4009
+
4010
+ await client.close();
4011
+ } finally {
4012
+ await server.stop();
4013
+ }
4014
+ });
4015
+
4016
+ test("authentication failure handling: should create session when auth is null/undefined (anonymous)", async () => {
4017
+ const port = await getRandomPort();
4018
+
4019
+ const server = new FastMCP({
4020
+ // No authenticate function - anonymous access
4021
+ name: "Test server",
4022
+ version: "1.0.0",
4023
+ });
4024
+
4025
+ server.addTool({
4026
+ description: "Test tool",
4027
+ execute: async (_args, context) => {
4028
+ return `Anonymous: ${context.session === undefined}`;
4029
+ },
4030
+ name: "ping",
4031
+ parameters: z.object({}),
4032
+ });
4033
+
4034
+ await server.start({
4035
+ httpStream: {
4036
+ port,
4037
+ stateless: true,
4038
+ },
4039
+ transportType: "httpStream",
4040
+ });
4041
+
4042
+ try {
4043
+ const client = new Client(
4044
+ {
4045
+ name: "Test client",
4046
+ version: "1.0.0",
4047
+ },
4048
+ {
4049
+ capabilities: {},
4050
+ },
4051
+ );
4052
+
4053
+ const transport = new StreamableHTTPClientTransport(
4054
+ new URL(`http://localhost:${port}/mcp`),
4055
+ );
4056
+
4057
+ await client.connect(transport);
4058
+
4059
+ const result = await client.callTool({
4060
+ arguments: {},
4061
+ name: "ping",
4062
+ });
4063
+
4064
+ expect(result.content).toEqual([
4065
+ {
4066
+ text: "Anonymous: true",
4067
+ type: "text",
4068
+ },
4069
+ ]);
4070
+
4071
+ await client.close();
4072
+ } finally {
4073
+ await server.stop();
4074
+ }
4075
+ });
4076
+
4077
+ test("authentication failure handling: should use default error message when auth.error is not provided", async () => {
4078
+ const port = await getRandomPort();
4079
+
4080
+ const server = new FastMCP<{ authenticated: boolean }>({
4081
+ authenticate: async () => {
4082
+ // Return authenticated: false without custom error message
4083
+ return { authenticated: false };
4084
+ },
4085
+ name: "Test server",
4086
+ version: "1.0.0",
4087
+ });
4088
+
4089
+ server.addTool({
4090
+ description: "Test tool",
4091
+ execute: async () => {
4092
+ return "pong";
4093
+ },
4094
+ name: "ping",
4095
+ parameters: z.object({}),
4096
+ });
4097
+
4098
+ await server.start({
4099
+ httpStream: {
4100
+ port,
4101
+ stateless: true,
4102
+ },
4103
+ transportType: "httpStream",
4104
+ });
4105
+
4106
+ try {
4107
+ const response = await fetch(`http://localhost:${port}/mcp`, {
4108
+ body: JSON.stringify({
4109
+ id: 1,
4110
+ jsonrpc: "2.0",
4111
+ method: "initialize",
4112
+ params: {
4113
+ capabilities: {},
4114
+ clientInfo: { name: "test", version: "1.0" },
4115
+ protocolVersion: "2024-11-05",
4116
+ },
4117
+ }),
4118
+ headers: {
4119
+ Accept: "application/json, text/event-stream",
4120
+ "Content-Type": "application/json",
4121
+ },
4122
+ method: "POST",
4123
+ });
4124
+
4125
+ expect(response.status).toBe(401);
4126
+
4127
+ const body = (await response.json()) as {
4128
+ error?: { message?: string };
4129
+ };
4130
+ expect(body.error?.message).toContain("Authentication failed");
4131
+ } finally {
4132
+ await server.stop();
4133
+ }
4134
+ });
4135
+
4136
+ test("authentication failure handling: should preserve existing behavior for truthy auth results", async () => {
4137
+ const port = await getRandomPort();
4138
+
4139
+ const server = new FastMCP<{ role: string; userId: string }>({
4140
+ authenticate: async () => {
4141
+ // Return a truthy object without 'authenticated' field (legacy pattern)
4142
+ return { role: "admin", userId: "456" };
4143
+ },
4144
+ name: "Test server",
4145
+ version: "1.0.0",
4146
+ });
4147
+
4148
+ server.addTool({
4149
+ description: "Test tool",
4150
+ execute: async (_args, context) => {
4151
+ return `User: ${context.session?.userId}, Role: ${context.session?.role}`;
4152
+ },
4153
+ name: "whoami",
4154
+ parameters: z.object({}),
4155
+ });
4156
+
4157
+ await server.start({
4158
+ httpStream: {
4159
+ port,
4160
+ stateless: true,
4161
+ },
4162
+ transportType: "httpStream",
4163
+ });
4164
+
4165
+ try {
4166
+ const client = new Client(
4167
+ {
4168
+ name: "Test client",
4169
+ version: "1.0.0",
4170
+ },
4171
+ {
4172
+ capabilities: {},
4173
+ },
4174
+ );
4175
+
4176
+ const transport = new StreamableHTTPClientTransport(
4177
+ new URL(`http://localhost:${port}/mcp`),
4178
+ );
4179
+
4180
+ await client.connect(transport);
4181
+
4182
+ const result = await client.callTool({
4183
+ arguments: {},
4184
+ name: "whoami",
4185
+ });
4186
+
4187
+ expect(result.content).toEqual([
4188
+ {
4189
+ text: "User: 456, Role: admin",
4190
+ type: "text",
4191
+ },
4192
+ ]);
4193
+
4194
+ await client.close();
4195
+ } finally {
4196
+ await server.stop();
4197
+ }
4198
+ });
4199
+
4200
+ test("authentication failure handling: should handle authentication with custom error messages", async () => {
4201
+ const port = await getRandomPort();
4202
+ const CUSTOM_ERROR_MSG = "Token expired at 2025-10-07T12:00:00Z";
4203
+
4204
+ const server = new FastMCP<{ authenticated: boolean; error?: string }>({
4205
+ authenticate: async () => {
4206
+ return { authenticated: false, error: CUSTOM_ERROR_MSG };
4207
+ },
4208
+ name: "Test server",
4209
+ version: "1.0.0",
4210
+ });
4211
+
4212
+ server.addTool({
4213
+ description: "Test tool",
4214
+ execute: async () => {
4215
+ return "pong";
4216
+ },
4217
+ name: "ping",
4218
+ parameters: z.object({}),
4219
+ });
4220
+
4221
+ await server.start({
4222
+ httpStream: {
4223
+ port,
4224
+ stateless: true,
4225
+ },
4226
+ transportType: "httpStream",
4227
+ });
4228
+
4229
+ try {
4230
+ const response = await fetch(`http://localhost:${port}/mcp`, {
4231
+ body: JSON.stringify({
4232
+ id: 1,
4233
+ jsonrpc: "2.0",
4234
+ method: "initialize",
4235
+ params: {
4236
+ capabilities: {},
4237
+ clientInfo: { name: "test", version: "1.0" },
4238
+ protocolVersion: "2024-11-05",
4239
+ },
4240
+ }),
4241
+ headers: {
4242
+ Accept: "application/json, text/event-stream",
4243
+ "Content-Type": "application/json",
4244
+ },
4245
+ method: "POST",
4246
+ });
4247
+
4248
+ expect(response.status).toBe(401);
4249
+
4250
+ const body = (await response.json()) as {
4251
+ error?: { message?: string };
4252
+ };
4253
+ expect(body.error?.message).toBe(CUSTOM_ERROR_MSG);
4254
+ } finally {
4255
+ await server.stop();
4256
+ }
4257
+ });
4258
+
4259
+ test("authentication failure handling: should not create session for authenticated=false even with session data", async () => {
4260
+ const port = await getRandomPort();
4261
+
4262
+ const server = new FastMCP<{
4263
+ authenticated: boolean;
4264
+ error?: string;
4265
+ session?: { userId: string };
4266
+ }>({
4267
+ authenticate: async () => {
4268
+ // Even if session data is present, authenticated: false should reject
4269
+ return {
4270
+ authenticated: false,
4271
+ error: "Insufficient permissions",
4272
+ session: { userId: "hacker" },
4273
+ };
4274
+ },
4275
+ name: "Test server",
4276
+ version: "1.0.0",
4277
+ });
4278
+
4279
+ server.addTool({
4280
+ description: "Test tool",
4281
+ execute: async () => {
4282
+ return "pong";
4283
+ },
4284
+ name: "ping",
4285
+ parameters: z.object({}),
4286
+ });
4287
+
4288
+ await server.start({
4289
+ httpStream: {
4290
+ port,
4291
+ stateless: true,
4292
+ },
4293
+ transportType: "httpStream",
4294
+ });
4295
+
4296
+ try {
4297
+ const response = await fetch(`http://localhost:${port}/mcp`, {
4298
+ body: JSON.stringify({
4299
+ id: 1,
4300
+ jsonrpc: "2.0",
4301
+ method: "initialize",
4302
+ params: {
4303
+ capabilities: {},
4304
+ clientInfo: { name: "test", version: "1.0" },
4305
+ protocolVersion: "2024-11-05",
4306
+ },
4307
+ }),
4308
+ headers: {
4309
+ Accept: "application/json, text/event-stream",
4310
+ "Content-Type": "application/json",
4311
+ },
4312
+ method: "POST",
4313
+ });
4314
+
4315
+ expect(response.status).toBe(401);
4316
+
4317
+ const body = (await response.json()) as {
4318
+ error?: { message?: string };
4319
+ };
4320
+ expect(body.error?.message).toContain("Insufficient permissions");
4321
+
4322
+ // Verify session was never created
4323
+ expect(server.sessions.length).toBe(0);
4324
+ } finally {
4325
+ await server.stop();
4326
+ }
4327
+ });
4328
+
3881
4329
  test("host configuration works with 0.0.0.0", async () => {
3882
4330
  const port = await getRandomPort();
3883
4331
 
package/src/FastMCP.ts CHANGED
@@ -210,7 +210,19 @@ type Context<T extends FastMCPSessionAuth> = {
210
210
  warn: (message: string, data?: SerializableValue) => void;
211
211
  };
212
212
  reportProgress: (progress: Progress) => Promise<void>;
213
+ /**
214
+ * Request ID from the current MCP request.
215
+ * Available for all transports when the client provides it.
216
+ */
217
+ requestId?: string;
213
218
  session: T | undefined;
219
+ /**
220
+ * Session ID from the Mcp-Session-Id header.
221
+ * Only available for HTTP-based transports (SSE, HTTP Stream).
222
+ * Can be used to track per-session state, implement session-specific
223
+ * counters, or maintain user-specific data across multiple requests.
224
+ */
225
+ sessionId?: string;
214
226
  streamContent: (content: Content | Content[]) => Promise<void>;
215
227
  };
216
228
 
@@ -936,6 +948,12 @@ export class FastMCPSession<
936
948
  public get server(): Server {
937
949
  return this.#server;
938
950
  }
951
+ public get sessionId(): string | undefined {
952
+ return this.#sessionId;
953
+ }
954
+ public set sessionId(value: string | undefined) {
955
+ this.#sessionId = value;
956
+ }
939
957
  #auth: T | undefined;
940
958
  #capabilities: ServerCapabilities = {};
941
959
  #clientCapabilities?: ClientCapabilities;
@@ -959,6 +977,12 @@ export class FastMCPSession<
959
977
 
960
978
  #server: Server;
961
979
 
980
+ /**
981
+ * Session ID from the Mcp-Session-Id header (HTTP transports only).
982
+ * Used to track per-session state across multiple requests.
983
+ */
984
+ #sessionId?: string;
985
+
962
986
  #utils?: ServerOptions<T>["utils"];
963
987
 
964
988
  constructor({
@@ -971,6 +995,7 @@ export class FastMCPSession<
971
995
  resources,
972
996
  resourcesTemplates,
973
997
  roots,
998
+ sessionId,
974
999
  tools,
975
1000
  transportType,
976
1001
  utils,
@@ -985,6 +1010,7 @@ export class FastMCPSession<
985
1010
  resources: Resource<T>[];
986
1011
  resourcesTemplates: InputResourceTemplate<T>[];
987
1012
  roots?: ServerOptions<T>["roots"];
1013
+ sessionId?: string;
988
1014
  tools: Tool<T>[];
989
1015
  transportType?: "httpStream" | "stdio";
990
1016
  utils?: ServerOptions<T>["utils"];
@@ -996,6 +1022,7 @@ export class FastMCPSession<
996
1022
  this.#logger = logger;
997
1023
  this.#pingConfig = ping;
998
1024
  this.#rootsConfig = roots;
1025
+ this.#sessionId = sessionId;
999
1026
  this.#needsEventLoopFlush = transportType === "httpStream";
1000
1027
 
1001
1028
  if (tools.length) {
@@ -1077,6 +1104,16 @@ export class FastMCPSession<
1077
1104
  try {
1078
1105
  await this.#server.connect(transport);
1079
1106
 
1107
+ // Extract session ID from transport if available (HTTP transports only)
1108
+ if ("sessionId" in transport) {
1109
+ const transportWithSessionId = transport as {
1110
+ sessionId?: string;
1111
+ } & Transport;
1112
+ if (typeof transportWithSessionId.sessionId === "string") {
1113
+ this.#sessionId = transportWithSessionId.sessionId;
1114
+ }
1115
+ }
1116
+
1080
1117
  let attempt = 0;
1081
1118
  const maxAttempts = 10;
1082
1119
  const retryDelay = 100;
@@ -1789,7 +1826,12 @@ export class FastMCPSession<
1789
1826
  },
1790
1827
  log,
1791
1828
  reportProgress,
1829
+ requestId:
1830
+ typeof request.params?._meta?.requestId === "string"
1831
+ ? request.params._meta.requestId
1832
+ : undefined,
1792
1833
  session: this.#auth,
1834
+ sessionId: this.#sessionId,
1793
1835
  streamContent,
1794
1836
  });
1795
1837
 
@@ -2110,7 +2152,7 @@ export class FastMCP<
2110
2152
  );
2111
2153
 
2112
2154
  this.#httpStreamServer = await startHTTPServer<FastMCPSession<T>>({
2113
- authenticate: this.#authenticate,
2155
+ ...(this.#authenticate ? { authenticate: this.#authenticate } : {}),
2114
2156
  createServer: async (request) => {
2115
2157
  let auth: T | undefined;
2116
2158
 
@@ -2124,9 +2166,14 @@ export class FastMCP<
2124
2166
  }
2125
2167
  }
2126
2168
 
2169
+ // Extract session ID from headers
2170
+ const sessionId = Array.isArray(request.headers["mcp-session-id"])
2171
+ ? request.headers["mcp-session-id"][0]
2172
+ : request.headers["mcp-session-id"];
2173
+
2127
2174
  // In stateless mode, create a new session for each request
2128
2175
  // without persisting it in the sessions array
2129
- return this.#createSession(auth);
2176
+ return this.#createSession(auth, sessionId);
2130
2177
  },
2131
2178
  enableJsonResponse: httpConfig.enableJsonResponse,
2132
2179
  eventStore: httpConfig.eventStore,
@@ -2151,7 +2198,7 @@ export class FastMCP<
2151
2198
  } else {
2152
2199
  // Regular mode with session management
2153
2200
  this.#httpStreamServer = await startHTTPServer<FastMCPSession<T>>({
2154
- authenticate: this.#authenticate,
2201
+ ...(this.#authenticate ? { authenticate: this.#authenticate } : {}),
2155
2202
  createServer: async (request) => {
2156
2203
  let auth: T | undefined;
2157
2204
 
@@ -2159,7 +2206,12 @@ export class FastMCP<
2159
2206
  auth = await this.#authenticate(request);
2160
2207
  }
2161
2208
 
2162
- return this.#createSession(auth);
2209
+ // Extract session ID from headers
2210
+ const sessionId = Array.isArray(request.headers["mcp-session-id"])
2211
+ ? request.headers["mcp-session-id"][0]
2212
+ : request.headers["mcp-session-id"];
2213
+
2214
+ return this.#createSession(auth, sessionId);
2163
2215
  },
2164
2216
  enableJsonResponse: httpConfig.enableJsonResponse,
2165
2217
  eventStore: httpConfig.eventStore,
@@ -2218,7 +2270,22 @@ export class FastMCP<
2218
2270
  * Creates a new FastMCPSession instance with the current configuration.
2219
2271
  * Used both for regular sessions and stateless requests.
2220
2272
  */
2221
- #createSession(auth?: T): FastMCPSession<T> {
2273
+ #createSession(auth?: T, sessionId?: string): FastMCPSession<T> {
2274
+ // Check if authentication failed
2275
+ if (
2276
+ auth &&
2277
+ typeof auth === "object" &&
2278
+ "authenticated" in auth &&
2279
+ !(auth as { authenticated: unknown }).authenticated
2280
+ ) {
2281
+ const errorMessage =
2282
+ "error" in auth &&
2283
+ typeof (auth as { error: unknown }).error === "string"
2284
+ ? (auth as { error: string }).error
2285
+ : "Authentication failed";
2286
+ throw new Error(errorMessage);
2287
+ }
2288
+
2222
2289
  const allowedTools = auth
2223
2290
  ? this.#tools.filter((tool) =>
2224
2291
  tool.canAccess ? tool.canAccess(auth) : true,
@@ -2234,6 +2301,7 @@ export class FastMCP<
2234
2301
  resources: this.#resources,
2235
2302
  resourcesTemplates: this.#resourcesTemplates,
2236
2303
  roots: this.#options.roots,
2304
+ sessionId,
2237
2305
  tools: allowedTools,
2238
2306
  transportType: "httpStream",
2239
2307
  utils: this.#options.utils,