type + more codex docs

This commit is contained in:
lda
2026-05-15 22:43:41 +07:00 Verified
parent 511075f8a3
commit 3f83afc100
6 changed files with 488 additions and 31 deletions
-18
View File
@@ -1,24 +1,6 @@
# talk w/ me
help me understand. i am tired, be clear
# pitfalls # pitfalls
no: assert dict == dict no: assert dict == dict
yes: assert dict['field'] == dict['field'] unless we know better yes: assert dict['field'] == dict['field'] unless we know better
more later more later
# when you move through code, add docstrings / comment weird/complicated logic
# use available MCP tools/skills
- serena mcp: symbol discovery. Has some of LSP stuff
- context7: docs
- skills: outside of workspace, request commands. Use when appropiate
# docs/ pair with `superpowers` and other skills
superpowers, a Codex-cli plugin, is a set of skills. plugin provided skills live in nonstandard paths. check paths given by Codex
if not found, might be stale hash, try find new hash
+178
View File
@@ -0,0 +1,178 @@
# FastMCP Proxy ResourceLink Issue Guide
Use this as a copy/rewrite starting point for an upstream FastMCP issue. Keep
the code blocks and concrete examples; rewrite the prose however you want.
## Suggested Title
```text
Namespace transform rewrites listed resource URIs but not ResourceLink URIs returned from proxied tools
```
## Short Summary
```text
When a FastMCP proxy has a Namespace transform, listed resources and resource
templates are correctly namespaced, and namespaced resource reads work.
However, ResourceLink content returned inside a proxied tools/call result keeps
the raw upstream URI instead of the downstream namespaced URI.
```
## Why This Looks Like A Proxy Bug
```text
The proxy is already doing the hard part correctly for normal resource surfaces:
- resources/list returns namespaced URIs
- resource_templates/list returns namespaced URI templates
- resources/read accepts the namespaced URI and reverses it upstream
Only ResourceLink content inside tools/call results escapes with the upstream
URI unchanged. That makes the returned link unusable from the downstream client
even though the same resource is available through the proxy under its
namespaced URI.
```
## Minimal Example
```python
from fastmcp.server import create_proxy
from fastmcp.server.transforms import Namespace
proxy = create_proxy(upstream_client, name="Proxy-everything.default")
proxy.add_transform(Namespace("everything.default"))
```
Given an upstream resource:
```text
demo://resource/dynamic/text/2
```
The proxy correctly exposes it as:
```text
demo://everything.default/resource/dynamic/text/2
```
But if an upstream tool returns:
```python
mcp.types.ResourceLink(
type="resource_link",
name="dynamic-text",
uri="demo://resource/dynamic/text/2",
)
```
the proxied tool result still contains:
```text
demo://resource/dynamic/text/2
```
instead of:
```text
demo://everything.default/resource/dynamic/text/2
```
## Observed Behavior
```text
resources/list:
upstream: demo://resource/dynamic/text/2
proxied: demo://everything.default/resource/dynamic/text/2
resources/read:
demo://everything.default/resource/dynamic/text/2
-> works
tools/call result content:
ResourceLink.uri == demo://resource/dynamic/text/2
-> raw upstream URI leaks through unchanged
```
## Expected Behavior
```text
If a proxy transform rewrites a resource URI for resources/list and
resources/read, ResourceLink content emitted by proxied tools should expose the
same downstream-facing URI.
```
## Relevant FastMCP Code Paths
`Namespace` already owns the URI mapping:
```python
class Namespace(Transform):
async def list_resources(...):
...
async def get_resource(...):
...
```
`ProxyTool.run(...)` currently returns upstream tool content unchanged:
```python
result = await client.call_tool_mcp(
name=backend_name,
arguments=arguments,
meta=meta,
)
return ToolResult(
content=result.content,
structured_content=result.structuredContent,
meta=result.meta,
)
```
FastMCP's `Transform` base class has hooks for listing/getting tools,
resources, templates, and prompts, but there does not appear to be a hook for
transforming tool results.
## Suggested Direction
```text
Prefer a general result-transform hook over special-casing Namespace logic in
ProxyTool.
```
For example, something in this shape:
```python
class Transform:
async def tool_result(self, result: ToolResult) -> ToolResult:
return result
```
Then `Namespace` could rewrite only typed MCP resource-link content:
```python
if isinstance(content, mcp.types.ResourceLink):
content = content.model_copy(
update={"uri": self._transform_uri(str(content.uri))}
)
```
That would keep URI rewriting logic in `Namespace`, where the forward and
reverse resource URI mapping already lives.
## Important Boundary
```text
This is specifically about mcp.types.ResourceLink content inside tools/call
results. Normal resources/list, resource_templates/list, and resources/read
already behave correctly through the proxy.
```
## Extra Note
```text
Session-scoped resources returned by some tools may have additional lifecycle
constraints beyond URI rewriting. This issue is about ordinary ResourceLink URI
projection only.
```
@@ -0,0 +1,81 @@
---
title: Namespace transform does not rewrite ResourceLink URIs returned from proxied tools
---
## summary
With a `Namespace` transform on a proxy, listed resources are namespaced
correctly.
However, `ResourceLink.uri` values returned from a proxied
`tools/call` result keep the upstream URI instead of the proxied URI.
Tested on FastMCP `3.3.0`.
## minimal example
```python
from fastmcp.server import create_proxy
from fastmcp.server.transforms import Namespace
proxy = create_proxy(upstream_client, name="Proxy-everything")
proxy.add_transform(Namespace("everything"))
```
An upstream resource looking like this:
```text
demo://resource/dynamic/text/2
```
will be exposed correctly as:
```text
demo://everything/resource/dynamic/text/2
```
But if an upstream tool returns:
```python
mcp.types.ResourceLink(
type="resource_link",
name="dynamic-text",
uri="demo://resource/dynamic/text/2",
)
```
the proxied tool result still contains:
```text
demo://resource/dynamic/text/2
```
## Observed Behavior
```text
resources/list:
upstream: demo://resource/dynamic/text/2
proxied: demo://everything/resource/dynamic/text/2
resources/read:
demo://everything/resource/dynamic/text/2
-> works
tools/call result content:
ResourceLink.uri == demo://resource/dynamic/text/2
-> raw upstream URI leaks through unchanged
```
## expected behavior
`ResourceLink` content returned by proxied tools should expose the same proxied
URI as `resources/list` and `resources/read`.
## scope of this issue
This is specifically about `mcp.types.ResourceLink` content inside `tools/call`
results. Normal `resources/list`, `resource_templates/list`, and
`resources/read` already behave correctly through the proxy.
Session-scoped resources returned by some tools may have additional lifecycle
constraints beyond URI rewriting. This issue is about ordinary ResourceLink URI
projection only.
@@ -0,0 +1,187 @@
# Proxy Mount Lifecycle Design
## Goal
Reduce avoidable proxy churn during `ProxyRuntime.reload()` without pretending
that FastMCP's missing unmount lifecycle is solved.
The next step is not "fully correct hot reload." The next step is to introduce a
small mount registry that can reuse unchanged enabled upstream proxy mounts
across reloads, while keeping the current best-effort remount behavior for
changed or removed connections.
## Current State
`ProxyRuntime.reload()` currently:
1. reloads config
2. resets the server provider list to `local_provider`
3. recreates the admin mount
4. recreates a `Client`, `FastMCPProxy`, and `Namespace(connection.id)` mount for
every enabled connection
This is simple and currently works, but it churns every upstream proxy mount on
every reload even when a connection is unchanged.
FastMCP does not currently expose a complete official mount/unmount lifecycle
that we can rely on for safe per-connection teardown. The codebase should keep
treating reload as best-effort until that exists.
## Non-Goals
- Do not implement a custom general-purpose unmount system.
- Do not claim long-lived subscriptions survive reload.
- Do not wire proxy-result `ResourceLink` rewriting into runtime as part of this
work.
- Do not replace FastMCP's own proxy/provider internals.
- Do not change public MCP naming or config formats.
## Recommended Design
Add a dedicated module:
```text
src/wf_mcp/transparent_proxy/mounts.py
```
with two internal models:
```python
ProxyMount
ProxyMountRegistry
```
### `ProxyMount`
Owns the mount objects created for one enabled connection:
- `connection_id`
- a stable config fingerprint
- the FastMCP proxy object returned by `create_proxy(...)`
- any future lifecycle metadata we need
It should not initially promise ownership of client shutdown. FastMCP owns too
much of that behavior today through its proxy/client factory internals.
### `ProxyMountRegistry`
Owns reusable mounts keyed by `connection_id`.
Minimal responsibilities:
- return an existing mount when the enabled connection fingerprint is unchanged
- create a new mount when the connection is new or materially changed
- report which cached mounts are no longer active after a reload
- keep the stale/retired concept explicit so later cleanup can be added in one
place when FastMCP exposes a safe lifecycle API
Initial API shape:
```python
class ProxyMountRegistry:
def get_or_create(self, connection: ConnectionConfig, *, store_root: Path) -> ProxyMount:
...
def active_mounts_for(self, config: BrokerConfig) -> list[ProxyMount]:
...
def retired_connection_ids(self, active_connection_ids: set[str]) -> set[str]:
...
```
The exact API can shrink during implementation; the boundary is more important
than these method names.
## Reload Flow
Target flow:
```text
reload config
validate config
reset mounted providers to local_provider
mount admin surface
ask ProxyMountRegistry for active enabled mounts
mount each active proxy
publish reload events
return ProxyReloadResult
```
For an unchanged enabled connection, the same proxy mount object should be
reused across reloads.
For a changed connection, create a new mount using the new config fingerprint.
For a disabled or removed connection, stop mounting it and mark its cached mount
retired. Do not invent unsafe teardown yet.
## Fingerprint
Reuse should be based on the connection fields that affect upstream transport or
identity:
- `id`
- `server`
- `account`
- `enabled`
- `metadata`
The fingerprint must be deterministic and easy to test. It does not need to be a
cryptographic API contract; it is an internal reuse key.
## Why Not Just A Dict In `reload()`
A raw dictionary inside `ProxyRuntime.reload()` would work for the first happy
path, but it would bury lifecycle decisions in the loop:
- when a cached proxy is still valid
- when changed config invalidates it
- what "retired" means
- where future close/unmount logic belongs
That is exactly the logic likely to grow once FastMCP exposes a real lifecycle
API. A small registry keeps the future deletion/replacement localized.
## Relationship To FastMCP
FastMCP already gives us:
- `create_proxy(...)`
- `ProxyProvider`
- component-list caching
- forwarded advanced protocol behavior
We should keep using those pieces. This work should not subclass or monkeypatch
FastMCP.
If FastMCP later ships official dynamic provider unmount or proxy lifecycle
support, the registry should become thinner or disappear.
## Testing
Add focused tests before implementation:
1. unchanged enabled connection reuses the same proxy mount across reloads
2. changed connection metadata produces a new mount
3. disabled or removed connection is not remounted
4. reload public payload remains unchanged
5. existing list-changed event/notification behavior remains unchanged
Do not write tests that imply retired mounts are safely closed until that is
actually implemented.
## Deferred Work
- safe close/unmount of retired mounts
- preserving long-lived subscriptions across reload
- forwarding upstream notifications through mount lifecycle changes
- wiring `wf_mcp.proxy_results` helpers into real tool-result handling
- replacing FastMCP behavior that should be fixed upstream instead
## Open Question
Whether reused mounts should keep FastMCP `ProxyProvider` component caches across
reload is not yet a promise. Reuse likely does preserve those caches. If that
causes stale catalog behavior, the registry should either invalidate the provider
cache explicitly when supported or treat reload as a reason to rebuild mounts
for affected connections.
+5 -3
View File
@@ -4,6 +4,8 @@ from collections.abc import Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from fastmcp.tools import Tool
from ..shared.names import is_admin_tool_name, parse_namespaced_tool_name from ..shared.names import is_admin_tool_name, parse_namespaced_tool_name
from ..shared.pagination import paginate_items from ..shared.pagination import paginate_items
@@ -61,7 +63,7 @@ def proxy_tool_payload(
proxy_name: str, proxy_name: str,
connection_id: str, connection_id: str,
local_name: str, local_name: str,
tool: Any, tool: Tool,
include_schema: bool, include_schema: bool,
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return the admin-facing metadata payload for one proxied tool.""" """Return the admin-facing metadata payload for one proxied tool."""
@@ -82,7 +84,7 @@ def proxy_tool_payload(
def collect_proxy_tools( def collect_proxy_tools(
*, *,
tools: Sequence[Any], tools: Sequence[Tool],
connection_ids: set[str], connection_ids: set[str],
) -> list[ProxyToolPayload]: ) -> list[ProxyToolPayload]:
"""Collect visible upstream tool metadata from FastMCP's listed tools.""" """Collect visible upstream tool metadata from FastMCP's listed tools."""
@@ -113,7 +115,7 @@ def collect_proxy_tools(
def collect_proxy_tool_payloads( def collect_proxy_tool_payloads(
*, *,
tools: Sequence[Any], tools: Sequence[Tool],
connection_ids: set[str], connection_ids: set[str],
include_schema: bool, include_schema: bool,
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
Generated
+37 -10
View File
@@ -279,9 +279,43 @@ wheels = [
[[package]] [[package]]
name = "fastmcp" name = "fastmcp"
version = "3.2.4" version = "3.3.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "fastmcp-slim", extra = ["client", "server"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/1a/b8/aff9378edc9438916ce5f06ac31bc1d60dd99e1a82411e186f1c38e20a8b/fastmcp-3.3.0.tar.gz", hash = "sha256:48c7fffdb6865cb9658ac02c2ff589caaaa3cd68e2cc37ed51402fa46e46c2eb", size = 28804871, upload-time = "2026-05-15T02:04:59.357Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/bd/4f21d58764e1f1e1237e29bc58bb1b5863fde2297796c8e96301d334fe2d/fastmcp-3.3.0-py3-none-any.whl", hash = "sha256:e6b1dc391a9fcc4b6a7f0bb7c2481d1aac8d03eefe818908c69909795a39d51b", size = 7903, upload-time = "2026-05-15T02:05:02.24Z" },
]
[[package]]
name = "fastmcp-slim"
version = "3.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "platformdirs" },
{ name = "pydantic", extra = ["email"] },
{ name = "pydantic-settings" },
{ name = "python-dotenv" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/db/6e/f4ba7d4f5d586ee85b7e2dd4feff89112de0e4cedc6a5fda794adb6b99c2/fastmcp_slim-3.3.0.tar.gz", hash = "sha256:56fd0077226b8dbf0bba253f9baaad5d97a3f74eb62750d6997128486b91b0ba", size = 567972, upload-time = "2026-05-15T02:04:34.015Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/96/36/fa34dd253426e03e4f68918ca700758a22f3600fb6d4dc5a260b3d152c82/fastmcp_slim-3.3.0-py3-none-any.whl", hash = "sha256:7478c5220e06e5ff4f9cb5270e46c6b6d0f44ec0d79372a572b0a71195baa69a", size = 739366, upload-time = "2026-05-15T02:04:32.528Z" },
]
[package.optional-dependencies]
client = [
{ name = "authlib" },
{ name = "exceptiongroup" },
{ name = "httpx" },
{ name = "mcp" },
{ name = "opentelemetry-api" },
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
]
server = [
{ name = "authlib" }, { name = "authlib" },
{ name = "cyclopts" }, { name = "cyclopts" },
{ name = "exceptiongroup" }, { name = "exceptiongroup" },
@@ -293,22 +327,15 @@ dependencies = [
{ name = "openapi-pydantic" }, { name = "openapi-pydantic" },
{ name = "opentelemetry-api" }, { name = "opentelemetry-api" },
{ name = "packaging" }, { name = "packaging" },
{ name = "platformdirs" },
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
{ name = "pydantic", extra = ["email"] },
{ name = "pyperclip" }, { name = "pyperclip" },
{ name = "python-dotenv" }, { name = "python-multipart" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "rich" },
{ name = "uncalled-for" }, { name = "uncalled-for" },
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "watchfiles" }, { name = "watchfiles" },
{ name = "websockets" }, { name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/9c/13/29544fbc6dfe45ea38046af0067311e0bad7acc7d1f2ad38bb08f2409fe2/fastmcp-3.2.4.tar.gz", hash = "sha256:083ecb75b44a4169e7fc0f632f94b781bdb0ff877c6b35b9877cbb566fd4d4d1", size = 28746127, upload-time = "2026-04-14T01:42:24.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/76/b310d52fa0e30d39bd937eb58ec2c1f1ea1b5f519f0575e9dd9612f01deb/fastmcp-3.2.4-py3-none-any.whl", hash = "sha256:e6c9c429171041455e47ab94bb3f83c4657622a0ec28922f6940053959bd58a9", size = 728599, upload-time = "2026-04-14T01:42:26.85Z" },
]
[[package]] [[package]]
name = "griffelib" name = "griffelib"
@@ -535,7 +562,7 @@ dev = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastmcp", specifier = ">=3.2.4" }, { name = "fastmcp", specifier = ">=3.2.4" },
{ name = "jsonschema", specifier = ">=4.26.0" }, { name = "jsonschema", specifier = ">=4.26" },
{ name = "mcp", extras = ["cli", "rich"], specifier = ">=1" }, { name = "mcp", extras = ["cli", "rich"], specifier = ">=1" },
{ name = "pydantic", specifier = ">=2" }, { name = "pydantic", specifier = ">=2" },
] ]