diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml deleted file mode 100644 index 5f2fe8d53..000000000 --- a/.github/workflows/publish.yml +++ /dev/null @@ -1,26 +0,0 @@ -name: Publish FastMCP to PyPI -on: - release: - types: [published] - workflow_dispatch: - -jobs: - pypi-publish: - name: Upload to PyPI - runs-on: ubuntu-latest - permissions: - id-token: write # For PyPI's trusted publishing - steps: - - name: Checkout - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - name: "Install uv" - uses: astral-sh/setup-uv@v7 - - - name: Build - run: uv build - - - name: Publish to PyPi - run: uv publish -v dist/* diff --git a/docs/apps/interactive-apps.mdx b/docs/apps/interactive-apps.mdx deleted file mode 100644 index fb2963114..000000000 --- a/docs/apps/interactive-apps.mdx +++ /dev/null @@ -1,538 +0,0 @@ ---- -title: FastMCPApp -sidebarTitle: FastMCPApp -description: Managed tool binding, visibility, and composition for apps with heavy server interaction. -icon: puzzle-piece -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - - - - -[Prefab](https://prefab.prefect.io) is in early, active development — its API changes frequently and breaking changes can occur with any release. Always pin `prefab-ui` to a specific version in your dependencies. - - -Any [Prefab app](/apps/prefab) can call server tools — there's nothing stopping you from using `CallTool("tool_name")` in a regular `@mcp.tool(app=True)`. But once you have multiple backend tools, the management overhead adds up: Which tools should the model see vs. only the UI? What happens to string-based tool references when servers are composed under namespaces? How do you keep things wired correctly as the app grows? - -`FastMCPApp` is a class that solves these problems. It gives you two decorators that work together: - -- **`@app.ui()`** — entry-point tools the model calls to open the app. These return a Prefab UI. -- **`@app.tool()`** — backend tools the UI calls via `CallTool`. These do the work. - -Backend tools get globally stable identifiers that survive namespacing. Visibility is managed automatically — the model sees entry points, the UI sees backends. And `CallTool` accepts function references instead of strings, so references are refactorable and composition-safe. - -## Your First Interactive App - -Here's a minimal app with a form that saves data: - -```python -from prefab_ui.actions import SetState, ShowToast -from prefab_ui.actions.mcp import CallTool -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - Badge, Button, Column, ForEach, Form, - Heading, Input, Row, Separator, Text, -) -from prefab_ui.rx import RESULT -from fastmcp import FastMCP, FastMCPApp - -app = FastMCPApp("Notes") - -notes_db: list[dict] = [] - - -@app.tool() -def add_note(title: str, body: str) -> list[dict]: - """Save a note and return all notes.""" - notes_db.append({"title": title, "body": body}) - return list(notes_db) - - -@app.ui() -def notes_app() -> PrefabApp: - """Open the notes app.""" - with Column(gap=6, css_class="p-6") as view: - Heading("Notes") - - with ForEach("notes") as note: - with Row(gap=2, align="center"): - Text(note.title, css_class="font-semibold") - Badge(note.body) - - Separator() - - with Form( - on_submit=CallTool( - "add_note", - on_success=[ - SetState("notes", RESULT), - ShowToast("Note saved!", variant="success"), - ], - on_error=ShowToast("Failed to save", variant="error"), - ) - ): - Input(name="title", label="Title", required=True) - Input(name="body", label="Body", required=True) - Button("Add Note") - - return PrefabApp(view=view, state={"notes": list(notes_db)}) - - -mcp = FastMCP("Notes Server", providers=[app]) -``` - -When the model calls `notes_app`, the user sees a form. Submitting it calls `add_note` on the server, updates the state with the result, and shows a toast — all without leaving the UI. - -Let's break down the key concepts. - -## Entry Points: @app.ui() - -Entry points are what the model sees and calls to open your app. They return a Prefab UI, just like display tools: - -```python -@app.ui() -def dashboard() -> PrefabApp: - """The model calls this to open the dashboard.""" - with Column(gap=4, css_class="p-6") as view: - Heading("Dashboard") - # ... build UI ... - return PrefabApp(view=view) -``` - -Entry points default to `visibility=["model"]` — they show up in the tool list for the LLM but aren't callable from within the app UI. They support the same options as `@mcp.tool`: `name`, `description`, `title`, `tags`, `icons`, `auth`, and `timeout`. - -```python -@app.ui(title="Contact Manager", description="Open the contact management interface") -def contact_manager() -> PrefabApp: - ... -``` - -## Backend Tools: @app.tool() - -Backend tools do the work. The UI calls them via `CallTool`; they run on the server and return data: - -```python -@app.tool() -def save_contact(name: str, email: str) -> list[dict]: - """Save a contact and return the updated list.""" - db.append({"name": name, "email": email}) - return list(db) -``` - -By default, backend tools are only visible to the app UI (`visibility=["app"]`). The model doesn't see them in the tool list. If you want a tool callable by both the model and the UI, pass `model=True`: - -```python -@app.tool(model=True) -def list_contacts() -> list[dict]: - """Both the model and the UI can call this.""" - return list(db) -``` - -Backend tools support `name`, `description`, `auth`, and `timeout`: - -```python -@app.tool(description="Search contacts by name or email", timeout=10.0) -def search(query: str) -> list[dict]: - ... -``` - -## Connecting UI to Backend: CallTool - -`CallTool` is the bridge between the UI and the server. Pass the name of a backend tool registered with `@app.tool()`: - -```python -from prefab_ui.actions.mcp import CallTool - -# Reference a backend tool by name -CallTool("save_contact", arguments={"name": "Alice", "email": "alice@example.com"}) - -# Arguments can reference state with Rx -from prefab_ui.rx import STATE - -CallTool("search", arguments={"query": STATE.search_term}) -``` - -FastMCPApp resolves the name to the tool's stable global key automatically, so `CallTool("save_contact")` keeps working even when the server is mounted under a namespace. - -You can also pass the function directly — `CallTool(save_contact)` — which can be convenient when the tool is defined in the same file. Both forms resolve identically. - -### Handling Results - -Server calls are asynchronous. Use `on_success` and `on_error` callbacks to handle outcomes: - -```python -from prefab_ui.actions import SetState, ShowToast -from prefab_ui.rx import RESULT - -CallTool( - "save_contact", - on_success=[ - SetState("contacts", RESULT), - ShowToast("Saved!", variant="success"), - ], - on_error=ShowToast("Something went wrong", variant="error"), -) -``` - -`RESULT` is a reactive reference to the value the tool returned — available inside `on_success` callbacks. Similarly, `ERROR` (from `prefab_ui.rx`) is available inside `on_error`. - -Callbacks can be a single action or a list of actions. They execute in order, and an error in any action short-circuits the rest. - -### result_key Shorthand - -When a tool returns data that should replace a state key, `result_key` is a convenient shorthand for `on_success=SetState(key, RESULT)`: - -```python -CallTool("list_contacts", result_key="contacts") - -# equivalent to: -CallTool( - "list_contacts", - on_success=SetState("contacts", RESULT), -) -``` - -## Actions - -`CallTool` is one of several actions available in Prefab. Actions are events attached to component handlers like `on_click`, `on_submit`, and `on_change`. - -### Client Actions - -These run instantly in the browser — no server round-trip: - -```python -from prefab_ui.actions import SetState, ToggleState, AppendState, PopState, ShowToast - -# Set a value -SetState("count", 42) - -# Toggle a boolean -ToggleState("expanded") - -# Append to a list -AppendState("items", {"name": "New Item"}) - -# Remove by index -PopState("items", 0) - -# Show a notification -ShowToast("Done!", variant="success") -``` - -### Chaining Actions - -Pass a list to execute multiple actions in sequence: - -```python -from prefab_ui.components import Button -from prefab_ui.actions import SetState, ShowToast - -Button( - "Reset", - on_click=[ - SetState("query", ""), - SetState("results", []), - ShowToast("Cleared", variant="default"), - ], -) -``` - -### Loading States - -A common pattern: show a loading indicator while a server call is in flight. - -```python -from prefab_ui.actions import SetState, ShowToast -from prefab_ui.actions.mcp import CallTool -from prefab_ui.components import Button -from prefab_ui.rx import RESULT, Rx - -saving = Rx("saving") - -Button( - saving.then("Saving...", "Save"), - disabled=saving, - on_click=[ - SetState("saving", True), - CallTool( - "save_data", - on_success=[ - SetState("saving", False), - SetState("result", RESULT), - ShowToast("Saved!", variant="success"), - ], - on_error=[ - SetState("saving", False), - ShowToast("Failed", variant="error"), - ], - ), - ], -) - -# Pass state={"saving": False} to PrefabApp when returning -``` - -## Forms - -Forms are the most common way to collect input and send it to the server. When a form submits, all named input values are gathered and passed as arguments to the `CallTool` action. - -### Manual Forms - -Build forms with individual input components: - -```python -from prefab_ui.components import Form, Input, Select, SelectOption, Textarea, Button -from prefab_ui.actions.mcp import CallTool -from prefab_ui.actions import ShowToast - -with Form( - on_submit=CallTool( - "create_ticket", - on_success=ShowToast("Ticket created!", variant="success"), - ) -): - Input(name="title", label="Title", required=True) - with Select(name="priority", label="Priority"): - SelectOption("Low", value="low") - SelectOption("Medium", value="medium") - SelectOption("High", value="high") - SelectOption("Critical", value="critical") - Textarea(name="description", label="Description") - Button("Create Ticket") -``` - -When submitted, the CallTool receives `{"title": "...", "priority": "...", "description": "..."}` as arguments to `create_ticket`. - -### Pydantic Model Forms - -For structured data, `Form.from_model()` generates the entire form from a Pydantic model — inputs, labels, and submit wiring: - -```python -from typing import Literal - -from pydantic import BaseModel, Field -from prefab_ui.components import Column, Heading, Form -from prefab_ui.actions.mcp import CallTool -from prefab_ui.actions import SetState, ShowToast -from prefab_ui.app import PrefabApp -from prefab_ui.rx import RESULT - -class BugReport(BaseModel): - title: str = Field(title="Bug Title") - severity: Literal["low", "medium", "high", "critical"] = Field( - title="Severity", default="medium" - ) - description: str = Field(title="Description") - - -@app.ui() -def report_bug() -> PrefabApp: - """File a bug report.""" - with Column(gap=4, css_class="p-6") as view: - Heading("Report a Bug") - Form.from_model( - BugReport, - on_submit=CallTool( - "create_bug", - on_success=ShowToast("Bug filed!", variant="success"), - on_error=ShowToast("Failed to submit", variant="error"), - ), - ) - return PrefabApp(view=view) - - -@app.tool() -def create_bug(data: BugReport) -> str: - """Create a bug report.""" - # save to database... - return f"Created: {data.title}" -``` - -`str` fields become text inputs, `Literal` becomes a select dropdown, `bool` becomes a checkbox. Field titles and defaults are respected. - -## Composition and Namespacing - -The reason `FastMCPApp` exists — and why you'd use it instead of plain `@mcp.tool(app=True)` with `CallTool("tool_name")` — is composition safety. - -When you mount a server under a namespace, tool names get prefixed: - -```python -from fastmcp import FastMCP - -platform = FastMCP("Platform") -platform.mount("contacts", contacts_server) - -# "save_contact" becomes "contacts_save_contact" -``` - -If your UI used `CallTool("save_contact")`, it would break — the tool is now named `contacts_save_contact`. But `CallTool(save_contact)` with a function reference resolves to a globally stable key (like `save_contact-a1b2c3d4`) that bypasses the namespace entirely. - -This is why `FastMCPApp` assigns global keys to backend tools, and why `CallTool` accepts function references. Your app works the same whether it's running standalone or mounted inside a larger platform. - -### Mounting an App - -`FastMCPApp` is a Provider. Add it to a server with `providers=` or `add_provider`: - -```python -from fastmcp import FastMCP, FastMCPApp - -app = FastMCPApp("Contacts") - -@app.ui() -def contact_manager() -> PrefabApp: - ... - -@app.tool() -def save_contact(name: str, email: str) -> dict: - ... - - -# Option 1: providers list -mcp = FastMCP("Platform", providers=[app]) - -# Option 2: add_provider -mcp = FastMCP("Platform") -mcp.add_provider(app) -``` - -Multiple apps can coexist on the same server: - -```python -mcp = FastMCP("Platform", providers=[contacts_app, inventory_app, billing_app]) -``` - -Each app's backend tools have their own global keys, so there's no collision even if two apps have a tool named `save`. - -### Running Standalone - -For development, `FastMCPApp` has a convenience `run()` method that wraps itself in a temporary `FastMCP` server: - -```python -app = FastMCPApp("Contacts") -# ... register tools ... - -if __name__ == "__main__": - app.run() -``` - -## Complete Example: Contact Manager - -This pulls together everything — entry points, backend tools, callable references, forms (both manual and Pydantic), state management, and actions: - -```python expandable -from __future__ import annotations - -from typing import Literal - -from prefab_ui.actions import SetState, ShowToast -from prefab_ui.actions.mcp import CallTool -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - Badge, Button, Column, ForEach, Form, - Heading, Input, Muted, Row, Separator, Text, -) -from prefab_ui.rx import RESULT, Rx -from pydantic import BaseModel, Field -from fastmcp import FastMCP, FastMCPApp - -# Data - -contacts_db: list[dict] = [ - {"name": "Arthur Dent", "email": "arthur@earth.com", "category": "Customer"}, - {"name": "Ford Prefect", "email": "ford@betelgeuse.org", "category": "Partner"}, -] - - -class ContactModel(BaseModel): - name: str = Field(title="Full Name", min_length=1) - email: str = Field(title="Email") - category: Literal["Customer", "Vendor", "Partner", "Other"] = "Other" - - -# App - -app = FastMCPApp("Contacts") - - -@app.tool() -def save_contact(data: ContactModel) -> list[dict]: - """Save a new contact and return the updated list.""" - contacts_db.append(data.model_dump()) - return list(contacts_db) - - -@app.tool() -def search_contacts(query: str) -> list[dict]: - """Filter contacts by name or email.""" - q = query.lower() - return [ - c for c in contacts_db - if q in c["name"].lower() or q in c["email"].lower() - ] - - -@app.tool(model=True) -def list_contacts() -> list[dict]: - """Return all contacts. Visible to both the model and the UI.""" - return list(contacts_db) - - -@app.ui() -def contact_manager() -> PrefabApp: - """Open the contact manager.""" - with Column(gap=6, css_class="p-6") as view: - Heading("Contacts") - - with ForEach("contacts") as contact: - with Row(gap=2, align="center"): - Text(contact.name, css_class="font-medium") - Muted(contact.email) - Badge(contact.category) - - Separator() - - Heading("Add Contact", level=3) - Form.from_model( - ContactModel, - on_submit=CallTool( - "save_contact", - on_success=[ - SetState("contacts", RESULT), - ShowToast("Contact saved!", variant="success"), - ], - on_error=ShowToast("Failed to save", variant="error"), - ), - ) - - Separator() - - Heading("Search", level=3) - with Form( - on_submit=CallTool( - "search_contacts", - arguments={"query": Rx("query")}, - on_success=SetState("contacts", RESULT), - ) - ): - Input(name="query", placeholder="Search by name or email...") - Button("Search") - - return PrefabApp(view=view, state={"contacts": list(contacts_db)}) - - -mcp = FastMCP("Contacts Server", providers=[app]) - -if __name__ == "__main__": - mcp.run() -``` - -This example is also available as a runnable server at `examples/apps/contacts/contacts_server.py`. - -## Next Steps - -- **[Prefab Apps](/apps/prefab)** — Components, state, and reactive displays (the building blocks) -- **[Patterns](/apps/patterns)** — Copy-paste examples for common UIs -- **[Development](/apps/development)** — Preview and test app tools locally -- **[Prefab UI Docs](https://prefab.prefect.io)** — Full component reference and advanced patterns diff --git a/docs/apps/patterns.mdx b/docs/apps/patterns.mdx deleted file mode 100644 index b0ff80376..000000000 --- a/docs/apps/patterns.mdx +++ /dev/null @@ -1,431 +0,0 @@ ---- -title: Patterns -sidebarTitle: Patterns -description: Copy-paste examples for common tool UIs. -icon: grid-2-plus -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - - - -Each pattern below is a complete, copy-pasteable tool. They're organized by what you're building — pick the one closest to your use case, paste it, and adapt. - -For the full set of available components — layout containers, form controls, overlays, and more — see the [Prefab component reference](https://prefab.prefect.io/docs/components). - -## Charts - -Prefab includes [bar, line, area, pie, radar, and radial charts](https://prefab.prefect.io/docs/components/charts). They render client-side with tooltips, legends, and responsive sizing. - -### Bar Chart - -```python -from prefab_ui.app import PrefabApp -from prefab_ui.components import Column, Heading -from prefab_ui.components.charts import BarChart, ChartSeries -from fastmcp import FastMCP - -mcp = FastMCP("Charts") - - -@mcp.tool(app=True) -def quarterly_revenue(year: int) -> PrefabApp: - """Show quarterly revenue as a bar chart.""" - data = [ - {"quarter": "Q1", "revenue": 42000, "costs": 28000}, - {"quarter": "Q2", "revenue": 51000, "costs": 31000}, - {"quarter": "Q3", "revenue": 47000, "costs": 29000}, - {"quarter": "Q4", "revenue": 63000, "costs": 35000}, - ] - - with Column(gap=4, css_class="p-6") as view: - Heading(f"{year} Revenue vs Costs") - BarChart( - data=data, - series=[ - ChartSeries(data_key="revenue", label="Revenue"), - ChartSeries(data_key="costs", label="Costs"), - ], - x_axis="quarter", - show_legend=True, - ) - - return PrefabApp(view=view) -``` - -Multiple `ChartSeries` entries plot different data keys. Add `stacked=True` to stack bars, or `horizontal=True` to flip the axes. - -### Area Chart - -`LineChart` and `AreaChart` share the same API as `BarChart`, with `curve` for interpolation and `show_dots` for data points: - -```python -from prefab_ui.app import PrefabApp -from prefab_ui.components import Column, Heading -from prefab_ui.components.charts import AreaChart, ChartSeries -from fastmcp import FastMCP - -mcp = FastMCP("Charts") - - -@mcp.tool(app=True) -def usage_trend() -> PrefabApp: - """Show API usage over time.""" - data = [ - {"date": "Feb 1", "requests": 1200}, - {"date": "Feb 2", "requests": 1350}, - {"date": "Feb 3", "requests": 980}, - {"date": "Feb 4", "requests": 1500}, - {"date": "Feb 5", "requests": 1420}, - ] - - with Column(gap=4, css_class="p-6") as view: - Heading("API Usage") - AreaChart( - data=data, - series=[ChartSeries(data_key="requests", label="Requests")], - x_axis="date", - curve="smooth", - height=250, - ) - - return PrefabApp(view=view) -``` - -### Pie and Donut Charts - -`PieChart` uses `data_key` (the numeric value) and `name_key` (the label). Set `inner_radius` for a donut: - -```python -from prefab_ui.app import PrefabApp -from prefab_ui.components import Column, Heading -from prefab_ui.components.charts import PieChart -from fastmcp import FastMCP - -mcp = FastMCP("Charts") - - -@mcp.tool(app=True) -def ticket_breakdown() -> PrefabApp: - """Show open tickets by category.""" - data = [ - {"category": "Bug", "count": 23}, - {"category": "Feature", "count": 15}, - {"category": "Docs", "count": 8}, - {"category": "Infra", "count": 12}, - ] - - with Column(gap=4, css_class="p-6") as view: - Heading("Open Tickets") - PieChart( - data=data, - data_key="count", - name_key="category", - show_legend=True, - inner_radius=60, - ) - - return PrefabApp(view=view) -``` - -## Data Tables - -[DataTable](https://prefab.prefect.io/docs/components/data-display/data-table) provides sortable columns, full-text search, and pagination — all client-side: - -```python -from prefab_ui.app import PrefabApp -from prefab_ui.components import Column, Heading, DataTable, DataTableColumn -from fastmcp import FastMCP - -mcp = FastMCP("Directory") - - -@mcp.tool(app=True) -def employee_directory() -> PrefabApp: - """Show a searchable, sortable employee directory.""" - employees = [ - {"name": "Alice Chen", "department": "Engineering", "role": "Staff Engineer", "location": "SF"}, - {"name": "Bob Martinez", "department": "Design", "role": "Lead Designer", "location": "NYC"}, - {"name": "Carol Johnson", "department": "Engineering", "role": "Senior Engineer", "location": "London"}, - {"name": "David Kim", "department": "Product", "role": "Product Manager", "location": "SF"}, - {"name": "Eva Müller", "department": "Engineering", "role": "Engineer", "location": "Berlin"}, - ] - - with Column(gap=4, css_class="p-6") as view: - Heading("Employee Directory") - DataTable( - columns=[ - DataTableColumn(key="name", header="Name", sortable=True), - DataTableColumn(key="department", header="Department", sortable=True), - DataTableColumn(key="role", header="Role"), - DataTableColumn(key="location", header="Office", sortable=True), - ], - rows=employees, - search=True, - paginated=True, - page_size=15, - ) - - return PrefabApp(view=view) -``` - -## Status Displays - -Cards, badges, progress bars, and grids combine naturally for dashboards: - -```python -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - Column, Row, Grid, Heading, Text, Muted, Badge, - Card, CardContent, Progress, Separator, -) -from fastmcp import FastMCP - -mcp = FastMCP("Monitoring") - - -@mcp.tool(app=True) -def system_status() -> PrefabApp: - """Show current system health.""" - services = [ - {"name": "API Gateway", "status": "healthy", "ok": True, "latency_ms": 12, "uptime_pct": 99.9}, - {"name": "Database", "status": "healthy", "ok": True, "latency_ms": 3, "uptime_pct": 99.99}, - {"name": "Cache", "status": "degraded", "ok": False, "latency_ms": 45, "uptime_pct": 98.2}, - {"name": "Queue", "status": "healthy", "ok": True, "latency_ms": 8, "uptime_pct": 99.8}, - ] - all_ok = all(s["ok"] for s in services) - - with Column(gap=4, css_class="p-6") as view: - with Row(gap=2, align="center"): - Heading("System Status") - Badge( - "All Healthy" if all_ok else "Degraded", - variant="success" if all_ok else "destructive", - ) - Separator() - with Grid(columns=2, gap=4): - for svc in services: - with Card(): - with CardContent(): - with Row(gap=2, align="center"): - Text(svc["name"], css_class="font-medium") - Badge( - svc["status"], - variant="success" if svc["ok"] else "destructive", - ) - Muted(f"Response: {svc['latency_ms']}ms") - Progress(value=svc["uptime_pct"]) - - return PrefabApp(view=view) -``` - -## Reactive Displays - -These patterns use state and `Rx()` for client-side interactivity — no server calls needed. - -### Feature Toggles - -```python -from prefab_ui.app import PrefabApp -from prefab_ui.components import Column, Heading, Switch, Alert, If, Separator -from prefab_ui.rx import Rx -from fastmcp import FastMCP - -mcp = FastMCP("Flags") - - -@mcp.tool(app=True) -def feature_flags() -> PrefabApp: - """Toggle feature flags with live preview.""" - with Column(gap=4, css_class="p-6") as view: - Heading("Feature Flags") - Switch(name="dark_mode", label="Dark Mode") - Switch(name="beta", label="Beta Features") - Separator() - with If(Rx("dark_mode")): - Alert(title="Dark mode enabled", description="UI will use dark theme.") - with If(Rx("beta")): - Alert( - title="Beta features active", - description="Experimental features are now visible.", - variant="warning", - ) - - return PrefabApp(view=view, state={"dark_mode": False, "beta": False}) -``` - -### Tabs - -```python -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - Column, Heading, Text, Muted, Badge, Row, - DataTable, DataTableColumn, Tabs, Tab, ForEach, -) -from fastmcp import FastMCP - -mcp = FastMCP("Projects") - - -@mcp.tool(app=True) -def project_overview() -> PrefabApp: - """Show project details organized in tabs.""" - project = { - "name": "FastMCP v3", - "description": "Next generation MCP framework with Apps support.", - "status": "Active", - "members": [ - {"name": "Alice Chen", "role": "Lead"}, - {"name": "Bob Martinez", "role": "Design"}, - ], - "activity": [ - {"timestamp": "2 hours ago", "message": "Merged PR #342"}, - {"timestamp": "1 day ago", "message": "Released v3.0.1"}, - ], - } - - with Column(gap=4, css_class="p-6") as view: - Heading(project["name"]) - with Tabs(): - with Tab("Overview"): - Text(project["description"]) - with Row(gap=4): - Badge(project["status"]) - - with Tab("Members"): - DataTable( - columns=[ - DataTableColumn(key="name", header="Name", sortable=True), - DataTableColumn(key="role", header="Role"), - ], - rows=project["members"], - ) - - with Tab("Activity"): - with ForEach("activity") as item: - with Row(gap=2): - Muted(item.timestamp) - Text(item.message) - - return PrefabApp(view=view, state={"activity": project["activity"]}) -``` - -### Accordion - -```python -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - Column, Heading, Row, Text, Badge, Progress, - Accordion, AccordionItem, -) -from fastmcp import FastMCP - -mcp = FastMCP("API Monitor") - - -@mcp.tool(app=True) -def api_health() -> PrefabApp: - """Show health details for each API endpoint.""" - endpoints = [ - {"path": "/api/users", "status": 200, "healthy": True, "avg_ms": 45, "p99_ms": 120, "uptime_pct": 99.9}, - {"path": "/api/orders", "status": 200, "healthy": True, "avg_ms": 82, "p99_ms": 250, "uptime_pct": 99.7}, - {"path": "/api/search", "status": 200, "healthy": True, "avg_ms": 150, "p99_ms": 500, "uptime_pct": 99.5}, - {"path": "/api/webhooks", "status": 503, "healthy": False, "avg_ms": 2000, "p99_ms": 5000, "uptime_pct": 95.1}, - ] - - with Column(gap=4, css_class="p-6") as view: - Heading("API Health") - with Accordion(multiple=True): - for ep in endpoints: - with AccordionItem(ep["path"]): - with Row(gap=4): - Badge( - f"{ep['status']}", - variant="success" if ep["healthy"] else "destructive", - ) - Text(f"Avg: {ep['avg_ms']}ms") - Text(f"P99: {ep['p99_ms']}ms") - Progress(value=ep["uptime_pct"]) - - return PrefabApp(view=view) -``` - -## Interactive Patterns - -These patterns call server tools. For context on `FastMCPApp`, `@app.tool()`, and `CallTool`, see [FastMCPApp](/apps/interactive-apps). - -### Contact Form - -```python -from prefab_ui.actions import SetState, ShowToast -from prefab_ui.actions.mcp import CallTool -from prefab_ui.app import PrefabApp -from prefab_ui.components import ( - Badge, Button, Column, ForEach, Form, Heading, - Input, Muted, Row, Select, SelectOption, Separator, Text, Textarea, -) -from prefab_ui.rx import RESULT -from fastmcp import FastMCP, FastMCPApp - -app = FastMCPApp("Contacts") - -contacts_db: list[dict] = [ - {"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Partner"}, -] - - -@app.tool() -def save_contact( - name: str, email: str, category: str = "Other", notes: str = "", -) -> list[dict]: - """Save a new contact and return the updated list.""" - contacts_db.append({"name": name, "email": email, "category": category}) - return list(contacts_db) - - -@app.ui() -def contact_form() -> PrefabApp: - """Contact list with an add form.""" - with Column(gap=6, css_class="p-6") as view: - Heading("Contacts") - - with ForEach("contacts") as contact: - with Row(gap=2, align="center"): - Text(contact.name, css_class="font-medium") - Muted(contact.email) - Badge(contact.category) - - Separator() - - with Form( - on_submit=CallTool( - "save_contact", - on_success=[ - SetState("contacts", RESULT), - ShowToast("Contact saved!", variant="success"), - ], - on_error=ShowToast("Failed to save", variant="error"), - ) - ): - Input(name="name", label="Full Name", required=True) - Input(name="email", label="Email", input_type="email", required=True) - with Select(name="category", label="Category"): - SelectOption("Customer", value="Customer") - SelectOption("Vendor", value="Vendor") - SelectOption("Partner", value="Partner") - SelectOption("Other", value="Other") - Textarea(name="notes", label="Notes", placeholder="Optional notes...") - Button("Save Contact") - - return PrefabApp(view=view, state={"contacts": list(contacts_db)}) - - -mcp = FastMCP("Server", providers=[app]) -``` - -## Next Steps - -- **[FastMCPApp](/apps/interactive-apps)** — Managed tool binding for server-connected UIs -- **[Development](/apps/development)** — Preview app tools locally with `fastmcp dev apps` -- **[Prefab UI Docs](https://prefab.prefect.io)** — Full component reference, layout guides, and more diff --git a/docs/apps/providers/generative.mdx b/docs/apps/providers/generative.mdx deleted file mode 100644 index a0795c939..000000000 --- a/docs/apps/providers/generative.mdx +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: Generative UI -sidebarTitle: Generative UI -description: Let the LLM generate custom UIs at runtime -icon: wand-magic-sparkles -tag: NEW ---- - -import { VersionBadge } from '/snippets/version-badge.mdx' - - - -`GenerativeUI` lets the LLM write Prefab Python code at runtime and render it as a streaming interactive UI. Instead of calling pre-built tools with fixed interfaces, the model creates tailored visualizations for whatever data it's working with. - -```python -from fastmcp import FastMCP -from fastmcp.apps.generative import GenerativeUI - -mcp = FastMCP("My Server") -mcp.add_provider(GenerativeUI()) -``` - -This registers: - -| Component | Type | Purpose | -|-----------|------|---------| -| `generate_prefab_ui` | Tool | Accepts Python code, executes in Pyodide sandbox, renders result | -| `search_prefab_components` | Tool | Lets the LLM discover available Prefab components | -| Generative renderer | Resource | `ui://` resource with browser-side Pyodide for streaming | - -The LLM writes real Python — loops, f-strings, computation — using Prefab's component library (charts, tables, forms, cards, layout primitives). As the model generates tokens, the host streams partial code to the renderer via `ontoolinputpartial`, so the user watches the UI build up in real time. - -## Configuration - -```python -GenerativeUI( - tool_name="generate_prefab_ui", # Rename the generation tool - components_tool_name="search_prefab_components", # Rename the search tool - include_components_tool=True, # Set False to omit the search tool -) -``` - -## What the LLM Sees - -The tool description includes code examples that teach the LLM the Prefab patterns. The LLM calls `generate_prefab_ui` with a `code` argument containing Prefab Python, and optionally a `data` argument to pass in real data from the conversation: - -```python -# The LLM generates something like: -generate_prefab_ui( - code=""" -from prefab_ui.components import Column, Heading -from prefab_ui.components.charts import BarChart, ChartSeries -from prefab_ui.app import PrefabApp - -with PrefabApp() as app: - with Column(gap=4): - Heading("Revenue") - BarChart(data=data, series=[ChartSeries(data_key="revenue")], x_axis="quarter") -""", - data={"data": [{"quarter": "Q1", "revenue": 42000}, ...]} -) -``` - -The component search tool lets the LLM discover what's available before writing code — `search_prefab_components("Chart")` returns matching components with import paths. - -## Requirements - -Requires `fastmcp[apps]` (installs `prefab-ui`). The Pyodide sandbox for server-side validation requires Deno, which installs automatically on first use. The streaming renderer loads Pyodide from CDN in the browser — CSP is configured automatically. - -The sandbox includes the Python standard library and Prefab. External packages (NumPy, pandas, etc.) are not available. - -## Learn More - -The full **[Generative UI guide](/apps/generative)** covers the streaming mechanics in detail, how to pass data, the component search tool, and sandbox limitations. diff --git a/examples/auth/authkit_dcr/README.md b/examples/auth/authkit_dcr/README.md deleted file mode 100644 index 808246199..000000000 --- a/examples/auth/authkit_dcr/README.md +++ /dev/null @@ -1,25 +0,0 @@ -# AuthKit DCR Example - -Demonstrates FastMCP server protection with AuthKit Dynamic Client Registration. - -## Setup - -1. Set your AuthKit domain: - - ```bash - export AUTHKIT_DOMAIN="https://your-app.authkit.app" - ``` - -2. Run the server: - - ```bash - python server.py - ``` - -3. In another terminal, run the client: - - ```bash - python client.py - ``` - -The client will open your browser for AuthKit authentication. diff --git a/examples/auth/authkit_dcr/client.py b/examples/auth/authkit_dcr/client.py deleted file mode 100644 index 562637bec..000000000 --- a/examples/auth/authkit_dcr/client.py +++ /dev/null @@ -1,33 +0,0 @@ -"""OAuth client example for connecting to FastMCP servers. - -This example demonstrates how to connect to an OAuth-protected FastMCP server. - -To run: - python client.py -""" - -import asyncio - -from fastmcp.client import Client -from fastmcp.client.auth import OAuth - -SERVER_URL = "http://127.0.0.1:8000/mcp" - - -async def main(): - # AuthKit defaults DCR clients to client_secret_basic, which conflicts - # with how MCP SDKs send credentials. Force "none" to register as a - # public client and avoid token exchange errors. - auth = OAuth(additional_client_metadata={"token_endpoint_auth_method": "none"}) - async with Client(SERVER_URL, auth=auth) as client: - assert await client.ping() - print("Successfully authenticated!") - - tools = await client.list_tools() - print(f"Available tools ({len(tools)}):") - for tool in tools: - print(f" - {tool.name}: {tool.description}") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/auth/authkit_dcr/server.py b/examples/auth/authkit_dcr/server.py deleted file mode 100644 index 8974376d2..000000000 --- a/examples/auth/authkit_dcr/server.py +++ /dev/null @@ -1,32 +0,0 @@ -"""AuthKit DCR server example for FastMCP. - -This example demonstrates how to protect a FastMCP server with AuthKit DCR. - -Required environment variables: -- FASTMCP_SERVER_AUTH_AUTHKITPROVIDER_AUTHKIT_DOMAIN: Your AuthKit domain (e.g., "https://your-app.authkit.app") - -To run: - python server.py -""" - -import os - -from fastmcp import FastMCP -from fastmcp.server.auth.providers.workos import AuthKitProvider - -auth = AuthKitProvider( - authkit_domain=os.getenv("AUTHKIT_DOMAIN") or "", - base_url="http://localhost:8000", -) - -mcp = FastMCP("AuthKit DCR Example Server", auth=auth) - - -@mcp.tool -def echo(message: str) -> str: - """Echo the provided message.""" - return message - - -if __name__ == "__main__": - mcp.run(transport="http", port=8000) diff --git a/examples/filesystem-provider/mcp/prompts/assistant.py b/examples/filesystem-provider/mcp/prompts/assistant.py deleted file mode 100644 index 0950e3423..000000000 --- a/examples/filesystem-provider/mcp/prompts/assistant.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Assistant prompts.""" - -from fastmcp.prompts import prompt - - -@prompt -def code_review(code: str, language: str = "python") -> str: - """Generate a code review prompt. - - Args: - code: The code to review. - language: Programming language (default: python). - """ - return f"""Please review this {language} code: - -```{language} -{code} -``` - -Focus on: -- Code quality and readability -- Potential bugs or issues -- Performance considerations -- Best practices""" - - -@prompt( - name="explain-concept", - description="Generate a prompt to explain a technical concept.", - tags={"education", "explanation"}, -) -def explain(topic: str, audience: str = "developer") -> str: - """Generate an explanation prompt. - - Args: - topic: The concept to explain. - audience: Target audience level. - """ - return f"Explain {topic} to a {audience}. Use clear examples and analogies." diff --git a/examples/filesystem-provider/mcp/resources/config.py b/examples/filesystem-provider/mcp/resources/config.py deleted file mode 100644 index 03a3f7b11..000000000 --- a/examples/filesystem-provider/mcp/resources/config.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Configuration resources - static and templated.""" - -import json - -from fastmcp.resources import resource - - -# Static resource - no parameters in URI -@resource("config://app") -def get_app_config() -> str: - """Get application configuration.""" - return json.dumps( - { - "name": "FilesystemDemo", - "version": "1.0.0", - "features": ["tools", "resources", "prompts"], - }, - indent=2, - ) - - -# Resource template - {env} is a parameter -@resource("config://env/{env}") -def get_env_config(env: str) -> str: - """Get environment-specific configuration. - - Args: - env: Environment name (dev, staging, prod). - """ - configs = { - "dev": {"debug": True, "log_level": "DEBUG", "database": "localhost"}, - "staging": {"debug": True, "log_level": "INFO", "database": "staging-db"}, - "prod": {"debug": False, "log_level": "WARNING", "database": "prod-db"}, - } - config = configs.get(env, {"error": f"Unknown environment: {env}"}) - return json.dumps(config, indent=2) - - -# Resource with custom metadata -@resource( - "config://features", - name="feature-flags", - mime_type="application/json", - tags={"config", "features"}, -) -def get_feature_flags() -> str: - """Get feature flags configuration.""" - return json.dumps( - { - "dark_mode": True, - "beta_features": False, - "max_upload_size_mb": 100, - }, - indent=2, - ) diff --git a/examples/filesystem-provider/mcp/tools/calculator.py b/examples/filesystem-provider/mcp/tools/calculator.py deleted file mode 100644 index f8d901067..000000000 --- a/examples/filesystem-provider/mcp/tools/calculator.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Math tools with custom metadata.""" - -from fastmcp.tools import tool - - -@tool( - name="add-numbers", # Custom name (default would be "add") - description="Add two numbers together.", - tags={"math", "arithmetic"}, -) -def add(a: float, b: float) -> float: - """Add two numbers.""" - return a + b - - -@tool(tags={"math", "arithmetic"}) -def multiply(a: float, b: float) -> float: - """Multiply two numbers. - - Args: - a: First number. - b: Second number. - """ - return a * b diff --git a/examples/filesystem-provider/mcp/tools/greeting.py b/examples/filesystem-provider/mcp/tools/greeting.py deleted file mode 100644 index f124902a1..000000000 --- a/examples/filesystem-provider/mcp/tools/greeting.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Greeting tools - multiple tools in one file.""" - -from fastmcp.tools import tool - - -@tool -def greet(name: str) -> str: - """Greet someone by name. - - Args: - name: The person's name. - """ - return f"Hello, {name}!" - - -@tool -def farewell(name: str) -> str: - """Say goodbye to someone. - - Args: - name: The person's name. - """ - return f"Goodbye, {name}!" - - -# Helper functions without decorators are ignored -def _format_message(msg: str) -> str: - return msg.strip().capitalize() diff --git a/src/fastmcp/__init__.py b/src/fastmcp/__init__.py deleted file mode 100644 index 3208b064a..000000000 --- a/src/fastmcp/__init__.py +++ /dev/null @@ -1,59 +0,0 @@ -"""FastMCP - An ergonomic MCP interface.""" - -import importlib -import warnings -from importlib.metadata import version as _version -from typing import TYPE_CHECKING - -from fastmcp.settings import Settings -from fastmcp.utilities.logging import configure_logging as _configure_logging - -if TYPE_CHECKING: - from fastmcp.client import Client as Client - from fastmcp.apps.app import FastMCPApp as FastMCPApp - -settings = Settings() -if settings.log_enabled: - _configure_logging( - level=settings.log_level, - enable_rich_tracebacks=settings.enable_rich_tracebacks, - ) - -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.server import FastMCP -from fastmcp.server.context import Context -import fastmcp.server - -__version__ = _version("fastmcp") - -if settings.deprecation_warnings: - warnings.simplefilter("default", FastMCPDeprecationWarning) - - -# --- Lazy imports for performance (see #3292) --- -# Client and the client submodule are deferred so that server-only users -# don't pay for the client import chain. Do not convert back to top-level. - - -def __getattr__(name: str) -> object: - if name == "Client": - from fastmcp.client import Client - - return Client - if name == "FastMCPApp": - from fastmcp.apps.app import FastMCPApp - - return FastMCPApp - if name == "client": - return importlib.import_module("fastmcp.client") - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - -__all__ = [ - "Client", - "Context", - "FastMCP", - "FastMCPApp", - "FastMCPDeprecationWarning", - "settings", -] diff --git a/src/fastmcp/apps/__init__.py b/src/fastmcp/apps/__init__.py deleted file mode 100644 index d8fc21696..000000000 --- a/src/fastmcp/apps/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -"""FastMCP Apps — interactive UIs for MCP tools. - -This package contains the app-related components: - -- ``FastMCPApp`` — composable provider for interactive apps with backend tools -- ``AppConfig`` — configuration for MCP App tools and resources -- ``ResourceCSP`` / ``ResourcePermissions`` — security configuration -""" - -from fastmcp.apps.app import FastMCPApp as FastMCPApp -from fastmcp.apps.config import AppConfig as AppConfig -from fastmcp.apps.config import PrefabAppConfig as PrefabAppConfig -from fastmcp.apps.config import ResourceCSP as ResourceCSP -from fastmcp.apps.config import ResourcePermissions as ResourcePermissions -from fastmcp.apps.config import UI_EXTENSION_ID as UI_EXTENSION_ID -from fastmcp.apps.config import app_config_to_meta_dict as app_config_to_meta_dict -from fastmcp.utilities.mime import UI_MIME_TYPE as UI_MIME_TYPE -from fastmcp.utilities.mime import resolve_ui_mime_type as resolve_ui_mime_type diff --git a/src/fastmcp/apps/app.py b/src/fastmcp/apps/app.py deleted file mode 100644 index f4fed7e15..000000000 --- a/src/fastmcp/apps/app.py +++ /dev/null @@ -1,428 +0,0 @@ -"""FastMCPApp — a Provider that represents a composable MCP application. - -FastMCPApp binds entry-point tools (model calls these) together with backend -tools (the UI calls these via CallTool). Backend tools are tagged with -``meta["fastmcp"]["app"]`` so they can be found through the provider chain -even when transforms (namespace, visibility, etc.) have renamed or hidden -them — the server sets a context var that tells ``Provider.get_tool`` to -fall back to a direct lookup for app-visible tools. - -Usage:: - - from fastmcp import FastMCP, FastMCPApp - - app = FastMCPApp("Dashboard") - - @app.ui() - def show_dashboard() -> Component: - return Column(...) - - @app.tool() - def save_contact(name: str, email: str) -> str: - return name - - server = FastMCP("Platform") - server.add_provider(app) -""" - -from __future__ import annotations - -import inspect -from collections.abc import AsyncIterator, Callable, Sequence -from contextlib import asynccontextmanager, suppress -from typing import Any, Literal, TypeVar, overload - -from mcp.types import AnyFunction, Icon, ToolAnnotations - -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.providers.base import Provider -from fastmcp.server.providers.local_provider import LocalProvider -from fastmcp.tools.base import Tool -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - -F = TypeVar("F", bound=Callable[..., Any]) - - -# --------------------------------------------------------------------------- -# CallTool resolver -# --------------------------------------------------------------------------- - - -def _make_resolver(app_name: str | None = None) -> Any: - """Create a CallTool resolver that prefixes tool names with the app name. - - When ``app_name`` is set, tool references like ``CallTool("store_files")`` - or ``CallTool(store_files)`` are resolved to - ``ResolvedTool(name="Files___store_files")``. This produces stable - identifiers that bypass transforms and work without host ``_meta`` - forwarding. - """ - - def _prefix(name: str) -> str: - if app_name and "___" not in name: - return f"{app_name}___{name}" - return name - - def _resolve_tool_ref(fn: Any) -> Any: - from prefab_ui.app import ResolvedTool - - if isinstance(fn, str): - return ResolvedTool(name=_prefix(fn)) - - fmeta: Any = None - try: - from fastmcp.decorators import get_fastmcp_meta - - fmeta = get_fastmcp_meta(fn) - except Exception: - pass - - if fmeta is not None: - name: str | None = getattr(fmeta, "name", None) - if name is not None: - return ResolvedTool(name=_prefix(name)) - - fn_name = getattr(fn, "__name__", None) - if fn_name is not None: - return ResolvedTool(name=_prefix(fn_name)) - - raise ValueError(f"Cannot resolve tool reference: {fn!r}") - - return _resolve_tool_ref - - -def _dispatch_decorator( - name_or_fn: str | AnyFunction | None, - name: str | None, - register: Callable[[Any, str | None], Any], - decorator_name: str, -) -> Any: - """Shared dispatch logic for @app.tool() and @app.ui() calling patterns.""" - if inspect.isroutine(name_or_fn): - return register(name_or_fn, name) - - if isinstance(name_or_fn, str): - if name is not None: - raise TypeError( - "Cannot specify both a name as first argument and as keyword argument." - ) - tool_name: str | None = name_or_fn - elif name_or_fn is None: - tool_name = name - else: - raise TypeError( - f"First argument to @{decorator_name} must be a function, string, or None, " - f"got {type(name_or_fn)}" - ) - - def decorator(fn: F) -> F: - return register(fn, tool_name) - - return decorator - - -# --------------------------------------------------------------------------- -# FastMCPApp -# --------------------------------------------------------------------------- - - -class FastMCPApp(Provider): - """A Provider that represents an MCP application. - - Binds together entry-point tools (``@app.ui``), backend tools - (``@app.tool``), and the Prefab renderer resource. Backend tools - are tagged with ``meta["fastmcp"]["app"]`` so ``Provider.get_tool`` - can find them by original name even when transforms have been applied. - """ - - def __init__(self, name: str) -> None: - if "___" in name: - raise ValueError( - f"App name {name!r} must not contain '___' " - "(reserved as the app tool routing separator)" - ) - super().__init__() - self.name = name - self._local = LocalProvider(on_duplicate="error") - - def __repr__(self) -> str: - return f"FastMCPApp({self.name!r})" - - # ------------------------------------------------------------------ - # @app.tool() — backend tools called by the UI - # ------------------------------------------------------------------ - - @overload - def tool( - self, - name_or_fn: F, - *, - name: str | None = None, - description: str | None = None, - model: bool = False, - auth: AuthCheck | list[AuthCheck] | None = None, - timeout: float | None = None, - ) -> F: ... - - @overload - def tool( - self, - name_or_fn: str | None = None, - *, - name: str | None = None, - description: str | None = None, - model: bool = False, - auth: AuthCheck | list[AuthCheck] | None = None, - timeout: float | None = None, - ) -> Callable[[F], F]: ... - - def tool( - self, - name_or_fn: str | AnyFunction | None = None, - *, - name: str | None = None, - description: str | None = None, - model: bool = False, - auth: AuthCheck | list[AuthCheck] | None = None, - timeout: float | None = None, - ) -> Any: - """Register a backend tool that the UI calls via CallTool. - - Backend tools default to ``visibility=["app"]``. Pass ``model=True`` - to also expose the tool to the model (``visibility=["app", "model"]``). - - Supports multiple calling patterns:: - - @app.tool - def save(name: str): ... - - @app.tool() - def save(name: str): ... - - @app.tool("custom_name") - def save(name: str): ... - """ - visibility: list[Literal["app", "model"]] = ( - ["app", "model"] if model else ["app"] - ) - - def _register(fn: F, tool_name: str | None) -> F: - resolved_name = tool_name or getattr(fn, "__name__", None) - if resolved_name is None: - raise ValueError(f"Cannot determine tool name for {fn!r}") - - from fastmcp.apps.config import AppConfig, app_config_to_meta_dict - - app_config = AppConfig(visibility=visibility) - meta: dict[str, Any] = { - "ui": app_config_to_meta_dict(app_config), - "fastmcp": {"app": self.name}, - } - - tool_obj = Tool.from_function( - fn, - name=resolved_name, - description=description, - meta=meta, - timeout=timeout, - auth=auth, - ) - self._local._add_component(tool_obj) - return fn - - return _dispatch_decorator(name_or_fn, name, _register, "tool") - - # ------------------------------------------------------------------ - # @app.ui() — entry-point tools the model calls to open the app - # ------------------------------------------------------------------ - - @overload - def ui( - self, - name_or_fn: F, - *, - name: str | None = None, - description: str | None = None, - title: str | None = None, - tags: set[str] | None = None, - icons: list[Icon] | None = None, - annotations: ToolAnnotations | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - timeout: float | None = None, - ) -> F: ... - - @overload - def ui( - self, - name_or_fn: str | None = None, - *, - name: str | None = None, - description: str | None = None, - title: str | None = None, - tags: set[str] | None = None, - icons: list[Icon] | None = None, - annotations: ToolAnnotations | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - timeout: float | None = None, - ) -> Callable[[F], F]: ... - - def ui( - self, - name_or_fn: str | AnyFunction | None = None, - *, - name: str | None = None, - description: str | None = None, - title: str | None = None, - tags: set[str] | None = None, - icons: list[Icon] | None = None, - annotations: ToolAnnotations | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - timeout: float | None = None, - ) -> Any: - """Register a UI entry-point tool that the model calls. - - Entry-point tools default to ``visibility=["model"]`` and auto-wire - the Prefab renderer resource and CSP. They are tagged with the app - name so structured content includes ``_meta.fastmcp.app``. - - Supports multiple calling patterns:: - - @app.ui - def dashboard() -> Component: ... - - @app.ui() - def dashboard() -> Component: ... - - @app.ui("my_dashboard") - def dashboard() -> Component: ... - """ - - def _register(fn: F, tool_name: str | None) -> F: - from fastmcp.apps.config import AppConfig, app_config_to_meta_dict - from fastmcp.server.providers.local_provider.decorators.tools import ( - PREFAB_RENDERER_URI, - _ensure_prefab_renderer, - ) - - try: - from prefab_ui.renderer import get_renderer_csp - - from fastmcp.apps.config import ResourceCSP - - csp = get_renderer_csp() - app_config = AppConfig( - resource_uri=PREFAB_RENDERER_URI, - visibility=["model"], - csp=ResourceCSP( - resource_domains=csp.get("resource_domains"), - connect_domains=csp.get("connect_domains"), - ), - ) - except ImportError: - app_config = AppConfig( - resource_uri=PREFAB_RENDERER_URI, - visibility=["model"], - ) - - meta: dict[str, Any] = { - "ui": app_config_to_meta_dict(app_config), - "fastmcp": {"app": self.name}, - } - - tool_obj = Tool.from_function( - fn, - name=tool_name, - description=description, - title=title, - tags=tags, - icons=icons, - annotations=annotations, - meta=meta, - timeout=timeout, - auth=auth, - ) - self._local._add_component(tool_obj) - - # Register the Prefab renderer resource on the internal provider - with suppress(ImportError): - _ensure_prefab_renderer(self._local) - - return fn - - return _dispatch_decorator(name_or_fn, name, _register, "ui") - - # ------------------------------------------------------------------ - # Programmatic tool addition - # ------------------------------------------------------------------ - - def add_tool( - self, - tool: Tool | Callable[..., Any], - ) -> Tool: - """Add a tool to this app programmatically. - - The tool is tagged with this app's name for routing. - """ - if not isinstance(tool, Tool): - tool = Tool._ensure_tool(tool) - - meta = dict(tool.meta) if tool.meta else {} - meta.setdefault("fastmcp", {})["app"] = self.name - ui = meta.setdefault("ui", {}) - if "visibility" not in ui: - ui["visibility"] = ["app"] - tool.meta = meta - - self._local._add_component(tool) - return tool - - # ------------------------------------------------------------------ - # Provider interface — delegate to internal LocalProvider - # ------------------------------------------------------------------ - - async def _list_tools(self) -> Sequence[Tool]: - return await self._local._list_tools() - - async def _get_tool(self, name: str, version: Any = None) -> Tool | None: - return await self._local._get_tool(name, version) - - async def _list_resources(self) -> Sequence[Any]: - return await self._local._list_resources() - - async def _get_resource(self, uri: str, version: Any = None) -> Any | None: - return await self._local._get_resource(uri, version) - - async def _list_resource_templates(self) -> Sequence[Any]: - return await self._local._list_resource_templates() - - async def _get_resource_template(self, uri: str, version: Any = None) -> Any | None: - return await self._local._get_resource_template(uri, version) - - async def _list_prompts(self) -> Sequence[Any]: - return await self._local._list_prompts() - - async def _get_prompt(self, name: str, version: Any = None) -> Any | None: - return await self._local._get_prompt(name, version) - - @asynccontextmanager - async def lifespan(self) -> AsyncIterator[None]: - async with self._local.lifespan(): - yield - - # ------------------------------------------------------------------ - # Convenience runner - # ------------------------------------------------------------------ - - def run( - self, - transport: Literal["stdio", "http", "sse", "streamable-http"] | None = None, - **kwargs: Any, - ) -> None: - """Create a temporary FastMCP server and run this app standalone.""" - from fastmcp.server.server import FastMCP - - server = FastMCP(self.name) - server.add_provider(self) - server.run(transport=transport, **kwargs) diff --git a/src/fastmcp/apps/approval.py b/src/fastmcp/apps/approval.py deleted file mode 100644 index 17b124e1f..000000000 --- a/src/fastmcp/apps/approval.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Approval — a Provider that adds human-in-the-loop approval to any server. - -The LLM presents a summary of what it's about to do, and the user -approves or rejects via buttons. The result is sent back into the -conversation as a message, prompting the LLM's next turn. - -Requires ``fastmcp[apps]`` (prefab-ui). - -Usage:: - - from fastmcp import FastMCP - from fastmcp.apps.approval import Approval - - mcp = FastMCP("My Server") - mcp.add_provider(Approval()) -""" - -from __future__ import annotations - -from typing import Literal - -try: - from prefab_ui.actions import SetState - from prefab_ui.actions.mcp import SendMessage - from prefab_ui.app import PrefabApp - from prefab_ui.components import ( - H3, - Button, - Card, - CardContent, - CardFooter, - CardHeader, - Column, - Muted, - Row, - Text, - ) - from prefab_ui.components.control_flow import If - from prefab_ui.rx import STATE -except ImportError as _exc: - raise ImportError( - "Approval requires prefab-ui. Install with: pip install 'fastmcp[apps]'" - ) from _exc - - -from fastmcp.apps.app import FastMCPApp - - -class Approval(FastMCPApp): - """A Provider that adds human-in-the-loop approval to a server. - - The LLM calls the ``request_approval`` tool with a summary and - optional details. The user sees an approval card with Approve and - Reject buttons. Clicking either sends a message back into the - conversation (via ``SendMessage``), triggering the LLM's next turn. - - The message appears as if the user sent it, so the LLM sees - something like ``'"Deploy v3.2 to production" is APPROVED'``. - - Example:: - - from fastmcp import FastMCP - from fastmcp.apps.approval import Approval - - mcp = FastMCP("My Server") - mcp.add_provider(Approval()) - - Customized:: - - Approval( - title="Deploy Gate", - approve_text="Ship it", - approve_variant="default", - reject_text="Abort", - reject_variant="destructive", - ) - """ - - def __init__( - self, - name: str = "Approval", - *, - title: str = "Approval Required", - approve_text: str = "Approve", - reject_text: str = "Reject", - approve_variant: Literal[ - "default", "destructive", "success", "info" - ] = "default", - reject_variant: Literal[ - "default", "outline", "destructive", "success", "info" - ] = "outline", - ) -> None: - super().__init__(name) - self._title = title - self._approve_text = approve_text - self._reject_text = reject_text - self._approve_variant = approve_variant - self._reject_variant = reject_variant - self._register_tools() - - def __repr__(self) -> str: - return f"Approval({self.name!r})" - - def _register_tools(self) -> None: - provider = self - - @self.ui() - def request_approval( - summary: str, - details: str | None = None, - title: str | None = None, - approve_text: str | None = None, - reject_text: str | None = None, - approve_variant: str | None = None, - reject_variant: str | None = None, - ) -> PrefabApp: - """Request human approval before proceeding with an action. - - Call this tool proactively whenever you are about to take a - significant or irreversible action and want the user to - confirm first. Do NOT wait for the user to ask you to seek - approval — use your judgment about when confirmation is - appropriate. - - The user will see an approval card with the summary, optional - details, and Approve/Reject buttons. When they click a button, - their decision appears as a message in the conversation (as if - the user typed it), like: - - "Deploy v3.2 to production" — I selected: Approve - - or: - - "Deploy v3.2 to production" — I selected: Reject - - IMPORTANT: After calling this tool, you MUST stop and wait - for the user's response. Do not continue, do not take any - other actions, do not generate further output until you see - the "I selected:" message. If approved, continue with the - action. If rejected, acknowledge and ask how to proceed. - - Args: - summary: Brief description of the action requiring approval - (shown prominently to the user). - details: Optional longer explanation, context, or - consequences of the action. - title: Heading for the approval card (default: "Approval Required"). - approve_text: Label for the approve button (default: "Approve"). - reject_text: Label for the reject button (default: "Reject"). - approve_variant: Button style — "default", "destructive", - "success", or "info". - reject_variant: Button style for the reject button - (same options plus "outline"). - """ - _title = title or provider._title - _approve = approve_text or provider._approve_text - _reject = reject_text or provider._reject_text - _approve_v = approve_variant or provider._approve_variant - _reject_v = reject_variant or provider._reject_variant - - approve_msg = f'"{summary}" — I selected: {_approve}' - reject_msg = f'"{summary}" — I selected: {_reject}' - - with Card(css_class="max-w-lg mx-auto") as view: - with CardHeader(): - H3(_title) - - with CardContent(), Column(gap=3): - Text(summary, css_class="font-medium") - if details: - Muted(details) - - with CardFooter(): - with If(STATE.decided): - Muted("Response sent.") - with If(~STATE.decided): # noqa: SIM117 - with Row(gap=2, css_class="w-full justify-end"): - Button( - _reject, - variant=_reject_v, - on_click=[ - SendMessage(reject_msg), - SetState("decided", True), - ], - ) - Button( - _approve, - variant=_approve_v, - on_click=[ - SendMessage(approve_msg), - SetState("decided", True), - ], - ) - - return PrefabApp( - view=view, - state={"decided": False}, - ) diff --git a/src/fastmcp/apps/choice.py b/src/fastmcp/apps/choice.py deleted file mode 100644 index aaffef903..000000000 --- a/src/fastmcp/apps/choice.py +++ /dev/null @@ -1,141 +0,0 @@ -"""Choice — a Provider that lets the user pick from a set of options. - -The LLM presents options, the user clicks one, and the selection -flows back into the conversation as a message. - -Requires ``fastmcp[apps]`` (prefab-ui). - -Usage:: - - from fastmcp import FastMCP - from fastmcp.apps.choice import Choice - - mcp = FastMCP("My Server") - mcp.add_provider(Choice()) -""" - -from __future__ import annotations - -from typing import Literal - -try: - from prefab_ui.actions import SetState - from prefab_ui.actions.mcp import SendMessage - from prefab_ui.app import PrefabApp - from prefab_ui.components import ( - H3, - Button, - Card, - CardContent, - CardFooter, - CardHeader, - Column, - Muted, - Text, - ) - from prefab_ui.components.control_flow import If - from prefab_ui.rx import STATE -except ImportError as _exc: - raise ImportError( - "Choice requires prefab-ui. Install with: pip install 'fastmcp[apps]'" - ) from _exc - -from fastmcp.apps.app import FastMCPApp - - -class Choice(FastMCPApp): - """A Provider that lets the user choose from a set of options. - - The LLM calls ``choose`` with a prompt and a list of options. - The user sees a card with one button per option. Clicking a button - sends the selection back into the conversation via ``SendMessage``, - triggering the LLM's next turn. - - Example:: - - from fastmcp import FastMCP - from fastmcp.apps.choice import Choice - - mcp = FastMCP("My Server") - mcp.add_provider(Choice()) - """ - - def __init__( - self, - name: str = "Choice", - *, - title: str = "Choose an Option", - variant: Literal[ - "default", "outline", "destructive", "success", "info" - ] = "outline", - ) -> None: - super().__init__(name) - self._title = title - self._variant = variant - self._register_tools() - - def __repr__(self) -> str: - return f"Choice({self.name!r})" - - def _register_tools(self) -> None: - provider = self - - @self.ui() - def choose( - prompt: str, - options: list[str], - title: str | None = None, - ) -> PrefabApp: - """Present the user with a set of options to choose from. - - Call this tool when you need the user to make a decision - between discrete alternatives. Use it proactively — don't - ask the user to type their choice in chat when you can - present clean, clickable options instead. - - The user will see a card with one button per option. When - they click one, their choice appears as a message in the - conversation (as if the user typed it), like: - - "Which deployment strategy?" — I selected: Blue-green - - IMPORTANT: After calling this tool, you MUST stop and wait - for the user's response. Do not continue or take any other - actions until you see the "I selected:" message. - - Args: - prompt: The question or decision to present to the user. - options: List of options the user can choose from. - title: Optional heading for the card. - """ - _title = title or provider._title - - with Card(css_class="max-w-lg mx-auto") as view: - with CardHeader(): - H3(_title) - - with CardContent(): - Text(prompt, css_class="font-medium") - - with CardFooter(): - with If(STATE.decided): - Muted("Response sent.") - with If(~STATE.decided): # noqa: SIM117 - with Column(gap=2, css_class="w-full"): - for option in options: - Button( - option, - variant=provider._variant, - css_class="w-full justify-start", - on_click=[ - SendMessage( - f'"{prompt}" — I selected: {option}' - ), - SetState("decided", True), - ], - ) - - return PrefabApp( - view=view, - state={"decided": False}, - ) diff --git a/src/fastmcp/apps/config.py b/src/fastmcp/apps/config.py deleted file mode 100644 index c55cd0b13..000000000 --- a/src/fastmcp/apps/config.py +++ /dev/null @@ -1,177 +0,0 @@ -"""MCP Apps support — extension negotiation and typed UI metadata models. - -Provides constants and Pydantic models for the MCP Apps extension -(io.modelcontextprotocol/ui), enabling tools and resources to carry -UI metadata for clients that support interactive app rendering. -""" - -from __future__ import annotations - -from typing import Any, Literal - -from pydantic import BaseModel, Field - -from fastmcp.utilities.mime import UI_MIME_TYPE as UI_MIME_TYPE -from fastmcp.utilities.mime import resolve_ui_mime_type as resolve_ui_mime_type - -UI_EXTENSION_ID = "io.modelcontextprotocol/ui" - - -class ResourceCSP(BaseModel): - """Content Security Policy for MCP App resources. - - Declares which external origins the app is allowed to connect to or - load resources from. Hosts use these declarations to build the - ``Content-Security-Policy`` header for the sandboxed iframe. - """ - - connect_domains: list[str] | None = Field( - default=None, - alias="connectDomains", - description="Origins allowed for fetch/XHR/WebSocket (connect-src)", - ) - resource_domains: list[str] | None = Field( - default=None, - alias="resourceDomains", - description="Origins allowed for scripts, images, styles, fonts (script-src etc.)", - ) - frame_domains: list[str] | None = Field( - default=None, - alias="frameDomains", - description="Origins allowed for nested iframes (frame-src)", - ) - base_uri_domains: list[str] | None = Field( - default=None, - alias="baseUriDomains", - description="Allowed base URIs for the document (base-uri)", - ) - - model_config = {"populate_by_name": True, "extra": "allow"} - - -class ResourcePermissions(BaseModel): - """Iframe sandbox permissions for MCP App resources. - - Each field, when set (typically to ``{}``), requests that the host - grant the corresponding Permission Policy feature to the sandboxed - iframe. Hosts MAY honour these; apps should use JS feature detection - as a fallback. - """ - - camera: dict[str, Any] | None = Field( - default=None, description="Request camera access" - ) - microphone: dict[str, Any] | None = Field( - default=None, description="Request microphone access" - ) - geolocation: dict[str, Any] | None = Field( - default=None, description="Request geolocation access" - ) - clipboard_write: dict[str, Any] | None = Field( - default=None, - alias="clipboardWrite", - description="Request clipboard-write access", - ) - - model_config = {"populate_by_name": True, "extra": "allow"} - - -class AppConfig(BaseModel): - """Configuration for MCP App tools and resources. - - Controls how a tool or resource participates in the MCP Apps extension. - On tools, ``resource_uri`` and ``visibility`` specify which UI resource - to render and where the tool appears. On resources, those fields must - be left unset (the resource itself is the UI). - - All fields use ``exclude_none`` serialization so only explicitly-set - values appear on the wire. Aliases match the MCP Apps wire format - (camelCase). - """ - - resource_uri: str | None = Field( - default=None, - alias="resourceUri", - description="URI of the UI resource (typically ui:// scheme). Tools only.", - ) - visibility: list[Literal["app", "model"]] | None = Field( - default=None, - description="Where this tool is visible: 'app', 'model', or both. Tools only.", - ) - csp: ResourceCSP | None = Field( - default=None, description="Content Security Policy for the app iframe" - ) - permissions: ResourcePermissions | None = Field( - default=None, description="Iframe sandbox permissions" - ) - domain: str | None = Field(default=None, description="Domain for the iframe") - prefers_border: bool | None = Field( - default=None, - alias="prefersBorder", - description="Whether the UI prefers a visible border", - ) - - model_config = {"populate_by_name": True, "extra": "allow"} - - -class PrefabAppConfig(AppConfig): - """App configuration for Prefab tools with sensible defaults. - - Like ``app=True`` but customizable. Auto-wires the Prefab renderer - URI and merges the renderer's CSP with any additional domains you - specify. The renderer resource is registered automatically. - - Example:: - - @mcp.tool(app=PrefabAppConfig()) # same as app=True - - @mcp.tool(app=PrefabAppConfig( - csp=ResourceCSP(frame_domains=["https://example.com"]), - )) - """ - - def model_post_init(self, __context: Any) -> None: - # Set the renderer URI if not explicitly overridden - if self.resource_uri is None: - self.resource_uri = "ui://prefab/renderer.html" - - # Merge renderer CSP with user-provided CSP - try: - from prefab_ui.renderer import get_renderer_csp - - renderer_csp = get_renderer_csp() - except ImportError: - renderer_csp = {} - - if renderer_csp: - user_csp = self.csp or ResourceCSP() - # Start from the user's CSP (preserves model_extra for - # forward-compat directives), then merge renderer domains. - merged_data = user_csp.model_dump(exclude_none=True) - merged_data["connect_domains"] = _merge_domains( - renderer_csp.get("connect_domains"), - user_csp.connect_domains, - ) - merged_data["resource_domains"] = _merge_domains( - renderer_csp.get("resource_domains"), - user_csp.resource_domains, - ) - self.csp = ResourceCSP(**merged_data) - - -def _merge_domains(base: list[str] | None, extra: list[str] | None) -> list[str] | None: - """Merge two domain lists, deduplicating.""" - if base is None and extra is None: - return None - combined = list(base or []) - for d in extra or []: - if d not in combined: - combined.append(d) - return combined or None - - -def app_config_to_meta_dict(app: AppConfig | dict[str, Any]) -> dict[str, Any]: - """Convert an AppConfig or dict to the wire-format dict for ``meta["ui"]``.""" - if isinstance(app, AppConfig): - return app.model_dump(by_alias=True, exclude_none=True) - return app diff --git a/src/fastmcp/apps/file_upload.py b/src/fastmcp/apps/file_upload.py deleted file mode 100644 index 8cc91e039..000000000 --- a/src/fastmcp/apps/file_upload.py +++ /dev/null @@ -1,393 +0,0 @@ -"""FileUpload — a Provider that adds drag-and-drop file upload to any server. - -Lets users upload files directly to the server through an interactive UI, -bypassing the LLM context window entirely. The LLM can then read and work -with uploaded files through model-visible tools. - -Requires ``fastmcp[apps]`` (prefab-ui). - -Usage:: - - from fastmcp import FastMCP - from fastmcp.apps import FileUpload - - mcp = FastMCP("My Server") - mcp.add_provider(FileUpload()) - -For custom persistence, override the storage methods:: - - class S3Upload(FileUpload): - def on_store(self, files, ctx): - # write to S3, return summaries - ... - - def on_list(self, ctx): - # list from S3 - ... - - def on_read(self, name, ctx): - # read from S3 - ... -""" - -from __future__ import annotations - -try: - from prefab_ui.actions import SetState, ShowToast - from prefab_ui.actions.mcp import CallTool - from prefab_ui.app import PrefabApp - from prefab_ui.components import ( - H3, - Badge, - Button, - Card, - CardContent, - CardFooter, - CardHeader, - Column, - DropZone, - Muted, - Row, - Separator, - Small, - Text, - ) - from prefab_ui.components.control_flow import Else, ForEach, If - from prefab_ui.rx import ERROR, RESULT, STATE, Rx -except ImportError as _exc: - raise ImportError( - "FileUpload requires prefab-ui. Install with: pip install 'fastmcp[apps]'" - ) from _exc - -import base64 -from datetime import datetime -from typing import Any - -from fastmcp.apps.app import FastMCPApp -from fastmcp.server.context import Context - -_TEXT_EXTENSIONS = frozenset( - (".csv", ".json", ".txt", ".md", ".py", ".yaml", ".yml", ".toml") -) - - -def _format_size(size: int) -> str: - if size < 1024: - return f"{size} B" - elif size < 1024 * 1024: - return f"{size / 1024:.1f} KB" - else: - return f"{size / (1024 * 1024):.1f} MB" - - -def _make_summary(entry: dict[str, Any]) -> dict[str, Any]: - return { - "name": entry["name"], - "type": entry["type"], - "size": entry["size"], - "size_display": _format_size(entry["size"]), - "uploaded_at": entry["uploaded_at"], - } - - -class FileUpload(FastMCPApp): - """A Provider that adds file upload capabilities to a server. - - Registers a drag-and-drop UI tool, a backend storage tool, and - model-visible tools for listing and reading uploaded files. - - Files are scoped by MCP session and stored in memory by default. - Override ``on_store``, ``on_list``, and ``on_read`` for custom - persistence (filesystem, S3, database, etc.). Each method receives - the current ``Context``, giving access to session ID, auth tokens, - and request metadata for partitioning and authorization. - - **Session scoping:** The default storage uses ``ctx.session_id`` to - isolate files by session. This works with stdio, SSE, and stateful - HTTP transports. In **stateless HTTP** mode, each request creates a - new session, so files won't persist across requests. For stateless - deployments, override the storage methods to partition by a stable - identifier from the auth context:: - - class UserScopedUpload(FileUpload): - def on_store(self, files, ctx): - user_id = ctx.access_token["sub"] - ... - - Example:: - - from fastmcp import FastMCP - from fastmcp.apps.file_upload import FileUpload - - mcp = FastMCP("My Server") - mcp.add_provider(FileUpload()) - """ - - def __init__( - self, - name: str = "Files", - *, - max_file_size: int = 10 * 1024 * 1024, - title: str = "File Upload", - description: str = ( - "Drop files to upload them to the server. " - "The model can then read and analyze them " - "without using the context window." - ), - drop_label: str = "Drop files here", - ) -> None: - super().__init__(name) - self._max_file_size = max_file_size - self._title = title - self._description = description - self._drop_label = drop_label - - # Default in-memory store, keyed by session_id - self._store: dict[str, dict[str, dict[str, Any]]] = {} - - self._register_tools() - - def __repr__(self) -> str: - return f"FileUpload({self.name!r})" - - # ------------------------------------------------------------------ - # Storage interface — override these for custom persistence - # ------------------------------------------------------------------ - - def _get_scope_key(self, ctx: Context) -> str: - """Return the key used to partition file storage. - - Defaults to ``ctx.session_id``, which is stable for stdio, SSE, - and stateful HTTP. The default ``on_store``/``on_list``/``on_read`` - implementations call this to partition the in-memory store. - - Override to scope by user, tenant, or any other dimension:: - - def _get_scope_key(self, ctx): - return ctx.access_token["sub"] - """ - try: - return ctx.session_id - except RuntimeError: - return "__default__" - - def on_store( - self, - files: list[dict[str, Any]], - ctx: Context, - ) -> list[dict[str, Any]]: - """Store uploaded files and return summaries. - - Args: - files: List of file dicts, each with ``name``, ``size``, - ``type``, and ``data`` (base64-encoded content). - ctx: The current request context. Use for session ID, - auth tokens, or any metadata needed for partitioning. - - Override this method for custom persistence. The default - implementation stores files in memory, scoped by - ``_get_scope_key(ctx)``. - - Returns: - List of file summary dicts (``name``, ``type``, ``size``, - ``size_display``, ``uploaded_at``). - """ - scope = self._get_scope_key(ctx) - session_files = self._store.setdefault(scope, {}) - for f in files: - session_files[f["name"]] = { - "name": f["name"], - "size": f["size"], - "type": f["type"], - "data": f["data"], - "uploaded_at": datetime.now().isoformat(timespec="seconds"), - } - return [_make_summary(e) for e in session_files.values()] - - def on_list(self, ctx: Context) -> list[dict[str, Any]]: - """List all stored files. - - Args: - ctx: The current request context. - - Override this method for custom persistence. The default - implementation returns files from the current scope. - - Returns: - List of file summary dicts. - """ - scope = self._get_scope_key(ctx) - session_files = self._store.get(scope, {}) - return [_make_summary(e) for e in session_files.values()] - - def on_read(self, name: str, ctx: Context) -> dict[str, Any]: - """Read a file's contents by name. - - Args: - name: The filename to read. - ctx: The current request context. - - Override this method for custom persistence. The default - implementation reads from the current scope's in-memory store. - Text files are decoded from base64; binary files return a - truncated base64 preview. - - Returns: - Dict with file metadata and ``content`` (text) or - ``content_base64`` (binary preview). - - Raises: - ValueError: If the file is not found. - """ - scope = self._get_scope_key(ctx) - session_files = self._store.get(scope, {}) - if name not in session_files: - available = list(session_files.keys()) - raise ValueError(f"File {name!r} not found. Available: {available}") - entry = session_files[name] - result: dict[str, Any] = { - "name": entry["name"], - "size": entry["size"], - "type": entry["type"], - "uploaded_at": entry["uploaded_at"], - } - is_text = entry["type"].startswith("text/") or any( - entry["name"].endswith(ext) for ext in _TEXT_EXTENSIONS - ) - if is_text: - try: - result["content"] = base64.b64decode(entry["data"]).decode("utf-8") - except UnicodeDecodeError: - result["content_base64"] = entry["data"][:200] + "..." - else: - result["content_base64"] = entry["data"][:200] + "..." - return result - - # ------------------------------------------------------------------ - # Tool registration - # ------------------------------------------------------------------ - - def _register_tools(self) -> None: - provider = self - - @self.tool() - def store_files(files: list[dict], ctx: Context) -> list[dict]: - """Store uploaded files. Receives file objects with name, size, type, data (base64).""" - for f in files: - if f.get("size", 0) > provider._max_file_size: - raise ValueError( - f"File {f.get('name', '?')!r} exceeds max size " - f"({_format_size(f['size'])} > " - f"{_format_size(provider._max_file_size)})" - ) - return provider.on_store(files, ctx) - - @self.tool(model=True) - def list_files(ctx: Context) -> list[dict]: - """List all uploaded files with metadata.""" - return provider.on_list(ctx) - - @self.tool(model=True) - def read_file(name: str, ctx: Context) -> dict: - """Read an uploaded file's contents by name.""" - return provider.on_read(name, ctx) - - @self.ui() - def file_manager(ctx: Context) -> PrefabApp: - """Upload and manage files. Drop files here to send them to the server.""" - with Card(css_class="max-w-2xl mx-auto") as view: - with CardHeader(), Row(gap=2, align="center"): - H3(provider._title) - with If(STATE.stored.length()): - Badge( - STATE.stored.length(), # ty:ignore[invalid-argument-type] - variant="secondary", - ) - - with CardContent(), Column(gap=4): - Muted(provider._description) - - DropZone( - name="pending", - icon="inbox", - label=provider._drop_label, - description=( - "Any file type, up to " - f"{_format_size(provider._max_file_size)}" - ), - multiple=True, - max_size=provider._max_file_size, - ) - - with If(STATE.pending.length()), Column(gap=2): - with ( - ForEach("pending"), - Row(gap=2, align="center"), - Column(gap=0), - ): - Small(Rx("$item.name")) # ty:ignore[invalid-argument-type] - Muted(Rx("$item.type")) # ty:ignore[invalid-argument-type] - - Button( - "Upload to Server", - on_click=CallTool( - "store_files", - arguments={ - "files": Rx("pending"), - }, - on_success=[ - SetState("stored", RESULT), - SetState("pending", []), - ShowToast( - "Files uploaded!", - variant="success", - ), - ], - on_error=ShowToast( - ERROR, # ty:ignore[invalid-argument-type] - variant="error", - ), - ), - ) - - with If(STATE.stored.length()): - Separator() - Text( - "Uploaded", - css_class="font-medium text-sm", - ) - with ( - ForEach("stored") as f, - Row( - gap=2, - align="center", - css_class="justify-between", - ), - ): - with Column(gap=0): - Small(f.name) # ty:ignore[invalid-argument-type] - Muted(f.uploaded_at) # ty:ignore[invalid-argument-type] - with Row(gap=2): - Badge(f.type, variant="secondary") # ty:ignore[invalid-argument-type] - Badge( - f.size_display, # ty:ignore[invalid-argument-type] - variant="outline", - ) - - with CardFooter(), Row(align="center", css_class="w-full"): - with If(STATE.stored.length()): - Muted( - f"{STATE.stored.length()}" - f" {STATE.stored.length().pluralize('file')}" - " on server" - ) - with Else(): - Muted("No files uploaded yet") - - return PrefabApp( - view=view, - state={ - "pending": [], - "stored": provider.on_list(ctx), - }, - ) diff --git a/src/fastmcp/apps/form.py b/src/fastmcp/apps/form.py deleted file mode 100644 index 1d6f67b5a..000000000 --- a/src/fastmcp/apps/form.py +++ /dev/null @@ -1,184 +0,0 @@ -"""FormInput — a Provider that collects structured input from the user. - -Define a Pydantic model for the data you need, and ``FormInput`` -generates a form UI. The user fills it out, the submission is -validated, and an optional callback processes the result. - -Requires ``fastmcp[apps]`` (prefab-ui). - -Usage:: - - from pydantic import BaseModel - from fastmcp import FastMCP - from fastmcp.apps.form import FormInput - - class ShippingAddress(BaseModel): - street: str - city: str - state: str - zip_code: str - - mcp = FastMCP("My Server") - mcp.add_provider(FormInput(model=ShippingAddress)) -""" - -from __future__ import annotations - -import json -from collections.abc import Callable -from typing import Any - -try: - from prefab_ui.actions import SetState - from prefab_ui.actions.mcp import CallTool, SendMessage - from prefab_ui.app import PrefabApp - from prefab_ui.components import ( - H3, - Card, - CardContent, - CardFooter, - CardHeader, - Column, - Form, - Muted, - ) - from prefab_ui.components.control_flow import If - from prefab_ui.rx import RESULT, STATE -except ImportError as _exc: - raise ImportError( - "FormInput requires prefab-ui. Install with: pip install 'fastmcp[apps]'" - ) from _exc - -import pydantic - -from fastmcp.apps.app import FastMCPApp - - -class FormInput(FastMCPApp): - """A Provider that collects structured input via a Pydantic model. - - Define a model for the data you need, and ``FormInput`` generates - a form from it using ``Form.from_model()``. Field types, labels, - descriptions, and validation are all derived from the model. - - Optionally provide an ``on_submit`` callback to process the - validated data. The callback receives a model instance and returns - a string that goes back to the LLM. Without a callback, the - validated JSON is sent directly. - - Example:: - - from pydantic import BaseModel - from fastmcp import FastMCP - from fastmcp.apps.form import FormInput - - class Contact(BaseModel): - name: str - email: str - - mcp = FastMCP("My Server") - mcp.add_provider(FormInput(model=Contact)) - - With a callback:: - - def save_contact(contact: Contact) -> str: - db.insert(contact.model_dump()) - return f"Saved {contact.name}" - - mcp.add_provider(FormInput(model=Contact, on_submit=save_contact)) - """ - - def __init__( - self, - model: type[pydantic.BaseModel], - *, - name: str | None = None, - title: str | None = None, - submit_text: str = "Submit", - tool_name: str | None = None, - on_submit: Callable[..., str] | None = None, - send_message: bool = False, - ) -> None: - app_name = name or model.__name__ - super().__init__(app_name) - self._model = model - self._title = title or model.__name__ - self._submit_text = submit_text - self._tool_name = tool_name or f"collect_{model.__name__.lower()}" - self._on_submit = on_submit - self._send_message = send_message - self._register_tools() - - def __repr__(self) -> str: - return f"FormInput({self._model.__name__!r})" - - def _register_tools(self) -> None: - provider = self - model = self._model - - @self.tool() - def submit_form(data: dict[str, Any]) -> str: - """Validate and process form submission.""" - validated = model.model_validate(data) - if provider._on_submit is not None: - return provider._on_submit(validated) - return json.dumps(validated.model_dump(mode="json")) - - @self.ui( - name=provider._tool_name, - description=( - f"Collect {model.__name__} information from the user via a form. " - f"Call this tool when you need the user to provide " - f"{model.__name__} data. The user will see a validated form. " - f"After calling this tool, STOP and wait for the user to submit." - ), - ) - def collect_input( - prompt: str, - title: str | None = None, - submit_text: str | None = None, - ) -> PrefabApp: - """Collect structured input from the user. - - Args: - prompt: Tell the user what you need and why. - title: Optional heading for the form card. - submit_text: Optional label for the submit button. - """ - _title = title or provider._title - _submit = submit_text or provider._submit_text - - with Card(css_class="max-w-lg mx-auto") as view: - with CardHeader(): - H3(_title) - - with CardContent(), Column(gap=4): - Muted(prompt) - - on_success_actions: list[Any] = [ - SetState("submitted", True), - ] - if provider._send_message: - on_success_actions.insert( - 0, - SendMessage(RESULT), # ty:ignore[invalid-argument-type] - ) - - Form.from_model( - model, - submit_label=_submit, - on_submit=[ - CallTool( - "submit_form", - on_success=on_success_actions, - ), - ], - ) - - with CardFooter(), If(STATE.submitted): - Muted("Submitted.") - - return PrefabApp( - view=view, - state={"submitted": False}, - ) diff --git a/src/fastmcp/apps/generative.py b/src/fastmcp/apps/generative.py deleted file mode 100644 index b3cbe3c33..000000000 --- a/src/fastmcp/apps/generative.py +++ /dev/null @@ -1,199 +0,0 @@ -"""GenerativeUI — a Provider that adds LLM-generated UI capabilities. - -Registers tools and resources from ``prefab_ui.generative`` so that an -LLM can write Prefab Python code, execute it in a sandbox, and render -the result as a streaming interactive UI. - -Requires ``fastmcp[apps]`` (prefab-ui). - -Usage:: - - from fastmcp import FastMCP - from fastmcp.apps.generative import GenerativeUI - - mcp = FastMCP("My Server") - mcp.add_provider(GenerativeUI()) -""" - -try: - import prefab_ui.generative as _gen - from prefab_ui.renderer import ( - get_generative_renderer_csp, - get_generative_renderer_html, - ) -except ImportError as _exc: - raise ImportError( - "GenerativeUI requires prefab-ui. Install with: pip install 'fastmcp[apps]'" - ) from _exc - -import json -from collections.abc import AsyncIterator, Sequence -from contextlib import asynccontextmanager -from typing import Any - -from fastmcp.apps.config import AppConfig, ResourceCSP, app_config_to_meta_dict -from fastmcp.server.providers.base import Provider -from fastmcp.server.providers.local_provider import LocalProvider -from fastmcp.tools.base import Tool -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mime import UI_MIME_TYPE - -logger = get_logger(__name__) - - -def _build_csp() -> ResourceCSP: - """Build CSP from the generative renderer's declared requirements.""" - csp = get_generative_renderer_csp() - return ResourceCSP( - resource_domains=csp.get("resource_domains"), - connect_domains=csp.get("connect_domains"), - ) - - -class GenerativeUI(Provider): - """A Provider that adds generative UI capabilities to a server. - - Registers: - - - A ``generate_ui`` tool that accepts Prefab Python code, executes - it in a Pyodide sandbox, and returns the rendered PrefabApp. - Supports streaming via ``ontoolinputpartial``. - - A ``components`` tool that searches the Prefab component library. - - The generative renderer resource with CSP for Pyodide CDN access. - - Example:: - - from fastmcp import FastMCP - from fastmcp.apps.generative import GenerativeUI - - mcp = FastMCP("My Server") - mcp.add_provider(GenerativeUI()) - """ - - def __init__( - self, - *, - tool_name: str = "generate_prefab_ui", - include_components_tool: bool = True, - components_tool_name: str = "search_prefab_components", - ) -> None: - super().__init__() - self._tool_name = tool_name - self._components_tool_name = components_tool_name - self._include_components_tool = include_components_tool - self._local = LocalProvider(on_duplicate="error") - self._sandbox: Any = None - self._setup_done = False - - def __repr__(self) -> str: - return f"GenerativeUI(tool_name={self._tool_name!r})" - - def _get_sandbox(self) -> Any: - """Lazily create the Pyodide sandbox.""" - if self._sandbox is None: - from prefab_ui.sandbox import Sandbox - - self._sandbox = Sandbox() - return self._sandbox - - def _ensure_setup(self) -> None: - """Lazily register tools and resources on first access.""" - if self._setup_done: - return - - csp = _build_csp() - app_config = AppConfig(resource_uri=_gen.RESOURCE_URI, csp=csp) - - # -- generate_ui tool -- - # Wraps prefab_ui.generative.execute with sandbox lifecycle management. - - from prefab_ui.app import PrefabApp - - sandbox_ref = self # capture for closure - - async def generate_ui( - code: str, - data: str | dict[str, Any] | None = None, - ) -> PrefabApp: - parsed_data: dict[str, Any] | None - if isinstance(data, str): - parsed_data = json.loads(data) if data.strip() else None - else: - parsed_data = data - return await _gen.execute( - code, - data=parsed_data, - sandbox=sandbox_ref._get_sandbox(), - ) - - tool = Tool.from_function( - generate_ui, - name=self._tool_name, - description=_gen.execute.__doc__ or "", - meta={"ui": app_config_to_meta_dict(app_config)}, - ) - self._local._add_component(tool) - - # -- components tool -- - - if self._include_components_tool: - components_tool = Tool.from_function( - _gen.search_components, - name=self._components_tool_name, - description=_gen.search_components.__doc__ or "", - ) - self._local._add_component(components_tool) - - # -- generative renderer resource -- - - from fastmcp.resources.types import TextResource - - resource_config = AppConfig(csp=csp) - resource = TextResource( - uri=_gen.RESOURCE_URI, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - name="Prefab Generative Renderer", - text=get_generative_renderer_html(), - mime_type=UI_MIME_TYPE, - meta={"ui": app_config_to_meta_dict(resource_config)}, - ) - self._local._add_component(resource) - - self._setup_done = True - - # ------------------------------------------------------------------ - # Provider interface - # ------------------------------------------------------------------ - - async def _list_tools(self) -> Sequence[Tool]: - self._ensure_setup() - return await self._local._list_tools() - - async def _get_tool(self, name: str, version: Any = None) -> Tool | None: - self._ensure_setup() - return await self._local._get_tool(name, version) - - async def _list_resources(self) -> Sequence[Any]: - self._ensure_setup() - return await self._local._list_resources() - - async def _get_resource(self, uri: str, version: Any = None) -> Any | None: - self._ensure_setup() - return await self._local._get_resource(uri, version) - - async def _list_resource_templates(self) -> Sequence[Any]: - return [] - - async def _get_resource_template(self, uri: str, version: Any = None) -> Any | None: - return None - - async def _list_prompts(self) -> Sequence[Any]: - return [] - - async def _get_prompt(self, name: str, version: Any = None) -> Any | None: - return None - - @asynccontextmanager - async def lifespan(self) -> AsyncIterator[None]: - self._ensure_setup() - async with self._local.lifespan(): - yield diff --git a/src/fastmcp/cli/__init__.py b/src/fastmcp/cli/__init__.py deleted file mode 100644 index 091667730..000000000 --- a/src/fastmcp/cli/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""FastMCP CLI package.""" - -from .cli import app diff --git a/src/fastmcp/cli/__main__.py b/src/fastmcp/cli/__main__.py deleted file mode 100644 index aca24b145..000000000 --- a/src/fastmcp/cli/__main__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""FastMCP CLI as a runnable package""" - -from .cli import app - -app() diff --git a/src/fastmcp/cli/apps_dev.py b/src/fastmcp/cli/apps_dev.py deleted file mode 100644 index 9a3d7be2d..000000000 --- a/src/fastmcp/cli/apps_dev.py +++ /dev/null @@ -1,1806 +0,0 @@ -"""Dev server for previewing FastMCPApp UIs locally. - -Starts the user's MCP server on a configurable port, then starts a lightweight -Starlette dev server that: - - - Serves a Prefab-based tool picker at GET / - - Proxies /mcp to the user's server (avoids browser CORS restrictions) - - Serves the AppBridge host page at GET /launch - -The host page uses @modelcontextprotocol/ext-apps to connect to the MCP server -and render the selected UI tool inside an iframe. - -Startup sequence ----------------- -1. Download ext-apps app-bridge.js from npm and patch its bare - ``@modelcontextprotocol/sdk/…`` imports to use concrete esm.sh URLs. -2. Detect the exact Zod v4 module URL that esm.sh serves for that SDK version - and build an import-map entry that redirects the broken ``v4.mjs`` (which - only re-exports ``{z, default}``) to ``v4/classic/index.mjs`` (which - correctly exports every named Zod v4 function). Import maps apply to the - full module graph in the document, including cross-origin esm.sh modules. -3. Serve both the patched JS and the import-map JSON from the dev server. -""" - -from __future__ import annotations - -import asyncio -import contextlib -import io -import json -import logging -import os -import re -import signal -import sys -import tarfile -import tempfile -import time -import urllib.request -import webbrowser -from pathlib import Path -from typing import Any -from urllib.parse import quote - -import httpcore -import httpx -import uvicorn -from starlette.applications import Starlette -from starlette.requests import Request -from starlette.responses import HTMLResponse, Response, StreamingResponse -from starlette.routing import Route - -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -# --------------------------------------------------------------------------- -# MCP message log (captures proxy traffic for the dev UI log panel) -# --------------------------------------------------------------------------- - - -class _MessageLog: - """In-memory buffer of MCP JSON-RPC messages flowing through the proxy.""" - - def __init__(self) -> None: - self._entries: list[dict[str, Any]] = [] - self._counter = 0 - self._request_methods: dict[int | str, str] = {} - self._request_times: dict[int | str, float] = {} - - def log_request(self, body: dict[str, Any]) -> None: - method = body.get("method", "unknown") - jsonrpc_id = body.get("id") - timestamp = time.time() - if jsonrpc_id is not None: - self._request_methods[jsonrpc_id] = method - self._request_times[jsonrpc_id] = timestamp - self._counter += 1 - self._entries.append( - { - "id": self._counter, - "timestamp": timestamp, - "direction": "request", - "method": method, - "body": body, - } - ) - - def log_response(self, body: dict[str, Any]) -> None: - # Server-initiated notifications have "method" but no "id" - if "method" in body and "id" not in body: - self._counter += 1 - self._entries.append( - { - "id": self._counter, - "timestamp": time.time(), - "direction": "notification", - "method": body.get("method", "unknown"), - "body": body, - } - ) - return - - jsonrpc_id = body.get("id") - method = ( - self._request_methods.pop(jsonrpc_id, None) - if jsonrpc_id is not None - else None - ) - request_time = ( - self._request_times.pop(jsonrpc_id, None) - if jsonrpc_id is not None - else None - ) - timestamp = time.time() - duration_ms = ( - round((timestamp - request_time) * 1000, 1) if request_time else None - ) - self._counter += 1 - self._entries.append( - { - "id": self._counter, - "timestamp": timestamp, - "direction": "response", - "method": method, - "body": body, - "duration_ms": duration_ms, - } - ) - - def get_since(self, since_id: int = 0) -> list[dict[str, Any]]: - return [e for e in self._entries if e["id"] > since_id] - - def log_bridge(self, body: dict[str, Any]) -> None: - method = body.get("method", "unknown") - self._counter += 1 - self._entries.append( - { - "id": self._counter, - "timestamp": time.time(), - "direction": "bridge", - "method": method, - "body": body, - } - ) - - def clear(self) -> None: - self._entries.clear() - self._request_methods.clear() - self._request_times.clear() - - -def _log_response_bytes(log: _MessageLog, raw: bytes, content_type: str) -> None: - """Parse accumulated proxy response bytes and log as message entries.""" - if not raw: - return - try: - if "text/event-stream" in content_type: - for line in raw.decode("utf-8", errors="replace").splitlines(): - if line.startswith("data: "): - with contextlib.suppress(json.JSONDecodeError): - log.log_response(json.loads(line[6:])) - else: - body = json.loads(raw) - if isinstance(body, list): - for item in body: - log.log_response(item) - else: - log.log_response(body) - except (json.JSONDecodeError, TypeError): - pass - - -_EXT_APPS_VERSION = "1.0.1" -# Pin to the SDK version ext-apps 1.0.1 was compiled against so the client -# and transport modules are API-compatible with the app-bridge internals. -_MCP_SDK_VERSION = "1.25.2" - -# --------------------------------------------------------------------------- -# Shared AppBridge host shell -# --------------------------------------------------------------------------- - -# Both the picker and the app launcher use the same host-page structure: an -# iframe that hosts a Prefab renderer, wired to the MCP server via AppBridge. -# The only differences are (a) which URL loads in the iframe and (b) what -# oninitialized does. -# -# app-bridge.js is served locally (see _fetch_app_bridge_bundle). -# Client/Transport are loaded from esm.sh. -# The import map (injected as {import_map_tag}) patches the broken esm.sh -# Zod v4 module so all Zod named exports are visible to the SDK at runtime. - -_HOST_SHELL = """\ - - - - - {title} -{import_map_tag} - - - -
{status_text}
- - - - -""" - -# --------------------------------------------------------------------------- -# Host page HTML -# --------------------------------------------------------------------------- - -_HOST_HTML_TEMPLATE = """\ - - - - - FastMCP Dev — {tool_name} -{import_map_tag} - - - -
Launching {tool_name}…
- - - - -""" - -# --------------------------------------------------------------------------- -# Dev log panel (injected into host pages) -# --------------------------------------------------------------------------- - -_LOG_PANEL_HTML = """\ - - - - -""" - - -def _inject_log_panel(html: str) -> str: - """Inject the MCP message log panel before .""" - return html.replace("", _LOG_PANEL_HTML + "\n") - - -# --------------------------------------------------------------------------- -# Picker UI (Prefab-based, built in Python) -# --------------------------------------------------------------------------- - - -def _has_ui_resource(tool: dict[str, Any]) -> bool: - """Return True if the tool has a UI resourceUri in its metadata.""" - for key in ("meta", "_meta"): - m = tool.get(key) - if isinstance(m, dict): - ui = m.get("ui") - if isinstance(ui, dict) and ui.get("resourceUri"): - return True - return False - - -def _model_from_schema(tool_name: str, input_schema: dict[str, Any]) -> type[Any]: - """Dynamically create a Pydantic model from a JSON Schema for form generation.""" - import pydantic - import pydantic.fields - - properties: dict[str, Any] = input_schema.get("properties") or {} - required: list[str] = input_schema.get("required") or [] - - field_definitions: dict[str, Any] = {} - for prop_name, prop in properties.items(): - json_type = prop.get("type", "string") - - # Handle anyOf / oneOf (union types like str | dict | None) - for key in ("anyOf", "oneOf"): - if key in prop: - non_null = [ - t - for t in prop[key] - if isinstance(t, dict) and t.get("type") != "null" - ] - if non_null: - types = [t.get("type") for t in non_null if "type" in t] - # Prefer object/array (need textarea for JSON editing), - # then string (most versatile text input), then scalars. - for candidate in ( - "object", - "array", - "string", - "integer", - "number", - "boolean", - ): - if candidate in types: - json_type = candidate - break - break - - match json_type: - case "integer": - py_type: type = int - case "number": - py_type = float - case "boolean": - py_type = bool - case "object" | "array": - # Render as a string textarea; api_launch parses JSON later - py_type = str - case _: - py_type = str - - title = prop.get("title") or prop_name.replace("_", " ").title() - description = prop.get("description") - is_required = prop_name in required - if is_required: - default = pydantic.fields.PydanticUndefined - elif "default" in prop: - default = prop["default"] - else: - default = None - py_type = py_type | None # type: ignore[assignment] # ty:ignore[invalid-assignment] - - extra: dict[str, Any] = {} - if prop.get("enum"): - from typing import Literal - - py_type = Literal[tuple(prop["enum"])] # type: ignore[assignment] # ty:ignore[invalid-type-form] - - # Textarea detection: - # 1. Explicit format: "textarea" in JSON schema - # 2. UI annotation: {"ui": {"type": "textarea"}} (json_schema_extra merged flat) - # 3. Object/array types need multiline JSON editing - use_textarea = ( - prop.get("format") == "textarea" - or ( - isinstance(prop.get("ui"), dict) - and prop["ui"].get("type") == "textarea" - ) - or json_type in ("object", "array") - ) - if use_textarea: - extra["json_schema_extra"] = {"ui": {"type": "textarea"}} - - field_definitions[prop_name] = ( - py_type, - pydantic.Field( - default=default, title=title, description=description, **extra - ), - ) - - return pydantic.create_model(f"{tool_name.title()}Form", **field_definitions) - - -def _build_picker_html(tools: list[dict[str, Any]]) -> str: - """Build Prefab picker page: dropdown selector with per-tool forms.""" - try: - from prefab_ui.actions import Fetch, OpenLink, SetState, ShowToast - from prefab_ui.app import PrefabApp - from prefab_ui.components import ( - Button, - Column, - Heading, - Label, - Markdown, - Muted, - Page, - Pages, - Select, - SelectOption, - Textarea, - ) - from prefab_ui.components.form import Form - from prefab_ui.rx import RESULT, Rx - except ImportError: - return "

prefab-ui not installed. Run: pip install fastmcp[apps]

" - - if not tools: - with Column(gap=4, css_class="p-6 max-w-2xl mx-auto") as view: - Heading("FastMCP Apps") - Muted( - "No UI tools found on this server. Use @app.ui() to register entry-point tools." - ) - return PrefabApp(title="FastMCP Apps", view=view).html() - - first_name: str = tools[0]["name"] - - def _tool_title(tool: dict[str, Any]) -> str: - return tool.get("title") or tool["name"] - - with Column(gap=6, css_class="p-8 max-w-2xl mx-auto") as view: - Heading("FastMCP Apps") - - if len(tools) > 1: - with Column(gap=1): - Label("Tool") - with Select( - placeholder="Choose a tool…", - on_change=SetState("activeTool", Rx("$event")), - ): - for tool in tools: - SelectOption( - _tool_title(tool), - value=tool["name"], - selected=tool["name"] == first_name, - ) - else: - Heading(_tool_title(tools[0]), level=3) - - with Pages(name="activeTool", value=first_name): - for tool in tools: - name: str = tool["name"] - desc: str = tool.get("description") or "" - input_schema: dict[str, Any] = tool.get("inputSchema") or {} - model = _model_from_schema(name, input_schema) - - form_body: dict[str, Any] = {"tool": name} - for field_name in model.model_fields: - form_body[field_name] = Rx(field_name) - - json_body: dict[str, Any] = { - "tool": name, - "__json_args__": Rx("__json_args__"), - } - - on_error = ShowToast(Rx("$error"), variant="error") # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - - input_mode = f"_mode_{name}" - _desc_max_lines = 10 - with Page(name, value=name), Column(gap=4): - if desc: - lines = desc.split("\n") - md_css = "text-sm text-muted-foreground" - if len(lines) <= _desc_max_lines: - Markdown(desc, css_class=md_css) - else: - desc_state = f"_desc_{name}" - short = "\n".join(lines[:_desc_max_lines]) - with Pages(name=desc_state, value="short"): - with ( - Page("short", value="short"), - Column(gap=1, css_class="items-start"), - ): - Markdown(short, css_class=md_css) - Button( - "Show more \u25be", - variant="link", - size="xs", - on_click=SetState(desc_state, "full"), - css_class="text-muted-foreground p-0 h-auto", - ) - with ( - Page("full", value="full"), - Column(gap=1, css_class="items-start"), - ): - Markdown(desc, css_class=md_css) - Button( - "Show less \u25b4", - variant="link", - size="xs", - on_click=SetState(desc_state, "short"), - css_class="text-muted-foreground p-0 h-auto", - ) - - with Pages(name=input_mode, value="form"): - with Page("form", value="form"), Column(gap=4): - with Column(gap=1, css_class="items-start"): - Heading("Arguments", level=3) - Button( - "Edit as JSON", - variant="link", - size="xs", - on_click=SetState(input_mode, "json"), - css_class="text-muted-foreground p-0 h-auto", - ) - with Form( - on_submit=Fetch.post( - "/api/launch", - body=form_body, - on_success=OpenLink(RESULT), - on_error=on_error, - ), - ): - Form.from_model(model, fields_only=True) - Button( - "Launch", - variant="success", - button_type="submit", - ) - with Page("json", value="json"), Column(gap=4): - with Column(gap=1, css_class="items-start"): - Heading("Arguments", level=3) - Button( - "Use form", - variant="link", - size="xs", - on_click=SetState(input_mode, "form"), - css_class="text-muted-foreground p-0 h-auto", - ) - with Form( - on_submit=Fetch.post( - "/api/launch", - body=json_body, - on_success=OpenLink(RESULT), - on_error=on_error, - ), - ): - Textarea( - name="__json_args__", - placeholder='{"key": "value"}', - rows=8, - ) - Button( - "Launch", - variant="success", - button_type="submit", - ) - - Markdown( - "Generated by [Prefab](https://prefab.prefect.io) 🎨", - css_class="text-xs text-muted-foreground text-right", - ) - - return PrefabApp(title="FastMCP Apps", view=view).html() - - -# --------------------------------------------------------------------------- -# MCP tool listing helper -# --------------------------------------------------------------------------- - - -async def _list_tools(mcp_url: str) -> list[dict[str, Any]]: - """Return raw tool dicts from the MCP server at mcp_url.""" - try: - from mcp import ClientSession - from mcp.client.streamable_http import streamable_http_client - except ImportError: - return [] - - try: - async with streamable_http_client(mcp_url) as (read, write, _): # noqa: SIM117 - async with ClientSession(read, write) as session: - await session.initialize() - result = await session.list_tools() - return [t.model_dump() for t in result.tools] - except Exception as exc: - logger.debug(f"Could not list tools from {mcp_url}: {exc}") - return [] - - -async def _read_mcp_resource(mcp_url: str, uri: str) -> str | None: - """Read an MCP resource by URI and return its text content.""" - try: - from mcp import ClientSession - from mcp.client.streamable_http import streamable_http_client - from pydantic import AnyUrl - except ImportError: - return None - - try: - async with streamable_http_client(mcp_url) as (read, write, _): # noqa: SIM117 - async with ClientSession(read, write) as session: - await session.initialize() - result = await session.read_resource(AnyUrl(uri)) - for content in result.contents: - text = getattr(content, "text", None) - if text: - return text - return None - except Exception as exc: - logger.debug(f"Could not read resource {uri} from {mcp_url}: {exc}") - return None - - -# --------------------------------------------------------------------------- -# app-bridge.js download, patch, and Zod import-map generation -# --------------------------------------------------------------------------- - - -def _fetch_app_bridge_bundle_sync( - version: str, - sdk_version: str, -) -> tuple[str, str]: - """Download app-bridge.js and build an import-map that fixes Zod v4 on esm.sh. - - Returns ``(app_bridge_js, import_map_json)`` where *import_map_json* is a - JSON string ready to embed in a ``' - ) - - ready = await _wait_for_server(mcp_url, timeout=15.0) - if not ready: - raise RuntimeError(f"User server did not start on port {mcp_port}") - - logger.info(f"FastMCP dev UI at {dev_url}") - - dev_app = _make_dev_app(mcp_url, app_bridge_js, import_map_tag, _MessageLog()) - config = uvicorn.Config( - dev_app, - host="localhost", - port=dev_port, - log_level="warning", - ws="websockets-sansio", - ) - server = uvicorn.Server(config) - # Suppress uvicorn's own signal handlers — they use signal.signal() which - # conflicts with asyncio and causes hangs. We cancel the task instead. - server.install_signal_handlers = lambda: None # type: ignore[method-assign] # ty:ignore[unresolved-attribute] - - async def _open_browser() -> None: - await asyncio.sleep(0.8) - webbrowser.open(dev_url) - - await asyncio.gather(server.serve(), _open_browser()) - - # Register signal handlers before any work starts so that Ctrl+C during - # startup (server spawn, npm fetch, server-ready poll) is handled the same - # way as Ctrl+C during the running phase — both cancel the body task and - # fall through to the cleanup finally block. - loop = asyncio.get_running_loop() - task = asyncio.ensure_future(_body()) - - def _on_signal() -> None: - # Silence uvicorn's error logger before cancelling so that the - # CancelledError propagating through uvicorn doesn't get logged as - # an ERROR during the forced shutdown. - logging.getLogger("uvicorn.error").setLevel(logging.CRITICAL) - task.cancel() - - if sys.platform != "win32": - loop.add_signal_handler(signal.SIGINT, _on_signal) - loop.add_signal_handler(signal.SIGTERM, _on_signal) - - try: - await task - except asyncio.CancelledError: - pass - finally: - if sys.platform != "win32": - loop.remove_signal_handler(signal.SIGINT) - loop.remove_signal_handler(signal.SIGTERM) - if user_proc is not None and user_proc.returncode is None: - # Kill the entire process group (not just the top-level process) - # because --reload creates a watcher that spawns child processes. - # Killing only the watcher leaves the actual server holding the port. - try: - if sys.platform != "win32": - os.killpg(os.getpgid(user_proc.pid), signal.SIGTERM) - else: - user_proc.kill() - except (ProcessLookupError, PermissionError): - user_proc.kill() - await user_proc.wait() diff --git a/src/fastmcp/cli/auth.py b/src/fastmcp/cli/auth.py deleted file mode 100644 index 4ea401b04..000000000 --- a/src/fastmcp/cli/auth.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Authentication-related CLI commands.""" - -import cyclopts - -from fastmcp.cli.cimd import cimd_app - -auth_app = cyclopts.App( - name="auth", - help="Authentication-related utilities and configuration.", -) - -# Nest CIMD commands under auth -auth_app.command(cimd_app) diff --git a/src/fastmcp/cli/cimd.py b/src/fastmcp/cli/cimd.py deleted file mode 100644 index d2def490c..000000000 --- a/src/fastmcp/cli/cimd.py +++ /dev/null @@ -1,218 +0,0 @@ -"""CIMD (Client ID Metadata Document) CLI commands.""" - -from __future__ import annotations - -import asyncio -import json -import sys -from pathlib import Path -from typing import Annotated - -import cyclopts -from rich.console import Console - -from fastmcp.server.auth.cimd import ( - CIMDFetcher, - CIMDFetchError, - CIMDValidationError, -) -from fastmcp.utilities.logging import get_logger - -logger = get_logger("cli.cimd") -console = Console() - - -cimd_app = cyclopts.App( - name="cimd", - help="CIMD (Client ID Metadata Document) utilities for OAuth authentication.", -) - - -@cimd_app.command(name="create") -def create_command( - *, - name: Annotated[ - str, - cyclopts.Parameter(help="Human-readable name of the client application"), - ], - redirect_uri: Annotated[ - list[str], - cyclopts.Parameter( - name=["--redirect-uri", "-r"], - help="Allowed redirect URIs (can specify multiple)", - ), - ], - client_id: Annotated[ - str | None, - cyclopts.Parameter( - name="--client-id", - help="The URL where this document will be hosted (sets client_id directly)", - ), - ] = None, - client_uri: Annotated[ - str | None, - cyclopts.Parameter( - name="--client-uri", - help="URL of the client's home page", - ), - ] = None, - logo_uri: Annotated[ - str | None, - cyclopts.Parameter( - name="--logo-uri", - help="URL of the client's logo image", - ), - ] = None, - scope: Annotated[ - str | None, - cyclopts.Parameter( - name="--scope", - help="Space-separated list of scopes the client may request", - ), - ] = None, - output: Annotated[ - str | None, - cyclopts.Parameter( - name=["--output", "-o"], - help="Output file path (default: stdout)", - ), - ] = None, - pretty: Annotated[ - bool, - cyclopts.Parameter( - help="Pretty-print JSON output", - ), - ] = True, -) -> None: - """Generate a CIMD document for hosting. - - Create a Client ID Metadata Document that you can host at an HTTPS URL. - The URL where you host this document becomes your client_id. - - Example: - fastmcp cimd create --name "My App" -r "http://localhost:*/callback" - - After creating the document, host it at an HTTPS URL with a non-root path, - for example: https://myapp.example.com/oauth/client.json - """ - # Build the document - doc = { - "client_id": client_id or "https://YOUR-DOMAIN.com/path/to/client.json", - "client_name": name, - "redirect_uris": redirect_uri, - "token_endpoint_auth_method": "none", - "grant_types": ["authorization_code"], - "response_types": ["code"], - } - - # Add optional fields - if client_uri: - doc["client_uri"] = client_uri - if logo_uri: - doc["logo_uri"] = logo_uri - if scope: - doc["scope"] = scope - - # Format output - json_output = json.dumps(doc, indent=2) if pretty else json.dumps(doc) - - # Write output - if output: - output_path = Path(output).expanduser().resolve() - output_path.parent.mkdir(parents=True, exist_ok=True) - with open(output_path, "w") as f: - f.write(json_output) - f.write("\n") - console.print(f"[green]✓[/green] CIMD document written to {output}") - if not client_id: - console.print( - "\n[yellow]Important:[/yellow] client_id is a placeholder. Update it to the URL where you will host this document, or re-run with --client-id." - ) - else: - print(json_output) - if not client_id: - # Print instructions to stderr so they don't interfere with piping - stderr_console = Console(stderr=True) - stderr_console.print( - "\n[yellow]Important:[/yellow] client_id is a placeholder." - " Update it to the URL where you will host this document," - " or re-run with --client-id." - ) - - -@cimd_app.command(name="validate") -def validate_command( - url: Annotated[ - str, - cyclopts.Parameter(help="URL of the CIMD document to validate"), - ], - *, - timeout: Annotated[ - float, - cyclopts.Parameter( - name=["--timeout", "-t"], - help="HTTP request timeout in seconds", - ), - ] = 10.0, -) -> None: - """Validate a hosted CIMD document. - - Fetches the document from the given URL and validates: - - URL is valid CIMD URL (HTTPS, non-root path) - - Document is valid JSON - - Document conforms to CIMD schema - - client_id in document matches the URL - - Example: - fastmcp cimd validate https://myapp.example.com/oauth/client.json - """ - - async def _validate() -> bool: - fetcher = CIMDFetcher(timeout=timeout) - - # Check URL format first - if not fetcher.is_cimd_client_id(url): - console.print(f"[red]✗[/red] Invalid CIMD URL: {url}") - console.print() - console.print("CIMD URLs must:") - console.print(" • Use HTTPS (not HTTP)") - console.print(" • Have a non-root path (e.g., /client.json, not just /)") - return False - - console.print(f"[blue]→[/blue] Fetching {url}...") - - try: - doc = await fetcher.fetch(url) - except CIMDFetchError as e: - console.print(f"[red]✗[/red] Failed to fetch document: {e}") - return False - except CIMDValidationError as e: - console.print(f"[red]✗[/red] Validation error: {e}") - return False - - # Success - show document details - console.print("[green]✓[/green] Valid CIMD document") - console.print() - console.print("[bold]Document details:[/bold]") - console.print(f" client_id: {doc.client_id}") - console.print(f" client_name: {doc.client_name or '(not set)'}") - console.print(f" token_endpoint_auth_method: {doc.token_endpoint_auth_method}") - - if doc.redirect_uris: - console.print(" redirect_uris:") - for uri in doc.redirect_uris: - console.print(f" • {uri}") - else: - console.print(" redirect_uris: (none)") - - if doc.scope: - console.print(f" scope: {doc.scope}") - - if doc.client_uri: - console.print(f" client_uri: {doc.client_uri}") - - return True - - success = asyncio.run(_validate()) - if not success: - sys.exit(1) diff --git a/src/fastmcp/cli/cli.py b/src/fastmcp/cli/cli.py deleted file mode 100644 index 876da5826..000000000 --- a/src/fastmcp/cli/cli.py +++ /dev/null @@ -1,1112 +0,0 @@ -"""FastMCP CLI tools using Cyclopts.""" - -import importlib.metadata -import importlib.util -import json -import os -import platform -import subprocess -import sys -from contextlib import contextmanager -from pathlib import Path -from typing import Annotated, Literal - -import cyclopts -import pyperclip -from cyclopts import Parameter -from rich.console import Console -from rich.table import Table - -import fastmcp -from fastmcp.cli import run as run_module -from fastmcp.cli.auth import auth_app -from fastmcp.cli.client import call_command, discover_command, list_command -from fastmcp.cli.generate import generate_cli_command -from fastmcp.cli.install import install_app -from fastmcp.cli.tasks import tasks_app -from fastmcp.utilities.cli import is_already_in_uv_subprocess, load_and_merge_config -from fastmcp.utilities.inspect import ( - InspectFormat, - format_info, - inspect_fastmcp, -) -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config import MCPServerConfig -from fastmcp.utilities.version_check import check_for_newer_version - -logger = get_logger("cli") -console = Console() - -app = cyclopts.App( - name="fastmcp", - help="FastMCP - The fast, Pythonic way to build MCP servers and clients.", - version=fastmcp.__version__, - # Disable automatic negative parameters by default - default_parameter=Parameter(negative=()), -) - - -def _get_npx_command(): - """Get the correct npx command for the current platform.""" - if sys.platform == "win32": - # Try both npx.cmd and npx.exe on Windows - for cmd in ["npx.cmd", "npx.exe", "npx"]: - try: - subprocess.run([cmd, "--version"], check=True, capture_output=True) - return cmd - except (subprocess.CalledProcessError, FileNotFoundError): - continue - return None - return "npx" # On Unix-like systems, just use npx - - -def _parse_env_var(env_var: str) -> tuple[str, str]: - """Parse environment variable string in format KEY=VALUE.""" - if "=" not in env_var: - logger.error("Invalid environment variable format. Must be KEY=VALUE") - sys.exit(1) - key, value = env_var.split("=", 1) - return key.strip(), value.strip() - - -@contextmanager -def with_argv(args: list[str] | None): - """Temporarily replace sys.argv if args provided. - - This context manager is used at the CLI boundary to inject - server arguments when needed, without mutating sys.argv deep - in the source loading logic. - - Args are provided without the script name, so we preserve sys.argv[0] - and replace the rest. - """ - if args is not None: - original = sys.argv[:] - try: - # Preserve the script name (sys.argv[0]) and replace the rest - sys.argv = [sys.argv[0], *args] - yield - finally: - sys.argv = original - else: - yield - - -@app.command -def version( - *, - copy: Annotated[ - bool, - cyclopts.Parameter("--copy", help="Copy version information to clipboard"), - ] = False, -): - """Display version information and platform details.""" - info = { - "FastMCP version": fastmcp.__version__, - "MCP version": importlib.metadata.version("mcp"), - "Python version": platform.python_version(), - "Platform": platform.platform(), - "FastMCP root path": Path(fastmcp.__file__ or ".").resolve().parents[1], - } - - g = Table.grid(padding=(0, 1)) - g.add_column(style="bold", justify="left") - g.add_column(style="cyan", justify="right") - for k, v in info.items(): - g.add_row(k + ":", str(v).replace("\n", " ")) - - if copy: - # Use Rich's plain text rendering for copying - plain_console = Console(file=None, force_terminal=False, legacy_windows=False) - with plain_console.capture() as capture: - plain_console.print(g) - pyperclip.copy(capture.get()) - console.print("[green]✓[/green] Version information copied to clipboard") - else: - console.print(g) - - # Check for updates (not included in --copy output) - if newer_version := check_for_newer_version(): - console.print() - console.print( - f"[bold]🎉 FastMCP update available:[/bold] [green]{newer_version}[/green]" - ) - console.print("[dim]Run: pip install --upgrade fastmcp[/dim]") - - -# Create dev subcommand group -dev_app = cyclopts.App(name="dev", help="Development tools for MCP servers") - - -@dev_app.command -async def inspector( - server_spec: str | None = None, - *, - with_editable: Annotated[ - list[Path] | None, - cyclopts.Parameter( - "--with-editable", - help="Directory containing pyproject.toml to install in editable mode (can be used multiple times)", - ), - ] = None, - with_packages: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--with", help="Additional packages to install (can be used multiple times)" - ), - ] = None, - inspector_version: Annotated[ - str | None, - cyclopts.Parameter( - "--inspector-version", - help="Version of the MCP Inspector to use", - ), - ] = None, - ui_port: Annotated[ - int | None, - cyclopts.Parameter( - "--ui-port", - help="Port for the MCP Inspector UI", - ), - ] = None, - server_port: Annotated[ - int | None, - cyclopts.Parameter( - "--server-port", - help="Port for the MCP Inspector Proxy server", - ), - ] = None, - python: Annotated[ - str | None, - cyclopts.Parameter( - "--python", - help="Python version to use (e.g., 3.10, 3.11)", - ), - ] = None, - with_requirements: Annotated[ - Path | None, - cyclopts.Parameter( - "--with-requirements", - help="Requirements file to install dependencies from", - ), - ] = None, - project: Annotated[ - Path | None, - cyclopts.Parameter( - "--project", - help="Run the command within the given project directory", - ), - ] = None, - reload: Annotated[ - bool, - cyclopts.Parameter( - "--reload", - help="Enable auto-reload on file changes (enabled by default)", - negative="--no-reload", - ), - ] = True, - reload_dir: Annotated[ - list[Path] | None, - cyclopts.Parameter( - "--reload-dir", - help="Directories to watch for changes (default: current directory)", - ), - ] = None, - module: Annotated[ - bool, - cyclopts.Parameter( - name=["--module", "-m"], - help="Run a Python module (python -m ) instead of importing a server object", - ), - ] = False, -) -> None: - """Run an MCP server with the MCP Inspector for development. - - Args: - server_spec: Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json - """ - - try: - # Load config and apply CLI overrides - config, server_spec = load_and_merge_config( - server_spec, - python=python, - with_packages=with_packages or [], - with_requirements=with_requirements, - project=project, - editable=[str(p) for p in with_editable] if with_editable else None, - port=server_port, # Use deployment config for server port - ) - - # Get server port from config if not specified via CLI - if not server_port: - server_port = config.deployment.port - - except FileNotFoundError: - sys.exit(1) - - logger.debug( - "Starting dev server", - extra={ - "server_spec": server_spec, - "with_editable": config.environment.editable, - "with_packages": config.environment.dependencies, - "ui_port": ui_port, - "server_port": server_port, - }, - ) - - try: - if not config: - logger.error("No configuration available") - sys.exit(1) - assert config is not None # For type checker - - # Skip server-object validation in module mode — the module - # manages its own startup and may not expose an importable server. - if not module: - await config.source.load_server() - - env_vars = {} - if ui_port: - env_vars["CLIENT_PORT"] = str(ui_port) - if server_port: - env_vars["SERVER_PORT"] = str(server_port) - - # Get the correct npx command - npx_cmd = _get_npx_command() - if not npx_cmd: - logger.error( - "npx not found. Please ensure Node.js and npm are properly installed " - "and added to your system PATH." - ) - sys.exit(1) - - inspector_cmd = "@modelcontextprotocol/inspector" - if inspector_version: - inspector_cmd += f"@{inspector_version}" - - # Build the fastmcp run command - fastmcp_cmd = ["fastmcp", "run", server_spec, "--no-banner"] - - # Forward module mode flag - if module: - fastmcp_cmd.append("--module") - - # Add reload flags if enabled - the server will handle reloading - if reload: - fastmcp_cmd.append("--reload") - if reload_dir: - for dir_path in reload_dir: - fastmcp_cmd.extend(["--reload-dir", str(dir_path)]) - - # Use the environment from config (already has CLI overrides applied) - uv_cmd = config.environment.build_command(fastmcp_cmd) - - # Set marker to prevent infinite loops when subprocess calls FastMCP - env = dict(os.environ.items()) | env_vars | {"FASTMCP_UV_SPAWNED": "1"} - - # Run the MCP Inspector command - process = subprocess.run( - [npx_cmd, inspector_cmd, *uv_cmd], - check=True, - env=env, - ) - sys.exit(process.returncode) - except subprocess.CalledProcessError as e: - logger.error( - "Dev server failed", - extra={ - "file": str(server_spec), - "error": str(e), - "returncode": e.returncode, - }, - ) - sys.exit(e.returncode) - except FileNotFoundError: - logger.error( - "npx not found. Please ensure Node.js and npm are properly installed " - "and added to your system PATH. You may need to restart your terminal " - "after installation.", - extra={"file": str(server_spec)}, - ) - sys.exit(1) - - -@dev_app.command -async def apps( - server_spec: str, - *, - mcp_port: Annotated[ - int, - cyclopts.Parameter( - "--mcp-port", - help="Port for the user's MCP server", - ), - ] = 8000, - dev_port: Annotated[ - int, - cyclopts.Parameter( - "--dev-port", - help="Port for the FastMCP dev UI", - ), - ] = 8080, - reload: Annotated[ - bool, - cyclopts.Parameter( - "--reload", - negative="--no-reload", - help="Auto-reload the MCP server on file changes", - ), - ] = True, -) -> None: - """Preview a FastMCPApp UI in the browser. - - Starts the MCP server from SERVER_SPEC on --mcp-port, launches a local - dev UI on --dev-port with a tool picker and AppBridge host, then opens - the browser automatically. - - Requires fastmcp[apps] to be installed (prefab-ui). - """ - try: - import prefab_ui # noqa: F401 - except ImportError: - logger.error( - "fastmcp dev apps requires prefab-ui. Install with: pip install 'fastmcp[apps]'" - ) - sys.exit(1) - - from fastmcp.cli.apps_dev import run_dev_apps - - await run_dev_apps(server_spec, mcp_port=mcp_port, dev_port=dev_port, reload=reload) - - -@app.command -async def run( - server_spec: str | None = None, - *server_args: str, - transport: Annotated[ - run_module.TransportType | None, - cyclopts.Parameter( - name=["--transport", "-t"], - help="Transport protocol to use", - ), - ] = None, - host: Annotated[ - str | None, - cyclopts.Parameter( - "--host", - help="Host to bind to when using http transport (default: 127.0.0.1)", - ), - ] = None, - port: Annotated[ - int | None, - cyclopts.Parameter( - name=["--port", "-p"], - help="Port to bind to when using http transport (default: 8000)", - ), - ] = None, - path: Annotated[ - str | None, - cyclopts.Parameter( - "--path", - help="The route path for the server (default: /mcp/ for http transport, /sse/ for sse transport)", - ), - ] = None, - log_level: Annotated[ - Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, - cyclopts.Parameter( - name=["--log-level", "-l"], - help="Log level", - ), - ] = None, - no_banner: Annotated[ - bool, - cyclopts.Parameter("--no-banner", help="Don't show the server banner"), - ] = False, - python: Annotated[ - str | None, - cyclopts.Parameter( - "--python", - help="Python version to use (e.g., 3.10, 3.11)", - ), - ] = None, - with_packages: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--with", help="Additional packages to install (can be used multiple times)" - ), - ] = None, - project: Annotated[ - Path | None, - cyclopts.Parameter( - "--project", - help="Run the command within the given project directory", - ), - ] = None, - with_requirements: Annotated[ - Path | None, - cyclopts.Parameter( - "--with-requirements", - help="Requirements file to install dependencies from", - ), - ] = None, - skip_source: Annotated[ - bool, - cyclopts.Parameter( - "--skip-source", - help="Skip source preparation step (use when source is already prepared)", - ), - ] = False, - skip_env: Annotated[ - bool, - cyclopts.Parameter( - "--skip-env", - help="Skip environment configuration (for internal use when already in a uv environment)", - ), - ] = False, - reload: Annotated[ - bool, - cyclopts.Parameter( - "--reload", - negative="--no-reload", - help="Enable auto-reload on file changes (development mode)", - ), - ] = False, - reload_dir: Annotated[ - list[Path] | None, - cyclopts.Parameter( - "--reload-dir", - help="Directories to watch for changes (default: current directory)", - ), - ] = None, - stateless: Annotated[ - bool, - cyclopts.Parameter( - "--stateless", - help="Run in stateless mode (no session, used internally for reload)", - ), - ] = False, - module: Annotated[ - bool, - cyclopts.Parameter( - name=["--module", "-m"], - help="Run a Python module (python -m ) instead of importing a server object", - ), - ] = False, -) -> None: - """Run an MCP server or connect to a remote one. - - The server can be specified in several ways: - 1. Module approach: "server.py" - runs the module directly, looking for an object named 'mcp', 'server', or 'app' - 2. Import approach: "server.py:app" - imports and runs the specified server object - 3. URL approach: "http://server-url" - connects to a remote server and creates a proxy - 4. MCPConfig file: "mcp.json" - runs as a proxy server for the MCP Servers in the MCPConfig file - 5. FastMCP config: "fastmcp.json" - runs server using FastMCP configuration - 6. No argument: looks for fastmcp.json in current directory - 7. Module mode: "-m my_module" - runs the module directly via python -m - - Server arguments can be passed after -- : - fastmcp run server.py -- --config config.json --debug - - Args: - server_spec: Python file, object specification (file:obj), config file, URL, or None to auto-detect - """ - - # --- Module mode: delegate to python -m and exit early --- - if module: - if server_spec is None: - logger.error("A module name is required when using --module / -m") - sys.exit(1) - - # Warn about options that are ignored in module mode - ignored_options: list[str] = [] - if transport: - ignored_options.append("--transport") - if host: - ignored_options.append("--host") - if port: - ignored_options.append("--port") - if path: - ignored_options.append("--path") - if ignored_options: - logger.warning( - f"Options {', '.join(ignored_options)} are ignored in module mode " - f"(-m). The module manages its own server startup." - ) - - # Build environment wrapper if needed - env_builder = None - if not skip_env and not is_already_in_uv_subprocess(): - from fastmcp.utilities.mcp_server_config.v1.environments.uv import ( - UVEnvironment, - ) - - env = UVEnvironment( - python=python, - dependencies=with_packages or None, - requirements=with_requirements, - project=project, - ) - test_cmd = ["test"] - if env.build_command(test_cmd) != test_cmd: - env_builder = env.build_command - - if reload: - # Build a fastmcp run command for the reload watcher to restart - reload_cmd = ["fastmcp", "run", server_spec, "--module", "--no-reload"] - if log_level: - reload_cmd.extend(["--log-level", log_level]) - if no_banner: - reload_cmd.append("--no-banner") - if env_builder is not None: - reload_cmd.append("--skip-env") - if server_args: - reload_cmd.append("--") - reload_cmd.extend(server_args) - if env_builder is not None: - reload_cmd = env_builder(reload_cmd) - await run_module.run_with_reload( - reload_cmd, reload_dirs=reload_dir, is_stdio=True - ) - return - - run_module.run_module_command( - server_spec, - env_command_builder=env_builder, - extra_args=list(server_args) if server_args else None, - ) - return - - # Check if we were spawned by uv (or user explicitly set --skip-env) - if skip_env or is_already_in_uv_subprocess(): - skip_env = True - - try: - # Load config and apply CLI overrides - config, server_spec = load_and_merge_config( - server_spec, - python=python, - with_packages=with_packages or [], - with_requirements=with_requirements, - project=project, - transport=transport, - host=host, - port=port, - path=path, - log_level=log_level, - server_args=list(server_args) if server_args else None, - ) - except FileNotFoundError: - sys.exit(1) - - # Get effective values (CLI overrides take precedence) - final_transport = transport or config.deployment.transport - final_host = host or config.deployment.host - final_port = port or config.deployment.port - final_path = path or config.deployment.path - final_log_level = log_level or config.deployment.log_level - final_server_args = server_args or config.deployment.args - # Use CLI override if provided, otherwise use settings - # no_banner CLI flag overrides the show_server_banner setting - final_no_banner = ( - no_banner if no_banner else not fastmcp.settings.show_server_banner - ) - - logger.debug( - "Running server or client", - extra={ - "server_spec": server_spec, - "transport": final_transport, - "host": final_host, - "port": final_port, - "path": final_path, - "log_level": final_log_level, - "server_args": list(final_server_args) if final_server_args else [], - }, - ) - - # Handle reload mode - if reload: - # SSE is incompatible with reload (no stateless mode exists) - if final_transport == "sse": - logger.warning( - "--reload is not supported with SSE transport (sessions are lost on restart). " - "Use streamable-http transport instead, or use --no-reload. " - "Running without reload." - ) - # Fall through to normal execution - else: - # Build command for subprocess (with --no-reload to prevent infinite spawning) - reload_cmd = ["fastmcp", "run", server_spec] - if final_transport: - reload_cmd.extend(["--transport", final_transport]) - if final_transport != "stdio": - if final_host: - reload_cmd.extend(["--host", final_host]) - if final_port: - reload_cmd.extend(["--port", str(final_port)]) - if final_path: - reload_cmd.extend(["--path", final_path]) - if final_log_level: - reload_cmd.extend(["--log-level", final_log_level]) - if final_no_banner: - reload_cmd.append("--no-banner") - reload_cmd.append("--no-reload") # Prevent infinite spawning - reload_cmd.append("--stateless") # Stateless mode for reload compatibility - - # If environment setup is needed, wrap with uv - test_cmd = ["test"] - needs_uv = ( - config.environment.build_command(test_cmd) != test_cmd and not skip_env - ) - if needs_uv: - # Add --skip-env to prevent nested uv runs (child would spawn another uv) - reload_cmd.append("--skip-env") - - if final_server_args: - reload_cmd.append("--") - reload_cmd.extend(final_server_args) - - if needs_uv: - reload_cmd = config.environment.build_command(reload_cmd) - - is_stdio = final_transport in ("stdio", None) - await run_module.run_with_reload( - reload_cmd, reload_dirs=reload_dir, is_stdio=is_stdio - ) - return - - # Check if we need to use uv run (but skip if we're already in uv or user said to skip) - # We check if the environment would modify the command - test_cmd = ["test"] - needs_uv = config.environment.build_command(test_cmd) != test_cmd and not skip_env - - if needs_uv: - # Build the inner fastmcp command - inner_cmd = ["fastmcp", "run", server_spec] - - # Add transport options to the inner command - if final_transport: - inner_cmd.extend(["--transport", final_transport]) - # Only add HTTP-specific options for non-stdio transports - if final_transport != "stdio": - if final_host: - inner_cmd.extend(["--host", final_host]) - if final_port: - inner_cmd.extend(["--port", str(final_port)]) - if final_path: - inner_cmd.extend(["--path", final_path]) - if final_log_level: - inner_cmd.extend(["--log-level", final_log_level]) - if final_no_banner: - inner_cmd.append("--no-banner") - # Add skip-env flag to prevent infinite recursion - inner_cmd.append("--skip-env") - - # Add server args if any - if final_server_args: - inner_cmd.append("--") - inner_cmd.extend(final_server_args) - - # Build the full uv command using the config's environment - cmd = config.environment.build_command(inner_cmd) - - # Set marker to prevent infinite loops when subprocess calls FastMCP again - env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} - - # Run the command - logger.debug(f"Running command: {' '.join(cmd)}") - try: - process = subprocess.run(cmd, check=True, env=env) - sys.exit(process.returncode) - except subprocess.CalledProcessError as e: - logger.exception( - f"Failed to run: {e}", - extra={ - "server_spec": server_spec, - "error": str(e), - "returncode": e.returncode, - }, - ) - sys.exit(e.returncode) - else: - # Use direct import for backwards compatibility - try: - await run_module.run_command( - server_spec=server_spec, - transport=final_transport, - host=final_host, - port=final_port, - path=final_path, - log_level=final_log_level, - server_args=list(final_server_args) if final_server_args else [], - show_banner=not final_no_banner, - skip_source=skip_source, - stateless=stateless, - ) - except Exception as e: - logger.exception( - f"Failed to run: {e}", - extra={ - "server_spec": server_spec, - "error": str(e), - }, - ) - sys.exit(1) - - -@app.command -async def inspect( - server_spec: str | None = None, - *, - format: Annotated[ - InspectFormat | None, - cyclopts.Parameter( - name=["--format", "-f"], - help="Output format: fastmcp (FastMCP-specific) or mcp (MCP protocol). Required when using -o.", - ), - ] = None, - output: Annotated[ - Path | None, - cyclopts.Parameter( - name=["--output", "-o"], - help="Output file path for the JSON report. If not specified, outputs to stdout when format is provided.", - ), - ] = None, - python: Annotated[ - str | None, - cyclopts.Parameter( - "--python", - help="Python version to use (e.g., 3.10, 3.11)", - ), - ] = None, - with_packages: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--with", help="Additional packages to install (can be used multiple times)" - ), - ] = None, - project: Annotated[ - Path | None, - cyclopts.Parameter( - "--project", - help="Run the command within the given project directory", - ), - ] = None, - with_requirements: Annotated[ - Path | None, - cyclopts.Parameter( - "--with-requirements", - help="Requirements file to install dependencies from", - ), - ] = None, - skip_env: Annotated[ - bool, - cyclopts.Parameter( - "--skip-env", - help="Skip environment configuration (for internal use when already in a uv environment)", - ), - ] = False, -) -> None: - """Inspect an MCP server and display information or generate a JSON report. - - This command analyzes an MCP server. Without flags, it displays a text summary. - Use --format to output complete JSON data. - - Examples: - # Show text summary - fastmcp inspect server.py - - # Output FastMCP format JSON to stdout - fastmcp inspect server.py --format fastmcp - - # Save MCP protocol format to file (format required with -o) - fastmcp inspect server.py --format mcp -o manifest.json - - # Inspect from fastmcp.json configuration - fastmcp inspect fastmcp.json - fastmcp inspect # auto-detect fastmcp.json - - Args: - server_spec: Python file to inspect, optionally with :object suffix, or fastmcp.json - """ - - # Check if we were spawned by uv (or user explicitly set --skip-env) - if skip_env or is_already_in_uv_subprocess(): - skip_env = True - - try: - # Load config and apply CLI overrides - config, server_spec = load_and_merge_config( - server_spec, - python=python, - with_packages=with_packages or [], - with_requirements=with_requirements, - project=project, - ) - - # Check if it's an MCPConfig (which inspect doesn't support) - if server_spec.endswith(".json") and config is None: - # This might be an MCPConfig, check the file - try: - with open(Path(server_spec)) as f: - data = json.load(f) - if "mcpServers" in data: - logger.error("MCPConfig files are not supported by inspect command") - sys.exit(1) - except (json.JSONDecodeError, FileNotFoundError): - pass - - except FileNotFoundError: - sys.exit(1) - - # Check if we need to use uv run (but skip if we're already in uv or user said to skip) - # We check if the environment would modify the command - test_cmd = ["test"] - needs_uv = config.environment.build_command(test_cmd) != test_cmd and not skip_env - - if needs_uv: - # Build and run uv command - # The environment is already configured in the config object - inspect_command = [ - "fastmcp", - "inspect", - server_spec, - "--skip-env", # Prevent infinite recursion - ] - - # Add format and output flags if specified - if format: - inspect_command.extend(["--format", format.value]) - if output: - inspect_command.extend(["--output", str(output)]) - - # Run the command using subprocess - import subprocess - - cmd = config.environment.build_command(inspect_command) - env = os.environ | {"FASTMCP_UV_SPAWNED": "1"} - process = subprocess.run(cmd, check=True, env=env) - sys.exit(process.returncode) - - logger.debug( - "Inspecting server", - extra={ - "server_spec": server_spec, - "format": format, - "output": str(output) if output else None, - }, - ) - - try: - # Load the server using the config - if not config: - logger.error("No configuration available") - sys.exit(1) - assert config is not None # For type checker - server = await config.source.load_server() - - # Get basic server information - info = await inspect_fastmcp(server) - - # Check for invalid combination - if output and not format: - console.print( - "[bold red]Error:[/bold red] --format is required when using -o/--output" - ) - console.print( - "[dim]Use --format fastmcp or --format mcp to specify the output format[/dim]" - ) - sys.exit(1) - - # If no format specified, show text summary - if format is None: - # Display text summary - console.print() - - # Server section - console.print("[bold]Server[/bold]") - console.print(f" Name: {info.name}") - if info.version: - console.print(f" Version: {info.version}") - if info.website_url: - console.print(f" Website: {info.website_url}") - if info.icons: - console.print(f" Icons: {len(info.icons)}") - console.print(f" Generation: {info.server_generation}") - if info.instructions: - console.print(f" Instructions: {info.instructions}") - console.print() - - # Components section - console.print("[bold]Components[/bold]") - console.print(f" Tools: {len(info.tools)}") - console.print(f" Prompts: {len(info.prompts)}") - console.print(f" Resources: {len(info.resources)}") - console.print(f" Templates: {len(info.templates)}") - console.print() - - # Environment section - console.print("[bold]Environment[/bold]") - console.print(f" FastMCP: {info.fastmcp_version}") - console.print(f" MCP: {info.mcp_version}") - console.print() - - console.print( - "[dim]Use --format \\[fastmcp|mcp] for complete JSON output[/dim]" - ) - return - - # Generate formatted JSON output - formatted_json = await format_info(server, format, info) - - # Output to file or stdout - if output: - # Ensure output directory exists - output.parent.mkdir(parents=True, exist_ok=True) - - # Write JSON report - with output.open("wb") as f: - f.write(formatted_json) - - logger.info(f"Server inspection complete. Report saved to {output}") - - # Print confirmation to console - console.print( - f"[bold green]✓[/bold green] Server inspection saved to: [cyan]{output}[/cyan]" - ) - console.print(f" Server: [bold]{info.name}[/bold]") - console.print(f" Format: {format.value}") - else: - # Output JSON to stdout - console.print(formatted_json.decode("utf-8")) - - except Exception as e: - logger.exception( - f"Failed to inspect server: {e}", - extra={ - "server_spec": server_spec, - "error": str(e), - }, - ) - console.print(f"[bold red]✗[/bold red] Failed to inspect server: {e}") - sys.exit(1) - - -# Create project subcommand group -project_app = cyclopts.App(name="project", help="Manage FastMCP projects") - - -@project_app.command -async def prepare( - config_path: Annotated[ - str | None, - cyclopts.Parameter(help="Path to fastmcp.json configuration file"), - ] = None, - output_dir: Annotated[ - str | None, - cyclopts.Parameter(help="Directory to create the persistent environment in"), - ] = None, - skip_source: Annotated[ - bool, - cyclopts.Parameter(help="Skip source preparation (e.g., git clone)"), - ] = False, -) -> None: - """Prepare a FastMCP project by creating a persistent uv environment. - - This command creates a persistent uv project with all dependencies installed: - - Creates a pyproject.toml with dependencies from the config - - Installs all Python packages into a .venv - - Prepares the source (git clone, download, etc.) unless --skip-source - - After running this command, you can use: - fastmcp run --project - - This is useful for: - - CI/CD pipelines with separate build and run stages - - Docker images where you prepare during build - - Production deployments where you want fast startup times - - Example: - fastmcp project prepare myserver.json --output-dir ./prepared-env - fastmcp run myserver.json --project ./prepared-env - """ - from pathlib import Path - - # Require output-dir - if output_dir is None: - logger.error( - "The --output-dir parameter is required.\n" - "Please specify where to create the persistent environment." - ) - sys.exit(1) - - # Auto-detect fastmcp.json if not provided - if config_path is None: - found_config = MCPServerConfig.find_config() - if found_config: - config_path = str(found_config) - logger.info(f"Using configuration from {config_path}") - else: - logger.error( - "No configuration file specified and no fastmcp.json found.\n" - "Please specify a configuration file or create a fastmcp.json." - ) - sys.exit(1) - - assert config_path is not None - config_file = Path(config_path) - if not config_file.exists(): - logger.error(f"Configuration file not found: {config_path}") - sys.exit(1) - - assert output_dir is not None - output_path = Path(output_dir) - - try: - # Load the configuration - config = MCPServerConfig.from_file(config_file) - - # Prepare environment and source - await config.prepare( - skip_source=skip_source, - output_dir=output_path, - ) - - console.print( - f"[bold green]✓[/bold green] Project prepared successfully in {output_path}!\n" - f"You can now run the server with:\n" - f" [cyan]fastmcp run {config_path} --project {output_dir}[/cyan]" - ) - - except Exception as e: - logger.error(f"Failed to prepare project: {e}") - console.print(f"[bold red]✗[/bold red] Failed to prepare project: {e}") - sys.exit(1) - - -# Add dev subcommand group -app.command(dev_app) - -# Add project subcommand group -app.command(project_app) - -# Add install subcommands using proper Cyclopts pattern -app.command(install_app) - -# Add tasks subcommand group -app.command(tasks_app) - -# Add client query commands -app.command(list_command, name="list") -app.command(call_command, name="call") -app.command(discover_command, name="discover") -app.command(generate_cli_command, name="generate-cli") - -# Add auth subcommand group (includes CIMD commands) -app.command(auth_app) - - -if __name__ == "__main__": - app() diff --git a/src/fastmcp/cli/client.py b/src/fastmcp/cli/client.py deleted file mode 100644 index cc43b3aff..000000000 --- a/src/fastmcp/cli/client.py +++ /dev/null @@ -1,986 +0,0 @@ -"""Client-side CLI commands for querying and invoking MCP servers.""" - -import difflib -import json -import shlex -import sys -from pathlib import Path -from typing import Annotated, Any, Literal - -import cyclopts -import mcp.types -from rich.console import Console -from rich.markup import escape as escape_rich_markup - -from fastmcp.cli.discovery import DiscoveredServer, discover_servers, resolve_name -from fastmcp.client.client import CallToolResult, Client -from fastmcp.client.elicitation import ElicitResult -from fastmcp.client.transports.base import ClientTransport -from fastmcp.client.transports.http import StreamableHttpTransport -from fastmcp.client.transports.sse import SSETransport -from fastmcp.client.transports.stdio import StdioTransport -from fastmcp.utilities.logging import get_logger - -logger = get_logger("cli.client") -console = Console() - - -# --------------------------------------------------------------------------- -# Server spec resolution -# --------------------------------------------------------------------------- - -_JSON_SCHEMA_TYPE_MAP: dict[str, str] = { - "string": "str", - "integer": "int", - "number": "float", - "boolean": "bool", - "array": "list", - "object": "dict", - "null": "None", -} - - -def resolve_server_spec( - server_spec: str | None, - *, - command: str | None = None, - transport: str | None = None, -) -> str | dict[str, Any] | ClientTransport: - """Turn CLI inputs into something ``Client()`` accepts. - - Exactly one of ``server_spec`` or ``command`` should be provided. - - Resolution order for ``server_spec``: - 1. URLs (``http://``, ``https://``) — passed through as-is. - If ``--transport`` is ``sse``, the URL is rewritten to end with ``/sse`` - so ``infer_transport`` picks the right transport. - 2. Existing file paths, or strings ending in ``.py``/``.js``/``.json``. - 3. Anything else — name-based resolution via ``resolve_name``. - - When ``command`` is provided, the string is shell-split into a - ``StdioTransport(command, args)``. - """ - - if command is not None and server_spec is not None: - console.print( - "[bold red]Error:[/bold red] Cannot use both a server spec and --command" - ) - sys.exit(1) - - if command is not None: - return _build_stdio_from_command(command) - - if server_spec is None: - console.print( - "[bold red]Error:[/bold red] Provide a server spec or use --command" - ) - sys.exit(1) - - assert isinstance(server_spec, str) - spec: str = server_spec - - # 1. URL - if spec.startswith(("http://", "https://")): - if transport == "sse" and not spec.rstrip("/").endswith("/sse"): - spec = spec.rstrip("/") + "/sse" - return spec - - # 2. File path (must be a file, not a directory) - path = Path(spec) - is_file = path.is_file() or ( - not path.is_dir() and spec.endswith((".py", ".js", ".json")) - ) - - if is_file: - if spec.endswith(".json"): - return _resolve_json_spec(path) - if spec.endswith(".py"): - # Run via `fastmcp run` so scripts don't need mcp.run() - resolved_path = path.resolve() - return StdioTransport( - command="fastmcp", - args=["run", str(resolved_path), "--no-banner"], - ) - # .js — pass through for Client's infer_transport - return spec - - # 3. Name-based resolution (bare name or source:name) - try: - return resolve_name(spec) - except ValueError as exc: - console.print(f"[bold red]Error:[/bold red] {exc}") - sys.exit(1) - - -def _build_stdio_from_command(command_str: str) -> StdioTransport: - """Shell-split a command string into a ``StdioTransport``.""" - try: - parts = shlex.split(command_str) - except ValueError as exc: - console.print(f"[bold red]Error:[/bold red] Invalid command: {exc}") - sys.exit(1) - - if not parts: - console.print("[bold red]Error:[/bold red] Empty --command") - sys.exit(1) - - return StdioTransport(command=parts[0], args=parts[1:]) - - -def _resolve_json_spec(path: Path) -> str | dict[str, Any]: - """Disambiguate a ``.json`` server spec.""" - - if not path.exists(): - console.print( - f"[bold red]Error:[/bold red] File not found: [cyan]{path}[/cyan]" - ) - sys.exit(1) - - try: - data = json.loads(path.read_text()) - except json.JSONDecodeError as exc: - console.print(f"[bold red]Error:[/bold red] Invalid JSON in {path}: {exc}") - sys.exit(1) - - if isinstance(data, dict) and "mcpServers" in data: - return data - - # Likely a fastmcp.json (MCPServerConfig) — not directly usable as a client target. - console.print( - f"[bold red]Error:[/bold red] [cyan]{path}[/cyan] is a FastMCP server config, not an MCPConfig.\n" - f"Start the server first, then query it:\n\n" - f" fastmcp run {path}\n" - f" fastmcp list http://localhost:8000/mcp\n" - ) - sys.exit(1) - - -def _is_http_target(resolved: str | dict[str, Any] | ClientTransport) -> bool: - """Return True if the resolved target will use an HTTP-based transport. - - MCPConfig dicts are excluded because ``MCPConfigTransport`` manages - individual server transports internally and does not support top-level auth. - """ - if isinstance(resolved, str): - return resolved.startswith(("http://", "https://")) - return isinstance(resolved, (StreamableHttpTransport, SSETransport)) - - -async def _terminal_elicitation_handler( - message: str, - response_type: type[Any] | None, - params: Any, - context: Any, -) -> ElicitResult[dict[str, Any]]: - """Prompt the user on the terminal for elicitation responses. - - Prints the server's message and prompts for each field in the schema. - The user can type 'decline' or 'cancel' instead of a value to abort. - """ - from mcp.types import ElicitRequestFormParams - - console.print(f"\n[bold yellow]Server asks:[/bold yellow] {message}") - - if not isinstance(params, ElicitRequestFormParams): - answer = console.input( - "[dim](press Enter to accept, or type 'decline'):[/dim] " - ) - if answer.strip().lower() == "decline": - return ElicitResult(action="decline") - if answer.strip().lower() == "cancel": - return ElicitResult(action="cancel") - return ElicitResult(action="accept", content={}) - - schema = params.requestedSchema - properties = schema.get("properties", {}) - required = set(schema.get("required", [])) - - if not properties: - answer = console.input( - "[dim](press Enter to accept, or type 'decline'):[/dim] " - ) - if answer.strip().lower() == "decline": - return ElicitResult(action="decline") - if answer.strip().lower() == "cancel": - return ElicitResult(action="cancel") - return ElicitResult(action="accept", content={}) - - result: dict[str, Any] = {} - for field_name, field_schema in properties.items(): - type_hint = field_schema.get("type", "string") - req_marker = " [red]*[/red]" if field_name in required else "" - prompt_text = f" [cyan]{field_name}[/cyan] ({type_hint}){req_marker}: " - - raw = console.input(prompt_text) - if raw.strip().lower() == "decline": - return ElicitResult(action="decline") - if raw.strip().lower() == "cancel": - return ElicitResult(action="cancel") - - if raw == "" and field_name not in required: - continue - - result[field_name] = coerce_value(raw, field_schema) - - return ElicitResult(action="accept", content=result) - - -def _build_client( - resolved: str | dict[str, Any] | ClientTransport, - *, - timeout: float | None = None, - auth: str | None = None, -) -> Client: - """Build a ``Client`` from a resolved server spec. - - Applies ``auth='oauth'`` automatically for HTTP-based targets unless - the caller explicitly passes ``--auth none`` to disable it. - - ``auth=None`` means "not specified" (use default), ``auth="none"`` - means "explicitly disabled". - """ - if auth == "none": - effective_auth: str | None = None - elif auth is not None: - effective_auth = auth - elif _is_http_target(resolved): - effective_auth = "oauth" - else: - effective_auth = None - - return Client( - resolved, - timeout=timeout, - auth=effective_auth, - elicitation_handler=_terminal_elicitation_handler, - ) - - -# --------------------------------------------------------------------------- -# Argument coercion -# --------------------------------------------------------------------------- - - -def coerce_value(raw: str, schema: dict[str, Any]) -> Any: - """Coerce a string CLI value according to a JSON-Schema type hint.""" - - schema_type = schema.get("type", "string") - - if schema_type == "integer": - try: - return int(raw) - except ValueError: - raise ValueError(f"Expected integer, got {raw!r}") from None - - if schema_type == "number": - try: - return float(raw) - except ValueError: - raise ValueError(f"Expected number, got {raw!r}") from None - - if schema_type == "boolean": - if raw.lower() in ("true", "1", "yes"): - return True - if raw.lower() in ("false", "0", "no"): - return False - raise ValueError(f"Expected boolean, got {raw!r}") - - if schema_type in ("array", "object"): - try: - return json.loads(raw) - except json.JSONDecodeError: - raise ValueError(f"Expected JSON {schema_type}, got {raw!r}") from None - - # Default: treat as string - return raw - - -def parse_tool_arguments( - raw_args: tuple[str, ...], - input_json: str | None, - input_schema: dict[str, Any], -) -> dict[str, Any]: - """Build a tool-call argument dict from CLI inputs. - - A single JSON object argument is treated as the full argument dict. - ``--input-json`` provides the base dict; ``key=value`` pairs override. - Values are coerced using the tool's ``inputSchema``. - """ - - # A single positional arg that looks like JSON → treat as input-json - if len(raw_args) == 1 and raw_args[0].startswith("{") and input_json is None: - input_json = raw_args[0] - raw_args = () - - result: dict[str, Any] = {} - - if input_json is not None: - try: - parsed = json.loads(input_json) - except json.JSONDecodeError as exc: - console.print(f"[bold red]Error:[/bold red] Invalid --input-json: {exc}") - sys.exit(1) - if not isinstance(parsed, dict): - console.print( - "[bold red]Error:[/bold red] --input-json must be a JSON object" - ) - sys.exit(1) - result.update(parsed) - - properties = input_schema.get("properties", {}) - - for arg in raw_args: - if "=" not in arg: - console.print( - f"[bold red]Error:[/bold red] Invalid argument [cyan]{arg}[/cyan] — expected key=value" - ) - sys.exit(1) - key, value = arg.split("=", 1) - prop_schema = properties.get(key, {}) - try: - result[key] = coerce_value(value, prop_schema) - except ValueError as exc: - console.print( - f"[bold red]Error:[/bold red] Argument [cyan]{key}[/cyan]: {exc}" - ) - sys.exit(1) - - return result - - -# --------------------------------------------------------------------------- -# Tool signature formatting -# --------------------------------------------------------------------------- - - -def _json_schema_type_to_str(schema: dict[str, Any]) -> str: - """Produce a short Python-style type string from a JSON-Schema fragment.""" - - if "anyOf" in schema: - parts = [_json_schema_type_to_str(s) for s in schema["anyOf"]] - return " | ".join(parts) - - schema_type = schema.get("type", "any") - if isinstance(schema_type, list): - return " | ".join(_JSON_SCHEMA_TYPE_MAP.get(t, t) for t in schema_type) - - return _JSON_SCHEMA_TYPE_MAP.get(schema_type, schema_type) - - -def format_tool_signature(tool: mcp.types.Tool) -> str: - """Build ``name(param: type, ...) -> return_type`` from a tool's JSON schemas.""" - - params: list[str] = [] - schema = tool.inputSchema - properties = schema.get("properties", {}) - required = set(schema.get("required", [])) - - for prop_name, prop_schema in properties.items(): - type_str = _json_schema_type_to_str(prop_schema) - if prop_name in required: - params.append(f"{prop_name}: {type_str}") - else: - default = prop_schema.get("default") - default_repr = repr(default) if default is not None else "..." - params.append(f"{prop_name}: {type_str} = {default_repr}") - - sig = f"{tool.name}({', '.join(params)})" - - if tool.outputSchema: - ret = _json_schema_type_to_str(tool.outputSchema) - sig += f" -> {ret}" - - return sig - - -# --------------------------------------------------------------------------- -# Output formatting -# --------------------------------------------------------------------------- - - -def _print_schema(label: str, schema: dict[str, Any]) -> None: - """Print a JSON schema with a label.""" - properties = schema.get("properties", {}) - if not properties: - return - console.print(f" [dim]{label}: {json.dumps(schema)}[/dim]") - - -def _sanitize_untrusted_text(value: str) -> str: - """Escape rich markup and encode control chars for terminal-safe output.""" - sanitized = escape_rich_markup(value) - return "".join( - ch - if ch in {"\n", "\t"} or (0x20 <= ord(ch) < 0x7F) or ord(ch) > 0x9F - else f"\\x{ord(ch):02x}" - for ch in sanitized - ) - - -def _format_call_result_text(result: CallToolResult) -> None: - """Pretty-print a tool call result to the console.""" - - if result.is_error: - for block in result.content: - if isinstance(block, mcp.types.TextContent): - console.print( - f"[bold red]Error:[/bold red] {_sanitize_untrusted_text(block.text)}" - ) - else: - console.print( - f"[bold red]Error:[/bold red] {_sanitize_untrusted_text(str(block))}" - ) - return - - if result.structured_content is not None: - console.print_json(json.dumps(result.structured_content)) - return - - for block in result.content: - if isinstance(block, mcp.types.TextContent): - console.print(_sanitize_untrusted_text(block.text)) - elif isinstance(block, mcp.types.ImageContent): - size = len(block.data) * 3 // 4 # rough decoded size - console.print(f"[dim][Image: {block.mimeType}, ~{size} bytes][/dim]") - elif isinstance(block, mcp.types.AudioContent): - size = len(block.data) * 3 // 4 - console.print(f"[dim][Audio: {block.mimeType}, ~{size} bytes][/dim]") - else: - console.print(_sanitize_untrusted_text(str(block))) - - -def _content_block_to_dict(block: mcp.types.ContentBlock) -> dict[str, Any]: - """Serialize a single content block to a JSON-safe dict.""" - if isinstance(block, mcp.types.TextContent): - return {"type": "text", "text": block.text} - if isinstance(block, mcp.types.ImageContent): - return {"type": "image", "mimeType": block.mimeType, "data": block.data} - if isinstance(block, mcp.types.AudioContent): - return {"type": "audio", "mimeType": block.mimeType, "data": block.data} - return {"type": "unknown", "value": str(block)} - - -def _call_result_to_dict(result: CallToolResult) -> dict[str, Any]: - """Serialize a ``CallToolResult`` to a JSON-safe dict.""" - - content_list = [_content_block_to_dict(block) for block in result.content] - out: dict[str, Any] = {"content": content_list, "is_error": result.is_error} - if result.structured_content is not None: - out["structured_content"] = result.structured_content - return out - - -def _tools_to_json(tools: list[mcp.types.Tool]) -> list[dict[str, Any]]: - """Serialize a list of tools to JSON-safe dicts.""" - - return [ - { - "name": t.name, - "description": t.description, - "inputSchema": t.inputSchema, - **({"outputSchema": t.outputSchema} if t.outputSchema else {}), - } - for t in tools - ] - - -# --------------------------------------------------------------------------- -# Call handlers (tool, resource, prompt) -# --------------------------------------------------------------------------- - - -async def _handle_tool_call( - client: Client, - tool_name: str, - arguments: tuple[str, ...], - input_json: str | None, - json_output: bool, -) -> None: - """Handle a tool call within an open client session.""" - tools = await client.list_tools() - tool_map = {t.name: t for t in tools} - - if tool_name not in tool_map: - close_matches = difflib.get_close_matches( - tool_name, tool_map.keys(), n=3, cutoff=0.5 - ) - msg = f"Tool [cyan]{tool_name}[/cyan] not found." - if close_matches: - suggestions = ", ".join(f"[cyan]{m}[/cyan]" for m in close_matches) - msg += f" Did you mean: {suggestions}?" - console.print(f"[bold red]Error:[/bold red] {msg}") - sys.exit(1) - - tool = tool_map[tool_name] - parsed_args = parse_tool_arguments(arguments, input_json, tool.inputSchema) - - required = set(tool.inputSchema.get("required", [])) - provided = set(parsed_args.keys()) - missing = required - provided - if missing: - missing_str = ", ".join(f"[cyan]{m}[/cyan]" for m in sorted(missing)) - console.print( - f"[bold red]Error:[/bold red] Missing required arguments: {missing_str}" - ) - console.print() - sig = format_tool_signature(tool) - console.print(f" [dim]{sig}[/dim]") - sys.exit(1) - - result = await client.call_tool(tool_name, parsed_args, raise_on_error=False) - - if json_output: - console.print_json(json.dumps(_call_result_to_dict(result))) - else: - _format_call_result_text(result) - - if result.is_error: - sys.exit(1) - - -async def _handle_resource( - client: Client, - uri: str, - json_output: bool, -) -> None: - """Handle a resource read within an open client session.""" - contents = await client.read_resource(uri) - - if json_output: - data = [] - for block in contents: - if isinstance(block, mcp.types.TextResourceContents): - data.append( - { - "uri": str(block.uri), - "mimeType": block.mimeType, - "text": block.text, - } - ) - elif isinstance(block, mcp.types.BlobResourceContents): - data.append( - { - "uri": str(block.uri), - "mimeType": block.mimeType, - "blob": block.blob, - } - ) - console.print_json(json.dumps(data)) - return - - for block in contents: - if isinstance(block, mcp.types.TextResourceContents): - console.print(_sanitize_untrusted_text(block.text)) - elif isinstance(block, mcp.types.BlobResourceContents): - size = len(block.blob) * 3 // 4 - console.print(f"[dim][Blob: {block.mimeType}, ~{size} bytes][/dim]") - - -async def _handle_prompt( - client: Client, - prompt_name: str, - arguments: tuple[str, ...], - input_json: str | None, - json_output: bool, -) -> None: - """Handle a prompt get within an open client session.""" - # Prompt arguments are always string->string, but we reuse - # parse_tool_arguments for the key=value / --input-json parsing. - # Pass an empty schema so values stay as strings. - parsed_args = parse_tool_arguments(arguments, input_json, {"type": "object"}) - - prompts = await client.list_prompts() - prompt_map = {p.name: p for p in prompts} - - if prompt_name not in prompt_map: - close_matches = difflib.get_close_matches( - prompt_name, prompt_map.keys(), n=3, cutoff=0.5 - ) - msg = f"Prompt [cyan]{prompt_name}[/cyan] not found." - if close_matches: - suggestions = ", ".join(f"[cyan]{m}[/cyan]" for m in close_matches) - msg += f" Did you mean: {suggestions}?" - console.print(f"[bold red]Error:[/bold red] {msg}") - sys.exit(1) - - result = await client.get_prompt(prompt_name, parsed_args or None) - - if json_output: - data: dict[str, Any] = {} - if result.description: - data["description"] = result.description - data["messages"] = [ - { - "role": msg.role, - "content": _content_block_to_dict(msg.content), - } - for msg in result.messages - ] - console.print_json(json.dumps(data)) - return - - for msg in result.messages: - console.print(f"[bold]{_sanitize_untrusted_text(msg.role)}:[/bold]") - if isinstance(msg.content, mcp.types.TextContent): - console.print(f" {_sanitize_untrusted_text(msg.content.text)}") - elif isinstance(msg.content, mcp.types.ImageContent): - size = len(msg.content.data) * 3 // 4 - console.print( - f" [dim][Image: {msg.content.mimeType}, ~{size} bytes][/dim]" - ) - else: - console.print(f" {_sanitize_untrusted_text(str(msg.content))}") - console.print() - - -# --------------------------------------------------------------------------- -# Commands -# --------------------------------------------------------------------------- - - -async def list_command( - server_spec: Annotated[ - str | None, - cyclopts.Parameter( - help="Server URL, Python file, MCPConfig JSON, or .js file", - ), - ] = None, - *, - command: Annotated[ - str | None, - cyclopts.Parameter( - "--command", - help="Stdio command to connect to (e.g. 'npx -y @mcp/server')", - ), - ] = None, - transport: Annotated[ - Literal["http", "sse"] | None, - cyclopts.Parameter( - name=["--transport", "-t"], - help="Force transport type for URL targets (http or sse)", - ), - ] = None, - resources: Annotated[ - bool, - cyclopts.Parameter("--resources", help="Also list resources"), - ] = False, - prompts: Annotated[ - bool, - cyclopts.Parameter("--prompts", help="Also list prompts"), - ] = False, - input_schema: Annotated[ - bool, - cyclopts.Parameter("--input-schema", help="Show full input schemas"), - ] = False, - output_schema: Annotated[ - bool, - cyclopts.Parameter("--output-schema", help="Show full output schemas"), - ] = False, - json_output: Annotated[ - bool, - cyclopts.Parameter("--json", help="Output as JSON"), - ] = False, - timeout: Annotated[ - float | None, - cyclopts.Parameter("--timeout", help="Connection timeout in seconds"), - ] = None, - auth: Annotated[ - str | None, - cyclopts.Parameter( - "--auth", - help="Auth method: 'oauth', a bearer token string, or 'none' to disable", - ), - ] = None, -) -> None: - """List tools available on an MCP server. - - Examples: - fastmcp list http://localhost:8000/mcp - fastmcp list server.py - fastmcp list mcp.json --json - fastmcp list --command 'npx -y @mcp/server' --resources - fastmcp list http://server/mcp --transport sse - """ - - resolved = resolve_server_spec(server_spec, command=command, transport=transport) - client = _build_client(resolved, timeout=timeout, auth=auth) - - try: - async with client: - tools = await client.list_tools() - - if json_output: - data: dict[str, Any] = {"tools": _tools_to_json(tools)} - if resources: - res = await client.list_resources() - data["resources"] = [ - { - "uri": str(r.uri), - "name": r.name, - "description": r.description, - "mimeType": r.mimeType, - } - for r in res - ] - if prompts: - prm = await client.list_prompts() - data["prompts"] = [ - { - "name": p.name, - "description": p.description, - "arguments": [a.model_dump() for a in (p.arguments or [])], - } - for p in prm - ] - console.print_json(json.dumps(data)) - return - - # Text output - if not tools: - console.print("[dim]No tools found.[/dim]") - else: - console.print(f"[bold]Tools ({len(tools)})[/bold]") - console.print() - for tool in tools: - sig = format_tool_signature(tool) - console.print(f" [cyan]{_sanitize_untrusted_text(sig)}[/cyan]") - if tool.description: - console.print( - f" {_sanitize_untrusted_text(tool.description)}" - ) - if input_schema: - _print_schema("Input", tool.inputSchema) - if output_schema and tool.outputSchema: - _print_schema("Output", tool.outputSchema) - console.print() - - if resources: - res = await client.list_resources() - console.print(f"[bold]Resources ({len(res)})[/bold]") - console.print() - if not res: - console.print(" [dim]No resources found.[/dim]") - for r in res: - console.print( - f" [cyan]{_sanitize_untrusted_text(str(r.uri))}[/cyan]" - ) - desc_parts = [r.name or "", r.description or ""] - desc = " — ".join(p for p in desc_parts if p) - if desc: - console.print(f" {_sanitize_untrusted_text(desc)}") - console.print() - - if prompts: - prm = await client.list_prompts() - console.print(f"[bold]Prompts ({len(prm)})[/bold]") - console.print() - if not prm: - console.print(" [dim]No prompts found.[/dim]") - for p in prm: - args_str = "" - if p.arguments: - parts = [a.name for a in p.arguments] - args_str = f"({', '.join(parts)})" - console.print( - f" [cyan]{_sanitize_untrusted_text(p.name + args_str)}[/cyan]" - ) - if p.description: - console.print(f" {_sanitize_untrusted_text(p.description)}") - console.print() - - except Exception as exc: - console.print(f"[bold red]Error:[/bold red] {exc}") - sys.exit(1) - - -async def call_command( - server_spec: Annotated[ - str | None, - cyclopts.Parameter( - help="Server URL, Python file, MCPConfig JSON, or .js file", - ), - ] = None, - target: Annotated[ - str, - cyclopts.Parameter( - help="Tool name, resource URI, or prompt name (with --prompt)", - ), - ] = "", - *arguments: str, - command: Annotated[ - str | None, - cyclopts.Parameter( - "--command", - help="Stdio command to connect to (e.g. 'npx -y @mcp/server')", - ), - ] = None, - transport: Annotated[ - Literal["http", "sse"] | None, - cyclopts.Parameter( - name=["--transport", "-t"], - help="Force transport type for URL targets (http or sse)", - ), - ] = None, - prompt: Annotated[ - bool, - cyclopts.Parameter("--prompt", help="Treat target as a prompt name"), - ] = False, - input_json: Annotated[ - str | None, - cyclopts.Parameter( - "--input-json", - help="JSON string of arguments (merged with key=value args)", - ), - ] = None, - json_output: Annotated[ - bool, - cyclopts.Parameter("--json", help="Output raw JSON result"), - ] = False, - timeout: Annotated[ - float | None, - cyclopts.Parameter("--timeout", help="Connection timeout in seconds"), - ] = None, - auth: Annotated[ - str | None, - cyclopts.Parameter( - "--auth", - help="Auth method: 'oauth', a bearer token string, or 'none' to disable", - ), - ] = None, -) -> None: - """Call a tool, read a resource, or get a prompt on an MCP server. - - By default the target is treated as a tool name. If the target - contains ``://`` it is treated as a resource URI. Pass ``--prompt`` - to treat it as a prompt name. - - Arguments are passed as key=value pairs. Use --input-json for complex - or nested arguments. - - Examples: - ``` - fastmcp call server.py greet name=World - fastmcp call server.py resource://docs/readme - fastmcp call server.py analyze --prompt data='[1,2,3]' - fastmcp call http://server/mcp create --input-json '{"tags": ["a","b"]}' - ``` - """ - - if not target: - console.print( - "[bold red]Error:[/bold red] Missing target.\n\n" - "Usage: fastmcp call [key=value ...]\n\n" - " target can be a tool name, a resource URI, or a prompt name (with --prompt).\n\n" - "Use [cyan]fastmcp list [/cyan] to see available tools." - ) - sys.exit(1) - - resolved = resolve_server_spec(server_spec, command=command, transport=transport) - client = _build_client(resolved, timeout=timeout, auth=auth) - - try: - async with client: - if prompt: - await _handle_prompt(client, target, arguments, input_json, json_output) - elif "://" in target: - await _handle_resource(client, target, json_output) - else: - await _handle_tool_call( - client, target, arguments, input_json, json_output - ) - - except Exception as exc: - console.print(f"[bold red]Error:[/bold red] {exc}") - sys.exit(1) - - -async def discover_command( - *, - source: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--source", - help="Only show servers from these sources (e.g. claude-code, cursor, gemini)", - ), - ] = None, - json_output: Annotated[ - bool, - cyclopts.Parameter("--json", help="Output as JSON"), - ] = False, -) -> None: - """Discover MCP servers configured in editor and project configs. - - Scans Claude Desktop, Claude Code, Cursor, Gemini CLI, Goose, and - project-level mcp.json files for MCP server definitions. - - Discovered server names can be used directly with ``fastmcp list`` - and ``fastmcp call`` instead of specifying a URL or file path. - - Examples: - fastmcp discover - fastmcp discover --source claude-code - fastmcp discover --source cursor --source gemini --json - fastmcp list weather - fastmcp call cursor:weather get_forecast city=London - """ - - servers = discover_servers() - - if source: - servers = [s for s in servers if s.source in source] - - if json_output: - data: list[dict[str, Any]] = [ - { - "name": s.name, - "source": s.source, - "qualified_name": s.qualified_name, - "transport_summary": s.transport_summary, - "config_path": str(s.config_path), - } - for s in servers - ] - console.print_json(json.dumps(data)) - return - - if not servers: - console.print("[dim]No MCP servers found.[/dim]") - console.print() - console.print("Searched:") - console.print(" • Claude Desktop config") - console.print(" • ~/.claude.json (Claude Code)") - console.print(" • .cursor/mcp.json (walked up from cwd)") - console.print(" • ~/.gemini/settings.json (Gemini CLI)") - console.print(" • ~/.config/goose/config.yaml (Goose)") - console.print(" • ./mcp.json") - return - - from rich.table import Table - - # Group by source - by_source: dict[str, list[DiscoveredServer]] = {} - for s in servers: - by_source.setdefault(s.source, []).append(s) - - for source_name, group in by_source.items(): - console.print() - console.print(f"[bold]Source:[/bold] {source_name}") - console.print(f"[bold]Config:[/bold] [dim]{group[0].config_path}[/dim]") - console.print() - - table = Table( - show_header=True, - header_style="bold", - show_edge=False, - pad_edge=False, - box=None, - padding=(0, 2), - ) - table.add_column("Server", style="cyan") - table.add_column("Transport", style="dim") - - for s in group: - table.add_row(s.name, s.transport_summary) - - console.print(table) - console.print() diff --git a/src/fastmcp/cli/discovery.py b/src/fastmcp/cli/discovery.py deleted file mode 100644 index 5acd42d61..000000000 --- a/src/fastmcp/cli/discovery.py +++ /dev/null @@ -1,375 +0,0 @@ -"""Discover MCP servers configured in editor config files. - -Scans filesystem-readable config files from editors like Claude Desktop, -Claude Code, Cursor, Gemini CLI, and Goose, as well as project-level -``mcp.json`` files. Each discovered server can be resolved by name -(or ``source:name``) so the CLI can connect without requiring a URL -or file path. -""" - -import json -import os -import sys -from dataclasses import dataclass -from pathlib import Path -from typing import Any - -import yaml - -from fastmcp.client.transports.base import ClientTransport -from fastmcp.mcp_config import ( - MCPConfig, - MCPServerTypes, - RemoteMCPServer, - StdioMCPServer, -) -from fastmcp.utilities.logging import get_logger - -logger = get_logger("cli.discovery") - - -# --------------------------------------------------------------------------- -# Data model -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class DiscoveredServer: - """A single MCP server found in an editor or project config.""" - - name: str - source: str - config: MCPServerTypes - config_path: Path - - @property - def qualified_name(self) -> str: - """Fully qualified ``source:name`` identifier.""" - return f"{self.source}:{self.name}" - - @property - def transport_summary(self) -> str: - """Human-readable one-liner describing the transport.""" - cfg = self.config - if isinstance(cfg, StdioMCPServer): - parts = [cfg.command, *cfg.args] - return f"stdio: {' '.join(parts)}" - if isinstance(cfg, RemoteMCPServer): - transport = cfg.transport or "http" - return f"{transport}: {cfg.url}" - return str(type(cfg).__name__) - - -# --------------------------------------------------------------------------- -# Scanners — one per config source -# --------------------------------------------------------------------------- - - -def _normalize_server_entry(entry: dict[str, Any]) -> dict[str, Any]: - """Normalize editor-specific server config fields to MCPConfig format. - - Handles two known differences: - - Claude Code uses ``type`` where MCPConfig uses ``transport`` for - remote servers. - - Gemini CLI uses ``httpUrl`` where MCPConfig uses ``url``. - """ - # Gemini: httpUrl → url - if "httpUrl" in entry and "url" not in entry: - entry = {**entry, "url": entry["httpUrl"]} - del entry["httpUrl"] - - # Claude Code / others: type → transport (for url-based entries only) - if "url" in entry and "type" in entry and "transport" not in entry: - transport = entry["type"] - entry = {k: v for k, v in entry.items() if k != "type"} - entry["transport"] = transport - - return entry - - -def _parse_mcp_servers( - servers_dict: dict[str, Any], - *, - source: str, - config_path: Path, -) -> list[DiscoveredServer]: - """Parse an ``mcpServers``-style dict into discovered servers.""" - if not servers_dict: - return [] - - normalized = { - name: _normalize_server_entry(entry) - for name, entry in servers_dict.items() - if isinstance(entry, dict) - } - - try: - config = MCPConfig.from_dict({"mcpServers": normalized}) - except Exception as exc: - logger.warning("Could not parse MCP servers from %s: %s", config_path, exc) - return [] - - return [ - DiscoveredServer( - name=name, source=source, config=server, config_path=config_path - ) - for name, server in config.mcpServers.items() - ] - - -def _parse_mcp_config(path: Path, source: str) -> list[DiscoveredServer]: - """Parse an mcpServers-style JSON file into discovered servers.""" - try: - text = path.read_text() - except OSError as exc: - logger.debug("Could not read %s: %s", path, exc) - return [] - - try: - data: dict[str, Any] = json.loads(text) - except json.JSONDecodeError as exc: - logger.warning("Invalid JSON in %s: %s", path, exc) - return [] - - if not isinstance(data, dict) or "mcpServers" not in data: - return [] - - return _parse_mcp_servers(data["mcpServers"], source=source, config_path=path) - - -def _scan_claude_desktop() -> list[DiscoveredServer]: - """Scan the Claude Desktop config file.""" - if sys.platform == "win32": - config_dir = Path(Path.home(), "AppData", "Roaming", "Claude") - elif sys.platform == "darwin": - config_dir = Path(Path.home(), "Library", "Application Support", "Claude") - elif sys.platform.startswith("linux"): - config_dir = Path( - os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude" - ) - else: - return [] - - path = config_dir / "claude_desktop_config.json" - return _parse_mcp_config(path, "claude-desktop") - - -def _scan_claude_code(start_dir: Path) -> list[DiscoveredServer]: - """Scan ``~/.claude.json`` for global and project-scoped MCP servers.""" - path = Path.home() / ".claude.json" - try: - text = path.read_text() - except OSError: - return [] - - try: - data: dict[str, Any] = json.loads(text) - except json.JSONDecodeError as exc: - logger.warning("Invalid JSON in %s: %s", path, exc) - return [] - - if not isinstance(data, dict): - return [] - - results: list[DiscoveredServer] = [] - - # Global servers - if global_servers := data.get("mcpServers"): - if isinstance(global_servers, dict): - results.extend( - _parse_mcp_servers( - global_servers, source="claude-code", config_path=path - ) - ) - - # Project-scoped servers matching start_dir - resolved_dir = str(start_dir.resolve()) - projects = data.get("projects", {}) - if isinstance(projects, dict): - project_data = projects.get(resolved_dir, {}) - if isinstance(project_data, dict): - if project_servers := project_data.get("mcpServers"): - if isinstance(project_servers, dict): - results.extend( - _parse_mcp_servers( - project_servers, - source="claude-code", - config_path=path, - ) - ) - - return results - - -def _scan_cursor_workspace(start_dir: Path) -> list[DiscoveredServer]: - """Walk up from *start_dir* looking for ``.cursor/mcp.json``.""" - current = start_dir.resolve() - home = Path.home().resolve() - - while True: - candidate = current / ".cursor" / "mcp.json" - if candidate.is_file(): - return _parse_mcp_config(candidate, "cursor") - - parent = current.parent - # Stop at filesystem root or home directory - if parent == current or current == home: - break - current = parent - - return [] - - -def _scan_project_mcp_json(start_dir: Path) -> list[DiscoveredServer]: - """Check for ``mcp.json`` in *start_dir*.""" - candidate = start_dir.resolve() / "mcp.json" - if candidate.is_file(): - return _parse_mcp_config(candidate, "project") - return [] - - -def _scan_gemini(start_dir: Path) -> list[DiscoveredServer]: - """Scan Gemini CLI settings for MCP servers. - - Checks both user-level ``~/.gemini/settings.json`` and project-level - ``.gemini/settings.json``. - """ - results: list[DiscoveredServer] = [] - - # User-level - user_path = Path.home() / ".gemini" / "settings.json" - results.extend(_parse_mcp_config(user_path, "gemini")) - - # Project-level - project_path = start_dir.resolve() / ".gemini" / "settings.json" - if project_path != user_path: - results.extend(_parse_mcp_config(project_path, "gemini")) - - return results - - -def _scan_goose() -> list[DiscoveredServer]: - """Scan Goose config for MCP server extensions. - - Goose uses YAML (``~/.config/goose/config.yaml``) with a different - schema — MCP servers are defined as ``extensions`` with ``type: stdio``. - """ - if sys.platform == "win32": - config_dir = Path( - os.environ.get("APPDATA", Path.home() / "AppData" / "Roaming"), - "Block", - "goose", - "config", - ) - else: - config_dir = Path( - os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), - "goose", - ) - - path = config_dir / "config.yaml" - try: - text = path.read_text() - except OSError: - return [] - - try: - data = yaml.safe_load(text) - except yaml.YAMLError as exc: - logger.warning("Invalid YAML in %s: %s", path, exc) - return [] - - if not isinstance(data, dict): - return [] - - extensions = data.get("extensions", {}) - if not isinstance(extensions, dict): - return [] - - # Convert Goose extensions to mcpServers format - servers: dict[str, Any] = {} - for name, ext in extensions.items(): - if not isinstance(ext, dict): - continue - if not ext.get("enabled", True): - continue - ext_type = ext.get("type", "") - if ext_type == "stdio" and "cmd" in ext: - servers[name] = { - "command": ext["cmd"], - "args": ext.get("args", []), - "env": ext.get("envs", {}), - } - elif ext_type == "sse" and "uri" in ext: - servers[name] = {"url": ext["uri"], "transport": "sse"} - - return _parse_mcp_servers(servers, source="goose", config_path=path) - - -# --------------------------------------------------------------------------- -# Public API -# --------------------------------------------------------------------------- - - -def discover_servers(start_dir: Path | None = None) -> list[DiscoveredServer]: - """Run all scanners and return the combined results. - - Duplicate names across sources are preserved — callers can - use :pyattr:`DiscoveredServer.qualified_name` to disambiguate. - """ - cwd = start_dir or Path.cwd() - results: list[DiscoveredServer] = [] - results.extend(_scan_claude_desktop()) - results.extend(_scan_claude_code(cwd)) - results.extend(_scan_cursor_workspace(cwd)) - results.extend(_scan_gemini(cwd)) - results.extend(_scan_goose()) - results.extend(_scan_project_mcp_json(cwd)) - return results - - -def resolve_name(name: str, start_dir: Path | None = None) -> ClientTransport: - """Resolve a server name (or ``source:name``) to a transport. - - Raises :class:`ValueError` when the name is not found or is ambiguous. - """ - servers = discover_servers(start_dir) - - # Qualified form: "cursor:weather" - if ":" in name: - source, server_name = name.split(":", 1) - matches = [s for s in servers if s.source == source and s.name == server_name] - if not matches: - raise ValueError( - f"No server named '{server_name}' found in source '{source}'." - ) - return matches[0].config.to_transport() - - # Bare name: "weather" - matches = [s for s in servers if s.name == name] - - if not matches: - if servers: - available = ", ".join(sorted({s.name for s in servers})) - raise ValueError(f"No server named '{name}' found. Available: {available}") - locations = [ - "Claude Desktop config", - "~/.claude.json (Claude Code)", - ".cursor/mcp.json (walked up from cwd)", - "~/.gemini/settings.json (Gemini CLI)", - "~/.config/goose/config.yaml (Goose)", - "./mcp.json", - ] - raise ValueError( - f"No server named '{name}' found. Searched: {', '.join(locations)}" - ) - - if len(matches) == 1: - return matches[0].config.to_transport() - - # Ambiguous — list qualified alternatives - alternatives = ", ".join(f"'{m.qualified_name}'" for m in matches) - raise ValueError( - f"Ambiguous server name '{name}' — found in multiple sources. " - f"Use a qualified name: {alternatives}" - ) diff --git a/src/fastmcp/cli/generate.py b/src/fastmcp/cli/generate.py deleted file mode 100644 index b5e652909..000000000 --- a/src/fastmcp/cli/generate.py +++ /dev/null @@ -1,808 +0,0 @@ -"""Generate a standalone CLI script and agent skill from an MCP server.""" - -import keyword -import re -import sys -import textwrap -from pathlib import Path -from typing import Annotated, Any -from urllib.parse import urlparse - -import cyclopts -import mcp.types -import pydantic_core -from mcp import McpError -from rich.console import Console - -from fastmcp.cli.client import _build_client, resolve_server_spec -from fastmcp.client.transports.base import ClientTransport -from fastmcp.client.transports.stdio import StdioTransport -from fastmcp.utilities.logging import get_logger - -logger = get_logger("cli.generate") -console = Console() - -# --------------------------------------------------------------------------- -# JSON Schema type → Python type string -# --------------------------------------------------------------------------- - -_SIMPLE_TYPES = {"string", "integer", "number", "boolean", "null"} - - -def _is_simple_type(schema: dict[str, Any]) -> bool: - """Check if a schema represents a simple (non-complex) type.""" - schema_type = schema.get("type") - if isinstance(schema_type, list): - # Union of types - simple only if all are simple - return all(t in _SIMPLE_TYPES for t in schema_type) - return schema_type in _SIMPLE_TYPES - - -def _is_simple_array(schema: dict[str, Any]) -> tuple[bool, str | None]: - """Check if schema is an array of simple types. - - Returns (is_simple_array, item_type_str). - """ - if schema.get("type") != "array": - return False, None - - items = schema.get("items", {}) - if not _is_simple_type(items): - return False, None - - # Map JSON Schema type to Python type - item_type = items.get("type", "string") - if isinstance(item_type, list): - return False, None - type_map = { - "string": "str", - "integer": "int", - "number": "float", - "boolean": "bool", - } - py_type = type_map.get(item_type) - if py_type is None: - return False, None - return True, py_type - - -def _schema_to_python_type(schema: dict[str, Any]) -> tuple[str, bool]: - """Convert a JSON Schema to a Python type annotation. - - Returns (type_annotation, needs_json_parsing). - """ - # Check for simple array first - is_simple_arr, item_type = _is_simple_array(schema) - if is_simple_arr: - return f"list[{item_type}]", False - - # Check for simple type - if _is_simple_type(schema): - schema_type = schema.get("type", "string") - if isinstance(schema_type, list): - # Union of simple types - type_map = { - "string": "str", - "integer": "int", - "number": "float", - "boolean": "bool", - "null": "None", - } - parts = [type_map.get(t, "str") for t in schema_type] - return " | ".join(parts), False - - type_map = { - "string": "str", - "integer": "int", - "number": "float", - "boolean": "bool", - "null": "None", - } - return type_map.get(schema_type, "str"), False - - # Complex type - needs JSON parsing - return "str", True - - -def _format_schema_for_help(schema: dict[str, Any]) -> str: - """Format a JSON schema for display in help text.""" - # Pretty print the schema, indented for help text - schema_str = pydantic_core.to_json(schema, indent=2).decode() - # Indent each line for help text alignment - lines = schema_str.split("\n") - indented = "\n ".join(lines) - return f"JSON Schema: {indented}" - - -# --------------------------------------------------------------------------- -# Transport serialization -# --------------------------------------------------------------------------- - - -def serialize_transport( - resolved: str | dict[str, Any] | ClientTransport, -) -> tuple[str, set[str]]: - """Serialize a resolved transport to a Python expression string. - - Returns ``(expression, extra_imports)`` where *extra_imports* is a set of - import lines needed by the expression. - """ - if isinstance(resolved, str): - return repr(resolved), set() - - if isinstance(resolved, StdioTransport): - parts = [f"command={resolved.command!r}", f"args={resolved.args!r}"] - if resolved.env: - parts.append(f"env={resolved.env!r}") - if resolved.cwd: - parts.append(f"cwd={resolved.cwd!r}") - expr = f"StdioTransport({', '.join(parts)})" - imports = {"from fastmcp.client.transports import StdioTransport"} - return expr, imports - - if isinstance(resolved, dict): - return repr(resolved), set() - - # Fallback: try repr - return repr(resolved), set() - - -# --------------------------------------------------------------------------- -# Per-tool code generation -# --------------------------------------------------------------------------- - - -def _to_python_identifier(name: str) -> str: - """Sanitize a string into a valid Python identifier.""" - safe = re.sub(r"[^a-zA-Z0-9_]", "_", name) - if safe and safe[0].isdigit(): - safe = f"_{safe}" - safe = safe or "_unnamed" - if keyword.iskeyword(safe): - safe = f"{safe}_" - return safe - - -def _tool_function_source(tool: mcp.types.Tool) -> str: - """Generate the source for a single ``@call_tool_app.command`` function.""" - schema = tool.inputSchema - properties: dict[str, Any] = schema.get("properties", {}) - required = set(schema.get("required", [])) - - # Build parameter lines and track which need JSON parsing - param_lines: list[str] = [] - call_args: list[str] = [] - json_params: list[tuple[str, str]] = [] # (prop_name, safe_name) - seen_names: dict[str, str] = {} # safe_name -> original prop_name - - for prop_name, prop_schema in properties.items(): - py_type, needs_json = _schema_to_python_type(prop_schema) - help_text = prop_schema.get("description", "") - is_required = prop_name in required - safe_name = _to_python_identifier(prop_name) - - # Check for name collisions after sanitization - if safe_name in seen_names: - raise ValueError( - f"Parameter name collision: '{prop_name}' and '{seen_names[safe_name]}' " - f"both sanitize to '{safe_name}'" - ) - seen_names[safe_name] = prop_name - - # For complex types, add schema to help text - if needs_json: - schema_help = _format_schema_for_help(prop_schema) - help_text = f"{help_text}\\n{schema_help}" if help_text else schema_help - json_params.append((prop_name, safe_name)) - - # Escape special characters in help text - help_escaped = ( - help_text.replace("\\", "\\\\").replace('"', '\\"').replace("\n", "\\n") - ) - - # Build parameter annotation - if is_required: - annotation = ( - f'Annotated[{py_type}, cyclopts.Parameter(help="{help_escaped}")]' - ) - param_lines.append(f" {safe_name}: {annotation},") - else: - default = prop_schema.get("default") - if default is not None: - # For complex types with defaults, serialize to JSON string - if needs_json: - default_str = pydantic_core.to_json(default, fallback=str).decode() - annotation = f'Annotated[{py_type}, cyclopts.Parameter(help="{help_escaped}")]' - param_lines.append( - f" {safe_name}: {annotation} = {default_str!r}," - ) - else: - annotation = f'Annotated[{py_type}, cyclopts.Parameter(help="{help_escaped}")]' - param_lines.append(f" {safe_name}: {annotation} = {default!r},") - else: - # For list types, default to empty list; others default to None - if py_type.startswith("list["): - annotation = f'Annotated[{py_type}, cyclopts.Parameter(help="{help_escaped}")]' - param_lines.append(f" {safe_name}: {annotation} = [],") - else: - annotation = f'Annotated[{py_type} | None, cyclopts.Parameter(help="{help_escaped}")]' - param_lines.append(f" {safe_name}: {annotation} = None,") - - call_args.append(f"{prop_name!r}: {safe_name}") - - # Function name: sanitize to valid Python identifier - fn_name = _to_python_identifier(tool.name) - - # Docstring - use single-quoted docstrings to avoid triple-quote escaping issues - description = (tool.description or "").replace("\\", "\\\\").replace("'", "\\'") - - lines = [] - lines.append("") - # Always pass name= to preserve the original tool name (cyclopts - # would otherwise convert underscores to hyphens). - lines.append(f"@call_tool_app.command(name={tool.name!r})") - lines.append(f"async def {fn_name}(") - - if param_lines: - lines.append(" *,") - lines.extend(param_lines) - - lines.append(") -> None:") - lines.append(f" '''{description}'''") - - # Add JSON parsing for complex parameters - if json_params: - lines.append(" # Parse JSON parameters") - for _prop_name, safe_name in json_params: - lines.append( - f" {safe_name}_parsed = json.loads({safe_name}) if isinstance({safe_name}, str) else {safe_name}" - ) - lines.append("") - - # Build call arguments, using parsed versions for JSON params - call_arg_parts = [] - for prop_name, _ in properties.items(): - safe_name = _to_python_identifier(prop_name) - if any(pn == prop_name for pn, _ in json_params): - call_arg_parts.append(f"{prop_name!r}: {safe_name}_parsed") - else: - call_arg_parts.append(f"{prop_name!r}: {safe_name}") - - dict_items = ", ".join(call_arg_parts) - lines.append(f" await _call_tool({tool.name!r}, {{{dict_items}}})") - lines.append("") - - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Full script generation -# --------------------------------------------------------------------------- - - -def generate_cli_script( - server_name: str, - server_spec: str, - transport_code: str, - extra_imports: set[str], - tools: list[mcp.types.Tool], -) -> str: - """Generate the full CLI script source code.""" - - # Determine app name from server_name - sanitize for use in string literal - app_name = ( - server_name.replace(" ", "-").lower().replace("\\", "\\\\").replace('"', '\\"') - ) - - # --- Header --- - lines: list[str] = [] - lines.append("#!/usr/bin/env python3") - lines.append(f'"""CLI for {server_name} MCP server.') - lines.append("") - lines.append(f"Generated by: fastmcp generate-cli {server_spec}") - lines.append('"""') - lines.append("") - - # --- Imports --- - lines.append("import json") - lines.append("import sys") - lines.append("from typing import Annotated") - lines.append("") - lines.append("import cyclopts") - lines.append("import mcp.types") - lines.append("from rich.console import Console") - lines.append("") - lines.append("from fastmcp import Client") - for imp in sorted(extra_imports): - lines.append(imp) - lines.append("") - - # --- Transport config --- - lines.append("# Modify this to change how the CLI connects to the MCP server.") - lines.append(f"CLIENT_SPEC = {transport_code}") - lines.append("") - - # --- App setup --- - server_name_escaped = server_name.replace("\\", "\\\\").replace('"', '\\"') - lines.append( - f'app = cyclopts.App(name="{app_name}", help="CLI for {server_name_escaped} MCP server")' - ) - lines.append( - 'call_tool_app = cyclopts.App(name="call-tool", help="Call a tool on the server")' - ) - lines.append("app.command(call_tool_app)") - lines.append("") - lines.append("console = Console()") - lines.append("") - lines.append("") - - # --- Shared helpers --- - lines.append( - textwrap.dedent("""\ - # --------------------------------------------------------------------------- - # Helpers - # --------------------------------------------------------------------------- - - - def _print_tool_result(result): - if result.is_error: - for block in result.content: - if isinstance(block, mcp.types.TextContent): - console.print(f"[bold red]Error:[/bold red] {block.text}") - else: - console.print(f"[bold red]Error:[/bold red] {block}") - sys.exit(1) - - if result.structured_content is not None: - console.print_json(json.dumps(result.structured_content)) - return - - for block in result.content: - if isinstance(block, mcp.types.TextContent): - console.print(block.text) - elif isinstance(block, mcp.types.ImageContent): - size = len(block.data) * 3 // 4 - console.print(f"[dim][Image: {block.mimeType}, ~{size} bytes][/dim]") - elif isinstance(block, mcp.types.AudioContent): - size = len(block.data) * 3 // 4 - console.print(f"[dim][Audio: {block.mimeType}, ~{size} bytes][/dim]") - - - async def _call_tool(tool_name: str, arguments: dict) -> None: - # Filter out None values and empty lists (defaults for optional array params) - filtered = { - k: v - for k, v in arguments.items() - if v is not None and (not isinstance(v, list) or len(v) > 0) - } - async with Client(CLIENT_SPEC) as client: - result = await client.call_tool(tool_name, filtered, raise_on_error=False) - _print_tool_result(result) - if result.is_error: - sys.exit(1)""") - ) - lines.append("") - lines.append("") - - # --- Generic commands --- - lines.append( - textwrap.dedent("""\ - # --------------------------------------------------------------------------- - # List / read commands - # --------------------------------------------------------------------------- - - - @app.command - async def list_tools() -> None: - \"\"\"List available tools.\"\"\" - async with Client(CLIENT_SPEC) as client: - tools = await client.list_tools() - if not tools: - console.print("[dim]No tools found.[/dim]") - return - for tool in tools: - sig_parts = [] - props = tool.inputSchema.get("properties", {}) - required = set(tool.inputSchema.get("required", [])) - for pname, pschema in props.items(): - ptype = pschema.get("type", "string") - if pname in required: - sig_parts.append(f"{pname}: {ptype}") - else: - sig_parts.append(f"{pname}: {ptype} = ...") - sig = f"{tool.name}({', '.join(sig_parts)})" - console.print(f" [cyan]{sig}[/cyan]") - if tool.description: - console.print(f" {tool.description}") - console.print() - - - @app.command - async def list_resources() -> None: - \"\"\"List available resources.\"\"\" - async with Client(CLIENT_SPEC) as client: - resources = await client.list_resources() - if not resources: - console.print("[dim]No resources found.[/dim]") - return - for r in resources: - console.print(f" [cyan]{r.uri}[/cyan]") - desc_parts = [r.name or "", r.description or ""] - desc = " — ".join(p for p in desc_parts if p) - if desc: - console.print(f" {desc}") - console.print() - - - @app.command - async def read_resource(uri: Annotated[str, cyclopts.Parameter(help="Resource URI")]) -> None: - \"\"\"Read a resource by URI.\"\"\" - async with Client(CLIENT_SPEC) as client: - contents = await client.read_resource(uri) - for block in contents: - if isinstance(block, mcp.types.TextResourceContents): - console.print(block.text) - elif isinstance(block, mcp.types.BlobResourceContents): - size = len(block.blob) * 3 // 4 - console.print(f"[dim][Blob: {block.mimeType}, ~{size} bytes][/dim]") - - - @app.command - async def list_prompts() -> None: - \"\"\"List available prompts.\"\"\" - async with Client(CLIENT_SPEC) as client: - prompts = await client.list_prompts() - if not prompts: - console.print("[dim]No prompts found.[/dim]") - return - for p in prompts: - args_str = "" - if p.arguments: - parts = [a.name for a in p.arguments] - args_str = f"({', '.join(parts)})" - console.print(f" [cyan]{p.name}{args_str}[/cyan]") - if p.description: - console.print(f" {p.description}") - console.print() - - - @app.command - async def get_prompt( - name: Annotated[str, cyclopts.Parameter(help="Prompt name")], - *arguments: str, - ) -> None: - \"\"\"Get a prompt by name. Pass arguments as key=value pairs.\"\"\" - parsed: dict[str, str] = {} - for arg in arguments: - if "=" not in arg: - console.print(f"[bold red]Error:[/bold red] Invalid argument {arg!r} — expected key=value") - sys.exit(1) - key, value = arg.split("=", 1) - parsed[key] = value - - async with Client(CLIENT_SPEC) as client: - result = await client.get_prompt(name, parsed or None) - for msg in result.messages: - console.print(f"[bold]{msg.role}:[/bold]") - if isinstance(msg.content, mcp.types.TextContent): - console.print(f" {msg.content.text}") - elif isinstance(msg.content, mcp.types.ImageContent): - size = len(msg.content.data) * 3 // 4 - console.print(f" [dim][Image: {msg.content.mimeType}, ~{size} bytes][/dim]") - else: - console.print(f" {msg.content}") - console.print()""") - ) - lines.append("") - lines.append("") - - # --- Generated tool commands --- - if tools: - lines.append( - "# ---------------------------------------------------------------------------" - ) - lines.append("# Tool commands (generated from server schema)") - lines.append( - "# ---------------------------------------------------------------------------" - ) - - for tool in tools: - lines.append(_tool_function_source(tool)) - - # --- Entry point --- - lines.append("") - lines.append('if __name__ == "__main__":') - lines.append(" app()") - lines.append("") - - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# Skill (SKILL.md) generation -# --------------------------------------------------------------------------- - -_JSON_SCHEMA_TYPE_LABELS: dict[str, str] = { - "string": "string", - "integer": "integer", - "number": "number", - "boolean": "boolean", - "null": "null", - "array": "array", - "object": "object", -} - - -def _param_to_cli_flag(prop_name: str) -> str: - """Convert a JSON Schema property name to its CLI flag form. - - Replicates cyclopts' default_name_transform: camelCase → snake_case, - lowercase, underscores → hyphens, strip leading/trailing hyphens. - """ - safe = _to_python_identifier(prop_name) - # camelCase / PascalCase → snake_case - safe = re.sub(r"(.)([A-Z][a-z]+)", r"\1_\2", safe) - safe = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", safe) - safe = safe.lower().replace("_", "-").strip("-") - return f"--{safe}" if safe else "--arg" - - -def _schema_type_label(prop_schema: dict[str, Any]) -> str: - """Return a human-readable type label for a property schema.""" - schema_type = prop_schema.get("type", "string") - if isinstance(schema_type, list): - labels = [_JSON_SCHEMA_TYPE_LABELS.get(t, t) for t in schema_type] - return " | ".join(labels) - - label = _JSON_SCHEMA_TYPE_LABELS.get(schema_type, schema_type) - - # For arrays, include item type if simple - if schema_type == "array": - items = prop_schema.get("items", {}) - item_type = items.get("type", "") - if isinstance(item_type, str) and item_type in _JSON_SCHEMA_TYPE_LABELS: - return f"array[{item_type}]" - - return label - - -def _tool_skill_section(tool: mcp.types.Tool, cli_filename: str) -> str: - """Generate a SKILL.md section for a single tool.""" - schema = tool.inputSchema - properties: dict[str, Any] = schema.get("properties", {}) - required = set(schema.get("required", [])) - - # Build example invocation flags - flag_parts_list: list[str] = [] - for p, p_schema in properties.items(): - flag = _param_to_cli_flag(p) - schema_type = p_schema.get("type") - is_bool = schema_type == "boolean" or ( - isinstance(schema_type, list) and "boolean" in schema_type - ) - if is_bool: - flag_parts_list.append(flag) - else: - flag_parts_list.append(f"{flag} ") - flag_parts = " ".join(flag_parts_list) - invocation = f"uv run --with fastmcp python {cli_filename} call-tool {tool.name}" - if flag_parts: - invocation += f" {flag_parts}" - - # Build parameter table rows - rows: list[str] = [] - for prop_name, prop_schema in properties.items(): - flag = f"`{_param_to_cli_flag(prop_name)}`" - type_label = _schema_type_label(prop_schema).replace("|", "\\|") - is_required = "yes" if prop_name in required else "no" - description = prop_schema.get("description", "") - _, needs_json = _schema_to_python_type(prop_schema) - if needs_json: - description = ( - f"{description} (JSON string)" if description else "JSON string" - ) - description = description.replace("\n", " ").replace("|", "\\|") - rows.append(f"| {flag} | {type_label} | {is_required} | {description} |") - - param_table = "" - if rows: - header = "| Flag | Type | Required | Description |\n|------|------|----------|-------------|" - param_table = f"\n{header}\n" + "\n".join(rows) + "\n" - - lines: list[str] = [f"### {tool.name}"] - if tool.description: - lines.extend(["", tool.description]) - lines.extend(["", "```bash", invocation, "```"]) - if param_table: - lines.extend(["", param_table.strip("\n")]) - return "\n".join(lines) - - -def generate_skill_content( - server_name: str, - cli_filename: str, - tools: list[mcp.types.Tool], -) -> str: - """Generate a SKILL.md file for a generated CLI script.""" - skill_name = ( - server_name.replace(" ", "-").lower().replace("\\", "").replace('"', "") - ) - safe_name = server_name.replace("\\", "").replace('"', "") - description = f"CLI for the {safe_name} MCP server. Call tools, list resources, and get prompts." - - lines = [ - "---", - f'name: "{skill_name}-cli"', - f'description: "{description}"', - "---", - "", - f"# {server_name} CLI", - "", - ] - - if tools: - tool_bodies = "\n\n".join( - _tool_skill_section(tool, cli_filename) for tool in tools - ) - lines.extend(["## Tool Commands", "", tool_bodies, ""]) - - lines.extend( - [ - "## Utility Commands", - "", - "```bash", - f"uv run --with fastmcp python {cli_filename} list-tools", - f"uv run --with fastmcp python {cli_filename} list-resources", - f"uv run --with fastmcp python {cli_filename} read-resource ", - f"uv run --with fastmcp python {cli_filename} list-prompts", - f"uv run --with fastmcp python {cli_filename} get-prompt [key=value ...]", - "```", - "", - ] - ) - - return "\n".join(lines) - - -# --------------------------------------------------------------------------- -# CLI command -# --------------------------------------------------------------------------- - - -async def generate_cli_command( - server_spec: Annotated[ - str, - cyclopts.Parameter( - help="Server URL, Python file, MCPConfig JSON, discovered name, or .js file", - ), - ], - output: Annotated[ - str, - cyclopts.Parameter( - help="Output file path (default: cli.py)", - ), - ] = "cli.py", - *, - force: Annotated[ - bool, - cyclopts.Parameter( - name=["-f", "--force"], - help="Overwrite output file if it exists", - ), - ] = False, - timeout: Annotated[ - float | None, - cyclopts.Parameter("--timeout", help="Connection timeout in seconds"), - ] = None, - auth: Annotated[ - str | None, - cyclopts.Parameter( - "--auth", - help="Auth method: 'oauth', a bearer token string, or 'none' to disable", - ), - ] = None, - no_skill: Annotated[ - bool, - cyclopts.Parameter( - "--no-skill", - help="Skip generating a SKILL.md agent skill alongside the CLI", - ), - ] = False, -) -> None: - """Generate a standalone CLI script from an MCP server. - - Connects to the server, reads its tools/resources/prompts, and writes - a Python script that can invoke them directly. Also generates a SKILL.md - agent skill file unless --no-skill is passed. - - Examples: - fastmcp generate-cli weather - fastmcp generate-cli weather my_cli.py - fastmcp generate-cli http://localhost:8000/mcp - fastmcp generate-cli server.py output.py -f - fastmcp generate-cli weather --no-skill - """ - output_path = Path(output) - skill_path = output_path.parent / "SKILL.md" - - # Check both files up front before doing any work - existing: list[Path] = [] - if output_path.exists() and not force: - existing.append(output_path) - if not no_skill and skill_path.exists() and not force: - existing.append(skill_path) - if existing: - names = ", ".join(f"[cyan]{p}[/cyan]" for p in existing) - console.print( - f"[bold red]Error:[/bold red] {names} already exist(s). " - f"Use [cyan]-f[/cyan] to overwrite." - ) - sys.exit(1) - - # Resolve the server spec to a transport - resolved = resolve_server_spec(server_spec) - transport_code, extra_imports = serialize_transport(resolved) - - # Derive a human-friendly server name from the spec - server_name = _derive_server_name(server_spec) - - # Connect and discover capabilities - client = _build_client(resolved, timeout=timeout, auth=auth) - - try: - async with client: - tools = await client.list_tools() - console.print( - f"[dim]Discovered {len(tools)} tool(s) from {server_spec}[/dim]" - ) - - except (RuntimeError, TimeoutError, McpError, OSError) as exc: - console.print(f"[bold red]Error:[/bold red] Could not connect: {exc}") - sys.exit(1) - - # Generate and write the script - script = generate_cli_script( - server_name=server_name, - server_spec=server_spec, - transport_code=transport_code, - extra_imports=extra_imports, - tools=tools, - ) - - output_path.write_text(script) - output_path.chmod(output_path.stat().st_mode | 0o111) # make executable - - console.print( - f"[green]✓[/green] Wrote [cyan]{output_path}[/cyan] " - f"with {len(tools)} tool command(s)" - ) - - if not no_skill: - skill_content = generate_skill_content( - server_name=server_name, - cli_filename=output_path.name, - tools=tools, - ) - skill_path.write_text(skill_content) - console.print(f"[green]✓[/green] Wrote [cyan]{skill_path}[/cyan]") - - console.print(f"[dim]Run: python {output_path} --help[/dim]") - - -def _derive_server_name(server_spec: str) -> str: - """Derive a human-friendly name from a server spec.""" - # URL — use hostname - if server_spec.startswith(("http://", "https://")): - parsed = urlparse(server_spec) - return parsed.hostname or "server" - - # File path — use stem - if server_spec.endswith((".py", ".js", ".json")): - return Path(server_spec).stem - - # Bare name or qualified name - if ":" in server_spec: - name = server_spec.split(":", 1)[1] - return name or server_spec.split(":", 1)[0] - - return server_spec diff --git a/src/fastmcp/cli/install/__init__.py b/src/fastmcp/cli/install/__init__.py deleted file mode 100644 index 35b8d832d..000000000 --- a/src/fastmcp/cli/install/__init__.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Install subcommands for FastMCP CLI using Cyclopts.""" - -import cyclopts - -from .claude_code import claude_code_command -from .claude_desktop import claude_desktop_command -from .cursor import cursor_command -from .gemini_cli import gemini_cli_command -from .goose import goose_command -from .mcp_json import mcp_json_command -from .stdio import stdio_command - -# Create a cyclopts app for install subcommands -install_app = cyclopts.App( - name="install", - help="Install MCP servers in various clients and formats.", -) - -# Register each command from its respective module -install_app.command(claude_code_command, name="claude-code") -install_app.command(claude_desktop_command, name="claude-desktop") -install_app.command(cursor_command, name="cursor") -install_app.command(gemini_cli_command, name="gemini-cli") -install_app.command(goose_command, name="goose") -install_app.command(mcp_json_command, name="mcp-json") -install_app.command(stdio_command, name="stdio") diff --git a/src/fastmcp/cli/install/claude_code.py b/src/fastmcp/cli/install/claude_code.py deleted file mode 100644 index 5aa377568..000000000 --- a/src/fastmcp/cli/install/claude_code.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Claude Code integration for FastMCP install using Cyclopts.""" - -import shutil -import subprocess -import sys -from pathlib import Path -from typing import Annotated - -import cyclopts -from rich import print - -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment - -from .shared import process_common_args, validate_server_name - -logger = get_logger(__name__) - - -def find_claude_command() -> str | None: - """Find the Claude Code CLI command. - - Checks common installation locations since 'claude' is often a shell alias - that doesn't work with subprocess calls. - """ - # First try shutil.which() in case it's a real executable in PATH - claude_in_path = shutil.which("claude") - if claude_in_path: - try: - result = subprocess.run( - [claude_in_path, "--version"], - check=True, - capture_output=True, - text=True, - ) - if "Claude Code" in result.stdout: - return claude_in_path - except (subprocess.CalledProcessError, FileNotFoundError): - pass - - # Check common installation locations (aliases don't work with subprocess) - potential_paths = [ - # Default Claude Code installation location (after migration) - Path.home() / ".claude" / "local" / "claude", - # npm global installation on macOS/Linux (default) - Path("/usr/local/bin/claude"), - # npm global installation with custom prefix - Path.home() / ".npm-global" / "bin" / "claude", - ] - - for path in potential_paths: - if path.exists(): - try: - result = subprocess.run( - [str(path), "--version"], - check=True, - capture_output=True, - text=True, - ) - if "Claude Code" in result.stdout: - return str(path) - except (subprocess.CalledProcessError, FileNotFoundError): - continue - - return None - - -def check_claude_code_available() -> bool: - """Check if Claude Code CLI is available.""" - return find_claude_command() is not None - - -def install_claude_code( - file: Path, - server_object: str | None, - name: str, - *, - with_editable: list[Path] | None = None, - with_packages: list[str] | None = None, - env_vars: dict[str, str] | None = None, - python_version: str | None = None, - with_requirements: Path | None = None, - project: Path | None = None, -) -> bool: - """Install FastMCP server in Claude Code. - - Args: - file: Path to the server file - server_object: Optional server object name (for :object suffix) - name: Name for the server in Claude Code - with_editable: Optional list of directories to install in editable mode - with_packages: Optional list of additional packages to install - env_vars: Optional dictionary of environment variables - python_version: Optional Python version to use - with_requirements: Optional requirements file to install from - project: Optional project directory to run within - - Returns: - True if installation was successful, False otherwise - """ - # Check if Claude Code CLI is available - claude_cmd = find_claude_command() - if not claude_cmd: - print( - "[red]Claude Code CLI not found.[/red]\n" - "[blue]Please ensure Claude Code is installed. Try running 'claude --version' to verify.[/blue]" - ) - return False - - env_config = UVEnvironment( - python=python_version, - dependencies=(with_packages or []) + ["fastmcp"], - requirements=with_requirements, - project=project, - editable=with_editable, - ) - - # Build server spec from parsed components - if server_object: - server_spec = f"{file.resolve()}:{server_object}" - else: - server_spec = str(file.resolve()) - - # Build the full command - full_command = env_config.build_command(["fastmcp", "run", server_spec]) - - validate_server_name(name) - - # Build claude mcp add command - cmd_parts = [claude_cmd, "mcp", "add", name] - - # Add environment variables if specified - if env_vars: - for key, value in env_vars.items(): - cmd_parts.extend(["-e", f"{key}={value}"]) - - # Add server name and command - cmd_parts.append("--") - cmd_parts.extend(full_command) - - try: - # Run the claude mcp add command - subprocess.run(cmd_parts, check=True, capture_output=True, text=True) - return True - except subprocess.CalledProcessError as e: - print( - f"[red]Failed to install '[bold]{name}[/bold]' in Claude Code: {e.stderr.strip() if e.stderr else str(e)}[/red]" - ) - return False - except Exception as e: - print(f"[red]Failed to install '[bold]{name}[/bold]' in Claude Code: {e}[/red]") - return False - - -async def claude_code_command( - server_spec: str, - *, - server_name: Annotated[ - str | None, - cyclopts.Parameter( - name=["--name", "-n"], - help="Custom name for the server in Claude Code", - ), - ] = None, - with_editable: Annotated[ - list[Path] | None, - cyclopts.Parameter( - "--with-editable", - help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", - ), - ] = None, - with_packages: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--with", help="Additional packages to install (can be used multiple times)" - ), - ] = None, - env_vars: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--env", - help="Environment variables in KEY=VALUE format (can be used multiple times)", - ), - ] = None, - env_file: Annotated[ - Path | None, - cyclopts.Parameter( - "--env-file", - help="Load environment variables from .env file", - ), - ] = None, - python: Annotated[ - str | None, - cyclopts.Parameter( - "--python", - help="Python version to use (e.g., 3.10, 3.11)", - ), - ] = None, - with_requirements: Annotated[ - Path | None, - cyclopts.Parameter( - "--with-requirements", - help="Requirements file to install dependencies from", - ), - ] = None, - project: Annotated[ - Path | None, - cyclopts.Parameter( - "--project", - help="Run the command within the given project directory", - ), - ] = None, -) -> None: - """Install an MCP server in Claude Code. - - Args: - server_spec: Python file to install, optionally with :object suffix - """ - # Convert None to empty lists for list parameters - with_editable = with_editable or [] - with_packages = with_packages or [] - env_vars = env_vars or [] - file, server_object, name, packages, env_dict = await process_common_args( - server_spec, server_name, with_packages, env_vars, env_file - ) - - success = install_claude_code( - file=file, - server_object=server_object, - name=name, - with_editable=with_editable, - with_packages=packages, - env_vars=env_dict, - python_version=python, - with_requirements=with_requirements, - project=project, - ) - - if success: - print(f"[green]Successfully installed '{name}' in Claude Code[/green]") - else: - sys.exit(1) diff --git a/src/fastmcp/cli/install/claude_desktop.py b/src/fastmcp/cli/install/claude_desktop.py deleted file mode 100644 index 780b97c36..000000000 --- a/src/fastmcp/cli/install/claude_desktop.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Claude Desktop integration for FastMCP install using Cyclopts.""" - -import os -import sys -from pathlib import Path -from typing import Annotated - -import cyclopts -from rich import print - -from fastmcp.mcp_config import StdioMCPServer, update_config_file -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment - -from .shared import process_common_args - -logger = get_logger(__name__) - - -def get_claude_config_path(config_path: Path | None = None) -> Path | None: - """Get the Claude config directory based on platform. - - Args: - config_path: Optional custom path to the Claude Desktop config directory - """ - - if config_path: - if not config_path.exists(): - print(f"[red]The specified config path does not exist: {config_path}[/red]") - return None - return config_path - - if sys.platform == "win32": - path = Path(Path.home(), "AppData", "Roaming", "Claude") - elif sys.platform == "darwin": - path = Path(Path.home(), "Library", "Application Support", "Claude") - elif sys.platform.startswith("linux"): - path = Path( - os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"), "Claude" - ) - else: - return None - - if path.exists(): - return path - return None - - -def install_claude_desktop( - file: Path, - server_object: str | None, - name: str, - *, - with_editable: list[Path] | None = None, - with_packages: list[str] | None = None, - env_vars: dict[str, str] | None = None, - python_version: str | None = None, - with_requirements: Path | None = None, - project: Path | None = None, - config_path: Path | None = None, -) -> bool: - """Install FastMCP server in Claude Desktop. - - Args: - file: Path to the server file - server_object: Optional server object name (for :object suffix) - name: Name for the server in Claude's config - with_editable: Optional list of directories to install in editable mode - with_packages: Optional list of additional packages to install - env_vars: Optional dictionary of environment variables - python_version: Optional Python version to use - with_requirements: Optional requirements file to install from - project: Optional project directory to run within - config_path: Optional custom path to Claude Desktop config directory - - Returns: - True if installation was successful, False otherwise - """ - config_dir = get_claude_config_path(config_path=config_path) - if not config_dir: - if not config_path: - print( - "[red]Claude Desktop config directory not found.[/red]\n" - "[blue]Please ensure Claude Desktop is installed and has been run at least once to initialize its config.[/blue]" - ) - return False - - config_file = config_dir / "claude_desktop_config.json" - - env_config = UVEnvironment( - python=python_version, - dependencies=(with_packages or []) + ["fastmcp"], - requirements=with_requirements, - project=project, - editable=with_editable, - ) - # Build server spec from parsed components - if server_object: - server_spec = f"{file.resolve()}:{server_object}" - else: - server_spec = str(file.resolve()) - - # Build the full command - full_command = env_config.build_command(["fastmcp", "run", server_spec]) - - # Create server configuration - server_config = StdioMCPServer( - command=full_command[0], - args=full_command[1:], - env=env_vars or {}, - ) - - try: - # Handle environment variable merging manually since we need to preserve existing config - if config_file.exists(): - import json - - content = config_file.read_text().strip() - if content: - config = json.loads(content) - if "mcpServers" in config and name in config["mcpServers"]: - existing_env = config["mcpServers"][name].get("env", {}) - if env_vars: - # New vars take precedence over existing ones - merged_env = {**existing_env, **env_vars} - else: - merged_env = existing_env - server_config.env = merged_env - - # Update configuration with correct function signature - update_config_file(config_file, name, server_config) - print(f"[green]Successfully installed '{name}' in Claude Desktop[/green]") - return True - except Exception as e: - print(f"[red]Failed to install server: {e}[/red]") - return False - - -async def claude_desktop_command( - server_spec: str, - *, - server_name: Annotated[ - str | None, - cyclopts.Parameter( - name=["--name", "-n"], - help="Custom name for the server in Claude Desktop's config", - ), - ] = None, - with_editable: Annotated[ - list[Path] | None, - cyclopts.Parameter( - "--with-editable", - help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", - ), - ] = None, - with_packages: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--with", help="Additional packages to install (can be used multiple times)" - ), - ] = None, - env_vars: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--env", - help="Environment variables in KEY=VALUE format (can be used multiple times)", - ), - ] = None, - env_file: Annotated[ - Path | None, - cyclopts.Parameter( - "--env-file", - help="Load environment variables from .env file", - ), - ] = None, - python: Annotated[ - str | None, - cyclopts.Parameter( - "--python", - help="Python version to use (e.g., 3.10, 3.11)", - ), - ] = None, - with_requirements: Annotated[ - Path | None, - cyclopts.Parameter( - "--with-requirements", - help="Requirements file to install dependencies from", - ), - ] = None, - project: Annotated[ - Path | None, - cyclopts.Parameter( - "--project", - help="Run the command within the given project directory", - ), - ] = None, - config_path: Annotated[ - Path | None, - cyclopts.Parameter( - "--config-path", - help="Custom path to Claude Desktop config directory", - ), - ] = None, -) -> None: - """Install an MCP server in Claude Desktop. - - Args: - server_spec: Python file to install, optionally with :object suffix - """ - # Convert None to empty lists for list parameters - with_editable = with_editable or [] - with_packages = with_packages or [] - env_vars = env_vars or [] - file, server_object, name, with_packages, env_dict = await process_common_args( - server_spec, server_name, with_packages, env_vars, env_file - ) - - success = install_claude_desktop( - file=file, - server_object=server_object, - name=name, - with_editable=with_editable, - with_packages=with_packages, - env_vars=env_dict, - python_version=python, - with_requirements=with_requirements, - project=project, - config_path=config_path, - ) - - if not success: - sys.exit(1) diff --git a/src/fastmcp/cli/install/cursor.py b/src/fastmcp/cli/install/cursor.py deleted file mode 100644 index 560d17e5d..000000000 --- a/src/fastmcp/cli/install/cursor.py +++ /dev/null @@ -1,321 +0,0 @@ -"""Cursor integration for FastMCP install using Cyclopts.""" - -import base64 -import sys -from pathlib import Path -from typing import Annotated -from urllib.parse import quote - -import cyclopts -from rich import print - -from fastmcp.mcp_config import StdioMCPServer, update_config_file -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment - -from .shared import open_deeplink as _shared_open_deeplink -from .shared import process_common_args - -logger = get_logger(__name__) - - -def generate_cursor_deeplink( - server_name: str, - server_config: StdioMCPServer, -) -> str: - """Generate a Cursor deeplink for installing the MCP server. - - Args: - server_name: Name of the server - server_config: Server configuration - - Returns: - Deeplink URL that can be clicked to install the server - """ - # Create the configuration structure expected by Cursor - # Base64 encode the configuration (URL-safe for query parameter) - config_json = server_config.model_dump_json(exclude_none=True) - config_b64 = base64.urlsafe_b64encode(config_json.encode()).decode() - - # Generate the deeplink URL with properly encoded server name - encoded_name = quote(server_name, safe="") - deeplink = f"cursor://anysphere.cursor-deeplink/mcp/install?name={encoded_name}&config={config_b64}" - - return deeplink - - -def open_deeplink(deeplink: str) -> bool: - """Attempt to open a Cursor deeplink URL using the system's default handler. - - Args: - deeplink: The deeplink URL to open - - Returns: - True if the command succeeded, False otherwise - """ - return _shared_open_deeplink(deeplink, expected_scheme="cursor") - - -def install_cursor_workspace( - file: Path, - server_object: str | None, - name: str, - workspace_path: Path, - *, - with_editable: list[Path] | None = None, - with_packages: list[str] | None = None, - env_vars: dict[str, str] | None = None, - python_version: str | None = None, - with_requirements: Path | None = None, - project: Path | None = None, -) -> bool: - """Install FastMCP server to workspace-specific Cursor configuration. - - Args: - file: Path to the server file - server_object: Optional server object name (for :object suffix) - name: Name for the server in Cursor - workspace_path: Path to the workspace directory - with_editable: Optional list of directories to install in editable mode - with_packages: Optional list of additional packages to install - env_vars: Optional dictionary of environment variables - python_version: Optional Python version to use - with_requirements: Optional requirements file to install from - project: Optional project directory to run within - - Returns: - True if installation was successful, False otherwise - """ - # Ensure workspace path is absolute and exists - workspace_path = workspace_path.resolve() - if not workspace_path.exists(): - print(f"[red]Workspace directory does not exist: {workspace_path}[/red]") - return False - if not workspace_path.is_dir(): - print(f"[red]Workspace path is not a directory: {workspace_path}[/red]") - return False - - # Create .cursor directory in workspace - cursor_dir = workspace_path / ".cursor" - cursor_dir.mkdir(exist_ok=True) - - config_file = cursor_dir / "mcp.json" - - env_config = UVEnvironment( - python=python_version, - dependencies=(with_packages or []) + ["fastmcp"], - requirements=with_requirements, - project=project, - editable=with_editable, - ) - # Build server spec from parsed components - if server_object: - server_spec = f"{file.resolve()}:{server_object}" - else: - server_spec = str(file.resolve()) - - # Build the full command - full_command = env_config.build_command(["fastmcp", "run", server_spec]) - - # Create server configuration - server_config = StdioMCPServer( - command=full_command[0], - args=full_command[1:], - env=env_vars or {}, - ) - - try: - # Create the config file if it doesn't exist - if not config_file.exists(): - config_file.write_text('{"mcpServers": {}}') - - # Update configuration with the new server - update_config_file(config_file, name, server_config) - print( - f"[green]Successfully installed '{name}' to workspace at {workspace_path}[/green]" - ) - return True - except Exception as e: - print(f"[red]Failed to install server to workspace: {e}[/red]") - return False - - -def install_cursor( - file: Path, - server_object: str | None, - name: str, - *, - with_editable: list[Path] | None = None, - with_packages: list[str] | None = None, - env_vars: dict[str, str] | None = None, - python_version: str | None = None, - with_requirements: Path | None = None, - project: Path | None = None, - workspace: Path | None = None, -) -> bool: - """Install FastMCP server in Cursor. - - Args: - file: Path to the server file - server_object: Optional server object name (for :object suffix) - name: Name for the server in Cursor - with_editable: Optional list of directories to install in editable mode - with_packages: Optional list of additional packages to install - env_vars: Optional dictionary of environment variables - python_version: Optional Python version to use - with_requirements: Optional requirements file to install from - project: Optional project directory to run within - workspace: Optional workspace directory for project-specific installation - - Returns: - True if installation was successful, False otherwise - """ - - env_config = UVEnvironment( - python=python_version, - dependencies=(with_packages or []) + ["fastmcp"], - requirements=with_requirements, - project=project, - editable=with_editable, - ) - # Build server spec from parsed components - if server_object: - server_spec = f"{file.resolve()}:{server_object}" - else: - server_spec = str(file.resolve()) - - # Build the full command - full_command = env_config.build_command(["fastmcp", "run", server_spec]) - - # If workspace is specified, install to workspace-specific config - if workspace: - return install_cursor_workspace( - file=file, - server_object=server_object, - name=name, - workspace_path=workspace, - with_editable=with_editable, - with_packages=with_packages, - env_vars=env_vars, - python_version=python_version, - with_requirements=with_requirements, - project=project, - ) - - # Create server configuration - server_config = StdioMCPServer( - command=full_command[0], - args=full_command[1:], - env=env_vars or {}, - ) - - # Generate deeplink - deeplink = generate_cursor_deeplink(name, server_config) - - print(f"[blue]Opening Cursor to install '{name}'[/blue]") - - if open_deeplink(deeplink): - print("[green]Cursor should now open with the installation dialog[/green]") - return True - else: - print( - "[red]Could not open Cursor automatically.[/red]\n" - f"[blue]Please copy this link and open it in Cursor: {deeplink}[/blue]" - ) - return False - - -async def cursor_command( - server_spec: str, - *, - server_name: Annotated[ - str | None, - cyclopts.Parameter( - name=["--name", "-n"], - help="Custom name for the server in Cursor", - ), - ] = None, - with_editable: Annotated[ - list[Path] | None, - cyclopts.Parameter( - "--with-editable", - help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", - ), - ] = None, - with_packages: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--with", help="Additional packages to install (can be used multiple times)" - ), - ] = None, - env_vars: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--env", - help="Environment variables in KEY=VALUE format (can be used multiple times)", - ), - ] = None, - env_file: Annotated[ - Path | None, - cyclopts.Parameter( - "--env-file", - help="Load environment variables from .env file", - ), - ] = None, - python: Annotated[ - str | None, - cyclopts.Parameter( - "--python", - help="Python version to use (e.g., 3.10, 3.11)", - ), - ] = None, - with_requirements: Annotated[ - Path | None, - cyclopts.Parameter( - "--with-requirements", - help="Requirements file to install dependencies from", - ), - ] = None, - project: Annotated[ - Path | None, - cyclopts.Parameter( - "--project", - help="Run the command within the given project directory", - ), - ] = None, - workspace: Annotated[ - Path | None, - cyclopts.Parameter( - "--workspace", - help="Install to workspace directory (will create .cursor/ inside it) instead of using deeplink", - ), - ] = None, -) -> None: - """Install an MCP server in Cursor. - - Args: - server_spec: Python file to install, optionally with :object suffix - """ - # Convert None to empty lists for list parameters - with_editable = with_editable or [] - with_packages = with_packages or [] - env_vars = env_vars or [] - file, server_object, name, with_packages, env_dict = await process_common_args( - server_spec, server_name, with_packages, env_vars, env_file - ) - - success = install_cursor( - file=file, - server_object=server_object, - name=name, - with_editable=with_editable, - with_packages=with_packages, - env_vars=env_dict, - python_version=python, - with_requirements=with_requirements, - project=project, - workspace=workspace, - ) - - if not success: - sys.exit(1) diff --git a/src/fastmcp/cli/install/gemini_cli.py b/src/fastmcp/cli/install/gemini_cli.py deleted file mode 100644 index 29cc39e15..000000000 --- a/src/fastmcp/cli/install/gemini_cli.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Gemini CLI integration for FastMCP install using Cyclopts.""" - -import shutil -import subprocess -import sys -from pathlib import Path -from typing import Annotated - -import cyclopts -from rich import print - -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment - -from .shared import process_common_args, validate_server_name - -logger = get_logger(__name__) - - -def find_gemini_command() -> str | None: - """Find the Gemini CLI command.""" - # First try shutil.which() in case it's a real executable in PATH - gemini_in_path = shutil.which("gemini") - if gemini_in_path: - try: - # If 'gemini --version' fails, it's not the correct path - subprocess.run( - [gemini_in_path, "--version"], - check=True, - capture_output=True, - ) - return gemini_in_path - except (subprocess.CalledProcessError, FileNotFoundError): - pass - - # Check common installation locations (aliases don't work with subprocess) - potential_paths = [ - # Default Gemini CLI installation location (after migration) - Path.home() / ".gemini" / "local" / "gemini", - # npm global installation on macOS/Linux (default) - Path("/usr/local/bin/gemini"), - # npm global installation with custom prefix - Path.home() / ".npm-global" / "bin" / "gemini", - # Homebrew installation on macOS - Path("/opt/homebrew/bin/gemini"), - ] - - for path in potential_paths: - if path.exists(): - # If 'gemini --version' fails, it's not the correct path - try: - subprocess.run( - [str(path), "--version"], - check=True, - capture_output=True, - ) - return str(path) - except (subprocess.CalledProcessError, FileNotFoundError): - continue - - return None - - -def check_gemini_cli_available() -> bool: - """Check if Gemini CLI is available.""" - return find_gemini_command() is not None - - -def install_gemini_cli( - file: Path, - server_object: str | None, - name: str, - *, - with_editable: list[Path] | None = None, - with_packages: list[str] | None = None, - env_vars: dict[str, str] | None = None, - python_version: str | None = None, - with_requirements: Path | None = None, - project: Path | None = None, -) -> bool: - """Install FastMCP server in Gemini CLI. - - Args: - file: Path to the server file - server_object: Optional server object name (for :object suffix) - name: Name for the server in Gemini CLI - with_editable: Optional list of directories to install in editable mode - with_packages: Optional list of additional packages to install - env_vars: Optional dictionary of environment variables - python_version: Optional Python version to use - with_requirements: Optional requirements file to install from - project: Optional project directory to run within - - Returns: - True if installation was successful, False otherwise - """ - # Check if Gemini CLI is available - gemini_cmd = find_gemini_command() - if not gemini_cmd: - print( - "[red]Gemini CLI not found.[/red]\n" - "[blue]Please ensure Gemini CLI is installed. Try running 'gemini --version' to verify.[/blue]\n" - "[blue]You can install it using 'npm install -g @google/gemini-cli'.[/blue]\n" - ) - return False - - env_config = UVEnvironment( - python=python_version, - dependencies=(with_packages or []) + ["fastmcp"], - requirements=with_requirements, - project=project, - editable=with_editable, - ) - - # Build server spec from parsed components - if server_object: - server_spec = f"{file.resolve()}:{server_object}" - else: - server_spec = str(file.resolve()) - - # Build the full command - full_command = env_config.build_command(["fastmcp", "run", server_spec]) - - # Build gemini mcp add command - cmd_parts = [gemini_cmd, "mcp", "add"] - - # Add environment variables if specified (before the name and command) - if env_vars: - for key, value in env_vars.items(): - cmd_parts.extend(["-e", f"{key}={value}"]) - - validate_server_name(name) - - # Add server name and command - cmd_parts.extend([name, full_command[0], "--"]) - cmd_parts.extend(full_command[1:]) - - try: - # Run the gemini mcp add command - subprocess.run(cmd_parts, check=True, capture_output=True, text=True) - return True - except subprocess.CalledProcessError as e: - print( - f"[red]Failed to install '[bold]{name}[/bold]' in Gemini CLI: {e.stderr.strip() if e.stderr else str(e)}[/red]" - ) - return False - except Exception as e: - print(f"[red]Failed to install '[bold]{name}[/bold]' in Gemini CLI: {e}[/red]") - return False - - -async def gemini_cli_command( - server_spec: str, - *, - server_name: Annotated[ - str | None, - cyclopts.Parameter( - name=["--name", "-n"], - help="Custom name for the server in Gemini CLI", - ), - ] = None, - with_editable: Annotated[ - list[Path] | None, - cyclopts.Parameter( - "--with-editable", - help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", - ), - ] = None, - with_packages: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--with", help="Additional packages to install (can be used multiple times)" - ), - ] = None, - env_vars: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--env", - help="Environment variables in KEY=VALUE format (can be used multiple times)", - ), - ] = None, - env_file: Annotated[ - Path | None, - cyclopts.Parameter( - "--env-file", - help="Load environment variables from .env file", - ), - ] = None, - python: Annotated[ - str | None, - cyclopts.Parameter( - "--python", - help="Python version to use (e.g., 3.10, 3.11)", - ), - ] = None, - with_requirements: Annotated[ - Path | None, - cyclopts.Parameter( - "--with-requirements", - help="Requirements file to install dependencies from", - ), - ] = None, - project: Annotated[ - Path | None, - cyclopts.Parameter( - "--project", - help="Run the command within the given project directory", - ), - ] = None, -) -> None: - """Install an MCP server in Gemini CLI. - - Args: - server_spec: Python file to install, optionally with :object suffix - """ - # Convert None to empty lists for list parameters - with_editable = with_editable or [] - with_packages = with_packages or [] - env_vars = env_vars or [] - file, server_object, name, packages, env_dict = await process_common_args( - server_spec, server_name, with_packages, env_vars, env_file - ) - - success = install_gemini_cli( - file=file, - server_object=server_object, - name=name, - with_editable=with_editable, - with_packages=packages, - env_vars=env_dict, - python_version=python, - with_requirements=with_requirements, - project=project, - ) - - if success: - print(f"[green]Successfully installed '{name}' in Gemini CLI") - else: - sys.exit(1) diff --git a/src/fastmcp/cli/install/goose.py b/src/fastmcp/cli/install/goose.py deleted file mode 100644 index 16161dcf1..000000000 --- a/src/fastmcp/cli/install/goose.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Goose integration for FastMCP install using Cyclopts.""" - -import re -import sys -from pathlib import Path -from typing import Annotated -from urllib.parse import quote - -import cyclopts -from rich import print - -from fastmcp.utilities.logging import get_logger - -from .shared import open_deeplink, process_common_args - -logger = get_logger(__name__) - - -def _slugify(name: str) -> str: - """Convert a display name to a URL-safe identifier. - - Lowercases, replaces non-alphanumeric runs with hyphens, - and strips leading/trailing hyphens. - """ - slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") - return slug or "fastmcp-server" - - -def generate_goose_deeplink( - name: str, - command: str, - args: list[str], - *, - description: str = "MCP server installed via FastMCP", -) -> str: - """Generate a Goose deeplink for installing an MCP extension. - - Args: - name: Human-readable display name for the extension. - command: The executable command (e.g. "uv"). - args: Arguments to the command. - description: Short description shown in Goose. - - Returns: - A goose://extension?... deeplink URL. - """ - extension_id = _slugify(name) - - params: list[str] = [f"cmd={quote(command, safe='')}"] - for arg in args: - params.append(f"arg={quote(arg, safe='')}") - params.append(f"id={quote(extension_id, safe='')}") - params.append(f"name={quote(name, safe='')}") - params.append(f"description={quote(description, safe='')}") - - return f"goose://extension?{'&'.join(params)}" - - -def _build_uvx_command( - server_spec: str, - *, - python_version: str | None = None, - with_packages: list[str] | None = None, -) -> list[str]: - """Build a uvx command for running a FastMCP server. - - Goose requires uvx (not uv run) as the command. The uvx format is: - uvx [--with pkg] [--python X] fastmcp run - - uvx automatically infers that the `fastmcp` command comes from the - `fastmcp` package, so --from is not needed. - """ - args: list[str] = ["uvx"] - - if python_version: - args.extend(["--python", python_version]) - - for pkg in sorted(set(with_packages or [])): - if pkg != "fastmcp": - args.extend(["--with", pkg]) - - args.extend(["fastmcp", "run", server_spec]) - return args - - -def install_goose( - file: Path, - server_object: str | None, - name: str, - *, - with_packages: list[str] | None = None, - python_version: str | None = None, -) -> bool: - """Install FastMCP server in Goose via deeplink. - - Args: - file: Path to the server file. - server_object: Optional server object name (for :object suffix). - name: Name for the extension in Goose. - with_packages: Optional list of additional packages to install. - python_version: Optional Python version to use. - - Returns: - True if installation was successful, False otherwise. - """ - if server_object: - server_spec = f"{file.resolve()}:{server_object}" - else: - server_spec = str(file.resolve()) - - full_command = _build_uvx_command( - server_spec, - python_version=python_version, - with_packages=with_packages, - ) - - deeplink = generate_goose_deeplink( - name=name, - command=full_command[0], - args=full_command[1:], - ) - - print(f"[blue]Opening Goose to install '{name}'[/blue]") - - if open_deeplink(deeplink, expected_scheme="goose"): - print("[green]Goose should now open with the installation dialog[/green]") - return True - else: - print( - "[red]Could not open Goose automatically.[/red]\n" - f"[blue]Please copy this link and open it in Goose: {deeplink}[/blue]" - ) - return False - - -async def goose_command( - server_spec: str, - *, - server_name: Annotated[ - str | None, - cyclopts.Parameter( - name=["--name", "-n"], - help="Custom name for the extension in Goose", - ), - ] = None, - with_packages: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--with", - help="Additional packages to install (can be used multiple times)", - ), - ] = None, - env_vars: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--env", - help="Environment variables in KEY=VALUE format (can be used multiple times)", - ), - ] = None, - env_file: Annotated[ - Path | None, - cyclopts.Parameter( - "--env-file", - help="Load environment variables from .env file", - ), - ] = None, - python: Annotated[ - str | None, - cyclopts.Parameter( - "--python", - help="Python version to use (e.g., 3.10, 3.11)", - ), - ] = None, -) -> None: - """Install an MCP server in Goose. - - Uses uvx to run the server. Environment variables are not included - in the deeplink; use `fastmcp install mcp-json` to generate a full - config for manual installation. - - Args: - server_spec: Python file to install, optionally with :object suffix - """ - with_packages = with_packages or [] - env_vars = env_vars or [] - - if env_vars or env_file: - print( - "[red]Goose deeplinks cannot include environment variables.[/red]\n" - "[yellow]Use `fastmcp install mcp-json` to generate a config, then add it " - "to your Goose config file with env vars: " - "https://block.github.io/goose/docs/getting-started/using-extensions/#config-entry[/yellow]" - ) - sys.exit(1) - - file, server_object, name, with_packages, _env_dict = await process_common_args( - server_spec, server_name, with_packages, env_vars, env_file - ) - - success = install_goose( - file=file, - server_object=server_object, - name=name, - with_packages=with_packages, - python_version=python, - ) - - if not success: - sys.exit(1) diff --git a/src/fastmcp/cli/install/mcp_json.py b/src/fastmcp/cli/install/mcp_json.py deleted file mode 100644 index ed2ac7382..000000000 --- a/src/fastmcp/cli/install/mcp_json.py +++ /dev/null @@ -1,191 +0,0 @@ -"""MCP configuration JSON generation for FastMCP install using Cyclopts.""" - -import json -import sys -from pathlib import Path -from typing import Annotated - -import cyclopts -import pyperclip -from rich import print - -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment - -from .shared import process_common_args - -logger = get_logger(__name__) - - -def install_mcp_json( - file: Path, - server_object: str | None, - name: str, - *, - with_editable: list[Path] | None = None, - with_packages: list[str] | None = None, - env_vars: dict[str, str] | None = None, - copy: bool = False, - python_version: str | None = None, - with_requirements: Path | None = None, - project: Path | None = None, -) -> bool: - """Generate MCP configuration JSON for manual installation. - - Args: - file: Path to the server file - server_object: Optional server object name (for :object suffix) - name: Name for the server in MCP config - with_editable: Optional list of directories to install in editable mode - with_packages: Optional list of additional packages to install - env_vars: Optional dictionary of environment variables - copy: If True, copy to clipboard instead of printing to stdout - python_version: Optional Python version to use - with_requirements: Optional requirements file to install from - project: Optional project directory to run within - - Returns: - True if generation was successful, False otherwise - """ - try: - env_config = UVEnvironment( - python=python_version, - dependencies=(with_packages or []) + ["fastmcp"], - requirements=with_requirements, - project=project, - editable=with_editable, - ) - # Build server spec from parsed components - if server_object: - server_spec = f"{file.resolve()}:{server_object}" - else: - server_spec = str(file.resolve()) - - # Build the full command - full_command = env_config.build_command(["fastmcp", "run", server_spec]) - - # Build MCP server configuration - server_config: dict[str, str | list[str] | dict[str, str]] = { - "command": full_command[0], - "args": full_command[1:], - } - - # Add environment variables if provided - if env_vars: - server_config["env"] = env_vars - - # Wrap with server name as root key - config = {name: server_config} - - # Convert to JSON - json_output = json.dumps(config, indent=2) - - # Handle output - if copy: - pyperclip.copy(json_output) - print(f"[green]MCP configuration for '{name}' copied to clipboard[/green]") - else: - # Print to stdout (for piping) - print(json_output) - - return True - - except Exception as e: - print(f"[red]Failed to generate MCP configuration: {e}[/red]") - return False - - -async def mcp_json_command( - server_spec: str, - *, - server_name: Annotated[ - str | None, - cyclopts.Parameter( - name=["--name", "-n"], - help="Custom name for the server in MCP config", - ), - ] = None, - with_editable: Annotated[ - list[Path] | None, - cyclopts.Parameter( - "--with-editable", - help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", - ), - ] = None, - with_packages: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--with", help="Additional packages to install (can be used multiple times)" - ), - ] = None, - env_vars: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--env", - help="Environment variables in KEY=VALUE format (can be used multiple times)", - ), - ] = None, - env_file: Annotated[ - Path | None, - cyclopts.Parameter( - "--env-file", - help="Load environment variables from .env file", - ), - ] = None, - copy: Annotated[ - bool, - cyclopts.Parameter( - "--copy", - help="Copy configuration to clipboard instead of printing to stdout", - ), - ] = False, - python: Annotated[ - str | None, - cyclopts.Parameter( - "--python", - help="Python version to use (e.g., 3.10, 3.11)", - ), - ] = None, - with_requirements: Annotated[ - Path | None, - cyclopts.Parameter( - "--with-requirements", - help="Requirements file to install dependencies from", - ), - ] = None, - project: Annotated[ - Path | None, - cyclopts.Parameter( - "--project", - help="Run the command within the given project directory", - ), - ] = None, -) -> None: - """Generate MCP configuration JSON for manual installation. - - Args: - server_spec: Python file to install, optionally with :object suffix - """ - # Convert None to empty lists for list parameters - with_editable = with_editable or [] - with_packages = with_packages or [] - env_vars = env_vars or [] - file, server_object, name, packages, env_dict = await process_common_args( - server_spec, server_name, with_packages, env_vars, env_file - ) - - success = install_mcp_json( - file=file, - server_object=server_object, - name=name, - with_editable=with_editable, - with_packages=packages, - env_vars=env_dict, - copy=copy, - python_version=python, - with_requirements=with_requirements, - project=project, - ) - - if not success: - sys.exit(1) diff --git a/src/fastmcp/cli/install/shared.py b/src/fastmcp/cli/install/shared.py deleted file mode 100644 index df22f5bbf..000000000 --- a/src/fastmcp/cli/install/shared.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Shared utilities for install commands.""" - -import json -import os -import re -import subprocess -import sys -from pathlib import Path -from urllib.parse import urlparse - -from dotenv import dotenv_values -from pydantic import ValidationError -from rich import print - -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config import MCPServerConfig -from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource - -logger = get_logger(__name__) - -# Server names are passed as subprocess arguments to CLI tools like `claude` -# and `gemini`. On Windows these may resolve to .cmd/.bat wrappers that run -# through cmd.exe, where shell metacharacters (& | ; etc.) in arguments can -# cause command injection. Restrict names to safe characters. -_SAFE_NAME_RE = re.compile(r"^[\w\-. ]+$") - - -def validate_server_name(name: str) -> str: - """Validate that a server name is safe for use as a subprocess argument. - - Raises SystemExit if the name contains shell metacharacters. - """ - if not _SAFE_NAME_RE.match(name): - print( - f"[red]Invalid server name '[bold]{name}[/bold]': " - "names may only contain letters, numbers, hyphens, underscores, dots, and spaces.[/red]" - ) - sys.exit(1) - return name - - -def parse_env_var(env_var: str) -> tuple[str, str]: - """Parse environment variable string in format KEY=VALUE.""" - if "=" not in env_var: - print( - f"[red]Invalid environment variable format: '[bold]{env_var}[/bold]'. Must be KEY=VALUE[/red]" - ) - sys.exit(1) - key, value = env_var.split("=", 1) - return key.strip(), value.strip() - - -async def process_common_args( - server_spec: str, - server_name: str | None, - with_packages: list[str] | None, - env_vars: list[str] | None, - env_file: Path | None, -) -> tuple[Path, str | None, str, list[str], dict[str, str] | None]: - """Process common arguments shared by all install commands. - - Handles both fastmcp.json config files and traditional file.py:object syntax. - """ - # Convert None to empty lists for list parameters - with_packages = with_packages or [] - env_vars = env_vars or [] - # Create MCPServerConfig from server_spec - config = None - config_path: Path | None = None - if server_spec.endswith(".json"): - config_path = Path(server_spec).resolve() - if not config_path.exists(): - print(f"[red]Configuration file not found: {config_path}[/red]") - sys.exit(1) - - try: - with open(config_path) as f: - data = json.load(f) - - # Check if it's an MCPConfig (has mcpServers key) - if "mcpServers" in data: - # MCPConfig files aren't supported for install - print("[red]MCPConfig files are not supported for installation[/red]") - sys.exit(1) - else: - # It's a MCPServerConfig - config = MCPServerConfig.from_file(config_path) - - # Merge packages from config if not overridden - if config.environment.dependencies: - # Merge with CLI packages (CLI takes precedence) - config_packages = list(config.environment.dependencies) - with_packages = list(set(with_packages + config_packages)) - except (json.JSONDecodeError, ValidationError) as e: - print(f"[red]Invalid configuration file: {e}[/red]") - sys.exit(1) - else: - # Create config from file path - source = FileSystemSource(path=server_spec) - config = MCPServerConfig(source=source) - - # Extract file and server_object from the source - # The FileSystemSource handles parsing path:object syntax - source_path = Path(config.source.path).expanduser() - # If loaded from a JSON config, resolve relative paths against the config's directory - if not source_path.is_absolute() and config_path is not None: - file = (config_path.parent / source_path).resolve() - else: - file = source_path.resolve() - # Update the source path so load_server() resolves correctly - config.source.path = str(file) - server_object = ( - config.source.entrypoint if hasattr(config.source, "entrypoint") else None - ) - - logger.debug( - "Installing server", - extra={ - "file": str(file), - "server_name": server_name, - "server_object": server_object, - "with_packages": with_packages, - }, - ) - - # Verify the resolved file actually exists - if not file.is_file(): - print(f"[red]Server file not found: {file}[/red]") - sys.exit(1) - - # Try to import server to get its name and dependencies. - # load_server() resolves paths against cwd, which may differ from our - # config-relative resolution, so we catch SystemExit from its file check. - name = server_name - server = None - if not name: - try: - server = await config.source.load_server() - name = server.name - except (ImportError, ModuleNotFoundError, SystemExit) as e: - logger.debug( - "Could not import server (likely missing dependencies), using file name", - extra={"error": str(e)}, - ) - name = file.stem - - # Process environment variables if provided - env_dict: dict[str, str] | None = None - if env_file or env_vars: - env_dict = {} - # Load from .env file if specified - if env_file: - try: - env_dict |= { - k: v for k, v in dotenv_values(env_file).items() if v is not None - } - except Exception as e: - print(f"[red]Failed to load .env file: {e}[/red]") - sys.exit(1) - - # Add command line environment variables - for env_var in env_vars: - key, value = parse_env_var(env_var) - env_dict[key] = value - - return file, server_object, name, with_packages, env_dict - - -def open_deeplink(url: str, *, expected_scheme: str) -> bool: - """Attempt to open a deeplink URL using the system's default handler. - - Args: - url: The deeplink URL to open. - expected_scheme: The URL scheme to validate (e.g. "cursor", "goose"). - - Returns: - True if the command succeeded, False otherwise. - """ - parsed = urlparse(url) - if parsed.scheme != expected_scheme: - logger.warning( - f"Invalid deeplink scheme: {parsed.scheme}, expected {expected_scheme}" - ) - return False - - try: - if sys.platform == "darwin": - subprocess.run(["open", url], check=True, capture_output=True) - elif sys.platform == "win32": - os.startfile(url) - else: - subprocess.run(["xdg-open", url], check=True, capture_output=True) - return True - except (subprocess.CalledProcessError, FileNotFoundError, OSError): - return False diff --git a/src/fastmcp/cli/install/stdio.py b/src/fastmcp/cli/install/stdio.py deleted file mode 100644 index 31c89c1dc..000000000 --- a/src/fastmcp/cli/install/stdio.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Stdio command generation for FastMCP install using Cyclopts.""" - -import builtins -import shlex -import sys -from pathlib import Path -from typing import Annotated - -import cyclopts -import pyperclip -from rich import print as rich_print - -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment - -from .shared import process_common_args - -logger = get_logger(__name__) - - -def install_stdio( - file: Path, - server_object: str | None, - *, - with_editable: list[Path] | None = None, - with_packages: list[str] | None = None, - copy: bool = False, - python_version: str | None = None, - with_requirements: Path | None = None, - project: Path | None = None, -) -> bool: - """Generate the stdio command for running a FastMCP server. - - Args: - file: Path to the server file - server_object: Optional server object name (for :object suffix) - with_editable: Optional list of directories to install in editable mode - with_packages: Optional list of additional packages to install - copy: If True, copy to clipboard instead of printing to stdout - python_version: Optional Python version to use - with_requirements: Optional requirements file to install from - project: Optional project directory to run within - - Returns: - True if generation was successful, False otherwise - """ - try: - env_config = UVEnvironment( - python=python_version, - dependencies=(with_packages or []) + ["fastmcp"], - requirements=with_requirements, - project=project, - editable=with_editable, - ) - # Build server spec from parsed components - if server_object: - server_spec = f"{file.resolve()}:{server_object}" - else: - server_spec = str(file.resolve()) - - # Build the full command - full_command = env_config.build_command(["fastmcp", "run", server_spec]) - command_str = shlex.join(full_command) - - if copy: - pyperclip.copy(command_str) - rich_print("[green]✓ Command copied to clipboard[/green]") - else: - builtins.print(command_str) - - return True - - except (OSError, ValueError, pyperclip.PyperclipException) as e: - rich_print(f"[red]Failed to generate stdio command: {e}[/red]") - return False - - -async def stdio_command( - server_spec: str, - *, - server_name: Annotated[ - str | None, - cyclopts.Parameter( - name=["--name", "-n"], - help="Custom name for the server (used for dependency resolution)", - ), - ] = None, - with_editable: Annotated[ - list[Path] | None, - cyclopts.Parameter( - "--with-editable", - help="Directory with pyproject.toml to install in editable mode (can be used multiple times)", - ), - ] = None, - with_packages: Annotated[ - list[str] | None, - cyclopts.Parameter( - "--with", help="Additional packages to install (can be used multiple times)" - ), - ] = None, - copy: Annotated[ - bool, - cyclopts.Parameter( - "--copy", - help="Copy command to clipboard instead of printing to stdout", - ), - ] = False, - python: Annotated[ - str | None, - cyclopts.Parameter( - "--python", - help="Python version to use (e.g., 3.10, 3.11)", - ), - ] = None, - with_requirements: Annotated[ - Path | None, - cyclopts.Parameter( - "--with-requirements", - help="Requirements file to install dependencies from", - ), - ] = None, - project: Annotated[ - Path | None, - cyclopts.Parameter( - "--project", - help="Run the command within the given project directory", - ), - ] = None, -) -> None: - """Generate the stdio command for running a FastMCP server. - - Outputs the shell command that an MCP host would use to start this server - over stdio transport. Useful for manual configuration or debugging. - - Args: - server_spec: Python file to run, optionally with :object suffix - """ - with_editable = with_editable or [] - with_packages = with_packages or [] - file, server_object, _name, packages, _env_dict = await process_common_args( - server_spec, server_name, with_packages, [], None - ) - - success = install_stdio( - file=file, - server_object=server_object, - with_editable=with_editable, - with_packages=packages, - copy=copy, - python_version=python, - with_requirements=with_requirements, - project=project, - ) - - if not success: - sys.exit(1) diff --git a/src/fastmcp/cli/run.py b/src/fastmcp/cli/run.py deleted file mode 100644 index 36b029c38..000000000 --- a/src/fastmcp/cli/run.py +++ /dev/null @@ -1,485 +0,0 @@ -"""FastMCP run command implementation with enhanced type hints.""" - -import asyncio -import contextlib -import json -import os -import re -import signal -import subprocess -import sys -from collections.abc import Callable -from pathlib import Path -from typing import Any, Literal - -from mcp.server.fastmcp import FastMCP as FastMCP1x -from watchfiles import Change, awatch - -from fastmcp.server.server import FastMCP, create_proxy -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config import ( - MCPServerConfig, -) -from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource - -logger = get_logger("cli.run") - -# Type aliases for better type safety -TransportType = Literal["stdio", "http", "sse", "streamable-http"] -LogLevelType = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - -# File extensions to watch for reload -WATCHED_EXTENSIONS: set[str] = { - # Python - ".py", - # JavaScript/TypeScript - ".js", - ".ts", - ".jsx", - ".tsx", - # Markup/Content - ".html", - ".md", - ".mdx", - ".txt", - ".xml", - # Styles - ".css", - ".scss", - ".sass", - ".less", - # Data/Config - ".json", - ".yaml", - ".yml", - ".toml", - # Framework-specific - ".vue", - ".svelte", - # GraphQL - ".graphql", - ".gql", - # Images - ".svg", - ".png", - ".jpg", - ".jpeg", - ".gif", - ".ico", - ".webp", - # Media - ".mp3", - ".mp4", - ".wav", - ".webm", - # Fonts - ".woff", - ".woff2", - ".ttf", - ".eot", -} - - -def is_url(path: str) -> bool: - """Check if a string is a URL.""" - url_pattern = re.compile(r"^https?://") - return bool(url_pattern.match(path)) - - -def create_client_server(url: str) -> Any: - """Create a FastMCP server from a client URL. - - Args: - url: The URL to connect to - - Returns: - A FastMCP server instance - """ - try: - import fastmcp - - client = fastmcp.Client(url) - server = create_proxy(client) - return server - except Exception as e: - logger.error(f"Failed to create client for URL {url}: {e}") - sys.exit(1) - - -def create_mcp_config_server(mcp_config_path: Path) -> FastMCP[None]: - """Create a FastMCP server from a MCPConfig.""" - with mcp_config_path.open() as src: - mcp_config = json.load(src) - - server = create_proxy(mcp_config) - return server - - -def load_mcp_server_config(config_path: Path) -> MCPServerConfig: - """Load a FastMCP configuration from a fastmcp.json file. - - Args: - config_path: Path to fastmcp.json file - - Returns: - MCPServerConfig object - """ - config = MCPServerConfig.from_file(config_path) - - # Apply runtime settings from deployment config - config.deployment.apply_runtime_settings(config_path) - - return config - - -async def run_command( - server_spec: str, - transport: TransportType | None = None, - host: str | None = None, - port: int | None = None, - path: str | None = None, - log_level: LogLevelType | None = None, - server_args: list[str] | None = None, - show_banner: bool = True, - use_direct_import: bool = False, - skip_source: bool = False, - stateless: bool = False, -) -> None: - """Run a MCP server or connect to a remote one. - - Args: - server_spec: Python file, object specification (file:obj), config file, or URL - transport: Transport protocol to use - host: Host to bind to when using http transport - port: Port to bind to when using http transport - path: Path to bind to when using http transport - log_level: Log level - server_args: Additional arguments to pass to the server - show_banner: Whether to show the server banner - use_direct_import: Whether to use direct import instead of subprocess - skip_source: Whether to skip source preparation step - stateless: Whether to run in stateless mode (no session) - """ - # Special case: URLs - if is_url(server_spec): - # Handle URL case - server = create_client_server(server_spec) - logger.debug(f"Created client proxy server for {server_spec}") - # Special case: MCPConfig files (legacy) - elif server_spec.endswith(".json"): - # Load JSON and check which type of config it is - config_path = Path(server_spec) - with open(config_path) as f: - data = json.load(f) - - # Check if it's an MCPConfig first (has canonical mcpServers key) - if "mcpServers" in data: - # It's an MCP config - server = create_mcp_config_server(config_path) - else: - # It's a FastMCP config - load it properly - config = load_mcp_server_config(config_path) - - # Merge deployment config with CLI arguments (CLI takes precedence) - transport = transport or config.deployment.transport - host = host or config.deployment.host - port = port or config.deployment.port - path = path or config.deployment.path - log_level = log_level or config.deployment.log_level - server_args = ( - server_args if server_args is not None else config.deployment.args - ) - - # Prepare source only (environment is handled by uv run) - await config.prepare_source() if not skip_source else None - - # Load the server using the source - from contextlib import nullcontext - - from fastmcp.cli.cli import with_argv - - # Use sys.argv context manager if deployment args specified - argv_context = with_argv(server_args) if server_args else nullcontext() - - with argv_context: - server = await config.source.load_server() - - logger.debug(f'Found server "{server.name}" from config {config_path}') - else: - # Regular file case - create a MCPServerConfig with FileSystemSource - source = FileSystemSource(path=server_spec) - config = MCPServerConfig(source=source) - - # Prepare source only (environment is handled by uv run) - await config.prepare_source() if not skip_source else None - - # Load the server - from contextlib import nullcontext - - from fastmcp.cli.cli import with_argv - - # Use sys.argv context manager if server_args specified - argv_context = with_argv(server_args) if server_args else nullcontext() - - with argv_context: - server = await config.source.load_server() - - logger.debug(f'Found server "{server.name}" in {source.path}') - - # Run the server - - # handle v1 servers - if isinstance(server, FastMCP1x): - await run_v1_server_async(server, host=host, port=port, transport=transport) - return - - kwargs = {} - if transport: - kwargs["transport"] = transport - if host: - kwargs["host"] = host - if port: - kwargs["port"] = port - if path: - kwargs["path"] = path - if log_level: - kwargs["log_level"] = log_level - if stateless: - kwargs["stateless"] = True - - if not show_banner: - kwargs["show_banner"] = False - - try: - await server.run_async(**kwargs) - except Exception as e: - logger.error(f"Failed to run server: {e}") - sys.exit(1) - - -def run_module_command( - module_name: str, - *, - env_command_builder: Callable[[list[str]], list[str]] | None = None, - extra_args: list[str] | None = None, -) -> None: - """Run a Python module directly using ``python -m ``. - - When ``-m`` is used, the module manages its own server startup. - No server-object discovery or transport overrides are applied. - - Args: - module_name: Dotted module name (e.g. ``my_package``). - env_command_builder: An optional callable that wraps a command list - with environment setup (e.g. ``UVEnvironment.build_command``). - extra_args: Extra arguments forwarded after the module name. - """ - # Use bare "python" when an env wrapper (e.g. uv run) is active so that - # the wrapper can resolve the interpreter via --python / environment config. - # Fall back to sys.executable for direct execution without a wrapper. - python = "python" if env_command_builder is not None else sys.executable - cmd: list[str] = [python, "-m", module_name] - if extra_args: - cmd.extend(extra_args) - - # Wrap with environment (e.g. uv run) if configured - if env_command_builder is not None: - cmd = env_command_builder(cmd) - - logger.debug(f"Running module: {' '.join(cmd)}") - - try: - process = subprocess.run(cmd, check=True) - sys.exit(process.returncode) - except subprocess.CalledProcessError as e: - logger.error(f"Module {module_name} exited with code {e.returncode}") - sys.exit(e.returncode) - - -async def run_v1_server_async( - server: FastMCP1x, - host: str | None = None, - port: int | None = None, - transport: TransportType | None = None, -) -> None: - """Run a FastMCP 1.x server using async methods. - - Args: - server: FastMCP 1.x server instance - host: Host to bind to - port: Port to bind to - transport: Transport protocol to use - """ - if host: - server.settings.host = host - if port: - server.settings.port = port - - match transport: - case "stdio": - await server.run_stdio_async() - case "http" | "streamable-http" | None: - await server.run_streamable_http_async() - case "sse": - await server.run_sse_async() - - -def _watch_filter(_change: Change, path: str) -> bool: - """Filter for files that should trigger reload.""" - return any(path.endswith(ext) for ext in WATCHED_EXTENSIONS) - - -async def _terminate_process(process: asyncio.subprocess.Process) -> None: - """Terminate a subprocess and all its children. - - Sends SIGTERM to the process group first for graceful shutdown, - then falls back to SIGKILL if the process doesn't exit in time. - """ - if process.returncode is not None: - return - - pid = process.pid - - if sys.platform != "win32": - # Send SIGTERM to the entire process group for graceful shutdown - with contextlib.suppress(ProcessLookupError, OSError): - os.killpg(os.getpgid(pid), signal.SIGTERM) - - # Wait briefly for graceful exit - try: - await asyncio.wait_for(process.wait(), timeout=3.0) - return - except asyncio.TimeoutError: - pass - - # Force kill the entire process group - with contextlib.suppress(ProcessLookupError, OSError): - os.killpg(os.getpgid(pid), signal.SIGKILL) - else: - process.kill() - - await process.wait() - - -async def run_with_reload( - cmd: list[str], - reload_dirs: list[Path] | None = None, - is_stdio: bool = False, -) -> None: - """Run a command with file watching and auto-reload. - - Args: - cmd: Command to run as subprocess (should include --no-reload) - reload_dirs: Directories to watch for changes (default: cwd) - is_stdio: Whether this is stdio transport - """ - watch_paths = reload_dirs or [Path.cwd()] - process: asyncio.subprocess.Process | None = None - first_run = True - - if is_stdio: - logger.info("Reload mode enabled (using stateless sessions)") - else: - logger.info( - "Reload mode enabled (using stateless HTTP). " - "Some features requiring bidirectional communication " - "(like elicitation) are not available." - ) - - # Handle SIGTERM/SIGINT gracefully with proper asyncio integration - shutdown_event = asyncio.Event() - loop = asyncio.get_running_loop() - - def signal_handler() -> None: - logger.info("Received shutdown signal, stopping...") - shutdown_event.set() - - # Windows doesn't support add_signal_handler - if sys.platform != "win32": - loop.add_signal_handler(signal.SIGTERM, signal_handler) - loop.add_signal_handler(signal.SIGINT, signal_handler) - - try: - while not shutdown_event.is_set(): - # Build command - add --no-banner on restarts to reduce noise - if first_run or "--no-banner" in cmd: - run_cmd = cmd - else: - run_cmd = [*cmd, "--no-banner"] - first_run = False - - process = await asyncio.create_subprocess_exec( - *run_cmd, - stdin=None, - stdout=None, - stderr=None, - # Own process group so _terminate_process can kill the whole tree - start_new_session=sys.platform != "win32", - ) - - # Watch for either: file changes OR process death - watch_task = asyncio.create_task( - anext(aiter(awatch(*watch_paths, watch_filter=_watch_filter))) # ty: ignore[invalid-argument-type] - ) - wait_task = asyncio.create_task(process.wait()) - shutdown_task = asyncio.create_task(shutdown_event.wait()) - - done, pending = await asyncio.wait( - [watch_task, wait_task, shutdown_task], - return_when=asyncio.FIRST_COMPLETED, - ) - - for task in pending: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - - if shutdown_task in done: - # User requested shutdown - break - - if wait_task in done: - # Server died on its own - wait for file change before restart - code = wait_task.result() - if code != 0: - logger.error( - f"Server exited with code {code}, waiting for file change..." - ) - else: - logger.info("Server exited, waiting for file change...") - - # Wait for file change or shutdown (avoid hot loop on crash) - watch_task = asyncio.create_task( - anext(aiter(awatch(*watch_paths, watch_filter=_watch_filter))) # ty: ignore[invalid-argument-type] - ) - shutdown_task = asyncio.create_task(shutdown_event.wait()) - done, pending = await asyncio.wait( - [watch_task, shutdown_task], - return_when=asyncio.FIRST_COMPLETED, - ) - for task in pending: - task.cancel() - with contextlib.suppress(asyncio.CancelledError): - await task - if shutdown_task in done: - break - logger.info("Detected changes, restarting...") - else: - # File changed - restart server - changes = watch_task.result() - logger.info( - f"Detected changes in {len(changes)} file(s), restarting..." - ) - await _terminate_process(process) - - except KeyboardInterrupt: - # Handle Ctrl+C on Windows (where add_signal_handler isn't available) - logger.info("Received shutdown signal, stopping...") - - finally: - # Clean up signal handlers - if sys.platform != "win32": - loop.remove_signal_handler(signal.SIGTERM) - loop.remove_signal_handler(signal.SIGINT) - if process and process.returncode is None: - await _terminate_process(process) diff --git a/src/fastmcp/cli/tasks.py b/src/fastmcp/cli/tasks.py deleted file mode 100644 index 23ddc6e58..000000000 --- a/src/fastmcp/cli/tasks.py +++ /dev/null @@ -1,110 +0,0 @@ -"""FastMCP tasks CLI for Docket task management.""" - -import asyncio -import sys -from typing import Annotated - -import cyclopts -from rich.console import Console - -from fastmcp.utilities.cli import load_and_merge_config -from fastmcp.utilities.logging import get_logger - -logger = get_logger("cli.tasks") -console = Console() - -tasks_app = cyclopts.App( - name="tasks", - help="Manage FastMCP background tasks using Docket", -) - - -def check_distributed_backend() -> None: - """Check if Docket is configured with a distributed backend. - - The CLI worker runs as a separate process, so it needs Redis/Valkey - to coordinate with the main server process. - - Raises: - SystemExit: If using memory:// URL - """ - import fastmcp - - docket_url = fastmcp.settings.docket.url - - # Check for memory:// URL and provide helpful error - if docket_url.startswith("memory://"): - console.print( - "[bold red]✗ In-memory backend not supported by CLI[/bold red]\n\n" - "Your Docket configuration uses an in-memory backend (memory://) which\n" - "only works within a single process.\n\n" - "To use [cyan]fastmcp tasks[/cyan] CLI commands (which run in separate\n" - "processes), you need a distributed backend:\n\n" - "[bold]1. Install Redis or Valkey:[/bold]\n" - " [dim]macOS:[/dim] brew install redis\n" - " [dim]Ubuntu:[/dim] apt install redis-server\n" - " [dim]Valkey:[/dim] See https://valkey.io/\n\n" - "[bold]2. Start the service:[/bold]\n" - " redis-server\n\n" - "[bold]3. Configure Docket URL:[/bold]\n" - " [dim]Environment variable:[/dim]\n" - " export FASTMCP_DOCKET_URL=redis://localhost:6379/0\n\n" - "[bold]4. Try again[/bold]\n\n" - "The memory backend works great for single-process servers, but the CLI\n" - "commands need a distributed backend to coordinate across processes.\n\n" - "Need help? See: [cyan]https://gofastmcp.com/docs/tasks[/cyan]" - ) - sys.exit(1) - - -@tasks_app.command -def worker( - server_spec: Annotated[ - str | None, - cyclopts.Parameter( - help="Python file to run, optionally with :object suffix, or None to auto-detect fastmcp.json" - ), - ] = None, -) -> None: - """Start an additional worker to process background tasks. - - Connects to your Docket backend and processes tasks in parallel with - any other running workers. Configure via environment variables - (FASTMCP_DOCKET_*). - - Example: - fastmcp tasks worker server.py - fastmcp tasks worker examples/tasks/server.py - """ - import fastmcp - - check_distributed_backend() - - # Load server to get task functions - try: - config, _resolved_spec = load_and_merge_config(server_spec) - except FileNotFoundError: - sys.exit(1) - - # Load the server - server = asyncio.run(config.source.load_server()) - - async def run_worker(): - """Enter server lifespan and camp forever.""" - async with server._lifespan_manager(): - console.print( - f"[bold green]✓[/bold green] Starting worker for [cyan]{server.name}[/cyan]" - ) - console.print(f" Docket: {fastmcp.settings.docket.name}") - console.print(f" Backend: {fastmcp.settings.docket.url}") - console.print(f" Concurrency: {fastmcp.settings.docket.concurrency}") - - # Server's lifespan has started its worker - just camp here forever - while True: - await asyncio.sleep(3600) - - try: - asyncio.run(run_worker()) - except KeyboardInterrupt: - console.print("\n[yellow]Worker stopped[/yellow]") - sys.exit(0) diff --git a/src/fastmcp/client/__init__.py b/src/fastmcp/client/__init__.py deleted file mode 100644 index e7e638176..000000000 --- a/src/fastmcp/client/__init__.py +++ /dev/null @@ -1,30 +0,0 @@ -from .auth import OAuth, BearerAuth -from .client import Client -from .transports import ( - ClientTransport, - FastMCPTransport, - NodeStdioTransport, - NpxStdioTransport, - PythonStdioTransport, - SSETransport, - StdioTransport, - StreamableHttpTransport, - UvStdioTransport, - UvxStdioTransport, -) - -__all__ = [ - "BearerAuth", - "Client", - "ClientTransport", - "FastMCPTransport", - "NodeStdioTransport", - "NpxStdioTransport", - "OAuth", - "PythonStdioTransport", - "SSETransport", - "StdioTransport", - "StreamableHttpTransport", - "UvStdioTransport", - "UvxStdioTransport", -] diff --git a/src/fastmcp/client/auth/__init__.py b/src/fastmcp/client/auth/__init__.py deleted file mode 100644 index 6ec3ecf4b..000000000 --- a/src/fastmcp/client/auth/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .bearer import BearerAuth -from .oauth import OAuth - -__all__ = ["BearerAuth", "OAuth"] diff --git a/src/fastmcp/client/auth/bearer.py b/src/fastmcp/client/auth/bearer.py deleted file mode 100644 index 0c38a11b0..000000000 --- a/src/fastmcp/client/auth/bearer.py +++ /dev/null @@ -1,17 +0,0 @@ -import httpx -from pydantic import SecretStr - -from fastmcp.utilities.logging import get_logger - -__all__ = ["BearerAuth"] - -logger = get_logger(__name__) - - -class BearerAuth(httpx.Auth): - def __init__(self, token: str): - self.token = SecretStr(token) - - def auth_flow(self, request): - request.headers["Authorization"] = f"Bearer {self.token.get_secret_value()}" - yield request diff --git a/src/fastmcp/client/auth/oauth.py b/src/fastmcp/client/auth/oauth.py deleted file mode 100644 index 64416393a..000000000 --- a/src/fastmcp/client/auth/oauth.py +++ /dev/null @@ -1,430 +0,0 @@ -from __future__ import annotations - -import time -import webbrowser -from collections.abc import AsyncGenerator -from contextlib import aclosing -from typing import Any - -import anyio -import httpx -from key_value.aio.adapters.pydantic import PydanticAdapter -from key_value.aio.protocols import AsyncKeyValue -from key_value.aio.stores.memory import MemoryStore -from mcp.client.auth import OAuthClientProvider, TokenStorage -from mcp.shared._httpx_utils import McpHttpClientFactory -from mcp.shared.auth import ( - OAuthClientInformationFull, - OAuthClientMetadata, - OAuthToken, -) -from pydantic import AnyHttpUrl -from typing_extensions import override -from uvicorn.server import Server - -from fastmcp.client.oauth_callback import ( - OAuthCallbackResult, - create_oauth_callback_server, -) -from fastmcp.utilities.http import find_available_port -from fastmcp.utilities.logging import get_logger - -__all__ = ["OAuth"] - -logger = get_logger(__name__) - - -class ClientNotFoundError(Exception): - """Raised when OAuth client credentials are not found on the server.""" - - -async def check_if_auth_required( - mcp_url: str, httpx_kwargs: dict[str, Any] | None = None -) -> bool: - """ - Check if the MCP endpoint requires authentication by making a test request. - - Returns: - True if auth appears to be required, False otherwise - """ - async with httpx.AsyncClient(**(httpx_kwargs or {})) as client: - try: - # Try a simple request to the endpoint - response = await client.get(mcp_url, timeout=5.0) - - # If we get 401/403, auth is likely required - if response.status_code in (401, 403): - return True - - # Check for WWW-Authenticate header - if "WWW-Authenticate" in response.headers: # noqa: SIM103 - return True - - # If we get a successful response, auth may not be required - return False - - except httpx.RequestError: - # If we can't connect, assume auth might be required - return True - - -class TokenStorageAdapter(TokenStorage): - _server_url: str - _key_value_store: AsyncKeyValue - _storage_oauth_token: PydanticAdapter[OAuthToken] - _storage_client_info: PydanticAdapter[OAuthClientInformationFull] - - def __init__(self, async_key_value: AsyncKeyValue, server_url: str): - self._server_url = server_url - self._key_value_store = async_key_value - self._storage_oauth_token = PydanticAdapter[OAuthToken]( - default_collection="mcp-oauth-token", - key_value=async_key_value, - pydantic_model=OAuthToken, - raise_on_validation_error=True, - ) - self._storage_client_info = PydanticAdapter[OAuthClientInformationFull]( - default_collection="mcp-oauth-client-info", - key_value=async_key_value, - pydantic_model=OAuthClientInformationFull, - raise_on_validation_error=True, - ) - - def _get_token_cache_key(self) -> str: - return f"{self._server_url}/tokens" - - def _get_client_info_cache_key(self) -> str: - return f"{self._server_url}/client_info" - - def _get_token_expiry_cache_key(self) -> str: - return f"{self._server_url}/token_expiry" - - async def clear(self) -> None: - await self._storage_oauth_token.delete(key=self._get_token_cache_key()) - await self._storage_client_info.delete(key=self._get_client_info_cache_key()) - await self._key_value_store.delete( - key=self._get_token_expiry_cache_key(), - collection="mcp-oauth-token-expiry", - ) - - @override - async def get_tokens(self) -> OAuthToken | None: - return await self._storage_oauth_token.get(key=self._get_token_cache_key()) - - @override - async def set_tokens(self, tokens: OAuthToken) -> None: - # Don't set TTL based on access token expiry - the refresh token may be - # valid much longer. Use 1 year as a reasonable upper bound; the OAuth - # provider handles actual token expiry/refresh logic. - await self._storage_oauth_token.put( - key=self._get_token_cache_key(), - value=tokens, - ttl=60 * 60 * 24 * 365, # 1 year - ) - # Store absolute expiry so reloads don't misinterpret the stale - # relative expires_in value (#2862). - if tokens.expires_in is not None: - expires_at = time.time() + int(tokens.expires_in) - await self._key_value_store.put( - key=self._get_token_expiry_cache_key(), - value={"expires_at": expires_at}, - collection="mcp-oauth-token-expiry", - ttl=60 * 60 * 24 * 365, - ) - - async def get_token_expiry(self) -> float | None: - raw = await self._key_value_store.get( - key=self._get_token_expiry_cache_key(), - collection="mcp-oauth-token-expiry", - ) - if raw is not None: - return float(raw["expires_at"]) - return None - - @override - async def get_client_info(self) -> OAuthClientInformationFull | None: - return await self._storage_client_info.get( - key=self._get_client_info_cache_key() - ) - - @override - async def set_client_info(self, client_info: OAuthClientInformationFull) -> None: - ttl: int | None = None - - if client_info.client_secret_expires_at: - ttl = client_info.client_secret_expires_at - int(time.time()) - - await self._storage_client_info.put( - key=self._get_client_info_cache_key(), - value=client_info, - ttl=ttl, - ) - - -class OAuth(OAuthClientProvider): - """ - OAuth client provider for MCP servers with browser-based authentication. - - This class provides OAuth authentication for FastMCP clients by opening - a browser for user authorization and running a local callback server. - """ - - _bound: bool - - def __init__( - self, - mcp_url: str | None = None, - scopes: str | list[str] | None = None, - client_name: str = "FastMCP Client", - token_storage: AsyncKeyValue | None = None, - additional_client_metadata: dict[str, Any] | None = None, - callback_port: int | None = None, - httpx_client_factory: McpHttpClientFactory | None = None, - # Alternative to dynamic client registration: - # --- Clients host a static JSON document at an HTTPS URL --- - client_metadata_url: str | None = None, - # --- OR clients provide full client information --- - client_id: str | None = None, - client_secret: str | None = None, - ): - """ - Initialize OAuth client provider for an MCP server. - - Args: - mcp_url: Full URL to the MCP endpoint (e.g. "http://host/mcp/sse/"). - Optional when OAuth is passed to Client(auth=...), which provides - the URL automatically from the transport. - scopes: OAuth scopes to request. Can be a - space-separated string or a list of strings. - client_name: Name for this client during registration - token_storage: An AsyncKeyValue-compatible token store, tokens are stored in memory if not provided - additional_client_metadata: Extra fields for OAuthClientMetadata - callback_port: Fixed port for OAuth callback (default: random available port) - client_metadata_url: A CIMD (Client ID Metadata Document) URL. When - provided, this URL is used as the client_id instead of performing - Dynamic Client Registration. Must be an HTTPS URL with a non-root - path (e.g. "https://myapp.example.com/oauth/client.json"). - client_id: Pre-registered OAuth client ID. When provided, skips dynamic - client registration and uses these static credentials instead. - client_secret: OAuth client secret (optional, used with client_id) - """ - # Store config for deferred binding if mcp_url not yet known - self._scopes = scopes - self._client_name = client_name - self._token_storage = token_storage - self._additional_client_metadata = additional_client_metadata - self._callback_port = callback_port - self._client_metadata_url = client_metadata_url - self._client_id = client_id - self._client_secret = client_secret - self._static_client_info = None - self.httpx_client_factory = httpx_client_factory or httpx.AsyncClient - self._bound = False - - if mcp_url is not None: - self._bind(mcp_url) - - def _bind(self, mcp_url: str) -> None: - """Bind this OAuth provider to a specific MCP server URL. - - Called automatically when mcp_url is provided to __init__, or by the - transport when OAuth is used without an explicit URL. - """ - if self._bound: - return - - mcp_url = mcp_url.rstrip("/") - - self.redirect_port = self._callback_port or find_available_port() - redirect_uri = f"http://localhost:{self.redirect_port}/callback" - - scopes_str: str - if isinstance(self._scopes, list): - scopes_str = " ".join(self._scopes) - elif self._scopes is not None: - scopes_str = str(self._scopes) - else: - scopes_str = "" - - client_metadata = OAuthClientMetadata( - client_name=self._client_name, - redirect_uris=[AnyHttpUrl(redirect_uri)], - grant_types=["authorization_code", "refresh_token"], - response_types=["code"], - scope=scopes_str, - **(self._additional_client_metadata or {}), - ) - - if self._client_id: - # Create the full static client info directly which will avoid DCR. - # Spread client_metadata so redirect_uris, grant_types, response_types, - # scope, etc. are included — servers may validate these fields. - metadata = client_metadata.model_dump(exclude_none=True) - # Default token_endpoint_auth_method based on whether a secret is - # provided, unless the caller already set it via additional_client_metadata. - if "token_endpoint_auth_method" not in metadata: - metadata["token_endpoint_auth_method"] = ( - "client_secret_post" if self._client_secret else "none" - ) - self._static_client_info = OAuthClientInformationFull( - client_id=self._client_id, - client_secret=self._client_secret, - **metadata, - ) - - token_storage = self._token_storage or MemoryStore() - - if isinstance(token_storage, MemoryStore): - from warnings import warn - - warn( - message="Using in-memory token storage -- tokens will be lost when the client restarts. " - + "For persistent storage across multiple MCP servers, provide an encrypted AsyncKeyValue backend. " - + "See https://gofastmcp.com/clients/auth/oauth#token-storage for details.", - stacklevel=2, - ) - - # Use full URL for token storage to properly separate tokens per MCP endpoint - self.token_storage_adapter: TokenStorageAdapter = TokenStorageAdapter( - async_key_value=token_storage, server_url=mcp_url - ) - - self.mcp_url = mcp_url - - super().__init__( - server_url=mcp_url, - client_metadata=client_metadata, - storage=self.token_storage_adapter, - redirect_handler=self.redirect_handler, - callback_handler=self.callback_handler, - client_metadata_url=self._client_metadata_url, - ) - - self._bound = True - - async def _initialize(self) -> None: - """Load stored tokens and client info, properly setting token expiry.""" - await super()._initialize() - - if self._static_client_info is not None: - self.context.client_info = self._static_client_info - await self.token_storage_adapter.set_client_info(self._static_client_info) - - if self.context.current_tokens and self.context.current_tokens.expires_in: - stored_expiry = await self.token_storage_adapter.get_token_expiry() - if stored_expiry is not None: - self.context.token_expiry_time = stored_expiry - else: - self.context.update_token_expiry(self.context.current_tokens) - - async def redirect_handler(self, authorization_url: str) -> None: - """Open browser for authorization, with pre-flight check for invalid client.""" - # Pre-flight check to detect invalid client_id before opening browser - async with self.httpx_client_factory() as client: - response = await client.get(authorization_url, follow_redirects=False) - - # Check for client not found error (400 typically means bad client_id) - if response.status_code == 400: - raise ClientNotFoundError( - "OAuth client not found - cached credentials may be stale" - ) - - # OAuth typically returns redirects, but some providers return 200 with HTML login pages - if response.status_code not in (200, 302, 303, 307, 308): - raise RuntimeError( - f"Unexpected authorization response: {response.status_code}" - ) - - logger.info(f"OAuth authorization URL: {authorization_url}") - webbrowser.open(authorization_url) - - async def callback_handler(self) -> tuple[str, str | None]: - """Handle OAuth callback and return (auth_code, state).""" - # Create result container and event to capture the OAuth response - result = OAuthCallbackResult() - result_ready = anyio.Event() - - # Create server with result tracking - server: Server = create_oauth_callback_server( - port=self.redirect_port, - server_url=self.mcp_url, - result_container=result, - result_ready=result_ready, - ) - - # Run server until response is received with timeout logic - async with anyio.create_task_group() as tg: - tg.start_soon(server.serve) - logger.info( - f"🎧 OAuth callback server started on http://localhost:{self.redirect_port}" - ) - - TIMEOUT = 300.0 # 5 minute timeout - try: - with anyio.fail_after(TIMEOUT): - await result_ready.wait() - if result.error: - raise result.error - return result.code, result.state # type: ignore - except TimeoutError as e: - raise TimeoutError( - f"OAuth callback timed out after {TIMEOUT} seconds" - ) from e - finally: - server.should_exit = True - await anyio.sleep(0.1) # Allow server to shut down gracefully - tg.cancel_scope.cancel() - - raise RuntimeError("OAuth callback handler could not be started") - - async def async_auth_flow( - self, request: httpx.Request - ) -> AsyncGenerator[httpx.Request, httpx.Response]: - """HTTPX auth flow with automatic retry on stale cached credentials. - - If the OAuth flow fails due to invalid/stale client credentials, - clears the cache and retries once with fresh registration. - """ - if not self._bound: - raise RuntimeError( - "OAuth provider has no server URL. Either pass mcp_url to OAuth() " - "or use it with Client(auth=...) which provides the URL automatically." - ) - try: - # First attempt with potentially cached credentials - async with aclosing(super().async_auth_flow(request)) as gen: - response = None - while True: - try: - # First iteration sends None, subsequent iterations send response - yielded_request = await gen.asend(response) # ty: ignore[invalid-argument-type] - response = yield yielded_request - except StopAsyncIteration: - break - - except ClientNotFoundError: - # Static credentials are fixed — retrying won't help. Surface the - # error so the user can correct their client_id / client_secret. - if self._static_client_info is not None: - raise ClientNotFoundError( - "OAuth server rejected the static client credentials. " - "Verify that the client_id (and client_secret, if provided) " - "are correct and that the client is registered with the server." - ) from None - - logger.debug( - "OAuth client not found on server, clearing cache and retrying..." - ) - # Clear cached state and retry once - self._initialized = False - await self.token_storage_adapter.clear() - - # Retry with fresh registration - async with aclosing(super().async_auth_flow(request)) as gen: - response = None - while True: - try: - yielded_request = await gen.asend(response) # ty: ignore[invalid-argument-type] - response = yield yielded_request - except StopAsyncIteration: - break diff --git a/src/fastmcp/client/client.py b/src/fastmcp/client/client.py deleted file mode 100644 index 17fb7be90..000000000 --- a/src/fastmcp/client/client.py +++ /dev/null @@ -1,875 +0,0 @@ -from __future__ import annotations - -import asyncio -import copy -import datetime -import secrets -import ssl -import weakref -from collections.abc import Coroutine -from contextlib import AsyncExitStack, asynccontextmanager, suppress -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any, Generic, Literal, TypeVar, cast, overload - -import anyio -import httpx -import mcp.types -from exceptiongroup import catch -from mcp import ClientSession, McpError -from mcp.types import GetTaskResult, TaskStatusNotification -from pydantic import AnyUrl - -import fastmcp -from fastmcp.client.auth.oauth import OAuth -from fastmcp.client.elicitation import ElicitationHandler, create_elicitation_callback -from fastmcp.client.logging import ( - LogHandler, - create_log_callback, - default_log_handler, -) -from fastmcp.client.messages import MessageHandler, MessageHandlerT -from fastmcp.client.mixins import ( - ClientPromptsMixin, - ClientResourcesMixin, - ClientTaskManagementMixin, - ClientToolsMixin, -) -from fastmcp.client.progress import ProgressHandler, default_progress_handler -from fastmcp.client.roots import ( - RootsHandler, - RootsList, - create_roots_callback, -) -from fastmcp.client.sampling import ( - SamplingHandler, - create_sampling_callback, -) -from fastmcp.client.tasks import ( - PromptTask, - ResourceTask, - TaskNotificationHandler, - ToolTask, -) -from fastmcp.mcp_config import MCPConfig -from fastmcp.server import FastMCP -from fastmcp.utilities.exceptions import get_catch_handlers -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.timeout import ( - normalize_timeout_to_seconds, - normalize_timeout_to_timedelta, -) - -from .transports import ( - ClientTransport, - ClientTransportT, - FastMCP1Server, - FastMCPTransport, - MCPConfigTransport, - NodeStdioTransport, - PythonStdioTransport, - SessionKwargs, - SSETransport, - StdioTransport, - StreamableHttpTransport, - infer_transport, -) - -__all__ = [ - "Client", - "ElicitationHandler", - "LogHandler", - "MessageHandler", - "ProgressHandler", - "RootsHandler", - "RootsList", - "SamplingHandler", - "SessionKwargs", -] - -logger = get_logger(__name__) - -T = TypeVar("T", bound="ClientTransport") -ResultT = TypeVar("ResultT") - - -@dataclass -class ClientSessionState: - """Holds all session-related state for a Client instance. - - This allows clean separation of configuration (which is copied) from - session state (which should be fresh for each new client instance). - """ - - session: ClientSession | None = None - nesting_counter: int = 0 - lock: anyio.Lock = field(default_factory=anyio.Lock) - session_task: asyncio.Task | None = None - ready_event: anyio.Event = field(default_factory=anyio.Event) - stop_event: anyio.Event = field(default_factory=anyio.Event) - initialize_result: mcp.types.InitializeResult | None = None - - -@dataclass -class CallToolResult: - """Parsed result from a tool call.""" - - content: list[mcp.types.ContentBlock] - structured_content: dict[str, Any] | None - meta: dict[str, Any] | None - data: Any = None - is_error: bool = False - - -class Client( - Generic[ClientTransportT], - ClientResourcesMixin, - ClientPromptsMixin, - ClientToolsMixin, - ClientTaskManagementMixin, -): - """ - MCP client that delegates connection management to a Transport instance. - - The Client class is responsible for MCP protocol logic, while the Transport - handles connection establishment and management. Client provides methods for - working with resources, prompts, tools and other MCP capabilities. - - This client supports reentrant context managers (multiple concurrent - `async with client:` blocks) using reference counting and background session - management. This allows efficient session reuse in any scenario with - nested or concurrent client usage. - - MCP SDK 1.10 introduced automatic list_tools() calls during call_tool() - execution. This created a race condition where events could be reset while - other tasks were waiting on them, causing deadlocks. The issue was exposed - in proxy scenarios but affects any reentrant usage. - - The solution uses reference counting to track active context managers, - a background task to manage the session lifecycle, events to coordinate - between tasks, and ensures all session state changes happen within a lock. - Events are only created when needed, never reset outside locks. - - This design prevents race conditions where tasks wait on events that get - replaced by other tasks, ensuring reliable coordination in concurrent scenarios. - - Args: - transport: - Connection source specification, which can be: - - - ClientTransport: Direct transport instance - - FastMCP: In-process FastMCP server - - AnyUrl or str: URL to connect to - - Path: File path for local socket - - MCPConfig: MCP server configuration - - dict: Transport configuration - - roots: Optional RootsList or RootsHandler for filesystem access - sampling_handler: Optional handler for sampling requests - log_handler: Optional handler for log messages - message_handler: Optional handler for protocol messages - progress_handler: Optional handler for progress notifications - timeout: Optional timeout for requests (seconds or timedelta) - init_timeout: Optional timeout for initial connection (seconds or timedelta). - Set to 0 to disable. If None, uses the value in the FastMCP global settings. - - Examples: - ```python - # Connect to FastMCP server - client = Client("http://localhost:8080") - - async with client: - # List available resources - resources = await client.list_resources() - - # Call a tool - result = await client.call_tool("my_tool", {"param": "value"}) - ``` - """ - - @overload - def __init__(self: Client[T], transport: T, *args: Any, **kwargs: Any) -> None: ... - - @overload - def __init__( - self: Client[SSETransport | StreamableHttpTransport], - transport: AnyUrl, - *args: Any, - **kwargs: Any, - ) -> None: ... - - @overload - def __init__( - self: Client[FastMCPTransport], - transport: FastMCP | FastMCP1Server, - *args: Any, - **kwargs: Any, - ) -> None: ... - - @overload - def __init__( - self: Client[PythonStdioTransport | NodeStdioTransport], - transport: Path, - *args: Any, - **kwargs: Any, - ) -> None: ... - - @overload - def __init__( - self: Client[MCPConfigTransport], - transport: MCPConfig | dict[str, Any], - *args: Any, - **kwargs: Any, - ) -> None: ... - - @overload - def __init__( - self: Client[ - PythonStdioTransport - | NodeStdioTransport - | SSETransport - | StreamableHttpTransport - ], - transport: str, - *args: Any, - **kwargs: Any, - ) -> None: ... - - def __init__( - self, - transport: ( - ClientTransportT - | FastMCP - | FastMCP1Server - | AnyUrl - | Path - | MCPConfig - | dict[str, Any] - | str - ), - name: str | None = None, - roots: RootsList | RootsHandler | None = None, - sampling_handler: SamplingHandler | None = None, - sampling_capabilities: mcp.types.SamplingCapability | None = None, - elicitation_handler: ElicitationHandler | None = None, - log_handler: LogHandler | None = None, - message_handler: MessageHandlerT | MessageHandler | None = None, - progress_handler: ProgressHandler | None = None, - timeout: datetime.timedelta | float | int | None = None, - auto_initialize: bool = True, - init_timeout: datetime.timedelta | float | int | None = None, - client_info: mcp.types.Implementation | None = None, - auth: httpx.Auth | Literal["oauth"] | str | None = None, - verify: ssl.SSLContext | bool | str | None = None, - ) -> None: - self.name = name or self.generate_name() - - self.transport = cast(ClientTransportT, infer_transport(transport)) - - if verify is not None: - from fastmcp.client.transports.http import StreamableHttpTransport - from fastmcp.client.transports.sse import SSETransport - - if isinstance(self.transport, StreamableHttpTransport | SSETransport): - self.transport.verify = verify - # Re-sync existing OAuth auth with the new verify setting, - # but only if the transport doesn't have a custom factory - # (which takes precedence and was already applied to OAuth). - if ( - isinstance(self.transport.auth, OAuth) - and auth is None - and self.transport.httpx_client_factory is None - ): - verify_factory = self.transport._make_verify_factory() - if verify_factory is not None: - self.transport.auth.httpx_client_factory = verify_factory - else: - raise ValueError( - "The 'verify' parameter is only supported for HTTP transports." - ) - - if auth is not None: - self.transport._set_auth(auth) - - if log_handler is None: - log_handler = default_log_handler - - if progress_handler is None: - progress_handler = default_progress_handler - - self._progress_handler = progress_handler - - # Convert timeout to timedelta if needed - timeout = normalize_timeout_to_timedelta(timeout) - - # handle init handshake timeout (0 means disabled) - if init_timeout is None: - init_timeout = fastmcp.settings.client_init_timeout - self._init_timeout = normalize_timeout_to_seconds(init_timeout) - - self.auto_initialize = auto_initialize - - self._session_kwargs: SessionKwargs = { - "sampling_callback": None, - "list_roots_callback": None, - "logging_callback": create_log_callback(log_handler), - "message_handler": message_handler or TaskNotificationHandler(self), - "read_timeout_seconds": timeout, - "client_info": client_info, - } - - if roots is not None: - self.set_roots(roots) - - if sampling_handler is not None: - self._session_kwargs["sampling_callback"] = create_sampling_callback( - sampling_handler - ) - self._session_kwargs["sampling_capabilities"] = ( - sampling_capabilities - if sampling_capabilities is not None - else mcp.types.SamplingCapability() - ) - - if elicitation_handler is not None: - self._session_kwargs["elicitation_callback"] = create_elicitation_callback( - elicitation_handler - ) - - # Maximum time to wait for a clean disconnect before giving up. - # Normally disconnects complete in <100ms; this is a safety net for - # unresponsive servers. - self._disconnect_timeout: float = fastmcp.settings.client_disconnect_timeout - - # Session context management - see class docstring for detailed explanation - self._session_state = ClientSessionState() - - # Track task IDs submitted by this client (for list_tasks support) - self._submitted_task_ids: set[str] = set() - - # Registry for routing notifications/tasks/status to Task objects - - self._task_registry: dict[ - str, weakref.ref[ToolTask | PromptTask | ResourceTask] - ] = {} - - def _reset_session_state(self, full: bool = False) -> None: - """Reset session state after disconnect or cancellation. - - Args: - full: If True, also resets session_task and nesting_counter. - Use full=True for cancellation cleanup where the session - task was started but never completed normally. - """ - self._session_state.session = None - self._session_state.initialize_result = None - if full: - self._session_state.session_task = None - self._session_state.nesting_counter = 0 - - @property - def session(self) -> ClientSession: - """Get the current active session. Raises RuntimeError if not connected.""" - if self._session_state.session is None: - raise RuntimeError( - "Client is not connected. Use the 'async with client:' context manager first." - ) - - return self._session_state.session - - @property - def initialize_result(self) -> mcp.types.InitializeResult | None: - """Get the result of the initialization request.""" - return self._session_state.initialize_result - - def set_roots(self, roots: RootsList | RootsHandler) -> None: - """Set the roots for the client. This does not automatically call `send_roots_list_changed`.""" - self._session_kwargs["list_roots_callback"] = create_roots_callback(roots) - - def set_sampling_callback( - self, - sampling_callback: SamplingHandler, - sampling_capabilities: mcp.types.SamplingCapability | None = None, - ) -> None: - """Set the sampling callback for the client.""" - self._session_kwargs["sampling_callback"] = create_sampling_callback( - sampling_callback - ) - self._session_kwargs["sampling_capabilities"] = ( - sampling_capabilities - if sampling_capabilities is not None - else mcp.types.SamplingCapability() - ) - - def set_elicitation_callback( - self, elicitation_callback: ElicitationHandler - ) -> None: - """Set the elicitation callback for the client.""" - self._session_kwargs["elicitation_callback"] = create_elicitation_callback( - elicitation_callback - ) - - def is_connected(self) -> bool: - """Check if the client is currently connected.""" - return self._session_state.session is not None - - def new(self) -> Client[ClientTransportT]: - """Create a new client instance with the same configuration but fresh session state. - - This creates a new client with the same transport, handlers, and configuration, - but with no active session. Useful for creating independent sessions that don't - share state with the original client. - - Returns: - A new Client instance with the same configuration but disconnected state. - - Example: - ```python - # Create a fresh client for each concurrent operation - fresh_client = client.new() - async with fresh_client: - await fresh_client.call_tool("some_tool", {}) - ``` - """ - new_client = copy.copy(self) - - if not isinstance(self.transport, StdioTransport): - # Reset session state to fresh state - new_client._session_state = ClientSessionState() - - new_client.name += f":{secrets.token_hex(2)}" - - return new_client - - @asynccontextmanager - async def _context_manager(self): - with catch(get_catch_handlers()): - async with self.transport.connect_session( - **self._session_kwargs - ) as session: - self._session_state.session = session - # Initialize the session if auto_initialize is enabled - try: - if self.auto_initialize: - await self.initialize() - yield - except anyio.ClosedResourceError as e: - raise RuntimeError("Server session was closed unexpectedly") from e - finally: - self._reset_session_state() - - async def initialize( - self, - timeout: datetime.timedelta | float | int | None = None, - ) -> mcp.types.InitializeResult: - """Send an initialize request to the server. - - This method performs the MCP initialization handshake with the server, - exchanging capabilities and server information. It is idempotent - calling - it multiple times returns the cached result from the first call. - - The initialization happens automatically when entering the client context - manager unless `auto_initialize=False` was set during client construction. - Manual calls to this method are only needed when auto-initialization is disabled. - - Args: - timeout: Optional timeout for the initialization request (seconds or timedelta). - If None, uses the client's init_timeout setting. - - Returns: - InitializeResult: The server's initialization response containing server info, - capabilities, protocol version, and optional instructions. - - Raises: - RuntimeError: If the client is not connected or initialization times out. - - Example: - ```python - # With auto-initialization disabled - client = Client(server, auto_initialize=False) - async with client: - result = await client.initialize() - print(f"Server: {result.serverInfo.name}") - print(f"Instructions: {result.instructions}") - ``` - """ - - if self.initialize_result is not None: - return self.initialize_result - - if timeout is None: - timeout = self._init_timeout - else: - timeout = normalize_timeout_to_seconds(timeout) - - try: - with anyio.fail_after(timeout): - self._session_state.initialize_result = await self.session.initialize() - return self._session_state.initialize_result - except TimeoutError as e: - raise RuntimeError("Failed to initialize server session") from e - - async def __aenter__(self): - return await self._connect() - - async def __aexit__(self, exc_type, exc_val, exc_tb): - # Use a timeout to prevent hanging during cleanup if the connection is in a bad - # state (e.g., rate-limited). The MCP SDK's transport may try to terminate the - # session which can hang if the server is unresponsive. - with anyio.move_on_after(self._disconnect_timeout): - await self._disconnect() - - async def _connect(self): - """ - Establish or reuse a session connection. - - This method implements the reentrant context manager pattern: - - First call: Creates background session task and waits for it to be ready - - Subsequent calls: Increments reference counter and reuses existing session - - All operations protected by _context_lock to prevent race conditions - - The critical fix: Events are only created when starting a new session, - never reset outside the lock, preventing the deadlock scenario where - tasks wait on events that get replaced by other tasks. - """ - # ensure only one session is running at a time to avoid race conditions - async with self._session_state.lock: - need_to_start = ( - self._session_state.session_task is None - or self._session_state.session_task.done() - ) - - if need_to_start: - if self._session_state.nesting_counter != 0: - raise RuntimeError( - f"Internal error: nesting counter should be 0 when starting new session, got {self._session_state.nesting_counter}" - ) - self._session_state.stop_event = anyio.Event() - self._session_state.ready_event = anyio.Event() - self._session_state.session_task = asyncio.create_task( - self._session_runner() - ) - try: - await self._session_state.ready_event.wait() - except asyncio.CancelledError: - # Cancellation during initial connection startup can leave the - # background session task running because __aexit__ is never invoked - # when __aenter__ is cancelled. Since we hold the session lock here - # and we know we started the session task, it's safe to tear it down - # without impacting other active contexts. - # - # Note: session_task is an asyncio.Task (not anyio) because it needs - # to outlive individual context manager scopes - anyio's structured - # concurrency doesn't allow tasks to escape their task group. - session_task = self._session_state.session_task - if session_task is not None: - # Request a graceful stop if the runner has already reached - # its stop_event wait. - self._session_state.stop_event.set() - session_task.cancel() - with anyio.CancelScope(shield=True): - with anyio.move_on_after(3): - try: - await session_task - except asyncio.CancelledError: - pass - except Exception as e: - logger.debug( - f"Error during cancelled session cleanup: {e}" - ) - - # Reset session state so future callers can reconnect cleanly. - self._reset_session_state(full=True) - - with anyio.CancelScope(shield=True): - with anyio.move_on_after(3): - try: - await self.transport.close() - except Exception as e: - logger.debug( - f"Error closing transport after cancellation: {e}" - ) - - raise - - if self._session_state.session_task.done(): - exception = self._session_state.session_task.exception() - if exception is None: - raise RuntimeError( - "Session task completed without exception but connection failed" - ) - # Preserve specific exception types that clients may want to handle - if isinstance(exception, httpx.HTTPStatusError | McpError): - raise exception - raise RuntimeError( - f"Client failed to connect: {exception}" - ) from exception - - self._session_state.nesting_counter += 1 - - return self - - async def _disconnect(self, force: bool = False): - """ - Disconnect from session using reference counting. - - This method implements proper cleanup for reentrant context managers: - - Decrements reference counter for normal exits - - Only stops session when counter reaches 0 (no more active contexts) - - Force flag bypasses reference counting for immediate shutdown - - Session cleanup happens inside the lock to ensure atomicity - - Key fix: Removed the problematic "Reset for future reconnects" logic - that was resetting events outside the lock, causing race conditions. - Event recreation now happens only in _connect() when actually needed. - """ - # ensure only one session is running at a time to avoid race conditions - async with self._session_state.lock: - # if we are forcing a disconnect, reset the nesting counter - if force: - self._session_state.nesting_counter = 0 - - # otherwise decrement to check if we are done nesting - else: - self._session_state.nesting_counter = max( - 0, self._session_state.nesting_counter - 1 - ) - - # if we are still nested, return - if self._session_state.nesting_counter > 0: - return - - # stop the active session - if self._session_state.session_task is None: - return - self._session_state.stop_event.set() - # wait for session to finish to ensure state has been reset - await self._session_state.session_task - self._session_state.session_task = None - - async def _session_runner(self): - """ - Background task that manages the actual session lifecycle. - - This task runs in the background and: - 1. Establishes the transport connection via _context_manager() - 2. Signals that the session is ready via _ready_event.set() - 3. Waits for disconnect signal via _stop_event.wait() - 4. Ensures _ready_event is always set, even on failures - - The simplified error handling (compared to the original) removes - redundant exception re-raising while ensuring waiting tasks are - always unblocked via the finally block. - """ - try: - async with AsyncExitStack() as stack: - await stack.enter_async_context(self._context_manager()) - # Session/context is now ready - self._session_state.ready_event.set() - # Wait until disconnect/stop is requested - await self._session_state.stop_event.wait() - finally: - # Ensure ready event is set even if context manager entry fails - self._session_state.ready_event.set() - - async def _await_with_session_monitoring( - self, coro: Coroutine[Any, Any, ResultT] - ) -> ResultT: - """Await a coroutine while monitoring the session task for errors. - - When using HTTP transports, server errors (4xx/5xx) are raised in the - background session task, not in the coroutine waiting for a response. - This causes the client to hang indefinitely since the response never - arrives. This method monitors the session task and propagates any - exceptions that occur, preventing the client from hanging. - - Args: - coro: The coroutine to await (typically a session method call) - - Returns: - The result of the coroutine - - Raises: - The exception from the session task if it fails, or RuntimeError - if the session task completes unexpectedly without an exception. - """ - session_task = self._session_state.session_task - - # If no session task, just await directly - if session_task is None: - return await coro - - # If session task already failed, raise immediately - if session_task.done(): - # Close the coroutine to avoid "was never awaited" warning - coro.close() - exc = session_task.exception() - if exc: - raise exc - raise RuntimeError("Session task completed unexpectedly") - - # Create task for our call - call_task = asyncio.create_task(coro) - - try: - done, _ = await asyncio.wait( - {call_task, session_task}, - return_when=asyncio.FIRST_COMPLETED, - ) - - if session_task in done: - # Session task completed (likely errored) before our call finished - call_task.cancel() - with anyio.CancelScope(shield=True), suppress(asyncio.CancelledError): - await call_task - - # Raise the session task exception - exc = session_task.exception() - if exc: - raise exc - raise RuntimeError("Session task completed unexpectedly") - - # Our call completed first - get the result - return call_task.result() - except asyncio.CancelledError: - call_task.cancel() - with anyio.CancelScope(shield=True), suppress(asyncio.CancelledError): - await call_task - raise - - def _handle_task_status_notification( - self, notification: TaskStatusNotification - ) -> None: - """Route task status notification to appropriate Task object. - - Called when notifications/tasks/status is received from server. - Updates Task object's cache and triggers events/callbacks. - """ - # Extract task ID from notification params - task_id = notification.params.taskId - if not task_id: - return - - # Look up task in registry (weakref) - task_ref = self._task_registry.get(task_id) - if task_ref: - task = task_ref() # Dereference weakref - if task: - # Convert notification params to GetTaskResult (they share the same fields via Task) - status = GetTaskResult.model_validate(notification.params.model_dump()) - task._handle_status_notification(status) - - async def close(self): - await self._disconnect(force=True) - await self.transport.close() - - # --- MCP Client Methods --- - - async def ping(self) -> bool: - """Send a ping request.""" - result = await self._await_with_session_monitoring(self.session.send_ping()) - return isinstance(result, mcp.types.EmptyResult) - - async def cancel( - self, - request_id: str | int, - reason: str | None = None, - ) -> None: - """Send a cancellation notification for an in-progress request.""" - notification = mcp.types.ClientNotification( - root=mcp.types.CancelledNotification( - method="notifications/cancelled", - params=mcp.types.CancelledNotificationParams( - requestId=request_id, - reason=reason, - ), - ) - ) - await self.session.send_notification(notification) - - async def progress( - self, - progress_token: str | int, - progress: float, - total: float | None = None, - message: str | None = None, - ) -> None: - """Send a progress notification.""" - await self.session.send_progress_notification( - progress_token, progress, total, message - ) - - async def set_logging_level(self, level: mcp.types.LoggingLevel) -> None: - """Send a logging/setLevel request.""" - await self._await_with_session_monitoring(self.session.set_logging_level(level)) - - async def send_roots_list_changed(self) -> None: - """Send a roots/list_changed notification.""" - await self.session.send_roots_list_changed() - - # --- Completion --- - - async def complete_mcp( - self, - ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, - argument: dict[str, str], - context_arguments: dict[str, Any] | None = None, - ) -> mcp.types.CompleteResult: - """Send a completion request and return the complete MCP protocol result. - - Args: - ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete. - argument (dict[str, str]): Arguments to pass to the completion request. - context_arguments (dict[str, Any] | None, optional): Optional context arguments to - include with the completion request. Defaults to None. - - Returns: - mcp.types.CompleteResult: The complete response object from the protocol, - containing the completion and any additional metadata. - - Raises: - RuntimeError: If called while the client is not connected. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - logger.debug(f"[{self.name}] called complete: {ref}") - - result = await self._await_with_session_monitoring( - self.session.complete( - ref=ref, argument=argument, context_arguments=context_arguments - ) - ) - return result - - async def complete( - self, - ref: mcp.types.ResourceTemplateReference | mcp.types.PromptReference, - argument: dict[str, str], - context_arguments: dict[str, Any] | None = None, - ) -> mcp.types.Completion: - """Send a completion request to the server. - - Args: - ref (mcp.types.ResourceTemplateReference | mcp.types.PromptReference): The reference to complete. - argument (dict[str, str]): Arguments to pass to the completion request. - context_arguments (dict[str, Any] | None, optional): Optional context arguments to - include with the completion request. Defaults to None. - - Returns: - mcp.types.Completion: The completion object. - - Raises: - RuntimeError: If called while the client is not connected. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - result = await self.complete_mcp( - ref=ref, argument=argument, context_arguments=context_arguments - ) - return result.completion - - @classmethod - def generate_name(cls, name: str | None = None) -> str: - class_name = cls.__name__ - if name is None: - return f"{class_name}-{secrets.token_hex(2)}" - else: - return f"{class_name}-{name}-{secrets.token_hex(2)}" diff --git a/src/fastmcp/client/elicitation.py b/src/fastmcp/client/elicitation.py deleted file mode 100644 index 60545a744..000000000 --- a/src/fastmcp/client/elicitation.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from typing import Any, Generic, TypeAlias - -import mcp.types -from mcp import ClientSession -from mcp.client.session import ElicitationFnT -from mcp.shared.context import LifespanContextT, RequestContext -from mcp.types import ElicitRequestFormParams, ElicitRequestParams -from mcp.types import ElicitResult as MCPElicitResult -from pydantic_core import to_jsonable_python -from typing_extensions import TypeVar - -from fastmcp.utilities.json_schema_type import json_schema_to_type - -__all__ = ["ElicitRequestParams", "ElicitResult", "ElicitationHandler"] - -T = TypeVar("T", default=Any) - - -class ElicitResult(MCPElicitResult, Generic[T]): - content: T | None = None - - -ElicitationHandler: TypeAlias = Callable[ - [ - str, # message - type[T] - | None, # a class for creating a structured response (None for URL elicitation) - ElicitRequestParams, - RequestContext[ClientSession, LifespanContextT], - ], - Awaitable[T | dict[str, Any] | ElicitResult[T | dict[str, Any]]], -] - - -def create_elicitation_callback( - elicitation_handler: ElicitationHandler, -) -> ElicitationFnT: - async def _elicitation_handler( - context: RequestContext[ClientSession, LifespanContextT], - params: ElicitRequestParams, - ) -> MCPElicitResult | mcp.types.ErrorData: - try: - # requestedSchema only exists on ElicitRequestFormParams, not ElicitRequestURLParams - if isinstance(params, ElicitRequestFormParams): - if params.requestedSchema == {"type": "object", "properties": {}}: - response_type = None - else: - response_type = json_schema_to_type(params.requestedSchema) - else: - # URL-based elicitation doesn't have a schema - response_type = None - - result = await elicitation_handler( - params.message, response_type, params, context - ) - # if the user returns data, we assume they've accepted the elicitation - if not isinstance(result, ElicitResult): - result = ElicitResult(action="accept", content=result) - content = to_jsonable_python(result.content) - if not isinstance(content, dict | None): - raise ValueError( - "Elicitation responses must be serializable as a JSON object (dict). Received: " - f"{result.content!r}" - ) - return MCPElicitResult( - _meta=result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] - action=result.action, - content=content, - ) - - except Exception as e: - return mcp.types.ErrorData( - code=mcp.types.INTERNAL_ERROR, - message=str(e), - ) - - return _elicitation_handler diff --git a/src/fastmcp/client/logging.py b/src/fastmcp/client/logging.py deleted file mode 100644 index 2a8dd04e9..000000000 --- a/src/fastmcp/client/logging.py +++ /dev/null @@ -1,54 +0,0 @@ -from collections.abc import Awaitable, Callable -from logging import Logger -from typing import TypeAlias - -from mcp.client.session import LoggingFnT -from mcp.types import LoggingMessageNotificationParams - -from fastmcp.utilities.logging import get_logger - -logger: Logger = get_logger(name=__name__) -from_server_logger: Logger = get_logger(name="fastmcp.client.from_server") - -LogMessage: TypeAlias = LoggingMessageNotificationParams -LogHandler: TypeAlias = Callable[[LogMessage], Awaitable[None]] - - -async def default_log_handler(message: LogMessage) -> None: - """Default handler that properly routes server log messages to appropriate log levels.""" - # data can be any JSON-serializable type, not just a dict - data = message.data - - # Map MCP log levels to Python logging levels - level_map = { - "debug": from_server_logger.debug, - "info": from_server_logger.info, - "notice": from_server_logger.info, # Python doesn't have 'notice', map to info - "warning": from_server_logger.warning, - "error": from_server_logger.error, - "critical": from_server_logger.critical, - "alert": from_server_logger.critical, # Map alert to critical - "emergency": from_server_logger.critical, # Map emergency to critical - } - - # Get the appropriate logging function based on the message level - log_fn = level_map.get(message.level.lower(), logger.info) - - # Include logger name if available - msg_prefix: str = f"Received {message.level.upper()} from server" - - if message.logger: - msg_prefix += f" ({message.logger})" - - # Log with appropriate level and data - log_fn(msg=f"{msg_prefix}: {data}") - - -def create_log_callback(handler: LogHandler | None = None) -> LoggingFnT: - if handler is None: - handler = default_log_handler - - async def log_callback(params: LoggingMessageNotificationParams) -> None: - await handler(params) - - return log_callback diff --git a/src/fastmcp/client/messages.py b/src/fastmcp/client/messages.py deleted file mode 100644 index 361dfe0bf..000000000 --- a/src/fastmcp/client/messages.py +++ /dev/null @@ -1,128 +0,0 @@ -from typing import TypeAlias - -import mcp.types -from mcp.client.session import MessageHandlerFnT -from mcp.shared.session import RequestResponder - -Message: TypeAlias = ( - RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult] - | mcp.types.ServerNotification - | Exception -) - -MessageHandlerT: TypeAlias = MessageHandlerFnT - - -class MessageHandler: - """ - This class is used to handle MCP messages sent to the client. It is used to handle all messages, - requests, notifications, and exceptions. Users can override any of the hooks - """ - - async def __call__( - self, - message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult] - | mcp.types.ServerNotification - | Exception, - ) -> None: - return await self.dispatch(message) - - async def dispatch(self, message: Message) -> None: - # handle all messages - await self.on_message(message) - - match message: - # requests - case RequestResponder(): - # handle all requests - # TODO(ty): remove when ty supports match statement narrowing - await self.on_request(message) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - - # handle specific requests - # TODO(ty): remove type ignores when ty supports match statement narrowing - match message.request.root: # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - case mcp.types.PingRequest(): - await self.on_ping(message.request.root) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - case mcp.types.ListRootsRequest(): - await self.on_list_roots(message.request.root) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - case mcp.types.CreateMessageRequest(): - await self.on_create_message(message.request.root) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - - # notifications - case mcp.types.ServerNotification(): - # handle all notifications - await self.on_notification(message) - - # handle specific notifications - match message.root: - case mcp.types.CancelledNotification(): - await self.on_cancelled(message.root) - case mcp.types.ProgressNotification(): - await self.on_progress(message.root) - case mcp.types.LoggingMessageNotification(): - await self.on_logging_message(message.root) - case mcp.types.ToolListChangedNotification(): - await self.on_tool_list_changed(message.root) - case mcp.types.ResourceListChangedNotification(): - await self.on_resource_list_changed(message.root) - case mcp.types.PromptListChangedNotification(): - await self.on_prompt_list_changed(message.root) - case mcp.types.ResourceUpdatedNotification(): - await self.on_resource_updated(message.root) - - case Exception(): - await self.on_exception(message) - - async def on_message(self, message: Message) -> None: - pass - - async def on_request( - self, message: RequestResponder[mcp.types.ServerRequest, mcp.types.ClientResult] - ) -> None: - pass - - async def on_ping(self, message: mcp.types.PingRequest) -> None: - pass - - async def on_list_roots(self, message: mcp.types.ListRootsRequest) -> None: - pass - - async def on_create_message(self, message: mcp.types.CreateMessageRequest) -> None: - pass - - async def on_notification(self, message: mcp.types.ServerNotification) -> None: - pass - - async def on_exception(self, message: Exception) -> None: - pass - - async def on_progress(self, message: mcp.types.ProgressNotification) -> None: - pass - - async def on_logging_message( - self, message: mcp.types.LoggingMessageNotification - ) -> None: - pass - - async def on_tool_list_changed( - self, message: mcp.types.ToolListChangedNotification - ) -> None: - pass - - async def on_resource_list_changed( - self, message: mcp.types.ResourceListChangedNotification - ) -> None: - pass - - async def on_prompt_list_changed( - self, message: mcp.types.PromptListChangedNotification - ) -> None: - pass - - async def on_resource_updated( - self, message: mcp.types.ResourceUpdatedNotification - ) -> None: - pass - - async def on_cancelled(self, message: mcp.types.CancelledNotification) -> None: - pass diff --git a/src/fastmcp/client/mixins/__init__.py b/src/fastmcp/client/mixins/__init__.py deleted file mode 100644 index 323e20991..000000000 --- a/src/fastmcp/client/mixins/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Client mixins for FastMCP.""" - -from fastmcp.client.mixins.prompts import ClientPromptsMixin -from fastmcp.client.mixins.resources import ClientResourcesMixin -from fastmcp.client.mixins.task_management import ClientTaskManagementMixin -from fastmcp.client.mixins.tools import ClientToolsMixin - -__all__ = [ - "ClientPromptsMixin", - "ClientResourcesMixin", - "ClientTaskManagementMixin", - "ClientToolsMixin", -] diff --git a/src/fastmcp/client/mixins/prompts.py b/src/fastmcp/client/mixins/prompts.py deleted file mode 100644 index 4b87bf270..000000000 --- a/src/fastmcp/client/mixins/prompts.py +++ /dev/null @@ -1,318 +0,0 @@ -"""Prompt-related methods for FastMCP Client.""" - -from __future__ import annotations - -import uuid -import weakref -from typing import TYPE_CHECKING, Any, Literal, overload - -import mcp.types -import pydantic_core -from pydantic import RootModel - -if TYPE_CHECKING: - from fastmcp.client.client import Client - -from fastmcp.client.tasks import PromptTask -from fastmcp.client.telemetry import client_span -from fastmcp.telemetry import inject_trace_context -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - -AUTO_PAGINATION_MAX_PAGES = 250 - -# Type alias for task response union (SEP-1686 graceful degradation) -PromptTaskResponseUnion = RootModel[ - mcp.types.CreateTaskResult | mcp.types.GetPromptResult -] - - -class ClientPromptsMixin: - """Mixin providing prompt-related methods for Client.""" - - # --- Prompts --- - - async def list_prompts_mcp( - self: Client, *, cursor: str | None = None - ) -> mcp.types.ListPromptsResult: - """Send a prompts/list request and return the complete MCP protocol result. - - Args: - cursor: Optional pagination cursor from a previous request's nextCursor. - - Returns: - mcp.types.ListPromptsResult: The complete response object from the protocol, - containing the list of prompts and any additional metadata. - - Raises: - RuntimeError: If called while the client is not connected. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - logger.debug(f"[{self.name}] called list_prompts") - - result = await self._await_with_session_monitoring( - self.session.list_prompts(cursor=cursor) - ) - return result - - async def list_prompts( - self: Client, - max_pages: int = AUTO_PAGINATION_MAX_PAGES, - ) -> list[mcp.types.Prompt]: - """Retrieve all prompts available on the server. - - This method automatically fetches all pages if the server paginates results, - returning the complete list. For manual pagination control (e.g., to handle - large result sets incrementally), use list_prompts_mcp() with the cursor parameter. - - Args: - max_pages: Maximum number of pages to fetch before raising. Defaults to 250. - - Returns: - list[mcp.types.Prompt]: A list of all Prompt objects. - - Raises: - RuntimeError: If the page limit is reached before pagination completes. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - all_prompts: list[mcp.types.Prompt] = [] - cursor: str | None = None - seen_cursors: set[str] = set() - - for _ in range(max_pages): - result = await self.list_prompts_mcp(cursor=cursor) - all_prompts.extend(result.prompts) - if not result.nextCursor: - break - if result.nextCursor in seen_cursors: - logger.warning( - f"[{self.name}] Server returned duplicate pagination cursor" - f" {result.nextCursor!r} for list_prompts; stopping pagination" - ) - break - seen_cursors.add(result.nextCursor) - cursor = result.nextCursor - else: - raise RuntimeError( - f"[{self.name}] Reached auto-pagination limit" - f" ({max_pages} pages) for list_prompts." - " Use list_prompts_mcp() with cursor for manual pagination," - " or increase max_pages." - ) - - return all_prompts - - # --- Prompt --- - async def get_prompt_mcp( - self: Client, - name: str, - arguments: dict[str, Any] | None = None, - meta: dict[str, Any] | None = None, - ) -> mcp.types.GetPromptResult: - """Send a prompts/get request and return the complete MCP protocol result. - - Args: - name (str): The name of the prompt to retrieve. - arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None. - meta (dict[str, Any] | None, optional): Request metadata (e.g., for SEP-1686 tasks). Defaults to None. - - Returns: - mcp.types.GetPromptResult: The complete response object from the protocol, - containing the prompt messages and any additional metadata. - - Raises: - RuntimeError: If called while the client is not connected. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - with client_span( - f"prompts/get {name}", - "prompts/get", - name, - session_id=self.transport.get_session_id(), - ): - logger.debug(f"[{self.name}] called get_prompt: {name}") - - # Serialize arguments for MCP protocol - convert non-string values to JSON - serialized_arguments: dict[str, str] | None = None - if arguments: - serialized_arguments = {} - for key, value in arguments.items(): - if isinstance(value, str): - serialized_arguments[key] = value - else: - # Use pydantic_core.to_json for consistent serialization - serialized_arguments[key] = pydantic_core.to_json(value).decode( - "utf-8" - ) - - # Inject trace context into meta for propagation to server - propagated_meta = inject_trace_context(meta) - - # If meta provided, use send_request for SEP-1686 task support - if propagated_meta: - task_dict = propagated_meta.get("modelcontextprotocol.io/task") - request = mcp.types.GetPromptRequest( - params=mcp.types.GetPromptRequestParams( - name=name, - arguments=serialized_arguments, - task=mcp.types.TaskMetadata(**task_dict) if task_dict else None, - _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument] - ) - ) - result = await self._await_with_session_monitoring( - self.session.send_request( - request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - result_type=mcp.types.GetPromptResult, - ) - ) - else: - result = await self._await_with_session_monitoring( - self.session.get_prompt(name=name, arguments=serialized_arguments) - ) - return result - - @overload - async def get_prompt( - self: Client, - name: str, - arguments: dict[str, Any] | None = None, - *, - version: str | None = None, - meta: dict[str, Any] | None = None, - task: Literal[False] = False, - ) -> mcp.types.GetPromptResult: ... - - @overload - async def get_prompt( - self: Client, - name: str, - arguments: dict[str, Any] | None = None, - *, - version: str | None = None, - meta: dict[str, Any] | None = None, - task: Literal[True], - task_id: str | None = None, - ttl: int = 60000, - ) -> PromptTask: ... - - async def get_prompt( - self: Client, - name: str, - arguments: dict[str, Any] | None = None, - *, - version: str | None = None, - meta: dict[str, Any] | None = None, - task: bool = False, - task_id: str | None = None, - ttl: int = 60000, - ) -> mcp.types.GetPromptResult | PromptTask: - """Retrieve a rendered prompt message list from the server. - - Args: - name (str): The name of the prompt to retrieve. - arguments (dict[str, Any] | None, optional): Arguments to pass to the prompt. Defaults to None. - version (str | None, optional): Specific prompt version to get. If None, gets highest version. - meta (dict[str, Any] | None): Optional request-level metadata. - task (bool): If True, execute as background task (SEP-1686). Defaults to False. - task_id (str | None): Optional client-provided task ID (auto-generated if not provided). - ttl (int): Time to keep results available in milliseconds (default 60s). - - Returns: - mcp.types.GetPromptResult | PromptTask: The complete response object if task=False, - or a PromptTask object if task=True. - - Raises: - RuntimeError: If called while the client is not connected. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - # Merge version into request-level meta (not arguments) - request_meta = dict(meta) if meta else {} - if version is not None: - request_meta["fastmcp"] = { - **request_meta.get("fastmcp", {}), - "version": version, - } - - if task: - return await self._get_prompt_as_task( - name, arguments, task_id, ttl, meta=request_meta or None - ) - - result = await self.get_prompt_mcp( - name=name, arguments=arguments, meta=request_meta or None - ) - return result - - async def _get_prompt_as_task( - self: Client, - name: str, - arguments: dict[str, Any] | None = None, - task_id: str | None = None, - ttl: int = 60000, - meta: dict[str, Any] | None = None, - ) -> PromptTask: - """Get a prompt for background execution (SEP-1686). - - Returns a PromptTask object that handles both background and immediate execution. - - Args: - name: Prompt name to get - arguments: Prompt arguments - task_id: Optional client-provided task ID (ignored, for backward compatibility) - ttl: Time to keep results available in milliseconds (default 60s) - meta: Optional request metadata (e.g., version info) - - Returns: - PromptTask: Future-like object for accessing task status and results - """ - # Per SEP-1686 final spec: client sends only ttl, server generates taskId - # Inject trace context into meta for propagation to server - propagated_meta = inject_trace_context(meta) - - # Serialize arguments for MCP protocol - serialized_arguments: dict[str, str] | None = None - if arguments: - serialized_arguments = {} - for key, value in arguments.items(): - if isinstance(value, str): - serialized_arguments[key] = value - else: - serialized_arguments[key] = pydantic_core.to_json(value).decode( - "utf-8" - ) - - request = mcp.types.GetPromptRequest( - params=mcp.types.GetPromptRequestParams( - name=name, - arguments=serialized_arguments, - task=mcp.types.TaskMetadata(ttl=ttl), - _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument] - ) - ) - - # Server returns CreateTaskResult (task accepted) or GetPromptResult (graceful degradation) - wrapped_result = await self._await_with_session_monitoring( - self.session.send_request( - request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - result_type=PromptTaskResponseUnion, - ) - ) - raw_result = wrapped_result.root - - if isinstance(raw_result, mcp.types.CreateTaskResult): - # Task was accepted - extract task info from CreateTaskResult - server_task_id = raw_result.task.taskId - self._submitted_task_ids.add(server_task_id) - - task_obj = PromptTask( - self, server_task_id, prompt_name=name, immediate_result=None - ) - self._task_registry[server_task_id] = weakref.ref(task_obj) - return task_obj - else: - # Graceful degradation - server returned GetPromptResult - synthetic_task_id = task_id or str(uuid.uuid4()) - return PromptTask( - self, synthetic_task_id, prompt_name=name, immediate_result=raw_result - ) diff --git a/src/fastmcp/client/mixins/resources.py b/src/fastmcp/client/mixins/resources.py deleted file mode 100644 index c0dc27fff..000000000 --- a/src/fastmcp/client/mixins/resources.py +++ /dev/null @@ -1,370 +0,0 @@ -"""Resource-related methods for FastMCP Client.""" - -from __future__ import annotations - -import uuid -import weakref -from typing import TYPE_CHECKING, Any, Literal, overload - -import mcp.types -from pydantic import AnyUrl, RootModel - -if TYPE_CHECKING: - from fastmcp.client.client import Client - -from fastmcp.client.tasks import ResourceTask -from fastmcp.client.telemetry import client_span -from fastmcp.telemetry import inject_trace_context -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - -AUTO_PAGINATION_MAX_PAGES = 250 - -# Type alias for task response union (SEP-1686 graceful degradation) -ResourceTaskResponseUnion = RootModel[ - mcp.types.CreateTaskResult | mcp.types.ReadResourceResult -] - - -class ClientResourcesMixin: - """Mixin providing resource-related methods for Client.""" - - # --- Resources --- - - async def list_resources_mcp( - self: Client, *, cursor: str | None = None - ) -> mcp.types.ListResourcesResult: - """Send a resources/list request and return the complete MCP protocol result. - - Args: - cursor: Optional pagination cursor from a previous request's nextCursor. - - Returns: - mcp.types.ListResourcesResult: The complete response object from the protocol, - containing the list of resources and any additional metadata. - - Raises: - RuntimeError: If called while the client is not connected. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - logger.debug(f"[{self.name}] called list_resources") - - result = await self._await_with_session_monitoring( - self.session.list_resources(cursor=cursor) - ) - return result - - async def list_resources( - self: Client, - max_pages: int = AUTO_PAGINATION_MAX_PAGES, - ) -> list[mcp.types.Resource]: - """Retrieve all resources available on the server. - - This method automatically fetches all pages if the server paginates results, - returning the complete list. For manual pagination control (e.g., to handle - large result sets incrementally), use list_resources_mcp() with the cursor parameter. - - Args: - max_pages: Maximum number of pages to fetch before raising. Defaults to 250. - - Returns: - list[mcp.types.Resource]: A list of all Resource objects. - - Raises: - RuntimeError: If the page limit is reached before pagination completes. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - all_resources: list[mcp.types.Resource] = [] - cursor: str | None = None - seen_cursors: set[str] = set() - - for _ in range(max_pages): - result = await self.list_resources_mcp(cursor=cursor) - all_resources.extend(result.resources) - if not result.nextCursor: - break - if result.nextCursor in seen_cursors: - logger.warning( - f"[{self.name}] Server returned duplicate pagination cursor" - f" {result.nextCursor!r} for list_resources; stopping pagination" - ) - break - seen_cursors.add(result.nextCursor) - cursor = result.nextCursor - else: - raise RuntimeError( - f"[{self.name}] Reached auto-pagination limit" - f" ({max_pages} pages) for list_resources." - " Use list_resources_mcp() with cursor for manual pagination," - " or increase max_pages." - ) - - return all_resources - - async def list_resource_templates_mcp( - self: Client, *, cursor: str | None = None - ) -> mcp.types.ListResourceTemplatesResult: - """Send a resources/listResourceTemplates request and return the complete MCP protocol result. - - Args: - cursor: Optional pagination cursor from a previous request's nextCursor. - - Returns: - mcp.types.ListResourceTemplatesResult: The complete response object from the protocol, - containing the list of resource templates and any additional metadata. - - Raises: - RuntimeError: If called while the client is not connected. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - logger.debug(f"[{self.name}] called list_resource_templates") - - result = await self._await_with_session_monitoring( - self.session.list_resource_templates(cursor=cursor) - ) - return result - - async def list_resource_templates( - self: Client, - max_pages: int = AUTO_PAGINATION_MAX_PAGES, - ) -> list[mcp.types.ResourceTemplate]: - """Retrieve all resource templates available on the server. - - This method automatically fetches all pages if the server paginates results, - returning the complete list. For manual pagination control (e.g., to handle - large result sets incrementally), use list_resource_templates_mcp() with the - cursor parameter. - - Args: - max_pages: Maximum number of pages to fetch before raising. Defaults to 250. - - Returns: - list[mcp.types.ResourceTemplate]: A list of all ResourceTemplate objects. - - Raises: - RuntimeError: If the page limit is reached before pagination completes. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - all_templates: list[mcp.types.ResourceTemplate] = [] - cursor: str | None = None - seen_cursors: set[str] = set() - - for _ in range(max_pages): - result = await self.list_resource_templates_mcp(cursor=cursor) - all_templates.extend(result.resourceTemplates) - if not result.nextCursor: - break - if result.nextCursor in seen_cursors: - logger.warning( - f"[{self.name}] Server returned duplicate pagination cursor" - f" {result.nextCursor!r} for list_resource_templates;" - " stopping pagination" - ) - break - seen_cursors.add(result.nextCursor) - cursor = result.nextCursor - else: - raise RuntimeError( - f"[{self.name}] Reached auto-pagination limit" - f" ({max_pages} pages) for list_resource_templates." - " Use list_resource_templates_mcp() with cursor for manual pagination," - " or increase max_pages." - ) - - return all_templates - - async def read_resource_mcp( - self: Client, uri: AnyUrl | str, meta: dict[str, Any] | None = None - ) -> mcp.types.ReadResourceResult: - """Send a resources/read request and return the complete MCP protocol result. - - Args: - uri (AnyUrl | str): The URI of the resource to read. Can be a string or an AnyUrl object. - meta (dict[str, Any] | None, optional): Request metadata (e.g., for SEP-1686 tasks). Defaults to None. - - Returns: - mcp.types.ReadResourceResult: The complete response object from the protocol, - containing the resource contents and any additional metadata. - - Raises: - RuntimeError: If called while the client is not connected. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - uri_str = str(uri) - with client_span( - f"resources/read {uri_str}", - "resources/read", - uri_str, - session_id=self.transport.get_session_id(), - resource_uri=uri_str, - ): - logger.debug(f"[{self.name}] called read_resource: {uri}") - - if isinstance(uri, str): - uri = AnyUrl(uri) # Ensure AnyUrl - - # Inject trace context into meta for propagation to server - propagated_meta = inject_trace_context(meta) - - # If meta provided, use send_request for SEP-1686 task support - if propagated_meta: - task_dict = propagated_meta.get("modelcontextprotocol.io/task") - request = mcp.types.ReadResourceRequest( - params=mcp.types.ReadResourceRequestParams( - uri=uri, - task=mcp.types.TaskMetadata(**task_dict) if task_dict else None, - _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument] - ) - ) - result = await self._await_with_session_monitoring( - self.session.send_request( - request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - result_type=mcp.types.ReadResourceResult, - ) - ) - else: - result = await self._await_with_session_monitoring( - self.session.read_resource(uri) - ) - return result - - @overload - async def read_resource( - self: Client, - uri: AnyUrl | str, - *, - version: str | None = None, - meta: dict[str, Any] | None = None, - task: Literal[False] = False, - ) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]: ... - - @overload - async def read_resource( - self: Client, - uri: AnyUrl | str, - *, - version: str | None = None, - meta: dict[str, Any] | None = None, - task: Literal[True], - task_id: str | None = None, - ttl: int = 60000, - ) -> ResourceTask: ... - - async def read_resource( - self: Client, - uri: AnyUrl | str, - *, - version: str | None = None, - meta: dict[str, Any] | None = None, - task: bool = False, - task_id: str | None = None, - ttl: int = 60000, - ) -> ( - list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] - | ResourceTask - ): - """Read the contents of a resource or resolved template. - - Args: - uri (AnyUrl | str): The URI of the resource to read. Can be a string or an AnyUrl object. - version (str | None): Specific version to read. If None, reads highest version. - meta (dict[str, Any] | None): Optional request-level metadata. - task (bool): If True, execute as background task (SEP-1686). Defaults to False. - task_id (str | None): Optional client-provided task ID (auto-generated if not provided). - ttl (int): Time to keep results available in milliseconds (default 60s). - - Returns: - list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents] | ResourceTask: - A list of content objects if task=False, or a ResourceTask object if task=True. - - Raises: - RuntimeError: If called while the client is not connected. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - # Merge version into request-level meta (not arguments) - request_meta = dict(meta) if meta else {} - if version is not None: - request_meta["fastmcp"] = { - **request_meta.get("fastmcp", {}), - "version": version, - } - - if task: - return await self._read_resource_as_task( - uri, task_id, ttl, meta=request_meta or None - ) - - if isinstance(uri, str): - try: - uri = AnyUrl(uri) # Ensure AnyUrl - except Exception as e: - raise ValueError( - f"Provided resource URI is invalid: {str(uri)!r}" - ) from e - result = await self.read_resource_mcp(uri, meta=request_meta or None) - return result.contents - - async def _read_resource_as_task( - self: Client, - uri: AnyUrl | str, - task_id: str | None = None, - ttl: int = 60000, - meta: dict[str, Any] | None = None, - ) -> ResourceTask: - """Read a resource for background execution (SEP-1686). - - Returns a ResourceTask object that handles both background and immediate execution. - - Args: - uri: Resource URI to read - task_id: Optional client-provided task ID (ignored, for backward compatibility) - ttl: Time to keep results available in milliseconds (default 60s) - meta: Optional metadata to pass with the request (e.g., version info) - - Returns: - ResourceTask: Future-like object for accessing task status and results - """ - # Per SEP-1686 final spec: client sends only ttl, server generates taskId - # Inject trace context into meta for propagation to server - propagated_meta = inject_trace_context(meta) - - if isinstance(uri, str): - uri = AnyUrl(uri) - - request = mcp.types.ReadResourceRequest( - params=mcp.types.ReadResourceRequestParams( - uri=uri, - task=mcp.types.TaskMetadata(ttl=ttl), - _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument] - ) - ) - - # Server returns CreateTaskResult (task accepted) or ReadResourceResult (graceful degradation) - wrapped_result = await self._await_with_session_monitoring( - self.session.send_request( - request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - result_type=ResourceTaskResponseUnion, - ) - ) - raw_result = wrapped_result.root - - if isinstance(raw_result, mcp.types.CreateTaskResult): - # Task was accepted - extract task info from CreateTaskResult - server_task_id = raw_result.task.taskId - self._submitted_task_ids.add(server_task_id) - - task_obj = ResourceTask( - self, server_task_id, uri=str(uri), immediate_result=None - ) - self._task_registry[server_task_id] = weakref.ref(task_obj) - return task_obj - else: - # Graceful degradation - server returned ReadResourceResult - synthetic_task_id = task_id or str(uuid.uuid4()) - return ResourceTask( - self, - synthetic_task_id, - uri=str(uri), - immediate_result=raw_result.contents, - ) diff --git a/src/fastmcp/client/mixins/task_management.py b/src/fastmcp/client/mixins/task_management.py deleted file mode 100644 index 40594bb4a..000000000 --- a/src/fastmcp/client/mixins/task_management.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Task management methods for FastMCP Client.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -import mcp.types -from mcp import McpError - -if TYPE_CHECKING: - from fastmcp.client.client import Client -from mcp.types import ( - CancelTaskRequest, - CancelTaskRequestParams, - GetTaskPayloadRequest, - GetTaskPayloadRequestParams, - GetTaskPayloadResult, - GetTaskRequest, - GetTaskRequestParams, - GetTaskResult, - ListTasksRequest, - PaginatedRequestParams, -) - -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class ClientTaskManagementMixin: - """Mixin providing task management methods for Client.""" - - async def get_task_status(self: Client, task_id: str) -> GetTaskResult: - """Query the status of a background task. - - Sends a 'tasks/get' MCP protocol request over the existing transport. - - Args: - task_id: The task ID returned from call_tool_as_task - - Returns: - GetTaskResult: Status information including taskId, status, pollInterval, etc. - - Raises: - RuntimeError: If client not connected - McpError: If the request results in a TimeoutError | JSONRPCError - """ - request = GetTaskRequest(params=GetTaskRequestParams(taskId=task_id)) - return await self._await_with_session_monitoring( - self.session.send_request( - request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - result_type=GetTaskResult, - ) - ) - - async def get_task_result(self: Client, task_id: str) -> Any: - """Retrieve the raw result of a completed background task. - - Sends a 'tasks/result' MCP protocol request over the existing transport. - Returns the raw result - callers should parse it appropriately. - - Args: - task_id: The task ID returned from call_tool_as_task - - Returns: - Any: The raw result (could be tool, prompt, or resource result) - - Raises: - RuntimeError: If client not connected, task not found, or task failed - McpError: If the request results in a TimeoutError | JSONRPCError - """ - request = GetTaskPayloadRequest( - params=GetTaskPayloadRequestParams(taskId=task_id) - ) - # Return raw result - Task classes handle type-specific parsing - result = await self._await_with_session_monitoring( - self.session.send_request( - request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - result_type=GetTaskPayloadResult, - ) - ) - # Return as dict for compatibility with Task class parsing - return result.model_dump(exclude_none=True, by_alias=True) - - async def list_tasks( - self: Client, - cursor: str | None = None, - limit: int = 50, - ) -> dict[str, Any]: - """List background tasks. - - Sends a 'tasks/list' MCP protocol request to the server. If the server - returns an empty list (indicating client-side tracking), falls back to - querying status for locally tracked task IDs. - - Args: - cursor: Optional pagination cursor - limit: Maximum number of tasks to return (default 50) - - Returns: - dict: Response with structure: - - tasks: List of task status dicts with taskId, status, etc. - - nextCursor: Optional cursor for next page - - Raises: - RuntimeError: If client not connected - McpError: If the request results in a TimeoutError | JSONRPCError - """ - # Send protocol request - params = PaginatedRequestParams(cursor=cursor, limit=limit) # type: ignore[call-arg] # Optional field in MCP SDK # ty:ignore[unknown-argument] - request = ListTasksRequest(params=params) - server_response = await self._await_with_session_monitoring( - self.session.send_request( - request=request, # type: ignore[invalid-argument-type] # ty:ignore[invalid-argument-type] - result_type=mcp.types.ListTasksResult, - ) - ) - - # If server returned tasks, use those - if server_response.tasks: - return server_response.model_dump(by_alias=True) - - # Server returned empty - fall back to client-side tracking - tasks = [] - for task_id in list(self._submitted_task_ids)[:limit]: - try: - status = await self.get_task_status(task_id) - tasks.append(status.model_dump(by_alias=True)) - except McpError: - # Task may have expired or been deleted, skip it - continue - - return {"tasks": tasks, "nextCursor": None} - - async def cancel_task(self: Client, task_id: str) -> mcp.types.CancelTaskResult: - """Cancel a task, transitioning it to cancelled state. - - Sends a 'tasks/cancel' MCP protocol request. Task will halt execution - and transition to cancelled state. - - Args: - task_id: The task ID to cancel - - Returns: - CancelTaskResult: The task status showing cancelled state - - Raises: - RuntimeError: If task doesn't exist - McpError: If the request results in a TimeoutError | JSONRPCError - """ - request = CancelTaskRequest(params=CancelTaskRequestParams(taskId=task_id)) - return await self._await_with_session_monitoring( - self.session.send_request( - request=request, # type: ignore[invalid-argument-type] # ty:ignore[invalid-argument-type] - result_type=mcp.types.CancelTaskResult, - ) - ) diff --git a/src/fastmcp/client/mixins/tools.py b/src/fastmcp/client/mixins/tools.py deleted file mode 100644 index d6c37a5bd..000000000 --- a/src/fastmcp/client/mixins/tools.py +++ /dev/null @@ -1,441 +0,0 @@ -"""Tool-related methods for FastMCP Client.""" - -from __future__ import annotations - -import uuid -import weakref -from typing import TYPE_CHECKING, Any, Literal, cast, overload - -import mcp.types -from pydantic import RootModel - -if TYPE_CHECKING: - import datetime - - from fastmcp.client.client import CallToolResult, Client -from fastmcp.client.progress import ProgressHandler -from fastmcp.client.tasks import ToolTask -from fastmcp.client.telemetry import client_span -from fastmcp.exceptions import ToolError -from fastmcp.telemetry import inject_trace_context -from fastmcp.utilities.json_schema_type import json_schema_to_type -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.timeout import normalize_timeout_to_timedelta -from fastmcp.utilities.types import get_cached_typeadapter - -logger = get_logger(__name__) - -AUTO_PAGINATION_MAX_PAGES = 250 - -# Type alias for task response union (SEP-1686 graceful degradation) -ToolTaskResponseUnion = RootModel[mcp.types.CreateTaskResult | mcp.types.CallToolResult] - - -class ClientToolsMixin: - """Mixin providing tool-related methods for Client.""" - - # --- Tools --- - - async def list_tools_mcp( - self: Client, *, cursor: str | None = None - ) -> mcp.types.ListToolsResult: - """Send a tools/list request and return the complete MCP protocol result. - - Args: - cursor: Optional pagination cursor from a previous request's nextCursor. - - Returns: - mcp.types.ListToolsResult: The complete response object from the protocol, - containing the list of tools and any additional metadata. - - Raises: - RuntimeError: If called while the client is not connected. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - logger.debug(f"[{self.name}] called list_tools") - - result = await self._await_with_session_monitoring( - self.session.list_tools(cursor=cursor) - ) - return result - - async def list_tools( - self: Client, - max_pages: int = AUTO_PAGINATION_MAX_PAGES, - ) -> list[mcp.types.Tool]: - """Retrieve all tools available on the server. - - This method automatically fetches all pages if the server paginates results, - returning the complete list. For manual pagination control (e.g., to handle - large result sets incrementally), use list_tools_mcp() with the cursor parameter. - - Args: - max_pages: Maximum number of pages to fetch before raising. Defaults to 250. - - Returns: - list[mcp.types.Tool]: A list of all Tool objects. - - Raises: - RuntimeError: If the page limit is reached before pagination completes. - McpError: If the request results in a TimeoutError | JSONRPCError - """ - all_tools: list[mcp.types.Tool] = [] - cursor: str | None = None - seen_cursors: set[str] = set() - - for _ in range(max_pages): - result = await self.list_tools_mcp(cursor=cursor) - all_tools.extend(result.tools) - if not result.nextCursor: - break - if result.nextCursor in seen_cursors: - logger.warning( - f"[{self.name}] Server returned duplicate pagination cursor" - f" {result.nextCursor!r} for list_tools; stopping pagination" - ) - break - seen_cursors.add(result.nextCursor) - cursor = result.nextCursor - else: - raise RuntimeError( - f"[{self.name}] Reached auto-pagination limit" - f" ({max_pages} pages) for list_tools." - " Use list_tools_mcp() with cursor for manual pagination," - " or increase max_pages." - ) - - return all_tools - - # --- Call Tool --- - - async def call_tool_mcp( - self: Client, - name: str, - arguments: dict[str, Any], - progress_handler: ProgressHandler | None = None, - timeout: datetime.timedelta | float | int | None = None, - meta: dict[str, Any] | None = None, - ) -> mcp.types.CallToolResult: - """Send a tools/call request and return the complete MCP protocol result. - - This method returns the raw CallToolResult object, which includes an isError flag - and other metadata. It does not raise an exception if the tool call results in an error. - - Args: - name (str): The name of the tool to call. - arguments (dict[str, Any]): Arguments to pass to the tool. - timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None. - progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None. - meta (dict[str, Any] | None, optional): Additional metadata to include with the request. - This is useful for passing contextual information (like user IDs, trace IDs, or preferences) - that shouldn't be tool arguments but may influence server-side processing. The server - can access this via `context.request_context.meta`. Defaults to None. - - Returns: - mcp.types.CallToolResult: The complete response object from the protocol, - containing the tool result and any additional metadata. - - Raises: - RuntimeError: If called while the client is not connected. - McpError: If the tool call requests results in a TimeoutError | JSONRPCError - """ - with client_span( - f"tools/call {name}", - "tools/call", - name, - session_id=self.transport.get_session_id(), - ): - logger.debug(f"[{self.name}] called call_tool: {name}") - - # Inject trace context into meta for propagation to server - propagated_meta = inject_trace_context(meta) - - result = await self._await_with_session_monitoring( - self.session.call_tool( - name=name, - arguments=arguments, - read_timeout_seconds=normalize_timeout_to_timedelta(timeout), - progress_callback=progress_handler or self._progress_handler, - meta=propagated_meta if propagated_meta else None, - ) - ) - return result - - async def _parse_call_tool_result( - self: Client, - name: str, - result: mcp.types.CallToolResult, - raise_on_error: bool = False, - ) -> CallToolResult: - """Parse an mcp.types.CallToolResult into our CallToolResult dataclass. - - Args: - name: Tool name (for schema lookup) - result: Raw MCP protocol result - raise_on_error: Whether to raise ToolError on errors - - Returns: - CallToolResult: Parsed result with structured data - """ - - return await _parse_call_tool_result( - name=name, - result=result, - tool_output_schemas=self.session._tool_output_schemas, - list_tools_fn=self.session.list_tools, - client_name=self.name, - raise_on_error=raise_on_error, - ) - - @overload - async def call_tool( - self: Client, - name: str, - arguments: dict[str, Any] | None = None, - *, - version: str | None = None, - timeout: datetime.timedelta | float | int | None = None, - progress_handler: ProgressHandler | None = None, - raise_on_error: bool = True, - meta: dict[str, Any] | None = None, - task: Literal[False] = False, - ) -> CallToolResult: ... - - @overload - async def call_tool( - self: Client, - name: str, - arguments: dict[str, Any] | None = None, - *, - version: str | None = None, - timeout: datetime.timedelta | float | int | None = None, - progress_handler: ProgressHandler | None = None, - raise_on_error: bool = True, - meta: dict[str, Any] | None = None, - task: Literal[True], - task_id: str | None = None, - ttl: int = 60000, - ) -> ToolTask: ... - - async def call_tool( - self: Client, - name: str, - arguments: dict[str, Any] | None = None, - *, - version: str | None = None, - timeout: datetime.timedelta | float | int | None = None, - progress_handler: ProgressHandler | None = None, - raise_on_error: bool = True, - meta: dict[str, Any] | None = None, - task: bool = False, - task_id: str | None = None, - ttl: int = 60000, - ) -> CallToolResult | ToolTask: - """Call a tool on the server. - - Unlike call_tool_mcp, this method raises a ToolError if the tool call results in an error. - - Args: - name (str): The name of the tool to call. - arguments (dict[str, Any] | None, optional): Arguments to pass to the tool. Defaults to None. - version (str | None, optional): Specific tool version to call. If None, calls highest version. - timeout (datetime.timedelta | float | int | None, optional): The timeout for the tool call. Defaults to None. - progress_handler (ProgressHandler | None, optional): The progress handler to use for the tool call. Defaults to None. - raise_on_error (bool, optional): Whether to raise an exception if the tool call results in an error. Defaults to True. - meta (dict[str, Any] | None, optional): Additional metadata to include with the request. - This is useful for passing contextual information (like user IDs, trace IDs, or preferences) - that shouldn't be tool arguments but may influence server-side processing. The server - can access this via `context.request_context.meta`. Defaults to None. - task (bool): If True, execute as background task (SEP-1686). Defaults to False. - task_id (str | None): Optional client-provided task ID (auto-generated if not provided). - ttl (int): Time to keep results available in milliseconds (default 60s). - - Returns: - CallToolResult | ToolTask: The content returned by the tool if task=False, - or a ToolTask object if task=True. If the tool returns structured - outputs, they are returned as a dataclass (if an output schema - is available) or a dictionary; otherwise, a list of content - blocks is returned. Note: to receive both structured and - unstructured outputs, use call_tool_mcp instead and access the - raw result object. - - Raises: - ToolError: If the tool call results in an error. - McpError: If the tool call request results in a TimeoutError | JSONRPCError - RuntimeError: If called while the client is not connected. - """ - # Merge version into request-level meta (not arguments) - request_meta = dict(meta) if meta else {} - if version is not None: - request_meta["fastmcp"] = { - **request_meta.get("fastmcp", {}), - "version": version, - } - - if task: - return await self._call_tool_as_task( - name, arguments, task_id, ttl, meta=request_meta or None - ) - - result = await self.call_tool_mcp( - name=name, - arguments=arguments or {}, - timeout=timeout, - progress_handler=progress_handler, - meta=request_meta or None, - ) - return await self._parse_call_tool_result( - name, result, raise_on_error=raise_on_error - ) - - async def _call_tool_as_task( - self: Client, - name: str, - arguments: dict[str, Any] | None = None, - task_id: str | None = None, - ttl: int = 60000, - meta: dict[str, Any] | None = None, - ) -> ToolTask: - """Call a tool for background execution (SEP-1686). - - Returns a ToolTask object that handles both background and immediate execution. - If the server accepts background execution, ToolTask will poll for results. - If the server declines (graceful degradation), ToolTask wraps the immediate result. - - Args: - name: Tool name to call - arguments: Tool arguments - task_id: Optional client-provided task ID (ignored, for backward compatibility) - ttl: Time to keep results available in milliseconds (default 60s) - meta: Optional request metadata (e.g., version info) - - Returns: - ToolTask: Future-like object for accessing task status and results - """ - # Per SEP-1686 final spec: client sends only ttl, server generates taskId - # Inject trace context into meta for propagation to server - propagated_meta = inject_trace_context(meta) - - # Build request with task metadata - request = mcp.types.CallToolRequest( - params=mcp.types.CallToolRequestParams( - name=name, - arguments=arguments or {}, - task=mcp.types.TaskMetadata(ttl=ttl), - _meta=propagated_meta, # type: ignore[unknown-argument] # pydantic alias # ty:ignore[unknown-argument] - ) - ) - - # Server returns CreateTaskResult (task accepted) or CallToolResult (graceful degradation) - # Use RootModel with Union to handle both response types (SDK calls model_validate) - wrapped_result = await self._await_with_session_monitoring( - self.session.send_request( - request=request, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - result_type=ToolTaskResponseUnion, - ) - ) - raw_result = wrapped_result.root - - if isinstance(raw_result, mcp.types.CreateTaskResult): - # Task was accepted - extract task info from CreateTaskResult - server_task_id = raw_result.task.taskId - self._submitted_task_ids.add(server_task_id) - - task_obj = ToolTask( - self, server_task_id, tool_name=name, immediate_result=None - ) - self._task_registry[server_task_id] = weakref.ref(task_obj) - return task_obj - else: - # Graceful degradation - server returned CallToolResult - parsed_result = await self._parse_call_tool_result(name, raw_result) - synthetic_task_id = task_id or str(uuid.uuid4()) - return ToolTask( - self, - synthetic_task_id, - tool_name=name, - immediate_result=parsed_result, - ) - - -async def _parse_call_tool_result( - name: str, - result: mcp.types.CallToolResult, - tool_output_schemas: dict[str, dict[str, Any] | None], - list_tools_fn: Any, # Callable[[], Awaitable[None]] - client_name: str | None = None, - raise_on_error: bool = False, -) -> CallToolResult: - """Parse an mcp.types.CallToolResult into our CallToolResult dataclass. - - Args: - name: Tool name (for schema lookup) - result: Raw MCP protocol result - tool_output_schemas: Dictionary mapping tool names to their output schemas - list_tools_fn: Async function to refresh tool schemas if needed - client_name: Optional client name for logging - raise_on_error: Whether to raise ToolError on errors - - Returns: - CallToolResult: Parsed result with structured data - """ - # Local import: CallToolResult is under TYPE_CHECKING at module level to - # avoid a circular import (client.client -> mixins.tools -> client.client), - # but we need the concrete class here to construct the return value. - from fastmcp.client.client import CallToolResult - - data = None - if result.isError and raise_on_error: - msg = cast(mcp.types.TextContent, result.content[0]).text - raise ToolError(msg) - elif result.structuredContent: - try: - raw_fastmcp_meta = (result.meta or {}).get("fastmcp") - fastmcp_meta = ( - raw_fastmcp_meta if isinstance(raw_fastmcp_meta, dict) else {} - ) - wrap_from_meta = fastmcp_meta.get("wrap_result", False) - - # Ensure the schema cache is populated for type validation. - # When meta tells us the result is wrapped we can skip the - # schema check for *wrap detection*, but we still need the - # schema for proper type coercion (e.g. list → set, str → datetime). - if name not in tool_output_schemas: - await list_tools_fn() - - if wrap_from_meta: - # Meta tells us the result is wrapped — unwrap and validate. - structured_content = result.structuredContent.get("result") - elif name in tool_output_schemas: - output_schema = tool_output_schemas.get(name) - if output_schema and output_schema.get("x-fastmcp-wrap-result"): - structured_content = result.structuredContent.get("result") - else: - structured_content = result.structuredContent - else: - structured_content = result.structuredContent - - # Type-validate through the schema if available. - output_schema = tool_output_schemas.get(name) - if output_schema: - if wrap_from_meta or output_schema.get("x-fastmcp-wrap-result"): - output_schema = output_schema.get("properties", {}).get( - "result", output_schema - ) - output_type = json_schema_to_type(output_schema) - type_adapter = get_cached_typeadapter(output_type) - data = type_adapter.validate_python(structured_content) - else: - data = structured_content - except Exception as e: - logger.error( - f"[{client_name or 'client'}] Error parsing structured content: {e}" - ) - - return CallToolResult( - content=result.content, - structured_content=result.structuredContent, - meta=result.meta, - data=data, - is_error=result.isError, - ) diff --git a/src/fastmcp/client/oauth_callback.py b/src/fastmcp/client/oauth_callback.py deleted file mode 100644 index 6bfba59cb..000000000 --- a/src/fastmcp/client/oauth_callback.py +++ /dev/null @@ -1,251 +0,0 @@ -""" -OAuth callback server for handling authorization code flows. - -This module provides a reusable callback server that can handle OAuth redirects -and display styled responses to users. -""" - -from __future__ import annotations - -from dataclasses import dataclass - -import anyio -from starlette.applications import Starlette -from starlette.requests import Request -from starlette.routing import Route -from uvicorn import Config, Server - -from fastmcp.utilities.http import find_available_port -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.ui import ( - HELPER_TEXT_STYLES, - INFO_BOX_STYLES, - STATUS_MESSAGE_STYLES, - create_info_box, - create_logo, - create_page, - create_secure_html_response, - create_status_message, -) - -logger = get_logger(__name__) - - -def create_callback_html( - message: str, - is_success: bool = True, - title: str = "FastMCP OAuth", - server_url: str | None = None, -) -> str: - """Create a styled HTML response for OAuth callbacks.""" - # Build the main status message - status_title = ( - "Authentication successful" if is_success else "Authentication failed" - ) - - # Add detail info box for both success and error cases - detail_info = "" - if is_success and server_url: - detail_info = create_info_box( - f"Connected to: {server_url}", centered=True, monospace=True - ) - elif not is_success: - detail_info = create_info_box( - message, is_error=True, centered=True, monospace=True - ) - - # Build the page content - content = f""" -
- {create_logo()} - {create_status_message(status_title, is_success=is_success)} - {detail_info} -
- You can safely close this tab now. -
-
- """ - - # Additional styles needed for this page - additional_styles = STATUS_MESSAGE_STYLES + INFO_BOX_STYLES + HELPER_TEXT_STYLES - - return create_page( - content=content, - title=title, - additional_styles=additional_styles, - ) - - -@dataclass -class CallbackResponse: - code: str | None = None - state: str | None = None - error: str | None = None - error_description: str | None = None - - @classmethod - def from_dict(cls, data: dict[str, str]) -> CallbackResponse: - return cls(**{k: v for k, v in data.items() if k in cls.__annotations__}) - - def to_dict(self) -> dict[str, str]: - return {k: v for k, v in self.__dict__.items() if v is not None} - - -@dataclass -class OAuthCallbackResult: - """Container for OAuth callback results, used with anyio.Event for async coordination.""" - - code: str | None = None - state: str | None = None - error: Exception | None = None - - -def create_oauth_callback_server( - port: int, - callback_path: str = "/callback", - server_url: str | None = None, - result_container: OAuthCallbackResult | None = None, - result_ready: anyio.Event | None = None, -) -> Server: - """ - Create an OAuth callback server. - - Args: - port: The port to run the server on - callback_path: The path to listen for OAuth redirects on - server_url: Optional server URL to display in success messages - result_container: Optional container to store callback results - result_ready: Optional event to signal when callback is received - - Returns: - Configured uvicorn Server instance (not yet running) - """ - - def store_result_once( - *, - code: str | None = None, - state: str | None = None, - error: Exception | None = None, - ) -> None: - """Store the first callback result and ignore subsequent requests.""" - if result_container is None or result_ready is None or result_ready.is_set(): - return - - result_container.code = code - result_container.state = state - result_container.error = error - result_ready.set() - - async def callback_handler(request: Request): - """Handle OAuth callback requests with proper HTML responses.""" - query_params = dict(request.query_params) - callback_response = CallbackResponse.from_dict(query_params) - - if callback_response.error: - error_desc = callback_response.error_description or "Unknown error" - - # Create user-friendly error messages - if callback_response.error == "access_denied": - user_message = "Access was denied by the authorization server." - else: - user_message = f"Authorization failed: {error_desc}" - - # Store error and signal completion if result tracking provided - store_result_once(error=RuntimeError(user_message)) - - return create_secure_html_response( - create_callback_html( - user_message, - is_success=False, - ), - status_code=400, - ) - - if not callback_response.code: - user_message = "No authorization code was received from the server." - - # Store error and signal completion if result tracking provided - store_result_once(error=RuntimeError(user_message)) - - return create_secure_html_response( - create_callback_html( - user_message, - is_success=False, - ), - status_code=400, - ) - - # Check for missing state parameter (indicates OAuth flow issue) - if callback_response.state is None: - user_message = ( - "The OAuth server did not return the expected state parameter." - ) - - # Store error and signal completion if result tracking provided - store_result_once(error=RuntimeError(user_message)) - - return create_secure_html_response( - create_callback_html( - user_message, - is_success=False, - ), - status_code=400, - ) - - # Success case - store result and signal completion if result tracking provided - store_result_once( - code=callback_response.code, - state=callback_response.state, - ) - - return create_secure_html_response( - create_callback_html("", is_success=True, server_url=server_url) - ) - - app = Starlette(routes=[Route(callback_path, callback_handler)]) - - return Server( - Config( - app=app, - host="127.0.0.1", - port=port, - lifespan="off", - log_level="warning", - ws="websockets-sansio", - ) - ) - - -if __name__ == "__main__": - """Run a test server when executed directly.""" - import webbrowser - - import uvicorn - - port = find_available_port() - print("🎭 OAuth Callback Test Server") - print("📍 Test URLs:") - print(f" Success: http://localhost:{port}/callback?code=test123&state=xyz") - print( - f" Error: http://localhost:{port}/callback?error=access_denied&error_description=User%20denied" - ) - print(f" Missing: http://localhost:{port}/callback") - print("🛑 Press Ctrl+C to stop") - print() - - # Create test server without future (just for testing HTML responses) - server = create_oauth_callback_server( - port=port, server_url="https://fastmcp-test-server.example.com" - ) - - # Open browser to success example - webbrowser.open(f"http://localhost:{port}/callback?code=test123&state=xyz") - - # Run with uvicorn directly - uvicorn.run( - server.config.app, - host="127.0.0.1", - port=port, - log_level="warning", - access_log=False, - ) diff --git a/src/fastmcp/client/progress.py b/src/fastmcp/client/progress.py deleted file mode 100644 index 826d2cb99..000000000 --- a/src/fastmcp/client/progress.py +++ /dev/null @@ -1,41 +0,0 @@ -from typing import TypeAlias - -from mcp.shared.session import ProgressFnT - -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - -ProgressHandler: TypeAlias = ProgressFnT - - -async def default_progress_handler( - progress: float, total: float | None, message: str | None -) -> None: - """Default handler for progress notifications. - - Logs progress updates at debug level, properly handling missing total or message values. - - Args: - progress: Current progress value - total: Optional total expected value - message: Optional status message - """ - if total not in (None, 0): - # We have both progress and total - percent = (progress / total) * 100 - progress_str = f"{progress}/{total} ({percent:.1f}%)" - elif total == 0: - # Avoid division by zero when a server reports an invalid total. - progress_str = f"{progress}/{total}" - else: - # We only have progress - progress_str = f"{progress}" - - # Include message if available - if message: - log_msg = f"Progress: {progress_str} - {message}" - else: - log_msg = f"Progress: {progress_str}" - - logger.debug(log_msg) diff --git a/src/fastmcp/client/roots.py b/src/fastmcp/client/roots.py deleted file mode 100644 index cdf97938b..000000000 --- a/src/fastmcp/client/roots.py +++ /dev/null @@ -1,78 +0,0 @@ -import inspect -from collections.abc import Awaitable, Callable -from typing import TypeAlias, cast - -import mcp.types -import pydantic -from mcp import ClientSession -from mcp.client.session import ListRootsFnT -from mcp.shared.context import LifespanContextT, RequestContext - -RootsList: TypeAlias = list[str] | list[mcp.types.Root] | list[str | mcp.types.Root] - -RootsHandler: TypeAlias = ( - Callable[[RequestContext[ClientSession, LifespanContextT]], RootsList] - | Callable[[RequestContext[ClientSession, LifespanContextT]], Awaitable[RootsList]] -) - - -def convert_roots_list(roots: RootsList) -> list[mcp.types.Root]: - roots_list = [] - for r in roots: - if isinstance(r, mcp.types.Root): - roots_list.append(r) - elif isinstance(r, pydantic.FileUrl): - roots_list.append(mcp.types.Root(uri=r)) - elif isinstance(r, str): - roots_list.append(mcp.types.Root(uri=pydantic.FileUrl(r))) - else: - raise ValueError(f"Invalid root: {r}") - return roots_list - - -def create_roots_callback( - handler: RootsList | RootsHandler, -) -> ListRootsFnT: - if isinstance(handler, list): - # TODO(ty): remove when ty supports isinstance union narrowing - return _create_roots_callback_from_roots(handler) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - elif inspect.isfunction(handler): - return _create_roots_callback_from_fn(handler) - else: - raise ValueError(f"Invalid roots handler: {handler}") - - -def _create_roots_callback_from_roots( - roots: RootsList, -) -> ListRootsFnT: - roots = convert_roots_list(roots) - - async def _roots_callback( - context: RequestContext[ClientSession, LifespanContextT], - ) -> mcp.types.ListRootsResult: - return mcp.types.ListRootsResult(roots=roots) - - return _roots_callback - - -def _create_roots_callback_from_fn( - fn: Callable[[RequestContext[ClientSession, LifespanContextT]], RootsList] - | Callable[[RequestContext[ClientSession, LifespanContextT]], Awaitable[RootsList]], -) -> ListRootsFnT: - async def _roots_callback( - context: RequestContext[ClientSession, LifespanContextT], - ) -> mcp.types.ListRootsResult | mcp.types.ErrorData: - try: - roots = fn(context) - if inspect.isawaitable(roots): - roots = await roots - return mcp.types.ListRootsResult( - roots=convert_roots_list(cast(RootsList, roots)) - ) - except Exception as e: - return mcp.types.ErrorData( - code=mcp.types.INTERNAL_ERROR, - message=str(e), - ) - - return _roots_callback diff --git a/src/fastmcp/client/sampling/__init__.py b/src/fastmcp/client/sampling/__init__.py deleted file mode 100644 index 2987e9259..000000000 --- a/src/fastmcp/client/sampling/__init__.py +++ /dev/null @@ -1,71 +0,0 @@ -import inspect -from collections.abc import Awaitable, Callable -from typing import TypeAlias, TypeVar, cast - -import mcp.types -from mcp import ClientSession, CreateMessageResult -from mcp.client.session import SamplingFnT -from mcp.server.session import ServerSession -from mcp.shared.context import LifespanContextT, RequestContext -from mcp.types import CreateMessageRequestParams as SamplingParams -from mcp.types import CreateMessageResultWithTools, SamplingMessage - -# Result type that handlers can return -SamplingHandlerResult: TypeAlias = ( - str | CreateMessageResult | CreateMessageResultWithTools -) - -# Session type for sampling handlers - works with both client and server sessions -SessionT = TypeVar("SessionT", ClientSession, ServerSession) - -# Unified sampling handler type that works for both clients and servers. -# Handlers receive messages and parameters from the MCP sampling flow -# and return LLM responses. -SamplingHandler: TypeAlias = Callable[ - [ - list[SamplingMessage], - SamplingParams, - RequestContext[SessionT, LifespanContextT], - ], - SamplingHandlerResult | Awaitable[SamplingHandlerResult], -] - - -__all__ = [ - "RequestContext", - "SamplingHandler", - "SamplingHandlerResult", - "SamplingMessage", - "SamplingParams", - "create_sampling_callback", -] - - -def create_sampling_callback( - sampling_handler: SamplingHandler, -) -> SamplingFnT: - async def _sampling_handler( - context, - params: SamplingParams, - ) -> CreateMessageResult | CreateMessageResultWithTools | mcp.types.ErrorData: - try: - result = sampling_handler(params.messages, params, context) - if inspect.isawaitable(result): - result = await result - - result = cast(SamplingHandlerResult, result) - - if isinstance(result, str): - result = CreateMessageResult( - role="assistant", - model="fastmcp-client", - content=mcp.types.TextContent(type="text", text=result), - ) - return result - except Exception as e: - return mcp.types.ErrorData( - code=mcp.types.INTERNAL_ERROR, - message=str(e), - ) - - return _sampling_handler diff --git a/src/fastmcp/client/sampling/handlers/__init__.py b/src/fastmcp/client/sampling/handlers/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/fastmcp/client/sampling/handlers/anthropic.py b/src/fastmcp/client/sampling/handlers/anthropic.py deleted file mode 100644 index b7a6ce090..000000000 --- a/src/fastmcp/client/sampling/handlers/anthropic.py +++ /dev/null @@ -1,447 +0,0 @@ -"""Anthropic sampling handler for FastMCP.""" - -from collections.abc import Iterator, Sequence -from typing import Any - -from mcp.types import ( - AudioContent, - CreateMessageResult, - CreateMessageResultWithTools, - ImageContent, - ModelPreferences, - SamplingMessage, - SamplingMessageContentBlock, - StopReason, - TextContent, - Tool, - ToolChoice, - ToolResultContent, - ToolUseContent, -) -from mcp.types import CreateMessageRequestParams as SamplingParams - -try: - from anthropic import AsyncAnthropic - from anthropic.types import ( - Base64ImageSourceParam, - ImageBlockParam, - Message, - MessageParam, - TextBlock, - TextBlockParam, - ToolParam, - ToolResultBlockParam, - ToolUseBlock, - ToolUseBlockParam, - ) - from anthropic.types.model_param import ModelParam - from anthropic.types.tool_choice_any_param import ToolChoiceAnyParam - from anthropic.types.tool_choice_auto_param import ToolChoiceAutoParam - from anthropic.types.tool_choice_param import ToolChoiceParam -except ImportError as e: - raise ImportError( - "The `anthropic` package is not installed. " - "Install it with `pip install fastmcp[anthropic]` or add `anthropic` to your dependencies." - ) from e - -__all__ = ["AnthropicSamplingHandler"] - -# Anthropic supports these image MIME types -_ANTHROPIC_IMAGE_MEDIA_TYPES = frozenset( - {"image/jpeg", "image/png", "image/gif", "image/webp"} -) - - -def _image_content_to_anthropic_block(content: ImageContent) -> ImageBlockParam: - """Convert MCP ImageContent to Anthropic ImageBlockParam.""" - if content.mimeType not in _ANTHROPIC_IMAGE_MEDIA_TYPES: - raise ValueError( - f"Unsupported image MIME type for Anthropic: {content.mimeType!r}. " - f"Supported types: {', '.join(sorted(_ANTHROPIC_IMAGE_MEDIA_TYPES))}" - ) - return ImageBlockParam( - type="image", - source=Base64ImageSourceParam( - type="base64", - media_type=content.mimeType, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - data=content.data, - ), - ) - - -class AnthropicSamplingHandler: - """Sampling handler that uses the Anthropic API. - - Example: - ```python - from anthropic import AsyncAnthropic - from fastmcp import FastMCP - from fastmcp.client.sampling.handlers.anthropic import AnthropicSamplingHandler - - handler = AnthropicSamplingHandler( - default_model="claude-sonnet-4-5", - client=AsyncAnthropic(), - ) - - server = FastMCP(sampling_handler=handler) - ``` - """ - - def __init__( - self, default_model: ModelParam, client: AsyncAnthropic | None = None - ) -> None: - self.client: AsyncAnthropic = client or AsyncAnthropic() - self.default_model: ModelParam = default_model - - async def __call__( - self, - messages: list[SamplingMessage], - params: SamplingParams, - context: Any, - ) -> CreateMessageResult | CreateMessageResultWithTools: - anthropic_messages: list[MessageParam] = self._convert_to_anthropic_messages( - messages=messages, - ) - - model: ModelParam = self._select_model_from_preferences(params.modelPreferences) - - # Convert MCP tools to Anthropic format - anthropic_tools: list[ToolParam] | None = None - if params.tools: - anthropic_tools = self._convert_tools_to_anthropic(params.tools) - - # Convert tool_choice to Anthropic format - # Returns None if mode is "none", signaling tools should be omitted - anthropic_tool_choice: ToolChoiceParam | None = None - if params.toolChoice: - converted = self._convert_tool_choice_to_anthropic(params.toolChoice) - if converted is None: - # tool_choice="none" means don't use tools - anthropic_tools = None - else: - anthropic_tool_choice = converted - - # Build kwargs to avoid sentinel type compatibility issues across - # anthropic SDK versions (NotGiven vs Omit) - kwargs: dict[str, Any] = { - "model": model, - "messages": anthropic_messages, - "max_tokens": params.maxTokens, - } - if params.systemPrompt is not None: - kwargs["system"] = params.systemPrompt - if params.temperature is not None: - kwargs["temperature"] = params.temperature - if params.stopSequences is not None: - kwargs["stop_sequences"] = params.stopSequences - if anthropic_tools is not None: - kwargs["tools"] = anthropic_tools - if anthropic_tool_choice is not None: - kwargs["tool_choice"] = anthropic_tool_choice - - response = await self.client.messages.create(**kwargs) - - # Return appropriate result type based on whether tools were provided - if params.tools: - return self._message_to_result_with_tools(response) - return self._message_to_create_message_result(response) - - @staticmethod - def _iter_models_from_preferences( - model_preferences: ModelPreferences | str | list[str] | None, - ) -> Iterator[str]: - if model_preferences is None: - return - - if isinstance(model_preferences, str): - yield model_preferences - - elif isinstance(model_preferences, list): - yield from model_preferences - - elif isinstance(model_preferences, ModelPreferences): - if not (hints := model_preferences.hints): - return - - for hint in hints: - if not (name := hint.name): - continue - - yield name - - @staticmethod - def _convert_to_anthropic_messages( - messages: Sequence[SamplingMessage], - ) -> list[MessageParam]: - anthropic_messages: list[MessageParam] = [] - - for message in messages: - content = message.content - - # Handle list content (from CreateMessageResultWithTools) - if isinstance(content, list): - content_blocks: list[ - TextBlockParam - | ImageBlockParam - | ToolUseBlockParam - | ToolResultBlockParam - ] = [] - - for item in content: - if isinstance(item, ToolUseContent): - content_blocks.append( - ToolUseBlockParam( - type="tool_use", - id=item.id, - name=item.name, - input=item.input, - ) - ) - elif isinstance(item, TextContent): - content_blocks.append( - TextBlockParam(type="text", text=item.text) - ) - elif isinstance(item, ImageContent): - if message.role != "user": - raise ValueError( - "ImageContent is only supported in user messages " - "for Anthropic" - ) - content_blocks.append(_image_content_to_anthropic_block(item)) - elif isinstance(item, AudioContent): - raise ValueError( - "AudioContent is not supported by the Anthropic API" - ) - elif isinstance(item, ToolResultContent): - # Extract text content from the result - result_content: str | list[TextBlockParam] = "" - if item.content: - text_blocks: list[TextBlockParam] = [] - for sub_item in item.content: - if isinstance(sub_item, TextContent): - text_blocks.append( - TextBlockParam(type="text", text=sub_item.text) - ) - if len(text_blocks) == 1: - result_content = text_blocks[0]["text"] - elif text_blocks: - result_content = text_blocks - - content_blocks.append( - ToolResultBlockParam( - type="tool_result", - tool_use_id=item.toolUseId, - content=result_content, - is_error=item.isError if item.isError else False, - ) - ) - - if content_blocks: - anthropic_messages.append( - MessageParam( - role=message.role, - content=content_blocks, - ) - ) - continue - - # Handle ToolUseContent (assistant's tool calls) - if isinstance(content, ToolUseContent): - anthropic_messages.append( - MessageParam( - role="assistant", - content=[ - ToolUseBlockParam( - type="tool_use", - id=content.id, - name=content.name, - input=content.input, - ) - ], - ) - ) - continue - - # Handle ToolResultContent (user's tool results) - if isinstance(content, ToolResultContent): - result_content_str: str | list[TextBlockParam] = "" - if content.content: - text_parts: list[TextBlockParam] = [] - for item in content.content: - if isinstance(item, TextContent): - text_parts.append( - TextBlockParam(type="text", text=item.text) - ) - if len(text_parts) == 1: - result_content_str = text_parts[0]["text"] - elif text_parts: - result_content_str = text_parts - - anthropic_messages.append( - MessageParam( - role="user", - content=[ - ToolResultBlockParam( - type="tool_result", - tool_use_id=content.toolUseId, - content=result_content_str, - is_error=content.isError if content.isError else False, - ) - ], - ) - ) - continue - - # Handle TextContent - if isinstance(content, TextContent): - anthropic_messages.append( - MessageParam( - role=message.role, - content=content.text, - ) - ) - continue - - # Handle ImageContent - if isinstance(content, ImageContent): - if message.role != "user": - raise ValueError( - "ImageContent is only supported in user messages for Anthropic" - ) - anthropic_messages.append( - MessageParam( - role="user", - content=[_image_content_to_anthropic_block(content)], - ) - ) - continue - - # Handle AudioContent - not supported by Anthropic - if isinstance(content, AudioContent): - raise ValueError("AudioContent is not supported by the Anthropic API") - - raise ValueError(f"Unsupported content type: {type(content)}") - - return anthropic_messages - - @staticmethod - def _message_to_create_message_result( - message: Message, - ) -> CreateMessageResult: - if len(message.content) == 0: - raise ValueError("No content in response from Anthropic") - - # Join all text blocks to avoid dropping content - text = "".join( - block.text for block in message.content if isinstance(block, TextBlock) - ) - if text: - return CreateMessageResult( - content=TextContent(type="text", text=text), - role="assistant", - model=message.model, - ) - - raise ValueError( - f"No text content in response from Anthropic: {[type(b).__name__ for b in message.content]}" - ) - - def _select_model_from_preferences( - self, model_preferences: ModelPreferences | str | list[str] | None - ) -> ModelParam: - for model_option in self._iter_models_from_preferences(model_preferences): - # Accept any model that starts with "claude" - if model_option.startswith("claude"): - return model_option - - return self.default_model - - @staticmethod - def _convert_tools_to_anthropic(tools: list[Tool]) -> list[ToolParam]: - """Convert MCP tools to Anthropic tool format.""" - anthropic_tools: list[ToolParam] = [] - for tool in tools: - # Build input_schema dict, ensuring required fields - input_schema: dict[str, Any] = dict(tool.inputSchema) - if "type" not in input_schema: - input_schema["type"] = "object" - - anthropic_tools.append( - ToolParam( - name=tool.name, - description=tool.description or "", - input_schema=input_schema, - ) - ) - return anthropic_tools - - @staticmethod - def _convert_tool_choice_to_anthropic( - tool_choice: ToolChoice, - ) -> ToolChoiceParam | None: - """Convert MCP tool_choice to Anthropic format. - - Returns None for "none" mode, signaling that tools should be omitted - from the request entirely (Anthropic doesn't have an explicit "none" option). - """ - if tool_choice.mode == "auto": - return ToolChoiceAutoParam(type="auto") - elif tool_choice.mode == "required": - return ToolChoiceAnyParam(type="any") - elif tool_choice.mode == "none": - # Anthropic doesn't have a "none" option - return None to signal - # that tools should be omitted from the request entirely - return None - else: - raise ValueError(f"Unsupported tool_choice mode: {tool_choice.mode!r}") - - @staticmethod - def _message_to_result_with_tools( - message: Message, - ) -> CreateMessageResultWithTools: - """Convert Anthropic response to CreateMessageResultWithTools.""" - if len(message.content) == 0: - raise ValueError("No content in response from Anthropic") - - # Determine stop reason - stop_reason: StopReason - if message.stop_reason == "tool_use": - stop_reason = "toolUse" - elif message.stop_reason == "end_turn": - stop_reason = "endTurn" - elif message.stop_reason == "max_tokens": - stop_reason = "maxTokens" - elif message.stop_reason == "stop_sequence": - stop_reason = "endTurn" - else: - stop_reason = "endTurn" - - # Build content list - content: list[SamplingMessageContentBlock] = [] - - for block in message.content: - if isinstance(block, TextBlock): - content.append(TextContent(type="text", text=block.text)) - elif isinstance(block, ToolUseBlock): - # Anthropic returns input as dict directly - arguments = block.input if isinstance(block.input, dict) else {} - - content.append( - ToolUseContent( - type="tool_use", - id=block.id, - name=block.name, - input=arguments, - ) - ) - - # Must have at least some content - if not content: - raise ValueError("No content in response from Anthropic") - - return CreateMessageResultWithTools( - content=content, - role="assistant", - model=message.model, - stopReason=stop_reason, - ) diff --git a/src/fastmcp/client/sampling/handlers/google_genai.py b/src/fastmcp/client/sampling/handlers/google_genai.py deleted file mode 100644 index ad1a3d1e8..000000000 --- a/src/fastmcp/client/sampling/handlers/google_genai.py +++ /dev/null @@ -1,386 +0,0 @@ -"""Google GenAI sampling handler with tool support for FastMCP 3.0.""" - -import base64 -from collections.abc import Sequence -from uuid import uuid4 - -try: - from google.genai import Client as GoogleGenaiClient - from google.genai.types import ( - Blob, - Candidate, - Content, - FunctionCall, - FunctionCallingConfig, - FunctionCallingConfigMode, - FunctionDeclaration, - FunctionResponse, - GenerateContentConfig, - GenerateContentResponse, - ModelContent, - Part, - ThinkingConfig, - ToolConfig, - UserContent, - ) - from google.genai.types import Tool as GoogleTool -except ImportError as e: - raise ImportError( - "The `google-genai` package is not installed. " - "Install it with `pip install fastmcp[gemini]` or add `google-genai` " - "to your dependencies." - ) from e - -from mcp import ClientSession, ServerSession -from mcp.shared.context import LifespanContextT, RequestContext -from mcp.types import ( - AudioContent, - CreateMessageResult, - CreateMessageResultWithTools, - ImageContent, - ModelPreferences, - SamplingMessage, - SamplingMessageContentBlock, - StopReason, - TextContent, - ToolChoice, - ToolResultContent, - ToolUseContent, -) -from mcp.types import CreateMessageRequestParams as SamplingParams -from mcp.types import Tool as MCPTool - -__all__ = ["GoogleGenaiSamplingHandler"] - - -class GoogleGenaiSamplingHandler: - """Sampling handler that uses the Google GenAI API with tool support. - - Example: - ```python - from google.genai import Client - from fastmcp import FastMCP - from fastmcp.client.sampling.handlers.google_genai import ( - GoogleGenaiSamplingHandler, - ) - - handler = GoogleGenaiSamplingHandler( - default_model="gemini-2.0-flash", - client=Client(), - ) - - server = FastMCP(sampling_handler=handler) - ``` - """ - - def __init__( - self, - default_model: str, - client: GoogleGenaiClient | None = None, - thinking_budget: int | None = None, - ) -> None: - self.client: GoogleGenaiClient = client or GoogleGenaiClient() - self.default_model: str = default_model - self.thinking_budget: int | None = thinking_budget - - async def __call__( - self, - messages: list[SamplingMessage], - params: SamplingParams, - context: RequestContext[ServerSession, LifespanContextT] - | RequestContext[ClientSession, LifespanContextT], - ) -> CreateMessageResult | CreateMessageResultWithTools: - contents: list[Content] = _convert_messages_to_google_genai_content(messages) - - # Convert MCP tools to Google GenAI format - google_tools: list[GoogleTool] | None = None - tool_config: ToolConfig | None = None - - if params.tools: - google_tools = [ - _convert_tool_to_google_genai(tool) for tool in params.tools - ] - tool_config = _convert_tool_choice_to_google_genai(params.toolChoice) - - # Select the model based on preferences - selected_model = self._get_model(model_preferences=params.modelPreferences) - - # Configure thinking if a budget is specified - thinking_config = ( - ThinkingConfig(thinking_budget=self.thinking_budget) - if self.thinking_budget is not None - else None - ) - - response: GenerateContentResponse = ( - await self.client.aio.models.generate_content( - model=selected_model, - contents=contents, - config=GenerateContentConfig( - system_instruction=params.systemPrompt, - temperature=params.temperature, - max_output_tokens=params.maxTokens, - stop_sequences=params.stopSequences, - thinking_config=thinking_config, - tools=google_tools, # ty: ignore[invalid-argument-type] - tool_config=tool_config, - ), - ) - ) - - # Return appropriate result type based on whether tools were provided - if params.tools: - return _response_to_result_with_tools(response, selected_model) - return _response_to_create_message_result(response, selected_model) - - def _get_model(self, model_preferences: ModelPreferences | None) -> str: - if model_preferences and model_preferences.hints: - for hint in model_preferences.hints: - if hint.name and hint.name.startswith("gemini"): - return hint.name - return self.default_model - - -def _convert_tool_to_google_genai(tool: MCPTool) -> GoogleTool: - """Convert an MCP Tool to Google GenAI format. - - Google's parameters_json_schema accepts standard JSON Schema format, - so we pass tool.inputSchema directly without conversion. - """ - return GoogleTool( - function_declarations=[ - FunctionDeclaration( - name=tool.name, - description=tool.description or "", - parameters_json_schema=tool.inputSchema, - ) - ] - ) - - -def _convert_tool_choice_to_google_genai(tool_choice: ToolChoice | None) -> ToolConfig: - """Convert MCP ToolChoice to Google GenAI ToolConfig.""" - if tool_choice is None: - return ToolConfig( - function_calling_config=FunctionCallingConfig( - mode=FunctionCallingConfigMode.AUTO - ) - ) - - if tool_choice.mode == "required": - return ToolConfig( - function_calling_config=FunctionCallingConfig( - mode=FunctionCallingConfigMode.ANY - ) - ) - if tool_choice.mode == "none": - return ToolConfig( - function_calling_config=FunctionCallingConfig( - mode=FunctionCallingConfigMode.NONE - ) - ) - - # Default to AUTO for "auto" or any other value - return ToolConfig( - function_calling_config=FunctionCallingConfig( - mode=FunctionCallingConfigMode.AUTO - ) - ) - - -def _sampling_content_to_google_genai_part( - content: TextContent - | ImageContent - | AudioContent - | ToolUseContent - | ToolResultContent, -) -> Part: - """Convert MCP content to Google GenAI Part.""" - if isinstance(content, TextContent): - return Part(text=content.text) - - if isinstance(content, ImageContent): - return Part( - inline_data=Blob( - data=base64.b64decode(content.data), - mime_type=content.mimeType, - ) - ) - - if isinstance(content, AudioContent): - return Part( - inline_data=Blob( - data=base64.b64decode(content.data), - mime_type=content.mimeType, - ) - ) - - if isinstance(content, ToolUseContent): - # Note: thought_signature bypass is required for manually constructed tool calls. - # Google's Gemini 3+ models enforce thought signature validation for function calls. - # Since we're constructing these Parts from MCP protocol data (not from model responses), - # they lack legitimate signatures. The bypass value allows validation to pass. - # See: https://ai.google.dev/gemini-api/docs/thought-signatures - return Part( - function_call=FunctionCall( - name=content.name, - args=content.input, - ), - thought_signature=b"skip_thought_signature_validator", - ) - - if isinstance(content, ToolResultContent): - # Extract text from tool result content - result_parts: list[str] = [] - if content.content: - for item in content.content: - if isinstance(item, TextContent): - result_parts.append(item.text) - else: - msg = f"Unsupported tool result content type: {type(item).__name__}" - raise ValueError(msg) - result_text = "".join(result_parts) - - # Extract function name from toolUseId - # Our IDs are formatted as "{function_name}_{uuid8}", so extract the name. - # Note: This is a limitation of MCP's ToolResultContent which only carries - # toolUseId, while Google's FunctionResponse requires the function name. - tool_use_id = content.toolUseId - if "_" in tool_use_id: - # Split and rejoin all but the last part (the UUID suffix) - parts = tool_use_id.rsplit("_", 1) - function_name = parts[0] - else: - # Fallback: use the full ID as the name - function_name = tool_use_id - - return Part( - function_response=FunctionResponse( - name=function_name, - response={"result": result_text}, - ) - ) - - msg = f"Unsupported content type: {type(content)}" - raise ValueError(msg) - - -def _convert_messages_to_google_genai_content( - messages: Sequence[SamplingMessage], -) -> list[Content]: - """Convert MCP messages to Google GenAI content.""" - google_messages: list[Content] = [] - - for message in messages: - content = message.content - - # Handle list content (tool calls + results) - if isinstance(content, list): - parts: list[Part] = [] - for item in content: - parts.append(_sampling_content_to_google_genai_part(item)) - - if message.role == "user": - google_messages.append(UserContent(parts=parts)) - elif message.role == "assistant": - google_messages.append(ModelContent(parts=parts)) - else: - msg = f"Invalid message role: {message.role}" - raise ValueError(msg) - continue - - # Handle single content item - part = _sampling_content_to_google_genai_part(content) - - if message.role == "user": - google_messages.append(UserContent(parts=[part])) - elif message.role == "assistant": - google_messages.append(ModelContent(parts=[part])) - else: - msg = f"Invalid message role: {message.role}" - raise ValueError(msg) - - return google_messages - - -def _get_candidate_from_response(response: GenerateContentResponse) -> Candidate: - """Extract the first candidate from a response.""" - if response.candidates and response.candidates[0]: - return response.candidates[0] - msg = "No candidate in response from completion." - raise ValueError(msg) - - -def _response_to_create_message_result( - response: GenerateContentResponse, - model: str, -) -> CreateMessageResult: - """Convert Google GenAI response to CreateMessageResult (no tools).""" - if not (text := response.text): - candidate = _get_candidate_from_response(response) - msg = f"No content in response: {candidate.finish_reason}" - raise ValueError(msg) - - return CreateMessageResult( - content=TextContent(type="text", text=text), - role="assistant", - model=model, - ) - - -def _response_to_result_with_tools( - response: GenerateContentResponse, - model: str, -) -> CreateMessageResultWithTools: - """Convert Google GenAI response to CreateMessageResultWithTools.""" - candidate = _get_candidate_from_response(response) - - # Determine stop reason and check for function calls - stop_reason: StopReason - finish_reason = candidate.finish_reason - has_function_calls = False - - if candidate.content and candidate.content.parts: - for part in candidate.content.parts: - if part.function_call is not None: - has_function_calls = True - break - - if has_function_calls: - stop_reason = "toolUse" - elif finish_reason == "STOP": - stop_reason = "endTurn" - elif finish_reason == "MAX_TOKENS": - stop_reason = "maxTokens" - else: - stop_reason = "endTurn" - - # Build content list - content: list[SamplingMessageContentBlock] = [] - - if candidate.content and candidate.content.parts: - for part in candidate.content.parts: - # Note: Skip thought parts from thinking_config - not relevant for MCP responses - if part.text: - content.append(TextContent(type="text", text=part.text)) - elif part.function_call is not None: - fc = part.function_call - fc_name: str = fc.name or "unknown" - content.append( - ToolUseContent( - type="tool_use", - id=f"{fc_name}_{uuid4().hex[:8]}", # Generate unique ID - name=fc_name, - input=dict(fc.args) if fc.args else {}, - ) - ) - - if not content: - raise ValueError("No content in response from completion") - - return CreateMessageResultWithTools( - content=content, - role="assistant", - model=model, - stopReason=stop_reason, - ) diff --git a/src/fastmcp/client/sampling/handlers/openai.py b/src/fastmcp/client/sampling/handlers/openai.py deleted file mode 100644 index ffc40f158..000000000 --- a/src/fastmcp/client/sampling/handlers/openai.py +++ /dev/null @@ -1,508 +0,0 @@ -"""OpenAI sampling handler for FastMCP.""" - -import json -from collections.abc import Iterator, Sequence -from typing import Any, get_args - -from mcp import ClientSession, ServerSession -from mcp.shared.context import LifespanContextT, RequestContext -from mcp.types import ( - AudioContent, - CreateMessageResult, - CreateMessageResultWithTools, - ImageContent, - ModelPreferences, - SamplingMessage, - StopReason, - TextContent, - Tool, - ToolChoice, - ToolResultContent, - ToolUseContent, -) -from mcp.types import CreateMessageRequestParams as SamplingParams - -try: - from openai import AsyncOpenAI - from openai.types.chat import ( - ChatCompletion, - ChatCompletionAssistantMessageParam, - ChatCompletionContentPartImageParam, - ChatCompletionContentPartInputAudioParam, - ChatCompletionContentPartParam, - ChatCompletionContentPartTextParam, - ChatCompletionMessageParam, - ChatCompletionMessageToolCallParam, - ChatCompletionSystemMessageParam, - ChatCompletionToolChoiceOptionParam, - ChatCompletionToolMessageParam, - ChatCompletionToolParam, - ChatCompletionUserMessageParam, - ) - from openai.types.shared.chat_model import ChatModel - from openai.types.shared_params import FunctionDefinition -except ImportError as e: - raise ImportError( - "The `openai` package is not installed. " - "Please install `fastmcp[openai]` or add `openai` to your dependencies manually." - ) from e - -# OpenAI only supports wav and mp3 for input audio -_OPENAI_AUDIO_FORMATS: dict[str, str] = { - "audio/wav": "wav", - "audio/x-wav": "wav", - "audio/mp3": "mp3", - "audio/mpeg": "mp3", -} - -_OPENAI_IMAGE_MEDIA_TYPES: frozenset[str] = frozenset( - {"image/jpeg", "image/png", "image/gif", "image/webp"} -) - - -def _image_content_to_openai_part( - content: ImageContent, -) -> ChatCompletionContentPartImageParam: - """Convert MCP ImageContent to OpenAI image_url content part.""" - if content.mimeType not in _OPENAI_IMAGE_MEDIA_TYPES: - raise ValueError( - f"Unsupported image MIME type for OpenAI: {content.mimeType!r}. " - f"Supported types: {', '.join(sorted(_OPENAI_IMAGE_MEDIA_TYPES))}" - ) - data_url = f"data:{content.mimeType};base64,{content.data}" - return ChatCompletionContentPartImageParam( - type="image_url", - image_url={"url": data_url}, - ) - - -def _audio_content_to_openai_part( - content: AudioContent, -) -> ChatCompletionContentPartInputAudioParam: - """Convert MCP AudioContent to OpenAI input_audio content part.""" - audio_format = _OPENAI_AUDIO_FORMATS.get(content.mimeType) - if audio_format is None: - raise ValueError( - f"Unsupported audio MIME type for OpenAI: {content.mimeType!r}. " - f"Supported types: {', '.join(sorted(_OPENAI_AUDIO_FORMATS))}" - ) - return ChatCompletionContentPartInputAudioParam( - type="input_audio", - input_audio={"data": content.data, "format": audio_format}, - ) - - -class OpenAISamplingHandler: - """Sampling handler that uses the OpenAI API.""" - - def __init__( - self, - default_model: ChatModel, - client: AsyncOpenAI | None = None, - ) -> None: - self.client: AsyncOpenAI = client or AsyncOpenAI() - self.default_model: ChatModel = default_model - - async def __call__( - self, - messages: list[SamplingMessage], - params: SamplingParams, - context: RequestContext[ServerSession, LifespanContextT] - | RequestContext[ClientSession, LifespanContextT], - ) -> CreateMessageResult | CreateMessageResultWithTools: - openai_messages: list[ChatCompletionMessageParam] = ( - self._convert_to_openai_messages( - system_prompt=params.systemPrompt, - messages=messages, - ) - ) - - model: ChatModel = self._select_model_from_preferences(params.modelPreferences) - - # Convert MCP tools to OpenAI format - openai_tools: list[ChatCompletionToolParam] | None = None - if params.tools: - openai_tools = self._convert_tools_to_openai(params.tools) - - # Convert tool_choice to OpenAI format - openai_tool_choice: ChatCompletionToolChoiceOptionParam | None = None - if params.toolChoice: - openai_tool_choice = self._convert_tool_choice_to_openai(params.toolChoice) - - # Build kwargs to avoid sentinel type compatibility issues across - # openai SDK versions (NotGiven vs Omit) - kwargs: dict[str, Any] = { - "model": model, - "messages": openai_messages, - } - if params.maxTokens is not None: - kwargs["max_completion_tokens"] = params.maxTokens - if params.temperature is not None: - kwargs["temperature"] = params.temperature - if params.stopSequences: - kwargs["stop"] = params.stopSequences - if openai_tools is not None: - kwargs["tools"] = openai_tools - if openai_tool_choice is not None: - kwargs["tool_choice"] = openai_tool_choice - - response = await self.client.chat.completions.create(**kwargs) - - # Return appropriate result type based on whether tools were provided - if params.tools: - return self._chat_completion_to_result_with_tools(response) - return self._chat_completion_to_create_message_result(response) - - @staticmethod - def _iter_models_from_preferences( - model_preferences: ModelPreferences | str | list[str] | None, - ) -> Iterator[str]: - if model_preferences is None: - return - - if isinstance(model_preferences, str) and model_preferences in get_args( - ChatModel - ): - yield model_preferences - - elif isinstance(model_preferences, list): - yield from model_preferences - - elif isinstance(model_preferences, ModelPreferences): - if not (hints := model_preferences.hints): - return - - for hint in hints: - if not (name := hint.name): - continue - - yield name - - @staticmethod - def _convert_to_openai_messages( - system_prompt: str | None, messages: Sequence[SamplingMessage] - ) -> list[ChatCompletionMessageParam]: - openai_messages: list[ChatCompletionMessageParam] = [] - - if system_prompt: - openai_messages.append( - ChatCompletionSystemMessageParam( - role="system", - content=system_prompt, - ) - ) - - for message in messages: - content = message.content - - # Handle list content (from CreateMessageResultWithTools) - if isinstance(content, list): - # Collect tool calls, content parts, and text from the list - tool_calls: list[ChatCompletionMessageToolCallParam] = [] - content_parts: list[ChatCompletionContentPartParam] = [] - text_parts: list[str] = [] - # Collect tool results separately to maintain correct ordering - tool_messages: list[ChatCompletionToolMessageParam] = [] - - for item in content: - if isinstance(item, ToolUseContent): - tool_calls.append( - ChatCompletionMessageToolCallParam( - id=item.id, - type="function", - function={ - "name": item.name, - "arguments": json.dumps(item.input), - }, - ) - ) - elif isinstance(item, TextContent): - text_parts.append(item.text) - content_parts.append( - ChatCompletionContentPartTextParam( - type="text", text=item.text - ) - ) - elif isinstance(item, ImageContent): - content_parts.append(_image_content_to_openai_part(item)) - elif isinstance(item, AudioContent): - content_parts.append(_audio_content_to_openai_part(item)) - elif isinstance(item, ToolResultContent): - # Collect tool results (added after assistant message) - content_text = "" - if item.content: - result_texts = [] - for sub_item in item.content: - if isinstance(sub_item, TextContent): - result_texts.append(sub_item.text) - content_text = "\n".join(result_texts) - tool_messages.append( - ChatCompletionToolMessageParam( - role="tool", - tool_call_id=item.toolUseId, - content=content_text, - ) - ) - - # Add assistant message with tool calls if present - # OpenAI requires: assistant (with tool_calls) -> tool messages - if tool_calls or content_parts: - if tool_calls: - has_multimodal = len(content_parts) > len(text_parts) - if has_multimodal: - raise ValueError( - "ImageContent/AudioContent is only supported " - "in user messages for OpenAI" - ) - text_str = "\n".join(text_parts) or None - openai_messages.append( - ChatCompletionAssistantMessageParam( - role="assistant", - content=text_str, - tool_calls=tool_calls, - ) - ) - # Add tool messages AFTER assistant message - openai_messages.extend(tool_messages) - elif content_parts: - if message.role == "user": - openai_messages.append( - ChatCompletionUserMessageParam( - role="user", - content=content_parts, - ) - ) - else: - has_multimodal = len(content_parts) > len(text_parts) - if has_multimodal: - raise ValueError( - "ImageContent/AudioContent is only supported " - "in user messages for OpenAI" - ) - assistant_text = "\n".join(text_parts) - if assistant_text: - openai_messages.append( - ChatCompletionAssistantMessageParam( - role="assistant", - content=assistant_text, - ) - ) - elif tool_messages: - # Tool results only (assistant message was in previous message) - openai_messages.extend(tool_messages) - continue - - # Handle ToolUseContent (assistant's tool calls) - if isinstance(content, ToolUseContent): - openai_messages.append( - ChatCompletionAssistantMessageParam( - role="assistant", - tool_calls=[ - ChatCompletionMessageToolCallParam( - id=content.id, - type="function", - function={ - "name": content.name, - "arguments": json.dumps(content.input), - }, - ) - ], - ) - ) - continue - - # Handle ToolResultContent (user's tool results) - if isinstance(content, ToolResultContent): - # Extract text parts from the content list - result_texts: list[str] = [] - if content.content: - for item in content.content: - if isinstance(item, TextContent): - result_texts.append(item.text) - openai_messages.append( - ChatCompletionToolMessageParam( - role="tool", - tool_call_id=content.toolUseId, - content="\n".join(result_texts), - ) - ) - continue - - # Handle TextContent - if isinstance(content, TextContent): - if message.role == "user": - openai_messages.append( - ChatCompletionUserMessageParam( - role="user", - content=content.text, - ) - ) - else: - openai_messages.append( - ChatCompletionAssistantMessageParam( - role="assistant", - content=content.text, - ) - ) - continue - - # Handle ImageContent - if isinstance(content, ImageContent): - if message.role != "user": - raise ValueError( - "ImageContent is only supported in user messages for OpenAI" - ) - openai_messages.append( - ChatCompletionUserMessageParam( - role="user", - content=[_image_content_to_openai_part(content)], - ) - ) - continue - - # Handle AudioContent - if isinstance(content, AudioContent): - if message.role != "user": - raise ValueError( - "AudioContent is only supported in user messages for OpenAI" - ) - openai_messages.append( - ChatCompletionUserMessageParam( - role="user", - content=[_audio_content_to_openai_part(content)], - ) - ) - continue - - raise ValueError(f"Unsupported content type: {type(content)}") - - return openai_messages - - @staticmethod - def _chat_completion_to_create_message_result( - chat_completion: ChatCompletion, - ) -> CreateMessageResult: - if len(chat_completion.choices) == 0: - raise ValueError("No response for completion") - - first_choice = chat_completion.choices[0] - - if content := first_choice.message.content: - return CreateMessageResult( - content=TextContent(type="text", text=content), - role="assistant", - model=chat_completion.model, - ) - - raise ValueError("No content in response from completion") - - def _select_model_from_preferences( - self, model_preferences: ModelPreferences | str | list[str] | None - ) -> ChatModel: - for model_option in self._iter_models_from_preferences(model_preferences): - if model_option in get_args(ChatModel): - chosen_model: ChatModel = model_option # type: ignore[assignment] # ty:ignore[invalid-assignment] - return chosen_model - - return self.default_model - - @staticmethod - def _convert_tools_to_openai(tools: list[Tool]) -> list[ChatCompletionToolParam]: - """Convert MCP tools to OpenAI tool format.""" - openai_tools: list[ChatCompletionToolParam] = [] - for tool in tools: - # Build parameters dict, ensuring required fields - parameters: dict[str, Any] = dict(tool.inputSchema) - if "type" not in parameters: - parameters["type"] = "object" - - openai_tools.append( - ChatCompletionToolParam( - type="function", - function=FunctionDefinition( - name=tool.name, - description=tool.description or "", - parameters=parameters, - ), - ) - ) - return openai_tools - - @staticmethod - def _convert_tool_choice_to_openai( - tool_choice: ToolChoice, - ) -> ChatCompletionToolChoiceOptionParam: - """Convert MCP tool_choice to OpenAI format.""" - if tool_choice.mode == "auto": - return "auto" - elif tool_choice.mode == "required": - return "required" - elif tool_choice.mode == "none": - return "none" - else: - raise ValueError(f"Unsupported tool_choice mode: {tool_choice.mode!r}") - - @staticmethod - def _chat_completion_to_result_with_tools( - chat_completion: ChatCompletion, - ) -> CreateMessageResultWithTools: - """Convert OpenAI response to CreateMessageResultWithTools.""" - if len(chat_completion.choices) == 0: - raise ValueError("No response for completion") - - first_choice = chat_completion.choices[0] - message = first_choice.message - - # Determine stop reason - stop_reason: StopReason - if first_choice.finish_reason == "tool_calls": - stop_reason = "toolUse" - elif first_choice.finish_reason == "stop": - stop_reason = "endTurn" - elif first_choice.finish_reason == "length": - stop_reason = "maxTokens" - else: - stop_reason = "endTurn" - - # Build content list - content: list[TextContent | ToolUseContent] = [] - - # Add text content if present - if message.content: - content.append(TextContent(type="text", text=message.content)) - - # Add tool calls if present - if message.tool_calls: - for tool_call in message.tool_calls: - # Skip non-function tool calls - if not hasattr(tool_call, "function"): - continue - func = tool_call.function - # Parse the arguments JSON string - try: - arguments = json.loads(func.arguments) # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - except json.JSONDecodeError as e: - raise ValueError( - f"Invalid JSON in tool arguments for " - f"'{func.name}': {func.arguments}" # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - ) from e - - content.append( - ToolUseContent( - type="tool_use", - id=tool_call.id, - name=func.name, # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - input=arguments, - ) - ) - - # Must have at least some content - if not content: - raise ValueError("No content in response from completion") - - return CreateMessageResultWithTools( - content=content, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - role="assistant", - model=chat_completion.model, - stopReason=stop_reason, - ) diff --git a/src/fastmcp/client/tasks.py b/src/fastmcp/client/tasks.py deleted file mode 100644 index ae6b0ad98..000000000 --- a/src/fastmcp/client/tasks.py +++ /dev/null @@ -1,551 +0,0 @@ -"""SEP-1686 client Task classes.""" - -from __future__ import annotations - -import abc -import asyncio -import inspect -import time -import weakref -from collections.abc import Awaitable, Callable -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Generic, TypeVar - -import mcp.types -from mcp.types import GetTaskResult, TaskStatusNotification - -from fastmcp.client.messages import Message, MessageHandler -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - -if TYPE_CHECKING: - from fastmcp.client.client import CallToolResult, Client - - -class TaskNotificationHandler(MessageHandler): - """MessageHandler that routes task status notifications to Task objects.""" - - def __init__(self, client: Client): - super().__init__() - self._client_ref: weakref.ref[Client] = weakref.ref(client) - - async def dispatch(self, message: Message) -> None: - """Dispatch messages, including task status notifications.""" - if isinstance(message, mcp.types.ServerNotification): - if isinstance(message.root, TaskStatusNotification): - client = self._client_ref() - if client: - client._handle_task_status_notification(message.root) - - await super().dispatch(message) - - -TaskResultT = TypeVar("TaskResultT") - - -class Task(abc.ABC, Generic[TaskResultT]): - """ - Abstract base class for MCP background tasks (SEP-1686). - - Provides a uniform API whether the server accepts background execution - or executes synchronously (graceful degradation per SEP-1686). - - Subclasses: - - ToolTask: For tool calls (result type: CallToolResult) - - PromptTask: For prompts (future, result type: GetPromptResult) - - ResourceTask: For resources (future, result type: ReadResourceResult) - """ - - def __init__( - self, - client: Client, - task_id: str, - immediate_result: TaskResultT | None = None, - ): - """ - Create a Task wrapper. - - Args: - client: The FastMCP client - task_id: The task identifier - immediate_result: If server executed synchronously, the immediate result - """ - self._client = client - self._task_id = task_id - self._immediate_result = immediate_result - self._is_immediate = immediate_result is not None - - # Notification-based optimization (SEP-1686 notifications/tasks/status) - self._status_cache: GetTaskResult | None = None - self._status_event: asyncio.Event | None = None # Lazy init - self._status_callbacks: list[ - Callable[[GetTaskResult], None | Awaitable[None]] - ] = [] - self._cached_result: TaskResultT | None = None - - def _check_client_connected(self) -> None: - """Validate that client context is still active. - - Raises: - RuntimeError: If accessed outside client context (unless immediate) - """ - if self._is_immediate: - return # Already resolved, no client needed - - try: - _ = self._client.session - except RuntimeError as e: - raise RuntimeError( - "Cannot access task results outside client context. " - "Task futures must be used within 'async with client:' block." - ) from e - - @property - def task_id(self) -> str: - """Get the task ID.""" - return self._task_id - - @property - def returned_immediately(self) -> bool: - """Check if server executed the task immediately. - - Returns: - True if server executed synchronously (graceful degradation or no task support) - False if server accepted background execution - """ - return self._is_immediate - - def _handle_status_notification(self, status: GetTaskResult) -> None: - """Process incoming notifications/tasks/status (internal). - - Called by Client when a notification is received for this task. - Updates cache, triggers events, and invokes user callbacks. - - Args: - status: Task status from notification - """ - # Update cache for next status() call - self._status_cache = status - - # Wake up any wait() calls - if self._status_event is not None: - self._status_event.set() - - # Invoke user callbacks - for callback in self._status_callbacks: - try: - result = callback(status) - if inspect.isawaitable(result): - # Fire and forget async callbacks - asyncio.create_task(result) # type: ignore[arg-type] # noqa: RUF006 # ty:ignore[invalid-argument-type] - except Exception as e: - logger.warning(f"Task callback error: {e}", exc_info=True) - - def on_status_change( - self, - callback: Callable[[GetTaskResult], None | Awaitable[None]], - ) -> None: - """Register callback for status change notifications. - - The callback will be invoked when a notifications/tasks/status is received - for this task (optional server feature per SEP-1686 lines 436-444). - - Supports both sync and async callbacks (auto-detected). - - Args: - callback: Function to call with GetTaskResult when status changes. - Can return None (sync) or Awaitable[None] (async). - - Example: - >>> task = await client.call_tool("slow_operation", {}, task=True) - >>> - >>> def on_update(status: GetTaskResult): - ... print(f"Task {status.taskId} is now {status.status}") - >>> - >>> task.on_status_change(on_update) - >>> result = await task # Callback fires when status changes - """ - self._status_callbacks.append(callback) - - async def status(self) -> GetTaskResult: - """Get current task status. - - If server executed immediately, returns synthetic completed status. - Otherwise queries the server for current status. - """ - self._check_client_connected() - - if self._is_immediate: - # Return synthetic completed status - now = datetime.now(timezone.utc) - return GetTaskResult( - taskId=self._task_id, - status="completed", - createdAt=now, - lastUpdatedAt=now, - ttl=None, - pollInterval=1000, - ) - - # Return cached status if available (from notification) - if self._status_cache is not None: - cached = self._status_cache - # Don't clear cache - keep it for next call - return cached - - # Query server and cache the result - self._status_cache = await self._client.get_task_status(self._task_id) - return self._status_cache - - @abc.abstractmethod - async def result(self) -> TaskResultT: - """Wait for and return the task result. - - Must be implemented by subclasses to return the appropriate result type. - """ - ... - - async def wait( - self, *, state: str | None = None, timeout: float = 300.0 - ) -> GetTaskResult: - """Wait for task to reach a specific state or complete. - - Uses event-based waiting when notifications are available (fast), - with fallback to polling (reliable). Optimally wakes up immediately - on status changes when server sends notifications/tasks/status. - - Args: - state: Desired state ('submitted', 'working', 'completed', 'failed'). - If None, waits for any terminal state (completed/failed) - timeout: Maximum time to wait in seconds - - Returns: - GetTaskResult: Final task status - - Raises: - TimeoutError: If desired state not reached within timeout - """ - self._check_client_connected() - - if self._is_immediate: - # Already done - return await self.status() - - # Initialize event for notification wake-ups - if self._status_event is None: - self._status_event = asyncio.Event() - - start = time.time() - terminal_states = {"completed", "failed", "cancelled"} - poll_interval = 0.5 # Fallback polling interval (500ms) - - while True: - # Check cached status first (updated by notifications) - if self._status_cache: - current = self._status_cache.status - if state is None: - if current in terminal_states: - return self._status_cache - elif current == state: - return self._status_cache - - # Check timeout - elapsed = time.time() - start - if elapsed >= timeout: - raise TimeoutError( - f"Task {self._task_id} did not reach {state or 'terminal state'} within {timeout}s" - ) - - remaining = timeout - elapsed - - # Wait for notification event OR poll timeout - try: - await asyncio.wait_for( - self._status_event.wait(), timeout=min(poll_interval, remaining) - ) - self._status_event.clear() - except asyncio.TimeoutError: - # Fallback: poll server (notification didn't arrive in time) - self._status_cache = await self._client.get_task_status(self._task_id) - - async def cancel(self) -> None: - """Cancel this task, transitioning it to cancelled state. - - Sends a tasks/cancel protocol request. The server will attempt to halt - execution and move the task to cancelled state. - - Note: If server executed immediately (graceful degradation), this is a no-op - as there's no server-side task to cancel. - """ - if self._is_immediate: - # No server-side task to cancel - return - self._check_client_connected() - await self._client.cancel_task(self._task_id) - # Invalidate cache to force fresh status fetch - self._status_cache = None - - def __await__(self): - """Allow 'await task' to get result.""" - return self.result().__await__() - - -class ToolTask(Task["CallToolResult"]): - """ - Represents a tool call that may execute in background or immediately. - - Provides a uniform API whether the server accepts background execution - or executes synchronously (graceful degradation per SEP-1686). - - Usage: - task = await client.call_tool_as_task("analyze", args) - - # Check status - status = await task.status() - - # Wait for completion - await task.wait() - - # Get result (waits if needed) - result = await task.result() # Returns CallToolResult - - # Or just await the task directly - result = await task - """ - - def __init__( - self, - client: Client, - task_id: str, - tool_name: str, - immediate_result: CallToolResult | None = None, - ): - """ - Create a ToolTask wrapper. - - Args: - client: The FastMCP client - task_id: The task identifier - tool_name: Name of the tool being executed - immediate_result: If server executed synchronously, the immediate result - """ - super().__init__(client, task_id, immediate_result) - self._tool_name = tool_name - - async def result(self) -> CallToolResult: - """Wait for and return the tool result. - - If server executed immediately, returns the immediate result. - Otherwise waits for background task to complete and retrieves result. - - Returns: - CallToolResult: The parsed tool result (same as call_tool returns) - """ - # Check cache first - if self._cached_result is not None: - return self._cached_result - - if self._is_immediate: - assert self._immediate_result is not None # Type narrowing - result = self._immediate_result - else: - # Check client connected - self._check_client_connected() - - # Wait for completion using event-based wait (respects notifications) - await self.wait() - - # Get the raw result (dict or CallToolResult) - raw_result = await self._client.get_task_result(self._task_id) - - # Convert to CallToolResult if needed and parse - if isinstance(raw_result, dict): - # Raw dict from get_task_result - parse as CallToolResult - mcp_result = mcp.types.CallToolResult.model_validate(raw_result) - result = await self._client._parse_call_tool_result( - self._tool_name, mcp_result, raise_on_error=True - ) - elif isinstance(raw_result, mcp.types.CallToolResult): - # Already a CallToolResult from MCP protocol - parse it - result = await self._client._parse_call_tool_result( - self._tool_name, raw_result, raise_on_error=True - ) - else: - # Legacy ToolResult format - convert to MCP type - if hasattr(raw_result, "content") and hasattr( - raw_result, "structured_content" - ): - mcp_result = mcp.types.CallToolResult( - content=raw_result.content, - structuredContent=raw_result.structured_content, - _meta=raw_result.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] - ) - result = await self._client._parse_call_tool_result( - self._tool_name, mcp_result, raise_on_error=True - ) - else: - # Unknown type - just return it - result = raw_result - - # Cache before returning - self._cached_result = result - return result - - -class PromptTask(Task[mcp.types.GetPromptResult]): - """ - Represents a prompt call that may execute in background or immediately. - - Provides a uniform API whether the server accepts background execution - or executes synchronously (graceful degradation per SEP-1686). - - Usage: - task = await client.get_prompt_as_task("analyze", args) - result = await task # Returns GetPromptResult - """ - - def __init__( - self, - client: Client, - task_id: str, - prompt_name: str, - immediate_result: mcp.types.GetPromptResult | None = None, - ): - """ - Create a PromptTask wrapper. - - Args: - client: The FastMCP client - task_id: The task identifier - prompt_name: Name of the prompt being executed - immediate_result: If server executed synchronously, the immediate result - """ - super().__init__(client, task_id, immediate_result) - self._prompt_name = prompt_name - - async def result(self) -> mcp.types.GetPromptResult: - """Wait for and return the prompt result. - - If server executed immediately, returns the immediate result. - Otherwise waits for background task to complete and retrieves result. - - Returns: - GetPromptResult: The prompt result with messages and description - """ - # Check cache first - if self._cached_result is not None: - return self._cached_result - - if self._is_immediate: - assert self._immediate_result is not None - result = self._immediate_result - else: - # Check client connected - self._check_client_connected() - - # Wait for completion using event-based wait (respects notifications) - await self.wait() - - # Get the raw MCP result - mcp_result = await self._client.get_task_result(self._task_id) - - # Parse as GetPromptResult - result = mcp.types.GetPromptResult.model_validate(mcp_result) - - # Cache before returning - self._cached_result = result - return result - - -class ResourceTask( - Task[list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]] -): - """ - Represents a resource read that may execute in background or immediately. - - Provides a uniform API whether the server accepts background execution - or executes synchronously (graceful degradation per SEP-1686). - - Usage: - task = await client.read_resource_as_task("file://data.txt") - contents = await task # Returns list[ReadResourceContents] - """ - - def __init__( - self, - client: Client, - task_id: str, - uri: str, - immediate_result: list[ - mcp.types.TextResourceContents | mcp.types.BlobResourceContents - ] - | None = None, - ): - """ - Create a ResourceTask wrapper. - - Args: - client: The FastMCP client - task_id: The task identifier - uri: URI of the resource being read - immediate_result: If server executed synchronously, the immediate result - """ - super().__init__(client, task_id, immediate_result) - self._uri = uri - - async def result( - self, - ) -> list[mcp.types.TextResourceContents | mcp.types.BlobResourceContents]: - """Wait for and return the resource contents. - - If server executed immediately, returns the immediate result. - Otherwise waits for background task to complete and retrieves result. - - Returns: - list[ReadResourceContents]: The resource contents - """ - # Check cache first - if self._cached_result is not None: - return self._cached_result - - if self._is_immediate: - assert self._immediate_result is not None - result = self._immediate_result - else: - # Check client connected - self._check_client_connected() - - # Wait for completion using event-based wait (respects notifications) - await self.wait() - - # Get the raw MCP result - mcp_result = await self._client.get_task_result(self._task_id) - - # Parse as ReadResourceResult or extract contents - if isinstance(mcp_result, mcp.types.ReadResourceResult): - # Already parsed by TasksResponse - extract contents - result = list(mcp_result.contents) - elif isinstance(mcp_result, dict) and "contents" in mcp_result: - # Dict format - parse each content item - parsed_contents = [] - for item in mcp_result["contents"]: - if isinstance(item, dict): - if "blob" in item: - parsed_contents.append( - mcp.types.BlobResourceContents.model_validate(item) - ) - else: - parsed_contents.append( - mcp.types.TextResourceContents.model_validate(item) - ) - else: - parsed_contents.append(item) - result = parsed_contents - else: - # Fallback - might be the list directly - result = mcp_result if isinstance(mcp_result, list) else [mcp_result] - - # Cache before returning - self._cached_result = result - return result diff --git a/src/fastmcp/client/telemetry.py b/src/fastmcp/client/telemetry.py deleted file mode 100644 index 10d6d825f..000000000 --- a/src/fastmcp/client/telemetry.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Client-side telemetry helpers.""" - -from collections.abc import Generator -from contextlib import contextmanager - -from opentelemetry.trace import Span, SpanKind, Status, StatusCode - -from fastmcp.telemetry import get_tracer - - -@contextmanager -def client_span( - name: str, - method: str, - component_key: str, - session_id: str | None = None, - resource_uri: str | None = None, -) -> Generator[Span, None, None]: - """Create a CLIENT span with standard MCP attributes. - - Automatically records any exception on the span and sets error status. - """ - tracer = get_tracer() - with tracer.start_as_current_span(name, kind=SpanKind.CLIENT) as span: - attrs: dict[str, str] = { - # RPC semantic conventions - "rpc.system": "mcp", - "rpc.method": method, - # MCP semantic conventions - "mcp.method.name": method, - # FastMCP-specific attributes - "fastmcp.component.key": component_key, - } - if session_id: - attrs["mcp.session.id"] = session_id - if resource_uri: - attrs["mcp.resource.uri"] = resource_uri - span.set_attributes(attrs) - try: - yield span - except Exception as e: - span.record_exception(e) - span.set_status(Status(StatusCode.ERROR)) - raise - - -__all__ = ["client_span"] diff --git a/src/fastmcp/client/transports/__init__.py b/src/fastmcp/client/transports/__init__.py deleted file mode 100644 index 010a7cb7c..000000000 --- a/src/fastmcp/client/transports/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -# Re-export all public APIs for backward compatibility -from mcp.server.fastmcp import FastMCP as FastMCP1Server - -from fastmcp.client.transports.base import ( - ClientTransport, - ClientTransportT, - SessionKwargs, -) -from fastmcp.client.transports.config import MCPConfigTransport -from fastmcp.client.transports.http import StreamableHttpTransport -from fastmcp.client.transports.inference import infer_transport -from fastmcp.client.transports.sse import SSETransport -from fastmcp.client.transports.memory import FastMCPTransport -from fastmcp.client.transports.stdio import ( - FastMCPStdioTransport, - NodeStdioTransport, - NpxStdioTransport, - PythonStdioTransport, - StdioTransport, - UvStdioTransport, - UvxStdioTransport, -) -from fastmcp.server.server import FastMCP - -__all__ = [ - "ClientTransport", - "FastMCPStdioTransport", - "FastMCPTransport", - "NodeStdioTransport", - "NpxStdioTransport", - "PythonStdioTransport", - "SSETransport", - "StdioTransport", - "StreamableHttpTransport", - "UvStdioTransport", - "UvxStdioTransport", - "infer_transport", -] diff --git a/src/fastmcp/client/transports/base.py b/src/fastmcp/client/transports/base.py deleted file mode 100644 index fb8047c89..000000000 --- a/src/fastmcp/client/transports/base.py +++ /dev/null @@ -1,82 +0,0 @@ -import abc -import contextlib -import datetime -from collections.abc import AsyncIterator -from typing import Literal, TypeVar - -import httpx -import mcp.types -from mcp import ClientSession -from mcp.client.session import ( - ElicitationFnT, - ListRootsFnT, - LoggingFnT, - MessageHandlerFnT, - SamplingFnT, -) -from typing_extensions import TypedDict, Unpack - -# TypeVar for preserving specific ClientTransport subclass types -ClientTransportT = TypeVar("ClientTransportT", bound="ClientTransport") - - -class SessionKwargs(TypedDict, total=False): - """Keyword arguments for the MCP ClientSession constructor.""" - - read_timeout_seconds: datetime.timedelta | None - sampling_callback: SamplingFnT | None - sampling_capabilities: mcp.types.SamplingCapability | None - list_roots_callback: ListRootsFnT | None - logging_callback: LoggingFnT | None - elicitation_callback: ElicitationFnT | None - message_handler: MessageHandlerFnT | None - client_info: mcp.types.Implementation | None - - -class ClientTransport(abc.ABC): - """ - Abstract base class for different MCP client transport mechanisms. - - A Transport is responsible for establishing and managing connections - to an MCP server, and providing a ClientSession within an async context. - - """ - - @abc.abstractmethod - @contextlib.asynccontextmanager - async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> AsyncIterator[ClientSession]: - """ - Establishes a connection and yields an active ClientSession. - - The ClientSession is *not* expected to be initialized in this context manager. - - The session is guaranteed to be valid only within the scope of the - async context manager. Connection setup and teardown are handled - within this context. - - Args: - **session_kwargs: Keyword arguments to pass to the ClientSession - constructor (e.g., callbacks, timeouts). - - Yields: - A mcp.ClientSession instance. - """ - raise NotImplementedError - yield # ty:ignore[invalid-yield] - - def __repr__(self) -> str: - # Basic representation for subclasses - return f"<{self.__class__.__name__}>" - - async def close(self): # noqa: B027 - """Close the transport.""" - - def get_session_id(self) -> str | None: - """Get the session ID for this transport, if available.""" - return None - - def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): - if auth is not None: - raise ValueError("This transport does not support auth") diff --git a/src/fastmcp/client/transports/config.py b/src/fastmcp/client/transports/config.py deleted file mode 100644 index cd1d59cfa..000000000 --- a/src/fastmcp/client/transports/config.py +++ /dev/null @@ -1,210 +0,0 @@ -import contextlib -import datetime -from collections.abc import AsyncIterator -from typing import Any - -from mcp import ClientSession -from typing_extensions import Unpack - -from fastmcp.client.transports.base import ClientTransport, SessionKwargs -from fastmcp.client.transports.memory import FastMCPTransport -from fastmcp.mcp_config import ( - MCPConfig, - MCPServerTypes, - RemoteMCPServer, - StdioMCPServer, - TransformingRemoteMCPServer, - TransformingStdioMCPServer, -) -from fastmcp.server.server import FastMCP, create_proxy -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class MCPConfigTransport(ClientTransport): - """Transport for connecting to one or more MCP servers defined in an MCPConfig. - - This transport provides a unified interface to multiple MCP servers defined in an MCPConfig - object or dictionary matching the MCPConfig schema. It supports two key scenarios: - - 1. If the MCPConfig contains exactly one server, it creates a direct transport to that server. - 2. If the MCPConfig contains multiple servers, it creates a composite client by mounting - all servers on a single FastMCP instance, with each server's name, by default, used as its mounting prefix. - - In the multiserver case, tools are accessible with the prefix pattern `{server_name}_{tool_name}` - and resources with the pattern `protocol://{server_name}/path/to/resource`. - - This is particularly useful for creating clients that need to interact with multiple specialized - MCP servers through a single interface, simplifying client code. - - Examples: - ```python - from fastmcp import Client - - # Create a config with multiple servers - config = { - "mcpServers": { - "weather": { - "url": "https://weather-api.example.com/mcp", - "transport": "http" - }, - "calendar": { - "url": "https://calendar-api.example.com/mcp", - "transport": "http" - } - } - } - - # Create a client with the config - client = Client(config) - - async with client: - # Access tools with prefixes - weather = await client.call_tool("weather_get_forecast", {"city": "London"}) - events = await client.call_tool("calendar_list_events", {"date": "2023-06-01"}) - - # Access resources with prefixed URIs - icons = await client.read_resource("weather://weather/icons/sunny") - ``` - """ - - def __init__(self, config: MCPConfig | dict, name_as_prefix: bool = True): - if isinstance(config, dict): - config = MCPConfig.from_dict(config) - self.config = config - self.name_as_prefix = name_as_prefix - self._transports: list[ClientTransport] = [] - - if not self.config.mcpServers: - raise ValueError("No MCP servers defined in the config") - - # For single server, create transport eagerly so it can be inspected - if len(self.config.mcpServers) == 1: - self.transport = next(iter(self.config.mcpServers.values())).to_transport() - self._transports.append(self.transport) - - @contextlib.asynccontextmanager - async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> AsyncIterator[ClientSession]: - # Single server - delegate directly to pre-created transport - if len(self.config.mcpServers) == 1: - async with self.transport.connect_session(**session_kwargs) as session: - yield session - return - - # Multiple servers - create composite with mounted proxies, connecting - # each ProxyClient so its underlying transport session stays alive for - # the duration of this context (fixes session persistence for - # streamable-http backends — see #2790). - timeout = session_kwargs.get("read_timeout_seconds") - composite = FastMCP[Any](name="MCPRouter") - - async with contextlib.AsyncExitStack() as stack: - # Close any previous transports from prior connections to avoid leaking - for t in self._transports: - await t.close() - self._transports = [] - - for name, server_config in self.config.mcpServers.items(): - try: - transport, _client, proxy = await self._create_proxy( - name, server_config, timeout, stack - ) - except Exception: # Broad catch is intentional: failure modes - # are diverse (OSError, TimeoutError, RuntimeError, etc.) - # and the whole point is to skip any server that can't connect. - logger.warning( - "Failed to connect to MCP server %r, skipping", - name, - exc_info=True, - ) - continue - self._transports.append(transport) - composite.mount(proxy, namespace=name if self.name_as_prefix else None) - - if not self._transports: - raise ConnectionError("All MCP servers failed to connect") - - async with FastMCPTransport(mcp=composite).connect_session( - **session_kwargs - ) as session: - yield session - - async def _create_proxy( - self, - name: str, - config: MCPServerTypes, - timeout: datetime.timedelta | None, - stack: contextlib.AsyncExitStack, - ) -> tuple[ClientTransport, Any, FastMCP[Any]]: - """Create underlying transport, proxy client, and proxy server for a single backend. - - The ProxyClient is connected via the AsyncExitStack *before* being - passed to create_proxy so the factory sees it as connected and reuses - the same session for all tool calls (instead of creating fresh copies). - - Returns a tuple of (transport, proxy_client, proxy_server). - """ - # Import here to avoid circular dependency - from fastmcp.server.providers.proxy import StatefulProxyClient - - tool_transforms = None - include_tags = None - exclude_tags = None - - # Handle transforming servers - call base class to_transport() for underlying transport - if isinstance(config, TransformingStdioMCPServer): - transport = StdioMCPServer.to_transport(config) - tool_transforms = config.tools - include_tags = config.include_tags - exclude_tags = config.exclude_tags - elif isinstance(config, TransformingRemoteMCPServer): - transport = RemoteMCPServer.to_transport(config) - tool_transforms = config.tools - include_tags = config.include_tags - exclude_tags = config.exclude_tags - else: - transport = config.to_transport() - - client = StatefulProxyClient(transport=transport, timeout=timeout) - # Connect the client *before* create_proxy so _create_client_factory - # detects it as connected and reuses it for all tool calls, preserving - # the session ID across requests. StatefulProxyClient is used instead - # of ProxyClient because its context-restoring handler wrappers prevent - # stale ContextVars in the reused session's receive loop. - # - # StatefulProxyClient.__aexit__ is a no-op (by design, for the - # new_stateful() use case), so we cannot rely on enter_async_context - # alone to clean up. Instead we connect manually and push an - # explicit force-disconnect callback so the subprocess is terminated - # when the AsyncExitStack unwinds. - await client.__aenter__() - # Callbacks run LIFO: transport.close() must run *after* - # client._disconnect so push it first. - stack.push_async_callback(transport.close) - stack.push_async_callback(client._disconnect, force=True) - # Create proxy without include_tags/exclude_tags - we'll add them after tool transforms - proxy = create_proxy( - client, - name=f"Proxy-{name}", - ) - # Add tool transforms FIRST - they may add/modify tags - if tool_transforms: - from fastmcp.server.transforms import ToolTransform - - proxy.add_transform(ToolTransform(tool_transforms)) - # Then add enabled filters - they filter based on tags - if include_tags: - proxy.enable(tags=set(include_tags), only=True) - if exclude_tags: - proxy.disable(tags=set(exclude_tags)) - return transport, client, proxy - - async def close(self): - for transport in self._transports: - await transport.close() - - def __repr__(self) -> str: - return f"" diff --git a/src/fastmcp/client/transports/http.py b/src/fastmcp/client/transports/http.py deleted file mode 100644 index 5ff52a091..000000000 --- a/src/fastmcp/client/transports/http.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Streamable HTTP transport for FastMCP Client.""" - -from __future__ import annotations - -import contextlib -import datetime -import ssl -from collections.abc import AsyncIterator, Callable -from typing import Any, Literal, cast - -import httpx -from mcp import ClientSession -from mcp.client.streamable_http import streamable_http_client -from mcp.shared._httpx_utils import McpHttpClientFactory, create_mcp_http_client -from pydantic import AnyUrl -from typing_extensions import Unpack - -import fastmcp -from fastmcp.client.auth.bearer import BearerAuth -from fastmcp.client.auth.oauth import OAuth -from fastmcp.client.transports.base import ClientTransport, SessionKwargs -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.dependencies import get_http_headers -from fastmcp.utilities.timeout import normalize_timeout_to_timedelta - - -class StreamableHttpTransport(ClientTransport): - """Transport implementation that connects to an MCP server via Streamable HTTP Requests.""" - - def __init__( - self, - url: str | AnyUrl, - headers: dict[str, str] | None = None, - auth: httpx.Auth | Literal["oauth"] | str | None = None, - sse_read_timeout: datetime.timedelta | float | int | None = None, - httpx_client_factory: McpHttpClientFactory | None = None, - verify: ssl.SSLContext | bool | str | None = None, - ): - """Initialize a Streamable HTTP transport. - - Args: - url: The MCP server endpoint URL. - headers: Optional headers to include in requests. - auth: Authentication method - httpx.Auth, "oauth" for OAuth flow, - or a bearer token string. - sse_read_timeout: Deprecated. Use read_timeout_seconds in session_kwargs. - httpx_client_factory: Optional factory for creating httpx.AsyncClient. - If provided, must accept keyword arguments: headers, auth, - follow_redirects, and optionally timeout. Using **kwargs is - recommended to ensure forward compatibility. - verify: SSL certificate verification. Accepts False to disable - verification, a path to a CA bundle, or an ssl.SSLContext - for full control. None (default) uses httpx defaults (verification - enabled). Ignored when httpx_client_factory is provided. - """ - if isinstance(url, AnyUrl): - url = str(url) - if not isinstance(url, str) or not url.startswith("http"): - raise ValueError("Invalid HTTP/S URL provided for Streamable HTTP.") - - # Don't modify the URL path - respect the exact URL provided by the user - # Some servers are strict about trailing slashes (e.g., PayPal MCP) - - self.url: str = url - self.headers = headers or {} - self.httpx_client_factory = httpx_client_factory - self.verify: ssl.SSLContext | bool | str | None = verify - - if httpx_client_factory is not None and verify is not None: - import warnings - - warnings.warn( - "Both 'httpx_client_factory' and 'verify' were provided. " - "The 'verify' parameter will be ignored because " - "'httpx_client_factory' takes precedence. Configure SSL " - "verification directly in your httpx_client_factory instead.", - UserWarning, - stacklevel=2, - ) - - self._set_auth(auth) - - if sse_read_timeout is not None: - if fastmcp.settings.deprecation_warnings: - import warnings - - warnings.warn( - "The `sse_read_timeout` parameter is deprecated and no longer used. " - "The new streamable_http_client API does not support this parameter. " - "Use `read_timeout_seconds` in session_kwargs or configure timeout on " - "the httpx client via `httpx_client_factory` instead.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - self.sse_read_timeout = normalize_timeout_to_timedelta(sse_read_timeout) - - self._get_session_id_cb: Callable[[], str | None] | None = None - - def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): - resolved: httpx.Auth | None - if auth == "oauth": - resolved = OAuth( - self.url, - httpx_client_factory=self.httpx_client_factory - or self._make_verify_factory(), - ) - elif isinstance(auth, OAuth): - auth._bind(self.url) - # Only inject the transport's factory into OAuth if OAuth still - # has the bare default — preserve any factory the caller attached - if auth.httpx_client_factory is httpx.AsyncClient: - factory = self.httpx_client_factory or self._make_verify_factory() - if factory is not None: - auth.httpx_client_factory = factory - resolved = auth - elif isinstance(auth, str): - resolved = BearerAuth(auth) - else: - resolved = auth - self.auth: httpx.Auth | None = resolved - - def _make_verify_factory(self) -> McpHttpClientFactory | None: - if self.verify is None: - return None - verify = self.verify - - def factory( - headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: - if timeout is None: - timeout = httpx.Timeout(30.0, read=300.0) - kwargs: dict[str, Any] = { - "follow_redirects": True, - "timeout": timeout, - "verify": verify, - } - if headers is not None: - kwargs["headers"] = headers - if auth is not None: - kwargs["auth"] = auth - return httpx.AsyncClient(**kwargs) - - return cast(McpHttpClientFactory, factory) - - @contextlib.asynccontextmanager - async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> AsyncIterator[ClientSession]: - # Load headers from an active HTTP request, if available. This will only be true - # if the client is used in a FastMCP Proxy, in which case the MCP client headers - # need to be forwarded to the remote server. - headers = get_http_headers(include={"authorization"}) | self.headers - - # Configure timeout if provided, preserving MCP's 30s connect default - timeout: httpx.Timeout | None = None - if session_kwargs.get("read_timeout_seconds") is not None: - read_timeout_seconds = cast( - datetime.timedelta, session_kwargs.get("read_timeout_seconds") - ) - timeout = httpx.Timeout(30.0, read=read_timeout_seconds.total_seconds()) - - # Create httpx client from factory or use default with MCP-appropriate - # timeouts. Note: create_mcp_http_client enables follow_redirects, but - # httpx automatically strips Authorization headers on cross-origin - # redirects to prevent credential leakage. - verify_factory = self._make_verify_factory() - if self.httpx_client_factory is not None: - http_client = self.httpx_client_factory( - headers=headers, - auth=self.auth, - follow_redirects=True, # type: ignore[call-arg] # ty:ignore[unknown-argument] - **({"timeout": timeout} if timeout else {}), - ) - elif verify_factory is not None: - http_client = verify_factory( - headers=headers, - timeout=timeout, - auth=self.auth, - ) - else: - http_client = create_mcp_http_client( - headers=headers, - timeout=timeout, - auth=self.auth, - ) - - # Ensure httpx client is closed after use - async with ( - http_client, - streamable_http_client(self.url, http_client=http_client) as transport, - ): - read_stream, write_stream, get_session_id = transport - self._get_session_id_cb = get_session_id - async with ClientSession( - read_stream, write_stream, **session_kwargs - ) as session: - yield session - - def get_session_id(self) -> str | None: - if self._get_session_id_cb: - try: - return self._get_session_id_cb() - except Exception: - return None - return None - - async def close(self): - # Reset the session id callback - self._get_session_id_cb = None - - def __repr__(self) -> str: - return f"" diff --git a/src/fastmcp/client/transports/inference.py b/src/fastmcp/client/transports/inference.py deleted file mode 100644 index 438995c23..000000000 --- a/src/fastmcp/client/transports/inference.py +++ /dev/null @@ -1,154 +0,0 @@ -from pathlib import Path -from typing import TYPE_CHECKING, Any, cast, overload - -from mcp.server.fastmcp import FastMCP as FastMCP1Server -from pydantic import AnyUrl - -from fastmcp.client.transports.base import ClientTransport, ClientTransportT -from fastmcp.client.transports.config import MCPConfigTransport -from fastmcp.client.transports.http import StreamableHttpTransport -from fastmcp.client.transports.memory import FastMCPTransport -from fastmcp.client.transports.sse import SSETransport -from fastmcp.client.transports.stdio import NodeStdioTransport, PythonStdioTransport -from fastmcp.mcp_config import MCPConfig, infer_transport_type_from_url -from fastmcp.server.server import FastMCP -from fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - pass - -logger = get_logger(__name__) - - -@overload -def infer_transport(transport: ClientTransportT) -> ClientTransportT: ... - - -@overload -def infer_transport(transport: FastMCP) -> FastMCPTransport: ... - - -@overload -def infer_transport(transport: FastMCP1Server) -> FastMCPTransport: ... - - -@overload -def infer_transport(transport: MCPConfig) -> MCPConfigTransport: ... - - -@overload -def infer_transport(transport: dict[str, Any]) -> MCPConfigTransport: ... - - -@overload -def infer_transport( - transport: AnyUrl, -) -> SSETransport | StreamableHttpTransport: ... - - -@overload -def infer_transport( - transport: str, -) -> ( - PythonStdioTransport | NodeStdioTransport | SSETransport | StreamableHttpTransport -): ... - - -@overload -def infer_transport(transport: Path) -> PythonStdioTransport | NodeStdioTransport: ... - - -def infer_transport( - transport: ClientTransport - | FastMCP - | FastMCP1Server - | AnyUrl - | Path - | MCPConfig - | dict[str, Any] - | str, -) -> ClientTransport: - """ - Infer the appropriate transport type from the given transport argument. - - This function attempts to infer the correct transport type from the provided - argument, handling various input types and converting them to the appropriate - ClientTransport subclass. - - The function supports these input types: - - ClientTransport: Used directly without modification - - FastMCP or FastMCP1Server: Creates an in-memory FastMCPTransport - - Path or str (file path): Creates PythonStdioTransport (.py) or NodeStdioTransport (.js) - - AnyUrl or str (URL): Creates StreamableHttpTransport (default) or SSETransport (for /sse endpoints) - - MCPConfig or dict: Creates MCPConfigTransport, potentially connecting to multiple servers - - For HTTP URLs, they are assumed to be Streamable HTTP URLs unless they end in `/sse`. - - For MCPConfig with multiple servers, a composite client is created where each server - is mounted with its name as prefix. This allows accessing tools and resources from multiple - servers through a single unified client interface, using naming patterns like - `servername_toolname` for tools and `protocol://servername/path` for resources. - If the MCPConfig contains only one server, a direct connection is established without prefixing. - - Examples: - ```python - # Connect to a local Python script - transport = infer_transport("my_script.py") - - # Connect to a remote server via HTTP - transport = infer_transport("http://example.com/mcp") - - # Connect to multiple servers using MCPConfig - config = { - "mcpServers": { - "weather": {"url": "http://weather.example.com/mcp"}, - "calendar": {"url": "http://calendar.example.com/mcp"} - } - } - transport = infer_transport(config) - ``` - """ - - # the transport is already a ClientTransport - if isinstance(transport, ClientTransport): - return transport - - # the transport is a FastMCP server (2.x or 1.0) - elif isinstance(transport, FastMCP | FastMCP1Server): - inferred_transport = FastMCPTransport( - mcp=cast(FastMCP[Any] | FastMCP1Server, transport) - ) - - # the transport is a path to a script - elif isinstance(transport, Path | str) and Path(transport).exists(): - if str(transport).endswith(".py"): - inferred_transport = PythonStdioTransport(script_path=cast(Path, transport)) - elif str(transport).endswith(".js"): - inferred_transport = NodeStdioTransport(script_path=cast(Path, transport)) - else: - raise ValueError(f"Unsupported script type: {transport}") - - # the transport is an http(s) URL - elif isinstance(transport, AnyUrl | str) and str(transport).startswith("http"): - inferred_transport_type = infer_transport_type_from_url( - cast(AnyUrl | str, transport) - ) - if inferred_transport_type == "sse": - inferred_transport = SSETransport(url=cast(AnyUrl | str, transport)) - else: - inferred_transport = StreamableHttpTransport( - url=cast(AnyUrl | str, transport) - ) - - # if the transport is a config dict or MCPConfig - elif isinstance(transport, dict | MCPConfig): - inferred_transport = MCPConfigTransport( - config=cast(dict | MCPConfig, transport) - ) - - # the transport is an unknown type - else: - raise ValueError(f"Could not infer a valid transport from: {transport}") - - logger.debug(f"Inferred transport: {inferred_transport}") - return inferred_transport diff --git a/src/fastmcp/client/transports/memory.py b/src/fastmcp/client/transports/memory.py deleted file mode 100644 index 7b2cbc8e4..000000000 --- a/src/fastmcp/client/transports/memory.py +++ /dev/null @@ -1,97 +0,0 @@ -import contextlib -from collections.abc import AsyncIterator - -import anyio -from mcp import ClientSession -from mcp.server.fastmcp import FastMCP as FastMCP1Server -from mcp.shared.memory import create_client_server_memory_streams -from typing_extensions import Unpack - -from fastmcp.client.transports.base import ClientTransport, SessionKwargs -from fastmcp.server.server import FastMCP - - -class FastMCPTransport(ClientTransport): - """In-memory transport for FastMCP servers. - - This transport connects directly to a FastMCP server instance in the same - Python process. It works with both FastMCP 2.x servers and FastMCP 1.0 - servers from the low-level MCP SDK. This is particularly useful for unit - tests or scenarios where client and server run in the same runtime. - """ - - def __init__(self, mcp: FastMCP | FastMCP1Server, raise_exceptions: bool = False): - """Initialize a FastMCPTransport from a FastMCP server instance.""" - - # Accept both FastMCP 2.x and FastMCP 1.0 servers. Both expose a - # ``_mcp_server`` attribute pointing to the underlying MCP server - # implementation, so we can treat them identically. - self.server = mcp - self.raise_exceptions = raise_exceptions - - @contextlib.asynccontextmanager - async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> AsyncIterator[ClientSession]: - async with create_client_server_memory_streams() as ( - client_streams, - server_streams, - ): - client_read, client_write = client_streams - server_read, server_write = server_streams - - # Capture exceptions to re-raise after task group cleanup. - # anyio task groups can suppress exceptions when cancel_scope.cancel() - # is called during cleanup, so we capture and re-raise manually. - exception_to_raise: BaseException | None = None - - # IMPORTANT: The lifespan MUST be the outer context and the task - # group MUST be the inner context. This ensures the task group - # (containing the server's run() and all its pub/sub subscriptions) - # is cancelled and fully drained BEFORE the lifespan tears down - # the Docket Worker and closes Redis connections. Reversing this - # order (e.g. via `async with (tg, lifespan):`) causes the Worker - # shutdown to hang for 5 seconds per test because fakeredis - # blocking operations hold references that prevent clean - # cancellation. - async with _enter_server_lifespan(server=self.server): # noqa: SIM117 - async with anyio.create_task_group() as tg: - tg.start_soon( - lambda: self.server._mcp_server.run( - server_read, - server_write, - self.server._mcp_server.create_initialization_options(), - raise_exceptions=self.raise_exceptions, - ) - ) - - try: - async with ClientSession( - read_stream=client_read, - write_stream=client_write, - **session_kwargs, - ) as client_session: - yield client_session - except BaseException as e: - exception_to_raise = e - finally: - tg.cancel_scope.cancel() - - # Re-raise after task group has exited cleanly - if exception_to_raise is not None: - raise exception_to_raise - - def __repr__(self) -> str: - return f"" - - -@contextlib.asynccontextmanager -async def _enter_server_lifespan( - server: FastMCP | FastMCP1Server, -) -> AsyncIterator[None]: - """Enters the server's lifespan context for FastMCP servers and does nothing for FastMCP 1 servers.""" - if isinstance(server, FastMCP): - async with server._lifespan_manager(): - yield - else: - yield diff --git a/src/fastmcp/client/transports/sse.py b/src/fastmcp/client/transports/sse.py deleted file mode 100644 index fa900eb7b..000000000 --- a/src/fastmcp/client/transports/sse.py +++ /dev/null @@ -1,152 +0,0 @@ -"""Server-Sent Events (SSE) transport for FastMCP Client.""" - -from __future__ import annotations - -import contextlib -import datetime -import ssl -from collections.abc import AsyncIterator -from typing import Any, Literal, cast - -import httpx -from mcp import ClientSession -from mcp.client.sse import sse_client -from mcp.shared._httpx_utils import McpHttpClientFactory -from pydantic import AnyUrl -from typing_extensions import Unpack - -from fastmcp.client.auth.bearer import BearerAuth -from fastmcp.client.auth.oauth import OAuth -from fastmcp.client.transports.base import ClientTransport, SessionKwargs -from fastmcp.server.dependencies import get_http_headers -from fastmcp.utilities.timeout import normalize_timeout_to_timedelta - - -class SSETransport(ClientTransport): - """Transport implementation that connects to an MCP server via Server-Sent Events.""" - - def __init__( - self, - url: str | AnyUrl, - headers: dict[str, str] | None = None, - auth: httpx.Auth | Literal["oauth"] | str | None = None, - sse_read_timeout: datetime.timedelta | float | int | None = None, - httpx_client_factory: McpHttpClientFactory | None = None, - verify: ssl.SSLContext | bool | str | None = None, - ): - if isinstance(url, AnyUrl): - url = str(url) - if not isinstance(url, str) or not url.startswith("http"): - raise ValueError("Invalid HTTP/S URL provided for SSE.") - - # Don't modify the URL path - respect the exact URL provided by the user - # Some servers are strict about trailing slashes (e.g., PayPal MCP) - - self.url: str = url - self.headers = headers or {} - self.httpx_client_factory = httpx_client_factory - self.verify: ssl.SSLContext | bool | str | None = verify - - if httpx_client_factory is not None and verify is not None: - import warnings - - warnings.warn( - "Both 'httpx_client_factory' and 'verify' were provided. " - "The 'verify' parameter will be ignored because " - "'httpx_client_factory' takes precedence. Configure SSL " - "verification directly in your httpx_client_factory instead.", - UserWarning, - stacklevel=2, - ) - - self._set_auth(auth) - - self.sse_read_timeout = normalize_timeout_to_timedelta(sse_read_timeout) - - def _set_auth(self, auth: httpx.Auth | Literal["oauth"] | str | None): - resolved: httpx.Auth | None - if auth == "oauth": - resolved = OAuth( - self.url, - httpx_client_factory=self.httpx_client_factory - or self._make_verify_factory(), - ) - elif isinstance(auth, OAuth): - auth._bind(self.url) - # Only inject the transport's factory into OAuth if OAuth still - # has the bare default — preserve any factory the caller attached - if auth.httpx_client_factory is httpx.AsyncClient: - factory = self.httpx_client_factory or self._make_verify_factory() - if factory is not None: - auth.httpx_client_factory = factory - resolved = auth - elif isinstance(auth, str): - resolved = BearerAuth(auth) - else: - resolved = auth - self.auth: httpx.Auth | None = resolved - - def _make_verify_factory(self) -> McpHttpClientFactory | None: - if self.verify is None: - return None - verify = self.verify - - def factory( - headers: dict[str, str] | None = None, - timeout: httpx.Timeout | None = None, - auth: httpx.Auth | None = None, - ) -> httpx.AsyncClient: - if timeout is None: - timeout = httpx.Timeout(30.0, read=300.0) - kwargs: dict[str, Any] = { - "follow_redirects": True, - "timeout": timeout, - "verify": verify, - } - if headers is not None: - kwargs["headers"] = headers - if auth is not None: - kwargs["auth"] = auth - return httpx.AsyncClient(**kwargs) - - return cast(McpHttpClientFactory, factory) - - @contextlib.asynccontextmanager - async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> AsyncIterator[ClientSession]: - client_kwargs: dict[str, Any] = {} - - # load headers from an active HTTP request, if available. This will only be true - # if the client is used in a FastMCP Proxy, in which case the MCP client headers - # need to be forwarded to the remote server. - client_kwargs["headers"] = ( - get_http_headers(include={"authorization"}) | self.headers - ) - - # sse_read_timeout has a default value set, so we can't pass None without overriding it - # instead we simply leave the kwarg out if it's not provided - if self.sse_read_timeout is not None: - client_kwargs["sse_read_timeout"] = self.sse_read_timeout.total_seconds() - if session_kwargs.get("read_timeout_seconds") is not None: - read_timeout_seconds = cast( - datetime.timedelta, session_kwargs.get("read_timeout_seconds") - ) - client_kwargs["timeout"] = read_timeout_seconds.total_seconds() - - if self.httpx_client_factory is not None: - client_kwargs["httpx_client_factory"] = self.httpx_client_factory - else: - verify_factory = self._make_verify_factory() - if verify_factory is not None: - client_kwargs["httpx_client_factory"] = verify_factory - - async with sse_client(self.url, auth=self.auth, **client_kwargs) as transport: - read_stream, write_stream = transport - async with ClientSession( - read_stream, write_stream, **session_kwargs - ) as session: - yield session - - def __repr__(self) -> str: - return f"" diff --git a/src/fastmcp/client/transports/stdio.py b/src/fastmcp/client/transports/stdio.py deleted file mode 100644 index d772c3a88..000000000 --- a/src/fastmcp/client/transports/stdio.py +++ /dev/null @@ -1,570 +0,0 @@ -import asyncio -import contextlib -import os -import shutil -import sys -from collections.abc import AsyncIterator -from pathlib import Path -from typing import TextIO, cast - -import anyio -from mcp import ClientSession, StdioServerParameters -from mcp.client.stdio import stdio_client -from typing_extensions import Unpack - -from fastmcp.client.transports.base import ClientTransport, SessionKwargs -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment - -logger = get_logger(__name__) - - -class StdioTransport(ClientTransport): - """ - Base transport for connecting to an MCP server via subprocess with stdio. - - This is a base class that can be subclassed for specific command-based - transports like Python, Node, Uvx, etc. - """ - - def __init__( - self, - command: str, - args: list[str], - env: dict[str, str] | None = None, - cwd: str | None = None, - keep_alive: bool | None = None, - log_file: Path | TextIO | None = None, - ): - """ - Initialize a Stdio transport. - - Args: - command: The command to run (e.g., "python", "node", "uvx") - args: The arguments to pass to the command - env: Environment variables to set for the subprocess - cwd: Current working directory for the subprocess - keep_alive: Whether to keep the subprocess alive between connections. - Defaults to True. When True, the subprocess remains active - after the connection context exits, allowing reuse in - subsequent connections. - log_file: Optional path or file-like object where subprocess stderr will - be written. Can be a Path or TextIO object. Defaults to sys.stderr - if not provided. When a Path is provided, the file will be created - if it doesn't exist, or appended to if it does. When set, server - errors will be written to this file instead of appearing in the console. - """ - self.command = command - self.args = args - self.env = env - self.cwd = cwd - if keep_alive is None: - keep_alive = True - self.keep_alive = keep_alive - self.log_file = log_file - - self._session: ClientSession | None = None - self._connect_task: asyncio.Task | None = None - self._ready_event = anyio.Event() - self._stop_event = anyio.Event() - - @contextlib.asynccontextmanager - async def connect_session( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> AsyncIterator[ClientSession]: - try: - await self.connect(**session_kwargs) - yield cast(ClientSession, self._session) - finally: - if not self.keep_alive: - await self.disconnect() - else: - logger.debug("Stdio transport has keep_alive=True, not disconnecting") - - async def connect( - self, **session_kwargs: Unpack[SessionKwargs] - ) -> ClientSession | None: - # If the connect task completed or the session's streams are dead, - # the subprocess has exited. Tear down so we can start fresh. - if self._connect_task is not None and ( - self._connect_task.done() or self._is_session_dead() - ): - await self.disconnect() - - if self._connect_task is not None: - return - - session_future: asyncio.Future[ClientSession] = asyncio.Future() - - # start the connection task - self._connect_task = asyncio.create_task( - _stdio_transport_connect_task( - command=self.command, - args=self.args, - env=self.env, - cwd=self.cwd, - log_file=self.log_file, - # TODO(ty): remove when ty supports Unpack[TypedDict] inference - session_kwargs=session_kwargs, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - ready_event=self._ready_event, - stop_event=self._stop_event, - session_future=session_future, - ) - ) - - # wait for the client to be ready before returning - await self._ready_event.wait() - - # Check if connect task completed with an exception (early failure) - if self._connect_task.done(): - exception = self._connect_task.exception() - if exception is not None: - raise exception - - self._session = await session_future - return self._session - - async def disconnect(self): - if self._connect_task is None: - return - - # signal the connection task to stop - self._stop_event.set() - - # wait for the connection task to finish cleanly - with contextlib.suppress(Exception): - await self._connect_task - - # reset variables and events for potential future reconnects - self._connect_task = None - self._session = None - self._stop_event = anyio.Event() - self._ready_event = anyio.Event() - - def _is_session_dead(self) -> bool: - """Check if the session's underlying streams have been closed. - - Checks both the write stream (stdin to subprocess) and the read - stream (stdout from subprocess). On some platforms the write-side - pipe lingers after the process exits, so the read-side check - (which reflects stdout_reader detecting the dead process) is the - more reliable signal. - """ - if self._session is None: - return False - try: - if self._session._write_stream.statistics().open_send_streams == 0: - return True - return self._session._read_stream.statistics().open_send_streams == 0 - except AttributeError: - return False - - async def close(self): - await self.disconnect() - - def __del__(self): - """Ensure that we send a disconnection signal to the transport task if we are being garbage collected.""" - if not self._stop_event.is_set(): - self._stop_event.set() - - def __repr__(self) -> str: - return ( - f"<{self.__class__.__name__}(command='{self.command}', args={self.args})>" - ) - - -async def _stdio_transport_connect_task( - command: str, - args: list[str], - env: dict[str, str] | None, - cwd: str | None, - log_file: Path | TextIO | None, - session_kwargs: SessionKwargs, - ready_event: anyio.Event, - stop_event: anyio.Event, - session_future: asyncio.Future[ClientSession], -): - """A standalone connection task for a stdio transport. It is not a part of the StdioTransport class - to ensure that the connection task does not hold a reference to the Transport object.""" - - try: - async with contextlib.AsyncExitStack() as stack: - try: - server_params = StdioServerParameters( - command=command, - args=args, - env=env, - cwd=cwd, - ) - # Handle log_file: Path needs to be opened, TextIO used as-is - if log_file is None: - log_file_handle = sys.stderr - elif isinstance(log_file, Path): - log_file_handle = stack.enter_context(log_file.open("a")) - else: - # Must be TextIO - use it directly - log_file_handle = log_file - - transport = await stack.enter_async_context( - stdio_client(server_params, errlog=log_file_handle) - ) - read_stream, write_stream = transport - session_future.set_result( - await stack.enter_async_context( - ClientSession(read_stream, write_stream, **session_kwargs) - ) - ) - - logger.debug("Stdio transport connected") - ready_event.set() - - # Wait until disconnect is requested (stop_event is set) - await stop_event.wait() - finally: - # Clean up client on exit - logger.debug("Stdio transport disconnected") - except Exception: - # Ensure ready event is set even if connection fails - ready_event.set() - raise - - -class PythonStdioTransport(StdioTransport): - """Transport for running Python scripts.""" - - def __init__( - self, - script_path: str | Path, - args: list[str] | None = None, - env: dict[str, str] | None = None, - cwd: str | None = None, - python_cmd: str = sys.executable, - keep_alive: bool | None = None, - log_file: Path | TextIO | None = None, - ): - """ - Initialize a Python transport. - - Args: - script_path: Path to the Python script to run - args: Additional arguments to pass to the script - env: Environment variables to set for the subprocess - cwd: Current working directory for the subprocess - python_cmd: Python command to use (default: "python") - keep_alive: Whether to keep the subprocess alive between connections. - Defaults to True. When True, the subprocess remains active - after the connection context exits, allowing reuse in - subsequent connections. - log_file: Optional path or file-like object where subprocess stderr will - be written. Can be a Path or TextIO object. Defaults to sys.stderr - if not provided. When a Path is provided, the file will be created - if it doesn't exist, or appended to if it does. When set, server - errors will be written to this file instead of appearing in the console. - """ - script_path = Path(script_path).resolve() - if not script_path.is_file(): - raise FileNotFoundError(f"Script not found: {script_path}") - if not str(script_path).endswith(".py"): - raise ValueError(f"Not a Python script: {script_path}") - - full_args = [str(script_path)] - if args: - full_args.extend(args) - - super().__init__( - command=python_cmd, - args=full_args, - env=env, - cwd=cwd, - keep_alive=keep_alive, - log_file=log_file, - ) - self.script_path = script_path - - -class FastMCPStdioTransport(StdioTransport): - """Transport for running FastMCP servers using the FastMCP CLI.""" - - def __init__( - self, - script_path: str | Path, - args: list[str] | None = None, - env: dict[str, str] | None = None, - cwd: str | None = None, - keep_alive: bool | None = None, - log_file: Path | TextIO | None = None, - ): - script_path = Path(script_path).resolve() - if not script_path.is_file(): - raise FileNotFoundError(f"Script not found: {script_path}") - if not str(script_path).endswith(".py"): - raise ValueError(f"Not a Python script: {script_path}") - - super().__init__( - command="fastmcp", - args=["run", str(script_path)], - env=env, - cwd=cwd, - keep_alive=keep_alive, - log_file=log_file, - ) - self.script_path = script_path - - -class NodeStdioTransport(StdioTransport): - """Transport for running Node.js scripts.""" - - def __init__( - self, - script_path: str | Path, - args: list[str] | None = None, - env: dict[str, str] | None = None, - cwd: str | None = None, - node_cmd: str = "node", - keep_alive: bool | None = None, - log_file: Path | TextIO | None = None, - ): - """ - Initialize a Node transport. - - Args: - script_path: Path to the Node.js script to run - args: Additional arguments to pass to the script - env: Environment variables to set for the subprocess - cwd: Current working directory for the subprocess - node_cmd: Node.js command to use (default: "node") - keep_alive: Whether to keep the subprocess alive between connections. - Defaults to True. When True, the subprocess remains active - after the connection context exits, allowing reuse in - subsequent connections. - log_file: Optional path or file-like object where subprocess stderr will - be written. Can be a Path or TextIO object. Defaults to sys.stderr - if not provided. When a Path is provided, the file will be created - if it doesn't exist, or appended to if it does. When set, server - errors will be written to this file instead of appearing in the console. - """ - script_path = Path(script_path).resolve() - if not script_path.is_file(): - raise FileNotFoundError(f"Script not found: {script_path}") - if not str(script_path).endswith(".js"): - raise ValueError(f"Not a JavaScript script: {script_path}") - - full_args = [str(script_path)] - if args: - full_args.extend(args) - - super().__init__( - command=node_cmd, - args=full_args, - env=env, - cwd=cwd, - keep_alive=keep_alive, - log_file=log_file, - ) - self.script_path = script_path - - -class UvStdioTransport(StdioTransport): - """Transport for running commands via the uv tool.""" - - def __init__( - self, - command: str, - args: list[str] | None = None, - module: bool = False, - project_directory: Path | None = None, - python_version: str | None = None, - with_packages: list[str] | None = None, - with_requirements: Path | None = None, - env_vars: dict[str, str] | None = None, - keep_alive: bool | None = None, - ): - # Basic validation - if project_directory and not project_directory.exists(): - raise NotADirectoryError( - f"Project directory not found: {project_directory}" - ) - - # Create Environment from provided parameters (internal use) - env_config = UVEnvironment( - python=python_version, - dependencies=with_packages, - requirements=with_requirements, - project=project_directory, - editable=None, # Not exposed in this transport - ) - - # Build uv arguments using the config - uv_args: list[str] = [] - - # Check if we need any environment setup - if env_config._must_run_with_uv(): - # Use the config to build args, but we need to handle the command differently - # since transport has specific needs - uv_args = ["run"] - - if python_version: - uv_args.extend(["--python", python_version]) - if project_directory: - uv_args.extend(["--directory", str(project_directory)]) - - # Note: Don't add fastmcp as dependency here, transport is for general use - for pkg in with_packages or []: - uv_args.extend(["--with", pkg]) - if with_requirements: - uv_args.extend(["--with-requirements", str(with_requirements)]) - else: - # No environment setup needed - uv_args = ["run"] - - if module: - uv_args.append("--module") - - if not args: - args = [] - - uv_args.extend([command, *args]) - - # Get environment with any additional variables - env: dict[str, str] | None = None - if env_vars or project_directory: - env = os.environ.copy() - if project_directory: - env["UV_PROJECT_DIR"] = str(project_directory) - if env_vars: - env.update(env_vars) - - super().__init__( - command="uv", - args=uv_args, - env=env, - cwd=None, # Use --directory flag instead of cwd - keep_alive=keep_alive, - ) - - -class UvxStdioTransport(StdioTransport): - """Transport for running commands via the uvx tool.""" - - def __init__( - self, - tool_name: str, - tool_args: list[str] | None = None, - project_directory: str | None = None, - python_version: str | None = None, - with_packages: list[str] | None = None, - from_package: str | None = None, - env_vars: dict[str, str] | None = None, - keep_alive: bool | None = None, - ): - """ - Initialize a Uvx transport. - - Args: - tool_name: Name of the tool to run via uvx - tool_args: Arguments to pass to the tool - project_directory: Project directory (for package resolution) - python_version: Python version to use - with_packages: Additional packages to include - from_package: Package to install the tool from - env_vars: Additional environment variables - keep_alive: Whether to keep the subprocess alive between connections. - Defaults to True. When True, the subprocess remains active - after the connection context exits, allowing reuse in - subsequent connections. - """ - # Basic validation - if project_directory and not Path(project_directory).exists(): - raise NotADirectoryError( - f"Project directory not found: {project_directory}" - ) - - # Build uvx arguments - uvx_args: list[str] = [] - if python_version: - uvx_args.extend(["--python", python_version]) - if from_package: - uvx_args.extend(["--from", from_package]) - for pkg in with_packages or []: - uvx_args.extend(["--with", pkg]) - - # Add the tool name and tool args - uvx_args.append(tool_name) - if tool_args: - uvx_args.extend(tool_args) - - env: dict[str, str] | None = None - if env_vars: - env = os.environ.copy() - env.update(env_vars) - - super().__init__( - command="uvx", - args=uvx_args, - env=env, - cwd=project_directory, - keep_alive=keep_alive, - ) - self.tool_name: str = tool_name - - -class NpxStdioTransport(StdioTransport): - """Transport for running commands via the npx tool.""" - - def __init__( - self, - package: str, - args: list[str] | None = None, - project_directory: str | None = None, - env_vars: dict[str, str] | None = None, - use_package_lock: bool = True, - keep_alive: bool | None = None, - ): - """ - Initialize an Npx transport. - - Args: - package: Name of the npm package to run - args: Arguments to pass to the package command - project_directory: Project directory with package.json - env_vars: Additional environment variables - use_package_lock: Whether to use package-lock.json (--prefer-offline) - keep_alive: Whether to keep the subprocess alive between connections. - Defaults to True. When True, the subprocess remains active - after the connection context exits, allowing reuse in - subsequent connections. - """ - # verify npx is installed - if shutil.which("npx") is None: - raise ValueError("Command 'npx' not found") - - # Basic validation - if project_directory and not Path(project_directory).exists(): - raise NotADirectoryError( - f"Project directory not found: {project_directory}" - ) - - # Build npx arguments - npx_args = [] - if use_package_lock: - npx_args.append("--prefer-offline") - - # Add the package name and args - npx_args.append(package) - if args: - npx_args.extend(args) - - # Get environment with any additional variables - env = None - if env_vars: - env = os.environ.copy() - env.update(env_vars) - - super().__init__( - command="npx", - args=npx_args, - env=env, - cwd=project_directory, - keep_alive=keep_alive, - ) - self.package = package diff --git a/src/fastmcp/contrib/README.md b/src/fastmcp/contrib/README.md deleted file mode 100644 index 7b1dbb550..000000000 --- a/src/fastmcp/contrib/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# FastMCP Contrib Modules - -This directory holds community-contributed modules for FastMCP. These modules extend FastMCP's functionality but are not officially maintained by the core team. - -**Guarantees:** -* Modules in `contrib` may have different testing requirements or stability guarantees compared to the core library. -* Changes to the core FastMCP library might break modules in `contrib` without explicit warnings in the main changelog. - -Use these modules at your own discretion. Contributions are welcome, but please include tests and documentation. - -## Usage - -To use a contrib module, import it from the `fastmcp.contrib` package. - -```python -from fastmcp.contrib import my_module -``` - -Note that the contrib modules may have different dependencies than the core library, which can be noted in their respective README's or even separate requirements / dependency files. \ No newline at end of file diff --git a/src/fastmcp/contrib/bulk_tool_caller/README.md b/src/fastmcp/contrib/bulk_tool_caller/README.md deleted file mode 100644 index 41daa7780..000000000 --- a/src/fastmcp/contrib/bulk_tool_caller/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Bulk Tool Caller - -This module provides the `BulkToolCaller` class, which extends the `MCPMixin` to offer tools for performing multiple tool calls in a single request to a FastMCP server. This can be useful for optimizing interactions with the server by reducing the overhead of individual tool calls. - -## Usage - -To use the `BulkToolCaller`, see the example [example.py](./example.py) file. The `BulkToolCaller` can be instantiated and then registered with a FastMCP server URL. It provides methods to call multiple tools in bulk, either different tools or the same tool with different arguments. - - -## Provided Tools - -The `BulkToolCaller` provides the following tools: - -### `call_tools_bulk` - -Calls multiple different tools registered on the MCP server in a single request. - -- **Arguments:** - - `tool_calls` (list of `CallToolRequest`): A list of objects, where each object specifies the `tool` name and `arguments` for an individual tool call. - - `continue_on_error` (bool, optional): If `True`, continue executing subsequent tool calls even if a previous one resulted in an error. Defaults to `True`. - -- **Returns:** - A list of `CallToolRequestResult` objects, each containing the result (`isError`, `content`) and the original `tool` name and `arguments` for each call. - -### `call_tool_bulk` - -Calls a single tool registered on the MCP server multiple times with different arguments in a single request. - -- **Arguments:** - - `tool` (str): The name of the tool to call. - - `tool_arguments` (list of dict): A list of dictionaries, where each dictionary contains the arguments for an individual run of the tool. - - `continue_on_error` (bool, optional): If `True`, continue executing subsequent tool calls even if a previous one resulted in an error. Defaults to `True`. - -- **Returns:** - A list of `CallToolRequestResult` objects, each containing the result (`isError`, `content`) and the original `tool` name and `arguments` for each call. \ No newline at end of file diff --git a/src/fastmcp/contrib/bulk_tool_caller/__init__.py b/src/fastmcp/contrib/bulk_tool_caller/__init__.py deleted file mode 100644 index b8f66ed8b..000000000 --- a/src/fastmcp/contrib/bulk_tool_caller/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .bulk_tool_caller import BulkToolCaller - -__all__ = ["BulkToolCaller"] diff --git a/src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py b/src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py deleted file mode 100644 index 71ed25482..000000000 --- a/src/fastmcp/contrib/bulk_tool_caller/bulk_tool_caller.py +++ /dev/null @@ -1,151 +0,0 @@ -from typing import Any - -from mcp.types import CallToolResult, TextContent -from pydantic import BaseModel, Field - -from fastmcp import FastMCP -from fastmcp.client import Client -from fastmcp.client.transports import FastMCPTransport -from fastmcp.contrib.mcp_mixin.mcp_mixin import ( - _DEFAULT_SEPARATOR_TOOL, - MCPMixin, - mcp_tool, -) - - -class CallToolRequest(BaseModel): - """A class to represent a request to call a tool with specific arguments.""" - - tool: str = Field(description="The name of the tool to call.") - arguments: dict[str, Any] = Field( - description="A dictionary containing the arguments for the tool call." - ) - - -class CallToolRequestResult(CallToolResult): - """ - A class to represent the result of a bulk tool call. - It extends CallToolResult to include information about the requested tool call. - """ - - tool: str = Field(description="The name of the tool that was called.") - arguments: dict[str, Any] = Field( - description="The arguments used for the tool call." - ) - - @classmethod - def from_call_tool_result( - cls, result: CallToolResult, tool: str, arguments: dict[str, Any] - ) -> "CallToolRequestResult": - """ - Create a CallToolRequestResult from a CallToolResult. - """ - return cls( - tool=tool, - arguments=arguments, - isError=result.isError, - content=result.content, - ) - - -class BulkToolCaller(MCPMixin): - """ - A class to provide a "bulk tool call" tool for a FastMCP server - """ - - _BULK_TOOL_NAMES: frozenset[str] = frozenset({"call_tools_bulk", "call_tool_bulk"}) - - def register_tools( - self, - mcp_server: "FastMCP", - prefix: str | None = None, - separator: str = _DEFAULT_SEPARATOR_TOOL, - ) -> None: - """ - Register the tools provided by this class with the given MCP server. - """ - self.connection = FastMCPTransport(mcp_server) - - super().register_tools(mcp_server=mcp_server) - - @mcp_tool() - async def call_tools_bulk( - self, tool_calls: list[CallToolRequest], continue_on_error: bool = True - ) -> list[CallToolRequestResult]: - """ - Call multiple tools registered on this MCP server in a single request. Each call can - be for a different tool and can include different arguments. Useful for speeding up - what would otherwise take several individual tool calls. - """ - results = [] - - for tool_call in tool_calls: - result = await self._call_tool(tool_call.tool, tool_call.arguments) - - results.append(result) - - if result.isError and not continue_on_error: - return results - - return results - - @mcp_tool() - async def call_tool_bulk( - self, - tool: str, - tool_arguments: list[dict[str, str | int | float | bool | None]], - continue_on_error: bool = True, - ) -> list[CallToolRequestResult]: - """ - Call a single tool registered on this MCP server multiple times with a single request. - Each call can include different arguments. Useful for speeding up what would otherwise - take several individual tool calls. - - Args: - tool: The name of the tool to call. - tool_arguments: A list of dictionaries, where each dictionary contains the arguments for an individual run of the tool. - """ - results = [] - - for tool_call_arguments in tool_arguments: - result = await self._call_tool(tool, tool_call_arguments) - - results.append(result) - - if result.isError and not continue_on_error: - return results - - return results - - async def _call_tool( - self, tool: str, arguments: dict[str, Any] - ) -> CallToolRequestResult: - """ - Helper method to call a tool with the provided arguments. - """ - - if tool in self._BULK_TOOL_NAMES: - return CallToolRequestResult( - tool=tool, - arguments=arguments, - isError=True, - content=[ - TextContent( - type="text", - text=( - "BulkToolCaller cannot call itself. " - "The tools 'call_tools_bulk' and 'call_tool_bulk' are disallowed." - ), - ) - ], - ) - - async with Client(self.connection) as client: - result = await client.call_tool_mcp(name=tool, arguments=arguments) - - return CallToolRequestResult( - tool=tool, - arguments=arguments, - isError=result.isError, - content=result.content, - ) diff --git a/src/fastmcp/contrib/bulk_tool_caller/example.py b/src/fastmcp/contrib/bulk_tool_caller/example.py deleted file mode 100644 index b86a53e2a..000000000 --- a/src/fastmcp/contrib/bulk_tool_caller/example.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Sample code for FastMCP using MCPMixin.""" - -from fastmcp import FastMCP -from fastmcp.contrib.bulk_tool_caller import BulkToolCaller - -mcp = FastMCP() - - -@mcp.tool -def echo_tool(text: str) -> str: - """Echo the input text""" - return text - - -bulk_tool_caller = BulkToolCaller() - -bulk_tool_caller.register_tools(mcp) diff --git a/src/fastmcp/contrib/component_manager/README.md b/src/fastmcp/contrib/component_manager/README.md deleted file mode 100644 index 0cc99ad15..000000000 --- a/src/fastmcp/contrib/component_manager/README.md +++ /dev/null @@ -1,164 +0,0 @@ -# Component Manager – Contrib Module for FastMCP - -The **Component Manager** provides a unified API for enabling and disabling tools, resources, and prompts at runtime in a FastMCP server. This module is useful for dynamic control over which components are active, enabling advanced features like feature toggling, admin interfaces, or automation workflows. - ---- - -## 🔧 Features - -- Enable/disable **tools**, **resources**, and **prompts** via HTTP endpoints. -- Supports **local** and **mounted (server)** components. -- Customizable **API root path**. -- Optional **Auth scopes** for secured access. -- Fully integrates with FastMCP with minimal configuration. - ---- - -## 📦 Installation - -This module is part of the `fastmcp.contrib` package. No separate installation is required if you're already using **FastMCP**. - ---- - -## 🚀 Usage - -### Basic Setup - -```python -from fastmcp import FastMCP -from fastmcp.contrib.component_manager import set_up_component_manager - -mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.") -set_up_component_manager(server=mcp) -``` - ---- - -## 🔗 API Endpoints - -All endpoints are registered at `/` by default, or under the custom path if one is provided. - -### Tools - -```http -POST /tools/{tool_name}/enable -POST /tools/{tool_name}/disable -``` - -### Resources - -```http -POST /resources/{uri:path}/enable -POST /resources/{uri:path}/disable -``` - - * Supports template URIs as well -```http -POST /resources/example://test/{id}/enable -POST /resources/example://test/{id}/disable -``` - -### Prompts - -```http -POST /prompts/{prompt_name}/enable -POST /prompts/{prompt_name}/disable -``` ---- - -#### 🧪 Example Response - -```http -HTTP/1.1 200 OK -Content-Type: application/json - -{ - "message": "Disabled tool: example_tool" -} - -``` - ---- - -## ⚙️ Configuration Options - -### Custom Root Path - -To mount the API under a different path: - -```python -set_up_component_manager(server=mcp, path="/admin") -``` - -### Securing Endpoints with Auth Scopes - -If your server uses authentication: - -```python -mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth) -set_up_component_manager(server=mcp, required_scopes=["write", "read"]) -``` - ---- - -## 🧪 Example: Enabling a Tool with Curl - -```bash -curl -X POST \ - -H "Authorization: Bearer YOUR_TOKEN_HERE" \ - -H "Content-Type: application/json" \ - http://localhost:8001/tools/example_tool/enable -``` - ---- - -## 🧱 Working with Mounted Servers - -You can also combine different configurations when working with mounted servers — for example, using different scopes: - -```python -mcp = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth) -set_up_component_manager(server=mcp, required_scopes=["mcp:write"]) - -mounted = FastMCP(name="Component Manager", instructions="This is a test server with component manager.", auth=auth) -set_up_component_manager(server=mounted, required_scopes=["mounted:write"]) - -mcp.mount(server=mounted, namespace="mo") -``` - -This allows you to grant different levels of access: - -```bash -# Accessing the main server gives you control over both local and mounted components -curl -X POST \ - -H "Authorization: Bearer YOUR_TOKEN_HERE" \ - -H "Content-Type: application/json" \ - http://localhost:8001/tools/mo_example_tool/enable - -# Accessing the mounted server gives you control only over its own components -curl -X POST \ - -H "Authorization: Bearer YOUR_TOKEN_HERE" \ - -H "Content-Type: application/json" \ - http://localhost:8002/tools/example_tool/enable -``` - ---- - -## ⚙️ How It Works - -- `set_up_component_manager()` registers HTTP routes for tools, resources, and prompts. -- Each endpoint calls `server.enable()` or `server.disable()` with the component name. -- Returns a success message in JSON. - ---- - -## Maintenance Notice - -This module is not officially maintained by the core FastMCP team. It is an independent extension developed by [gorocode](https://github.com/gorocode). - -If you encounter any issues or wish to contribute, please feel free to open an issue or submit a pull request, and kindly notify me. I'd love to stay up to date. - - -## 📄 License - -This module follows the license of the main [FastMCP](https://github.com/PrefectHQ/fastmcp) project. \ No newline at end of file diff --git a/src/fastmcp/contrib/component_manager/__init__.py b/src/fastmcp/contrib/component_manager/__init__.py deleted file mode 100644 index b89a6d984..000000000 --- a/src/fastmcp/contrib/component_manager/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .component_manager import set_up_component_manager - -__all__ = ["set_up_component_manager"] diff --git a/src/fastmcp/contrib/component_manager/component_manager.py b/src/fastmcp/contrib/component_manager/component_manager.py deleted file mode 100644 index 099b9052f..000000000 --- a/src/fastmcp/contrib/component_manager/component_manager.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -HTTP routes for enabling/disabling components in FastMCP. - -Provides REST endpoints for controlling component enabled state with optional -authentication scopes. -""" - -from mcp.server.auth.middleware.bearer_auth import RequireAuthMiddleware -from starlette.applications import Starlette -from starlette.requests import Request -from starlette.responses import JSONResponse -from starlette.routing import Mount, Route - -from fastmcp.server.server import FastMCP - - -def set_up_component_manager( - server: FastMCP, path: str = "/", required_scopes: list[str] | None = None -) -> None: - """Set up HTTP routes for enabling/disabling tools, resources, and prompts. - - Args: - server: The FastMCP server instance. - path: Base path for component management routes. - required_scopes: Optional list of scopes required for these routes. - Applies only if authentication is enabled. - - Routes created: - POST /tools/{name}/enable[?version=v1] - POST /tools/{name}/disable[?version=v1] - POST /resources/{uri}/enable[?version=v1] - POST /resources/{uri}/disable[?version=v1] - POST /prompts/{name}/enable[?version=v1] - POST /prompts/{name}/disable[?version=v1] - """ - if required_scopes is None: - # No auth - include path prefix in routes - routes = _build_routes(server, path) - server._additional_http_routes.extend(routes) - else: - # With auth - Mount handles path prefix, routes shouldn't have it - routes = _build_routes(server, "/") - mount = Mount( - path if path != "/" else "", - app=RequireAuthMiddleware(Starlette(routes=routes), required_scopes), - ) - server._additional_http_routes.append(mount) - - -def _build_routes(server: FastMCP, base_path: str) -> list[Route]: - """Build all component management routes.""" - prefix = base_path.rstrip("/") if base_path != "/" else "" - - return [ - # Tools - Route( - f"{prefix}/tools/{{name}}/enable", - endpoint=_make_endpoint(server, "tool", "enable"), - methods=["POST"], - ), - Route( - f"{prefix}/tools/{{name}}/disable", - endpoint=_make_endpoint(server, "tool", "disable"), - methods=["POST"], - ), - # Resources - Route( - f"{prefix}/resources/{{uri:path}}/enable", - endpoint=_make_endpoint(server, "resource", "enable"), - methods=["POST"], - ), - Route( - f"{prefix}/resources/{{uri:path}}/disable", - endpoint=_make_endpoint(server, "resource", "disable"), - methods=["POST"], - ), - # Prompts - Route( - f"{prefix}/prompts/{{name}}/enable", - endpoint=_make_endpoint(server, "prompt", "enable"), - methods=["POST"], - ), - Route( - f"{prefix}/prompts/{{name}}/disable", - endpoint=_make_endpoint(server, "prompt", "disable"), - methods=["POST"], - ), - ] - - -def _make_endpoint(server: FastMCP, component_type: str, action: str): - """Create an endpoint function for enabling/disabling a component type.""" - - async def endpoint(request: Request) -> JSONResponse: - # Get name from path params (tools/prompts use 'name', resources use 'uri') - name = request.path_params.get("name") or request.path_params.get("uri") - version = request.query_params.get("version") - - # Map component type to components list - # Note: "resource" in the route can refer to either a resource or template - # We need to check if it's a template (contains {}) and use "template" if so - if component_type == "resource" and name is not None and "{" in name: - components = ["template"] - elif component_type == "resource": - components = ["resource"] - else: - component_map = { - "tool": ["tool"], - "prompt": ["prompt"], - } - components = component_map[component_type] - - # Call server.enable() or server.disable() - method = getattr(server, action) - method(names={name} if name else None, version=version, components=components) - - return JSONResponse( - {"message": f"{action.capitalize()}d {component_type}: {name}"} - ) - - return endpoint diff --git a/src/fastmcp/contrib/component_manager/example.py b/src/fastmcp/contrib/component_manager/example.py deleted file mode 100644 index 7780f8a7c..000000000 --- a/src/fastmcp/contrib/component_manager/example.py +++ /dev/null @@ -1,59 +0,0 @@ -from fastmcp import FastMCP -from fastmcp.contrib.component_manager import set_up_component_manager -from fastmcp.server.auth.providers.jwt import JWTVerifier, RSAKeyPair - -key_pair = RSAKeyPair.generate() - -auth = JWTVerifier( - public_key=key_pair.public_key, - issuer="https://dev.example.com", - audience="my-dev-server", - required_scopes=["mcp:read"], -) - -# Build main server -mcp_token = key_pair.create_token( - subject="dev-user", - issuer="https://dev.example.com", - audience="my-dev-server", - scopes=["mcp:write", "mcp:read"], -) -mcp = FastMCP( - name="Component Manager", - instructions="This is a test server with component manager.", - auth=auth, -) - -# Set up main server component manager -set_up_component_manager(server=mcp, required_scopes=["mcp:write"]) - -# Build mounted server -mounted_token = key_pair.create_token( - subject="dev-user", - issuer="https://dev.example.com", - audience="my-dev-server", - scopes=["mounted:write", "mcp:read"], -) -mounted = FastMCP( - name="Component Manager", - instructions="This is a test server with component manager.", - auth=auth, -) - -# Set up mounted server component manager -set_up_component_manager(server=mounted, required_scopes=["mounted:write"]) - -# Mount -mcp.mount(server=mounted, namespace="mo") - - -@mcp.resource("resource://greeting") -def get_greeting() -> str: - """Provides a simple greeting message.""" - return "Hello from FastMCP Resources!" - - -@mounted.tool("greeting") -def get_info() -> str: - """Provides a simple info.""" - return "You are using component manager contrib module!" diff --git a/src/fastmcp/contrib/mcp_mixin/README.md b/src/fastmcp/contrib/mcp_mixin/README.md deleted file mode 100644 index 39c3a2352..000000000 --- a/src/fastmcp/contrib/mcp_mixin/README.md +++ /dev/null @@ -1,147 +0,0 @@ -from mcp.types import ToolAnnotations - -# MCP Mixin - -This module provides the `MCPMixin` base class and associated decorators (`@mcp_tool`, `@mcp_resource`, `@mcp_prompt`). - -It allows developers to easily define classes whose methods can be registered as tools, resources, or prompts with a `FastMCP` server instance using the `register_all()`, `register_tools()`, `register_resources()`, or `register_prompts()` methods provided by the mixin. - -Includes support for -Tools: -* [enable/disable](https://gofastmcp.com/servers/tools#disabling-tools) -* [annotations](https://gofastmcp.com/servers/tools#annotations-2) -* [excluded arguments](https://gofastmcp.com/servers/tools#excluding-arguments) -* [meta](https://gofastmcp.com/servers/tools#param-meta) - -Prompts: -* [enable/disable](https://gofastmcp.com/servers/prompts#disabling-prompts) -* [meta](https://gofastmcp.com/servers/prompts#param-meta) - -Resources: -* [enable/disable](https://gofastmcp.com/servers/resources#disabling-resources) -* [meta](https://gofastmcp.com/servers/resources#param-meta) - -## Usage - -Inherit from `MCPMixin` and use the decorators on the methods you want to register. - -```python -from mcp.types import ToolAnnotations -from fastmcp import FastMCP -from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool, mcp_resource, mcp_prompt - -class MyComponent(MCPMixin): - @mcp_tool(name="my_tool", description="Does something cool.") - def tool_method(self): - return "Tool executed!" - - # example of disabled tool - @mcp_tool(name="my_tool", description="Does something cool.", enabled=False) - def disabled_tool_method(self): - # This function can't be called by client because it's disabled - return "You'll never get here!" - - # example of excluded parameter tool - @mcp_tool( - name="my_tool", description="Does something cool.", - enabled=False, exclude_args=['delete_everything'], - ) - def excluded_param_tool_method(self, delete_everything=False): - # MCP tool calls can't pass the "delete_everything" argument - if delete_everything: - return "Nothing to delete, I bet you're not a tool :)" - return "You might be a tool if..." - - # example tool w/annotations - @mcp_tool( - name="my_tool", description="Does something cool.", - annotations=ToolAnnotations( - title="Attn LLM, use this tool first!", - readOnlyHint=False, - destructiveHint=False, - idempotentHint=False, - ) - ) - def tool_method(self): - return "Tool executed!" - - # example tool w/everything - @mcp_tool( - name="my_tool", description="Does something cool.", - enabled=True, - exclude_args=['delete_all'], - annotations=ToolAnnotations( - title="Attn LLM, use this tool first!", - readOnlyHint=False, - destructiveHint=False, - idempotentHint=False, - ) - ) - def tool_method(self, delete_all=False): - if delete_all: - return "99 records deleted. I bet you're not a tool :)" - return "Tool executed, but you might be a tool!" - - # example tool w/ meta - @mcp_tool( - name="data_tool", - description="Fetches user data from database", - meta={"version": "2.0", "category": "database", "author": "dev-team"} - ) - def data_tool_method(self, user_id: int): - return f"Fetching data for user {user_id}" - - @mcp_resource(uri="component://data") - def resource_method(self): - return {"data": "some data"} - - # Disabled resource - @mcp_resource(uri="component://data", enabled=False) - def resource_method(self): - return {"data": "some data"} - - # example resource w/meta and title - @mcp_resource( - uri="component://config", - title="Data resource Title, - meta={"internal": True, "cache_ttl": 3600, "priority": "high"} - ) - def config_resource_method(self): - return {"config": "data"} - - # prompt - @mcp_prompt(name="A prompt") - def prompt_method(self, name): - return f"What's up {name}?" - - # disabled prompt - @mcp_prompt(name="A prompt", enabled=False) - def prompt_method(self, name): - return f"What's up {name}?" - - # example prompt w/title and meta - @mcp_prompt( - name="analysis_prompt", - title="Data Analysis Prompt", - description="Analyzes data patterns", - meta={"complexity": "high", "domain": "analytics", "requires_context": True} - ) - def analysis_prompt_method(self, dataset: str): - return f"Analyze the patterns in {dataset}" - -mcp_server = FastMCP() -component = MyComponent() - -# Register all decorated methods with a prefix -# Useful if you will have multiple instantiated objects of the same class -# and want to avoid name collisions. -component.register_all(mcp_server, prefix="my_comp") - -# Register without a prefix -# component.register_all(mcp_server) - -# Now 'my_comp_my_tool' tool and 'my_comp+component://data' resource are registered (if prefix used) -# Or 'my_tool' and 'component://data' are registered (if no prefix used) -``` - -The `prefix` argument in registration methods is optional. If omitted, methods are registered with their original decorated names/URIs. Individual separators (`tools_separator`, `resources_separator`, `prompts_separator`) can also be provided to `register_all` to change the separator for specific types. diff --git a/src/fastmcp/contrib/mcp_mixin/__init__.py b/src/fastmcp/contrib/mcp_mixin/__init__.py deleted file mode 100644 index 48a536632..000000000 --- a/src/fastmcp/contrib/mcp_mixin/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -from .mcp_mixin import MCPMixin, mcp_tool, mcp_resource, mcp_prompt - -__all__ = [ - "MCPMixin", - "mcp_prompt", - "mcp_resource", - "mcp_tool", -] diff --git a/src/fastmcp/contrib/mcp_mixin/example.py b/src/fastmcp/contrib/mcp_mixin/example.py deleted file mode 100644 index b282b58fe..000000000 --- a/src/fastmcp/contrib/mcp_mixin/example.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Sample code for FastMCP using MCPMixin.""" - -import asyncio - -from fastmcp import FastMCP -from fastmcp.contrib.mcp_mixin import ( - MCPMixin, - mcp_prompt, - mcp_resource, - mcp_tool, -) - -mcp = FastMCP() - - -class Sample(MCPMixin): - def __init__(self, name): - self.name = name - - @mcp_tool() - def first_tool(self): - """First tool description.""" - return f"Executed tool {self.name}." - - @mcp_resource(uri="test://test") - def first_resource(self): - """First resource description.""" - return f"Executed resource {self.name}." - - @mcp_prompt() - def first_prompt(self): - """First prompt description.""" - return f"here's a prompt! {self.name}." - - -first_sample = Sample("First") -second_sample = Sample("Second") - -first_sample.register_all(mcp_server=mcp, prefix="first") -second_sample.register_all(mcp_server=mcp, prefix="second") - - -async def list_components() -> None: - print("MCP Server running with registered components...") - print("Tools:", list(await mcp.list_tools())) - print("Resources:", list(await mcp.list_resources())) - print("Prompts:", list(await mcp.list_prompts())) - - -if __name__ == "__main__": - asyncio.run(list_components()) - mcp.run() diff --git a/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py b/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py deleted file mode 100644 index f783f7748..000000000 --- a/src/fastmcp/contrib/mcp_mixin/mcp_mixin.py +++ /dev/null @@ -1,321 +0,0 @@ -"""Provides a base mixin class and decorators for easy registration of class methods with FastMCP.""" - -import inspect -import warnings -from collections.abc import Callable -from typing import TYPE_CHECKING, Any - -import fastmcp -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.prompts.base import Prompt -from fastmcp.resources.base import Resource -from fastmcp.tools.base import Tool -from fastmcp.utilities.types import get_fn_name - -if TYPE_CHECKING: - from fastmcp.server import FastMCP - -_MCP_REGISTRATION_TOOL_ATTR = "_mcp_tool_registration" -_MCP_REGISTRATION_RESOURCE_ATTR = "_mcp_resource_registration" -_MCP_REGISTRATION_PROMPT_ATTR = "_mcp_prompt_registration" - -_DEFAULT_SEPARATOR_TOOL = "_" -_DEFAULT_SEPARATOR_RESOURCE = "+" -_DEFAULT_SEPARATOR_PROMPT = "_" - -# Sentinel key stored in registration dicts for the mixin-only `enabled` flag. -# Prefixed with an underscore to avoid collisions with any from_function parameter. -_MIXIN_ENABLED_KEY = "_mixin_enabled" - -# Valid keyword arguments for each from_function, derived once at import time -# directly from the live signatures. They stay in sync automatically whenever -# the underlying signatures gain or lose parameters — no manual updates needed. -_TOOL_VALID_KWARGS: frozenset[str] = frozenset( - p for p in inspect.signature(Tool.from_function).parameters if p != "fn" -) -_RESOURCE_VALID_KWARGS: frozenset[str] = frozenset( - p - for p in inspect.signature(Resource.from_function).parameters - if p not in ("fn", "uri") -) -_PROMPT_VALID_KWARGS: frozenset[str] = frozenset( - p for p in inspect.signature(Prompt.from_function).parameters if p != "fn" -) - - -def mcp_tool( - name: str | None = None, - *, - enabled: bool | None = None, - **kwargs: Any, -) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - """Decorator to mark a method as an MCP tool for later registration. - - Accepts all parameters supported by ``Tool.from_function``. Any new - parameters added to ``Tool.from_function`` are automatically forwarded - without requiring changes here. - - Args: - name: Tool name. Defaults to the decorated method name. - enabled: If ``False``, the tool is skipped during registration. - **kwargs: Additional keyword arguments forwarded verbatim to - ``Tool.from_function`` (e.g. ``description``, ``tags``, - ``annotations``, ``auth``, ``timeout``, ``version``, …). - - Raises: - TypeError: If an unrecognised keyword argument is supplied. The error - is raised immediately at decoration time rather than later. - """ - unknown = set(kwargs) - _TOOL_VALID_KWARGS - if unknown: - raise TypeError( - f"mcp_tool() got unexpected keyword argument(s): {sorted(unknown)!r}. " - f"Valid keyword arguments are: {sorted(_TOOL_VALID_KWARGS)}" - ) - - if "serializer" in kwargs and fastmcp.settings.deprecation_warnings: - warnings.warn( - "The `serializer` parameter is deprecated. " - "Return ToolResult from your tools for full control over serialization. " - "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - call_args: dict[str, Any] = {"name": name or get_fn_name(func), **kwargs} - if enabled is not None: - call_args[_MIXIN_ENABLED_KEY] = enabled - setattr(func, _MCP_REGISTRATION_TOOL_ATTR, call_args) - return func - - return decorator - - -def mcp_resource( - uri: str, - *, - name: str | None = None, - enabled: bool | None = None, - **kwargs: Any, -) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - """Decorator to mark a method as an MCP resource for later registration. - - Accepts all parameters supported by ``Resource.from_function``. Any new - parameters added to ``Resource.from_function`` are automatically forwarded - without requiring changes here. - - Args: - uri: Resource URI (required). - name: Resource name. Defaults to the decorated method name. - enabled: If ``False``, the resource is skipped during registration. - **kwargs: Additional keyword arguments forwarded verbatim to - ``Resource.from_function`` (e.g. ``description``, ``tags``, - ``mime_type``, ``auth``, ``version``, …). - - Raises: - TypeError: If an unrecognised keyword argument is supplied. The error - is raised immediately at decoration time rather than later. - """ - unknown = set(kwargs) - _RESOURCE_VALID_KWARGS - if unknown: - raise TypeError( - f"mcp_resource() got unexpected keyword argument(s): {sorted(unknown)!r}. " - f"Valid keyword arguments are: {sorted(_RESOURCE_VALID_KWARGS)}" - ) - - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - call_args: dict[str, Any] = { - "uri": uri, - "name": name or get_fn_name(func), - **kwargs, - } - if enabled is not None: - call_args[_MIXIN_ENABLED_KEY] = enabled - setattr(func, _MCP_REGISTRATION_RESOURCE_ATTR, call_args) - return func - - return decorator - - -def mcp_prompt( - name: str | None = None, - *, - enabled: bool | None = None, - **kwargs: Any, -) -> Callable[[Callable[..., Any]], Callable[..., Any]]: - """Decorator to mark a method as an MCP prompt for later registration. - - Accepts all parameters supported by ``Prompt.from_function``. Any new - parameters added to ``Prompt.from_function`` are automatically forwarded - without requiring changes here. - - Args: - name: Prompt name. Defaults to the decorated method name. - enabled: If ``False``, the prompt is skipped during registration. - **kwargs: Additional keyword arguments forwarded verbatim to - ``Prompt.from_function`` (e.g. ``description``, ``tags``, - ``auth``, ``version``, …). - - Raises: - TypeError: If an unrecognised keyword argument is supplied. The error - is raised immediately at decoration time rather than later. - """ - unknown = set(kwargs) - _PROMPT_VALID_KWARGS - if unknown: - raise TypeError( - f"mcp_prompt() got unexpected keyword argument(s): {sorted(unknown)!r}. " - f"Valid keyword arguments are: {sorted(_PROMPT_VALID_KWARGS)}" - ) - - def decorator(func: Callable[..., Any]) -> Callable[..., Any]: - call_args: dict[str, Any] = {"name": name or get_fn_name(func), **kwargs} - if enabled is not None: - call_args[_MIXIN_ENABLED_KEY] = enabled - setattr(func, _MCP_REGISTRATION_PROMPT_ATTR, call_args) - return func - - return decorator - - -class MCPMixin: - """Base mixin class for objects that can register tools, resources, and prompts - with a FastMCP server instance using decorators. - - This mixin provides methods like ``register_all``, ``register_tools``, etc., - which iterate over the methods of the inheriting class, find methods - decorated with ``@mcp_tool``, ``@mcp_resource``, or ``@mcp_prompt``, and - register them with the provided FastMCP server instance. - """ - - def _get_methods_to_register(self, registration_type: str): - """Retrieves all methods marked for a specific registration type.""" - return [ - ( - getattr(self, method_name), - getattr(getattr(self, method_name), registration_type).copy(), - ) - for method_name in dir(self) - if callable(getattr(self, method_name)) - and hasattr(getattr(self, method_name), registration_type) - ] - - def register_tools( - self, - mcp_server: "FastMCP", - prefix: str | None = None, - separator: str = _DEFAULT_SEPARATOR_TOOL, - ) -> None: - """Registers all methods marked with @mcp_tool with the FastMCP server. - - Args: - mcp_server: The FastMCP server instance to register tools with. - prefix: Optional prefix to prepend to tool names. If provided, the - final name will be ``f"{prefix}{separator}{original_name}"``. - separator: The separator string used between prefix and original name. - Defaults to ``'_'``. - """ - for method, registration_info in self._get_methods_to_register( - _MCP_REGISTRATION_TOOL_ATTR - ): - if prefix: - registration_info["name"] = ( - f"{prefix}{separator}{registration_info['name']}" - ) - - enabled = registration_info.pop(_MIXIN_ENABLED_KEY, True) - if enabled is False: - continue - - tool = Tool.from_function(fn=method, **registration_info) - mcp_server.add_tool(tool) - - def register_resources( - self, - mcp_server: "FastMCP", - prefix: str | None = None, - separator: str = _DEFAULT_SEPARATOR_RESOURCE, - ) -> None: - """Registers all methods marked with @mcp_resource with the FastMCP server. - - Args: - mcp_server: The FastMCP server instance to register resources with. - prefix: Optional prefix to prepend to resource names and URIs. If - provided, the final name will be - ``f"{prefix}{separator}{original_name}"`` and the final URI will - be ``f"{prefix}{separator}{original_uri}"``. - separator: The separator string used between prefix and original - name/URI. Defaults to ``'+'``. - """ - for method, registration_info in self._get_methods_to_register( - _MCP_REGISTRATION_RESOURCE_ATTR - ): - if prefix: - registration_info["name"] = ( - f"{prefix}{separator}{registration_info['name']}" - ) - registration_info["uri"] = ( - f"{prefix}{separator}{registration_info['uri']}" - ) - - enabled = registration_info.pop(_MIXIN_ENABLED_KEY, True) - if enabled is False: - continue - - resource = Resource.from_function(fn=method, **registration_info) - mcp_server.add_resource(resource) - - def register_prompts( - self, - mcp_server: "FastMCP", - prefix: str | None = None, - separator: str = _DEFAULT_SEPARATOR_PROMPT, - ) -> None: - """Registers all methods marked with @mcp_prompt with the FastMCP server. - - Args: - mcp_server: The FastMCP server instance to register prompts with. - prefix: Optional prefix to prepend to prompt names. If provided, - the final name will be ``f"{prefix}{separator}{original_name}"``. - separator: The separator string used between prefix and original name. - Defaults to ``'_'``. - """ - for method, registration_info in self._get_methods_to_register( - _MCP_REGISTRATION_PROMPT_ATTR - ): - if prefix: - registration_info["name"] = ( - f"{prefix}{separator}{registration_info['name']}" - ) - - enabled = registration_info.pop(_MIXIN_ENABLED_KEY, True) - if enabled is False: - continue - - prompt = Prompt.from_function(fn=method, **registration_info) - mcp_server.add_prompt(prompt) - - def register_all( - self, - mcp_server: "FastMCP", - prefix: str | None = None, - tool_separator: str = _DEFAULT_SEPARATOR_TOOL, - resource_separator: str = _DEFAULT_SEPARATOR_RESOURCE, - prompt_separator: str = _DEFAULT_SEPARATOR_PROMPT, - ) -> None: - """Registers all marked tools, resources, and prompts with the server. - - This method calls ``register_tools``, ``register_resources``, and - ``register_prompts`` internally, passing the provided prefix and - separators. - - Args: - mcp_server: The FastMCP server instance to register with. - prefix: Optional prefix applied to all registered items. - tool_separator: Separator for tool names (defaults to ``'_'``). - resource_separator: Separator for resource names/URIs (defaults to ``'+'``). - prompt_separator: Separator for prompt names (defaults to ``'_'``). - """ - self.register_tools(mcp_server, prefix=prefix, separator=tool_separator) - self.register_resources(mcp_server, prefix=prefix, separator=resource_separator) - self.register_prompts(mcp_server, prefix=prefix, separator=prompt_separator) diff --git a/src/fastmcp/decorators.py b/src/fastmcp/decorators.py deleted file mode 100644 index 75dff25ac..000000000 --- a/src/fastmcp/decorators.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Shared decorator utilities for FastMCP.""" - -from __future__ import annotations - -import inspect -from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable - -if TYPE_CHECKING: - from fastmcp.prompts.function_prompt import PromptMeta - from fastmcp.resources.function_resource import ResourceMeta - from fastmcp.server.tasks.config import TaskConfig - from fastmcp.tools.function_tool import ToolMeta - - FastMCPMeta = ToolMeta | ResourceMeta | PromptMeta - - -def resolve_task_config(task: bool | TaskConfig | None) -> bool | TaskConfig: - """Resolve task config, defaulting None to False.""" - return task if task is not None else False - - -@runtime_checkable -class HasFastMCPMeta(Protocol): - """Protocol for callables decorated with FastMCP metadata.""" - - __fastmcp__: Any - - -def get_fastmcp_meta(fn: Any) -> Any | None: - """Extract FastMCP metadata from a function, handling bound methods and wrappers.""" - if hasattr(fn, "__fastmcp__"): - return fn.__fastmcp__ - if hasattr(fn, "__func__") and hasattr(fn.__func__, "__fastmcp__"): - return fn.__func__.__fastmcp__ - try: - unwrapped = inspect.unwrap(fn) - if unwrapped is not fn and hasattr(unwrapped, "__fastmcp__"): - return unwrapped.__fastmcp__ - except ValueError: - pass - return None diff --git a/src/fastmcp/dependencies.py b/src/fastmcp/dependencies.py deleted file mode 100644 index 2aa8c145a..000000000 --- a/src/fastmcp/dependencies.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Dependency injection exports for FastMCP. - -This module re-exports dependency injection symbols to provide a clean, -centralized import location for all dependency-related functionality. - -DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket -using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket, -CurrentWorker) and background task execution require fastmcp[tasks]. -""" - -from uncalled_for import Dependency, Depends, Shared - -from fastmcp.server.dependencies import ( - CurrentAccessToken, - CurrentContext, - CurrentDocket, - CurrentFastMCP, - CurrentHeaders, - CurrentRequest, - CurrentWorker, - Progress, - ProgressLike, - TokenClaim, -) - -__all__ = [ - "CurrentAccessToken", - "CurrentContext", - "CurrentDocket", - "CurrentFastMCP", - "CurrentHeaders", - "CurrentRequest", - "CurrentWorker", - "Dependency", - "Depends", - "Progress", - "ProgressLike", - "Shared", - "TokenClaim", -] diff --git a/src/fastmcp/exceptions.py b/src/fastmcp/exceptions.py deleted file mode 100644 index e5e079023..000000000 --- a/src/fastmcp/exceptions.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Custom exceptions for FastMCP.""" - -from mcp import McpError # noqa: F401 - - -class FastMCPDeprecationWarning(DeprecationWarning): - """Deprecation warning for FastMCP APIs. - - Subclass of DeprecationWarning so that standard warning filters - still apply, but FastMCP can selectively enable its own warnings - without affecting other libraries in the process. - """ - - -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 PromptError(FastMCPError): - """Error in prompt operations.""" - - -class InvalidSignature(Exception): - """Invalid signature for use with FastMCP.""" - - -class ClientError(Exception): - """Error in client operations.""" - - -class NotFoundError(Exception): - """Object not found.""" - - -class DisabledError(Exception): - """Object is disabled.""" - - -class AuthorizationError(FastMCPError): - """Error when authorization check fails.""" diff --git a/src/fastmcp/experimental/__init__.py b/src/fastmcp/experimental/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/fastmcp/experimental/sampling/__init__.py b/src/fastmcp/experimental/sampling/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/fastmcp/experimental/sampling/handlers/__init__.py b/src/fastmcp/experimental/sampling/handlers/__init__.py deleted file mode 100644 index 627dfd011..000000000 --- a/src/fastmcp/experimental/sampling/handlers/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -# Re-export for backwards compatibility -# The canonical location is now fastmcp.client.sampling.handlers -from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler - -__all__ = ["OpenAISamplingHandler"] diff --git a/src/fastmcp/experimental/sampling/handlers/openai.py b/src/fastmcp/experimental/sampling/handlers/openai.py deleted file mode 100644 index b466f7a77..000000000 --- a/src/fastmcp/experimental/sampling/handlers/openai.py +++ /dev/null @@ -1,5 +0,0 @@ -# Re-export for backwards compatibility -# The canonical location is now fastmcp.client.sampling.handlers.openai -from fastmcp.client.sampling.handlers.openai import OpenAISamplingHandler - -__all__ = ["OpenAISamplingHandler"] diff --git a/src/fastmcp/experimental/server/openapi/__init__.py b/src/fastmcp/experimental/server/openapi/__init__.py deleted file mode 100644 index b19563400..000000000 --- a/src/fastmcp/experimental/server/openapi/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Deprecated: Import from fastmcp.server.providers.openapi instead.""" - -import warnings - -from fastmcp.exceptions import FastMCPDeprecationWarning - -# Deprecated in 2.14 when OpenAPI support was promoted out of experimental -warnings.warn( - "Importing from fastmcp.experimental.server.openapi is deprecated. " - "Import from fastmcp.server.providers.openapi instead.", - FastMCPDeprecationWarning, - stacklevel=2, -) - -# Import from canonical location -from fastmcp.server.openapi.server import FastMCPOpenAPI as FastMCPOpenAPI # noqa: E402 -from fastmcp.server.providers.openapi import ( # noqa: E402 - ComponentFn as ComponentFn, - MCPType as MCPType, - OpenAPIResource as OpenAPIResource, - OpenAPIResourceTemplate as OpenAPIResourceTemplate, - OpenAPITool as OpenAPITool, - RouteMap as RouteMap, - RouteMapFn as RouteMapFn, -) -from fastmcp.server.providers.openapi.routing import ( # noqa: E402 - DEFAULT_ROUTE_MAPPINGS as DEFAULT_ROUTE_MAPPINGS, - _determine_route_type as _determine_route_type, -) - -__all__ = [ - "DEFAULT_ROUTE_MAPPINGS", - "ComponentFn", - "FastMCPOpenAPI", - "MCPType", - "OpenAPIResource", - "OpenAPIResourceTemplate", - "OpenAPITool", - "RouteMap", - "RouteMapFn", - "_determine_route_type", -] diff --git a/src/fastmcp/experimental/transforms/__init__.py b/src/fastmcp/experimental/transforms/__init__.py deleted file mode 100644 index 8b1378917..000000000 --- a/src/fastmcp/experimental/transforms/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/fastmcp/experimental/transforms/code_mode.py b/src/fastmcp/experimental/transforms/code_mode.py deleted file mode 100644 index 57644239c..000000000 --- a/src/fastmcp/experimental/transforms/code_mode.py +++ /dev/null @@ -1,574 +0,0 @@ -import importlib -import json -from collections.abc import Awaitable, Callable, Sequence -from typing import Annotated, Any, Literal, Protocol - -from mcp.types import TextContent -from pydantic import Field - -from fastmcp.exceptions import NotFoundError -from fastmcp.server.context import Context -from fastmcp.server.transforms import GetToolNext -from fastmcp.server.transforms.catalog import CatalogTransform -from fastmcp.server.transforms.search.base import ( - serialize_tools_for_output_json, - serialize_tools_for_output_markdown, -) -from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.async_utils import is_coroutine_function -from fastmcp.utilities.versions import VersionSpec - -# --------------------------------------------------------------------------- -# Type aliases -# --------------------------------------------------------------------------- - -GetToolCatalog = Callable[[Context], Awaitable[Sequence[Tool]]] -"""Async callable that returns the auth-filtered tool catalog.""" - -SearchFn = Callable[[Sequence[Tool], str], Awaitable[Sequence[Tool]]] -"""Async callable that searches a tool sequence by query string.""" - -DiscoveryToolFactory = Callable[[GetToolCatalog], Tool] -"""Factory that receives catalog access and returns a synthetic Tool.""" - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _ensure_async(fn: Callable[..., Any]) -> Callable[..., Any]: - if is_coroutine_function(fn): - return fn - - async def wrapper(*args: Any, **kwargs: Any) -> Any: - return fn(*args, **kwargs) - - return wrapper - - -def _unwrap_tool_result(result: ToolResult) -> dict[str, Any] | str: - """Convert a ToolResult for use in the sandbox. - - - Output schema present → structured_content dict (matches the schema) - - Otherwise → concatenated text content as a string - """ - if result.structured_content is not None: - return result.structured_content - - parts: list[str] = [] - for content in result.content: - if isinstance(content, TextContent): - parts.append(content.text) - else: - parts.append(str(content)) - return "\n".join(parts) - - -# --------------------------------------------------------------------------- -# Sandbox providers -# --------------------------------------------------------------------------- - - -class SandboxProvider(Protocol): - """Interface for executing LLM-generated Python code in a sandbox. - - WARNING: The ``code`` parameter passed to ``run`` contains untrusted, - LLM-generated Python. Implementations MUST execute it in an isolated - sandbox — never with plain ``exec()``. Use ``MontySandboxProvider`` - (backed by ``pydantic-monty``) for production workloads. - """ - - async def run( - self, - code: str, - *, - inputs: dict[str, Any] | None = None, - external_functions: dict[str, Callable[..., Any]] | None = None, - ) -> Any: ... - - -class MontySandboxProvider: - """Sandbox provider backed by `pydantic-monty`. - - Args: - limits: Resource limits for sandbox execution. Supported keys: - ``max_duration_secs`` (float), ``max_allocations`` (int), - ``max_memory`` (int), ``max_recursion_depth`` (int), - ``gc_interval`` (int). All are optional; omit a key to - leave that limit uncapped. - """ - - def __init__( - self, - *, - limits: dict[str, Any] | None = None, - ) -> None: - self.limits = limits - - async def run( - self, - code: str, - *, - inputs: dict[str, Any] | None = None, - external_functions: dict[str, Callable[..., Any]] | None = None, - ) -> Any: - try: - pydantic_monty = importlib.import_module("pydantic_monty") - except ModuleNotFoundError as exc: - raise ImportError( - "CodeMode requires pydantic-monty for the Monty sandbox provider. " - "Install it with `fastmcp[code-mode]` or pass a custom SandboxProvider." - ) from exc - - inputs = inputs or {} - async_functions = { - key: _ensure_async(value) - for key, value in (external_functions or {}).items() - } - - monty = pydantic_monty.Monty( - code, - inputs=list(inputs.keys()), - ) - run_kwargs: dict[str, Any] = {"external_functions": async_functions} - if inputs: - run_kwargs["inputs"] = inputs - if self.limits is not None: - run_kwargs["limits"] = self.limits - return await pydantic_monty.run_monty_async(monty, **run_kwargs) - - -# --------------------------------------------------------------------------- -# Built-in discovery tools -# --------------------------------------------------------------------------- - - -ToolDetailLevel = Literal["brief", "detailed", "full"] -"""Detail level for discovery tool output. - -- ``"brief"``: tool names and one-line descriptions -- ``"detailed"``: compact markdown with parameter names, types, and required markers -- ``"full"``: complete JSON schema -""" - - -def _render_tools(tools: Sequence[Tool], detail: ToolDetailLevel) -> str: - """Render tools at the requested detail level. - - The same detail value produces the same output format regardless of - which discovery tool calls this, so ``detail="detailed"`` on Search - gives identical formatting to ``detail="detailed"`` on GetSchemas. - """ - if not tools: - if detail == "full": - return json.dumps([], indent=2) - return "No tools matched the query." - if detail == "full": - return json.dumps(serialize_tools_for_output_json(tools), indent=2) - if detail == "detailed": - return serialize_tools_for_output_markdown(tools) - # brief - lines: list[str] = [] - for tool in tools: - desc = f": {tool.description}" if tool.description else "" - lines.append(f"- {tool.name}{desc}") - return "\n".join(lines) - - -class Search: - """Discovery tool factory that searches the catalog by query. - - Args: - search_fn: Async callable ``(tools, query) -> matching_tools``. - Defaults to BM25 ranking. - name: Name of the synthetic tool exposed to the LLM. - default_detail: Default detail level for search results. - ``"brief"`` returns tool names and descriptions only. - ``"detailed"`` returns compact markdown with parameter schemas. - ``"full"`` returns complete JSON tool definitions. - default_limit: Maximum number of results to return. - The LLM can override this per call. ``None`` means no limit. - """ - - def __init__( - self, - *, - search_fn: SearchFn | None = None, - name: str = "search", - default_detail: ToolDetailLevel | None = None, - default_limit: int | None = None, - ) -> None: - if search_fn is None: - from fastmcp.server.transforms.search.bm25 import BM25SearchTransform - - _bm25 = BM25SearchTransform(max_results=default_limit or 50) - search_fn = _bm25._search - self._search_fn = search_fn - self._name = name - self._default_detail: ToolDetailLevel = default_detail or "brief" - self._default_limit = default_limit - - def __call__(self, get_catalog: GetToolCatalog) -> Tool: - search_fn = self._search_fn - default_detail = self._default_detail - default_limit = self._default_limit - - async def search( - query: Annotated[str, "Search query to find available tools"], - tags: Annotated[ - list[str] | None, - "Filter to tools with any of these tags before searching", - ] = None, - detail: Annotated[ - ToolDetailLevel, - "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", - ] = default_detail, - limit: Annotated[ - int | None, - "Maximum number of results to return", - ] = default_limit, - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> str: - """Search for available tools by query. - - Returns matching tools ranked by relevance. - """ - catalog = await get_catalog(ctx) - catalog_size = len(catalog) - tools: Sequence[Tool] = catalog - if tags: - tag_set = set(tags) - has_untagged = "untagged" in tag_set - real_tags = tag_set - {"untagged"} - tools = [ - t - for t in tools - if (t.tags & real_tags) or (has_untagged and not t.tags) - ] - results = await search_fn(tools, query) - if limit is not None: - results = results[:limit] - rendered = _render_tools(results, detail) - if len(results) < catalog_size and detail != "full": - n = len(results) - rendered = f"{n} of {catalog_size} tools:\n\n{rendered}" - return rendered - - return Tool.from_function(fn=search, name=self._name) - - -class GetSchemas: - """Discovery tool factory that returns schemas for tools by name. - - Args: - name: Name of the synthetic tool exposed to the LLM. - default_detail: Default detail level for schema results. - ``"brief"`` returns tool names and descriptions only. - ``"detailed"`` renders compact markdown with parameter names, - types, and required markers. - ``"full"`` returns the complete JSON schema. - """ - - def __init__( - self, - *, - name: str = "get_schema", - default_detail: ToolDetailLevel | None = None, - ) -> None: - self._name = name - self._default_detail: ToolDetailLevel = default_detail or "detailed" - - def __call__(self, get_catalog: GetToolCatalog) -> Tool: - default_detail = self._default_detail - - async def get_schema( - tools: Annotated[ - list[str], - "List of tool names to get schemas for", - ], - detail: Annotated[ - ToolDetailLevel, - "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", - ] = default_detail, - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> str: - """Get parameter schemas for specific tools. - - Use after searching to get the detail needed to call a tool. - """ - catalog = await get_catalog(ctx) - catalog_by_name = {t.name: t for t in catalog} - matched = [catalog_by_name[n] for n in tools if n in catalog_by_name] - not_found = [n for n in tools if n not in catalog_by_name] - - if not matched and not_found: - return f"Tools not found: {', '.join(not_found)}" - - if detail == "full": - data = serialize_tools_for_output_json(matched) - if not_found: - data.append({"not_found": not_found}) - return json.dumps(data, indent=2) - - result = _render_tools(matched, detail) - if not_found: - result += f"\n\nTools not found: {', '.join(not_found)}" - return result - - return Tool.from_function(fn=get_schema, name=self._name) - - -class GetTags: - """Discovery tool factory that lists tool tags from the catalog. - - Reads ``tool.tags`` from the catalog and groups tools by tag. Tools - without tags appear under ``"untagged"``. - - Args: - name: Name of the synthetic tool exposed to the LLM. - default_detail: Default detail level. - ``"brief"`` returns tag names with tool counts. - ``"full"`` lists all tools under each tag. - """ - - def __init__( - self, - *, - name: str = "tags", - default_detail: Literal["brief", "full"] | None = None, - ) -> None: - self._name = name - self._default_detail: Literal["brief", "full"] = default_detail or "brief" - - def __call__(self, get_catalog: GetToolCatalog) -> Tool: - default_detail = self._default_detail - - async def tags( - detail: Annotated[ - Literal["brief", "full"], - "Level of detail: 'brief' for tag names and counts, 'full' for tools listed under each tag", - ] = default_detail, - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> str: - """List available tool tags. - - Use to browse available tools by tag before searching. - """ - catalog = await get_catalog(ctx) - by_tag: dict[str, list[Tool]] = {} - for tool in catalog: - if tool.tags: - for tag in tool.tags: - by_tag.setdefault(tag, []).append(tool) - else: - by_tag.setdefault("untagged", []).append(tool) - - if not by_tag: - return "No tools available." - - if detail == "brief": - lines = [ - f"- {tag} ({len(tools)} tool{'s' if len(tools) != 1 else ''})" - for tag, tools in sorted(by_tag.items()) - ] - return "\n".join(lines) - - blocks: list[str] = [] - for tag, tools in sorted(by_tag.items()): - lines = [f"### {tag}"] - for tool in tools: - desc = f": {tool.description}" if tool.description else "" - lines.append(f"- {tool.name}{desc}") - blocks.append("\n".join(lines)) - return "\n\n".join(blocks) - - return Tool.from_function(fn=tags, name=self._name) - - -class ListTools: - """Discovery tool factory that lists all tools in the catalog. - - Args: - name: Name of the synthetic tool exposed to the LLM. - default_detail: Default detail level. - ``"brief"`` returns tool names and one-line descriptions. - ``"detailed"`` returns compact markdown with parameter schemas. - ``"full"`` returns the complete JSON schema. - """ - - def __init__( - self, - *, - name: str = "list_tools", - default_detail: ToolDetailLevel | None = None, - ) -> None: - self._name = name - self._default_detail: ToolDetailLevel = default_detail or "brief" - - def __call__(self, get_catalog: GetToolCatalog) -> Tool: - default_detail = self._default_detail - - async def list_tools( - detail: Annotated[ - ToolDetailLevel, - "'brief' for names and descriptions, 'detailed' for parameter schemas as markdown, 'full' for complete JSON schemas", - ] = default_detail, - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> str: - """List all available tools. - - Use to see the full catalog before searching or calling tools. - """ - catalog = await get_catalog(ctx) - return _render_tools(catalog, detail) - - return Tool.from_function(fn=list_tools, name=self._name) - - -# --------------------------------------------------------------------------- -# CodeMode -# --------------------------------------------------------------------------- - - -def _default_discovery_tools() -> list[DiscoveryToolFactory]: - return [Search(), GetSchemas()] - - -class CodeMode(CatalogTransform): - """Transform that collapses all tools into discovery + execute meta-tools. - - Discovery tools are composable via the ``discovery_tools`` parameter. - Each is a callable that receives catalog access and returns a ``Tool``. - By default, ``Search`` and ``GetSchemas`` are included for - progressive disclosure: search finds candidates, get_schema retrieves - parameter details, and execute runs code. - - The ``execute`` tool is always present and provides a sandboxed Python - environment with ``call_tool(name, params)`` in scope. - """ - - def __init__( - self, - *, - sandbox_provider: SandboxProvider | None = None, - discovery_tools: list[DiscoveryToolFactory] | None = None, - execute_tool_name: str = "execute", - execute_description: str | None = None, - ) -> None: - super().__init__() - self.execute_tool_name = execute_tool_name - self.execute_description = execute_description - self.sandbox_provider = sandbox_provider or MontySandboxProvider() - - self._discovery_factories = ( - discovery_tools - if discovery_tools is not None - else _default_discovery_tools() - ) - self._built_discovery_tools: list[Tool] | None = None - self._cached_execute_tool: Tool | None = None - - def _build_discovery_tools(self) -> list[Tool]: - if self._built_discovery_tools is None: - tools = [ - factory(self.get_tool_catalog) for factory in self._discovery_factories - ] - names = {t.name for t in tools} - if self.execute_tool_name in names: - raise ValueError( - f"Discovery tool name '{self.execute_tool_name}' " - f"collides with execute_tool_name." - ) - if len(names) != len(tools): - raise ValueError("Discovery tools must have unique names.") - self._built_discovery_tools = tools - return self._built_discovery_tools - - async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - return [*self._build_discovery_tools(), self._get_execute_tool()] - - async def get_tool( - self, - name: str, - call_next: GetToolNext, - *, - version: VersionSpec | None = None, - ) -> Tool | None: - for tool in self._build_discovery_tools(): - if tool.name == name: - return tool - if name == self.execute_tool_name: - return self._get_execute_tool() - return await call_next(name, version=version) - - def _build_execute_description(self) -> str: - if self.execute_description is not None: - return self.execute_description - - return ( - "Chain `await call_tool(...)` calls in one Python block; prefer returning the final answer from a single block.\n" - "Use `return` to produce output.\n" - "Only `call_tool(tool_name: str, params: dict) -> Any` is available in scope." - ) - - @staticmethod - def _find_tool(name: str, tools: Sequence[Tool]) -> Tool | None: - """Find a tool by name from a pre-fetched list.""" - for tool in tools: - if tool.name == name: - return tool - return None - - def _get_execute_tool(self) -> Tool: - if self._cached_execute_tool is None: - self._cached_execute_tool = self._make_execute_tool() - return self._cached_execute_tool - - def _make_execute_tool(self) -> Tool: - transform = self - - async def execute( - code: Annotated[ - str, - Field( - description=( - "Python async code to execute tool calls via call_tool(name, arguments)" - ) - ), - ], - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> Any: - """Execute tool calls using Python code.""" - - async def call_tool(tool_name: str, params: dict[str, Any]) -> Any: - backend_tools = await transform.get_tool_catalog(ctx) - tool = transform._find_tool(tool_name, backend_tools) - if tool is None: - raise NotFoundError(f"Unknown tool: {tool_name}") - - result = await ctx.fastmcp.call_tool(tool.name, params) - return _unwrap_tool_result(result) - - return await transform.sandbox_provider.run( - code, - external_functions={"call_tool": call_tool}, - ) - - return Tool.from_function( - fn=execute, - name=self.execute_tool_name, - description=self._build_execute_description(), - ) - - -__all__ = [ - "CodeMode", - "GetSchemas", - "GetTags", - "GetToolCatalog", - "ListTools", - "MontySandboxProvider", - "SandboxProvider", - "Search", -] diff --git a/src/fastmcp/experimental/utilities/openapi/__init__.py b/src/fastmcp/experimental/utilities/openapi/__init__.py deleted file mode 100644 index cdba7dda8..000000000 --- a/src/fastmcp/experimental/utilities/openapi/__init__.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Deprecated: Import from fastmcp.utilities.openapi instead.""" - -import warnings - -from fastmcp.exceptions import FastMCPDeprecationWarning - -from fastmcp.utilities.openapi import ( - HTTPRoute, - HttpMethod, - ParameterInfo, - ParameterLocation, - RequestBodyInfo, - ResponseInfo, - extract_output_schema_from_responses, - parse_openapi_to_http_routes, - _combine_schemas, -) - -# Deprecated in 2.14 when OpenAPI support was promoted out of experimental -warnings.warn( - "Importing from fastmcp.experimental.utilities.openapi is deprecated. " - "Import from fastmcp.utilities.openapi instead.", - FastMCPDeprecationWarning, - stacklevel=2, -) - -__all__ = [ - "HTTPRoute", - "HttpMethod", - "ParameterInfo", - "ParameterLocation", - "RequestBodyInfo", - "ResponseInfo", - "_combine_schemas", - "extract_output_schema_from_responses", - "parse_openapi_to_http_routes", -] diff --git a/src/fastmcp/mcp_config.py b/src/fastmcp/mcp_config.py deleted file mode 100644 index d4dbf2df5..000000000 --- a/src/fastmcp/mcp_config.py +++ /dev/null @@ -1,371 +0,0 @@ -"""Canonical MCP Configuration Format. - -This module defines the standard configuration format for Model Context Protocol (MCP) servers. -It provides a client-agnostic, extensible format that can be used across all MCP implementations. - -The configuration format supports both stdio and remote (HTTP/SSE) transports, with comprehensive -field definitions for server metadata, authentication, and execution parameters. - -Example configuration: -```json -{ - "mcpServers": { - "my-server": { - "command": "npx", - "args": ["-y", "@my/mcp-server"], - "env": {"API_KEY": "secret"}, - "timeout": 30000, - "description": "My MCP server" - } - } -} -``` -""" - -from __future__ import annotations - -import datetime -import re -from pathlib import Path -from typing import TYPE_CHECKING, Annotated, Any, Literal, cast -from urllib.parse import urlparse - -import httpx -from pydantic import ( - AnyUrl, - BaseModel, - ConfigDict, - Field, - model_validator, -) -from typing_extensions import Self, override - -from fastmcp.tools.tool_transform import ToolTransformConfig -from fastmcp.utilities.types import FastMCPBaseModel - -if TYPE_CHECKING: - from fastmcp.client.transports import ( - ClientTransport, - SSETransport, - StdioTransport, - StreamableHttpTransport, - ) - from fastmcp.server.server import FastMCP - - -def infer_transport_type_from_url( - url: str | AnyUrl, -) -> Literal["http", "sse"]: - """ - Infer the appropriate transport type from the given URL. - """ - url = str(url) - if not url.startswith("http"): - raise ValueError(f"Invalid URL: {url}") - - parsed_url = urlparse(url) - path = parsed_url.path - - # Match /sse followed by /, ?, &, or end of string - if re.search(r"/sse(/|\?|&|$)", path): - return "sse" - else: - return "http" - - -class _TransformingMCPServerMixin(FastMCPBaseModel): - """A mixin that enables wrapping an MCP Server with tool transforms.""" - - tools: dict[str, ToolTransformConfig] = Field(default_factory=dict) - """The multi-tool transform to apply to the tools.""" - - include_tags: set[str] | None = Field( - default=None, - description="The tags to include in the proxy.", - ) - - exclude_tags: set[str] | None = Field( - default=None, - description="The tags to exclude in the proxy.", - ) - - @model_validator(mode="before") - @classmethod - def _require_at_least_one_transform_field( - cls, values: dict[str, Any] - ) -> dict[str, Any]: - """Reject if none of the transforming fields are set. - - This ensures that plain server configs (without tools, include_tags, - or exclude_tags) fall through to the base server types during union - validation, avoiding unnecessary proxy wrapping. - """ - if isinstance(values, dict): - has_tools = bool(values.get("tools")) - has_include = values.get("include_tags") is not None - has_exclude = values.get("exclude_tags") is not None - if not (has_tools or has_include or has_exclude): - raise ValueError( - "At least one of 'tools', 'include_tags', or 'exclude_tags' is required" - ) - return values - - def _to_server_and_underlying_transport( - self, - server_name: str | None = None, - client_name: str | None = None, - ) -> tuple[FastMCP[Any], ClientTransport]: - """Turn the Transforming MCPServer into a FastMCP Server and also return the underlying transport.""" - from fastmcp.client import Client - from fastmcp.client.transports import ( - ClientTransport, # pyright: ignore[reportUnusedImport] - ) - from fastmcp.server import create_proxy - - transport: ClientTransport = super().to_transport() # pyright: ignore[reportUnknownMemberType, reportAttributeAccessIssue, reportUnknownVariableType] # ty: ignore[unresolved-attribute] - transport = cast(ClientTransport, transport) - - client: Client[ClientTransport] = Client(transport=transport, name=client_name) - - wrapped_mcp_server = create_proxy( - client, - name=server_name, - ) - - if self.include_tags is not None: - wrapped_mcp_server.enable(tags=self.include_tags, only=True) - if self.exclude_tags is not None: - wrapped_mcp_server.disable(tags=self.exclude_tags) - - # Apply tool transforms if configured - if self.tools: - from fastmcp.server.transforms import ToolTransform - - wrapped_mcp_server.add_transform(ToolTransform(self.tools)) - - return wrapped_mcp_server, transport - - def to_transport(self) -> ClientTransport: - """Get the transport for the transforming MCP server.""" - from fastmcp.client.transports import FastMCPTransport - - return FastMCPTransport(mcp=self._to_server_and_underlying_transport()[0]) - - -class StdioMCPServer(BaseModel): - """MCP server configuration for stdio transport. - - This is the canonical configuration format for MCP servers using stdio transport. - """ - - # Required fields - command: str - - # Common optional fields - args: list[str] = Field(default_factory=list) - env: dict[str, Any] = Field(default_factory=dict) - - # Transport specification - transport: Literal["stdio"] = "stdio" - type: Literal["stdio"] | None = None # Alternative transport field name - - # Execution context - cwd: str | None = None # Working directory for command execution - timeout: int | None = None # Maximum response time in milliseconds - keep_alive: bool | None = ( - None # Whether to keep the subprocess alive between connections - ) - - # Metadata - description: str | None = None # Human-readable server description - icon: str | None = None # Icon path or URL for UI display - - # Authentication configuration - authentication: dict[str, Any] | None = None # Auth configuration object - - model_config = ConfigDict(extra="allow") # Preserve unknown fields - - def to_transport(self) -> StdioTransport: - from fastmcp.client.transports import StdioTransport - - return StdioTransport( - command=self.command, - args=self.args, - env=self.env, - cwd=self.cwd, - keep_alive=self.keep_alive, - ) - - -class TransformingStdioMCPServer(_TransformingMCPServerMixin, StdioMCPServer): - """A Stdio server with tool transforms.""" - - -class RemoteMCPServer(BaseModel): - """MCP server configuration for HTTP/SSE transport. - - This is the canonical configuration format for MCP servers using remote transports. - """ - - # Required fields - url: str - - # Transport configuration - transport: Literal["http", "streamable-http", "sse"] | None = None - headers: dict[str, str] = Field(default_factory=dict) - - # Authentication - auth: Annotated[ - str | Literal["oauth"] | httpx.Auth | None, - Field( - description='Either a string representing a Bearer token, the literal "oauth" to use OAuth authentication, or an httpx.Auth instance for custom authentication.', - ), - ] = None - - # Timeout configuration - sse_read_timeout: datetime.timedelta | int | float | None = None - timeout: int | None = None # Maximum response time in milliseconds - - # Metadata - description: str | None = None # Human-readable server description - icon: str | None = None # Icon path or URL for UI display - - # Authentication configuration - authentication: dict[str, Any] | None = None # Auth configuration object - - model_config = ConfigDict( - extra="allow", arbitrary_types_allowed=True - ) # Preserve unknown fields - - def to_transport(self) -> StreamableHttpTransport | SSETransport: - from fastmcp.client.transports import SSETransport, StreamableHttpTransport - - if self.transport is None: - transport = infer_transport_type_from_url(self.url) - else: - transport = self.transport - - if transport == "sse": - return SSETransport( - self.url, - headers=self.headers, - auth=self.auth, - sse_read_timeout=self.sse_read_timeout, - ) - else: - # Both "http" and "streamable-http" map to StreamableHttpTransport - return StreamableHttpTransport( - self.url, - headers=self.headers, - auth=self.auth, - sse_read_timeout=self.sse_read_timeout, - ) - - -class TransformingRemoteMCPServer(_TransformingMCPServerMixin, RemoteMCPServer): - """A Remote server with tool transforms.""" - - -TransformingMCPServerTypes = TransformingStdioMCPServer | TransformingRemoteMCPServer - -CanonicalMCPServerTypes = StdioMCPServer | RemoteMCPServer - -MCPServerTypes = TransformingMCPServerTypes | CanonicalMCPServerTypes - - -class MCPConfig(BaseModel): - """A configuration object for MCP Servers that conforms to the canonical MCP configuration format - while adding additional fields for enabling FastMCP-specific features like tool transformations - and filtering by tags. - - For an MCPConfig that is strictly canonical, see the `CanonicalMCPConfig` class. - """ - - mcpServers: dict[str, MCPServerTypes] = Field(default_factory=dict) - - model_config = ConfigDict(extra="allow") # Preserve unknown top-level fields - - @model_validator(mode="before") - @classmethod - def wrap_servers_at_root(cls, values: dict[str, Any]) -> dict[str, Any]: - """If there's no mcpServers key but there are server configs at root, wrap them.""" - if "mcpServers" not in values: - # Check if any values look like server configs - has_servers = any( - isinstance(v, dict) and ("command" in v or "url" in v) - for v in values.values() - ) - if has_servers: - # Move all server-like configs under mcpServers - return {"mcpServers": values} - return values - - def add_server(self, name: str, server: MCPServerTypes) -> None: - """Add or update a server in the configuration.""" - self.mcpServers[name] = server - - @classmethod - def from_dict(cls, config: dict[str, Any]) -> Self: - """Parse MCP configuration from dictionary format.""" - return cls.model_validate(config) - - def to_dict(self) -> dict[str, Any]: - """Convert MCPConfig to dictionary format, preserving all fields.""" - return self.model_dump(exclude_none=True) - - def write_to_file(self, file_path: Path) -> None: - """Write configuration to JSON file.""" - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(self.model_dump_json(indent=2)) - - @classmethod - def from_file(cls, file_path: Path) -> Self: - """Load configuration from JSON file.""" - if file_path.exists() and (content := file_path.read_text().strip()): - return cls.model_validate_json(content) - - raise ValueError(f"No MCP servers defined in the config: {file_path}") - - -class CanonicalMCPConfig(MCPConfig): - """Canonical MCP configuration format. - - This defines the standard configuration format for Model Context Protocol servers. - The format is designed to be client-agnostic and extensible for future use cases. - """ - - mcpServers: dict[str, CanonicalMCPServerTypes] = Field(default_factory=dict) - - @override - def add_server(self, name: str, server: CanonicalMCPServerTypes) -> None: - """Add or update a server in the configuration.""" - self.mcpServers[name] = server - - -def update_config_file( - file_path: Path, - server_name: str, - server_config: CanonicalMCPServerTypes, -) -> None: - """Update an MCP configuration file from a server object, preserving existing fields. - - This is used for updating the mcpServer configurations of third-party tools so we do not - worry about transforming server objects here.""" - config = MCPConfig.from_file(file_path) - - # If updating an existing server, merge with existing configuration - # to preserve any unknown fields - if existing_server := config.mcpServers.get(server_name): - # Get the raw dict representation of both servers - existing_dict = existing_server.model_dump() - - new_dict = server_config.model_dump(exclude_none=True) - - # Merge, with new values taking precedence - merged_config = server_config.model_validate({**existing_dict, **new_dict}) - - config.add_server(server_name, merged_config) - else: - config.add_server(server_name, server_config) - - config.write_to_file(file_path) diff --git a/src/fastmcp/prompts/__init__.py b/src/fastmcp/prompts/__init__.py deleted file mode 100644 index d1b866075..000000000 --- a/src/fastmcp/prompts/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -import sys - -from .function_prompt import FunctionPrompt, prompt -from .base import Message, Prompt, PromptArgument, PromptMessage, PromptResult - -# Backward compat: prompt.py was renamed to base.py to stop Pyright from resolving -# `from fastmcp.prompts import prompt` as the submodule instead of the decorator function. -# This shim keeps `from fastmcp.prompts.prompt import Prompt` working at runtime. -# Safe to remove once we're confident no external code imports from the old path. -sys.modules[f"{__name__}.prompt"] = sys.modules[f"{__name__}.base"] - -__all__ = [ - "FunctionPrompt", - "Message", - "Prompt", - "PromptArgument", - "PromptMessage", - "PromptResult", - "prompt", -] diff --git a/src/fastmcp/prompts/base.py b/src/fastmcp/prompts/base.py deleted file mode 100644 index db399bde2..000000000 --- a/src/fastmcp/prompts/base.py +++ /dev/null @@ -1,440 +0,0 @@ -"""Base classes for FastMCP prompts.""" - -from __future__ import annotations as _annotations - -import warnings -from collections.abc import Callable -from typing import TYPE_CHECKING, Any, ClassVar, Literal, overload - -import pydantic -import pydantic_core - -if TYPE_CHECKING: - from docket import Docket - from docket.execution import Execution - - from fastmcp.prompts.function_prompt import FunctionPrompt -import mcp.types -from mcp import GetPromptResult -from mcp.types import ( - AudioContent, - EmbeddedResource, - Icon, - ImageContent, - PromptMessage, - TextContent, -) -from mcp.types import Prompt as SDKPrompt -from mcp.types import PromptArgument as SDKPromptArgument -from pydantic import Field -from pydantic.json_schema import SkipJsonSchema - -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.tasks.config import TaskConfig, TaskMeta -from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import ( - FastMCPBaseModel, -) - -logger = get_logger(__name__) - - -class Message(pydantic.BaseModel): - """Wrapper for prompt message with auto-serialization. - - Accepts any content - strings pass through, other types - (dict, list, BaseModel) are JSON-serialized to text. - - Example: - ```python - from fastmcp.prompts import Message - - # String content (user role by default) - Message("Hello, world!") - - # Explicit role - Message("I can help with that.", role="assistant") - - # Auto-serialized to JSON - Message({"key": "value"}) - Message(["item1", "item2"]) - ``` - """ - - role: Literal["user", "assistant"] - content: TextContent | ImageContent | AudioContent | EmbeddedResource - - def __init__( - self, - content: Any, - role: Literal["user", "assistant"] = "user", - ): - """Create Message with automatic serialization. - - Args: - content: The message content. str passes through directly. - TextContent, ImageContent, AudioContent, and - EmbeddedResource pass through. - Other types (dict, list, BaseModel) are JSON-serialized. - role: The message role, either "user" or "assistant". - """ - # Handle already-wrapped content types - if isinstance( - content, (TextContent, ImageContent, AudioContent, EmbeddedResource) - ): - normalized_content: ( - TextContent | ImageContent | AudioContent | EmbeddedResource - ) = content - elif isinstance(content, str): - normalized_content = TextContent(type="text", text=content) - else: - # dict, list, BaseModel → JSON string - serialized = pydantic_core.to_json(content, fallback=str).decode() - normalized_content = TextContent(type="text", text=serialized) - - super().__init__(role=role, content=normalized_content) - - def to_mcp_prompt_message(self) -> PromptMessage: - """Convert to MCP PromptMessage.""" - return PromptMessage(role=self.role, content=self.content) - - -class PromptArgument(FastMCPBaseModel): - """An argument that can be passed to a prompt.""" - - name: str = Field(description="Name of the argument") - description: str | None = Field( - default=None, description="Description of what the argument does" - ) - required: bool = Field( - default=False, description="Whether the argument is required" - ) - - -class PromptResult(pydantic.BaseModel): - """Canonical result type for prompt rendering. - - Provides explicit control over prompt responses: multiple messages, - roles, and metadata at both the message and result level. - - Accepts: - - str: Wrapped as single Message (user role) - - list[Message]: Used directly for multiple messages or custom roles - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.prompts import PromptResult, Message - - mcp = FastMCP() - - # Simple string content - @mcp.prompt - def greet() -> PromptResult: - return PromptResult("Hello!") - - # Multiple messages with roles - @mcp.prompt - def conversation() -> PromptResult: - return PromptResult([ - Message("What's the weather?"), - Message("It's sunny today.", role="assistant"), - ]) - ``` - """ - - messages: list[Message] - description: str | None = None - meta: dict[str, Any] | None = None - - def __init__( - self, - messages: str | list[Message], - description: str | None = None, - meta: dict[str, Any] | None = None, - ): - """Create PromptResult. - - Args: - messages: String or list of Message objects. - description: Optional description of the prompt result. - meta: Optional metadata about the prompt result. - """ - normalized = self._normalize_messages(messages) - super().__init__(messages=normalized, description=description, meta=meta) - - @staticmethod - def _normalize_messages( - messages: str | list[Message], - ) -> list[Message]: - """Normalize input to list[Message].""" - if isinstance(messages, str): - return [Message(messages)] - if isinstance(messages, list): - # Validate all items are Message - for i, item in enumerate(messages): - if not isinstance(item, Message): - raise TypeError( - f"messages[{i}] must be Message, got {type(item).__name__}. " - f"Use Message({item!r}) to wrap the value." - ) - return messages - raise TypeError( - f"messages must be str or list[Message], got {type(messages).__name__}" - ) - - def to_mcp_prompt_result(self) -> GetPromptResult: - """Convert to MCP GetPromptResult.""" - mcp_messages = [m.to_mcp_prompt_message() for m in self.messages] - return GetPromptResult( - description=self.description, - messages=mcp_messages, - _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] - ) - - -class Prompt(FastMCPComponent): - """A prompt template that can be rendered with parameters.""" - - KEY_PREFIX: ClassVar[str] = "prompt" - - arguments: list[PromptArgument] | None = Field( - default=None, description="Arguments that can be passed to the prompt" - ) - auth: SkipJsonSchema[AuthCheck | list[AuthCheck] | None] = Field( - default=None, description="Authorization checks for this prompt", exclude=True - ) - - def to_mcp_prompt( - self, - **overrides: Any, - ) -> SDKPrompt: - """Convert the prompt to an MCP prompt.""" - arguments = [ - SDKPromptArgument( - name=arg.name, - description=arg.description, - required=arg.required, - ) - for arg in self.arguments or [] - ] - - return SDKPrompt( - name=overrides.get("name", self.name), - description=overrides.get("description", self.description), - arguments=arguments, - title=overrides.get("title", self.title), - icons=overrides.get("icons", self.icons), - _meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field - "_meta", self.get_meta() - ), # ty:ignore[unknown-argument] - ) - - @classmethod - def from_function( - cls, - fn: Callable[..., Any], - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - tags: set[str] | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> FunctionPrompt: - """Create a Prompt from a function. - - The function can return: - - str: wrapped as single user Message - - list[Message | str]: converted to list[Message] - - PromptResult: used directly - """ - from fastmcp.prompts.function_prompt import FunctionPrompt - - return FunctionPrompt.from_function( - fn=fn, - name=name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - meta=meta, - task=task, - auth=auth, - ) - - async def render( - self, - arguments: dict[str, Any] | None = None, - ) -> str | list[Message | str] | PromptResult: - """Render the prompt with arguments. - - Subclasses must implement this method. Return one of: - - str: Wrapped as single user Message - - list[Message | str]: Converted to list[Message] - - PromptResult: Used directly - """ - raise NotImplementedError("Subclasses must implement render()") - - def convert_result(self, raw_value: Any) -> PromptResult: - """Convert a raw return value to PromptResult. - - Accepts: - - PromptResult: passed through - - str: wrapped as single Message - - list[Message | str]: converted to list[Message] - - Raises: - TypeError: for unsupported types - """ - if isinstance(raw_value, PromptResult): - return raw_value - - if isinstance(raw_value, str): - return PromptResult(raw_value, description=self.description, meta=self.meta) - - if isinstance(raw_value, list | tuple): - messages: list[Message] = [] - for i, item in enumerate(raw_value): - if isinstance(item, Message): - messages.append(item) - elif isinstance(item, str): - messages.append(Message(item)) - else: - raise TypeError( - f"messages[{i}] must be Message or str, got {type(item).__name__}. " - f"Use Message({item!r}) to wrap the value." - ) - return PromptResult(messages, description=self.description, meta=self.meta) - - raise TypeError( - f"Prompt must return str, list[Message], or PromptResult, " - f"got {type(raw_value).__name__}" - ) - - @overload - async def _render( - self, - arguments: dict[str, Any] | None = None, - task_meta: None = None, - ) -> PromptResult: ... - - @overload - async def _render( - self, - arguments: dict[str, Any] | None, - task_meta: TaskMeta, - ) -> mcp.types.CreateTaskResult: ... - - async def _render( - self, - arguments: dict[str, Any] | None = None, - task_meta: TaskMeta | None = None, - ) -> PromptResult | mcp.types.CreateTaskResult: - """Server entry point that handles task routing. - - This allows ANY Prompt subclass to support background execution by setting - task_config.mode to "supported" or "required". The server calls this - method instead of render() directly. - - Args: - arguments: Prompt arguments - task_meta: If provided, execute as background task and return - CreateTaskResult. If None (default), execute synchronously and - return PromptResult. - - Returns: - PromptResult when task_meta is None. - CreateTaskResult when task_meta is provided. - - Subclasses can override this to customize task routing behavior. - For example, FastMCPProviderPrompt overrides to delegate to child - middleware without submitting to Docket. - """ - from fastmcp.server.tasks.routing import check_background_task - - task_result = await check_background_task( - component=self, - task_type="prompt", - arguments=arguments, - task_meta=task_meta, - ) - if task_result: - return task_result - - # Synchronous execution - result = await self.render(arguments) - return self.convert_result(result) - - def register_with_docket(self, docket: Docket) -> None: - """Register this prompt with docket for background execution.""" - if not self.task_config.supports_tasks(): - return - docket.register(self.render, names=[self.key]) - - async def add_to_docket( # type: ignore[override] - self, - docket: Docket, - arguments: dict[str, Any] | None, - *, - fn_key: str | None = None, - task_key: str | None = None, - **kwargs: Any, - ) -> Execution: - """Schedule this prompt for background execution via docket. - - Args: - docket: The Docket instance - arguments: Prompt arguments - fn_key: Function lookup key in Docket registry (defaults to self.key) - task_key: Redis storage key for the result - **kwargs: Additional kwargs passed to docket.add() - """ - lookup_key = fn_key or self.key - if task_key: - kwargs["key"] = task_key - return await docket.add(lookup_key, **kwargs)(arguments) - - def get_span_attributes(self) -> dict[str, Any]: - return super().get_span_attributes() | { - "fastmcp.component.type": "prompt", - "fastmcp.provider.type": "LocalProvider", - } - - -__all__ = [ - "Message", - "Prompt", - "PromptArgument", - "PromptResult", -] - - -def __getattr__(name: str) -> Any: - """Deprecated re-exports for backwards compatibility.""" - deprecated_exports = { - "FunctionPrompt": "FunctionPrompt", - "prompt": "prompt", - } - - if name in deprecated_exports: - import fastmcp - - if fastmcp.settings.deprecation_warnings: - warnings.warn( - f"Importing {name} from fastmcp.prompts.prompt is deprecated. " - f"Import from fastmcp.prompts.function_prompt instead.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - from fastmcp.prompts import function_prompt - - return getattr(function_prompt, name) - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/fastmcp/prompts/function_prompt.py b/src/fastmcp/prompts/function_prompt.py deleted file mode 100644 index b5ddd3425..000000000 --- a/src/fastmcp/prompts/function_prompt.py +++ /dev/null @@ -1,484 +0,0 @@ -"""Standalone @prompt decorator for FastMCP.""" - -from __future__ import annotations - -import functools -import inspect -import json -import warnings -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import ( - TYPE_CHECKING, - Any, - Literal, - Protocol, - TypeVar, - overload, - runtime_checkable, -) - -import pydantic_core -from mcp.types import Icon -from pydantic.json_schema import SkipJsonSchema - -import fastmcp -from fastmcp.decorators import resolve_task_config -from fastmcp.exceptions import FastMCPDeprecationWarning, PromptError -from fastmcp.prompts.base import Prompt, PromptArgument, PromptResult -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.dependencies import ( - transform_context_annotations, - without_injected_parameters, -) -from fastmcp.server.tasks.config import TaskConfig -from fastmcp.utilities.async_utils import ( - call_sync_fn_in_threadpool, - is_coroutine_function, -) -from fastmcp.utilities.json_schema import compress_schema -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import get_cached_typeadapter - -if TYPE_CHECKING: - from docket import Docket - from docket.execution import Execution - -F = TypeVar("F", bound=Callable[..., Any]) - -logger = get_logger(__name__) - - -@runtime_checkable -class DecoratedPrompt(Protocol): - """Protocol for functions decorated with @prompt.""" - - __fastmcp__: PromptMeta - - def __call__(self, *args: Any, **kwargs: Any) -> Any: ... - - -@dataclass(frozen=True, kw_only=True) -class PromptMeta: - """Metadata attached to functions by the @prompt decorator.""" - - type: Literal["prompt"] = field(default="prompt", init=False) - name: str | None = None - version: str | int | None = None - title: str | None = None - description: str | None = None - icons: list[Icon] | None = None - tags: set[str] | None = None - meta: dict[str, Any] | None = None - task: bool | TaskConfig | None = None - auth: AuthCheck | list[AuthCheck] | None = None - enabled: bool = True - - -class FunctionPrompt(Prompt): - """A prompt that is a function.""" - - fn: SkipJsonSchema[Callable[..., Any]] - - @classmethod - def from_function( - cls, - fn: Callable[..., Any], - *, - metadata: PromptMeta | None = None, - # Keep individual params for backwards compat - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - tags: set[str] | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> FunctionPrompt: - """Create a Prompt from a function. - - Args: - fn: The function to wrap - metadata: PromptMeta object with all configuration. If provided, - individual parameters must not be passed. - name, title, etc.: Individual parameters for backwards compatibility. - Cannot be used together with metadata parameter. - - The function can return: - - str: wrapped as single user Message - - list[Message | str]: converted to list[Message] - - PromptResult: used directly - """ - # Check mutual exclusion - individual_params_provided = any( - x is not None - for x in [name, version, title, description, icons, tags, meta, task, auth] - ) - - if metadata is not None and individual_params_provided: - raise TypeError( - "Cannot pass both 'metadata' and individual parameters to from_function(). " - "Use metadata alone or individual parameters alone." - ) - - # Build metadata from kwargs if not provided - if metadata is None: - metadata = PromptMeta( - name=name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - meta=meta, - task=task, - auth=auth, - ) - - func_name = ( - metadata.name or getattr(fn, "__name__", None) or fn.__class__.__name__ - ) - - if func_name == "": - raise ValueError("You must provide a name for lambda functions") - - # Reject functions with *args or **kwargs - sig = inspect.signature(fn) - for param in sig.parameters.values(): - if param.kind == inspect.Parameter.VAR_POSITIONAL: - raise ValueError("Functions with *args are not supported as prompts") - if param.kind == inspect.Parameter.VAR_KEYWORD: - raise ValueError("Functions with **kwargs are not supported as prompts") - - description = metadata.description or inspect.getdoc(fn) - - # Normalize task to TaskConfig and validate - task_value = metadata.task - if task_value is None: - task_config = TaskConfig(mode="forbidden") - elif isinstance(task_value, bool): - task_config = TaskConfig.from_bool(task_value) - else: - task_config = task_value - task_config.validate_function(fn, func_name) - - # if the fn is a callable class, we need to get the __call__ method from here out - if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): - fn = fn.__call__ - # if the fn is a staticmethod, we need to work with the underlying function - if isinstance(fn, staticmethod): - fn = fn.__func__ - - # Transform Context type annotations to Depends() for unified DI - fn = transform_context_annotations(fn) - - # Wrap fn to handle dependency resolution internally - wrapped_fn = without_injected_parameters(fn) - type_adapter = get_cached_typeadapter(wrapped_fn) - parameters = type_adapter.json_schema() - parameters = compress_schema(parameters, prune_titles=True) - - # Convert parameters to PromptArguments - arguments: list[PromptArgument] = [] - if "properties" in parameters: - for param_name, param in parameters["properties"].items(): - arg_description = param.get("description") - - # For non-string parameters, append JSON schema info to help users - # understand the expected format when passing as strings (MCP requirement) - if param_name in sig.parameters: - sig_param = sig.parameters[param_name] - if ( - sig_param.annotation != inspect.Parameter.empty - and sig_param.annotation is not str - ): - # Get the JSON schema for this specific parameter type - try: - param_adapter = get_cached_typeadapter(sig_param.annotation) - param_schema = param_adapter.json_schema() - - # Create compact schema representation - schema_str = json.dumps(param_schema, separators=(",", ":")) - - # Append schema info to description - schema_note = f"Provide as a JSON string matching the following schema: {schema_str}" - if arg_description: - arg_description = f"{arg_description}\n\n{schema_note}" - else: - arg_description = schema_note - except Exception as e: - # If schema generation fails, skip enhancement - logger.debug( - "Failed to generate schema for prompt argument %s: %s", - param_name, - e, - ) - - arguments.append( - PromptArgument( - name=param_name, - description=arg_description, - required=param_name in parameters.get("required", []), - ) - ) - - return cls( - name=func_name, - version=str(metadata.version) if metadata.version is not None else None, - title=metadata.title, - description=description, - icons=metadata.icons, - arguments=arguments, - tags=metadata.tags or set(), - fn=wrapped_fn, - meta=metadata.meta, - task_config=task_config, - auth=metadata.auth, - ) - - def _convert_string_arguments(self, kwargs: dict[str, Any]) -> dict[str, Any]: - """Convert string arguments to expected types based on function signature.""" - from fastmcp.server.dependencies import without_injected_parameters - - wrapper_fn = without_injected_parameters(self.fn) - sig = inspect.signature(wrapper_fn) - converted_kwargs = {} - - for param_name, param_value in kwargs.items(): - if param_name in sig.parameters: - param = sig.parameters[param_name] - - # If parameter has no annotation or annotation is str, pass as-is - if ( - param.annotation == inspect.Parameter.empty - or param.annotation is str - ) or not isinstance(param_value, str): - converted_kwargs[param_name] = param_value - else: - # Try to convert string argument using type adapter - try: - adapter = get_cached_typeadapter(param.annotation) - # Try JSON parsing first for complex types - try: - converted_kwargs[param_name] = adapter.validate_json( - param_value - ) - except (ValueError, TypeError, pydantic_core.ValidationError): - # Fallback to direct validation - converted_kwargs[param_name] = adapter.validate_python( - param_value - ) - except (ValueError, TypeError, pydantic_core.ValidationError) as e: - # If conversion fails, provide informative error - raise PromptError( - f"Could not convert argument '{param_name}' with value '{param_value}' " - f"to expected type {param.annotation}. Error: {e}" - ) from e - else: - # Parameter not in function signature, pass as-is - converted_kwargs[param_name] = param_value - - return converted_kwargs - - async def render( - self, - arguments: dict[str, Any] | None = None, - ) -> PromptResult: - """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: - # Prepare arguments - kwargs = arguments.copy() if arguments else {} - - # Convert string arguments to expected types BEFORE validation - kwargs = self._convert_string_arguments(kwargs) - - # Filter out arguments that aren't in the function signature - # This is important for security: dependencies should not be overridable - # from external callers. self.fn is wrapped by without_injected_parameters, - # so we only accept arguments that are in the wrapped function's signature. - sig = inspect.signature(self.fn) - valid_params = set(sig.parameters.keys()) - kwargs = {k: v for k, v in kwargs.items() if k in valid_params} - - # Use type adapter to validate arguments and handle Field() defaults - # This matches the behavior of tools in function_tool - type_adapter = get_cached_typeadapter(self.fn) - - # self.fn is wrapped by without_injected_parameters which handles - # dependency resolution internally - if is_coroutine_function(self.fn): - result = await type_adapter.validate_python(kwargs) - else: - # Run sync functions in threadpool to avoid blocking the event loop - result = await call_sync_fn_in_threadpool( - type_adapter.validate_python, kwargs - ) - # Handle sync wrappers that return awaitables (e.g., partial(async_fn)) - if inspect.isawaitable(result): - result = await result - - return self.convert_result(result) - except Exception as e: - logger.exception(f"Error rendering prompt {self.name}") - raise PromptError(f"Error rendering prompt {self.name}.") from e - - def register_with_docket(self, docket: Docket) -> None: - """Register this prompt with docket for background execution. - - FunctionPrompt registers the underlying function, which has the user's - Depends parameters for docket to resolve. - """ - if not self.task_config.supports_tasks(): - return - docket.register(self.fn, names=[self.key]) - - async def add_to_docket( - self, - docket: Docket, - arguments: dict[str, Any] | None, - *, - fn_key: str | None = None, - task_key: str | None = None, - **kwargs: Any, - ) -> Execution: - """Schedule this prompt for background execution via docket. - - FunctionPrompt splats the arguments dict since .fn expects **kwargs. - - Args: - docket: The Docket instance - arguments: Prompt arguments - fn_key: Function lookup key in Docket registry (defaults to self.key) - task_key: Redis storage key for the result - **kwargs: Additional kwargs passed to docket.add() - """ - lookup_key = fn_key or self.key - if task_key: - kwargs["key"] = task_key - return await docket.add(lookup_key, **kwargs)(**(arguments or {})) - - -@overload -def prompt(fn: F) -> F: ... -@overload -def prompt( - name_or_fn: str, - *, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - tags: set[str] | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, -) -> Callable[[F], F]: ... -@overload -def prompt( - name_or_fn: None = None, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - tags: set[str] | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, -) -> Callable[[F], F]: ... - - -def prompt( - name_or_fn: str | Callable[..., Any] | None = None, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - tags: set[str] | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, -) -> Any: - """Standalone decorator to mark a function as an MCP prompt. - - Returns the original function with metadata attached. Register with a server - using mcp.add_prompt(). - """ - if isinstance(name_or_fn, classmethod): - raise TypeError( - "To decorate a classmethod, use @classmethod above @prompt. " - "See https://gofastmcp.com/servers/prompts#using-with-methods" - ) - - def create_prompt( - fn: Callable[..., Any], prompt_name: str | None - ) -> FunctionPrompt: - # Create metadata first, then pass it - prompt_meta = PromptMeta( - name=prompt_name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - meta=meta, - task=resolve_task_config(task), - auth=auth, - ) - return FunctionPrompt.from_function(fn, metadata=prompt_meta) - - def attach_metadata(fn: F, prompt_name: str | None) -> F: - metadata = PromptMeta( - name=prompt_name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - meta=meta, - task=task, - auth=auth, - ) - target = fn.__func__ if hasattr(fn, "__func__") else fn - target.__fastmcp__ = metadata - return fn - - def decorator(fn: F, prompt_name: str | None) -> F: - if fastmcp.settings.decorator_mode == "object": - warnings.warn( - "decorator_mode='object' is deprecated and will be removed in a future version. " - "Decorators now return the original function with metadata attached.", - FastMCPDeprecationWarning, - stacklevel=4, - ) - return create_prompt(fn, prompt_name) # type: ignore[return-value] # ty:ignore[invalid-return-type] - return attach_metadata(fn, prompt_name) - - if inspect.isroutine(name_or_fn): - return decorator(name_or_fn, name) - elif isinstance(name_or_fn, str): - if name is not None: - raise TypeError("Cannot specify name both as first argument and keyword") - prompt_name = name_or_fn - elif name_or_fn is None: - prompt_name = name - else: - raise TypeError(f"Invalid first argument: {type(name_or_fn)}") - - def wrapper(fn: F) -> F: - return decorator(fn, prompt_name) - - return wrapper diff --git a/src/fastmcp/py.typed b/src/fastmcp/py.typed deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/fastmcp/resources/__init__.py b/src/fastmcp/resources/__init__.py deleted file mode 100644 index cbcfff785..000000000 --- a/src/fastmcp/resources/__init__.py +++ /dev/null @@ -1,32 +0,0 @@ -import sys - -from .function_resource import FunctionResource, resource -from .base import Resource, ResourceContent, ResourceResult -from .template import ResourceTemplate -from .types import ( - BinaryResource, - DirectoryResource, - FileResource, - HttpResource, - TextResource, -) - -__all__ = [ - "BinaryResource", - "DirectoryResource", - "FileResource", - "FunctionResource", - "HttpResource", - "Resource", - "ResourceContent", - "ResourceResult", - "ResourceTemplate", - "TextResource", - "resource", -] - -# Backward compat: resource.py was renamed to base.py to stop Pyright from resolving -# `from fastmcp.resources import resource` as the submodule instead of the decorator function. -# This shim keeps `from fastmcp.resources.resource import Resource` working at runtime. -# Safe to remove once we're confident no external code imports from the old path. -sys.modules[f"{__name__}.resource"] = sys.modules[f"{__name__}.base"] diff --git a/src/fastmcp/resources/base.py b/src/fastmcp/resources/base.py deleted file mode 100644 index bd86459b8..000000000 --- a/src/fastmcp/resources/base.py +++ /dev/null @@ -1,467 +0,0 @@ -"""Base classes and interfaces for FastMCP resources.""" - -from __future__ import annotations - -import base64 -from collections.abc import Callable -from typing import TYPE_CHECKING, Annotated, Any, ClassVar, overload - -import mcp.types - -if TYPE_CHECKING: - from docket import Docket - from docket.execution import Execution - - from fastmcp.resources.function_resource import FunctionResource - -import pydantic -import pydantic_core -from mcp.types import Annotations, Icon -from mcp.types import Resource as SDKResource -from pydantic import ( - AnyUrl, - ConfigDict, - Field, - UrlConstraints, - field_validator, - model_validator, -) -from pydantic.json_schema import SkipJsonSchema -from typing_extensions import Self - -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.tasks.config import TaskConfig, TaskMeta -from fastmcp.utilities.components import FastMCPComponent - - -class ResourceContent(pydantic.BaseModel): - """Wrapper for resource content with optional MIME type and metadata. - - Accepts any value for content - strings and bytes pass through directly, - other types (dict, list, BaseModel, etc.) are automatically JSON-serialized. - - Example: - ```python - from fastmcp.resources import ResourceContent - - # String content - ResourceContent("plain text") - - # Binary content - ResourceContent(b"binary data", mime_type="application/octet-stream") - - # Auto-serialized to JSON - ResourceContent({"key": "value"}) - ResourceContent(["a", "b", "c"]) - ``` - """ - - content: str | bytes - mime_type: str | None = None - meta: dict[str, Any] | None = None - - def __init__( - self, - content: Any, - mime_type: str | None = None, - meta: dict[str, Any] | None = None, - ): - """Create ResourceContent with automatic serialization. - - Args: - content: The content value. str and bytes pass through directly. - Other types (dict, list, BaseModel) are JSON-serialized. - mime_type: Optional MIME type. Defaults based on content type: - str → "text/plain", bytes → "application/octet-stream", - other → "application/json" - meta: Optional metadata dictionary. - """ - if isinstance(content, str): - normalized_content: str | bytes = content - mime_type = mime_type or "text/plain" - elif isinstance(content, bytes): - normalized_content = content - mime_type = mime_type or "application/octet-stream" - else: - # dict, list, BaseModel, etc → JSON - normalized_content = pydantic_core.to_json(content, fallback=str).decode() - mime_type = mime_type or "application/json" - - super().__init__(content=normalized_content, mime_type=mime_type, meta=meta) - - def to_mcp_resource_contents( - self, uri: AnyUrl | str - ) -> mcp.types.TextResourceContents | mcp.types.BlobResourceContents: - """Convert to MCP resource contents type. - - Args: - uri: The URI of the resource (required by MCP types) - - Returns: - TextResourceContents for str content, BlobResourceContents for bytes - """ - if isinstance(self.content, str): - return mcp.types.TextResourceContents( - uri=AnyUrl(uri) if isinstance(uri, str) else uri, - text=self.content, - mimeType=self.mime_type or "text/plain", - _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] - ) - else: - return mcp.types.BlobResourceContents( - uri=AnyUrl(uri) if isinstance(uri, str) else uri, - blob=base64.b64encode(self.content).decode(), - mimeType=self.mime_type or "application/octet-stream", - _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] - ) - - -class ResourceResult(pydantic.BaseModel): - """Canonical result type for resource reads. - - Provides explicit control over resource responses: multiple content items, - per-item MIME types, and metadata at both the item and result level. - - Accepts: - - str: Wrapped as single ResourceContent (text/plain) - - bytes: Wrapped as single ResourceContent (application/octet-stream) - - list[ResourceContent]: Used directly for multiple items or custom MIME types - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.resources import ResourceResult, ResourceContent - - mcp = FastMCP() - - # Simple string content - @mcp.resource("data://simple") - def get_simple() -> ResourceResult: - return ResourceResult("hello world") - - # Multiple items with custom MIME types - @mcp.resource("data://items") - def get_items() -> ResourceResult: - return ResourceResult( - contents=[ - ResourceContent({"key": "value"}), # auto-serialized to JSON - ResourceContent(b"binary data"), - ], - meta={"count": 2} - ) - ``` - """ - - contents: list[ResourceContent] - meta: dict[str, Any] | None = None - - def __init__( - self, - contents: str | bytes | list[ResourceContent], - meta: dict[str, Any] | None = None, - ): - """Create ResourceResult. - - Args: - contents: String, bytes, or list of ResourceContent objects. - meta: Optional metadata about the resource result. - """ - normalized = self._normalize_contents(contents) - super().__init__(contents=normalized, meta=meta) - - @staticmethod - def _normalize_contents( - contents: str | bytes | list[ResourceContent], - ) -> list[ResourceContent]: - """Normalize input to list[ResourceContent].""" - if isinstance(contents, str): - return [ResourceContent(contents)] - if isinstance(contents, bytes): - return [ResourceContent(contents)] - if isinstance(contents, list): - # Validate all items are ResourceContent - for i, item in enumerate(contents): - if not isinstance(item, ResourceContent): - raise TypeError( - f"contents[{i}] must be ResourceContent, got {type(item).__name__}. " - f"Use ResourceContent({item!r}) to wrap the value." - ) - return contents - raise TypeError( - f"contents must be str, bytes, or list[ResourceContent], got {type(contents).__name__}" - ) - - def to_mcp_result(self, uri: AnyUrl | str) -> mcp.types.ReadResourceResult: - """Convert to MCP ReadResourceResult. - - Args: - uri: The URI of the resource (required by MCP types) - - Returns: - MCP ReadResourceResult with converted contents - """ - mcp_contents = [item.to_mcp_resource_contents(uri) for item in self.contents] - return mcp.types.ReadResourceResult( - contents=mcp_contents, - _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] - ) - - -class Resource(FastMCPComponent): - """Base class for all resources.""" - - KEY_PREFIX: ClassVar[str] = "resource" - - model_config = ConfigDict(validate_default=True) - - uri: Annotated[AnyUrl, UrlConstraints(host_required=False)] = Field( - default=..., description="URI of the resource" - ) - name: str = Field(default="", description="Name of the resource") - mime_type: str = Field( - default="text/plain", - description="MIME type of the resource content", - ) - annotations: Annotated[ - Annotations | None, - Field(description="Optional annotations about the resource's behavior"), - ] = None - auth: Annotated[ - SkipJsonSchema[AuthCheck | list[AuthCheck] | None], - Field(description="Authorization checks for this resource", exclude=True), - ] = None - - @classmethod - def from_function( - cls, - fn: Callable[..., Any], - uri: str | AnyUrl, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - mime_type: str | None = None, - tags: set[str] | None = None, - annotations: Annotations | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> FunctionResource: - from fastmcp.resources.function_resource import ( - FunctionResource, - ) - - return FunctionResource.from_function( - fn=fn, - uri=uri, - name=name, - version=version, - title=title, - description=description, - icons=icons, - mime_type=mime_type, - tags=tags, - annotations=annotations, - meta=meta, - task=task, - auth=auth, - ) - - @field_validator("mime_type", mode="before") - @classmethod - def set_default_mime_type(cls, mime_type: str | None) -> str: - """Set default MIME type if not provided.""" - if mime_type: - return mime_type - return "text/plain" - - @model_validator(mode="after") - def set_default_name(self) -> Self: - """Set default name from URI if not provided.""" - if self.name: - pass - elif self.uri: - self.name = str(self.uri) - else: - raise ValueError("Either name or uri must be provided") - return self - - async def read( - self, - ) -> str | bytes | ResourceResult: - """Read the resource content. - - Subclasses implement this to return resource data. Supported return types: - - str: Text content - - bytes: Binary content - - ResourceResult: Full control over contents and result-level meta - """ - raise NotImplementedError("Subclasses must implement read()") - - def convert_result(self, raw_value: Any) -> ResourceResult: - """Convert a raw result to ResourceResult. - - This is used in two contexts: - 1. In _read() to convert user function return values to ResourceResult - 2. In tasks_result_handler() to convert Docket task results to ResourceResult - - Handles ResourceResult passthrough and converts raw values using - ResourceResult's normalization. When the raw value is a plain - string or bytes, the resource's own ``mime_type`` is forwarded so - that ``ui://`` resources (and others with non-default MIME types) - don't fall back to ``text/plain``. - - The resource's component-level ``meta`` (e.g. ``ui`` metadata for - MCP Apps CSP/permissions) is propagated to each content item so - that hosts can read it from the ``resources/read`` response. - """ - if isinstance(raw_value, ResourceResult): - return raw_value - - # For plain str/bytes returns, wrap in ResourceContent with the - # resource's MIME type and component meta so the wire response - # carries the correct type and metadata (e.g. CSP for MCP Apps). - if isinstance(raw_value, (str, bytes)): - return ResourceResult( - [ResourceContent(raw_value, mime_type=self.mime_type, meta=self.meta)] - ) - - # ResourceResult.__init__ handles all other normalization - return ResourceResult(raw_value) - - @overload - async def _read(self, task_meta: None = None) -> ResourceResult: ... - - @overload - async def _read(self, task_meta: TaskMeta) -> mcp.types.CreateTaskResult: ... - - async def _read( - self, task_meta: TaskMeta | None = None - ) -> ResourceResult | mcp.types.CreateTaskResult: - """Server entry point that handles task routing. - - This allows ANY Resource subclass to support background execution by setting - task_config.mode to "supported" or "required". The server calls this - method instead of read() directly. - - Args: - task_meta: If provided, execute as a background task and return - CreateTaskResult. If None (default), execute synchronously and - return ResourceResult. - - Returns: - ResourceResult when task_meta is None. - CreateTaskResult when task_meta is provided. - - Subclasses can override this to customize task routing behavior. - For example, FastMCPProviderResource overrides to delegate to child - middleware without submitting to Docket. - """ - from fastmcp.server.tasks.routing import check_background_task - - task_result = await check_background_task( - component=self, task_type="resource", arguments=None, task_meta=task_meta - ) - if task_result: - return task_result - - # Synchronous execution - convert result to ResourceResult - result = await self.read() - return self.convert_result(result) - - def to_mcp_resource( - self, - **overrides: Any, - ) -> SDKResource: - """Convert the resource to an SDKResource.""" - - return SDKResource( - name=overrides.get("name", self.name), - uri=overrides.get("uri", self.uri), - description=overrides.get("description", self.description), - mimeType=overrides.get("mimeType", self.mime_type), - title=overrides.get("title", self.title), - icons=overrides.get("icons", self.icons), - annotations=overrides.get("annotations", self.annotations), - _meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field - "_meta", self.get_meta() - ), # ty:ignore[unknown-argument] - ) - - def __repr__(self) -> str: - return f"{self.__class__.__name__}(uri={self.uri!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})" - - @property - def key(self) -> str: - """The globally unique lookup key for this resource.""" - base_key = self.make_key(str(self.uri)) - return f"{base_key}@{self.version or ''}" - - def register_with_docket(self, docket: Docket) -> None: - """Register this resource with docket for background execution.""" - if not self.task_config.supports_tasks(): - return - docket.register(self.read, names=[self.key]) - - async def add_to_docket( # type: ignore[override] - self, - docket: Docket, - *, - fn_key: str | None = None, - task_key: str | None = None, - **kwargs: Any, - ) -> Execution: - """Schedule this resource for background execution via docket. - - Args: - docket: The Docket instance - fn_key: Function lookup key in Docket registry (defaults to self.key) - task_key: Redis storage key for the result - **kwargs: Additional kwargs passed to docket.add() - """ - lookup_key = fn_key or self.key - if task_key: - kwargs["key"] = task_key - return await docket.add(lookup_key, **kwargs)() - - def get_span_attributes(self) -> dict[str, Any]: - return super().get_span_attributes() | { - "fastmcp.component.type": "resource", - "fastmcp.provider.type": "LocalProvider", - } - - -__all__ = [ - "Resource", - "ResourceContent", - "ResourceResult", -] - - -def __getattr__(name: str) -> Any: - """Deprecated re-exports for backwards compatibility.""" - deprecated_exports = { - "FunctionResource": "FunctionResource", - "resource": "resource", - } - - if name in deprecated_exports: - import warnings - - import fastmcp - - if fastmcp.settings.deprecation_warnings: - warnings.warn( - f"Importing {name} from fastmcp.resources.resource is deprecated. " - f"Import from fastmcp.resources.function_resource instead.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - from fastmcp.resources import function_resource - - return getattr(function_resource, name) - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/fastmcp/resources/function_resource.py b/src/fastmcp/resources/function_resource.py deleted file mode 100644 index 771eeb7cb..000000000 --- a/src/fastmcp/resources/function_resource.py +++ /dev/null @@ -1,345 +0,0 @@ -"""Standalone @resource decorator for FastMCP.""" - -from __future__ import annotations - -import functools -import inspect -import warnings -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any, Literal, Protocol, TypeVar, runtime_checkable - -from mcp.types import Annotations, Icon -from pydantic import AnyUrl -from pydantic.json_schema import SkipJsonSchema - -import fastmcp -from fastmcp.decorators import resolve_task_config -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.resources.base import Resource, ResourceResult -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.dependencies import ( - transform_context_annotations, - without_injected_parameters, -) -from fastmcp.server.tasks.config import TaskConfig -from fastmcp.utilities.async_utils import ( - call_sync_fn_in_threadpool, - is_coroutine_function, -) -from fastmcp.utilities.mime import resolve_ui_mime_type - -if TYPE_CHECKING: - from docket import Docket - - from fastmcp.resources.template import ResourceTemplate - -F = TypeVar("F", bound=Callable[..., Any]) - - -@runtime_checkable -class DecoratedResource(Protocol): - """Protocol for functions decorated with @resource.""" - - __fastmcp__: ResourceMeta - - def __call__(self, *args: Any, **kwargs: Any) -> Any: ... - - -@dataclass(frozen=True, kw_only=True) -class ResourceMeta: - """Metadata attached to functions by the @resource decorator.""" - - type: Literal["resource"] = field(default="resource", init=False) - uri: str - name: str | None = None - version: str | int | None = None - title: str | None = None - description: str | None = None - icons: list[Icon] | None = None - tags: set[str] | None = None - mime_type: str | None = None - annotations: Annotations | None = None - meta: dict[str, Any] | None = None - task: bool | TaskConfig | None = None - auth: AuthCheck | list[AuthCheck] | None = None - enabled: bool = True - - -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: SkipJsonSchema[Callable[..., Any]] - - @classmethod - def from_function( - cls, - fn: Callable[..., Any], - uri: str | AnyUrl | None = None, - *, - metadata: ResourceMeta | None = None, - # Keep individual params for backwards compat - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - mime_type: str | None = None, - tags: set[str] | None = None, - annotations: Annotations | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> FunctionResource: - """Create a FunctionResource from a function. - - Args: - fn: The function to wrap - uri: The URI for the resource (required if metadata not provided) - metadata: ResourceMeta object with all configuration. If provided, - individual parameters must not be passed. - name, title, etc.: Individual parameters for backwards compatibility. - Cannot be used together with metadata parameter. - """ - # Check mutual exclusion - individual_params_provided = ( - any( - x is not None - for x in [ - name, - version, - title, - description, - icons, - mime_type, - tags, - annotations, - meta, - task, - auth, - ] - ) - or uri is not None - ) - - if metadata is not None and individual_params_provided: - raise TypeError( - "Cannot pass both 'metadata' and individual parameters to from_function(). " - "Use metadata alone or individual parameters alone." - ) - - # Build metadata from kwargs if not provided - if metadata is None: - if uri is None: - raise TypeError("uri is required when metadata is not provided") - metadata = ResourceMeta( - uri=str(uri), - name=name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - mime_type=mime_type, - annotations=annotations, - meta=meta, - task=task, - auth=auth, - ) - - uri_obj = AnyUrl(metadata.uri) - - # Get function name - use class name for callable objects - func_name = ( - metadata.name or getattr(fn, "__name__", None) or fn.__class__.__name__ - ) - - # Normalize task to TaskConfig and validate - task_value = metadata.task - if task_value is None: - task_config = TaskConfig(mode="forbidden") - elif isinstance(task_value, bool): - task_config = TaskConfig.from_bool(task_value) - else: - task_config = task_value - task_config.validate_function(fn, func_name) - - # if the fn is a callable class, we need to get the __call__ method from here out - if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): - fn = fn.__call__ - # if the fn is a staticmethod, we need to work with the underlying function - if isinstance(fn, staticmethod): - fn = fn.__func__ - - # Transform Context type annotations to Depends() for unified DI - fn = transform_context_annotations(fn) - - # Wrap fn to handle dependency resolution internally - wrapped_fn = without_injected_parameters(fn) - - # Apply ui:// MIME default, then fall back to text/plain - resolved_mime = resolve_ui_mime_type(metadata.uri, metadata.mime_type) - - return cls( - fn=wrapped_fn, - uri=uri_obj, - name=func_name, - version=str(metadata.version) if metadata.version is not None else None, - title=metadata.title, - description=metadata.description or inspect.getdoc(fn), - icons=metadata.icons, - mime_type=resolved_mime or "text/plain", - tags=metadata.tags or set(), - annotations=metadata.annotations, - meta=metadata.meta, - task_config=task_config, - auth=metadata.auth, - ) - - async def read( - self, - ) -> str | bytes | ResourceResult: - """Read the resource by calling the wrapped function.""" - # self.fn is wrapped by without_injected_parameters which handles - # dependency resolution internally - if is_coroutine_function(self.fn): - result = await self.fn() - else: - # Run sync functions in threadpool to avoid blocking the event loop - result = await call_sync_fn_in_threadpool(self.fn) - # Handle sync wrappers that return awaitables (e.g., partial(async_fn)) - if inspect.isawaitable(result): - result = await result - - # If user returned another Resource, read it recursively - if isinstance(result, Resource): - return await result.read() - - return result - - def register_with_docket(self, docket: Docket) -> None: - """Register this resource with docket for background execution. - - FunctionResource registers the underlying function, which has the user's - Depends parameters for docket to resolve. - """ - if not self.task_config.supports_tasks(): - return - docket.register(self.fn, names=[self.key]) - - -def resource( - uri: str, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - mime_type: str | None = None, - tags: set[str] | None = None, - annotations: Annotations | dict[str, Any] | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, -) -> Callable[[F], F]: - """Standalone decorator to mark a function as an MCP resource. - - Returns the original function with metadata attached. Register with a server - using mcp.add_resource(). - """ - if isinstance(annotations, dict): - annotations = Annotations(**annotations) - - if inspect.isroutine(uri): - raise TypeError( - "The @resource decorator requires a URI. " - "Use @resource('uri') instead of @resource" - ) - - def create_resource(fn: Callable[..., Any]) -> FunctionResource | ResourceTemplate: - from fastmcp.resources.template import ResourceTemplate - from fastmcp.server.dependencies import without_injected_parameters - - resolved = resolve_task_config(task) - has_uri_params = "{" in uri and "}" in uri - wrapper_fn = without_injected_parameters(fn) - has_func_params = bool(inspect.signature(wrapper_fn).parameters) - - # Create metadata first - resource_meta = ResourceMeta( - uri=uri, - name=name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - mime_type=mime_type, - annotations=annotations, - meta=meta, - task=resolved, - auth=auth, - ) - - if has_uri_params or has_func_params: - # ResourceTemplate doesn't have metadata support yet, so pass individual params - return ResourceTemplate.from_function( - fn=fn, - uri_template=uri, - name=name, - version=version, - title=title, - description=description, - icons=icons, - mime_type=mime_type, - tags=tags, - annotations=annotations, - meta=meta, - task=resolved, - auth=auth, - ) - else: - return FunctionResource.from_function(fn, metadata=resource_meta) - - def attach_metadata(fn: F) -> F: - metadata = ResourceMeta( - uri=uri, - name=name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - mime_type=mime_type, - annotations=annotations, - meta=meta, - task=task, - auth=auth, - ) - target = fn.__func__ if hasattr(fn, "__func__") else fn - target.__fastmcp__ = metadata - return fn - - def decorator(fn: F) -> F: - if fastmcp.settings.decorator_mode == "object": - warnings.warn( - "decorator_mode='object' is deprecated and will be removed in a future version. " - "Decorators now return the original function with metadata attached.", - FastMCPDeprecationWarning, - stacklevel=3, - ) - return create_resource(fn) # type: ignore[return-value] # ty:ignore[invalid-return-type] - return attach_metadata(fn) - - return decorator diff --git a/src/fastmcp/resources/template.py b/src/fastmcp/resources/template.py deleted file mode 100644 index 3a3180c77..000000000 --- a/src/fastmcp/resources/template.py +++ /dev/null @@ -1,607 +0,0 @@ -"""Resource template functionality.""" - -from __future__ import annotations - -import functools -import inspect -import re -from collections.abc import Callable -from typing import TYPE_CHECKING, Any, ClassVar, overload -from urllib.parse import parse_qs, unquote - -import mcp.types -from mcp.types import Annotations, Icon -from pydantic.json_schema import SkipJsonSchema - -if TYPE_CHECKING: - from docket import Docket - from docket.execution import Execution -from mcp.types import ResourceTemplate as SDKResourceTemplate -from pydantic import ( - Field, - field_validator, - validate_call, -) - -from fastmcp.resources.base import Resource, ResourceResult -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.dependencies import ( - transform_context_annotations, - without_injected_parameters, -) -from fastmcp.server.tasks.config import TaskConfig, TaskMeta -from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.json_schema import compress_schema -from fastmcp.utilities.mime import resolve_ui_mime_type -from fastmcp.utilities.types import get_cached_typeadapter - - -def extract_query_params(uri_template: str) -> set[str]: - """Extract query parameter names from RFC 6570 `{?param1,param2}` syntax.""" - match = re.search(r"\{\?([^}]+)\}", uri_template) - if match: - return {p.strip() for p in match.group(1).split(",")} - return set() - - -def build_regex(template: str) -> re.Pattern[str] | None: - """Build regex pattern for URI template, handling RFC 6570 syntax. - - Supports: - - `{var}` - simple path parameter - - `{var*}` - wildcard path parameter (captures multiple segments) - - `{?var1,var2}` - query parameters (ignored in path matching) - - Returns None if the template produces an invalid regex (e.g. parameter - names with hyphens, leading digits, or duplicates from a remote server). - """ - # Remove query parameter syntax for path matching - template_without_query = re.sub(r"\{\?[^}]+\}", "", template) - - parts = re.split(r"(\{[^}]+\})", template_without_query) - pattern = "" - for part in parts: - if part.startswith("{") and part.endswith("}"): - name = part[1:-1] - if name.endswith("*"): - name = name[:-1] - pattern += f"(?P<{name}>.+)" - else: - pattern += f"(?P<{name}>[^/]+)" - else: - pattern += re.escape(part) - try: - return re.compile(f"^{pattern}$") - except re.error: - return None - - -def match_uri_template(uri: str, uri_template: str) -> dict[str, str] | None: - """Match URI against template and extract both path and query parameters. - - Supports RFC 6570 URI templates: - - Path params: `{var}`, `{var*}` - - Query params: `{?var1,var2}` - """ - # Split URI into path and query parts - uri_path, _, query_string = uri.partition("?") - - # Match path parameters - regex = build_regex(uri_template) - if regex is None: - return None - match = regex.match(uri_path) - if not match: - return None - - params = {k: unquote(v) for k, v in match.groupdict().items()} - - # Extract query parameters if present in URI and template - if query_string: - query_param_names = extract_query_params(uri_template) - parsed_query = parse_qs(query_string) - - for name in query_param_names: - if name in parsed_query: - # Take first value if multiple provided - params[name] = parsed_query[name][0] - - return params - - -class ResourceTemplate(FastMCPComponent): - """A template for dynamically creating resources.""" - - KEY_PREFIX: ClassVar[str] = "template" - - uri_template: str = Field( - description="URI template with parameters (e.g. weather://{city}/current)" - ) - mime_type: str = Field( - default="text/plain", description="MIME type of the resource content" - ) - parameters: dict[str, Any] = Field( - description="JSON schema for function parameters" - ) - annotations: Annotations | None = Field( - default=None, description="Optional annotations about the resource's behavior" - ) - auth: SkipJsonSchema[AuthCheck | list[AuthCheck] | None] = Field( - default=None, - description="Authorization checks for this resource template", - exclude=True, - ) - - def __repr__(self) -> str: - return f"{self.__class__.__name__}(uri_template={self.uri_template!r}, name={self.name!r}, description={self.description!r}, tags={self.tags})" - - @staticmethod - def from_function( - fn: Callable[..., Any], - uri_template: str, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - mime_type: str | None = None, - tags: set[str] | None = None, - annotations: Annotations | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> FunctionResourceTemplate: - return FunctionResourceTemplate.from_function( - fn=fn, - uri_template=uri_template, - name=name, - version=version, - title=title, - description=description, - icons=icons, - mime_type=mime_type, - tags=tags, - annotations=annotations, - meta=meta, - task=task, - auth=auth, - ) - - @field_validator("mime_type", mode="before") - @classmethod - def set_default_mime_type(cls, mime_type: str | None) -> str: - """Set default MIME type if not provided.""" - if mime_type: - return mime_type - return "text/plain" - - def matches(self, uri: str) -> dict[str, Any] | None: - """Check if URI matches template and extract parameters.""" - return match_uri_template(uri, self.uri_template) - - async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: - """Read the resource content.""" - raise NotImplementedError( - "Subclasses must implement read() or override create_resource()" - ) - - def convert_result(self, raw_value: Any) -> ResourceResult: - """Convert a raw result to ResourceResult. - - This is used in two contexts: - 1. In _read() to convert user function return values to ResourceResult - 2. In tasks_result_handler() to convert Docket task results to ResourceResult - - Handles ResourceResult passthrough and converts raw values using - ResourceResult's normalization. - """ - if isinstance(raw_value, ResourceResult): - return raw_value - - # ResourceResult.__init__ handles all normalization - return ResourceResult(raw_value) - - @overload - async def _read( - self, uri: str, params: dict[str, Any], task_meta: None = None - ) -> ResourceResult: ... - - @overload - async def _read( - self, uri: str, params: dict[str, Any], task_meta: TaskMeta - ) -> mcp.types.CreateTaskResult: ... - - async def _read( - self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None - ) -> ResourceResult | mcp.types.CreateTaskResult: - """Server entry point that handles task routing. - - This allows ANY ResourceTemplate subclass to support background execution - by setting task_config.mode to "supported" or "required". The server calls - this method instead of create_resource()/read() directly. - - Args: - uri: The concrete URI being read - params: Template parameters extracted from the URI - task_meta: If provided, execute as a background task and return - CreateTaskResult. If None (default), execute synchronously and - return ResourceResult. - - Returns: - ResourceResult when task_meta is None. - CreateTaskResult when task_meta is provided. - - Subclasses can override this to customize task routing behavior. - For example, FastMCPProviderResourceTemplate overrides to delegate to child - middleware without submitting to Docket. - """ - from fastmcp.server.tasks.routing import check_background_task - - task_result = await check_background_task( - component=self, task_type="template", arguments=params, task_meta=task_meta - ) - if task_result: - return task_result - - # Synchronous execution - create resource and read directly - # Call resource.read() not resource._read() to avoid task routing on ephemeral resource - resource = await self.create_resource(uri, params) - result = await resource.read() - return self.convert_result(result) - - async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: - """Create a resource from the template with the given parameters. - - The base implementation does not support background tasks. - Use FunctionResourceTemplate for task support. - """ - raise NotImplementedError( - "Subclasses must implement create_resource(). " - "Use FunctionResourceTemplate for task support." - ) - - def to_mcp_template( - self, - **overrides: Any, - ) -> SDKResourceTemplate: - """Convert the resource template to an SDKResourceTemplate.""" - - return SDKResourceTemplate( - name=overrides.get("name", self.name), - uriTemplate=overrides.get("uriTemplate", self.uri_template), - description=overrides.get("description", self.description), - mimeType=overrides.get("mimeType", self.mime_type), - title=overrides.get("title", self.title), - icons=overrides.get("icons", self.icons), - annotations=overrides.get("annotations", self.annotations), - _meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field - "_meta", self.get_meta() - ), # ty:ignore[unknown-argument] - ) - - @classmethod - def from_mcp_template(cls, mcp_template: SDKResourceTemplate) -> ResourceTemplate: - """Creates a FastMCP ResourceTemplate from a raw MCP ResourceTemplate object.""" - # Note: This creates a simple ResourceTemplate instance. For function-based templates, - # the original function is lost, which is expected for remote templates. - return cls( - uri_template=mcp_template.uriTemplate, - name=mcp_template.name, - description=mcp_template.description, - mime_type=mcp_template.mimeType or "text/plain", - parameters={}, # Remote templates don't have local parameters - ) - - @property - def key(self) -> str: - """The globally unique lookup key for this template.""" - base_key = self.make_key(self.uri_template) - return f"{base_key}@{self.version or ''}" - - def register_with_docket(self, docket: Docket) -> None: - """Register this template with docket for background execution.""" - if not self.task_config.supports_tasks(): - return - docket.register(self.read, names=[self.key]) - - async def add_to_docket( # type: ignore[override] - self, - docket: Docket, - params: dict[str, Any], - *, - fn_key: str | None = None, - task_key: str | None = None, - **kwargs: Any, - ) -> Execution: - """Schedule this template for background execution via docket. - - Args: - docket: The Docket instance - params: Template parameters - fn_key: Function lookup key in Docket registry (defaults to self.key) - task_key: Redis storage key for the result - **kwargs: Additional kwargs passed to docket.add() - """ - lookup_key = fn_key or self.key - if task_key: - kwargs["key"] = task_key - return await docket.add(lookup_key, **kwargs)(params) - - def get_span_attributes(self) -> dict[str, Any]: - return super().get_span_attributes() | { - "fastmcp.component.type": "resource_template", - "fastmcp.provider.type": "LocalProvider", - } - - -class FunctionResourceTemplate(ResourceTemplate): - """A template for dynamically creating resources.""" - - fn: SkipJsonSchema[Callable[..., Any]] - - @overload - async def _read( - self, uri: str, params: dict[str, Any], task_meta: None = None - ) -> ResourceResult: ... - - @overload - async def _read( - self, uri: str, params: dict[str, Any], task_meta: TaskMeta - ) -> mcp.types.CreateTaskResult: ... - - async def _read( - self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None - ) -> ResourceResult | mcp.types.CreateTaskResult: - """Optimized server entry point that skips ephemeral resource creation. - - For FunctionResourceTemplate, we can call read() directly instead of - creating a temporary resource, which is more efficient. - - Args: - uri: The concrete URI being read - params: Template parameters extracted from the URI - task_meta: If provided, execute as a background task and return - CreateTaskResult. If None (default), execute synchronously and - return ResourceResult. - - Returns: - ResourceResult when task_meta is None. - CreateTaskResult when task_meta is provided. - """ - from fastmcp.server.tasks.routing import check_background_task - - task_result = await check_background_task( - component=self, task_type="template", arguments=params, task_meta=task_meta - ) - if task_result: - return task_result - - # Synchronous execution - call read() directly, skip resource creation - result = await self.read(arguments=params) - return self.convert_result(result) - - async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: - """Create a resource from the template with the given parameters.""" - - async def resource_read_fn() -> str | bytes | ResourceResult: - # Call function and check if result is a coroutine - result = await self.read(arguments=params) - return result - - return Resource.from_function( - fn=resource_read_fn, - uri=uri, - name=self.name, - description=self.description, - mime_type=self.mime_type, - tags=self.tags, - task=self.task_config, - auth=self.auth, - ) - - async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: - """Read the resource content.""" - # Type coercion for query parameters (which arrive as strings) - kwargs = arguments.copy() - sig = inspect.signature(self.fn) - for param_name, param_value in list(kwargs.items()): - if param_name in sig.parameters and isinstance(param_value, str): - param = sig.parameters[param_name] - annotation = param.annotation - - if annotation is inspect.Parameter.empty or annotation is str: - continue - - try: - if annotation is int: - kwargs[param_name] = int(param_value) - elif annotation is float: - kwargs[param_name] = float(param_value) - elif annotation is bool: - lower = param_value.lower() - if lower in ("true", "1", "yes"): - kwargs[param_name] = True - elif lower in ("false", "0", "no"): - kwargs[param_name] = False - else: - raise ValueError( - f"Invalid boolean value for {param_name}: {param_value!r}" - ) - except (ValueError, AttributeError): - raise - - # self.fn is wrapped by without_injected_parameters which handles - # dependency resolution internally, so we call it directly - result = self.fn(**kwargs) - if inspect.isawaitable(result): - result = await result - - return result - - def register_with_docket(self, docket: Docket) -> None: - """Register this template with docket for background execution. - - FunctionResourceTemplate registers the underlying function, which has the - user's Depends parameters for docket to resolve. - """ - if not self.task_config.supports_tasks(): - return - docket.register(self.fn, names=[self.key]) - - async def add_to_docket( - self, - docket: Docket, - params: dict[str, Any], - *, - fn_key: str | None = None, - task_key: str | None = None, - **kwargs: Any, - ) -> Execution: - """Schedule this template for background execution via docket. - - FunctionResourceTemplate splats the params dict since .fn expects **kwargs. - - Args: - docket: The Docket instance - params: Template parameters - fn_key: Function lookup key in Docket registry (defaults to self.key) - task_key: Redis storage key for the result - **kwargs: Additional kwargs passed to docket.add() - """ - lookup_key = fn_key or self.key - if task_key: - kwargs["key"] = task_key - return await docket.add(lookup_key, **kwargs)(**params) - - @classmethod - def from_function( - cls, - fn: Callable[..., Any], - uri_template: str, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - mime_type: str | None = None, - tags: set[str] | None = None, - annotations: Annotations | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> FunctionResourceTemplate: - """Create a template from a function.""" - - func_name = name or getattr(fn, "__name__", None) or fn.__class__.__name__ - if func_name == "": - raise ValueError("You must provide a name for lambda functions") - - # Reject functions with *args - # (**kwargs is allowed because the URI will define the parameter names) - sig = inspect.signature(fn) - for param in sig.parameters.values(): - if param.kind == inspect.Parameter.VAR_POSITIONAL: - raise ValueError( - "Functions with *args are not supported as resource templates" - ) - - # Extract path and query parameters from URI template - path_params = set(re.findall(r"{(\w+)(?:\*)?}", uri_template)) - query_params = extract_query_params(uri_template) - all_uri_params = path_params | query_params - - if not all_uri_params: - raise ValueError("URI template must contain at least one parameter") - - # Use wrapper to get user-facing parameters (excludes injected params) - wrapper_fn = without_injected_parameters(fn) - user_sig = inspect.signature(wrapper_fn) - func_params = set(user_sig.parameters.keys()) - - # Get required and optional function parameters - required_params = { - p - for p in func_params - if user_sig.parameters[p].default is inspect.Parameter.empty - and user_sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD - } - optional_params = { - p - for p in func_params - if user_sig.parameters[p].default is not inspect.Parameter.empty - and user_sig.parameters[p].kind != inspect.Parameter.VAR_KEYWORD - } - - # Validate RFC 6570 query parameters - # Query params must be optional (have defaults) - if query_params: - invalid_query_params = query_params - optional_params - if invalid_query_params: - raise ValueError( - f"Query parameters {invalid_query_params} must be optional function parameters with default values" - ) - - # Check if required parameters are a subset of the path parameters - if not required_params.issubset(path_params): - raise ValueError( - f"Required function arguments {required_params} must be a subset of the URI path parameters {path_params}" - ) - - # Check if all URI parameters are valid function parameters (skip if **kwargs present) - if not any( - param.kind == inspect.Parameter.VAR_KEYWORD - for param in sig.parameters.values() - ): - if not all_uri_params.issubset(func_params): - raise ValueError( - f"URI parameters {all_uri_params} must be a subset of the function arguments: {func_params}" - ) - - description = description or inspect.getdoc(fn) - - # Normalize task to TaskConfig and validate - if task is None: - task_config = TaskConfig(mode="forbidden") - elif isinstance(task, bool): - task_config = TaskConfig.from_bool(task) - else: - task_config = task - task_config.validate_function(fn, func_name) - - # if the fn is a callable class, we need to get the __call__ method from here out - if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): - fn = fn.__call__ - # if the fn is a staticmethod, we need to work with the underlying function - if isinstance(fn, staticmethod): - fn = fn.__func__ - - # Transform Context type annotations to Depends() for unified DI - fn = transform_context_annotations(fn) - - wrapper_fn = without_injected_parameters(fn) - type_adapter = get_cached_typeadapter(wrapper_fn) - parameters = type_adapter.json_schema() - parameters = compress_schema(parameters, prune_titles=True) - - # Use validate_call on wrapper for runtime type coercion - fn = validate_call(wrapper_fn) - - # Apply ui:// MIME default, then fall back to text/plain - resolved_mime = resolve_ui_mime_type(uri_template, mime_type) - - return cls( - uri_template=uri_template, - name=func_name, - version=str(version) if version is not None else None, - title=title, - description=description, - icons=icons, - mime_type=resolved_mime or "text/plain", - fn=fn, - parameters=parameters, - tags=tags or set(), - annotations=annotations, - meta=meta, - task_config=task_config, - auth=auth, - ) diff --git a/src/fastmcp/resources/types.py b/src/fastmcp/resources/types.py deleted file mode 100644 index ed514c5ce..000000000 --- a/src/fastmcp/resources/types.py +++ /dev/null @@ -1,188 +0,0 @@ -"""Concrete resource implementations.""" - -from __future__ import annotations - -import json -from pathlib import Path - -import httpx -import pydantic.json -from anyio import Path as AsyncPath -from pydantic import Field, ValidationInfo -from typing_extensions import override - -from fastmcp.exceptions import ResourceError -from fastmcp.resources.base import Resource, ResourceContent, ResourceResult -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class TextResource(Resource): - """A resource that reads from a string.""" - - text: str = Field(description="Text content of the resource") - - async def read(self) -> ResourceResult: - """Read the text content.""" - return ResourceResult( - contents=[ - ResourceContent( - content=self.text, mime_type=self.mime_type, meta=self.meta - ) - ] - ) - - -class BinaryResource(Resource): - """A resource that reads from bytes.""" - - data: bytes = Field(description="Binary content of the resource") - - async def read(self) -> ResourceResult: - """Read the binary content.""" - return ResourceResult( - contents=[ - ResourceContent( - content=self.data, mime_type=self.mime_type, meta=self.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", - ) - encoding: str | None = Field( - default="utf-8", - description=( - "Encoding to use when reading text files. " - "Defaults to 'utf-8' for cross-platform compatibility. " - "Set to None to use the system default encoding." - ), - ) - - @property - def _async_path(self) -> AsyncPath: - return AsyncPath(self.path) - - @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/") - - @override - async def read(self) -> ResourceResult: - """Read the file content.""" - try: - if self.is_binary: - content: str | bytes = await self._async_path.read_bytes() - else: - content = await self._async_path.read_text(encoding=self.encoding) - return ResourceResult( - contents=[ResourceContent(content=content, mime_type=self.mime_type)] - ) - except Exception as e: - raise ResourceError(f"Error reading file {self.path}") from 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" - ) - - @override - async def read(self) -> ResourceResult: - """Read the HTTP content.""" - async with httpx.AsyncClient() as client: - response = await client.get(self.url) - _ = response.raise_for_status() - return ResourceResult( - contents=[ - ResourceContent(content=response.text, mime_type=self.mime_type) - ] - ) - - -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" - ) - - @property - def _async_path(self) -> AsyncPath: - return AsyncPath(self.path) - - @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 - - async def list_files(self) -> list[Path]: - """List files in the directory.""" - if not await self._async_path.exists(): - raise FileNotFoundError(f"Directory not found: {self.path}") - if not await self._async_path.is_dir(): - raise NotADirectoryError(f"Not a directory: {self.path}") - - pattern = self.pattern or "*" - - glob_fn = self._async_path.rglob if self.recursive else self._async_path.glob - try: - return [Path(p) async for p in glob_fn(pattern) if await p.is_file()] - except Exception as e: - raise ResourceError(f"Error listing directory {self.path}") from e - - @override - async def read(self) -> ResourceResult: - """Read the directory listing.""" - try: - files: list[Path] = await self.list_files() - - file_list = [str(f.relative_to(self.path)) for f in files] - - content = json.dumps({"files": file_list}, indent=2) - return ResourceResult( - contents=[ResourceContent(content=content, mime_type=self.mime_type)] - ) - except Exception as e: - raise ResourceError(f"Error reading directory {self.path}") from e diff --git a/src/fastmcp/server/__init__.py b/src/fastmcp/server/__init__.py deleted file mode 100644 index 3f64a0f39..000000000 --- a/src/fastmcp/server/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -import importlib - -from .context import Context -from .server import FastMCP, create_proxy - - -def __getattr__(name: str) -> object: - if name == "dependencies": - return importlib.import_module("fastmcp.server.dependencies") - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - -__all__ = ["Context", "FastMCP", "create_proxy"] diff --git a/src/fastmcp/server/app.py b/src/fastmcp/server/app.py deleted file mode 100644 index ad15d59b1..000000000 --- a/src/fastmcp/server/app.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Backward-compatible re-exports from fastmcp.apps.app. - -.. deprecated:: 3.2.0 - Import from ``fastmcp.apps.app`` or ``fastmcp`` instead. -""" - -import warnings - -from fastmcp.apps.app import FastMCPApp as FastMCPApp -from fastmcp.apps.app import _dispatch_decorator as _dispatch_decorator -from fastmcp.apps.app import _make_resolver as _make_resolver -from fastmcp.exceptions import FastMCPDeprecationWarning - -warnings.warn( - "'fastmcp.server.app' is deprecated. " - "Use 'fastmcp.apps.app' or 'from fastmcp import FastMCPApp' instead.", - FastMCPDeprecationWarning, - stacklevel=2, -) diff --git a/src/fastmcp/server/apps.py b/src/fastmcp/server/apps.py deleted file mode 100644 index 57e95ebf7..000000000 --- a/src/fastmcp/server/apps.py +++ /dev/null @@ -1,22 +0,0 @@ -"""Backward-compatible re-exports from fastmcp.apps. - -.. deprecated:: 3.2.0 - Import from ``fastmcp.apps`` instead. -""" - -import warnings - -from fastmcp.apps.config import UI_EXTENSION_ID as UI_EXTENSION_ID -from fastmcp.apps.config import AppConfig as AppConfig -from fastmcp.apps.config import ResourceCSP as ResourceCSP -from fastmcp.apps.config import ResourcePermissions as ResourcePermissions -from fastmcp.apps.config import app_config_to_meta_dict as app_config_to_meta_dict -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.utilities.mime import UI_MIME_TYPE as UI_MIME_TYPE -from fastmcp.utilities.mime import resolve_ui_mime_type as resolve_ui_mime_type - -warnings.warn( - "'fastmcp.server.apps' is deprecated. Use 'from fastmcp.apps import ...' instead.", - FastMCPDeprecationWarning, - stacklevel=2, -) diff --git a/src/fastmcp/server/auth/__init__.py b/src/fastmcp/server/auth/__init__.py deleted file mode 100644 index cd6a300ad..000000000 --- a/src/fastmcp/server/auth/__init__.py +++ /dev/null @@ -1,75 +0,0 @@ -from typing import TYPE_CHECKING - -from .auth import ( - OAuthProvider, - TokenVerifier, - RemoteAuthProvider, - MultiAuth, - AccessToken, - AuthProvider, -) -from .authorization import ( - AuthCheck, - AuthContext, - require_scopes, - restrict_tag, - run_auth_checks, -) - -if TYPE_CHECKING: - from .oauth_proxy import OAuthProxy as OAuthProxy - from .oidc_proxy import OIDCProxy as OIDCProxy - from .providers.debug import DebugTokenVerifier as DebugTokenVerifier - from .providers.jwt import JWTVerifier as JWTVerifier - from .providers.jwt import StaticTokenVerifier as StaticTokenVerifier - - -# --- Lazy imports for performance (see #3292) --- -# These providers pull in heavy deps (authlib, cryptography, key_value.aio, -# beartype) that most users never need. Keeping them behind __getattr__ -# avoids ~150ms+ of import overhead for the common server-only case. -# Do not convert these back to top-level imports. - - -def __getattr__(name: str) -> object: - if name == "DebugTokenVerifier": - from .providers.debug import DebugTokenVerifier - - return DebugTokenVerifier - if name == "JWTVerifier": - from .providers.jwt import JWTVerifier - - return JWTVerifier - if name == "StaticTokenVerifier": - from .providers.jwt import StaticTokenVerifier - - return StaticTokenVerifier - if name == "OAuthProxy": - from .oauth_proxy import OAuthProxy - - return OAuthProxy - if name == "OIDCProxy": - from .oidc_proxy import OIDCProxy - - return OIDCProxy - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") - - -__all__ = [ - "AccessToken", - "AuthCheck", - "AuthContext", - "AuthProvider", - "DebugTokenVerifier", - "JWTVerifier", - "MultiAuth", - "OAuthProvider", - "OAuthProxy", - "OIDCProxy", - "RemoteAuthProvider", - "StaticTokenVerifier", - "TokenVerifier", - "require_scopes", - "restrict_tag", - "run_auth_checks", -] diff --git a/src/fastmcp/server/auth/auth.py b/src/fastmcp/server/auth/auth.py deleted file mode 100644 index 3903d513f..000000000 --- a/src/fastmcp/server/auth/auth.py +++ /dev/null @@ -1,785 +0,0 @@ -from __future__ import annotations - -import json -from typing import TYPE_CHECKING, Any, cast -from urllib.parse import urlparse - -from mcp.server.auth.handlers.token import TokenErrorResponse -from mcp.server.auth.handlers.token import TokenHandler as _SDKTokenHandler -from mcp.server.auth.json_response import PydanticJSONResponse -from mcp.server.auth.middleware.auth_context import AuthContextMiddleware -from mcp.server.auth.middleware.bearer_auth import BearerAuthBackend -from mcp.server.auth.middleware.client_auth import ( - AuthenticationError, - ClientAuthenticator, -) -from mcp.server.auth.middleware.client_auth import ( - ClientAuthenticator as _SDKClientAuthenticator, -) -from mcp.server.auth.provider import ( - AccessToken as _SDKAccessToken, -) -from mcp.server.auth.provider import ( - AuthorizationCode, - OAuthAuthorizationServerProvider, - RefreshToken, -) -from mcp.server.auth.provider import ( - TokenVerifier as TokenVerifierProtocol, -) -from mcp.server.auth.routes import ( - cors_middleware, - create_auth_routes, - create_protected_resource_routes, -) -from mcp.server.auth.settings import ( - ClientRegistrationOptions, - RevocationOptions, -) -from mcp.shared.auth import OAuthClientInformationFull -from pydantic import AnyHttpUrl, Field -from starlette.middleware import Middleware -from starlette.middleware.authentication import AuthenticationMiddleware -from starlette.requests import Request -from starlette.routing import Route - -from fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - from fastmcp.server.auth.cimd import CIMDClientManager - -logger = get_logger(__name__) - - -class AccessToken(_SDKAccessToken): - """AccessToken that includes all JWT claims.""" - - claims: dict[str, Any] = Field(default_factory=dict) - - -class TokenHandler(_SDKTokenHandler): - """TokenHandler that returns MCP-compliant error responses. - - This handler addresses two SDK issues: - - 1. Error code: The SDK returns `unauthorized_client` for client authentication - failures, but RFC 6749 Section 5.2 requires `invalid_client` with HTTP 401. - This distinction matters for client re-registration behavior. - - 2. Status code: The SDK returns HTTP 400 for all token errors including - `invalid_grant` (expired/invalid tokens). However, the MCP spec requires: - "Invalid or expired tokens MUST receive a HTTP 401 response." - - This handler transforms responses to be compliant with both OAuth 2.1 and MCP specs. - """ - - async def handle(self, request: Any): - """Wrap SDK handle() and transform auth error responses.""" - response = await super().handle(request) - - # Transform 401 unauthorized_client -> invalid_client - if response.status_code == 401: - try: - body = json.loads(response.body) - if body.get("error") == "unauthorized_client": - return PydanticJSONResponse( - content=TokenErrorResponse( - error="invalid_client", - error_description=body.get("error_description"), - ), - status_code=401, - headers={ - "Cache-Control": "no-store", - "Pragma": "no-cache", - }, - ) - except (json.JSONDecodeError, AttributeError): - pass # Not JSON or unexpected format, return as-is - - # Transform 400 invalid_grant -> 401 for expired/invalid tokens - # Per MCP spec: "Invalid or expired tokens MUST receive a HTTP 401 response." - if response.status_code == 400: - try: - body = json.loads(response.body) - if body.get("error") == "invalid_grant": - return PydanticJSONResponse( - content=TokenErrorResponse( - error="invalid_grant", - error_description=body.get("error_description"), - ), - status_code=401, - headers={ - "Cache-Control": "no-store", - "Pragma": "no-cache", - }, - ) - except (json.JSONDecodeError, AttributeError): - pass # Not JSON or unexpected format, return as-is - - return response - - -# Expected assertion type for private_key_jwt -JWT_BEARER_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" - - -class PrivateKeyJWTClientAuthenticator(_SDKClientAuthenticator): - """Client authenticator with private_key_jwt support for CIMD clients. - - Extends the SDK's ClientAuthenticator to add support for the `private_key_jwt` - authentication method per RFC 7523. This is required for CIMD (Client ID Metadata - Document) clients that use asymmetric keys for authentication. - - The authenticator: - 1. Delegates to SDK for standard methods (client_secret_basic, client_secret_post, none) - 2. Adds private_key_jwt handling for CIMD clients - 3. Validates JWT assertions against client's JWKS - """ - - def __init__( - self, - provider: OAuthAuthorizationServerProvider[Any, Any, Any], - cimd_manager: CIMDClientManager, - token_endpoint_url: str, - ): - """Initialize the authenticator. - - Args: - provider: OAuth provider for client lookups - cimd_manager: CIMD manager for private_key_jwt validation - token_endpoint_url: Token endpoint URL for audience validation - """ - super().__init__(provider) - self._cimd_manager = cimd_manager - self._token_endpoint_url = token_endpoint_url - - async def authenticate_request( - self, request: Request - ) -> OAuthClientInformationFull: - """Authenticate a client from an HTTP request. - - Extends SDK authentication to support private_key_jwt for CIMD clients. - Delegates to SDK for client_secret_basic (Authorization header) and - client_secret_post (form body) authentication. - """ - form_data = await request.form() - client_id = form_data.get("client_id") - - # If client_id is not in form data, delegate to SDK - # This handles client_secret_basic which sends credentials in Authorization header - if not client_id: - return await super().authenticate_request(request) - - client = await self.provider.get_client(str(client_id)) - if not client: - raise AuthenticationError("Invalid client_id") - - # Handle private_key_jwt authentication for CIMD clients - if client.token_endpoint_auth_method == "private_key_jwt": - # Validate assertion parameters - assertion_type = form_data.get("client_assertion_type") - assertion = form_data.get("client_assertion") - - if assertion_type != JWT_BEARER_ASSERTION_TYPE: - raise AuthenticationError( - f"Invalid client_assertion_type: expected {JWT_BEARER_ASSERTION_TYPE}" - ) - - if not assertion or not isinstance(assertion, str): - raise AuthenticationError("Missing client_assertion") - - # Validate the JWT assertion using CIMD manager - try: - await self._cimd_manager.validate_private_key_jwt( - assertion=assertion, - client=client, - token_endpoint=self._token_endpoint_url, - ) - except ValueError as e: - raise AuthenticationError(f"Invalid client assertion: {e}") from e - - return client - - # Delegate to SDK for other authentication methods - return await super().authenticate_request(request) - - -class AuthProvider(TokenVerifierProtocol): - """Base class for all FastMCP authentication providers. - - This class provides a unified interface for all authentication providers, - whether they are simple token verifiers or full OAuth authorization servers. - All providers must be able to verify tokens and can optionally provide - custom authentication routes. - """ - - def __init__( - self, - base_url: AnyHttpUrl | str | None = None, - required_scopes: list[str] | None = None, - ): - """ - Initialize the auth provider. - - Args: - base_url: The base URL of this server (e.g., http://localhost:8000). - This is used for constructing .well-known endpoints and OAuth metadata. - required_scopes: List of OAuth scopes required for all requests. - """ - if isinstance(base_url, str): - base_url = AnyHttpUrl(base_url) - self.base_url = base_url - self.required_scopes = required_scopes or [] - self._mcp_path: str | None = None - self._resource_url: AnyHttpUrl | None = None - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify a bearer token and return access info if valid. - - All auth providers must implement token verification. - - Args: - token: The token string to validate - - Returns: - AccessToken object if valid, None if invalid or expired - """ - raise NotImplementedError("Subclasses must implement verify_token") - - def set_mcp_path(self, mcp_path: str | None) -> None: - """Set the MCP endpoint path and compute resource URL. - - This method is called by get_routes() to configure the expected - resource URL before route creation. Subclasses can override to - perform additional initialization that depends on knowing the - MCP endpoint path. - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - """ - self._mcp_path = mcp_path - self._resource_url = self._get_resource_url(mcp_path) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get all routes for this authentication provider. - - This includes both well-known discovery routes and operational routes. - Each provider is responsible for creating whatever routes it needs: - - TokenVerifier: typically no routes (default implementation) - - RemoteAuthProvider: protected resource metadata routes - - OAuthProvider: full OAuth authorization server routes - - Custom providers: whatever routes they need - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata, but the - provider does not create the actual MCP endpoint route. - - Returns: - List of all routes for this provider (excluding the MCP endpoint itself) - """ - return [] - - def get_well_known_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get well-known discovery routes for this authentication provider. - - This is a utility method that filters get_routes() to return only - well-known discovery routes (those starting with /.well-known/). - - Well-known routes provide OAuth metadata and discovery endpoints that - clients use to discover authentication capabilities. These routes should - be mounted at the root level of the application to comply with RFC 8414 - and RFC 9728. - - Common well-known routes: - - /.well-known/oauth-authorization-server (authorization server metadata) - - /.well-known/oauth-protected-resource/* (protected resource metadata) - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to construct path-scoped well-known URLs. - - Returns: - List of well-known discovery routes (typically mounted at root level) - """ - all_routes = self.get_routes(mcp_path) - return [ - route - for route in all_routes - if isinstance(route, Route) and route.path.startswith("/.well-known/") - ] - - def get_middleware(self) -> list: - """Get HTTP application-level middleware for this auth provider. - - Returns: - List of Starlette Middleware instances to apply to the HTTP app - """ - return [ - Middleware( - AuthenticationMiddleware, # type: ignore[arg-type] - backend=BearerAuthBackend(self), - ), - Middleware(AuthContextMiddleware), # type: ignore[arg-type] - ] - - def _get_resource_url(self, path: str | None = None) -> AnyHttpUrl | None: - """Get the actual resource URL being protected. - - Args: - path: The path where the resource endpoint is mounted (e.g., "/mcp") - - Returns: - The full URL of the protected resource - """ - if self.base_url is None: - return None - - if path: - prefix = str(self.base_url).rstrip("/") - suffix = path.lstrip("/") - return AnyHttpUrl(f"{prefix}/{suffix}") - return self.base_url - - -class TokenVerifier(AuthProvider): - """Base class for token verifiers (Resource Servers). - - This class provides token verification capability without OAuth server functionality. - Token verifiers typically don't provide authentication routes by default. - """ - - def __init__( - self, - base_url: AnyHttpUrl | str | None = None, - required_scopes: list[str] | None = None, - ): - """ - Initialize the token verifier. - - Args: - base_url: The base URL of this server - required_scopes: Scopes that are required for all requests - """ - super().__init__(base_url=base_url, required_scopes=required_scopes) - - @property - def scopes_supported(self) -> list[str]: - """Scopes to advertise in OAuth metadata. - - Defaults to required_scopes. Override in subclasses when the - advertised scopes differ from the validation scopes (e.g., Azure AD - where tokens contain short-form scopes but clients request full URI - scopes). - """ - return self.required_scopes or [] - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify a bearer token and return access info if valid.""" - raise NotImplementedError("Subclasses must implement verify_token") - - -class RemoteAuthProvider(AuthProvider): - """Authentication provider for resource servers that verify tokens from known authorization servers. - - This provider composes a TokenVerifier with authorization server metadata to create - standardized OAuth 2.0 Protected Resource endpoints (RFC 9728). Perfect for: - - JWT verification with known issuers - - Remote token introspection services - - Any resource server that knows where its tokens come from - - Use this when you have token verification logic and want to advertise - the authorization servers that issue valid tokens. - """ - - base_url: AnyHttpUrl - - def __init__( - self, - token_verifier: TokenVerifier, - authorization_servers: list[AnyHttpUrl], - base_url: AnyHttpUrl | str, - scopes_supported: list[str] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, - ): - """Initialize the remote auth provider. - - Args: - token_verifier: TokenVerifier instance for token validation - authorization_servers: List of authorization servers that issue valid tokens - base_url: The base URL of this server - scopes_supported: Scopes to advertise in OAuth metadata. If None, - uses the token verifier's scopes_supported property. Use this - when the scopes clients request differ from the scopes that - appear in tokens (e.g., Azure AD full URI scopes vs short-form). - resource_name: Optional name for the protected resource - resource_documentation: Optional documentation URL for the protected resource - """ - super().__init__( - base_url=base_url, - required_scopes=token_verifier.required_scopes, - ) - self.token_verifier = token_verifier - self.authorization_servers = authorization_servers - self._scopes_supported = scopes_supported - self.resource_name = resource_name - self.resource_documentation = resource_documentation - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token using the configured token verifier.""" - return await self.token_verifier.verify_token(token) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get routes for this provider. - - Creates protected resource metadata routes (RFC 9728). - """ - routes = [] - - # Get the resource URL based on the MCP path - resource_url = self._get_resource_url(mcp_path) - - if resource_url: - # Add protected resource metadata routes - routes.extend( - create_protected_resource_routes( - resource_url=resource_url, - authorization_servers=self.authorization_servers, - scopes_supported=( - self._scopes_supported - if self._scopes_supported is not None - else self.token_verifier.scopes_supported - ), - resource_name=self.resource_name, - resource_documentation=self.resource_documentation, - ) - ) - - return routes - - -class MultiAuth(AuthProvider): - """Composes an optional auth server with additional token verifiers. - - Use this when a single server needs to accept tokens from multiple sources. - For example, an OAuth proxy for interactive clients combined with a JWT - verifier for machine-to-machine tokens. - - Token verification tries the server first (if present), then each verifier - in order, returning the first successful result. Routes and OAuth metadata - come from the server; verifiers contribute only token verification. - - Example: - ```python - from fastmcp.server.auth import MultiAuth, JWTVerifier, OAuthProxy - - auth = MultiAuth( - server=OAuthProxy(issuer_url="https://login.example.com/..."), - verifiers=[JWTVerifier(jwks_uri="https://example.com/.well-known/jwks.json")], - ) - mcp = FastMCP("my-server", auth=auth) - ``` - """ - - def __init__( - self, - *, - server: AuthProvider | None = None, - verifiers: list[TokenVerifier] | TokenVerifier | None = None, - base_url: AnyHttpUrl | str | None = None, - required_scopes: list[str] | None = None, - ): - """Initialize the multi-auth provider. - - Args: - server: Optional auth provider (e.g., OAuthProxy) that owns routes - and OAuth metadata. Also participates in token verification as - the first verifier tried. - verifiers: One or more token verifiers to try after the server. - base_url: Override the base URL. Defaults to the server's base_url. - required_scopes: Override required scopes. Defaults to the server's. - """ - if verifiers is None: - verifiers = [] - elif isinstance(verifiers, TokenVerifier): - verifiers = [verifiers] - - if server is None and not verifiers: - raise ValueError("MultiAuth requires at least a server or one verifier") - - effective_base_url = base_url or (server.base_url if server else None) - effective_scopes = ( - required_scopes - if required_scopes is not None - else (server.required_scopes if server else None) - ) - - super().__init__(base_url=effective_base_url, required_scopes=effective_scopes) - self.server = server - self.verifiers = list(verifiers) - - self._sources: list[AuthProvider] = [] - if self.server is not None: - self._sources.append(self.server) - self._sources.extend(self.verifiers) - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify a token by trying the server, then each verifier in order. - - Each source is tried independently. If a source raises an exception, - it is logged and treated as a non-match so that remaining sources - still get a chance to verify the token. - """ - for source in self._sources: - try: - result = await source.verify_token(token) - if result is not None: - return result - except Exception: - logger.debug( - "Token verification failed for %s, trying next source", - type(source).__name__, - exc_info=True, - ) - - return None - - def set_mcp_path(self, mcp_path: str | None) -> None: - """Propagate MCP path to the server and all verifiers.""" - super().set_mcp_path(mcp_path) - if self.server is not None: - self.server.set_mcp_path(mcp_path) - for verifier in self.verifiers: - verifier.set_mcp_path(mcp_path) - - def get_routes(self, mcp_path: str | None = None) -> list[Route]: - """Delegate route creation to the server.""" - if self.server is not None: - return self.server.get_routes(mcp_path) - return [] - - def get_well_known_routes(self, mcp_path: str | None = None) -> list[Route]: - """Delegate well-known route creation to the server. - - This ensures that server-specific well-known route logic (e.g., - OAuthProvider's RFC 8414 path-aware discovery) is preserved. - """ - if self.server is not None: - return self.server.get_well_known_routes(mcp_path) - return [] - - -class OAuthProvider( - AuthProvider, - OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken], -): - """OAuth Authorization Server provider. - - This class provides full OAuth server functionality including client registration, - authorization flows, token issuance, and token verification. - """ - - def __init__( - self, - *, - base_url: AnyHttpUrl | str, - issuer_url: AnyHttpUrl | str | None = None, - service_documentation_url: AnyHttpUrl | str | None = None, - client_registration_options: ClientRegistrationOptions | None = None, - revocation_options: RevocationOptions | None = None, - required_scopes: list[str] | None = None, - ): - """ - Initialize the OAuth provider. - - Args: - base_url: The public URL of this FastMCP server - issuer_url: The issuer URL for OAuth metadata (defaults to base_url) - service_documentation_url: The URL of the service documentation. - client_registration_options: The client registration options. - revocation_options: The revocation options. - required_scopes: Scopes that are required for all requests. - """ - - super().__init__(base_url=base_url, required_scopes=required_scopes) - - if issuer_url is None: - self.issuer_url = self.base_url - elif isinstance(issuer_url, str): - self.issuer_url = AnyHttpUrl(issuer_url) - else: - self.issuer_url = issuer_url - - # Log if issuer_url and base_url differ (requires additional setup) - if ( - self.base_url is not None - and self.issuer_url is not None - and str(self.base_url) != str(self.issuer_url) - ): - logger.info( - f"OAuth endpoints at {self.base_url}, issuer at {self.issuer_url}. " - f"Ensure well-known routes are accessible at root ({self.issuer_url}/.well-known/). " - f"See: https://gofastmcp.com/deployment/http#mounting-authenticated-servers" - ) - - # Initialize OAuth Authorization Server Provider - OAuthAuthorizationServerProvider.__init__(self) - - if isinstance(service_documentation_url, str): - service_documentation_url = AnyHttpUrl(service_documentation_url) - - self.service_documentation_url = service_documentation_url - self.client_registration_options = client_registration_options - self.revocation_options = revocation_options - - async def verify_token(self, token: str) -> AccessToken | None: - """ - Verify a bearer token and return access info if valid. - - This method implements the TokenVerifier protocol by delegating - to our existing load_access_token method. - - Args: - token: The token string to validate - - Returns: - AccessToken object if valid, None if invalid or expired - """ - return await self.load_access_token(token) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get OAuth authorization server routes and optional protected resource routes. - - This method creates the full set of OAuth routes including: - - Standard OAuth authorization server routes (/.well-known/oauth-authorization-server, /authorize, /token, etc.) - - Optional protected resource routes - - Returns: - List of OAuth routes - """ - # Configure resource URL before creating routes - self.set_mcp_path(mcp_path) - - # Create standard OAuth authorization server routes - # Pass base_url as issuer_url to ensure metadata declares endpoints where - # they're actually accessible (operational routes are mounted at - # base_url) - assert self.base_url is not None # typing check - assert ( - self.issuer_url is not None - ) # typing check (issuer_url defaults to base_url) - - sdk_routes = create_auth_routes( - provider=self, - issuer_url=self.base_url, - service_documentation_url=self.service_documentation_url, - client_registration_options=self.client_registration_options, - revocation_options=self.revocation_options, - ) - - # Replace the token endpoint with our custom handler that returns - # proper OAuth 2.1 error codes (invalid_client instead of unauthorized_client) - oauth_routes: list[Route] = [] - for route in sdk_routes: - if ( - isinstance(route, Route) - and route.path == "/token" - and route.methods is not None - and "POST" in route.methods - ): - # Replace with our OAuth 2.1 compliant token handler - token_handler = TokenHandler( - provider=self, client_authenticator=ClientAuthenticator(self) - ) - oauth_routes.append( - Route( - path="/token", - endpoint=cors_middleware( - token_handler.handle, ["POST", "OPTIONS"] - ), - methods=["POST", "OPTIONS"], - ) - ) - else: - oauth_routes.append(route) - - # Add protected resource routes if this server is also acting as a resource server - if self._resource_url: - supported_scopes = ( - self.client_registration_options.valid_scopes - if self.client_registration_options - and self.client_registration_options.valid_scopes - else self.required_scopes - ) - protected_routes = create_protected_resource_routes( - resource_url=self._resource_url, - authorization_servers=[cast(AnyHttpUrl, self.issuer_url)], - scopes_supported=supported_scopes, - ) - oauth_routes.extend(protected_routes) - - # Add base routes - oauth_routes.extend(super().get_routes(mcp_path)) - - return oauth_routes - - def get_well_known_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get well-known discovery routes with RFC 8414 path-aware support. - - Overrides the base implementation to support path-aware authorization - server metadata discovery per RFC 8414. If issuer_url has a path component, - the authorization server metadata route is adjusted to include that path. - - For example, if issuer_url is "http://example.com/api", the discovery - endpoint will be at "/.well-known/oauth-authorization-server/api" instead - of just "/.well-known/oauth-authorization-server". - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - - Returns: - List of well-known discovery routes - """ - routes = super().get_well_known_routes(mcp_path) - - # RFC 8414: If issuer_url has a path, use path-aware discovery - if self.issuer_url: - parsed = urlparse(str(self.issuer_url)) - issuer_path = parsed.path.rstrip("/") - - if issuer_path and issuer_path != "/": - # Replace /.well-known/oauth-authorization-server with path-aware version - new_routes = [] - for route in routes: - if route.path == "/.well-known/oauth-authorization-server": - new_path = ( - f"/.well-known/oauth-authorization-server{issuer_path}" - ) - new_routes.append( - Route( - new_path, - endpoint=route.endpoint, - methods=route.methods, - ) - ) - else: - new_routes.append(route) - return new_routes - - return routes diff --git a/src/fastmcp/server/auth/authorization.py b/src/fastmcp/server/auth/authorization.py deleted file mode 100644 index 64eb5f721..000000000 --- a/src/fastmcp/server/auth/authorization.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Authorization checks for FastMCP components. - -This module provides callable-based authorization for tools, resources, and prompts. -Auth checks are functions that receive an AuthContext and return True to allow access -or False to deny. - -Auth checks can also raise exceptions: -- AuthorizationError: Propagates with the custom message for explicit denial -- Other exceptions: Masked for security (logged, treated as auth failure) - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth import require_scopes - - mcp = FastMCP() - - @mcp.tool(auth=require_scopes("write")) - def protected_tool(): ... - - @mcp.resource("data://secret", auth=require_scopes("read")) - def secret_data(): ... - - @mcp.prompt(auth=require_scopes("admin")) - def admin_prompt(): ... - ``` -""" - -from __future__ import annotations - -import inspect -import logging -from collections.abc import Awaitable, Callable -from dataclasses import dataclass -from typing import TYPE_CHECKING, cast - -from fastmcp.exceptions import AuthorizationError - -logger = logging.getLogger(__name__) - -if TYPE_CHECKING: - from fastmcp.server.auth import AccessToken - from fastmcp.tools.base import Tool - from fastmcp.utilities.components import FastMCPComponent - - -@dataclass -class AuthContext: - """Context passed to auth check callables. - - This object is passed to each auth check function and provides - access to the current authentication token and the component being accessed. - - Attributes: - token: The current access token, or None if unauthenticated. - component: The component (tool, resource, or prompt) being accessed. - tool: Backwards-compatible alias for component when it's a Tool. - """ - - token: AccessToken | None - component: FastMCPComponent - - @property - def tool(self) -> Tool | None: - """Backwards-compatible access to the component as a Tool. - - Returns the component if it's a Tool, None otherwise. - """ - from fastmcp.tools.base import Tool - - return self.component if isinstance(self.component, Tool) else None - - -# Type alias for auth check functions (sync or async) -AuthCheck = Callable[[AuthContext], bool] | Callable[[AuthContext], Awaitable[bool]] - - -def require_scopes(*scopes: str) -> AuthCheck: - """Require specific OAuth scopes. - - Returns an auth check that requires ALL specified scopes to be present - in the token (AND logic). - - Args: - *scopes: One or more scope strings that must all be present. - - Example: - ```python - @mcp.tool(auth=require_scopes("admin")) - def admin_tool(): ... - - @mcp.tool(auth=require_scopes("read", "write")) - def read_write_tool(): ... - ``` - """ - required = set(scopes) - - def check(ctx: AuthContext) -> bool: - if ctx.token is None: - return False - return required.issubset(set(ctx.token.scopes)) - - return check - - -def restrict_tag(tag: str, *, scopes: list[str]) -> AuthCheck: - """Restrict components with a specific tag to require certain scopes. - - If the component has the specified tag, the token must have ALL the - required scopes. If the component doesn't have the tag, access is allowed. - - Args: - tag: The tag that triggers the scope requirement. - scopes: List of scopes required when the tag is present. - - Example: - ```python - # Components tagged "admin" require the "admin" scope - AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) - ``` - """ - required = set(scopes) - - def check(ctx: AuthContext) -> bool: - if tag not in ctx.component.tags: - return True # Tag not present, no restriction - if ctx.token is None: - return False - return required.issubset(set(ctx.token.scopes)) - - return check - - -async def run_auth_checks( - checks: AuthCheck | list[AuthCheck], - ctx: AuthContext, -) -> bool: - """Run auth checks with AND logic. - - All checks must pass for authorization to succeed. Checks can be - synchronous or asynchronous functions. - - Auth checks can: - - Return True to allow access - - Return False to deny access - - Raise AuthorizationError to deny with a custom message (propagates) - - Raise other exceptions (masked for security, treated as denial) - - Args: - checks: A single check function or list of check functions. - Each check can be sync (returns bool) or async (returns Awaitable[bool]). - ctx: The auth context to pass to each check. - - Returns: - True if all checks pass, False if any check fails. - - Raises: - AuthorizationError: If an auth check explicitly raises it. - """ - check_list = [checks] if not isinstance(checks, list) else checks - check_list = cast(list[AuthCheck], check_list) - - for check in check_list: - try: - result = check(ctx) - if inspect.isawaitable(result): - result = await result - if not result: - return False - except AuthorizationError: - # Let AuthorizationError propagate with its custom message - raise - except Exception: - # Mask other exceptions for security - log and treat as auth failure - logger.warning( - f"Auth check {getattr(check, '__name__', repr(check))} " - "raised an unexpected exception", - exc_info=True, - ) - return False - - return True diff --git a/src/fastmcp/server/auth/cimd.py b/src/fastmcp/server/auth/cimd.py deleted file mode 100644 index 714de99e8..000000000 --- a/src/fastmcp/server/auth/cimd.py +++ /dev/null @@ -1,797 +0,0 @@ -"""CIMD (Client ID Metadata Document) support for FastMCP. - -.. warning:: - **Beta Feature**: CIMD support is currently in beta. The API may change - in future releases. Please report any issues you encounter. - -CIMD is a simpler alternative to Dynamic Client Registration where clients -host a static JSON document at an HTTPS URL, and that URL becomes their -client_id. See the IETF draft: draft-parecki-oauth-client-id-metadata-document - -This module provides: -- CIMDDocument: Pydantic model for CIMD document validation -- CIMDFetcher: Fetch and validate CIMD documents with SSRF protection -- CIMDClientManager: Manages CIMD client operations -""" - -from __future__ import annotations - -import json -import time -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import timezone -from email.utils import parsedate_to_datetime -from typing import TYPE_CHECKING, Any, Literal -from urllib.parse import urlparse - -from pydantic import AnyHttpUrl, BaseModel, Field, field_validator - -from fastmcp.server.auth.redirect_validation import matches_allowed_pattern -from fastmcp.server.auth.ssrf import ( - SSRFError, - SSRFFetchError, - ssrf_safe_fetch_response, - validate_url, -) -from fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - from fastmcp.server.auth.providers.jwt import JWTVerifier - -logger = get_logger(__name__) - - -class CIMDDocument(BaseModel): - """CIMD document per draft-parecki-oauth-client-id-metadata-document. - - The client metadata document is a JSON document containing OAuth client - metadata. The client_id property MUST match the URL where this document - is hosted. - - Key constraint: token_endpoint_auth_method MUST NOT use shared secrets - (client_secret_post, client_secret_basic, client_secret_jwt). - - redirect_uris is required and must contain at least one entry. - """ - - client_id: AnyHttpUrl = Field( - ..., - description="Must match the URL where this document is hosted", - ) - client_name: str | None = Field( - default=None, - description="Human-readable name of the client", - ) - client_uri: AnyHttpUrl | None = Field( - default=None, - description="URL of the client's home page", - ) - logo_uri: AnyHttpUrl | None = Field( - default=None, - description="URL of the client's logo image", - ) - redirect_uris: list[str] = Field( - ..., - description="Array of allowed redirect URIs (may include wildcards like http://localhost:*/callback)", - ) - token_endpoint_auth_method: Literal["none", "private_key_jwt"] = Field( - default="none", - description="Authentication method for token endpoint (no shared secrets allowed)", - ) - grant_types: list[str] = Field( - default_factory=lambda: ["authorization_code"], - description="OAuth grant types the client will use", - ) - response_types: list[str] = Field( - default_factory=lambda: ["code"], - description="OAuth response types the client will use", - ) - scope: str | None = Field( - default=None, - description="Space-separated list of scopes the client may request", - ) - contacts: list[str] | None = Field( - default=None, - description="Contact information for the client developer", - ) - tos_uri: AnyHttpUrl | None = Field( - default=None, - description="URL of the client's terms of service", - ) - policy_uri: AnyHttpUrl | None = Field( - default=None, - description="URL of the client's privacy policy", - ) - jwks_uri: AnyHttpUrl | None = Field( - default=None, - description="URL of the client's JSON Web Key Set (for private_key_jwt)", - ) - jwks: dict[str, Any] | None = Field( - default=None, - description="Client's JSON Web Key Set (for private_key_jwt)", - ) - software_id: str | None = Field( - default=None, - description="Unique identifier for the client software", - ) - software_version: str | None = Field( - default=None, - description="Version of the client software", - ) - - @field_validator("token_endpoint_auth_method") - @classmethod - def validate_auth_method(cls, v: str) -> str: - """Ensure no shared-secret auth methods are used.""" - forbidden = {"client_secret_post", "client_secret_basic", "client_secret_jwt"} - if v in forbidden: - raise ValueError( - f"CIMD documents cannot use shared-secret auth methods: {v}. " - "Use 'none' or 'private_key_jwt' instead." - ) - return v - - @field_validator("redirect_uris") - @classmethod - def validate_redirect_uris(cls, v: list[str]) -> list[str]: - """Ensure redirect_uris is non-empty and each entry is a valid URI.""" - if not v: - raise ValueError("CIMD documents must include at least one redirect_uri") - for uri in v: - if not uri or not uri.strip(): - raise ValueError("CIMD redirect_uris must be non-empty strings") - parsed = urlparse(uri) - if not parsed.scheme: - raise ValueError( - f"CIMD redirect_uri must have a scheme (e.g. http:// or https://): {uri!r}" - ) - if not parsed.netloc and not uri.startswith("urn:"): - raise ValueError(f"CIMD redirect_uri must have a host: {uri!r}") - return v - - -class CIMDValidationError(Exception): - """Raised when CIMD document validation fails.""" - - -class CIMDFetchError(Exception): - """Raised when CIMD document fetching fails.""" - - -@dataclass -class _CIMDCacheEntry: - """Cached CIMD document and associated HTTP cache metadata.""" - - doc: CIMDDocument - etag: str | None - last_modified: str | None - expires_at: float - freshness_lifetime: float - must_revalidate: bool - - -@dataclass -class _CIMDCachePolicy: - """Normalized cache directives parsed from HTTP response headers.""" - - etag: str | None - last_modified: str | None - expires_at: float - freshness_lifetime: float - no_store: bool - must_revalidate: bool - - -class CIMDFetcher: - """Fetch and validate CIMD documents with SSRF protection. - - Delegates HTTP fetching to ssrf_safe_fetch_response, which provides DNS - pinning, IP validation, size limits, and timeout enforcement. Documents are - cached using HTTP caching semantics (Cache-Control/ETag/Last-Modified), with - a TTL fallback when response headers do not define caching behavior. - """ - - # Maximum response size (bytes) - MAX_RESPONSE_SIZE = 5120 # 5KB - # Default cache TTL (seconds) - DEFAULT_CACHE_TTL_SECONDS = 3600 - - def __init__( - self, - timeout: float = 10.0, - ): - """Initialize the CIMD fetcher. - - Args: - timeout: HTTP request timeout in seconds (default 10.0) - """ - self.timeout = timeout - self._cache: dict[str, _CIMDCacheEntry] = {} - - def _parse_cache_policy( - self, headers: Mapping[str, str], now: float - ) -> _CIMDCachePolicy: - """Parse HTTP cache headers and derive cache behavior.""" - normalized = {k.lower(): v for k, v in headers.items()} - cache_control = normalized.get("cache-control", "") - directives = { - part.strip().lower() for part in cache_control.split(",") if part.strip() - } - - no_store = "no-store" in directives - must_revalidate = "no-cache" in directives - max_age: int | None = None - - for directive in directives: - if directive.startswith("max-age="): - value = directive.removeprefix("max-age=").strip() - try: - max_age = max(0, int(value)) - except ValueError: - logger.debug( - "Ignoring invalid Cache-Control max-age value: %s", value - ) - break - - expires_at: float | None = None - if max_age is not None: - expires_at = now + max_age - elif "expires" in normalized: - try: - dt = parsedate_to_datetime(normalized["expires"]) - if dt.tzinfo is None: - dt = dt.replace(tzinfo=timezone.utc) - expires_at = dt.timestamp() - except (TypeError, ValueError): - logger.debug( - "Ignoring invalid Expires header on CIMD response: %s", - normalized["expires"], - ) - - if expires_at is None: - expires_at = now + self.DEFAULT_CACHE_TTL_SECONDS - freshness_lifetime = max(0.0, expires_at - now) - - return _CIMDCachePolicy( - etag=normalized.get("etag"), - last_modified=normalized.get("last-modified"), - expires_at=expires_at, - freshness_lifetime=freshness_lifetime, - no_store=no_store, - must_revalidate=must_revalidate, - ) - - def _has_freshness_headers(self, headers: Mapping[str, str]) -> bool: - """Return True when response includes cache freshness directives.""" - normalized = {k.lower() for k in headers} - return "cache-control" in normalized or "expires" in normalized - - def is_cimd_client_id(self, client_id: str) -> bool: - """Check if a client_id looks like a CIMD URL. - - CIMD URLs must be HTTPS with a host and non-root path. - """ - if not client_id: - return False - try: - parsed = urlparse(client_id) - return ( - parsed.scheme == "https" - and bool(parsed.netloc) - and parsed.path not in ("", "/") - ) - except (ValueError, AttributeError): - return False - - async def fetch(self, client_id_url: str) -> CIMDDocument: - """Fetch and validate a CIMD document with SSRF protection. - - Uses ssrf_safe_fetch_response for the HTTP layer, which provides: - - HTTPS only, DNS resolution with IP validation - - DNS pinning (connects to validated IP directly) - - Blocks private/loopback/link-local/multicast IPs - - Response size limit and timeout enforcement - - Redirects disabled - - Args: - client_id_url: The URL to fetch (also the expected client_id) - - Returns: - Validated CIMDDocument - - Raises: - CIMDValidationError: If document is invalid or URL blocked - CIMDFetchError: If document cannot be fetched - """ - cached = self._cache.get(client_id_url) - now = time.time() - request_headers: dict[str, str] | None = None - allowed_status_codes = {200} - - if cached is not None: - if not cached.must_revalidate and now < cached.expires_at: - return cached.doc - - request_headers = {} - if cached.etag: - request_headers["If-None-Match"] = cached.etag - if cached.last_modified: - request_headers["If-Modified-Since"] = cached.last_modified - if request_headers: - allowed_status_codes = {200, 304} - - try: - response = await ssrf_safe_fetch_response( - client_id_url, - require_path=True, - max_size=self.MAX_RESPONSE_SIZE, - timeout=self.timeout, - overall_timeout=30.0, - request_headers=request_headers, - allowed_status_codes=allowed_status_codes, - ) - except SSRFError as e: - raise CIMDValidationError(str(e)) from e - except SSRFFetchError as e: - raise CIMDFetchError(str(e)) from e - - if response.status_code == 304: - if cached is None: - raise CIMDFetchError( - "CIMD server returned 304 Not Modified without cached document" - ) - - now = time.time() - if self._has_freshness_headers(response.headers): - policy = self._parse_cache_policy(response.headers, now) - else: - # RFC allows 304 to omit unchanged headers. Preserve existing - # cache policy rather than resetting to fallback defaults. - policy = _CIMDCachePolicy( - etag=None, - last_modified=None, - expires_at=now + cached.freshness_lifetime, - freshness_lifetime=cached.freshness_lifetime, - no_store=False, - must_revalidate=cached.must_revalidate, - ) - - if not policy.no_store: - self._cache[client_id_url] = _CIMDCacheEntry( - doc=cached.doc, - etag=policy.etag or cached.etag, - last_modified=policy.last_modified or cached.last_modified, - expires_at=policy.expires_at, - freshness_lifetime=policy.freshness_lifetime, - must_revalidate=policy.must_revalidate, - ) - else: - self._cache.pop(client_id_url, None) - return cached.doc - - now = time.time() - policy = self._parse_cache_policy(response.headers, now) - - try: - data = json.loads(response.content) - except json.JSONDecodeError as e: - raise CIMDValidationError(f"CIMD document is not valid JSON: {e}") from e - - try: - doc = CIMDDocument.model_validate(data) - except Exception as e: - raise CIMDValidationError(f"Invalid CIMD document: {e}") from e - - if str(doc.client_id).rstrip("/") != client_id_url.rstrip("/"): - raise CIMDValidationError( - f"CIMD client_id mismatch: document says '{doc.client_id}' " - f"but was fetched from '{client_id_url}'" - ) - - # Validate jwks_uri if present (SSRF check for JWKS endpoint) - if doc.jwks_uri: - jwks_uri_str = str(doc.jwks_uri) - try: - await validate_url(jwks_uri_str) - except SSRFError as e: - raise CIMDValidationError( - f"CIMD jwks_uri failed SSRF validation: {e}" - ) from e - - logger.info( - "CIMD document fetched and validated: %s (client_name=%s)", - client_id_url, - doc.client_name, - ) - - if not policy.no_store: - self._cache[client_id_url] = _CIMDCacheEntry( - doc=doc, - etag=policy.etag, - last_modified=policy.last_modified, - expires_at=policy.expires_at, - freshness_lifetime=policy.freshness_lifetime, - must_revalidate=policy.must_revalidate, - ) - else: - self._cache.pop(client_id_url, None) - - return doc - - def validate_redirect_uri(self, doc: CIMDDocument, redirect_uri: str) -> bool: - """Validate that a redirect_uri is allowed by the CIMD document. - - Uses component-level matching (scheme, host, port, path) which correctly - handles RFC 8252 §7.3 loopback port flexibility and wildcard patterns. - - Args: - doc: The CIMD document - redirect_uri: The redirect URI to validate - - Returns: - True if valid, False otherwise - """ - if not doc.redirect_uris: - # No redirect_uris specified - reject all - return False - - # Normalize for comparison - redirect_uri = redirect_uri.rstrip("/") - - for allowed in doc.redirect_uris: - allowed_str = allowed.rstrip("/") - if matches_allowed_pattern(redirect_uri, allowed_str): - return True - - return False - - -class CIMDAssertionValidator: - """Validates JWT assertions for private_key_jwt CIMD clients. - - Implements RFC 7523 (JSON Web Token (JWT) Profile for OAuth 2.0 Client - Authentication and Authorization Grants) for CIMD client authentication. - - JTI replay protection uses TTL-based caching to ensure proper security: - - JTIs are cached with expiration matching the JWT's exp claim - - Expired JTIs are automatically cleaned up - - Maximum assertion lifetime is enforced (5 minutes) - """ - - # Maximum allowed assertion lifetime in seconds (RFC 7523 recommends short-lived) - MAX_ASSERTION_LIFETIME = 300 # 5 minutes - - def __init__(self): - # JTI cache: maps jti -> expiration timestamp - self._jti_cache: dict[str, float] = {} - self._jti_cache_max_size = 10000 - self._last_cleanup = time.monotonic() - self._cleanup_interval = 60 # Cleanup every 60 seconds - # Cache JWTVerifier per jwks_uri so JWKS keys are not re-fetched - # on every token exchange - self._verifier_cache: dict[str, JWTVerifier] = {} - self._verifier_cache_max_size = 100 - self.logger = get_logger(__name__) - - def _cleanup_expired_jtis(self) -> None: - """Remove expired JTIs from cache.""" - now = time.time() - expired = [jti for jti, exp in self._jti_cache.items() if exp < now] - for jti in expired: - del self._jti_cache[jti] - if expired: - self.logger.debug("Cleaned up %d expired JTIs from cache", len(expired)) - - def _maybe_cleanup(self) -> None: - """Periodically cleanup expired JTIs to prevent unbounded growth.""" - now = time.monotonic() - if now - self._last_cleanup > self._cleanup_interval: - self._cleanup_expired_jtis() - self._last_cleanup = now - - async def validate_assertion( - self, - assertion: str, - client_id: str, - token_endpoint: str, - cimd_doc: CIMDDocument, - ) -> bool: - """Validate JWT assertion from client. - - Args: - assertion: The JWT assertion string - client_id: Expected client_id (must match iss and sub claims) - token_endpoint: Token endpoint URL (must match aud claim) - cimd_doc: CIMD document containing JWKS for key verification - - Returns: - True if valid - - Raises: - ValueError: If validation fails - """ - from fastmcp.server.auth.providers.jwt import JWTVerifier as _JWTVerifier - - # Periodic cleanup of expired JTIs - self._maybe_cleanup() - - # 1. Validate CIMD document has key material and get/create verifier - if cimd_doc.jwks_uri: - jwks_uri_str = str(cimd_doc.jwks_uri) - cache_key = f"{jwks_uri_str}|{client_id}|{token_endpoint}" - verifier = self._verifier_cache.get(cache_key) - if verifier is None: - verifier = _JWTVerifier( - jwks_uri=jwks_uri_str, - issuer=client_id, - audience=token_endpoint, - ssrf_safe=True, - ) - if len(self._verifier_cache) >= self._verifier_cache_max_size: - oldest_key = next(iter(self._verifier_cache)) - del self._verifier_cache[oldest_key] - self._verifier_cache[cache_key] = verifier - elif cimd_doc.jwks: - # Inline JWKS — no caching since the key is embedded - public_key = self._extract_public_key_from_jwks(assertion, cimd_doc.jwks) - verifier = _JWTVerifier( - public_key=public_key, - issuer=client_id, - audience=token_endpoint, - ) - else: - raise ValueError( - "CIMD document must have jwks_uri or jwks for private_key_jwt" - ) - - # 2. Verify JWT using JWTVerifier (handles signature, exp, iss, aud) - access_token = await verifier.load_access_token(assertion) - if not access_token: - raise ValueError("Invalid JWT assertion") - - claims = access_token.claims - - # 3. Validate assertion lifetime (exp and iat) - now = time.time() - exp = claims.get("exp") - iat = claims.get("iat") - - if not exp: - raise ValueError("Assertion must include exp claim") - - # Validate exp is in the future (with small clock skew tolerance) - if exp < now - 30: # 30 second clock skew tolerance - raise ValueError("Assertion has expired") - - # If iat is present, validate it and check assertion lifetime - if iat: - if iat > now + 30: # 30 second clock skew tolerance - raise ValueError("Assertion iat is in the future") - if exp - iat > self.MAX_ASSERTION_LIFETIME: - raise ValueError( - f"Assertion lifetime too long: {exp - iat}s (max {self.MAX_ASSERTION_LIFETIME}s)" - ) - else: - # No iat, enforce max lifetime from now - if exp > now + self.MAX_ASSERTION_LIFETIME: - raise ValueError( - f"Assertion exp too far in future (max {self.MAX_ASSERTION_LIFETIME}s)" - ) - - # 4. Additional RFC 7523 validation: sub claim must equal client_id - if claims.get("sub") != client_id: - raise ValueError(f"Assertion sub claim must be {client_id}") - - # 5. Check jti for replay attacks (RFC 7523 requirement) - jti = claims.get("jti") - if not jti: - raise ValueError("Assertion must include jti claim") - - # Check if JTI was already used (and hasn't expired from cache) - if jti in self._jti_cache: - cached_exp = self._jti_cache[jti] - if cached_exp > now: # Still valid in cache - raise ValueError(f"Assertion replay detected: jti {jti} already used") - # Expired in cache, can be reused (clean it up) - del self._jti_cache[jti] - - # Add to cache with expiration time - # Use the assertion's exp claim so it stays cached until it would expire anyway - self._jti_cache[jti] = exp - - # Emergency size limit (shouldn't hit with proper TTL cleanup) - if len(self._jti_cache) > self._jti_cache_max_size: - self._cleanup_expired_jtis() - # If still over limit after cleanup, reject to prevent DoS - if len(self._jti_cache) > self._jti_cache_max_size: - self.logger.warning( - "JTI cache at max capacity (%d), possible attack", - self._jti_cache_max_size, - ) - raise ValueError("Server overloaded, please retry") - - self.logger.debug( - "JWT assertion validated successfully for client %s", client_id - ) - return True - - def _extract_public_key_from_jwks(self, token: str, jwks: dict) -> str: - """Extract public key from inline JWKS. - - Args: - token: JWT token to extract kid from - jwks: JWKS document containing keys - - Returns: - PEM-encoded public key - - Raises: - ValueError: If key cannot be found or extracted - """ - import base64 - import json - - from authlib.jose import JsonWebKey - - # Extract kid from token header - try: - header_b64 = token.split(".")[0] - header_b64 += "=" * (4 - len(header_b64) % 4) # Add padding - header = json.loads(base64.urlsafe_b64decode(header_b64)) - kid = header.get("kid") - except Exception as e: - raise ValueError(f"Failed to extract key ID from token: {e}") from e - - # Find matching key in JWKS - keys = jwks.get("keys", []) - if not keys: - raise ValueError("JWKS document contains no keys") - - matching_key = None - for key in keys: - if kid and key.get("kid") == kid: - matching_key = key - break - - if not matching_key: - # If no kid match, try first key as fallback - if len(keys) == 1: - matching_key = keys[0] - self.logger.warning( - "No matching kid in JWKS, using single available key" - ) - else: - raise ValueError(f"No matching key found for kid={kid} in JWKS") - - # Convert JWK to PEM - try: - jwk = JsonWebKey.import_key(matching_key) - return jwk.as_pem().decode("utf-8") - except Exception as e: - raise ValueError(f"Failed to convert JWK to PEM: {e}") from e - - -class CIMDClientManager: - """Manages all CIMD client operations for OAuth proxy. - - This class encapsulates: - - CIMD client detection - - Document fetching and validation - - Synthetic OAuth client creation - - Private key JWT assertion validation - - This allows the OAuth proxy to delegate all CIMD-specific logic to a - single, focused manager class. - """ - - def __init__( - self, - enable_cimd: bool = True, - default_scope: str = "", - allowed_redirect_uri_patterns: list[str] | None = None, - ): - """Initialize CIMD client manager. - - Args: - enable_cimd: Whether CIMD support is enabled - default_scope: Default scope for CIMD clients if not specified in document - allowed_redirect_uri_patterns: Allowed redirect URI patterns (proxy's config) - """ - self.enabled = enable_cimd - self.default_scope = default_scope - self.allowed_redirect_uri_patterns = allowed_redirect_uri_patterns - - self._fetcher = CIMDFetcher() - self._assertion_validator = CIMDAssertionValidator() - self.logger = get_logger(__name__) - - def is_cimd_client_id(self, client_id: str) -> bool: - """Check if client_id is a CIMD URL. - - Args: - client_id: Client ID to check - - Returns: - True if client_id is an HTTPS URL (CIMD format) - """ - return self.enabled and self._fetcher.is_cimd_client_id(client_id) - - async def get_client(self, client_id_url: str): - """Fetch CIMD document and create synthetic OAuth client. - - Args: - client_id_url: HTTPS URL pointing to CIMD document - - Returns: - OAuthProxyClient with CIMD document attached, or None if fetch fails - - Note: - Return type is left untyped to avoid circular import with oauth_proxy. - Returns OAuthProxyClient instance or None. - """ - if not self.enabled: - return None - - try: - cimd_doc = await self._fetcher.fetch(client_id_url) - except (CIMDFetchError, CIMDValidationError) as e: - self.logger.warning("CIMD fetch failed for %s: %s", client_id_url, e) - return None - - # Import here to avoid circular dependency - from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient - - # Create synthetic client from CIMD document. - # Keep CIMD redirect_uris as strings on the document itself so wildcard - # patterns like http://localhost:*/callback remain valid. - redirect_uris = None - client = ProxyDCRClient( - client_id=client_id_url, - client_secret=None, - redirect_uris=redirect_uris, - grant_types=cimd_doc.grant_types, - scope=cimd_doc.scope or self.default_scope, - token_endpoint_auth_method=cimd_doc.token_endpoint_auth_method, - allowed_redirect_uri_patterns=self.allowed_redirect_uri_patterns, - client_name=cimd_doc.client_name, - cimd_document=cimd_doc, - cimd_fetched_at=time.time(), - ) - - self.logger.debug( - "CIMD client resolved: %s (name=%s)", - client_id_url, - cimd_doc.client_name, - ) - return client - - async def validate_private_key_jwt( - self, - assertion: str, - client, # OAuthProxyClient, untyped to avoid circular import - token_endpoint: str, - ) -> bool: - """Validate JWT assertion for private_key_jwt auth. - - Args: - assertion: JWT assertion string from client - client: OAuth proxy client (must have cimd_document) - token_endpoint: Token endpoint URL for aud validation - - Returns: - True if assertion is valid - - Raises: - ValueError: If client doesn't have CIMD document or validation fails - """ - if not hasattr(client, "cimd_document") or not client.cimd_document: - raise ValueError("Client must have CIMD document for private_key_jwt") - - cimd_doc = client.cimd_document - if cimd_doc.token_endpoint_auth_method != "private_key_jwt": - raise ValueError("CIMD document must specify private_key_jwt auth method") - - return await self._assertion_validator.validate_assertion( - assertion, client.client_id, token_endpoint, cimd_doc - ) diff --git a/src/fastmcp/server/auth/handlers/authorize.py b/src/fastmcp/server/auth/handlers/authorize.py deleted file mode 100644 index f91b266b7..000000000 --- a/src/fastmcp/server/auth/handlers/authorize.py +++ /dev/null @@ -1,326 +0,0 @@ -"""Enhanced authorization handler with improved error responses. - -This module provides an enhanced authorization handler that wraps the MCP SDK's -AuthorizationHandler to provide better error messages when clients attempt to -authorize with unregistered client IDs. - -The enhancement adds: -- Content negotiation: HTML for browsers, JSON for API clients -- Enhanced JSON responses with registration endpoint hints -- Styled HTML error pages with registration links/forms -- Link headers pointing to registration endpoints -""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING - -from mcp.server.auth.handlers.authorize import ( - AuthorizationHandler as SDKAuthorizationHandler, -) -from pydantic import AnyHttpUrl -from starlette.requests import Request -from starlette.responses import Response - -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.ui import ( - INFO_BOX_STYLES, - TOOLTIP_STYLES, - create_logo, - create_page, - create_secure_html_response, -) - -if TYPE_CHECKING: - from mcp.server.auth.provider import OAuthAuthorizationServerProvider - -logger = get_logger(__name__) - - -def create_unregistered_client_html( - client_id: str, - registration_endpoint: str, - discovery_endpoint: str, - server_name: str | None = None, - server_icon_url: str | None = None, - title: str = "Client Not Registered", -) -> str: - """Create styled HTML error page for unregistered client attempts. - - Args: - client_id: The unregistered client ID that was provided - registration_endpoint: URL of the registration endpoint - discovery_endpoint: URL of the OAuth metadata discovery endpoint - server_name: Optional server name for branding - server_icon_url: Optional server icon URL - title: Page title - - Returns: - HTML string for the error page - """ - import html as html_module - - client_id_escaped = html_module.escape(client_id) - - # Main error message - error_box = f""" -
-

The client ID {client_id_escaped} was not found in the server's client registry.

-
- """ - - # What to do - yellow warning box - warning_box = """ -
-

Your MCP client opened this page to complete OAuth authorization, - but the server did not recognize its client ID. To fix this:

-
    -
  • Close this browser window
  • -
  • Clear authentication tokens in your MCP client (or restart it)
  • -
  • Try connecting again - your client should automatically re-register
  • -
-
- """ - - # Help link with tooltip (similar to consent screen) - help_link = """ - - """ - - # Build page content - content = f""" -
- {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")} -

{title}

- {error_box} - {warning_box} -
- {help_link} - """ - - # Use same styles as consent page - additional_styles = ( - INFO_BOX_STYLES - + TOOLTIP_STYLES - + """ - /* Error variant for info-box */ - .info-box.error { - background: #fef2f2; - border-color: #f87171; - } - .info-box.error strong { - color: #991b1b; - } - /* Warning variant for info-box (yellow) */ - .info-box.warning { - background: #fffbeb; - border-color: #fbbf24; - } - .info-box.warning strong { - color: #92400e; - } - .info-box code { - background: rgba(0, 0, 0, 0.05); - padding: 2px 6px; - border-radius: 3px; - font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; - font-size: 0.9em; - } - .info-box ul { - margin: 10px 0; - padding-left: 20px; - } - .info-box li { - margin: 6px 0; - } - """ - ) - - return create_page( - content=content, - title=title, - additional_styles=additional_styles, - ) - - -class AuthorizationHandler(SDKAuthorizationHandler): - """Authorization handler with enhanced error responses for unregistered clients. - - This handler extends the MCP SDK's AuthorizationHandler to provide better UX - when clients attempt to authorize without being registered. It implements - content negotiation to return: - - - HTML error pages for browser requests - - Enhanced JSON with registration hints for API clients - - Link headers pointing to registration endpoints - - This maintains OAuth 2.1 compliance (returns 400 for invalid client_id) - while providing actionable guidance to fix the error. - """ - - def __init__( - self, - provider: OAuthAuthorizationServerProvider, - base_url: AnyHttpUrl | str, - server_name: str | None = None, - server_icon_url: str | None = None, - ): - """Initialize the enhanced authorization handler. - - Args: - provider: OAuth authorization server provider - base_url: Base URL of the server for constructing endpoint URLs - server_name: Optional server name for branding - server_icon_url: Optional server icon URL for branding - """ - super().__init__(provider) - self._base_url = str(base_url).rstrip("/") - self._server_name = server_name - self._server_icon_url = server_icon_url - - async def handle(self, request: Request) -> Response: - """Handle authorization request with enhanced error responses. - - This method extends the SDK's authorization handler and intercepts - errors for unregistered clients to provide better error responses - based on the client's Accept header. - - Args: - request: The authorization request - - Returns: - Response (redirect on success, error response on failure) - """ - # Call the SDK handler - response = await super().handle(request) - - # Check if this is a client not found error - if response.status_code == 400: - # Try to extract client_id from request for enhanced error - client_id: str | None = None - if request.method == "GET": - client_id = request.query_params.get("client_id") - else: - form = await request.form() - client_id_value = form.get("client_id") - # Ensure client_id is a string, not UploadFile - if isinstance(client_id_value, str): - client_id = client_id_value - - # If we have a client_id and the error is about it not being found, - # enhance the response - if client_id: - try: - # Check if response body contains "not found" error - if hasattr(response, "body"): - body = json.loads(bytes(response.body)) - if ( - body.get("error") == "invalid_request" - and "not found" in body.get("error_description", "").lower() - ): - return await self._create_enhanced_error_response( - request, client_id, body.get("state") - ) - except Exception: - # If we can't parse the response, just return the original - pass - - return response - - async def _create_enhanced_error_response( - self, request: Request, client_id: str, state: str | None - ) -> Response: - """Create enhanced error response with content negotiation. - - Args: - request: The original request - client_id: The unregistered client ID - state: The state parameter from the request - - Returns: - HTML or JSON error response based on Accept header - """ - registration_endpoint = f"{self._base_url}/register" - discovery_endpoint = f"{self._base_url}/.well-known/oauth-authorization-server" - - # Extract server metadata from app state (same pattern as consent screen) - from fastmcp.server.server import FastMCP - - fastmcp = getattr(request.app.state, "fastmcp_server", None) - - if isinstance(fastmcp, FastMCP): - server_name = fastmcp.name - icons = fastmcp.icons - server_icon_url = icons[0].src if icons else None - else: - server_name = self._server_name - server_icon_url = self._server_icon_url - - # Check Accept header for content negotiation - accept = request.headers.get("accept", "") - - # Prefer HTML for browsers - if "text/html" in accept: - html = create_unregistered_client_html( - client_id=client_id, - registration_endpoint=registration_endpoint, - discovery_endpoint=discovery_endpoint, - server_name=server_name, - server_icon_url=server_icon_url, - ) - response = create_secure_html_response(html, status_code=400) - else: - # Return enhanced JSON for API clients - from mcp.server.auth.handlers.authorize import AuthorizationErrorResponse - - error_data = AuthorizationErrorResponse( - error="invalid_request", - error_description=( - f"Client ID '{client_id}' is not registered with this server. " - f"MCP clients should automatically re-register by sending a POST request to " - f"the registration_endpoint and retry authorization. " - f"If this persists, clear cached authentication tokens and reconnect." - ), - state=state, - ) - - # Add extra fields to help clients discover registration - error_dict = error_data.model_dump(exclude_none=True) - error_dict["registration_endpoint"] = registration_endpoint - error_dict["authorization_server_metadata"] = discovery_endpoint - - from starlette.responses import JSONResponse - - response = JSONResponse( - status_code=400, - content=error_dict, - headers={"Cache-Control": "no-store"}, - ) - - # Add Link header for registration endpoint discovery - response.headers["Link"] = ( - f'<{registration_endpoint}>; rel="http://oauth.net/core/2.1/#registration"' - ) - - logger.info( - "Unregistered client_id=%s, returned %s error response", - client_id, - "HTML" if "text/html" in accept else "JSON", - ) - - return response diff --git a/src/fastmcp/server/auth/jwt_issuer.py b/src/fastmcp/server/auth/jwt_issuer.py deleted file mode 100644 index 4e17eac60..000000000 --- a/src/fastmcp/server/auth/jwt_issuer.py +++ /dev/null @@ -1,270 +0,0 @@ -"""JWT token issuance and verification for FastMCP OAuth Proxy. - -This module implements the token factory pattern for OAuth proxies, where the proxy -issues its own JWT tokens to clients instead of forwarding upstream provider tokens. -This maintains proper OAuth 2.0 token audience boundaries. -""" - -from __future__ import annotations - -import base64 -import time -from typing import Any, overload - -from authlib.jose import JsonWebToken -from authlib.jose.errors import JoseError -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives.kdf.hkdf import HKDF -from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC - -import fastmcp -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - -KDF_ITERATIONS = 1_000_000 -KDF_ITERATIONS_TEST = 10 - - -@overload -def derive_jwt_key(*, high_entropy_material: str, salt: str) -> bytes: - """Derive JWT signing key from a high-entropy key material and server salt.""" - - -@overload -def derive_jwt_key(*, low_entropy_material: str, salt: str) -> bytes: - """Derive JWT signing key from a low-entropy key material and server salt.""" - - -def derive_jwt_key( - *, - high_entropy_material: str | None = None, - low_entropy_material: str | None = None, - salt: str, -) -> bytes: - """Derive JWT signing key from a high-entropy or low-entropy key material and server salt.""" - if high_entropy_material is not None and low_entropy_material is not None: - raise ValueError( - "Either high_entropy_material or low_entropy_material must be provided, but not both" - ) - - if high_entropy_material is not None: - derived_key = HKDF( - algorithm=hashes.SHA256(), - length=32, - salt=salt.encode(), - info=b"Fernet", - ).derive(key_material=high_entropy_material.encode()) - - return base64.urlsafe_b64encode(derived_key) - - if low_entropy_material is not None: - iterations = ( - KDF_ITERATIONS_TEST if fastmcp.settings.test_mode else KDF_ITERATIONS - ) - pbkdf2 = PBKDF2HMAC( - algorithm=hashes.SHA256(), - length=32, - salt=salt.encode(), - iterations=iterations, - ).derive(key_material=low_entropy_material.encode()) - - return base64.urlsafe_b64encode(pbkdf2) - - raise ValueError( - "Either high_entropy_material or low_entropy_material must be provided" - ) - - -class JWTIssuer: - """Issues and validates FastMCP-signed JWT tokens using HS256. - - This issuer creates JWT tokens for MCP clients with proper audience claims, - maintaining OAuth 2.0 token boundaries. Tokens are signed with HS256 using - a key derived from the upstream client secret. - """ - - def __init__( - self, - issuer: str, - audience: str, - signing_key: bytes, - ): - """Initialize JWT issuer. - - Args: - issuer: Token issuer (FastMCP server base URL) - audience: Token audience (typically {base_url}/mcp) - signing_key: HS256 signing key (32 bytes) - """ - self.issuer = issuer - self.audience = audience - self._signing_key = signing_key - self._jwt = JsonWebToken(["HS256"]) - - def issue_access_token( - self, - client_id: str, - scopes: list[str], - jti: str, - expires_in: int = 3600, - upstream_claims: dict[str, Any] | None = None, - ) -> str: - """Issue a minimal FastMCP access token. - - FastMCP tokens are reference tokens containing only the minimal claims - needed for validation and lookup. The JTI maps to the upstream token - which contains actual user identity and authorization data. - - Args: - client_id: MCP client ID - scopes: Token scopes - jti: Unique token identifier (maps to upstream token) - expires_in: Token lifetime in seconds - upstream_claims: Optional claims from upstream IdP token to include - - Returns: - Signed JWT token - """ - now = int(time.time()) - - header = {"alg": "HS256", "typ": "JWT"} - payload: dict[str, Any] = { - "iss": self.issuer, - "aud": self.audience, - "client_id": client_id, - "scope": " ".join(scopes), - "exp": now + expires_in, - "iat": now, - "jti": jti, - } - - if upstream_claims: - payload["upstream_claims"] = upstream_claims - - token_bytes = self._jwt.encode(header, payload, self._signing_key) - token = token_bytes.decode("utf-8") - - logger.debug( - "Issued access token for client=%s jti=%s exp=%d", - client_id, - jti[:8], - payload["exp"], - ) - - return token - - def issue_refresh_token( - self, - client_id: str, - scopes: list[str], - jti: str, - expires_in: int, - upstream_claims: dict[str, Any] | None = None, - ) -> str: - """Issue a minimal FastMCP refresh token. - - FastMCP refresh tokens are reference tokens containing only the minimal - claims needed for validation and lookup. The JTI maps to the upstream - token which contains actual user identity and authorization data. - - Args: - client_id: MCP client ID - scopes: Token scopes - jti: Unique token identifier (maps to upstream token) - expires_in: Token lifetime in seconds (should match upstream refresh expiry) - upstream_claims: Optional claims from upstream IdP token to include - - Returns: - Signed JWT token - """ - now = int(time.time()) - - header = {"alg": "HS256", "typ": "JWT"} - payload: dict[str, Any] = { - "iss": self.issuer, - "aud": self.audience, - "client_id": client_id, - "scope": " ".join(scopes), - "exp": now + expires_in, - "iat": now, - "jti": jti, - "token_use": "refresh", - } - - if upstream_claims: - payload["upstream_claims"] = upstream_claims - - token_bytes = self._jwt.encode(header, payload, self._signing_key) - token = token_bytes.decode("utf-8") - - logger.debug( - "Issued refresh token for client=%s jti=%s exp=%d", - client_id, - jti[:8], - payload["exp"], - ) - - return token - - def verify_token( - self, - token: str, - expected_token_use: str = "access", - ) -> dict[str, Any]: - """Verify and decode a FastMCP token. - - Validates JWT signature, expiration, issuer, audience, and token type. - - Args: - token: JWT token to verify - expected_token_use: Expected token type ("access" or "refresh"). - Defaults to "access", which rejects refresh tokens. - - Returns: - Decoded token payload - - Raises: - JoseError: If token is invalid, expired, or has wrong claims - """ - try: - # Decode and verify signature - payload = self._jwt.decode(token, self._signing_key) - - # Validate token type - token_use = payload.get("token_use", "access") - if token_use != expected_token_use: - logger.debug( - "Token type mismatch: expected %s, got %s", - expected_token_use, - token_use, - ) - raise JoseError( - f"Token type mismatch: expected {expected_token_use}, " - f"got {token_use}" - ) - - # Validate expiration - exp = payload.get("exp") - if exp and exp < time.time(): - logger.debug("Token expired") - raise JoseError("Token has expired") - - # Validate issuer - if payload.get("iss") != self.issuer: - logger.debug("Token has invalid issuer") - raise JoseError("Invalid token issuer") - - # Validate audience - if payload.get("aud") != self.audience: - logger.debug("Token has invalid audience") - raise JoseError("Invalid token audience") - - logger.debug( - "Token verified successfully for subject=%s", payload.get("sub") - ) - return payload - - except JoseError as e: - logger.debug("Token validation failed: %s", e) - raise diff --git a/src/fastmcp/server/auth/middleware.py b/src/fastmcp/server/auth/middleware.py deleted file mode 100644 index a7e80a7ff..000000000 --- a/src/fastmcp/server/auth/middleware.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Enhanced authentication middleware with better error messages. - -This module provides enhanced versions of MCP SDK authentication middleware -that return more helpful error messages for developers troubleshooting -authentication issues. -""" - -from __future__ import annotations - -import json - -from mcp.server.auth.middleware.bearer_auth import ( - RequireAuthMiddleware as SDKRequireAuthMiddleware, -) -from starlette.types import Send - -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class RequireAuthMiddleware(SDKRequireAuthMiddleware): - """Enhanced authentication middleware with detailed error messages. - - Extends the SDK's RequireAuthMiddleware to provide more actionable - error messages when authentication fails. This helps developers - understand what went wrong and how to fix it. - """ - - async def _send_auth_error( - self, send: Send, status_code: int, error: str, description: str - ) -> None: - """Send an authentication error response with enhanced error messages. - - Overrides the SDK's _send_auth_error to provide more detailed - error descriptions that help developers troubleshoot authentication - issues. - - Args: - send: ASGI send callable - status_code: HTTP status code (401 or 403) - error: OAuth error code - description: Base error description - """ - # Enhance error descriptions based on error type - enhanced_description = description - - if error == "invalid_token" and status_code == 401: - # This is the "Authentication required" error - enhanced_description = ( - "Authentication failed. The provided bearer token is invalid, expired, or no longer recognized by the server. " - "To resolve: clear authentication tokens in your MCP client and reconnect. " - "Your client should automatically re-register and obtain new tokens." - ) - elif error == "insufficient_scope": - # Scope error - already has good detail from SDK - pass - - # Build WWW-Authenticate header value - www_auth_parts = [ - f'error="{error}"', - f'error_description="{enhanced_description}"', - ] - if self.resource_metadata_url: - www_auth_parts.append(f'resource_metadata="{self.resource_metadata_url}"') - - www_authenticate = f"Bearer {', '.join(www_auth_parts)}" - - # Send response - body = {"error": error, "error_description": enhanced_description} - body_bytes = json.dumps(body).encode() - - await send( - { - "type": "http.response.start", - "status": status_code, - "headers": [ - (b"content-type", b"application/json"), - (b"content-length", str(len(body_bytes)).encode()), - (b"www-authenticate", www_authenticate.encode()), - ], - } - ) - - await send( - { - "type": "http.response.body", - "body": body_bytes, - } - ) - - logger.info( - "Auth error returned: %s (status=%d)", - error, - status_code, - ) diff --git a/src/fastmcp/server/auth/oauth_proxy/__init__.py b/src/fastmcp/server/auth/oauth_proxy/__init__.py deleted file mode 100644 index 5e9ff4315..000000000 --- a/src/fastmcp/server/auth/oauth_proxy/__init__.py +++ /dev/null @@ -1,14 +0,0 @@ -"""OAuth Proxy Provider for FastMCP. - -This package provides OAuth proxy functionality split across multiple modules: -- models: Pydantic models and constants -- ui: HTML generation functions -- consent: Consent management mixin -- proxy: Main OAuthProxy class -""" - -from fastmcp.server.auth.oauth_proxy.proxy import OAuthProxy - -__all__ = [ - "OAuthProxy", -] diff --git a/src/fastmcp/server/auth/oauth_proxy/consent.py b/src/fastmcp/server/auth/oauth_proxy/consent.py deleted file mode 100644 index 03b1e6ef3..000000000 --- a/src/fastmcp/server/auth/oauth_proxy/consent.py +++ /dev/null @@ -1,512 +0,0 @@ -"""OAuth Proxy Consent Management. - -This module contains consent management functionality for the OAuth proxy. -The ConsentMixin class provides methods for handling user consent flows, -cookie management, and consent page rendering. -""" - -from __future__ import annotations - -import base64 -import hashlib -import hmac -import json -import secrets -import time -from base64 import urlsafe_b64encode -from typing import TYPE_CHECKING, Any -from urllib.parse import urlencode, urlparse - -from pydantic import AnyUrl -from starlette.requests import Request -from starlette.responses import HTMLResponse, RedirectResponse - -from fastmcp.server.auth.oauth_proxy.models import ProxyDCRClient -from fastmcp.server.auth.oauth_proxy.ui import create_consent_html -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.ui import create_secure_html_response - -if TYPE_CHECKING: - from fastmcp.server.auth.oauth_proxy.proxy import OAuthProxy - -logger = get_logger(__name__) - - -class ConsentMixin: - """Mixin class providing consent management functionality for OAuthProxy. - - This mixin contains all methods related to: - - Cookie signing and verification - - Consent page rendering - - Consent approval/denial handling - - URI normalization for consent tracking - """ - - def _normalize_uri(self, uri: str) -> str: - """Normalize a URI to a canonical form for consent tracking.""" - parsed = urlparse(uri) - path = parsed.path or "" - normalized = f"{parsed.scheme.lower()}://{parsed.netloc.lower()}{path}" - if normalized.endswith("/") and len(path) > 1: - normalized = normalized[:-1] - return normalized - - def _make_client_key(self, client_id: str, redirect_uri: str | AnyUrl) -> str: - """Create a stable key for consent tracking from client_id and redirect_uri.""" - normalized = self._normalize_uri(str(redirect_uri)) - return f"{client_id}:{normalized}" - - def _cookie_name(self: OAuthProxy, base_name: str) -> str: - """Return secure cookie name for HTTPS, fallback for HTTP development.""" - if self._is_https: - return f"__Host-{base_name}" - return f"__{base_name}" - - def _cookie_signing_key(self: OAuthProxy) -> bytes: - """Return the key used for HMAC-signing consent cookies. - - Uses the upstream client secret when available, falling back to the - JWT signing key (which is always present — OAuthProxy requires it - when no client secret is provided). - """ - if self._upstream_client_secret is not None: - return self._upstream_client_secret.get_secret_value().encode() - return self._jwt_signing_key - - def _sign_cookie(self: OAuthProxy, payload: str) -> str: - """Sign a cookie payload with HMAC-SHA256. - - Returns: base64(payload).base64(signature) - """ - key = self._cookie_signing_key() - signature = hmac.new(key, payload.encode(), hashlib.sha256).digest() - signature_b64 = base64.b64encode(signature).decode() - return f"{payload}.{signature_b64}" - - def _verify_cookie(self: OAuthProxy, signed_value: str) -> str | None: - """Verify and extract payload from signed cookie. - - Returns: payload if signature valid, None otherwise - """ - try: - if "." not in signed_value: - return None - payload, signature_b64 = signed_value.rsplit(".", 1) - - # Verify signature - key = self._cookie_signing_key() - expected_sig = hmac.new(key, payload.encode(), hashlib.sha256).digest() - provided_sig = base64.b64decode(signature_b64.encode()) - - # Constant-time comparison - if not hmac.compare_digest(expected_sig, provided_sig): - return None - - return payload - except Exception: - return None - - def _decode_list_cookie( - self: OAuthProxy, request: Request, base_name: str - ) -> list[str]: - """Decode and verify a signed base64-encoded JSON list from cookie. Returns [] if missing/invalid.""" - secure_name = self._cookie_name(base_name) - raw = request.cookies.get(secure_name) - # Only fall back to the non-__Host- name over plain HTTP. On HTTPS, - # __Host- enforces host-only scope; accepting the weaker name would - # let a sibling-subdomain attacker inject a domain-scoped cookie. - if not raw and not self._is_https: - raw = request.cookies.get(f"__{base_name}") - if not raw: - return [] - try: - # Verify signature - payload = self._verify_cookie(raw) - if not payload: - logger.debug("Cookie signature verification failed for %s", secure_name) - return [] - - # Decode payload - data = base64.b64decode(payload.encode()) - value = json.loads(data.decode()) - if isinstance(value, list): - return [str(x) for x in value] - except Exception: - logger.debug("Failed to decode cookie %s; treating as empty", secure_name) - return [] - - def _encode_list_cookie(self: OAuthProxy, values: list[str]) -> str: - """Encode values to base64 and sign with HMAC. - - Returns: signed cookie value (payload.signature) - """ - payload = json.dumps(values, separators=(",", ":")).encode() - payload_b64 = base64.b64encode(payload).decode() - return self._sign_cookie(payload_b64) - - def _set_list_cookie( - self: OAuthProxy, - response: HTMLResponse | RedirectResponse, - base_name: str, - value_b64: str, - max_age: int, - ) -> None: - name = self._cookie_name(base_name) - response.set_cookie( - name, - value_b64, - max_age=max_age, - secure=self._is_https, - httponly=True, - samesite="lax", - path="/", - ) - - def _read_consent_bindings(self: OAuthProxy, request: Request) -> dict[str, str]: - """Read the consent binding map from the signed cookie. - - Returns a dict of {txn_id: consent_token} for all pending flows. - """ - cookie_name = self._cookie_name("MCP_CONSENT_BINDING") - raw = request.cookies.get(cookie_name) - # Only fall back to the non-__Host- name over plain HTTP. On HTTPS, - # __Host- enforces host-only scope; accepting the weaker name would - # bypass that guarantee. - if not raw and not self._is_https: - raw = request.cookies.get("__MCP_CONSENT_BINDING") - if not raw: - return {} - payload = self._verify_cookie(raw) - if not payload: - return {} - try: - data = json.loads(base64.b64decode(payload.encode()).decode()) - if isinstance(data, dict): - return {str(k): str(v) for k, v in data.items()} - except Exception: - logger.debug("Failed to decode consent binding cookie") - return {} - - def _write_consent_bindings( - self: OAuthProxy, - response: HTMLResponse | RedirectResponse, - bindings: dict[str, str], - ) -> None: - """Write the consent binding map to a signed cookie.""" - name = self._cookie_name("MCP_CONSENT_BINDING") - if not bindings: - response.set_cookie( - name, - "", - max_age=0, - secure=self._is_https, - httponly=True, - samesite="lax", - path="/", - ) - return - payload_bytes = json.dumps(bindings, separators=(",", ":")).encode() - payload_b64 = base64.b64encode(payload_bytes).decode() - signed_value = self._sign_cookie(payload_b64) - response.set_cookie( - name, - signed_value, - max_age=15 * 60, - secure=self._is_https, - httponly=True, - samesite="lax", - path="/", - ) - - def _set_consent_binding_cookie( - self: OAuthProxy, - request: Request, - response: HTMLResponse | RedirectResponse, - txn_id: str, - consent_token: str, - ) -> None: - """Add a consent binding entry for a transaction. - - This cookie binds the browser that approved consent to the IdP callback, - ensuring a different browser cannot complete the OAuth flow. Multiple - concurrent flows are supported by storing a map of txn_id → consent_token. - """ - bindings = self._read_consent_bindings(request) - bindings[txn_id] = consent_token - self._write_consent_bindings(response, bindings) - - def _clear_consent_binding_cookie( - self: OAuthProxy, - request: Request, - response: HTMLResponse | RedirectResponse, - txn_id: str, - ) -> None: - """Remove a specific consent binding entry after successful callback.""" - bindings = self._read_consent_bindings(request) - bindings.pop(txn_id, None) - self._write_consent_bindings(response, bindings) - - def _verify_consent_binding_cookie( - self: OAuthProxy, - request: Request, - txn_id: str, - expected_token: str, - ) -> bool: - """Verify the consent binding for a specific transaction.""" - bindings = self._read_consent_bindings(request) - actual = bindings.get(txn_id) - if not actual: - return False - return hmac.compare_digest(actual, expected_token) - - def _build_upstream_authorize_url( - self: OAuthProxy, txn_id: str, transaction: dict[str, Any] - ) -> str: - """Construct the upstream IdP authorization URL using stored transaction data.""" - query_params: dict[str, Any] = { - "response_type": "code", - "client_id": self._upstream_client_id, - "redirect_uri": f"{str(self.base_url).rstrip('/')}{self._redirect_path}", - "state": txn_id, - } - - scopes_to_use = transaction.get("scopes") or self.required_scopes or [] - if scopes_to_use: - query_params["scope"] = " ".join(scopes_to_use) - - # If PKCE forwarding was enabled, include the proxy challenge - proxy_code_verifier = transaction.get("proxy_code_verifier") - if proxy_code_verifier: - challenge_bytes = hashlib.sha256(proxy_code_verifier.encode()).digest() - proxy_code_challenge = ( - urlsafe_b64encode(challenge_bytes).decode().rstrip("=") - ) - query_params["code_challenge"] = proxy_code_challenge - query_params["code_challenge_method"] = "S256" - - # Forward resource indicator if present in transaction - if self._forward_resource: - if resource := transaction.get("resource"): - query_params["resource"] = resource - - # Extra configured parameters - if self._extra_authorize_params: - query_params.update(self._extra_authorize_params) - - separator = "&" if "?" in self._upstream_authorization_endpoint else "?" - return f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}" - - async def _handle_consent( - self: OAuthProxy, request: Request - ) -> HTMLResponse | RedirectResponse: - """Handle consent page - dispatch to GET or POST handler based on method.""" - if request.method == "POST": - return await self._submit_consent(request) - return await self._show_consent_page(request) - - async def _show_consent_page( - self: OAuthProxy, request: Request - ) -> HTMLResponse | RedirectResponse: - """Display consent page or auto-approve/deny based on cookies.""" - from fastmcp.server.server import FastMCP - - txn_id = request.query_params.get("txn_id") - if not txn_id: - return create_secure_html_response( - "

Error

Invalid or expired transaction

", status_code=400 - ) - - txn_model = await self._transaction_store.get(key=txn_id) - if not txn_model: - return create_secure_html_response( - "

Error

Invalid or expired transaction

", status_code=400 - ) - - txn = txn_model.model_dump() - client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"]) - - approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS")) - denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS")) - - if client_key in approved: - consent_token = secrets.token_urlsafe(32) - txn_model.consent_token = consent_token - await self._transaction_store.put(key=txn_id, value=txn_model, ttl=15 * 60) - upstream_url = self._build_upstream_authorize_url(txn_id, txn) - response = RedirectResponse(url=upstream_url, status_code=302) - self._set_consent_binding_cookie(request, response, txn_id, consent_token) - return response - - if client_key in denied: - callback_params = { - "error": "access_denied", - "state": txn.get("client_state") or "", - } - sep = "&" if "?" in txn["client_redirect_uri"] else "?" - return RedirectResponse( - url=f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}", - status_code=302, - ) - - # Need consent: issue CSRF token and show HTML - csrf_token = secrets.token_urlsafe(32) - csrf_expires_at = time.time() + 15 * 60 - - # Update transaction with CSRF token - txn_model.csrf_token = csrf_token - txn_model.csrf_expires_at = csrf_expires_at - await self._transaction_store.put( - key=txn_id, value=txn_model, ttl=15 * 60 - ) # Auto-expire after 15 minutes - - # Update dict for use in HTML generation - txn["csrf_token"] = csrf_token - txn["csrf_expires_at"] = csrf_expires_at - - # Load client to get client_name and CIMD info if available - client = await self.get_client(txn["client_id"]) - client_name = getattr(client, "client_name", None) if client else None - - # Detect CIMD clients for verified domain badge - is_cimd_client = False - cimd_domain: str | None = None - if isinstance(client, ProxyDCRClient) and client.cimd_document is not None: - is_cimd_client = True - cimd_domain = urlparse(txn["client_id"]).hostname - - # Extract server metadata from app state - fastmcp = getattr(request.app.state, "fastmcp_server", None) - - if isinstance(fastmcp, FastMCP): - server_name = fastmcp.name - icons = fastmcp.icons - server_icon_url = icons[0].src if icons else None - server_website_url = fastmcp.website_url - else: - server_name = None - server_icon_url = None - server_website_url = None - - html = create_consent_html( - client_id=txn["client_id"], - redirect_uri=txn["client_redirect_uri"], - scopes=txn.get("scopes") or [], - txn_id=txn_id, - csrf_token=csrf_token, - client_name=client_name, - server_name=server_name, - server_icon_url=server_icon_url, - server_website_url=server_website_url, - csp_policy=self._consent_csp_policy, - is_cimd_client=is_cimd_client, - cimd_domain=cimd_domain, - ) - response = create_secure_html_response(html) - # Merge new CSRF token with any existing ones (supports concurrent flows) - existing_tokens = self._decode_list_cookie(request, "MCP_CONSENT_STATE") - existing_tokens.append(csrf_token) - self._set_list_cookie( - response, - "MCP_CONSENT_STATE", - self._encode_list_cookie(existing_tokens), - max_age=15 * 60, - ) - return response - - async def _submit_consent( - self: OAuthProxy, request: Request - ) -> RedirectResponse | HTMLResponse: - """Handle consent approval/denial, set cookies, and redirect appropriately.""" - form = await request.form() - txn_id = str(form.get("txn_id", "")) - action = str(form.get("action", "")) - csrf_token = str(form.get("csrf_token", "")) - - if not txn_id: - return create_secure_html_response( - "

Error

Invalid or expired transaction

", status_code=400 - ) - - txn_model = await self._transaction_store.get(key=txn_id) - if not txn_model: - return create_secure_html_response( - "

Error

Invalid or expired transaction

", status_code=400 - ) - - txn = txn_model.model_dump() - expected_csrf = txn.get("csrf_token") - expires_at = float(txn.get("csrf_expires_at") or 0) - - if not expected_csrf or csrf_token != expected_csrf or time.time() > expires_at: - return create_secure_html_response( - "

Error

Invalid or expired consent token

", status_code=400 - ) - - # Double-submit CSRF check: verify the form token matches the cookie. - # Without this, an attacker who knows their own tx_id/csrf_token can - # CSRF the victim's browser into approving consent, bypassing the - # consent binding cookie protection. - cookie_csrf_tokens = self._decode_list_cookie(request, "MCP_CONSENT_STATE") - if csrf_token not in cookie_csrf_tokens: - logger.warning( - "CSRF double-submit check failed for transaction %s " - "(possible cross-site consent forgery)", - txn_id, - ) - return create_secure_html_response( - "

Error

Authorization session mismatch. " - "Please try authenticating again.

", - status_code=403, - ) - - client_key = self._make_client_key(txn["client_id"], txn["client_redirect_uri"]) - - if action == "approve": - approved = set(self._decode_list_cookie(request, "MCP_APPROVED_CLIENTS")) - if client_key not in approved: - approved.add(client_key) - approved_b64 = self._encode_list_cookie(sorted(approved)) - - consent_token = secrets.token_urlsafe(32) - txn_model.consent_token = consent_token - await self._transaction_store.put(key=txn_id, value=txn_model, ttl=15 * 60) - - upstream_url = self._build_upstream_authorize_url(txn_id, txn) - response = RedirectResponse(url=upstream_url, status_code=302) - self._set_list_cookie( - response, "MCP_APPROVED_CLIENTS", approved_b64, max_age=365 * 24 * 3600 - ) - # Clear CSRF cookie by setting empty short-lived value - self._set_list_cookie( - response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60 - ) - self._set_consent_binding_cookie(request, response, txn_id, consent_token) - return response - - elif action == "deny": - denied = set(self._decode_list_cookie(request, "MCP_DENIED_CLIENTS")) - if client_key not in denied: - denied.add(client_key) - denied_b64 = self._encode_list_cookie(sorted(denied)) - - callback_params = { - "error": "access_denied", - "state": txn.get("client_state") or "", - } - sep = "&" if "?" in txn["client_redirect_uri"] else "?" - client_callback_url = ( - f"{txn['client_redirect_uri']}{sep}{urlencode(callback_params)}" - ) - response = RedirectResponse(url=client_callback_url, status_code=302) - self._set_list_cookie( - response, "MCP_DENIED_CLIENTS", denied_b64, max_age=365 * 24 * 3600 - ) - self._set_list_cookie( - response, "MCP_CONSENT_STATE", self._encode_list_cookie([]), max_age=60 - ) - return response - - else: - return create_secure_html_response( - "

Error

Invalid action

", status_code=400 - ) diff --git a/src/fastmcp/server/auth/oauth_proxy/models.py b/src/fastmcp/server/auth/oauth_proxy/models.py deleted file mode 100644 index b78decf17..000000000 --- a/src/fastmcp/server/auth/oauth_proxy/models.py +++ /dev/null @@ -1,252 +0,0 @@ -"""OAuth Proxy Models and Constants. - -This module contains all Pydantic models and constants used by the OAuth proxy. -""" - -from __future__ import annotations - -import hashlib -from typing import Any, Final - -from mcp.shared.auth import InvalidRedirectUriError, OAuthClientInformationFull -from pydantic import AnyUrl, BaseModel, Field - -from fastmcp.server.auth.cimd import CIMDDocument -from fastmcp.server.auth.redirect_validation import ( - matches_allowed_pattern, - validate_redirect_uri, -) - -# ------------------------------------------------------------------------- -# Constants -# ------------------------------------------------------------------------- - -# Default token expiration times -DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 # 1 hour -DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS: Final[int] = ( - 60 * 60 * 24 * 365 -) # 1 year -DEFAULT_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes - -# HTTP client timeout -HTTP_TIMEOUT_SECONDS: Final[int] = 30 - - -# ------------------------------------------------------------------------- -# Pydantic Models -# ------------------------------------------------------------------------- - - -class OAuthTransaction(BaseModel): - """OAuth transaction state for consent flow. - - Stored server-side to track active authorization flows with client context. - Includes CSRF tokens for consent protection per MCP security best practices. - """ - - txn_id: str - client_id: str - client_redirect_uri: str - client_state: str - code_challenge: str | None - code_challenge_method: str - scopes: list[str] - created_at: float - resource: str | None = None - proxy_code_verifier: str | None = None - csrf_token: str | None = None - csrf_expires_at: float | None = None - consent_token: str | None = None - - -class ClientCode(BaseModel): - """Client authorization code with PKCE and upstream tokens. - - Stored server-side after upstream IdP callback. Contains the upstream - tokens bound to the client's PKCE challenge for secure token exchange. - """ - - code: str - client_id: str - redirect_uri: str - code_challenge: str | None - code_challenge_method: str - scopes: list[str] - idp_tokens: dict[str, Any] - expires_at: float - created_at: float - - -class UpstreamTokenSet(BaseModel): - """Stored upstream OAuth tokens from identity provider. - - These tokens are obtained from the upstream provider (Google, GitHub, etc.) - and stored in plaintext within this model. Encryption is handled transparently - at the storage layer via FernetEncryptionWrapper. Tokens are never exposed to MCP clients. - """ - - upstream_token_id: str # Unique ID for this token set - access_token: str # Upstream access token - refresh_token: str | None # Upstream refresh token - refresh_token_expires_at: ( - float | None - ) # Unix timestamp when refresh token expires (if known) - expires_at: float # Unix timestamp when access token expires - token_type: str # Usually "Bearer" - scope: str # Space-separated scopes - client_id: str # MCP client this is bound to - created_at: float # Unix timestamp - raw_token_data: dict[str, Any] = Field(default_factory=dict) # Full token response - - -class JTIMapping(BaseModel): - """Maps FastMCP token JTI to upstream token ID. - - This allows stateless JWT validation while still being able to look up - the corresponding upstream token when tools need to access upstream APIs. - """ - - jti: str # JWT ID from FastMCP-issued token - upstream_token_id: str # References UpstreamTokenSet - created_at: float # Unix timestamp - - -class RefreshTokenMetadata(BaseModel): - """Metadata for a refresh token, stored keyed by token hash. - - We store only metadata (not the token itself) for security - if storage - is compromised, attackers get hashes they can't reverse into usable tokens. - """ - - client_id: str - scopes: list[str] - expires_at: int | None = None - created_at: float - - -def _hash_token(token: str) -> str: - """Hash a token for secure storage lookup. - - Uses SHA-256 to create a one-way hash. The original token cannot be - recovered from the hash, providing defense in depth if storage is compromised. - """ - return hashlib.sha256(token.encode()).hexdigest() - - -class ProxyDCRClient(OAuthClientInformationFull): - """Client for DCR proxy with configurable redirect URI validation. - - This special client class is critical for the OAuth proxy to work correctly - with Dynamic Client Registration (DCR). Here's why it exists: - - Problem: - -------- - When MCP clients use OAuth, they dynamically register with random localhost - ports (e.g., http://localhost:55454/callback). The OAuth proxy needs to: - 1. Accept these dynamic redirect URIs from clients based on configured patterns - 2. Use its own fixed redirect URI with the upstream provider (Google, GitHub, etc.) - 3. Forward the authorization code back to the client's dynamic URI - - Solution: - --------- - This class validates redirect URIs against configurable patterns, - while the proxy internally uses its own fixed redirect URI with the upstream - provider. This allows the flow to work even when clients reconnect with - different ports or when tokens are cached. - - Without proper validation, clients could get "Redirect URI not registered" errors - when trying to authenticate with cached tokens, or security vulnerabilities could - arise from accepting arbitrary redirect URIs. - """ - - allowed_redirect_uri_patterns: list[str] | None = Field(default=None) - client_name: str | None = Field(default=None) - cimd_document: CIMDDocument | None = Field(default=None) - cimd_fetched_at: float | None = Field(default=None) - - def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl: - """Validate redirect URI against proxy patterns and optionally CIMD redirect_uris. - - For CIMD clients: validates against BOTH the CIMD document's redirect_uris - AND the proxy's allowed patterns (if configured). Both must pass. - - For DCR clients: validates against proxy patterns first, falling back to - base validation (registered redirect_uris) if patterns don't match. - """ - if redirect_uri is None and self.cimd_document is not None: - cimd_redirect_uris = self.cimd_document.redirect_uris - if len(cimd_redirect_uris) == 1: - candidate = cimd_redirect_uris[0] - if "*" in candidate: - raise InvalidRedirectUriError( - "redirect_uri must be specified when CIMD redirect_uris uses wildcards." - ) - try: - resolved = AnyUrl(candidate) - except Exception as e: - raise InvalidRedirectUriError( - f"Invalid CIMD redirect_uri: {e}" - ) from e - - # Respect proxy-level redirect URI restrictions even when the - # client omits redirect_uri and we fall back to CIMD defaults. - if ( - self.allowed_redirect_uri_patterns is not None - and not validate_redirect_uri( - redirect_uri=resolved, - allowed_patterns=self.allowed_redirect_uri_patterns, - ) - ): - raise InvalidRedirectUriError( - f"Redirect URI '{resolved}' does not match allowed patterns." - ) - - return resolved - - raise InvalidRedirectUriError( - "redirect_uri must be specified when CIMD lists multiple redirect_uris." - ) - - if redirect_uri is not None: - cimd_redirect_uris = ( - self.cimd_document.redirect_uris if self.cimd_document else None - ) - - if cimd_redirect_uris: - uri_str = str(redirect_uri) - cimd_match = any( - matches_allowed_pattern(uri_str, pattern) - for pattern in cimd_redirect_uris - ) - if not cimd_match: - raise InvalidRedirectUriError( - f"Redirect URI '{redirect_uri}' does not match CIMD redirect_uris." - ) - - if self.allowed_redirect_uri_patterns is not None: - if not validate_redirect_uri( - redirect_uri=redirect_uri, - allowed_patterns=self.allowed_redirect_uri_patterns, - ): - raise InvalidRedirectUriError( - f"Redirect URI '{redirect_uri}' does not match allowed patterns." - ) - - return redirect_uri - - pattern_matches = validate_redirect_uri( - redirect_uri=redirect_uri, - allowed_patterns=self.allowed_redirect_uri_patterns, - ) - - if pattern_matches: - return redirect_uri - - # Patterns configured but didn't match - if self.allowed_redirect_uri_patterns: - raise InvalidRedirectUriError( - f"Redirect URI '{redirect_uri}' does not match allowed patterns." - ) - - # No redirect_uri provided or no patterns configured — use base validation - return super().validate_redirect_uri(redirect_uri) diff --git a/src/fastmcp/server/auth/oauth_proxy/proxy.py b/src/fastmcp/server/auth/oauth_proxy/proxy.py deleted file mode 100644 index bcad01941..000000000 --- a/src/fastmcp/server/auth/oauth_proxy/proxy.py +++ /dev/null @@ -1,2084 +0,0 @@ -"""OAuth Proxy Provider for FastMCP. - -This provider acts as a transparent proxy to an upstream OAuth Authorization Server, -handling Dynamic Client Registration locally while forwarding all other OAuth flows. -This enables authentication with upstream providers that don't support DCR or have -restricted client registration policies. - -Key features: -- Proxies authorization and token endpoints to upstream server -- Implements local Dynamic Client Registration with fixed upstream credentials -- Validates tokens using upstream JWKS -- Maintains minimal local state for bookkeeping -- Enhanced logging with request correlation - -This implementation is based on the OAuth 2.1 specification and is designed for -production use with enterprise identity providers. -""" - -from __future__ import annotations - -import hashlib -import secrets -import time -from base64 import urlsafe_b64encode -from typing import Any, Literal -from urllib.parse import urlencode, urlparse, urlunparse - -import anyio -import httpx -from authlib.common.security import generate_token -from authlib.integrations.httpx_client import AsyncOAuth2Client -from cryptography.fernet import Fernet -from key_value.aio.adapters.pydantic import PydanticAdapter -from key_value.aio.protocols import AsyncKeyValue -from key_value.aio.stores.filetree import ( - FileTreeStore, - FileTreeV1CollectionSanitizationStrategy, - FileTreeV1KeySanitizationStrategy, -) -from key_value.aio.wrappers.encryption import FernetEncryptionWrapper -from mcp.server.auth.handlers.metadata import MetadataHandler -from mcp.server.auth.provider import ( - AccessToken, - AuthorizationCode, - AuthorizationParams, - AuthorizeError, - RefreshToken, - TokenError, -) -from mcp.server.auth.routes import build_metadata, cors_middleware -from mcp.server.auth.settings import ( - ClientRegistrationOptions, - RevocationOptions, -) -from mcp.shared.auth import OAuthClientInformationFull, OAuthToken -from pydantic import AnyHttpUrl, AnyUrl, SecretStr -from starlette.requests import Request -from starlette.responses import HTMLResponse, RedirectResponse -from starlette.routing import Route -from typing_extensions import override - -from fastmcp import settings -from fastmcp.server.auth.auth import ( - OAuthProvider, - PrivateKeyJWTClientAuthenticator, - TokenHandler, - TokenVerifier, -) -from fastmcp.server.auth.cimd import CIMDClientManager -from fastmcp.server.auth.handlers.authorize import AuthorizationHandler -from fastmcp.server.auth.jwt_issuer import ( - JWTIssuer, - derive_jwt_key, -) -from fastmcp.server.auth.oauth_proxy.consent import ConsentMixin -from fastmcp.server.auth.oauth_proxy.models import ( - DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS, - DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS, - DEFAULT_AUTH_CODE_EXPIRY_SECONDS, - HTTP_TIMEOUT_SECONDS, - ClientCode, - JTIMapping, - OAuthTransaction, - ProxyDCRClient, - RefreshTokenMetadata, - UpstreamTokenSet, - _hash_token, -) -from fastmcp.server.auth.oauth_proxy.ui import create_error_html -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -def _normalize_resource_url(url: str) -> str: - """Normalize a resource URL by removing query parameters and trailing slashes. - - RFC 8707 allows clients to include query parameters in resource URLs, but the - server's configured resource URL typically doesn't include them. This function - normalizes URLs for comparison by stripping query params and fragments. - - Args: - url: The URL to normalize - - Returns: - Normalized URL with scheme, host, and path only (no query/fragment) - """ - parsed = urlparse(str(url)) - return urlunparse( - (parsed.scheme, parsed.netloc, parsed.path.rstrip("/"), "", "", "") - ) - - -def _server_url_has_query(url: str) -> bool: - """Check if a URL has query parameters.""" - return bool(urlparse(str(url)).query) - - -class OAuthProxy(OAuthProvider, ConsentMixin): - """OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs. - - Purpose - ------- - MCP clients expect OAuth providers to support Dynamic Client Registration (DCR), - where clients can register themselves dynamically and receive unique credentials. - Most enterprise IDPs (Google, GitHub, Azure AD, etc.) don't support DCR and require - pre-registered OAuth applications with fixed credentials. - - This proxy bridges that gap by: - - Presenting a full DCR-compliant OAuth interface to MCP clients - - Translating DCR registration requests to use pre-configured upstream credentials - - Proxying all OAuth flows to the upstream IDP with appropriate translations - - Managing the state and security requirements of both protocols - - Architecture Overview - -------------------- - The proxy maintains a single OAuth app registration with the upstream provider - while allowing unlimited MCP clients to register and authenticate dynamically. - It implements the complete OAuth 2.1 + DCR specification for clients while - translating to whatever OAuth variant the upstream provider requires. - - Key Translation Challenges Solved - --------------------------------- - 1. Dynamic Client Registration: - - MCP clients expect to register dynamically and get unique credentials - - Upstream IDPs require pre-registered apps with fixed credentials - - Solution: Accept DCR requests, return shared upstream credentials - - 2. Dynamic Redirect URIs: - - MCP clients use random localhost ports that change between sessions - - Upstream IDPs require fixed, pre-registered redirect URIs - - Solution: Use proxy's fixed callback URL with upstream, forward to client's dynamic URI - - 3. Authorization Code Mapping: - - Upstream returns codes for the proxy's redirect URI - - Clients expect codes for their own redirect URIs - - Solution: Exchange upstream code server-side, issue new code to client - - 4. State Parameter Collision: - - Both client and proxy need to maintain state through the flow - - Only one state parameter available in OAuth - - Solution: Use transaction ID as state with upstream, preserve client's state - - 5. Token Management: - - Clients may expect different token formats/claims than upstream provides - - Need to track tokens for revocation and refresh - - Solution: Store token relationships, forward upstream tokens transparently - - OAuth Flow Implementation - ------------------------ - 1. Client Registration (DCR): - - Accept any client registration request - - Store ProxyDCRClient that accepts dynamic redirect URIs - - 2. Authorization: - - Store transaction mapping client details to proxy flow - - Redirect to upstream with proxy's fixed redirect URI - - Use transaction ID as state parameter with upstream - - 3. Upstream Callback: - - Exchange upstream authorization code for tokens (server-side) - - Generate new authorization code bound to client's PKCE challenge - - Redirect to client's original dynamic redirect URI - - 4. Token Exchange: - - Validate client's code and PKCE verifier - - Return previously obtained upstream tokens - - Clean up one-time use authorization code - - 5. Token Refresh: - - Forward refresh requests to upstream using authlib - - Handle token rotation if upstream issues new refresh token - - Update local token mappings - - State Management - --------------- - The proxy maintains minimal but crucial state via pluggable storage (client_storage): - - _oauth_transactions: Active authorization flows with client context - - _client_codes: Authorization codes with PKCE challenges and upstream tokens - - _jti_mapping_store: Maps FastMCP token JTIs to upstream token IDs - - _refresh_token_store: Refresh token metadata (keyed by token hash) - - All state is stored in the configured client_storage backend (Redis, disk, etc.) - enabling horizontal scaling across multiple instances. - - Security Considerations - ---------------------- - - Refresh tokens stored by hash only (defense in depth if storage compromised) - - PKCE enforced end-to-end (client to proxy, proxy to upstream) - - Authorization codes are single-use with short expiry - - Transaction IDs are cryptographically random - - All state is cleaned up after use to prevent replay - - Token validation delegates to upstream provider - - Provider Compatibility - --------------------- - Works with any OAuth 2.0 provider that supports: - - Authorization code flow - - Fixed redirect URI (configured in provider's app settings) - - Standard token endpoint - - Handles provider-specific requirements: - - Google: Ensures minimum scope requirements - - GitHub: Compatible with OAuth Apps and GitHub Apps - - Azure AD: Handles tenant-specific endpoints - - Generic: Works with any spec-compliant provider - """ - - def __init__( - self, - *, - # Upstream server configuration - upstream_authorization_endpoint: str, - upstream_token_endpoint: str, - upstream_client_id: str, - upstream_client_secret: str | None = None, - upstream_revocation_endpoint: str | None = None, - # Token validation - token_verifier: TokenVerifier, - # FastMCP server configuration - base_url: AnyHttpUrl | str, - redirect_path: str | None = None, - issuer_url: AnyHttpUrl | str | None = None, - service_documentation_url: AnyHttpUrl | str | None = None, - # Client redirect URI validation - allowed_client_redirect_uris: list[str] | None = None, - valid_scopes: list[str] | None = None, - # PKCE configuration - forward_pkce: bool = True, - # Resource indicator (RFC 8707) - forward_resource: bool = True, - # Token endpoint authentication - token_endpoint_auth_method: str | None = None, - # Extra parameters to forward to authorization endpoint - extra_authorize_params: dict[str, str] | None = None, - # Extra parameters to forward to token endpoint - extra_token_params: dict[str, str] | None = None, - # Client storage - client_storage: AsyncKeyValue | None = None, - # JWT signing key - jwt_signing_key: str | bytes | None = None, - # Consent screen configuration - require_authorization_consent: bool | Literal["external"] = True, - consent_csp_policy: str | None = None, - # Token expiry fallback - fallback_access_token_expiry_seconds: int | None = None, - # CIMD (Client ID Metadata Document) support - enable_cimd: bool = True, - ): - """Initialize the OAuth proxy provider. - - Args: - upstream_authorization_endpoint: URL of upstream authorization endpoint - upstream_token_endpoint: URL of upstream token endpoint - upstream_client_id: Client ID registered with upstream server - upstream_client_secret: Client secret for upstream server. Optional for - PKCE public clients or when using alternative credentials (e.g., - managed identity). When omitted, jwt_signing_key must be provided. - upstream_revocation_endpoint: Optional upstream revocation endpoint - token_verifier: Token verifier for validating access tokens - base_url: Public URL of the server that exposes this FastMCP server; redirect path is - relative to this URL - redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback") - issuer_url: Issuer URL for OAuth metadata (defaults to base_url) - service_documentation_url: Optional service documentation URL - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - Patterns support wildcards (e.g., "http://localhost:*", "https://*.example.com/*"). - If None (default), all redirect URIs are allowed (for DCR compatibility). - If empty list, no redirect URIs are allowed. - These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app. - valid_scopes: List of all the possible valid scopes for a client. - These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` if not provided. - forward_pkce: Whether to forward PKCE to upstream server (default True). - Enable for providers that support/require PKCE (Google, Azure, AWS, etc.). - Disable only if upstream provider doesn't support PKCE. - token_endpoint_auth_method: Token endpoint authentication method for upstream server. - Common values: "client_secret_basic", "client_secret_post", "none". - If None, authlib will use its default (typically "client_secret_basic"). - extra_authorize_params: Additional parameters to forward to the upstream authorization endpoint. - Useful for provider-specific parameters like Auth0's "audience". - Example: {"audience": "https://api.example.com"} - extra_token_params: Additional parameters to forward to the upstream token endpoint. - Useful for provider-specific parameters during token exchange. - client_storage: Storage backend for OAuth state (client registrations, tokens). - If None, an encrypted file store will be created in the data directory. - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). - If bytes are provided, they will be used as-is. - If a string is provided, it will be derived into a 32-byte key using PBKDF2 (1.2M iterations). - If not provided, it will be derived from the upstream client secret using HKDF. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to the upstream IdP. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - consent_csp_policy: Content Security Policy for the consent page. - If None (default), uses the built-in CSP policy with appropriate directives. - If empty string "", disables CSP entirely (no meta tag is rendered). - If a non-empty string, uses that as the CSP policy value. - This allows organizations with their own CSP policies to override or disable - the built-in CSP directives. - fallback_access_token_expiry_seconds: Expiry time to use when upstream provider - doesn't return `expires_in` in the token response. If not set, uses smart - defaults: 1 hour if a refresh token is available (since we can refresh), - or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps). - Set explicitly to override these defaults. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs. When True, clients can authenticate using HTTPS URLs as client - IDs, with metadata fetched from the URL. Supports private_key_jwt auth. - """ - - # Always enable DCR since we implement it locally for MCP clients - client_registration_options = ClientRegistrationOptions( - enabled=True, - valid_scopes=valid_scopes or token_verifier.required_scopes, - ) - - # Enable revocation only if upstream endpoint provided - revocation_options = ( - RevocationOptions(enabled=True) if upstream_revocation_endpoint else None - ) - - super().__init__( - base_url=base_url, - issuer_url=issuer_url, - service_documentation_url=service_documentation_url, - client_registration_options=client_registration_options, - revocation_options=revocation_options, - required_scopes=token_verifier.required_scopes, - ) - - # Store upstream configuration - self._upstream_authorization_endpoint: str = upstream_authorization_endpoint - self._upstream_token_endpoint: str = upstream_token_endpoint - self._upstream_client_id: str = upstream_client_id - self._upstream_client_secret: SecretStr | None = ( - SecretStr(secret_value=upstream_client_secret) - if upstream_client_secret is not None - else None - ) - self._upstream_revocation_endpoint: str | None = upstream_revocation_endpoint - self._default_scope_str: str = " ".join(self.required_scopes or []) - - # Store redirect configuration - if not redirect_path: - self._redirect_path = "/auth/callback" - else: - self._redirect_path = ( - redirect_path if redirect_path.startswith("/") else f"/{redirect_path}" - ) - - if ( - isinstance(allowed_client_redirect_uris, list) - and not allowed_client_redirect_uris - ): - logger.warning( - "allowed_client_redirect_uris is empty list; no redirect URIs will be accepted. " - + "This will block all OAuth clients." - ) - self._allowed_client_redirect_uris: list[str] | None = ( - allowed_client_redirect_uris - ) - - # PKCE configuration - self._forward_pkce: bool = forward_pkce - # Resource indicator (RFC 8707) - self._forward_resource: bool = forward_resource - - # Token endpoint authentication - self._token_endpoint_auth_method: str | None = token_endpoint_auth_method - - # Consent screen configuration - self._require_authorization_consent: bool | Literal["external"] = ( - require_authorization_consent - ) - self._consent_csp_policy: str | None = consent_csp_policy - if require_authorization_consent == "external": - logger.info( - "Built-in consent screen disabled; consent is handled externally." - ) - elif not require_authorization_consent: - logger.warning( - "Authorization consent screen disabled - only use for local development or testing. " - + "In production, this screen protects against confused deputy attacks." - ) - - # Extra parameters for authorization and token endpoints - self._extra_authorize_params: dict[str, str] = extra_authorize_params or {} - self._extra_token_params: dict[str, str] = extra_token_params or {} - - # Token expiry fallback (None means use smart default based on refresh token) - self._fallback_access_token_expiry_seconds: int | None = ( - fallback_access_token_expiry_seconds - ) - - if jwt_signing_key is None: - if upstream_client_secret is None: - raise ValueError( - "jwt_signing_key is required when upstream_client_secret is not provided. " - "The JWT signing key cannot be derived without a client secret." - ) - jwt_signing_key = derive_jwt_key( - high_entropy_material=upstream_client_secret, - salt="fastmcp-jwt-signing-key", - ) - - if isinstance(jwt_signing_key, str): - if len(jwt_signing_key) < 12: - logger.warning( - "jwt_signing_key is less than 12 characters; it is recommended to use a longer. " - + "string for the key derivation." - ) - jwt_signing_key = derive_jwt_key( - low_entropy_material=jwt_signing_key, - salt="fastmcp-jwt-signing-key", - ) - - # Store JWT signing key for deferred JWTIssuer creation in set_mcp_path() - self._jwt_signing_key: bytes = jwt_signing_key - # JWTIssuer will be created in set_mcp_path() with correct audience - self._jwt_issuer: JWTIssuer | None = None - - # If the user does not provide a store, we will provide an encrypted file store. - # The storage directory is derived from the encryption key so that different - # keys get isolated directories (e.g. two servers on the same machine with - # different keys won't collide). Decryption errors are treated as cache misses - # rather than hard failures, so key rotation just causes re-registration. - if client_storage is None: - storage_encryption_key = derive_jwt_key( - high_entropy_material=jwt_signing_key.decode(), - salt="fastmcp-storage-encryption-key", - ) - - key_fingerprint = hashlib.sha256(storage_encryption_key).hexdigest()[:12] - storage_dir = settings.home / "oauth-proxy" / key_fingerprint - storage_dir.mkdir(parents=True, exist_ok=True) - - file_store = FileTreeStore( - data_directory=storage_dir, - key_sanitization_strategy=FileTreeV1KeySanitizationStrategy( - storage_dir - ), - collection_sanitization_strategy=FileTreeV1CollectionSanitizationStrategy( - storage_dir - ), - ) - - client_storage = FernetEncryptionWrapper( - key_value=file_store, - fernet=Fernet(key=storage_encryption_key), - raise_on_decryption_error=False, - ) - - self._client_storage: AsyncKeyValue = client_storage - - # Cache HTTPS check to avoid repeated logging - self._is_https: bool = str(self.base_url).startswith("https://") - if not self._is_https: - logger.warning( - "Using non-secure cookies for development; deploy with HTTPS for production." - ) - - self._upstream_token_store: PydanticAdapter[UpstreamTokenSet] = PydanticAdapter[ - UpstreamTokenSet - ]( - key_value=self._client_storage, - pydantic_model=UpstreamTokenSet, - default_collection="mcp-upstream-tokens", - raise_on_validation_error=True, - ) - - self._client_store: PydanticAdapter[ProxyDCRClient] = PydanticAdapter[ - ProxyDCRClient - ]( - key_value=self._client_storage, - pydantic_model=ProxyDCRClient, - default_collection="mcp-oauth-proxy-clients", - raise_on_validation_error=True, - ) - - # OAuth transaction storage for IdP callback forwarding - # Reuse client_storage with different collections for state management - self._transaction_store: PydanticAdapter[OAuthTransaction] = PydanticAdapter[ - OAuthTransaction - ]( - key_value=self._client_storage, - pydantic_model=OAuthTransaction, - default_collection="mcp-oauth-transactions", - raise_on_validation_error=True, - ) - - self._code_store: PydanticAdapter[ClientCode] = PydanticAdapter[ClientCode]( - key_value=self._client_storage, - pydantic_model=ClientCode, - default_collection="mcp-authorization-codes", - raise_on_validation_error=True, - ) - - # Storage for JTI mappings (FastMCP token -> upstream token) - self._jti_mapping_store: PydanticAdapter[JTIMapping] = PydanticAdapter[ - JTIMapping - ]( - key_value=self._client_storage, - pydantic_model=JTIMapping, - default_collection="mcp-jti-mappings", - raise_on_validation_error=True, - ) - - # Refresh token metadata storage, keyed by token hash for security. - # We only store metadata (not the token itself) - if storage is compromised, - # attackers get hashes they can't reverse into usable tokens. - self._refresh_token_store: PydanticAdapter[RefreshTokenMetadata] = ( - PydanticAdapter[RefreshTokenMetadata]( - key_value=self._client_storage, - pydantic_model=RefreshTokenMetadata, - default_collection="mcp-refresh-tokens", - raise_on_validation_error=True, - ) - ) - - # Use the provided token validator - self._token_validator: TokenVerifier = token_verifier - - # CIMD (Client ID Metadata Document) support - self._cimd_manager: CIMDClientManager | None = None - if enable_cimd: - self._cimd_manager = CIMDClientManager( - enable_cimd=True, - default_scope=self._default_scope_str, - allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, - ) - - # Advisory locks for transparent upstream token refresh, keyed by - # upstream_token_id. Prevents concurrent async tasks from racing to - # refresh the same token within a single process. Does not protect - # against cross-process races in distributed deployments — those are - # handled by re-reading from storage after refresh failure. - self._refresh_locks: dict[str, anyio.Lock] = {} - - logger.debug( - "Initialized OAuth proxy provider with upstream server %s", - self._upstream_authorization_endpoint, - ) - - # ------------------------------------------------------------------------- - # MCP Path Configuration - # ------------------------------------------------------------------------- - - def set_mcp_path(self, mcp_path: str | None) -> None: - """Set the MCP endpoint path and create JWTIssuer with correct audience. - - This method is called by get_routes() to configure the resource URL - and create the JWTIssuer. The JWT audience is set to the full resource - URL (e.g., http://localhost:8000/mcp) to ensure tokens are bound to - this specific MCP endpoint. - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - """ - super().set_mcp_path(mcp_path) - - # Create JWT issuer with correct audience based on actual MCP path - # This ensures tokens are bound to the specific resource URL - self._jwt_issuer = JWTIssuer( - issuer=str(self.base_url), - audience=str(self._resource_url), - signing_key=self._jwt_signing_key, - ) - - logger.debug("Configured OAuth proxy for resource URL: %s", self._resource_url) - - @property - def jwt_issuer(self) -> JWTIssuer: - """Get the JWT issuer, ensuring it has been initialized. - - The JWT issuer is created when set_mcp_path() is called (via get_routes()). - This property ensures a clear error if used before initialization. - """ - if self._jwt_issuer is None: - raise RuntimeError( - "JWT issuer not initialized. Ensure get_routes() is called " - "before token operations." - ) - return self._jwt_issuer - - # ------------------------------------------------------------------------- - # Upstream OAuth Client - # ------------------------------------------------------------------------- - - def _create_upstream_oauth_client(self) -> AsyncOAuth2Client: - """Create an OAuth2 client for communicating with the upstream IdP. - - This is the single point for constructing the client used in token - exchange, refresh, and other upstream interactions. Subclasses can - override this to provide alternative authentication methods (e.g., - managed-identity client assertions instead of a static client secret). - """ - return AsyncOAuth2Client( - client_id=self._upstream_client_id, - client_secret=( - self._upstream_client_secret.get_secret_value() - if self._upstream_client_secret is not None - else None - ), - token_endpoint_auth_method=self._token_endpoint_auth_method, - timeout=HTTP_TIMEOUT_SECONDS, - ) - - # ------------------------------------------------------------------------- - # PKCE Helper Methods - # ------------------------------------------------------------------------- - - def _generate_pkce_pair(self) -> tuple[str, str]: - """Generate PKCE code verifier and challenge pair. - - Returns: - Tuple of (code_verifier, code_challenge) using S256 method - """ - # Generate code verifier: 43-128 characters from unreserved set - code_verifier = generate_token(48) - - # Generate code challenge using S256 (SHA256 + base64url) - challenge_bytes = hashlib.sha256(code_verifier.encode()).digest() - code_challenge = urlsafe_b64encode(challenge_bytes).decode().rstrip("=") - - return code_verifier, code_challenge - - # ------------------------------------------------------------------------- - # Client Registration (Local Implementation) - # ------------------------------------------------------------------------- - - @override - async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: - """Get client information by ID. This is generally the random ID - provided to the DCR client during registration, not the upstream client ID. - - For unregistered clients, returns None (which will raise an error in the SDK). - CIMD clients (URL-based client IDs) are looked up and cached automatically. - """ - # Load from storage - client = await self._client_store.get(key=client_id) - - if client is not None: - if client.allowed_redirect_uri_patterns is None: - client.allowed_redirect_uri_patterns = ( - self._allowed_client_redirect_uris - ) - - # Refresh CIMD clients using HTTP cache-aware fetcher. - if self._cimd_manager is not None and client.cimd_document is not None: - try: - refreshed = await self._cimd_manager.get_client(client_id) - if refreshed is not None: - await self._client_store.put(key=client_id, value=refreshed) - return refreshed - except Exception as e: - logger.debug( - "CIMD refresh failed for %s, using cached client: %s", - client_id, - e, - ) - - return client - - # Client not in storage — try CIMD lookup for URL-based client IDs - if self._cimd_manager is not None and self._cimd_manager.is_cimd_client_id( - client_id - ): - cimd_client = await self._cimd_manager.get_client(client_id) - if cimd_client is not None: - await self._client_store.put(key=client_id, value=cimd_client) - return cimd_client - - return None - - @override - async def register_client(self, client_info: OAuthClientInformationFull) -> None: - """Register a client locally - - When a client registers, we create a ProxyDCRClient that is more - forgiving about validating redirect URIs, since the DCR client's - redirect URI will likely be localhost or unknown to the proxied IDP. The - proxied IDP only knows about this server's fixed redirect URI. - """ - - # Create a ProxyDCRClient with configured redirect URI validation - if client_info.client_id is None: - raise ValueError("client_id is required for client registration") - # We use token_endpoint_auth_method="none" because the proxy handles - # all upstream authentication. The client_secret must also be None - # because the SDK requires secrets to be provided if they're set, - # regardless of auth method. - proxy_client: ProxyDCRClient = ProxyDCRClient( - client_id=client_info.client_id, - client_secret=None, - redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")], - grant_types=client_info.grant_types - or ["authorization_code", "refresh_token"], - scope=client_info.scope or self._default_scope_str, - token_endpoint_auth_method="none", - allowed_redirect_uri_patterns=self._allowed_client_redirect_uris, - client_name=getattr(client_info, "client_name", None), - ) - - await self._client_store.put( - key=client_info.client_id, - value=proxy_client, - ) - - # Log redirect URIs to help users discover what patterns they might need - if client_info.redirect_uris: - for uri in client_info.redirect_uris: - logger.debug( - "Client registered with redirect_uri: %s - if restricting redirect URIs, " - "ensure this pattern is allowed in allowed_client_redirect_uris", - uri, - ) - - logger.debug( - "Registered client %s with %d redirect URIs", - client_info.client_id, - len(proxy_client.redirect_uris) if proxy_client.redirect_uris else 0, - ) - - # ------------------------------------------------------------------------- - # Authorization Flow (Proxy to Upstream) - # ------------------------------------------------------------------------- - - @override - async def authorize( - self, - client: OAuthClientInformationFull, - params: AuthorizationParams, - ) -> str: - """Start OAuth transaction and route through consent interstitial. - - Flow: - 1. Validate client's resource matches server's resource URL (security check) - 2. Store transaction with client details and PKCE (if forwarding) - 3. Return local /consent URL; browser visits consent first - 4. Consent handler redirects to upstream IdP if approved/already approved - - If consent is disabled (require_authorization_consent=False), skip the consent screen - and redirect directly to the upstream IdP. - """ - # Security check: validate client's requested resource matches this server - # This prevents tokens intended for one server from being used on another - # - # Per RFC 8707, clients may include query parameters in resource URLs (e.g., - # ChatGPT sends ?kb_name=X). We handle two cases: - # - # 1. Server URL has NO query params: normalize both URLs (strip query/fragment) - # to allow clients like ChatGPT that add query params to still match. - # - # 2. Server URL HAS query params (e.g., multi-tenant ?tenant=X): require exact - # match to prevent clients from bypassing tenant isolation by changing params. - # - # Claude doesn't send a resource parameter at all, so this check is skipped. - client_resource = getattr(params, "resource", None) - if client_resource and self._resource_url: - server_url = str(self._resource_url) - client_url = str(client_resource) - - if _server_url_has_query(server_url): - # Server has query params - require exact match for security - urls_match = client_url.rstrip("/") == server_url.rstrip("/") - else: - # Server has no query params - normalize both for comparison - urls_match = _normalize_resource_url( - client_url - ) == _normalize_resource_url(server_url) - - if not urls_match: - logger.warning( - "Resource mismatch: client requested %s but server is %s", - client_resource, - self._resource_url, - ) - raise AuthorizeError( - error="invalid_target", # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - error_description="Resource does not match this server", - ) - - # Generate transaction ID for this authorization request - txn_id = secrets.token_urlsafe(32) - - # Generate proxy's own PKCE parameters if forwarding is enabled - proxy_code_verifier = None - proxy_code_challenge = None - if self._forward_pkce and params.code_challenge: - proxy_code_verifier, proxy_code_challenge = self._generate_pkce_pair() - logger.debug( - "Generated proxy PKCE for transaction %s (forwarding client PKCE to upstream)", - txn_id, - ) - - # Store transaction data for IdP callback processing - if client.client_id is None: - raise AuthorizeError( - error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type # ty:ignore[invalid-argument-type] - error_description="Client ID is required", - ) - transaction = OAuthTransaction( - txn_id=txn_id, - client_id=client.client_id, - client_redirect_uri=str(params.redirect_uri), - client_state=params.state or "", - code_challenge=params.code_challenge, - code_challenge_method=getattr(params, "code_challenge_method", "S256"), - scopes=params.scopes or [], - created_at=time.time(), - resource=getattr(params, "resource", None), - proxy_code_verifier=proxy_code_verifier, - ) - await self._transaction_store.put( - key=txn_id, - value=transaction, - ttl=15 * 60, # Auto-expire after 15 minutes - ) - - # If consent is disabled or handled externally, skip consent screen - if self._require_authorization_consent is not True: - upstream_url = self._build_upstream_authorize_url( - txn_id, transaction.model_dump() - ) - logger.debug( - "Starting OAuth transaction %s for client %s, redirecting directly to upstream IdP (consent disabled, PKCE forwarding: %s)", - txn_id, - client.client_id, - "enabled" if proxy_code_challenge else "disabled", - ) - return upstream_url - - consent_url = f"{str(self.base_url).rstrip('/')}/consent?txn_id={txn_id}" - - logger.debug( - "Starting OAuth transaction %s for client %s, redirecting to consent page (PKCE forwarding: %s)", - txn_id, - client.client_id, - "enabled" if proxy_code_challenge else "disabled", - ) - return consent_url - - # ------------------------------------------------------------------------- - # Authorization Code Handling - # ------------------------------------------------------------------------- - - @override - async def load_authorization_code( - self, - client: OAuthClientInformationFull, - authorization_code: str, - ) -> AuthorizationCode | None: - """Load authorization code for validation. - - Look up our client code and return authorization code object - with PKCE challenge for validation. - """ - # Look up client code data - code_model = await self._code_store.get(key=authorization_code) - if not code_model: - logger.debug("Authorization code not found: %s", authorization_code) - return None - - # Check if code expired - if time.time() > code_model.expires_at: - logger.debug("Authorization code expired: %s", authorization_code) - _ = await self._code_store.delete(key=authorization_code) - return None - - # Verify client ID matches - if code_model.client_id != client.client_id: - logger.debug( - "Authorization code client ID mismatch: %s vs %s", - code_model.client_id, - client.client_id, - ) - return None - - # Create authorization code object with PKCE challenge - if client.client_id is None: - raise AuthorizeError( - error="invalid_client", # type: ignore[arg-type] # "invalid_client" is valid OAuth error but not in Literal type # ty:ignore[invalid-argument-type] - error_description="Client ID is required", - ) - return AuthorizationCode( - code=authorization_code, - client_id=client.client_id, - redirect_uri=AnyUrl(url=code_model.redirect_uri), - redirect_uri_provided_explicitly=True, - scopes=code_model.scopes, - expires_at=code_model.expires_at, - code_challenge=code_model.code_challenge or "", - ) - - @override - async def exchange_authorization_code( - self, - client: OAuthClientInformationFull, - authorization_code: AuthorizationCode, - ) -> OAuthToken: - """Exchange authorization code for FastMCP-issued tokens. - - Implements the token factory pattern: - 1. Retrieves upstream tokens from stored authorization code - 2. Extracts user identity from upstream token - 3. Encrypts and stores upstream tokens - 4. Issues FastMCP-signed JWT tokens - 5. Returns FastMCP tokens (NOT upstream tokens) - - PKCE validation is handled by the MCP framework before this method is called. - """ - # Look up stored code data - code_model = await self._code_store.get(key=authorization_code.code) - if not code_model: - logger.error( - "Authorization code not found in client codes: %s", - authorization_code.code, - ) - raise TokenError("invalid_grant", "Authorization code not found") - - # Get stored upstream tokens - idp_tokens = code_model.idp_tokens - - # Use IdP-granted scopes when available (RFC 6749 §5.1: the IdP MUST - # include a scope parameter when the granted scope differs from the - # requested scope). Fall back to requested scopes only when the IdP - # omits scope, meaning it granted exactly what was requested. - granted_scopes: list[str] = ( - parse_scopes(idp_tokens["scope"]) or [] - if "scope" in idp_tokens - else list(authorization_code.scopes) - ) - - # Clean up client code (one-time use) - await self._code_store.delete(key=authorization_code.code) - - # Generate IDs for token storage - upstream_token_id = secrets.token_urlsafe(32) - access_jti = secrets.token_urlsafe(32) - refresh_jti = ( - secrets.token_urlsafe(32) if idp_tokens.get("refresh_token") else None - ) - - # Calculate token expiry times - # If upstream provides expires_in, use it. Otherwise use fallback based on: - # - User-provided fallback if set - # - 1 hour if refresh token available (can refresh when expired) - # - 1 year if no refresh token (likely API-key-style token like GitHub OAuth Apps) - if "expires_in" in idp_tokens: - expires_in = int(idp_tokens["expires_in"]) - logger.debug( - "Access token TTL: %d seconds (from IdP expires_in)", expires_in - ) - elif self._fallback_access_token_expiry_seconds is not None: - expires_in = self._fallback_access_token_expiry_seconds - logger.debug( - "Access token TTL: %d seconds (using configured fallback)", expires_in - ) - elif idp_tokens.get("refresh_token"): - expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS - logger.debug( - "Access token TTL: %d seconds (default, has refresh token)", expires_in - ) - else: - expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_NO_REFRESH_SECONDS - logger.debug( - "Access token TTL: %d seconds (default, no refresh token)", expires_in - ) - - # Calculate refresh token expiry if provided by upstream - # Some providers include refresh_expires_in, some don't - refresh_expires_in = None - refresh_token_expires_at = None - if idp_tokens.get("refresh_token"): - if "refresh_expires_in" in idp_tokens and int( - idp_tokens["refresh_expires_in"] - ): - refresh_expires_in = int(idp_tokens["refresh_expires_in"]) - refresh_token_expires_at = time.time() + refresh_expires_in - logger.debug( - "Upstream refresh token expires in %d seconds", refresh_expires_in - ) - else: - # Default to 30 days if upstream doesn't specify - # This is conservative - most providers use longer expiry - refresh_expires_in = 60 * 60 * 24 * 30 # 30 days - refresh_token_expires_at = time.time() + refresh_expires_in - logger.debug( - "Upstream refresh token expiry unknown, using 30-day default" - ) - - # Encrypt and store upstream tokens - upstream_token_set = UpstreamTokenSet( - upstream_token_id=upstream_token_id, - access_token=idp_tokens["access_token"], - refresh_token=idp_tokens["refresh_token"] - if idp_tokens.get("refresh_token") - else None, - refresh_token_expires_at=refresh_token_expires_at, - expires_at=time.time() + expires_in, - token_type=idp_tokens.get("token_type", "Bearer"), - scope=" ".join(granted_scopes), - client_id=client.client_id or "", - created_at=time.time(), - raw_token_data=idp_tokens, - ) - await self._upstream_token_store.put( - key=upstream_token_id, - value=upstream_token_set, - ttl=max( - refresh_expires_in or 0, expires_in, 1 - ), # Keep until longest-lived token expires (min 1s for safety) - ) - logger.debug("Stored encrypted upstream tokens (jti=%s)", access_jti[:8]) - - # Extract upstream claims to embed in FastMCP JWT (if subclass implements) - upstream_claims = await self._extract_upstream_claims(idp_tokens) - - # Issue minimal FastMCP access token (just a reference via JTI) - if client.client_id is None: - raise TokenError("invalid_client", "Client ID is required") - fastmcp_access_token = self.jwt_issuer.issue_access_token( - client_id=client.client_id, - scopes=granted_scopes, - jti=access_jti, - expires_in=expires_in, - upstream_claims=upstream_claims, - ) - - # Issue minimal FastMCP refresh token if upstream provided one - # Use upstream refresh token expiry to align lifetimes - fastmcp_refresh_token = None - if refresh_jti and refresh_expires_in: - fastmcp_refresh_token = self.jwt_issuer.issue_refresh_token( - client_id=client.client_id, - scopes=granted_scopes, - jti=refresh_jti, - expires_in=refresh_expires_in, - upstream_claims=upstream_claims, - ) - - # Store JTI mappings - await self._jti_mapping_store.put( - key=access_jti, - value=JTIMapping( - jti=access_jti, - upstream_token_id=upstream_token_id, - created_at=time.time(), - ), - ttl=expires_in, # Auto-expire with access token - ) - if refresh_jti: - await self._jti_mapping_store.put( - key=refresh_jti, - value=JTIMapping( - jti=refresh_jti, - upstream_token_id=upstream_token_id, - created_at=time.time(), - ), - ttl=60 * 60 * 24 * 30, # Auto-expire with refresh token (30 days) - ) - - # Store refresh token metadata (keyed by hash for security) - if fastmcp_refresh_token and refresh_expires_in: - await self._refresh_token_store.put( - key=_hash_token(fastmcp_refresh_token), - value=RefreshTokenMetadata( - client_id=client.client_id, - scopes=granted_scopes, - expires_at=int(time.time()) + refresh_expires_in, - created_at=time.time(), - ), - ttl=refresh_expires_in, - ) - - logger.debug( - "Issued FastMCP tokens for client=%s (access_jti=%s, refresh_jti=%s)", - client.client_id, - access_jti[:8], - refresh_jti[:8] if refresh_jti else "none", - ) - - # Return FastMCP-issued tokens (NOT upstream tokens!) - return OAuthToken( - access_token=fastmcp_access_token, - token_type="Bearer", - expires_in=expires_in, - refresh_token=fastmcp_refresh_token, - scope=" ".join(granted_scopes), - ) - - # ------------------------------------------------------------------------- - # Refresh Token Flow - # ------------------------------------------------------------------------- - - def _prepare_scopes_for_token_exchange(self, scopes: list[str]) -> list[str]: - """Prepare scopes for initial token exchange (auth code -> tokens). - - Override this method to provide scopes during the authorization - code exchange. Some providers (like Azure) require scopes to be sent. - - Args: - scopes: Scopes from the authorization request - - Returns: - List of scopes to send, or empty list to omit scope parameter - """ - return scopes - - def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]: - """Prepare scopes for upstream token refresh request. - - Override this method to transform scopes before sending to upstream provider. - For example, Azure needs to prefix scopes and add additional Graph scopes. - - The scopes parameter represents what should be stored in the RefreshToken. - This method returns what should be sent to the upstream provider. - - Args: - scopes: Base scopes that will be stored in RefreshToken - - Returns: - Scopes to send to upstream provider (may be transformed/augmented) - """ - return scopes - - async def _extract_upstream_claims( - self, idp_tokens: dict[str, Any] - ) -> dict[str, Any] | None: - """Extract upstream claims to embed in FastMCP JWT. - - Override this method to decode upstream tokens, call userinfo endpoints, - or otherwise extract claims that should be embedded in the FastMCP JWT - issued to MCP clients. This enables gateways to inspect upstream identity - information by decoding the JWT without server-side storage lookups. - - Args: - idp_tokens: Full token response from upstream provider. Contains - access_token, and for OIDC providers may include id_token, - refresh_token, and other response fields. - - Returns: - Dict of claims to embed in JWT under the "upstream_claims" key, - or None to not embed any upstream claims. - - Example: - For Azure/Entra ID, you might decode the access_token JWT and - extract claims like sub, oid, name, preferred_username, email, - roles, and groups. - """ - _ = idp_tokens - return None - - async def load_refresh_token( - self, - client: OAuthClientInformationFull, - refresh_token: str, - ) -> RefreshToken | None: - """Load refresh token metadata from distributed storage. - - Looks up by token hash and reconstructs the RefreshToken object. - Validates that the token belongs to the requesting client. - """ - token_hash = _hash_token(refresh_token) - metadata = await self._refresh_token_store.get(key=token_hash) - if not metadata: - return None - # Verify token belongs to this client (prevents cross-client token usage) - if metadata.client_id != client.client_id: - logger.warning( - "Refresh token client_id mismatch: expected %s, got %s", - client.client_id, - metadata.client_id, - ) - return None - return RefreshToken( - token=refresh_token, - client_id=metadata.client_id, - scopes=metadata.scopes, - expires_at=metadata.expires_at, - ) - - async def exchange_refresh_token( - self, - client: OAuthClientInformationFull, - refresh_token: RefreshToken, - scopes: list[str], - ) -> OAuthToken: - """Exchange FastMCP refresh token for new FastMCP access token. - - Implements two-tier refresh: - 1. Verify FastMCP refresh token - 2. Look up upstream token via JTI mapping - 3. Refresh upstream token with upstream provider - 4. Update stored upstream token - 5. Issue new FastMCP access token - 6. Keep same FastMCP refresh token (unless upstream rotates) - """ - # Verify FastMCP refresh token - try: - refresh_payload = self.jwt_issuer.verify_token( - refresh_token.token, expected_token_use="refresh" - ) - refresh_jti = refresh_payload["jti"] - except Exception as e: - logger.debug("FastMCP refresh token validation failed: %s", e) - raise TokenError("invalid_grant", "Invalid refresh token") from e - - # Look up upstream token via JTI mapping - jti_mapping = await self._jti_mapping_store.get(key=refresh_jti) - if not jti_mapping: - logger.error("JTI mapping not found for refresh token: %s", refresh_jti[:8]) - raise TokenError("invalid_grant", "Refresh token mapping not found") - - upstream_token_set = await self._upstream_token_store.get( - key=jti_mapping.upstream_token_id - ) - if not upstream_token_set: - logger.error( - "Upstream token set not found: %s", jti_mapping.upstream_token_id[:8] - ) - raise TokenError("invalid_grant", "Upstream token not found") - - # Decrypt upstream refresh token - if not upstream_token_set.refresh_token: - logger.error("No upstream refresh token available") - raise TokenError("invalid_grant", "Refresh not supported for this token") - - # Refresh upstream token using authlib - oauth_client = self._create_upstream_oauth_client() - - # Allow child classes to transform scopes before sending to upstream - # This enables provider-specific scope formatting (e.g., Azure prefixing) - # while keeping original scopes in storage - upstream_scopes = self._prepare_scopes_for_upstream_refresh(scopes) - - try: - logger.debug("Refreshing upstream token (jti=%s)", refresh_jti[:8]) - token_response: dict[str, Any] = await oauth_client.refresh_token( - url=self._upstream_token_endpoint, - refresh_token=upstream_token_set.refresh_token, - scope=" ".join(upstream_scopes) if upstream_scopes else None, - **self._extra_token_params, - ) - logger.debug("Successfully refreshed upstream token") - except Exception as e: - logger.error("Upstream token refresh failed: %s", e) - raise TokenError("invalid_grant", f"Upstream refresh failed: {e}") from e - - # Update stored upstream token - # In refresh flow, we know there's a refresh token, so default to 1 hour - # (user override still applies if set) - if "expires_in" in token_response: - new_expires_in = int(token_response["expires_in"]) - logger.debug( - "Refreshed access token TTL: %d seconds (from IdP expires_in)", - new_expires_in, - ) - elif self._fallback_access_token_expiry_seconds is not None: - new_expires_in = self._fallback_access_token_expiry_seconds - logger.debug( - "Refreshed access token TTL: %d seconds (using configured fallback)", - new_expires_in, - ) - else: - new_expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS - logger.debug( - "Refreshed access token TTL: %d seconds (default)", new_expires_in - ) - upstream_token_set.access_token = token_response["access_token"] - upstream_token_set.expires_at = time.time() + new_expires_in - - # Prefer IdP-granted scopes from refresh response (RFC 6749 §5.1) - refreshed_scopes: list[str] = ( - parse_scopes(token_response["scope"]) or [] - if "scope" in token_response - else scopes - ) - upstream_token_set.scope = " ".join(refreshed_scopes) - - # Handle upstream refresh token rotation and expiry - new_refresh_expires_in = None - if new_upstream_refresh := token_response.get("refresh_token"): - if new_upstream_refresh != upstream_token_set.refresh_token: - upstream_token_set.refresh_token = new_upstream_refresh - logger.debug("Upstream refresh token rotated") - - # Update refresh token expiry if provided - if "refresh_expires_in" in token_response and int( - token_response["refresh_expires_in"] - ): - new_refresh_expires_in = int(token_response["refresh_expires_in"]) - upstream_token_set.refresh_token_expires_at = ( - time.time() + new_refresh_expires_in - ) - logger.debug( - "Upstream refresh token expires in %d seconds", - new_refresh_expires_in, - ) - elif upstream_token_set.refresh_token_expires_at: - # Keep existing expiry if upstream doesn't provide new one - new_refresh_expires_in = int( - upstream_token_set.refresh_token_expires_at - time.time() - ) - else: - # Default to 30 days if unknown - new_refresh_expires_in = 60 * 60 * 24 * 30 - upstream_token_set.refresh_token_expires_at = ( - time.time() + new_refresh_expires_in - ) - - upstream_token_set.raw_token_data = { - **upstream_token_set.raw_token_data, - **token_response, - } - # Calculate refresh TTL for storage - refresh_ttl = new_refresh_expires_in or ( - int(upstream_token_set.refresh_token_expires_at - time.time()) - if upstream_token_set.refresh_token_expires_at - else 60 * 60 * 24 * 30 # Default to 30 days if unknown - ) - await self._upstream_token_store.put( - key=upstream_token_set.upstream_token_id, - value=upstream_token_set, - ttl=max( - refresh_ttl, new_expires_in, 1 - ), # Keep until longest-lived token expires (min 1s for safety) - ) - - # Re-extract upstream claims from refreshed token response - upstream_claims = await self._extract_upstream_claims( - upstream_token_set.raw_token_data - ) - - # Issue new minimal FastMCP access token (just a reference via JTI) - if client.client_id is None: - raise TokenError("invalid_client", "Client ID is required") - new_access_jti = secrets.token_urlsafe(32) - new_fastmcp_access = self.jwt_issuer.issue_access_token( - client_id=client.client_id, - scopes=refreshed_scopes, - jti=new_access_jti, - expires_in=new_expires_in, - upstream_claims=upstream_claims, - ) - - # Store new access token JTI mapping - await self._jti_mapping_store.put( - key=new_access_jti, - value=JTIMapping( - jti=new_access_jti, - upstream_token_id=upstream_token_set.upstream_token_id, - created_at=time.time(), - ), - ttl=new_expires_in, # Auto-expire with refreshed access token - ) - - # Issue NEW minimal FastMCP refresh token (rotation for security) - # Use upstream refresh token expiry to align lifetimes - new_refresh_jti = secrets.token_urlsafe(32) - new_fastmcp_refresh = self.jwt_issuer.issue_refresh_token( - client_id=client.client_id, - scopes=refreshed_scopes, - jti=new_refresh_jti, - expires_in=new_refresh_expires_in - or 60 * 60 * 24 * 30, # Fallback to 30 days - upstream_claims=upstream_claims, - ) - - # Store new refresh token JTI mapping with aligned expiry - # (reuse refresh_ttl calculated above for upstream token store) - await self._jti_mapping_store.put( - key=new_refresh_jti, - value=JTIMapping( - jti=new_refresh_jti, - upstream_token_id=upstream_token_set.upstream_token_id, - created_at=time.time(), - ), - ttl=refresh_ttl, # Align with upstream refresh token expiry - ) - - # Invalidate old refresh token (refresh token rotation - enforces one-time use) - await self._jti_mapping_store.delete(key=refresh_jti) - logger.debug( - "Rotated refresh token (old JTI invalidated - one-time use enforced)" - ) - - # Store new refresh token metadata (keyed by hash) - await self._refresh_token_store.put( - key=_hash_token(new_fastmcp_refresh), - value=RefreshTokenMetadata( - client_id=client.client_id, - scopes=refreshed_scopes, - expires_at=int(time.time()) + refresh_ttl, - created_at=time.time(), - ), - ttl=refresh_ttl, - ) - - # Delete old refresh token (by hash) - await self._refresh_token_store.delete(key=_hash_token(refresh_token.token)) - - logger.info( - "Issued new FastMCP tokens (rotated refresh) for client=%s (access_jti=%s, refresh_jti=%s)", - client.client_id, - new_access_jti[:8], - new_refresh_jti[:8], - ) - - # Return new FastMCP tokens (both access AND refresh are new) - return OAuthToken( - access_token=new_fastmcp_access, - token_type="Bearer", - expires_in=new_expires_in, - refresh_token=new_fastmcp_refresh, # NEW refresh token (rotated) - scope=" ".join(refreshed_scopes), - ) - - # ------------------------------------------------------------------------- - # Token Validation - # ------------------------------------------------------------------------- - - def _get_verification_token( - self, upstream_token_set: UpstreamTokenSet - ) -> str | None: - """Get the token string to pass to the token verifier. - - Returns the upstream access token by default. Subclasses can override - to verify a different token (e.g., the OIDC id_token for providers - that issue opaque access tokens). - """ - return upstream_token_set.access_token - - def _uses_alternate_verification(self) -> bool: - """Whether this provider verifies a different token than the access token. - - When True, ``load_access_token`` patches the validated result with - the upstream access token, scopes, and expiry so that the returned - ``AccessToken`` reflects the access token rather than the - verification token. - - The default implementation compares token values, but subclasses - should override this to use an intent-based flag so the patch is - applied even when the verification token and access token happen to - carry the same value (e.g., some OIDC providers issue identical - JWTs for both). - """ - return False - - async def _try_transparent_refresh( - self, - upstream_token_set: UpstreamTokenSet, - ) -> UpstreamTokenSet: - """Refresh the upstream token transparently and update storage. - - Called during load_access_token when the upstream token has expired - but a refresh token is available. This avoids returning a 401 that - would force the client into a full re-authentication flow. - - Mutates and returns the upstream_token_set with refreshed token data. - Raises on failure (caller should catch and fall through to None). - """ - scopes = upstream_token_set.scope.split() if upstream_token_set.scope else [] - upstream_scopes = self._prepare_scopes_for_upstream_refresh(scopes) - oauth_client = self._create_upstream_oauth_client() - - token_response: dict[str, Any] = await oauth_client.refresh_token( - url=self._upstream_token_endpoint, - refresh_token=upstream_token_set.refresh_token, - scope=" ".join(upstream_scopes) if upstream_scopes else None, - **self._extra_token_params, - ) - logger.debug( - "Transparent upstream refresh succeeded (token_id=%s)", - upstream_token_set.upstream_token_id[:8], - ) - - # Calculate new expiry - if "expires_in" in token_response: - new_expires_in = int(token_response["expires_in"]) - elif self._fallback_access_token_expiry_seconds is not None: - new_expires_in = self._fallback_access_token_expiry_seconds - else: - new_expires_in = DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS - - upstream_token_set.access_token = token_response["access_token"] - upstream_token_set.expires_at = time.time() + new_expires_in - upstream_token_set.scope = " ".join( - parse_scopes(token_response["scope"]) or [] - if "scope" in token_response - else scopes - ) - - # Handle upstream refresh token rotation - new_refresh_expires_in = None - if new_upstream_refresh := token_response.get("refresh_token"): - if new_upstream_refresh != upstream_token_set.refresh_token: - upstream_token_set.refresh_token = new_upstream_refresh - if "refresh_expires_in" in token_response and int( - token_response["refresh_expires_in"] - ): - new_refresh_expires_in = int(token_response["refresh_expires_in"]) - upstream_token_set.refresh_token_expires_at = ( - time.time() + new_refresh_expires_in - ) - elif upstream_token_set.refresh_token_expires_at: - new_refresh_expires_in = int( - upstream_token_set.refresh_token_expires_at - time.time() - ) - else: - new_refresh_expires_in = 60 * 60 * 24 * 30 - upstream_token_set.refresh_token_expires_at = ( - time.time() + new_refresh_expires_in - ) - - upstream_token_set.raw_token_data = { - **upstream_token_set.raw_token_data, - **token_response, - } - - refresh_ttl = new_refresh_expires_in or ( - int(upstream_token_set.refresh_token_expires_at - time.time()) - if upstream_token_set.refresh_token_expires_at - else 60 * 60 * 24 * 30 - ) - await self._upstream_token_store.put( - key=upstream_token_set.upstream_token_id, - value=upstream_token_set, - ttl=max(refresh_ttl, new_expires_in, 1), - ) - - return upstream_token_set - - async def load_access_token(self, token: str) -> AccessToken | None: # type: ignore[override] # ty:ignore[invalid-method-override] - """Validate FastMCP JWT by swapping for upstream token. - - This implements the token swap pattern: - 1. Verify FastMCP JWT signature (proves it's our token) - 2. Look up upstream token via JTI mapping - 3. Decrypt upstream token - 4. Validate upstream token with provider (GitHub API, JWT validation, etc.) - 5. If upstream validation fails, attempt transparent refresh - 6. Return upstream validation result - - The FastMCP JWT is a reference token - all authorization data comes - from validating the upstream token via the TokenVerifier. - """ - try: - # 1. Verify FastMCP JWT signature and claims - payload = self.jwt_issuer.verify_token(token) - jti = payload["jti"] - - # 2. Look up upstream token via JTI mapping - jti_mapping = await self._jti_mapping_store.get(key=jti) - if not jti_mapping: - logger.info( - "JTI mapping not found (token may have expired): jti=%s...", - jti[:16], - ) - return None - - upstream_token_set = await self._upstream_token_store.get( - key=jti_mapping.upstream_token_id - ) - if not upstream_token_set: - logger.debug( - "Upstream token not found: %s", jti_mapping.upstream_token_id - ) - return None - - # 3. Validate with upstream provider (delegated to TokenVerifier) - # This calls the real token validator (GitHub API, JWKS, etc.) - verification_token = self._get_verification_token(upstream_token_set) - if verification_token is None: - logger.debug("No verification token available") - return None - validated = await self._token_validator.verify_token(verification_token) - - # 4. If upstream validation failed due to token expiry and we - # have a refresh token, attempt transparent refresh to avoid - # forcing the client into a full re-auth flow. Only refresh on - # expiry — other failures (scope mismatch, revocation) won't be - # helped by a refresh and would just burn tokens. - if ( - not validated - and upstream_token_set.refresh_token - and upstream_token_set.expires_at <= time.time() - ): - try: - token_id = upstream_token_set.upstream_token_id - - # Advisory lock prevents concurrent requests from racing - # to refresh the same upstream token. - if token_id not in self._refresh_locks: - self._refresh_locks[token_id] = anyio.Lock() - lock = self._refresh_locks[token_id] - - async with lock: - # Re-read from storage — another task may have - # already refreshed while we waited for the lock. - upstream_token_set = ( - await self._upstream_token_store.get(key=token_id) - or upstream_token_set - ) - - verification_token = self._get_verification_token( - upstream_token_set - ) - if verification_token is not None: - validated = await self._token_validator.verify_token( - verification_token - ) - - # Only refresh if the (possibly reloaded) token is - # still expired — a non-expiry failure on a fresh - # token (scope mismatch, revocation) won't be - # helped by refreshing. - if ( - not validated - and upstream_token_set.expires_at <= time.time() - ): - upstream_token_set = await self._try_transparent_refresh( - upstream_token_set - ) - verification_token = self._get_verification_token( - upstream_token_set - ) - if verification_token is not None: - validated = await self._token_validator.verify_token( - verification_token - ) - except Exception as e: - logger.debug("Transparent upstream refresh failed: %s", e) - # In a distributed deployment, another worker may have - # already refreshed and rotated the token, causing our - # stale refresh token to fail. Re-read and re-validate. - try: - reloaded = await self._upstream_token_store.get( - key=upstream_token_set.upstream_token_id - ) - if reloaded: - verification_token = self._get_verification_token(reloaded) - if verification_token is not None: - validated = await self._token_validator.verify_token( - verification_token - ) - if validated: - upstream_token_set = reloaded - except Exception: - pass - - if not validated: - logger.debug("Upstream token validation failed") - return None - - # When alternate verification is in use (e.g., id_token - # verification in OIDCProxy), ensure the returned AccessToken - # carries the upstream access token and its scopes, not the - # verification token's values. We use an intent-based check - # rather than value equality because some IdPs issue identical - # JWTs for both access_token and id_token, which would cause - # the scope patch to be skipped even though it's needed. - if self._uses_alternate_verification(): - validated = validated.model_copy( - update={ - "token": upstream_token_set.access_token, - "scopes": upstream_token_set.scope.split() - if upstream_token_set.scope - else validated.scopes, - "expires_at": int(upstream_token_set.expires_at), - } - ) - - logger.debug( - "Token swap successful for JTI=%s (upstream validated)", jti[:8] - ) - return validated - - except Exception as e: - logger.debug("Token swap validation failed: %s", e) - return None - - # ------------------------------------------------------------------------- - # Token Revocation - # ------------------------------------------------------------------------- - - async def revoke_token(self, token: AccessToken | RefreshToken) -> None: - """Revoke token locally and with upstream server if supported. - - For refresh tokens, removes from local storage by hash. - For all tokens, attempts upstream revocation if endpoint is configured. - Access token JTI mappings expire via TTL. - """ - # For refresh tokens, delete from local storage by hash - if isinstance(token, RefreshToken): - await self._refresh_token_store.delete(key=_hash_token(token.token)) - - # Attempt upstream revocation if endpoint is configured - if self._upstream_revocation_endpoint: - try: - async with httpx.AsyncClient( - timeout=HTTP_TIMEOUT_SECONDS - ) as http_client: - revocation_data: dict[str, str] = {"token": token.token} - request_kwargs: dict[str, Any] = {"data": revocation_data} - - # Use the factory method when available (supports alternative auth like - # client assertions for managed identity), falling back to basic auth - # or client_id-only for public clients per RFC 7009 - oauth_client = self._create_upstream_oauth_client() - if oauth_client.client_secret is not None: - # Client secret is available, use HTTP Basic auth - request_kwargs["auth"] = ( - self._upstream_client_id, - oauth_client.client_secret, - ) - else: - # No secret; public client must still identify itself per RFC 7009 - revocation_data["client_id"] = self._upstream_client_id - - await http_client.post( - self._upstream_revocation_endpoint, - **request_kwargs, - ) - logger.debug("Successfully revoked token with upstream server") - except Exception as e: - logger.warning("Failed to revoke token with upstream server: %s", e) - else: - logger.debug("No upstream revocation endpoint configured") - - logger.debug("Token revoked successfully") - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get OAuth routes with custom handlers for better error UX. - - This method creates standard OAuth routes and replaces: - - /authorize endpoint: Enhanced error responses for unregistered clients - - /token endpoint: OAuth 2.1 compliant error codes - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata. - """ - # Get standard OAuth routes from parent class - # Note: parent already replaces /token with TokenHandler for proper error codes - routes = super().get_routes(mcp_path) - custom_routes = [] - - logger.debug( - f"get_routes called - configuring OAuth routes in {len(routes)} routes" - ) - - for i, route in enumerate(routes): - logger.debug( - f"Route {i}: {route} - path: {getattr(route, 'path', 'N/A')}, methods: {getattr(route, 'methods', 'N/A')}" - ) - - # Replace the authorize endpoint with our enhanced handler for better error UX - if ( - isinstance(route, Route) - and route.path == "/authorize" - and route.methods is not None - and ("GET" in route.methods or "POST" in route.methods) - ): - # Replace with our enhanced authorization handler - # Note: self.base_url is guaranteed to be set in parent __init__ - authorize_handler = AuthorizationHandler( - provider=self, - base_url=self.base_url, # ty: ignore[invalid-argument-type] - server_name=None, # Could be extended to pass server metadata - server_icon_url=None, - ) - custom_routes.append( - Route( - path="/authorize", - endpoint=authorize_handler.handle, - methods=["GET", "POST"], - ) - ) - elif ( - self._cimd_manager is not None - and isinstance(route, Route) - and route.path == "/token" - and route.methods is not None - and "POST" in route.methods - ): - # Replace the token endpoint authenticator with one that supports - # private_key_jwt for CIMD clients - token_endpoint_url = f"{self.base_url}/token" - cimd_authenticator = PrivateKeyJWTClientAuthenticator( - provider=self, - cimd_manager=self._cimd_manager, - token_endpoint_url=token_endpoint_url, - ) - token_handler = TokenHandler( - provider=self, client_authenticator=cimd_authenticator - ) - custom_routes.append( - Route( - path="/token", - endpoint=cors_middleware( - token_handler.handle, ["POST", "OPTIONS"] - ), - methods=["POST", "OPTIONS"], - ) - ) - elif ( - self._cimd_manager is not None - and isinstance(route, Route) - and route.path.startswith("/.well-known/oauth-authorization-server") - ): - client_registration_options = ( - self.client_registration_options or ClientRegistrationOptions() - ) - revocation_options = self.revocation_options or RevocationOptions() - metadata = build_metadata( - self.base_url, # ty: ignore[invalid-argument-type] - self.service_documentation_url, - client_registration_options, - revocation_options, - ) - metadata.client_id_metadata_document_supported = True - handler = MetadataHandler(metadata) - methods = route.methods or ["GET", "OPTIONS"] - - custom_routes.append( - Route( - path=route.path, - endpoint=cors_middleware(handler.handle, ["GET", "OPTIONS"]), - methods=methods, - name=route.name, - include_in_schema=route.include_in_schema, - ) - ) - else: - # Keep all other standard OAuth routes unchanged - custom_routes.append(route) - - # Add OAuth callback endpoint for forwarding to client callbacks - custom_routes.append( - Route( - path=self._redirect_path, - endpoint=self._handle_idp_callback, - methods=["GET"], - ) - ) - - # Add consent endpoints - # Handle both GET (show page) and POST (submit) at /consent - custom_routes.append( - Route( - path="/consent", endpoint=self._handle_consent, methods=["GET", "POST"] - ) - ) - - return custom_routes - - # ------------------------------------------------------------------------- - # IdP Callback Forwarding - # ------------------------------------------------------------------------- - - async def _handle_idp_callback( - self, request: Request - ) -> HTMLResponse | RedirectResponse: - """Handle callback from upstream IdP and forward to client. - - This implements the DCR-compliant callback forwarding: - 1. Receive IdP callback with code and txn_id as state - 2. Exchange IdP code for tokens (server-side) - 3. Generate our own client code bound to PKCE challenge - 4. Redirect to client's callback with client code and original state - """ - try: - idp_code = request.query_params.get("code") - txn_id = request.query_params.get("state") - error = request.query_params.get("error") - - if error: - error_description = request.query_params.get("error_description") - logger.error( - "IdP callback error: %s - %s", - error, - error_description, - ) - # Show error page to user - html_content = create_error_html( - error_title="OAuth Error", - error_message=f"Authentication failed: {error_description or 'Unknown error'}", - error_details={"Error Code": error} if error else None, - ) - return HTMLResponse(content=html_content, status_code=400) - - if not idp_code or not txn_id: - logger.error("IdP callback missing code or transaction ID") - html_content = create_error_html( - error_title="OAuth Error", - error_message="Missing authorization code or transaction ID from the identity provider.", - ) - return HTMLResponse(content=html_content, status_code=400) - - # Look up transaction data - transaction_model = await self._transaction_store.get(key=txn_id) - if not transaction_model: - logger.error("IdP callback with invalid transaction ID: %s", txn_id) - html_content = create_error_html( - error_title="OAuth Error", - error_message="Invalid or expired authorization transaction. Please try authenticating again.", - ) - return HTMLResponse(content=html_content, status_code=400) - # Verify consent binding cookie to prevent confused deputy attacks. - # When consent is enabled, the browser that approved consent receives - # a signed cookie. A different browser (e.g., a victim lured to the - # IdP URL) won't have this cookie and will be rejected. - if self._require_authorization_consent is True: - consent_token = transaction_model.consent_token - if not consent_token: - logger.error("Transaction %s missing consent_token", txn_id) - html_content = create_error_html( - error_title="Authorization Error", - error_message="Invalid authorization flow. Please try authenticating again.", - ) - return HTMLResponse(content=html_content, status_code=403) - - if not self._verify_consent_binding_cookie( - request, txn_id, consent_token - ): - logger.warning( - "Consent binding cookie missing or invalid for transaction %s " - "(possible confused deputy attack)", - txn_id, - ) - html_content = create_error_html( - error_title="Authorization Error", - error_message=( - "Authorization session mismatch. This can happen if you " - "followed a link from another person or your session expired. " - "Please try authenticating again." - ), - ) - return HTMLResponse(content=html_content, status_code=403) - - transaction = transaction_model.model_dump() - - # Exchange IdP code for tokens (server-side) - oauth_client = self._create_upstream_oauth_client() - - try: - idp_redirect_uri = ( - f"{str(self.base_url).rstrip('/')}{self._redirect_path}" - ) - logger.debug( - f"Exchanging IdP code for tokens with redirect_uri: {idp_redirect_uri}" - ) - - # Build token exchange parameters - token_params = { - "url": self._upstream_token_endpoint, - "code": idp_code, - "redirect_uri": idp_redirect_uri, - } - - # Include proxy's code_verifier if we forwarded PKCE - proxy_code_verifier = transaction.get("proxy_code_verifier") - if proxy_code_verifier: - token_params["code_verifier"] = proxy_code_verifier - logger.debug( - "Including proxy code_verifier in token exchange for transaction %s", - txn_id, - ) - - # Allow providers to specify scope for token exchange - exchange_scopes = self._prepare_scopes_for_token_exchange( - transaction.get("scopes") or [] - ) - if exchange_scopes: - token_params["scope"] = " ".join(exchange_scopes) - - # Add any extra token parameters configured for this proxy - if self._extra_token_params: - token_params.update(self._extra_token_params) - logger.debug( - "Adding extra token parameters for transaction %s: %s", - txn_id, - list(self._extra_token_params.keys()), - ) - - idp_tokens: dict[str, Any] = await oauth_client.fetch_token( - **token_params - ) - - logger.debug( - f"Successfully exchanged IdP code for tokens (transaction: {txn_id}, PKCE: {bool(proxy_code_verifier)})" - ) - logger.debug( - "IdP token response: expires_in=%s, has_refresh_token=%s", - idp_tokens.get("expires_in"), - "refresh_token" in idp_tokens, - ) - - except Exception as e: - logger.error("IdP token exchange failed: %s", e) - html_content = create_error_html( - error_title="OAuth Error", - error_message=f"Token exchange with identity provider failed: {e}", - ) - return HTMLResponse(content=html_content, status_code=500) - - # Generate our own authorization code for the client - client_code = secrets.token_urlsafe(32) - code_expires_at = int(time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS) - - # Store client code with PKCE challenge and IdP tokens - await self._code_store.put( - key=client_code, - value=ClientCode( - code=client_code, - client_id=transaction["client_id"], - redirect_uri=transaction["client_redirect_uri"], - code_challenge=transaction["code_challenge"], - code_challenge_method=transaction["code_challenge_method"], - scopes=transaction["scopes"], - idp_tokens=idp_tokens, - expires_at=code_expires_at, - created_at=time.time(), - ), - ttl=DEFAULT_AUTH_CODE_EXPIRY_SECONDS, # Auto-expire after 5 minutes - ) - - # Clean up transaction - await self._transaction_store.delete(key=txn_id) - - # Build client callback URL with our code and original state - client_redirect_uri = transaction["client_redirect_uri"] - client_state = transaction["client_state"] - - callback_params = { - "code": client_code, - "state": client_state, - } - - # Add query parameters to client redirect URI - separator = "&" if "?" in client_redirect_uri else "?" - client_callback_url = ( - f"{client_redirect_uri}{separator}{urlencode(callback_params)}" - ) - - logger.debug(f"Forwarding to client callback for transaction {txn_id}") - - response = RedirectResponse(url=client_callback_url, status_code=302) - self._clear_consent_binding_cookie(request, response, txn_id) - return response - - except Exception as e: - logger.error("Error in IdP callback handler: %s", e, exc_info=True) - html_content = create_error_html( - error_title="OAuth Error", - error_message="Internal server error during OAuth callback processing. Please try again.", - ) - return HTMLResponse(content=html_content, status_code=500) diff --git a/src/fastmcp/server/auth/oauth_proxy/ui.py b/src/fastmcp/server/auth/oauth_proxy/ui.py deleted file mode 100644 index 4ae6b5fb5..000000000 --- a/src/fastmcp/server/auth/oauth_proxy/ui.py +++ /dev/null @@ -1,300 +0,0 @@ -"""OAuth Proxy UI Generation Functions. - -This module contains HTML generation functions for consent and error pages. -""" - -from __future__ import annotations - -from fastmcp.utilities.ui import ( - BUTTON_STYLES, - DETAIL_BOX_STYLES, - DETAILS_STYLES, - INFO_BOX_STYLES, - REDIRECT_SECTION_STYLES, - TOOLTIP_STYLES, - create_logo, - create_page, -) - - -def create_consent_html( - client_id: str, - redirect_uri: str, - scopes: list[str], - txn_id: str, - csrf_token: str, - client_name: str | None = None, - title: str = "Application Access Request", - server_name: str | None = None, - server_icon_url: str | None = None, - server_website_url: str | None = None, - client_website_url: str | None = None, - csp_policy: str | None = None, - is_cimd_client: bool = False, - cimd_domain: str | None = None, -) -> str: - """Create a styled HTML consent page for OAuth authorization requests. - - Args: - csp_policy: Content Security Policy override. - If None, uses the built-in CSP policy with appropriate directives. - If empty string "", disables CSP entirely (no meta tag is rendered). - If a non-empty string, uses that as the CSP policy value. - """ - import html as html_module - - client_display = html_module.escape(client_name or client_id) - server_name_escaped = html_module.escape(server_name or "FastMCP") - - # Make server name a hyperlink if website URL is available - if server_website_url: - website_url_escaped = html_module.escape(server_website_url) - server_display = f'{server_name_escaped}' - else: - server_display = server_name_escaped - - # Build intro box with call-to-action - intro_box = f""" -
-

The application {client_display} wants to access the MCP server {server_display}. Please ensure you recognize the callback address below.

-
- """ - - # Build CIMD verified domain badge if applicable - cimd_badge = "" - if is_cimd_client and cimd_domain: - cimd_domain_escaped = html_module.escape(cimd_domain) - cimd_badge = f""" -
- - Verified domain: {cimd_domain_escaped} -
- """ - - # Build redirect URI section (yellow box, centered) - redirect_uri_escaped = html_module.escape(redirect_uri) - redirect_section = f""" -
- Credentials will be sent to: -
{redirect_uri_escaped}
-
- """ - - # Build advanced details with collapsible section - detail_rows = [ - ("Application Name", html_module.escape(client_name or client_id)), - ("Application Website", html_module.escape(client_website_url or "N/A")), - ("Application ID", html_module.escape(client_id)), - ("Redirect URI", redirect_uri_escaped), - ( - "Requested Scopes", - ", ".join(html_module.escape(s) for s in scopes) if scopes else "None", - ), - ] - - detail_rows_html = "\n".join( - [ - f""" -
-
{label}:
-
{value}
-
- """ - for label, value in detail_rows - ] - ) - - advanced_details = f""" -
- Advanced Details -
- {detail_rows_html} -
-
- """ - - # Build form with buttons - # Use empty action to submit to current URL (/consent or /mcp/consent) - # The POST handler is registered at the same path as GET - form = f""" -
- - - -
- - -
-
- """ - - # Build help link with tooltip (identical to current implementation) - help_link = """ - - """ - - # Build the page content - content = f""" -
- {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")} -

Application Access Request

- {intro_box} - {cimd_badge} - {redirect_section} - {advanced_details} - {form} -
- {help_link} - """ - - # Additional styles needed for this page - cimd_badge_styles = """ - .cimd-badge { - background: #ecfdf5; - border: 1px solid #6ee7b7; - border-radius: 8px; - padding: 8px 16px; - margin-bottom: 16px; - font-size: 14px; - color: #065f46; - text-align: center; - } - .cimd-check { - color: #059669; - font-weight: bold; - margin-right: 4px; - } - """ - additional_styles = ( - INFO_BOX_STYLES - + REDIRECT_SECTION_STYLES - + DETAILS_STYLES - + DETAIL_BOX_STYLES - + BUTTON_STYLES - + TOOLTIP_STYLES - + cimd_badge_styles - ) - - # Determine CSP policy to use - # If csp_policy is None, build the default CSP policy - # If csp_policy is empty string, CSP will be disabled entirely in create_page - # If csp_policy is a non-empty string, use it as-is - if csp_policy is None: - # The consent form posts to itself (action="") and all subsequent redirects - # are server-controlled. Chrome enforces form-action across the entire redirect - # chain (Chromium issue #40923007), which breaks flows where an HTTPS callback - # internally redirects to a custom scheme (e.g., claude:// or cursor://). - # Since the form target is same-origin and we control the redirect chain, - # omitting form-action is safe and avoids these browser-specific CSP issues. - csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'" - - return create_page( - content=content, - title=title, - additional_styles=additional_styles, - csp_policy=csp_policy, - ) - - -def create_error_html( - error_title: str, - error_message: str, - error_details: dict[str, str] | None = None, - server_name: str | None = None, - server_icon_url: str | None = None, -) -> str: - """Create a styled HTML error page for OAuth errors. - - Args: - error_title: The error title (e.g., "OAuth Error", "Authorization Failed") - error_message: The main error message to display - error_details: Optional dictionary of error details to show (e.g., `{"Error Code": "invalid_client"}`) - server_name: Optional server name to display - server_icon_url: Optional URL to server icon/logo - - Returns: - Complete HTML page as a string - """ - import html as html_module - - error_message_escaped = html_module.escape(error_message) - - # Build error message box - error_box = f""" -
-

{error_message_escaped}

-
- """ - - # Build error details section if provided - details_section = "" - if error_details: - detail_rows_html = "\n".join( - [ - f""" -
-
{html_module.escape(label)}:
-
{html_module.escape(value)}
-
- """ - for label, value in error_details.items() - ] - ) - - details_section = f""" -
- Error Details -
- {detail_rows_html} -
-
- """ - - # Build the page content - content = f""" -
- {create_logo(icon_url=server_icon_url, alt_text=server_name or "FastMCP")} -

{html_module.escape(error_title)}

- {error_box} - {details_section} -
- """ - - # Additional styles needed for this page - # Override .info-box.error to use normal text color instead of red - additional_styles = ( - INFO_BOX_STYLES - + DETAILS_STYLES - + DETAIL_BOX_STYLES - + """ - .info-box.error { - color: #111827; - } - """ - ) - - # Simple CSP policy for error pages (no forms needed) - csp_policy = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'" - - return create_page( - content=content, - title=error_title, - additional_styles=additional_styles, - csp_policy=csp_policy, - ) diff --git a/src/fastmcp/server/auth/oidc_proxy.py b/src/fastmcp/server/auth/oidc_proxy.py deleted file mode 100644 index ebf048c5a..000000000 --- a/src/fastmcp/server/auth/oidc_proxy.py +++ /dev/null @@ -1,491 +0,0 @@ -"""OIDC Proxy Provider for FastMCP. - -This provider acts as a transparent proxy to an upstream OIDC compliant Authorization -Server. It leverages the OAuthProxy class to handle Dynamic Client Registration and -forwarding of all OAuth flows. - -This implementation is based on: - OpenID Connect Discovery 1.0 - https://openid.net/specs/openid-connect-discovery-1_0.html - OAuth 2.0 Authorization Server Metadata - https://datatracker.ietf.org/doc/html/rfc8414 -""" - -from collections.abc import Sequence -from typing import Literal - -import httpx -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl, BaseModel, model_validator -from typing_extensions import Self - -from fastmcp.server.auth import TokenVerifier -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.server.auth.oauth_proxy.models import UpstreamTokenSet -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class OIDCConfiguration(BaseModel): - """OIDC Configuration. - - See: - https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderMetadata - https://datatracker.ietf.org/doc/html/rfc8414#section-2 - """ - - strict: bool = True - - # OpenID Connect Discovery 1.0 - issuer: AnyHttpUrl | str | None = None # Strict - - authorization_endpoint: AnyHttpUrl | str | None = None # Strict - token_endpoint: AnyHttpUrl | str | None = None # Strict - userinfo_endpoint: AnyHttpUrl | str | None = None - - jwks_uri: AnyHttpUrl | str | None = None # Strict - - registration_endpoint: AnyHttpUrl | str | None = None - - scopes_supported: Sequence[str] | None = None - - response_types_supported: Sequence[str] | None = None # Strict - response_modes_supported: Sequence[str] | None = None - - grant_types_supported: Sequence[str] | None = None - - acr_values_supported: Sequence[str] | None = None - - subject_types_supported: Sequence[str] | None = None # Strict - - id_token_signing_alg_values_supported: Sequence[str] | None = None # Strict - id_token_encryption_alg_values_supported: Sequence[str] | None = None - id_token_encryption_enc_values_supported: Sequence[str] | None = None - - userinfo_signing_alg_values_supported: Sequence[str] | None = None - userinfo_encryption_alg_values_supported: Sequence[str] | None = None - userinfo_encryption_enc_values_supported: Sequence[str] | None = None - - request_object_signing_alg_values_supported: Sequence[str] | None = None - request_object_encryption_alg_values_supported: Sequence[str] | None = None - request_object_encryption_enc_values_supported: Sequence[str] | None = None - - token_endpoint_auth_methods_supported: Sequence[str] | None = None - token_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = None - - display_values_supported: Sequence[str] | None = None - - claim_types_supported: Sequence[str] | None = None - claims_supported: Sequence[str] | None = None - - service_documentation: AnyHttpUrl | str | None = None - - claims_locales_supported: Sequence[str] | None = None - ui_locales_supported: Sequence[str] | None = None - - claims_parameter_supported: bool | None = None - request_parameter_supported: bool | None = None - request_uri_parameter_supported: bool | None = None - - require_request_uri_registration: bool | None = None - - op_policy_uri: AnyHttpUrl | str | None = None - op_tos_uri: AnyHttpUrl | str | None = None - - # OAuth 2.0 Authorization Server Metadata - revocation_endpoint: AnyHttpUrl | str | None = None - revocation_endpoint_auth_methods_supported: Sequence[str] | None = None - revocation_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = None - - introspection_endpoint: AnyHttpUrl | str | None = None - introspection_endpoint_auth_methods_supported: Sequence[str] | None = None - introspection_endpoint_auth_signing_alg_values_supported: Sequence[str] | None = ( - None - ) - - code_challenge_methods_supported: Sequence[str] | None = None - - signed_metadata: str | None = None - - @model_validator(mode="after") - def _enforce_strict(self) -> Self: - """Enforce strict rules.""" - if not self.strict: - return self - - def enforce(attr: str, is_url: bool = False) -> None: - value = getattr(self, attr, None) - if not value: - message = f"Missing required configuration metadata: {attr}" - logger.error(message) - raise ValueError(message) - - if not is_url or isinstance(value, AnyHttpUrl): - return - - try: - AnyHttpUrl(value) - except Exception as e: - message = f"Invalid URL for configuration metadata: {attr}" - logger.error(message) - raise ValueError(message) from e - - enforce("issuer", True) - enforce("authorization_endpoint", True) - enforce("token_endpoint", True) - enforce("jwks_uri", True) - enforce("response_types_supported") - enforce("subject_types_supported") - enforce("id_token_signing_alg_values_supported") - - return self - - @classmethod - def get_oidc_configuration( - cls, config_url: AnyHttpUrl, *, strict: bool | None, timeout_seconds: int | None - ) -> Self: - """Get the OIDC configuration for the specified config URL. - - Args: - config_url: The OIDC config URL - strict: The strict flag for the configuration - timeout_seconds: HTTP request timeout in seconds - """ - get_kwargs = {} - if timeout_seconds is not None: - get_kwargs["timeout"] = timeout_seconds - - try: - response = httpx.get(str(config_url), **get_kwargs) - response.raise_for_status() - - config_data = response.json() - if strict is not None: - config_data["strict"] = strict - - return cls.model_validate(config_data) - except Exception: - logger.exception( - f"Unable to get OIDC configuration for config url: {config_url}" - ) - raise - - -class OIDCProxy(OAuthProxy): - """OAuth provider that wraps OAuthProxy to provide configuration via an OIDC configuration URL. - - This provider makes it easier to add OAuth protection for any upstream provider - that is OIDC compliant. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.oidc_proxy import OIDCProxy - - # Simple OIDC based protection - auth = OIDCProxy( - config_url="https://oidc.config.url", - client_id="your-oidc-client-id", - client_secret="your-oidc-client-secret", - base_url="https://your.server.url", - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` - """ - - oidc_config: OIDCConfiguration - - def __init__( - self, - *, - # OIDC configuration - config_url: AnyHttpUrl | str, - strict: bool | None = None, - # Upstream server configuration - client_id: str, - client_secret: str | None = None, - audience: str | None = None, - timeout_seconds: int | None = None, - # Token verifier - token_verifier: TokenVerifier | None = None, - algorithm: str | None = None, - required_scopes: list[str] | None = None, - verify_id_token: bool = False, - # FastMCP server configuration - base_url: AnyHttpUrl | str, - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str | None = None, - # Client configuration - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - # JWT and encryption keys - jwt_signing_key: str | bytes | None = None, - # Token validation configuration - token_endpoint_auth_method: str | None = None, - # Consent screen configuration - require_authorization_consent: bool | Literal["external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - # Extra parameters - extra_authorize_params: dict[str, str] | None = None, - extra_token_params: dict[str, str] | None = None, - # Token expiry fallback - fallback_access_token_expiry_seconds: int | None = None, - # CIMD configuration - enable_cimd: bool = True, - ) -> None: - """Initialize the OIDC proxy provider. - - Args: - config_url: URL of upstream configuration - strict: Optional strict flag for the configuration - client_id: Client ID registered with upstream server - client_secret: Client secret for upstream server. Optional for PKCE public - clients or when using alternative credentials. When omitted, - jwt_signing_key must be provided. - audience: Audience for upstream server - timeout_seconds: HTTP request timeout in seconds - token_verifier: Optional custom token verifier (e.g., IntrospectionTokenVerifier for opaque tokens). - If not provided, a JWTVerifier will be created using the OIDC configuration. - Cannot be used with algorithm or required_scopes parameters (configure these on your verifier instead). - algorithm: Token verifier algorithm (only used if token_verifier is not provided) - required_scopes: Required scopes for token validation (only used if token_verifier is not provided) - verify_id_token: If True, verify the OIDC id_token instead of the access_token. - Useful for providers that issue opaque (non-JWT) access tokens, since the - id_token is always a standard JWT verifiable via the provider's JWKS. - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in upstream OAuth app (defaults to "/auth/callback") - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - Patterns support wildcards (e.g., "http://localhost:*", "https://*.example.com/*"). - If None (default), all redirect URIs are allowed (for DCR compatibility). - If empty list, no redirect URIs are allowed. - These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - token_endpoint_auth_method: Token endpoint authentication method for upstream server. - Common values: "client_secret_basic", "client_secret_post", "none". - If None, authlib will use its default (typically "client_secret_basic"). - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to the upstream IdP. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - consent_csp_policy: Content Security Policy for the consent page. - If None (default), uses the built-in CSP policy with appropriate directives. - If empty string "", disables CSP entirely (no meta tag is rendered). - If a non-empty string, uses that as the CSP policy value. - extra_authorize_params: Additional parameters to forward to the upstream authorization endpoint. - Useful for provider-specific parameters like prompt=consent or access_type=offline. - Example: {"prompt": "consent", "access_type": "offline"} - extra_token_params: Additional parameters to forward to the upstream token endpoint. - Useful for provider-specific parameters during token exchange. - fallback_access_token_expiry_seconds: Expiry time to use when upstream provider - doesn't return `expires_in` in the token response. If not set, uses smart - defaults: 1 hour if a refresh token is available (since we can refresh), - or 1 year if no refresh token (for API-key-style tokens like GitHub OAuth Apps). - enable_cimd: Whether to enable CIMD (Client ID Metadata Document) client support. - When True, clients can use their metadata document URL as client_id instead of - Dynamic Client Registration. Default is True. - """ - if not config_url: - raise ValueError("Missing required config URL") - - if not client_id: - raise ValueError("Missing required client id") - - if not client_secret and not jwt_signing_key: - raise ValueError( - "Either client_secret or jwt_signing_key must be provided. " - "jwt_signing_key is required when client_secret is omitted " - "(e.g., for PKCE public clients)." - ) - - if not base_url: - raise ValueError("Missing required base URL") - - # Validate that verifier-specific parameters are not used with custom verifier - if token_verifier is not None: - if algorithm is not None: - raise ValueError( - "Cannot specify 'algorithm' when providing a custom token_verifier. " - "Configure the algorithm on your token verifier instead." - ) - if required_scopes is not None: - raise ValueError( - "Cannot specify 'required_scopes' when providing a custom token_verifier. " - "Configure required scopes on your token verifier instead." - ) - - if isinstance(config_url, str): - config_url = AnyHttpUrl(config_url) - - self.oidc_config = self.get_oidc_configuration( - config_url, strict, timeout_seconds - ) - if ( - not self.oidc_config.authorization_endpoint - or not self.oidc_config.token_endpoint - ): - logger.debug(f"Invalid OIDC Configuration: {self.oidc_config}") - raise ValueError("Missing required OIDC endpoints") - - revocation_endpoint = ( - str(self.oidc_config.revocation_endpoint) - if self.oidc_config.revocation_endpoint - else None - ) - - # Use custom verifier if provided, otherwise create default JWTVerifier - if token_verifier is None: - # When verifying id_tokens: - # - aud is always the OAuth client_id (per OIDC Core §2), not - # the API audience, so use client_id for audience validation. - # - id_tokens don't carry scope/scp claims, so don't pass - # required_scopes to the verifier (scope enforcement happens - # at the FastMCP token level instead). - verifier_audience = client_id if verify_id_token else audience - verifier_scopes = None if verify_id_token else required_scopes - token_verifier = self.get_token_verifier( - algorithm=algorithm, - audience=verifier_audience, - required_scopes=verifier_scopes, - timeout_seconds=timeout_seconds, - ) - - init_kwargs: dict[str, object] = { - "upstream_authorization_endpoint": str( - self.oidc_config.authorization_endpoint - ), - "upstream_token_endpoint": str(self.oidc_config.token_endpoint), - "upstream_client_id": client_id, - "upstream_client_secret": client_secret, - "upstream_revocation_endpoint": revocation_endpoint, - "token_verifier": token_verifier, - "base_url": base_url, - "issuer_url": issuer_url or base_url, - "service_documentation_url": self.oidc_config.service_documentation, - "allowed_client_redirect_uris": allowed_client_redirect_uris, - "client_storage": client_storage, - "jwt_signing_key": jwt_signing_key, - "token_endpoint_auth_method": token_endpoint_auth_method, - "require_authorization_consent": require_authorization_consent, - "consent_csp_policy": consent_csp_policy, - "forward_resource": forward_resource, - "fallback_access_token_expiry_seconds": fallback_access_token_expiry_seconds, - "enable_cimd": enable_cimd, - } - - if redirect_path: - init_kwargs["redirect_path"] = redirect_path - - # Build extra params, merging audience with user-provided params - # User params override audience if there's a conflict - final_authorize_params: dict[str, str] = {} - final_token_params: dict[str, str] = {} - - if audience: - final_authorize_params["audience"] = audience - final_token_params["audience"] = audience - - if extra_authorize_params: - final_authorize_params.update(extra_authorize_params) - if extra_token_params: - final_token_params.update(extra_token_params) - - if final_authorize_params: - init_kwargs["extra_authorize_params"] = final_authorize_params - if final_token_params: - init_kwargs["extra_token_params"] = final_token_params - - super().__init__(**init_kwargs) # ty: ignore[invalid-argument-type] - - self._verify_id_token = verify_id_token - - # When verify_id_token strips scopes from the verifier, restore - # them on the provider so they're still advertised to clients - # and enforced at the FastMCP token level. We also need to - # recompute derived state that OAuthProxy.__init__ already built - # from the (empty) verifier scopes. - if verify_id_token and required_scopes: - self.required_scopes = required_scopes - self._default_scope_str = " ".join(required_scopes) - if self.client_registration_options: - self.client_registration_options.valid_scopes = required_scopes - if self._cimd_manager is not None: - self._cimd_manager.default_scope = self._default_scope_str - - def _get_verification_token( - self, upstream_token_set: UpstreamTokenSet - ) -> str | None: - """Get the token to verify from the upstream token set. - - When verify_id_token is enabled, returns the id_token from the - upstream token response instead of the access_token. - """ - if self._verify_id_token: - id_token = upstream_token_set.raw_token_data.get("id_token") - if id_token is None: - logger.warning( - "verify_id_token is enabled but no id_token found in" - " upstream token response" - ) - return id_token - return upstream_token_set.access_token - - def _uses_alternate_verification(self) -> bool: - """Return True when id_token verification is enabled. - - This ensures ``load_access_token`` always patches the validated - result with upstream scopes, even when the IdP issues the same - JWT for both ``access_token`` and ``id_token``. - """ - return self._verify_id_token - - def get_oidc_configuration( - self, - config_url: AnyHttpUrl, - strict: bool | None, - timeout_seconds: int | None, - ) -> OIDCConfiguration: - """Gets the OIDC configuration for the specified configuration URL. - - Args: - config_url: The OIDC configuration URL - strict: The strict flag for the configuration - timeout_seconds: HTTP request timeout in seconds - """ - return OIDCConfiguration.get_oidc_configuration( - config_url, strict=strict, timeout_seconds=timeout_seconds - ) - - def get_token_verifier( - self, - *, - algorithm: str | None = None, - audience: str | None = None, - required_scopes: list[str] | None = None, - timeout_seconds: int | None = None, - ) -> TokenVerifier: - """Creates the token verifier for the specified OIDC configuration and arguments. - - Args: - algorithm: Optional token verifier algorithm - audience: Optional token verifier audience - required_scopes: Optional token verifier required_scopes - timeout_seconds: HTTP request timeout in seconds - """ - return JWTVerifier( - jwks_uri=str(self.oidc_config.jwks_uri), - issuer=str(self.oidc_config.issuer), - algorithm=algorithm, - audience=audience, - required_scopes=required_scopes, - ) diff --git a/src/fastmcp/server/auth/providers/__init__.py b/src/fastmcp/server/auth/providers/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/fastmcp/server/auth/providers/auth0.py b/src/fastmcp/server/auth/providers/auth0.py deleted file mode 100644 index 5b1017c6a..000000000 --- a/src/fastmcp/server/auth/providers/auth0.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Auth0 OAuth provider for FastMCP. - -This module provides a complete Auth0 integration that's ready to use with -just the configuration URL, client ID, client secret, audience, and base URL. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.auth0 import Auth0Provider - - # Simple Auth0 OAuth protection - auth = Auth0Provider( - config_url="https://auth0.config.url", - client_id="your-auth0-client-id", - client_secret="your-auth0-client-secret", - audience="your-auth0-api-audience", - base_url="http://localhost:8000", - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" - -from typing import Literal - -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl - -from fastmcp.server.auth.oidc_proxy import OIDCProxy -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class Auth0Provider(OIDCProxy): - """An Auth0 provider implementation for FastMCP. - - This provider is a complete Auth0 integration that's ready to use with - just the configuration URL, client ID, client secret, audience, and base URL. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.auth0 import Auth0Provider - - # Simple Auth0 OAuth protection - auth = Auth0Provider( - config_url="https://auth0.config.url", - client_id="your-auth0-client-id", - client_secret="your-auth0-client-secret", - audience="your-auth0-api-audience", - base_url="http://localhost:8000", - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` - """ - - def __init__( - self, - *, - config_url: AnyHttpUrl | str, - client_id: str, - client_secret: str, - audience: str, - base_url: AnyHttpUrl | str, - issuer_url: AnyHttpUrl | str | None = None, - required_scopes: list[str] | None = None, - redirect_path: str | None = None, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - ) -> None: - """Initialize Auth0 OAuth provider. - - Args: - config_url: Auth0 config URL - client_id: Auth0 application client id - client_secret: Auth0 application client secret - audience: Auth0 API audience - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - required_scopes: Required Auth0 scopes (defaults to ["openid"]) - redirect_path: Redirect path configured in Auth0 application - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to Auth0. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - """ - # Parse scopes if provided as string - auth0_required_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else ["openid"] - ) - - super().__init__( - config_url=config_url, - client_id=client_id, - client_secret=client_secret, - audience=audience, - base_url=base_url, - issuer_url=issuer_url, - redirect_path=redirect_path, - required_scopes=auth0_required_scopes, - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - ) - - logger.debug( - "Initialized Auth0 OAuth provider for client %s with scopes: %s", - client_id, - auth0_required_scopes, - ) diff --git a/src/fastmcp/server/auth/providers/aws.py b/src/fastmcp/server/auth/providers/aws.py deleted file mode 100644 index fc5ca0666..000000000 --- a/src/fastmcp/server/auth/providers/aws.py +++ /dev/null @@ -1,202 +0,0 @@ -"""AWS Cognito OAuth provider for FastMCP. - -This module provides a complete AWS Cognito OAuth integration that's ready to use -with a user pool ID, domain prefix, client ID and client secret. It handles all -the complexity of AWS Cognito's OAuth flow, token validation, and user management. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider - - # Simple AWS Cognito OAuth protection - auth = AWSCognitoProvider( - user_pool_id="your-user-pool-id", - aws_region="eu-central-1", - client_id="your-cognito-client-id", - client_secret="your-cognito-client-secret" - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" - -from __future__ import annotations - -from typing import Literal - -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl - -from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oidc_proxy import OIDCProxy -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class AWSCognitoTokenVerifier(JWTVerifier): - """Token verifier that filters claims to Cognito-specific subset.""" - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token and filter claims to Cognito-specific subset.""" - # Use base JWT verification - access_token = await super().verify_token(token) - if not access_token: - return None - - # Filter claims to Cognito-specific subset - cognito_claims = { - "sub": access_token.claims.get("sub"), - "username": access_token.claims.get("username"), - "cognito:groups": access_token.claims.get("cognito:groups", []), - } - - # Return new AccessToken with filtered claims - return AccessToken( - token=access_token.token, - client_id=access_token.client_id, - scopes=access_token.scopes, - expires_at=access_token.expires_at, - claims=cognito_claims, - ) - - -class AWSCognitoProvider(OIDCProxy): - """Complete AWS Cognito OAuth provider for FastMCP. - - This provider makes it trivial to add AWS Cognito OAuth protection to any - FastMCP server using OIDC Discovery. Just provide your Cognito User Pool details, - client credentials, and a base URL, and you're ready to go. - - Features: - - Automatic OIDC Discovery from AWS Cognito User Pool - - Automatic JWT token validation via Cognito's public keys - - Cognito-specific claim filtering (sub, username, cognito:groups) - - Support for Cognito User Pools - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.aws_cognito import AWSCognitoProvider - - auth = AWSCognitoProvider( - user_pool_id="eu-central-1_XXXXXXXXX", - aws_region="eu-central-1", - client_id="your-cognito-client-id", - client_secret="your-cognito-client-secret", - base_url="https://my-server.com", - redirect_path="/custom/callback", - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - user_pool_id: str, - client_id: str, - client_secret: str, - base_url: AnyHttpUrl | str, - aws_region: str = "eu-central-1", - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str = "/auth/callback", - required_scopes: list[str] | None = None, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - ): - """Initialize AWS Cognito OAuth provider. - - Args: - user_pool_id: Your Cognito User Pool ID (e.g., "eu-central-1_XXXXXXXXX") - client_id: Cognito app client ID - client_secret: Cognito app client secret - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - aws_region: AWS region where your User Pool is located (defaults to "eu-central-1") - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in Cognito app (defaults to "/auth/callback") - required_scopes: Required Cognito scopes (defaults to ["openid"]) - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to AWS Cognito. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - """ - # Parse scopes if provided as string - required_scopes_final = ( - parse_scopes(required_scopes) if required_scopes is not None else ["openid"] - ) - - # Construct OIDC discovery URL - config_url = f"https://cognito-idp.{aws_region}.amazonaws.com/{user_pool_id}/.well-known/openid-configuration" - - # Store Cognito-specific info for claim filtering - self.user_pool_id = user_pool_id - self.aws_region = aws_region - self.client_id = client_id - - # Initialize OIDC proxy with Cognito discovery - super().__init__( - config_url=config_url, - client_id=client_id, - client_secret=client_secret, - algorithm="RS256", - required_scopes=required_scopes_final, - base_url=base_url, - issuer_url=issuer_url, - redirect_path=redirect_path, - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - ) - - logger.debug( - "Initialized AWS Cognito OAuth provider for client %s with scopes: %s", - client_id, - required_scopes_final, - ) - - def get_token_verifier( - self, - *, - algorithm: str | None = None, - audience: str | None = None, - required_scopes: list[str] | None = None, - timeout_seconds: int | None = None, - ) -> AWSCognitoTokenVerifier: - """Creates a Cognito-specific token verifier with claim filtering. - - Args: - algorithm: Optional token verifier algorithm - audience: Optional token verifier audience - required_scopes: Optional token verifier required_scopes - timeout_seconds: HTTP request timeout in seconds - """ - return AWSCognitoTokenVerifier( - issuer=str(self.oidc_config.issuer), - audience=audience or self.client_id, - algorithm=algorithm, - jwks_uri=str(self.oidc_config.jwks_uri), - required_scopes=required_scopes, - ) diff --git a/src/fastmcp/server/auth/providers/azure.py b/src/fastmcp/server/auth/providers/azure.py deleted file mode 100644 index cd1de2c4a..000000000 --- a/src/fastmcp/server/auth/providers/azure.py +++ /dev/null @@ -1,763 +0,0 @@ -"""Azure (Microsoft Entra) OAuth provider for FastMCP. - -This provider implements Azure/Microsoft Entra ID OAuth authentication -using the OAuth Proxy pattern for non-DCR OAuth flows. -""" - -from __future__ import annotations - -import hashlib -from collections import OrderedDict -from typing import TYPE_CHECKING, Any, Literal, cast - -import httpx -from key_value.aio.protocols import AsyncKeyValue - -from fastmcp.dependencies import Dependency -from fastmcp.server.auth.auth import MultiAuth -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.auth import decode_jwt_payload, parse_scopes -from fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - from azure.identity.aio import OnBehalfOfCredential - from mcp.server.auth.provider import AuthorizationParams - from mcp.shared.auth import OAuthClientInformationFull - - from fastmcp.server.auth.auth import AuthProvider - -logger = get_logger(__name__) - -# Standard OIDC scopes that should never be prefixed with identifier_uri. -# Per Microsoft docs: https://learn.microsoft.com/en-us/entra/identity-platform/scopes-oidc -# "OIDC scopes are requested as simple string identifiers without resource prefixes" -OIDC_SCOPES = frozenset({"openid", "profile", "email", "offline_access"}) - - -class AzureProvider(OAuthProxy): - """Azure (Microsoft Entra) OAuth provider for FastMCP. - - This provider implements Azure/Microsoft Entra ID authentication using the - OAuth Proxy pattern. It supports both organizational accounts and personal - Microsoft accounts depending on the tenant configuration. - - Scope Handling: - - required_scopes: Provide unprefixed scope names (e.g., ["read", "write"]) - → Automatically prefixed with identifier_uri during initialization - → Validated on all tokens and advertised to MCP clients - - additional_authorize_scopes: Provide full format (e.g., ["User.Read"]) - → NOT prefixed, NOT validated, NOT advertised to clients - → Used to request Microsoft Graph or other upstream API permissions - - Features: - - OAuth proxy to Azure/Microsoft identity platform - - JWT validation using tenant issuer and JWKS - - Supports tenant configurations: specific tenant ID, "organizations", or "consumers" - - Custom API scopes and Microsoft Graph scopes in a single provider - - Setup: - 1. Create an App registration in Azure Portal - 2. Configure Web platform redirect URI: http://localhost:8000/auth/callback (or your custom path) - 3. Add an Application ID URI under "Expose an API" (defaults to api://{client_id}) - 4. Add custom scopes (e.g., "read", "write") under "Expose an API" - 5. Set access token version to 2 in the App manifest: "requestedAccessTokenVersion": 2 - 6. Create a client secret - 7. Get Application (client) ID, Directory (tenant) ID, and client secret - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.azure import AzureProvider - - # Standard Azure (Public Cloud) - auth = AzureProvider( - client_id="your-client-id", - client_secret="your-client-secret", - tenant_id="your-tenant-id", - required_scopes=["read", "write"], # Unprefixed scope names - additional_authorize_scopes=["User.Read", "Mail.Read"], # Optional Graph scopes - base_url="http://localhost:8000", - # identifier_uri defaults to api://{client_id} - ) - - # Azure Government - auth_gov = AzureProvider( - client_id="your-client-id", - client_secret="your-client-secret", - tenant_id="your-tenant-id", - required_scopes=["read", "write"], - base_authority="login.microsoftonline.us", # Override for Azure Gov - base_url="http://localhost:8000", - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - client_id: str, - client_secret: str | None = None, - tenant_id: str, - required_scopes: list[str], - base_url: str, - identifier_uri: str | None = None, - issuer_url: str | None = None, - redirect_path: str | None = None, - additional_authorize_scopes: list[str] | None = None, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - base_authority: str = "login.microsoftonline.com", - http_client: httpx.AsyncClient | None = None, - enable_cimd: bool = True, - ) -> None: - """Initialize Azure OAuth provider. - - Args: - client_id: Azure application (client) ID from your App registration - client_secret: Azure client secret from your App registration. Optional when - using alternative credentials (e.g., managed identity with a custom - _create_upstream_oauth_client override). When omitted, jwt_signing_key - must be provided. - tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers") - identifier_uri: Optional Application ID URI for your custom API (defaults to api://{client_id}). - This URI is automatically prefixed to all required_scopes during initialization. - Example: identifier_uri="api://my-api" + required_scopes=["read"] - → tokens validated for "api://my-api/read" - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in Azure App registration (defaults to "/auth/callback") - base_authority: Azure authority base URL (defaults to "login.microsoftonline.com"). - For Azure Government, use "login.microsoftonline.us". - required_scopes: Custom API scope names WITHOUT prefix (e.g., ["read", "write"]). - - Automatically prefixed with identifier_uri during initialization - - Validated on all tokens - - Advertised in Protected Resource Metadata - - Must match scope names defined in Azure Portal under "Expose an API" - Example: ["read", "write"] → validates tokens containing ["api://xxx/read", "api://xxx/write"] - additional_authorize_scopes: Microsoft Graph or other upstream scopes in full format. - - NOT prefixed with identifier_uri - - NOT validated on tokens - - NOT advertised to MCP clients - - Used to request additional permissions from Azure (e.g., Graph API access) - Example: ["User.Read", "Mail.Read"] - These scopes allow your FastMCP server to call Microsoft Graph APIs using the - upstream Azure token, but MCP clients are unaware of them. - Note: "offline_access" is automatically included to obtain refresh tokens. - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to Azure. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - http_client: Optional httpx.AsyncClient for connection pooling in JWKS fetches. - When provided, the client is reused for JWT key fetches and the caller - is responsible for its lifecycle. When None (default), a fresh client is created per fetch. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. - """ - # Parse scopes if provided as string - parsed_required_scopes = parse_scopes(required_scopes) - parsed_additional_scopes: list[str] = ( - parse_scopes(additional_authorize_scopes) or [] - if additional_authorize_scopes - else [] - ) - - # Always include offline_access to get refresh tokens from Azure - if "offline_access" not in parsed_additional_scopes: - parsed_additional_scopes = [*parsed_additional_scopes, "offline_access"] - - # Store Azure-specific config for OBO credential creation - self._tenant_id = tenant_id - self._base_authority = base_authority - - # Cache of OBO credentials keyed by hash of user assertion token. - # Reusing credentials allows the Azure SDK's internal token cache - # to avoid redundant OBO exchanges for the same user + scopes. - self._obo_credentials: OrderedDict[str, OnBehalfOfCredential] = OrderedDict() - self._obo_max_credentials: int = 128 - - # Apply defaults - self.identifier_uri = identifier_uri or f"api://{client_id}" - self.additional_authorize_scopes: list[str] = parsed_additional_scopes - - # Always validate tokens against the app's API client ID using JWT - issuer = f"https://{base_authority}/{tenant_id}/v2.0" - jwks_uri = f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys" - - # Azure access tokens only include custom API scopes in the `scp` claim, - # NOT standard OIDC scopes (openid, profile, email, offline_access). - # Filter out OIDC scopes from validation - they'll still be sent to Azure - # during authorization (handled by _prefix_scopes_for_azure). - validation_scopes = [ - s for s in (parsed_required_scopes or []) if s not in OIDC_SCOPES - ] - if not validation_scopes: - raise ValueError( - "AzureProvider requires at least one non-OIDC scope in " - "required_scopes (e.g., 'read', 'write'). OIDC scopes like " - "'openid', 'profile', 'email', and 'offline_access' are not " - "included in Azure access token claims and cannot be used for " - "scope enforcement." - ) - - token_verifier = JWTVerifier( - jwks_uri=jwks_uri, - issuer=issuer, - audience=client_id, - algorithm="RS256", - required_scopes=validation_scopes, # Only validate non-OIDC scopes - http_client=http_client, - ) - - # Build Azure OAuth endpoints with tenant - authorization_endpoint = ( - f"https://{base_authority}/{tenant_id}/oauth2/v2.0/authorize" - ) - token_endpoint = f"https://{base_authority}/{tenant_id}/oauth2/v2.0/token" - - # Initialize OAuth proxy with Azure endpoints - # Remember there's hooks called, such as _prepare_scopes_for_token_exchange - # and _prepare_scopes_for_upstream_refresh - super().__init__( - upstream_authorization_endpoint=authorization_endpoint, - upstream_token_endpoint=token_endpoint, - upstream_client_id=client_id, - upstream_client_secret=client_secret, - token_verifier=token_verifier, - base_url=base_url, - redirect_path=redirect_path, - issuer_url=issuer_url or base_url, # Default to base_url if not specified - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - valid_scopes=parsed_required_scopes, - enable_cimd=enable_cimd, - ) - - authority_info = "" - if base_authority != "login.microsoftonline.com": - authority_info = f" using authority {base_authority}" - logger.info( - "Initialized Azure OAuth provider for client %s with tenant %s%s%s", - client_id, - tenant_id, - f" and identifier_uri {self.identifier_uri}" if self.identifier_uri else "", - authority_info, - ) - - async def authorize( - self, - client: OAuthClientInformationFull, - params: AuthorizationParams, - ) -> str: - """Start OAuth transaction and redirect to Azure AD. - - Override parent's authorize method to filter out the 'resource' parameter - which is not supported by Azure AD v2.0 endpoints. The v2.0 endpoints use - scopes to determine the resource/audience instead of a separate parameter. - - Args: - client: OAuth client information - params: Authorization parameters from the client - - Returns: - Authorization URL to redirect the user to Azure AD - """ - # Clear the resource parameter that Azure AD v2.0 doesn't support - # This parameter comes from RFC 8707 (OAuth 2.0 Resource Indicators) - # but Azure AD v2.0 uses scopes instead to determine the audience - params_to_use = params - if hasattr(params, "resource"): - original_resource = getattr(params, "resource", None) - if original_resource is not None: - params_to_use = params.model_copy(update={"resource": None}) - if original_resource: - logger.debug( - "Filtering out 'resource' parameter '%s' for Azure AD v2.0 (use scopes instead)", - original_resource, - ) - # Don't modify the scopes in params - they stay unprefixed for MCP clients - # We'll prefix them when building the Azure authorization URL (in _build_upstream_authorize_url) - auth_url = await super().authorize(client, params_to_use) - separator = "&" if "?" in auth_url else "?" - return f"{auth_url}{separator}prompt=select_account" - - def _prefix_scopes_for_azure(self, scopes: list[str]) -> list[str]: - """Prefix unprefixed custom API scopes with identifier_uri for Azure. - - This helper centralizes the scope prefixing logic used in both - authorization and token refresh flows. - - Scopes that are NOT prefixed: - - Standard OIDC scopes (openid, profile, email, offline_access) - - Fully-qualified URIs (contain "://") - - Scopes with path component (contain "/") - - Note: Microsoft Graph scopes (e.g., User.Read) should be passed via - `additional_authorize_scopes` or use fully-qualified format - (e.g., https://graph.microsoft.com/User.Read). - - Args: - scopes: List of scopes, may be prefixed or unprefixed - - Returns: - List of scopes with identifier_uri prefix applied where needed - """ - prefixed = [] - for scope in scopes: - if scope in OIDC_SCOPES: - # Standard OIDC scopes - never prefix - prefixed.append(scope) - elif "://" in scope or "/" in scope: - # Already fully-qualified (e.g., "api://xxx/read" or - # "https://graph.microsoft.com/User.Read") - prefixed.append(scope) - else: - # Unprefixed custom API scope - prefix with identifier_uri - prefixed.append(f"{self.identifier_uri}/{scope}") - return prefixed - - def _build_upstream_authorize_url( - self, txn_id: str, transaction: dict[str, Any] - ) -> str: - """Build Azure authorization URL with prefixed scopes. - - Overrides parent to prefix scopes with identifier_uri before sending to Azure, - while keeping unprefixed scopes in the transaction for MCP clients. - """ - # Get unprefixed scopes from transaction - unprefixed_scopes = transaction.get("scopes") or self.required_scopes or [] - - # Prefix scopes for Azure authorization request - prefixed_scopes = self._prefix_scopes_for_azure(unprefixed_scopes) - - # Add Microsoft Graph scopes (not validated, not prefixed) - if self.additional_authorize_scopes: - prefixed_scopes.extend(self.additional_authorize_scopes) - - # Temporarily modify transaction dict for parent's URL building - modified_transaction = transaction.copy() - modified_transaction["scopes"] = prefixed_scopes - - # Let parent build the URL with prefixed scopes - return super()._build_upstream_authorize_url(txn_id, modified_transaction) - - def _prepare_scopes_for_token_exchange(self, scopes: list[str]) -> list[str]: - """Prepare scopes for Azure authorization code exchange. - - Azure requires scopes during token exchange (AADSTS28003 error if missing). - Azure only allows ONE resource per token request (AADSTS28000), so we only - include scopes for this API plus OIDC scopes. - - Args: - scopes: Scopes from the authorization request (unprefixed) - - Returns: - List of scopes for Azure token endpoint - """ - # Prefix scopes for this API - prefixed_scopes = self._prefix_scopes_for_azure(scopes or []) - - # Add OIDC scopes only (not other API scopes) to avoid AADSTS28000 - if self.additional_authorize_scopes: - prefixed_scopes.extend( - s for s in self.additional_authorize_scopes if s in OIDC_SCOPES - ) - - deduplicated = list(dict.fromkeys(prefixed_scopes)) - logger.debug("Token exchange scopes: %s", deduplicated) - return deduplicated - - def _prepare_scopes_for_upstream_refresh(self, scopes: list[str]) -> list[str]: - """Prepare scopes for Azure token refresh. - - Azure requires fully-qualified scopes and only allows ONE resource per - token request (AADSTS28000). We include scopes for this API plus OIDC scopes. - - Args: - scopes: Base scopes from RefreshToken (unprefixed, e.g., ["read"]) - - Returns: - Deduplicated list of scopes formatted for Azure token endpoint - """ - logger.debug("Base scopes from storage: %s", scopes) - - # Filter out any additional_authorize_scopes that may have been stored - additional_scopes_set = set(self.additional_authorize_scopes or []) - base_scopes = [s for s in scopes if s not in additional_scopes_set] - - # Prefix base scopes with identifier_uri for Azure - prefixed_scopes = self._prefix_scopes_for_azure(base_scopes) - - # Add OIDC scopes only (not other API scopes) to avoid AADSTS28000 - if self.additional_authorize_scopes: - prefixed_scopes.extend( - s for s in self.additional_authorize_scopes if s in OIDC_SCOPES - ) - - deduplicated_scopes = list(dict.fromkeys(prefixed_scopes)) - logger.debug("Scopes for Azure token endpoint: %s", deduplicated_scopes) - return deduplicated_scopes - - async def _extract_upstream_claims( - self, idp_tokens: dict[str, Any] - ) -> dict[str, Any] | None: - """Extract claims from Azure token response to embed in FastMCP JWT. - - Decodes the Azure access token (which is a JWT) to extract user identity - claims. This allows gateways to inspect upstream identity information by - decoding the FastMCP JWT without needing server-side storage lookups. - - Azure access tokens contain claims like: - - sub: Subject identifier (unique per user per application) - - oid: Object ID (unique user identifier across Azure AD) - - tid: Tenant ID - - azp: Authorized party (client ID that requested the token) - - name: Display name - - given_name: First name - - family_name: Last name - - preferred_username: User principal name (email format) - - upn: User Principal Name - - email: Email address (if available) - - roles: Application roles assigned to the user - - groups: Group memberships (if configured) - - Args: - idp_tokens: Full token response from Azure, containing access_token - and potentially id_token. - - Returns: - Dict of extracted claims, or None if extraction fails. - """ - access_token = idp_tokens.get("access_token") - if not access_token: - return None - - try: - # Azure access tokens are JWTs - decode without verification - # (already validated by token_verifier during token exchange) - payload = decode_jwt_payload(access_token) - - # Extract useful identity claims - claims: dict[str, Any] = {} - claim_keys = [ - "sub", - "oid", - "tid", - "azp", - "name", - "given_name", - "family_name", - "preferred_username", - "upn", - "email", - "roles", - "groups", - ] - for claim in claim_keys: - if claim in payload: - claims[claim] = payload[claim] - - if claims: - logger.debug( - "Extracted %d Azure claims for embedding in FastMCP JWT", - len(claims), - ) - return claims - - return None - - except Exception as e: - logger.debug("Failed to extract Azure claims: %s", e) - return None - - async def get_obo_credential(self, user_assertion: str) -> OnBehalfOfCredential: - """Get a cached or new OnBehalfOfCredential for OBO token exchange. - - Credentials are cached by user assertion so the Azure SDK's internal - token cache can avoid redundant OBO exchanges when the same user - calls multiple tools with the same scopes. - - Args: - user_assertion: The user's access token to exchange via OBO. - - Returns: - A configured OnBehalfOfCredential ready for get_token() calls. - - Raises: - ImportError: If azure-identity is not installed (requires fastmcp[azure]). - """ - _require_azure_identity("OBO token exchange") - from azure.identity.aio import OnBehalfOfCredential - - key = hashlib.sha256(user_assertion.encode()).hexdigest() - - if key in self._obo_credentials: - self._obo_credentials.move_to_end(key) - return self._obo_credentials[key] - - obo_kwargs: dict[str, Any] = { - "tenant_id": self._tenant_id, - "client_id": self._upstream_client_id, - "user_assertion": user_assertion, - "authority": f"https://{self._base_authority}", - } - if self._upstream_client_secret is not None: - obo_kwargs["client_secret"] = ( - self._upstream_client_secret.get_secret_value() - ) - else: - raise ValueError( - "OBO token exchange requires either a client_secret or a subclass " - "that overrides get_obo_credential() to provide alternative credentials " - "(e.g., client_assertion_func for managed identity)." - ) - credential = OnBehalfOfCredential(**obo_kwargs) - self._obo_credentials[key] = credential - - # Evict oldest if over capacity - while len(self._obo_credentials) > self._obo_max_credentials: - _, evicted = self._obo_credentials.popitem(last=False) - await evicted.close() - - return credential - - async def close_obo_credentials(self) -> None: - """Close all cached OBO credentials.""" - credentials = list(self._obo_credentials.values()) - self._obo_credentials.clear() - for credential in credentials: - try: - await credential.close() - except Exception: - logger.debug("Error closing OBO credential", exc_info=True) - - -class AzureJWTVerifier(JWTVerifier): - """JWT verifier pre-configured for Azure AD / Microsoft Entra ID. - - Auto-configures JWKS URI, issuer, audience, and scope handling from your - Azure app registration details. Designed for Managed Identity and other - token-verification-only scenarios where AzureProvider's full OAuth proxy - isn't needed. - - Handles Azure's scope format automatically: - - Validates tokens using short-form scopes (what Azure puts in ``scp`` claims) - - Advertises full-URI scopes in OAuth metadata (what clients need to request) - - Example:: - - from fastmcp.server.auth import RemoteAuthProvider - from fastmcp.server.auth.providers.azure import AzureJWTVerifier - from pydantic import AnyHttpUrl - - verifier = AzureJWTVerifier( - client_id="your-client-id", - tenant_id="your-tenant-id", - required_scopes=["access_as_user"], - ) - - auth = RemoteAuthProvider( - token_verifier=verifier, - authorization_servers=[ - AnyHttpUrl("https://login.microsoftonline.com/your-tenant-id/v2.0") - ], - base_url="https://my-server.com", - ) - """ - - def __init__( - self, - *, - client_id: str, - tenant_id: str, - required_scopes: list[str] | None = None, - identifier_uri: str | None = None, - base_authority: str = "login.microsoftonline.com", - ): - """Initialize Azure JWT verifier. - - Args: - client_id: Azure application (client) ID from your App registration - tenant_id: Azure tenant ID (specific tenant GUID, "organizations", or "consumers"). - For multi-tenant apps ("organizations" or "consumers"), issuer validation - is skipped since Azure tokens carry the actual tenant GUID as issuer. - required_scopes: Scope names as they appear in Azure Portal under "Expose an API" - (e.g., ["access_as_user", "read"]). These are validated against - the short-form scopes in token ``scp`` claims, and automatically - prefixed with identifier_uri for OAuth metadata. - identifier_uri: Application ID URI (defaults to ``api://{client_id}``). - Used to prefix scopes in OAuth metadata so clients know the full - scope URIs to request from Azure. - base_authority: Azure authority base URL (defaults to "login.microsoftonline.com"). - For Azure Government, use "login.microsoftonline.us". - """ - self._identifier_uri = identifier_uri or f"api://{client_id}" - - # For multi-tenant apps, Azure tokens carry the actual tenant GUID as - # issuer, not the literal "organizations" or "consumers" string. Skip - # issuer validation for these — audience still protects against wrong-app tokens. - multi_tenant_values = {"organizations", "consumers", "common"} - issuer: str | None = ( - None - if tenant_id in multi_tenant_values - else f"https://{base_authority}/{tenant_id}/v2.0" - ) - - super().__init__( - jwks_uri=f"https://{base_authority}/{tenant_id}/discovery/v2.0/keys", - issuer=issuer, - audience=client_id, - algorithm="RS256", - required_scopes=required_scopes, - ) - - @property - def scopes_supported(self) -> list[str]: - """Return scopes with Azure URI prefix for OAuth metadata. - - Azure tokens contain short-form scopes (e.g., ``read``) in the ``scp`` - claim, but clients must request full URI scopes (e.g., - ``api://client-id/read``) from the Azure authorization endpoint. This - property returns the full-URI form for OAuth metadata while - ``required_scopes`` retains the short form for token validation. - """ - if not self.required_scopes: - return [] - prefixed = [] - for scope in self.required_scopes: - if scope in OIDC_SCOPES or "://" in scope or "/" in scope: - prefixed.append(scope) - else: - prefixed.append(f"{self._identifier_uri}/{scope}") - return prefixed - - -# --- Dependency injection support --- -# These require fastmcp[azure] extra for azure-identity - - -def _require_azure_identity(feature: str) -> None: - """Raise ImportError with install instructions if azure-identity is not available.""" - try: - import azure.identity # noqa: F401 - except ImportError as e: - raise ImportError( - f"{feature} requires the `azure` extra. " - "Install with: pip install 'fastmcp[azure]'" - ) from e - - -def _find_azure_provider(auth: AuthProvider | None) -> AzureProvider | None: - """Extract an AzureProvider from an auth provider, unwrapping MultiAuth if needed.""" - if isinstance(auth, AzureProvider): - return auth - - if isinstance(auth, MultiAuth) and isinstance(auth.server, AzureProvider): - return auth.server - - return None - - -class _EntraOBOToken(Dependency[str]): - """Dependency that performs OBO token exchange for Microsoft Entra. - - Uses azure.identity's OnBehalfOfCredential for async-native OBO, - with automatic token caching and refresh. Credentials are cached on - the AzureProvider so repeated tool calls reuse existing credentials - and benefit from the Azure SDK's internal token cache. - """ - - def __init__(self, scopes: list[str]): - self.scopes = scopes - - async def __aenter__(self) -> str: - _require_azure_identity("EntraOBOToken") - - from fastmcp.server.dependencies import get_access_token, get_server - - access_token = get_access_token() - if access_token is None: - raise RuntimeError( - "No access token available. Cannot perform OBO exchange." - ) - - server = get_server() - azure_provider = _find_azure_provider(server.auth) - if azure_provider is None: - raise RuntimeError( - "EntraOBOToken requires an AzureProvider as the auth provider. " - f"Current provider: {type(server.auth).__name__}" - ) - - credential = await azure_provider.get_obo_credential( - user_assertion=access_token.token, - ) - - result = await credential.get_token(*self.scopes) - return result.token - - -def EntraOBOToken(scopes: list[str]) -> str: - """Exchange the user's Entra token for a downstream API token via OBO. - - This dependency performs a Microsoft Entra On-Behalf-Of (OBO) token exchange, - allowing your MCP server to call downstream APIs (like Microsoft Graph) on - behalf of the authenticated user. - - Args: - scopes: The scopes to request for the downstream API. For Microsoft Graph, - use scopes like ["https://graph.microsoft.com/Mail.Read"] or - ["https://graph.microsoft.com/.default"]. - - Returns: - A dependency that resolves to the downstream API access token string - - Raises: - ImportError: If fastmcp[azure] is not installed - RuntimeError: If no access token is available, provider is not Azure, - or OBO exchange fails - - Example: - ```python - from fastmcp.server.auth.providers.azure import EntraOBOToken - import httpx - - @mcp.tool() - async def get_my_emails( - graph_token: str = EntraOBOToken(["https://graph.microsoft.com/Mail.Read"]) - ): - async with httpx.AsyncClient() as client: - resp = await client.get( - "https://graph.microsoft.com/v1.0/me/messages", - headers={"Authorization": f"Bearer {graph_token}"} - ) - return resp.json() - ``` - - Note: - For OBO to work, ensure the scopes are included in the AzureProvider's - `additional_authorize_scopes` parameter, and that admin consent has been - granted for those scopes in your Entra app registration. - """ - return cast(str, _EntraOBOToken(scopes)) diff --git a/src/fastmcp/server/auth/providers/clerk.py b/src/fastmcp/server/auth/providers/clerk.py deleted file mode 100644 index 409a065b3..000000000 --- a/src/fastmcp/server/auth/providers/clerk.py +++ /dev/null @@ -1,384 +0,0 @@ -"""Clerk OAuth provider for FastMCP. - -This module provides a complete Clerk OAuth integration that's ready to use -with a Clerk domain, client ID, and client secret. It handles all the complexity -of Clerk's OAuth/OIDC flow, token validation, and user management. - -Clerk uses standard OIDC endpoints derived from the instance domain -(e.g., ``https://.clerk.accounts.dev``). Token verification is -performed via the introspection endpoint (RFC 7662) for security-critical -checks (active status, audience, scopes), followed by the userinfo endpoint -for profile enrichment. Userinfo failure is non-fatal. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.clerk import ClerkProvider - - auth = ClerkProvider( - domain="saving-primate-16.clerk.accounts.dev", - client_id="your-clerk-client-id", - client_secret="your-clerk-client-secret", - base_url="https://my-server.com", - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" - -from __future__ import annotations - -import contextlib -from typing import Literal - -import httpx -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl - -from fastmcp.server.auth import TokenVerifier -from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class ClerkTokenVerifier(TokenVerifier): - """Token verifier for Clerk OAuth tokens. - - Clerk issues standard OIDC tokens. Verification uses the introspection - endpoint (RFC 7662) as the primary security gate — it confirms the token - is active and provides metadata (scopes, expiry, audience). The userinfo - endpoint is called second for profile enrichment (name, email, picture) - and its failure is non-fatal. - - When a ``client_id`` is configured, the audience from introspection is - validated against it. When ``required_scopes`` are configured, - introspection must return the token's scopes — the verifier will not - assume scopes when introspection is unavailable. - """ - - def __init__( - self, - *, - domain: str, - client_id: str | None = None, - client_secret: str | None = None, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - http_client: httpx.AsyncClient | None = None, - ): - """Initialize the Clerk token verifier. - - Args: - domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev") - client_id: Clerk OAuth client ID, used for introspection endpoint authentication - client_secret: Clerk OAuth client secret, used for introspection endpoint authentication - required_scopes: Required OAuth scopes (e.g., ["openid", "email", "profile"]) - timeout_seconds: HTTP request timeout - http_client: Optional httpx.AsyncClient for connection pooling. When provided, - the client is reused across calls and the caller is responsible for its - lifecycle. When None (default), a fresh client is created per call. - """ - super().__init__(required_scopes=required_scopes) - self.domain = domain.rstrip("/") - self._client_id = client_id - self._client_secret = client_secret - self.timeout_seconds = timeout_seconds - self._http_client = http_client - - self._userinfo_url = f"https://{self.domain}/oauth/userinfo" - self._introspection_url = f"https://{self.domain}/oauth/token_info" - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify a Clerk OAuth token via introspection and userinfo. - - Calls the introspection endpoint first to validate the token and - retrieve auth metadata (active status, scopes, expiry, audience). - If the token passes security checks, the userinfo endpoint is called - for profile enrichment. Userinfo failure is non-fatal. - - When a ``client_id`` is configured, the token's audience must match it. - When ``required_scopes`` are configured, introspection must confirm - them; tokens are rejected if scope information is unavailable. - """ - try: - async with ( - contextlib.nullcontext(self._http_client) - if self._http_client is not None - else httpx.AsyncClient(timeout=self.timeout_seconds) - ) as client: - # Step 1: Validate token via introspection (RFC 7662). - # Security-critical checks (active, audience, scopes) come first. - introspect_data_payload: dict = {"token": token} - introspect_kwargs: dict = { - "data": introspect_data_payload, - "headers": {"User-Agent": "FastMCP-Clerk-OAuth"}, - } - - if self._client_id and self._client_secret: - introspect_kwargs["auth"] = ( - self._client_id, - self._client_secret, - ) - elif self._client_id: - introspect_data_payload["client_id"] = self._client_id - - introspect_response = await client.post( - self._introspection_url, - **introspect_kwargs, - ) - - if introspect_response.status_code != 200: - logger.debug( - "Clerk introspection failed: %d", - introspect_response.status_code, - ) - return None - - introspect_data = introspect_response.json() - - # RFC 7662 requires the 'active' field in the response. - # A missing field indicates a malformed response — reject. - if "active" not in introspect_data or not introspect_data["active"]: - logger.debug( - "Clerk introspection: token inactive or missing 'active' field" - ) - return None - - scope_str = introspect_data.get("scope", "") - token_scopes = scope_str.split() if scope_str else [] - - aud = introspect_data.get("aud") or introspect_data.get("client_id") - - expires_at: int | None = None - exp = introspect_data.get("exp") - if exp is not None: - with contextlib.suppress(ValueError, TypeError): - expires_at = int(exp) - - if self._client_id and aud != self._client_id: - logger.debug( - "Clerk token audience mismatch: got %s, expected %s", - aud, - self._client_id, - ) - return None - - if self.required_scopes: - if not token_scopes: - logger.debug( - "Clerk token missing scope information; " - "cannot verify required scopes %s", - self.required_scopes, - ) - return None - token_scopes_set = set(token_scopes) - required_scopes_set = set(self.required_scopes) - if not required_scopes_set.issubset(token_scopes_set): - logger.debug( - "Clerk token missing required scopes. Has %s, needs %s", - token_scopes_set, - required_scopes_set, - ) - return None - - # Step 2: Fetch user profile via userinfo. - # Enriches the token with profile data (name, email, picture). - sub = introspect_data.get("sub") - user_data: dict = {} - try: - userinfo_response = await client.get( - self._userinfo_url, - headers={ - "Authorization": f"Bearer {token}", - "User-Agent": "FastMCP-Clerk-OAuth", - }, - ) - if userinfo_response.status_code == 200: - user_data = userinfo_response.json() - if not sub: - sub = user_data.get("sub") - except Exception as e: - logger.debug("Clerk userinfo call failed: %s", e) - - if not sub: - logger.debug("Clerk token missing 'sub' claim") - return None - - access_token = AccessToken( - token=token, - client_id=aud or sub, - scopes=token_scopes, - expires_at=expires_at, - claims={ - "sub": sub, - "aud": aud, - "email": user_data.get("email"), - "email_verified": user_data.get("email_verified"), - "name": user_data.get("name"), - "picture": user_data.get("picture"), - "given_name": user_data.get("given_name"), - "family_name": user_data.get("family_name"), - "preferred_username": user_data.get("preferred_username"), - "iss": user_data.get("iss"), - "clerk_user_data": user_data or None, - }, - ) - logger.debug("Clerk token verified successfully for sub=%s", sub) - return access_token - - except httpx.RequestError as e: - logger.debug("Failed to verify Clerk token: %s", e) - return None - except Exception as e: - logger.debug("Clerk token verification error: %s", e) - return None - - -class ClerkProvider(OAuthProxy): - """Complete Clerk OAuth provider for FastMCP. - - This provider makes it trivial to add Clerk OAuth protection to any - FastMCP server. Provide your Clerk instance domain, OAuth app credentials, - and a base URL, and you're ready to go. - - Clerk uses standard OIDC endpoints derived from the instance domain. - All endpoint URLs are constructed automatically from the domain parameter. - - Features: - - Transparent OAuth proxy to Clerk - - Automatic token validation via Clerk's userinfo & introspection APIs - - User information extraction from Clerk's OIDC claims - - PKCE support (S256) - - Minimal configuration required - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.clerk import ClerkProvider - - auth = ClerkProvider( - domain="saving-primate-16.clerk.accounts.dev", - client_id="your-clerk-client-id", - client_secret="your-clerk-client-secret", - base_url="https://my-server.com", - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - domain: str, - client_id: str, - client_secret: str | None = None, - base_url: AnyHttpUrl | str, - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str | None = None, - required_scopes: list[str] | None = None, - valid_scopes: list[str] | None = None, - timeout_seconds: int = 10, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - extra_authorize_params: dict[str, str] | None = None, - http_client: httpx.AsyncClient | None = None, - enable_cimd: bool = True, - ): - """Initialize Clerk OAuth provider. - - Args: - domain: Clerk instance domain (e.g., "saving-primate-16.clerk.accounts.dev"). - This is used to derive all OAuth/OIDC endpoint URLs. - client_id: Clerk OAuth application client ID - client_secret: Clerk OAuth application client secret. - Optional for PKCE public clients. When omitted, jwt_signing_key must be provided. - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in Clerk OAuth app (defaults to "/auth/callback") - required_scopes: Required Clerk scopes (defaults to ["openid", "email", "profile"]). - Clerk supports: "openid", "email", "profile", "public_metadata", - "private_metadata", "offline_access". - valid_scopes: All scopes that clients are allowed to request, advertised through - well-known endpoints. Defaults to required_scopes if not provided. - timeout_seconds: HTTP request timeout for Clerk API calls (defaults to 10) - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from ``platformdirs``). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes - are provided, they will be used as is. If a string is provided, it will be derived - into a 32-byte key. If not provided, the upstream client secret will be used to - derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing - clients (default True). When "external", the built-in consent screen is skipped - but no warning is logged, indicating that consent is handled externally by Clerk. - consent_csp_policy: Custom CSP policy for the consent page. - extra_authorize_params: Additional parameters to forward to Clerk's authorization - endpoint. Example: {"prompt": "login"} to force re-authentication. - http_client: Optional httpx.AsyncClient for connection pooling in token verification. - When provided, the client is reused across verify_token calls and the caller - is responsible for its lifecycle. When None (default), a fresh client is created - per call. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. - """ - domain = domain.rstrip("/") - - required_scopes_final = ( - parse_scopes(required_scopes) - if required_scopes is not None - else ["openid", "email", "profile"] - ) - - parsed_valid_scopes = ( - parse_scopes(valid_scopes) if valid_scopes is not None else None - ) - - token_verifier = ClerkTokenVerifier( - domain=domain, - client_id=client_id, - client_secret=client_secret, - required_scopes=required_scopes_final, - timeout_seconds=timeout_seconds, - http_client=http_client, - ) - - extra_authorize_params_final = ( - dict(extra_authorize_params) if extra_authorize_params else {} - ) - - super().__init__( - upstream_authorization_endpoint=f"https://{domain}/oauth/authorize", - upstream_token_endpoint=f"https://{domain}/oauth/token", - upstream_client_id=client_id, - upstream_client_secret=client_secret, - token_verifier=token_verifier, - base_url=base_url, - redirect_path=redirect_path, - issuer_url=issuer_url or base_url, - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - extra_authorize_params=extra_authorize_params_final or None, - valid_scopes=parsed_valid_scopes, - enable_cimd=enable_cimd, - ) - - logger.debug( - "Initialized Clerk OAuth provider for domain %s with scopes: %s", - domain, - required_scopes_final, - ) diff --git a/src/fastmcp/server/auth/providers/debug.py b/src/fastmcp/server/auth/providers/debug.py deleted file mode 100644 index 5b6de01e3..000000000 --- a/src/fastmcp/server/auth/providers/debug.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Debug token verifier for testing and special cases. - -This module provides a flexible token verifier that delegates validation -to a custom callable. Useful for testing, development, or scenarios where -standard verification isn't possible (like opaque tokens without introspection). - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.debug import DebugTokenVerifier - - # Accept all tokens (default - useful for testing) - auth = DebugTokenVerifier() - - # Custom sync validation logic - auth = DebugTokenVerifier(validate=lambda token: token.startswith("valid-")) - - # Custom async validation logic - async def check_cache(token: str) -> bool: - return await redis.exists(f"token:{token}") - - auth = DebugTokenVerifier(validate=check_cache) - - mcp = FastMCP("My Server", auth=auth) - ``` -""" - -from __future__ import annotations - -import inspect -from collections.abc import Awaitable, Callable - -from fastmcp.server.auth import TokenVerifier -from fastmcp.server.auth.auth import AccessToken -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class DebugTokenVerifier(TokenVerifier): - """Token verifier with custom validation logic. - - This verifier delegates token validation to a user-provided callable. - By default, it accepts all non-empty tokens (useful for testing). - - Use cases: - - Testing: Accept any token without real verification - - Development: Custom validation logic for prototyping - - Opaque tokens: When you have tokens with no introspection endpoint - - WARNING: This bypasses standard security checks. Only use in controlled - environments or when you understand the security implications. - """ - - def __init__( - self, - validate: Callable[[str], bool] - | Callable[[str], Awaitable[bool]] = lambda token: True, - client_id: str = "debug-client", - scopes: list[str] | None = None, - required_scopes: list[str] | None = None, - ): - """Initialize the debug token verifier. - - Args: - validate: Callable that takes a token string and returns True if valid. - Can be sync or async. Default accepts all tokens. - client_id: Client ID to assign to validated tokens - scopes: Scopes to assign to validated tokens - required_scopes: Required scopes (inherited from TokenVerifier base class) - """ - super().__init__(required_scopes=required_scopes) - self.validate = validate - self.client_id = client_id - self.scopes = scopes or [] - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token using custom validation logic. - - Args: - token: The token string to validate - - Returns: - AccessToken if validation succeeds, None otherwise - """ - # Reject empty tokens - if not token or not token.strip(): - logger.debug("Rejecting empty token") - return None - - try: - # Call validation function and await if result is awaitable - result = self.validate(token) - if inspect.isawaitable(result): - is_valid = await result - else: - is_valid = result - - if not is_valid: - logger.debug("Token validation failed: callable returned False") - return None - - # Return valid AccessToken - return AccessToken( - token=token, - client_id=self.client_id, - scopes=self.scopes, - expires_at=None, # No expiration - claims={"token": token}, # Store original token in claims - ) - - except Exception as e: - logger.debug("Token validation error: %s", e, exc_info=True) - return None diff --git a/src/fastmcp/server/auth/providers/descope.py b/src/fastmcp/server/auth/providers/descope.py deleted file mode 100644 index 3bdccf8d5..000000000 --- a/src/fastmcp/server/auth/providers/descope.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Descope authentication provider for FastMCP. - -This module provides DescopeProvider - a complete authentication solution that integrates -with Descope's OAuth 2.1 and OpenID Connect services, supporting Dynamic Client Registration (DCR) -for seamless MCP client authentication. -""" - -from __future__ import annotations - -from urllib.parse import urlparse - -import httpx -from pydantic import AnyHttpUrl -from starlette.responses import JSONResponse -from starlette.routing import Route - -from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class DescopeProvider(RemoteAuthProvider): - """Descope metadata provider for DCR (Dynamic Client Registration). - - This provider implements Descope integration using metadata forwarding. - This is the recommended approach for Descope DCR - as it allows Descope to handle the OAuth flow directly while FastMCP acts - as a resource server. - - IMPORTANT SETUP REQUIREMENTS: - - 1. Create an MCP Server in Descope Console: - - Go to the [MCP Servers page](https://app.descope.com/mcp-servers) of the Descope Console - - Create a new MCP Server - - Ensure that **Dynamic Client Registration (DCR)** is enabled - - Note your Well-Known URL - - 2. Note your Well-Known URL: - - Save your Well-Known URL from [MCP Server Settings](https://app.descope.com/mcp-servers) - - Format: ``https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration`` - - For detailed setup instructions, see: - https://docs.descope.com/identity-federation/inbound-apps/creating-inbound-apps#method-2-dynamic-client-registration-dcr - - Example: - ```python - from fastmcp.server.auth.providers.descope import DescopeProvider - - # Create Descope metadata provider (JWT verifier created automatically) - descope_auth = DescopeProvider( - config_url="https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration", - base_url="https://your-fastmcp-server.com", - ) - - # Use with FastMCP - mcp = FastMCP("My App", auth=descope_auth) - ``` - """ - - def __init__( - self, - *, - base_url: AnyHttpUrl | str, - config_url: AnyHttpUrl | str | None = None, - project_id: str | None = None, - descope_base_url: AnyHttpUrl | str | None = None, - required_scopes: list[str] | None = None, - scopes_supported: list[str] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, - token_verifier: TokenVerifier | None = None, - ): - """Initialize Descope metadata provider. - - Args: - base_url: Public URL of this FastMCP server - config_url: Your Descope Well-Known URL (e.g., "https://.../v1/apps/agentic/P.../M.../.well-known/openid-configuration") - This is the new recommended way. If provided, project_id and descope_base_url are ignored. - project_id: Your Descope Project ID (e.g., "P2abc123"). Used with descope_base_url for backwards compatibility. - descope_base_url: Your Descope base URL (e.g., "https://api.descope.com"). Used with project_id for backwards compatibility. - required_scopes: Optional list of scopes that must be present in validated tokens. - These scopes will be included in the protected resource metadata. - scopes_supported: Optional list of scopes to advertise in OAuth metadata. - If None, uses required_scopes. Use this when the scopes clients should - request differ from the scopes enforced on tokens. - resource_name: Optional name for the protected resource metadata. - resource_documentation: Optional documentation URL for the protected resource. - token_verifier: Optional token verifier. If None, creates JWT verifier for Descope - """ - self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) - - # Parse scopes if provided as string - parsed_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else None - ) - - # Determine which API is being used - if config_url is not None: - # New API: use config_url - # Strip /.well-known/openid-configuration from config_url if present - issuer_url = str(config_url) - if issuer_url.endswith("/.well-known/openid-configuration"): - issuer_url = issuer_url[: -len("/.well-known/openid-configuration")] - - # Parse the issuer URL to extract descope_base_url and project_id for other uses - parsed_url = urlparse(issuer_url) - path_parts = parsed_url.path.strip("/").split("/") - - # Extract project_id from path (format: /v1/apps/agentic/P.../M...) - if "agentic" in path_parts: - agentic_index = path_parts.index("agentic") - if agentic_index + 1 < len(path_parts): - self.project_id = path_parts[agentic_index + 1] - else: - raise ValueError( - f"Could not extract project_id from config_url: {issuer_url}" - ) - else: - raise ValueError( - f"Could not find 'agentic' in config_url path: {issuer_url}" - ) - - # Extract descope_base_url (scheme + netloc) - self.descope_base_url = f"{parsed_url.scheme}://{parsed_url.netloc}".rstrip( - "/" - ) - elif project_id is not None and descope_base_url is not None: - # Old API: use project_id and descope_base_url - self.project_id = project_id - descope_base_url_str = str(descope_base_url).rstrip("/") - # Ensure descope_base_url has a scheme - if not descope_base_url_str.startswith(("http://", "https://")): - descope_base_url_str = f"https://{descope_base_url_str}" - self.descope_base_url = descope_base_url_str - # Old issuer format - issuer_url = f"{self.descope_base_url}/v1/apps/{self.project_id}" - else: - raise ValueError( - "Either config_url (new API) or both project_id and descope_base_url (old API) must be provided" - ) - - # Create default JWT verifier if none provided - if token_verifier is None: - token_verifier = JWTVerifier( - jwks_uri=f"{self.descope_base_url}/{self.project_id}/.well-known/jwks.json", - issuer=issuer_url, - algorithm="RS256", - audience=self.project_id, - required_scopes=parsed_scopes, - ) - - # Initialize RemoteAuthProvider with Descope as the authorization server - super().__init__( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl(issuer_url)], - base_url=self.base_url, - scopes_supported=scopes_supported, - resource_name=resource_name, - resource_documentation=resource_documentation, - ) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get OAuth routes including Descope authorization server metadata forwarding. - - This returns the standard protected resource routes plus an authorization server - metadata endpoint that forwards Descope's OAuth metadata to clients. - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata. - """ - # Get the standard protected resource routes from RemoteAuthProvider - routes = super().get_routes(mcp_path) - - async def oauth_authorization_server_metadata(request): - """Forward Descope OAuth authorization server metadata with FastMCP customizations.""" - try: - async with httpx.AsyncClient() as client: - response = await client.get( - f"{self.descope_base_url}/v1/apps/{self.project_id}/.well-known/oauth-authorization-server" - ) - response.raise_for_status() - metadata = response.json() - return JSONResponse(metadata) - except Exception as e: - return JSONResponse( - { - "error": "server_error", - "error_description": f"Failed to fetch Descope metadata: {e}", - }, - status_code=500, - ) - - # Add Descope authorization server metadata forwarding - routes.append( - Route( - "/.well-known/oauth-authorization-server", - endpoint=oauth_authorization_server_metadata, - methods=["GET"], - ) - ) - - return routes diff --git a/src/fastmcp/server/auth/providers/discord.py b/src/fastmcp/server/auth/providers/discord.py deleted file mode 100644 index d646743f9..000000000 --- a/src/fastmcp/server/auth/providers/discord.py +++ /dev/null @@ -1,284 +0,0 @@ -"""Discord OAuth provider for FastMCP. - -This module provides a complete Discord OAuth integration that's ready to use -with just a client ID and client secret. It handles all the complexity of -Discord's OAuth flow, token validation, and user management. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.discord import DiscordProvider - - # Simple Discord OAuth protection - auth = DiscordProvider( - client_id="your-discord-client-id", - client_secret="your-discord-client-secret" - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" - -from __future__ import annotations - -import contextlib -import time -from datetime import datetime -from typing import Literal - -import httpx -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl - -from fastmcp.server.auth import TokenVerifier -from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class DiscordTokenVerifier(TokenVerifier): - """Token verifier for Discord OAuth tokens. - - Discord OAuth tokens are opaque (not JWTs), so we verify them - by calling Discord's tokeninfo API to check if they're valid and get user info. - """ - - def __init__( - self, - *, - expected_client_id: str, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - http_client: httpx.AsyncClient | None = None, - ): - """Initialize the Discord token verifier. - - Args: - expected_client_id: Expected Discord OAuth client ID for audience binding - required_scopes: Required OAuth scopes (e.g., ['email']) - timeout_seconds: HTTP request timeout - http_client: Optional httpx.AsyncClient for connection pooling. When provided, - the client is reused across calls and the caller is responsible for its - lifecycle. When None (default), a fresh client is created per call. - """ - super().__init__(required_scopes=required_scopes) - self.expected_client_id = expected_client_id - self.timeout_seconds = timeout_seconds - self._http_client = http_client - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify Discord OAuth token by calling Discord's tokeninfo API.""" - try: - async with ( - contextlib.nullcontext(self._http_client) - if self._http_client is not None - else httpx.AsyncClient(timeout=self.timeout_seconds) - ) as client: - # Use Discord's tokeninfo endpoint to validate the token - headers = { - "Authorization": f"Bearer {token}", - "User-Agent": "FastMCP-Discord-OAuth", - } - response = await client.get( - "https://discord.com/api/oauth2/@me", - headers=headers, - ) - - if response.status_code != 200: - logger.debug( - "Discord token verification failed: %d", - response.status_code, - ) - return None - - token_info = response.json() - - # Check if token is expired (Discord returns ISO timestamp) - expires_str = token_info.get("expires") - expires_at = None - if expires_str: - expires_dt = datetime.fromisoformat( - expires_str.replace("Z", "+00:00") - ) - expires_at = int(expires_dt.timestamp()) - if expires_at <= int(time.time()): - logger.debug("Discord token has expired") - return None - - token_scopes = token_info.get("scopes", []) - - # Check required scopes - if self.required_scopes: - token_scopes_set = set(token_scopes) - required_scopes_set = set(self.required_scopes) - if not required_scopes_set.issubset(token_scopes_set): - logger.debug( - "Discord token missing required scopes. Has %d, needs %d", - len(token_scopes_set), - len(required_scopes_set), - ) - return None - - user_data = token_info.get("user", {}) - application = token_info.get("application") or {} - client_id = str(application.get("id", "unknown")) - if client_id != self.expected_client_id: - logger.debug( - "Discord token app ID mismatch: expected %s, got %s", - self.expected_client_id, - client_id, - ) - return None - - # Create AccessToken with Discord user info - access_token = AccessToken( - token=token, - client_id=client_id, - scopes=token_scopes, - expires_at=expires_at, - claims={ - "sub": user_data.get("id"), - "username": user_data.get("username"), - "discriminator": user_data.get("discriminator"), - "avatar": user_data.get("avatar"), - "email": user_data.get("email"), - "verified": user_data.get("verified"), - "locale": user_data.get("locale"), - "discord_user": user_data, - "discord_token_info": token_info, - }, - ) - logger.debug("Discord token verified successfully") - return access_token - - except httpx.RequestError as e: - logger.debug("Failed to verify Discord token: %s", e) - return None - except Exception as e: - logger.debug("Discord token verification error: %s", e) - return None - - -class DiscordProvider(OAuthProxy): - """Complete Discord OAuth provider for FastMCP. - - This provider makes it trivial to add Discord OAuth protection to any - FastMCP server. Just provide your Discord OAuth app credentials and - a base URL, and you're ready to go. - - Features: - - Transparent OAuth proxy to Discord - - Automatic token validation via Discord's API - - User information extraction from Discord APIs - - Minimal configuration required - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.discord import DiscordProvider - - auth = DiscordProvider( - client_id="123456789", - client_secret="discord-client-secret-abc123...", - base_url="https://my-server.com" - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - client_id: str, - client_secret: str, - base_url: AnyHttpUrl | str, - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str | None = None, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - http_client: httpx.AsyncClient | None = None, - enable_cimd: bool = True, - ): - """Initialize Discord OAuth provider. - - Args: - client_id: Discord OAuth client ID (e.g., "123456789") - client_secret: Discord OAuth client secret (e.g., "S....") - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in Discord OAuth app (defaults to "/auth/callback") - required_scopes: Required Discord scopes (defaults to ["identify"]). Common scopes include: - - "identify" for profile info (default) - - "email" for email access - - "guilds" for server membership info - timeout_seconds: HTTP request timeout for Discord API calls (defaults to 10) - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to Discord. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - http_client: Optional httpx.AsyncClient for connection pooling in token verification. - When provided, the client is reused across verify_token calls and the caller - is responsible for its lifecycle. When None (default), a fresh client is created per call. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. - """ - # Parse scopes if provided as string - required_scopes_final = ( - parse_scopes(required_scopes) - if required_scopes is not None - else ["identify"] - ) - - # Create Discord token verifier - token_verifier = DiscordTokenVerifier( - expected_client_id=client_id, - required_scopes=required_scopes_final, - timeout_seconds=timeout_seconds, - http_client=http_client, - ) - - # Initialize OAuth proxy with Discord endpoints - super().__init__( - upstream_authorization_endpoint="https://discord.com/oauth2/authorize", - upstream_token_endpoint="https://discord.com/api/oauth2/token", - upstream_client_id=client_id, - upstream_client_secret=client_secret, - token_verifier=token_verifier, - base_url=base_url, - redirect_path=redirect_path, - issuer_url=issuer_url or base_url, # Default to base_url if not specified - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - enable_cimd=enable_cimd, - ) - - logger.debug( - "Initialized Discord OAuth provider for client %s with scopes: %s", - client_id, - required_scopes_final, - ) diff --git a/src/fastmcp/server/auth/providers/github.py b/src/fastmcp/server/auth/providers/github.py deleted file mode 100644 index b8f5a16e2..000000000 --- a/src/fastmcp/server/auth/providers/github.py +++ /dev/null @@ -1,299 +0,0 @@ -"""GitHub OAuth provider for FastMCP. - -This module provides a complete GitHub OAuth integration that's ready to use -with just a client ID and client secret. It handles all the complexity of -GitHub's OAuth flow, token validation, and user management. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.github import GitHubProvider - - # Simple GitHub OAuth protection - auth = GitHubProvider( - client_id="your-github-client-id", - client_secret="your-github-client-secret" - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" - -from __future__ import annotations - -import contextlib -from typing import Literal - -import httpx -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl - -from fastmcp.server.auth import TokenVerifier -from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.token_cache import TokenCache - -logger = get_logger(__name__) - - -class GitHubTokenVerifier(TokenVerifier): - """Token verifier for GitHub OAuth tokens. - - GitHub OAuth tokens are opaque (not JWTs), so we verify them - by calling GitHub's API to check if they're valid and get user info. - - Caching is disabled by default. Set ``cache_ttl_seconds`` to a positive - integer to cache successful verification results and avoid repeated - GitHub API calls for the same token. - """ - - def __init__( - self, - *, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - cache_ttl_seconds: int | None = None, - max_cache_size: int | None = None, - http_client: httpx.AsyncClient | None = None, - ): - """Initialize the GitHub token verifier. - - Args: - required_scopes: Required OAuth scopes (e.g., ['user:email']) - timeout_seconds: HTTP request timeout - cache_ttl_seconds: How long to cache verification results in seconds. - Caching is disabled by default (None). Set to a positive integer - to enable (e.g., 300 for 5 minutes). - max_cache_size: Maximum number of tokens to cache. Default: 10 000. - http_client: Optional httpx.AsyncClient for connection pooling. When provided, - the client is reused across calls and the caller is responsible for its - lifecycle. When None (default), a fresh client is created per call. - """ - super().__init__(required_scopes=required_scopes) - self.timeout_seconds = timeout_seconds - self._http_client = http_client - self._cache = TokenCache( - ttl_seconds=cache_ttl_seconds, - max_size=max_cache_size, - ) - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify GitHub OAuth token by calling GitHub API.""" - is_cached, cached_result = self._cache.get(token) - if is_cached: - logger.debug("GitHub token cache hit") - return cached_result - - try: - async with ( - contextlib.nullcontext(self._http_client) - if self._http_client is not None - else httpx.AsyncClient(timeout=self.timeout_seconds) - ) as client: - # Get token info from GitHub API - response = await client.get( - "https://api.github.com/user", - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github.v3+json", - "User-Agent": "FastMCP-GitHub-OAuth", - }, - ) - - if response.status_code != 200: - logger.debug( - "GitHub token verification failed: %d - %s", - response.status_code, - response.text[:200], - ) - return None - - user_data = response.json() - - # Get token scopes from GitHub API - # GitHub includes scopes in the X-OAuth-Scopes header - scopes_response = await client.get( - "https://api.github.com/user/repos", # Any authenticated endpoint - headers={ - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github.v3+json", - "User-Agent": "FastMCP-GitHub-OAuth", - }, - ) - - # Extract scopes from X-OAuth-Scopes header if available - scopes_verified = scopes_response.status_code == 200 - oauth_scopes_header = scopes_response.headers.get("x-oauth-scopes", "") - token_scopes = [ - scope.strip() - for scope in oauth_scopes_header.split(",") - if scope.strip() - ] - - # If no scopes in header, assume basic scopes based on successful user API call - if not token_scopes: - token_scopes = ["user"] # Basic scope if we can access user info - - # Check required scopes - if self.required_scopes: - token_scopes_set = set(token_scopes) - required_scopes_set = set(self.required_scopes) - if not required_scopes_set.issubset(token_scopes_set): - logger.debug( - "GitHub token missing required scopes. Has %d, needs %d", - len(token_scopes_set), - len(required_scopes_set), - ) - return None - - # Create AccessToken with GitHub user info - result = AccessToken( - token=token, - client_id=str(user_data.get("id", "unknown")), # Use GitHub user ID - scopes=token_scopes, - expires_at=None, # GitHub tokens don't typically expire - claims={ - "sub": str(user_data["id"]), - "login": user_data.get("login"), - "name": user_data.get("name"), - "email": user_data.get("email"), - "avatar_url": user_data.get("avatar_url"), - "github_user_data": user_data, - }, - ) - if scopes_verified: - self._cache.set(token, result) - return result - - except httpx.RequestError as e: - logger.debug("Failed to verify GitHub token: %s", e) - return None - except Exception as e: - logger.debug("GitHub token verification error: %s", e) - return None - - -class GitHubProvider(OAuthProxy): - """Complete GitHub OAuth provider for FastMCP. - - This provider makes it trivial to add GitHub OAuth protection to any - FastMCP server. Just provide your GitHub OAuth app credentials and - a base URL, and you're ready to go. - - Features: - - Transparent OAuth proxy to GitHub - - Automatic token validation via GitHub API - - User information extraction - - Minimal configuration required - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.github import GitHubProvider - - auth = GitHubProvider( - client_id="Ov23li...", - client_secret="abc123...", - base_url="https://my-server.com" - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - client_id: str, - client_secret: str, - base_url: AnyHttpUrl | str, - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str | None = None, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - cache_ttl_seconds: int | None = None, - max_cache_size: int | None = None, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - http_client: httpx.AsyncClient | None = None, - enable_cimd: bool = True, - ): - """Initialize GitHub OAuth provider. - - Args: - client_id: GitHub OAuth app client ID (e.g., "Ov23li...") - client_secret: GitHub OAuth app client secret - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/auth/callback") - required_scopes: Required GitHub scopes (defaults to ["user"]) - timeout_seconds: HTTP request timeout for GitHub API calls (defaults to 10) - cache_ttl_seconds: How long to cache token verification results in seconds. - Caching is disabled by default (None). Set to a positive integer to - enable (e.g., 300 for 5 minutes). - max_cache_size: Maximum number of tokens to cache. Default: 10 000. - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to GitHub. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - http_client: Optional httpx.AsyncClient for connection pooling in token verification. - When provided, the client is reused across verify_token calls and the caller - is responsible for its lifecycle. When None (default), a fresh client is created per call. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. - """ - # Parse scopes if provided as string - required_scopes_final = ( - parse_scopes(required_scopes) if required_scopes is not None else ["user"] - ) - - # Create GitHub token verifier - token_verifier = GitHubTokenVerifier( - required_scopes=required_scopes_final, - timeout_seconds=timeout_seconds, - cache_ttl_seconds=cache_ttl_seconds, - max_cache_size=max_cache_size, - http_client=http_client, - ) - - # Initialize OAuth proxy with GitHub endpoints - super().__init__( - upstream_authorization_endpoint="https://github.com/login/oauth/authorize", - upstream_token_endpoint="https://github.com/login/oauth/access_token", - upstream_client_id=client_id, - upstream_client_secret=client_secret, - token_verifier=token_verifier, - base_url=base_url, - redirect_path=redirect_path, - issuer_url=issuer_url or base_url, # Default to base_url if not specified - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - enable_cimd=enable_cimd, - ) - - logger.debug( - "Initialized GitHub OAuth provider for client %s with scopes: %s", - client_id, - required_scopes_final, - ) diff --git a/src/fastmcp/server/auth/providers/google.py b/src/fastmcp/server/auth/providers/google.py deleted file mode 100644 index 48b84b9c0..000000000 --- a/src/fastmcp/server/auth/providers/google.py +++ /dev/null @@ -1,361 +0,0 @@ -"""Google OAuth provider for FastMCP. - -This module provides a complete Google OAuth integration that's ready to use -with just a client ID and client secret. It handles all the complexity of -Google's OAuth flow, token validation, and user management. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.google import GoogleProvider - - # Simple Google OAuth protection - auth = GoogleProvider( - client_id="your-google-client-id.apps.googleusercontent.com", - client_secret="your-google-client-secret" - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" - -from __future__ import annotations - -import contextlib -import time -from typing import Literal - -import httpx -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl - -from fastmcp.server.auth import TokenVerifier -from fastmcp.server.auth.auth import AccessToken -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -GOOGLE_SCOPE_ALIASES: dict[str, str] = { - "email": "https://www.googleapis.com/auth/userinfo.email", - "profile": "https://www.googleapis.com/auth/userinfo.profile", -} - - -def _normalize_google_scope(scope: str) -> str: - """Normalize a Google scope shorthand to its canonical full URI. - - Google accepts shorthand scopes like "email" and "profile" in authorization - requests, but returns the full URI form in token responses. This normalizes - to the full URI so comparisons work regardless of which form was used. - """ - return GOOGLE_SCOPE_ALIASES.get(scope, scope) - - -class GoogleTokenVerifier(TokenVerifier): - """Token verifier for Google OAuth tokens. - - Google OAuth tokens are opaque (not JWTs), so we verify them by calling - Google's tokeninfo endpoint with the access token as a query parameter. - This returns the OAuth app ID (``aud``), granted scopes, and expiry time. - User profile data (name, picture, etc.) is fetched separately from the - v2 userinfo endpoint when the token is valid. - """ - - def __init__( - self, - *, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - http_client: httpx.AsyncClient | None = None, - ): - """Initialize the Google token verifier. - - Args: - required_scopes: Required OAuth scopes (e.g., ['openid', 'https://www.googleapis.com/auth/userinfo.email']) - timeout_seconds: HTTP request timeout - http_client: Optional httpx.AsyncClient for connection pooling. When provided, - the client is reused across calls and the caller is responsible for its - lifecycle. When None (default), a fresh client is created per call. - """ - normalized = ( - [_normalize_google_scope(s) for s in required_scopes] - if required_scopes - else required_scopes - ) - super().__init__(required_scopes=normalized) - self.timeout_seconds = timeout_seconds - self._http_client = http_client - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify a Google OAuth token using the tokeninfo endpoint. - - Calls ``https://oauth2.googleapis.com/tokeninfo?access_token=TOKEN`` - to validate the token and retrieve the OAuth app ID (``aud``), granted - scopes, and expiry time. On success, fetches user profile data from - the v2 userinfo endpoint to populate name, picture, and locale claims. - """ - try: - async with ( - contextlib.nullcontext(self._http_client) - if self._http_client is not None - else httpx.AsyncClient(timeout=self.timeout_seconds) - ) as client: - # Step 1: Verify token via tokeninfo endpoint. - # Returns aud (OAuth app ID), scope (space-separated), expires_in, sub, email. - response = await client.get( - "https://oauth2.googleapis.com/tokeninfo", - params={"access_token": token}, - headers={"User-Agent": "FastMCP-Google-OAuth"}, - ) - - if response.status_code != 200: - logger.debug( - "Google token verification failed: %d", - response.status_code, - ) - return None - - token_data = response.json() - - # aud is the OAuth app ID (client_id / audience) - aud = token_data.get("aud") - if not aud: - logger.debug("Google tokeninfo missing 'aud' claim") - return None - - # sub is required (unique Google user ID) - sub = token_data.get("sub") - if not sub: - logger.debug("Google tokeninfo missing 'sub' claim") - return None - - # Parse scopes directly from the tokeninfo response (space-separated) - scope_str = token_data.get("scope", "") - token_scopes = scope_str.split() if scope_str else [] - - # Check required scopes - if self.required_scopes: - token_scopes_set = set(token_scopes) - required_scopes_set = set(self.required_scopes) - if not required_scopes_set.issubset(token_scopes_set): - logger.debug( - "Google token missing required scopes. Has %d, needs %d", - len(token_scopes_set), - len(required_scopes_set), - ) - return None - - # Compute expiry from expires_in (seconds until expiry) - expires_at: int | None = None - expires_in = token_data.get("expires_in") - if expires_in is not None: - with contextlib.suppress(ValueError, TypeError): - expires_at = int(time.time()) + int(expires_in) - - # Step 2: Fetch user profile from v2 userinfo endpoint. - # tokeninfo provides auth data; userinfo provides name, picture, locale. - user_data: dict = {} - try: - userinfo_response = await client.get( - "https://www.googleapis.com/oauth2/v2/userinfo", - headers={ - "Authorization": f"Bearer {token}", - "User-Agent": "FastMCP-Google-OAuth", - }, - ) - if userinfo_response.status_code == 200: - user_data = userinfo_response.json() - except Exception as e: - logger.debug("Failed to fetch Google user profile: %s", e) - - access_token = AccessToken( - token=token, - client_id=aud, - scopes=token_scopes, - expires_at=expires_at, - claims={ - "sub": sub, - "aud": aud, - "email": token_data.get("email") or user_data.get("email"), - "email_verified": token_data.get("email_verified") - or user_data.get("verified_email"), - "name": user_data.get("name"), - "picture": user_data.get("picture"), - "given_name": user_data.get("given_name"), - "family_name": user_data.get("family_name"), - "locale": user_data.get("locale"), - "google_user_data": user_data or None, - }, - ) - logger.debug("Google token verified successfully") - return access_token - - except httpx.RequestError as e: - logger.debug("Failed to verify Google token: %s", e) - return None - except Exception as e: - logger.debug("Google token verification error: %s", e) - return None - - -class GoogleProvider(OAuthProxy): - """Complete Google OAuth provider for FastMCP. - - This provider makes it trivial to add Google OAuth protection to any - FastMCP server. Just provide your Google OAuth app credentials and - a base URL, and you're ready to go. - - Features: - - Transparent OAuth proxy to Google - - Automatic token validation via Google's tokeninfo API - - User information extraction from Google APIs - - Minimal configuration required - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.google import GoogleProvider - - auth = GoogleProvider( - client_id="123456789.apps.googleusercontent.com", - client_secret="GOCSPX-abc123...", - base_url="https://my-server.com" - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - client_id: str, - client_secret: str | None = None, - base_url: AnyHttpUrl | str, - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str | None = None, - required_scopes: list[str] | None = None, - valid_scopes: list[str] | None = None, - timeout_seconds: int = 10, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - extra_authorize_params: dict[str, str] | None = None, - http_client: httpx.AsyncClient | None = None, - enable_cimd: bool = True, - ): - """Initialize Google OAuth provider. - - Args: - client_id: Google OAuth client ID (e.g., "123456789.apps.googleusercontent.com") - client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123..."). - Optional for PKCE public clients (e.g., native apps). When omitted, - jwt_signing_key must be provided. - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in Google OAuth app (defaults to "/auth/callback") - required_scopes: Required Google scopes (defaults to ["openid"]). Common scopes include: - - "openid" for OpenID Connect (default) - - "https://www.googleapis.com/auth/userinfo.email" for email access - - "https://www.googleapis.com/auth/userinfo.profile" for profile info - Google scope shorthands like "email" and "profile" are automatically - normalized to their full URI forms for token verification. - valid_scopes: All scopes that clients are allowed to request, advertised through - well-known endpoints. Defaults to required_scopes if not provided. Use this - when you want clients to be able to request additional scopes beyond the - required minimum. Shorthands are normalized to full URI forms. - timeout_seconds: HTTP request timeout for Google API calls (defaults to 10) - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to Google. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by Google's own consent). - SECURITY WARNING: Only set to False for local development or testing environments. - extra_authorize_params: Additional parameters to forward to Google's authorization endpoint. - By default, GoogleProvider sets {"access_type": "offline", "prompt": "consent"} to ensure - refresh tokens are returned. You can override these defaults or add additional parameters. - Example: {"prompt": "select_account"} to let users choose their Google account. - http_client: Optional httpx.AsyncClient for connection pooling in token verification. - When provided, the client is reused across verify_token calls and the caller - is responsible for its lifecycle. When None (default), a fresh client is created per call. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. - """ - # Parse scopes if provided as string - # Google requires at least one scope - openid is the minimal OIDC scope - required_scopes_final = ( - parse_scopes(required_scopes) if required_scopes is not None else ["openid"] - ) - - # Normalize valid_scopes if provided - parsed_valid_scopes = ( - parse_scopes(valid_scopes) if valid_scopes is not None else None - ) - valid_scopes_final = ( - [_normalize_google_scope(s) for s in parsed_valid_scopes] - if parsed_valid_scopes is not None - else None - ) - - # Create Google token verifier - # Normalization of shorthand scopes (e.g. "email" -> full URI) happens - # inside GoogleTokenVerifier so required_scopes match what Google returns. - token_verifier = GoogleTokenVerifier( - required_scopes=required_scopes_final, - timeout_seconds=timeout_seconds, - http_client=http_client, - ) - - # Set Google-specific defaults for extra authorize params - # access_type=offline ensures refresh tokens are returned - # prompt=consent forces consent screen to get refresh token (Google only issues on first auth otherwise) - google_defaults = { - "access_type": "offline", - "prompt": "consent", - } - # User-provided params override defaults - if extra_authorize_params: - google_defaults.update(extra_authorize_params) - extra_authorize_params_final = google_defaults - - # Initialize OAuth proxy with Google endpoints - super().__init__( - upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth", - upstream_token_endpoint="https://oauth2.googleapis.com/token", - upstream_client_id=client_id, - upstream_client_secret=client_secret, - token_verifier=token_verifier, - base_url=base_url, - redirect_path=redirect_path, - issuer_url=issuer_url or base_url, # Default to base_url if not specified - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - extra_authorize_params=extra_authorize_params_final, - valid_scopes=valid_scopes_final, - enable_cimd=enable_cimd, - ) - - logger.debug( - "Initialized Google OAuth provider for client %s with scopes: %s", - client_id, - required_scopes_final, - ) diff --git a/src/fastmcp/server/auth/providers/in_memory.py b/src/fastmcp/server/auth/providers/in_memory.py deleted file mode 100644 index 08a7fc2a1..000000000 --- a/src/fastmcp/server/auth/providers/in_memory.py +++ /dev/null @@ -1,364 +0,0 @@ -import secrets -import time - -from mcp.server.auth.provider import ( - AccessToken, - AuthorizationCode, - AuthorizationParams, - AuthorizeError, - RefreshToken, - TokenError, - construct_redirect_uri, -) -from mcp.shared.auth import ( - OAuthClientInformationFull, - OAuthToken, -) -from pydantic import AnyHttpUrl - -from fastmcp.server.auth.auth import ( - ClientRegistrationOptions, - OAuthProvider, - RevocationOptions, -) - -# Default expiration times (in seconds) -DEFAULT_AUTH_CODE_EXPIRY_SECONDS = 5 * 60 # 5 minutes -DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS = 60 * 60 # 1 hour -DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS = None # No expiry - - -class InMemoryOAuthProvider(OAuthProvider): - """ - An in-memory OAuth provider for testing purposes. - It simulates the OAuth 2.1 flow locally without external calls. - """ - - def __init__( - self, - base_url: AnyHttpUrl | str | None = None, - service_documentation_url: AnyHttpUrl | str | None = None, - client_registration_options: ClientRegistrationOptions | None = None, - revocation_options: RevocationOptions | None = None, - required_scopes: list[str] | None = None, - ): - super().__init__( - base_url=base_url or "http://fastmcp.example.com", - service_documentation_url=service_documentation_url, - client_registration_options=client_registration_options, - revocation_options=revocation_options, - required_scopes=required_scopes, - ) - self.clients: dict[str, OAuthClientInformationFull] = {} - self.auth_codes: dict[str, AuthorizationCode] = {} - self.access_tokens: dict[str, AccessToken] = {} - self.refresh_tokens: dict[str, RefreshToken] = {} - - # For revoking associated tokens - self._access_to_refresh_map: dict[ - str, str - ] = {} # access_token_str -> refresh_token_str - self._refresh_to_access_map: dict[ - str, str - ] = {} # refresh_token_str -> access_token_str - - async def get_client(self, client_id: str) -> OAuthClientInformationFull | None: - return self.clients.get(client_id) - - async def register_client(self, client_info: OAuthClientInformationFull) -> None: - # Validate scopes against valid_scopes if configured (matches MCP SDK behavior) - if ( - client_info.scope is not None - and self.client_registration_options is not None - and self.client_registration_options.valid_scopes is not None - ): - requested_scopes = set(client_info.scope.split()) - valid_scopes = set(self.client_registration_options.valid_scopes) - invalid_scopes = requested_scopes - valid_scopes - if invalid_scopes: - raise ValueError( - f"Requested scopes are not valid: {', '.join(invalid_scopes)}" - ) - - if client_info.client_id is None: - raise ValueError("client_id is required for client registration") - if client_info.client_id in self.clients: - # As per RFC 7591, if client_id is already known, it's an update. - # For this simple provider, we'll treat it as re-registration. - # A real provider might handle updates or raise errors for conflicts. - pass - self.clients[client_info.client_id] = client_info - - async def authorize( - self, client: OAuthClientInformationFull, params: AuthorizationParams - ) -> str: - """ - Simulates user authorization and generates an authorization code. - Returns a redirect URI with the code and state. - """ - if client.client_id not in self.clients: - raise AuthorizeError( - error="unauthorized_client", - error_description=f"Client '{client.client_id}' not registered.", - ) - - # Validate redirect_uri (already validated by AuthorizationHandler, but good practice) - try: - # OAuthClientInformationFull should have a method like validate_redirect_uri - # For this test provider, we assume it's valid if it matches one in client_info - # The AuthorizationHandler already does robust validation using client.validate_redirect_uri - if client.redirect_uris and params.redirect_uri not in client.redirect_uris: - # This check might be too simplistic if redirect_uris can be patterns - # or if params.redirect_uri is None and client has a default. - # However, the AuthorizationHandler handles the primary validation. - pass # Let's assume AuthorizationHandler did its job. - except Exception as e: # Replace with specific validation error if client.validate_redirect_uri existed - raise AuthorizeError( - error="invalid_request", error_description="Invalid redirect_uri." - ) from e - - auth_code_value = f"test_auth_code_{secrets.token_hex(16)}" - expires_at = time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS - - # Ensure scopes are a list - scopes_list = params.scopes if params.scopes is not None else [] - if client.scope: # Filter params.scopes against client's registered scopes - client_allowed_scopes = set(client.scope.split()) - scopes_list = [s for s in scopes_list if s in client_allowed_scopes] - - if client.client_id is None: - raise AuthorizeError( - error="invalid_client", error_description="Client ID is required" - ) - auth_code = AuthorizationCode( - code=auth_code_value, - client_id=client.client_id, - redirect_uri=params.redirect_uri, - redirect_uri_provided_explicitly=params.redirect_uri_provided_explicitly, - scopes=scopes_list, - expires_at=expires_at, - code_challenge=params.code_challenge, - # code_challenge_method is assumed S256 by the framework - ) - self.auth_codes[auth_code_value] = auth_code - - return construct_redirect_uri( - str(params.redirect_uri), code=auth_code_value, state=params.state - ) - - async def load_authorization_code( - self, client: OAuthClientInformationFull, authorization_code: str - ) -> AuthorizationCode | None: - auth_code_obj = self.auth_codes.get(authorization_code) - if auth_code_obj: - if auth_code_obj.client_id != client.client_id: - return None # Belongs to a different client - if auth_code_obj.expires_at < time.time(): - del self.auth_codes[authorization_code] # Expired - return None - return auth_code_obj - return None - - async def exchange_authorization_code( - self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode - ) -> OAuthToken: - # Authorization code should have been validated (existence, expiry, client_id match) - # by the TokenHandler calling load_authorization_code before this. - # We might want to re-verify or simply trust it's valid. - - if authorization_code.code not in self.auth_codes: - raise TokenError( - "invalid_grant", "Authorization code not found or already used." - ) - - # Consume the auth code - del self.auth_codes[authorization_code.code] - - access_token_value = f"test_access_token_{secrets.token_hex(32)}" - refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}" - - access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS) - - # Refresh token expiry - refresh_token_expires_at = None - if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None: - refresh_token_expires_at = int( - time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS - ) - - if client.client_id is None: - raise TokenError("invalid_client", "Client ID is required") - self.access_tokens[access_token_value] = AccessToken( - token=access_token_value, - client_id=client.client_id, - scopes=authorization_code.scopes, - expires_at=access_token_expires_at, - ) - self.refresh_tokens[refresh_token_value] = RefreshToken( - token=refresh_token_value, - client_id=client.client_id, - scopes=authorization_code.scopes, # Refresh token inherits scopes - expires_at=refresh_token_expires_at, - ) - - self._access_to_refresh_map[access_token_value] = refresh_token_value - self._refresh_to_access_map[refresh_token_value] = access_token_value - - return OAuthToken( - access_token=access_token_value, - token_type="Bearer", - expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS, - refresh_token=refresh_token_value, - scope=" ".join(authorization_code.scopes), - ) - - async def load_refresh_token( - self, client: OAuthClientInformationFull, refresh_token: str - ) -> RefreshToken | None: - token_obj = self.refresh_tokens.get(refresh_token) - if token_obj: - if token_obj.client_id != client.client_id: - return None # Belongs to different client - if token_obj.expires_at is not None and token_obj.expires_at < time.time(): - self._revoke_internal( - refresh_token_str=token_obj.token - ) # Clean up expired - return None - return token_obj - return None - - async def exchange_refresh_token( - self, - client: OAuthClientInformationFull, - refresh_token: RefreshToken, # This is the RefreshToken object, already loaded - scopes: list[str], # Requested scopes for the new access token - ) -> OAuthToken: - # Validate scopes: requested scopes must be a subset of original scopes - original_scopes = set(refresh_token.scopes) - requested_scopes = set(scopes) - if not requested_scopes.issubset(original_scopes): - raise TokenError( - "invalid_scope", - "Requested scopes exceed those authorized by the refresh token.", - ) - - # Invalidate old refresh token and its associated access token (rotation) - self._revoke_internal(refresh_token_str=refresh_token.token) - - # Issue new tokens - new_access_token_value = f"test_access_token_{secrets.token_hex(32)}" - new_refresh_token_value = f"test_refresh_token_{secrets.token_hex(32)}" - - access_token_expires_at = int(time.time() + DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS) - - # Refresh token expiry - refresh_token_expires_at = None - if DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS is not None: - refresh_token_expires_at = int( - time.time() + DEFAULT_REFRESH_TOKEN_EXPIRY_SECONDS - ) - - if client.client_id is None: - raise TokenError("invalid_client", "Client ID is required") - self.access_tokens[new_access_token_value] = AccessToken( - token=new_access_token_value, - client_id=client.client_id, - scopes=scopes, # Use newly requested (and validated) scopes - expires_at=access_token_expires_at, - ) - self.refresh_tokens[new_refresh_token_value] = RefreshToken( - token=new_refresh_token_value, - client_id=client.client_id, - scopes=scopes, # New refresh token also gets these scopes - expires_at=refresh_token_expires_at, - ) - - self._access_to_refresh_map[new_access_token_value] = new_refresh_token_value - self._refresh_to_access_map[new_refresh_token_value] = new_access_token_value - - return OAuthToken( - access_token=new_access_token_value, - token_type="Bearer", - expires_in=DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS, - refresh_token=new_refresh_token_value, - scope=" ".join(scopes), - ) - - async def load_access_token(self, token: str) -> AccessToken | None: # type: ignore[override] # ty:ignore[invalid-method-override] - token_obj = self.access_tokens.get(token) - if token_obj: - if token_obj.expires_at is not None and token_obj.expires_at < time.time(): - self._revoke_internal( - access_token_str=token_obj.token - ) # Clean up expired - return None - return token_obj - return None - - async def verify_token(self, token: str) -> AccessToken | None: # type: ignore[override] # ty:ignore[invalid-method-override] - """ - Verify a bearer token and return access info if valid. - - This method implements the TokenVerifier protocol by delegating - to our existing load_access_token method. - - Args: - token: The token string to validate - - Returns: - AccessToken object if valid, None if invalid or expired - """ - return await self.load_access_token(token) - - def _revoke_internal( - self, access_token_str: str | None = None, refresh_token_str: str | None = None - ): - """Internal helper to remove tokens and their associations.""" - removed_access_token = None - removed_refresh_token = None - - if access_token_str: - if access_token_str in self.access_tokens: - del self.access_tokens[access_token_str] - removed_access_token = access_token_str - - # Get associated refresh token - associated_refresh = self._access_to_refresh_map.pop(access_token_str, None) - if associated_refresh: - if associated_refresh in self.refresh_tokens: - del self.refresh_tokens[associated_refresh] - removed_refresh_token = associated_refresh - self._refresh_to_access_map.pop(associated_refresh, None) - - if refresh_token_str: - if refresh_token_str in self.refresh_tokens: - del self.refresh_tokens[refresh_token_str] - removed_refresh_token = refresh_token_str - - # Get associated access token - associated_access = self._refresh_to_access_map.pop(refresh_token_str, None) - if associated_access: - if associated_access in self.access_tokens: - del self.access_tokens[associated_access] - removed_access_token = associated_access - self._access_to_refresh_map.pop(associated_access, None) - - # Clean up any dangling references if one part of the pair was already gone - if removed_access_token and removed_access_token in self._access_to_refresh_map: - del self._access_to_refresh_map[removed_access_token] - if ( - removed_refresh_token - and removed_refresh_token in self._refresh_to_access_map - ): - del self._refresh_to_access_map[removed_refresh_token] - - async def revoke_token( - self, - token: AccessToken | RefreshToken, - ) -> None: - """Revokes an access or refresh token and its counterpart.""" - if isinstance(token, AccessToken): - self._revoke_internal(access_token_str=token.token) - elif isinstance(token, RefreshToken): - self._revoke_internal(refresh_token_str=token.token) - # If token is not found or already revoked, _revoke_internal does nothing, which is correct. diff --git a/src/fastmcp/server/auth/providers/introspection.py b/src/fastmcp/server/auth/providers/introspection.py deleted file mode 100644 index 65d9d86ce..000000000 --- a/src/fastmcp/server/auth/providers/introspection.py +++ /dev/null @@ -1,305 +0,0 @@ -"""OAuth 2.0 Token Introspection (RFC 7662) provider for FastMCP. - -This module provides token verification for opaque tokens using the OAuth 2.0 -Token Introspection protocol defined in RFC 7662. It allows FastMCP servers to -validate tokens issued by authorization servers that don't use JWT format. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier - - # Verify opaque tokens via RFC 7662 introspection - verifier = IntrospectionTokenVerifier( - introspection_url="https://auth.example.com/oauth/introspect", - client_id="your-client-id", - client_secret="your-client-secret", - required_scopes=["read", "write"] - ) - - mcp = FastMCP("My Protected Server", auth=verifier) - ``` -""" - -from __future__ import annotations - -import base64 -import contextlib -import time -from typing import Any, Literal, get_args - -import httpx -from pydantic import AnyHttpUrl, SecretStr - -from fastmcp.server.auth import AccessToken, TokenVerifier -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.token_cache import TokenCache - -logger = get_logger(__name__) - - -ClientAuthMethod = Literal["client_secret_basic", "client_secret_post"] - - -class IntrospectionTokenVerifier(TokenVerifier): - """ - OAuth 2.0 Token Introspection verifier (RFC 7662). - - This verifier validates opaque tokens by calling an OAuth 2.0 token introspection - endpoint. Unlike JWT verification which is stateless, token introspection requires - a network call to the authorization server for each token validation. - - The verifier authenticates to the introspection endpoint using either: - - HTTP Basic Auth (client_secret_basic, default): credentials in Authorization header - - POST body authentication (client_secret_post): credentials in request body - - Both methods are specified in RFC 6749 (OAuth 2.0) and RFC 7662 (Token Introspection). - - Use this when: - - Your authorization server issues opaque (non-JWT) tokens - - You need to validate tokens from Auth0, Okta, Keycloak, or other OAuth servers - - Your tokens require real-time revocation checking - - Your authorization server supports RFC 7662 introspection - - Caching is disabled by default to preserve real-time revocation semantics. - Set ``cache_ttl_seconds`` to enable caching and reduce load on the - introspection endpoint (e.g., ``cache_ttl_seconds=300`` for 5 minutes). - - Example: - ```python - verifier = IntrospectionTokenVerifier( - introspection_url="https://auth.example.com/oauth/introspect", - client_id="my-service", - client_secret="secret-key", - required_scopes=["api:read"] - ) - ``` - """ - - def __init__( - self, - *, - introspection_url: str, - client_id: str, - client_secret: str | SecretStr, - client_auth_method: ClientAuthMethod = "client_secret_basic", - timeout_seconds: int = 10, - required_scopes: list[str] | None = None, - base_url: AnyHttpUrl | str | None = None, - cache_ttl_seconds: int | None = None, - max_cache_size: int | None = None, - http_client: httpx.AsyncClient | None = None, - ): - """ - Initialize the introspection token verifier. - - Args: - introspection_url: URL of the OAuth 2.0 token introspection endpoint - client_id: OAuth client ID for authenticating to the introspection endpoint - client_secret: OAuth client secret for authenticating to the introspection endpoint - client_auth_method: Client authentication method. "client_secret_basic" (default) - uses HTTP Basic Auth header, "client_secret_post" sends credentials in POST body - timeout_seconds: HTTP request timeout in seconds (default: 10) - required_scopes: Required scopes for all tokens (optional) - base_url: Base URL for TokenVerifier protocol - cache_ttl_seconds: How long to cache introspection results in seconds. - Caching is disabled by default (None) to preserve real-time - revocation semantics. Set to a positive integer to enable caching - (e.g., 300 for 5 minutes). - max_cache_size: Maximum number of tokens to cache when caching is - enabled. Default: 10000. - http_client: Optional httpx.AsyncClient for connection pooling. When provided, - the client is reused across calls and the caller is responsible for its - lifecycle. When None (default), a fresh client is created per call. - """ - # Parse scopes if provided as string - parsed_required_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else None - ) - - super().__init__(base_url=base_url, required_scopes=parsed_required_scopes) - - self.introspection_url = introspection_url - self.client_id = client_id - self.client_secret = ( - client_secret.get_secret_value() - if isinstance(client_secret, SecretStr) - else client_secret - ) - - # Validate client_auth_method to catch typos/invalid values early - valid_methods = get_args(ClientAuthMethod) - if client_auth_method not in valid_methods: - options = " or ".join(f"'{m}'" for m in valid_methods) - raise ValueError( - f"Invalid client_auth_method: {client_auth_method!r}. " - f"Must be {options}." - ) - self.client_auth_method: ClientAuthMethod = client_auth_method - - self.timeout_seconds = timeout_seconds - self._http_client = http_client - self.logger = get_logger(__name__) - - self._cache = TokenCache( - ttl_seconds=cache_ttl_seconds, - max_size=max_cache_size, - ) - - def _create_basic_auth_header(self) -> str: - """Create HTTP Basic Auth header value from client credentials.""" - credentials = f"{self.client_id}:{self.client_secret}" - encoded = base64.b64encode(credentials.encode("utf-8")).decode("utf-8") - return f"Basic {encoded}" - - def _extract_scopes(self, introspection_response: dict[str, Any]) -> list[str]: - """ - Extract scopes from introspection response. - - RFC 7662 allows scopes to be returned as either: - - A space-separated string in the 'scope' field - - An array of strings in the 'scope' field (less common but valid) - """ - scope_value = introspection_response.get("scope") - - if scope_value is None: - return [] - - # Handle string (space-separated) scopes - if isinstance(scope_value, str): - return [s.strip() for s in scope_value.split() if s.strip()] - - # Handle array of scopes - if isinstance(scope_value, list): - return [str(s) for s in scope_value if s] - - return [] - - async def verify_token(self, token: str) -> AccessToken | None: - """ - Verify a bearer token using OAuth 2.0 Token Introspection (RFC 7662). - - This method makes a POST request to the introspection endpoint with the token, - authenticated using the configured client authentication method (client_secret_basic - or client_secret_post). - - Results are cached in-memory to reduce load on the introspection endpoint. - Cache TTL and size are configurable via constructor parameters. - - Args: - token: The opaque token string to validate - - Returns: - AccessToken object if valid and active, None if invalid, inactive, or expired - """ - # Check cache first - is_cached, cached_result = self._cache.get(token) - if is_cached: - self.logger.debug("Token introspection cache hit") - return cached_result - - try: - async with ( - contextlib.nullcontext(self._http_client) - if self._http_client is not None - else httpx.AsyncClient(timeout=self.timeout_seconds) - ) as client: - # Prepare introspection request per RFC 7662 - # Build request data with token and token_type_hint - data = { - "token": token, - "token_type_hint": "access_token", - } - - # Build headers - headers = { - "Content-Type": "application/x-www-form-urlencoded", - "Accept": "application/json", - } - - # Add client authentication based on method - if self.client_auth_method == "client_secret_basic": - headers["Authorization"] = self._create_basic_auth_header() - elif self.client_auth_method == "client_secret_post": - data["client_id"] = self.client_id - data["client_secret"] = self.client_secret - - response = await client.post( - self.introspection_url, - data=data, - headers=headers, - ) - - # Check for HTTP errors - don't cache HTTP errors (may be transient) - if response.status_code != 200: - self.logger.debug( - "Token introspection failed: HTTP %d - %s", - response.status_code, - response.text[:200] if response.text else "", - ) - return None - - introspection_data = response.json() - - # Check if token is active (required field per RFC 7662) - # Don't cache inactive tokens - they may become valid later - # (e.g., tokens with future nbf, or propagation delays) - if not introspection_data.get("active", False): - self.logger.debug("Token introspection returned active=false") - return None - - # Extract client_id (should be present for active tokens) - client_id = introspection_data.get( - "client_id" - ) or introspection_data.get("sub", "unknown") - - # Extract expiration time - exp = introspection_data.get("exp") - if exp: - # Validate expiration (belt and suspenders - server should set active=false) - if exp < time.time(): - self.logger.debug( - "Token validation failed: expired token for client %s", - client_id, - ) - return None - - # Extract scopes - scopes = self._extract_scopes(introspection_data) - - # Check required scopes - # Don't cache scope failures - permissions may be updated dynamically - if self.required_scopes: - token_scopes = set(scopes) - required_scopes = set(self.required_scopes) - if not required_scopes.issubset(token_scopes): - self.logger.debug( - "Token missing required scopes. Has: %s, Required: %s", - token_scopes, - required_scopes, - ) - return None - - # Create AccessToken with introspection response data - result = AccessToken( - token=token, - client_id=str(client_id), - scopes=scopes, - expires_at=int(exp) if exp else None, - claims=introspection_data, # Store full response for extensibility - ) - self._cache.set(token, result) - return result - - except httpx.TimeoutException: - self.logger.debug( - "Token introspection timed out after %d seconds", self.timeout_seconds - ) - return None - except httpx.RequestError as e: - self.logger.debug("Token introspection request failed: %s", e) - return None - except Exception as e: - self.logger.debug("Token introspection error: %s", e) - return None diff --git a/src/fastmcp/server/auth/providers/jwt.py b/src/fastmcp/server/auth/providers/jwt.py deleted file mode 100644 index a97c1bd60..000000000 --- a/src/fastmcp/server/auth/providers/jwt.py +++ /dev/null @@ -1,594 +0,0 @@ -"""TokenVerifier implementations for FastMCP.""" - -from __future__ import annotations - -import contextlib -import json -import time -from dataclasses import dataclass -from typing import Any, cast - -import httpx -from authlib.jose import JsonWebKey, JsonWebToken -from authlib.jose.errors import JoseError -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from pydantic import AnyHttpUrl, SecretStr -from typing_extensions import TypedDict - -from fastmcp.server.auth import AccessToken, TokenVerifier -from fastmcp.server.auth.ssrf import SSRFError, SSRFFetchError, ssrf_safe_fetch -from fastmcp.utilities.auth import decode_jwt_header, parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class JWKData(TypedDict, total=False): - """JSON Web Key data structure.""" - - kty: str # Key type (e.g., "RSA") - required - kid: str # Key ID (optional but recommended) - use: str # Usage (e.g., "sig") - alg: str # Algorithm (e.g., "RS256") - n: str # Modulus (for RSA keys) - e: str # Exponent (for RSA keys) - x5c: list[str] # X.509 certificate chain (for JWKs) - x5t: str # X.509 certificate thumbprint (for JWKs) - - -class JWKSData(TypedDict): - """JSON Web Key Set data structure.""" - - keys: list[JWKData] - - -@dataclass(frozen=True, kw_only=True, repr=False) -class RSAKeyPair: - """RSA key pair for JWT testing.""" - - private_key: SecretStr - public_key: str - - @classmethod - def generate(cls) -> RSAKeyPair: - """ - Generate an RSA key pair for testing. - - Returns: - RSAKeyPair: Generated key pair - """ - # Generate private key - private_key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - ) - - # Serialize private key to PEM format - private_pem = private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ).decode("utf-8") - - # Serialize public key to PEM format - public_pem = ( - private_key.public_key() - .public_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PublicFormat.SubjectPublicKeyInfo, - ) - .decode("utf-8") - ) - - return cls( - private_key=SecretStr(private_pem), - public_key=public_pem, - ) - - def create_token( - self, - subject: str = "fastmcp-user", - issuer: str = "https://fastmcp.example.com", - audience: str | list[str] | None = None, - scopes: list[str] | None = None, - expires_in_seconds: int = 3600, - additional_claims: dict[str, Any] | None = None, - kid: str | None = None, - ) -> str: - """ - Generate a test JWT token for testing purposes. - - Args: - subject: Subject claim (usually user ID) - issuer: Issuer claim - audience: Audience claim - can be a string or list of strings (optional) - scopes: List of scopes to include - expires_in_seconds: Token expiration time in seconds - additional_claims: Any additional claims to include - kid: Key ID to include in header - """ - # Create header - header = {"alg": "RS256"} - if kid: - header["kid"] = kid - - # Create payload - payload: dict[str, str | int | list[str]] = { - "sub": subject, - "iss": issuer, - "iat": int(time.time()), - "exp": int(time.time()) + expires_in_seconds, - } - - if audience: - payload["aud"] = audience - - if scopes: - payload["scope"] = " ".join(scopes) - - if additional_claims: - payload.update(additional_claims) - - # Create JWT - jwt_lib = JsonWebToken(["RS256"]) - token_bytes = jwt_lib.encode( - header, payload, self.private_key.get_secret_value() - ) - - return token_bytes.decode("utf-8") - - -def _looks_like_pem_public_key(key: str | bytes) -> bool: - """Return True when key text appears to be PEM-encoded asymmetric key material.""" - if isinstance(key, bytes): - key = key.decode("utf-8", errors="replace") - key_text = key.strip() - pem_markers = ( - "-----BEGIN PUBLIC KEY-----", - "-----BEGIN RSA PUBLIC KEY-----", - "-----BEGIN EC PUBLIC KEY-----", - "-----BEGIN CERTIFICATE-----", - ) - return any(marker in key_text for marker in pem_markers) - - -class JWTVerifier(TokenVerifier): - """ - JWT token verifier supporting both asymmetric (RSA/ECDSA) and symmetric (HMAC) algorithms. - - This verifier validates JWT tokens using various signing algorithms: - - **Asymmetric algorithms** (RS256/384/512, ES256/384/512, PS256/384/512): - Uses public/private key pairs. Ideal for external clients and services where - only the authorization server has the private key. - - **Symmetric algorithms** (HS256/384/512): Uses a shared secret for both - signing and verification. Perfect for internal microservices and trusted - environments where the secret can be securely shared. - - Use this when: - - You have JWT tokens issued by an external service (asymmetric) - - You need JWKS support for automatic key rotation (asymmetric) - - You have internal microservices sharing a secret key (symmetric) - - Your tokens contain standard OAuth scopes and claims - """ - - def __init__( - self, - *, - public_key: str | bytes | None = None, - jwks_uri: str | None = None, - issuer: str | list[str] | None = None, - audience: str | list[str] | None = None, - algorithm: str | None = None, - required_scopes: list[str] | None = None, - base_url: AnyHttpUrl | str | None = None, - ssrf_safe: bool = False, - http_client: httpx.AsyncClient | None = None, - ): - """ - Initialize a JWTVerifier configured to validate JWTs using either a static key or a JWKS endpoint. - - Parameters: - public_key: PEM-encoded public key for asymmetric algorithms or shared secret for symmetric algorithms. - jwks_uri: URI to fetch a JSON Web Key Set; used when verifying tokens with remote JWKS. - issuer: Expected issuer claim value or list of allowed issuer values. - audience: Expected audience claim value or list of allowed audience values. - algorithm: JWT signing algorithm to accept (default: "RS256"). Supported: HS256/384/512, RS256/384/512, ES256/384/512, PS256/384/512. - required_scopes: Scopes that must be present in validated tokens. - base_url: Base URL passed to the parent TokenVerifier. - ssrf_safe: If True, JWKS fetches use SSRF protection (HTTPS-only, - public IPs, DNS pinning). Enable when the JWKS URI comes from - untrusted input (e.g. CIMD documents). Defaults to False so - operator-configured JWKS URIs (including localhost) work normally. - http_client: Optional httpx.AsyncClient for connection pooling. When provided, - the client is reused for JWKS fetches and the caller is responsible for - its lifecycle. When None (default), a fresh client is created per fetch. - Cannot be used with ssrf_safe=True. - - Raises: - ValueError: If neither or both of `public_key` and `jwks_uri` are provided, - if `algorithm` is unsupported, or if `http_client` is provided with `ssrf_safe=True`. - """ - if not public_key and not jwks_uri: - raise ValueError("Either public_key or jwks_uri must be provided") - - if public_key and jwks_uri: - raise ValueError("Provide either public_key or jwks_uri, not both") - - # Only enforce ssrf_safe/http_client exclusivity when JWKS fetching is used - if jwks_uri and ssrf_safe and http_client is not None: - raise ValueError( - "http_client cannot be used with ssrf_safe=True; " - "SSRF-safe mode requires its own hardened transport" - ) - - algorithm = algorithm or "RS256" - if algorithm not in { - "HS256", - "HS384", - "HS512", - "RS256", - "RS384", - "RS512", - "ES256", - "ES384", - "ES512", - "PS256", - "PS384", - "PS512", - }: - raise ValueError(f"Unsupported algorithm: {algorithm}.") - - if algorithm.startswith("HS"): - if jwks_uri: - raise ValueError( - "Symmetric HS* algorithms cannot be used with jwks_uri; " - "configure a shared secret via public_key instead." - ) - if public_key and _looks_like_pem_public_key(public_key): - raise ValueError( - "Symmetric HS* algorithms require a shared secret, not a public key." - ) - - # Parse scopes if provided as string - parsed_required_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else None - ) - - # Initialize parent TokenVerifier - super().__init__( - base_url=base_url, - required_scopes=parsed_required_scopes, - ) - - self.algorithm = algorithm - self.issuer = issuer - self.audience = audience - self.public_key = public_key - self.jwks_uri = jwks_uri - self.ssrf_safe = ssrf_safe - self._http_client = http_client - self.jwt = JsonWebToken([self.algorithm]) - self.logger = get_logger(__name__) - - # Simple JWKS cache - self._jwks_cache: dict[str, str] = {} - self._jwks_cache_time: float = 0 - self._cache_ttl = 3600 # 1 hour - - async def _get_verification_key(self, token: str) -> str | bytes: - """Get the verification key for the token.""" - if self.public_key: - return self.public_key - - # Extract kid from token header for JWKS lookup - try: - header = decode_jwt_header(token) - kid = header.get("kid") - return await self._get_jwks_key(kid) - - except (ValueError, KeyError, IndexError, json.JSONDecodeError) as e: - raise ValueError(f"Failed to extract key ID from token: {e}") from e - - async def _get_jwks_key(self, kid: str | None) -> str: - """Fetch key from JWKS with simple caching and SSRF protection.""" - if not self.jwks_uri: - raise ValueError("JWKS URI not configured") - - current_time = time.time() - - # Check cache first - if current_time - self._jwks_cache_time < self._cache_ttl: - if kid and kid in self._jwks_cache: - return self._jwks_cache[kid] - elif not kid and len(self._jwks_cache) == 1: - # If no kid but only one key cached, use it - return next(iter(self._jwks_cache.values())) - - # Fetch JWKS — with SSRF protection when enabled (untrusted URIs) - try: - jwks_data = await self._fetch_jwks() - - # Cache all keys - self._jwks_cache = {} - for key_data in jwks_data.get("keys", []): - key_kid = key_data.get("kid") - jwk = JsonWebKey.import_key(key_data) - public_key = jwk.get_public_key() - - if key_kid: - self._jwks_cache[key_kid] = public_key - else: - # Key without kid - use a default identifier - self._jwks_cache["_default"] = public_key - - self._jwks_cache_time = current_time - - # Select the appropriate key - if kid: - if kid not in self._jwks_cache: - self.logger.debug( - "JWKS key lookup failed: key ID '%s' not found", kid - ) - raise ValueError(f"Key ID '{kid}' not found in JWKS") - return self._jwks_cache[kid] - else: - # No kid in token - only allow if there's exactly one key - if len(self._jwks_cache) == 1: - return next(iter(self._jwks_cache.values())) - elif len(self._jwks_cache) > 1: - raise ValueError( - "Multiple keys in JWKS but no key ID (kid) in token" - ) - else: - raise ValueError("No keys found in JWKS") - - except (SSRFError, SSRFFetchError) as e: - self.logger.debug("JWKS fetch blocked by SSRF protection: %s", e) - raise ValueError(f"Failed to fetch JWKS: {e}") from e - except httpx.HTTPError as e: - raise ValueError(f"Failed to fetch JWKS: {e}") from e - except json.JSONDecodeError as e: - raise ValueError(f"Invalid JWKS JSON: {e}") from e - except (JoseError, TypeError, KeyError) as e: - self.logger.debug("JWKS key processing failed: %s", e) - raise ValueError(f"Failed to process JWKS: {e}") from e - - async def _fetch_jwks(self) -> dict[str, Any]: - """Fetch JWKS data, using SSRF-safe or standard fetch based on config.""" - if not self.jwks_uri: - raise ValueError("JWKS URI not configured") - - if self.ssrf_safe: - content = await ssrf_safe_fetch( - self.jwks_uri, - max_size=65536, - timeout=10.0, - overall_timeout=30.0, - ) - return json.loads(content) - else: - async with ( - contextlib.nullcontext(self._http_client) - if self._http_client is not None - else httpx.AsyncClient(timeout=httpx.Timeout(10.0)) - ) as client: - response = await client.get(self.jwks_uri) - response.raise_for_status() - return response.json() - - def _extract_scopes(self, claims: dict[str, Any]) -> list[str]: - """ - Extract scopes from JWT claims. Supports both 'scope' and 'scp' - claims. - - Checks the `scope` claim first (standard OAuth2 claim), then the `scp` - claim (used by some Identity Providers). - """ - for claim in ["scope", "scp"]: - if claim in claims: - if isinstance(claims[claim], str): - return claims[claim].split() - elif isinstance(claims[claim], list): - return claims[claim] - - return [] - - async def load_access_token(self, token: str) -> AccessToken | None: - """ - Validate a JWT bearer token and return an AccessToken when the token is valid. - - Parameters: - token (str): The JWT bearer token string to validate. - - Returns: - AccessToken | None: An AccessToken populated from token claims if the token is valid; `None` if the token is expired, has an invalid signature or format, fails issuer/audience/scope validation, or any other validation error occurs. - """ - try: - # Get verification key (static or from JWKS) - verification_key = await self._get_verification_key(token) - - # Decode and verify the JWT token - claims = self.jwt.decode(token, verification_key) - - # Extract client ID early for logging - client_id = ( - claims.get("client_id") - or claims.get("azp") - or claims.get("sub") - or "unknown" - ) - - # Validate expiration - exp = claims.get("exp") - if exp and exp < time.time(): - self.logger.debug( - "Token validation failed: expired token for client %s", client_id - ) - self.logger.info("Bearer token rejected for client %s", client_id) - return None - - # Validate issuer - note we use issuer instead of issuer_url here because - # issuer is optional, allowing users to make this check optional - if self.issuer: - iss = claims.get("iss") - - # Handle different combinations of issuer types - issuer_valid = False - if isinstance(self.issuer, list): - # self.issuer is a list - check if token issuer matches any expected issuer - issuer_valid = iss in self.issuer - else: - # self.issuer is a string - check for equality - issuer_valid = iss == self.issuer - - if not issuer_valid: - self.logger.debug( - "Token validation failed: issuer mismatch for client %s", - client_id, - ) - self.logger.info("Bearer token rejected for client %s", client_id) - return None - - # Validate audience if configured - if self.audience: - aud = claims.get("aud") - - # Handle different combinations of audience types - audience_valid = False - if isinstance(self.audience, list): - # self.audience is a list - check if any expected audience is present - if isinstance(aud, list): - # Both are lists - check for intersection - audience_valid = any( - expected in aud for expected in self.audience - ) - else: - # aud is a string - check if it's in our expected list - audience_valid = aud in cast(list, self.audience) - else: - # self.audience is a string - use original logic - if isinstance(aud, list): - audience_valid = self.audience in aud - else: - audience_valid = aud == self.audience - - if not audience_valid: - self.logger.debug( - "Token validation failed: audience mismatch for client %s", - client_id, - ) - self.logger.info("Bearer token rejected for client %s", client_id) - return None - - # Extract scopes - scopes = self._extract_scopes(claims) - - # Check required scopes - if self.required_scopes: - token_scopes = set(scopes) - required_scopes = set(self.required_scopes) - if not required_scopes.issubset(token_scopes): - self.logger.debug( - "Token missing required scopes. Has: %s, Required: %s", - token_scopes, - required_scopes, - ) - self.logger.info("Bearer token rejected for client %s", client_id) - return None - - return AccessToken( - token=token, - client_id=str(client_id), - scopes=scopes, - expires_at=int(exp) if exp else None, - claims=claims, - ) - - except JoseError: - self.logger.debug("Token validation failed: JWT signature/format invalid") - return None - except (ValueError, TypeError, KeyError, AttributeError) as e: - self.logger.debug("Token validation failed: %s", str(e)) - return None - - async def verify_token(self, token: str) -> AccessToken | None: - """ - Verify a bearer token and return access info if valid. - - This method implements the TokenVerifier protocol by delegating - to our existing load_access_token method. - - Args: - token: The JWT token string to validate - - Returns: - AccessToken object if valid, None if invalid or expired - """ - return await self.load_access_token(token) - - -class StaticTokenVerifier(TokenVerifier): - """ - Simple static token verifier for testing and development. - - This verifier validates tokens against a predefined dictionary of valid token - strings and their associated claims. When a token string matches a key in the - dictionary, the verifier returns the corresponding claims as if the token was - validated by a real authorization server. - - Use this when: - - You're developing or testing locally without a real OAuth server - - You need predictable tokens for automated testing - - You want to simulate different users/scopes without complex setup - - You're prototyping and need simple API key-style authentication - - WARNING: Never use this in production - tokens are stored in plain text! - """ - - def __init__( - self, - tokens: dict[str, dict[str, Any]], - required_scopes: list[str] | None = None, - ): - """ - Initialize the static token verifier. - - Args: - tokens: Dict mapping token strings to token metadata - Each token should have: client_id, scopes, expires_at (optional) - required_scopes: Required scopes for all tokens - """ - super().__init__(required_scopes=required_scopes) - self.tokens = tokens - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token against static token dictionary.""" - token_data = self.tokens.get(token) - if not token_data: - return None - - # Check expiration if present - expires_at = token_data.get("expires_at") - if expires_at is not None and expires_at < time.time(): - return None - - scopes = token_data.get("scopes", []) - - # Check required scopes - if self.required_scopes: - token_scopes = set(scopes) - required_scopes = set(self.required_scopes) - if not required_scopes.issubset(token_scopes): - logger.debug( - f"Token missing required scopes. Has: {token_scopes}, Required: {required_scopes}" - ) - return None - - return AccessToken( - token=token, - client_id=token_data["client_id"], - scopes=scopes, - expires_at=expires_at, - claims=token_data, - ) diff --git a/src/fastmcp/server/auth/providers/oci.py b/src/fastmcp/server/auth/providers/oci.py deleted file mode 100644 index 98011e4ed..000000000 --- a/src/fastmcp/server/auth/providers/oci.py +++ /dev/null @@ -1,176 +0,0 @@ -"""OCI OIDC provider for FastMCP. - -The pull request for the provider is submitted to fastmcp. - -This module provides OIDC Implementation to integrate MCP servers with OCI. -You only need OCI Identity Domain's discovery URL, client ID, client secret, and base URL. - -Post Authentication, you get OCI IAM domain access token. That is not authorized to invoke OCI control plane. -You need to exchange the IAM domain access token for OCI UPST token to invoke OCI control plane APIs. -The sample code below has get_oci_signer function that returns OCI TokenExchangeSigner object. -You can use the signer object to create OCI service object. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.oci import OCIProvider - from fastmcp.server.dependencies import get_access_token - from fastmcp.utilities.logging import get_logger - - import os - - import oci - from oci.auth.signers import TokenExchangeSigner - - logger = get_logger(__name__) - - # Load configuration from environment - config_url = os.environ.get("OCI_CONFIG_URL") # OCI IAM Domain OIDC discovery URL - client_id = os.environ.get("OCI_CLIENT_ID") # Client ID configured for the OCI IAM Domain Integrated Application - client_secret = os.environ.get("OCI_CLIENT_SECRET") # Client secret configured for the OCI IAM Domain Integrated Application - iam_guid = os.environ.get("OCI_IAM_GUID") # IAM GUID configured for the OCI IAM Domain - - # Simple OCI OIDC protection - auth = OCIProvider( - config_url=config_url, # config URL is the OCI IAM Domain OIDC discovery URL - client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application - client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application - required_scopes=["openid", "profile", "email"], - redirect_path="/auth/callback", - base_url="http://localhost:8000", - ) - - # NOTE: For production use, replace this with a thread-safe cache implementation - # such as threading.Lock-protected dict or a proper caching library - _global_token_cache = {} # In memory cache for OCI session token signer - - def get_oci_signer() -> TokenExchangeSigner: - - authntoken = get_access_token() - tokenID = authntoken.claims.get("jti") - token = authntoken.token - - # Check if the signer exists for the token ID in memory cache - cached_signer = _global_token_cache.get(tokenID) - logger.debug(f"Global cached signer: {cached_signer}") - if cached_signer: - logger.debug(f"Using globally cached signer for token ID: {tokenID}") - return cached_signer - - # If the signer is not yet created for the token then create new OCI signer object - logger.debug(f"Creating new signer for token ID: {tokenID}") - signer = TokenExchangeSigner( - jwt_or_func=token, - oci_domain_id=iam_guid.split(".")[0] if iam_guid else None, # This is same as IAM GUID configured for the OCI IAM Domain - client_id=client_id, # This is same as the client ID configured for the OCI IAM Domain Integrated Application - client_secret=client_secret, # This is same as the client secret configured for the OCI IAM Domain Integrated Application - ) - logger.debug(f"Signer {signer} created for token ID: {tokenID}") - - #Cache the signer object in memory cache - _global_token_cache[tokenID] = signer - logger.debug(f"Signer cached for token ID: {tokenID}") - - return signer - - mcp = FastMCP("My Protected Server", auth=auth) - ``` -""" - -from typing import Literal - -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl - -from fastmcp.server.auth.oidc_proxy import OIDCProxy -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class OCIProvider(OIDCProxy): - """An OCI IAM Domain provider implementation for FastMCP. - - This provider is a complete OCI integration that's ready to use with - just the configuration URL, client ID, client secret, and base URL. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.oci import OCIProvider - - import os - - # Load configuration from environment - auth = OCIProvider( - config_url=os.environ.get("OCI_CONFIG_URL"), # OCI IAM Domain OIDC discovery URL - client_id=os.environ.get("OCI_CLIENT_ID"), # Client ID configured for the OCI IAM Domain Integrated Application - client_secret=os.environ.get("OCI_CLIENT_SECRET"), # Client secret configured for the OCI IAM Domain Integrated Application - base_url="http://localhost:8000", - required_scopes=["openid", "profile", "email"], - redirect_path="/auth/callback", - ) - - mcp = FastMCP("My Protected Server", auth=auth) - ``` - """ - - def __init__( - self, - *, - config_url: AnyHttpUrl | str, - client_id: str, - client_secret: str, - base_url: AnyHttpUrl | str, - audience: str | None = None, - issuer_url: AnyHttpUrl | str | None = None, - required_scopes: list[str] | None = None, - redirect_path: str | None = None, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - ) -> None: - """Initialize OCI OIDC provider. - - Args: - config_url: OCI OIDC Discovery URL - client_id: OCI IAM Domain Integrated Application client id - client_secret: OCI Integrated Application client secret - base_url: Public URL where OIDC endpoints will be accessible (includes any mount path) - audience: OCI API audience (optional) - issuer_url: Issuer URL for OCI IAM Domain metadata. This will override issuer URL from the discovery URL. - required_scopes: Required OCI scopes (defaults to ["openid"]) - redirect_path: Redirect path configured in OCI IAM Domain Integrated Application. The default is "/auth/callback". - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - """ - # Parse scopes if provided as string - oci_required_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else ["openid"] - ) - - super().__init__( - config_url=config_url, - client_id=client_id, - client_secret=client_secret, - audience=audience, - base_url=base_url, - issuer_url=issuer_url, - redirect_path=redirect_path, - required_scopes=oci_required_scopes, - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - ) - - logger.debug( - "Initialized OCI OAuth provider for client %s with scopes: %s", - client_id, - oci_required_scopes, - ) diff --git a/src/fastmcp/server/auth/providers/propelauth.py b/src/fastmcp/server/auth/providers/propelauth.py deleted file mode 100644 index 82e55e172..000000000 --- a/src/fastmcp/server/auth/providers/propelauth.py +++ /dev/null @@ -1,234 +0,0 @@ -"""PropelAuth authentication provider for FastMCP. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.propelauth import PropelAuthProvider - - auth = PropelAuthProvider( - auth_url="https://auth.yourdomain.com", - introspection_client_id="your-client-id", - introspection_client_secret="your-client-secret", - base_url="https://your-fastmcp-server.com", - required_scopes=["read:user_data"], - ) - - mcp = FastMCP("My App", auth=auth) - ``` -""" - -from __future__ import annotations - -from typing import TypedDict - -import httpx -from pydantic import AnyHttpUrl, SecretStr -from starlette.responses import JSONResponse -from starlette.routing import Route - -from fastmcp.server.auth import AccessToken, RemoteAuthProvider -from fastmcp.server.auth.providers.introspection import IntrospectionTokenVerifier -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class PropelAuthTokenIntrospectionOverrides(TypedDict, total=False): - timeout_seconds: int - cache_ttl_seconds: int | None - max_cache_size: int | None - http_client: httpx.AsyncClient | None - - -class PropelAuthProvider(RemoteAuthProvider): - """PropelAuth resource server provider using OAuth 2.1 token introspection. - - This provider validates access tokens via PropelAuth's introspection endpoint - and forwards authorization server metadata for OAuth discovery. - - Setup: - 1. Enable MCP authentication in the PropelAuth Dashboard - 2. Configure scopes on the MCP page - 3. Select which redirect URIs to enable by picking which clients you support - 4. Generate introspection credentials (Client ID + Client Secret) - - For detailed setup instructions, see: - https://docs.propelauth.com/mcp-authentication/overview - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.propelauth import PropelAuthProvider - - auth = PropelAuthProvider( - auth_url="https://auth.yourdomain.com", - introspection_client_id="your-client-id", - introspection_client_secret="your-client-secret", - base_url="https://your-fastmcp-server.com", - required_scopes=["read:user_data"], - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - auth_url: AnyHttpUrl | str, - introspection_client_id: str, - introspection_client_secret: str | SecretStr, - base_url: AnyHttpUrl | str, - required_scopes: list[str] | None = None, - scopes_supported: list[str] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, - resource: AnyHttpUrl | str | None = None, - token_introspection_overrides: ( - PropelAuthTokenIntrospectionOverrides | None - ) = None, - ): - """Initialize PropelAuth provider. - - Args: - auth_url: Your PropelAuth Auth URL (from the Backend Integration page) - introspection_client_id: Introspection Client ID from the PropelAuth Dashboard - introspection_client_secret: Introspection Client Secret from the PropelAuth Dashboard - base_url: Public URL of this FastMCP server - required_scopes: Optional list of scopes that must be present in tokens - scopes_supported: Optional list of scopes to advertise in OAuth metadata. - If None, uses required_scopes. Use this when the scopes clients should - request differ from the scopes enforced on tokens. - resource_name: Optional name for the protected resource metadata. - resource_documentation: Optional documentation URL for the protected resource. - resource: Optional resource URI (RFC 8707) identifying this MCP server. - Use this when multiple MCP servers share the same PropelAuth - authorization server (e.g. ``resource="https://api.example.com/mcp"``), - so only tokens intended for this MCP server are accepted. - token_introspection_overrides: Optional overrides for the underlying - IntrospectionTokenVerifier (timeout, caching, http_client) - """ - normalized_auth_url = str(auth_url).rstrip("/") - introspection_url = f"{normalized_auth_url}/oauth/2.1/introspect" - authorization_server_url = AnyHttpUrl(f"{normalized_auth_url}/oauth/2.1") - - if resource is None: - self._resource = None - logger.debug( - "PropelAuthProvider: no resource configured, audience checking disabled" - ) - else: - self._resource = str(resource) - - token_verifier = self._create_token_verifier( - introspection_url=introspection_url, - client_id=introspection_client_id, - client_secret=introspection_client_secret, - required_scopes=required_scopes, - introspection_overrides=token_introspection_overrides, - ) - - self._normalized_auth_url = normalized_auth_url - super().__init__( - token_verifier=token_verifier, - authorization_servers=[authorization_server_url], - base_url=base_url, - scopes_supported=scopes_supported, - resource_name=resource_name, - resource_documentation=resource_documentation, - ) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get routes for this provider. - - Includes the standard routes from the RemoteAuthProvider (protected resource metadata routes (RFC 9728)), - and creates an authorization server metadata route that forwards to PropelAuth's route - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata. - """ - routes = super().get_routes(mcp_path) - - async def oauth_authorization_server_metadata(request): - """Forward PropelAuth OAuth authorization server metadata""" - try: - async with httpx.AsyncClient() as client: - response = await client.get( - f"{self._normalized_auth_url}/.well-known/oauth-authorization-server/oauth/2.1" - ) - response.raise_for_status() - metadata = response.json() - return JSONResponse(metadata) - except Exception as e: - return JSONResponse( - { - "error": "server_error", - "error_description": f"Failed to fetch PropelAuth metadata: {e}", - }, - status_code=500, - ) - - routes.append( - Route( - "/.well-known/oauth-authorization-server", - endpoint=oauth_authorization_server_metadata, - methods=["GET"], - ) - ) - - return routes - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify token and check the ``aud`` claim against the configured resource.""" - result = await super().verify_token(token) - if result is None or self._resource is None: - return result - - aud = result.claims.get("aud") - if aud != self._resource: - logger.debug( - "PropelAuthProvider: token audience %r does not match resource %s", - aud, - self._resource, - ) - return None - - return result - - def _create_token_verifier( - self, - introspection_url: str, - client_id: str, - client_secret: str | SecretStr, - required_scopes: list[str] | None, - introspection_overrides: PropelAuthTokenIntrospectionOverrides | None, - ) -> IntrospectionTokenVerifier: - # Being defensive here, check for only the fields we are expecting - safe_overrides: PropelAuthTokenIntrospectionOverrides = {} - if introspection_overrides is not None: - if "timeout_seconds" in introspection_overrides: - safe_overrides["timeout_seconds"] = introspection_overrides[ - "timeout_seconds" - ] - if "cache_ttl_seconds" in introspection_overrides: - safe_overrides["cache_ttl_seconds"] = introspection_overrides[ - "cache_ttl_seconds" - ] - if "max_cache_size" in introspection_overrides: - safe_overrides["max_cache_size"] = introspection_overrides[ - "max_cache_size" - ] - if "http_client" in introspection_overrides: - safe_overrides["http_client"] = introspection_overrides["http_client"] - - return IntrospectionTokenVerifier( - introspection_url=introspection_url, - client_id=client_id, - client_secret=client_secret, - required_scopes=required_scopes, - **safe_overrides, - ) diff --git a/src/fastmcp/server/auth/providers/scalekit.py b/src/fastmcp/server/auth/providers/scalekit.py deleted file mode 100644 index ffcacc9c5..000000000 --- a/src/fastmcp/server/auth/providers/scalekit.py +++ /dev/null @@ -1,212 +0,0 @@ -"""Scalekit authentication provider for FastMCP. - -This module provides ScalekitProvider - a complete authentication solution that integrates -with Scalekit's OAuth 2.1 and OpenID Connect services, supporting Resource Server -authentication for seamless MCP client authentication. -""" - -from __future__ import annotations - -import httpx -from pydantic import AnyHttpUrl -from starlette.responses import JSONResponse -from starlette.routing import Route - -from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class ScalekitProvider(RemoteAuthProvider): - """Scalekit resource server provider for OAuth 2.1 authentication. - - This provider implements Scalekit integration using resource server pattern. - FastMCP acts as a protected resource server that validates access tokens issued - by Scalekit's authorization server. - - IMPORTANT SETUP REQUIREMENTS: - - 1. Create an MCP Server in Scalekit Dashboard: - - Go to your [Scalekit Dashboard](https://app.scalekit.com/) - - Navigate to MCP Servers section - - Register a new MCP Server with appropriate scopes - - Ensure the Resource Identifier matches exactly what you configure as MCP URL - - Note the Resource ID - - 2. Environment Configuration: - - Set SCALEKIT_ENVIRONMENT_URL (e.g., https://your-env.scalekit.com) - - Set SCALEKIT_RESOURCE_ID from your created resource - - Set BASE_URL to your FastMCP server's public URL - - For detailed setup instructions, see: - https://docs.scalekit.com/mcp/overview/ - - Example: - ```python - from fastmcp.server.auth.providers.scalekit import ScalekitProvider - - # Create Scalekit resource server provider - scalekit_auth = ScalekitProvider( - environment_url="https://your-env.scalekit.com", - resource_id="sk_resource_...", - base_url="https://your-fastmcp-server.com", - ) - - # Use with FastMCP - mcp = FastMCP("My App", auth=scalekit_auth) - ``` - """ - - def __init__( - self, - *, - environment_url: AnyHttpUrl | str, - resource_id: str, - base_url: AnyHttpUrl | str | None = None, - mcp_url: AnyHttpUrl | str | None = None, - client_id: str | None = None, - required_scopes: list[str] | None = None, - scopes_supported: list[str] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, - token_verifier: TokenVerifier | None = None, - ): - """Initialize Scalekit resource server provider. - - Args: - environment_url: Your Scalekit environment URL (e.g., "https://your-env.scalekit.com") - resource_id: Your Scalekit resource ID - base_url: Public URL of this FastMCP server (or use mcp_url for backwards compatibility) - mcp_url: Deprecated alias for base_url. Will be removed in a future release. - client_id: Deprecated parameter, no longer required. Will be removed in a future release. - required_scopes: Optional list of scopes that must be present in tokens - scopes_supported: Optional list of scopes to advertise in OAuth metadata. - If None, uses required_scopes. Use this when the scopes clients should - request differ from the scopes enforced on tokens. - resource_name: Optional name for the protected resource metadata. - resource_documentation: Optional documentation URL for the protected resource. - token_verifier: Optional token verifier. If None, creates JWT verifier for Scalekit - """ - # Resolve base_url from mcp_url if needed (backwards compatibility) - resolved_base_url = base_url or mcp_url - if not resolved_base_url: - raise ValueError("Either base_url or mcp_url must be provided") - - if mcp_url is not None: - logger.warning( - "ScalekitProvider parameter 'mcp_url' is deprecated and will be removed in a future release. " - "Rename it to 'base_url'." - ) - - if client_id is not None: - logger.warning( - "ScalekitProvider no longer requires 'client_id'. The parameter is accepted only for backward " - "compatibility and will be removed in a future release." - ) - - self.environment_url = str(environment_url).rstrip("/") - self.resource_id = resource_id - parsed_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else [] - ) - self.required_scopes = parsed_scopes - base_url_value = str(resolved_base_url) - - logger.debug( - "Initializing ScalekitProvider: environment_url=%s resource_id=%s base_url=%s required_scopes=%s", - self.environment_url, - self.resource_id, - base_url_value, - self.required_scopes, - ) - - # Create default JWT verifier if none provided - if token_verifier is None: - logger.debug( - "Creating default JWTVerifier for Scalekit: jwks_uri=%s issuer=%s required_scopes=%s", - f"{self.environment_url}/keys", - self.environment_url, - self.required_scopes, - ) - token_verifier = JWTVerifier( - jwks_uri=f"{self.environment_url}/keys", - issuer=self.environment_url, - algorithm="RS256", - audience=self.resource_id, - required_scopes=self.required_scopes or None, - ) - else: - logger.debug("Using custom token verifier for ScalekitProvider") - - # Initialize RemoteAuthProvider with Scalekit as the authorization server - super().__init__( - token_verifier=token_verifier, - authorization_servers=[ - AnyHttpUrl(f"{self.environment_url}/resources/{self.resource_id}") - ], - base_url=base_url_value, - scopes_supported=scopes_supported, - resource_name=resource_name, - resource_documentation=resource_documentation, - ) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get OAuth routes including Scalekit authorization server metadata forwarding. - - This returns the standard protected resource routes plus an authorization server - metadata endpoint that forwards Scalekit's OAuth metadata to clients. - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata. - """ - # Get the standard protected resource routes from RemoteAuthProvider - routes = super().get_routes(mcp_path) - logger.debug( - "Preparing Scalekit metadata routes: mcp_path=%s resource_id=%s", - mcp_path, - self.resource_id, - ) - - async def oauth_authorization_server_metadata(request): - """Forward Scalekit OAuth authorization server metadata with FastMCP customizations.""" - try: - metadata_url = f"{self.environment_url}/.well-known/oauth-authorization-server/resources/{self.resource_id}" - logger.debug( - "Fetching Scalekit OAuth metadata: metadata_url=%s", metadata_url - ) - async with httpx.AsyncClient() as client: - response = await client.get(metadata_url) - response.raise_for_status() - metadata = response.json() - logger.debug( - "Scalekit metadata fetched successfully: metadata_keys=%s", - list(metadata.keys()), - ) - return JSONResponse(metadata) - except Exception as e: - logger.error(f"Failed to fetch Scalekit metadata: {e}") - return JSONResponse( - { - "error": "server_error", - "error_description": f"Failed to fetch Scalekit metadata: {e}", - }, - status_code=500, - ) - - # Add Scalekit authorization server metadata forwarding - routes.append( - Route( - "/.well-known/oauth-authorization-server", - endpoint=oauth_authorization_server_metadata, - methods=["GET"], - ) - ) - - return routes diff --git a/src/fastmcp/server/auth/providers/supabase.py b/src/fastmcp/server/auth/providers/supabase.py deleted file mode 100644 index 527d701ee..000000000 --- a/src/fastmcp/server/auth/providers/supabase.py +++ /dev/null @@ -1,181 +0,0 @@ -"""Supabase authentication provider for FastMCP. - -This module provides SupabaseProvider - a complete authentication solution that integrates -with Supabase Auth's JWT verification, supporting Dynamic Client Registration (DCR) -for seamless MCP client authentication. -""" - -from __future__ import annotations - -from typing import Literal - -import httpx -from pydantic import AnyHttpUrl -from starlette.responses import JSONResponse -from starlette.routing import Route - -from fastmcp.server.auth import RemoteAuthProvider, TokenVerifier -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class SupabaseProvider(RemoteAuthProvider): - """Supabase metadata provider for DCR (Dynamic Client Registration). - - This provider implements Supabase Auth integration using metadata forwarding. - This approach allows Supabase to handle the OAuth flow directly while FastMCP acts - as a resource server, verifying JWTs issued by Supabase Auth. - - IMPORTANT SETUP REQUIREMENTS: - - 1. Supabase Project Setup: - - Create a Supabase project at https://supabase.com - - Note your project URL (e.g., "https://abc123.supabase.co") - - Configure your JWT algorithm in Supabase Auth settings (RS256 or ES256) - - Asymmetric keys (RS256/ES256) are recommended for production - - 2. JWT Verification: - - FastMCP verifies JWTs using the JWKS endpoint at {project_url}{auth_route}/.well-known/jwks.json - - JWTs are issued by {project_url}{auth_route} - - Default auth_route is "/auth/v1" (can be customized for self-hosted setups) - - Tokens are cached for up to 10 minutes by Supabase's edge servers - - Algorithm must match your Supabase Auth configuration - - 3. Authorization: - - Supabase uses Row Level Security (RLS) policies for database authorization - - OAuth-level scopes are an upcoming feature in Supabase Auth - - Both approaches will be supported once scope handling is available - - For detailed setup instructions, see: - https://supabase.com/docs/guides/auth/jwts - - Example: - ```python - from fastmcp.server.auth.providers.supabase import SupabaseProvider - - # Create Supabase metadata provider (JWT verifier created automatically) - supabase_auth = SupabaseProvider( - project_url="https://abc123.supabase.co", - base_url="https://your-fastmcp-server.com", - algorithm="ES256", # Match your Supabase Auth configuration - ) - - # Use with FastMCP - mcp = FastMCP("My App", auth=supabase_auth) - ``` - """ - - def __init__( - self, - *, - project_url: AnyHttpUrl | str, - base_url: AnyHttpUrl | str, - auth_route: str = "/auth/v1", - algorithm: Literal["RS256", "ES256"] = "ES256", - required_scopes: list[str] | None = None, - scopes_supported: list[str] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, - token_verifier: TokenVerifier | None = None, - ): - """Initialize Supabase metadata provider. - - Args: - project_url: Your Supabase project URL (e.g., "https://abc123.supabase.co") - base_url: Public URL of this FastMCP server - auth_route: Supabase Auth route. Defaults to "/auth/v1". Can be customized - for self-hosted Supabase Auth setups using custom routes. - algorithm: JWT signing algorithm (RS256 or ES256). Must match your - Supabase Auth configuration. Defaults to ES256. - required_scopes: Optional list of scopes to require for all requests. - Note: Supabase currently uses RLS policies for authorization. OAuth-level - scopes are an upcoming feature. - scopes_supported: Optional list of scopes to advertise in OAuth metadata. - If None, uses required_scopes. Use this when the scopes clients should - request differ from the scopes enforced on tokens. - resource_name: Optional name for the protected resource metadata. - resource_documentation: Optional documentation URL for the protected resource. - token_verifier: Optional token verifier. If None, creates JWT verifier for Supabase - """ - self.project_url = str(project_url).rstrip("/") - self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) - self.auth_route = auth_route.strip("/") - - # Parse scopes if provided as string - parsed_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else None - ) - - # Create default JWT verifier if none provided - if token_verifier is None: - logger.warning( - "SupabaseProvider cannot validate token audience for the specific resource " - "because Supabase Auth does not support RFC 8707 resource indicators. " - "This may leave the server vulnerable to cross-server token replay." - ) - token_verifier = JWTVerifier( - jwks_uri=f"{self.project_url}/{self.auth_route}/.well-known/jwks.json", - issuer=f"{self.project_url}/{self.auth_route}", - algorithm=algorithm, - audience="authenticated", - required_scopes=parsed_scopes, - ) - - # Initialize RemoteAuthProvider with Supabase as the authorization server - super().__init__( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl(f"{self.project_url}/{self.auth_route}")], - base_url=self.base_url, - scopes_supported=scopes_supported, - resource_name=resource_name, - resource_documentation=resource_documentation, - ) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get OAuth routes including Supabase authorization server metadata forwarding. - - This returns the standard protected resource routes plus an authorization server - metadata endpoint that forwards Supabase's OAuth metadata to clients. - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata. - """ - # Get the standard protected resource routes from RemoteAuthProvider - routes = super().get_routes(mcp_path) - - async def oauth_authorization_server_metadata(request): - """Forward Supabase OAuth authorization server metadata with FastMCP customizations.""" - try: - async with httpx.AsyncClient() as client: - response = await client.get( - f"{self.project_url}/{self.auth_route}/.well-known/oauth-authorization-server" - ) - response.raise_for_status() - metadata = response.json() - return JSONResponse(metadata) - except Exception as e: - return JSONResponse( - { - "error": "server_error", - "error_description": f"Failed to fetch Supabase metadata: {e}", - }, - status_code=500, - ) - - # Add Supabase authorization server metadata forwarding - routes.append( - Route( - "/.well-known/oauth-authorization-server", - endpoint=oauth_authorization_server_metadata, - methods=["GET"], - ) - ) - - return routes diff --git a/src/fastmcp/server/auth/providers/workos.py b/src/fastmcp/server/auth/providers/workos.py deleted file mode 100644 index 85dd8feca..000000000 --- a/src/fastmcp/server/auth/providers/workos.py +++ /dev/null @@ -1,397 +0,0 @@ -"""WorkOS authentication providers for FastMCP. - -This module provides two WorkOS authentication strategies: - -1. WorkOSProvider - OAuth proxy for WorkOS Connect applications (non-DCR) -2. AuthKitProvider - DCR-compliant provider for WorkOS AuthKit - -Choose based on your WorkOS setup and authentication requirements. -""" - -from __future__ import annotations - -import contextlib -from typing import Literal - -import httpx -from key_value.aio.protocols import AsyncKeyValue -from pydantic import AnyHttpUrl -from starlette.responses import JSONResponse -from starlette.routing import Route - -from fastmcp.server.auth import AccessToken, RemoteAuthProvider, TokenVerifier -from fastmcp.server.auth.oauth_proxy import OAuthProxy -from fastmcp.server.auth.providers.jwt import JWTVerifier -from fastmcp.utilities.auth import parse_scopes -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -class WorkOSTokenVerifier(TokenVerifier): - """Token verifier for WorkOS OAuth tokens. - - WorkOS AuthKit tokens are opaque, so we verify them by calling - the /oauth2/userinfo endpoint to check validity and get user info. - """ - - def __init__( - self, - *, - authkit_domain: str, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - http_client: httpx.AsyncClient | None = None, - ): - """Initialize the WorkOS token verifier. - - Args: - authkit_domain: WorkOS AuthKit domain (e.g., "https://your-app.authkit.app") - required_scopes: Required OAuth scopes - timeout_seconds: HTTP request timeout - http_client: Optional httpx.AsyncClient for connection pooling. When provided, - the client is reused across calls and the caller is responsible for its - lifecycle. When None (default), a fresh client is created per call. - """ - super().__init__(required_scopes=required_scopes) - self.authkit_domain = authkit_domain.rstrip("/") - self.timeout_seconds = timeout_seconds - self._http_client = http_client - - async def verify_token(self, token: str) -> AccessToken | None: - """Verify WorkOS OAuth token by calling userinfo endpoint.""" - try: - async with ( - contextlib.nullcontext(self._http_client) - if self._http_client is not None - else httpx.AsyncClient(timeout=self.timeout_seconds) - ) as client: - # Use WorkOS AuthKit userinfo endpoint to validate token - response = await client.get( - f"{self.authkit_domain}/oauth2/userinfo", - headers={ - "Authorization": f"Bearer {token}", - "User-Agent": "FastMCP-WorkOS-OAuth", - }, - ) - - if response.status_code != 200: - logger.debug( - "WorkOS token verification failed: %d - %s", - response.status_code, - response.text[:200], - ) - return None - - user_data = response.json() - token_scopes = ( - parse_scopes(user_data.get("scope") or user_data.get("scopes")) - or [] - ) - - if self.required_scopes and not all( - scope in token_scopes for scope in self.required_scopes - ): - logger.debug( - "WorkOS token missing required scopes. required=%s actual=%s", - self.required_scopes, - token_scopes, - ) - return None - - # Create AccessToken with WorkOS user info - return AccessToken( - token=token, - client_id=str(user_data.get("sub", "unknown")), - scopes=token_scopes, - expires_at=None, # Will be set from token introspection if needed - claims={ - "sub": user_data.get("sub"), - "email": user_data.get("email"), - "email_verified": user_data.get("email_verified"), - "name": user_data.get("name"), - "given_name": user_data.get("given_name"), - "family_name": user_data.get("family_name"), - }, - ) - - except httpx.RequestError as e: - logger.debug("Failed to verify WorkOS token: %s", e) - return None - except Exception as e: - logger.debug("WorkOS token verification error: %s", e) - return None - - -class WorkOSProvider(OAuthProxy): - """Complete WorkOS OAuth provider for FastMCP. - - This provider implements WorkOS AuthKit OAuth using the OAuth Proxy pattern. - It provides OAuth2 authentication for users through WorkOS Connect applications. - - Features: - - Transparent OAuth proxy to WorkOS AuthKit - - Automatic token validation via userinfo endpoint - - User information extraction from ID tokens - - Support for standard OAuth scopes (openid, profile, email) - - Setup Requirements: - 1. Create a WorkOS Connect application in your dashboard - 2. Note your AuthKit domain (e.g., "https://your-app.authkit.app") - 3. Configure redirect URI as: http://localhost:8000/auth/callback - 4. Note your Client ID and Client Secret - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth.providers.workos import WorkOSProvider - - auth = WorkOSProvider( - client_id="client_123", - client_secret="sk_test_456", - authkit_domain="https://your-app.authkit.app", - base_url="http://localhost:8000" - ) - - mcp = FastMCP("My App", auth=auth) - ``` - """ - - def __init__( - self, - *, - client_id: str, - client_secret: str, - authkit_domain: str, - base_url: AnyHttpUrl | str, - issuer_url: AnyHttpUrl | str | None = None, - redirect_path: str | None = None, - required_scopes: list[str] | None = None, - timeout_seconds: int = 10, - allowed_client_redirect_uris: list[str] | None = None, - client_storage: AsyncKeyValue | None = None, - jwt_signing_key: str | bytes | None = None, - require_authorization_consent: bool | Literal["external"] = True, - consent_csp_policy: str | None = None, - forward_resource: bool = True, - http_client: httpx.AsyncClient | None = None, - enable_cimd: bool = True, - ): - """Initialize WorkOS OAuth provider. - - Args: - client_id: WorkOS client ID - client_secret: WorkOS client secret - authkit_domain: Your WorkOS AuthKit domain (e.g., "https://your-app.authkit.app") - base_url: Public URL where OAuth endpoints will be accessible (includes any mount path) - issuer_url: Issuer URL for OAuth metadata (defaults to base_url). Use root-level URL - to avoid 404s during discovery when mounting under a path. - redirect_path: Redirect path configured in WorkOS (defaults to "/auth/callback") - required_scopes: Required OAuth scopes (no default) - timeout_seconds: HTTP request timeout for WorkOS API calls (defaults to 10) - allowed_client_redirect_uris: List of allowed redirect URI patterns for MCP clients. - If None (default), all URIs are allowed. If empty list, no URIs are allowed. - client_storage: Storage backend for OAuth state (client registrations, encrypted tokens). - If None, an encrypted file store will be created in the data directory - (derived from `platformdirs`). - jwt_signing_key: Secret for signing FastMCP JWT tokens (any string or bytes). If bytes are provided, - they will be used as is. If a string is provided, it will be derived into a 32-byte key. If not - provided, the upstream client secret will be used to derive a 32-byte key using PBKDF2. - require_authorization_consent: Whether to require user consent before authorizing clients (default True). - When True, users see a consent screen before being redirected to WorkOS. - When False, authorization proceeds directly without user confirmation. - When "external", the built-in consent screen is skipped but no warning is - logged, indicating that consent is handled externally (e.g. by the upstream IdP). - SECURITY WARNING: Only set to False for local development or testing environments. - http_client: Optional httpx.AsyncClient for connection pooling in token verification. - When provided, the client is reused across verify_token calls and the caller - is responsible for its lifecycle. When None (default), a fresh client is created per call. - enable_cimd: Enable CIMD (Client ID Metadata Document) support for URL-based - client IDs (default True). Set to False to disable. - """ - # Apply defaults and ensure authkit_domain is a full URL - authkit_domain_str = authkit_domain - if not authkit_domain_str.startswith(("http://", "https://")): - authkit_domain_str = f"https://{authkit_domain_str}" - authkit_domain_final = authkit_domain_str.rstrip("/") - scopes_final = ( - parse_scopes(required_scopes) if required_scopes is not None else [] - ) - - # Create WorkOS token verifier - token_verifier = WorkOSTokenVerifier( - authkit_domain=authkit_domain_final, - required_scopes=scopes_final, - timeout_seconds=timeout_seconds, - http_client=http_client, - ) - - # Initialize OAuth proxy with WorkOS AuthKit endpoints - super().__init__( - upstream_authorization_endpoint=f"{authkit_domain_final}/oauth2/authorize", - upstream_token_endpoint=f"{authkit_domain_final}/oauth2/token", - upstream_client_id=client_id, - upstream_client_secret=client_secret, - token_verifier=token_verifier, - base_url=base_url, - redirect_path=redirect_path, - issuer_url=issuer_url or base_url, # Default to base_url if not specified - allowed_client_redirect_uris=allowed_client_redirect_uris, - client_storage=client_storage, - jwt_signing_key=jwt_signing_key, - require_authorization_consent=require_authorization_consent, - consent_csp_policy=consent_csp_policy, - forward_resource=forward_resource, - enable_cimd=enable_cimd, - ) - - logger.debug( - "Initialized WorkOS OAuth provider for client %s with AuthKit domain %s", - client_id, - authkit_domain_final, - ) - - -class AuthKitProvider(RemoteAuthProvider): - """AuthKit metadata provider for DCR (Dynamic Client Registration). - - This provider implements AuthKit integration using metadata forwarding - instead of OAuth proxying. This is the recommended approach for WorkOS DCR - as it allows WorkOS to handle the OAuth flow directly while FastMCP acts - as a resource server. - - IMPORTANT SETUP REQUIREMENTS: - - 1. Enable Dynamic Client Registration in WorkOS Dashboard: - - Go to Applications → Configuration - - Toggle "Dynamic Client Registration" to enabled - - 2. Configure your FastMCP server URL as a callback: - - Add your server URL to the Redirects tab in WorkOS dashboard - - Example: https://your-fastmcp-server.com/oauth2/callback - - For detailed setup instructions, see: - https://workos.com/docs/authkit/mcp/integrating/token-verification - - Example: - ```python - from fastmcp.server.auth.providers.workos import AuthKitProvider - - # Create AuthKit metadata provider (JWT verifier created automatically) - workos_auth = AuthKitProvider( - authkit_domain="https://your-workos-domain.authkit.app", - base_url="https://your-fastmcp-server.com", - ) - - # Use with FastMCP - mcp = FastMCP("My App", auth=workos_auth) - ``` - """ - - def __init__( - self, - *, - authkit_domain: AnyHttpUrl | str, - base_url: AnyHttpUrl | str, - client_id: str | None = None, - required_scopes: list[str] | None = None, - scopes_supported: list[str] | None = None, - resource_name: str | None = None, - resource_documentation: AnyHttpUrl | None = None, - token_verifier: TokenVerifier | None = None, - ): - """Initialize AuthKit metadata provider. - - Args: - authkit_domain: Your AuthKit domain (e.g., "https://your-app.authkit.app") - base_url: Public URL of this FastMCP server - client_id: Your WorkOS project client ID (e.g., "client_01ABC..."). Used to - validate the JWT audience claim. Found in your WorkOS Dashboard under - API Keys. This is the project-level client ID, not individual MCP client IDs. - required_scopes: Optional list of scopes to require for all requests - scopes_supported: Optional list of scopes to advertise in OAuth metadata. - If None, uses required_scopes. Use this when the scopes clients should - request differ from the scopes enforced on tokens. - resource_name: Optional name for the protected resource metadata. - resource_documentation: Optional documentation URL for the protected resource. - token_verifier: Optional token verifier. If None, creates JWT verifier for AuthKit - """ - self.authkit_domain = str(authkit_domain).rstrip("/") - self.base_url = AnyHttpUrl(str(base_url).rstrip("/")) - - # Parse scopes if provided as string - parsed_scopes = ( - parse_scopes(required_scopes) if required_scopes is not None else None - ) - - # Create default JWT verifier if none provided - if token_verifier is None: - logger.warning( - "AuthKitProvider cannot validate token audience for the specific resource " - "because AuthKit does not support RFC 8707 resource indicators. " - "This may leave the server vulnerable to cross-server token replay. " - "Consider using WorkOSProvider (OAuth proxy) for audience-bound tokens." - ) - token_verifier = JWTVerifier( - jwks_uri=f"{self.authkit_domain}/oauth2/jwks", - issuer=self.authkit_domain, - algorithm="RS256", - audience=client_id, - required_scopes=parsed_scopes, - ) - - # Initialize RemoteAuthProvider with AuthKit as the authorization server - super().__init__( - token_verifier=token_verifier, - authorization_servers=[AnyHttpUrl(self.authkit_domain)], - base_url=self.base_url, - scopes_supported=scopes_supported, - resource_name=resource_name, - resource_documentation=resource_documentation, - ) - - def get_routes( - self, - mcp_path: str | None = None, - ) -> list[Route]: - """Get OAuth routes including AuthKit authorization server metadata forwarding. - - This returns the standard protected resource routes plus an authorization server - metadata endpoint that forwards AuthKit's OAuth metadata to clients. - - Args: - mcp_path: The path where the MCP endpoint is mounted (e.g., "/mcp") - This is used to advertise the resource URL in metadata. - """ - # Get the standard protected resource routes from RemoteAuthProvider - routes = super().get_routes(mcp_path) - - async def oauth_authorization_server_metadata(request): - """Forward AuthKit OAuth authorization server metadata with FastMCP customizations.""" - try: - async with httpx.AsyncClient() as client: - response = await client.get( - f"{self.authkit_domain}/.well-known/oauth-authorization-server" - ) - response.raise_for_status() - metadata = response.json() - return JSONResponse(metadata) - except Exception as e: - return JSONResponse( - { - "error": "server_error", - "error_description": f"Failed to fetch AuthKit metadata: {e}", - }, - status_code=500, - ) - - # Add AuthKit authorization server metadata forwarding - routes.append( - Route( - "/.well-known/oauth-authorization-server", - endpoint=oauth_authorization_server_metadata, - methods=["GET"], - ) - ) - - return routes diff --git a/src/fastmcp/server/auth/redirect_validation.py b/src/fastmcp/server/auth/redirect_validation.py deleted file mode 100644 index 7e55a477b..000000000 --- a/src/fastmcp/server/auth/redirect_validation.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Utilities for validating client redirect URIs in OAuth flows. - -This module provides secure redirect URI validation with wildcard support, -protecting against userinfo-based bypass attacks like http://localhost@evil.com. -""" - -import fnmatch -from urllib.parse import urlparse - -from pydantic import AnyUrl - - -def _parse_host_port(netloc: str) -> tuple[str | None, str | None]: - """Parse host and port from netloc, handling wildcards. - - Args: - netloc: The netloc component (e.g., "localhost:8080" or "localhost:*") - - Returns: - Tuple of (host, port_str) where port_str may be "*" or a number string - """ - # Handle userinfo (remove it for parsing, but we check separately) - if "@" in netloc: - netloc = netloc.split("@")[-1] - - # Handle IPv6 addresses [::1]:port - if netloc.startswith("["): - bracket_end = netloc.find("]") - if bracket_end == -1: - return netloc, None - host = netloc[1:bracket_end] - rest = netloc[bracket_end + 1 :] - if rest.startswith(":"): - return host, rest[1:] - return host, None - - # Handle regular host:port - if ":" in netloc: - host, port = netloc.rsplit(":", 1) - return host, port - - return netloc, None - - -def _match_host(uri_host: str | None, pattern_host: str | None) -> bool: - """Match host component, supporting *.example.com wildcard patterns. - - Args: - uri_host: The host from the URI being validated - pattern_host: The host pattern (may start with *.) - - Returns: - True if the host matches - """ - if not uri_host or not pattern_host: - return uri_host == pattern_host - - # Normalize to lowercase for comparison - uri_host = uri_host.lower() - pattern_host = pattern_host.lower() - - # Handle *.example.com wildcard subdomain patterns - if pattern_host.startswith("*."): - suffix = pattern_host[1:] # .example.com - # Only match actual subdomains (foo.example.com), NOT the base domain - return uri_host.endswith(suffix) and uri_host != pattern_host[2:] - - return uri_host == pattern_host - - -def _is_loopback_host(host: str | None) -> bool: - """Check if a host is a loopback address. - - Per RFC 8252 §7.3, loopback addresses include localhost, 127.0.0.1, and ::1. - """ - if not host: - return False - host = host.lower() - return host in ("localhost", "127.0.0.1", "::1") - - -def _match_port( - uri_port: str | None, - pattern_port: str | None, - uri_scheme: str, -) -> bool: - """Match port component, supporting * wildcard for any port. - - Args: - uri_port: The port from the URI (None if default, string otherwise) - pattern_port: The port from the pattern (None if default, "*" for wildcard) - uri_scheme: The URI scheme (http/https) for default port handling - - Returns: - True if the port matches - """ - # Wildcard matches any port - if pattern_port == "*": - return True - - # Normalize None to default ports - default_port = "443" if uri_scheme == "https" else "80" - uri_effective = uri_port if uri_port else default_port - pattern_effective = pattern_port if pattern_port else default_port - - return uri_effective == pattern_effective - - -def _match_path(uri_path: str, pattern_path: str) -> bool: - """Match path component using fnmatch for wildcard support. - - Args: - uri_path: The path from the URI - pattern_path: The path pattern (may contain * wildcards) - - Returns: - True if the path matches - """ - # Normalize empty paths to / - uri_path = uri_path or "/" - pattern_path = pattern_path or "/" - - # Empty or root pattern path matches any path - # This makes http://localhost:* match http://localhost:3000/callback - if pattern_path == "/": - return True - - # Use fnmatch for path wildcards (e.g., /auth/*) - return fnmatch.fnmatch(uri_path, pattern_path) - - -def matches_allowed_pattern(uri: str, pattern: str) -> bool: - """Securely check if a URI matches an allowed pattern with wildcard support. - - This function parses both the URI and pattern as URLs, comparing each - component separately to prevent bypass attacks like userinfo injection. - - Patterns support wildcards: - - http://localhost:* matches any localhost port - - http://127.0.0.1:* matches any 127.0.0.1 port - - https://*.example.com/* matches any subdomain of example.com - - https://app.example.com/auth/* matches any path under /auth/ - - Security: Rejects URIs with userinfo (user:pass@host) which could bypass - naive string matching (e.g., http://localhost@evil.com). - - Args: - uri: The redirect URI to validate - pattern: The allowed pattern (may contain wildcards) - - Returns: - True if the URI matches the pattern - """ - try: - uri_parsed = urlparse(uri) - pattern_parsed = urlparse(pattern) - except ValueError: - return False - - # SECURITY: Reject URIs with userinfo (user:pass@host) - # This prevents bypass attacks like http://localhost@evil.com/callback - # which would match http://localhost:* with naive fnmatch - if uri_parsed.username is not None or uri_parsed.password is not None: - return False - - # Scheme must match exactly - if uri_parsed.scheme.lower() != pattern_parsed.scheme.lower(): - return False - - # Parse host and port manually to handle wildcards - uri_host, uri_port = _parse_host_port(uri_parsed.netloc) - pattern_host, pattern_port = _parse_host_port(pattern_parsed.netloc) - - # Host must match (with subdomain wildcard support) - if not _match_host(uri_host, pattern_host): - return False - - # RFC 8252 §7.3: loopback patterns without an explicit port match any port - if not (_is_loopback_host(pattern_host) and pattern_port is None): - if not _match_port(uri_port, pattern_port, uri_parsed.scheme.lower()): - return False - - # Path must match (with fnmatch wildcards) - return _match_path(uri_parsed.path, pattern_parsed.path) - - -def validate_redirect_uri( - redirect_uri: str | AnyUrl | None, - allowed_patterns: list[str] | None, -) -> bool: - """Validate a redirect URI against allowed patterns. - - Args: - redirect_uri: The redirect URI to validate - allowed_patterns: List of allowed patterns. If None, all URIs are allowed (for DCR compatibility). - If empty list, no URIs are allowed. - To restrict to localhost only, explicitly pass DEFAULT_LOCALHOST_PATTERNS. - - Returns: - True if the redirect URI is allowed - """ - if redirect_uri is None: - return True # None is allowed (will use client's default) - - uri_str = str(redirect_uri) - - # If no patterns specified, allow all for DCR compatibility - # (clients need to dynamically register with their own redirect URIs) - if allowed_patterns is None: - return True - - # Check if URI matches any allowed pattern - for pattern in allowed_patterns: - if matches_allowed_pattern(uri_str, pattern): - return True - - return False - - -# Default patterns for localhost-only validation -DEFAULT_LOCALHOST_PATTERNS = [ - "http://localhost:*", - "http://127.0.0.1:*", -] diff --git a/src/fastmcp/server/auth/ssrf.py b/src/fastmcp/server/auth/ssrf.py deleted file mode 100644 index 39c28e959..000000000 --- a/src/fastmcp/server/auth/ssrf.py +++ /dev/null @@ -1,356 +0,0 @@ -"""SSRF-safe HTTP utilities for FastMCP. - -This module provides SSRF-protected HTTP fetching with: -- DNS resolution and IP validation before requests -- DNS pinning to prevent rebinding TOCTOU attacks -- Support for both CIMD and JWKS fetches -""" - -from __future__ import annotations - -import asyncio -import ipaddress -import socket -import time -from collections.abc import Mapping -from dataclasses import dataclass -from urllib.parse import urlparse - -import httpx - -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -def format_ip_for_url(ip_str: str) -> str: - """Format IP address for use in URL (bracket IPv6 addresses). - - IPv6 addresses must be bracketed in URLs to distinguish the address from - the port separator. For example: https://[2001:db8::1]:443/path - - Args: - ip_str: IP address string - - Returns: - IP string suitable for URL (IPv6 addresses are bracketed) - """ - try: - ip = ipaddress.ip_address(ip_str) - if isinstance(ip, ipaddress.IPv6Address): - return f"[{ip_str}]" - return ip_str - except ValueError: - return ip_str - - -class SSRFError(Exception): - """Raised when an SSRF protection check fails.""" - - -class SSRFFetchError(Exception): - """Raised when SSRF-safe fetch fails.""" - - -def is_ip_allowed(ip_str: str) -> bool: - """Check if an IP address is allowed (must be globally routable unicast). - - Uses ip.is_global which catches: - - Private (10.x, 172.16-31.x, 192.168.x) - - Loopback (127.x, ::1) - - Link-local (169.254.x, fe80::) - includes AWS metadata! - - Reserved, unspecified - - RFC6598 Carrier-Grade NAT (100.64.0.0/10) - can point to internal networks - - Additionally blocks multicast addresses (not caught by is_global). - - Args: - ip_str: IP address string to check - - Returns: - True if the IP is allowed (public unicast internet), False if blocked - """ - try: - ip = ipaddress.ip_address(ip_str) - except ValueError: - return False - - if not ip.is_global: - return False - - # Block multicast (not caught by is_global for some ranges) - if ip.is_multicast: - return False - - # IPv6-specific checks for embedded IPv4 addresses - if isinstance(ip, ipaddress.IPv6Address): - if ip.ipv4_mapped: - return is_ip_allowed(str(ip.ipv4_mapped)) - if ip.sixtofour: - return is_ip_allowed(str(ip.sixtofour)) - if ip.teredo: - server, client = ip.teredo - return is_ip_allowed(str(server)) and is_ip_allowed(str(client)) - - return True - - -async def resolve_hostname(hostname: str, port: int = 443) -> list[str]: - """Resolve hostname to IP addresses using DNS. - - Args: - hostname: Hostname to resolve - port: Port number (used for getaddrinfo) - - Returns: - List of resolved IP addresses - - Raises: - SSRFError: If resolution fails - """ - loop = asyncio.get_running_loop() - try: - infos = await loop.run_in_executor( - None, - lambda: socket.getaddrinfo( - hostname, port, socket.AF_UNSPEC, socket.SOCK_STREAM - ), - ) - ips = list({info[4][0] for info in infos}) - if not ips: - raise SSRFError(f"DNS resolution returned no addresses for {hostname}") - return ips - except socket.gaierror as e: - raise SSRFError(f"DNS resolution failed for {hostname}: {e}") from e - - -@dataclass -class ValidatedURL: - """A URL that has been validated for SSRF with resolved IPs.""" - - original_url: str - hostname: str - port: int - path: str - resolved_ips: list[str] - - -@dataclass -class SSRFFetchResponse: - """Response payload from an SSRF-safe fetch.""" - - content: bytes - status_code: int - headers: dict[str, str] - - -async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: - """Validate URL for SSRF and resolve to IPs. - - Args: - url: URL to validate - require_path: If True, require non-root path (for CIMD) - - Returns: - ValidatedURL with resolved IPs - - Raises: - SSRFError: If URL is invalid or resolves to blocked IPs - """ - try: - parsed = urlparse(url) - except (ValueError, AttributeError) as e: - raise SSRFError(f"Invalid URL: {e}") from e - - if parsed.scheme != "https": - raise SSRFError(f"URL must use HTTPS, got: {parsed.scheme}") - - if not parsed.netloc: - raise SSRFError("URL must have a host") - - if require_path and parsed.path in ("", "/"): - raise SSRFError("URL must have a non-root path") - - hostname = parsed.hostname or parsed.netloc - port = parsed.port or 443 - - # Resolve and validate IPs - resolved_ips = await resolve_hostname(hostname, port) - - blocked = [ip for ip in resolved_ips if not is_ip_allowed(ip)] - if blocked: - raise SSRFError( - f"URL resolves to blocked IP address(es): {blocked}. " - f"Private, loopback, link-local, and reserved IPs are not allowed." - ) - - return ValidatedURL( - original_url=url, - hostname=hostname, - port=port, - path=parsed.path + ("?" + parsed.query if parsed.query else ""), - resolved_ips=resolved_ips, - ) - - -async def ssrf_safe_fetch( - url: str, - *, - require_path: bool = False, - max_size: int = 5120, - timeout: float = 10.0, - overall_timeout: float = 30.0, -) -> bytes: - """Fetch URL with comprehensive SSRF protection and DNS pinning. - - Security measures: - 1. HTTPS only - 2. DNS resolution with IP validation - 3. Connects to validated IP directly (DNS pinning prevents rebinding) - 4. Response size limit - 5. Redirects disabled - 6. Overall timeout - - Args: - url: URL to fetch - require_path: If True, require non-root path - max_size: Maximum response size in bytes (default 5KB) - timeout: Per-operation timeout in seconds - overall_timeout: Overall timeout for entire operation - - Returns: - Response body as bytes - - Raises: - SSRFError: If SSRF validation fails - SSRFFetchError: If fetch fails - """ - response = await ssrf_safe_fetch_response( - url, - require_path=require_path, - max_size=max_size, - timeout=timeout, - overall_timeout=overall_timeout, - allowed_status_codes={200}, - ) - return response.content - - -async def ssrf_safe_fetch_response( - url: str, - *, - require_path: bool = False, - max_size: int = 5120, - timeout: float = 10.0, - overall_timeout: float = 30.0, - request_headers: Mapping[str, str] | None = None, - allowed_status_codes: set[int] | None = None, -) -> SSRFFetchResponse: - """Fetch URL with SSRF protection and return response metadata. - - This is equivalent to :func:`ssrf_safe_fetch` but returns response headers - and status code, and supports conditional request headers. - """ - start_time = time.monotonic() - - # Validate URL and resolve DNS - validated = await validate_url(url, require_path=require_path) - - last_error: Exception | None = None - expected_statuses = allowed_status_codes or {200} - - for pinned_ip in validated.resolved_ips: - elapsed = time.monotonic() - start_time - if elapsed > overall_timeout: - raise SSRFFetchError(f"Overall timeout exceeded: {url}") - remaining = max(1.0, overall_timeout - elapsed) - - pinned_url = ( - f"https://{format_ip_for_url(pinned_ip)}:{validated.port}{validated.path}" - ) - - logger.debug( - "SSRF-safe fetch: %s -> %s (pinned to %s)", - url, - pinned_url, - pinned_ip, - ) - - headers = {"Host": validated.hostname} - if request_headers: - for key, value in request_headers.items(): - # Host must remain pinned to the validated hostname. - if key.lower() == "host": - continue - headers[key] = value - - try: - # Use httpx with streaming to enforce size limit during download - async with ( - httpx.AsyncClient( - timeout=httpx.Timeout( - connect=min(timeout, remaining), - read=min(timeout, remaining), - write=min(timeout, remaining), - pool=min(timeout, remaining), - ), - follow_redirects=False, - verify=True, - ) as client, - client.stream( - "GET", - pinned_url, - headers=headers, - extensions={"sni_hostname": validated.hostname}, - ) as response, - ): - if time.monotonic() - start_time > overall_timeout: - raise SSRFFetchError(f"Overall timeout exceeded: {url}") - - if response.status_code not in expected_statuses: - raise SSRFFetchError(f"HTTP {response.status_code} fetching {url}") - - # Check Content-Length header first if available - content_length = response.headers.get("content-length") - if content_length: - try: - size = int(content_length) - if size > max_size: - raise SSRFFetchError( - f"Response too large: {size} bytes (max {max_size})" - ) - except ValueError: - pass - - # Stream the response and enforce size limit during download - chunks = [] - total = 0 - async for chunk in response.aiter_bytes(): - if time.monotonic() - start_time > overall_timeout: - raise SSRFFetchError(f"Overall timeout exceeded: {url}") - total += len(chunk) - if total > max_size: - raise SSRFFetchError( - f"Response too large: exceeded {max_size} bytes" - ) - chunks.append(chunk) - - return SSRFFetchResponse( - content=b"".join(chunks), - status_code=response.status_code, - headers=dict(response.headers), - ) - - except httpx.TimeoutException as e: - last_error = e - continue - except httpx.RequestError as e: - last_error = e - continue - - if last_error is not None: - if isinstance(last_error, httpx.TimeoutException): - raise SSRFFetchError(f"Timeout fetching {url}") from last_error - raise SSRFFetchError(f"Error fetching {url}: {last_error}") from last_error - - raise SSRFFetchError(f"Error fetching {url}: no resolved IPs succeeded") diff --git a/src/fastmcp/server/context.py b/src/fastmcp/server/context.py deleted file mode 100644 index 11bbbf90e..000000000 --- a/src/fastmcp/server/context.py +++ /dev/null @@ -1,1403 +0,0 @@ -from __future__ import annotations - -import logging -import weakref -from collections.abc import Callable, Generator, Mapping, Sequence -from contextlib import contextmanager -from contextvars import ContextVar, Token -from dataclasses import dataclass -from logging import Logger -from typing import Any, Literal, overload - -import mcp.types -from mcp import LoggingLevel, ServerSession -from mcp.server.lowlevel.server import request_ctx -from mcp.shared.context import RequestContext -from mcp.types import ( - GetPromptResult, - ModelPreferences, - Root, - SamplingMessage, -) -from mcp.types import Prompt as SDKPrompt -from mcp.types import Resource as SDKResource -from pydantic.networks import AnyUrl -from starlette.requests import Request -from typing_extensions import TypeVar -from uncalled_for import SharedContext - -from fastmcp.resources.base import ResourceResult -from fastmcp.server.elicitation import ( - AcceptedElicitation, - CancelledElicitation, - DeclinedElicitation, - handle_elicit_accept, - parse_elicit_response_type, -) -from fastmcp.server.low_level import MiddlewareServerSession -from fastmcp.server.sampling import SampleStep, SamplingResult, SamplingTool -from fastmcp.server.sampling.run import ( - sample_impl, - sample_step_impl, -) -from fastmcp.server.server import FastMCP, StateValue -from fastmcp.server.transforms.visibility import ( - Visibility, -) -from fastmcp.server.transforms.visibility import ( - disable_components as _disable_components, -) -from fastmcp.server.transforms.visibility import ( - enable_components as _enable_components, -) -from fastmcp.server.transforms.visibility import ( - get_session_transforms as _get_session_transforms, -) -from fastmcp.server.transforms.visibility import ( - get_visibility_rules as _get_visibility_rules, -) -from fastmcp.server.transforms.visibility import ( - reset_visibility as _reset_visibility, -) -from fastmcp.utilities.logging import _clamp_logger, get_logger -from fastmcp.utilities.versions import VersionSpec - -logger: Logger = get_logger(name=__name__) -to_client_logger: Logger = logger.getChild(suffix="to_client") - -# Convert all levels of server -> client messages to debug level -# This clamp can be undone at runtime by calling `_unclamp_logger` or calling -# `_clamp_logger` with a different max level. -_clamp_logger(logger=to_client_logger, max_level="DEBUG") - - -T = TypeVar("T", default=Any) -ResultT = TypeVar("ResultT", default=str) - -# Import ToolChoiceOption from sampling module (after other imports) -from fastmcp.server.sampling.run import ToolChoiceOption # noqa: E402 - -_current_context: ContextVar[Context | None] = ContextVar("context", default=None) - -TransportType = Literal["stdio", "sse", "streamable-http"] -_current_transport: ContextVar[TransportType | None] = ContextVar( - "transport", default=None -) - - -def set_transport( - transport: TransportType, -) -> Token[TransportType | None]: - """Set the current transport type. Returns token for reset.""" - return _current_transport.set(transport) - - -def reset_transport(token: Token[TransportType | None]) -> None: - """Reset transport to previous value.""" - _current_transport.reset(token) - - -@dataclass -class LogData: - """Data object for passing log arguments to client-side handlers. - - This provides an interface to match the Python standard library logging, - for compatibility with structured logging. - """ - - msg: str - extra: Mapping[str, Any] | None = None - - -_mcp_level_to_python_level = { - "debug": logging.DEBUG, - "info": logging.INFO, - "notice": logging.INFO, - "warning": logging.WARNING, - "error": logging.ERROR, - "critical": logging.CRITICAL, - "alert": logging.CRITICAL, - "emergency": logging.CRITICAL, -} - - -@contextmanager -def set_context(context: Context) -> Generator[Context, None, None]: - token = _current_context.set(context) - try: - yield context - finally: - _current_context.reset(token) - - -@dataclass -class Context: - """Context object providing access to MCP capabilities. - - This provides a cleaner interface to MCP's RequestContext functionality. - It gets injected into tool and resource functions that request it via type hints. - - To use context in a tool function, add a parameter with the Context type annotation: - - ```python - @server.tool - async def my_tool(x: int, ctx: Context) -> str: - # Log messages to the client - await ctx.info(f"Processing {x}") - await ctx.debug("Debug info") - await ctx.warning("Warning message") - await ctx.error("Error message") - - # Report progress - await ctx.report_progress(50, 100, "Processing") - - # Access resources - data = await ctx.read_resource("resource://data") - - # Get request info - request_id = ctx.request_id - client_id = ctx.client_id - - # Manage state across the session (persists across requests) - await ctx.set_state("key", "value") - value = await ctx.get_state("key") - - # Store non-serializable values for the current request only - await ctx.set_state("client", http_client, serializable=False) - - return str(x) - ``` - - State Management: - Context provides session-scoped state that persists across requests within - the same MCP session. State is automatically keyed by session, ensuring - isolation between different clients. - - State set during `on_initialize` middleware will persist to subsequent tool - calls when using the same session object (STDIO, SSE, single-server HTTP). - For distributed/serverless HTTP deployments where different machines handle - the init and tool calls, state is isolated by the mcp-session-id header. - - The context parameter name can be anything as long as it's annotated with Context. - The context is optional - tools that don't need it can omit the parameter. - - """ - - # Default TTL for session state: 1 day in seconds - _STATE_TTL_SECONDS: int = 86400 - - def __init__( - self, - fastmcp: FastMCP, - session: ServerSession | None = None, - *, - task_id: str | None = None, - origin_request_id: str | None = None, - ): - self._fastmcp: weakref.ref[FastMCP] = weakref.ref(fastmcp) - self._session: ServerSession | None = session # For state ops during init - self._tokens: list[Token] = [] - # Background task support (SEP-1686) - self._task_id: str | None = task_id - self._origin_request_id: str | None = origin_request_id - # Request-scoped state for non-serializable values (serializable=False) - self._request_state: dict[str, Any] = {} - - @property - def is_background_task(self) -> bool: - """True when this context is running in a background task (Docket worker). - - When True, certain operations like elicit() and sample() will use - task-aware implementations that can pause the task and wait for - client input. - - Example: - ```python - @server.tool(task=True) - async def my_task(ctx: Context) -> str: - # Works transparently in both foreground and background task modes - result = await ctx.elicit("Need input", str) - return str(result) - ``` - """ - return self._task_id is not None - - @property - def task_id(self) -> str | None: - """Get the background task ID if running in a background task. - - Returns None if not running in a background task context. - """ - return self._task_id - - @property - def origin_request_id(self) -> str | None: - """Get the request ID that originated this execution, if available. - - In foreground request mode, this is the current request_id. - In background task mode, this is the request_id captured when the task - was submitted, if one was available. - """ - if self.request_context is not None: - return str(self.request_context.request_id) - return self._origin_request_id - - @property - def fastmcp(self) -> FastMCP: - """Get the FastMCP instance.""" - fastmcp = self._fastmcp() - if fastmcp is None: - raise RuntimeError("FastMCP instance is no longer available") - return fastmcp - - async def __aenter__(self) -> Context: - """Enter the context manager and set this context as the current context.""" - # Inherit request-scoped state from parent context so middleware - # and tool contexts share the same in-memory state dict. - parent = _current_context.get(None) - if parent is not None: - self._request_state = parent._request_state - - # Always set this context and save the token - token = _current_context.set(self) - self._tokens.append(token) - - # Set current server for dependency injection (use weakref to avoid reference cycles) - from fastmcp.server.dependencies import ( - _current_docket, - _current_server, - _current_worker, - is_docket_available, - ) - - self._server_token = _current_server.set(weakref.ref(self.fastmcp)) - - # Set docket/worker from server instance for this request's context. - # This ensures ContextVars work even in ASGI environments (Lambda, FastAPI mount) - # where lifespan ContextVars don't propagate to request handlers. - server = self.fastmcp - if is_docket_available(): - if server._docket is not None: - self._docket_token = _current_docket.set(server._docket) - if server._worker is not None: - self._worker_token = _current_worker.set(server._worker) - else: - # Without docket, the lifespan won't provide a SharedContext, - # so create one scoped to this Context for Shared() dependencies. - self._shared_context = SharedContext() - await self._shared_context.__aenter__() - - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: - """Exit the context manager and reset the most recent token.""" - from fastmcp.server.dependencies import ( - _current_docket, - _current_server, - _current_worker, - ) - - # Mirror __aenter__: clean up docket/worker tokens or SharedContext - if hasattr(self, "_worker_token"): - _current_worker.reset(self._worker_token) - del self._worker_token - if hasattr(self, "_docket_token"): - _current_docket.reset(self._docket_token) - del self._docket_token - if hasattr(self, "_shared_context"): - await self._shared_context.__aexit__(exc_type, exc_val, exc_tb) - del self._shared_context - - if hasattr(self, "_server_token"): - _current_server.reset(self._server_token) - del self._server_token - - # Reset context token - if self._tokens: - token = self._tokens.pop() - _current_context.reset(token) - - @property - def request_context(self) -> RequestContext[ServerSession, Any, Request] | None: - """Access to the underlying request context. - - Returns None when the MCP session has not been established yet. - Returns the full RequestContext once the MCP session is available. - - For HTTP request access in middleware, use `get_http_request()` from fastmcp.server.dependencies, - which works whether or not the MCP session is available. - - Example in middleware: - ```python - async def on_request(self, context, call_next): - ctx = context.fastmcp_context - if ctx.request_context: - # MCP session available - can access session_id, request_id, etc. - session_id = ctx.session_id - else: - # MCP session not available yet - use HTTP helpers - from fastmcp.server.dependencies import get_http_request - request = get_http_request() - return await call_next(context) - ``` - """ - try: - return request_ctx.get() - except LookupError: - return None - - @property - def lifespan_context(self) -> dict[str, Any]: - """Access the server's lifespan context. - - Returns the context dict yielded by the server's lifespan function. - Returns an empty dict if no lifespan was configured or if the MCP - session is not yet established. - - In background tasks (Docket workers), where request_context is not - available, falls back to reading from the FastMCP server's lifespan - result directly. - - Example: - ```python - @server.tool - def my_tool(ctx: Context) -> str: - db = ctx.lifespan_context.get("db") - if db: - return db.query("SELECT 1") - return "No database connection" - ``` - """ - rc = self.request_context - if rc is None: - # In background tasks, request_context is not available. - # Fall back to the server's lifespan result directly (#3095). - result = self.fastmcp._lifespan_result - if result is not None: - return result - return {} - return rc.lifespan_context - - async def report_progress( - self, progress: float, total: float | None = None, message: str | None = None - ) -> None: - """Report progress for the current operation. - - Works in both foreground (MCP progress notifications) and background - (Docket task execution) contexts. - - Args: - progress: Current progress value e.g. 24 - total: Optional total value e.g. 100 - message: Optional status message describing current progress - """ - - progress_token = ( - self.request_context.meta.progressToken - if self.request_context and self.request_context.meta - else None - ) - - # Foreground: Send MCP progress notification if we have a token - if progress_token is not None: - await self.session.send_progress_notification( - progress_token=progress_token, - progress=progress, - total=total, - message=message, - related_request_id=self.request_id, - ) - return - - # Background: Update Docket execution progress (stored in Redis) - # This makes progress visible via tasks/get and notifications/tasks/status - from fastmcp.server.dependencies import is_docket_available - - if not is_docket_available(): - return - - try: - from docket.dependencies import current_execution - - execution = current_execution.get() - - # Update progress in Redis using Docket's progress API. - # Docket only exposes increment() (relative), so we compute - # the delta from the last reported value stored on this execution. - if total is not None: - await execution.progress.set_total(int(total)) - - current = int(progress) - last: int = getattr(execution, "_fastmcp_last_progress", 0) - delta = current - last - if delta > 0: - await execution.progress.increment(delta) - execution._fastmcp_last_progress = current # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - - if message is not None: - await execution.progress.set_message(message) - except LookupError: - # Not running in Docket worker context - no progress tracking available - pass - - async def _paginate_list( - self, - request_factory: Callable[[str | None], Any], - call_method: Callable[[Any], Any], - extract_items: Callable[[Any], list[Any]], - ) -> list[Any]: - """Generic pagination helper for list operations. - - Args: - request_factory: Function that creates a request from a cursor - call_method: Async method to call with the request - extract_items: Function to extract items from the result - - Returns: - List of all items across all pages - """ - all_items: list[Any] = [] - cursor: str | None = None - seen_cursors: set[str] = set() - while True: - request = request_factory(cursor) - result = await call_method(request) - all_items.extend(extract_items(result)) - if not result.nextCursor: - break - if result.nextCursor in seen_cursors: - break - seen_cursors.add(result.nextCursor) - cursor = result.nextCursor - return all_items - - async def list_resources(self) -> list[SDKResource]: - """List all available resources from the server. - - Returns: - List of Resource objects available on the server - """ - return await self._paginate_list( - request_factory=lambda cursor: mcp.types.ListResourcesRequest( - params=mcp.types.PaginatedRequestParams(cursor=cursor) - if cursor - else None - ), - call_method=self.fastmcp._list_resources_mcp, - extract_items=lambda result: result.resources, - ) - - async def list_prompts(self) -> list[SDKPrompt]: - """List all available prompts from the server. - - Returns: - List of Prompt objects available on the server - """ - return await self._paginate_list( - request_factory=lambda cursor: mcp.types.ListPromptsRequest( - params=mcp.types.PaginatedRequestParams(cursor=cursor) - if cursor - else None - ), - call_method=self.fastmcp._list_prompts_mcp, - extract_items=lambda result: result.prompts, - ) - - async def get_prompt( - self, name: str, arguments: dict[str, Any] | None = None - ) -> GetPromptResult: - """Get a prompt by name with optional arguments. - - Args: - name: The name of the prompt to get - arguments: Optional arguments to pass to the prompt - - Returns: - The prompt result - """ - result = await self.fastmcp.render_prompt(name, arguments) - if isinstance(result, mcp.types.CreateTaskResult): - raise RuntimeError( - "Unexpected CreateTaskResult: Context calls should not have task metadata" - ) - return result.to_mcp_prompt_result() - - async def read_resource(self, uri: str | AnyUrl) -> ResourceResult: - """Read a resource by URI. - - Args: - uri: Resource URI to read - - Returns: - ResourceResult with contents - """ - result = await self.fastmcp.read_resource(str(uri)) - if isinstance(result, mcp.types.CreateTaskResult): - raise RuntimeError( - "Unexpected CreateTaskResult: Context calls should not have task metadata" - ) - return result - - async def log( - self, - message: str, - level: LoggingLevel | None = None, - logger_name: str | None = None, - extra: Mapping[str, Any] | None = None, - ) -> None: - """Send a log message to the client. - - Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`. - - Args: - message: Log message - level: Optional log level. One of "debug", "info", "notice", "warning", "error", "critical", - "alert", or "emergency". Default is "info". - logger_name: Optional logger name - extra: Optional mapping for additional arguments - """ - data = LogData(msg=message, extra=extra) - related_request_id = self.origin_request_id - - await _log_to_server_and_client( - data=data, - session=self.session, - level=level or "info", - logger_name=logger_name, - related_request_id=related_request_id, - ) - - @property - def transport(self) -> TransportType | None: - """Get the current transport type. - - Returns the transport type used to run this server: "stdio", "sse", - or "streamable-http". Returns None if called outside of a server context. - """ - return _current_transport.get() - - def client_supports_extension(self, extension_id: str) -> bool: - """Check whether the connected client supports a given MCP extension. - - Inspects the ``extensions`` extra field on ``ClientCapabilities`` - sent by the client during initialization. - - Returns ``False`` when no session is available (e.g., outside a - request context) or when the client did not advertise the extension. - - Example:: - - from fastmcp.apps.config import UI_EXTENSION_ID - - @mcp.tool - async def my_tool(ctx: Context) -> str: - if ctx.client_supports_extension(UI_EXTENSION_ID): - return "UI-capable client" - return "text-only client" - """ - rc = self.request_context - if rc is None: - return False - session = rc.session - if not isinstance(session, MiddlewareServerSession): - return False - return session.client_supports_extension(extension_id) - - @property - def client_id(self) -> str | None: - """Get the client ID if available.""" - return ( - getattr(self.request_context.meta, "client_id", None) - if self.request_context and self.request_context.meta - else None - ) - - @property - def request_id(self) -> str: - """Get the unique ID for this request. - - Raises RuntimeError if MCP request context is not available. - """ - if self.request_context is None: - raise RuntimeError( - "request_id is not available because the MCP session has not been established yet. " - "Check `context.request_context` for None before accessing this attribute." - ) - return str(self.request_context.request_id) - - @property - def session_id(self) -> str: - """Get the MCP session ID for ALL transports. - - Returns the session ID that can be used as a key for session-based - data storage (e.g., Redis) to share data between tool calls within - the same client session. - - Returns: - The session ID for StreamableHTTP transports, or a generated ID - for other transports. - - Raises: - RuntimeError if no session is available. - - Example: - ```python - @server.tool - def store_data(data: dict, ctx: Context) -> str: - session_id = ctx.session_id - redis_client.set(f"session:{session_id}:data", json.dumps(data)) - return f"Data stored for session {session_id}" - ``` - """ - from uuid import uuid4 - - # Get session from request context or _session (for on_initialize) - request_ctx = self.request_context - if request_ctx is not None: - session = request_ctx.session - elif self._session is not None: - session = self._session - else: - raise RuntimeError( - "session_id is not available because no session exists. " - "This typically means you're outside a request context." - ) - - # Check for cached session ID - session_id = getattr(session, "_fastmcp_state_prefix", None) - if session_id is not None: - return session_id - - # For HTTP, try to get from header - if request_ctx is not None: - request = request_ctx.request - if request: - session_id = request.headers.get("mcp-session-id") - - # For STDIO/SSE/in-memory, generate a UUID - if session_id is None: - session_id = str(uuid4()) - - # Cache on session for consistency - session._fastmcp_state_prefix = session_id # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - return session_id - - @property - def session(self) -> ServerSession: - """Access to the underlying session for advanced usage. - - In request mode: Returns the session from the active request context. - In background task mode: Returns the session stored at Context creation. - - Raises RuntimeError if no session is available. - """ - # Background task mode: use the stored session - if self.is_background_task and self._session is not None: - return self._session - - # Request mode: use request context - if self.request_context is not None: - return self.request_context.session - - # Fallback to stored session (e.g., during on_initialize) - if self._session is not None: - return self._session - - raise RuntimeError( - "session is not available because the MCP session has not been established yet. " - "Check `context.request_context` for None before accessing this attribute." - ) - - # Convenience methods for common log levels - async def debug( - self, - message: str, - logger_name: str | None = None, - extra: Mapping[str, Any] | None = None, - ) -> None: - """Send a `DEBUG`-level message to the connected MCP Client. - - Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.""" - await self.log( - level="debug", - message=message, - logger_name=logger_name, - extra=extra, - ) - - async def info( - self, - message: str, - logger_name: str | None = None, - extra: Mapping[str, Any] | None = None, - ) -> None: - """Send a `INFO`-level message to the connected MCP Client. - - Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.""" - await self.log( - level="info", - message=message, - logger_name=logger_name, - extra=extra, - ) - - async def warning( - self, - message: str, - logger_name: str | None = None, - extra: Mapping[str, Any] | None = None, - ) -> None: - """Send a `WARNING`-level message to the connected MCP Client. - - Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.""" - await self.log( - level="warning", - message=message, - logger_name=logger_name, - extra=extra, - ) - - async def error( - self, - message: str, - logger_name: str | None = None, - extra: Mapping[str, Any] | None = None, - ) -> None: - """Send a `ERROR`-level message to the connected MCP Client. - - Messages sent to Clients are also logged to the `fastmcp.server.context.to_client` logger with a level of `DEBUG`.""" - await self.log( - level="error", - message=message, - logger_name=logger_name, - extra=extra, - ) - - async def list_roots(self) -> list[Root]: - """List the roots available to the server, as indicated by the client.""" - result = await self.session.list_roots() - return result.roots - - async def send_notification( - self, notification: mcp.types.ServerNotificationType - ) -> None: - """Send a notification to the client immediately. - - Args: - notification: An MCP notification instance (e.g., ToolListChangedNotification()) - """ - await self.session.send_notification(mcp.types.ServerNotification(notification)) - - async def close_sse_stream(self) -> None: - """Close the current response stream to trigger client reconnection. - - When using StreamableHTTP transport with an EventStore configured, this - method gracefully closes the HTTP connection for the current request. - The client will automatically reconnect (after `retry_interval` milliseconds) - and resume receiving events from where it left off via the EventStore. - - This is useful for long-running operations to avoid load balancer timeouts. - Instead of holding a connection open for minutes, you can periodically close - and let the client reconnect. - - Example: - ```python - @mcp.tool - async def long_running_task(ctx: Context) -> str: - for i in range(100): - await ctx.report_progress(i, 100) - - # Close connection every 30 iterations to avoid LB timeouts - if i % 30 == 0 and i > 0: - await ctx.close_sse_stream() - - await do_work() - return "Done" - ``` - - Note: - This is a no-op (with a debug log) if not using StreamableHTTP - transport with an EventStore configured. - """ - if not self.request_context or not self.request_context.close_sse_stream: - logger.debug( - "close_sse_stream() called but not applicable " - "(requires StreamableHTTP transport with event_store)" - ) - return - await self.request_context.close_sse_stream() - - async def sample_step( - self, - messages: str | Sequence[str | SamplingMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - max_tokens: int | None = None, - model_preferences: ModelPreferences | str | list[str] | None = None, - tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, - tool_choice: ToolChoiceOption | str | None = None, - execute_tools: bool = True, - mask_error_details: bool | None = None, - tool_concurrency: int | None = None, - ) -> SampleStep: - """ - Make a single LLM sampling call. - - This is a stateless function that makes exactly one LLM call and optionally - executes any requested tools. Use this for fine-grained control over the - sampling loop. - - Args: - messages: The message(s) to send. Can be a string, list of strings, - or list of SamplingMessage objects. - system_prompt: Optional system prompt for the LLM. - temperature: Optional sampling temperature. - max_tokens: Maximum tokens to generate. Defaults to 512. - model_preferences: Optional model preferences. - tools: Optional list of tools the LLM can use. - tool_choice: Tool choice mode ("auto", "required", or "none"). - execute_tools: If True (default), execute tool calls and append results - to history. If False, return immediately with tool_calls available - in the step for manual execution. - mask_error_details: If True, mask detailed error messages from tool - execution. When None (default), uses the global settings value. - Tools can raise ToolError to bypass masking. - tool_concurrency: Controls parallel execution of tools: - - None (default): Sequential execution (one at a time) - - 0: Unlimited parallel execution - - N > 0: Execute at most N tools concurrently - If any tool has sequential=True, all tools execute sequentially - regardless of this setting. - - Returns: - SampleStep containing: - - .response: The raw LLM response - - .history: Messages including input, assistant response, and tool results - - .is_tool_use: True if the LLM requested tool execution - - .tool_calls: List of tool calls (if any) - - .text: The text content (if any) - - Example: - messages = "Research X" - - while True: - step = await ctx.sample_step(messages, tools=[search]) - - if not step.is_tool_use: - print(step.text) - break - - # Continue with tool results - messages = step.history - """ - return await sample_step_impl( - self, - messages=messages, - system_prompt=system_prompt, - temperature=temperature, - max_tokens=max_tokens, - model_preferences=model_preferences, - tools=tools, - tool_choice=tool_choice, - auto_execute_tools=execute_tools, - mask_error_details=mask_error_details, - tool_concurrency=tool_concurrency, - ) - - @overload - async def sample( - self, - messages: str | Sequence[str | SamplingMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - max_tokens: int | None = None, - model_preferences: ModelPreferences | str | list[str] | None = None, - tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, - result_type: type[ResultT], - mask_error_details: bool | None = None, - tool_concurrency: int | None = None, - ) -> SamplingResult[ResultT]: - """Overload: With result_type, returns SamplingResult[ResultT].""" - - @overload - async def sample( - self, - messages: str | Sequence[str | SamplingMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - max_tokens: int | None = None, - model_preferences: ModelPreferences | str | list[str] | None = None, - tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, - result_type: None = None, - mask_error_details: bool | None = None, - tool_concurrency: int | None = None, - ) -> SamplingResult[str]: - """Overload: Without result_type, returns SamplingResult[str].""" - - async def sample( - self, - messages: str | Sequence[str | SamplingMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - max_tokens: int | None = None, - model_preferences: ModelPreferences | str | list[str] | None = None, - tools: Sequence[SamplingTool | Callable[..., Any]] | None = None, - result_type: type[ResultT] | None = None, - mask_error_details: bool | None = None, - tool_concurrency: int | None = None, - ) -> SamplingResult[ResultT] | SamplingResult[str]: - """ - Send a sampling request to the client and await the response. - - This method runs to completion automatically. When tools are provided, - it executes a tool loop: if the LLM returns a tool use request, the tools - are executed and the results are sent back to the LLM. This continues - until the LLM provides a final text response. - - When result_type is specified, a synthetic `final_response` tool is - created. The LLM calls this tool to provide the structured response, - which is validated against the result_type and returned as `.result`. - - For fine-grained control over the sampling loop, use sample_step() instead. - - Args: - messages: The message(s) to send. Can be a string, list of strings, - or list of SamplingMessage objects. - system_prompt: Optional system prompt for the LLM. - temperature: Optional sampling temperature. - max_tokens: Maximum tokens to generate. Defaults to 512. - model_preferences: Optional model preferences. - tools: Optional list of tools the LLM can use. Accepts plain - functions or SamplingTools. - result_type: Optional type for structured output. When specified, - a synthetic `final_response` tool is created and the LLM's - response is validated against this type. - mask_error_details: If True, mask detailed error messages from tool - execution. When None (default), uses the global settings value. - Tools can raise ToolError to bypass masking. - tool_concurrency: Controls parallel execution of tools: - - None (default): Sequential execution (one at a time) - - 0: Unlimited parallel execution - - N > 0: Execute at most N tools concurrently - If any tool has sequential=True, all tools execute sequentially - regardless of this setting. - - Returns: - SamplingResult[T] containing: - - .text: The text representation (raw text or JSON for structured) - - .result: The typed result (str for text, parsed object for structured) - - .history: All messages exchanged during sampling - - Note: - Background task support for sampling is planned for a future release. - Currently, sampling in background tasks requires using the low-level - session.create_message() API directly. - """ - # TODO: Add background task support similar to elicit() when is_background_task - return await sample_impl( - self, - messages=messages, - system_prompt=system_prompt, - temperature=temperature, - max_tokens=max_tokens, - model_preferences=model_preferences, - tools=tools, - result_type=result_type, - mask_error_details=mask_error_details, - tool_concurrency=tool_concurrency, - ) - - @overload - async def elicit( - self, - message: str, - response_type: None, - ) -> ( - AcceptedElicitation[dict[str, Any]] | DeclinedElicitation | CancelledElicitation - ): ... - - """When response_type is None, the accepted elicitation will contain an - empty dict""" - - @overload - async def elicit( - self, - message: str, - response_type: type[T], - ) -> AcceptedElicitation[T] | DeclinedElicitation | CancelledElicitation: ... - - """When response_type is not None, the accepted elicitation will contain the - response data""" - - @overload - async def elicit( - self, - message: str, - response_type: list[str], - ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ... - - """When response_type is a list of strings, the accepted elicitation will - contain the selected string response""" - - @overload - async def elicit( - self, - message: str, - response_type: dict[str, dict[str, str]], - ) -> AcceptedElicitation[str] | DeclinedElicitation | CancelledElicitation: ... - - """When response_type is a dict mapping keys to title dicts, the accepted - elicitation will contain the selected key""" - - @overload - async def elicit( - self, - message: str, - response_type: list[list[str]], - ) -> ( - AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation - ): ... - - """When response_type is a list containing a list of strings (multi-select), - the accepted elicitation will contain a list of selected strings""" - - @overload - async def elicit( - self, - message: str, - response_type: list[dict[str, dict[str, str]]], - ) -> ( - AcceptedElicitation[list[str]] | DeclinedElicitation | CancelledElicitation - ): ... - - """When response_type is a list containing a dict mapping keys to title dicts - (multi-select with titles), the accepted elicitation will contain a list of - selected keys""" - - async def elicit( - self, - message: str, - response_type: type[T] - | list[str] - | dict[str, dict[str, str]] - | list[list[str]] - | list[dict[str, dict[str, str]]] - | None = None, - ) -> ( - AcceptedElicitation[T] - | AcceptedElicitation[dict[str, Any]] - | AcceptedElicitation[str] - | AcceptedElicitation[list[str]] - | DeclinedElicitation - | CancelledElicitation - ): - """ - Send an elicitation request to the client and await the response. - - Call this method at any time to request additional information from - the user through the client. The client must support elicitation, - or the request will error. - - Note that the MCP protocol only supports simple object schemas with - primitive types. You can provide a dataclass, TypedDict, or BaseModel to - comply. If you provide a primitive type, an object schema with a single - "value" field will be generated for the MCP interaction and - automatically deconstructed into the primitive type upon response. - - If the response_type is None, the generated schema will be that of an - empty object in order to comply with the MCP protocol requirements. - Clients must send an empty object ("{}")in response. - - Args: - message: A human-readable message explaining what information is needed - response_type: The type of the response, which should be a primitive - type or dataclass or BaseModel. If it is a primitive type, an - object schema with a single "value" field will be generated. - - Note: - This method works transparently in both request and background task - contexts. In background task mode (SEP-1686), it will set the task - status to "input_required" and wait for the client to provide input. - """ - config = parse_elicit_response_type(response_type) - - if self.is_background_task: - # Background task mode: use task-aware elicitation - result = await self._elicit_for_task( - message=message, - schema=config.schema, - ) - else: - # Standard request mode: use session.elicit directly - result = await self.session.elicit( - message=message, - requestedSchema=config.schema, - related_request_id=self.request_id, - ) - - if result.action == "accept": - return handle_elicit_accept(config, result.content) - elif result.action == "decline": - return DeclinedElicitation() - elif result.action == "cancel": - return CancelledElicitation() - else: - raise ValueError(f"Unexpected elicitation action: {result.action}") - - async def _elicit_for_task( - self, - message: str, - schema: dict[str, Any], - ) -> mcp.types.ElicitResult: - """Send an elicitation request from a background task (SEP-1686). - - This method handles elicitation when running in a Docket worker context, - where there's no active MCP request. It: - 1. Sets the task status to "input_required" - 2. Sends the elicitation request with task metadata - 3. Waits for the client to provide input via tasks/sendInput - 4. Returns the result and resumes task execution - - Args: - message: The message to display to the user - schema: The JSON schema for the expected response - - Returns: - ElicitResult with the user's response - - Raises: - RuntimeError: If not running in a background task context - """ - if not self.is_background_task: - raise RuntimeError( - "_elicit_for_task called but not in a background task context" - ) - - # Import here to avoid circular imports and optional dependency issues - from fastmcp.server.tasks.elicitation import elicit_for_task - - return await elicit_for_task( - task_id=self._task_id, # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - session=self._session, - message=message, - schema=schema, - fastmcp=self.fastmcp, - ) - - def _make_state_key(self, key: str) -> str: - """Create session-prefixed key for state storage.""" - return f"{self.session_id}:{key}" - - async def set_state( - self, key: str, value: Any, *, serializable: bool = True - ) -> None: - """Set a value in the state store. - - By default, values are stored in the session-scoped state store and - persist across requests within the same MCP session. Values must be - JSON-serializable (dicts, lists, strings, numbers, etc.). - - For non-serializable values (e.g., HTTP clients, database connections), - pass ``serializable=False``. These values are stored in a request-scoped - dict and only live for the current MCP request (tool call, resource - read, or prompt render). They will not be available in subsequent - requests. - - The key is automatically prefixed with the session identifier. - """ - prefixed_key = self._make_state_key(key) - if not serializable: - self._request_state[prefixed_key] = value - return - # Clear any request-scoped shadow so the session value is visible - self._request_state.pop(prefixed_key, None) - try: - await self.fastmcp._state_store.put( - key=prefixed_key, - value=StateValue(value=value), - ttl=self._STATE_TTL_SECONDS, - ) - except Exception as e: - # Catch serialization errors from Pydantic (ValueError) or - # the key_value library (SerializationError). Both contain - # "serialize" in the message. Other exceptions propagate as-is. - if "serialize" in str(e).lower(): - raise TypeError( - f"Value for state key {key!r} is not serializable. " - f"Use set_state({key!r}, value, serializable=False) to store " - f"non-serializable values. Note: non-serializable state is " - f"request-scoped and will not persist across requests." - ) from e - raise - - async def get_state(self, key: str) -> Any: - """Get a value from the state store. - - Checks request-scoped state first (set with ``serializable=False``), - then falls back to the session-scoped state store. - - Returns None if the key is not found. - """ - prefixed_key = self._make_state_key(key) - if prefixed_key in self._request_state: - return self._request_state[prefixed_key] - result = await self.fastmcp._state_store.get(key=prefixed_key) - return result.value if result is not None else None - - async def delete_state(self, key: str) -> None: - """Delete a value from the state store. - - Removes from both request-scoped and session-scoped stores. - """ - prefixed_key = self._make_state_key(key) - self._request_state.pop(prefixed_key, None) - await self.fastmcp._state_store.delete(key=prefixed_key) - - # ------------------------------------------------------------------------- - # Session visibility control - # ------------------------------------------------------------------------- - - async def _get_visibility_rules(self) -> list[dict[str, Any]]: - """Load visibility rule dicts from session state.""" - return await _get_visibility_rules(self) - - async def _get_session_transforms(self) -> list[Visibility]: - """Get session-specific Visibility transforms from state store.""" - return await _get_session_transforms(self) - - async def enable_components( - self, - *, - names: set[str] | None = None, - keys: set[str] | None = None, - version: VersionSpec | None = None, - tags: set[str] | None = None, - components: set[Literal["tool", "resource", "template", "prompt"]] - | None = None, - match_all: bool = False, - ) -> None: - """Enable components matching criteria for this session only. - - Session rules override global transforms. Rules accumulate - each call - adds a new rule to the session. Later marks override earlier ones - (Visibility transform semantics). - - Sends notifications to this session only: ToolListChangedNotification, - ResourceListChangedNotification, and PromptListChangedNotification. - - Args: - names: Component names or URIs to match. - keys: Component keys to match (e.g., {"tool:my_tool@v1"}). - version: Component version spec to match. - tags: Tags to match (component must have at least one). - components: Component types to match (e.g., {"tool", "prompt"}). - match_all: If True, matches all components regardless of other criteria. - """ - await _enable_components( - self, - names=names, - keys=keys, - version=version, - tags=tags, - components=components, - match_all=match_all, - ) - - async def disable_components( - self, - *, - names: set[str] | None = None, - keys: set[str] | None = None, - version: VersionSpec | None = None, - tags: set[str] | None = None, - components: set[Literal["tool", "resource", "template", "prompt"]] - | None = None, - match_all: bool = False, - ) -> None: - """Disable components matching criteria for this session only. - - Session rules override global transforms. Rules accumulate - each call - adds a new rule to the session. Later marks override earlier ones - (Visibility transform semantics). - - Sends notifications to this session only: ToolListChangedNotification, - ResourceListChangedNotification, and PromptListChangedNotification. - - Args: - names: Component names or URIs to match. - keys: Component keys to match (e.g., {"tool:my_tool@v1"}). - version: Component version spec to match. - tags: Tags to match (component must have at least one). - components: Component types to match (e.g., {"tool", "prompt"}). - match_all: If True, matches all components regardless of other criteria. - """ - await _disable_components( - self, - names=names, - keys=keys, - version=version, - tags=tags, - components=components, - match_all=match_all, - ) - - async def reset_visibility(self) -> None: - """Clear all session visibility rules. - - Use this to reset session visibility back to global defaults. - - Sends notifications to this session only: ToolListChangedNotification, - ResourceListChangedNotification, and PromptListChangedNotification. - """ - await _reset_visibility(self) - - -_MCP_LEVEL_SEVERITY: dict[LoggingLevel, int] = { - "debug": 0, - "info": 1, - "notice": 2, - "warning": 3, - "error": 4, - "critical": 5, - "alert": 6, - "emergency": 7, -} - - -async def _log_to_server_and_client( - data: LogData, - session: ServerSession, - level: LoggingLevel, - logger_name: str | None = None, - related_request_id: str | None = None, -) -> None: - """Log a message to the server and client.""" - from fastmcp.server.low_level import MiddlewareServerSession - - if isinstance(session, MiddlewareServerSession): - min_level = session._minimum_logging_level or session.fastmcp.client_log_level - if min_level is not None: - if _MCP_LEVEL_SEVERITY[level] < _MCP_LEVEL_SEVERITY[min_level]: - return - - msg_prefix = f"Sending {level.upper()} to client" - - if logger_name: - msg_prefix += f" ({logger_name})" - - to_client_logger.log( - level=_mcp_level_to_python_level[level], - msg=f"{msg_prefix}: {data.msg}", - extra=data.extra, - ) - - await session.send_log_message( - level=level, - data=data, - logger=logger_name, - related_request_id=related_request_id, - ) diff --git a/src/fastmcp/server/dependencies.py b/src/fastmcp/server/dependencies.py deleted file mode 100644 index b024a1037..000000000 --- a/src/fastmcp/server/dependencies.py +++ /dev/null @@ -1,1591 +0,0 @@ -"""Dependency injection for FastMCP. - -DI features (Depends, CurrentContext, CurrentFastMCP) work without pydocket -using the uncalled-for DI engine. Only task-related dependencies (CurrentDocket, -CurrentWorker) and background task execution require fastmcp[tasks]. -""" - -from __future__ import annotations - -import contextlib -import inspect -import json -import logging -import weakref -from collections import OrderedDict -from collections.abc import AsyncGenerator, Callable -from contextlib import AsyncExitStack, asynccontextmanager -from contextvars import ContextVar, Token -from dataclasses import dataclass -from datetime import datetime, timezone -from functools import lru_cache -from types import TracebackType -from typing import TYPE_CHECKING, Any, Protocol, cast, get_type_hints, runtime_checkable - -from mcp.server.auth.middleware.auth_context import ( - get_access_token as _sdk_get_access_token, -) -from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser -from mcp.server.auth.provider import ( - AccessToken as _SDKAccessToken, -) -from mcp.server.lowlevel.server import request_ctx -from starlette.requests import Request -from uncalled_for import Dependency, get_dependency_parameters -from uncalled_for.resolution import _Depends - -from fastmcp.exceptions import FastMCPError -from fastmcp.server.auth import AccessToken -from fastmcp.server.http import _current_http_request -from fastmcp.utilities.async_utils import ( - call_sync_fn_in_threadpool, - is_coroutine_function, -) -from fastmcp.utilities.types import find_kwarg_by_type, is_class_member_of_type - -_logger = logging.getLogger(__name__) - -if TYPE_CHECKING: - from docket import Docket - from docket.worker import Worker - from mcp.server.session import ServerSession - - from fastmcp.server.context import Context - from fastmcp.server.server import FastMCP - - -__all__ = [ - "AccessToken", - "CurrentAccessToken", - "CurrentContext", - "CurrentDocket", - "CurrentFastMCP", - "CurrentHeaders", - "CurrentRequest", - "CurrentWorker", - "Progress", - "TaskContextInfo", - "TokenClaim", - "get_access_token", - "get_context", - "get_http_headers", - "get_http_request", - "get_server", - "get_task_context", - "get_task_session", - "is_docket_available", - "register_task_server", - "register_task_session", - "require_docket", - "resolve_dependencies", - "transform_context_annotations", - "without_injected_parameters", -] - - -# --- TaskContextInfo and get_task_context --- - - -@dataclass(frozen=True, slots=True) -class TaskContextInfo: - """Information about the current background task context. - - Returned by ``get_task_context()`` when running inside a Docket worker. - Contains identifiers needed to communicate with the MCP session. - """ - - task_id: str - """The MCP task ID (server-generated UUID).""" - - session_id: str - """The session ID that submitted this task.""" - - -def get_task_context() -> TaskContextInfo | None: - """Get the current task context if running inside a background task worker. - - This function extracts task information from the Docket execution context. - Returns None if not running in a task context (e.g., foreground execution). - - Returns: - TaskContextInfo with task_id and session_id, or None if not in a task. - """ - if not is_docket_available(): - return None - - from docket.dependencies import current_execution - - try: - execution = current_execution.get() - # Parse the task key: {session_id}:{task_id}:{task_type}:{component} - from fastmcp.server.tasks.keys import parse_task_key - - key_parts = parse_task_key(execution.key) - return TaskContextInfo( - task_id=key_parts["client_task_id"], - session_id=key_parts["session_id"], - ) - except LookupError: - # Not in worker context - return None - except (ValueError, KeyError): - # Invalid task key format - return None - - -# --- Session registry for background task Context --- - - -_task_sessions: dict[str, weakref.ref[ServerSession]] = {} - - -def register_task_session(session_id: str, session: ServerSession) -> None: - """Register a session for Context access in background tasks. - - Called automatically when a task is submitted to Docket. The session is - stored as a weakref so it doesn't prevent garbage collection when the - client disconnects. - - Args: - session_id: The session identifier - session: The ServerSession instance - """ - _task_sessions[session_id] = weakref.ref(session) - - -def get_task_session(session_id: str) -> ServerSession | None: - """Get a registered session by ID if still alive. - - Args: - session_id: The session identifier - - Returns: - The ServerSession if found and alive, None otherwise - """ - ref = _task_sessions.get(session_id) - if ref is None: - return None - session = ref() - if session is None: - # Session was garbage collected, clean up entry - _task_sessions.pop(session_id, None) - return session - - -# --- ContextVars --- - -_current_server: ContextVar[weakref.ref[FastMCP] | None] = ContextVar( - "server", default=None -) - -# --- Background task server map --- -# Maps task_id → server weakref so background workers can resolve the correct -# server for mounted-child tasks. Follows the same pattern as _task_sessions. -# Populated in submit_to_docket() where the child server is in context; -# consulted in get_server() when running inside a Docket worker. - -_task_server_map: OrderedDict[str, weakref.ref[FastMCP]] = OrderedDict() -_TASK_SERVER_MAP_MAX_SIZE = 10_000 - - -def register_task_server(task_id: str, server: FastMCP) -> None: - """Register the server for a background task. - - Called at task-submission time (inside the child server's call_tool - context) so that background workers can resolve CurrentFastMCP() and - ctx.fastmcp to the child server for mounted tasks. - - The map is bounded to avoid unbounded growth in long-lived servers. - Evicted entries fall back to the ContextVar (parent server). - """ - _task_server_map[task_id] = weakref.ref(server) - while len(_task_server_map) > _TASK_SERVER_MAP_MAX_SIZE: - _task_server_map.popitem(last=False) - - -_current_docket: ContextVar[Docket | None] = ContextVar("docket", default=None) -_current_worker: ContextVar[Worker | None] = ContextVar("worker", default=None) -_task_access_token: ContextVar[AccessToken | None] = ContextVar( - "task_access_token", default=None -) -_task_http_headers: ContextVar[dict[str, str] | None] = ContextVar( - "task_http_headers", default=None -) - - -# --- Docket availability check --- - -_DOCKET_AVAILABLE: bool | None = None - - -def is_docket_available() -> bool: - """Check if pydocket is installed.""" - global _DOCKET_AVAILABLE - if _DOCKET_AVAILABLE is None: - try: - import docket # noqa: F401 - - _DOCKET_AVAILABLE = True - except ImportError: - _DOCKET_AVAILABLE = False - return _DOCKET_AVAILABLE - - -def require_docket(feature: str) -> None: - """Raise ImportError with install instructions if docket not available. - - Args: - feature: Description of what requires docket (e.g., "`task=True`", - "CurrentDocket()"). Will be included in the error message. - """ - if not is_docket_available(): - raise ImportError( - f"FastMCP background tasks require the `tasks` extra. " - f"Install with: pip install 'fastmcp[tasks]'. " - f"(Triggered by {feature})" - ) - - -# Import Progress separately — it's docket-specific, not part of uncalled-for -try: - from docket.dependencies import Progress as DocketProgress -except ImportError: - DocketProgress = None # type: ignore[assignment] # ty:ignore[invalid-assignment] - - -# --- Context utilities --- - - -def transform_context_annotations(fn: Callable[..., Any]) -> Callable[..., Any]: - """Transform ctx: Context into ctx: Context = CurrentContext(). - - Transforms ALL params typed as Context to use Docket's DI system, - unless they already have a Dependency-based default (like CurrentContext()). - - This unifies the legacy type annotation DI with Docket's Depends() system, - allowing both patterns to work through a single resolution path. - - Note: Only POSITIONAL_OR_KEYWORD parameters are reordered (params with defaults - after those without). KEYWORD_ONLY parameters keep their position since Python - allows them to have defaults in any order. - - Args: - fn: Function to transform - - Returns: - Function with modified signature (same function object, updated __signature__) - """ - from fastmcp.server.context import Context - - # Get the function's signature - try: - sig = inspect.signature(fn) - except (ValueError, TypeError): - return fn - - # Get type hints for accurate type checking - try: - type_hints = get_type_hints(fn, include_extras=True) - except Exception: - type_hints = getattr(fn, "__annotations__", {}) - - # First pass: identify which params need transformation - params_to_transform: set[str] = set() - optional_context_params: set[str] = set() - for name, param in sig.parameters.items(): - annotation = type_hints.get(name, param.annotation) - if is_class_member_of_type(annotation, Context): - if not isinstance(param.default, Dependency): - params_to_transform.add(name) - if param.default is None: - optional_context_params.add(name) - - if not params_to_transform: - return fn - - # Second pass: build new param list preserving parameter kind structure - # Python signature structure: [POSITIONAL_ONLY] / [POSITIONAL_OR_KEYWORD] *args [KEYWORD_ONLY] **kwargs - # Within POSITIONAL_ONLY and POSITIONAL_OR_KEYWORD: params without defaults must come first - # KEYWORD_ONLY params can have defaults in any order - P = inspect.Parameter - - # Group params by section, preserving order within each - positional_only_no_default: list[P] = [] - positional_only_with_default: list[P] = [] - positional_or_keyword_no_default: list[P] = [] - positional_or_keyword_with_default: list[P] = [] - var_positional: list[P] = [] # *args (at most one) - keyword_only: list[P] = [] # After * or *args, order preserved - var_keyword: list[P] = [] # **kwargs (at most one) - - for name, param in sig.parameters.items(): - # Transform Context params by adding CurrentContext default - if name in params_to_transform: - # We use CurrentContext() instead of Depends(get_context) because - # get_context() returns the Context which is an AsyncContextManager, - # and the DI system would try to enter it again (it's already entered) - if name in optional_context_params: - param = param.replace(default=OptionalCurrentContext()) - else: - param = param.replace(default=CurrentContext()) - - # Sort into buckets based on parameter kind - if param.kind == P.POSITIONAL_ONLY: - if param.default is P.empty: - positional_only_no_default.append(param) - else: - positional_only_with_default.append(param) - elif param.kind == P.POSITIONAL_OR_KEYWORD: - if param.default is P.empty: - positional_or_keyword_no_default.append(param) - else: - positional_or_keyword_with_default.append(param) - elif param.kind == P.VAR_POSITIONAL: - var_positional.append(param) - elif param.kind == P.KEYWORD_ONLY: - keyword_only.append(param) - elif param.kind == P.VAR_KEYWORD: - var_keyword.append(param) - - # Reconstruct parameter list maintaining Python's required structure - new_params: list[P] = ( - positional_only_no_default - + positional_only_with_default - + positional_or_keyword_no_default - + positional_or_keyword_with_default - + var_positional - + keyword_only - + var_keyword - ) - - # Update function's signature in place - # Handle methods by setting signature on the underlying function - # For bound methods, we need to preserve the 'self' parameter because - # inspect.signature(bound_method) automatically removes the first param - if inspect.ismethod(fn): - # Get the original __func__ signature which includes 'self' - func_sig = inspect.signature(fn.__func__) - # Insert 'self' at the beginning of our new params - self_param = next(iter(func_sig.parameters.values())) # Should be 'self' - new_sig = func_sig.replace(parameters=[self_param, *new_params]) - fn.__func__.__signature__ = new_sig # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - else: - new_sig = sig.replace(parameters=new_params) - fn.__signature__ = new_sig # type: ignore[attr-defined] # ty:ignore[invalid-assignment] - - # Clear caches that may have cached the old signature - # This ensures get_dependency_parameters and without_injected_parameters - # see the transformed signature - _clear_signature_caches(fn) - - return fn - - -def _clear_signature_caches(fn: Callable[..., Any]) -> None: - """Clear signature-related caches for a function. - - Called after modifying a function's signature to ensure downstream - code sees the updated signature. - """ - from uncalled_for.introspection import _parameter_cache, _signature_cache - - _signature_cache.pop(fn, None) - _parameter_cache.pop(fn, None) - - if inspect.ismethod(fn): - _signature_cache.pop(fn.__func__, None) - _parameter_cache.pop(fn.__func__, None) - - -def get_context() -> Context: - """Get the current FastMCP Context instance directly.""" - from fastmcp.server.context import _current_context - - context = _current_context.get() - if context is None: - raise RuntimeError("No active context found.") - return context - - -def get_server() -> FastMCP: - """Get the current FastMCP server instance directly. - - In a background-task worker, checks the task-server map first so that - mounted-child tasks resolve to the child server (not the parent that - started the worker). - - Returns: - The active FastMCP server - - Raises: - RuntimeError: If no server in context - """ - # In a task context, prefer the task-specific server mapping. - # This handles mounted-child tasks where _current_server is the parent. - task_info = get_task_context() - if task_info is not None: - ref = _task_server_map.get(task_info.task_id) - if ref is not None: - server = ref() - if server is not None: - return server - # Server was garbage collected, clean up - _task_server_map.pop(task_info.task_id, None) - - server_ref = _current_server.get() - if server_ref is None: - raise RuntimeError("No FastMCP server instance in context") - server = server_ref() - if server is None: - raise RuntimeError("FastMCP server instance is no longer available") - return server - - -def get_http_request() -> Request: - """Get the current HTTP request. - - Tries MCP SDK's request_ctx first, then falls back to FastMCP's HTTP context. - In background tasks, returns a synthetic request populated with the - snapshotted headers from the originating HTTP request. - """ - # Try MCP SDK's request_ctx first (set during normal MCP request handling) - request = None - with contextlib.suppress(LookupError): - request = request_ctx.get().request - - # Fallback to FastMCP's HTTP context variable - # This is needed during `on_initialize` middleware where request_ctx isn't set yet - if request is None: - request = _current_http_request.get() - - # In Docket workers, restore a minimal request from the snapshotted headers. - if request is None: - task_headers = _task_http_headers.get() - if task_headers: - request = Request( - { - "type": "http", - "http_version": "1.1", - "method": "POST", - "scheme": "http", - "path": "/", - "raw_path": b"/", - "query_string": b"", - "headers": [ - (name.encode("latin-1"), value.encode("latin-1")) - for name, value in task_headers.items() - ], - "client": None, - "server": None, - "root_path": "", - } - ) - - if request is None: - raise RuntimeError("No active HTTP request found.") - return request - - -def get_http_headers( - include_all: bool = False, - include: set[str] | None = None, -) -> dict[str, str]: - """Extract headers from the current HTTP request if available. - - Never raises an exception, even if there is no active HTTP request (in which case - an empty dict is returned). - - By default, strips problematic headers like `content-length` and `authorization` - that cause issues if forwarded to downstream services. If `include_all` is True, - all headers are returned. - - The `include` parameter allows specific headers to be included even if they would - normally be excluded. This is useful for proxy transports that need to forward - authorization headers to upstream MCP servers. - """ - if include_all: - exclude_headers: set[str] = set() - else: - exclude_headers = { - "host", - "content-length", - "content-type", - "connection", - "transfer-encoding", - "upgrade", - "te", - "keep-alive", - "expect", - "accept", - "authorization", - # Proxy-related headers - "proxy-authenticate", - "proxy-authorization", - "proxy-connection", - # MCP-related headers - "mcp-session-id", - } - if include: - exclude_headers -= {h.lower() for h in include} - # (just in case) - if not all(h.lower() == h for h in exclude_headers): - raise ValueError("Excluded headers must be lowercase") - headers: dict[str, str] = {} - - try: - request = get_http_request() - for name, value in request.headers.items(): - lower_name = name.lower() - if lower_name not in exclude_headers: - headers[lower_name] = str(value) - return headers - except RuntimeError: - return {} - - -def get_access_token() -> AccessToken | None: - """Get the FastMCP access token from the current context. - - This function first tries to get the token from the current HTTP request's scope, - which is more reliable for long-lived connections where the SDK's auth_context_var - may become stale after token refresh. Falls back to the SDK's context var if no - request is available. In background tasks (Docket workers), falls back to the - token snapshot stored in Redis at task submission time. - - Returns: - The access token if an authenticated user is available, None otherwise. - """ - access_token: _SDKAccessToken | None = None - - # First, try to get from current HTTP request's scope (issue #1863) - # This is more reliable than auth_context_var for Streamable HTTP sessions - # where tokens may be refreshed between MCP messages - try: - request = get_http_request() - user = request.scope.get("user") - if isinstance(user, AuthenticatedUser): - access_token = user.access_token - except RuntimeError: - # No HTTP request available, fall back to context var - pass - - # Fall back to SDK's context var if we didn't get a token from the request - if access_token is None: - access_token = _sdk_get_access_token() - - # Fall back to background task snapshot (#3095) - # In Docket workers, neither HTTP request nor SDK context var are available. - # The token was snapshotted in Redis at submit_to_docket() time and restored - # into this ContextVar by _CurrentContext.__aenter__(). - if access_token is None: - task_token = _task_access_token.get() - if task_token is not None: - # Check expiration: if expires_at is set and past, treat as expired - if task_token.expires_at is not None: - if task_token.expires_at < int(datetime.now(timezone.utc).timestamp()): - return None - return task_token - - if access_token is None or isinstance(access_token, AccessToken): - return access_token - - # If the object is not a FastMCP AccessToken, convert it to one if the - # fields are compatible (e.g. `claims` is not present in the SDK's AccessToken). - # This is a workaround for the case where the SDK or auth provider returns a different type - # If it fails, it will raise a TypeError - try: - access_token_as_dict = access_token.model_dump() - return AccessToken( - token=access_token_as_dict["token"], - client_id=access_token_as_dict["client_id"], - scopes=access_token_as_dict["scopes"], - # Optional fields - expires_at=access_token_as_dict.get("expires_at"), - resource=access_token_as_dict.get("resource"), - claims=access_token_as_dict.get("claims") or {}, - ) - except Exception as e: - raise TypeError( - f"Expected fastmcp.server.auth.auth.AccessToken, got {type(access_token).__name__}. " - "Ensure the SDK is using the correct AccessToken type." - ) from e - - -# --- Schema generation helper --- - - -@lru_cache(maxsize=5000) -def without_injected_parameters(fn: Callable[..., Any]) -> Callable[..., Any]: - """Create a wrapper function without injected parameters. - - Returns a wrapper that excludes Context and Docket dependency parameters, - making it safe to use with Pydantic TypeAdapter for schema generation and - validation. The wrapper internally handles all dependency resolution and - Context injection when called. - - Handles: - - Legacy Context injection (always works) - - Depends() injection (always works - uses docket or vendored DI engine) - - Args: - fn: Original function with Context and/or dependencies - - Returns: - Async wrapper function without injected parameters - """ - from fastmcp.server.context import Context - - # Identify parameters to exclude - context_kwarg = find_kwarg_by_type(fn, Context) - dependency_params = get_dependency_parameters(fn) - - exclude = set() - if context_kwarg: - exclude.add(context_kwarg) - if dependency_params: - exclude.update(dependency_params.keys()) - - if not exclude: - return fn - - # Build new signature with only user parameters - sig = inspect.signature(fn) - user_params = [ - param for name, param in sig.parameters.items() if name not in exclude - ] - new_sig = inspect.Signature(user_params) - - # Create async wrapper that handles dependency resolution - fn_is_async = is_coroutine_function(fn) - - async def wrapper(**user_kwargs: Any) -> Any: - async with resolve_dependencies(fn, user_kwargs) as resolved_kwargs: - if fn_is_async: - return await fn(**resolved_kwargs) - else: - # Run sync functions in threadpool to avoid blocking the event loop - result = await call_sync_fn_in_threadpool(fn, **resolved_kwargs) - # Handle sync wrappers that return awaitables (e.g., partial(async_fn)) - if inspect.isawaitable(result): - result = await result - return result - - # Resolve string annotations (from `from __future__ import annotations`) using - # the original function's module context. The wrapper's __globals__ points to - # this module (dependencies.py) and is read-only, so some Pydantic versions - # can't resolve names like Annotated or Literal from string annotations. - try: - resolved_hints = get_type_hints(fn, include_extras=True) - except Exception: - resolved_hints = getattr(fn, "__annotations__", {}) - - wrapper.__signature__ = new_sig # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - wrapper.__annotations__ = { - k: v for k, v in resolved_hints.items() if k not in exclude and k != "return" - } - wrapper.__name__ = getattr(fn, "__name__", "wrapper") - wrapper.__doc__ = getattr(fn, "__doc__", None) - wrapper.__module__ = fn.__module__ - wrapper.__qualname__ = getattr(fn, "__qualname__", wrapper.__qualname__) - - return wrapper - - -# --- Dependency resolution --- - - -@asynccontextmanager -async def _resolve_fastmcp_dependencies( - fn: Callable[..., Any], arguments: dict[str, Any] -) -> AsyncGenerator[dict[str, Any], None]: - """Resolve Docket dependencies for a FastMCP function. - - Sets up the minimal context needed for Docket's Depends() to work: - - A cache for resolved dependencies - - An AsyncExitStack for managing context manager lifetimes - - The Docket instance (for CurrentDocket dependency) is managed separately - by the server's lifespan and made available via ContextVar. - - Note: This does NOT set up Docket's Execution context. If user code needs - Docket-specific dependencies like TaskArgument(), TaskKey(), etc., those - will fail with clear errors about missing context. - - Args: - fn: The function to resolve dependencies for - arguments: The arguments passed to the function - - Yields: - Dictionary of resolved dependencies merged with provided arguments - """ - dependency_params = get_dependency_parameters(fn) - - if not dependency_params: - yield arguments - return - - # Initialize dependency cache and exit stack - cache_token = _Depends.cache.set({}) - try: - async with AsyncExitStack() as stack: - stack_token = _Depends.stack.set(stack) - try: - resolved: dict[str, Any] = {} - - for parameter, dependency in dependency_params.items(): - # If argument was explicitly provided, use that instead - if parameter in arguments: - resolved[parameter] = arguments[parameter] - continue - - # Resolve the dependency - try: - resolved[parameter] = await stack.enter_async_context( - dependency - ) - except FastMCPError: - # Let FastMCPError subclasses (ToolError, ResourceError, etc.) - # propagate unchanged so they can be handled appropriately - raise - except Exception as error: - fn_name = getattr(fn, "__name__", repr(fn)) - raise RuntimeError( - f"Failed to resolve dependency '{parameter}' for {fn_name}" - ) from error - - # Merge resolved dependencies with provided arguments - final_arguments = {**arguments, **resolved} - - yield final_arguments - finally: - _Depends.stack.reset(stack_token) - finally: - _Depends.cache.reset(cache_token) - - -@asynccontextmanager -async def resolve_dependencies( - fn: Callable[..., Any], arguments: dict[str, Any] -) -> AsyncGenerator[dict[str, Any], None]: - """Resolve dependencies for a FastMCP function. - - This function: - 1. Filters out any dependency parameter names from user arguments (security) - 2. Resolves Depends() parameters via the DI system - - The filtering prevents external callers from overriding injected parameters by - providing values for dependency parameter names. This is a security feature. - - Note: Context injection is handled via transform_context_annotations() which - converts `ctx: Context` to `ctx: Context = Depends(get_context)` at registration - time, so all injection goes through the unified DI system. - - Args: - fn: The function to resolve dependencies for - arguments: User arguments (may contain keys that match dependency names, - which will be filtered out) - - Yields: - Dictionary of filtered user args + resolved dependencies - - Example: - ```python - async with resolve_dependencies(my_tool, {"name": "Alice"}) as kwargs: - result = my_tool(**kwargs) - if inspect.isawaitable(result): - result = await result - ``` - """ - # Filter out dependency parameters from user arguments to prevent override - # This is a security measure - external callers should never be able to - # provide values for injected parameters - dependency_params = get_dependency_parameters(fn) - user_args = {k: v for k, v in arguments.items() if k not in dependency_params} - - async with _resolve_fastmcp_dependencies(fn, user_args) as resolved_kwargs: - yield resolved_kwargs - - -# --- Dependency classes --- -# These must inherit from docket.dependencies.Dependency when docket is available -# so that get_dependency_parameters can detect them. - - -async def _restore_task_access_token( - session_id: str, task_id: str -) -> Token[AccessToken | None] | None: - """Restore the access token snapshot from Redis into a ContextVar. - - Called when setting up context in a Docket worker. The token was stored at - submit_to_docket() time. The token is restored regardless of expiration; - get_access_token() checks expiry when reading from the ContextVar. - - Returns: - The ContextVar token for resetting, or None if nothing was restored. - """ - docket = _current_docket.get() - if docket is None: - return None - - token_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:access_token") - try: - async with docket.redis() as redis: - token_data = await redis.get(token_key) - if token_data is not None: - restored = AccessToken.model_validate_json(token_data) - return _task_access_token.set(restored) - except Exception: - _logger.warning( - "Failed to restore access token for task %s:%s", - session_id, - task_id, - exc_info=True, - ) - return None - - -async def _restore_task_http_headers( - session_id: str, task_id: str -) -> Token[dict[str, str] | None] | None: - """Restore the HTTP header snapshot from Redis into a ContextVar.""" - docket = _current_docket.get() - if docket is None: - return None - - headers_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:http_headers") - try: - async with docket.redis() as redis: - headers_data = await redis.get(headers_key) - if headers_data is None: - return None - if isinstance(headers_data, bytes): - headers_data = headers_data.decode() - restored = json.loads(str(headers_data)) - if not isinstance(restored, dict): - return None - return _task_http_headers.set( - {str(name).lower(): str(value) for name, value in restored.items()} - ) - except Exception: - _logger.warning( - "Failed to restore HTTP headers for task %s:%s", - session_id, - task_id, - exc_info=True, - ) - return None - - -async def _restore_task_origin_request_id(session_id: str, task_id: str) -> str | None: - """Restore the origin request ID snapshot for a background task. - - Returns None if no request ID was captured at submission time. - """ - docket = _current_docket.get() - if docket is None: - return None - - request_id_key = docket.key( - f"fastmcp:task:{session_id}:{task_id}:origin_request_id" - ) - try: - async with docket.redis() as redis: - request_id_data = await redis.get(request_id_key) - if request_id_data is None: - return None - if isinstance(request_id_data, bytes): - return request_id_data.decode() - return str(request_id_data) - except Exception: - _logger.warning( - "Failed to restore origin request ID for task %s:%s", - session_id, - task_id, - exc_info=True, - ) - return None - - -class _CurrentContext(Dependency["Context"]): - """Async context manager for Context dependency. - - In foreground (request) mode: returns the active context from _current_context. - In background (Docket worker) mode: creates a task-aware Context with task_id - and restores the access token snapshot from Redis. - """ - - _context: Context | None = None - _access_token_cv_token: Token[AccessToken | None] | None = None - _http_headers_cv_token: Token[dict[str, str] | None] | None = None - - async def __aenter__(self) -> Context: - from fastmcp.server.context import Context, _current_context - - # Try foreground context first (normal MCP request) - context = _current_context.get() - if context is not None: - return context - - # Check if we're in a Docket worker context - task_info = get_task_context() - if task_info is not None: - # Get session from registry (registered when task was submitted) - session = get_task_session(task_info.session_id) - # Get server from ContextVar - server = get_server() - origin_request_id = await _restore_task_origin_request_id( - task_info.session_id, task_info.task_id - ) - # Create task-aware Context - self._context = Context( - fastmcp=server, - session=session, - task_id=task_info.task_id, - origin_request_id=origin_request_id, - ) - # Enter the context to set up ContextVars - await self._context.__aenter__() - - # Restore access token snapshot from Redis (#3095) - self._access_token_cv_token = await _restore_task_access_token( - task_info.session_id, task_info.task_id - ) - - # Restore HTTP headers snapshot from Redis (#3631) - self._http_headers_cv_token = await _restore_task_http_headers( - task_info.session_id, task_info.task_id - ) - - return self._context - - # Neither foreground nor background context available - raise RuntimeError( - "No active context found. This can happen if:\n" - " - Called outside an MCP request handler\n" - " - Called in a background task before session was registered\n" - "Check `context.request_context` for None before accessing." - ) - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - # Clean up access token ContextVar - if self._access_token_cv_token is not None: - _task_access_token.reset(self._access_token_cv_token) - self._access_token_cv_token = None - # Clean up HTTP headers ContextVar - if self._http_headers_cv_token is not None: - _task_http_headers.reset(self._http_headers_cv_token) - self._http_headers_cv_token = None - # Clean up if we created a context for background task - if self._context is not None: - await self._context.__aexit__(exc_type, exc_value, traceback) - self._context = None - - -class _OptionalCurrentContext(Dependency["Context | None"]): - """Context dependency that degrades to None when no context is active. - - This is implemented as a wrapper (composition), not a subclass of - `_CurrentContext`, to avoid overriding `__aenter__` with an incompatible - return type. - """ - - _inner: _CurrentContext | None = None - - async def __aenter__(self) -> Context | None: - inner = _CurrentContext() - try: - context = await inner.__aenter__() - except RuntimeError as exc: - if "No active context found" in str(exc): - return None - raise - self._inner = inner - return context - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - if self._inner is None: - return - await self._inner.__aexit__(exc_type, exc_value, traceback) - self._inner = None - - -def CurrentContext() -> Context: - """Get the current FastMCP Context instance. - - This dependency provides access to the active FastMCP Context for the - current MCP operation (tool/resource/prompt call). - - Returns: - A dependency that resolves to the active Context instance - - Raises: - RuntimeError: If no active context found (during resolution) - - Example: - ```python - from fastmcp.dependencies import CurrentContext - - @mcp.tool() - async def log_progress(ctx: Context = CurrentContext()) -> str: - ctx.report_progress(50, 100, "Halfway done") - return "Working" - ``` - """ - return cast("Context", _CurrentContext()) - - -def OptionalCurrentContext() -> Context | None: - """Get the current FastMCP Context, or None when no context is active.""" - return cast("Context | None", _OptionalCurrentContext()) - - -class _CurrentDocket(Dependency["Docket"]): - """Async context manager for Docket dependency.""" - - async def __aenter__(self) -> Docket: - require_docket("CurrentDocket()") - docket = _current_docket.get() - if docket is None: - raise RuntimeError( - "No Docket instance found. Docket is only initialized when there are " - "task-enabled components (task=True). Add task=True to a component " - "to enable Docket infrastructure." - ) - return docket - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - pass - - -def CurrentDocket() -> Docket: - """Get the current Docket instance managed by FastMCP. - - This dependency provides access to the Docket instance that FastMCP - automatically creates for background task scheduling. - - Returns: - A dependency that resolves to the active Docket instance - - Raises: - RuntimeError: If not within a FastMCP server context - ImportError: If fastmcp[tasks] not installed - - Example: - ```python - from fastmcp.dependencies import CurrentDocket - - @mcp.tool() - async def schedule_task(docket: Docket = CurrentDocket()) -> str: - await docket.add(some_function)(arg1, arg2) - return "Scheduled" - ``` - """ - require_docket("CurrentDocket()") - return cast("Docket", _CurrentDocket()) - - -class _CurrentWorker(Dependency["Worker"]): - """Async context manager for Worker dependency.""" - - async def __aenter__(self) -> Worker: - require_docket("CurrentWorker()") - worker = _current_worker.get() - if worker is None: - raise RuntimeError( - "No Worker instance found. Worker is only initialized when there are " - "task-enabled components (task=True). Add task=True to a component " - "to enable Docket infrastructure." - ) - return worker - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - pass - - -def CurrentWorker() -> Worker: - """Get the current Docket Worker instance managed by FastMCP. - - This dependency provides access to the Worker instance that FastMCP - automatically creates for background task processing. - - Returns: - A dependency that resolves to the active Worker instance - - Raises: - RuntimeError: If not within a FastMCP server context - ImportError: If fastmcp[tasks] not installed - - Example: - ```python - from fastmcp.dependencies import CurrentWorker - - @mcp.tool() - async def check_worker_status(worker: Worker = CurrentWorker()) -> str: - return f"Worker: {worker.name}" - ``` - """ - require_docket("CurrentWorker()") - return cast("Worker", _CurrentWorker()) - - -class _CurrentFastMCP(Dependency["FastMCP"]): - """Async context manager for FastMCP server dependency.""" - - async def __aenter__(self) -> FastMCP: - return get_server() - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - pass - - -def CurrentFastMCP() -> FastMCP: - """Get the current FastMCP server instance. - - This dependency provides access to the active FastMCP server. - - Returns: - A dependency that resolves to the active FastMCP server - - Raises: - RuntimeError: If no server in context (during resolution) - - Example: - ```python - from fastmcp.dependencies import CurrentFastMCP - - @mcp.tool() - async def introspect(server: FastMCP = CurrentFastMCP()) -> str: - return f"Server: {server.name}" - ``` - """ - from fastmcp.server.server import FastMCP - - return cast(FastMCP, _CurrentFastMCP()) - - -class _CurrentRequest(Dependency[Request]): - """Async context manager for HTTP Request dependency.""" - - _task_http_headers_cv_token: Token[dict[str, str] | None] | None = None - - async def __aenter__(self) -> Request: - try: - return get_http_request() - except RuntimeError: - task_info = get_task_context() - if task_info is None: - raise - if _task_http_headers.get() is None: - self._task_http_headers_cv_token = await _restore_task_http_headers( - task_info.session_id, task_info.task_id - ) - return get_http_request() - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - if self._task_http_headers_cv_token is not None: - _task_http_headers.reset(self._task_http_headers_cv_token) - self._task_http_headers_cv_token = None - - -def CurrentRequest() -> Request: - """Get the current HTTP request. - - This dependency provides access to the Starlette Request object for the - current HTTP request. Only available when running over HTTP transports - (SSE or Streamable HTTP). - - Returns: - A dependency that resolves to the active Starlette Request - - Raises: - RuntimeError: If no HTTP request in context (e.g., STDIO transport) - - Example: - ```python - from fastmcp.server.dependencies import CurrentRequest - from starlette.requests import Request - - @mcp.tool() - async def get_client_ip(request: Request = CurrentRequest()) -> str: - return request.client.host if request.client else "Unknown" - ``` - """ - return cast(Request, _CurrentRequest()) - - -class _CurrentHeaders(Dependency[dict[str, str]]): - """Async context manager for HTTP Headers dependency.""" - - _task_http_headers_cv_token: Token[dict[str, str] | None] | None = None - - async def __aenter__(self) -> dict[str, str]: - if _task_http_headers.get() is None: - task_info = get_task_context() - if task_info is not None: - self._task_http_headers_cv_token = await _restore_task_http_headers( - task_info.session_id, task_info.task_id - ) - return get_http_headers(include={"authorization"}) - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - if self._task_http_headers_cv_token is not None: - _task_http_headers.reset(self._task_http_headers_cv_token) - self._task_http_headers_cv_token = None - - -def CurrentHeaders() -> dict[str, str]: - """Get the current HTTP request headers. - - This dependency provides access to the HTTP headers for the current request, - including the authorization header. Returns an empty dictionary when no HTTP - request is available, making it safe to use in code that might run over any - transport. - - Returns: - A dependency that resolves to a dictionary of header name -> value - - Example: - ```python - from fastmcp.server.dependencies import CurrentHeaders - - @mcp.tool() - async def get_auth_type(headers: dict = CurrentHeaders()) -> str: - auth = headers.get("authorization", "") - return "Bearer" if auth.startswith("Bearer ") else "None" - ``` - """ - return cast(dict[str, str], _CurrentHeaders()) - - -# --- Progress dependency --- - - -@runtime_checkable -class ProgressLike(Protocol): - """Protocol for progress tracking interface. - - Defines the common interface between InMemoryProgress (server context) - and Docket's Progress (worker context). - """ - - @property - def current(self) -> int | None: - """Current progress value.""" - ... - - @property - def total(self) -> int: - """Total/target progress value.""" - ... - - @property - def message(self) -> str | None: - """Current progress message.""" - ... - - async def set_total(self, total: int) -> None: - """Set the total/target value for progress tracking.""" - ... - - async def increment(self, amount: int = 1) -> None: - """Atomically increment the current progress value.""" - ... - - async def set_message(self, message: str | None) -> None: - """Update the progress status message.""" - ... - - -class InMemoryProgress: - """In-memory progress tracker for immediate tool execution. - - Provides the same interface as Docket's Progress but stores state in memory - instead of Redis. Useful for testing and immediate execution where - progress doesn't need to be observable across processes. - """ - - def __init__(self) -> None: - self._current: int | None = None - self._total: int = 1 - self._message: str | None = None - - async def __aenter__(self) -> InMemoryProgress: - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - pass - - @property - def current(self) -> int | None: - return self._current - - @property - def total(self) -> int: - return self._total - - @property - def message(self) -> str | None: - return self._message - - async def set_total(self, total: int) -> None: - """Set the total/target value for progress tracking.""" - if total < 1: - raise ValueError("Total must be at least 1") - self._total = total - - async def increment(self, amount: int = 1) -> None: - """Atomically increment the current progress value.""" - if amount < 1: - raise ValueError("Amount must be at least 1") - if self._current is None: - self._current = amount - else: - self._current += amount - - async def set_message(self, message: str | None) -> None: - """Update the progress status message.""" - self._message = message - - -class Progress(Dependency["Progress"]): - """FastMCP Progress dependency that works in both server and worker contexts. - - Handles three execution modes: - - In Docket worker: Uses the execution's progress (observable via Redis) - - In FastMCP server with Docket: Falls back to in-memory progress - - In FastMCP server without Docket: Uses in-memory progress - - This allows tools to use Progress() regardless of whether they're called - immediately or as background tasks, and regardless of whether pydocket - is installed. - """ - - _impl: ProgressLike | None = None - - async def __aenter__(self) -> Progress: - server_ref = _current_server.get() - if server_ref is None or server_ref() is None: - raise RuntimeError("Progress dependency requires a FastMCP server context.") - - if is_docket_available(): - from docket.dependencies import Progress as DocketProgress - - try: - docket_progress = DocketProgress() - self._impl = await docket_progress.__aenter__() - return self - except LookupError: - pass - - self._impl = InMemoryProgress() - return self - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - self._impl = None - - @property - def current(self) -> int | None: - """Current progress value.""" - assert self._impl is not None, "Progress must be used as a dependency" - return self._impl.current - - @property - def total(self) -> int: - """Total/target progress value.""" - assert self._impl is not None, "Progress must be used as a dependency" - return self._impl.total - - @property - def message(self) -> str | None: - """Current progress message.""" - assert self._impl is not None, "Progress must be used as a dependency" - return self._impl.message - - async def set_total(self, total: int) -> None: - """Set the total/target value for progress tracking.""" - assert self._impl is not None, "Progress must be used as a dependency" - await self._impl.set_total(total) - - async def increment(self, amount: int = 1) -> None: - """Atomically increment the current progress value.""" - assert self._impl is not None, "Progress must be used as a dependency" - await self._impl.increment(amount) - - async def set_message(self, message: str | None) -> None: - """Update the progress status message.""" - assert self._impl is not None, "Progress must be used as a dependency" - await self._impl.set_message(message) - - -# --- Access Token dependency --- - - -class _CurrentAccessToken(Dependency[AccessToken]): - """Async context manager for AccessToken dependency.""" - - _access_token_cv_token: Token[AccessToken | None] | None = None - - async def __aenter__(self) -> AccessToken: - token = get_access_token() - - # If no token found and we're in a Docket worker, try restoring from - # Redis. This handles the case where ctx: Context is not in the - # function signature, so _CurrentContext never ran the restoration. - if token is None: - task_info = get_task_context() - if task_info is not None: - self._access_token_cv_token = await _restore_task_access_token( - task_info.session_id, task_info.task_id - ) - token = get_access_token() - - if token is None: - raise RuntimeError( - "No access token found. Ensure authentication is configured " - "and the request is authenticated." - ) - return token - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - if self._access_token_cv_token is not None: - _task_access_token.reset(self._access_token_cv_token) - self._access_token_cv_token = None - - -def CurrentAccessToken() -> AccessToken: - """Get the current access token for the authenticated user. - - This dependency provides access to the AccessToken for the current - authenticated request. Raises an error if no authentication is present. - - Returns: - A dependency that resolves to the active AccessToken - - Raises: - RuntimeError: If no authenticated user (use get_access_token() for optional) - - Example: - ```python - from fastmcp.server.dependencies import CurrentAccessToken - from fastmcp.server.auth import AccessToken - - @mcp.tool() - async def get_user_id(token: AccessToken = CurrentAccessToken()) -> str: - return token.claims.get("sub", "unknown") - ``` - """ - return cast(AccessToken, _CurrentAccessToken()) - - -# --- Token Claim dependency --- - - -class _TokenClaim(Dependency[str]): - """Dependency that extracts a specific claim from the access token.""" - - def __init__(self, claim_name: str): - self.claim_name = claim_name - - async def __aenter__(self) -> str: - token = get_access_token() - if token is None: - raise RuntimeError( - f"No access token available. Cannot extract claim '{self.claim_name}'." - ) - value = token.claims.get(self.claim_name) - if value is None: - raise RuntimeError( - f"Claim '{self.claim_name}' not found in access token. " - f"Available claims: {list(token.claims.keys())}" - ) - return str(value) - - async def __aexit__( - self, - exc_type: type[BaseException] | None, - exc_value: BaseException | None, - traceback: TracebackType | None, - ) -> None: - pass - - -def TokenClaim(name: str) -> str: - """Get a specific claim from the access token. - - This dependency extracts a single claim value from the current access token. - It's useful for getting user identifiers, roles, or other token claims - without needing the full token object. - - Args: - name: The name of the claim to extract (e.g., "oid", "sub", "email") - - Returns: - A dependency that resolves to the claim value as a string - - Raises: - RuntimeError: If no access token is available or claim is missing - - Example: - ```python - from fastmcp.server.dependencies import TokenClaim - - @mcp.tool() - async def add_expense( - user_id: str = TokenClaim("oid"), # Azure object ID - amount: float, - ): - # user_id is automatically injected from the token - await db.insert({"user_id": user_id, "amount": amount}) - ``` - """ - return cast(str, _TokenClaim(name)) diff --git a/src/fastmcp/server/elicitation.py b/src/fastmcp/server/elicitation.py deleted file mode 100644 index caa53049e..000000000 --- a/src/fastmcp/server/elicitation.py +++ /dev/null @@ -1,464 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from enum import Enum -from typing import Any, Generic, Literal, cast, get_origin - -from mcp.server.elicitation import ( - CancelledElicitation, - DeclinedElicitation, -) -from pydantic import BaseModel -from pydantic.json_schema import GenerateJsonSchema, JsonSchemaValue -from pydantic_core import core_schema -from typing_extensions import TypeVar - -from fastmcp.utilities.json_schema import compress_schema -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import get_cached_typeadapter - -__all__ = [ - "AcceptedElicitation", - "CancelledElicitation", - "DeclinedElicitation", - "ElicitConfig", - "ScalarElicitationType", - "get_elicitation_schema", - "handle_elicit_accept", - "parse_elicit_response_type", -] - -logger = get_logger(__name__) - -T = TypeVar("T", default=Any) - - -class ElicitationJsonSchema(GenerateJsonSchema): - """Custom JSON schema generator for MCP elicitation that always inlines enums. - - MCP elicitation requires inline enum schemas without $ref/$defs references. - This generator ensures enums are always generated inline for compatibility. - Optionally adds enumNames for better UI display when available. - """ - - def generate_inner(self, schema: core_schema.CoreSchema) -> JsonSchemaValue: # type: ignore[override] # ty:ignore[invalid-method-override] - """Override to prevent ref generation for enums and handle list schemas.""" - # For enum schemas, bypass the ref mechanism entirely - if schema["type"] == "enum": - # Directly call our custom enum_schema without going through handler - # This prevents the ref/defs mechanism from being invoked - return self.enum_schema(schema) - # For list schemas, check if items are enums - if schema["type"] == "list": - return self.list_schema(schema) - # For all other types, use the default implementation - return super().generate_inner(schema) - - def list_schema(self, schema: core_schema.ListSchema) -> JsonSchemaValue: - """Generate schema for list types, detecting enum items for multi-select.""" - items_schema = schema.get("items_schema") - - # Check if items are enum/Literal - if items_schema and items_schema.get("type") == "enum": - # Generate array with enum items - items = self.enum_schema(items_schema) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - # If items have oneOf pattern, convert to anyOf for multi-select per SEP-1330 - if "oneOf" in items: - items = {"anyOf": items["oneOf"]} - return { - "type": "array", - "items": items, # Will be {"enum": [...]} or {"anyOf": [...]} - } - - # Check if items are Literal (which Pydantic represents differently) - if items_schema: - # Try to detect Literal patterns - items_result = super().generate_inner(items_schema) - # If it's a const pattern or enum-like, allow it - if ( - "const" in items_result - or "enum" in items_result - or "oneOf" in items_result - ): - # Convert oneOf to anyOf for multi-select - if "oneOf" in items_result: - items_result = {"anyOf": items_result["oneOf"]} - return { - "type": "array", - "items": items_result, - } - - # Default behavior for non-enum arrays - return super().list_schema(schema) - - def enum_schema(self, schema: core_schema.EnumSchema) -> JsonSchemaValue: - """Generate inline enum schema. - - Always generates enum pattern: `{"enum": [value, ...]}` - Titled enums are handled separately via dict-based syntax in ctx.elicit(). - """ - # Get the base schema from parent - always use simple enum pattern - return super().enum_schema(schema) - - -# we can't use the low-level AcceptedElicitation because it only works with BaseModels -class AcceptedElicitation(BaseModel, Generic[T]): - """Result when user accepts the elicitation.""" - - action: Literal["accept"] = "accept" - data: T - - -@dataclass -class ScalarElicitationType(Generic[T]): - value: T - - -@dataclass -class ElicitConfig: - """Configuration for an elicitation request. - - Attributes: - schema: The JSON schema to send to the client - response_type: The type to validate responses with (None for raw schemas) - is_raw: True if schema was built directly (extract "value" from response) - """ - - schema: dict[str, Any] - response_type: type | None - is_raw: bool - - -def parse_elicit_response_type(response_type: Any) -> ElicitConfig: - """Parse response_type into schema and handling configuration. - - Supports multiple syntaxes: - - None: Empty object schema, expect empty response - - dict: `{"low": {"title": "..."}}` -> single-select titled enum - - list patterns: - - `[["a", "b"]]` -> multi-select untitled - - `[{"low": {...}}]` -> multi-select titled - - `["a", "b"]` -> single-select untitled - - `list[X]` type annotation: multi-select with type - - Scalar types (bool, int, float, str, Literal, Enum): single value - - Other types (dataclass, BaseModel): use directly - """ - if response_type is None: - return ElicitConfig( - schema={"type": "object", "properties": {}}, - response_type=None, - is_raw=False, - ) - - if isinstance(response_type, dict): - return _parse_dict_syntax(response_type) - - if isinstance(response_type, list): - return _parse_list_syntax(response_type) - - if get_origin(response_type) is list: - return _parse_generic_list(response_type) - - if _is_scalar_type(response_type): - return _parse_scalar_type(response_type) - - # Other types (dataclass, BaseModel, etc.) - use directly - return ElicitConfig( - schema=get_elicitation_schema(response_type), - response_type=response_type, - is_raw=False, - ) - - -def _is_scalar_type(response_type: Any) -> bool: - """Check if response_type is a scalar type that needs wrapping.""" - return ( - response_type in {bool, int, float, str} - or get_origin(response_type) is Literal - or (isinstance(response_type, type) and issubclass(response_type, Enum)) - ) - - -def _parse_dict_syntax(d: dict[str, Any]) -> ElicitConfig: - """Parse dict syntax: {"low": {"title": "..."}} -> single-select titled.""" - if not d: - raise ValueError("Dict response_type cannot be empty.") - enum_schema = _dict_to_enum_schema(d, multi_select=False) - return ElicitConfig( - schema={ - "type": "object", - "properties": {"value": enum_schema}, - "required": ["value"], - }, - response_type=None, - is_raw=True, - ) - - -def _parse_list_syntax(lst: list[Any]) -> ElicitConfig: - """Parse list patterns: [[...]], [{...}], or [...].""" - # [["a", "b", "c"]] -> multi-select untitled - if ( - len(lst) == 1 - and isinstance(lst[0], list) - and lst[0] - and all(isinstance(item, str) for item in lst[0]) - ): - return ElicitConfig( - schema={ - "type": "object", - "properties": {"value": {"type": "array", "items": {"enum": lst[0]}}}, - "required": ["value"], - }, - response_type=None, - is_raw=True, - ) - - # [{"low": {"title": "..."}}] -> multi-select titled - if len(lst) == 1 and isinstance(lst[0], dict) and lst[0]: - enum_schema = _dict_to_enum_schema(lst[0], multi_select=True) - return ElicitConfig( - schema={ - "type": "object", - "properties": {"value": {"type": "array", "items": enum_schema}}, - "required": ["value"], - }, - response_type=None, - is_raw=True, - ) - - # ["a", "b", "c"] -> single-select untitled - if lst and all(isinstance(item, str) for item in lst): - # Construct Literal type from tuple - use cast since we can't construct Literal dynamically - # but we know the values are all strings - choice_literal: type[Any] = cast(type[Any], Literal[tuple(lst)]) # type: ignore[valid-type] # ty:ignore[invalid-type-form] - wrapped = ScalarElicitationType[choice_literal] # type: ignore[valid-type] # ty:ignore[invalid-type-form] - return ElicitConfig( - schema=get_elicitation_schema(wrapped), - response_type=wrapped, - is_raw=False, - ) - - raise ValueError(f"Invalid list response_type format. Received: {lst}") - - -def _parse_generic_list(response_type: Any) -> ElicitConfig: - """Parse list[X] type annotation -> multi-select.""" - wrapped = ScalarElicitationType[response_type] - return ElicitConfig( - schema=get_elicitation_schema(wrapped), - response_type=wrapped, - is_raw=False, - ) - - -def _parse_scalar_type(response_type: Any) -> ElicitConfig: - """Parse scalar types (bool, int, float, str, Literal, Enum).""" - wrapped = ScalarElicitationType[response_type] - return ElicitConfig( - schema=get_elicitation_schema(wrapped), - response_type=wrapped, - is_raw=False, - ) - - -def handle_elicit_accept( - config: ElicitConfig, content: Any -) -> AcceptedElicitation[Any]: - """Handle an accepted elicitation response. - - Args: - config: The elicitation configuration from parse_elicit_response_type - content: The response content from the client - - Returns: - AcceptedElicitation with the extracted/validated data - """ - # For raw schemas (dict/nested-list syntax), extract value directly - if config.is_raw: - if not isinstance(content, dict) or "value" not in content: - raise ValueError("Elicitation response missing required 'value' field.") - return AcceptedElicitation[Any](data=content["value"]) - - # For typed schemas, validate with Pydantic - if config.response_type is not None: - type_adapter = get_cached_typeadapter(config.response_type) - validated_data = type_adapter.validate_python(content) - if isinstance(validated_data, ScalarElicitationType): - return AcceptedElicitation[Any](data=validated_data.value) - return AcceptedElicitation[Any](data=validated_data) - - # For None response_type, expect empty response - if content: - raise ValueError( - f"Elicitation expected an empty response, but received: {content}" - ) - return AcceptedElicitation[dict[str, Any]](data={}) - - -def _dict_to_enum_schema( - enum_dict: dict[str, dict[str, str]], multi_select: bool = False -) -> dict[str, Any]: - """Convert dict enum to SEP-1330 compliant schema pattern. - - Args: - enum_dict: {"low": {"title": "Low Priority"}, "medium": {"title": "Medium Priority"}} - multi_select: If True, use anyOf pattern; if False, use oneOf pattern - - Returns: - {"type": "string", "oneOf": [...]} for single-select - {"anyOf": [...]} for multi-select (used as array items) - """ - pattern_key = "anyOf" if multi_select else "oneOf" - pattern = [] - for value, metadata in enum_dict.items(): - title = metadata.get("title", value) - pattern.append({"const": value, "title": title}) - - result: dict[str, Any] = {pattern_key: pattern} - if not multi_select: - result["type"] = "string" - return result - - -def get_elicitation_schema(response_type: type[T]) -> dict[str, Any]: - """Get the schema for an elicitation response. - - Args: - response_type: The type of the response - """ - - # Use custom schema generator that inlines enums for MCP compatibility - schema = get_cached_typeadapter(response_type).json_schema( - schema_generator=ElicitationJsonSchema - ) - schema = compress_schema(schema) - - # Validate the schema to ensure it follows MCP elicitation requirements - validate_elicitation_json_schema(schema) - - return schema - - -def validate_elicitation_json_schema(schema: dict[str, Any]) -> None: - """Validate that a JSON schema follows MCP elicitation requirements. - - This ensures the schema is compatible with MCP elicitation requirements: - - Must be an object schema - - Must only contain primitive field types (string, number, integer, boolean) - - Must be flat (no nested objects or arrays of objects) - - Allows const fields (for Literal types) and enum fields (for Enum types) - - Only primitive types and their nullable variants are allowed - - Args: - schema: The JSON schema to validate - - Raises: - TypeError: If the schema doesn't meet MCP elicitation requirements - """ - ALLOWED_TYPES = {"string", "number", "integer", "boolean"} - - # Check that the schema is an object - if schema.get("type") != "object": - raise TypeError( - f"Elicitation schema must be an object schema, got type '{schema.get('type')}'. " - "Elicitation schemas are limited to flat objects with primitive properties only." - ) - - properties = schema.get("properties", {}) - - for prop_name, prop_schema in properties.items(): - prop_type = prop_schema.get("type") - - # Handle nullable types - if isinstance(prop_type, list): - if "null" in prop_type: - prop_type = [t for t in prop_type if t != "null"] - if len(prop_type) == 1: - prop_type = prop_type[0] - elif prop_schema.get("nullable", False): - continue # Nullable with no other type is fine - - # Handle const fields (Literal types) - if "const" in prop_schema: - continue # const fields are allowed regardless of type - - # Handle enum fields (Enum types) - if "enum" in prop_schema: - continue # enum fields are allowed regardless of type - - # Handle references to definitions (like Enum types) - if "$ref" in prop_schema: - # Get the referenced definition - ref_path = prop_schema["$ref"] - if ref_path.startswith("#/$defs/"): - def_name = ref_path[8:] # Remove "#/$defs/" prefix - ref_def = schema.get("$defs", {}).get(def_name, {}) - # If the referenced definition has an enum, it's allowed - if "enum" in ref_def: - continue - # If the referenced definition has a type that's allowed, it's allowed - ref_type = ref_def.get("type") - if ref_type in ALLOWED_TYPES: - continue - # If we can't determine what the ref points to, reject it for safety - raise TypeError( - f"Elicitation schema field '{prop_name}' contains a reference '{ref_path}' " - "that could not be validated. Only references to enum types or primitive types are allowed." - ) - - # Handle union types (oneOf/anyOf) - if "oneOf" in prop_schema or "anyOf" in prop_schema: - union_schemas = prop_schema.get("oneOf", []) + prop_schema.get("anyOf", []) - for union_schema in union_schemas: - # Allow const and enum in unions - if "const" in union_schema or "enum" in union_schema: - continue - union_type = union_schema.get("type") - if union_type not in ALLOWED_TYPES: - raise TypeError( - f"Elicitation schema field '{prop_name}' has union type '{union_type}' which is not " - f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas." - ) - continue - - # Check for arrays before checking primitive types - if prop_type == "array": - items_schema = prop_schema.get("items", {}) - if items_schema.get("type") == "object": - raise TypeError( - f"Elicitation schema field '{prop_name}' is an array of objects, but arrays of objects are not allowed. " - "Elicitation schemas must be flat objects with primitive properties only." - ) - - # Allow arrays with enum patterns (for multi-select) - if "enum" in items_schema: - continue # Allowed: {"type": "array", "items": {"enum": [...]}} - - # Allow arrays with oneOf/anyOf const patterns (SEP-1330) - if "oneOf" in items_schema or "anyOf" in items_schema: - union_schemas = items_schema.get("oneOf", []) + items_schema.get( - "anyOf", [] - ) - if union_schemas and all("const" in s for s in union_schemas): - continue # Allowed: {"type": "array", "items": {"anyOf": [{"const": ...}, ...]}} - - # Reject other array types (e.g., arrays of primitives without enum pattern) - raise TypeError( - f"Elicitation schema field '{prop_name}' is an array, but arrays are only allowed " - "when items are enums (for multi-select). Only enum arrays are supported in elicitation schemas." - ) - - # Check for nested objects (not allowed) - if prop_type == "object": - raise TypeError( - f"Elicitation schema field '{prop_name}' is an object, but nested objects are not allowed. " - "Elicitation schemas must be flat objects with primitive properties only." - ) - - # Check if it's a primitive type - if prop_type not in ALLOWED_TYPES: - raise TypeError( - f"Elicitation schema field '{prop_name}' has type '{prop_type}' which is not " - f"a primitive type. Only {ALLOWED_TYPES} are allowed in elicitation schemas." - ) diff --git a/src/fastmcp/server/event_store.py b/src/fastmcp/server/event_store.py deleted file mode 100644 index 9304a931e..000000000 --- a/src/fastmcp/server/event_store.py +++ /dev/null @@ -1,177 +0,0 @@ -"""EventStore implementation backed by AsyncKeyValue. - -This module provides an EventStore implementation that enables SSE polling/resumability -for Streamable HTTP transports. Events are stored using the key_value package's -AsyncKeyValue protocol, allowing users to configure any compatible backend -(in-memory, Redis, etc.) following the same pattern as ResponseCachingMiddleware. -""" - -from __future__ import annotations - -from uuid import uuid4 - -from key_value.aio.adapters.pydantic import PydanticAdapter -from key_value.aio.protocols import AsyncKeyValue -from key_value.aio.stores.memory import MemoryStore -from mcp.server.streamable_http import EventCallback, EventId, EventMessage, StreamId -from mcp.server.streamable_http import EventStore as SDKEventStore -from mcp.types import JSONRPCMessage - -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import FastMCPBaseModel - -logger = get_logger(__name__) - - -class EventEntry(FastMCPBaseModel): - """Stored event entry.""" - - event_id: str - stream_id: str - message: dict | None # JSONRPCMessage serialized to dict - - -class StreamEventList(FastMCPBaseModel): - """List of event IDs for a stream.""" - - event_ids: list[str] - - -class EventStore(SDKEventStore): - """EventStore implementation backed by AsyncKeyValue. - - Enables SSE polling/resumability by storing events that can be replayed - when clients reconnect. Works with any AsyncKeyValue backend (memory, Redis, etc.) - following the same pattern as ResponseCachingMiddleware and OAuthProxy. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.event_store import EventStore - - # Default in-memory storage - event_store = EventStore() - - # Or with a custom backend - from key_value.aio.stores.redis import RedisStore - redis_backend = RedisStore(url="redis://localhost") - event_store = EventStore(storage=redis_backend) - - mcp = FastMCP("MyServer") - app = mcp.http_app(event_store=event_store, retry_interval=2000) - ``` - - Args: - storage: AsyncKeyValue backend. Defaults to MemoryStore. - max_events_per_stream: Maximum events to retain per stream. Default 100. - ttl: Event TTL in seconds. Default 3600 (1 hour). Set to None for no expiration. - """ - - def __init__( - self, - storage: AsyncKeyValue | None = None, - max_events_per_stream: int = 100, - ttl: int | None = 3600, - ): - self._storage: AsyncKeyValue = storage or MemoryStore() - self._max_events_per_stream = max_events_per_stream - self._ttl = ttl - - # PydanticAdapter for type-safe storage (following OAuth proxy pattern) - self._event_store: PydanticAdapter[EventEntry] = PydanticAdapter[EventEntry]( - key_value=self._storage, - pydantic_model=EventEntry, - default_collection="fastmcp_events", - ) - self._stream_store: PydanticAdapter[StreamEventList] = PydanticAdapter[ - StreamEventList - ]( - key_value=self._storage, - pydantic_model=StreamEventList, - default_collection="fastmcp_streams", - ) - - async def store_event( - self, stream_id: StreamId, message: JSONRPCMessage | None - ) -> EventId: - """Store an event and return its ID. - - Args: - stream_id: ID of the stream the event belongs to - message: The JSON-RPC message to store, or None for priming events - - Returns: - The generated event ID for the stored event - """ - event_id = str(uuid4()) - - # Store the event entry - entry = EventEntry( - event_id=event_id, - stream_id=stream_id, - message=message.model_dump(mode="json") if message else None, - ) - await self._event_store.put(key=event_id, value=entry, ttl=self._ttl) - - # Update stream's event list - stream_data = await self._stream_store.get(key=stream_id) - event_ids = stream_data.event_ids if stream_data else [] - event_ids.append(event_id) - - # Trim to max events (delete old events) - if len(event_ids) > self._max_events_per_stream: - for old_id in event_ids[: -self._max_events_per_stream]: - await self._event_store.delete(key=old_id) - event_ids = event_ids[-self._max_events_per_stream :] - - await self._stream_store.put( - key=stream_id, - value=StreamEventList(event_ids=event_ids), - ttl=self._ttl, - ) - - return event_id - - async def replay_events_after( - self, - last_event_id: EventId, - send_callback: EventCallback, - ) -> StreamId | None: - """Replay events that occurred after the specified event ID. - - Args: - last_event_id: The ID of the last event the client received - send_callback: A callback function to send events to the client - - Returns: - The stream ID of the replayed events, or None if the event ID was not found - """ - # Look up the event to find its stream - entry = await self._event_store.get(key=last_event_id) - if not entry: - logger.warning(f"Event ID {last_event_id} not found in store") - return None - - stream_id = entry.stream_id - stream_data = await self._stream_store.get(key=stream_id) - if not stream_data: - logger.warning(f"Stream {stream_id} not found in store") - return None - - event_ids = stream_data.event_ids - - # Find events after last_event_id - try: - start_idx = event_ids.index(last_event_id) + 1 - except ValueError: - logger.warning(f"Event ID {last_event_id} not found in stream {stream_id}") - return None - - # Replay events after the last one - for event_id in event_ids[start_idx:]: - event = await self._event_store.get(key=event_id) - if event and event.message: - msg = JSONRPCMessage.model_validate(event.message) - await send_callback(EventMessage(msg, event.event_id)) - - return stream_id diff --git a/src/fastmcp/server/http.py b/src/fastmcp/server/http.py deleted file mode 100644 index e60ae6061..000000000 --- a/src/fastmcp/server/http.py +++ /dev/null @@ -1,382 +0,0 @@ -from __future__ import annotations - -from collections.abc import AsyncGenerator, Callable, Generator -from contextlib import asynccontextmanager, contextmanager -from contextvars import ContextVar -from typing import TYPE_CHECKING - -from mcp.server.auth.routes import build_resource_metadata_url -from mcp.server.lowlevel.server import LifespanResultT -from mcp.server.sse import SseServerTransport -from mcp.server.streamable_http import ( - EventStore, -) -from mcp.server.streamable_http_manager import StreamableHTTPSessionManager -from starlette.applications import Starlette -from starlette.middleware import Middleware -from starlette.requests import Request -from starlette.responses import Response -from starlette.routing import BaseRoute, Mount, Route -from starlette.types import Lifespan, Receive, Scope, Send - -from fastmcp.server.auth import AuthProvider -from fastmcp.server.auth.middleware import RequireAuthMiddleware -from fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - from fastmcp.server.server import FastMCP - -logger = get_logger(__name__) - - -class StreamableHTTPASGIApp: - """ASGI application wrapper for Streamable HTTP server transport.""" - - def __init__(self, session_manager): - self.session_manager = session_manager - - async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: - try: - await self.session_manager.handle_request(scope, receive, send) - except RuntimeError as e: - if str(e) == "Task group is not initialized. Make sure to use run().": - logger.error( - f"Original RuntimeError from mcp library: {e}", exc_info=True - ) - new_error_message = ( - "FastMCP's StreamableHTTPSessionManager task group was not initialized. " - "This commonly occurs when the FastMCP application's lifespan is not " - "passed to the parent ASGI application (e.g., FastAPI or Starlette). " - "Please ensure you are setting `lifespan=mcp_app.lifespan` in your " - "parent app's constructor, where `mcp_app` is the application instance " - "returned by `fastmcp_instance.http_app()`. \\n" - "For more details, see the FastMCP ASGI integration documentation: " - "https://gofastmcp.com/deployment/asgi" - ) - # Raise a new RuntimeError that includes the original error's message - # for full context, but leads with the more helpful guidance. - raise RuntimeError(f"{new_error_message}\\nOriginal error: {e}") from e - else: - # Re-raise other RuntimeErrors if they don't match the specific message - raise - - -_current_http_request: ContextVar[Request | None] = ContextVar( - "http_request", - default=None, -) - - -class StarletteWithLifespan(Starlette): - @property - def lifespan(self) -> Lifespan[Starlette]: - return self.router.lifespan_context - - -@contextmanager -def set_http_request(request: Request) -> Generator[Request, None, None]: - token = _current_http_request.set(request) - try: - yield request - finally: - _current_http_request.reset(token) - - -class RequestContextMiddleware: - """ - Middleware that stores each request in a ContextVar and sets transport type. - """ - - def __init__(self, app): - self.app = app - - async def __call__(self, scope, receive, send): - if scope["type"] == "http": - from fastmcp.server.context import reset_transport, set_transport - - # Get transport type from app state (set during app creation) - transport_type = getattr(scope["app"].state, "transport_type", None) - transport_token = set_transport(transport_type) if transport_type else None - try: - with set_http_request(Request(scope)): - await self.app(scope, receive, send) - finally: - if transport_token is not None: - reset_transport(transport_token) - else: - await self.app(scope, receive, send) - - -def create_base_app( - routes: list[BaseRoute], - middleware: list[Middleware], - debug: bool = False, - lifespan: Callable | None = None, -) -> StarletteWithLifespan: - """Create a base Starlette app with common middleware and routes. - - Args: - routes: List of routes to include in the app - middleware: List of middleware to include in the app - debug: Whether to enable debug mode - lifespan: Optional lifespan manager for the app - - Returns: - A Starlette application - """ - # Always add RequestContextMiddleware as the outermost middleware - middleware.insert(0, Middleware(RequestContextMiddleware)) # type: ignore[arg-type] - - return StarletteWithLifespan( - routes=routes, - middleware=middleware, - debug=debug, - lifespan=lifespan, - ) - - -def create_sse_app( - server: FastMCP[LifespanResultT], - message_path: str, - sse_path: str, - auth: AuthProvider | None = None, - debug: bool = False, - routes: list[BaseRoute] | None = None, - middleware: list[Middleware] | None = None, -) -> StarletteWithLifespan: - """Return an instance of the SSE server app. - - Args: - server: The FastMCP server instance - message_path: Path for SSE messages - sse_path: Path for SSE connections - auth: Optional authentication provider (AuthProvider) - debug: Whether to enable debug mode - routes: Optional list of custom routes - middleware: Optional list of middleware - Returns: - A Starlette application with RequestContextMiddleware - """ - - server_routes: list[BaseRoute] = [] - server_middleware: list[Middleware] = [] - - # Set up SSE transport - sse = SseServerTransport(message_path) - - # Create handler for SSE connections - async def handle_sse(scope: Scope, receive: Receive, send: Send) -> Response: - async with sse.connect_sse(scope, receive, send) as streams: - await server._mcp_server.run( - streams[0], - streams[1], - server._mcp_server.create_initialization_options(), - ) - return Response() - - # Set up auth if enabled - if auth: - # Get auth middleware from the provider - auth_middleware = auth.get_middleware() - - # Get auth provider's own routes (OAuth endpoints, metadata, etc) - auth_routes = auth.get_routes(mcp_path=sse_path) - server_routes.extend(auth_routes) - server_middleware.extend(auth_middleware) - - # Build RFC 9728-compliant metadata URL - resource_url = auth._get_resource_url(sse_path) - resource_metadata_url = ( - build_resource_metadata_url(resource_url) if resource_url else None - ) - - # Create protected SSE endpoint route - server_routes.append( - Route( - sse_path, - endpoint=RequireAuthMiddleware( - handle_sse, - auth.required_scopes, - resource_metadata_url, - ), - methods=["GET"], - ) - ) - - # Wrap the SSE message endpoint with RequireAuthMiddleware - server_routes.append( - Mount( - message_path, - app=RequireAuthMiddleware( - sse.handle_post_message, - auth.required_scopes, - resource_metadata_url, - ), - ) - ) - else: - # No auth required - async def sse_endpoint(request: Request) -> Response: - return await handle_sse(request.scope, request.receive, request._send) - - server_routes.append( - Route( - sse_path, - endpoint=sse_endpoint, - methods=["GET"], - ) - ) - server_routes.append( - Mount( - message_path, - app=sse.handle_post_message, - ) - ) - - # Add custom routes with lowest precedence - if routes: - server_routes.extend(routes) - server_routes.extend(server._get_additional_http_routes()) - - # Add middleware - if middleware: - server_middleware.extend(middleware) - - @asynccontextmanager - async def lifespan(app: Starlette) -> AsyncGenerator[None, None]: - async with server._lifespan_manager(): - yield - - # Create and return the app - app = create_base_app( - routes=server_routes, - middleware=server_middleware, - debug=debug, - lifespan=lifespan, - ) - # Store the FastMCP server instance on the Starlette app state - app.state.fastmcp_server = server - app.state.path = sse_path - app.state.transport_type = "sse" - - return app - - -def create_streamable_http_app( - server: FastMCP[LifespanResultT], - streamable_http_path: str, - event_store: EventStore | None = None, - retry_interval: int | None = None, - auth: AuthProvider | None = None, - json_response: bool = False, - stateless_http: bool = False, - debug: bool = False, - routes: list[BaseRoute] | None = None, - middleware: list[Middleware] | None = None, -) -> StarletteWithLifespan: - """Return an instance of the StreamableHTTP server app. - - Args: - server: The FastMCP server instance - streamable_http_path: Path for StreamableHTTP connections - event_store: Optional event store for SSE polling/resumability - retry_interval: Optional retry interval in milliseconds for SSE polling. - Controls how quickly clients should reconnect after server-initiated - disconnections. Requires event_store to be set. Defaults to SDK default. - auth: Optional authentication provider (AuthProvider) - json_response: Whether to use JSON response format - stateless_http: Whether to use stateless mode (new transport per request) - debug: Whether to enable debug mode - routes: Optional list of custom routes - middleware: Optional list of middleware - - Returns: - A Starlette application with StreamableHTTP support - """ - server_routes: list[BaseRoute] = [] - server_middleware: list[Middleware] = [] - - # Create session manager using the provided event store - session_manager = StreamableHTTPSessionManager( - app=server._mcp_server, - event_store=event_store, - retry_interval=retry_interval, - json_response=json_response, - stateless=stateless_http, - ) - - # Create the ASGI app wrapper - streamable_http_app = StreamableHTTPASGIApp(session_manager) - - # Add StreamableHTTP routes with or without auth - if auth: - # Get auth middleware from the provider - auth_middleware = auth.get_middleware() - - # Get auth provider's own routes (OAuth endpoints, metadata, etc) - auth_routes = auth.get_routes(mcp_path=streamable_http_path) - server_routes.extend(auth_routes) - server_middleware.extend(auth_middleware) - - # Build RFC 9728-compliant metadata URL - resource_url = auth._get_resource_url(streamable_http_path) - resource_metadata_url = ( - build_resource_metadata_url(resource_url) if resource_url else None - ) - - # Create protected HTTP endpoint route - # Stateless servers have no session tracking, so GET SSE streams - # (for server-initiated notifications) serve no purpose. - http_methods = ( - ["POST", "DELETE"] if stateless_http else ["GET", "POST", "DELETE"] - ) - server_routes.append( - Route( - streamable_http_path, - endpoint=RequireAuthMiddleware( - streamable_http_app, - auth.required_scopes, - resource_metadata_url, - ), - methods=http_methods, - ) - ) - else: - # No auth required - http_methods = ["POST", "DELETE"] if stateless_http else None - server_routes.append( - Route( - streamable_http_path, - endpoint=streamable_http_app, - methods=http_methods, - ) - ) - - # Add custom routes with lowest precedence - if routes: - server_routes.extend(routes) - server_routes.extend(server._get_additional_http_routes()) - - # Add middleware - if middleware: - server_middleware.extend(middleware) - - # Create a lifespan manager to start and stop the session manager - @asynccontextmanager - async def lifespan(app: Starlette) -> AsyncGenerator[None, None]: - async with server._lifespan_manager(), session_manager.run(): - yield - - # Create and return the app with lifespan - app = create_base_app( - routes=server_routes, - middleware=server_middleware, - debug=debug, - lifespan=lifespan, - ) - # Store the FastMCP server instance on the Starlette app state - app.state.fastmcp_server = server - app.state.path = streamable_http_path - app.state.transport_type = "streamable-http" - - return app diff --git a/src/fastmcp/server/lifespan.py b/src/fastmcp/server/lifespan.py deleted file mode 100644 index c3cd91695..000000000 --- a/src/fastmcp/server/lifespan.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Composable lifespans for FastMCP servers. - -This module provides a `@lifespan` decorator for creating composable server lifespans -that can be combined using the `|` operator. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.lifespan import lifespan - - @lifespan - async def db_lifespan(server): - conn = await connect_db() - yield {"db": conn} - await conn.close() - - @lifespan - async def cache_lifespan(server): - cache = await connect_cache() - yield {"cache": cache} - await cache.close() - - mcp = FastMCP("server", lifespan=db_lifespan | cache_lifespan) - ``` - -To compose with existing `@asynccontextmanager` lifespans, wrap them explicitly: - - ```python - from contextlib import asynccontextmanager - from fastmcp.server.lifespan import lifespan, ContextManagerLifespan - - @asynccontextmanager - async def legacy_lifespan(server): - yield {"legacy": True} - - @lifespan - async def new_lifespan(server): - yield {"new": True} - - # Wrap the legacy lifespan explicitly - combined = ContextManagerLifespan(legacy_lifespan) | new_lifespan - ``` -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator, Callable -from contextlib import AbstractAsyncContextManager, asynccontextmanager -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from fastmcp.server.server import FastMCP - - -LifespanFn = Callable[["FastMCP[Any]"], AsyncIterator[dict[str, Any] | None]] -LifespanContextManagerFn = Callable[ - ["FastMCP[Any]"], AbstractAsyncContextManager[dict[str, Any] | None] -] - - -class Lifespan: - """Composable lifespan wrapper. - - Wraps an async generator function and enables composition via the `|` operator. - The wrapped function should yield a dict that becomes part of the lifespan context. - """ - - def __init__(self, fn: LifespanFn) -> None: - """Initialize a Lifespan wrapper. - - Args: - fn: An async generator function that takes a FastMCP server and yields - a dict for the lifespan context. - """ - self._fn = fn - - @asynccontextmanager - async def __call__(self, server: FastMCP[Any]) -> AsyncIterator[dict[str, Any]]: - """Execute the lifespan as an async context manager. - - Args: - server: The FastMCP server instance. - - Yields: - The lifespan context dict. - """ - async with asynccontextmanager(self._fn)(server) as result: - yield result if result is not None else {} - - def __or__(self, other: Lifespan) -> ComposedLifespan: - """Compose with another lifespan using the | operator. - - Args: - other: Another Lifespan instance. - - Returns: - A ComposedLifespan that runs both lifespans. - - Raises: - TypeError: If other is not a Lifespan instance. - """ - if not isinstance(other, Lifespan): - raise TypeError( - f"Cannot compose Lifespan with {type(other).__name__}. " - f"Use @lifespan decorator or wrap with ContextManagerLifespan()." - ) - return ComposedLifespan(self, other) - - -class ContextManagerLifespan(Lifespan): - """Lifespan wrapper for already-wrapped context manager functions. - - Use this for functions already decorated with @asynccontextmanager. - """ - - _fn: LifespanContextManagerFn # Override type for this subclass - - def __init__(self, fn: LifespanContextManagerFn) -> None: - """Initialize with a context manager factory function.""" - self._fn = fn - - @asynccontextmanager - async def __call__(self, server: FastMCP[Any]) -> AsyncIterator[dict[str, Any]]: - """Execute the lifespan as an async context manager. - - Args: - server: The FastMCP server instance. - - Yields: - The lifespan context dict. - """ - # self._fn is already a context manager factory, just call it - async with self._fn(server) as result: - yield result if result is not None else {} - - -class ComposedLifespan(Lifespan): - """Two lifespans composed together. - - Enters the left lifespan first, then the right. Exits in reverse order. - Results are shallow-merged into a single dict. - """ - - def __init__(self, left: Lifespan, right: Lifespan) -> None: - """Initialize a composed lifespan. - - Args: - left: The first lifespan to enter. - right: The second lifespan to enter. - """ - # Don't call super().__init__ since we override __call__ - self._left = left - self._right = right - - @asynccontextmanager - async def __call__(self, server: FastMCP[Any]) -> AsyncIterator[dict[str, Any]]: - """Execute both lifespans, merging their results. - - Args: - server: The FastMCP server instance. - - Yields: - The merged lifespan context dict from both lifespans. - """ - async with ( - self._left(server) as left_result, - self._right(server) as right_result, - ): - yield {**left_result, **right_result} - - -def lifespan(fn: LifespanFn) -> Lifespan: - """Decorator to create a composable lifespan. - - Use this decorator on an async generator function to make it composable - with other lifespans using the `|` operator. - - Example: - ```python - @lifespan - async def my_lifespan(server): - # Setup - resource = await create_resource() - yield {"resource": resource} - # Teardown - await resource.close() - - mcp = FastMCP("server", lifespan=my_lifespan | other_lifespan) - ``` - - Args: - fn: An async generator function that takes a FastMCP server and yields - a dict for the lifespan context. - - Returns: - A composable Lifespan wrapper. - """ - return Lifespan(fn) diff --git a/src/fastmcp/server/low_level.py b/src/fastmcp/server/low_level.py deleted file mode 100644 index 36255f4c7..000000000 --- a/src/fastmcp/server/low_level.py +++ /dev/null @@ -1,347 +0,0 @@ -from __future__ import annotations - -import weakref -from collections.abc import Awaitable, Callable -from contextlib import AsyncExitStack -from typing import TYPE_CHECKING, Any - -import anyio -import mcp.types -from anyio.streams.memory import MemoryObjectReceiveStream, MemoryObjectSendStream -from mcp import LoggingLevel, McpError -from mcp.server.lowlevel.server import ( - LifespanResultT, - NotificationOptions, - RequestT, -) -from mcp.server.lowlevel.server import ( - Server as _Server, -) -from mcp.server.models import InitializationOptions -from mcp.server.session import ServerSession -from mcp.server.stdio import stdio_server as stdio_server -from mcp.shared.message import SessionMessage -from mcp.shared.session import RequestResponder -from pydantic import AnyUrl - -from fastmcp.apps.config import UI_EXTENSION_ID -from fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - from fastmcp.server.server import FastMCP - -logger = get_logger(__name__) - - -class MiddlewareServerSession(ServerSession): - """ServerSession that routes initialization requests through FastMCP middleware.""" - - def __init__(self, fastmcp: FastMCP, *args, **kwargs): - super().__init__(*args, **kwargs) - self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp) - # Task group for subscription tasks (set during session run) - self._subscription_task_group: anyio.TaskGroup | None = None # type: ignore[valid-type] # ty:ignore[invalid-type-form] - # Minimum logging level requested by the client via logging/setLevel - self._minimum_logging_level: LoggingLevel | None = None - - @property - def fastmcp(self) -> FastMCP: - """Get the FastMCP instance.""" - fastmcp = self._fastmcp_ref() - if fastmcp is None: - raise RuntimeError("FastMCP instance is no longer available") - return fastmcp - - def client_supports_extension(self, extension_id: str) -> bool: - """Check if the connected client supports a given MCP extension. - - Inspects the ``extensions`` extra field on ``ClientCapabilities`` - sent by the client during initialization. - """ - client_params = self._client_params - if client_params is None: - return False - caps = client_params.capabilities - if caps is None: - return False - # ClientCapabilities uses extra="allow" — extensions is an extra field - extras = caps.model_extra or {} - extensions: dict[str, Any] | None = extras.get("extensions") - if not extensions: - return False - return extension_id in extensions - - async def _received_request( - self, - responder: RequestResponder[mcp.types.ClientRequest, mcp.types.ServerResult], - ): - """ - Override the _received_request method to route special requests - through FastMCP middleware. - - Handles initialization requests and SEP-1686 task methods. - """ - import fastmcp.server.context - from fastmcp.server.middleware.middleware import MiddlewareContext - - if isinstance(responder.request.root, mcp.types.InitializeRequest): - # The MCP SDK's ServerSession._received_request() handles the - # initialize request internally by calling responder.respond() - # to send the InitializeResult directly to the write stream, then - # returning None. This bypasses the middleware return path entirely, - # so middleware would only see the request, never the response. - # - # To expose the response to middleware (e.g., for logging server - # capabilities), we wrap responder.respond() to capture the - # InitializeResult before it's sent, then return it from - # call_original_handler so it flows back through the middleware chain. - captured_response: mcp.types.ServerResult | None = None - original_respond = responder.respond - - async def capturing_respond( - response: mcp.types.ServerResult, - ) -> None: - nonlocal captured_response - captured_response = response - return await original_respond(response) - - responder.respond = capturing_respond # type: ignore[method-assign] # ty:ignore[invalid-assignment] - - async def call_original_handler( - ctx: MiddlewareContext, - ) -> mcp.types.InitializeResult | None: - await super(MiddlewareServerSession, self)._received_request(responder) - if captured_response is not None and isinstance( - captured_response.root, mcp.types.InitializeResult - ): - return captured_response.root - return None - - async with fastmcp.server.context.Context( - fastmcp=self.fastmcp, session=self - ) as fastmcp_ctx: - # Create the middleware context. - mw_context = MiddlewareContext( - message=responder.request.root, - source="client", - type="request", - method="initialize", - fastmcp_context=fastmcp_ctx, - ) - - try: - return await self.fastmcp._run_middleware( - mw_context, call_original_handler - ) - except McpError as e: - # McpError can be thrown from middleware in `on_initialize` - # send the error to responder. - if not responder._completed: - with responder: - await responder.respond(e.error) - else: - # Don't re-raise: prevents responding to initialize request twice - logger.warning( - "Received McpError but responder is already completed. " - "Cannot send error response as response was already sent.", - exc_info=e, - ) - return None - - # Fall through to default handling (task methods now handled via registered handlers) - return await super()._received_request(responder) - - -class LowLevelServer(_Server[LifespanResultT, RequestT]): - def __init__(self, fastmcp: FastMCP, *args: Any, **kwargs: Any): - super().__init__(*args, **kwargs) - # Store a weak reference to FastMCP to avoid circular references - self._fastmcp_ref: weakref.ref[FastMCP] = weakref.ref(fastmcp) - - # FastMCP servers support notifications for all components - self.notification_options = NotificationOptions( - prompts_changed=True, - resources_changed=True, - tools_changed=True, - ) - - @property - def fastmcp(self) -> FastMCP: - """Get the FastMCP instance.""" - fastmcp = self._fastmcp_ref() - if fastmcp is None: - raise RuntimeError("FastMCP instance is no longer available") - return fastmcp - - def create_initialization_options( - self, - notification_options: NotificationOptions | None = None, - experimental_capabilities: dict[str, dict[str, Any]] | None = None, - **kwargs: Any, - ) -> InitializationOptions: - # ensure we use the FastMCP notification options - if notification_options is None: - notification_options = self.notification_options - return super().create_initialization_options( - notification_options=notification_options, - experimental_capabilities=experimental_capabilities, - **kwargs, - ) - - def get_capabilities( - self, - notification_options: NotificationOptions, - experimental_capabilities: dict[str, dict[str, Any]], - ) -> mcp.types.ServerCapabilities: - """Override to set capabilities.tasks as a first-class field per SEP-1686. - - This ensures task capabilities appear in capabilities.tasks instead of - capabilities.experimental.tasks, which is required by the MCP spec and - enables proper task detection by clients like VS Code Copilot 1.107+. - """ - from fastmcp.server.tasks.capabilities import get_task_capabilities - - # Get base capabilities from SDK (pass empty dict for experimental) - # since we'll set tasks as a first-class field instead - capabilities = super().get_capabilities( - notification_options, - experimental_capabilities or {}, - ) - - # Set tasks as a first-class field (not experimental) per SEP-1686 - capabilities.tasks = get_task_capabilities() - - # Advertise MCP Apps extension support (io.modelcontextprotocol/ui) - # Uses the same extra-field pattern as tasks above — ServerCapabilities - # has extra="allow" so this survives serialization. - # Merge with any existing extensions to avoid clobbering other features. - existing_extensions: dict[str, Any] = ( - getattr(capabilities, "extensions", None) or {} - ) - capabilities.extensions = {**existing_extensions, UI_EXTENSION_ID: {}} - - return capabilities - - async def run( - self, - read_stream: MemoryObjectReceiveStream[SessionMessage | Exception], - write_stream: MemoryObjectSendStream[SessionMessage], - initialization_options: InitializationOptions, - raise_exceptions: bool = False, - stateless: bool = False, - ): - """ - Overrides the run method to use the MiddlewareServerSession. - """ - async with AsyncExitStack() as stack: - lifespan_context = await stack.enter_async_context(self.lifespan(self)) - session = await stack.enter_async_context( - MiddlewareServerSession( - self.fastmcp, - read_stream, - write_stream, - initialization_options, - stateless=stateless, - ) - ) - - async with anyio.create_task_group() as tg: - # Store task group on session for subscription tasks (SEP-1686) - session._subscription_task_group = tg - - async for message in session.incoming_messages: - tg.start_soon( - self._handle_message, - message, - session, - lifespan_context, - raise_exceptions, - ) - - def read_resource( - self, - ) -> Callable[ - [ - Callable[ - [AnyUrl], - Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult], - ] - ], - Callable[ - [AnyUrl], - Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult], - ], - ]: - """ - Decorator for registering a read_resource handler with CreateTaskResult support. - - The MCP SDK's read_resource decorator does not support returning CreateTaskResult - for background task execution. This decorator wraps the result in ServerResult. - - This decorator can be removed once the MCP SDK adds native CreateTaskResult support - for resources. - """ - - def decorator( - func: Callable[ - [AnyUrl], - Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult], - ], - ) -> Callable[ - [AnyUrl], - Awaitable[mcp.types.ReadResourceResult | mcp.types.CreateTaskResult], - ]: - async def handler( - req: mcp.types.ReadResourceRequest, - ) -> mcp.types.ServerResult: - result = await func(req.params.uri) - return mcp.types.ServerResult(result) - - self.request_handlers[mcp.types.ReadResourceRequest] = handler - return func - - return decorator - - def get_prompt( - self, - ) -> Callable[ - [ - Callable[ - [str, dict[str, Any] | None], - Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult], - ] - ], - Callable[ - [str, dict[str, Any] | None], - Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult], - ], - ]: - """ - Decorator for registering a get_prompt handler with CreateTaskResult support. - - The MCP SDK's get_prompt decorator does not support returning CreateTaskResult - for background task execution. This decorator wraps the result in ServerResult. - - This decorator can be removed once the MCP SDK adds native CreateTaskResult support - for prompts. - """ - - def decorator( - func: Callable[ - [str, dict[str, Any] | None], - Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult], - ], - ) -> Callable[ - [str, dict[str, Any] | None], - Awaitable[mcp.types.GetPromptResult | mcp.types.CreateTaskResult], - ]: - async def handler( - req: mcp.types.GetPromptRequest, - ) -> mcp.types.ServerResult: - result = await func(req.params.name, req.params.arguments) - return mcp.types.ServerResult(result) - - self.request_handlers[mcp.types.GetPromptRequest] = handler - return func - - return decorator diff --git a/src/fastmcp/server/middleware/__init__.py b/src/fastmcp/server/middleware/__init__.py deleted file mode 100644 index 8df6962bd..000000000 --- a/src/fastmcp/server/middleware/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from .authorization import AuthMiddleware -from .middleware import ( - CallNext, - Middleware, - MiddlewareContext, -) -from .ping import PingMiddleware - -__all__ = [ - "AuthMiddleware", - "CallNext", - "Middleware", - "MiddlewareContext", - "PingMiddleware", -] diff --git a/src/fastmcp/server/middleware/authorization.py b/src/fastmcp/server/middleware/authorization.py deleted file mode 100644 index 19b050370..000000000 --- a/src/fastmcp/server/middleware/authorization.py +++ /dev/null @@ -1,321 +0,0 @@ -"""Authorization middleware for FastMCP. - -This module provides middleware-based authorization using callable auth checks. -AuthMiddleware applies auth checks globally to all components on the server. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth import require_scopes, restrict_tag - from fastmcp.server.middleware import AuthMiddleware - - # Require specific scope for all components - mcp = FastMCP(middleware=[ - AuthMiddleware(auth=require_scopes("api")) - ]) - - # Tag-based: components tagged "admin" require "admin" scope - mcp = FastMCP(middleware=[ - AuthMiddleware(auth=restrict_tag("admin", scopes=["admin"])) - ]) - ``` -""" - -from __future__ import annotations - -import logging -from collections.abc import Sequence - -import mcp.types as mt - -from fastmcp.exceptions import AuthorizationError -from fastmcp.prompts.base import Prompt, PromptResult -from fastmcp.resources.base import Resource, ResourceResult -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.auth.authorization import ( - AuthCheck, - AuthContext, - run_auth_checks, -) -from fastmcp.server.dependencies import get_access_token -from fastmcp.server.middleware.middleware import ( - CallNext, - Middleware, - MiddlewareContext, -) -from fastmcp.tools.base import Tool, ToolResult - -logger = logging.getLogger(__name__) - - -class AuthMiddleware(Middleware): - """Global authorization middleware using callable checks. - - This middleware applies auth checks to all components (tools, resources, - prompts) on the server. It uses the same callable API as component-level - auth checks. - - The middleware: - - Filters tools/resources/prompts from list responses based on auth checks - - Checks auth before tool execution, resource read, and prompt render - - Skips all auth checks for STDIO transport (no OAuth concept) - - Args: - auth: A single auth check function or list of check functions. - All checks must pass for authorization to succeed (AND logic). - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.auth import require_scopes - - # Require specific scope for all components - mcp = FastMCP(middleware=[AuthMiddleware(auth=require_scopes("api"))]) - - # Multiple scopes (AND logic) - mcp = FastMCP(middleware=[ - AuthMiddleware(auth=require_scopes("read", "api")) - ]) - ``` - """ - - def __init__(self, auth: AuthCheck | list[AuthCheck]) -> None: - self.auth = auth - - async def on_list_tools( - self, - context: MiddlewareContext[mt.ListToolsRequest], - call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]], - ) -> Sequence[Tool]: - """Filter tools/list response based on auth checks.""" - tools = await call_next(context) - - # STDIO has no auth concept, skip filtering - # Late import to avoid circular import with context.py - from fastmcp.server.context import _current_transport - - if _current_transport.get() == "stdio": - return tools - - token = get_access_token() - - authorized_tools: list[Tool] = [] - for tool in tools: - ctx = AuthContext(token=token, component=tool) - try: - if await run_auth_checks(self.auth, ctx): - authorized_tools.append(tool) - except AuthorizationError: - continue - - return authorized_tools - - async def on_call_tool( - self, - context: MiddlewareContext[mt.CallToolRequestParams], - call_next: CallNext[mt.CallToolRequestParams, ToolResult], - ) -> ToolResult: - """Check auth before tool execution.""" - # STDIO has no auth concept, skip enforcement - # Late import to avoid circular import with context.py - from fastmcp.server.context import _current_transport - - if _current_transport.get() == "stdio": - return await call_next(context) - - # Get the tool being called - tool_name = context.message.name - fastmcp = context.fastmcp_context - if fastmcp is None: - # Fail closed: deny access when context is missing - logger.warning( - f"AuthMiddleware: fastmcp_context is None for tool '{tool_name}'. " - "Denying access for security." - ) - raise AuthorizationError( - f"Authorization failed for tool '{tool_name}': missing context" - ) - - # Get tool (component auth is checked in get_tool, raises if unauthorized) - tool = await fastmcp.fastmcp.get_tool(tool_name) - if tool is None: - raise AuthorizationError( - f"Authorization failed for tool '{tool_name}': tool not found" - ) - - # Global auth check - token = get_access_token() - ctx = AuthContext(token=token, component=tool) - if not await run_auth_checks(self.auth, ctx): - raise AuthorizationError( - f"Authorization failed for tool '{tool_name}': insufficient permissions" - ) - - return await call_next(context) - - async def on_list_resources( - self, - context: MiddlewareContext[mt.ListResourcesRequest], - call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]], - ) -> Sequence[Resource]: - """Filter resources/list response based on auth checks.""" - resources = await call_next(context) - - # STDIO has no auth concept, skip filtering - from fastmcp.server.context import _current_transport - - if _current_transport.get() == "stdio": - return resources - - token = get_access_token() - - authorized_resources: list[Resource] = [] - for resource in resources: - ctx = AuthContext(token=token, component=resource) - try: - if await run_auth_checks(self.auth, ctx): - authorized_resources.append(resource) - except AuthorizationError: - continue - - return authorized_resources - - async def on_read_resource( - self, - context: MiddlewareContext[mt.ReadResourceRequestParams], - call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult], - ) -> ResourceResult: - """Check auth before resource read.""" - # STDIO has no auth concept, skip enforcement - from fastmcp.server.context import _current_transport - - if _current_transport.get() == "stdio": - return await call_next(context) - - # Get the resource being read - uri = context.message.uri - fastmcp = context.fastmcp_context - if fastmcp is None: - logger.warning( - f"AuthMiddleware: fastmcp_context is None for resource '{uri}'. " - "Denying access for security." - ) - raise AuthorizationError( - f"Authorization failed for resource '{uri}': missing context" - ) - - # Get resource/template (component auth is checked in get_*, raises if unauthorized) - component = await fastmcp.fastmcp.get_resource(str(uri)) - if component is None: - component = await fastmcp.fastmcp.get_resource_template(str(uri)) - if component is None: - raise AuthorizationError( - f"Authorization failed for resource '{uri}': resource not found" - ) - - # Global auth check - token = get_access_token() - ctx = AuthContext(token=token, component=component) - if not await run_auth_checks(self.auth, ctx): - raise AuthorizationError( - f"Authorization failed for resource '{uri}': insufficient permissions" - ) - - return await call_next(context) - - async def on_list_resource_templates( - self, - context: MiddlewareContext[mt.ListResourceTemplatesRequest], - call_next: CallNext[ - mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate] - ], - ) -> Sequence[ResourceTemplate]: - """Filter resource templates/list response based on auth checks.""" - templates = await call_next(context) - - # STDIO has no auth concept, skip filtering - from fastmcp.server.context import _current_transport - - if _current_transport.get() == "stdio": - return templates - - token = get_access_token() - - authorized_templates: list[ResourceTemplate] = [] - for template in templates: - ctx = AuthContext(token=token, component=template) - try: - if await run_auth_checks(self.auth, ctx): - authorized_templates.append(template) - except AuthorizationError: - continue - - return authorized_templates - - async def on_list_prompts( - self, - context: MiddlewareContext[mt.ListPromptsRequest], - call_next: CallNext[mt.ListPromptsRequest, Sequence[Prompt]], - ) -> Sequence[Prompt]: - """Filter prompts/list response based on auth checks.""" - prompts = await call_next(context) - - # STDIO has no auth concept, skip filtering - from fastmcp.server.context import _current_transport - - if _current_transport.get() == "stdio": - return prompts - - token = get_access_token() - - authorized_prompts: list[Prompt] = [] - for prompt in prompts: - ctx = AuthContext(token=token, component=prompt) - try: - if await run_auth_checks(self.auth, ctx): - authorized_prompts.append(prompt) - except AuthorizationError: - continue - - return authorized_prompts - - async def on_get_prompt( - self, - context: MiddlewareContext[mt.GetPromptRequestParams], - call_next: CallNext[mt.GetPromptRequestParams, PromptResult], - ) -> PromptResult: - """Check auth before prompt render.""" - # STDIO has no auth concept, skip enforcement - from fastmcp.server.context import _current_transport - - if _current_transport.get() == "stdio": - return await call_next(context) - - # Get the prompt being rendered - prompt_name = context.message.name - fastmcp = context.fastmcp_context - if fastmcp is None: - logger.warning( - f"AuthMiddleware: fastmcp_context is None for prompt '{prompt_name}'. " - "Denying access for security." - ) - raise AuthorizationError( - f"Authorization failed for prompt '{prompt_name}': missing context" - ) - - # Get prompt (component auth is checked in get_prompt, raises if unauthorized) - prompt = await fastmcp.fastmcp.get_prompt(prompt_name) - if prompt is None: - raise AuthorizationError( - f"Authorization failed for prompt '{prompt_name}': prompt not found" - ) - - # Global auth check - token = get_access_token() - ctx = AuthContext(token=token, component=prompt) - if not await run_auth_checks(self.auth, ctx): - raise AuthorizationError( - f"Authorization failed for prompt '{prompt_name}': insufficient permissions" - ) - - return await call_next(context) diff --git a/src/fastmcp/server/middleware/caching.py b/src/fastmcp/server/middleware/caching.py deleted file mode 100644 index cb2b49866..000000000 --- a/src/fastmcp/server/middleware/caching.py +++ /dev/null @@ -1,552 +0,0 @@ -"""A middleware for response caching.""" - -import hashlib -from collections.abc import Sequence -from logging import Logger -from typing import Any, TypedDict - -import mcp.types -import pydantic_core -from key_value.aio.adapters.pydantic import PydanticAdapter -from key_value.aio.protocols.key_value import AsyncKeyValue -from key_value.aio.stores.memory import MemoryStore -from key_value.aio.wrappers.limit_size import LimitSizeWrapper -from key_value.aio.wrappers.statistics import StatisticsWrapper -from key_value.aio.wrappers.statistics.wrapper import ( - KVStoreCollectionStatistics, -) -from pydantic import Field -from typing_extensions import NotRequired, Self, override - -from fastmcp.prompts.base import Message, Prompt, PromptResult -from fastmcp.resources.base import Resource, ResourceContent, ResourceResult -from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import FastMCPBaseModel - -logger: Logger = get_logger(name=__name__) - -# Constants -ONE_HOUR_IN_SECONDS = 3600 -FIVE_MINUTES_IN_SECONDS = 300 - -ONE_MB_IN_BYTES = 1024 * 1024 - -GLOBAL_KEY = "__global__" - - -class CachableResourceContent(FastMCPBaseModel): - """A wrapper for ResourceContent that can be cached.""" - - content: str | bytes - mime_type: str | None = None - meta: dict[str, Any] | None = None - - -class CachableResourceResult(FastMCPBaseModel): - """A wrapper for ResourceResult that can be cached.""" - - contents: list[CachableResourceContent] - meta: dict[str, Any] | None = None - - def get_size(self) -> int: - return len(self.model_dump_json()) - - @classmethod - def wrap(cls, value: ResourceResult) -> Self: - return cls( - contents=[ - CachableResourceContent( - content=item.content, mime_type=item.mime_type, meta=item.meta - ) - for item in value.contents - ], - meta=value.meta, - ) - - def unwrap(self) -> ResourceResult: - return ResourceResult( - contents=[ - ResourceContent( - content=item.content, mime_type=item.mime_type, meta=item.meta - ) - for item in self.contents - ], - meta=self.meta, - ) - - -class CachableToolResult(FastMCPBaseModel): - content: list[mcp.types.ContentBlock] - structured_content: dict[str, Any] | None - meta: dict[str, Any] | None - - @classmethod - def wrap(cls, value: ToolResult) -> Self: - return cls( - content=value.content, - structured_content=value.structured_content, - meta=value.meta, - ) - - def unwrap(self) -> ToolResult: - return ToolResult( - content=self.content, - structured_content=self.structured_content, - meta=self.meta, - ) - - -class CachableMessage(FastMCPBaseModel): - """A wrapper for Message that can be cached.""" - - role: str - content: ( - mcp.types.TextContent - | mcp.types.ImageContent - | mcp.types.AudioContent - | mcp.types.EmbeddedResource - ) - - -class CachablePromptResult(FastMCPBaseModel): - """A wrapper for PromptResult that can be cached.""" - - messages: list[CachableMessage] - description: str | None = None - meta: dict[str, Any] | None = None - - def get_size(self) -> int: - return len(self.model_dump_json()) - - @classmethod - def wrap(cls, value: PromptResult) -> Self: - return cls( - messages=[ - CachableMessage(role=m.role, content=m.content) for m in value.messages - ], - description=value.description, - meta=value.meta, - ) - - def unwrap(self) -> PromptResult: - return PromptResult( - messages=[ - Message(content=m.content, role=m.role) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - for m in self.messages - ], - description=self.description, - meta=self.meta, - ) - - -class SharedMethodSettings(TypedDict): - """Shared config for a cache method.""" - - ttl: NotRequired[int] - enabled: NotRequired[bool] - - -class ListToolsSettings(SharedMethodSettings): - """Configuration options for Tool-related caching.""" - - -class ListResourcesSettings(SharedMethodSettings): - """Configuration options for Resource-related caching.""" - - -class ListPromptsSettings(SharedMethodSettings): - """Configuration options for Prompt-related caching.""" - - -class CallToolSettings(SharedMethodSettings): - """Configuration options for Tool-related caching.""" - - included_tools: NotRequired[list[str]] - excluded_tools: NotRequired[list[str]] - - -class ReadResourceSettings(SharedMethodSettings): - """Configuration options for Resource-related caching.""" - - -class GetPromptSettings(SharedMethodSettings): - """Configuration options for Prompt-related caching.""" - - -class ResponseCachingStatistics(FastMCPBaseModel): - list_tools: KVStoreCollectionStatistics | None = Field(default=None) - list_resources: KVStoreCollectionStatistics | None = Field(default=None) - list_prompts: KVStoreCollectionStatistics | None = Field(default=None) - read_resource: KVStoreCollectionStatistics | None = Field(default=None) - get_prompt: KVStoreCollectionStatistics | None = Field(default=None) - call_tool: KVStoreCollectionStatistics | None = Field(default=None) - - -class ResponseCachingMiddleware(Middleware): - """The response caching middleware offers a simple way to cache responses to mcp methods. The Middleware - supports cache invalidation via notifications from the server. The Middleware implements TTL-based caching - but cache implementations may offer additional features like LRU eviction, size limits, and more. - - When items are retrieved from the cache they will no longer be the original objects, but rather no-op objects - this means that response caching may not be compatible with other middleware that expects original subclasses. - - Notes: - - Caches `tools/call`, `resources/read`, `prompts/get`, `tools/list`, `resources/list`, and `prompts/list` requests. - - Cache keys are derived from method name and arguments. - """ - - def __init__( - self, - cache_storage: AsyncKeyValue | None = None, - list_tools_settings: ListToolsSettings | None = None, - list_resources_settings: ListResourcesSettings | None = None, - list_prompts_settings: ListPromptsSettings | None = None, - read_resource_settings: ReadResourceSettings | None = None, - get_prompt_settings: GetPromptSettings | None = None, - call_tool_settings: CallToolSettings | None = None, - max_item_size: int = ONE_MB_IN_BYTES, - ): - """Initialize the response caching middleware. - - Args: - cache_storage: The cache backend to use. If None, an in-memory cache is used. - list_tools_settings: The settings for the list tools method. If None, the default settings are used (5 minute TTL). - list_resources_settings: The settings for the list resources method. If None, the default settings are used (5 minute TTL). - list_prompts_settings: The settings for the list prompts method. If None, the default settings are used (5 minute TTL). - read_resource_settings: The settings for the read resource method. If None, the default settings are used (1 hour TTL). - get_prompt_settings: The settings for the get prompt method. If None, the default settings are used (1 hour TTL). - call_tool_settings: The settings for the call tool method. If None, the default settings are used (1 hour TTL). - max_item_size: The maximum size of items eligible for caching. Defaults to 1MB. - """ - - self._backend: AsyncKeyValue = cache_storage or MemoryStore() - - # When the size limit is exceeded, the put will silently fail - self._size_limiter: LimitSizeWrapper = LimitSizeWrapper( - key_value=self._backend, max_size=max_item_size, raise_on_too_large=False - ) - self._stats: StatisticsWrapper = StatisticsWrapper(key_value=self._size_limiter) - - self._list_tools_settings: ListToolsSettings = ( - list_tools_settings or ListToolsSettings() - ) - self._list_resources_settings: ListResourcesSettings = ( - list_resources_settings or ListResourcesSettings() - ) - self._list_prompts_settings: ListPromptsSettings = ( - list_prompts_settings or ListPromptsSettings() - ) - - self._read_resource_settings: ReadResourceSettings = ( - read_resource_settings or ReadResourceSettings() - ) - self._get_prompt_settings: GetPromptSettings = ( - get_prompt_settings or GetPromptSettings() - ) - self._call_tool_settings: CallToolSettings = ( - call_tool_settings or CallToolSettings() - ) - - self._list_tools_cache: PydanticAdapter[list[Tool]] = PydanticAdapter( - key_value=self._stats, - pydantic_model=list[Tool], - default_collection="tools/list", - ) - - self._list_resources_cache: PydanticAdapter[list[Resource]] = PydanticAdapter( - key_value=self._stats, - pydantic_model=list[Resource], - default_collection="resources/list", - ) - - self._list_prompts_cache: PydanticAdapter[list[Prompt]] = PydanticAdapter( - key_value=self._stats, - pydantic_model=list[Prompt], - default_collection="prompts/list", - ) - - self._read_resource_cache: PydanticAdapter[CachableResourceResult] = ( - PydanticAdapter( - key_value=self._stats, - pydantic_model=CachableResourceResult, - default_collection="resources/read", - ) - ) - - self._get_prompt_cache: PydanticAdapter[CachablePromptResult] = PydanticAdapter( - key_value=self._stats, - pydantic_model=CachablePromptResult, - default_collection="prompts/get", - ) - - self._call_tool_cache: PydanticAdapter[CachableToolResult] = PydanticAdapter( - key_value=self._stats, - pydantic_model=CachableToolResult, - default_collection="tools/call", - ) - - @override - async def on_list_tools( - self, - context: MiddlewareContext[mcp.types.ListToolsRequest], - call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]], - ) -> Sequence[Tool]: - """List tools from the cache, if caching is enabled, and the result is in the cache. Otherwise, - otherwise call the next middleware and store the result in the cache if caching is enabled.""" - if self._list_tools_settings.get("enabled") is False: - return await call_next(context) - - if cached_value := await self._list_tools_cache.get(key=GLOBAL_KEY): - return cached_value - - tools: Sequence[Tool] = await call_next(context=context) - - # Turn any subclass of Tool into a Tool - cachable_tools: list[Tool] = [ - Tool( - name=tool.name, - title=tool.title, - description=tool.description, - parameters=tool.parameters, - output_schema=tool.output_schema, - annotations=tool.annotations, - meta=tool.meta, - tags=tool.tags, - ) - for tool in tools - ] - - await self._list_tools_cache.put( - key=GLOBAL_KEY, - value=cachable_tools, - ttl=self._list_tools_settings.get("ttl", FIVE_MINUTES_IN_SECONDS), - ) - - return cachable_tools - - @override - async def on_list_resources( - self, - context: MiddlewareContext[mcp.types.ListResourcesRequest], - call_next: CallNext[mcp.types.ListResourcesRequest, Sequence[Resource]], - ) -> Sequence[Resource]: - """List resources from the cache, if caching is enabled, and the result is in the cache. Otherwise, - otherwise call the next middleware and store the result in the cache if caching is enabled.""" - if self._list_resources_settings.get("enabled") is False: - return await call_next(context) - - if cached_value := await self._list_resources_cache.get(key=GLOBAL_KEY): - return cached_value - - resources: Sequence[Resource] = await call_next(context=context) - - # Turn any subclass of Resource into a Resource - cachable_resources: list[Resource] = [ - Resource( - name=resource.name, - title=resource.title, - description=resource.description, - tags=resource.tags, - meta=resource.meta, - mime_type=resource.mime_type, - annotations=resource.annotations, - uri=resource.uri, - ) - for resource in resources - ] - - await self._list_resources_cache.put( - key=GLOBAL_KEY, - value=cachable_resources, - ttl=self._list_resources_settings.get("ttl", FIVE_MINUTES_IN_SECONDS), - ) - - return cachable_resources - - @override - async def on_list_prompts( - self, - context: MiddlewareContext[mcp.types.ListPromptsRequest], - call_next: CallNext[mcp.types.ListPromptsRequest, Sequence[Prompt]], - ) -> Sequence[Prompt]: - """List prompts from the cache, if caching is enabled, and the result is in the cache. Otherwise, - otherwise call the next middleware and store the result in the cache if caching is enabled.""" - if self._list_prompts_settings.get("enabled") is False: - return await call_next(context) - - if cached_value := await self._list_prompts_cache.get(key=GLOBAL_KEY): - return cached_value - - prompts: Sequence[Prompt] = await call_next(context=context) - - # Turn any subclass of Prompt into a Prompt - cachable_prompts: list[Prompt] = [ - Prompt( - name=prompt.name, - title=prompt.title, - description=prompt.description, - tags=prompt.tags, - meta=prompt.meta, - arguments=prompt.arguments, - ) - for prompt in prompts - ] - - await self._list_prompts_cache.put( - key=GLOBAL_KEY, - value=cachable_prompts, - ttl=self._list_prompts_settings.get("ttl", FIVE_MINUTES_IN_SECONDS), - ) - - return cachable_prompts - - @override - async def on_call_tool( - self, - context: MiddlewareContext[mcp.types.CallToolRequestParams], - call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult], - ) -> ToolResult: - """Call a tool from the cache, if caching is enabled, and the result is in the cache. Otherwise, - otherwise call the next middleware and store the result in the cache if caching is enabled.""" - tool_name = context.message.name - - if self._call_tool_settings.get( - "enabled" - ) is False or not self._matches_tool_cache_settings(tool_name=tool_name): - return await call_next(context=context) - - cache_key: str = _make_call_tool_cache_key(msg=context.message) - - if cached_value := await self._call_tool_cache.get(key=cache_key): - return cached_value.unwrap() - - tool_result: ToolResult = await call_next(context=context) - cachable_tool_result: CachableToolResult = CachableToolResult.wrap( - value=tool_result - ) - - await self._call_tool_cache.put( - key=cache_key, - value=cachable_tool_result, - ttl=self._call_tool_settings.get("ttl", ONE_HOUR_IN_SECONDS), - ) - - return cachable_tool_result.unwrap() - - @override - async def on_read_resource( - self, - context: MiddlewareContext[mcp.types.ReadResourceRequestParams], - call_next: CallNext[mcp.types.ReadResourceRequestParams, ResourceResult], - ) -> ResourceResult: - """Read a resource from the cache, if caching is enabled, and the result is in the cache. Otherwise, - otherwise call the next middleware and store the result in the cache if caching is enabled.""" - if self._read_resource_settings.get("enabled") is False: - return await call_next(context=context) - - cache_key: str = _make_read_resource_cache_key(msg=context.message) - cached_value: CachableResourceResult | None - - if cached_value := await self._read_resource_cache.get(key=cache_key): - return cached_value.unwrap() - - value: ResourceResult = await call_next(context=context) - cached_value = CachableResourceResult.wrap(value) - - await self._read_resource_cache.put( - key=cache_key, - value=cached_value, - ttl=self._read_resource_settings.get("ttl", ONE_HOUR_IN_SECONDS), - ) - - return cached_value.unwrap() - - @override - async def on_get_prompt( - self, - context: MiddlewareContext[mcp.types.GetPromptRequestParams], - call_next: CallNext[mcp.types.GetPromptRequestParams, PromptResult], - ) -> PromptResult: - """Get a prompt from the cache, if caching is enabled, and the result is in the cache. Otherwise, - otherwise call the next middleware and store the result in the cache if caching is enabled.""" - if self._get_prompt_settings.get("enabled") is False: - return await call_next(context=context) - - cache_key: str = _make_get_prompt_cache_key(msg=context.message) - - if cached_value := await self._get_prompt_cache.get(key=cache_key): - return cached_value.unwrap() - - value: PromptResult = await call_next(context=context) - cached_value = CachablePromptResult.wrap(value) - - await self._get_prompt_cache.put( - key=cache_key, - value=cached_value, - ttl=self._get_prompt_settings.get("ttl", ONE_HOUR_IN_SECONDS), - ) - - return cached_value.unwrap() - - def _matches_tool_cache_settings(self, tool_name: str) -> bool: - """Check if the tool matches the cache settings for tool calls.""" - - if included_tools := self._call_tool_settings.get("included_tools"): - if tool_name not in included_tools: - return False - - if excluded_tools := self._call_tool_settings.get("excluded_tools"): - if tool_name in excluded_tools: - return False - - return True - - def statistics(self) -> ResponseCachingStatistics: - """Get the statistics for the cache.""" - return ResponseCachingStatistics( - list_tools=self._stats.statistics.collections.get("tools/list"), - list_resources=self._stats.statistics.collections.get("resources/list"), - list_prompts=self._stats.statistics.collections.get("prompts/list"), - read_resource=self._stats.statistics.collections.get("resources/read"), - get_prompt=self._stats.statistics.collections.get("prompts/get"), - call_tool=self._stats.statistics.collections.get("tools/call"), - ) - - -def _get_arguments_str(arguments: dict[str, Any] | None) -> str: - """Get a string representation of the arguments.""" - - if arguments is None: - return "null" - - try: - return pydantic_core.to_json(value=arguments, fallback=str).decode() - - except TypeError: - return repr(arguments) - - -def _hash_cache_key(value: str) -> str: - """Build a fixed-length SHA-256 cache key from request-derived input.""" - - return hashlib.sha256(value.encode()).hexdigest() - - -def _make_call_tool_cache_key(msg: mcp.types.CallToolRequestParams) -> str: - """Make a cache key for a tool call using a stable hash of name and arguments.""" - - return _hash_cache_key(f"{msg.name}:{_get_arguments_str(msg.arguments)}") - - -def _make_read_resource_cache_key(msg: mcp.types.ReadResourceRequestParams) -> str: - """Make a cache key for a resource read using a stable hash of URI.""" - - return _hash_cache_key(str(msg.uri)) - - -def _make_get_prompt_cache_key(msg: mcp.types.GetPromptRequestParams) -> str: - """Make a cache key for a prompt get using a stable hash of name and arguments.""" - - return _hash_cache_key(f"{msg.name}:{_get_arguments_str(msg.arguments)}") diff --git a/src/fastmcp/server/middleware/dereference.py b/src/fastmcp/server/middleware/dereference.py deleted file mode 100644 index 0c27585bc..000000000 --- a/src/fastmcp/server/middleware/dereference.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Middleware that dereferences $ref in JSON schemas before sending to clients.""" - -from collections.abc import Sequence -from typing import Any - -import mcp.types as mt -from typing_extensions import override - -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.tools.base import Tool -from fastmcp.utilities.json_schema import dereference_refs - - -class DereferenceRefsMiddleware(Middleware): - """Dereferences $ref in component schemas before sending to clients. - - Some MCP clients (e.g., VS Code Copilot) don't handle JSON Schema $ref - properly. This middleware inlines all $ref definitions so schemas are - self-contained. Enabled by default via ``FastMCP(dereference_schemas=True)``. - """ - - @override - async def on_list_tools( - self, - context: MiddlewareContext[mt.ListToolsRequest], - call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]], - ) -> Sequence[Tool]: - tools = await call_next(context) - return [_dereference_tool(tool) for tool in tools] - - @override - async def on_list_resource_templates( - self, - context: MiddlewareContext[mt.ListResourceTemplatesRequest], - call_next: CallNext[ - mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate] - ], - ) -> Sequence[ResourceTemplate]: - templates = await call_next(context) - return [_dereference_resource_template(t) for t in templates] - - -def _dereference_tool(tool: Tool) -> Tool: - """Return a copy of the tool with dereferenced schemas.""" - updates: dict[str, object] = {} - if "$defs" in tool.parameters or _has_ref(tool.parameters): - updates["parameters"] = dereference_refs(tool.parameters) - if tool.output_schema is not None and ( - "$defs" in tool.output_schema or _has_ref(tool.output_schema) - ): - updates["output_schema"] = dereference_refs(tool.output_schema) - if updates: - return tool.model_copy(update=updates) - return tool - - -def _dereference_resource_template(template: ResourceTemplate) -> ResourceTemplate: - """Return a copy of the template with dereferenced schemas.""" - if "$defs" in template.parameters or _has_ref(template.parameters): - return template.model_copy( - update={"parameters": dereference_refs(template.parameters)} - ) - return template - - -def _has_ref(schema: dict[str, Any]) -> bool: - """Check if a schema contains any $ref.""" - if "$ref" in schema: - return True - for value in schema.values(): - if isinstance(value, dict) and _has_ref(value): - return True - if isinstance(value, list): - for item in value: - if isinstance(item, dict) and _has_ref(item): - return True - return False diff --git a/src/fastmcp/server/middleware/error_handling.py b/src/fastmcp/server/middleware/error_handling.py deleted file mode 100644 index 5b235e804..000000000 --- a/src/fastmcp/server/middleware/error_handling.py +++ /dev/null @@ -1,216 +0,0 @@ -"""Error handling middleware for consistent error responses and tracking.""" - -import asyncio -import logging -import traceback -from collections.abc import Callable -from typing import Any - -import anyio -from mcp import McpError -from mcp.types import ErrorData - -from fastmcp.exceptions import NotFoundError - -from .middleware import CallNext, Middleware, MiddlewareContext - - -class ErrorHandlingMiddleware(Middleware): - """Middleware that provides consistent error handling and logging. - - Catches exceptions, logs them appropriately, and converts them to - proper MCP error responses. Also tracks error patterns for monitoring. - - Example: - ```python - from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware - import logging - - # Configure logging to see error details - logging.basicConfig(level=logging.ERROR) - - mcp = FastMCP("MyServer") - mcp.add_middleware(ErrorHandlingMiddleware()) - ``` - """ - - def __init__( - self, - logger: logging.Logger | None = None, - include_traceback: bool = False, - error_callback: Callable[[Exception, MiddlewareContext], None] | None = None, - transform_errors: bool = True, - ): - """Initialize error handling middleware. - - Args: - logger: Logger instance for error logging. If None, uses 'fastmcp.errors' - include_traceback: Whether to include full traceback in error logs - error_callback: Optional callback function called for each error - transform_errors: Whether to transform non-MCP errors to McpError - """ - self.logger = logger or logging.getLogger("fastmcp.errors") - self.include_traceback = include_traceback - self.error_callback = error_callback - self.transform_errors = transform_errors - self.error_counts = {} - - def _log_error(self, error: Exception, context: MiddlewareContext) -> None: - """Log error with appropriate detail level.""" - error_type = type(error).__name__ - method = context.method or "unknown" - - # Track error counts - error_key = f"{error_type}:{method}" - self.error_counts[error_key] = self.error_counts.get(error_key, 0) + 1 - - base_message = f"Error in {method}: {error_type}: {error!s}" - - if self.include_traceback: - self.logger.error(f"{base_message}\n{traceback.format_exc()}") - else: - self.logger.error(base_message) - - # Call custom error callback if provided - if self.error_callback: - try: - self.error_callback(error, context) - except Exception as callback_error: - self.logger.error(f"Error in error callback: {callback_error}") - - def _transform_error( - self, error: Exception, context: MiddlewareContext - ) -> Exception: - """Transform non-MCP errors to proper MCP errors.""" - if isinstance(error, McpError): - return error - - if not self.transform_errors: - return error - - # Map common exceptions to appropriate MCP error codes - error_type = type(error.__cause__) if error.__cause__ else type(error) - - if error_type in (ValueError, TypeError): - return McpError( - ErrorData(code=-32602, message=f"Invalid params: {error!s}") - ) - elif error_type in (FileNotFoundError, KeyError, NotFoundError): - # MCP spec defines -32002 specifically for resource not found - method = context.method or "" - if method.startswith("resources/"): - return McpError( - ErrorData(code=-32002, message=f"Resource not found: {error!s}") - ) - return McpError(ErrorData(code=-32001, message=f"Not found: {error!s}")) - elif error_type is PermissionError: - return McpError( - ErrorData(code=-32000, message=f"Permission denied: {error!s}") - ) - # asyncio.TimeoutError is a subclass of TimeoutError in Python 3.10, alias in 3.11+ - elif error_type in (TimeoutError, asyncio.TimeoutError): - return McpError( - ErrorData(code=-32000, message=f"Request timeout: {error!s}") - ) - else: - return McpError( - ErrorData(code=-32603, message=f"Internal error: {error!s}") - ) - - async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any: - """Handle errors for all messages.""" - try: - return await call_next(context) - except Exception as error: - self._log_error(error, context) - - # Transform and re-raise - transformed_error = self._transform_error(error, context) - raise transformed_error from error - - def get_error_stats(self) -> dict[str, int]: - """Get error statistics for monitoring.""" - return self.error_counts.copy() - - -class RetryMiddleware(Middleware): - """Middleware that implements automatic retry logic for failed requests. - - Retries requests that fail with transient errors, using exponential - backoff to avoid overwhelming the server or external dependencies. - - Example: - ```python - from fastmcp.server.middleware.error_handling import RetryMiddleware - - # Retry up to 3 times with exponential backoff - retry_middleware = RetryMiddleware( - max_retries=3, - retry_exceptions=(ConnectionError, TimeoutError) - ) - - mcp = FastMCP("MyServer") - mcp.add_middleware(retry_middleware) - ``` - """ - - def __init__( - self, - max_retries: int = 3, - base_delay: float = 1.0, - max_delay: float = 60.0, - backoff_multiplier: float = 2.0, - retry_exceptions: tuple[type[Exception], ...] = (ConnectionError, TimeoutError), - logger: logging.Logger | None = None, - ): - """Initialize retry middleware. - - Args: - max_retries: Maximum number of retry attempts - base_delay: Initial delay between retries in seconds - max_delay: Maximum delay between retries in seconds - backoff_multiplier: Multiplier for exponential backoff - retry_exceptions: Tuple of exception types that should trigger retries - logger: Logger for retry attempts - """ - self.max_retries = max_retries - self.base_delay = base_delay - self.max_delay = max_delay - self.backoff_multiplier = backoff_multiplier - self.retry_exceptions = retry_exceptions - self.logger = logger or logging.getLogger("fastmcp.retry") - - def _should_retry(self, error: Exception) -> bool: - """Determine if an error should trigger a retry.""" - return isinstance(error, self.retry_exceptions) - - def _calculate_delay(self, attempt: int) -> float: - """Calculate delay for the given attempt number.""" - delay = self.base_delay * (self.backoff_multiplier**attempt) - return min(delay, self.max_delay) - - async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any: - """Implement retry logic for requests.""" - last_error = None - - for attempt in range(self.max_retries + 1): - try: - return await call_next(context) - except Exception as error: - last_error = error - - # Don't retry on the last attempt or if it's not a retryable error - if attempt == self.max_retries or not self._should_retry(error): - break - - delay = self._calculate_delay(attempt) - self.logger.warning( - f"Request {context.method} failed (attempt {attempt + 1}/{self.max_retries + 1}): " - f"{type(error).__name__}: {error!s}. Retrying in {delay:.1f}s..." - ) - - await anyio.sleep(delay) - - # Re-raise the last error if all retries failed - if last_error: - raise last_error diff --git a/src/fastmcp/server/middleware/logging.py b/src/fastmcp/server/middleware/logging.py deleted file mode 100644 index 33825df47..000000000 --- a/src/fastmcp/server/middleware/logging.py +++ /dev/null @@ -1,256 +0,0 @@ -"""Comprehensive logging middleware for FastMCP servers.""" - -import json -import logging -import time -from collections.abc import Callable -from logging import Logger -from typing import Any - -import pydantic_core - -from .middleware import CallNext, Middleware, MiddlewareContext - - -def default_serializer(data: Any) -> str: - """The default serializer for Payloads in the logging middleware.""" - return pydantic_core.to_json(data, fallback=str).decode() - - -class BaseLoggingMiddleware(Middleware): - """Base class for logging middleware.""" - - logger: Logger - log_level: int - include_payloads: bool - include_payload_length: bool - estimate_payload_tokens: bool - max_payload_length: int | None - methods: list[str] | None - structured_logging: bool - payload_serializer: Callable[[Any], str] | None - - def _serialize_payload(self, context: MiddlewareContext[Any]) -> str: - payload: str - - if not self.payload_serializer: - payload = default_serializer(context.message) - else: - try: - payload = self.payload_serializer(context.message) - except Exception as e: - self.logger.warning( - f"Failed to serialize payload due to {e}: {context.type} {context.method} {context.source}." - ) - payload = default_serializer(context.message) - - return payload - - def _format_message(self, message: dict[str, str | int | float]) -> str: - """Format a message for logging.""" - if self.structured_logging: - return json.dumps(message) - else: - return " ".join([f"{k}={v}" for k, v in message.items()]) - - def _create_before_message( - self, context: MiddlewareContext[Any] - ) -> dict[str, str | int | float]: - message: dict[str, str | int | float] = { - "event": context.type + "_start", - "method": context.method or "unknown", - "source": context.source, - } - - if ( - self.include_payloads - or self.include_payload_length - or self.estimate_payload_tokens - ): - payload = self._serialize_payload(context) - - if self.include_payload_length or self.estimate_payload_tokens: - payload_length = len(payload) - payload_tokens = payload_length // 4 - if self.estimate_payload_tokens: - message["payload_tokens"] = payload_tokens - if self.include_payload_length: - message["payload_length"] = payload_length - - if self.max_payload_length and len(payload) > self.max_payload_length: - payload = payload[: self.max_payload_length] + "..." - - if self.include_payloads: - message["payload"] = payload - message["payload_type"] = type(context.message).__name__ - - return message - - def _create_error_message( - self, - context: MiddlewareContext[Any], - start_time: float, - error: Exception, - ) -> dict[str, str | int | float]: - duration_ms: float = _get_duration_ms(start_time) - message = { - "event": context.type + "_error", - "method": context.method or "unknown", - "source": context.source, - "duration_ms": duration_ms, - "error": str(object=error), - } - return message - - def _create_after_message( - self, - context: MiddlewareContext[Any], - start_time: float, - ) -> dict[str, str | int | float]: - duration_ms: float = _get_duration_ms(start_time) - message = { - "event": context.type + "_success", - "method": context.method or "unknown", - "source": context.source, - "duration_ms": duration_ms, - } - return message - - def _log_message( - self, message: dict[str, str | int | float], log_level: int | None = None - ): - self.logger.log(log_level or self.log_level, self._format_message(message)) - - async def on_message( - self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any] - ) -> Any: - """Log messages for configured methods.""" - - if self.methods and context.method not in self.methods: - return await call_next(context) - - self._log_message(self._create_before_message(context)) - - start_time = time.perf_counter() - try: - result = await call_next(context) - - self._log_message(self._create_after_message(context, start_time)) - - return result - except Exception as e: - self._log_message( - self._create_error_message(context, start_time, e), logging.ERROR - ) - raise - - -class LoggingMiddleware(BaseLoggingMiddleware): - """Middleware that provides comprehensive request and response logging. - - Logs all MCP messages with configurable detail levels. Useful for debugging, - monitoring, and understanding server usage patterns. - - Example: - ```python - from fastmcp.server.middleware.logging import LoggingMiddleware - import logging - - # Configure logging - logging.basicConfig(level=logging.INFO) - - mcp = FastMCP("MyServer") - mcp.add_middleware(LoggingMiddleware()) - ``` - """ - - def __init__( - self, - *, - logger: logging.Logger | None = None, - log_level: int = logging.INFO, - include_payloads: bool = False, - include_payload_length: bool = False, - estimate_payload_tokens: bool = False, - max_payload_length: int = 1000, - methods: list[str] | None = None, - payload_serializer: Callable[[Any], str] | None = None, - ): - """Initialize logging middleware. - - Args: - logger: Logger instance to use. If None, creates a logger named 'fastmcp.requests' - log_level: Log level for messages (default: INFO) - include_payloads: Whether to include message payloads in logs - include_payload_length: Whether to include response size in logs - estimate_payload_tokens: Whether to estimate response tokens - max_payload_length: Maximum length of payload to log (prevents huge logs) - methods: List of methods to log. If None, logs all methods. - payload_serializer: Callable that converts objects to a JSON string for the - payload. If not provided, uses FastMCP's default tool serializer. - """ - self.logger: Logger = logger or logging.getLogger("fastmcp.middleware.logging") - self.log_level = log_level - self.include_payloads: bool = include_payloads - self.include_payload_length: bool = include_payload_length - self.estimate_payload_tokens: bool = estimate_payload_tokens - self.max_payload_length: int = max_payload_length - self.methods: list[str] | None = methods - self.payload_serializer: Callable[[Any], str] | None = payload_serializer - self.structured_logging: bool = False - - -class StructuredLoggingMiddleware(BaseLoggingMiddleware): - """Middleware that provides structured JSON logging for better log analysis. - - Outputs structured logs that are easier to parse and analyze with log - aggregation tools like ELK stack, Splunk, or cloud logging services. - - Example: - ```python - from fastmcp.server.middleware.logging import StructuredLoggingMiddleware - import logging - - mcp = FastMCP("MyServer") - mcp.add_middleware(StructuredLoggingMiddleware()) - ``` - """ - - def __init__( - self, - *, - logger: logging.Logger | None = None, - log_level: int = logging.INFO, - include_payloads: bool = False, - include_payload_length: bool = False, - estimate_payload_tokens: bool = False, - methods: list[str] | None = None, - payload_serializer: Callable[[Any], str] | None = None, - ): - """Initialize structured logging middleware. - - Args: - logger: Logger instance to use. If None, creates a logger named 'fastmcp.structured' - log_level: Log level for messages (default: INFO) - include_payloads: Whether to include message payloads in logs - include_payload_length: Whether to include payload size in logs - estimate_payload_tokens: Whether to estimate token count using length // 4 - methods: List of methods to log. If None, logs all methods. - payload_serializer: Callable that converts objects to a JSON string for the - payload. If not provided, uses FastMCP's default tool serializer. - """ - self.logger: Logger = logger or logging.getLogger( - "fastmcp.middleware.structured_logging" - ) - self.log_level: int = log_level - self.include_payloads: bool = include_payloads - self.include_payload_length: bool = include_payload_length - self.estimate_payload_tokens: bool = estimate_payload_tokens - self.methods: list[str] | None = methods - self.payload_serializer: Callable[[Any], str] | None = payload_serializer - self.max_payload_length: int | None = None - self.structured_logging: bool = True - - -def _get_duration_ms(start_time: float, /) -> float: - return round(number=(time.perf_counter() - start_time) * 1000, ndigits=2) diff --git a/src/fastmcp/server/middleware/middleware.py b/src/fastmcp/server/middleware/middleware.py deleted file mode 100644 index ce7f13567..000000000 --- a/src/fastmcp/server/middleware/middleware.py +++ /dev/null @@ -1,205 +0,0 @@ -from __future__ import annotations - -import logging -from collections.abc import Awaitable, Sequence -from dataclasses import dataclass, field, replace -from datetime import datetime, timezone -from functools import partial -from typing import ( - TYPE_CHECKING, - Any, - Generic, - Literal, - Protocol, - runtime_checkable, -) - -import mcp.types as mt -from typing_extensions import TypeVar - -from fastmcp.prompts.base import Prompt, PromptResult -from fastmcp.resources.base import Resource, ResourceResult -from fastmcp.resources.template import ResourceTemplate -from fastmcp.tools.base import Tool, ToolResult - -if TYPE_CHECKING: - from fastmcp.server.context import Context - -__all__ = [ - "CallNext", - "Middleware", - "MiddlewareContext", -] - -logger = logging.getLogger(__name__) - - -T = TypeVar("T", default=Any) -R = TypeVar("R", covariant=True, default=Any) - - -@runtime_checkable -class CallNext(Protocol[T, R]): - def __call__(self, context: MiddlewareContext[T]) -> Awaitable[R]: ... - - -@dataclass(kw_only=True, frozen=True) -class MiddlewareContext(Generic[T]): - """ - Unified context for all middleware operations. - """ - - message: T - - fastmcp_context: Context | None = None - - # Common metadata - source: Literal["client", "server"] = "client" - type: Literal["request", "notification"] = "request" - method: str | None = None - timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - - def copy(self, **kwargs: Any) -> MiddlewareContext[T]: - return replace(self, **kwargs) - - -def make_middleware_wrapper( - middleware: Middleware, call_next: CallNext[T, R] -) -> CallNext[T, R]: - """Create a wrapper that applies a single middleware to a context. The - closure bakes in the middleware and call_next function, so it can be - passed to other functions that expect a call_next function.""" - - async def wrapper(context: MiddlewareContext[T]) -> R: - return await middleware(context, call_next) - - return wrapper - - -class Middleware: - """Base class for FastMCP middleware with dispatching hooks.""" - - async def __call__( - self, - context: MiddlewareContext[T], - call_next: CallNext[T, Any], - ) -> Any: - """Main entry point that orchestrates the pipeline.""" - handler_chain = await self._dispatch_handler( - context, - call_next=call_next, - ) - return await handler_chain(context) - - async def _dispatch_handler( - self, context: MiddlewareContext[Any], call_next: CallNext[Any, Any] - ) -> CallNext[Any, Any]: - """Builds a chain of handlers for a given message.""" - handler = call_next - - match context.method: - case "initialize": - handler = partial(self.on_initialize, call_next=handler) - case "tools/call": - handler = partial(self.on_call_tool, call_next=handler) - case "resources/read": - handler = partial(self.on_read_resource, call_next=handler) - case "prompts/get": - handler = partial(self.on_get_prompt, call_next=handler) - case "tools/list": - handler = partial(self.on_list_tools, call_next=handler) - case "resources/list": - handler = partial(self.on_list_resources, call_next=handler) - case "resources/templates/list": - handler = partial(self.on_list_resource_templates, call_next=handler) - case "prompts/list": - handler = partial(self.on_list_prompts, call_next=handler) - - match context.type: - case "request": - handler = partial(self.on_request, call_next=handler) - case "notification": - handler = partial(self.on_notification, call_next=handler) - - handler = partial(self.on_message, call_next=handler) - - return handler - - async def on_message( - self, - context: MiddlewareContext[Any], - call_next: CallNext[Any, Any], - ) -> Any: - return await call_next(context) - - async def on_request( - self, - context: MiddlewareContext[mt.Request[Any, Any]], - call_next: CallNext[mt.Request[Any, Any], Any], - ) -> Any: - return await call_next(context) - - async def on_notification( - self, - context: MiddlewareContext[mt.Notification[Any, Any]], - call_next: CallNext[mt.Notification[Any, Any], Any], - ) -> Any: - return await call_next(context) - - async def on_initialize( - self, - context: MiddlewareContext[mt.InitializeRequest], - call_next: CallNext[mt.InitializeRequest, mt.InitializeResult | None], - ) -> mt.InitializeResult | None: - return await call_next(context) - - async def on_call_tool( - self, - context: MiddlewareContext[mt.CallToolRequestParams], - call_next: CallNext[mt.CallToolRequestParams, ToolResult], - ) -> ToolResult: - return await call_next(context) - - async def on_read_resource( - self, - context: MiddlewareContext[mt.ReadResourceRequestParams], - call_next: CallNext[mt.ReadResourceRequestParams, ResourceResult], - ) -> ResourceResult: - return await call_next(context) - - async def on_get_prompt( - self, - context: MiddlewareContext[mt.GetPromptRequestParams], - call_next: CallNext[mt.GetPromptRequestParams, PromptResult], - ) -> PromptResult: - return await call_next(context) - - async def on_list_tools( - self, - context: MiddlewareContext[mt.ListToolsRequest], - call_next: CallNext[mt.ListToolsRequest, Sequence[Tool]], - ) -> Sequence[Tool]: - return await call_next(context) - - async def on_list_resources( - self, - context: MiddlewareContext[mt.ListResourcesRequest], - call_next: CallNext[mt.ListResourcesRequest, Sequence[Resource]], - ) -> Sequence[Resource]: - return await call_next(context) - - async def on_list_resource_templates( - self, - context: MiddlewareContext[mt.ListResourceTemplatesRequest], - call_next: CallNext[ - mt.ListResourceTemplatesRequest, Sequence[ResourceTemplate] - ], - ) -> Sequence[ResourceTemplate]: - return await call_next(context) - - async def on_list_prompts( - self, - context: MiddlewareContext[mt.ListPromptsRequest], - call_next: CallNext[mt.ListPromptsRequest, Sequence[Prompt]], - ) -> Sequence[Prompt]: - return await call_next(context) diff --git a/src/fastmcp/server/middleware/ping.py b/src/fastmcp/server/middleware/ping.py deleted file mode 100644 index a8e35bf2d..000000000 --- a/src/fastmcp/server/middleware/ping.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Ping middleware for keeping client connections alive.""" - -from typing import Any - -import anyio - -from .middleware import CallNext, Middleware, MiddlewareContext - - -class PingMiddleware(Middleware): - """Middleware that sends periodic pings to keep client connections alive. - - Starts a background ping task on first message from each session. The task - sends server-to-client pings at the configured interval until the session - ends. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.middleware import PingMiddleware - - mcp = FastMCP("MyServer") - mcp.add_middleware(PingMiddleware(interval_ms=5000)) - ``` - """ - - def __init__(self, interval_ms: int = 30000): - """Initialize ping middleware. - - Args: - interval_ms: Interval between pings in milliseconds (default: 30000) - - Raises: - ValueError: If interval_ms is not positive - """ - if interval_ms <= 0: - raise ValueError("interval_ms must be positive") - self.interval_ms = interval_ms - self._active_sessions: set[int] = set() - self._lock = anyio.Lock() - - async def on_message(self, context: MiddlewareContext, call_next: CallNext) -> Any: - """Start ping task on first message from a session.""" - if ( - context.fastmcp_context is None - or context.fastmcp_context.request_context is None - ): - return await call_next(context) - - session = context.fastmcp_context.session - session_id = id(session) - - async with self._lock: - if session_id not in self._active_sessions: - # _subscription_task_group is added by MiddlewareServerSession - tg = session._subscription_task_group # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - if tg is not None: - self._active_sessions.add(session_id) - tg.start_soon(self._ping_loop, session, session_id) - - return await call_next(context) - - async def _ping_loop(self, session: Any, session_id: int) -> None: - """Send periodic pings until session ends.""" - try: - while True: - await anyio.sleep(self.interval_ms / 1000) - await session.send_ping() - finally: - self._active_sessions.discard(session_id) diff --git a/src/fastmcp/server/middleware/rate_limiting.py b/src/fastmcp/server/middleware/rate_limiting.py deleted file mode 100644 index 703fd393a..000000000 --- a/src/fastmcp/server/middleware/rate_limiting.py +++ /dev/null @@ -1,231 +0,0 @@ -"""Rate limiting middleware for protecting FastMCP servers from abuse.""" - -import time -from collections import defaultdict, deque -from collections.abc import Callable -from typing import Any - -import anyio -from mcp import McpError -from mcp.types import ErrorData - -from .middleware import CallNext, Middleware, MiddlewareContext - - -class RateLimitError(McpError): - """Error raised when rate limit is exceeded.""" - - def __init__(self, message: str = "Rate limit exceeded"): - super().__init__(ErrorData(code=-32000, message=message)) - - -class TokenBucketRateLimiter: - """Token bucket implementation for rate limiting.""" - - def __init__(self, capacity: int, refill_rate: float): - """Initialize token bucket. - - Args: - capacity: Maximum number of tokens in the bucket - refill_rate: Tokens added per second - """ - self.capacity = capacity - self.refill_rate = refill_rate - self.tokens = capacity - self.last_refill = time.time() - self._lock = anyio.Lock() - - async def consume(self, tokens: int = 1) -> bool: - """Try to consume tokens from the bucket. - - Args: - tokens: Number of tokens to consume - - Returns: - True if tokens were available and consumed, False otherwise - """ - async with self._lock: - now = time.time() - elapsed = now - self.last_refill - - # Add tokens based on elapsed time - self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate) - self.last_refill = now - - if self.tokens >= tokens: - self.tokens -= tokens - return True - return False - - -class SlidingWindowRateLimiter: - """Sliding window rate limiter implementation.""" - - def __init__(self, max_requests: int, window_seconds: int): - """Initialize sliding window rate limiter. - - Args: - max_requests: Maximum requests allowed in the time window - window_seconds: Time window in seconds - """ - self.max_requests = max_requests - self.window_seconds = window_seconds - self.requests = deque() - self._lock = anyio.Lock() - - async def is_allowed(self) -> bool: - """Check if a request is allowed.""" - async with self._lock: - now = time.time() - cutoff = now - self.window_seconds - - # Remove old requests outside the window - while self.requests and self.requests[0] < cutoff: - self.requests.popleft() - - if len(self.requests) < self.max_requests: - self.requests.append(now) - return True - return False - - -class RateLimitingMiddleware(Middleware): - """Middleware that implements rate limiting to prevent server abuse. - - Uses a token bucket algorithm by default, allowing for burst traffic - while maintaining a sustainable long-term rate. - - Example: - ```python - from fastmcp.server.middleware.rate_limiting import RateLimitingMiddleware - - # Allow 10 requests per second with bursts up to 20 - rate_limiter = RateLimitingMiddleware( - max_requests_per_second=10, - burst_capacity=20 - ) - - mcp = FastMCP("MyServer") - mcp.add_middleware(rate_limiter) - ``` - """ - - def __init__( - self, - max_requests_per_second: float = 10.0, - burst_capacity: int | None = None, - get_client_id: Callable[[MiddlewareContext], str] | None = None, - global_limit: bool = False, - ): - """Initialize rate limiting middleware. - - Args: - max_requests_per_second: Sustained requests per second allowed - burst_capacity: Maximum burst capacity. If None, defaults to 2x max_requests_per_second - get_client_id: Function to extract client ID from context. If None, uses global limiting - global_limit: If True, apply limit globally; if False, per-client - """ - self.max_requests_per_second = max_requests_per_second - self.burst_capacity = burst_capacity or int(max_requests_per_second * 2) - self.get_client_id = get_client_id - self.global_limit = global_limit - - # Storage for rate limiters per client - self.limiters: dict[str, TokenBucketRateLimiter] = defaultdict( - lambda: TokenBucketRateLimiter( - self.burst_capacity, self.max_requests_per_second - ) - ) - - # Global rate limiter - if self.global_limit: - self.global_limiter = TokenBucketRateLimiter( - self.burst_capacity, self.max_requests_per_second - ) - - def _get_client_identifier(self, context: MiddlewareContext) -> str: - """Get client identifier for rate limiting.""" - if self.get_client_id: - return self.get_client_id(context) - return "global" - - async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any: - """Apply rate limiting to requests.""" - if self.global_limit: - # Global rate limiting - allowed = await self.global_limiter.consume() - if not allowed: - raise RateLimitError("Global rate limit exceeded") - else: - # Per-client rate limiting - client_id = self._get_client_identifier(context) - limiter = self.limiters[client_id] - allowed = await limiter.consume() - if not allowed: - raise RateLimitError(f"Rate limit exceeded for client: {client_id}") - - return await call_next(context) - - -class SlidingWindowRateLimitingMiddleware(Middleware): - """Middleware that implements sliding window rate limiting. - - Uses a sliding window approach which provides more precise rate limiting - but uses more memory to track individual request timestamps. - - Example: - ```python - from fastmcp.server.middleware.rate_limiting import SlidingWindowRateLimitingMiddleware - - # Allow 100 requests per minute - rate_limiter = SlidingWindowRateLimitingMiddleware( - max_requests=100, - window_minutes=1 - ) - - mcp = FastMCP("MyServer") - mcp.add_middleware(rate_limiter) - ``` - """ - - def __init__( - self, - max_requests: int, - window_minutes: int = 1, - get_client_id: Callable[[MiddlewareContext], str] | None = None, - ): - """Initialize sliding window rate limiting middleware. - - Args: - max_requests: Maximum requests allowed in the time window - window_minutes: Time window in minutes - get_client_id: Function to extract client ID from context - """ - self.max_requests = max_requests - self.window_seconds = window_minutes * 60 - self.get_client_id = get_client_id - - # Storage for rate limiters per client - self.limiters: dict[str, SlidingWindowRateLimiter] = defaultdict( - lambda: SlidingWindowRateLimiter(self.max_requests, self.window_seconds) - ) - - def _get_client_identifier(self, context: MiddlewareContext) -> str: - """Get client identifier for rate limiting.""" - if self.get_client_id: - return self.get_client_id(context) - return "global" - - async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any: - """Apply sliding window rate limiting to requests.""" - client_id = self._get_client_identifier(context) - limiter = self.limiters[client_id] - - allowed = await limiter.is_allowed() - if not allowed: - raise RateLimitError( - f"Rate limit exceeded: {self.max_requests} requests per " - f"{self.window_seconds // 60} minutes for client: {client_id}" - ) - - return await call_next(context) diff --git a/src/fastmcp/server/middleware/response_limiting.py b/src/fastmcp/server/middleware/response_limiting.py deleted file mode 100644 index 3afaf0705..000000000 --- a/src/fastmcp/server/middleware/response_limiting.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Response limiting middleware for controlling tool response sizes.""" - -from __future__ import annotations - -import logging - -import mcp.types as mt -import pydantic_core -from mcp.types import TextContent - -from fastmcp.tools.base import ToolResult - -from .middleware import CallNext, Middleware, MiddlewareContext - -__all__ = ["ResponseLimitingMiddleware"] - -logger = logging.getLogger(__name__) - - -class ResponseLimitingMiddleware(Middleware): - """Middleware that limits the response size of tool calls. - - Intercepts tool call responses and enforces size limits. If a response - exceeds the limit, it extracts text content, truncates it, and returns - a single TextContent block. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.middleware.response_limiting import ( - ResponseLimitingMiddleware, - ) - - mcp = FastMCP("MyServer") - - # Limit all tool responses to 500KB - mcp.add_middleware(ResponseLimitingMiddleware(max_size=500_000)) - - # Limit only specific tools - mcp.add_middleware( - ResponseLimitingMiddleware( - max_size=100_000, - tools=["search", "fetch_data"], - ) - ) - ``` - """ - - def __init__( - self, - *, - max_size: int = 1_000_000, - truncation_suffix: str = "\n\n[Response truncated due to size limit]", - tools: list[str] | None = None, - ) -> None: - """Initialize response limiting middleware. - - Args: - max_size: Maximum response size in bytes. Defaults to 1MB (1,000,000). - truncation_suffix: Suffix to append when truncating responses. - Defaults to "\\n\\n[Response truncated due to size limit]". - tools: List of tool names to apply limiting to. If None, applies to all. - """ - if max_size <= 0: - raise ValueError(f"max_size must be positive, got {max_size}") - self.max_size = max_size - self.truncation_suffix = truncation_suffix - self.tools = set(tools) if tools is not None else None - - def _truncate_to_result(self, text: str) -> ToolResult: - """Truncate text to fit within max_size and wrap in ToolResult.""" - suffix_bytes = len(self.truncation_suffix.encode("utf-8")) - # Account for JSON wrapper overhead: {"content":[{"type":"text","text":"..."}]} - overhead = 50 - target_size = self.max_size - suffix_bytes - overhead - - if target_size <= 0: - # Edge case: max_size too small for even the suffix - truncated = self.truncation_suffix - else: - # Truncate to target size, preserving UTF-8 boundaries - encoded = text.encode("utf-8") - if len(encoded) <= target_size: - truncated = text + self.truncation_suffix - else: - truncated = ( - encoded[:target_size].decode("utf-8", errors="ignore") - + self.truncation_suffix - ) - - return ToolResult(content=[TextContent(type="text", text=truncated)]) - - async def on_call_tool( - self, - context: MiddlewareContext[mt.CallToolRequestParams], - call_next: CallNext[mt.CallToolRequestParams, ToolResult], - ) -> ToolResult: - """Intercept tool calls and limit response size.""" - result = await call_next(context) - - # Check if we should limit this tool - if self.tools is not None and context.message.name not in self.tools: - return result - - # Measure serialized size - serialized = pydantic_core.to_json(result, fallback=str) - if len(serialized) <= self.max_size: - return result - - # Over limit: extract text, truncate, return single TextContent - logger.warning( - "Tool %r response exceeds size limit: %d bytes > %d bytes, truncating", - context.message.name, - len(serialized), - self.max_size, - ) - - texts = [b.text for b in result.content if isinstance(b, TextContent)] - text = ( - "\n\n".join(texts) - if texts - else serialized.decode("utf-8", errors="replace") - ) - - return self._truncate_to_result(text) diff --git a/src/fastmcp/server/middleware/timing.py b/src/fastmcp/server/middleware/timing.py deleted file mode 100644 index 178b3b250..000000000 --- a/src/fastmcp/server/middleware/timing.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Timing middleware for measuring and logging request performance.""" - -import logging -import time -from typing import Any - -from .middleware import CallNext, Middleware, MiddlewareContext - - -class TimingMiddleware(Middleware): - """Middleware that logs the execution time of requests. - - Only measures and logs timing for request messages (not notifications). - Provides insights into performance characteristics of your MCP server. - - Example: - ```python - from fastmcp.server.middleware.timing import TimingMiddleware - - mcp = FastMCP("MyServer") - mcp.add_middleware(TimingMiddleware()) - - # Now all requests will be timed and logged - ``` - """ - - def __init__( - self, logger: logging.Logger | None = None, log_level: int = logging.INFO - ): - """Initialize timing middleware. - - Args: - logger: Logger instance to use. If None, creates a logger named 'fastmcp.timing' - log_level: Log level for timing messages (default: INFO) - """ - self.logger = logger or logging.getLogger("fastmcp.timing") - self.log_level = log_level - - async def on_request(self, context: MiddlewareContext, call_next: CallNext) -> Any: - """Time request execution and log the results.""" - method = context.method or "unknown" - - start_time = time.perf_counter() - try: - result = await call_next(context) - duration_ms = (time.perf_counter() - start_time) * 1000 - self.logger.log( - self.log_level, f"Request {method} completed in {duration_ms:.2f}ms" - ) - return result - except Exception as e: - duration_ms = (time.perf_counter() - start_time) * 1000 - self.logger.log( - self.log_level, - f"Request {method} failed after {duration_ms:.2f}ms: {e}", - ) - raise - - -class DetailedTimingMiddleware(Middleware): - """Enhanced timing middleware with per-operation breakdowns. - - Provides detailed timing information for different types of MCP operations, - allowing you to identify performance bottlenecks in specific operations. - - Example: - ```python - from fastmcp.server.middleware.timing import DetailedTimingMiddleware - import logging - - # Configure logging to see the output - logging.basicConfig(level=logging.INFO) - - mcp = FastMCP("MyServer") - mcp.add_middleware(DetailedTimingMiddleware()) - ``` - """ - - def __init__( - self, logger: logging.Logger | None = None, log_level: int = logging.INFO - ): - """Initialize detailed timing middleware. - - Args: - logger: Logger instance to use. If None, creates a logger named 'fastmcp.timing.detailed' - log_level: Log level for timing messages (default: INFO) - """ - self.logger = logger or logging.getLogger("fastmcp.timing.detailed") - self.log_level = log_level - - async def _time_operation( - self, context: MiddlewareContext, call_next: CallNext, operation_name: str - ) -> Any: - """Helper method to time any operation.""" - start_time = time.perf_counter() - try: - result = await call_next(context) - duration_ms = (time.perf_counter() - start_time) * 1000 - self.logger.log( - self.log_level, f"{operation_name} completed in {duration_ms:.2f}ms" - ) - return result - except Exception as e: - duration_ms = (time.perf_counter() - start_time) * 1000 - self.logger.log( - self.log_level, - f"{operation_name} failed after {duration_ms:.2f}ms: {e}", - ) - raise - - async def on_call_tool( - self, context: MiddlewareContext, call_next: CallNext - ) -> Any: - """Time tool execution.""" - tool_name = getattr(context.message, "name", "unknown") - return await self._time_operation(context, call_next, f"Tool '{tool_name}'") - - async def on_read_resource( - self, context: MiddlewareContext, call_next: CallNext - ) -> Any: - """Time resource reading.""" - resource_uri = getattr(context.message, "uri", "unknown") - return await self._time_operation( - context, call_next, f"Resource '{resource_uri}'" - ) - - async def on_get_prompt( - self, context: MiddlewareContext, call_next: CallNext - ) -> Any: - """Time prompt retrieval.""" - prompt_name = getattr(context.message, "name", "unknown") - return await self._time_operation(context, call_next, f"Prompt '{prompt_name}'") - - async def on_list_tools( - self, context: MiddlewareContext, call_next: CallNext - ) -> Any: - """Time tool listing.""" - return await self._time_operation(context, call_next, "List tools") - - async def on_list_resources( - self, context: MiddlewareContext, call_next: CallNext - ) -> Any: - """Time resource listing.""" - return await self._time_operation(context, call_next, "List resources") - - async def on_list_resource_templates( - self, context: MiddlewareContext, call_next: CallNext - ) -> Any: - """Time resource template listing.""" - return await self._time_operation(context, call_next, "List resource templates") - - async def on_list_prompts( - self, context: MiddlewareContext, call_next: CallNext - ) -> Any: - """Time prompt listing.""" - return await self._time_operation(context, call_next, "List prompts") diff --git a/src/fastmcp/server/middleware/tool_injection.py b/src/fastmcp/server/middleware/tool_injection.py deleted file mode 100644 index 7dfd59694..000000000 --- a/src/fastmcp/server/middleware/tool_injection.py +++ /dev/null @@ -1,141 +0,0 @@ -"""A middleware for injecting tools into the MCP server context.""" - -import warnings -from collections.abc import Sequence -from logging import Logger -from typing import Annotated, Any - -import mcp.types -from mcp.types import Prompt -from pydantic import AnyUrl -from typing_extensions import override - -import fastmcp -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.resources.base import ResourceResult -from fastmcp.server.context import Context -from fastmcp.server.middleware.middleware import CallNext, Middleware, MiddlewareContext -from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.logging import get_logger - -logger: Logger = get_logger(name=__name__) - - -class ToolInjectionMiddleware(Middleware): - """A middleware for injecting tools into the context.""" - - def __init__(self, tools: Sequence[Tool]): - """Initialize the tool injection middleware.""" - self._tools_to_inject: Sequence[Tool] = tools - self._tools_to_inject_by_name: dict[str, Tool] = { - tool.name: tool for tool in tools - } - - @override - async def on_list_tools( - self, - context: MiddlewareContext[mcp.types.ListToolsRequest], - call_next: CallNext[mcp.types.ListToolsRequest, Sequence[Tool]], - ) -> Sequence[Tool]: - """Inject tools into the response.""" - return [*self._tools_to_inject, *await call_next(context)] - - @override - async def on_call_tool( - self, - context: MiddlewareContext[mcp.types.CallToolRequestParams], - call_next: CallNext[mcp.types.CallToolRequestParams, ToolResult], - ) -> ToolResult: - """Intercept tool calls to injected tools.""" - if context.message.name in self._tools_to_inject_by_name: - tool = self._tools_to_inject_by_name[context.message.name] - return await tool.run(arguments=context.message.arguments or {}) - - return await call_next(context) - - -async def list_prompts(context: Context) -> list[Prompt]: - """List prompts available on the server.""" - return await context.list_prompts() - - -list_prompts_tool = Tool.from_function( - fn=list_prompts, -) - - -async def get_prompt( - context: Context, - name: Annotated[str, "The name of the prompt to render."], - arguments: Annotated[ - dict[str, Any] | None, "The arguments to pass to the prompt." - ] = None, -) -> mcp.types.GetPromptResult: - """Render a prompt available on the server.""" - return await context.get_prompt(name=name, arguments=arguments) - - -get_prompt_tool = Tool.from_function( - fn=get_prompt, -) - - -class PromptToolMiddleware(ToolInjectionMiddleware): - """A middleware for injecting prompts as tools into the context. - - .. deprecated:: - Use ``fastmcp.server.transforms.PromptsAsTools`` instead. - """ - - def __init__(self) -> None: - if fastmcp.settings.deprecation_warnings: - warnings.warn( - "PromptToolMiddleware is deprecated. Use the PromptsAsTools transform instead: " - "from fastmcp.server.transforms import PromptsAsTools", - FastMCPDeprecationWarning, - stacklevel=2, - ) - tools: list[Tool] = [list_prompts_tool, get_prompt_tool] - super().__init__(tools=tools) - - -async def list_resources(context: Context) -> list[mcp.types.Resource]: - """List resources available on the server.""" - return await context.list_resources() - - -list_resources_tool = Tool.from_function( - fn=list_resources, -) - - -async def read_resource( - context: Context, - uri: Annotated[AnyUrl | str, "The URI of the resource to read."], -) -> ResourceResult: - """Read a resource available on the server.""" - return await context.read_resource(uri=uri) - - -read_resource_tool = Tool.from_function( - fn=read_resource, -) - - -class ResourceToolMiddleware(ToolInjectionMiddleware): - """A middleware for injecting resources as tools into the context. - - .. deprecated:: - Use ``fastmcp.server.transforms.ResourcesAsTools`` instead. - """ - - def __init__(self) -> None: - if fastmcp.settings.deprecation_warnings: - warnings.warn( - "ResourceToolMiddleware is deprecated. Use the ResourcesAsTools transform instead: " - "from fastmcp.server.transforms import ResourcesAsTools", - FastMCPDeprecationWarning, - stacklevel=2, - ) - tools: list[Tool] = [list_resources_tool, read_resource_tool] - super().__init__(tools=tools) diff --git a/src/fastmcp/server/mixins/__init__.py b/src/fastmcp/server/mixins/__init__.py deleted file mode 100644 index 3df6e8d36..000000000 --- a/src/fastmcp/server/mixins/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Server mixins for FastMCP.""" - -from fastmcp.server.mixins.lifespan import LifespanMixin -from fastmcp.server.mixins.mcp_operations import MCPOperationsMixin -from fastmcp.server.mixins.transport import TransportMixin - -__all__ = ["LifespanMixin", "MCPOperationsMixin", "TransportMixin"] diff --git a/src/fastmcp/server/mixins/lifespan.py b/src/fastmcp/server/mixins/lifespan.py deleted file mode 100644 index a6c0cb9ed..000000000 --- a/src/fastmcp/server/mixins/lifespan.py +++ /dev/null @@ -1,251 +0,0 @@ -"""Lifespan and Docket task infrastructure for FastMCP Server.""" - -from __future__ import annotations - -import asyncio -import weakref -from collections.abc import AsyncIterator -from contextlib import AsyncExitStack, asynccontextmanager, suppress -from typing import TYPE_CHECKING, Any - -import anyio -from uncalled_for import SharedContext - -import fastmcp -from fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - from docket import Docket - - from fastmcp.server.server import FastMCP - -logger = get_logger(__name__) - - -class LifespanMixin: - """Mixin providing lifespan and Docket task infrastructure for FastMCP.""" - - @property - def docket(self: FastMCP) -> Docket | None: - """Get the Docket instance if Docket support is enabled. - - Returns None if Docket is not enabled or server hasn't been started yet. - """ - return self._docket - - @asynccontextmanager - async def _docket_lifespan(self: FastMCP) -> AsyncIterator[None]: - """Manage Docket instance and Worker for background task execution. - - Docket infrastructure is only initialized if: - 1. pydocket is installed (fastmcp[tasks] extra) - 2. There are task-enabled components (task_config.mode != 'forbidden') - - This means users with pydocket installed but no task-enabled components - won't spin up Docket/Worker infrastructure. - """ - from fastmcp.server.dependencies import _current_server, is_docket_available - - # Set FastMCP server in ContextVar so CurrentFastMCP can access it - # (use weakref to avoid reference cycles) - server_token = _current_server.set(weakref.ref(self)) - - try: - # If docket is not available, skip task infrastructure but still - # set up SharedContext so Shared() dependencies work. - if not is_docket_available(): - async with SharedContext(): - yield - return - - # Collect task-enabled components at startup with all transforms applied. - # Components must be available now to be registered with Docket workers; - # dynamically added components after startup won't be registered. - try: - task_components = list(await self.get_tasks()) - except Exception as e: - logger.warning(f"Failed to get tasks: {e}") - if fastmcp.settings.mounted_components_raise_on_load_error: - raise - task_components = [] - - # If no task-enabled components, skip Docket infrastructure but still - # set up SharedContext so Shared() dependencies work. - if not task_components: - async with SharedContext(): - yield - return - - # Docket is available AND there are task-enabled components - from docket import Docket, Worker - - from fastmcp import settings - from fastmcp.server.dependencies import ( - _current_docket, - _current_worker, - ) - - # Create Docket instance using configured name and URL - async with Docket( - name=settings.docket.name, - url=settings.docket.url, - ) as docket: - # Store on server instance for cross-task access (FastMCPTransport) - self._docket = docket - - # Register task-enabled components with Docket - for component in task_components: - component.register_with_docket(docket) - - # Set Docket in ContextVar so CurrentDocket can access it - docket_token = _current_docket.set(docket) - try: - # Build worker kwargs from settings - worker_kwargs: dict[str, Any] = { - "concurrency": settings.docket.concurrency, - "redelivery_timeout": settings.docket.redelivery_timeout, - "reconnection_delay": settings.docket.reconnection_delay, - "minimum_check_interval": settings.docket.minimum_check_interval, - } - if settings.docket.worker_name: - worker_kwargs["name"] = settings.docket.worker_name - - # Create and start Worker - async with Worker(docket, **worker_kwargs) as worker: - # Store on server instance for cross-context access - self._worker = worker - # Set Worker in ContextVar so CurrentWorker can access it - worker_token = _current_worker.set(worker) - try: - worker_task = asyncio.create_task(worker.run_forever()) - try: - yield - finally: - worker_task.cancel() - with suppress(asyncio.CancelledError): - await worker_task - finally: - _current_worker.reset(worker_token) - self._worker = None - finally: - # Reset ContextVar - _current_docket.reset(docket_token) - # Clear instance attribute - self._docket = None - finally: - # Reset server ContextVar - _current_server.reset(server_token) - - @asynccontextmanager - async def _lifespan_manager(self: FastMCP) -> AsyncIterator[None]: - async with self._lifespan_lock: - if self._lifespan_result_set: - self._lifespan_ref_count += 1 - should_enter_lifespan = False - else: - self._lifespan_ref_count = 1 - should_enter_lifespan = True - - if not should_enter_lifespan: - try: - yield - finally: - async with self._lifespan_lock: - self._lifespan_ref_count -= 1 - if self._lifespan_ref_count == 0: - self._lifespan_result_set = False - self._lifespan_result = None - return - - # Use an explicit AsyncExitStack so we can shield teardown from - # cancellation. Without this, Ctrl-C causes CancelledError to - # propagate into lifespan finally blocks, preventing any async - # cleanup (e.g. closing DB connections, flushing buffers). - stack = AsyncExitStack() - try: - user_lifespan_result = await stack.enter_async_context(self._lifespan(self)) - await stack.enter_async_context(self._docket_lifespan()) - - self._lifespan_result = user_lifespan_result - self._lifespan_result_set = True - - # Start lifespans for all providers - for provider in self.providers: - await stack.enter_async_context(provider.lifespan()) - - self._started.set() - try: - yield - finally: - self._started.clear() - finally: - try: - with anyio.CancelScope(shield=True): - await stack.aclose() - finally: - async with self._lifespan_lock: - self._lifespan_ref_count -= 1 - if self._lifespan_ref_count == 0: - self._lifespan_result_set = False - self._lifespan_result = None - - def _setup_task_protocol_handlers(self: FastMCP) -> None: - """Register SEP-1686 task protocol handlers with SDK. - - Only registers handlers if docket is installed. Without docket, - task protocol requests will return "method not found" errors. - """ - from fastmcp.server.dependencies import is_docket_available - - if not is_docket_available(): - return - - from mcp.types import ( - CancelTaskRequest, - GetTaskPayloadRequest, - GetTaskRequest, - ListTasksRequest, - ServerResult, - ) - - from fastmcp.server.tasks.requests import ( - tasks_cancel_handler, - tasks_get_handler, - tasks_list_handler, - tasks_result_handler, - ) - - # Manually register handlers (SDK decorators fail with locally-defined functions) - # SDK expects handlers that receive Request objects and return ServerResult - - async def handle_get_task(req: GetTaskRequest) -> ServerResult: - params = req.params.model_dump(by_alias=True, exclude_none=True) - result = await tasks_get_handler(self, params) - return ServerResult(result) - - async def handle_get_task_result(req: GetTaskPayloadRequest) -> ServerResult: - params = req.params.model_dump(by_alias=True, exclude_none=True) - result = await tasks_result_handler(self, params) - return ServerResult(result) - - async def handle_list_tasks(req: ListTasksRequest) -> ServerResult: - params = ( - req.params.model_dump(by_alias=True, exclude_none=True) - if req.params - else {} - ) - result = await tasks_list_handler(self, params) - return ServerResult(result) - - async def handle_cancel_task(req: CancelTaskRequest) -> ServerResult: - params = req.params.model_dump(by_alias=True, exclude_none=True) - result = await tasks_cancel_handler(self, params) - return ServerResult(result) - - # Register directly with SDK (same as what decorators do internally) - self._mcp_server.request_handlers[GetTaskRequest] = handle_get_task - self._mcp_server.request_handlers[GetTaskPayloadRequest] = ( - handle_get_task_result - ) - self._mcp_server.request_handlers[ListTasksRequest] = handle_list_tasks - self._mcp_server.request_handlers[CancelTaskRequest] = handle_cancel_task diff --git a/src/fastmcp/server/mixins/mcp_operations.py b/src/fastmcp/server/mixins/mcp_operations.py deleted file mode 100644 index 70bd65607..000000000 --- a/src/fastmcp/server/mixins/mcp_operations.py +++ /dev/null @@ -1,373 +0,0 @@ -"""MCP protocol handler setup and wire-format handlers for FastMCP Server.""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable, Sequence -from typing import TYPE_CHECKING, Any, TypeVar, cast - -import mcp.types -from mcp.shared.exceptions import McpError -from mcp.types import ContentBlock -from pydantic import AnyUrl - -from fastmcp.exceptions import DisabledError, NotFoundError -from fastmcp.server.tasks.config import TaskMeta -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.pagination import paginate_sequence -from fastmcp.utilities.versions import VersionSpec, dedupe_with_versions - -if TYPE_CHECKING: - from fastmcp.server.server import FastMCP - -logger = get_logger(__name__) - -PaginateT = TypeVar("PaginateT") - - -def _apply_pagination( - items: Sequence[PaginateT], - cursor: str | None, - page_size: int | None, -) -> tuple[list[PaginateT], str | None]: - """Apply pagination to items, raising McpError for invalid cursors. - - If page_size is None, returns all items without pagination. - """ - if page_size is None: - return list(items), None - try: - return paginate_sequence(items, cursor, page_size) - except ValueError as e: - raise McpError(mcp.types.ErrorData(code=-32602, message=str(e))) from e - - -class MCPOperationsMixin: - """Mixin providing MCP protocol handler setup and wire-format handlers. - - Note: Methods registered with SDK decorators (e.g., _list_tools_mcp, _call_tool_mcp) - cannot use `self: FastMCP` type hints because the SDK's `get_type_hints()` fails - to resolve FastMCP at runtime (it's only available under TYPE_CHECKING). When - type hints fail to resolve, the SDK falls back to calling handlers with no arguments. - These methods use untyped `self` to avoid this issue. - """ - - def _setup_handlers(self: FastMCP) -> None: - """Set up core MCP protocol handlers. - - List handlers use SDK decorators that pass the request object to our handler - (needed for pagination cursor). The SDK also populates caches like _tool_cache. - - Exception: list_resource_templates SDK decorator doesn't pass the request, - so we register that handler directly. - - The call_tool decorator is from the SDK (supports CreateTaskResult + validate_input). - The read_resource and get_prompt decorators are from LowLevelServer to add - CreateTaskResult support until the SDK provides it natively. - """ - self._mcp_server.list_tools()(self._list_tools_mcp) - self._mcp_server.list_resources()(self._list_resources_mcp) - self._mcp_server.list_prompts()(self._list_prompts_mcp) - - # list_resource_templates SDK decorator doesn't pass the request to handlers, - # so we register directly to get cursor access for pagination - self._mcp_server.request_handlers[mcp.types.ListResourceTemplatesRequest] = ( - self._wrap_list_handler(self._list_resource_templates_mcp) - ) - - self._mcp_server.call_tool(validate_input=self.strict_input_validation)( - self._call_tool_mcp - ) - self._mcp_server.read_resource()(self._read_resource_mcp) - self._mcp_server.get_prompt()(self._get_prompt_mcp) - self._mcp_server.set_logging_level()(self._set_logging_level_mcp) - - # Register SEP-1686 task protocol handlers - self._setup_task_protocol_handlers() - - def _wrap_list_handler( - self: FastMCP, handler: Callable[..., Awaitable[Any]] - ) -> Callable[..., Awaitable[mcp.types.ServerResult]]: - """Wrap a list handler to pass the request and return ServerResult.""" - - async def wrapper(request: Any) -> mcp.types.ServerResult: - result = await handler(request) - return mcp.types.ServerResult(result) - - return wrapper - - async def _list_tools_mcp( - self, request: mcp.types.ListToolsRequest - ) -> mcp.types.ListToolsResult: - """ - List all available tools, in the format expected by the low-level MCP - server. Supports pagination when list_page_size is configured. - """ - # Cast self to FastMCP for type checking (see class docstring for why - # we can't use `self: FastMCP` annotation on SDK-registered handlers) - server = cast("FastMCP", self) - logger.debug(f"[{server.name}] Handler called: list_tools") - - tools = dedupe_with_versions(list(await server.list_tools()), lambda t: t.name) - sdk_tools = [tool.to_mcp_tool(name=tool.name) for tool in tools] - - # SDK may pass None for internal cache refresh despite type hint - cursor = ( - request.params.cursor if request is not None and request.params else None - ) - page, next_cursor = _apply_pagination(sdk_tools, cursor, server._list_page_size) - return mcp.types.ListToolsResult(tools=page, nextCursor=next_cursor) - - async def _list_resources_mcp( - self, request: mcp.types.ListResourcesRequest - ) -> mcp.types.ListResourcesResult: - """ - List all available resources, in the format expected by the low-level MCP - server. Supports pagination when list_page_size is configured. - """ - server = cast("FastMCP", self) - logger.debug(f"[{server.name}] Handler called: list_resources") - - resources = dedupe_with_versions( - list(await server.list_resources()), lambda r: str(r.uri) - ) - sdk_resources = [ - resource.to_mcp_resource(uri=str(resource.uri)) for resource in resources - ] - - cursor = request.params.cursor if request.params else None - page, next_cursor = _apply_pagination( - sdk_resources, cursor, server._list_page_size - ) - return mcp.types.ListResourcesResult(resources=page, nextCursor=next_cursor) - - async def _list_resource_templates_mcp( - self, request: mcp.types.ListResourceTemplatesRequest - ) -> mcp.types.ListResourceTemplatesResult: - """ - List all available resource templates, in the format expected by the low-level MCP - server. Supports pagination when list_page_size is configured. - """ - server = cast("FastMCP", self) - logger.debug(f"[{server.name}] Handler called: list_resource_templates") - - templates = dedupe_with_versions( - list(await server.list_resource_templates()), lambda t: t.uri_template - ) - sdk_templates = [ - template.to_mcp_template(uriTemplate=template.uri_template) - for template in templates - ] - cursor = request.params.cursor if request.params else None - page, next_cursor = _apply_pagination( - sdk_templates, cursor, server._list_page_size - ) - return mcp.types.ListResourceTemplatesResult( - resourceTemplates=page, nextCursor=next_cursor - ) - - async def _list_prompts_mcp( - self, request: mcp.types.ListPromptsRequest - ) -> mcp.types.ListPromptsResult: - """ - List all available prompts, in the format expected by the low-level MCP - server. Supports pagination when list_page_size is configured. - """ - server = cast("FastMCP", self) - logger.debug(f"[{server.name}] Handler called: list_prompts") - - prompts = dedupe_with_versions( - list(await server.list_prompts()), lambda p: p.name - ) - sdk_prompts = [prompt.to_mcp_prompt(name=prompt.name) for prompt in prompts] - cursor = request.params.cursor if request.params else None - page, next_cursor = _apply_pagination( - sdk_prompts, cursor, server._list_page_size - ) - return mcp.types.ListPromptsResult(prompts=page, nextCursor=next_cursor) - - async def _call_tool_mcp( - self, key: str, arguments: dict[str, Any] - ) -> ( - list[ContentBlock] - | tuple[list[ContentBlock], dict[str, Any]] - | mcp.types.CallToolResult - | mcp.types.CreateTaskResult - ): - """ - Handle MCP 'callTool' requests. - - Extracts task metadata from MCP request context and passes it explicitly - to call_tool(). The tool's _run() method handles the backgrounding decision, - ensuring middleware runs before Docket. - - Args: - key: The name of the tool to call - arguments: Arguments to pass to the tool - - Returns: - Tool result or CreateTaskResult for background execution - """ - server = cast("FastMCP", self) - logger.debug( - f"[{server.name}] Handler called: call_tool %s with %s", key, arguments - ) - - try: - # Extract version and task metadata from request context. - # fn_key is set by call_tool() after finding the tool. - version_str: str | None = None - task_meta: TaskMeta | None = None - try: - ctx = server._mcp_server.request_context - # Extract version from _meta.fastmcp - if ctx.meta: - meta_dict = ctx.meta.model_dump(exclude_none=True) - version_str = meta_dict.get("fastmcp", {}).get("version") - # Extract SEP-1686 task metadata - if ctx.experimental.is_task: - mcp_task_meta = ctx.experimental.task_metadata - task_meta_dict = mcp_task_meta.model_dump(exclude_none=True) - task_meta = TaskMeta(ttl=task_meta_dict.get("ttl")) - except (AttributeError, LookupError): - pass - - version = VersionSpec(eq=version_str) if version_str else None - result = await server.call_tool( - key, arguments, version=version, task_meta=task_meta - ) - - if isinstance(result, mcp.types.CreateTaskResult): - return result - return result.to_mcp_result() - - except DisabledError as e: - raise NotFoundError(f"Unknown tool: {key!r}") from e - except NotFoundError as e: - raise NotFoundError(f"Unknown tool: {key!r}") from e - - async def _read_resource_mcp( - self, uri: AnyUrl | str - ) -> mcp.types.ReadResourceResult | mcp.types.CreateTaskResult: - """Handle MCP 'readResource' requests. - - Extracts task metadata from MCP request context and passes it explicitly - to read_resource(). The resource's _read() method handles the backgrounding - decision, ensuring middleware runs before Docket. - - Args: - uri: The resource URI - - Returns: - ReadResourceResult or CreateTaskResult for background execution - """ - server = cast("FastMCP", self) - logger.debug(f"[{server.name}] Handler called: read_resource %s", uri) - - try: - # Extract version and task metadata from request context. - version_str: str | None = None - task_meta: TaskMeta | None = None - try: - ctx = server._mcp_server.request_context - # Extract version from _meta.fastmcp.version if provided - if ctx.meta: - meta_dict = ctx.meta.model_dump(exclude_none=True) - fastmcp_meta = meta_dict.get("fastmcp") or {} - version_str = fastmcp_meta.get("version") - # Extract SEP-1686 task metadata - if ctx.experimental.is_task: - mcp_task_meta = ctx.experimental.task_metadata - task_meta_dict = mcp_task_meta.model_dump(exclude_none=True) - task_meta = TaskMeta(ttl=task_meta_dict.get("ttl")) - except (AttributeError, LookupError): - pass - - version = VersionSpec(eq=version_str) if version_str else None - result = await server.read_resource( - str(uri), version=version, task_meta=task_meta - ) - - if isinstance(result, mcp.types.CreateTaskResult): - return result - return result.to_mcp_result(uri) - except DisabledError as e: - raise McpError( - mcp.types.ErrorData( - code=-32002, message=f"Resource not found: {str(uri)!r}" - ) - ) from e - except NotFoundError as e: - raise McpError( - mcp.types.ErrorData(code=-32002, message=f"Resource not found: {e}") - ) from e - - async def _get_prompt_mcp( - self, name: str, arguments: dict[str, Any] | None - ) -> mcp.types.GetPromptResult | mcp.types.CreateTaskResult: - """Handle MCP 'getPrompt' requests. - - Extracts task metadata from MCP request context and passes it explicitly - to render_prompt(). The prompt's _render() method handles the backgrounding - decision, ensuring middleware runs before Docket. - - Args: - name: The prompt name - arguments: Prompt arguments - - Returns: - GetPromptResult or CreateTaskResult for background execution - """ - server = cast("FastMCP", self) - logger.debug( - f"[{server.name}] Handler called: get_prompt %s with %s", name, arguments - ) - - try: - # Extract version and task metadata from request context. - # fn_key is set by render_prompt() after finding the prompt. - version_str: str | None = None - task_meta: TaskMeta | None = None - try: - ctx = server._mcp_server.request_context - # Extract version from request-level _meta.fastmcp.version - if ctx.meta: - meta_dict = ctx.meta.model_dump(exclude_none=True) - version_str = meta_dict.get("fastmcp", {}).get("version") - # Extract SEP-1686 task metadata - if ctx.experimental.is_task: - mcp_task_meta = ctx.experimental.task_metadata - task_meta_dict = mcp_task_meta.model_dump(exclude_none=True) - task_meta = TaskMeta(ttl=task_meta_dict.get("ttl")) - except (AttributeError, LookupError): - pass - - version = VersionSpec(eq=version_str) if version_str else None - result = await server.render_prompt( - name, arguments, version=version, task_meta=task_meta - ) - - if isinstance(result, mcp.types.CreateTaskResult): - return result - return result.to_mcp_prompt_result() - except DisabledError as e: - raise NotFoundError(f"Unknown prompt: {name!r}") from e - except NotFoundError: - raise - - async def _set_logging_level_mcp(self, level: mcp.types.LoggingLevel) -> None: - """Handle MCP 'logging/setLevel' requests. - - Stores the requested minimum log level on the session so that - subsequent log messages below this level are suppressed. - """ - from fastmcp.server.low_level import MiddlewareServerSession - - server = cast("FastMCP", self) - logger.debug(f"[{server.name}] Handler called: set_logging_level %s", level) - try: - ctx = server._mcp_server.request_context - session = ctx.session - if isinstance(session, MiddlewareServerSession): - session._minimum_logging_level = level - except LookupError: - pass diff --git a/src/fastmcp/server/mixins/transport.py b/src/fastmcp/server/mixins/transport.py deleted file mode 100644 index 10223f38a..000000000 --- a/src/fastmcp/server/mixins/transport.py +++ /dev/null @@ -1,365 +0,0 @@ -"""Transport-related methods for FastMCP Server.""" - -from __future__ import annotations - -from collections.abc import Awaitable, Callable -from functools import partial -from typing import TYPE_CHECKING, Any, Literal - -import anyio -import uvicorn -from mcp.server.lowlevel.server import NotificationOptions -from mcp.server.stdio import stdio_server -from starlette.middleware import Middleware as ASGIMiddleware -from starlette.requests import Request -from starlette.responses import Response -from starlette.routing import BaseRoute, Route - -import fastmcp -from fastmcp.server.event_store import EventStore -from fastmcp.server.http import ( - StarletteWithLifespan, - create_sse_app, - create_streamable_http_app, -) -from fastmcp.server.providers.base import Provider -from fastmcp.server.providers.fastmcp_provider import FastMCPProvider -from fastmcp.server.providers.wrapped_provider import _WrappedProvider -from fastmcp.utilities.cli import log_server_banner -from fastmcp.utilities.logging import get_logger, temporary_log_level - -if TYPE_CHECKING: - from fastmcp.server.server import FastMCP, Transport - -logger = get_logger(__name__) - - -class TransportMixin: - """Mixin providing transport-related methods for FastMCP. - - Includes HTTP/stdio/SSE transport handling and custom HTTP routes. - """ - - async def run_async( - self: FastMCP, - transport: Transport | None = None, - show_banner: bool | None = None, - **transport_kwargs: Any, - ) -> None: - """Run the FastMCP server asynchronously. - - Args: - transport: Transport protocol to use ("stdio", "http", "sse", or "streamable-http") - show_banner: Whether to display the server banner. If None, uses the - FASTMCP_SHOW_SERVER_BANNER setting (default: True). - """ - if show_banner is None: - show_banner = fastmcp.settings.show_server_banner - if transport is None: - transport = fastmcp.settings.transport - if transport not in {"stdio", "http", "sse", "streamable-http"}: - raise ValueError(f"Unknown transport: {transport}") - - if transport == "stdio": - await self.run_stdio_async( - show_banner=show_banner, - **transport_kwargs, - ) - elif transport in {"http", "sse", "streamable-http"}: - await self.run_http_async( - transport=transport, - show_banner=show_banner, - **transport_kwargs, - ) - else: - raise ValueError(f"Unknown transport: {transport}") - - def run( - self: FastMCP, - transport: Transport | None = None, - show_banner: bool | None = None, - **transport_kwargs: Any, - ) -> None: - """Run the FastMCP server. Note this is a synchronous function. - - Args: - transport: Transport protocol to use ("http", "stdio", "sse", or "streamable-http") - show_banner: Whether to display the server banner. If None, uses the - FASTMCP_SHOW_SERVER_BANNER setting (default: True). - """ - - anyio.run( - partial( - self.run_async, - transport, - show_banner=show_banner, - **transport_kwargs, - ) - ) - - def custom_route( - self: FastMCP, - path: str, - methods: list[str], - name: str | None = None, - include_in_schema: bool = True, - ) -> Callable[ - [Callable[[Request], Awaitable[Response]]], - Callable[[Request], Awaitable[Response]], - ]: - """ - Decorator to register a custom HTTP route on the FastMCP server. - - Allows adding arbitrary HTTP endpoints outside the standard MCP protocol, - which can be useful for OAuth callbacks, health checks, or admin APIs. - The handler function must be an async function that accepts a Starlette - Request and returns a Response. - - Args: - path: URL path for the route (e.g., "/auth/callback") - methods: List of HTTP methods to support (e.g., ["GET", "POST"]) - name: Optional name for the route (to reference this route with - Starlette's reverse URL lookup feature) - include_in_schema: Whether to include in OpenAPI schema, defaults to True - - Example: - Register a custom HTTP route for a health check endpoint: - ```python - @server.custom_route("/health", methods=["GET"]) - async def health_check(request: Request) -> Response: - return JSONResponse({"status": "ok"}) - ``` - """ - - def decorator( - fn: Callable[[Request], Awaitable[Response]], - ) -> Callable[[Request], Awaitable[Response]]: - self._additional_http_routes.append( - Route( - path, - endpoint=fn, - methods=methods, - name=name, - include_in_schema=include_in_schema, - ) - ) - return fn - - return decorator - - def _get_additional_http_routes(self: FastMCP) -> list[BaseRoute]: - """Get all additional HTTP routes including from mounted servers. - - Collects custom HTTP routes registered via ``@server.custom_route()`` - from this server **and** from any FastMCP servers reachable through - mounted providers (recursively). This ensures that routes defined on - a child server are forwarded to the parent's HTTP app when using - ``server.mount(child)``. - - Note: - When path collisions occur between a parent and a mounted child, - the parent's routes take precedence because they appear first in - the returned list. - - Returns: - List of Starlette Route objects - """ - routes: list[BaseRoute] = list(self._additional_http_routes) - - def _unwrap_provider(provider: Provider) -> Provider: - """Unwrap _WrappedProvider layers to find the inner provider.""" - while isinstance(provider, _WrappedProvider): - provider = provider._inner - return provider - - for provider in self.providers: - inner = _unwrap_provider(provider) - if isinstance(inner, FastMCPProvider): - # Recurse into the mounted server to collect its routes - # (and any routes from servers mounted on *it*). - routes.extend(inner.server._get_additional_http_routes()) - - return routes - - async def run_stdio_async( - self: FastMCP, - show_banner: bool = True, - log_level: str | None = None, - stateless: bool = False, - ) -> None: - """Run the server using stdio transport. - - Args: - show_banner: Whether to display the server banner - log_level: Log level for the server - stateless: Whether to run in stateless mode (no session initialization) - """ - from fastmcp.server.context import reset_transport, set_transport - - # Display server banner - if show_banner: - log_server_banner(server=self) - - token = set_transport("stdio") - try: - with temporary_log_level(log_level): - async with self._lifespan_manager(): - async with stdio_server() as (read_stream, write_stream): - mode = " (stateless)" if stateless else "" - logger.info( - f"Starting MCP server {self.name!r} with transport 'stdio'{mode}" - ) - - await self._mcp_server.run( - read_stream, - write_stream, - self._mcp_server.create_initialization_options( - notification_options=NotificationOptions( - tools_changed=True - ), - ), - stateless=stateless, - ) - finally: - reset_transport(token) - - async def run_http_async( - self: FastMCP, - show_banner: bool = True, - transport: Literal["http", "streamable-http", "sse"] = "http", - host: str | None = None, - port: int | None = None, - log_level: str | None = None, - path: str | None = None, - uvicorn_config: dict[str, Any] | None = None, - middleware: list[ASGIMiddleware] | None = None, - json_response: bool | None = None, - stateless_http: bool | None = None, - stateless: bool | None = None, - ) -> None: - """Run the server using HTTP transport. - - Args: - transport: Transport protocol to use - "http" (default), "streamable-http", or "sse" - host: Host address to bind to (defaults to settings.host) - port: Port to bind to (defaults to settings.port) - log_level: Log level for the server (defaults to settings.log_level) - path: Path for the endpoint (defaults to settings.streamable_http_path or settings.sse_path) - uvicorn_config: Additional configuration for the Uvicorn server - middleware: A list of middleware to apply to the app - json_response: Whether to use JSON response format (defaults to settings.json_response) - stateless_http: Whether to use stateless HTTP (defaults to settings.stateless_http) - stateless: Alias for stateless_http for CLI consistency - """ - # Allow stateless as alias for stateless_http - if stateless is not None and stateless_http is None: - stateless_http = stateless - - # Resolve from settings/env var if not explicitly set - if stateless_http is None: - stateless_http = fastmcp.settings.stateless_http - - # SSE doesn't support stateless mode - if stateless_http and transport == "sse": - raise ValueError("SSE transport does not support stateless mode") - - host = host or fastmcp.settings.host - port = port or fastmcp.settings.port - default_log_level_to_use = (log_level or fastmcp.settings.log_level).lower() - - app = self.http_app( - path=path, - transport=transport, - middleware=middleware, - json_response=json_response, - stateless_http=stateless_http, - ) - - # Display server banner - if show_banner: - log_server_banner(server=self) - uvicorn_config_from_user = uvicorn_config or {} - - config_kwargs: dict[str, Any] = { - "timeout_graceful_shutdown": 2, - "lifespan": "on", - "ws": "websockets-sansio", - } - config_kwargs.update(uvicorn_config_from_user) - - if "log_config" not in config_kwargs and "log_level" not in config_kwargs: - config_kwargs["log_level"] = default_log_level_to_use - - with temporary_log_level(log_level): - async with self._lifespan_manager(): - config = uvicorn.Config(app, host=host, port=port, **config_kwargs) - server = uvicorn.Server(config) - path = getattr(app.state, "path", "").lstrip("/") - mode = " (stateless)" if stateless_http else "" - logger.info( - f"Starting MCP server {self.name!r} with transport {transport!r}{mode} on http://{host}:{port}/{path}" - ) - - await server.serve() - - def http_app( - self: FastMCP, - path: str | None = None, - middleware: list[ASGIMiddleware] | None = None, - json_response: bool | None = None, - stateless_http: bool | None = None, - transport: Literal["http", "streamable-http", "sse"] = "http", - event_store: EventStore | None = None, - retry_interval: int | None = None, - ) -> StarletteWithLifespan: - """Create a Starlette app using the specified HTTP transport. - - Args: - path: The path for the HTTP endpoint - middleware: A list of middleware to apply to the app - json_response: Whether to use JSON response format - stateless_http: Whether to use stateless mode (new transport per request) - transport: Transport protocol to use - "http", "streamable-http", or "sse" - event_store: Optional event store for SSE polling/resumability. When set, - enables clients to reconnect and resume receiving events after - server-initiated disconnections. Only used with streamable-http transport. - retry_interval: Optional retry interval in milliseconds for SSE polling. - Controls how quickly clients should reconnect after server-initiated - disconnections. Requires event_store to be set. Only used with - streamable-http transport. - - Returns: - A Starlette application configured with the specified transport - """ - - if transport in ("streamable-http", "http"): - return create_streamable_http_app( - server=self, - streamable_http_path=path or fastmcp.settings.streamable_http_path, - event_store=event_store, - retry_interval=retry_interval, - auth=self.auth, - json_response=( - json_response - if json_response is not None - else fastmcp.settings.json_response - ), - stateless_http=( - stateless_http - if stateless_http is not None - else fastmcp.settings.stateless_http - ), - debug=fastmcp.settings.debug, - middleware=middleware, - ) - elif transport == "sse": - return create_sse_app( - server=self, - message_path=fastmcp.settings.message_path, - sse_path=path or fastmcp.settings.sse_path, - auth=self.auth, - debug=fastmcp.settings.debug, - middleware=middleware, - ) - else: - raise ValueError(f"Unknown transport: {transport}") diff --git a/src/fastmcp/server/openapi/__init__.py b/src/fastmcp/server/openapi/__init__.py deleted file mode 100644 index 3ea81d109..000000000 --- a/src/fastmcp/server/openapi/__init__.py +++ /dev/null @@ -1,57 +0,0 @@ -"""OpenAPI server implementation for FastMCP. - -.. deprecated:: - This module is deprecated. Import from fastmcp.server.providers.openapi instead. - -The recommended approach is to use OpenAPIProvider with FastMCP: - - from fastmcp import FastMCP - from fastmcp.server.providers.openapi import OpenAPIProvider - import httpx - - client = httpx.AsyncClient(base_url="https://api.example.com") - provider = OpenAPIProvider(openapi_spec=spec, client=client) - - mcp = FastMCP("My API Server") - mcp.add_provider(provider) - -FastMCPOpenAPI is still available but deprecated. -""" - -import warnings - -from fastmcp.exceptions import FastMCPDeprecationWarning - -warnings.warn( - "fastmcp.server.openapi is deprecated. " - "Import from fastmcp.server.providers.openapi instead.", - FastMCPDeprecationWarning, - stacklevel=2, -) - -# Re-export from new canonical location -from fastmcp.server.providers.openapi import ( # noqa: E402 - ComponentFn as ComponentFn, - MCPType as MCPType, - OpenAPIProvider as OpenAPIProvider, - OpenAPIResource as OpenAPIResource, - OpenAPIResourceTemplate as OpenAPIResourceTemplate, - OpenAPITool as OpenAPITool, - RouteMap as RouteMap, - RouteMapFn as RouteMapFn, -) - -# Keep FastMCPOpenAPI for backwards compat (it has its own deprecation warning) -from fastmcp.server.openapi.server import FastMCPOpenAPI as FastMCPOpenAPI # noqa: E402 - -__all__ = [ - "ComponentFn", - "FastMCPOpenAPI", - "MCPType", - "OpenAPIProvider", - "OpenAPIResource", - "OpenAPIResourceTemplate", - "OpenAPITool", - "RouteMap", - "RouteMapFn", -] diff --git a/src/fastmcp/server/openapi/components.py b/src/fastmcp/server/openapi/components.py deleted file mode 100644 index ce1eeaf7d..000000000 --- a/src/fastmcp/server/openapi/components.py +++ /dev/null @@ -1,30 +0,0 @@ -"""OpenAPI component implementations - backwards compatibility stub. - -This module is deprecated. Import from fastmcp.server.providers.openapi instead. -""" - -from __future__ import annotations - -import warnings - -from fastmcp.exceptions import FastMCPDeprecationWarning - -warnings.warn( - "fastmcp.server.openapi.components is deprecated. " - "Import from fastmcp.server.providers.openapi instead.", - FastMCPDeprecationWarning, - stacklevel=2, -) - -from fastmcp.server.providers.openapi import ( # noqa: E402 - OpenAPIResource, - OpenAPIResourceTemplate, - OpenAPITool, -) - -# Export public symbols -__all__ = [ - "OpenAPIResource", - "OpenAPIResourceTemplate", - "OpenAPITool", -] diff --git a/src/fastmcp/server/openapi/routing.py b/src/fastmcp/server/openapi/routing.py deleted file mode 100644 index 309e503ca..000000000 --- a/src/fastmcp/server/openapi/routing.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Route mapping logic for OpenAPI operations. - -.. deprecated:: - This module is deprecated. Import from fastmcp.server.providers.openapi instead. -""" - -# ruff: noqa: E402 - -import warnings - -from fastmcp.exceptions import FastMCPDeprecationWarning - -# Backwards compatibility - export everything that was previously public -__all__ = [ - "DEFAULT_ROUTE_MAPPINGS", - "ComponentFn", - "MCPType", - "RouteMap", - "RouteMapFn", - "_determine_route_type", -] - -warnings.warn( - "fastmcp.server.openapi.routing is deprecated. " - "Import from fastmcp.server.providers.openapi instead.", - FastMCPDeprecationWarning, - stacklevel=2, -) - -# Re-export from new canonical location -from fastmcp.server.providers.openapi.routing import ( - DEFAULT_ROUTE_MAPPINGS as DEFAULT_ROUTE_MAPPINGS, -) -from fastmcp.server.providers.openapi.routing import ( - ComponentFn as ComponentFn, -) -from fastmcp.server.providers.openapi.routing import ( - MCPType as MCPType, -) -from fastmcp.server.providers.openapi.routing import ( - RouteMap as RouteMap, -) -from fastmcp.server.providers.openapi.routing import ( - RouteMapFn as RouteMapFn, -) -from fastmcp.server.providers.openapi.routing import ( - _determine_route_type as _determine_route_type, -) diff --git a/src/fastmcp/server/openapi/server.py b/src/fastmcp/server/openapi/server.py deleted file mode 100644 index a7292129d..000000000 --- a/src/fastmcp/server/openapi/server.py +++ /dev/null @@ -1,125 +0,0 @@ -"""FastMCPOpenAPI - backwards compatibility wrapper. - -This class is deprecated. Use FastMCP with OpenAPIProvider instead: - - from fastmcp import FastMCP - from fastmcp.server.providers.openapi import OpenAPIProvider - import httpx - - client = httpx.AsyncClient(base_url="https://api.example.com") - provider = OpenAPIProvider(openapi_spec=spec, client=client) - mcp = FastMCP("My API Server", providers=[provider]) -""" - -from __future__ import annotations - -import warnings -from typing import Any - -import httpx - -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.providers.openapi import ( - ComponentFn, - OpenAPIProvider, - RouteMap, - RouteMapFn, -) -from fastmcp.server.server import FastMCP - - -class FastMCPOpenAPI(FastMCP): - """FastMCP server implementation that creates components from an OpenAPI schema. - - .. deprecated:: - Use FastMCP with OpenAPIProvider instead. This class will be - removed in a future version. - - Example (deprecated): - ```python - from fastmcp.server.openapi import FastMCPOpenAPI - import httpx - - server = FastMCPOpenAPI( - openapi_spec=spec, - client=httpx.AsyncClient(), - ) - ``` - - New approach: - ```python - from fastmcp import FastMCP - from fastmcp.server.providers.openapi import OpenAPIProvider - import httpx - - client = httpx.AsyncClient(base_url="https://api.example.com") - provider = OpenAPIProvider(openapi_spec=spec, client=client) - mcp = FastMCP("API Server", providers=[provider]) - ``` - """ - - def __init__( - self, - openapi_spec: dict[str, Any], - client: httpx.AsyncClient | None = None, - name: str | None = None, - route_maps: list[RouteMap] | None = None, - route_map_fn: RouteMapFn | None = None, - mcp_component_fn: ComponentFn | None = None, - mcp_names: dict[str, str] | None = None, - tags: set[str] | None = None, - **settings: Any, - ): - """Initialize a FastMCP server from an OpenAPI schema. - - .. deprecated:: - Use FastMCP with OpenAPIProvider instead. - - Args: - openapi_spec: OpenAPI schema as a dictionary - client: Optional httpx AsyncClient for making HTTP requests. - If not provided, a default client is created from the spec. - name: Optional name for the server - route_maps: Optional list of RouteMap objects defining route mappings - route_map_fn: Optional callable for advanced route type mapping - mcp_component_fn: Optional callable for component customization - mcp_names: Optional dictionary mapping operationId to component names - tags: Optional set of tags to add to all components - **settings: Additional settings for FastMCP - """ - warnings.warn( - "FastMCPOpenAPI is deprecated. Use FastMCP with OpenAPIProvider instead:\n" - " provider = OpenAPIProvider(openapi_spec=spec, client=client)\n" - " mcp = FastMCP('name', providers=[provider])", - FastMCPDeprecationWarning, - stacklevel=2, - ) - - super().__init__(name=name or "OpenAPI FastMCP", **settings) - - # Store references for backwards compatibility - self._client = client - self._mcp_component_fn = mcp_component_fn - - # Create provider with the client - provider = OpenAPIProvider( - openapi_spec=openapi_spec, - client=client, - route_maps=route_maps, - route_map_fn=route_map_fn, - mcp_component_fn=mcp_component_fn, - mcp_names=mcp_names, - tags=tags, - ) - - self.add_provider(provider) - - # Expose internal attributes for backwards compatibility - self._spec = provider._spec - self._director = provider._director - - -# Export public symbols -__all__ = [ - "FastMCPOpenAPI", -] diff --git a/src/fastmcp/server/providers/__init__.py b/src/fastmcp/server/providers/__init__.py deleted file mode 100644 index 4c673be03..000000000 --- a/src/fastmcp/server/providers/__init__.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Providers for dynamic MCP components. - -This module provides the `Provider` abstraction for providing tools, -resources, and prompts dynamically at runtime. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.providers import Provider - from fastmcp.tools import Tool - - class DatabaseProvider(Provider): - def __init__(self, db_url: str): - self.db = Database(db_url) - - async def _list_tools(self) -> list[Tool]: - rows = await self.db.fetch("SELECT * FROM tools") - return [self._make_tool(row) for row in rows] - - async def _get_tool(self, name: str) -> Tool | None: - row = await self.db.fetchone("SELECT * FROM tools WHERE name = ?", name) - return self._make_tool(row) if row else None - - mcp = FastMCP("Server", providers=[DatabaseProvider(db_url)]) - ``` -""" - -from typing import TYPE_CHECKING - -from fastmcp.server.providers.aggregate import AggregateProvider -from fastmcp.server.providers.base import Provider -from fastmcp.server.providers.fastmcp_provider import FastMCPProvider -from fastmcp.server.providers.filesystem import FileSystemProvider -from fastmcp.server.providers.local_provider import LocalProvider -from fastmcp.server.providers.skills import ( - ClaudeSkillsProvider, - SkillProvider, - SkillsDirectoryProvider, - SkillsProvider, -) - -if TYPE_CHECKING: - from fastmcp.server.providers.openapi import OpenAPIProvider as OpenAPIProvider - from fastmcp.server.providers.proxy import ProxyProvider as ProxyProvider - -__all__ = [ - "AggregateProvider", - "ClaudeSkillsProvider", - "FastMCPProvider", - "FileSystemProvider", - "LocalProvider", - "OpenAPIProvider", - "Provider", - "ProxyProvider", - "SkillProvider", - "SkillsDirectoryProvider", - "SkillsProvider", # Backwards compatibility alias for SkillsDirectoryProvider -] - - -def __getattr__(name: str): - """Lazy import for providers to avoid circular imports.""" - if name == "ProxyProvider": - from fastmcp.server.providers.proxy import ProxyProvider - - return ProxyProvider - if name == "OpenAPIProvider": - from fastmcp.server.providers.openapi import OpenAPIProvider - - return OpenAPIProvider - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/fastmcp/server/providers/aggregate.py b/src/fastmcp/server/providers/aggregate.py deleted file mode 100644 index c881595e5..000000000 --- a/src/fastmcp/server/providers/aggregate.py +++ /dev/null @@ -1,274 +0,0 @@ -"""AggregateProvider for combining multiple providers into one. - -This module provides `AggregateProvider`, a utility class that presents -multiple providers as a single unified provider. Useful when you want to -combine custom providers without creating a full FastMCP server. - -Example: - ```python - from fastmcp.server.providers import AggregateProvider - - # Combine multiple providers into one - combined = AggregateProvider() - combined.add_provider(provider1) - combined.add_provider(provider2, namespace="api") # Tools become "api_foo" - - # Use like any other provider - tools = await combined.list_tools() - ``` -""" - -from __future__ import annotations - -import logging -from collections.abc import AsyncIterator, Sequence -from contextlib import AsyncExitStack, asynccontextmanager -from typing import TYPE_CHECKING, TypeVar - -from fastmcp.exceptions import NotFoundError -from fastmcp.server.providers.base import Provider -from fastmcp.server.transforms import Namespace -from fastmcp.utilities.async_utils import gather -from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.versions import VersionSpec, version_sort_key - -if TYPE_CHECKING: - from fastmcp.prompts.base import Prompt - from fastmcp.resources.base import Resource - from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.base import Tool - -logger = logging.getLogger(__name__) - -T = TypeVar("T") - - -class AggregateProvider(Provider): - """Utility provider that combines multiple providers into one. - - Components are aggregated from all providers. For get_* operations, - providers are queried in parallel and the highest version is returned. - - When adding providers with a namespace, wrap_transform() is used to apply - the Namespace transform. This means namespace transformation is handled - by the wrapped provider, not by AggregateProvider. - - Errors from individual providers are logged and skipped (graceful degradation). - - Example: - ```python - combined = AggregateProvider() - combined.add_provider(db_provider) - combined.add_provider(api_provider, namespace="api") - # db_provider's tools keep original names - # api_provider's tools become "api_foo", "api_bar", etc. - ``` - """ - - def __init__(self, providers: Sequence[Provider] | None = None) -> None: - """Initialize with an optional sequence of providers. - - Args: - providers: Optional initial providers (without namespacing). - For namespaced providers, use add_provider() instead. - """ - super().__init__() - self.providers: list[Provider] = list(providers or []) - - def add_provider(self, provider: Provider, *, namespace: str = "") -> None: - """Add a provider with optional namespace. - - If the provider is a FastMCP server, it's automatically wrapped in - FastMCPProvider to ensure middleware is invoked correctly. - - Args: - provider: The provider to add. - namespace: Optional namespace prefix. When set: - - Tools become "namespace_toolname" - - Resources become "protocol://namespace/path" - - Prompts become "namespace_promptname" - """ - # Import here to avoid circular imports - from fastmcp.server.server import FastMCP - - # Auto-wrap FastMCP servers to ensure middleware is invoked - if isinstance(provider, FastMCP): - from fastmcp.server.providers.fastmcp_provider import FastMCPProvider - - provider = FastMCPProvider(provider) - - # Apply namespace via wrap_transform if specified - if namespace: - provider = provider.wrap_transform(Namespace(namespace)) - - self.providers.append(provider) - - def _collect_list_results( - self, results: list[Sequence[T] | BaseException], operation: str - ) -> list[T]: - """Collect successful list results, logging any exceptions.""" - collected: list[T] = [] - for i, result in enumerate(results): - if isinstance(result, BaseException): - logger.debug( - f"Error during {operation} from provider " - f"{self.providers[i]}: {result}" - ) - continue - collected.extend(result) - return collected - - def _get_highest_version_result( - self, - results: list[FastMCPComponent | None | BaseException], - operation: str, - ) -> FastMCPComponent | None: - """Get the highest version from successful non-None results. - - Used for versioned components where we want the highest version - across all providers rather than the first match. - """ - valid: list[FastMCPComponent] = [] - for i, result in enumerate(results): - if isinstance(result, BaseException): - if not isinstance(result, NotFoundError): - logger.debug( - f"Error during {operation} from provider " - f"{self.providers[i]}: {result}" - ) - continue - if result is not None: - valid.append(result) - if not valid: - return None - return max(valid, key=version_sort_key) - - def __repr__(self) -> str: - return f"AggregateProvider(providers={self.providers!r})" - - # ------------------------------------------------------------------------- - # Tools - # ------------------------------------------------------------------------- - - async def _list_tools(self) -> Sequence[Tool]: - """List all tools from all providers.""" - results = await gather( - *[p.list_tools() for p in self.providers], - return_exceptions=True, - ) - return self._collect_list_results(results, "list_tools") - - async def _get_tool( - self, name: str, version: VersionSpec | None = None - ) -> Tool | None: - """Get tool by name from providers.""" - results = await gather( - *[p.get_tool(name, version) for p in self.providers], - return_exceptions=True, - ) - return self._get_highest_version_result(results, f"get_tool({name!r})") # type: ignore[return-value] # ty:ignore[invalid-argument-type, invalid-return-type] - - async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: - """Query all child providers for an app tool.""" - results = await gather( - *[p.get_app_tool(app_name, tool_name) for p in self.providers], - return_exceptions=True, - ) - for r in results: - if isinstance(r, BaseException): - continue - if r is not None: - return r - return None - - # ------------------------------------------------------------------------- - # Resources - # ------------------------------------------------------------------------- - - async def _list_resources(self) -> Sequence[Resource]: - """List all resources from all providers.""" - results = await gather( - *[p.list_resources() for p in self.providers], - return_exceptions=True, - ) - return self._collect_list_results(results, "list_resources") - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Get resource by URI from providers.""" - results = await gather( - *[p.get_resource(uri, version) for p in self.providers], - return_exceptions=True, - ) - return self._get_highest_version_result(results, f"get_resource({uri!r})") # type: ignore[return-value] # ty:ignore[invalid-argument-type, invalid-return-type] - - # ------------------------------------------------------------------------- - # Resource Templates - # ------------------------------------------------------------------------- - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - """List all resource templates from all providers.""" - results = await gather( - *[p.list_resource_templates() for p in self.providers], - return_exceptions=True, - ) - return self._collect_list_results(results, "list_resource_templates") - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Get resource template by URI from providers.""" - results = await gather( - *[p.get_resource_template(uri, version) for p in self.providers], - return_exceptions=True, - ) - return self._get_highest_version_result( - list(results), f"get_resource_template({uri!r})" - ) # type: ignore[return-value] # ty:ignore[invalid-return-type] - - # ------------------------------------------------------------------------- - # Prompts - # ------------------------------------------------------------------------- - - async def _list_prompts(self) -> Sequence[Prompt]: - """List all prompts from all providers.""" - results = await gather( - *[p.list_prompts() for p in self.providers], - return_exceptions=True, - ) - return self._collect_list_results(results, "list_prompts") - - async def _get_prompt( - self, name: str, version: VersionSpec | None = None - ) -> Prompt | None: - """Get prompt by name from providers.""" - results = await gather( - *[p.get_prompt(name, version) for p in self.providers], - return_exceptions=True, - ) - return self._get_highest_version_result(results, f"get_prompt({name!r})") # type: ignore[return-value] # ty:ignore[invalid-argument-type, invalid-return-type] - - # ------------------------------------------------------------------------- - # Tasks - # ------------------------------------------------------------------------- - - async def get_tasks(self) -> Sequence[FastMCPComponent]: - """Get all task-eligible components from all providers.""" - results = await gather( - *[p.get_tasks() for p in self.providers], - return_exceptions=True, - ) - return self._collect_list_results(results, "get_tasks") - - # ------------------------------------------------------------------------- - # Lifecycle - # ------------------------------------------------------------------------- - - @asynccontextmanager - async def lifespan(self) -> AsyncIterator[None]: - """Combine lifespans of all providers.""" - async with AsyncExitStack() as stack: - for p in self.providers: - await stack.enter_async_context(p.lifespan()) - yield diff --git a/src/fastmcp/server/providers/base.py b/src/fastmcp/server/providers/base.py deleted file mode 100644 index 5f6e59cdd..000000000 --- a/src/fastmcp/server/providers/base.py +++ /dev/null @@ -1,604 +0,0 @@ -"""Base Provider class for dynamic MCP components. - -This module provides the `Provider` abstraction for providing tools, -resources, and prompts dynamically at runtime. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.providers import Provider - from fastmcp.tools import Tool - - class DatabaseProvider(Provider): - def __init__(self, db_url: str): - super().__init__() - self.db = Database(db_url) - - async def _list_tools(self) -> list[Tool]: - rows = await self.db.fetch("SELECT * FROM tools") - return [self._make_tool(row) for row in rows] - - async def _get_tool(self, name: str) -> Tool | None: - row = await self.db.fetchone("SELECT * FROM tools WHERE name = ?", name) - return self._make_tool(row) if row else None - - mcp = FastMCP("Server", providers=[DatabaseProvider(db_url)]) - ``` -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator, Sequence -from contextlib import asynccontextmanager -from functools import partial -from typing import TYPE_CHECKING, Literal, cast - -from typing_extensions import Self - -from fastmcp.prompts.base import Prompt -from fastmcp.resources.base import Resource -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.transforms.visibility import Visibility -from fastmcp.tools.base import Tool -from fastmcp.utilities.async_utils import gather -from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.versions import VersionSpec, version_sort_key - -if TYPE_CHECKING: - from fastmcp.server.transforms import Transform - - -class Provider: - """Base class for dynamic component providers. - - Subclass and override whichever methods you need. Default implementations - return empty lists / None, so you only need to implement what your provider - supports. - - Provider semantics: - - Return `None` from `get_*` methods to indicate "I don't have it" (search continues) - - Static components (registered via decorators) always take precedence over providers - - Providers are queried in registration order; first non-None wins - - Components execute themselves via run()/read()/render() - providers just source them - - Error handling: - - `list_*` methods: Errors are logged and the provider returns empty (graceful degradation). - This allows other providers to still contribute their components. - """ - - def __init__(self) -> None: - self._transforms: list[Transform] = [] - - def __repr__(self) -> str: - return f"{self.__class__.__name__}()" - - @property - def transforms(self) -> list[Transform]: - """All transforms applied to components from this provider.""" - return list(self._transforms) - - def add_transform(self, transform: Transform) -> None: - """Add a transform to this provider. - - Transforms modify components (tools, resources, prompts) as they flow - through the provider. They're applied in order - first added is innermost. - - Args: - transform: The transform to add. - - Example: - ```python - from fastmcp.server.transforms import Namespace - - provider = MyProvider() - provider.add_transform(Namespace("api")) - # Tools become "api_toolname" - ``` - """ - self._transforms.append(transform) - - def wrap_transform(self, transform: Transform) -> Provider: - """Return a new provider with this transform applied (immutable). - - Unlike add_transform() which mutates this provider, wrap_transform() - returns a new provider that wraps this one. The original provider - is unchanged. - - This is useful when you want to apply transforms without side effects, - such as adding the same provider to multiple aggregators with different - namespaces. - - Args: - transform: The transform to apply. - - Returns: - A new provider that wraps this one with the transform applied. - - Example: - ```python - from fastmcp.server.transforms import Namespace - - provider = MyProvider() - namespaced = provider.wrap_transform(Namespace("api")) - # provider is unchanged - # namespaced returns tools as "api_toolname" - ``` - """ - # Import here to avoid circular imports - from fastmcp.server.providers.wrapped_provider import _WrappedProvider - - return _WrappedProvider(self, transform) - - # ------------------------------------------------------------------------- - # Internal transform chain building - # ------------------------------------------------------------------------- - - async def list_tools(self) -> Sequence[Tool]: - """List tools with all transforms applied. - - Applies transforms sequentially: base → transforms (in order). - Each transform receives the result from the previous transform. - Components may be marked as disabled but are NOT filtered here - - filtering happens at the server level to allow session transforms to override. - - Returns: - Transformed sequence of tools (including disabled ones). - """ - tools = await self._list_tools() - for transform in self.transforms: - tools = await transform.list_tools(tools) - return tools - - async def get_tool( - self, name: str, version: VersionSpec | None = None - ) -> Tool | None: - """Get tool by transformed name with all transforms applied. - - Note: This method does NOT filter disabled components. The Server - (FastMCP) performs enabled filtering after all transforms complete, - allowing session-level transforms to override provider-level disables. - - Args: - name: The transformed tool name to look up. - version: Optional version filter. If None, returns highest version. - - Returns: - The tool if found (may be marked disabled), None if not found. - """ - - async def base(n: str, version: VersionSpec | None = None) -> Tool | None: - return await self._get_tool(n, version) - - chain = base - for transform in self.transforms: - chain = partial(transform.get_tool, call_next=chain) - - return await chain(name, version=version) - - async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: - """Look up an app-visible tool by original name, bypassing transforms. - - Searches for a tool named ``tool_name`` tagged with the given app - name. Skips the transform chain entirely. - - Returns: - The tool if found and tagged with the given app name, else None. - """ - tool = await self._get_tool(tool_name) - if tool is not None: - meta = tool.meta or {} - fastmcp_meta = meta.get("fastmcp") - ui_meta = meta.get("ui") - # Must match app name AND have app visibility (not model-only) - visibility = ( - ui_meta.get("visibility", []) if isinstance(ui_meta, dict) else [] - ) - if ( - isinstance(fastmcp_meta, dict) - and fastmcp_meta.get("app") == app_name - and "app" in visibility - ): - return tool - return None - - async def list_resources(self) -> Sequence[Resource]: - """List resources with all transforms applied. - - Components may be marked as disabled but are NOT filtered here. - """ - resources = await self._list_resources() - for transform in self.transforms: - resources = await transform.list_resources(resources) - return resources - - async def get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Get resource by transformed URI with all transforms applied. - - Note: This method does NOT filter disabled components. The Server - (FastMCP) performs enabled filtering after all transforms complete. - - Args: - uri: The transformed resource URI to look up. - version: Optional version filter. If None, returns highest version. - - Returns: - The resource if found (may be marked disabled), None if not found. - """ - - async def base(u: str, version: VersionSpec | None = None) -> Resource | None: - return await self._get_resource(u, version) - - chain = base - for transform in self.transforms: - chain = partial(transform.get_resource, call_next=chain) - - return await chain(uri, version=version) - - async def list_resource_templates(self) -> Sequence[ResourceTemplate]: - """List resource templates with all transforms applied. - - Components may be marked as disabled but are NOT filtered here. - """ - templates = await self._list_resource_templates() - for transform in self.transforms: - templates = await transform.list_resource_templates(templates) - return templates - - async def get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Get resource template by transformed URI with all transforms applied. - - Note: This method does NOT filter disabled components. The Server - (FastMCP) performs enabled filtering after all transforms complete. - - Args: - uri: The transformed template URI to look up. - version: Optional version filter. If None, returns highest version. - - Returns: - The template if found (may be marked disabled), None if not found. - """ - - async def base( - u: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - return await self._get_resource_template(u, version) - - chain = base - for transform in self.transforms: - chain = partial(transform.get_resource_template, call_next=chain) - - return await chain(uri, version=version) - - async def list_prompts(self) -> Sequence[Prompt]: - """List prompts with all transforms applied. - - Components may be marked as disabled but are NOT filtered here. - """ - prompts = await self._list_prompts() - for transform in self.transforms: - prompts = await transform.list_prompts(prompts) - return prompts - - async def get_prompt( - self, name: str, version: VersionSpec | None = None - ) -> Prompt | None: - """Get prompt by transformed name with all transforms applied. - - Note: This method does NOT filter disabled components. The Server - (FastMCP) performs enabled filtering after all transforms complete. - - Args: - name: The transformed prompt name to look up. - version: Optional version filter. If None, returns highest version. - - Returns: - The prompt if found (may be marked disabled), None if not found. - """ - - async def base(n: str, version: VersionSpec | None = None) -> Prompt | None: - return await self._get_prompt(n, version) - - chain = base - for transform in self.transforms: - chain = partial(transform.get_prompt, call_next=chain) - - return await chain(name, version=version) - - # ------------------------------------------------------------------------- - # Private list/get methods (override these to provide components) - # ------------------------------------------------------------------------- - - async def _list_tools(self) -> Sequence[Tool]: - """Return all available tools. - - Override to provide tools dynamically. Returns ALL versions of all tools. - The server handles deduplication to show one tool per name. - """ - return [] - - async def _get_tool( - self, name: str, version: VersionSpec | None = None - ) -> Tool | None: - """Get a specific tool by name. - - Default implementation filters _list_tools() and picks the highest version - that matches the spec. - - Args: - name: The tool name. - version: Optional version filter. If None, returns highest version. - If specified, returns highest version matching the spec. - - Returns: - The Tool if found, or None to continue searching other providers. - """ - tools = await self._list_tools() - matching = [t for t in tools if t.name == name] - if version: - matching = [t for t in matching if version.matches(t.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - async def _list_resources(self) -> Sequence[Resource]: - """Return all available resources. - - Override to provide resources dynamically. Returns ALL versions of all resources. - The server handles deduplication to show one resource per URI. - """ - return [] - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Get a specific resource by URI. - - Default implementation filters _list_resources() and returns highest - version matching the spec. - - Args: - uri: The resource URI. - version: Optional version filter. If None, returns highest version. - - Returns: - The Resource if found, or None to continue searching other providers. - """ - resources = await self._list_resources() - matching = [r for r in resources if str(r.uri) == uri] - if version: - matching = [r for r in matching if version.matches(r.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - """Return all available resource templates. - - Override to provide resource templates dynamically. Returns ALL versions. - The server handles deduplication. - """ - return [] - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Get a resource template that matches the given URI. - - Default implementation lists all templates, finds those whose pattern - matches the URI, and returns the highest version matching the spec. - - Args: - uri: The URI to match against templates. - version: Optional version filter. If None, returns highest version. - - Returns: - The ResourceTemplate if a matching one is found, or None to continue searching. - """ - templates = await self._list_resource_templates() - matching = [t for t in templates if t.matches(uri) is not None] - if version: - matching = [t for t in matching if version.matches(t.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - async def _list_prompts(self) -> Sequence[Prompt]: - """Return all available prompts. - - Override to provide prompts dynamically. Returns ALL versions of all prompts. - The server handles deduplication to show one prompt per name. - """ - return [] - - async def _get_prompt( - self, name: str, version: VersionSpec | None = None - ) -> Prompt | None: - """Get a specific prompt by name. - - Default implementation filters _list_prompts() and picks the highest version - matching the spec. - - Args: - name: The prompt name. - version: Optional version filter. If None, returns highest version. - - Returns: - The Prompt if found, or None to continue searching other providers. - """ - prompts = await self._list_prompts() - matching = [p for p in prompts if p.name == name] - if version: - matching = [p for p in matching if version.matches(p.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - # ------------------------------------------------------------------------- - # Task registration - # ------------------------------------------------------------------------- - - async def get_tasks(self) -> Sequence[FastMCPComponent]: - """Return components that should be registered as background tasks. - - Override to customize which components are task-eligible. - Default calls list_* methods, applies provider transforms, and filters - for components with task_config.mode != 'forbidden'. - - Used by the server during startup to register functions with Docket. - """ - # Fetch all component types in parallel - results = await gather( - self._list_tools(), - self._list_resources(), - self._list_resource_templates(), - self._list_prompts(), - ) - tools = cast(Sequence[Tool], results[0]) - resources = cast(Sequence[Resource], results[1]) - templates = cast(Sequence[ResourceTemplate], results[2]) - prompts = cast(Sequence[Prompt], results[3]) - - # Apply provider's own transforms sequentially - # For tasks, we need the fully-transformed names - for transform in self.transforms: - tools = await transform.list_tools(tools) - resources = await transform.list_resources(resources) - templates = await transform.list_resource_templates(templates) - prompts = await transform.list_prompts(prompts) - - return [ - c - for c in [ - *tools, - *resources, - *templates, - *prompts, - ] - if c.task_config.supports_tasks() - ] - - # ------------------------------------------------------------------------- - # Lifecycle methods - # ------------------------------------------------------------------------- - - @asynccontextmanager - async def lifespan(self) -> AsyncIterator[None]: - """User-overridable lifespan for custom setup and teardown. - - Override this method to perform provider-specific initialization - like opening database connections, setting up external resources, - or other state management needed for the provider's lifetime. - - The lifespan scope matches the server's lifespan - code before yield - runs at startup, code after yield runs at shutdown. - - Example: - ```python - @asynccontextmanager - async def lifespan(self): - # Setup - self.db = await connect_database() - try: - yield - finally: - # Teardown - await self.db.close() - ``` - """ - yield - - # ------------------------------------------------------------------------- - # Enable/Disable - # ------------------------------------------------------------------------- - - def enable( - self, - *, - names: set[str] | None = None, - keys: set[str] | None = None, - version: VersionSpec | None = None, - tags: set[str] | None = None, - components: set[Literal["tool", "resource", "template", "prompt"]] - | None = None, - only: bool = False, - ) -> Self: - """Enable components matching all specified criteria. - - Adds a visibility transform that marks matching components as enabled. - Later transforms override earlier ones, so enable after disable makes - the component enabled. - - With only=True, switches to allowlist mode - first disables everything, - then enables matching components. - - Args: - names: Component names or URIs to enable. - keys: Component keys to enable (e.g., {"tool:my_tool@v1"}). - version: Component version spec to enable (e.g., VersionSpec(eq="v1") or - VersionSpec(gte="v2")). Unversioned components will not match. - tags: Enable components with these tags. - components: Component types to include (e.g., {"tool", "prompt"}). - only: If True, ONLY enable matching components (allowlist mode). - - Returns: - Self for method chaining. - """ - if only: - # Allowlist: disable everything, then enable matching - # The enable transform runs later on return path, so it overrides - self._transforms.append(Visibility(False, match_all=True)) - self._transforms.append( - Visibility( - True, - names=names, - keys=keys, - version=version, - components=set(components) if components else None, - tags=set(tags) if tags else None, - ) - ) - - return self - - def disable( - self, - *, - names: set[str] | None = None, - keys: set[str] | None = None, - version: VersionSpec | None = None, - tags: set[str] | None = None, - components: set[Literal["tool", "resource", "template", "prompt"]] - | None = None, - ) -> Self: - """Disable components matching all specified criteria. - - Adds a visibility transform that marks matching components as disabled. - Components can be re-enabled by calling enable() with matching criteria - (the later transform wins). - - Args: - names: Component names or URIs to disable. - keys: Component keys to disable (e.g., {"tool:my_tool@v1"}). - version: Component version spec to disable (e.g., VersionSpec(eq="v1") or - VersionSpec(gte="v2")). Unversioned components will not match. - tags: Disable components with these tags. - components: Component types to include (e.g., {"tool", "prompt"}). - - Returns: - Self for method chaining. - """ - self._transforms.append( - Visibility( - False, - names=names, - keys=keys, - version=version, - components=set(components) if components else None, - tags=set(tags) if tags else None, - ) - ) - return self diff --git a/src/fastmcp/server/providers/fastmcp_provider.py b/src/fastmcp/server/providers/fastmcp_provider.py deleted file mode 100644 index f13b241c1..000000000 --- a/src/fastmcp/server/providers/fastmcp_provider.py +++ /dev/null @@ -1,720 +0,0 @@ -"""FastMCPProvider for wrapping FastMCP servers as providers. - -This module provides the `FastMCPProvider` class that wraps a FastMCP server -and exposes its components through the Provider interface. - -It also provides FastMCPProvider* component classes that delegate execution to -the wrapped server's middleware, ensuring middleware runs when components are -executed. -""" - -from __future__ import annotations - -import re -from collections.abc import AsyncIterator, Sequence -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING, Any, overload -from urllib.parse import quote - -import mcp.types -from mcp.types import AnyUrl - -from fastmcp.prompts.base import Prompt, PromptResult -from fastmcp.resources.base import Resource, ResourceResult -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.providers.base import Provider -from fastmcp.server.tasks.config import TaskMeta -from fastmcp.server.telemetry import delegate_span -from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.versions import VersionSpec - -if TYPE_CHECKING: - from docket import Docket - from docket.execution import Execution - - from fastmcp.server.server import FastMCP - - -def _expand_uri_template(template: str, params: dict[str, Any]) -> str: - """Expand a URI template with parameters. - - Handles both {name} path placeholders and RFC 6570 {?param1,param2} - query parameter syntax. - """ - result = template - - # Replace {name} path placeholders - for key, value in params.items(): - result = re.sub(rf"\{{{key}\}}", str(value), result) - - # Expand {?param1,param2,...} query parameter blocks - def _expand_query_block(match: re.Match[str]) -> str: - names = [n.strip() for n in match.group(1).split(",")] - parts = [] - for name in names: - if name in params: - parts.append(f"{quote(name)}={quote(str(params[name]))}") - if parts: - return "?" + "&".join(parts) - return "" - - result = re.sub(r"\{\?([^}]+)\}", _expand_query_block, result) - - return result - - -# ----------------------------------------------------------------------------- -# FastMCPProvider component classes -# ----------------------------------------------------------------------------- - - -class FastMCPProviderTool(Tool): - """Tool that delegates execution to a wrapped server's middleware. - - When `run()` is called, this tool invokes the wrapped server's - `_call_tool_middleware()` method, ensuring the server's middleware - chain is executed. - """ - - _server: Any = None # FastMCP, but Any to avoid circular import - _original_name: str | None = None - - def __init__( - self, - server: Any, - original_name: str, - **kwargs: Any, - ): - super().__init__(**kwargs) - self._server = server - self._original_name = original_name - - @classmethod - def wrap(cls, server: Any, tool: Tool) -> FastMCPProviderTool: - """Wrap a Tool to delegate execution to the server's middleware.""" - return cls( - server=server, - original_name=tool.name, - name=tool.name, - version=tool.version, - description=tool.description, - parameters=tool.parameters, - output_schema=tool.output_schema, - tags=tool.tags, - annotations=tool.annotations, - task_config=tool.task_config, - execution=tool.execution, - meta=tool.get_meta(), - title=tool.title, - icons=tool.icons, - ) - - @overload - async def _run( - self, - arguments: dict[str, Any], - task_meta: None = None, - ) -> ToolResult: ... - - @overload - async def _run( - self, - arguments: dict[str, Any], - task_meta: TaskMeta, - ) -> mcp.types.CreateTaskResult: ... - - async def _run( - self, - arguments: dict[str, Any], - task_meta: TaskMeta | None = None, - ) -> ToolResult | mcp.types.CreateTaskResult: - """Delegate to child server's call_tool() with task_meta. - - Passes task_meta through to the child server so it can handle - backgrounding appropriately. fn_key is already set by the parent - server before calling this method. - """ - # Pass exact version so child executes the correct version - version = VersionSpec(eq=self.version) if self.version else None - - with delegate_span( - self._original_name or "", "FastMCPProvider", self._original_name or "" - ): - return await self._server.call_tool( - self._original_name, - arguments, - version=version, - task_meta=task_meta, - ) - - async def run(self, arguments: dict[str, Any]) -> ToolResult: - """Delegate to child server's call_tool() without task_meta. - - This is called when the tool is used within a TransformedTool - forwarding function or other contexts where task_meta is not available. - """ - # Pass exact version so child executes the correct version - version = VersionSpec(eq=self.version) if self.version else None - - result = await self._server.call_tool( - self._original_name, arguments, version=version - ) - # Result from call_tool should always be ToolResult when no task_meta - if isinstance(result, mcp.types.CreateTaskResult): - raise RuntimeError( - "Unexpected CreateTaskResult from call_tool without task_meta" - ) - return result - - def get_span_attributes(self) -> dict[str, Any]: - return super().get_span_attributes() | { - "fastmcp.provider.type": "FastMCPProvider", - "fastmcp.delegate.original_name": self._original_name, - } - - -class FastMCPProviderResource(Resource): - """Resource that delegates reading to a wrapped server's read_resource(). - - When `read()` is called, this resource invokes the wrapped server's - `read_resource()` method, ensuring the server's middleware chain is executed. - """ - - _server: Any = None # FastMCP, but Any to avoid circular import - _original_uri: str | None = None - - def __init__( - self, - server: Any, - original_uri: str, - **kwargs: Any, - ): - super().__init__(**kwargs) - self._server = server - self._original_uri = original_uri - - @classmethod - def wrap(cls, server: Any, resource: Resource) -> FastMCPProviderResource: - """Wrap a Resource to delegate reading to the server's middleware.""" - return cls( - server=server, - original_uri=str(resource.uri), - uri=resource.uri, - version=resource.version, - name=resource.name, - description=resource.description, - mime_type=resource.mime_type, - tags=resource.tags, - annotations=resource.annotations, - task_config=resource.task_config, - meta=resource.get_meta(), - title=resource.title, - icons=resource.icons, - ) - - @overload - async def _read(self, task_meta: None = None) -> ResourceResult: ... - - @overload - async def _read(self, task_meta: TaskMeta) -> mcp.types.CreateTaskResult: ... - - async def _read( - self, task_meta: TaskMeta | None = None - ) -> ResourceResult | mcp.types.CreateTaskResult: - """Delegate to child server's read_resource() with task_meta. - - Passes task_meta through to the child server so it can handle - backgrounding appropriately. fn_key is already set by the parent - server before calling this method. - """ - # Pass exact version so child reads the correct version - version = VersionSpec(eq=self.version) if self.version else None - - with delegate_span( - self._original_uri or "", "FastMCPProvider", self._original_uri or "" - ): - return await self._server.read_resource( - self._original_uri, version=version, task_meta=task_meta - ) - - def get_span_attributes(self) -> dict[str, Any]: - return super().get_span_attributes() | { - "fastmcp.provider.type": "FastMCPProvider", - "fastmcp.delegate.original_uri": self._original_uri, - } - - -class FastMCPProviderPrompt(Prompt): - """Prompt that delegates rendering to a wrapped server's render_prompt(). - - When `render()` is called, this prompt invokes the wrapped server's - `render_prompt()` method, ensuring the server's middleware chain is executed. - """ - - _server: Any = None # FastMCP, but Any to avoid circular import - _original_name: str | None = None - - def __init__( - self, - server: Any, - original_name: str, - **kwargs: Any, - ): - super().__init__(**kwargs) - self._server = server - self._original_name = original_name - - @classmethod - def wrap(cls, server: Any, prompt: Prompt) -> FastMCPProviderPrompt: - """Wrap a Prompt to delegate rendering to the server's middleware.""" - return cls( - server=server, - original_name=prompt.name, - name=prompt.name, - version=prompt.version, - description=prompt.description, - arguments=prompt.arguments, - tags=prompt.tags, - task_config=prompt.task_config, - meta=prompt.get_meta(), - title=prompt.title, - icons=prompt.icons, - ) - - @overload - async def _render( - self, - arguments: dict[str, Any] | None = None, - task_meta: None = None, - ) -> PromptResult: ... - - @overload - async def _render( - self, - arguments: dict[str, Any] | None, - task_meta: TaskMeta, - ) -> mcp.types.CreateTaskResult: ... - - async def _render( - self, - arguments: dict[str, Any] | None = None, - task_meta: TaskMeta | None = None, - ) -> PromptResult | mcp.types.CreateTaskResult: - """Delegate to child server's render_prompt() with task_meta. - - Passes task_meta through to the child server so it can handle - backgrounding appropriately. fn_key is already set by the parent - server before calling this method. - """ - # Pass exact version so child renders the correct version - version = VersionSpec(eq=self.version) if self.version else None - - with delegate_span( - self._original_name or "", "FastMCPProvider", self._original_name or "" - ): - return await self._server.render_prompt( - self._original_name, arguments, version=version, task_meta=task_meta - ) - - async def render(self, arguments: dict[str, Any] | None = None) -> PromptResult: - """Delegate to child server's render_prompt() without task_meta. - - This is called when the prompt is used within a transformed context - or other contexts where task_meta is not available. - """ - # Pass exact version so child renders the correct version - version = VersionSpec(eq=self.version) if self.version else None - - result = await self._server.render_prompt( - self._original_name, arguments, version=version - ) - # Result from render_prompt should always be PromptResult when no task_meta - if isinstance(result, mcp.types.CreateTaskResult): - raise RuntimeError( - "Unexpected CreateTaskResult from render_prompt without task_meta" - ) - return result - - def get_span_attributes(self) -> dict[str, Any]: - return super().get_span_attributes() | { - "fastmcp.provider.type": "FastMCPProvider", - "fastmcp.delegate.original_name": self._original_name, - } - - -class FastMCPProviderResourceTemplate(ResourceTemplate): - """Resource template that creates FastMCPProviderResources. - - When `create_resource()` is called, this template creates a - FastMCPProviderResource that will invoke the wrapped server's middleware - when read. - """ - - _server: Any = None # FastMCP, but Any to avoid circular import - _original_uri_template: str | None = None - - def __init__( - self, - server: Any, - original_uri_template: str, - **kwargs: Any, - ): - super().__init__(**kwargs) - self._server = server - self._original_uri_template = original_uri_template - - @classmethod - def wrap( - cls, server: Any, template: ResourceTemplate - ) -> FastMCPProviderResourceTemplate: - """Wrap a ResourceTemplate to create FastMCPProviderResources.""" - return cls( - server=server, - original_uri_template=template.uri_template, - uri_template=template.uri_template, - version=template.version, - name=template.name, - description=template.description, - mime_type=template.mime_type, - parameters=template.parameters, - tags=template.tags, - annotations=template.annotations, - task_config=template.task_config, - meta=template.get_meta(), - title=template.title, - icons=template.icons, - ) - - async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: - """Create a FastMCPProviderResource for the given URI. - - The `uri` is the external/transformed URI (e.g., with namespace prefix). - We use `_original_uri_template` with `params` to construct the internal - URI that the nested server understands. - """ - # Expand the original template with params to get internal URI - original_uri = _expand_uri_template(self._original_uri_template or "", params) - return FastMCPProviderResource( - server=self._server, - original_uri=original_uri, - uri=AnyUrl(uri), - name=self.name, - description=self.description, - mime_type=self.mime_type, - ) - - @overload - async def _read( - self, uri: str, params: dict[str, Any], task_meta: None = None - ) -> ResourceResult: ... - - @overload - async def _read( - self, uri: str, params: dict[str, Any], task_meta: TaskMeta - ) -> mcp.types.CreateTaskResult: ... - - async def _read( - self, uri: str, params: dict[str, Any], task_meta: TaskMeta | None = None - ) -> ResourceResult | mcp.types.CreateTaskResult: - """Delegate to child server's read_resource() with task_meta. - - Passes task_meta through to the child server so it can handle - backgrounding appropriately. fn_key is already set by the parent - server before calling this method. - """ - # Expand the original template with params to get internal URI - original_uri = _expand_uri_template(self._original_uri_template or "", params) - - # Pass exact version so child reads the correct version - version = VersionSpec(eq=self.version) if self.version else None - - with delegate_span( - original_uri, "FastMCPProvider", self._original_uri_template or "" - ): - return await self._server.read_resource( - original_uri, version=version, task_meta=task_meta - ) - - async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: - """Read the resource content for background task execution. - - Reads the resource via the wrapped server and returns the ResourceResult. - This method is called by Docket during background task execution. - """ - # Expand the original template with arguments to get internal URI - original_uri = _expand_uri_template( - self._original_uri_template or "", arguments - ) - - # Pass exact version so child reads the correct version - version = VersionSpec(eq=self.version) if self.version else None - - # Read from the wrapped server - result = await self._server.read_resource(original_uri, version=version) - if isinstance(result, mcp.types.CreateTaskResult): - raise RuntimeError("Unexpected CreateTaskResult during Docket execution") - - return result - - def register_with_docket(self, docket: Docket) -> None: - """No-op: the child's actual template is registered via get_tasks().""" - - async def add_to_docket( - self, - docket: Docket, - params: dict[str, Any], - *, - fn_key: str | None = None, - task_key: str | None = None, - **kwargs: Any, - ) -> Execution: - """Schedule this template for background execution via docket. - - The child's FunctionResourceTemplate.fn is registered (via get_tasks), - and it expects splatted **kwargs, so we splat params here. - """ - lookup_key = fn_key or self.key - if task_key: - kwargs["key"] = task_key - return await docket.add(lookup_key, **kwargs)(**params) - - def get_span_attributes(self) -> dict[str, Any]: - return super().get_span_attributes() | { - "fastmcp.provider.type": "FastMCPProvider", - "fastmcp.delegate.original_uri_template": self._original_uri_template, - } - - -# ----------------------------------------------------------------------------- -# FastMCPProvider -# ----------------------------------------------------------------------------- - - -class FastMCPProvider(Provider): - """Provider that wraps a FastMCP server. - - This provider enables mounting one FastMCP server onto another, exposing - the mounted server's tools, resources, and prompts through the parent - server. - - Components returned by this provider are wrapped in FastMCPProvider* - classes that delegate execution to the wrapped server's middleware chain. - This ensures middleware runs when components are executed. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.providers import FastMCPProvider - - main = FastMCP("Main") - sub = FastMCP("Sub") - - @sub.tool - def greet(name: str) -> str: - return f"Hello, {name}!" - - # Mount directly - tools accessible by original names - main.add_provider(FastMCPProvider(sub)) - - # Or with namespace - from fastmcp.server.transforms import Namespace - provider = FastMCPProvider(sub) - provider.add_transform(Namespace("sub")) - main.add_provider(provider) - ``` - - Note: - Normally you would use `FastMCP.mount()` which handles proxy conversion - and creates the provider with namespace automatically. - """ - - def __init__(self, server: FastMCP[Any]): - """Initialize a FastMCPProvider. - - Args: - server: The FastMCP server to wrap. - """ - super().__init__() - self.server = server - - # ------------------------------------------------------------------------- - # Tool methods - # ------------------------------------------------------------------------- - - async def _list_tools(self) -> Sequence[Tool]: - """List all tools from the mounted server as FastMCPProviderTools. - - Runs the mounted server's middleware so filtering/transformation applies. - Wraps each tool as a FastMCPProviderTool that delegates execution to - the nested server's middleware. - """ - raw_tools = await self.server.list_tools() - return [FastMCPProviderTool.wrap(self.server, t) for t in raw_tools] - - async def _get_tool( - self, name: str, version: VersionSpec | None = None - ) -> Tool | None: - """Get a tool by name as a FastMCPProviderTool. - - Passes the full VersionSpec to the nested server, which handles both - exact version matching and range filtering. Uses get_tool to ensure - the nested server's transforms are applied. - """ - raw_tool = await self.server.get_tool(name, version) - if raw_tool is None: - return None - return FastMCPProviderTool.wrap(self.server, raw_tool) - - async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: - """Delegate to nested server's get_app_tool, wrapping for middleware.""" - raw_tool = await self.server.get_app_tool(app_name, tool_name) - if raw_tool is None: - return None - wrapped = FastMCPProviderTool.wrap(self.server, raw_tool) - # Use the ___-prefixed name so the inner server's call_tool also - # takes the app-tool bypass path (app-only tools are hidden from - # normal get_tool visibility filtering). - wrapped._original_name = f"{app_name}___{tool_name}" - return wrapped - - # ------------------------------------------------------------------------- - # Resource methods - # ------------------------------------------------------------------------- - - async def _list_resources(self) -> Sequence[Resource]: - """List all resources from the mounted server as FastMCPProviderResources. - - Runs the mounted server's middleware so filtering/transformation applies. - Wraps each resource as a FastMCPProviderResource that delegates reading - to the nested server's middleware. - """ - raw_resources = await self.server.list_resources() - return [FastMCPProviderResource.wrap(self.server, r) for r in raw_resources] - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Get a concrete resource by URI as a FastMCPProviderResource. - - Passes the full VersionSpec to the nested server, which handles both - exact version matching and range filtering. Uses get_resource to ensure - the nested server's transforms are applied. - """ - raw_resource = await self.server.get_resource(uri, version) - if raw_resource is None: - return None - return FastMCPProviderResource.wrap(self.server, raw_resource) - - # ------------------------------------------------------------------------- - # Resource template methods - # ------------------------------------------------------------------------- - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - """List all resource templates from the mounted server. - - Runs the mounted server's middleware so filtering/transformation applies. - Returns FastMCPProviderResourceTemplate instances that create - FastMCPProviderResources when materialized. - """ - raw_templates = await self.server.list_resource_templates() - return [ - FastMCPProviderResourceTemplate.wrap(self.server, t) for t in raw_templates - ] - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Get a resource template that matches the given URI. - - Passes the full VersionSpec to the nested server, which handles both - exact version matching and range filtering. Uses get_resource_template - to ensure the nested server's transforms are applied. - """ - raw_template = await self.server.get_resource_template(uri, version) - if raw_template is None: - return None - return FastMCPProviderResourceTemplate.wrap(self.server, raw_template) - - # ------------------------------------------------------------------------- - # Prompt methods - # ------------------------------------------------------------------------- - - async def _list_prompts(self) -> Sequence[Prompt]: - """List all prompts from the mounted server as FastMCPProviderPrompts. - - Runs the mounted server's middleware so filtering/transformation applies. - Returns FastMCPProviderPrompt instances that delegate rendering to the - wrapped server's middleware. - """ - raw_prompts = await self.server.list_prompts() - return [FastMCPProviderPrompt.wrap(self.server, p) for p in raw_prompts] - - async def _get_prompt( - self, name: str, version: VersionSpec | None = None - ) -> Prompt | None: - """Get a prompt by name as a FastMCPProviderPrompt. - - Passes the full VersionSpec to the nested server, which handles both - exact version matching and range filtering. Uses get_prompt to ensure - the nested server's transforms are applied. - """ - raw_prompt = await self.server.get_prompt(name, version) - if raw_prompt is None: - return None - return FastMCPProviderPrompt.wrap(self.server, raw_prompt) - - # ------------------------------------------------------------------------- - # Task registration - # ------------------------------------------------------------------------- - - async def get_tasks(self) -> Sequence[FastMCPComponent]: - """Return task-eligible components from the mounted server. - - Returns the child's ACTUAL components (not wrapped) so their actual - functions get registered with Docket. Gets components with child - server's transforms applied, then applies this provider's transforms - for correct registration keys. - """ - # Get tasks with child server's transforms already applied - components = list(await self.server.get_tasks()) - - # Separate by type for this provider's transform application - tools = [c for c in components if isinstance(c, Tool)] - resources = [c for c in components if isinstance(c, Resource)] - templates = [c for c in components if isinstance(c, ResourceTemplate)] - prompts = [c for c in components if isinstance(c, Prompt)] - - # Apply this provider's transforms sequentially - for transform in self.transforms: - tools = await transform.list_tools(tools) - resources = await transform.list_resources(resources) - templates = await transform.list_resource_templates(templates) - prompts = await transform.list_prompts(prompts) - - # Filter to only task-eligible components (same as base Provider) - return [ - c - for c in [ - *tools, - *resources, - *templates, - *prompts, - ] - if c.task_config.supports_tasks() - ] - - # ------------------------------------------------------------------------- - # Lifecycle methods - # ------------------------------------------------------------------------- - - @asynccontextmanager - async def lifespan(self) -> AsyncIterator[None]: - """Start the mounted server's user lifespan. - - This starts only the wrapped server's user-defined lifespan, NOT its - full _lifespan_manager() (which includes Docket). The parent server's - Docket handles all background tasks. - """ - async with self.server._lifespan(self.server): - yield diff --git a/src/fastmcp/server/providers/filesystem.py b/src/fastmcp/server/providers/filesystem.py deleted file mode 100644 index 774021dca..000000000 --- a/src/fastmcp/server/providers/filesystem.py +++ /dev/null @@ -1,226 +0,0 @@ -"""FileSystemProvider for filesystem-based component discovery. - -FileSystemProvider scans a directory for Python files, imports them, and -registers any Tool, Resource, ResourceTemplate, or Prompt objects found. - -Components are created using the standalone decorators from fastmcp.tools, -fastmcp.resources, and fastmcp.prompts: - -Example: - ```python - # In mcp/tools.py - from fastmcp.tools import tool - - @tool - def greet(name: str) -> str: - return f"Hello, {name}!" - - # In main.py - from pathlib import Path - - from fastmcp import FastMCP - from fastmcp.server.providers import FileSystemProvider - - mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp")]) - ``` -""" - -from __future__ import annotations - -import asyncio -from collections.abc import Sequence -from pathlib import Path - -from fastmcp.prompts.base import Prompt -from fastmcp.resources.base import Resource -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.providers.filesystem_discovery import discover_and_import -from fastmcp.server.providers.local_provider import LocalProvider -from fastmcp.tools.base import Tool -from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.versions import VersionSpec - -logger = get_logger(__name__) - - -class FileSystemProvider(LocalProvider): - """Provider that discovers components from the filesystem. - - Scans a directory for Python files and registers any Tool, Resource, - ResourceTemplate, or Prompt objects found. Components are created using - the standalone decorators: - - @tool from fastmcp.tools - - @resource from fastmcp.resources - - @prompt from fastmcp.prompts - - Args: - root: Root directory to scan. Defaults to current directory. - reload: If True, re-scan files on every request (dev mode). - Defaults to False (scan once at init, cache results). - - Example: - ```python - # In mcp/tools.py - from fastmcp.tools import tool - - @tool - def greet(name: str) -> str: - return f"Hello, {name}!" - - # In main.py - from pathlib import Path - - from fastmcp import FastMCP - from fastmcp.server.providers import FileSystemProvider - - # Path relative to this file - mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp")]) - - # Dev mode - re-scan on every request - mcp = FastMCP("MyServer", providers=[FileSystemProvider(Path(__file__).parent / "mcp", reload=True)]) - ``` - """ - - def __init__( - self, - root: str | Path = ".", - reload: bool = False, - ) -> None: - super().__init__(on_duplicate="replace") - self._root = Path(root).resolve() - self._reload = reload - self._loaded = False - # Track files we've warned about: path -> mtime when warned - # Re-warn if file changes (mtime differs) - self._warned_files: dict[Path, float] = {} - # Lock for serializing reload operations (created lazily) - self._reload_lock: asyncio.Lock | None = None - - # Always load once at init to catch errors early - self._load_components() - - def _load_components(self) -> None: - """Discover and register all components from the filesystem.""" - # Clear existing components if reloading - if self._loaded: - self._components.clear() - - result = discover_and_import(self._root) - - # Log warnings for failed files (only once per file version) - for file_path, error in result.failed_files.items(): - try: - current_mtime = file_path.stat().st_mtime - except OSError: - current_mtime = 0.0 - - # Warn if we haven't warned about this file, or if it changed - last_warned_mtime = self._warned_files.get(file_path) - if last_warned_mtime is None or last_warned_mtime != current_mtime: - logger.warning(f"Failed to import {file_path}: {error}") - self._warned_files[file_path] = current_mtime - - # Clear warnings for files that now import successfully - successful_files = {fp for fp, _ in result.components} - for fp in successful_files: - self._warned_files.pop(fp, None) - - for file_path, component in result.components: - try: - self._register_component(component) - except Exception: - logger.exception( - "Failed to register %s from %s", - getattr(component, "name", repr(component)), - file_path, - ) - - self._loaded = True - logger.debug( - f"FileSystemProvider loaded {len(self._components)} components from {self._root}" - ) - - def _register_component(self, component: FastMCPComponent) -> None: - """Register a single component based on its type.""" - if isinstance(component, Tool): - self.add_tool(component) - elif isinstance(component, ResourceTemplate): - self.add_template(component) - elif isinstance(component, Resource): - self.add_resource(component) - elif isinstance(component, Prompt): - self.add_prompt(component) - else: - logger.debug("Ignoring unknown component type: %r", type(component)) - - async def _ensure_loaded(self) -> None: - """Ensure components are loaded, reloading if in reload mode. - - Uses a lock to serialize concurrent reload operations and runs - filesystem I/O off the event loop using asyncio.to_thread. - """ - if not self._reload and self._loaded: - return - - # Create lock lazily (can't create in __init__ without event loop) - if self._reload_lock is None: - self._reload_lock = asyncio.Lock() - - async with self._reload_lock: - # Double-check after acquiring lock - if self._reload or not self._loaded: - await asyncio.to_thread(self._load_components) - - # Override provider methods to support reload mode - - async def _list_tools(self) -> Sequence[Tool]: - """Return all tools, reloading if in reload mode.""" - await self._ensure_loaded() - return await super()._list_tools() - - async def _get_tool( - self, name: str, version: VersionSpec | None = None - ) -> Tool | None: - """Get a tool by name, reloading if in reload mode.""" - await self._ensure_loaded() - return await super()._get_tool(name, version) - - async def _list_resources(self) -> Sequence[Resource]: - """Return all resources, reloading if in reload mode.""" - await self._ensure_loaded() - return await super()._list_resources() - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Get a resource by URI, reloading if in reload mode.""" - await self._ensure_loaded() - return await super()._get_resource(uri, version) - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - """Return all resource templates, reloading if in reload mode.""" - await self._ensure_loaded() - return await super()._list_resource_templates() - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Get a resource template, reloading if in reload mode.""" - await self._ensure_loaded() - return await super()._get_resource_template(uri, version) - - async def _list_prompts(self) -> Sequence[Prompt]: - """Return all prompts, reloading if in reload mode.""" - await self._ensure_loaded() - return await super()._list_prompts() - - async def _get_prompt( - self, name: str, version: VersionSpec | None = None - ) -> Prompt | None: - """Get a prompt by name, reloading if in reload mode.""" - await self._ensure_loaded() - return await super()._get_prompt(name, version) - - def __repr__(self) -> str: - return f"FileSystemProvider(root={self._root!r}, reload={self._reload})" diff --git a/src/fastmcp/server/providers/filesystem_discovery.py b/src/fastmcp/server/providers/filesystem_discovery.py deleted file mode 100644 index db8e0ce83..000000000 --- a/src/fastmcp/server/providers/filesystem_discovery.py +++ /dev/null @@ -1,388 +0,0 @@ -"""File discovery and module import utilities for filesystem-based routing. - -This module provides functions to: -1. Discover Python files in a directory tree -2. Import modules (as packages if __init__.py exists, else directly) -3. Extract decorated components (Tool, Resource, Prompt objects) from imported modules -""" - -from __future__ import annotations - -import contextlib -import hashlib -import importlib.util -import sys -from dataclasses import dataclass, field -from pathlib import Path -from types import ModuleType - -from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - - -@dataclass -class DiscoveryResult: - """Result of filesystem discovery.""" - - # Components are real objects (Tool, Resource, ResourceTemplate, Prompt) - components: list[tuple[Path, FastMCPComponent]] = field(default_factory=list) - failed_files: dict[Path, str] = field(default_factory=dict) # path -> error message - - -def discover_files(root: Path) -> list[Path]: - """Recursively discover all Python files under a directory. - - Excludes __init__.py files (they're for package structure, not components). - - Args: - root: Root directory to scan. - - Returns: - List of .py file paths, sorted for deterministic order. - """ - if not root.exists(): - return [] - - if not root.is_dir(): - # If root is a file, just return it (if it's a .py file) - if root.suffix == ".py" and root.name != "__init__.py": - return [root] - return [] - - files: list[Path] = [] - for path in root.rglob("*.py"): - # Skip __init__.py files - if path.name == "__init__.py": - continue - # Skip __pycache__ directories - if "__pycache__" in path.parts: - continue - files.append(path) - - # Sort for deterministic discovery order - return sorted(files) - - -def _is_package_dir(directory: Path) -> bool: - """Check if a directory is a Python package (has __init__.py).""" - return (directory / "__init__.py").exists() - - -def _find_package_root(file_path: Path, stop_at: Path | None = None) -> Path | None: - """Find the root of the package containing this file. - - Walks up the directory tree until we find a directory without __init__.py, - but never above stop_at (the provider root). This prevents escaping into - ancestor packages when the provider is nested inside a larger Python project. - - Args: - file_path: Path to the Python file. - stop_at: Do not walk above this directory. Typically the provider root. - - Returns: - The package root directory, or None if not in a package. - """ - current = file_path.parent - package_root = None - - while current != current.parent: # Stop at filesystem root - if stop_at is not None and current == stop_at.parent: - break # Don't escape above the provider root - if _is_package_dir(current): - package_root = current - current = current.parent - else: - break - - return package_root - - -def _compute_module_name(file_path: Path, package_root: Path) -> str: - """Compute the dotted module name for a file within a package. - - Args: - file_path: Path to the Python file. - package_root: Root directory of the package. - - Returns: - Dotted module name (e.g., "mcp.tools.greet"). - """ - relative = file_path.relative_to(package_root.parent) - parts = list(relative.parts) - # Remove .py extension from last part - parts[-1] = parts[-1].removesuffix(".py") - return ".".join(parts) - - -def import_module_from_file( - file_path: Path, provider_root: Path | None = None -) -> ModuleType: - """Import a Python file as a module. - - If the file is part of a package (directory has __init__.py), imports - it as a proper package member (relative imports work). Otherwise, - imports directly using spec_from_file_location. - - sys.path is modified only for the duration of the import and restored - immediately after, so no permanent pollution occurs. - - Args: - file_path: Path to the Python file. - provider_root: The provider's root directory. Prevents package root - discovery from walking above this boundary into ancestor packages. - - Returns: - The imported module. - - Raises: - ImportError: If the module cannot be imported. - """ - file_path = file_path.resolve() - if provider_root is not None: - provider_root = provider_root.resolve() - - # Check if this file is part of a package - package_root = _find_package_root(file_path, stop_at=provider_root) - - if package_root is not None: - # Import as part of a package - module_name = _compute_module_name(file_path, package_root) - - # Temporarily add package root's parent to sys.path for the import - package_parent = str(package_root.parent) - path_added = package_parent not in sys.path - if path_added: - sys.path.insert(0, package_parent) - - try: - # If already imported, reload to pick up changes (for reload mode) - if module_name in sys.modules: - return importlib.reload(sys.modules[module_name]) - return importlib.import_module(module_name) - except ImportError as e: - raise ImportError( - f"Failed to import {module_name} from {file_path}: {e}" - ) from e - finally: - if path_added: - with contextlib.suppress(ValueError): - sys.path.remove(package_parent) - else: - # Import directly using spec_from_file_location - stem = file_path.stem - parent_dir = str(file_path.parent) - - # Determine the sys.modules key. Prefer the bare stem (so that sibling - # imports like `import helpers` resolve correctly), but fall back to a - # private collision-safe key if the bare stem is already claimed by - # something else (stdlib, a third-party package, or another provider file - # from a different directory). - existing = sys.modules.get(stem) - if existing is not None and getattr(existing, "__file__", None) != str( - file_path - ): - module_name = f"_fastmcp_{stem}_{hashlib.sha1(str(file_path).encode()).hexdigest()[:12]}" - else: - module_name = stem - - # Temporarily add parent to sys.path so module-level sibling imports resolve. - # Safe to remove after exec_module: all top-level imports are resolved by then, - # and sibling files imported as side effects are already in sys.modules. - path_added = parent_dir not in sys.path - if path_added: - sys.path.insert(0, parent_dir) - - try: - spec = importlib.util.spec_from_file_location(module_name, file_path) - if spec is None or spec.loader is None: - raise ImportError(f"Cannot load spec for {file_path}") - - existing = sys.modules.get(module_name) - if existing is not None: - # Re-exec in place rather than importlib.reload: reload() re-finds - # the module by name via sys.path, which fails for private keys - # (the file is tool.py, not _fastmcp_tool_xxx.py). - existing.__spec__ = spec - existing.__loader__ = spec.loader - existing.__file__ = str(file_path) - try: - spec.loader.exec_module(existing) - except Exception as e: - raise ImportError( - f"Failed to reload module {file_path}: {e}" - ) from e - return existing - - module = importlib.util.module_from_spec(spec) - sys.modules[module_name] = module - - try: - spec.loader.exec_module(module) - except Exception as e: - # Clean up sys.modules on failure - sys.modules.pop(module_name, None) - raise ImportError(f"Failed to execute module {file_path}: {e}") from e - - return module - finally: - if path_added: - with contextlib.suppress(ValueError): - sys.path.remove(parent_dir) - - -def extract_components(module: ModuleType) -> list[FastMCPComponent]: - """Extract all MCP components from a module. - - Scans all module attributes for instances of Tool, Resource, - ResourceTemplate, or Prompt objects created by standalone decorators, - or functions decorated with @tool/@resource/@prompt that have __fastmcp__ metadata. - - Args: - module: The imported module to scan. - - Returns: - List of component objects (Tool, Resource, ResourceTemplate, Prompt). - """ - # Import here to avoid circular imports - import inspect - - from fastmcp.decorators import get_fastmcp_meta - from fastmcp.prompts.base import Prompt - from fastmcp.prompts.function_prompt import PromptMeta - from fastmcp.resources.base import Resource - from fastmcp.resources.function_resource import ResourceMeta - from fastmcp.resources.template import ResourceTemplate - from fastmcp.server.dependencies import without_injected_parameters - from fastmcp.tools.base import Tool - from fastmcp.tools.function_tool import ToolMeta - - component_types = (Tool, Resource, ResourceTemplate, Prompt) - components: list[FastMCPComponent] = [] - - for name in dir(module): - # Skip private/magic attributes - if name.startswith("_"): - continue - - try: - obj = getattr(module, name) - except AttributeError: - continue - - # Check if this object is a component type - if isinstance(obj, component_types): - components.append(obj) - continue - - # Check for functions with __fastmcp__ metadata - meta = get_fastmcp_meta(obj) - if meta is not None: - if isinstance(meta, ToolMeta): - resolved_task = meta.task if meta.task is not None else False - tool = Tool.from_function( - obj, - name=meta.name, - version=meta.version, - title=meta.title, - description=meta.description, - icons=meta.icons, - tags=meta.tags, - output_schema=meta.output_schema, - annotations=meta.annotations, - meta=meta.meta, - task=resolved_task, - exclude_args=meta.exclude_args, - serializer=meta.serializer, - auth=meta.auth, - ) - components.append(tool) - elif isinstance(meta, ResourceMeta): - resolved_task = meta.task if meta.task is not None else False - has_uri_params = "{" in meta.uri and "}" in meta.uri - wrapper_fn = without_injected_parameters(obj) - has_func_params = bool(inspect.signature(wrapper_fn).parameters) - - if has_uri_params or has_func_params: - resource = ResourceTemplate.from_function( - fn=obj, - uri_template=meta.uri, - name=meta.name, - version=meta.version, - title=meta.title, - description=meta.description, - icons=meta.icons, - mime_type=meta.mime_type, - tags=meta.tags, - annotations=meta.annotations, - meta=meta.meta, - task=resolved_task, - auth=meta.auth, - ) - else: - resource = Resource.from_function( - fn=obj, - uri=meta.uri, - name=meta.name, - version=meta.version, - title=meta.title, - description=meta.description, - icons=meta.icons, - mime_type=meta.mime_type, - tags=meta.tags, - annotations=meta.annotations, - meta=meta.meta, - task=resolved_task, - auth=meta.auth, - ) - components.append(resource) - elif isinstance(meta, PromptMeta): - resolved_task = meta.task if meta.task is not None else False - prompt = Prompt.from_function( - obj, - name=meta.name, - version=meta.version, - title=meta.title, - description=meta.description, - icons=meta.icons, - tags=meta.tags, - meta=meta.meta, - task=resolved_task, - auth=meta.auth, - ) - components.append(prompt) - - return components - - -def discover_and_import(root: Path) -> DiscoveryResult: - """Discover files, import modules, and extract components. - - This is the main entry point for filesystem-based discovery. - - Args: - root: Root directory to scan. - - Returns: - DiscoveryResult with components and any failed files. - - Note: - Files that fail to import are tracked in failed_files, not logged. - The caller is responsible for logging/handling failures. - Files with no components are silently skipped. - """ - result = DiscoveryResult() - - for file_path in discover_files(root): - try: - module = import_module_from_file(file_path, provider_root=root) - except Exception as e: - result.failed_files[file_path] = str(e) - continue - - components = extract_components(module) - for component in components: - result.components.append((file_path, component)) - - return result diff --git a/src/fastmcp/server/providers/local_provider/__init__.py b/src/fastmcp/server/providers/local_provider/__init__.py deleted file mode 100644 index f3587e2d8..000000000 --- a/src/fastmcp/server/providers/local_provider/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -"""LocalProvider for locally-defined MCP components. - -This module provides the `LocalProvider` class that manages tools, resources, -templates, and prompts registered via decorators or direct methods. -""" - -from fastmcp.server.providers.local_provider.local_provider import ( - LocalProvider, -) - -__all__ = ["LocalProvider"] diff --git a/src/fastmcp/server/providers/local_provider/decorators/__init__.py b/src/fastmcp/server/providers/local_provider/decorators/__init__.py deleted file mode 100644 index fe66eafa4..000000000 --- a/src/fastmcp/server/providers/local_provider/decorators/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Decorator mixins for LocalProvider. - -This module provides mixin classes that add decorator functionality -to LocalProvider for tools, resources, templates, and prompts. -""" - -from .prompts import PromptDecoratorMixin -from .resources import ResourceDecoratorMixin -from .tools import ToolDecoratorMixin - -__all__ = [ - "PromptDecoratorMixin", - "ResourceDecoratorMixin", - "ToolDecoratorMixin", -] diff --git a/src/fastmcp/server/providers/local_provider/decorators/prompts.py b/src/fastmcp/server/providers/local_provider/decorators/prompts.py deleted file mode 100644 index 583aed563..000000000 --- a/src/fastmcp/server/providers/local_provider/decorators/prompts.py +++ /dev/null @@ -1,258 +0,0 @@ -"""Prompt decorator mixin for LocalProvider. - -This module provides the PromptDecoratorMixin class that adds prompt -registration functionality to LocalProvider. -""" - -from __future__ import annotations - -import inspect -from collections.abc import Callable -from functools import partial -from typing import TYPE_CHECKING, Any, TypeVar, overload - -import mcp.types -from mcp.types import AnyFunction - -import fastmcp -from fastmcp.prompts.base import Prompt -from fastmcp.prompts.function_prompt import FunctionPrompt -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.tasks.config import TaskConfig - -if TYPE_CHECKING: - from fastmcp.server.providers.local_provider import LocalProvider - -F = TypeVar("F", bound=Callable[..., Any]) - - -class PromptDecoratorMixin: - """Mixin class providing prompt decorator functionality for LocalProvider. - - This mixin contains all methods related to: - - Prompt registration via add_prompt() - - Prompt decorator (@provider.prompt) - """ - - def add_prompt(self: LocalProvider, prompt: Prompt | Callable[..., Any]) -> Prompt: - """Add a prompt to this provider's storage. - - Accepts either a Prompt object or a decorated function with __fastmcp__ metadata. - """ - enabled = True - if not isinstance(prompt, Prompt): - from fastmcp.decorators import get_fastmcp_meta - from fastmcp.prompts.function_prompt import PromptMeta - - meta = get_fastmcp_meta(prompt) - if meta is not None and isinstance(meta, PromptMeta): - resolved_task = meta.task if meta.task is not None else False - enabled = meta.enabled - prompt = Prompt.from_function( - prompt, - name=meta.name, - version=meta.version, - title=meta.title, - description=meta.description, - icons=meta.icons, - tags=meta.tags, - meta=meta.meta, - task=resolved_task, - auth=meta.auth, - ) - else: - raise TypeError( - f"Expected Prompt or @prompt-decorated function, got {type(prompt).__name__}. " - "Use @prompt decorator or pass a Prompt instance." - ) - self._add_component(prompt) - if not enabled: - self.disable(keys={prompt.key}) - return prompt - - @overload - def prompt( - self: LocalProvider, - name_or_fn: F, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - tags: set[str] | None = None, - enabled: bool = True, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> F: ... - - @overload - def prompt( - self: LocalProvider, - name_or_fn: str | None = None, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - tags: set[str] | None = None, - enabled: bool = True, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> Callable[[F], F]: ... - - def prompt( - self: LocalProvider, - name_or_fn: str | AnyFunction | None = None, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - tags: set[str] | None = None, - enabled: bool = True, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> ( - Callable[[AnyFunction], FunctionPrompt] - | FunctionPrompt - | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] - ): - """Decorator to register a prompt. - - This decorator supports multiple calling patterns: - - @provider.prompt (without parentheses) - - @provider.prompt() (with empty parentheses) - - @provider.prompt("custom_name") (with name as first argument) - - @provider.prompt(name="custom_name") (with name as keyword argument) - - provider.prompt(function, name="custom_name") (direct function call) - - Args: - name_or_fn: Either a function (when used as @prompt), a string name, or None - name: Optional name for the prompt (keyword-only, alternative to name_or_fn) - title: Optional title for the prompt - description: Optional description of what the prompt does - icons: Optional icons for the prompt - tags: Optional set of tags for categorizing the prompt - enabled: Whether the prompt is enabled (default True). If False, adds to blocklist. - meta: Optional meta information about the prompt - task: Optional task configuration for background execution - auth: Optional authorization checks for the prompt - - Returns: - The registered FunctionPrompt or a decorator function. - - Example: - ```python - provider = LocalProvider() - - @provider.prompt - def analyze(topic: str) -> list: - return [{"role": "user", "content": f"Analyze: {topic}"}] - - @provider.prompt("custom_name") - def my_prompt(data: str) -> list: - return [{"role": "user", "content": data}] - ``` - """ - if isinstance(name_or_fn, classmethod): - raise TypeError( - "To decorate a classmethod, use @classmethod above @prompt. " - "See https://gofastmcp.com/servers/prompts#using-with-methods" - ) - - def decorate_and_register( - fn: AnyFunction, prompt_name: str | None - ) -> FunctionPrompt | AnyFunction: - # Check for unbound method - try: - params = list(inspect.signature(fn).parameters.keys()) - except (ValueError, TypeError): - params = [] - if params and params[0] in ("self", "cls"): - fn_name = getattr(fn, "__name__", "function") - raise TypeError( - f"The function '{fn_name}' has '{params[0]}' as its first parameter. " - f"Use the standalone @prompt decorator and register the bound method:\n\n" - f" from fastmcp.prompts import prompt\n\n" - f" class MyClass:\n" - f" @prompt\n" - f" def {fn_name}(...):\n" - f" ...\n\n" - f" obj = MyClass()\n" - f" mcp.add_prompt(obj.{fn_name})\n\n" - f"See https://gofastmcp.com/servers/prompts#using-with-methods" - ) - - resolved_task: bool | TaskConfig = task if task is not None else False - - if fastmcp.settings.decorator_mode == "object": - prompt_obj = Prompt.from_function( - fn, - name=prompt_name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - meta=meta, - task=resolved_task, - auth=auth, - ) - self._add_component(prompt_obj) - if not enabled: - self.disable(keys={prompt_obj.key}) - return prompt_obj - else: - from fastmcp.prompts.function_prompt import PromptMeta - - metadata = PromptMeta( - name=prompt_name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - meta=meta, - task=task, - auth=auth, - enabled=enabled, - ) - target = fn.__func__ if hasattr(fn, "__func__") else fn - target.__fastmcp__ = metadata # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - self.add_prompt(fn) - return fn - - if inspect.isroutine(name_or_fn): - return decorate_and_register(name_or_fn, name) - - elif isinstance(name_or_fn, str): - if name is not None: - raise TypeError( - f"Cannot specify both a name as first argument and as keyword argument. " - f"Use either @prompt('{name_or_fn}') or @prompt(name='{name}'), not both." - ) - prompt_name = name_or_fn - elif name_or_fn is None: - prompt_name = name - else: - raise TypeError(f"Invalid first argument: {type(name_or_fn)}") - - return partial( - self.prompt, - name=prompt_name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - meta=meta, - enabled=enabled, - task=task, - auth=auth, - ) diff --git a/src/fastmcp/server/providers/local_provider/decorators/resources.py b/src/fastmcp/server/providers/local_provider/decorators/resources.py deleted file mode 100644 index 41043a461..000000000 --- a/src/fastmcp/server/providers/local_provider/decorators/resources.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Resource decorator mixin for LocalProvider. - -This module provides the ResourceDecoratorMixin class that adds resource -and template registration functionality to LocalProvider. -""" - -from __future__ import annotations - -import inspect -from collections.abc import Callable -from typing import TYPE_CHECKING, Any, TypeVar - -import mcp.types -from mcp.types import Annotations, AnyFunction - -import fastmcp -from fastmcp.resources.base import Resource -from fastmcp.resources.function_resource import resource as standalone_resource -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.tasks.config import TaskConfig - -if TYPE_CHECKING: - from fastmcp.server.providers.local_provider import LocalProvider - -F = TypeVar("F", bound=Callable[..., Any]) - - -class ResourceDecoratorMixin: - """Mixin class providing resource decorator functionality for LocalProvider. - - This mixin contains all methods related to: - - Resource registration via add_resource() - - Resource template registration via add_template() - - Resource decorator (@provider.resource) - """ - - def add_resource( - self: LocalProvider, resource: Resource | ResourceTemplate | Callable[..., Any] - ) -> Resource | ResourceTemplate: - """Add a resource to this provider's storage. - - Accepts either a Resource/ResourceTemplate object or a decorated function with __fastmcp__ metadata. - """ - enabled = True - if not isinstance(resource, (Resource, ResourceTemplate)): - from fastmcp.decorators import get_fastmcp_meta - from fastmcp.resources.function_resource import ResourceMeta - from fastmcp.server.dependencies import without_injected_parameters - - meta = get_fastmcp_meta(resource) - if meta is not None and isinstance(meta, ResourceMeta): - resolved_task = meta.task if meta.task is not None else False - enabled = meta.enabled - has_uri_params = "{" in meta.uri and "}" in meta.uri - wrapper_fn = without_injected_parameters(resource) - has_func_params = bool(inspect.signature(wrapper_fn).parameters) - - if has_uri_params or has_func_params: - resource = ResourceTemplate.from_function( - fn=resource, - uri_template=meta.uri, - name=meta.name, - version=meta.version, - title=meta.title, - description=meta.description, - icons=meta.icons, - mime_type=meta.mime_type, - tags=meta.tags, - annotations=meta.annotations, - meta=meta.meta, - task=resolved_task, - auth=meta.auth, - ) - else: - resource = Resource.from_function( - fn=resource, - uri=meta.uri, - name=meta.name, - version=meta.version, - title=meta.title, - description=meta.description, - icons=meta.icons, - mime_type=meta.mime_type, - tags=meta.tags, - annotations=meta.annotations, - meta=meta.meta, - task=resolved_task, - auth=meta.auth, - ) - else: - raise TypeError( - f"Expected Resource, ResourceTemplate, or @resource-decorated function, got {type(resource).__name__}. " - "Use @resource('uri') decorator or pass a Resource/ResourceTemplate instance." - ) - self._add_component(resource) - if not enabled: - self.disable(keys={resource.key}) - return resource - - def add_template( - self: LocalProvider, template: ResourceTemplate - ) -> ResourceTemplate: - """Add a resource template to this provider's storage.""" - return self._add_component(template) - - def resource( - self: LocalProvider, - uri: str, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - mime_type: str | None = None, - tags: set[str] | None = None, - enabled: bool = True, - annotations: Annotations | dict[str, Any] | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> Callable[[F], F]: - """Decorator to register a function as a resource. - - If the URI contains parameters (e.g. "resource://{param}") or the function - has parameters, it will be registered as a template resource. - - Args: - uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}") - name: Optional name for the resource - title: Optional title for the resource - description: Optional description of the resource - icons: Optional icons for the resource - mime_type: Optional MIME type for the resource - tags: Optional set of tags for categorizing the resource - enabled: Whether the resource is enabled (default True). If False, adds to blocklist. - annotations: Optional annotations about the resource's behavior - meta: Optional meta information about the resource - task: Optional task configuration for background execution - auth: Optional authorization checks for the resource - - Returns: - A decorator function. - - Example: - ```python - provider = LocalProvider() - - @provider.resource("data://config") - def get_config() -> str: - return '{"setting": "value"}' - - @provider.resource("data://{city}/weather") - def get_weather(city: str) -> str: - return f"Weather for {city}" - ``` - """ - if isinstance(annotations, dict): - annotations = Annotations(**annotations) - - if inspect.isroutine(uri): - raise TypeError( - "The @resource decorator was used incorrectly. " - "It requires a URI as the first argument. " - "Use @resource('uri') instead of @resource" - ) - - resolved_task: bool | TaskConfig = task if task is not None else False - - def decorator(fn: AnyFunction) -> Any: - # Check for unbound method - try: - params = list(inspect.signature(fn).parameters.keys()) - except (ValueError, TypeError): - params = [] - if params and params[0] in ("self", "cls"): - fn_name = getattr(fn, "__name__", "function") - raise TypeError( - f"The function '{fn_name}' has '{params[0]}' as its first parameter. " - f"Use the standalone @resource decorator and register the bound method:\n\n" - f" from fastmcp.resources import resource\n\n" - f" class MyClass:\n" - f" @resource('{uri}')\n" - f" def {fn_name}(...):\n" - f" ...\n\n" - f" obj = MyClass()\n" - f" mcp.add_resource(obj.{fn_name})\n\n" - f"See https://gofastmcp.com/servers/resources#using-with-methods" - ) - - if fastmcp.settings.decorator_mode == "object": - create_resource = standalone_resource( - uri, - name=name, - version=version, - title=title, - description=description, - icons=icons, - mime_type=mime_type, - tags=tags, - annotations=annotations, - meta=meta, - task=resolved_task, - auth=auth, - ) - obj = create_resource(fn) - # In legacy mode, standalone_resource always returns a component - assert isinstance(obj, (Resource, ResourceTemplate)) - if isinstance(obj, ResourceTemplate): - self.add_template(obj) - if not enabled: - self.disable(keys={obj.key}) - else: - self.add_resource(obj) - if not enabled: - self.disable(keys={obj.key}) - return obj - else: - from fastmcp.resources.function_resource import ResourceMeta - - metadata = ResourceMeta( - uri=uri, - name=name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - mime_type=mime_type, - annotations=annotations, - meta=meta, - task=task, - auth=auth, - enabled=enabled, - ) - target = fn.__func__ if hasattr(fn, "__func__") else fn - target.__fastmcp__ = metadata # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - self.add_resource(fn) - return fn - - return decorator diff --git a/src/fastmcp/server/providers/local_provider/decorators/tools.py b/src/fastmcp/server/providers/local_provider/decorators/tools.py deleted file mode 100644 index e79cc2c65..000000000 --- a/src/fastmcp/server/providers/local_provider/decorators/tools.py +++ /dev/null @@ -1,448 +0,0 @@ -"""Tool decorator mixin for LocalProvider. - -This module provides the ToolDecoratorMixin class that adds tool -registration functionality to LocalProvider. -""" - -from __future__ import annotations - -import inspect -import types -import warnings -from collections.abc import Callable -from functools import partial -from typing import ( - TYPE_CHECKING, - Annotated, - Any, - Literal, - TypeVar, - Union, - get_args, - get_origin, - overload, -) - -import mcp.types -from mcp.types import AnyFunction, ToolAnnotations - -import fastmcp -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.tasks.config import TaskConfig -from fastmcp.tools.base import Tool -from fastmcp.tools.function_tool import FunctionTool -from fastmcp.utilities.types import NotSet, NotSetT - -try: - from prefab_ui.app import PrefabApp as _PrefabApp - from prefab_ui.components.base import Component as _PrefabComponent - - _HAS_PREFAB = True -except ImportError: - _HAS_PREFAB = False - -if TYPE_CHECKING: - from fastmcp.server.providers.local_provider import LocalProvider - from fastmcp.tools.base import ToolResultSerializerType - -F = TypeVar("F", bound=Callable[..., Any]) - -DuplicateBehavior = Literal["error", "warn", "replace", "ignore"] - -PREFAB_RENDERER_URI = "ui://prefab/renderer.html" - - -def _is_prefab_type(tp: Any) -> bool: - """Check if *tp* is or contains a prefab type, recursing through unions and Annotated.""" - if isinstance(tp, type) and issubclass(tp, (_PrefabApp, _PrefabComponent)): - return True - origin = get_origin(tp) - if origin is Union or origin is types.UnionType or origin is Annotated: - return any(_is_prefab_type(a) for a in get_args(tp)) - return False - - -def _has_prefab_return_type(tool: Tool) -> bool: - """Check if a FunctionTool's return type annotation is a prefab type.""" - if not _HAS_PREFAB or not isinstance(tool, FunctionTool): - return False - rt = tool.return_type - if rt is None or rt is inspect.Parameter.empty: - return False - return _is_prefab_type(rt) - - -def _ensure_prefab_renderer(provider: LocalProvider) -> None: - """Lazily register the shared prefab renderer as a ui:// resource.""" - from prefab_ui.renderer import get_renderer_csp, get_renderer_html - - from fastmcp.apps.config import ( - UI_MIME_TYPE, - AppConfig, - ResourceCSP, - app_config_to_meta_dict, - ) - from fastmcp.resources.types import TextResource - - renderer_key = f"resource:{PREFAB_RENDERER_URI}@" - if renderer_key in provider._components: - return - - csp = get_renderer_csp() - resource_app = AppConfig( - csp=ResourceCSP( - resource_domains=csp.get("resource_domains"), - connect_domains=csp.get("connect_domains"), - ) - ) - resource = TextResource( - uri=PREFAB_RENDERER_URI, # type: ignore[arg-type] # AnyUrl accepts ui:// scheme at runtime # ty:ignore[invalid-argument-type] - name="Prefab Renderer", - text=get_renderer_html(), - mime_type=UI_MIME_TYPE, - meta={"ui": app_config_to_meta_dict(resource_app)}, - ) - provider._add_component(resource) - - -def _expand_prefab_ui_meta(tool: Tool) -> None: - """Expand meta["ui"] = True into the full AppConfig dict for a prefab tool.""" - from prefab_ui.renderer import get_renderer_csp - - from fastmcp.apps.config import AppConfig, ResourceCSP, app_config_to_meta_dict - - csp = get_renderer_csp() - app_config = AppConfig( - resource_uri=PREFAB_RENDERER_URI, - csp=ResourceCSP( - resource_domains=csp.get("resource_domains"), - connect_domains=csp.get("connect_domains"), - ), - ) - meta = dict(tool.meta) if tool.meta else {} - meta["ui"] = app_config_to_meta_dict(app_config) - tool.meta = meta - - -def _maybe_apply_prefab_ui(provider: LocalProvider, tool: Tool) -> None: - """Auto-wire prefab UI metadata and renderer resource if needed.""" - if not _HAS_PREFAB: - return - - meta = tool.meta or {} - ui = meta.get("ui") - - if ui is True: - # Explicit app=True: expand to full AppConfig and register renderer - _ensure_prefab_renderer(provider) - _expand_prefab_ui_meta(tool) - elif ui is None and _has_prefab_return_type(tool): - # Inference: return type is a prefab type, auto-wire - _ensure_prefab_renderer(provider) - _expand_prefab_ui_meta(tool) - elif isinstance(ui, dict) and ui.get("resourceUri") == PREFAB_RENDERER_URI: - # PrefabAppConfig or manual config pointing to the Prefab renderer — - # ensure the renderer resource is registered (CSP already set by caller) - _ensure_prefab_renderer(provider) - - -class ToolDecoratorMixin: - """Mixin class providing tool decorator functionality for LocalProvider. - - This mixin contains all methods related to: - - Tool registration via add_tool() - - Tool decorator (@provider.tool) - """ - - def add_tool(self: LocalProvider, tool: Tool | Callable[..., Any]) -> Tool: - """Add a tool to this provider's storage. - - Accepts either a Tool object or a decorated function with __fastmcp__ metadata. - """ - enabled = True - if not isinstance(tool, Tool): - from fastmcp.decorators import get_fastmcp_meta - from fastmcp.tools.function_tool import ToolMeta - - fmeta = get_fastmcp_meta(tool) - if fmeta is not None and isinstance(fmeta, ToolMeta): - resolved_task = fmeta.task if fmeta.task is not None else False - enabled = fmeta.enabled - - # Merge ToolMeta.app into the meta dict - tool_meta = fmeta.meta - if fmeta.app is not None: - from fastmcp.apps.config import app_config_to_meta_dict - - tool_meta = dict(tool_meta) if tool_meta else {} - if fmeta.app is True: - tool_meta["ui"] = True - else: - tool_meta["ui"] = app_config_to_meta_dict(fmeta.app) - - tool = Tool.from_function( - tool, - name=fmeta.name, - version=fmeta.version, - title=fmeta.title, - description=fmeta.description, - icons=fmeta.icons, - tags=fmeta.tags, - output_schema=fmeta.output_schema, - annotations=fmeta.annotations, - meta=tool_meta, - task=resolved_task, - exclude_args=fmeta.exclude_args, - serializer=fmeta.serializer, - timeout=fmeta.timeout, - auth=fmeta.auth, - ) - else: - tool = Tool.from_function(tool) - self._add_component(tool) - if not enabled: - self.disable(keys={tool.key}) - _maybe_apply_prefab_ui(self, tool) - return tool - - @overload - def tool( - self: LocalProvider, - name_or_fn: F, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - tags: set[str] | None = None, - output_schema: dict[str, Any] | NotSetT | None = NotSet, - annotations: ToolAnnotations | dict[str, Any] | None = None, - exclude_args: list[str] | None = None, - meta: dict[str, Any] | None = None, - enabled: bool = True, - task: bool | TaskConfig | None = None, - serializer: ToolResultSerializerType | None = None, # Deprecated - timeout: float | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> F: ... - - @overload - def tool( - self: LocalProvider, - name_or_fn: str | None = None, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - tags: set[str] | None = None, - output_schema: dict[str, Any] | NotSetT | None = NotSet, - annotations: ToolAnnotations | dict[str, Any] | None = None, - exclude_args: list[str] | None = None, - meta: dict[str, Any] | None = None, - enabled: bool = True, - task: bool | TaskConfig | None = None, - serializer: ToolResultSerializerType | None = None, # Deprecated - timeout: float | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> Callable[[F], F]: ... - - # NOTE: This method mirrors fastmcp.tools.tool() but adds registration, - # the `enabled` param, and supports deprecated params (serializer, exclude_args). - # When deprecated params are removed, this should delegate to the standalone - # decorator to reduce duplication. - def tool( - self: LocalProvider, - name_or_fn: str | AnyFunction | None = None, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - tags: set[str] | None = None, - output_schema: dict[str, Any] | NotSetT | None = NotSet, - annotations: ToolAnnotations | dict[str, Any] | None = None, - exclude_args: list[str] | None = None, - meta: dict[str, Any] | None = None, - enabled: bool = True, - task: bool | TaskConfig | None = None, - serializer: ToolResultSerializerType | None = None, # Deprecated - timeout: float | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> ( - Callable[[AnyFunction], FunctionTool] - | FunctionTool - | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] - ): - """Decorator to register a tool. - - This decorator supports multiple calling patterns: - - @provider.tool (without parentheses) - - @provider.tool() (with empty parentheses) - - @provider.tool("custom_name") (with name as first argument) - - @provider.tool(name="custom_name") (with name as keyword argument) - - provider.tool(function, name="custom_name") (direct function call) - - Args: - name_or_fn: Either a function (when used as @tool), a string name, or None - name: Optional name for the tool (keyword-only, alternative to name_or_fn) - title: Optional title for the tool - description: Optional description of what the tool does - icons: Optional icons for the tool - tags: Optional set of tags for categorizing the tool - output_schema: Optional JSON schema for the tool's output - annotations: Optional annotations about the tool's behavior - exclude_args: Optional list of argument names to exclude from the tool schema - meta: Optional meta information about the tool - enabled: Whether the tool is enabled (default True). If False, adds to blocklist. - task: Optional task configuration for background execution - serializer: Deprecated. Return ToolResult from your tools for full control over serialization. - - Returns: - The registered FunctionTool or a decorator function. - - Example: - ```python - provider = LocalProvider() - - @provider.tool - def greet(name: str) -> str: - return f"Hello, {name}!" - - @provider.tool("custom_name") - def my_tool(x: int) -> str: - return str(x) - ``` - """ - if serializer is not None and fastmcp.settings.deprecation_warnings: - warnings.warn( - "The `serializer` parameter is deprecated. " - "Return ToolResult from your tools for full control over serialization. " - "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - if isinstance(annotations, dict): - annotations = ToolAnnotations(**annotations) - - if isinstance(name_or_fn, classmethod): - raise TypeError( - "To decorate a classmethod, use @classmethod above @tool. " - "See https://gofastmcp.com/servers/tools#using-with-methods" - ) - - def decorate_and_register( - fn: AnyFunction, tool_name: str | None - ) -> FunctionTool | AnyFunction: - # Check for unbound method - try: - params = list(inspect.signature(fn).parameters.keys()) - except (ValueError, TypeError): - params = [] - if params and params[0] in ("self", "cls"): - fn_name = getattr(fn, "__name__", "function") - raise TypeError( - f"The function '{fn_name}' has '{params[0]}' as its first parameter. " - f"Use the standalone @tool decorator and register the bound method:\n\n" - f" from fastmcp.tools import tool\n\n" - f" class MyClass:\n" - f" @tool\n" - f" def {fn_name}(...):\n" - f" ...\n\n" - f" obj = MyClass()\n" - f" mcp.add_tool(obj.{fn_name})\n\n" - f"See https://gofastmcp.com/servers/tools#using-with-methods" - ) - - resolved_task: bool | TaskConfig = task if task is not None else False - - if fastmcp.settings.decorator_mode == "object": - tool_obj = Tool.from_function( - fn, - name=tool_name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - output_schema=output_schema, - annotations=annotations, - exclude_args=exclude_args, - meta=meta, - serializer=serializer, - task=resolved_task, - timeout=timeout, - auth=auth, - ) - self._add_component(tool_obj) - if not enabled: - self.disable(keys={tool_obj.key}) - _maybe_apply_prefab_ui(self, tool_obj) - return tool_obj - else: - from fastmcp.tools.function_tool import ToolMeta - - metadata = ToolMeta( - name=tool_name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - output_schema=output_schema, - annotations=annotations, - meta=meta, - task=task, - exclude_args=exclude_args, - serializer=serializer, - timeout=timeout, - auth=auth, - enabled=enabled, - ) - target = fn.__func__ if hasattr(fn, "__func__") else fn - target.__fastmcp__ = metadata # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - tool_obj = self.add_tool(fn) - return fn - - if inspect.isroutine(name_or_fn): - return decorate_and_register(name_or_fn, name) - - elif isinstance(name_or_fn, str): - # Case 3: @tool("custom_name") - name passed as first argument - if name is not None: - raise TypeError( - "Cannot specify both a name as first argument and as keyword argument. " - f"Use either @tool('{name_or_fn}') or @tool(name='{name}'), not both." - ) - tool_name = name_or_fn - elif name_or_fn is None: - # Case 4: @tool() or @tool(name="something") - use keyword name - tool_name = name - else: - raise TypeError( - f"First argument to @tool must be a function, string, or None, got {type(name_or_fn)}" - ) - - # Return partial for cases where we need to wait for the function - return partial( - self.tool, - name=tool_name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - output_schema=output_schema, - annotations=annotations, - exclude_args=exclude_args, - meta=meta, - enabled=enabled, - task=task, - serializer=serializer, - timeout=timeout, - auth=auth, - ) diff --git a/src/fastmcp/server/providers/local_provider/local_provider.py b/src/fastmcp/server/providers/local_provider/local_provider.py deleted file mode 100644 index 675ff0e63..000000000 --- a/src/fastmcp/server/providers/local_provider/local_provider.py +++ /dev/null @@ -1,465 +0,0 @@ -"""LocalProvider for locally-defined MCP components. - -This module provides the `LocalProvider` class that manages tools, resources, -templates, and prompts registered via decorators or direct methods. - -LocalProvider can be used standalone and attached to multiple servers: - -```python -from fastmcp.server.providers import LocalProvider - -# Create a reusable provider with tools -provider = LocalProvider() - -@provider.tool -def greet(name: str) -> str: - return f"Hello, {name}!" - -# Attach to any server -from fastmcp import FastMCP -server1 = FastMCP("Server1", providers=[provider]) -server2 = FastMCP("Server2", providers=[provider]) -``` -""" - -from __future__ import annotations - -from collections.abc import Sequence -from typing import Literal, TypeVar - -from fastmcp.prompts.base import Prompt -from fastmcp.resources.base import Resource -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.providers.base import Provider -from fastmcp.server.providers.local_provider.decorators import ( - PromptDecoratorMixin, - ResourceDecoratorMixin, - ToolDecoratorMixin, -) -from fastmcp.tools.base import Tool -from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.versions import VersionSpec, version_sort_key - -logger = get_logger(__name__) - -DuplicateBehavior = Literal["error", "warn", "replace", "ignore"] - -_C = TypeVar("_C", bound=FastMCPComponent) - - -class LocalProvider( - Provider, - ToolDecoratorMixin, - ResourceDecoratorMixin, - PromptDecoratorMixin, -): - """Provider for locally-defined components. - - Supports decorator-based registration (`@provider.tool`, `@provider.resource`, - `@provider.prompt`) and direct object registration methods. - - When used standalone, LocalProvider uses default settings. When attached - to a FastMCP server via the server's decorators, server-level settings - like `_tool_serializer` and `_support_tasks_by_default` are injected. - - Example: - ```python - from fastmcp.server.providers import LocalProvider - - # Standalone usage - provider = LocalProvider() - - @provider.tool - def greet(name: str) -> str: - return f"Hello, {name}!" - - @provider.resource("data://config") - def get_config() -> str: - return '{"setting": "value"}' - - @provider.prompt - def analyze(topic: str) -> list: - return [{"role": "user", "content": f"Analyze: {topic}"}] - - # Attach to server(s) - from fastmcp import FastMCP - server = FastMCP("MyServer", providers=[provider]) - ``` - """ - - def __init__( - self, - on_duplicate: DuplicateBehavior = "error", - ) -> None: - """Initialize a LocalProvider with empty storage. - - Args: - on_duplicate: Behavior when adding a component that already exists: - - "error": Raise ValueError - - "warn": Log warning and replace - - "replace": Silently replace - - "ignore": Keep existing, return it - """ - super().__init__() - self._on_duplicate = on_duplicate - # Unified component storage - keyed by prefixed key (e.g., "tool:name", "resource:uri") - self._components: dict[str, FastMCPComponent] = {} - - # ========================================================================= - # Storage methods - # ========================================================================= - - def _get_component_identity(self, component: FastMCPComponent) -> tuple[type, str]: - """Get the identity (type, name/uri) for a component. - - Returns: - A tuple of (component_type, logical_name) where logical_name is - the name for tools/prompts or URI for resources/templates. - """ - if isinstance(component, Tool): - return (Tool, component.name) - elif isinstance(component, ResourceTemplate): - return (ResourceTemplate, component.uri_template) - elif isinstance(component, Resource): - return (Resource, str(component.uri)) - elif isinstance(component, Prompt): - return (Prompt, component.name) - else: - # Fall back to key without version suffix - key = component.key - base_key = key.rsplit("@", 1)[0] if "@" in key else key - return (type(component), base_key) - - def _check_version_mixing(self, component: _C) -> None: - """Check that versioned and unversioned components aren't mixed. - - LocalProvider enforces a simple rule: for any given name/URI, all - registered components must either be versioned or unversioned, not both. - This prevents confusing situations where unversioned components can't - be filtered out by version filters. - - Args: - component: The component being added. - - Raises: - ValueError: If adding would mix versioned and unversioned components. - """ - comp_type, logical_name = self._get_component_identity(component) - is_versioned = component.version is not None - - # Check all existing components of the same type and logical name - for existing in self._components.values(): - if not isinstance(existing, comp_type): - continue - - _, existing_name = self._get_component_identity(existing) - if existing_name != logical_name: - continue - - existing_versioned = existing.version is not None - if is_versioned != existing_versioned: - type_name = comp_type.__name__.lower() - if is_versioned: - raise ValueError( - f"Cannot add versioned {type_name} {logical_name!r} " - f"(version={component.version!r}): an unversioned " - f"{type_name} with this name already exists. " - f"Either version all components or none." - ) - else: - raise ValueError( - f"Cannot add unversioned {type_name} {logical_name!r}: " - f"versioned {type_name}s with this name already exist " - f"(e.g., version={existing.version!r}). " - f"Either version all components or none." - ) - - def _add_component(self, component: _C) -> _C: - """Add a component to unified storage. - - Args: - component: The component to add. - - Returns: - The component that was added (or existing if on_duplicate="ignore"). - """ - existing = self._components.get(component.key) - if existing: - if self._on_duplicate == "error": - raise ValueError(f"Component already exists: {component.key}") - elif self._on_duplicate == "warn": - logger.warning(f"Component already exists: {component.key}") - elif self._on_duplicate == "ignore": - return existing # type: ignore[return-value] # ty:ignore[invalid-return-type] - # "replace" and "warn" fall through to add - - # Check for versioned/unversioned mixing before adding - self._check_version_mixing(component) - - self._components[component.key] = component - return component - - def _remove_component(self, key: str) -> None: - """Remove a component from unified storage. - - Args: - key: The prefixed key of the component. - - Raises: - KeyError: If the component is not found. - """ - component = self._components.get(key) - if component is None: - raise KeyError(f"Component {key!r} not found") - - del self._components[key] - - def _get_component(self, key: str) -> FastMCPComponent | None: - """Get a component by its prefixed key. - - Args: - key: The prefixed key (e.g., "tool:name", "resource:uri"). - - Returns: - The component, or None if not found. - """ - return self._components.get(key) - - def remove_tool(self, name: str, version: str | None = None) -> None: - """Remove tool(s) from this provider's storage. - - Args: - name: The tool name. - version: If None, removes ALL versions. If specified, removes only that version. - - Raises: - KeyError: If no matching tool is found. - """ - if version is None: - # Remove all versions - keys_to_remove = [ - k - for k, c in self._components.items() - if isinstance(c, Tool) and c.name == name - ] - if not keys_to_remove: - raise KeyError(f"Tool {name!r} not found") - for key in keys_to_remove: - self._remove_component(key) - else: - # Remove specific version - key format is "tool:name@version" - key = f"{Tool.make_key(name)}@{version}" - if key not in self._components: - raise KeyError(f"Tool {name!r} version {version!r} not found") - self._remove_component(key) - - def remove_resource(self, uri: str, version: str | None = None) -> None: - """Remove resource(s) from this provider's storage. - - Args: - uri: The resource URI. - version: If None, removes ALL versions. If specified, removes only that version. - - Raises: - KeyError: If no matching resource is found. - """ - if version is None: - # Remove all versions - keys_to_remove = [ - k - for k, c in self._components.items() - if isinstance(c, Resource) and str(c.uri) == uri - ] - if not keys_to_remove: - raise KeyError(f"Resource {uri!r} not found") - for key in keys_to_remove: - self._remove_component(key) - else: - # Remove specific version - key = f"{Resource.make_key(uri)}@{version}" - if key not in self._components: - raise KeyError(f"Resource {uri!r} version {version!r} not found") - self._remove_component(key) - - def remove_template(self, uri_template: str, version: str | None = None) -> None: - """Remove resource template(s) from this provider's storage. - - Args: - uri_template: The template URI pattern. - version: If None, removes ALL versions. If specified, removes only that version. - - Raises: - KeyError: If no matching template is found. - """ - if version is None: - # Remove all versions - keys_to_remove = [ - k - for k, c in self._components.items() - if isinstance(c, ResourceTemplate) and c.uri_template == uri_template - ] - if not keys_to_remove: - raise KeyError(f"Template {uri_template!r} not found") - for key in keys_to_remove: - self._remove_component(key) - else: - # Remove specific version - key = f"{ResourceTemplate.make_key(uri_template)}@{version}" - if key not in self._components: - raise KeyError( - f"Template {uri_template!r} version {version!r} not found" - ) - self._remove_component(key) - - def remove_prompt(self, name: str, version: str | None = None) -> None: - """Remove prompt(s) from this provider's storage. - - Args: - name: The prompt name. - version: If None, removes ALL versions. If specified, removes only that version. - - Raises: - KeyError: If no matching prompt is found. - """ - if version is None: - # Remove all versions - keys_to_remove = [ - k - for k, c in self._components.items() - if isinstance(c, Prompt) and c.name == name - ] - if not keys_to_remove: - raise KeyError(f"Prompt {name!r} not found") - for key in keys_to_remove: - self._remove_component(key) - else: - # Remove specific version - key = f"{Prompt.make_key(name)}@{version}" - if key not in self._components: - raise KeyError(f"Prompt {name!r} version {version!r} not found") - self._remove_component(key) - - # ========================================================================= - # Provider interface implementation - # ========================================================================= - - async def _list_tools(self) -> Sequence[Tool]: - """Return all tools.""" - return [v for v in self._components.values() if isinstance(v, Tool)] - - async def _get_tool( - self, name: str, version: VersionSpec | None = None - ) -> Tool | None: - """Get a tool by name. - - Args: - name: The tool name. - version: Optional version filter. If None, returns highest version. - """ - matching = [ - v - for v in self._components.values() - if isinstance(v, Tool) and v.name == name - ] - if version: - matching = [t for t in matching if version.matches(t.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - async def _list_resources(self) -> Sequence[Resource]: - """Return all resources.""" - return [v for v in self._components.values() if isinstance(v, Resource)] - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Get a resource by URI. - - Args: - uri: The resource URI. - version: Optional version filter. If None, returns highest version. - """ - matching = [ - v - for v in self._components.values() - if isinstance(v, Resource) and str(v.uri) == uri - ] - if version: - matching = [r for r in matching if version.matches(r.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - """Return all resource templates.""" - return [v for v in self._components.values() if isinstance(v, ResourceTemplate)] - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Get a resource template that matches the given URI. - - Args: - uri: The URI to match against templates. - version: Optional version filter. If None, returns highest version. - """ - # Find all templates that match the URI - matching = [ - component - for component in self._components.values() - if isinstance(component, ResourceTemplate) - and component.matches(uri) is not None - ] - if version: - matching = [t for t in matching if version.matches(t.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - async def _list_prompts(self) -> Sequence[Prompt]: - """Return all prompts.""" - return [v for v in self._components.values() if isinstance(v, Prompt)] - - async def _get_prompt( - self, name: str, version: VersionSpec | None = None - ) -> Prompt | None: - """Get a prompt by name. - - Args: - name: The prompt name. - version: Optional version filter. If None, returns highest version. - """ - matching = [ - v - for v in self._components.values() - if isinstance(v, Prompt) and v.name == name - ] - if version: - matching = [p for p in matching if version.matches(p.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - # ========================================================================= - # Task registration - # ========================================================================= - - async def get_tasks(self) -> Sequence[FastMCPComponent]: - """Return components eligible for background task execution. - - Returns components that have task_config.mode != 'forbidden'. - This includes both FunctionTool/Resource/Prompt instances created via - decorators and custom Tool/Resource/Prompt subclasses. - """ - return [c for c in self._components.values() if c.task_config.supports_tasks()] - - # ========================================================================= - # Decorator methods - # ========================================================================= - # Note: Decorator methods (tool, resource, prompt, add_tool, add_resource, - # add_template, add_prompt) are provided by mixin classes: - # - ToolDecoratorMixin - # - ResourceDecoratorMixin - # - PromptDecoratorMixin diff --git a/src/fastmcp/server/providers/openapi/README.md b/src/fastmcp/server/providers/openapi/README.md deleted file mode 100644 index 8c5e890c4..000000000 --- a/src/fastmcp/server/providers/openapi/README.md +++ /dev/null @@ -1,266 +0,0 @@ -# OpenAPI Server Implementation (New) - -This directory contains the next-generation FastMCP server implementation for OpenAPI integration, designed to replace the legacy implementation in `/server/openapi.py`. - -## Architecture Overview - -The new implementation uses a **stateless request building approach** with `openapi-core` and `RequestDirector`, providing zero-latency startup and robust OpenAPI support optimized for serverless environments. - -### Core Components - -1. **`server.py`** - `FastMCPOpenAPI` main server class with RequestDirector integration -2. **`components.py`** - Simplified component implementations using RequestDirector -3. **`routing.py`** - Route mapping and component selection logic - -### Key Architecture Principles - -#### 1. Stateless Performance -- **Zero Startup Latency**: No code generation or heavy initialization -- **RequestDirector**: Stateless HTTP request building using openapi-core -- **Pre-calculated Schemas**: All complex processing done during parsing - -#### 2. Unified Implementation -- **Single Code Path**: All components use RequestDirector consistently -- **No Fallbacks**: Simplified architecture without hybrid complexity -- **Performance First**: Optimized for cold starts and serverless deployments - -#### 3. OpenAPI Compliance -- **openapi-core Integration**: Leverages proven library for parameter serialization -- **Full Feature Support**: Complete OpenAPI 3.0/3.1 support including deepObject -- **Error Handling**: Comprehensive HTTP error mapping to MCP errors - -## Component Classes - -### RequestDirector-Based Components - -#### `OpenAPITool` -- Executes operations using RequestDirector for HTTP request building -- Automatic parameter validation and OpenAPI-compliant serialization -- Built-in error handling and structured response processing -- **Advantages**: Zero latency, robust, comprehensive OpenAPI support - -#### `OpenAPIResource` / `OpenAPIResourceTemplate` -- Provides resource access using RequestDirector -- Consistent parameter handling across all resource types -- Support for complex parameter patterns and collision resolution -- **Advantages**: High performance, simplified architecture, reliable error handling - -## Server Implementation - -### `FastMCPOpenAPI` Class - -The main server class orchestrates the stateless request building approach: - -```python -class FastMCPOpenAPI(FastMCP): - def __init__(self, openapi_spec: dict, client: httpx.AsyncClient, **kwargs): - # 1. Parse OpenAPI spec to HTTP routes with pre-calculated schemas - self._routes = parse_openapi_to_http_routes(openapi_spec) - - # 2. Initialize RequestDirector with openapi-core Spec - self._spec = Spec.from_dict(openapi_spec) - self._director = RequestDirector(self._spec) - - # 3. Create components using RequestDirector - self._create_components() -``` - -### Component Creation Logic - -```python -def _create_tool(self, route: HTTPRoute) -> Tool: - # All tools use RequestDirector for consistent, high-performance request building - return OpenAPITool( - client=self._client, - route=route, - director=self._director, - name=tool_name, - description=description, - parameters=flat_param_schema - ) -``` - -## Data Flow - -### Stateless Request Building - -``` -OpenAPI Spec → HTTPRoute with Pre-calculated Fields → RequestDirector → HTTP Request → Structured Response -``` - -1. **Spec Parsing**: OpenAPI spec parsed to `HTTPRoute` models with pre-calculated schemas -2. **RequestDirector Setup**: openapi-core Spec initialized for request building -3. **Component Creation**: Create components with RequestDirector reference -4. **Request Building**: RequestDirector builds HTTP request from flat parameters -5. **Request Execution**: Execute request with httpx client -6. **Response Processing**: Return structured MCP response - -## Key Features - -### 1. Enhanced Parameter Handling - -#### Parameter Collision Resolution -- **Automatic Suffixing**: Colliding parameters get location-based suffixes -- **Example**: `id` in path and body becomes `id__path` and `id` -- **Transparent**: LLMs see suffixed parameters, implementation routes correctly - -#### DeepObject Style Support -- **Native Support**: Generated client handles all deepObject variations -- **Explode Handling**: Proper support for explode=true/false -- **Complex Objects**: Nested object serialization works correctly - -### 2. Robust Error Handling - -#### HTTP Error Mapping -- **Status Code Mapping**: HTTP errors mapped to appropriate MCP errors -- **Structured Responses**: Error details preserved in tool results -- **Timeout Handling**: Network timeouts handled gracefully - -#### Request Building Error Handling -- **Parameter Validation**: Invalid parameters caught during request building -- **Schema Validation**: openapi-core validates all OpenAPI constraints -- **Graceful Degradation**: Missing optional parameters handled smoothly - -### 3. Performance Optimizations - -#### Efficient Client Reuse -- **Connection Pooling**: HTTP connections reused across requests -- **Client Caching**: Generated clients cached for performance -- **Async Support**: Full async/await throughout - -#### Request Optimization -- **Pre-calculated Schemas**: All complex processing done during initialization -- **Parameter Mapping**: Collision resolution handled upfront -- **Zero Latency**: No runtime code generation or complex schema processing - -## Configuration - -### Server Options - -```python -server = FastMCPOpenAPI( - openapi_spec=spec, # Required: OpenAPI specification - client=httpx_client, # Required: HTTP client instance - name="API Server", # Optional: Server name - route_map=custom_routes, # Optional: Custom route mappings - enable_caching=True, # Optional: Enable response caching -) -``` - -### Route Mapping Customization - -```python -from fastmcp.server.openapi_new.routing import RouteMap - -custom_routes = RouteMap({ - "GET:/users": "tool", # Force specific operations to be tools - "GET:/status": "resource", # Force specific operations to be resources -}) -``` - -## Testing Strategy - -### Test Structure - -Tests are organized by functionality: -- `test_server.py` - Server integration and RequestDirector behavior -- `test_parameter_collisions.py` - Parameter collision handling -- `test_deepobject_style.py` - DeepObject parameter style support -- `test_openapi_features.py` - General OpenAPI feature compliance - -### Testing Philosophy - -1. **Real Integration**: Test with real OpenAPI specs and HTTP clients -2. **Minimal Mocking**: Only mock external API endpoints -3. **Behavioral Focus**: Test behavior, not implementation details -4. **Performance Focus**: Test that initialization is fast and stateless - -### Example Test Pattern - -```python -async def test_stateless_request_building(): - """Test that server works with stateless RequestDirector approach.""" - - # Test server initialization is fast - start_time = time.time() - server = FastMCPOpenAPI(spec=valid_spec, client=client) - init_time = time.time() - start_time - assert init_time < 0.01 # Should be very fast - - # Verify RequestDirector functionality - assert hasattr(server, '_director') - assert hasattr(server, '_spec') -``` - -## Migration Benefits - -### From Legacy Implementation - -1. **Eliminated Startup Latency**: Zero code generation overhead (100-200ms improvement) -2. **Better OpenAPI Compliance**: openapi-core handles all OpenAPI features correctly -3. **Serverless Friendly**: Perfect for cold-start environments -4. **Simplified Architecture**: Single RequestDirector approach eliminates complexity -5. **Enhanced Reliability**: No dynamic code generation failures - -### Backward Compatibility - -- **Same Interface**: Public API unchanged from legacy implementation -- **Performance Improvement**: Significantly faster initialization -- **No Breaking Changes**: Existing code works without modification - -## Monitoring and Debugging - -### Logging - -```python -# Enable debug logging to see implementation choices -import logging -logging.getLogger("fastmcp.server.openapi_new").setLevel(logging.DEBUG) -``` - -### Key Log Messages -- **RequestDirector Initialization**: Success/failure of RequestDirector setup -- **Schema Pre-calculation**: Pre-calculated schema and parameter map status -- **Request Building**: Parameter mapping and URL construction details -- **Performance Metrics**: Request timing and error rates - -### Debugging Common Issues - -1. **RequestDirector Initialization Fails** - - Check OpenAPI spec validity with `openapi-core` - - Verify spec format is correct JSON/YAML - - Ensure all required OpenAPI fields are present - -2. **Parameter Issues** - - Enable debug logging for parameter processing - - Check for parameter collision warnings - - Verify OpenAPI spec parameter definitions - -3. **Performance Issues** - - Monitor RequestDirector request building timing - - Check HTTP client configuration - - Review response processing timing - -## Future Enhancements - -### Planned Features - -1. **Advanced Caching**: Intelligent response caching with TTL -2. **Streaming Support**: Handle streaming API responses -3. **Batch Operations**: Optimize multiple operation calls -4. **Enhanced Monitoring**: Detailed metrics and health checks -5. **Configuration Management**: Dynamic configuration updates - -### Performance Improvements - -1. **Enhanced Schema Caching**: More aggressive schema pre-calculation -2. **Parallel Processing**: Concurrent operation execution -3. **Memory Optimization**: Further reduce memory footprint -4. **Request Optimization**: Smart request batching and deduplication - -## Related Documentation - -- `/utilities/openapi_new/README.md` - Utility implementation details -- `/server/openapi/README.md` - Legacy implementation reference -- `/tests/server/openapi_new/` - Comprehensive test suite -- Project documentation on OpenAPI integration patterns \ No newline at end of file diff --git a/src/fastmcp/server/providers/openapi/__init__.py b/src/fastmcp/server/providers/openapi/__init__.py deleted file mode 100644 index 3cbdde5e8..000000000 --- a/src/fastmcp/server/providers/openapi/__init__.py +++ /dev/null @@ -1,39 +0,0 @@ -"""OpenAPI provider for FastMCP. - -This module provides OpenAPI integration for FastMCP through the Provider pattern. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.providers.openapi import OpenAPIProvider - import httpx - - client = httpx.AsyncClient(base_url="https://api.example.com") - provider = OpenAPIProvider(openapi_spec=spec, client=client) - mcp = FastMCP("API Server", providers=[provider]) - ``` -""" - -from fastmcp.server.providers.openapi.components import ( - OpenAPIResource, - OpenAPIResourceTemplate, - OpenAPITool, -) -from fastmcp.server.providers.openapi.provider import OpenAPIProvider -from fastmcp.server.providers.openapi.routing import ( - ComponentFn, - MCPType, - RouteMap, - RouteMapFn, -) - -__all__ = [ - "ComponentFn", - "MCPType", - "OpenAPIProvider", - "OpenAPIResource", - "OpenAPIResourceTemplate", - "OpenAPITool", - "RouteMap", - "RouteMapFn", -] diff --git a/src/fastmcp/server/providers/openapi/components.py b/src/fastmcp/server/providers/openapi/components.py deleted file mode 100644 index 5d8cee1f4..000000000 --- a/src/fastmcp/server/providers/openapi/components.py +++ /dev/null @@ -1,421 +0,0 @@ -"""OpenAPI component classes: Tool, Resource, and ResourceTemplate.""" - -from __future__ import annotations - -import json -import re -import warnings -from collections.abc import Callable -from typing import TYPE_CHECKING, Any - -import httpx -from mcp.types import ToolAnnotations -from pydantic.networks import AnyUrl - -import fastmcp -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.resources import ( - Resource, - ResourceContent, - ResourceResult, - ResourceTemplate, -) -from fastmcp.server.dependencies import get_http_headers -from fastmcp.server.tasks.config import TaskConfig -from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.openapi import HTTPRoute -from fastmcp.utilities.openapi.director import RequestDirector - -if TYPE_CHECKING: - from fastmcp.server import Context - -_SAFE_HEADERS = frozenset( - { - "accept", - "accept-encoding", - "accept-language", - "cache-control", - "connection", - "content-length", - "content-type", - "host", - "user-agent", - } -) - - -def _redact_headers(headers: httpx.Headers) -> dict[str, str]: - return {k: v if k.lower() in _SAFE_HEADERS else "***" for k, v in headers.items()} - - -__all__ = [ - "OpenAPIResource", - "OpenAPIResourceTemplate", - "OpenAPITool", - "_extract_mime_type_from_route", -] - -logger = get_logger(__name__) - -# Default MIME type when no response content type can be inferred -_DEFAULT_MIME_TYPE = "application/json" - - -def _extract_mime_type_from_route(route: HTTPRoute) -> str: - """Extract the primary MIME type from an HTTPRoute's response definitions. - - Looks for the first successful response (2xx) and returns its content type. - Prefers JSON-compatible types when multiple are available. - Falls back to "application/json" when no response content type is declared. - """ - if not route.responses: - return _DEFAULT_MIME_TYPE - - # Priority order for success status codes - success_codes = ["200", "201", "202", "204"] - - response_info = None - for status_code in success_codes: - if status_code in route.responses: - response_info = route.responses[status_code] - break - - # If no explicit success codes, try any 2xx response - if response_info is None: - for status_code, resp_info in route.responses.items(): - if status_code.startswith("2"): - response_info = resp_info - break - - if response_info is None or not response_info.content_schema: - return _DEFAULT_MIME_TYPE - - # If there's only one content type, use it directly - content_types = list(response_info.content_schema.keys()) - if len(content_types) == 1: - return content_types[0] - - # When multiple types exist, prefer JSON-compatible types - json_compatible_types = [ - "application/json", - "application/vnd.api+json", - "application/hal+json", - "application/ld+json", - "text/json", - ] - for ct in json_compatible_types: - if ct in response_info.content_schema: - return ct - - # Fall back to the first available content type - return content_types[0] - - -def _slugify(text: str) -> str: - """Convert text to a URL-friendly slug format. - - Only contains lowercase letters, uppercase letters, numbers, and underscores. - """ - if not text: - return "" - - # Replace spaces and common separators with underscores - slug = re.sub(r"[\s\-\.]+", "_", text) - - # Remove non-alphanumeric characters except underscores - slug = re.sub(r"[^a-zA-Z0-9_]", "", slug) - - # Remove multiple consecutive underscores - slug = re.sub(r"_+", "_", slug) - - # Remove leading/trailing underscores - slug = slug.strip("_") - - return slug - - -class OpenAPITool(Tool): - """Tool implementation for OpenAPI endpoints.""" - - task_config: TaskConfig = TaskConfig(mode="forbidden") - - def __init__( - self, - client: httpx.AsyncClient, - route: HTTPRoute, - director: RequestDirector, - name: str, - description: str, - parameters: dict[str, Any], - output_schema: dict[str, Any] | None = None, - tags: set[str] | None = None, - annotations: ToolAnnotations | None = None, - serializer: Callable[[Any], str] | None = None, # Deprecated - ): - if serializer is not None and fastmcp.settings.deprecation_warnings: - warnings.warn( - "The `serializer` parameter is deprecated. " - "Return ToolResult from your tools for full control over serialization. " - "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - super().__init__( - name=name, - description=description, - parameters=parameters, - output_schema=output_schema, - tags=tags or set(), - annotations=annotations, - serializer=serializer, - ) - self._client = client - self._route = route - self._director = director - - def __repr__(self) -> str: - return f"OpenAPITool(name={self.name!r}, method={self._route.method}, path={self._route.path})" - - async def run(self, arguments: dict[str, Any]) -> ToolResult: - """Execute the HTTP request using RequestDirector.""" - # Build the request — errors here are programming/schema issues, - # not HTTP failures, so we catch them separately. - try: - base_url = str(self._client.base_url) or "http://localhost" - request = self._director.build(self._route, arguments, base_url) - - if self._client.headers: - for key, value in self._client.headers.items(): - if key not in request.headers: - request.headers[key] = value - - mcp_headers = get_http_headers() - if mcp_headers: - for key, value in mcp_headers.items(): - if key not in request.headers: - request.headers[key] = value - except Exception as e: - raise ValueError( - f"Error building request for {self._route.method.upper()} " - f"{self._route.path}: {type(e).__name__}: {e}" - ) from e - - # Send the request and process the response. - try: - logger.debug( - f"run - sending request; headers: {_redact_headers(request.headers)}" - ) - - response = await self._client.send(request) - response.raise_for_status() - - # Try to parse as JSON first - try: - result = response.json() - - # Handle structured content based on output schema - if self.output_schema is not None: - if self.output_schema.get("x-fastmcp-wrap-result"): - structured_output = {"result": result} - else: - structured_output = result - elif not isinstance(result, dict): - structured_output = {"result": result} - else: - structured_output = result - - # Structured content must be a dict for the MCP protocol. - # Wrap non-dict values that slipped through (e.g. a backend - # returning an array when the schema declared an object). - if not isinstance(structured_output, dict): - structured_output = {"result": structured_output} - - return ToolResult(structured_content=structured_output) - except json.JSONDecodeError: - return ToolResult(content=response.text) - - except httpx.HTTPStatusError as e: - error_message = ( - f"HTTP error {e.response.status_code}: {e.response.reason_phrase}" - ) - try: - error_data = e.response.json() - error_message += f" - {error_data}" - except (json.JSONDecodeError, ValueError): - if e.response.text: - error_message += f" - {e.response.text}" - raise ValueError(error_message) from e - - except httpx.TimeoutException as e: - raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e - - except httpx.RequestError as e: - raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e - - -class OpenAPIResource(Resource): - """Resource implementation for OpenAPI endpoints.""" - - task_config: TaskConfig = TaskConfig(mode="forbidden") - - def __init__( - self, - client: httpx.AsyncClient, - route: HTTPRoute, - director: RequestDirector, - uri: str, - name: str, - description: str, - mime_type: str = "application/json", - tags: set[str] | None = None, - ): - super().__init__( - uri=AnyUrl(uri), - name=name, - description=description, - mime_type=mime_type, - tags=tags or set(), - ) - self._client = client - self._route = route - self._director = director - - def __repr__(self) -> str: - return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})" - - async def read(self) -> ResourceResult: - """Fetch the resource data by making an HTTP request.""" - try: - path = self._route.path - resource_uri = str(self.uri) - - # If this is a templated resource, extract path parameters from the URI - if "{" in path and "}" in path: - parts = resource_uri.split("/") - - if len(parts) > 1: - path_params = {} - param_matches = re.findall(r"\{([^}]+)\}", path) - if param_matches: - param_matches.sort(reverse=True) - expected_param_count = len(parts) - 1 - for i, param_name in enumerate(param_matches): - if i < expected_param_count: - param_value = parts[-1 - i] - path_params[param_name] = param_value - - for param_name, param_value in path_params.items(): - path = path.replace(f"{{{param_name}}}", str(param_value)) - - # Build headers with correct precedence - headers: dict[str, str] = {} - if self._client.headers: - headers.update(self._client.headers) - mcp_headers = get_http_headers() - if mcp_headers: - headers.update(mcp_headers) - - response = await self._client.request( - method=self._route.method, - url=path, - headers=headers, - ) - response.raise_for_status() - - content_type = response.headers.get("content-type", "").lower() - - if "application/json" in content_type: - result = response.json() - return ResourceResult( - contents=[ - ResourceContent( - content=json.dumps(result), mime_type="application/json" - ) - ] - ) - elif any(ct in content_type for ct in ["text/", "application/xml"]): - return ResourceResult( - contents=[ - ResourceContent(content=response.text, mime_type=self.mime_type) - ] - ) - else: - return ResourceResult( - contents=[ - ResourceContent( - content=response.content, mime_type=self.mime_type - ) - ] - ) - - except httpx.HTTPStatusError as e: - error_message = ( - f"HTTP error {e.response.status_code}: {e.response.reason_phrase}" - ) - try: - error_data = e.response.json() - error_message += f" - {error_data}" - except (json.JSONDecodeError, ValueError): - if e.response.text: - error_message += f" - {e.response.text}" - raise ValueError(error_message) from e - - except httpx.TimeoutException as e: - raise ValueError(f"HTTP request timed out ({type(e).__name__})") from e - - except httpx.RequestError as e: - raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e - - -class OpenAPIResourceTemplate(ResourceTemplate): - """Resource template implementation for OpenAPI endpoints.""" - - task_config: TaskConfig = TaskConfig(mode="forbidden") - - def __init__( - self, - client: httpx.AsyncClient, - route: HTTPRoute, - director: RequestDirector, - uri_template: str, - name: str, - description: str, - parameters: dict[str, Any], - tags: set[str] | None = None, - mime_type: str = _DEFAULT_MIME_TYPE, - ): - super().__init__( - uri_template=uri_template, - name=name, - description=description, - parameters=parameters, - tags=tags or set(), - mime_type=mime_type, - ) - self._client = client - self._route = route - self._director = director - - def __repr__(self) -> str: - return f"OpenAPIResourceTemplate(name={self.name!r}, uri_template={self.uri_template!r}, path={self._route.path})" - - async def create_resource( - self, - uri: str, - params: dict[str, Any], - context: Context | None = None, - ) -> Resource: - """Create a resource with the given parameters.""" - uri_parts = [f"{key}={value}" for key, value in params.items()] - - return OpenAPIResource( - client=self._client, - route=self._route, - director=self._director, - uri=uri, - name=f"{self.name}-{'-'.join(uri_parts)}", - description=self.description or f"Resource for {self._route.path}", - mime_type=self.mime_type, - tags=set(self._route.tags or []), - ) diff --git a/src/fastmcp/server/providers/openapi/provider.py b/src/fastmcp/server/providers/openapi/provider.py deleted file mode 100644 index bc826d1df..000000000 --- a/src/fastmcp/server/providers/openapi/provider.py +++ /dev/null @@ -1,433 +0,0 @@ -"""OpenAPIProvider for creating MCP components from OpenAPI specifications.""" - -from __future__ import annotations - -from collections import Counter -from collections.abc import AsyncIterator, Sequence -from contextlib import asynccontextmanager -from typing import Any, Literal, cast - -import httpx -from jsonschema_path import SchemaPath - -from fastmcp.prompts import Prompt -from fastmcp.resources import Resource, ResourceTemplate -from fastmcp.server.providers.base import Provider -from fastmcp.server.providers.openapi.components import ( - OpenAPIResource, - OpenAPIResourceTemplate, - OpenAPITool, - _extract_mime_type_from_route, - _slugify, -) -from fastmcp.server.providers.openapi.routing import ( - DEFAULT_ROUTE_MAPPINGS, - ComponentFn, - MCPType, - RouteMap, - RouteMapFn, - _determine_route_type, -) -from fastmcp.tools.base import Tool -from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.openapi import ( - HTTPRoute, - extract_output_schema_from_responses, - parse_openapi_to_http_routes, -) -from fastmcp.utilities.openapi.director import RequestDirector -from fastmcp.utilities.versions import VersionSpec, version_sort_key - -__all__ = [ - "OpenAPIProvider", -] - -logger = get_logger(__name__) - -DEFAULT_TIMEOUT: float = 30.0 - - -class OpenAPIProvider(Provider): - """Provider that creates MCP components from an OpenAPI specification. - - Components are created eagerly during initialization by parsing the OpenAPI - spec. Each component makes HTTP calls to the described API endpoints. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.providers.openapi import OpenAPIProvider - import httpx - - client = httpx.AsyncClient(base_url="https://api.example.com") - provider = OpenAPIProvider(openapi_spec=spec, client=client) - - mcp = FastMCP("API Server") - mcp.add_provider(provider) - ``` - """ - - def __init__( - self, - openapi_spec: dict[str, Any], - client: httpx.AsyncClient | None = None, - *, - route_maps: list[RouteMap] | None = None, - route_map_fn: RouteMapFn | None = None, - mcp_component_fn: ComponentFn | None = None, - mcp_names: dict[str, str] | None = None, - tags: set[str] | None = None, - validate_output: bool = True, - ): - """Initialize provider by parsing OpenAPI spec and creating components. - - Args: - openapi_spec: OpenAPI schema as a dictionary - client: Optional httpx AsyncClient for making HTTP requests. - If not provided, a default client is created using the first - server URL from the OpenAPI spec with a 30-second timeout. - To customize timeout or other settings, pass your own client. - route_maps: Optional list of RouteMap objects defining route mappings - route_map_fn: Optional callable for advanced route type mapping - mcp_component_fn: Optional callable for component customization - mcp_names: Optional dictionary mapping operationId to component names - tags: Optional set of tags to add to all components - validate_output: If True (default), tools use the output schema - extracted from the OpenAPI spec for response validation. If - False, a permissive schema is used instead, allowing any - response structure while still returning structured JSON. - """ - super().__init__() - - self._owns_client = client is None - if client is None: - client = self._create_default_client(openapi_spec) - self._client = client - self._mcp_component_fn = mcp_component_fn - self._validate_output = validate_output - - # Keep track of names to detect collisions - self._used_names: dict[str, Counter[str]] = { - "tool": Counter(), - "resource": Counter(), - "resource_template": Counter(), - "prompt": Counter(), - } - - # Pre-created component storage - self._tools: dict[str, OpenAPITool] = {} - self._resources: dict[str, OpenAPIResource] = {} - self._templates: dict[str, OpenAPIResourceTemplate] = {} - - # Create openapi-core Spec and RequestDirector - try: - self._spec = SchemaPath.from_dict(cast(Any, openapi_spec)) - self._director = RequestDirector(self._spec) - except Exception as e: - logger.exception("Failed to initialize RequestDirector") - raise ValueError(f"Invalid OpenAPI specification: {e}") from e - - http_routes = parse_openapi_to_http_routes(openapi_spec) - - # Process routes - route_maps = (route_maps or []) + DEFAULT_ROUTE_MAPPINGS - for route in http_routes: - route_map = _determine_route_type(route, route_maps) - route_type = route_map.mcp_type - - if route_map_fn is not None: - try: - result = route_map_fn(route, route_type) - if result is not None: - route_type = result - logger.debug( - f"Route {route.method} {route.path} mapping customized: " - f"type={route_type.name}" - ) - except Exception as e: - logger.warning( - f"Error in route_map_fn for {route.method} {route.path}: {e}. " - f"Using default values." - ) - - component_name = self._generate_default_name(route, mcp_names) - route_tags = set(route.tags) | route_map.mcp_tags | (tags or set()) - - if route_type == MCPType.TOOL: - self._create_openapi_tool(route, component_name, tags=route_tags) - elif route_type == MCPType.RESOURCE: - self._create_openapi_resource(route, component_name, tags=route_tags) - elif route_type == MCPType.RESOURCE_TEMPLATE: - self._create_openapi_template(route, component_name, tags=route_tags) - elif route_type == MCPType.EXCLUDE: - logger.debug(f"Excluding route: {route.method} {route.path}") - - logger.debug(f"Created OpenAPIProvider with {len(http_routes)} routes") - - @classmethod - def _create_default_client(cls, openapi_spec: dict[str, Any]) -> httpx.AsyncClient: - """Create a default httpx client from the OpenAPI spec's server URL.""" - servers = openapi_spec.get("servers", []) - if not servers or not servers[0].get("url"): - raise ValueError( - "No server URL found in OpenAPI spec. Either add a 'servers' " - "entry to the spec or provide an httpx.AsyncClient explicitly." - ) - base_url = servers[0]["url"] - return httpx.AsyncClient(base_url=base_url, timeout=DEFAULT_TIMEOUT) - - @asynccontextmanager - async def lifespan(self) -> AsyncIterator[None]: - """Manage the lifecycle of the auto-created httpx client.""" - if self._owns_client: - async with self._client: - yield - else: - yield - - def _generate_default_name( - self, route: HTTPRoute, mcp_names_map: dict[str, str] | None = None - ) -> str: - """Generate a default name from the route.""" - mcp_names_map = mcp_names_map or {} - - if route.operation_id: - if route.operation_id in mcp_names_map: - name = mcp_names_map[route.operation_id] - else: - name = route.operation_id.split("__")[0] - else: - name = route.summary or f"{route.method}_{route.path}" - - name = _slugify(name) - - if len(name) > 56: - name = name[:56] - - return name - - def _get_unique_name( - self, - name: str, - component_type: Literal["tool", "resource", "resource_template", "prompt"], - ) -> str: - """Ensure the name is unique by appending numbers if needed.""" - self._used_names[component_type][name] += 1 - if self._used_names[component_type][name] == 1: - return name - - new_name = f"{name}_{self._used_names[component_type][name]}" - logger.debug( - f"Name collision: '{name}' exists as {component_type}. Using '{new_name}'." - ) - return new_name - - def _create_openapi_tool( - self, - route: HTTPRoute, - name: str, - tags: set[str], - ) -> None: - """Create and register an OpenAPITool.""" - combined_schema = route.flat_param_schema - output_schema = extract_output_schema_from_responses( - route.responses, - route.response_schemas, - route.openapi_version, - ) - - if not self._validate_output and output_schema is not None: - # Use a permissive schema that accepts any object, preserving - # the wrap-result flag so non-object responses still get wrapped - permissive: dict[str, Any] = { - "type": "object", - "additionalProperties": True, - } - if output_schema.get("x-fastmcp-wrap-result"): - permissive["x-fastmcp-wrap-result"] = True - output_schema = permissive - - tool_name = self._get_unique_name(name, "tool") - base_description = ( - route.description - or route.summary - or f"Executes {route.method} {route.path}" - ) - - tool = OpenAPITool( - client=self._client, - route=route, - director=self._director, - name=tool_name, - description=base_description, - parameters=combined_schema, - output_schema=output_schema, - tags=set(route.tags or []) | tags, - ) - - if self._mcp_component_fn is not None: - try: - self._mcp_component_fn(route, tool) - logger.debug(f"Tool {tool_name} customized by component_fn") - except Exception as e: - logger.warning(f"Error in component_fn for tool {tool_name}: {e}") - - self._tools[tool.name] = tool - - def _create_openapi_resource( - self, - route: HTTPRoute, - name: str, - tags: set[str], - ) -> None: - """Create and register an OpenAPIResource.""" - resource_name = self._get_unique_name(name, "resource") - resource_uri = f"resource://{resource_name}" - base_description = ( - route.description or route.summary or f"Represents {route.path}" - ) - - resource = OpenAPIResource( - client=self._client, - route=route, - director=self._director, - uri=resource_uri, - name=resource_name, - description=base_description, - mime_type=_extract_mime_type_from_route(route), - tags=set(route.tags or []) | tags, - ) - - if self._mcp_component_fn is not None: - try: - self._mcp_component_fn(route, resource) - logger.debug(f"Resource {resource_uri} customized by component_fn") - except Exception as e: - logger.warning( - f"Error in component_fn for resource {resource_uri}: {e}" - ) - - self._resources[str(resource.uri)] = resource - - def _create_openapi_template( - self, - route: HTTPRoute, - name: str, - tags: set[str], - ) -> None: - """Create and register an OpenAPIResourceTemplate.""" - template_name = self._get_unique_name(name, "resource_template") - - path_params = sorted(p.name for p in route.parameters if p.location == "path") - uri_template_str = f"resource://{template_name}" - if path_params: - uri_template_str += "/" + "/".join(f"{{{p}}}" for p in path_params) - - base_description = ( - route.description or route.summary or f"Template for {route.path}" - ) - - template_params_schema = { - "type": "object", - "properties": { - p.name: { - **(p.schema_.copy() if isinstance(p.schema_, dict) else {}), - **( - {"description": p.description} - if p.description - and not ( - isinstance(p.schema_, dict) and "description" in p.schema_ - ) - else {} - ), - } - for p in route.parameters - if p.location == "path" - }, - "required": [ - p.name for p in route.parameters if p.location == "path" and p.required - ], - } - - template = OpenAPIResourceTemplate( - client=self._client, - route=route, - director=self._director, - uri_template=uri_template_str, - name=template_name, - description=base_description, - parameters=template_params_schema, - tags=set(route.tags or []) | tags, - mime_type=_extract_mime_type_from_route(route), - ) - - if self._mcp_component_fn is not None: - try: - self._mcp_component_fn(route, template) - logger.debug(f"Template {uri_template_str} customized by component_fn") - except Exception as e: - logger.warning( - f"Error in component_fn for template {uri_template_str}: {e}" - ) - - self._templates[template.uri_template] = template - - # ------------------------------------------------------------------------- - # Provider interface - # ------------------------------------------------------------------------- - - async def _list_tools(self) -> Sequence[Tool]: - """Return all tools created from the OpenAPI spec.""" - return list(self._tools.values()) - - async def _get_tool( - self, name: str, version: VersionSpec | None = None - ) -> Tool | None: - """Get a tool by name.""" - tool = self._tools.get(name) - if tool is None: - return None - if version is not None and not version.matches(tool.version): - return None - return tool - - async def _list_resources(self) -> Sequence[Resource]: - """Return all resources created from the OpenAPI spec.""" - return list(self._resources.values()) - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Get a resource by URI.""" - resource = self._resources.get(uri) - if resource is None: - return None - if version is not None and not version.matches(resource.version): - return None - return resource - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - """Return all resource templates created from the OpenAPI spec.""" - return list(self._templates.values()) - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Get a resource template that matches the given URI.""" - matching = [t for t in self._templates.values() if t.matches(uri) is not None] - if not matching: - return None - if version is not None: - matching = [t for t in matching if version.matches(t.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - async def _list_prompts(self) -> Sequence[Prompt]: - """Return empty list - OpenAPI doesn't create prompts.""" - return [] - - async def get_tasks(self) -> Sequence[FastMCPComponent]: - """Return empty list - OpenAPI components don't support tasks.""" - return [] diff --git a/src/fastmcp/server/providers/openapi/routing.py b/src/fastmcp/server/providers/openapi/routing.py deleted file mode 100644 index 7805f0011..000000000 --- a/src/fastmcp/server/providers/openapi/routing.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Route mapping logic for OpenAPI operations.""" - -from __future__ import annotations - -import enum -import re -from collections.abc import Callable -from dataclasses import dataclass, field -from re import Pattern -from typing import TYPE_CHECKING, Literal - -if TYPE_CHECKING: - from fastmcp.server.providers.openapi.components import ( - OpenAPIResource, - OpenAPIResourceTemplate, - OpenAPITool, - ) - -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.openapi import HttpMethod, HTTPRoute - -__all__ = [ - "ComponentFn", - "MCPType", - "RouteMap", - "RouteMapFn", -] - -logger = get_logger(__name__) - -# Type definitions for the mapping functions -RouteMapFn = Callable[[HTTPRoute, "MCPType"], "MCPType | None"] -ComponentFn = Callable[ - [ - HTTPRoute, - "OpenAPITool | OpenAPIResource | OpenAPIResourceTemplate", - ], - None, -] - - -class MCPType(enum.Enum): - """Type of FastMCP component to create from a route. - - Enum values: - TOOL: Convert the route to a callable Tool - RESOURCE: Convert the route to a Resource (typically GET endpoints) - RESOURCE_TEMPLATE: Convert the route to a ResourceTemplate (typically GET with path params) - EXCLUDE: Exclude the route from being converted to any MCP component - """ - - TOOL = "TOOL" - RESOURCE = "RESOURCE" - RESOURCE_TEMPLATE = "RESOURCE_TEMPLATE" - EXCLUDE = "EXCLUDE" - - -@dataclass(kw_only=True) -class RouteMap: - """Mapping configuration for HTTP routes to FastMCP component types.""" - - methods: list[HttpMethod] | Literal["*"] = field(default="*") - pattern: Pattern[str] | str = field(default=r".*") - - tags: set[str] = field( - default_factory=set, - metadata={"description": "A set of tags to match. All tags must match."}, - ) - mcp_type: MCPType = field( - metadata={"description": "The type of FastMCP component to create."}, - ) - mcp_tags: set[str] = field( - default_factory=set, - metadata={ - "description": "A set of tags to apply to the generated FastMCP component." - }, - ) - - -# Default route mapping: all routes become tools. -DEFAULT_ROUTE_MAPPINGS = [ - RouteMap(mcp_type=MCPType.TOOL), -] - - -def _determine_route_type( - route: HTTPRoute, - mappings: list[RouteMap], -) -> RouteMap: - """Determine the FastMCP component type based on the route and mappings.""" - for route_map in mappings: - if route_map.methods == "*" or route.method in route_map.methods: - if isinstance(route_map.pattern, Pattern): - pattern_matches = route_map.pattern.search(route.path) - else: - pattern_matches = re.search(route_map.pattern, route.path) - - if pattern_matches: - if route_map.tags: - route_tags_set = set(route.tags or []) - if not route_map.tags.issubset(route_tags_set): - continue - - logger.debug( - f"Route {route.method} {route.path} mapped to {route_map.mcp_type.name}" - ) - return route_map - - return RouteMap(mcp_type=MCPType.TOOL) diff --git a/src/fastmcp/server/providers/proxy.py b/src/fastmcp/server/providers/proxy.py deleted file mode 100644 index f2df251cf..000000000 --- a/src/fastmcp/server/providers/proxy.py +++ /dev/null @@ -1,1077 +0,0 @@ -"""ProxyProvider for proxying to remote MCP servers. - -This module provides the `ProxyProvider` class that proxies components from -a remote MCP server via a client factory. It also provides proxy component -classes that forward execution to remote servers. -""" - -from __future__ import annotations - -import base64 -import inspect -import time -from collections.abc import Awaitable, Callable, Sequence -from typing import TYPE_CHECKING, Any, cast -from urllib.parse import quote - -import mcp.types -from mcp import ServerSession -from mcp.client.session import ClientSession -from mcp.server.lowlevel.server import request_ctx -from mcp.shared.context import LifespanContextT, RequestContext -from mcp.shared.exceptions import McpError -from mcp.types import ( - METHOD_NOT_FOUND, - BlobResourceContents, - ElicitRequestFormParams, - TextResourceContents, -) -from pydantic.networks import AnyUrl - -from fastmcp.client.client import Client, FastMCP1Server -from fastmcp.client.elicitation import ElicitResult -from fastmcp.client.logging import LogMessage -from fastmcp.client.roots import RootsList -from fastmcp.client.telemetry import client_span -from fastmcp.client.transports import ClientTransportT -from fastmcp.exceptions import ResourceError, ToolError -from fastmcp.mcp_config import MCPConfig -from fastmcp.prompts import Message, Prompt, PromptResult -from fastmcp.prompts.base import PromptArgument -from fastmcp.resources import Resource, ResourceTemplate -from fastmcp.resources.base import ResourceContent, ResourceResult -from fastmcp.server.context import Context -from fastmcp.server.dependencies import get_context -from fastmcp.server.providers.base import Provider -from fastmcp.server.server import FastMCP -from fastmcp.server.tasks.config import TaskConfig -from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.components import FastMCPComponent, get_fastmcp_metadata -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.versions import VersionSpec, version_sort_key - -if TYPE_CHECKING: - from pathlib import Path - - from fastmcp.client.transports import ClientTransport - -logger = get_logger(__name__) - -# Type alias for client factory functions -ClientFactoryT = Callable[[], Client] | Callable[[], Awaitable[Client]] - - -# ----------------------------------------------------------------------------- -# Proxy Component Classes -# ----------------------------------------------------------------------------- - - -class ProxyTool(Tool): - """A Tool that represents and executes a tool on a remote server.""" - - task_config: TaskConfig = TaskConfig(mode="forbidden") - _backend_name: str | None = None - - def __init__(self, client_factory: ClientFactoryT, **kwargs: Any): - super().__init__(**kwargs) - self._client_factory = client_factory - - async def _get_client(self) -> Client: - """Gets a client instance by calling the sync or async factory.""" - client = self._client_factory() - if inspect.isawaitable(client): - client = cast(Client, await client) - return client - - def model_copy(self, **kwargs: Any) -> ProxyTool: - """Override to preserve _backend_name when name changes.""" - update = kwargs.get("update", {}) - if "name" in update and self._backend_name is None: - # First time name is being changed, preserve original for backend calls - update = {**update, "_backend_name": self.name} - kwargs["update"] = update - return super().model_copy(**kwargs) - - @classmethod - def from_mcp_tool( - cls, client_factory: ClientFactoryT, mcp_tool: mcp.types.Tool - ) -> ProxyTool: - """Factory method to create a ProxyTool from a raw MCP tool schema.""" - return cls( - client_factory=client_factory, - name=mcp_tool.name, - title=mcp_tool.title, - description=mcp_tool.description, - parameters=mcp_tool.inputSchema, - annotations=mcp_tool.annotations, - output_schema=mcp_tool.outputSchema, - icons=mcp_tool.icons, - meta=mcp_tool.meta, - tags=get_fastmcp_metadata(mcp_tool.meta).get("tags", []), - ) - - async def run( - self, - arguments: dict[str, Any], - context: Context | None = None, - ) -> ToolResult: - """Executes the tool by making a call through the client.""" - backend_name = self._backend_name or self.name - with client_span( - f"tools/call {backend_name}", "tools/call", backend_name - ) as span: - span.set_attribute("fastmcp.provider.type", "ProxyProvider") - client = await self._get_client() - async with client: - ctx = context or get_context() - # StatefulProxyClient reuses sessions across requests, so - # its receive-loop task has stale ContextVars from the first - # request. Stash the current RequestContext in the shared - # ref so handlers can restore it before forwarding. - if isinstance(client, StatefulProxyClient): - client._proxy_rc_ref[0] = ( - ctx.request_context, - ctx._fastmcp, # weakref to FastMCP, not the Context - ) - # Build meta dict from request context - meta: dict[str, Any] | None = None - if hasattr(ctx, "request_context"): - req_ctx = ctx.request_context - # Start with existing meta if present - if hasattr(req_ctx, "meta") and req_ctx.meta: - meta = dict(req_ctx.meta) - # Add task metadata if this is a task request - if ( - hasattr(req_ctx, "experimental") - and hasattr(req_ctx.experimental, "is_task") - and req_ctx.experimental.is_task - ): - task_metadata = req_ctx.experimental.task_metadata - if task_metadata: - meta = meta or {} - meta["modelcontextprotocol.io/task"] = ( - task_metadata.model_dump(exclude_none=True) - ) - - result = await client.call_tool_mcp( - name=backend_name, arguments=arguments, meta=meta - ) - if result.isError: - raise ToolError(cast(mcp.types.TextContent, result.content[0]).text) - # Preserve backend's meta (includes task metadata for background tasks) - return ToolResult( - content=result.content, - structured_content=result.structuredContent, - meta=result.meta, - ) - - def get_span_attributes(self) -> dict[str, Any]: - return super().get_span_attributes() | { - "fastmcp.provider.type": "ProxyProvider", - "fastmcp.proxy.backend_name": self._backend_name, - } - - -class ProxyResource(Resource): - """A Resource that represents and reads a resource from a remote server.""" - - task_config: TaskConfig = TaskConfig(mode="forbidden") - _cached_content: ResourceResult | None = None - _backend_uri: str | None = None - - def __init__( - self, - client_factory: ClientFactoryT, - *, - _cached_content: ResourceResult | None = None, - **kwargs, - ): - super().__init__(**kwargs) - self._client_factory = client_factory - self._cached_content = _cached_content - - async def _get_client(self) -> Client: - """Gets a client instance by calling the sync or async factory.""" - client = self._client_factory() - if inspect.isawaitable(client): - client = cast(Client, await client) - return client - - def model_copy(self, **kwargs: Any) -> ProxyResource: - """Override to preserve _backend_uri when uri changes.""" - update = kwargs.get("update", {}) - if "uri" in update and self._backend_uri is None: - # First time uri is being changed, preserve original for backend calls - update = {**update, "_backend_uri": str(self.uri)} - kwargs["update"] = update - return super().model_copy(**kwargs) - - @classmethod - def from_mcp_resource( - cls, - client_factory: ClientFactoryT, - mcp_resource: mcp.types.Resource, - ) -> ProxyResource: - """Factory method to create a ProxyResource from a raw MCP resource schema.""" - - return cls( - client_factory=client_factory, - uri=mcp_resource.uri, - name=mcp_resource.name, - title=mcp_resource.title, - description=mcp_resource.description, - mime_type=mcp_resource.mimeType or "text/plain", - icons=mcp_resource.icons, - meta=mcp_resource.meta, - tags=get_fastmcp_metadata(mcp_resource.meta).get("tags", []), - task_config=TaskConfig(mode="forbidden"), - ) - - async def read(self) -> ResourceResult: - """Read the resource content from the remote server.""" - if self._cached_content is not None: - return self._cached_content - - backend_uri = self._backend_uri or str(self.uri) - with client_span( - f"resources/read {backend_uri}", - "resources/read", - backend_uri, - resource_uri=backend_uri, - ) as span: - span.set_attribute("fastmcp.provider.type", "ProxyProvider") - client = await self._get_client() - async with client: - result = await client.read_resource(backend_uri) - if not result: - raise ResourceError( - f"Remote server returned empty content for {backend_uri}" - ) - - # Process all items in the result list, not just the first one - contents: list[ResourceContent] = [] - for item in result: - if isinstance(item, TextResourceContents): - contents.append( - ResourceContent( - content=item.text, - mime_type=item.mimeType, - meta=item.meta, - ) - ) - elif isinstance(item, BlobResourceContents): - contents.append( - ResourceContent( - content=base64.b64decode(item.blob), - mime_type=item.mimeType, - meta=item.meta, - ) - ) - else: - raise ResourceError(f"Unsupported content type: {type(item)}") - - return ResourceResult(contents=contents) - - def get_span_attributes(self) -> dict[str, Any]: - return super().get_span_attributes() | { - "fastmcp.provider.type": "ProxyProvider", - "fastmcp.proxy.backend_uri": self._backend_uri, - } - - -class ProxyTemplate(ResourceTemplate): - """A ResourceTemplate that represents and creates resources from a remote server template.""" - - task_config: TaskConfig = TaskConfig(mode="forbidden") - _backend_uri_template: str | None = None - - def __init__(self, client_factory: ClientFactoryT, **kwargs: Any): - super().__init__(**kwargs) - self._client_factory = client_factory - - async def _get_client(self) -> Client: - """Gets a client instance by calling the sync or async factory.""" - client = self._client_factory() - if inspect.isawaitable(client): - client = cast(Client, await client) - return client - - def model_copy(self, **kwargs: Any) -> ProxyTemplate: - """Override to preserve _backend_uri_template when uri_template changes.""" - update = kwargs.get("update", {}) - if "uri_template" in update and self._backend_uri_template is None: - # First time uri_template is being changed, preserve original for backend - update = {**update, "_backend_uri_template": self.uri_template} - kwargs["update"] = update - return super().model_copy(**kwargs) - - @classmethod - def from_mcp_template( # type: ignore[override] - cls, client_factory: ClientFactoryT, mcp_template: mcp.types.ResourceTemplate - ) -> ProxyTemplate: # ty:ignore[invalid-method-override] - """Factory method to create a ProxyTemplate from a raw MCP template schema.""" - - return cls( - client_factory=client_factory, - uri_template=mcp_template.uriTemplate, - name=mcp_template.name, - title=mcp_template.title, - description=mcp_template.description, - mime_type=mcp_template.mimeType or "text/plain", - icons=mcp_template.icons, - parameters={}, # Remote templates don't have local parameters - meta=mcp_template.meta, - tags=get_fastmcp_metadata(mcp_template.meta).get("tags", []), - task_config=TaskConfig(mode="forbidden"), - ) - - async def create_resource( - self, - uri: str, - params: dict[str, Any], - context: Context | None = None, - ) -> ProxyResource: - """Create a resource from the template by calling the remote server.""" - # don't use the provided uri, because it may not be the same as the - # uri_template on the remote server. - # quote params to ensure they are valid for the uri_template - backend_template = self._backend_uri_template or self.uri_template - parameterized_uri = backend_template.format( - **{k: quote(v, safe="") for k, v in params.items()} - ) - client = await self._get_client() - async with client: - result = await client.read_resource(parameterized_uri) - - if not result: - raise ResourceError( - f"Remote server returned empty content for {parameterized_uri}" - ) - - # Process all items in the result list, not just the first one - contents: list[ResourceContent] = [] - for item in result: - if isinstance(item, TextResourceContents): - contents.append( - ResourceContent( - content=item.text, - mime_type=item.mimeType, - meta=item.meta, - ) - ) - elif isinstance(item, BlobResourceContents): - contents.append( - ResourceContent( - content=base64.b64decode(item.blob), - mime_type=item.mimeType, - meta=item.meta, - ) - ) - else: - raise ResourceError(f"Unsupported content type: {type(item)}") - - cached_content = ResourceResult(contents=contents) - - return ProxyResource( - client_factory=self._client_factory, - uri=parameterized_uri, - name=self.name, - title=self.title, - description=self.description, - mime_type=result[ - 0 - ].mimeType, # Use first item's mimeType for backward compatibility - icons=self.icons, - meta=self.meta, - tags=get_fastmcp_metadata(self.meta).get("tags", []), - _cached_content=cached_content, - ) - - def get_span_attributes(self) -> dict[str, Any]: - return super().get_span_attributes() | { - "fastmcp.provider.type": "ProxyProvider", - "fastmcp.proxy.backend_uri_template": self._backend_uri_template, - } - - -class ProxyPrompt(Prompt): - """A Prompt that represents and renders a prompt from a remote server.""" - - task_config: TaskConfig = TaskConfig(mode="forbidden") - _backend_name: str | None = None - - def __init__(self, client_factory: ClientFactoryT, **kwargs): - super().__init__(**kwargs) - self._client_factory = client_factory - - async def _get_client(self) -> Client: - """Gets a client instance by calling the sync or async factory.""" - client = self._client_factory() - if inspect.isawaitable(client): - client = cast(Client, await client) - return client - - def model_copy(self, **kwargs: Any) -> ProxyPrompt: - """Override to preserve _backend_name when name changes.""" - update = kwargs.get("update", {}) - if "name" in update and self._backend_name is None: - # First time name is being changed, preserve original for backend calls - update = {**update, "_backend_name": self.name} - kwargs["update"] = update - return super().model_copy(**kwargs) - - @classmethod - def from_mcp_prompt( - cls, client_factory: ClientFactoryT, mcp_prompt: mcp.types.Prompt - ) -> ProxyPrompt: - """Factory method to create a ProxyPrompt from a raw MCP prompt schema.""" - arguments = [ - PromptArgument( - name=arg.name, - description=arg.description, - required=arg.required or False, - ) - for arg in mcp_prompt.arguments or [] - ] - return cls( - client_factory=client_factory, - name=mcp_prompt.name, - title=mcp_prompt.title, - description=mcp_prompt.description, - arguments=arguments, - icons=mcp_prompt.icons, - meta=mcp_prompt.meta, - tags=get_fastmcp_metadata(mcp_prompt.meta).get("tags", []), - task_config=TaskConfig(mode="forbidden"), - ) - - async def render(self, arguments: dict[str, Any]) -> PromptResult: # type: ignore[override] # ty:ignore[invalid-method-override] - """Render the prompt by making a call through the client.""" - backend_name = self._backend_name or self.name - with client_span( - f"prompts/get {backend_name}", "prompts/get", backend_name - ) as span: - span.set_attribute("fastmcp.provider.type", "ProxyProvider") - client = await self._get_client() - async with client: - result = await client.get_prompt(backend_name, arguments) - # Convert GetPromptResult to PromptResult, preserving meta from result - # (not the static prompt meta which includes fastmcp tags) - # Convert PromptMessages to Messages - messages = [ - Message(content=m.content, role=m.role) for m in result.messages - ] - return PromptResult( - messages=messages, - description=result.description, - meta=result.meta, - ) - - def get_span_attributes(self) -> dict[str, Any]: - return super().get_span_attributes() | { - "fastmcp.provider.type": "ProxyProvider", - "fastmcp.proxy.backend_name": self._backend_name, - } - - -# ----------------------------------------------------------------------------- -# ProxyProvider -# ----------------------------------------------------------------------------- - - -class _CacheEntry: - """A cached sequence of components with a monotonic timestamp.""" - - __slots__ = ("items", "timestamp") - - def __init__(self, items: Sequence[Any], timestamp: float): - self.items = items - self.timestamp = timestamp - - def is_fresh(self, ttl: float) -> bool: - return (time.monotonic() - self.timestamp) < ttl - - -_DEFAULT_CACHE_TTL: float = 300.0 - - -class ProxyProvider(Provider): - """Provider that proxies to a remote MCP server via a client factory. - - This provider fetches components from a remote server and returns Proxy* - component instances that forward execution to the remote server. - - All components returned by this provider have task_config.mode="forbidden" - because tasks cannot be executed through a proxy. - - Component lists (tools, resources, templates, prompts) are cached so that - individual lookups (e.g. during ``call_tool``) can resolve from the cache - instead of opening a new backend connection. The cache stores the - backend's raw component metadata and is shared across all sessions; - per-session visibility and auth filtering are applied after cache lookup - by the server layer. The cache is refreshed whenever a ``list_*`` call - is made, and entries expire after ``cache_ttl`` seconds (default 300). - Set ``cache_ttl=0`` to disable caching. Disabling is recommended for - backends whose component lists change dynamically. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.providers.proxy import ProxyProvider, ProxyClient - - # Create a proxy provider for a remote server - proxy = ProxyProvider(lambda: ProxyClient("http://localhost:8000/mcp")) - - mcp = FastMCP("Proxy Server") - mcp.add_provider(proxy) - - # Can also add with namespace - mcp.add_provider(proxy.with_namespace("remote")) - ``` - """ - - def __init__( - self, - client_factory: ClientFactoryT, - cache_ttl: float | None = None, - ): - """Initialize a ProxyProvider. - - Args: - client_factory: A callable that returns a Client instance when called. - This gives you full control over session creation and reuse. - Can be either a synchronous or asynchronous function. - cache_ttl: How long (in seconds) to cache component lists for - individual lookups. Defaults to 300. Set to 0 to - disable caching. - """ - super().__init__() - self.client_factory = client_factory - self._cache_ttl = cache_ttl if cache_ttl is not None else _DEFAULT_CACHE_TTL - self._tools_cache: _CacheEntry[Tool] | None = None - self._resources_cache: _CacheEntry[Resource] | None = None - self._templates_cache: _CacheEntry[ResourceTemplate] | None = None - self._prompts_cache: _CacheEntry[Prompt] | None = None - - async def _get_client(self) -> Client: - """Gets a client instance by calling the sync or async factory.""" - client = self.client_factory() - if inspect.isawaitable(client): - client = cast(Client, await client) - return client - - # ------------------------------------------------------------------------- - # Tool methods - # ------------------------------------------------------------------------- - - async def _list_tools(self) -> Sequence[Tool]: - """List all tools from the remote server.""" - try: - client = await self._get_client() - async with client: - mcp_tools = await client.list_tools() - tools = [ - ProxyTool.from_mcp_tool(self.client_factory, t) for t in mcp_tools - ] - except McpError as e: - if e.error.code == METHOD_NOT_FOUND: - tools = [] - else: - raise - self._tools_cache = _CacheEntry(tools, time.monotonic()) - return tools - - async def _get_tool( - self, name: str, version: VersionSpec | None = None - ) -> Tool | None: - cache = self._tools_cache - if cache is None or not cache.is_fresh(self._cache_ttl): - await self._list_tools() - cache = self._tools_cache - assert cache is not None - matching = [t for t in cache.items if t.name == name] - if version: - matching = [t for t in matching if version.matches(t.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - # ------------------------------------------------------------------------- - # Resource methods - # ------------------------------------------------------------------------- - - async def _list_resources(self) -> Sequence[Resource]: - """List all resources from the remote server.""" - try: - client = await self._get_client() - async with client: - mcp_resources = await client.list_resources() - resources = [ - ProxyResource.from_mcp_resource(self.client_factory, r) - for r in mcp_resources - ] - except McpError as e: - if e.error.code == METHOD_NOT_FOUND: - resources = [] - else: - raise - self._resources_cache = _CacheEntry(resources, time.monotonic()) - return resources - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - cache = self._resources_cache - if cache is None or not cache.is_fresh(self._cache_ttl): - await self._list_resources() - cache = self._resources_cache - assert cache is not None - matching = [r for r in cache.items if str(r.uri) == uri] - if version: - matching = [r for r in matching if version.matches(r.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - # ------------------------------------------------------------------------- - # Resource template methods - # ------------------------------------------------------------------------- - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - """List all resource templates from the remote server.""" - try: - client = await self._get_client() - async with client: - mcp_templates = await client.list_resource_templates() - templates = [ - ProxyTemplate.from_mcp_template(self.client_factory, t) - for t in mcp_templates - ] - except McpError as e: - if e.error.code == METHOD_NOT_FOUND: - templates = [] - else: - raise - self._templates_cache = _CacheEntry(templates, time.monotonic()) - return templates - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - cache = self._templates_cache - if cache is None or not cache.is_fresh(self._cache_ttl): - await self._list_resource_templates() - cache = self._templates_cache - assert cache is not None - matching = [t for t in cache.items if t.matches(uri) is not None] - if version: - matching = [t for t in matching if version.matches(t.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - # ------------------------------------------------------------------------- - # Prompt methods - # ------------------------------------------------------------------------- - - async def _list_prompts(self) -> Sequence[Prompt]: - """List all prompts from the remote server.""" - try: - client = await self._get_client() - async with client: - mcp_prompts = await client.list_prompts() - prompts = [ - ProxyPrompt.from_mcp_prompt(self.client_factory, p) - for p in mcp_prompts - ] - except McpError as e: - if e.error.code == METHOD_NOT_FOUND: - prompts = [] - else: - raise - self._prompts_cache = _CacheEntry(prompts, time.monotonic()) - return prompts - - async def _get_prompt( - self, name: str, version: VersionSpec | None = None - ) -> Prompt | None: - cache = self._prompts_cache - if cache is None or not cache.is_fresh(self._cache_ttl): - await self._list_prompts() - cache = self._prompts_cache - assert cache is not None - matching = [p for p in cache.items if p.name == name] - if version: - matching = [p for p in matching if version.matches(p.version)] - if not matching: - return None - return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type] - - # ------------------------------------------------------------------------- - # Task methods - # ------------------------------------------------------------------------- - - async def get_tasks(self) -> Sequence[FastMCPComponent]: - """Return empty list since proxy components don't support tasks. - - Override the base implementation to avoid calling list_tools() during - server lifespan initialization, which would open the client before any - context is set. All Proxy* components have task_config.mode="forbidden". - """ - return [] - - # lifespan() uses default implementation (empty context manager) - # because client cleanup is handled per-request - - -# ----------------------------------------------------------------------------- -# Factory Functions -# ----------------------------------------------------------------------------- - - -def _create_client_factory( - target: ( - Client[ClientTransportT] - | ClientTransport - | FastMCP[Any] - | FastMCP1Server - | AnyUrl - | Path - | MCPConfig - | dict[str, Any] - | str - ), -) -> ClientFactoryT: - """Create a client factory from the given target. - - Internal helper that handles the session strategy based on the target type: - - Connected Client: reuses existing session (with warning about context mixing) - - Disconnected Client: creates fresh sessions per request - - Other targets: creates ProxyClient and fresh sessions per request - """ - if isinstance(target, Client): - client = target - if client.is_connected() and type(client) is ProxyClient: - logger.info( - "Proxy detected connected ProxyClient - creating fresh sessions for each " - "request to avoid request context leakage." - ) - - def fresh_client_factory() -> Client: - return client.new() - - return fresh_client_factory - - if client.is_connected(): - logger.info( - "Proxy detected connected client - reusing existing session for all requests. " - "This may cause context mixing in concurrent scenarios." - ) - - def reuse_client_factory() -> Client: - return client - - return reuse_client_factory - - def fresh_client_factory() -> Client: - return client.new() - - return fresh_client_factory - else: - # target is not a Client, so it's compatible with ProxyClient.__init__ - base_client = ProxyClient(cast(Any, target)) - - def proxy_client_factory() -> Client: - return base_client.new() - - return proxy_client_factory - - -# ----------------------------------------------------------------------------- -# FastMCPProxy - Convenience Wrapper -# ----------------------------------------------------------------------------- - - -class FastMCPProxy(FastMCP): - """A FastMCP server that acts as a proxy to a remote MCP-compliant server. - - This is a convenience wrapper that creates a FastMCP server with a - ProxyProvider. For more control, use FastMCP with add_provider(ProxyProvider(...)). - - Example: - ```python - from fastmcp.server import create_proxy - from fastmcp.server.providers.proxy import FastMCPProxy, ProxyClient - - # Create a proxy server using create_proxy (recommended) - proxy = create_proxy("http://localhost:8000/mcp") - - # Or use FastMCPProxy directly with explicit client factory - proxy = FastMCPProxy(client_factory=lambda: ProxyClient("http://localhost:8000/mcp")) - ``` - """ - - def __init__( - self, - *, - client_factory: ClientFactoryT, - **kwargs, - ): - """Initialize the proxy server. - - FastMCPProxy requires explicit session management via client_factory. - Use create_proxy() for convenience with automatic session strategy. - - Args: - client_factory: A callable that returns a Client instance when called. - This gives you full control over session creation and reuse. - Can be either a synchronous or asynchronous function. - **kwargs: Additional settings for the FastMCP server. - """ - super().__init__(**kwargs) - self.client_factory = client_factory - provider: Provider = ProxyProvider(client_factory) - self.add_provider(provider) - - -# ----------------------------------------------------------------------------- -# ProxyClient and Related -# ----------------------------------------------------------------------------- - - -async def default_proxy_roots_handler( - context: RequestContext[ClientSession, LifespanContextT], -) -> RootsList: - """Forward list roots request from remote server to proxy's connected clients.""" - ctx = get_context() - return await ctx.list_roots() - - -async def default_proxy_sampling_handler( - messages: list[mcp.types.SamplingMessage], - params: mcp.types.CreateMessageRequestParams, - context: RequestContext[ClientSession, LifespanContextT], -) -> mcp.types.CreateMessageResult: - """Forward sampling request from remote server to proxy's connected clients.""" - ctx = get_context() - result = await ctx.sample( - list(messages), - system_prompt=params.systemPrompt, - temperature=params.temperature, - max_tokens=params.maxTokens, - model_preferences=params.modelPreferences, - ) - content = mcp.types.TextContent(type="text", text=result.text or "") - return mcp.types.CreateMessageResult( - role="assistant", - model="fastmcp-client", - # TODO(ty): remove when ty supports isinstance exclusion narrowing - content=content, - ) - - -async def default_proxy_elicitation_handler( - message: str, - response_type: type, - params: mcp.types.ElicitRequestParams, - context: RequestContext[ClientSession, LifespanContextT], -) -> ElicitResult: - """Forward elicitation request from remote server to proxy's connected clients.""" - ctx = get_context() - # requestedSchema only exists on ElicitRequestFormParams, not ElicitRequestURLParams - requested_schema = ( - params.requestedSchema - if isinstance(params, ElicitRequestFormParams) - else {"type": "object", "properties": {}} - ) - result = await ctx.session.elicit( - message=message, - requestedSchema=requested_schema, - related_request_id=ctx.request_id, - ) - return ElicitResult(action=result.action, content=result.content) - - -async def default_proxy_log_handler(message: LogMessage) -> None: - """Forward log notification from remote server to proxy's connected clients.""" - ctx = get_context() - msg = message.data.get("msg") - extra = message.data.get("extra") - await ctx.log(msg, level=message.level, logger_name=message.logger, extra=extra) - - -async def default_proxy_progress_handler( - progress: float, - total: float | None, - message: str | None, -) -> None: - """Forward progress notification from remote server to proxy's connected clients.""" - ctx = get_context() - await ctx.report_progress(progress, total, message) - - -def _restore_request_context( - rc_ref: list[Any], -) -> None: - """Set the ``request_ctx`` and ``_current_context`` ContextVars from stashed values. - - Called at the start of proxy handler invocations in - ``StatefulProxyClient`` to fix stale ContextVars in the receive-loop - task. Only overrides when the ContextVar is genuinely stale (same - session, different request_id) to avoid corrupting the concurrent - case where multiple sessions share the same ref via ``copy.copy``. - - We stash a ``(RequestContext, weakref[FastMCP])`` tuple — never a - ``Context`` instance — because ``Context`` properties are themselves - ContextVar-dependent and would resolve stale values in the receive - loop. Instead we construct a fresh ``Context`` here after restoring - ``request_ctx``, so its property accesses read the correct values. - """ - from fastmcp.server.context import Context, _current_context - - stashed = rc_ref[0] - if stashed is None: - return - - rc, fastmcp_ref = stashed - try: - current_rc = request_ctx.get() - except LookupError: - request_ctx.set(rc) - fastmcp = fastmcp_ref() - if fastmcp is not None: - _current_context.set(Context(fastmcp)) - return - if current_rc.session is rc.session and current_rc.request_id != rc.request_id: - request_ctx.set(rc) - fastmcp = fastmcp_ref() - if fastmcp is not None: - _current_context.set(Context(fastmcp)) - - -def _make_restoring_handler(handler: Callable, rc_ref: list[Any]) -> Callable: - """Wrap a proxy handler to restore request_ctx before delegating. - - The wrapper is a plain ``async def`` so it passes - ``inspect.isfunction()`` checks in handler registration paths - (e.g., ``create_roots_callback``). - """ - - async def wrapper(*args: Any, **kwargs: Any) -> Any: - _restore_request_context(rc_ref) - return await handler(*args, **kwargs) - - return wrapper - - -class ProxyClient(Client[ClientTransportT]): - """A proxy client that forwards advanced interactions between a remote MCP server and the proxy's connected clients. - - Supports forwarding roots, sampling, elicitation, logging, and progress. - """ - - def __init__( - self, - transport: ClientTransportT - | FastMCP[Any] - | FastMCP1Server - | AnyUrl - | Path - | MCPConfig - | dict[str, Any] - | str, - **kwargs, - ): - if "name" not in kwargs: - kwargs["name"] = self.generate_name() - if "roots" not in kwargs: - kwargs["roots"] = default_proxy_roots_handler - if "sampling_handler" not in kwargs: - kwargs["sampling_handler"] = default_proxy_sampling_handler - if "elicitation_handler" not in kwargs: - kwargs["elicitation_handler"] = default_proxy_elicitation_handler - if "log_handler" not in kwargs: - kwargs["log_handler"] = default_proxy_log_handler - if "progress_handler" not in kwargs: - kwargs["progress_handler"] = default_proxy_progress_handler - super().__init__(**kwargs | {"transport": transport}) - - -class StatefulProxyClient(ProxyClient[ClientTransportT]): - """A proxy client that provides a stateful client factory for the proxy server. - - The stateful proxy client bound its copy to the server session. - And it will be disconnected when the session is exited. - - This is useful to proxy a stateful mcp server such as the Playwright MCP server. - Note that it is essential to ensure that the proxy server itself is also stateful. - - Because session reuse means the receive-loop task inherits a stale - ``request_ctx`` ContextVar snapshot, the default proxy handlers are - replaced with versions that restore the ContextVar before forwarding. - ``ProxyTool.run`` stashes the current ``RequestContext`` in - ``_proxy_rc_ref`` before each backend call, and the handlers consult - it to detect (and correct) staleness. - """ - - # Mutable list shared across copies (Client.new() uses copy.copy, - # which preserves references to mutable containers). ProxyTool.run - # writes [0] before each backend call; handlers read it to detect - # stale ContextVars and restore the correct request_ctx. - # - # Stores a (RequestContext, weakref[FastMCP]) tuple — never a Context - # instance — because Context properties are ContextVar-dependent and - # would resolve stale values in the receive loop. The restore helper - # constructs a fresh Context from the weakref after setting request_ctx. - _proxy_rc_ref: list[Any] - - def __init__(self, *args: Any, **kwargs: Any): - # Install context-restoring handler wrappers BEFORE super().__init__ - # registers them with the Client's session kwargs. - self._proxy_rc_ref = [None] - for key, default_fn in ( - ("roots", default_proxy_roots_handler), - ("sampling_handler", default_proxy_sampling_handler), - ("elicitation_handler", default_proxy_elicitation_handler), - ("log_handler", default_proxy_log_handler), - ("progress_handler", default_proxy_progress_handler), - ): - if key not in kwargs: - kwargs[key] = _make_restoring_handler(default_fn, self._proxy_rc_ref) - - super().__init__(*args, **kwargs) - self._caches: dict[ServerSession, Client[ClientTransportT]] = {} - - async def __aexit__(self, exc_type, exc_value, traceback) -> None: # type: ignore[override] # ty:ignore[invalid-method-override] - """The stateful proxy client will be forced disconnected when the session is exited. - - So we do nothing here. - """ - - async def clear(self): - """Clear all cached clients and force disconnect them.""" - while self._caches: - _, cache = self._caches.popitem() - await cache._disconnect(force=True) - - def new_stateful(self) -> Client[ClientTransportT]: - """Create a new stateful proxy client instance with the same configuration. - - Use this method as the client factory for stateful proxy server. - """ - session = get_context().session - proxy_client = self._caches.get(session, None) - - if proxy_client is None: - proxy_client = self.new() - logger.debug(f"{proxy_client} created for {session}") - self._caches[session] = proxy_client - - async def _on_session_exit(): - self._caches.pop(session) - logger.debug(f"{proxy_client} will be disconnect") - await proxy_client._disconnect(force=True) - - session._exit_stack.push_async_callback(_on_session_exit) - - return proxy_client diff --git a/src/fastmcp/server/providers/skills/__init__.py b/src/fastmcp/server/providers/skills/__init__.py deleted file mode 100644 index b15c1c636..000000000 --- a/src/fastmcp/server/providers/skills/__init__.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Skills providers for exposing agent skills as MCP resources. - -This module provides a two-layer architecture for skill discovery: - -- **SkillProvider**: Handles a single skill folder, exposing its files as resources. -- **SkillsDirectoryProvider**: Scans a directory, creates a SkillProvider per folder. -- **Vendor providers**: Platform-specific providers for Claude, Cursor, VS Code, Codex, - Gemini, Goose, Copilot, and OpenCode. - -Example: - ```python - from pathlib import Path - from fastmcp import FastMCP - from fastmcp.server.providers.skills import ClaudeSkillsProvider, SkillProvider - - mcp = FastMCP("Skills Server") - - # Load a single skill - mcp.add_provider(SkillProvider(Path.home() / ".claude/skills/pdf-processing")) - - # Or load all skills in a directory - mcp.add_provider(ClaudeSkillsProvider()) # Uses ~/.claude/skills/ - ``` -""" - -from __future__ import annotations - -# Import providers -from fastmcp.server.providers.skills.claude_provider import ClaudeSkillsProvider -from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider -from fastmcp.server.providers.skills.skill_provider import SkillProvider -from fastmcp.server.providers.skills.vendor_providers import ( - CodexSkillsProvider, - CopilotSkillsProvider, - CursorSkillsProvider, - GeminiSkillsProvider, - GooseSkillsProvider, - OpenCodeSkillsProvider, - VSCodeSkillsProvider, -) - - -# Backwards compatibility alias -SkillsProvider = SkillsDirectoryProvider - - -__all__ = [ - "ClaudeSkillsProvider", - "CodexSkillsProvider", - "CopilotSkillsProvider", - "CursorSkillsProvider", - "GeminiSkillsProvider", - "GooseSkillsProvider", - "OpenCodeSkillsProvider", - "SkillProvider", - "SkillsDirectoryProvider", - "SkillsProvider", # Backwards compatibility alias - "VSCodeSkillsProvider", -] diff --git a/src/fastmcp/server/providers/skills/_common.py b/src/fastmcp/server/providers/skills/_common.py deleted file mode 100644 index d0e1177a5..000000000 --- a/src/fastmcp/server/providers/skills/_common.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Shared utilities and data structures for skills providers.""" - -from __future__ import annotations - -import hashlib -import re -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - - -@dataclass -class SkillFileInfo: - """Information about a file within a skill.""" - - path: str # Relative path within skill directory - size: int - hash: str # sha256 hash - - -@dataclass -class SkillInfo: - """Parsed information about a skill.""" - - name: str # Directory name (canonical identifier) - description: str # From frontmatter or first line - path: Path # Absolute path to skill directory - main_file: str # Name of main file (e.g., "SKILL.md") - files: list[SkillFileInfo] = field(default_factory=list) - frontmatter: dict[str, Any] = field(default_factory=dict) - - -def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]: - """Parse YAML frontmatter from markdown content. - - Args: - content: Markdown content potentially starting with --- - - Returns: - Tuple of (frontmatter dict, remaining content) - """ - if not content.startswith("---"): - return {}, content - - # Find the closing --- - end_match = re.search(r"\n---\s*\n", content[3:]) - if not end_match: - return {}, content - - frontmatter_text = content[3 : 3 + end_match.start()] - remaining = content[3 + end_match.end() :] - - # Parse YAML (simple key: value parsing, no complex types) - frontmatter: dict[str, Any] = {} - for line in frontmatter_text.strip().split("\n"): - if ":" in line: - key, _, value = line.partition(":") - key = key.strip() - value = value.strip() - - # Handle quoted strings - if (value.startswith('"') and value.endswith('"')) or ( - value.startswith("'") and value.endswith("'") - ): - value = value[1:-1] - - # Handle lists [a, b, c] - if value.startswith("[") and value.endswith("]"): - items = value[1:-1].split(",") - value = [item.strip().strip("\"'") for item in items if item.strip()] - - frontmatter[key] = value - - return frontmatter, remaining - - -def compute_file_hash(path: Path) -> str: - """Compute SHA256 hash of a file.""" - sha256 = hashlib.sha256() - with open(path, "rb") as f: - for chunk in iter(lambda: f.read(8192), b""): - sha256.update(chunk) - return f"sha256:{sha256.hexdigest()}" - - -def scan_skill_files(skill_dir: Path) -> list[SkillFileInfo]: - """Scan a skill directory for all files.""" - files = [] - resolved_skill_dir = skill_dir.resolve() - - # Sort for deterministic ordering across platforms - for file_path in sorted(skill_dir.rglob("*")): - if file_path.is_file(): - resolved_file_path = file_path.resolve() - if not resolved_file_path.is_relative_to(resolved_skill_dir): - continue - - rel_path = file_path.relative_to(skill_dir) - files.append( - SkillFileInfo( - # Use POSIX paths for cross-platform URI consistency - path=rel_path.as_posix(), - size=resolved_file_path.stat().st_size, - hash=compute_file_hash(resolved_file_path), - ) - ) - return files diff --git a/src/fastmcp/server/providers/skills/claude_provider.py b/src/fastmcp/server/providers/skills/claude_provider.py deleted file mode 100644 index b7264953c..000000000 --- a/src/fastmcp/server/providers/skills/claude_provider.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Claude-specific skills provider for Claude Code skills.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Literal - -from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider - - -class ClaudeSkillsProvider(SkillsDirectoryProvider): - """Provider for Claude Code skills from ~/.claude/skills/. - - A convenience subclass that sets the default root to Claude's skills location. - - Args: - reload: If True, re-scan on every request. Defaults to False. - supporting_files: How supporting files are exposed: - - "template": Accessed via ResourceTemplate, hidden from list_resources(). - - "resources": Each file exposed as individual Resource in list_resources(). - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.providers.skills import ClaudeSkillsProvider - - mcp = FastMCP("Claude Skills") - mcp.add_provider(ClaudeSkillsProvider()) # Uses default location - ``` - """ - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".claude" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) diff --git a/src/fastmcp/server/providers/skills/directory_provider.py b/src/fastmcp/server/providers/skills/directory_provider.py deleted file mode 100644 index c390b42f5..000000000 --- a/src/fastmcp/server/providers/skills/directory_provider.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Directory scanning provider for discovering multiple skills.""" - -from __future__ import annotations - -from collections.abc import Sequence -from pathlib import Path -from typing import Literal - -from fastmcp.resources.base import Resource -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.providers.aggregate import AggregateProvider -from fastmcp.server.providers.skills.skill_provider import SkillProvider -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.versions import VersionSpec - -logger = get_logger(__name__) - - -class SkillsDirectoryProvider(AggregateProvider): - """Provider that scans directories and creates a SkillProvider per skill folder. - - This extends AggregateProvider to combine multiple SkillProviders into one. - Each subdirectory containing a main file (default: SKILL.md) becomes a skill. - Can scan multiple root directories - if a skill name appears in multiple roots, - the first one found wins. - - Args: - roots: Root directory(ies) containing skill folders. Can be a single path - or a sequence of paths. - reload: If True, re-discover skills on each request. Defaults to False. - main_file_name: Name of the main skill file. Defaults to "SKILL.md". - supporting_files: How supporting files are exposed in child SkillProviders: - - "template": Accessed via ResourceTemplate, hidden from list_resources(). - - "resources": Each file exposed as individual Resource in list_resources(). - - Example: - ```python - from pathlib import Path - from fastmcp import FastMCP - from fastmcp.server.providers.skills import SkillsDirectoryProvider - - mcp = FastMCP("Skills") - # Single directory - mcp.add_provider(SkillsDirectoryProvider( - roots=Path.home() / ".claude" / "skills", - reload=True, # Re-scan on each request - )) - # Multiple directories - mcp.add_provider(SkillsDirectoryProvider( - roots=[Path("/etc/skills"), Path.home() / ".local" / "skills"], - )) - ``` - """ - - def __init__( - self, - roots: str | Path | Sequence[str | Path], - reload: bool = False, - main_file_name: str = "SKILL.md", - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - super().__init__() - # Normalize to sequence: single path becomes list - if isinstance(roots, (str, Path)): - roots = [roots] - - self._roots = [Path(r).resolve() for r in roots] - self._reload = reload - self._main_file_name = main_file_name - self._supporting_files = supporting_files - self._discovered = False - - # Discover skills at init - self._discover_skills() - - def _discover_skills(self) -> None: - """Scan root directories and create SkillProvider per valid skill folder.""" - # Clear existing providers if reloading - self.providers.clear() - - seen_skill_names: set[str] = set() - - for root in self._roots: - if not root.exists(): - logger.debug(f"Skills root does not exist: {root}") - continue - - for skill_dir in root.iterdir(): - if not skill_dir.is_dir(): - continue - - main_file = skill_dir / self._main_file_name - if not main_file.exists(): - continue - - skill_name = skill_dir.name - # Skip if we've already seen this skill name (first wins) - if skill_name in seen_skill_names: - logger.debug( - f"Skipping duplicate skill '{skill_name}' from {root} " - f"(already found in earlier root)" - ) - continue - - try: - provider = SkillProvider( - skill_path=skill_dir, - main_file_name=self._main_file_name, - supporting_files=self._supporting_files, - ) - self.providers.append(provider) - seen_skill_names.add(skill_name) - except (FileNotFoundError, PermissionError, OSError): - logger.exception(f"Failed to load skill: {skill_dir.name}") - - self._discovered = True - logger.debug( - f"SkillsDirectoryProvider loaded {len(self.providers)} skills " - f"from {len(self._roots)} root(s)" - ) - - async def _ensure_discovered(self) -> None: - """Ensure skills are discovered, rediscovering if reload is enabled.""" - if self._reload or not self._discovered: - self._discover_skills() - - # Override list methods to support reload - async def _list_resources(self) -> Sequence[Resource]: - await self._ensure_discovered() - return await super()._list_resources() - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - await self._ensure_discovered() - return await super()._list_resource_templates() - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - await self._ensure_discovered() - return await super()._get_resource(uri, version) - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - await self._ensure_discovered() - return await super()._get_resource_template(uri, version) - - def __repr__(self) -> str: - roots_repr = self._roots[0] if len(self._roots) == 1 else self._roots - return ( - f"SkillsDirectoryProvider(roots={roots_repr!r}, " - f"reload={self._reload}, skills={len(self.providers)})" - ) diff --git a/src/fastmcp/server/providers/skills/skill_provider.py b/src/fastmcp/server/providers/skills/skill_provider.py deleted file mode 100644 index 8e8d2cf4b..000000000 --- a/src/fastmcp/server/providers/skills/skill_provider.py +++ /dev/null @@ -1,449 +0,0 @@ -"""Basic skill provider for handling a single skill folder.""" - -from __future__ import annotations - -import json -import mimetypes -from collections.abc import Sequence -from pathlib import Path -from typing import Any, Literal, cast - -from pydantic import AnyUrl - -from fastmcp.resources.base import Resource, ResourceResult -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.providers.base import Provider -from fastmcp.server.providers.skills._common import ( - SkillInfo, - parse_frontmatter, - scan_skill_files, -) -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.versions import VersionSpec - -logger = get_logger(__name__) - -# Ensure .md is recognized as text/markdown on all platforms (Windows may not have this) -mimetypes.add_type("text/markdown", ".md") - - -# ----------------------------------------------------------------------------- -# Skill-specific Resource and ResourceTemplate subclasses -# ----------------------------------------------------------------------------- - - -class SkillResource(Resource): - """A resource representing a skill's main file or manifest.""" - - skill_info: SkillInfo - is_manifest: bool = False - - def get_meta(self) -> dict[str, Any]: - meta = super().get_meta() - fastmcp = cast(dict[str, Any], meta["fastmcp"]) - fastmcp["skill"] = { - "name": self.skill_info.name, - "is_manifest": self.is_manifest, - } - return meta - - async def read(self) -> str | bytes | ResourceResult: - """Read the resource content.""" - if self.is_manifest: - return self._generate_manifest() - else: - main_file_path = self.skill_info.path / self.skill_info.main_file - return main_file_path.read_text() - - def _generate_manifest(self) -> str: - """Generate JSON manifest for the skill.""" - manifest = { - "skill": self.skill_info.name, - "files": [ - {"path": f.path, "size": f.size, "hash": f.hash} - for f in self.skill_info.files - ], - } - return json.dumps(manifest, indent=2) - - -class SkillFileTemplate(ResourceTemplate): - """A template for accessing files within a skill.""" - - skill_info: SkillInfo - - async def read(self, arguments: dict[str, Any]) -> str | bytes | ResourceResult: - """Read a file from the skill directory.""" - file_path = arguments.get("path", "") - full_path = self.skill_info.path / file_path - - # Security: ensure path doesn't escape skill directory - try: - full_path = full_path.resolve() - if not full_path.is_relative_to(self.skill_info.path): - raise ValueError(f"Path {file_path} escapes skill directory") - except ValueError as e: - raise ValueError(f"Invalid path: {e}") from e - - if not full_path.exists(): - raise FileNotFoundError(f"File not found: {file_path}") - - if not full_path.is_file(): - raise ValueError(f"Not a file: {file_path}") - - # Determine if binary or text based on mime type - mime_type, _ = mimetypes.guess_type(str(full_path)) - if mime_type and mime_type.startswith("text/"): - return full_path.read_text() - else: - return full_path.read_bytes() - - async def _read( # type: ignore[override] - self, - uri: str, - params: dict[str, Any], - task_meta: Any = None, - ) -> ResourceResult: # ty:ignore[invalid-method-override] - """Server entry point - read file directly without creating ephemeral resource. - - Note: task_meta is ignored - this template doesn't support background tasks. - """ - # Call read() directly and convert to ResourceResult - result = await self.read(arguments=params) - return self.convert_result(result) - - async def create_resource(self, uri: str, params: dict[str, Any]) -> Resource: - """Create a resource for the given URI and parameters. - - Note: This is not typically used since _read() handles file reading directly. - Provided for compatibility with the ResourceTemplate interface. - """ - file_path = params.get("path", "") - full_path = (self.skill_info.path / file_path).resolve() - - # Security: ensure path doesn't escape skill directory - if not full_path.is_relative_to(self.skill_info.path): - raise ValueError(f"Path {file_path} escapes skill directory") - - mime_type, _ = mimetypes.guess_type(str(full_path)) - - # Create a SkillFileResource that can read the file - return SkillFileResource( - uri=AnyUrl(uri), - name=f"{self.skill_info.name}/{file_path}", - description=f"File from {self.skill_info.name} skill", - mime_type=mime_type or "application/octet-stream", - skill_info=self.skill_info, - file_path=file_path, - ) - - -class SkillFileResource(Resource): - """A resource representing a specific file within a skill.""" - - skill_info: SkillInfo - file_path: str - - def get_meta(self) -> dict[str, Any]: - meta = super().get_meta() - fastmcp = cast(dict[str, Any], meta["fastmcp"]) - fastmcp["skill"] = { - "name": self.skill_info.name, - } - return meta - - async def read(self) -> str | bytes | ResourceResult: - """Read the file content.""" - full_path = self.skill_info.path / self.file_path - - # Security check - full_path = full_path.resolve() - if not full_path.is_relative_to(self.skill_info.path): - raise ValueError(f"Path {self.file_path} escapes skill directory") - - if not full_path.exists(): - raise FileNotFoundError(f"File not found: {self.file_path}") - - mime_type, _ = mimetypes.guess_type(str(full_path)) - if mime_type and mime_type.startswith("text/"): - return full_path.read_text() - else: - return full_path.read_bytes() - - -# ----------------------------------------------------------------------------- -# SkillProvider - handles a SINGLE skill folder -# ----------------------------------------------------------------------------- - - -class SkillProvider(Provider): - """Provider that exposes a single skill folder as MCP resources. - - Each skill folder must contain a main file (default: SKILL.md) and may - contain additional supporting files. - - Exposes: - - A Resource for the main file (skill://{name}/SKILL.md) - - A Resource for the synthetic manifest (skill://{name}/_manifest) - - Supporting files via ResourceTemplate or Resources (configurable) - - Args: - skill_path: Path to the skill directory. - main_file_name: Name of the main skill file. Defaults to "SKILL.md". - supporting_files: How supporting files (everything except main file and - manifest) are exposed to clients: - - "template": Accessed via ResourceTemplate, hidden from list_resources(). - Clients discover files by reading the manifest first. - - "resources": Each file exposed as individual Resource in list_resources(). - Full enumeration upfront. - - Example: - ```python - from pathlib import Path - from fastmcp import FastMCP - from fastmcp.server.providers.skills import SkillProvider - - mcp = FastMCP("My Skill") - mcp.add_provider(SkillProvider( - Path.home() / ".claude/skills/pdf-processing" - )) - ``` - """ - - def __init__( - self, - skill_path: str | Path, - main_file_name: str = "SKILL.md", - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - super().__init__() - self._skill_path = Path(skill_path).resolve() - self._main_file_name = main_file_name - self._supporting_files = supporting_files - self._skill_info: SkillInfo | None = None - - # Load at init to catch errors early - self._load_skill() - - def _load_skill(self) -> None: - """Load and parse the skill directory.""" - main_file = self._skill_path / self._main_file_name - - if not self._skill_path.exists(): - raise FileNotFoundError(f"Skill directory not found: {self._skill_path}") - - if not main_file.exists(): - raise FileNotFoundError( - f"Main skill file not found: {main_file}. " - f"Expected {self._main_file_name} in {self._skill_path}" - ) - - content = main_file.read_text() - frontmatter, body = parse_frontmatter(content) - - # Get description from frontmatter or first non-empty line - description = frontmatter.get("description", "") - if not description: - for line in body.strip().split("\n"): - line = line.strip() - if line and not line.startswith("#"): - description = line[:200] - break - elif line.startswith("#"): - description = line.lstrip("#").strip()[:200] - break - - # Scan all files in the skill directory - files = scan_skill_files(self._skill_path) - - self._skill_info = SkillInfo( - name=self._skill_path.name, - description=description or f"Skill: {self._skill_path.name}", - path=self._skill_path, - main_file=self._main_file_name, - files=files, - frontmatter=frontmatter, - ) - - logger.debug(f"SkillProvider loaded skill: {self._skill_info.name}") - - @property - def skill_info(self) -> SkillInfo: - """Get the loaded skill info.""" - if self._skill_info is None: - raise RuntimeError("Skill not loaded") - return self._skill_info - - # ------------------------------------------------------------------------- - # Provider interface implementation - # ------------------------------------------------------------------------- - - async def _list_resources(self) -> Sequence[Resource]: - """List skill resources.""" - skill = self.skill_info - resources: list[Resource] = [] - - # Main skill file - resources.append( - SkillResource( - uri=AnyUrl(f"skill://{skill.name}/{self._main_file_name}"), - name=f"{skill.name}/{self._main_file_name}", - description=skill.description, - mime_type="text/markdown", - skill_info=skill, - is_manifest=False, - ) - ) - - # Synthetic manifest - resources.append( - SkillResource( - uri=AnyUrl(f"skill://{skill.name}/_manifest"), - name=f"{skill.name}/_manifest", - description=f"File listing for {skill.name}", - mime_type="application/json", - skill_info=skill, - is_manifest=True, - ) - ) - - # If supporting_files="resources", add all supporting files as resources - if self._supporting_files == "resources": - for file_info in skill.files: - # Skip main file and manifest (already added) - if file_info.path == self._main_file_name: - continue - - mime_type, _ = mimetypes.guess_type(file_info.path) - resources.append( - SkillFileResource( - uri=AnyUrl(f"skill://{skill.name}/{file_info.path}"), - name=f"{skill.name}/{file_info.path}", - description=f"File from {skill.name} skill", - mime_type=mime_type or "application/octet-stream", - skill_info=skill, - file_path=file_info.path, - ) - ) - - return resources - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Get a resource by URI.""" - skill = self.skill_info - - # Parse URI: skill://{skill_name}/{file_path} - if not uri.startswith("skill://"): - return None - - path_part = uri[len("skill://") :] - parts = path_part.split("/", 1) - if len(parts) != 2: - return None - - skill_name, file_path = parts - if skill_name != skill.name: - return None - - if file_path == "_manifest": - return SkillResource( - uri=AnyUrl(uri), - name=f"{skill_name}/_manifest", - description=f"File listing for {skill_name}", - mime_type="application/json", - skill_info=skill, - is_manifest=True, - ) - elif file_path == self._main_file_name: - return SkillResource( - uri=AnyUrl(uri), - name=f"{skill_name}/{self._main_file_name}", - description=skill.description, - mime_type="text/markdown", - skill_info=skill, - is_manifest=False, - ) - elif self._supporting_files == "resources": - # Check if it's a known supporting file - for file_info in skill.files: - if file_info.path == file_path: - mime_type, _ = mimetypes.guess_type(file_path) - return SkillFileResource( - uri=AnyUrl(uri), - name=f"{skill_name}/{file_path}", - description=f"File from {skill_name} skill", - mime_type=mime_type or "application/octet-stream", - skill_info=skill, - file_path=file_path, - ) - - return None - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - """List resource templates for accessing files within the skill.""" - # Only expose template if supporting_files="template" - if self._supporting_files != "template": - return [] - - skill = self.skill_info - return [ - SkillFileTemplate( - uri_template=f"skill://{skill.name}/{{path*}}", - name=f"{skill.name}_files", - description=f"Access files within {skill.name}", - mime_type="application/octet-stream", - parameters={ - "type": "object", - "properties": {"path": {"type": "string"}}, - "required": ["path"], - }, - skill_info=skill, - ) - ] - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Get a resource template that matches the given URI.""" - # Only match if supporting_files="template" - if self._supporting_files != "template": - return None - - skill = self.skill_info - - if not uri.startswith("skill://"): - return None - - path_part = uri[len("skill://") :] - parts = path_part.split("/", 1) - if len(parts) != 2: - return None - - skill_name, file_path = parts - if skill_name != skill.name: - return None - - # Don't match known resources (main file, manifest) - if file_path == "_manifest" or file_path == self._main_file_name: - return None - - return SkillFileTemplate( - uri_template=f"skill://{skill.name}/{{path*}}", - name=f"{skill.name}_files", - description=f"Access files within {skill.name}", - mime_type="application/octet-stream", - parameters={ - "type": "object", - "properties": {"path": {"type": "string"}}, - "required": ["path"], - }, - skill_info=skill, - ) - - def __repr__(self) -> str: - return ( - f"SkillProvider(skill_path={self._skill_path!r}, " - f"supporting_files={self._supporting_files!r})" - ) diff --git a/src/fastmcp/server/providers/skills/vendor_providers.py b/src/fastmcp/server/providers/skills/vendor_providers.py deleted file mode 100644 index df870f161..000000000 --- a/src/fastmcp/server/providers/skills/vendor_providers.py +++ /dev/null @@ -1,142 +0,0 @@ -"""Vendor-specific skills providers for various AI coding platforms.""" - -from __future__ import annotations - -from pathlib import Path -from typing import Literal - -from fastmcp.server.providers.skills.directory_provider import SkillsDirectoryProvider - - -class CursorSkillsProvider(SkillsDirectoryProvider): - """Cursor skills from ~/.cursor/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".cursor" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class VSCodeSkillsProvider(SkillsDirectoryProvider): - """VS Code skills from ~/.copilot/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".copilot" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class CodexSkillsProvider(SkillsDirectoryProvider): - """Codex skills from /etc/codex/skills/ and ~/.codex/skills/. - - Scans both system-level and user-level directories. System skills take - precedence if duplicates exist. - """ - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - system_root = Path("/etc/codex/skills") - user_root = Path.home() / ".codex" / "skills" - - # Include both paths (system first, then user) - roots = [system_root, user_root] - - super().__init__( - roots=roots, - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class GeminiSkillsProvider(SkillsDirectoryProvider): - """Gemini skills from ~/.gemini/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".gemini" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class GooseSkillsProvider(SkillsDirectoryProvider): - """Goose skills from ~/.config/agents/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".config" / "agents" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class CopilotSkillsProvider(SkillsDirectoryProvider): - """GitHub Copilot skills from ~/.copilot/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".copilot" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) - - -class OpenCodeSkillsProvider(SkillsDirectoryProvider): - """OpenCode skills from ~/.config/opencode/skills/.""" - - def __init__( - self, - reload: bool = False, - supporting_files: Literal["template", "resources"] = "template", - ) -> None: - root = Path.home() / ".config" / "opencode" / "skills" - - super().__init__( - roots=[root], - reload=reload, - main_file_name="SKILL.md", - supporting_files=supporting_files, - ) diff --git a/src/fastmcp/server/providers/wrapped_provider.py b/src/fastmcp/server/providers/wrapped_provider.py deleted file mode 100644 index 3ce097fff..000000000 --- a/src/fastmcp/server/providers/wrapped_provider.py +++ /dev/null @@ -1,144 +0,0 @@ -"""WrappedProvider for immutable transform composition. - -This module provides `_WrappedProvider`, an internal class that wraps a provider -with an additional transform. Created by `Provider.wrap_transform()`. -""" - -from __future__ import annotations - -from collections.abc import AsyncIterator, Sequence -from contextlib import asynccontextmanager -from typing import TYPE_CHECKING - -from fastmcp.server.providers.base import Provider -from fastmcp.utilities.versions import VersionSpec - -if TYPE_CHECKING: - from fastmcp.prompts.base import Prompt - from fastmcp.resources.base import Resource - from fastmcp.resources.template import ResourceTemplate - from fastmcp.server.transforms import Transform - from fastmcp.tools.base import Tool - from fastmcp.utilities.components import FastMCPComponent - - -class _WrappedProvider(Provider): - """Internal provider that wraps another provider with a transform. - - Created by Provider.wrap_transform(). Delegates all component sourcing - to the inner provider's public methods (which apply inner's transforms), - then applies the wrapper's transform on top. - - This enables immutable transform composition - the inner provider is - unchanged, and the wrapper adds its transform layer. - """ - - def __init__(self, inner: Provider, transform: Transform) -> None: - """Initialize wrapped provider. - - Args: - inner: The provider to wrap. - transform: The transform to apply on top of inner's results. - """ - super().__init__() - self._inner = inner - # Add the transform to this provider's transform list - # It will be applied via the normal transform chain - self._transforms.append(transform) - - def __repr__(self) -> str: - return f"_WrappedProvider({self._inner!r}, transforms={self._transforms!r})" - - # ------------------------------------------------------------------------- - # Delegate to inner provider's public methods (which apply inner's transforms) - # ------------------------------------------------------------------------- - - async def _list_tools(self) -> Sequence[Tool]: - """Delegate to inner's list_tools (includes inner's transforms).""" - return await self._inner.list_tools() - - async def _get_tool( - self, name: str, version: VersionSpec | None = None - ) -> Tool | None: - """Delegate to inner's get_tool (includes inner's transforms).""" - return await self._inner.get_tool(name, version) - - async def get_app_tool(self, app_name: str, tool_name: str) -> Tool | None: - """Delegate to inner, bypassing this wrapper's transforms.""" - return await self._inner.get_app_tool(app_name, tool_name) - - async def _list_resources(self) -> Sequence[Resource]: - """Delegate to inner's list_resources (includes inner's transforms).""" - return await self._inner.list_resources() - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Delegate to inner's get_resource (includes inner's transforms).""" - return await self._inner.get_resource(uri, version) - - async def _list_resource_templates(self) -> Sequence[ResourceTemplate]: - """Delegate to inner's list_resource_templates (includes inner's transforms).""" - return await self._inner.list_resource_templates() - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Delegate to inner's get_resource_template (includes inner's transforms).""" - return await self._inner.get_resource_template(uri, version) - - async def _list_prompts(self) -> Sequence[Prompt]: - """Delegate to inner's list_prompts (includes inner's transforms).""" - return await self._inner.list_prompts() - - async def _get_prompt( - self, name: str, version: VersionSpec | None = None - ) -> Prompt | None: - """Delegate to inner's get_prompt (includes inner's transforms).""" - return await self._inner.get_prompt(name, version) - - async def get_tasks(self) -> Sequence[FastMCPComponent]: - """Delegate to inner's get_tasks and apply wrapper's transforms.""" - # Import here to avoid circular imports - from fastmcp.prompts.base import Prompt - from fastmcp.resources.base import Resource - from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.base import Tool - - # Get tasks from inner (already has inner's transforms) - components = list(await self._inner.get_tasks()) - - # Apply this wrapper's transforms to the components - # We need to apply transforms per component type - tools = [c for c in components if isinstance(c, Tool)] - resources = [c for c in components if isinstance(c, Resource)] - templates = [c for c in components if isinstance(c, ResourceTemplate)] - prompts = [c for c in components if isinstance(c, Prompt)] - - # Apply this wrapper's transforms sequentially - for transform in self.transforms: - tools = await transform.list_tools(tools) - resources = await transform.list_resources(resources) - templates = await transform.list_resource_templates(templates) - prompts = await transform.list_prompts(prompts) - - return [ - c - for c in [ - *tools, - *resources, - *templates, - *prompts, - ] - if c.task_config.supports_tasks() - ] - - # ------------------------------------------------------------------------- - # Lifecycle - combine with inner - # ------------------------------------------------------------------------- - - @asynccontextmanager - async def lifespan(self) -> AsyncIterator[None]: - """Combine lifespan with inner provider.""" - async with self._inner.lifespan(): - yield diff --git a/src/fastmcp/server/proxy.py b/src/fastmcp/server/proxy.py deleted file mode 100644 index fccfd5041..000000000 --- a/src/fastmcp/server/proxy.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Backwards compatibility - import from fastmcp.server.providers.proxy instead. - -This module re-exports all proxy-related classes from their new location -at fastmcp.server.providers.proxy. Direct imports from this module are -deprecated and will be removed in a future version. -""" - -from __future__ import annotations - -import warnings - -from fastmcp.exceptions import FastMCPDeprecationWarning - -warnings.warn( - "fastmcp.server.proxy is deprecated. Use fastmcp.server.providers.proxy instead.", - FastMCPDeprecationWarning, - stacklevel=2, -) - -# Re-export everything from the new location -from fastmcp.server.providers.proxy import ( # noqa: E402 - ClientFactoryT, - FastMCPProxy, - ProxyClient, - ProxyPrompt, - ProxyProvider, - ProxyResource, - ProxyTemplate, - ProxyTool, - StatefulProxyClient, -) - -__all__ = [ - "ClientFactoryT", - "FastMCPProxy", - "ProxyClient", - "ProxyPrompt", - "ProxyProvider", - "ProxyResource", - "ProxyTemplate", - "ProxyTool", - "StatefulProxyClient", -] diff --git a/src/fastmcp/server/sampling/__init__.py b/src/fastmcp/server/sampling/__init__.py deleted file mode 100644 index 392326d35..000000000 --- a/src/fastmcp/server/sampling/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Sampling module for FastMCP servers.""" - -from fastmcp.server.sampling.run import SampleStep, SamplingResult -from fastmcp.server.sampling.sampling_tool import SamplingTool - -__all__ = [ - "SampleStep", - "SamplingResult", - "SamplingTool", -] diff --git a/src/fastmcp/server/sampling/run.py b/src/fastmcp/server/sampling/run.py deleted file mode 100644 index 4ea31f0b6..000000000 --- a/src/fastmcp/server/sampling/run.py +++ /dev/null @@ -1,703 +0,0 @@ -"""Sampling types and helper functions for FastMCP servers.""" - -from __future__ import annotations - -import inspect -import json -from collections.abc import Callable, Sequence -from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Generic, Literal, cast - -import anyio -from mcp.types import ( - ClientCapabilities, - CreateMessageResult, - CreateMessageResultWithTools, - ModelHint, - ModelPreferences, - SamplingCapability, - SamplingMessage, - SamplingMessageContentBlock, - SamplingToolsCapability, - TextContent, - ToolChoice, - ToolResultContent, - ToolUseContent, -) -from mcp.types import CreateMessageRequestParams as SamplingParams -from mcp.types import Tool as SDKTool -from pydantic import ValidationError -from typing_extensions import TypeVar - -from fastmcp import settings -from fastmcp.exceptions import ToolError -from fastmcp.server.sampling.sampling_tool import SamplingTool -from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool_transform import TransformedTool -from fastmcp.utilities.async_utils import gather -from fastmcp.utilities.json_schema import compress_schema -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import get_cached_typeadapter - -logger = get_logger(__name__) - -if TYPE_CHECKING: - from fastmcp.server.context import Context - -ResultT = TypeVar("ResultT") - -# Simplified tool choice type - just the mode string instead of the full MCP object -ToolChoiceOption = Literal["auto", "required", "none"] - - -@dataclass -class SamplingResult(Generic[ResultT]): - """Result of a sampling operation. - - Attributes: - text: The text representation of the result (raw text or JSON for structured). - result: The typed result (str for text, parsed object for structured output). - history: All messages exchanged during sampling. - """ - - text: str | None - result: ResultT - history: list[SamplingMessage] - - -@dataclass -class SampleStep: - """Result of a single sampling call. - - Represents what the LLM returned in this step plus the message history. - """ - - response: CreateMessageResult | CreateMessageResultWithTools - history: list[SamplingMessage] - - @property - def is_tool_use(self) -> bool: - """True if the LLM is requesting tool execution.""" - if isinstance(self.response, CreateMessageResultWithTools): - return self.response.stopReason == "toolUse" - return False - - @property - def text(self) -> str | None: - """Extract text from the response, if available.""" - content = self.response.content - if isinstance(content, list): - for block in content: - if isinstance(block, TextContent): - return block.text - return None - elif isinstance(content, TextContent): - return content.text - return None - - @property - def tool_calls(self) -> list[ToolUseContent]: - """Get the list of tool calls from the response.""" - content = self.response.content - if isinstance(content, list): - return [c for c in content if isinstance(c, ToolUseContent)] - elif isinstance(content, ToolUseContent): - return [content] - return [] - - -def _parse_model_preferences( - model_preferences: ModelPreferences | str | list[str] | None, -) -> ModelPreferences | None: - """Convert model preferences to ModelPreferences object.""" - if model_preferences is None: - return None - elif isinstance(model_preferences, ModelPreferences): - return model_preferences - elif isinstance(model_preferences, str): - return ModelPreferences(hints=[ModelHint(name=model_preferences)]) - elif isinstance(model_preferences, list): - if not all(isinstance(h, str) for h in model_preferences): - raise ValueError("All elements of model_preferences list must be strings.") - return ModelPreferences(hints=[ModelHint(name=h) for h in model_preferences]) - else: - raise ValueError( - "model_preferences must be one of: ModelPreferences, str, list[str], or None." - ) - - -# --- Standalone functions for sample_step() --- - - -def determine_handler_mode(context: Context, needs_tools: bool) -> bool: - """Determine whether to use fallback handler or client for sampling. - - Args: - context: The MCP context. - needs_tools: Whether the sampling request requires tool support. - - Returns: - True if fallback handler should be used, False to use client. - - Raises: - ValueError: If client lacks required capability and no fallback configured. - """ - fastmcp = context.fastmcp - session = context.session - - # Check what capabilities the client has - has_sampling = session.check_client_capability( - capability=ClientCapabilities(sampling=SamplingCapability()) - ) - has_tools_capability = session.check_client_capability( - capability=ClientCapabilities( - sampling=SamplingCapability(tools=SamplingToolsCapability()) - ) - ) - - if fastmcp.sampling_handler_behavior == "always": - if fastmcp.sampling_handler is None: - raise ValueError( - "sampling_handler_behavior is 'always' but no handler configured" - ) - return True - elif fastmcp.sampling_handler_behavior == "fallback": - client_sufficient = has_sampling and (not needs_tools or has_tools_capability) - if not client_sufficient: - if fastmcp.sampling_handler is None: - if needs_tools and has_sampling and not has_tools_capability: - raise ValueError( - "Client does not support sampling with tools. " - "The client must advertise the sampling.tools capability." - ) - raise ValueError("Client does not support sampling") - return True - elif fastmcp.sampling_handler_behavior is not None: - raise ValueError( - f"Invalid sampling_handler_behavior: {fastmcp.sampling_handler_behavior!r}. " - "Must be 'always', 'fallback', or None." - ) - elif not has_sampling: - raise ValueError("Client does not support sampling") - elif needs_tools and not has_tools_capability: - raise ValueError( - "Client does not support sampling with tools. " - "The client must advertise the sampling.tools capability." - ) - - return False - - -async def call_sampling_handler( - context: Context, - messages: list[SamplingMessage], - *, - system_prompt: str | None, - temperature: float | None, - max_tokens: int, - model_preferences: ModelPreferences | str | list[str] | None, - sdk_tools: list[SDKTool] | None, - tool_choice: ToolChoice | None, -) -> CreateMessageResult | CreateMessageResultWithTools: - """Make LLM call using the fallback handler. - - Note: This function expects the caller (sample_step) to have validated that - sampling_handler is set via determine_handler_mode(). The checks below are - safeguards against internal misuse. - """ - if context.fastmcp.sampling_handler is None: - raise RuntimeError("sampling_handler is None") - if context.request_context is None: - raise RuntimeError("request_context is None") - - result = context.fastmcp.sampling_handler( - messages, - SamplingParams( - systemPrompt=system_prompt, - messages=messages, - temperature=temperature, - maxTokens=max_tokens, - modelPreferences=_parse_model_preferences(model_preferences), - tools=sdk_tools, - toolChoice=tool_choice, - ), - context.request_context, - ) - - if inspect.isawaitable(result): - result = await result - - result = cast("str | CreateMessageResult | CreateMessageResultWithTools", result) - - # Convert string to CreateMessageResult - if isinstance(result, str): - return CreateMessageResult( - role="assistant", - content=TextContent(type="text", text=result), - model="unknown", - stopReason="endTurn", - ) - - return result - - -async def execute_tools( - tool_calls: list[ToolUseContent], - tool_map: dict[str, SamplingTool], - mask_error_details: bool = False, - tool_concurrency: int | None = None, -) -> list[ToolResultContent]: - """Execute tool calls and return results. - - Args: - tool_calls: List of tool use requests from the LLM. - tool_map: Mapping from tool name to SamplingTool. - mask_error_details: If True, mask detailed error messages from tool execution. - When masked, only generic error messages are returned to the LLM. - Tools can explicitly raise ToolError to bypass masking when they want - to provide specific error messages to the LLM. - tool_concurrency: Controls parallel execution of tools: - - None (default): Sequential execution (one at a time) - - 0: Unlimited parallel execution - - N > 0: Execute at most N tools concurrently - If any tool has sequential=True, all tools execute sequentially - regardless of this setting. - - Returns: - List of tool result content blocks in the same order as tool_calls. - """ - if tool_concurrency is not None and tool_concurrency < 0: - raise ValueError( - f"tool_concurrency must be None, 0 (unlimited), or a positive integer, " - f"got {tool_concurrency}" - ) - - async def _execute_single_tool(tool_use: ToolUseContent) -> ToolResultContent: - """Execute a single tool and return its result.""" - tool = tool_map.get(tool_use.name) - if tool is None: - return ToolResultContent( - type="tool_result", - toolUseId=tool_use.id, - content=[ - TextContent( - type="text", - text=f"Error: Unknown tool '{tool_use.name}'", - ) - ], - isError=True, - ) - - try: - result_value = await tool.run(tool_use.input) - return ToolResultContent( - type="tool_result", - toolUseId=tool_use.id, - content=[TextContent(type="text", text=str(result_value))], - ) - except ToolError as e: - # ToolError is the escape hatch - always pass message through - logger.exception(f"Error calling sampling tool '{tool_use.name}'") - return ToolResultContent( - type="tool_result", - toolUseId=tool_use.id, - content=[TextContent(type="text", text=str(e))], - isError=True, - ) - except Exception as e: - # Generic exceptions - mask based on setting - logger.exception(f"Error calling sampling tool '{tool_use.name}'") - if mask_error_details: - error_text = f"Error executing tool '{tool_use.name}'" - else: - error_text = f"Error executing tool '{tool_use.name}': {e}" - return ToolResultContent( - type="tool_result", - toolUseId=tool_use.id, - content=[TextContent(type="text", text=error_text)], - isError=True, - ) - - # Check if any tool requires sequential execution - requires_sequential = any( - tool.sequential - for tool_use in tool_calls - if (tool := tool_map.get(tool_use.name)) is not None - ) - - # Execute sequentially if required or if concurrency is None (default) - if tool_concurrency is None or requires_sequential: - tool_results: list[ToolResultContent] = [] - for tool_use in tool_calls: - result = await _execute_single_tool(tool_use) - tool_results.append(result) - return tool_results - - # Execute in parallel - if tool_concurrency == 0: - # Unlimited parallel execution - return await gather(*[_execute_single_tool(tc) for tc in tool_calls]) - else: - # Bounded parallel execution with semaphore - semaphore = anyio.Semaphore(tool_concurrency) - - async def bounded_execute(tool_use: ToolUseContent) -> ToolResultContent: - async with semaphore: - return await _execute_single_tool(tool_use) - - return await gather(*[bounded_execute(tc) for tc in tool_calls]) - - -# --- Helper functions for sampling --- - - -def prepare_messages( - messages: str | Sequence[str | SamplingMessage], -) -> list[SamplingMessage]: - """Convert various message formats to a list of SamplingMessage objects.""" - if isinstance(messages, str): - return [ - SamplingMessage( - content=TextContent(text=messages, type="text"), role="user" - ) - ] - else: - return [ - SamplingMessage(content=TextContent(text=m, type="text"), role="user") - if isinstance(m, str) - else m - for m in messages - ] - - -def prepare_tools( - tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] - | None, -) -> list[SamplingTool] | None: - """Convert tools to SamplingTool objects. - - Accepts SamplingTool instances, FunctionTool instances, TransformedTool instances, - or plain callable functions. FunctionTool and TransformedTool are converted using - from_callable_tool(), while plain functions use from_function(). - - Args: - tools: Sequence of tools to prepare. Can be SamplingTool, FunctionTool, - TransformedTool, or plain callable functions. - - Returns: - List of SamplingTool instances, or None if tools is None. - """ - if tools is None: - return None - - sampling_tools: list[SamplingTool] = [] - for t in tools: - if isinstance(t, SamplingTool): - sampling_tools.append(t) - elif isinstance(t, (FunctionTool, TransformedTool)): - sampling_tools.append(SamplingTool.from_callable_tool(t)) - elif callable(t): - sampling_tools.append(SamplingTool.from_function(t)) - else: - raise TypeError( - f"Expected SamplingTool, FunctionTool, TransformedTool, or callable, got {type(t)}" - ) - - return sampling_tools if sampling_tools else None - - -def extract_tool_calls( - response: CreateMessageResult | CreateMessageResultWithTools, -) -> list[ToolUseContent]: - """Extract tool calls from a response.""" - content = response.content - if isinstance(content, list): - return [c for c in content if isinstance(c, ToolUseContent)] - elif isinstance(content, ToolUseContent): - return [content] - return [] - - -def create_final_response_tool(result_type: type) -> SamplingTool: - """Create a synthetic 'final_response' tool for structured output. - - This tool is used to capture structured responses from the LLM. - The tool's schema is derived from the result_type. - """ - type_adapter = get_cached_typeadapter(result_type) - schema = type_adapter.json_schema() - schema = compress_schema(schema, prune_titles=True) - - # Tool parameters must be object-shaped. Wrap primitives in {"value": } - if schema.get("type") != "object": - schema = { - "type": "object", - "properties": {"value": schema}, - "required": ["value"], - } - - # The fn just returns the input as-is (validation happens in the loop) - def final_response(**kwargs: Any) -> dict[str, Any]: - return kwargs - - return SamplingTool( - name="final_response", - description=( - "Call this tool to provide your final response. " - "Use this when you have completed the task and are ready to return the result." - ), - parameters=schema, - fn=final_response, - ) - - -# --- Implementation functions for Context methods --- - - -async def sample_step_impl( - context: Context, - messages: str | Sequence[str | SamplingMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - max_tokens: int | None = None, - model_preferences: ModelPreferences | str | list[str] | None = None, - tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] - | None = None, - tool_choice: ToolChoiceOption | str | None = None, - auto_execute_tools: bool = True, - mask_error_details: bool | None = None, - tool_concurrency: int | None = None, -) -> SampleStep: - """Implementation of Context.sample_step(). - - Make a single LLM sampling call. This is a stateless function that makes - exactly one LLM call and optionally executes any requested tools. - """ - # Convert messages to SamplingMessage objects - current_messages = prepare_messages(messages) - - # Convert tools to SamplingTools - sampling_tools = prepare_tools(tools) - sdk_tools: list[SDKTool] | None = ( - [t._to_sdk_tool() for t in sampling_tools] if sampling_tools else None - ) - tool_map: dict[str, SamplingTool] = ( - {t.name: t for t in sampling_tools} if sampling_tools else {} - ) - - # Determine whether to use fallback handler or client - use_fallback = determine_handler_mode(context, bool(sampling_tools)) - - # Build tool choice - effective_tool_choice: ToolChoice | None = None - if tool_choice is not None: - if tool_choice not in ("auto", "required", "none"): - raise ValueError( - f"Invalid tool_choice: {tool_choice!r}. " - "Must be 'auto', 'required', or 'none'." - ) - effective_tool_choice = ToolChoice( - mode=cast(Literal["auto", "required", "none"], tool_choice) - ) - - # Effective max_tokens - effective_max_tokens = max_tokens if max_tokens is not None else 512 - - # Make the LLM call - if use_fallback: - response = await call_sampling_handler( - context, - current_messages, - system_prompt=system_prompt, - temperature=temperature, - max_tokens=effective_max_tokens, - model_preferences=model_preferences, - sdk_tools=sdk_tools, - tool_choice=effective_tool_choice, - ) - else: - response = await context.session.create_message( - messages=current_messages, - system_prompt=system_prompt, - temperature=temperature, - max_tokens=effective_max_tokens, - model_preferences=_parse_model_preferences(model_preferences), - tools=sdk_tools, - tool_choice=effective_tool_choice, - related_request_id=context.request_id, - ) - - # Check if this is a tool use response - is_tool_use_response = ( - isinstance(response, CreateMessageResultWithTools) - and response.stopReason == "toolUse" - ) - - # Always include the assistant response in history - current_messages.append(SamplingMessage(role="assistant", content=response.content)) - - # If not a tool use, return immediately - if not is_tool_use_response: - return SampleStep(response=response, history=current_messages) - - # If not executing tools, return with assistant message but no tool results - if not auto_execute_tools: - return SampleStep(response=response, history=current_messages) - - # Execute tools and add results to history - step_tool_calls = extract_tool_calls(response) - if step_tool_calls: - effective_mask = ( - mask_error_details - if mask_error_details is not None - else settings.mask_error_details - ) - tool_results: list[ToolResultContent] = await execute_tools( - step_tool_calls, - tool_map, - mask_error_details=effective_mask, - tool_concurrency=tool_concurrency, - ) - - if tool_results: - current_messages.append( - SamplingMessage( - role="user", - content=cast(list[SamplingMessageContentBlock], tool_results), - ) - ) - - return SampleStep(response=response, history=current_messages) - - -async def sample_impl( - context: Context, - messages: str | Sequence[str | SamplingMessage], - *, - system_prompt: str | None = None, - temperature: float | None = None, - max_tokens: int | None = None, - model_preferences: ModelPreferences | str | list[str] | None = None, - tools: Sequence[SamplingTool | FunctionTool | TransformedTool | Callable[..., Any]] - | None = None, - result_type: type[ResultT] | None = None, - mask_error_details: bool | None = None, - tool_concurrency: int | None = None, -) -> SamplingResult[ResultT]: - """Implementation of Context.sample(). - - Send a sampling request to the client and await the response. This method - runs to completion automatically, executing a tool loop until the LLM - provides a final text response. - """ - # Safety limit to prevent infinite loops - max_iterations = 100 - - # Convert tools to SamplingTools - sampling_tools = prepare_tools(tools) - - # Handle structured output with result_type - tool_choice: str | None = None - if result_type is not None and result_type is not str: - final_response_tool = create_final_response_tool(result_type) - sampling_tools = list(sampling_tools) if sampling_tools else [] - sampling_tools.append(final_response_tool) - - # Always require tool calls when result_type is set - the LLM must - # eventually call final_response (text responses are not accepted) - tool_choice = "required" - - # Convert messages for the loop - current_messages: str | Sequence[str | SamplingMessage] = messages - - for _iteration in range(max_iterations): - step = await sample_step_impl( - context, - messages=current_messages, - system_prompt=system_prompt, - temperature=temperature, - max_tokens=max_tokens, - model_preferences=model_preferences, - tools=sampling_tools, - tool_choice=tool_choice, - mask_error_details=mask_error_details, - tool_concurrency=tool_concurrency, - ) - - # Check for final_response tool call for structured output - if result_type is not None and result_type is not str and step.is_tool_use: - for tool_call in step.tool_calls: - if tool_call.name == "final_response": - # Validate and return the structured result - type_adapter = get_cached_typeadapter(result_type) - - # Unwrap if we wrapped primitives (non-object schemas) - input_data = tool_call.input - original_schema = compress_schema( - type_adapter.json_schema(), prune_titles=True - ) - if ( - original_schema.get("type") != "object" - and isinstance(input_data, dict) - and "value" in input_data - ): - input_data = input_data["value"] - - try: - validated_result = type_adapter.validate_python(input_data) - text = json.dumps( - type_adapter.dump_python(validated_result, mode="json") - ) - return SamplingResult( - text=text, - result=validated_result, - history=step.history, - ) - except ValidationError as e: - # Validation failed - add error as tool result - step.history.append( - SamplingMessage( - role="user", - content=[ - ToolResultContent( - type="tool_result", - toolUseId=tool_call.id, - content=[ - TextContent( - type="text", - text=( - f"Validation error: {e}. " - "Please try again with valid data." - ), - ) - ], - isError=True, - ) - ], - ) - ) - - # If not a tool use response, we're done - if not step.is_tool_use: - # For structured output, the LLM must use the final_response tool - if result_type is not None and result_type is not str: - raise RuntimeError( - f"Expected structured output of type {result_type.__name__}, " - "but the LLM returned a text response instead of calling " - "the final_response tool." - ) - return SamplingResult( - text=step.text, - result=cast(ResultT, step.text if step.text else ""), - history=step.history, - ) - - # Continue with the updated history - current_messages = step.history - - # After first iteration, reset tool_choice to auto (unless structured output is required) - if result_type is None or result_type is str: - tool_choice = None - - raise RuntimeError(f"Sampling exceeded maximum iterations ({max_iterations})") diff --git a/src/fastmcp/server/sampling/sampling_tool.py b/src/fastmcp/server/sampling/sampling_tool.py deleted file mode 100644 index 7f9354bbb..000000000 --- a/src/fastmcp/server/sampling/sampling_tool.py +++ /dev/null @@ -1,204 +0,0 @@ -"""SamplingTool for use during LLM sampling requests.""" - -from __future__ import annotations - -import inspect -from collections.abc import Callable -from typing import Any - -from mcp.types import TextContent -from mcp.types import Tool as SDKTool -from pydantic import ConfigDict - -from fastmcp.exceptions import AuthorizationError -from fastmcp.server.auth.authorization import AuthContext, run_auth_checks -from fastmcp.server.dependencies import get_access_token -from fastmcp.tools.base import ToolResult -from fastmcp.tools.function_parsing import ParsedFunction -from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool_transform import TransformedTool -from fastmcp.utilities.types import FastMCPBaseModel - - -class SamplingTool(FastMCPBaseModel): - """A tool that can be used during LLM sampling. - - SamplingTools bundle a tool's schema (name, description, parameters) with - an executor function, enabling servers to execute agentic workflows where - the LLM can request tool calls during sampling. - - In most cases, pass functions directly to ctx.sample(): - - def search(query: str) -> str: - '''Search the web.''' - return web_search(query) - - result = await context.sample( - messages="Find info about Python", - tools=[search], # Plain functions work directly - ) - - Create a SamplingTool explicitly when you need custom name/description: - - tool = SamplingTool.from_function(search, name="web_search") - """ - - name: str - description: str | None = None - parameters: dict[str, Any] - fn: Callable[..., Any] - sequential: bool = False - - model_config = ConfigDict(arbitrary_types_allowed=True) - - async def run(self, arguments: dict[str, Any] | None = None) -> Any: - """Execute the tool with the given arguments. - - Args: - arguments: Dictionary of arguments to pass to the tool function. - - Returns: - The result of executing the tool function. - """ - if arguments is None: - arguments = {} - - result = self.fn(**arguments) - if inspect.isawaitable(result): - result = await result - return result - - def _to_sdk_tool(self) -> SDKTool: - """Convert to an mcp.types.Tool for SDK compatibility. - - This is used internally when passing tools to the MCP SDK's - create_message() method. - """ - return SDKTool( - name=self.name, - description=self.description, - inputSchema=self.parameters, - ) - - @classmethod - def from_function( - cls, - fn: Callable[..., Any], - *, - name: str | None = None, - description: str | None = None, - sequential: bool = False, - ) -> SamplingTool: - """Create a SamplingTool from a function. - - The function's signature is analyzed to generate a JSON schema for - the tool's parameters. Type hints are used to determine parameter types. - - Args: - fn: The function to create a tool from. - name: Optional name override. Defaults to the function's name. - description: Optional description override. Defaults to the function's docstring. - sequential: If True, this tool requires sequential execution and prevents - parallel execution of all tools in the batch. Set to True for tools - with shared state, file writes, or other operations that cannot run - concurrently. Defaults to False. - - Returns: - A SamplingTool wrapping the function. - - Raises: - ValueError: If the function is a lambda without a name override. - """ - parsed = ParsedFunction.from_function(fn, validate=True) - - if name is None and parsed.name == "": - raise ValueError("You must provide a name for lambda functions") - - return cls( - name=name or parsed.name, - description=description or parsed.description, - parameters=parsed.input_schema, - fn=parsed.fn, - sequential=sequential, - ) - - @classmethod - def from_callable_tool( - cls, - tool: FunctionTool | TransformedTool, - *, - name: str | None = None, - description: str | None = None, - ) -> SamplingTool: - """Create a SamplingTool from a FunctionTool or TransformedTool. - - Reuses existing server tools in sampling contexts. For TransformedTool, - the tool's .run() method is used to ensure proper argument transformation, - and the ToolResult is automatically unwrapped. - - Args: - tool: A FunctionTool or TransformedTool to convert. - name: Optional name override. Defaults to tool.name. - description: Optional description override. Defaults to tool.description. - - Raises: - TypeError: If the tool is not a FunctionTool or TransformedTool. - """ - # Validate that the tool is a supported type - if not isinstance(tool, (FunctionTool, TransformedTool)): - raise TypeError( - f"Expected FunctionTool or TransformedTool, got {type(tool).__name__}. " - "Only callable tools can be converted to SamplingTools." - ) - - # Both FunctionTool and TransformedTool need .run() to ensure proper - # result processing (serializers, output_schema, wrap-result flags) - async def wrapper(**kwargs: Any) -> Any: - # Enforce per-tool auth checks, mirroring what the server - # dispatcher does for direct tool calls. Without this, an - # auth-protected tool wrapped as a SamplingTool could be - # invoked by the LLM during sampling without authorization. - if tool.auth is not None: - # Late import to avoid circular import with context.py - from fastmcp.server.context import _current_transport - - is_stdio = _current_transport.get() == "stdio" - if not is_stdio: - token = get_access_token() - ctx = AuthContext(token=token, component=tool) - if not await run_auth_checks(tool.auth, ctx): - raise AuthorizationError( - f"Authorization failed for tool '{tool.name}': " - "insufficient permissions" - ) - - result = await tool.run(kwargs) - # Unwrap ToolResult - extract the actual value - if isinstance(result, ToolResult): - # If there's structured_content, use that - if result.structured_content is not None: - # Check tool's schema - this is the source of truth - if tool.output_schema and tool.output_schema.get( - "x-fastmcp-wrap-result" - ): - # Tool wraps results: {"result": value} -> value - return result.structured_content.get("result") - else: - # No wrapping: use structured_content directly - return result.structured_content - # Otherwise, extract from text content - if result.content and len(result.content) > 0: - first_content = result.content[0] - if isinstance(first_content, TextContent): - return first_content.text - return result - - fn = wrapper - - # Extract the callable function, name, description, and parameters - return cls( - name=name or tool.name, - description=description or tool.description, - parameters=tool.parameters, - fn=fn, - ) diff --git a/src/fastmcp/server/server.py b/src/fastmcp/server/server.py deleted file mode 100644 index 789bc42ba..000000000 --- a/src/fastmcp/server/server.py +++ /dev/null @@ -1,2311 +0,0 @@ -"""FastMCP - A more ergonomic interface for MCP servers.""" - -from __future__ import annotations - -import asyncio -import logging -import re -import secrets -import warnings -from collections.abc import ( - AsyncIterator, - Awaitable, - Callable, - Sequence, -) -from contextlib import ( - AbstractAsyncContextManager, - asynccontextmanager, -) -from dataclasses import replace -from functools import partial -from pathlib import Path -from typing import TYPE_CHECKING, Any, Generic, Literal, TypeVar, cast, overload - -import httpx -import mcp.types -from key_value.aio.adapters.pydantic import PydanticAdapter -from key_value.aio.protocols import AsyncKeyValue -from key_value.aio.stores.memory import MemoryStore -from mcp.server.lowlevel.server import LifespanResultT -from mcp.shared.exceptions import McpError -from mcp.types import ( - Annotations, - AnyFunction, - CallToolRequestParams, - ToolAnnotations, -) -from pydantic import AnyUrl -from pydantic import ValidationError as PydanticValidationError -from starlette.routing import BaseRoute -from typing_extensions import Self - -import fastmcp -import fastmcp.server -from fastmcp.apps.config import ( - AppConfig, - app_config_to_meta_dict, - resolve_ui_mime_type, -) -from fastmcp.exceptions import ( - AuthorizationError, - FastMCPDeprecationWarning, - FastMCPError, - NotFoundError, - PromptError, - ResourceError, - ToolError, - ValidationError, -) -from fastmcp.mcp_config import MCPConfig -from fastmcp.prompts import Prompt -from fastmcp.prompts.base import PromptResult -from fastmcp.prompts.function_prompt import FunctionPrompt -from fastmcp.resources.base import Resource, ResourceResult -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.auth import AuthCheck, AuthContext, AuthProvider, run_auth_checks -from fastmcp.server.lifespan import Lifespan -from fastmcp.server.low_level import LowLevelServer -from fastmcp.server.middleware import Middleware, MiddlewareContext -from fastmcp.server.mixins import LifespanMixin, MCPOperationsMixin, TransportMixin -from fastmcp.server.providers import LocalProvider, Provider -from fastmcp.server.providers.aggregate import AggregateProvider -from fastmcp.server.tasks.config import TaskConfig, TaskMeta -from fastmcp.server.telemetry import server_span -from fastmcp.server.transforms import ( - ToolTransform, - Transform, -) -from fastmcp.server.transforms.visibility import apply_session_transforms, is_enabled -from fastmcp.settings import DuplicateBehavior as DuplicateBehaviorSetting -from fastmcp.tools.base import Tool, ToolResult -from fastmcp.tools.function_tool import FunctionTool -from fastmcp.tools.tool_transform import ToolTransformConfig -from fastmcp.utilities.components import FastMCPComponent, _coerce_version -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import FastMCPBaseModel, NotSet, NotSetT -from fastmcp.utilities.versions import ( - VersionSpec, - version_sort_key, -) - -if TYPE_CHECKING: - from fastmcp.client import Client - from fastmcp.client.client import FastMCP1Server - from fastmcp.client.sampling import SamplingHandler - from fastmcp.client.transports import ClientTransport, ClientTransportT - from fastmcp.server.providers.openapi import ComponentFn as OpenAPIComponentFn - from fastmcp.server.providers.openapi import RouteMap - from fastmcp.server.providers.openapi import RouteMapFn as OpenAPIRouteMapFn - from fastmcp.server.providers.proxy import FastMCPProxy - -logger = get_logger(__name__) - - -# The MCP SDK warns "Tool X not listed, no validation will be performed" -# for every call to app-only tools (hidden from list_tools by design). -# This fires even when validate_input=False. Suppress it. -class _SuppressUnlistedToolWarning(logging.Filter): - def filter(self, record: logging.LogRecord) -> bool: - return "not listed, no validation" not in record.getMessage() - - -logging.getLogger("mcp.server.lowlevel.server").addFilter( - _SuppressUnlistedToolWarning() -) - -F = TypeVar("F", bound=Callable[..., Any]) - -DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] - - -_REMOVED_KWARGS: dict[str, str] = { - "host": "Pass `host` to `run_http_async()`, or set FASTMCP_HOST.", - "port": "Pass `port` to `run_http_async()`, or set FASTMCP_PORT.", - "sse_path": "Pass `path` to `run_http_async()` or `http_app()`, or set FASTMCP_SSE_PATH.", - "message_path": "Set FASTMCP_MESSAGE_PATH.", - "streamable_http_path": "Pass `path` to `run_http_async()` or `http_app()`, or set FASTMCP_STREAMABLE_HTTP_PATH.", - "json_response": "Pass `json_response` to `run_http_async()` or `http_app()`, or set FASTMCP_JSON_RESPONSE.", - "stateless_http": "Pass `stateless_http` to `run_http_async()` or `http_app()`, or set FASTMCP_STATELESS_HTTP.", - "debug": "Set FASTMCP_DEBUG.", - "log_level": "Pass `log_level` to `run_http_async()`, or set FASTMCP_LOG_LEVEL.", - "on_duplicate_tools": "Use `on_duplicate=` instead.", - "on_duplicate_resources": "Use `on_duplicate=` instead.", - "on_duplicate_prompts": "Use `on_duplicate=` instead.", - "tool_serializer": "Return ToolResult from your tools instead. See https://gofastmcp.com/servers/tools#custom-serialization", - "include_tags": "Use `server.enable(tags=..., only=True)` after creating the server.", - "exclude_tags": "Use `server.disable(tags=...)` after creating the server.", - "tool_transformations": "Use `server.add_transform(ToolTransform(...))` after creating the server.", -} - - -def _check_removed_kwargs(kwargs: dict[str, Any]) -> None: - """Raise helpful TypeErrors for kwargs removed in v3.""" - for key in kwargs: - if key in _REMOVED_KWARGS: - raise TypeError( - f"FastMCP() no longer accepts `{key}`. {_REMOVED_KWARGS[key]}" - ) - if kwargs: - raise TypeError( - f"FastMCP() got unexpected keyword argument(s): {', '.join(repr(k) for k in kwargs)}" - ) - - -Transport = Literal["stdio", "http", "sse", "streamable-http"] - -# Compiled URI parsing regex to split a URI into protocol and path components -URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$") - - -LifespanCallable = Callable[ - ["FastMCP[LifespanResultT]"], AbstractAsyncContextManager[LifespanResultT] -] - - -def _get_auth_context() -> tuple[bool, Any]: - """Get auth context for the current request. - - Returns a tuple of (skip_auth, token) where: - - skip_auth=True means auth checks should be skipped (STDIO transport) - - token is the access token for HTTP transports (may be None if unauthenticated) - - Uses late import to avoid circular import with context.py. - """ - from fastmcp.server.context import _current_transport - - is_stdio = _current_transport.get() == "stdio" - if is_stdio: - return (True, None) - from fastmcp.server.dependencies import get_access_token - - return (False, get_access_token()) - - -def _is_model_visible(tool: Tool) -> bool: - """Check whether a tool should be visible to the model. - - Tools registered via ``@app.tool()`` (without ``model=True``) have - ``meta["ui"]["visibility"] == ["app"]`` — they are callable by app UIs - but should not appear in the model's tool list. - - Returns True (visible) when: - - The tool has no ``meta.ui.visibility`` (normal tools). - - ``"model"`` is in the visibility list (e.g. ``["model"]`` or ``["app", "model"]``). - - Returns False when the visibility list exists and does not contain ``"model"`` - (e.g. ``["app"]``). - """ - meta = tool.meta - if not meta: - return True - ui = meta.get("ui") - if not isinstance(ui, dict): - return True - visibility = ui.get("visibility") - if not isinstance(visibility, list): - return True - return "model" in visibility - - -@asynccontextmanager -async def default_lifespan(server: FastMCP[LifespanResultT]) -> AsyncIterator[Any]: - """Default lifespan context manager that does nothing. - - Args: - server: The server instance this lifespan is managing - - Returns: - An empty dictionary as the lifespan result. - """ - yield {} - - -def _lifespan_proxy( - fastmcp_server: FastMCP[LifespanResultT], -) -> Callable[ - [LowLevelServer[LifespanResultT]], AbstractAsyncContextManager[LifespanResultT] -]: - @asynccontextmanager - async def wrap( - low_level_server: LowLevelServer[LifespanResultT], - ) -> AsyncIterator[LifespanResultT]: - if fastmcp_server._lifespan is default_lifespan: - yield {} # ty:ignore[invalid-yield] - return - - if not fastmcp_server._lifespan_result_set: - raise RuntimeError( - "FastMCP server has a lifespan defined but no lifespan result is set, which means the server's context manager was not entered. " - + " Are you running the server in a way that supports lifespans? If so, please file an issue at https://github.com/PrefectHQ/fastmcp/issues." - ) - - yield fastmcp_server._lifespan_result # ty:ignore[invalid-yield] - - return wrap - - -class StateValue(FastMCPBaseModel): - """Wrapper for stored context state values.""" - - value: Any - - -class FastMCP( - AggregateProvider, - LifespanMixin, - MCPOperationsMixin, - TransportMixin, - Generic[LifespanResultT], -): - def __init__( - self, - name: str | None = None, - instructions: str | None = None, - *, - version: str | int | float | None = None, - website_url: str | None = None, - icons: list[mcp.types.Icon] | None = None, - auth: AuthProvider | None = None, - middleware: Sequence[Middleware] | None = None, - providers: Sequence[Provider] | None = None, - transforms: Sequence[Transform] | None = None, - lifespan: LifespanCallable | Lifespan | None = None, - tools: Sequence[Tool | Callable[..., Any]] | None = None, - on_duplicate: DuplicateBehavior | None = None, - mask_error_details: bool | None = None, - dereference_schemas: bool = True, - strict_input_validation: bool | None = None, - list_page_size: int | None = None, - tasks: bool | None = None, - session_state_store: AsyncKeyValue | None = None, - sampling_handler: SamplingHandler | None = None, - sampling_handler_behavior: Literal["always", "fallback"] | None = None, - client_log_level: mcp.types.LoggingLevel | None = None, - **kwargs: Any, - ): - _check_removed_kwargs(kwargs) - - # Initialize Provider (sets up _transforms) - super().__init__() - - self._on_duplicate: DuplicateBehaviorSetting = on_duplicate or "warn" - - # Resolve server default for background task support - self._support_tasks_by_default: bool = tasks if tasks is not None else False - - # Docket and Worker instances (set during lifespan for cross-task access) - self._docket = None - self._worker = None - - self._additional_http_routes: list[BaseRoute] = [] - - # Session-scoped state store (shared across all requests) - self._state_storage: AsyncKeyValue = session_state_store or MemoryStore() - self._state_store: PydanticAdapter[StateValue] = PydanticAdapter[StateValue]( - key_value=self._state_storage, - pydantic_model=StateValue, - default_collection="fastmcp_state", - ) - - # Create LocalProvider for local components - self._local_provider: LocalProvider = LocalProvider( - on_duplicate=self._on_duplicate - ) - - # Add providers using AggregateProvider's add_provider - # LocalProvider is always first (no namespace) - self.add_provider(self._local_provider) - for p in providers or []: - self.add_provider(p) - - for t in transforms or []: - self.add_transform(t) - - # Store mask_error_details for execution error handling - self._mask_error_details: bool = ( - mask_error_details - if mask_error_details is not None - else fastmcp.settings.mask_error_details - ) - - # Store list_page_size for pagination of list operations - if list_page_size is not None and list_page_size <= 0: - raise ValueError("list_page_size must be a positive integer") - self._list_page_size: int | None = list_page_size - - # Handle Lifespan instances (they're callable) or regular lifespan functions - if lifespan is not None: - self._lifespan: LifespanCallable[LifespanResultT] = cast( - LifespanCallable[LifespanResultT], lifespan - ) - else: - self._lifespan = cast(LifespanCallable[LifespanResultT], default_lifespan) - self._lifespan_result: LifespanResultT | None = None - self._lifespan_result_set: bool = False - self._lifespan_ref_count: int = 0 - self._lifespan_lock: asyncio.Lock = asyncio.Lock() - self._started: asyncio.Event = asyncio.Event() - - # Generate random ID if no name provided - self._mcp_server: LowLevelServer[LifespanResultT, Any] = LowLevelServer[ - LifespanResultT - ]( - fastmcp=self, - name=name or self.generate_name(), - version=_coerce_version(version) or fastmcp.__version__, - instructions=instructions, - website_url=website_url, - icons=icons, - lifespan=_lifespan_proxy(fastmcp_server=self), - ) - - self.auth: AuthProvider | None = auth - - if tools: - for tool in tools: - if not isinstance(tool, Tool): - tool = Tool.from_function(tool) - self.add_tool(tool) - - self.strict_input_validation: bool = ( - strict_input_validation - if strict_input_validation is not None - else fastmcp.settings.strict_input_validation - ) - - self.client_log_level: mcp.types.LoggingLevel | None = ( - client_log_level - if client_log_level is not None - else fastmcp.settings.client_log_level - ) - - self.middleware: list[Middleware] = list(middleware or []) - - if dereference_schemas: - from fastmcp.server.middleware.dereference import ( - DereferenceRefsMiddleware, - ) - - self.middleware.append(DereferenceRefsMiddleware()) - - # Set up MCP protocol handlers - self._setup_handlers() - - self.sampling_handler: SamplingHandler | None = sampling_handler - self.sampling_handler_behavior: Literal["always", "fallback"] = ( - sampling_handler_behavior or "fallback" - ) - - def __repr__(self) -> str: - return f"{type(self).__name__}({self.name!r})" - - @property - def name(self) -> str: - return self._mcp_server.name - - @property - def instructions(self) -> str | None: - return self._mcp_server.instructions - - @instructions.setter - def instructions(self, value: str | None) -> None: - self._mcp_server.instructions = value - - @property - def version(self) -> str | None: - return self._mcp_server.version - - @property - def website_url(self) -> str | None: - return self._mcp_server.website_url - - @property - def icons(self) -> list[mcp.types.Icon]: - if self._mcp_server.icons is None: - return [] - else: - return list(self._mcp_server.icons) - - @property - def local_provider(self) -> LocalProvider: - """The server's local provider, which stores directly-registered components. - - Use this to remove components: - - mcp.local_provider.remove_tool("my_tool") - mcp.local_provider.remove_resource("data://info") - mcp.local_provider.remove_prompt("my_prompt") - """ - return self._local_provider - - async def _run_middleware( - self, - context: MiddlewareContext[Any], - call_next: Callable[[MiddlewareContext[Any]], Awaitable[Any]], - ) -> Any: - """Builds and executes the middleware chain.""" - chain = call_next - for mw in reversed(self.middleware): - chain = partial(mw, call_next=chain) - return await chain(context) - - def add_middleware(self, middleware: Middleware) -> None: - self.middleware.append(middleware) - - def add_provider(self, provider: Provider, *, namespace: str = "") -> None: - """Add a provider for dynamic tools, resources, and prompts. - - Providers are queried in registration order. The first provider to return - a non-None result wins. Static components (registered via decorators) - always take precedence over providers. - - Args: - provider: A Provider instance that will provide components dynamically. - namespace: Optional namespace prefix. When set: - - Tools become "namespace_toolname" - - Resources become "protocol://namespace/path" - - Prompts become "namespace_promptname" - """ - super().add_provider(provider, namespace=namespace) - - # ------------------------------------------------------------------------- - # Provider interface overrides - inherited from AggregateProvider - # ------------------------------------------------------------------------- - # _list_tools, _list_resources, _list_resource_templates, _list_prompts - # are inherited from AggregateProvider which handles aggregation and namespacing - - async def get_tasks(self) -> Sequence[FastMCPComponent]: - """Get task-eligible components with all transforms applied. - - Overrides AggregateProvider.get_tasks() to apply server-level transforms - after aggregation. AggregateProvider handles provider-level namespacing. - """ - # Get tasks from AggregateProvider (handles aggregation and namespacing) - components = list(await super().get_tasks()) - - # Separate by component type for server-level transform application - tools = [c for c in components if isinstance(c, Tool)] - resources = [c for c in components if isinstance(c, Resource)] - templates = [c for c in components if isinstance(c, ResourceTemplate)] - prompts = [c for c in components if isinstance(c, Prompt)] - - # Apply server-level transforms sequentially - for transform in self.transforms: - tools = await transform.list_tools(tools) - resources = await transform.list_resources(resources) - templates = await transform.list_resource_templates(templates) - prompts = await transform.list_prompts(prompts) - - return [ - *tools, - *resources, - *templates, - *prompts, - ] - - def add_transform(self, transform: Transform) -> None: - """Add a server-level transform. - - Server-level transforms are applied after all providers are aggregated. - They transform tools, resources, and prompts from ALL providers. - - Args: - transform: The transform to add. - - Example: - ```python - from fastmcp.server.transforms import Namespace - - server = FastMCP("Server") - server.add_transform(Namespace("api")) - # All tools from all providers become "api_toolname" - ``` - """ - self._transforms.append(transform) - - def add_tool_transformation( - self, tool_name: str, transformation: ToolTransformConfig - ) -> None: - """Add a tool transformation. - - .. deprecated:: - Use ``add_transform(ToolTransform({...}))`` instead. - """ - if fastmcp.settings.deprecation_warnings: - warnings.warn( - "add_tool_transformation is deprecated. Use " - "server.add_transform(ToolTransform({tool_name: config})) instead.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - self.add_transform(ToolTransform({tool_name: transformation})) - - def remove_tool_transformation(self, _tool_name: str) -> None: - """Remove a tool transformation. - - .. deprecated:: - Tool transformations are now immutable. Use enable/disable controls instead. - """ - if fastmcp.settings.deprecation_warnings: - warnings.warn( - "remove_tool_transformation is deprecated and has no effect. " - "Transforms are immutable once added. Use server.disable(keys=[...]) " - "to hide tools instead.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - - async def list_tools(self, *, run_middleware: bool = True) -> Sequence[Tool]: - """List all enabled tools from providers. - - Overrides Provider.list_tools() to add visibility filtering, auth filtering, - and middleware execution. Returns all versions (no deduplication). - Protocol handlers deduplicate for MCP wire format. - """ - async with fastmcp.server.context.Context(fastmcp=self) as ctx: - if run_middleware: - mw_context = MiddlewareContext( - message=mcp.types.ListToolsRequest(method="tools/list"), - source="client", - type="request", - method="tools/list", - fastmcp_context=ctx, - ) - return await self._run_middleware( - context=mw_context, - call_next=lambda context: self.list_tools(run_middleware=False), - ) - - # Get all tools, apply session transforms, then filter enabled - # and model-visible (app-only tools are hidden from the model). - tools = list(await super().list_tools()) - tools = await apply_session_transforms(tools) - tools = [t for t in tools if is_enabled(t) and _is_model_visible(t)] - - skip_auth, token = _get_auth_context() - authorized: list[Tool] = [] - for tool in tools: - if not skip_auth and tool.auth is not None: - ctx = AuthContext(token=token, component=tool) - try: - if not await run_auth_checks(tool.auth, ctx): - continue - except AuthorizationError: - continue - authorized.append(tool) - return authorized - - async def _get_tool( - self, name: str, version: VersionSpec | None = None - ) -> Tool | None: - """Get a tool by name via aggregation from providers. - - Extends AggregateProvider._get_tool() with component-level auth checks. - - Args: - name: The tool name. - version: Version filter (None returns highest version). - - Returns: - The tool if found and authorized, None if not found or unauthorized. - """ - # Get tool from AggregateProvider (handles aggregation and namespacing) - tool = await super()._get_tool(name, version) - if tool is None: - return None - - # Component auth - return None if unauthorized (consistent with list filtering) - skip_auth, token = _get_auth_context() - if not skip_auth and tool.auth is not None: - ctx = AuthContext(token=token, component=tool) - try: - if not await run_auth_checks(tool.auth, ctx): - return None - except AuthorizationError: - return None - - return tool - - async def get_tool( - self, name: str, version: VersionSpec | None = None - ) -> Tool | None: - """Get a tool by name, filtering disabled tools. - - Overrides Provider.get_tool() to add visibility filtering after all - transforms (including session-level) have been applied. This ensures - session transforms can override provider-level disables. - - When the highest version is disabled and no explicit version was - requested, falls back to the next-highest enabled version. - - Args: - name: The tool name. - version: Version filter (None returns highest version). - - Returns: - The tool if found and enabled, None otherwise. - """ - tool = await super().get_tool(name, version) - if tool is None: - return None - - # Apply session transforms to single item - tools = await apply_session_transforms([tool]) - if tools and is_enabled(tools[0]) and _is_model_visible(tools[0]): - return tools[0] - - # The highest version is disabled (or app-only). If an explicit version - # was requested, respect that. Otherwise fall back to the next-highest - # enabled, model-visible version. - if version is not None: - return None - - all_tools = [t for t in await super().list_tools() if t.name == name] - all_tools = list(await apply_session_transforms(all_tools)) - enabled = [t for t in all_tools if is_enabled(t) and _is_model_visible(t)] - - skip_auth, token = _get_auth_context() - authorized: list[Tool] = [] - for t in enabled: - if not skip_auth and t.auth is not None: - ctx = AuthContext(token=token, component=t) - try: - if not await run_auth_checks(t.auth, ctx): - continue - except AuthorizationError: - continue - authorized.append(t) - - if not authorized: - return None - return cast(Tool, max(authorized, key=version_sort_key)) - - async def list_resources( - self, *, run_middleware: bool = True - ) -> Sequence[Resource]: - """List all enabled resources from providers. - - Overrides Provider.list_resources() to add visibility filtering, auth filtering, - and middleware execution. Returns all versions (no deduplication). - Protocol handlers deduplicate for MCP wire format. - """ - async with fastmcp.server.context.Context(fastmcp=self) as ctx: - if run_middleware: - mw_context = MiddlewareContext( - message={}, - source="client", - type="request", - method="resources/list", - fastmcp_context=ctx, - ) - return await self._run_middleware( - context=mw_context, - call_next=lambda context: self.list_resources(run_middleware=False), - ) - - # Get all resources, apply session transforms, then filter enabled - resources = list(await super().list_resources()) - resources = await apply_session_transforms(resources) - resources = [r for r in resources if is_enabled(r)] - - skip_auth, token = _get_auth_context() - authorized: list[Resource] = [] - for resource in resources: - if not skip_auth and resource.auth is not None: - ctx = AuthContext(token=token, component=resource) - try: - if not await run_auth_checks(resource.auth, ctx): - continue - except AuthorizationError: - continue - authorized.append(resource) - return authorized - - async def _get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Get a resource by URI via aggregation from providers. - - Extends AggregateProvider._get_resource() with component-level auth checks. - - Args: - uri: The resource URI. - version: Version filter (None returns highest version). - - Returns: - The resource if found and authorized, None if not found or unauthorized. - """ - # Get resource from AggregateProvider (handles aggregation and namespacing) - resource = await super()._get_resource(uri, version) - if resource is None: - return None - - # Component auth - return None if unauthorized (consistent with list filtering) - skip_auth, token = _get_auth_context() - if not skip_auth and resource.auth is not None: - ctx = AuthContext(token=token, component=resource) - try: - if not await run_auth_checks(resource.auth, ctx): - return None - except AuthorizationError: - return None - - return resource - - async def get_resource( - self, uri: str, version: VersionSpec | None = None - ) -> Resource | None: - """Get a resource by URI, filtering disabled resources. - - Overrides Provider.get_resource() to add visibility filtering after all - transforms (including session-level) have been applied. - - When the highest version is disabled and no explicit version was - requested, falls back to the next-highest enabled version. - - Args: - uri: The resource URI. - version: Version filter (None returns highest version). - - Returns: - The resource if found and enabled, None otherwise. - """ - resource = await super().get_resource(uri, version) - if resource is None: - return None - - # Apply session transforms to single item - resources = await apply_session_transforms([resource]) - if resources and is_enabled(resources[0]): - return resources[0] - - if version is not None: - return None - - all_resources = [r for r in await super().list_resources() if str(r.uri) == uri] - all_resources = list(await apply_session_transforms(all_resources)) - enabled = [r for r in all_resources if is_enabled(r)] - - skip_auth, token = _get_auth_context() - authorized: list[Resource] = [] - for r in enabled: - if not skip_auth and r.auth is not None: - ctx = AuthContext(token=token, component=r) - try: - if not await run_auth_checks(r.auth, ctx): - continue - except AuthorizationError: - continue - authorized.append(r) - - if not authorized: - return None - return cast(Resource, max(authorized, key=version_sort_key)) - - async def list_resource_templates( - self, *, run_middleware: bool = True - ) -> Sequence[ResourceTemplate]: - """List all enabled resource templates from providers. - - Overrides Provider.list_resource_templates() to add visibility filtering, - auth filtering, and middleware execution. Returns all versions (no deduplication). - Protocol handlers deduplicate for MCP wire format. - """ - async with fastmcp.server.context.Context(fastmcp=self) as ctx: - if run_middleware: - mw_context = MiddlewareContext( - message={}, - source="client", - type="request", - method="resources/templates/list", - fastmcp_context=ctx, - ) - return await self._run_middleware( - context=mw_context, - call_next=lambda context: self.list_resource_templates( - run_middleware=False - ), - ) - - # Get all templates, apply session transforms, then filter enabled - templates = list(await super().list_resource_templates()) - templates = await apply_session_transforms(templates) - templates = [t for t in templates if is_enabled(t)] - - skip_auth, token = _get_auth_context() - authorized: list[ResourceTemplate] = [] - for template in templates: - if not skip_auth and template.auth is not None: - ctx = AuthContext(token=token, component=template) - try: - if not await run_auth_checks(template.auth, ctx): - continue - except AuthorizationError: - continue - authorized.append(template) - return authorized - - async def _get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Get a resource template by URI via aggregation from providers. - - Extends AggregateProvider._get_resource_template() with component-level auth checks. - - Args: - uri: The template URI to match. - version: Version filter (None returns highest version). - - Returns: - The template if found and authorized, None if not found or unauthorized. - """ - # Get template from AggregateProvider (handles aggregation and namespacing) - template = await super()._get_resource_template(uri, version) - if template is None: - return None - - # Component auth - return None if unauthorized (consistent with list filtering) - skip_auth, token = _get_auth_context() - if not skip_auth and template.auth is not None: - ctx = AuthContext(token=token, component=template) - try: - if not await run_auth_checks(template.auth, ctx): - return None - except AuthorizationError: - return None - - return template - - async def get_resource_template( - self, uri: str, version: VersionSpec | None = None - ) -> ResourceTemplate | None: - """Get a resource template by URI, filtering disabled templates. - - Overrides Provider.get_resource_template() to add visibility filtering after - all transforms (including session-level) have been applied. - - When the highest version is disabled and no explicit version was - requested, falls back to the next-highest enabled version. - - Args: - uri: The template URI. - version: Version filter (None returns highest version). - - Returns: - The template if found and enabled, None otherwise. - """ - template = await super().get_resource_template(uri, version) - if template is None: - return None - - # Apply session transforms to single item - templates = await apply_session_transforms([template]) - if templates and is_enabled(templates[0]): - return templates[0] - - if version is not None: - return None - - all_templates = [ - t - for t in await super().list_resource_templates() - if t.matches(uri) is not None - ] - all_templates = list(await apply_session_transforms(all_templates)) - enabled = [t for t in all_templates if is_enabled(t)] - - skip_auth, token = _get_auth_context() - authorized: list[ResourceTemplate] = [] - for t in enabled: - if not skip_auth and t.auth is not None: - ctx = AuthContext(token=token, component=t) - try: - if not await run_auth_checks(t.auth, ctx): - continue - except AuthorizationError: - continue - authorized.append(t) - - if not authorized: - return None - return cast(ResourceTemplate, max(authorized, key=version_sort_key)) - - async def list_prompts(self, *, run_middleware: bool = True) -> Sequence[Prompt]: - """List all enabled prompts from providers. - - Overrides Provider.list_prompts() to add visibility filtering, auth filtering, - and middleware execution. Returns all versions (no deduplication). - Protocol handlers deduplicate for MCP wire format. - """ - async with fastmcp.server.context.Context(fastmcp=self) as ctx: - if run_middleware: - mw_context = MiddlewareContext( - message={}, - source="client", - type="request", - method="prompts/list", - fastmcp_context=ctx, - ) - return await self._run_middleware( - context=mw_context, - call_next=lambda context: self.list_prompts(run_middleware=False), - ) - - # Get all prompts, apply session transforms, then filter enabled - prompts = list(await super().list_prompts()) - prompts = await apply_session_transforms(prompts) - prompts = [p for p in prompts if is_enabled(p)] - - skip_auth, token = _get_auth_context() - authorized: list[Prompt] = [] - for prompt in prompts: - if not skip_auth and prompt.auth is not None: - ctx = AuthContext(token=token, component=prompt) - try: - if not await run_auth_checks(prompt.auth, ctx): - continue - except AuthorizationError: - continue - authorized.append(prompt) - return authorized - - async def _get_prompt( - self, name: str, version: VersionSpec | None = None - ) -> Prompt | None: - """Get a prompt by name via aggregation from providers. - - Extends AggregateProvider._get_prompt() with component-level auth checks. - - Args: - name: The prompt name. - version: Version filter (None returns highest version). - - Returns: - The prompt if found and authorized, None if not found or unauthorized. - """ - # Get prompt from AggregateProvider (handles aggregation and namespacing) - prompt = await super()._get_prompt(name, version) - if prompt is None: - return None - - # Component auth - return None if unauthorized (consistent with list filtering) - skip_auth, token = _get_auth_context() - if not skip_auth and prompt.auth is not None: - ctx = AuthContext(token=token, component=prompt) - try: - if not await run_auth_checks(prompt.auth, ctx): - return None - except AuthorizationError: - return None - - return prompt - - async def get_prompt( - self, name: str, version: VersionSpec | None = None - ) -> Prompt | None: - """Get a prompt by name, filtering disabled prompts. - - Overrides Provider.get_prompt() to add visibility filtering after all - transforms (including session-level) have been applied. - - When the highest version is disabled and no explicit version was - requested, falls back to the next-highest enabled version. - - Args: - name: The prompt name. - version: Version filter (None returns highest version). - - Returns: - The prompt if found and enabled, None otherwise. - """ - prompt = await super().get_prompt(name, version) - if prompt is None: - return None - - # Apply session transforms to single item - prompts = await apply_session_transforms([prompt]) - if prompts and is_enabled(prompts[0]): - return prompts[0] - - if version is not None: - return None - - all_prompts = [p for p in await super().list_prompts() if p.name == name] - all_prompts = list(await apply_session_transforms(all_prompts)) - enabled = [p for p in all_prompts if is_enabled(p)] - - skip_auth, token = _get_auth_context() - authorized: list[Prompt] = [] - for p in enabled: - if not skip_auth and p.auth is not None: - ctx = AuthContext(token=token, component=p) - try: - if not await run_auth_checks(p.auth, ctx): - continue - except AuthorizationError: - continue - authorized.append(p) - - if not authorized: - return None - return cast(Prompt, max(authorized, key=version_sort_key)) - - @overload - async def call_tool( - self, - name: str, - arguments: dict[str, Any] | None = None, - *, - version: VersionSpec | None = None, - run_middleware: bool = True, - task_meta: None = None, - ) -> ToolResult: ... - - @overload - async def call_tool( - self, - name: str, - arguments: dict[str, Any] | None = None, - *, - version: VersionSpec | None = None, - run_middleware: bool = True, - task_meta: TaskMeta, - ) -> mcp.types.CreateTaskResult: ... - - async def call_tool( - self, - name: str, - arguments: dict[str, Any] | None = None, - *, - version: VersionSpec | None = None, - run_middleware: bool = True, - task_meta: TaskMeta | None = None, - ) -> ToolResult | mcp.types.CreateTaskResult: - """Call a tool by name. - - This is the public API for executing tools. By default, middleware is applied. - - Args: - name: The tool name - arguments: Tool arguments (optional) - version: Specific version to call. If None, calls highest version. - run_middleware: If True (default), apply the middleware chain. - Set to False when called from middleware to avoid re-applying. - task_meta: If provided, execute as a background task and return - CreateTaskResult. If None (default), execute synchronously and - return ToolResult. - - Returns: - ToolResult when task_meta is None. - CreateTaskResult when task_meta is provided. - - Raises: - NotFoundError: If tool not found or disabled - ToolError: If tool execution fails - ValidationError: If arguments fail validation - """ - # Note: fn_key enrichment happens here after finding the tool. - # For mounted servers, the parent's provider sets fn_key to the - # namespaced key before delegating, ensuring correct Docket routing. - - async with fastmcp.server.context.Context(fastmcp=self) as ctx: - if run_middleware: - mw_context = MiddlewareContext[CallToolRequestParams]( - message=mcp.types.CallToolRequestParams( - name=name, arguments=arguments or {} - ), - source="client", - type="request", - method="tools/call", - fastmcp_context=ctx, - ) - return await self._run_middleware( - context=mw_context, - call_next=lambda context: self.call_tool( - context.message.name, - context.message.arguments or {}, - version=version, - run_middleware=False, - task_meta=task_meta, - ), - ) - - # Core logic: find and execute tool (providers queried in parallel) - # Use get_tool to apply transforms and filter disabled - with server_span( - f"tools/call {name}", "tools/call", self.name, "tool", name - ) as span: - # Try normal resolution first. If that fails and the name - # contains "___" (app tool prefix), parse out the app name - # and route via get_app_tool which bypasses transforms. - tool: Tool | None = await self.get_tool(name, version=version) - if tool is None and "___" in name: - app_prefix, _, tool_suffix = name.partition("___") - tool = await self.get_app_tool(app_prefix, tool_suffix) - if tool is not None: - # Auth still applies to app tools - skip_auth, token = _get_auth_context() - if not skip_auth and tool.auth is not None: - try: - ctx = AuthContext(token=token, component=tool) - if not await run_auth_checks(tool.auth, ctx): - raise NotFoundError(f"Unknown tool: {name!r}") - except AuthorizationError: - raise NotFoundError(f"Unknown tool: {name!r}") from None - if tool is None: - raise NotFoundError(f"Unknown tool: {name!r}") - span.set_attributes(tool.get_span_attributes()) - if task_meta is not None and task_meta.fn_key is None: - task_meta = replace(task_meta, fn_key=tool.key) - try: - return await tool._run(arguments or {}, task_meta=task_meta) - except FastMCPError: - logger.exception(f"Error calling tool {name!r}") - raise - except (ValidationError, PydanticValidationError): - logger.exception(f"Error validating tool {name!r}") - raise - except Exception as e: - logger.exception(f"Error calling tool {name!r}") - # Handle actionable errors that should reach the LLM - # even when masking is enabled - if isinstance(e, httpx.HTTPStatusError): - if e.response.status_code == 429: - raise ToolError( - "Rate limited by upstream API, please retry later" - ) from e - if isinstance(e, httpx.TimeoutException): - raise ToolError( - "Upstream request timed out, please retry" - ) from e - # Standard masking logic - if self._mask_error_details: - raise ToolError(f"Error calling tool {name!r}") from e - raise ToolError(f"Error calling tool {name!r}: {e}") from e - - @overload - async def read_resource( - self, - uri: str, - *, - version: VersionSpec | None = None, - run_middleware: bool = True, - task_meta: None = None, - ) -> ResourceResult: ... - - @overload - async def read_resource( - self, - uri: str, - *, - version: VersionSpec | None = None, - run_middleware: bool = True, - task_meta: TaskMeta, - ) -> mcp.types.CreateTaskResult: ... - - async def read_resource( - self, - uri: str, - *, - version: VersionSpec | None = None, - run_middleware: bool = True, - task_meta: TaskMeta | None = None, - ) -> ResourceResult | mcp.types.CreateTaskResult: - """Read a resource by URI. - - This is the public API for reading resources. By default, middleware is applied. - Checks concrete resources first, then templates. - - Args: - uri: The resource URI - version: Specific version to read. If None, reads highest version. - run_middleware: If True (default), apply the middleware chain. - Set to False when called from middleware to avoid re-applying. - task_meta: If provided, execute as a background task and return - CreateTaskResult. If None (default), execute synchronously and - return ResourceResult. - - Returns: - ResourceResult when task_meta is None. - CreateTaskResult when task_meta is provided. - - Raises: - NotFoundError: If resource not found or disabled - ResourceError: If resource read fails - """ - # Note: fn_key enrichment happens here after finding the resource/template. - # Resources and templates use different key formats: - # - Resources use resource.key (derived from the concrete URI) - # - Templates use template.key (the template pattern) - # For mounted servers, the parent's provider sets fn_key to the - # namespaced key before delegating, ensuring correct Docket routing. - - async with fastmcp.server.context.Context(fastmcp=self) as ctx: - if run_middleware: - uri_param = AnyUrl(uri) - mw_context = MiddlewareContext( - message=mcp.types.ReadResourceRequestParams(uri=uri_param), - source="client", - type="request", - method="resources/read", - fastmcp_context=ctx, - ) - return await self._run_middleware( - context=mw_context, - call_next=lambda context: self.read_resource( - str(context.message.uri), - version=version, - run_middleware=False, - task_meta=task_meta, - ), - ) - - # Core logic: find and read resource (providers queried in parallel) - with server_span( - f"resources/read {uri}", - "resources/read", - self.name, - "resource", - uri, - resource_uri=uri, - ) as span: - # Try concrete resources first (transforms + auth via _get_resource) - resource = await self.get_resource(uri, version=version) - if resource is not None: - span.set_attributes(resource.get_span_attributes()) - if task_meta is not None and task_meta.fn_key is None: - task_meta = replace(task_meta, fn_key=resource.key) - try: - return await resource._read(task_meta=task_meta) - except (FastMCPError, McpError): - logger.exception(f"Error reading resource {uri!r}") - raise - except Exception as e: - logger.exception(f"Error reading resource {uri!r}") - # Handle actionable errors that should reach the LLM - if isinstance(e, httpx.HTTPStatusError): - if e.response.status_code == 429: - raise ResourceError( - "Rate limited by upstream API, please retry later" - ) from e - if isinstance(e, httpx.TimeoutException): - raise ResourceError( - "Upstream request timed out, please retry" - ) from e - # Standard masking logic - if self._mask_error_details: - raise ResourceError( - f"Error reading resource {uri!r}" - ) from e - raise ResourceError( - f"Error reading resource {uri!r}: {e}" - ) from e - - # Try templates (transforms + auth via get_resource_template) - template = await self.get_resource_template(uri, version=version) - if template is None: - if version is None: - raise NotFoundError(f"Unknown resource: {uri!r}") - raise NotFoundError( - f"Unknown resource: {uri!r} version {version!r}" - ) - span.set_attributes(template.get_span_attributes()) - params = template.matches(uri) - assert params is not None - if task_meta is not None and task_meta.fn_key is None: - task_meta = replace(task_meta, fn_key=template.key) - try: - return await template._read(uri, params, task_meta=task_meta) - except (FastMCPError, McpError): - logger.exception(f"Error reading resource {uri!r}") - raise - except Exception as e: - logger.exception(f"Error reading resource {uri!r}") - # Handle actionable errors that should reach the LLM - if isinstance(e, httpx.HTTPStatusError): - if e.response.status_code == 429: - raise ResourceError( - "Rate limited by upstream API, please retry later" - ) from e - if isinstance(e, httpx.TimeoutException): - raise ResourceError( - "Upstream request timed out, please retry" - ) from e - # Standard masking logic - if self._mask_error_details: - raise ResourceError(f"Error reading resource {uri!r}") from e - raise ResourceError(f"Error reading resource {uri!r}: {e}") from e - - @overload - async def render_prompt( - self, - name: str, - arguments: dict[str, Any] | None = None, - *, - version: VersionSpec | None = None, - run_middleware: bool = True, - task_meta: None = None, - ) -> PromptResult: ... - - @overload - async def render_prompt( - self, - name: str, - arguments: dict[str, Any] | None = None, - *, - version: VersionSpec | None = None, - run_middleware: bool = True, - task_meta: TaskMeta, - ) -> mcp.types.CreateTaskResult: ... - - async def render_prompt( - self, - name: str, - arguments: dict[str, Any] | None = None, - *, - version: VersionSpec | None = None, - run_middleware: bool = True, - task_meta: TaskMeta | None = None, - ) -> PromptResult | mcp.types.CreateTaskResult: - """Render a prompt by name. - - This is the public API for rendering prompts. By default, middleware is applied. - Use get_prompt() to retrieve the prompt definition without rendering. - - Args: - name: The prompt name - arguments: Prompt arguments (optional) - version: Specific version to render. If None, renders highest version. - run_middleware: If True (default), apply the middleware chain. - Set to False when called from middleware to avoid re-applying. - task_meta: If provided, execute as a background task and return - CreateTaskResult. If None (default), execute synchronously and - return PromptResult. - - Returns: - PromptResult when task_meta is None. - CreateTaskResult when task_meta is provided. - - Raises: - NotFoundError: If prompt not found or disabled - PromptError: If prompt rendering fails - """ - async with fastmcp.server.context.Context(fastmcp=self) as ctx: - if run_middleware: - mw_context = MiddlewareContext( - message=mcp.types.GetPromptRequestParams( - name=name, arguments=arguments - ), - source="client", - type="request", - method="prompts/get", - fastmcp_context=ctx, - ) - return await self._run_middleware( - context=mw_context, - call_next=lambda context: self.render_prompt( - context.message.name, - context.message.arguments, - version=version, - run_middleware=False, - task_meta=task_meta, - ), - ) - - # Core logic: find and render prompt (providers queried in parallel) - # Use get_prompt to apply transforms and filter disabled - with server_span( - f"prompts/get {name}", "prompts/get", self.name, "prompt", name - ) as span: - prompt = await self.get_prompt(name, version=version) - if prompt is None: - raise NotFoundError(f"Unknown prompt: {name!r}") - span.set_attributes(prompt.get_span_attributes()) - if task_meta is not None and task_meta.fn_key is None: - task_meta = replace(task_meta, fn_key=prompt.key) - try: - return await prompt._render(arguments, task_meta=task_meta) - except (FastMCPError, McpError): - logger.exception(f"Error rendering prompt {name!r}") - raise - except Exception as e: - logger.exception(f"Error rendering prompt {name!r}") - if self._mask_error_details: - raise PromptError(f"Error rendering prompt {name!r}") from e - raise PromptError(f"Error rendering prompt {name!r}: {e}") from e - - def add_tool(self, tool: Tool | Callable[..., Any]) -> Tool: - """Add a tool to the server. - - The tool function can optionally request a Context object by adding a parameter - with the Context type annotation. See the @tool decorator for examples. - - Args: - tool: The Tool instance or @tool-decorated function to register - - Returns: - The tool instance that was added to the server. - """ - return self._local_provider.add_tool(tool) - - def remove_tool(self, name: str, version: str | None = None) -> None: - """Remove tool(s) from the server. - - .. deprecated:: - Use ``mcp.local_provider.remove_tool(name)`` instead. - - Args: - name: The name of the tool to remove. - version: If None, removes ALL versions. If specified, removes only that version. - - Raises: - NotFoundError: If no matching tool is found. - """ - if fastmcp.settings.deprecation_warnings: - warnings.warn( - "remove_tool() is deprecated. Use " - "mcp.local_provider.remove_tool(name) instead.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - try: - self._local_provider.remove_tool(name, version) - except KeyError: - if version is None: - raise NotFoundError(f"Tool {name!r} not found") from None - raise NotFoundError( - f"Tool {name!r} version {version!r} not found" - ) from None - - @overload - def tool( - self, - name_or_fn: F, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - tags: set[str] | None = None, - output_schema: dict[str, Any] | NotSetT | None = NotSet, - annotations: ToolAnnotations | dict[str, Any] | None = None, - exclude_args: list[str] | None = None, - meta: dict[str, Any] | None = None, - app: AppConfig | dict[str, Any] | bool | None = None, - task: bool | TaskConfig | None = None, - timeout: float | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> F: ... - - @overload - def tool( - self, - name_or_fn: str | None = None, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - tags: set[str] | None = None, - output_schema: dict[str, Any] | NotSetT | None = NotSet, - annotations: ToolAnnotations | dict[str, Any] | None = None, - exclude_args: list[str] | None = None, - meta: dict[str, Any] | None = None, - app: AppConfig | dict[str, Any] | bool | None = None, - task: bool | TaskConfig | None = None, - timeout: float | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> Callable[[F], F]: ... - - def tool( - self, - name_or_fn: str | AnyFunction | None = None, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - tags: set[str] | None = None, - output_schema: dict[str, Any] | NotSetT | None = NotSet, - annotations: ToolAnnotations | dict[str, Any] | None = None, - exclude_args: list[str] | None = None, - meta: dict[str, Any] | None = None, - app: AppConfig | dict[str, Any] | bool | None = None, - task: bool | TaskConfig | None = None, - timeout: float | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> ( - Callable[[AnyFunction], FunctionTool] - | FunctionTool - | partial[Callable[[AnyFunction], FunctionTool] | FunctionTool] - ): - """Decorator to register a tool. - - Tools can optionally request a Context object by adding a parameter with the - Context type annotation. The context provides access to MCP capabilities like - logging, progress reporting, and resource access. - - This decorator supports multiple calling patterns: - - @server.tool (without parentheses) - - @server.tool (with empty parentheses) - - @server.tool("custom_name") (with name as first argument) - - @server.tool(name="custom_name") (with name as keyword argument) - - server.tool(function, name="custom_name") (direct function call) - - Args: - name_or_fn: Either a function (when used as @tool), a string name, or None - name: Optional name for the tool (keyword-only, alternative to name_or_fn) - description: Optional description of what the tool does - tags: Optional set of tags for categorizing the tool - output_schema: Optional JSON schema for the tool's output - annotations: Optional annotations about the tool's behavior - exclude_args: Optional list of argument names to exclude from the tool schema. - Deprecated: Use `Depends()` for dependency injection instead. - meta: Optional meta information about the tool - - Examples: - Register a tool with a custom name: - ```python - @server.tool - def my_tool(x: int) -> str: - return str(x) - - # Register a tool with a custom name - @server.tool - def my_tool(x: int) -> str: - return str(x) - - @server.tool("custom_name") - def my_tool(x: int) -> str: - return str(x) - - @server.tool(name="custom_name") - def my_tool(x: int) -> str: - return str(x) - - # Direct function call - server.tool(my_function, name="custom_name") - ``` - """ - # Merge app config into meta["ui"] (wire format) before passing to provider - if app is not None and app is not False: - meta = dict(meta) if meta else {} - if app is True: - meta["ui"] = True - else: - meta["ui"] = app_config_to_meta_dict(app) - - # Delegate to LocalProvider with server-level defaults - result = self._local_provider.tool( - name_or_fn, - name=name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - output_schema=output_schema, - annotations=annotations, - exclude_args=exclude_args, - meta=meta, - task=task if task is not None else self._support_tasks_by_default, - timeout=timeout, - auth=auth, - ) - - return result - - def add_resource( - self, resource: Resource | Callable[..., Any] - ) -> Resource | ResourceTemplate: - """Add a resource to the server. - - Args: - resource: A Resource instance or @resource-decorated function to add - - Returns: - The resource instance that was added to the server. - """ - return self._local_provider.add_resource(resource) - - def add_template(self, template: ResourceTemplate) -> ResourceTemplate: - """Add a resource template to the server. - - Args: - template: A ResourceTemplate instance to add - - Returns: - The template instance that was added to the server. - """ - return self._local_provider.add_template(template) - - def resource( - self, - uri: str, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - mime_type: str | None = None, - tags: set[str] | None = None, - annotations: Annotations | dict[str, Any] | None = None, - meta: dict[str, Any] | None = None, - app: AppConfig | dict[str, Any] | bool | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> Callable[[F], F]: - """Decorator to register a function as a resource. - - The function will be called when the resource is read to generate its content. - The function can return: - - str for text content - - bytes for binary content - - other types will be converted to JSON - - Resources can optionally request a Context object by adding a parameter with the - Context type annotation. The context provides access to MCP capabilities like - logging, progress reporting, and session information. - - If the URI contains parameters (e.g. "resource://{param}") or the function - has parameters, it will be registered as a template resource. - - Args: - uri: URI for the resource (e.g. "resource://my-resource" or "resource://{param}") - name: Optional name for the resource - description: Optional description of the resource - mime_type: Optional MIME type for the resource - tags: Optional set of tags for categorizing the resource - annotations: Optional annotations about the resource's behavior - meta: Optional meta information about the resource - - Examples: - Register a resource with a custom name: - ```python - @server.resource("resource://my-resource") - def get_data() -> str: - return "Hello, world!" - - @server.resource("resource://my-resource") - async get_data() -> str: - data = await fetch_data() - return f"Hello, world! {data}" - - @server.resource("resource://{city}/weather") - def get_weather(city: str) -> str: - return f"Weather for {city}" - - @server.resource("resource://{city}/weather") - async def get_weather_with_context(city: str, ctx: Context) -> str: - await ctx.info(f"Fetching weather for {city}") - return f"Weather for {city}" - - @server.resource("resource://{city}/weather") - async def get_weather(city: str) -> str: - data = await fetch_weather(city) - return f"Weather for {city}: {data}" - ``` - """ - # Catch incorrect decorator usage early (before any processing) - if not isinstance(uri, str): - raise TypeError( - "The @resource decorator was used incorrectly. " - "It requires a URI as the first argument. " - "Use @resource('uri') instead of @resource" - ) - - # Apply default MIME type for ui:// scheme resources - mime_type = resolve_ui_mime_type(uri, mime_type) - - # Validate app config for resources — resource_uri and visibility - # don't apply since the resource itself is the UI - if isinstance(app, AppConfig): - if app.resource_uri is not None: - raise ValueError( - "resource_uri cannot be set on resources — " - "the resource itself is the UI. " - "Use resource_uri on tools to point to a UI resource." - ) - if app.visibility is not None: - raise ValueError( - "visibility cannot be set on resources — it only applies to tools." - ) - - # Merge app config into meta["ui"] (wire format) before passing to provider - if app is not None and app is not False: - meta = dict(meta) if meta else {} - if app is True: - meta["ui"] = True - else: - meta["ui"] = app_config_to_meta_dict(app) - - # Delegate to LocalProvider with server-level defaults - inner_decorator = self._local_provider.resource( - uri, - name=name, - version=version, - title=title, - description=description, - icons=icons, - mime_type=mime_type, - tags=tags, - annotations=annotations, - meta=meta, - task=task if task is not None else self._support_tasks_by_default, - auth=auth, - ) - - return inner_decorator - - def add_prompt(self, prompt: Prompt | Callable[..., Any]) -> Prompt: - """Add a prompt to the server. - - Args: - prompt: A Prompt instance or @prompt-decorated function to add - - Returns: - The prompt instance that was added to the server. - """ - return self._local_provider.add_prompt(prompt) - - @overload - def prompt( - self, - name_or_fn: F, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - tags: set[str] | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> F: ... - - @overload - def prompt( - self, - name_or_fn: str | None = None, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - tags: set[str] | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> Callable[[F], F]: ... - - def prompt( - self, - name_or_fn: str | AnyFunction | None = None, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[mcp.types.Icon] | None = None, - tags: set[str] | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> ( - Callable[[AnyFunction], FunctionPrompt] - | FunctionPrompt - | partial[Callable[[AnyFunction], FunctionPrompt] | FunctionPrompt] - ): - """Decorator to register a prompt. - - Prompts can optionally request a Context object by adding a parameter with the - Context type annotation. The context provides access to MCP capabilities like - logging, progress reporting, and session information. - - This decorator supports multiple calling patterns: - - @server.prompt (without parentheses) - - @server.prompt() (with empty parentheses) - - @server.prompt("custom_name") (with name as first argument) - - @server.prompt(name="custom_name") (with name as keyword argument) - - server.prompt(function, name="custom_name") (direct function call) - - Args: - name_or_fn: Either a function (when used as @prompt), a string name, or None - name: Optional name for the prompt (keyword-only, alternative to name_or_fn) - description: Optional description of what the prompt does - tags: Optional set of tags for categorizing the prompt - meta: Optional meta information about the prompt - - Examples: - - ```python - @server.prompt - def analyze_table(table_name: str) -> list[Message]: - schema = read_table_schema(table_name) - return [ - { - "role": "user", - "content": f"Analyze this schema:\n{schema}" - } - ] - - @server.prompt() - async def analyze_with_context(table_name: str, ctx: Context) -> list[Message]: - await ctx.info(f"Analyzing table {table_name}") - schema = read_table_schema(table_name) - return [ - { - "role": "user", - "content": f"Analyze this schema:\n{schema}" - } - ] - - @server.prompt("custom_name") - async def analyze_file(path: str) -> list[Message]: - content = await read_file(path) - return [ - { - "role": "user", - "content": { - "type": "resource", - "resource": { - "uri": f"file://{path}", - "text": content - } - } - } - ] - - @server.prompt(name="custom_name") - def another_prompt(data: str) -> list[Message]: - return [{"role": "user", "content": data}] - - # Direct function call - server.prompt(my_function, name="custom_name") - ``` - """ - # Delegate to LocalProvider with server-level defaults - return self._local_provider.prompt( - name_or_fn, - name=name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - meta=meta, - task=task if task is not None else self._support_tasks_by_default, - auth=auth, - ) - - def mount( - self, - server: FastMCP[LifespanResultT], - namespace: str | None = None, - as_proxy: bool | None = None, - tool_names: dict[str, str] | None = None, - prefix: str | None = None, # deprecated, use namespace - ) -> None: - """Mount another FastMCP server on this server with an optional namespace. - - Unlike importing (with import_server), mounting establishes a dynamic connection - between servers. When a client interacts with a mounted server's objects through - the parent server, requests are forwarded to the mounted server in real-time. - This means changes to the mounted server are immediately reflected when accessed - through the parent. - - When a server is mounted with a namespace: - - Tools from the mounted server are accessible with namespaced names. - Example: If server has a tool named "get_weather", it will be available as "namespace_get_weather". - - Resources are accessible with namespaced URIs. - Example: If server has a resource with URI "weather://forecast", it will be available as - "weather://namespace/forecast". - - Templates are accessible with namespaced URI templates. - Example: If server has a template with URI "weather://location/{id}", it will be available - as "weather://namespace/location/{id}". - - Prompts are accessible with namespaced names. - Example: If server has a prompt named "weather_prompt", it will be available as - "namespace_weather_prompt". - - When a server is mounted without a namespace (namespace=None), its tools, resources, templates, - and prompts are accessible with their original names. Multiple servers can be mounted - without namespaces, and they will be tried in order until a match is found. - - The mounted server's lifespan is executed when the parent server starts, and its - middleware chain is invoked for all operations (tool calls, resource reads, prompts). - - Args: - server: The FastMCP server to mount. - namespace: Optional namespace to use for the mounted server's objects. If None, - the server's objects are accessible with their original names. - as_proxy: Deprecated. Mounted servers now always have their lifespan and - middleware invoked. To create a proxy server, use create_proxy() - explicitly before mounting. - tool_names: Optional mapping of original tool names to custom names. Use this - to override namespaced names. Keys are the original tool names from the - mounted server. - prefix: Deprecated. Use namespace instead. - """ - import warnings - - from fastmcp.server.providers.fastmcp_provider import FastMCPProvider - - # Handle deprecated prefix parameter - if prefix is not None: - warnings.warn( - "The 'prefix' parameter is deprecated, use 'namespace' instead", - FastMCPDeprecationWarning, - stacklevel=2, - ) - if namespace is None: - namespace = prefix - else: - raise ValueError("Cannot specify both 'prefix' and 'namespace'") - - if as_proxy is not None: - warnings.warn( - "as_proxy is deprecated and will be removed in a future version. " - "Mounted servers now always have their lifespan and middleware invoked. " - "To create a proxy server, use create_proxy() explicitly.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - # Still honor the flag for backward compatibility - if as_proxy: - from fastmcp.server.providers.proxy import FastMCPProxy - - if not isinstance(server, FastMCPProxy): - server = FastMCP.as_proxy(server) - - # Create provider and add it with namespace - provider: Provider = FastMCPProvider(server) - - # Apply tool renames first (scoped to this provider), then namespace - # So foo → bar with namespace="baz" becomes baz_bar - if tool_names: - transforms = { - old_name: ToolTransformConfig(name=new_name) - for old_name, new_name in tool_names.items() - } - provider = provider.wrap_transform(ToolTransform(transforms)) - - # Use add_provider with namespace (applies namespace in AggregateProvider) - self.add_provider(provider, namespace=namespace or "") - - async def import_server( - self, - server: FastMCP[LifespanResultT], - prefix: str | None = None, - ) -> None: - """ - Import the MCP objects from another FastMCP server into this one, - optionally with a given prefix. - - .. deprecated:: - Use :meth:`mount` instead. ``import_server`` will be removed in a - future version. - - Note that when a server is *imported*, its objects are immediately - registered to the importing server. This is a one-time operation and - future changes to the imported server will not be reflected in the - importing server. Server-level configurations and lifespans are not imported. - - When a server is imported with a prefix: - - The tools are imported with prefixed names - Example: If server has a tool named "get_weather", it will be - available as "prefix_get_weather" - - The resources are imported with prefixed URIs using the new format - Example: If server has a resource with URI "weather://forecast", it will - be available as "weather://prefix/forecast" - - The templates are imported with prefixed URI templates using the new format - Example: If server has a template with URI "weather://location/{id}", it will - be available as "weather://prefix/location/{id}" - - The prompts are imported with prefixed names - Example: If server has a prompt named "weather_prompt", it will be available as - "prefix_weather_prompt" - - When a server is imported without a prefix (prefix=None), its tools, resources, - templates, and prompts are imported with their original names. - - Args: - server: The FastMCP server to import - prefix: Optional prefix to use for the imported server's objects. If None, - objects are imported with their original names. - """ - import warnings - - warnings.warn( - "import_server is deprecated, use mount() instead", - FastMCPDeprecationWarning, - stacklevel=2, - ) - - def add_resource_prefix(uri: str, prefix: str) -> str: - """Add prefix to resource URI: protocol://path → protocol://prefix/path.""" - match = URI_PATTERN.match(uri) - if match: - protocol, path = match.groups() - return f"{protocol}{prefix}/{path}" - return uri - - # Import tools from the server - for tool in await server.list_tools(): - if prefix: - tool = tool.model_copy(update={"name": f"{prefix}_{tool.name}"}) - self.add_tool(tool) - - # Import resources and templates from the server - for resource in await server.list_resources(): - if prefix: - new_uri = add_resource_prefix(str(resource.uri), prefix) - resource = resource.model_copy(update={"uri": new_uri}) - self.add_resource(resource) - - for template in await server.list_resource_templates(): - if prefix: - new_uri_template = add_resource_prefix(template.uri_template, prefix) - template = template.model_copy( - update={"uri_template": new_uri_template} - ) - self.add_template(template) - - # Import prompts from the server - for prompt in await server.list_prompts(): - if prefix: - prompt = prompt.model_copy(update={"name": f"{prefix}_{prompt.name}"}) - self.add_prompt(prompt) - - if server._lifespan != default_lifespan: - from warnings import warn - - warn( - message="When importing from a server with a lifespan, the lifespan from the imported server will not be used.", - category=RuntimeWarning, - stacklevel=2, - ) - - if prefix: - logger.debug( - f"[{self.name}] Imported server {server.name} with prefix '{prefix}'" - ) - else: - logger.debug(f"[{self.name}] Imported server {server.name}") - - @classmethod - def from_openapi( - cls, - openapi_spec: dict[str, Any], - client: httpx.AsyncClient | None = None, - name: str = "OpenAPI Server", - route_maps: list[RouteMap] | None = None, - route_map_fn: OpenAPIRouteMapFn | None = None, - mcp_component_fn: OpenAPIComponentFn | None = None, - mcp_names: dict[str, str] | None = None, - tags: set[str] | None = None, - validate_output: bool = True, - **settings: Any, - ) -> Self: - """ - Create a FastMCP server from an OpenAPI specification. - - Args: - openapi_spec: OpenAPI schema as a dictionary - client: Optional httpx AsyncClient for making HTTP requests. - If not provided, a default client is created using the first - server URL from the OpenAPI spec with a 30-second timeout. - name: Name for the MCP server - route_maps: Optional list of RouteMap objects defining route mappings - route_map_fn: Optional callable for advanced route type mapping - mcp_component_fn: Optional callable for component customization - mcp_names: Optional dictionary mapping operationId to component names - tags: Optional set of tags to add to all components - validate_output: If True (default), tools use the output schema - extracted from the OpenAPI spec for response validation. If - False, a permissive schema is used instead, allowing any - response structure while still returning structured JSON. - **settings: Additional settings passed to FastMCP - - Returns: - A FastMCP server with an OpenAPIProvider attached. - """ - from .providers.openapi import OpenAPIProvider - - provider: Provider = OpenAPIProvider( - openapi_spec=openapi_spec, - client=client, - route_maps=route_maps, - route_map_fn=route_map_fn, - mcp_component_fn=mcp_component_fn, - mcp_names=mcp_names, - tags=tags, - validate_output=validate_output, - ) - return cls(name=name, providers=[provider], **settings) - - @classmethod - def from_fastapi( - cls, - app: Any, - name: str | None = None, - route_maps: list[RouteMap] | None = None, - route_map_fn: OpenAPIRouteMapFn | None = None, - mcp_component_fn: OpenAPIComponentFn | None = None, - mcp_names: dict[str, str] | None = None, - httpx_client_kwargs: dict[str, Any] | None = None, - tags: set[str] | None = None, - **settings: Any, - ) -> Self: - """ - Create a FastMCP server from a FastAPI application. - - Args: - app: FastAPI application instance - name: Name for the MCP server (defaults to app.title) - route_maps: Optional list of RouteMap objects defining route mappings - route_map_fn: Optional callable for advanced route type mapping - mcp_component_fn: Optional callable for component customization - mcp_names: Optional dictionary mapping operationId to component names - httpx_client_kwargs: Optional kwargs passed to httpx.AsyncClient. - Use this to configure timeout and other client settings. - tags: Optional set of tags to add to all components - **settings: Additional settings passed to FastMCP - - Returns: - A FastMCP server with an OpenAPIProvider attached. - """ - from .providers.openapi import OpenAPIProvider - - if httpx_client_kwargs is None: - httpx_client_kwargs = {} - httpx_client_kwargs.setdefault("base_url", "http://fastapi") - - client = httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), - **httpx_client_kwargs, - ) - - server_name = name or app.title - - provider: Provider = OpenAPIProvider( - openapi_spec=app.openapi(), - client=client, - route_maps=route_maps, - route_map_fn=route_map_fn, - mcp_component_fn=mcp_component_fn, - mcp_names=mcp_names, - tags=tags, - ) - return cls(name=server_name, providers=[provider], **settings) - - @classmethod - def as_proxy( - cls, - backend: ( - Client[ClientTransportT] - | ClientTransport - | FastMCP[Any] - | FastMCP1Server - | AnyUrl - | Path - | MCPConfig - | dict[str, Any] - | str - ), - **settings: Any, - ) -> FastMCPProxy: - """Create a FastMCP proxy server for the given backend. - - .. deprecated:: - Use :func:`fastmcp.server.create_proxy` instead. - This method will be removed in a future version. - - The `backend` argument can be either an existing `fastmcp.client.Client` - instance or any value accepted as the `transport` argument of - `fastmcp.client.Client`. This mirrors the convenience of the - `fastmcp.client.Client` constructor. - """ - if fastmcp.settings.deprecation_warnings: - warnings.warn( - "FastMCP.as_proxy() is deprecated. Use create_proxy() from " - "fastmcp.server instead: `from fastmcp.server import create_proxy`", - FastMCPDeprecationWarning, - stacklevel=2, - ) - # Call the module-level create_proxy function directly - return create_proxy(backend, **settings) - - @classmethod - def generate_name(cls, name: str | None = None) -> str: - class_name = cls.__name__ - - if name is None: - return f"{class_name}-{secrets.token_hex(2)}" - else: - return f"{class_name}-{name}-{secrets.token_hex(2)}" - - -# ----------------------------------------------------------------------------- -# Module-level Factory Functions -# ----------------------------------------------------------------------------- - - -def create_proxy( - target: ( - Client[ClientTransportT] - | ClientTransport - | FastMCP[Any] - | FastMCP1Server - | AnyUrl - | Path - | MCPConfig - | dict[str, Any] - | str - ), - **settings: Any, -) -> FastMCPProxy: - """Create a FastMCP proxy server for the given target. - - This is the recommended way to create a proxy server. For lower-level control, - use `FastMCPProxy` or `ProxyProvider` directly from `fastmcp.server.providers.proxy`. - - Args: - target: The backend to proxy to. Can be: - - A Client instance (connected or disconnected) - - A ClientTransport - - A FastMCP server instance - - A URL string or AnyUrl - - A Path to a server script - - An MCPConfig or dict - **settings: Additional settings passed to FastMCPProxy (name, etc.) - - Returns: - A FastMCPProxy server that proxies to the target. - - Example: - ```python - from fastmcp.server import create_proxy - - # Create a proxy to a remote server - proxy = create_proxy("http://remote-server/mcp") - - # Create a proxy to another FastMCP server - proxy = create_proxy(other_server) - ``` - """ - from fastmcp.server.providers.proxy import ( - FastMCPProxy, - _create_client_factory, - ) - - client_factory = _create_client_factory(target) - return FastMCPProxy( - client_factory=client_factory, - **settings, - ) diff --git a/src/fastmcp/server/tasks/__init__.py b/src/fastmcp/server/tasks/__init__.py deleted file mode 100644 index 008332db5..000000000 --- a/src/fastmcp/server/tasks/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -"""MCP SEP-1686 background tasks support. - -This module implements protocol-level background task execution for MCP servers. -""" - -from fastmcp.server.tasks.capabilities import get_task_capabilities -from fastmcp.server.tasks.config import TaskConfig, TaskMeta, TaskMode -from fastmcp.server.tasks.elicitation import ( - elicit_for_task, - handle_task_input, - relay_elicitation, -) -from fastmcp.server.tasks.keys import ( - build_task_key, - get_client_task_id_from_key, - parse_task_key, -) -from fastmcp.server.tasks.notifications import ( - ensure_subscriber_running, - push_notification, - stop_subscriber, -) - -__all__ = [ - "TaskConfig", - "TaskMeta", - "TaskMode", - "build_task_key", - "elicit_for_task", - "ensure_subscriber_running", - "get_client_task_id_from_key", - "get_task_capabilities", - "handle_task_input", - "parse_task_key", - "push_notification", - "relay_elicitation", - "stop_subscriber", -] diff --git a/src/fastmcp/server/tasks/capabilities.py b/src/fastmcp/server/tasks/capabilities.py deleted file mode 100644 index 48c1f3d71..000000000 --- a/src/fastmcp/server/tasks/capabilities.py +++ /dev/null @@ -1,42 +0,0 @@ -"""SEP-1686 task capabilities declaration.""" - -from importlib.util import find_spec - -from mcp.types import ( - ServerTasksCapability, - ServerTasksRequestsCapability, - TasksCallCapability, - TasksCancelCapability, - TasksListCapability, - TasksToolsCapability, -) - - -def _is_docket_available() -> bool: - """Check if pydocket is installed (local to avoid circular import).""" - return find_spec("docket") is not None - - -def get_task_capabilities() -> ServerTasksCapability | None: - """Return the SEP-1686 task capabilities. - - Returns task capabilities as a first-class ServerCapabilities field, - declaring support for list, cancel, and request operations per SEP-1686. - - Returns None if pydocket is not installed (no task support). - - Note: prompts/resources are passed via extra_data since the SDK types - don't include them yet (FastMCP supports them ahead of the spec). - """ - if not _is_docket_available(): - return None - - return ServerTasksCapability( - list=TasksListCapability(), - cancel=TasksCancelCapability(), - requests=ServerTasksRequestsCapability( - tools=TasksToolsCapability(call=TasksCallCapability()), - prompts={"get": {}}, # type: ignore[call-arg] # extra_data for forward compat # ty:ignore[unknown-argument] - resources={"read": {}}, # type: ignore[call-arg] # extra_data for forward compat # ty:ignore[unknown-argument] - ), - ) diff --git a/src/fastmcp/server/tasks/config.py b/src/fastmcp/server/tasks/config.py deleted file mode 100644 index 1d5befa2a..000000000 --- a/src/fastmcp/server/tasks/config.py +++ /dev/null @@ -1,147 +0,0 @@ -"""TaskConfig for MCP SEP-1686 background task execution modes. - -This module defines the configuration for how tools, resources, and prompts -handle task-augmented execution as specified in SEP-1686. -""" - -from __future__ import annotations - -import functools -import inspect -from collections.abc import Callable -from dataclasses import dataclass -from datetime import timedelta -from typing import Any, Literal - -from fastmcp.utilities.async_utils import is_coroutine_function - -# Task execution modes per SEP-1686 / MCP ToolExecution.taskSupport -TaskMode = Literal["forbidden", "optional", "required"] - -# Default values for task metadata (single source of truth) -DEFAULT_POLL_INTERVAL = timedelta(seconds=5) # Default poll interval -DEFAULT_POLL_INTERVAL_MS = int(DEFAULT_POLL_INTERVAL.total_seconds() * 1000) -DEFAULT_TTL_MS = 60_000 # Default TTL in milliseconds - - -@dataclass -class TaskMeta: - """Metadata for task-augmented execution requests. - - When passed to call_tool/read_resource/get_prompt, signals that - the operation should be submitted as a background task. - - Attributes: - ttl: Client-requested TTL in milliseconds. If None, uses server default. - fn_key: Docket routing key. Auto-derived from component name if None. - """ - - ttl: int | None = None - fn_key: str | None = None - - -@dataclass -class TaskConfig: - """Configuration for MCP background task execution (SEP-1686). - - Controls how a component handles task-augmented requests: - - - "forbidden": Component does not support task execution. Clients must not - request task augmentation; server returns -32601 if they do. - - "optional": Component supports both synchronous and task execution. - Client may request task augmentation or call normally. - - "required": Component requires task execution. Clients must request task - augmentation; server returns -32601 if they don't. - - Important: - Task-enabled components must be available at server startup to be - registered with all Docket workers. Components added dynamically after - startup will not be registered for background execution. - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.tasks import TaskConfig - - mcp = FastMCP("MyServer") - - # Background execution required - @mcp.tool(task=TaskConfig(mode="required")) - async def long_running_task(): ... - - # Supports both modes (default when task=True) - @mcp.tool(task=TaskConfig(mode="optional")) - async def flexible_task(): ... - ``` - """ - - mode: TaskMode = "optional" - poll_interval: timedelta = DEFAULT_POLL_INTERVAL - - @classmethod - def from_bool(cls, value: bool) -> TaskConfig: - """Convert boolean task flag to TaskConfig. - - Args: - value: True for "optional" mode, False for "forbidden" mode. - - Returns: - TaskConfig with appropriate mode. - """ - return cls(mode="optional" if value else "forbidden") - - def supports_tasks(self) -> bool: - """Check if this component supports task execution. - - Returns: - True if mode is "optional" or "required", False if "forbidden". - """ - return self.mode != "forbidden" - - def validate_function(self, fn: Callable[..., Any], name: str) -> None: - """Validate that function is compatible with this task config. - - Task execution requires: - 1. fastmcp[tasks] to be installed (pydocket) - 2. Async functions - - Raises ImportError if mode is "optional" or "required" but pydocket - is not installed. Raises ValueError if function is synchronous. - - Args: - fn: The function to validate (handles callable classes and staticmethods). - name: Name for error messages. - - Raises: - ImportError: If task execution is enabled but pydocket not installed. - ValueError: If task execution is enabled but function is sync. - """ - if not self.supports_tasks(): - return - - # Check that docket is available for task execution - # Lazy import to avoid circular: dependencies.py → http.py → tasks/__init__.py → config.py - from fastmcp.server.dependencies import require_docket - - require_docket(f"`task=True` on function '{name}'") - - # Unwrap callable classes and staticmethods - fn_to_check = fn - if ( - not inspect.isroutine(fn) - and not isinstance(fn, functools.partial) - and callable(fn) - ): - fn_to_check = fn.__call__ - if isinstance(fn_to_check, staticmethod): - fn_to_check = fn_to_check.__func__ - - if not is_coroutine_function(fn_to_check): - raise ValueError( - f"'{name}' uses a sync function but has task execution enabled. " - "Background tasks require async functions." - ) - - # Note: Context IS now available in background task workers (SEP-1686) - # The wiring in _CurrentContext creates a task-aware Context with task_id - # and session from the registry. No warning needed. diff --git a/src/fastmcp/server/tasks/elicitation.py b/src/fastmcp/server/tasks/elicitation.py deleted file mode 100644 index cc6ac2624..000000000 --- a/src/fastmcp/server/tasks/elicitation.py +++ /dev/null @@ -1,346 +0,0 @@ -"""Background task elicitation support (SEP-1686). - -This module provides elicitation capabilities for background tasks running -in Docket workers. Unlike regular MCP requests, background tasks don't have -an active request context, so elicitation requires special handling: - -1. Set task status to "input_required" via Redis -2. Send notifications/tasks/status with elicitation metadata -3. Wait for client to send input via tasks/sendInput -4. Resume task execution with the provided input - -This uses the public MCP SDK APIs where possible, with minimal use of -internal APIs for background task coordination. -""" - -from __future__ import annotations - -import json -import logging -import uuid -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, cast - -import mcp.types -from mcp import ServerSession - -logger = logging.getLogger(__name__) - -if TYPE_CHECKING: - from fastmcp.server.server import FastMCP - - -# Redis key patterns for task elicitation state -ELICIT_REQUEST_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:request" -ELICIT_RESPONSE_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:response" -ELICIT_STATUS_KEY = "fastmcp:task:{session_id}:{task_id}:elicit:status" - -# TTL for elicitation state (1 hour) -ELICIT_TTL_SECONDS = 3600 - - -async def elicit_for_task( - task_id: str, - session: ServerSession | None, - message: str, - schema: dict[str, Any], - fastmcp: FastMCP, -) -> mcp.types.ElicitResult: - """Send an elicitation request from a background task. - - This function handles the complexity of eliciting user input when running - in a Docket worker context where there's no active MCP request. - - Args: - task_id: The background task ID - session: The MCP ServerSession for this task - message: The message to display to the user - schema: The JSON schema for the expected response - fastmcp: The FastMCP server instance - - Returns: - ElicitResult containing the user's response - - Raises: - RuntimeError: If Docket is not available - McpError: If the elicitation request fails - """ - docket = fastmcp._docket - if docket is None: - raise RuntimeError( - "Background task elicitation requires Docket. " - "Ensure 'fastmcp[tasks]' is installed and the server has task-enabled components." - ) - - # Generate a unique request ID for this elicitation - request_id = str(uuid.uuid4()) - - # Get session ID from task context (authoritative source for background tasks) - # This is extracted from the Docket execution key: {session_id}:{task_id}:... - from fastmcp.server.dependencies import get_task_context - - task_context = get_task_context() - if task_context is not None: - session_id = task_context.session_id - else: - # Fallback: try to get from session attribute (shouldn't happen in background) - session_id = getattr(session, "_fastmcp_state_prefix", None) - if session_id is None: - raise RuntimeError( - "Cannot determine session_id for elicitation. " - "This typically means elicit_for_task() was called outside a Docket worker context." - ) - - # Store elicitation request in Redis - request_key = ELICIT_REQUEST_KEY.format(session_id=session_id, task_id=task_id) - response_key = ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id) - status_key = ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id) - - elicit_request = { - "request_id": request_id, - "message": message, - "schema": schema, - } - - async with docket.redis() as redis: - # Store the elicitation request - await redis.set( - docket.key(request_key), - json.dumps(elicit_request), - ex=ELICIT_TTL_SECONDS, - ) - # Set status to "waiting" - await redis.set( - docket.key(status_key), - "waiting", - ex=ELICIT_TTL_SECONDS, - ) - - # Send task status update notification with input_required status. - # Use notifications/tasks/status so typed MCP clients can consume it. - # - # NOTE: We use the distributed notification queue instead of session.send_notification() - # This enables notifications to work when workers run in separate processes - # (Azure Web PubSub / Service Bus inspired pattern) - timestamp = datetime.now(timezone.utc).isoformat() - notification_dict = { - "method": "notifications/tasks/status", - "params": { - "taskId": task_id, - "status": "input_required", - "statusMessage": message, - "createdAt": timestamp, - "lastUpdatedAt": timestamp, - "ttl": ELICIT_TTL_SECONDS * 1000, - }, - "_meta": { - "io.modelcontextprotocol/related-task": { - "taskId": task_id, - "status": "input_required", - "statusMessage": message, - "elicitation": { - "requestId": request_id, - "message": message, - "requestedSchema": schema, - }, - } - }, - } - - # Push notification to Redis queue (works from any process) - # Server's subscriber loop will forward to client - from fastmcp.server.tasks.notifications import push_notification - - try: - await push_notification(session_id, notification_dict, docket) - except Exception as e: - # Fail fast: if notification can't be queued, client won't know to respond - # Return cancel immediately rather than waiting for 1-hour timeout - logger.warning( - "Failed to queue input_required notification for task %s, cancelling elicitation: %s", - task_id, - e, - ) - # Best-effort cleanup - try: - async with docket.redis() as redis: - await redis.delete( - docket.key(request_key), - docket.key(status_key), - ) - except Exception: - pass # Keys will expire via TTL - return mcp.types.ElicitResult(action="cancel", content=None) - - # Wait for response using BLPOP (blocking pop) - # This is much more efficient than polling - single Redis round-trip - # that blocks until a response is pushed, vs 7,200 round-trips/hour with polling - max_wait_seconds = ELICIT_TTL_SECONDS - - try: - async with docket.redis() as redis: - # BLPOP blocks until an item is pushed to the list or timeout - # Returns tuple of (key, value) or None on timeout - result = await cast( - Any, - redis.blpop( - [docket.key(response_key)], - timeout=max_wait_seconds, - ), - ) - - if result: - # result is (key, value) tuple - _key, response_data = result - response = json.loads(response_data) - - # Clean up Redis keys - await redis.delete( - docket.key(request_key), - docket.key(status_key), - ) - - # Convert to ElicitResult - return mcp.types.ElicitResult( - action=response.get("action", "accept"), - content=response.get("content"), - ) - except Exception as e: - logger.warning( - "BLPOP failed for task %s elicitation, falling back to cancel: %s", - task_id, - e, - ) - - # Timeout or error - treat as cancellation - # Best-effort cleanup - if Redis is unavailable, keys will expire via TTL - try: - async with docket.redis() as redis: - await redis.delete( - docket.key(request_key), - docket.key(response_key), - docket.key(status_key), - ) - except Exception as cleanup_error: - logger.debug( - "Failed to clean up elicitation keys for task %s (will expire via TTL): %s", - task_id, - cleanup_error, - ) - - return mcp.types.ElicitResult(action="cancel", content=None) - - -async def relay_elicitation( - session: ServerSession, - session_id: str, - task_id: str, - elicitation: dict[str, Any], - fastmcp: FastMCP, -) -> None: - """Relay elicitation from a background task worker to the client. - - Called by the notification subscriber when it detects an input_required - notification with elicitation metadata. Sends a standard elicitation/create - request to the client session, then uses handle_task_input() to push the - response to Redis so the blocked worker can resume. - - Args: - session: MCP ServerSession - session_id: Session identifier - task_id: Background task ID - elicitation: Elicitation metadata (message, requestedSchema) - fastmcp: FastMCP server instance - """ - try: - result = await session.elicit( - message=elicitation["message"], - requestedSchema=elicitation["requestedSchema"], - ) - await handle_task_input( - task_id=task_id, - session_id=session_id, - action=result.action, - content=result.content, - fastmcp=fastmcp, - ) - logger.debug( - "Relayed elicitation response for task %s (action=%s)", - task_id, - result.action, - ) - except Exception as e: - logger.warning("Failed to relay elicitation for task %s: %s", task_id, e) - # Push a cancel response so the worker's BLPOP doesn't block forever - success = await handle_task_input( - task_id=task_id, - session_id=session_id, - action="cancel", - content=None, - fastmcp=fastmcp, - ) - if not success: - logger.warning( - "Failed to push cancel response for task %s " - "(worker may block until TTL)", - task_id, - ) - - -async def handle_task_input( - task_id: str, - session_id: str, - action: str, - content: dict[str, Any] | None, - fastmcp: FastMCP, -) -> bool: - """Handle input sent to a background task via tasks/sendInput. - - This is called when a client sends input in response to an elicitation - request from a background task. - - Args: - task_id: The background task ID - session_id: The MCP session ID - action: The elicitation action ("accept", "decline", "cancel") - content: The response content (for "accept" action) - fastmcp: The FastMCP server instance - - Returns: - True if the input was successfully stored, False otherwise - """ - docket = fastmcp._docket - if docket is None: - return False - - response_key = ELICIT_RESPONSE_KEY.format(session_id=session_id, task_id=task_id) - status_key = ELICIT_STATUS_KEY.format(session_id=session_id, task_id=task_id) - - response = { - "action": action, - "content": content, - } - - async with docket.redis() as redis: - # Check if there's a pending elicitation - status = await redis.get(docket.key(status_key)) - if status is None or status.decode("utf-8") != "waiting": - return False - - # Push response to list - this wakes up the BLPOP in elicit_for_task - # Using LPUSH instead of SET enables the efficient blocking wait pattern - await redis.lpush( # type: ignore[invalid-await] # redis-py union type (sync/async) - docket.key(response_key), - json.dumps(response), - ) # ty:ignore[invalid-await] - # Set TTL on the response list (in case BLPOP doesn't consume it) - await redis.expire(docket.key(response_key), ELICIT_TTL_SECONDS) - - # Update status to "responded" - await redis.set( - docket.key(status_key), - "responded", - ex=ELICIT_TTL_SECONDS, - ) - - return True diff --git a/src/fastmcp/server/tasks/handlers.py b/src/fastmcp/server/tasks/handlers.py deleted file mode 100644 index 051785ddd..000000000 --- a/src/fastmcp/server/tasks/handlers.py +++ /dev/null @@ -1,244 +0,0 @@ -"""SEP-1686 task execution handlers. - -Handles queuing tool/prompt/resource executions to Docket as background tasks. -""" - -from __future__ import annotations - -import json -import uuid -from contextlib import suppress -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, Literal - -import mcp.types -from mcp.shared.exceptions import McpError -from mcp.types import INTERNAL_ERROR, ErrorData - -from fastmcp.server.dependencies import ( - _current_docket, - get_access_token, - get_context, - get_http_headers, - register_task_server, -) -from fastmcp.server.tasks.config import TaskMeta -from fastmcp.server.tasks.keys import build_task_key -from fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - from fastmcp.prompts.base import Prompt - from fastmcp.resources.base import Resource - from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.base import Tool - -logger = get_logger(__name__) - -# Redis mapping TTL buffer: Add 15 minutes to Docket's execution_ttl -TASK_MAPPING_TTL_BUFFER_SECONDS = 15 * 60 - - -async def submit_to_docket( - task_type: Literal["tool", "resource", "template", "prompt"], - key: str, - component: Tool | Resource | ResourceTemplate | Prompt, - arguments: dict[str, Any] | None = None, - task_meta: TaskMeta | None = None, -) -> mcp.types.CreateTaskResult: - """Submit any component to Docket for background execution (SEP-1686). - - Unified handler for all component types. Called by component's internal - methods (_run, _read, _render) when task metadata is present and mode allows. - - Queues the component's method to Docket, stores raw return values, - and converts to MCP types on retrieval. - - Args: - task_type: Component type for task key construction - key: The component key as seen by MCP layer (with namespace prefix) - component: The component instance (Tool, Resource, ResourceTemplate, Prompt) - arguments: Arguments/params (None for Resource which has no args) - task_meta: Task execution metadata. If task_meta.ttl is provided, it - overrides the server default (docket.execution_ttl). - - Returns: - CreateTaskResult: Task stub with proper Task object - """ - # Generate server-side task ID per SEP-1686 final spec (line 375-377) - # Server MUST generate task IDs, clients no longer provide them - server_task_id = str(uuid.uuid4()) - - # Record creation timestamp per SEP-1686 final spec (line 430) - created_at = datetime.now(timezone.utc) - - # Get session ID - use "internal" for programmatic calls without MCP session - ctx = get_context() - try: - session_id = ctx.session_id - except RuntimeError: - session_id = "internal" - - docket = _current_docket.get() - if docket is None: - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message="Background tasks require a running FastMCP server context", - ) - ) - - # Register the current server so background workers resolve - # CurrentFastMCP() / ctx.fastmcp to the correct (child) server - # for mounted tasks. At this point ctx.fastmcp is the child because - # we're inside the child's call_tool dispatch. - register_task_server(server_task_id, ctx.fastmcp) - - # Build full task key with embedded metadata - task_key = build_task_key(session_id, server_task_id, task_type, key) - - # Determine TTL: use task_meta.ttl if provided, else docket default - if task_meta is not None and task_meta.ttl is not None: - ttl_ms = task_meta.ttl - else: - ttl_ms = int(docket.execution_ttl.total_seconds() * 1000) - ttl_seconds = int(ttl_ms / 1000) + TASK_MAPPING_TTL_BUFFER_SECONDS - - # Store task metadata in Redis for protocol handlers - task_meta_key = docket.key(f"fastmcp:task:{session_id}:{server_task_id}") - created_at_key = docket.key( - f"fastmcp:task:{session_id}:{server_task_id}:created_at" - ) - poll_interval_key = docket.key( - f"fastmcp:task:{session_id}:{server_task_id}:poll_interval" - ) - origin_request_id_key = docket.key( - f"fastmcp:task:{session_id}:{server_task_id}:origin_request_id" - ) - poll_interval_ms = int(component.task_config.poll_interval.total_seconds() * 1000) - origin_request_id = ( - str(ctx.request_context.request_id) if ctx.request_context is not None else None - ) - - # Snapshot the current access token (if any) for background task access (#3095) - access_token = get_access_token() - access_token_key = docket.key( - f"fastmcp:task:{session_id}:{server_task_id}:access_token" - ) - http_headers = get_http_headers(include_all=True) - http_headers_key = docket.key( - f"fastmcp:task:{session_id}:{server_task_id}:http_headers" - ) - - async with docket.redis() as redis: - await redis.set(task_meta_key, task_key, ex=ttl_seconds) - await redis.set(created_at_key, created_at.isoformat(), ex=ttl_seconds) - await redis.set(poll_interval_key, str(poll_interval_ms), ex=ttl_seconds) - if origin_request_id is not None: - await redis.set(origin_request_id_key, origin_request_id, ex=ttl_seconds) - if access_token is not None: - await redis.set( - access_token_key, access_token.model_dump_json(), ex=ttl_seconds - ) - if http_headers: - await redis.set(http_headers_key, json.dumps(http_headers), ex=ttl_seconds) - - # Register session for Context access in background workers (SEP-1686) - # This enables elicitation/sampling from background tasks via weakref - # Skip for "internal" sessions (programmatic calls without MCP session) - if session_id != "internal": - from fastmcp.server.dependencies import register_task_session - - register_task_session(session_id, ctx.session) - - # Send an initial tasks/status notification before queueing. - # This guarantees clients can observe task creation immediately. - notification = mcp.types.TaskStatusNotification.model_validate( - { - "method": "notifications/tasks/status", - "params": { - "taskId": server_task_id, - "status": "working", - "statusMessage": "Task submitted", - "createdAt": created_at, - "lastUpdatedAt": created_at, - "ttl": ttl_ms, - "pollInterval": poll_interval_ms, - }, - "_meta": { - "io.modelcontextprotocol/related-task": { - "taskId": server_task_id, - } - }, - } - ) - server_notification = mcp.types.ServerNotification(notification) - with suppress(Exception): - # Don't let notification failures break task creation - await ctx.session.send_notification(server_notification) - - # Queue function to Docket by key (result storage via execution_ttl) - # Use component.add_to_docket() which handles calling conventions - # `fn_key` is the function lookup key (e.g., "child_multiply") - # `task_key` is the task result key (e.g., "fastmcp:task:{session}:{task_id}:tool:child_multiply") - # Resources don't take arguments; tools/prompts/templates always pass arguments (even if None/empty) - if task_type == "resource": - await component.add_to_docket(docket, fn_key=key, task_key=task_key) # type: ignore[call-arg] # ty:ignore[missing-argument] - else: - await component.add_to_docket(docket, arguments, fn_key=key, task_key=task_key) # type: ignore[call-arg] # ty:ignore[invalid-argument-type, too-many-positional-arguments] - - # Spawn subscription task to send status notifications (SEP-1686 optional feature) - from fastmcp.server.tasks.subscriptions import subscribe_to_task_updates - - # Start subscription in session's task group (persists for connection lifetime) - if hasattr(ctx.session, "_subscription_task_group"): - tg = ctx.session._subscription_task_group - if tg: - tg.start_soon( # type: ignore[union-attr] # ty:ignore[unresolved-attribute] - subscribe_to_task_updates, - server_task_id, - task_key, - ctx.session, - docket, - poll_interval_ms, - ) - - # Start notification subscriber for distributed elicitation (idempotent) - # This enables ctx.elicit() to work when workers run in separate processes - # Subscriber forwards notifications from Redis queue to client session - from fastmcp.server.tasks.notifications import ( - ensure_subscriber_running, - stop_subscriber, - ) - - try: - await ensure_subscriber_running(session_id, ctx.session, docket, ctx.fastmcp) - - # Register cleanup callback on session exit (once per session) - # This ensures subscriber is stopped when the session disconnects - if ( - hasattr(ctx.session, "_exit_stack") - and ctx.session._exit_stack is not None - and not getattr(ctx.session, "_notification_cleanup_registered", False) - ): - - async def _cleanup_subscriber() -> None: - await stop_subscriber(session_id) - - ctx.session._exit_stack.push_async_callback(_cleanup_subscriber) - ctx.session._notification_cleanup_registered = True # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - except Exception as e: - # Non-fatal: elicitation will still work via polling fallback - logger.debug("Failed to start notification subscriber: %s", e) - - # Return CreateTaskResult with proper Task object - # Tasks MUST begin in "working" status per SEP-1686 final spec (line 381) - return mcp.types.CreateTaskResult( - task=mcp.types.Task( - taskId=server_task_id, - status="working", - createdAt=created_at, - lastUpdatedAt=created_at, - ttl=ttl_ms, - pollInterval=poll_interval_ms, - ) - ) diff --git a/src/fastmcp/server/tasks/keys.py b/src/fastmcp/server/tasks/keys.py deleted file mode 100644 index 0e28cf592..000000000 --- a/src/fastmcp/server/tasks/keys.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Task key management for SEP-1686 background tasks. - -Task keys encode security scoping and metadata in the Docket key format: - `{session_id}:{client_task_id}:{task_type}:{component_identifier}` - -This format provides: -- Session-based security scoping (prevents cross-session access) -- Task type identification (tool/prompt/resource) -- Component identification (name or URI for result conversion) -""" - -from urllib.parse import quote, unquote - - -def build_task_key( - session_id: str, - client_task_id: str, - task_type: str, - component_identifier: str, -) -> str: - """Build Docket task key with embedded metadata. - - Format: `{session_id}:{client_task_id}:{task_type}:{component_identifier}` - - The component_identifier is URI-encoded to handle special characters (colons, slashes, etc.). - - Args: - session_id: Session ID for security scoping - client_task_id: Client-provided task ID - task_type: Type of task ("tool", "prompt", "resource") - component_identifier: Tool name, prompt name, or resource URI - - Returns: - Encoded task key for Docket - - Examples: - >>> build_task_key("session123", "task456", "tool", "my_tool") - 'session123:task456:tool:my_tool' - - >>> build_task_key("session123", "task456", "resource", "file://data.txt") - 'session123:task456:resource:file%3A%2F%2Fdata.txt' - """ - encoded_identifier = quote(component_identifier, safe="") - return f"{session_id}:{client_task_id}:{task_type}:{encoded_identifier}" - - -def parse_task_key(task_key: str) -> dict[str, str]: - """Parse Docket task key to extract metadata. - - Args: - task_key: Encoded task key from Docket - - Returns: - Dict with keys: session_id, client_task_id, task_type, component_identifier - - Examples: - >>> parse_task_key("session123:task456:tool:my_tool") - `{'session_id': 'session123', 'client_task_id': 'task456', 'task_type': 'tool', 'component_identifier': 'my_tool'}` - - >>> parse_task_key("session123:task456:resource:file%3A%2F%2Fdata.txt") - `{'session_id': 'session123', 'client_task_id': 'task456', 'task_type': 'resource', 'component_identifier': 'file://data.txt'}` - """ - parts = task_key.split(":", 3) - if len(parts) != 4: - raise ValueError( - f"Invalid task key format: {task_key}. " - f"Expected: {{session_id}}:{{client_task_id}}:{{task_type}}:{{component_identifier}}" - ) - - return { - "session_id": parts[0], - "client_task_id": parts[1], - "task_type": parts[2], - "component_identifier": unquote(parts[3]), - } - - -def get_client_task_id_from_key(task_key: str) -> str: - """Extract just the client task ID from a task key. - - Args: - task_key: Full encoded task key - - Returns: - Client-provided task ID (second segment) - - Example: - >>> get_client_task_id_from_key("session123:task456:tool:my_tool") - 'task456' - """ - return task_key.split(":", 3)[1] diff --git a/src/fastmcp/server/tasks/notifications.py b/src/fastmcp/server/tasks/notifications.py deleted file mode 100644 index 6656bc361..000000000 --- a/src/fastmcp/server/tasks/notifications.py +++ /dev/null @@ -1,300 +0,0 @@ -"""Distributed notification queue for background task events (SEP-1686). - -Enables distributed Docket workers to send MCP notifications to clients -without holding session references. Workers push to a Redis queue, -the MCP server process subscribes and forwards to the client's session. - -Pattern: Fire-and-forward with retry -- One queue per session_id -- LPUSH/BRPOP for reliable ordered delivery -- Retry up to 3 times on delivery failure, then discard -- TTL-based expiration for stale messages - -Note: Docket's execution.subscribe() handles task state/progress events via -Redis Pub/Sub. This module handles elicitation-specific notifications that -require reliable delivery (input_required prompts, cancel signals). -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import weakref -from contextlib import suppress -from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any, cast - -import mcp.types - -if TYPE_CHECKING: - from docket import Docket - from mcp.server.session import ServerSession - - from fastmcp.server.server import FastMCP - -logger = logging.getLogger(__name__) - -# Redis key patterns -NOTIFICATION_QUEUE_KEY = "fastmcp:notifications:{session_id}" -NOTIFICATION_ACTIVE_KEY = "fastmcp:notifications:{session_id}:active" - -# Configuration -NOTIFICATION_TTL_SECONDS = 300 # 5 minute message TTL (elicitation response window) -MAX_DELIVERY_ATTEMPTS = 3 # Retry failed deliveries before discarding -SUBSCRIBER_TIMEOUT_SECONDS = 30 # BRPOP timeout (also heartbeat interval) - - -async def push_notification( - session_id: str, - notification: dict[str, Any], - docket: Docket, -) -> None: - """Push notification to session's queue (called from Docket worker). - - Used for elicitation-specific notifications (input_required, cancel) - that need reliable delivery across distributed processes. - - Args: - session_id: Target session's identifier - notification: MCP notification dict (method, params, _meta) - docket: Docket instance for Redis access - """ - key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id)) - message = json.dumps( - { - "notification": notification, - "attempt": 0, - "enqueued_at": datetime.now(timezone.utc).isoformat(), - } - ) - async with docket.redis() as redis: - await redis.lpush(key, message) # type: ignore[invalid-await] # redis-py union type (sync/async) # ty:ignore[invalid-await] - await redis.expire(key, NOTIFICATION_TTL_SECONDS) - - -async def notification_subscriber_loop( - session_id: str, - session: ServerSession, - docket: Docket, - fastmcp: FastMCP, -) -> None: - """Subscribe to notification queue and forward to session. - - Runs in the MCP server process. Bridges distributed workers to clients. - - This loop: - 1. Maintains a heartbeat (active subscriber marker for debugging) - 2. Blocks on BRPOP waiting for notifications - 3. Forwards notifications to the client's session - 4. Retries failed deliveries, then discards (no dead-letter queue) - - Args: - session_id: Session identifier to subscribe to - session: MCP ServerSession for sending notifications - docket: Docket instance for Redis access - fastmcp: FastMCP server instance (for elicitation relay) - """ - queue_key = docket.key(NOTIFICATION_QUEUE_KEY.format(session_id=session_id)) - active_key = docket.key(NOTIFICATION_ACTIVE_KEY.format(session_id=session_id)) - - logger.debug("Starting notification subscriber for session %s", session_id) - - while True: - try: - async with docket.redis() as redis: - # Heartbeat: mark subscriber as active (for distributed debugging) - await redis.set(active_key, "1", ex=SUBSCRIBER_TIMEOUT_SECONDS * 2) - - # Blocking wait for notification (timeout refreshes heartbeat) - # Using BRPOP (right pop) for FIFO order with LPUSH (left push) - result = await cast( - Any, redis.brpop([queue_key], timeout=SUBSCRIBER_TIMEOUT_SECONDS) - ) - if not result: - continue # Timeout - refresh heartbeat and retry - - _, message_bytes = result - message = json.loads(message_bytes) - notification_dict = message["notification"] - attempt = message.get("attempt", 0) - - try: - # Reconstruct and send MCP notification - await _send_mcp_notification( - session, notification_dict, session_id, docket, fastmcp - ) - logger.debug( - "Delivered notification to session %s (attempt %d)", - session_id, - attempt + 1, - ) - except Exception as send_error: - # Delivery failed - retry or discard - if attempt < MAX_DELIVERY_ATTEMPTS - 1: - # Re-queue with incremented attempt (back of queue) - message["attempt"] = attempt + 1 - message["last_error"] = str(send_error) - await redis.lpush(queue_key, json.dumps(message)) # type: ignore[invalid-await] # ty:ignore[invalid-await] - logger.debug( - "Requeued notification for session %s (attempt %d): %s", - session_id, - attempt + 2, - send_error, - ) - else: - # Discard after max attempts (session likely disconnected) - logger.warning( - "Discarding notification for session %s after %d attempts: %s", - session_id, - MAX_DELIVERY_ATTEMPTS, - send_error, - ) - - except asyncio.CancelledError: - # Graceful shutdown - leave pending messages in queue for reconnect - logger.debug("Notification subscriber cancelled for session %s", session_id) - break - except Exception as e: - logger.debug( - "Notification subscriber error for session %s: %s", session_id, e - ) - await asyncio.sleep(1) # Backoff on error - - -async def _send_mcp_notification( - session: ServerSession, - notification_dict: dict[str, Any], - session_id: str, - docket: Docket, - fastmcp: FastMCP, -) -> None: - """Reconstruct MCP notification from dict and send to session. - - For input_required notifications with elicitation metadata, also sends - a standard elicitation/create request to the client and relays the - response back to the worker via Redis. - - Args: - session: MCP ServerSession - notification_dict: Notification as dict (method, params, _meta) - session_id: Session identifier (for elicitation relay) - docket: Docket instance (for notification delivery) - fastmcp: FastMCP server instance (for elicitation relay) - """ - method = notification_dict.get("method", "notifications/tasks/status") - if method != "notifications/tasks/status": - raise ValueError(f"Unsupported notification method for subscriber: {method}") - - notification = mcp.types.TaskStatusNotification.model_validate( - { - "method": "notifications/tasks/status", - "params": notification_dict.get("params", {}), - "_meta": notification_dict.get("_meta"), - } - ) - server_notification = mcp.types.ServerNotification(notification) - - await session.send_notification(server_notification) - - # If this is an input_required notification with elicitation metadata, - # relay the elicitation to the client via standard elicitation/create - params = notification_dict.get("params", {}) - if params.get("status") == "input_required": - meta = notification_dict.get("_meta", {}) - related_task = meta.get("io.modelcontextprotocol/related-task", {}) - elicitation = related_task.get("elicitation") - if elicitation: - task_id = params.get("taskId") - if not task_id: - logger.warning( - "input_required notification missing taskId, skipping relay" - ) - return - from fastmcp.server.tasks.elicitation import relay_elicitation - - task = asyncio.create_task( - relay_elicitation(session, session_id, task_id, elicitation, fastmcp), - name=f"elicitation-relay-{task_id[:8]}", - ) - _background_tasks.add(task) - task.add_done_callback(_background_tasks.discard) - - -# ============================================================================= -# Subscriber Management -# ============================================================================= - -# Strong references to fire-and-forget relay tasks (prevent GC mid-flight) -_background_tasks: set[asyncio.Task[None]] = set() - -# Registry of active subscribers per session (prevents duplicates) -# Uses weakref to session to detect disconnects -_active_subscribers: dict[ - str, tuple[asyncio.Task[None], weakref.ref[ServerSession]] -] = {} - - -async def ensure_subscriber_running( - session_id: str, - session: ServerSession, - docket: Docket, - fastmcp: FastMCP, -) -> None: - """Start notification subscriber if not already running (idempotent). - - Subscriber is created on first task submission and cleaned up on disconnect. - Safe to call multiple times for the same session. - - Args: - session_id: Session identifier - session: MCP ServerSession - docket: Docket instance - fastmcp: FastMCP server instance (for elicitation relay) - """ - # Check if subscriber already running for this session - if session_id in _active_subscribers: - task, session_ref = _active_subscribers[session_id] - # Check if task is still running AND session is still alive - if not task.done() and session_ref() is not None: - return # Already running - - # Task finished or session dead - clean up - if not task.done(): - task.cancel() - with suppress(asyncio.CancelledError): - await task - del _active_subscribers[session_id] - - # Start new subscriber task - task = asyncio.create_task( - notification_subscriber_loop(session_id, session, docket, fastmcp), - name=f"notification-subscriber-{session_id[:8]}", - ) - _active_subscribers[session_id] = (task, weakref.ref(session)) - logger.debug("Started notification subscriber for session %s", session_id) - - -async def stop_subscriber(session_id: str) -> None: - """Stop notification subscriber for a session. - - Called when session disconnects. Pending messages remain in queue - for delivery if client reconnects (with TTL expiration). - - Args: - session_id: Session identifier - """ - if session_id not in _active_subscribers: - return - - task, _ = _active_subscribers.pop(session_id) - if not task.done(): - task.cancel() - with suppress(asyncio.CancelledError): - await task - logger.debug("Stopped notification subscriber for session %s", session_id) - - -def get_subscriber_count() -> int: - """Get number of active subscribers (for monitoring).""" - return len(_active_subscribers) diff --git a/src/fastmcp/server/tasks/requests.py b/src/fastmcp/server/tasks/requests.py deleted file mode 100644 index 8743356e5..000000000 --- a/src/fastmcp/server/tasks/requests.py +++ /dev/null @@ -1,479 +0,0 @@ -"""SEP-1686 task request handlers. - -Handles MCP task protocol requests: tasks/get, tasks/result, tasks/list, tasks/cancel. -These handlers query and manage existing tasks (contrast with handlers.py which creates tasks). - -This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available. -""" - -from __future__ import annotations - -from datetime import datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Literal - -import mcp.types -from docket.execution import ExecutionState -from mcp.shared.exceptions import McpError -from mcp.types import ( - INTERNAL_ERROR, - INVALID_PARAMS, - CancelTaskResult, - ErrorData, - GetTaskResult, - ListTasksResult, -) - -import fastmcp.server.context -from fastmcp.exceptions import NotFoundError -from fastmcp.prompts.base import Prompt -from fastmcp.resources.base import Resource -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.tasks.config import DEFAULT_POLL_INTERVAL_MS, DEFAULT_TTL_MS -from fastmcp.server.tasks.keys import parse_task_key -from fastmcp.tools.base import Tool -from fastmcp.utilities.versions import VersionSpec - -if TYPE_CHECKING: - from fastmcp.server.server import FastMCP - - -# Map Docket execution states to MCP task status strings -# Per SEP-1686 final spec (line 381): tasks MUST begin in "working" status -DOCKET_TO_MCP_STATE: dict[ExecutionState, str] = { - ExecutionState.SCHEDULED: "working", # Initial state per spec - ExecutionState.QUEUED: "working", # Initial state per spec - ExecutionState.RUNNING: "working", - ExecutionState.COMPLETED: "completed", - ExecutionState.FAILED: "failed", - ExecutionState.CANCELLED: "cancelled", -} - - -def _parse_key_version(key_suffix: str) -> tuple[str, str | None]: - """Parse a key suffix into (name_or_uri, version). - - Keys always contain @ as a version delimiter (sentinel pattern): - - "add@1.0" → ("add", "1.0") # versioned - - "add@" → ("add", None) # unversioned - - "user@example.com@1.0" → ("user@example.com", "1.0") # @ in URI - - Uses rsplit to split on the LAST @ which is always the version delimiter. - Falls back to treating the whole string as the name if @ is not present - (for backwards compatibility with legacy task keys). - """ - if "@" not in key_suffix: - # Legacy key without version sentinel - treat as unversioned - return key_suffix, None - name_or_uri, version = key_suffix.rsplit("@", 1) - return name_or_uri, version if version else None - - -async def _lookup_task_execution( - docket: Any, - session_id: str, - client_task_id: str, -) -> tuple[Any, str | None, int]: - """Look up task execution and metadata from Redis. - - Consolidates the common pattern of fetching task metadata from Redis, - validating it exists, and retrieving the Docket execution. - - Args: - docket: Docket instance - session_id: Session ID - client_task_id: Client-provided task ID - - Returns: - Tuple of (execution, created_at, poll_interval_ms) - - Raises: - McpError: If task not found or execution not found - """ - task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}") - created_at_key = docket.key( - f"fastmcp:task:{session_id}:{client_task_id}:created_at" - ) - poll_interval_key = docket.key( - f"fastmcp:task:{session_id}:{client_task_id}:poll_interval" - ) - - # Fetch metadata (single round-trip with mget) - async with docket.redis() as redis: - task_key_bytes, created_at_bytes, poll_interval_bytes = await redis.mget( - task_meta_key, created_at_key, poll_interval_key - ) - - # Decode and validate task_key - task_key = task_key_bytes.decode("utf-8") if task_key_bytes else None - if not task_key: - raise McpError( - ErrorData(code=INVALID_PARAMS, message=f"Task {client_task_id} not found") - ) - - # Get execution - execution = await docket.get_execution(task_key) - if not execution: - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Task {client_task_id} execution not found", - ) - ) - - # Parse metadata with defaults - created_at = created_at_bytes.decode("utf-8") if created_at_bytes else None - try: - poll_interval_ms = ( - int(poll_interval_bytes.decode("utf-8")) - if poll_interval_bytes - else DEFAULT_POLL_INTERVAL_MS - ) - except (ValueError, UnicodeDecodeError): - poll_interval_ms = DEFAULT_POLL_INTERVAL_MS - - return execution, created_at, poll_interval_ms - - -async def tasks_get_handler(server: FastMCP, params: dict[str, Any]) -> GetTaskResult: - """Handle MCP 'tasks/get' request (SEP-1686). - - Args: - server: FastMCP server instance - params: Request params containing taskId - - Returns: - GetTaskResult: Task status response with spec-compliant fields - """ - async with fastmcp.server.context.Context(fastmcp=server) as ctx: - client_task_id = params.get("taskId") - if not client_task_id: - raise McpError( - ErrorData( - code=INVALID_PARAMS, message="Missing required parameter: taskId" - ) - ) - - # Get session ID from Context - session_id = ctx.session_id - - # Get Docket instance - docket = server._docket - if docket is None: - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message="Background tasks require Docket", - ) - ) - - # Look up task execution and metadata - execution, created_at, poll_interval_ms = await _lookup_task_execution( - docket, session_id, client_task_id - ) - - # Sync state from Redis - await execution.sync() - - # Map Docket state to MCP state - state_map = DOCKET_TO_MCP_STATE - mcp_state: Literal[ - "working", "input_required", "completed", "failed", "cancelled" - ] = state_map.get(execution.state, "failed") # type: ignore[assignment] # ty:ignore[invalid-assignment] - - # Build response (use default ttl since we don't track per-task values) - # createdAt is REQUIRED per SEP-1686 final spec (line 430) - # Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/get - error_message = None - status_message = None - - if execution.state == ExecutionState.FAILED: - try: - await execution.get_result(timeout=timedelta(seconds=0)) - except Exception as error: - error_message = str(error) - status_message = f"Task failed: {error_message}" - elif execution.progress and execution.progress.message: - # Extract progress message from Docket if available (spec line 403) - status_message = execution.progress.message - - # createdAt is required per spec, but can be None from Redis - # Parse ISO string to datetime, or use current time as fallback - if created_at: - try: - created_at_dt = datetime.fromisoformat( - created_at.replace("Z", "+00:00") - ) - except (ValueError, AttributeError): - created_at_dt = datetime.now(timezone.utc) - else: - created_at_dt = datetime.now(timezone.utc) - - return GetTaskResult( - taskId=client_task_id, - status=mcp_state, - createdAt=created_at_dt, - lastUpdatedAt=datetime.now(timezone.utc), - ttl=DEFAULT_TTL_MS, - pollInterval=poll_interval_ms, - statusMessage=status_message, - ) - - -async def tasks_result_handler(server: FastMCP, params: dict[str, Any]) -> Any: - """Handle MCP 'tasks/result' request (SEP-1686). - - Converts raw task return values to MCP types based on task type. - - Args: - server: FastMCP server instance - params: Request params containing taskId - - Returns: - MCP result (CallToolResult, GetPromptResult, or ReadResourceResult) - """ - async with fastmcp.server.context.Context(fastmcp=server) as ctx: - client_task_id = params.get("taskId") - if not client_task_id: - raise McpError( - ErrorData( - code=INVALID_PARAMS, message="Missing required parameter: taskId" - ) - ) - - # Get session ID from Context - session_id = ctx.session_id - - # Get execution from Docket (use instance attribute for cross-task access) - docket = server._docket - if docket is None: - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message="Background tasks require Docket", - ) - ) - - # Look up full task key from Redis - task_meta_key = docket.key(f"fastmcp:task:{session_id}:{client_task_id}") - async with docket.redis() as redis: - task_key_bytes = await redis.get(task_meta_key) - - task_key = None if task_key_bytes is None else task_key_bytes.decode("utf-8") - - if task_key is None: - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Invalid taskId: {client_task_id} not found", - ) - ) - - execution = await docket.get_execution(task_key) - if execution is None: - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Invalid taskId: {client_task_id} not found", - ) - ) - - # Sync state from Redis - await execution.sync() - - # Check if completed - state_map = DOCKET_TO_MCP_STATE - if execution.state not in (ExecutionState.COMPLETED, ExecutionState.FAILED): - mcp_state = state_map.get(execution.state, "failed") - raise McpError( - ErrorData( - code=INVALID_PARAMS, - message=f"Task not completed yet (current state: {mcp_state})", - ) - ) - - # Get result from Docket - try: - raw_value = await execution.get_result(timeout=timedelta(seconds=0)) - except Exception as error: - # Task failed - return error result - return mcp.types.CallToolResult( - content=[mcp.types.TextContent(type="text", text=str(error))], - isError=True, - _meta={ # type: ignore[call-arg] # _meta is Pydantic alias for meta field - "io.modelcontextprotocol/related-task": { - "taskId": client_task_id, - } - }, # ty:ignore[unknown-argument] - ) - - # Parse task key to get component key - key_parts = parse_task_key(task_key) - component_key = key_parts["component_identifier"] - - # Look up component by its prefixed key (inlined from deleted get_component) - component: Tool | Resource | ResourceTemplate | Prompt | None = None - try: - if component_key.startswith("tool:"): - name, version_str = _parse_key_version(component_key[5:]) - version = VersionSpec(eq=version_str) if version_str else None - component = await server.get_tool(name, version) - elif component_key.startswith("resource:"): - uri, version_str = _parse_key_version(component_key[9:]) - version = VersionSpec(eq=version_str) if version_str else None - component = await server.get_resource(uri, version) - elif component_key.startswith("template:"): - uri, version_str = _parse_key_version(component_key[9:]) - version = VersionSpec(eq=version_str) if version_str else None - component = await server.get_resource_template(uri, version) - elif component_key.startswith("prompt:"): - name, version_str = _parse_key_version(component_key[7:]) - version = VersionSpec(eq=version_str) if version_str else None - component = await server.get_prompt(name, version) - except NotFoundError: - component = None - - if component is None: - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message=f"Component not found for task: {component_key}", - ) - ) - - # Build related-task metadata - related_task_meta = { - "io.modelcontextprotocol/related-task": { - "taskId": client_task_id, - } - } - - # Convert based on component type. - # Each branch merges related_task_meta with any existing _meta - # (e.g. fastmcp.wrap_result) rather than overwriting it. - if isinstance(component, Tool): - fastmcp_result = component.convert_result(raw_value) - mcp_result = fastmcp_result.to_mcp_result() - if isinstance(mcp_result, mcp.types.CallToolResult): - merged = {**(mcp_result.meta or {}), **related_task_meta} - mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - elif isinstance(mcp_result, tuple): - content, structured_content = mcp_result - mcp_result = mcp.types.CallToolResult( - content=content, - structuredContent=structured_content, - _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] - ) - else: - mcp_result = mcp.types.CallToolResult( - content=mcp_result, - _meta=related_task_meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] - ) - return mcp_result - - elif isinstance(component, Prompt): - fastmcp_result = component.convert_result(raw_value) - mcp_result = fastmcp_result.to_mcp_prompt_result() - merged = {**(mcp_result.meta or {}), **related_task_meta} - mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - return mcp_result - - elif isinstance(component, ResourceTemplate): - fastmcp_result = component.convert_result(raw_value) - mcp_result = fastmcp_result.to_mcp_result(component.uri_template) - merged = {**(mcp_result.meta or {}), **related_task_meta} - mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - return mcp_result - - elif isinstance(component, Resource): - fastmcp_result = component.convert_result(raw_value) - mcp_result = fastmcp_result.to_mcp_result(str(component.uri)) - merged = {**(mcp_result.meta or {}), **related_task_meta} - mcp_result._meta = merged # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - return mcp_result - - else: - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message=f"Internal error: Unknown component type: {type(component).__name__}", - ) - ) - - -async def tasks_list_handler( - server: FastMCP, params: dict[str, Any] -) -> ListTasksResult: - """Handle MCP 'tasks/list' request (SEP-1686). - - Note: With client-side tracking, this returns minimal info. - - Args: - server: FastMCP server instance - params: Request params (cursor, limit) - - Returns: - ListTasksResult: Response with tasks list and pagination - """ - # Return empty list - client tracks tasks locally - return ListTasksResult(tasks=[], nextCursor=None) - - -async def tasks_cancel_handler( - server: FastMCP, params: dict[str, Any] -) -> CancelTaskResult: - """Handle MCP 'tasks/cancel' request (SEP-1686). - - Cancels a running task, transitioning it to cancelled state. - - Args: - server: FastMCP server instance - params: Request params containing taskId - - Returns: - CancelTaskResult: Task status response showing cancelled state - """ - async with fastmcp.server.context.Context(fastmcp=server) as ctx: - client_task_id = params.get("taskId") - if not client_task_id: - raise McpError( - ErrorData( - code=INVALID_PARAMS, message="Missing required parameter: taskId" - ) - ) - - # Get session ID from Context - session_id = ctx.session_id - - # Get Docket instance - docket = server._docket - if docket is None: - raise McpError( - ErrorData( - code=INTERNAL_ERROR, - message="Background tasks require Docket", - ) - ) - - # Look up task execution and metadata - execution, created_at, poll_interval_ms = await _lookup_task_execution( - docket, session_id, client_task_id - ) - - # Cancel via Docket (now sets CANCELLED state natively) - # Note: We need to get task_key from execution.key for cancellation - await docket.cancel(execution.key) - - # Return task status with cancelled state - # createdAt is REQUIRED per SEP-1686 final spec (line 430) - # Per spec lines 447-448: SHOULD NOT include related-task metadata in tasks/cancel - return CancelTaskResult( - taskId=client_task_id, - status="cancelled", - createdAt=datetime.fromisoformat(created_at) - if created_at - else datetime.now(timezone.utc), - lastUpdatedAt=datetime.now(timezone.utc), - ttl=DEFAULT_TTL_MS, - pollInterval=poll_interval_ms, - statusMessage="Task cancelled", - ) diff --git a/src/fastmcp/server/tasks/routing.py b/src/fastmcp/server/tasks/routing.py deleted file mode 100644 index cb6812a87..000000000 --- a/src/fastmcp/server/tasks/routing.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Task routing helper for MCP components. - -Provides unified task mode enforcement and docket routing logic. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, Literal - -import mcp.types -from mcp.shared.exceptions import McpError -from mcp.types import METHOD_NOT_FOUND, ErrorData - -from fastmcp.server.tasks.config import TaskMeta -from fastmcp.server.tasks.handlers import submit_to_docket - -if TYPE_CHECKING: - from fastmcp.prompts.base import Prompt - from fastmcp.resources.base import Resource - from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.base import Tool - -TaskType = Literal["tool", "resource", "template", "prompt"] - - -async def check_background_task( - component: Tool | Resource | ResourceTemplate | Prompt, - task_type: TaskType, - arguments: dict[str, Any] | None = None, - task_meta: TaskMeta | None = None, -) -> mcp.types.CreateTaskResult | None: - """Check task mode and submit to background if requested. - - Args: - component: The MCP component - task_type: Type of task ("tool", "resource", "template", "prompt") - arguments: Arguments for tool/prompt/template execution - task_meta: Task execution metadata. If provided, execute as background task. - - Returns: - CreateTaskResult if submitted to docket, None for sync execution - - Raises: - McpError: If mode="required" but no task metadata, or mode="forbidden" - but task metadata is present - """ - task_config = component.task_config - - # Infer label from component - entity_label = f"{type(component).__name__} '{component.title or component.key}'" - - # Enforce mode="required" - must have task metadata - if task_config.mode == "required" and not task_meta: - raise McpError( - ErrorData( - code=METHOD_NOT_FOUND, - message=f"{entity_label} requires task-augmented execution", - ) - ) - - # Enforce mode="forbidden" - cannot be called with task metadata - if not task_config.supports_tasks() and task_meta: - raise McpError( - ErrorData( - code=METHOD_NOT_FOUND, - message=f"{entity_label} does not support task-augmented execution", - ) - ) - - # No task metadata - synchronous execution - if not task_meta: - return None - - # fn_key is expected to be set; fall back to component.key for direct calls - fn_key = task_meta.fn_key or component.key - return await submit_to_docket(task_type, fn_key, component, arguments, task_meta) diff --git a/src/fastmcp/server/tasks/subscriptions.py b/src/fastmcp/server/tasks/subscriptions.py deleted file mode 100644 index 772b82671..000000000 --- a/src/fastmcp/server/tasks/subscriptions.py +++ /dev/null @@ -1,222 +0,0 @@ -"""Task subscription helpers for sending MCP notifications (SEP-1686). - -Subscribes to Docket execution state changes and sends notifications/tasks/status -to clients when their tasks change state. - -This module requires fastmcp[tasks] (pydocket). It is only imported when docket is available. -""" - -from __future__ import annotations - -from contextlib import suppress -from datetime import datetime, timezone -from typing import TYPE_CHECKING - -from docket.execution import ExecutionState -from mcp.types import TaskStatusNotification, TaskStatusNotificationParams - -from fastmcp.server.tasks.config import DEFAULT_TTL_MS -from fastmcp.server.tasks.keys import parse_task_key -from fastmcp.server.tasks.requests import DOCKET_TO_MCP_STATE -from fastmcp.utilities.logging import get_logger - -if TYPE_CHECKING: - from docket import Docket - from docket.execution import Execution - from mcp.server.session import ServerSession - -logger = get_logger(__name__) - - -async def subscribe_to_task_updates( - task_id: str, - task_key: str, - session: ServerSession, - docket: Docket, - poll_interval_ms: int = 5000, -) -> None: - """Subscribe to Docket execution events and send MCP notifications. - - Per SEP-1686 lines 436-444, servers MAY send notifications/tasks/status - when task state changes. This is an optional optimization that reduces - client polling frequency. - - Args: - task_id: Client-visible task ID (server-generated UUID) - task_key: Internal Docket execution key (includes session, type, component) - session: MCP ServerSession for sending notifications - docket: Docket instance for subscribing to execution events - poll_interval_ms: Poll interval in milliseconds to include in notifications - """ - try: - execution = await docket.get_execution(task_key) - if execution is None: - logger.warning(f"No execution found for task {task_id}") - return - - # Subscribe to state and progress events from Docket - terminal_states = { - ExecutionState.COMPLETED, - ExecutionState.FAILED, - ExecutionState.CANCELLED, - } - async for event in execution.subscribe(): - if event["type"] == "state": - state = ExecutionState(event["state"]) - # Send notifications/tasks/status when state changes - await _send_status_notification( - session=session, - task_id=task_id, - task_key=task_key, - docket=docket, - state=state, - poll_interval_ms=poll_interval_ms, - ) - # Stop subscribing once the task reaches a terminal state - if state in terminal_states: - break - elif event["type"] == "progress": - # Send notification when progress message changes - await _send_progress_notification( - session=session, - task_id=task_id, - task_key=task_key, - docket=docket, - execution=execution, - poll_interval_ms=poll_interval_ms, - ) - - except Exception as e: - logger.warning(f"Subscription task failed for {task_id}: {e}", exc_info=True) - - -async def _send_status_notification( - session: ServerSession, - task_id: str, - task_key: str, - docket: Docket, - state: ExecutionState, - poll_interval_ms: int = 5000, -) -> None: - """Send notifications/tasks/status to client. - - Per SEP-1686 line 454: notification SHOULD NOT include related-task metadata - (taskId is already in params). - - Args: - session: MCP ServerSession - task_id: Client-visible task ID - task_key: Internal task key (for metadata lookup) - docket: Docket instance - state: Docket execution state (enum) - poll_interval_ms: Poll interval in milliseconds - """ - # Map Docket state to MCP status - state_map = DOCKET_TO_MCP_STATE - mcp_status = state_map.get(state, "failed") - - # Extract session_id from task_key for Redis lookup - key_parts = parse_task_key(task_key) - session_id = key_parts["session_id"] - - created_at_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:created_at") - async with docket.redis() as redis: - created_at_bytes = await redis.get(created_at_key) - - created_at = ( - created_at_bytes.decode("utf-8") - if created_at_bytes - else datetime.now(timezone.utc).isoformat() - ) - - # Build status message - status_message = None - if state == ExecutionState.COMPLETED: - status_message = "Task completed successfully" - elif state == ExecutionState.FAILED: - status_message = "Task failed" - elif state == ExecutionState.CANCELLED: - status_message = "Task cancelled" - - params_dict = { - "taskId": task_id, - "status": mcp_status, - "createdAt": created_at, - "lastUpdatedAt": datetime.now(timezone.utc).isoformat(), - "ttl": DEFAULT_TTL_MS, - "pollInterval": poll_interval_ms, - } - - if status_message: - params_dict["statusMessage"] = status_message - - # Create notification (no related-task metadata per spec line 454) - notification = TaskStatusNotification( - params=TaskStatusNotificationParams.model_validate(params_dict), - ) - - # Send notification (don't let failures break the subscription) - with suppress(Exception): - await session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - - -async def _send_progress_notification( - session: ServerSession, - task_id: str, - task_key: str, - docket: Docket, - execution: Execution, - poll_interval_ms: int = 5000, -) -> None: - """Send notifications/tasks/status when progress updates. - - Args: - session: MCP ServerSession - task_id: Client-visible task ID - task_key: Internal task key - docket: Docket instance - execution: Execution object with current progress - poll_interval_ms: Poll interval in milliseconds - """ - # Sync execution to get latest progress - await execution.sync() - - # Only send if there's a progress message - if not execution.progress or not execution.progress.message: - return - - # Map Docket state to MCP status - state_map = DOCKET_TO_MCP_STATE - mcp_status = state_map.get(execution.state, "failed") - - # Extract session_id from task_key for Redis lookup - key_parts = parse_task_key(task_key) - session_id = key_parts["session_id"] - - created_at_key = docket.key(f"fastmcp:task:{session_id}:{task_id}:created_at") - async with docket.redis() as redis: - created_at_bytes = await redis.get(created_at_key) - - created_at = ( - created_at_bytes.decode("utf-8") - if created_at_bytes - else datetime.now(timezone.utc).isoformat() - ) - - params_dict = { - "taskId": task_id, - "status": mcp_status, - "createdAt": created_at, - "lastUpdatedAt": datetime.now(timezone.utc).isoformat(), - "ttl": DEFAULT_TTL_MS, - "pollInterval": poll_interval_ms, - "statusMessage": execution.progress.message, - } - - # Create and send notification - notification = TaskStatusNotification( - params=TaskStatusNotificationParams.model_validate(params_dict), - ) - - with suppress(Exception): - await session.send_notification(notification) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] diff --git a/src/fastmcp/server/telemetry.py b/src/fastmcp/server/telemetry.py deleted file mode 100644 index 6c263225d..000000000 --- a/src/fastmcp/server/telemetry.py +++ /dev/null @@ -1,131 +0,0 @@ -"""Server-side telemetry helpers.""" - -from collections.abc import Generator -from contextlib import contextmanager - -from mcp.server.lowlevel.server import request_ctx -from opentelemetry.context import Context -from opentelemetry.trace import Span, SpanKind, Status, StatusCode - -from fastmcp.telemetry import extract_trace_context, get_tracer - - -def get_auth_span_attributes() -> dict[str, str]: - """Get auth attributes for the current request, if authenticated.""" - from fastmcp.server.dependencies import get_access_token - - attrs: dict[str, str] = {} - try: - token = get_access_token() - if token: - if token.client_id: - attrs["enduser.id"] = token.client_id - if token.scopes: - attrs["enduser.scope"] = " ".join(token.scopes) - except RuntimeError: - pass - return attrs - - -def get_session_span_attributes() -> dict[str, str]: - """Get session attributes for the current request.""" - from fastmcp.server.dependencies import get_context - - attrs: dict[str, str] = {} - try: - ctx = get_context() - if ctx.request_context is not None and ctx.session_id is not None: - attrs["mcp.session.id"] = ctx.session_id - except RuntimeError: - pass - return attrs - - -def _get_parent_trace_context() -> Context | None: - """Get parent trace context from request meta for distributed tracing.""" - try: - req_ctx = request_ctx.get() - if req_ctx and hasattr(req_ctx, "meta") and req_ctx.meta: - return extract_trace_context(dict(req_ctx.meta)) - except LookupError: - pass - return None - - -@contextmanager -def server_span( - name: str, - method: str, - server_name: str, - component_type: str, - component_key: str, - resource_uri: str | None = None, -) -> Generator[Span, None, None]: - """Create a SERVER span with standard MCP attributes and auth context. - - Automatically records any exception on the span and sets error status. - """ - tracer = get_tracer() - with tracer.start_as_current_span( - name, - context=_get_parent_trace_context(), - kind=SpanKind.SERVER, - ) as span: - attrs: dict[str, str] = { - # RPC semantic conventions - "rpc.system": "mcp", - "rpc.service": server_name, - "rpc.method": method, - # MCP semantic conventions - "mcp.method.name": method, - # FastMCP-specific attributes - "fastmcp.server.name": server_name, - "fastmcp.component.type": component_type, - "fastmcp.component.key": component_key, - **get_auth_span_attributes(), - **get_session_span_attributes(), - } - if resource_uri is not None: - attrs["mcp.resource.uri"] = resource_uri - span.set_attributes(attrs) - try: - yield span - except Exception as e: - span.record_exception(e) - span.set_status(Status(StatusCode.ERROR)) - raise - - -@contextmanager -def delegate_span( - name: str, - provider_type: str, - component_key: str, -) -> Generator[Span, None, None]: - """Create an INTERNAL span for provider delegation. - - Used by FastMCPProvider when delegating to mounted servers. - Automatically records any exception on the span and sets error status. - """ - tracer = get_tracer() - with tracer.start_as_current_span(f"delegate {name}") as span: - span.set_attributes( - { - "fastmcp.provider.type": provider_type, - "fastmcp.component.key": component_key, - } - ) - try: - yield span - except Exception as e: - span.record_exception(e) - span.set_status(Status(StatusCode.ERROR)) - raise - - -__all__ = [ - "delegate_span", - "get_auth_span_attributes", - "get_session_span_attributes", - "server_span", -] diff --git a/src/fastmcp/server/transforms/__init__.py b/src/fastmcp/server/transforms/__init__.py deleted file mode 100644 index 411a2e0f8..000000000 --- a/src/fastmcp/server/transforms/__init__.py +++ /dev/null @@ -1,240 +0,0 @@ -"""Transform system for component transformations. - -Transforms modify components (tools, resources, prompts). List operations use a pure -function pattern where transforms receive sequences and return transformed sequences. -Get operations use a middleware pattern with `call_next` to chain lookups. - -Unlike middleware (which operates on requests), transforms are observable by the -system for task registration, tag filtering, and component introspection. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.transforms import Namespace - - server = FastMCP("Server") - mount = server.mount(other_server) - mount.add_transform(Namespace("api")) # Tools become api_toolname - ``` -""" - -from __future__ import annotations - -from collections.abc import Awaitable, Sequence -from typing import TYPE_CHECKING, Protocol - -from fastmcp.utilities.versions import VersionSpec - -if TYPE_CHECKING: - from fastmcp.prompts.base import Prompt - from fastmcp.resources.base import Resource - from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.base import Tool - - -# Get methods use Protocol to express keyword-only version parameter -class GetToolNext(Protocol): - """Protocol for get_tool call_next functions.""" - - def __call__( - self, name: str, *, version: VersionSpec | None = None - ) -> Awaitable[Tool | None]: ... - - -class GetResourceNext(Protocol): - """Protocol for get_resource call_next functions.""" - - def __call__( - self, uri: str, *, version: VersionSpec | None = None - ) -> Awaitable[Resource | None]: ... - - -class GetResourceTemplateNext(Protocol): - """Protocol for get_resource_template call_next functions.""" - - def __call__( - self, uri: str, *, version: VersionSpec | None = None - ) -> Awaitable[ResourceTemplate | None]: ... - - -class GetPromptNext(Protocol): - """Protocol for get_prompt call_next functions.""" - - def __call__( - self, name: str, *, version: VersionSpec | None = None - ) -> Awaitable[Prompt | None]: ... - - -class Transform: - """Base class for component transformations. - - List operations use a pure function pattern: transforms receive sequences - and return transformed sequences. Get operations use a middleware pattern - with `call_next` to chain lookups. - - Example: - ```python - class MyTransform(Transform): - async def list_tools(self, tools): - return [transform(t) for t in tools] # Transform sequence - - async def get_tool(self, name, call_next, *, version=None): - original = self.reverse_name(name) # Map to original name - tool = await call_next(original, version=version) # Get from downstream - return transform(tool) if tool else None - ``` - """ - - def __repr__(self) -> str: - return f"{self.__class__.__name__}()" - - # ------------------------------------------------------------------------- - # Tools - # ------------------------------------------------------------------------- - - async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - """List tools with transformation applied. - - Args: - tools: Sequence of tools to transform. - - Returns: - Transformed sequence of tools. - """ - return tools - - async def get_tool( - self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None - ) -> Tool | None: - """Get a tool by name. - - Args: - name: The requested tool name (may be transformed). - call_next: Callable to get tool from downstream. - version: Optional version filter to apply. - - Returns: - The tool if found, None otherwise. - """ - return await call_next(name, version=version) - - # ------------------------------------------------------------------------- - # Resources - # ------------------------------------------------------------------------- - - async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]: - """List resources with transformation applied. - - Args: - resources: Sequence of resources to transform. - - Returns: - Transformed sequence of resources. - """ - return resources - - async def get_resource( - self, - uri: str, - call_next: GetResourceNext, - *, - version: VersionSpec | None = None, - ) -> Resource | None: - """Get a resource by URI. - - Args: - uri: The requested resource URI (may be transformed). - call_next: Callable to get resource from downstream. - version: Optional version filter to apply. - - Returns: - The resource if found, None otherwise. - """ - return await call_next(uri, version=version) - - # ------------------------------------------------------------------------- - # Resource Templates - # ------------------------------------------------------------------------- - - async def list_resource_templates( - self, templates: Sequence[ResourceTemplate] - ) -> Sequence[ResourceTemplate]: - """List resource templates with transformation applied. - - Args: - templates: Sequence of resource templates to transform. - - Returns: - Transformed sequence of resource templates. - """ - return templates - - async def get_resource_template( - self, - uri: str, - call_next: GetResourceTemplateNext, - *, - version: VersionSpec | None = None, - ) -> ResourceTemplate | None: - """Get a resource template by URI. - - Args: - uri: The requested template URI (may be transformed). - call_next: Callable to get template from downstream. - version: Optional version filter to apply. - - Returns: - The resource template if found, None otherwise. - """ - return await call_next(uri, version=version) - - # ------------------------------------------------------------------------- - # Prompts - # ------------------------------------------------------------------------- - - async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: - """List prompts with transformation applied. - - Args: - prompts: Sequence of prompts to transform. - - Returns: - Transformed sequence of prompts. - """ - return prompts - - async def get_prompt( - self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None - ) -> Prompt | None: - """Get a prompt by name. - - Args: - name: The requested prompt name (may be transformed). - call_next: Callable to get prompt from downstream. - version: Optional version filter to apply. - - Returns: - The prompt if found, None otherwise. - """ - return await call_next(name, version=version) - - -# Re-export built-in transforms (must be after Transform class to avoid circular imports) -from fastmcp.server.transforms.visibility import Visibility, is_enabled # noqa: E402 -from fastmcp.server.transforms.namespace import Namespace # noqa: E402 -from fastmcp.server.transforms.prompts_as_tools import PromptsAsTools # noqa: E402 -from fastmcp.server.transforms.resources_as_tools import ResourcesAsTools # noqa: E402 -from fastmcp.server.transforms.tool_transform import ToolTransform # noqa: E402 -from fastmcp.server.transforms.version_filter import VersionFilter # noqa: E402 - -__all__ = [ - "Namespace", - "PromptsAsTools", - "ResourcesAsTools", - "ToolTransform", - "Transform", - "VersionFilter", - "VersionSpec", - "Visibility", - "is_enabled", -] diff --git a/src/fastmcp/server/transforms/catalog.py b/src/fastmcp/server/transforms/catalog.py deleted file mode 100644 index 936fcd9b3..000000000 --- a/src/fastmcp/server/transforms/catalog.py +++ /dev/null @@ -1,245 +0,0 @@ -"""Base class for transforms that need to read the real component catalog. - -Some transforms replace ``list_tools()`` output with synthetic components -(e.g. a search interface) while still needing access to the *real* -(auth-filtered) catalog at call time. ``CatalogTransform`` provides the -bypass machinery so subclasses can call ``get_tool_catalog()`` without -triggering their own replacement logic. - -Re-entrancy problem -------------------- - -When a synthetic tool handler calls ``get_tool_catalog()``, that calls -``ctx.fastmcp.list_tools()`` which re-enters the transform pipeline — -including *this* transform's ``list_tools()``. If the subclass overrides -``list_tools()`` directly, the re-entrant call would hit the subclass's -replacement logic again (returning synthetic tools instead of the real -catalog). A ``super()`` call can't prevent this because Python can't -short-circuit a method after ``super()`` returns. - -Solution: ``CatalogTransform`` owns ``list_tools()`` and uses a -per-instance ``ContextVar`` to detect re-entrant calls. During bypass, -it passes through to the base ``Transform.list_tools()`` (a no-op). -Otherwise, it delegates to ``transform_tools()`` — the subclass hook -where replacement logic lives. Same pattern for resources, prompts, -and resource templates. - -This is *not* the same as the ``Provider._list_tools()`` convention -(which produces raw components with no arguments). ``transform_tools()`` -receives the current catalog and returns a transformed version. The -distinct name avoids confusion between the two patterns. - -Usage:: - - class MyTransform(CatalogTransform): - async def transform_tools(self, tools): - return [self._make_search_tool()] - - def _make_search_tool(self): - async def search(ctx: Context = None): - real_tools = await self.get_tool_catalog(ctx) - ... - return Tool.from_function(fn=search, name="search") -""" - -from __future__ import annotations - -import itertools -from collections.abc import Sequence -from contextvars import ContextVar -from typing import TYPE_CHECKING - -from fastmcp.server.transforms import Transform -from fastmcp.utilities.versions import dedupe_with_versions - -if TYPE_CHECKING: - from fastmcp.prompts.base import Prompt - from fastmcp.resources.base import Resource - from fastmcp.resources.template import ResourceTemplate - from fastmcp.server.context import Context - from fastmcp.tools.base import Tool - -_instance_counter = itertools.count() - - -class CatalogTransform(Transform): - """Transform that needs access to the real component catalog. - - Subclasses override ``transform_tools()`` / ``transform_resources()`` - / ``transform_prompts()`` / ``transform_resource_templates()`` - instead of the ``list_*()`` methods. The base class owns - ``list_*()`` and handles re-entrant bypass automatically — subclasses - never see re-entrant calls from ``get_*_catalog()``. - - The ``get_*_catalog()`` methods fetch the real (auth-filtered) catalog - by temporarily setting a bypass flag so that this transform's - ``list_*()`` passes through without calling the subclass hook. - """ - - def __init__(self) -> None: - self._instance_id: int = next(_instance_counter) - self._bypass: ContextVar[bool] = ContextVar( - f"_catalog_bypass_{self._instance_id}", default=False - ) - - # ------------------------------------------------------------------ - # list_* (bypass-aware — subclasses override transform_* instead) - # ------------------------------------------------------------------ - - async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - if self._bypass.get(): - return await super().list_tools(tools) - return await self.transform_tools(tools) - - async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]: - if self._bypass.get(): - return await super().list_resources(resources) - return await self.transform_resources(resources) - - async def list_resource_templates( - self, templates: Sequence[ResourceTemplate] - ) -> Sequence[ResourceTemplate]: - if self._bypass.get(): - return await super().list_resource_templates(templates) - return await self.transform_resource_templates(templates) - - async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: - if self._bypass.get(): - return await super().list_prompts(prompts) - return await self.transform_prompts(prompts) - - # ------------------------------------------------------------------ - # Subclass hooks (override these, not list_*) - # ------------------------------------------------------------------ - - async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - """Transform the tool catalog. - - Override this method to replace, filter, or augment the tool listing. - The default implementation passes through unchanged. - - Do NOT override ``list_tools()`` directly — the base class uses it - to handle re-entrant bypass when ``get_tool_catalog()`` reads the - real catalog. - """ - return tools - - async def transform_resources( - self, resources: Sequence[Resource] - ) -> Sequence[Resource]: - """Transform the resource catalog. - - Override this method to replace, filter, or augment the resource listing. - The default implementation passes through unchanged. - - Do NOT override ``list_resources()`` directly — the base class uses it - to handle re-entrant bypass when ``get_resource_catalog()`` reads the - real catalog. - """ - return resources - - async def transform_resource_templates( - self, templates: Sequence[ResourceTemplate] - ) -> Sequence[ResourceTemplate]: - """Transform the resource template catalog. - - Override this method to replace, filter, or augment the template listing. - The default implementation passes through unchanged. - - Do NOT override ``list_resource_templates()`` directly — the base class - uses it to handle re-entrant bypass when - ``get_resource_template_catalog()`` reads the real catalog. - """ - return templates - - async def transform_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: - """Transform the prompt catalog. - - Override this method to replace, filter, or augment the prompt listing. - The default implementation passes through unchanged. - - Do NOT override ``list_prompts()`` directly — the base class uses it - to handle re-entrant bypass when ``get_prompt_catalog()`` reads the - real catalog. - """ - return prompts - - # ------------------------------------------------------------------ - # Catalog accessors - # ------------------------------------------------------------------ - - async def get_tool_catalog( - self, ctx: Context, *, run_middleware: bool = True - ) -> Sequence[Tool]: - """Fetch the real tool catalog, bypassing this transform. - - The result is deduplicated by name so that only the highest version - of each tool is returned — matching what protocol handlers expose - on the wire. - - Args: - ctx: The current request context. - run_middleware: Whether to run middleware on the inner call. - Defaults to True because this is typically called from a - tool handler where list_tools middleware has not yet run. - """ - token = self._bypass.set(True) - try: - tools = await ctx.fastmcp.list_tools(run_middleware=run_middleware) - finally: - self._bypass.reset(token) - return dedupe_with_versions(tools, lambda t: t.name) - - async def get_resource_catalog( - self, ctx: Context, *, run_middleware: bool = True - ) -> Sequence[Resource]: - """Fetch the real resource catalog, bypassing this transform. - - Args: - ctx: The current request context. - run_middleware: Whether to run middleware on the inner call. - Defaults to True because this is typically called from a - tool handler where list_resources middleware has not yet run. - """ - token = self._bypass.set(True) - try: - return await ctx.fastmcp.list_resources(run_middleware=run_middleware) - finally: - self._bypass.reset(token) - - async def get_prompt_catalog( - self, ctx: Context, *, run_middleware: bool = True - ) -> Sequence[Prompt]: - """Fetch the real prompt catalog, bypassing this transform. - - Args: - ctx: The current request context. - run_middleware: Whether to run middleware on the inner call. - Defaults to True because this is typically called from a - tool handler where list_prompts middleware has not yet run. - """ - token = self._bypass.set(True) - try: - return await ctx.fastmcp.list_prompts(run_middleware=run_middleware) - finally: - self._bypass.reset(token) - - async def get_resource_template_catalog( - self, ctx: Context, *, run_middleware: bool = True - ) -> Sequence[ResourceTemplate]: - """Fetch the real resource template catalog, bypassing this transform. - - Args: - ctx: The current request context. - run_middleware: Whether to run middleware on the inner call. - Defaults to True because this is typically called from a - tool handler where list_resource_templates middleware has - not yet run. - """ - token = self._bypass.set(True) - try: - return await ctx.fastmcp.list_resource_templates( - run_middleware=run_middleware - ) - finally: - self._bypass.reset(token) diff --git a/src/fastmcp/server/transforms/namespace.py b/src/fastmcp/server/transforms/namespace.py deleted file mode 100644 index f1a219ac7..000000000 --- a/src/fastmcp/server/transforms/namespace.py +++ /dev/null @@ -1,193 +0,0 @@ -"""Namespace transform for prefixing component names.""" - -from __future__ import annotations - -import re -from collections.abc import Sequence -from typing import TYPE_CHECKING - -from fastmcp.server.transforms import ( - GetPromptNext, - GetResourceNext, - GetResourceTemplateNext, - GetToolNext, - Transform, -) -from fastmcp.utilities.versions import VersionSpec - -if TYPE_CHECKING: - from fastmcp.prompts.base import Prompt - from fastmcp.resources.base import Resource - from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.base import Tool - -# Pattern for matching URIs: protocol://path -_URI_PATTERN = re.compile(r"^([^:]+://)(.*?)$") - - -class Namespace(Transform): - """Prefixes component names with a namespace. - - - Tools: name → namespace_name - - Prompts: name → namespace_name - - Resources: protocol://path → protocol://namespace/path - - Resource Templates: same as resources - - Example: - ```python - transform = Namespace("math") - # Tool "add" becomes "math_add" - # Resource "file://data.txt" becomes "file://math/data.txt" - ``` - """ - - def __init__(self, prefix: str) -> None: - """Initialize Namespace transform. - - Args: - prefix: The namespace prefix to apply. - """ - self._prefix = prefix - self._name_prefix = f"{prefix}_" - - def __repr__(self) -> str: - return f"Namespace({self._prefix!r})" - - # ------------------------------------------------------------------------- - # Name transformation helpers - # ------------------------------------------------------------------------- - - def _transform_name(self, name: str) -> str: - """Apply namespace prefix to a name.""" - return f"{self._name_prefix}{name}" - - def _reverse_name(self, name: str) -> str | None: - """Remove namespace prefix from a name, or None if no match.""" - if name.startswith(self._name_prefix): - return name[len(self._name_prefix) :] - return None - - # ------------------------------------------------------------------------- - # URI transformation helpers - # ------------------------------------------------------------------------- - - def _transform_uri(self, uri: str) -> str: - """Apply namespace to a URI: protocol://path → protocol://namespace/path.""" - match = _URI_PATTERN.match(uri) - if match: - protocol, path = match.groups() - return f"{protocol}{self._prefix}/{path}" - return uri - - def _reverse_uri(self, uri: str) -> str | None: - """Remove namespace from a URI, or None if no match.""" - match = _URI_PATTERN.match(uri) - if match: - protocol, path = match.groups() - prefix = f"{self._prefix}/" - if path.startswith(prefix): - return f"{protocol}{path[len(prefix) :]}" - return None - return None - - # ------------------------------------------------------------------------- - # Tools - # ------------------------------------------------------------------------- - - async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - """Prefix tool names with namespace.""" - return [ - t.model_copy(update={"name": self._transform_name(t.name)}) for t in tools - ] - - async def get_tool( - self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None - ) -> Tool | None: - """Get tool by namespaced name.""" - original = self._reverse_name(name) - if original is None: - return None - tool = await call_next(original, version=version) - if tool: - return tool.model_copy(update={"name": name}) - return None - - # ------------------------------------------------------------------------- - # Resources - # ------------------------------------------------------------------------- - - async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]: - """Add namespace path segment to resource URIs.""" - return [ - r.model_copy(update={"uri": self._transform_uri(str(r.uri))}) - for r in resources - ] - - async def get_resource( - self, - uri: str, - call_next: GetResourceNext, - *, - version: VersionSpec | None = None, - ) -> Resource | None: - """Get resource by namespaced URI.""" - original = self._reverse_uri(uri) - if original is None: - return None - resource = await call_next(original, version=version) - if resource: - return resource.model_copy(update={"uri": uri}) - return None - - # ------------------------------------------------------------------------- - # Resource Templates - # ------------------------------------------------------------------------- - - async def list_resource_templates( - self, templates: Sequence[ResourceTemplate] - ) -> Sequence[ResourceTemplate]: - """Add namespace path segment to template URIs.""" - return [ - t.model_copy(update={"uri_template": self._transform_uri(t.uri_template)}) - for t in templates - ] - - async def get_resource_template( - self, - uri: str, - call_next: GetResourceTemplateNext, - *, - version: VersionSpec | None = None, - ) -> ResourceTemplate | None: - """Get resource template by namespaced URI.""" - original = self._reverse_uri(uri) - if original is None: - return None - template = await call_next(original, version=version) - if template: - return template.model_copy( - update={"uri_template": self._transform_uri(template.uri_template)} - ) - return None - - # ------------------------------------------------------------------------- - # Prompts - # ------------------------------------------------------------------------- - - async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: - """Prefix prompt names with namespace.""" - return [ - p.model_copy(update={"name": self._transform_name(p.name)}) for p in prompts - ] - - async def get_prompt( - self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None - ) -> Prompt | None: - """Get prompt by namespaced name.""" - original = self._reverse_name(name) - if original is None: - return None - prompt = await call_next(original, version=version) - if prompt: - return prompt.model_copy(update={"name": name}) - return None diff --git a/src/fastmcp/server/transforms/prompts_as_tools.py b/src/fastmcp/server/transforms/prompts_as_tools.py deleted file mode 100644 index 078b250d0..000000000 --- a/src/fastmcp/server/transforms/prompts_as_tools.py +++ /dev/null @@ -1,169 +0,0 @@ -"""Transform that exposes prompts as tools. - -This transform generates tools for listing and getting prompts, enabling -clients that only support tools to access prompt functionality. - -The generated tools route through `ctx.fastmcp` at runtime, so all server -middleware (auth, visibility, rate limiting, etc.) applies to prompt -operations exactly as it would for direct `prompts/get` calls. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.transforms import PromptsAsTools - - mcp = FastMCP("Server") - mcp.add_transform(PromptsAsTools(mcp)) - # Now has list_prompts and get_prompt tools - ``` -""" - -from __future__ import annotations - -import json -from collections.abc import Sequence -from typing import TYPE_CHECKING, Annotated, Any - -from mcp.types import TextContent - -from fastmcp.server.dependencies import get_context -from fastmcp.server.transforms import GetToolNext, Transform -from fastmcp.tools.base import Tool -from fastmcp.utilities.versions import VersionSpec - -if TYPE_CHECKING: - from fastmcp.server.providers.base import Provider - - -class PromptsAsTools(Transform): - """Transform that adds tools for listing and getting prompts. - - Generates two tools: - - `list_prompts`: Lists all prompts - - `get_prompt`: Gets a specific prompt with optional arguments - - The generated tools route through the server at runtime, so auth, - middleware, and visibility apply automatically. - - This transform should be applied to a FastMCP server instance, not - a raw Provider, because the generated tools need the server's - middleware chain for auth and visibility filtering. - - Example: - ```python - mcp = FastMCP("Server") - mcp.add_transform(PromptsAsTools(mcp)) - # Now has list_prompts and get_prompt tools - ``` - """ - - def __init__(self, provider: Provider) -> None: - from fastmcp.server.server import FastMCP - - if not isinstance(provider, FastMCP): - raise TypeError( - "PromptsAsTools requires a FastMCP server instance, not a" - f" {type(provider).__name__}. The generated tools route through" - " the server's middleware chain at runtime for auth and" - " visibility. Pass your FastMCP server: PromptsAsTools(mcp)" - ) - self._provider = provider - - def __repr__(self) -> str: - return f"PromptsAsTools({self._provider!r})" - - async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - """Add prompt tools to the tool list.""" - return [ - *tools, - self._make_list_prompts_tool(), - self._make_get_prompt_tool(), - ] - - async def get_tool( - self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None - ) -> Tool | None: - """Get a tool by name, including generated prompt tools.""" - if name == "list_prompts": - return self._make_list_prompts_tool() - if name == "get_prompt": - return self._make_get_prompt_tool() - return await call_next(name, version=version) - - def _make_list_prompts_tool(self) -> Tool: - """Create the list_prompts tool.""" - - async def list_prompts() -> str: - """List all available prompts. - - Returns JSON with prompt metadata including name, description, - and optional arguments. - """ - ctx = get_context() - prompts = await ctx.fastmcp.list_prompts() - - result: list[dict[str, Any]] = [] - for p in prompts: - result.append( - { - "name": p.name, - "description": p.description, - "arguments": [ - { - "name": arg.name, - "description": arg.description, - "required": arg.required, - } - for arg in (p.arguments or []) - ], - } - ) - - return json.dumps(result, indent=2) - - return Tool.from_function(fn=list_prompts) - - def _make_get_prompt_tool(self) -> Tool: - """Create the get_prompt tool.""" - - async def get_prompt( - name: Annotated[str, "The name of the prompt to get"], - arguments: Annotated[ - dict[str, Any] | None, - "Optional arguments for the prompt", - ] = None, - ) -> str: - """Get a prompt by name with optional arguments. - - Returns the rendered prompt as JSON with a messages array. - Arguments should be provided as a dict mapping argument names - to values. - """ - ctx = get_context() - result = await ctx.fastmcp.render_prompt(name, arguments=arguments or {}) - return _format_prompt_result(result) - - return Tool.from_function(fn=get_prompt) - - -def _format_prompt_result(result: Any) -> str: - """Format PromptResult for tool output. - - Returns JSON with the messages array. Preserves embedded resources - as structured JSON objects. - """ - messages = [] - for msg in result.messages: - if isinstance(msg.content, TextContent): - content = msg.content.text - else: - content = msg.content.model_dump(mode="json", exclude_none=True) - - messages.append( - { - "role": msg.role, - "content": content, - } - ) - - return json.dumps({"messages": messages}, indent=2) diff --git a/src/fastmcp/server/transforms/resources_as_tools.py b/src/fastmcp/server/transforms/resources_as_tools.py deleted file mode 100644 index 780e513b7..000000000 --- a/src/fastmcp/server/transforms/resources_as_tools.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Transform that exposes resources as tools. - -This transform generates tools for listing and reading resources, enabling -clients that only support tools to access resource functionality. - -The generated tools route through `ctx.fastmcp` at runtime, so all server -middleware (auth, visibility, rate limiting, etc.) applies to resource -operations exactly as it would for direct `resources/read` calls. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.transforms import ResourcesAsTools - - mcp = FastMCP("Server") - mcp.add_transform(ResourcesAsTools(mcp)) - # Now has list_resources and read_resource tools - ``` -""" - -from __future__ import annotations - -import base64 -import json -from collections.abc import Sequence -from typing import TYPE_CHECKING, Annotated, Any - -from mcp.types import ToolAnnotations - -from fastmcp.server.dependencies import get_context -from fastmcp.server.transforms import GetToolNext, Transform -from fastmcp.tools.base import Tool -from fastmcp.utilities.versions import VersionSpec - -_DEFAULT_ANNOTATIONS = ToolAnnotations(readOnlyHint=True) - -if TYPE_CHECKING: - from fastmcp.server.providers.base import Provider - - -class ResourcesAsTools(Transform): - """Transform that adds tools for listing and reading resources. - - Generates two tools: - - `list_resources`: Lists all resources and templates - - `read_resource`: Reads a resource by URI - - The generated tools route through the server at runtime, so auth, - middleware, and visibility apply automatically. - - This transform should be applied to a FastMCP server instance, not - a raw Provider, because the generated tools need the server's - middleware chain for auth and visibility filtering. - - Example: - ```python - mcp = FastMCP("Server") - mcp.add_transform(ResourcesAsTools(mcp)) - # Now has list_resources and read_resource tools - ``` - """ - - def __init__(self, provider: Provider) -> None: - from fastmcp.server.server import FastMCP - - if not isinstance(provider, FastMCP): - raise TypeError( - "ResourcesAsTools requires a FastMCP server instance, not a" - f" {type(provider).__name__}. The generated tools route through" - " the server's middleware chain at runtime for auth and" - " visibility. Pass your FastMCP server: ResourcesAsTools(mcp)" - ) - self._provider = provider - - def __repr__(self) -> str: - return f"ResourcesAsTools({self._provider!r})" - - async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - """Add resource tools to the tool list.""" - return [ - *tools, - self._make_list_resources_tool(), - self._make_read_resource_tool(), - ] - - async def get_tool( - self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None - ) -> Tool | None: - """Get a tool by name, including generated resource tools.""" - if name == "list_resources": - return self._make_list_resources_tool() - if name == "read_resource": - return self._make_read_resource_tool() - return await call_next(name, version=version) - - def _make_list_resources_tool(self) -> Tool: - """Create the list_resources tool.""" - - async def list_resources() -> str: - """List all available resources and resource templates. - - Returns JSON with resource metadata. Static resources have a - 'uri' field, while templates have a 'uri_template' field with - placeholders like {name}. - """ - ctx = get_context() - resources = await ctx.fastmcp.list_resources() - templates = await ctx.fastmcp.list_resource_templates() - - result: list[dict[str, Any]] = [] - - for r in resources: - result.append( - { - "uri": str(r.uri), - "name": r.name, - "description": r.description, - "mime_type": r.mime_type, - } - ) - - for t in templates: - result.append( - { - "uri_template": t.uri_template, - "name": t.name, - "description": t.description, - } - ) - - return json.dumps(result, indent=2) - - return Tool.from_function(fn=list_resources, annotations=_DEFAULT_ANNOTATIONS) - - def _make_read_resource_tool(self) -> Tool: - """Create the read_resource tool.""" - - async def read_resource( - uri: Annotated[str, "The URI of the resource to read"], - ) -> str: - """Read a resource by its URI. - - For static resources, provide the exact URI. For templated - resources, provide the URI with template parameters filled in. - - Returns the resource content as a string. Binary content is - base64-encoded. - """ - ctx = get_context() - result = await ctx.fastmcp.read_resource(uri) - return _format_result(result) - - return Tool.from_function(fn=read_resource, annotations=_DEFAULT_ANNOTATIONS) - - -def _format_result(result: Any) -> str: - """Format ResourceResult for tool output. - - Single text content is returned as-is. Single binary content is - base64-encoded. Multiple contents are JSON-encoded. - """ - if len(result.contents) == 1: - content = result.contents[0].content - if isinstance(content, bytes): - return base64.b64encode(content).decode() - return content - - return json.dumps( - [ - { - "content": ( - c.content - if isinstance(c.content, str) - else base64.b64encode(c.content).decode() - ), - "mime_type": c.mime_type, - } - for c in result.contents - ] - ) diff --git a/src/fastmcp/server/transforms/search/__init__.py b/src/fastmcp/server/transforms/search/__init__.py deleted file mode 100644 index 756244f23..000000000 --- a/src/fastmcp/server/transforms/search/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Search transforms for tool discovery. - -Search transforms collapse a large tool catalog into a search interface, -letting LLMs discover tools on demand instead of seeing the full list. - -Example: - ```python - from fastmcp import FastMCP - from fastmcp.server.transforms.search import RegexSearchTransform - - mcp = FastMCP("Server") - mcp.add_transform(RegexSearchTransform()) - # list_tools now returns only search_tools + call_tool - ``` -""" - -from fastmcp.server.transforms.search.base import ( - SearchResultSerializer, - serialize_tools_for_output_json, - serialize_tools_for_output_markdown, -) -from fastmcp.server.transforms.search.bm25 import BM25SearchTransform -from fastmcp.server.transforms.search.regex import RegexSearchTransform - -__all__ = [ - "BM25SearchTransform", - "RegexSearchTransform", - "SearchResultSerializer", - "serialize_tools_for_output_json", - "serialize_tools_for_output_markdown", -] diff --git a/src/fastmcp/server/transforms/search/base.py b/src/fastmcp/server/transforms/search/base.py deleted file mode 100644 index 7368d62f5..000000000 --- a/src/fastmcp/server/transforms/search/base.py +++ /dev/null @@ -1,269 +0,0 @@ -"""Base class for search transforms. - -Search transforms replace ``list_tools()`` output with a small set of -synthetic tools — a search tool and a call-tool proxy — so LLMs can -discover tools on demand instead of receiving the full catalog. - -All concrete search transforms (``RegexSearchTransform``, -``BM25SearchTransform``, etc.) inherit from ``BaseSearchTransform`` and -implement ``_make_search_tool()`` and ``_search()`` to provide their -specific search strategy. - -Example:: - - from fastmcp import FastMCP - from fastmcp.server.transforms.search import RegexSearchTransform - - mcp = FastMCP("Server") - - @mcp.tool - def add(a: int, b: int) -> int: ... - - @mcp.tool - def multiply(x: float, y: float) -> float: ... - - # Clients now see only ``search_tools`` and ``call_tool``. - # The original tools are discoverable via search. - mcp.add_transform(RegexSearchTransform()) -""" - -from abc import abstractmethod -from collections.abc import Awaitable, Callable, Sequence -from typing import Annotated, Any - -from fastmcp.server.context import Context -from fastmcp.server.transforms import GetToolNext -from fastmcp.server.transforms.catalog import CatalogTransform -from fastmcp.tools.base import Tool, ToolResult -from fastmcp.utilities.versions import VersionSpec - - -def _extract_searchable_text(tool: Tool) -> str: - """Combine tool name, description, and parameter info into searchable text.""" - parts = [tool.name] - if tool.description: - parts.append(tool.description) - - schema = tool.parameters - if schema: - properties = schema.get("properties", {}) - for param_name, param_info in properties.items(): - parts.append(param_name) - if isinstance(param_info, dict): - desc = param_info.get("description", "") - if desc: - parts.append(desc) - - return " ".join(parts) - - -def serialize_tools_for_output_json(tools: Sequence[Tool]) -> list[dict[str, Any]]: - """Serialize tools to the same dict format as ``list_tools`` output.""" - return [ - tool.to_mcp_tool().model_dump(mode="json", exclude_none=True) for tool in tools - ] - - -SearchResultSerializer = Callable[[Sequence[Tool]], Any | Awaitable[Any]] - - -async def _invoke_serializer( - serializer: SearchResultSerializer, tools: Sequence[Tool] -) -> Any: - """Call a serializer and await the result if it returns a coroutine.""" - result = serializer(tools) - if isinstance(result, Awaitable): - return await result - return result - - -def _union_type(branches: list[Any]) -> str: - branch_types = list(dict.fromkeys(_schema_type(b) for b in branches)) - if "null" not in branch_types: - return " | ".join(branch_types) if branch_types else "any" - non_null = [b for b in branch_types if b != "null"] - if not non_null: - return "null" - return f"{' | '.join(non_null)}?" - - -def _schema_type(schema: Any) -> str: - # Intentionally heuristic: the goal is a concise readable label, not a - # complete type system. Malformed schemas (e.g. {"type": ""}) → "any". - if not isinstance(schema, dict): - return "any" - t = schema.get("type") - if isinstance(t, str) and t: - if t == "array": - return f"{_schema_type(schema.get('items'))}[]" - if t == "null": - return "null" - return t - if "$ref" in schema: - return "object" - if "anyOf" in schema: - return _union_type(schema["anyOf"]) - if "oneOf" in schema: - return _union_type(schema["oneOf"]) - if "allOf" in schema: - # allOf = intersection / Pydantic composed model → always an object - return "object" - return "object" if "properties" in schema else "any" - - -def _schema_section(schema: dict[str, Any] | None, title: str) -> list[str]: - lines = [f"**{title}**"] - if not isinstance(schema, dict): - lines.append("- `value` (any)") - return lines - - props = schema.get("properties") - raw_required = schema.get("required") - req = set(raw_required) if isinstance(raw_required, list) else set() - if props is None: - # Not a properties-based schema — treat as a single unnamed value. - lines.append(f"- `value` ({_schema_type(schema)})") - return lines - if not props: - # Object schema with no properties — zero-argument tool. - lines.append("*(no parameters)*") - return lines - - for name, field in props.items(): - required = ", required" if name in req else "" - lines.append(f"- `{name}` ({_schema_type(field)}{required})") - return lines - - -def serialize_tools_for_output_markdown(tools: Sequence[Tool]) -> str: - """Serialize tools to compact markdown, using ~65-70% fewer tokens than JSON.""" - if not tools: - return "No tools matched the query." - blocks: list[str] = [] - for tool in tools: - lines = [f"### {tool.name}"] - if tool.description: - lines.extend(["", tool.description.strip()]) - lines.extend(["", *_schema_section(tool.parameters, "Parameters")]) - if tool.output_schema is not None: - lines.extend(["", *_schema_section(tool.output_schema, "Returns")]) - blocks.append("\n".join(lines)) - return "\n\n".join(blocks) - - -class BaseSearchTransform(CatalogTransform): - """Replace the tool listing with a search interface. - - When this transform is active, ``list_tools()`` returns only: - - * Any tools listed in ``always_visible`` (pinned). - * A **search tool** that finds tools matching a query. - * A **call_tool** proxy that executes tools discovered via search. - - Hidden tools remain callable — ``get_tool()`` delegates unknown - names downstream, so direct calls and the call-tool proxy both work. - - Search results respect the full auth pipeline: middleware, visibility - transforms, and component-level auth checks all apply. - - Args: - max_results: Maximum number of tools returned per search. - always_visible: Tool names that stay in the ``list_tools`` - output alongside the synthetic search/call tools. - search_tool_name: Name of the generated search tool. - call_tool_name: Name of the generated call-tool proxy. - """ - - def __init__( - self, - *, - max_results: int = 5, - always_visible: list[str] | None = None, - search_tool_name: str = "search_tools", - call_tool_name: str = "call_tool", - search_result_serializer: SearchResultSerializer | None = None, - ) -> None: - super().__init__() - self._max_results = max_results - self._always_visible = set(always_visible or []) - self._search_tool_name = search_tool_name - self._call_tool_name = call_tool_name - self._search_result_serializer: SearchResultSerializer = ( - search_result_serializer or serialize_tools_for_output_json - ) - - # ------------------------------------------------------------------ - # Transform interface - # ------------------------------------------------------------------ - - async def transform_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - """Replace the catalog with pinned + synthetic search/call tools.""" - pinned = [t for t in tools if t.name in self._always_visible] - return [*pinned, self._make_search_tool(), self._make_call_tool()] - - async def get_tool( - self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None - ) -> Tool | None: - """Intercept synthetic tool names; delegate everything else.""" - if name == self._search_tool_name: - return self._make_search_tool() - if name == self._call_tool_name: - return self._make_call_tool() - return await call_next(name, version=version) - - # ------------------------------------------------------------------ - # Synthetic tools - # ------------------------------------------------------------------ - - @abstractmethod - def _make_search_tool(self) -> Tool: - """Create the search tool. Subclasses define the parameter schema.""" - ... - - def _make_call_tool(self) -> Tool: - """Create the call_tool proxy that executes discovered tools.""" - transform = self - - async def call_tool( - name: Annotated[str, "The name of the tool to call"], - arguments: Annotated[ - dict[str, Any] | None, "Arguments to pass to the tool" - ] = None, - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> ToolResult: - """Call a tool by name with the given arguments. - - Use this to execute tools discovered via search_tools. - """ - if name in {transform._call_tool_name, transform._search_tool_name}: - raise ValueError( - f"'{name}' is a synthetic search tool and cannot be called via the call_tool proxy" - ) - return await ctx.fastmcp.call_tool(name, arguments) - - return Tool.from_function(fn=call_tool, name=self._call_tool_name) - - # ------------------------------------------------------------------ - # Serialization - # ------------------------------------------------------------------ - - async def _render_results(self, tools: Sequence[Tool]) -> Any: - return await _invoke_serializer(self._search_result_serializer, tools) - - # ------------------------------------------------------------------ - # Catalog access - # ------------------------------------------------------------------ - - async def _get_visible_tools(self, ctx: Context) -> Sequence[Tool]: - """Get the auth-filtered tool catalog, excluding pinned tools.""" - tools = await self.get_tool_catalog(ctx) - return [t for t in tools if t.name not in self._always_visible] - - # ------------------------------------------------------------------ - # Abstract search - # ------------------------------------------------------------------ - - @abstractmethod - async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]: - """Search the given tools and return matches.""" - ... diff --git a/src/fastmcp/server/transforms/search/bm25.py b/src/fastmcp/server/transforms/search/bm25.py deleted file mode 100644 index 447db8cac..000000000 --- a/src/fastmcp/server/transforms/search/bm25.py +++ /dev/null @@ -1,144 +0,0 @@ -"""BM25-based search transform.""" - -import hashlib -import math -import re -from collections.abc import Sequence -from typing import Annotated, Any - -from fastmcp.server.context import Context -from fastmcp.server.transforms.search.base import ( - BaseSearchTransform, - SearchResultSerializer, - _extract_searchable_text, -) -from fastmcp.tools.base import Tool - - -def _tokenize(text: str) -> list[str]: - """Lowercase, split on non-alphanumeric, filter short tokens.""" - return [t for t in re.split(r"[^a-z0-9]+", text.lower()) if len(t) > 1] - - -class _BM25Index: - """Self-contained BM25 Okapi index.""" - - def __init__(self, k1: float = 1.5, b: float = 0.75) -> None: - self.k1 = k1 - self.b = b - self._doc_tokens: list[list[str]] = [] - self._doc_lengths: list[int] = [] - self._avg_dl: float = 0.0 - self._df: dict[str, int] = {} - self._tf: list[dict[str, int]] = [] - self._n: int = 0 - - def build(self, documents: list[str]) -> None: - self._doc_tokens = [_tokenize(doc) for doc in documents] - self._doc_lengths = [len(tokens) for tokens in self._doc_tokens] - self._n = len(documents) - self._avg_dl = sum(self._doc_lengths) / self._n if self._n else 0.0 - - self._df = {} - self._tf = [] - for tokens in self._doc_tokens: - tf: dict[str, int] = {} - seen: set[str] = set() - for token in tokens: - tf[token] = tf.get(token, 0) + 1 - if token not in seen: - self._df[token] = self._df.get(token, 0) + 1 - seen.add(token) - self._tf.append(tf) - - def query(self, text: str, top_k: int) -> list[int]: - """Return indices of top_k documents sorted by BM25 score.""" - query_tokens = _tokenize(text) - if not query_tokens or not self._n: - return [] - - scores: list[float] = [0.0] * self._n - for token in query_tokens: - if token not in self._df: - continue - idf = math.log( - (self._n - self._df[token] + 0.5) / (self._df[token] + 0.5) + 1.0 - ) - for i in range(self._n): - tf = self._tf[i].get(token, 0) - if tf == 0: - continue - dl = self._doc_lengths[i] - numerator = tf * (self.k1 + 1) - denominator = tf + self.k1 * (1 - self.b + self.b * dl / self._avg_dl) - scores[i] += idf * numerator / denominator - - ranked = sorted(range(self._n), key=lambda i: scores[i], reverse=True) - return [i for i in ranked[:top_k] if scores[i] > 0] - - -def _catalog_hash(tools: Sequence[Tool]) -> str: - """SHA256 hash of sorted tool searchable text for staleness detection.""" - key = "|".join(sorted(_extract_searchable_text(t) for t in tools)) - return hashlib.sha256(key.encode()).hexdigest() - - -class BM25SearchTransform(BaseSearchTransform): - """Search transform using BM25 Okapi relevance ranking. - - Maintains an in-memory index that is lazily rebuilt when the tool - catalog changes (detected via a hash of tool names). - """ - - def __init__( - self, - *, - max_results: int = 5, - always_visible: list[str] | None = None, - search_tool_name: str = "search_tools", - call_tool_name: str = "call_tool", - search_result_serializer: SearchResultSerializer | None = None, - ) -> None: - super().__init__( - max_results=max_results, - always_visible=always_visible, - search_tool_name=search_tool_name, - call_tool_name=call_tool_name, - search_result_serializer=search_result_serializer, - ) - self._index = _BM25Index() - self._indexed_tools: Sequence[Tool] = () - self._last_hash: str = "" - - def _make_search_tool(self) -> Tool: - transform = self - - async def search_tools( - query: Annotated[str, "Natural language query to search for tools"], - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> str | list[dict[str, Any]]: - """Search for tools using natural language. - - Returns matching tool definitions ranked by relevance, - in the same format as list_tools. - """ - hidden = await transform._get_visible_tools(ctx) - results = await transform._search(hidden, query) - return await transform._render_results(results) - - return Tool.from_function(fn=search_tools, name=self._search_tool_name) - - async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]: - current_hash = _catalog_hash(tools) - if current_hash != self._last_hash: - documents = [_extract_searchable_text(t) for t in tools] - new_index = _BM25Index(self._index.k1, self._index.b) - new_index.build(documents) - self._index, self._indexed_tools, self._last_hash = ( - new_index, - tools, - current_hash, - ) - - indices = self._index.query(query, self._max_results) - return [self._indexed_tools[i] for i in indices] diff --git a/src/fastmcp/server/transforms/search/regex.py b/src/fastmcp/server/transforms/search/regex.py deleted file mode 100644 index f1b2d25a5..000000000 --- a/src/fastmcp/server/transforms/search/regex.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Regex-based search transform.""" - -import re -from collections.abc import Sequence -from typing import Annotated, Any - -from fastmcp.server.context import Context -from fastmcp.server.transforms.search.base import ( - BaseSearchTransform, - _extract_searchable_text, -) -from fastmcp.tools.base import Tool - - -class RegexSearchTransform(BaseSearchTransform): - """Search transform using regex pattern matching. - - Tools are matched against their name, description, and parameter - information using ``re.search`` with ``re.IGNORECASE``. - """ - - def _make_search_tool(self) -> Tool: - transform = self - - async def search_tools( - pattern: Annotated[ - str, - "Regex pattern to match against tool names, descriptions, and parameters", - ], - ctx: Context = None, # type: ignore[assignment] # ty:ignore[invalid-parameter-default] - ) -> str | list[dict[str, Any]]: - """Search for tools matching a regex pattern. - - Returns matching tool definitions in the same format as list_tools. - """ - hidden = await transform._get_visible_tools(ctx) - results = await transform._search(hidden, pattern) - return await transform._render_results(results) - - return Tool.from_function(fn=search_tools, name=self._search_tool_name) - - async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]: - try: - compiled = re.compile(query, re.IGNORECASE) - except re.error: - return [] - - matches: list[Tool] = [] - for tool in tools: - text = _extract_searchable_text(tool) - if compiled.search(text): - matches.append(tool) - if len(matches) >= self._max_results: - break - return matches diff --git a/src/fastmcp/server/transforms/tool_transform.py b/src/fastmcp/server/transforms/tool_transform.py deleted file mode 100644 index bd4f168cf..000000000 --- a/src/fastmcp/server/transforms/tool_transform.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Transform for applying tool transformations.""" - -from __future__ import annotations - -from collections.abc import Sequence -from typing import TYPE_CHECKING - -from fastmcp.server.transforms import GetToolNext, Transform -from fastmcp.tools.tool_transform import ToolTransformConfig -from fastmcp.utilities.versions import VersionSpec - -if TYPE_CHECKING: - from fastmcp.tools.base import Tool - - -class ToolTransform(Transform): - """Applies tool transformations to modify tool schemas. - - Wraps ToolTransformConfig to apply argument renames, schema changes, - hidden arguments, and other transformations at the transform level. - - Example: - ```python - transform = ToolTransform({ - "my_tool": ToolTransformConfig( - name="renamed_tool", - arguments={"old_arg": ArgTransformConfig(name="new_arg")} - ) - }) - ``` - """ - - def __init__(self, transforms: dict[str, ToolTransformConfig]) -> None: - """Initialize ToolTransform. - - Args: - transforms: Map of original tool name → transform config. - """ - self._transforms = transforms - - # Build reverse mapping: final_name → original_name - self._name_reverse: dict[str, str] = {} - for original_name, config in transforms.items(): - final_name = config.name if config.name else original_name - self._name_reverse[final_name] = original_name - - # Validate no duplicate target names - seen_targets: dict[str, str] = {} - for original_name, config in transforms.items(): - target = config.name if config.name else original_name - if target in seen_targets: - raise ValueError( - f"ToolTransform has duplicate target name {target!r}: " - f"both {seen_targets[target]!r} and {original_name!r} map to it" - ) - seen_targets[target] = original_name - - def __repr__(self) -> str: - names = list(self._transforms.keys()) - if len(names) <= 3: - return f"ToolTransform({names!r})" - return f"ToolTransform({names[:3]!r}... +{len(names) - 3} more)" - - async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - """Apply transforms to matching tools.""" - result: list[Tool] = [] - for tool in tools: - if tool.name in self._transforms: - transformed = self._transforms[tool.name].apply(tool) - result.append(transformed) - else: - result.append(tool) - return result - - async def get_tool( - self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None - ) -> Tool | None: - """Get tool by transformed name.""" - # Check if this name is a transformed name - original_name = self._name_reverse.get(name, name) - - # Get the original tool - tool = await call_next(original_name, version=version) - if tool is None: - return None - - # Apply transform if applicable - if original_name in self._transforms: - transformed = self._transforms[original_name].apply(tool) - # Only return if requested name matches transformed name - if transformed.name == name: - return transformed - return None - - # No transform, return as-is only if name matches - return tool if tool.name == name else None diff --git a/src/fastmcp/server/transforms/version_filter.py b/src/fastmcp/server/transforms/version_filter.py deleted file mode 100644 index 11a586248..000000000 --- a/src/fastmcp/server/transforms/version_filter.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Version filter transform for filtering components by version range.""" - -from __future__ import annotations - -from collections.abc import Sequence -from typing import TYPE_CHECKING - -from fastmcp.server.transforms import ( - GetPromptNext, - GetResourceNext, - GetResourceTemplateNext, - GetToolNext, - Transform, -) -from fastmcp.utilities.versions import VersionSpec - -if TYPE_CHECKING: - from fastmcp.prompts.base import Prompt - from fastmcp.resources.base import Resource - from fastmcp.resources.template import ResourceTemplate - from fastmcp.tools.base import Tool - - -class VersionFilter(Transform): - """Filters components by version range. - - When applied to a provider or server, components within the version range - are visible, and unversioned components are included by default. Within - that filtered set, the highest version of each component is exposed to - clients (standard deduplication behavior). Set - ``include_unversioned=False`` to exclude unversioned components. - - Parameters mirror comparison operators for clarity: - - # Versions < 3.0 (v1 and v2) - server.add_transform(VersionFilter(version_lt="3.0")) - - # Versions >= 2.0 and < 3.0 (only v2.x) - server.add_transform(VersionFilter(version_gte="2.0", version_lt="3.0")) - - Works with any version string - PEP 440 (1.0, 2.0) or dates (2025-01-01). - - Args: - version_gte: Versions >= this value pass through. - version_lt: Versions < this value pass through. - include_unversioned: Whether unversioned components (``version=None``) - should pass through the filter. Defaults to True. - """ - - def __init__( - self, - *, - version_gte: str | None = None, - version_lt: str | None = None, - include_unversioned: bool = True, - ) -> None: - if version_gte is None and version_lt is None: - raise ValueError( - "At least one of version_gte or version_lt must be specified" - ) - self.version_gte = version_gte - self.version_lt = version_lt - self.include_unversioned = include_unversioned - self._spec = VersionSpec(gte=version_gte, lt=version_lt) - - def __repr__(self) -> str: - parts = [] - if self.version_gte: - parts.append(f"version_gte={self.version_gte!r}") - if self.version_lt: - parts.append(f"version_lt={self.version_lt!r}") - if not self.include_unversioned: - parts.append("include_unversioned=False") - return f"VersionFilter({', '.join(parts)})" - - # ------------------------------------------------------------------------- - # Tools - # ------------------------------------------------------------------------- - - async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - return [ - t - for t in tools - if self._spec.matches(t.version, match_none=self.include_unversioned) - ] - - async def get_tool( - self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None - ) -> Tool | None: - return await call_next(name, version=self._spec.intersect(version)) - - # ------------------------------------------------------------------------- - # Resources - # ------------------------------------------------------------------------- - - async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]: - return [ - r - for r in resources - if self._spec.matches(r.version, match_none=self.include_unversioned) - ] - - async def get_resource( - self, - uri: str, - call_next: GetResourceNext, - *, - version: VersionSpec | None = None, - ) -> Resource | None: - return await call_next(uri, version=self._spec.intersect(version)) - - # ------------------------------------------------------------------------- - # Resource Templates - # ------------------------------------------------------------------------- - - async def list_resource_templates( - self, templates: Sequence[ResourceTemplate] - ) -> Sequence[ResourceTemplate]: - return [ - t - for t in templates - if self._spec.matches(t.version, match_none=self.include_unversioned) - ] - - async def get_resource_template( - self, - uri: str, - call_next: GetResourceTemplateNext, - *, - version: VersionSpec | None = None, - ) -> ResourceTemplate | None: - return await call_next(uri, version=self._spec.intersect(version)) - - # ------------------------------------------------------------------------- - # Prompts - # ------------------------------------------------------------------------- - - async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: - return [ - p - for p in prompts - if self._spec.matches(p.version, match_none=self.include_unversioned) - ] - - async def get_prompt( - self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None - ) -> Prompt | None: - return await call_next(name, version=self._spec.intersect(version)) diff --git a/src/fastmcp/server/transforms/visibility.py b/src/fastmcp/server/transforms/visibility.py deleted file mode 100644 index e95fd5845..000000000 --- a/src/fastmcp/server/transforms/visibility.py +++ /dev/null @@ -1,526 +0,0 @@ -"""Visibility transform for marking component visibility state. - -Each Visibility instance marks components via internal metadata. Multiple -visibility transforms can be stacked - later transforms override earlier ones. -Final filtering happens at the Provider level. -""" - -from __future__ import annotations - -from collections.abc import Sequence -from typing import TYPE_CHECKING, Any, Literal, TypeVar - -import mcp.types - -from fastmcp.resources.base import Resource -from fastmcp.resources.template import ResourceTemplate -from fastmcp.server.transforms import ( - GetPromptNext, - GetResourceNext, - GetResourceTemplateNext, - GetToolNext, - Transform, -) -from fastmcp.utilities.versions import VersionSpec - -if TYPE_CHECKING: - from fastmcp.prompts.base import Prompt - from fastmcp.server.context import Context - from fastmcp.tools.base import Tool - from fastmcp.utilities.components import FastMCPComponent - -T = TypeVar("T", bound="FastMCPComponent") - -# Visibility state stored at meta["fastmcp"]["_internal"]["visibility"] -_FASTMCP_KEY = "fastmcp" -_INTERNAL_KEY = "_internal" - - -class Visibility(Transform): - """Sets visibility state on matching components. - - Does NOT filter inline - just marks components with visibility state. - Later transforms in the chain can override earlier marks. - Final filtering happens at the Provider level after all transforms run. - - Example: - ```python - # Disable components tagged "internal" - Visibility(False, tags={"internal"}) - - # Re-enable specific tool (override earlier disable) - Visibility(True, names={"safe_tool"}) - - # Allowlist via composition: - Visibility(False, match_all=True) # disable everything - Visibility(True, tags={"public"}) # enable public - ``` - """ - - def __init__( - self, - enabled: bool, - *, - names: set[str] | None = None, - keys: set[str] | None = None, - version: VersionSpec | None = None, - tags: set[str] | None = None, - components: set[Literal["tool", "resource", "template", "prompt"]] - | None = None, - match_all: bool = False, - ) -> None: - """Initialize a visibility marker. - - Args: - enabled: If True, mark matching as enabled; if False, mark as disabled. - names: Component names or URIs to match. - keys: Component keys to match (e.g., {"tool:my_tool@v1"}). - version: Component version spec to match. Unversioned components (version=None) - will NOT match a version spec. - tags: Tags to match (component must have at least one). - components: Component types to match (e.g., {"tool", "prompt"}). - match_all: If True, matches all components regardless of other criteria. - """ - self._enabled = enabled - self.names = names - self.keys = keys - self.version = version - self.tags = tags # e.g., {"internal", "deprecated"} - self.components = components # e.g., {"tool", "prompt"} - self.match_all = match_all - - def __repr__(self) -> str: - action = "enable" if self._enabled else "disable" - if self.match_all: - return f"Visibility({self._enabled}, match_all=True)" - parts = [] - if self.names: - parts.append(f"names={set(self.names)}") - if self.keys: - parts.append(f"keys={set(self.keys)}") - if self.version: - parts.append(f"version={self.version!r}") - if self.components: - parts.append(f"components={set(self.components)}") - if self.tags: - parts.append(f"tags={set(self.tags)}") - if parts: - return f"Visibility({action}, {', '.join(parts)})" - return f"Visibility({action})" - - def _matches(self, component: FastMCPComponent) -> bool: - """Check if this transform applies to the component. - - All specified criteria must match (intersection semantics). - An empty rule (no criteria) matches nothing. - Use match_all=True to match everything. - - Args: - component: Component to check. - - Returns: - True if this transform should mark the component. - """ - # Match-all flag matches everything - if self.match_all: - return True - - # Empty criteria matches nothing (safe default) - if ( - self.names is None - and self.keys is None - and self.version is None - and self.components is None - and self.tags is None - ): - return False - - # Check component type if specified - if self.components is not None: - component_type = component.key.split(":")[ - 0 - ] # e.g., "tool" from "tool:foo@" - if component_type not in self.components: - return False - - # Check keys if specified (exact match only) - if self.keys is not None: - if component.key not in self.keys: - return False - - # Check names if specified - if self.names is not None: - # For resources, also check URI; for templates, check uri_template - matches_name = component.name in self.names - matches_uri = False - if isinstance(component, Resource): - matches_uri = str(component.uri) in self.names - elif isinstance(component, ResourceTemplate): - matches_uri = component.uri_template in self.names - if not (matches_name or matches_uri): - return False - - # Check version if specified - # Note: match_none=False means unversioned components don't match a version spec - if self.version is not None and not self.version.matches( - component.version, match_none=False - ): - return False - - # Check tags if specified (component must have at least one matching tag) - return self.tags is None or bool(component.tags & self.tags) - - def _mark_component(self, component: T) -> T: - """Set visibility state in component metadata if rule matches. - - Returns a copy of the component with updated metadata to avoid - mutating shared objects cached in providers. - """ - if not self._matches(component): - return component - - if component.meta is None: - new_meta = {_FASTMCP_KEY: {_INTERNAL_KEY: {"visibility": self._enabled}}} - else: - old_fastmcp = component.meta.get(_FASTMCP_KEY, {}) - old_internal = old_fastmcp.get(_INTERNAL_KEY, {}) - new_internal = {**old_internal, "visibility": self._enabled} - new_fastmcp = {**old_fastmcp, _INTERNAL_KEY: new_internal} - new_meta = {**component.meta, _FASTMCP_KEY: new_fastmcp} - return component.model_copy(update={"meta": new_meta}) - - # ------------------------------------------------------------------------- - # Transform methods (mark components, don't filter) - # ------------------------------------------------------------------------- - - async def list_tools(self, tools: Sequence[Tool]) -> Sequence[Tool]: - """Mark tools by visibility state.""" - return [self._mark_component(t) for t in tools] - - async def get_tool( - self, name: str, call_next: GetToolNext, *, version: VersionSpec | None = None - ) -> Tool | None: - """Mark tool if found.""" - tool = await call_next(name, version=version) - if tool is None: - return None - return self._mark_component(tool) - - # ------------------------------------------------------------------------- - # Resources - # ------------------------------------------------------------------------- - - async def list_resources(self, resources: Sequence[Resource]) -> Sequence[Resource]: - """Mark resources by visibility state.""" - return [self._mark_component(r) for r in resources] - - async def get_resource( - self, - uri: str, - call_next: GetResourceNext, - *, - version: VersionSpec | None = None, - ) -> Resource | None: - """Mark resource if found.""" - resource = await call_next(uri, version=version) - if resource is None: - return None - return self._mark_component(resource) - - # ------------------------------------------------------------------------- - # Resource Templates - # ------------------------------------------------------------------------- - - async def list_resource_templates( - self, templates: Sequence[ResourceTemplate] - ) -> Sequence[ResourceTemplate]: - """Mark resource templates by visibility state.""" - return [self._mark_component(t) for t in templates] - - async def get_resource_template( - self, - uri: str, - call_next: GetResourceTemplateNext, - *, - version: VersionSpec | None = None, - ) -> ResourceTemplate | None: - """Mark resource template if found.""" - template = await call_next(uri, version=version) - if template is None: - return None - return self._mark_component(template) - - # ------------------------------------------------------------------------- - # Prompts - # ------------------------------------------------------------------------- - - async def list_prompts(self, prompts: Sequence[Prompt]) -> Sequence[Prompt]: - """Mark prompts by visibility state.""" - return [self._mark_component(p) for p in prompts] - - async def get_prompt( - self, name: str, call_next: GetPromptNext, *, version: VersionSpec | None = None - ) -> Prompt | None: - """Mark prompt if found.""" - prompt = await call_next(name, version=version) - if prompt is None: - return None - return self._mark_component(prompt) - - -def is_enabled(component: FastMCPComponent) -> bool: - """Check if component is enabled. - - Returns True if: - - No visibility mark exists (default is enabled) - - Visibility mark is True - - Returns False if visibility mark is False. - - Args: - component: Component to check. - - Returns: - True if component should be enabled/visible to clients. - """ - meta = component.meta or {} - fastmcp = meta.get(_FASTMCP_KEY, {}) - internal = fastmcp.get(_INTERNAL_KEY, {}) - return internal.get("visibility", True) # Default True if not set - - -# ------------------------------------------------------------------------- -# Session visibility control -# ------------------------------------------------------------------------- - -if TYPE_CHECKING: - from fastmcp.server.context import Context - - -async def get_visibility_rules(context: Context) -> list[dict[str, Any]]: - """Load visibility rule dicts from session state.""" - return await context.get_state("_visibility_rules") or [] - - -async def save_visibility_rules( - context: Context, - rules: list[dict[str, Any]], - *, - components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, -) -> None: - """Save visibility rule dicts to session state and send notifications. - - Args: - context: The context to save rules for. - rules: The visibility rules to save. - components: Optional hint about which component types are affected. - If None, sends notifications for all types (safe default). - If provided, only sends notifications for specified types. - """ - await context.set_state("_visibility_rules", rules) - - # Send notifications based on components hint - # Note: MCP has no separate template notification - templates use ResourceListChangedNotification - if components is None or "tool" in components: - await context.send_notification(mcp.types.ToolListChangedNotification()) - if components is None or "resource" in components or "template" in components: - await context.send_notification(mcp.types.ResourceListChangedNotification()) - if components is None or "prompt" in components: - await context.send_notification(mcp.types.PromptListChangedNotification()) - - -def create_visibility_transforms(rules: list[dict[str, Any]]) -> list[Visibility]: - """Convert rule dicts to Visibility transforms.""" - transforms = [] - for params in rules: - version = None - if params.get("version"): - version_dict = params["version"] - version = VersionSpec( - gte=version_dict.get("gte"), - lt=version_dict.get("lt"), - eq=version_dict.get("eq"), - ) - transforms.append( - Visibility( - params["enabled"], - names=set(params["names"]) if params.get("names") else None, - keys=set(params["keys"]) if params.get("keys") else None, - version=version, - tags=set(params["tags"]) if params.get("tags") else None, - components=( - set(params["components"]) if params.get("components") else None - ), - match_all=params.get("match_all", False), - ) - ) - return transforms - - -async def get_session_transforms(context: Context) -> list[Visibility]: - """Get session-specific Visibility transforms from state store.""" - try: - # Will raise RuntimeError if no session available - _ = context.session_id - except RuntimeError: - return [] - - rules = await get_visibility_rules(context) - return create_visibility_transforms(rules) - - -async def enable_components( - context: Context, - *, - names: set[str] | None = None, - keys: set[str] | None = None, - version: VersionSpec | None = None, - tags: set[str] | None = None, - components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, - match_all: bool = False, -) -> None: - """Enable components matching criteria for this session only. - - Session rules override global transforms. Rules accumulate - each call - adds a new rule to the session. Later marks override earlier ones - (Visibility transform semantics). - - Sends notifications to this session only: ToolListChangedNotification, - ResourceListChangedNotification, and PromptListChangedNotification. - - Args: - context: The context for this session. - names: Component names or URIs to match. - keys: Component keys to match (e.g., {"tool:my_tool@v1"}). - version: Component version spec to match. - tags: Tags to match (component must have at least one). - components: Component types to match (e.g., {"tool", "prompt"}). - match_all: If True, matches all components regardless of other criteria. - """ - # Normalize empty sets to None (empty = match all) - components = components if components else None - - # Load current rules - rules = await get_visibility_rules(context) - - # Create new rule dict - rule: dict[str, Any] = { - "enabled": True, - "names": list(names) if names else None, - "keys": list(keys) if keys else None, - "version": ( - {"gte": version.gte, "lt": version.lt, "eq": version.eq} - if version - else None - ), - "tags": list(tags) if tags else None, - "components": list(components) if components else None, - "match_all": match_all, - } - - # Add and save (notifications sent by save_visibility_rules) - rules.append(rule) - await save_visibility_rules(context, rules, components=components) - - -async def disable_components( - context: Context, - *, - names: set[str] | None = None, - keys: set[str] | None = None, - version: VersionSpec | None = None, - tags: set[str] | None = None, - components: set[Literal["tool", "resource", "template", "prompt"]] | None = None, - match_all: bool = False, -) -> None: - """Disable components matching criteria for this session only. - - Session rules override global transforms. Rules accumulate - each call - adds a new rule to the session. Later marks override earlier ones - (Visibility transform semantics). - - Sends notifications to this session only: ToolListChangedNotification, - ResourceListChangedNotification, and PromptListChangedNotification. - - Args: - context: The context for this session. - names: Component names or URIs to match. - keys: Component keys to match (e.g., {"tool:my_tool@v1"}). - version: Component version spec to match. - tags: Tags to match (component must have at least one). - components: Component types to match (e.g., {"tool", "prompt"}). - match_all: If True, matches all components regardless of other criteria. - """ - # Normalize empty sets to None (empty = match all) - components = components if components else None - - # Load current rules - rules = await get_visibility_rules(context) - - # Create new rule dict - rule: dict[str, Any] = { - "enabled": False, - "names": list(names) if names else None, - "keys": list(keys) if keys else None, - "version": ( - {"gte": version.gte, "lt": version.lt, "eq": version.eq} - if version - else None - ), - "tags": list(tags) if tags else None, - "components": list(components) if components else None, - "match_all": match_all, - } - - # Add and save (notifications sent by save_visibility_rules) - rules.append(rule) - await save_visibility_rules(context, rules, components=components) - - -async def reset_visibility(context: Context) -> None: - """Clear all session visibility rules. - - Use this to reset session visibility back to global defaults. - - Sends notifications to this session only: ToolListChangedNotification, - ResourceListChangedNotification, and PromptListChangedNotification. - - Args: - context: The context for this session. - """ - await save_visibility_rules(context, []) - - -ComponentT = TypeVar("ComponentT", bound="FastMCPComponent") - - -async def apply_session_transforms( - components: Sequence[ComponentT], -) -> Sequence[ComponentT]: - """Apply session-specific visibility transforms to components. - - This helper applies session-level enable/disable rules by marking - components with their visibility state. Session transforms override - global transforms due to mark-based semantics (later marks win). - - Args: - components: The components to apply session transforms to. - - Returns: - The components with session transforms applied. - """ - from fastmcp.server.context import _current_context - - current_ctx = _current_context.get() - if current_ctx is None: - return components - - session_transforms = await get_session_transforms(current_ctx) - if not session_transforms: - return components - - # Apply each transform's marking to each component - result = list(components) - for transform in session_transforms: - result = [transform._mark_component(c) for c in result] - return result diff --git a/src/fastmcp/settings.py b/src/fastmcp/settings.py deleted file mode 100644 index 393b1ff2f..000000000 --- a/src/fastmcp/settings.py +++ /dev/null @@ -1,381 +0,0 @@ -from __future__ import annotations as _annotations - -import inspect -import os -from datetime import timedelta -from pathlib import Path -from typing import Annotated, Any, Literal - -from platformdirs import user_data_dir -from pydantic import Field, field_validator -from pydantic_settings import ( - BaseSettings, - SettingsConfigDict, -) - -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - -ENV_FILE = os.getenv("FASTMCP_ENV_FILE", ".env") - -LOG_LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - -MCP_LOG_LEVEL = Literal[ - "debug", "info", "notice", "warning", "error", "critical", "alert", "emergency" -] - -DuplicateBehavior = Literal["warn", "error", "replace", "ignore"] - -TEN_MB_IN_BYTES = 1024 * 1024 * 10 - - -class DocketSettings(BaseSettings): - """Docket worker configuration.""" - - model_config = SettingsConfigDict( - env_prefix="FASTMCP_DOCKET_", - extra="ignore", - ) - - name: Annotated[ - str, - Field( - description=inspect.cleandoc( - """ - Name for the Docket queue. All servers/workers sharing the same name - and backend URL will share a task queue. - """ - ), - ), - ] = "fastmcp" - - url: Annotated[ - str, - Field( - description=inspect.cleandoc( - """ - URL for the Docket backend. Supports: - - memory:// - In-memory backend (single process only) - - redis://host:port/db - Redis/Valkey backend (distributed, multi-process) - - Example: redis://localhost:6379/0 - - Default is memory:// for single-process scenarios. Use Redis or Valkey - when coordinating tasks across multiple processes (e.g., additional - workers via the fastmcp tasks CLI). - """ - ), - ), - ] = "memory://" - - worker_name: Annotated[ - str | None, - Field( - description=inspect.cleandoc( - """ - Name for the Docket worker. If None, Docket will auto-generate - a unique worker name. - """ - ), - ), - ] = None - - concurrency: Annotated[ - int, - Field( - description=inspect.cleandoc( - """ - Maximum number of tasks the worker can process concurrently. - """ - ), - ), - ] = 10 - - redelivery_timeout: Annotated[ - timedelta, - Field( - description=inspect.cleandoc( - """ - Task redelivery timeout. If a worker doesn't complete - a task within this time, the task will be redelivered to another - worker. - """ - ), - ), - ] = timedelta(seconds=300) - - reconnection_delay: Annotated[ - timedelta, - Field( - description=inspect.cleandoc( - """ - Delay between reconnection attempts when the worker - loses connection to the Docket backend. - """ - ), - ), - ] = timedelta(seconds=5) - - minimum_check_interval: Annotated[ - timedelta, - Field( - description=inspect.cleandoc( - """ - How frequently the worker polls for new tasks. Lower - values reduce latency for task pickup at the cost of - more CPU usage. The default of 50ms is a good balance; - increase for high-volume production deployments where - tasks are long-running. - """ - ), - ), - ] = timedelta(milliseconds=50) - - -class Settings(BaseSettings): - """FastMCP settings.""" - - model_config = SettingsConfigDict( - env_prefix="FASTMCP_", - env_file=ENV_FILE, - extra="ignore", - env_nested_delimiter="__", - nested_model_default_partial_update=True, - validate_assignment=True, - ) - - def get_setting(self, attr: str) -> Any: - """ - Get a setting. If the setting contains one or more `__`, it will be - treated as a nested setting. - """ - settings = self - while "__" in attr: - parent_attr, attr = attr.split("__", 1) - if not hasattr(settings, parent_attr): - raise AttributeError(f"Setting {parent_attr} does not exist.") - settings = getattr(settings, parent_attr) - return getattr(settings, attr) - - def set_setting(self, attr: str, value: Any) -> None: - """ - Set a setting. If the setting contains one or more `__`, it will be - treated as a nested setting. - """ - settings = self - while "__" in attr: - parent_attr, attr = attr.split("__", 1) - if not hasattr(settings, parent_attr): - raise AttributeError(f"Setting {parent_attr} does not exist.") - settings = getattr(settings, parent_attr) - setattr(settings, attr, value) - - home: Path = Path(user_data_dir("fastmcp", appauthor=False)) - - test_mode: bool = False - - log_enabled: bool = True - log_level: LOG_LEVEL = "INFO" - - @field_validator("log_level", mode="before") - @classmethod - def normalize_log_level(cls, v): - if isinstance(v, str): - return v.upper() - return v - - docket: DocketSettings = DocketSettings() - - enable_rich_logging: Annotated[ - bool, - Field( - description=inspect.cleandoc( - """ - If True, will use rich formatting for log output. If False, - will use standard Python logging without rich formatting. - """ - ) - ), - ] = True - - enable_rich_tracebacks: Annotated[ - bool, - Field( - description=inspect.cleandoc( - """ - If True, will use rich tracebacks for logging. - """ - ) - ), - ] = True - - deprecation_warnings: Annotated[ - bool, - Field( - description=inspect.cleandoc( - """ - Whether to show deprecation warnings. You can completely reset - Python's warning behavior by running `warnings.resetwarnings()`. - Note this will NOT apply to deprecation warnings from the - settings class itself. - """, - ) - ), - ] = True - - client_raise_first_exceptiongroup_error: Annotated[ - bool, - Field( - description=inspect.cleandoc( - """ - Many MCP components operate in anyio taskgroups, and raise - ExceptionGroups instead of exceptions. If this setting is True, FastMCP Clients - will `raise` the first error in any ExceptionGroup instead of raising - the ExceptionGroup as a whole. This is useful for debugging, but may - mask other errors. - """ - ), - ), - ] = True - - client_init_timeout: Annotated[ - float | None, - Field( - description="The timeout for the client's initialization handshake, in seconds. Set to None or 0 to disable.", - ), - ] = None - - client_disconnect_timeout: Annotated[ - float, - Field( - description="Maximum time to wait for a clean disconnect before giving up, in seconds.", - ), - ] = 5 - - # Transport settings - transport: Literal["stdio", "http", "sse", "streamable-http"] = "stdio" - - # HTTP settings - host: str = "127.0.0.1" - port: int = 8000 - sse_path: str = "/sse" - message_path: str = "/messages/" - streamable_http_path: str = "/mcp" - debug: bool = False - - # error handling - mask_error_details: Annotated[ - bool, - Field( - description=inspect.cleandoc( - """ - If True, error details from user-supplied functions (tool, resource, prompt) - will be masked before being sent to clients. Only error messages from explicitly - raised ToolError, ResourceError, or PromptError will be included in responses. - If False (default), all error details will be included in responses, but prefixed - with appropriate context. - """ - ), - ), - ] = False - - client_log_level: Annotated[ - MCP_LOG_LEVEL | None, - Field( - description=inspect.cleandoc( - """ - Default minimum log level for messages sent to MCP clients. - When set, log messages below this level are suppressed. - Individual clients can override this per-session using the - MCP logging/setLevel request. - """ - ), - ), - ] = None - - strict_input_validation: Annotated[ - bool, - Field( - description=inspect.cleandoc( - """ - If True, tool inputs are strictly validated against the input - JSON schema. For example, providing the string \"10\" to an - integer field will raise an error. If False, compatible inputs - will be coerced to match the schema, which can increase - compatibility. For example, providing the string \"10\" to an - integer field will be coerced to 10. Defaults to False. - """ - ), - ), - ] = False - - server_dependencies: list[str] = Field( - default_factory=list, - description="List of dependencies to install in the server environment", - ) - - # StreamableHTTP settings - json_response: bool = False - stateless_http: bool = ( - False # If True, uses true stateless mode (new transport per request) - ) - - mounted_components_raise_on_load_error: Annotated[ - bool, - Field( - description=inspect.cleandoc( - """ - If True, errors encountered when loading mounted components (tools, resources, prompts) - will be raised instead of logged as warnings. This is useful for debugging - but will interrupt normal operation. - """ - ), - ), - ] = False - - show_server_banner: Annotated[ - bool, - Field( - description=inspect.cleandoc( - """ - If True, the server banner will be displayed when running the server. - This setting can be overridden by the --no-banner CLI flag or by - passing show_banner=False to server.run(). - Set to False via FASTMCP_SHOW_SERVER_BANNER=false to suppress the banner. - """ - ), - ), - ] = True - - check_for_updates: Annotated[ - Literal["stable", "prerelease", "off"], - Field( - description=inspect.cleandoc( - """ - Controls update checking when displaying the CLI banner. - - "stable": Check for stable releases only (default) - - "prerelease": Also check for pre-release versions (alpha, beta, rc) - - "off": Disable update checking entirely - Set via FASTMCP_CHECK_FOR_UPDATES environment variable. - """ - ), - ), - ] = "stable" - - decorator_mode: Annotated[ - Literal["function", "object"], - Field( - description=inspect.cleandoc( - """ - Controls what decorators (@tool, @resource, @prompt) return. - - - "function" (default): Decorators return the original function unchanged. - The function remains callable and is registered with the server normally. - - "object" (deprecated): Decorators return component objects (FunctionTool, - FunctionResource, FunctionPrompt). This was the default behavior in v2 and - will be removed in a future version. - """ - ), - ), - ] = "function" diff --git a/src/fastmcp/telemetry.py b/src/fastmcp/telemetry.py deleted file mode 100644 index 0965b8b71..000000000 --- a/src/fastmcp/telemetry.py +++ /dev/null @@ -1,122 +0,0 @@ -"""OpenTelemetry instrumentation for FastMCP. - -This module provides native OpenTelemetry integration for FastMCP servers and clients. -It uses only the opentelemetry-api package, so telemetry is a no-op unless the user -installs an OpenTelemetry SDK and configures exporters. - -Example usage with SDK: - ```python - from opentelemetry import trace - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import ConsoleSpanExporter, SimpleSpanProcessor - - # Configure the SDK (user responsibility) - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) - trace.set_tracer_provider(provider) - - # Now FastMCP will emit traces - from fastmcp import FastMCP - mcp = FastMCP("my-server") - ``` -""" - -from typing import Any - -from opentelemetry import context as otel_context -from opentelemetry import propagate, trace -from opentelemetry.context import Context -from opentelemetry.trace import Span, Status, StatusCode, Tracer -from opentelemetry.trace import get_tracer as otel_get_tracer - -INSTRUMENTATION_NAME = "fastmcp" - -TRACE_PARENT_KEY = "traceparent" -TRACE_STATE_KEY = "tracestate" - - -def get_tracer(version: str | None = None) -> Tracer: - """Get the FastMCP tracer for creating spans. - - Args: - version: Optional version string for the instrumentation - - Returns: - A tracer instance. Returns a no-op tracer if no SDK is configured. - """ - return otel_get_tracer(INSTRUMENTATION_NAME, version) - - -def inject_trace_context( - meta: dict[str, Any] | None = None, -) -> dict[str, Any] | None: - """Inject current trace context into a meta dict for MCP request propagation. - - Args: - meta: Optional existing meta dict to merge with trace context - - Returns: - A new dict containing the original meta (if any) plus trace context keys, - or None if no trace context to inject and meta was None - """ - carrier: dict[str, str] = {} - propagate.inject(carrier) - - trace_meta: dict[str, Any] = {} - if "traceparent" in carrier: - trace_meta[TRACE_PARENT_KEY] = carrier["traceparent"] - if "tracestate" in carrier: - trace_meta[TRACE_STATE_KEY] = carrier["tracestate"] - - if trace_meta: - return {**(meta or {}), **trace_meta} - return meta - - -def record_span_error(span: Span, exception: BaseException) -> None: - """Record an exception on a span and set error status.""" - span.record_exception(exception) - span.set_status(Status(StatusCode.ERROR)) - - -def extract_trace_context(meta: dict[str, Any] | None) -> Context: - """Extract trace context from an MCP request meta dict. - - If already in a valid trace (e.g., from HTTP propagation), the existing - trace context is preserved and meta is not used. - - Args: - meta: The meta dict from an MCP request (ctx.request_context.meta) - - Returns: - An OpenTelemetry Context with the extracted trace context, - or the current context if no trace context found or already in a trace - """ - # Don't override existing trace context (e.g., from HTTP propagation) - current_span = trace.get_current_span() - if current_span.get_span_context().is_valid: - return otel_context.get_current() - - if not meta: - return otel_context.get_current() - - carrier: dict[str, str] = {} - if TRACE_PARENT_KEY in meta: - carrier["traceparent"] = str(meta[TRACE_PARENT_KEY]) - if TRACE_STATE_KEY in meta: - carrier["tracestate"] = str(meta[TRACE_STATE_KEY]) - - if carrier: - return propagate.extract(carrier) - return otel_context.get_current() - - -__all__ = [ - "INSTRUMENTATION_NAME", - "TRACE_PARENT_KEY", - "TRACE_STATE_KEY", - "extract_trace_context", - "get_tracer", - "inject_trace_context", - "record_span_error", -] diff --git a/src/fastmcp/tools/__init__.py b/src/fastmcp/tools/__init__.py deleted file mode 100644 index 64360b2aa..000000000 --- a/src/fastmcp/tools/__init__.py +++ /dev/null @@ -1,20 +0,0 @@ -import sys - -from .function_tool import FunctionTool, tool -from .base import Tool, ToolResult -from .tool_transform import forward, forward_raw - -# Backward compat: tool.py was renamed to base.py to stop Pyright from resolving -# `from fastmcp.tools import tool` as the submodule instead of the decorator function. -# This shim keeps `from fastmcp.tools.tool import Tool` working at runtime. -# Safe to remove once we're confident no external code imports from the old path. -sys.modules[f"{__name__}.tool"] = sys.modules[f"{__name__}.base"] - -__all__ = [ - "FunctionTool", - "Tool", - "ToolResult", - "forward", - "forward_raw", - "tool", -] diff --git a/src/fastmcp/tools/base.py b/src/fastmcp/tools/base.py deleted file mode 100644 index b704016fa..000000000 --- a/src/fastmcp/tools/base.py +++ /dev/null @@ -1,597 +0,0 @@ -from __future__ import annotations - -import warnings -from collections.abc import Callable -from typing import ( - TYPE_CHECKING, - Annotated, - Any, - ClassVar, - TypeAlias, - overload, -) - -import mcp.types -import pydantic_core -from mcp.shared.tool_name_validation import validate_and_warn_tool_name -from mcp.types import ( - CallToolResult, - ContentBlock, - Icon, - TextContent, - ToolAnnotations, - ToolExecution, -) -from mcp.types import Tool as MCPTool -from pydantic import BaseModel, Field, model_validator -from pydantic.json_schema import SkipJsonSchema - -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.tasks.config import TaskConfig, TaskMeta -from fastmcp.utilities.components import FastMCPComponent -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import ( - Audio, - File, - Image, - NotSet, - NotSetT, -) - -try: - from prefab_ui.app import PrefabApp as _PrefabApp - from prefab_ui.components.base import Component as _PrefabComponent - - _HAS_PREFAB = True -except ImportError: - _HAS_PREFAB = False - -if TYPE_CHECKING: - from docket import Docket - from docket.execution import Execution - - from fastmcp.tools.function_tool import FunctionTool - from fastmcp.tools.tool_transform import ArgTransform, TransformedTool - -# Re-export from function_tool module - -logger = get_logger(__name__) - - -ToolResultSerializerType: TypeAlias = Callable[[Any], str] - - -def default_serializer(data: Any) -> str: - return pydantic_core.to_json(data, fallback=str).decode() - - -class ToolResult(BaseModel): - content: list[ContentBlock] = Field( - description="List of content blocks for the tool result" - ) - structured_content: dict[str, Any] | None = Field( - default=None, description="Structured content matching the tool's output schema" - ) - meta: dict[str, Any] | None = Field( - default=None, description="Runtime metadata about the tool execution" - ) - - def __init__( - self, - content: list[ContentBlock] | Any | None = None, - structured_content: dict[str, Any] | Any | None = None, - meta: dict[str, Any] | None = None, - ): - if content is None and structured_content is None: - raise ValueError("Either content or structured_content must be provided") - elif content is None: - content = structured_content - - converted_content: list[ContentBlock] = _convert_to_content(result=content) - - if structured_content is not None: - # Convert Prefab types to their wire-format envelope before - # generic serialization, so the renderer gets the right shape. - if _HAS_PREFAB: - if isinstance(structured_content, _PrefabApp): - structured_content = _prefab_to_json(structured_content) - elif isinstance(structured_content, _PrefabComponent): - structured_content = _prefab_to_json( - _PrefabApp(view=structured_content) - ) - - try: - structured_content = pydantic_core.to_jsonable_python( - value=structured_content - ) - except pydantic_core.PydanticSerializationError as e: - logger.error( - f"Could not serialize structured content. If this is unexpected, set your tool's output_schema to None to disable automatic serialization: {e}" - ) - raise - if not isinstance(structured_content, dict): - raise ValueError( - "structured_content must be a dict or None. " - f"Got {type(structured_content).__name__}: {structured_content!r}. " - "Tools should wrap non-dict values based on their output_schema." - ) - - super().__init__( - content=converted_content, structured_content=structured_content, meta=meta - ) - - def to_mcp_result( - self, - ) -> ( - list[ContentBlock] | tuple[list[ContentBlock], dict[str, Any]] | CallToolResult - ): - if self.meta is not None: - return CallToolResult( - structuredContent=self.structured_content, - content=self.content, - _meta=self.meta, # type: ignore[call-arg] # _meta is Pydantic alias for meta field # ty:ignore[unknown-argument] - ) - if self.structured_content is None: - return self.content - return self.content, self.structured_content - - -class Tool(FastMCPComponent): - """Internal tool registration info.""" - - KEY_PREFIX: ClassVar[str] = "tool" - - parameters: Annotated[ - dict[str, Any], Field(description="JSON schema for tool parameters") - ] - output_schema: Annotated[ - dict[str, Any] | None, Field(description="JSON schema for tool output") - ] = None - annotations: Annotated[ - ToolAnnotations | None, - Field(description="Additional annotations about the tool"), - ] = None - execution: Annotated[ - ToolExecution | None, - Field(description="Task execution configuration (SEP-1686)"), - ] = None - serializer: Annotated[ - SkipJsonSchema[ToolResultSerializerType | None], - Field( - description="Deprecated. Return ToolResult from your tools for full control over serialization." - ), - ] = None - auth: Annotated[ - SkipJsonSchema[AuthCheck | list[AuthCheck] | None], - Field(description="Authorization checks for this tool", exclude=True), - ] = None - timeout: Annotated[ - float | None, - Field( - description="Execution timeout in seconds. If None, no timeout is applied." - ), - ] = None - - @model_validator(mode="after") - def _validate_tool_name(self) -> Tool: - """Validate tool name according to MCP specification (SEP-986).""" - validate_and_warn_tool_name(self.name) - return self - - def to_mcp_tool( - self, - **overrides: Any, - ) -> MCPTool: - """Convert the FastMCP tool to an MCP tool.""" - title = None - - if self.title: - title = self.title - elif self.annotations and self.annotations.title: - title = self.annotations.title - - mcp_tool = MCPTool( - name=overrides.get("name", self.name), - title=overrides.get("title", title), - description=overrides.get("description", self.description), - inputSchema=overrides.get("inputSchema", self.parameters), - outputSchema=overrides.get("outputSchema", self.output_schema), - icons=overrides.get("icons", self.icons), - annotations=overrides.get("annotations", self.annotations), - execution=overrides.get("execution", self.execution), - _meta=overrides.get( # type: ignore[call-arg] # _meta is Pydantic alias for meta field - "_meta", self.get_meta() - ), # ty:ignore[unknown-argument] - ) - - if ( - self.task_config.supports_tasks() - and "execution" not in overrides - and not self.execution - ): - mcp_tool.execution = ToolExecution(taskSupport=self.task_config.mode) - - return mcp_tool - - @classmethod - def from_function( - cls, - fn: Callable[..., Any], - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - tags: set[str] | None = None, - annotations: ToolAnnotations | None = None, - exclude_args: list[str] | None = None, - output_schema: dict[str, Any] | NotSetT | None = NotSet, - serializer: ToolResultSerializerType | None = None, # Deprecated - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - timeout: float | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> FunctionTool: - """Create a Tool from a function.""" - from fastmcp.tools.function_tool import FunctionTool - - return FunctionTool.from_function( - fn=fn, - name=name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - annotations=annotations, - exclude_args=exclude_args, - output_schema=output_schema, - serializer=serializer, - meta=meta, - task=task, - timeout=timeout, - auth=auth, - ) - - async def run(self, arguments: dict[str, Any]) -> ToolResult: - """ - Run the tool with arguments. - - This method is not implemented in the base Tool class and must be - implemented by subclasses. - - `run()` can EITHER return a list of ContentBlocks, or a tuple of - (list of ContentBlocks, dict of structured output). - """ - raise NotImplementedError("Subclasses must implement run()") - - def convert_result(self, raw_value: Any) -> ToolResult: - """Convert a raw result to ToolResult. - - Handles ToolResult passthrough and converts raw values using the tool's - attributes (serializer, output_schema) for proper conversion. - """ - if isinstance(raw_value, ToolResult): - return raw_value - - if _HAS_PREFAB: - if isinstance(raw_value, _PrefabApp): - return _prefab_to_tool_result( - raw_value, - fastmcp_app_name=_get_fastmcp_app_name(self), - ) - if isinstance(raw_value, _PrefabComponent): - return _prefab_to_tool_result( - _PrefabApp(view=raw_value), - fastmcp_app_name=_get_fastmcp_app_name(self), - ) - - content = _convert_to_content(raw_value, serializer=self.serializer) - - # Skip structured content for ContentBlock types only if no output_schema - # (if output_schema exists, MCP SDK requires structured_content) - if self.output_schema is None and ( - isinstance(raw_value, ContentBlock | Audio | Image | File) - or ( - isinstance(raw_value, list | tuple) - and any(isinstance(item, ContentBlock) for item in raw_value) - ) - ): - return ToolResult(content=content) - - try: - structured = pydantic_core.to_jsonable_python(raw_value) - except pydantic_core.PydanticSerializationError: - return ToolResult(content=content) - - if self.output_schema is None: - # No schema - only use structured_content for dicts - if isinstance(structured, dict): - return ToolResult(content=content, structured_content=structured) - return ToolResult(content=content) - - # Has output_schema - wrap if x-fastmcp-wrap-result is set - wrap_result = self.output_schema.get("x-fastmcp-wrap-result") - return ToolResult( - content=content, - structured_content={"result": structured} if wrap_result else structured, - meta={"fastmcp": {"wrap_result": True}} if wrap_result else None, - ) - - @overload - async def _run( - self, - arguments: dict[str, Any], - task_meta: None = None, - ) -> ToolResult: ... - - @overload - async def _run( - self, - arguments: dict[str, Any], - task_meta: TaskMeta, - ) -> mcp.types.CreateTaskResult: ... - - async def _run( - self, - arguments: dict[str, Any], - task_meta: TaskMeta | None = None, - ) -> ToolResult | mcp.types.CreateTaskResult: - """Server entry point that handles task routing. - - This allows ANY Tool subclass to support background execution by setting - task_config.mode to "supported" or "required". The server calls this - method instead of run() directly. - - Args: - arguments: Tool arguments - task_meta: If provided, execute as background task and return - CreateTaskResult. If None (default), execute synchronously and - return ToolResult. - - Returns: - ToolResult when task_meta is None. - CreateTaskResult when task_meta is provided. - - Subclasses can override this to customize task routing behavior. - For example, FastMCPProviderTool overrides to delegate to child - middleware without submitting to Docket. - """ - from fastmcp.server.tasks.routing import check_background_task - - task_result = await check_background_task( - component=self, - task_type="tool", - arguments=arguments, - task_meta=task_meta, - ) - if task_result: - return task_result - - return await self.run(arguments) - - def register_with_docket(self, docket: Docket) -> None: - """Register this tool with docket for background execution.""" - if not self.task_config.supports_tasks(): - return - docket.register(self.run, names=[self.key]) - - async def add_to_docket( # type: ignore[override] - self, - docket: Docket, - arguments: dict[str, Any], - *, - fn_key: str | None = None, - task_key: str | None = None, - **kwargs: Any, - ) -> Execution: - """Schedule this tool for background execution via docket. - - Args: - docket: The Docket instance - arguments: Tool arguments - fn_key: Function lookup key in Docket registry (defaults to self.key) - task_key: Redis storage key for the result - **kwargs: Additional kwargs passed to docket.add() - """ - lookup_key = fn_key or self.key - if task_key: - kwargs["key"] = task_key - return await docket.add(lookup_key, **kwargs)(arguments) - - @classmethod - def from_tool( - cls, - tool: Tool | Callable[..., Any], - *, - name: str | None = None, - title: str | NotSetT | None = NotSet, - description: str | NotSetT | None = NotSet, - tags: set[str] | None = None, - annotations: ToolAnnotations | NotSetT | None = NotSet, - output_schema: dict[str, Any] | NotSetT | None = NotSet, - serializer: ToolResultSerializerType | None = None, # Deprecated - meta: dict[str, Any] | NotSetT | None = NotSet, - transform_args: dict[str, ArgTransform] | None = None, - transform_fn: Callable[..., Any] | None = None, - ) -> TransformedTool: - from fastmcp.tools.tool_transform import TransformedTool - - tool = cls._ensure_tool(tool) - - return TransformedTool.from_tool( - tool=tool, - transform_fn=transform_fn, - name=name, - title=title, - transform_args=transform_args, - description=description, - tags=tags, - annotations=annotations, - output_schema=output_schema, - serializer=serializer, - meta=meta, - ) - - @classmethod - def _ensure_tool(cls, tool: Tool | Callable[..., Any]) -> Tool: - """Coerce a callable into a Tool, respecting @tool decorator metadata.""" - if isinstance(tool, Tool): - return tool - - from fastmcp.decorators import get_fastmcp_meta - from fastmcp.tools.function_tool import FunctionTool, ToolMeta - - fmeta = get_fastmcp_meta(tool) - if isinstance(fmeta, ToolMeta): - return FunctionTool.from_function(tool, metadata=fmeta) - - return cls.from_function(tool) - - def get_span_attributes(self) -> dict[str, Any]: - return super().get_span_attributes() | { - "fastmcp.component.type": "tool", - "fastmcp.provider.type": "LocalProvider", - } - - -def _serialize_with_fallback( - result: Any, serializer: ToolResultSerializerType | None = None -) -> str: - if serializer is not None: - try: - return serializer(result) - except Exception as e: - logger.warning( - "Error serializing tool result: %s", - e, - exc_info=True, - ) - - return default_serializer(result) - - -def _convert_to_single_content_block( - item: Any, - serializer: ToolResultSerializerType | None = None, -) -> ContentBlock: - if isinstance(item, ContentBlock): - return item - - if isinstance(item, Image): - return item.to_image_content() - - if isinstance(item, Audio): - return item.to_audio_content() - - if isinstance(item, File): - return item.to_resource_content() - - if isinstance(item, str): - return TextContent(type="text", text=item) - - return TextContent(type="text", text=_serialize_with_fallback(item, serializer)) - - -_PREFAB_TEXT_FALLBACK = "[Rendered Prefab UI]" - - -def _get_tool_resolver(app_name: str | None = None) -> Callable[..., str] | None: - """Get the FastMCPApp callable resolver, if available.""" - try: - from fastmcp.apps.app import _make_resolver - - return _make_resolver(app_name) - except ImportError: - return None - - -def _prefab_to_json(app: Any, fastmcp_app_name: str | None = None) -> dict[str, Any]: - """Call PrefabApp.to_json() with the FastMCPApp callable resolver. - - The resolver prefixes tool names with the app name (e.g. - ``"store_files"`` → ``"Files___store_files"``) so the server can - find them via the bypass lookup regardless of transforms. - """ - data = app.to_json(tool_resolver=_get_tool_resolver(fastmcp_app_name)) - return data - - -def _get_fastmcp_app_name(tool: Tool) -> str | None: - """Read the FastMCPApp name from a tool's metadata, if present.""" - meta = tool.meta - if not meta: - return None - fastmcp_meta = meta.get("fastmcp") - if isinstance(fastmcp_meta, dict): - app = fastmcp_meta.get("app") - if isinstance(app, str): - return app - return None - - -def _prefab_to_tool_result(app: Any, fastmcp_app_name: str | None = None) -> ToolResult: - """Convert a PrefabApp to a FastMCP ToolResult.""" - return ToolResult( - content=[TextContent(type="text", text=_PREFAB_TEXT_FALLBACK)], - structured_content=_prefab_to_json(app, fastmcp_app_name=fastmcp_app_name), - ) - - -def _convert_to_content( - result: Any, - serializer: ToolResultSerializerType | None = None, -) -> list[ContentBlock]: - """Convert a result to a sequence of content objects.""" - - if result is None: - return [] - - if not isinstance(result, (list | tuple)): - return [_convert_to_single_content_block(result, serializer)] - - # If all items are ContentBlocks, return them as is - if all(isinstance(item, ContentBlock) for item in result): - return result - - # If any item is a ContentBlock, convert non-ContentBlock items to TextContent - # without aggregating them - if any(isinstance(item, ContentBlock | Image | Audio | File) for item in result): - return [ - _convert_to_single_content_block(item, serializer) - if not isinstance(item, ContentBlock) - else item - for item in result - ] - # If none of the items are ContentBlocks, aggregate all items into a single TextContent - return [TextContent(type="text", text=_serialize_with_fallback(result, serializer))] - - -__all__ = ["Tool", "ToolResult"] - - -def __getattr__(name: str) -> Any: - """Deprecated re-exports for backwards compatibility.""" - deprecated_exports = { - "FunctionTool": "FunctionTool", - "ParsedFunction": "ParsedFunction", - "tool": "tool", - } - - if name in deprecated_exports: - import fastmcp - - if fastmcp.settings.deprecation_warnings: - warnings.warn( - f"Importing {name} from fastmcp.tools.tool is deprecated. " - f"Import from fastmcp.tools.function_tool instead.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - from fastmcp.tools import function_tool - - return getattr(function_tool, name) - - raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/fastmcp/tools/function_parsing.py b/src/fastmcp/tools/function_parsing.py deleted file mode 100644 index 804dc6efd..000000000 --- a/src/fastmcp/tools/function_parsing.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Function introspection and schema generation for FastMCP tools.""" - -from __future__ import annotations - -import functools -import inspect -import types -from collections.abc import Callable -from dataclasses import dataclass -from typing import Annotated, Any, Generic, Union, get_args, get_origin, get_type_hints - -import mcp.types -from pydantic import PydanticSchemaGenerationError -from typing_extensions import TypeVar as TypeVarExt - -from fastmcp.server.dependencies import ( - transform_context_annotations, - without_injected_parameters, -) -from fastmcp.tools.base import ToolResult -from fastmcp.utilities.json_schema import compress_schema -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import ( - Audio, - File, - Image, - create_function_without_params, - get_cached_typeadapter, - is_class_member_of_type, - replace_type, -) - -try: - from prefab_ui.app import PrefabApp as _PrefabApp - from prefab_ui.components.base import Component as _PrefabComponent - - _PREFAB_TYPES: tuple[type, ...] = (_PrefabApp, _PrefabComponent) -except ImportError: - _PREFAB_TYPES = () - - -def _contains_prefab_type(tp: Any) -> bool: - """Check if *tp* is or contains a prefab type, recursing through unions and Annotated.""" - if isinstance(tp, type) and issubclass(tp, _PREFAB_TYPES): - return True - origin = get_origin(tp) - if origin is Union or origin is types.UnionType or origin is Annotated: - return any(_contains_prefab_type(a) for a in get_args(tp)) - return False - - -T = TypeVarExt("T", default=Any) - -logger = get_logger(__name__) - - -@dataclass -class _WrappedResult(Generic[T]): - """Generic wrapper for non-object return types.""" - - result: T - - -class _UnserializableType: - pass - - -def _is_object_schema( - schema: dict[str, Any], - *, - _root_schema: dict[str, Any] | None = None, - _seen_refs: set[str] | None = None, -) -> bool: - """Check if a JSON schema represents an object type.""" - root_schema = _root_schema or schema - seen_refs = _seen_refs or set() - - # Direct object type - if schema.get("type") == "object": - return True - - # Schema with properties but no explicit type is treated as object - if "properties" in schema: - return True - - # Resolve local $ref definitions and recurse into the target schema. - ref = schema.get("$ref") - if not isinstance(ref, str) or not ref.startswith("#/"): - return False - - if ref in seen_refs: - return False - - # Walk the JSON Pointer path from the root schema, unescaping each - # token per RFC 6901 (~1 → /, ~0 → ~). - pointer = ref.removeprefix("#/") - segments = pointer.split("/") - target: Any = root_schema - for segment in segments: - unescaped = segment.replace("~1", "/").replace("~0", "~") - if not isinstance(target, dict) or unescaped not in target: - return False - target = target[unescaped] - - target_schema = target - if not isinstance(target_schema, dict): - return False - - return _is_object_schema( - target_schema, - _root_schema=root_schema, - _seen_refs=seen_refs | {ref}, - ) - - -@dataclass -class ParsedFunction: - fn: Callable[..., Any] - name: str - description: str | None - input_schema: dict[str, Any] - output_schema: dict[str, Any] | None - return_type: Any = None - - @classmethod - def from_function( - cls, - fn: Callable[..., Any], - exclude_args: list[str] | None = None, - validate: bool = True, - wrap_non_object_output_schema: bool = True, - ) -> ParsedFunction: - if validate: - sig = inspect.signature(fn) - # Reject functions with *args or **kwargs - for param in sig.parameters.values(): - if param.kind == inspect.Parameter.VAR_POSITIONAL: - raise ValueError("Functions with *args are not supported as tools") - if param.kind == inspect.Parameter.VAR_KEYWORD: - raise ValueError( - "Functions with **kwargs are not supported as tools" - ) - - # Reject exclude_args that don't exist in the function or don't have a default value - if exclude_args: - for arg_name in exclude_args: - if arg_name not in sig.parameters: - raise ValueError( - f"Parameter '{arg_name}' in exclude_args does not exist in function." - ) - param = sig.parameters[arg_name] - if param.default == inspect.Parameter.empty: - raise ValueError( - f"Parameter '{arg_name}' in exclude_args must have a default value." - ) - - # collect name and doc before we potentially modify the function - fn_name = getattr(fn, "__name__", None) or fn.__class__.__name__ - fn_doc = inspect.getdoc(fn) - - # if the fn is a callable class, we need to get the __call__ method from here out - if not inspect.isroutine(fn) and not isinstance(fn, functools.partial): - fn = fn.__call__ - # if the fn is a staticmethod, we need to work with the underlying function - if isinstance(fn, staticmethod): - fn = fn.__func__ - - # Transform Context type annotations to Depends() for unified DI - fn = transform_context_annotations(fn) - - # Handle injected parameters (Context, Docket dependencies) - wrapper_fn = without_injected_parameters(fn) - - # Also handle exclude_args with non-serializable types (issue #2431) - # This must happen before Pydantic tries to serialize the parameters - if exclude_args: - wrapper_fn = create_function_without_params(wrapper_fn, list(exclude_args)) - - input_type_adapter = get_cached_typeadapter(wrapper_fn) - input_schema = input_type_adapter.json_schema() - - # Compress and handle exclude_args - prune_params = list(exclude_args) if exclude_args else None - input_schema = compress_schema( - input_schema, prune_params=prune_params, prune_titles=True - ) - - output_schema = None - # Get the return annotation from the signature - sig = inspect.signature(fn) - output_type = sig.return_annotation - - # If the annotation is a string (from __future__ annotations), resolve it - if isinstance(output_type, str): - try: - # Use get_type_hints to resolve the return type - # include_extras=True preserves Annotated metadata - type_hints = get_type_hints(fn, include_extras=True) - output_type = type_hints.get("return", output_type) - except Exception as e: - # If resolution fails, keep the string annotation - logger.debug("Failed to resolve type hint for return annotation: %s", e) - - # Save original for return_type before any schema-related replacement - original_output_type = output_type - - if output_type not in (inspect._empty, None, Any, ...): - # Prefab component subclasses (Column, Card, etc.) shouldn't - # produce output schemas — replace_type only does exact matching, - # so we handle subclass matching explicitly here. We also need - # to handle composite types like ``Column | None`` and - # ``Annotated[PrefabApp, ...]`` by recursing into their args. - if _PREFAB_TYPES and _contains_prefab_type(output_type): - output_type = _UnserializableType - - # ToolResult subclasses should suppress schema generation just - # like ToolResult itself — replace_type only does exact matching. - if is_class_member_of_type(output_type, ToolResult): - output_type = _UnserializableType - - # there are a variety of types that we don't want to attempt to - # serialize because they are either used by FastMCP internally, - # or are MCP content types that explicitly don't form structured - # content. By replacing them with an explicitly unserializable type, - # we ensure that no output schema is automatically generated. - clean_output_type = replace_type( - output_type, - dict.fromkeys( - ( - Image, - Audio, - File, - ToolResult, - mcp.types.TextContent, - mcp.types.ImageContent, - mcp.types.AudioContent, - mcp.types.ResourceLink, - mcp.types.EmbeddedResource, - *_PREFAB_TYPES, - ), - _UnserializableType, - ), - ) - - try: - type_adapter = get_cached_typeadapter(clean_output_type) - base_schema = type_adapter.json_schema(mode="serialization") - - # Generate schema for wrapped type if it's non-object - # because MCP requires that output schemas are objects - # Check if schema is an object type, resolving $ref references - # (self-referencing types use $ref at root level) - if wrap_non_object_output_schema and not _is_object_schema(base_schema): - # Use the wrapped result schema directly - wrapped_type = _WrappedResult[clean_output_type] - wrapped_adapter = get_cached_typeadapter(wrapped_type) - output_schema = wrapped_adapter.json_schema(mode="serialization") - output_schema["x-fastmcp-wrap-result"] = True - else: - output_schema = base_schema - - output_schema = compress_schema(output_schema, prune_titles=True) - - except PydanticSchemaGenerationError as e: - if "_UnserializableType" not in str(e): - logger.debug(f"Unable to generate schema for type {output_type!r}") - - return cls( - fn=fn, - name=fn_name, - description=fn_doc, - input_schema=input_schema, - output_schema=output_schema or None, - return_type=original_output_type, - ) diff --git a/src/fastmcp/tools/function_tool.py b/src/fastmcp/tools/function_tool.py deleted file mode 100644 index 0f0dc8325..000000000 --- a/src/fastmcp/tools/function_tool.py +++ /dev/null @@ -1,494 +0,0 @@ -"""Standalone @tool decorator for FastMCP.""" - -from __future__ import annotations - -import functools -import inspect -import warnings -from collections.abc import Callable -from dataclasses import dataclass, field -from typing import ( - TYPE_CHECKING, - Annotated, - Any, - Literal, - Protocol, - TypeVar, - overload, - runtime_checkable, -) - -import anyio -from mcp.shared.exceptions import McpError -from mcp.types import ErrorData, Icon, ToolAnnotations -from pydantic import Field -from pydantic.json_schema import SkipJsonSchema - -import fastmcp -from fastmcp.decorators import resolve_task_config -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.server.auth.authorization import AuthCheck -from fastmcp.server.dependencies import ( - _restore_task_http_headers, - _task_http_headers, - get_task_context, - without_injected_parameters, -) -from fastmcp.server.tasks.config import TaskConfig -from fastmcp.tools.base import ( - Tool, - ToolResult, - ToolResultSerializerType, -) -from fastmcp.tools.function_parsing import ParsedFunction, _is_object_schema -from fastmcp.utilities.async_utils import ( - call_sync_fn_in_threadpool, - is_coroutine_function, -) -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import ( - NotSet, - NotSetT, - get_cached_typeadapter, -) - -logger = get_logger(__name__) - -if TYPE_CHECKING: - from docket import Docket - from docket.execution import Execution - -F = TypeVar("F", bound=Callable[..., Any]) - - -@runtime_checkable -class DecoratedTool(Protocol): - """Protocol for functions decorated with @tool.""" - - __fastmcp__: ToolMeta - - def __call__(self, *args: Any, **kwargs: Any) -> Any: ... - - -@dataclass(frozen=True, kw_only=True) -class ToolMeta: - """Metadata attached to functions by the @tool decorator.""" - - type: Literal["tool"] = field(default="tool", init=False) - name: str | None = None - version: str | int | None = None - title: str | None = None - description: str | None = None - icons: list[Icon] | None = None - tags: set[str] | None = None - output_schema: dict[str, Any] | NotSetT | None = NotSet - annotations: ToolAnnotations | None = None - meta: dict[str, Any] | None = None - app: Any = None - task: bool | TaskConfig | None = None - exclude_args: list[str] | None = None - serializer: Any | None = None - timeout: float | None = None - auth: AuthCheck | list[AuthCheck] | None = None - enabled: bool = True - - -class FunctionTool(Tool): - fn: SkipJsonSchema[Callable[..., Any]] - return_type: Annotated[SkipJsonSchema[Any], Field(exclude=True)] = None - - @classmethod - def from_function( - cls, - fn: Callable[..., Any], - *, - metadata: ToolMeta | None = None, - # Keep individual params for backwards compat - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - tags: set[str] | None = None, - annotations: ToolAnnotations | None = None, - exclude_args: list[str] | None = None, - output_schema: dict[str, Any] | NotSetT | None = NotSet, - serializer: ToolResultSerializerType | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - timeout: float | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, - ) -> FunctionTool: - """Create a FunctionTool from a function. - - Args: - fn: The function to wrap - metadata: ToolMeta object with all configuration. If provided, - individual parameters must not be passed. - name, title, etc.: Individual parameters for backwards compatibility. - Cannot be used together with metadata parameter. - """ - # Check mutual exclusion - individual_params_provided = ( - any( - x is not None and x is not NotSet - for x in [ - name, - version, - title, - description, - icons, - tags, - annotations, - meta, - task, - serializer, - timeout, - auth, - ] - ) - or output_schema is not NotSet - or exclude_args is not None - ) - - if metadata is not None and individual_params_provided: - raise TypeError( - "Cannot pass both 'metadata' and individual parameters to from_function(). " - "Use metadata alone or individual parameters alone." - ) - - # Build metadata from kwargs if not provided - if metadata is None: - metadata = ToolMeta( - name=name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - output_schema=output_schema, - annotations=annotations, - meta=meta, - task=task, - exclude_args=exclude_args, - serializer=serializer, - timeout=timeout, - auth=auth, - ) - - if metadata.serializer is not None and fastmcp.settings.deprecation_warnings: - warnings.warn( - "The `serializer` parameter is deprecated. " - "Return ToolResult from your tools for full control over serialization. " - "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - if metadata.exclude_args and fastmcp.settings.deprecation_warnings: - warnings.warn( - "The `exclude_args` parameter is deprecated as of FastMCP 2.14. " - "Use dependency injection with `Depends()` instead for better lifecycle management. " - "See https://gofastmcp.com/servers/dependency-injection#using-depends for examples.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - - parsed_fn = ParsedFunction.from_function(fn, exclude_args=metadata.exclude_args) - func_name = metadata.name or parsed_fn.name - - if func_name == "": - raise ValueError("You must provide a name for lambda functions") - - # Normalize task to TaskConfig - task_value = metadata.task - if task_value is None: - task_config = TaskConfig(mode="forbidden") - elif isinstance(task_value, bool): - task_config = TaskConfig.from_bool(task_value) - else: - task_config = task_value - task_config.validate_function(fn, func_name) - - # Handle output_schema - if isinstance(metadata.output_schema, NotSetT): - final_output_schema = parsed_fn.output_schema - else: - final_output_schema = metadata.output_schema - - if final_output_schema is not None and isinstance(final_output_schema, dict): - if not _is_object_schema(final_output_schema): - raise ValueError( - f"Output schemas must represent object types due to MCP spec limitations. " - f"Received: {final_output_schema!r}" - ) - - return cls( - fn=parsed_fn.fn, - return_type=parsed_fn.return_type, - name=metadata.name or parsed_fn.name, - version=str(metadata.version) if metadata.version is not None else None, - title=metadata.title, - description=metadata.description or parsed_fn.description, - icons=metadata.icons, - parameters=parsed_fn.input_schema, - output_schema=final_output_schema, - annotations=metadata.annotations, - tags=metadata.tags or set(), - serializer=metadata.serializer, - meta=metadata.meta, - task_config=task_config, - timeout=metadata.timeout, - auth=metadata.auth, - ) - - async def run(self, arguments: dict[str, Any]) -> ToolResult: - """Run the tool with arguments.""" - wrapper_fn = without_injected_parameters(self.fn) - type_adapter = get_cached_typeadapter(wrapper_fn) - - # Apply timeout if configured - if self.timeout is not None: - try: - with anyio.fail_after(self.timeout): - # Thread pool execution for sync functions, direct await for async - if is_coroutine_function(wrapper_fn): - result = await type_adapter.validate_python(arguments) - else: - # Sync function: run in threadpool to avoid blocking - result = await call_sync_fn_in_threadpool( - type_adapter.validate_python, arguments - ) - # Handle sync wrappers that return awaitables - if inspect.isawaitable(result): - result = await result - except TimeoutError: - logger.warning( - f"Tool '{self.name}' timed out after {self.timeout}s. " - f"Consider using task=True for long-running operations. " - f"See https://gofastmcp.com/servers/tasks" - ) - raise McpError( - ErrorData( - code=-32000, - message=f"Tool '{self.name}' execution timed out after {self.timeout}s", - ) - ) from None - else: - # No timeout: use existing execution path - if is_coroutine_function(wrapper_fn): - result = await type_adapter.validate_python(arguments) - else: - result = await call_sync_fn_in_threadpool( - type_adapter.validate_python, arguments - ) - if inspect.isawaitable(result): - result = await result - - return self.convert_result(result) - - def register_with_docket(self, docket: Docket) -> None: - """Register this tool with docket for background execution. - - FunctionTool registers the underlying function, which has the user's - Depends parameters for docket to resolve. The function is wrapped to - eagerly restore HTTP headers from Redis so that get_http_request() - works even without explicit dependency injection. - """ - if not self.task_config.supports_tasks(): - return - docket.register(_wrap_for_task_http_headers(self.fn), names=[self.key]) - - async def add_to_docket( - self, - docket: Docket, - arguments: dict[str, Any], - *, - fn_key: str | None = None, - task_key: str | None = None, - **kwargs: Any, - ) -> Execution: - """Schedule this tool for background execution via docket. - - FunctionTool splats the arguments dict since .fn expects **kwargs. - - Args: - docket: The Docket instance - arguments: Tool arguments - fn_key: Function lookup key in Docket registry (defaults to self.key) - task_key: Redis storage key for the result - **kwargs: Additional kwargs passed to docket.add() - """ - lookup_key = fn_key or self.key - if task_key: - kwargs["key"] = task_key - return await docket.add(lookup_key, **kwargs)(**arguments) - - -def _wrap_for_task_http_headers(fn: Callable[..., Any]) -> Callable[..., Any]: - """Wrap a function to restore HTTP headers in background task workers. - - Uses functools.wraps so docket sees the original signature for dependency - resolution while the wrapper eagerly populates _task_http_headers before - the user's function runs. - """ - - @functools.wraps(fn) - async def wrapper(*args: Any, **kwargs: Any) -> Any: - task_info = get_task_context() - token = None - if task_info is not None and _task_http_headers.get() is None: - token = await _restore_task_http_headers( - task_info.session_id, task_info.task_id - ) - try: - result = fn(*args, **kwargs) - if inspect.isawaitable(result): - result = await result - return result - finally: - if token is not None: - _task_http_headers.reset(token) - - return wrapper - - -@overload -def tool(fn: F) -> F: ... -@overload -def tool( - name_or_fn: str, - *, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - tags: set[str] | None = None, - output_schema: dict[str, Any] | NotSetT | None = NotSet, - annotations: ToolAnnotations | dict[str, Any] | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - exclude_args: list[str] | None = None, - serializer: Any | None = None, - timeout: float | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, -) -> Callable[[F], F]: ... -@overload -def tool( - name_or_fn: None = None, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - tags: set[str] | None = None, - output_schema: dict[str, Any] | NotSetT | None = NotSet, - annotations: ToolAnnotations | dict[str, Any] | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - exclude_args: list[str] | None = None, - serializer: Any | None = None, - timeout: float | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, -) -> Callable[[F], F]: ... - - -def tool( - name_or_fn: str | Callable[..., Any] | None = None, - *, - name: str | None = None, - version: str | int | None = None, - title: str | None = None, - description: str | None = None, - icons: list[Icon] | None = None, - tags: set[str] | None = None, - output_schema: dict[str, Any] | NotSetT | None = NotSet, - annotations: ToolAnnotations | dict[str, Any] | None = None, - meta: dict[str, Any] | None = None, - task: bool | TaskConfig | None = None, - exclude_args: list[str] | None = None, - serializer: Any | None = None, - timeout: float | None = None, - auth: AuthCheck | list[AuthCheck] | None = None, -) -> Any: - """Standalone decorator to mark a function as an MCP tool. - - Returns the original function with metadata attached. Register with a server - using mcp.add_tool(). - """ - if isinstance(annotations, dict): - annotations = ToolAnnotations(**annotations) - - if isinstance(name_or_fn, classmethod): - raise TypeError( - "To decorate a classmethod, use @classmethod above @tool. " - "See https://gofastmcp.com/servers/tools#using-with-methods" - ) - - def create_tool(fn: Callable[..., Any], tool_name: str | None) -> FunctionTool: - # Create metadata first, then pass it - tool_meta = ToolMeta( - name=tool_name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - output_schema=output_schema, - annotations=annotations, - meta=meta, - task=resolve_task_config(task), - exclude_args=exclude_args, - serializer=serializer, - timeout=timeout, - auth=auth, - ) - return FunctionTool.from_function(fn, metadata=tool_meta) - - def attach_metadata(fn: F, tool_name: str | None) -> F: - metadata = ToolMeta( - name=tool_name, - version=version, - title=title, - description=description, - icons=icons, - tags=tags, - output_schema=output_schema, - annotations=annotations, - meta=meta, - task=task, - exclude_args=exclude_args, - serializer=serializer, - timeout=timeout, - auth=auth, - ) - target = fn.__func__ if hasattr(fn, "__func__") else fn - target.__fastmcp__ = metadata - return fn - - def decorator(fn: F, tool_name: str | None) -> F: - if fastmcp.settings.decorator_mode == "object": - warnings.warn( - "decorator_mode='object' is deprecated and will be removed in a future version. " - "Decorators now return the original function with metadata attached.", - FastMCPDeprecationWarning, - stacklevel=4, - ) - return create_tool(fn, tool_name) # type: ignore[return-value] # ty:ignore[invalid-return-type] - return attach_metadata(fn, tool_name) - - if inspect.isroutine(name_or_fn): - return decorator(name_or_fn, name) - elif isinstance(name_or_fn, str): - if name is not None: - raise TypeError("Cannot specify name both as first argument and keyword") - tool_name = name_or_fn - elif name_or_fn is None: - tool_name = name - else: - raise TypeError(f"Invalid first argument: {type(name_or_fn)}") - - def wrapper(fn: F) -> F: - return decorator(fn, tool_name) - - return wrapper diff --git a/src/fastmcp/tools/tool_transform.py b/src/fastmcp/tools/tool_transform.py deleted file mode 100644 index a1ec42302..000000000 --- a/src/fastmcp/tools/tool_transform.py +++ /dev/null @@ -1,998 +0,0 @@ -from __future__ import annotations - -import inspect -import warnings -from collections.abc import Callable -from contextvars import ContextVar -from copy import deepcopy -from dataclasses import dataclass -from typing import Annotated, Any, Literal, cast - -import pydantic_core -from mcp.types import ToolAnnotations -from pydantic import ConfigDict -from pydantic.fields import Field -from pydantic.functional_validators import BeforeValidator -from pydantic.json_schema import SkipJsonSchema - -import fastmcp -from fastmcp.exceptions import FastMCPDeprecationWarning -from fastmcp.tools.base import Tool, ToolResult, _convert_to_content -from fastmcp.tools.function_parsing import ParsedFunction -from fastmcp.utilities.components import _convert_set_default_none -from fastmcp.utilities.json_schema import compress_schema -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.types import ( - FastMCPBaseModel, - NotSet, - NotSetT, - get_cached_typeadapter, - issubclass_safe, -) - -logger = get_logger(__name__) - - -# Context variable to store current transformed tool -_current_tool: ContextVar[TransformedTool | None] = ContextVar( - "_current_tool", default=None -) - - -async def forward(**kwargs: Any) -> ToolResult: - """Forward to parent tool with argument transformation applied. - - This function can only be called from within a transformed tool's custom - function. It applies argument transformation (renaming, validation) before - calling the parent tool. - - For example, if the parent tool has args `x` and `y`, but the transformed - tool has args `a` and `b`, and an `transform_args` was provided that maps `x` to - `a` and `y` to `b`, then `forward(a=1, b=2)` will call the parent tool with - `x=1` and `y=2`. - - Args: - **kwargs: Arguments to forward to the parent tool (using transformed names). - - Returns: - The ToolResult from the parent tool execution. - - Raises: - RuntimeError: If called outside a transformed tool context. - TypeError: If provided arguments don't match the transformed schema. - """ - tool = _current_tool.get() - if tool is None: - raise RuntimeError("forward() can only be called within a transformed tool") - - # Use the forwarding function that handles mapping - return await tool.forwarding_fn(**kwargs) - - -async def forward_raw(**kwargs: Any) -> ToolResult: - """Forward directly to parent tool without transformation. - - This function bypasses all argument transformation and validation, calling the parent - tool directly with the provided arguments. Use this when you need to call the parent - with its original parameter names and structure. - - For example, if the parent tool has args `x` and `y`, then `forward_raw(x=1, - y=2)` will call the parent tool with `x=1` and `y=2`. - - Args: - **kwargs: Arguments to pass directly to the parent tool (using original names). - - Returns: - The ToolResult from the parent tool execution. - - Raises: - RuntimeError: If called outside a transformed tool context. - """ - tool = _current_tool.get() - if tool is None: - raise RuntimeError("forward_raw() can only be called within a transformed tool") - - return await tool.parent_tool.run(kwargs) - - -@dataclass(kw_only=True) -class ArgTransform: - """Configuration for transforming a parent tool's argument. - - This class allows fine-grained control over how individual arguments are transformed - when creating a new tool from an existing one. You can rename arguments, change their - descriptions, add default values, or hide them from clients while passing constants. - - Attributes: - name: New name for the argument. Use None to keep original name, or ... for no change. - description: New description for the argument. Use None to remove description, or ... for no change. - default: New default value for the argument. Use ... for no change. - default_factory: Callable that returns a default value. Cannot be used with default. - type: New type for the argument. Use ... for no change. - hide: If True, hide this argument from clients but pass a constant value to parent. - required: If True, make argument required (remove default). Use ... for no change. - examples: Examples for the argument. Use ... for no change. - - Examples: - Rename argument 'old_name' to 'new_name' - ```python - ArgTransform(name="new_name") - ``` - - Change description only - ```python - ArgTransform(description="Updated description") - ``` - - Add a default value (makes argument optional) - ```python - ArgTransform(default=42) - ``` - - Add a default factory (makes argument optional) - ```python - ArgTransform(default_factory=lambda: time.time()) - ``` - - Change the type - ```python - ArgTransform(type=str) - ``` - - Hide the argument entirely from clients - ```python - ArgTransform(hide=True) - ``` - - Hide argument but pass a constant value to parent - ```python - ArgTransform(hide=True, default="constant_value") - ``` - - Hide argument but pass a factory-generated value to parent - ```python - ArgTransform(hide=True, default_factory=lambda: uuid.uuid4().hex) - ``` - - Make an optional parameter required (removes any default) - ```python - ArgTransform(required=True) - ``` - - Combine multiple transformations - ```python - ArgTransform(name="new_name", description="New desc", default=None, type=int) - ``` - """ - - name: str | NotSetT = NotSet - description: str | NotSetT = NotSet - default: Any | NotSetT = NotSet - default_factory: Callable[[], Any] | NotSetT = NotSet - type: Any | NotSetT = NotSet - hide: bool = False - required: Literal[True] | NotSetT = NotSet - examples: Any | NotSetT = NotSet - - def __post_init__(self): - """Validate that only one of default or default_factory is provided.""" - has_default = self.default is not NotSet - has_factory = self.default_factory is not NotSet - - if has_default and has_factory: - raise ValueError( - "Cannot specify both 'default' and 'default_factory' in ArgTransform. " - "Use either 'default' for a static value or 'default_factory' for a callable." - ) - - if has_factory and not self.hide: - raise ValueError( - "default_factory can only be used with hide=True. " - "Visible parameters must use static 'default' values since JSON schema " - "cannot represent dynamic factories." - ) - - if self.required is True and (has_default or has_factory): - raise ValueError( - "Cannot specify 'required=True' with 'default' or 'default_factory'. " - "Required parameters cannot have defaults." - ) - - if self.hide and self.required is True: - raise ValueError( - "Cannot specify both 'hide=True' and 'required=True'. " - "Hidden parameters cannot be required since clients cannot provide them." - ) - - if self.required is False: - raise ValueError( - "Cannot specify 'required=False'. Set a default value instead." - ) - - -class ArgTransformConfig(FastMCPBaseModel): - """A model for requesting a single argument transform.""" - - name: str | None = Field(default=None, description="The new name for the argument.") - description: str | None = Field( - default=None, description="The new description for the argument." - ) - default: str | int | float | bool | None = Field( - default=None, description="The new default value for the argument." - ) - hide: bool = Field( - default=False, description="Whether to hide the argument from the tool." - ) - required: Literal[True] | None = Field( - default=None, description="Whether the argument is required." - ) - examples: Any | None = Field(default=None, description="Examples of the argument.") - - def to_arg_transform(self) -> ArgTransform: - """Convert the argument transform to a FastMCP argument transform.""" - - return ArgTransform(**self.model_dump(exclude_unset=True)) # pyright: ignore[reportAny] - - -class TransformedTool(Tool): - """A tool that is transformed from another tool. - - This class represents a tool that has been created by transforming another tool. - It supports argument renaming, schema modification, custom function injection, - structured output control, and provides context for the forward() and forward_raw() functions. - - The transformation can be purely schema-based (argument renaming, dropping, etc.) - or can include a custom function that uses forward() to call the parent tool - with transformed arguments. Output schemas and structured outputs are automatically - inherited from the parent tool but can be overridden or disabled. - - Attributes: - parent_tool: The original tool that this tool was transformed from. - fn: The function to execute when this tool is called (either the forwarding - function for pure transformations or a custom user function). - forwarding_fn: Internal function that handles argument transformation and - validation when forward() is called from custom functions. - """ - - model_config = ConfigDict(extra="allow", arbitrary_types_allowed=True) - - parent_tool: SkipJsonSchema[Tool] - fn: SkipJsonSchema[Callable[..., Any]] - forwarding_fn: SkipJsonSchema[ - Callable[..., Any] - ] # Always present, handles arg transformation - transform_args: dict[str, ArgTransform] - - async def run(self, arguments: dict[str, Any]) -> ToolResult: - """Run the tool with context set for forward() functions. - - This method executes the tool's function while setting up the context - that allows forward() and forward_raw() to work correctly within custom - functions. - - Args: - arguments: Dictionary of arguments to pass to the tool's function. - - Returns: - ToolResult object containing content and optional structured output. - """ - - # Fill in missing arguments with schema defaults to ensure - # ArgTransform defaults take precedence over function defaults - arguments = arguments.copy() - properties = self.parameters.get("properties", {}) - - for param_name, param_schema in properties.items(): - if param_name not in arguments and "default" in param_schema: - # Check if this parameter has a default_factory from transform_args - # We need to call the factory for each run, not use the cached schema value - has_factory_default = False - if self.transform_args: - # Find the original parameter name that maps to this param_name - for orig_name, transform in self.transform_args.items(): - transform_name = ( - transform.name - if transform.name is not NotSet - else orig_name - ) - if ( - transform_name == param_name - and transform.default_factory is not NotSet - ): - # Type check to ensure default_factory is callable - if callable(transform.default_factory): - arguments[param_name] = transform.default_factory() - has_factory_default = True - break - - if not has_factory_default: - arguments[param_name] = param_schema["default"] - - token = _current_tool.set(self) - try: - result = await self.fn(**arguments) - - # If transform function returns ToolResult, respect our output_schema setting - if isinstance(result, ToolResult): - if self.output_schema is None: - return result - elif self.output_schema.get( - "type" - ) != "object" and not self.output_schema.get("x-fastmcp-wrap-result"): - # Non-object explicit schemas disable structured content - return ToolResult( - content=result.content, - structured_content=None, - ) - else: - return result - - # Otherwise convert to content and create ToolResult with proper structured content - - unstructured_result = _convert_to_content( - result, serializer=self.serializer - ) - - structured_output = None - # First handle structured content based on output schema, if any - if self.output_schema is not None: - if self.output_schema.get("x-fastmcp-wrap-result"): - # Schema says wrap - always wrap in result key - structured_output = {"result": result} - else: - structured_output = result - # If no output schema, try to serialize the result. If it is a dict, use - # it as structured content. If it is not a dict, ignore it. - if structured_output is None: - try: - structured_output = pydantic_core.to_jsonable_python(result) - if not isinstance(structured_output, dict): - structured_output = None - except Exception: - pass - - return ToolResult( - content=unstructured_result, - structured_content=structured_output, - ) - finally: - _current_tool.reset(token) - - @classmethod - def from_tool( - cls, - tool: Tool | Callable[..., Any], - name: str | None = None, - version: str | NotSetT | None = NotSet, - title: str | NotSetT | None = NotSet, - description: str | NotSetT | None = NotSet, - tags: set[str] | None = None, - transform_fn: Callable[..., Any] | None = None, - transform_args: dict[str, ArgTransform] | None = None, - annotations: ToolAnnotations | NotSetT | None = NotSet, - output_schema: dict[str, Any] | NotSetT | None = NotSet, - serializer: Callable[[Any], str] | NotSetT | None = NotSet, # Deprecated - meta: dict[str, Any] | NotSetT | None = NotSet, - ) -> TransformedTool: - """Create a transformed tool from a parent tool. - - Args: - tool: The parent tool to transform. - transform_fn: Optional custom function. Can use forward() and forward_raw() - to call the parent tool. Functions with **kwargs receive transformed - argument names. - name: New name for the tool. Defaults to parent tool's name. - version: New version for the tool. Defaults to parent tool's version. - title: New title for the tool. Defaults to parent tool's title. - transform_args: Optional transformations for parent tool arguments. - Only specified arguments are transformed, others pass through unchanged: - - Simple rename (str) - - Complex transformation (rename/description/default/drop) (ArgTransform) - - Drop the argument (None) - description: New description. Defaults to parent's description. - tags: New tags. Defaults to parent's tags. - annotations: New annotations. Defaults to parent's annotations. - output_schema: Control output schema for structured outputs: - - None (default): Inherit from transform_fn if available, then parent tool - - dict: Use custom output schema - - False: Disable output schema and structured outputs - serializer: Deprecated. Return ToolResult from your tools for full control over serialization. - meta: Control meta information: - - NotSet (default): Inherit from parent tool - - dict: Use custom meta information - - None: Remove meta information - - Returns: - TransformedTool with the specified transformations. - - Examples: - # Transform specific arguments only - ```python - Tool.from_tool(parent, transform_args={"old": "new"}) # Others unchanged - ``` - - # Custom function with partial transforms - ```python - async def custom(x: int, y: int) -> str: - result = await forward(x=x, y=y) - return f"Custom: {result}" - - Tool.from_tool(parent, transform_fn=custom, transform_args={"a": "x", "b": "y"}) - ``` - - # Using **kwargs (gets all args, transformed and untransformed) - ```python - async def flexible(**kwargs) -> str: - result = await forward(**kwargs) - return f"Got: {kwargs}" - - Tool.from_tool(parent, transform_fn=flexible, transform_args={"a": "x"}) - ``` - - # Control structured outputs and schemas - ```python - # Custom output schema - Tool.from_tool(parent, output_schema={ - "type": "object", - "properties": {"status": {"type": "string"}} - }) - - # Disable structured outputs - Tool.from_tool(parent, output_schema=None) - - # Return ToolResult for full control - async def custom_output(**kwargs) -> ToolResult: - result = await forward(**kwargs) - return ToolResult( - content=[TextContent(text="Summary")], - structured_content={"processed": True} - ) - ``` - """ - tool = Tool._ensure_tool(tool) - - if ( - serializer is not NotSet - and serializer is not None - and fastmcp.settings.deprecation_warnings - ): - warnings.warn( - "The `serializer` parameter is deprecated. " - "Return ToolResult from your tools for full control over serialization. " - "See https://gofastmcp.com/servers/tools#custom-serialization for migration examples.", - FastMCPDeprecationWarning, - stacklevel=2, - ) - transform_args = transform_args or {} - - if transform_fn is not None: - parsed_fn = ParsedFunction.from_function(transform_fn, validate=False) - else: - parsed_fn = None - - # Validate transform_args - parent_params = set(tool.parameters.get("properties", {}).keys()) - unknown_args = set(transform_args.keys()) - parent_params - if unknown_args: - raise ValueError( - f"Unknown arguments in transform_args: {', '.join(sorted(unknown_args))}. " - f"Parent tool `{tool.name}` has: {', '.join(sorted(parent_params))}" - ) - - # Always create the forwarding transform - schema, forwarding_fn = cls._create_forwarding_transform(tool, transform_args) - - # Handle output schema - if output_schema is NotSet: - # Use smart fallback: try custom function, then parent - if transform_fn is not None: - # parsed fn is not none here - final_output_schema = cast(ParsedFunction, parsed_fn).output_schema - if final_output_schema is None: - # Check if function returns ToolResult (or subclass) - if so, don't fall back to parent. - # Use parsed_fn.return_type (resolved via get_type_hints) instead of - # inspect.signature, which returns strings under `from __future__ import annotations`. - return_type = cast(ParsedFunction, parsed_fn).return_type - if issubclass_safe(return_type, ToolResult): - final_output_schema = None - else: - final_output_schema = tool.output_schema - else: - final_output_schema = tool.output_schema - else: - final_output_schema = cast(dict | None, output_schema) - - if transform_fn is None: - # User wants pure transformation - use forwarding_fn as the main function - final_fn = forwarding_fn - final_schema = schema - else: - # parsed fn is not none here - parsed_fn = cast(ParsedFunction, parsed_fn) - # User provided custom function - merge schemas - final_fn = transform_fn - - has_kwargs = cls._function_has_kwargs(transform_fn) - - # Validate function parameters against transformed schema - fn_params = set(parsed_fn.input_schema.get("properties", {}).keys()) - transformed_params = set(schema.get("properties", {}).keys()) - - if not has_kwargs: - # Without **kwargs, function must declare all transformed params - # Check if function is missing any parameters required after transformation - missing_params = transformed_params - fn_params - if missing_params: - raise ValueError( - f"Function missing parameters required after transformation: " - f"{', '.join(sorted(missing_params))}. " - f"Function declares: {', '.join(sorted(fn_params))}" - ) - - # ArgTransform takes precedence over function signature - # Start with function schema as base, then override with transformed schema - final_schema = cls._merge_schema_with_precedence( - parsed_fn.input_schema, schema - ) - else: - # With **kwargs, function can access all transformed params - # ArgTransform takes precedence over function signature - # No validation needed - kwargs makes everything accessible - - # Start with function schema as base, then override with transformed schema - final_schema = cls._merge_schema_with_precedence( - parsed_fn.input_schema, schema - ) - - # Additional validation: check for naming conflicts after transformation - if transform_args: - new_names = [] - for old_name in parent_params: - transform = transform_args.get(old_name, ArgTransform()) - - if transform.hide: - continue - - if transform.name is not NotSet: - new_names.append(transform.name) - else: - new_names.append(old_name) - - # Check for duplicate names after transformation - name_counts = {} - for arg_name in new_names: - name_counts[arg_name] = name_counts.get(arg_name, 0) + 1 - - duplicates = [ - arg_name for arg_name, count in name_counts.items() if count > 1 - ] - if duplicates: - raise ValueError( - f"Multiple arguments would be mapped to the same names: " - f"{', '.join(sorted(duplicates))}" - ) - - final_name = name or tool.name - final_version = version if not isinstance(version, NotSetT) else tool.version - final_description = ( - description if not isinstance(description, NotSetT) else tool.description - ) - final_title = title if not isinstance(title, NotSetT) else tool.title - final_meta = meta if not isinstance(meta, NotSetT) else tool.meta - final_annotations = ( - annotations if not isinstance(annotations, NotSetT) else tool.annotations - ) - final_serializer = ( - serializer if not isinstance(serializer, NotSetT) else tool.serializer - ) - - transformed_tool = cls( - fn=final_fn, - forwarding_fn=forwarding_fn, - parent_tool=tool, - name=final_name, - version=final_version, - title=final_title, - description=final_description, - parameters=final_schema, - output_schema=final_output_schema, - tags=tags or tool.tags, - annotations=final_annotations, - serializer=final_serializer, - meta=final_meta, - transform_args=transform_args, - auth=tool.auth, - ) - - return transformed_tool - - @classmethod - def _create_forwarding_transform( - cls, - parent_tool: Tool, - transform_args: dict[str, ArgTransform] | None, - ) -> tuple[dict[str, Any], Callable[..., Any]]: - """Create schema and forwarding function that encapsulates all transformation logic. - - This method builds a new JSON schema for the transformed tool and creates a - forwarding function that validates arguments against the new schema and maps - them back to the parent tool's expected arguments. - - Args: - parent_tool: The original tool to transform. - transform_args: Dictionary defining how to transform each argument. - - Returns: - A tuple containing: - - The new JSON schema for the transformed tool as a dictionary - - Async function that validates and forwards calls to the parent tool - """ - - # Build transformed schema and mapping - # Deep copy to prevent compress_schema from mutating parent tool's $defs - parent_defs = deepcopy(parent_tool.parameters.get("$defs", {})) - parent_props = parent_tool.parameters.get("properties", {}).copy() - parent_required = set(parent_tool.parameters.get("required", [])) - - new_props = {} - new_required = set() - new_to_old = {} - hidden_defaults = {} # Track hidden parameters with constant values - - for old_name, old_schema in parent_props.items(): - # Check if parameter is in transform_args - if transform_args and old_name in transform_args: - transform = transform_args[old_name] - else: - # Default behavior - pass through (no transformation) - transform = ArgTransform() # Default ArgTransform with no changes - - # Handle hidden parameters with defaults - if transform.hide: - # Validate that hidden parameters without user defaults have parent defaults - has_user_default = ( - transform.default is not NotSet - or transform.default_factory is not NotSet - ) - if not has_user_default and old_name in parent_required: - raise ValueError( - f"Hidden parameter '{old_name}' has no default value in parent tool " - f"and no default or default_factory provided in ArgTransform. Either provide a default " - f"or default_factory in ArgTransform or don't hide required parameters." - ) - if has_user_default: - # Store info for later factory calling or direct value - hidden_defaults[old_name] = transform - # Skip adding to schema (not exposed to clients) - continue - - transform_result = cls._apply_single_transform( - old_name, - old_schema, - transform, - old_name in parent_required, - ) - - if transform_result: - new_name, new_schema, is_required = transform_result - new_props[new_name] = new_schema - new_to_old[new_name] = old_name - if is_required: - new_required.add(new_name) - - schema = { - "type": "object", - "properties": new_props, - "required": list(new_required), - "additionalProperties": False, - } - - if parent_defs: - schema["$defs"] = parent_defs - schema = compress_schema(schema) - - # Create forwarding function that closes over everything it needs - async def _forward(**kwargs: Any): - # Validate arguments - valid_args = set(new_props.keys()) - provided_args = set(kwargs.keys()) - unknown_args = provided_args - valid_args - - if unknown_args: - raise TypeError( - f"Got unexpected keyword argument(s): {', '.join(sorted(unknown_args))}" - ) - - # Check required arguments - missing_args = new_required - provided_args - if missing_args: - raise TypeError( - f"Missing required argument(s): {', '.join(sorted(missing_args))}" - ) - - # Map arguments to parent names - parent_args = {} - for new_name, value in kwargs.items(): - old_name = new_to_old.get(new_name, new_name) - parent_args[old_name] = value - - # Add hidden defaults (constant values for hidden parameters) - for old_name, transform in hidden_defaults.items(): - if transform.default is not NotSet: - parent_args[old_name] = transform.default - elif transform.default_factory is not NotSet: - # Type check to ensure default_factory is callable - if callable(transform.default_factory): - parent_args[old_name] = transform.default_factory() - - return await parent_tool.run(parent_args) - - return schema, _forward - - @staticmethod - def _apply_single_transform( - old_name: str, - old_schema: dict[str, Any], - transform: ArgTransform, - is_required: bool, - ) -> tuple[str, dict[str, Any], bool] | None: - """Apply transformation to a single parameter. - - This method handles the transformation of a single argument according to - the specified transformation rules. - - Args: - old_name: Original name of the parameter. - old_schema: Original JSON schema for the parameter. - transform: ArgTransform object specifying how to transform the parameter. - is_required: Whether the original parameter was required. - - Returns: - Tuple of (new_name, new_schema, new_is_required) if parameter should be kept, - None if parameter should be dropped. - """ - if transform.hide: - return None - - # Handle name transformation - ensure we always have a string - if transform.name is not NotSet: - new_name = transform.name if transform.name is not None else old_name - else: - new_name = old_name - - # Ensure new_name is always a string - if not isinstance(new_name, str): - new_name = old_name - - new_schema = old_schema.copy() - - # Handle description transformation - if transform.description is not NotSet: - if transform.description is None: - new_schema.pop("description", None) # Remove description - else: - new_schema["description"] = transform.description - - # Handle required transformation first - if transform.required is not NotSet: - is_required = bool(transform.required) - if transform.required is True: - # Remove any existing default when making required - new_schema.pop("default", None) - - # Handle default value transformation (only if not making required) - if transform.default is not NotSet and transform.required is not True: - new_schema["default"] = transform.default - is_required = False - - # Handle type transformation - if transform.type is not NotSet: - # Use TypeAdapter to get proper JSON schema for the type - type_schema = get_cached_typeadapter(transform.type).json_schema() - # Update the schema with the type information from TypeAdapter - new_schema.update(type_schema) - - # Handle examples transformation - if transform.examples is not NotSet: - new_schema["examples"] = transform.examples - - return new_name, new_schema, is_required - - @staticmethod - def _merge_schema_with_precedence( - base_schema: dict[str, Any], override_schema: dict[str, Any] - ) -> dict[str, Any]: - """Merge two schemas, with the override schema taking precedence. - - Args: - base_schema: Base schema to start with - override_schema: Schema that takes precedence for overlapping properties - - Returns: - Merged schema with override taking precedence - """ - merged_props = base_schema.get("properties", {}).copy() - merged_required = set(base_schema.get("required", [])) - - override_props = override_schema.get("properties", {}) - override_required = set(override_schema.get("required", [])) - - # Override properties - for param_name, param_schema in override_props.items(): - if param_name in merged_props: - # Merge the schemas, with override taking precedence - base_param = merged_props[param_name].copy() - base_param.update(param_schema) - merged_props[param_name] = base_param - else: - merged_props[param_name] = param_schema.copy() - - # Handle required parameters - override takes complete precedence - # Start with override's required set - final_required = override_required.copy() - - # For parameters not in override, inherit base requirement status - # but only if they don't have a default in the final merged properties - for param_name in merged_required: - if param_name not in override_props: - # Parameter not mentioned in override, keep base requirement status - final_required.add(param_name) - elif ( - param_name in override_props - and "default" not in merged_props[param_name] - ): - # Parameter in override but no default, keep required if it was required in base - if param_name not in override_required: - # Override doesn't specify it as required, and it has no default, - # so inherit from base - final_required.add(param_name) - - # Remove any parameters that have defaults (they become optional) - for param_name, param_schema in merged_props.items(): - if "default" in param_schema: - final_required.discard(param_name) - - # Merge $defs from both schemas, with override taking precedence - merged_defs = base_schema.get("$defs", {}).copy() - override_defs = override_schema.get("$defs", {}) - - for def_name, def_schema in override_defs.items(): - if def_name in merged_defs: - base_def = merged_defs[def_name].copy() - base_def.update(def_schema) - merged_defs[def_name] = base_def - else: - merged_defs[def_name] = def_schema.copy() - - result = { - "type": "object", - "properties": merged_props, - "required": list(final_required), - "additionalProperties": False, - } - - if merged_defs: - result["$defs"] = merged_defs - result = compress_schema(result) - - return result - - @staticmethod - def _function_has_kwargs(fn: Callable[..., Any]) -> bool: - """Check if function accepts **kwargs. - - This determines whether a custom function can accept arbitrary keyword arguments, - which affects how schemas are merged during tool transformation. - - Args: - fn: Function to inspect. - - Returns: - True if the function has a **kwargs parameter, False otherwise. - """ - sig = inspect.signature(fn) - return any( - p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values() - ) - - -def _set_visibility_metadata(tool: Tool, *, enabled: bool) -> None: - """Set visibility state in tool metadata. - - This uses the same metadata format as the Visibility transform, - so tools marked here will be filtered by the standard visibility system. - - Args: - tool: Tool to mark. - enabled: Whether the tool should be visible to clients. - """ - # Import here to avoid circular imports - from fastmcp.server.transforms.visibility import _FASTMCP_KEY, _INTERNAL_KEY - - if tool.meta is None: - tool.meta = {_FASTMCP_KEY: {_INTERNAL_KEY: {"visibility": enabled}}} - else: - old_fastmcp = tool.meta.get(_FASTMCP_KEY, {}) - old_internal = old_fastmcp.get(_INTERNAL_KEY, {}) - new_internal = {**old_internal, "visibility": enabled} - new_fastmcp = {**old_fastmcp, _INTERNAL_KEY: new_internal} - tool.meta = {**tool.meta, _FASTMCP_KEY: new_fastmcp} - - -class ToolTransformConfig(FastMCPBaseModel): - """Provides a way to transform a tool.""" - - name: str | None = Field(default=None, description="The new name for the tool.") - version: str | None = Field( - default=None, description="The new version for the tool." - ) - title: str | None = Field( - default=None, - description="The new title of the tool.", - ) - description: str | None = Field( - default=None, - description="The new description of the tool.", - ) - tags: Annotated[set[str], BeforeValidator(_convert_set_default_none)] = Field( - default_factory=set, - description="The new tags for the tool.", - ) - meta: dict[str, Any] | None = Field( - default=None, - description="The new meta information for the tool.", - ) - enabled: bool = Field( - default=True, - description="Whether the tool is enabled. If False, the tool will be hidden from clients.", - ) - - arguments: dict[str, ArgTransformConfig] = Field( - default_factory=dict, - description="A dictionary of argument transforms to apply to the tool.", - ) - - def apply(self, tool: Tool) -> TransformedTool: - """Create a TransformedTool from a provided tool and this transformation configuration.""" - - tool_changes: dict[str, Any] = self.model_dump( - exclude_unset=True, exclude={"arguments", "enabled"} - ) - - transformed = TransformedTool.from_tool( - tool=tool, - **tool_changes, - transform_args={k: v.to_arg_transform() for k, v in self.arguments.items()}, - ) - - # Set visibility metadata if enabled was explicitly provided. - # This allows enabled=True to override an earlier disable (later transforms win). - if "enabled" in self.model_fields_set: - _set_visibility_metadata(transformed, enabled=self.enabled) - - return transformed - - -def apply_transformations_to_tools( - tools: dict[str, Tool], - transformations: dict[str, ToolTransformConfig], -) -> dict[str, Tool]: - """Apply a list of transformations to a list of tools. Tools that do not have any transformations - are left unchanged. - - Note: tools dict is keyed by prefixed key (e.g., "tool:my_tool"), - but transformations are keyed by tool name (e.g., "my_tool"). - """ - - transformed_tools: dict[str, Tool] = {} - - for tool_key, tool in tools.items(): - # Look up transformation by tool name, not prefixed key - if transformation := transformations.get(tool.name): - transformed = transformation.apply(tool) - transformed_tools[transformed.key] = transformed - continue - - transformed_tools[tool_key] = tool - - return transformed_tools diff --git a/src/fastmcp/types.py b/src/fastmcp/types.py deleted file mode 100644 index f078ceb6b..000000000 --- a/src/fastmcp/types.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Reusable type annotations for FastMCP tool parameters. - -These types can be used in tool function signatures to influence how -parameters are presented in UIs (e.g. ``fastmcp dev apps``) and -serialized in JSON Schema. - -Example:: - - from fastmcp import FastMCP - from fastmcp.types import Textarea - - mcp = FastMCP("demo") - - @mcp.tool() - def run_query(sql: Textarea) -> str: - ... -""" - -from __future__ import annotations - -from typing import Annotated - -from pydantic import Field - -Textarea = Annotated[str, Field(json_schema_extra={"format": "textarea"})] -"""A string rendered as a multiline textarea in form-based UIs. - -Produces ``"format": "textarea"`` in the JSON Schema, which -``fastmcp dev apps`` picks up automatically. -""" - -__all__ = ["Textarea"] diff --git a/src/fastmcp/utilities/__init__.py b/src/fastmcp/utilities/__init__.py deleted file mode 100644 index be448f97a..000000000 --- a/src/fastmcp/utilities/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""FastMCP utility modules.""" diff --git a/src/fastmcp/utilities/async_utils.py b/src/fastmcp/utilities/async_utils.py deleted file mode 100644 index 3f7e816fb..000000000 --- a/src/fastmcp/utilities/async_utils.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Async utilities for FastMCP.""" - -import asyncio -import functools -import inspect -from collections.abc import Awaitable, Callable -from typing import Any, Literal, TypeVar, overload - -import anyio -from anyio.to_thread import run_sync as run_sync_in_threadpool - -T = TypeVar("T") - - -def is_coroutine_function(fn: Any) -> bool: - """Check if a callable is a coroutine function, unwrapping functools.partial. - - ``inspect.iscoroutinefunction`` returns ``False`` for - ``functools.partial`` objects wrapping an async function on Python < 3.12. - This helper unwraps any layers of ``partial`` before checking. - """ - while isinstance(fn, functools.partial): - fn = fn.func - return inspect.iscoroutinefunction(fn) or asyncio.iscoroutinefunction(fn) - - -async def call_sync_fn_in_threadpool( - fn: Callable[..., Any], *args: Any, **kwargs: Any -) -> Any: - """Call a sync function in a threadpool to avoid blocking the event loop. - - Uses anyio.to_thread.run_sync which properly propagates contextvars, - making this safe for functions that depend on context (like dependency injection). - """ - return await run_sync_in_threadpool(functools.partial(fn, *args, **kwargs)) - - -@overload -async def gather( - *awaitables: Awaitable[T], - return_exceptions: Literal[True], -) -> list[T | BaseException]: ... - - -@overload -async def gather( - *awaitables: Awaitable[T], - return_exceptions: Literal[False] = ..., -) -> list[T]: ... - - -async def gather( - *awaitables: Awaitable[T], - return_exceptions: bool = False, -) -> list[T] | list[T | BaseException]: - """Run awaitables concurrently and return results in order. - - Uses anyio TaskGroup for structured concurrency. - - Args: - *awaitables: Awaitables to run concurrently - return_exceptions: If True, exceptions are returned in results. - If False, first exception cancels all and raises. - - Returns: - List of results in the same order as input awaitables. - """ - results: list[T | BaseException] = [None] * len(awaitables) # type: ignore[assignment] # ty:ignore[invalid-assignment] - - async def run_at(i: int, aw: Awaitable[T]) -> None: - try: - results[i] = await aw - except BaseException as e: - if return_exceptions: - results[i] = e - else: - raise - - async with anyio.create_task_group() as tg: - for i, aw in enumerate(awaitables): - tg.start_soon(run_at, i, aw) - - return results diff --git a/src/fastmcp/utilities/auth.py b/src/fastmcp/utilities/auth.py deleted file mode 100644 index 03eb59bb3..000000000 --- a/src/fastmcp/utilities/auth.py +++ /dev/null @@ -1,91 +0,0 @@ -"""Authentication utility helpers.""" - -from __future__ import annotations - -import base64 -import json -from typing import Any - - -def _decode_jwt_part(token: str, part_index: int) -> dict[str, Any]: - """Decode a JWT part (header or payload) without signature verification. - - Args: - token: JWT token string (header.payload.signature) - part_index: 0 for header, 1 for payload - - Returns: - Decoded part as a dictionary - - Raises: - ValueError: If token is not a valid JWT format - """ - parts = token.split(".") - if len(parts) != 3: - raise ValueError("Invalid JWT format (expected 3 parts)") - - part_b64 = parts[part_index] - part_b64 += "=" * (-len(part_b64) % 4) # Add padding - return json.loads(base64.urlsafe_b64decode(part_b64)) - - -def decode_jwt_header(token: str) -> dict[str, Any]: - """Decode JWT header without signature verification. - - Useful for extracting the key ID (kid) for JWKS lookup. - - Args: - token: JWT token string (header.payload.signature) - - Returns: - Decoded header as a dictionary - - Raises: - ValueError: If token is not a valid JWT format - """ - return _decode_jwt_part(token, 0) - - -def decode_jwt_payload(token: str) -> dict[str, Any]: - """Decode JWT payload without signature verification. - - Use only for tokens received directly from trusted sources (e.g., IdP token endpoints). - - Args: - token: JWT token string (header.payload.signature) - - Returns: - Decoded payload as a dictionary - - Raises: - ValueError: If token is not a valid JWT format - """ - return _decode_jwt_part(token, 1) - - -def parse_scopes(value: Any) -> list[str] | None: - """Parse scopes from environment variables or settings values. - - Accepts either a JSON array string, a comma- or space-separated string, - a list of strings, or ``None``. Returns a list of scopes or ``None`` if - no value is provided. - """ - if value is None or value == "": - return None if value is None else [] - if isinstance(value, list): - return [str(v).strip() for v in value if str(v).strip()] - if isinstance(value, str): - value = value.strip() - if not value: - return [] - # Try JSON array first - if value.startswith("["): - try: - data = json.loads(value) - if isinstance(data, list): - return [str(v).strip() for v in data if str(v).strip()] - except Exception: - pass - # Fallback to comma/space separated list - return [s.strip() for s in value.replace(",", " ").split() if s.strip()] - return value diff --git a/src/fastmcp/utilities/cli.py b/src/fastmcp/utilities/cli.py deleted file mode 100644 index cac3e65e3..000000000 --- a/src/fastmcp/utilities/cli.py +++ /dev/null @@ -1,269 +0,0 @@ -from __future__ import annotations - -import json -import os -from pathlib import Path -from typing import TYPE_CHECKING, Any - -from pydantic import ValidationError -from rich.align import Align -from rich.console import Console, Group -from rich.panel import Panel -from rich.table import Table -from rich.text import Text - -import fastmcp -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config import MCPServerConfig -from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource -from fastmcp.utilities.types import get_cached_typeadapter -from fastmcp.utilities.version_check import check_for_newer_version - -if TYPE_CHECKING: - from fastmcp import FastMCP - -logger = get_logger("cli.config") - - -def is_already_in_uv_subprocess() -> bool: - """Check if we're already running in a FastMCP uv subprocess.""" - return bool(os.environ.get("FASTMCP_UV_SPAWNED")) - - -def load_and_merge_config( - server_spec: str | None, - **cli_overrides, -) -> tuple[MCPServerConfig, str]: - """Load config from server_spec and apply CLI overrides. - - This consolidates the config parsing logic that was duplicated across - run, inspect, and dev commands. - - Args: - server_spec: Python file, config file, URL, or None to auto-detect - cli_overrides: CLI arguments that override config values - - Returns: - Tuple of (MCPServerConfig, resolved_server_spec) - """ - config = None - config_path = None - - # Auto-detect fastmcp.json if no server_spec provided - if server_spec is None: - config_path = Path("fastmcp.json") - if not config_path.exists(): - found_config = MCPServerConfig.find_config() - if found_config: - config_path = found_config - else: - logger.error( - "No server specification provided and no fastmcp.json found in current directory.\n" - "Please specify a server file or create a fastmcp.json configuration." - ) - raise FileNotFoundError("No server specification or fastmcp.json found") - - resolved_spec = str(config_path) - logger.info(f"Using configuration from {config_path}") - else: - resolved_spec = server_spec - - # Load config if server_spec is a .json file - if resolved_spec.endswith(".json"): - config_path = Path(resolved_spec) - if config_path.exists(): - try: - with open(config_path) as f: - data = json.load(f) - - # Check if it's an MCPConfig first (has canonical mcpServers key) - if "mcpServers" in data: - # MCPConfig - we don't process these here, just pass through - pass - else: - # Try to parse as MCPServerConfig - try: - adapter = get_cached_typeadapter(MCPServerConfig) - config = adapter.validate_python(data) - - # Apply deployment settings - if config.deployment: - config.deployment.apply_runtime_settings(config_path) - - except ValidationError: - # Not a valid MCPServerConfig, just pass through - pass - except (json.JSONDecodeError, FileNotFoundError): - # Not a valid JSON file, just pass through - pass - - # If we don't have a config object yet, create one from filesystem source - if config is None: - source = FileSystemSource(path=resolved_spec) - config = MCPServerConfig(source=source) - - # Convert to dict for immutable transformation - config_dict = config.model_dump() - - # Apply CLI overrides to config's environment (always exists due to default_factory) - if python_override := cli_overrides.get("python"): - config_dict["environment"]["python"] = python_override - if packages_override := cli_overrides.get("with_packages"): - # Merge packages - CLI packages are added to config packages - existing = config_dict["environment"].get("dependencies") or [] - config_dict["environment"]["dependencies"] = packages_override + existing - if requirements_override := cli_overrides.get("with_requirements"): - config_dict["environment"]["requirements"] = str(requirements_override) - if project_override := cli_overrides.get("project"): - config_dict["environment"]["project"] = str(project_override) - if editable_override := cli_overrides.get("editable"): - config_dict["environment"]["editable"] = editable_override - - # Apply deployment CLI overrides (always exists due to default_factory) - if transport_override := cli_overrides.get("transport"): - config_dict["deployment"]["transport"] = transport_override - if host_override := cli_overrides.get("host"): - config_dict["deployment"]["host"] = host_override - if port_override := cli_overrides.get("port"): - config_dict["deployment"]["port"] = port_override - if path_override := cli_overrides.get("path"): - config_dict["deployment"]["path"] = path_override - if log_level_override := cli_overrides.get("log_level"): - config_dict["deployment"]["log_level"] = log_level_override - if server_args_override := cli_overrides.get("server_args"): - config_dict["deployment"]["args"] = server_args_override - - # Create new config from modified dict - new_config = MCPServerConfig(**config_dict) - return new_config, resolved_spec - - -LOGO_ASCII_1 = r""" - _ __ ___ _____ __ __ _____________ ____ ____ - _ __ ___ .'____/___ ______/ /_/ |/ / ____/ __ \ |___ \ / __ \ - _ __ ___ / /_ / __ `/ ___/ __/ /|_/ / / / /_/ / ___/ / / / / / - _ __ ___ / __/ / /_/ (__ ) /_/ / / / /___/ ____/ / __/_/ /_/ / -_ __ ___ /_/ \____/____/\__/_/ /_/\____/_/ /_____(*)____/ - -""".lstrip("\n") - -# This prints the below in a blue gradient -# █▀▀ ▄▀█ █▀▀ ▀█▀ █▀▄▀█ █▀▀ █▀█ -# █▀ █▀█ ▄▄█ █ █ ▀ █ █▄▄ █▀▀ -LOGO_ASCII_2 = ( - "\x1b[38;2;0;198;255m \x1b[38;2;0;195;255m█\x1b[38;2;0;192;255m▀\x1b[38;2;0;189;255m▀\x1b[38;2;0;186;255m " - "\x1b[38;2;0;184;255m▄\x1b[38;2;0;181;255m▀\x1b[38;2;0;178;255m█\x1b[38;2;0;175;255m " - "\x1b[38;2;0;172;255m█\x1b[38;2;0;169;255m▀\x1b[38;2;0;166;255m▀\x1b[38;2;0;163;255m " - "\x1b[38;2;0;160;255m▀\x1b[38;2;0;157;255m█\x1b[38;2;0;155;255m▀\x1b[38;2;0;152;255m " - "\x1b[38;2;0;149;255m█\x1b[38;2;0;146;255m▀\x1b[38;2;0;143;255m▄\x1b[38;2;0;140;255m▀\x1b[38;2;0;137;255m█\x1b[38;2;0;134;255m " - "\x1b[38;2;0;131;255m█\x1b[38;2;0;128;255m▀\x1b[38;2;0;126;255m▀\x1b[38;2;0;123;255m " - "\x1b[38;2;0;120;255m█\x1b[38;2;0;117;255m▀\x1b[38;2;0;114;255m█\x1b[39m\n" - "\x1b[38;2;0;198;255m \x1b[38;2;0;195;255m█\x1b[38;2;0;192;255m▀\x1b[38;2;0;189;255m \x1b[38;2;0;186;255m " - "\x1b[38;2;0;184;255m█\x1b[38;2;0;181;255m▀\x1b[38;2;0;178;255m█\x1b[38;2;0;175;255m " - "\x1b[38;2;0;172;255m▄\x1b[38;2;0;169;255m▄\x1b[38;2;0;166;255m█\x1b[38;2;0;163;255m " - "\x1b[38;2;0;160;255m \x1b[38;2;0;157;255m█\x1b[38;2;0;155;255m \x1b[38;2;0;152;255m " - "\x1b[38;2;0;149;255m█\x1b[38;2;0;146;255m \x1b[38;2;0;143;255m▀\x1b[38;2;0;140;255m \x1b[38;2;0;137;255m█\x1b[38;2;0;134;255m " - "\x1b[38;2;0;131;255m█\x1b[38;2;0;128;255m▄\x1b[38;2;0;126;255m▄\x1b[38;2;0;123;255m " - "\x1b[38;2;0;120;255m█\x1b[38;2;0;117;255m▀\x1b[38;2;0;114;255m▀\x1b[39m" -).strip() - -# Prints the below in a blue gradient - stylized F -# ▄▀▀▀ -# █▀▀ -# ▀ -LOGO_ASCII_3 = ( - " \x1b[38;2;0;170;255m▄\x1b[38;2;0;142;255m▀\x1b[38;2;0;114;255m▀\x1b[38;2;0;86;255m▀\x1b[39m\n" - " \x1b[38;2;0;170;255m█\x1b[38;2;0;142;255m▀\x1b[38;2;0;114;255m▀\x1b[39m\n" - "\x1b[38;2;0;170;255m▀\x1b[39m\n" - "\x1b[0m" -) - -# Prints the below in a blue gradient - block logo with slightly stylized F -# ▄▀▀ ▄▀█ █▀▀ ▀█▀ █▀▄▀█ █▀▀ █▀█ -# █▀ █▀█ ▄▄█ █ █ ▀ █ █▄▄ █▀▀ - -LOGO_ASCII_4 = ( - "\x1b[38;2;0;198;255m \x1b[38;2;0;195;255m▄\x1b[38;2;0;192;255m▀\x1b[38;2;0;189;255m▀\x1b[38;2;0;186;255m \x1b[38;2;0;184;255m▄\x1b[38;2;0;181;255m▀\x1b[38;2;0;178;255m█\x1b[38;2;0;175;255m " - "\x1b[38;2;0;172;255m█\x1b[38;2;0;169;255m▀\x1b[38;2;0;166;255m▀\x1b[38;2;0;163;255m " - "\x1b[38;2;0;160;255m▀\x1b[38;2;0;157;255m█\x1b[38;2;0;155;255m▀\x1b[38;2;0;152;255m " - "\x1b[38;2;0;149;255m█\x1b[38;2;0;146;255m▀\x1b[38;2;0;143;255m▄\x1b[38;2;0;140;255m▀\x1b[38;2;0;137;255m█\x1b[38;2;0;134;255m " - "\x1b[38;2;0;131;255m█\x1b[38;2;0;128;255m▀\x1b[38;2;0;126;255m▀\x1b[38;2;0;123;255m " - "\x1b[38;2;0;120;255m█\x1b[38;2;0;117;255m▀\x1b[38;2;0;114;255m█\x1b[39m\n" - "\x1b[38;2;0;198;255m \x1b[38;2;0;195;255m█\x1b[38;2;0;192;255m▀\x1b[38;2;0;189;255m \x1b[38;2;0;186;255m \x1b[38;2;0;184;255m█\x1b[38;2;0;181;255m▀\x1b[38;2;0;178;255m█\x1b[38;2;0;175;255m " - "\x1b[38;2;0;172;255m▄\x1b[38;2;0;169;255m▄\x1b[38;2;0;166;255m█\x1b[38;2;0;163;255m " - "\x1b[38;2;0;160;255m \x1b[38;2;0;157;255m█\x1b[38;2;0;155;255m \x1b[38;2;0;152;255m " - "\x1b[38;2;0;149;255m█\x1b[38;2;0;146;255m \x1b[38;2;0;143;255m▀\x1b[38;2;0;140;255m \x1b[38;2;0;137;255m█\x1b[38;2;0;134;255m " - "\x1b[38;2;0;131;255m█\x1b[38;2;0;128;255m▄\x1b[38;2;0;126;255m▄\x1b[38;2;0;123;255m " - "\x1b[38;2;0;120;255m█\x1b[38;2;0;117;255m▀\x1b[38;2;0;114;255m▀\x1b[39m\n" -) - - -def log_server_banner(server: FastMCP[Any]) -> None: - """Creates and logs a formatted banner with server information and logo.""" - - # Check for updates (non-blocking, fails silently) - newer_version = check_for_newer_version() - - # Create the logo text - # Use Text with no_wrap and markup disabled to preserve ANSI escape codes - logo_text = Text.from_ansi(LOGO_ASCII_4, no_wrap=True) - - # Create the main title - title_text = Text(f"FastMCP {fastmcp.__version__}", style="bold blue") - - # Create the information table - info_table = Table.grid(padding=(0, 1)) - info_table.add_column(style="bold", justify="center") # Emoji column - info_table.add_column(style="cyan", justify="left") # Label column - info_table.add_column(style="dim", justify="left") # Value column - - server_info = server.name - if server.version: - server_info += f", {server.version}" - info_table.add_row("🖥", "Server:", Text(server_info, style="dim")) - info_table.add_row("🚀", "Deploy free:", "https://horizon.prefect.io") - - # Create panel with logo, title, and information using Group - docs_url = Text("https://gofastmcp.com", style="dim") - panel_content = Group( - "", - Align.center(logo_text), - "", - "", - Align.center(title_text), - Align.center(docs_url), - "", - Align.center(info_table), - ) - - panel = Panel( - panel_content, - border_style="dim", - padding=(1, 4), - # expand=False, - width=80, # Set max width for the panel - ) - - console = Console(stderr=True) - - # Build output elements - output_elements: list[Align | Panel | str] = ["\n", Align.center(panel)] - - # Add update notice if a newer version is available (shown last for visibility) - if newer_version: - update_line1 = Text.assemble( - ("🎉 Update available: ", "bold"), - (newer_version, "bold green"), - ) - update_line2 = Text("Run: pip install --upgrade fastmcp", style="dim") - update_notice = Panel( - Group(Align.center(update_line1), Align.center(update_line2)), - border_style="blue", - padding=(0, 2), - width=80, - ) - output_elements.append(Align.center(update_notice)) - - output_elements.append("\n") - - console.print(Group(*output_elements)) diff --git a/src/fastmcp/utilities/components.py b/src/fastmcp/utilities/components.py deleted file mode 100644 index 34ab62c3b..000000000 --- a/src/fastmcp/utilities/components.py +++ /dev/null @@ -1,255 +0,0 @@ -from __future__ import annotations - -from collections.abc import Sequence -from typing import TYPE_CHECKING, Annotated, Any, ClassVar, TypedDict, cast - -from mcp.types import Icon -from pydantic import BeforeValidator, Field -from typing_extensions import Self, TypeVar - -from fastmcp.server.tasks.config import TaskConfig -from fastmcp.utilities.types import FastMCPBaseModel - -if TYPE_CHECKING: - from docket import Docket - from docket.execution import Execution - -T = TypeVar("T", default=Any) - - -class FastMCPMeta(TypedDict, total=False): - tags: list[str] - version: str - versions: list[str] - - -def get_fastmcp_metadata(meta: dict[str, Any] | None) -> FastMCPMeta: - """Extract FastMCP metadata from a component's meta dict. - - Handles both the current `fastmcp` namespace and the legacy `_fastmcp` - namespace for compatibility with older FastMCP servers. - """ - if not meta: - return {} - - for key in ("fastmcp", "_fastmcp"): - metadata = meta.get(key) - if isinstance(metadata, dict): - return cast(FastMCPMeta, metadata) - - return {} - - -def _convert_set_default_none(maybe_set: set[T] | Sequence[T] | None) -> set[T]: - """Convert a sequence to a set, defaulting to an empty set if None.""" - if maybe_set is None: - return set() - if isinstance(maybe_set, set): - return maybe_set - return set(maybe_set) - - -def _coerce_version(v: str | int | float | None) -> str | None: - """Coerce version to string, accepting int, float, or str. - - Raises TypeError for non-scalar types (list, dict, set, etc.). - Raises ValueError if version contains '@' (used as key delimiter). - """ - if v is None: - return None - if isinstance(v, bool): - raise TypeError(f"Version must be a string, int, or float, got bool: {v!r}") - if not isinstance(v, (str, int, float)): - raise TypeError( - f"Version must be a string, int, or float, got {type(v).__name__}: {v!r}" - ) - version = str(v) - if "@" in version: - raise ValueError( - f"Version string cannot contain '@' (used as key delimiter): {version!r}" - ) - return version - - -class FastMCPComponent(FastMCPBaseModel): - """Base class for FastMCP tools, prompts, resources, and resource templates.""" - - KEY_PREFIX: ClassVar[str] = "" - - def __init_subclass__(cls, **kwargs: Any) -> None: - super().__init_subclass__(**kwargs) - # Warn if a subclass doesn't define KEY_PREFIX (inherited or its own) - if not cls.KEY_PREFIX: - import warnings - - warnings.warn( - f"{cls.__name__} does not define KEY_PREFIX. " - f"Component keys will not be type-prefixed, which may cause collisions.", - UserWarning, - stacklevel=2, - ) - - name: str = Field( - description="The name of the component.", - ) - version: Annotated[str | None, BeforeValidator(_coerce_version)] = Field( - default=None, - description="Optional version identifier for this component. " - "Multiple versions of the same component (same name) can coexist.", - ) - title: str | None = Field( - default=None, - description="The title of the component for display purposes.", - ) - description: str | None = Field( - default=None, - description="The description of the component.", - ) - icons: list[Icon] | None = Field( - default=None, - description="Optional list of icons for this component to display in user interfaces.", - ) - tags: Annotated[set[str], BeforeValidator(_convert_set_default_none)] = Field( - default_factory=set, - description="Tags for the component.", - ) - meta: dict[str, Any] | None = Field( - default=None, description="Meta information about the component" - ) - task_config: Annotated[ - TaskConfig, - Field(description="Background task execution configuration (SEP-1686)."), - ] = Field(default_factory=lambda: TaskConfig(mode="forbidden")) - - @classmethod - def make_key(cls, identifier: str) -> str: - """Construct the lookup key for this component type. - - Args: - identifier: The raw identifier (name for tools/prompts, uri for resources) - - Returns: - A prefixed key like "tool:name" or "resource:uri" - """ - if cls.KEY_PREFIX: - return f"{cls.KEY_PREFIX}:{identifier}" - return identifier - - @property - def key(self) -> str: - """The globally unique lookup key for this component. - - Format: "{key_prefix}:{identifier}@{version}" or "{key_prefix}:{identifier}@" - e.g. "tool:my_tool@v2", "tool:my_tool@", "resource:file://x.txt@" - - The @ suffix is ALWAYS present to enable unambiguous parsing of keys - (URIs may contain @ characters, so we always include the delimiter). - - Subclasses should override this to use their specific identifier. - Base implementation uses name. - """ - base_key = self.make_key(self.name) - return f"{base_key}@{self.version or ''}" - - def get_meta(self) -> dict[str, Any]: - """Get the meta information about the component. - - Returns a dict that always includes a `fastmcp` key containing: - - `tags`: sorted list of component tags - - `version`: component version (only if set) - - Internal keys (prefixed with `_`) are stripped from the fastmcp namespace. - """ - meta = dict(self.meta) if self.meta else {} - - fastmcp_meta: FastMCPMeta = {"tags": sorted(self.tags)} - if self.version is not None: - fastmcp_meta["version"] = self.version - - # Merge with upstream fastmcp meta, stripping internal keys - if (upstream_meta := meta.get("fastmcp")) is not None: - if not isinstance(upstream_meta, dict): - raise TypeError("meta['fastmcp'] must be a dict") - # Filter out internal keys (e.g., _internal used for enabled state) - public_upstream = { - k: v for k, v in upstream_meta.items() if not k.startswith("_") - } - fastmcp_meta = cast(FastMCPMeta, public_upstream | fastmcp_meta) - meta["fastmcp"] = fastmcp_meta - - return meta - - def __eq__(self, other: object) -> bool: - if type(self) is not type(other): - return False - if not isinstance(other, type(self)): - return False - return self.model_dump() == other.model_dump() - - def __repr__(self) -> str: - parts = [f"name={self.name!r}"] - if self.version: - parts.append(f"version={self.version!r}") - parts.extend( - [ - f"title={self.title!r}", - f"description={self.description!r}", - f"tags={self.tags}", - ] - ) - return f"{self.__class__.__name__}({', '.join(parts)})" - - def enable(self) -> None: - """Removed in 3.0. Use server.enable(keys=[...]) instead.""" - raise NotImplementedError( - f"Component.enable() was removed in FastMCP 3.0. " - f"Use server.enable(keys=['{self.key}']) instead." - ) - - def disable(self) -> None: - """Removed in 3.0. Use server.disable(keys=[...]) instead.""" - raise NotImplementedError( - f"Component.disable() was removed in FastMCP 3.0. " - f"Use server.disable(keys=['{self.key}']) instead." - ) - - def copy(self) -> Self: # type: ignore[override] # ty:ignore[invalid-method-override] - """Create a copy of the component.""" - return self.model_copy() - - def register_with_docket(self, docket: Docket) -> None: - """Register this component with docket for background execution. - - No-ops if task_config.mode is "forbidden". Subclasses override to - register their callable (self.run, self.read, self.render, or self.fn). - """ - # Base implementation: no-op (subclasses override) - - async def add_to_docket( - self, docket: Docket, *args: Any, **kwargs: Any - ) -> Execution: - """Schedule this component for background execution via docket. - - Subclasses override this to handle their specific calling conventions: - - Tool: add_to_docket(docket, arguments: dict, **kwargs) - - Resource: add_to_docket(docket, **kwargs) - - ResourceTemplate: add_to_docket(docket, params: dict, **kwargs) - - Prompt: add_to_docket(docket, arguments: dict | None, **kwargs) - - The **kwargs are passed through to docket.add() (e.g., key=task_key). - """ - if not self.task_config.supports_tasks(): - raise RuntimeError( - f"Cannot add {self.__class__.__name__} '{self.name}' to docket: " - f"task execution not supported" - ) - raise NotImplementedError( - f"{self.__class__.__name__} does not implement add_to_docket()" - ) - - def get_span_attributes(self) -> dict[str, Any]: - """Return span attributes for telemetry. - - Subclasses should call super() and merge their specific attributes. - """ - return {"fastmcp.component.key": self.key} diff --git a/src/fastmcp/utilities/exceptions.py b/src/fastmcp/utilities/exceptions.py deleted file mode 100644 index a85bbafa7..000000000 --- a/src/fastmcp/utilities/exceptions.py +++ /dev/null @@ -1,49 +0,0 @@ -from collections.abc import Callable, Iterable, Mapping -from typing import Any - -import httpx -import mcp.types -from exceptiongroup import BaseExceptionGroup -from mcp import McpError - -import fastmcp - - -def iter_exc(group: BaseExceptionGroup): - for exc in group.exceptions: - if isinstance(exc, BaseExceptionGroup): - yield from iter_exc(exc) - else: - yield exc - - -def _exception_handler(group: BaseExceptionGroup): - for leaf in iter_exc(group): - if isinstance(leaf, httpx.ConnectTimeout): - raise McpError( - error=mcp.types.ErrorData( - code=httpx.codes.REQUEST_TIMEOUT, - message="Timed out while waiting for response.", - ) - ) - raise leaf - - -# this catch handler is used to catch taskgroup exception groups and raise the -# first exception. This allows more sane debugging. -_catch_handlers: Mapping[ - type[BaseException] | Iterable[type[BaseException]], - Callable[[BaseExceptionGroup[Any]], Any], -] = { - Exception: _exception_handler, -} - - -def get_catch_handlers() -> Mapping[ - type[BaseException] | Iterable[type[BaseException]], - Callable[[BaseExceptionGroup[Any]], Any], -]: - if fastmcp.settings.client_raise_first_exceptiongroup_error: - return _catch_handlers - else: - return {} diff --git a/src/fastmcp/utilities/http.py b/src/fastmcp/utilities/http.py deleted file mode 100644 index c1237d62e..000000000 --- a/src/fastmcp/utilities/http.py +++ /dev/null @@ -1,8 +0,0 @@ -import socket - - -def find_available_port() -> int: - """Find an available port by letting the OS assign one.""" - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.bind(("127.0.0.1", 0)) - return s.getsockname()[1] diff --git a/src/fastmcp/utilities/inspect.py b/src/fastmcp/utilities/inspect.py deleted file mode 100644 index 369351ba5..000000000 --- a/src/fastmcp/utilities/inspect.py +++ /dev/null @@ -1,494 +0,0 @@ -"""Utilities for inspecting FastMCP instances.""" - -from __future__ import annotations - -import importlib.metadata -from dataclasses import dataclass -from enum import Enum -from typing import Any, Literal, cast - -import pydantic_core -from mcp.server.fastmcp import FastMCP as FastMCP1x - -import fastmcp -from fastmcp import Client -from fastmcp.server.server import FastMCP - - -@dataclass -class ToolInfo: - """Information about a tool.""" - - key: str - name: str - description: str | None - input_schema: dict[str, Any] - output_schema: dict[str, Any] | None = None - annotations: dict[str, Any] | None = None - tags: list[str] | None = None - title: str | None = None - icons: list[dict[str, Any]] | None = None - meta: dict[str, Any] | None = None - - -@dataclass -class PromptInfo: - """Information about a prompt.""" - - key: str - name: str - description: str | None - arguments: list[dict[str, Any]] | None = None - tags: list[str] | None = None - title: str | None = None - icons: list[dict[str, Any]] | None = None - meta: dict[str, Any] | None = None - - -@dataclass -class ResourceInfo: - """Information about a resource.""" - - key: str - uri: str - name: str | None - description: str | None - mime_type: str | None = None - annotations: dict[str, Any] | None = None - tags: list[str] | None = None - title: str | None = None - icons: list[dict[str, Any]] | None = None - meta: dict[str, Any] | None = None - - -@dataclass -class TemplateInfo: - """Information about a resource template.""" - - key: str - uri_template: str - name: str | None - description: str | None - mime_type: str | None = None - parameters: dict[str, Any] | None = None - annotations: dict[str, Any] | None = None - tags: list[str] | None = None - title: str | None = None - icons: list[dict[str, Any]] | None = None - meta: dict[str, Any] | None = None - - -@dataclass -class FastMCPInfo: - """Information extracted from a FastMCP instance.""" - - name: str - instructions: str | None - version: str | None # The server's own version string (if specified) - website_url: str | None - icons: list[dict[str, Any]] | None - fastmcp_version: str # Version of FastMCP generating this manifest - mcp_version: str # Version of MCP protocol library - server_generation: int # Server generation: 1 (mcp package) or 2 (fastmcp) - tools: list[ToolInfo] - prompts: list[PromptInfo] - resources: list[ResourceInfo] - templates: list[TemplateInfo] - capabilities: dict[str, Any] - - -async def inspect_fastmcp_v2(mcp: FastMCP[Any]) -> FastMCPInfo: - """Extract information from a FastMCP v2.x instance. - - Args: - mcp: The FastMCP v2.x instance to inspect - - Returns: - FastMCPInfo dataclass containing the extracted information - """ - # Get all components (list_* includes middleware, enabled/auth filtering) - tools_list = await mcp.list_tools() - prompts_list = await mcp.list_prompts() - resources_list = await mcp.list_resources() - templates_list = await mcp.list_resource_templates() - - # Extract detailed tool information - tool_infos = [] - for tool in tools_list: - mcp_tool = tool.to_mcp_tool(name=tool.name) - tool_infos.append( - ToolInfo( - key=tool.key, - name=tool.name or tool.key, - description=tool.description, - input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {}, - output_schema=tool.output_schema, - annotations=tool.annotations.model_dump() if tool.annotations else None, - tags=list(tool.tags) if tool.tags else None, - title=tool.title, - icons=[icon.model_dump() for icon in tool.icons] - if tool.icons - else None, - meta=tool.meta, - ) - ) - - # Extract detailed prompt information - prompt_infos = [] - for prompt in prompts_list: - prompt_infos.append( - PromptInfo( - key=prompt.key, - name=prompt.name or prompt.key, - description=prompt.description, - arguments=[arg.model_dump() for arg in prompt.arguments] - if prompt.arguments - else None, - tags=list(prompt.tags) if prompt.tags else None, - title=prompt.title, - icons=[icon.model_dump() for icon in prompt.icons] - if prompt.icons - else None, - meta=prompt.meta, - ) - ) - - # Extract detailed resource information - resource_infos = [] - for resource in resources_list: - resource_infos.append( - ResourceInfo( - key=resource.key, - uri=str(resource.uri), - name=resource.name, - description=resource.description, - mime_type=resource.mime_type, - annotations=resource.annotations.model_dump() - if resource.annotations - else None, - tags=list(resource.tags) if resource.tags else None, - title=resource.title, - icons=[icon.model_dump() for icon in resource.icons] - if resource.icons - else None, - meta=resource.meta, - ) - ) - - # Extract detailed template information - template_infos = [] - for template in templates_list: - template_infos.append( - TemplateInfo( - key=template.key, - uri_template=template.uri_template, - name=template.name, - description=template.description, - mime_type=template.mime_type, - parameters=template.parameters, - annotations=template.annotations.model_dump() - if template.annotations - else None, - tags=list(template.tags) if template.tags else None, - title=template.title, - icons=[icon.model_dump() for icon in template.icons] - if template.icons - else None, - meta=template.meta, - ) - ) - - # Basic MCP capabilities that FastMCP supports - capabilities = { - "tools": {"listChanged": True}, - "resources": {"subscribe": False, "listChanged": False}, - "prompts": {"listChanged": False}, - "logging": {}, - } - - # Extract server-level icons and website_url - server_icons = ( - [icon.model_dump() for icon in mcp._mcp_server.icons] - if hasattr(mcp._mcp_server, "icons") and mcp._mcp_server.icons - else None - ) - server_website_url = ( - mcp._mcp_server.website_url if hasattr(mcp._mcp_server, "website_url") else None - ) - - return FastMCPInfo( - name=mcp.name, - instructions=mcp.instructions, - version=(mcp.version if hasattr(mcp, "version") else mcp._mcp_server.version), - website_url=server_website_url, - icons=server_icons, - fastmcp_version=fastmcp.__version__, - mcp_version=importlib.metadata.version("mcp"), - server_generation=2, # FastMCP v2 - tools=tool_infos, - prompts=prompt_infos, - resources=resource_infos, - templates=template_infos, - capabilities=capabilities, - ) - - -async def inspect_fastmcp_v1(mcp: FastMCP1x) -> FastMCPInfo: - """Extract information from a FastMCP v1.x instance using a Client. - - Args: - mcp: The FastMCP v1.x instance to inspect - - Returns: - FastMCPInfo dataclass containing the extracted information - """ - # Use a client to interact with the FastMCP1x server - async with Client(mcp) as client: - # Get components via client calls (these return MCP objects) - mcp_tools = await client.list_tools() - mcp_prompts = await client.list_prompts() - mcp_resources = await client.list_resources() - - # Try to get resource templates (FastMCP 1.x does have templates) - try: - mcp_templates = await client.list_resource_templates() - except Exception: - mcp_templates = [] - - # Extract detailed tool information from MCP Tool objects - tool_infos = [] - for mcp_tool in mcp_tools: - tool_infos.append( - ToolInfo( - key=mcp_tool.name, - name=mcp_tool.name, - description=mcp_tool.description, - input_schema=mcp_tool.inputSchema if mcp_tool.inputSchema else {}, - output_schema=None, # v1 doesn't have output_schema - annotations=None, # v1 doesn't have annotations - tags=None, # v1 doesn't have tags - title=None, # v1 doesn't have title - icons=[icon.model_dump() for icon in mcp_tool.icons] - if hasattr(mcp_tool, "icons") and mcp_tool.icons - else None, - meta=None, # v1 doesn't have meta field - ) - ) - - # Extract detailed prompt information from MCP Prompt objects - prompt_infos = [] - for mcp_prompt in mcp_prompts: - # Convert arguments if they exist - arguments = None - if hasattr(mcp_prompt, "arguments") and mcp_prompt.arguments: - arguments = [arg.model_dump() for arg in mcp_prompt.arguments] - - prompt_infos.append( - PromptInfo( - key=mcp_prompt.name, - name=mcp_prompt.name, - description=mcp_prompt.description, - arguments=arguments, - tags=None, # v1 doesn't have tags - title=None, # v1 doesn't have title - icons=[icon.model_dump() for icon in mcp_prompt.icons] - if hasattr(mcp_prompt, "icons") and mcp_prompt.icons - else None, - meta=None, # v1 doesn't have meta field - ) - ) - - # Extract detailed resource information from MCP Resource objects - resource_infos = [] - for mcp_resource in mcp_resources: - resource_infos.append( - ResourceInfo( - key=str(mcp_resource.uri), - uri=str(mcp_resource.uri), - name=mcp_resource.name, - description=mcp_resource.description, - mime_type=mcp_resource.mimeType, - annotations=None, # v1 doesn't have annotations - tags=None, # v1 doesn't have tags - title=None, # v1 doesn't have title - icons=[icon.model_dump() for icon in mcp_resource.icons] - if hasattr(mcp_resource, "icons") and mcp_resource.icons - else None, - meta=None, # v1 doesn't have meta field - ) - ) - - # Extract detailed template information from MCP ResourceTemplate objects - template_infos = [] - for mcp_template in mcp_templates: - template_infos.append( - TemplateInfo( - key=str(mcp_template.uriTemplate), - uri_template=str(mcp_template.uriTemplate), - name=mcp_template.name, - description=mcp_template.description, - mime_type=mcp_template.mimeType, - parameters=None, # v1 doesn't expose template parameters - annotations=None, # v1 doesn't have annotations - tags=None, # v1 doesn't have tags - title=None, # v1 doesn't have title - icons=[icon.model_dump() for icon in mcp_template.icons] - if hasattr(mcp_template, "icons") and mcp_template.icons - else None, - meta=None, # v1 doesn't have meta field - ) - ) - - # Basic MCP capabilities - capabilities = { - "tools": {"listChanged": True}, - "resources": {"subscribe": False, "listChanged": False}, - "prompts": {"listChanged": False}, - "logging": {}, - } - - # Extract server-level icons and website_url from serverInfo - server_info = client.initialize_result.serverInfo - server_icons = ( - [icon.model_dump() for icon in server_info.icons] - if hasattr(server_info, "icons") and server_info.icons - else None - ) - server_website_url = ( - server_info.websiteUrl if hasattr(server_info, "websiteUrl") else None - ) - - return FastMCPInfo( - name=mcp._mcp_server.name, - instructions=mcp._mcp_server.instructions, - version=mcp._mcp_server.version, - website_url=server_website_url, - icons=server_icons, - fastmcp_version=fastmcp.__version__, # Version generating this manifest - mcp_version=importlib.metadata.version("mcp"), - server_generation=1, # MCP v1 - tools=tool_infos, - prompts=prompt_infos, - resources=resource_infos, - templates=template_infos, - capabilities=capabilities, - ) - - -async def inspect_fastmcp(mcp: FastMCP[Any] | FastMCP1x) -> FastMCPInfo: - """Extract information from a FastMCP instance into a dataclass. - - This function automatically detects whether the instance is FastMCP v1.x or v2.x - and uses the appropriate extraction method. - - Args: - mcp: The FastMCP instance to inspect (v1.x or v2.x) - - Returns: - FastMCPInfo dataclass containing the extracted information - """ - if isinstance(mcp, FastMCP1x): - return await inspect_fastmcp_v1(mcp) - else: - return await inspect_fastmcp_v2(cast(FastMCP[Any], mcp)) - - -class InspectFormat(str, Enum): - """Output format for inspect command.""" - - FASTMCP = "fastmcp" - MCP = "mcp" - - -def format_fastmcp_info(info: FastMCPInfo) -> bytes: - """Format FastMCPInfo as FastMCP-specific JSON. - - This includes FastMCP-specific fields like tags, enabled, annotations, etc. - """ - # Build the output dict with nested structure - result = { - "server": { - "name": info.name, - "instructions": info.instructions, - "version": info.version, - "website_url": info.website_url, - "icons": info.icons, - "generation": info.server_generation, - "capabilities": info.capabilities, - }, - "environment": { - "fastmcp": info.fastmcp_version, - "mcp": info.mcp_version, - }, - "tools": info.tools, - "prompts": info.prompts, - "resources": info.resources, - "templates": info.templates, - } - - return pydantic_core.to_json(result, indent=2) - - -async def format_mcp_info(mcp: FastMCP[Any] | FastMCP1x) -> bytes: - """Format server info as standard MCP protocol JSON. - - Uses Client to get the standard MCP protocol format with camelCase fields. - Includes version metadata at the top level. - """ - async with Client(mcp) as client: - # Get all the MCP protocol objects - tools_result = await client.list_tools_mcp() - prompts_result = await client.list_prompts_mcp() - resources_result = await client.list_resources_mcp() - templates_result = await client.list_resource_templates_mcp() - - # Get server info from the initialize result - server_info = client.initialize_result.serverInfo - - # Combine into MCP protocol structure with environment metadata - result = { - "environment": { - "fastmcp": fastmcp.__version__, # Version generating this manifest - "mcp": importlib.metadata.version("mcp"), # MCP protocol version - }, - "serverInfo": server_info, - "capabilities": {}, # MCP format doesn't include capabilities at top level - "tools": tools_result.tools, - "prompts": prompts_result.prompts, - "resources": resources_result.resources, - "resourceTemplates": templates_result.resourceTemplates, - } - - return pydantic_core.to_json(result, indent=2) - - -async def format_info( - mcp: FastMCP[Any] | FastMCP1x, - format: InspectFormat | Literal["fastmcp", "mcp"], - info: FastMCPInfo | None = None, -) -> bytes: - """Format server information according to the specified format. - - Args: - mcp: The FastMCP instance - format: Output format ("fastmcp" or "mcp") - info: Pre-extracted FastMCPInfo (optional, will be extracted if not provided) - - Returns: - JSON bytes in the requested format - """ - # Convert string to enum if needed - if isinstance(format, str): - format = InspectFormat(format) - - if format == InspectFormat.MCP: - # MCP format doesn't need FastMCPInfo, it uses Client directly - return await format_mcp_info(mcp) - elif format == InspectFormat.FASTMCP: - # For FastMCP format, we need the FastMCPInfo - # This works for both v1 and v2 servers - if info is None: - info = await inspect_fastmcp(mcp) - return format_fastmcp_info(info) - else: - raise ValueError(f"Unknown format: {format}") diff --git a/src/fastmcp/utilities/json_schema.py b/src/fastmcp/utilities/json_schema.py deleted file mode 100644 index fc4e069c6..000000000 --- a/src/fastmcp/utilities/json_schema.py +++ /dev/null @@ -1,522 +0,0 @@ -from __future__ import annotations - -from collections import defaultdict -from typing import Any - -from jsonref import JsonRefError, replace_refs - - -def _defs_have_cycles(defs: dict[str, Any]) -> bool: - """Check whether any definitions in ``$defs`` form a reference cycle. - - A cycle means a definition directly or transitively references itself - (e.g. Node → children → Node, or A → B → A). ``jsonref.replace_refs`` - silently produces Python-level object cycles for these, which Pydantic's - serializer rejects. - """ - if not defs: - return False - - # Build adjacency: def_name -> set of def_names it references. - edges: dict[str, set[str]] = defaultdict(set) - - def _collect_refs(obj: Any, source: str) -> None: - if isinstance(obj, dict): - ref = obj.get("$ref") - if isinstance(ref, str) and ref.startswith("#/$defs/"): - edges[source].add(ref.split("/")[-1]) - for v in obj.values(): - _collect_refs(v, source) - elif isinstance(obj, list): - for item in obj: - _collect_refs(item, source) - - for name, definition in defs.items(): - _collect_refs(definition, name) - - # DFS cycle detection. - UNVISITED, IN_STACK, DONE = 0, 1, 2 - state: dict[str, int] = defaultdict(int) - - def _has_cycle(node: str) -> bool: - state[node] = IN_STACK - for neighbor in edges.get(node, ()): - if neighbor not in defs: - continue - if state[neighbor] == IN_STACK: - return True - if state[neighbor] == UNVISITED and _has_cycle(neighbor): - return True - state[node] = DONE - return False - - return any(state[name] == UNVISITED and _has_cycle(name) for name in defs) - - -def _strip_remote_refs(obj: Any) -> Any: - """Return a deep copy of *obj* with non-local ``$ref`` values removed. - - Local refs (starting with ``#``) are kept intact. Remote refs - (``http://``, ``https://``, ``file://``, or any other URI scheme) are - stripped so that ``jsonref.replace_refs`` never attempts to fetch an - external resource. This prevents SSRF / LFI when proxying schemas - from untrusted servers. - """ - if isinstance(obj, dict): - ref = obj.get("$ref") - if isinstance(ref, str) and not ref.startswith("#"): - # Drop the remote $ref key; keep all other keys. - return {k: _strip_remote_refs(v) for k, v in obj.items() if k != "$ref"} - return {k: _strip_remote_refs(v) for k, v in obj.items()} - if isinstance(obj, list): - return [_strip_remote_refs(item) for item in obj] - return obj - - -def _strip_discriminator(obj: Any) -> Any: - """Recursively remove OpenAPI ``discriminator`` keys from a schema. - - Pydantic emits ``discriminator.mapping`` with values like - ``#/$defs/ClassName``. After ``$defs`` are inlined and removed by - ``dereference_refs``, those mapping entries dangle. The keyword is an - OpenAPI extension — the ``anyOf`` variants already carry ``const`` on - the discriminant field, so the mapping is redundant. - - Only strips ``discriminator`` when it appears alongside ``anyOf`` or - ``oneOf``, which is where the OpenAPI keyword lives. A property - *named* ``discriminator`` (inside ``properties``) is left alone. - """ - if isinstance(obj, dict): - skip = "discriminator" in obj and ("anyOf" in obj or "oneOf" in obj) - # Keys that hold instance data, not sub-schemas — don't recurse. - _DATA_KEYS = {"default", "const", "examples", "enum"} - return { - k: (v if k in _DATA_KEYS else _strip_discriminator(v)) - for k, v in obj.items() - if not (k == "discriminator" and skip) - } - if isinstance(obj, list): - return [_strip_discriminator(item) for item in obj] - return obj - - -def dereference_refs(schema: dict[str, Any]) -> dict[str, Any]: - """Resolve all $ref references in a JSON schema by inlining definitions. - - This function resolves $ref references that point to $defs, replacing them - with the actual definition content while preserving sibling keywords (like - description, default, examples) that Pydantic places alongside $ref. - - This is necessary because some MCP clients (e.g., VS Code Copilot) don't - properly handle $ref in tool input schemas. - - For self-referencing/circular schemas where full dereferencing is not possible, - this function falls back to resolving only the root-level $ref while preserving - $defs for nested references. - - Only local ``$ref`` values (those starting with ``#``) are resolved. - Remote URIs (``http://``, ``file://``, etc.) are stripped before - resolution to prevent SSRF / local-file-inclusion attacks when proxying - schemas from untrusted servers. - - Args: - schema: JSON schema dict that may contain $ref references - - Returns: - A new schema dict with $ref resolved where possible and $defs removed - when no longer needed - - Example: - >>> schema = { - ... "$defs": {"Category": {"enum": ["a", "b"], "type": "string"}}, - ... "properties": {"cat": {"$ref": "#/$defs/Category", "default": "a"}} - ... } - >>> resolved = dereference_refs(schema) - >>> # Result: {"properties": {"cat": {"enum": ["a", "b"], "type": "string", "default": "a"}}} - """ - # Strip any remote $ref values before processing to prevent SSRF / LFI. - schema = _strip_remote_refs(schema) - - # Circular $defs can't be fully inlined — jsonref.replace_refs produces - # Python dicts with object-identity cycles that Pydantic's model_dump - # rejects with "Circular reference detected (id repeated)". - # Detect cycles up front and fall back to root-only resolution. - if _defs_have_cycles(schema.get("$defs", {})): - return resolve_root_ref(schema) - - try: - # Use jsonref to resolve all $ref references - # proxies=False returns plain dicts (not proxy objects) - # lazy_load=False resolves immediately - dereferenced = replace_refs(schema, proxies=False, lazy_load=False) - - # Merge sibling keywords that were lost during dereferencing - # Pydantic puts description, default, examples as siblings to $ref - defs = schema.get("$defs", {}) - merged = _merge_ref_siblings(schema, dereferenced, defs) - # Type assertion: top-level schema is always a dict - assert isinstance(merged, dict) - dereferenced = merged - - # Remove $defs since all references have been resolved - if "$defs" in dereferenced: - dereferenced = {k: v for k, v in dereferenced.items() if k != "$defs"} - - # Strip `discriminator` keys — they contain `mapping` values that - # point at `#/$defs/...` entries we just removed. `discriminator` - # is an OpenAPI extension; after inlining, the `anyOf` variants - # already carry `const` on the discriminant field, making the - # mapping redundant. - dereferenced = _strip_discriminator(dereferenced) - - return dereferenced - - except JsonRefError: - # Self-referencing/circular schemas can't be fully dereferenced - # Fall back to resolving only root-level $ref (for MCP spec compliance) - return resolve_root_ref(schema) - - -def _merge_ref_siblings( - original: Any, - dereferenced: Any, - defs: dict[str, Any], - visited: set[str] | None = None, -) -> Any: - """Merge sibling keywords from original $ref nodes into dereferenced schema. - - When jsonref resolves $ref, it replaces the entire node with the referenced - definition, losing any sibling keywords like description, default, or examples. - This function walks both trees in parallel and merges those siblings back. - - Args: - original: The original schema with $ref and potential siblings - dereferenced: The schema after jsonref processing - defs: The $defs from the original schema, for looking up referenced definitions - visited: Set of definition names already being processed (prevents cycles) - - Returns: - The dereferenced schema with sibling keywords restored - """ - if visited is None: - visited = set() - - if isinstance(original, dict) and isinstance(dereferenced, dict): - # Check if original had a $ref - if "$ref" in original: - ref = original["$ref"] - siblings = {k: v for k, v in original.items() if k not in ("$ref", "$defs")} - - # Look up the referenced definition to process its nested siblings - if isinstance(ref, str) and ref.startswith("#/$defs/"): - def_name = ref.split("/")[-1] - # Prevent infinite recursion on circular references - if def_name in defs and def_name not in visited: - # Recursively process the definition's content for nested siblings - dereferenced = _merge_ref_siblings( - defs[def_name], dereferenced, defs, visited | {def_name} - ) - - if siblings: - # Merge local siblings, which take precedence - merged = dict(dereferenced) - merged.update(siblings) - return merged - return dereferenced - - # Recurse into nested structures - result = {} - for key, value in dereferenced.items(): - if key in original: - result[key] = _merge_ref_siblings(original[key], value, defs, visited) - else: - result[key] = value - return result - - elif isinstance(original, list) and isinstance(dereferenced, list): - # Process list items in parallel - min_len = min(len(original), len(dereferenced)) - return [ - _merge_ref_siblings(o, d, defs, visited) - for o, d in zip(original[:min_len], dereferenced[:min_len], strict=False) - ] + dereferenced[min_len:] - - return dereferenced - - -def resolve_root_ref(schema: dict[str, Any]) -> dict[str, Any]: - """Resolve $ref at root level to meet MCP spec requirements. - - MCP specification requires outputSchema to have "type": "object" at the root level. - When Pydantic generates schemas for self-referential models, it uses $ref at the - root level pointing to $defs. This function resolves such references by inlining - the referenced definition while preserving $defs for nested references. - - Args: - schema: JSON schema dict that may have $ref at root level - - Returns: - A new schema dict with root-level $ref resolved, or the original schema - if no resolution is needed - - Example: - >>> schema = { - ... "$defs": {"Node": {"type": "object", "properties": {...}}}, - ... "$ref": "#/$defs/Node" - ... } - >>> resolved = resolve_root_ref(schema) - >>> # Result: {"type": "object", "properties": {...}, "$defs": {...}} - """ - # Only resolve if we have $ref at root level with $defs but no explicit type - if "$ref" in schema and "$defs" in schema and "type" not in schema: - ref = schema["$ref"] - # Only handle local $defs references - if isinstance(ref, str) and ref.startswith("#/$defs/"): - def_name = ref.split("/")[-1] - defs = schema["$defs"] - if def_name in defs: - # Create a new schema by copying the referenced definition - resolved = dict(defs[def_name]) - # Preserve $defs for nested references (other fields may still use them) - resolved["$defs"] = defs - return resolved - return schema - - -def _prune_param(schema: dict[str, Any], param: str) -> dict[str, Any]: - """Return a new schema with *param* removed from `properties`, `required`, - and (if no longer referenced) `$defs`. - """ - - # ── 1. drop from properties/required ────────────────────────────── - props = schema.get("properties", {}) - removed = props.pop(param, None) - if removed is None: # nothing to do - return schema - - # Keep empty properties object rather than removing it entirely - schema["properties"] = props - if param in schema.get("required", []): - schema["required"].remove(param) - if not schema["required"]: - schema.pop("required") - - return schema - - -def _single_pass_optimize( - schema: dict[str, Any], - prune_titles: bool = False, - prune_additional_properties: bool = False, - prune_defs: bool = True, -) -> dict[str, Any]: - """ - Optimize JSON schemas in a single traversal for better performance. - - This function combines three schema cleanup operations that would normally require - separate tree traversals: - - 1. **Remove unused definitions** (prune_defs): Finds and removes `$defs` entries - that aren't referenced anywhere in the schema, reducing schema size. - - 2. **Remove titles** (prune_titles): Strips `title` fields throughout the schema - to reduce verbosity while preserving functional information. - - 3. **Remove restrictive additionalProperties** (prune_additional_properties): - Removes `"additionalProperties": false` constraints to make schemas more flexible. - - **Performance Benefits:** - - Single tree traversal instead of multiple passes (2-3x faster) - - Immutable design prevents shared reference bugs - - Early termination prevents runaway recursion on deeply nested schemas - - **Algorithm Overview:** - 1. Traverse main schema, collecting $ref references and applying cleanups - 2. Traverse $defs section to map inter-definition dependencies - 3. Remove unused definitions based on reference analysis - - Args: - schema: JSON schema dict to optimize (not modified) - prune_titles: Remove title fields for cleaner output - prune_additional_properties: Remove "additionalProperties": false constraints - prune_defs: Remove unused $defs entries to reduce size - - Returns: - A new optimized schema dict - - Example: - >>> schema = { - ... "type": "object", - ... "title": "MySchema", - ... "additionalProperties": False, - ... "$defs": {"UnusedDef": {"type": "string"}} - ... } - >>> result = _single_pass_optimize(schema, prune_titles=True, prune_defs=True) - >>> # Result: {"type": "object", "additionalProperties": False} - """ - if not (prune_defs or prune_titles or prune_additional_properties): - return schema # Nothing to do - - # Phase 1: Collect references and apply simple cleanups - # Track which $defs are referenced from the main schema and from other $defs - root_refs: set[str] = set() # $defs referenced directly from main schema - def_dependencies: defaultdict[str, list[str]] = defaultdict( - list - ) # def A references def B - defs = schema.get("$defs") - - def traverse_and_clean( - node: object, - current_def_name: str | None = None, - skip_defs_section: bool = False, - depth: int = 0, - ) -> None: - """Traverse schema tree, collecting $ref info and applying cleanups.""" - if depth > 50: # Prevent infinite recursion - return - - if isinstance(node, dict): - # Collect $ref references for unused definition removal - if prune_defs: - ref = node.get("$ref") # type: ignore - if isinstance(ref, str) and ref.startswith("#/$defs/"): - referenced_def = ref.split("/")[-1] - if current_def_name: - # We're inside a $def, so this is a def->def reference - def_dependencies[referenced_def].append(current_def_name) - else: - # We're in the main schema, so this is a root reference - root_refs.add(referenced_def) - - # Apply cleanups - # Only remove "title" if it's a schema metadata field - # Schema objects have keywords like "type", "properties", "$ref", etc. - # If we see these, then "title" is metadata, not a property name - if prune_titles and "title" in node: - # Only remove "title" if it's a string (schema metadata). - # In a "properties" dict, "title" would be a dict (a sub-schema - # for a parameter named "title"), which we must preserve. - if isinstance(node["title"], str) and any( # type: ignore - k in node - for k in [ - "type", - "properties", - "$ref", - "items", - "allOf", - "oneOf", - "anyOf", - "required", - ] - ): - node.pop("title") # type: ignore - - if ( - prune_additional_properties - and node.get("additionalProperties") is False # type: ignore - ): - node.pop("additionalProperties") # type: ignore - - # Recursive traversal - for key, value in node.items(): - if skip_defs_section and key == "$defs": - continue # Skip $defs during main schema traversal - - # Handle schema composition keywords with special traversal - if key in ["allOf", "oneOf", "anyOf"] and isinstance(value, list): - for item in value: - traverse_and_clean(item, current_def_name, depth=depth + 1) - else: - traverse_and_clean(value, current_def_name, depth=depth + 1) - - elif isinstance(node, list): - for item in node: - traverse_and_clean(item, current_def_name, depth=depth + 1) - - # Phase 2: Traverse main schema (excluding $defs section) - traverse_and_clean(schema, skip_defs_section=True) - - # Phase 3: Traverse $defs to find inter-definition references - if prune_defs and defs: - for def_name, def_schema in defs.items(): - traverse_and_clean(def_schema, current_def_name=def_name) - - # Phase 4: Remove unused definitions - def is_def_used(def_name: str, visiting: set[str] | None = None) -> bool: - """Check if a definition is used, handling circular references.""" - if def_name in root_refs: - return True # Used directly from main schema - - # Check if any definition that references this one is itself used - referencing_defs = def_dependencies.get(def_name, []) - if referencing_defs: - if visiting is None: - visiting = set() - - # Avoid infinite recursion on circular references - if def_name in visiting: - return False - visiting = visiting | {def_name} - - # If any referencing def is used, then this def is used - for referencing_def in referencing_defs: - if referencing_def not in visiting and is_def_used( - referencing_def, visiting - ): - return True - - return False - - # Remove unused definitions - for def_name in list(defs.keys()): - if not is_def_used(def_name): - defs.pop(def_name) - - # Clean up empty $defs section - if not defs: - schema.pop("$defs", None) - - return schema - - -def compress_schema( - schema: dict[str, Any], - prune_params: list[str] | None = None, - prune_additional_properties: bool = False, - prune_titles: bool = False, - dereference: bool = False, -) -> dict[str, Any]: - """ - Compress and optimize a JSON schema for MCP compatibility. - - Args: - schema: The schema to compress - prune_params: List of parameter names to remove from properties - prune_additional_properties: Whether to remove additionalProperties: false. - Defaults to False to maintain MCP client compatibility, as some clients - (e.g., Claude) require additionalProperties: false for strict validation. - prune_titles: Whether to remove title fields from the schema - dereference: Whether to dereference $ref by inlining definitions. - Defaults to False; dereferencing is typically handled by - middleware at serve-time instead. - """ - if dereference: - schema = dereference_refs(schema) - - # Resolve root-level $ref for MCP spec compliance (requires type: object at root) - schema = resolve_root_ref(schema) - - # Remove specific parameters if requested - for param in prune_params or []: - schema = _prune_param(schema, param=param) - - # Apply combined optimizations in a single tree traversal. - # Always prune unused $defs to keep schemas clean after parameter removal. - schema = _single_pass_optimize( - schema, - prune_titles=prune_titles, - prune_additional_properties=prune_additional_properties, - prune_defs=True, - ) - - return schema diff --git a/src/fastmcp/utilities/json_schema_type.py b/src/fastmcp/utilities/json_schema_type.py deleted file mode 100644 index e45bab71a..000000000 --- a/src/fastmcp/utilities/json_schema_type.py +++ /dev/null @@ -1,650 +0,0 @@ -"""Convert JSON Schema to Python types with validation. - -The json_schema_to_type function converts a JSON Schema into a Python type that can be used -for validation with Pydantic. It supports: - -- Basic types (string, number, integer, boolean, null) -- Complex types (arrays, objects) -- Format constraints (date-time, email, uri) -- Numeric constraints (minimum, maximum, multipleOf) -- String constraints (minLength, maxLength, pattern) -- Array constraints (minItems, maxItems, uniqueItems) -- Object properties with defaults -- References and recursive schemas -- Enums and constants -- Union types - -Example: - ```python - schema = { - "type": "object", - "properties": { - "name": {"type": "string", "minLength": 1}, - "age": {"type": "integer", "minimum": 0}, - "email": {"type": "string", "format": "email"} - }, - "required": ["name", "age"] - } - - # Name is optional and will be inferred from schema's "title" property if not provided - Person = json_schema_to_type(schema) - # Creates a validated dataclass with name, age, and optional email fields - ``` -""" - -from __future__ import annotations - -import hashlib -import json -import re -from collections.abc import Callable, Mapping -from copy import deepcopy -from dataclasses import MISSING, field, make_dataclass -from datetime import datetime -from typing import ( - Annotated, - Any, - ForwardRef, - Literal, - Union, - cast, -) - -from pydantic import ( - AnyUrl, - BaseModel, - ConfigDict, - EmailStr, - Field, - Json, - StringConstraints, - model_validator, -) -from typing_extensions import NotRequired, TypedDict - -__all__ = ["JSONSchema", "json_schema_to_type"] - - -FORMAT_TYPES: dict[str, Any] = { - "date-time": datetime, - "email": EmailStr, - "uri": AnyUrl, - "json": Json, -} - -_classes: dict[tuple[str, Any], type | None] = {} - - -class JSONSchema(TypedDict): - type: NotRequired[str | list[str]] - properties: NotRequired[dict[str, JSONSchema]] - required: NotRequired[list[str]] - additionalProperties: NotRequired[bool | JSONSchema] - items: NotRequired[JSONSchema | list[JSONSchema]] - enum: NotRequired[list[Any]] - const: NotRequired[Any] - default: NotRequired[Any] - description: NotRequired[str] - title: NotRequired[str] - examples: NotRequired[list[Any]] - format: NotRequired[str] - allOf: NotRequired[list[JSONSchema]] - anyOf: NotRequired[list[JSONSchema]] - oneOf: NotRequired[list[JSONSchema]] - not_: NotRequired[JSONSchema] - definitions: NotRequired[dict[str, JSONSchema]] - dependencies: NotRequired[dict[str, JSONSchema | list[str]]] - pattern: NotRequired[str] - minLength: NotRequired[int] - maxLength: NotRequired[int] - minimum: NotRequired[int | float] - maximum: NotRequired[int | float] - exclusiveMinimum: NotRequired[int | float] - exclusiveMaximum: NotRequired[int | float] - multipleOf: NotRequired[int | float] - uniqueItems: NotRequired[bool] - minItems: NotRequired[int] - maxItems: NotRequired[int] - additionalItems: NotRequired[bool | JSONSchema] - - -def json_schema_to_type( - schema: Mapping[str, Any], - name: str | None = None, -) -> type: - """Convert JSON schema to appropriate Python type with validation. - - Args: - schema: A JSON Schema dictionary defining the type structure and validation rules - name: Optional name for object schemas. Only allowed when schema type is "object". - If not provided for objects, name will be inferred from schema's "title" - property or default to "Root". - - Returns: - A Python type (typically a dataclass for objects) with Pydantic validation - - Raises: - ValueError: If a name is provided for a non-object schema - - Examples: - Create a dataclass from an object schema: - ```python - schema = { - "type": "object", - "title": "Person", - "properties": { - "name": {"type": "string", "minLength": 1}, - "age": {"type": "integer", "minimum": 0}, - "email": {"type": "string", "format": "email"} - }, - "required": ["name", "age"] - } - - Person = json_schema_to_type(schema) - # Creates a dataclass with name, age, and optional email fields: - # @dataclass - # class Person: - # name: str - # age: int - # email: str | None = None - ``` - Person(name="John", age=30) - - Create a scalar type with constraints: - ```python - schema = { - "type": "string", - "minLength": 3, - "pattern": "^[A-Z][a-z]+$" - } - - NameType = json_schema_to_type(schema) - # Creates Annotated[str, StringConstraints(min_length=3, pattern="^[A-Z][a-z]+$")] - - @dataclass - class Name: - name: NameType - ``` - """ - # Always use the top-level schema for references - if schema.get("type") == "object": - # If no properties defined but has additionalProperties, return typed dict - if not schema.get("properties") and schema.get("additionalProperties"): - additional_props = schema["additionalProperties"] - if additional_props is True: - return dict[str, Any] - else: - # Handle typed dictionaries like dict[str, str] - value_type = _schema_to_type(additional_props, schemas=schema) - # value_type might be ForwardRef or type - cast to Any for dynamic type construction - return cast(type[Any], dict[str, value_type]) # type: ignore[valid-type] # ty:ignore[invalid-type-form] - # If no properties and no additionalProperties, default to dict[str, Any] for safety - elif not schema.get("properties") and not schema.get("additionalProperties"): - return dict[str, Any] - # If has properties AND additionalProperties is True, use Pydantic BaseModel - elif schema.get("properties") and schema.get("additionalProperties") is True: - return _create_pydantic_model(schema, name, schemas=schema) - # Otherwise use fast dataclass - return _create_dataclass(schema, name, schemas=schema) - elif name: - raise ValueError(f"Can not apply name to non-object schema: {name}") - result = _schema_to_type(schema, schemas=schema) - return result # type: ignore[return-value] # ty:ignore[invalid-return-type] - - -def _hash_schema(schema: Mapping[str, Any]) -> str: - """Generate a deterministic hash for schema caching.""" - return hashlib.sha256(json.dumps(schema, sort_keys=True).encode()).hexdigest() - - -def _resolve_ref(ref: str, schemas: Mapping[str, Any]) -> Mapping[str, Any]: - """Resolve JSON Schema reference to target schema.""" - path = ref.replace("#/", "").split("/") - current = schemas - for part in path: - current = current.get(part, {}) - return current - - -def _create_string_type(schema: Mapping[str, Any]) -> type | Annotated[Any, ...]: - """Create string type with optional constraints.""" - if "const" in schema: - return Literal[schema["const"]] # type: ignore - - if fmt := schema.get("format"): - if fmt == "uri": - return AnyUrl - elif fmt == "uri-reference": - return str - return FORMAT_TYPES.get(fmt, str) - - constraints = { - k: v - for k, v in { - "min_length": schema.get("minLength"), - "max_length": schema.get("maxLength"), - "pattern": schema.get("pattern"), - }.items() - if v is not None - } - - return Annotated[str, StringConstraints(**constraints)] if constraints else str - - -def _create_numeric_type( - base: type[int | float], schema: Mapping[str, Any] -) -> type | Annotated[Any, ...]: - """Create numeric type with optional constraints.""" - if "const" in schema: - return Literal[schema["const"]] # type: ignore - - constraints = { - k: v - for k, v in { - "gt": schema.get("exclusiveMinimum"), - "ge": schema.get("minimum"), - "lt": schema.get("exclusiveMaximum"), - "le": schema.get("maximum"), - "multiple_of": schema.get("multipleOf"), - }.items() - if v is not None - } - - return Annotated[base, Field(**constraints)] if constraints else base # type: ignore[return-value] # ty:ignore[invalid-type-form] - - -def _create_enum(name: str, values: list[Any]) -> type: - """Create enum type from list of values.""" - # Always return Literal for enum fields to preserve the literal nature - return Literal[tuple(values)] # type: ignore[return-value] # ty:ignore[invalid-type-form] - - -def _create_array_type( - schema: Mapping[str, Any], schemas: Mapping[str, Any] -) -> type | Annotated[Any, ...]: - """Create list/set type with optional constraints.""" - items = schema.get("items", {}) - if isinstance(items, list): - # Handle positional item schemas - item_types = [_schema_to_type(s, schemas) for s in items] - combined = Union[tuple(item_types)] # noqa: UP007 - base = list[combined] # type: ignore[valid-type] # ty:ignore[invalid-type-form] - else: - # Handle single item schema - item_type = _schema_to_type(items, schemas) - base_class = set if schema.get("uniqueItems") else list - base = base_class[item_type] - - constraints = { - k: v - for k, v in { - "min_length": schema.get("minItems"), - "max_length": schema.get("maxItems"), - }.items() - if v is not None - } - - return Annotated[base, Field(**constraints)] if constraints else base # type: ignore[return-value] # ty:ignore[invalid-type-form] - - -def _return_Any() -> Any: - return Any - - -def _get_from_type_handler( - schema: Mapping[str, Any], schemas: Mapping[str, Any] -) -> Callable[..., Any]: - """Get the appropriate type handler for the schema.""" - - type_handlers: dict[str, Callable[..., Any]] = { # TODO - "string": lambda s: _create_string_type(s), - "integer": lambda s: _create_numeric_type(int, s), - "number": lambda s: _create_numeric_type(float, s), - "boolean": lambda _: bool, - "null": lambda _: type(None), - "array": lambda s: _create_array_type(s, schemas), - "object": lambda s: ( - _create_pydantic_model(s, s.get("title"), schemas) - if s.get("properties") and s.get("additionalProperties") is True - else _create_dataclass(s, s.get("title"), schemas) - ), - } - return type_handlers.get(schema.get("type", None), _return_Any) - - -def _schema_to_type( - schema: Mapping[str, Any], - schemas: Mapping[str, Any], -) -> type | ForwardRef: - """Convert schema to appropriate Python type.""" - if not schema: - return object - - if "type" not in schema and "properties" in schema: - return _create_dataclass(schema, schema.get("title", ""), schemas) - - # Handle references first - if "$ref" in schema: - ref = schema["$ref"] - # Handle self-reference - if ref == "#": - return ForwardRef(schema.get("title", "Root")) - return _schema_to_type(_resolve_ref(ref, schemas), schemas) - - if "const" in schema: - return Literal[schema["const"]] # type: ignore - - if "enum" in schema: - return _create_enum(f"Enum_{len(_classes)}", schema["enum"]) - - # Handle anyOf unions - if "anyOf" in schema: - types: list[type | Any] = [] - for subschema in schema["anyOf"]: - # Special handling for dict-like objects in unions - if ( - subschema.get("type") == "object" - and not subschema.get("properties") - and subschema.get("additionalProperties") - ): - # This is a dict type, handle it directly - additional_props = subschema["additionalProperties"] - if additional_props is True: - types.append(dict[str, Any]) - else: - value_type = _schema_to_type(additional_props, schemas) - types.append(dict[str, value_type]) # type: ignore - else: - types.append(_schema_to_type(subschema, schemas)) - - # Check if one of the types is None (null) - has_null = type(None) in types - types = [t for t in types if t is not type(None)] - - if len(types) == 0: - return type(None) - elif len(types) == 1: - if has_null: - return types[0] | None # type: ignore - else: - return types[0] - else: - if has_null: - return Union[(*types, type(None))] # type: ignore - else: - return Union[tuple(types)] # type: ignore # noqa: UP007 - - schema_type = schema.get("type") - if not schema_type: - return Any - - if isinstance(schema_type, list): - # Create a copy of the schema for each type, but keep all constraints - types: list[type | Any] = [] - for t in schema_type: - type_schema = dict(schema) - type_schema["type"] = t - types.append(_schema_to_type(type_schema, schemas)) - has_null = type(None) in types - types = [t for t in types if t is not type(None)] - if has_null: - if len(types) == 1: - return types[0] | None # type: ignore - else: - return Union[(*types, type(None))] # type: ignore - return Union[tuple(types)] # type: ignore # noqa: UP007 - - return _get_from_type_handler(schema, schemas)(schema) - - -def _sanitize_name(name: str) -> str: - """Convert string to valid Python identifier.""" - original_name = name - # Step 1: replace everything except [0-9a-zA-Z_] with underscores - cleaned = re.sub(r"[^0-9a-zA-Z_]", "_", name) - # Step 2: deduplicate underscores - cleaned = re.sub(r"__+", "_", cleaned) - # Step 3: if the first char of original name isn't a letter or underscore, prepend field_ - if not name or not re.match(r"[a-zA-Z_]", name[0]): - cleaned = f"field_{cleaned}" - # Step 4: deduplicate again - cleaned = re.sub(r"__+", "_", cleaned) - # Step 5: only strip trailing underscores if they weren't in the original name - if not original_name.endswith("_"): - cleaned = cleaned.rstrip("_") - return cleaned - - -def _get_default_value( - schema: dict[str, Any], - prop_name: str, - parent_default: dict[str, Any] | None = None, -) -> Any: - """Get default value with proper priority ordering. - 1. Value from parent's default if it exists - 2. Property's own default if it exists - 3. None - """ - if parent_default is not None and prop_name in parent_default: - return parent_default[prop_name] - return schema.get("default") - - -def _create_field_with_default( - field_type: type, - default_value: Any, - schema: dict[str, Any], -) -> Any: - """Create a field with simplified default handling.""" - # Always use None as default for complex types - if isinstance(default_value, dict | list) or default_value is None: - return field(default=None) - - # For simple types, use the value directly - return field(default=default_value) - - -def _create_pydantic_model( - schema: Mapping[str, Any], - name: str | None = None, - schemas: Mapping[str, Any] | None = None, -) -> type: - """Create Pydantic BaseModel from object schema with additionalProperties.""" - name = name or schema.get("title", "Root") - if name is None: - raise ValueError("Name is required") - sanitized_name = _sanitize_name(name) - schema_hash = _hash_schema(schema) - cache_key = (schema_hash, sanitized_name) - - # Return existing class if already built - if cache_key in _classes: - existing = _classes[cache_key] - if existing is None: - return ForwardRef(sanitized_name) # type: ignore[return-value] # ty:ignore[invalid-return-type] - return existing - - # Place placeholder for recursive references - _classes[cache_key] = None - - properties = schema.get("properties", {}) - required = schema.get("required", []) - - # Build field annotations and defaults - annotations = {} - defaults = {} - - for prop_name, prop_schema in properties.items(): - field_type = _schema_to_type(prop_schema, schemas or {}) - - # Handle defaults - default_value = prop_schema.get("default", MISSING) - if default_value is not MISSING: - defaults[prop_name] = default_value - annotations[prop_name] = field_type - elif prop_name in required: - annotations[prop_name] = field_type - else: - annotations[prop_name] = Union[field_type, type(None)] # type: ignore[misc] # noqa: UP007 # ty:ignore[invalid-type-form] - defaults[prop_name] = None - - # Create Pydantic model class - cls_dict = { - "__annotations__": annotations, - "model_config": ConfigDict(extra="allow"), - **defaults, - } - - cls = type(sanitized_name, (BaseModel,), cls_dict) - - # Store completed class - _classes[cache_key] = cls - return cls - - -def _create_dataclass( - schema: Mapping[str, Any], - name: str | None = None, - schemas: Mapping[str, Any] | None = None, -) -> type: - """Create dataclass from object schema.""" - name = name or schema.get("title", "Root") - # Sanitize name for class creation - if name is None: - raise ValueError("Name is required") - sanitized_name = _sanitize_name(name) - schema_hash = _hash_schema(schema) - cache_key = (schema_hash, sanitized_name) - original_schema = dict(schema) # Store copy for validator - - # Return existing class if already built - if cache_key in _classes: - existing = _classes[cache_key] - if existing is None: - return ForwardRef(sanitized_name) # type: ignore[return-value] # ty:ignore[invalid-return-type] - return existing - - # Place placeholder for recursive references - _classes[cache_key] = None - - if "$ref" in schema: - ref = schema["$ref"] - if ref == "#": - return ForwardRef(sanitized_name) # type: ignore[return-value] # ty:ignore[invalid-return-type] - schema = _resolve_ref(ref, schemas or {}) - - properties = schema.get("properties", {}) - required = schema.get("required", []) - - fields: list[tuple[Any, ...]] = [] - for prop_name, prop_schema in properties.items(): - field_name = _sanitize_name(prop_name) - - # Check for self-reference in property - if prop_schema.get("$ref") == "#": - field_type = ForwardRef(sanitized_name) - else: - field_type = _schema_to_type(prop_schema, schemas or {}) - - default_val = prop_schema.get("default", MISSING) - is_required = prop_name in required - - # Include alias in field metadata - meta = {"alias": prop_name} - - if default_val is not MISSING: - if isinstance(default_val, dict | list): - field_def = field( - default_factory=lambda d=default_val: deepcopy(d), metadata=meta - ) - else: - field_def = field(default=default_val, metadata=meta) - else: - if is_required: - field_def = field(metadata=meta) - else: - field_def = field(default=None, metadata=meta) - - if is_required or default_val is not MISSING: - fields.append((field_name, field_type, field_def)) - else: - fields.append((field_name, Union[field_type, type(None)], field_def)) # type: ignore[misc] # noqa: UP007 # ty:ignore[invalid-type-form] - - cls = make_dataclass(sanitized_name, fields, kw_only=True) - - # Add model validator for defaults - @model_validator(mode="before") - @classmethod - def _apply_defaults(cls, data: Mapping[str, Any]): - if isinstance(data, dict): - return _merge_defaults(data, original_schema) - return data - - cls._apply_defaults = _apply_defaults # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - - # Store completed class - _classes[cache_key] = cls - return cls - - -def _merge_defaults( - data: Mapping[str, Any], - schema: Mapping[str, Any], - parent_default: Mapping[str, Any] | None = None, -) -> dict[str, Any]: - """Merge defaults with provided data at all levels.""" - # If we have no data - if not data: - # Start with parent default if available - if parent_default: - result = dict(parent_default) - # Otherwise use schema default if available - elif "default" in schema: - result = dict(schema["default"]) - # Otherwise start empty - else: - result = {} - # If we have data and a parent default, merge them - elif parent_default: - result = dict(parent_default) - for key, value in data.items(): - if ( - isinstance(value, dict) - and key in result - and isinstance(result[key], dict) - ): - # recursively merge nested dicts - result[key] = _merge_defaults(value, {"properties": {}}, result[key]) - else: - result[key] = value - # Otherwise just use the data - else: - result = dict(data) - - # For each property in the schema - for prop_name, prop_schema in schema.get("properties", {}).items(): - # If property is missing, apply defaults in priority order - if prop_name not in result: - if parent_default and prop_name in parent_default: - result[prop_name] = parent_default[prop_name] - elif "default" in prop_schema: - result[prop_name] = prop_schema["default"] - - # If property exists and is an object, recursively merge - if ( - prop_name in result - and isinstance(result[prop_name], dict) - and prop_schema.get("type") == "object" - ): - # Get the appropriate default for this nested object - nested_default = None - if parent_default and prop_name in parent_default: - nested_default = parent_default[prop_name] - elif "default" in prop_schema: - nested_default = prop_schema["default"] - - result[prop_name] = _merge_defaults( - result[prop_name], prop_schema, nested_default - ) - - return result diff --git a/src/fastmcp/utilities/lifespan.py b/src/fastmcp/utilities/lifespan.py deleted file mode 100644 index f7f00b895..000000000 --- a/src/fastmcp/utilities/lifespan.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Lifespan utilities for combining async context manager lifespans.""" - -from __future__ import annotations - -from collections.abc import AsyncIterator, Callable, Mapping -from contextlib import AbstractAsyncContextManager, AsyncExitStack, asynccontextmanager -from typing import Any, TypeVar - -AppT = TypeVar("AppT") - - -def combine_lifespans( - *lifespans: Callable[[AppT], AbstractAsyncContextManager[Mapping[str, Any] | None]], -) -> Callable[[AppT], AbstractAsyncContextManager[dict[str, Any]]]: - """Combine multiple lifespans into a single lifespan. - - Useful when mounting FastMCP into FastAPI and you need to run - both your app's lifespan and the MCP server's lifespan. - - Works with both FastAPI-style lifespans (yield None) and FastMCP-style - lifespans (yield dict). Results are merged; later lifespans override - earlier ones on key conflicts. - - Lifespans are entered in order and exited in reverse order (LIFO). - - Example: - ```python - from fastmcp import FastMCP - from fastmcp.utilities.lifespan import combine_lifespans - from fastapi import FastAPI - - mcp = FastMCP("Tools") - mcp_app = mcp.http_app() - - app = FastAPI(lifespan=combine_lifespans(app_lifespan, mcp_app.lifespan)) - app.mount("/mcp", mcp_app) # MCP endpoint at /mcp - ``` - - Args: - *lifespans: Lifespan context manager factories to combine. - - Returns: - A combined lifespan context manager factory. - """ - - @asynccontextmanager - async def combined(app: AppT) -> AsyncIterator[dict[str, Any]]: - merged: dict[str, Any] = {} - async with AsyncExitStack() as stack: - for ls in lifespans: - result = await stack.enter_async_context(ls(app)) - if result is not None: - merged.update(result) - yield merged - - return combined diff --git a/src/fastmcp/utilities/logging.py b/src/fastmcp/utilities/logging.py deleted file mode 100644 index 1cd0470d7..000000000 --- a/src/fastmcp/utilities/logging.py +++ /dev/null @@ -1,238 +0,0 @@ -"""Logging utilities for FastMCP.""" - -import contextlib -import logging -from typing import Any, Literal, cast - -from rich.console import Console -from rich.logging import RichHandler -from typing_extensions import override - -import fastmcp - - -def get_logger(name: str) -> logging.Logger: - """Get a logger nested under FastMCP namespace. - - Args: - name: the name of the logger, which will be prefixed with 'FastMCP.' - - Returns: - a configured logger instance - """ - if name.startswith("fastmcp."): - return logging.getLogger(name=name) - - return logging.getLogger(name=f"fastmcp.{name}") - - -def configure_logging( - level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | int = "INFO", - logger: logging.Logger | None = None, - enable_rich_tracebacks: bool | None = None, - **rich_kwargs: Any, -) -> None: - """ - Configure logging for FastMCP. - - Args: - logger: the logger to configure - level: the log level to use - rich_kwargs: the parameters to use for creating RichHandler - """ - # Check if logging is disabled in settings - if not fastmcp.settings.log_enabled: - return - - # Use settings default if not specified - if enable_rich_tracebacks is None: - enable_rich_tracebacks = fastmcp.settings.enable_rich_tracebacks - - if logger is None: - logger = logging.getLogger("fastmcp") - - formatter = logging.Formatter("%(message)s") - - # Don't propagate to the root logger - logger.propagate = False - logger.setLevel(level) - - # Remove any existing handlers to avoid duplicates on reconfiguration - for hdlr in logger.handlers[:]: - logger.removeHandler(hdlr) - - # Use standard logging handlers if rich logging is disabled - if not fastmcp.settings.enable_rich_logging: - # Create a standard StreamHandler for stderr - handler = logging.StreamHandler() - handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) - logger.addHandler(handler) - return - - # Configure the handler for normal logs - handler = RichHandler( - console=Console(stderr=True), - **rich_kwargs, - ) - handler.setFormatter(formatter) - - # filter to exclude tracebacks - handler.addFilter(lambda record: record.exc_info is None) - - # Configure the handler for tracebacks, for tracebacks we use a compressed format: - # no path or level name to maximize width available for the traceback - # suppress framework frames and limit the number of frames to 3 - - import mcp - import pydantic - - # Build traceback kwargs with defaults that can be overridden - traceback_kwargs = { - "console": Console(stderr=True), - "show_path": False, - "show_level": False, - "rich_tracebacks": enable_rich_tracebacks, - "tracebacks_max_frames": 3, - "tracebacks_suppress": [fastmcp, mcp, pydantic], - } - # Override defaults with user-provided values - traceback_kwargs.update(rich_kwargs) - - traceback_handler = RichHandler(**traceback_kwargs) # type: ignore[arg-type] # ty:ignore[invalid-argument-type] - traceback_handler.setFormatter(formatter) - - traceback_handler.addFilter(lambda record: record.exc_info is not None) - - logger.addHandler(handler) - logger.addHandler(traceback_handler) - - -@contextlib.contextmanager -def temporary_log_level( - level: str | None, - logger: logging.Logger | None = None, - enable_rich_tracebacks: bool | None = None, - **rich_kwargs: Any, -): - """Context manager to temporarily set log level and restore it afterwards. - - Args: - level: The temporary log level to set (e.g., "DEBUG", "INFO") - logger: Optional logger to configure (defaults to FastMCP logger) - enable_rich_tracebacks: Whether to enable rich tracebacks - **rich_kwargs: Additional parameters for RichHandler - - Usage: - with temporary_log_level("DEBUG"): - # Code that runs with DEBUG logging - pass - # Original log level is restored here - """ - if level: - # Get the original log level from settings - original_level = fastmcp.settings.log_level - - # Configure with new level - # Cast to proper type for type checker - log_level_literal = cast( - Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], - level.upper(), - ) - configure_logging( - level=log_level_literal, - logger=logger, - enable_rich_tracebacks=enable_rich_tracebacks, - **rich_kwargs, - ) - try: - yield - finally: - # Restore original configuration using configure_logging - # This will respect the log_enabled setting - configure_logging( - level=original_level, - logger=logger, - enable_rich_tracebacks=enable_rich_tracebacks, - **rich_kwargs, - ) - else: - yield - - -_level_to_no: dict[ - Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None, int | None -] = { - "DEBUG": logging.DEBUG, - "INFO": logging.INFO, - "WARNING": logging.WARNING, - "ERROR": logging.ERROR, - "CRITICAL": logging.CRITICAL, - None: None, -} - - -class _ClampedLogFilter(logging.Filter): - min_level: tuple[int, str] | None - max_level: tuple[int, str] | None - - def __init__( - self, - min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - | None = None, - max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - | None = None, - ): - self.min_level = None - self.max_level = None - - if min_level_no := _level_to_no.get(min_level): - self.min_level = (min_level_no, str(min_level)) - if max_level_no := _level_to_no.get(max_level): - self.max_level = (max_level_no, str(max_level)) - - super().__init__() - - @override - def filter(self, record: logging.LogRecord) -> bool: - if self.max_level: - max_level_no, max_level_name = self.max_level - - if record.levelno > max_level_no: - record.levelno = max_level_no - record.levelname = max_level_name - return True - - if self.min_level: - min_level_no, min_level_name = self.min_level - if record.levelno < min_level_no: - record.levelno = min_level_no - record.levelname = min_level_name - return True - - return True - - -def _clamp_logger( - logger: logging.Logger, - min_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, - max_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = None, -) -> None: - """Clamp the logger to a minimum and maximum level. - - If min_level is provided, messages logged at a lower level than `min_level` will have their level increased to `min_level`. - If max_level is provided, messages logged at a higher level than `max_level` will have their level decreased to `max_level`. - - Args: - min_level: The lower bound of the clamp - max_level: The upper bound of the clamp - """ - _unclamp_logger(logger=logger) - - logger.addFilter(filter=_ClampedLogFilter(min_level=min_level, max_level=max_level)) - - -def _unclamp_logger(logger: logging.Logger) -> None: - """Remove all clamped log filters from the logger.""" - for filter in logger.filters[:]: - if isinstance(filter, _ClampedLogFilter): - logger.removeFilter(filter) diff --git a/src/fastmcp/utilities/mcp_server_config/__init__.py b/src/fastmcp/utilities/mcp_server_config/__init__.py deleted file mode 100644 index 6cdfadcc5..000000000 --- a/src/fastmcp/utilities/mcp_server_config/__init__.py +++ /dev/null @@ -1,25 +0,0 @@ -"""FastMCP Configuration module. - -This module provides versioned configuration support for FastMCP servers. -The current version is v1, which is re-exported here for convenience. -""" - -from fastmcp.utilities.mcp_server_config.v1.environments.base import Environment -from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment -from fastmcp.utilities.mcp_server_config.v1.mcp_server_config import ( - Deployment, - MCPServerConfig, - generate_schema, -) -from fastmcp.utilities.mcp_server_config.v1.sources.base import Source -from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource - -__all__ = [ - "Deployment", - "Environment", - "FileSystemSource", - "MCPServerConfig", - "Source", - "UVEnvironment", - "generate_schema", -] diff --git a/src/fastmcp/utilities/mcp_server_config/v1/__init__.py b/src/fastmcp/utilities/mcp_server_config/v1/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/__init__.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/__init__.py deleted file mode 100644 index 3cccf548f..000000000 --- a/src/fastmcp/utilities/mcp_server_config/v1/environments/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Environment configuration for MCP servers.""" - -from fastmcp.utilities.mcp_server_config.v1.environments.base import Environment -from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment - -__all__ = ["Environment", "UVEnvironment"] diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py deleted file mode 100644 index 0d1b1f8b1..000000000 --- a/src/fastmcp/utilities/mcp_server_config/v1/environments/base.py +++ /dev/null @@ -1,29 +0,0 @@ -from abc import ABC, abstractmethod -from pathlib import Path - -from pydantic import BaseModel, Field - - -class Environment(BaseModel, ABC): - """Base class for environment configuration.""" - - type: str = Field(description="Environment type identifier") - - @abstractmethod - def build_command(self, command: list[str]) -> list[str]: - """Build the full command with environment setup. - - Args: - command: Base command to wrap with environment setup - - Returns: - Full command ready for subprocess execution - """ - - async def prepare(self, output_dir: Path | None = None) -> None: - """Prepare the environment (optional, can be no-op). - - Args: - output_dir: Directory for persistent environment setup - """ - # Default no-op implementation diff --git a/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py b/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py deleted file mode 100644 index a88965435..000000000 --- a/src/fastmcp/utilities/mcp_server_config/v1/environments/uv.py +++ /dev/null @@ -1,271 +0,0 @@ -import shutil -import subprocess -from pathlib import Path -from typing import Literal - -from pydantic import Field - -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config.v1.environments.base import Environment - -logger = get_logger("cli.config") - - -class UVEnvironment(Environment): - """Configuration for Python environment setup.""" - - type: Literal["uv"] = "uv" - - python: str | None = Field( - default=None, - description="Python version constraint", - examples=["3.10", "3.11", "3.12"], - ) - - dependencies: list[str] | None = Field( - default=None, - description="Python packages to install with PEP 508 specifiers", - examples=[["fastmcp>=2.0,<3", "httpx", "pandas>=2.0"]], - ) - - requirements: Path | None = Field( - default=None, - description="Path to requirements.txt file", - examples=["requirements.txt", "../requirements/prod.txt"], - ) - - project: Path | None = Field( - default=None, - description="Path to project directory containing pyproject.toml", - examples=[".", "../my-project"], - ) - - editable: list[Path] | None = Field( - default=None, - description="Directories to install in editable mode", - examples=[[".", "../my-package"], ["/path/to/package"]], - ) - - def build_command(self, command: list[str]) -> list[str]: - """Build complete uv run command with environment args and command to execute. - - Args: - command: Command to execute (e.g., ["fastmcp", "run", "server.py"]) - - Returns: - Complete command ready for subprocess.run, including "uv" prefix if needed. - If no environment configuration is set, returns the command unchanged. - """ - # If no environment setup is needed, return command as-is - if not self._must_run_with_uv(): - return command - - args = ["uv", "run"] - - # Add project if specified - if self.project: - args.extend(["--project", str(self.project.resolve())]) - - # Add Python version if specified (only if no project, as project has its own Python) - if self.python and not self.project: - args.extend(["--python", self.python]) - - # Always add dependencies, requirements, and editable packages - # These work with --project to add additional packages on top of the project env - if self.dependencies: - for dep in sorted(set(self.dependencies)): - args.extend(["--with", dep]) - - # Add requirements file - if self.requirements: - args.extend(["--with-requirements", str(self.requirements.resolve())]) - - # Add editable packages - if self.editable: - for editable_path in self.editable: - args.extend(["--with-editable", str(editable_path.resolve())]) - - # Add the command - args.extend(command) - - return args - - def _must_run_with_uv(self) -> bool: - """Check if this environment config requires uv to set up. - - Returns: - True if any environment settings require uv run - """ - return any( - [ - self.python is not None, - self.dependencies is not None, - self.requirements is not None, - self.project is not None, - self.editable is not None, - ] - ) - - async def prepare(self, output_dir: Path | None = None) -> None: - """Prepare the Python environment using uv. - - Args: - output_dir: Directory where the persistent uv project will be created. - If None, creates a temporary directory for ephemeral use. - """ - - # Check if uv is available - if not shutil.which("uv"): - raise RuntimeError( - "uv is not installed. Please install it with: " - "curl -LsSf https://astral.sh/uv/install.sh | sh" - ) - - # Only prepare environment if there are actual settings to apply - if not self._must_run_with_uv(): - logger.debug("No environment settings configured, skipping preparation") - return - - # Handle None case for ephemeral use - if output_dir is None: - import tempfile - - output_dir = Path(tempfile.mkdtemp(prefix="fastmcp-env-")) - logger.info(f"Creating ephemeral environment in {output_dir}") - else: - logger.info(f"Creating persistent environment in {output_dir}") - output_dir = Path(output_dir).resolve() - - # Initialize the project - logger.debug(f"Initializing uv project in {output_dir}") - try: - subprocess.run( - [ - "uv", - "init", - "--project", - str(output_dir), - "--name", - "fastmcp-env", - ], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as e: - # If project already exists, that's fine - continue - if "already initialized" in e.stderr.lower(): - logger.debug( - f"Project already initialized at {output_dir}, continuing..." - ) - else: - logger.error(f"Failed to initialize project: {e.stderr}") - raise RuntimeError(f"Failed to initialize project: {e.stderr}") from e - - # Pin Python version if specified - if self.python: - logger.debug(f"Pinning Python version to {self.python}") - try: - subprocess.run( - [ - "uv", - "python", - "pin", - self.python, - "--project", - str(output_dir), - ], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as e: - logger.error(f"Failed to pin Python version: {e.stderr}") - raise RuntimeError(f"Failed to pin Python version: {e.stderr}") from e - - # Add dependencies with --no-sync to defer installation - # dependencies ALWAYS include fastmcp; this is compatible with - # specific fastmcp versions that might be in the dependencies list - dependencies = (self.dependencies or []) + ["fastmcp"] - logger.debug(f"Adding dependencies: {', '.join(dependencies)}") - try: - subprocess.run( - [ - "uv", - "add", - *dependencies, - "--no-sync", - "--project", - str(output_dir), - ], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as e: - logger.error(f"Failed to add dependencies: {e.stderr}") - raise RuntimeError(f"Failed to add dependencies: {e.stderr}") from e - - # Add requirements file if specified - if self.requirements: - logger.debug(f"Adding requirements from {self.requirements}") - # Resolve requirements path relative to current directory - req_path = Path(self.requirements).resolve() - try: - subprocess.run( - [ - "uv", - "add", - "-r", - str(req_path), - "--no-sync", - "--project", - str(output_dir), - ], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as e: - logger.error(f"Failed to add requirements: {e.stderr}") - raise RuntimeError(f"Failed to add requirements: {e.stderr}") from e - - # Add editable packages if specified - if self.editable: - editable_paths = [str(Path(e).resolve()) for e in self.editable] - logger.debug(f"Adding editable packages: {', '.join(editable_paths)}") - try: - subprocess.run( - [ - "uv", - "add", - "--editable", - *editable_paths, - "--no-sync", - "--project", - str(output_dir), - ], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as e: - logger.error(f"Failed to add editable packages: {e.stderr}") - raise RuntimeError( - f"Failed to add editable packages: {e.stderr}" - ) from e - - # Final sync to install everything - logger.info("Installing dependencies...") - try: - subprocess.run( - ["uv", "sync", "--project", str(output_dir)], - check=True, - capture_output=True, - text=True, - ) - except subprocess.CalledProcessError as e: - logger.error(f"Failed to sync dependencies: {e.stderr}") - raise RuntimeError(f"Failed to sync dependencies: {e.stderr}") from e - - logger.info(f"Environment prepared successfully in {output_dir}") diff --git a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py b/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py deleted file mode 100644 index b926bebcd..000000000 --- a/src/fastmcp/utilities/mcp_server_config/v1/mcp_server_config.py +++ /dev/null @@ -1,447 +0,0 @@ -"""FastMCP Configuration File Support. - -This module provides support for fastmcp.json configuration files that allow -users to specify server settings in a declarative format instead of using -command-line arguments. -""" - -from __future__ import annotations - -import json -import os -import re -from pathlib import Path -from typing import TYPE_CHECKING, Any, Literal, TypeAlias, cast, overload - -from pydantic import BaseModel, Field, field_validator - -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config.v1.environments.uv import UVEnvironment -from fastmcp.utilities.mcp_server_config.v1.sources.base import Source -from fastmcp.utilities.mcp_server_config.v1.sources.filesystem import FileSystemSource - -logger = get_logger("cli.config") - -# JSON Schema for IDE support -FASTMCP_JSON_SCHEMA = "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" - - -# Type alias for source union (will expand with GitSource, etc. in future) -SourceType: TypeAlias = FileSystemSource - -# Type alias for environment union (will expand with other environments in future) -EnvironmentType: TypeAlias = UVEnvironment - - -class Deployment(BaseModel): - """Configuration for server deployment and runtime settings.""" - - transport: Literal["stdio", "http", "sse", "streamable-http"] | None = Field( - default=None, - description="Transport protocol to use", - ) - - host: str | None = Field( - default=None, - description="Host to bind to when using HTTP transport", - examples=["127.0.0.1", "0.0.0.0", "localhost"], - ) - - port: int | None = Field( - default=None, - description="Port to bind to when using HTTP transport", - examples=[8000, 3000, 5000], - ) - - path: str | None = Field( - default=None, - description="URL path for the server endpoint", - examples=["/mcp/", "/api/mcp/", "/sse/"], - ) - - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] | None = Field( - default=None, - description="Log level for the server", - ) - - cwd: str | None = Field( - default=None, - description="Working directory for the server process", - examples=[".", "./src", "/app"], - ) - - env: dict[str, str] | None = Field( - default=None, - description="Environment variables to set when running the server", - examples=[{"API_KEY": "secret", "DEBUG": "true"}], - ) - - args: list[str] | None = Field( - default=None, - description="Arguments to pass to the server (after --)", - examples=[["--config", "config.json", "--debug"]], - ) - - def apply_runtime_settings(self, config_path: Path | None = None) -> None: - """Apply runtime settings like environment variables and working directory. - - Args: - config_path: Path to config file for resolving relative paths - - Environment variables support interpolation with ${VAR_NAME} syntax. - For example: "API_URL": "https://api.${ENVIRONMENT}.example.com" - will substitute the value of the ENVIRONMENT variable at runtime. - """ - import os - from pathlib import Path - - # Set environment variables with interpolation support - if self.env: - for key, value in self.env.items(): - # Interpolate environment variables in the value - interpolated_value = self._interpolate_env_vars(value) - os.environ[key] = interpolated_value - - # Change working directory - if self.cwd: - cwd_path = Path(self.cwd) - if not cwd_path.is_absolute() and config_path: - cwd_path = (config_path.parent / cwd_path).resolve() - os.chdir(cwd_path) - - def _interpolate_env_vars(self, value: str) -> str: - """Interpolate environment variables in a string. - - Replaces ${VAR_NAME} with the value of VAR_NAME from the environment. - If the variable is not set, the placeholder is left unchanged. - - Args: - value: String potentially containing ${VAR_NAME} placeholders - - Returns: - String with environment variables interpolated - """ - - def replace_var(match: re.Match) -> str: - var_name = match.group(1) - # Return the environment variable value if it exists, otherwise keep the placeholder - return os.environ.get(var_name, match.group(0)) - - # Match ${VAR_NAME} pattern and replace with environment variable values - return re.sub(r"\$\{([^}]+)\}", replace_var, value) - - -class MCPServerConfig(BaseModel): - """Configuration for a FastMCP server. - - This configuration file allows you to specify all settings needed to run - a FastMCP server in a declarative format. - """ - - # Schema field for IDE support - schema_: str | None = Field( - default="https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - alias="$schema", - description="JSON schema for IDE support and validation", - ) - - # Server source - defines where and how to load the server - source: SourceType = Field( - description="Source configuration for the server", - examples=[ - {"path": "server.py"}, - {"path": "server.py", "entrypoint": "app"}, - {"type": "filesystem", "path": "src/server.py", "entrypoint": "mcp"}, - ], - ) - - # Environment configuration - environment: EnvironmentType = Field( - default_factory=lambda: UVEnvironment(), - description="Python environment setup configuration", - ) - - # Deployment configuration - deployment: Deployment = Field( - default_factory=lambda: Deployment(), - description="Server deployment and runtime settings", - ) - - # purely for static type checkers to avoid issues with providing dict source - if TYPE_CHECKING: - - @overload - def __init__(self, *, source: dict | FileSystemSource, **data) -> None: ... - @overload - def __init__(self, *, environment: dict | UVEnvironment, **data) -> None: ... - @overload - def __init__(self, *, deployment: dict | Deployment, **data) -> None: ... - def __init__(self, **data) -> None: ... - - @field_validator("source", mode="before") - @classmethod - def validate_source(cls, v: dict | Source) -> SourceType: - """Validate and convert source to proper format. - - Supports: - - Dict format: `{"path": "server.py", "entrypoint": "app"}` - - FileSystemSource instance (passed through) - - No string parsing happens here - that's only at CLI boundaries. - MCPServerConfig works only with properly typed objects. - """ - if isinstance(v, dict): - return FileSystemSource(**v) - return v # type: ignore[return-value] # ty:ignore[invalid-return-type] - - @field_validator("environment", mode="before") - @classmethod - def validate_environment(cls, v: dict | Any) -> EnvironmentType: - """Ensure environment has a type field for discrimination. - - For backward compatibility, if no type is specified, default to "uv". - """ - if isinstance(v, dict): - return UVEnvironment(**v) - return v - - @field_validator("deployment", mode="before") - @classmethod - def validate_deployment(cls, v: dict | Deployment) -> Deployment: - """Validate and convert deployment to Deployment. - - Accepts: - - Deployment instance - - dict that can be converted to Deployment - - """ - if isinstance(v, dict): - return Deployment(**v) - return cast(Deployment, v) # type: ignore[return-value] # ty:ignore[redundant-cast] - - @classmethod - def from_file(cls, file_path: Path) -> MCPServerConfig: - """Load configuration from a JSON file. - - Args: - file_path: Path to the configuration file - - Returns: - MCPServerConfig instance - - Raises: - FileNotFoundError: If the file doesn't exist - json.JSONDecodeError: If the file is not valid JSON - pydantic.ValidationError: If the configuration is invalid - """ - if not file_path.exists(): - raise FileNotFoundError(f"Configuration file not found: {file_path}") - - with file_path.open("r", encoding="utf-8") as f: - data = json.load(f) - - return cls.model_validate(data) - - @classmethod - def from_cli_args( - cls, - source: FileSystemSource, - transport: Literal["stdio", "http", "sse", "streamable-http"] | None = None, - host: str | None = None, - port: int | None = None, - path: str | None = None, - log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] - | None = None, - python: str | None = None, - dependencies: list[str] | None = None, - requirements: str | None = None, - project: str | None = None, - editable: str | None = None, - env: dict[str, str] | None = None, - cwd: str | None = None, - args: list[str] | None = None, - ) -> MCPServerConfig: - """Create a config from CLI arguments. - - This allows us to have a single code path where everything - goes through a config object. - - Args: - source: Server source (FileSystemSource instance) - transport: Transport protocol - host: Host for HTTP transport - port: Port for HTTP transport - path: URL path for server - log_level: Logging level - python: Python version - dependencies: Python packages to install - requirements: Path to requirements file - project: Path to project directory - editable: Path to install in editable mode - env: Environment variables - cwd: Working directory - args: Server arguments - - Returns: - MCPServerConfig instance - """ - # Build environment config if any env args provided - environment = None - if any([python, dependencies, requirements, project, editable]): - environment = UVEnvironment( - python=python, - dependencies=dependencies, - requirements=Path(requirements) if requirements else None, - project=Path(project) if project else None, - editable=[Path(editable)] if editable else None, - ) - - # Build deployment config if any deployment args provided - deployment = None - if any([transport, host, port, path, log_level, env, cwd, args]): - # Convert streamable-http to http for backward compatibility - if transport == "streamable-http": - transport = "http" - deployment = Deployment( - transport=transport, - host=host, - port=port, - path=path, - log_level=log_level, - env=env, - cwd=cwd, - args=args, - ) - - return cls( - source=source, - environment=environment, - deployment=deployment, - ) - - @classmethod - def find_config(cls, start_path: Path | None = None) -> Path | None: - """Find a fastmcp.json file in the specified directory. - - Args: - start_path: Directory to look in (defaults to current directory) - - Returns: - Path to the configuration file, or None if not found - """ - if start_path is None: - start_path = Path.cwd() - - config_path = start_path / "fastmcp.json" - if config_path.exists(): - logger.debug(f"Found configuration file: {config_path}") - return config_path - - return None - - async def prepare( - self, - skip_source: bool = False, - output_dir: Path | None = None, - ) -> None: - """Prepare environment and source for execution. - - When output_dir is provided, creates a persistent uv project. - When output_dir is None, does ephemeral caching (for backwards compatibility). - - Args: - skip_source: Skip source preparation if True - output_dir: Directory to create the persistent uv project in (optional) - """ - # Prepare environment (persistent if output_dir provided, ephemeral otherwise) - if self.environment: - await self.prepare_environment(output_dir=output_dir) - - if not skip_source: - await self.prepare_source() - - async def prepare_environment(self, output_dir: Path | None = None) -> None: - """Prepare the Python environment. - - Args: - output_dir: If provided, creates a persistent uv project in this directory. - If None, just populates uv's cache for ephemeral use. - - Delegates to the environment's prepare() method - """ - await self.environment.prepare(output_dir=output_dir) - - async def prepare_source(self) -> None: - """Prepare the source for loading. - - Delegates to the source's prepare() method. - """ - await self.source.prepare() - - async def run_server(self, **kwargs: Any) -> None: - """Load and run the server with this configuration. - - Args: - **kwargs: Additional arguments to pass to server.run_async() - These override config settings - """ - # Apply deployment settings (env vars, cwd) - if self.deployment: - self.deployment.apply_runtime_settings() - - # Load the server - server = await self.source.load_server() - - # Build run arguments from config - run_args = {} - if self.deployment: - if self.deployment.transport: - run_args["transport"] = self.deployment.transport - if self.deployment.host: - run_args["host"] = self.deployment.host - if self.deployment.port: - run_args["port"] = self.deployment.port - if self.deployment.path: - run_args["path"] = self.deployment.path - if self.deployment.log_level: - run_args["log_level"] = self.deployment.log_level - - # Override with any provided kwargs - run_args.update(kwargs) - - # Run the server - await server.run_async(**run_args) - - -def generate_schema(output_path: Path | str | None = None) -> dict[str, Any] | None: - """Generate JSON schema for fastmcp.json files. - - This is used to create the schema file that IDEs can use for - validation and auto-completion. - - Args: - output_path: Optional path to write the schema to. If provided, - writes the schema and returns None. If not provided, - returns the schema as a dictionary. - - Returns: - JSON schema as a dictionary if output_path is None, otherwise None - """ - schema = MCPServerConfig.model_json_schema() - - # Add some metadata - schema["$id"] = FASTMCP_JSON_SCHEMA - schema["title"] = "FastMCP Configuration" - schema["description"] = "Configuration file for FastMCP servers" - - if output_path: - import json - - output = Path(output_path) - output.parent.mkdir(parents=True, exist_ok=True) - with open(output, "w") as f: - json.dump(schema, f, indent=2) - f.write("\n") # Add trailing newline - return None - - return schema diff --git a/src/fastmcp/utilities/mcp_server_config/v1/schema.json b/src/fastmcp/utilities/mcp_server_config/v1/schema.json deleted file mode 100644 index aa1f59ce4..000000000 --- a/src/fastmcp/utilities/mcp_server_config/v1/schema.json +++ /dev/null @@ -1,365 +0,0 @@ -{ - "$defs": { - "Deployment": { - "description": "Configuration for server deployment and runtime settings.", - "properties": { - "transport": { - "anyOf": [ - { - "enum": [ - "stdio", - "http", - "sse", - "streamable-http" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Transport protocol to use", - "title": "Transport" - }, - "host": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Host to bind to when using HTTP transport", - "examples": [ - "127.0.0.1", - "0.0.0.0", - "localhost" - ], - "title": "Host" - }, - "port": { - "anyOf": [ - { - "type": "integer" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Port to bind to when using HTTP transport", - "examples": [ - 8000, - 3000, - 5000 - ], - "title": "Port" - }, - "path": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "URL path for the server endpoint", - "examples": [ - "/mcp/", - "/api/mcp/", - "/sse/" - ], - "title": "Path" - }, - "log_level": { - "anyOf": [ - { - "enum": [ - "DEBUG", - "INFO", - "WARNING", - "ERROR", - "CRITICAL" - ], - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Log level for the server", - "title": "Log Level" - }, - "cwd": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Working directory for the server process", - "examples": [ - ".", - "./src", - "/app" - ], - "title": "Cwd" - }, - "env": { - "anyOf": [ - { - "additionalProperties": { - "type": "string" - }, - "type": "object" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Environment variables to set when running the server", - "examples": [ - { - "API_KEY": "secret", - "DEBUG": "true" - } - ], - "title": "Env" - }, - "args": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Arguments to pass to the server (after --)", - "examples": [ - [ - "--config", - "config.json", - "--debug" - ] - ], - "title": "Args" - } - }, - "title": "Deployment", - "type": "object" - }, - "FileSystemSource": { - "description": "Source for local Python files.", - "properties": { - "type": { - "const": "filesystem", - "default": "filesystem", - "title": "Type", - "type": "string" - }, - "path": { - "description": "Path to Python file containing the server", - "title": "Path", - "type": "string" - }, - "entrypoint": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Name of server instance or factory function (a no-arg function that returns a FastMCP server)", - "title": "Entrypoint" - } - }, - "required": [ - "path" - ], - "title": "FileSystemSource", - "type": "object" - }, - "UVEnvironment": { - "description": "Configuration for Python environment setup.", - "properties": { - "type": { - "const": "uv", - "default": "uv", - "title": "Type", - "type": "string" - }, - "python": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Python version constraint", - "examples": [ - "3.10", - "3.11", - "3.12" - ], - "title": "Python" - }, - "dependencies": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Python packages to install with PEP 508 specifiers", - "examples": [ - [ - "fastmcp>=2.0,<3", - "httpx", - "pandas>=2.0" - ] - ], - "title": "Dependencies" - }, - "requirements": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Path to requirements.txt file", - "examples": [ - "requirements.txt", - "../requirements/prod.txt" - ], - "title": "Requirements" - }, - "project": { - "anyOf": [ - { - "format": "path", - "type": "string" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Path to project directory containing pyproject.toml", - "examples": [ - ".", - "../my-project" - ], - "title": "Project" - }, - "editable": { - "anyOf": [ - { - "items": { - "format": "path", - "type": "string" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Directories to install in editable mode", - "examples": [ - [ - ".", - "../my-package" - ], - [ - "/path/to/package" - ] - ], - "title": "Editable" - } - }, - "title": "UVEnvironment", - "type": "object" - } - }, - "description": "Configuration file for FastMCP servers", - "properties": { - "$schema": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "default": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json", - "description": "JSON schema for IDE support and validation", - "title": "$Schema" - }, - "source": { - "$ref": "#/$defs/FileSystemSource", - "description": "Source configuration for the server", - "examples": [ - { - "path": "server.py" - }, - { - "entrypoint": "app", - "path": "server.py" - }, - { - "entrypoint": "mcp", - "path": "src/server.py", - "type": "filesystem" - } - ] - }, - "environment": { - "$ref": "#/$defs/UVEnvironment", - "description": "Python environment setup configuration" - }, - "deployment": { - "$ref": "#/$defs/Deployment", - "description": "Server deployment and runtime settings" - } - }, - "required": [ - "source" - ], - "title": "FastMCP Configuration", - "type": "object", - "$id": "https://gofastmcp.com/public/schemas/fastmcp.json/v1.json" -} diff --git a/src/fastmcp/utilities/mcp_server_config/v1/sources/__init__.py b/src/fastmcp/utilities/mcp_server_config/v1/sources/__init__.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py b/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py deleted file mode 100644 index cc1e9412b..000000000 --- a/src/fastmcp/utilities/mcp_server_config/v1/sources/base.py +++ /dev/null @@ -1,29 +0,0 @@ -from abc import ABC, abstractmethod -from typing import Any - -from pydantic import BaseModel, Field - - -class Source(BaseModel, ABC): - """Abstract base class for all source types.""" - - type: str = Field(description="Source type identifier") - - async def prepare(self) -> None: - """Prepare the source (download, clone, install, etc). - - For sources that need preparation (e.g., git clone, download), - this method performs that preparation. For sources that don't - need preparation (e.g., local files), this is a no-op. - """ - # Default implementation for sources that don't need preparation - - @abstractmethod - async def load_server(self) -> Any: - """Load and return the FastMCP server instance. - - Must be called after prepare() if the source requires preparation. - All information needed to load the server should be available - as attributes on the source instance. - """ - ... diff --git a/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py b/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py deleted file mode 100644 index f94dc7c97..000000000 --- a/src/fastmcp/utilities/mcp_server_config/v1/sources/filesystem.py +++ /dev/null @@ -1,217 +0,0 @@ -import importlib.util -import inspect -import sys -from pathlib import Path -from typing import Any, Literal - -from pydantic import Field, field_validator - -from fastmcp.utilities.async_utils import is_coroutine_function -from fastmcp.utilities.logging import get_logger -from fastmcp.utilities.mcp_server_config.v1.sources.base import Source - -logger = get_logger(__name__) - - -class FileSystemSource(Source): - """Source for local Python files.""" - - type: Literal["filesystem"] = "filesystem" - - path: str = Field(description="Path to Python file containing the server") - entrypoint: str | None = Field( - default=None, - description="Name of server instance or factory function (a no-arg function that returns a FastMCP server)", - ) - - @field_validator("path", mode="before") - @classmethod - def parse_path_with_object(cls, v: str) -> str: - """Parse path:object syntax and extract the object name. - - This validator runs before the model is created, allowing us to - handle the "file.py:object" syntax at the model boundary. - """ - if isinstance(v, str) and ":" in v: - # Check if it's a Windows path (e.g., C:\...) - has_windows_drive = len(v) > 1 and v[1] == ":" - - # Only split if colon is not part of Windows drive - if ":" in (v[2:] if has_windows_drive else v): - # This path has an object specification - # We'll handle it in __init__ by setting entrypoint - return v - return v - - def __init__(self, **data: Any) -> None: - """Initialize FileSystemSource, handling path:object syntax.""" - # Check if path contains an object specification - if "path" in data and isinstance(data["path"], str) and ":" in data["path"]: - path_str = data["path"] - # Check if it's a Windows path (e.g., C:\...) - has_windows_drive = len(path_str) > 1 and path_str[1] == ":" - - # Only split if colon is not part of Windows drive - if ":" in (path_str[2:] if has_windows_drive else path_str): - file_str, obj = path_str.rsplit(":", 1) - data["path"] = file_str - # Only set entrypoint if not already provided - if "entrypoint" not in data or data["entrypoint"] is None: - data["entrypoint"] = obj - - super().__init__(**data) - - async def load_server(self) -> Any: - """Load server from filesystem.""" - # Resolve the file path - file_path = Path(self.path).expanduser().resolve() - if not file_path.exists(): - logger.error(f"File not found: {file_path}") - sys.exit(1) - if not file_path.is_file(): - logger.error(f"Not a file: {file_path}") - sys.exit(1) - - # Import the module - module = self._import_module(file_path) - - # Find the server object - server = await self._find_server_object(module, file_path) - - return server - - def _import_module(self, file_path: Path) -> Any: - """Import a Python module from a file path. - - Args: - file_path: Path to the Python file - - Returns: - The imported module - """ - # Add parent directory to Python path so imports can be resolved - file_dir = str(file_path.parent) - if file_dir not in sys.path: - sys.path.insert(0, file_dir) - - # Import the module - spec = importlib.util.spec_from_file_location("server_module", file_path) - if not spec or not spec.loader: - logger.error("Could not load module", extra={"file": str(file_path)}) - sys.exit(1) - - module = importlib.util.module_from_spec(spec) - sys.modules["server_module"] = module # Register in sys.modules - spec.loader.exec_module(module) - - return module - - async def _find_server_object(self, module: Any, file_path: Path) -> Any: - """Find the server object in the module. - - Args: - module: The imported Python module - file_path: Path to the file (for error messages) - - Returns: - The server object (or result of calling a factory function) - """ - # Avoid circular import by importing here - from mcp.server.fastmcp import FastMCP as FastMCP1x - - from fastmcp.server.server import FastMCP - - # If entrypoint is specified, use it - if self.entrypoint: - # Handle module:object syntax (though this is legacy) - if ":" in self.entrypoint: - module_name, object_name = self.entrypoint.split(":", 1) - try: - import importlib - - server_module = importlib.import_module(module_name) - obj = getattr(server_module, object_name, None) - except ImportError: - logger.error( - f"Could not import module '{module_name}'", - extra={"file": str(file_path)}, - ) - sys.exit(1) - else: - # Just object name - obj = getattr(module, self.entrypoint, None) - - if obj is None: - logger.error( - f"Server object '{self.entrypoint}' not found", - extra={"file": str(file_path)}, - ) - sys.exit(1) - - return await self._resolve_factory(obj, file_path, self.entrypoint) - - # No entrypoint specified, try common server names - for name in ["mcp", "server", "app"]: - if hasattr(module, name): - obj = getattr(module, name) - if isinstance(obj, FastMCP | FastMCP1x): - return await self._resolve_factory(obj, file_path, name) - - # No server found - logger.error( - f"No server object found in {file_path}. Please either:\n" - "1. Use a standard variable name (mcp, server, or app)\n" - "2. Specify the entrypoint name in fastmcp.json or use `file.py:object` syntax as your path.", - extra={"file": str(file_path)}, - ) - sys.exit(1) - - async def _resolve_factory(self, obj: Any, file_path: Path, name: str) -> Any: - """Resolve a server object or factory function to a server instance. - - Args: - obj: The object that might be a server or factory function - file_path: Path to the file for error messages - name: Name of the object for error messages - - Returns: - A server instance - """ - # Avoid circular import by importing here - from mcp.server.fastmcp import FastMCP as FastMCP1x - - from fastmcp.server.server import FastMCP - - # Check if it's a function or coroutine function - if inspect.isfunction(obj) or is_coroutine_function(obj): - logger.debug(f"Found factory function '{name}' in {file_path}") - - try: - if is_coroutine_function(obj): - # Async factory function - server = await obj() - else: - # Sync factory function - server = obj() - - # Validate the result is a FastMCP server - if not isinstance(server, FastMCP | FastMCP1x): - logger.error( - f"Factory function '{name}' must return a FastMCP server instance, " - f"got {type(server).__name__}", - extra={"file": str(file_path)}, - ) - sys.exit(1) - - logger.debug(f"Factory function '{name}' created server: {server.name}") - return server - - except Exception as e: - logger.error( - f"Failed to call factory function '{name}': {e}", - extra={"file": str(file_path)}, - ) - sys.exit(1) - - # Not a function, return as-is (should be a server instance) - return obj diff --git a/src/fastmcp/utilities/mime.py b/src/fastmcp/utilities/mime.py deleted file mode 100644 index 73b912e7c..000000000 --- a/src/fastmcp/utilities/mime.py +++ /dev/null @@ -1,27 +0,0 @@ -"""MIME type constants and helpers for MCP Apps UI resources. - -This module has no dependencies on the server or resource packages, -so it can be safely imported from anywhere. -""" - -UI_MIME_TYPE = "text/html;profile=mcp-app" - - -def resolve_ui_mime_type(uri: str, explicit_mime_type: str | None) -> str | None: - """Return the appropriate MIME type for a resource URI. - - For ``ui://`` scheme resources, defaults to ``UI_MIME_TYPE`` when no - explicit MIME type is provided. - - Args: - uri: The resource URI string - explicit_mime_type: The MIME type explicitly provided by the user - - Returns: - The resolved MIME type (explicit value, UI default, or None) - """ - if explicit_mime_type is not None: - return explicit_mime_type - if uri.lower().startswith("ui://"): - return UI_MIME_TYPE - return None diff --git a/src/fastmcp/utilities/openapi/README.md b/src/fastmcp/utilities/openapi/README.md deleted file mode 100644 index 2f2a5f45f..000000000 --- a/src/fastmcp/utilities/openapi/README.md +++ /dev/null @@ -1,211 +0,0 @@ -# OpenAPI Utilities - -This directory contains the OpenAPI integration utilities for FastMCP. - -## Architecture Overview - -The implementation follows a **stateless request building strategy** using `openapi-core` for high-performance, per-request HTTP request construction, eliminating startup latency while maintaining robust OpenAPI compliance. - -### Core Components - -1. **`director.py`** - `RequestDirector` for stateless HTTP request building -2. **`parser.py`** - OpenAPI spec parsing and route extraction with pre-calculated schemas -3. **`schemas.py`** - Schema processing with parameter mapping for collision handling -4. **`models.py`** - Enhanced data models with pre-calculated fields for performance -5. **`formatters.py`** - Response formatting and processing utilities - -### Key Architecture Principles - -#### 1. Stateless Request Building -- Uses `openapi-core` library for robust OpenAPI parameter serialization -- Builds HTTP requests on-demand with zero startup latency -- Offloads OpenAPI compliance to a well-tested library without code generation overhead - -#### 2. Pre-calculated Optimization -- **Schema Pre-calculation**: Combined schemas calculated once during parsing -- **Parameter Mapping**: Collision resolution mapping calculated upfront -- **Zero Runtime Overhead**: All complex processing done during initialization - -#### 3. Performance-First Design -- **No Code Generation**: Eliminates 100-200ms startup latency -- **Serverless Friendly**: Ideal for cold-start environments -- **Minimal Dependencies**: Uses lightweight `openapi-core` instead of full client generation - -## Data Flow - -### Initialization Process - -``` -OpenAPI Spec → Parser → HTTPRoute with Pre-calculated Fields → RequestDirector + SchemaPath -``` - -1. **Input**: Raw OpenAPI specification (dict) -2. **Parsing**: Extract operations to `HTTPRoute` models -3. **Pre-calculation**: Generate combined schemas and parameter maps during parsing -4. **Director Setup**: Create `RequestDirector` with `SchemaPath` for request building - -### Request Processing - -``` -MCP Tool Call → RequestDirector.build() → httpx.Request → HTTP Response → Structured Output -``` - -1. **Tool Invocation**: FastMCP receives tool call with parameters -2. **Request Building**: RequestDirector builds HTTP request using parameter map -3. **Parameter Handling**: openapi-core handles all OpenAPI serialization rules -4. **Response Processing**: Parse response into structured format with proper error handling - -## Key Features - -### 1. High-Performance Request Building -- Zero startup latency - no code generation required -- Stateless request building scales infinitely -- Uses proven `openapi-core` library for OpenAPI compliance -- Perfect for serverless and cold-start environments - -### 2. Comprehensive Parameter Support -- **Parameter Collisions**: Intelligent collision resolution with suffixing -- **DeepObject Style**: Full support for deepObject parameters with explode=true/false -- **Complex Schemas**: Handles nested objects, arrays, and all OpenAPI types -- **Pre-calculated Mapping**: Parameter location mapping done upfront for performance - -### 3. Enhanced Error Handling -- HTTP status code mapping to MCP errors -- Structured error responses with detailed information -- Graceful handling of network timeouts and connection errors -- Proper error context preservation - -### 4. Advanced Schema Processing -- **Pre-calculated Schemas**: Combined parameter and body schemas calculated once -- **Collision-aware**: Automatically handles parameter name collisions -- **Type Safety**: Full Pydantic model validation -- **Performance**: Zero runtime schema processing overhead - -## Component Integration - -### Server Components (`/server/openapi/`) - -1. **`OpenAPITool`** - Simplified tool implementation using RequestDirector -2. **`OpenAPIResource`** - Resource implementation with RequestDirector -3. **`OpenAPIResourceTemplate`** - Resource template with RequestDirector support -4. **`FastMCPOpenAPI`** - Main server class with stateless request building - -### RequestDirector Integration - -All components use the same RequestDirector approach: -- Consistent parameter handling across all component types -- Uniform error handling and response processing -- Simplified architecture without fallback complexity -- High performance for all operation types - -## Usage Examples - -### Basic Server Setup - -```python -import httpx -from fastmcp.server.openapi import FastMCPOpenAPI - -# OpenAPI spec (can be loaded from file/URL) -openapi_spec = {...} - -# Create HTTP client -async with httpx.AsyncClient() as client: - # Create server with stateless request building - server = FastMCPOpenAPI( - openapi_spec=openapi_spec, - client=client, - name="My API Server" - ) - - # Server automatically creates RequestDirector and pre-calculates schemas -``` - -### Direct RequestDirector Usage - -```python -from fastmcp.utilities.openapi.director import RequestDirector -from jsonschema_path import SchemaPath - -# Create RequestDirector manually -spec = SchemaPath.from_dict(openapi_spec) -director = RequestDirector(spec) - -# Build HTTP request -request = director.build(route, flat_arguments, base_url) - -# Execute with httpx -async with httpx.AsyncClient() as client: - response = await client.send(request) -``` - -## Testing Strategy - -Tests are located in `/tests/server/openapi/`: - -### Test Categories - -1. **Core Functionality** - - `test_server.py` - Server initialization and RequestDirector integration - -2. **OpenAPI Features** - - `test_parameter_collisions.py` - Parameter name collision handling - - `test_deepobject_style.py` - DeepObject parameter style support - - `test_openapi_features.py` - General OpenAPI feature compliance - -### Testing Philosophy - -- **Real Objects**: Use real HTTPRoute models and OpenAPI specifications -- **Minimal Mocking**: Only mock external HTTP endpoints -- **Performance Focus**: Test that initialization is fast and stateless -- **Behavioral Testing**: Verify OpenAPI compliance without implementation details - -## Future Enhancements - -### Planned Features - -1. **Response Streaming**: Handle streaming API responses -2. **Enhanced Authentication**: More auth provider integrations -3. **Advanced Metrics**: Detailed request/response monitoring -4. **Schema Validation**: Enhanced input/output validation -5. **Batch Operations**: Optimized multi-operation requests - -### Performance Improvements - -1. **Schema Caching**: More aggressive schema pre-calculation -2. **Memory Optimization**: Further reduce memory footprint -3. **Request Batching**: Smart batching for bulk operations -4. **Connection Optimization**: Enhanced connection pooling strategies - -## Troubleshooting - -### Common Issues - -1. **RequestDirector Initialization Fails** - - Check OpenAPI spec validity with `jsonschema-path` - - Verify spec format is correct JSON/YAML - - Ensure all required OpenAPI fields are present - -2. **Parameter Mapping Issues** - - Check parameter collision resolution in debug logs - - Verify parameter names match OpenAPI spec exactly - - Review pre-calculated parameter map in HTTPRoute - -3. **Request Building Errors** - - Check network connectivity to target API - - Verify base URL configuration - - Review parameter validation and type mismatches - -### Debugging - -- Enable debug logging: `logger.setLevel(logging.DEBUG)` -- Check RequestDirector initialization logs -- Review parameter mapping in HTTPRoute models -- Monitor request building and API response patterns - -## Dependencies - -- `openapi-core` - OpenAPI specification processing and validation -- `httpx` - HTTP client library -- `pydantic` - Data validation and serialization -- `urllib.parse` - URL building and manipulation \ No newline at end of file diff --git a/src/fastmcp/utilities/openapi/__init__.py b/src/fastmcp/utilities/openapi/__init__.py deleted file mode 100644 index eb25666d1..000000000 --- a/src/fastmcp/utilities/openapi/__init__.py +++ /dev/null @@ -1,61 +0,0 @@ -"""OpenAPI utilities for FastMCP - refactored for better maintainability.""" - -# Import from models -from .models import ( - HTTPRoute, - HttpMethod, - JsonSchema, - ParameterInfo, - ParameterLocation, - RequestBodyInfo, - ResponseInfo, -) - -# Import from parser -from .parser import parse_openapi_to_http_routes - -# Import from formatters -from .formatters import ( - format_array_parameter, - format_deep_object_parameter, - format_description_with_responses, - format_json_for_description, - generate_example_from_schema, -) - -# Import from schemas -from .schemas import ( - _combine_schemas, - extract_output_schema_from_responses, - clean_schema_for_display, - _make_optional_parameter_nullable, -) - -# Import from json_schema_converter -from .json_schema_converter import ( - convert_openapi_schema_to_json_schema, - convert_schema_definitions, -) - -# Export public symbols - maintaining backward compatibility -__all__ = [ - "HTTPRoute", - "HttpMethod", - "JsonSchema", - "ParameterInfo", - "ParameterLocation", - "RequestBodyInfo", - "ResponseInfo", - "_combine_schemas", - "_make_optional_parameter_nullable", - "clean_schema_for_display", - "convert_openapi_schema_to_json_schema", - "convert_schema_definitions", - "extract_output_schema_from_responses", - "format_array_parameter", - "format_deep_object_parameter", - "format_description_with_responses", - "format_json_for_description", - "generate_example_from_schema", - "parse_openapi_to_http_routes", -] diff --git a/src/fastmcp/utilities/openapi/director.py b/src/fastmcp/utilities/openapi/director.py deleted file mode 100644 index 8980e3d6a..000000000 --- a/src/fastmcp/utilities/openapi/director.py +++ /dev/null @@ -1,325 +0,0 @@ -"""Request director using openapi-core for stateless HTTP request building.""" - -import json as _json -from typing import Any, ClassVar -from urllib.parse import quote, urljoin - -import httpx -from jsonschema_path import SchemaPath - -from fastmcp.utilities.logging import get_logger - -from .models import HTTPRoute, ParameterInfo - -logger = get_logger(__name__) - - -def _query_scalar_to_str(value: Any) -> str: - """Convert a scalar to its query-string representation. - - Booleans are lowercased to match JSON/OpenAPI conventions (true/false) - rather than Python's str(True) → "True". - """ - if isinstance(value, bool): - return "true" if value else "false" - return str(value) - - -class RequestDirector: - """Builds httpx.Request objects from HTTPRoute and arguments using openapi-core.""" - - def __init__(self, spec: SchemaPath): - """Initialize with a parsed SchemaPath object.""" - self._spec = spec - - def build( - self, - route: HTTPRoute, - flat_args: dict[str, Any], - base_url: str = "http://localhost", - ) -> httpx.Request: - """ - Constructs a final httpx.Request object, handling all OpenAPI serialization. - - Args: - route: HTTPRoute containing OpenAPI operation details - flat_args: Flattened arguments from LLM (may include suffixed parameters) - base_url: Base URL for the request - - Returns: - httpx.Request: Properly formatted HTTP request - """ - logger.debug( - f"Building request for {route.method} {route.path} with args: {flat_args}" - ) - - # Step 1: Un-flatten arguments into path, query, body, etc. using parameter map - path_params, query_params, header_params, body = self._unflatten_arguments( - route, flat_args - ) - - logger.debug( - f"Unflattened - path: {path_params}, query: {query_params}, headers: {header_params}, body: {body}" - ) - - # Step 2: Serialize query parameters according to OpenAPI style/explode - query_params = self._serialize_query_params(route, query_params) - - # Step 3: Build base URL with path parameters - url = self._build_url(route.path, path_params, base_url) - - # Step 4: Prepare request data - method: str = route.method.upper() - params = query_params if query_params else None - headers = header_params if header_params else None - json_body: dict[str, Any] | list[Any] | None = None - content: str | bytes | None = None - - # Step 5: Determine the declared content type from the OpenAPI spec - declared_content_type: str | None = None - if route.request_body and route.request_body.content_schema: - declared_content_type = next(iter(route.request_body.content_schema)) - - # Step 6: Handle request body - if body is not None: - if isinstance(body, dict | list): - if ( - declared_content_type is not None - and declared_content_type != "application/json" - and "json" in declared_content_type - ): - # JSON-compatible types like application/json-patch+json - # or application/merge-patch+json need an explicit - # Content-Type header since httpx's json= always - # sets application/json. - content = _json.dumps(body, allow_nan=False).encode("utf-8") - headers = dict(headers) if headers else {} - headers["Content-Type"] = declared_content_type - else: - json_body = body - else: - content = body - - # Step 7: Create httpx.Request - return httpx.Request( - method=method, - url=url, - params=params, - headers=headers, - json=json_body, - content=content, - ) - - def _unflatten_arguments( - self, route: HTTPRoute, flat_args: dict[str, Any] - ) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], Any]: - """ - Maps flat arguments back to their OpenAPI locations using the parameter map. - - Args: - route: HTTPRoute with parameter_map containing location mappings - flat_args: Flat arguments from LLM call - - Returns: - Tuple of (path_params, query_params, header_params, body) - """ - path_params = {} - query_params = {} - header_params = {} - body_props = {} - - # Use parameter map to route arguments to correct locations - if hasattr(route, "parameter_map") and route.parameter_map: - for arg_name, value in flat_args.items(): - if value is None: - continue # Skip None values for optional parameters - - if arg_name not in route.parameter_map: - logger.warning( - f"Argument '{arg_name}' not found in parameter map for {route.operation_id}" - ) - continue - - mapping = route.parameter_map[arg_name] - location = mapping["location"] - openapi_name = mapping["openapi_name"] - - if location == "path": - path_params[openapi_name] = value - elif location == "query": - query_params[openapi_name] = value - elif location == "header": - header_params[openapi_name] = value - elif location == "body": - body_props[openapi_name] = value - else: - logger.warning( - f"Unknown parameter location '{location}' for {arg_name}" - ) - else: - # Fallback: try to map arguments based on parameter definitions - logger.debug("No parameter map available, using fallback mapping") - - # Create a mapping from parameter names to their locations - param_locations = {} - for param in route.parameters: - param_locations[param.name] = param.location - - # Map arguments to locations - for arg_name, value in flat_args.items(): - if value is None: - continue - - # Check if it's a suffixed parameter (e.g., id__path) - if "__" in arg_name: - base_name, location = arg_name.rsplit("__", 1) - if location in ["path", "query", "header"]: - if location == "path": - path_params[base_name] = value - elif location == "query": - query_params[base_name] = value - elif location == "header": - header_params[base_name] = value - continue - - # Check if it's a known parameter - if arg_name in param_locations: - location = param_locations[arg_name] - if location == "path": - path_params[arg_name] = value - elif location == "query": - query_params[arg_name] = value - elif location == "header": - header_params[arg_name] = value - else: - # Assume it's a body property - body_props[arg_name] = value - - # Handle body construction - body = None - if body_props: - # If we have body properties, construct the body object - if ( - route.request_body - and route.request_body.content_schema - and len(route.request_body.content_schema) > 0 - ): - content_type = next(iter(route.request_body.content_schema)) - body_schema = route.request_body.content_schema[content_type] - - if ( - isinstance(body_schema, dict) - and body_schema.get("type") == "object" - ): - body = body_props - elif len(body_props) == 1: - # If body schema is not an object and we have exactly one property, - # use the property value directly - body = next(iter(body_props.values())) - else: - # Multiple properties but schema is not object - wrap in object - body = body_props - else: - body = body_props - - return path_params, query_params, header_params, body - - # Delimiter per OpenAPI style when explode=false - _STYLE_DELIMITERS: ClassVar[dict[str, str]] = { - "form": ",", - "spaceDelimited": " ", - "pipeDelimited": "|", - } - - def _serialize_query_params( - self, - route: HTTPRoute, - query_params: dict[str, Any], - ) -> dict[str, Any]: - """ - Serialize query parameter values according to their OpenAPI style/explode settings. - - By default (style=form, explode=true), list values are passed through as-is - so httpx repeats the key (e.g. values=a&values=b). When explode=false, - list values are joined with the style-appropriate delimiter: - - form (default): comma (values=a,b) - - pipeDelimited: pipe (values=a|b) - - spaceDelimited: space (values=a%20b) - """ - if not query_params: - return query_params - - # Build a lookup from openapi_name -> ParameterInfo for query params - param_lookup: dict[str, ParameterInfo] = { - p.name: p for p in route.parameters if p.location == "query" - } - - serialized: dict[str, Any] = {} - for key, value in query_params.items(): - param_info = param_lookup.get(key) - if param_info is not None: - explode = param_info.explode if param_info.explode is not None else True - if isinstance(value, dict): - if not value: - continue - if explode: - # form,explode=true on objects: each property becomes - # a separate query parameter. - # e.g. {"R": 100, "G": 200} → R=100&G=200 - for k, v in value.items(): - serialized[_query_scalar_to_str(k)] = _query_scalar_to_str( - v - ) - else: - style = param_info.style or "form" - delimiter = self._STYLE_DELIMITERS.get(style, ",") - # form,explode=false on objects: key,value pairs - # e.g. {"R": 100, "G": 200} → "R,100,G,200" - parts: list[str] = [] - for k, v in value.items(): - parts.append(_query_scalar_to_str(k)) - parts.append(_query_scalar_to_str(v)) - serialized[key] = delimiter.join(parts) - continue - if not explode: - style = param_info.style or "form" - delimiter = self._STYLE_DELIMITERS.get(style, ",") - if isinstance(value, list): - if not value: - continue - serialized[key] = delimiter.join( - _query_scalar_to_str(v) for v in value - ) - continue - serialized[key] = value - return serialized - - def _build_url( - self, path_template: str, path_params: dict[str, Any], base_url: str - ) -> str: - """ - Build URL by substituting path parameters in the template. - - Args: - path_template: OpenAPI path template (e.g., "/users/{id}") - path_params: Path parameter values - base_url: Base URL to prepend - - Returns: - Complete URL with path parameters substituted - """ - # Substitute path parameters with URL-encoding to prevent - # path traversal and SSRF via crafted parameter values - url_path = path_template - for param_name, param_value in path_params.items(): - placeholder = f"{{{param_name}}}" - if placeholder in url_path: - safe_value = quote(str(param_value), safe="").replace(".", "%2E") - url_path = url_path.replace(placeholder, safe_value) - - # Combine with base URL - return urljoin(base_url.rstrip("/") + "/", url_path.lstrip("/")) - - -# Export public symbols -__all__ = ["RequestDirector"] diff --git a/src/fastmcp/utilities/openapi/formatters.py b/src/fastmcp/utilities/openapi/formatters.py deleted file mode 100644 index a0bd75bef..000000000 --- a/src/fastmcp/utilities/openapi/formatters.py +++ /dev/null @@ -1,355 +0,0 @@ -"""Parameter formatting functions for OpenAPI operations.""" - -import json -import logging -from typing import Any - -from .models import JsonSchema, ParameterInfo, RequestBodyInfo - -logger = logging.getLogger(__name__) - - -def format_array_parameter( - values: list, parameter_name: str, is_query_parameter: bool = False -) -> str | list: - """ - Format an array parameter according to OpenAPI specifications. - - Args: - values: List of values to format - parameter_name: Name of the parameter (for error messages) - is_query_parameter: If True, can return list for explode=True behavior - - Returns: - String (comma-separated) or list (for query params with explode=True) - """ - # For arrays of simple types (strings, numbers, etc.), join with commas - if all(isinstance(item, str | int | float | bool) for item in values): - return ",".join(str(v) for v in values) - - # For complex types, try to create a simpler representation - try: - # Try to create a simple string representation - formatted_parts = [] - for item in values: - if isinstance(item, dict): - # For objects, serialize key-value pairs - item_parts = [] - for k, v in item.items(): - item_parts.append(f"{k}:{v}") - formatted_parts.append(".".join(item_parts)) - else: - formatted_parts.append(str(item)) - - return ",".join(formatted_parts) - except Exception as e: - param_type = "query" if is_query_parameter else "path" - logger.warning( - f"Failed to format complex array {param_type} parameter '{parameter_name}': {e}" - ) - - if is_query_parameter: - # For query parameters, fallback to original list - return values - else: - # For path parameters, fallback to string representation without Python syntax - str_value = ( - str(values) - .replace("[", "") - .replace("]", "") - .replace("'", "") - .replace('"', "") - ) - return str_value - - -def format_deep_object_parameter( - param_value: dict, parameter_name: str -) -> dict[str, str]: - """ - Format a dictionary parameter for deep-object style serialization. - - According to OpenAPI 3.0 spec, deepObject style with explode=true serializes - object properties as separate query parameters with bracket notation. - - For example, `{"id": "123", "type": "user"}` becomes - `param[id]=123¶m[type]=user`. - - Args: - param_value: Dictionary value to format - parameter_name: Name of the parameter - - Returns: - Dictionary with bracketed parameter names as keys - """ - if not isinstance(param_value, dict): - logger.warning( - f"Deep-object style parameter '{parameter_name}' expected dict, got {type(param_value)}" - ) - return {} - - result = {} - for key, value in param_value.items(): - # Format as param[key]=value - bracketed_key = f"{parameter_name}[{key}]" - result[bracketed_key] = str(value) - - return result - - -def generate_example_from_schema(schema: JsonSchema | None) -> Any: - """ - Generate a simple example value from a JSON schema dictionary. - Very basic implementation focusing on types. - """ - if not schema or not isinstance(schema, dict): - return "unknown" # Or None? - - # Use default value if provided - if "default" in schema: - return schema["default"] - # Use first enum value if provided - if "enum" in schema and isinstance(schema["enum"], list) and schema["enum"]: - return schema["enum"][0] - # Use first example if provided - if ( - "examples" in schema - and isinstance(schema["examples"], list) - and schema["examples"] - ): - return schema["examples"][0] - if "example" in schema: - return schema["example"] - - schema_type = schema.get("type") - - if schema_type == "object": - result = {} - properties = schema.get("properties", {}) - if isinstance(properties, dict): - # Generate example for first few properties or required ones? Limit complexity. - required_props = set(schema.get("required", [])) - props_to_include = list(properties.keys())[ - :3 - ] # Limit to first 3 for brevity - for prop_name in props_to_include: - if prop_name in properties: - result[prop_name] = generate_example_from_schema( - properties[prop_name] - ) - # Ensure required props are present if possible - for req_prop in required_props: - if req_prop not in result and req_prop in properties: - result[req_prop] = generate_example_from_schema( - properties[req_prop] - ) - return result if result else {"key": "value"} # Basic object if no props - - elif schema_type == "array": - items_schema = schema.get("items") - if isinstance(items_schema, dict): - # Generate one example item - item_example = generate_example_from_schema(items_schema) - return [item_example] if item_example is not None else [] - return ["example_item"] # Fallback - - elif schema_type == "string": - format_type = schema.get("format") - if format_type == "date-time": - return "2024-01-01T12:00:00Z" - if format_type == "date": - return "2024-01-01" - if format_type == "email": - return "user@example.com" - if format_type == "uuid": - return "123e4567-e89b-12d3-a456-426614174000" - if format_type == "byte": - return "ZXhhbXBsZQ==" # "example" base64 - return "string" - - elif schema_type == "integer": - return 1 - elif schema_type == "number": - return 1.5 - elif schema_type == "boolean": - return True - elif schema_type == "null": - return None - - # Fallback if type is unknown or missing - return "unknown_type" - - -def format_json_for_description(data: Any, indent: int = 2) -> str: - """Formats Python data as a JSON string block for Markdown.""" - try: - json_str = json.dumps(data, indent=indent) - return f"```json\n{json_str}\n```" - except TypeError: - return f"```\nCould not serialize to JSON: {data}\n```" - - -def format_description_with_responses( - base_description: str, - responses: dict[ - str, Any - ], # Changed from specific ResponseInfo type to avoid circular imports - parameters: list[ParameterInfo] | None = None, # Add parameters parameter - request_body: RequestBodyInfo | None = None, # Add request_body parameter -) -> str: - """ - Formats the base description string with response, parameter, and request body information. - - Args: - base_description (str): The initial description to be formatted. - responses (dict[str, Any]): A dictionary of response information, keyed by status code. - parameters (list[ParameterInfo] | None, optional): A list of parameter information, - including path and query parameters. Each parameter includes details such as name, - location, whether it is required, and a description. - request_body (RequestBodyInfo | None, optional): Information about the request body, - including its description, whether it is required, and its content schema. - - Returns: - str: The formatted description string with additional details about responses, parameters, - and the request body. - """ - desc_parts = [base_description] - - # Add parameter information - if parameters: - # Process path parameters - path_params = [p for p in parameters if p.location == "path"] - if path_params: - param_section = "\n\n**Path Parameters:**" - desc_parts.append(param_section) - for param in path_params: - required_marker = " (Required)" if param.required else "" - param_desc = f"\n- **{param.name}**{required_marker}: {param.description or 'No description.'}" - desc_parts.append(param_desc) - - # Process query parameters - query_params = [p for p in parameters if p.location == "query"] - if query_params: - param_section = "\n\n**Query Parameters:**" - desc_parts.append(param_section) - for param in query_params: - required_marker = " (Required)" if param.required else "" - param_desc = f"\n- **{param.name}**{required_marker}: {param.description or 'No description.'}" - desc_parts.append(param_desc) - - # Add request body information if present - if request_body and request_body.description: - req_body_section = "\n\n**Request Body:**" - desc_parts.append(req_body_section) - required_marker = " (Required)" if request_body.required else "" - desc_parts.append(f"\n{request_body.description}{required_marker}") - - # Add request body property descriptions if available - if request_body.content_schema: - media_type = ( - "application/json" - if "application/json" in request_body.content_schema - else next(iter(request_body.content_schema), None) - ) - if media_type: - schema = request_body.content_schema.get(media_type, {}) - if isinstance(schema, dict) and "properties" in schema: - desc_parts.append("\n\n**Request Properties:**") - for prop_name, prop_schema in schema["properties"].items(): - if ( - isinstance(prop_schema, dict) - and "description" in prop_schema - ): - required = prop_name in schema.get("required", []) - req_mark = " (Required)" if required else "" - desc_parts.append( - f"\n- **{prop_name}**{req_mark}: {prop_schema['description']}" - ) - - # Add response information - if responses: - response_section = "\n\n**Responses:**" - added_response_section = False - - # Determine success codes (common ones) - success_codes = {"200", "201", "202", "204"} # As strings - success_status = next((s for s in success_codes if s in responses), None) - - # Process all responses - responses_to_process = responses.items() - - for status_code, resp_info in sorted(responses_to_process): - if not added_response_section: - desc_parts.append(response_section) - added_response_section = True - - status_marker = " (Success)" if status_code == success_status else "" - desc_parts.append( - f"\n- **{status_code}**{status_marker}: {resp_info.description or 'No description.'}" - ) - - # Process content schemas for this response - if resp_info.content_schema: - # Prioritize json, then take first available - media_type = ( - "application/json" - if "application/json" in resp_info.content_schema - else next(iter(resp_info.content_schema), None) - ) - - if media_type: - schema = resp_info.content_schema.get(media_type) - desc_parts.append(f" - Content-Type: `{media_type}`") - - # Add response property descriptions - if isinstance(schema, dict): - # Handle array responses - if schema.get("type") == "array" and "items" in schema: - items_schema = schema["items"] - if ( - isinstance(items_schema, dict) - and "properties" in items_schema - ): - desc_parts.append("\n - **Response Item Properties:**") - for prop_name, prop_schema in items_schema[ - "properties" - ].items(): - if ( - isinstance(prop_schema, dict) - and "description" in prop_schema - ): - desc_parts.append( - f"\n - **{prop_name}**: {prop_schema['description']}" - ) - # Handle object responses - elif "properties" in schema: - desc_parts.append("\n - **Response Properties:**") - for prop_name, prop_schema in schema["properties"].items(): - if ( - isinstance(prop_schema, dict) - and "description" in prop_schema - ): - desc_parts.append( - f"\n - **{prop_name}**: {prop_schema['description']}" - ) - - # Generate Example - if schema: - example = generate_example_from_schema(schema) - if example != "unknown_type" and example is not None: - desc_parts.append("\n - **Example:**") - desc_parts.append( - format_json_for_description(example, indent=2) - ) - - return "\n".join(desc_parts) - - -# Export public symbols -__all__ = [ - "format_array_parameter", - "format_deep_object_parameter", - "format_description_with_responses", - "format_json_for_description", - "generate_example_from_schema", -] diff --git a/src/fastmcp/utilities/openapi/json_schema_converter.py b/src/fastmcp/utilities/openapi/json_schema_converter.py deleted file mode 100644 index c92b2f394..000000000 --- a/src/fastmcp/utilities/openapi/json_schema_converter.py +++ /dev/null @@ -1,344 +0,0 @@ -""" -Clean OpenAPI 3.0 to JSON Schema converter for the experimental parser. - -This module provides a systematic approach to converting OpenAPI 3.0 schemas -to JSON Schema, inspired by py-openapi-schema-to-json-schema but optimized -for our specific use case. -""" - -from typing import Any - -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - -# OpenAPI-specific fields that should be removed from JSON Schema -OPENAPI_SPECIFIC_FIELDS = { - "nullable", # Handled by converting to type arrays - "discriminator", # OpenAPI-specific - "readOnly", # OpenAPI-specific metadata - "writeOnly", # OpenAPI-specific metadata - "xml", # OpenAPI-specific metadata - "externalDocs", # OpenAPI-specific metadata - "deprecated", # Can be kept but not part of JSON Schema core -} - -# Fields that should be recursively processed -RECURSIVE_FIELDS = { - "properties": dict, - "items": dict, - "additionalProperties": dict, - "allOf": list, - "anyOf": list, - "oneOf": list, - "not": dict, -} - - -def convert_openapi_schema_to_json_schema( - schema: dict[str, Any], - openapi_version: str | None = None, - remove_read_only: bool = False, - remove_write_only: bool = False, - convert_one_of_to_any_of: bool = True, -) -> dict[str, Any]: - """ - Convert an OpenAPI schema to JSON Schema format. - - This is a clean, systematic approach that: - 1. Removes OpenAPI-specific fields - 2. Converts nullable fields to type arrays (for OpenAPI 3.0 only) - 3. Converts oneOf to anyOf for overlapping union handling - 4. Recursively processes nested schemas - 5. Optionally removes readOnly/writeOnly properties - - Args: - schema: OpenAPI schema dictionary - openapi_version: OpenAPI version for optimization - remove_read_only: Whether to remove readOnly properties - remove_write_only: Whether to remove writeOnly properties - convert_one_of_to_any_of: Whether to convert oneOf to anyOf - - Returns: - JSON Schema-compatible dictionary - """ - if not isinstance(schema, dict): - return schema - - # Early exit optimization - check if conversion is needed - needs_conversion = ( - any(field in schema for field in OPENAPI_SPECIFIC_FIELDS) - or (remove_read_only and _has_read_only_properties(schema)) - or (remove_write_only and _has_write_only_properties(schema)) - or (convert_one_of_to_any_of and "oneOf" in schema) - or _needs_recursive_processing( - schema, - openapi_version, - remove_read_only, - remove_write_only, - convert_one_of_to_any_of, - ) - ) - - if not needs_conversion: - return schema - - # Work on a copy to avoid mutation - result = schema.copy() - - # Step 1: Handle nullable field conversion (OpenAPI 3.0 only) - if openapi_version and openapi_version.startswith("3.0"): - result = _convert_nullable_field(result) - - # Step 2: Convert oneOf to anyOf if requested - if convert_one_of_to_any_of and "oneOf" in result: - result["anyOf"] = result.pop("oneOf") - - # Step 3: Remove OpenAPI-specific fields - for field in OPENAPI_SPECIFIC_FIELDS: - result.pop(field, None) - - # Step 4: Handle readOnly/writeOnly property removal - if remove_read_only or remove_write_only: - result = _filter_properties_by_access( - result, remove_read_only, remove_write_only - ) - - # Step 5: Recursively process nested schemas - for field_name, field_type in RECURSIVE_FIELDS.items(): - if field_name in result: - if field_type is dict and isinstance(result[field_name], dict): - if field_name == "properties": - # Handle properties specially - each property is a schema - result[field_name] = { - prop_name: convert_openapi_schema_to_json_schema( - prop_schema, - openapi_version, - remove_read_only, - remove_write_only, - convert_one_of_to_any_of, - ) - if isinstance(prop_schema, dict) - else prop_schema - for prop_name, prop_schema in result[field_name].items() - } - else: - result[field_name] = convert_openapi_schema_to_json_schema( - result[field_name], - openapi_version, - remove_read_only, - remove_write_only, - convert_one_of_to_any_of, - ) - elif field_type is list and isinstance(result[field_name], list): - result[field_name] = [ - convert_openapi_schema_to_json_schema( - item, - openapi_version, - remove_read_only, - remove_write_only, - convert_one_of_to_any_of, - ) - if isinstance(item, dict) - else item - for item in result[field_name] - ] - - return result - - -def _convert_nullable_field(schema: dict[str, Any]) -> dict[str, Any]: - """Convert OpenAPI nullable field to JSON Schema type array.""" - if "nullable" not in schema: - return schema - - result = schema.copy() - nullable_value = result.pop("nullable") - - # Only convert if nullable is True and we have a type structure - if not nullable_value: - return result - - if "type" in result: - current_type = result["type"] - if isinstance(current_type, str): - result["type"] = [current_type, "null"] - elif isinstance(current_type, list) and "null" not in current_type: - result["type"] = [*current_type, "null"] - elif "oneOf" in result: - # Convert oneOf to anyOf with null - result["anyOf"] = [*result.pop("oneOf"), {"type": "null"}] - elif "anyOf" in result: - # Add null to anyOf if not present - if not any(item.get("type") == "null" for item in result["anyOf"]): - result["anyOf"].append({"type": "null"}) - elif "allOf" in result: - # Wrap allOf in anyOf with null option - result["anyOf"] = [{"allOf": result.pop("allOf")}, {"type": "null"}] - - # Handle enum fields - add null to enum values if present - if "enum" in result and None not in result["enum"]: - result["enum"] = result["enum"] + [None] - - return result - - -def _has_read_only_properties(schema: dict[str, Any]) -> bool: - """Quick check if schema has any readOnly properties.""" - if "properties" not in schema: - return False - return any( - isinstance(prop, dict) and prop.get("readOnly") - for prop in schema["properties"].values() - ) - - -def _has_write_only_properties(schema: dict[str, Any]) -> bool: - """Quick check if schema has any writeOnly properties.""" - if "properties" not in schema: - return False - return any( - isinstance(prop, dict) and prop.get("writeOnly") - for prop in schema["properties"].values() - ) - - -def _needs_recursive_processing( - schema: dict[str, Any], - openapi_version: str | None, - remove_read_only: bool, - remove_write_only: bool, - convert_one_of_to_any_of: bool, -) -> bool: - """Check if the schema needs recursive processing (smarter than just checking for recursive fields).""" - for field_name, field_type in RECURSIVE_FIELDS.items(): - if field_name in schema: - if field_type is dict and isinstance(schema[field_name], dict): - if field_name == "properties": - # Check if any property needs conversion - for prop_schema in schema[field_name].values(): - if isinstance(prop_schema, dict): - nested_needs_conversion = ( - any( - field in prop_schema - for field in OPENAPI_SPECIFIC_FIELDS - ) - or (remove_read_only and prop_schema.get("readOnly")) - or (remove_write_only and prop_schema.get("writeOnly")) - or (convert_one_of_to_any_of and "oneOf" in prop_schema) - or _needs_recursive_processing( - prop_schema, - openapi_version, - remove_read_only, - remove_write_only, - convert_one_of_to_any_of, - ) - ) - if nested_needs_conversion: - return True - else: - # Check if nested schema needs conversion - nested_needs_conversion = ( - any( - field in schema[field_name] - for field in OPENAPI_SPECIFIC_FIELDS - ) - or ( - remove_read_only - and _has_read_only_properties(schema[field_name]) - ) - or ( - remove_write_only - and _has_write_only_properties(schema[field_name]) - ) - or (convert_one_of_to_any_of and "oneOf" in schema[field_name]) - or _needs_recursive_processing( - schema[field_name], - openapi_version, - remove_read_only, - remove_write_only, - convert_one_of_to_any_of, - ) - ) - if nested_needs_conversion: - return True - elif field_type is list and isinstance(schema[field_name], list): - # Check if any list item needs conversion - for item in schema[field_name]: - if isinstance(item, dict): - nested_needs_conversion = ( - any(field in item for field in OPENAPI_SPECIFIC_FIELDS) - or (remove_read_only and _has_read_only_properties(item)) - or (remove_write_only and _has_write_only_properties(item)) - or (convert_one_of_to_any_of and "oneOf" in item) - or _needs_recursive_processing( - item, - openapi_version, - remove_read_only, - remove_write_only, - convert_one_of_to_any_of, - ) - ) - if nested_needs_conversion: - return True - return False - - -def _filter_properties_by_access( - schema: dict[str, Any], remove_read_only: bool, remove_write_only: bool -) -> dict[str, Any]: - """Remove readOnly and/or writeOnly properties from schema.""" - if "properties" not in schema: - return schema - - result = schema.copy() - filtered_properties = {} - - for prop_name, prop_schema in result["properties"].items(): - if not isinstance(prop_schema, dict): - filtered_properties[prop_name] = prop_schema - continue - - should_remove = (remove_read_only and prop_schema.get("readOnly")) or ( - remove_write_only and prop_schema.get("writeOnly") - ) - - if not should_remove: - filtered_properties[prop_name] = prop_schema - - result["properties"] = filtered_properties - - # Clean up required array if properties were removed - if "required" in result and filtered_properties: - result["required"] = [ - prop for prop in result["required"] if prop in filtered_properties - ] - if not result["required"]: - result.pop("required") - - return result - - -def convert_schema_definitions( - schema_definitions: dict[str, Any] | None, - openapi_version: str | None = None, - **kwargs, -) -> dict[str, Any]: - """ - Convert a dictionary of OpenAPI schema definitions to JSON Schema. - - Args: - schema_definitions: Dictionary of schema definitions - openapi_version: OpenAPI version for optimization - **kwargs: Additional arguments passed to convert_openapi_schema_to_json_schema - - Returns: - Dictionary of converted schema definitions - """ - if not schema_definitions: - return {} - - return { - name: convert_openapi_schema_to_json_schema(schema, openapi_version, **kwargs) - for name, schema in schema_definitions.items() - } diff --git a/src/fastmcp/utilities/openapi/models.py b/src/fastmcp/utilities/openapi/models.py deleted file mode 100644 index 03d2eb68d..000000000 --- a/src/fastmcp/utilities/openapi/models.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Intermediate Representation (IR) models for OpenAPI operations.""" - -from typing import Any, Literal - -from pydantic import Field - -from fastmcp.utilities.types import FastMCPBaseModel - -# Type definitions -HttpMethod = Literal[ - "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD", "TRACE" -] -ParameterLocation = Literal["path", "query", "header", "cookie"] -JsonSchema = dict[str, Any] - - -class ParameterInfo(FastMCPBaseModel): - """Represents a single parameter for an HTTP operation in our IR.""" - - name: str - location: ParameterLocation # Mapped from 'in' field of openapi-pydantic Parameter - required: bool = False - schema_: JsonSchema = Field(..., alias="schema") # Target name in IR - description: str | None = None - explode: bool | None = None # OpenAPI explode property for array parameters - style: str | None = None # OpenAPI style property for parameter serialization - - -class RequestBodyInfo(FastMCPBaseModel): - """Represents the request body for an HTTP operation in our IR.""" - - required: bool = False - content_schema: dict[str, JsonSchema] = Field( - default_factory=dict - ) # Key: media type - description: str | None = None - - -class ResponseInfo(FastMCPBaseModel): - """Represents response information in our IR.""" - - description: str | None = None - # Store schema per media type, key is media type - content_schema: dict[str, JsonSchema] = Field(default_factory=dict) - - -class HTTPRoute(FastMCPBaseModel): - """Intermediate Representation for a single OpenAPI operation.""" - - path: str - method: HttpMethod - operation_id: str | None = None - summary: str | None = None - description: str | None = None - tags: list[str] = Field(default_factory=list) - parameters: list[ParameterInfo] = Field(default_factory=list) - request_body: RequestBodyInfo | None = None - responses: dict[str, ResponseInfo] = Field( - default_factory=dict - ) # Key: status code str - request_schemas: dict[str, JsonSchema] = Field( - default_factory=dict - ) # Store schemas needed for input (parameters/request body) - response_schemas: dict[str, JsonSchema] = Field( - default_factory=dict - ) # Store schemas needed for output (responses) - extensions: dict[str, Any] = Field(default_factory=dict) - openapi_version: str | None = None - - # Pre-calculated fields for performance - flat_param_schema: JsonSchema = Field( - default_factory=dict - ) # Combined schema for MCP tools - parameter_map: dict[str, dict[str, str]] = Field( - default_factory=dict - ) # Maps flat args to locations - - -# Export public symbols -__all__ = [ - "HTTPRoute", - "HttpMethod", - "JsonSchema", - "ParameterInfo", - "ParameterLocation", - "RequestBodyInfo", - "ResponseInfo", -] diff --git a/src/fastmcp/utilities/openapi/parser.py b/src/fastmcp/utilities/openapi/parser.py deleted file mode 100644 index f83a45249..000000000 --- a/src/fastmcp/utilities/openapi/parser.py +++ /dev/null @@ -1,825 +0,0 @@ -"""OpenAPI parsing logic for converting OpenAPI specs to HTTPRoute objects.""" - -from typing import Any, Generic, TypeVar, cast - -from openapi_pydantic import ( - OpenAPI, - Operation, - Parameter, - PathItem, - Reference, - RequestBody, - Response, - Schema, -) - -# Import OpenAPI 3.0 models as well -from openapi_pydantic.v3.v3_0 import OpenAPI as OpenAPI_30 -from openapi_pydantic.v3.v3_0 import Operation as Operation_30 -from openapi_pydantic.v3.v3_0 import Parameter as Parameter_30 -from openapi_pydantic.v3.v3_0 import PathItem as PathItem_30 -from openapi_pydantic.v3.v3_0 import Reference as Reference_30 -from openapi_pydantic.v3.v3_0 import RequestBody as RequestBody_30 -from openapi_pydantic.v3.v3_0 import Response as Response_30 -from openapi_pydantic.v3.v3_0 import Schema as Schema_30 -from pydantic import BaseModel, ValidationError - -from fastmcp.utilities.logging import get_logger - -from .models import ( - HTTPRoute, - JsonSchema, - ParameterInfo, - ParameterLocation, - RequestBodyInfo, - ResponseInfo, -) -from .schemas import ( - _combine_schemas_and_map_params, - _replace_ref_with_defs, -) - -logger = get_logger(__name__) - -# Type variables for generic parser -TOpenAPI = TypeVar("TOpenAPI", OpenAPI, OpenAPI_30) -TSchema = TypeVar("TSchema", Schema, Schema_30) -TReference = TypeVar("TReference", Reference, Reference_30) -TParameter = TypeVar("TParameter", Parameter, Parameter_30) -TRequestBody = TypeVar("TRequestBody", RequestBody, RequestBody_30) -TResponse = TypeVar("TResponse", Response, Response_30) -TOperation = TypeVar("TOperation", Operation, Operation_30) -TPathItem = TypeVar("TPathItem", PathItem, PathItem_30) - - -def parse_openapi_to_http_routes(openapi_dict: dict[str, Any]) -> list[HTTPRoute]: - """ - Parses an OpenAPI schema dictionary into a list of HTTPRoute objects - using the openapi-pydantic library. - - Supports both OpenAPI 3.0.x and 3.1.x versions. - """ - # Check OpenAPI version to use appropriate model - openapi_version = openapi_dict.get("openapi", "") - - try: - if openapi_version.startswith("3.0"): - # Use OpenAPI 3.0 models - openapi_30 = OpenAPI_30.model_validate(openapi_dict) - logger.debug( - f"Successfully parsed OpenAPI 3.0 schema version: {openapi_30.openapi}" - ) - parser = OpenAPIParser( - openapi_30, - Reference_30, - Schema_30, - Parameter_30, - RequestBody_30, - Response_30, - Operation_30, - PathItem_30, - openapi_version, - ) - return parser.parse() - else: - # Default to OpenAPI 3.1 models - openapi_31 = OpenAPI.model_validate(openapi_dict) - logger.debug( - f"Successfully parsed OpenAPI 3.1 schema version: {openapi_31.openapi}" - ) - parser = OpenAPIParser( - openapi_31, - Reference, - Schema, - Parameter, - RequestBody, - Response, - Operation, - PathItem, - openapi_version, - ) - return parser.parse() - except ValidationError as e: - logger.error(f"OpenAPI schema validation failed: {e}") - error_details = e.errors() - logger.error(f"Validation errors: {error_details}") - raise ValueError(f"Invalid OpenAPI schema: {error_details}") from e - - -class OpenAPIParser( - Generic[ - TOpenAPI, - TReference, - TSchema, - TParameter, - TRequestBody, - TResponse, - TOperation, - TPathItem, - ] -): - """Unified parser for OpenAPI schemas with generic type parameters to handle both 3.0 and 3.1.""" - - def __init__( - self, - openapi: TOpenAPI, - reference_cls: type[TReference], - schema_cls: type[TSchema], - parameter_cls: type[TParameter], - request_body_cls: type[TRequestBody], - response_cls: type[TResponse], - operation_cls: type[TOperation], - path_item_cls: type[TPathItem], - openapi_version: str, - ): - """Initialize the parser with the OpenAPI schema and type classes.""" - self.openapi = openapi - self.reference_cls = reference_cls - self.schema_cls = schema_cls - self.parameter_cls = parameter_cls - self.request_body_cls = request_body_cls - self.response_cls = response_cls - self.operation_cls = operation_cls - self.path_item_cls = path_item_cls - self.openapi_version = openapi_version - - def _convert_to_parameter_location(self, param_in: str) -> ParameterLocation: - """Convert string parameter location to our ParameterLocation type.""" - if param_in in ["path", "query", "header", "cookie"]: - return cast(ParameterLocation, param_in) - logger.warning(f"Unknown parameter location: {param_in}, defaulting to 'query'") - return cast(ParameterLocation, "query") - - def _resolve_ref(self, item: Any) -> Any: - """Resolves a reference to its target definition.""" - if isinstance(item, self.reference_cls): - ref_str = item.ref - # Ensure ref_str is a string before calling startswith() - if not isinstance(ref_str, str): - return item - try: - if not ref_str.startswith("#/"): - raise ValueError( - f"External or non-local reference not supported: {ref_str}" - ) - - parts = ref_str.strip("#/").split("/") - target = self.openapi - - for part in parts: - if part.isdigit() and isinstance(target, list): - target = target[int(part)] - elif isinstance(target, BaseModel): - # Check class fields first, then model_extra - if part in target.__class__.model_fields: - target = getattr(target, part, None) - elif target.model_extra and part in target.model_extra: - target = target.model_extra[part] - else: - # Special handling for components - if part == "components" and hasattr(target, "components"): - target = target.components - elif hasattr(target, part): # Fallback check - target = getattr(target, part, None) - else: - target = None # Part not found - elif isinstance(target, dict): - target = target.get(part) - else: - raise ValueError( - f"Cannot traverse part '{part}' in reference '{ref_str}'" - ) - - if target is None: - raise ValueError( - f"Reference part '{part}' not found in path '{ref_str}'" - ) - - # Handle nested references - if isinstance(target, self.reference_cls): - return self._resolve_ref(target) - - return target - except (AttributeError, KeyError, IndexError, TypeError, ValueError) as e: - raise ValueError(f"Failed to resolve reference '{ref_str}': {e}") from e - - return item - - def _extract_schema_as_dict(self, schema_obj: Any) -> JsonSchema: - """Resolves a schema and returns it as a dictionary.""" - try: - resolved_schema = self._resolve_ref(schema_obj) - - if isinstance(resolved_schema, self.schema_cls): - # Convert schema to dictionary - result = resolved_schema.model_dump( - mode="json", by_alias=True, exclude_none=True - ) - elif isinstance(resolved_schema, dict): - result = resolved_schema - else: - logger.warning( - f"Expected Schema after resolving, got {type(resolved_schema)}. Returning empty dict." - ) - result = {} - - # Convert refs from OpenAPI format to JSON Schema format using recursive approach - - result = _replace_ref_with_defs(result) - return result - except ValueError as e: - # Re-raise ValueError for external reference errors and other validation issues - if "External or non-local reference not supported" in str(e): - raise - logger.error(f"Failed to extract schema as dict: {e}", exc_info=False) - return {} - except Exception as e: - logger.error(f"Failed to extract schema as dict: {e}", exc_info=False) - return {} - - def _extract_parameters( - self, - operation_params: list[Any] | None = None, - path_item_params: list[Any] | None = None, - ) -> list[ParameterInfo]: - """Extract and resolve parameters from operation and path item.""" - extracted_params: list[ParameterInfo] = [] - seen_params: dict[ - tuple[str, str], bool - ] = {} # Use tuple of (name, location) as key - all_params = (operation_params or []) + (path_item_params or []) - - for param_or_ref in all_params: - try: - parameter = self._resolve_ref(param_or_ref) - - if not isinstance(parameter, self.parameter_cls): - logger.warning( - f"Expected Parameter after resolving, got {type(parameter)}. Skipping." - ) - continue - - # Extract parameter info - handle both 3.0 and 3.1 parameter models - param_in = parameter.param_in # Both use param_in - # Handle enum or string parameter locations - from enum import Enum - - param_in_str = ( - param_in.value if isinstance(param_in, Enum) else param_in - ) - param_location = self._convert_to_parameter_location(param_in_str) - param_schema_obj = parameter.param_schema # Both use param_schema - - # Skip duplicate parameters (same name and location) - param_key = (parameter.name, param_in_str) - if param_key in seen_params: - continue - seen_params[param_key] = True - - # Extract schema - param_schema_dict = {} - if param_schema_obj: - # Process schema object - param_schema_dict = self._extract_schema_as_dict(param_schema_obj) - - # Handle default value - resolved_schema = self._resolve_ref(param_schema_obj) - if ( - not isinstance(resolved_schema, self.reference_cls) - and hasattr(resolved_schema, "default") - and resolved_schema.default is not None - ): - param_schema_dict["default"] = resolved_schema.default - - elif hasattr(parameter, "content") and parameter.content: - # Handle content-based parameters - first_media_type = next(iter(parameter.content.values()), None) - if ( - first_media_type - and hasattr(first_media_type, "media_type_schema") - and first_media_type.media_type_schema - ): - media_schema = first_media_type.media_type_schema - param_schema_dict = self._extract_schema_as_dict(media_schema) - - # Handle default value in content schema - resolved_media_schema = self._resolve_ref(media_schema) - if ( - not isinstance(resolved_media_schema, self.reference_cls) - and hasattr(resolved_media_schema, "default") - and resolved_media_schema.default is not None - ): - param_schema_dict["default"] = resolved_media_schema.default - - # Extract explode and style properties if present - explode = getattr(parameter, "explode", None) - style = getattr(parameter, "style", None) - - # Create parameter info object - param_info = ParameterInfo( - name=parameter.name, - location=param_location, - required=parameter.required, - schema=param_schema_dict, - description=parameter.description, - explode=explode, - style=style, - ) - extracted_params.append(param_info) - except Exception as e: - param_name = getattr( - param_or_ref, "name", getattr(param_or_ref, "ref", "unknown") - ) - logger.error( - f"Failed to extract parameter '{param_name}': {e}", exc_info=False - ) - - return extracted_params - - def _extract_request_body(self, request_body_or_ref: Any) -> RequestBodyInfo | None: - """Extract and resolve request body information.""" - if not request_body_or_ref: - return None - - try: - request_body = self._resolve_ref(request_body_or_ref) - - if not isinstance(request_body, self.request_body_cls): - logger.warning( - f"Expected RequestBody after resolving, got {type(request_body)}. Returning None." - ) - return None - - # Create request body info - request_body_info = RequestBodyInfo( - required=request_body.required, - description=request_body.description, - ) - - # Extract content schemas - if hasattr(request_body, "content") and request_body.content: - for media_type_str, media_type_obj in request_body.content.items(): - if ( - media_type_obj - and hasattr(media_type_obj, "media_type_schema") - and media_type_obj.media_type_schema - ): - try: - schema_dict = self._extract_schema_as_dict( - media_type_obj.media_type_schema - ) - request_body_info.content_schema[media_type_str] = ( - schema_dict - ) - except ValueError as e: - # Re-raise ValueError for external reference errors - if "External or non-local reference not supported" in str( - e - ): - raise - logger.error( - f"Failed to extract schema for media type '{media_type_str}': {e}" - ) - except Exception as e: - logger.error( - f"Failed to extract schema for media type '{media_type_str}': {e}" - ) - - return request_body_info - except ValueError as e: - # Re-raise ValueError for external reference errors - if "External or non-local reference not supported" in str(e): - raise - ref_name = getattr(request_body_or_ref, "ref", "unknown") - logger.error( - f"Failed to extract request body '{ref_name}': {e}", exc_info=False - ) - return None - except Exception as e: - ref_name = getattr(request_body_or_ref, "ref", "unknown") - logger.error( - f"Failed to extract request body '{ref_name}': {e}", exc_info=False - ) - return None - - def _is_success_status_code(self, status_code: str) -> bool: - """Check if a status code represents a successful response (2xx).""" - try: - code_int = int(status_code) - return 200 <= code_int < 300 - except (ValueError, TypeError): - # Handle special cases like 'default' or other non-numeric codes - return status_code.lower() in ["default", "2xx"] - - def _get_primary_success_response( - self, operation_responses: dict[str, Any] - ) -> tuple[str, Any] | None: - """Get the primary success response for an MCP tool. We only need one success response.""" - if not operation_responses: - return None - - # Priority order: 200, 201, 202, 204, 207, then any other 2xx - priority_codes = ["200", "201", "202", "204", "207"] - - # First check priority codes - for code in priority_codes: - if code in operation_responses: - return (code, operation_responses[code]) - - # Then check any other 2xx codes - for status_code, resp_or_ref in operation_responses.items(): - if self._is_success_status_code(status_code): - return (status_code, resp_or_ref) - - # If no success codes found, return None (tool will have no output schema) - return None - - def _extract_responses( - self, operation_responses: dict[str, Any] | None - ) -> dict[str, ResponseInfo]: - """Extract and resolve response information. Only includes the primary success response for MCP tools.""" - extracted_responses: dict[str, ResponseInfo] = {} - - if not operation_responses: - return extracted_responses - - # For MCP tools, we only need the primary success response - primary_response = self._get_primary_success_response(operation_responses) - if not primary_response: - logger.debug("No success responses found, tool will have no output schema") - return extracted_responses - - status_code, resp_or_ref = primary_response - logger.debug(f"Using primary success response: {status_code}") - - try: - response = self._resolve_ref(resp_or_ref) - - if not isinstance(response, self.response_cls): - logger.warning( - f"Expected Response after resolving for status code {status_code}, " - f"got {type(response)}. Returning empty responses." - ) - return extracted_responses - - # Create response info - resp_info = ResponseInfo(description=response.description) - - # Extract content schemas - if hasattr(response, "content") and response.content: - for media_type_str, media_type_obj in response.content.items(): - if ( - media_type_obj - and hasattr(media_type_obj, "media_type_schema") - and media_type_obj.media_type_schema - ): - try: - # Track if this is a top-level $ref before resolution - top_level_schema_name = None - media_schema = media_type_obj.media_type_schema - if isinstance(media_schema, self.reference_cls): - ref_str = media_schema.ref - if isinstance(ref_str, str) and ref_str.startswith( - "#/components/schemas/" - ): - top_level_schema_name = ref_str.split("/")[-1] - - schema_dict = self._extract_schema_as_dict(media_schema) - # Add marker for top-level schema if it was a ref - if top_level_schema_name: - schema_dict["x-fastmcp-top-level-schema"] = ( - top_level_schema_name - ) - resp_info.content_schema[media_type_str] = schema_dict - except ValueError as e: - # Re-raise ValueError for external reference errors - if "External or non-local reference not supported" in str( - e - ): - raise - logger.error( - f"Failed to extract schema for media type '{media_type_str}' " - f"in response {status_code}: {e}" - ) - except Exception as e: - logger.error( - f"Failed to extract schema for media type '{media_type_str}' " - f"in response {status_code}: {e}" - ) - else: - # Record the media type even without a schema so MIME - # type inference can still use the declared content type. - resp_info.content_schema.setdefault(media_type_str, {}) - - extracted_responses[str(status_code)] = resp_info - except ValueError as e: - # Re-raise ValueError for external reference errors - if "External or non-local reference not supported" in str(e): - raise - ref_name = getattr(resp_or_ref, "ref", "unknown") - logger.error( - f"Failed to extract response for status code {status_code} " - f"from reference '{ref_name}': {e}", - exc_info=False, - ) - except Exception as e: - ref_name = getattr(resp_or_ref, "ref", "unknown") - logger.error( - f"Failed to extract response for status code {status_code} " - f"from reference '{ref_name}': {e}", - exc_info=False, - ) - - return extracted_responses - - def _extract_schema_dependencies( - self, - schema: dict, - all_schemas: dict[str, Any], - collected: set[str] | None = None, - ) -> set[str]: - """ - Extract all schema names referenced by a schema (including transitive dependencies). - - Args: - schema: The schema to analyze - all_schemas: All available schema definitions - collected: Set of already collected schema names (for recursion) - - Returns: - Set of schema names that are referenced - """ - if collected is None: - collected = set() - - def find_refs(obj): - """Recursively find all $ref references.""" - if isinstance(obj, dict): - if "$ref" in obj and isinstance(obj["$ref"], str): - ref = obj["$ref"] - # Handle both converted and unconverted refs - if ref.startswith(("#/$defs/", "#/components/schemas/")): - schema_name = ref.split("/")[-1] - else: - return - - # Add this schema and recursively find its dependencies - if ( - collected is not None - and schema_name not in collected - and schema_name in all_schemas - ): - collected.add(schema_name) - # Recursively find dependencies of this schema - find_refs(all_schemas[schema_name]) - - # Continue searching in all values - for value in obj.values(): - find_refs(value) - elif isinstance(obj, list): - for item in obj: - find_refs(item) - - find_refs(schema) - return collected - - def _extract_input_schema_dependencies( - self, - parameters: list[ParameterInfo], - request_body: RequestBodyInfo | None, - all_schemas: dict[str, Any], - ) -> dict[str, Any]: - """ - Extract only the schema definitions needed for input (parameters and request body). - - Args: - parameters: Route parameters - request_body: Route request body - all_schemas: All available schema definitions - - Returns: - Dictionary containing only the schemas needed for input - """ - needed_schemas = set() - - # Check parameters for schema references - for param in parameters: - if param.schema_: - deps = self._extract_schema_dependencies(param.schema_, all_schemas) - needed_schemas.update(deps) - - # Check request body for schema references - if request_body and request_body.content_schema: - for content_schema in request_body.content_schema.values(): - deps = self._extract_schema_dependencies(content_schema, all_schemas) - needed_schemas.update(deps) - - # Return only the needed input schemas - return { - name: all_schemas[name] for name in needed_schemas if name in all_schemas - } - - def _extract_output_schema_dependencies( - self, - responses: dict[str, ResponseInfo], - all_schemas: dict[str, Any], - ) -> dict[str, Any]: - """ - Extract only the schema definitions needed for outputs (responses). - - Args: - responses: Route responses - all_schemas: All available schema definitions - - Returns: - Dictionary containing only the schemas needed for outputs - """ - if not responses or not all_schemas: - return {} - - needed_schemas: set[str] = set() - - for response in responses.values(): - if not response.content_schema: - continue - - for content_schema in response.content_schema.values(): - deps = self._extract_schema_dependencies(content_schema, all_schemas) - needed_schemas.update(deps) - - schema_name = content_schema.get("x-fastmcp-top-level-schema") - if isinstance(schema_name, str) and schema_name in all_schemas: - needed_schemas.add(schema_name) - self._extract_schema_dependencies( - all_schemas[schema_name], - all_schemas, - collected=needed_schemas, - ) - - return { - name: all_schemas[name] for name in needed_schemas if name in all_schemas - } - - def parse(self) -> list[HTTPRoute]: - """Parse the OpenAPI schema into HTTP routes.""" - routes: list[HTTPRoute] = [] - - if not hasattr(self.openapi, "paths") or not self.openapi.paths: - logger.warning("OpenAPI schema has no paths defined.") - return [] - - # Extract component schemas - schema_definitions = {} - if hasattr(self.openapi, "components") and self.openapi.components: - components = self.openapi.components - if hasattr(components, "schemas") and components.schemas: - for name, schema in components.schemas.items(): - try: - if isinstance(schema, self.reference_cls): - resolved_schema = self._resolve_ref(schema) - schema_definitions[name] = self._extract_schema_as_dict( - resolved_schema - ) - else: - schema_definitions[name] = self._extract_schema_as_dict( - schema - ) - except Exception as e: - logger.warning( - f"Failed to extract schema definition '{name}': {e}" - ) - - # Convert schema definitions refs from OpenAPI to JSON Schema format (once) - if schema_definitions: - # Convert each schema definition recursively - for name, schema in schema_definitions.items(): - if isinstance(schema, dict): - schema_definitions[name] = _replace_ref_with_defs(schema) - - # Process paths and operations - for path_str, path_item_obj in self.openapi.paths.items(): - if not isinstance(path_item_obj, self.path_item_cls): - logger.warning( - f"Skipping invalid path item for path '{path_str}' (type: {type(path_item_obj)})" - ) - continue - - path_level_params = ( - path_item_obj.parameters - if hasattr(path_item_obj, "parameters") - else None - ) - - # Get HTTP methods from the path item class fields - http_methods = [ - "get", - "put", - "post", - "delete", - "options", - "head", - "patch", - "trace", - ] - for method_lower in http_methods: - operation = getattr(path_item_obj, method_lower, None) - - if operation and isinstance(operation, self.operation_cls): - # Cast method to HttpMethod - safe since we only use valid HTTP methods - method_upper = method_lower.upper() - - try: - parameters = self._extract_parameters( - getattr(operation, "parameters", None), path_level_params - ) - - request_body_info = self._extract_request_body( - getattr(operation, "requestBody", None) - ) - - responses = self._extract_responses( - getattr(operation, "responses", None) - ) - - extensions = {} - if hasattr(operation, "model_extra") and operation.model_extra: - extensions = { - k: v - for k, v in operation.model_extra.items() - if k.startswith("x-") - } - - # Extract schemas separately for input and output - input_schemas = self._extract_input_schema_dependencies( - parameters, - request_body_info, - schema_definitions, - ) - output_schemas = self._extract_output_schema_dependencies( - responses, - schema_definitions, - ) - - # Create initial route without pre-calculated fields - route = HTTPRoute( - path=path_str, - method=method_upper, # type: ignore[arg-type] # Known valid HTTP method # ty:ignore[invalid-argument-type] - operation_id=getattr(operation, "operationId", None), - summary=getattr(operation, "summary", None), - description=getattr(operation, "description", None), - tags=getattr(operation, "tags", []) or [], - parameters=parameters, - request_body=request_body_info, - responses=responses, - request_schemas=input_schemas, - response_schemas=output_schemas, - extensions=extensions, - openapi_version=self.openapi_version, - ) - - # Pre-calculate schema and parameter mapping for performance - try: - flat_schema, param_map = _combine_schemas_and_map_params( - route, - convert_refs=False, # Parser already converted refs - ) - route.flat_param_schema = flat_schema - route.parameter_map = param_map - except Exception as schema_error: - logger.warning( - f"Failed to pre-calculate schema for route {method_upper} {path_str}: {schema_error}" - ) - # Continue with empty pre-calculated fields - route.flat_param_schema = { - "type": "object", - "properties": {}, - } - route.parameter_map = {} - routes.append(route) - except ValueError as op_error: - # Re-raise ValueError for external reference errors - if "External or non-local reference not supported" in str( - op_error - ): - raise - op_id = getattr(operation, "operationId", "unknown") - logger.error( - f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}", - exc_info=True, - ) - except Exception as op_error: - op_id = getattr(operation, "operationId", "unknown") - logger.error( - f"Failed to process operation {method_upper} {path_str} (ID: {op_id}): {op_error}", - exc_info=True, - ) - - logger.debug(f"Finished parsing. Extracted {len(routes)} HTTP routes.") - return routes - - -# Export public symbols -__all__ = [ - "OpenAPIParser", - "parse_openapi_to_http_routes", -] diff --git a/src/fastmcp/utilities/openapi/schemas.py b/src/fastmcp/utilities/openapi/schemas.py deleted file mode 100644 index fa93b6c8d..000000000 --- a/src/fastmcp/utilities/openapi/schemas.py +++ /dev/null @@ -1,606 +0,0 @@ -"""Schema manipulation utilities for OpenAPI operations.""" - -from typing import Any - -from fastmcp.utilities.logging import get_logger - -from .models import HTTPRoute, JsonSchema, ResponseInfo - -logger = get_logger(__name__) - - -def clean_schema_for_display(schema: JsonSchema | None) -> JsonSchema | None: - """ - Clean up a schema dictionary for display by removing internal/complex fields. - """ - if not schema or not isinstance(schema, dict): - return schema - - # Make a copy to avoid modifying the input schema - cleaned = schema.copy() - - # Fields commonly removed for simpler display to LLMs or users - fields_to_remove = [ - "allOf", - "anyOf", - "oneOf", - "not", # Composition keywords - "nullable", # Handled by type unions usually - "discriminator", - "readOnly", - "writeOnly", - "deprecated", - "xml", - "externalDocs", - # Can be verbose, maybe remove based on flag? - # "pattern", "minLength", "maxLength", - # "minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", - # "multipleOf", "minItems", "maxItems", "uniqueItems", - # "minProperties", "maxProperties" - ] - - for field in fields_to_remove: - if field in cleaned: - cleaned.pop(field) - - # Recursively clean properties and items - if "properties" in cleaned: - cleaned["properties"] = { - k: clean_schema_for_display(v) for k, v in cleaned["properties"].items() - } - # Remove properties section if empty after cleaning - if not cleaned["properties"]: - cleaned.pop("properties") - - if "items" in cleaned: - cleaned["items"] = clean_schema_for_display(cleaned["items"]) - # Remove items section if empty after cleaning - if not cleaned["items"]: - cleaned.pop("items") - - if "additionalProperties" in cleaned: - # Often verbose, can be simplified - if isinstance(cleaned["additionalProperties"], dict): - cleaned["additionalProperties"] = clean_schema_for_display( - cleaned["additionalProperties"] - ) - elif cleaned["additionalProperties"] is True: - # Maybe keep 'true' or represent as 'Allows additional properties' text? - pass # Keep simple boolean for now - - return cleaned - - -def _replace_ref_with_defs( - info: dict[str, Any], description: str | None = None -) -> dict[str, Any]: - """ - Replace openapi $ref with jsonschema $defs recursively. - - Examples: - - {"type": "object", "properties": {"$ref": "#/components/schemas/..."}} - - {"type": "object", "additionalProperties": {"$ref": "#/components/schemas/..."}, "properties": {...}} - - {"$ref": "#/components/schemas/..."} - - {"items": {"$ref": "#/components/schemas/..."}} - - {"anyOf": [{"$ref": "#/components/schemas/..."}]} - - {"allOf": [{"$ref": "#/components/schemas/..."}]} - - {"oneOf": [{"$ref": "#/components/schemas/..."}]} - - Args: - info: dict[str, Any] - description: str | None - - Returns: - dict[str, Any] - """ - schema = info.copy() - if ref_path := schema.get("$ref"): - if isinstance(ref_path, str): - if ref_path.startswith("#/components/schemas/"): - schema_name = ref_path.split("/")[-1] - schema["$ref"] = f"#/$defs/{schema_name}" - elif not ref_path.startswith("#/"): - raise ValueError( - f"External or non-local reference not supported: {ref_path}. " - f"FastMCP only supports local schema references starting with '#/'. " - f"Please include all schema definitions within the OpenAPI document." - ) - elif properties := schema.get("properties"): - if "$ref" in properties: - schema["properties"] = _replace_ref_with_defs(properties) - else: - schema["properties"] = { - prop_name: _replace_ref_with_defs(prop_schema) - for prop_name, prop_schema in properties.items() - } - elif item_schema := schema.get("items"): - schema["items"] = _replace_ref_with_defs(item_schema) - for section in ["anyOf", "allOf", "oneOf"]: - if section in schema: - schema[section] = [_replace_ref_with_defs(item) for item in schema[section]] - if additionalProperties := schema.get("additionalProperties"): - if not isinstance(additionalProperties, bool): - schema["additionalProperties"] = _replace_ref_with_defs( - additionalProperties - ) - # Handle propertyNames - if property_names := schema.get("propertyNames"): - if isinstance(property_names, dict): - schema["propertyNames"] = _replace_ref_with_defs(property_names) - # Handle patternProperties - if pattern_properties := schema.get("patternProperties"): - if isinstance(pattern_properties, dict): - schema["patternProperties"] = { - pattern: _replace_ref_with_defs(subschema) - if isinstance(subschema, dict) - else subschema - for pattern, subschema in pattern_properties.items() - } - if info.get("description", description) and not schema.get("description"): - schema["description"] = description - return schema - - -def _make_optional_parameter_nullable(schema: dict[str, Any]) -> dict[str, Any]: - """ - Make an optional parameter schema nullable to allow None values. - - For optional parameters, we need to allow null values in addition to the - specified type to handle cases where None is passed for optional parameters. - """ - # If schema already has multiple types or is already nullable, don't modify - if "anyOf" in schema or "oneOf" in schema or "allOf" in schema: - return schema - - # If it's already nullable (type includes null), don't modify - if isinstance(schema.get("type"), list) and "null" in schema["type"]: - return schema - - # Create a new schema that allows null in addition to the original type - if "type" in schema: - original_type = schema["type"] - if isinstance(original_type, str): - # Handle different types appropriately - if original_type in ("array", "object"): - # For complex types (array/object), preserve the full structure - # and allow null as an alternative - if original_type == "array" and "items" in schema: - # Array with items - preserve items in anyOf branch - array_schema = schema.copy() - top_level_fields = ["default", "description", "title", "example"] - nullable_schema = {} - - # Move top-level fields to the root - for field in top_level_fields: - if field in array_schema: - nullable_schema[field] = array_schema.pop(field) - - nullable_schema["anyOf"] = [array_schema, {"type": "null"}] - return nullable_schema - - elif original_type == "object" and "properties" in schema: - # Object with properties - preserve properties in anyOf branch - object_schema = schema.copy() - top_level_fields = ["default", "description", "title", "example"] - nullable_schema = {} - - # Move top-level fields to the root - for field in top_level_fields: - if field in object_schema: - nullable_schema[field] = object_schema.pop(field) - - nullable_schema["anyOf"] = [object_schema, {"type": "null"}] - return nullable_schema - else: - # Simple object/array without items/properties - nullable_schema = {} - original_schema = schema.copy() - top_level_fields = ["default", "description", "title", "example"] - - for field in top_level_fields: - if field in original_schema: - nullable_schema[field] = original_schema.pop(field) - - nullable_schema["anyOf"] = [original_schema, {"type": "null"}] - return nullable_schema - else: - # Simple types (string, integer, number, boolean) - top_level_fields = ["default", "description", "title", "example"] - nullable_schema = {} - original_schema = schema.copy() - - for field in top_level_fields: - if field in original_schema: - nullable_schema[field] = original_schema.pop(field) - - nullable_schema["anyOf"] = [original_schema, {"type": "null"}] - return nullable_schema - - return schema - - -def _combine_schemas_and_map_params( - route: HTTPRoute, - convert_refs: bool = True, -) -> tuple[dict[str, Any], dict[str, dict[str, str]]]: - """ - Combines parameter and request body schemas into a single schema. - Handles parameter name collisions by adding location suffixes. - Also returns parameter mapping for request director. - - Args: - route: HTTPRoute object - - Returns: - Tuple of (combined schema dictionary, parameter mapping) - Parameter mapping format: {'flat_arg_name': {'location': 'path', 'openapi_name': 'id'}} - """ - properties = {} - required = [] - parameter_map = {} # Track mapping from flat arg names to OpenAPI locations - - # First pass: collect parameter names by location and body properties - param_names_by_location = { - "path": set(), - "query": set(), - "header": set(), - "cookie": set(), - } - body_props = {} - - for param in route.parameters: - param_names_by_location[param.location].add(param.name) - - if route.request_body and route.request_body.content_schema: - content_type = next(iter(route.request_body.content_schema)) - - # Convert refs if needed - if convert_refs: - body_schema = _replace_ref_with_defs( - route.request_body.content_schema[content_type] - ) - else: - body_schema = route.request_body.content_schema[content_type] - - if route.request_body.description and not body_schema.get("description"): - body_schema["description"] = route.request_body.description - - # Handle allOf at the top level by merging all schemas - if "allOf" in body_schema and isinstance(body_schema["allOf"], list): - merged_props = {} - merged_required = [] - - for sub_schema in body_schema["allOf"]: - if isinstance(sub_schema, dict): - # Merge properties - if "properties" in sub_schema: - merged_props.update(sub_schema["properties"]) - # Merge required fields - if "required" in sub_schema: - merged_required.extend(sub_schema["required"]) - - # Update body_schema with merged properties - body_schema["properties"] = merged_props - if merged_required: - # Remove duplicates while preserving order - seen = set() - body_schema["required"] = [ - x for x in merged_required if not (x in seen or seen.add(x)) - ] - # Remove the allOf since we've merged it - body_schema.pop("allOf", None) - - body_props = body_schema.get("properties", {}) - - # Detect collisions: parameters that exist in both body and path/query/header - all_non_body_params = set() - for location_params in param_names_by_location.values(): - all_non_body_params.update(location_params) - - body_param_names = set(body_props.keys()) - colliding_params = all_non_body_params & body_param_names - - # Add parameters with suffixes for collisions - for param in route.parameters: - if param.name in colliding_params: - # Add suffix for non-body parameters when collision detected - suffixed_name = f"{param.name}__{param.location}" - if param.required: - required.append(suffixed_name) - - # Track parameter mapping - parameter_map[suffixed_name] = { - "location": param.location, - "openapi_name": param.name, - } - - # Convert refs if needed - if convert_refs: - param_schema = _replace_ref_with_defs(param.schema_, param.description) - else: - param_schema = param.schema_.copy() - if param.description and not param_schema.get("description"): - param_schema["description"] = param.description - original_desc = param_schema.get("description", "") - location_desc = f"({param.location.capitalize()} parameter)" - if original_desc: - param_schema["description"] = f"{original_desc} {location_desc}" - else: - param_schema["description"] = location_desc - - # Don't make optional parameters nullable - they can simply be omitted - # The OpenAPI specification doesn't require optional parameters to accept null values - - properties[suffixed_name] = param_schema - else: - # No collision, use original name - if param.required: - required.append(param.name) - - # Track parameter mapping - parameter_map[param.name] = { - "location": param.location, - "openapi_name": param.name, - } - - # Convert refs if needed - if convert_refs: - param_schema = _replace_ref_with_defs(param.schema_, param.description) - else: - param_schema = param.schema_.copy() - if param.description and not param_schema.get("description"): - param_schema["description"] = param.description - - # Don't make optional parameters nullable - they can simply be omitted - # The OpenAPI specification doesn't require optional parameters to accept null values - - properties[param.name] = param_schema - - # Add request body properties (no suffixes for body parameters) - if route.request_body and route.request_body.content_schema: - # If body is just a $ref, we need to handle it differently - if "$ref" in body_schema and not body_props: - # The entire body is a reference to a schema - # We need to expand this inline or keep the ref - # For simplicity, we'll keep it as a single property - properties["body"] = body_schema - if route.request_body.required: - required.append("body") - parameter_map["body"] = {"location": "body", "openapi_name": "body"} - elif body_props: - # Normal case: body has properties - for prop_name, prop_schema in body_props.items(): - properties[prop_name] = prop_schema - - # Track parameter mapping for body properties - parameter_map[prop_name] = { - "location": "body", - "openapi_name": prop_name, - } - - if route.request_body.required: - required.extend(body_schema.get("required", [])) - else: - # Handle direct array/primitive schemas (like list[str] parameters from FastAPI) - # Use the schema title as parameter name, fall back to generic name - param_name = body_schema.get("title", "body").lower() - - # Clean the parameter name to be valid - import re - - param_name = re.sub(r"[^a-zA-Z0-9_]", "_", param_name) - if not param_name or param_name[0].isdigit(): - param_name = "body_data" - - properties[param_name] = body_schema - if route.request_body.required: - required.append(param_name) - parameter_map[param_name] = {"location": "body", "openapi_name": param_name} - - result = { - "type": "object", - "properties": properties, - "required": required, - } - # Add schema definitions if available - schema_defs = route.request_schemas - if schema_defs: - if convert_refs: - # Need to convert refs and prune - all_defs = schema_defs.copy() - # Convert each schema definition recursively - for name, schema in all_defs.items(): - if isinstance(schema, dict): - all_defs[name] = _replace_ref_with_defs(schema) - - # Prune to only needed schemas - used_refs = set() - - def find_refs_in_value(value): - """Recursively find all $ref references.""" - if isinstance(value, dict): - if "$ref" in value and isinstance(value["$ref"], str): - ref = value["$ref"] - if ref.startswith("#/$defs/"): - used_refs.add(ref.split("/")[-1]) - for v in value.values(): - find_refs_in_value(v) - elif isinstance(value, list): - for item in value: - find_refs_in_value(item) - - # Find refs in properties - find_refs_in_value(properties) - - # Collect transitive dependencies - if used_refs: - collected_all = False - while not collected_all: - initial_count = len(used_refs) - for name in list(used_refs): - if name in all_defs: - find_refs_in_value(all_defs[name]) - collected_all = len(used_refs) == initial_count - - result["$defs"] = { - name: def_schema - for name, def_schema in all_defs.items() - if name in used_refs - } - else: - # From parser - already converted and pruned - result["$defs"] = schema_defs - - return result, parameter_map - - -def _combine_schemas(route: HTTPRoute) -> dict[str, Any]: - """ - Combines parameter and request body schemas into a single schema. - Handles parameter name collisions by adding location suffixes. - - This is a backward compatibility wrapper around _combine_schemas_and_map_params. - - Args: - route: HTTPRoute object - - Returns: - Combined schema dictionary - """ - schema, _ = _combine_schemas_and_map_params(route) - return schema - - -def extract_output_schema_from_responses( - responses: dict[str, ResponseInfo], - schema_definitions: dict[str, Any] | None = None, - openapi_version: str | None = None, -) -> dict[str, Any] | None: - """ - Extract output schema from OpenAPI responses for use as MCP tool output schema. - - This function finds the first successful response (200, 201, 202, 204) with a - JSON-compatible content type and extracts its schema. If the schema is not an - object type, it wraps it to comply with MCP requirements. - - Args: - responses: Dictionary of ResponseInfo objects keyed by status code - schema_definitions: Optional schema definitions to include in the output schema - openapi_version: OpenAPI version string, used to optimize nullable field handling - - Returns: - dict: MCP-compliant output schema with potential wrapping, or None if no suitable schema found - """ - if not responses: - return None - - # Priority order for success status codes - success_codes = ["200", "201", "202", "204"] - - # Find the first successful response - response_info = None - for status_code in success_codes: - if status_code in responses: - response_info = responses[status_code] - break - - # If no explicit success codes, try any 2xx response - if response_info is None: - for status_code, resp_info in responses.items(): - if status_code.startswith("2"): - response_info = resp_info - break - - if response_info is None or not response_info.content_schema: - return None - - # Prefer application/json, then fall back to other JSON-compatible types - json_compatible_types = [ - "application/json", - "application/vnd.api+json", - "application/hal+json", - "application/ld+json", - "text/json", - ] - - schema = None - for content_type in json_compatible_types: - if content_type in response_info.content_schema: - schema = response_info.content_schema[content_type] - break - - # If no JSON-compatible type found, try the first available content type - if schema is None and response_info.content_schema: - first_content_type = next(iter(response_info.content_schema)) - schema = response_info.content_schema[first_content_type] - logger.debug( - f"Using non-JSON content type for output schema: {first_content_type}" - ) - - if not schema or not isinstance(schema, dict): - return None - - # Convert refs if needed - output_schema = _replace_ref_with_defs(schema) - - # If schema has a $ref, resolve it first before processing nullable fields - if "$ref" in output_schema and schema_definitions: - ref_path = output_schema["$ref"] - if ref_path.startswith("#/$defs/"): - schema_name = ref_path.split("/")[-1] - if schema_name in schema_definitions: - # Replace $ref with the actual schema definition - output_schema = _replace_ref_with_defs(schema_definitions[schema_name]) - - if openapi_version and openapi_version.startswith("3"): - # Convert OpenAPI 3.x schema to JSON Schema format for proper handling - # of constructs like oneOf, anyOf, and nullable fields - from .json_schema_converter import convert_openapi_schema_to_json_schema - - output_schema = convert_openapi_schema_to_json_schema( - output_schema, openapi_version - ) - - # MCP requires output schemas to be objects. If this schema is not an object, - # we need to wrap it similar to how ParsedFunction.from_function() does it - if output_schema.get("type") != "object": - # Create a wrapped schema that contains the original schema under a "result" key - wrapped_schema = { - "type": "object", - "properties": {"result": output_schema}, - "required": ["result"], - "x-fastmcp-wrap-result": True, - } - output_schema = wrapped_schema - - # Add schema definitions if available - if schema_definitions: - # Convert refs if needed - processed_defs = schema_definitions.copy() - # Convert each schema definition recursively - for name, schema in processed_defs.items(): - if isinstance(schema, dict): - processed_defs[name] = _replace_ref_with_defs(schema) - - # Convert OpenAPI schema definitions to JSON Schema format if needed - if openapi_version and openapi_version.startswith("3"): - from .json_schema_converter import convert_openapi_schema_to_json_schema - - for def_name in list(processed_defs.keys()): - processed_defs[def_name] = convert_openapi_schema_to_json_schema( - processed_defs[def_name], openapi_version - ) - - output_schema["$defs"] = processed_defs - - return output_schema - - -# Export public symbols -__all__ = [ - "_combine_schemas", - "_combine_schemas_and_map_params", - "_make_optional_parameter_nullable", - "clean_schema_for_display", - "extract_output_schema_from_responses", -] diff --git a/src/fastmcp/utilities/pagination.py b/src/fastmcp/utilities/pagination.py deleted file mode 100644 index 48e2e45bb..000000000 --- a/src/fastmcp/utilities/pagination.py +++ /dev/null @@ -1,80 +0,0 @@ -"""Pagination utilities for MCP list operations.""" - -from __future__ import annotations - -import base64 -import binascii -import json -from collections.abc import Sequence -from dataclasses import dataclass -from typing import TypeVar - -T = TypeVar("T") - - -@dataclass -class CursorState: - """Internal representation of pagination cursor state. - - The cursor encodes the offset into the result set. This is opaque to clients - per the MCP spec - they should not parse or modify cursors. - """ - - offset: int - - def encode(self) -> str: - """Encode cursor state to an opaque string.""" - data = json.dumps({"o": self.offset}) - return base64.urlsafe_b64encode(data.encode()).decode() - - @classmethod - def decode(cls, cursor: str) -> CursorState: - """Decode cursor from an opaque string. - - Raises: - ValueError: If the cursor is invalid or malformed. - """ - try: - data = json.loads(base64.urlsafe_b64decode(cursor.encode()).decode()) - return cls(offset=data["o"]) - except ( - json.JSONDecodeError, - KeyError, - ValueError, - TypeError, - binascii.Error, - ) as e: - raise ValueError(f"Invalid cursor: {cursor}") from e - - -def paginate_sequence( - items: Sequence[T], - cursor: str | None, - page_size: int, -) -> tuple[list[T], str | None]: - """Paginate a sequence of items. - - Args: - items: The full sequence to paginate. - cursor: Optional cursor from a previous request. None for first page. - page_size: Maximum number of items per page. - - Returns: - Tuple of (page_items, next_cursor). next_cursor is None if no more pages. - - Raises: - ValueError: If the cursor is invalid. - """ - offset = 0 - if cursor: - state = CursorState.decode(cursor) - offset = state.offset - - end = offset + page_size - page = list(items[offset:end]) - - next_cursor = None - if end < len(items): - next_cursor = CursorState(offset=end).encode() - - return page, next_cursor diff --git a/src/fastmcp/utilities/skills.py b/src/fastmcp/utilities/skills.py deleted file mode 100644 index 49b13d859..000000000 --- a/src/fastmcp/utilities/skills.py +++ /dev/null @@ -1,257 +0,0 @@ -"""Client utilities for discovering and downloading skills from MCP servers.""" - -from __future__ import annotations - -import base64 -import json -from dataclasses import dataclass -from pathlib import Path -from typing import TYPE_CHECKING - -import mcp.types - -if TYPE_CHECKING: - from fastmcp.client import Client - - -@dataclass -class SkillSummary: - """Summary information about a skill available on a server.""" - - name: str - description: str - uri: str - - -@dataclass -class SkillFile: - """Information about a file within a skill.""" - - path: str - size: int - hash: str - - -@dataclass -class SkillManifest: - """Full manifest of a skill including all files.""" - - name: str - files: list[SkillFile] - - -async def list_skills(client: Client) -> list[SkillSummary]: - """List all available skills from an MCP server. - - Discovers skills by finding resources with URIs matching the - `skill://{name}/SKILL.md` pattern. - - Args: - client: Connected FastMCP client - - Returns: - List of SkillSummary objects with name, description, and URI - - Example: - ```python - from fastmcp import Client - from fastmcp.utilities.skills import list_skills - - async with Client("http://skills-server/mcp") as client: - skills = await list_skills(client) - for skill in skills: - print(f"{skill.name}: {skill.description}") - ``` - """ - resources = await client.list_resources() - skills = [] - - for resource in resources: - uri = str(resource.uri) - # Match skill://{name}/SKILL.md pattern - if uri.startswith("skill://") and uri.endswith("/SKILL.md"): - # Extract skill name from URI - path_part = uri[len("skill://") :] - name = path_part.rsplit("/", 1)[0] - skills.append( - SkillSummary( - name=name, - description=resource.description or "", - uri=uri, - ) - ) - - return skills - - -async def get_skill_manifest(client: Client, skill_name: str) -> SkillManifest: - """Get the manifest for a specific skill. - - Args: - client: Connected FastMCP client - skill_name: Name of the skill - - Returns: - SkillManifest with file listing - - Raises: - ValueError: If manifest cannot be read or parsed - """ - manifest_uri = f"skill://{skill_name}/_manifest" - result = await client.read_resource(manifest_uri) - - if not result: - raise ValueError(f"Could not read manifest for skill: {skill_name}") - - content = result[0] - if isinstance(content, mcp.types.TextResourceContents): - try: - manifest_data = json.loads(content.text) - except json.JSONDecodeError as e: - raise ValueError(f"Invalid manifest JSON for skill: {skill_name}") from e - else: - raise ValueError(f"Unexpected manifest format for skill: {skill_name}") - - try: - return SkillManifest( - name=manifest_data["skill"], - files=[ - SkillFile(path=f["path"], size=f["size"], hash=f["hash"]) - for f in manifest_data["files"] - ], - ) - except (KeyError, TypeError) as e: - raise ValueError(f"Invalid manifest format for skill: {skill_name}") from e - - -async def download_skill( - client: Client, - skill_name: str, - target_dir: str | Path, - *, - overwrite: bool = False, -) -> Path: - """Download a skill and all its files to a local directory. - - Creates a subdirectory named after the skill containing all files. - - Args: - client: Connected FastMCP client - skill_name: Name of the skill to download - target_dir: Directory where skill folder will be created - overwrite: If True, overwrite existing skill directory. If False - (default), raise FileExistsError if directory exists. - - Returns: - Path to the downloaded skill directory - - Raises: - ValueError: If skill cannot be found or downloaded - FileExistsError: If skill directory exists and overwrite=False - - Example: - ```python - from fastmcp import Client - from fastmcp.utilities.skills import download_skill - - async with Client("http://skills-server/mcp") as client: - skill_path = await download_skill( - client, - "pdf-processing", - "~/.claude/skills" - ) - print(f"Downloaded to: {skill_path}") - ``` - """ - target_dir = Path(target_dir).expanduser().resolve() - skill_dir = (target_dir / skill_name).resolve() - - # Security: ensure skill_dir stays within target_dir - if not skill_dir.is_relative_to(target_dir): - raise ValueError(f"Skill name {skill_name!r} would escape the target directory") - - # Check if directory exists - if skill_dir.exists() and not overwrite: - raise FileExistsError( - f"Skill directory already exists: {skill_dir}. " - "Use overwrite=True to replace." - ) - - # Get manifest to know what files to download - manifest = await get_skill_manifest(client, skill_name) - - # Create skill directory - skill_dir.mkdir(parents=True, exist_ok=True) - - # Download each file - for file_info in manifest.files: - # Security: reject absolute paths and paths that escape skill_dir - if Path(file_info.path).is_absolute(): - continue - file_path = (skill_dir / file_info.path).resolve() - if not file_path.is_relative_to(skill_dir): - continue - - file_uri = f"skill://{skill_name}/{file_info.path}" - result = await client.read_resource(file_uri) - - if not result: - continue - - content = result[0] - - # Create parent directories if needed - file_path.parent.mkdir(parents=True, exist_ok=True) - - # Write content - if isinstance(content, mcp.types.TextResourceContents): - file_path.write_text(content.text) - elif isinstance(content, mcp.types.BlobResourceContents): - file_path.write_bytes(base64.b64decode(content.blob)) - else: - # Skip unknown content types - continue - - return skill_dir - - -async def sync_skills( - client: Client, - target_dir: str | Path, - *, - overwrite: bool = False, -) -> list[Path]: - """Download all available skills from a server. - - Args: - client: Connected FastMCP client - target_dir: Directory where skill folders will be created - overwrite: If True, overwrite existing files - - Returns: - List of paths to downloaded skill directories - - Example: - ```python - from fastmcp import Client - from fastmcp.utilities.skills import sync_skills - - async with Client("http://skills-server/mcp") as client: - paths = await sync_skills(client, "~/.claude/skills") - print(f"Downloaded {len(paths)} skills") - ``` - """ - skills = await list_skills(client) - downloaded = [] - - for skill in skills: - try: - path = await download_skill( - client, skill.name, target_dir, overwrite=overwrite - ) - downloaded.append(path) - except FileExistsError: - # Skip existing skills when not overwriting - continue - - return downloaded diff --git a/src/fastmcp/utilities/tests.py b/src/fastmcp/utilities/tests.py deleted file mode 100644 index e25bb38b5..000000000 --- a/src/fastmcp/utilities/tests.py +++ /dev/null @@ -1,270 +0,0 @@ -from __future__ import annotations - -import copy -import multiprocessing -import socket -import time -from collections.abc import AsyncGenerator, Callable, Generator -from contextlib import asynccontextmanager, contextmanager, suppress -from typing import TYPE_CHECKING, Any, Literal -from urllib.parse import parse_qs, urlparse - -import httpx -import uvicorn - -from fastmcp import settings -from fastmcp.client.auth.oauth import OAuth -from fastmcp.utilities.http import find_available_port - -if TYPE_CHECKING: - from fastmcp.server.server import FastMCP - - -@contextmanager -def temporary_settings(**kwargs: Any): - """ - Temporarily override FastMCP setting values. - - Args: - **kwargs: The settings to override, including nested settings. - - Example: - Temporarily override a setting: - ```python - import fastmcp - from fastmcp.utilities.tests import temporary_settings - - with temporary_settings(log_level='DEBUG'): - assert fastmcp.settings.log_level == 'DEBUG' - assert fastmcp.settings.log_level == 'INFO' - ``` - """ - old_settings = copy.deepcopy(settings) - - try: - # apply the new settings - for attr, value in kwargs.items(): - settings.set_setting(attr, value) - yield - - finally: - # restore the old settings - for attr in kwargs: - settings.set_setting(attr, old_settings.get_setting(attr)) - - -def _run_server(mcp_server: FastMCP, transport: Literal["sse"], port: int) -> None: - # Some Starlette apps are not pickleable, so we need to create them here based on the indicated transport - if transport == "sse": - app = mcp_server.http_app(transport="sse") - else: - raise ValueError(f"Invalid transport: {transport}") - uvicorn_server = uvicorn.Server( - config=uvicorn.Config( - app=app, - host="127.0.0.1", - port=port, - log_level="error", - ws="websockets-sansio", - ) - ) - uvicorn_server.run() - - -@contextmanager -def run_server_in_process( - server_fn: Callable[..., None], - *args: Any, - provide_host_and_port: bool = True, - host: str = "127.0.0.1", - port: int | None = None, - **kwargs: Any, -) -> Generator[str, None, None]: - """ - Context manager that runs a FastMCP server in a separate process and - returns the server URL. When the context manager is exited, the server process is killed. - - Args: - server_fn: The function that runs a FastMCP server. FastMCP servers are - not pickleable, so we need a function that creates and runs one. - *args: Arguments to pass to the server function. - provide_host_and_port: Whether to provide the host and port to the server function as kwargs. - host: Host to bind the server to (default: "127.0.0.1"). - port: Port to bind the server to (default: find available port). - **kwargs: Keyword arguments to pass to the server function. - - Returns: - The server URL. - """ - # Use provided port or find an available one - if port is None: - port = find_available_port() - - if provide_host_and_port: - kwargs |= {"host": host, "port": port} - - proc = multiprocessing.Process( - target=server_fn, args=args, kwargs=kwargs, daemon=True - ) - proc.start() - - # Wait for server to be running - max_attempts = 30 - attempt = 0 - while attempt < max_attempts and proc.is_alive(): - try: - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: - s.connect((host, port)) - break - except ConnectionRefusedError: - if attempt < 5: - time.sleep(0.05) - elif attempt < 15: - time.sleep(0.1) - else: - time.sleep(0.2) - attempt += 1 - else: - raise RuntimeError(f"Server failed to start after {max_attempts} attempts") - - yield f"http://{host}:{port}" - - proc.terminate() - proc.join(timeout=5) - if proc.is_alive(): - # If it's still alive, then force kill it - proc.kill() - proc.join(timeout=2) - if proc.is_alive(): - raise RuntimeError("Server process failed to terminate even after kill") - - -@asynccontextmanager -async def run_server_async( - server: FastMCP, - port: int | None = None, - transport: Literal["http", "streamable-http", "sse"] = "http", - path: str = "/mcp", - host: str = "127.0.0.1", -) -> AsyncGenerator[str, None]: - """ - Start a FastMCP server as an asyncio task for in-process async testing. - - This is the recommended way to test FastMCP servers. It runs the server - as an async task in the same process, eliminating subprocess coordination, - sleeps, and cleanup issues. - - Args: - server: FastMCP server instance - port: Port to bind to (default: find available port) - transport: Transport type ("http", "streamable-http", or "sse") - path: URL path for the server (default: "/mcp") - host: Host to bind to (default: "127.0.0.1") - - Yields: - Server URL string - - Example: - ```python - import pytest - from fastmcp import FastMCP, Client - from fastmcp.client.transports import StreamableHttpTransport - from fastmcp.utilities.tests import run_server_async - - @pytest.fixture - async def server(): - mcp = FastMCP("test") - - @mcp.tool() - def greet(name: str) -> str: - return f"Hello, {name}!" - - async with run_server_async(mcp) as url: - yield url - - async def test_greet(server: str): - async with Client(StreamableHttpTransport(server)) as client: - result = await client.call_tool("greet", {"name": "World"}) - assert result.content[0].text == "Hello, World!" - ``` - """ - import asyncio - - if port is None: - port = find_available_port() - - # Wait a tiny bit for the port to be released if it was just used - await asyncio.sleep(0.01) - - # Start server as a background task - server_task = asyncio.create_task( - server.run_http_async( - host=host, - port=port, - transport=transport, - path=path, - show_banner=False, - ) - ) - - # Wait for server lifespan to be ready - await server._started.wait() - - # Give uvicorn a moment to bind the port after lifespan is ready - await asyncio.sleep(0.1) - - try: - yield f"http://{host}:{port}{path}" - finally: - # Cleanup: cancel the task with timeout to avoid hanging on Windows - server_task.cancel() - with suppress(asyncio.CancelledError, asyncio.TimeoutError): - await asyncio.wait_for(server_task, timeout=2.0) - - -class HeadlessOAuth(OAuth): - """ - OAuth provider that bypasses browser interaction for testing. - - This simulates the complete OAuth flow programmatically by making HTTP requests - instead of opening a browser and running a callback server. Useful for automated testing. - """ - - def __init__(self, mcp_url: str, **kwargs): - """Initialize HeadlessOAuth with stored response tracking.""" - self._stored_response = None - super().__init__(mcp_url, **kwargs) - - async def redirect_handler(self, authorization_url: str) -> None: - """Make HTTP request to authorization URL and store response for callback handler.""" - async with httpx.AsyncClient() as client: - response = await client.get(authorization_url, follow_redirects=False) - self._stored_response = response - - async def callback_handler(self) -> tuple[str, str | None]: - """Parse stored response and return (auth_code, state).""" - if not self._stored_response: - raise RuntimeError( - "No authorization response stored. redirect_handler must be called first." - ) - - response = self._stored_response - - # Extract auth code from redirect location - if response.status_code == 302: - redirect_url = response.headers["location"] - parsed = urlparse(redirect_url) - query_params = parse_qs(parsed.query) - - if "error" in query_params: - error = query_params["error"][0] - error_desc = query_params.get("error_description", ["Unknown error"])[0] - raise RuntimeError( - f"OAuth authorization failed: {error} - {error_desc}" - ) - - auth_code = query_params["code"][0] - state = query_params.get("state", [None])[0] - return auth_code, state - else: - raise RuntimeError(f"Authorization failed: {response.status_code}") diff --git a/src/fastmcp/utilities/timeout.py b/src/fastmcp/utilities/timeout.py deleted file mode 100644 index b12997807..000000000 --- a/src/fastmcp/utilities/timeout.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Timeout normalization utilities.""" - -from __future__ import annotations - -import datetime - - -def normalize_timeout_to_timedelta( - value: int | float | datetime.timedelta | None, -) -> datetime.timedelta | None: - """Normalize a timeout value to a timedelta. - - Args: - value: Timeout value as int/float (seconds), timedelta, or None - - Returns: - timedelta if value provided, None otherwise - """ - if value is None: - return None - if isinstance(value, datetime.timedelta): - return value - if isinstance(value, int | float): - return datetime.timedelta(seconds=float(value)) - raise TypeError(f"Invalid timeout type: {type(value)}") - - -def normalize_timeout_to_seconds( - value: int | float | datetime.timedelta | None, -) -> float | None: - """Normalize a timeout value to seconds (float). - - Args: - value: Timeout value as int/float (seconds), timedelta, or None. - Zero values are treated as "disabled" and return None. - - Returns: - float seconds if value provided and non-zero, None otherwise - """ - if value is None: - return None - if isinstance(value, datetime.timedelta): - seconds = value.total_seconds() - return None if seconds == 0 else seconds - if isinstance(value, int | float): - return None if value == 0 else float(value) - raise TypeError(f"Invalid timeout type: {type(value)}") diff --git a/src/fastmcp/utilities/token_cache.py b/src/fastmcp/utilities/token_cache.py deleted file mode 100644 index 9446090d3..000000000 --- a/src/fastmcp/utilities/token_cache.py +++ /dev/null @@ -1,173 +0,0 @@ -"""In-memory cache for token verification results. - -Provides a generic TTL-based cache for ``AccessToken`` objects, designed to -reduce repeated network calls during opaque-token verification. Only -*successful* verifications should be cached; errors and failures must be -retried on every request. - -Example: - ```python - from fastmcp.utilities.token_cache import TokenCache - - cache = TokenCache(ttl_seconds=300, max_size=10000) - - # On cache miss, call the upstream verifier and store the result. - hit, token = cache.get(raw_token) - if not hit: - token = await _call_upstream(raw_token) - if token is not None: - cache.set(raw_token, token) - ``` -""" - -from __future__ import annotations - -import hashlib -import time -from dataclasses import dataclass - -from fastmcp.server.auth.auth import AccessToken -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - -DEFAULT_MAX_CACHE_SIZE = 10_000 -_CLEANUP_INTERVAL = 60 # seconds between periodic sweeps - - -@dataclass -class _CacheEntry: - """A cached token result with its absolute expiration timestamp.""" - - result: AccessToken - expires_at: float - - -class TokenCache: - """TTL-based in-memory cache for ``AccessToken`` objects. - - Features: - - SHA-256 hashed cache keys (fixed size, regardless of token length). - - Per-entry TTL that respects both the configured ``ttl_seconds`` and the - token's own ``expires_at`` claim (whichever is sooner). - - Bounded size with FIFO eviction when the cache is full. - - Periodic cleanup of expired entries to prevent unbounded growth. - - Defensive deep copies on both store and retrieve to prevent - callers from mutating cached values. - - Caching is disabled when ``ttl_seconds`` is ``None`` or ``0``, or - when ``max_size`` is ``0``. Negative values raise ``ValueError``. - """ - - def __init__( - self, - *, - ttl_seconds: int | None = None, - max_size: int | None = None, - ) -> None: - """Initialise the cache. - - Args: - ttl_seconds: How long cached entries remain valid, in seconds. - ``None`` or ``0`` disables caching entirely. - max_size: Upper bound on the number of entries. When the limit is - reached, expired entries are swept first; if still full the - oldest entry is evicted. Defaults to 10 000. - """ - if ttl_seconds is not None and ttl_seconds < 0: - raise ValueError( - f"cache_ttl_seconds must be non-negative, got {ttl_seconds}" - ) - if max_size is not None and max_size < 0: - raise ValueError(f"max_cache_size must be non-negative, got {max_size}") - self._ttl = ttl_seconds or 0 - self._max_size = max_size if max_size is not None else DEFAULT_MAX_CACHE_SIZE - self._entries: dict[str, _CacheEntry] = {} - self._last_cleanup = time.monotonic() - - @property - def enabled(self) -> bool: - """Return whether caching is active.""" - return self._ttl > 0 and self._max_size > 0 - - # -- public API ---------------------------------------------------------- - - def get(self, token: str) -> tuple[bool, AccessToken | None]: - """Look up a cached verification result. - - Returns: - ``(True, AccessToken)`` on a cache hit, ``(False, None)`` on a miss - or when caching is disabled. The returned ``AccessToken`` is a deep - copy that is safe to mutate. - """ - if not self.enabled: - return (False, None) - - cache_key = self._hash_token(token) - entry = self._entries.get(cache_key) - - if entry is None: - return (False, None) - - if entry.expires_at < time.time(): - del self._entries[cache_key] - return (False, None) - - return (True, entry.result.model_copy(deep=True)) - - def set(self, token: str, result: AccessToken) -> None: - """Store a *successful* verification result. - - Only successful verifications should be cached. Failures (inactive - tokens, missing scopes, HTTP errors, timeouts) must **not** be cached - so that transient problems do not produce sticky false negatives. - """ - if not self.enabled: - return - - cache_key = self._hash_token(token) - - self._maybe_cleanup() - if cache_key not in self._entries: - self._enforce_size_limit() - - expires_at = time.time() + self._ttl - if result.expires_at: - expires_at = min(expires_at, float(result.expires_at)) - - self._entries[cache_key] = _CacheEntry( - result=result.model_copy(deep=True), - expires_at=expires_at, - ) - - # -- internals ----------------------------------------------------------- - - @staticmethod - def _hash_token(token: str) -> str: - """Return the SHA-256 hex digest of *token*.""" - return hashlib.sha256(token.encode("utf-8")).hexdigest() - - def _cleanup_expired(self) -> None: - """Remove all entries whose TTL has elapsed.""" - now = time.time() - expired = [k for k, v in self._entries.items() if v.expires_at < now] - for key in expired: - del self._entries[key] - if expired: - logger.debug("Cleaned up %d expired cache entries", len(expired)) - - def _maybe_cleanup(self) -> None: - """Run ``_cleanup_expired`` at most once per cleanup interval.""" - now = time.monotonic() - if now - self._last_cleanup > _CLEANUP_INTERVAL: - self._cleanup_expired() - self._last_cleanup = now - - def _enforce_size_limit(self) -> None: - """Ensure there is room for at least one new entry.""" - if len(self._entries) < self._max_size: - return - self._cleanup_expired() - if len(self._entries) >= self._max_size: - oldest_key = next(iter(self._entries)) - del self._entries[oldest_key] diff --git a/src/fastmcp/utilities/types.py b/src/fastmcp/utilities/types.py deleted file mode 100644 index 9980d7e62..000000000 --- a/src/fastmcp/utilities/types.py +++ /dev/null @@ -1,499 +0,0 @@ -"""Common types used across FastMCP.""" - -import base64 -import inspect -import mimetypes -import os -from collections.abc import Callable -from functools import lru_cache -from pathlib import Path -from types import EllipsisType, UnionType -from typing import ( - Annotated, - Any, - Protocol, - TypeAlias, - Union, - get_args, - get_origin, - get_type_hints, -) - -import mcp.types -from mcp.types import Annotations, ContentBlock, ModelPreferences, SamplingMessage -from pydantic import AnyUrl, BaseModel, ConfigDict, Field, TypeAdapter, UrlConstraints -from typing_extensions import TypeVar - -T = TypeVar("T", default=Any) - -# sentinel values for optional arguments -NotSet = ... -NotSetT: TypeAlias = EllipsisType - - -def get_fn_name(fn: Callable[..., Any]) -> str: - return fn.__name__ # ty: ignore[unresolved-attribute] - - -class FastMCPBaseModel(BaseModel): - """Base model for FastMCP models.""" - - model_config = ConfigDict(extra="forbid") - - -@lru_cache(maxsize=5000) -def get_cached_typeadapter(cls: T) -> TypeAdapter[T]: - """ - TypeAdapters are heavy objects, and in an application context we'd typically - create them once in a global scope and reuse them as often as possible. - However, this isn't feasible for user-generated functions. Instead, we use a - cache to minimize the cost of creating them as much as possible. - """ - # For functions, process annotations to handle forward references and convert - # Annotated[Type, "string"] to Annotated[Type, Field(description="string")] - if inspect.isfunction(cls) or inspect.ismethod(cls): - if hasattr(cls, "__annotations__") and cls.__annotations__: - try: - # Resolve forward references first - resolved_hints = get_type_hints(cls, include_extras=True) - except Exception: - # If forward reference resolution fails, use original annotations - resolved_hints = cls.__annotations__ - - # Process annotations to convert string descriptions to Fields - processed_hints = {} - - for name, annotation in resolved_hints.items(): - # Check if this is Annotated[Type, "string"] and convert to Annotated[Type, Field(description="string")] - if ( - get_origin(annotation) is Annotated - and len(get_args(annotation)) == 2 - and isinstance(get_args(annotation)[1], str) - ): - base_type, description = get_args(annotation) - processed_hints[name] = Annotated[ - base_type, Field(description=description) - ] - else: - processed_hints[name] = annotation - - # Create new function if annotations changed - if processed_hints != cls.__annotations__: - import types - - # Handle both functions and methods - if inspect.ismethod(cls): - actual_func = cls.__func__ - code = actual_func.__code__ # ty: ignore[unresolved-attribute] - globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute] - name = actual_func.__name__ # ty: ignore[unresolved-attribute] - defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute] - kwdefaults = actual_func.__kwdefaults__ # ty: ignore[unresolved-attribute] - closure = actual_func.__closure__ # ty: ignore[unresolved-attribute] - else: - code = cls.__code__ - globals_dict = cls.__globals__ - name = cls.__name__ - defaults = cls.__defaults__ - kwdefaults = cls.__kwdefaults__ - closure = cls.__closure__ - - new_func = types.FunctionType( - code, - globals_dict, - name, - defaults, - closure, - ) - new_func.__dict__.update(cls.__dict__) - new_func.__module__ = cls.__module__ - new_func.__qualname__ = getattr(cls, "__qualname__", cls.__name__) - new_func.__annotations__ = processed_hints - new_func.__kwdefaults__ = kwdefaults - - if inspect.ismethod(cls): - new_method = types.MethodType(new_func, cls.__self__) - return TypeAdapter(new_method) - else: - return TypeAdapter(new_func) - - return TypeAdapter(cls) - - -def issubclass_safe(cls: type, base: type) -> bool: - """Check if cls is a subclass of base, even if cls is a type variable.""" - try: - if origin := get_origin(cls): - return issubclass_safe(origin, base) - return issubclass(cls, base) - except TypeError: - return False - - -def is_class_member_of_type(cls: Any, base: type) -> bool: - """ - Check if cls is a member of base, even if cls is a type variable. - - Base can be a type, a UnionType, or an Annotated type. Generic types are not - considered members (e.g. T is not a member of list[T]). - """ - origin = get_origin(cls) - # Handle both types of unions: UnionType (from types module, used with | syntax) - # and typing.Union (used with Union[] syntax) - if origin is UnionType or origin == Union: - return any(is_class_member_of_type(arg, base) for arg in get_args(cls)) - elif origin is Annotated: - # For Annotated[T, ...], check if T is a member of base - args = get_args(cls) - if args: - return is_class_member_of_type(args[0], base) - return False - else: - return issubclass_safe(cls, base) - - -def find_kwarg_by_type(fn: Callable, kwarg_type: type) -> str | None: - """ - Find the name of the kwarg that is of type kwarg_type. - - Includes union types that contain the kwarg_type, as well as Annotated types. - """ - if inspect.ismethod(fn) and hasattr(fn, "__func__"): - fn = fn.__func__ - - # Try to get resolved type hints - try: - # Use include_extras=True to preserve Annotated metadata - type_hints = get_type_hints(fn, include_extras=True) - except Exception: - # If resolution fails, use raw annotations if they exist - type_hints = getattr(fn, "__annotations__", {}) - - sig = inspect.signature(fn) - for name, param in sig.parameters.items(): - # Use resolved hint if available, otherwise raw annotation - annotation = type_hints.get(name, param.annotation) - if is_class_member_of_type(annotation, kwarg_type): - return name - return None - - -def create_function_without_params( - fn: Callable[..., Any], exclude_params: list[str] -) -> Callable[..., Any]: - """ - Create a new function with the same code but without the specified parameters in annotations. - - This is used to exclude parameters from type adapter processing when they can't be serialized. - The excluded parameters are removed from the function's __annotations__ dictionary. - """ - import types - - if inspect.ismethod(fn): - actual_func = fn.__func__ - code = actual_func.__code__ # ty: ignore[unresolved-attribute] - globals_dict = actual_func.__globals__ # ty: ignore[unresolved-attribute] - name = actual_func.__name__ # ty: ignore[unresolved-attribute] - defaults = actual_func.__defaults__ # ty: ignore[unresolved-attribute] - closure = actual_func.__closure__ # ty: ignore[unresolved-attribute] - else: - code = fn.__code__ # ty: ignore[unresolved-attribute] - globals_dict = fn.__globals__ # ty: ignore[unresolved-attribute] - name = fn.__name__ # ty: ignore[unresolved-attribute] - defaults = fn.__defaults__ # ty: ignore[unresolved-attribute] - closure = fn.__closure__ # ty: ignore[unresolved-attribute] - - # Create a copy of annotations without the excluded parameters - original_annotations = getattr(fn, "__annotations__", {}) - new_annotations = { - k: v for k, v in original_annotations.items() if k not in exclude_params - } - - # Create new signature without the excluded parameters - sig = inspect.signature(fn) - new_params = [ - param for name, param in sig.parameters.items() if name not in exclude_params - ] - new_sig = inspect.Signature(new_params, return_annotation=sig.return_annotation) - - new_func = types.FunctionType( - code, - globals_dict, - name, - defaults, - closure, - ) - new_func.__dict__.update(fn.__dict__) - new_func.__module__ = fn.__module__ - new_func.__qualname__ = getattr(fn, "__qualname__", fn.__name__) # ty: ignore[unresolved-attribute] - new_func.__annotations__ = new_annotations - new_func.__signature__ = new_sig # type: ignore[attr-defined] # ty:ignore[unresolved-attribute] - - if inspect.ismethod(fn): - return types.MethodType(new_func, fn.__self__) - else: - return new_func - - -class Image: - """Helper class for returning images from tools.""" - - def __init__( - self, - path: str | Path | None = None, - data: bytes | None = None, - format: str | None = None, - annotations: Annotations | None = None, - ): - if path is None and data is None: - raise ValueError("Either path or data must be provided") - if path is not None and data is not None: - raise ValueError("Only one of path or data can be provided") - - self.path = self._get_expanded_path(path) - self.data = data - self._format = format - self._mime_type = self._get_mime_type() - self.annotations = annotations - - @staticmethod - def _get_expanded_path(path: str | Path | None) -> Path | None: - """Expand environment variables and user home in path.""" - return Path(os.path.expandvars(str(path))).expanduser() if path else None - - def _get_mime_type(self) -> str: - """Get MIME type from format or guess from file extension.""" - if self._format: - return f"image/{self._format.lower()}" - - if self.path: - # Workaround for WEBP in Py3.10 - mimetypes.add_type("image/webp", ".webp") - resp = mimetypes.guess_type(self.path, strict=False) - if resp and resp[0] is not None: - return resp[0] - return "application/octet-stream" - return "image/png" # default for raw binary data - - def _get_data(self) -> str: - """Get raw image data as base64-encoded string.""" - if self.path: - with open(self.path, "rb") as f: - data = base64.b64encode(f.read()).decode() - elif self.data is not None: - data = base64.b64encode(self.data).decode() - else: - raise ValueError("No image data available") - return data - - def to_image_content( - self, - mime_type: str | None = None, - annotations: Annotations | None = None, - ) -> mcp.types.ImageContent: - """Convert to MCP ImageContent.""" - data = self._get_data() - - return mcp.types.ImageContent( - type="image", - data=data, - mimeType=mime_type or self._mime_type, - annotations=annotations or self.annotations, - ) - - def to_data_uri(self, mime_type: str | None = None) -> str: - """Get image as a data URI.""" - data = self._get_data() - return f"data:{mime_type or self._mime_type};base64,{data}" - - -class Audio: - """Helper class for returning audio from tools.""" - - def __init__( - self, - path: str | Path | None = None, - data: bytes | None = None, - format: str | None = None, - annotations: Annotations | None = None, - ): - if path is None and data is None: - raise ValueError("Either path or data must be provided") - if path is not None and data is not None: - raise ValueError("Only one of path or data can be provided") - - self.path = Path(os.path.expandvars(str(path))).expanduser() if path else None - self.data = data - self._format = format - self._mime_type = self._get_mime_type() - self.annotations = annotations - - def _get_mime_type(self) -> str: - """Get MIME type from format or guess from file extension.""" - if self._format: - return f"audio/{self._format.lower()}" - - if self.path: - suffix = self.path.suffix.lower() - return { - ".wav": "audio/wav", - ".mp3": "audio/mpeg", - ".ogg": "audio/ogg", - ".m4a": "audio/mp4", - ".flac": "audio/flac", - }.get(suffix, "application/octet-stream") - return "audio/wav" # default for raw binary data - - def to_audio_content( - self, - mime_type: str | None = None, - annotations: Annotations | None = None, - ) -> mcp.types.AudioContent: - if self.path: - with open(self.path, "rb") as f: - data = base64.b64encode(f.read()).decode() - elif self.data is not None: - data = base64.b64encode(self.data).decode() - else: - raise ValueError("No audio data available") - - return mcp.types.AudioContent( - type="audio", - data=data, - mimeType=mime_type or self._mime_type, - annotations=annotations or self.annotations, - ) - - -class File: - """Helper class for returning file data from tools.""" - - def __init__( - self, - path: str | Path | None = None, - data: bytes | None = None, - format: str | None = None, - name: str | None = None, - annotations: Annotations | None = None, - ): - if path is None and data is None: - raise ValueError("Either path or data must be provided") - if path is not None and data is not None: - raise ValueError("Only one of path or data can be provided") - - self.path = Path(os.path.expandvars(str(path))).expanduser() if path else None - self.data = data - self._format = format - self._mime_type = self._get_mime_type() - self._name = name - self.annotations = annotations - - def _get_mime_type(self) -> str: - """Get MIME type from format or guess from file extension.""" - if self._format: - fmt = self._format.lower() - # Map common text formats to text/plain - if fmt in {"plain", "txt", "text"}: - return "text/plain" - return f"application/{fmt}" - - if self.path: - mime_type, _ = mimetypes.guess_type(self.path) - if mime_type: - return mime_type - - return "application/octet-stream" - - def to_resource_content( - self, - mime_type: str | None = None, - annotations: Annotations | None = None, - ) -> mcp.types.EmbeddedResource: - if self.path: - with open(self.path, "rb") as f: - raw_data = f.read() - uri_str = self.path.resolve().as_uri() - elif self.data is not None: - raw_data = self.data - if self._name: - uri_str = f"file:///{self._name}.{self._mime_type.split('/')[1]}" - else: - uri_str = f"file:///resource.{self._mime_type.split('/')[1]}" - else: - raise ValueError("No resource data available") - - mime = mime_type or self._mime_type - UriType = Annotated[AnyUrl, UrlConstraints(host_required=False)] - uri = TypeAdapter(UriType).validate_python(uri_str) - - if mime.startswith("text/"): - try: - text = raw_data.decode("utf-8") - except UnicodeDecodeError: - text = raw_data.decode("latin-1") - resource = mcp.types.TextResourceContents( - text=text, - mimeType=mime, - uri=uri, - ) - else: - data = base64.b64encode(raw_data).decode() - resource = mcp.types.BlobResourceContents( - blob=data, - mimeType=mime, - uri=uri, - ) - - return mcp.types.EmbeddedResource( - type="resource", - resource=resource, - annotations=annotations or self.annotations, - ) - - -def replace_type(type_, type_map: dict[type, type]): - """ - Given a (possibly generic, nested, or otherwise complex) type, replaces all - instances of old_type with new_type. - - This is useful for transforming types when creating tools. - - Args: - type_: The type to replace instances of old_type with new_type. - old_type: The type to replace. - new_type: The type to replace old_type with. - - Examples: - ```python - >>> replace_type(list[int | bool], {int: str}) - list[str | bool] - - >>> replace_type(list[list[int]], {int: str}) - list[list[str]] - ``` - """ - if type_ in type_map: - return type_map[type_] - - origin = get_origin(type_) - if not origin: - return type_ - - args = get_args(type_) - new_args = tuple(replace_type(arg, type_map) for arg in args) - - if origin is UnionType: - return Union[new_args] # noqa: UP007 - else: - return origin[new_args] - - -class ContextSamplingFallbackProtocol(Protocol): - async def __call__( - self, - messages: str | list[str | SamplingMessage], - system_prompt: str | None = None, - temperature: float | None = None, - max_tokens: int | None = None, - model_preferences: ModelPreferences | str | list[str] | None = None, - ) -> ContentBlock: ... diff --git a/src/fastmcp/utilities/ui.py b/src/fastmcp/utilities/ui.py deleted file mode 100644 index 8baacddf2..000000000 --- a/src/fastmcp/utilities/ui.py +++ /dev/null @@ -1,626 +0,0 @@ -""" -Shared UI utilities for FastMCP HTML pages. - -This module provides reusable HTML/CSS components for OAuth callbacks, -consent pages, and other user-facing interfaces. -""" - -from __future__ import annotations - -import html - -from starlette.responses import HTMLResponse - -# FastMCP branding -FASTMCP_LOGO_URL = "https://gofastmcp.com/assets/brand/blue-logo.png" - -# Base CSS styles shared across all FastMCP pages -BASE_STYLES = """ - * { - margin: 0; - padding: 0; - box-sizing: border-box; - } - - body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - margin: 0; - padding: 0; - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - background: #f9fafb; - color: #0a0a0a; - } - - .container { - background: #ffffff; - border: 1px solid #e5e7eb; - padding: 3rem 2.5rem; - border-radius: 1rem; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); - text-align: center; - max-width: 36rem; - margin: 1rem; - width: 100%; - } - - @media (max-width: 640px) { - .container { - padding: 2rem 1.5rem; - margin: 0.5rem; - } - } - - .logo { - width: 64px; - height: auto; - margin-bottom: 1.5rem; - display: block; - margin-left: auto; - margin-right: auto; - } - - h1 { - font-size: 1.5rem; - font-weight: 600; - margin-bottom: 1.5rem; - color: #111827; - } -""" - -# Button styles -BUTTON_STYLES = """ - .button-group { - display: flex; - gap: 0.75rem; - margin-top: 1.5rem; - justify-content: center; - } - - button { - padding: 0.75rem 2rem; - font-size: 0.9375rem; - font-weight: 500; - border-radius: 0.5rem; - border: none; - cursor: pointer; - transition: all 0.15s; - font-family: inherit; - } - - button:hover { - transform: translateY(-1px); - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1); - } - - .btn-approve, .btn-primary { - background: #10b981; - color: #ffffff; - min-width: 120px; - } - - .btn-deny, .btn-secondary { - background: #6b7280; - color: #ffffff; - min-width: 120px; - } -""" - -# Info box / message box styles -INFO_BOX_STYLES = """ - .info-box { - background: #f0f9ff; - border: 1px solid #bae6fd; - border-radius: 0.5rem; - padding: 1rem; - margin-bottom: 1.5rem; - text-align: left; - font-size: 0.9375rem; - line-height: 1.5; - color: #374151; - } - - .info-box p { - margin-bottom: 0.5rem; - } - - .info-box p:last-child { - margin-bottom: 0; - } - - .info-box.centered { - text-align: center; - } - - .info-box.error { - background: #fef2f2; - border-color: #fecaca; - color: #991b1b; - } - - .info-box strong { - color: #0ea5e9; - font-weight: 600; - } - - .info-box .server-name-link { - color: #0ea5e9; - text-decoration: underline; - font-weight: 600; - cursor: pointer; - transition: opacity 0.15s; - } - - .info-box .server-name-link:hover { - opacity: 0.8; - } - - /* Monospace info box - gray styling with code font */ - .info-box-mono { - background: #f9fafb; - border: 1px solid #e5e7eb; - border-radius: 0.5rem; - padding: 0.875rem; - margin: 1.25rem 0; - font-size: 0.875rem; - color: #6b7280; - font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace; - text-align: left; - } - - .info-box-mono.centered { - text-align: center; - } - - .info-box-mono.error { - background: #fef2f2; - border-color: #fecaca; - color: #991b1b; - } - - .info-box-mono strong { - color: #111827; - font-weight: 600; - } - - .warning-box { - background: #f0f9ff; - border: 1px solid #bae6fd; - border-radius: 0.5rem; - padding: 1rem; - margin-bottom: 1.5rem; - text-align: center; - } - - .warning-box p { - margin-bottom: 0.5rem; - line-height: 1.5; - color: #6b7280; - font-size: 0.9375rem; - } - - .warning-box p:last-child { - margin-bottom: 0; - } - - .warning-box strong { - color: #0ea5e9; - font-weight: 600; - } - - .warning-box a { - color: #0ea5e9; - text-decoration: underline; - font-weight: 600; - } - - .warning-box a:hover { - color: #0284c7; - text-decoration: underline; - } -""" - -# Status message styles (for success/error indicators) -STATUS_MESSAGE_STYLES = """ - .status-message { - display: flex; - align-items: center; - justify-content: center; - gap: 0.75rem; - margin-bottom: 1.5rem; - } - - .status-icon { - font-size: 1.5rem; - line-height: 1; - display: inline-flex; - align-items: center; - justify-content: center; - width: 2rem; - height: 2rem; - border-radius: 0.5rem; - flex-shrink: 0; - } - - .status-icon.success { - background: #10b98120; - } - - .status-icon.error { - background: #ef444420; - } - - .message { - font-size: 1.125rem; - line-height: 1.75; - color: #111827; - font-weight: 600; - text-align: left; - } -""" - -# Detail box styles (for key-value pairs) -DETAIL_BOX_STYLES = """ - .detail-box { - background: #f9fafb; - border: 1px solid #e5e7eb; - border-radius: 0.5rem; - padding: 1rem; - margin-bottom: 1.5rem; - text-align: left; - } - - .detail-row { - display: flex; - padding: 0.5rem 0; - border-bottom: 1px solid #e5e7eb; - } - - .detail-row:last-child { - border-bottom: none; - } - - .detail-label { - font-weight: 600; - min-width: 160px; - color: #6b7280; - font-size: 0.875rem; - flex-shrink: 0; - padding-right: 1rem; - } - - .detail-value { - flex: 1; - font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace; - font-size: 0.75rem; - color: #111827; - word-break: break-all; - overflow-wrap: break-word; - } -""" - -# Redirect section styles (for OAuth redirect URI box) -REDIRECT_SECTION_STYLES = """ - .redirect-section { - background: #fffbeb; - border: 1px solid #fcd34d; - border-radius: 0.5rem; - padding: 1rem; - margin-bottom: 1.5rem; - text-align: left; - } - - .redirect-section .label { - font-size: 0.875rem; - color: #6b7280; - font-weight: 600; - margin-bottom: 0.5rem; - display: block; - } - - .redirect-section .value { - font-family: 'SF Mono', 'Monaco', 'Consolas', 'Courier New', monospace; - font-size: 0.875rem; - color: #111827; - word-break: break-all; - margin-top: 0.25rem; - } -""" - -# Collapsible details styles -DETAILS_STYLES = """ - details { - margin-bottom: 1.5rem; - text-align: left; - } - - summary { - cursor: pointer; - font-size: 0.875rem; - color: #6b7280; - font-weight: 600; - list-style: none; - padding: 0.5rem; - border-radius: 0.25rem; - } - - summary:hover { - background: #f9fafb; - } - - summary::marker { - display: none; - } - - summary::before { - content: "▶"; - display: inline-block; - margin-right: 0.5rem; - transition: transform 0.2s; - font-size: 0.75rem; - } - - details[open] summary::before { - transform: rotate(90deg); - } -""" - -# Helper text styles -HELPER_TEXT_STYLES = """ - .close-instruction, .help-text { - font-size: 0.875rem; - color: #6b7280; - margin-top: 1.5rem; - } -""" - -# Tooltip styles for hover help -TOOLTIP_STYLES = """ - .help-link-container { - position: fixed; - bottom: 1.5rem; - right: 1.5rem; - font-size: 0.875rem; - } - - .help-link { - color: #6b7280; - text-decoration: none; - cursor: help; - position: relative; - display: inline-block; - border-bottom: 1px dotted #9ca3af; - } - - @media (max-width: 640px) { - .help-link { - background: #ffffff; - padding: 0.25rem 0.5rem; - border-radius: 0.25rem; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - } - } - - .help-link:hover { - color: #111827; - border-bottom-color: #111827; - } - - .help-link:hover .tooltip { - opacity: 1; - visibility: visible; - } - - .tooltip { - position: absolute; - bottom: 100%; - right: 0; - left: auto; - margin-bottom: 0.5rem; - background: #1f2937; - color: #ffffff; - padding: 0.75rem 1rem; - border-radius: 0.5rem; - font-size: 0.8125rem; - line-height: 1.5; - width: 280px; - max-width: calc(100vw - 3rem); - opacity: 0; - visibility: hidden; - transition: opacity 0.2s, visibility 0.2s; - box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1); - text-align: left; - } - - .tooltip::after { - content: ''; - position: absolute; - top: 100%; - right: 1rem; - border: 6px solid transparent; - border-top-color: #1f2937; - } - - .tooltip-link { - color: #60a5fa; - text-decoration: underline; - } -""" - - -def create_page( - content: str, - title: str = "FastMCP", - additional_styles: str = "", - csp_policy: str = "default-src 'none'; style-src 'unsafe-inline'; img-src https: data:; base-uri 'none'", -) -> str: - """ - Create a complete HTML page with FastMCP styling. - - Args: - content: HTML content to place inside the page - title: Page title - additional_styles: Extra CSS to include - csp_policy: Content Security Policy header value. - If empty string "", the CSP meta tag is omitted entirely. - - Returns: - Complete HTML page as string - """ - title = html.escape(title) - - # Only include CSP meta tag if policy is non-empty - csp_meta = ( - f'' - if csp_policy - else "" - ) - - return f""" - - - - - - {title} - - {csp_meta} - - - {content} - - - """ - - -def create_logo(icon_url: str | None = None, alt_text: str = "FastMCP") -> str: - """Create logo HTML. - - Args: - icon_url: Optional custom icon URL. If not provided, uses the FastMCP logo. - alt_text: Alt text for the logo image. - - Returns: - HTML for logo image tag. - """ - url = icon_url or FASTMCP_LOGO_URL - alt = html.escape(alt_text) - return f'' - - -def create_status_message(message: str, is_success: bool = True) -> str: - """ - Create a status message with icon. - - Args: - message: Status message text - is_success: True for success (✓), False for error (✕) - - Returns: - HTML for status message - """ - message = html.escape(message) - icon = "✓" if is_success else "✕" - icon_class = "success" if is_success else "error" - - return f""" -
- {icon} -
{message}
-
- """ - - -def create_info_box( - content: str, - is_error: bool = False, - centered: bool = False, - monospace: bool = False, -) -> str: - """ - Create an info box. - - Args: - content: HTML content for the info box - is_error: True for error styling, False for normal - centered: True to center the text, False for left-aligned - monospace: True to use gray monospace font styling instead of blue - - Returns: - HTML for info box - """ - content = html.escape(content) - base_class = "info-box-mono" if monospace else "info-box" - classes = [base_class] - if is_error: - classes.append("error") - if centered: - classes.append("centered") - class_str = " ".join(classes) - return f'
{content}
' - - -def create_detail_box(rows: list[tuple[str, str]]) -> str: - """ - Create a detail box with key-value pairs. - - Args: - rows: List of (label, value) tuples - - Returns: - HTML for detail box - """ - rows_html = "\n".join( - f""" -
-
{html.escape(label)}:
-
{html.escape(value)}
-
- """ - for label, value in rows - ) - - return f'
{rows_html}
' - - -def create_button_group(buttons: list[tuple[str, str, str]]) -> str: - """ - Create a group of buttons. - - Args: - buttons: List of (text, value, css_class) tuples - - Returns: - HTML for button group - """ - buttons_html = "\n".join( - f'' - for text, value, css_class in buttons - ) - - return f'
{buttons_html}
' - - -def create_secure_html_response(html: str, status_code: int = 200) -> HTMLResponse: - """ - Create an HTMLResponse with security headers. - - Adds X-Frame-Options: DENY to prevent clickjacking attacks per MCP security best practices. - - Args: - html: HTML content to return - status_code: HTTP status code - - Returns: - HTMLResponse with security headers - """ - return HTMLResponse( - content=html, - status_code=status_code, - headers={"X-Frame-Options": "DENY"}, - ) diff --git a/src/fastmcp/utilities/version_check.py b/src/fastmcp/utilities/version_check.py deleted file mode 100644 index 5c3aba634..000000000 --- a/src/fastmcp/utilities/version_check.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Version checking utilities for FastMCP.""" - -from __future__ import annotations - -import json -import time -from pathlib import Path - -import httpx -from packaging.version import Version - -from fastmcp.utilities.logging import get_logger - -logger = get_logger(__name__) - -PYPI_URL = "https://pypi.org/pypi/fastmcp/json" -CACHE_TTL_SECONDS = 60 * 60 * 12 # 12 hours -REQUEST_TIMEOUT_SECONDS = 2.0 - - -def _get_cache_path(include_prereleases: bool = False) -> Path: - """Get the path to the version cache file.""" - import fastmcp - - suffix = "_prerelease" if include_prereleases else "" - return fastmcp.settings.home / f"version_cache{suffix}.json" - - -def _read_cache(include_prereleases: bool = False) -> tuple[str | None, float]: - """Read cached version info. - - Returns: - Tuple of (cached_version, cache_timestamp) or (None, 0) if no cache. - """ - cache_path = _get_cache_path(include_prereleases) - if not cache_path.exists(): - return None, 0 - - try: - data = json.loads(cache_path.read_text()) - return data.get("latest_version"), data.get("timestamp", 0) - except (json.JSONDecodeError, OSError): - return None, 0 - - -def _write_cache(latest_version: str, include_prereleases: bool = False) -> None: - """Write version info to cache.""" - cache_path = _get_cache_path(include_prereleases) - try: - cache_path.parent.mkdir(parents=True, exist_ok=True) - cache_path.write_text( - json.dumps({"latest_version": latest_version, "timestamp": time.time()}) - ) - except OSError: - # Silently ignore cache write failures - pass - - -def _fetch_latest_version(include_prereleases: bool = False) -> str | None: - """Fetch the latest version from PyPI. - - Args: - include_prereleases: If True, include pre-release versions (alpha, beta, rc). - - Returns: - The latest version string, or None if the fetch failed. - """ - try: - response = httpx.get(PYPI_URL, timeout=REQUEST_TIMEOUT_SECONDS) - response.raise_for_status() - data = response.json() - - releases = data.get("releases", {}) - if not releases: - return None - - versions = [] - for version_str in releases: - try: - v = Version(version_str) - # Skip prereleases if not requested - if not include_prereleases and v.is_prerelease: - continue - versions.append(v) - except ValueError: - logger.debug(f"Skipping invalid version string: {version_str}") - continue - - if not versions: - return None - - return str(max(versions)) - - except (httpx.HTTPError, json.JSONDecodeError, KeyError): - return None - - -def get_latest_version(include_prereleases: bool = False) -> str | None: - """Get the latest version of FastMCP from PyPI, using cache when available. - - Args: - include_prereleases: If True, include pre-release versions. - - Returns: - The latest version string, or None if unavailable. - """ - # Check cache first - cached_version, cache_timestamp = _read_cache(include_prereleases) - if cached_version and (time.time() - cache_timestamp) < CACHE_TTL_SECONDS: - return cached_version - - # Fetch from PyPI - latest_version = _fetch_latest_version(include_prereleases) - - # Update cache if we got a valid version - if latest_version: - _write_cache(latest_version, include_prereleases) - return latest_version - - # Return stale cache if available - return cached_version - - -def check_for_newer_version() -> str | None: - """Check if a newer version of FastMCP is available. - - Returns: - The latest version string if newer than current, None otherwise. - """ - import fastmcp - - setting = fastmcp.settings.check_for_updates - if setting == "off": - return None - - include_prereleases = setting == "prerelease" - latest_version = get_latest_version(include_prereleases) - if not latest_version: - return None - - try: - current = Version(fastmcp.__version__) - latest = Version(latest_version) - - if latest > current: - return latest_version - except ValueError: - logger.debug( - f"Could not compare versions: current={fastmcp.__version__!r}, " - f"latest={latest_version!r}" - ) - - return None diff --git a/src/fastmcp/utilities/versions.py b/src/fastmcp/utilities/versions.py deleted file mode 100644 index a16389fe3..000000000 --- a/src/fastmcp/utilities/versions.py +++ /dev/null @@ -1,333 +0,0 @@ -"""Version comparison utilities for component versioning. - -This module provides utilities for comparing component versions. Versions are -strings that are first attempted to be parsed as PEP 440 versions (using the -`packaging` library), falling back to lexicographic string comparison. - -Examples: - - "1", "2", "10" → parsed as PEP 440, compared semantically (1 < 2 < 10) - - "1.0", "2.0" → parsed as PEP 440 - - "v1.0" → 'v' prefix stripped, parsed as "1.0" - - "2025-01-15" → not valid PEP 440, compared as strings - - None → sorts lowest (unversioned components) -""" - -from __future__ import annotations - -from collections.abc import Callable, Sequence -from dataclasses import dataclass -from functools import total_ordering -from typing import TYPE_CHECKING, Any, TypeVar, cast - -from packaging.version import InvalidVersion, Version - -if TYPE_CHECKING: - from fastmcp.utilities.components import FastMCPComponent - -C = TypeVar("C", bound=Any) - - -@dataclass -class VersionSpec: - """Specification for filtering components by version. - - Used by transforms and providers to filter components to a specific - version or version range. Unversioned components (version=None) always - match any spec. - - Args: - gte: If set, only versions >= this value match. - lt: If set, only versions < this value match. - eq: If set, only this exact version matches (gte/lt ignored). - """ - - gte: str | None = None - lt: str | None = None - eq: str | None = None - - def matches(self, version: str | None, *, match_none: bool = True) -> bool: - """Check if a version matches this spec. - - Args: - version: The version to check, or None for unversioned. - match_none: Whether unversioned (None) components match. Defaults to True - for backward compatibility with retrieval operations. Set to False - when filtering (e.g., enable/disable) to exclude unversioned components - from version-specific rules. - - Returns: - True if the version matches the spec. - """ - if version is None: - return match_none - - if self.eq is not None: - return version == self.eq - - key = parse_version_key(version) - - if self.gte is not None: - gte_key = parse_version_key(self.gte) - if key < gte_key: - return False - - if self.lt is not None: - lt_key = parse_version_key(self.lt) - if not key < lt_key: - return False - - return True - - def intersect(self, other: VersionSpec | None) -> VersionSpec: - """Return a spec that satisfies both this spec and other. - - Used by transforms to combine caller constraints with filter constraints. - For example, if a VersionFilter has lt="3.0" and caller requests eq="1.0", - the intersection validates "1.0" is in range and returns the exact spec. - - Args: - other: Another spec to intersect with, or None. - - Returns: - A VersionSpec that matches only versions satisfying both specs. - """ - if other is None: - return self - - if self.eq is not None: - # This spec wants exact - validate against other's range - if other.matches(self.eq): - return self - return VersionSpec(eq="__impossible__") - - if other.eq is not None: - # Other wants exact - validate against our range - if self.matches(other.eq): - return other - return VersionSpec(eq="__impossible__") - - # Both are ranges - take tighter bounds - return VersionSpec( - gte=max_version(self.gte, other.gte), - lt=min_version(self.lt, other.lt), - ) - - -@total_ordering -class VersionKey: - """A comparable version key that handles None, PEP 440 versions, and strings. - - Comparison order: - 1. None (unversioned) sorts lowest - 2. PEP 440 versions sort by semantic version order - 3. Invalid versions (strings) sort lexicographically - 4. When comparing PEP 440 vs string, PEP 440 comes first - """ - - __slots__ = ("_is_none", "_is_pep440", "_parsed", "_raw") - - def __init__(self, version: str | None) -> None: - self._raw = version - self._is_none = version is None - self._is_pep440 = False - self._parsed: Version | str | None = None - - if version is not None: - # Strip leading 'v' if present (common convention like "v1.0") - normalized = version.lstrip("v") if version.startswith("v") else version - try: - self._parsed = Version(normalized) - self._is_pep440 = True - except InvalidVersion: - # Fall back to string comparison for non-PEP 440 versions - self._parsed = version - - def __eq__(self, other: object) -> bool: - if not isinstance(other, VersionKey): - return NotImplemented - if self._is_none and other._is_none: - return True - if self._is_none != other._is_none: - return False - # Both are not None - if self._is_pep440 and other._is_pep440: - return self._parsed == other._parsed - if not self._is_pep440 and not other._is_pep440: - return self._parsed == other._parsed - # One is PEP 440, other is string - never equal - return False - - def __lt__(self, other: object) -> bool: - if not isinstance(other, VersionKey): - return NotImplemented - # None sorts lowest - if self._is_none and other._is_none: - return False # Equal - if self._is_none: - return True # None < anything - if other._is_none: - return False # anything > None - - # Both are not None - if self._is_pep440 and other._is_pep440: - # Both PEP 440 - compare normally - assert isinstance(self._parsed, Version) - assert isinstance(other._parsed, Version) - return self._parsed < other._parsed - if not self._is_pep440 and not other._is_pep440: - # Both strings - lexicographic - assert isinstance(self._parsed, str) - assert isinstance(other._parsed, str) - return self._parsed < other._parsed - # Mixed: PEP 440 sorts before strings - # (arbitrary but consistent choice) - return self._is_pep440 - - def __repr__(self) -> str: - return f"VersionKey({self._raw!r})" - - -def parse_version_key(version: str | None) -> VersionKey: - """Parse a version string into a sortable key. - - Args: - version: The version string, or None for unversioned. - - Returns: - A VersionKey suitable for sorting. - """ - return VersionKey(version) - - -def version_sort_key(component: FastMCPComponent) -> VersionKey: - """Get a sort key for a component based on its version. - - Use with sorted() or max() to order components by version. - - Args: - component: The component to get a sort key for. - - Returns: - A sortable VersionKey. - - Example: - ```python - tools = [tool_v1, tool_v2, tool_unversioned] - highest = max(tools, key=version_sort_key) # Returns tool_v2 - ``` - """ - return parse_version_key(component.version) - - -def compare_versions(a: str | None, b: str | None) -> int: - """Compare two version strings. - - Args: - a: First version string (or None). - b: Second version string (or None). - - Returns: - -1 if a < b, 0 if a == b, 1 if a > b. - - Example: - ```python - compare_versions("1.0", "2.0") # Returns -1 - compare_versions("2.0", "1.0") # Returns 1 - compare_versions(None, "1.0") # Returns -1 (None < any version) - ``` - """ - key_a = parse_version_key(a) - key_b = parse_version_key(b) - return (key_a > key_b) - (key_a < key_b) - - -def is_version_greater(a: str | None, b: str | None) -> bool: - """Check if version a is greater than version b. - - Args: - a: First version string (or None). - b: Second version string (or None). - - Returns: - True if a > b, False otherwise. - """ - return compare_versions(a, b) > 0 - - -def max_version(a: str | None, b: str | None) -> str | None: - """Return the greater of two versions. - - Args: - a: First version string (or None). - b: Second version string (or None). - - Returns: - The greater version, or None if both are None. - """ - if a is None: - return b - if b is None: - return a - return a if compare_versions(a, b) >= 0 else b - - -def min_version(a: str | None, b: str | None) -> str | None: - """Return the lesser of two versions. - - Args: - a: First version string (or None). - b: Second version string (or None). - - Returns: - The lesser version, or None if both are None. - """ - if a is None: - return b - if b is None: - return a - return a if compare_versions(a, b) <= 0 else b - - -def dedupe_with_versions( - components: Sequence[C], - key_fn: Callable[[C], str], -) -> list[C]: - """Deduplicate components by key, keeping highest version. - - Groups components by key, selects the highest version from each group, - and injects available versions into meta if any component is versioned. - - Args: - components: Sequence of components to deduplicate. - key_fn: Function to extract the grouping key from a component. - - Returns: - Deduplicated list with versions injected into meta. - """ - by_key: dict[str, list[C]] = {} - for c in components: - by_key.setdefault(key_fn(c), []).append(c) - - result: list[C] = [] - for versions in by_key.values(): - highest: C = cast(C, max(versions, key=version_sort_key)) - if any(c.version is not None for c in versions): - all_versions = sorted( - [c.version for c in versions if c.version is not None], - key=parse_version_key, - reverse=True, - ) - meta = highest.meta or {} - highest = highest.model_copy( - update={ - "meta": { - **meta, - "fastmcp": { - **meta.get("fastmcp", {}), - "versions": all_versions, - }, - } - } - ) - result.append(highest) - return result diff --git a/tests/server/test_run_server.py b/tests/server/test_run_server.py deleted file mode 100644 index 65e3112ac..000000000 --- a/tests/server/test_run_server.py +++ /dev/null @@ -1,98 +0,0 @@ -# from pathlib import Path -# from typing import TYPE_CHECKING, Any - -# import pytest - -# import fastmcp -# from fastmcp import FastMCP - -# if TYPE_CHECKING: -# pass - -# USERS = [ -# {"id": "1", "name": "Alice", "active": True}, -# {"id": "2", "name": "Bob", "active": True}, -# {"id": "3", "name": "Charlie", "active": False}, -# ] - - -# @pytest.fixture -# def fastmcp_server(): -# server = FastMCP("TestServer") - -# # --- Tools --- - -# @server.tool -# def greet(name: str) -> str: -# """Greet someone by name.""" -# return f"Hello, {name}!" - -# @server.tool -# def add(a: int, b: int) -> int: -# """Add two numbers together.""" -# return a + b - -# @server.tool -# def error_tool(): -# """This tool always raises an error.""" -# raise ValueError("This is a test error") - -# # --- Resources --- - -# @server.resource(uri="resource://wave") -# def wave() -> str: -# return "👋" - -# @server.resource(uri="data://users") -# async def get_users() -> list[dict[str, Any]]: -# return USERS - -# @server.resource(uri="data://user/{user_id}") -# async def get_user(user_id: str) -> dict[str, Any] | None: -# return next((user for user in USERS if user["id"] == user_id), None) - -# # --- Prompts --- - -# @server.prompt -# def welcome(name: str) -> str: -# return f"Welcome to FastMCP, {name}!" - -# return server - - -# @pytest.fixture -# async def stdio_client(): -# # Find the stdio.py script path -# base_dir = Path(__file__).parent -# stdio_script = base_dir / "test_servers" / "stdio.py" - -# if not stdio_script.exists(): -# raise FileNotFoundError(f"Could not find stdio.py script at {stdio_script}") - -# client = fastmcp.Client( -# transport=fastmcp.client.transports.StdioTransport( -# command="python", -# args=[str(stdio_script)], -# ) -# ) - -# async with client: -# print("READY") -# yield client -# print("DONE") - - -# class TestRunServerStdio: -# async def test_run_server_stdio( -# self, fastmcp_server: FastMCP, stdio_client: fastmcp.Client -# ): -# print("TEST") -# tools = await stdio_client.list_tools() -# print("TEST 2") -# assert tools == 1 - - -# class TestRunServerSSE: -# -# async def test_run_server_sse(self, fastmcp_server: FastMCP): -# pass