diff --git a/.github/workflows/martian-test-failure.yml b/.github/workflows/martian-test-failure.yml index 5d9f7d4ae..e693c589d 100644 --- a/.github/workflows/martian-test-failure.yml +++ b/.github/workflows/martian-test-failure.yml @@ -60,6 +60,17 @@ jobs: 2. Identify the root cause of the failure(s) 3. Suggest a clear, actionable solution to fix the failure(s) + # Response Proportionality + Match your response length to the complexity of the failure. Not every failure needs a full investigation: + + **Trivial failures** (formatting, linting) — post a short, direct comment. No collapsible sections, no root-cause deep-dive. Example: + > CI failed: `ruff format` reformatted 2 files. Run `uv run ruff format .` locally and push. + + **Pre-existing flaky tests** unrelated to the PR — say so briefly. Don't write a full analysis of a test the PR didn't touch. Example: + > CI failed due to a pre-existing flaky test (`test_name`) unrelated to this PR's changes. Safe to re-run. + + **Real failures caused by the PR** — these deserve the full analysis format below. Spend your effort here. + # Getting Started 1. Call the generate_agents_md tool to get a high-level summary of the project 2. Get the pull request associated with this workflow run from the GitHub repository: ${{ github.repository }} @@ -75,60 +86,61 @@ jobs: 5. Search the codebase for relevant files, tests, and implementations # Your Response - Post a comment on the pull request with your analysis. Your comment should include: + Post a comment on the pull request with your analysis. - ## Test Failure Analysis + Lead with a tl;dr — 1-2 sentences that tell the developer what broke and what to do about it. This should be visible without expanding anything. - **Summary**: A brief 1-2 sentence summary of what failed. + Push supporting detail into collapsible `
` blocks. The reader should be able to act on your comment without expanding a single one. Think of details blocks as appendices — there if someone wants to dig deeper, not required for the main message. - **Root Cause**: A clear explanation of why the tests failed, based on your analysis of the logs and code. + For real (non-trivial) failures, use this structure: - **Suggested Solution**: Specific, actionable steps to fix the failure(s). Include: - - Which files need to be modified - - What changes are needed - - Why these changes will fix the issue + **tl;dr**: What failed and what to do (1-2 sentences, always visible) + + **Root Cause**: Why it failed (a short paragraph, always visible) + + **Fix**: Specific files and changes needed (always visible)
- Detailed Analysis - - Include here: - - Relevant log excerpts showing the failure - - Code snippets that are causing the issue - - Any related issues or PRs that might be relevant + Log excerpts + Relevant failure output
- Related Files - - List files that are relevant to the failure with brief explanations of their relevance. + Related files + Files relevant to the failure
- # Important Guidelines - - Be concise and actionable - developers want to quickly understand and fix the issue. Provide - additional context, references, etc in collapsible details blocks to ensure that the comment you're adding - is short and easy to read but additional information is a click away. - - Focus on facts from the logs and code, not speculation - - If you can't determine the root cause, say so clearly - - If your only suggestion is a bad suggestion (disable the test, change the timeout, etc), indicate that you've run out of ideas and - that they probably don't want to do that. - - Provide specific file names, line numbers, and code references when possible - - You can run make commands (e.g., `make lint`, `make typecheck`, `make sync`) to build, test, or lint the code - - You can also run git commands (e.g., `git status`, `git log`, `git diff`) to inspect the repository - - You can use WebSearch and WebFetch to research errors, stack traces, or related issues - - For bash commands, you are limited to make and git commands only + # Quality Standards + - Every claim needs evidence: file paths, line numbers, log excerpts. Never say "the test fails" without citing which test and what the error was. + - Focus on facts from the logs and code, not speculation. If you can't determine the root cause, say so clearly — "I don't know" is better than a wrong diagnosis. + - If your only suggestion is a bad one (disable the test, increase the timeout, etc.), say so honestly rather than dressing it up. + - Do not paste raw CLI output (e.g., prek progress bars, pytest collection output) into the comment body. Quote only the relevant failure lines. + - Always include specific file names, tool names, and test names in your summary. Never leave a sentence with a blank where a name should be. - # CRITICAL: ANGRY USERS - **IMPORTANT**: If the user is angry with you, the triage bot, don't respond. Just exit immediately without further action. - If at any point in the conversation the user has asked you to stop replying to the thread, just exit immediately. + # Self-Review Before Posting + Before posting your comment, re-read it as the PR author would. Ask: + - Can I act on this without expanding any `
` block? + - Does every claim cite a specific file, line, or log excerpt? + - Am I telling them something they can't already see in the CI logs, or just restating them? + If your comment doesn't add value beyond what the logs already show, don't post it. + + # STOP SIGNALS + If anyone on the PR has asked the bot to stop — e.g., "stop", "go away", "don't comment", "no more bot comments" — exit immediately without further action. This includes past comments in the thread, not just the most recent one. If you are posting the same suggestion as you have previously made, do not post the suggestion again. # IMPORTANT: EDIT YOUR COMMENT Do not post a new comment every time you triage a failing workflow. If a previous comment has been posted by you (marvin) in a previous triage, edit that comment do not add a new comment for each failure. Be sure to include a note that you've edited - your comment to reflect the latest analysis. Don't worry about keeping the old content around, there's comment history for + your comment to reflect the latest analysis. Don't worry about keeping the old content around, there's comment history for that. + # Available Tools + - You can run make commands (e.g., `make lint`, `make typecheck`, `make sync`) to build, test, or lint the code + - You can also run git commands (e.g., `git status`, `git log`, `git diff`) to inspect the repository + - You can use WebSearch and WebFetch to research errors, stack traces, or related issues + - For bash commands, you are limited to make and git commands only + # Problems Encountered If you encounter any problems during your analysis (e.g., unable to fetch logs, tools not working), document them clearly so the team knows what limitations you faced. PROMPT_END diff --git a/.github/workflows/martian-triage-issue.yml b/.github/workflows/martian-triage-issue.yml index cb5c8b55d..2789bd7be 100644 --- a/.github/workflows/martian-triage-issue.yml +++ b/.github/workflows/martian-triage-issue.yml @@ -119,8 +119,26 @@ jobs: 2. Layout a single high-quality and actionable recommendation for how to address the issue based on your knowledge of the project, codebase, and issue 3. Provide a high quality and detailed plan that a junior developer could follow to implement the recommendation 4. Use execution to verify findings when appropriate (check `` section for available commands) + + Report findings and recommendations — not your process. Do not include task checklists, progress tracking, or "steps I took" narration (e.g., `- [x] Read source code`). The reader cares about what you found, not how you found it. + + Every claim in your response must be grounded in evidence you can cite: + - **Code references**: Always include file path and line number (e.g., `fastmcp_slim/fastmcp/client/client.py:142`). Never say "the client code does X" without pointing to where. + - **Bug confirmation**: If you say a bug is real, show the specific code path that produces it. If you ran a test, include the command and output. + - **Related items**: When citing a related issue or PR, explain specifically why it's related — not just that it exists. + - **Confidence**: If you're uncertain about a finding, say so. "I don't know" or "I couldn't confirm this" is better than a speculative diagnosis. Only report findings you would confidently defend. + + + + Before posting, re-read your response as a maintainer would: + - Does the tl;dr give the full picture without expanding anything? + - Does every claim cite a specific file, line, or test result? + - Is this telling the maintainer something they couldn't find in 5 minutes of reading the issue and grepping the code? + If your response doesn't add meaningful value beyond restating the issue, it's okay to post a short "confirmed, straightforward fix in [file]:[line]" response instead of a full analysis. + + Populate the following sections in your response: Recommendation (or "No recommendation" with reason) @@ -133,12 +151,17 @@ jobs: You may not be able to do all of these things, sometimes you may find that all you can do is provide in-depth context of the issue and related items. That's perfectly acceptable and expected. Your performance is judged by how accurate your findings are, do the investigation required to have high confidence in your findings and recommendations. "I don't know" or "I'm unable to recommend a course of action" is better than a bad or wrong answer. - When formulating your response, you will never "bury the lede", you will always provide a clear and concise tl;dr as the first thing in your response. As your response grows in length you can organize the more detailed parts of your response collapsible sections using
and tags. You shouldn't put everything in collapsible sections, especially if the response is short. Use your discretion to determine when to use collapsible sections to avoid overwhelming the reader with too much detail -- think of them like an appendix that can be expanded if the reader is interested. + Structure: Lead with a tl;dr (1-3 sentences, always visible) that gives the reader the bottom line — what this issue is, whether it's valid, and what to do about it. The reader should be able to act on your comment without expanding anything. + + Push everything else into collapsible `
` blocks: findings, verification output, action plans, related items, related files. These are appendices — valuable for someone who wants to dig deeper, but not required for the main message. The only things that should be visible without clicking are the tl;dr and the recommendation. Short responses (a few sentences) don't need collapsible sections at all. - # Example output for "Recommendation" part of the response - PR #654 already implements the requested feature but is incomplete. The Pull Request is not in a mergeable state yet, the remaining work should be completed: 1) update the Calculator.divide method to utilize the new DivisionByZeroError or the safe_divide function, and 2) update the tests to ensure that the Calculator.divide method raises the new DivisionByZeroError when the divisor is 0. + # Example: the tl;dr and recommendation are always visible, everything else is collapsed + + **tl;dr**: Confirmed bug — `Calculator.divide` raises `ValueError` instead of `DivisionByZeroError`. PR #654 partially addresses this but is incomplete. + + **Recommendation**: Complete PR #654: update `Calculator.divide` to raise `DivisionByZeroError` and update the test assertions to match.
Findings @@ -147,7 +170,7 @@ jobs:
Verification - I ran the existing tests (if execution commands are available in ``) and confirmed the current behavior: + ```bash $ pytest test_calculator.py::test_divide_by_zero FAILED - raises ValueError instead of DivisionByZeroError @@ -156,36 +179,25 @@ jobs:
- Detailed Action Plan + Action Plan ...a detailed plan that a junior developer could follow to implement the recommendation...
- # Example Output for "Related Items" part of the response -
Related Issues and Pull Requests - | Repository | Issue or PR | Relevance | - | --- | --- | --- | - | PrefectHQ/fastmcp | [Add matrix operations support](https://github.com/PrefectHQ/fastmcp/pull/680) | This pull request directly addresses the feature request for adding matrix operations to the calculator. | - | PrefectHQ/fastmcp | [Add matrix operations support](https://github.com/PrefectHQ/fastmcp/issues/681) | This issue directly addresses the feature request for adding matrix operations to the calculator. | + | Issue or PR | Relevance | + | --- | --- | + | [Add matrix operations support](https://github.com/PrefectHQ/fastmcp/pull/680) | Directly addresses the feature request |
Related Files - | Repository | File | Relevance | Sections | - | --- | --- | --- | --- | - | modelcontextprotocol/python-sdk | [test_calculator.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/test_calculator.py) | This file contains the test cases for the Calculator class, including a test that specifically asserts a ValueError is raised for division by zero, confirming the current intended behavior. | [25-27](https://github.com/modelcontextprotocol/python-sdk/blob/main/test_calculator.py#L25-L27) | - | modelcontextprotocol/python-sdk | [calculator.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/calculator.py) | This file contains the implementation of the Calculator class, specifically the `divide` method which raises the ValueError when dividing by zero, matching the bug report. | [29-32](https://github.com/modelcontextprotocol/python-sdk/blob/main/calculator.py#L29-L32) | -
- -
- Related Webpages - - | Name | URL | Relevance | - | --- | --- | --- | - | Handling Division by Zero Best Practices | https://my-blog-about-division-by-zero.com/handling+division+by+zero+in+calculator | This webpage provides general best practices for handling division by zero in calculator applications and in Python, which is directly relevant to the issue and potential solutions. | + | File | Relevance | + | --- | --- | + | [calculator.py L29-32](https://github.com/modelcontextprotocol/python-sdk/blob/main/calculator.py#L29-L32) | The `divide` method that raises ValueError | + | [test_calculator.py L25-27](https://github.com/modelcontextprotocol/python-sdk/blob/main/test_calculator.py#L25-L27) | Test asserting ValueError (needs updating) |
@@ -202,4 +214,5 @@ jobs: When writing GitHub comments, wrap branch names, tags, or other @-references in backticks (e.g., `@main`, `@v1.0`) to avoid accidentally pinging users. Do not add backticks around terms that are already inside backticks or code blocks. + Do not write `fixes #N`, `closes #N`, or `resolves #N` in comments — these can accidentally close issues. Use plain `#N` references instead. diff --git a/.github/workflows/marvin-comment-on-issue.yml b/.github/workflows/marvin-comment-on-issue.yml index 8029d4ab9..f70928a5d 100644 --- a/.github/workflows/marvin-comment-on-issue.yml +++ b/.github/workflows/marvin-comment-on-issue.yml @@ -75,9 +75,7 @@ jobs: - You CAN: Read/analyze code, modify files, write code, run tests, execute commands - You CAN: Commit code, push changes, create branches, create pull requests - + You CAN: Read/analyze code, modify files, write code, run tests, execute commands, commit code, push changes, create branches, create pull requests @@ -107,20 +105,30 @@ jobs: - Answer questions about the codebase - - Help debug reported problems (make changes locally to test, cannot push) + - Help debug reported problems - Suggest solutions or workarounds - Provide code examples - Help clarify requirements - Link to relevant documentation or code + - Create branches, commit changes, and open PRs when asked - - Be concise and actionable - - If the request is unclear, ask clarifying questions - - If the request requires actions you cannot perform (like pushing changes), explain what you can and cannot do - - When making code changes, explain that they are local only and cannot be pushed + - Lead with a tl;dr — the bottom line in 1-3 sentences, always visible. The reader should be able to act without expanding anything. + - Push supporting detail (code analysis, verification output, related items) into collapsible `
` blocks. These are appendices, not the main message. + - Short responses (a few sentences) don't need collapsible sections at all. + - Be concise and actionable. + - If the request is unclear, ask clarifying questions. + - Report findings and recommendations — not your process. Do not include task checklists or "steps I took" narration. + - Every claim needs evidence: cite file paths, line numbers, or command output. Never say "the code does X" without pointing to where. + - If you're uncertain, say so. "I couldn't confirm this" is better than a speculative answer. + + - Do not write `fixes #N`, `closes #N`, or `resolves #N` in comments — these can accidentally close issues. + - When referencing issues, use plain `#N` or link syntax without action keywords. + + Always end your comment with a new line, three dashes, and the footer message: diff --git a/.github/workflows/marvin-comment-on-pr.yml b/.github/workflows/marvin-comment-on-pr.yml index 9e4e4fd9d..d927c8059 100644 --- a/.github/workflows/marvin-comment-on-pr.yml +++ b/.github/workflows/marvin-comment-on-pr.yml @@ -98,12 +98,10 @@ jobs: - This workflow allows read, write, and execute capabilities but cannot push changes. + You CAN: Read/analyze code, modify files, write code, run tests, execute commands, resolve review threads, commit and push changes to the PR branch, checkout branches + You CANNOT: Create new branches unrelated to this PR, create new pull requests - You CAN: Read/analyze code, modify files, write code, run tests, execute commands, resolve review threads - You CANNOT: Commit code, push changes, create branches, checkout branches, create pull requests - - **Important**: You cannot push changes to the repository - you can only make changes locally and provide feedback or recommendations. + When making changes, commit and push to the PR's head branch so the author gets the fix directly. @@ -132,10 +130,10 @@ jobs: - - Address review feedback and fix issues (make changes locally, cannot push) + - Address review feedback and fix issues (commit and push to the PR branch) - Answer questions about the changes - - Make additional code changes (local only) - - Resolve review threads after addressing feedback (if changes are made separately) + - Make code changes and push them + - Resolve review threads after addressing feedback - Perform PR reviews when asked (use the PR review process below) @@ -224,6 +222,25 @@ jobs: 6. Breaking changes to public APIs without migration path 7. Missing or incorrect test coverage for critical paths + + + **What NOT to flag** — do not comment on: + - Issues in unchanged code (only review the diff) + - Input already validated or sanitized at a different layer + - Theoretical performance concerns without evidence that N is large + - Style or formatting not in the project's linting rules + - Missing tests for trivial or generated code + - Pre-existing patterns the PR is following consistently + + **Calibration examples**: + - Unguarded return from a lookup (e.g., `tool = registry.get(name)` used without None check) → FLAG if the diff introduces the unguarded usage + - Same pattern, but the function's return type is `Tool` (not `Optional[Tool]`) → DO NOT FLAG, the type system guarantees non-None + - String interpolation in a query with user input → FLAG + - String interpolation in a query with a hardcoded enum value → DO NOT FLAG + - O(n²) loop → FLAG only if there's evidence N can be large (e.g., user-controlled list). If N is bounded by design (e.g., number of MCP tools), do not flag. + + When in doubt, do not flag. A false positive wastes a reviewer's time and erodes trust in every future review comment. + @@ -244,14 +261,18 @@ jobs: - `THREAD_ID` is the GraphQL node ID from the review threads output (e.g., `PRRT_kwDOABC123`) - The comment is optional - use it to explain what you did - Note: Since you cannot push changes, you can resolve threads to acknowledge feedback, but actual fixes would need to be applied separately. + Note: You can resolve threads after pushing fixes, or resolve them to acknowledge feedback that will be addressed separately. - - Be concise and actionable - - If the request is unclear, ask clarifying questions - - If the request requires actions you cannot perform (like pushing changes), explain what you can and cannot do - - When making code changes, explain that they are local only and cannot be pushed + - Lead with a tl;dr — the bottom line in 1-3 sentences, always visible. The reader should be able to act without expanding anything. + - Push supporting detail (code analysis, verification output, related items) into collapsible `
` blocks. These are appendices, not the main message. + - Short responses (a few sentences) don't need collapsible sections at all. + - Be concise and actionable. + - If the request is unclear, ask clarifying questions. + - When making code changes, commit and push them to the PR branch so the author gets the fix directly. + - Every claim needs evidence: cite file paths, line numbers, or command output. Never say "the code does X" without pointing to where. + - If you're uncertain, say so. "I couldn't confirm this" is better than a speculative answer. **When performing a PR review**: Your substantive feedback belongs in the PR review submission (via pr-review.sh), not in the comment response. The comment should only report: @@ -263,6 +284,11 @@ jobs: Keep the comment short, e.g., "I've submitted my review requesting changes. See the review for details." + + - Do not write `fixes #N`, `closes #N`, or `resolves #N` in comments — these can accidentally close issues. + - When referencing issues, use plain `#N` or link syntax without action keywords. + + Always end your comment with a new line, three dashes, and the footer message: diff --git a/.github/workflows/marvin-dedupe-issues.yml b/.github/workflows/marvin-dedupe-issues.yml index 727063f9a..4b5c10548 100644 --- a/.github/workflows/marvin-dedupe-issues.yml +++ b/.github/workflows/marvin-dedupe-issues.yml @@ -37,23 +37,38 @@ jobs: PROMPT<&2 + exit 1 + + - name: Publish fastmcp to PyPI + run: uv publish -v dist/fastmcp-*.tar.gz dist/fastmcp-*.whl diff --git a/.github/workflows/run-schema-crash-test.yml b/.github/workflows/run-schema-crash-test.yml new file mode 100644 index 000000000..2adb9ff83 --- /dev/null +++ b/.github/workflows/run-schema-crash-test.yml @@ -0,0 +1,55 @@ +name: Schema Crash Test + +on: + push: + branches: ["main"] + paths: + - "fastmcp_slim/fastmcp/utilities/json_schema_type.py" + - "fastmcp_slim/fastmcp/utilities/json_schema.py" + - "fastmcp_slim/fastmcp/utilities/openapi/**" + - "fastmcp_slim/fastmcp/server/providers/openapi/**" + - "fastmcp_slim/fastmcp/client/mixins/tools.py" + - "tests/utilities/json_schema_type/test_real_world_schemas.py" + - ".github/workflows/run-schema-crash-test.yml" + + pull_request: + paths: + - "fastmcp_slim/fastmcp/utilities/json_schema_type.py" + - "fastmcp_slim/fastmcp/utilities/json_schema.py" + - "fastmcp_slim/fastmcp/utilities/openapi/**" + - "fastmcp_slim/fastmcp/server/providers/openapi/**" + - "fastmcp_slim/fastmcp/client/mixins/tools.py" + - "tests/utilities/json_schema_type/test_real_world_schemas.py" + - ".github/workflows/run-schema-crash-test.yml" + + workflow_dispatch: + +permissions: + contents: read + +jobs: + schema_crash_test: + name: "Real-world schema crash test (232K schemas)" + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v6 + + - name: Install uv + uses: astral-sh/setup-uv@v7 + + - name: Set up Python + run: uv python install 3.12 + + - name: Install dependencies + run: uv sync + + - name: Clone openapi-directory + run: git clone --depth 1 https://github.com/APIs-guru/openapi-directory.git /tmp/openapi-directory + + - name: Run schema crash test + env: + RUN_REAL_WORLD_SCHEMA_TEST: "1" + OPENAPI_DIRECTORY_PATH: /tmp/openapi-directory + run: uv run pytest tests/utilities/json_schema_type/test_real_world_schemas.py -m integration -v -n auto --timeout-method=thread diff --git a/.github/workflows/run-static.yml b/.github/workflows/run-static.yml index 01976291a..e6cd8a2c2 100644 --- a/.github/workflows/run-static.yml +++ b/.github/workflows/run-static.yml @@ -7,10 +7,10 @@ on: push: branches: ["main"] paths: - - "src/**" + - "fastmcp_slim/**" - "tests/**" - - "uv.lock" - "pyproject.toml" + - "uv.lock" - ".github/workflows/**" # run on all pull requests because these checks are required and will block merges otherwise diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index dbac1d9c2..a3a144677 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -7,10 +7,10 @@ on: push: branches: ["main"] paths: - - "src/**" + - "fastmcp_slim/**" - "tests/**" - - "uv.lock" - "pyproject.toml" + - "uv.lock" - ".github/workflows/**" # run on all pull requests because these checks are required and will block merges otherwise @@ -117,3 +117,130 @@ jobs: FASTMCP_GITHUB_TOKEN: ${{ secrets.FASTMCP_GITHUB_TOKEN }} FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID }} FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET }} + + package_install_smoke: + name: "Package install smoke" + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v6 + + - name: Setup uv + uses: ./.github/actions/setup-uv + with: + resolution: locked + + - name: Build package wheels + run: uv build --all-packages --wheel --out-dir /tmp/fastmcp-dist + + - name: Install bare slim wheel + run: | + uv venv /tmp/fastmcp-slim-bare-smoke + SLIM_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp_slim-*.whl) + uv pip install --python /tmp/fastmcp-slim-bare-smoke/bin/python "$SLIM_WHEEL" + /tmp/fastmcp-slim-bare-smoke/bin/python - <<'PY' + from importlib.metadata import entry_points + + import fastmcp + import fastmcp.settings + + assert any(ep.name == "fastmcp" for ep in entry_points(group="console_scripts")) + + try: + from fastmcp.cli import app + except ImportError as exc: + assert "FastMCP CLI support is not installed" in str(exc) + else: + raise AssertionError(f"bare fastmcp-slim unexpectedly imported CLI app {app!r}") + + try: + fastmcp.FastMCP + except ImportError as exc: + assert "fastmcp-slim[server]" in str(exc) + else: + raise AssertionError("bare fastmcp-slim unexpectedly imported FastMCP") + PY + + - name: Install client slim wheel + run: | + uv venv /tmp/fastmcp-slim-client-smoke + SLIM_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp_slim-*.whl) + uv pip install --python /tmp/fastmcp-slim-client-smoke/bin/python "${SLIM_WHEEL}[client]" + /tmp/fastmcp-slim-client-smoke/bin/python - <<'PY' + from importlib.metadata import entry_points + + from fastmcp import Client + from fastmcp.client.transports import StdioTransport, StreamableHttpTransport + from fastmcp.mcp_config import MCPConfig + + assert any(ep.name == "fastmcp" for ep in entry_points(group="console_scripts")) + + try: + from fastmcp.cli import app + except ImportError as exc: + assert "FastMCP CLI support is not installed" in str(exc) + else: + raise AssertionError(f"client-only slim unexpectedly imported CLI app {app!r}") + + assert Client("https://example.com/mcp") + assert StreamableHttpTransport("https://example.com/mcp") + assert StdioTransport(command="uvx", args=["demo"]) + assert MCPConfig.from_dict({"mcpServers": {"demo": {"url": "https://example.com/mcp"}}}) + + try: + from fastmcp import FastMCP + except ImportError as exc: + assert "fastmcp-slim[server]" in str(exc) + else: + raise AssertionError(f"client-only slim unexpectedly imported {FastMCP!r}") + PY + + - name: Install server slim wheel + run: | + uv venv /tmp/fastmcp-slim-server-smoke + SLIM_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp_slim-*.whl) + uv pip install --python /tmp/fastmcp-slim-server-smoke/bin/python "${SLIM_WHEEL}[server]" + /tmp/fastmcp-slim-server-smoke/bin/python - <<'PY' + from importlib.metadata import entry_points + + from fastmcp import FastMCP + from fastmcp.cli import app + + assert any( + ep.name == "fastmcp" and ep.value == "fastmcp.cli:app" + for ep in entry_points(group="console_scripts") + ) + + mcp = FastMCP("smoke") + assert app is not None + assert mcp.name == "smoke" + PY + + - name: Install full package from matching local wheels + run: | + uv venv /tmp/fastmcp-full-smoke + FULL_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp-*.whl) + uv pip install --python /tmp/fastmcp-full-smoke/bin/python --find-links /tmp/fastmcp-dist "$FULL_WHEEL" + /tmp/fastmcp-full-smoke/bin/python - <<'PY' + from importlib.metadata import entry_points + from importlib.metadata import requires + + from fastmcp import Client, FastMCP + from fastmcp.client.client import CallToolResult + from fastmcp.exceptions import ToolError + + fastmcp_reqs = requires("fastmcp") or [] + assert any("fastmcp-slim[client,server]" in req for req in fastmcp_reqs) + assert not any("fastmcp-slim[full" in req for req in fastmcp_reqs) + + assert any( + ep.name == "fastmcp" and ep.value == "fastmcp.cli:app" + for ep in entry_points(group="console_scripts") + ) + + assert Client("https://example.com/mcp") + assert FastMCP("smoke").name == "smoke" + assert CallToolResult is not None + assert ToolError is not None + PY diff --git a/.github/workflows/run-upgrade-checks.yml b/.github/workflows/run-upgrade-checks.yml index 180c25bc0..582be2b6f 100644 --- a/.github/workflows/run-upgrade-checks.yml +++ b/.github/workflows/run-upgrade-checks.yml @@ -7,10 +7,10 @@ on: push: branches: ["main"] paths: - - "src/**" + - "fastmcp_slim/**" - "tests/**" - - "uv.lock" - "pyproject.toml" + - "uv.lock" - ".github/workflows/**" schedule: @@ -121,7 +121,7 @@ jobs: - **ty (type checker)**: New ty releases frequently add stricter checks that flag previously-accepted code. Run `uv run ty check` locally with the latest ty to reproduce. Fix the type errors or bump the ty version floor in `pyproject.toml`. - **ruff**: New lint rules or stricter defaults in a ruff upgrade. - - **mcp SDK**: Breaking changes in the `mcp` package (new method signatures, renamed types). + - **MCP SDK**: Breaking changes in the `mcp` package (new method signatures, renamed types). ### What to do diff --git a/.github/workflows/update-config-schema.yml b/.github/workflows/update-config-schema.yml index 128f660d1..78898be3f 100644 --- a/.github/workflows/update-config-schema.yml +++ b/.github/workflows/update-config-schema.yml @@ -7,8 +7,8 @@ on: push: branches: ["main"] paths: - - "src/fastmcp/utilities/mcp_server_config/**" - - "!src/fastmcp/utilities/mcp_server_config/v1/schema.json" + - "fastmcp_slim/fastmcp/utilities/mcp_server_config/**" + - "!fastmcp_slim/fastmcp/utilities/mcp_server_config/v1/schema.json" workflow_dispatch: permissions: @@ -47,7 +47,7 @@ jobs: from fastmcp.utilities.mcp_server_config import generate_schema generate_schema('docs/public/schemas/fastmcp.json/latest.json') generate_schema('docs/public/schemas/fastmcp.json/v1.json') - generate_schema('src/fastmcp/utilities/mcp_server_config/v1/schema.json') + generate_schema('fastmcp_slim/fastmcp/utilities/mcp_server_config/v1/schema.json') " - name: Create Pull Request @@ -59,7 +59,7 @@ jobs: body: | This PR updates the fastmcp.json schema files to match the current source code. - The schema is automatically generated from `src/fastmcp/utilities/mcp_server_config/` to ensure consistency. + The schema is automatically generated from `fastmcp_slim/fastmcp/utilities/mcp_server_config/` to ensure consistency. **Note:** This PR is fully automated and will update itself with any subsequent changes to the schema, or close automatically if the schema becomes up-to-date through other means. diff --git a/.github/workflows/update-sdk-docs.yml b/.github/workflows/update-sdk-docs.yml index db9b6fba4..373c7dc24 100644 --- a/.github/workflows/update-sdk-docs.yml +++ b/.github/workflows/update-sdk-docs.yml @@ -7,7 +7,7 @@ on: push: branches: ["main"] paths: - - "src/**" + - "fastmcp_slim/**" - "pyproject.toml" workflow_dispatch: @@ -42,7 +42,7 @@ jobs: run: uv sync --python 3.12 - name: Install just - uses: extractions/setup-just@v3 + uses: extractions/setup-just@v4 - name: Generate SDK documentation run: just api-ref-all diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dd3574432..2209f8d6e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: entry: uv run --isolated ty check language: system types: [python] - files: ^src/|^tests/ + files: ^fastmcp_slim/|^tests/ pass_filenames: false require_serial: true diff --git a/CLAUDE.md b/CLAUDE.md index ac80f2d25..58f33c5c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ uv run prek run --all-files # Ruff + Prettier + ty | Path | Purpose | | ----------------- | -------------------------------------- | -| `src/fastmcp/` | Library source code | +| `fastmcp_slim/fastmcp/` | Library source code | | `├─server/` | Server implementation | | `│ ├─auth/` | Authentication providers | | `│ └─middleware/` | Error handling, logging, rate limiting | @@ -50,6 +50,8 @@ When modifying MCP functionality, changes typically need to be applied across al - **Resource Templates** (`src/resources/`) - **Prompts** (`src/prompts/`) +**Before writing cross-component logic (dedupe, grouping, lookups, identity checks), read `FastMCPComponent` in `fastmcp_slim/fastmcp/utilities/components.py`.** The base class defines the shared surface — `name`, `version`, `tags`, `meta`, and critically the `key` property which is the canonical MCP identity (encodes type, identifier, and version). Prefer `item.key` over ad-hoc `name or uri or uri_template` fallbacks; overrides in `Resource` and `ResourceTemplate` already handle URI-based identity, and `.key` includes the version suffix so variants of the same component don't falsely collide. + ## Development Rules **Read `CONTRIBUTING.md` before opening issues or PRs.** It describes when PRs are appropriate, what we expect from enhancement proposals, and what we'll close without review. @@ -63,6 +65,9 @@ When modifying MCP functionality, changes typically need to be applied across al - **NEVER** force-push on collaborative repos - **ALWAYS** run prek before PRs - **NEVER** create a release, comment on an issue, or open a PR unless specifically instructed to do so. +- **NEVER** merge a PR marked as do-not-merge or draft. Check title, body, AND labels for `[DNM]`, `DNM`, `DO NOT MERGE`, `DON'T MERGE`, `DONT MERGE`, `do-not-merge`, `dont-merge`, `[DRAFT]`, or `DRAFT` (case-insensitive, any variation — some authors use `[DRAFT]` in the title even when `isDraft` is false). Authors use these as hard stops — respect them even if CI is green and review looks clean. When triaging a batch of PRs, filter these out up front AND re-check each one's labels immediately before merging, since labels can change mid-session. +- **ALWAYS** read review-bot comments before approving a PR. CodeRabbit and chatgpt-codex-connector (Codex) leave substantive review comments on most PRs in this repo — these bots have read the diff and often flag real issues that aren't in the PR description. Use `gh pr view --comments` and read the bot feedback as part of review. Unlike proposed solutions from issue reporters, review-bot feedback should be evaluated on its merits, not discounted. +- **Be constructively skeptical of bot review comments on your own PRs.** CodeRabbit, Codex, and claude[bot] run a fresh review pass on every push, which means a PR with active churn can accumulate bot comments in a stream that never really ends — each fix surfaces a new edge case the next pass can flag. Most of the early feedback is real and worth acting on; diminishing returns set in fast. Evaluate each comment on its merits, the same way you would a human reviewer: is this a real bug users will hit, or a hypothetical that requires an adversarial setup? Does the fix introduce more complexity than the problem? Has the bot missed context that's obvious to a human reader (a `*,` keyword-only marker, a design decision documented elsewhere, something already resolved on a later commit)? When a comment is pedantic, a false positive, or flagging something already fixed, reply on the thread explaining the reasoning and move on — don't keep iterating just because more comments arrive. If you find yourself three rounds deep and the feedback is shifting toward "what if someone does X" hypotheticals, you're past the point where each fix is improving the PR. Stop, document the contract as-is, and ship. ### Releases @@ -82,6 +87,16 @@ The handwritten notes are prepended above the auto-generated changelog and are t **Before drafting, always read recent existing releases** (`gh release list` then `gh release view `) to absorb the voice, structure, and level of detail. Each release builds on the tone of previous ones — don't guess at the style from these instructions alone. +**To preview what PRs will be in the release** before it's cut, call the GitHub generate-notes API. This returns the exact auto-generated changelog that `--generate-notes` would append, so you can see the full PR list — useful for picking a pun theme and making sure nothing's been missed: + +```bash +gh api -X POST repos/PrefectHQ/fastmcp/releases/generate-notes \ + -f tag_name=v3.2.3 \ + -f target_commitish=main \ + -f previous_tag_name=v3.2.2 \ + --jq '.body' +``` + **Point releases** (3.0, 3.1, 3.2) get narrative prose: open with the theme of the release, then walk through headline features conceptually — what they enable, why they matter, how they fit together. Write it the way a blog post reads, not a changelog. Multiple paragraphs, code examples where they clarify. **Patch releases** (3.1.1, 3.0.2) get 1-2 sentences explaining what broke and what the fix does. Keep it minimal — the auto-generated changelog has the details. @@ -132,9 +147,9 @@ The handwritten notes are prepended above the auto-generated changelog and are t - Uses Mintlify framework - Files must be in docs.json to be included - Do not manually modify `docs/python-sdk/**` — these files are auto-generated from source code by a bot and maintained via a long-lived PR. Do not include changes to these files in contributor PRs. -- Do not manually modify `docs/public/schemas/**` or `src/fastmcp/utilities/mcp_server_config/v1/schema.json` — these are auto-generated and maintained via a long-lived PR. +- Do not manually modify `docs/public/schemas/**` or `fastmcp_slim/fastmcp/utilities/mcp_server_config/v1/schema.json` — these are auto-generated and maintained via a long-lived PR. - **Core Principle:** A feature doesn't exist unless it is documented! -- When adding or modifying settings in `src/fastmcp/settings.py`, update `docs/more/settings.mdx` to match. +- When adding or modifying settings in `fastmcp_slim/fastmcp/settings.py`, update `docs/more/settings.mdx` to match. ### Documentation Guidelines diff --git a/README.md b/README.md index 3bfcefd2d..9afc7f4f7 100644 --- a/README.md +++ b/README.md @@ -79,11 +79,15 @@ FastMCP has three pillars: Ready to build? Start with the [installation guide](https://gofastmcp.com/getting-started/installation) or jump straight to the [quickstart](https://gofastmcp.com/getting-started/quickstart). -## From prototype to production +## Run FastMCP in production with Horizon -FastMCP is how teams build MCP servers. **[Prefect Horizon](https://www.prefect.io/horizon)** is how enterprises run them in production. Register any MCP server behind a managed gateway with SSO, tool-level RBAC, audit logs, and observability. Deploy FastMCP servers and go from PR to preview in 60 seconds, then remix tools from across your registry into use-case-specific, permissioned endpoints. Horizon is everything we've learned about MCP at scale from building the world's most popular MCP framework. Free for individuals, built for teams. +FastMCP is the standard way to build MCP servers. **[Prefect Horizon](https://www.prefect.io/horizon?utm_source=github&utm_medium=readme&utm_campaign=readme_horizon&utm_content=readme_body)** is the enterprise MCP gateway for running them safely. -[Deploy your first server →](https://www.prefect.io/horizon) +Built by the FastMCP team, Horizon packages the best practices we've learned shipping the world's most popular MCP framework. + +Deploy FastMCP servers from GitHub with branch previews and instant rollback. Create a private registry of every MCP your company uses. Secure access with SSO and tool-level RBAC. Get audit logs, observability, and governance across your MCP stack. Remix approved tools into purpose-built endpoints for teams and agents. + +Start with FastMCP. [Scale with Horizon →](https://www.prefect.io/horizon?utm_source=github&utm_medium=readme&utm_campaign=readme_horizon&utm_content=readme_cta) ## Installation diff --git a/docs/apps/architecture.mdx b/docs/apps/architecture.mdx index 26588c2f8..7ecab2aa3 100644 --- a/docs/apps/architecture.mdx +++ b/docs/apps/architecture.mdx @@ -1,119 +1,118 @@ --- -title: App Architecture +title: Architecture sidebarTitle: Architecture description: How FastMCP apps work under the hood — from Python to pixels. icon: sitemap -tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' -This page explains how Prefab apps work under the hood — how your Python code becomes an interactive UI inside a host client's conversation. You don't need any of this to build apps, but the mental model is useful when something isn't rendering the way you expect, when tool calls from the UI aren't reaching your server, or when you're building [custom HTML apps](/apps/low-level) and need to understand the protocol directly. +You don't need this page to build apps. It's for when something isn't rendering the way you expect, when UI tool calls aren't reaching your server, or when you're writing [custom HTML apps](/apps/low-level) and need to understand the protocol directly. -## The Pipeline +## The pipeline -An MCP App moves through five stages from Python to pixels: +An MCP app moves through five stages from Python to pixels: ``` Python components → JSON tree → structuredContent → Renderer iframe → Host UI ``` -You write Prefab components in Python. FastMCP serializes them to a JSON component tree and delivers it as `structuredContent` on the tool result. The host loads the Prefab renderer in a sandboxed iframe, pushes the JSON into it, and the renderer paints the UI. If the UI needs to call server tools, it talks back through the same `postMessage` channel. +You write Prefab components. FastMCP serializes them to a JSON component tree and delivers it as `structuredContent` on the tool result. The host loads the Prefab renderer in a sandboxed iframe, pushes the JSON in, and the renderer paints the UI. If the UI calls server tools, it talks back through the same `postMessage` channel. -The following sections walk through each stage. +The sections below walk each stage. -## Tool Registration +## Tool registration When you mark a tool with `app=True` or `@app.ui()`, FastMCP wires up the metadata and renderer resource that the protocol requires. -### The `app=True` Flag +### The `app=True` flag -The `app` parameter on `@mcp.tool` accepts `True`, an `AppConfig` object, or a dict. When you pass `True`, FastMCP checks whether the tool's return type is a Prefab type (`PrefabApp`, `Component`, or unions containing them). If the tool qualifies, FastMCP expands `True` into a full `AppConfig` — setting the renderer URI, CSP headers, and visibility — and stores it in the tool's `meta["ui"]` dict. +`app` on `@mcp.tool` accepts `True`, an `AppConfig`, or a dict. When you pass `True`, FastMCP checks whether the tool's return type is a Prefab type (`PrefabApp`, `Component`, or unions containing them). If it qualifies, FastMCP expands `True` into a full `AppConfig` — setting the renderer URI, CSP headers, and visibility — and stores it in the tool's `meta["ui"]` dict. -This expansion also triggers registration of the shared Prefab renderer resource (discussed below). The tool and the renderer are linked through a `resourceUri` field in the metadata: the tool says "render me with `ui://prefab/renderer.html`", and the host fetches that resource when it needs to display the result. +This expansion also registers the shared Prefab renderer resource (below). The tool and the renderer are linked through a `resourceUri` field in the metadata: the tool says "render me with `ui://prefab/renderer.html`" and the host fetches that resource when it displays the result. -Type inference works the same way. If your return type annotation is a Prefab type and you haven't set `app` explicitly, FastMCP auto-wires the metadata as if you'd written `app=True`. +Type inference works the same way. If the return type is a Prefab type and you haven't set `app` explicitly, FastMCP auto-wires the metadata as if you'd written `app=True`. -### FastMCPApp Registration +### FastMCPApp registration -`FastMCPApp` uses the same underlying mechanism but adds two things. First, it tags every tool — both `@app.ui()` entry points and `@app.tool()` backends — with `meta["fastmcp"]["app"]` set to the app's name. This tag is how the server identifies which app a tool belongs to when routing calls from the UI. +`FastMCPApp` uses the same mechanism but adds two things. First, it tags every tool — both `@app.ui()` entry points and `@app.tool()` backends — with `meta["fastmcp"]["app"]` set to the app's name. That tag lets the server identify which app a tool belongs to when routing UI calls. -Second, it sets `meta["ui"]["visibility"]` to control who can see each tool. Entry points default to `["model"]` (visible to the LLM). Backend tools default to `["app"]` (visible only to the UI). Hosts use this to filter the tool list — the model sees entry points, and the UI sees backends. +Second, it sets `meta["ui"]["visibility"]` to control who can see each tool. Entry points default to `["model"]` (LLM-visible). Backend tools default to `["app"]` (UI-only). Hosts use this to filter the tool list. ## Serialization -When a Prefab tool runs, its return value — a `PrefabApp` or a raw `Component` — needs to become a JSON blob that the renderer can interpret. +When a Prefab tool runs, its return value — a `PrefabApp` or a bare `Component` — becomes a JSON blob the renderer can interpret. -### PrefabApp.to_json() +### `PrefabApp.to_json()` -The serialization entry point is `PrefabApp.to_json()`. This method walks the component tree and produces a JSON object with three top-level keys: `view` (the component tree), `state` (initial state values), and `_meta` (routing metadata). +The entry point is `PrefabApp.to_json()`. It walks the component tree and produces a JSON object with three top-level keys: `view` (the component tree), `state` (initial state values), and `_meta` (routing metadata). -FastMCP passes a `tool_resolver` callback to `to_json()`. Whenever the component tree contains a `CallTool` action that references a function (not a string), the resolver converts it to a `ResolvedTool` with the function's registered name. This is how `CallTool(save_contact)` becomes `CallTool("save_contact")` in the wire format. The resolver also handles `unwrap_result` — a flag that tells the renderer to unwrap single-value results from the `{"result": value}` envelope that FastMCP uses for schema compliance. +FastMCP passes a `tool_resolver` callback to `to_json()`. Whenever the tree contains a `CallTool` action that references a function (not a string), the resolver converts it to a `ResolvedTool` with the function's registered name. This is how `CallTool(save_contact)` becomes `CallTool("save_contact")` on the wire. The resolver also handles `unwrap_result` — a flag telling the renderer to unwrap single-value results from the `{"result": value}` envelope FastMCP uses for schema compliance. -### The _meta.fastmcp.app Tag +### The `_meta.fastmcp.app` tag -After `to_json()` produces the JSON tree, FastMCP injects `_meta.fastmcp.app` with the app's name (if the tool belongs to a `FastMCPApp`). This tag rides along inside `structuredContent` all the way to the renderer. +After `to_json()` produces the tree, FastMCP injects `_meta.fastmcp.app` with the app's name (if the tool belongs to a `FastMCPApp`). This tag rides along inside `structuredContent` all the way to the renderer. -When the renderer calls a backend tool, it includes `_meta.fastmcp.app` in the `CallTool` request. The server sees this tag and routes the call through a special path that bypasses transforms — more on this in the next section. +When the renderer calls a backend tool, it includes `_meta.fastmcp.app` in the `CallTool` request. The server sees this tag and routes the call through a special path that bypasses transforms (below). -### ToolResult Assembly +### ToolResult assembly The final tool result has two parts: `content` (a list of `TextContent` blocks for the LLM) and `structuredContent` (the JSON tree for the renderer). By default, Prefab tools send `"[Rendered Prefab UI]"` as the text content — just enough for the LLM to know something was rendered. If you return a `ToolResult` explicitly, you control both halves. -## Tool Call Routing +## Tool call routing -When a host calls a tool, the server needs to find it. Normal tool calls go through the provider chain, which applies transforms (namespace prefixes, visibility filters, etc.) before resolving the tool by name. But app UI calls need a different path. +Normal tool calls go through the provider chain, which applies transforms (namespace prefixes, visibility filters) before resolving by name. App UI calls need a different path. -### The get_app_tool Bypass +### The `get_app_tool` bypass -Backend tools registered with `@app.tool()` are typically hidden from the model (`visibility=["app"]`). Visibility transforms would filter them out of normal resolution. And namespace transforms might rename them — `save_contact` becomes `contacts_save_contact` — but the renderer still uses the original name. +Backend tools are typically hidden from the model (`visibility=["app"]`). Visibility transforms would filter them out of normal resolution. And namespace transforms might rename them — `save_contact` becomes `contacts_save_contact` — while the renderer still uses the original name. -`get_app_tool` solves both problems. When the server sees `_meta.fastmcp.app` on an incoming `CallTool` request, it calls `get_app_tool(app_name, tool_name)` instead of the normal `get_tool(name)`. This method walks the provider tree directly, skipping the transform chain entirely. It finds the tool by its original registered name and verifies that its `meta["fastmcp"]["app"]` matches the expected app identity. +`get_app_tool` solves both problems. When the server sees `_meta.fastmcp.app` on an incoming `CallTool` request, it calls `get_app_tool(app_name, tool_name)` instead of the normal `get_tool(name)`. This walks the provider tree directly, skipping transforms. It finds the tool by its original registered name and verifies that its `meta["fastmcp"]["app"]` matches the expected app. -This is why `CallTool("save_contact")` keeps working even when the server is mounted under a namespace prefix. The renderer sends the original name plus the app identity; the server uses `get_app_tool` to find the tool without transforms getting in the way. +That's why `CallTool("save_contact")` keeps working when the server is mounted under a namespace. The renderer sends the original name plus the app identity; the server uses `get_app_tool` to find it without transforms in the way. -Authorization checks still apply — `get_app_tool` bypasses transforms, but it runs auth checks against the tool's `auth` configuration before executing. +Authorization still applies. `get_app_tool` bypasses transforms but runs auth checks against the tool's `auth` config before executing. -### Provider Delegation +### Provider delegation -The `get_app_tool` method is defined on the `Provider` base class and overridden by aggregate and wrapped providers. Aggregate providers fan out the lookup across all child providers in parallel. Wrapped providers (like `FastMCPProvider`, which wraps a nested `FastMCP` server) delegate to the inner server's `get_app_tool`. This means backend tools are reachable through any depth of server composition. +`get_app_tool` is defined on the `Provider` base class and overridden by aggregate and wrapped providers. Aggregate providers fan out the lookup across child providers in parallel. Wrapped providers (like `FastMCPProvider`, which wraps a nested `FastMCP` server) delegate to the inner server's `get_app_tool`. Backend tools are reachable through any depth of composition. -## The Renderer +## The renderer The Prefab renderer is a self-contained JavaScript application that interprets the JSON component tree and renders it as a React UI. -### The Shared Resource +### The shared resource -FastMCP registers the renderer as a `ui://prefab/renderer.html` resource with MIME type `text/html;profile=mcp-app`. The renderer HTML is bundled inside the `prefab-ui` Python package — `get_renderer_html()` returns it as a string. All Prefab tools on a server share this single resource, regardless of how many tools or apps are registered. +FastMCP registers the renderer as a `ui://prefab/renderer.html` resource with MIME type `text/html;profile=mcp-app`. The HTML is bundled inside the `prefab-ui` Python package; `get_renderer_html()` returns it as a string. All Prefab tools on a server share this single resource. -The resource also carries CSP metadata (via `get_renderer_csp()`) declaring which CDN domains the renderer needs to load its JavaScript dependencies. Hosts use this to configure the iframe's Content Security Policy. +The resource also carries CSP metadata (via `get_renderer_csp()`) declaring the CDN domains the renderer needs. Hosts use this to configure the iframe's Content Security Policy. -### postMessage Communication +### `postMessage` communication -The renderer lives in a sandboxed iframe. It communicates with the host using `postMessage` — the standard browser API for cross-origin iframe communication. The protocol follows the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) specification: +The renderer lives in a sandboxed iframe and communicates with the host using `postMessage`. The protocol follows the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) spec: -The host pushes the tool result (including `structuredContent`) into the iframe. The renderer parses the JSON component tree, initializes state, and renders the UI. When the user interacts with the UI — submitting a form, clicking a button — and that interaction triggers a `CallTool` action, the renderer sends a `callServerTool` message back to the host via `postMessage`. The host forwards this as a regular MCP `tools/call` request to the server, including `_meta.fastmcp.app` for routing. +The host pushes the tool result (with `structuredContent`) into the iframe. The renderer parses the component tree, initializes state, and renders the UI. When the user interacts — submitting a form, clicking a button — and the interaction triggers a `CallTool` action, the renderer sends a `callServerTool` message back to the host via `postMessage`. The host forwards it as a regular MCP `tools/call` request to the server, including `_meta.fastmcp.app` for routing. -The response flows back the same way: server to host, host to iframe via `postMessage`, renderer updates state with the result. +The response flows back the same way: server → host → iframe via `postMessage`, and the renderer updates state with the result. ### AppBridge -The `@modelcontextprotocol/ext-apps` JavaScript SDK provides the `App` class (sometimes called AppBridge) that manages the `postMessage` handshake. It handles connection negotiation, tool result delivery, server tool calls, and host context (like safe area insets and theme preferences). The Prefab renderer uses this SDK internally — you only interact with it directly when building [custom HTML apps](/apps/low-level). +The `@modelcontextprotocol/ext-apps` JavaScript SDK provides the `App` class (sometimes called AppBridge) that manages the `postMessage` handshake. It handles connection negotiation, tool result delivery, server tool calls, and host context (safe area insets, theme preferences). The Prefab renderer uses it internally; you only touch it directly when building [custom HTML apps](/apps/low-level). -## The Dev Server +## The dev server -`fastmcp dev apps` provides a local preview environment that simulates the host-side behavior without requiring a real MCP host client. +`fastmcp dev apps` simulates the host-side behavior locally without a real MCP client. -### Proxy Architecture +### Proxy architecture -The dev server runs two HTTP servers. Your MCP server starts on port 8000 (configurable) with the Streamable HTTP transport. The dev UI runs on port 8080 and serves a picker page that lists your app tools. +Two HTTP servers. Your MCP server runs on port 8000 with the Streamable HTTP transport. The dev UI runs on port 8080 and serves a picker page that lists your app tools. -A reverse proxy at `/mcp` on the dev server forwards requests to your MCP server. This is important because the renderer iframe runs on `localhost:8080`, and your MCP server runs on `localhost:8000`. Without the proxy, the renderer's `callServerTool` requests would be cross-origin and blocked by the browser. The proxy makes everything same-origin from the iframe's perspective. +A reverse proxy at `/mcp` on the dev server forwards requests to your MCP server. This matters because the renderer iframe runs on `localhost:8080` and your MCP server runs on `localhost:8000` — without the proxy, the renderer's `callServerTool` requests would be cross-origin and the browser would block them. The proxy keeps everything same-origin from the iframe's perspective. -### The Launch Flow +### The launch flow -When you select a tool and click launch, the dev UI calls the tool through the proxy, receives the `structuredContent` response, and opens a new tab. That tab loads the tool's renderer resource (fetched from the proxy) in an iframe, creates an AppBridge instance, and pushes the tool result into the renderer. From this point forward, the experience matches what a real host would provide — the renderer displays the UI, and any `CallTool` actions route back through the proxy to your MCP server. +When you select a tool and click launch, the dev UI calls the tool through the proxy, receives the `structuredContent` response, and opens a new tab. That tab loads the tool's renderer resource (via the proxy), creates an AppBridge, and pushes the tool result into the renderer. From here on it matches what a real host provides: the renderer displays the UI, and any `CallTool` actions route back through the proxy to your server. -Auto-reload is enabled by default, so changes to your server code restart the MCP server automatically. The dev UI stays running — just re-launch the tool to see your changes. +Auto-reload is on by default, so changes to your server code restart the MCP server automatically. The dev UI keeps running — relaunch the tool to see changes. diff --git a/docs/apps/demos/bar-chart.html b/docs/apps/demos/bar-chart.html new file mode 100644 index 000000000..81372d859 --- /dev/null +++ b/docs/apps/demos/bar-chart.html @@ -0,0 +1,76 @@ + + + + Prefab + + + + + + +
+ + + \ No newline at end of file diff --git a/docs/apps/demos/bar-chart.py b/docs/apps/demos/bar-chart.py new file mode 100644 index 000000000..e2430b981 --- /dev/null +++ b/docs/apps/demos/bar-chart.py @@ -0,0 +1,23 @@ +from prefab_ui.app import PrefabApp +from prefab_ui.components import Column +from prefab_ui.components.charts import BarChart, ChartSeries + +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 PrefabApp() as app: + with Column(css_class="p-6"): + BarChart( + data=data, + series=[ + ChartSeries(data_key="revenue", label="Revenue"), + ChartSeries(data_key="costs", label="Costs"), + ], + x_axis="quarter", + show_legend=True, + height=250, + ) diff --git a/docs/apps/demos/contacts.html b/docs/apps/demos/contacts.html new file mode 100644 index 000000000..5831d639c --- /dev/null +++ b/docs/apps/demos/contacts.html @@ -0,0 +1,172 @@ + + + + Prefab + + + + + + +
+ + + \ No newline at end of file diff --git a/docs/apps/demos/contacts.py b/docs/apps/demos/contacts.py new file mode 100644 index 000000000..0cbe60c0b --- /dev/null +++ b/docs/apps/demos/contacts.py @@ -0,0 +1,78 @@ +from prefab_ui.actions import ShowToast +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + H3, + Badge, + Button, + Column, + DataTable, + DataTableColumn, + Form, + Input, + Row, + Select, + SelectOption, + Separator, +) + +contacts = [ + {"name": "Arthur Dent", "email": "arthur@earth.com", "category": "Customer"}, + {"name": "Ford Prefect", "email": "ford@betelgeuse.org", "category": "Partner"}, + { + "name": "Trillian Astra", + "email": "trillian@heartofgold.com", + "category": "Customer", + }, + {"name": "Zaphod Beeblebrox", "email": "zaphod@galaxy.gov", "category": "Vendor"}, +] + +rows = [ + { + "name": c["name"], + "email": c["email"], + "category": Badge( + c["category"], + variant="success" + if c["category"] == "Customer" + else "secondary" + if c["category"] == "Partner" + else "outline", + ), + } + for c in contacts +] + +with PrefabApp() as app: + with Column(gap=4, css_class="p-6"): + DataTable( + columns=[ + DataTableColumn(key="name", header="Name", sortable=True), + DataTableColumn(key="email", header="Email"), + DataTableColumn(key="category", header="Category"), + ], + rows=rows, + search=True, + ) + + Separator() + + H3("Add Contact") + with Form( + on_submit=ShowToast( + "Contact saved! (preview demo — no backend wired)", + variant="success", + ), + ): + with Row(gap=4): + Input(name="name", label="Name", placeholder="Full name", required=True) + Input( + name="email", + label="Email", + placeholder="name@example.com", + required=True, + ) + with Select(name="category", label="Category"): + SelectOption(value="Customer", label="Customer") + SelectOption(value="Partner", label="Partner") + SelectOption(value="Vendor", label="Vendor") + Button("Save Contact") diff --git a/docs/apps/demos/dashboard.html b/docs/apps/demos/dashboard.html new file mode 100644 index 000000000..21c1c8924 --- /dev/null +++ b/docs/apps/demos/dashboard.html @@ -0,0 +1,157 @@ + + + + Prefab + + + + + + +
+ + + \ No newline at end of file diff --git a/docs/apps/demos/dashboard.py b/docs/apps/demos/dashboard.py new file mode 100644 index 000000000..06fe6285d --- /dev/null +++ b/docs/apps/demos/dashboard.py @@ -0,0 +1,68 @@ +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Badge, + Column, + DataTable, + DataTableColumn, + Row, + Separator, +) +from prefab_ui.components.charts import BarChart, ChartSeries +from prefab_ui.components.metric import Metric + +monthly = [ + {"month": "Jan", "revenue": 48200, "costs": 31000}, + {"month": "Feb", "revenue": 52100, "costs": 32500}, + {"month": "Mar", "revenue": 61800, "costs": 34200}, + {"month": "Apr", "revenue": 58400, "costs": 33800}, +] + +deals = [ + {"account": "Acme Corp", "value": "$84,000", "stage": "Won"}, + {"account": "Globex Inc", "value": "$52,000", "stage": "Negotiation"}, + {"account": "Initech", "value": "$31,500", "stage": "Proposal"}, + {"account": "Wayne Enterprises", "value": "$45,000", "stage": "Lost"}, +] + +rows = [ + { + "account": d["account"], + "value": d["value"], + "stage": Badge( + d["stage"], + variant="success" + if d["stage"] == "Won" + else "destructive" + if d["stage"] == "Lost" + else "secondary", + ), + } + for d in deals +] + +total = sum(m["revenue"] for m in monthly) + +with PrefabApp() as app: + with Column(gap=4, css_class="p-6"): + with Row(gap=6): + Metric(label="Revenue (Q1-Q4)", value=f"${total:,}") + Metric(label="Deals", value=f"{len(deals)}") + BarChart( + data=monthly, + series=[ + ChartSeries(data_key="revenue", label="Revenue"), + ChartSeries(data_key="costs", label="Costs"), + ], + x_axis="month", + show_legend=True, + height=200, + ) + Separator() + DataTable( + columns=[ + DataTableColumn(key="account", header="Account", sortable=True), + DataTableColumn(key="value", header="Value", sortable=True), + DataTableColumn(key="stage", header="Stage"), + ], + rows=rows, + ) diff --git a/docs/apps/demos/data-table.html b/docs/apps/demos/data-table.html new file mode 100644 index 000000000..fe84abcd4 --- /dev/null +++ b/docs/apps/demos/data-table.html @@ -0,0 +1,90 @@ + + + + Prefab + + + + + + +
+ + + \ No newline at end of file diff --git a/docs/apps/demos/data-table.py b/docs/apps/demos/data-table.py new file mode 100644 index 000000000..5100237bf --- /dev/null +++ b/docs/apps/demos/data-table.py @@ -0,0 +1,24 @@ +from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, DataTable, DataTableColumn + +employees = [ + {"name": "Alice Chen", "role": "Staff Engineer", "dept": "Platform"}, + {"name": "Bob Martinez", "role": "Lead Designer", "dept": "Design"}, + {"name": "Carol Johnson", "role": "Senior Engineer", "dept": "Platform"}, + {"name": "David Kim", "role": "Product Manager", "dept": "Product"}, + {"name": "Eva Mueller", "role": "Engineer", "dept": "Platform"}, + {"name": "Frank Lee", "role": "Data Scientist", "dept": "ML"}, + {"name": "Grace Park", "role": "Eng Manager", "dept": "Platform"}, +] + +with PrefabApp() as app: + with Column(gap=4, css_class="p-6"): + DataTable( + columns=[ + DataTableColumn(key="name", header="Name", sortable=True), + DataTableColumn(key="role", header="Role", sortable=True), + DataTableColumn(key="dept", header="Dept", sortable=True), + ], + rows=employees, + search=True, + ) diff --git a/docs/apps/demos/hitchhikers.html b/docs/apps/demos/hitchhikers.html new file mode 100644 index 000000000..7f26f9796 --- /dev/null +++ b/docs/apps/demos/hitchhikers.html @@ -0,0 +1,1105 @@ + + + + Prefab Showcase + + + + + + +
+ + + \ No newline at end of file diff --git a/docs/apps/demos/hitchhikers.py b/docs/apps/demos/hitchhikers.py new file mode 100644 index 000000000..1554e5165 --- /dev/null +++ b/docs/apps/demos/hitchhikers.py @@ -0,0 +1,461 @@ +"""The Hitchhiker's Guide dashboard from the Prefab welcome page. + +Run with: + prefab serve examples/hitchhikers-guide/dashboard.py + prefab export examples/hitchhikers-guide/dashboard.py +""" + +from prefab_ui import PrefabApp +from prefab_ui.actions import SetInterval, SetState, ShowToast +from prefab_ui.components import ( + Alert, + AlertDescription, + AlertTitle, + Badge, + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + Carousel, + Checkbox, + Column, + Combobox, + ComboboxOption, + DataTable, + DataTableColumn, + DatePicker, + Dialog, + Grid, + GridItem, + HoverCard, + Loader, + Metric, + Muted, + P, + Progress, + Radio, + RadioGroup, + Ring, + Row, + Separator, + Slider, + Switch, + Text, + Tooltip, +) +from prefab_ui.components.charts import ( + BarChart, + ChartSeries, + RadarChart, + Sparkline, +) +from prefab_ui.components.control_flow import Else, If +from prefab_ui.rx import Rx + +ctx_tick = Rx("ctx_tick") + +# Context window: climbs from 24% to ~78%, then resets +ctx_pct = (ctx_tick % 20) * 3 + 20 +ctx_variant = (ctx_pct > 70).then( + "destructive", (ctx_pct <= 33).then("success", "default") +) + +with PrefabApp( + title="Prefab Showcase", + state={"ctx_tick": 0, "improbability": 42}, + on_mount=SetInterval( + 400, + on_tick=SetState("ctx_tick", ctx_tick + 1), + ), +) as app: + with Grid(columns={"default": 1, "md": 2, "lg": 4}, gap=4): + # ── Col 1 ───────────────────────────────────────────────────────── + with Column(gap=4): + with Card(): + with CardHeader(): + CardTitle("Register Towel") + CardDescription("The most important item in the galaxy") + with CardContent(): + with Column(gap=3): + with Combobox( + placeholder="Type...", + search_placeholder="Search types...", + ): + ComboboxOption("Bath", value="bath") + ComboboxOption("Beach", value="beach") + ComboboxOption("Interstellar", value="interstellar") + ComboboxOption("Microfiber", value="micro") + DatePicker(placeholder="Registration date") + with CardFooter(): + with Row(gap=2): + with Dialog( + title="Towel Registered!", + description="Your towel has been added to the galactic registry.", + ): + Button("Register") + Text("Don't forget to bring it.") + Button("Cancel", variant="outline") + with If("{{ !pressed }}"): + Button( + "This is probably the best button to press.", + variant="success", + on_click=SetState("pressed", True), + ) + with Else(): + Button( + "Please do not press this button again.", + variant="destructive", + on_click=SetState("pressed", False), + ) + + with Card(): + with CardHeader(): + CardTitle("Ship Status") + with CardContent(): + with Column(gap=3): + with Row( + align="center", + css_class="justify-between", + ): + Text("heart-of-gold") + with HoverCard(open_delay=0, close_delay=200): + Badge("In Orbit", variant="default") + with Column(gap=2): + Text("heart-of-gold") + Muted("Deployed 2h ago") + Progress( + value=100, + max=100, + variant="success", + ) + Progress( + value=100, + max=100, + indicator_class="bg-yellow-400", + ) + with Row( + align="center", + css_class="justify-between", + ): + Text("vogon-poetry") + with Tooltip("64% — ETA 12 min", delay=0): + with Badge(variant="secondary"): + Loader(size="sm") + Text("Deploying") + Progress(value=64, max=100) + with Row( + align="center", + css_class="justify-between", + ): + Text("deep-thought") + with Tooltip( + "Computing... 7.5 million years remaining", + delay=0, + ): + with Badge(variant="outline"): + Loader(size="sm", variant="ios") + Text("Soon...") + Progress(value=12, max=100) + with Card(): + with CardHeader(): + CardTitle("Planet Ratings") + with CardContent(): + RadarChart( + data=[ + {"axis": "Views", "earth": 30, "mag": 95}, + {"axis": "Fjords", "earth": 65, "mag": 100}, + {"axis": "Pubs", "earth": 90, "mag": 10}, + {"axis": "Mice", "earth": 40, "mag": 85}, + {"axis": "Tea", "earth": 95, "mag": 15}, + {"axis": "Safety", "earth": 45, "mag": 70}, + ], + series=[ + ChartSeries(dataKey="earth", label="Earth"), + ChartSeries(dataKey="mag", label="Magrathea"), + ], + axis_key="axis", + height=200, + show_legend=True, + show_tooltip=True, + ) + + # ── Col 2 ───────────────────────────────────────────────────────── + with Column(gap=4): + with Card(): + with CardHeader(): + CardTitle("Survival Odds") + with CardContent(css_class="w-fit mx-auto"): + Ring( + value=42, + label="42%", + variant="info", + size="lg", + thickness=12, + indicator_class="group-hover:drop-shadow-[0_0_24px_rgba(59,130,246,0.9)]", + ) + with Card(): + with CardHeader(): + with Row(gap=2, align="center"): + CardTitle("Improbability Drive") + Loader( + variant="pulse", + size="sm", + css_class="text-blue-500", + ) + with CardContent(): + with Column(gap=2): + Slider( + min=0, + max=100, + value=42, + name="improbability", + ) + with Row( + align="center", + css_class="justify-between", + ): + Muted("Probable") + Muted("Infinite") + with Carousel(auto_advance=3000, show_controls=False, direction="up"): + with Alert(variant="success", icon="circle-check"): + AlertTitle("Don't Panic") + AlertDescription("Normality achieved.") + with Alert(variant="destructive", icon="triangle-alert"): + AlertTitle("Display Department") + AlertDescription("Beware of the leopard.") + with Card(): + with CardHeader(): + CardTitle("Prefect Horizon Config") + with CardContent(): + with Column(gap=3): + Switch( + label="Auto-scale agents", + value=True, + name="autoscale", + ) + Separator() + Switch( + label="Code Mode", + value=True, + name="code_mode", + ) + Separator() + Switch( + label="Tool call caching", + value=False, + name="cache", + ) + with CardFooter(): + Button( + "Save Preferences", + on_click=ShowToast("Preferences saved!"), + ) + with Card(): + with CardHeader(): + CardTitle("Travel Class") + with CardContent(): + with RadioGroup(name="travel_class"): + Radio(option="economy", label="Economy") + Radio(option="business", label="Business Class") + Radio( + option="improbability", + label="Infinite Improbability", + value=True, + ) + + # ── Cols 3–4: summary row, chart, then 2-col grid below ───────── + with GridItem(css_class="md:col-span-2"): + with Column(gap=4): + with Grid(columns=2, gap=4, css_class="h-32"): + with Card(): + with CardHeader(): + CardTitle("Context Window") + with CardContent(): + with Column( + gap=6, + justify="center", + css_class="h-full", + ): + with Row( + align="center", + css_class="justify-between", + ): + Text(f"{ctx_pct}% used") + Muted(f"{ctx_pct * 2}k / 200k tokens") + with Tooltip( + "Auto-compact buffer: 12%", + delay=0, + ): + Progress( + value=ctx_pct, + max=100, + variant=ctx_variant, + ) + with Card(css_class="pb-0 gap-0"): + with CardContent(): + Metric( + label="Fjords designed", + value="1,847", + delta="+3 coastlines", + ) + Sparkline( + data=[ + 820, + 950, + 1100, + 980, + 1250, + 1400, + 1350, + 1500, + 1680, + 1847, + ], + variant="success", + fill=True, + css_class="h-16", + ) + with Card(): + with CardHeader(): + CardTitle("Towel Incidents") + with CardContent(): + BarChart( + data=[ + {"month": "Jan", "lost": 8, "found": 5}, + {"month": "Feb", "lost": 24, "found": 15}, + {"month": "Mar", "lost": 12, "found": 28}, + {"month": "Apr", "lost": 35, "found": 19}, + {"month": "May", "lost": 18, "found": 38}, + {"month": "Jun", "lost": 42, "found": 30}, + ], + series=[ + ChartSeries(dataKey="lost", label="Lost"), + ChartSeries(dataKey="found", label="Found"), + ], + x_axis="month", + height=200, + bar_radius=4, + show_legend=True, + show_tooltip=True, + show_grid=True, + ) + + with Grid(columns=2, gap=4): + with Column(gap=4): + with Card(): + with CardContent(): + with Column(gap=2): + Checkbox(label="Towel packed", value=True) + Checkbox(label="Guide charged", value=True) + Checkbox( + label="Babel fish inserted", + value=False, + ) + with Card(): + with CardHeader(): + CardTitle("Marvin's Mood") + with CardContent(): + with Column(gap=3): + P("How's life?") + with Column(gap=2): + Button( + "Meh", + on_click=ShowToast( + "Noted. Enthusiasm levels nominal." + ), + ) + Button( + "Depressed", + variant="info", + on_click=ShowToast( + "I think you ought to " + "know I'm feeling very " + "depressed." + ), + ) + Button( + "Don't talk to me about life", + variant="warning", + on_click=ShowToast( + "Brain the size of a " + "planet and they ask me " + "to pick up a piece of " + "paper." + ), + ) + + with Column(gap=4): + with Card(): + with CardContent(): + with Row(gap=2, align="center"): + Loader(variant="dots", size="sm") + Muted("Marvin is thinking...") + with Card(): + with CardContent(): + DataTable( + columns=[ + DataTableColumn( + key="crew", + header="Crew", + sortable=True, + ), + DataTableColumn( + key="species", + header="Species", + sortable=True, + ), + DataTableColumn( + key="towel", + header="Towel?", + sortable=True, + ), + DataTableColumn( + key="status", + header="Status", + sortable=True, + ), + ], + rows=[ + { + "crew": "Arthur Dent", + "species": "Human", + "towel": "Yes", + "status": "Confused", + }, + { + "crew": "Ford Prefect", + "species": "Betelgeusian", + "towel": "Always", + "status": "Drinking", + }, + { + "crew": "Zaphod", + "species": "Betelgeusian", + "towel": "Lost it", + "status": "Presidential", + }, + { + "crew": "Trillian", + "species": "Human", + "towel": "Yes", + "status": "Navigating", + }, + { + "crew": "Marvin", + "species": "Android", + "towel": "No point", + "status": "Depressed", + }, + { + "crew": "Slartibartfast", + "species": "Magrathean", + "towel": "Somewhere", + "status": "Designing", + }, + ], + search=True, + paginated=False, + ) diff --git a/docs/apps/demos/pie-chart.html b/docs/apps/demos/pie-chart.html new file mode 100644 index 000000000..c712eeb33 --- /dev/null +++ b/docs/apps/demos/pie-chart.html @@ -0,0 +1,60 @@ + + + + Prefab + + + + + + +
+ + + \ No newline at end of file diff --git a/docs/apps/demos/pie-chart.py b/docs/apps/demos/pie-chart.py new file mode 100644 index 000000000..c1fb489e4 --- /dev/null +++ b/docs/apps/demos/pie-chart.py @@ -0,0 +1,21 @@ +from prefab_ui.app import PrefabApp +from prefab_ui.components import Column +from prefab_ui.components.charts import PieChart + +data = [ + {"category": "Bug", "count": 42}, + {"category": "Feature", "count": 28}, + {"category": "Docs", "count": 15}, + {"category": "Infra", "count": 10}, +] + +with PrefabApp() as app: + with Column(css_class="p-6"): + PieChart( + data=data, + data_key="count", + name_key="category", + inner_radius=50, + show_legend=True, + height=240, + ) diff --git a/docs/apps/demos/reactive.html b/docs/apps/demos/reactive.html new file mode 100644 index 000000000..29f05e2d3 --- /dev/null +++ b/docs/apps/demos/reactive.html @@ -0,0 +1,167 @@ + + + + Prefab + + + + + + +
+ + + \ No newline at end of file diff --git a/docs/apps/demos/reactive.py b/docs/apps/demos/reactive.py new file mode 100644 index 000000000..16f2f9829 --- /dev/null +++ b/docs/apps/demos/reactive.py @@ -0,0 +1,66 @@ +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + Column, + Row, + Select, + SelectOption, + Switch, + Text, +) +from prefab_ui.components.charts import BarChart, ChartSeries +from prefab_ui.components.control_flow import If +from prefab_ui.components.metric import Metric +from prefab_ui.rx import Rx + +region = Rx("region") + +north = [ + {"month": "Jan", "sales": 22000}, + {"month": "Feb", "sales": 25500}, + {"month": "Mar", "sales": 24200}, +] +south = [ + {"month": "Jan", "sales": 5800}, + {"month": "Feb", "sales": 6400}, + {"month": "Mar", "sales": 5600}, +] +west = [ + {"month": "Jan", "sales": 6000}, + {"month": "Feb", "sales": 6000}, + {"month": "Mar", "sales": 5600}, +] + +with PrefabApp( + state={ + "region": "north", + "north": north, + "south": south, + "west": west, + "show_target": True, + }, +) as app: + with Column( + gap=4, + css_class="p-6", + let={ + "data": "{{ region == 'south' ? south : region == 'west' ? west : north }}", + }, + ): + with Row(gap=4, align="center"): + with Select(name="region", css_class="w-40"): + SelectOption(value="north", label="North") + SelectOption(value="south", label="South") + SelectOption(value="west", label="West") + Switch(name="show_target", css_class="ml-auto") + Text("Show target", css_class="text-sm text-muted-foreground") + BarChart( + data=Rx("data"), + series=[ChartSeries(data_key="sales", label="Sales")], + x_axis="month", + height=200, + ) + with If(Rx("show_target")): + Metric( + label="Q1 Target", + value="$75,000", + ) diff --git a/docs/apps/demos/team-directory-reactive.html b/docs/apps/demos/team-directory-reactive.html new file mode 100644 index 000000000..f2ab5bf7e --- /dev/null +++ b/docs/apps/demos/team-directory-reactive.html @@ -0,0 +1,237 @@ + + + + Prefab + + + + + + +
+ + + \ No newline at end of file diff --git a/docs/apps/demos/team-directory-reactive.py b/docs/apps/demos/team-directory-reactive.py new file mode 100644 index 000000000..b6aa004f7 --- /dev/null +++ b/docs/apps/demos/team-directory-reactive.py @@ -0,0 +1,116 @@ +from collections import Counter + +from prefab_ui.actions import SetState +from prefab_ui.app import PrefabApp +from prefab_ui.components import ( + H3, + Badge, + Card, + CardContent, + CardHeader, + Column, + DataTable, + DataTableColumn, + Grid, + Row, + Small, + Text, +) +from prefab_ui.components.charts import PieChart +from prefab_ui.components.control_flow import If +from prefab_ui.rx import STATE, Rx + +MEMBERS = [ + { + "name": "Alice Chen", + "role": "Staff Engineer", + "office": "San Francisco", + "email": "alice@company.com", + "projects": 3, + }, + { + "name": "Bob Martinez", + "role": "Lead Designer", + "office": "New York", + "email": "bob@company.com", + "projects": 5, + }, + { + "name": "Carol Johnson", + "role": "Senior Engineer", + "office": "London", + "email": "carol@company.com", + "projects": 2, + }, + { + "name": "David Kim", + "role": "Product Manager", + "office": "San Francisco", + "email": "david@company.com", + "projects": 7, + }, + { + "name": "Eva Mueller", + "role": "Engineer", + "office": "Berlin", + "email": "eva@company.com", + "projects": 1, + }, + { + "name": "Frank Lee", + "role": "Data Scientist", + "office": "San Francisco", + "email": "frank@company.com", + "projects": 4, + }, + { + "name": "Grace Park", + "role": "Engineering Manager", + "office": "New York", + "email": "grace@company.com", + "projects": 6, + }, +] + +OFFICE_COUNTS = [ + {"office": office, "count": count} + for office, count in Counter(m["office"] for m in MEMBERS).items() +] + +with PrefabApp(state={"selected": None}) as app: + with Column(gap=4, css_class="p-6"): + with Grid(columns=[1, 2], gap=4): + PieChart( + data=OFFICE_COUNTS, + data_key="count", + name_key="office", + show_legend=True, + ) + DataTable( + columns=[ + DataTableColumn(key="name", header="Name", sortable=True), + DataTableColumn(key="role", header="Role", sortable=True), + DataTableColumn(key="office", header="Office", sortable=True), + ], + rows=MEMBERS, + search=True, + on_row_click=SetState("selected", Rx("$event")), + ) + + with If(STATE.selected): + with Card(): + with CardHeader(): + with Row(gap=2, align="center"): + H3(Rx("selected.name")) + Badge(Rx("selected.office")) + with CardContent(): + with Grid(columns=3, gap=4): + with Column(gap=0): + Small("Role") + Text(Rx("selected.role")) + with Column(gap=0): + Small("Email") + Text(Rx("selected.email")) + with Column(gap=0): + Small("Active Projects") + Text(Rx("selected.projects")) diff --git a/docs/apps/demos/team-directory.html b/docs/apps/demos/team-directory.html new file mode 100644 index 000000000..be577ddde --- /dev/null +++ b/docs/apps/demos/team-directory.html @@ -0,0 +1,127 @@ + + + + Prefab + + + + + + +
+ + + \ No newline at end of file diff --git a/docs/apps/demos/team-directory.py b/docs/apps/demos/team-directory.py new file mode 100644 index 000000000..7cfe21bc9 --- /dev/null +++ b/docs/apps/demos/team-directory.py @@ -0,0 +1,39 @@ +from collections import Counter + +from prefab_ui.app import PrefabApp +from prefab_ui.components import Column, DataTable, DataTableColumn, Grid +from prefab_ui.components.charts import PieChart + +members = [ + {"name": "Alice Chen", "role": "Staff Engineer", "office": "San Francisco"}, + {"name": "Bob Martinez", "role": "Lead Designer", "office": "New York"}, + {"name": "Carol Johnson", "role": "Senior Engineer", "office": "London"}, + {"name": "David Kim", "role": "Product Manager", "office": "San Francisco"}, + {"name": "Eva Mueller", "role": "Engineer", "office": "Berlin"}, + {"name": "Frank Lee", "role": "Data Scientist", "office": "San Francisco"}, + {"name": "Grace Park", "role": "Engineering Manager", "office": "New York"}, +] + +office_counts = [ + {"office": office, "count": count} + for office, count in Counter(m["office"] for m in members).items() +] + +with PrefabApp() as app: + with Column(gap=4, css_class="p-6"): + with Grid(columns=[1, 2], gap=4): + PieChart( + data=office_counts, + data_key="count", + name_key="office", + show_legend=True, + ) + DataTable( + columns=[ + DataTableColumn(key="name", header="Name", sortable=True), + DataTableColumn(key="role", header="Role", sortable=True), + DataTableColumn(key="office", header="Office", sortable=True), + ], + rows=members, + search=True, + ) diff --git a/docs/apps/development.mdx b/docs/apps/development.mdx index 7045cd939..0d3a71ac7 100644 --- a/docs/apps/development.mdx +++ b/docs/apps/development.mdx @@ -3,7 +3,6 @@ title: Development sidebarTitle: Development description: Preview and test your app tools locally without a full MCP host. icon: flask -tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' @@ -14,11 +13,11 @@ import { VersionBadge } from '/snippets/version-badge.mdx' The dev UI showing a rendered Prefab app with the MCP inspector panel -`fastmcp dev apps` launches a browser-based preview for your app tools. It starts your MCP server and a local dev UI side by side — you pick a tool, fill in its arguments, and see the rendered result in a new tab. No MCP host client needed. +`fastmcp dev apps` gives you a browser preview for your app tools without needing an MCP host client. It starts your server and a local dev UI side by side: you pick a tool, fill in its arguments, and the rendered result opens in a new tab. -This works with both [Prefab apps](/apps/prefab) and [custom HTML apps](/apps/low-level). +Works with both [Interactive Tools](/apps/prefab) and [custom HTML apps](/apps/low-level). -## Quick Start +## Quick start ```bash fastmcp dev apps server.py @@ -26,7 +25,7 @@ fastmcp dev apps server.py The dev UI opens at `http://localhost:8080`. Your MCP server runs on port 8000 with auto-reload enabled by default — save a file and the server restarts automatically. -## How It Works +## How it works The dev server does three things: @@ -36,7 +35,7 @@ When you submit a form, the dev server **calls your tool** via the MCP protocol A **reverse proxy** on `/mcp` forwards requests from the browser to your MCP server, avoiding CORS issues that would otherwise block the iframe-based renderer from talking to a different port. -## MCP Inspector +## MCP inspector The dev UI includes an inspector panel on the left side that captures MCP traffic in real time. It shows JSON-RPC messages flowing between the browser and your server — requests, responses, and AppBridge `postMessage` traffic. @@ -56,7 +55,7 @@ fastmcp dev apps server.py:mcp --mcp-port 9000 --dev-port 9090 --no-reload | Dev Port | `--dev-port` | `8080` | Port for the dev UI | | Auto-Reload | `--reload` / `--no-reload` | On | Watch files and restart the server on changes | -## Multiple Tools +## Multiple tools If your server has multiple app tools, the picker shows a dropdown. Each tool gets its own form and launch button. The tool's `title` is displayed when available, falling back to the tool name. diff --git a/docs/apps/examples.mdx b/docs/apps/examples.mdx index 024808f5d..5078120e7 100644 --- a/docs/apps/examples.mdx +++ b/docs/apps/examples.mdx @@ -3,14 +3,13 @@ title: Examples sidebarTitle: Examples description: Example apps you can run right now. icon: images -tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' -Every example below is a working FastMCP server you can run with `fastmcp dev apps` or connect to from any MCP host. The source is in `examples/apps/` in the repository. +Each tile below is a working FastMCP server you can run with `fastmcp dev apps` or connect to from any MCP host. Source lives in `examples/apps/` in the repository. @@ -44,7 +43,7 @@ Every example below is a working FastMCP server you can run with `fastmcp dev ap -## Running Examples +## Running the examples Preview any example in your browser with the dev server: @@ -53,11 +52,11 @@ pip install "fastmcp[apps]" fastmcp dev apps examples/apps/sales_dashboard/sales_dashboard_server.py ``` -The dev server opens an interactive browser UI where you can select a tool and provide arguments. In a real deployment, the LLM provides these arguments on the fly based on the conversation. For example, the quiz example works best when connected to an MCP host like Goose or Claude Desktop, where the LLM generates the questions itself. +The dev UI lets you pick a tool and fill in arguments. In a real deployment the LLM provides those arguments from conversation context — the quiz example especially shines when connected to a host like Goose or Claude Desktop, where the LLM generates the questions itself. -## Standalone Examples +## Standalone apps -### Sales Dashboard +### Sales dashboard A full dashboard with KPI metrics, revenue trends, segment breakdown, and a deal pipeline table. Shows what you can build with a single `app=True` tool and Prefab's chart and data components. @@ -65,9 +64,9 @@ A full dashboard with KPI metrics, revenue trends, segment breakdown, and a deal fastmcp dev apps examples/apps/sales_dashboard/sales_dashboard_server.py ``` -### System Monitor +### System monitor -Reads live CPU, memory, and disk stats from your machine using `psutil`. Auto-refreshes via `SetInterval` calling a backend tool, with a dropdown to control the refresh rate. The chart accumulates 100 data points over time. +Reads live CPU, memory, and disk stats from your machine using `psutil`. Auto-refreshes via `SetInterval` calling a backend tool, with a dropdown to control the refresh rate. The chart accumulates up to 100 data points over time. ```bash pip install psutil @@ -82,59 +81,12 @@ The LLM generates trivia questions and passes them to the tool. The user answers fastmcp dev apps examples/apps/quiz/quiz_server.py ``` -### Interactive Map +### Interactive map -Accepts addresses or place names, geocodes them via OpenStreetMap Nominatim (free, no API key), and renders an interactive Leaflet map using Prefab's `Embed` component with inline HTML. Proves that Prefab apps aren't limited to built-in components. +Accepts addresses or place names, geocodes them via OpenStreetMap Nominatim (free, no API key), and renders an interactive Leaflet map using Prefab's `Embed` component with inline HTML. A reminder that Prefab apps can break out of built-in components when they need to. ```bash fastmcp dev apps examples/apps/map/map_server.py ``` -## Built-in Providers - -These are ready-made capabilities you add with a single `add_provider()` call. - -### [File Upload](/apps/providers/file-upload) - -Drag-and-drop file upload. The user drops files, clicks Upload, and the server stores them. The LLM can list and read uploaded files through model-visible tools. - -```python -from fastmcp.apps.file_upload import FileUpload -mcp.add_provider(FileUpload()) -``` - -### [Approval](/apps/providers/approval) - -Human-in-the-loop confirmation. The LLM presents what it's about to do, the user clicks Approve or Reject, and the decision flows back as a message. - -```python -from fastmcp.apps.approval import Approval -mcp.add_provider(Approval()) -``` - -### [Choice](/apps/providers/choice) - -Present clickable options instead of asking users to type. Clean structured input without parsing free text. - -```python -from fastmcp.apps.choice import Choice -mcp.add_provider(Choice()) -``` - -### [Form Input](/apps/providers/form) - -Generate a validated form from a Pydantic model. Submission is validated against the model before being returned. - -```python -from fastmcp.apps.form import FormInput -mcp.add_provider(FormInput(model=MyModel)) -``` - -### [Generative UI](/apps/providers/generative) - -The LLM writes Prefab Python code at runtime and the result renders as a streaming interactive UI. Tailored visualizations for any data. See the [full guide](/apps/generative) for details. - -```python -from fastmcp.apps.generative import GenerativeUI -mcp.add_provider(GenerativeUI()) -``` +For ready-made building blocks like approvals, choice pickers, file uploads, and Pydantic forms, see the [Providers](/apps/providers/approval) group. diff --git a/docs/apps/fastmcp-app.mdx b/docs/apps/fastmcp-app.mdx new file mode 100644 index 000000000..f3463da66 --- /dev/null +++ b/docs/apps/fastmcp-app.mdx @@ -0,0 +1,469 @@ +--- +title: FastMCPApp +sidebarTitle: FastMCPApp +description: Wire an interactive UI to backend tools with managed visibility and composition safety. +icon: puzzle-piece +tag: NEW +--- + +import { VersionBadge } from '/snippets/version-badge.mdx' +import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx' + + + + + + + +Search a list, fill out a form, click save, the list updates. That pattern — UI that reads and writes data on the server — needs two things: backend tools that actually do the work, and a way to call them from the UI. `FastMCPApp` handles the wiring. + +You'll build up to the contacts app above by the end of this page. Let's start with something smaller. + +## A minimal interactive app + +The smallest interactive app: a form that saves a note, and a list that updates when the user submits. + +```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]) +``` + +The model sees one tool: `notes_app`. Calling it opens the UI. When the user submits the form, `CallTool("add_note")` fires, the server saves the note, returns the updated list, and `SetState("notes", RESULT)` writes that list back into state. `ForEach("notes")` re-renders. The model never sees `add_note` — it's UI-only. + +## Why not just `@mcp.tool(app=True)`? + +A fair question. Any [Interactive Tool](/apps/prefab) can call a server tool — there's nothing stopping you from putting `CallTool("add_note")` inside a regular `@mcp.tool(app=True)`. It works for one or two tools. Things get harder once the app grows: + +- Which tools should the model see, and which are UI-only? +- What happens to `CallTool("add_note")` when you mount this server under a namespace and the tool becomes `notes_add_note`? +- How do you keep it all wired correctly as you compose servers? + +`FastMCPApp` owns these concerns. Entry points register as model-visible. Backend tools register as UI-only by default. Backend tools get globally stable identifiers that survive namespacing, and `CallTool` accepts function references, so references stay valid when you compose servers. + +The rest of this page covers each piece in turn. + +## `@app.ui()` — entry points + +Entry points are what the model sees. They return a `PrefabApp` and default to `visibility=["model"]`, showing up in the LLM tool list but not callable from within the UI. + +```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") + ... + return PrefabApp(view=view) +``` + +`@app.ui()` supports the same options as `@mcp.tool`: `name`, `description`, `title`, `tags`, `icons`, `auth`, and `timeout`. + +## `@app.tool()` — backend tools + +Backend tools do the work. By default they're visible only to the UI (`visibility=["app"]`), not the model. + +```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) +``` + +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`. + +## `CallTool` — UI → backend + +`CallTool` is how the UI invokes a backend tool. Pass the tool's name (or a direct function reference): + +```python +from prefab_ui.actions.mcp import CallTool + +CallTool("save_contact", arguments={"name": "Alice", "email": "alice@example.com"}) + +# Or a function reference — resolves to a stable global key +CallTool(save_contact, arguments={...}) +``` + +Arguments can reference state with `Rx`: + +```python +from prefab_ui.rx import STATE + +CallTool("search", arguments={"query": STATE.search_term}) +``` + +### Handling results + +Server calls are async. Use `on_success` and `on_error` callbacks: + +```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 tool's return value, available inside `on_success`. `ERROR` (from `prefab_ui.rx`) is the counterpart inside `on_error`. Callbacks can be a single action or a list; they execute in order and short-circuit on error. + +### `result_key` shorthand + +When a tool's return value should replace a state key, use `result_key`: + +```python +CallTool("list_contacts", result_key="contacts") + +# same as: +CallTool("list_contacts", on_success=SetState("contacts", RESULT)) +``` + +## Actions + +`CallTool` is one of several actions. Actions attach to handlers like `on_click`, `on_submit`, and `on_change`. + +Client-side actions run instantly in the browser, no server round-trip: + +```python +from prefab_ui.actions import SetState, ToggleState, AppendState, PopState, ShowToast + +SetState("count", 42) +ToggleState("expanded") +AppendState("items", {"name": "New Item"}) +PopState("items", 0) +ShowToast("Done!", variant="success") +``` + +Pass a list to chain actions: + +```python +Button( + "Reset", + on_click=[ + SetState("query", ""), + SetState("results", []), + ShowToast("Cleared"), + ], +) +``` + +### Loading states + +A common pattern: disable a button and show a spinner while a call is in flight. + +```python +from prefab_ui.rx import 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"), + ], + ), + ], +) + +# PrefabApp(view=view, state={"saving": False, ...}) +``` + +## Forms + +Forms collect input and submit it to a tool. When submitted, named input values become the tool's arguments. + +### Manual forms + +```python +from prefab_ui.components import Form, Input, Select, SelectOption, Textarea, Button + +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") + Textarea(name="description", label="Description") + Button("Create Ticket") +``` + +On submit, `CallTool` receives `{"title": ..., "priority": ..., "description": ...}`. + +### Forms from Pydantic models + +For structured input, `Form.from_model()` generates the whole form — inputs, labels, validation: + +```python +from typing import Literal +from pydantic import BaseModel, Field + +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: + 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"), + ), + ) + return PrefabApp(view=view) + + +@app.tool() +def create_bug(data: BugReport) -> str: + return f"Created: {data.title}" +``` + +`str` becomes a text input, `Literal` becomes a select, `bool` becomes a checkbox. Field titles and defaults are respected. + +## Composition and namespacing + +The reason `FastMCPApp` exists — and why you'd pick it over plain `@mcp.tool(app=True)` with string-based `CallTool` — is composition safety. + +When you mount a server under a namespace, tool names get prefixed: + +```python +platform = FastMCP("Platform") +platform.mount("contacts", contacts_server) + +# "save_contact" becomes "contacts_save_contact" +``` + +`CallTool("save_contact")` would now be broken. But `CallTool(save_contact)` with a function reference resolves to a globally stable identifier that bypasses the namespace. Your app works the same whether standalone or mounted. + +### Mounting + +`FastMCPApp` is a Provider. Add it to a server with `providers=` or `add_provider`: + +```python +mcp = FastMCP("Platform", providers=[app]) + +# or +mcp = FastMCP("Platform") +mcp.add_provider(app) +``` + +Multiple apps can coexist; each gets its own global keys, so there's no collision even if two apps have a tool named `save`. + +```python +mcp = FastMCP("Platform", providers=[contacts_app, inventory_app, billing_app]) +``` + +### Running standalone + +For development, `FastMCPApp` has a `run()` shortcut that wraps itself in a temporary `FastMCP` server: + +```python +app = FastMCPApp("Contacts") +# ... register tools ... + +if __name__ == "__main__": + app.run() +``` + +## A full example: contact manager + +This brings everything together — entry point, backend tools, Pydantic form, manual form, state, actions, and multi-visibility. + +```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 + +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 = 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() +``` + +Also available as a runnable server at `examples/apps/contacts/contacts_server.py`. + +## Next steps + +- **[Interactive Tools](/apps/prefab)** — the building blocks: charts, tables, dashboards, reactive state +- **[Examples](/apps/examples)** — complete working servers +- **[Development](/apps/development)** — preview and test app tools locally +- **[Prefab UI docs](https://prefab.prefect.io)** — full component reference diff --git a/docs/apps/generative.mdx b/docs/apps/generative.mdx index 86d306dd7..b6293d32b 100644 --- a/docs/apps/generative.mdx +++ b/docs/apps/generative.mdx @@ -10,7 +10,9 @@ import { VersionBadge } from '/snippets/version-badge.mdx' -Generative UI means the LLM writes the UI code at runtime. Instead of calling a pre-built tool with a fixed interface, the model writes Prefab Python code tailored to the current data and request. The user watches the UI build up in real time as the model generates code. +