Skip to content

Commit 3edfe1d

Browse files
committed
refactor to support more flexible tool toggles
1 parent c8bdaf2 commit 3edfe1d

File tree

10 files changed

+345
-232
lines changed

10 files changed

+345
-232
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: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,39 @@
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+
from planet_mcp.server import init
10+
11+
12+
def parse_args() -> argparse.Namespace:
13+
def csv(value):
14+
return set(t.strip() for t in (value or "").split(","))
15+
16+
parser = argparse.ArgumentParser(
17+
description="Planet MCP Server",
18+
)
19+
parser.add_argument("args", nargs="*")
20+
parser.add_argument("--include-tags", type=csv, default=None)
21+
parser.add_argument("--exclude-tags", type=csv, default=None)
22+
parser.add_argument("--servers", type=csv, default=None)
23+
return parser.parse_args()
24+
25+
26+
args = parse_args()
27+
mcp = init(
28+
enabled_servers=args.servers,
29+
include_tags=args.include_tags,
30+
exclude_tags=args.exclude_tags,
31+
)
1532

1633

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

src/planet_mcp/server.py

Lines changed: 42 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -1,150 +1,56 @@
1-
import functools
2-
import inspect
3-
from planet_mcp import descriptions, tiles
1+
from contextlib import asynccontextmanager
2+
from typing import AsyncIterator
43
from fastmcp import FastMCP
5-
import planet
6-
from typing import Union, Optional
4+
from planet_mcp import servers
75

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

1010

11-
DEFAULT_IGNORE = {
12-
"data_wait_asset",
13-
"orders_wait",
14-
"data_get_stats",
15-
"data_update_search",
16-
"orders_aggregated_order_stats",
17-
"subscriptions_get_results_csv",
18-
"subscriptions_patch_subscription",
19-
"subscriptions_update_subscription",
20-
"mosaics_get_quad_contributions",
21-
"destinations_patch_destination",
22-
}
11+
class PlanetContext:
12+
def __init__(self):
13+
pass
2314

2415

25-
class MCPService:
26-
"""MCP service for the Planet API
16+
def _lifespan(context: PlanetContext):
2717

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

31-
def __init__(
32-
self,
33-
mcp: FastMCP,
34-
session: planet.Session,
35-
ignore: set[str] | None = None,
36-
include: set[str] | None = None,
37-
):
38-
self.mcp = mcp
39-
self.session = session
22+
return lifespan
4023

41-
if ignore and include:
42-
raise ValueError("Cannot specify both ignore and include sets.")
4324

44-
self.include = include
45-
self.ignore = ignore
25+
def init(
26+
enabled_servers: set[str] | None = None,
27+
include_tags: set[str] | None = None,
28+
exclude_tags: set[str] | None = None,
29+
) -> FastMCP:
4630

47-
if self.ignore is None and self.include is None:
48-
self.ignore = DEFAULT_IGNORE
49-
self.ignore = ignore if ignore is not None else DEFAULT_IGNORE
31+
context = PlanetContext()
5032

51-
self.make_tools(planet.FeaturesClient, "features")
52-
self.make_tools(planet.DataClient, "data")
53-
self.make_tools(planet.OrdersClient, "orders")
54-
self.make_tools(planet.SubscriptionsClient, "subscriptions")
55-
self.make_tools(planet.MosaicsClient, "mosaics")
56-
self.make_tools(planet.DestinationsClient, "destinations")
33+
mcp = FastMCP(
34+
"Planet MCP Server",
35+
lifespan=_lifespan(context),
36+
instructions=_instructions,
37+
include_tags=include_tags,
38+
exclude_tags=exclude_tags,
39+
)
5740

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

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

0 commit comments

Comments
 (0)