Skip to content

Commit 1ce49df

Browse files
committed
feat(tools): surface write annotations via mcp metadata
1 parent 44a953f commit 1ce49df

File tree

3 files changed

+154
-1
lines changed

3 files changed

+154
-1
lines changed

src/schwab_mcp/tools/__init__.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,14 @@ def register_tools(server: FastMCP, client: AsyncClient, *, allow_write: bool) -
2323
for func in iter_registered_tools():
2424
if getattr(func, "_write", False) and not allow_write:
2525
continue
26-
server.tool(name=func.__name__, description=func.__doc__)(func)
26+
annotations = getattr(func, "_tool_annotations", None)
27+
tool_kwargs = {
28+
"name": func.__name__,
29+
"description": func.__doc__,
30+
}
31+
if annotations is not None:
32+
tool_kwargs["annotations"] = annotations
33+
server.tool(**tool_kwargs)(func)
2734

2835

2936
__all__ = ["register_tools", "register"]

src/schwab_mcp/tools/registry.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from collections.abc import Awaitable, Callable
44
from typing import Any, ParamSpec, TypeVar, overload
5+
6+
from mcp.types import ToolAnnotations
57
P = ParamSpec("P")
68
R = TypeVar("R")
79

@@ -15,6 +17,7 @@ def register(
1517
func: RegisteredTool,
1618
*,
1719
write: bool = False,
20+
annotations: ToolAnnotations | None = None,
1821
) -> RegisteredTool:
1922
...
2023

@@ -24,6 +27,7 @@ def register(
2427
func: None = None,
2528
*,
2629
write: bool = False,
30+
annotations: ToolAnnotations | None = None,
2731
) -> Callable[[RegisteredTool], RegisteredTool]:
2832
...
2933

@@ -32,6 +36,7 @@ def register(
3236
func: RegisteredTool | None = None,
3337
*,
3438
write: bool = False,
39+
annotations: ToolAnnotations | None = None,
3540
) -> RegisteredTool | Callable[[RegisteredTool], RegisteredTool]:
3641
"""Decorator used by tool modules to mark async callables for registration.
3742
@@ -44,10 +49,28 @@ def register(
4449
Flag indicating whether the tool performs a write/side-effecting
4550
operation that should only be exposed when the server is started with
4651
explicit write access enabled.
52+
annotations:
53+
Optional MCP tool annotations to attach when the tool is registered.
54+
Defaults to describing the tool as read-only unless ``write`` is True.
4755
"""
4856

4957
def _decorator(fn: RegisteredTool) -> RegisteredTool:
5058
setattr(fn, "_write", write)
59+
default_annotations = ToolAnnotations(
60+
readOnlyHint=not write,
61+
destructiveHint=True if write else None,
62+
)
63+
if annotations is not None:
64+
update: dict[str, Any] = {}
65+
if annotations.readOnlyHint is None:
66+
update["readOnlyHint"] = not write
67+
if write and annotations.destructiveHint is None:
68+
update["destructiveHint"] = True
69+
tool_annotations = annotations.model_copy(update=update)
70+
else:
71+
tool_annotations = default_annotations
72+
73+
setattr(fn, "_tool_annotations", tool_annotations)
5174
_REGISTERED_TOOLS.append(fn) # type: ignore[arg-type]
5275
return fn
5376

