Skip to content

Commit e6b1f64

Browse files
committed
Address review findings on the pre-release machinery
- comment-on-release: pick the previous release by smallest ahead_by across all same-major releases instead of trusting list order (releases published from drafts keep their draft creation date, so list order can hide the true predecessor); check the major line before comparing so cross-line candidates cost no API calls; skip only candidates whose tag no longer resolves and fail loudly on other compare errors; skip drafts; paginate release and comment listings; cap the commit range at 250 and skip commenting beyond it; derive pre-release wording from the tag as well as the release flag - RELEASE.md: stable releases must target the v1.x branch (the UI defaults to main, which is the v2 rework); the --target commit must contain the release tooling and is ignored if the tag already exists; release notes need absolute links; yanked versions should point at their replacement; bump the Development Status classifier when the line changes stage - README.v2.md: pin install and quickstart commands while v2 is in pre-release; point the docs badge and API reference at the v2 docs site; fix examples against the v2 API (Icon mime_type/sizes, snake_case CallToolResult fields via a re-synced snippet, keyword-only max_tokens, elicitation result shape, request context fields) - snippets: fix the resource-read isinstance in the stdio client example (TextResourceContents, not TextContent) - pyproject: drop the leftover "git" keyword
1 parent 29944e1 commit e6b1f64

5 files changed

Lines changed: 108 additions & 57 deletions

File tree

.github/workflows/comment-on-release.yml

