|
1 | | -import functools |
2 | | -import inspect |
3 | | -from planet_mcp import descriptions, tiles |
| 1 | +from contextlib import asynccontextmanager |
| 2 | +from typing import AsyncIterator |
4 | 3 | from fastmcp import FastMCP |
5 | | -import planet |
6 | | -from typing import Union, Optional |
| 4 | +from planet_mcp import servers |
7 | 5 |
|
8 | | -from pydantic import PydanticSchemaGenerationError |
| 6 | +_instructions = """ |
| 7 | +Instructions for using Planet's official MCP server. |
| 8 | +""" |
9 | 9 |
|
10 | 10 |
|
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 |
23 | 14 |
|
24 | 15 |
|
25 | | -class MCPService: |
26 | | - """MCP service for the Planet API |
| 16 | +def _lifespan(context: PlanetContext): |
27 | 17 |
|
28 | | - member `mcp` contains the FastMCP app instance. |
29 | | - """ |
| 18 | + @asynccontextmanager |
| 19 | + async def lifespan(server: FastMCP) -> AsyncIterator[PlanetContext]: |
| 20 | + yield context |
30 | 21 |
|
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 |
40 | 23 |
|
41 | | - if ignore and include: |
42 | | - raise ValueError("Cannot specify both ignore and include sets.") |
43 | 24 |
|
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: |
46 | 30 |
|
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() |
50 | 32 |
|
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 | + ) |
57 | 40 |
|
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 |
60 | 55 |
|
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 |
0 commit comments