mcbp 0.2.4__py3-none-any.whl
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.
- mcbp-0.2.4.dist-info/METADATA +177 -0
- mcbp-0.2.4.dist-info/RECORD +12 -0
- mcbp-0.2.4.dist-info/WHEEL +4 -0
- mcbp-0.2.4.dist-info/entry_points.txt +3 -0
- mcbp-0.2.4.dist-info/licenses/LICENSE +21 -0
- mcbp_mcp_server/__init__.py +19 -0
- mcbp_mcp_server/__main__.py +5 -0
- mcbp_mcp_server/credentials.py +298 -0
- mcbp_mcp_server/errors.py +52 -0
- mcbp_mcp_server/http.py +317 -0
- mcbp_mcp_server/registry.py +112 -0
- mcbp_mcp_server/server.py +206 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mcbp
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: Standalone MCP server exposing BAS MCBP_AI (mcbp.plus)
|
|
5
|
+
Project-URL: Homepage, https://mcbp.plus
|
|
6
|
+
Project-URL: Repository, https://github.com/kzavrazhnyi/mcbp-mcp-server
|
|
7
|
+
Author: MCBP.PLUS
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: 1c,bas,erp,mcbp,mcp
|
|
11
|
+
Requires-Python: >=3.11
|
|
12
|
+
Requires-Dist: anyio>=4.0
|
|
13
|
+
Requires-Dist: mcbp-core<0.2,>=0.1.1
|
|
14
|
+
Requires-Dist: mcp>=2.0
|
|
15
|
+
Provides-Extra: dev
|
|
16
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
17
|
+
Requires-Dist: pytest>=8.3; extra == 'dev'
|
|
18
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
19
|
+
Requires-Dist: ruff>=0.7; extra == 'dev'
|
|
20
|
+
Requires-Dist: starlette>=0.40; extra == 'dev'
|
|
21
|
+
Provides-Extra: http
|
|
22
|
+
Requires-Dist: starlette>=0.40; extra == 'http'
|
|
23
|
+
Requires-Dist: uvicorn>=0.30; extra == 'http'
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
<!-- mcp-name: io.github.kzavrazhnyi/mcbp-ai -->
|
|
27
|
+
|
|
28
|
+
# mcbp-ai — MCP server for BAS / 1C (MCBP+)
|
|
29
|
+
|
|
30
|
+
Read your BAS (1C) database from any MCP client. Catalogs, documents, register balances and records,
|
|
31
|
+
and the full configuration metadata tree — exposed as MCP tools over the `MCBP_AI` HTTP service.
|
|
32
|
+
|
|
33
|
+
**Read-only by default.** None of the tools available out of the box modifies your data.
|
|
34
|
+
|
|
35
|
+
**Model-agnostic.** MCP is a vendor-neutral standard, so this server works with **Claude Desktop /
|
|
36
|
+
Code, ChatGPT desktop, Gemini, Microsoft and GitHub Copilot, Cursor, Windsurf, VS Code and Zed** —
|
|
37
|
+
it serves `tools/list` and executes `tools/call` without knowing which model is asking. The
|
|
38
|
+
transport is **stdio**, so the client launches the process locally; browser-based clients would
|
|
39
|
+
need a remote HTTP server, which this one does not expose.
|
|
40
|
+
|
|
41
|
+
> ### ⚠ This server needs a server-side component that is not in this repository
|
|
42
|
+
>
|
|
43
|
+
> `mcbp-ai` is a client for the **`MCBP_AI` HTTP service** — a BSL module (a common module plus an
|
|
44
|
+
> HTTP service definition) that must be installed into your BAS/1C configuration through
|
|
45
|
+
> Конфігуратор. **That module is licensed separately and is not part of this repository.**
|
|
46
|
+
>
|
|
47
|
+
> Without it this server starts, connects, and every route answers `404`. If you want to run it
|
|
48
|
+
> against your own base, contact MCBP.PLUS to obtain the `MCBP_AI` module.
|
|
49
|
+
>
|
|
50
|
+
> The Python code here — the MCP adapter and the shared BAS client — is open source and complete.
|
|
51
|
+
|
|
52
|
+
The MCBP+ configuration and the `MCBP_AI` service module are proprietary works of
|
|
53
|
+
**[MCBP.PLUS](https://mcbp.plus)** and are distributed separately from this repository. This MIT
|
|
54
|
+
licence covers the Python packages only.
|
|
55
|
+
|
|
56
|
+
## Why this exists
|
|
57
|
+
|
|
58
|
+
BAS/1C holds the data, but it is not reachable from an LLM: the platform speaks its own query
|
|
59
|
+
language, metadata names are inherited Russian identifiers while synonyms are Ukrainian, and a raw
|
|
60
|
+
OData feed is both heavy and hostile to a model (opaque errors, no self-correction path).
|
|
61
|
+
|
|
62
|
+
`MCBP_AI` solves that on the BAS side — canonical English field names in list rows, compact
|
|
63
|
+
responses, and errors that name the offending field verbatim so a model can fix its own call. This
|
|
64
|
+
server is the thin MCP adapter in front of it.
|
|
65
|
+
|
|
66
|
+
## Tools
|
|
67
|
+
|
|
68
|
+
10 read tools are always registered. Three write tools appear only when `MCP_ALLOW_WRITE` is on.
|
|
69
|
+
|
|
70
|
+
| Tool | What it does |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `list_metadata` | Every object of a metadata kind (names + synonyms) — start here |
|
|
73
|
+
| `describe_metadata` | Full attribute tree of one object, incl. tabular sections |
|
|
74
|
+
| `search_catalog` | Substring search over a catalog by name |
|
|
75
|
+
| `filter_catalog` | Filter/sort/aggregate a catalog by any field (`agg`, `groupby`) |
|
|
76
|
+
| `get_documents` | Documents over a period, with filters, sorting and aggregation |
|
|
77
|
+
| `get_schema` | Data structure of one document type |
|
|
78
|
+
| `get_object` | **All** attribute values of one record + its tabular sections |
|
|
79
|
+
| `get_register_balance` | Accumulation-register balance (native query) |
|
|
80
|
+
| `get_register_records` | Raw rows of an information or accumulation register |
|
|
81
|
+
| `health` | Service ping — reports whether the infobase key matches |
|
|
82
|
+
| `write_object` *(gated)* | Create/update an object through MCBP Plus conversion rules |
|
|
83
|
+
| `patch_object` *(gated)* | Update header attributes of an existing object natively |
|
|
84
|
+
| `save_context` *(gated)* | Push a conversation turn upstream (skeleton) |
|
|
85
|
+
|
|
86
|
+
The model is expected to walk `list_metadata → describe_metadata → search/filter → get_object`.
|
|
87
|
+
Errors are deliberately not swallowed: an unknown field comes back as `BAD_PARAMETER` naming that
|
|
88
|
+
field, which is how the model corrects itself.
|
|
89
|
+
|
|
90
|
+
## Install
|
|
91
|
+
|
|
92
|
+
Requires **Python 3.11+**.
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install mcbp
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Configure
|
|
99
|
+
|
|
100
|
+
### Claude Desktop
|
|
101
|
+
|
|
102
|
+
`%APPDATA%\Claude\claude_desktop_config.json` (Windows) or
|
|
103
|
+
`~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
|
|
104
|
+
|
|
105
|
+
```json
|
|
106
|
+
{
|
|
107
|
+
"mcpServers": {
|
|
108
|
+
"mcbp-ai": {
|
|
109
|
+
"command": "python",
|
|
110
|
+
"args": ["-m", "mcbp_mcp_server"],
|
|
111
|
+
"env": {
|
|
112
|
+
"ONEC_BASE_URL": "http://host/base/hs/mcbp_ai",
|
|
113
|
+
"ONEC_USER": "ai_service",
|
|
114
|
+
"ONEC_PASSWORD": "***"
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Claude Code
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
claude mcp add mcbp-ai --env ONEC_BASE_URL=http://host/base/hs/mcbp_ai \
|
|
125
|
+
--env ONEC_USER=ai_service \
|
|
126
|
+
--env ONEC_PASSWORD=*** \
|
|
127
|
+
-- python -m mcbp_mcp_server
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Environment
|
|
131
|
+
|
|
132
|
+
| Variable | Required | Default | Purpose |
|
|
133
|
+
|---|---|---|---|
|
|
134
|
+
| `ONEC_BASE_URL` | yes | — | `http(s)://<host>/<base>/hs/mcbp_ai` — see the note below |
|
|
135
|
+
| `ONEC_USER` | yes | — | HTTP Basic user of the BAS publication |
|
|
136
|
+
| `ONEC_PASSWORD` | yes | — | Password. **May be empty**, but the variable must be present |
|
|
137
|
+
| `ONEC_TIMEOUT` | no | `30` | Request timeout, seconds |
|
|
138
|
+
| `ONEC_POOL_MAX` | no | `10` | Connection pool size |
|
|
139
|
+
| `ONEC_VERIFY_SSL` | no | `1` | `0` for self-signed intranet publications |
|
|
140
|
+
| `MCP_ALLOW_WRITE` | no | `0` | `1` enables the three write tools |
|
|
141
|
+
|
|
142
|
+
### `ONEC_BASE_URL` — leave off the `/ai/v1`
|
|
143
|
+
|
|
144
|
+
The client appends `/ai/v1/...` itself, so the base URL stops at the service name:
|
|
145
|
+
|
|
146
|
+
```
|
|
147
|
+
http://localhost/mybase/hs/mcbp_ai ← correct
|
|
148
|
+
http://localhost/mybase/hs/mcbp_ai/ai/v1/ ← also accepted (normalized away)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Note that the published path genuinely repeats the segment: `rootUrl` is `mcbp_ai` in `default.vrd`,
|
|
152
|
+
and the service's own URL templates start with `ai/v1`.
|
|
153
|
+
|
|
154
|
+
## Security
|
|
155
|
+
|
|
156
|
+
- Read-only unless you deliberately set `MCP_ALLOW_WRITE=1`. The write tools are not merely hidden
|
|
157
|
+
— they are never registered, so they cannot be invoked.
|
|
158
|
+
- Credentials live only in the MCP client's env block; nothing is written to disk by this server.
|
|
159
|
+
- The BAS side adds its own checks: the publication's user rights, plus an infobase-key check. If
|
|
160
|
+
the key does not match, `health` reports `key: false` and every other route answers
|
|
161
|
+
`403 KEY_MISMATCH`.
|
|
162
|
+
- `write_object` additionally requires the MCBP Plus extension and a configured conversion rule;
|
|
163
|
+
without one it returns `422 CONVERSION_NOT_CONFIGURED` and writes nothing.
|
|
164
|
+
|
|
165
|
+
## Troubleshooting
|
|
166
|
+
|
|
167
|
+
| Symptom | Cause |
|
|
168
|
+
|---|---|
|
|
169
|
+
| Every route `404`, `health` also fails | `ONEC_BASE_URL` wrong, or the `MCBP_AI` module is not installed in the base |
|
|
170
|
+
| Every route `403 KEY_MISMATCH` | Infobase key mismatch — check `health`, fix the publication key |
|
|
171
|
+
| `ONEC_PASSWORD is required` | The variable is absent. An empty value is fine; an absent one is not |
|
|
172
|
+
| `501 PLUS_REQUIRED` | Only `write_object` raises it — that base has no MCBP Plus |
|
|
173
|
+
| Model says a register is unreadable | Information registers have no `Ref`; use `get_register_records` |
|
|
174
|
+
|
|
175
|
+
## License
|
|
176
|
+
|
|
177
|
+
See `LICENSE`.
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
mcbp_mcp_server/__init__.py,sha256=sK7mdXJQ1IQhpmWM88h_J62_d5MA0Ir6YhF16SL-j1k,902
|
|
2
|
+
mcbp_mcp_server/__main__.py,sha256=KoNgTTd3m9kq6CSN_cIYkkib4slJNIyUQyTG16_3dLM,84
|
|
3
|
+
mcbp_mcp_server/credentials.py,sha256=cqmeQMCEnsiz7MGvcfmzKceyT5OLfO_c8lwWCTF0ih4,12505
|
|
4
|
+
mcbp_mcp_server/errors.py,sha256=a0HVKp4kJFwHOV9pf6d5ljRADZgyS3Gl3LbonrZmkB4,2996
|
|
5
|
+
mcbp_mcp_server/http.py,sha256=L2264kJQONeq6SPsk_EGNZwwTAaMuk39HNPgyBJaABk,13841
|
|
6
|
+
mcbp_mcp_server/registry.py,sha256=Dxgy4Oh1RteKGOX4L2gGmU8M1RXMq7D4wE-53fOhkZ0,4818
|
|
7
|
+
mcbp_mcp_server/server.py,sha256=TV8c_SYUTMs-4I52ufl_HcRvarX20biSRg9sNf27mOc,8204
|
|
8
|
+
mcbp-0.2.4.dist-info/METADATA,sha256=a6thIlQALsUwXLB00Aj8R_ga3os-LAt1xNBFYrtegDo,7555
|
|
9
|
+
mcbp-0.2.4.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
|
|
10
|
+
mcbp-0.2.4.dist-info/entry_points.txt,sha256=htifNeITOg2oK_HYKeUGQxEKywceEu0HVgXG8ElEJyc,91
|
|
11
|
+
mcbp-0.2.4.dist-info/licenses/LICENSE,sha256=85cNfzM7nd42WOo4lnmqJjxfpHM3oflTLSIlpo-7IHU,1066
|
|
12
|
+
mcbp-0.2.4.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 MCBP.PLUS
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Standalone MCP server exposing BAS MCBP_AI (/ai/v1) to MCP clients.
|
|
2
|
+
|
|
3
|
+
A thin stdio adapter over `mcbp_core` — see `.claude/skills/mcbp-mcp-server/SKILL.md` for the
|
|
4
|
+
build blueprint. This package holds no knowledge of BAS routes, fields, or response shapes.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version as _distribution_version
|
|
9
|
+
|
|
10
|
+
# The distribution is named `mcbp`, the import package `mcbp_mcp_server` — read the version from
|
|
11
|
+
# installed metadata rather than restating it here, so `pyproject.toml` stays the single source.
|
|
12
|
+
# MCP clients show this string in their connected-servers UI; an empty one makes releases
|
|
13
|
+
# indistinguishable there.
|
|
14
|
+
try:
|
|
15
|
+
__version__ = _distribution_version("mcbp")
|
|
16
|
+
except PackageNotFoundError: # source tree that was never installed (e.g. a bare checkout)
|
|
17
|
+
__version__ = "0+unknown"
|
|
18
|
+
|
|
19
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Per-request BAS credentials for the HTTP transport.
|
|
2
|
+
|
|
3
|
+
The stdio server has exactly one identity — the `ONEC_*` env block — and that stays true. Over
|
|
4
|
+
HTTP the process serves many people, and a single service account would make every action in the
|
|
5
|
+
BAS registration log look identical. So an HTTP caller may present its OWN BAS account in an
|
|
6
|
+
`Authorization: Basic` header, and BAS rights become the access control.
|
|
7
|
+
|
|
8
|
+
Three pieces live here:
|
|
9
|
+
|
|
10
|
+
* `Credentials` + `parse_authorization()` — the header, validated, never echoed back.
|
|
11
|
+
* a `ContextVar` carrying this request's credentials from the ASGI layer to the tool executor.
|
|
12
|
+
* `ClientPool` — one `MCBPClient` (and therefore one httpx pool AND one metadata cache) per
|
|
13
|
+
distinct credential set, reused across that caller's requests.
|
|
14
|
+
|
|
15
|
+
WHY A CONTEXTVAR, AND WHY IT IS SAFE HERE. `ServerRequestContext` (what `on_call_tool` receives
|
|
16
|
+
on `mcp==2.0.0`) has NO `transport` field at all, so the SDK's `TransportContext.headers` never
|
|
17
|
+
reaches a handler; and `StreamableHTTPSessionManager` only populates that field on the modern
|
|
18
|
+
(2026-07-28) path anyway. Capturing the headers in our own ASGI wrapper is therefore the only
|
|
19
|
+
route. That the value survives into the handler's task was verified empirically against
|
|
20
|
+
`mcp==2.0.0` on a live uvicorn run — stateful sessions, stateless mode, and the modern path, and
|
|
21
|
+
under four concurrent overlapping tool calls each seeing its own header. It is a per-REQUEST
|
|
22
|
+
value, not a per-session one: a session whose `initialize` carried header A and whose `tools/call`
|
|
23
|
+
carried header B executes as B.
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import base64
|
|
28
|
+
import binascii
|
|
29
|
+
import hashlib
|
|
30
|
+
import logging
|
|
31
|
+
import secrets
|
|
32
|
+
from collections.abc import AsyncIterator
|
|
33
|
+
from contextlib import asynccontextmanager
|
|
34
|
+
from contextvars import ContextVar, Token
|
|
35
|
+
from dataclasses import dataclass, field
|
|
36
|
+
from typing import TYPE_CHECKING
|
|
37
|
+
|
|
38
|
+
import anyio
|
|
39
|
+
from mcbp_core.client import ConnectionConfig, MCBPClient
|
|
40
|
+
|
|
41
|
+
if TYPE_CHECKING:
|
|
42
|
+
from mcbp_mcp_server.server import AppContext, Settings
|
|
43
|
+
|
|
44
|
+
log = logging.getLogger("mcbp_mcp_server")
|
|
45
|
+
|
|
46
|
+
# A long-lived process must not accumulate one httpx pool per person who ever connected. Both
|
|
47
|
+
# limits are deliberately generous: eviction closes a client, which throws away its warmed
|
|
48
|
+
# metadata cache, so it should happen on abandonment, not on ordinary idleness between turns.
|
|
49
|
+
MAX_CLIENTS = 32
|
|
50
|
+
IDLE_TTL_S = 1800.0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class CredentialsError(Exception):
|
|
54
|
+
"""A present but unusable `Authorization` header.
|
|
55
|
+
|
|
56
|
+
Never carries the header value: the message goes into an HTTP response and a log line, and
|
|
57
|
+
the header is a password. Absent header is NOT this error — that is the env fallback.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class Credentials:
|
|
63
|
+
user: str
|
|
64
|
+
password: str = field(repr=False)
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def cache_key(self) -> str:
|
|
68
|
+
"""Identifies the credential set without carrying it. The password never appears in a
|
|
69
|
+
dict key, a log line, or an eviction message — only this digest does."""
|
|
70
|
+
raw = f"{self.user}\0{self.password}".encode()
|
|
71
|
+
return hashlib.sha256(raw).hexdigest()
|
|
72
|
+
|
|
73
|
+
def __repr__(self) -> str: # pragma: no cover - defensive, exercised only by a stray log
|
|
74
|
+
return f"Credentials(user={self.user!r})"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class TokenEntry:
|
|
79
|
+
"""One line of the token registry: an opaque bearer token standing in for a BAS account.
|
|
80
|
+
|
|
81
|
+
`label` exists so a log line can name WHO acted without naming the token or the password.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
token: str = field(repr=False)
|
|
85
|
+
credentials: Credentials
|
|
86
|
+
label: str = ""
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def display_name(self) -> str:
|
|
90
|
+
return self.label or self.credentials.user
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class TokenRegistry:
|
|
94
|
+
"""The `Bearer` half of the auth story: token -> BAS account.
|
|
95
|
+
|
|
96
|
+
Deliberately a linear scan compared with `secrets.compare_digest`, not a dict lookup. A dict
|
|
97
|
+
compares hashes and then the strings themselves with an early-exit `==`; the scan keeps the
|
|
98
|
+
comparison constant-time per entry, which is what stops a caller from probing the token a
|
|
99
|
+
byte at a time. The registries here are a handful of people, so the cost is irrelevant.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, entries: list[TokenEntry] | None = None) -> None:
|
|
103
|
+
self._entries: list[TokenEntry] = list(entries or ())
|
|
104
|
+
|
|
105
|
+
def __len__(self) -> int:
|
|
106
|
+
return len(self._entries)
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def labels(self) -> list[str]:
|
|
110
|
+
"""Safe to log: names only, never tokens."""
|
|
111
|
+
return [e.display_name for e in self._entries]
|
|
112
|
+
|
|
113
|
+
def lookup(self, token: str) -> TokenEntry | None:
|
|
114
|
+
"""Compares BYTES, not strings: `compare_digest` on `str` raises `TypeError` on any
|
|
115
|
+
non-ASCII character, and a token arrives from a header, so a stray byte would turn a
|
|
116
|
+
rejection into a 500. Encoded, a non-ASCII token simply matches nothing and takes the
|
|
117
|
+
ordinary "not recognised" path — no separate error telling a caller its token was
|
|
118
|
+
merely malformed rather than unknown."""
|
|
119
|
+
probe = token.encode("utf-8")
|
|
120
|
+
found: TokenEntry | None = None
|
|
121
|
+
for entry in self._entries:
|
|
122
|
+
if secrets.compare_digest(entry.token.encode("utf-8"), probe):
|
|
123
|
+
found = entry
|
|
124
|
+
return found
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_authorization(value: str, tokens: TokenRegistry | None = None) -> Credentials:
|
|
128
|
+
"""`Basic <base64 user:password>` or `Bearer <token>` -> `Credentials`. Raises
|
|
129
|
+
`CredentialsError` on anything else — a malformed header must never fall back to the env
|
|
130
|
+
account, which would mean a user with a bad config quietly acting as the service identity.
|
|
131
|
+
|
|
132
|
+
The `Bearer` form exists because some MCP clients (Codex) can send a token from an env var
|
|
133
|
+
but cannot send an arbitrary header. The token itself never appears in the raised message:
|
|
134
|
+
the message reaches an HTTP response body and a log line, and the token IS the password.
|
|
135
|
+
"""
|
|
136
|
+
scheme, _, payload = value.partition(" ")
|
|
137
|
+
if scheme.lower() == "bearer":
|
|
138
|
+
return _resolve_bearer(payload.strip(), tokens)
|
|
139
|
+
if scheme.lower() != "basic":
|
|
140
|
+
raise CredentialsError(
|
|
141
|
+
"Authorization must use the Basic or Bearer scheme: "
|
|
142
|
+
"'Basic <base64 of user:password>' or 'Bearer <token>'"
|
|
143
|
+
)
|
|
144
|
+
payload = payload.strip()
|
|
145
|
+
if not payload:
|
|
146
|
+
raise CredentialsError("Authorization: Basic is missing its base64 credentials")
|
|
147
|
+
try:
|
|
148
|
+
decoded = base64.b64decode(payload, validate=True).decode("utf-8")
|
|
149
|
+
except (binascii.Error, ValueError, UnicodeDecodeError) as e:
|
|
150
|
+
raise CredentialsError(
|
|
151
|
+
"Authorization: Basic value is not valid base64 of a UTF-8 'user:password'"
|
|
152
|
+
) from e
|
|
153
|
+
user, sep, password = decoded.partition(":")
|
|
154
|
+
if not sep:
|
|
155
|
+
raise CredentialsError("Authorization: Basic credentials must be 'user:password'")
|
|
156
|
+
if not user:
|
|
157
|
+
raise CredentialsError("Authorization: Basic credentials have an empty user name")
|
|
158
|
+
return Credentials(user=user, password=password)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _resolve_bearer(token: str, tokens: TokenRegistry | None) -> Credentials:
|
|
162
|
+
if not token:
|
|
163
|
+
raise CredentialsError("Authorization: Bearer is missing its token")
|
|
164
|
+
if tokens is None or not len(tokens):
|
|
165
|
+
raise CredentialsError(
|
|
166
|
+
"Authorization: Bearer is not accepted — no token registry is configured "
|
|
167
|
+
"(set MCP_TOKENS_FILE)"
|
|
168
|
+
)
|
|
169
|
+
entry = tokens.lookup(token)
|
|
170
|
+
if entry is None:
|
|
171
|
+
raise CredentialsError("Authorization: Bearer token is not recognised")
|
|
172
|
+
log.info("authorized bearer request for %s", entry.display_name)
|
|
173
|
+
return entry.credentials
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
_request_credentials: ContextVar[Credentials | None] = ContextVar(
|
|
177
|
+
"mcbp_request_credentials", default=None,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def set_request_credentials(creds: Credentials | None) -> Token[Credentials | None]:
|
|
182
|
+
return _request_credentials.set(creds)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def reset_request_credentials(token: Token[Credentials | None]) -> None:
|
|
186
|
+
_request_credentials.reset(token)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def current_credentials() -> Credentials | None:
|
|
190
|
+
"""`None` on stdio (no headers exist) and for an HTTP request with no `Authorization` — both
|
|
191
|
+
mean "use the env identity"."""
|
|
192
|
+
return _request_credentials.get()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass
|
|
196
|
+
class _Entry:
|
|
197
|
+
client: MCBPClient
|
|
198
|
+
last_used: float
|
|
199
|
+
inflight: int = 0
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
class ClientPool:
|
|
203
|
+
"""One live `MCBPClient` per credential set, reused across that caller's requests.
|
|
204
|
+
|
|
205
|
+
A fresh client per tool call would mean a fresh httpx pool and a cold metadata cache on every
|
|
206
|
+
model turn. Conversely the cache MUST NOT be shared: it is per client instance, which is
|
|
207
|
+
exactly what keeps one user's metadata (and connection) out of another's requests.
|
|
208
|
+
"""
|
|
209
|
+
|
|
210
|
+
def __init__(self, settings: Settings, *, max_clients: int = MAX_CLIENTS,
|
|
211
|
+
idle_ttl_s: float = IDLE_TTL_S) -> None:
|
|
212
|
+
self._settings = settings
|
|
213
|
+
self._max_clients = max_clients
|
|
214
|
+
self._idle_ttl_s = idle_ttl_s
|
|
215
|
+
self._entries: dict[str, _Entry] = {}
|
|
216
|
+
self._lock = anyio.Lock()
|
|
217
|
+
|
|
218
|
+
@property
|
|
219
|
+
def size(self) -> int:
|
|
220
|
+
return len(self._entries)
|
|
221
|
+
|
|
222
|
+
def _build(self, creds: Credentials) -> MCBPClient:
|
|
223
|
+
s = self._settings
|
|
224
|
+
return MCBPClient(ConnectionConfig(
|
|
225
|
+
base_url=s.base_url,
|
|
226
|
+
user=creds.user,
|
|
227
|
+
password=creds.password,
|
|
228
|
+
timeout_s=s.timeout,
|
|
229
|
+
pool_max=s.pool_max,
|
|
230
|
+
mock=False,
|
|
231
|
+
verify=s.verify_ssl,
|
|
232
|
+
))
|
|
233
|
+
|
|
234
|
+
@asynccontextmanager
|
|
235
|
+
async def lease(self, creds: Credentials) -> AsyncIterator[MCBPClient]:
|
|
236
|
+
"""Hands out the client for `creds`, held against eviction for the duration."""
|
|
237
|
+
key = creds.cache_key
|
|
238
|
+
async with self._lock:
|
|
239
|
+
entry = self._entries.get(key)
|
|
240
|
+
if entry is None:
|
|
241
|
+
client = self._build(creds)
|
|
242
|
+
await client.startup()
|
|
243
|
+
entry = _Entry(client=client, last_used=anyio.current_time())
|
|
244
|
+
self._entries[key] = entry
|
|
245
|
+
log.info("BAS client opened for user %s (pool size %d)", creds.user, self.size)
|
|
246
|
+
entry.inflight += 1
|
|
247
|
+
entry.last_used = anyio.current_time()
|
|
248
|
+
await self._evict_locked()
|
|
249
|
+
try:
|
|
250
|
+
yield entry.client
|
|
251
|
+
finally:
|
|
252
|
+
async with self._lock:
|
|
253
|
+
entry.inflight -= 1
|
|
254
|
+
entry.last_used = anyio.current_time()
|
|
255
|
+
|
|
256
|
+
async def _evict_locked(self) -> None:
|
|
257
|
+
"""Closes idle clients: anything unused past the TTL, then the least recently used while
|
|
258
|
+
over the size cap. An entry with a call in flight is never closed — `aclose()` on a live
|
|
259
|
+
httpx pool would abort that caller's request."""
|
|
260
|
+
now = anyio.current_time()
|
|
261
|
+
stale = [
|
|
262
|
+
key for key, e in self._entries.items()
|
|
263
|
+
if e.inflight == 0 and now - e.last_used > self._idle_ttl_s
|
|
264
|
+
]
|
|
265
|
+
for key in stale:
|
|
266
|
+
await self._close(key, "idle")
|
|
267
|
+
if len(self._entries) <= self._max_clients:
|
|
268
|
+
return
|
|
269
|
+
by_age = sorted(
|
|
270
|
+
(k for k, e in self._entries.items() if e.inflight == 0),
|
|
271
|
+
key=lambda k: self._entries[k].last_used,
|
|
272
|
+
)
|
|
273
|
+
for key in by_age[: len(self._entries) - self._max_clients]:
|
|
274
|
+
await self._close(key, "pool full")
|
|
275
|
+
|
|
276
|
+
async def _close(self, key: str, reason: str) -> None:
|
|
277
|
+
entry = self._entries.pop(key, None)
|
|
278
|
+
if entry is None: # pragma: no cover - only reachable on a concurrent pop
|
|
279
|
+
return
|
|
280
|
+
log.info("closing BAS client %s (%s)", key[:8], reason)
|
|
281
|
+
await entry.client.shutdown()
|
|
282
|
+
|
|
283
|
+
async def aclose(self) -> None:
|
|
284
|
+
async with self._lock:
|
|
285
|
+
for key in list(self._entries):
|
|
286
|
+
await self._close(key, "shutdown")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@asynccontextmanager
|
|
290
|
+
async def client_for(app: AppContext) -> AsyncIterator[MCBPClient]:
|
|
291
|
+
"""The client this request must run as: the caller's own when an `Authorization` header was
|
|
292
|
+
presented, otherwise the process's env identity (always the case on stdio)."""
|
|
293
|
+
creds = current_credentials()
|
|
294
|
+
if creds is None or app.pool is None:
|
|
295
|
+
yield app.client
|
|
296
|
+
return
|
|
297
|
+
async with app.pool.lease(creds) as client:
|
|
298
|
+
yield client
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Maps `mcbp_core.errors.MCBPError` subclasses to the two channels a tool call on the
|
|
2
|
+
low-level `Server` can take.
|
|
3
|
+
|
|
4
|
+
EMPIRICALLY VERIFIED against the installed `mcp==2.0.0` (probe: raise a plain exception from
|
|
5
|
+
`on_call_tool`, call through the SDK's in-memory `Client`) — the low-level `Server` has NO
|
|
6
|
+
wrapper around `on_call_tool` the way `MCPServer`'s `@tool()` decorator did. An exception that
|
|
7
|
+
escapes the handler is NOT turned into `CallToolResult(is_error=True, ...)` automatically: it
|
|
8
|
+
propagates through the JSON-RPC dispatcher, which recognizes only `MCPError` and pydantic
|
|
9
|
+
`ValidationError` and maps everything else to a GENERIC `MCPError(code=INTERNAL_ERROR,
|
|
10
|
+
message="Internal server error")` — the original message is DISCARDED. So:
|
|
11
|
+
|
|
12
|
+
- `KeyMismatchError` / `AuthError` (fatal configuration, the model cannot fix it): build our own
|
|
13
|
+
`MCPError` here and `raise` it from `on_call_tool` — that is the one exception type the
|
|
14
|
+
dispatcher passes through with its message intact, so this is the only way to get a
|
|
15
|
+
*meaningful* protocol-level error to the host instead of the generic swallowed one.
|
|
16
|
+
- Every other `MCBPError` (`ParameterError`, `NotFoundError`, `PlusRequiredError`,
|
|
17
|
+
`UpstreamError`, …): must NOT be allowed to escape `on_call_tool` — `registry.py` catches it and
|
|
18
|
+
calls `to_tool_error()` to build a `CallToolResult(is_error=True, ...)` explicitly, which is the
|
|
19
|
+
only way the model sees the message (e.g. `BAD_PARAMETER` naming the unknown field verbatim)
|
|
20
|
+
instead of a swallowed "Internal server error".
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from mcbp_core.errors import AuthError, KeyMismatchError, LicenseRequiredError, MCBPError
|
|
25
|
+
from mcp import MCPError, types
|
|
26
|
+
|
|
27
|
+
_FATAL: tuple[type[MCBPError], ...] = (KeyMismatchError, AuthError, LicenseRequiredError)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def is_fatal(exc: MCBPError) -> bool:
|
|
31
|
+
"""True for the error types that are a fatal configuration issue, not a recoverable tool-call
|
|
32
|
+
failure — the model cannot self-correct a key mismatch, bad credentials or a missing licence.
|
|
33
|
+
|
|
34
|
+
A licence covers the whole service: every remaining tool would fail identically, so surfacing
|
|
35
|
+
it as an ordinary tool error would have the model walk the registry one call at a time."""
|
|
36
|
+
return isinstance(exc, _FATAL)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def to_mcp_error(exc: MCBPError) -> MCPError:
|
|
40
|
+
"""`KeyMismatchError`/`AuthError` only. `code`/`message` are carried verbatim, not reworded —
|
|
41
|
+
the host surfaces this as a JSON-RPC error, never a normal tool result."""
|
|
42
|
+
return MCPError(code=types.INTERNAL_ERROR, message=f"{exc.code}: {exc.message}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def to_tool_error(exc: MCBPError) -> types.CallToolResult:
|
|
46
|
+
"""Every other `MCBPError`. `code`/`message` are carried verbatim (never wrapped or
|
|
47
|
+
reworded) — `BAD_PARAMETER` naming the unknown field is exactly what lets the model recover
|
|
48
|
+
via `describe_metadata`."""
|
|
49
|
+
return types.CallToolResult(
|
|
50
|
+
is_error=True,
|
|
51
|
+
content=[types.TextContent(type="text", text=f"{exc.code}: {exc.message}")],
|
|
52
|
+
)
|
mcbp_mcp_server/http.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Streamable HTTP transport for the `mcbp-ai` MCP server.
|
|
2
|
+
|
|
3
|
+
A second entry point NEXT TO stdio, not a replacement: `create_server()`, the tool registry and
|
|
4
|
+
the `lifespan` that owns `MCBPClient` are reused untouched. `StreamableHTTPSessionManager` takes
|
|
5
|
+
the low-level `Server` directly, so this module only wires ASGI — it holds no knowledge of BAS
|
|
6
|
+
routes or of the tool set.
|
|
7
|
+
|
|
8
|
+
Deployment notes that are NOT obvious from the code:
|
|
9
|
+
* `Route`, not `Mount`. Starlette compiles `Mount("/mcp")` to `^/mcp/(?P<path>.*)$`, so an exact
|
|
10
|
+
POST to `/mcp` misses the mount and gets a 307 to `/mcp/` — and that bare form is exactly what
|
|
11
|
+
a user types into a connector URL field.
|
|
12
|
+
* DNS-rebinding protection is opt-in here: the SDK's own default when no settings are passed is
|
|
13
|
+
"disabled", and enabling it with an empty allow-list would answer every request with 421.
|
|
14
|
+
Set MCP_HTTP_ALLOWED_HOSTS once the public hostname is known.
|
|
15
|
+
* nginx in front needs `proxy_buffering off` (SSE) and a read timeout above the client's own
|
|
16
|
+
300 s tool-call limit.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import tomllib
|
|
24
|
+
from collections.abc import AsyncIterator, Mapping
|
|
25
|
+
from contextlib import asynccontextmanager
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
|
30
|
+
from mcp.server.transport_security import TransportSecuritySettings
|
|
31
|
+
from starlette.applications import Starlette
|
|
32
|
+
from starlette.datastructures import Headers
|
|
33
|
+
from starlette.requests import Request
|
|
34
|
+
from starlette.responses import JSONResponse, PlainTextResponse
|
|
35
|
+
from starlette.routing import Route
|
|
36
|
+
from starlette.types import Receive, Scope, Send
|
|
37
|
+
|
|
38
|
+
from mcbp_mcp_server.credentials import (
|
|
39
|
+
Credentials,
|
|
40
|
+
CredentialsError,
|
|
41
|
+
TokenEntry,
|
|
42
|
+
TokenRegistry,
|
|
43
|
+
parse_authorization,
|
|
44
|
+
reset_request_credentials,
|
|
45
|
+
set_request_credentials,
|
|
46
|
+
)
|
|
47
|
+
from mcbp_mcp_server.server import (
|
|
48
|
+
Settings,
|
|
49
|
+
SettingsError,
|
|
50
|
+
_optional_float,
|
|
51
|
+
_optional_int,
|
|
52
|
+
_truthy,
|
|
53
|
+
create_server,
|
|
54
|
+
load_settings,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
log = logging.getLogger("mcbp_mcp_server")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class HttpSettings:
|
|
62
|
+
host: str = "127.0.0.1"
|
|
63
|
+
port: int = 8010
|
|
64
|
+
path: str = "/mcp"
|
|
65
|
+
allowed_hosts: list[str] = field(default_factory=list)
|
|
66
|
+
allowed_origins: list[str] = field(default_factory=list)
|
|
67
|
+
session_idle_timeout: float = 1800.0
|
|
68
|
+
stateless: bool = False
|
|
69
|
+
require_auth: bool = False
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _csv(env: Mapping[str, str], name: str) -> list[str]:
|
|
73
|
+
return [item.strip() for item in env.get(name, "").split(",") if item.strip()]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_http_settings(env: Mapping[str, str] | None = None) -> HttpSettings:
|
|
77
|
+
"""Reads `MCP_HTTP_*` from `env` (defaults to `os.environ`). Pure, like `load_settings()`:
|
|
78
|
+
raises `SettingsError`, never prints or exits. Binds to loopback by default — the process is
|
|
79
|
+
meant to sit behind nginx, and a default of 0.0.0.0 would publish an unauthenticated endpoint
|
|
80
|
+
the moment someone forgets the reverse proxy."""
|
|
81
|
+
e = os.environ if env is None else env
|
|
82
|
+
path = e.get("MCP_HTTP_PATH", "/mcp").rstrip("/") or "/"
|
|
83
|
+
if not path.startswith("/"):
|
|
84
|
+
raise SettingsError(f"MCP_HTTP_PATH must start with '/', got {path!r}")
|
|
85
|
+
return HttpSettings(
|
|
86
|
+
host=e.get("MCP_HTTP_HOST", "127.0.0.1"),
|
|
87
|
+
port=_optional_int(e, "MCP_HTTP_PORT", 8010),
|
|
88
|
+
path=path,
|
|
89
|
+
allowed_hosts=_csv(e, "MCP_HTTP_ALLOWED_HOSTS"),
|
|
90
|
+
allowed_origins=_csv(e, "MCP_HTTP_ALLOWED_ORIGINS"),
|
|
91
|
+
session_idle_timeout=_optional_float(e, "MCP_HTTP_SESSION_IDLE_TIMEOUT", 1800.0),
|
|
92
|
+
stateless=_truthy(e.get("MCP_HTTP_STATELESS")),
|
|
93
|
+
require_auth=_truthy(e.get("MCP_REQUIRE_AUTH")),
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def load_token_registry(env: Mapping[str, str] | None = None) -> TokenRegistry:
|
|
98
|
+
"""Reads the `MCP_TOKENS_FILE` TOML registry. Pure, like `load_http_settings()`.
|
|
99
|
+
|
|
100
|
+
An unset variable yields an EMPTY registry, not an error — Bearer is then simply not
|
|
101
|
+
accepted, which is the stdio/Basic-only deployment. A variable that IS set and points at a
|
|
102
|
+
missing or malformed file is a startup error: silently serving with Bearer disabled would
|
|
103
|
+
look exactly like a working server until someone's token is rejected.
|
|
104
|
+
|
|
105
|
+
Expected shape (one table per person)::
|
|
106
|
+
|
|
107
|
+
[[users]]
|
|
108
|
+
token = "<random string>"
|
|
109
|
+
onec_user = "Директор-Корнієнко"
|
|
110
|
+
onec_password = ""
|
|
111
|
+
label = "Директор"
|
|
112
|
+
|
|
113
|
+
Error messages name the file and the entry number, never a token, a password, or any other
|
|
114
|
+
file content.
|
|
115
|
+
"""
|
|
116
|
+
e = os.environ if env is None else env
|
|
117
|
+
raw_path = (e.get("MCP_TOKENS_FILE") or "").strip()
|
|
118
|
+
if not raw_path:
|
|
119
|
+
return TokenRegistry()
|
|
120
|
+
path = Path(raw_path)
|
|
121
|
+
try:
|
|
122
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
123
|
+
except FileNotFoundError as exc:
|
|
124
|
+
raise SettingsError(f"MCP_TOKENS_FILE points at a file that does not exist: {path}") from exc
|
|
125
|
+
except OSError as exc:
|
|
126
|
+
raise SettingsError(f"MCP_TOKENS_FILE {path} cannot be read: {exc.strerror}") from exc
|
|
127
|
+
except UnicodeDecodeError as exc:
|
|
128
|
+
raise SettingsError(f"MCP_TOKENS_FILE {path} is not valid UTF-8") from exc
|
|
129
|
+
except tomllib.TOMLDecodeError as exc:
|
|
130
|
+
raise SettingsError(f"MCP_TOKENS_FILE {path} is not valid TOML: {exc}") from exc
|
|
131
|
+
return _registry_from(data, path)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _registry_from(data: dict[str, object], path: Path) -> TokenRegistry:
|
|
135
|
+
users = data.get("users", [])
|
|
136
|
+
if not isinstance(users, list):
|
|
137
|
+
raise SettingsError(f"MCP_TOKENS_FILE {path} must define an array of tables '[[users]]'")
|
|
138
|
+
entries: list[TokenEntry] = []
|
|
139
|
+
seen: set[str] = set()
|
|
140
|
+
for index, item in enumerate(users, start=1):
|
|
141
|
+
where = f"MCP_TOKENS_FILE {path}, [[users]] entry #{index}"
|
|
142
|
+
if not isinstance(item, dict):
|
|
143
|
+
raise SettingsError(f"{where} must be a table")
|
|
144
|
+
token = _entry_str(item, "token", where, required=True)
|
|
145
|
+
user = _entry_str(item, "onec_user", where, required=True)
|
|
146
|
+
# Empty is legal and common (basmbdemo has no password) — only a missing key fails.
|
|
147
|
+
password = _entry_str(item, "onec_password", where, required=False, allow_empty=True)
|
|
148
|
+
if "onec_password" not in item:
|
|
149
|
+
raise SettingsError(f"{where} is missing 'onec_password' (use \"\" for none)")
|
|
150
|
+
label = _entry_str(item, "label", where, required=False, allow_empty=True)
|
|
151
|
+
if token in seen:
|
|
152
|
+
raise SettingsError(f"{where} repeats a token already used by an earlier entry")
|
|
153
|
+
seen.add(token)
|
|
154
|
+
entries.append(TokenEntry(
|
|
155
|
+
token=token,
|
|
156
|
+
credentials=Credentials(user=user, password=password),
|
|
157
|
+
label=label,
|
|
158
|
+
))
|
|
159
|
+
return TokenRegistry(entries)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _entry_str(item: dict[str, object], key: str, where: str, *, required: bool,
|
|
163
|
+
allow_empty: bool = False) -> str:
|
|
164
|
+
value = item.get(key)
|
|
165
|
+
if value is None:
|
|
166
|
+
if required:
|
|
167
|
+
raise SettingsError(f"{where} is missing '{key}'")
|
|
168
|
+
return ""
|
|
169
|
+
if not isinstance(value, str):
|
|
170
|
+
raise SettingsError(f"{where} has a non-string '{key}'")
|
|
171
|
+
if not value and not allow_empty:
|
|
172
|
+
raise SettingsError(f"{where} has an empty '{key}'")
|
|
173
|
+
return value
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _security(settings: HttpSettings) -> TransportSecuritySettings | None:
|
|
177
|
+
"""`None` leaves the SDK middleware at its own permissive default. Protection is enabled only
|
|
178
|
+
once a host allow-list exists, because the middleware rejects EVERY request (421) when
|
|
179
|
+
enabled with an empty list."""
|
|
180
|
+
if not settings.allowed_hosts:
|
|
181
|
+
return None
|
|
182
|
+
return TransportSecuritySettings(
|
|
183
|
+
enable_dns_rebinding_protection=True,
|
|
184
|
+
allowed_hosts=settings.allowed_hosts,
|
|
185
|
+
allowed_origins=settings.allowed_origins,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class _ManagerEndpoint:
|
|
190
|
+
"""Raw ASGI adapter so `Route` treats this as an ASGI app rather than a `func(request)`
|
|
191
|
+
endpoint — Starlette makes that decision with `inspect.isfunction/ismethod`, and
|
|
192
|
+
`manager.handle_request` is a bound method, which would be misread as the latter.
|
|
193
|
+
|
|
194
|
+
It is also where the caller's BAS credentials are picked up. This has to happen HERE: the
|
|
195
|
+
SDK hands `on_call_tool` a `ServerRequestContext`, which carries no transport metadata at
|
|
196
|
+
all, and the manager attaches `TransportContext.headers` only on the modern protocol path.
|
|
197
|
+
Our own ASGI layer sees the headers whatever the protocol version — see `credentials.py`
|
|
198
|
+
for the propagation evidence."""
|
|
199
|
+
|
|
200
|
+
def __init__(self, manager: StreamableHTTPSessionManager,
|
|
201
|
+
tokens: TokenRegistry | None = None, *, require_auth: bool = False) -> None:
|
|
202
|
+
self._manager = manager
|
|
203
|
+
self._tokens = tokens
|
|
204
|
+
self._require_auth = require_auth
|
|
205
|
+
|
|
206
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
207
|
+
raw = Headers(scope=scope).get("authorization")
|
|
208
|
+
try:
|
|
209
|
+
if raw is None:
|
|
210
|
+
# With MCP_REQUIRE_AUTH the env account stops being a fallback: on a port that is
|
|
211
|
+
# reachable by anyone, an unauthenticated request would otherwise act as the
|
|
212
|
+
# service identity.
|
|
213
|
+
if self._require_auth:
|
|
214
|
+
raise CredentialsError(
|
|
215
|
+
"Authorization is required: send 'Bearer <token>' or "
|
|
216
|
+
"'Basic <base64 of user:password>'"
|
|
217
|
+
)
|
|
218
|
+
creds = None
|
|
219
|
+
else:
|
|
220
|
+
creds = parse_authorization(raw, self._tokens)
|
|
221
|
+
except CredentialsError as e:
|
|
222
|
+
# 400, deliberately NOT 401: MCP clients read a 401 as "begin OAuth discovery" and
|
|
223
|
+
# would chase an authorization server that does not exist instead of showing the
|
|
224
|
+
# reason. Falling through to the env account is not an option either — that is the
|
|
225
|
+
# silent-service-account failure this whole feature exists to remove.
|
|
226
|
+
log.warning("rejected request with an unusable Authorization header: %s", e)
|
|
227
|
+
await JSONResponse({"error": {"code": "BAD_AUTHORIZATION", "message": str(e)}},
|
|
228
|
+
status_code=400)(scope, receive, send)
|
|
229
|
+
return
|
|
230
|
+
token = set_request_credentials(creds)
|
|
231
|
+
try:
|
|
232
|
+
await self._manager.handle_request(scope, receive, send)
|
|
233
|
+
finally:
|
|
234
|
+
reset_request_credentials(token)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
async def _healthz(request: Request) -> PlainTextResponse:
|
|
238
|
+
"""Liveness only — says the process is up, NOT that BAS is reachable. The MCP endpoint itself
|
|
239
|
+
cannot serve as a probe: it answers 400 without a negotiated session."""
|
|
240
|
+
return PlainTextResponse("ok")
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def create_app(settings: HttpSettings | None = None,
|
|
244
|
+
tokens: TokenRegistry | None = None) -> Starlette:
|
|
245
|
+
"""Builds the ASGI app without touching the network. `create_server()` is called once; the
|
|
246
|
+
`MCBPClient` behind it is still built lazily by the server's own `lifespan`."""
|
|
247
|
+
http_settings = load_http_settings() if settings is None else settings
|
|
248
|
+
registry = load_token_registry() if tokens is None else tokens
|
|
249
|
+
manager = StreamableHTTPSessionManager(
|
|
250
|
+
create_server(),
|
|
251
|
+
json_response=False,
|
|
252
|
+
stateless=http_settings.stateless,
|
|
253
|
+
security_settings=_security(http_settings),
|
|
254
|
+
session_idle_timeout=http_settings.session_idle_timeout,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
@asynccontextmanager
|
|
258
|
+
async def app_lifespan(app: Starlette) -> AsyncIterator[None]:
|
|
259
|
+
async with manager.run():
|
|
260
|
+
log.info("MCP Streamable HTTP ready on %s", http_settings.path)
|
|
261
|
+
yield
|
|
262
|
+
|
|
263
|
+
return Starlette(
|
|
264
|
+
routes=[
|
|
265
|
+
# Route, not Mount — see the module docstring.
|
|
266
|
+
Route(
|
|
267
|
+
http_settings.path,
|
|
268
|
+
endpoint=_ManagerEndpoint(
|
|
269
|
+
manager, registry, require_auth=http_settings.require_auth,
|
|
270
|
+
),
|
|
271
|
+
methods=["GET", "POST", "DELETE"],
|
|
272
|
+
),
|
|
273
|
+
Route("/healthz", endpoint=_healthz, methods=["GET"]),
|
|
274
|
+
],
|
|
275
|
+
lifespan=app_lifespan,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def main() -> None:
|
|
280
|
+
"""Entry point for the `mcbp-http` console script."""
|
|
281
|
+
logging.basicConfig(
|
|
282
|
+
level=logging.INFO, stream=sys.stderr,
|
|
283
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
284
|
+
)
|
|
285
|
+
try:
|
|
286
|
+
onec: Settings = load_settings()
|
|
287
|
+
http_settings = load_http_settings()
|
|
288
|
+
tokens = load_token_registry()
|
|
289
|
+
except SettingsError as e:
|
|
290
|
+
log.error(str(e))
|
|
291
|
+
sys.exit(1)
|
|
292
|
+
|
|
293
|
+
import uvicorn
|
|
294
|
+
|
|
295
|
+
if not http_settings.allowed_hosts:
|
|
296
|
+
log.warning(
|
|
297
|
+
"MCP_HTTP_ALLOWED_HOSTS is unset — DNS-rebinding protection is off. Set it to the "
|
|
298
|
+
"public hostname before exposing this endpoint."
|
|
299
|
+
)
|
|
300
|
+
if http_settings.require_auth and not len(tokens):
|
|
301
|
+
log.warning(
|
|
302
|
+
"MCP_REQUIRE_AUTH is on with an empty token registry — only Basic callers can "
|
|
303
|
+
"connect. Set MCP_TOKENS_FILE to accept Bearer tokens."
|
|
304
|
+
)
|
|
305
|
+
log.info(
|
|
306
|
+
"starting mcbp-ai MCP over HTTP on %s:%s%s (BAS %s, write=%s, require_auth=%s, "
|
|
307
|
+
"bearer users: %s)",
|
|
308
|
+
http_settings.host, http_settings.port, http_settings.path,
|
|
309
|
+
onec.base_url, onec.allow_write, http_settings.require_auth,
|
|
310
|
+
", ".join(tokens.labels) or "none",
|
|
311
|
+
)
|
|
312
|
+
uvicorn.run(
|
|
313
|
+
create_app(http_settings, tokens),
|
|
314
|
+
host=http_settings.host,
|
|
315
|
+
port=http_settings.port,
|
|
316
|
+
log_level="info",
|
|
317
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""`ToolSpec` -> MCP tool registration for the low-level `Server`.
|
|
2
|
+
|
|
3
|
+
One loop over `mcbp_core.tools.tool_specs(surface="mcp", ...)`, no per-tool `async def` and no
|
|
4
|
+
hardcoded tool list — a spec added to the shared registry is exposed here with zero edits.
|
|
5
|
+
`spec.parameters` is passed through as `input_schema` UNCHANGED (that is the whole reason the
|
|
6
|
+
skeleton is built on the low-level `Server` rather than `MCPServer` — see
|
|
7
|
+
`.claude/skills/mcbp-mcp-server/SKILL.md`).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from mcbp_core.errors import MCBPError
|
|
17
|
+
from mcbp_core.tools import ToolSpec, tool_specs
|
|
18
|
+
from mcp import types
|
|
19
|
+
from mcp.server.context import ServerRequestContext
|
|
20
|
+
|
|
21
|
+
from mcbp_mcp_server.credentials import client_for
|
|
22
|
+
from mcbp_mcp_server.errors import is_fatal, to_mcp_error, to_tool_error
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from mcbp_mcp_server.server import AppContext
|
|
26
|
+
|
|
27
|
+
log = logging.getLogger("mcbp_mcp_server")
|
|
28
|
+
|
|
29
|
+
_OnListTools = Callable[
|
|
30
|
+
["ServerRequestContext[AppContext, Any]", "types.PaginatedRequestParams | None"],
|
|
31
|
+
Awaitable[types.ListToolsResult],
|
|
32
|
+
]
|
|
33
|
+
_OnCallTool = Callable[
|
|
34
|
+
["ServerRequestContext[AppContext, Any]", types.CallToolRequestParams],
|
|
35
|
+
Awaitable[types.CallToolResult],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _annotations(spec: ToolSpec) -> types.ToolAnnotations:
|
|
40
|
+
return types.ToolAnnotations(
|
|
41
|
+
read_only_hint=spec.read_only,
|
|
42
|
+
destructive_hint=True if not spec.read_only else None,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _to_tool(spec: ToolSpec) -> types.Tool:
|
|
47
|
+
return types.Tool(
|
|
48
|
+
name=spec.name,
|
|
49
|
+
description=spec.description_en or spec.description,
|
|
50
|
+
input_schema=spec.parameters, # already JSON Schema — passed through untouched
|
|
51
|
+
annotations=_annotations(spec),
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def make_on_list_tools(allow_write: bool) -> _OnListTools:
|
|
56
|
+
# Built once at registration time so `tools/list` is deterministic and cheap — the registry
|
|
57
|
+
# order from `tool_specs()` is preserved, never sorted.
|
|
58
|
+
tools = [_to_tool(spec) for spec in tool_specs(surface="mcp", include_write=allow_write)]
|
|
59
|
+
|
|
60
|
+
async def on_list_tools(
|
|
61
|
+
ctx: ServerRequestContext[AppContext, Any], params: types.PaginatedRequestParams | None,
|
|
62
|
+
) -> types.ListToolsResult:
|
|
63
|
+
return types.ListToolsResult(tools=tools)
|
|
64
|
+
|
|
65
|
+
return on_list_tools
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def make_on_call_tool(allow_write: bool) -> _OnCallTool:
|
|
69
|
+
specs: dict[str, ToolSpec] = {
|
|
70
|
+
spec.name: spec for spec in tool_specs(surface="mcp", include_write=allow_write)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async def on_call_tool(
|
|
74
|
+
ctx: ServerRequestContext[AppContext, Any], params: types.CallToolRequestParams,
|
|
75
|
+
) -> types.CallToolResult:
|
|
76
|
+
spec = specs.get(params.name)
|
|
77
|
+
if spec is None:
|
|
78
|
+
return types.CallToolResult(
|
|
79
|
+
is_error=True,
|
|
80
|
+
content=[types.TextContent(type="text", text=f"Unknown tool: {params.name}")],
|
|
81
|
+
)
|
|
82
|
+
arguments = params.arguments or {}
|
|
83
|
+
try:
|
|
84
|
+
# Before the executor, not inside it: executors index required arguments directly, so
|
|
85
|
+
# a misspelled name would otherwise surface as a bare KeyError the model cannot act on.
|
|
86
|
+
spec.validate_arguments(arguments)
|
|
87
|
+
# The client is chosen per REQUEST, not per process: an HTTP caller presenting its
|
|
88
|
+
# own BAS account runs as that account (see `credentials.py`), stdio always runs as
|
|
89
|
+
# the env identity.
|
|
90
|
+
async with client_for(ctx.lifespan_context) as client:
|
|
91
|
+
result = await spec.executor(client, arguments)
|
|
92
|
+
except MCBPError as exc:
|
|
93
|
+
if is_fatal(exc):
|
|
94
|
+
raise to_mcp_error(exc) from exc
|
|
95
|
+
log.warning("%s -> %s: %s", spec.name, exc.code, exc.message)
|
|
96
|
+
return to_tool_error(exc)
|
|
97
|
+
except Exception as exc: # noqa: BLE001
|
|
98
|
+
# An untyped error would reach the JSON-RPC dispatcher, which discards its message and
|
|
99
|
+
# answers a generic "Internal server error". A tool result keeps the text.
|
|
100
|
+
log.exception("%s raised an untyped error", spec.name)
|
|
101
|
+
return types.CallToolResult(
|
|
102
|
+
is_error=True,
|
|
103
|
+
content=[
|
|
104
|
+
types.TextContent(type="text", text=f"TOOL_FAILED: {type(exc).__name__}: {exc}"),
|
|
105
|
+
],
|
|
106
|
+
)
|
|
107
|
+
# Cyrillic is the overwhelming majority of BAS field values — ensure_ascii=False keeps
|
|
108
|
+
# the response readable and avoids bloating the token count with \uXXXX escapes.
|
|
109
|
+
text = json.dumps(result, ensure_ascii=False)
|
|
110
|
+
return types.CallToolResult(content=[types.TextContent(type="text", text=text)])
|
|
111
|
+
|
|
112
|
+
return on_call_tool
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Settings, lifespan, and the `Server` factory for the `mcbp-ai` MCP server.
|
|
2
|
+
|
|
3
|
+
This module is a thin stdio adapter over `mcbp_core` — it holds no knowledge of BAS routes,
|
|
4
|
+
fields, or response shapes. Tool registration itself lives in `registry.py`; this module owns
|
|
5
|
+
the process lifecycle: read env -> build a live `MCBPClient` -> serve stdio -> close the client.
|
|
6
|
+
|
|
7
|
+
Built on the LOW-LEVEL `mcp.server.Server`, not `MCPServer` — `MCPServer.add_tool` derives its
|
|
8
|
+
JSON Schema from a Python function's type hints and has no way to take a ready-made schema, while
|
|
9
|
+
our tool schemas already live in `mcbp_core.tools.ToolSpec.parameters`. See
|
|
10
|
+
`.claude/skills/mcbp-mcp-server/SKILL.md` for the full rationale. The low-level `Server.run()`
|
|
11
|
+
wants ready anyio streams instead of raising stdio itself, so `main()` is async under the hood.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import sys
|
|
19
|
+
from collections.abc import AsyncIterator, Mapping
|
|
20
|
+
from contextlib import asynccontextmanager
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
|
|
23
|
+
import anyio
|
|
24
|
+
from mcbp_core.client import ConnectionConfig, MCBPClient
|
|
25
|
+
from mcp.server import Server
|
|
26
|
+
from mcp.server.stdio import stdio_server
|
|
27
|
+
|
|
28
|
+
from mcbp_mcp_server import __version__
|
|
29
|
+
from mcbp_mcp_server.credentials import ClientPool
|
|
30
|
+
from mcbp_mcp_server.registry import make_on_call_tool, make_on_list_tools
|
|
31
|
+
|
|
32
|
+
log = logging.getLogger("mcbp_mcp_server")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SettingsError(Exception):
|
|
36
|
+
"""Raised by `load_settings()` for a missing or malformed required env var."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Settings:
|
|
41
|
+
base_url: str
|
|
42
|
+
user: str
|
|
43
|
+
password: str
|
|
44
|
+
timeout: float = 30.0
|
|
45
|
+
pool_max: int = 10
|
|
46
|
+
allow_write: bool = False
|
|
47
|
+
verify_ssl: bool = True
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# Strips a trailing '/ai/v1' (any case, optional trailing slash) off ONEC_BASE_URL. Anchored to
|
|
51
|
+
# the END of the string, so a URL that merely CONTAINS 'ai/v1' mid-path is left untouched.
|
|
52
|
+
_AI_V1_SUFFIX_RE = re.compile(r"/ai/v1/?$", re.IGNORECASE)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _normalize_base_url(url: str) -> str:
|
|
56
|
+
"""`MCBPClient` appends `/ai/v1/...` itself inside every method, so `ConnectionConfig.base_url`
|
|
57
|
+
must stop at the service name. Existing docs and `.mcp.json` show the suffixed form (written
|
|
58
|
+
for a from-scratch client that never existed) — accept both: strip the suffix if present.
|
|
59
|
+
A config that still carries it hits /ai/v1/ai/v1/... and every route (except the
|
|
60
|
+
coincidentally-shaped /health) 404s, with no error pointing at the cause."""
|
|
61
|
+
return _AI_V1_SUFFIX_RE.sub("", url.rstrip("/"))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _require(env: Mapping[str, str], name: str) -> str:
|
|
65
|
+
value = env.get(name)
|
|
66
|
+
if not value:
|
|
67
|
+
raise SettingsError(f"{name} is required — set it in the MCP client's env block")
|
|
68
|
+
return value
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _require_present(env: Mapping[str, str], name: str) -> str:
|
|
72
|
+
"""Like `_require`, but accepts an explicit empty string — some BAS publications
|
|
73
|
+
(e.g. basmbdemo) genuinely have no password, and `.mcp.json`'s `${VAR:-}` substitution
|
|
74
|
+
collapses "unset" and "set to empty" into the same wire value anyway, so an empty
|
|
75
|
+
`ONEC_PASSWORD` cannot be told apart from a deliberate one. Only a fully absent key fails."""
|
|
76
|
+
if name not in env:
|
|
77
|
+
raise SettingsError(f"{name} is required — set it in the MCP client's env block")
|
|
78
|
+
return env[name]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _optional_float(env: Mapping[str, str], name: str, default: float) -> float:
|
|
82
|
+
raw = env.get(name)
|
|
83
|
+
if not raw:
|
|
84
|
+
return default
|
|
85
|
+
try:
|
|
86
|
+
return float(raw)
|
|
87
|
+
except ValueError as e:
|
|
88
|
+
raise SettingsError(f"{name} must be a number, got {raw!r}") from e
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _optional_int(env: Mapping[str, str], name: str, default: int) -> int:
|
|
92
|
+
raw = env.get(name)
|
|
93
|
+
if not raw:
|
|
94
|
+
return default
|
|
95
|
+
try:
|
|
96
|
+
return int(raw)
|
|
97
|
+
except ValueError as e:
|
|
98
|
+
raise SettingsError(f"{name} must be an integer, got {raw!r}") from e
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _truthy(raw: str | None) -> bool:
|
|
102
|
+
return (raw or "").strip().lower() in {"1", "true", "yes"}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def load_settings(env: Mapping[str, str] | None = None) -> Settings:
|
|
106
|
+
"""Reads `ONEC_*` / `MCP_ALLOW_WRITE` from `env` (defaults to `os.environ`). Pure: raises
|
|
107
|
+
`SettingsError` on a missing/malformed required var, never prints or exits — `main()` is the
|
|
108
|
+
only place that turns that into a stderr message and a non-zero exit. Safe to call more than
|
|
109
|
+
once (`create_server()` and the lifespan each call it independently)."""
|
|
110
|
+
e = os.environ if env is None else env
|
|
111
|
+
return Settings(
|
|
112
|
+
base_url=_normalize_base_url(_require(e, "ONEC_BASE_URL")),
|
|
113
|
+
user=_require(e, "ONEC_USER"),
|
|
114
|
+
password=_require_present(e, "ONEC_PASSWORD"),
|
|
115
|
+
timeout=_optional_float(e, "ONEC_TIMEOUT", 30.0),
|
|
116
|
+
pool_max=_optional_int(e, "ONEC_POOL_MAX", 10),
|
|
117
|
+
allow_write=_truthy(e.get("MCP_ALLOW_WRITE")),
|
|
118
|
+
verify_ssl=_truthy(e.get("ONEC_VERIFY_SSL", "1")),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass
|
|
123
|
+
class AppContext:
|
|
124
|
+
"""`client` is the process identity from the `ONEC_*` env block — the only identity on stdio,
|
|
125
|
+
and the fallback for an HTTP request that presents no `Authorization` header. `pool` holds the
|
|
126
|
+
per-caller clients built from such a header; it stays empty on stdio, which has no headers at
|
|
127
|
+
all, so nothing there can ever populate it."""
|
|
128
|
+
|
|
129
|
+
client: MCBPClient
|
|
130
|
+
allow_write: bool
|
|
131
|
+
pool: ClientPool | None = None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _probe_health(client: MCBPClient) -> None:
|
|
135
|
+
"""Advisory only — never raises, never blocks startup. A server that refuses to start is
|
|
136
|
+
worse than one that warns."""
|
|
137
|
+
try:
|
|
138
|
+
result = await client.health()
|
|
139
|
+
except Exception as e: # noqa: BLE001 - advisory probe must never block startup
|
|
140
|
+
log.warning(
|
|
141
|
+
"MCBP_AI health probe failed (%s) — every route may be unreachable until this "
|
|
142
|
+
"clears", e,
|
|
143
|
+
)
|
|
144
|
+
return
|
|
145
|
+
if result.get("key") is False:
|
|
146
|
+
log.warning(
|
|
147
|
+
"MCBP_AI health probe returned key:false — the infobase key does not match the "
|
|
148
|
+
"publication; every route except /health will answer 403 KEY_MISMATCH until fixed."
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@asynccontextmanager
|
|
153
|
+
async def lifespan(server: Server[AppContext]) -> AsyncIterator[AppContext]:
|
|
154
|
+
settings = load_settings()
|
|
155
|
+
client = MCBPClient(ConnectionConfig(
|
|
156
|
+
base_url=settings.base_url,
|
|
157
|
+
user=settings.user,
|
|
158
|
+
password=settings.password,
|
|
159
|
+
timeout_s=settings.timeout,
|
|
160
|
+
pool_max=settings.pool_max,
|
|
161
|
+
mock=False,
|
|
162
|
+
verify=settings.verify_ssl,
|
|
163
|
+
))
|
|
164
|
+
await client.startup()
|
|
165
|
+
pool = ClientPool(settings)
|
|
166
|
+
try:
|
|
167
|
+
await _probe_health(client)
|
|
168
|
+
yield AppContext(client=client, allow_write=settings.allow_write, pool=pool)
|
|
169
|
+
finally:
|
|
170
|
+
await pool.aclose()
|
|
171
|
+
await client.shutdown()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def create_server() -> Server[AppContext]:
|
|
175
|
+
"""Constructs the low-level `Server` without touching the network — the client is built
|
|
176
|
+
lazily by `lifespan` only once a client actually connects. Reads only `MCP_ALLOW_WRITE` (via
|
|
177
|
+
the same `_truthy()` helper `load_settings()` uses — one decision, two read sites, never a
|
|
178
|
+
second parsing rule) so the tool SET is decidable without live `ONEC_*` credentials; the
|
|
179
|
+
per-request `AppContext.allow_write` set up by `lifespan` carries the identical value."""
|
|
180
|
+
allow_write = _truthy(os.environ.get("MCP_ALLOW_WRITE"))
|
|
181
|
+
return Server(
|
|
182
|
+
"mcbp-ai",
|
|
183
|
+
version=__version__,
|
|
184
|
+
lifespan=lifespan,
|
|
185
|
+
on_list_tools=make_on_list_tools(allow_write),
|
|
186
|
+
on_call_tool=make_on_call_tool(allow_write),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _serve() -> None:
|
|
191
|
+
server = create_server()
|
|
192
|
+
async with stdio_server() as (read, write):
|
|
193
|
+
await server.run(read, write, server.create_initialization_options())
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def main() -> None:
|
|
197
|
+
logging.basicConfig(
|
|
198
|
+
level=logging.INFO, stream=sys.stderr,
|
|
199
|
+
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
|
200
|
+
)
|
|
201
|
+
try:
|
|
202
|
+
load_settings()
|
|
203
|
+
except SettingsError as e:
|
|
204
|
+
log.error(str(e))
|
|
205
|
+
sys.exit(1)
|
|
206
|
+
anyio.run(_serve)
|