Lines changed: 58 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -27,33 +27,42 @@ jobs:
2727
script: |
2828
const currentTag = process.env.CURRENT_TAG;
2929
30-
// Get all releases
31-
const { data: releases } = await github.rest.repos.listReleases({
30+
// Paginate: with two release lines publishing interleaved, the
31+
// previous release on this line can sit far down the list.
32+
const releases = await github.paginate(github.rest.repos.listReleases, {
3233
owner: context.repo.owner,
3334
repo: context.repo.repo,
3435
per_page: 100
3536
});
3637
37-
// Find current release index
38-
const currentIndex = releases.findIndex(r => r.tag_name === currentTag);
39-
40-
if (currentIndex === -1) {
38+
if (!releases.some(r => r.tag_name === currentTag)) {
4139
console.log('Current release not found in list');
4240
return null;
4341
}
4442
4543
const major = tag => (tag.match(/^v?(\d+)/) || [])[1];
4644
47-
// Releases are sorted by date, but with multiple release lines
48-
// (v1.x and the v2 pre-release series) the most recent release may
49-
// be on a different branch. Walk older releases until we find one
50-
// whose tag is an ancestor of the current tag AND on the same
51-
// major line, so the compare never spans branches. For the first
52-
// release of a new major line no such release exists, and we skip
53-
// commenting rather than compare across the entire new line's
54-
// history. per_page=1 because only comparison.status is needed
55-
// here; the commits are fetched in the next step.
56-
for (const candidate of releases.slice(currentIndex + 1)) {
45+
if (major(currentTag) === undefined) {
46+
console.log(`Cannot parse a major version from ${currentTag}; skipping comments`);
47+
return null;
48+
}
49+
50+
// The list is ordered by release creation date, which does not
51+
// reliably reflect tag topology (for example, a release published
52+
// from a long-lived draft keeps its draft creation date). Instead
53+
// of trusting list order, compare every same-major release and
54+
// pick the nearest ancestor of the current tag: the one the
55+
// smallest number of commits behind it. The major check runs
56+
// first so cross-line candidates cost no API calls; per_page=1
57+
// because only status/ahead_by are needed here (the commits are
58+
// fetched in the next step). For the first release of a new major
59+
// line there is no same-line predecessor, and we skip commenting
60+
// rather than compare across the entire new line's history.
61+
let best = null;
62+
for (const candidate of releases) {
63+
if (candidate.tag_name === currentTag || candidate.draft) continue;
64+
if (major(candidate.tag_name) !== major(currentTag)) continue;
65+
5766
let comparison;
5867
try {
5968
({ data: comparison } = await github.rest.repos.compareCommits({
@@ -64,8 +73,14 @@ jobs:
6473
per_page: 1
6574
}));
6675
} catch (error) {
67-
console.log(`Skipping ${candidate.tag_name}: compare failed (${error.message})`);
68-
continue;
76+
// Tolerate only candidates whose tag no longer resolves;
77+
// anything else (rate limits, server errors) must fail the
78+
// job rather than silently produce a wrong comparison base.
79+
if (error.status === 404) {
80+
console.log(`Skipping ${candidate.tag_name}: tag does not resolve`);
81+
continue;
82+
}
83+
throw error;
6984
}
7085
7186
// 'identical' covers a release re-cut on the same commit; it
@@ -75,17 +90,18 @@ jobs:
7590
continue;
7691
}
7792
78-
if (major(candidate.tag_name) === undefined || major(candidate.tag_name) !== major(currentTag)) {
79-
console.log(`Skipping ${candidate.tag_name}: different release line (${major(candidate.tag_name)} vs ${major(currentTag)})`);
80-
continue;
93+
if (best === null || comparison.ahead_by < best.aheadBy) {
94+
best = { tagName: candidate.tag_name, aheadBy: comparison.ahead_by };
8195
}
96+
}
8297
83-
console.log(`Found previous release: ${candidate.tag_name}`);
84-
return candidate.tag_name;
98+
if (best === null) {
99+
console.log(`No previous release found for ${currentTag} on its major line (it may be the first); skipping comments`);
100+
return null;
85101
}
86102
87-
console.log(`No previous release found for ${currentTag} on its major line (it may be the first); skipping comments`);
88-
return null;
103+
console.log(`Found previous release: ${best.tagName} (${best.aheadBy} commits behind ${currentTag})`);
104+
return best.tagName;
89105
90106
- name: Get merged PRs between releases
91107
id: get_prs
@@ -106,7 +122,10 @@ jobs:
106122
console.log(`Finding PRs between ${previousTag} and ${currentTag}`);
107123
108124
// Get commits between previous and current release. A single
109-
// compare response caps the commit list, so paginate.
125+
// compare response caps the commit list, so paginate — but bound
126+
// the total: a range this large means a mis-selected base, and
127+
// commenting on hundreds of PRs is worse than commenting on none.
128+
const MAX_COMMITS = 250;
110129
const commits = [];
111130
for (let page = 1; ; page++) {
112131
const { data: comparison } = await github.rest.repos.compareCommits({
@@ -118,6 +137,10 @@ jobs:
118137
page
119138
});
120139
commits.push(...comparison.commits);
140+
if (commits.length > MAX_COMMITS) {
141+
console.log(`Range ${previousTag}...${currentTag} exceeds ${MAX_COMMITS} commits; skipping comments`);
142+
return [];
143+
}
121144
if (comparison.commits.length < 100) break;
122145
}
123146
console.log(`Found ${commits.length} commits`);
@@ -159,16 +182,22 @@ jobs:
159182
const prNumbers = JSON.parse(process.env.PR_NUMBERS_JSON);
160183
const releaseTag = process.env.RELEASE_TAG;
161184
const releaseUrl = process.env.RELEASE_URL;
162-
const releaseKind = process.env.RELEASE_IS_PRERELEASE === 'true' ? 'pre-release' : 'release';
185+
// Trust the tag as well as the flag, in case the release manager
186+
// forgets to tick the pre-release checkbox.
187+
const isPrerelease = process.env.RELEASE_IS_PRERELEASE === 'true' || /\d(a|b|rc)\d/.test(releaseTag);
188+
const releaseKind = isPrerelease ? 'pre-release' : 'release';
163189
164190
const comment = `This pull request is included in ${releaseKind} [${releaseTag}](${releaseUrl})`;
165191
166192
let commentedCount = 0;
167193
168194
for (const prNumber of prNumbers) {
169195
try {
170-
// Check if we've already commented on this PR for this release
171-
const { data: comments } = await github.rest.issues.listComments({
196+
// Check if we've already commented on this PR for this
197+
// release. Paginate: comments are returned oldest-first, so
198+
// on a busy PR an earlier bot comment is exactly what would
199+
// fall off a single page.
200+
const comments = await github.paginate(github.rest.issues.listComments, {
172201
owner: context.repo.owner,
173202
repo: context.repo.repo,
174203
issue_number: prNumber,

README.v2.md

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
[python-badge]: https://img.shields.io/pypi/pyversions/mcp.svg
8686
[python-url]: https://www.python.org/downloads/
8787
[docs-badge]: https://img.shields.io/badge/docs-python--sdk-blue.svg
88-
[docs-url]: https://modelcontextprotocol.github.io/python-sdk/
88+
[docs-url]: https://py.sdk.modelcontextprotocol.io/v2/
8989
[protocol-badge]: https://img.shields.io/badge/protocol-modelcontextprotocol.io-blue.svg
9090
[protocol-url]: https://modelcontextprotocol.io
9191
[spec-badge]: https://img.shields.io/badge/spec-spec.modelcontextprotocol.io-blue.svg
@@ -116,15 +116,17 @@ If you haven't created a uv-managed project yet, create one:
116116
Then add MCP to your project dependencies:
117117

118118
```bash
119-
uv add "mcp[cli]"
119+
uv add "mcp[cli]==2.0.0a1"
120120
```
121121

122122
Alternatively, for projects using pip for dependencies:
123123

124124
```bash
125-
pip install "mcp[cli]"
125+
pip install "mcp[cli]==2.0.0a1"
126126
```
127127

128+
> While v2 is in pre-release, you must pin the version explicitly: unpinned installs resolve to the latest stable v1.x release, which these docs do not describe. Check the [release history](https://pypi.org/project/mcp/#history) for the newest pre-release. The same applies to ad-hoc commands: use `uv run --with "mcp==2.0.0a1"` rather than `uv run --with mcp`.
129+
128130
### Running the standalone MCP development tools
129131

130132
To run the mcp command with uv:
@@ -189,7 +191,7 @@ _Full example: [examples/snippets/servers/mcpserver_quickstart.py](https://githu
189191
You can install this server in [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp) and interact with it right away. First, run the server:
190192

191193
```bash
192-
uv run --with mcp examples/snippets/servers/mcpserver_quickstart.py
194+
uv run --with "mcp==2.0.0a1" examples/snippets/servers/mcpserver_quickstart.py
193195
```
194196

195197
Then add it to Claude Code:
@@ -605,8 +607,8 @@ from mcp.server.mcpserver import MCPServer, Icon
605607
# Create an icon from a file path or URL
606608
icon = Icon(
607609
src="icon.png",
608-
mimeType="image/png",
609-
sizes="64x64"
610+
mime_type="image/png",
611+
sizes=["64x64"]
610612
)
611613

612614
# Add icons to server
@@ -926,7 +928,8 @@ The `elicit()` method returns an `ElicitationResult` with:
926928

927929
- `action`: "accept", "decline", or "cancel"
928930
- `data`: The validated response (only when accepted)
929-
- `validation_error`: Any validation error message
931+
932+
If the client returns data that doesn't match the schema, `elicit()` raises a `pydantic.ValidationError`.
930933

931934
### Sampling
932935

@@ -1099,7 +1102,7 @@ The session object accessible via `ctx.session` provides advanced control over c
10991102

11001103
- `ctx.session.client_params` - Client initialization parameters and declared capabilities
11011104
- `await ctx.session.send_log_message(level, data, logger)` - Send log messages with full control
1102-
- `await ctx.session.create_message(messages, max_tokens)` - Request LLM sampling/completion
1105+
- `await ctx.session.create_message(messages, max_tokens=...)` - Request LLM sampling/completion (`max_tokens` is keyword-only)
11031106
- `await ctx.session.send_progress_notification(token, progress, total, message)` - Direct progress updates
11041107
- `await ctx.session.send_resource_updated(uri)` - Notify clients that a specific resource changed
11051108
- `await ctx.session.send_resource_list_changed()` - Notify clients that the resource list changed
@@ -1129,9 +1132,9 @@ The request context accessible via `ctx.request_context` contains request-specif
11291132
- Database connections, configuration objects, shared services
11301133
- Type-safe access to resources defined in your server's lifespan function
11311134
- `ctx.request_context.meta` - Request metadata from the client including:
1132-
- `progressToken` - Token for progress notifications
1135+
- `progress_token` - Token for progress notifications
11331136
- Other client-provided metadata
1134-
- `ctx.request_context.request` - The original MCP request object for advanced processing
1137+
- `ctx.request_context.request` - Data the transport attached to this message (for example the HTTP request object on HTTP transports; `None` on stdio)
11351138
- `ctx.request_context.request_id` - Unique identifier for this request
11361139

11371140
```python
@@ -2158,7 +2161,7 @@ async def run():
21582161
# Read a resource (greeting resource from mcpserver_quickstart)
21592162
resource_content = await session.read_resource("greeting://World")
21602163
content_block = resource_content.contents[0]
2161-
if isinstance(content_block, types.TextContent):
2164+
if isinstance(content_block, types.TextResourceContents):
21622165
print(f"Resource content: {content_block.text}")
21632166

21642167
# Call a tool (add tool from mcpserver_quickstart)
@@ -2404,6 +2407,7 @@ For a complete working example, see [`examples/clients/simple-auth-client/`](htt
24042407

24052408
When calling tools through MCP, the `CallToolResult` object contains the tool's response in a structured format. Understanding how to parse this result is essential for properly handling tool outputs.
24062409

2410+
<!-- snippet-source examples/snippets/clients/parsing_tool_results.py -->
24072411
```python
24082412
"""examples/snippets/clients/parsing_tool_results.py"""
24092413

@@ -2415,9 +2419,7 @@ from mcp.client.stdio import stdio_client
24152419

24162420
async def parse_tool_results():
24172421
"""Demonstrates how to parse different types of content in CallToolResult."""
2418-
server_params = StdioServerParameters(
2419-
command="python", args=["path/to/mcp_server.py"]
2420-
)
2422+
server_params = StdioServerParameters(command="python", args=["path/to/mcp_server.py"])
24212423

24222424
async with stdio_client(server_params) as (read, write):
24232425
async with ClientSession(read, write) as session:
@@ -2431,9 +2433,9 @@ async def parse_tool_results():
24312433

24322434
# Example 2: Parsing structured content from JSON tools
24332435
result = await session.call_tool("get_user", {"id": "123"})
2434-
if hasattr(result, "structuredContent") and result.structuredContent:
2436+
if hasattr(result, "structured_content") and result.structured_content:
24352437
# Access structured data directly
2436-
user_data = result.structuredContent
2438+
user_data = result.structured_content
24372439
print(f"User: {user_data.get('name')}, Age: {user_data.get('age')}")
24382440

24392441
# Example 3: Parsing embedded resources
@@ -2443,18 +2445,18 @@ async def parse_tool_results():
24432445
resource = content.resource
24442446
if isinstance(resource, types.TextResourceContents):
24452447
print(f"Config from {resource.uri}: {resource.text}")
2446-
elif isinstance(resource, types.BlobResourceContents):
2448+
else:
24472449
print(f"Binary data from {resource.uri}")
24482450

24492451
# Example 4: Parsing image content
24502452
result = await session.call_tool("generate_chart", {"data": [1, 2, 3]})
24512453
for content in result.content:
24522454
if isinstance(content, types.ImageContent):
2453-
print(f"Image ({content.mimeType}): {len(content.data)} bytes")
2455+
print(f"Image ({content.mime_type}): {len(content.data)} bytes")
24542456

24552457
# Example 5: Handling errors
24562458
result = await session.call_tool("failing_tool", {})
2457-
if result.isError:
2459+
if result.is_error:
24582460
print("Tool execution failed!")
24592461
for content in result.content:
24602462
if isinstance(content, types.TextContent):
@@ -2469,6 +2471,9 @@ if __name__ == "__main__":
24692471
asyncio.run(main())
24702472
```
24712473

2474+
_Full example: [examples/snippets/clients/parsing_tool_results.py](https://github.com/modelcontextprotocol/python-sdk/blob/main/examples/snippets/clients/parsing_tool_results.py)_
2475+
<!-- /snippet-source -->
2476+
24722477
### MCP Primitives
24732478

24742479
The MCP protocol defines three core primitives that servers can implement:
@@ -2493,7 +2498,7 @@ MCP servers declare capabilities during initialization:
24932498

24942499
## Documentation
24952500

2496-
- [API Reference](https://modelcontextprotocol.github.io/python-sdk/api/)
2501+
- [API Reference](https://py.sdk.modelcontextprotocol.io/v2/api/mcp/)
24972502
- [Model Context Protocol documentation](https://modelcontextprotocol.io)
24982503
- [Model Context Protocol specification](https://modelcontextprotocol.io/specification/latest)
24992504
- [Officially supported servers](https://github.com/modelcontextprotocol/servers)

RELEASE.md

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,12 @@
77

88
## Major or Minor Release
99

10-
Create a GitHub release via UI with the tag being `vX.Y.Z` where `X.Y.Z` is the version,
11-
and the release title being the same. Then ask someone to review the release.
10+
Stable releases are cut from the `v1.x` branch. Create a GitHub release via UI
11+
with the tag being `vX.Y.Z` where `X.Y.Z` is the version and the release title
12+
being the same, and **set the tag's target to the `v1.x` branch** — the UI
13+
defaults to `main`, which is the v2 rework, and a v1 tag created there would
14+
publish the v2 codebase as a stable release. Then ask someone to review the
15+
release.
1216

1317
The package version will be set automatically from the tag.
1418

@@ -22,16 +26,29 @@ for alphas, later `bN`/`rcN` for betas and release candidates.
2226
passed — check the individual jobs.
2327
2. Create the release as a pre-release, passing the exact commit verified in
2428
step 1 as `--target` (otherwise the tag is created from whatever `main`'s
25-
HEAD is by then). The pre-release flag keeps GitHub's "Latest" badge and
26-
`/releases/latest` pointing at the stable v1.x line:
29+
HEAD is by then). The tagged commit determines everything about the
30+
release — the workflows that run and the package metadata (readme,
31+
classifiers) that gets published — so it must contain the current release
32+
tooling, not just pass tests. `--target` is ignored if the tag already
33+
exists: when re-creating a release, delete the old tag first and
34+
double-check where the new tag points. The pre-release flag keeps GitHub's
35+
"Latest" badge and `/releases/latest` pointing at the stable v1.x line:
2736

2837
```shell
2938
gh release create v2.0.0aN --prerelease --title v2.0.0aN --target <commit-sha>
3039
```
3140

3241
3. Curate the release notes instead of relying on auto-generated ones: what
3342
changed since the previous pre-release, what is known-incomplete, the
34-
install line (`pip install mcp==2.0.0aN`), and a link to the
35-
[migration guide](docs/migration.md).
43+
install line (`pip install mcp==2.0.0aN`), and a link to the migration
44+
guide. Use the absolute URL
45+
(`https://github.com/modelcontextprotocol/python-sdk/blob/main/docs/migration.md`)
46+
because relative links don't resolve in GitHub release bodies.
3647
4. If a pre-release turns out to be broken, yank it on PyPI and cut the next
3748
one. Never delete a release from PyPI — version numbers cannot be reused.
49+
Yanking doesn't stop `==` pins from installing the broken version, so set
50+
the yank reason (and edit the GitHub release notes) to point at the
51+
replacement version.
52+
5. When the line moves to a new stage (first beta, first release candidate,
53+
stable), update the `Development Status` classifier in `pyproject.toml`
54+
before tagging — PyPI uploads are immutable.

examples/snippets/clients/stdio_client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ async def run():
5959
# Read a resource (greeting resource from mcpserver_quickstart)
6060
resource_content = await session.read_resource("greeting://World")
6161
content_block = resource_content.contents[0]
62-
if isinstance(content_block, types.TextContent):
62+
if isinstance(content_block, types.TextResourceContents):
6363
print(f"Resource content: {content_block.text}")
6464

6565
# Call a tool (add tool from mcpserver_quickstart)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ maintainers = [
1111
{ name = "Max Isbey", email = "maxisbey@anthropic.com" },
1212
{ name = "Felix Weinberger", email = "fweinberger@anthropic.com" },
1313
]
14-
keywords = ["git", "mcp", "llm", "automation"]
14+
keywords = ["mcp", "llm", "automation"]
1515
license = { text = "MIT" }
1616
classifiers = [
1717
"Development Status :: 3 - Alpha",

0 commit comments

Comments
 (0)