Rename FastMCP to MCPServer (#1951)
This commit is contained in:
parent
4a2d83a0cb
commit
65c614e48e
24 changed files with 713 additions and 693 deletions
|
|
@ -1,5 +1,5 @@
|
|||
from .fastmcp import FastMCP
|
||||
from .lowlevel import NotificationOptions, Server
|
||||
from .mcpserver import MCPServer
|
||||
from .models import InitializationOptions
|
||||
|
||||
__all__ = ["Server", "FastMCP", "NotificationOptions", "InitializationOptions"]
|
||||
__all__ = ["Server", "MCPServer", "NotificationOptions", "InitializationOptions"]
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ class TokenVerifier(Protocol):
|
|||
"""Verify a bearer token and return access info if valid."""
|
||||
|
||||
|
||||
# NOTE: FastMCP doesn't render any of these types in the user response, so it's
|
||||
# NOTE: MCPServer doesn't render any of these types in the user response, so it's
|
||||
# OK to add fields to subclasses which should not be exposed externally.
|
||||
AuthorizationCodeT = TypeVar("AuthorizationCodeT", bound=AuthorizationCode)
|
||||
RefreshTokenT = TypeVar("RefreshTokenT", bound=RefreshToken)
|
||||
|
|
|
|||
|
|
@ -1,8 +0,0 @@
|
|||
"""FastMCP - A more ergonomic interface for MCP servers."""
|
||||
|
||||
from mcp.types import Icon
|
||||
|
||||
from .server import Context, FastMCP
|
||||
from .utilities.types import Audio, Image
|
||||
|
||||
__all__ = ["FastMCP", "Context", "Image", "Audio", "Icon"]
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
"""Custom exceptions for FastMCP."""
|
||||
|
||||
|
||||
class FastMCPError(Exception):
|
||||
"""Base error for FastMCP."""
|
||||
|
||||
|
||||
class ValidationError(FastMCPError):
|
||||
"""Error in validating parameters or return values."""
|
||||
|
||||
|
||||
class ResourceError(FastMCPError):
|
||||
"""Error in resource operations."""
|
||||
|
||||
|
||||
class ToolError(FastMCPError):
|
||||
"""Error in tool operations."""
|
||||
|
||||
|
||||
class InvalidSignature(Exception):
|
||||
"""Invalid signature for use with FastMCP."""
|
||||
|
|
@ -1 +0,0 @@
|
|||
"""FastMCP utility modules."""
|
||||
8
src/mcp/server/mcpserver/__init__.py
Normal file
8
src/mcp/server/mcpserver/__init__.py
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
"""MCPServer - A more ergonomic interface for MCP servers."""
|
||||
|
||||
from mcp.types import Icon
|
||||
|
||||
from .server import Context, MCPServer
|
||||
from .utilities.types import Audio, Image
|
||||
|
||||
__all__ = ["MCPServer", "Context", "Image", "Audio", "Icon"]
|
||||
21
src/mcp/server/mcpserver/exceptions.py
Normal file
21
src/mcp/server/mcpserver/exceptions.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Custom exceptions for MCPServer."""
|
||||
|
||||
|
||||
class MCPServerError(Exception):
|
||||
"""Base error for MCPServer."""
|
||||
|
||||
|
||||
class ValidationError(MCPServerError):
|
||||
"""Error in validating parameters or return values."""
|
||||
|
||||
|
||||
class ResourceError(MCPServerError):
|
||||
"""Error in resource operations."""
|
||||
|
||||
|
||||
class ToolError(MCPServerError):
|
||||
"""Error in tool operations."""
|
||||
|
||||
|
||||
class InvalidSignature(Exception):
|
||||
"""Invalid signature for use with MCPServer."""
|
||||
4
src/mcp/server/mcpserver/prompts/__init__.py
Normal file
4
src/mcp/server/mcpserver/prompts/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .base import Prompt
|
||||
from .manager import PromptManager
|
||||
|
||||
__all__ = ["Prompt", "PromptManager"]
|
||||
183
src/mcp/server/mcpserver/prompts/base.py
Normal file
183
src/mcp/server/mcpserver/prompts/base.py
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
"""Base classes for MCPServer prompts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from collections.abc import Awaitable, Callable, Sequence
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import pydantic_core
|
||||
from pydantic import BaseModel, Field, TypeAdapter, validate_call
|
||||
|
||||
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
|
||||
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
|
||||
from mcp.types import ContentBlock, Icon, TextContent
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.mcpserver.server import Context
|
||||
from mcp.server.session import ServerSessionT
|
||||
from mcp.shared.context import LifespanContextT, RequestT
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
"""Base class for all prompt messages."""
|
||||
|
||||
role: Literal["user", "assistant"]
|
||||
content: ContentBlock
|
||||
|
||||
def __init__(self, content: str | ContentBlock, **kwargs: Any):
|
||||
if isinstance(content, str):
|
||||
content = TextContent(type="text", text=content)
|
||||
super().__init__(content=content, **kwargs)
|
||||
|
||||
|
||||
class UserMessage(Message):
|
||||
"""A message from the user."""
|
||||
|
||||
role: Literal["user", "assistant"] = "user"
|
||||
|
||||
def __init__(self, content: str | ContentBlock, **kwargs: Any):
|
||||
super().__init__(content=content, **kwargs)
|
||||
|
||||
|
||||
class AssistantMessage(Message):
|
||||
"""A message from the assistant."""
|
||||
|
||||
role: Literal["user", "assistant"] = "assistant"
|
||||
|
||||
def __init__(self, content: str | ContentBlock, **kwargs: Any):
|
||||
super().__init__(content=content, **kwargs)
|
||||
|
||||
|
||||
message_validator = TypeAdapter[UserMessage | AssistantMessage](UserMessage | AssistantMessage)
|
||||
|
||||
SyncPromptResult = str | Message | dict[str, Any] | Sequence[str | Message | dict[str, Any]]
|
||||
PromptResult = SyncPromptResult | Awaitable[SyncPromptResult]
|
||||
|
||||
|
||||
class PromptArgument(BaseModel):
|
||||
"""An argument that can be passed to a prompt."""
|
||||
|
||||
name: str = Field(description="Name of the argument")
|
||||
description: str | None = Field(None, description="Description of what the argument does")
|
||||
required: bool = Field(default=False, description="Whether the argument is required")
|
||||
|
||||
|
||||
class Prompt(BaseModel):
|
||||
"""A prompt template that can be rendered with parameters."""
|
||||
|
||||
name: str = Field(description="Name of the prompt")
|
||||
title: str | None = Field(None, description="Human-readable title of the prompt")
|
||||
description: str | None = Field(None, description="Description of what the prompt does")
|
||||
arguments: list[PromptArgument] | None = Field(None, description="Arguments that can be passed to the prompt")
|
||||
fn: Callable[..., PromptResult | Awaitable[PromptResult]] = Field(exclude=True)
|
||||
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this prompt")
|
||||
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context", exclude=True)
|
||||
|
||||
@classmethod
|
||||
def from_function(
|
||||
cls,
|
||||
fn: Callable[..., PromptResult | Awaitable[PromptResult]],
|
||||
name: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
icons: list[Icon] | None = None,
|
||||
context_kwarg: str | None = None,
|
||||
) -> Prompt:
|
||||
"""Create a Prompt from a function.
|
||||
|
||||
The function can return:
|
||||
- A string (converted to a message)
|
||||
- A Message object
|
||||
- A dict (converted to a message)
|
||||
- A sequence of any of the above
|
||||
"""
|
||||
func_name = name or fn.__name__
|
||||
|
||||
if func_name == "<lambda>": # pragma: no cover
|
||||
raise ValueError("You must provide a name for lambda functions")
|
||||
|
||||
# Find context parameter if it exists
|
||||
if context_kwarg is None: # pragma: no branch
|
||||
context_kwarg = find_context_parameter(fn)
|
||||
|
||||
# Get schema from func_metadata, excluding context parameter
|
||||
func_arg_metadata = func_metadata(
|
||||
fn,
|
||||
skip_names=[context_kwarg] if context_kwarg is not None else [],
|
||||
)
|
||||
parameters = func_arg_metadata.arg_model.model_json_schema()
|
||||
|
||||
# Convert parameters to PromptArguments
|
||||
arguments: list[PromptArgument] = []
|
||||
if "properties" in parameters: # pragma: no branch
|
||||
for param_name, param in parameters["properties"].items():
|
||||
required = param_name in parameters.get("required", [])
|
||||
arguments.append(
|
||||
PromptArgument(
|
||||
name=param_name,
|
||||
description=param.get("description"),
|
||||
required=required,
|
||||
)
|
||||
)
|
||||
|
||||
# ensure the arguments are properly cast
|
||||
fn = validate_call(fn)
|
||||
|
||||
return cls(
|
||||
name=func_name,
|
||||
title=title,
|
||||
description=description or fn.__doc__ or "",
|
||||
arguments=arguments,
|
||||
fn=fn,
|
||||
icons=icons,
|
||||
context_kwarg=context_kwarg,
|
||||
)
|
||||
|
||||
async def render(
|
||||
self,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
|
||||
) -> list[Message]:
|
||||
"""Render the prompt with arguments."""
|
||||
# Validate required arguments
|
||||
if self.arguments:
|
||||
required = {arg.name for arg in self.arguments if arg.required}
|
||||
provided = set(arguments or {})
|
||||
missing = required - provided
|
||||
if missing:
|
||||
raise ValueError(f"Missing required arguments: {missing}")
|
||||
|
||||
try:
|
||||
# Add context to arguments if needed
|
||||
call_args = inject_context(self.fn, arguments or {}, context, self.context_kwarg)
|
||||
|
||||
# Call function and check if result is a coroutine
|
||||
result = self.fn(**call_args)
|
||||
if inspect.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
# Validate messages
|
||||
if not isinstance(result, list | tuple):
|
||||
result = [result]
|
||||
|
||||
# Convert result to messages
|
||||
messages: list[Message] = []
|
||||
for msg in result: # type: ignore[reportUnknownVariableType]
|
||||
try:
|
||||
if isinstance(msg, Message):
|
||||
messages.append(msg)
|
||||
elif isinstance(msg, dict):
|
||||
messages.append(message_validator.validate_python(msg))
|
||||
elif isinstance(msg, str):
|
||||
content = TextContent(type="text", text=msg)
|
||||
messages.append(UserMessage(content=content))
|
||||
else: # pragma: no cover
|
||||
content = pydantic_core.to_json(msg, fallback=str, indent=2).decode()
|
||||
messages.append(Message(role="user", content=content))
|
||||
except Exception: # pragma: no cover
|
||||
raise ValueError(f"Could not convert prompt result to message: {msg}")
|
||||
|
||||
return messages
|
||||
except Exception as e: # pragma: no cover
|
||||
raise ValueError(f"Error rendering prompt {self.name}: {e}")
|
||||
60
src/mcp/server/mcpserver/prompts/manager.py
Normal file
60
src/mcp/server/mcpserver/prompts/manager.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Prompt management functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mcp.server.mcpserver.prompts.base import Message, Prompt
|
||||
from mcp.server.mcpserver.utilities.logging import get_logger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.mcpserver.server import Context
|
||||
from mcp.server.session import ServerSessionT
|
||||
from mcp.shared.context import LifespanContextT, RequestT
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class PromptManager:
|
||||
"""Manages MCPServer prompts."""
|
||||
|
||||
def __init__(self, warn_on_duplicate_prompts: bool = True):
|
||||
self._prompts: dict[str, Prompt] = {}
|
||||
self.warn_on_duplicate_prompts = warn_on_duplicate_prompts
|
||||
|
||||
def get_prompt(self, name: str) -> Prompt | None:
|
||||
"""Get prompt by name."""
|
||||
return self._prompts.get(name)
|
||||
|
||||
def list_prompts(self) -> list[Prompt]:
|
||||
"""List all registered prompts."""
|
||||
return list(self._prompts.values())
|
||||
|
||||
def add_prompt(
|
||||
self,
|
||||
prompt: Prompt,
|
||||
) -> Prompt:
|
||||
"""Add a prompt to the manager."""
|
||||
|
||||
# Check for duplicates
|
||||
existing = self._prompts.get(prompt.name)
|
||||
if existing:
|
||||
if self.warn_on_duplicate_prompts:
|
||||
logger.warning(f"Prompt already exists: {prompt.name}")
|
||||
return existing
|
||||
|
||||
self._prompts[prompt.name] = prompt
|
||||
return prompt
|
||||
|
||||
async def render_prompt(
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
|
||||
) -> list[Message]:
|
||||
"""Render a prompt by name with arguments."""
|
||||
prompt = self.get_prompt(name)
|
||||
if not prompt:
|
||||
raise ValueError(f"Unknown prompt: {name}")
|
||||
|
||||
return await prompt.render(arguments, context=context)
|
||||
23
src/mcp/server/mcpserver/resources/__init__.py
Normal file
23
src/mcp/server/mcpserver/resources/__init__.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
from .base import Resource
|
||||
from .resource_manager import ResourceManager
|
||||
from .templates import ResourceTemplate
|
||||
from .types import (
|
||||
BinaryResource,
|
||||
DirectoryResource,
|
||||
FileResource,
|
||||
FunctionResource,
|
||||
HttpResource,
|
||||
TextResource,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Resource",
|
||||
"TextResource",
|
||||
"BinaryResource",
|
||||
"FunctionResource",
|
||||
"FileResource",
|
||||
"HttpResource",
|
||||
"DirectoryResource",
|
||||
"ResourceTemplate",
|
||||
"ResourceManager",
|
||||
]
|
||||
48
src/mcp/server/mcpserver/resources/base.py
Normal file
48
src/mcp/server/mcpserver/resources/base.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
"""Base classes and interfaces for MCPServer resources."""
|
||||
|
||||
import abc
|
||||
from typing import Any
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
ValidationInfo,
|
||||
field_validator,
|
||||
)
|
||||
|
||||
from mcp.types import Annotations, Icon
|
||||
|
||||
|
||||
class Resource(BaseModel, abc.ABC):
|
||||
"""Base class for all resources."""
|
||||
|
||||
model_config = ConfigDict(validate_default=True)
|
||||
|
||||
uri: str = Field(default=..., description="URI of the resource")
|
||||
name: str | None = Field(description="Name of the resource", default=None)
|
||||
title: str | None = Field(description="Human-readable title of the resource", default=None)
|
||||
description: str | None = Field(description="Description of the resource", default=None)
|
||||
mime_type: str = Field(
|
||||
default="text/plain",
|
||||
description="MIME type of the resource content",
|
||||
pattern=r"^[a-zA-Z0-9]+/[a-zA-Z0-9\-+.]+(;\s*[a-zA-Z0-9\-_.]+=[a-zA-Z0-9\-_.]+)*$",
|
||||
)
|
||||
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this resource")
|
||||
annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource")
|
||||
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource")
|
||||
|
||||
@field_validator("name", mode="before")
|
||||
@classmethod
|
||||
def set_default_name(cls, name: str | None, info: ValidationInfo) -> str:
|
||||
"""Set default name from URI if not provided."""
|
||||
if name:
|
||||
return name
|
||||
if uri := info.data.get("uri"):
|
||||
return str(uri)
|
||||
raise ValueError("Either name or uri must be provided")
|
||||
|
||||
@abc.abstractmethod
|
||||
async def read(self) -> str | bytes:
|
||||
"""Read the resource content."""
|
||||
pass # pragma: no cover
|
||||
115
src/mcp/server/mcpserver/resources/resource_manager.py
Normal file
115
src/mcp/server/mcpserver/resources/resource_manager.py
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
"""Resource manager functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import AnyUrl
|
||||
|
||||
from mcp.server.mcpserver.resources.base import Resource
|
||||
from mcp.server.mcpserver.resources.templates import ResourceTemplate
|
||||
from mcp.server.mcpserver.utilities.logging import get_logger
|
||||
from mcp.types import Annotations, Icon
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.mcpserver.server import Context
|
||||
from mcp.server.session import ServerSessionT
|
||||
from mcp.shared.context import LifespanContextT, RequestT
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ResourceManager:
|
||||
"""Manages MCPServer resources."""
|
||||
|
||||
def __init__(self, warn_on_duplicate_resources: bool = True):
|
||||
self._resources: dict[str, Resource] = {}
|
||||
self._templates: dict[str, ResourceTemplate] = {}
|
||||
self.warn_on_duplicate_resources = warn_on_duplicate_resources
|
||||
|
||||
def add_resource(self, resource: Resource) -> Resource:
|
||||
"""Add a resource to the manager.
|
||||
|
||||
Args:
|
||||
resource: A Resource instance to add
|
||||
|
||||
Returns:
|
||||
The added resource. If a resource with the same URI already exists,
|
||||
returns the existing resource.
|
||||
"""
|
||||
logger.debug(
|
||||
"Adding resource",
|
||||
extra={
|
||||
"uri": resource.uri,
|
||||
"type": type(resource).__name__,
|
||||
"resource_name": resource.name,
|
||||
},
|
||||
)
|
||||
existing = self._resources.get(str(resource.uri))
|
||||
if existing:
|
||||
if self.warn_on_duplicate_resources:
|
||||
logger.warning(f"Resource already exists: {resource.uri}")
|
||||
return existing
|
||||
self._resources[str(resource.uri)] = resource
|
||||
return resource
|
||||
|
||||
def add_template(
|
||||
self,
|
||||
fn: Callable[..., Any],
|
||||
uri_template: str,
|
||||
name: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
mime_type: str | None = None,
|
||||
icons: list[Icon] | None = None,
|
||||
annotations: Annotations | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> ResourceTemplate:
|
||||
"""Add a template from a function."""
|
||||
template = ResourceTemplate.from_function(
|
||||
fn,
|
||||
uri_template=uri_template,
|
||||
name=name,
|
||||
title=title,
|
||||
description=description,
|
||||
mime_type=mime_type,
|
||||
icons=icons,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
)
|
||||
self._templates[template.uri_template] = template
|
||||
return template
|
||||
|
||||
async def get_resource(
|
||||
self,
|
||||
uri: AnyUrl | str,
|
||||
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
|
||||
) -> Resource | None:
|
||||
"""Get resource by URI, checking concrete resources first, then templates."""
|
||||
uri_str = str(uri)
|
||||
logger.debug("Getting resource", extra={"uri": uri_str})
|
||||
|
||||
# First check concrete resources
|
||||
if resource := self._resources.get(uri_str):
|
||||
return resource
|
||||
|
||||
# Then check templates
|
||||
for template in self._templates.values():
|
||||
if params := template.matches(uri_str):
|
||||
try:
|
||||
return await template.create_resource(uri_str, params, context=context)
|
||||
except Exception as e: # pragma: no cover
|
||||
raise ValueError(f"Error creating resource from template: {e}")
|
||||
|
||||
raise ValueError(f"Unknown resource: {uri}")
|
||||
|
||||
def list_resources(self) -> list[Resource]:
|
||||
"""List all registered resources."""
|
||||
logger.debug("Listing resources", extra={"count": len(self._resources)})
|
||||
return list(self._resources.values())
|
||||
|
||||
def list_templates(self) -> list[ResourceTemplate]:
|
||||
"""List all registered templates."""
|
||||
logger.debug("Listing templates", extra={"count": len(self._templates)})
|
||||
return list(self._templates.values())
|
||||
127
src/mcp/server/mcpserver/resources/templates.py
Normal file
127
src/mcp/server/mcpserver/resources/templates.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""Resource template functionality."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from pydantic import BaseModel, Field, validate_call
|
||||
|
||||
from mcp.server.mcpserver.resources.types import FunctionResource, Resource
|
||||
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter, inject_context
|
||||
from mcp.server.mcpserver.utilities.func_metadata import func_metadata
|
||||
from mcp.types import Annotations, Icon
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.mcpserver.server import Context
|
||||
from mcp.server.session import ServerSessionT
|
||||
from mcp.shared.context import LifespanContextT, RequestT
|
||||
|
||||
|
||||
class ResourceTemplate(BaseModel):
|
||||
"""A template for dynamically creating resources."""
|
||||
|
||||
uri_template: str = Field(description="URI template with parameters (e.g. weather://{city}/current)")
|
||||
name: str = Field(description="Name of the resource")
|
||||
title: str | None = Field(description="Human-readable title of the resource", default=None)
|
||||
description: str | None = Field(description="Description of what the resource does")
|
||||
mime_type: str = Field(default="text/plain", description="MIME type of the resource content")
|
||||
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for the resource template")
|
||||
annotations: Annotations | None = Field(default=None, description="Optional annotations for the resource template")
|
||||
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this resource template")
|
||||
fn: Callable[..., Any] = Field(exclude=True)
|
||||
parameters: dict[str, Any] = Field(description="JSON schema for function parameters")
|
||||
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
|
||||
|
||||
@classmethod
|
||||
def from_function(
|
||||
cls,
|
||||
fn: Callable[..., Any],
|
||||
uri_template: str,
|
||||
name: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
mime_type: str | None = None,
|
||||
icons: list[Icon] | None = None,
|
||||
annotations: Annotations | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
context_kwarg: str | None = None,
|
||||
) -> ResourceTemplate:
|
||||
"""Create a template from a function."""
|
||||
func_name = name or fn.__name__
|
||||
if func_name == "<lambda>":
|
||||
raise ValueError("You must provide a name for lambda functions") # pragma: no cover
|
||||
|
||||
# Find context parameter if it exists
|
||||
if context_kwarg is None: # pragma: no branch
|
||||
context_kwarg = find_context_parameter(fn)
|
||||
|
||||
# Get schema from func_metadata, excluding context parameter
|
||||
func_arg_metadata = func_metadata(
|
||||
fn,
|
||||
skip_names=[context_kwarg] if context_kwarg is not None else [],
|
||||
)
|
||||
parameters = func_arg_metadata.arg_model.model_json_schema()
|
||||
|
||||
# ensure the arguments are properly cast
|
||||
fn = validate_call(fn)
|
||||
|
||||
return cls(
|
||||
uri_template=uri_template,
|
||||
name=func_name,
|
||||
title=title,
|
||||
description=description or fn.__doc__ or "",
|
||||
mime_type=mime_type or "text/plain",
|
||||
icons=icons,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
fn=fn,
|
||||
parameters=parameters,
|
||||
context_kwarg=context_kwarg,
|
||||
)
|
||||
|
||||
def matches(self, uri: str) -> dict[str, Any] | None:
|
||||
"""Check if URI matches template and extract parameters.
|
||||
|
||||
Extracted parameters are URL-decoded to handle percent-encoded characters.
|
||||
"""
|
||||
# Convert template to regex pattern
|
||||
pattern = self.uri_template.replace("{", "(?P<").replace("}", ">[^/]+)")
|
||||
match = re.match(f"^{pattern}$", uri)
|
||||
if match:
|
||||
# URL-decode all extracted parameter values
|
||||
return {key: unquote(value) for key, value in match.groupdict().items()}
|
||||
return None
|
||||
|
||||
async def create_resource(
|
||||
self,
|
||||
uri: str,
|
||||
params: dict[str, Any],
|
||||
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
|
||||
) -> Resource:
|
||||
"""Create a resource from the template with the given parameters."""
|
||||
try:
|
||||
# Add context to params if needed
|
||||
params = inject_context(self.fn, params, context, self.context_kwarg)
|
||||
|
||||
# Call function and check if result is a coroutine
|
||||
result = self.fn(**params)
|
||||
if inspect.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
return FunctionResource(
|
||||
uri=uri, # type: ignore
|
||||
name=self.name,
|
||||
title=self.title,
|
||||
description=self.description,
|
||||
mime_type=self.mime_type,
|
||||
icons=self.icons,
|
||||
annotations=self.annotations,
|
||||
meta=self.meta,
|
||||
fn=lambda: result, # Capture result in closure
|
||||
)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error creating resource from template: {e}")
|
||||
203
src/mcp/server/mcpserver/resources/types.py
Normal file
203
src/mcp/server/mcpserver/resources/types.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
"""Concrete resource implementations."""
|
||||
|
||||
import inspect
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import anyio
|
||||
import anyio.to_thread
|
||||
import httpx
|
||||
import pydantic
|
||||
import pydantic_core
|
||||
from pydantic import Field, ValidationInfo, validate_call
|
||||
|
||||
from mcp.server.mcpserver.resources.base import Resource
|
||||
from mcp.types import Annotations, Icon
|
||||
|
||||
|
||||
class TextResource(Resource):
|
||||
"""A resource that reads from a string."""
|
||||
|
||||
text: str = Field(description="Text content of the resource")
|
||||
|
||||
async def read(self) -> str:
|
||||
"""Read the text content."""
|
||||
return self.text # pragma: no cover
|
||||
|
||||
|
||||
class BinaryResource(Resource):
|
||||
"""A resource that reads from bytes."""
|
||||
|
||||
data: bytes = Field(description="Binary content of the resource")
|
||||
|
||||
async def read(self) -> bytes:
|
||||
"""Read the binary content."""
|
||||
return self.data # pragma: no cover
|
||||
|
||||
|
||||
class FunctionResource(Resource):
|
||||
"""A resource that defers data loading by wrapping a function.
|
||||
|
||||
The function is only called when the resource is read, allowing for lazy loading
|
||||
of potentially expensive data. This is particularly useful when listing resources,
|
||||
as the function won't be called until the resource is actually accessed.
|
||||
|
||||
The function can return:
|
||||
- str for text content (default)
|
||||
- bytes for binary content
|
||||
- other types will be converted to JSON
|
||||
"""
|
||||
|
||||
fn: Callable[[], Any] = Field(exclude=True)
|
||||
|
||||
async def read(self) -> str | bytes:
|
||||
"""Read the resource by calling the wrapped function."""
|
||||
try:
|
||||
# Call the function first to see if it returns a coroutine
|
||||
result = self.fn()
|
||||
# If it's a coroutine, await it
|
||||
if inspect.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
if isinstance(result, Resource): # pragma: no cover
|
||||
return await result.read()
|
||||
elif isinstance(result, bytes):
|
||||
return result
|
||||
elif isinstance(result, str):
|
||||
return result
|
||||
else:
|
||||
return pydantic_core.to_json(result, fallback=str, indent=2).decode()
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error reading resource {self.uri}: {e}")
|
||||
|
||||
@classmethod
|
||||
def from_function(
|
||||
cls,
|
||||
fn: Callable[..., Any],
|
||||
uri: str,
|
||||
name: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
mime_type: str | None = None,
|
||||
icons: list[Icon] | None = None,
|
||||
annotations: Annotations | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> "FunctionResource":
|
||||
"""Create a FunctionResource from a function."""
|
||||
func_name = name or fn.__name__
|
||||
if func_name == "<lambda>": # pragma: no cover
|
||||
raise ValueError("You must provide a name for lambda functions")
|
||||
|
||||
# ensure the arguments are properly cast
|
||||
fn = validate_call(fn)
|
||||
|
||||
return cls(
|
||||
uri=uri,
|
||||
name=func_name,
|
||||
title=title,
|
||||
description=description or fn.__doc__ or "",
|
||||
mime_type=mime_type or "text/plain",
|
||||
fn=fn,
|
||||
icons=icons,
|
||||
annotations=annotations,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
|
||||
class FileResource(Resource):
|
||||
"""A resource that reads from a file.
|
||||
|
||||
Set is_binary=True to read file as binary data instead of text.
|
||||
"""
|
||||
|
||||
path: Path = Field(description="Path to the file")
|
||||
is_binary: bool = Field(
|
||||
default=False,
|
||||
description="Whether to read the file as binary data",
|
||||
)
|
||||
mime_type: str = Field(
|
||||
default="text/plain",
|
||||
description="MIME type of the resource content",
|
||||
)
|
||||
|
||||
@pydantic.field_validator("path")
|
||||
@classmethod
|
||||
def validate_absolute_path(cls, path: Path) -> Path:
|
||||
"""Ensure path is absolute."""
|
||||
if not path.is_absolute():
|
||||
raise ValueError("Path must be absolute")
|
||||
return path
|
||||
|
||||
@pydantic.field_validator("is_binary")
|
||||
@classmethod
|
||||
def set_binary_from_mime_type(cls, is_binary: bool, info: ValidationInfo) -> bool:
|
||||
"""Set is_binary based on mime_type if not explicitly set."""
|
||||
if is_binary:
|
||||
return True
|
||||
mime_type = info.data.get("mime_type", "text/plain")
|
||||
return not mime_type.startswith("text/")
|
||||
|
||||
async def read(self) -> str | bytes:
|
||||
"""Read the file content."""
|
||||
try:
|
||||
if self.is_binary:
|
||||
return await anyio.to_thread.run_sync(self.path.read_bytes)
|
||||
return await anyio.to_thread.run_sync(self.path.read_text)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error reading file {self.path}: {e}")
|
||||
|
||||
|
||||
class HttpResource(Resource):
|
||||
"""A resource that reads from an HTTP endpoint."""
|
||||
|
||||
url: str = Field(description="URL to fetch content from")
|
||||
mime_type: str = Field(default="application/json", description="MIME type of the resource content")
|
||||
|
||||
async def read(self) -> str | bytes:
|
||||
"""Read the HTTP content."""
|
||||
async with httpx.AsyncClient() as client: # pragma: no cover
|
||||
response = await client.get(self.url)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
|
||||
|
||||
class DirectoryResource(Resource):
|
||||
"""A resource that lists files in a directory."""
|
||||
|
||||
path: Path = Field(description="Path to the directory")
|
||||
recursive: bool = Field(default=False, description="Whether to list files recursively")
|
||||
pattern: str | None = Field(default=None, description="Optional glob pattern to filter files")
|
||||
mime_type: str = Field(default="application/json", description="MIME type of the resource content")
|
||||
|
||||
@pydantic.field_validator("path")
|
||||
@classmethod
|
||||
def validate_absolute_path(cls, path: Path) -> Path: # pragma: no cover
|
||||
"""Ensure path is absolute."""
|
||||
if not path.is_absolute():
|
||||
raise ValueError("Path must be absolute")
|
||||
return path
|
||||
|
||||
def list_files(self) -> list[Path]: # pragma: no cover
|
||||
"""List files in the directory."""
|
||||
if not self.path.exists():
|
||||
raise FileNotFoundError(f"Directory not found: {self.path}")
|
||||
if not self.path.is_dir():
|
||||
raise NotADirectoryError(f"Not a directory: {self.path}")
|
||||
|
||||
try:
|
||||
if self.pattern:
|
||||
return list(self.path.glob(self.pattern)) if not self.recursive else list(self.path.rglob(self.pattern))
|
||||
return list(self.path.glob("*")) if not self.recursive else list(self.path.rglob("*"))
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error listing directory {self.path}: {e}")
|
||||
|
||||
async def read(self) -> str: # Always returns JSON string # pragma: no cover
|
||||
"""Read the directory listing."""
|
||||
try:
|
||||
files = await anyio.to_thread.run_sync(self.list_files)
|
||||
file_list = [str(f.relative_to(self.path)) for f in files if f.is_file()]
|
||||
return json.dumps({"files": file_list}, indent=2)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Error reading directory {self.path}: {e}")
|
||||
1231
src/mcp/server/mcpserver/server.py
Normal file
1231
src/mcp/server/mcpserver/server.py
Normal file
File diff suppressed because it is too large
Load diff
4
src/mcp/server/mcpserver/tools/__init__.py
Normal file
4
src/mcp/server/mcpserver/tools/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
from .base import Tool
|
||||
from .tool_manager import ToolManager
|
||||
|
||||
__all__ = ["Tool", "ToolManager"]
|
||||
126
src/mcp/server/mcpserver/tools/base.py
Normal file
126
src/mcp/server/mcpserver/tools/base.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from functools import cached_property
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
from mcp.server.mcpserver.utilities.context_injection import find_context_parameter
|
||||
from mcp.server.mcpserver.utilities.func_metadata import FuncMetadata, func_metadata
|
||||
from mcp.shared.exceptions import UrlElicitationRequiredError
|
||||
from mcp.shared.tool_name_validation import validate_and_warn_tool_name
|
||||
from mcp.types import Icon, ToolAnnotations
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.mcpserver.server import Context
|
||||
from mcp.server.session import ServerSessionT
|
||||
from mcp.shared.context import LifespanContextT, RequestT
|
||||
|
||||
|
||||
class Tool(BaseModel):
|
||||
"""Internal tool registration info."""
|
||||
|
||||
fn: Callable[..., Any] = Field(exclude=True)
|
||||
name: str = Field(description="Name of the tool")
|
||||
title: str | None = Field(None, description="Human-readable title of the tool")
|
||||
description: str = Field(description="Description of what the tool does")
|
||||
parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
|
||||
fn_metadata: FuncMetadata = Field(
|
||||
description="Metadata about the function including a pydantic model for tool arguments"
|
||||
)
|
||||
is_async: bool = Field(description="Whether the tool is async")
|
||||
context_kwarg: str | None = Field(None, description="Name of the kwarg that should receive context")
|
||||
annotations: ToolAnnotations | None = Field(None, description="Optional annotations for the tool")
|
||||
icons: list[Icon] | None = Field(default=None, description="Optional list of icons for this tool")
|
||||
meta: dict[str, Any] | None = Field(default=None, description="Optional metadata for this tool")
|
||||
|
||||
@cached_property
|
||||
def output_schema(self) -> dict[str, Any] | None:
|
||||
return self.fn_metadata.output_schema
|
||||
|
||||
@classmethod
|
||||
def from_function(
|
||||
cls,
|
||||
fn: Callable[..., Any],
|
||||
name: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
context_kwarg: str | None = None,
|
||||
annotations: ToolAnnotations | None = None,
|
||||
icons: list[Icon] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
structured_output: bool | None = None,
|
||||
) -> Tool:
|
||||
"""Create a Tool from a function."""
|
||||
func_name = name or fn.__name__
|
||||
|
||||
validate_and_warn_tool_name(func_name)
|
||||
|
||||
if func_name == "<lambda>":
|
||||
raise ValueError("You must provide a name for lambda functions")
|
||||
|
||||
func_doc = description or fn.__doc__ or ""
|
||||
is_async = _is_async_callable(fn)
|
||||
|
||||
if context_kwarg is None: # pragma: no branch
|
||||
context_kwarg = find_context_parameter(fn)
|
||||
|
||||
func_arg_metadata = func_metadata(
|
||||
fn,
|
||||
skip_names=[context_kwarg] if context_kwarg is not None else [],
|
||||
structured_output=structured_output,
|
||||
)
|
||||
parameters = func_arg_metadata.arg_model.model_json_schema(by_alias=True)
|
||||
|
||||
return cls(
|
||||
fn=fn,
|
||||
name=func_name,
|
||||
title=title,
|
||||
description=func_doc,
|
||||
parameters=parameters,
|
||||
fn_metadata=func_arg_metadata,
|
||||
is_async=is_async,
|
||||
context_kwarg=context_kwarg,
|
||||
annotations=annotations,
|
||||
icons=icons,
|
||||
meta=meta,
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
arguments: dict[str, Any],
|
||||
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
|
||||
convert_result: bool = False,
|
||||
) -> Any:
|
||||
"""Run the tool with arguments."""
|
||||
try:
|
||||
result = await self.fn_metadata.call_fn_with_arg_validation(
|
||||
self.fn,
|
||||
self.is_async,
|
||||
arguments,
|
||||
{self.context_kwarg: context} if self.context_kwarg is not None else None,
|
||||
)
|
||||
|
||||
if convert_result:
|
||||
result = self.fn_metadata.convert_result(result)
|
||||
|
||||
return result
|
||||
except UrlElicitationRequiredError:
|
||||
# Re-raise UrlElicitationRequiredError so it can be properly handled
|
||||
# as an MCP error response with code -32042
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ToolError(f"Error executing tool {self.name}: {e}") from e
|
||||
|
||||
|
||||
def _is_async_callable(obj: Any) -> bool:
|
||||
while isinstance(obj, functools.partial): # pragma: lax no cover
|
||||
obj = obj.func
|
||||
|
||||
return inspect.iscoroutinefunction(obj) or (
|
||||
callable(obj) and inspect.iscoroutinefunction(getattr(obj, "__call__", None))
|
||||
)
|
||||
93
src/mcp/server/mcpserver/tools/tool_manager.py
Normal file
93
src/mcp/server/mcpserver/tools/tool_manager.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from mcp.server.mcpserver.exceptions import ToolError
|
||||
from mcp.server.mcpserver.tools.base import Tool
|
||||
from mcp.server.mcpserver.utilities.logging import get_logger
|
||||
from mcp.shared.context import LifespanContextT, RequestT
|
||||
from mcp.types import Icon, ToolAnnotations
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mcp.server.mcpserver.server import Context
|
||||
from mcp.server.session import ServerSessionT
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ToolManager:
|
||||
"""Manages MCPServer tools."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
warn_on_duplicate_tools: bool = True,
|
||||
*,
|
||||
tools: list[Tool] | None = None,
|
||||
):
|
||||
self._tools: dict[str, Tool] = {}
|
||||
if tools is not None:
|
||||
for tool in tools:
|
||||
if warn_on_duplicate_tools and tool.name in self._tools:
|
||||
logger.warning(f"Tool already exists: {tool.name}")
|
||||
self._tools[tool.name] = tool
|
||||
|
||||
self.warn_on_duplicate_tools = warn_on_duplicate_tools
|
||||
|
||||
def get_tool(self, name: str) -> Tool | None:
|
||||
"""Get tool by name."""
|
||||
return self._tools.get(name)
|
||||
|
||||
def list_tools(self) -> list[Tool]:
|
||||
"""List all registered tools."""
|
||||
return list(self._tools.values())
|
||||
|
||||
def add_tool(
|
||||
self,
|
||||
fn: Callable[..., Any],
|
||||
name: str | None = None,
|
||||
title: str | None = None,
|
||||
description: str | None = None,
|
||||
annotations: ToolAnnotations | None = None,
|
||||
icons: list[Icon] | None = None,
|
||||
meta: dict[str, Any] | None = None,
|
||||
structured_output: bool | None = None,
|
||||
) -> Tool:
|
||||
"""Add a tool to the server."""
|
||||
tool = Tool.from_function(
|
||||
fn,
|
||||
name=name,
|
||||
title=title,
|
||||
description=description,
|
||||
annotations=annotations,
|
||||
icons=icons,
|
||||
meta=meta,
|
||||
structured_output=structured_output,
|
||||
)
|
||||
existing = self._tools.get(tool.name)
|
||||
if existing:
|
||||
if self.warn_on_duplicate_tools:
|
||||
logger.warning(f"Tool already exists: {tool.name}")
|
||||
return existing
|
||||
self._tools[tool.name] = tool
|
||||
return tool
|
||||
|
||||
def remove_tool(self, name: str) -> None:
|
||||
"""Remove a tool by name."""
|
||||
if name not in self._tools:
|
||||
raise ToolError(f"Unknown tool: {name}")
|
||||
del self._tools[name]
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
name: str,
|
||||
arguments: dict[str, Any],
|
||||
context: Context[ServerSessionT, LifespanContextT, RequestT] | None = None,
|
||||
convert_result: bool = False,
|
||||
) -> Any:
|
||||
"""Call a tool by name with arguments."""
|
||||
tool = self.get_tool(name)
|
||||
if not tool:
|
||||
raise ToolError(f"Unknown tool: {name}")
|
||||
|
||||
return await tool.run(arguments, context=context, convert_result=convert_result)
|
||||
1
src/mcp/server/mcpserver/utilities/__init__.py
Normal file
1
src/mcp/server/mcpserver/utilities/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""MCPServer utility modules."""
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue