Publish FastMCP 4 (alpha) docs to gofastmcp.com (#4624)

This commit is contained in:
Jeremiah Lowin 2026-07-23 21:21:01 -04:00 committed by GitHub
commit 8cf4506aa9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
846 changed files with 86684 additions and 22013 deletions

View file

@ -100,10 +100,10 @@ jobs:
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
--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",
"model": "claude-sonnet-5",
"env": {
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
}

View file

@ -49,13 +49,16 @@ jobs:
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).
IMPORTANT: Your primary action should be to apply labels using the locked-down helper `.github/scripts/triage-label.sh`. 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.
- Apply labels ONLY through the helper, which adds or removes repository labels on THIS issue/PR. It already knows the target repo and number (from the workflow environment) — you never pass them:
add: `bash .github/scripts/triage-label.sh add "label1" "label2"`
remove: `bash .github/scripts/triage-label.sh remove "label1"`
- The helper uses the additive REST labels endpoint, so it works for both issues and PRs and never clobbers labels applied by other workflows — notably the Require Issue Link workflow's `missing-issue-link` control label, which must survive or an auto-closed PR won't reopen when its author is assigned.
- The helper is your ONLY GitHub write access. Do NOT use raw `gh api`, `gh issue edit`, `gh pr edit`, or any other mutation — they are not available to you.
- Only apply labels that exist in the repository (from `gh label list` in step 1). Never invent labels.
- Use `remove` only to correct a label you believe is wrong, and never remove the control labels `missing-issue-link`, `bypass-issue-check`, or `trusted-contributor`.
Issue/PR Information:
- REPO: ${{ github.repository }}
@ -131,7 +134,7 @@ jobs:
- 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
Add them with `bash .github/scripts/triage-label.sh add "label1" "label2"`.
DO NOT post any comments unless applying too-long (see above)
PROMPT_END
EOF
@ -140,6 +143,7 @@ jobs:
run: rm -rf ~/.claude/.locks ~/.local/state/claude/locks || true
- name: Run Marvin for Issue Triage
id: marvin
uses: anthropics/claude-code-action@v1
with:
github_token: ${{ steps.marvin-token.outputs.token }}
@ -149,11 +153,82 @@ jobs:
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
--allowedTools "Bash(gh label list:*)","Bash(bash .github/scripts/triage-label.sh:*)",mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__add_issue_comment,mcp__github__get_pull_request,mcp__github__get_pull_request_files
settings: |
{
"model": "claude-sonnet-4-6",
"model": "claude-sonnet-5",
"env": {
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}"
"GH_TOKEN": "${{ steps.marvin-token.outputs.token }}",
"TRIAGE_REPO": "${{ github.repository }}",
"TRIAGE_NUMBER": "${{ github.event.issue.number || github.event.pull_request.number || inputs.issue_number }}"
}
}
# Triage is fire-and-forget: nobody watches a green run, so a broken
# allowlist has to fail the job or it goes unnoticed indefinitely — a
# mangled pattern silently produced zero labels across a dozen PRs
# because the run still reported success.
#
# Only denials of commands we MEANT to grant indicate that breakage. An
# agent reaching for something never on the allowlist (falling back to
# `gh issue view` when the API is down, say) is behaving normally, and
# failing on that would cry wolf during every GitHub incident.
- name: Fail if an allowlisted tool was denied
if: always() && steps.marvin.conclusion != 'skipped'
env:
EXECUTION_FILE: ${{ steps.marvin.outputs.execution_file }}
run: |
file="${EXECUTION_FILE:-}"
if [[ -z "$file" || ! -s "$file" ]]; then
file="${RUNNER_TEMP}/claude-execution-output.json"
fi
# A missing or empty log means we cannot tell a clean run from a
# blocked one, which is the exact failure this step exists to catch.
if [[ ! -s "$file" ]]; then
echo "::error::No Marvin execution log found; cannot verify tool permissions."
exit 1
fi
# The persisted log carries a `permission_denials` array on each
# `type: result` entry; the `permission_denials_count` scalar only
# appears in the action's condensed stdout summary, never on disk.
# Anchor to result entries rather than recursing with `..`, which
# descends into each denial's `tool_input` and double-counts any
# denied command that happens to mention the field name.
if ! summary=$(jq -sr '
[ .[] | if type == "array" then .[] else . end ]
| map(select(type == "object" and .type == "result"))
| map(.permission_denials // []) | flatten
| map(.tool_input.command // "")
| { total: length,
granted: map(select(
startswith("gh label list")
or startswith("bash .github/scripts/triage-label.sh")
))
}
| "\(.total)\t\(.granted | length)\t\(.granted | join(" | "))"
' "$file"); then
echo "::error::Could not parse Marvin execution log ($file)."
exit 1
fi
IFS=$'\t' read -r total granted commands <<<"$summary"
echo "Denied tool calls: $total (of which allowlisted: $granted)"
if [[ "$granted" -gt 0 ]]; then
echo "::error::Marvin was denied $granted call(s) to tools this workflow grants, so it could not apply labels: ${commands}. The --allowedTools value is not reaching the permission matcher intact — claude_args is lexed with shell-quote, so any Bash(...) pattern containing a space must be quoted or it is split into fragments."
exit 1
fi
if [[ "$total" -gt 0 ]]; then
echo "::notice::Marvin was denied $total call(s), none of them to tools this workflow grants. That is expected when it probes for a tool we deliberately withhold; the allowlist is intact."
fi
- name: Upload Marvin execution log
if: always() && steps.marvin.conclusion != 'skipped'
uses: actions/upload-artifact@v4
with:
name: marvin-triage-execution-log
path: |
${{ steps.marvin.outputs.execution_file }}
${{ runner.temp }}/claude-execution-output.json
if-no-files-found: ignore
retention-days: 14

View file

@ -11,7 +11,7 @@ concurrency:
cancel-in-progress: true
jobs:
martian-test-failure:
marvin-test-failure:
# Only run if the test workflow failed
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
runs-on: ubuntu-latest
@ -193,5 +193,5 @@ jobs:
prompt: ${{ steps.analysis-prompt.outputs.PROMPT }}
claude_args: |
--allowed-tools mcp__repository-summary,mcp__code-search,mcp__github-research,WebSearch,WebFetch,Bash(make:*,git:*)
--allowed-tools mcp__repository-summary,mcp__code-search,mcp__github-research,WebSearch,WebFetch,"Bash(make:*)","Bash(git:*)"
--mcp-config /tmp/mcp-config/mcp-servers.json

View file

@ -0,0 +1,87 @@
name: Publish fastmcp-tasks 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-tasks 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-tasks
run: uv build --package fastmcp-tasks
- 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_tasks-*.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-tasks." >&2
exit 1
- name: Publish fastmcp-tasks to PyPI
run: uv publish -v dist/fastmcp_tasks-*.tar.gz dist/fastmcp_tasks-*.whl

View file

@ -115,6 +115,58 @@ jobs:
echo "fastmcp-slim ${SLIM_VERSION} is not available on PyPI; refusing to publish fastmcp." >&2
exit 1
- name: Verify matching fastmcp-tasks is published
run: |
TASKS_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()
)
# fastmcp-tasks is pinned via the optional `tasks` extra, so its
# Requires-Dist entry carries an `extra == "tasks"` marker — unlike the
# base slim dependency, do not skip marked entries here.
for value in metadata.get_all("Requires-Dist", []):
requirement, _, _marker = value.partition(";")
match = re.fullmatch(r"fastmcp-tasks==([^;\s]+)", requirement.strip())
if match:
print(match.group(1))
break
else:
raise RuntimeError("Could not find the fastmcp-tasks extra dependency")
PY
)
for attempt in {1..12}; do
if python - "$TASKS_VERSION" <<'PY'
import json
import sys
import urllib.request
version = sys.argv[1]
url = f"https://pypi.org/pypi/fastmcp-tasks/{version}/json"
with urllib.request.urlopen(url, timeout=30) as response:
json.load(response)
PY
then
exit 0
fi
echo "fastmcp-tasks ${TASKS_VERSION} is not available on PyPI yet; retrying (${attempt}/12)."
sleep 10
done
echo "fastmcp-tasks ${TASKS_VERSION} is not available on PyPI; refusing to publish fastmcp (the [tasks] extra would be uninstallable)." >&2
exit 1
- name: Publish fastmcp to PyPI
run: uv publish -v dist/fastmcp-*.tar.gz dist/fastmcp-*.whl
@ -133,5 +185,19 @@ jobs:
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"

View file

@ -1,5 +1,8 @@
# 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.
# (e.g. "Fixes #123") AND have the PR author assigned to that issue —
# unless the referenced issue is labeled "prs welcome", which waives the
# assignment requirement for everyone (the link itself is still required,
# since that's how the check finds the issue to read the label from).
# 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.
@ -96,6 +99,8 @@ jobs:
const enforce = process.env.ENFORCE_ISSUE_LINK === 'true';
const LABEL = 'missing-issue-link';
const MARKER = '<!-- require-issue-link -->';
// Issue-level label that waives the assignment requirement.
const OPEN_LABEL = 'prs welcome';
// Dry-run guard: every mutating call goes through this so that
// ENFORCE_ISSUE_LINK=false means strictly read-only.
@ -300,6 +305,13 @@ jobs:
// CONTRIBUTING.md requires external contributors to be assigned
// before opening a PR (so maintainers can deconflict / steer
// approach first).
//
// Exception: an issue labeled OPEN_LABEL waives that requirement
// for everyone. It's how maintainers advertise "the reporter
// isn't implementing this, we'd take a PR from anyone" without
// having to assign a specific person up front. Unlike the
// PR-level `trusted-contributor` / `bypass-issue-check` escapes,
// this one lives on the *issue* and is set ahead of time.
const MAX_ISSUES = 5;
const allNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))];
const numbers = allNumbers.slice(0, MAX_ISSUES);
@ -326,6 +338,19 @@ jobs:
throw new Error(`Cannot fetch issue #${num} (HTTP ${e.status ?? 'unknown'}): ${e.message}`);
}
sawRealIssue = true;
// GitHub returns labels as objects here, but the REST schema
// permits bare strings — normalize both rather than assume.
const labelNames = (issue.labels || [])
.map(l => (typeof l === 'string' ? l : l && l.name))
.filter(Boolean)
.map(n => n.toLowerCase());
if (labelNames.includes(OPEN_LABEL)) {
console.log(`#${num} is labeled "${OPEN_LABEL}" — assignment not required`);
assignedToAny = true;
break;
}
const assignees = (issue.assignees || []).map(a => a.login.toLowerCase());
if (assignees.includes(prAuthor)) {
console.log(`PR author ${pr.user.login} is assigned to #${num}`);
@ -354,29 +379,30 @@ jobs:
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 reason = kind === 'no-link'
? "it doesn't reference a tracked issue assigned to you"
: "you aren't 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. Find or [open an issue](https://github.com/${owner}/${repo}/issues/new/choose) describing the change — if you open it, you have first claim on it.`,
"2. Add `Fixes #<issue>`, `Closes #<issue>`, or `Resolves #<issue>` to **this** PR's description — edit it in place, don't open a new PR.",
]
: [
'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.',
"1. If you opened the linked issue, a maintainer will assign you when they pick it up and this PR reopens automatically. If someone else opened it, the PR reopens only if a maintainer chooses to assign it to you — please don't comment to ask.",
];
const commentBody = [
MARKER,
intro,
"**Don't open a new pull request — this one reopens on its own.** It's closed for " +
`now because ${reason}, but the moment that's fixed it reopens automatically. Keep this ` +
'PR and edit it; opening a fresh duplicate just starts you over and creates more to triage.',
'',
`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:`,
`Per [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md), an external PR must reference an issue that's assigned to its author. To get there:`,
'',
...steps,
'',
"Once you're assigned and the link is present, this PR reopens automatically — no further action needed.",
'',
`*Maintainers: reopen this PR or remove the \`${LABEL}\` label to bypass this check.*`,
].join('\n');

