@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.
- package/LICENSE +21 -0
- package/README.md +89 -0
- package/bin/sonenta-mcp.js +142 -0
- package/bin/verbumia-mcp.js +6 -0
- package/package.json +42 -0
- package/python/README.md +109 -0
- package/python/dist/verbumia_mcp-0.16.0-py3-none-any.whl +0 -0
- package/python/pyproject.toml +58 -0
- package/python/verbumia_mcp/__init__.py +3 -0
- package/python/verbumia_mcp/__main__.py +23 -0
- package/python/verbumia_mcp/client.py +85 -0
- package/python/verbumia_mcp/config.py +105 -0
- package/python/verbumia_mcp/server.py +88 -0
- package/python/verbumia_mcp/tools.py +1284 -0
|
@@ -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
|
+
)
|