tests/test_registry.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import pytest
2+
from mcp.types import ToolAnnotations
3+
from mcp.server.fastmcp import FastMCP
4+
from schwab.client import AsyncClient
5+
from typing import cast
6+
7+
import schwab_mcp.tools as tools_module
8+
from schwab_mcp.tools import registry as tool_registry
9+
from schwab_mcp.tools import utils as tool_utils
10+
11+
12+
def _pop_registered(count_before: int) -> None:
13+
"""Helper to remove any tools appended during a test."""
14+
while len(tool_registry._REGISTERED_TOOLS) > count_before:
15+
tool_registry._REGISTERED_TOOLS.pop()
16+
17+
18+
def test_register_sets_readonly_annotations() -> None:
19+
count_before = len(tool_registry._REGISTERED_TOOLS)
20+
21+
async def sample_tool() -> str:
22+
return "ok"
23+
24+
registered = tool_registry.register(sample_tool)
25+
try:
26+
annotations = getattr(registered, "_tool_annotations")
27+
assert isinstance(annotations, ToolAnnotations)
28+
assert annotations.readOnlyHint is True
29+
assert annotations.destructiveHint is None
30+
assert getattr(registered, "_write") is False
31+
finally:
32+
_pop_registered(count_before)
33+
34+
35+
def test_register_sets_write_annotations() -> None:
36+
count_before = len(tool_registry._REGISTERED_TOOLS)
37+
38+
async def write_tool() -> str:
39+
return "ok"
40+
41+
registered = tool_registry.register(write_tool, write=True)
42+
try:
43+
annotations = getattr(registered, "_tool_annotations")
44+
assert isinstance(annotations, ToolAnnotations)
45+
assert annotations.readOnlyHint is False
46+
assert annotations.destructiveHint is True
47+
assert getattr(registered, "_write") is True
48+
finally:
49+
_pop_registered(count_before)
50+
51+
52+
def test_register_tools_uses_annotations(monkeypatch: pytest.MonkeyPatch) -> None:
53+
async def read_tool() -> str:
54+
return "read"
55+
56+
async def write_tool() -> str:
57+
return "write"
58+
59+
read_annotations = ToolAnnotations(readOnlyHint=True)
60+
write_annotations = ToolAnnotations(readOnlyHint=False, destructiveHint=True)
61+
62+
setattr(read_tool, "__doc__", "read tool")
63+
setattr(write_tool, "__doc__", "write tool")
64+
setattr(read_tool, "_write", False)
65+
setattr(write_tool, "_write", True)
66+
setattr(read_tool, "_tool_annotations", read_annotations)
67+
setattr(write_tool, "_tool_annotations", write_annotations)
68+
69+
monkeypatch.setattr(
70+
tools_module,
71+
"iter_registered_tools",
72+
lambda: [read_tool, write_tool],
73+
)
74+
75+
class DummyServer:
76+
def __init__(self) -> None:
77+
self.tools: list[dict[str, object]] = []
78+
79+
def tool(self, *, name=None, description=None, annotations=None):
80+
def decorator(fn):
81+
self.tools.append(
82+
{
83+
"fn": fn,
84+
"name": name,
85+
"description": description,
86+
"annotations": annotations,
87+
}
88+
)
89+
return fn
90+
91+
return decorator
92+
93+
dummy_client = object()
94+
95+
server_read_only = DummyServer()
96+
tools_module.register_tools(
97+
cast(FastMCP, server_read_only),
98+
cast(AsyncClient, dummy_client),
99+
allow_write=False,
100+
)
101+
102+
assert [entry["fn"] for entry in server_read_only.tools] == [read_tool]
103+
read_entry = server_read_only.tools[0]
104+
assert isinstance(read_entry["annotations"], ToolAnnotations)
105+
assert read_entry["annotations"].readOnlyHint is True
106+
107+
server_read_write = DummyServer()
108+
tools_module.register_tools(
109+
cast(FastMCP, server_read_write),
110+
cast(AsyncClient, dummy_client),
111+
allow_write=True,
112+
)
113+
114+
registered_functions = {entry["fn"] for entry in server_read_write.tools}
115+
assert registered_functions == {read_tool, write_tool}
116+
117+
write_entry = next(entry for entry in server_read_write.tools if entry["fn"] is write_tool)
118+
assert isinstance(write_entry["annotations"], ToolAnnotations)
119+
assert write_entry["annotations"].readOnlyHint is False
120+
assert write_entry["annotations"].destructiveHint is True
121+
122+
# Reset write access flag for other tests
123+
tool_utils.set_write_enabled(False)

0 commit comments

Comments
 (0)