sequenx-mcp 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: sequenx-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server exposing the SequenxAI public API as tools for AI hosts
5
+ Author-email: SequenxAI <lcw_1497007939@163.com>
6
+ License-Expression: LicenseRef-Proprietary
7
+ Keywords: mcp,time-series,forecasting,sequenxai
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: mcp<3,>=2.3
17
+ Requires-Dist: pydantic<3,>=2.7
18
+ Requires-Dist: sequenx-ai>=0.1.0
19
+
20
+ # sequenx-mcp (SequenxAI MCP server)
21
+
22
+ MCP server that exposes the SequenxAI public API as tools for AI hosts (Claude
23
+ Desktop, Cursor, …). It is a thin adapter: every tool maps one-to-one onto an
24
+ existing operation and calls the Python SDK — there is no second HTTP client and
25
+ no business logic here (see
26
+ [`docs/plans/phase_3/06-MCP服务器与宿主Agent工具面.md`](../../docs/plans/phase_3/06-MCP服务器与宿主Agent工具面.md)).
27
+
28
+ Not published on PyPI yet; it lives in this repository.
29
+
30
+ ## Install
31
+
32
+ The server depends on the sibling SDK, which is not on PyPI either — install it
33
+ from this repository first:
34
+
35
+ ```bash
36
+ pip install -e packages/sequenx-py
37
+ pip install -e packages/sequenx-mcp
38
+ ```
39
+
40
+ ## Run
41
+
42
+ The server speaks stdio, the transport a local MCP host starts as a child
43
+ process:
44
+
45
+ ```bash
46
+ SEQUENX_API_KEY=sqx_live_... python -m sequenx_mcp
47
+ ```
48
+
49
+ | Variable | Meaning |
50
+ |---|---|
51
+ | `SEQUENX_API_KEY` | The `sqx_live_*` key the tools call with (required). |
52
+ | `SEQUENX_API_BASE_URL` | Base URL override; default `https://api.sequenxai.com/v1`. |
53
+
54
+ Host configuration (any MCP host that starts stdio servers):
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "sequenx": {
60
+ "command": "python",
61
+ "args": ["-m", "sequenx_mcp"],
62
+ "env": { "SEQUENX_API_KEY": "sqx_live_..." }
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ ## Tools
69
+
70
+ | Tool | Endpoint | What it does |
71
+ |---|---|---|
72
+ | `list_models` | `GET /v1/models` | The public model catalog for the key: ids, the default model, per-model context / horizon limits and capabilities. |
73
+ | `clean_data` | `POST /v1/clean` | The free cleaning dry-run: cleaning report and effective policy. No inference, no billing. Takes `series`, `options`, `freq`, `model` and `clean_series`. |
74
+ | `forecast` | `POST /v1/forecast` | Synchronous forecast over inline rows: points and intervals, data-quality report, billed usage. Takes `series`, `h`, `model`, `freq`, `level` and `options`. |
75
+
76
+ Input rows are the contract's long table: `unique_id`, `ds`, `y`.
77
+
78
+ **Limits.** `forecast` takes inline rows only — the public API has no dataset
79
+ upload, so the tool is for small-to-medium series. The model reads only the most
80
+ recent points of a series: a longer history is truncated by the API and
81
+ reported (`model_input[].truncated` plus a `context_truncated` warning), and
82
+ `list_models` gives each model's context window. The server adds no limit of
83
+ its own. For larger data use the REST or SDK batch path:
84
+ `POST /v1/forecast/batch`, then poll `GET /v1/forecast/batch/{job_id}`.
85
+
86
+ **Errors.** A tool error carries the contract's error body
87
+ (`{"error": {code, message, request_id, category, retryable, details?}}`), so
88
+ the host agent sees a structured error instead of a silent empty answer. The MCP
89
+ framework prefixes the message with its own text, so the envelope travels
90
+ *inside* the error text. `403` is not retried; `429` carries `retry_after` and
91
+ `limit_type` (`rpm` / `daily`) in `details`.
92
+
93
+ Three codes are the SDK's own, because the contract's code list covers
94
+ server-side failures only: `HTTP_<status>` (a response without a code),
95
+ `REQUEST_FAILED` (a response body the SDK could not read) and `TRANSPORT_ERROR`
96
+ (the request never reached the server).
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ pip install -e packages/sequenx-py
102
+ pip install -e packages/sequenx-mcp -r packages/sequenx-mcp/requirements-dev.txt
103
+ cd packages/sequenx-mcp
104
+ python -m pytest -q
105
+ python -m ruff check src tests
106
+ ```
107
+
108
+ Gitee Go runs the suite via `scripts/ci/gitee-verify.sh mcp`.
109
+
110
+ Use a separate virtualenv for this package: `mcp` 2.x installs `httpx2`, and a
111
+ control-plane environment that has `httpx2` next to `httpx` breaks Starlette's
112
+ test client (`services/api` needs `httpx` 1.x).
@@ -0,0 +1,93 @@
1
+ # sequenx-mcp (SequenxAI MCP server)
2
+
3
+ MCP server that exposes the SequenxAI public API as tools for AI hosts (Claude
4
+ Desktop, Cursor, …). It is a thin adapter: every tool maps one-to-one onto an
5
+ existing operation and calls the Python SDK — there is no second HTTP client and
6
+ no business logic here (see
7
+ [`docs/plans/phase_3/06-MCP服务器与宿主Agent工具面.md`](../../docs/plans/phase_3/06-MCP服务器与宿主Agent工具面.md)).
8
+
9
+ Not published on PyPI yet; it lives in this repository.
10
+
11
+ ## Install
12
+
13
+ The server depends on the sibling SDK, which is not on PyPI either — install it
14
+ from this repository first:
15
+
16
+ ```bash
17
+ pip install -e packages/sequenx-py
18
+ pip install -e packages/sequenx-mcp
19
+ ```
20
+
21
+ ## Run
22
+
23
+ The server speaks stdio, the transport a local MCP host starts as a child
24
+ process:
25
+
26
+ ```bash
27
+ SEQUENX_API_KEY=sqx_live_... python -m sequenx_mcp
28
+ ```
29
+
30
+ | Variable | Meaning |
31
+ |---|---|
32
+ | `SEQUENX_API_KEY` | The `sqx_live_*` key the tools call with (required). |
33
+ | `SEQUENX_API_BASE_URL` | Base URL override; default `https://api.sequenxai.com/v1`. |
34
+
35
+ Host configuration (any MCP host that starts stdio servers):
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "sequenx": {
41
+ "command": "python",
42
+ "args": ["-m", "sequenx_mcp"],
43
+ "env": { "SEQUENX_API_KEY": "sqx_live_..." }
44
+ }
45
+ }
46
+ }
47
+ ```
48
+
49
+ ## Tools
50
+
51
+ | Tool | Endpoint | What it does |
52
+ |---|---|---|
53
+ | `list_models` | `GET /v1/models` | The public model catalog for the key: ids, the default model, per-model context / horizon limits and capabilities. |
54
+ | `clean_data` | `POST /v1/clean` | The free cleaning dry-run: cleaning report and effective policy. No inference, no billing. Takes `series`, `options`, `freq`, `model` and `clean_series`. |
55
+ | `forecast` | `POST /v1/forecast` | Synchronous forecast over inline rows: points and intervals, data-quality report, billed usage. Takes `series`, `h`, `model`, `freq`, `level` and `options`. |
56
+
57
+ Input rows are the contract's long table: `unique_id`, `ds`, `y`.
58
+
59
+ **Limits.** `forecast` takes inline rows only — the public API has no dataset
60
+ upload, so the tool is for small-to-medium series. The model reads only the most
61
+ recent points of a series: a longer history is truncated by the API and
62
+ reported (`model_input[].truncated` plus a `context_truncated` warning), and
63
+ `list_models` gives each model's context window. The server adds no limit of
64
+ its own. For larger data use the REST or SDK batch path:
65
+ `POST /v1/forecast/batch`, then poll `GET /v1/forecast/batch/{job_id}`.
66
+
67
+ **Errors.** A tool error carries the contract's error body
68
+ (`{"error": {code, message, request_id, category, retryable, details?}}`), so
69
+ the host agent sees a structured error instead of a silent empty answer. The MCP
70
+ framework prefixes the message with its own text, so the envelope travels
71
+ *inside* the error text. `403` is not retried; `429` carries `retry_after` and
72
+ `limit_type` (`rpm` / `daily`) in `details`.
73
+
74
+ Three codes are the SDK's own, because the contract's code list covers
75
+ server-side failures only: `HTTP_<status>` (a response without a code),
76
+ `REQUEST_FAILED` (a response body the SDK could not read) and `TRANSPORT_ERROR`
77
+ (the request never reached the server).
78
+
79
+ ## Development
80
+
81
+ ```bash
82
+ pip install -e packages/sequenx-py
83
+ pip install -e packages/sequenx-mcp -r packages/sequenx-mcp/requirements-dev.txt
84
+ cd packages/sequenx-mcp
85
+ python -m pytest -q
86
+ python -m ruff check src tests
87
+ ```
88
+
89
+ Gitee Go runs the suite via `scripts/ci/gitee-verify.sh mcp`.
90
+
91
+ Use a separate virtualenv for this package: `mcp` 2.x installs `httpx2`, and a
92
+ control-plane environment that has `httpx2` next to `httpx` breaks Starlette's
93
+ test client (`services/api` needs `httpx` 1.x).
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "sequenx-mcp"
7
+ dynamic = ["version"]
8
+ description = "MCP server exposing the SequenxAI public API as tools for AI hosts"
9
+ authors = [{ name = "SequenxAI", email = "lcw_1497007939@163.com" }]
10
+ keywords = ["mcp", "time-series", "forecasting", "sequenxai"]
11
+ classifiers = [
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.10",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
18
+ ]
19
+ readme = "README.md"
20
+ requires-python = ">=3.10"
21
+ license = "LicenseRef-Proprietary"
22
+ dependencies = [
23
+ "mcp>=2.3,<3",
24
+ "pydantic>=2.7,<3",
25
+ # The sibling SDK in this repository; install it first
26
+ # (pip install -e packages/sequenx-py) — it is not on PyPI yet.
27
+ "sequenx-ai>=0.1.0",
28
+ ]
29
+
30
+ [tool.setuptools.dynamic]
31
+ version = { attr = "sequenx_mcp._version.__version__" }
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["src"]
35
+
36
+ [tool.ruff]
37
+ line-length = 100
38
+ target-version = "py310"
39
+ src = ["src", "tests"]
40
+
41
+ [tool.ruff.lint]
42
+ # Same rule set as the other Python packages in the monorepo.
43
+ select = ["E", "F", "I", "B", "W", "UP"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """MCP server exposing the SequenxAI public API as tools for AI hosts."""
2
+
3
+ from ._version import __version__
4
+ from .server import build_server, main
5
+
6
+ __all__ = ["__version__", "build_server", "main"]
@@ -0,0 +1,6 @@
1
+ """``python -m sequenx_mcp``: run the MCP server over stdio."""
2
+
3
+ from .server import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,71 @@
1
+ """Map the SDK's typed errors onto the contract's error shape.
2
+
3
+ The contract answers with ``ErrorResponse`` = ``{"error": {code, message,
4
+ request_id, details?}}`` and does not use RFC 7807 (see
5
+ ``docs/plans/phase_3/06-MCP服务器与宿主Agent工具面.md`` §3.2). A tool therefore
6
+ reports the API's own error body as the tool error message, so the host agent
7
+ sees a structured error instead of a silent empty answer. The MCP framework
8
+ prefixes the message with its own text ("Error executing tool …"), so the
9
+ envelope travels inside the message rather than as the whole message.
10
+
11
+ The body keeps the contract's ``ErrorDetail`` fields — ``code``, ``message``,
12
+ ``request_id``, ``category``, ``retryable`` and ``details`` — and adds the
13
+ retry hints the RFC asks the host to see (``retry_after`` and ``limit_type``)
14
+ inside ``details``. ``category`` and ``retryable`` are only present when the
15
+ upstream body carried them.
16
+
17
+ Three codes are the SDK's own, because no contract code covers them: the
18
+ contract's list (``x-error-codes`` in openapi.yaml) describes server-side
19
+ failures only. ``HTTP_<status>`` is a response without a code, ``REQUEST_FAILED``
20
+ a response the SDK could not read, ``TRANSPORT_ERROR`` a request that never
21
+ reached the server.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ from typing import Any
28
+
29
+ from mcp.server.mcpserver.exceptions import ToolError
30
+ from sequenx import RateLimitedError, SequenxError, TransportError
31
+
32
+
33
+ def error_body(exc: SequenxError) -> dict[str, Any]:
34
+ """The contract's ErrorDetail body for an SDK error."""
35
+ details: dict[str, Any] = {}
36
+ if isinstance(exc.details, dict):
37
+ details.update(exc.details)
38
+ elif exc.details is not None:
39
+ details["details"] = exc.details
40
+ if isinstance(exc, RateLimitedError):
41
+ if exc.retry_after is not None:
42
+ details["retry_after"] = exc.retry_after
43
+ if exc.limit_type is not None:
44
+ details["limit_type"] = exc.limit_type
45
+ if exc.code:
46
+ code = exc.code
47
+ elif exc.status is not None:
48
+ code = f"HTTP_{exc.status}"
49
+ elif isinstance(exc, TransportError):
50
+ # No response reached us at all.
51
+ code = "TRANSPORT_ERROR"
52
+ else:
53
+ # A client-side failure (e.g. a response without a JSON body).
54
+ code = "REQUEST_FAILED"
55
+ body: dict[str, Any] = {
56
+ "code": code,
57
+ "message": str(exc),
58
+ "request_id": exc.request_id,
59
+ }
60
+ if exc.category is not None:
61
+ body["category"] = exc.category
62
+ if exc.retryable is not None:
63
+ body["retryable"] = exc.retryable
64
+ if details:
65
+ body["details"] = details
66
+ return {"error": body}
67
+
68
+
69
+ def tool_error(exc: SequenxError) -> ToolError:
70
+ """A tool error carrying the contract's error body as its message."""
71
+ return ToolError(json.dumps(error_body(exc), ensure_ascii=False))
@@ -0,0 +1,115 @@
1
+ """The MCP server: three tools over the SequenxAI public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+ import sys
8
+ from typing import Annotated, Any
9
+
10
+ from mcp.server.mcpserver import MCPServer
11
+ from pydantic import Field
12
+ from sequenx import SequenxClient
13
+
14
+ from . import tools
15
+ from ._version import __version__
16
+ from .tools import Point
17
+
18
+ INSTRUCTIONS = (
19
+ "Time-series forecasting on the SequenxAI platform. The data is passed inline as "
20
+ "long-table rows (unique_id, ds, y). Start with list_models to learn the models and "
21
+ "their limits, clean_data to preview how the data will be cleaned (free), and "
22
+ "forecast to get the prediction and the usage that was billed."
23
+ )
24
+
25
+ LIST_MODELS_DESCRIPTION = (
26
+ "List the public time-series models available to this API key: each model's id, the "
27
+ "default model, its context and horizon limits and its capabilities. Call it before "
28
+ "forecasting to learn the limits; the catalog is owned by the server and changes "
29
+ "without a version bump."
30
+ )
31
+
32
+ CLEAN_DATA_DESCRIPTION = (
33
+ "Run the free cleaning dry run: the same cleaning stage as a forecast, returning the "
34
+ "cleaning report (with every automatic decision) and the effective policy. No "
35
+ "inference, no billing. Input is inline rows (unique_id, ds, y); freq, model, "
36
+ "clean_series and options are the contract's CleanRequest fields."
37
+ )
38
+
39
+ FORECAST_DESCRIPTION = (
40
+ "Forecast one or more series from inline rows (unique_id, ds, y): point forecast plus "
41
+ "intervals, the data-quality report and the billed usage. The tool takes the request "
42
+ "fields h, model, freq, level and options; the remaining contract fields (quantile "
43
+ "policy, covariates, joint groups, ...) are available through REST or the SDK. Two "
44
+ "limits to know: the model reads only the most recent points of a series, so a longer "
45
+ "history is truncated by the API and reported (model_input[].truncated plus a "
46
+ "context_truncated warning) — list_models gives each model's context window; and a "
47
+ "host tool call cannot carry a large table, so for larger data use the REST or SDK "
48
+ "batch path: POST /v1/forecast/batch, then poll GET /v1/forecast/batch/{job_id}."
49
+ )
50
+
51
+ # The contract's horizon bounds (ForecastRequest.h: 1..2048).
52
+ Horizon = Annotated[int, Field(ge=1, le=2048)]
53
+
54
+
55
+ def build_server(client: SequenxClient) -> MCPServer:
56
+ """The MCP server with the three tools, bound to one API client."""
57
+ server = MCPServer(name="sequenx", version=__version__, instructions=INSTRUCTIONS)
58
+
59
+ @server.tool(name="list_models", description=LIST_MODELS_DESCRIPTION)
60
+ def list_models_tool() -> dict[str, Any]:
61
+ return tools.list_models(client)
62
+
63
+ @server.tool(name="clean_data", description=CLEAN_DATA_DESCRIPTION)
64
+ def clean_data_tool(
65
+ series: list[Point],
66
+ options: dict[str, Any] | None = None,
67
+ freq: str | None = None,
68
+ model: str | None = None,
69
+ clean_series: bool | None = None,
70
+ ) -> dict[str, Any]:
71
+ return tools.clean_data(
72
+ client, series, options=options, freq=freq, model=model, clean_series=clean_series
73
+ )
74
+
75
+ @server.tool(name="forecast", description=FORECAST_DESCRIPTION)
76
+ def forecast_tool(
77
+ series: list[Point],
78
+ h: Horizon,
79
+ model: str | None = None,
80
+ freq: str | None = None,
81
+ level: list[int] | None = None,
82
+ options: dict[str, Any] | None = None,
83
+ ) -> dict[str, Any]:
84
+ return tools.forecast(
85
+ client, series, h=h, model=model, freq=freq, level=level, options=options
86
+ )
87
+
88
+ return server
89
+
90
+
91
+ def _quiet_httpx() -> None:
92
+ """The MCP SDK configures the root logger, so httpx's per-request INFO lines would
93
+ reach the host's stderr on every tool call. The protocol lives on stdout; these lines
94
+ are noise there."""
95
+ logging.getLogger("httpx").setLevel(logging.WARNING)
96
+ logging.getLogger("httpcore").setLevel(logging.WARNING)
97
+
98
+
99
+ def main() -> int:
100
+ """Run the server over stdio (the transport a local MCP host uses)."""
101
+ api_key = os.environ.get("SEQUENX_API_KEY")
102
+ if not api_key:
103
+ print(
104
+ "set SEQUENX_API_KEY=<sqx_live_... key> to run the SequenxAI MCP server",
105
+ file=sys.stderr,
106
+ )
107
+ return 2
108
+ base_url = os.environ.get("SEQUENX_API_BASE_URL") or SequenxClient.DEFAULT_BASE_URL
109
+ _quiet_httpx()
110
+ client = SequenxClient(api_key, base_url=base_url)
111
+ try:
112
+ build_server(client).run()
113
+ finally:
114
+ client.close()
115
+ return 0
@@ -0,0 +1,97 @@
1
+ """The three tools: thin adapters over the public API.
2
+
3
+ Each tool maps one-to-one onto an existing operation (RFC
4
+ ``docs/plans/phase_3/06-MCP服务器与宿主Agent工具面.md`` §2) and calls the Python
5
+ SDK — there is no second HTTP client and no business logic here. The server
6
+ adds no limits of its own: the control plane owns the point caps and the model
7
+ context, and it reports what it did (``model_input[].truncated`` plus a
8
+ ``context_truncated`` warning).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel, ConfigDict
16
+ from sequenx import SequenxClient, SequenxError
17
+
18
+ from .errors import tool_error
19
+
20
+ # tool name -> (the SDK method it calls, the contract path, the contract method)
21
+ TOOL_OPERATIONS = {
22
+ "list_models": ("models", "/models", "get"),
23
+ "clean_data": ("clean", "/clean", "post"),
24
+ "forecast": ("forecast", "/forecast", "post"),
25
+ }
26
+
27
+
28
+ class Point(BaseModel):
29
+ """One row of the contract's long table (contract: TimeSeriesPoint)."""
30
+
31
+ model_config = ConfigDict(extra="allow")
32
+
33
+ unique_id: str
34
+ ds: str
35
+ y: float | None
36
+
37
+
38
+ def rows_of(series: list[Point]) -> list[dict[str, Any]]:
39
+ """The rows as the API takes them (the contract's long table)."""
40
+ return [point.model_dump() for point in series]
41
+
42
+
43
+ def list_models(client: SequenxClient) -> dict[str, Any]:
44
+ """GET /models: the public model catalog for this key."""
45
+ try:
46
+ return dict(client.models())
47
+ except SequenxError as exc:
48
+ raise tool_error(exc) from exc
49
+
50
+
51
+ def clean_data(
52
+ client: SequenxClient,
53
+ series: list[Point],
54
+ options: dict[str, Any] | None = None,
55
+ freq: str | None = None,
56
+ model: str | None = None,
57
+ clean_series: bool | None = None,
58
+ ) -> dict[str, Any]:
59
+ """POST /clean: the free cleaning dry-run (report + effective policy)."""
60
+ extra: dict[str, Any] = {}
61
+ if options is not None:
62
+ extra["options"] = options
63
+ if freq is not None:
64
+ extra["freq"] = freq
65
+ if model is not None:
66
+ extra["model"] = model
67
+ if clean_series is not None:
68
+ extra["clean_series"] = clean_series
69
+ try:
70
+ return dict(client.clean(rows_of(series), **extra))
71
+ except SequenxError as exc:
72
+ raise tool_error(exc) from exc
73
+
74
+
75
+ def forecast(
76
+ client: SequenxClient,
77
+ series: list[Point],
78
+ h: int,
79
+ model: str | None = None,
80
+ freq: str | None = None,
81
+ level: list[int] | None = None,
82
+ options: dict[str, Any] | None = None,
83
+ ) -> dict[str, Any]:
84
+ """POST /forecast: a synchronous forecast over inline rows."""
85
+ extra: dict[str, Any] = {}
86
+ if model is not None:
87
+ extra["model"] = model
88
+ if freq is not None:
89
+ extra["freq"] = freq
90
+ if level is not None:
91
+ extra["level"] = level
92
+ if options is not None:
93
+ extra["options"] = options
94
+ try:
95
+ return dict(client.forecast(rows_of(series), h=h, **extra))
96
+ except SequenxError as exc:
97
+ raise tool_error(exc) from exc
@@ -0,0 +1,112 @@
1
+ Metadata-Version: 2.4
2
+ Name: sequenx-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server exposing the SequenxAI public API as tools for AI hosts
5
+ Author-email: SequenxAI <lcw_1497007939@163.com>
6
+ License-Expression: LicenseRef-Proprietary
7
+ Keywords: mcp,time-series,forecasting,sequenxai
8
+ Classifier: Intended Audience :: Developers
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.10
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: mcp<3,>=2.3
17
+ Requires-Dist: pydantic<3,>=2.7
18
+ Requires-Dist: sequenx-ai>=0.1.0
19
+
20
+ # sequenx-mcp (SequenxAI MCP server)
21
+
22
+ MCP server that exposes the SequenxAI public API as tools for AI hosts (Claude
23
+ Desktop, Cursor, …). It is a thin adapter: every tool maps one-to-one onto an
24
+ existing operation and calls the Python SDK — there is no second HTTP client and
25
+ no business logic here (see
26
+ [`docs/plans/phase_3/06-MCP服务器与宿主Agent工具面.md`](../../docs/plans/phase_3/06-MCP服务器与宿主Agent工具面.md)).
27
+
28
+ Not published on PyPI yet; it lives in this repository.
29
+
30
+ ## Install
31
+
32
+ The server depends on the sibling SDK, which is not on PyPI either — install it
33
+ from this repository first:
34
+
35
+ ```bash
36
+ pip install -e packages/sequenx-py
37
+ pip install -e packages/sequenx-mcp
38
+ ```
39
+
40
+ ## Run
41
+
42
+ The server speaks stdio, the transport a local MCP host starts as a child
43
+ process:
44
+
45
+ ```bash
46
+ SEQUENX_API_KEY=sqx_live_... python -m sequenx_mcp
47
+ ```
48
+
49
+ | Variable | Meaning |
50
+ |---|---|
51
+ | `SEQUENX_API_KEY` | The `sqx_live_*` key the tools call with (required). |
52
+ | `SEQUENX_API_BASE_URL` | Base URL override; default `https://api.sequenxai.com/v1`. |
53
+
54
+ Host configuration (any MCP host that starts stdio servers):
55
+
56
+ ```json
57
+ {
58
+ "mcpServers": {
59
+ "sequenx": {
60
+ "command": "python",
61
+ "args": ["-m", "sequenx_mcp"],
62
+ "env": { "SEQUENX_API_KEY": "sqx_live_..." }
63
+ }
64
+ }
65
+ }
66
+ ```
67
+
68
+ ## Tools
69
+
70
+ | Tool | Endpoint | What it does |
71
+ |---|---|---|
72
+ | `list_models` | `GET /v1/models` | The public model catalog for the key: ids, the default model, per-model context / horizon limits and capabilities. |
73
+ | `clean_data` | `POST /v1/clean` | The free cleaning dry-run: cleaning report and effective policy. No inference, no billing. Takes `series`, `options`, `freq`, `model` and `clean_series`. |
74
+ | `forecast` | `POST /v1/forecast` | Synchronous forecast over inline rows: points and intervals, data-quality report, billed usage. Takes `series`, `h`, `model`, `freq`, `level` and `options`. |
75
+
76
+ Input rows are the contract's long table: `unique_id`, `ds`, `y`.
77
+
78
+ **Limits.** `forecast` takes inline rows only — the public API has no dataset
79
+ upload, so the tool is for small-to-medium series. The model reads only the most
80
+ recent points of a series: a longer history is truncated by the API and
81
+ reported (`model_input[].truncated` plus a `context_truncated` warning), and
82
+ `list_models` gives each model's context window. The server adds no limit of
83
+ its own. For larger data use the REST or SDK batch path:
84
+ `POST /v1/forecast/batch`, then poll `GET /v1/forecast/batch/{job_id}`.
85
+
86
+ **Errors.** A tool error carries the contract's error body
87
+ (`{"error": {code, message, request_id, category, retryable, details?}}`), so
88
+ the host agent sees a structured error instead of a silent empty answer. The MCP
89
+ framework prefixes the message with its own text, so the envelope travels
90
+ *inside* the error text. `403` is not retried; `429` carries `retry_after` and
91
+ `limit_type` (`rpm` / `daily`) in `details`.
92
+
93
+ Three codes are the SDK's own, because the contract's code list covers
94
+ server-side failures only: `HTTP_<status>` (a response without a code),
95
+ `REQUEST_FAILED` (a response body the SDK could not read) and `TRANSPORT_ERROR`
96
+ (the request never reached the server).
97
+
98
+ ## Development
99
+
100
+ ```bash
101
+ pip install -e packages/sequenx-py
102
+ pip install -e packages/sequenx-mcp -r packages/sequenx-mcp/requirements-dev.txt
103
+ cd packages/sequenx-mcp
104
+ python -m pytest -q
105
+ python -m ruff check src tests
106
+ ```
107
+
108
+ Gitee Go runs the suite via `scripts/ci/gitee-verify.sh mcp`.
109
+
110
+ Use a separate virtualenv for this package: `mcp` 2.x installs `httpx2`, and a
111
+ control-plane environment that has `httpx2` next to `httpx` breaks Starlette's
112
+ test client (`services/api` needs `httpx` 1.x).
@@ -0,0 +1,16 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/sequenx_mcp/__init__.py
4
+ src/sequenx_mcp/__main__.py
5
+ src/sequenx_mcp/_version.py
6
+ src/sequenx_mcp/errors.py
7
+ src/sequenx_mcp/server.py
8
+ src/sequenx_mcp/tools.py
9
+ src/sequenx_mcp.egg-info/PKG-INFO
10
+ src/sequenx_mcp.egg-info/SOURCES.txt
11
+ src/sequenx_mcp.egg-info/dependency_links.txt
12
+ src/sequenx_mcp.egg-info/requires.txt
13
+ src/sequenx_mcp.egg-info/top_level.txt
14
+ tests/test_contract_drift.py
15
+ tests/test_server.py
16
+ tests/test_tools.py
@@ -0,0 +1,3 @@
1
+ mcp<3,>=2.3
2
+ pydantic<3,>=2.7
3
+ sequenx-ai>=0.1.0
@@ -0,0 +1 @@
1
+ sequenx_mcp
@@ -0,0 +1,167 @@
1
+ """The MCP tools must match the contract (packages/contracts/openapi.yaml).
2
+
3
+ The tool input schemas are read from the server itself, so this test checks what
4
+ a host actually sees, not a hand-written copy of it.
5
+ """
6
+
7
+ import ast
8
+ import asyncio
9
+ import pathlib
10
+ import re
11
+
12
+ import yaml
13
+ from sequenx import SequenxClient
14
+ from sequenx._endpoints import ENDPOINTS
15
+
16
+ from sequenx_mcp.server import build_server
17
+ from sequenx_mcp.tools import TOOL_OPERATIONS
18
+
19
+ _PACKAGE = pathlib.Path(__file__).resolve().parents[1]
20
+ _CONTRACT = (
21
+ pathlib.Path(__file__).resolve().parents[3]
22
+ / "packages"
23
+ / "contracts"
24
+ / "openapi.yaml"
25
+ )
26
+
27
+ # The request fields each tool exposes (a subset of the contract's request).
28
+ _TOOL_FIELDS = {
29
+ "list_models": set(),
30
+ "clean_data": {"series", "options", "freq", "model", "clean_series"},
31
+ "forecast": {"series", "h", "model", "freq", "level", "options"},
32
+ }
33
+
34
+ # The tool descriptions must not hardcode a model id: the catalog is owned by the
35
+ # server and the public model list changes without a version bump (RFC §7.5).
36
+ # Exact ids are matched as whole tokens; the model families are matched as
37
+ # prefixes, so "timestral-209m" is caught too. The list holds the ids the
38
+ # catalog can serve (public and internal); retired names are not part of it.
39
+ _MODEL_IDS = (
40
+ "omni-1.0",
41
+ "traffic-1.0",
42
+ "traffic",
43
+ "t0-gamma",
44
+ "hodos",
45
+ )
46
+ _MODEL_PREFIXES = ("chronos-", "timesfm-", "timestral-")
47
+
48
+
49
+ def _spec() -> dict:
50
+ return yaml.safe_load(_CONTRACT.read_text())
51
+
52
+
53
+ def _tools() -> dict:
54
+ server = build_server(SequenxClient("sqx_live_test", base_url="https://api.test/v1"))
55
+ return {tool.name: tool for tool in asyncio.run(server.list_tools())}
56
+
57
+
58
+ def test_the_server_publishes_exactly_the_three_tools():
59
+ assert set(_tools()) == set(TOOL_OPERATIONS)
60
+
61
+
62
+ def test_every_tool_maps_to_an_open_contract_operation():
63
+ paths = _spec()["paths"]
64
+ for name, (_, path, method) in TOOL_OPERATIONS.items():
65
+ assert path in paths, name
66
+ assert method in paths[path], name
67
+
68
+
69
+ def test_the_sdk_serves_the_operation_behind_every_tool():
70
+ for name, (method_name, path, method) in TOOL_OPERATIONS.items():
71
+ served = [
72
+ spec
73
+ for spec in ENDPOINTS.values()
74
+ if spec.method.lower() == method and spec.path == path
75
+ ]
76
+ assert served, name
77
+ assert callable(getattr(SequenxClient, method_name)), name
78
+
79
+
80
+ def test_every_tool_calls_the_sdk_method_it_claims():
81
+ """The tool function and the endpoint it claims must not drift apart."""
82
+ tree = ast.parse((_PACKAGE / "src" / "sequenx_mcp" / "tools.py").read_text())
83
+ calls: dict[str, set[str]] = {}
84
+ for node in ast.walk(tree):
85
+ if not isinstance(node, ast.FunctionDef):
86
+ continue
87
+ calls[node.name] = {
88
+ call.func.attr
89
+ for call in ast.walk(node)
90
+ if isinstance(call, ast.Call)
91
+ and isinstance(call.func, ast.Attribute)
92
+ and isinstance(call.func.value, ast.Name)
93
+ and call.func.value.id == "client"
94
+ }
95
+ for name, (method_name, _, _) in TOOL_OPERATIONS.items():
96
+ assert method_name in calls[name], name
97
+
98
+
99
+ def test_the_tool_input_fields_are_the_declared_ones_and_exist_in_the_contract():
100
+ schemas = _spec()["components"]["schemas"]
101
+ tools = _tools()
102
+ for name in TOOL_OPERATIONS:
103
+ tool_props = set(tools[name].input_schema.get("properties", {}))
104
+ assert tool_props == _TOOL_FIELDS[name], name
105
+ if not _TOOL_FIELDS[name]:
106
+ continue
107
+ request_schema = "CleanRequest" if name == "clean_data" else "ForecastRequest"
108
+ properties = set(schemas[request_schema]["properties"])
109
+ assert tool_props <= properties, name
110
+ # Every field the contract requires must be expressible by the tool.
111
+ assert set(schemas[request_schema]["required"]) <= tool_props, name
112
+
113
+
114
+ def test_the_horizon_bounds_match_the_contract():
115
+ contract_h = _spec()["components"]["schemas"]["ForecastRequest"]["properties"]["h"]
116
+ tool_h = _tools()["forecast"].input_schema["properties"]["h"]
117
+ assert tool_h.get("minimum") == contract_h["minimum"]
118
+ assert tool_h.get("maximum") == contract_h["maximum"]
119
+
120
+
121
+ def test_the_row_model_matches_the_contract_point():
122
+ schemas = _spec()["components"]["schemas"]["TimeSeriesPoint"]
123
+ point = _tools()["forecast"].input_schema["$defs"]["Point"]
124
+ assert set(point["required"]) == set(schemas["required"])
125
+ assert set(point["properties"]) == set(schemas["properties"])
126
+
127
+
128
+ def _names_a_model_id(text: str) -> str | None:
129
+ """The model id the text names first, if any.
130
+
131
+ Exact ids match as whole tokens (so "traffic" also trips on ordinary prose —
132
+ a loud, cheap false positive); the model families match as prefixes. The
133
+ match that starts earliest in the text wins.
134
+ """
135
+ matches: list[tuple[int, str]] = []
136
+ for model_id in _MODEL_IDS:
137
+ found = re.search(rf"(?<![\w-]){re.escape(model_id)}(?![\w-])", text)
138
+ if found:
139
+ matches.append((found.start(), model_id))
140
+ for prefix in _MODEL_PREFIXES:
141
+ found = text.find(prefix)
142
+ if found >= 0:
143
+ matches.append((found, prefix))
144
+ return min(matches)[1] if matches else None
145
+
146
+
147
+ def test_no_tool_description_names_a_model_id():
148
+ for name, tool in _tools().items():
149
+ assert _names_a_model_id(tool.description or "") is None, name
150
+
151
+
152
+ def test_the_server_instructions_name_no_model_id():
153
+ server = build_server(SequenxClient("sqx_live_test", base_url="https://api.test/v1"))
154
+ assert _names_a_model_id(server.instructions or "") is None
155
+
156
+
157
+ def test_the_client_side_codes_are_outside_the_contract_list():
158
+ """The SDK adds codes for failures no contract code covers (server-side only)."""
159
+ listed = _spec()["components"]["schemas"]["ErrorDetail"]["x-error-codes"]
160
+ contract_codes = {entry["code"] for entry in listed}
161
+ for code in ("HTTP_404", "REQUEST_FAILED", "TRANSPORT_ERROR"):
162
+ assert code not in contract_codes
163
+
164
+
165
+ def test_the_guard_reports_the_id_the_text_names_first():
166
+ assert _names_a_model_id("timestral-209m and omni-1.0") == "timestral-"
167
+ assert _names_a_model_id("omni-1.0 then timestral-209m") == "omni-1.0"
@@ -0,0 +1,30 @@
1
+ """The command line contract of the server."""
2
+
3
+ from mcp.server.mcpserver import MCPServer
4
+ from sequenx import SequenxClient
5
+
6
+ from sequenx_mcp.server import main
7
+
8
+
9
+ def test_without_a_key_the_server_refuses_to_start(monkeypatch, capsys):
10
+ monkeypatch.delenv("SEQUENX_API_KEY", raising=False)
11
+ assert main() == 2
12
+ assert "SEQUENX_API_KEY" in capsys.readouterr().err
13
+
14
+
15
+ def test_with_a_key_the_server_runs_over_stdio(monkeypatch):
16
+ monkeypatch.setenv("SEQUENX_API_KEY", "sqx_live_test")
17
+ monkeypatch.delenv("SEQUENX_API_BASE_URL", raising=False)
18
+ ran = []
19
+ monkeypatch.setattr(MCPServer, "run", lambda self, *a, **kw: ran.append(True))
20
+ assert main() == 0
21
+ assert ran == [True]
22
+
23
+
24
+ def test_the_client_is_closed_when_the_server_stops(monkeypatch):
25
+ monkeypatch.setenv("SEQUENX_API_KEY", "sqx_live_test")
26
+ closed = []
27
+ monkeypatch.setattr(MCPServer, "run", lambda self, *a, **kw: None)
28
+ monkeypatch.setattr(SequenxClient, "close", lambda self: closed.append(True))
29
+ assert main() == 0
30
+ assert closed == [True]
@@ -0,0 +1,264 @@
1
+ import json
2
+
3
+ import httpx
4
+ import pytest
5
+ from mcp.server.mcpserver.exceptions import ToolError
6
+
7
+ from sequenx_mcp.server import build_server
8
+ from sequenx_mcp.tools import Point, clean_data, forecast, list_models
9
+
10
+ SERIES = [Point(unique_id="m-1", ds="2026-01-01T00:00:00Z", y=1.5)]
11
+
12
+ MODEL_LIST = {
13
+ "default": "omni-1.0",
14
+ "models": [{"id": "omni-1.0", "name": "Omni 1.0"}],
15
+ "request_id": "req_1",
16
+ }
17
+
18
+
19
+ def test_list_models_returns_the_catalog(make_client):
20
+ def handler(request: httpx.Request) -> httpx.Response:
21
+ assert request.url.path == "/v1/models"
22
+ return httpx.Response(200, json=MODEL_LIST)
23
+
24
+ assert list_models(make_client(handler)) == MODEL_LIST
25
+
26
+
27
+ def test_clean_data_calls_the_clean_endpoint(make_client):
28
+ seen = {}
29
+
30
+ def handler(request: httpx.Request) -> httpx.Response:
31
+ seen["method"], seen["path"] = request.method, request.url.path
32
+ seen["body"] = request.read()
33
+ return httpx.Response(
34
+ 200,
35
+ json={
36
+ "request_id": "req_2",
37
+ "freq": "D",
38
+ "cleaning_report": {"decisions": []},
39
+ "effective_policy": {"preset": "default"},
40
+ },
41
+ )
42
+
43
+ out = clean_data(make_client(handler), SERIES)
44
+ assert seen["method"] == "POST" and seen["path"] == "/v1/clean"
45
+ assert b'"unique_id"' in seen["body"]
46
+ assert out["request_id"] == "req_2"
47
+
48
+
49
+ def test_forecast_calls_the_forecast_endpoint_with_a_key(make_client):
50
+ seen = {}
51
+
52
+ def handler(request: httpx.Request) -> httpx.Response:
53
+ seen["method"], seen["path"] = request.method, request.url.path
54
+ seen["key"] = request.headers.get("idempotency-key")
55
+ seen["body"] = request.read()
56
+ return httpx.Response(
57
+ 200,
58
+ json={
59
+ "request_id": "req_3",
60
+ "model_used": "omni-1.0",
61
+ "forecast": [],
62
+ "data_quality": {"status": "ok", "missing_ratio": 0.0},
63
+ "usage": {"series_count": 1, "input_data_points": 1, "latency_ms": 12},
64
+ },
65
+ )
66
+
67
+ out = forecast(make_client(handler), SERIES, h=24, model="omni-1.0")
68
+ assert seen["method"] == "POST" and seen["path"] == "/v1/forecast"
69
+ assert seen["key"]
70
+ assert b'"h":24' in seen["body"] and b"omni-1.0" in seen["body"]
71
+ assert out["model_used"] == "omni-1.0"
72
+
73
+
74
+ def test_an_api_error_becomes_a_tool_error_with_the_contract_body(make_client):
75
+ def handler(request: httpx.Request) -> httpx.Response:
76
+ return httpx.Response(
77
+ 422,
78
+ json={
79
+ "error": {
80
+ "code": "SERIES_TOO_LONG",
81
+ "message": "too long",
82
+ "request_id": "req_x",
83
+ "details": {"field": "series"},
84
+ }
85
+ },
86
+ )
87
+
88
+ with pytest.raises(ToolError) as exc:
89
+ forecast(make_client(handler), SERIES, h=8)
90
+ body = json.loads(str(exc.value))
91
+ assert body["error"]["code"] == "SERIES_TOO_LONG"
92
+ assert body["error"]["message"] == "too long"
93
+ assert body["error"]["request_id"] == "req_x"
94
+ assert body["error"]["details"] == {"field": "series"}
95
+
96
+
97
+ def test_a_rate_limit_error_carries_the_retry_after(make_client):
98
+ def handler(request: httpx.Request) -> httpx.Response:
99
+ return httpx.Response(
100
+ 429,
101
+ headers={"retry-after": "30"},
102
+ json={
103
+ "error": {
104
+ "code": "RATE_LIMIT_EXCEEDED",
105
+ "message": "slow down",
106
+ "request_id": "req_y",
107
+ }
108
+ },
109
+ )
110
+
111
+ # max_retries=0: the SDK would otherwise honour Retry-After and wait.
112
+ with pytest.raises(ToolError) as exc:
113
+ list_models(make_client(handler, max_retries=0))
114
+ body = json.loads(str(exc.value))
115
+ assert body["error"]["code"] == "RATE_LIMIT_EXCEEDED"
116
+ assert body["error"]["details"]["retry_after"] == 30.0
117
+
118
+
119
+ def test_a_long_history_is_left_to_the_api(make_client):
120
+ """The server adds no limit of its own: the control plane owns the caps."""
121
+ rows = [Point(unique_id="m-1", ds="2026-01-01", y=1.0) for _ in range(5000)]
122
+ seen = {}
123
+
124
+ def handler(request: httpx.Request) -> httpx.Response:
125
+ seen["rows"] = request.read().count(b"unique_id")
126
+ return httpx.Response(200, json={"request_id": "r", "forecast": []})
127
+
128
+ forecast(make_client(handler), rows, h=8)
129
+ assert seen["rows"] == 5000
130
+
131
+
132
+ def test_the_server_exposes_exactly_three_tools(make_client):
133
+ import asyncio
134
+
135
+ def handler(request: httpx.Request) -> httpx.Response:
136
+ return httpx.Response(200, json=MODEL_LIST)
137
+
138
+ server = build_server(make_client(handler))
139
+ tools = asyncio.run(server.list_tools())
140
+ assert {tool.name for tool in tools} == {"list_models", "clean_data", "forecast"}
141
+ forecast_tool = next(tool for tool in tools if tool.name == "forecast")
142
+ assert "batch" in forecast_tool.description
143
+ assert "context_truncated" in forecast_tool.description
144
+
145
+
146
+ def test_a_tool_call_returns_the_payload(make_client):
147
+ import asyncio
148
+
149
+ def handler(request: httpx.Request) -> httpx.Response:
150
+ return httpx.Response(200, json=MODEL_LIST)
151
+
152
+ server = build_server(make_client(handler))
153
+ result = asyncio.run(server.call_tool("list_models", {}))
154
+ assert result.is_error is False
155
+ assert result.structured_content == MODEL_LIST
156
+
157
+
158
+ def test_a_transport_failure_carries_the_sdk_code(make_client):
159
+ def handler(request: httpx.Request) -> httpx.Response:
160
+ raise httpx.ConnectError("boom")
161
+
162
+ client = make_client(handler, max_retries=0)
163
+ with pytest.raises(ToolError) as exc:
164
+ list_models(client)
165
+ body = json.loads(str(exc.value))
166
+ assert body["error"]["code"] == "TRANSPORT_ERROR"
167
+ assert "boom" in body["error"]["message"]
168
+
169
+
170
+ def test_the_optional_request_fields_reach_the_api(make_client):
171
+ seen = {}
172
+
173
+ def handler(request: httpx.Request) -> httpx.Response:
174
+ seen["body"] = request.read()
175
+ return httpx.Response(200, json={"request_id": "r", "forecast": []})
176
+
177
+ client = make_client(handler)
178
+ forecast(client, SERIES, h=24, level=[80, 90])
179
+ assert b'"level":[80,90]' in seen["body"]
180
+ clean_data(client, SERIES, freq="1h", model="omni-1.0", clean_series=False)
181
+ assert b'"freq":"1h"' in seen["body"]
182
+ assert b'"model":"omni-1.0"' in seen["body"]
183
+ assert b'"clean_series":false' in seen["body"]
184
+
185
+
186
+ def test_an_error_without_a_body_code_keeps_the_http_status(make_client):
187
+ def handler(request: httpx.Request) -> httpx.Response:
188
+ return httpx.Response(404, json={"detail": "not found"})
189
+
190
+ with pytest.raises(ToolError) as exc:
191
+ list_models(make_client(handler))
192
+ body = json.loads(str(exc.value))
193
+ assert body["error"]["code"] == "HTTP_404"
194
+
195
+
196
+ def test_the_error_body_keeps_the_contract_detail_fields(make_client):
197
+ def handler(request: httpx.Request) -> httpx.Response:
198
+ return httpx.Response(
199
+ 422,
200
+ json={
201
+ "error": {
202
+ "code": "SERIES_TOO_LONG",
203
+ "message": "too long",
204
+ "request_id": "req_x",
205
+ "category": "limit_exceeded",
206
+ "retryable": False,
207
+ }
208
+ },
209
+ )
210
+
211
+ with pytest.raises(ToolError) as exc:
212
+ forecast(make_client(handler), SERIES, h=8)
213
+ body = json.loads(str(exc.value))
214
+ assert body["error"]["category"] == "limit_exceeded"
215
+ assert body["error"]["retryable"] is False
216
+
217
+
218
+ def test_a_rate_limit_error_carries_the_retry_hints(make_client):
219
+ def handler(request: httpx.Request) -> httpx.Response:
220
+ return httpx.Response(
221
+ 429,
222
+ headers={"retry-after": "30", "x-ratelimit-limit-type": "daily"},
223
+ json={"error": {"code": "POINTS_EXCEEDED", "message": "m", "request_id": "r"}},
224
+ )
225
+
226
+ with pytest.raises(ToolError) as exc:
227
+ list_models(make_client(handler, max_retries=0))
228
+ body = json.loads(str(exc.value))
229
+ assert body["error"]["details"]["retry_after"] == 30.0
230
+ assert body["error"]["details"]["limit_type"] == "daily"
231
+
232
+
233
+ def test_a_client_side_failure_is_not_reported_as_a_transport_error(make_client):
234
+ def handler(request: httpx.Request) -> httpx.Response:
235
+ return httpx.Response(200, content=b"")
236
+
237
+ with pytest.raises(ToolError) as exc:
238
+ list_models(make_client(handler))
239
+ body = json.loads(str(exc.value))
240
+ assert body["error"]["code"] == "REQUEST_FAILED"
241
+
242
+
243
+ def test_a_non_json_body_is_reported_as_the_contract_envelope(make_client):
244
+ def handler(request: httpx.Request) -> httpx.Response:
245
+ return httpx.Response(200, content=b"<html>not json</html>")
246
+
247
+ with pytest.raises(ToolError) as exc:
248
+ list_models(make_client(handler))
249
+ body = json.loads(str(exc.value))
250
+ assert body["error"]["code"] == "REQUEST_FAILED"
251
+ assert "non-JSON body" in body["error"]["message"]
252
+
253
+
254
+ def test_the_error_body_omits_absent_detail_fields(make_client):
255
+ def handler(request: httpx.Request) -> httpx.Response:
256
+ return httpx.Response(
257
+ 422, json={"error": {"code": "SERIES_TOO_SHORT", "message": "m", "request_id": "r"}}
258
+ )
259
+
260
+ with pytest.raises(ToolError) as exc:
261
+ forecast(make_client(handler), SERIES, h=8)
262
+ body = json.loads(str(exc.value))
263
+ assert "category" not in body["error"]
264
+ assert "retryable" not in body["error"]