@sonenta/mcp 0.16.0

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,105 @@
1
+ """Runtime configuration loaded from environment variables.
2
+
3
+ The MCP server is invoked by clients like Claude Desktop, which pass `env`
4
+ entries through to the spawned process. We never read a config file — env
5
+ vars are the only contract.
6
+
7
+ Canonical env vars are SONENTA_*. The legacy VERBUMIA_* names are still
8
+ accepted (deprecate-not-remove) so existing MCP client configs keep working;
9
+ the SONENTA_* name wins when both are set.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+ import sys
16
+ from dataclasses import dataclass
17
+
18
+ DEFAULT_API_BASE = "https://api.sonenta.com"
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Config:
23
+ api_base: str
24
+ token: str
25
+ # Tuple of project UUIDs the operator scoped this MCP server to. Empty
26
+ # means "unrestricted — fall back to whatever the API key can see".
27
+ allowed_project_uuids: tuple[str, ...]
28
+ user_agent: str
29
+
30
+ @property
31
+ def default_project_uuid(self) -> str | None:
32
+ """The single project to use when none is passed explicitly.
33
+
34
+ Only defined when exactly one project is configured — otherwise the
35
+ caller MUST pass `project_uuid` per call (the resolver in tools.py
36
+ enforces this).
37
+ """
38
+ return self.allowed_project_uuids[0] if len(self.allowed_project_uuids) == 1 else None
39
+
40
+
41
+ def _parse_projects_csv(raw: str) -> tuple[str, ...]:
42
+ """Split, strip, and de-duplicate a CSV of project UUIDs while preserving order."""
43
+ seen: list[str] = []
44
+ for token in raw.split(","):
45
+ v = token.strip()
46
+ if v and v not in seen:
47
+ seen.append(v)
48
+ return tuple(seen)
49
+
50
+
51
+ def _env(*names: str) -> str:
52
+ """First non-empty env var among `names` (canonical SONENTA_* first,
53
+ legacy VERBUMIA_* fallback)."""
54
+ for n in names:
55
+ v = os.environ.get(n, "").strip()
56
+ if v:
57
+ return v
58
+ return ""
59
+
60
+
61
+ def load_config() -> Config:
62
+ # Auth key. Canonical: SONENTA_API_KEY. Accepted fallbacks (in order):
63
+ # SONENTA_TOKEN, then the legacy VERBUMIA_API_KEY / VERBUMIA_TOKEN.
64
+ token = _env("SONENTA_API_KEY", "SONENTA_TOKEN", "VERBUMIA_API_KEY", "VERBUMIA_TOKEN")
65
+ if not token:
66
+ raise RuntimeError(
67
+ "SONENTA_API_KEY is required. Generate one in Sonenta → Org Settings → "
68
+ "API Keys and add it to your MCP client config (e.g. Claude Desktop's "
69
+ "mcp.json `env`). SONENTA_TOKEN — and the legacy VERBUMIA_API_KEY / "
70
+ "VERBUMIA_TOKEN — are also accepted for back-compat."
71
+ )
72
+
73
+ # API base. Canonical: SONENTA_BASE_URL. Fallbacks: SONENTA_API_BASE, then
74
+ # legacy VERBUMIA_BASE_URL / VERBUMIA_API_BASE, then the default host.
75
+ api_base = (
76
+ _env("SONENTA_BASE_URL", "SONENTA_API_BASE", "VERBUMIA_BASE_URL", "VERBUMIA_API_BASE")
77
+ or DEFAULT_API_BASE
78
+ ).rstrip("/")
79
+
80
+ # Multi-project scoping. Canonical: SONENTA_PROJECTS (CSV) / SONENTA_PROJECT
81
+ # (singular). Legacy VERBUMIA_PROJECTS / VERBUMIA_PROJECT still accepted.
82
+ projects_csv = _env("SONENTA_PROJECTS", "VERBUMIA_PROJECTS")
83
+ project_single = _env("SONENTA_PROJECT", "VERBUMIA_PROJECT")
84
+ if projects_csv and project_single:
85
+ print(
86
+ "sonenta-mcp: both *_PROJECTS and *_PROJECT are set; the CSV "
87
+ "(*_PROJECTS) wins. Remove the singular var to silence this warning.",
88
+ file=sys.stderr,
89
+ )
90
+ if projects_csv:
91
+ allowed = _parse_projects_csv(projects_csv)
92
+ elif project_single:
93
+ allowed = (project_single,)
94
+ else:
95
+ allowed = ()
96
+
97
+ from . import __version__
98
+
99
+ user_agent = f"sonenta-mcp/{__version__}"
100
+ return Config(
101
+ api_base=api_base,
102
+ token=token,
103
+ allowed_project_uuids=allowed,
104
+ user_agent=user_agent,
105
+ )
@@ -0,0 +1,88 @@
1
+ """MCP stdio server bootstrapping.
2
+
3
+ `run()` is the async entrypoint: load config, open the HTTP client, register
4
+ tools, then hand control to mcp's stdio transport. The MCP protocol uses
5
+ JSON-RPC over stdin/stdout, so we never print to stdout for logging — all
6
+ diagnostics go to stderr.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import sys
12
+ from collections.abc import AsyncIterator
13
+ from contextlib import asynccontextmanager
14
+
15
+ from mcp.server import Server
16
+ from mcp.server.models import InitializationOptions
17
+ from mcp.server.stdio import stdio_server
18
+ from mcp.types import ServerCapabilities, ToolsCapability
19
+
20
+ from . import __version__
21
+ from .client import VerbumiaApiError, VerbumiaClient
22
+ from .config import Config, load_config
23
+ from .tools import register
24
+
25
+
26
+ def _info(msg: str) -> None:
27
+ """Stderr-only logging — stdout is reserved for the MCP framing."""
28
+ print(msg, file=sys.stderr, flush=True)
29
+
30
+
31
+ @asynccontextmanager
32
+ async def _client_for(config: Config) -> AsyncIterator[VerbumiaClient]:
33
+ client = VerbumiaClient(config)
34
+ try:
35
+ yield client
36
+ finally:
37
+ await client.aclose()
38
+
39
+
40
+ async def _preflight(client: VerbumiaClient) -> None:
41
+ """Verify reachability + auth before starting the JSON-RPC loop.
42
+
43
+ Hits `/v1/mcp/projects?limit=1` so we exercise both the API key (auth)
44
+ and the mcp:* scope. Failures are turned into stderr-friendly messages
45
+ so the MCP host (Claude Desktop / etc.) surfaces a real cause.
46
+ """
47
+ try:
48
+ await client.get("/v1/mcp/projects", params={"limit": 1})
49
+ _info(f"verbumia-mcp: connected to {client.config.api_base}")
50
+ except VerbumiaApiError as exc:
51
+ if exc.status == 401:
52
+ raise RuntimeError(
53
+ "SONENTA_API_KEY was rejected (401). Verify the key in Verbumia → "
54
+ "Org Settings → API Keys; new keys are shown once on creation."
55
+ ) from exc
56
+ if exc.status == 403:
57
+ raise RuntimeError(
58
+ "API key lacks the `mcp:*` scope (403). Issue a key with mcp:* in "
59
+ "Verbumia → Org Settings → API Keys."
60
+ ) from exc
61
+ raise RuntimeError(
62
+ f"Verbumia API rejected the connectivity probe ({exc.status}): {exc.body}"
63
+ ) from exc
64
+ except Exception as exc:
65
+ raise RuntimeError(
66
+ f"Could not reach {client.config.api_base}: {exc}. "
67
+ f"Set SONENTA_BASE_URL (or legacy VERBUMIA_*) for self-host or staging."
68
+ ) from exc
69
+
70
+
71
+ async def run() -> None:
72
+ config = load_config()
73
+ _info(f"verbumia-mcp {__version__} starting (api={config.api_base})")
74
+ server: Server = Server("verbumia")
75
+
76
+ async with _client_for(config) as client:
77
+ await _preflight(client)
78
+ register(server, client)
79
+ async with stdio_server() as (read_stream, write_stream):
80
+ await server.run(
81
+ read_stream,
82
+ write_stream,
83
+ InitializationOptions(
84
+ server_name="verbumia",
85
+ server_version=__version__,
86
+ capabilities=ServerCapabilities(tools=ToolsCapability(listChanged=False)),
87
+ ),
88
+ )