Skip to content

Comprehensive MCP Apps docs, string CallTool resolution#3575

Merged
jlowin merged 3 commits intomainfrom
apps-docs-and-string-calltool
Mar 21, 2026
Merged

Comprehensive MCP Apps docs, string CallTool resolution#3575
jlowin merged 3 commits intomainfrom
apps-docs-and-string-calltool

Conversation

@jlowin
Copy link
Copy Markdown
Member

@jlowin jlowin commented Mar 21, 2026

The apps documentation was thin reference material — enough for someone who already understood the feature, but not for someone learning it. This rewrites it as a proper learning path: conceptual overview → Prefab apps (with Rx, state, reactivity) → FastMCPApp (composable apps with managed tool binding) → patterns → dev tools → custom HTML escape hatch.

The big addition is the FastMCPApp page, which documents the composable app pattern end-to-end: @app.tool(), @app.ui(), CallTool, forms, actions, visibility, and composition safety with global keys.

On the code side, CallTool("save_contact") now resolves to the tool's global key just like CallTool(save_contact) does. A new _NAME_TO_GLOBAL_KEY registry maps registered tool names to their global keys, and the resolver handles both strings and callables. This requires prefab-ui ≥0.13.0 which passes strings through the tool resolver.

app = FastMCPApp("Contacts")

@app.tool()
def save_contact(name: str) -> str:
    return name

@app.ui()
def contact_manager() -> PrefabApp:
    with Column() as view:
        # Both forms work identically — string is simpler
        Button("Save", on_click=CallTool("save_contact"))
    return PrefabApp(view=view)

…ui >=0.13.0

Rewrites the apps documentation as a learning journey: overview → Prefab apps
→ FastMCPApp → patterns → dev tools → custom HTML. Adds a new FastMCPApp page
covering composable apps with @app.tool()/@app.ui(), CallTool, forms, actions,
and composition. Teaches Rx() and set_initial_state() as the primary state API.

Adds string-based CallTool resolution so CallTool("save_contact") resolves to
the tool's global key, matching callable ref behavior. Requires prefab-ui 0.13.0
which passes strings through the tool resolver.
@marvin-context-protocol marvin-context-protocol Bot added enhancement Improvement to existing functionality. For issues and smaller PR improvements. mcp apps Related to MCP Apps - user-facing applications with frontend bundles served by MCP servers. labels Mar 21, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 02d0780a9c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/fastmcp/server/app.py
Comment on lines +119 to +121
global_key = _NAME_TO_GLOBAL_KEY.get(fn)
if global_key is not None:
tool = _APP_TOOL_REGISTRY.get(global_key)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope string CallTool resolution to the current app

This new global lookup rewrites every CallTool("save") in the process to a single _NAME_TO_GLOBAL_KEY entry, but that registry is populated by plain tool.name (src/fastmcp/server/app.py:84 and :440). If two FastMCPApp providers both define a backend tool named save—or a regular @mcp.tool(app=True) uses CallTool("save") while some unrelated app backend also uses that name—the later registration wins and the UI will invoke the wrong backend tool. That breaks the main composition/namespacing use case this change is trying to support.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c969954ca1

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/fastmcp/server/app.py
Comment on lines +120 to +136
if isinstance(fn, str):
global_keys = _NAME_TO_GLOBAL_KEY.get(fn)
if global_keys is not None:
if len(global_keys) > 1:
raise ValueError(
f"Ambiguous tool name {fn!r}: registered by multiple apps. "
f"Use CallTool(fn) with a function reference to disambiguate."
)
global_key = global_keys[0]
tool = _APP_TOOL_REGISTRY.get(global_key)
unwrap = bool(
tool is not None
and tool.output_schema
and tool.output_schema.get("x-fastmcp-wrap-result")
)
return ResolvedTool(name=global_key, unwrap_result=unwrap)
return ResolvedTool(name=fn)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Don't hijack plain string CallTool references globally

Fresh evidence in this revision: _resolve_tool_ref now consults _NAME_TO_GLOBAL_KEY before falling back to the original string, and src/fastmcp/tools/base.py:495-497 installs that resolver for every Prefab UI, not just FastMCPApp. In a mixed process, a regular @mcp.tool(app=True) view that does CallTool("save") will now be rewritten to an unrelated FastMCPApp backend named save, so the UI silently calls the wrong server tool whenever a matching app backend exists.

Useful? React with 👍 / 👎.

Comment thread src/fastmcp/server/app.py Outdated
Comment on lines +82 to +86
def _register_global_key(tool: Tool, fn: Any, global_key: str) -> None:
"""Register a tool in both process-level registries."""
"""Register a tool in all process-level registries."""
_APP_TOOL_REGISTRY[global_key] = tool
_FN_TO_GLOBAL_KEY[id(fn)] = global_key
_NAME_TO_GLOBAL_KEY.setdefault(tool.name, []).append(global_key)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid accumulating stale name→global-key entries

_NAME_TO_GLOBAL_KEY only ever appends, so long-lived processes accumulate duplicate registrations for the same logical backend name. That becomes user-visible on in-process reload paths: FileSystemProvider(reload=True) re-imports modules with importlib.reload() (src/fastmcp/server/providers/filesystem_discovery.py:140-144), and after the first reload a single @app.tool("save") module leaves two global keys in this list, causing _resolve_tool_ref("save") to raise ValueError("Ambiguous tool name") even though there is still only one live app.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f70e85734c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/fastmcp/server/app.py
self._local._add_component(tool)

_APP_TOOL_REGISTRY[global_key] = tool
_NAME_TO_GLOBAL_KEY[tool.name] = global_key
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve default version selection for string CallTool refs

