ghh1125 commited on
Commit
079b2ee
·
verified ·
1 Parent(s): 019e193

Upload 14 files

Browse files
Dockerfile CHANGED
@@ -1,17 +1,22 @@
1
- FROM python:3.10
2
 
3
- RUN useradd -m -u 1000 user && python -m pip install --upgrade pip
4
- USER user
5
- ENV PATH="/home/user/.local/bin:$PATH"
 
6
 
7
  WORKDIR /app
8
 
9
- COPY --chown=user ./requirements.txt requirements.txt
10
- RUN pip install --no-cache-dir --upgrade -r requirements.txt
11
 
12
- COPY --chown=user . /app
13
- ENV MCP_TRANSPORT=http
14
- ENV MCP_PORT=7860
 
 
 
 
 
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: Climlab
3
- emoji:
4
  colorFrom: blue
5
- colorTo: yellow
6
  sdk: docker
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- mcp_plugin_path = os.path.join(os.path.dirname(__file__), "climlab", "mcp_output", "mcp_plugin")
6
- sys.path.insert(0, mcp_plugin_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "service": "Climlab MCP Service",
18
- "version": "1.0.0",
19
- "status": "running",
20
- "transport": os.environ.get("MCP_TRANSPORT", "http")
21
  }
22
 
 
23
  @app.get("/health")
24
- def health_check():
25
- return {"status": "healthy", "service": "climlab MCP"}
 
26
 
27
  @app.get("/tools")
28
- def list_tools():
29
- try:
30
- from mcp_service import create_app
31
- mcp_app = create_app()
32
- tools = []
33
- for tool_name, tool_func in mcp_app.tools.items():
34
- tools.append({
35
- "name": tool_name,
36
- "description": tool_func.__doc__ or "No description available"
37
- })
38
- return {"tools": tools}
39
- except Exception as e:
40
- return {"error": f"Failed to load tools: {str(e)}"}
41
-
42
- if __name__ == "__main__":
43
- import uvicorn
44
- port = int(os.environ.get("PORT", 7860))
45
- uvicorn.run(app, host="0.0.0.0", port=port)
 
 
 
 
 
 
 
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 (Model Context Protocol) Service README
2
-
3
- ## 1) Project Introduction
4
-
5
- This service wraps key `climlab` capabilities into MCP (Model Context Protocol)-friendly endpoints for climate modeling workflows.
6
- It is designed for developers who want to:
7
-
8
- - Build and run simple climate models (especially EBM and column models)
9
- - Compute insolation diagnostics
10
- - Access thermodynamic helper functions
11
- - Read core constants and model metadata
12
- - Orchestrate process-based simulations with `Process` / `TimeDependentProcess`
13
-
14
- Primary library: https://github.com/climlab/climlab
15
-
16
- ---
17
-
18
- ## 2) Installation Method
19
-
20
- ### System requirements
21
- - Python 3.9+ recommended
22
- - `numpy`, `scipy` required
23
- - Optional: `xarray`, `matplotlib`, `netCDF4`, `numba`
24
- - Optional advanced radiation: Fortran-compiled RRTMG extensions
25
-
26
- ### Install with pip
27
- - pip install climlab
28
-
29
- ### Optional extras (as needed)
30
- - pip install xarray matplotlib netCDF4 numba
31
-
32
- ### For development setup (from source)
33
- - git clone https://github.com/climlab/climlab
34
- - cd climlab
35
- - pip install -e .
36
-
37
- ---
38
-
39
- ## 3) Quick Start
40
-
41
- ### Typical MCP (Model Context Protocol) workflow
42
- 1. Initialize the service runtime.
43
- 2. Call model constructor endpoint (e.g., EBM or column model).
44
- 3. Step model forward in time.
45
- 4. Retrieve diagnostics/state fields.
46
- 5. Optionally call utility endpoints (insolation, thermo, constants).
47
-
48
- ### Minimal example flow
49
- - Create an EBM model instance
50
- - Integrate for N timesteps
51
- - Fetch temperature field and energy budget diagnostics
52
- - Compute reference insolation for given latitude/day
53
- - Compare model output against insolation/thermo helpers
54
-
55
- ---
56
-
57
- ## 4) Available Tools and Endpoints List
58
-
59
- Recommended endpoint surface for this repository:
60
-
61
- - `models.create_ebm`
62
- - Create `EBM`, `EBM_annual`, `EBM_seasonal`, or related variants.
63
- - `models.create_column`
64
- - Create `GreyRadiationModel`, `RadiativeConvectiveModel`, or `BandRCModel`.
65
- - `models.step`
66
- - Advance a model using `TimeDependentProcess` stepping.
67
- - `models.integrate`
68
- - Run multi-step integration and return selected diagnostics.
69
- - `models.get_state`
70
- - Return state variables (`Field`) with domain metadata.
71
- - `models.get_diagnostics`
72
- - Return process diagnostics and energy-budget outputs.
73
-
74
- - `insolation.daily`
75
- - Wrapper for daily insolation calculation.
76
- - `insolation.annual_mean`
77
- - Wrapper for annual-mean insolation calculation.
78
-
79
- - `thermo.clausius_clapeyron`
80
- - `thermo.qsat`
81
- - `thermo.mixing_ratio_from_vapor_pressure`
82
- - `thermo.vapor_pressure_from_specific_humidity`
83
- - Stateless thermodynamic utilities.
84
-
85
- - `constants.list`
86
- - Enumerate available physical constants.
87
- - `constants.get`
88
- - Fetch constant value by name.
89
-
90
- - `domain.describe`
91
- - Summarize axes/domains (`Axis`, `Domain`, atmosphere/ocean slabs).
92
- - `health.check`
93
- - Verify import/runtime readiness, including optional modules.
94
-
95
- ---
96
-
97
- ## 5) Common Issues and Notes
98
-
99
- - RRTMG-related functionality may fail without compiled Fortran extensions.
100
- - Some scientific workflows expect `xarray` for richer labeled output.
101
- - Numerical performance can vary by grid size and subprocess complexity.
102
- - Insolation APIs exist in both `climlab.radiation.insolation` and `climlab.solar.insolation`; keep endpoint mapping explicit.
103
- - Keep model objects session-scoped; avoid unnecessary re-instantiation for repeated stepping.
104
- - If running in constrained environments, disable heavy optional endpoints first (RRTMG, plotting-related paths).
105
-
106
- ---
107
-
108
- ## 6) Reference Links / Documentation
109
-
110
- - Repository: https://github.com/climlab/climlab
111
- - Package docs source: `docs/source` in repository
112
- - Core modules to review:
113
- - `climlab/process/process.py`
114
- - `climlab/process/time_dependent_process.py`
115
- - `climlab/model/ebm.py`
116
- - `climlab/model/column.py`
117
- - `climlab/radiation/insolation.py`
118
- - `climlab/utils/thermo.py`
119
- - `climlab/utils/constants.py`
120
- - Tests for usage patterns:
121
- - `climlab/tests/test_ebm.py`
122
- - `climlab/tests/test_rcm.py`
123
- - `climlab/tests/test_insolation.py`
124
- - `climlab/tests/test_rrtm.py`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- from typing import Any, Dict, List, Optional
 
 
 
 
7
 
8
- source_path = os.path.join(
9
- os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
10
- "source",
11
- )
12
- if source_path not in sys.path:
13
- sys.path.insert(0, source_path)
14
 
15
 
16
  class Adapter:
17
- """
18
- MCP Import Mode Adapter for climlab repository.
19
-
20
- This adapter prioritizes direct import execution against repository source code.
21
- If import-based execution is unavailable, it gracefully degrades to fallback behavior
22
- with actionable error guidance.
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
- mod = self._imports.get(module_name) or importlib.import_module(module_name)
136
- names = [n for n in dir(mod) if not n.startswith("_")]
137
- return self._result("ok", module=module_name, symbols=names)
138
- except Exception as e:
139
- return self._result(
140
- "error",
141
- module=module_name,
142
- error=f"{type(e).__name__}: {e}",
143
- guidance="Verify module path and required dependencies.",
144
- )
145
-
146
- def create_instance(self, module_name: str, class_name: str, *args: Any, **kwargs: Any) -> Dict[str, Any]:
147
- """
148
- Create an instance from a class in the specified module.
149
-
150
- Parameters:
151
- module_name (str): Full module import path.
152
- class_name (str): Class name to instantiate.
153
- *args: Positional constructor arguments.
154
- **kwargs: Keyword constructor arguments.
155
-
156
- Returns:
157
- dict: Status + created object or actionable error guidance.
158
- """
159
- try:
160
- mod = self._imports.get(module_name) or importlib.import_module(module_name)
161
- cls = getattr(mod, class_name)
162
- instance = cls(*args, **kwargs)
163
- return self._result("ok", module=module_name, class_name=class_name, instance=instance)
164
- except Exception as e:
165
- return self._result(
166
- "error",
167
- module=module_name,
168
- class_name=class_name,
169
- error=f"{type(e).__name__}: {e}",
170
- traceback=traceback.format_exc(),
171
- guidance="Check constructor arguments and optional compiled dependency requirements.",
172
- )
173
-
174
- def call_function(self, module_name: str, function_name: str, *args: Any, **kwargs: Any) -> Dict[str, Any]:
175
- """
176
- Call a function from the specified module.
177
-
178
- Parameters:
179
- module_name (str): Full module import path.
180
- function_name (str): Function name to call.
181
- *args: Positional function arguments.
182
- **kwargs: Keyword function arguments.
183
-
184
- Returns:
185
- dict: Status + function result or clear error details.
186
- """
187
- try:
188
- mod = self._imports.get(module_name) or importlib.import_module(module_name)
189
- fn = getattr(mod, function_name)
190
- if not callable(fn):
191
- return self._result(
192
- "error",
193
- module=module_name,
194
- function_name=function_name,
195
- error="Attribute exists but is not callable.",
196
- guidance="Use list_module_symbols() to inspect callable APIs.",
197
- )
198
- result = fn(*args, **kwargs)
199
- return self._result("ok", module=module_name, function_name=function_name, result=result)
200
- except Exception as e:
201
- return self._result(
202
- "error",
203
- module=module_name,
204
- function_name=function_name,
205
- error=f"{type(e).__name__}: {e}",
206
- traceback=traceback.format_exc(),
207
- guidance="Verify function signature and input types using describe_symbol().",
208
- )
209
-
210
- def describe_symbol(self, module_name: str, symbol_name: str) -> Dict[str, Any]:
211
- """
212
- Describe a symbol signature and docstring for safe invocation.
213
-
214
- Parameters:
215
- module_name (str): Full module path.
216
- symbol_name (str): Name of class/function/attribute.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
 
