Skip to content

Commit bada22d

Browse files
committed
refactor to support more flexible tool toggles
1 parent d72aecc commit bada22d

File tree

10 files changed

+341
-224
lines changed

10 files changed

+341
-224
lines changed

src/planet_mcp/clients.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from planet import Session, __version__
2+
3+
_session: Session | None = None
4+
5+
6+
def _init_session(cls: type[Session]) -> Session:
7+
_session = cls()
8+
# todo add our version?
9+
_session._client.headers["User-Agent"] = f"planet-mcp {__version__}"
10+
return _session
11+
12+
13+
def session() -> Session:
14+
"""session returns a global Session. dependencies should only call this
15+
in their tool/resource calls or within a callable `mcp` initializer
16+
"""
17+
global _session
18+
if _session is None:
19+
_session = _init_session(Session)
20+
return _session

src/planet_mcp/main.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,40 @@
33
is installed, this is installed as an executable named planet-mcp.
44
"""
55

6-
from fastmcp import FastMCP
7-
import planet
8-
from planet_mcp.server import MCPService
9-
106
# note - the mcp dev tooling (e.g. uv run fastmcp dev src/main.py)
117
# wants to find a server object named `mcp` (or it won't work)
12-
mcp = FastMCP("Planet local/stdio MCP")
13-
sess = planet.Session()
14-
svc = MCPService(mcp=mcp, session=sess)
8+
import argparse
9+
import asyncio
10+
from planet_mcp.server import init
11+
12+
13+
def parse_args() -> argparse.Namespace:
14+
def csv(value):
15+
return set(t.strip() for t in (value or "").split(","))
16+
17+
parser = argparse.ArgumentParser(
18+
description="Planet MCP Server",
19+
)
20+
parser.add_argument("args", nargs="*")
21+
parser.add_argument("--include-tags", type=csv, default=None)
22+
parser.add_argument("--exclude-tags", type=csv, default=None)
23+
parser.add_argument("--servers", type=csv, default=None)
24+
return parser.parse_args()
25+
26+
27+
args = parse_args()
28+
mcp = init(
29+
enabled_servers=args.servers,
30+
include_tags=args.include_tags,
31+
exclude_tags=args.exclude_tags,
32+
)
1533

1634

1735
# this is the entry point for the executable script installed via package
1836
# and also supports execution via `uv run fastmcp run src/main.py`
1937
def main():
20-
svc.mcp.run(transport="stdio")
38+
mcp.run(transport="stdio")
39+
40+
41+
if __name__ == "__main__":
42+
main()

src/planet_mcp/server.py

Lines changed: 43 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -1,142 +1,57 @@
1-
import functools
2-
import inspect
3-
from planet_mcp import descriptions, tiles
1+
import asyncio
2+
from contextlib import asynccontextmanager
3+
from typing import AsyncIterator
44
from fastmcp import FastMCP
5-
import planet
6-
from typing import Union, Optional
5+
from planet_mcp import servers
76

8-
from pydantic import PydanticSchemaGenerationError
7+
_instructions = """
8+
Instructions for using Planet's official MCP server.
9+
"""
910

1011

11-
DEFAULT_IGNORE = {
12-
"data_wait_asset",
13-
"orders_wait",
14-
}
12+
class PlanetContext:
13+
def __init__(self):
14+
pass
1515

1616

17-
class MCPService:
18-
"""MCP service for the Planet API
17+
def _lifespan(context: PlanetContext):
1918

