Upload 14 files
Browse files- Dockerfile +14 -9
- README.md +63 -4
- app.py +53 -33
- climlab/mcp_output/README_MCP.md +182 -124
- climlab/mcp_output/mcp_plugin/adapter.py +187 -307
- climlab/mcp_output/mcp_plugin/main.py +3 -10
- climlab/mcp_output/mcp_plugin/mcp_service.py +191 -216
- climlab/mcp_output/requirements.txt +3 -3
- climlab/mcp_output/start_mcp.py +23 -20
- port.json +1 -5
- requirements.txt +5 -3
- run_docker.ps1 +6 -25
- run_docker.sh +6 -73
Dockerfile
CHANGED
|
@@ -1,17 +1,22 @@
|
|
| 1 |
-
FROM python:3.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
ENV
|
|
|
|
| 6 |
|
| 7 |
WORKDIR /app
|
| 8 |
|
| 9 |
-
|
| 10 |
-
RUN pip install --no-cache-dir --upgrade -r requirements.txt
|
| 11 |
|
| 12 |
-
COPY
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
EXPOSE 7860
|
| 17 |
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 4 |
+
ENV PYTHONUNBUFFERED=1
|
| 5 |
+
ENV MCP_TRANSPORT=http
|
| 6 |
+
ENV MCP_PORT=7860
|
| 7 |
|
| 8 |
WORKDIR /app
|
| 9 |
|
| 10 |
+
RUN useradd -m -u 1000 appuser
|
|
|
|
| 11 |
|
| 12 |
+
COPY requirements.txt /app/requirements.txt
|
| 13 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 14 |
+
pip install --no-cache-dir -r /app/requirements.txt
|
| 15 |
+
|
| 16 |
+
COPY climlab/ /app/climlab/
|
| 17 |
+
COPY app.py /app/app.py
|
| 18 |
+
|
| 19 |
+
USER appuser
|
| 20 |
|
| 21 |
EXPOSE 7860
|
| 22 |
|
README.md
CHANGED
|
@@ -1,10 +1,69 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: climlab MCP Service
|
| 3 |
+
emoji: 🔧
|
| 4 |
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
sdk: docker
|
| 7 |
pinned: false
|
| 8 |
+
license: mit
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# climlab MCP Service
|
| 12 |
+
|
| 13 |
+
这是一个面向 `climlab` 仓库的 MCP 部署包,支持:
|
| 14 |
+
- 本地 `stdio` 连接(Claude Desktop / CLI)
|
| 15 |
+
- HTTP 方式连接(Docker / HuggingFace Spaces)
|
| 16 |
+
|
| 17 |
+
## 可用工具
|
| 18 |
+
|
| 19 |
+
- `health_check`
|
| 20 |
+
- `get_version`
|
| 21 |
+
- `list_modules`
|
| 22 |
+
- `list_symbols`
|
| 23 |
+
- `compute_daily_insolation`
|
| 24 |
+
- `run_ebm_simulation`
|
| 25 |
+
- `run_grey_radiation_column`
|
| 26 |
+
- `call_function`
|
| 27 |
+
|
| 28 |
+
详细参数与示例见 `climlab/mcp_output/README_MCP.md`。
|
| 29 |
+
|
| 30 |
+
## 本地 stdio 运行
|
| 31 |
+
|
| 32 |
+
```bash
|
| 33 |
+
cd climlab/mcp_output
|
| 34 |
+
python start_mcp.py
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
或:
|
| 38 |
+
|
| 39 |
+
```bash
|
| 40 |
+
MCP_TRANSPORT=stdio MCP_PORT=8000 python climlab/mcp_output/start_mcp.py
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
## HTTP 客户端连接
|
| 44 |
+
|
| 45 |
+
### 本地 Docker
|
| 46 |
+
|
| 47 |
+
```bash
|
| 48 |
+
./run_docker.sh
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
容器启动后,MCP HTTP 端点为:
|
| 52 |
+
|
| 53 |
+
- `http://localhost:7860/mcp`
|
| 54 |
+
|
| 55 |
+
### HuggingFace Spaces
|
| 56 |
+
|
| 57 |
+
Docker 入口直接运行 MCP 服务:
|
| 58 |
+
|
| 59 |
+
- `python climlab/mcp_output/start_mcp.py`
|
| 60 |
+
|
| 61 |
+
平台会暴露 7860 端口,客户端连接:
|
| 62 |
+
|
| 63 |
+
- `https://<your-space>.hf.space/mcp`
|
| 64 |
+
|
| 65 |
+
## 目录说明
|
| 66 |
+
|
| 67 |
+
- `climlab/mcp_output/mcp_plugin/`:MCP 插件核心
|
| 68 |
+
- `climlab/mcp_output/start_mcp.py`:按环境变量选择 `stdio/http` 的入口
|
| 69 |
+
- `app.py`:补充 FastAPI 信息页(非 Docker 主入口)
|
app.py
CHANGED
|
@@ -1,45 +1,65 @@
|
|
| 1 |
-
from fastapi import FastAPI
|
| 2 |
import os
|
| 3 |
import sys
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
app = FastAPI(
|
| 9 |
-
title="Climlab MCP Service",
|
| 10 |
-
description="Auto-generated MCP service for climlab",
|
| 11 |
-
version="1.0.0"
|
| 12 |
-
)
|
| 13 |
|
| 14 |
@app.get("/")
|
| 15 |
-
def root():
|
| 16 |
return {
|
| 17 |
-
"
|
| 18 |
-
"
|
| 19 |
-
"
|
| 20 |
-
"
|
| 21 |
}
|
| 22 |
|
|
|
|
| 23 |
@app.get("/health")
|
| 24 |
-
def
|
| 25 |
-
return {"status": "healthy"
|
|
|
|
| 26 |
|
| 27 |
@app.get("/tools")
|
| 28 |
-
def
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
if
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
import sys
|
| 3 |
+
import importlib
|
| 4 |
+
from pathlib import Path
|
| 5 |
|
| 6 |
+
try:
|
| 7 |
+
FastAPI = importlib.import_module("fastapi").FastAPI
|
| 8 |
+
except Exception:
|
| 9 |
+
FastAPI = None
|
| 10 |
+
|
| 11 |
+
PLUGIN_DIR = Path(__file__).resolve().parent / "climlab" / "mcp_output" / "mcp_plugin"
|
| 12 |
+
if str(PLUGIN_DIR) not in sys.path:
|
| 13 |
+
sys.path.insert(0, str(PLUGIN_DIR))
|
| 14 |
+
|
| 15 |
+
if FastAPI is None:
|
| 16 |
+
class _FallbackApp:
|
| 17 |
+
def get(self, _: str):
|
| 18 |
+
def decorator(func):
|
| 19 |
+
return func
|
| 20 |
+
return decorator
|
| 21 |
+
|
| 22 |
+
app = _FallbackApp()
|
| 23 |
+
else:
|
| 24 |
+
app = FastAPI(title="climlab MCP info app", version="1.0.0")
|
| 25 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
@app.get("/")
|
| 28 |
+
def root() -> dict:
|
| 29 |
return {
|
| 30 |
+
"name": "climlab-mcp",
|
| 31 |
+
"description": "Supplementary API for local development and tool discovery.",
|
| 32 |
+
"mcp_http_path": "/mcp",
|
| 33 |
+
"port": int(os.getenv("PORT", "7860")),
|
| 34 |
}
|
| 35 |
|
| 36 |
+
|
| 37 |
@app.get("/health")
|
| 38 |
+
def health() -> dict:
|
| 39 |
+
return {"status": "healthy"}
|
| 40 |
+
|
| 41 |
|
| 42 |
@app.get("/tools")
|
| 43 |
+
def tools() -> dict:
|
| 44 |
+
mcp_service = importlib.import_module("mcp_service")
|
| 45 |
+
create_app = getattr(mcp_service, "create_app")
|
| 46 |
+
|
| 47 |
+
mcp = create_app()
|
| 48 |
+
tool_entries = []
|
| 49 |
+
|
| 50 |
+
raw_tools = getattr(mcp, "tools", [])
|
| 51 |
+
if isinstance(raw_tools, dict):
|
| 52 |
+
iterable = raw_tools.values()
|
| 53 |
+
else:
|
| 54 |
+
iterable = raw_tools
|
| 55 |
+
|
| 56 |
+
for item in iterable:
|
| 57 |
+
if isinstance(item, dict):
|
| 58 |
+
name = item.get("name", "unknown")
|
| 59 |
+
description = item.get("description", "")
|
| 60 |
+
else:
|
| 61 |
+
name = getattr(item, "name", "unknown")
|
| 62 |
+
description = getattr(item, "description", "")
|
| 63 |
+
tool_entries.append({"name": name, "description": description})
|
| 64 |
+
|
| 65 |
+
return {"count": len(tool_entries), "tools": tool_entries}
|
climlab/mcp_output/README_MCP.md
CHANGED
|
@@ -1,124 +1,182 @@
|
|
| 1 |
-
# climlab MCP
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
-
|
| 9 |
-
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
-
|
| 34 |
-
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
-
|
| 50 |
-
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
-
|
| 66 |
-
-
|
| 67 |
-
- `
|
| 68 |
-
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
- `
|
| 86 |
-
-
|
| 87 |
-
- `
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
-
|
| 103 |
-
-
|
| 104 |
-
-
|
| 105 |
-
|
| 106 |
-
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# climlab MCP Plugin
|
| 2 |
+
|
| 3 |
+
`climlab` 的 MCP 工具层,面向本地 `stdio` 与服务化 `http` 两种传输方式。
|
| 4 |
+
|
| 5 |
+
## 已暴露工具
|
| 6 |
+
|
| 7 |
+
### 1) `health_check()`
|
| 8 |
+
- 描述:检查服务与关键依赖(`fastmcp`、`climlab`、`numpy`、`adapter`)可用性。
|
| 9 |
+
- 返回:依赖状态、适配器加载统计、默认传输模式。
|
| 10 |
+
|
| 11 |
+
示例:
|
| 12 |
+
```json
|
| 13 |
+
{
|
| 14 |
+
"name": "health_check",
|
| 15 |
+
"arguments": {}
|
| 16 |
+
}
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
### 2) `get_version()`
|
| 20 |
+
- 描述:获取已安装 `climlab` 的版本。
|
| 21 |
+
- 参数:无。
|
| 22 |
+
|
| 23 |
+
示例:
|
| 24 |
+
```json
|
| 25 |
+
{
|
| 26 |
+
"name": "get_version",
|
| 27 |
+
"arguments": {}
|
| 28 |
+
}
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
### 3) `list_modules(include_failed=true)`
|
| 32 |
+
- 描述:列出适配器发现并尝试导入的模块。
|
| 33 |
+
- 参数:
|
| 34 |
+
- `include_failed` (bool, 默认 `true`):是否返回失败模块及原因。
|
| 35 |
+
|
| 36 |
+
示例:
|
| 37 |
+
```json
|
| 38 |
+
{
|
| 39 |
+
"name": "list_modules",
|
| 40 |
+
"arguments": {
|
| 41 |
+
"include_failed": true
|
| 42 |
+
}
|
| 43 |
+
}
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### 4) `list_symbols(module_name, include_private=false)`
|
| 47 |
+
- 描述:查看指定模块符号列表。
|
| 48 |
+
- 参数:
|
| 49 |
+
- `module_name` (str):如 `climlab.solar.insolation`
|
| 50 |
+
- `include_private` (bool, 默认 `false`)
|
| 51 |
+
|
| 52 |
+
示例:
|
| 53 |
+
```json
|
| 54 |
+
{
|
| 55 |
+
"name": "list_symbols",
|
| 56 |
+
"arguments": {
|
| 57 |
+
"module_name": "climlab.solar.insolation",
|
| 58 |
+
"include_private": false
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
```
|
| 62 |
+
|
| 63 |
+
### 5) `compute_daily_insolation(lat, day, solar_constant=1365.2)`
|
| 64 |
+
- 描述:计算指定纬度与日序的日平均顶层入射短波。
|
| 65 |
+
- 参数:
|
| 66 |
+
- `lat` (float):纬度(度)
|
| 67 |
+
- `day` (float):年内日序
|
| 68 |
+
- `solar_constant` (float, 默认 `1365.2`)
|
| 69 |
+
|
| 70 |
+
示例:
|
| 71 |
+
```json
|
| 72 |
+
{
|
| 73 |
+
"name": "compute_daily_insolation",
|
| 74 |
+
"arguments": {
|
| 75 |
+
"lat": 65.0,
|
| 76 |
+
"day": 172.0,
|
| 77 |
+
"solar_constant": 1365.2
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
### 6) `run_ebm_simulation(years=1.0, num_lat=36, water_depth=10.0)`
|
| 83 |
+
- 描述:构建并积分一个基础 `EBM`,返回全局平均诊断量。
|
| 84 |
+
- 参数:
|
| 85 |
+
- `years` (float)
|
| 86 |
+
- `num_lat` (int)
|
| 87 |
+
- `water_depth` (float)
|
| 88 |
+
|
| 89 |
+
示例:
|
| 90 |
+
```json
|
| 91 |
+
{
|
| 92 |
+
"name": "run_ebm_simulation",
|
| 93 |
+
"arguments": {
|
| 94 |
+
"years": 2.0,
|
| 95 |
+
"num_lat": 72,
|
| 96 |
+
"water_depth": 20.0
|
| 97 |
+
}
|
| 98 |
+
}
|
| 99 |
+
```
|
| 100 |
+
|
| 101 |
+
### 7) `run_grey_radiation_column(num_lev=30, num_steps=10, water_depth=1.0, insolation=341.3)`
|
| 102 |
+
- 描述:运行灰气体柱模式若干步并返回均值诊断。
|
| 103 |
+
- 参数:
|
| 104 |
+
- `num_lev` (int)
|
| 105 |
+
- `num_steps` (int)
|
| 106 |
+
- `water_depth` (float)
|
| 107 |
+
- `insolation` (float)
|
| 108 |
+
|
| 109 |
+
示例:
|
| 110 |
+
```json
|
| 111 |
+
{
|
| 112 |
+
"name": "run_grey_radiation_column",
|
| 113 |
+
"arguments": {
|
| 114 |
+
"num_lev": 30,
|
| 115 |
+
"num_steps": 20,
|
| 116 |
+
"water_depth": 1.0,
|
| 117 |
+
"insolation": 341.3
|
| 118 |
+
}
|
| 119 |
+
}
|
| 120 |
+
```
|
| 121 |
+
|
| 122 |
+
### 8) `call_function(module_name, function_name, kwargs_json="{}")`
|
| 123 |
+
- 描述:通过白名单调用函数(仅允许 `climlab.solar.*`、`climlab.model.*`、`climlab.utils.*`)。
|
| 124 |
+
- 参数:
|
| 125 |
+
- `module_name` (str)
|
| 126 |
+
- `function_name` (str)
|
| 127 |
+
- `kwargs_json` (str):JSON 对象字符串
|
| 128 |
+
|
| 129 |
+
示例:
|
| 130 |
+
```json
|
| 131 |
+
{
|
| 132 |
+
"name": "call_function",
|
| 133 |
+
"arguments": {
|
| 134 |
+
"module_name": "climlab.solar.insolation",
|
| 135 |
+
"function_name": "daily_insolation",
|
| 136 |
+
"kwargs_json": "{\"lat\": 45, \"day\": 80}"
|
| 137 |
+
}
|
| 138 |
+
}
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
## 统一返回格式
|
| 142 |
+
|
| 143 |
+
所有工具返回统一结构:
|
| 144 |
+
|
| 145 |
+
```json
|
| 146 |
+
{
|
| 147 |
+
"success": true,
|
| 148 |
+
"result": {},
|
| 149 |
+
"error": null
|
| 150 |
+
}
|
| 151 |
+
```
|
| 152 |
+
|
| 153 |
+
失败时:
|
| 154 |
+
|
| 155 |
+
```json
|
| 156 |
+
{
|
| 157 |
+
"success": false,
|
| 158 |
+
"result": null,
|
| 159 |
+
"error": "..."
|
| 160 |
+
}
|
| 161 |
+
```
|
| 162 |
+
|
| 163 |
+
## 本地运行(stdio)
|
| 164 |
+
|
| 165 |
+
```bash
|
| 166 |
+
cd climlab/mcp_output
|
| 167 |
+
python start_mcp.py
|
| 168 |
+
```
|
| 169 |
+
|
| 170 |
+
或显式指定:
|
| 171 |
+
|
| 172 |
+
```bash
|
| 173 |
+
MCP_TRANSPORT=stdio MCP_PORT=8000 python start_mcp.py
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
## HTTP 运行
|
| 177 |
+
|
| 178 |
+
```bash
|
| 179 |
+
MCP_TRANSPORT=http MCP_PORT=7860 python start_mcp.py
|
| 180 |
+
```
|
| 181 |
+
|
| 182 |
+
默认情况下客户端连接 `http://<host>:<port>/mcp`。
|
climlab/mcp_output/mcp_plugin/adapter.py
CHANGED
|
@@ -1,321 +1,201 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import sys
|
| 3 |
-
import traceback
|
| 4 |
-
import inspect
|
| 5 |
import importlib
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
)
|
| 12 |
-
if source_path not in sys.path:
|
| 13 |
-
sys.path.insert(0, source_path)
|
| 14 |
|
| 15 |
|
| 16 |
class Adapter:
|
| 17 |
-
""
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
"""
|
| 24 |
-
|
| 25 |
-
# -------------------------------------------------------------------------
|
| 26 |
-
# Initialization and Module Management
|
| 27 |
-
# -------------------------------------------------------------------------
|
| 28 |
-
def __init__(self) -> None:
|
| 29 |
-
self.mode = "import"
|
| 30 |
-
self._imports: Dict[str, Any] = {}
|
| 31 |
-
self._import_errors: Dict[str, str] = {}
|
| 32 |
-
self._modules_to_load = [
|
| 33 |
-
"climlab",
|
| 34 |
-
"climlab.convection.akmaev_adjustment",
|
| 35 |
-
"climlab.convection.convadj",
|
| 36 |
-
"climlab.convection.emanuel_convection",
|
| 37 |
-
"climlab.convection.simplified_betts_miller",
|
| 38 |
-
"climlab.domain.axis",
|
| 39 |
-
"climlab.domain.domain",
|
| 40 |
-
"climlab.domain.field",
|
| 41 |
-
"climlab.domain.initial",
|
| 42 |
-
"climlab.domain.xarray",
|
| 43 |
-
"climlab.dynamics.adv_diff_numerics",
|
| 44 |
-
"climlab.dynamics.advection_diffusion",
|
| 45 |
-
"climlab.dynamics.budyko_transport",
|
| 46 |
-
"climlab.dynamics.large_scale_condensation",
|
| 47 |
-
"climlab.dynamics.meridional_advection_diffusion",
|
| 48 |
-
"climlab.dynamics.meridional_heat_diffusion",
|
| 49 |
-
"climlab.dynamics.meridional_moist_diffusion",
|
| 50 |
-
"climlab.model.column",
|
| 51 |
-
"climlab.model.ebm",
|
| 52 |
-
"climlab.model.stommelbox",
|
| 53 |
-
"climlab.process.diagnostic",
|
| 54 |
-
"climlab.process.energy_budget",
|
| 55 |
-
"climlab.process.external_forcing",
|
| 56 |
-
"climlab.process.implicit",
|
| 57 |
-
"climlab.process.limiter",
|
| 58 |
-
"climlab.process.process",
|
| 59 |
-
"climlab.process.time_dependent_process",
|
| 60 |
-
"climlab.radiation.absorbed_shorwave",
|
| 61 |
-
"climlab.radiation.aplusbt",
|
| 62 |
-
"climlab.radiation.boltzmann",
|
| 63 |
-
"climlab.radiation.cam3",
|
| 64 |
-
"climlab.radiation.greygas",
|
| 65 |
-
"climlab.radiation.insolation",
|
| 66 |
-
"climlab.radiation.nband",
|
| 67 |
-
"climlab.radiation.radiation",
|
| 68 |
-
"climlab.radiation.rrtm.rrtmg",
|
| 69 |
-
"climlab.radiation.rrtm.rrtmg_lw",
|
| 70 |
-
"climlab.radiation.rrtm.rrtmg_sw",
|
| 71 |
-
"climlab.radiation.rrtm.utils",
|
| 72 |
-
"climlab.radiation.transmissivity",
|
| 73 |
-
"climlab.radiation.water_vapor",
|
| 74 |
-
"climlab.solar.insolation",
|
| 75 |
-
"climlab.solar.orbital.long",
|
| 76 |
-
"climlab.solar.orbital.table",
|
| 77 |
-
"climlab.solar.orbital_cycles",
|
| 78 |
-
"climlab.surface.albedo",
|
| 79 |
-
"climlab.surface.surface_radiation",
|
| 80 |
-
"climlab.surface.turbulent",
|
| 81 |
-
"climlab.utils.constants",
|
| 82 |
-
"climlab.utils.heat_capacity",
|
| 83 |
-
"climlab.utils.legendre",
|
| 84 |
-
"climlab.utils.thermo",
|
| 85 |
-
"climlab.utils.walk",
|
| 86 |
-
]
|
| 87 |
self._load_modules()
|
| 88 |
|
| 89 |
-
def _result(self, status: str, **kwargs: Any) -> Dict[str, Any]:
|
| 90 |
-
data = {"status": status}
|
| 91 |
-
data.update(kwargs)
|
| 92 |
-
return data
|
| 93 |
-
|
| 94 |
def _load_modules(self) -> None:
|
| 95 |
-
for mod in self._modules_to_load:
|
| 96 |
-
try:
|
| 97 |
-
self._imports[mod] = importlib.import_module(mod)
|
| 98 |
-
except Exception as e:
|
| 99 |
-
self._import_errors[mod] = f"{type(e).__name__}: {e}"
|
| 100 |
-
|
| 101 |
-
def health(self) -> Dict[str, Any]:
|
| 102 |
-
"""
|
| 103 |
-
Return adapter health and import readiness.
|
| 104 |
-
|
| 105 |
-
Returns:
|
| 106 |
-
dict: Unified status dictionary with mode, loaded modules, and import errors.
|
| 107 |
-
"""
|
| 108 |
-
return self._result(
|
| 109 |
-
"ok" if len(self._import_errors) == 0 else "partial",
|
| 110 |
-
mode=self.mode,
|
| 111 |
-
loaded_count=len(self._imports),
|
| 112 |
-
failed_count=len(self._import_errors),
|
| 113 |
-
failed_modules=self._import_errors,
|
| 114 |
-
guidance=(
|
| 115 |
-
"Install required dependencies: numpy, scipy. "
|
| 116 |
-
"Optional: xarray, matplotlib, netCDF4, numba. "
|
| 117 |
-
"RRTMG modules may require Fortran-compiled extensions."
|
| 118 |
-
),
|
| 119 |
-
)
|
| 120 |
-
|
| 121 |
-
# -------------------------------------------------------------------------
|
| 122 |
-
# Generic Reflection and Invocation Utilities
|
| 123 |
-
# -------------------------------------------------------------------------
|
| 124 |
-
def list_module_symbols(self, module_name: str) -> Dict[str, Any]:
|
| 125 |
-
"""
|
| 126 |
-
List public symbols in a module.
|
| 127 |
-
|
| 128 |
-
Parameters:
|
| 129 |
-
module_name (str): Full module path, e.g., 'climlab.model.ebm'.
|
| 130 |
-
|
| 131 |
-
Returns:
|
| 132 |
-
dict: Status + module symbols or error details.
|
| 133 |
-
"""
|
| 134 |
try:
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
"
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
-
Returns:
|
| 219 |
-
dict: Status + symbol metadata.
|
| 220 |
-
"""
|
| 221 |
try:
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
return
|
| 232 |
-
"
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
return
|
| 242 |
-
"
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
# -------------------------------------------------------------------------
|
| 250 |
-
# Bulk Operations for Full Repository Utilization
|
| 251 |
-
# -------------------------------------------------------------------------
|
| 252 |
-
def scan_all_modules(self) -> Dict[str, Any]:
|
| 253 |
-
"""
|
| 254 |
-
Scan all configured modules and report available public classes and functions.
|
| 255 |
-
|
| 256 |
-
Returns:
|
| 257 |
-
dict: Status + catalog of discovered APIs across climlab modules.
|
| 258 |
-
"""
|
| 259 |
-
catalog: Dict[str, Dict[str, List[str]]] = {}
|
| 260 |
-
errors: Dict[str, str] = {}
|
| 261 |
-
for mod_name in self._modules_to_load:
|
| 262 |
try:
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
)
|
| 283 |
-
|
| 284 |
-
def invoke(self, module_name: str, symbol_name: str, *args: Any, **kwargs: Any) -> Dict[str, Any]:
|
| 285 |
-
"""
|
| 286 |
-
Unified invoke entrypoint.
|
| 287 |
-
- If symbol is a class: instantiate it.
|
| 288 |
-
- If symbol is callable: call it.
|
| 289 |
-
|
| 290 |
-
Parameters:
|
| 291 |
-
module_name (str): Full module path.
|
| 292 |
-
symbol_name (str): Symbol name in module.
|
| 293 |
-
*args: Positional args.
|
| 294 |
-
**kwargs: Keyword args.
|
| 295 |
|
| 296 |
-
Returns:
|
| 297 |
-
dict: Unified status dictionary with invocation result.
|
| 298 |
-
"""
|
| 299 |
try:
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
"
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
"error",
|
| 316 |
-
|
| 317 |
-
symbol_name=symbol_name,
|
| 318 |
-
error=f"{type(e).__name__}: {e}",
|
| 319 |
-
traceback=traceback.format_exc(),
|
| 320 |
-
guidance="Ensure correct module path and symbol name.",
|
| 321 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import importlib
|
| 2 |
+
import inspect
|
| 3 |
+
import pkgutil
|
| 4 |
+
import sys
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
|
| 8 |
+
SOURCE_DIR = Path(__file__).resolve().parents[2] / "source"
|
| 9 |
+
if SOURCE_DIR.exists() and str(SOURCE_DIR) not in sys.path:
|
| 10 |
+
sys.path.insert(0, str(SOURCE_DIR))
|
|
|
|
|
|
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
class Adapter:
|
| 14 |
+
def __init__(self, package_name: str = "climlab") -> None:
|
| 15 |
+
self.package_name = package_name
|
| 16 |
+
self.package = None
|
| 17 |
+
self.loaded_modules: dict[str, Any] = {}
|
| 18 |
+
self.failed_modules: dict[str, str] = {}
|
| 19 |
+
self.mode = "normal"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
self._load_modules()
|
| 21 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
def _load_modules(self) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
try:
|
| 24 |
+
self.package = importlib.import_module(self.package_name)
|
| 25 |
+
self.loaded_modules[self.package_name] = self.package
|
| 26 |
+
except Exception as exc:
|
| 27 |
+
self.package = None
|
| 28 |
+
self.mode = "blackbox"
|
| 29 |
+
self.failed_modules[self.package_name] = str(exc)
|
| 30 |
+
return
|
| 31 |
+
|
| 32 |
+
package_path = getattr(self.package, "__path__", None)
|
| 33 |
+
if package_path is None:
|
| 34 |
+
self.mode = "blackbox"
|
| 35 |
+
return
|
| 36 |
+
|
| 37 |
+
for module_info in pkgutil.walk_packages(package_path, prefix=f"{self.package_name}."):
|
| 38 |
+
module_name = module_info.name
|
| 39 |
+
try:
|
| 40 |
+
module = importlib.import_module(module_name)
|
| 41 |
+
self.loaded_modules[module_name] = module
|
| 42 |
+
except Exception as exc:
|
| 43 |
+
self.failed_modules[module_name] = str(exc)
|
| 44 |
+
|
| 45 |
+
if len(self.loaded_modules) <= 1:
|
| 46 |
+
self.mode = "blackbox"
|
| 47 |
+
|
| 48 |
+
def health(self) -> dict[str, Any]:
|
| 49 |
+
if self.package is None:
|
| 50 |
+
return {
|
| 51 |
+
"status": "fallback",
|
| 52 |
+
"mode": "blackbox",
|
| 53 |
+
"package": self.package_name,
|
| 54 |
+
"loaded_count": 0,
|
| 55 |
+
"failed_count": len(self.failed_modules),
|
| 56 |
+
"errors": self.failed_modules,
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
return {
|
| 60 |
+
"status": "ok" if self.mode == "normal" else "fallback",
|
| 61 |
+
"mode": self.mode,
|
| 62 |
+
"package": self.package_name,
|
| 63 |
+
"loaded_count": len(self.loaded_modules),
|
| 64 |
+
"failed_count": len(self.failed_modules),
|
| 65 |
+
"failed_examples": dict(list(self.failed_modules.items())[:10]),
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
def list_modules(self, include_failed: bool = True) -> dict[str, Any]:
|
| 69 |
+
return {
|
| 70 |
+
"status": "ok",
|
| 71 |
+
"mode": self.mode,
|
| 72 |
+
"loaded": sorted(self.loaded_modules.keys()),
|
| 73 |
+
"failed": self.failed_modules if include_failed else {},
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
def list_symbols(self, module_name: str, include_private: bool = False) -> dict[str, Any]:
|
| 77 |
+
module = self.loaded_modules.get(module_name)
|
| 78 |
+
if module is None:
|
| 79 |
+
try:
|
| 80 |
+
module = importlib.import_module(module_name)
|
| 81 |
+
self.loaded_modules[module_name] = module
|
| 82 |
+
except Exception as exc:
|
| 83 |
+
return {
|
| 84 |
+
"status": "error",
|
| 85 |
+
"mode": self.mode,
|
| 86 |
+
"module": module_name,
|
| 87 |
+
"error": str(exc),
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
symbols: list[dict[str, str]] = []
|
| 91 |
+
for name, obj in inspect.getmembers(module):
|
| 92 |
+
if not include_private and name.startswith("_"):
|
| 93 |
+
continue
|
| 94 |
+
symbols.append({"name": name, "kind": type(obj).__name__})
|
| 95 |
+
|
| 96 |
+
return {
|
| 97 |
+
"status": "ok",
|
| 98 |
+
"mode": self.mode,
|
| 99 |
+
"module": module_name,
|
| 100 |
+
"count": len(symbols),
|
| 101 |
+
"symbols": symbols,
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
def call_function(self, module_name: str, function_name: str, kwargs: dict[str, Any]) -> dict[str, Any]:
|
| 105 |
+
if self.mode == "blackbox":
|
| 106 |
+
return {
|
| 107 |
+
"status": "fallback",
|
| 108 |
+
"mode": self.mode,
|
| 109 |
+
"error": "Adapter is in blackbox mode; no modules are available.",
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
module = self.loaded_modules.get(module_name)
|
| 113 |
+
if module is None:
|
| 114 |
+
try:
|
| 115 |
+
module = importlib.import_module(module_name)
|
| 116 |
+
self.loaded_modules[module_name] = module
|
| 117 |
+
except Exception as exc:
|
| 118 |
+
return {
|
| 119 |
+
"status": "error",
|
| 120 |
+
"mode": self.mode,
|
| 121 |
+
"module": module_name,
|
| 122 |
+
"error": str(exc),
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
target = getattr(module, function_name, None)
|
| 126 |
+
if target is None or not callable(target):
|
| 127 |
+
return {
|
| 128 |
+
"status": "error",
|
| 129 |
+
"mode": self.mode,
|
| 130 |
+
"module": module_name,
|
| 131 |
+
"function": function_name,
|
| 132 |
+
"error": "Function not found or not callable.",
|
| 133 |
+
}
|
| 134 |
|
|
|
|
|
|
|
|
|
|
| 135 |
try:
|
| 136 |
+
result = target(**kwargs)
|
| 137 |
+
return {
|
| 138 |
+
"status": "ok",
|
| 139 |
+
"mode": self.mode,
|
| 140 |
+
"module": module_name,
|
| 141 |
+
"function": function_name,
|
| 142 |
+
"result": result,
|
| 143 |
+
}
|
| 144 |
+
except Exception as exc:
|
| 145 |
+
return {
|
| 146 |
+
"status": "error",
|
| 147 |
+
"mode": self.mode,
|
| 148 |
+
"module": module_name,
|
| 149 |
+
"function": function_name,
|
| 150 |
+
"error": str(exc),
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
def create_instance(self, module_name: str, class_name: str, kwargs: dict[str, Any]) -> dict[str, Any]:
|
| 154 |
+
if self.mode == "blackbox":
|
| 155 |
+
return {
|
| 156 |
+
"status": "fallback",
|
| 157 |
+
"mode": self.mode,
|
| 158 |
+
"error": "Adapter is in blackbox mode; no modules are available.",
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
module = self.loaded_modules.get(module_name)
|
| 162 |
+
if module is None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 163 |
try:
|
| 164 |
+
module = importlib.import_module(module_name)
|
| 165 |
+
self.loaded_modules[module_name] = module
|
| 166 |
+
except Exception as exc:
|
| 167 |
+
return {
|
| 168 |
+
"status": "error",
|
| 169 |
+
"mode": self.mode,
|
| 170 |
+
"module": module_name,
|
| 171 |
+
"error": str(exc),
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
target = getattr(module, class_name, None)
|
| 175 |
+
if target is None or not inspect.isclass(target):
|
| 176 |
+
return {
|
| 177 |
+
"status": "error",
|
| 178 |
+
"mode": self.mode,
|
| 179 |
+
"module": module_name,
|
| 180 |
+
"class": class_name,
|
| 181 |
+
"error": "Class not found.",
|
| 182 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
|
|
|
|
|
|
|
|
|
|
| 184 |
try:
|
| 185 |
+
instance = target(**kwargs)
|
| 186 |
+
return {
|
| 187 |
+
"status": "ok",
|
| 188 |
+
"mode": self.mode,
|
| 189 |
+
"module": module_name,
|
| 190 |
+
"class": class_name,
|
| 191 |
+
"instance_type": type(instance).__name__,
|
| 192 |
+
"repr": repr(instance),
|
| 193 |
+
}
|
| 194 |
+
except Exception as exc:
|
| 195 |
+
return {
|
| 196 |
+
"status": "error",
|
| 197 |
+
"mode": self.mode,
|
| 198 |
+
"module": module_name,
|
| 199 |
+
"class": class_name,
|
| 200 |
+
"error": str(exc),
|
| 201 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
climlab/mcp_output/mcp_plugin/main.py
CHANGED
|
@@ -1,13 +1,6 @@
|
|
| 1 |
-
"""
|
| 2 |
-
MCP Service Auto-Wrapper - Auto-generated
|
| 3 |
-
"""
|
| 4 |
from mcp_service import create_app
|
| 5 |
|
| 6 |
-
|
| 7 |
-
"""Main entry point"""
|
| 8 |
-
app = create_app()
|
| 9 |
-
return app
|
| 10 |
-
|
| 11 |
if __name__ == "__main__":
|
| 12 |
-
app =
|
| 13 |
-
app.run()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from mcp_service import create_app
|
| 2 |
|
| 3 |
+
# Local stdio entrypoint only (Claude Desktop / CLI); not for web or Docker deployment.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
if __name__ == "__main__":
|
| 5 |
+
app = create_app()
|
| 6 |
+
app.run()
|
climlab/mcp_output/mcp_plugin/mcp_service.py
CHANGED
|
@@ -1,292 +1,267 @@
|
|
|
|
|
| 1 |
import os
|
| 2 |
import sys
|
| 3 |
-
from
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
-
source_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "source")
|
| 6 |
-
if source_path not in sys.path:
|
| 7 |
-
sys.path.insert(0, source_path)
|
| 8 |
|
| 9 |
-
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
| 12 |
|
|
|
|
| 13 |
|
| 14 |
-
def
|
| 15 |
-
|
| 16 |
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
"""
|
| 25 |
-
Return basic package metadata.
|
| 26 |
|
| 27 |
-
Returns:
|
| 28 |
-
dict: Standard response dictionary with success/result/error fields.
|
| 29 |
-
"""
|
| 30 |
-
try:
|
| 31 |
-
import climlab # type: ignore
|
| 32 |
-
|
| 33 |
-
return _ok(
|
| 34 |
-
{
|
| 35 |
-
"version": getattr(climlab, "__version__", "unknown"),
|
| 36 |
-
"module": "climlab",
|
| 37 |
-
}
|
| 38 |
-
)
|
| 39 |
-
except Exception as exc:
|
| 40 |
-
return _err(exc)
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
-
@mcp.tool(name="create_ebm_model", description="Create an Energy Balance Model (EBM) with configurable diffusion and grid.")
|
| 44 |
-
def create_ebm_model(num_lat: int = 90, D: float = 0.55, water_depth: float = 10.0) -> Dict:
|
| 45 |
-
"""
|
| 46 |
-
Create a climlab EBM model instance and summarize key diagnostics.
|
| 47 |
|
| 48 |
-
|
| 49 |
-
num_lat: Number of latitude points.
|
| 50 |
-
D: Diffusivity parameter for meridional heat transport.
|
| 51 |
-
water_depth: Slab ocean water depth in meters.
|
| 52 |
-
|
| 53 |
-
Returns:
|
| 54 |
-
dict: Standard response dictionary with model summary.
|
| 55 |
-
"""
|
| 56 |
try:
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
result = {
|
| 61 |
-
"
|
| 62 |
-
"
|
| 63 |
-
"
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
|
|
|
|
|
|
|
|
|
| 67 |
}
|
| 68 |
-
return
|
| 69 |
except Exception as exc:
|
| 70 |
-
return
|
| 71 |
|
| 72 |
|
| 73 |
-
@mcp.tool(name="
|
| 74 |
-
def
|
| 75 |
-
"""
|
| 76 |
-
Run a climlab EBM simulation.
|
| 77 |
-
|
| 78 |
-
Args:
|
| 79 |
-
num_lat: Number of latitude points.
|
| 80 |
-
years: Integration length in years.
|
| 81 |
-
D: Diffusivity parameter.
|
| 82 |
-
water_depth: Slab ocean water depth in meters.
|
| 83 |
|
| 84 |
Returns:
|
| 85 |
-
|
| 86 |
"""
|
| 87 |
try:
|
| 88 |
-
|
| 89 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
-
model = EBM(num_lat=num_lat, D=D, water_depth=water_depth)
|
| 92 |
-
model.integrate_years(years)
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
global_mean_ts = float(np.mean(ts))
|
| 99 |
-
return _ok(
|
| 100 |
-
{
|
| 101 |
-
"years": years,
|
| 102 |
-
"num_lat": num_lat,
|
| 103 |
-
"global_mean_surface_temp": global_mean_ts,
|
| 104 |
-
}
|
| 105 |
-
)
|
| 106 |
-
except Exception as exc:
|
| 107 |
-
return _err(exc)
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
@mcp.tool(name="compute_daily_insolation", description="Compute daily mean insolation for latitude/day with optional orbital parameters.")
|
| 111 |
-
def compute_daily_insolation(
|
| 112 |
-
latitude: float,
|
| 113 |
-
day_of_year: int,
|
| 114 |
-
ecc: Optional[float] = None,
|
| 115 |
-
long_peri: Optional[float] = None,
|
| 116 |
-
obliquity: Optional[float] = None,
|
| 117 |
-
solar_constant: float = 1365.2,
|
| 118 |
-
) -> Dict:
|
| 119 |
-
"""
|
| 120 |
-
Compute daily insolation using climlab solar tools.
|
| 121 |
|
| 122 |
Args:
|
| 123 |
-
|
| 124 |
-
day_of_year: Day of year (1-365/366).
|
| 125 |
-
ecc: Orbital eccentricity (optional).
|
| 126 |
-
long_peri: Longitude of perihelion in degrees (optional).
|
| 127 |
-
obliquity: Obliquity in degrees (optional).
|
| 128 |
-
solar_constant: Solar constant in W/m^2.
|
| 129 |
-
|
| 130 |
-
Returns:
|
| 131 |
-
dict: Standard response dictionary with insolation value(s).
|
| 132 |
"""
|
| 133 |
try:
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
if ecc is not None:
|
| 138 |
-
kwargs["ecc"] = ecc
|
| 139 |
-
if long_peri is not None:
|
| 140 |
-
kwargs["long_peri"] = long_peri
|
| 141 |
-
if obliquity is not None:
|
| 142 |
-
kwargs["obliquity"] = obliquity
|
| 143 |
-
|
| 144 |
-
value = daily_insolation(lat=latitude, day=day_of_year, **kwargs)
|
| 145 |
-
try:
|
| 146 |
-
output = float(value)
|
| 147 |
-
except Exception:
|
| 148 |
-
output = str(value)
|
| 149 |
-
|
| 150 |
-
return _ok(
|
| 151 |
-
{
|
| 152 |
-
"latitude": latitude,
|
| 153 |
-
"day_of_year": day_of_year,
|
| 154 |
-
"insolation": output,
|
| 155 |
-
}
|
| 156 |
-
)
|
| 157 |
except Exception as exc:
|
| 158 |
-
return
|
| 159 |
|
| 160 |
|
| 161 |
-
@mcp.tool(name="
|
| 162 |
-
def
|
| 163 |
-
|
| 164 |
-
days_per_year: int = 365,
|
| 165 |
-
solar_constant: float = 1365.2,
|
| 166 |
-
) -> Dict:
|
| 167 |
-
"""
|
| 168 |
-
Compute annual-mean insolation for a list of latitudes.
|
| 169 |
|
| 170 |
Args:
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
solar_constant: Solar constant in W/m^2.
|
| 174 |
-
|
| 175 |
-
Returns:
|
| 176 |
-
dict: Standard response dictionary with latitude/insolation pairs.
|
| 177 |
"""
|
| 178 |
try:
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
days = np.arange(1, days_per_year + 1)
|
| 183 |
-
profile = []
|
| 184 |
-
for lat in latitudes:
|
| 185 |
-
vals = daily_insolation(lat=lat, day=days, S0=solar_constant)
|
| 186 |
-
mean_val = float(np.mean(vals))
|
| 187 |
-
profile.append({"latitude": float(lat), "annual_mean_insolation": mean_val})
|
| 188 |
-
return _ok({"profile": profile})
|
| 189 |
except Exception as exc:
|
| 190 |
-
return
|
| 191 |
|
| 192 |
|
| 193 |
-
@mcp.tool(name="
|
| 194 |
-
def
|
| 195 |
-
"""
|
| 196 |
-
Compute blackbody (or gray-body) outgoing longwave radiation.
|
| 197 |
|
| 198 |
Args:
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
Returns:
|
| 203 |
-
dict: Standard response dictionary with OLR in W/m^2.
|
| 204 |
"""
|
| 205 |
try:
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
except Exception as exc:
|
| 212 |
-
return
|
| 213 |
|
| 214 |
|
| 215 |
-
@mcp.tool(name="
|
| 216 |
-
def
|
| 217 |
-
"""
|
| 218 |
-
Compute outgoing longwave radiation using A+BT relation.
|
| 219 |
|
| 220 |
Args:
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
Returns:
|
| 226 |
-
dict: Standard response dictionary with OLR.
|
| 227 |
"""
|
| 228 |
try:
|
| 229 |
-
|
|
|
|
|
|
|
|
|
|
| 230 |
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
except Exception as exc:
|
| 235 |
-
return
|
| 236 |
|
| 237 |
|
| 238 |
-
@mcp.tool(name="
|
| 239 |
-
def
|
| 240 |
-
"""
|
| 241 |
-
Compute selected thermodynamic diagnostics.
|
| 242 |
|
| 243 |
Args:
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
dict: Standard response dictionary with saturation vapor pressure and specific humidity estimates.
|
| 249 |
"""
|
| 250 |
try:
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
except Exception as exc:
|
| 264 |
-
return
|
| 265 |
|
| 266 |
|
| 267 |
-
@mcp.tool(name="
|
| 268 |
-
def
|
| 269 |
-
"""
|
| 270 |
-
Compute heat capacity for a slab ocean.
|
| 271 |
|
| 272 |
Args:
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
dict: Standard response dictionary with heat capacity in J m^-2 K^-1.
|
| 277 |
"""
|
| 278 |
try:
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
-
|
| 282 |
-
|
|
|
|
|
|
|
| 283 |
except Exception as exc:
|
| 284 |
-
return
|
| 285 |
|
| 286 |
|
| 287 |
-
def create_app()
|
| 288 |
return mcp
|
| 289 |
|
| 290 |
|
| 291 |
if __name__ == "__main__":
|
| 292 |
-
mcp.run()
|
|
|
|
| 1 |
+
import json
|
| 2 |
import os
|
| 3 |
import sys
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
SOURCE_DIR = Path(__file__).resolve().parents[2] / "source"
|
| 8 |
+
if SOURCE_DIR.exists() and str(SOURCE_DIR) not in sys.path:
|
| 9 |
+
sys.path.insert(0, str(SOURCE_DIR))
|
| 10 |
+
|
| 11 |
+
try:
|
| 12 |
+
from fastmcp import FastMCP
|
| 13 |
+
except Exception:
|
| 14 |
+
FastMCP = None
|
| 15 |
+
|
| 16 |
+
try:
|
| 17 |
+
import numpy as np
|
| 18 |
+
except Exception:
|
| 19 |
+
np = None
|
| 20 |
+
|
| 21 |
+
try:
|
| 22 |
+
import climlab
|
| 23 |
+
except Exception:
|
| 24 |
+
climlab = None
|
| 25 |
+
|
| 26 |
+
try:
|
| 27 |
+
from .adapter import Adapter
|
| 28 |
+
except Exception:
|
| 29 |
+
try:
|
| 30 |
+
from adapter import Adapter
|
| 31 |
+
except Exception:
|
| 32 |
+
Adapter = None
|
| 33 |
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
+
class _FallbackMCP:
|
| 36 |
+
def __init__(self, name: str) -> None:
|
| 37 |
+
self.name = name
|
| 38 |
+
self.tools = []
|
| 39 |
|
| 40 |
+
def tool(self, name: str, description: str):
|
| 41 |
+
def decorator(func):
|
| 42 |
+
self.tools.append({"name": name, "description": description, "fn": func})
|
| 43 |
+
return func
|
| 44 |
|
| 45 |
+
return decorator
|
| 46 |
|
| 47 |
+
def run(self, **_: Any) -> None:
|
| 48 |
+
raise RuntimeError("fastmcp is not installed; cannot run MCP service.")
|
| 49 |
|
| 50 |
|
| 51 |
+
mcp = FastMCP("climlab-mcp") if FastMCP is not None else _FallbackMCP("climlab-mcp")
|
| 52 |
+
adapter = Adapter("climlab") if Adapter is not None else None
|
| 53 |
|
| 54 |
|
| 55 |
+
def _response(success: bool, result: Any = None, error: str | None = None) -> dict[str, Any]:
|
| 56 |
+
return {"success": success, "result": result, "error": error}
|
|
|
|
|
|
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
+
def _to_python_scalar(value: Any) -> Any:
|
| 60 |
+
if np is None:
|
| 61 |
+
return value
|
| 62 |
+
if isinstance(value, np.generic):
|
| 63 |
+
return value.item()
|
| 64 |
+
return value
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
+
def _safe_float(value: Any) -> Any:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
try:
|
| 69 |
+
return float(value)
|
| 70 |
+
except Exception:
|
| 71 |
+
return _to_python_scalar(value)
|
| 72 |
+
|
| 73 |
|
| 74 |
+
@mcp.tool(name="health_check", description="Check MCP service and dependency availability.")
|
| 75 |
+
def health_check() -> dict[str, Any]:
|
| 76 |
+
"""Return service health and runtime dependency status."""
|
| 77 |
+
try:
|
| 78 |
+
adapter_health = adapter.health() if adapter is not None else {"status": "fallback", "reason": "adapter unavailable"}
|
| 79 |
result = {
|
| 80 |
+
"service": "climlab-mcp",
|
| 81 |
+
"transport_default": os.getenv("MCP_TRANSPORT", "stdio"),
|
| 82 |
+
"dependencies": {
|
| 83 |
+
"fastmcp": FastMCP is not None,
|
| 84 |
+
"climlab": climlab is not None,
|
| 85 |
+
"numpy": np is not None,
|
| 86 |
+
"adapter": adapter is not None,
|
| 87 |
+
},
|
| 88 |
+
"adapter": adapter_health,
|
| 89 |
}
|
| 90 |
+
return _response(True, result=result)
|
| 91 |
except Exception as exc:
|
| 92 |
+
return _response(False, error=str(exc))
|
| 93 |
|
| 94 |
|
| 95 |
+
@mcp.tool(name="get_version", description="Get installed climlab package version.")
|
| 96 |
+
def get_version() -> dict[str, Any]:
|
| 97 |
+
"""Fetch the climlab package version.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
Returns:
|
| 100 |
+
Standard MCP response with package name and version.
|
| 101 |
"""
|
| 102 |
try:
|
| 103 |
+
if climlab is None:
|
| 104 |
+
return _response(False, error="climlab is not available")
|
| 105 |
+
version = getattr(climlab, "__version__", "unknown")
|
| 106 |
+
return _response(True, result={"package": "climlab", "version": version})
|
| 107 |
+
except Exception as exc:
|
| 108 |
+
return _response(False, error=str(exc))
|
| 109 |
|
|
|
|
|
|
|
| 110 |
|
| 111 |
+
@mcp.tool(name="list_modules", description="List loadable climlab modules discovered by adapter.")
|
| 112 |
+
def list_modules(include_failed: bool = True) -> dict[str, Any]:
|
| 113 |
+
"""List modules discovered by the adapter.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
Args:
|
| 116 |
+
include_failed: Whether to include failed imports in output.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
"""
|
| 118 |
try:
|
| 119 |
+
if adapter is None:
|
| 120 |
+
return _response(False, error="adapter is not available")
|
| 121 |
+
return _response(True, result=adapter.list_modules(include_failed=include_failed))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
except Exception as exc:
|
| 123 |
+
return _response(False, error=str(exc))
|
| 124 |
|
| 125 |
|
| 126 |
+
@mcp.tool(name="list_symbols", description="List symbols from a specific climlab module.")
|
| 127 |
+
def list_symbols(module_name: str, include_private: bool = False) -> dict[str, Any]:
|
| 128 |
+
"""Inspect a module and return symbol names.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
Args:
|
| 131 |
+
module_name: Fully-qualified module path, e.g. 'climlab.solar.insolation'.
|
| 132 |
+
include_private: If true, include members starting with underscore.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
"""
|
| 134 |
try:
|
| 135 |
+
if adapter is None:
|
| 136 |
+
return _response(False, error="adapter is not available")
|
| 137 |
+
return _response(True, result=adapter.list_symbols(module_name=module_name, include_private=include_private))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
except Exception as exc:
|
| 139 |
+
return _response(False, error=str(exc))
|
| 140 |
|
| 141 |
|
| 142 |
+
@mcp.tool(name="compute_daily_insolation", description="Compute daily top-of-atmosphere insolation for latitude/day.")
|
| 143 |
+
def compute_daily_insolation(lat: float, day: float, solar_constant: float = 1365.2) -> dict[str, Any]:
|
| 144 |
+
"""Compute daily-average incoming solar radiation.
|
|
|
|
| 145 |
|
| 146 |
Args:
|
| 147 |
+
lat: Latitude in degrees (-90 to 90).
|
| 148 |
+
day: Day of year (calendar day if day_type=1).
|
| 149 |
+
solar_constant: Solar constant in W/m^2.
|
|
|
|
|
|
|
| 150 |
"""
|
| 151 |
try:
|
| 152 |
+
if climlab is None:
|
| 153 |
+
return _response(False, error="climlab is not available")
|
| 154 |
+
values = climlab.solar.insolation.daily_insolation(lat=lat, day=day, S0=solar_constant)
|
| 155 |
+
arr = np.asarray(values) if np is not None else values
|
| 156 |
+
result = {
|
| 157 |
+
"lat": lat,
|
| 158 |
+
"day": day,
|
| 159 |
+
"solar_constant": solar_constant,
|
| 160 |
+
"shape": list(arr.shape) if np is not None else None,
|
| 161 |
+
"insolation_wm2": _safe_float(arr.squeeze() if np is not None else values),
|
| 162 |
+
}
|
| 163 |
+
return _response(True, result=result)
|
| 164 |
except Exception as exc:
|
| 165 |
+
return _response(False, error=str(exc))
|
| 166 |
|
| 167 |
|
| 168 |
+
@mcp.tool(name="run_ebm_simulation", description="Build and integrate a basic climlab EBM model.")
|
| 169 |
+
def run_ebm_simulation(years: float = 1.0, num_lat: int = 36, water_depth: float = 10.0) -> dict[str, Any]:
|
| 170 |
+
"""Run an Energy Balance Model and return summary diagnostics.
|
|
|
|
| 171 |
|
| 172 |
Args:
|
| 173 |
+
years: Number of model years to integrate.
|
| 174 |
+
num_lat: Number of latitude grid points.
|
| 175 |
+
water_depth: Mixed-layer depth in meters.
|
|
|
|
|
|
|
|
|
|
| 176 |
"""
|
| 177 |
try:
|
| 178 |
+
if climlab is None:
|
| 179 |
+
return _response(False, error="climlab is not available")
|
| 180 |
+
model = climlab.EBM(num_lat=num_lat, water_depth=water_depth)
|
| 181 |
+
model.integrate_years(years)
|
| 182 |
|
| 183 |
+
ts_global = climlab.global_mean(model.Ts)
|
| 184 |
+
net_global = climlab.global_mean(model.net_radiation)
|
| 185 |
+
result = {
|
| 186 |
+
"years": years,
|
| 187 |
+
"num_lat": num_lat,
|
| 188 |
+
"water_depth": water_depth,
|
| 189 |
+
"global_mean_surface_temp_c": _safe_float(ts_global),
|
| 190 |
+
"global_mean_net_radiation_wm2": _safe_float(net_global),
|
| 191 |
+
"elapsed_model_years": _safe_float(model.time.get("days_elapsed", 0.0) / 365.2422) if hasattr(model, "time") else None,
|
| 192 |
+
}
|
| 193 |
+
return _response(True, result=result)
|
| 194 |
except Exception as exc:
|
| 195 |
+
return _response(False, error=str(exc))
|
| 196 |
|
| 197 |
|
| 198 |
+
@mcp.tool(name="run_grey_radiation_column", description="Run a grey-gas radiative column model for a few timesteps.")
|
| 199 |
+
def run_grey_radiation_column(num_lev: int = 30, num_steps: int = 10, water_depth: float = 1.0, insolation: float = 341.3) -> dict[str, Any]:
|
| 200 |
+
"""Run a simple radiative column model.
|
|
|
|
| 201 |
|
| 202 |
Args:
|
| 203 |
+
num_lev: Number of atmospheric levels.
|
| 204 |
+
num_steps: Number of timesteps to integrate.
|
| 205 |
+
water_depth: Surface slab water depth (m).
|
| 206 |
+
insolation: Fixed insolation value Q (W/m^2).
|
|
|
|
| 207 |
"""
|
| 208 |
try:
|
| 209 |
+
if climlab is None:
|
| 210 |
+
return _response(False, error="climlab is not available")
|
| 211 |
+
|
| 212 |
+
model = climlab.GreyRadiationModel(num_lev=num_lev, water_depth=water_depth, Q=insolation)
|
| 213 |
+
model.integrate_steps(num_steps)
|
| 214 |
+
|
| 215 |
+
olr = np.asarray(model.OLR).mean() if np is not None else model.OLR
|
| 216 |
+
asr = np.asarray(model.ASR).mean() if np is not None else model.ASR
|
| 217 |
+
ts = np.asarray(model.Ts).mean() if np is not None else model.Ts
|
| 218 |
+
|
| 219 |
+
result = {
|
| 220 |
+
"num_lev": num_lev,
|
| 221 |
+
"num_steps": num_steps,
|
| 222 |
+
"water_depth": water_depth,
|
| 223 |
+
"insolation": insolation,
|
| 224 |
+
"mean_olr_wm2": _safe_float(olr),
|
| 225 |
+
"mean_asr_wm2": _safe_float(asr),
|
| 226 |
+
"mean_surface_temp_c": _safe_float(ts),
|
| 227 |
+
}
|
| 228 |
+
return _response(True, result=result)
|
| 229 |
except Exception as exc:
|
| 230 |
+
return _response(False, error=str(exc))
|
| 231 |
|
| 232 |
|
| 233 |
+
@mcp.tool(name="call_function", description="Call a whitelisted climlab function via adapter.")
|
| 234 |
+
def call_function(module_name: str, function_name: str, kwargs_json: str = "{}") -> dict[str, Any]:
|
| 235 |
+
"""Generic function invocation helper.
|
|
|
|
| 236 |
|
| 237 |
Args:
|
| 238 |
+
module_name: Fully-qualified module name.
|
| 239 |
+
function_name: Function name inside the module.
|
| 240 |
+
kwargs_json: JSON object string with keyword arguments.
|
|
|
|
| 241 |
"""
|
| 242 |
try:
|
| 243 |
+
if adapter is None:
|
| 244 |
+
return _response(False, error="adapter is not available")
|
| 245 |
+
|
| 246 |
+
allowed_prefixes = ("climlab.solar.", "climlab.model.", "climlab.utils.")
|
| 247 |
+
if not module_name.startswith(allowed_prefixes):
|
| 248 |
+
return _response(False, error="Module is not in whitelist.")
|
| 249 |
+
|
| 250 |
+
kwargs_obj = json.loads(kwargs_json)
|
| 251 |
+
if not isinstance(kwargs_obj, dict):
|
| 252 |
+
return _response(False, error="kwargs_json must decode to an object")
|
| 253 |
|
| 254 |
+
call_result = adapter.call_function(module_name=module_name, function_name=function_name, kwargs=kwargs_obj)
|
| 255 |
+
if call_result.get("status") != "ok":
|
| 256 |
+
return _response(False, result=call_result, error=call_result.get("error", "call failed"))
|
| 257 |
+
return _response(True, result=call_result)
|
| 258 |
except Exception as exc:
|
| 259 |
+
return _response(False, error=str(exc))
|
| 260 |
|
| 261 |
|
| 262 |
+
def create_app():
|
| 263 |
return mcp
|
| 264 |
|
| 265 |
|
| 266 |
if __name__ == "__main__":
|
| 267 |
+
mcp.run()
|
climlab/mcp_output/requirements.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
fastmcp
|
| 2 |
-
|
| 3 |
-
uvicorn[standard]
|
| 4 |
-
pydantic>=2.0.0
|
| 5 |
numpy
|
| 6 |
scipy
|
|
|
|
|
|
|
|
|
| 1 |
fastmcp
|
| 2 |
+
climlab
|
|
|
|
|
|
|
| 3 |
numpy
|
| 4 |
scipy
|
| 5 |
+
xarray
|
| 6 |
+
pooch
|
climlab/mcp_output/start_mcp.py
CHANGED
|
@@ -1,30 +1,33 @@
|
|
| 1 |
-
|
| 2 |
-
"""
|
| 3 |
-
MCP Service Startup Entry
|
| 4 |
-
"""
|
| 5 |
-
import sys
|
| 6 |
import os
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
sys.path.insert(0, mcp_plugin_dir)
|
| 12 |
|
| 13 |
from mcp_service import create_app
|
| 14 |
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
app = create_app()
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
if transport == "http":
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
if __name__ == "__main__":
|
| 30 |
main()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
+
import sys
|
| 3 |
+
from pathlib import Path
|
| 4 |
|
| 5 |
+
PLUGIN_DIR = Path(__file__).resolve().parent / "mcp_plugin"
|
| 6 |
+
if str(PLUGIN_DIR) not in sys.path:
|
| 7 |
+
sys.path.insert(0, str(PLUGIN_DIR))
|
|
|
|
| 8 |
|
| 9 |
from mcp_service import create_app
|
| 10 |
|
| 11 |
+
|
| 12 |
+
def main() -> None:
|
| 13 |
+
transport = os.getenv("MCP_TRANSPORT", "stdio").strip().lower()
|
| 14 |
+
port = int(os.getenv("MCP_PORT", "8000"))
|
| 15 |
+
|
| 16 |
app = create_app()
|
| 17 |
+
|
| 18 |
+
if transport == "stdio":
|
| 19 |
+
app.run(transport="stdio")
|
| 20 |
+
return
|
| 21 |
+
|
| 22 |
if transport == "http":
|
| 23 |
+
try:
|
| 24 |
+
app.run(transport="http", host="0.0.0.0", port=port, path="/mcp")
|
| 25 |
+
except TypeError:
|
| 26 |
+
app.run(transport="http", host="0.0.0.0", port=port)
|
| 27 |
+
return
|
| 28 |
+
|
| 29 |
+
raise ValueError(f"Unsupported MCP_TRANSPORT: {transport}")
|
| 30 |
+
|
| 31 |
|
| 32 |
if __name__ == "__main__":
|
| 33 |
main()
|
port.json
CHANGED
|
@@ -1,5 +1 @@
|
|
| 1 |
-
{
|
| 2 |
-
"repo": "climlab",
|
| 3 |
-
"port": 7864,
|
| 4 |
-
"timestamp": 1773384553
|
| 5 |
-
}
|
|
|
|
| 1 |
+
{"port": 7860}
|
|
|
|
|
|
|
|
|
|
|
|
requirements.txt
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
fastmcp
|
| 2 |
-
|
| 3 |
-
uvicorn[standard]
|
| 4 |
-
pydantic>=2.0.0
|
| 5 |
numpy
|
| 6 |
scipy
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
fastmcp
|
| 2 |
+
climlab
|
|
|
|
|
|
|
| 3 |
numpy
|
| 4 |
scipy
|
| 5 |
+
xarray
|
| 6 |
+
pooch
|
| 7 |
+
fastapi
|
| 8 |
+
uvicorn
|
run_docker.ps1
CHANGED
|
@@ -1,26 +1,7 @@
|
|
| 1 |
-
cd $PSScriptRoot
|
| 2 |
$ErrorActionPreference = "Stop"
|
| 3 |
-
|
| 4 |
-
$
|
| 5 |
-
$
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
$config = @{}
|
| 10 |
-
if (Test-Path $mcpPath) {
|
| 11 |
-
try { $config = Get-Content $mcpPath -Raw | ConvertFrom-Json } catch { $config = @{} }
|
| 12 |
-
}
|
| 13 |
-
$serversOrdered = [ordered]@{}
|
| 14 |
-
if ($config -and ($config.PSObject.Properties.Name -contains "mcpServers") -and $config.mcpServers) {
|
| 15 |
-
$existing = $config.mcpServers
|
| 16 |
-
if ($existing -is [pscustomobject]) {
|
| 17 |
-
foreach ($p in $existing.PSObject.Properties) { if ($p.Name -ne $entryName) { $serversOrdered[$p.Name] = $p.Value } }
|
| 18 |
-
} elseif ($existing -is [System.Collections.IDictionary]) {
|
| 19 |
-
foreach ($k in $existing.Keys) { if ($k -ne $entryName) { $serversOrdered[$k] = $existing[$k] } }
|
| 20 |
-
}
|
| 21 |
-
}
|
| 22 |
-
$serversOrdered[$entryName] = @{ url = $entryUrl }
|
| 23 |
-
$config = @{ mcpServers = $serversOrdered }
|
| 24 |
-
$config | ConvertTo-Json -Depth 10 | Set-Content -Path $mcpPath -Encoding UTF8
|
| 25 |
-
docker build -t $imageName .
|
| 26 |
-
docker run --rm -p 7864:7860 $imageName
|
|
|
|
|
|
|
| 1 |
$ErrorActionPreference = "Stop"
|
| 2 |
+
|
| 3 |
+
$port = (Get-Content -Raw "port.json" | ConvertFrom-Json).port
|
| 4 |
+
$image = "climlab-mcp"
|
| 5 |
+
|
| 6 |
+
docker build -t $image .
|
| 7 |
+
docker run --rm -p "${port}:${port}" $image
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
run_docker.sh
CHANGED
|
@@ -1,75 +1,8 @@
|
|
| 1 |
#!/usr/bin/env bash
|
| 2 |
set -euo pipefail
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
if command -v python3 >/dev/null 2>&1; then
|
| 10 |
-
python3 - "${mcp_path}" "${mcp_entry_name}" "${mcp_entry_url}" <<'PY'
|
| 11 |
-
import json, os, sys
|
| 12 |
-
path, name, url = sys.argv[1:4]
|
| 13 |
-
cfg = {"mcpServers": {}}
|
| 14 |
-
if os.path.exists(path):
|
| 15 |
-
try:
|
| 16 |
-
with open(path, "r", encoding="utf-8") as f:
|
| 17 |
-
cfg = json.load(f)
|
| 18 |
-
except Exception:
|
| 19 |
-
cfg = {"mcpServers": {}}
|
| 20 |
-
if not isinstance(cfg, dict):
|
| 21 |
-
cfg = {"mcpServers": {}}
|
| 22 |
-
servers = cfg.get("mcpServers")
|
| 23 |
-
if not isinstance(servers, dict):
|
| 24 |
-
servers = {}
|
| 25 |
-
ordered = {}
|
| 26 |
-
for k, v in servers.items():
|
| 27 |
-
if k != name:
|
| 28 |
-
ordered[k] = v
|
| 29 |
-
ordered[name] = {"url": url}
|
| 30 |
-
cfg = {"mcpServers": ordered}
|
| 31 |
-
with open(path, "w", encoding="utf-8") as f:
|
| 32 |
-
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
| 33 |
-
PY
|
| 34 |
-
elif command -v python >/dev/null 2>&1; then
|
| 35 |
-
python - "${mcp_path}" "${mcp_entry_name}" "${mcp_entry_url}" <<'PY'
|
| 36 |
-
import json, os, sys
|
| 37 |
-
path, name, url = sys.argv[1:4]
|
| 38 |
-
cfg = {"mcpServers": {}}
|
| 39 |
-
if os.path.exists(path):
|
| 40 |
-
try:
|
| 41 |
-
with open(path, "r", encoding="utf-8") as f:
|
| 42 |
-
cfg = json.load(f)
|
| 43 |
-
except Exception:
|
| 44 |
-
cfg = {"mcpServers": {}}
|
| 45 |
-
if not isinstance(cfg, dict):
|
| 46 |
-
cfg = {"mcpServers": {}}
|
| 47 |
-
servers = cfg.get("mcpServers")
|
| 48 |
-
if not isinstance(servers, dict):
|
| 49 |
-
servers = {}
|
| 50 |
-
ordered = {}
|
| 51 |
-
for k, v in servers.items():
|
| 52 |
-
if k != name:
|
| 53 |
-
ordered[k] = v
|
| 54 |
-
ordered[name] = {"url": url}
|
| 55 |
-
cfg = {"mcpServers": ordered}
|
| 56 |
-
with open(path, "w", encoding="utf-8") as f:
|
| 57 |
-
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
| 58 |
-
PY
|
| 59 |
-
elif command -v jq >/dev/null 2>&1; then
|
| 60 |
-
name="${mcp_entry_name}"; url="${mcp_entry_url}"
|
| 61 |
-
if [ -f "${mcp_path}" ]; then
|
| 62 |
-
tmp="$(mktemp)"
|
| 63 |
-
jq --arg name "$name" --arg url "$url" '
|
| 64 |
-
.mcpServers = (.mcpServers // {})
|
| 65 |
-
| .mcpServers as $s
|
| 66 |
-
| ($s | with_entries(select(.key != $name))) as $base
|
| 67 |
-
| .mcpServers = ($base + {($name): {"url": $url}})
|
| 68 |
-
' "${mcp_path}" > "${tmp}" && mv "${tmp}" "${mcp_path}"
|
| 69 |
-
else
|
| 70 |
-
printf '{ "mcpServers": { "%s": { "url": "%s" } } }
|
| 71 |
-
' "$name" "$url" > "${mcp_path}"
|
| 72 |
-
fi
|
| 73 |
-
fi
|
| 74 |
-
docker build -t climlab-mcp .
|
| 75 |
-
docker run --rm -p 7864:7860 climlab-mcp
|
|
|
|
| 1 |
#!/usr/bin/env bash
|
| 2 |
set -euo pipefail
|
| 3 |
+
|
| 4 |
+
PORT=$(python3 -c 'import json; print(json.load(open("port.json"))["port"])')
|
| 5 |
+
IMAGE="climlab-mcp"
|
| 6 |
+
|
| 7 |
+
docker build -t "$IMAGE" .
|
| 8 |
+
docker run --rm -p "${PORT}:${PORT}" "$IMAGE"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|