218
- Returns:
219
- dict: Status + symbol metadata.
220
- """
221
  try:
222
- mod = self._imports.get(module_name) or importlib.import_module(module_name)
223
- sym = getattr(mod, symbol_name)
224
- signature = None
225
- if callable(sym):
226
- try:
227
- signature = str(inspect.signature(sym))
228
- except Exception:
229
- signature = "unavailable"
230
- doc = inspect.getdoc(sym) or ""
231
- return self._result(
232
- "ok",
233
- module=module_name,
234
- symbol_name=symbol_name,
235
- is_callable=callable(sym),
236
- signature=signature,
237
- doc=doc,
238
- type=str(type(sym)),
239
- )
240
- except Exception as e:
241
- return self._result(
242
- "error",
243
- module=module_name,
244
- symbol_name=symbol_name,
245
- error=f"{type(e).__name__}: {e}",
246
- guidance="Confirm symbol exists and module imports correctly.",
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
- mod = self._imports.get(mod_name) or importlib.import_module(mod_name)
264
- classes = []
265
- functions = []
266
- for name, obj in inspect.getmembers(mod):
267
- if name.startswith("_"):
268
- continue
269
- if inspect.isclass(obj):
270
- classes.append(name)
271
- elif inspect.isfunction(obj):
272
- functions.append(name)
273
- catalog[mod_name] = {"classes": classes, "functions": functions}
274
- except Exception as e:
275
- errors[mod_name] = f"{type(e).__name__}: {e}"
276
-
277
- return self._result(
278
- "ok" if not errors else "partial",
279
- catalog=catalog,
280
- errors=errors,
281
- guidance="Use create_instance() and call_function() for concrete execution.",
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
- mod = self._imports.get(module_name) or importlib.import_module(module_name)
301
- sym = getattr(mod, symbol_name)
302
- if inspect.isclass(sym):
303
- return self.create_instance(module_name, symbol_name, *args, **kwargs)
304
- if callable(sym):
305
- return self.call_function(module_name, symbol_name, *args, **kwargs)
306
- return self._result(
307
- "error",
308
- module=module_name,
309
- symbol_name=symbol_name,
310
- error="Symbol is not callable and not a class.",
311
- guidance="Use describe_symbol() to inspect valid operations.",
312
- )
313
- except Exception as e:
314
- return self._result(
315
- "error",
316
- module=module_name,
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
- def main():
7
- """Main entry point"""
8
- app = create_app()
9
- return app
10
-
11
  if __name__ == "__main__":
12
- app = main()
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 typing import Dict, List, Optional
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- from fastmcp import FastMCP
 
 
 
10
 
11
- mcp = FastMCP("climlab_service")
 
 
 
12
 
 
13
 
14
- def _ok(result):
15
- return {"success": True, "result": result, "error": None}
16
 
17
 
18
- def _err(exc: Exception):
19
- return {"success": False, "result": None, "error": str(exc)}
20
 
21
 
22
- @mcp.tool(name="climlab_version_info", description="Get version and basic availability information for climlab.")
23
- def climlab_version_info() -> Dict:
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
- Args:
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
- from climlab.model.ebm import EBM # type: ignore
 
 
 
58
 
59
- model = EBM(num_lat=num_lat, D=D, water_depth=water_depth)
 
 
 
 
60
  result = {
61
- "class": model.__class__.__name__,
62
- "num_lat": num_lat,
63
- "D": D,
64
- "water_depth": water_depth,
65
- "state_variables": list(model.state.keys()),
66
- "diagnostics": list(model.diagnostics.keys()),
 
 
 
67
  }
68
- return _ok(result)
69
  except Exception as exc:
70
- return _err(exc)
71
 
72
 
73
- @mcp.tool(name="integrate_ebm", description="Integrate an EBM model forward in time and return global mean surface temperature.")
74
- def integrate_ebm(num_lat: int = 90, years: float = 1.0, D: float = 0.55, water_depth: float = 10.0) -> Dict:
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
- dict: Standard response dictionary with time-integrated summary.
86
  """
