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.
- fastmcp/client/client.py +79 -12
- fastmcp/client/sampling/__init__.py +69 -0
- fastmcp/client/sampling/handlers/__init__.py +0 -0
- fastmcp/client/sampling/handlers/anthropic.py +387 -0
- fastmcp/client/sampling/handlers/openai.py +399 -0
- fastmcp/client/tasks.py +0 -63
- fastmcp/client/transports.py +35 -16
- fastmcp/experimental/sampling/handlers/__init__.py +5 -0
- fastmcp/experimental/sampling/handlers/openai.py +4 -169
- fastmcp/prompts/prompt.py +5 -5
- fastmcp/prompts/prompt_manager.py +3 -4
- fastmcp/resources/resource.py +4 -4
- fastmcp/resources/resource_manager.py +9 -14
- fastmcp/resources/template.py +5 -5
- fastmcp/server/auth/auth.py +20 -5
- fastmcp/server/auth/oauth_proxy.py +73 -15
- fastmcp/server/auth/providers/supabase.py +11 -6
- fastmcp/server/context.py +448 -113
- fastmcp/server/dependencies.py +5 -0
- fastmcp/server/elicitation.py +7 -3
- fastmcp/server/middleware/error_handling.py +1 -1
- fastmcp/server/openapi/components.py +2 -4
- fastmcp/server/proxy.py +3 -3
- fastmcp/server/sampling/__init__.py +10 -0
- fastmcp/server/sampling/run.py +301 -0
- fastmcp/server/sampling/sampling_tool.py +108 -0
- fastmcp/server/server.py +84 -78
- fastmcp/server/tasks/converters.py +2 -1
- fastmcp/tools/tool.py +8 -6
- fastmcp/tools/tool_manager.py +5 -7
- fastmcp/utilities/cli.py +23 -43
- fastmcp/utilities/json_schema.py +40 -0
- fastmcp/utilities/openapi/schemas.py +4 -4
- {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/METADATA +8 -3
- {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/RECORD +38 -34
- fastmcp/client/sampling.py +0 -56
- fastmcp/experimental/sampling/handlers/base.py +0 -21
- fastmcp/server/sampling/handler.py +0 -19
- {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/WHEEL +0 -0
- {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/entry_points.txt +0 -0
- {fastmcp-2.14.0.dist-info → fastmcp-2.14.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -1,170 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
|
|
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
|
|
13
|
-
from mcp.types import PromptArgument as
|
|
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
|
-
) ->
|
|
92
|
+
) -> SDKPrompt:
|
|
93
93
|
"""Convert the prompt to an MCP prompt."""
|
|
94
94
|
arguments = [
|
|
95
|
-
|
|
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
|
|
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
|
|
111
|
-
|
|
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:
|
fastmcp/resources/resource.py
CHANGED
|
@@ -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
|
|
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
|
-
) ->
|
|
130
|
-
"""Convert the resource to an
|
|
129
|
+
) -> SDKResource:
|
|
130
|
+
"""Convert the resource to an SDKResource."""
|
|
131
131
|
|
|
132
|
-
return
|
|
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
|
|
272
|
-
except
|
|
273
|
-
|
|
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
|
|
303
|
-
except
|
|
304
|
-
|
|
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
|
|
326
|
-
|
|
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}"
|
fastmcp/resources/template.py
CHANGED
|
@@ -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
|
|
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
|
-
) ->
|
|
192
|
-
"""Convert the resource template to an
|
|
191
|
+
) -> SDKResourceTemplate:
|
|
192
|
+
"""Convert the resource template to an SDKResourceTemplate."""
|
|
193
193
|
|
|
194
|
-
return
|
|
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:
|
|
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.
|
fastmcp/server/auth/auth.py
CHANGED
|
@@ -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
|
|
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=
|
|
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
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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.
|
|
1002
|
-
2.
|
|
1003
|
-
3.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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}
|
|
63
|
-
- JWTs are issued by {project_url}
|
|
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}/
|
|
132
|
-
issuer=f"{self.project_url}/
|
|
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}/
|
|
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}/
|
|
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()
|