fastmcp 3.18.0 → 3.19.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fastmcp",
3
- "version": "3.18.0",
3
+ "version": "3.19.1",
4
4
  "main": "dist/FastMCP.js",
5
5
  "scripts": {
6
6
  "build": "tsup",
@@ -27,7 +27,7 @@
27
27
  "execa": "^9.6.0",
28
28
  "file-type": "^21.0.0",
29
29
  "fuse.js": "^7.1.0",
30
- "mcp-proxy": "^5.5.4",
30
+ "mcp-proxy": "^5.8.0",
31
31
  "strict-event-emitter-types": "^2.0.0",
32
32
  "undici": "^7.13.0",
33
33
  "uri-templates": "^0.2.0",
@@ -1052,6 +1052,238 @@ test("embedded resources work with direct resources", async () => {
1052
1052
  });
1053
1053
  });
1054
1054
 
1055
+ test("embedded resources work with URI templates and query parameters", async () => {
1056
+ await runWithTestServer({
1057
+ run: async ({ client }) => {
1058
+ // Test case 1: Simple query parameter extraction
1059
+ expect(
1060
+ await client.callTool({
1061
+ arguments: {
1062
+ uri: "ui://search?location=a&q=b",
1063
+ },
1064
+ name: "get_search_resource",
1065
+ }),
1066
+ ).toEqual({
1067
+ content: [
1068
+ {
1069
+ resource: {
1070
+ mimeType: "application/json",
1071
+ text: '{"location":"a","query":"b","type":"search"}',
1072
+ uri: "ui://search?location=a&q=b",
1073
+ },
1074
+ type: "resource",
1075
+ },
1076
+ ],
1077
+ });
1078
+
1079
+ // Test case 2: Query parameters with different order
1080
+ expect(
1081
+ await client.callTool({
1082
+ arguments: {
1083
+ uri: "ui://search?q=test&location=home",
1084
+ },
1085
+ name: "get_search_resource",
1086
+ }),
1087
+ ).toEqual({
1088
+ content: [
1089
+ {
1090
+ resource: {
1091
+ mimeType: "application/json",
1092
+ text: '{"location":"home","query":"test","type":"search"}',
1093
+ uri: "ui://search?q=test&location=home",
1094
+ },
1095
+ type: "resource",
1096
+ },
1097
+ ],
1098
+ });
1099
+
1100
+ // Test case 3: Query parameters with encoded values
1101
+ expect(
1102
+ await client.callTool({
1103
+ arguments: {
1104
+ uri: "ui://search?location=new%20york&q=hello%20world",
1105
+ },
1106
+ name: "get_search_resource",
1107
+ }),
1108
+ ).toEqual({
1109
+ content: [
1110
+ {
1111
+ resource: {
1112
+ mimeType: "application/json",
1113
+ text: '{"location":"new york","query":"hello world","type":"search"}',
1114
+ uri: "ui://search?location=new%20york&q=hello%20world",
1115
+ },
1116
+ type: "resource",
1117
+ },
1118
+ ],
1119
+ });
1120
+ },
1121
+
1122
+ server: async () => {
1123
+ const server = new FastMCP({
1124
+ name: "Test",
1125
+ version: "1.0.0",
1126
+ });
1127
+
1128
+ server.addResourceTemplate({
1129
+ arguments: [
1130
+ {
1131
+ name: "location",
1132
+ required: true,
1133
+ },
1134
+ {
1135
+ name: "q",
1136
+ required: true,
1137
+ },
1138
+ ],
1139
+ async load(args) {
1140
+ return {
1141
+ text: JSON.stringify({
1142
+ location: args.location,
1143
+ query: args.q,
1144
+ type: "search",
1145
+ }),
1146
+ };
1147
+ },
1148
+ mimeType: "application/json",
1149
+ name: "Search Resource",
1150
+ uriTemplate: "ui://search{?location,q}",
1151
+ });
1152
+
1153
+ server.addTool({
1154
+ description:
1155
+ "Get search resource data using embedded function with query parameters",
1156
+ execute: async (args) => {
1157
+ return {
1158
+ content: [
1159
+ {
1160
+ resource: await server.embedded(args.uri),
1161
+ type: "resource",
1162
+ },
1163
+ ],
1164
+ };
1165
+ },
1166
+ name: "get_search_resource",
1167
+ parameters: z.object({
1168
+ uri: z.string(),
1169
+ }),
1170
+ });
1171
+
1172
+ return server;
1173
+ },
1174
+ });
1175
+ });
1176
+
1177
+ test("embedded resources work with complex URI template patterns", async () => {
1178
+ await runWithTestServer({
1179
+ run: async ({ client }) => {
1180
+ // Test case 1: Path and query parameters combined
1181
+ expect(
1182
+ await client.callTool({
1183
+ arguments: {
1184
+ uri: "api://users/123?fields=name,email&format=json",
1185
+ },
1186
+ name: "get_user_data",
1187
+ }),
1188
+ ).toEqual({
1189
+ content: [
1190
+ {
1191
+ resource: {
1192
+ mimeType: "application/json",
1193
+ text: '{"userId":"123","fields":["name","email"],"format":"json"}',
1194
+ uri: "api://users/123?fields=name,email&format=json",
1195
+ },
1196
+ type: "resource",
1197
+ },
1198
+ ],
1199
+ });
1200
+
1201
+ // Test case 2: Optional query parameters (some missing)
1202
+ expect(
1203
+ await client.callTool({
1204
+ arguments: {
1205
+ uri: "api://users/456?format=xml",
1206
+ },
1207
+ name: "get_user_data",
1208
+ }),
1209
+ ).toEqual({
1210
+ content: [
1211
+ {
1212
+ resource: {
1213
+ mimeType: "application/json",
1214
+ text: '{"userId":"456","format":"xml"}',
1215
+ uri: "api://users/456?format=xml",
1216
+ },
1217
+ type: "resource",
1218
+ },
1219
+ ],
1220
+ });
1221
+ },
1222
+
1223
+ server: async () => {
1224
+ const server = new FastMCP({
1225
+ name: "Test",
1226
+ version: "1.0.0",
1227
+ });
1228
+
1229
+ server.addResourceTemplate({
1230
+ arguments: [
1231
+ {
1232
+ name: "userId",
1233
+ required: true,
1234
+ },
1235
+ {
1236
+ name: "fields",
1237
+ required: false,
1238
+ },
1239
+ {
1240
+ name: "format",
1241
+ required: false,
1242
+ },
1243
+ ],
1244
+ async load(args) {
1245
+ const result: Record<string, string> = {
1246
+ userId: args.userId,
1247
+ };
1248
+ if (args.fields) {
1249
+ result.fields = args.fields;
1250
+ }
1251
+ if (args.format) {
1252
+ result.format = args.format;
1253
+ }
1254
+ return {
1255
+ text: JSON.stringify(result),
1256
+ };
1257
+ },
1258
+ mimeType: "application/json",
1259
+ name: "User Data API",
1260
+ uriTemplate: "api://users/{userId}{?fields,format}",
1261
+ });
1262
+
1263
+ server.addTool({
1264
+ description:
1265
+ "Get user data using complex URI templates with path and query parameters",
1266
+ execute: async (args) => {
1267
+ return {
1268
+ content: [
1269
+ {
1270
+ resource: await server.embedded(args.uri),
1271
+ type: "resource",
1272
+ },
1273
+ ],
1274
+ };
1275
+ },
1276
+ name: "get_user_data",
1277
+ parameters: z.object({
1278
+ uri: z.string(),
1279
+ }),
1280
+ });
1281
+
1282
+ return server;
1283
+ },
1284
+ });
1285
+ });
1286
+
1055
1287
  test("adds prompts", async () => {
1056
1288
  await runWithTestServer({
1057
1289
  run: async ({ client }) => {
@@ -3293,6 +3525,359 @@ test("stateless mode health check includes mode indicator", async () => {
3293
3525
  }
3294
3526
  });
3295
3527
 
3528
+ test("stateless mode with valid authentication allows access", async () => {
3529
+ const port = await getRandomPort();
3530
+
3531
+ const server = new FastMCP<{ userId: string }>({
3532
+ authenticate: async () => {
3533
+ // Always authenticate successfully for this test
3534
+ return { userId: "123" };
3535
+ },
3536
+ name: "Test server",
3537
+ version: "1.0.0",
3538
+ });
3539
+
3540
+ server.addTool({
3541
+ description: "Test tool",
3542
+ execute: async () => {
3543
+ return "pong";
3544
+ },
3545
+ name: "ping",
3546
+ parameters: z.object({}),
3547
+ });
3548
+
3549
+ await server.start({
3550
+ httpStream: {
3551
+ port,
3552
+ stateless: true,
3553
+ },
3554
+ transportType: "httpStream",
3555
+ });
3556
+
3557
+ try {
3558
+ const client = new Client(
3559
+ {
3560
+ name: "Test client",
3561
+ version: "1.0.0",
3562
+ },
3563
+ {
3564
+ capabilities: {},
3565
+ },
3566
+ );
3567
+
3568
+ const transport = new StreamableHTTPClientTransport(
3569
+ new URL(`http://localhost:${port}/mcp`),
3570
+ );
3571
+
3572
+ await client.connect(transport);
3573
+
3574
+ const result = await client.callTool({
3575
+ arguments: {},
3576
+ name: "ping",
3577
+ });
3578
+
3579
+ expect(result.content).toEqual([
3580
+ {
3581
+ text: "pong",
3582
+ type: "text",
3583
+ },
3584
+ ]);
3585
+
3586
+ // Server should not track sessions in stateless mode
3587
+ expect(server.sessions.length).toBe(0);
3588
+
3589
+ await client.close();
3590
+ } finally {
3591
+ await server.stop();
3592
+ }
3593
+ });
3594
+
3595
+ test("stateless mode rejects missing Authorization header", async () => {
3596
+ const port = await getRandomPort();
3597
+
3598
+ const server = new FastMCP<{ userId: string }>({
3599
+ authenticate: async (req) => {
3600
+ const authHeader = req.headers.authorization;
3601
+
3602
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
3603
+ throw new Response(null, {
3604
+ status: 401,
3605
+ statusText: "Unauthorized",
3606
+ });
3607
+ }
3608
+
3609
+ return { userId: "123" };
3610
+ },
3611
+ name: "Test server",
3612
+ version: "1.0.0",
3613
+ });
3614
+
3615
+ server.addTool({
3616
+ description: "Test tool",
3617
+ execute: async () => {
3618
+ return "pong";
3619
+ },
3620
+ name: "ping",
3621
+ parameters: z.object({}),
3622
+ });
3623
+
3624
+ await server.start({
3625
+ httpStream: {
3626
+ port,
3627
+ stateless: true,
3628
+ },
3629
+ transportType: "httpStream",
3630
+ });
3631
+
3632
+ try {
3633
+ // Send a raw HTTP request without Authorization header
3634
+ const response = await fetch(`http://localhost:${port}/mcp`, {
3635
+ body: JSON.stringify({
3636
+ id: 1,
3637
+ jsonrpc: "2.0",
3638
+ method: "tools/call",
3639
+ params: {
3640
+ arguments: {},
3641
+ name: "ping",
3642
+ },
3643
+ }),
3644
+ headers: {
3645
+ "Content-Type": "application/json",
3646
+ },
3647
+ method: "POST",
3648
+ });
3649
+
3650
+ expect(response.status).toBe(401);
3651
+
3652
+ const body = (await response.json()) as { error?: { message?: string } };
3653
+ expect(body.error?.message).toContain("Unauthorized");
3654
+ } finally {
3655
+ await server.stop();
3656
+ }
3657
+ });
3658
+
3659
+ test("stateless mode rejects invalid authentication token", async () => {
3660
+ const port = await getRandomPort();
3661
+ const VALID_TOKEN = "valid_jwt_token";
3662
+ const INVALID_TOKEN = "invalid_jwt_token";
3663
+
3664
+ const server = new FastMCP<{ userId: string }>({
3665
+ authenticate: async (req) => {
3666
+ const authHeader = req.headers.authorization;
3667
+
3668
+ if (!authHeader || !authHeader.startsWith("Bearer ")) {
3669
+ throw new Response(null, {
3670
+ status: 401,
3671
+ statusText: "Unauthorized",
3672
+ });
3673
+ }
3674
+
3675
+ const token = authHeader.split(" ")[1];
3676
+
3677
+ if (token === VALID_TOKEN) {
3678
+ return { userId: "123" };
3679
+ }
3680
+
3681
+ throw new Response(null, {
3682
+ status: 401,
3683
+ statusText: "Unauthorized",
3684
+ });
3685
+ },
3686
+ name: "Test server",
3687
+ version: "1.0.0",
3688
+ });
3689
+
3690
+ server.addTool({
3691
+ description: "Test tool",
3692
+ execute: async () => {
3693
+ return "pong";
3694
+ },
3695
+ name: "ping",
3696
+ parameters: z.object({}),
3697
+ });
3698
+
3699
+ await server.start({
3700
+ httpStream: {
3701
+ port,
3702
+ stateless: true,
3703
+ },
3704
+ transportType: "httpStream",
3705
+ });
3706
+
3707
+ try {
3708
+ // Send a raw HTTP request with invalid token
3709
+ const response = await fetch(`http://localhost:${port}/mcp`, {
3710
+ body: JSON.stringify({
3711
+ id: 1,
3712
+ jsonrpc: "2.0",
3713
+ method: "tools/call",
3714
+ params: {
3715
+ arguments: {},
3716
+ name: "ping",
3717
+ },
3718
+ }),
3719
+ headers: {
3720
+ Authorization: `Bearer ${INVALID_TOKEN}`,
3721
+ "Content-Type": "application/json",
3722
+ },
3723
+ method: "POST",
3724
+ });
3725
+
3726
+ expect(response.status).toBe(401);
3727
+
3728
+ const body = (await response.json()) as { error?: { message?: string } };
3729
+ expect(body.error?.message).toContain("Unauthorized");
3730
+ } finally {
3731
+ await server.stop();
3732
+ }
3733
+ });
3734
+
3735
+ test("stateless mode handles authentication function throwing errors", async () => {
3736
+ const port = await getRandomPort();
3737
+
3738
+ const server = new FastMCP<{ userId: string }>({
3739
+ authenticate: async () => {
3740
+ // Simulate an internal error during token validation
3741
+ throw new Error("JWT validation service is down");
3742
+ },
3743
+ name: "Test server",
3744
+ version: "1.0.0",
3745
+ });
3746
+
3747
+ server.addTool({
3748
+ description: "Test tool",
3749
+ execute: async () => {
3750
+ return "pong";
3751
+ },
3752
+ name: "ping",
3753
+ parameters: z.object({}),
3754
+ });
3755
+
3756
+ await server.start({
3757
+ httpStream: {
3758
+ port,
3759
+ stateless: true,
3760
+ },
3761
+ transportType: "httpStream",
3762
+ });
3763
+
3764
+ try {
3765
+ // Send a raw HTTP request
3766
+ const response = await fetch(`http://localhost:${port}/mcp`, {
3767
+ body: JSON.stringify({
3768
+ id: 1,
3769
+ jsonrpc: "2.0",
3770
+ method: "tools/call",
3771
+ params: {
3772
+ arguments: {},
3773
+ name: "ping",
3774
+ },
3775
+ }),
3776
+ headers: {
3777
+ Authorization: "Bearer any_token",
3778
+ "Content-Type": "application/json",
3779
+ },
3780
+ method: "POST",
3781
+ });
3782
+
3783
+ expect(response.status).toBe(401);
3784
+
3785
+ const body = (await response.json()) as { error?: { message?: string } };
3786
+ expect(body.error?.message).toContain("Unauthorized");
3787
+ } finally {
3788
+ await server.stop();
3789
+ }
3790
+ });
3791
+
3792
+ test("stateless mode handles concurrent requests with authentication", async () => {
3793
+ const port = await getRandomPort();
3794
+ let requestCount = 0;
3795
+
3796
+ const server = new FastMCP<{ requestId: number }>({
3797
+ authenticate: async () => {
3798
+ // Track each authentication request
3799
+ requestCount++;
3800
+ return { requestId: requestCount };
3801
+ },
3802
+ name: "Test server",
3803
+ version: "1.0.0",
3804
+ });
3805
+
3806
+ server.addTool({
3807
+ description: "Echo request ID",
3808
+ execute: async (_args, context) => {
3809
+ return `Request ${context.session?.requestId}`;
3810
+ },
3811
+ name: "whoami",
3812
+ parameters: z.object({}),
3813
+ });
3814
+
3815
+ await server.start({
3816
+ httpStream: {
3817
+ port,
3818
+ stateless: true,
3819
+ },
3820
+ transportType: "httpStream",
3821
+ });
3822
+
3823
+ try {
3824
+ // Create two clients to test concurrent stateless requests
3825
+ const client1 = new Client(
3826
+ {
3827
+ name: "Client 1",
3828
+ version: "1.0.0",
3829
+ },
3830
+ {
3831
+ capabilities: {},
3832
+ },
3833
+ );
3834
+
3835
+ const client2 = new Client(
3836
+ {
3837
+ name: "Client 2",
3838
+ version: "1.0.0",
3839
+ },
3840
+ {
3841
+ capabilities: {},
3842
+ },
3843
+ );
3844
+
3845
+ const transport1 = new StreamableHTTPClientTransport(
3846
+ new URL(`http://localhost:${port}/mcp`),
3847
+ );
3848
+
3849
+ const transport2 = new StreamableHTTPClientTransport(
3850
+ new URL(`http://localhost:${port}/mcp`),
3851
+ );
3852
+
3853
+ await client1.connect(transport1);
3854
+ await client2.connect(transport2);
3855
+
3856
+ // Both clients should work independently
3857
+ const result1 = await client1.callTool({
3858
+ arguments: {},
3859
+ name: "whoami",
3860
+ });
3861
+
3862
+ const result2 = await client2.callTool({
3863
+ arguments: {},
3864
+ name: "whoami",
3865
+ });
3866
+
3867
+ // Each request should have been authenticated
3868
+ expect((result1.content as unknown[])[0]).toHaveProperty("text");
3869
+ expect((result2.content as unknown[])[0]).toHaveProperty("text");
3870
+
3871
+ // Server should not track sessions in stateless mode
3872
+ expect(server.sessions.length).toBe(0);
3873
+
3874
+ await client1.close();
3875
+ await client2.close();
3876
+ } finally {
3877
+ await server.stop();
3878
+ }
3879
+ });
3880
+
3296
3881
  test("host configuration works with 0.0.0.0", async () => {
3297
3882
  const port = await getRandomPort();
3298
3883
 
package/src/FastMCP.ts CHANGED
@@ -1987,48 +1987,30 @@ export class FastMCP<
1987
1987
 
1988
1988
  // Try to match against resource templates
1989
1989
  for (const template of this.#resourcesTemplates) {
1990
- // Check if the URI starts with the template base
1991
- const templateBase = template.uriTemplate.split("{")[0];
1992
-
1993
- if (uri.startsWith(templateBase)) {
1994
- const params: Record<string, string> = {};
1995
- const templateParts = template.uriTemplate.split("/");
1996
- const uriParts = uri.split("/");
1997
-
1998
- for (let i = 0; i < templateParts.length; i++) {
1999
- const templatePart = templateParts[i];
2000
-
2001
- if (templatePart?.startsWith("{") && templatePart.endsWith("}")) {
2002
- const paramName = templatePart.slice(1, -1);
2003
- const paramValue = uriParts[i];
2004
-
2005
- if (paramValue) {
2006
- params[paramName] = paramValue;
2007
- }
2008
- }
2009
- }
2010
-
2011
- const result = await template.load(
2012
- params as ResourceTemplateArgumentsToObject<
2013
- typeof template.arguments
2014
- >,
2015
- );
1990
+ const parsedTemplate = parseURITemplate(template.uriTemplate);
1991
+ const params = parsedTemplate.fromUri(uri);
1992
+ if (!params) {
1993
+ continue;
1994
+ }
2016
1995
 
2017
- const resourceData: ResourceContent["resource"] = {
2018
- mimeType: template.mimeType,
2019
- uri,
2020
- };
1996
+ const result = await template.load(
1997
+ params as ResourceTemplateArgumentsToObject<typeof template.arguments>,
1998
+ );
2021
1999
 
2022
- if ("text" in result) {
2023
- resourceData.text = result.text;
2024
- }
2000
+ const resourceData: ResourceContent["resource"] = {
2001
+ mimeType: template.mimeType,
2002
+ uri,
2003
+ };
2025
2004
 
2026
- if ("blob" in result) {
2027
- resourceData.blob = result.blob;
2028
- }
2005
+ if ("text" in result) {
2006
+ resourceData.text = result.text;
2007
+ }
2029
2008
 
2030
- return resourceData; // The resource we're looking for
2009
+ if ("blob" in result) {
2010
+ resourceData.blob = result.blob;
2031
2011
  }
2012
+
2013
+ return resourceData; // The resource we're looking for
2032
2014
  }
2033
2015
 
2034
2016
  throw new UnexpectedStateError(`Resource not found: ${uri}`, { uri });
@@ -2127,11 +2109,18 @@ export class FastMCP<
2127
2109
  );
2128
2110
 
2129
2111
  this.#httpStreamServer = await startHTTPServer<FastMCPSession<T>>({
2112
+ authenticate: this.#authenticate,
2130
2113
  createServer: async (request) => {
2131
2114
  let auth: T | undefined;
2132
2115
 
2133
2116
  if (this.#authenticate) {
2134
2117
  auth = await this.#authenticate(request);
2118
+
2119
+ // In stateless mode, authentication is REQUIRED
2120
+ // mcp-proxy will catch this error and return 401
2121
+ if (auth === undefined || auth === null) {
2122
+ throw new Error("Authentication required");
2123
+ }
2135
2124
  }
2136
2125
 
2137
2126
  // In stateless mode, create a new session for each request
@@ -2161,6 +2150,7 @@ export class FastMCP<
2161
2150
  } else {
2162
2151
  // Regular mode with session management
2163
2152
  this.#httpStreamServer = await startHTTPServer<FastMCPSession<T>>({
2153
+ authenticate: this.#authenticate,
2164
2154
  createServer: async (request) => {
2165
2155
  let auth: T | undefined;
2166
2156
 
@@ -2201,6 +2191,7 @@ export class FastMCP<
2201
2191
  );
2202
2192
  },
2203
2193
  port: httpConfig.port,
2194
+ stateless: httpConfig.stateless,
2204
2195
  streamEndpoint: httpConfig.endpoint,
2205
2196
  });
2206
2197