20-
member `mcp` contains the FastMCP app instance.
21-
"""
19+
@asynccontextmanager
20+
async def lifespan(server: FastMCP) -> AsyncIterator[PlanetContext]:
21+
yield context
2222

23-
def __init__(
24-
self,
25-
mcp: FastMCP,
26-
session: planet.Session,
27-
ignore: set[str] | None = None,
28-
include: set[str] | None = None,
29-
):
30-
self.mcp = mcp
31-
self.session = session
23+
return lifespan
3224

33-
if ignore and include:
34-
raise ValueError("Cannot specify both ignore and include sets.")
3525

36-
self.include = include
37-
self.ignore = ignore
26+
def init(
27+
enabled_servers: set[str] | None = None,
28+
include_tags: set[str] | None = None,
29+
exclude_tags: set[str] | None = None,
30+
) -> FastMCP:
3831

39-
if self.ignore is None and self.include is None:
40-
self.ignore = DEFAULT_IGNORE
41-
self.ignore = ignore if ignore is not None else DEFAULT_IGNORE
32+
context = PlanetContext()
4233

43-
self.make_tools(planet.FeaturesClient, "features")
44-
self.make_tools(planet.DataClient, "data")
45-
self.make_tools(planet.OrdersClient, "orders")
46-
self.make_tools(planet.SubscriptionsClient, "subscriptions")
47-
self.make_tools(planet.MosaicsClient, "mosaics")
48-
self.make_tools(planet.DestinationsClient, "destinations")
34+
mcp = FastMCP(
35+
"Planet MCP Server",
36+
lifespan=_lifespan(context),
37+
instructions=_instructions,
38+
include_tags=include_tags,
39+
exclude_tags=exclude_tags,
40+
)
4941

50-
# add tiles tools
51-
tiles.init(self.mcp, self.session)
42+
for server in servers.all:
43+
try:
44+
# server protocol is either a variable or callable named mcp
45+
entry = getattr(server, "mcp")
46+
except AttributeError:
47+
raise Exception(f"programmer error, mcp attribute not in {server}")
48+
if callable(entry):
49+
entry = entry()
50+
if not isinstance(entry, FastMCP):
51+
raise Exception(
52+
f"programmer error, expected FastMCP type, got {type(entry)}"
53+
)
54+
if enabled_servers is None or entry.name in enabled_servers:
55+
mcp.mount(entry, entry.name) # type: ignore
5256

53-
def make_tools(self, client_class, prefix: str):
54-
for name, func in inspect.getmembers(client_class(self.session)):
55-
if inspect.ismethod(func) and name[0] != "_":
56-
full_name = f"{prefix}_{name}"
57-
58-
if self.ignore is not None and full_name in self.ignore:
59-
continue
60-
if self.include is not None and name not in self.include:
61-
continue
62-
63-
# extended tool options
64-
opts = {}
65-
66-
# check if there is a description override for this tool
67-
if full_name in descriptions.overrides:
68-
opts["description"] = descriptions.overrides[full_name]
69-
70-
# async generator functions have an incompatible return type.
71-
# ensure they are converted to a list[dict] return type.
72-
if inspect.isasyncgenfunction(func):
73-
func = _async_get_wrapper(func, prefix)
74-
75-
# no return functions end up with a "self" parameter so this
76-
# works around by adding a simple response
77-
# @todo - upstream bug?
78-
sig = inspect.signature(func)
79-
if sig.return_annotation is None:
80-
func = _return_wrapper(func)
81-
82-
try:
83-
self.mcp.tool(func, name=full_name, **opts)
84-
except PydanticSchemaGenerationError:
85-
# there's a few functions we need to modify again because of custom types.
86-
modified_func = _create_param_modified_wrapper(func)
87-
try:
88-
self.mcp.tool(modified_func, name=full_name, **opts)
89-
except Exception as e:
90-
print("Unable to add tool", full_name, e)
91-
92-
93-
def _async_get_wrapper(f, prefix):
94-
"""wrap an async generator to return a list[dict]"""
95-
96-
@functools.wraps(f)
97-
async def generate_async(*args, **kwargs) -> list[dict]:
98-
return [i async for i in (f(*args, **kwargs))]
99-
100-
# functool.wraps annotates using the original function return
101-
generate_async.__annotations__["return"] = list[dict]
102-
return generate_async
103-
104-
105-
def _return_wrapper(func):
106-
@functools.wraps(func)
107-
async def wrapper(*args, **kwargs):
108-
await func(*args, **kwargs)
109-
return {"status": "ok"}
110-
111-
wrapper.__annotations__["return"] = dict
112-
return wrapper
113-
114-
115-
def _create_param_modified_wrapper(original_func):
116-
"""
117-
Some functions that accept special types (typing.Protocol) fail during
118-
FastMCP tool registration. This wrapper modifies the function's signature,
119-
replacing the type hints with simple types that FastMCP can handle.
120-
"""
121-
122-
@functools.wraps(original_func)
123-
async def wrapper(*args, **kwargs):
124-
return await original_func(*args, **kwargs)
125-
126-
try:
127-
sig = inspect.signature(original_func)
128-
129-
for param_name, param in sig.parameters.items():
130-
# Convert problematic types into useable ones.
131-
# we want to override the GeojsonLike field
132-
# and remove the union of the Feature which accepts a dict or a GeoInterface
133-
if param_name in ("feature", "quad", "mosaic", "series"):
134-
wrapper.__annotations__[param_name] = dict
135-
elif param_name == "geometry" and "planet.models" in str(param.annotation):
136-
wrapper.__annotations__[param_name] = Optional[Union[dict, str]] | None
137-
138-
except Exception as e:
139-
print(f"Error modifying signature: {e}")
140-
wrapper.__annotations__ = {}
141-
142-
return wrapper
57+
return mcp

src/planet_mcp/servers/__init__.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from . import sdk_wrapper
2+
from . import tiles
3+
4+
all = [
5+
sdk_wrapper,
6+
tiles,
7+
]
File renamed without changes.
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import functools
2+
import inspect
3+
4+
from planet_mcp.clients import session
5+
from . import descriptions
6+
from fastmcp import FastMCP
7+
import planet
8+
from typing import Union, Optional
9+
10+
from pydantic import PydanticSchemaGenerationError
11+
12+
13+
_DEFAULT_IGNORE = {
14+
"data_wait_asset",
15+
"orders_wait",
16+
}
17+
18+
19+
def mcp() -> FastMCP:
20+
mcp = FastMCP("sdk_wrapper")
21+
make_tools(mcp, planet.FeaturesClient, "features")
22+
make_tools(mcp, planet.DataClient, "data")
23+
make_tools(mcp, planet.OrdersClient, "orders")
24+
make_tools(mcp, planet.SubscriptionsClient, "subscriptions")
25+
make_tools(mcp, planet.MosaicsClient, "mosaics")
26+
make_tools(mcp, planet.DestinationsClient, "destinations")
27+
return mcp
28+
29+
30+
def make_tools(mcp: FastMCP, client_class: type, prefix: str):
31+
for name, func in inspect.getmembers(client_class(session())):
32+
if inspect.ismethod(func) and name[0] != "_":
33+
full_name = f"{prefix}_{name}"
34+
35+
if full_name in _DEFAULT_IGNORE:
36+
continue
37+
38+
# extended tool options
39+
opts = {}
40+
41+
# check if there is a description override for this tool
42+
if full_name in descriptions.overrides:
43+
opts["description"] = descriptions.overrides[full_name]
44+
45+
# async generator functions have an incompatible return type.
46+
# ensure they are converted to a list[dict] return type.
47+
if inspect.isasyncgenfunction(func):
48+
func = _async_get_wrapper(func, prefix)
49+
50+
# no return functions end up with a "self" parameter so this
51+
# works around by adding a simple response
52+
# @todo - upstream bug?
53+
sig = inspect.signature(func)
54+
if sig.return_annotation is None:
55+
func = _return_wrapper(func)
56+
57+
if "download" in name:
58+
opts["tags"] = set("download")
59+
60+
try:
61+
mcp.tool(func, name=full_name, **opts)
62+
except PydanticSchemaGenerationError:
63+
# there's a few functions we need to modify again because of custom types.
64+
modified_func = _create_param_modified_wrapper(func)
65+
try:
66+
mcp.tool(modified_func, name=full_name, **opts)
67+
except Exception as e:
68+
print("Unable to add tool", full_name, e)
69+
70+
71+
def _async_get_wrapper(f, prefix):
72+
"""wrap an async generator to return a list[dict]"""
73+
74+
@functools.wraps(f)
75+
async def generate_async(*args, **kwargs) -> list[dict]:
76+
return [i async for i in (f(*args, **kwargs))]
77+
78+
# functool.wraps annotates using the original function return
79+
generate_async.__annotations__["return"] = list[dict]
80+
return generate_async
81+
82+
83+
def _return_wrapper(func):
84+
@functools.wraps(func)
85+
async def wrapper(*args, **kwargs):
86+
await func(*args, **kwargs)
87+
return {"status": "ok"}
88+
89+
wrapper.__annotations__["return"] = dict
90+
return wrapper
91+
92+
93+
def _create_param_modified_wrapper(original_func):
94+
"""
95+
Some functions that accept special types (typing.Protocol) fail during
96+
FastMCP tool registration. This wrapper modifies the function's signature,
97+
replacing the type hints with simple types that FastMCP can handle.
98+
"""
99+
100+
@functools.wraps(original_func)
101+
async def wrapper(*args, **kwargs):
102+
return await original_func(*args, **kwargs)
103+
104+
try:
105+
sig = inspect.signature(original_func)
106+
107+
for param_name, param in sig.parameters.items():
108+
# Convert problematic types into useable ones.
109+
# we want to override the GeojsonLike field
110+
# and remove the union of the Feature which accepts a dict or a GeoInterface
111+
if param_name in ("feature", "quad", "mosaic", "series"):
112+
wrapper.__annotations__[param_name] = dict
113+
elif param_name == "geometry" and "planet.models" in str(param.annotation):
114+
wrapper.__annotations__[param_name] = Optional[Union[dict, str]] | None
115+
116+
except Exception as e:
117+
print(f"Error modifying signature: {e}")
118+
wrapper.__annotations__ = {}
119+
120+
return wrapper

0 commit comments

Comments
 (0)