mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-09 07:09:11 +02:00
Merge remote-tracking branch 'origin/main' into sdkv2
# Conflicts: # fastmcp_slim/fastmcp/server/providers/proxy.py # tests/test_apps_prefab.py
This commit is contained in:
commit
3acb239a7a
22 changed files with 676 additions and 128 deletions
|
|
@ -5,6 +5,91 @@ rss: true
|
|||
tag: NEW
|
||||
---
|
||||
|
||||
<Update label="v3.4.3" description="2026-07-05">
|
||||
|
||||
**[v3.4.3: The Fast and the Secure-ious](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.3)**
|
||||
|
||||
FastMCP 3.4.3 closes out a month of SSRF and OAuth hardening: NAT64, 6to4, Teredo, and ISATAP transition addresses can no longer smuggle private IPv4 targets past the SSRF allow-list, Streamable HTTP now validates Host and Origin before session handling to block DNS rebinding against localhost-bound servers, and OAuth redirect validation rejects unsafe schemes and unregistered DCR redirect URIs. Alongside the security work, this release also fixes proxy session teardown races, discriminator-tag handling in JSON schema conversion, and several smaller reliability issues.
|
||||
|
||||
### Enhancements ✨
|
||||
* Dedupe discriminator-required helper across schema converters by [@jlowin](https://github.com/jlowin) in [#4362](https://github.com/PrefectHQ/fastmcp/pull/4362)
|
||||
* Add real Monty sandbox e2e coverage for CodeMode call_tool by [@AlexlaGuardia](https://github.com/AlexlaGuardia) in [#4274](https://github.com/PrefectHQ/fastmcp/pull/4274)
|
||||
* Switch prettier hook to rbubley/mirrors-prettier by [@jlowin](https://github.com/jlowin) in [#4366](https://github.com/PrefectHQ/fastmcp/pull/4366)
|
||||
* feat(remote): add --verify flag for TLS certificate verification by [@jlowin](https://github.com/jlowin) in [#4369](https://github.com/PrefectHQ/fastmcp/pull/4369)
|
||||
### Security 🔒
|
||||
* fix(deps): clear Dependabot security alerts via lockfile bumps by [@jlowin](https://github.com/jlowin) in [#4393](https://github.com/PrefectHQ/fastmcp/pull/4393)
|
||||
* Clarify resource path parameter safety by [@jlowin](https://github.com/jlowin) in [#4398](https://github.com/PrefectHQ/fastmcp/pull/4398)
|
||||
* Fix dev apps launch escaping by [@jlowin](https://github.com/jlowin) in [#4399](https://github.com/PrefectHQ/fastmcp/pull/4399)
|
||||
* Block NAT64 SSRF bypass by [@jlowin](https://github.com/jlowin) in [#4400](https://github.com/PrefectHQ/fastmcp/pull/4400)
|
||||
* [codex] Fix event store replay isolation by [@jlowin](https://github.com/jlowin) in [#4402](https://github.com/PrefectHQ/fastmcp/pull/4402)
|
||||
* Fix DCR redirect URI validation by [@jlowin](https://github.com/jlowin) in [#4408](https://github.com/PrefectHQ/fastmcp/pull/4408)
|
||||
* Protect streamable HTTP from DNS rebinding by [@jlowin](https://github.com/jlowin) in [#4405](https://github.com/PrefectHQ/fastmcp/pull/4405)
|
||||
* Block unsafe OAuth redirect schemes by [@jlowin](https://github.com/jlowin) in [#4419](https://github.com/PrefectHQ/fastmcp/pull/4419)
|
||||
* Block IPv6 transition SSRF bypasses by [@jlowin](https://github.com/jlowin) in [#4426](https://github.com/PrefectHQ/fastmcp/pull/4426)
|
||||
### Fixes 🐞
|
||||
* fix: caching middleware TypeError on cache miss due to mismatched call_next parameter by [@gmenziesint](https://github.com/gmenziesint) in [#4301](https://github.com/PrefectHQ/fastmcp/pull/4301)
|
||||
* Fix: async rate limiting middleware get_client_id callbacks by [@Chotom](https://github.com/Chotom) in [#4319](https://github.com/PrefectHQ/fastmcp/pull/4319)
|
||||
* Recognize all GitHub issue-link forms in require-issue-link workflow by [@jlowin](https://github.com/jlowin) in [#4359](https://github.com/PrefectHQ/fastmcp/pull/4359)
|
||||
* fix: preserve required discriminator tags by [@he-yufeng](https://github.com/he-yufeng) in [#4297](https://github.com/PrefectHQ/fastmcp/pull/4297)
|
||||
* fix(proxy): shield stateful proxy disconnect during session teardown by [@jlowin](https://github.com/jlowin) in [#4363](https://github.com/PrefectHQ/fastmcp/pull/4363)
|
||||
* fix(fs): isolate same-named package imports across providers by [@jlowin](https://github.com/jlowin) in [#4361](https://github.com/PrefectHQ/fastmcp/pull/4361)
|
||||
* fix: StatefulProxyClient.clear() no longer causes KeyError on session teardown by [@tcconnally](https://github.com/tcconnally) in [#4328](https://github.com/PrefectHQ/fastmcp/pull/4328)
|
||||
* fix: guard recursive refs in json_schema_to_type by [@Epochex](https://github.com/Epochex) in [#4312](https://github.com/PrefectHQ/fastmcp/pull/4312)
|
||||
* Forward IdP auth errors to MCP client instead of showing HTML error page by [@bobbyjames839](https://github.com/bobbyjames839) in [#4293](https://github.com/PrefectHQ/fastmcp/pull/4293)
|
||||
* fix(resources): round-trip path values with reserved characters in URI templates by [@jlowin](https://github.com/jlowin) in [#4368](https://github.com/PrefectHQ/fastmcp/pull/4368)
|
||||
* fix: bracket IPv6 hosts in server startup log URL by [@jlowin](https://github.com/jlowin) in [#4372](https://github.com/PrefectHQ/fastmcp/pull/4372)
|
||||
* fix: bound default OIDC discovery timeout and expose it on provider wrappers by [@jlowin](https://github.com/jlowin) in [#4374](https://github.com/PrefectHQ/fastmcp/pull/4374)
|
||||
* fix: validate task tool arguments against declared types by [@jlowin](https://github.com/jlowin) in [#4373](https://github.com/PrefectHQ/fastmcp/pull/4373)
|
||||
* fix(tools): honor serialize_by_alias in tool result serialization by [@jlowin](https://github.com/jlowin) in [#4391](https://github.com/PrefectHQ/fastmcp/pull/4391)
|
||||
* Fix/cimd flow issue by [@twjackysu](https://github.com/twjackysu) in [#4206](https://github.com/PrefectHQ/fastmcp/pull/4206)
|
||||
* Reject empty env var keys by [@CodingFeng101](https://github.com/CodingFeng101) in [#4410](https://github.com/PrefectHQ/fastmcp/pull/4410)
|
||||
* fix: correct replace_type docstring parameter descriptions by [@hiSandog](https://github.com/hiSandog) in [#4375](https://github.com/PrefectHQ/fastmcp/pull/4375)
|
||||
* Fix ty 0.0.55 diagnostics and prefab-ui protocol version drift by [@jlowin](https://github.com/jlowin) in [#4428](https://github.com/PrefectHQ/fastmcp/pull/4428)
|
||||
* [codex] Fix OpenAPI resource template requests by [@jlowin](https://github.com/jlowin) in [#4407](https://github.com/PrefectHQ/fastmcp/pull/4407)
|
||||
### Docs 📚
|
||||
* fix: RST docstrings in fastmcp.types render raw on gofastmcp.com by [@jlowin](https://github.com/jlowin) in [#4367](https://github.com/PrefectHQ/fastmcp/pull/4367)
|
||||
* docs: fix 5 broken internal links (auth & providers pages) by [@Michael-WhiteCapData](https://github.com/Michael-WhiteCapData) in [#4344](https://github.com/PrefectHQ/fastmcp/pull/4344)
|
||||
* docs: add audit/event-record recipe for tool-call middleware by [@AlexlaGuardia](https://github.com/AlexlaGuardia) in [#4345](https://github.com/PrefectHQ/fastmcp/pull/4345)
|
||||
### Dependencies 📦
|
||||
* chore(deps): bump actions/checkout from 6 to 7 by [@dependabot](https://github.com/apps/dependabot) in [#4343](https://github.com/PrefectHQ/fastmcp/pull/4343)
|
||||
* chore(deps): bump joserfc from 1.6.5 to 1.6.7 in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4394](https://github.com/PrefectHQ/fastmcp/pull/4394)
|
||||
* chore(deps): bump joserfc from 1.6.7 to 1.6.8 in the uv group across 1 directory by [@dependabot](https://github.com/apps/dependabot) in [#4429](https://github.com/PrefectHQ/fastmcp/pull/4429)
|
||||
### Other Changes 🦾
|
||||
* Raise fastmcp.ValidationError for invalid tool arguments by [@jlowin](https://github.com/jlowin) in [#4392](https://github.com/PrefectHQ/fastmcp/pull/4392)
|
||||
* Fix versioned auth middleware checks by [@jlowin](https://github.com/jlowin) in [#4401](https://github.com/PrefectHQ/fastmcp/pull/4401)
|
||||
|
||||
## New Contributors
|
||||
* @gmenziesint made their first contribution in [#4301](https://github.com/PrefectHQ/fastmcp/pull/4301)
|
||||
* @Chotom made their first contribution in [#4319](https://github.com/PrefectHQ/fastmcp/pull/4319)
|
||||
* @he-yufeng made their first contribution in [#4297](https://github.com/PrefectHQ/fastmcp/pull/4297)
|
||||
* @AlexlaGuardia made their first contribution in [#4274](https://github.com/PrefectHQ/fastmcp/pull/4274)
|
||||
* @tcconnally made their first contribution in [#4328](https://github.com/PrefectHQ/fastmcp/pull/4328)
|
||||
* @Epochex made their first contribution in [#4312](https://github.com/PrefectHQ/fastmcp/pull/4312)
|
||||
* @Michael-WhiteCapData made their first contribution in [#4344](https://github.com/PrefectHQ/fastmcp/pull/4344)
|
||||
* @bobbyjames839 made their first contribution in [#4293](https://github.com/PrefectHQ/fastmcp/pull/4293)
|
||||
* @twjackysu made their first contribution in [#4206](https://github.com/PrefectHQ/fastmcp/pull/4206)
|
||||
* @CodingFeng101 made their first contribution in [#4410](https://github.com/PrefectHQ/fastmcp/pull/4410)
|
||||
* @hiSandog made their first contribution in [#4375](https://github.com/PrefectHQ/fastmcp/pull/4375)
|
||||
|
||||
**Full Changelog**: [v3.4.2...v3.4.3](https://github.com/PrefectHQ/fastmcp/compare/v3.4.2...v3.4.3)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.4.2" description="2026-06-06">
|
||||
|
||||
**[v3.4.2: Heads Up](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.2)**
|
||||
|
||||
FastMCP 3.4.2 restores JWT compatibility for providers that include private, non-critical JWS header parameters. Tokens from providers like Clerk can carry header metadata such as `cat` without being rejected before signature and claim validation, while unsupported critical headers are still rejected.
|
||||
|
||||
### Fixes 🐞
|
||||
* Allow private JWT headers by [@jlowin](https://github.com/jlowin) in [#4290](https://github.com/PrefectHQ/fastmcp/pull/4290)
|
||||
### Docs 📚
|
||||
* Docs: add v3.4.1 changelog entries by [@jlowin](https://github.com/jlowin) in [#4289](https://github.com/PrefectHQ/fastmcp/pull/4289)
|
||||
|
||||
**Full Changelog**: [v3.4.1...v3.4.2](https://github.com/PrefectHQ/fastmcp/compare/v3.4.1...v3.4.2)
|
||||
|
||||
</Update>
|
||||
|
||||
<Update label="v3.4.1" description="2026-06-05">
|
||||
|
||||
**[v3.4.1: Floor It](https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.1)**
|
||||
|
|
|
|||
|
|
@ -85,14 +85,13 @@ replace_type(type_, type_map: dict[type, type])
|
|||
|
||||
|
||||
Given a (possibly generic, nested, or otherwise complex) type, replaces all
|
||||
instances of old_type with new_type.
|
||||
instances of keys in type_map with their corresponding values.
|
||||
|
||||
This is useful for transforming types when creating tools.
|
||||
|
||||
**Args:**
|
||||
- `type_`: The type to replace instances of old_type with new_type.
|
||||
- `old_type`: The type to replace.
|
||||
- `new_type`: The type to replace old_type with.
|
||||
- `type_`: The type to transform.
|
||||
- `type_map`: A mapping of types to replace (keys are replaced by values).
|
||||
|
||||
Examples:
|
||||
```python
|
||||
|
|
@ -166,4 +165,4 @@ Helper class for returning file data from tools.
|
|||
to_resource_content(self, mime_type: str | None = None, annotations: Annotations | None = None) -> mcp.types.EmbeddedResource
|
||||
```
|
||||
|
||||
### `ContextSamplingFallbackProtocol` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L491" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
### `ContextSamplingFallbackProtocol` <sup><a href="https://github.com/PrefectHQ/fastmcp/blob/main/fastmcp_slim/fastmcp/utilities/types.py#L490" target="_blank"><Icon icon="github" style="width: 14px; height: 14px;" /></a></sup>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,34 @@ icon: "sparkles"
|
|||
tag: NEW
|
||||
---
|
||||
|
||||
<Update label="FastMCP 3.4.3" description="July 5, 2026" tags={["Releases"]}>
|
||||
<Card
|
||||
title="FastMCP v3.4.3: The Fast and the Secure-ious"
|
||||
href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.3"
|
||||
cta="Read the release notes"
|
||||
>
|
||||
A month of SSRF and OAuth hardening lands in one patch. NAT64, 6to4, Teredo, and ISATAP transition addresses can no longer smuggle private IPv4 targets past the SSRF allow-list, Streamable HTTP validates Host and Origin before session handling to block DNS rebinding, and OAuth redirect validation rejects unsafe schemes and unregistered DCR redirect URIs.
|
||||
|
||||
🛡️ **SSRF allow-list hardening** — every IPv6 transition form (NAT64, 6to4, Teredo, ISATAP) now unwraps to its embedded IPv4 target and gets checked against the same policy.
|
||||
|
||||
🌐 **DNS rebinding protection** — Streamable HTTP validates `Host` and browser `Origin` before session handling, closing a path to localhost-bound unauthenticated servers.
|
||||
|
||||
🔐 **Stricter OAuth redirects** — unsafe redirect schemes are rejected before registration, and DCR clients are bound to the redirect URIs they registered.
|
||||
|
||||
🧵 **Reliability fixes** — proxy session teardown races, discriminator-tag handling in JSON schema conversion, and several smaller fixes across middleware and resource templates.
|
||||
</Card>
|
||||
</Update>
|
||||
|
||||
<Update label="FastMCP 3.4.2" description="June 6, 2026" tags={["Releases"]}>
|
||||
<Card
|
||||
title="FastMCP v3.4.2: Heads Up"
|
||||
href="https://github.com/PrefectHQ/fastmcp/releases/tag/v3.4.2"
|
||||
cta="Read the release notes"
|
||||
>
|
||||
A compatibility patch. `JWTVerifier` now accepts JWTs carrying private, non-critical JWS header parameters (like Clerk's `cat`) instead of rejecting them before signature and claim validation, while unsupported critical headers are still rejected.
|
||||
</Card>
|
||||
</Update>
|
||||
|
||||
<Update label="FastMCP 3.4.1" description="June 5, 2026" tags={["Releases"]}>
|
||||
<Card
|
||||
title="FastMCP v3.4.1: Floor It"
|
||||
|
|
|
|||
|
|
@ -22,7 +22,23 @@ from fastmcp.utilities.logging import get_logger
|
|||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
NAT64_WELL_KNOWN_PREFIX = ipaddress.ip_network("64:ff9b::/96")
|
||||
NAT64_PREFIXES: tuple[
|
||||
tuple[ipaddress.IPv6Network, tuple[tuple[int, int, int, int], ...]], ...
|
||||
] = (
|
||||
(ipaddress.IPv6Network("64:ff9b::/96"), ((12, 13, 14, 15),)),
|
||||
(
|
||||
ipaddress.IPv6Network("64:ff9b:1::/48"),
|
||||
(
|
||||
(6, 7, 9, 10),
|
||||
(7, 9, 10, 11),
|
||||
(9, 10, 11, 12),
|
||||
(12, 13, 14, 15),
|
||||
),
|
||||
),
|
||||
)
|
||||
LOW32_OFFSETS = (12, 13, 14, 15)
|
||||
IPV4_TRANSLATED_PREFIX = ipaddress.IPv6Network("0:0:0:0:ffff:0:0:0/96")
|
||||
ISATAP_INTERFACE_IDS = (b"\x00\x00\x5e\xfe", b"\x02\x00\x5e\xfe")
|
||||
|
||||
|
||||
def format_ip_for_url(ip_str: str) -> str:
|
||||
|
|
@ -54,6 +70,39 @@ class SSRFFetchError(Exception):
|
|||
"""Raised when SSRF-safe fetch fails."""
|
||||
|
||||
|
||||
def _embedded_ipv4_addresses(
|
||||
ip: ipaddress.IPv6Address,
|
||||
) -> set[ipaddress.IPv4Address]:
|
||||
"""Return IPv4 addresses embedded in known IPv6 transition forms."""
|
||||
candidates: set[ipaddress.IPv4Address] = set()
|
||||
packed = ip.packed
|
||||
|
||||
def from_offsets(offsets: tuple[int, int, int, int]) -> ipaddress.IPv4Address:
|
||||
return ipaddress.IPv4Address(bytes(packed[i] for i in offsets))
|
||||
|
||||
if ip.ipv4_mapped:
|
||||
candidates.add(ip.ipv4_mapped)
|
||||
if ip.sixtofour:
|
||||
candidates.add(ip.sixtofour)
|
||||
if ip.teredo:
|
||||
server, client = ip.teredo
|
||||
candidates.update((server, client))
|
||||
if ip in IPV4_TRANSLATED_PREFIX:
|
||||
candidates.add(from_offsets(LOW32_OFFSETS))
|
||||
|
||||
for prefix, offset_options in NAT64_PREFIXES:
|
||||
if ip in prefix:
|
||||
candidates.update(from_offsets(offsets) for offsets in offset_options)
|
||||
|
||||
if int(ip) >> 32 == 0 and not ip.is_loopback and not ip.is_unspecified:
|
||||
candidates.add(from_offsets(LOW32_OFFSETS))
|
||||
|
||||
if packed[8:12] in ISATAP_INTERFACE_IDS:
|
||||
candidates.add(from_offsets(LOW32_OFFSETS))
|
||||
|
||||
return candidates
|
||||
|
||||
|
||||
def is_ip_allowed(ip_str: str) -> bool:
|
||||
"""Check if an IP address is allowed (must be globally routable unicast).
|
||||
|
||||
|
|
@ -63,7 +112,7 @@ def is_ip_allowed(ip_str: str) -> bool:
|
|||
- Link-local (169.254.x, fe80::) - includes AWS metadata!
|
||||
- Reserved, unspecified
|
||||
- RFC6598 Carrier-Grade NAT (100.64.0.0/10) - can point to internal networks
|
||||
- NAT64 (64:ff9b::/96) - can point to internal networks
|
||||
- IPv6 transition forms that embed blocked IPv4 targets
|
||||
|
||||
Additionally blocks multicast addresses (not caught by is_global).
|
||||
|
||||
|
|
@ -78,26 +127,18 @@ def is_ip_allowed(ip_str: str) -> bool:
|
|||
except ValueError:
|
||||
return False
|
||||
|
||||
if isinstance(ip, ipaddress.IPv6Address):
|
||||
if any(
|
||||
not is_ip_allowed(str(embedded_ip))
|
||||
for embedded_ip in _embedded_ipv4_addresses(ip)
|
||||
):
|
||||
return False
|
||||
|
||||
if not ip.is_global:
|
||||
return False
|
||||
|
||||
# Block multicast (not caught by is_global for some ranges)
|
||||
if ip.is_multicast:
|
||||
return False
|
||||
|
||||
# IPv6-specific checks for embedded IPv4 addresses
|
||||
if isinstance(ip, ipaddress.IPv6Address):
|
||||
if ip.ipv4_mapped:
|
||||
return is_ip_allowed(str(ip.ipv4_mapped))
|
||||
if ip.sixtofour:
|
||||
return is_ip_allowed(str(ip.sixtofour))
|
||||
if ip.teredo:
|
||||
server, client = ip.teredo
|
||||
return is_ip_allowed(str(server)) and is_ip_allowed(str(client))
|
||||
if ip in NAT64_WELL_KNOWN_PREFIX:
|
||||
return is_ip_allowed(str(ipaddress.IPv4Address(ip.packed[-4:])))
|
||||
|
||||
return True
|
||||
return not ip.is_multicast
|
||||
|
||||
|
||||
async def resolve_hostname(hostname: str, port: int = 443) -> list[str]:
|
||||
|
|
|
|||
|
|
@ -388,7 +388,7 @@ class Provider:
|
|||
matching = [t for t in matching if version.matches(t.version)]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
async def _list_resources(self) -> Sequence[Resource]:
|
||||
"""Return all available resources.
|
||||
|
|
@ -419,7 +419,7 @@ class Provider:
|
|||
matching = [r for r in matching if version.matches(r.version)]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
async def _list_resource_templates(self) -> Sequence[ResourceTemplate]:
|
||||
"""Return all available resource templates.
|
||||
|
|
@ -450,7 +450,7 @@ class Provider:
|
|||
matching = [t for t in matching if version.matches(t.version)]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
async def _list_prompts(self) -> Sequence[Prompt]:
|
||||
"""Return all available prompts.
|
||||
|
|
@ -481,7 +481,7 @@ class Provider:
|
|||
matching = [p for p in matching if version.matches(p.version)]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task registration
|
||||
|
|
|
|||
|
|
@ -366,7 +366,7 @@ class LocalProvider(
|
|||
matching = [t for t in matching if version.matches(t.version)]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
async def _list_resources(self) -> Sequence[Resource]:
|
||||
"""Return all resources."""
|
||||
|
|
@ -390,7 +390,7 @@ class LocalProvider(
|
|||
matching = [r for r in matching if version.matches(r.version)]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
async def _list_resource_templates(self) -> Sequence[ResourceTemplate]:
|
||||
"""Return all resource templates."""
|
||||
|
|
@ -416,7 +416,7 @@ class LocalProvider(
|
|||
matching = [t for t in matching if version.matches(t.version)]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
async def _list_prompts(self) -> Sequence[Prompt]:
|
||||
"""Return all prompts."""
|
||||
|
|
@ -440,7 +440,7 @@ class LocalProvider(
|
|||
matching = [p for p in matching if version.matches(p.version)]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
# =========================================================================
|
||||
# Task registration
|
||||
|
|
|
|||
|
|
@ -269,6 +269,7 @@ class OpenAPIResource(Resource):
|
|||
description: str,
|
||||
mime_type: str = "application/json",
|
||||
tags: set[str] | None = None,
|
||||
arguments: dict[str, Any] | None = None,
|
||||
):
|
||||
super().__init__(
|
||||
uri=AnyUrl(uri),
|
||||
|
|
@ -280,6 +281,7 @@ class OpenAPIResource(Resource):
|
|||
self._client = client
|
||||
self._route = route
|
||||
self._director = director
|
||||
self._arguments = dict(arguments or {})
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"OpenAPIResource(name={self.name!r}, uri={self.uri!r}, path={self._route.path})"
|
||||
|
|
@ -287,40 +289,23 @@ class OpenAPIResource(Resource):
|
|||
async def read(self) -> ResourceResult:
|
||||
"""Fetch the resource data by making an HTTP request."""
|
||||
try:
|
||||
path = self._route.path
|
||||
resource_uri = str(self.uri)
|
||||
|
||||
# If this is a templated resource, extract path parameters from the URI
|
||||
if "{" in path and "}" in path:
|
||||
parts = resource_uri.split("/")
|
||||
|
||||
if len(parts) > 1:
|
||||
path_params = {}
|
||||
param_matches = re.findall(r"\{([^}]+)\}", path)
|
||||
if param_matches:
|
||||
param_matches.sort(reverse=True)
|
||||
expected_param_count = len(parts) - 1
|
||||
for i, param_name in enumerate(param_matches):
|
||||
if i < expected_param_count:
|
||||
param_value = parts[-1 - i]
|
||||
path_params[param_name] = param_value
|
||||
|
||||
for param_name, param_value in path_params.items():
|
||||
path = path.replace(f"{{{param_name}}}", str(param_value))
|
||||
|
||||
# Build headers with correct precedence
|
||||
headers: dict[str, str] = {}
|
||||
if self._client.headers:
|
||||
headers.update(self._client.headers)
|
||||
base_url = str(self._client.base_url) or "http://localhost"
|
||||
directed_request = self._director.build(
|
||||
self._route, self._arguments, base_url
|
||||
)
|
||||
request = self._client.build_request(
|
||||
method=directed_request.method,
|
||||
url=directed_request.url.copy_with(query=None),
|
||||
params=directed_request.url.params,
|
||||
headers=directed_request.headers,
|
||||
content=directed_request.content,
|
||||
extensions=directed_request.extensions,
|
||||
)
|
||||
mcp_headers = get_http_headers()
|
||||
if mcp_headers:
|
||||
headers.update(mcp_headers)
|
||||
request.headers.update(mcp_headers)
|
||||
|
||||
response = await self._client.request(
|
||||
method=self._route.method,
|
||||
url=path,
|
||||
headers=headers,
|
||||
)
|
||||
response = await self._client.send(request)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
|
|
@ -368,6 +353,13 @@ class OpenAPIResource(Resource):
|
|||
raise ValueError(f"Request error ({type(e).__name__}): {e!s}") from e
|
||||
|
||||
|
||||
def _path_argument_name(route: HTTPRoute, parameter_name: str) -> str:
|
||||
for argument_name, mapping in route.parameter_map.items():
|
||||
if mapping["location"] == "path" and mapping["openapi_name"] == parameter_name:
|
||||
return argument_name
|
||||
return parameter_name
|
||||
|
||||
|
||||
class OpenAPIResourceTemplate(ResourceTemplate):
|
||||
"""Resource template implementation for OpenAPI endpoints."""
|
||||
|
||||
|
|
@ -408,6 +400,17 @@ class OpenAPIResourceTemplate(ResourceTemplate):
|
|||
) -> Resource:
|
||||
"""Create a resource with the given parameters."""
|
||||
uri_parts = [f"{key}={value}" for key, value in params.items()]
|
||||
arguments = {}
|
||||
for parameter in self._route.parameters:
|
||||
if parameter.location != "path":
|
||||
continue
|
||||
argument_name = _path_argument_name(self._route, parameter.name)
|
||||
if parameter.name in params:
|
||||
arguments[argument_name] = params[parameter.name]
|
||||
continue
|
||||
normalized_name = parameter.name.replace("-", "_")
|
||||
if normalized_name in params:
|
||||
arguments[argument_name] = params[normalized_name]
|
||||
|
||||
return OpenAPIResource(
|
||||
client=self._client,
|
||||
|
|
@ -418,4 +421,5 @@ class OpenAPIResourceTemplate(ResourceTemplate):
|
|||
description=self.description or f"Resource for {self._route.path}",
|
||||
mime_type=self.mime_type,
|
||||
tags=set(self._route.tags or []),
|
||||
arguments=arguments,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -425,7 +425,7 @@ class OpenAPIProvider(Provider):
|
|||
matching = [t for t in matching if version.matches(t.version)]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
async def _list_prompts(self) -> Sequence[Prompt]:
|
||||
"""Return empty list - OpenAPI doesn't create prompts."""
|
||||
|
|
|
|||
|
|
@ -643,7 +643,7 @@ class ProxyProvider(Provider):
|
|||
matching = [t for t in matching if version.matches(t.version)]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Resource methods
|
||||
|
|
@ -680,7 +680,7 @@ class ProxyProvider(Provider):
|
|||
matching = [r for r in matching if version.matches(r.version)]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Resource template methods
|
||||
|
|
@ -717,7 +717,7 @@ class ProxyProvider(Provider):
|
|||
matching = [t for t in matching if version.matches(t.version)]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Prompt methods
|
||||
|
|
@ -754,7 +754,7 @@ class ProxyProvider(Provider):
|
|||
matching = [p for p in matching if version.matches(p.version)]
|
||||
if not matching:
|
||||
return None
|
||||
return max(matching, key=version_sort_key) # type: ignore[type-var] # ty:ignore[invalid-return-type]
|
||||
return max(matching, key=version_sort_key)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Task methods
|
||||
|
|
@ -1114,7 +1114,7 @@ class ProxyClient(Client[ClientTransportT]):
|
|||
if key not in kwargs:
|
||||
kwargs[key] = _make_restoring_handler(default_fn, self._proxy_rc_ref)
|
||||
self._proxy_restoring_handler_keys.add(key)
|
||||
super().__init__(transport=transport, **kwargs)
|
||||
super().__init__(transport=transport, **kwargs) # ty: ignore[no-matching-overload]
|
||||
|
||||
# Enable forwarding of inbound HTTP headers (e.g. authorization) to
|
||||
# the upstream server. This is only appropriate for proxy clients,
|
||||
|
|
|
|||
|
|
@ -789,7 +789,7 @@ class FastMCP(
|
|||
|
||||
if not authorized:
|
||||
return None
|
||||
return cast(Tool, max(authorized, key=version_sort_key))
|
||||
return max(authorized, key=version_sort_key)
|
||||
|
||||
async def list_resources(
|
||||
self, *, run_middleware: bool = True
|
||||
|
|
@ -924,7 +924,7 @@ class FastMCP(
|
|||
|
||||
if not authorized:
|
||||
return None
|
||||
return cast(Resource, max(authorized, key=version_sort_key))
|
||||
return max(authorized, key=version_sort_key)
|
||||
|
||||
async def list_resource_templates(
|
||||
self, *, run_middleware: bool = True
|
||||
|
|
@ -1060,7 +1060,7 @@ class FastMCP(
|
|||
|
||||
if not authorized:
|
||||
return None
|
||||
return cast(ResourceTemplate, max(authorized, key=version_sort_key))
|
||||
return max(authorized, key=version_sort_key)
|
||||
|
||||
async def list_prompts(self, *, run_middleware: bool = True) -> Sequence[Prompt]:
|
||||
"""List all enabled prompts from providers.
|
||||
|
|
@ -1182,7 +1182,7 @@ class FastMCP(
|
|||
|
||||
if not authorized:
|
||||
return None
|
||||
return cast(Prompt, max(authorized, key=version_sort_key))
|
||||
return max(authorized, key=version_sort_key)
|
||||
|
||||
@overload
|
||||
async def call_tool(
|
||||
|
|
|
|||
|
|
@ -531,7 +531,7 @@ def _single_pass_optimize(
|
|||
# this regardless of `in_schema` — a $ref in a user extension
|
||||
# still pins the referenced $def as "used".
|
||||
if prune_defs:
|
||||
ref = node.get("$ref") # type: ignore
|
||||
ref = node.get("$ref")
|
||||
if isinstance(ref, str) and ref.startswith("#/$defs/"):
|
||||
referenced_def = ref.split("/")[-1]
|
||||
if current_def_name:
|
||||
|
|
@ -565,7 +565,7 @@ def _single_pass_optimize(
|
|||
|
||||
if (
|
||||
prune_additional_properties
|
||||
and node.get("additionalProperties") is False # type: ignore
|
||||
and node.get("additionalProperties") is False
|
||||
):
|
||||
node.pop("additionalProperties") # type: ignore
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Schema manipulation utilities for OpenAPI operations."""
|
||||
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
from fastmcp.utilities.logging import get_logger
|
||||
|
|
@ -294,13 +295,17 @@ def _combine_schemas_and_map_params(
|
|||
|
||||
body_props = body_schema.get("properties", {})
|
||||
|
||||
# Detect collisions: parameters that exist in both body and path/query/header
|
||||
# Detect collisions: parameters that exist in multiple non-body locations
|
||||
# or between body and path/query/header/cookie.
|
||||
all_non_body_params = set()
|
||||
for location_params in param_names_by_location.values():
|
||||
all_non_body_params.update(location_params)
|
||||
|
||||
body_param_names = set(body_props.keys())
|
||||
colliding_params = all_non_body_params & body_param_names
|
||||
non_body_param_counts = Counter(param.name for param in route.parameters)
|
||||
colliding_params = (all_non_body_params & body_param_names) | {
|
||||
name for name, count in non_body_param_counts.items() if count > 1
|
||||
}
|
||||
|
||||
# Add parameters with suffixes for collisions
|
||||
for param in route.parameters:
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ from __future__ import annotations
|
|||
from collections.abc import Callable, Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import total_ordering
|
||||
from typing import TYPE_CHECKING, Any, TypeVar, cast
|
||||
from typing import TYPE_CHECKING, Any, TypeVar
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
|
|
@ -325,7 +325,7 @@ def dedupe_with_versions(
|
|||
|
||||
result: list[C] = []
|
||||
for versions in by_key.values():
|
||||
highest: C = cast(C, max(versions, key=version_sort_key))
|
||||
highest: C = max(versions, key=version_sort_key)
|
||||
if any(c.version is not None for c in versions):
|
||||
all_versions = sorted(
|
||||
[c.version for c in versions if c.version is not None],
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ dev = [
|
|||
"pytest-timeout>=2.4.0",
|
||||
"pytest-xdist>=3.6.1",
|
||||
"ruff>=0.12.8",
|
||||
"ty>=0.0.39",
|
||||
"ty>=0.0.55",
|
||||
"prek>=0.2.12",
|
||||
"loq>=0.1.0a3",
|
||||
"opentelemetry-exporter-otlp-proto-grpc>=1.39.0",
|
||||
|
|
|
|||
|
|
@ -319,7 +319,7 @@ class TestScalarResponseTypes:
|
|||
result = await context.elicit(message="", response_type=None)
|
||||
assert result.action == "accept"
|
||||
assert isinstance(result, AcceptedElicitation)
|
||||
accepted = cast(AcceptedElicitation[dict[str, Any]], result)
|
||||
accepted = result
|
||||
assert isinstance(accepted.data, dict)
|
||||
return accepted.data
|
||||
|
||||
|
|
@ -341,7 +341,7 @@ class TestScalarResponseTypes:
|
|||
result = await context.elicit(message="", response_type=None)
|
||||
assert result.action == "accept"
|
||||
assert isinstance(result, AcceptedElicitation)
|
||||
accepted = cast(AcceptedElicitation[dict[str, Any]], result)
|
||||
accepted = result
|
||||
assert isinstance(accepted.data, dict)
|
||||
return accepted.data
|
||||
|
||||
|
|
|
|||
|
|
@ -200,7 +200,7 @@ async def test_list_list_multi_select_untitled():
|
|||
if result.action == "accept":
|
||||
assert isinstance(result, AcceptedElicitation)
|
||||
assert isinstance(result.data, list)
|
||||
return ",".join(result.data) # type: ignore[no-matching-overload] # ty:ignore[no-matching-overload]
|
||||
return ",".join(result.data) # type: ignore[no-matching-overload]
|
||||
return "declined"
|
||||
|
||||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
|
|
@ -238,7 +238,7 @@ async def test_list_dict_multi_select_titled():
|
|||
if result.action == "accept":
|
||||
assert isinstance(result, AcceptedElicitation)
|
||||
assert isinstance(result.data, list)
|
||||
return ",".join(result.data) # type: ignore[no-matching-overload] # ty:ignore[no-matching-overload]
|
||||
return ",".join(result.data) # type: ignore[no-matching-overload]
|
||||
return "declined"
|
||||
|
||||
async def elicitation_handler(message, response_type, params, ctx):
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ def create_test_server() -> FastMCP:
|
|||
result = await ctx.elicit("What is your name?", response_type=str)
|
||||
|
||||
if result.action == "accept":
|
||||
return f"You said your name was: {result.data}!" # ty: ignore[unresolved-attribute]
|
||||
return f"You said your name was: {result.data}!"
|
||||
else:
|
||||
return "No name provided"
|
||||
|
||||
|
|
|
|||
|
|
@ -54,19 +54,44 @@ class TestIsIPAllowed:
|
|||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
[
|
||||
pytest.param("64:ff9b::7f00:1", id="loopback"),
|
||||
pytest.param("64:ff9b::0a00:1", id="private"),
|
||||
pytest.param("64:ff9b::a9fe:a9fe", id="link-local"),
|
||||
pytest.param("64:ff9b::6440:1", id="cgnat"),
|
||||
pytest.param("64:ff9b::7f00:1", id="nat64-loopback"),
|
||||
pytest.param("64:ff9b::0a00:1", id="nat64-private"),
|
||||
pytest.param("64:ff9b::a9fe:a9fe", id="nat64-link-local"),
|
||||
pytest.param("64:ff9b::6440:1", id="nat64-cgnat"),
|
||||
pytest.param("64:ff9b:1::a9fe:a9fe", id="nat64-local-use-low32"),
|
||||
pytest.param("64:ff9b:1:a9fe:a9:fe00::", id="nat64-local-use-48"),
|
||||
pytest.param("::ffff:0:7f00:1", id="ipv4-translated-loopback"),
|
||||
pytest.param("::ffff:0:0a00:1", id="ipv4-translated-private"),
|
||||
pytest.param("::ffff:0:a9fe:a9fe", id="ipv4-translated-link-local"),
|
||||
pytest.param("::ffff:0:6440:1", id="ipv4-translated-cgnat"),
|
||||
pytest.param("::7f00:1", id="ipv4-compatible-loopback"),
|
||||
pytest.param("::0a00:1", id="ipv4-compatible-private"),
|
||||
pytest.param("::a9fe:a9fe", id="ipv4-compatible-link-local"),
|
||||
pytest.param("::6440:1", id="ipv4-compatible-cgnat"),
|
||||
pytest.param("2002:a9fe:a9fe::1", id="6to4-link-local"),
|
||||
pytest.param("2606:4700::5efe:192.168.1.1", id="isatap-private"),
|
||||
pytest.param(
|
||||
"2606:4700::200:5efe:169.254.169.254",
|
||||
id="isatap-link-local",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_nat64_ipv6_blocked_if_embedded_ipv4_blocked(self, address: str):
|
||||
"""NAT64 IPv6 addresses should check the embedded IPv4."""
|
||||
def test_ipv6_transition_blocked_if_embedded_ipv4_blocked(self, address: str):
|
||||
"""IPv6 transition addresses should check the embedded IPv4."""
|
||||
assert is_ip_allowed(address) is False
|
||||
|
||||
def test_nat64_ipv6_allowed_if_embedded_ipv4_allowed(self):
|
||||
"""NAT64 IPv6 addresses should stay allowed for public embedded IPv4."""
|
||||
assert is_ip_allowed("64:ff9b::0808:0808") is True
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
[
|
||||
pytest.param("64:ff9b::0808:0808", id="nat64"),
|
||||
pytest.param("::ffff:0:0808:0808", id="ipv4-translated"),
|
||||
pytest.param("::0808:0808", id="ipv4-compatible"),
|
||||
pytest.param("2606:4700::5efe:8.8.8.8", id="isatap"),
|
||||
],
|
||||
)
|
||||
def test_ipv6_transition_allowed_if_embedded_ipv4_allowed(self, address: str):
|
||||
"""IPv6 transition addresses should allow public embedded IPv4."""
|
||||
assert is_ip_allowed(address) is True
|
||||
|
||||
|
||||
class TestValidateURL:
|
||||
|
|
@ -100,11 +125,21 @@ class TestValidateURL:
|
|||
with pytest.raises(SSRFError, match="blocked IP"):
|
||||
await validate_url("https://example.com/path")
|
||||
|
||||
async def test_nat64_private_ip_rejected(self):
|
||||
"""URLs resolving to NAT64-wrapped private IPs should be rejected."""
|
||||
@pytest.mark.parametrize(
|
||||
"address",
|
||||
[
|
||||
pytest.param("64:ff9b::0a00:1", id="nat64"),
|
||||
pytest.param("64:ff9b:1:a9fe:a9:fe00::", id="nat64-local-use"),
|
||||
pytest.param("::ffff:0:a9fe:a9fe", id="ipv4-translated"),
|
||||
pytest.param("::a9fe:a9fe", id="ipv4-compatible"),
|
||||
pytest.param("2606:4700::5efe:169.254.169.254", id="isatap"),
|
||||
],
|
||||
)
|
||||
async def test_ipv6_transition_private_ip_rejected(self, address: str):
|
||||
"""URLs resolving to IPv6-wrapped private IPs should be rejected."""
|
||||
with patch(
|
||||
"fastmcp.server.auth.ssrf.resolve_hostname",
|
||||
return_value=["64:ff9b::0a00:1"],
|
||||
return_value=[address],
|
||||
):
|
||||
with pytest.raises(SSRFError, match="blocked IP"):
|
||||
await validate_url("https://example.com/path")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"""Tests for OpenAPI feature support in OpenAPIProvider."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
|
|
@ -705,6 +706,315 @@ class TestResourceTemplateMimeType:
|
|||
assert templates[0].mime_type == "application/json"
|
||||
|
||||
|
||||
class TestResourceTemplateRequestBuilding:
|
||||
@pytest.fixture
|
||||
def path_param_spec(self) -> dict[str, Any]:
|
||||
return {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "User API", "version": "1.0.0"},
|
||||
"servers": [{"url": "https://api.example.com/api/v1"}],
|
||||
"paths": {
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"operationId": "get_user",
|
||||
"summary": "Get user",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User data",
|
||||
"content": {
|
||||
"application/json": {"schema": {"type": "object"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
async def test_resource_template_encodes_matched_path_params(
|
||||
self, path_param_spec: dict[str, Any]
|
||||
):
|
||||
seen_urls: list[httpx.URL] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_urls.append(request.url)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://api.example.com/api/v1",
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as client:
|
||||
provider = OpenAPIProvider(
|
||||
openapi_spec=path_param_spec,
|
||||
client=client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
mcp = FastMCP("Test")
|
||||
mcp.add_provider(provider)
|
||||
|
||||
async with Client(mcp) as mcp_client:
|
||||
await mcp_client.read_resource(
|
||||
"resource://get_user/..%2F..%2Fadmin%2Fsecret"
|
||||
)
|
||||
|
||||
assert seen_urls == [
|
||||
httpx.URL(
|
||||
"https://api.example.com/api/v1/users/%2E%2E%2F%2E%2E%2Fadmin%2Fsecret"
|
||||
)
|
||||
]
|
||||
|
||||
async def test_resource_template_ignores_unmatched_query_string(
|
||||
self, path_param_spec: dict[str, Any]
|
||||
):
|
||||
seen_urls: list[httpx.URL] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_urls.append(request.url)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://api.example.com/api/v1",
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as client:
|
||||
provider = OpenAPIProvider(
|
||||
openapi_spec=path_param_spec,
|
||||
client=client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
mcp = FastMCP("Test")
|
||||
mcp.add_provider(provider)
|
||||
|
||||
async with Client(mcp) as mcp_client:
|
||||
await mcp_client.read_resource("resource://get_user/alice?admin=true")
|
||||
|
||||
assert seen_urls == [httpx.URL("https://api.example.com/api/v1/users/alice")]
|
||||
|
||||
async def test_resource_template_preserves_hyphenated_path_params(self):
|
||||
seen_urls: list[httpx.URL] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_urls.append(request.url)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
spec = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "User API", "version": "1.0.0"},
|
||||
"servers": [{"url": "https://api.example.com/api/v1"}],
|
||||
"paths": {
|
||||
"/users/{user-id}": {
|
||||
"get": {
|
||||
"operationId": "get_user",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "user-id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User data",
|
||||
"content": {
|
||||
"application/json": {"schema": {"type": "object"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://api.example.com/api/v1",
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as client:
|
||||
provider = OpenAPIProvider(
|
||||
openapi_spec=spec,
|
||||
client=client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
mcp = FastMCP("Test")
|
||||
mcp.add_provider(provider)
|
||||
|
||||
async with Client(mcp) as mcp_client:
|
||||
await mcp_client.read_resource("resource://get_user/abc")
|
||||
|
||||
assert seen_urls == [httpx.URL("https://api.example.com/api/v1/users/abc")]
|
||||
|
||||
async def test_resource_template_preserves_client_defaults(
|
||||
self, path_param_spec: dict[str, Any]
|
||||
):
|
||||
seen_requests: list[httpx.Request] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_requests.append(request)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://api.example.com/api/v1",
|
||||
params={"api-version": "2026-06-29"},
|
||||
cookies={"session": "abc123"},
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as client:
|
||||
provider = OpenAPIProvider(
|
||||
openapi_spec=path_param_spec,
|
||||
client=client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
mcp = FastMCP("Test")
|
||||
mcp.add_provider(provider)
|
||||
|
||||
async with Client(mcp) as mcp_client:
|
||||
await mcp_client.read_resource("resource://get_user/alice")
|
||||
|
||||
assert seen_requests[0].url == httpx.URL(
|
||||
"https://api.example.com/api/v1/users/alice?api-version=2026-06-29"
|
||||
)
|
||||
assert seen_requests[0].headers["cookie"] == "session=abc123"
|
||||
|
||||
async def test_resource_template_uses_mapped_path_argument_names(self):
|
||||
seen_urls: list[httpx.URL] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_urls.append(request.url)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
spec = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "User API", "version": "1.0.0"},
|
||||
"servers": [{"url": "https://api.example.com/api/v1"}],
|
||||
"paths": {
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"operationId": "get_user",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"name": {"type": "string"},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User data",
|
||||
"content": {
|
||||
"application/json": {"schema": {"type": "object"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://api.example.com/api/v1",
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as client:
|
||||
provider = OpenAPIProvider(
|
||||
openapi_spec=spec,
|
||||
client=client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
mcp = FastMCP("Test")
|
||||
mcp.add_provider(provider)
|
||||
|
||||
async with Client(mcp) as mcp_client:
|
||||
await mcp_client.read_resource("resource://get_user/abc")
|
||||
|
||||
assert seen_urls == [httpx.URL("https://api.example.com/api/v1/users/abc")]
|
||||
|
||||
async def test_resource_template_uses_path_arg_when_query_param_has_same_name(
|
||||
self,
|
||||
):
|
||||
seen_urls: list[httpx.URL] = []
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen_urls.append(request.url)
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
spec = {
|
||||
"openapi": "3.0.0",
|
||||
"info": {"title": "User API", "version": "1.0.0"},
|
||||
"servers": [{"url": "https://api.example.com/api/v1"}],
|
||||
"paths": {
|
||||
"/users/{id}": {
|
||||
"get": {
|
||||
"operationId": "get_user",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": True,
|
||||
"schema": {"type": "string"},
|
||||
},
|
||||
{
|
||||
"name": "id",
|
||||
"in": "query",
|
||||
"required": False,
|
||||
"schema": {"type": "string"},
|
||||
},
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "User data",
|
||||
"content": {
|
||||
"application/json": {"schema": {"type": "object"}}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
route_maps = [RouteMap(methods=["GET"], mcp_type=MCPType.RESOURCE_TEMPLATE)]
|
||||
async with httpx.AsyncClient(
|
||||
base_url="https://api.example.com/api/v1",
|
||||
transport=httpx.MockTransport(handler),
|
||||
) as client:
|
||||
provider = OpenAPIProvider(
|
||||
openapi_spec=spec,
|
||||
client=client,
|
||||
route_maps=route_maps,
|
||||
)
|
||||
mcp = FastMCP("Test")
|
||||
mcp.add_provider(provider)
|
||||
|
||||
async with Client(mcp) as mcp_client:
|
||||
await mcp_client.read_resource("resource://get_user/abc")
|
||||
|
||||
assert seen_urls == [httpx.URL("https://api.example.com/api/v1/users/abc")]
|
||||
|
||||
|
||||
class TestResourceMimeType:
|
||||
"""Test that OpenAPIResource uses inferred MIME types."""
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
|||
from typing import Annotated
|
||||
|
||||
from mcp_types import TextContent
|
||||
from prefab_ui.app import PrefabApp
|
||||
from prefab_ui.app import PROTOCOL_VERSION, PrefabApp
|
||||
from prefab_ui.components import Column, Heading, Text
|
||||
from prefab_ui.components.base import Component
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ class TestConvertResult:
|
|||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "[Rendered Prefab UI]"
|
||||
assert result.structured_content is not None
|
||||
assert result.structured_content["$prefab"]["version"] == "0.2"
|
||||
assert result.structured_content["$prefab"]["version"] == PROTOCOL_VERSION
|
||||
assert result.structured_content["state"] == {"name": "Alice"}
|
||||
# PrefabApp wraps view in a pf-app-root Div
|
||||
root = result.structured_content["view"]
|
||||
|
|
@ -55,7 +55,7 @@ class TestConvertResult:
|
|||
|
||||
assert isinstance(result, ToolResult)
|
||||
assert result.structured_content is not None
|
||||
assert result.structured_content["$prefab"]["version"] == "0.2"
|
||||
assert result.structured_content["$prefab"]["version"] == PROTOCOL_VERSION
|
||||
assert result.structured_content["view"]["type"] == "Div"
|
||||
assert result.structured_content["view"]["children"][0]["type"] == "Heading"
|
||||
|
||||
|
|
@ -71,7 +71,7 @@ class TestConvertResult:
|
|||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "Custom fallback text"
|
||||
assert result.structured_content is not None
|
||||
assert result.structured_content["$prefab"]["version"] == "0.2"
|
||||
assert result.structured_content["$prefab"]["version"] == PROTOCOL_VERSION
|
||||
assert result.structured_content["view"]["type"] == "Div"
|
||||
assert result.structured_content["view"]["children"][0]["type"] == "Heading"
|
||||
|
||||
|
|
@ -85,7 +85,7 @@ class TestConvertResult:
|
|||
assert isinstance(result.content[0], TextContent)
|
||||
assert result.content[0].text == "My text"
|
||||
assert result.structured_content is not None
|
||||
assert result.structured_content["$prefab"]["version"] == "0.2"
|
||||
assert result.structured_content["$prefab"]["version"] == PROTOCOL_VERSION
|
||||
assert result.structured_content["view"]["type"] == "Div"
|
||||
assert result.structured_content["view"]["children"][0]["type"] == "Heading"
|
||||
|
||||
|
|
@ -391,7 +391,7 @@ class TestIntegration:
|
|||
result = await client.call_tool("greet", {"name": "Alice"})
|
||||
|
||||
assert result.structured_content is not None
|
||||
assert result.structured_content["$prefab"]["version"] == "0.2"
|
||||
assert result.structured_content["$prefab"]["version"] == PROTOCOL_VERSION
|
||||
assert result.structured_content["state"] == {"name": "Alice"}
|
||||
|
||||
async def test_tool_call_with_custom_text(self):
|
||||
|
|
@ -412,7 +412,7 @@ class TestIntegration:
|
|||
"Greeting for Alice" in c.text for c in result.content if hasattr(c, "text")
|
||||
)
|
||||
assert result.structured_content is not None
|
||||
assert result.structured_content["$prefab"]["version"] == "0.2"
|
||||
assert result.structured_content["$prefab"]["version"] == PROTOCOL_VERSION
|
||||
|
||||
async def test_tools_list_includes_app_meta(self):
|
||||
mcp = FastMCP("test")
|
||||
|
|
|
|||
|
|
@ -102,6 +102,47 @@ class TestSchemaGeneration:
|
|||
assert param_map["id"]["location"] == "body"
|
||||
assert param_map["name"]["location"] == "body"
|
||||
|
||||
def test_non_body_parameter_collision_handling(self):
|
||||
"""Test that same-named parameters in different locations use suffixes."""
|
||||
route = HTTPRoute(
|
||||
method="GET",
|
||||
path="/users/{id}",
|
||||
operation_id="get_user",
|
||||
parameters=[
|
||||
ParameterInfo(
|
||||
name="id",
|
||||
location="path",
|
||||
required=True,
|
||||
schema={"type": "string"},
|
||||
),
|
||||
ParameterInfo(
|
||||
name="id",
|
||||
location="query",
|
||||
required=False,
|
||||
schema={"type": "string"},
|
||||
),
|
||||
ParameterInfo(
|
||||
name="id",
|
||||
location="header",
|
||||
required=False,
|
||||
schema={"type": "string"},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
schema, param_map = _combine_schemas_and_map_params(route)
|
||||
|
||||
properties = schema["properties"]
|
||||
assert "id" not in properties
|
||||
assert "id__path" in properties
|
||||
assert "id__query" in properties
|
||||
assert "id__header" in properties
|
||||
|
||||
assert schema["required"] == ["id__path"]
|
||||
assert param_map["id__path"] == {"location": "path", "openapi_name": "id"}
|
||||
assert param_map["id__query"] == {"location": "query", "openapi_name": "id"}
|
||||
assert param_map["id__header"] == {"location": "header", "openapi_name": "id"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"param_type",
|
||||
[
|
||||
|
|
|
|||
46
uv.lock
generated
46
uv.lock
generated
|
|
@ -938,7 +938,7 @@ dev = [
|
|||
{ name = "pytest-timeout", specifier = ">=2.4.0" },
|
||||
{ name = "pytest-xdist", specifier = ">=3.6.1" },
|
||||
{ name = "ruff", specifier = ">=0.12.8" },
|
||||
{ name = "ty", specifier = ">=0.0.39" },
|
||||
{ name = "ty", specifier = ">=0.0.55" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -1549,14 +1549,14 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "joserfc"
|
||||
version = "1.6.7"
|
||||
version = "1.6.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/cb/52e479f20804904f5df20ac4539d292dcecd1287aaa33cba1d1def1d9d8e/joserfc-1.6.7.tar.gz", hash = "sha256:6999fe89457069ecacd8cc797c88a805f83054dd883333fa0409f74b46479fd7", size = 232158, upload-time = "2026-05-23T01:46:44.069Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5d/ac/d4fd5b30f82900eac60d765f179f0ba005825ac462cc8ced6e13ec685ab3/joserfc-1.6.8.tar.gz", hash = "sha256:878620c553a6ebdd76ccdc356782fee3f735f21a356d079a546b42a4670ace5f", size = 232930, upload-time = "2026-05-27T03:22:37.819Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/e4/bcf6718b5662894c6831f46296b73cd4b1a2e90c20b6d437e20c4997388c/joserfc-1.6.7-py3-none-any.whl", hash = "sha256:9e51e4a64840aa1734a058258e80a4480e2ff2d5686e480e7c92c954a92fbe05", size = 70603, upload-time = "2026-05-23T01:46:42.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/8c/5cdce2cf3ce8155849baf9a5e2ce77e89dc87ec3bdb38259e5d85fbc45bd/joserfc-1.6.8-py3-none-any.whl", hash = "sha256:22fb31a69094a5e6f44632002a9df2c30c941fc6c8ce1b037e92c03de954cf9f", size = 70927, upload-time = "2026-05-27T03:22:35.796Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -3177,27 +3177,27 @@ wheels = [
|
|||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.39"
|
||||
version = "0.0.55"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/8d/7b5c74dc287fbcb37bae9853cec13bf44717c1735298500e4aeba31579a9/ty-0.0.39.tar.gz", hash = "sha256:f750277e76a01ecd86185960eca73823c26a53c51103568d56d4d904575159fd", size = 5702365, upload-time = "2026-05-22T21:09:56.403Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/48/f687c8d268e3581f2f104d1f2ac5944d5b5e841b3695c613b3f263e5bbf7/ty-0.0.55.tar.gz", hash = "sha256:88ca87073825a79a8327c550efcc86cec94344890244c5946f84c9e44a969f31", size = 6040230, upload-time = "2026-06-27T00:27:29.385Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/08/17/9b89802c26d12d0f7a27bc25d4066d941d42891e8898f9f26499f0067e32/ty-0.0.39-py3-none-linux_armv6l.whl", hash = "sha256:c1bb7ac70f1f7d70cc6655fd96558039e4562b10f489fa49c7ebfd5fcee73ad1", size = 11360431, upload-time = "2026-05-22T21:09:18.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/c6/663ded50e823dbf9fb9d002eca46b7cb1fb2c72b744b84f22ce732a0ee0b/ty-0.0.39-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3435b64c1e59c14c9aa39c20cc018823937cd38d55db853e74d95b8f420569b0", size = 11096281, upload-time = "2026-05-22T21:09:15.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ae/5d38ba9a6456ff4c78d212cf464fd8b9a25d8118465197b0b2dc891c0b19/ty-0.0.39-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5f136377ce46c73677701a9e1ad730bf72f699bcec046e422eb79d0886cac3ab", size = 10529674, upload-time = "2026-05-22T21:09:46.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/6f/43638cb8106445d3c8817256a0731cde9dd7b6a53ae2e881294bc1930ca3/ty-0.0.39-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36b65fb0cc17f03e851d40e210d420be94ab8bc52d041328ad1e45f616036a61", size = 11055561, upload-time = "2026-05-22T21:09:36.981Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/17/95e62cf4458527ce78dc386eba18f8b10c3fb64cd8c9e7e59b262ff6029d/ty-0.0.39-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4967967bfadf3860ff84c3fccdbaec8edf8aa20d0d727521084733d853de6657", size = 11127185, upload-time = "2026-05-22T21:09:31.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/c0/93666c213db5c71ab1b1f1a0db5f66bf8c7c0e0b0bf59859f5da8f0b3c36/ty-0.0.39-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e10ecb1297099ddf9a1f054f8bd921d1863ce85fb819a3c96ed27865a1ba6ed", size = 11608459, upload-time = "2026-05-22T21:09:12.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/85/3b26585afc8b50230d6464bb0642feef4fab3f847e38b1f0ffa971a81446/ty-0.0.39-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9b19cca70e465d71b0510656343883d62372bbe74b7845cae7c0e701d6d5264b", size = 12177101, upload-time = "2026-05-22T21:09:40.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/4a/1039e4f6afc576dc1c3a4d22a6478904a1ad3766597cd0b93c077ab9dfce/ty-0.0.39-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:56c6704b01b9b3d80ff26b2918423b742516d1e469bef830e9254dcedc9185bf", size = 11827815, upload-time = "2026-05-22T21:09:49.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/c5/4688652870e350a76a8157f7ffb59ad54f37d5d10725aa7076f66ac94ec8/ty-0.0.39-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40b7840ff46764b6a6757f4ade1cd0530fc3e8a0b435ca93e7602360e4cb90b6", size = 11694429, upload-time = "2026-05-22T21:09:21.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/72/8a1c4e823bb5bdc935a1c8140e100304e36a68a4139592f170aa9736fdb7/ty-0.0.39-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1c62a3a87ce26b50819f0dbf03bd95f23f19eeb87bbc7aa732ec64277c77f1aa", size = 11869846, upload-time = "2026-05-22T21:09:28.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/9f/cf982457b861ae22d657c5dcdbc631199f7f90264279db1d17230dfbc3ff/ty-0.0.39-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f8c34bc81a9c3516e49904e9d8330aac385377cca98390193ea02b903a40fcf0", size = 11029763, upload-time = "2026-05-22T21:09:06.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/c9/95b64f6d43ae6e8f0b7e13dacf9c196d35819af22b1924171fba31383156/ty-0.0.39-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:66f5ab11586a64e79cb692ad685ee5469325c31b5f30bd3554f52f36dbe28cc4", size = 11146761, upload-time = "2026-05-22T21:09:10.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/69/0a89cfb06f7632a05bf56c78e0affb4a40f81759e275376cea75c9c5abe9/ty-0.0.39-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e8d89732bcbbcb091f439e556dfc4932f198b118b47d5b85212c60662099670e", size = 11281843, upload-time = "2026-05-22T21:09:34.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/53/64c4a27067a46643fea2b3fcf21a8a2f838d91a65ffdd14f2e82945b9538/ty-0.0.39-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:eceb6c91dcd05a231119f82abdd9aa337513de23ca6ac990bc44f88791dc1799", size = 11792477, upload-time = "2026-05-22T21:09:24.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/e8/02f4dd4a12bcdbda0006f9c7ff3b99a4be06bd0d257d3bd4a5b66de074e6/ty-0.0.39-py3-none-win32.whl", hash = "sha256:891c3262314dbc80bf3e872634d23dd216306945daa9a9fcc206ce5ed21ac4c9", size = 10615377, upload-time = "2026-05-22T21:09:43.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/5a/aaeb22faa8d4dae90a287d4c3636c671edcff3b99be5f4fc8b79ad71eef6/ty-0.0.39-py3-none-win_amd64.whl", hash = "sha256:ba7f2d54452535419e90f6f03ff39282999e87b43c21c00559f6d7ad711a36d5", size = 11710711, upload-time = "2026-05-22T21:09:53.179Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/17/ae7339651bfcaa5f54698c8c70eaf5031baa400ecb67baec31d03a56cbd4/ty-0.0.39-py3-none-win_arm64.whl", hash = "sha256:eb4cf0fefbbfedf9a352597bb2431ebdcb7eb3a595c0f825f228e897a0ec285d", size = 11081409, upload-time = "2026-05-22T21:09:03.741Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/a3/1a90ba7e5a61c6d09adb92346ddba97668095fc257b577af433e5ac4f404/ty-0.0.55-py3-none-linux_armv6l.whl", hash = "sha256:31e83eef512d066542fe990fe1a3b814423abd1616376c54e48af7045b3e1749", size = 11677249, upload-time = "2026-06-27T00:26:52.18Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/3a/669f9aa478c38243e213a2684db1502086026cfadc15bb1b29b7cbde030d/ty-0.0.55-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ab4bca857950608fea73e269e2da369d43e6467131de85160d68e2fa466fa248", size = 11444180, upload-time = "2026-06-27T00:26:54.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/a4/6a4b2507a53ce6530c66c5b4fe0d58551eb1748ffa9e0696c32fdd55bbd4/ty-0.0.55-py3-none-macosx_11_0_arm64.whl", hash = "sha256:55032bfd31bf2c5355ee81bdc6407b144a1cc7ee41e5681dd1368e4cef2ba327", size = 10963134, upload-time = "2026-06-27T00:26:57.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/ae/a3b1a0f1cc83b7d258662cb98aa80a720c2e671d0e8fa0d17a4d5d057a7a/ty-0.0.55-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1e049f69ce65b3c269af67624607f435e1c32319786c1e453ef9611502f295", size = 11493517, upload-time = "2026-06-27T00:26:59.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/9f/311ce39065a979ef40a9b847f685c8e02464e53adf1671e081eea90640ca/ty-0.0.55-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:631409975c681d5a280fc5a99b7b32e9e801f33be7567c6b42ec331362f59d7d", size = 11460590, upload-time = "2026-06-27T00:27:01.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8f/3bf29aa77bd78aae48275153135a2052fa7d3ccdf1ecabeb99c8773abd66/ty-0.0.55-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e08cb0436e68b9351555ae8f2697138c9009b4d5b4ae4272232988b2a431a98f", size = 12098430, upload-time = "2026-06-27T00:27:03.596Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/6e/e88411a88240b94640bba06fb6d0d92b247fbeef47ee2bc71f39e58c2558/ty-0.0.55-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:16c215ad9f823829409b94ee188cfaa4563f6e1384f6ce3fecb1db75f6c7cf7c", size = 12673086, upload-time = "2026-06-27T00:27:05.589Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/7e/8f1762fb7f9245a68ba5ae338d73c59403ce57554e5d311b8bb55027b0ec/ty-0.0.55-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b510eb8f4032baf11b7aee2f1d53babc3b4ca03939b9cdcf6a9d15761d575188", size = 12242559, upload-time = "2026-06-27T00:27:07.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/1f/143657daf2670d977dac83435f1fe03d4843efb798d8e1e75950e541aadd/ty-0.0.55-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0ddc05e7959709c3b9b83aa627128a80446865e3c1a4882638dcff6d776dc34a", size = 12021409, upload-time = "2026-06-27T00:27:09.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/30/69487c439dd1fad3a4a3d96f0a472193de297eaba6fc4b8ea687ce434ac2/ty-0.0.55-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:636e8e5078787b8c6916c94e1406719f10189a4ca6b37b813a5922ce5857a8c7", size = 12303807, upload-time = "2026-06-27T00:27:11.986Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/ca/cd88b6493dafc7db077f5e17c0438eb3af6e2d6d08f616dbb52a8ddfd567/ty-0.0.55-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ef7d6deaacb73fec603666b5471f1dc5a5699aa84e11a6d4d644dd07ca72121e", size = 11441263, upload-time = "2026-06-27T00:27:14.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/fe/66b6915671653ab739f71e4f1b0528e69da64429b7ebf3840c625b6e43f2/ty-0.0.55-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:9aeea0fe5875d3cf37faf0e44d0fdf9669335467749741b8fc0103916fb5cd32", size = 11484584, upload-time = "2026-06-27T00:27:16.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/4f/7a9c0bbac8b899e9f6c0ec110c6612f52e4db35f6bb17ddc0ef60384fa3e/ty-0.0.55-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0b699c01310dbd2705a07c97c5f4aaeedef61bd9adeea2e7c46aed32401d3576", size = 11759309, upload-time = "2026-06-27T00:27:18.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ca/de/b6f8b1b69aa631b5716ef3f985c3b56de0e46c2499cc00d30c402b41f714/ty-0.0.55-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:32cbeba543e46de2a983ec6d525d8b56514f7422bd1e1b57c44ccf7bfa72c38a", size = 12128755, upload-time = "2026-06-27T00:27:20.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/90/a912531e51ee7e076b42972479290fa687c0f5e747b7e773f3033164acaa/ty-0.0.55-py3-none-win32.whl", hash = "sha256:52b968e24eb4f7a5c3bd251db1f99f60dd385890356d38fc619d84f1b423446a", size = 11117501, upload-time = "2026-06-27T00:27:22.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/7a/99d59843bf8908a7f9f4d13fda107dbad07b7faa28ecd7860eacf363fb1c/ty-0.0.55-py3-none-win_amd64.whl", hash = "sha256:bf39cbfdc0add44d94bd3fff1f53c351418d134b6a66b87efdb7876d7b7a2224", size = 12150106, upload-time = "2026-06-27T00:27:24.881Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/44/20987505cedf2a865b08482f0eabc181fd9599b062964057ec8a128a4296/ty-0.0.55-py3-none-win_arm64.whl", hash = "sha256:f7f3700a9a060e8f1af11e4fb63fafcaf272b041781f4ccdfda2b3b5c6c1e439", size = 11560157, upload-time = "2026-06-27T00:27:27.332Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue