添加MAP MCP、联网搜索skill
This commit is contained in:
233
MCP/map-mcp/server.py
Normal file
233
MCP/map-mcp/server.py
Normal file
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Map-MCP: 地图操作 MCP 服务器
|
||||
|
||||
通过 stdio 与 nanobot 通信 (MCP 协议),
|
||||
同时运行 WebSocket 服务器与浏览器中的 Cesium 前端通信。
|
||||
|
||||
架构:
|
||||
nanobot ←stdio→ Map-MCP ←WebSocket→ 浏览器(Cesium)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
import websockets
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
# ─── 配置 ─────────────────────────────────────────────────────────────────────
|
||||
WS_HOST = os.getenv("MAP_MCP_WS_HOST", "0.0.0.0")
|
||||
WS_PORT = int(os.getenv("MAP_MCP_WS_PORT", "8005"))
|
||||
|
||||
logger = logging.getLogger("map-mcp")
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
||||
)
|
||||
|
||||
# ─── WebSocket 状态 ───────────────────────────────────────────────────────────
|
||||
_clients: set[websockets.ServerConnection] = set()
|
||||
_pending: dict[str, asyncio.Future] = {}
|
||||
|
||||
|
||||
async def _ws_handler(websocket):
|
||||
"""处理浏览器 WebSocket 连接"""
|
||||
_clients.add(websocket)
|
||||
logger.info("浏览器已连接 (共 %d 个客户端)", len(_clients))
|
||||
try:
|
||||
async for raw in websocket:
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("收到无效 JSON: %s", raw[:200])
|
||||
continue
|
||||
req_id = data.get("id")
|
||||
if req_id and req_id in _pending:
|
||||
_pending[req_id].set_result(data)
|
||||
except websockets.ConnectionClosed:
|
||||
pass
|
||||
finally:
|
||||
_clients.discard(websocket)
|
||||
logger.info("浏览器断开 (共 %d 个客户端)", len(_clients))
|
||||
|
||||
|
||||
async def _send_cmd(cmd: str, params: dict | None = None, timeout: float = 15.0) -> dict:
|
||||
"""向浏览器发送命令并等待响应"""
|
||||
if not _clients:
|
||||
return {"ok": False, "error": "没有浏览器连接,请先打开地图页面"}
|
||||
|
||||
req_id = str(uuid.uuid4())
|
||||
msg = {"id": req_id, "cmd": cmd}
|
||||
if params:
|
||||
msg.update(params)
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.create_future()
|
||||
_pending[req_id] = future
|
||||
|
||||
# 发送给第一个连接的客户端
|
||||
client = next(iter(_clients))
|
||||
await client.send(json.dumps(msg, ensure_ascii=False))
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(future, timeout=timeout)
|
||||
return result
|
||||
except asyncio.TimeoutError:
|
||||
return {"ok": False, "error": f"浏览器响应超时 ({timeout}s)"}
|
||||
finally:
|
||||
_pending.pop(req_id, None)
|
||||
|
||||
|
||||
# ─── FastMCP 生命周期:启动 WebSocket 服务器 ──────────────────────────────────
|
||||
@asynccontextmanager
|
||||
async def lifespan(server):
|
||||
ws_server = await websockets.serve(_ws_handler, WS_HOST, WS_PORT)
|
||||
logger.info("WebSocket 服务器已启动: ws://%s:%d", WS_HOST, WS_PORT)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
ws_server.close()
|
||||
await ws_server.wait_closed()
|
||||
logger.info("WebSocket 服务器已关闭")
|
||||
|
||||
|
||||
mcp = FastMCP("map-mcp", lifespan=lifespan)
|
||||
|
||||
|
||||
# ─── MCP 工具定义 ─────────────────────────────────────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def map_zoom_to(lon: float, lat: float, altitude: float = 500000.0) -> str:
|
||||
"""飞行到指定火星坐标位置。
|
||||
|
||||
Args:
|
||||
lon: 经度 (火星坐标, -180 ~ 180)
|
||||
lat: 纬度 (火星坐标, -90 ~ 90)
|
||||
altitude: 相机高度,单位米 (默认 500km,适合查看区域全貌。近距离查看可用 50000~100000)
|
||||
"""
|
||||
result = await _send_cmd("zoom_to", {"lon": lon, "lat": lat, "altitude": altitude})
|
||||
if result.get("ok"):
|
||||
return f"已飞行到 经度{lon}°, 纬度{lat}°, 高度{altitude/1000:.0f}km"
|
||||
return f"操作失败: {result.get('error', '未知错误')}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def map_get_viewport() -> str:
|
||||
"""获取当前地图视野信息,包括相机位置(经纬度、高度)和可视范围边界框。
|
||||
|
||||
返回 JSON:
|
||||
{
|
||||
"center": {"lon": ..., "lat": ..., "altitude": ...},
|
||||
"bbox": {"west": ..., "south": ..., "east": ..., "north": ...}
|
||||
}
|
||||
"""
|
||||
result = await _send_cmd("get_viewport")
|
||||
if result.get("ok"):
|
||||
return json.dumps(result.get("data", {}), ensure_ascii=False)
|
||||
return f"获取失败: {result.get('error', '未知错误')}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def map_draw_bbox(
|
||||
west: float, south: float, east: float, north: float,
|
||||
color: str = "#ff6600",
|
||||
) -> str:
|
||||
"""在地图上绘制矩形边界框并飞行到该区域。
|
||||
|
||||
Args:
|
||||
west: 西边界经度
|
||||
south: 南边界纬度
|
||||
east: 东边界经度
|
||||
north: 北边界纬度
|
||||
color: 边框颜色 (CSS 颜色值, 默认橙色)
|
||||
"""
|
||||
result = await _send_cmd("draw_bbox", {
|
||||
"bbox": {"west": west, "south": south, "east": east, "north": north},
|
||||
"color": color,
|
||||
})
|
||||
if result.get("ok"):
|
||||
return f"已绘制矩形: ({west},{south}) - ({east},{north})"
|
||||
return f"绘制失败: {result.get('error', '未知错误')}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def map_spatial_query(product: str = "tianwen_moric") -> str:
|
||||
"""在当前绘制的矩形区域内查询火星遥感影像数据,结果会显示在前端界面。
|
||||
|
||||
必须先调用 map_draw_bbox 绘制查询区域,然后再调用此工具执行查询。
|
||||
|
||||
Args:
|
||||
product: 数据产品类型,可选值:
|
||||
tianwen_moric - 天问中分相机
|
||||
tianwen_hiric - 天问高分相机
|
||||
mars_ctx - Mars CTX
|
||||
mars_hirise - Mars HiRISE
|
||||
"""
|
||||
result = await _send_cmd("spatial_query", {"product": product}, timeout=30.0)
|
||||
if result.get("ok"):
|
||||
count = result.get("count", 0)
|
||||
items = result.get("items", [])
|
||||
if count == 0:
|
||||
return "查询完成,未找到匹配的影像数据。可尝试扩大查询范围或更换产品类型。"
|
||||
summary = f"查询完成,找到 {count} 条影像数据,已在前端结果面板中显示。\n\n"
|
||||
for i, item in enumerate(items[:5], 1):
|
||||
name = item.get("name", "N/A")
|
||||
summary += f"{i}. {name} ({item.get('product', '')})\n"
|
||||
if count > 5:
|
||||
summary += f"\n... 还有 {count - 5} 条结果"
|
||||
return summary
|
||||
return f"查询失败: {result.get('error', '未知错误')}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def map_render_footprints(geojson: str) -> str:
|
||||
"""将 GeoJSON FeatureCollection 渲染到地图上,用于显示影像覆盖范围。
|
||||
|
||||
每个 Feature 的 geometry 应为 Polygon 类型,properties 中可包含 id、filename 等信息。
|
||||
|
||||
Args:
|
||||
geojson: GeoJSON 字符串 (FeatureCollection 格式)
|
||||
"""
|
||||
try:
|
||||
data = json.loads(geojson) if isinstance(geojson, str) else geojson
|
||||
except json.JSONDecodeError:
|
||||
return "GeoJSON 解析失败,请检查格式"
|
||||
|
||||
result = await _send_cmd("render_footprints", {"geojson": data})
|
||||
if result.get("ok"):
|
||||
count = result.get("count", len(data.get("features", [])))
|
||||
return f"已渲染 {count} 个覆盖范围到地图"
|
||||
return f"渲染失败: {result.get('error', '未知错误')}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def map_clear() -> str:
|
||||
"""清除地图上所有由 AI 绘制的内容(边界框、覆盖范围等),不影响用户手绘内容。"""
|
||||
result = await _send_cmd("clear")
|
||||
if result.get("ok"):
|
||||
return "已清除地图上的 AI 绘制内容"
|
||||
return f"清除失败: {result.get('error', '未知错误')}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def map_get_drawn_geometry() -> str:
|
||||
"""获取用户在地图上手绘的几何图形,可用于确定查询范围。
|
||||
|
||||
返回 JSON:
|
||||
{
|
||||
"bbox": {"minLon": ..., "maxLon": ..., "minLat": ..., "maxLat": ...},
|
||||
"hasGeometry": true/false
|
||||
}
|
||||
"""
|
||||
result = await _send_cmd("get_drawn_geometry")
|
||||
if result.get("ok"):
|
||||
return json.dumps(result.get("data", {}), ensure_ascii=False)
|
||||
return f"获取失败: {result.get('error', '未知错误')}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
Reference in New Issue
Block a user