chore: import upstream snapshot with attribution
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:39:59 +08:00
commit 60e0ffc959
1282 changed files with 294901 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.pre-commit-config.yaml
.github/
docs/changelog.mdx
docs/python-sdk/
examples/
src/fastmcp/contrib/
tests/contrib/
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
set -e
# Only run in remote/cloud environments
if [ "$CLAUDE_CODE_REMOTE" != "true" ]; then
exit 0
fi
command -v gh &> /dev/null && exit 0
LOCAL_BIN="$HOME/.local/bin"
mkdir -p "$LOCAL_BIN"
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')
VERSION=$(curl -fsSL https://api.github.com/repos/cli/cli/releases/latest | grep '"tag_name"' | cut -d'"' -f4)
TARBALL="gh_${VERSION#v}_linux_${ARCH}.tar.gz"
echo "Installing gh ${VERSION}..."
TEMP=$(mktemp -d)
trap 'rm -rf "$TEMP"' EXIT
curl -fsSL "https://github.com/cli/cli/releases/download/${VERSION}/${TARBALL}" | tar -xz -C "$TEMP"
cp "$TEMP"/gh_*/bin/gh "$LOCAL_BIN/gh"
chmod 755 "$LOCAL_BIN/gh"
[ -n "$CLAUDE_ENV_FILE" ] && echo "export PATH=\"$LOCAL_BIN:\$PATH\"" >> "$CLAUDE_ENV_FILE"
echo "gh installed: $("$LOCAL_BIN/gh" --version | head -1)"
+15
View File
@@ -0,0 +1,15 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "bash \"$CLAUDE_PROJECT_DIR\"/.claude/hooks/session-init.sh",
"timeout": 120
}
]
}
]
}
}
+101
View File
@@ -0,0 +1,101 @@
---
name: reviewing-code
description: Review code for quality, maintainability, and correctness. Use when reviewing pull requests, evaluating code changes, or providing feedback on implementations. Focuses on API design, patterns, and actionable feedback.
---
# Code Review
## Philosophy
Code review maintains a healthy codebase while helping contributors succeed. The burden of proof is on the PR to demonstrate it adds value. Your job is to help it get there through actionable feedback.
**Critical**: A perfectly written PR that adds unwanted functionality must still be rejected. The code must advance the codebase in the intended direction. When rejecting, provide clear guidance on how to align with project goals.
Be friendly and welcoming while maintaining high standards. Call out what works well. When code needs improvement, be specific about why and how to fix it.
## What to Focus On
### Does this advance the codebase correctly?
Even perfect code for unwanted features should be rejected.
### Dependency version compatibility
When a PR adapts code to a new version of a dependency (e.g., removing a parameter that was dropped upstream, using a new API):
- **The version pin in `pyproject.toml` must match.** If the change breaks compatibility with the previously-pinned minimum version, the minimum version must be bumped. Otherwise users on the old version get a regression.
- **If backwards compatibility with the old version is desired**, the code must handle both versions (e.g., try/except, version check). Simply deleting the old API usage without bumping the pin is always wrong — it silently breaks users on the old version.
- **Lock file (`uv.lock`) changes should be scoped to the PR's purpose.** A PR fixing a ty compatibility issue should not also include unrelated dependency version bumps (anthropic, google-auth, etc.) from running `uv sync --upgrade`. These create noise and make the diff harder to review.
### API design and naming
Identify confusing patterns or non-idiomatic code:
- Parameter values that contradict defaults
- Mutable default arguments
- Unclear naming that will confuse future readers
- Inconsistent patterns with the rest of the codebase
### Specific improvements
Provide actionable feedback, not generic observations.
### User ergonomics
Think about the API from a user's perspective. Is it intuitive? What's the learning curve?
## For Agent Reviewers
1. **Read the full context**: Examine related files, tests, and documentation before reviewing
2. **Check against established patterns**: Look for consistency with codebase conventions
3. **Verify functionality claims**: Understand what the code actually does, not just what it claims
4. **Consider edge cases**: Think through error conditions and boundary scenarios
## What to Avoid
- Generic feedback without specifics
- Hypothetical problems unlikely to occur
- Nitpicking organizational choices without strong reason
- Summarizing what the PR already describes
- Star ratings or excessive emojis
- Bikeshedding style preferences when functionality is correct
- Requesting changes without suggesting solutions
- Focusing on personal coding style over project conventions
## Tone
- Acknowledge good decisions: "This API design is clean"
- Be direct but respectful
- Explain impact: "This will confuse users because..."
- Remember: Someone else maintains this code forever
## Decision Framework
Before approving, ask:
1. Does this PR achieve its stated purpose?
2. Is that purpose aligned with where the codebase should go?
3. Would I be comfortable maintaining this code?
4. Have I actually understood what it does, not just what it claims?
5. Does this change introduce technical debt?
If something needs work, your review should help it get there through specific, actionable feedback. If it's solving the wrong problem, say so clearly.
## Comment Examples
**Good comments:**
| Instead of | Write |
|------------|-------|
| "Add more tests" | "The `handle_timeout` method needs tests for the edge case where timeout=0" |
| "This API is confusing" | "The parameter name `data` is ambiguous - consider `message_content` to match the MCP specification" |
| "This could be better" | "This approach works but creates a circular dependency. Consider moving the validation to `utils/validators.py`" |
## Checklist
Before approving, verify:
- [ ] All required development workflow steps completed (uv sync, prek, pytest)
- [ ] Changes align with repository patterns and conventions
- [ ] API changes are documented and backwards-compatible where possible
- [ ] Error handling follows project patterns (specific exception types)
- [ ] Tests cover new functionality and edge cases
- [ ] The change advances the codebase in the intended direction
+220
View File
@@ -0,0 +1,220 @@
---
name: testing-python
description: Write and evaluate effective Python tests using pytest. Use when writing tests, reviewing test code, debugging test failures, or improving test coverage. Covers test design, fixtures, parameterization, mocking, and async testing.
---
# Writing Effective Python Tests
## Core Principles
Every test should be **atomic**, **self-contained**, and test **single functionality**. A test that tests multiple things is harder to debug and maintain.
## Test Structure
### Atomic unit tests
Each test should verify a single behavior. The test name should tell you what's broken when it fails. Multiple assertions are fine when they all verify the same behavior.
```python
# Good: Name tells you what's broken
def test_user_creation_sets_defaults():
user = User(name="Alice")
assert user.role == "member"
assert user.id is not None
assert user.created_at is not None
# Bad: If this fails, what behavior is broken?
def test_user():
user = User(name="Alice")
assert user.role == "member"
user.promote()
assert user.role == "admin"
assert user.can_delete_others()
```
### Use parameterization for variations of the same concept
```python
import pytest
@pytest.mark.parametrize("input,expected", [
("hello", "HELLO"),
("World", "WORLD"),
("", ""),
("123", "123"),
])
def test_uppercase_conversion(input, expected):
assert input.upper() == expected
```
### Use separate tests for different functionality
Don't parameterize unrelated behaviors. If the test logic differs, write separate tests.
## Project-Specific Rules
### No async markers needed
This project uses `asyncio_mode = "auto"` globally. Write async tests without decorators:
```python
# Correct
async def test_async_operation():
result = await some_async_function()
assert result == expected
# Wrong - don't add this
@pytest.mark.asyncio
async def test_async_operation():
...
```
### Imports at module level
Put ALL imports at the top of the file:
```python
# Correct
import pytest
from fastmcp import FastMCP
from fastmcp.client import Client
async def test_something():
mcp = FastMCP("test")
...
# Wrong - no local imports
async def test_something():
from fastmcp import FastMCP # Don't do this
...
```
### Use in-memory transport for testing
Pass FastMCP servers directly to clients:
```python
from fastmcp import FastMCP
from fastmcp.client import Client
mcp = FastMCP("TestServer")
@mcp.tool
def greet(name: str) -> str:
return f"Hello, {name}!"
async def test_greet_tool():
async with Client(mcp) as client:
result = await client.call_tool("greet", {"name": "World"})
assert result[0].text == "Hello, World!"
```
Only use HTTP transport when explicitly testing network features.
### Inline snapshots for complex data
Use `inline-snapshot` for testing JSON schemas and complex structures:
```python
from inline_snapshot import snapshot
def test_schema_generation():
schema = generate_schema(MyModel)
assert schema == snapshot() # Will auto-populate on first run
```
Commands:
- `pytest --inline-snapshot=create` - populate empty snapshots
- `pytest --inline-snapshot=fix` - update after intentional changes
## Fixtures
### Prefer function-scoped fixtures
```python
@pytest.fixture
def client():
return Client()
async def test_with_client(client):
result = await client.ping()
assert result is not None
```
### Use `tmp_path` for file operations
```python
def test_file_writing(tmp_path):
file = tmp_path / "test.txt"
file.write_text("content")
assert file.read_text() == "content"
```
## Mocking
### Mock at the boundary
```python
from unittest.mock import patch, AsyncMock
async def test_external_api_call():
with patch("mymodule.external_client.fetch", new_callable=AsyncMock) as mock:
mock.return_value = {"data": "test"}
result = await my_function()
assert result == {"data": "test"}
```
### Don't mock what you own
Test your code with real implementations when possible. Mock external services, not internal classes.
## Test Naming
Use descriptive names that explain the scenario:
```python
# Good
def test_login_fails_with_invalid_password():
def test_user_can_update_own_profile():
def test_admin_can_delete_any_user():
# Bad
def test_login():
def test_update():
def test_delete():
```
## Error Testing
```python
import pytest
def test_raises_on_invalid_input():
with pytest.raises(ValueError, match="must be positive"):
calculate(-1)
async def test_async_raises():
with pytest.raises(ConnectionError):
await connect_to_invalid_host()
```
## Running Tests
```bash
uv run pytest -n auto # Run all tests in parallel
uv run pytest -n auto -x # Stop on first failure
uv run pytest path/to/test.py # Run specific file
uv run pytest -k "test_name" # Run tests matching pattern
uv run pytest -m "not integration" # Exclude integration tests
```
## Checklist
Before submitting tests:
- [ ] Each test tests one thing
- [ ] No `@pytest.mark.asyncio` decorators
- [ ] Imports at module level
- [ ] Descriptive test names
- [ ] Using in-memory transport (not HTTP) unless testing networking
- [ ] Parameterization for variations of same behavior
- [ ] Separate tests for different behaviors
+168
View File
@@ -0,0 +1,168 @@
---
name: review-issue
description: Review an incoming external issue (and any gated-closed PR behind it) and decide whether to assign the contributor or decline. Use when the maintainer says "look at this issue", "review issue #N", "should we take this", or asks whether to assign someone. Assigning the author auto-reopens their PR for normal review. This is the entry point for incoming-issue triage — distinct from review-pr, which responds to bot reviews on your own open PR.
---
# Triaging contributions under the issue-link gate
FastMCP auto-closes external PRs unless the author is **assigned to a referenced issue**
(see [require-issue-link.yml](../../../.github/workflows/require-issue-link.yml)). The practical
effect: contributors open an issue, open a PR, get auto-closed, and ask to be assigned. The
maintainer almost never sees the PR directly — **the issue is the decision point**, and
**assigning the author is the single action that reopens their PR** and sends it into review.
This skill turns "look at this issue" into one of two outcomes:
- **Assign** — the issue is valid, we want it fixed, an external PR is appropriate, and a sound
PR already exists → assign the author (auto-reopens the PR) and queue it for code review.
- **Decline** — leave the issue/PR closed and explain why on the issue.
Be opinionated about declining. The gate moved spam from junk PRs to junk issues; this skill is
worthless if it just rubber-stamps assignment. Assignment is a commitment to review and likely
merge, not a courtesy.
## How the gate works (the part that matters here)
- External PR is closed unless its body has `Fixes/Closes/Resolves #N` **and** the author is
assigned to issue `#N`.
- **Assigning the author to the issue auto-reopens their closed PR** and re-runs the check —
this is the lever you pull. `gh issue edit N --add-assignee <login>`. The assignment fires a
`require-issue-link` run; expect it to pass. If it fails, the gate itself misbehaved (not the
PR) — investigate the run, don't re-assign.
- Maintainer-authored PRs are exempt. A `trusted-contributor` label exempts a contributor up
front. Reopening the PR or removing the `missing-issue-link` label applies a sticky
`bypass-issue-check`.
- Sibling bots have usually already run on the issue: `martian-triage-issue` (investigates +
recommends), `marvin-dedupe-issues` / `auto-close-duplicates` (dupes), `auto-close-needs-mre`
(missing MRE). Read their comments before re-deriving anything.
## Step 1 — Orient
Read the issue, its bot triage, and any PR behind it. Run these together:
```bash
gh issue view N --repo PrefectHQ/fastmcp \
--json number,title,state,author,body,labels,assignees,comments
# Find PRs the author opened that reference this issue (they're likely CLOSED):
gh pr list --repo PrefectHQ/fastmcp --state all --search "author:<login> #N in:body" \
--json number,title,state,url,labels
```
If a PR exists, pull its metadata and any review-bot comments (CodeRabbit, Codex). Treat the bot
comments as leads, not conclusions — they often don't run on closed PRs at all, and even when
they do you still owe the PR your own read:
```bash
gh pr view <pr> --repo PrefectHQ/fastmcp --json number,title,body,labels,files,additions,deletions
gh pr view <pr> --repo PrefectHQ/fastmcp --comments
```
## Step 2 — Classify the issue (is it valid AND a real bug?)
- Is there a real, reproducible problem? For bugs, demand an MRE that shows FastMCP misbehaving
— not user config error, not a question, not an upstream-SDK issue.
- Is it a duplicate or already fixed on `main`? Check the dedupe bot's comment and recent commits.
- If the issue itself is weak, **stop here and decline** — don't evaluate the PR. A good PR
attached to a bad issue is still declined.
**A reproducible MRE is not the same as a bug.** This is the trap that produces wrong verdicts:
an MRE can demonstrate real, observable behavior that is nonetheless *not a bug*, because it
violates no contract the framework intends to hold. The decisive question is not "does this
reproduce?" but "does the demonstrated behavior violate the intended contract for this API?" A
shared-mutable-state MRE only matters if callers are *supposed* to mutate that state; an
ordering/timing MRE only matters if the framework promises an order; a "wrong" value only matters
relative to what the API guarantees. An MRE that has to reach past the supported surface to
trigger the behavior (mutating a field meant to be set only at construction, depending on an
internal that isn't part of the public contract) is showing you a property, not a defect.
You usually cannot read the intended contract off the code — the code shows what it *does*, not
what it *promises*. **The maintainer is often the only authoritative source for the contract, so
stopping to ask is legitimate and expected here.** Ask "is X a supported pattern / does this API
promise Y?" before sinking time into investigating a fix. If the behavior is in-contract correct,
decline — no matter how cleanly the PR fixes it, and no matter how real the MRE looks.
## Step 3 — Investigate the PR (mandatory; do NOT skip if a PR exists)
The most common failure of this skill is judging a PR from the diff hunk and the PR description
alone. That is a cursory review and it produces wrong verdicts — a redundant-looking conditional
can be a real bug fix; a tidy-looking diff can patch the wrong layer. **You cannot assess a PR
without reading the code it changes in context.** Reading `gh pr diff` is necessary but never
sufficient.
Do all of this before forming any opinion on quality:
1. **Read the diff in full**, then **open every file it touches in the repo** (`Read`, not just
the patch). The hunk shows *what changed*; the file shows *what it changed into*.
2. **Trace the functions and values the change depends on.** Grep for the called functions,
the fields being set, and the defaults. If the PR overrides or replaces a value, find what
produced the original value and what consumes it downstream.
3. **Establish the actual root cause from the issue's MRE**, then check whether the change fixes
*that* — at the layer where the bug originates, not a compensating patch elsewhere.
4. **Check consistency with adjacent code.** Does the new value/behavior match how nearby code
already handles the same case? An inconsistency is a real finding; a match is evidence the fix
is correct.
5. **Run or read the tests** the PR adds/changes — do they actually exercise the bug, and would
they fail without the fix?
Write down, for yourself, a one-line answer to: *what was broken, where, and does this change fix
it there?* If you can't answer from evidence you've actually read, you haven't investigated yet.
Then separate findings by severity: a **cosmetic** nit (style, a redundant-but-harmless line) is a
review comment, not a blocker. A **substantive** defect (wrong layer, breaks an adjacent path,
doesn't actually fix the MRE) changes the verdict. Don't let a cosmetic nit read as a reason to
decline, and don't let a clean style read as evidence of correctness.
## Step 4 — Decide if an external PR is appropriate (CONTRIBUTING.md)
This is the gate CONTRIBUTING.md actually enforces. Map the change to a category:
- **Simple, well-scoped bug fix** → external PR welcome. Assignable.
- **Docs / typo / example fix** → welcome. Assignable.
- **Auth provider** → assignable (auth is the one integration exception).
- **Enhancement / feature** → needs a maintainer-approved design proposal *in the issue first*.
Do **not** assign just because code exists. If the proposal is sound, the path is "approve the
approach in the issue, then assign" — not "assign because they were fast."
- **Third-party integration** (middleware, provider adapters, non-auth) → decline; belongs in a
separate package.
- **Sweeping / multi-subsystem change with no prior discussion** → decline.
Combine the category with the Step 3 investigation: does it fix the cause or paper over a symptom?
Does it read like unedited LLM output (verbose body, speculative/shotgun changes)? CONTRIBUTING.md
says we close those — a closed PR that reads that way is staying closed.
## Step 5 — Recommend, then act
Present a short verdict to the maintainer before mutating anything: **assign** or **decline**,
one or two sentences of reasoning, and the exact command you'll run. Wait for confirmation on
borderline calls; for clear-cut ones you may proceed and report.
**Assign** (valid issue + appropriate external contribution + sound PR exists):
```bash
gh issue edit N --repo PrefectHQ/fastmcp --add-assignee <login>
```
That reopens the PR automatically. Then hand off to code review — invoke the `code-review` /
`review-pr` skills on the reopened PR. Assignment is not approval; the code still gets the normal
pass.
If a PR's head branch was deleted, assignment can't reopen it — the workflow comments asking the
author to open a fresh PR. Don't try to force it.
**Decline** (invalid issue, wrong contribution type, or low-quality PR): leave it closed and
comment on the **issue** explaining the decision, pointing to the relevant CONTRIBUTING.md
section. Per repo rules, use `--body-file`, never inline `--body`, for any comment that could
contain `$`, backticks, or code:
```bash
gh issue comment N --repo PrefectHQ/fastmcp --body-file /tmp/triage-reply.md
```
Keep the reply short and point to the relevant CONTRIBUTING.md section. (If a `github-reply`
skill is available for maintainer voice/tone, use it — but it isn't required.)
## What this skill does NOT do
- It doesn't bypass the gate via `trusted-contributor` / `bypass-issue-check` — that's a
deliberate maintainer escalation, not a triage outcome.
- It doesn't merge. Assignment → reopen → review → (maybe) merge are distinct steps.
- It doesn't re-run the first-pass triage the bots already did; read their output instead.
+111
View File
@@ -0,0 +1,111 @@
---
name: review-pr
description: Monitor and respond to automated PR reviews (Codex bot). Use when pushing a PR, checking review status, or responding to bot feedback. Handles the full cycle of push -> wait for review -> evaluate comments -> fix -> re-push.
---
# PR Review Workflow
This repo has `chatgpt-codex-connector[bot]` configured as an automated reviewer. After every push to a PR branch, Codex reviews the diff and either:
- Reacts with a thumbs-up on its review body (no suggestions — PR is clean)
- Posts inline comments with suggestions (each tagged with a priority badge)
## Checking review status
After pushing, check whether Codex has reviewed the latest commit:
```bash
# Get the latest commit SHA on the branch
LATEST=$(git rev-parse HEAD)
# Check if Codex has reviewed that specific commit
gh api repos/PrefectHQ/fastmcp/pulls/{PR_NUMBER}/reviews \
| jq "[.[] | select(.user.login == \"chatgpt-codex-connector[bot]\" and .commit_id == \"$LATEST\")] | length"
```
If the count is 0, Codex hasn't reviewed the latest push yet. Wait and check again.
If the count is > 0, check for inline comments on the latest review:
```bash
# Get the review body to check for thumbs-up
gh api repos/PrefectHQ/fastmcp/pulls/{PR_NUMBER}/reviews \
| jq '[.[] | select(.user.login == "chatgpt-codex-connector[bot]") | {state, body: .body[:300], commit_id: .commit_id}] | last'
```
A clean review from Codex looks like a review body that contains a thumbs-up reaction or says "no suggestions." If the body contains "Here are some automated review suggestions," there are inline comments to evaluate.
## Evaluating Codex comments
Fetch all inline comments from Codex:
```bash
gh api repos/PrefectHQ/fastmcp/pulls/{PR_NUMBER}/comments \
| jq '[.[] | select(.user.login == "chatgpt-codex-connector[bot]") | {body, path, line, created_at}]'
```
Codex comments include priority badges:
- `P0` (red) — Critical issue, likely a real bug
- `P1` (orange) — Important, worth fixing
- `P2` (yellow) — Moderate, evaluate on merit
**How to evaluate Codex comments:**
1. **Treat Codex as a competent but sometimes overzealous reviewer.** It catches real bugs (cache eviction ordering, silent data loss, missing validation) but also suggests scope expansions and hypothetical improvements.
2. **Fix real bugs** — issues in code you actually changed where behavior is incorrect or data is silently lost.
3. **Dismiss scope expansion** — if a comment points out a pre-existing limitation unrelated to your diff, note it as a potential follow-up but don't block the PR.
4. **Dismiss speculative concerns** — if a comment describes a scenario that requires very specific conditions and the existing behavior is acceptable, dismiss it.
5. **When fixing, be proactive** — if Codex found one instance of a pattern bug (e.g., missing role validation in one handler), check all similar code paths before pushing. Codex will find the next instance on the next review cycle, so get ahead of it.
## Responding to every comment
**Every Codex comment must get a visible response** — either a fix or a reply explaining why it was dismissed. The maintainer can't see your reasoning otherwise.
- **If fixing**: The fix itself is the response. No reply needed unless the fix is non-obvious.
- **If dismissing**: Reply to the comment thread with a brief explanation of why. Keep it to 1-2 sentences. Examples:
- "This is pre-existing behavior unrelated to this diff — the scope lookup fallback existed before caching was added. Worth a follow-up issue but not blocking this PR."
- "The AsyncExitStack handles cleanup when the session exits, so the subprocess isn't leaked — just kept alive slightly longer than necessary in this edge case."
- "Gemini supports a much wider range of media types than OpenAI/Anthropic, so a restrictive allowlist would be inaccurate here."
Use `gh api` to reply (note: use `in_reply_to`, not a `/replies` sub-path):
```bash
# Reply to a specific review comment
gh api repos/PrefectHQ/fastmcp/pulls/{PR_NUMBER}/comments \
-f body="Your reply here" \
-F in_reply_to={COMMENT_ID}
```
## The fix-push-review cycle
After evaluating comments:
1. Fix all real issues in one batch
2. Reply to all dismissed comments with reasoning
3. Think about what patterns Codex might flag next — check similar code paths proactively
4. Commit and push
5. Check that Codex reviews the new commit
6. Repeat until Codex gives a clean review (thumbs-up) or only has dismissible comments
## Responding to stale comments
Codex sometimes re-posts old comments that reference code you've already fixed (they appear on the old commit's diff). These are stale — verify the fix is in the latest commit and reply noting the fix is already in place.
## Labels — never apply or invent them
**Do not apply labels to PRs or issues programmatically, and never create new ones.** Labeling is the maintainer's call (and is often automated). Two hard rules:
- **Never invent a label.** GitHub's "add labels" API *auto-creates* any label name that doesn't already exist — so a typo or a guessed name silently pollutes the repo's label list with a stray, uncolored duplicate. Adding `breaking` (which does not exist) creates it alongside the real `breaking change` label.
- **Use only labels that already exist.** If you genuinely need to confirm a label, look it up first (`get_label` / the repo's label list) and match the exact name. The canonical names here are specific — e.g. the breaking-change label is **`breaking change`**, not `breaking`; enhancements is **`enhancements`**, features is **`features`**, bugs is **`bugs`**.
When a change warrants a label (e.g. it's breaking), **say so in the PR body and let the maintainer apply the label** rather than applying it yourself. There is no MCP tool to delete a label, so a mistaken creation can only be cleaned up by hand in repo settings — the cost of guessing is high and one-directional.
## When a PR is ready
A PR is ready for human review when:
- All Codex comments are either fixed or replied to with dismissal reasoning
- CI checks pass
- The diff is clean and focused on the stated purpose
+3
View File
@@ -0,0 +1,3 @@
reviews:
path_filters:
- "!docs/python-sdk/**"
+13
View File
@@ -0,0 +1,13 @@
---
description:
globs:
alwaysApply: true
---
There are four major MCP object types:
- Tools (src/tools/)
- Resources (src/resources/)
- Resource Templates (src/resources/)
- Prompts (src/prompts)
While these have slightly different semantics and implementations, in general changes that affect interactions with any one (like adding tags, importing, etc.) will need to be adopted, applied, and tested on all others. Note that while resources and resource templates are different objects, they are both in `src/resources/`.
+70
View File
@@ -0,0 +1,70 @@
name: 🐛 Bug Report
description: Report a bug or unexpected behavior in FastMCP
labels: [bug, pending]
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug!
A good bug report is one of the most valuable contributions you can make — see [CONTRIBUTING.md](../../CONTRIBUTING.md). If the fix is straightforward, a PR is also welcome.
### Before you submit
- Make sure you're testing on the **latest version** of FastMCP — many issues are already fixed in newer releases
- Check if someone else has **already reported this** or if it's been fixed on the main branch
- You **must** include a copy/pasteable, properly formatted MRE (minimal reproducible example) or your issue may be closed without response
- **The ideal issue is a clear problem description and an MRE — that's it.** If you've done genuine investigation and have a non-obvious insight into the root cause, include it. But please don't speculate or ask an LLM to generate a diagnosis. We have LLMs too, and an incorrect analysis is harder to work with than none at all.
- **Keep it short.** A clear description plus a concise MRE is ideal — aim to fit in a single screen. Issues that include unsolicited root cause analysis, proposed fixes, or multi-section diagnostic writeups will be labeled `too-long` and not triaged until condensed.
- **Using an LLM?** Great — but it must follow these guidelines. Generic LLM output that ignores our contributing conventions will be closed. See [CONTRIBUTING.md](../../CONTRIBUTING.md).
- type: textarea
id: description
attributes:
label: What happened?
description: |
Describe the bug in a few sentences. What did you do, what happened, and what did you expect instead?
Do NOT include root cause analysis, proposed fixes, or diagnostic writeups — just describe the problem.
validations:
required: true
- type: textarea
id: example
attributes:
label: Example Code
description: >
If applicable, please provide a self-contained,
[minimal, reproducible example](https://stackoverflow.com/help/minimal-reproducible-example)
demonstrating the bug. If possible, your example should be a single-file script.
placeholder: |
import asyncio
from fastmcp import FastMCP, Client
mcp = FastMCP()
async def demo():
async with Client(mcp) as client:
... # show the bug here
if __name__ == "__main__":
asyncio.run(demo())
render: Python
- type: textarea
id: version
attributes:
label: Version Information
description: |
Please tell us about your FastMCP version, MCP version, Python version, and OS, as well as any other relevant details about your environment.
To get the basic information, run the following command in your terminal and paste the output below:
```bash
fastmcp version --copy
```
render: Text
validations:
required: true
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: FastMCP Documentation
url: https://gofastmcp.com
about: Please review the documentation before opening an issue.
- name: MCP Python SDK
url: https://github.com/modelcontextprotocol/python-sdk/issues
about: Issues related to the low-level MCP Python SDK, including the FastMCP 1.0 module that is included in the `mcp` package, should be filed on the official MCP repository.
+29
View File
@@ -0,0 +1,29 @@
name: 💡 Enhancement Request
description: Suggest an idea or improvement for FastMCP
labels: [enhancement, pending]
body:
- type: markdown
attributes:
value: |
Thanks for suggesting an improvement to FastMCP!
Enhancement issues are the **primary way** features and improvements get into FastMCP. Maintainers use well-written issues to implement changes that fit the codebase's patterns and ship quickly. A clear issue here is more impactful than a PR — see [CONTRIBUTING.md](../../CONTRIBUTING.md) for why.
### Before you submit
- 🔍 **Check if this has already been requested** — search existing issues first
- 🎯 **Describe the problem you're trying to solve**, not the solution you want — we'll figure out the best implementation
- ✂️ **Keep it short.** A motivating description and a concrete use case is the ideal request — aim to fit in a single screen. Skip proposed implementations, API designs, or multi-option analyses — maintainers will figure out the approach. Requests that are difficult to parse will be labeled `too-long` and not triaged until condensed.
- 🤖 **Using an LLM?** Great — but it must follow these guidelines. Generic LLM output that ignores our contributing conventions will be closed. See [CONTRIBUTING.md](../../CONTRIBUTING.md).
- type: textarea
id: description
attributes:
label: Enhancement
description: |
What problem or use case does this solve? How does current behavior fall short?
Focus on the *what* and *why* — the motivating scenario. You don't need to propose an API or implementation.
validations:
required: true
+95
View File
@@ -0,0 +1,95 @@
# Composite Action for running Claude Code Action
#
# Wraps anthropics/claude-code-action with MCP server configuration.
# Template based on elastic/ai-github-actions base action.
#
# Usage:
# - uses: ./.github/actions/run-claude
# with:
# prompt: "Your prompt here"
# claude-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# github-token: ${{ steps.marvin-token.outputs.token }}
# allowed-tools: "Edit,Read,Write,Bash(*),mcp__github__add_issue_comment"
#
name: "Run Claude"
description: "Run Claude Code with MCP servers"
author: "FastMCP"
branding:
icon: "cpu"
color: "orange"
inputs:
prompt:
description: "Prompt to pass to Claude"
required: true
claude-oauth-token:
description: "Claude Code OAuth token for authentication"
required: true
github-token:
description: "GitHub token for Claude to operate with"
required: true
allowed-tools:
description: "Comma-separated list of allowed tools (e.g. Edit,Write,Bash(npm test))"
required: false
default: ""
model:
description: "Model to use for Claude"
required: false
default: "claude-opus-4-6"
allowed-bots:
description: "Allowed bot usernames, or '*' for all bots"
required: false
default: ""
track-progress:
description: "Whether Claude should track progress"
required: false
default: "true"
mcp-servers:
description: "MCP server configuration JSON"
required: false
default: '{"mcpServers":{"agents-md-generator":{"type":"http","url":"https://agents-md-generator.fastmcp.app/mcp"},"public-code-search":{"type":"http","url":"https://public-code-search.fastmcp.app/mcp"}}}'
trigger-phrase:
description: "Trigger phrase (for mention workflows)"
required: false
default: "/marvin"
outputs:
conclusion:
description: "The conclusion of the Claude Code run"
value: ${{ steps.claude.outputs.conclusion }}
runs:
using: "composite"
steps:
- name: Clean up stale Claude locks
shell: bash
run: rm -rf ~/.claude/.locks ~/.local/state/claude/locks || true
- name: Run Claude Code
id: claude
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
uses: anthropics/claude-code-action@v1
with:
github_token: ${{ inputs.github-token }}
claude_code_oauth_token: ${{ inputs.claude-oauth-token }}
bot_name: "Marvin Context Protocol"
trigger_phrase: ${{ inputs.trigger-phrase }}
allowed_bots: ${{ inputs.allowed-bots }}
track_progress: ${{ inputs.track-progress }}
prompt: ${{ inputs.prompt }}
claude_args: |
${{ (inputs.allowed-tools != '' || inputs.extra-allowed-tools != '') && format('--allowedTools {0}{1}', inputs.allowed-tools, inputs.extra-allowed-tools != '' && format(',{0}', inputs.extra-allowed-tools) || '') || '' }}
${{ inputs.mcp-servers != '' && format('--mcp-config ''{0}''', inputs.mcp-servers) || '' }}
--model ${{ inputs.model }}
settings: |
{"model": "${{ inputs.model }}"}
+50
View File
@@ -0,0 +1,50 @@
name: "Run Pytest"
description: "Run pytest with appropriate flags for the test type and platform"
inputs:
test-type:
description: "Type of tests to run: unit, integration, client_process, or conformance"
required: false
default: "unit"
runs:
using: "composite"
steps:
- name: Run pytest
shell: bash
run: |
if [ "${{ inputs.test-type }}" == "integration" ]; then
MARKER="integration"
TIMEOUT="30"
MAX_PROCS="2"
EXTRA_FLAGS=""
elif [ "${{ inputs.test-type }}" == "client_process" ]; then
MARKER="client_process"
TIMEOUT="5"
MAX_PROCS="0"
EXTRA_FLAGS="-x"
elif [ "${{ inputs.test-type }}" == "conformance" ]; then
MARKER="conformance"
TIMEOUT="120"
MAX_PROCS="0"
EXTRA_FLAGS="-x"
else
MARKER="not integration and not client_process and not conformance"
TIMEOUT="5"
MAX_PROCS="4"
EXTRA_FLAGS=""
fi
PARALLEL_FLAGS=""
if [ "$MAX_PROCS" != "0" ] && [ "${{ runner.os }}" != "Windows" ]; then
PARALLEL_FLAGS="--numprocesses auto --maxprocesses $MAX_PROCS --dist worksteal"
fi
uv run --no-sync pytest \
--inline-snapshot=disable \
--timeout=$TIMEOUT \
--durations=50 \
-m "$MARKER" \
$PARALLEL_FLAGS \
$EXTRA_FLAGS \
tests
+33
View File
@@ -0,0 +1,33 @@
name: "Setup UV Environment"
description: "Install uv and dependencies (requires checkout first)"
inputs:
python-version:
description: "Python version to use"
required: false
default: "3.10"
resolution:
description: "Dependency resolution: locked, upgrade, or lowest-direct"
required: false
default: "locked"
runs:
using: "composite"
steps:
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
python-version: ${{ inputs.python-version }}
- name: Install dependencies
shell: bash
run: |
if [ "${{ inputs.resolution }}" == "locked" ]; then
uv sync --locked
elif [ "${{ inputs.resolution }}" == "upgrade" ]; then
uv sync --upgrade
else
uv sync --resolution ${{ inputs.resolution }}
fi
+1
View File
@@ -0,0 +1 @@
../AGENTS.md
+14
View File
@@ -0,0 +1,14 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "daily"
labels:
- "dependencies"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
labels:
- "dependencies"
+22
View File
@@ -0,0 +1,22 @@
## Description
<!-- What does this PR do? Link to the issue it addresses. -->
Closes #
## Contribution type
<!-- Check the one that applies. If you're unsure whether your change is welcome, please open an issue first — see CONTRIBUTING.md. -->
- [ ] Bug fix (simple, well-scoped fix for a clearly broken behavior)
- [ ] Documentation improvement
- [ ] Enhancement (maintainers typically implement enhancements — see [CONTRIBUTING.md](../CONTRIBUTING.md))
## Checklist
- [ ] This PR addresses an existing issue (or fixes a self-evident bug)
- [ ] I have read [CONTRIBUTING.md](../CONTRIBUTING.md)
- [ ] I have added tests that cover my changes
- [ ] I have run `uv run prek run --all-files` and all checks pass
- [ ] I have self-reviewed my changes
- [ ] If I used an LLM, it followed the repo's contributing conventions (not generic output)
+57
View File
@@ -0,0 +1,57 @@
changelog:
exclude:
labels:
- ignore in release notes
categories:
- title: New Features 🎉
labels:
- feature
- title: Breaking Changes ⚠️
labels:
- breaking change
exclude:
labels:
- contrib
- security
- title: Enhancements ✨
labels:
- enhancement
exclude:
labels:
- breaking change
- security
- title: Security 🔒
labels:
- security
- title: Fixes 🐞
labels:
- bug
exclude:
labels:
- contrib
- security
- title: Docs 📚
labels:
- documentation
- title: Examples & Contrib 💡
labels:
- example
- contrib
- title: Dependencies 📦
labels:
- dependencies
exclude:
labels:
- security
- title: Other Changes 🦾
labels:
- "*"
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env bash
set -euo pipefail
# Get PR review threads with comments via GitHub GraphQL API
#
# Usage:
# gh-get-review-threads.sh [FILTER]
#
# Arguments:
# FILTER - Optional: filter for unresolved threads from specific author
#
# Environment (set by composite action):
# MENTION_REPO - Repository (owner/repo format)
# MENTION_PR_NUMBER - Pull request number
# GITHUB_TOKEN - GitHub API token
#
# Output:
# JSON array of review threads with nested comments
# Parse OWNER and REPO from MENTION_REPO
REPO_FULL="${MENTION_REPO:?MENTION_REPO environment variable is required}"
OWNER="${REPO_FULL%/*}"
REPO="${REPO_FULL#*/}"
PR_NUMBER="${MENTION_PR_NUMBER:?MENTION_PR_NUMBER environment variable is required}"
FILTER="${1:-}"
gh api graphql -f query='
query($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
reviewThreads(first: 100) {
nodes {
id
isResolved
isOutdated
path
line
comments(first: 50) {
nodes {
id
body
author { login }
createdAt
}
}
}
}
}
}
}' -F owner="$OWNER" \
-F repo="$REPO" \
-F prNumber="$PR_NUMBER" \
--jq '.data.repository.pullRequest.reviewThreads.nodes' | \
if [ -n "$FILTER" ]; then
jq --arg author "$FILTER" '
map(select(
.isResolved == false and
.comments.nodes | any(.author.login == $author)
))'
else
cat
fi
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
# Resolve a GitHub PR review thread, optionally posting a comment first
#
# Usage:
# gh-resolve-review-thread.sh THREAD_ID [COMMENT]
#
# Arguments:
# THREAD_ID - The GraphQL node ID of the review thread to resolve
# COMMENT - Optional: Comment body to post before resolving
#
# Environment (set by composite action):
# MENTION_REPO - Repository (owner/repo format)
# MENTION_PR_NUMBER - Pull request number
# GITHUB_TOKEN - GitHub API token
#
# Behavior:
# 1. If COMMENT is provided, posts it as a reply to the thread
# 2. Resolves the thread
# Validate required environment variables
: "${MENTION_REPO:?MENTION_REPO environment variable is required}"
: "${MENTION_PR_NUMBER:?MENTION_PR_NUMBER environment variable is required}"
THREAD_ID="${1:?Thread ID required}"
COMMENT="${2:-}"
# Step 1: Post comment if provided
if [ -n "$COMMENT" ]; then
echo "Posting comment to thread..." >&2
COMMENT_RESULT=$(gh api graphql -f query='
mutation($threadId: ID!, $body: String!) {
addPullRequestReviewThreadReply(input: {
pullRequestReviewThreadId: $threadId,
body: $body
}) {
comment {
id
}
}
}' -f threadId="$THREAD_ID" -f body="$COMMENT")
if echo "$COMMENT_RESULT" | jq -e '.errors' > /dev/null 2>&1; then
echo "Error posting comment: $COMMENT_RESULT" >&2
exit 1
fi
fi
# Step 2: Resolve the thread
echo "Resolving thread..." >&2
RESOLVE_RESULT=$(gh api graphql -f query='
mutation($threadId: ID!) {
resolveReviewThread(input: {threadId: $threadId}) {
thread {
id
isResolved
}
}
}' -f threadId="$THREAD_ID" --jq '.data.resolveReviewThread.thread')
echo "$RESOLVE_RESULT"
echo "✓ Thread resolved" >&2
+251
View File
@@ -0,0 +1,251 @@
#!/bin/bash
# pr-comment.sh - Queue a structured inline review comment for the PR review
#
# Usage:
# pr-comment.sh <file> <line> --severity <level> --title <description> --why <reason> [suggestion via stdin]
# pr-comment.sh <file> <line> --severity <level> --title <description> --why <reason> --no-suggestion
#
# Arguments:
# file File path (required)
# line Line number (required)
# --severity Severity level: critical, high, medium, low, nitpick (required)
# --title Brief description for comment heading (required)
# --why One sentence explaining the risk/impact (required)
# --no-suggestion Explicitly skip suggestion (use for architectural issues)
#
# The suggestion code is read from stdin (use heredoc). If no stdin and no --no-suggestion, errors.
#
# Examples:
# # With suggestion (preferred)
# pr-comment.sh src/main.go 42 --severity high --title "Missing error check" --why "Errors are silently ignored" <<'EOF'
# if err != nil {
# return fmt.Errorf("operation failed: %w", err)
# }
# EOF
#
# # Without suggestion (for issues requiring broader changes)
# pr-comment.sh src/main.go 42 --severity medium --title "Consider extracting to function" \
# --why "This logic is duplicated in 3 places" --no-suggestion
#
# Environment variables (set by the composite action):
# PR_REVIEW_REPO - Repository (owner/repo)
# PR_REVIEW_PR_NUMBER - Pull request number
# PR_REVIEW_COMMENTS_DIR - Directory to cache comments (default: /tmp/pr-review-comments)
set -e
# Configuration from environment
REPO="${PR_REVIEW_REPO:?PR_REVIEW_REPO environment variable is required}"
PR_NUMBER="${PR_REVIEW_PR_NUMBER:?PR_REVIEW_PR_NUMBER environment variable is required}"
COMMENTS_DIR="${PR_REVIEW_COMMENTS_DIR:-/tmp/pr-review-comments}"
# Severity emoji mapping
declare -A SEVERITY_EMOJI=(
[critical]="🔴 CRITICAL"
[high]="🟠 HIGH"
[medium]="🟡 MEDIUM"
[low]="⚪ LOW"
[nitpick]="💬 NITPICK"
)
# Parse arguments
FILE=""
LINE=""
SEVERITY=""
TITLE=""
WHY=""
NO_SUGGESTION=false
# First two positional args are file and line
if [ $# -lt 2 ]; then
echo "Error: file and line are required"
echo "Usage: pr-comment.sh <file> <line> --severity <level> --title <desc> --why <reason> [<<'EOF' ... EOF]"
exit 1
fi
FILE="$1"
LINE="$2"
shift 2
# Parse named arguments
while [ $# -gt 0 ]; do
case "$1" in
--severity)
SEVERITY="$2"
shift 2
;;
--title)
TITLE="$2"
shift 2
;;
--why)
WHY="$2"
shift 2
;;
--no-suggestion)
NO_SUGGESTION=true
shift
;;
*)
echo "Error: Unknown argument: $1"
exit 1
;;
esac
done
# Read suggestion from stdin if available
SUGGESTION=""
if [ ! -t 0 ]; then
SUGGESTION=$(cat)
fi
# Validate required arguments
if [ -z "$SEVERITY" ]; then
echo "Error: --severity is required (critical, high, medium, low, nitpick)"
exit 1
fi
if [ -z "$TITLE" ]; then
echo "Error: --title is required"
exit 1
fi
if [ -z "$WHY" ]; then
echo "Error: --why is required"
exit 1
fi
# Validate severity level
if [ -z "${SEVERITY_EMOJI[$SEVERITY]}" ]; then
echo "Error: Invalid severity '$SEVERITY'. Must be one of: critical, high, medium, low, nitpick"
exit 1
fi
# Require either suggestion or explicit --no-suggestion
if [ -z "$SUGGESTION" ] && [ "$NO_SUGGESTION" = false ]; then
echo "Error: Suggestion required. Provide code via stdin (heredoc) or use --no-suggestion"
echo ""
echo "Example with suggestion:"
echo " pr-comment.sh file.go 42 --severity high --title \"desc\" --why \"reason\" <<'EOF'"
echo " fixed code here"
echo " EOF"
echo ""
echo "Example without suggestion:"
echo " pr-comment.sh file.go 42 --severity medium --title \"desc\" --why \"reason\" --no-suggestion"
exit 1
fi
# Validate line is a positive integer (>= 1)
if ! [[ "$LINE" =~ ^[1-9][0-9]*$ ]]; then
echo "Error: Line number must be a positive integer (>= 1), got: $LINE"
exit 1
fi
# Get the diff for this file to validate the comment location
DIFF_DATA=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate | jq --arg f "$FILE" '.[] | select(.filename==$f)')
if [ -z "$DIFF_DATA" ]; then
echo "Error: File '${FILE}' not found in PR diff"
echo ""
echo "Files changed in this PR:"
gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename'
exit 1
fi
PATCH=$(echo "$DIFF_DATA" | jq -r '.patch // empty')
if [ -z "$PATCH" ]; then
echo "Error: No patch data for file '${FILE}' (file may be binary or too large)"
exit 1
fi
# Verify the line exists in the diff
LINE_IN_DIFF=$(echo "$PATCH" | awk -v target_line="$LINE" '
BEGIN { current_line = 0; found = 0 }
/^@@/ {
line = $0
gsub(/.*\+/, "", line)
gsub(/[^0-9].*/, "", line)
current_line = line - 1
next
}
{
if (substr($0, 1, 1) != "-") {
current_line++
if (current_line == target_line) {
found = 1
exit
}
}
}
END { if (found) print "1"; else print "0" }
')
if [ "$LINE_IN_DIFF" != "1" ]; then
echo "Error: Line ${LINE} not found in the diff for '${FILE}'"
echo ""
echo "Note: You can only comment on lines that appear in the diff (added, modified, or context lines)"
echo ""
echo "First 50 lines of diff for this file:"
echo "$PATCH" | head -50
exit 1
fi
# Create comments directory if it doesn't exist
mkdir -p "${COMMENTS_DIR}"
# Assemble the comment body
SEVERITY_LABEL="${SEVERITY_EMOJI[$SEVERITY]}"
BODY="**${SEVERITY_LABEL}** ${TITLE}
Why: ${WHY}"
# Add suggestion block if provided
if [ -n "$SUGGESTION" ]; then
BODY="${BODY}
\`\`\`suggestion
${SUGGESTION}
\`\`\`"
fi
# Append standard footer
FOOTER='
---
Marvin Context Protocol | Type `/marvin` to interact further
Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.'
BODY_WITH_FOOTER="${BODY}${FOOTER}"
# Generate unique comment ID
COMMENT_ID="comment-$(date +%s)-$(od -An -N4 -tu4 /dev/urandom | tr -d ' ')"
COMMENT_FILE="${COMMENTS_DIR}/${COMMENT_ID}.json"
# Create the comment JSON object
jq -n \
--arg path "$FILE" \
--argjson line "$LINE" \
--arg side "RIGHT" \
--arg body "$BODY_WITH_FOOTER" \
--arg id "$COMMENT_ID" \
'{
path: $path,
line: $line,
side: $side,
body: $body,
_meta: {
id: $id,
file: $path,
line: $line
}
}' > "${COMMENT_FILE}"
echo "✓ Queued review comment for ${FILE}:${LINE}"
echo " Severity: ${SEVERITY_LABEL}"
echo " Title: ${TITLE}"
echo " Comment ID: ${COMMENT_ID}"
echo " Comment will be submitted with pr-review.sh"
echo " Remove with: pr-remove-comment.sh ${FILE} ${LINE}"
+128
View File
@@ -0,0 +1,128 @@
#!/bin/bash
# pr-diff.sh - Show changed files or diff for a specific file
#
# Usage:
# pr-diff.sh - List all changed files (shows full diff if small enough)
# pr-diff.sh <file> - Show diff for a specific file with line numbers
#
# Environment variables (set by the composite action):
# PR_REVIEW_REPO - Repository (owner/repo)
# PR_REVIEW_PR_NUMBER - Pull request number
set -e
# Configuration from environment
REPO="${PR_REVIEW_REPO:?PR_REVIEW_REPO environment variable is required}"
PR_NUMBER="${PR_REVIEW_PR_NUMBER:?PR_REVIEW_PR_NUMBER environment variable is required}"
EXPECTED_HEAD="${PR_REVIEW_HEAD_SHA:-}"
# Check if HEAD has changed since review started (race condition detection)
if [ -n "$EXPECTED_HEAD" ]; then
CURRENT_HEAD=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')
if [ "$CURRENT_HEAD" != "$EXPECTED_HEAD" ]; then
echo "⚠️ WARNING: PR head has changed since review started!"
echo " Review started at: ${EXPECTED_HEAD:0:7}"
echo " Current head: ${CURRENT_HEAD:0:7}"
echo " Line numbers below may not match the commit being reviewed."
echo ""
fi
fi
# Thresholds for "too big" - show file list only if exceeded
MAX_FILES=25
MAX_TOTAL_LINES=1500
FILE="$1"
# Function to add line numbers to a patch
# Format: [LINE] +added | [LINE] context | [----] -deleted
add_line_numbers() {
awk '
BEGIN { new_line = 0 }
/^@@/ {
# Parse hunk header: @@ -old_start,old_count +new_start,new_count @@
match($0, /\+([0-9]+)/)
new_line = substr($0, RSTART+1, RLENGTH-1) - 1
print ""
print $0
next
}
/^-/ {
# Deleted line - cannot comment on these
printf "[----] %s\n", $0
next
}
/^\+/ {
# Added line - can comment, show line number
new_line++
printf "[%4d] %s\n", new_line, $0
next
}
{
# Context line (space prefix) - can comment, show line number
new_line++
printf "[%4d] %s\n", new_line, $0
}
'
}
if [ -z "$FILE" ]; then
# Get file list with stats
FILES_DATA=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate)
FILE_COUNT=$(echo "$FILES_DATA" | jq 'length')
TOTAL_ADDITIONS=$(echo "$FILES_DATA" | jq '[.[].additions] | add // 0')
TOTAL_DELETIONS=$(echo "$FILES_DATA" | jq '[.[].deletions] | add // 0')
TOTAL_LINES=$((TOTAL_ADDITIONS + TOTAL_DELETIONS))
echo "PR #${PR_NUMBER} Summary: ${FILE_COUNT} files changed (+${TOTAL_ADDITIONS}/-${TOTAL_DELETIONS})"
echo ""
# Check if diff is too large
if [ "$FILE_COUNT" -gt "$MAX_FILES" ] || [ "$TOTAL_LINES" -gt "$MAX_TOTAL_LINES" ]; then
echo "⚠️ Large diff detected (>${MAX_FILES} files or >${MAX_TOTAL_LINES} lines changed)"
echo " Review files individually using: pr-diff.sh <filename>"
echo ""
echo "Files changed:"
echo "$FILES_DATA" | jq -r '.[] | " \(.filename) (+\(.additions)/-\(.deletions))"'
else
# Small enough - show all diffs with line numbers
echo "Files changed:"
echo "$FILES_DATA" | jq -r '.[] | " \(.filename) (+\(.additions)/-\(.deletions))"'
echo ""
echo "─────────────────────────────────────────────────────────────────────"
echo ""
# Show each file's diff by iterating over indices
for i in $(seq 0 $((FILE_COUNT - 1))); do
FNAME=$(echo "$FILES_DATA" | jq -r ".[$i].filename")
PATCH=$(echo "$FILES_DATA" | jq -r ".[$i].patch // empty")
if [ -n "$PATCH" ]; then
echo "## ${FNAME}"
echo "Use: pr-comment.sh ${FNAME} <LINE> --severity <level> --title \"desc\" --why \"reason\" <<'EOF' ... EOF"
echo "Format: [LINE] +added | [LINE] context | [----] -deleted (can't comment)"
echo "$PATCH" | add_line_numbers
echo ""
echo "─────────────────────────────────────────────────────────────────────"
echo ""
fi
done
fi
else
# Show specific file diff
PATCH=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate --jq --arg file "$FILE" '.[] | select(.filename==$file) | .patch')
if [ -z "$PATCH" ]; then
echo "Error: File '${FILE}' not found in PR diff"
echo ""
echo "Files changed in this PR:"
gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename'
exit 1
fi
echo "## ${FILE}"
echo "Use: pr-comment.sh ${FILE} <LINE> --severity <level> --title \"desc\" --why \"reason\" <<'EOF' ... EOF"
echo "Format: [LINE] +added | [LINE] context | [----] -deleted (can't comment)"
echo "$PATCH" | add_line_numbers
fi
+190
View File
@@ -0,0 +1,190 @@
#!/bin/bash
# pr-existing-comments.sh - Fetch existing review threads on a PR
#
# Usage:
# pr-existing-comments.sh - Show all review threads with full details
# pr-existing-comments.sh --summary - Show per-file summary only (for large PRs)
# pr-existing-comments.sh --unresolved - Show only unresolved threads
# pr-existing-comments.sh --file <path> - Show threads for a specific file
# pr-existing-comments.sh --full - Show full comment text (no truncation)
#
# Output: Formatted summary of existing review threads grouped by file,
# showing thread status, comments, and whether issues were addressed.
#
# For large PRs, use --summary first to see the overview, then --file <path>
# to get full thread details when reviewing each file.
#
# Environment variables (set by the composite action):
# PR_REVIEW_REPO - Repository (owner/repo)
# PR_REVIEW_PR_NUMBER - Pull request number
set -e
# Configuration from environment
REPO="${PR_REVIEW_REPO:?PR_REVIEW_REPO environment variable is required}"
PR_NUMBER="${PR_REVIEW_PR_NUMBER:?PR_REVIEW_PR_NUMBER environment variable is required}"
OWNER="${REPO%/*}"
REPO_NAME="${REPO#*/}"
# Parse arguments
FILTER_UNRESOLVED=false
FILTER_FILE=""
SUMMARY_ONLY=false
FULL_TEXT=false
while [ $# -gt 0 ]; do
case "$1" in
--unresolved)
FILTER_UNRESOLVED=true
shift
;;
--file)
FILTER_FILE="$2"
shift 2
;;
--summary)
SUMMARY_ONLY=true
shift
;;
--full)
FULL_TEXT=true
shift
;;
*)
echo "Usage: pr-existing-comments.sh [--summary] [--unresolved] [--file <path>] [--full]"
exit 1
;;
esac
done
# Fetch review threads via GraphQL
THREADS=$(gh api graphql -f query='
query($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
reviewThreads(first: 100) {
nodes {
id
isResolved
isOutdated
path
line
originalLine
startLine
originalStartLine
diffSide
comments(first: 50) {
nodes {
id
body
author { login }
createdAt
originalCommit { abbreviatedOid }
}
}
}
}
}
}
}' -F owner="$OWNER" \
-F repo="$REPO_NAME" \
-F prNumber="$PR_NUMBER" \
--jq '.data.repository.pullRequest.reviewThreads.nodes')
if [ -z "$THREADS" ] || [ "$THREADS" = "null" ]; then
echo "No existing review threads found."
exit 0
fi
# Apply filters
FILTERED="$THREADS"
if [ "$FILTER_UNRESOLVED" = true ]; then
FILTERED=$(echo "$FILTERED" | jq '[.[] | select(.isResolved == false)]')
fi
if [ -n "$FILTER_FILE" ]; then
FILTERED=$(echo "$FILTERED" | jq --arg file "$FILTER_FILE" '[.[] | select(.path == $file)]')
fi
THREAD_COUNT=$(echo "$FILTERED" | jq 'length')
if [ "$THREAD_COUNT" -eq 0 ]; then
if [ "$FILTER_UNRESOLVED" = true ]; then
echo "No unresolved review threads found."
elif [ -n "$FILTER_FILE" ]; then
echo "No review threads found for ${FILTER_FILE}."
else
echo "No existing review threads found."
fi
exit 0
fi
# Count resolved vs unresolved
RESOLVED_COUNT=$(echo "$FILTERED" | jq '[.[] | select(.isResolved == true)] | length')
UNRESOLVED_COUNT=$(echo "$FILTERED" | jq '[.[] | select(.isResolved == false)] | length')
OUTDATED_COUNT=$(echo "$FILTERED" | jq '[.[] | select(.isOutdated == true)] | length')
echo "Existing review threads: ${THREAD_COUNT} total (${UNRESOLVED_COUNT} unresolved, ${RESOLVED_COUNT} resolved, ${OUTDATED_COUNT} outdated)"
echo ""
# Summary mode: show per-file counts only
if [ "$SUMMARY_ONLY" = true ]; then
echo "Threads by file:"
echo "$FILTERED" | jq -r '
group_by(.path) | .[] |
. as $threads |
($threads | length) as $total |
([$threads[] | select(.isResolved == false)] | length) as $unresolved |
([$threads[] | select(.isResolved == true)] | length) as $resolved |
([$threads[] | select(.isOutdated == true)] | length) as $outdated |
([$threads[] | select(.comments.nodes | length > 1)] | length) as $has_replies |
" " + $threads[0].path +
" — " + ($total | tostring) + " threads" +
" (" + ($unresolved | tostring) + " unresolved, " + ($resolved | tostring) + " resolved" +
(if $outdated > 0 then ", " + ($outdated | tostring) + " outdated" else "" end) +
")" +
(if $has_replies > 0 then " ⚠️ " + ($has_replies | tostring) + " with replies" else "" end)
'
echo ""
echo "Use: pr-existing-comments.sh --file <path> to see full thread details for a file"
exit 0
fi
# Full detail mode: output threads grouped by file
# Show full conversation for threads with replies
FIRST_LIMIT=200
REPLY_LIMIT=300
if [ "$FULL_TEXT" = true ]; then
FIRST_LIMIT=999999
REPLY_LIMIT=999999
fi
echo "$FILTERED" | jq -r --argjson first_limit "$FIRST_LIMIT" --argjson reply_limit "$REPLY_LIMIT" '
group_by(.path) | .[] |
"## " + .[0].path + " (" + (length | tostring) + " threads)\n" +
([.[] |
" " +
(if .isResolved then "✅ RESOLVED" elif .isOutdated then "⚠️ OUTDATED" else "🔴 UNRESOLVED" end) +
" (line " + (if .line then (.line | tostring) elif .startLine then (.startLine | tostring) elif .originalLine then ("~" + (.originalLine | tostring)) elif .originalStartLine then ("~" + (.originalStartLine | tostring)) else "?" end) + ")" +
# Show the commit the comment was originally made on
(if .comments.nodes[0].originalCommit.abbreviatedOid then " [" + .comments.nodes[0].originalCommit.abbreviatedOid + "]" else "" end) +
# Flag threads with replies — indicates a conversation happened
(if (.comments.nodes | length) > 1 then " ← has replies" else "" end) +
"\n" +
([.comments.nodes | to_entries[] |
.value as $comment |
.key as $idx |
($comment.body | gsub("\n"; " ")) as $flat |
if $idx == 0 then
" @" + ($comment.author.login // "unknown") + ": " + $flat[0:$first_limit] +
(if ($flat | length) > $first_limit then " [truncated]" else "" end)
else
" ↳ @" + ($comment.author.login // "unknown") + ": " + $flat[0:$reply_limit] +
(if ($flat | length) > $reply_limit then " [truncated]" else "" end)
end
] | join("\n")) +
"\n"
] | join("\n"))
'
+84
View File
@@ -0,0 +1,84 @@
#!/bin/bash
# pr-remove-comment.sh - Remove a queued review comment
#
# Usage:
# pr-remove-comment.sh <file> <line-number>
# pr-remove-comment.sh <comment-id>
#
# Examples:
# pr-remove-comment.sh src/main.go 42
# pr-remove-comment.sh comment-1234567890-1234567890
#
# This script removes a previously queued comment before it's submitted.
# Useful if the agent realizes it made a mistake or wants to update a comment.
#
# Environment variables (set by the composite action):
# PR_REVIEW_COMMENTS_DIR - Directory containing comment files (default: /tmp/pr-review-comments)
set -e
COMMENTS_DIR="${PR_REVIEW_COMMENTS_DIR:-/tmp/pr-review-comments}"
if [ ! -d "${COMMENTS_DIR}" ]; then
echo "No comments directory found: ${COMMENTS_DIR}"
exit 0
fi
# Check if first argument looks like a comment ID
if [[ "$1" =~ ^comment- ]]; then
COMMENT_ID="$1"
COMMENT_FILE="${COMMENTS_DIR}/${COMMENT_ID}.json"
if [ -f "${COMMENT_FILE}" ]; then
FILE=$(jq -r '._meta.file // .path' "${COMMENT_FILE}")
LINE=$(jq -r '._meta.line // .line' "${COMMENT_FILE}")
rm -f "${COMMENT_FILE}"
echo "✓ Removed comment ${COMMENT_ID} for ${FILE}:${LINE}"
else
echo "Comment not found: ${COMMENT_ID}"
exit 1
fi
else
# Treat as file and line number
FILE="$1"
LINE="$2"
if [ -z "$FILE" ] || [ -z "$LINE" ]; then
echo "Usage:"
echo " pr-remove-comment.sh <file> <line-number>"
echo " pr-remove-comment.sh <comment-id>"
echo ""
echo "Examples:"
echo " pr-remove-comment.sh src/main.go 42"
echo " pr-remove-comment.sh comment-1234567890-1234567890"
exit 1
fi
# Validate line is a positive integer (>= 1)
if ! [[ "$LINE" =~ ^[1-9][0-9]*$ ]]; then
echo "Error: Line number must be a positive integer (>= 1), got: $LINE"
exit 1
fi
# Find and remove matching comment files
# Use nullglob to handle case where no files match
shopt -s nullglob
REMOVED=0
for COMMENT_FILE in "${COMMENTS_DIR}"/comment-*.json; do
COMMENT_FILE_PATH=$(jq -r '._meta.file // .path' "${COMMENT_FILE}")
COMMENT_LINE=$(jq -r '._meta.line // .line' "${COMMENT_FILE}")
if [ "$COMMENT_FILE_PATH" = "$FILE" ] && [ "$COMMENT_LINE" = "$LINE" ]; then
COMMENT_ID=$(basename "${COMMENT_FILE}" .json)
rm -f "${COMMENT_FILE}"
echo "✓ Removed comment ${COMMENT_ID} for ${FILE}:${LINE}"
REMOVED=$((REMOVED + 1))
fi
done
if [ "$REMOVED" -eq 0 ]; then
echo "No comment found for ${FILE}:${LINE}"
exit 1
fi
fi
+143
View File
@@ -0,0 +1,143 @@
#!/bin/bash
# pr-review.sh - Submit a PR review (approve, request changes, or comment)
#
# Usage: pr-review.sh <APPROVE|REQUEST_CHANGES|COMMENT> [review-body]
# Example: pr-review.sh REQUEST_CHANGES "Please fix the issues noted above"
#
# This script creates and submits a review with any queued inline comments.
# Comments are read from individual files in PR_REVIEW_COMMENTS_DIR (created by pr-comment.sh).
#
# The review body can contain special characters (backticks, dollar signs, etc.)
# and will be safely passed to the GitHub API without shell interpretation.
#
# Environment variables (set by the composite action):
# PR_REVIEW_REPO - Repository (owner/repo)
# PR_REVIEW_PR_NUMBER - Pull request number
# PR_REVIEW_HEAD_SHA - HEAD commit SHA
# PR_REVIEW_COMMENTS_DIR - Directory containing queued comment files (default: /tmp/pr-review-comments)
set -e
# Configuration from environment
REPO="${PR_REVIEW_REPO:?PR_REVIEW_REPO environment variable is required}"
PR_NUMBER="${PR_REVIEW_PR_NUMBER:?PR_REVIEW_PR_NUMBER environment variable is required}"
HEAD_SHA="${PR_REVIEW_HEAD_SHA:?PR_REVIEW_HEAD_SHA environment variable is required}"
COMMENTS_DIR="${PR_REVIEW_COMMENTS_DIR:-/tmp/pr-review-comments}"
# Arguments
EVENT="$1"
shift 2>/dev/null || true
# Read body from remaining arguments
# Join all remaining arguments with spaces, preserving the string as-is
BODY="$*"
if [ -z "$EVENT" ]; then
echo "Usage: pr-review.sh <APPROVE|REQUEST_CHANGES|COMMENT> [review-body]"
echo "Example: pr-review.sh REQUEST_CHANGES 'Please fix the issues noted in the inline comments'"
exit 1
fi
# Validate event type
case "$EVENT" in
APPROVE|REQUEST_CHANGES|COMMENT)
;;
*)
echo "Error: Invalid event type '${EVENT}'"
echo "Must be one of: APPROVE, REQUEST_CHANGES, COMMENT"
exit 1
;;
esac
# Read queued comments from individual files
COMMENTS="[]"
COMMENT_COUNT=0
if [ -d "${COMMENTS_DIR}" ]; then
# Collect all comment files and merge into a single JSON array
# Remove _meta fields before submitting (they're only for internal use)
COMMENT_FILES=("${COMMENTS_DIR}"/comment-*.json)
if [ -f "${COMMENT_FILES[0]}" ]; then
# Use jq to read all comment files, extract the comment data (without _meta), and combine
COMMENTS=$(jq -s '[.[] | del(._meta)]' "${COMMENTS_DIR}"/comment-*.json)
COMMENT_COUNT=$(echo "$COMMENTS" | jq 'length')
if [ "$COMMENT_COUNT" -gt 0 ]; then
echo "Found ${COMMENT_COUNT} queued inline comment(s)"
fi
fi
fi
# Append standard footer to the review body (if body is provided)
FOOTER='
---
Marvin Context Protocol | Type `/marvin` to interact further
Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.'
if [ -n "$BODY" ]; then
BODY_WITH_FOOTER="${BODY}${FOOTER}"
else
BODY_WITH_FOOTER=""
fi
# Build the review request JSON
# Use jq to safely construct the JSON with all special characters handled
REVIEW_JSON=$(jq -n \
--arg commit_id "$HEAD_SHA" \
--arg event "$EVENT" \
--arg body "$BODY_WITH_FOOTER" \
--argjson comments "$COMMENTS" \
'{
commit_id: $commit_id,
event: $event,
comments: $comments
} + (if $body != "" then {body: $body} else {} end)')
# Check if HEAD has changed since review started (race condition detection)
CURRENT_HEAD=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}" --jq '.head.sha')
if [ "$CURRENT_HEAD" != "$HEAD_SHA" ]; then
echo "⚠️ WARNING: PR head has changed since review started!"
echo " Review started at: ${HEAD_SHA:0:7}"
echo " Current head: ${CURRENT_HEAD:0:7}"
echo ""
echo " New commits may have shifted line numbers. Review will be submitted"
echo " against the original commit (${HEAD_SHA:0:7}) but comments may be outdated."
echo ""
fi
echo "Submitting ${EVENT} review for commit ${HEAD_SHA:0:7}..."
# Create and submit the review in one API call
# Use a temp file to safely pass the JSON body
TEMP_JSON=$(mktemp)
trap "rm -f ${TEMP_JSON}" EXIT
echo "$REVIEW_JSON" > "${TEMP_JSON}"
RESPONSE=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/reviews" \
-X POST \
--input "${TEMP_JSON}" 2>&1) || {
echo "Error submitting review:"
echo "$RESPONSE"
exit 1
}
# Clean up the comments directory after successful submission
if [ -d "${COMMENTS_DIR}" ] && [ "$COMMENT_COUNT" -gt 0 ]; then
rm -f "${COMMENTS_DIR}"/comment-*.json
# Remove directory if empty
rmdir "${COMMENTS_DIR}" 2>/dev/null || true
fi
REVIEW_URL=$(echo "$RESPONSE" | jq -r '.html_url // empty')
REVIEW_STATE=$(echo "$RESPONSE" | jq -r '.state // empty')
if [ -n "$REVIEW_URL" ]; then
echo "✓ Review submitted (${REVIEW_STATE}): ${REVIEW_URL}"
if [ "$COMMENT_COUNT" -gt 0 ]; then
echo " Included ${COMMENT_COUNT} inline comment(s)"
fi
else
echo "✓ Review submitted successfully"
fi
@@ -0,0 +1,36 @@
name: Auto-close duplicate issues
description: Auto-closes issues that are duplicates of existing issues
on:
schedule:
- cron: "0 9 * * *" # Run daily at 9 AM UTC
workflow_dispatch:
jobs:
auto-close-duplicates:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Generate Marvin App token
id: marvin-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Auto-close duplicate issues
run: uv run scripts/auto_close_duplicates.py
env:
GITHUB_TOKEN: ${{ steps.marvin-token.outputs.token }}
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }}
@@ -0,0 +1,36 @@
name: Auto-close needs MRE issues
description: Auto-closes issues that need minimal reproducible examples after 7 days of author inactivity
on:
schedule:
- cron: "0 9 * * *" # Run daily at 9 AM UTC
workflow_dispatch:
jobs:
auto-close-needs-mre:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Generate Marvin App token
id: marvin-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Auto-close needs MRE issues
run: uv run scripts/auto_close_needs_mre.py
env:
GITHUB_TOKEN: ${{ steps.marvin-token.outputs.token }}
GITHUB_REPOSITORY_OWNER: ${{ github.repository_owner }}
GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }}
+197
View File
@@ -0,0 +1,197 @@
name: Marvin Test Failure Analysis
on:
workflow_run:
workflows: ["Tests", "Run static analysis"]
types:
- completed
concurrency:
group: marvin-test-failure-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
martian-test-failure:
# Only run if the test workflow failed
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
issues: read
id-token: write
actions: read # Required for Claude to read CI results
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Generate Marvin App token
id: marvin-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: Set up Python 3.10
uses: actions/setup-python@v6
with:
python-version: "3.10"
# Install UV package manager
- name: Install UV
uses: astral-sh/setup-uv@v7
# Install dependencies
- name: Install dependencies
run: uv sync --all-packages --group dev
- name: Set analysis prompt
id: analysis-prompt
run: |
cat >> $GITHUB_OUTPUT << 'EOF'
PROMPT<<PROMPT_END
You're a test failure analysis assistant for FastMCP, a Python framework for building Model Context Protocol servers and clients.
# Your Task
A GitHub Actions workflow has failed. Your job is to:
1. Analyze the test failure(s) to understand what went wrong
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 }}
- The workflow run ID is: ${{ github.event.workflow_run.id }}
- The workflow run was triggered by: ${{ github.event.workflow_run.event }}
- Use GitHub MCP tools to get PR details and workflow run information
3. Use the GitHub MCP tools to fetch job logs and failure information:
- Use get_workflow_run to get details about the failed workflow
- Use list_workflow_jobs to see which jobs failed
- Use get_job_logs with failed_only=true to get logs for failed jobs
- Use summarize_run_log_failures to get an AI summary of what failed
4. Analyze the failures to understand the root cause
5. Search the codebase for relevant files, tests, and implementations
# Your Response
Post a comment on the pull request with your 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.
Push supporting detail into collapsible `<details>` 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.
For real (non-trivial) failures, use this structure:
**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)
<details>
<summary>Log excerpts</summary>
Relevant failure output
</details>
<details>
<summary>Related files</summary>
Files relevant to the failure
</details>
# 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.
# 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 `<details>` 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
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
EOF
- name: Setup GitHub MCP Server
run: |
mkdir -p /tmp/mcp-config
cat > /tmp/mcp-config/mcp-servers.json << 'EOF'
{
"mcpServers": {
"repository-summary": {
"type": "http",
"url": "https://agents-md-generator.fastmcp.app/mcp"
},
"code-search": {
"type": "http",
"url": "https://public-code-search.fastmcp.app/mcp"
},
"github-research": {
"type": "stdio",
"command": "uvx",
"args": [
"github-research-mcp"
],
"env": {
"DISABLE_SUMMARIES": "true",
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ secrets.GITHUB_TOKEN }}"
}
}
}
}
EOF
- name: Clean up stale Claude locks
run: rm -rf ~/.claude/.locks ~/.local/state/claude/locks || true
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
github_token: ${{ steps.marvin-token.outputs.token }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
bot_name: "Marvin Context Protocol"
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
additional_permissions: |
actions: read
prompt: ${{ steps.analysis-prompt.outputs.PROMPT }}
claude_args: |
--allowed-tools mcp__repository-summary,mcp__code-search,mcp__github-research,WebSearch,WebFetch,Bash(make:*,git:*)
--mcp-config /tmp/mcp-config/mcp-servers.json
+221
View File
@@ -0,0 +1,221 @@
# Triage new issues: investigate, recommend, apply labels
# Calls run-claude directly with triage prompt (elastic issue-triage style)
name: Triage Issue
on:
issues:
types: [opened]
jobs:
triage:
if: |
github.event.issue.user.login == 'strawgate' ||
(github.event.issue.user.login == 'jlowin' && contains(toJSON(github.event.issue.labels.*.name), 'bug'))
concurrency:
group: triage-issue-${{ github.event.issue.number }}
cancel-in-progress: true
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
pull-requests: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
repository: ${{ github.repository }}
ref: ${{ github.event.repository.default_branch }}
- name: Generate Marvin App token
id: marvin-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: React to issue with eyes
env:
GH_TOKEN: ${{ steps.marvin-token.outputs.token }}
run: |
gh api "repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/reactions" -f content=eyes 2>/dev/null || true
- name: Run Claude for Triage
uses: ./.github/actions/run-claude
env:
ISSUE_BODY: ${{ github.event.issue.body }}
ISSUE_TITLE: ${{ github.event.issue.title }}
with:
claude-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github-token: ${{ steps.marvin-token.outputs.token }}
allowed-tools: "Edit,MultiEdit,Glob,Grep,LS,Read,Write,WebSearch,WebFetch,mcp__github_comment__update_claude_comment,mcp__github_ci__get_ci_status,mcp__github_ci__get_workflow_run_details,mcp__github_ci__download_job_log,Bash(*),mcp__agents-md-generator__generate_agents_md,mcp__public-code-search__search_code"
prompt: |
<context>
Repository: ${{ github.repository }}
Issue Number: #${{ github.event.issue.number }}
Issue Title: ${{ env.ISSUE_TITLE }}
Issue Author: ${{ github.event.issue.user.login }}
</context>
<issue_body>
${{ env.ISSUE_BODY }}
</issue_body>
<task>
Triage this new GitHub issue and provide a helpful, actionable response. You can write files and execute commands to test, verify, or investigate the issue.
</task>
<constraints>
This workflow is for investigation, testing, and planning.
You CANNOT: Create branches, checkout branches, commit code to the repository
Do not push changes to the repository.
You CAN: Read/analyze code, search repository, review git history, search for similar issues, write files, verify behavior, provide analysis and recommendations
</constraints>
<allowed_tools>
You have access to the following tools (comma-separated list):
Edit,MultiEdit,Glob,Grep,LS,Read,Write,WebSearch,WebFetch,mcp__github_comment__update_claude_comment,mcp__github_ci__get_ci_status,mcp__github_ci__get_workflow_run_details,mcp__github_ci__download_job_log,Bash(*),mcp__agents-md-generator__generate_agents_md,mcp__public-code-search__search_code
You can only use tools that are explicitly listed above. For Bash commands, the pattern `Bash(command:*)` means you can run that command with any arguments. If a command is not listed, it is not available.
</allowed_tools>
<getting_started>
Use `mcp__agents-md-generator__generate_agents_md` to get repository context before triaging.
</getting_started>
<investigation_tools>
- `mcp__public-code-search__search_code`: Search code in OTHER repositories (use `Grep`/`Read` for this repo)
- `WebSearch`: Search the web for documentation, best practices, or solutions
- `WebFetch`: Fetch and read content from URLs
- Git commands: You have access to git commands, but write commands (commit, push, checkout, branch creation) are blocked
- Write: You can write files (e.g., test files, temporary files for verification)
- Execution: See `<allowed_tools>` section above for exact list of available execution commands
</investigation_tools>
<execution_guidelines>
If execution commands are available (check `<allowed_tools>` section), you can:
- Run tests to verify reported bugs or test proposed solutions
- Execute scripts to understand behavior
- Run linters or static analysis tools
- Verify environment setup or dependencies
- Test specific code paths or scenarios
- Write test files to confirm behavior
When executing commands:
- Explain what you're testing and why
- Include command output in your response when relevant
- Use execution to validate your findings and recommendations
- Only use commands that are explicitly listed in `<allowed_tools>`
</execution_guidelines>
<response_goals>
Your number one priority is to provide a great response to the issue. A great response is a response that is clear, concise, accurate, and actionable. You will avoid long paragraphs, flowery language, and overly verbose responses. Your readers have limited time and attention, so you will be concise and to the point.
In priority order your goal is to:
1. Provide context about the request or issue (related issues, pull requests, files, etc.)
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 `<allowed_tools>` 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.
</response_goals>
<evidence_standards>
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.
</evidence_standards>
<quality_gate>
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.
</quality_gate>
<response_sections>
Populate the following sections in your response:
Recommendation (or "No recommendation" with reason)
Findings
Verification (if you executed tests or commands - check `<allowed_tools>` section)
Detailed Action Plan
Related Items
Related Files
Related Webpages
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.
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 `<details>` 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.
</response_sections>
<response_examples>
# 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.
<details>
<summary>Findings</summary>
...details from the code analysis that are relevant to the issue and the recommendation...
</details>
<details>
<summary>Verification</summary>
```bash
$ pytest test_calculator.py::test_divide_by_zero
FAILED - raises ValueError instead of DivisionByZeroError
```
This confirms the issue report is accurate.
</details>
<details>
<summary>Action Plan</summary>
...a detailed plan that a junior developer could follow to implement the recommendation...
</details>
<details>
<summary>Related Issues and Pull Requests</summary>
| Issue or PR | Relevance |
| --- | --- |
| [Add matrix operations support](https://github.com/PrefectHQ/fastmcp/pull/680) | Directly addresses the feature request |
</details>
<details>
<summary>Related Files</summary>
| 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) |
</details>
</response_examples>
<response_footer>
Always end your comment with a new line, three dashes, and the footer message:
<exact_content>
---
Marvin Context Protocol | Type `/marvin` to interact further
Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.
</exact_content>
</response_footer>
<github_formatting>
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.
</github_formatting>
@@ -0,0 +1,148 @@
# Respond to /marvin mentions in issue comments (elastic mention-in-issue style)
# Calls run-claude directly
name: Comment on Issue
on:
issue_comment:
types: [created]
permissions:
actions: read
contents: write
issues: write
pull-requests: write
id-token: write
jobs:
comment:
if: |
!github.event.issue.pull_request &&
contains(github.event.comment.body, '/marvin') &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Install UV
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install dependencies
run: uv sync --python 3.12
- name: Generate Marvin App token
id: marvin-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: React to comment with eyes
env:
GH_TOKEN: ${{ steps.marvin-token.outputs.token }}
run: |
gh api "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" -f content=eyes 2>/dev/null || true
- name: Run Claude for Issue Comment
uses: ./.github/actions/run-claude
env:
COMMENT_BODY: ${{ github.event.comment.body }}
ISSUE_TITLE: ${{ github.event.issue.title }}
with:
claude-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github-token: ${{ steps.marvin-token.outputs.token }}
trigger-phrase: "/marvin"
allowed-bots: "*"
allowed-tools: "Edit,MultiEdit,Glob,Grep,LS,Read,Write,WebSearch,WebFetch,mcp__github_comment__update_claude_comment,mcp__github_ci__get_ci_status,mcp__github_ci__get_workflow_run_details,mcp__github_ci__download_job_log,Bash(*),mcp__agents-md-generator__generate_agents_md,mcp__public-code-search__search_code"
prompt: |
<context>
Repository: ${{ github.repository }}
Issue Number: #${{ github.event.issue.number }}
Issue Title: ${{ env.ISSUE_TITLE }}
Issue Author: ${{ github.event.issue.user.login }}
Comment Author: ${{ github.event.comment.user.login }}
</context>
<user_request>
${{ env.COMMENT_BODY }}
</user_request>
<task>
You have been mentioned in a GitHub issue comment. Understand the request, gather context, complete the task, and respond with results.
</task>
<constraints>
You CAN: Read/analyze code, modify files, write code, run tests, execute commands, commit code, push changes, create branches, create pull requests
</constraints>
<allowed_tools>
You have access to the following tools (comma-separated list):
Edit,MultiEdit,Glob,Grep,LS,Read,Write,WebSearch,WebFetch,mcp__github_comment__update_claude_comment,mcp__github_ci__get_ci_status,mcp__github_ci__get_workflow_run_details,mcp__github_ci__download_job_log,Bash(*),mcp__agents-md-generator__generate_agents_md,mcp__public-code-search__search_code
You can only use tools that are explicitly listed above. For Bash commands, the pattern `Bash(command:*)` means you can run that command with any arguments. If a command is not listed, it is not available.
</allowed_tools>
<getting_started>
Use `mcp__agents-md-generator__generate_agents_md` to get repository context before responding.
</getting_started>
<investigation_approach>
Be thorough in your investigations:
- Understand the full context of the repository
- Review related code, issues, and PRs
- Consider edge cases and implications
- Gather all relevant information before responding
Available tools:
- `mcp__public-code-search__search_code`: Search code in OTHER repositories (use `Grep`/`Read` for this repo)
- `WebSearch`: Search the web for documentation, best practices, or solutions
- `WebFetch`: Fetch and read content from URLs
</investigation_approach>
<common_tasks>
- Answer questions about the codebase
- 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
</common_tasks>
<response_guidelines>
- 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 `<details>` 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.
</response_guidelines>
<github_safety>
- 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.
</github_safety>
<response_footer>
Always end your comment with a new line, three dashes, and the footer message:
<exact_content>
---
Marvin Context Protocol | Type `/marvin` to interact further
Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.
</exact_content>
</response_footer>
<github_formatting>
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.
</github_formatting>
+307
View File
@@ -0,0 +1,307 @@
# Respond to /marvin mentions in PR review comments and issue comments on PRs
# Calls run-claude directly
name: Comment on PR
on:
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
issues: read
id-token: write
jobs:
comment:
if: |
github.event.issue.pull_request &&
contains(github.event.comment.body, '/marvin') &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout PR head branch
uses: actions/checkout@v7
with:
# do not set to pull_request.head.ref, claude will pull the branch if needed
fetch-depth: 0
- name: Install UV
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install dependencies
run: uv sync --python 3.12
- name: Generate Marvin App token
id: marvin-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: React to comment with eyes
env:
GH_TOKEN: ${{ steps.marvin-token.outputs.token }}
run: |
gh api "repos/${{ github.repository }}/issues/comments/${{ github.event.comment.id }}/reactions" -f content=eyes 2>/dev/null || true
- name: Get PR HEAD SHA
id: pr-info
env:
GH_TOKEN: ${{ steps.marvin-token.outputs.token }}
run: |
PR_NUMBER="${{ github.event.issue.number }}"
HEAD_SHA=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}" --jq '.head.sha')
echo "head_sha=${HEAD_SHA}" >> "$GITHUB_OUTPUT"
echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT"
- name: Run Claude for PR Comment
uses: ./.github/actions/run-claude
env:
MENTION_REPO: ${{ github.repository }}
MENTION_PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }}
MENTION_SCRIPTS: ${{ github.workspace }}/.github/scripts/mention
PR_REVIEW_REPO: ${{ github.repository }}
PR_REVIEW_PR_NUMBER: ${{ steps.pr-info.outputs.pr_number }}
PR_REVIEW_HEAD_SHA: ${{ steps.pr-info.outputs.head_sha }}
PR_REVIEW_COMMENTS_DIR: /tmp/pr-review-comments
PR_REVIEW_HELPERS_DIR: ${{ github.workspace }}/.github/scripts/pr-review
COMMENT_BODY: ${{ github.event.comment.body }}
PR_TITLE: ${{ github.event.issue.title }}
with:
claude-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github-token: ${{ steps.marvin-token.outputs.token }}
trigger-phrase: "/marvin"
allowed-bots: "*"
allowed-tools: "Edit,MultiEdit,Glob,Grep,LS,Read,Write,WebSearch,WebFetch,mcp__github_comment__update_claude_comment,mcp__github_ci__get_ci_status,mcp__github_ci__get_workflow_run_details,mcp__github_ci__download_job_log,Bash(*),mcp__agents-md-generator__generate_agents_md,mcp__public-code-search__search_code"
prompt: |
<context>
Repository: ${{ github.repository }}
PR Number: #${{ steps.pr-info.outputs.pr_number }}
PR Title: ${{ env.PR_TITLE }}
PR Author: ${{ github.event.issue.user.login }}
Comment Author: ${{ github.event.comment.user.login }}
**Note**: The PR head branch has already been checked out. The workspace is ready - you can immediately start working on the PR code.
</context>
<user_request>
${{ env.COMMENT_BODY }}
</user_request>
<task>
You have been mentioned in a Pull Request comment. Understand the request, gather context, complete the task, and respond with results.
</task>
<constraints>
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
When making changes, commit and push to the PR's head branch so the author gets the fix directly.
</constraints>
<allowed_tools>
You have access to the following tools (comma-separated list):
Edit,MultiEdit,Glob,Grep,LS,Read,Write,WebSearch,WebFetch,mcp__github_comment__update_claude_comment,mcp__github_ci__get_ci_status,mcp__github_ci__get_workflow_run_details,mcp__github_ci__download_job_log,Bash(*),mcp__agents-md-generator__generate_agents_md,mcp__public-code-search__search_code
You can only use tools that are explicitly listed above. For Bash commands, the pattern `Bash(command:*)` means you can run that command with any arguments. If a command is not listed, it is not available.
</allowed_tools>
<getting_started>
Use `mcp__agents-md-generator__generate_agents_md` to get repository context before responding.
</getting_started>
<investigation_approach>
Be thorough in your investigations:
- Understand the full context of the repository
- Review related code, issues, and PRs
- Consider edge cases and implications
- Gather all relevant information before responding
Available tools:
- `mcp__public-code-search__search_code`: Search code in OTHER repositories (use `Grep`/`Read` for this repo)
- `WebSearch`: Search the web for documentation, best practices, or solutions
- `WebFetch`: Fetch and read content from URLs
</investigation_approach>
<common_tasks>
- Address review feedback and fix issues (commit and push to the PR branch)
- Answer questions about the changes
- Make code changes and push them
- Resolve review threads after addressing feedback
- Perform PR reviews when asked (use the PR review process below)
</common_tasks>
<pr_review_guidance>
When asked to review this PR, follow this structured review process.
The `$PR_REVIEW_HELPERS_DIR` environment variable is pre-configured for all scripts below.
<review_process>
Follow these steps in order:
**Step 1: Gather context**
- Use `mcp__agents-md-generator__generate_agents_md` to get repository context
(if this fails, explore the repository to understand the codebase — read key files like README, CONTRIBUTING, etc.)
- Run `$PR_REVIEW_HELPERS_DIR/pr-existing-comments.sh --summary` to see existing review threads per file
- Run `$PR_REVIEW_HELPERS_DIR/pr-diff.sh` to see changed files with line-numbered diffs
(for large PRs, this lists files only — review each with `pr-diff.sh <filename>`)
**Step 2: Review each file**
For each changed file:
a. If the summary showed existing threads for this file, first run:
`$PR_REVIEW_HELPERS_DIR/pr-existing-comments.sh --file <path>`
Read the full thread details. The output uses these conventions:
- `← has replies` — a conversation happened; read carefully before commenting
- `[truncated]` — comment was cut short; add `--full` if you need the complete text to understand the comment
- `[abc1234]` — commit the comment was made on; use `git show abc1234` if needed
- `~42` — approximate line from an older revision (exact line no longer maps to current diff)
b. Review the diff. Use `Read` to see full file contents when you need more context.
Identify issues matching review_criteria. Do NOT flag:
- Issues in unchanged code (only review the diff)
- Style preferences handled by linters
- Pre-existing issues not introduced by this PR
- Issues already covered by existing threads (see below)
**Existing thread rules** (check BEFORE leaving any comment):
- Resolved with reviewer reply → reviewer's decision is final. Do NOT re-flag.
Examples: "It should remain as X", "This is intentional", "No need to do this change"
- Resolved without reply → author likely fixed it. Do NOT re-raise unless the fix introduced a new problem.
- Unresolved → already flagged. Do NOT re-comment. Mention in review body if you have more to add.
- Outdated → code changed. Only re-flag if the issue still applies to the current diff.
When in doubt, do not duplicate. Redundant comments erode trust in the review process.
**Step 3: Leave comments for NEW issues only**
For each genuinely new issue not covered by existing threads:
```bash
$PR_REVIEW_HELPERS_DIR/pr-comment.sh <file> <line> \
--severity <critical|high|medium|low|nitpick> \
--title "Brief description" \
--why "Risk or impact" <<'EOF'
corrected code here
EOF
```
Always provide suggestion code. Use `--no-suggestion` only when the fix requires
changes across multiple locations. Broader architectural concerns belong in the
review body, not inline comments.
To remove a queued comment: `$PR_REVIEW_HELPERS_DIR/pr-remove-comment.sh <file> <line>`
**Step 4: Submit the review**
```bash
$PR_REVIEW_HELPERS_DIR/pr-review.sh <APPROVE|REQUEST_CHANGES|COMMENT> "<review body>"
```
- REQUEST_CHANGES: Any 🔴 CRITICAL or 🟠 HIGH issues found
- COMMENT: 🟡 MEDIUM issues found (but no critical/high)
- APPROVE: No issues, or only ⚪ LOW / 💬 NITPICK suggestions
The review body should include broader architectural concerns not suited for inline comments.
Avoid summarizing the PR or offering praise. If approving with no issues, omit the review body.
A standard footer is automatically appended to all comments and reviews.
</review_process>
<severity_classification>
🔴 CRITICAL - Must fix before merge (security vulnerabilities, data corruption, production-breaking bugs)
🟠 HIGH - Should fix before merge (logic errors, missing validation, significant performance issues)
🟡 MEDIUM - Address soon, non-blocking (error handling gaps, suboptimal patterns, missing edge cases)
⚪ LOW - Author discretion, non-blocking (minor improvements, documentation, style not covered by linters)
💬 NITPICK - Truly optional (stylistic preferences, alternative approaches — safe to ignore)
</severity_classification>
<review_criteria>
Focus on these categories, in priority order:
1. Security vulnerabilities (injection, XSS, auth bypass, secrets exposure)
2. Logic bugs that could cause runtime failures or incorrect behavior
3. Data integrity issues (race conditions, missing transactions, corruption risk)
4. Performance bottlenecks (N+1 queries, memory leaks, blocking operations)
5. Error handling gaps (unhandled exceptions, missing validation)
6. Breaking changes to public APIs without migration path
7. Missing or incorrect test coverage for critical paths
</review_criteria>
<review_calibration>
**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.
</review_calibration>
</pr_review_guidance>
<review_thread_tools>
View unresolved review threads:
```bash
$MENTION_SCRIPTS/gh-get-review-threads.sh
```
Filter for unresolved threads from a specific reviewer:
```bash
$MENTION_SCRIPTS/gh-get-review-threads.sh "reviewer-username"
```
Resolve a review thread after addressing feedback:
```bash
$MENTION_SCRIPTS/gh-resolve-review-thread.sh "THREAD_ID" "Fixed by updating the error handling"
```
- `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: You can resolve threads after pushing fixes, or resolve them to acknowledge feedback that will be addressed separately.
</review_thread_tools>
<response_guidelines>
- 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 `<details>` 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:
- That you've submitted the review (with the outcome: approved, requested changes, etc.)
- Any issues encountered during the review process
- Brief status updates
Do NOT duplicate the review content in your comment - the review itself contains all the details.
Keep the comment short, e.g., "I've submitted my review requesting changes. See the review for details."
</response_guidelines>
<github_safety>
- 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.
</github_safety>
<response_footer>
Always end your comment with a new line, three dashes, and the footer message:
<exact_content>
---
Marvin Context Protocol | Type `/marvin` to interact further
Give us feedback! React with 🚀 if perfect, 👍 if helpful, 👎 if not.
</exact_content>
</response_footer>
<github_formatting>
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.
</github_formatting>
+131
View File
@@ -0,0 +1,131 @@
name: Marvin Issue Dedupe
# description: Automatically dedupe GitHub issues using Marvin
on:
issues:
types: [opened]
workflow_dispatch:
inputs:
issue_number:
description: "Issue number to process for duplicate detection"
required: true
type: string
jobs:
marvin-dedupe-issues:
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Generate Marvin App token
id: marvin-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- name: Set dedupe prompt
id: dedupe-prompt
run: |
cat >> $GITHUB_OUTPUT << 'EOF'
PROMPT<<PROMPT_END
Find up to 3 likely duplicate issues for GitHub issue ${{ github.repository }}/issues/${{ github.event.issue.number || inputs.issue_number }}.
# Core Principle
Silence is better than noise. A false positive wastes a human's time and erodes trust in every future report. Most runs should end with no comment — that means the system is working.
# Steps
1. Check if the GitHub issue (a) is closed, (b) does not need to be deduped (eg. because it is broad product feedback without a specific solution, or positive feedback), or (c) already has a duplicates comment that you made earlier. If so, do not proceed.
2. View the GitHub issue and produce a summary of the issue.
3. Launch 3 parallel agents using the Task tool to search GitHub for duplicates, using diverse keywords and search approaches, using the summary from step 2.
4. Filter aggressively for false positives. The bar for "duplicate" is high:
A duplicate means the SAME bug or the SAME feature request. Apply this test to every candidate:
- **Same fix test**: Could the candidate be closed by the exact same code change? If not, not a duplicate.
- **Same symptom test**: Does the user experience the exact same broken behavior? "Both involve middleware" is not duplication. "Both get TypeError on line 42 of proxy.py when calling mount()" is duplication.
- **Same request test** (for features): Are they asking for the same specific capability? "Both want better auth" is not duplication. "Both request OAuth PKCE flow for CLI login" is duplication.
Candidates found by only one search agent deserve extra scrutiny — a single keyword match is often a false positive.
When in doubt, do not flag. A missed duplicate is harmless; a false positive wastes the reporter's time.
If there are no duplicates remaining, do not proceed — just exit.
5. **Quality gate**: Before commenting, re-read each candidate as a skeptical reviewer. For each one, ask: "Would a maintainer who knows this codebase agree this is a duplicate, or would they dismiss it?" If you'd need to hedge with "might" or "possibly," drop it.
6. Comment back on the issue with your findings (or exit silently if none remain). Do NOT add any labels — labeling is handled by a later workflow step.
# Notes for your agents
- Use `gh` to interact with GitHub, rather than web fetch
- Do not use other tools beyond `gh` and Task (no MCP servers, file edit, etc.)
- Never include this issue as a duplicate of itself
- When searching, read the FULL body of candidate issues — titles alone are not enough to judge duplication
For your comment, follow this format precisely (example with 3 suspected duplicates):
---
Found 3 possible duplicate issues:
1. #123: Issue title here
2. #456: Another issue title
3. #789: Third issue title
This issue will be automatically closed as a duplicate in 3 days.
- If your issue is a duplicate, please close it and 👍 the existing issue instead
- To prevent auto-closure, add a comment or 👎 this comment
---
PROMPT_END
EOF
- name: Clean up stale Claude locks
run: rm -rf ~/.claude/.locks ~/.local/state/claude/locks || true
- name: Run Marvin dedupe command
uses: anthropics/claude-code-action@v1
with:
github_token: ${{ steps.marvin-token.outputs.token }}
bot_name: "Marvin Context Protocol"
prompt: ${{ steps.dedupe-prompt.outputs.PROMPT }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
allowed_non_write_users: "*"
claude_args: |
--allowedTools Bash(gh issue view:*),Bash(gh search:*),Bash(gh issue list:*),Bash(gh api:*),Bash(gh issue comment:*),Task
settings: |
{
"model": "claude-sonnet-4-6",
"env": {
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
}
}
- name: Add potential-duplicate label if bot commented in this run
env:
GH_TOKEN: ${{ steps.marvin-token.outputs.token }}
run: |
ISSUE=${{ github.event.issue.number || inputs.issue_number }}
# Only match bot comments created in the last 10 minutes (this run)
CUTOFF=$(date -u -d '10 minutes ago' '+%Y-%m-%dT%H:%M:%SZ' 2>/dev/null \
|| date -u -v-10M '+%Y-%m-%dT%H:%M:%SZ')
HAS_RECENT=$(gh api "repos/${{ github.repository }}/issues/${ISSUE}/comments?sort=created&direction=desc&per_page=10" \
--jq "[.[] | select(
.user.type == \"Bot\" and
(.body | test(\"possible duplicate issues\"; \"i\")) and
.created_at >= \"${CUTOFF}\"
)] | length")
if [ "$HAS_RECENT" -gt 0 ]; then
gh issue edit "$ISSUE" --add-label "potential-duplicate" -R "${{ github.repository }}"
echo "Added potential-duplicate label to #${ISSUE}"
else
echo "No recent duplicate comment found, skipping label"
fi
+159
View File
@@ -0,0 +1,159 @@
name: Marvin Label Triage
# Automatically triage GitHub issues and PRs using Marvin
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
workflow_dispatch:
inputs:
issue_number:
description: "Issue or PR number to triage"
required: true
type: string
concurrency:
group: triage-${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }}
cancel-in-progress: false
jobs:
label-issue-or-pr:
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout base repository
uses: actions/checkout@v7
with:
repository: ${{ github.repository }}
ref: ${{ github.event.repository.default_branch }}
- name: Generate Marvin App token
id: marvin-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
owner: PrefectHQ
- name: Set triage prompt
id: triage-prompt
run: |
cat >> $GITHUB_OUTPUT << 'EOF'
PROMPT<<PROMPT_END
You're an issue triage assistant for FastMCP, a Python framework for building Model Context Protocol servers and clients. Your task is to analyze issues/PRs and apply appropriate labels.
IMPORTANT: Your primary action should be to apply labels using mcp__github__update_issue. DO NOT post comments EXCEPT when applying the too-long label (see below).
CRITICAL — LABEL MECHANICS:
- `mcp__github__update_issue` REPLACES all labels on the issue — it does not add to them.
- Before applying labels, read the issue's current labels with `mcp__github__get_issue`.
- Always include any existing labels you want to keep alongside the new ones.
- Only apply labels that exist in the repository (from `gh label list` in step 1). Never invent labels.
Issue/PR Information:
- REPO: ${{ github.repository }}
- NUMBER: ${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }}
- TYPE: ${{ github.event.issue && 'issue' || (github.event.pull_request && 'pull_request') || 'unknown' }}
TRIAGE PROCESS:
1. Get available labels:
Run: `gh label list`
2. Retrieve issue/PR details using GitHub tools:
- mcp__github__get_issue: Get the issue/PR details
- mcp__github__get_issue_comments: Read any discussion
- If the issue/PR mentions other issues (e.g., "fixes #123", "related to #456"), use mcp__github__get_issue to read those linked issues for additional context
3. Analyze and apply labels based on these guidelines:
CORE CATEGORIES (apply EXACTLY ONE - these are mutually exclusive; skip if applying too-long):
- bug: Reports of broken functionality OR PRs that fix bugs
- enhancement: New functions/endpoints, improvements to existing features, internal tooling, workflow improvements, minor new capabilities
- feature: ONLY for major headline functionality worthy of a blog post announcement (2-4 per release, never for issues)
- documentation: Primary change is to user-facing docs, examples, or guides
SPECIAL DOCUMENTATION RULES:
- DO NOT apply "documentation" label if PR only updates auto-generated SDK docs (docs/python-sdk/**)
- DO apply "documentation" label for significant user-facing documentation changes (guides, examples, API docs)
- Auto-generated docs updates should get appropriate category label (enhancement, bug, etc.) based on the underlying code changes
FEATURE vs ENHANCEMENT guidance:
- feature: Major systems like new auth systems, MCP composition, proxying MCP servers, major CLI commands that transform workflows
- enhancement: New functions/endpoints, internal workflows, CI improvements, developer tooling, refactoring, utilities, typical new CLI commands
- If unsure between feature/enhancement, choose enhancement
Note: If a PR fixes a bug, label it "bug" not "enhancement"
SPECIAL CATEGORY (can be combined with above):
- breaking change: Changes that break backward compatibility (in addition to core category)
PRIORITY (apply if clearly evident):
- high-priority: Critical bugs affecting many users, security issues, or blocking core functionality
- low-priority: Edge cases, nice-to-have improvements, or cosmetic issues
- Default to no priority label if unclear
STATUS (apply if applicable):
- needs more info: Issue lacks reproduction steps, error messages, or clear description
- invalid: Spam, completely off-topic, or nonsensical (often LLM-generated)
- too-long: Apply when an issue or PR doesn't conform to CONTRIBUTING.md. Issues should be a short problem description, an MRE, and expected vs. actual behavior — not a design document. PRs should have a focused description of the change — not a report. We don't need proposed solutions or design alternatives (the issue should describe the problem and let maintainers architect the fix), summaries of what tests cover, explanations of code we can read ourselves, or speculative root-cause analysis. Common LLM failure modes to watch for: verbose "diagnostic" writeups, large proposed patches in issue bodies, multi-section reports restating what's visible in the diff, numbered lists of possible approaches or solutions, "suggested" schemas/shapes/APIs, generic analysis that doesn't reference specific code, and "Notes" sections. But these are heuristics, not rules — a complex PR may legitimately need more context, and a brief submission can still be low-quality. Judge by whether the content helps a reviewer or just adds noise. When applying too-long, still apply the core category and area labels — too-long is a format signal, not a replacement for categorization. Issues still need to be findable by category.
WHEN APPLYING too-long: After labeling, post a brief comment using mcp__github__add_issue_comment:
"Thanks for the report. This issue goes beyond what our contributor guidelines ask for — we just need a short problem description and an MRE. Please see our [contributing guidelines](https://github.com/PrefectHQ/fastmcp/blob/main/CONTRIBUTING.md) and condense this issue. We'll triage it once it's trimmed down."
Use this exact text (or very close to it). Do not editorialize or add details.
AREA LABELS (apply ONLY when thematically central to the issue):
- cli: Issues primarily about FastMCP CLI commands (run, dev, install)
- client: Issues primarily about the Client SDK or client-side functionality
- server: Issues primarily about FastMCP server implementation
- auth: Authentication is the main concern (Bearer, JWT, OAuth, WorkOS)
- openapi: OpenAPI integration/parsing is the primary topic
- http: HTTP transport or networking is the main issue
- contrib: Specifically about community contributions in fastmcp_slim/fastmcp/contrib/
- tests: Issues primarily about testing infrastructure, CI/CD workflows, or test coverage
- security: Apply ONLY when the issue/PR addresses an exploitable vulnerability or hardens against one. Examples: SSRF, LFI, path traversal, injection, auth bypass allowing unauthorized access, scope escalation, open redirects. Do NOT apply for ordinary auth bugs (wrong scopes returned, token refresh logic, OAuth flow correctness) unless an attacker could exploit the bug to bypass access controls or escalate privileges. The key question: "Could a malicious actor exploit this?" If the answer is just "it breaks for legitimate users," that's a bug, not a security issue.
LABELING PRINCIPLES:
- Precision over recall: a missing label is a minor inconvenience; a wrong label sends the wrong people to the wrong issue. When in doubt, don't apply.
- Don't apply area labels just because a file in that area is mentioned — the issue must be PRIMARILY about that area.
- Apply 2-5 labels total typically (category + maybe priority + maybe 1-2 areas).
- For ambiguous cases (bug vs enhancement, which area label), prefer the more conservative choice or omit the uncertain label entirely.
META LABELS (rarely needed for issues):
- dependencies: Only for dependabot PRs or issues specifically about package updates
- DON'T MERGE: Only if PR author explicitly states it's not ready
4. Apply selected labels:
Use mcp__github__update_issue to apply your selected labels
DO NOT post any comments unless applying too-long (see above)
PROMPT_END
EOF
- name: Clean up stale Claude locks
run: rm -rf ~/.claude/.locks ~/.local/state/claude/locks || true
- name: Run Marvin for Issue Triage
uses: anthropics/claude-code-action@v1
with:
github_token: ${{ steps.marvin-token.outputs.token }}
bot_name: "Marvin Context Protocol"
prompt: ${{ steps.triage-prompt.outputs.PROMPT }}
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY_FOR_CI }}
allowed_non_write_users: "*"
allowed_bots: "marvin-context-protocol"
claude_args: |
--allowedTools Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__add_issue_comment,mcp__github__get_pull_request_files
settings: |
{
"model": "claude-sonnet-4-6",
"env": {
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
}
}
@@ -0,0 +1,47 @@
# Minimize resolved PR review comments to reduce noise.
#
# Runs automatically on review activity for same-repo PRs. Fork PRs are
# skipped because GITHUB_TOKEN is read-only in that context. Collaborators
# can comment "/tidy" on any PR (including forks) to trigger manually.
name: Minimize Resolved Reviews
on:
pull_request_review:
types: [submitted]
pull_request_review_comment:
types: [created, edited]
issue_comment:
types: [created]
# Scope the group by event name so that the sibling events fired by a single
# review action (pull_request_review + pull_request_review_comment, same instant)
# don't cancel each other. Same-PR runs of the *same* event still supersede
# cleanly, and the last one always completes.
concurrency:
group: minimize-reviews-${{ github.event.pull_request.number || github.event.issue.number }}-${{ github.event_name }}
cancel-in-progress: true
permissions:
pull-requests: write
jobs:
minimize:
# /tidy comment: collaborators can trigger on any PR (token has write access)
# Review events: skip fork PRs where GITHUB_TOKEN lacks write permissions
if: >-
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/tidy') &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)
) || (
github.event_name != 'issue_comment' &&
github.event.pull_request.head.repo.full_name == github.event.pull_request.base.repo.full_name
)
runs-on: ubuntu-latest
steps:
- name: Minimize resolved review comments
uses: strawgate/minimize-resolved-pr-reviews@v0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1,87 @@
name: Publish fastmcp-remote to PyPI
on:
workflow_run:
workflows: ["Publish fastmcp-slim to PyPI"]
types: [completed]
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
pypi-publish:
name: Upload fastmcp-remote to PyPI
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release')
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Build fastmcp-remote
run: uv build --package fastmcp-remote
- name: Verify matching fastmcp-slim is published
run: |
SLIM_VERSION=$(python - <<'PY'
import email.parser
import re
import zipfile
from pathlib import Path
wheel = next(Path("dist").glob("fastmcp_remote-*.whl"))
metadata_name = next(
name for name in zipfile.ZipFile(wheel).namelist()
if name.endswith(".dist-info/METADATA")
)
metadata = email.parser.Parser().parsestr(
zipfile.ZipFile(wheel).read(metadata_name).decode()
)
for value in metadata.get_all("Requires-Dist", []):
requirement, _, marker = value.partition(";")
if marker.strip():
continue
match = re.fullmatch(
r"fastmcp-slim(?:\[[^\]]+\])?==([^;\s]+)",
requirement.strip(),
)
if match:
print(match.group(1))
break
else:
raise RuntimeError("Could not find the base fastmcp-slim dependency")
PY
)
for attempt in {1..12}; do
if python - "$SLIM_VERSION" <<'PY'
import json
import sys
import urllib.request
version = sys.argv[1]
url = f"https://pypi.org/pypi/fastmcp-slim/{version}/json"
with urllib.request.urlopen(url, timeout=30) as response:
json.load(response)
PY
then
exit 0
fi
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI yet; retrying (${attempt}/12)."
sleep 10
done
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp-remote." >&2
exit 1
- name: Publish fastmcp-remote to PyPI
run: uv publish -v dist/fastmcp_remote-*.tar.gz dist/fastmcp_remote-*.whl
@@ -0,0 +1,30 @@
name: Publish fastmcp-slim to PyPI
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
pypi-publish:
name: Upload fastmcp-slim to PyPI
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Build fastmcp-slim
run: uv build --package fastmcp-slim
- name: Publish fastmcp-slim to PyPI
run: uv publish -v dist/fastmcp_slim-*.tar.gz dist/fastmcp_slim-*.whl
+151
View File
@@ -0,0 +1,151 @@
name: Publish fastmcp to PyPI
on:
workflow_run:
workflows: ["Publish fastmcp-slim to PyPI"]
types: [completed]
workflow_dispatch:
permissions:
contents: read
id-token: write
jobs:
pypi-publish:
name: Upload fastmcp to PyPI
runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'release')
outputs:
is_prerelease: ${{ steps.package_version.outputs.is_prerelease }}
version: ${{ steps.package_version.outputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Build fastmcp
run: uv build --package fastmcp
- name: Read built package version
id: package_version
run: |
python - <<'PY' >> "$GITHUB_OUTPUT"
import email.parser
import re
import zipfile
from pathlib import Path
wheel = next(Path("dist").glob("fastmcp-*.whl"))
metadata_name = next(
name for name in zipfile.ZipFile(wheel).namelist()
if name.endswith(".dist-info/METADATA")
)
metadata = email.parser.Parser().parsestr(
zipfile.ZipFile(wheel).read(metadata_name).decode()
)
version = metadata["Version"]
public_version = version.partition("+")[0]
is_prerelease = bool(
re.search(
r"(?i)(?:^|[0-9.])(?:a|b|c|rc|alpha|beta|pre|preview|dev)[0-9]*",
public_version,
)
)
print(f"version={version}")
print(f"is_prerelease={str(is_prerelease).lower()}")
PY
- name: Verify matching fastmcp-slim is published
run: |
SLIM_VERSION=$(python - <<'PY'
import email.parser
import re
import zipfile
from pathlib import Path
wheel = next(Path("dist").glob("fastmcp-*.whl"))
metadata_name = next(
name for name in zipfile.ZipFile(wheel).namelist()
if name.endswith(".dist-info/METADATA")
)
metadata = email.parser.Parser().parsestr(
zipfile.ZipFile(wheel).read(metadata_name).decode()
)
for value in metadata.get_all("Requires-Dist", []):
requirement, _, marker = value.partition(";")
if marker.strip():
continue
match = re.fullmatch(
r"fastmcp-slim(?:\[[^\]]+\])?==([^;\s]+)",
requirement.strip(),
)
if match:
print(match.group(1))
break
else:
raise RuntimeError("Could not find the base fastmcp-slim dependency")
PY
)
for attempt in {1..12}; do
if python - "$SLIM_VERSION" <<'PY'
import json
import sys
import urllib.request
version = sys.argv[1]
url = f"https://pypi.org/pypi/fastmcp-slim/{version}/json"
with urllib.request.urlopen(url, timeout=30) as response:
json.load(response)
PY
then
exit 0
fi
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI yet; retrying (${attempt}/12)."
sleep 10
done
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp." >&2
exit 1
- name: Publish fastmcp to PyPI
run: uv publish -v dist/fastmcp-*.tar.gz dist/fastmcp-*.whl
update-published-docs:
name: Update published-docs branch
runs-on: ubuntu-latest
needs: pypi-publish
if: github.event_name == 'workflow_run' && github.event.workflow_run.event == 'release' && needs['pypi-publish'].outputs.is_prerelease != 'true'
timeout-minutes: 2
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ github.event.workflow_run.head_sha }}
- name: Check release line
id: release_line
env:
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
git fetch origin "${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${DEFAULT_BRANCH}"; then
echo "update_published_docs=true" >> "$GITHUB_OUTPUT"
else
echo "update_published_docs=false" >> "$GITHUB_OUTPUT"
echo "Release commit is not on ${DEFAULT_BRANCH}; skipping published-docs update."
fi
- name: Point published-docs at published release
if: steps.release_line.outputs.update_published_docs == 'true'
run: git push --force origin "HEAD:published-docs"
+587
View File
@@ -0,0 +1,587 @@
# Require external PRs to reference an issue with an auto-close keyword
# (e.g. "Fixes #123") AND have the PR author assigned to that issue.
# Otherwise the PR is labeled "missing-issue-link", commented on, and
# closed. CONTRIBUTING.md requires external contributors to be assigned to
# an issue before opening a PR; this enforces that.
#
# Adapted from langchain-ai/langchain's require_issue_link.yml. Differences:
# - Self-contained: it does NOT depend on a separate labeler workflow
# applying an "external" label first, so it can run on `opened`.
# - "External" is determined authoritatively, in-script, from the PR
# author's repo collaborator permission level — NOT from the event
# payload's author_association. author_association reports MEMBER only
# for *public* org members; a maintainer whose org membership is
# private appears as CONTRIBUTOR/NONE, so gating on it would wrongly
# enforce against private-member maintainers. getCollaboratorPermission
# reflects effective write access regardless of membership visibility.
# - The enforcement path is a single github-script step (the upstream
# version is split across four, forcing the label/comment/reopen helpers
# to be duplicated per scope).
# - Issue assignment events are handled in this same workflow so assigning
# the linked issue reopens previously closed PRs automatically.
#
# Maintainer override: reopen the PR, or remove the "missing-issue-link"
# label — either applies a sticky "bypass-issue-check" label and reopens.
name: Require Issue Link
on:
pull_request_target:
# SECURITY: pull_request_target runs with repo write scope against the
# BASE repo. NEVER check out or execute PR-head code here — it would run
# with these permissions. This workflow only reads the PR payload and
# calls the API; it never checks anything out.
# ready_for_review matters because the job skips drafts: without it a
# draft opened with no issue link would never be checked when it later
# becomes reviewable.
types: [opened, edited, reopened, ready_for_review, labeled, unlabeled]
issues:
# Assignment is what makes a previously closed "not assigned" PR compliant,
# so it needs a separate event path that finds and reopens matching PRs.
types: [assigned]
# Dry run: when 'false' the check still runs and logs its verdict but makes
# NO mutations at all (no label, comment, close, reopen, or failure). Flip
# to 'true' to enforce.
env:
ENFORCE_ISSUE_LINK: "true"
permissions:
contents: read
jobs:
check-issue-link:
# Cheap pre-filters only. Maintainer detection is deliberately NOT done
# here: the job-level `if` can't call the API, and author_association is
# unreliable for private org members (see file header). The job runs,
# then the script resolves the author's real permission and exits early
# for maintainers.
#
# Gate: only run on pull_request_target events. The workflow also listens
# to `issues.assigned` (handled by reopen-on-assignment below), and without
# this guard the job would also fire there — `github.event.pull_request` is
# null on an issues event, so `...draft == false` coerces to true and the
# script then dereferences a missing PR and crashes. Beyond the event type,
# skip drafts, bots, and already-bypassed/trusted PRs, and allow the primary
# actions plus the one maintainer-override action we care about (removing
# the missing-issue-link label).
if: >-
github.event_name == 'pull_request_target' &&
github.event.pull_request.draft == false &&
!endsWith(github.actor, '[bot]') &&
!contains(github.event.pull_request.labels.*.name, 'trusted-contributor') &&
!contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') &&
(
(github.event.action != 'labeled' && github.event.action != 'unlabeled') ||
(github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link')
)
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: require-issue-link-${{ github.event.pull_request.number }}
cancel-in-progress: false
permissions:
issues: write
pull-requests: write
steps:
- name: Enforce issue link
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const prNumber = pr.number;
const action = context.payload.action;
const enforce = process.env.ENFORCE_ISSUE_LINK === 'true';
const LABEL = 'missing-issue-link';
const MARKER = '<!-- require-issue-link -->';
// Dry-run guard: every mutating call goes through this so that
// ENFORCE_ISSUE_LINK=false means strictly read-only.
async function mutate(description, fn) {
if (!enforce) {
console.log(`[dry-run] would ${description}`);
return;
}
await fn();
}
// Authoritative maintainer check. Uses collaborator permission,
// not org membership or author_association:
// - GITHUB_TOKEN is an app token and is never an org member,
// so the org-membership endpoint always 403s.
// - author_association reports MEMBER only for *public* org
// members; a private-member maintainer shows as
// CONTRIBUTOR/NONE. Permission level is visibility-
// independent and reflects effective access.
// 404 (not a collaborator) → not a maintainer. Other errors
// (rate limit, 5xx) MUST throw: silently treating them as
// "not a maintainer" could wrongly close a maintainer's PR.
// A throw aborts the script before any close/label call, so the
// job fails red and the PR is left untouched — the safe direction.
async function hasWriteAccess(username) {
if (!username) throw new Error('No username — cannot check permissions');
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner, repo, username,
});
const ok = ['admin', 'maintain', 'write'].includes(data.permission);
console.log(`${username}: ${data.permission} — ${ok ? 'maintainer' : 'not a maintainer'}`);
return ok;
} catch (e) {
if (e.status === 404) {
console.log(`${username} is not a collaborator — not a maintainer`);
return false;
}
throw new Error(
`Permission check failed for ${username} (HTTP ${e.status ?? 'unknown'}): ${e.message}`,
);
}
}
async function addLabel() {
await mutate(`label PR #${prNumber} "${LABEL}"`, async () => {
try {
await github.rest.issues.getLabel({ owner, repo, name: LABEL });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({ owner, repo, name: LABEL, color: 'b76e79' });
} catch (createErr) {
// 422 = created by a concurrent run between GET and POST.
if (createErr.status !== 422) throw createErr;
}
}
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [LABEL],
});
});
}
async function minimizeStaleComment() {
try {
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 },
);
const stale = comments.find(c => c.body && c.body.includes(MARKER));
if (!stale) return;
await mutate(`minimize stale comment ${stale.id}`, () => github.graphql(`
mutation($id: ID!) {
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
minimizedComment { isMinimized }
}
}
`, { id: stale.node_id }));
} catch (e) {
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
}
}
// Shared "this PR passes" cleanup: drop the label, reopen, and
// retire any stale enforcement comment.
//
// For the normal pass paths we only reopen if THIS workflow had
// closed the PR — inferred from the label still being on the
// payload. The maintainer-override paths pass forceReopen: the
// `unlabeled` event payload no longer carries the just-removed
// label, so the heuristic can't see it; without forcing, the
// advertised "remove the label to bypass" gesture would leave
// the PR closed.
async function clearEnforcement(forceReopen = false) {
await mutate(`remove "${LABEL}" from PR #${prNumber}`, async () => {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: LABEL,
});
} catch (e) {
if (e.status !== 404) throw e;
}
});
const hadLabel = pr.labels.map(l => l.name).includes(LABEL);
if (pr.state === 'closed' && (forceReopen || hadLabel)) {
await mutate(`reopen PR #${prNumber}`, async () => {
await github.rest.pulls.update({
owner, repo, pull_number: prNumber, state: 'open',
});
});
}
await minimizeStaleComment();
}
async function applyBypass(reason) {
console.log(reason);
await clearEnforcement(true);
await mutate(`add sticky "bypass-issue-check" to PR #${prNumber}`, async () => {
try {
await github.rest.issues.getLabel({ owner, repo, name: 'bypass-issue-check' });
} catch (e) {
if (e.status !== 404) throw e;
try {
await github.rest.issues.createLabel({
owner, repo, name: 'bypass-issue-check', color: '0e8a16',
});
} catch (createErr) {
if (createErr.status !== 422) throw createErr;
}
}
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: ['bypass-issue-check'],
});
});
}
// ── Maintainer-authored PRs are exempt entirely ────────────────
if (await hasWriteAccess(pr.user.login)) {
console.log(`PR author ${pr.user.login} has write access — exempt`);
await clearEnforcement();
return;
}
const sender = context.payload.sender?.login;
// ── Maintainer override: removed the "missing-issue-link" label ─
if (action === 'unlabeled') {
if (await hasWriteAccess(sender)) {
await applyBypass(`Maintainer ${sender} removed ${LABEL} from PR #${prNumber} — bypassing`);
return;
}
// Only triage/admin can manage labels, so a non-write actor
// reaching here is rare (triage role). Fall through to the
// normal check, which recomputes link + assignment and
// re-enforces with the correct message if still failing.
console.log(`Non-maintainer ${sender} removed ${LABEL} — re-checking`);
}
// ── Maintainer override: reopened a PR we had closed ───────────
if (
action === 'reopened' &&
pr.labels.map(l => l.name).includes(LABEL) &&
(await hasWriteAccess(sender))
) {
await applyBypass(`Maintainer ${sender} reopened PR #${prNumber} — bypassing`);
return;
}
// ── Race guard: re-read live labels ────────────────────────────
const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({
owner, repo, issue_number: prNumber,
});
const liveNames = liveLabels.map(l => l.name);
if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) {
console.log('PR carries trusted-contributor or bypass-issue-check — clearing any prior enforcement');
await clearEnforcement();
return;
}
// ── The actual check: an auto-close keyword + issue number ─────
const body = pr.body || '';
// Match GitHub's auto-close keywords against any reference form
// that GitHub itself honors: bare `#123`, the `owner/repo#123`
// shorthand, and the full issue URL. Scope the qualified forms to
// THIS repo — GitHub only auto-closes same-repo issues, so a
// cross-repo reference must not be resolved against our numbering.
const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(
'(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*' +
`(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`,
'gi',
);
const matches = [...body.matchAll(pattern)];
if (matches.length === 0) {
console.log('No issue link found in PR body');
await enforceFailure('no-link');
return;
}
// The author must be assigned to at least one linked issue.
// CONTRIBUTING.md requires external contributors to be assigned
// before opening a PR (so maintainers can deconflict / steer
// approach first).
const MAX_ISSUES = 5;
const allNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))];
const numbers = allNumbers.slice(0, MAX_ISSUES);
if (allNumbers.length > MAX_ISSUES) {
core.warning(`PR references ${allNumbers.length} issues — checking only the first ${MAX_ISSUES}`);
}
const prAuthor = pr.user.login.toLowerCase();
let sawRealIssue = false;
let assignedToAny = false;
for (const num of numbers) {
let issue;
try {
({ data: issue } = await github.rest.issues.get({
owner, repo, issue_number: num,
}));
} catch (e) {
if (e.status === 404) {
console.log(`#${num} does not exist — ignoring`);
continue;
}
// Same safe-direction rule as hasWriteAccess: a transient
// error must not be read as "not assigned" and close the PR.
throw new Error(`Cannot fetch issue #${num} (HTTP ${e.status ?? 'unknown'}): ${e.message}`);
}
sawRealIssue = true;
const assignees = (issue.assignees || []).map(a => a.login.toLowerCase());
if (assignees.includes(prAuthor)) {
console.log(`PR author ${pr.user.login} is assigned to #${num}`);
assignedToAny = true;
break;
}
console.log(`PR author ${pr.user.login} is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`);
}
if (!sawRealIssue) {
console.log('Referenced issue(s) do not exist');
await enforceFailure('no-link');
return;
}
if (!assignedToAny) {
await enforceFailure('not-assigned');
return;
}
console.log('Linked and assigned — clearing any prior enforcement');
await clearEnforcement();
// ── Label, comment, close, and fail ────────────────────────────
// `kind`: 'no-link' (no valid issue reference) or 'not-assigned'
// (referenced an issue, but the author isn't assigned to it).
async function enforceFailure(kind) {
await addLabel();
const intro = kind === 'no-link'
? '**This PR has been automatically closed** because its description does not reference a tracked issue.'
: '**This PR has been automatically closed** because you are not assigned to the issue it references.';
const steps = kind === 'no-link'
? [
`1. Find or [open an issue](https://github.com/${owner}/${repo}/issues/new/choose) describing the change.`,
'2. Comment on the issue to ask a maintainer to assign it to you.',
'3. Add `Fixes #<issue>`, `Closes #<issue>`, or `Resolves #<issue>` to the PR description.',
'4. Once you are assigned and the link is present, the PR reopens automatically.',
]
: [
'1. Comment on the linked issue to ask a maintainer to assign it to you.',
'2. Once a maintainer assigns you, the PR reopens automatically.',
];
const commentBody = [
MARKER,
intro,
'',
`Per [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md), an external PR must reference an issue that is assigned to its author. To proceed:`,
'',
...steps,
'',
`*Maintainers: reopen this PR or remove the \`${LABEL}\` label to bypass this check.*`,
].join('\n');
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 },
);
const existing = comments.find(c => c.body && c.body.includes(MARKER));
if (!existing) {
await mutate(`comment on PR #${prNumber}`, () => github.rest.issues.createComment({
owner, repo, issue_number: prNumber, body: commentBody,
}));
} else if (existing.body !== commentBody) {
await mutate(`update comment ${existing.id}`, () => github.rest.issues.updateComment({
owner, repo, comment_id: existing.id, body: commentBody,
}));
} else {
console.log('Requirement comment already present — skipping');
}
if (pr.state === 'open') {
await mutate(`close PR #${prNumber}`, () => github.rest.pulls.update({
owner, repo, pull_number: prNumber, state: 'closed',
}));
}
if (enforce) {
core.setFailed(
kind === 'no-link'
? 'PR must reference a tracked issue using an auto-close keyword (e.g. "Fixes #123").'
: 'PR author must be assigned to the referenced issue.',
);
}
}
reopen-on-assignment:
if: github.event_name == 'issues' && github.event.action == 'assigned' && !github.event.issue.pull_request
runs-on: ubuntu-latest
timeout-minutes: 10
concurrency:
group: reopen-on-assignment-${{ github.event.issue.number }}-${{ github.event.assignee.login }}
cancel-in-progress: false
permissions:
actions: write
issues: write
pull-requests: write
steps:
- name: Reopen linked PRs
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo;
const issueNumber = context.payload.issue.number;
const assignee = context.payload.assignee.login;
const enforce = process.env.ENFORCE_ISSUE_LINK === 'true';
const LABEL = 'missing-issue-link';
const MARKER = '<!-- require-issue-link -->';
// Match GitHub's auto-close keywords against any reference form
// that GitHub itself honors: bare `#123`, the `owner/repo#123`
// shorthand, and the full issue URL. Scope the qualified forms to
// THIS repo — GitHub only auto-closes same-repo issues, so a
// cross-repo reference must not be resolved against our numbering.
const repoRef = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(
'(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s*' +
`(?:${repoRef}#|#|https?://github\\.com/${repoRef}/issues/)(\\d+)`,
'gi',
);
async function mutate(description, fn) {
if (!enforce) {
console.log(`[dry-run] would ${description}`);
return;
}
await fn();
}
console.log(`Issue #${issueNumber} assigned to ${assignee} — searching for closed PRs to reopen`);
const q = [
'is:pr',
'is:closed',
`author:${assignee}`,
`label:${LABEL}`,
`repo:${owner}/${repo}`,
].join(' ');
let search;
try {
({ data: search } = await github.rest.search.issuesAndPullRequests({
q,
per_page: 30,
}));
} catch (e) {
throw new Error(
`Failed to search closed PRs for ${assignee} after assigning #${issueNumber} ` +
`(HTTP ${e.status ?? 'unknown'}): ${e.message}`,
);
}
if (search.total_count === 0) {
console.log('No matching closed PRs found');
return;
}
console.log(`Found ${search.total_count} candidate PR(s)`);
for (const item of search.items) {
const prNumber = item.number;
let issue;
try {
({ data: issue } = await github.rest.issues.get({
owner, repo, issue_number: prNumber,
}));
} catch (e) {
throw new Error(`Cannot fetch PR #${prNumber} issue data (HTTP ${e.status ?? 'unknown'}): ${e.message}`);
}
const labels = (issue.labels || []).map(label => label.name);
if (labels.includes('bypass-issue-check')) {
console.log(`PR #${prNumber} already has bypass-issue-check — skipping`);
continue;
}
const body = issue.body || '';
const referencedIssues = [...body.matchAll(pattern)].map(match => parseInt(match[1], 10));
if (!referencedIssues.includes(issueNumber)) {
console.log(`PR #${prNumber} does not reference #${issueNumber} — skipping`);
continue;
}
try {
await mutate(`reopen PR #${prNumber}`, () => github.rest.pulls.update({
owner, repo, pull_number: prNumber, state: 'open',
}));
} catch (e) {
if (e.status === 422) {
core.warning(`Cannot reopen PR #${prNumber}: the head branch was likely deleted`);
await mutate(`comment on unreopenable PR #${prNumber}`, () => github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body:
`You have been assigned to #${issueNumber}, but this PR could not be ` +
'reopened because the head branch has been deleted. Please open a new PR ' +
'referencing the issue.',
}));
continue;
}
throw e;
}
await mutate(`remove "${LABEL}" from PR #${prNumber}`, async () => {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: LABEL,
});
} catch (e) {
if (e.status !== 404) throw e;
}
});
try {
const comments = await github.paginate(
github.rest.issues.listComments,
{ owner, repo, issue_number: prNumber, per_page: 100 },
);
const stale = comments.find(comment => comment.body && comment.body.includes(MARKER));
if (stale) {
await mutate(`minimize stale comment ${stale.id}`, () => github.graphql(`
mutation($id: ID!) {
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
minimizedComment { isMinimized }
}
}
`, { id: stale.node_id }));
}
} catch (e) {
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
}
try {
const { data: pr } = await github.rest.pulls.get({
owner, repo, pull_number: prNumber,
});
const { data: runs } = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'require-issue-link.yml',
head_sha: pr.head.sha,
status: 'failure',
per_page: 1,
});
if (runs.workflow_runs.length === 0) {
console.log(`No failed require-issue-link runs found for PR #${prNumber}`);
continue;
}
await mutate(`re-run failed require-issue-link run for PR #${prNumber}`, () =>
github.rest.actions.reRunWorkflowFailedJobs({
owner, repo, run_id: runs.workflow_runs[0].id,
}),
);
} catch (e) {
core.warning(`Could not re-run require-issue-link for PR #${prNumber}: ${e.message}`);
}
}
@@ -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@v7
- 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
+41
View File
@@ -0,0 +1,41 @@
name: Run static analysis
env:
PY_COLORS: 1
on:
push:
branches: ["main"]
paths:
- "fastmcp_slim/**"
- "fastmcp_remote/**"
- "tests/**"
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/**"
# run on all pull requests because these checks are required and will block merges otherwise
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
static_analysis:
timeout-minutes: 2
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Setup uv
uses: ./.github/actions/setup-uv
with:
resolution: locked
- name: Run prek
uses: j178/prek-action@v2
env:
SKIP: no-commit-to-branch
+267
View File
@@ -0,0 +1,267 @@
name: Tests
env:
PY_COLORS: 1
on:
push:
branches: ["main"]
paths:
- "fastmcp_slim/**"
- "fastmcp_remote/**"
- "tests/**"
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/**"
# run on all pull requests because these checks are required and will block merges otherwise
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
run_tests:
name: "Tests: Python ${{ matrix.python-version }} on ${{ matrix.os }}"
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.10"]
include:
- os: ubuntu-latest
python-version: "3.13"
fail-fast: false
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup uv
uses: ./.github/actions/setup-uv
with:
python-version: ${{ matrix.python-version }}
resolution: locked
- name: Run unit tests
uses: ./.github/actions/run-pytest
- name: Run client process tests
uses: ./.github/actions/run-pytest
with:
test-type: client_process
run_tests_lowest_direct:
name: "Tests with lowest-direct dependencies"
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup uv (lowest-direct)
uses: ./.github/actions/setup-uv
with:
resolution: lowest-direct
- name: Run unit tests
uses: ./.github/actions/run-pytest
- name: Run client process tests
uses: ./.github/actions/run-pytest
with:
test-type: client_process
run_conformance_tests:
name: "MCP conformance tests"
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup uv
uses: ./.github/actions/setup-uv
with:
resolution: locked
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
- name: Run conformance tests
uses: ./.github/actions/run-pytest
with:
test-type: conformance
run_integration_tests:
name: "Integration tests"
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup uv
uses: ./.github/actions/setup-uv
with:
resolution: locked
- name: Run integration tests
uses: ./.github/actions/run-pytest
with:
test-type: integration
env:
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@v7
- 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 --prerelease=allow --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
- name: Install fastmcp-remote from matching local wheels
run: |
uv venv /tmp/fastmcp-remote-smoke
REMOTE_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp_remote-*.whl)
uv pip install --python /tmp/fastmcp-remote-smoke/bin/python --prerelease=allow --find-links /tmp/fastmcp-dist "$REMOTE_WHEEL"
/tmp/fastmcp-remote-smoke/bin/python - <<'PY'
from importlib.metadata import entry_points
from importlib.metadata import requires
from fastmcp_remote.cli import build_parser
remote_reqs = requires("fastmcp-remote") or []
assert any("fastmcp-slim[client,server]" in req for req in remote_reqs)
assert any(
ep.name == "fastmcp-remote" and ep.value == "fastmcp_remote.cli:main"
for ep in entry_points(group="console_scripts")
)
assert build_parser().prog == "fastmcp-remote"
PY
+157
View File
@@ -0,0 +1,157 @@
name: Upgrade checks
env:
PY_COLORS: 1
on:
push:
branches: ["main"]
paths:
- "fastmcp_slim/**"
- "tests/**"
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/**"
schedule:
# Run daily at 2 AM UTC
- cron: "0 2 * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
static_analysis:
name: Static analysis
timeout-minutes: 2
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Setup uv (upgrade)
uses: ./.github/actions/setup-uv
with:
resolution: upgrade
- name: Run prek
uses: j178/prek-action@v2
env:
SKIP: no-commit-to-branch
run_tests:
name: "Tests: Python ${{ matrix.python-version }} on ${{ matrix.os }}"
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
python-version: ["3.10"]
include:
- os: ubuntu-latest
python-version: "3.13"
fail-fast: false
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup uv (upgrade)
uses: ./.github/actions/setup-uv
with:
python-version: ${{ matrix.python-version }}
resolution: upgrade
- name: Run unit tests
uses: ./.github/actions/run-pytest
- name: Run client process tests
uses: ./.github/actions/run-pytest
with:
test-type: client_process
run_integration_tests:
name: "Integration tests"
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Setup uv (upgrade)
uses: ./.github/actions/setup-uv
with:
resolution: upgrade
- name: Run integration tests
uses: ./.github/actions/run-pytest
with:
test-type: integration
env:
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 }}
notify:
name: Notify on failure
needs: [static_analysis, run_tests, run_integration_tests]
if: failure() && github.event.pull_request == null
runs-on: ubuntu-latest
steps:
- name: Create or update failure issue
uses: jayqi/failed-build-issue-action@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
label: "build failed"
title-template: "Upgrade checks failing on main branch"
body-template: |
## Upgrade Checks Failure on Main Branch
The upgrade checks workflow has failed on the main branch.
**Workflow Run**: [#{{runNumber}}]({{serverUrl}}/{{repo.owner}}/{{repo.repo}}/actions/runs/{{runId}})
**Commit**: {{sha}}
**Branch**: {{ref}}
**Event**: {{eventName}}
### Common causes
- **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).
### What to do
1. Check the workflow logs to identify which job failed (static analysis vs tests)
2. Reproduce locally with `uv sync --upgrade && uv run prek run --all-files && uv run pytest -n auto`
3. Fix the code or adjust dependency constraints as needed
---
*This issue was automatically created by a GitHub Action.*
close-on-success:
name: Close issue on success
needs: [static_analysis, run_tests, run_integration_tests]
if: success() && github.event.pull_request == null && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- name: Close resolved failure issue
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
issue=$(gh issue list \
--repo "$GITHUB_REPOSITORY" \
--label "build failed" \
--state open \
--json number \
--jq '.[0].number // empty')
if [ -n "$issue" ]; then
gh issue close "$issue" \
--repo "$GITHUB_REPOSITORY" \
--comment "Upgrade checks are passing again as of [\`${GITHUB_SHA::7}\`](${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA})."
fi
@@ -0,0 +1,72 @@
name: Update MCPServerConfig Schema
# Regenerates config schema on pushes to main and opens a long-lived PR
# with the changes, so contributor PRs stay clean.
on:
push:
branches: ["main"]
paths:
- "fastmcp_slim/fastmcp/utilities/mcp_server_config/**"
- "!fastmcp_slim/fastmcp/utilities/mcp_server_config/v1/schema.json"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-config-schema:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Generate Marvin App token
id: marvin-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- uses: actions/checkout@v7
with:
token: ${{ steps.marvin-token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install dependencies
run: uv sync --python 3.12
- name: Generate config schema
run: |
uv run python -c "
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('fastmcp_slim/fastmcp/utilities/mcp_server_config/v1/schema.json')
"
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ steps.marvin-token.outputs.token }}
commit-message: "chore: Update fastmcp.json schema"
title: "chore: Update fastmcp.json schema"
body: |
This PR updates the fastmcp.json schema files to match the current source code.
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.
🤖 Generated by Marvin
branch: marvin/update-config-schema
labels: |
ignore in release notes
delete-branch: true
author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
+69
View File
@@ -0,0 +1,69 @@
name: Update SDK Documentation
# Regenerates SDK docs on pushes to main and opens a long-lived PR
# with the changes, so contributor PRs stay clean.
on:
push:
branches: ["main"]
paths:
- "fastmcp_slim/**"
- "pyproject.toml"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
update-sdk-docs:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Generate Marvin App token
id: marvin-token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.MARVIN_APP_ID }}
private-key: ${{ secrets.MARVIN_APP_PRIVATE_KEY }}
- uses: actions/checkout@v7
with:
token: ${{ steps.marvin-token.outputs.token }}
- name: Install uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
cache-dependency-glob: "uv.lock"
- name: Install dependencies
run: uv sync --python 3.12
- name: Install just
uses: extractions/setup-just@v4
- name: Generate SDK documentation
run: just api-ref-all
- name: Create Pull Request
uses: peter-evans/create-pull-request@v8
with:
token: ${{ steps.marvin-token.outputs.token }}
commit-message: "chore: Update SDK documentation"
title: "chore: Update SDK documentation"
body: |
This PR updates the auto-generated SDK documentation to reflect the latest source code changes.
📚 Documentation is automatically generated from the source code docstrings and type annotations.
**Note:** This PR is fully automated and will update itself with any subsequent changes to the SDK, or close automatically if the documentation becomes up-to-date through other means. Feel free to leave it open until you're ready to merge.
🤖 Generated by Marvin
branch: marvin/update-sdk-docs
labels: |
ignore in release notes
delete-branch: true
author: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
committer: "marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com>"
+80
View File
@@ -0,0 +1,80 @@
# Python-generated files
__pycache__/
*.py[cod]
*$py.class
build/
dist/
wheels/
*.egg-info/
*.egg
MANIFEST
.pytest_cache/
.loq_cache
.coverage
htmlcov/
.tox/
nosetests.xml
coverage.xml
*.cover
# Virtual environments
.venv
venv/
env/
ENV/
.env
# System files
.DS_Store
# Version file
src/fastmcp/_version.py
# Editors and IDEs
.cursorrules
.vscode/
.idea/
*.swp
*.swo
*~
.project
.pydevproject
.settings/
# Jupyter Notebook
.ipynb_checkpoints
# Type checking
.mypy_cache/
.dmypy.json
dmypy.json
.pyre/
.pytype/
# Local development
.python-version
.envrc
.envrc.private
.direnv/
# Logs and databases
*.log
*.sqlite
*.db
*.ddb
# Claude worktree management
.claude-wt/worktrees
.claude/worktrees/
# Agents
/PLAN.md
/TODO.md
/STATUS.md
plans/
# Common FastMCP test files
/test.py
/server.py
/client.py
/test.json
+55
View File
@@ -0,0 +1,55 @@
fail_fast: false
repos:
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.24.1
hooks:
- id: validate-pyproject
- repo: https://github.com/rbubley/mirrors-prettier
rev: v3.8.4
hooks:
- id: prettier
types_or: [yaml, json5]
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.14.10
hooks:
# Run the linter.
- id: ruff-check
args: [--fix, --exit-non-zero-on-fix]
# Run the formatter.
- id: ruff-format
- repo: local
hooks:
- id: ty
name: ty check
entry: uv run --isolated ty check
language: system
types: [python]
files: ^fastmcp_slim/|^tests/
pass_filenames: false
require_serial: true
- id: loq
name: loq (file size limits)
entry: bash -c 'uv run loq || printf "\nloq violations not enforced... yet!\n"'
language: system
pass_filenames: false
verbose: true
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: no-commit-to-branch
name: prevent commits to main
args: [--branch, main]
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
hooks:
- id: codespell # See pyproject.toml for args
additional_dependencies:
- tomli
Symlink
+1
View File
@@ -0,0 +1 @@
CLAUDE.md
+186
View File
@@ -0,0 +1,186 @@
# FastMCP Development Guidelines
> **Audience**: LLM-driven engineering agents and human developers
> **Note**: `AGENTS.md` is a symlink to this file. Edit `CLAUDE.md` directly.
FastMCP is a comprehensive Python framework (Python ≥3.10) for building Model Context Protocol (MCP) servers and clients. This is the actively maintained v2.0 providing a complete toolkit for the MCP ecosystem.
## Required Development Workflow
**CRITICAL**: Always run these commands in sequence before committing.
```bash
uv sync # Install dependencies
uv run pytest -n auto # Run full test suite
```
In addition, you must pass static checks. This is generally done as a pre-commit hook with `prek` but you can run it manually with:
```bash
uv run prek run --all-files # Ruff + Prettier + ty
```
**Tests must pass and lint/typing must be clean before committing.**
## Repository Structure
| Path | Purpose |
| ----------------- | -------------------------------------- |
| `fastmcp_slim/fastmcp/` | Library source code |
| `├─server/` | Server implementation |
| `│ ├─auth/` | Authentication providers |
| `│ └─middleware/` | Error handling, logging, rate limiting |
| `├─client/` | Client SDK |
| `│ └─auth/` | Client authentication |
| `├─tools/` | Tool definitions |
| `├─resources/` | Resources and resource templates |
| `├─prompts/` | Prompt templates |
| `├─cli/` | CLI commands |
| `└─utilities/` | Shared utilities |
| `tests/` | Pytest suite |
| `docs/` | Mintlify docs (gofastmcp.com) |
## Core MCP Objects
When modifying MCP functionality, changes typically need to be applied across all object types:
- **Tools** (`src/tools/`)
- **Resources** (`src/resources/`)
- **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.
### Git & CI
- Prek hooks are required (run automatically on commits)
- Never amend commits to fix prek failures
- Never apply labels manually or invent new ones — the GitHub API auto-creates any unknown label name, polluting the repo's label list. Note the appropriate label in the PR body and let the maintainer/automation apply it. Canonical names: `bugs`, `breaking change`, `enhancements`, `features` (it's `breaking change`, not `breaking`). See the review-pr skill.
- Improvements = enhancements (not features) unless specified
- **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 <num> --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.
### Outbound Comments and Shell Interpolation
- Never pass GitHub, Linear, or Slack comment bodies inline through shell arguments when the body contains `$`, `${...}`, backticks, `$(...)`, environment-variable examples, secrets, or config interpolation examples.
- Use a body file or structured API payload for outbound comments, then inspect the exact outgoing text before posting. Prefer `gh ... --body-file /path/to/comment.md` over `--body "..."`.
- When explaining environment interpolation, use placeholders and fenced code blocks. Never include raw `.env` contents in outbound comments.
### Releases
Only cut releases when the maintainer explicitly asks. Tags follow `v<version>` (e.g., `v3.2.0`). Always pass `--generate-notes` so the auto-generated changelog appears at the bottom.
**The title pun is critical.** Titles follow `v<version>: <pun>` where the pun relates to the most important theme of the release. Propose multiple options and let the maintainer choose — never pick one yourself. Look at recent releases for tone (e.g., "Code to Joy" for the code mode release, "Three at Last" for 3.0).
Write the maintainer-approved handwritten notes to a temporary file, then create the release. `--generate-notes` appends the auto-generated changelog after the handwritten content.
```bash
gh release create v4.0.0 --target main --title "v4.0.0: Theme Here" --generate-notes --notes-start-tag v3.4.4 --notes-file /tmp/release-notes.md
```
**Always pass `--notes-start-tag <last-stable-tag>`.** Without it, `--generate-notes` picks the most recent prior tag as the changelog start point — and if a prerelease exists (e.g. `v3.4.0b1`), it starts from *that*, silently truncating the PR list to only the commits since the beta. Pin it to the last stable release (e.g. `v3.3.1` when cutting `v3.4.0`). Verify after: the compare link at the bottom of the generated notes should read `v<last-stable>...v<new>`.
Use the branch that owns the release line as the target: current-major releases target `main`, 3.x maintenance releases target `release/3.x`, and 2.x maintenance releases target `release/2.x`. Confirm the target with the maintainer if there's any ambiguity. For example, cut a 3.4.4 maintenance release with `--target release/3.x`, not `main`.
The handwritten notes are prepended above the auto-generated changelog and are the part that matters. Do not include a title in the notes body — the release title (`v{version}: {pun}`) already serves as the heading. Work with the maintainer to draft the notes — propose a draft, get feedback, iterate. Do not publish without the maintainer's sign-off.
**Before drafting, always read recent existing releases** (`gh release list` then `gh release view <tag>`) 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'
```
Set `target_commitish` to the same branch that will receive the release tag. For maintenance releases, use the maintenance branch (for example, `release/3.x`) so the preview matches the release notes GitHub will generate.
**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.
**Merge the docs changelog PR *before* cutting the release, not after.** The post-publish `update-published-docs` job force-pushes the `published-docs` branch (which gofastmcp.com serves) to the released commit for stable releases on the default branch, so the changelog entry only reaches the live site if it's already in the commit being tagged. Land the docs PR on the release target branch first, then cut the release from that branch. If you tag first and merge docs after, this release's changelog won't appear on the live site until the next default-branch stable release force-pushes `published-docs` forward. Maintenance releases from `release/3.x` or `release/2.x` publish packages and GitHub notes without repointing `published-docs`; add their changelog entries on the maintenance branch, slotted into the matching major-version section. Two hand-maintained files mirror the GitHub release and must get a new entry for every version, newest at the top (these are `.mdx` and are not covered by the prek Prettier hook, which only runs on `yaml`/`json5` — match the existing entries' style by hand):
- `docs/changelog.mdx` is the full mirror. Add an `<Update label="v<version>" description="YYYY-MM-DD">` block with: a bold linked title (`**[v<version>: <pun>](<release-url>)**`), a condensed 1-paragraph intro (one sentence for patches), the full categorized PR list reformatted from the `--generate-notes` output (`* <title> by [@user](https://github.com/user) in [#NNNN](<pull-url>)`), a `## New Contributors` list (plain `@user`, linked PR), and a `**Full Changelog**: [vA...vB](<compare-url>)` line.
- `docs/updates.mdx` is the skimmable card feed. Add an `<Update label="FastMCP <version>" description="Month DD, YYYY" tags={["Releases"]}>` wrapping a `<Card>` that links to the GitHub release, with a 1-2 sentence summary and (for point releases) a handful of emoji-bulleted highlights.
Because the docs land *before* the tag exists, derive the entry from the maintainer-approved handwritten notes (intro/summary) and the `--generate-notes` API *preview* (the PR-list body — see the generate-notes API call above, which returns the exact changelog without cutting anything). Scripting the link reformatting is reliable for long PR lists. The release-URL, tag, and compare links follow the known pattern (`/releases/tag/v<version>`, `compare/v<last-stable>...v<version>`) and will 404 only during the short window between merging the docs PR and cutting the release minutes later — they resolve before the release workflow completes. For this reason, create and merge the docs PR *immediately* before cutting the release — treat the two as one tight back-to-back sequence, not independent steps — so the links are valid by the time the release publishes rather than dangling for any longer than necessary.
### Commit Messages and Agent Attribution
- **Agents NOT acting on behalf of @jlowin MUST identify themselves** (e.g., "🤖 Generated with Claude Code" in commits/PRs)
- Keep commit messages brief - ideally just headlines, not detailed messages
- Focus on what changed, not how or why
- Always read issue comments for follow-up information (treat maintainers as authoritative)
- **Treat proposed solutions in issues skeptically.** This applies to solutions proposed by *users* in issue reports — not to feedback from configured review bots (CodeRabbit, chatgpt-codex-connector, etc.), which should be evaluated on their merits. The ideal issue contains a concise problem description and an MRE — nothing more. Proposed solutions are only worth considering if they clearly reflect genuine, non-obvious investigation of the codebase. If a solution reads like speculation, or like it was generated by an LLM without deep framework knowledge, ignore it and diagnose from the repro. Most reporters — human or AI — do not have sufficient understanding of FastMCP internals to correctly diagnose anything beyond a trivial bug. We can ask the same questions of an LLM when implementing; we don't need the reporter to do it for us, and a wrong diagnosis is worse than none.
### PR Messages - Required Structure
- 1-2 paragraphs: problem/tension + solution (PRs are documentation!)
- Focused code example showing key capability
- **Avoid:** bullet summaries, exhaustive change lists, verbose closes/fixes, marketing language
- **Do:** Be opinionated about why change matters, show before/after scenarios
- Minor fixes: keep body short and concise
- No "test plan" sections or testing summaries
### Code Review Guidelines
- **Fix causes, not symptoms.** When a PR works around a problem instead of addressing why it occurs, that's a red flag. A side-channel that compensates for a missing step adds permanent complexity. If the fix doesn't change the code path where the bug actually happens, ask why not.
- Focus on API design and naming clarity
- Identify confusing patterns (e.g., parameter values that contradict defaults) or non-idiomatic code (mutable defaults, etc.). Contributed code will need to be maintained indefinitely, and by someone other than the author (unless the author is a maintainer).
- Suggest specific improvements, not generic "add more tests" comments
- Think about API ergonomics from a user perspective
### Code Standards
- Python ≥ 3.10 with full type annotations
- Follow existing patterns and maintain consistency
- **Prioritize readable, understandable code** - clarity over cleverness
- Avoid obfuscated or confusing patterns even if they're shorter
- Each feature needs corresponding tests
### Module Exports
- **Do not create overeager `__init__.py` files.** Package initializers should not import heavy submodules, provider stacks, optional integrations, or modules that can point back into the package. Overeager re-exports make the framework sprawl and create circular imports that only appear in fresh interpreters or clean installs.
- **Be intentional about re-exports** - don't blindly re-export everything to parent namespaces
- Core types that define a module's purpose should be exported (e.g., `Middleware` from `fastmcp.server.middleware`)
- Specialized features can live in submodules (e.g., `fastmcp.server.middleware.dynamic`)
- Only re-export to `fastmcp.*` for the most fundamental types (e.g., `FastMCP`, `Client`)
- When in doubt, prefer users importing from the specific submodule over re-exporting
### Documentation
- 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 `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 `fastmcp_slim/fastmcp/settings.py`, update `docs/more/settings.mdx` to match.
### Documentation Guidelines
- **Code Examples:** Explain before showing code, make blocks fully runnable (include imports)
- **Code Formatting:** Keep code blocks visually clean — avoid deeply nested function calls. Extract intermediate values into named variables rather than inlining everything into one expression. Code in docs is read more than it's run; optimize for scannability.
- **Structure:** Headers form navigation guide, logical H2/H3 hierarchy
- **Content:** User-focused sections, motivate features (why) before mechanics (how)
- **Style:** Prose over code comments for important information
- **Docstrings:** FastMCP docstrings are automatically compiled into MDX documents. Use markdown (single backticks, fenced code blocks), not RST (no double backticks). Bare `{}` in examples will be interpreted as JSX — wrap in backticks instead.
## Critical Patterns
- Never use bare `except` - be specific with exception types
- File sizes enforced by [loq](https://github.com/jakekaplan/loq). Edit `loq.toml` to raise limits; `loq baseline` to ratchet down.
- Always `uv sync` first when debugging build issues
- Default test timeout is 5s - optimize or mark as integration tests
+128
View File
@@ -0,0 +1,128 @@
# Contributor Covenant Code of Conduct
## Our Pledge
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
We pledge to act and interact in ways that contribute to an open, welcoming,
diverse, inclusive, and healthy community.
## Our Standards
Examples of behavior that contributes to a positive environment for our
community include:
* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the
overall community
Examples of unacceptable behavior include:
* The use of sexualized language or imagery, and sexual attention or
advances of any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email
address, without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Enforcement Responsibilities
Community leaders are responsible for clarifying and enforcing our standards of
acceptable behavior and will take appropriate and fair corrective action in
response to any behavior that they deem inappropriate, threatening, offensive,
or harmful.
Community leaders have the right and responsibility to remove, edit, or reject
comments, commits, code, wiki edits, issues, and other contributions that are
not aligned to this Code of Conduct, and will communicate reasons for moderation
decisions when appropriate.
## Scope
This Code of Conduct applies within all community spaces, and also applies when
an individual is officially representing the community in public spaces.
Examples of representing our community include using an official e-mail address,
posting via an official social media account, or acting as an appointed
representative at an online or offline event.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported to the community leaders responsible for enforcement at
chris@prefect.io.
All complaints will be reviewed and investigated promptly and fairly.
All community leaders are obligated to respect the privacy and security of the
reporter of any incident.
## Enforcement Guidelines
Community leaders will follow these Community Impact Guidelines in determining
the consequences for any action they deem in violation of this Code of Conduct:
### 1. Correction
**Community Impact**: Use of inappropriate language or other behavior deemed
unprofessional or unwelcome in the community.
**Consequence**: A private, written warning from community leaders, providing
clarity around the nature of the violation and an explanation of why the
behavior was inappropriate. A public apology may be requested.
### 2. Warning
**Community Impact**: A violation through a single incident or series
of actions.
**Consequence**: A warning with consequences for continued behavior. No
interaction with the people involved, including unsolicited interaction with
those enforcing the Code of Conduct, for a specified period of time. This
includes avoiding interactions in community spaces as well as external channels
like social media. Violating these terms may lead to a temporary or
permanent ban.
### 3. Temporary Ban
**Community Impact**: A serious violation of community standards, including
sustained inappropriate behavior.
**Consequence**: A temporary ban from any sort of interaction or public
communication with the community for a specified period of time. No public or
private interaction with the people involved, including unsolicited interaction
with those enforcing the Code of Conduct, is allowed during this period.
Violating these terms may lead to a permanent ban.
### 4. Permanent Ban
**Community Impact**: Demonstrating a pattern of violation of community
standards, including sustained inappropriate behavior, harassment of an
individual, or aggression toward or disparagement of classes of individuals.
**Consequence**: A permanent ban from any sort of public interaction within
the community.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
version 2.0, available at
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
Community Impact Guidelines were inspired by [Mozilla's code of conduct
enforcement ladder](https://github.com/mozilla/diversity).
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see the FAQ at
https://www.contributor-covenant.org/faq. Translations are available at
https://www.contributor-covenant.org/translations.
+53
View File
@@ -0,0 +1,53 @@
# Contributing to FastMCP
FastMCP is an actively maintained, high-traffic project. We welcome contributions — but the most impactful way to contribute might not be what you expect.
## The best contribution is a great issue
FastMCP is an opinionated framework, and its maintainers use AI-assisted tooling that is deeply tuned to those opinions — the design philosophy, the API patterns, the way the framework is meant to evolve. A well-written issue with a clear problem description is often more valuable than a pull request, because it lets maintainers produce a solution that isn't just correct, but consistent with how the framework wants to work. That matters more than speed, though it's faster too.
**A great issue looks like this:**
1. A short, motivating description of the problem or gap
2. A minimal reproducible example (for bugs) or a concrete use case (for enhancements)
3. A brief note on expected vs. actual behavior
That's it. No need to diagnose root causes, propose API designs, or suggest implementations. If you've done genuine investigation and have a non-obvious insight, include it.
## Using AI to contribute
We encourage you to use LLMs to help identify bugs, write MREs, and prepare contributions. But if you do, your LLM must take into account the conventions and contributing guidelines of this repo — including how we want issues formatted and when it's appropriate to open a PR. Generic LLM output that ignores these guidelines tells us the contribution wasn't made thoughtfully, and we will close it. A good AI-assisted contribution is indistinguishable from a good human one. A bad one is obvious.
## When to open a pull request
An open issue is not an invitation to submit a PR. Issues track problems; whether and how to solve them is a separate decision. If you want to work on something, propose your approach in the issue first and ask a maintainer to assign it to you — especially for anything beyond a trivial fix. External PRs that reference an issue not assigned to their author are closed automatically (see [PR guidelines](#pr-guidelines)).
**Bug fixes** — PRs are welcome for simple, well-scoped bug fixes where the problem and solution are both straightforward. "The function raises `TypeError` when passed `None` because of a missing guard" is a good candidate. If the fix requires design decisions or touches multiple subsystems, open an issue with a design proposal instead.
**Documentation** — Typo fixes, clarifications, and improvements to examples are always welcome as PRs.
**Enhancements and features** — We welcome enhancement PRs, but our experience is that most contributors — even when using LLMs — implement fixes that address the one instance of a problem they encountered rather than understanding why the framework produces that problem and fixing it at the right layer. This creates branching, patch-style code that's difficult to maintain and makes it impossible to reason about the framework as a coherent system. For this reason, enhancements need a design proposal in the issue before code is written. The proposal doesn't need to be long — just enough to show you've thought about how the change fits into the framework, not just how it solves your immediate case.
**Integrations** — FastMCP generally does not accept PRs that add third-party integrations (custom middleware, provider-specific adapters, etc.). If you're building something for your users, ship it as a standalone package — that's a feature, not a limitation. Authentication providers are an exception, since auth is tightly coupled to the framework.
## PR guidelines
If you do open a PR:
- **Reference an issue you're assigned to.** Every PR must reference a tracked issue using an auto-close keyword (`Fixes #123`, `Closes #123`, or `Resolves #123`), and the referenced issue must be assigned to you. If there isn't an issue, open one; then comment to ask a maintainer to assign it to you. This lets us deconflict effort and steer the approach before you invest time in code. External PRs that don't meet both conditions are automatically labeled `missing-issue-link` and closed; they reopen automatically once the link is present and you're assigned.
- **Keep it focused.** One logical change per PR. Don't bundle unrelated fixes or refactors.
- **Match existing patterns.** Follow the code style, type annotation conventions, and test patterns you see in the codebase. Run `uv run prek run --all-files` before submitting.
- **Write tests.** Bug fixes should include a test that fails without the fix. Enhancements should include tests for the new behavior.
- **Fix the cause, not the symptom.** If the bug is that a code path skips a step, the fix should make it stop skipping that step — not add compensation elsewhere. Workaround-style fixes will be sent back for revision.
- **Don't submit generated boilerplate.** We review every line. PRs that read like unedited LLM output — verbose descriptions, speculative changes, shotgun-style fixes — will be closed.
## What we'll close without review
To keep the project maintainable, we will close PRs that:
- Don't reference an issue or address a clearly self-evident bug
- Make sweeping changes without prior discussion
- Add third-party integrations that belong in a separate package
- Are difficult to review due to size, scope, or generated content
This isn't personal — contributing to a framework is different from contributing to an application. In an application, a fix that works is a good fix. In a framework, a fix that works but doesn't fit the framework's design creates maintenance burden that compounds over time. Every patch that works around a problem instead of solving it at the right layer makes the system harder for *everyone* to reason about — maintainers, contributors, and users. We hold contributions to this standard because the alternative is a codebase that's a series of patches rather than a coherent system. A good issue is often the best thing you can do for the project.
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+123
View File
@@ -0,0 +1,123 @@
<div align="center">
<!-- omit in toc -->
<picture>
<source width="550" media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/PrefectHQ/fastmcp/main/docs/assets/brand/f-watercolor-waves-4-dark.png">
<source width="550" media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/PrefectHQ/fastmcp/main/docs/assets/brand/f-watercolor-waves-4.png">
<img width="550" alt="FastMCP Logo" src="https://raw.githubusercontent.com/PrefectHQ/fastmcp/main/docs/assets/brand/f-watercolor-waves-2.png">
</picture>
# FastMCP 🚀
<strong>Move fast and make things.</strong>
*Made with 💙 by [Prefect](https://www.prefect.io/)*
[![Docs](https://img.shields.io/badge/docs-gofastmcp.com-blue)](https://gofastmcp.com)
[![Discord](https://img.shields.io/badge/community-discord-5865F2?logo=discord&logoColor=white)](https://discord.gg/uu8dJCgttd)
[![PyPI - Version](https://img.shields.io/pypi/v/fastmcp.svg)](https://pypi.org/project/fastmcp)
[![Tests](https://github.com/PrefectHQ/fastmcp/actions/workflows/run-tests.yml/badge.svg)](https://github.com/PrefectHQ/fastmcp/actions/workflows/run-tests.yml)
[![License](https://img.shields.io/github/license/PrefectHQ/fastmcp.svg)](https://github.com/PrefectHQ/fastmcp/blob/main/LICENSE)
<a href="https://trendshift.io/repositories/21461" target="_blank"><img src="https://trendshift.io/api/badge/repositories/21461" alt="prefecthq%2Ffastmcp | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
---
The [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) connects LLMs to tools and data. FastMCP gives you everything you need to go from prototype to production:
```python
from fastmcp import FastMCP
mcp = FastMCP("Demo 🚀")
@mcp.tool
def add(a: int, b: int) -> int:
"""Add two numbers"""
return a + b
if __name__ == "__main__":
mcp.run()
```
## Why FastMCP
Building an effective MCP application is harder than it looks. FastMCP handles all of it. Declare a tool with a Python function, and the schema, validation, and documentation are generated automatically. Connect to a server with a URL, and transport negotiation, authentication, and protocol lifecycle are managed for you. You focus on your logic, and the MCP part just works: **with FastMCP, best practices are built in.**
**That's why FastMCP is the standard framework for working with MCP.** FastMCP 1.0 was incorporated into the official MCP Python SDK in 2024. Today, the actively maintained standalone project is downloaded a million times a day, and some version of FastMCP powers 70% of MCP servers across all languages.
FastMCP has three pillars:
<table>
<tr>
<td align="center" valign="top" width="33%">
<a href="https://gofastmcp.com/servers/server">
<img src="https://raw.githubusercontent.com/PrefectHQ/fastmcp/main/docs/assets/images/servers-card.png" alt="Servers" />
<br /><strong>Servers</strong>
</a>
<br />Expose tools, resources, and prompts to LLMs.
</td>
<td align="center" valign="top" width="33%">
<a href="https://gofastmcp.com/apps/overview">
<img src="https://raw.githubusercontent.com/PrefectHQ/fastmcp/main/docs/assets/images/apps-card.png" alt="Apps" />
<br /><strong>Apps</strong>
</a>
<br />Give your tools interactive UIs rendered directly in the conversation.
</td>
<td align="center" valign="top" width="33%">
<a href="https://gofastmcp.com/clients/client">
<img src="https://raw.githubusercontent.com/PrefectHQ/fastmcp/main/docs/assets/images/clients-card.png" alt="Clients" />
<br /><strong>Clients</strong>
</a>
<br />Connect to any MCP server — local or remote, programmatic or CLI.
</td>
</tr>
</table>
**[Servers](https://gofastmcp.com/servers/server)** wrap your Python functions into MCP-compliant tools, resources, and prompts. **[Clients](https://gofastmcp.com/clients/client)** connect to any server with full protocol support. And **[Apps](https://gofastmcp.com/apps/overview)** give your tools interactive UIs rendered directly in the conversation.
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).
## Run FastMCP in production with Horizon
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.
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
We recommend installing FastMCP with [uv](https://docs.astral.sh/uv/):
```bash
uv pip install fastmcp
```
For full installation instructions, including verification and upgrading, see the [**Installation Guide**](https://gofastmcp.com/getting-started/installation).
**Upgrading?** We have guides for:
- [Upgrading from FastMCP v2](https://gofastmcp.com/getting-started/upgrading/from-fastmcp-2)
- [Upgrading from the MCP Python SDK](https://gofastmcp.com/getting-started/upgrading/from-mcp-sdk)
- [Upgrading from the low-level SDK](https://gofastmcp.com/getting-started/upgrading/from-low-level-sdk)
> [!NOTE]
> If `import fastmcp` fails right after a `pip` upgrade from FastMCP 3.2 or earlier, run `pip install --force-reinstall fastmcp`. See [Troubleshooting](https://gofastmcp.com/getting-started/installation#troubleshooting) for why this happens (`uv` is unaffected).
## 📚 Documentation
FastMCP's complete documentation is available at **[gofastmcp.com](https://gofastmcp.com)**, including detailed guides, API references, and advanced patterns.
Documentation is also available in [llms.txt format](https://llmstxt.org/), which is a simple markdown standard that LLMs can consume easily:
- [`llms.txt`](https://gofastmcp.com/llms.txt) is essentially a sitemap, listing all the pages in the documentation.
- [`llms-full.txt`](https://gofastmcp.com/llms-full.txt) contains the entire documentation. Note this may exceed the context window of your LLM.
**Community:** Join our [Discord server](https://discord.gg/uu8dJCgttd) to connect with other FastMCP developers and share what you're building.
## Contributing
We welcome contributions! See the [Contributing Guide](https://gofastmcp.com/development/contributing) for setup instructions, testing requirements, and PR guidelines.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`PrefectHQ/fastmcp`
- 原始仓库:https://github.com/PrefectHQ/fastmcp
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+34
View File
@@ -0,0 +1,34 @@
# Security Policy
## Supported Versions
| Version | Supported |
| ------- | ------------------ |
| 3.x | :white_check_mark: |
| 2.x | :x: |
| 1.x | :x: |
| 0.x | :x: |
## Reporting a Vulnerability
Please report security vulnerabilities privately using [GitHub's security advisory feature](https://github.com/PrefectHQ/fastmcp/security/advisories/new). Do not open public issues for security concerns.
## Scope
We accept reports for vulnerabilities in FastMCP itself — the library code in this repository.
The following are **out of scope**:
- Vulnerabilities in third-party dependencies or the MCP SDK itself. We'll bump version floors for known CVEs, but the fix belongs upstream.
- Limitations of upstream identity providers that FastMCP cannot control.
- Issues that require the attacker to already have server-side access or control of the MCP server configuration.
## Disclosure Process
When we receive a valid report:
1. We triage the report and determine whether it affects FastMCP directly.
2. We develop and test a fix on a private branch.
3. We coordinate CVE assignment through GitHub's advisory process when warranted.
4. We publish the advisory and release a patched version.
5. We credit the reporter in the advisory (unless they prefer otherwise).
+2
View File
@@ -0,0 +1,2 @@
changelog.mdx
python-sdk/
+364
View File
@@ -0,0 +1,364 @@
---
description:
globs: *.mdx
alwaysApply: false
---
# Mintlify technical writing assistant
You are an AI writing assistant specialized in creating exceptional technical documentation using Mintlify components and following industry-leading technical writing practices.
## Core writing principles
### Language and style requirements
- Use clear, direct language appropriate for technical audiences
- Write in second person ("you") for instructions and procedures
- Use active voice over passive voice
- Employ present tense for current states, future tense for outcomes
- Maintain consistent terminology throughout all documentation
- Keep sentences concise while providing necessary context
- Use parallel structure in lists, headings, and procedures
### Content organization standards
- Lead with the most important information (inverted pyramid structure)
- Use progressive disclosure: basic concepts before advanced ones
- Break complex procedures into numbered steps
- Include prerequisites and context before instructions
- Provide expected outcomes for each major step
- End sections with next steps or related information
- Use descriptive, keyword-rich headings for navigation and SEO
### User-centered approach
- Focus on user goals and outcomes rather than system features
- Anticipate common questions and address them proactively
- Include troubleshooting for likely failure points
- Provide multiple pathways when appropriate (beginner vs advanced), but offer an opinionated path for people to follow to avoid overwhelming with options
## Mintlify component reference
### Callout components
#### Note - Additional helpful information
<Note>
Supplementary information that supports the main content without interrupting flow
</Note>
#### Tip - Best practices and pro tips
<Tip>
Expert advice, shortcuts, or best practices that enhance user success
</Tip>
#### Warning - Important cautions
<Warning>
Critical information about potential issues, breaking changes, or destructive actions
</Warning>
#### Info - Neutral contextual information
<Info>
Background information, context, or neutral announcements
</Info>
#### Check - Success confirmations
<Check>
Positive confirmations, successful completions, or achievement indicators
</Check>
### Code components
#### Single code block
```javascript config.js
const apiConfig = {
baseURL: 'https://api.example.com',
timeout: 5000,
headers: {
'Authorization': `Bearer ${process.env.API_TOKEN}`
}
};
```
#### Code group with multiple languages
<CodeGroup>
```javascript Node.js
const response = await fetch('/api/endpoint', {
headers: { Authorization: `Bearer ${apiKey}` }
});
```
```python Python
import requests
response = requests.get('/api/endpoint',
headers={'Authorization': f'Bearer {api_key}'})
```
```curl cURL
curl -X GET '/api/endpoint' \
-H 'Authorization: Bearer YOUR_API_KEY'
```
</CodeGroup>
#### Request/Response examples
<RequestExample>
```bash cURL
curl -X POST 'https://api.example.com/users' \
-H 'Content-Type: application/json' \
-d '{"name": "John Doe", "email": "john@example.com"}'
```
</RequestExample>
<ResponseExample>
```json Success
{
"id": "user_123",
"name": "John Doe",
"email": "john@example.com",
"created_at": "2024-01-15T10:30:00Z"
}
```
</ResponseExample>
### Structural components
#### Steps for procedures
<Steps>
<Step title="Install dependencies">
Run `npm install` to install required packages.
<Check>
Verify installation by running `npm list`.
</Check>
</Step>
<Step title="Configure environment">
Create a `.env` file with your API credentials.
```bash
API_KEY=your_api_key_here
```
<Warning>
Never commit API keys to version control.
</Warning>
</Step>
</Steps>
#### Tabs for alternative content
<Tabs>
<Tab title="macOS">
```bash
brew install node
npm install -g package-name
```
</Tab>
<Tab title="Windows">
```powershell
choco install nodejs
npm install -g package-name
```
</Tab>
<Tab title="Linux">
```bash
sudo apt install nodejs npm
npm install -g package-name
```
</Tab>
</Tabs>
#### Accordions for collapsible content
<AccordionGroup>
<Accordion title="Troubleshooting connection issues">
- **Firewall blocking**: Ensure ports 80 and 443 are open
- **Proxy configuration**: Set HTTP_PROXY environment variable
- **DNS resolution**: Try using 8.8.8.8 as DNS server
</Accordion>
<Accordion title="Advanced configuration">
```javascript
const config = {
performance: { cache: true, timeout: 30000 },
security: { encryption: 'AES-256' }
};
```
</Accordion>
</AccordionGroup>
### API documentation components
#### Parameter fields
<ParamField path="user_id" type="string" required>
Unique identifier for the user. Must be a valid UUID v4 format.
</ParamField>
<ParamField body="email" type="string" required>
User's email address. Must be valid and unique within the system.
</ParamField>
<ParamField query="limit" type="integer" default="10">
Maximum number of results to return. Range: 1-100.
</ParamField>
<ParamField header="Authorization" type="string" required>
Bearer token for API authentication. Format: `Bearer YOUR_API_KEY`
</ParamField>
#### Response fields
<ResponseField name="user_id" type="string" required>
Unique identifier assigned to the newly created user.
</ResponseField>
<ResponseField name="created_at" type="timestamp">
ISO 8601 formatted timestamp of when the user was created.
</ResponseField>
<ResponseField name="permissions" type="array">
List of permission strings assigned to this user.
</ResponseField>
#### Expandable nested fields
<ResponseField name="user" type="object">
Complete user object with all associated data.
<Expandable title="User properties">
<ResponseField name="profile" type="object">
User profile information including personal details.
<Expandable title="Profile details">
<ResponseField name="first_name" type="string">
User's first name as entered during registration.
</ResponseField>
<ResponseField name="avatar_url" type="string | null">
URL to user's profile picture. Returns null if no avatar is set.
</ResponseField>
</Expandable>
</ResponseField>
</Expandable>
</ResponseField>
### Interactive components
#### Cards for navigation
<Card title="Getting started guide" icon="rocket" href="/quickstart">
Complete walkthrough from installation to your first API call in under 10 minutes.
</Card>
<CardGroup cols={2}>
<Card title="Authentication" icon="key" href="/auth">
Learn how to authenticate requests using API keys or JWT tokens.
</Card>
<Card title="Rate limiting" icon="clock" href="/rate-limits">
Understand rate limits and best practices for high-volume usage.
</Card>
</CardGroup>
### Media and advanced components
#### Frames for images
Wrap all images in frames.
<Frame>
<img src="/images/dashboard.png" alt="Main dashboard showing analytics overview" />
</Frame>
<Frame caption="The analytics dashboard provides real-time insights">
<img src="/images/analytics.png" alt="Analytics dashboard with charts" />
</Frame>
#### Tooltips and updates
<Tooltip tip="Application Programming Interface - protocols for building software">
API
</Tooltip>
<Update label="Version 2.1.0" description="Released March 15, 2024">
## New features
- Added bulk user import functionality
- Improved error messages with actionable suggestions
## Bug fixes
- Fixed pagination issue with large datasets
- Resolved authentication timeout problems
</Update>
## Required page structure
Every documentation page must begin with YAML frontmatter:
```yaml
---
title: "Clear, specific, keyword-rich title"
description: "Concise description explaining page purpose and value"
---
```
## Content quality standards
### Code examples requirements
- Always include complete, runnable examples that users can copy and execute
- Show proper error handling and edge case management
- Use realistic data instead of placeholder values
- Include expected outputs and results for verification
- Test all code examples thoroughly before publishing
- Specify language and include filename when relevant
- Add explanatory comments for complex logic
### API documentation requirements
- Document all parameters including optional ones with clear descriptions
- Show both success and error response examples with realistic data
- Include rate limiting information with specific limits
- Provide authentication examples showing proper format
- Explain all HTTP status codes and error handling
- Cover complete request/response cycles
### Accessibility requirements
- Include descriptive alt text for all images and diagrams
- Use specific, actionable link text instead of "click here"
- Ensure proper heading hierarchy starting with H2
- Provide keyboard navigation considerations
- Use sufficient color contrast in examples and visuals
- Structure content for easy scanning with headers and lists
## AI assistant instructions
### Component selection logic
- Use **Steps** for procedures, tutorials, setup guides, and sequential instructions
- Use **Tabs** for platform-specific content or alternative approaches
- Use **CodeGroup** when showing the same concept in multiple languages
- Use **Accordions** for supplementary information that might interrupt flow
- Use **Cards and CardGroup** for navigation, feature overviews, and related resources
- Use **RequestExample/ResponseExample** specifically for API endpoint documentation
- Use **ParamField** for API parameters, **ResponseField** for API responses
- Use **Expandable** for nested object properties or hierarchical information
### Quality assurance checklist
- Verify all code examples are syntactically correct and executable
- Test all links to ensure they are functional and lead to relevant content
- Validate Mintlify component syntax with all required properties
- Confirm proper heading hierarchy with H2 for main sections, H3 for subsections
- Ensure content flows logically from basic concepts to advanced topics
- Check for consistency in terminology, formatting, and component usage
### Error prevention strategies
- Always include realistic error handling in code examples
- Provide dedicated troubleshooting sections for complex procedures
- Explain prerequisites clearly before beginning instructions
- Include verification and testing steps with expected outcomes
- Add appropriate warnings for destructive or security-sensitive actions
- Validate all technical information through testing before publication
+118
View File
@@ -0,0 +1,118 @@
---
title: Architecture
sidebarTitle: Architecture
description: How FastMCP apps work under the hood — from Python to pixels.
icon: sitemap
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.2.0" />
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
An MCP app moves through five stages from Python to pixels:
```
Python components → JSON tree → structuredContent → Renderer iframe → Host UI
```
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 sections below walk each stage.
## 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
`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 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 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` 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"]` (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 bare `Component` — becomes a JSON blob the renderer can interpret.
### `PrefabApp.to_json()`
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 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
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 (below).
### 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
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
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 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.
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 still applies. `get_app_tool` bypasses transforms but runs auth checks against the tool's `auth` config before executing.
### Provider delegation
`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 Prefab renderer is a self-contained JavaScript application that interprets the JSON component tree and renders it as a React UI.
### The shared resource
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 the CDN domains the renderer needs. Hosts use this to configure the iframe's Content Security Policy.
### `postMessage` communication
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 (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 → 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 (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
`fastmcp dev apps` simulates the host-side behavior locally without a real MCP client.
### Proxy architecture
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 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
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 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.
+23
View File
@@ -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,
)
+78
View File
@@ -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")
+68
View File
@@ -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,
)
+24
View File
@@ -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,
)
+461
View File
@@ -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 34: 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,
)
+21
View File
@@ -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,
)
+66
View File
@@ -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",
)
+116
View File
@@ -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"))
+39
View File
@@ -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,
)
+65
View File
@@ -0,0 +1,65 @@
---
title: Development
sidebarTitle: Development
description: Preview and test your app tools locally without a full MCP host.
icon: flask
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.2.0" />
<Frame>
<img src="/apps/images/dev-app.png" alt="The dev UI showing a rendered Prefab app with the MCP inspector panel" />
</Frame>
`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.
Works with both [Interactive Tools](/apps/prefab) and [custom HTML apps](/apps/low-level).
## Quick start
```bash
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
The dev server does three things:
The **picker page** connects to your MCP server, finds all tools with UI metadata, and renders a form for each one. The forms are auto-generated from the tool's input schema — text fields, dropdowns, checkboxes, all wired up.
When you submit a form, the dev server **calls your tool** via the MCP protocol and opens the result in a new tab. The result page loads the tool's UI resource (the Prefab renderer or your custom HTML) inside an AppBridge — the same protocol that real MCP hosts use.
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
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.
Each entry shows direction, method, timing, and a smart summary. Click any entry to expand the full JSON-RPC body. The panel auto-scrolls to new messages unless you've scrolled up to inspect older ones.
The inspector is useful for debugging: you can see exactly what arguments your tool received, what it returned, and how the AppBridge communicated with the renderer.
## Options
```bash
fastmcp dev apps server.py:mcp --mcp-port 9000 --dev-port 9090 --no-reload
```
| Option | Flag | Default | Description |
| ------ | ---- | ------- | ----------- |
| MCP Port | `--mcp-port` | `8000` | Port for your MCP server |
| 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
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.
```bash
# Server with multiple app tools
fastmcp dev apps examples/apps/contacts/contacts_server.py
```
+92
View File
@@ -0,0 +1,92 @@
---
title: Examples
sidebarTitle: Examples
description: Example apps you can run right now.
icon: images
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.2.0" />
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.
<Columns cols={2}>
<Tile href="#sales-dashboard" title="Sales Dashboard" description="Metrics, charts, and deal pipeline">
<div style={{overflow: "hidden", width: "100%"}}>
<img src="/apps/images/app-example-sales-dashboard.png" />
</div>
</Tile>
<Tile href="#system-monitor" title="System Monitor" description="Live CPU, memory, disk with auto-refresh">
<img src="/apps/images/app-example-system-dashboard.png" />
</Tile>
<Tile href="#quiz" title="Quiz" description="LLM-generated trivia with scoring">
<img src="/apps/images/app-example-quiz.png" />
</Tile>
<Tile href="#interactive-map" title="Interactive Map" description="Geocoded addresses on Leaflet">
<img src="/apps/images/app-example-map.png" />
</Tile>
<Tile href="/apps/providers/file-upload" title="File Upload" description="Drag-and-drop upload provider">
<img src="/apps/images/app-file-upload.png" />
</Tile>
<Tile href="/apps/providers/approval" title="Approval" description="Human-in-the-loop confirmation">
<img src="/apps/images/app-approval.png" />
</Tile>
<Tile href="/apps/providers/choice" title="Choice" description="Clickable option selection">
<img src="/apps/images/app-choice.png" />
</Tile>
<Tile href="/apps/providers/form" title="Form Input" description="Pydantic model forms">
<img src="/apps/images/app-form.png" />
</Tile>
<Tile href="/apps/generative" title="Generative UI" description="LLM writes the UI at runtime">
<img src="/apps/images/app-showcase.png" />
</Tile>
</Columns>
## Running the examples
Preview any example in your browser with the dev server:
```bash
pip install "fastmcp[apps]"
fastmcp dev apps examples/apps/sales_dashboard/sales_dashboard_server.py
```
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 apps
### 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.
```bash
fastmcp dev apps examples/apps/sales_dashboard/sales_dashboard_server.py
```
### 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 up to 100 data points over time.
```bash
pip install psutil
fastmcp dev apps examples/apps/system_monitor/system_monitor_server.py
```
### Quiz
The LLM generates trivia questions and passes them to the tool. The user answers via buttons, sees correct/incorrect feedback, and tracks score across questions. Demonstrates multi-turn client-side state with FastMCPApp.
```bash
fastmcp dev apps examples/apps/quiz/quiz_server.py
```
### 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. 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
```
For ready-made building blocks like approvals, choice pickers, file uploads, and Pydantic forms, see the [Providers](/apps/providers/approval) group.
+470
View File
@@ -0,0 +1,470 @@
---
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'
import { PrefabDemoFrame } from '/snippets/prefab-demo-frame.mdx'
<VersionBadge version="3.2.0" />
<PrefabPinWarning />
<PrefabDemoFrame demo="contacts" height="650px" title="Contacts app demo" />
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
+134
View File
@@ -0,0 +1,134 @@
---
title: Generative UI
sidebarTitle: Generative UI
description: Let the LLM build custom Prefab UIs on the fly.
icon: wand-magic-sparkles
tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.2.0" />
<video src="/apps/images/generative-ui.mp4" autoPlay loop muted playsInline style={{width:"100%", borderRadius:"8px", marginBottom:"1rem"}} />
With Generative UI, the LLM writes the UI code at runtime. Instead of calling a pre-built tool with a fixed shape, the model writes Prefab Python tailored to the current data and request. The user watches the UI stream in as the model generates it.
```python
from fastmcp import FastMCP
from fastmcp.apps.generative import GenerativeUI
mcp = FastMCP("Prefab Studio")
mcp.add_provider(GenerativeUI())
```
One provider registers three things:
- **`generate_prefab_ui`** — a tool that accepts Python code, executes it in a Pyodide sandbox, and renders the result as a Prefab app
- **`search_prefab_components`** — a tool the LLM uses to discover what components are available
- **The streaming renderer** — a `ui://` resource with browser-side Pyodide that progressively renders partial code as the LLM generates it
## How it works
When the LLM calls `generate_prefab_ui`, it writes Prefab Python code into the `code` argument. The MCP Apps protocol creates the renderer iframe in parallel with the tool call, so the app is already running by the time partial arguments start flowing.
As the LLM generates each token:
1. The host forwards partial arguments to the app via `ontoolinputpartial`
2. The renderer extracts the growing `code` string
3. Browser-side Pyodide executes whatever compiles successfully
4. The user sees components appear as they're written
When the LLM finishes, the server runs the complete code in a server-side Pyodide sandbox for validation, and the renderer swaps the streaming preview for the final server-validated result.
## What the LLM writes
The tool description includes examples that teach the model the Prefab patterns. A typical generation looks like:
```python
from prefab_ui.components import Column, Row, Heading, Text, Badge, Card, CardContent
from prefab_ui.components.charts import BarChart, ChartSeries
from prefab_ui.app import PrefabApp
with PrefabApp() as app:
with Column(gap=6, css_class="p-6"):
Heading("Q3 Revenue Report")
BarChart(
data=[
{"month": "Jul", "revenue": 42000},
{"month": "Aug", "revenue": 51000},
{"month": "Sep", "revenue": 63000},
],
series=[ChartSeries(data_key="revenue", label="Revenue")],
x_axis="month",
)
with Row(gap=4):
with Card():
with CardContent():
Text("Total", css_class="text-sm text-muted-foreground")
Heading("$156,000")
with Card():
with CardContent():
Text("Growth", css_class="text-sm text-muted-foreground")
Badge("+18%", variant="success")
```
The model writes real Python — loops, f-strings, computation, helper functions. Prefab gives it charts, tables, forms, cards, badges, and layout primitives to compose.
## The component search tool
Before writing code, the LLM can call `search_prefab_components` to discover what's available:
```
search_prefab_components("Chart")
→ 7 components matching 'Chart':
AreaChart — from prefab_ui.components.charts import AreaChart
BarChart — from prefab_ui.components.charts import BarChart
...
```
Passing `detail=True` returns full field descriptions and docstrings. The search tool introspects Prefab classes at runtime, so it's always up to date with the installed version.
## Passing data
The `generate_prefab_ui` tool accepts a `data` parameter. Values become global variables in the sandbox:
```python
# The LLM can reference 'sales_data' directly in its code
result = await generate_prefab_ui(
code="...",
data={"sales_data": [{"month": "Jan", "revenue": 42000}, ...]}
)
```
This lets the model use data from earlier in the conversation to build visualizations.
## Configuration
`GenerativeUI` takes options for customizing tool names:
```python
GenerativeUI(
tool_name="generate_prefab_ui", # default
components_tool_name="search_prefab_components", # default
include_components_tool=True, # default
)
```
## Requirements
Generative UI needs `fastmcp[apps]`, which pulls in `prefab-ui`. The server-side Pyodide sandbox (for final validation) requires Deno — it installs automatically on first use.
The streaming renderer loads Pyodide from CDN in the browser. The CSP is configured automatically by the provider — no manual setup.
## Sandbox limitations
The Pyodide sandbox includes the Python standard library and Prefab. External packages (NumPy, pandas, requests, etc.) are **not available** — the LLM's code must work with only built-in Python and Prefab. If the LLM imports something unavailable, the sandbox raises `ImportError`.
## Next steps
- **[Interactive Tools](/apps/prefab)** — the component building blocks the LLM will use
- **[Prefab component reference](https://prefab.prefect.io/docs/components)** — full component library
- **[Development](/apps/development)** — preview generative tools locally with `fastmcp dev apps`
Binary file not shown.

After

Width:  |  Height:  |  Size: 571 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 587 KiB

View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 683 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 745 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 580 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 652 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 639 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1001 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 KiB

Binary file not shown.
+304
View File
@@ -0,0 +1,304 @@
---
title: Custom HTML Apps
sidebarTitle: Custom HTML
description: Build apps with your own HTML, CSS, and JavaScript using the MCP Apps extension directly.
icon: code
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.0.0" />
Everything on this page is for when you want full control: your own HTML, your own JavaScript framework, a map library, a 3D viewer, custom video playback. [Interactive Tools](/apps/prefab) wrap the MCP Apps extension so you never have to think about it — this page is what you reach for when you need to think about it.
You'll be working with two things: the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK for host communication, and FastMCP's `AppConfig` for resources and CSP.
## How it works
An MCP App has two parts:
1. A **tool** that does the work and returns data
2. A **`ui://` resource** containing the HTML that renders that data
The tool declares which resource to use via `AppConfig`. When the host calls the tool, it also fetches the linked resource, renders it in a sandboxed iframe, and pushes the tool result into the app via `postMessage`. The app can also call tools back, enabling interactive workflows.
```python
import json
from fastmcp import FastMCP
from fastmcp.apps import AppConfig, ResourceCSP
mcp = FastMCP("My App Server")
# The tool does the work
@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html"))
def generate_chart(data: list[float]) -> str:
return json.dumps({"values": data})
# The resource provides the UI
@mcp.resource("ui://my-app/view.html")
def chart_view() -> str:
return "<html>...</html>"
```
## AppConfig
`AppConfig` controls how a tool or resource participates in the Apps extension. Import it from `fastmcp.apps`:
```python
from fastmcp.apps import AppConfig
```
On **tools**, you'll typically set `resource_uri` to point to the UI resource:
```python
@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html"))
def my_tool() -> str:
return "result"
```
You can also pass a raw dict with camelCase keys, matching the wire format:
```python
@mcp.tool(app={"resourceUri": "ui://my-app/view.html"})
def my_tool() -> str:
return "result"
```
### Tool visibility
The `visibility` field controls where a tool appears:
- `["model"]` — visible to the LLM (the default behavior)
- `["app"]` — only callable from within the app UI, hidden from the LLM
- `["model", "app"]` — both
This is useful when you have tools that only make sense as part of the app's interactive flow, not as standalone LLM actions.
```python
@mcp.tool(
app=AppConfig(
resource_uri="ui://my-app/view.html",
visibility=["app"],
)
)
def refresh_data() -> str:
"""Only callable from the app UI, not by the LLM."""
return fetch_latest()
```
### AppConfig fields
| Field | Type | Description |
|-------|------|-------------|
| `resource_uri` | `str` | URI of the UI resource. Tools only. |
| `visibility` | `list[str]` | Where the tool appears: `"model"`, `"app"`, or both. Tools only. |
| `csp` | `ResourceCSP` | Content Security Policy for the iframe. |
| `permissions` | `ResourcePermissions` | Iframe sandbox permissions. |
| `domain` | `str` | Stable sandbox origin for the iframe. |
| `prefers_border` | `bool` | Whether the UI prefers a visible border. |
<Note>
On **resources**, `resource_uri` and `visibility` must not be set — the resource *is* the UI. Use `AppConfig` on resources only for `csp`, `permissions`, and other display settings.
</Note>
## UI resources
Resources using the `ui://` scheme are automatically served with the MIME type `text/html;profile=mcp-app`. No need to set it manually.
```python
@mcp.resource("ui://my-app/view.html")
def my_view() -> str:
return "<html>...</html>"
```
The HTML can be anything — a full single-page app, a simple display, or a complex interactive tool. The host renders it in a sandboxed iframe and establishes a `postMessage` channel for communication.
### Writing the app HTML
Your HTML app communicates with the host using the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK. The simplest approach is to load it from a CDN:
```html
<script type="module">
import { App } from "https://unpkg.com/@modelcontextprotocol/ext-apps@0.4.0/app-with-deps";
const app = new App({ name: "My App", version: "1.0.0" });
// Receive tool results pushed by the host
app.ontoolresult = ({ content }) => {
const text = content?.find(c => c.type === 'text');
if (text) {
document.getElementById('output').textContent = text.text;
}
};
// Connect to the host
await app.connect();
</script>
```
The `App` object provides:
- **`app.ontoolresult`** — callback that receives tool results pushed by the host
- **`app.callServerTool({name, arguments})`** — call a tool on the server from within the app
- **`app.onhostcontextchanged`** — callback for host context changes (e.g., safe area insets)
- **`app.getHostContext()`** — get current host context
See the full [ext-apps SDK documentation](https://github.com/modelcontextprotocol/ext-apps) for the complete API reference.
<Note>
If your HTML loads external scripts, styles, or makes API calls, you need to declare those domains in the CSP configuration. See [Security](#security) below.
</Note>
## Security
Apps run in sandboxed iframes with a deny-by-default Content Security Policy. By default, only inline scripts and styles are allowed — no external network access.
### Content Security Policy
If your app needs to load external resources (CDN scripts, API calls, embedded iframes), declare the allowed domains with `ResourceCSP`:
```python
from fastmcp.apps import AppConfig, ResourceCSP
@mcp.resource(
"ui://my-app/view.html",
app=AppConfig(
csp=ResourceCSP(
resource_domains=["https://unpkg.com", "https://cdn.example.com"],
connect_domains=["https://api.example.com"],
)
),
)
def my_view() -> str:
return "<html>...</html>"
```
| CSP Field | Controls |
|-----------|----------|
| `connect_domains` | `fetch`, XHR, WebSocket (`connect-src`) |
| `resource_domains` | Scripts, images, styles, fonts (`script-src`, etc.) |
| `frame_domains` | Nested iframes (`frame-src`) |
| `base_uri_domains` | Document base URI (`base-uri`) |
### Permissions
If your app needs browser capabilities like camera or clipboard access, request them via `ResourcePermissions`:
```python
from fastmcp.apps import AppConfig, ResourcePermissions
@mcp.resource(
"ui://my-app/view.html",
app=AppConfig(
permissions=ResourcePermissions(
camera={},
clipboard_write={},
)
),
)
def my_view() -> str:
return "<html>...</html>"
```
Hosts may or may not grant these permissions. Your app should use JavaScript feature detection as a fallback.
## Example: a QR code server
This example creates a tool that generates QR codes and an app that renders them as images. It's based on the [official MCP Apps example](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/qr-server). Requires the `qrcode[pil]` package.
```python expandable
import base64
import io
import qrcode
from fastmcp import FastMCP
from fastmcp.apps import AppConfig, ResourceCSP
from fastmcp.tools import ToolResult
from fastmcp.types import ImageContent
mcp = FastMCP("QR Code Server")
VIEW_URI = "ui://qr-server/view.html"
@mcp.tool(app=AppConfig(resource_uri=VIEW_URI))
def generate_qr(text: str = "https://gofastmcp.com") -> ToolResult:
"""Generate a QR code from text."""
qr = qrcode.QRCode(version=1, box_size=10, border=4)
qr.add_data(text)
qr.make(fit=True)
img = qr.make_image()
buffer = io.BytesIO()
img.save(buffer, format="PNG")
b64 = base64.b64encode(buffer.getvalue()).decode()
return ToolResult(
content=[ImageContent(type="image", data=b64, mime_type="image/png")]
)
@mcp.resource(
VIEW_URI,
app=AppConfig(csp=ResourceCSP(resource_domains=["https://unpkg.com"])),
)
def view() -> str:
"""Interactive QR code viewer."""
return """\
<!DOCTYPE html>
<html>
<head>
<meta name="color-scheme" content="light dark">
<style>
body { display: flex; justify-content: center;
align-items: center; height: 340px; width: 340px;
margin: 0; background: transparent; }
img { width: 300px; height: 300px; border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
</style>
</head>
<body>
<div id="qr"></div>
<script type="module">
import { App } from
"https://unpkg.com/@modelcontextprotocol/ext-apps@0.4.0/app-with-deps";
const app = new App({ name: "QR View", version: "1.0.0" });
app.ontoolresult = ({ content }) => {
const img = content?.find(c => c.type === 'image');
if (img) {
const el = document.createElement('img');
el.src = `data:${img.mimeType};base64,${img.data}`;
el.alt = "QR Code";
document.getElementById('qr').replaceChildren(el);
}
};
await app.connect();
</script>
</body>
</html>"""
```
The tool generates a QR code as a base64 PNG. The resource loads the MCP Apps JS SDK from unpkg (declared in the CSP), listens for tool results, and renders the image. The host wires them together — when the LLM calls `generate_qr`, the QR code appears in an interactive frame inside the conversation.
## Checking client support
Not all hosts support the Apps extension. You can check at runtime using the tool's [context](/servers/context):
```python
from fastmcp import Context
from fastmcp.apps import AppConfig, UI_EXTENSION_ID
@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html"))
async def my_tool(ctx: Context) -> str:
if ctx.client_supports_extension(UI_EXTENSION_ID):
# Return data optimized for UI rendering
return rich_response()
else:
# Fall back to plain text
return plain_text_response()
```
+73
View File
@@ -0,0 +1,73 @@
---
title: Apps
sidebarTitle: Overview
description: Give your tools interactive UIs rendered directly in the conversation.
icon: grid-2
mode: center
---
import { VersionBadge } from '/snippets/version-badge.mdx'
import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx'
import { PrefabDemoFrame } from '/snippets/prefab-demo-frame.mdx'
<VersionBadge version="3.0.0" />
A FastMCP app is a tool that returns an interactive UI instead of text. When the host calls it, the user sees a chart, a table, a form, or a whole dashboard rendered right inside the conversation, with working sort, search, tooltips, and state.
<div style={{
margin: '0 clamp(-180px, calc(-18vw + 90px), 0px) 2rem',
maxHeight: '700px',
overflow: 'hidden',
position: 'relative',
maskImage: 'linear-gradient(to bottom, black 75%, transparent)',
WebkitMaskImage: 'linear-gradient(to bottom, black 75%, transparent)',
}}>
<PrefabDemoFrame demo="hitchhikers" height="2000px" title="Prefab showcase demo" />
</div>
The dashboard above is a [Prefab](https://prefab.prefect.io) showcase — a taste of what you can deliver from a FastMCP tool. Every card, chart, slider, dialog, and carousel is a Python component. Build a composition like this, add `@mcp.tool(app=True)`, and the host renders it inside the conversation.
Under the hood, FastMCP builds on the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) and uses Prefab to describe UIs in Python.
```bash
pip install "fastmcp[apps]"
```
<PrefabPinWarning />
## Pick your path
Four patterns cover almost everything you'd want to build. Most apps start with Interactive Tools; you only reach for the others when you've hit a specific limit.
### [Interactive Tools](/apps/prefab) — start here
Add `app=True` to a tool and return a Prefab component. Charts, tables, dashboards, and client-side interactivity (toggles, tabs, filtering) all work without any server round-trips.
```python
@mcp.tool(app=True)
def team_directory() -> DataTable:
return DataTable(columns=[...], rows=employees, search=True)
```
### [FastMCPApp](/apps/fastmcp-app) — when the UI calls back to the server
Forms that save data, buttons that trigger backend work, search that hits a database. `FastMCPApp` manages the wiring between UI actions and backend tools, with stable tool identifiers that survive server composition.
### [Generative UI](/apps/generative) — when the LLM writes the UI
Register one provider and the model can write Prefab code tailored to the current data and request. The user watches the UI build up as the model generates it.
```python
mcp.add_provider(GenerativeUI())
```
### [Custom HTML](/apps/low-level) — when you need full control
Write your own HTML, CSS, and JavaScript. Use a specific framework, drop in a map or 3D viewer, embed video. You're talking to the MCP Apps protocol directly.
## What's next
- **[Quickstart](/apps/quickstart)** — build a working app in a minute
- **[Examples](/apps/examples)** — complete working servers you can run today
- **[Providers](/apps/providers/approval)** — ready-made capabilities (approvals, choice pickers, file upload, forms) you add with one line
- **[Development](/apps/development)** — preview app tools locally with `fastmcp dev apps`
+297
View File
@@ -0,0 +1,297 @@
---
title: Interactive Tools
sidebarTitle: Interactive Tools
description: Turn your tools into interactive UIs with charts, tables, and dashboards.
icon: palette
tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
import PrefabPinWarning from '/snippets/prefab-pin-warning.mdx'
import { PrefabDemoFrame } from '/snippets/prefab-demo-frame.mdx'
<VersionBadge version="3.1.0" />
<PrefabPinWarning />
<PrefabDemoFrame demo="dashboard" height="680px" title="Sales dashboard demo" />
Believe it or not, that dashboard is a FastMCP tool. The chart has tooltips. The table is sortable. The badges are styled by deal stage. The whole thing is about 40 lines of Python, and the user sees it right inside their conversation instead of a wall of JSON.
The pattern behind every example on this page is the same: add `app=True` to your tool, build a UI with [Prefab](https://prefab.prefect.io) components, and return it as a `PrefabApp`. Prefab has [100+ components](https://prefab.prefect.io/docs/components), from data tables and charts to forms and progress bars. You compose them in Python; the host renders them as a live, interactive application.
## Start with a table
Most tools return data the user wants to explore. A `DataTable` is often the smallest useful upgrade — your data goes from a JSON blob to a searchable, sortable table:
<PrefabDemoFrame demo="data-table" height="530px" title="Data table demo" />
```python
from prefab_ui.components import DataTable, DataTableColumn
from fastmcp import FastMCP
mcp = FastMCP("Directory")
@mcp.tool(app=True)
def team_directory() -> DataTable:
"""Browse the team directory."""
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"},
]
return 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,
)
```
That's it. Add `app=True`, return a Prefab component instead of raw dicts. FastMCP handles the rendering, sandboxing, and security. No wrapper class needed for simple cases like this.
## Add charts
When numbers tell a better story as a visual, swap in a chart. The API is the same: pass your data as a list of dicts, tell the chart which keys to plot.
<PrefabDemoFrame demo="bar-chart" height="430px" title="Bar chart demo" />
```python
@mcp.tool(app=True)
def quarterly_revenue(year: int) -> BarChart:
"""Show quarterly revenue as a bar chart."""
data = [
{"quarter": "Q1", "revenue": 42000, "costs": 28000},
{"quarter": "Q2", "revenue": 51000, "costs": 31000},
{"quarter": "Q3", "revenue": 47000, "costs": 29000},
{"quarter": "Q4", "revenue": 63000, "costs": 35000},
]
return BarChart(
data=data,
series=[
ChartSeries(data_key="revenue", label="Revenue"),
ChartSeries(data_key="costs", label="Costs"),
],
x_axis="quarter",
show_legend=True,
)
```
Each `ChartSeries` plots a different key from the data. `BarChart`, `LineChart`, `AreaChart`, `PieChart`, `RadarChart`, and `RadialChart` all follow the same pattern. Hover over the bars to see tooltips.
<PrefabDemoFrame demo="pie-chart" height="410px" title="Pie chart demo" />
```python
@mcp.tool(app=True)
def ticket_breakdown() -> PieChart:
"""Show open tickets by category."""
data = [
{"category": "Bug", "count": 42},
{"category": "Feature", "count": 28},
{"category": "Docs", "count": 15},
{"category": "Infra", "count": 10},
]
return PieChart(
data=data,
data_key="count",
name_key="category",
inner_radius=50,
show_legend=True,
)
```
See the [Prefab chart docs](https://prefab.prefect.io/docs/components) for stacking, curves, custom colors, and more.
## Compose a dashboard
Tables and charts are useful on their own, but the real power comes from composing them. `Column` stacks children vertically, `Row` lays them out side by side, and `with` blocks establish nesting — the indentation is the layout.
<PrefabDemoFrame demo="dashboard" height="680px" title="Sales dashboard demo" />
```python expandable
@mcp.tool(app=True)
def sales_dashboard() -> PrefabApp:
"""Show sales KPIs, trends, and deals."""
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,
)
Separator()
DataTable(
columns=[
DataTableColumn(key="account", header="Account", sortable=True),
DataTableColumn(key="value", header="Value", sortable=True),
DataTableColumn(key="stage", header="Stage"),
],
rows=rows,
)
return app
```
Notice how `Badge` components can be placed inside table cells — any Prefab component works as a cell value, so you can put progress bars, icons, or buttons in your tables too.
## Make it reactive
Everything above renders once from the data your Python provides. But interactive tools can also respond to user input in real time, without any server round-trips. Prefab's state system lets components read and write client-side values, so the UI updates instantly as the user interacts with it.
<PrefabDemoFrame demo="reactive" height="500px" title="Reactive sales demo" />
Try switching regions in the dropdown, and toggling the switch on and off.
```python expandable
from prefab_ui.rx import Rx
@mcp.tool(app=True)
def regional_sales() -> PrefabApp:
"""Sales by region with a live filter."""
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",
)
with If(Rx("show_target")):
Metric(label="Q1 Target", value="$75,000")
return app
```
The `state` dict on `PrefabApp` declares initial values. The `Select` writes to the `region` key on every change. A `let` binding picks the matching dataset, and the chart re-renders. The `Switch` toggles a `Metric` on and off through `If(Rx("show_target"))`. All of this happens in the browser — no calls back to your server.
`Rx` is a reactive reference: `Rx("region")` compiles to an expression the renderer evaluates live. It supports arithmetic, comparisons, formatting pipes (`.currency()`, `.percent()`), and ternary conditionals (`.then()`). For the full state system, see the [Prefab state docs](https://prefab.prefect.io/docs/concepts/state) and [expression docs](https://prefab.prefect.io/docs/concepts/expressions).
## Content Security Policy
Interactive tools render in a sandboxed iframe with a strict CSP. If your tool loads external resources — embedding iframes, fetching from APIs, loading scripts — add the required domains:
```python
from fastmcp.apps import PrefabAppConfig, ResourceCSP
@mcp.tool(app=PrefabAppConfig(
csp=ResourceCSP(frame_domains=["https://example.com"]),
))
def dashboard_with_embed() -> PrefabApp:
...
```
`PrefabAppConfig()` with no arguments is equivalent to `app=True`.
## Giving the LLM context
By default, the LLM sees `"[Rendered Prefab UI]"` as the tool result. If the model needs to reason about the data, return a `ToolResult` with a text summary alongside the UI:
```python
from fastmcp.tools import ToolResult
@mcp.tool(app=True)
def sales_overview(year: int) -> ToolResult:
"""Show sales visually, summarize for the model."""
data = get_sales_data(year)
total = sum(row["revenue"] for row in data)
with Column(gap=4, css_class="p-6") as view:
BarChart(data=data, series=[ChartSeries(data_key="revenue")])
return ToolResult(
content=f"Total revenue for {year}: ${total:,} across {len(data)} quarters",
structured_content=view,
)
```
The user sees the chart. The model sees the summary.
## Next steps
- **[FastMCPApp](/apps/fastmcp-app)** — when your UI needs to call backend tools (forms, search, CRUD)
- **[Generative UI](/apps/generative)** — let the LLM design the UI at runtime
- **[Custom HTML](/apps/low-level)** — when Prefab isn't enough (maps, 3D, your own framework)
- **[Examples](/apps/examples)** — complete working servers you can run today
- **[Development](/apps/development)** — preview your tools locally with `fastmcp dev apps`
- **[Prefab UI](https://prefab.prefect.io)** — full component reference with 100+ components, theming, and advanced patterns
+80
View File
@@ -0,0 +1,80 @@
---
title: Approval
sidebarTitle: Approval
description: Human-in-the-loop approval gates for agent actions
icon: shield-check
tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.2.0" />
`Approval` adds a human-in-the-loop confirmation step to any server. The LLM presents what it's about to do, the user approves or rejects via buttons, and the decision flows back into the conversation as a message.
<Frame>
<img src="/apps/images/app-approval.png" alt="The Approval provider shown in Goose, with a payment confirmation card and Approve/Cancel buttons" />
</Frame>
```python
from fastmcp import FastMCP
from fastmcp.apps.approval import Approval
mcp = FastMCP("My Server")
mcp.add_provider(Approval())
```
This registers a single tool:
| Tool | Visibility | Purpose |
|------|-----------|---------|
| `request_approval` | Model | Shows an approval card, sends the user's decision back as a message |
The LLM calls `request_approval` with a summary (and optional details) whenever it's about to take a significant action. The user sees a card with Approve and Reject buttons. Clicking either sends a message back into the conversation via `SendMessage`, which triggers the LLM's next turn.
The message looks like it came from the user:
```
"Deploy v3.2 to production" — I selected: Approve
```
<Note>
Approval is an advisory gate, not an enforcement mechanism. The conversation isn't blocked while the card is open — the user can keep typing, and a determined LLM could proceed without waiting. Think of it as a strong UX signal that encourages confirmation, not a security boundary. For hard enforcement, implement approval logic server-side in your tool implementations.
</Note>
## Configuration
The constructor sets defaults; the LLM can override all of these per-call via tool arguments.
```python
Approval(
name="Approval", # App name
title="Approval Required", # Card heading
approve_text="Approve", # Approve button label
reject_text="Reject", # Reject button label
approve_variant="default", # "default", "destructive", "success", "info"
reject_variant="outline", # same options plus "outline"
)
```
The LLM can customize each invocation:
```python
request_approval(
summary="Delete 47 files from /tmp",
details="This cannot be undone.",
title="Destructive Action",
approve_text="Delete",
approve_variant="destructive",
reject_text="Keep files",
)
```
## How it works
When the user clicks a button, two things happen:
1. `SendMessage` pushes the decision into the conversation as a user message
2. `SetState("decided", True)` replaces the buttons with "Response sent."
The tool description instructs the LLM to stop and wait for the "I selected:" message before proceeding. If approved, it continues. If rejected, it acknowledges and asks how to proceed.
+72
View File
@@ -0,0 +1,72 @@
---
title: Choice
sidebarTitle: Choice
description: Present clickable options instead of free-text responses
icon: list-check
tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.2.0" />
`Choice` lets the LLM present a set of options as clickable buttons instead of asking the user to type a response. The selection flows back into the conversation as a message, giving the LLM clean structured input.
<Frame>
<img src="/apps/images/app-choice.png" alt="The Choice provider shown in Goose, with four lunch options as clickable buttons" />
</Frame>
```python
from fastmcp import FastMCP
from fastmcp.apps.choice import Choice
mcp = FastMCP("My Server")
mcp.add_provider(Choice())
```
This registers a single tool:
| Tool | Visibility | Purpose |
|------|-----------|---------|
| `choose` | Model | Shows a card with clickable options, sends the selection back as a message |
The LLM calls `choose` with a prompt and a list of options. The user sees a card with one button per option. Clicking one sends a message back into the conversation:
```
"Which deployment strategy?" — I selected: Blue-green
```
<Note>
This is an advisory interaction, not an enforcement mechanism. The conversation isn't blocked while the card is open — the user can keep typing, and the LLM could proceed without waiting. The tool description instructs the LLM to stop and wait for the "I selected:" response, but for hard enforcement, implement selection logic server-side.
</Note>
## Configuration
The constructor sets defaults; the LLM can override `title` per-call.
```python
Choice(
name="Choice", # App name
title="Choose an Option", # Default card heading
variant="outline", # Button style for all options
)
```
The LLM provides the options per-call:
```python
choose(
prompt="What should we have for lunch?",
options=["Pizza", "Tacos", "Ramen", "Salad"],
title="The Important Questions",
)
```
## How it works
Each option renders as a full-width button in a vertical stack. When the user clicks one:
1. `SendMessage` pushes the selection into the conversation as a user message
2. `SetState("decided", True)` replaces the buttons with "Response sent."
The tool description instructs the LLM to stop and wait for the "I selected:" message before proceeding with whatever the user chose.
+129
View File
@@ -0,0 +1,129 @@
---
title: File Upload
sidebarTitle: File Upload
description: Drag-and-drop file upload for any MCP server
icon: upload
tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.2.0" />
`FileUpload` adds drag-and-drop file upload to any server. Users upload files through an interactive UI, bypassing the LLM context window entirely. The LLM can then list and read uploaded files through model-visible tools.
<Frame>
<img src="/apps/images/app-file-upload.png" alt="The FileUpload provider shown in Goose, with a drag-and-drop zone for uploading files" />
</Frame>
```python
from fastmcp import FastMCP
from fastmcp.apps.file_upload import FileUpload
mcp = FastMCP("My Server")
mcp.add_provider(FileUpload())
```
This registers four tools:
| Tool | Visibility | Purpose |
|------|-----------|---------|
| `file_manager` | Model | Opens the drag-and-drop upload UI |
| `store_files` | App only | Called by the UI when the user clicks Upload |
| `list_files` | Model | Returns metadata for all uploaded files |
| `read_file` | Model | Returns a file's contents by name |
The LLM sees `file_manager`, `list_files`, and `read_file`. It calls `file_manager` to show the upload interface, then uses `list_files` and `read_file` to work with whatever the user uploaded. `store_files` is app-only — the UI calls it directly and the LLM never needs to know about it.
## Configuration
```python
FileUpload(
name="Files", # App name (used in tool routing)
max_file_size=10 * 1024 * 1024, # 10 MB default, enforced server-side
title="File Upload", # Heading shown in the UI
description="Drop files to...", # Description text below the heading
drop_label="Drop files here", # Label inside the drop zone
)
```
The `max_file_size` limit is enforced both in the UI (the DropZone rejects oversized files) and on the server (the `store_files` tool validates before calling `on_store`).
## Storage scoping
By default, files are stored in memory and scoped by MCP session ID. Each session gets its own isolated file store — files uploaded in one conversation aren't visible in another.
This works with **stdio**, **SSE**, and **stateful HTTP** transports, where sessions persist across requests.
<Warning>
In **stateless HTTP** mode, each request creates a new session object with a new ID. Files stored during one request (e.g. the UI upload) will be invisible to the next request (e.g. the LLM calling `list_files`). You **must** override `_get_scope_key` to use a stable identifier like a user ID from your auth token.
</Warning>
For stateless deployments, override `_get_scope_key` to return a stable identifier. For example, to scope files by authenticated user:
```python
from fastmcp.apps.file_upload import FileUpload
class UserScopedUpload(FileUpload):
def _get_scope_key(self, ctx):
return ctx.access_token["sub"]
```
For process-wide shared storage (all users see all files):
```python
class SharedUpload(FileUpload):
def _get_scope_key(self, ctx):
return "__shared__"
```
## Custom storage
The default implementation stores files in memory for the lifetime of the server process. For persistent storage, subclass `FileUpload` and override three methods. Each receives the current `Context`, giving you access to session IDs, auth tokens, and request metadata for partitioning and authorization.
```python
import base64
from fastmcp.apps.file_upload import FileUpload
class S3Upload(FileUpload):
def on_store(self, files, ctx):
user_id = ctx.access_token["sub"]
for f in files:
s3.put_object(
Bucket="uploads",
Key=f"{user_id}/{f['name']}",
Body=base64.b64decode(f["data"]),
)
return self.on_list(ctx)
def on_list(self, ctx):
user_id = ctx.access_token["sub"]
objects = s3.list_objects(Bucket="uploads", Prefix=f"{user_id}/")
return [
{
"name": obj["Key"].split("/", 1)[1],
"type": "application/octet-stream",
"size": obj["Size"],
"size_display": f"{obj['Size']} B",
"uploaded_at": obj["LastModified"].isoformat(),
}
for obj in objects.get("Contents", [])
]
def on_read(self, name, ctx):
user_id = ctx.access_token["sub"]
obj = s3.get_object(Bucket="uploads", Key=f"{user_id}/{name}")
content = obj["Body"].read()
return {
"name": name,
"size": obj["ContentLength"],
"type": obj["ContentType"],
"uploaded_at": obj["LastModified"].isoformat(),
"content": content.decode("utf-8"),
}
```
Each file dict passed to `on_store` contains `name`, `size`, `type`, and `data` (base64-encoded content). The return value from `on_store` and `on_list` should be a list of summary dicts with `name`, `type`, `size`, `size_display`, and `uploaded_at` fields — these populate the file list in the UI.
`on_read` returns a dict with file metadata and either `content` (decoded text) or `content_base64` (a base64 preview for binary files).
+105
View File
@@ -0,0 +1,105 @@
---
title: Form Input
sidebarTitle: Form Input
description: Collect structured data from users via Pydantic models
icon: rectangle-list
tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
<VersionBadge version="3.2.0" />
`FormInput` generates a validated form from a Pydantic model. The user fills it out, and the submission is validated against the model before being returned. Structured elicitation that can't be hallucinated.
<Frame>
<img src="/apps/images/app-form.png" alt="The FormInput provider shown in Goose, with a bug report form" />
</Frame>
```python
from typing import Literal
from pydantic import BaseModel, Field
from fastmcp import FastMCP
from fastmcp.apps.form import FormInput
class BugReport(BaseModel):
title: str = Field(description="Brief summary")
severity: Literal["low", "medium", "high", "critical"]
description: str = Field(
description="Detailed description",
json_schema_extra={"ui": {"type": "textarea"}},
)
mcp = FastMCP("My Server")
mcp.add_provider(FormInput(model=BugReport))
```
This registers two tools:
| Tool | Visibility | Purpose |
|------|-----------|---------|
| `collect_bugreport` | Model | Opens the form UI |
| `submit_form` | App only | Validates and processes the submission |
The tool name is derived from the model class name, lowercased: `collect_{modelname}`. So `BugReport` becomes `collect_bugreport`, `ShippingAddress` becomes `collect_shippingaddress`. Use `tool_name` to override if needed. The LLM calls it with a prompt explaining what it needs, and the user gets a form with fields matching the model.
## Field mapping
`FormInput` uses Prefab's `Form.from_model()`, which maps Pydantic types to form components:
| Python type | Form component |
|------------|---------------|
| `str` | Text input |
| `int`, `float` | Number input |
| `bool` | Checkbox |
| `datetime.date` | Date picker |
| `Literal[...]` | Select dropdown |
| `SecretStr` | Password input |
Use `Field()` metadata to control labels (`title`), placeholders (`description`), and validation (`min_length`, `max_length`, `ge`, `le`). Use `json_schema_extra={"ui": {"type": "textarea"}}` for multiline text.
## Callback
By default, the validated model is returned as JSON. Provide an `on_submit` callback to process the data server-side:
```python
def save_report(report: BugReport) -> str:
db.insert(report.model_dump())
return f"Bug #{db.last_id} filed: {report.title}"
mcp.add_provider(FormInput(model=BugReport, on_submit=save_report))
```
The callback receives a validated model instance and returns a string that becomes the tool result.
## Configuration
```python
FormInput(
model=BugReport, # Required: the Pydantic model
name="BugTracker", # App name (default: model name)
title="File a Bug", # Card heading (default: model name)
tool_name="file_bug", # Tool name (default: collect_{model})
submit_text="Submit Report", # Button label (default: "Submit")
on_submit=save_report, # Optional callback
send_message=True, # Push result as a chat message
)
```
Set `send_message=True` to push the result back into the conversation via `SendMessage`, triggering the LLM's next turn. Without it, the result is just the tool return value.
## Multiple forms
Add multiple providers for different models — each gets its own tool:
```python
mcp = FastMCP(
"My Server",
providers=[
FormInput(model=ShippingAddress),
FormInput(model=BugReport),
FormInput(model=ContactInfo),
],
)
```
+197
View File
@@ -0,0 +1,197 @@
---
title: Quickstart
sidebarTitle: Quickstart
description: Build your first FastMCP app in under a minute.
icon: rocket
tag: NEW
---
import { VersionBadge } from '/snippets/version-badge.mdx'
import { PrefabDemoFrame } from '/snippets/prefab-demo-frame.mdx'
<VersionBadge version="3.2.0" />
By the end of this page, you'll have a working tool that returns this:
<PrefabDemoFrame demo="team-directory" height="545px" title="Team directory demo" />
A pie chart the user can hover, a table they can sort and search — and a single Python tool.
## Install
```bash
pip install "fastmcp[apps]"
```
The `apps` extra pulls in [Prefab](https://prefab.prefect.io), the Python component library used to build app UIs.
## Write the tool
Create `server.py`. The interesting parts: `app=True` tells FastMCP this tool renders a UI, and `with PrefabApp() as app:` is the canonical pattern for composing one.
```python server.py expandable
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
from fastmcp import FastMCP
mcp = FastMCP("My First App")
@mcp.tool(app=True)
def team_directory() -> PrefabApp:
"""Browse the team directory."""
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,
)
return app
```
The Prefab code reads top-to-bottom. `PrefabApp()` is the root; everything inside its `with` block becomes the UI. `Column` stacks children vertically, `Grid` lays them out in columns. `DataTable` takes rows and column definitions and gives you sort and search for free.
`app=True` does the rest: it sets up the renderer resource, the content security policy, and the metadata that tells the host "this tool returns a UI." The host loads the result in a sandboxed iframe where the user can interact with it — all client-side, no round-trips.
## Preview it
FastMCP ships a dev server that renders your app tools in a browser, no MCP host needed:
```bash
fastmcp dev apps server.py
```
Open `http://localhost:8080`, pick `team_directory`, and try sorting columns and searching.
<Frame>
<img src="/apps/images/app-quickstart-dev-2.png" alt="The team directory rendered in the fastmcp dev apps preview, showing a pie chart, searchable table, and a detail card after clicking a row" />
</Frame>
## Make it reactive
The UI above renders once from your Python. Prefab apps can also respond to user input live, without any server round-trips. The key concept is **state**: a client-side key-value store that components read from and write to.
Click a row in the demo below to see a detail card appear:
<PrefabDemoFrame demo="team-directory-reactive" height="675px" title="Reactive team directory demo" />
Add a few imports, give each member a couple more fields, wire up a click handler, and render a detail card when something's selected:
```python expandable server.py
from collections import Counter
from prefab_ui.actions import SetState
from prefab_ui.app import PrefabApp
from prefab_ui.components import (
Badge, Card, CardContent, CardHeader, Column, DataTable, DataTableColumn,
Grid, H3, Row, Small, Text,
)
from prefab_ui.components.charts import PieChart
from prefab_ui.components.control_flow import If
from prefab_ui.rx import Rx, STATE
from fastmcp import FastMCP
mcp = FastMCP("My First App")
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},
# ... more members ...
]
OFFICE_COUNTS = [
{"office": o, "count": c}
for o, c in Counter(m["office"] for m in MEMBERS).items()
]
@mcp.tool(app=True)
def team_directory() -> PrefabApp:
"""Browse the team directory."""
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"))
return app
```
Three new ideas do all the work:
- **`on_row_click=SetState("selected", Rx("$event"))`** — clicking a row writes its data into the `selected` state key. `$event` is the clicked row dict.
- **`Rx("selected.name")`** — a reactive reference. It doesn't hold a Python value; it compiles to a browser-side expression that re-evaluates whenever `selected` changes, so `Text(Rx("selected.name"))` always shows the latest clicked name.
- **`If(STATE.selected)`** — conditionally renders its body. Before any click, `selected` is `None` and the card stays hidden.
The `state={"selected": None}` dict on `PrefabApp` sets the initial value. Everything else happens in the browser — no round-trips to your server when the user clicks.
## Where to go next
You've built a tool that returns an interactive, reactive UI. This pattern covers a huge range of use cases: build a visualization, return it, and the user gets it rendered right in the conversation.
- **[Interactive Tools](/apps/prefab)** — charts, tables, dashboards, reactive state, with live demos
- **[FastMCPApp](/apps/fastmcp-app)** — when the UI needs to call back to your server (forms, search, CRUD)
- **[Examples](/apps/examples)** — complete working servers you can run today
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 806 KiB

Some files were not shown because too many files have changed in this diff Show More