87
  try:
88
- import numpy as np # type: ignore
89
- from climlab.model.ebm import EBM # type: ignore
 
 
 
 
90
 
91
- model = EBM(num_lat=num_lat, D=D, water_depth=water_depth)
92
- model.integrate_years(years)
93
 
94
- ts = model.state.get("Ts")
95
- if ts is None:
96
- return _ok({"message": "Model integrated, but Ts state variable not found."})
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
- latitude: Latitude in degrees.
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
- from climlab.solar.insolation import daily_insolation # type: ignore
135
-
136
- kwargs = {"S0": solar_constant}
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 _err(exc)
159
 
160
 
161
- @mcp.tool(name="compute_annual_mean_insolation_profile", description="Compute annual-mean insolation profile across latitude bands.")
162
- def compute_annual_mean_insolation_profile(
163
- latitudes: List[float],
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
- latitudes: List of latitude values in degrees.
172
- days_per_year: Number of days for averaging.
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
- import numpy as np # type: ignore
180
- from climlab.solar.insolation import daily_insolation # type: ignore
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 _err(exc)
191
 
192
 
193
- @mcp.tool(name="blackbody_olr", description="Compute outgoing longwave radiation using Stefan-Boltzmann relation.")
194
- def blackbody_olr(temperature: float, emissivity: float = 1.0) -> Dict:
195
- """
196
- Compute blackbody (or gray-body) outgoing longwave radiation.
197
 
198
  Args:
199
- temperature: Temperature in Kelvin.
200
- emissivity: Effective emissivity.
201
-
202
- Returns:
203
- dict: Standard response dictionary with OLR in W/m^2.
204
  """
205
  try:
206
- from climlab.radiation.boltzmann import Boltzmann # type: ignore
207
-
208
- proc = Boltzmann(eps=emissivity)
209
- olr = float(proc._compute_emission(temperature))
210
- return _ok({"temperature": temperature, "emissivity": emissivity, "olr": olr})
 
 
 
 
 
 
 
211
  except Exception as exc:
212
- return _err(exc)
213
 
214
 
215
- @mcp.tool(name="aplusbt_olr", description="Compute linearized OLR using A + B*T parameterization.")
216
- def aplusbt_olr(temperature: float, A: float = 210.0, B: float = 2.0) -> Dict:
217
- """
218
- Compute outgoing longwave radiation using A+BT relation.
219
 
220
  Args:
221
- temperature: Temperature in Kelvin.
222
- A: Intercept parameter.
223
- B: Slope parameter.
224
-
225
- Returns:
226
- dict: Standard response dictionary with OLR.
227
  """
228
  try:
229
- from climlab.radiation.aplusbt import AplusBT # type: ignore
 
 
 
230
 
231
- proc = AplusBT(A=A, B=B)
232
- olr = float(A + B * temperature)
233
- return _ok({"temperature": temperature, "A": A, "B": B, "olr": olr, "model": proc.__class__.__name__})
 
 
 
 
 
 
 
 
234
  except Exception as exc:
235
- return _err(exc)
236
 
237
 
238
- @mcp.tool(name="moist_thermo_diagnostics", description="Compute core moist thermodynamic diagnostics from temperature and pressure.")
239
- def moist_thermo_diagnostics(temperature: float, pressure: float) -> Dict:
240
- """
241
- Compute selected thermodynamic diagnostics.
242
 
243
  Args:
244
- temperature: Air temperature in Kelvin.
245
- pressure: Pressure in hPa.
246
-
247
- Returns:
248
- dict: Standard response dictionary with saturation vapor pressure and specific humidity estimates.
249
  """
250
  try:
251
- from climlab.utils.thermo import clausius_clapeyron, qsat # type: ignore
252
-
253
- es = float(clausius_clapeyron(temperature))
254
- qsat_val = float(qsat(temperature, pressure))
255
- return _ok(
256
- {
257
- "temperature": temperature,
258
- "pressure_hPa": pressure,
259
- "saturation_vapor_pressure_Pa": es,
260
- "saturation_specific_humidity": qsat_val,
261
- }
262
- )
 
 
 
 
 
 
 
 
263
  except Exception as exc:
264
- return _err(exc)
265
 
266
 
267
- @mcp.tool(name="heat_capacity_ocean_slab", description="Compute areal heat capacity of an ocean slab.")
268
- def heat_capacity_ocean_slab(water_depth: float) -> Dict:
269
- """
270
- Compute heat capacity for a slab ocean.
271
 
272
  Args:
273
- water_depth: Slab depth in meters.
274
-
275
- Returns:
276
- dict: Standard response dictionary with heat capacity in J m^-2 K^-1.
277
  """
278
  try:
279
- from climlab.utils.heat_capacity import ocean # type: ignore
 
 
 
 
 
 
 
 
 
280
 
281
- cap = float(ocean(water_depth))
282
- return _ok({"water_depth": water_depth, "heat_capacity": cap})
 
 
283
  except Exception as exc:
284
- return _err(exc)
285
 
286
 
287
- def create_app() -> FastMCP:
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
- fastapi
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
- project_root = os.path.dirname(os.path.abspath(__file__))
9
- mcp_plugin_dir = os.path.join(project_root, "mcp_plugin")
10
- if mcp_plugin_dir not in sys.path:
11
- sys.path.insert(0, mcp_plugin_dir)
12
 
13
  from mcp_service import create_app
14
 
15
- def main():
16
- """Start FastMCP service"""
 
 
 
17
  app = create_app()
18
- # Use environment variable to configure port, default 8000
19
- port = int(os.environ.get("MCP_PORT", "8000"))
20
-
21
- # Choose transport mode based on environment variable
22
- transport = os.environ.get("MCP_TRANSPORT", "stdio")
23
  if transport == "http":
24
- app.run(transport="http", host="0.0.0.0", port=port)
25
- else:
26
- # Default to STDIO mode
27
- app.run()
 
 
 
 
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
- fastapi
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
- $entryName = if ($env:MCP_ENTRY_NAME) { $env:MCP_ENTRY_NAME } else { "climlab" }
4
- $entryUrl = if ($env:MCP_ENTRY_URL) { $env:MCP_ENTRY_URL } else { "http://localhost:7864/mcp" }
5
- $imageName = if ($env:MCP_IMAGE_NAME) { $env:MCP_IMAGE_NAME } else { "climlab-mcp" }
6
- $mcpDir = Join-Path $env:USERPROFILE ".cursor"
7
- $mcpPath = Join-Path $mcpDir "mcp.json"
8
- if (!(Test-Path $mcpDir)) { New-Item -ItemType Directory -Path $mcpDir | Out-Null }
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
- cd "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
4
- mcp_entry_name="${MCP_ENTRY_NAME:-climlab}"
5
- mcp_entry_url="${MCP_ENTRY_URL:-http://localhost:7864/mcp}"
6
- mcp_dir="${HOME}/.cursor"
7
- mcp_path="${mcp_dir}/mcp.json"
8
- mkdir -p "${mcp_dir}"
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"