fastmcp 2.14.0__py3-none-any.whl → 2.14.2__py3-none-any.whl

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.
Files changed (41) hide show
  1. fastmcp/client/client.py +79 -12
  2. fastmcp/client/sampling/__init__.py +69 -0
  3. fastmcp/client/sampling/handlers/__init__.py +0 -0
  4. fastmcp/client/sampling/handlers/anthropic.py +387 -0
  5. fastmcp/client/sampling/handlers/openai.py +399 -0
  6. fastmcp/client/tasks.py +0 -63
  7. fastmcp/client/transports.py +35 -16
  8. fastmcp/experimental/sampling/handlers/__init__.py +5 -0
  9. fastmcp/experimental/sampling/handlers/openai.py +4 -169
  10. fastmcp/prompts/prompt.py +5 -5
  11. fastmcp/prompts/prompt_manager.py +3 -4
  12. fastmcp/resources/resource.py +4 -4
  13. fastmcp/resources/resource_manager.py +9 -14
  14. fastmcp/resources/template.py +5 -5
  15. fastmcp/server/auth/auth.py +20 -5
  16. fastmcp/server/auth/oauth_proxy.py +73 -15
  17. fastmcp/server/auth/providers/supabase.py +11 -6
  18. fastmcp/server/context.py +448 -113
  19. fastmcp/server/dependencies.py +5 -0
  20. fastmcp/server/elicitation.py +7 -3
  21. fastmcp/server/middleware/error_handling.py +1 -1
  22. fastmcp/server/openapi/components.py +2 -4
  23. fastmcp/server/proxy.py +3 -3
  24. fastmcp/server/sampling/__init__.py +10 -0
  25. fastmcp/server/sampling/run.py +301 -0
  26. fastmcp/server/sampling/sampling_tool.py +108 -0
  27. fastmcp/server/server.py +84 -78
  28. fastmcp/server/tasks/converters.py +2 -1
  29. fastmcp/tools/tool.py +8 -6
  30. fastmcp/tools/tool_manager.py +5 -7
  31. fastmcp/utilities/cli.py +23 -43
  32. fastmcp/utilities/json_schema.py +40 -0
  33. fastmcp/utilities/openapi/schemas.py +4 -4
  34. {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/METADATA +8 -3
  35. {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/RECORD +38 -34
  36. fastmcp/client/sampling.py +0 -56
  37. fastmcp/experimental/sampling/handlers/base.py +0 -21
  38. fastmcp/server/sampling/handler.py +0 -19
  39. {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/WHEEL +0 -0
  40. {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/entry_points.txt +0 -0
  41. {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,170 +1,5 @@
1
- from collections.abc import Iterator, Sequence
2
- from typing import get_args
1
+ # Re-export for backwards compatibility
2
+ # The canonical location is now fastmcp.client.sampling.handlers.openai
3
+ from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler
3
4
 
4
- from mcp import ClientSession, ServerSession
5
- from mcp.shared.context import LifespanContextT, RequestContext
6
- from mcp.types import CreateMessageRequestParams as SamplingParams
7
- from mcp.types import (
8
- CreateMessageResult,
9
- ModelPreferences,
10
- SamplingMessage,
11
- TextContent,
12
- )
13
-
14
- try:
15
- from openai import NOT_GIVEN, OpenAI
16
- from openai.types.chat import (
17
- ChatCompletion,
18
- ChatCompletionAssistantMessageParam,
19
- ChatCompletionMessageParam,
20
- ChatCompletionSystemMessageParam,
21
- ChatCompletionUserMessageParam,
22
- )
23
- from openai.types.shared.chat_model import ChatModel
24
- except ImportError as e:
25
- raise ImportError(
26
- "The `openai` package is not installed. Please install `fastmcp[openai]` or add `openai` to your dependencies manually."
27
- ) from e
28
-
29
- from typing_extensions import override
30
-
31
- from fastmcp.experimental.sampling.handlers.base import BaseLLMSamplingHandler
32
-
33
-
34
- class OpenAISamplingHandler(BaseLLMSamplingHandler):
35
- def __init__(self, default_model: ChatModel, client: OpenAI | None = None):
36
- self.client: OpenAI = client or OpenAI()
37
- self.default_model: ChatModel = default_model
38
-
39
- @override
40
- async def __call__(
41
- self,
42
- messages: list[SamplingMessage],
43
- params: SamplingParams,
44
- context: RequestContext[ServerSession, LifespanContextT]
45
- | RequestContext[ClientSession, LifespanContextT],
46
- ) -> CreateMessageResult:
47
- openai_messages: list[ChatCompletionMessageParam] = (
48
- self._convert_to_openai_messages(
49
- system_prompt=params.systemPrompt,
50
- messages=messages,
51
- )
52
- )
53
-
54
- model: ChatModel = self._select_model_from_preferences(params.modelPreferences)
55
-
56
- response = self.client.chat.completions.create(
57
- model=model,
58
- messages=openai_messages,
59
- temperature=params.temperature or NOT_GIVEN,
60
- max_tokens=params.maxTokens,
61
- stop=params.stopSequences or NOT_GIVEN,
62
- )
63
-
64
- return self._chat_completion_to_create_message_result(response)
65
-
66
- @staticmethod
67
- def _iter_models_from_preferences(
68
- model_preferences: ModelPreferences | str | list[str] | None,
69
- ) -> Iterator[str]:
70
- if model_preferences is None:
71
- return
72
-
73
- if isinstance(model_preferences, str) and model_preferences in get_args(
74
- ChatModel
75
- ):
76
- yield model_preferences
77
-
78
- if isinstance(model_preferences, list):
79
- yield from model_preferences
80
-
81
- if isinstance(model_preferences, ModelPreferences):
82
- if not (hints := model_preferences.hints):
83
- return
84
-
85
- for hint in hints:
86
- if not (name := hint.name):
87
- continue
88
-
89
- yield name
90
-
91
- @staticmethod
92
- def _convert_to_openai_messages(
93
- system_prompt: str | None, messages: Sequence[SamplingMessage]
94
- ) -> list[ChatCompletionMessageParam]:
95
- openai_messages: list[ChatCompletionMessageParam] = []
96
-
97
- if system_prompt:
98
- openai_messages.append(
99
- ChatCompletionSystemMessageParam(
100
- role="system",
101
- content=system_prompt,
102
- )
103
- )
104
-
105
- if isinstance(messages, str):
106
- openai_messages.append(
107
- ChatCompletionUserMessageParam(
108
- role="user",
109
- content=messages,
110
- )
111
- )
112
-
113
- if isinstance(messages, list):
114
- for message in messages:
115
- if isinstance(message, str):
116
- openai_messages.append(
117
- ChatCompletionUserMessageParam(
118
- role="user",
119
- content=message,
120
- )
121
- )
122
- continue
123
-
124
- if not isinstance(message.content, TextContent):
125
- raise ValueError("Only text content is supported")
126
-
127
- if message.role == "user":
128
- openai_messages.append(
129
- ChatCompletionUserMessageParam(
130
- role="user",
131
- content=message.content.text,
132
- )
133
- )
134
- else:
135
- openai_messages.append(
136
- ChatCompletionAssistantMessageParam(
137
- role="assistant",
138
- content=message.content.text,
139
- )
140
- )
141
-
142
- return openai_messages
143
-
144
- @staticmethod
145
- def _chat_completion_to_create_message_result(
146
- chat_completion: ChatCompletion,
147
- ) -> CreateMessageResult:
148
- if len(chat_completion.choices) == 0:
149
- raise ValueError("No response for completion")
150
-
151
- first_choice = chat_completion.choices[0]
152
-
153
- if content := first_choice.message.content:
154
- return CreateMessageResult(
155
- content=TextContent(type="text", text=content),
156
- role="assistant",
157
- model=chat_completion.model,
158
- )
159
-
160
- raise ValueError("No content in response from completion")
161
-
162
- def _select_model_from_preferences(
163
- self, model_preferences: ModelPreferences | str | list[str] | None
164
- ) -> ChatModel:
165
- for model_option in self._iter_models_from_preferences(model_preferences):
166
- if model_option in get_args(ChatModel):
167
- chosen_model: ChatModel = model_option # type: ignore[assignment]
168
- return chosen_model
169
-
170
- return self.default_model
5
+ __all__ = ["OpenAISamplingHandler"]
fastmcp/prompts/prompt.py CHANGED
@@ -9,8 +9,8 @@ from typing import Annotated, Any
9
9
 
10
10
  import pydantic_core
11
11
  from mcp.types import ContentBlock, Icon, PromptMessage, Role, TextContent
12
- from mcp.types import Prompt as MCPPrompt
13
- from mcp.types import PromptArgument as MCPPromptArgument
12
+ from mcp.types import Prompt as SDKPrompt
13
+ from mcp.types import PromptArgument as SDKPromptArgument
14
14
  from pydantic import Field, TypeAdapter
15
15
 
16
16
  from fastmcp.exceptions import PromptError
@@ -89,10 +89,10 @@ class Prompt(FastMCPComponent):
89
89
  *,
90
90
  include_fastmcp_meta: bool | None = None,
91
91
  **overrides: Any,
92
- ) -> MCPPrompt:
92
+ ) -> SDKPrompt:
93
93
  """Convert the prompt to an MCP prompt."""
94
94
  arguments = [
95
- MCPPromptArgument(
95
+ SDKPromptArgument(
96
96
  name=arg.name,
97
97
  description=arg.description,
98
98
  required=arg.required,
@@ -100,7 +100,7 @@ class Prompt(FastMCPComponent):
100
100
  for arg in self.arguments or []
101
101
  ]
102
102
 
103
- return MCPPrompt(
103
+ return SDKPrompt(
104
104
  name=overrides.get("name", self.name),
105
105
  description=overrides.get("description", self.description),
106
106
  arguments=arguments,
@@ -7,7 +7,7 @@ from typing import Any
7
7
  from mcp import GetPromptResult
8
8
 
9
9
  from fastmcp import settings
10
- from fastmcp.exceptions import NotFoundError, PromptError
10
+ from fastmcp.exceptions import FastMCPError, NotFoundError, PromptError
11
11
  from fastmcp.prompts.prompt import FunctionPrompt, Prompt, PromptResult
12
12
  from fastmcp.settings import DuplicateBehavior
13
13
  from fastmcp.utilities.logging import get_logger
@@ -107,9 +107,8 @@ class PromptManager:
107
107
  try:
108
108
  messages = await prompt.render(arguments)
109
109
  return GetPromptResult(description=prompt.description, messages=messages)
110
- except PromptError as e:
111
- logger.exception(f"Error rendering prompt {name!r}")
112
- raise e
110
+ except FastMCPError:
111
+ raise
113
112
  except Exception as e:
114
113
  logger.exception(f"Error rendering prompt {name!r}")
115
114
  if self.mask_error_details:
@@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Annotated, Any
8
8
 
9
9
  import pydantic_core
10
10
  from mcp.types import Annotations, Icon
11
- from mcp.types import Resource as MCPResource
11
+ from mcp.types import Resource as SDKResource
12
12
  from pydantic import (
13
13
  AnyUrl,
14
14
  ConfigDict,
@@ -126,10 +126,10 @@ class Resource(FastMCPComponent):
126
126
  *,
127
127
  include_fastmcp_meta: bool | None = None,
128
128
  **overrides: Any,
129
- ) -> MCPResource:
130
- """Convert the resource to an MCPResource."""
129
+ ) -> SDKResource:
130
+ """Convert the resource to an SDKResource."""
131
131
 
132
- return MCPResource(
132
+ return SDKResource(
133
133
  name=overrides.get("name", self.name),
134
134
  uri=overrides.get("uri", self.uri),
135
135
  description=overrides.get("description", self.description),
@@ -10,7 +10,7 @@ from typing import Any
10
10
  from pydantic import AnyUrl
11
11
 
12
12
  from fastmcp import settings
13
- from fastmcp.exceptions import NotFoundError, ResourceError
13
+ from fastmcp.exceptions import FastMCPError, NotFoundError, ResourceError
14
14
  from fastmcp.resources.resource import Resource
15
15
  from fastmcp.resources.template import (
16
16
  ResourceTemplate,
@@ -268,10 +268,9 @@ class ResourceManager:
268
268
  uri_str,
269
269
  params=params,
270
270
  )
271
- # Pass through ResourceErrors as-is
272
- except ResourceError as e:
273
- logger.error(f"Error creating resource from template: {e}")
274
- raise e
271
+ # Pass through FastMCPErrors as-is
272
+ except FastMCPError:
273
+ raise
275
274
  # Handle other exceptions
276
275
  except Exception as e:
277
276
  logger.error(f"Error creating resource from template: {e}")
@@ -299,10 +298,9 @@ class ResourceManager:
299
298
  try:
300
299
  return await resource.read()
301
300
 
302
- # raise ResourceErrors as-is
303
- except ResourceError as e:
304
- logger.exception(f"Error reading resource {uri_str!r}")
305
- raise e
301
+ # raise FastMCPErrors as-is
302
+ except FastMCPError:
303
+ raise
306
304
 
307
305
  # Handle other exceptions
308
306
  except Exception as e:
@@ -322,11 +320,8 @@ class ResourceManager:
322
320
  try:
323
321
  resource = await template.create_resource(uri_str, params=params)
324
322
  return await resource.read()
325
- except ResourceError as e:
326
- logger.exception(
327
- f"Error reading resource from template {uri_str!r}"
328
- )
329
- raise e
323
+ except FastMCPError:
324
+ raise
330
325
  except Exception as e:
331
326
  logger.exception(
332
327
  f"Error reading resource from template {uri_str!r}"
@@ -9,7 +9,7 @@ from typing import Annotated, Any
9
9
  from urllib.parse import parse_qs, unquote
10
10
 
11
11
  from mcp.types import Annotations, Icon
12
- from mcp.types import ResourceTemplate as MCPResourceTemplate
12
+ from mcp.types import ResourceTemplate as SDKResourceTemplate
13
13
  from pydantic import (
14
14
  Field,
15
15
  field_validator,
@@ -188,10 +188,10 @@ class ResourceTemplate(FastMCPComponent):
188
188
  *,
189
189
  include_fastmcp_meta: bool | None = None,
190
190
  **overrides: Any,
191
- ) -> MCPResourceTemplate:
192
- """Convert the resource template to an MCPResourceTemplate."""
191
+ ) -> SDKResourceTemplate:
192
+ """Convert the resource template to an SDKResourceTemplate."""
193
193
 
194
- return MCPResourceTemplate(
194
+ return SDKResourceTemplate(
195
195
  name=overrides.get("name", self.name),
196
196
  uriTemplate=overrides.get("uriTemplate", self.uri_template),
197
197
  description=overrides.get("description", self.description),
@@ -205,7 +205,7 @@ class ResourceTemplate(FastMCPComponent):
205
205
  )
206
206
 
207
207
  @classmethod
208
- def from_mcp_template(cls, mcp_template: MCPResourceTemplate) -> ResourceTemplate:
208
+ def from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplate:
209
209
  """Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object."""
210
210
  # Note: This creates a simple ResourceTemplate instance. For function-based templates,
211
211
  # the original function is lost, which is expected for remote templates.
@@ -114,6 +114,8 @@ class AuthProvider(TokenVerifierProtocol):
114
114
  base_url = AnyHttpUrl(base_url)
115
115
  self.base_url = base_url
116
116
  self.required_scopes = required_scopes or []
117
+ self._mcp_path: str | None = None
118
+ self._resource_url: AnyHttpUrl | None = None
117
119
 
118
120
  async def verify_token(self, token: str) -> AccessToken | None:
119
121
  """Verify a bearer token and return access info if valid.
@@ -128,6 +130,20 @@ class AuthProvider(TokenVerifierProtocol):
128
130
  """
129
131
  raise NotImplementedError("Subclasses must implement verify_token")
130
132
 
133
+ def set_mcp_path(self, mcp_path: str | None) -> None:
134
+ """Set the MCP endpoint path and compute resource URL.
135
+
136
+ This method is called by get_routes() to configure the expected
137
+ resource URL before route creation. Subclasses can override to
138
+ perform additional initialization that depends on knowing the
139
+ MCP endpoint path.
140
+
141
+ Args:
142
+ mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
143
+ """
144
+ self._mcp_path = mcp_path
145
+ self._resource_url = self._get_resource_url(mcp_path)
146
+
131
147
  def get_routes(
132
148
  self,
133
149
  mcp_path: str | None = None,
@@ -407,6 +423,8 @@ class OAuthProvider(
407
423
  Returns:
408
424
  List of OAuth routes
409
425
  """
426
+ # Configure resource URL before creating routes
427
+ self.set_mcp_path(mcp_path)
410
428
 
411
429
  # Create standard OAuth authorization server routes
412
430
  # Pass base_url as issuer_url to ensure metadata declares endpoints where
@@ -451,11 +469,8 @@ class OAuthProvider(
451
469
  else:
452
470
  oauth_routes.append(route)
453
471
 
454
- # Get the resource URL based on the MCP path
455
- resource_url = self._get_resource_url(mcp_path)
456
-
457
472
  # Add protected resource routes if this server is also acting as a resource server
458
- if resource_url:
473
+ if self._resource_url:
459
474
  supported_scopes = (
460
475
  self.client_registration_options.valid_scopes
461
476
  if self.client_registration_options
@@ -463,7 +478,7 @@ class OAuthProvider(
463
478
  else self.required_scopes
464
479
  )
465
480
  protected_routes = create_protected_resource_routes(
466
- resource_url=resource_url,
481
+ resource_url=self._resource_url,
467
482
  authorization_servers=[cast(AnyHttpUrl, self.issuer_url)],
468
483
  scopes_supported=supported_scopes,
469
484
  )
@@ -34,7 +34,6 @@ from authlib.integrations.httpx_client import AsyncOAuth2Client
34
34
  from cryptography.fernet import Fernet
35
35
  from key_value.aio.adapters.pydantic import PydanticAdapter
36
36
  from key_value.aio.protocols import AsyncKeyValue
37
- from key_value.aio.stores.disk import DiskStore
38
37
  from key_value.aio.wrappers.encryption import FernetEncryptionWrapper
39
38
  from mcp.server.auth.provider import (
40
39
  AccessToken,
@@ -805,14 +804,16 @@ class OAuthProxy(OAuthProvider):
805
804
  salt="fastmcp-jwt-signing-key",
806
805
  )
807
806
 
808
- self._jwt_issuer: JWTIssuer = JWTIssuer(
809
- issuer=str(self.base_url),
810
- audience=f"{str(self.base_url).rstrip('/')}/mcp",
811
- signing_key=jwt_signing_key,
812
- )
807
+ # Store JWT signing key for deferred JWTIssuer creation in set_mcp_path()
808
+ self._jwt_signing_key: bytes = jwt_signing_key
809
+ # JWTIssuer will be created in set_mcp_path() with correct audience
810
+ self._jwt_issuer: JWTIssuer | None = None
813
811
 
814
812
  # If the user does not provide a store, we will provide an encrypted disk store
815
813
  if client_storage is None:
814
+ # Import lazily to avoid sqlite3 dependency when not using OAuthProxy
815
+ from key_value.aio.stores.disk import DiskStore
816
+
816
817
  storage_encryption_key = derive_jwt_key(
817
818
  high_entropy_material=jwt_signing_key.decode(),
818
819
  salt="fastmcp-storage-encryption-key",
@@ -897,6 +898,47 @@ class OAuthProxy(OAuthProvider):
897
898
  self._upstream_authorization_endpoint,
898
899
  )
899
900
 
901
+ # -------------------------------------------------------------------------
902
+ # MCP Path Configuration
903
+ # -------------------------------------------------------------------------
904
+
905
+ def set_mcp_path(self, mcp_path: str | None) -> None:
906
+ """Set the MCP endpoint path and create JWTIssuer with correct audience.
907
+
908
+ This method is called by get_routes() to configure the resource URL
909
+ and create the JWTIssuer. The JWT audience is set to the full resource
910
+ URL (e.g., http://localhost:8000/mcp) to ensure tokens are bound to
911
+ this specific MCP endpoint.
912
+
913
+ Args:
914
+ mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp")
915
+ """
916
+ super().set_mcp_path(mcp_path)
917
+
918
+ # Create JWT issuer with correct audience based on actual MCP path
919
+ # This ensures tokens are bound to the specific resource URL
920
+ self._jwt_issuer = JWTIssuer(
921
+ issuer=str(self.base_url),
922
+ audience=str(self._resource_url),
923
+ signing_key=self._jwt_signing_key,
924
+ )
925
+
926
+ logger.debug("Configured OAuth proxy for resource URL: %s", self._resource_url)
927
+
928
+ @property
929
+ def jwt_issuer(self) -> JWTIssuer:
930
+ """Get the JWT issuer, ensuring it has been initialized.
931
+
932
+ The JWT issuer is created when set_mcp_path() is called (via get_routes()).
933
+ This property ensures a clear error if used before initialization.
934
+ """
935
+ if self._jwt_issuer is None:
936
+ raise RuntimeError(
937
+ "JWT issuer not initialized. Ensure get_routes() is called "
938
+ "before token operations."
939
+ )
940
+ return self._jwt_issuer
941
+
900
942
  # -------------------------------------------------------------------------
901
943
  # PKCE Helper Methods
902
944
  # -------------------------------------------------------------------------
@@ -998,13 +1040,29 @@ class OAuthProxy(OAuthProvider):
998
1040
  """Start OAuth transaction and route through consent interstitial.
999
1041
 
1000
1042
  Flow:
1001
- 1. Store transaction with client details and PKCE (if forwarding)
1002
- 2. Return local /consent URL; browser visits consent first
1003
- 3. Consent handler redirects to upstream IdP if approved/already approved
1043
+ 1. Validate client's resource matches server's resource URL (security check)
1044
+ 2. Store transaction with client details and PKCE (if forwarding)
1045
+ 3. Return local /consent URL; browser visits consent first
1046
+ 4. Consent handler redirects to upstream IdP if approved/already approved
1004
1047
 
1005
1048
  If consent is disabled (require_authorization_consent=False), skip the consent screen
1006
1049
  and redirect directly to the upstream IdP.
1007
1050
  """
1051
+ # Security check: validate client's requested resource matches this server
1052
+ # This prevents tokens intended for one server from being used on another
1053
+ client_resource = getattr(params, "resource", None)
1054
+ if client_resource and self._resource_url:
1055
+ if str(client_resource) != str(self._resource_url):
1056
+ logger.warning(
1057
+ "Resource mismatch: client requested %s but server is %s",
1058
+ client_resource,
1059
+ self._resource_url,
1060
+ )
1061
+ raise AuthorizeError(
1062
+ error="invalid_target", # type: ignore[arg-type]
1063
+ error_description="Resource does not match this server",
1064
+ )
1065
+
1008
1066
  # Generate transaction ID for this authorization request
1009
1067
  txn_id = secrets.token_urlsafe(32)
1010
1068
 
@@ -1216,7 +1274,7 @@ class OAuthProxy(OAuthProvider):
1216
1274
  # Issue minimal FastMCP access token (just a reference via JTI)
1217
1275
  if client.client_id is None:
1218
1276
  raise TokenError("invalid_client", "Client ID is required")
1219
- fastmcp_access_token = self._jwt_issuer.issue_access_token(
1277
+ fastmcp_access_token = self.jwt_issuer.issue_access_token(
1220
1278
  client_id=client.client_id,
1221
1279
  scopes=authorization_code.scopes,
1222
1280
  jti=access_jti,
@@ -1227,7 +1285,7 @@ class OAuthProxy(OAuthProvider):
1227
1285
  # Use upstream refresh token expiry to align lifetimes
1228
1286
  fastmcp_refresh_token = None
1229
1287
  if refresh_jti and refresh_expires_in:
1230
- fastmcp_refresh_token = self._jwt_issuer.issue_refresh_token(
1288
+ fastmcp_refresh_token = self.jwt_issuer.issue_refresh_token(
1231
1289
  client_id=client.client_id,
1232
1290
  scopes=authorization_code.scopes,
1233
1291
  jti=refresh_jti,
@@ -1352,7 +1410,7 @@ class OAuthProxy(OAuthProvider):
1352
1410
  """
1353
1411
  # Verify FastMCP refresh token
1354
1412
  try:
1355
- refresh_payload = self._jwt_issuer.verify_token(refresh_token.token)
1413
+ refresh_payload = self.jwt_issuer.verify_token(refresh_token.token)
1356
1414
  refresh_jti = refresh_payload["jti"]
1357
1415
  except Exception as e:
1358
1416
  logger.debug("FastMCP refresh token validation failed: %s", e)
@@ -1461,7 +1519,7 @@ class OAuthProxy(OAuthProvider):
1461
1519
  if client.client_id is None:
1462
1520
  raise TokenError("invalid_client", "Client ID is required")
1463
1521
  new_access_jti = secrets.token_urlsafe(32)
1464
- new_fastmcp_access = self._jwt_issuer.issue_access_token(
1522
+ new_fastmcp_access = self.jwt_issuer.issue_access_token(
1465
1523
  client_id=client.client_id,
1466
1524
  scopes=scopes,
1467
1525
  jti=new_access_jti,
@@ -1482,7 +1540,7 @@ class OAuthProxy(OAuthProvider):
1482
1540
  # Issue NEW minimal FastMCP refresh token (rotation for security)
1483
1541
  # Use upstream refresh token expiry to align lifetimes
1484
1542
  new_refresh_jti = secrets.token_urlsafe(32)
1485
- new_fastmcp_refresh = self._jwt_issuer.issue_refresh_token(
1543
+ new_fastmcp_refresh = self.jwt_issuer.issue_refresh_token(
1486
1544
  client_id=client.client_id,
1487
1545
  scopes=scopes,
1488
1546
  jti=new_refresh_jti,
@@ -1558,7 +1616,7 @@ class OAuthProxy(OAuthProvider):
1558
1616
  """
1559
1617
  try:
1560
1618
  # 1. Verify FastMCP JWT signature and claims
1561
- payload = self._jwt_issuer.verify_token(token)
1619
+ payload = self.jwt_issuer.verify_token(token)
1562
1620
  jti = payload["jti"]
1563
1621
 
1564
1622
  # 2. Look up upstream token via JTI mapping
@@ -34,6 +34,7 @@ class SupabaseProviderSettings(BaseSettings):
34
34
 
35
35
  project_url: AnyHttpUrl
36
36
  base_url: AnyHttpUrl
37
+ auth_route: str = "/auth/v1"
37
38
  algorithm: Literal["HS256", "RS256", "ES256"] = "ES256"
38
39
  required_scopes: list[str] | None = None
39
40
 
@@ -59,8 +60,8 @@ class SupabaseProvider(RemoteAuthProvider):
59
60
  - Asymmetric keys (RS256/ES256) are recommended for production
60
61
 
61
62
  2. JWT Verification:
62
- - FastMCP verifies JWTs using the JWKS endpoint at {project_url}/auth/v1/.well-known/jwks.json
63
- - JWTs are issued by {project_url}/auth/v1
63
+ - FastMCP verifies JWTs using the JWKS endpoint at {project_url}{auth_route}/.well-known/jwks.json
64
+ - JWTs are issued by {project_url}{auth_route}
64
65
  - Tokens are cached for up to 10 minutes by Supabase's edge servers
65
66
  - Algorithm must match your Supabase Auth configuration
66
67
 
@@ -93,6 +94,7 @@ class SupabaseProvider(RemoteAuthProvider):
93
94
  *,
94
95
  project_url: AnyHttpUrl | str | NotSetT = NotSet,
95
96
  base_url: AnyHttpUrl | str | NotSetT = NotSet,
97
+ auth_route: str | NotSetT = NotSet,
96
98
  algorithm: Literal["HS256", "RS256", "ES256"] | NotSetT = NotSet,
97
99
  required_scopes: list[str] | NotSetT | None = NotSet,
98
100
  token_verifier: TokenVerifier | None = None,
@@ -102,6 +104,7 @@ class SupabaseProvider(RemoteAuthProvider):
102
104
  Args:
103
105
  project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co")
104
106
  base_url: Public URL of this FastMCP server
107
+ auth_route: Supabase Auth route. Defaults to "/auth/v1".
105
108
  algorithm: JWT signing algorithm (HS256, RS256, or ES256). Must match your
106
109
  Supabase Auth configuration. Defaults to ES256.
107
110
  required_scopes: Optional list of scopes to require for all requests.
@@ -115,6 +118,7 @@ class SupabaseProvider(RemoteAuthProvider):
115
118
  for k, v in {
116
119
  "project_url": project_url,
117
120
  "base_url": base_url,
121
+ "auth_route": auth_route,
118
122
  "algorithm": algorithm,
119
123
  "required_scopes": required_scopes,
120
124
  }.items()
@@ -124,12 +128,13 @@ class SupabaseProvider(RemoteAuthProvider):
124
128
 
125
129
  self.project_url = str(settings.project_url).rstrip("/")
126
130
  self.base_url = AnyHttpUrl(str(settings.base_url).rstrip("/"))
131
+ self.auth_route = settings.auth_route.strip("/")
127
132
 
128
133
  # Create default JWT verifier if none provided
129
134
  if token_verifier is None:
130
135
  token_verifier = JWTVerifier(
131
- jwks_uri=f"{self.project_url}/auth/v1/.well-known/jwks.json",
132
- issuer=f"{self.project_url}/auth/v1",
136
+ jwks_uri=f"{self.project_url}/{self.auth_route}/.well-known/jwks.json",
137
+ issuer=f"{self.project_url}/{self.auth_route}",
133
138
  algorithm=settings.algorithm,
134
139
  required_scopes=settings.required_scopes,
135
140
  )
@@ -137,7 +142,7 @@ class SupabaseProvider(RemoteAuthProvider):
137
142
  # Initialize RemoteAuthProvider with Supabase as the authorization server
138
143
  super().__init__(
139
144
  token_verifier=token_verifier,
140
- authorization_servers=[AnyHttpUrl(f"{self.project_url}/auth/v1")],
145
+ authorization_servers=[AnyHttpUrl(f"{self.project_url}/{self.auth_route}")],
141
146
  base_url=self.base_url,
142
147
  )
143
148
 
@@ -162,7 +167,7 @@ class SupabaseProvider(RemoteAuthProvider):
162
167
  try:
163
168
  async with httpx.AsyncClient() as client:
164
169
  response = await client.get(
165
- f"{self.project_url}/auth/v1/.well-known/oauth-authorization-server"
170
+ f"{self.project_url}/{self.auth_route}/.well-known/oauth-authorization-server"
166
171
  )
167
172
  response.raise_for_status()
168
173
  metadata = response.json()