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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ mcbp = mcbp_mcp_server.server:main
3
+ mcbp-http = mcbp_mcp_server.http:main
@@ -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,5 @@
1
+ from __future__ import annotations
2
+
3
+ from mcbp_mcp_server.server import main
4
+
5
+ main()
@@ -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
+ )
@@ -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)