View file

@ -10,6 +10,7 @@ on:
- "fastmcp_slim/**"
- "fastmcp_remote/**"
- "tests/**"
- "examples/**"
- "pyproject.toml"
- "uv.lock"
- ".github/workflows/**"

View file

@ -48,7 +48,7 @@ jobs:
- name: Run unit tests
uses: ./.github/actions/run-pytest
- name: Run client process tests
- name: Run serial subprocess tests
uses: ./.github/actions/run-pytest
with:
test-type: client_process
@ -69,7 +69,7 @@ jobs:
- name: Run unit tests
uses: ./.github/actions/run-pytest
- name: Run client process tests
- name: Run serial subprocess tests
uses: ./.github/actions/run-pytest
with:
test-type: client_process
@ -88,7 +88,7 @@ jobs:
resolution: locked
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v7
with:
node-version: "22"
@ -222,7 +222,7 @@ jobs:
run: |
uv venv /tmp/fastmcp-full-smoke
FULL_WHEEL=$(ls /tmp/fastmcp-dist/fastmcp-*.whl)
uv pip install --python /tmp/fastmcp-full-smoke/bin/python --find-links /tmp/fastmcp-dist "$FULL_WHEEL"
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
@ -250,7 +250,7 @@ jobs:
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 --find-links /tmp/fastmcp-dist "$REMOTE_WHEEL"
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

View file

@ -67,7 +67,7 @@ jobs:
- name: Run unit tests
uses: ./.github/actions/run-pytest
- name: Run client process tests
- name: Run serial subprocess tests
uses: ./.github/actions/run-pytest
with:
test-type: client_process