If a FastMCPApp is assembled with versioned backend Tools via add_tool(), this single tool.name -> global_key mapping pins CallTool("save") to whichever version was registered last. That changes FastMCP's normal behavior of resolving an unversioned call to the highest matching version (LocalProvider._get_tool, src/fastmcp/server/providers/local_provider/local_provider.py:351-369), so registering save@v2 and then save@v1 makes the UI silently call v1.

Useful? React with 👍 / 👎.

Comment on lines +334 to +337
Form.from_model(
BugReport,
on_submit=CallTool(
"create_bug",
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make Form.from_model examples match the tool argument shape

This runnable example will fail if copied as-is. Form.from_model(BugReport) creates top-level title/severity/description fields, but FastMCP still exposes a BaseModel parameter under its argument name — tests/tools/tool/test_tool.py:187-215 shows create_user(user: UserInput, ...) expecting {"user": {...}}. Submitting this form therefore sends separate kwargs to create_bug(data: BugReport) instead of the required data object, and the later save_contact(data: ContactModel) example repeats the same mismatch.

Useful? React with 👍 / 👎.

@jlowin jlowin merged commit 1be9b4d into main Mar 21, 2026
10 of 11 checks passed
@jlowin jlowin deleted the apps-docs-and-string-calltool branch March 21, 2026 23:44
@marvin-context-protocol
Copy link
Copy Markdown
Contributor

Test Failure Analysis

Summary: The Windows Python 3.10 CI job timed out during the setup of TestSupabaseProviderIntegration::test_unauthorized_access while waiting for a subprocess server to start. All other platforms passed.

Root Cause: The mcp_server_url fixture in tests/server/auth/providers/test_supabase.py uses run_server_in_process(), which starts a FastMCP server in a multiprocessing.Process. On Windows, the default multiprocessing start method is spawn (not fork), meaning the child process must re-import the entire module from scratch. The fixture has no @pytest.mark.timeout override, so it inherits the global 5-second test timeout from pyproject.toml.

In the previous successful run (commit 85faad59), the subprocess startup took 3.37 seconds — dangerously close to the 5-second limit. In this run, the uv cache was busted (the uv.lock changed due to the prefab-ui>=0.13.0 bump), so packages were freshly installed and the subprocess startup took longer than 5 seconds, tripping the timeout.

Suggested Solution: Add a @pytest.mark.timeout override on the mcp_server_url fixture or on TestSupabaseProviderIntegration to give it more headroom on Windows:

In /home/runner/work/fastmcp/fastmcp/tests/server/auth/providers/test_supabase.py:

@pytest.fixture
@pytest.mark.timeout(30)  # Windows spawn-based subprocess startup is slow
def mcp_server_url() -> Generator[str]:
    with run_server_in_process(run_mcp_server) as url:
        yield f"{url}/mcp"

Or alternatively, add the timeout mark to the test class:

@pytest.mark.timeout(30)
class TestSupabaseProviderIntegration:
    ...

This is consistent with how other slow tests in the suite handle Windows/subprocess timing (e.g. test_uv_transport.py uses @pytest.mark.timeout(60)).

Detailed Analysis

Failing job: Tests: Python 3.10 on windows-latest (job ID: 68046586449)

Log excerpt showing the failure:

tests\server\auth\providers\test_supabase.py ............. +++++++++++++++++++++++++++++++++++ Timeout +++++++++++++++++++++++++++++++++++
...
File "D:\a\fastmcp\fastmcp\tests\server\auth\providers\test_supabase.py", line 180, in mcp_server_url
    with run_server_in_process(run_mcp_server) as url:
...
File "D:\a\fastmcp\fastmcp\src\fastmcp\utilities\tests.py", line 117, in run_server_in_process
    s.connect((host, port))
+++++++++++++++++++++++++++++++++++ Timeout +++++++++++++++++++++++++++++++++++

Previous run timing data (commit 85faad59, job 68027074398 — successful):

3.37s setup    tests/server/auth/providers/test_supabase.py::TestSupabaseProviderIntegration::test_unauthorized_access

The supabase tests pass 13 unit tests quickly (no subprocess), then the 14th test is the integration test test_unauthorized_access which triggers the mcp_server_url fixture. This fixture spawns a subprocess server and polls for it to be listening. The 5s global timeout fires before the server is ready.

Why the cache bust matters: The uv.lock changed in this commit (due to prefab-ui>=0.11.2>=0.13.0), so no cache was available for the Windows runner. Fresh package installs + no cache = first-time Python import in the subprocess is slower.

The run_server_in_process utility (src/fastmcp/utilities/tests.py:75-138) uses multiprocessing.Process with no explicit start_method. On Windows this defaults to spawn, requiring a full Python interpreter boot and module re-import in the child process on every test setup.

Related Files
  • tests/server/auth/providers/test_supabase.py — contains the failing mcp_server_url fixture (line 178) and TestSupabaseProviderIntegration class (line 196) without a timeout override
  • src/fastmcp/utilities/tests.pyrun_server_in_process() (line 75): uses multiprocessing.Process, polls for server startup with a socket connect; the poll loop itself has plenty of attempts but the outer pytest 5s timeout fires first
  • pyproject.toml — sets global timeout = 5 (line 132) with no Windows-specific override
  • tests/client/transports/test_uv_transport.py — example of tests that correctly override with @pytest.mark.timeout(60) for subprocess-heavy tests

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement Improvement to existing functionality. For issues and smaller PR improvements. mcp apps Related to MCP Apps - user-facing applications with frontend bundles served by MCP servers.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant