@sonenta/mcp 0.34.1 → 0.35.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/README.md +9 -74
- package/bin/sonenta-mcp.js +20047 -182
- package/bin/sonenta-mcp.js.map +1 -0
- package/package.json +34 -14
- package/python/README.md +0 -114
- package/python/dist/sonenta_mcp-0.34.1-py3-none-any.whl +0 -0
- package/python/pyproject.toml +0 -59
- package/python/verbumia_mcp/__init__.py +0 -3
- package/python/verbumia_mcp/__main__.py +0 -23
- package/python/verbumia_mcp/client.py +0 -85
- package/python/verbumia_mcp/config.py +0 -157
- package/python/verbumia_mcp/server.py +0 -88
- package/python/verbumia_mcp/tools.py +0 -3300
package/package.json
CHANGED
|
@@ -1,40 +1,60 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sonenta/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "MCP server for Sonenta translation management
|
|
3
|
+
"version": "0.35.0",
|
|
4
|
+
"description": "MCP server for Sonenta translation management — pure TypeScript, no Python. Wires Claude Desktop and other MCP clients into your project's keys, translations, accessibility, and source-health tools.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://sonenta.com",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/
|
|
10
|
-
"directory": "mcp
|
|
9
|
+
"url": "https://github.com/verbumia/verbumia-tools.git",
|
|
10
|
+
"directory": "mcp-ts"
|
|
11
11
|
},
|
|
12
12
|
"bugs": {
|
|
13
|
-
"url": "https://github.com/
|
|
13
|
+
"url": "https://github.com/verbumia/verbumia-tools/issues"
|
|
14
14
|
},
|
|
15
15
|
"keywords": [
|
|
16
|
-
"mcp",
|
|
17
16
|
"sonenta",
|
|
17
|
+
"verbumia",
|
|
18
|
+
"mcp",
|
|
18
19
|
"i18n",
|
|
19
20
|
"translations",
|
|
20
|
-
"claude"
|
|
21
|
-
"anthropic"
|
|
21
|
+
"claude"
|
|
22
22
|
],
|
|
23
23
|
"author": "Sonenta",
|
|
24
|
-
"
|
|
25
|
-
"node": ">=18.0.0"
|
|
26
|
-
},
|
|
24
|
+
"type": "module",
|
|
27
25
|
"bin": {
|
|
28
26
|
"sonenta-mcp": "bin/sonenta-mcp.js"
|
|
29
27
|
},
|
|
30
28
|
"files": [
|
|
31
29
|
"bin/",
|
|
32
|
-
"
|
|
30
|
+
"dist/",
|
|
33
31
|
"README.md",
|
|
34
32
|
"LICENSE"
|
|
35
33
|
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18.0.0"
|
|
36
|
+
},
|
|
36
37
|
"scripts": {
|
|
37
|
-
"
|
|
38
|
-
"
|
|
38
|
+
"build": "tsup",
|
|
39
|
+
"test": "vitest run",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"dev": "tsx src/index.ts",
|
|
42
|
+
"prepublishOnly": "npm run typecheck && npm run test && npm run build",
|
|
43
|
+
"gen:schemas": "node scripts/gen-schemas.mjs"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
47
|
+
"lossless-json": "^4.3.0"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@types/node": "^20.0.0",
|
|
51
|
+
"tsup": "^8.3.0",
|
|
52
|
+
"tsx": "^4.19.0",
|
|
53
|
+
"typescript": "^5.5.0",
|
|
54
|
+
"vitest": "^2.1.0"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public",
|
|
58
|
+
"registry": "https://registry.npmjs.org/"
|
|
39
59
|
}
|
|
40
60
|
}
|
package/python/README.md
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
# Sonenta MCP server
|
|
2
|
-
|
|
3
|
-
Model Context Protocol server for [Sonenta](https://sonenta.com) — exposes
|
|
4
|
-
your translation project to Claude Desktop and other MCP clients.
|
|
5
|
-
|
|
6
|
-
## Status
|
|
7
|
-
|
|
8
|
-
Thirteen tools wired: `list_projects`, `get_project_info`, `list_keys`,
|
|
9
|
-
`list_untranslated_keys`, `list_missing_keys`, `missing_keys_stats`,
|
|
10
|
-
`acknowledge_missing_keys`, `create_key`, `propose_translation`,
|
|
11
|
-
`publish_cdn`, `validate_translations`, `project_context_get`,
|
|
12
|
-
`project_context_set`.
|
|
13
|
-
|
|
14
|
-
### Project context document (V1.2)
|
|
15
|
-
|
|
16
|
-
`project_context_get` / `project_context_set` read and write a free-form
|
|
17
|
-
markdown blob attached to the project — terminology, brand voice, domain
|
|
18
|
-
notes (religious, legal, medical, gaming, etc.). The content is intended
|
|
19
|
-
as ambient context for human translators **and** for AI agents producing
|
|
20
|
-
translations: prepend it to your translation prompts so every output stays
|
|
21
|
-
consistent with the project's vocabulary and tone. Hard cap 100 KiB.
|
|
22
|
-
|
|
23
|
-
Scopes:
|
|
24
|
-
- `project_context_get` requires `project:read` (existing scope).
|
|
25
|
-
- `project_context_set` requires `project:settings:write` (new scope, narrower
|
|
26
|
-
than `project:write` since settings changes propagate to every translator's
|
|
27
|
-
prompt context).
|
|
28
|
-
|
|
29
|
-
Note: the blanket `mcp:*` scope is **not** sufficient for these tools — grant
|
|
30
|
-
the precise scope on the key.
|
|
31
|
-
|
|
32
|
-
## Install
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
# Recommended (zero-install, used by Claude Desktop's mcp.json):
|
|
36
|
-
npx -y @sonenta/mcp
|
|
37
|
-
|
|
38
|
-
# Or from PyPI:
|
|
39
|
-
pipx install sonenta-mcp
|
|
40
|
-
```
|
|
41
|
-
|
|
42
|
-
A Homebrew tap (`brew install sonenta/tap/sonenta-mcp`) is coming once we
|
|
43
|
-
publish to npm.
|
|
44
|
-
|
|
45
|
-
## Configure (Claude Desktop)
|
|
46
|
-
|
|
47
|
-
Open **Settings → Developer → Edit Config**, then:
|
|
48
|
-
|
|
49
|
-
Single-project setup:
|
|
50
|
-
|
|
51
|
-
```json
|
|
52
|
-
{
|
|
53
|
-
"mcpServers": {
|
|
54
|
-
"sonenta": {
|
|
55
|
-
"command": "npx",
|
|
56
|
-
"args": ["-y", "@sonenta/mcp"],
|
|
57
|
-
"env": {
|
|
58
|
-
"SONENTA_API_KEY": "vrb_live_<prefix>.<secret>",
|
|
59
|
-
"SONENTA_PROJECTS": "<project_uuid>",
|
|
60
|
-
"SONENTA_BASE_URL": "https://api.sonenta.dev"
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
Multi-project setup (v0.11+) — comma-separate the UUIDs and Claude will pass
|
|
68
|
-
`project_uuid` on each tool call:
|
|
69
|
-
|
|
70
|
-
```json
|
|
71
|
-
"env": {
|
|
72
|
-
"SONENTA_API_KEY": "vrb_live_<prefix>.<secret>",
|
|
73
|
-
"SONENTA_PROJECTS": "01993a..,01993b..,01993c.."
|
|
74
|
-
}
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
Restart Claude Desktop. Tools show up in the prompt UI.
|
|
78
|
-
|
|
79
|
-
## Environment
|
|
80
|
-
|
|
81
|
-
| Variable | Required | Default | Description |
|
|
82
|
-
|----------------------|----------|--------------------------------|--------------------------------------------------------------------------------------------|
|
|
83
|
-
| `SONENTA_API_KEY` | yes* | | API key from Org Settings → API Keys (`SONENTA_TOKEN` also accepted). *Optional if you ran `sonenta login` — the server then reads `~/.sonenta/credentials`. |
|
|
84
|
-
| `SONENTA_PROJECTS` | no | (LLM passes per call) | CSV of project UUIDs. When >1, the LLM MUST pass `project_uuid` on each tool call. |
|
|
85
|
-
| `SONENTA_PROJECT` | no | (legacy) | Singular fallback for v0.10.x users. Ignored when `SONENTA_PROJECTS` is set (warns). |
|
|
86
|
-
| `SONENTA_BASE_URL` | no | `https://api.sonenta.dev` | Override for self-host or staging (`SONENTA_API_BASE` also accepted, back-compat) |
|
|
87
|
-
|
|
88
|
-
> Migrating from `verbumia-mcp`? The legacy `VERBUMIA_API_KEY` / `VERBUMIA_TOKEN`
|
|
89
|
-
> / `VERBUMIA_PROJECTS` / `VERBUMIA_BASE_URL` env vars are still accepted, so
|
|
90
|
-
> existing MCP client configs keep working — switch to the `SONENTA_*` names
|
|
91
|
-
> when convenient.
|
|
92
|
-
|
|
93
|
-
The token format is `vrb_live_<prefix>.<secret>` and must carry the `mcp:*`
|
|
94
|
-
scope. For the `project_context_*` tools, also grant `project:read` and/or
|
|
95
|
-
`project:settings:write` — see the **Project context document** section
|
|
96
|
-
above. Treat the token like any other secret — keychain or `.env`, never
|
|
97
|
-
commit.
|
|
98
|
-
|
|
99
|
-
If your API key is pinned to a single project (its `project_uuid` is set
|
|
100
|
-
server-side), listing additional UUIDs in `SONENTA_PROJECTS` will surface
|
|
101
|
-
as a `403 API key is scoped to a different project` from the backend on
|
|
102
|
-
any call targeting one of the others.
|
|
103
|
-
|
|
104
|
-
## Local development
|
|
105
|
-
|
|
106
|
-
```bash
|
|
107
|
-
cd mcp
|
|
108
|
-
uv sync
|
|
109
|
-
uv run sonenta-mcp # stdio transport — pipe MCP frames over stdin/stdout
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
## License
|
|
113
|
-
|
|
114
|
-
MIT.
|
|
Binary file
|
package/python/pyproject.toml
DELETED
|
@@ -1,59 +0,0 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "sonenta-mcp"
|
|
3
|
-
version = "0.34.1"
|
|
4
|
-
description = "Model Context Protocol server for Sonenta — list projects, missing keys, propose translations from Claude Desktop and other MCP clients."
|
|
5
|
-
readme = "README.md"
|
|
6
|
-
requires-python = ">=3.11,<3.14"
|
|
7
|
-
license = { text = "MIT" }
|
|
8
|
-
authors = [{ name = "Sonenta" }]
|
|
9
|
-
keywords = ["mcp", "sonenta", "i18n", "translations", "claude"]
|
|
10
|
-
classifiers = [
|
|
11
|
-
"License :: OSI Approved :: MIT License",
|
|
12
|
-
"Programming Language :: Python :: 3.11",
|
|
13
|
-
"Programming Language :: Python :: 3.12",
|
|
14
|
-
"Topic :: Software Development :: Internationalization",
|
|
15
|
-
]
|
|
16
|
-
dependencies = [
|
|
17
|
-
"mcp>=1.0",
|
|
18
|
-
"httpx>=0.28",
|
|
19
|
-
"pydantic>=2.9",
|
|
20
|
-
]
|
|
21
|
-
|
|
22
|
-
[project.scripts]
|
|
23
|
-
sonenta-mcp = "verbumia_mcp.__main__:main"
|
|
24
|
-
|
|
25
|
-
[project.urls]
|
|
26
|
-
Homepage = "https://sonenta.com"
|
|
27
|
-
Documentation = "https://sonenta.com/docs/mcp/setup"
|
|
28
|
-
Source = "https://github.com/sonenta/tools"
|
|
29
|
-
Issues = "https://github.com/sonenta/tools/issues"
|
|
30
|
-
|
|
31
|
-
[dependency-groups]
|
|
32
|
-
dev = [
|
|
33
|
-
"pytest>=8.3",
|
|
34
|
-
"pytest-asyncio>=0.24",
|
|
35
|
-
"ruff>=0.8",
|
|
36
|
-
]
|
|
37
|
-
|
|
38
|
-
[tool.uv]
|
|
39
|
-
package = true
|
|
40
|
-
|
|
41
|
-
[tool.ruff]
|
|
42
|
-
line-length = 100
|
|
43
|
-
target-version = "py312"
|
|
44
|
-
|
|
45
|
-
[tool.ruff.lint]
|
|
46
|
-
select = ["E", "F", "W", "I", "B", "UP", "SIM", "ASYNC", "RUF"]
|
|
47
|
-
ignore = ["E501"]
|
|
48
|
-
|
|
49
|
-
[build-system]
|
|
50
|
-
requires = ["hatchling"]
|
|
51
|
-
build-backend = "hatchling.build"
|
|
52
|
-
|
|
53
|
-
[tool.hatch.build.targets.wheel]
|
|
54
|
-
packages = ["verbumia_mcp"]
|
|
55
|
-
|
|
56
|
-
[tool.pytest.ini_options]
|
|
57
|
-
asyncio_mode = "auto"
|
|
58
|
-
testpaths = ["tests"]
|
|
59
|
-
addopts = "-q --strict-markers"
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
"""Entrypoint for `sonenta-mcp` (and `python -m verbumia_mcp`)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import asyncio
|
|
6
|
-
import sys
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def main() -> None:
|
|
10
|
-
from .server import run
|
|
11
|
-
|
|
12
|
-
try:
|
|
13
|
-
asyncio.run(run())
|
|
14
|
-
except KeyboardInterrupt:
|
|
15
|
-
sys.exit(130)
|
|
16
|
-
except RuntimeError as exc:
|
|
17
|
-
# Configuration errors (missing token, etc.) — print a friendly hint.
|
|
18
|
-
print(f"sonenta-mcp: {exc}", file=sys.stderr)
|
|
19
|
-
sys.exit(2)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
if __name__ == "__main__":
|
|
23
|
-
main()
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
"""Async HTTP client wrapping the Sonenta REST API.
|
|
2
|
-
|
|
3
|
-
The MCP tools are thin adapters over this client — they translate MCP
|
|
4
|
-
arguments to API calls and shape the response. Authentication is the
|
|
5
|
-
`Authorization: ApiKey vrb_live_<prefix>.<secret>` header read from
|
|
6
|
-
`SONENTA_API_KEY` (or `SONENTA_TOKEN` for back-compat).
|
|
7
|
-
"""
|
|
8
|
-
|
|
9
|
-
from __future__ import annotations
|
|
10
|
-
|
|
11
|
-
from typing import Any
|
|
12
|
-
|
|
13
|
-
import httpx
|
|
14
|
-
|
|
15
|
-
from .config import Config
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
class VerbumiaApiError(Exception):
|
|
19
|
-
"""Raised when the API returns a non-2xx response."""
|
|
20
|
-
|
|
21
|
-
def __init__(self, status: int, body: str | dict[str, Any]) -> None:
|
|
22
|
-
self.status = status
|
|
23
|
-
self.body = body
|
|
24
|
-
super().__init__(f"Sonenta API error {status}: {body}")
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
class VerbumiaClient:
|
|
28
|
-
"""Tiny async REST client. Reuse across tool calls — open once at startup."""
|
|
29
|
-
|
|
30
|
-
def __init__(
|
|
31
|
-
self, config: Config, *, transport: httpx.AsyncBaseTransport | None = None
|
|
32
|
-
) -> None:
|
|
33
|
-
self._config = config
|
|
34
|
-
self._http = httpx.AsyncClient(
|
|
35
|
-
base_url=config.api_base,
|
|
36
|
-
headers={
|
|
37
|
-
"Authorization": f"ApiKey {config.token}",
|
|
38
|
-
"User-Agent": config.user_agent,
|
|
39
|
-
"Accept": "application/json",
|
|
40
|
-
},
|
|
41
|
-
timeout=httpx.Timeout(15.0, connect=5.0),
|
|
42
|
-
transport=transport,
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
async def aclose(self) -> None:
|
|
46
|
-
await self._http.aclose()
|
|
47
|
-
|
|
48
|
-
@property
|
|
49
|
-
def config(self) -> Config:
|
|
50
|
-
return self._config
|
|
51
|
-
|
|
52
|
-
async def get(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
53
|
-
r = await self._http.get(path, params=params)
|
|
54
|
-
return _unpack(r)
|
|
55
|
-
|
|
56
|
-
async def post(self, path: str, json: dict[str, Any]) -> Any:
|
|
57
|
-
r = await self._http.post(path, json=json)
|
|
58
|
-
return _unpack(r)
|
|
59
|
-
|
|
60
|
-
async def put(self, path: str, json: dict[str, Any]) -> Any:
|
|
61
|
-
r = await self._http.put(path, json=json)
|
|
62
|
-
return _unpack(r)
|
|
63
|
-
|
|
64
|
-
async def patch(self, path: str, json: dict[str, Any]) -> Any:
|
|
65
|
-
r = await self._http.patch(path, json=json)
|
|
66
|
-
return _unpack(r)
|
|
67
|
-
|
|
68
|
-
async def delete(self, path: str) -> Any:
|
|
69
|
-
r = await self._http.delete(path)
|
|
70
|
-
return _unpack(r)
|
|
71
|
-
|
|
72
|
-
async def health(self) -> dict[str, Any]:
|
|
73
|
-
return await self.get("/health")
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
def _unpack(r: httpx.Response) -> Any:
|
|
77
|
-
if r.status_code >= 400:
|
|
78
|
-
try:
|
|
79
|
-
body: str | dict[str, Any] = r.json()
|
|
80
|
-
except ValueError:
|
|
81
|
-
body = r.text
|
|
82
|
-
raise VerbumiaApiError(r.status_code, body)
|
|
83
|
-
if r.status_code == 204 or not r.content:
|
|
84
|
-
return None
|
|
85
|
-
return r.json()
|
|
@@ -1,157 +0,0 @@
|
|
|
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. Env vars are the primary contract.
|
|
5
|
-
|
|
6
|
-
Canonical env vars are SONENTA_*. The legacy VERBUMIA_* names are still
|
|
7
|
-
accepted (deprecate-not-remove) so existing MCP client configs keep working;
|
|
8
|
-
the SONENTA_* name wins when both are set.
|
|
9
|
-
|
|
10
|
-
One exception to "env only": when no API key is in the env, we fall back to
|
|
11
|
-
the CLI credentials store (`~/.sonenta/credentials`, written by `sonenta
|
|
12
|
-
login`). That lets the auto-wired `.mcp.json` carry NO secret — just the host
|
|
13
|
-
and project — and the server resolves the key from the developer's login at
|
|
14
|
-
startup. CI / headless still pass SONENTA_API_KEY explicitly.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
from __future__ import annotations
|
|
18
|
-
|
|
19
|
-
import json
|
|
20
|
-
import os
|
|
21
|
-
import sys
|
|
22
|
-
from dataclasses import dataclass
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
|
|
25
|
-
DEFAULT_API_BASE = "https://api.sonenta.dev"
|
|
26
|
-
|
|
27
|
-
# CLI credentials store (written by `sonenta login`). Canonical ~/.sonenta,
|
|
28
|
-
# legacy ~/.verbumia read as a fallback. Shape:
|
|
29
|
-
# {"default": "<host>", "hosts": {"<host>": {"api_key": "...", ...}}}
|
|
30
|
-
_CREDENTIALS_PATHS = (
|
|
31
|
-
Path.home() / ".sonenta" / "credentials",
|
|
32
|
-
Path.home() / ".verbumia" / "credentials",
|
|
33
|
-
)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _credentials_token(api_base: str) -> str:
|
|
37
|
-
"""Resolve an API key from the CLI credentials store for `api_base`.
|
|
38
|
-
|
|
39
|
-
Lets the MCP server work with NO secret in its env / `.mcp.json` when the
|
|
40
|
-
user has logged in via `sonenta login`: we read the same creds file the CLI
|
|
41
|
-
writes and pick the key for the configured host (falling back to the file's
|
|
42
|
-
`default` host, then a sole entry). Returns "" when nothing resolves.
|
|
43
|
-
"""
|
|
44
|
-
target = api_base.rstrip("/")
|
|
45
|
-
for path in _CREDENTIALS_PATHS:
|
|
46
|
-
try:
|
|
47
|
-
data = json.loads(path.read_text(encoding="utf-8"))
|
|
48
|
-
except (OSError, ValueError):
|
|
49
|
-
continue
|
|
50
|
-
hosts = data.get("hosts") if isinstance(data, dict) else None
|
|
51
|
-
if not isinstance(hosts, dict):
|
|
52
|
-
continue
|
|
53
|
-
for key in (target, api_base, data.get("default")):
|
|
54
|
-
entry = hosts.get(key) if isinstance(key, str) else None
|
|
55
|
-
if isinstance(entry, dict):
|
|
56
|
-
tok = str(entry.get("api_key", "")).strip()
|
|
57
|
-
if tok:
|
|
58
|
-
return tok
|
|
59
|
-
# A single-account creds file: use it regardless of the host key.
|
|
60
|
-
if len(hosts) == 1:
|
|
61
|
-
entry = next(iter(hosts.values()))
|
|
62
|
-
if isinstance(entry, dict):
|
|
63
|
-
tok = str(entry.get("api_key", "")).strip()
|
|
64
|
-
if tok:
|
|
65
|
-
return tok
|
|
66
|
-
return ""
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
@dataclass(frozen=True)
|
|
70
|
-
class Config:
|
|
71
|
-
api_base: str
|
|
72
|
-
token: str
|
|
73
|
-
# Tuple of project UUIDs the operator scoped this MCP server to. Empty
|
|
74
|
-
# means "unrestricted — fall back to whatever the API key can see".
|
|
75
|
-
allowed_project_uuids: tuple[str, ...]
|
|
76
|
-
user_agent: str
|
|
77
|
-
|
|
78
|
-
@property
|
|
79
|
-
def default_project_uuid(self) -> str | None:
|
|
80
|
-
"""The single project to use when none is passed explicitly.
|
|
81
|
-
|
|
82
|
-
Only defined when exactly one project is configured — otherwise the
|
|
83
|
-
caller MUST pass `project_uuid` per call (the resolver in tools.py
|
|
84
|
-
enforces this).
|
|
85
|
-
"""
|
|
86
|
-
return self.allowed_project_uuids[0] if len(self.allowed_project_uuids) == 1 else None
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def _parse_projects_csv(raw: str) -> tuple[str, ...]:
|
|
90
|
-
"""Split, strip, and de-duplicate a CSV of project UUIDs while preserving order."""
|
|
91
|
-
seen: list[str] = []
|
|
92
|
-
for token in raw.split(","):
|
|
93
|
-
v = token.strip()
|
|
94
|
-
if v and v not in seen:
|
|
95
|
-
seen.append(v)
|
|
96
|
-
return tuple(seen)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _env(*names: str) -> str:
|
|
100
|
-
"""First non-empty env var among `names` (canonical SONENTA_* first,
|
|
101
|
-
legacy VERBUMIA_* fallback)."""
|
|
102
|
-
for n in names:
|
|
103
|
-
v = os.environ.get(n, "").strip()
|
|
104
|
-
if v:
|
|
105
|
-
return v
|
|
106
|
-
return ""
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def load_config() -> Config:
|
|
110
|
-
# API base FIRST — the creds fallback below keys the API key on this host.
|
|
111
|
-
# Canonical: SONENTA_BASE_URL. Fallbacks: SONENTA_API_BASE, then legacy
|
|
112
|
-
# VERBUMIA_BASE_URL / VERBUMIA_API_BASE, then the default host.
|
|
113
|
-
api_base = (
|
|
114
|
-
_env("SONENTA_BASE_URL", "SONENTA_API_BASE", "VERBUMIA_BASE_URL", "VERBUMIA_API_BASE")
|
|
115
|
-
or DEFAULT_API_BASE
|
|
116
|
-
).rstrip("/")
|
|
117
|
-
|
|
118
|
-
# Auth key. Canonical: SONENTA_API_KEY. Env fallbacks (in order): SONENTA_TOKEN,
|
|
119
|
-
# then legacy VERBUMIA_API_KEY / VERBUMIA_TOKEN. When NONE are set, fall back to
|
|
120
|
-
# the CLI credentials store (`sonenta login`) so .mcp.json needs no secret.
|
|
121
|
-
token = _env("SONENTA_API_KEY", "SONENTA_TOKEN", "VERBUMIA_API_KEY", "VERBUMIA_TOKEN")
|
|
122
|
-
if not token:
|
|
123
|
-
token = _credentials_token(api_base)
|
|
124
|
-
if not token:
|
|
125
|
-
raise RuntimeError(
|
|
126
|
-
"No Sonenta API key. Either run `sonenta login` (the server then reads "
|
|
127
|
-
"your key from ~/.sonenta/credentials) or set SONENTA_API_KEY in your MCP "
|
|
128
|
-
"client config (e.g. Claude Desktop's mcp.json `env`). SONENTA_TOKEN — and "
|
|
129
|
-
"the legacy VERBUMIA_API_KEY / VERBUMIA_TOKEN — are also accepted."
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
# Multi-project scoping. Canonical: SONENTA_PROJECTS (CSV) / SONENTA_PROJECT
|
|
133
|
-
# (singular). Legacy VERBUMIA_PROJECTS / VERBUMIA_PROJECT still accepted.
|
|
134
|
-
projects_csv = _env("SONENTA_PROJECTS", "VERBUMIA_PROJECTS")
|
|
135
|
-
project_single = _env("SONENTA_PROJECT", "VERBUMIA_PROJECT")
|
|
136
|
-
if projects_csv and project_single:
|
|
137
|
-
print(
|
|
138
|
-
"sonenta-mcp: both *_PROJECTS and *_PROJECT are set; the CSV "
|
|
139
|
-
"(*_PROJECTS) wins. Remove the singular var to silence this warning.",
|
|
140
|
-
file=sys.stderr,
|
|
141
|
-
)
|
|
142
|
-
if projects_csv:
|
|
143
|
-
allowed = _parse_projects_csv(projects_csv)
|
|
144
|
-
elif project_single:
|
|
145
|
-
allowed = (project_single,)
|
|
146
|
-
else:
|
|
147
|
-
allowed = ()
|
|
148
|
-
|
|
149
|
-
from . import __version__
|
|
150
|
-
|
|
151
|
-
user_agent = f"sonenta-mcp/{__version__}"
|
|
152
|
-
return Config(
|
|
153
|
-
api_base=api_base,
|
|
154
|
-
token=token,
|
|
155
|
-
allowed_project_uuids=allowed,
|
|
156
|
-
user_agent=user_agent,
|
|
157
|
-
)
|
|
@@ -1,88 +0,0 @@
|
|
|
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"sonenta-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 Sonenta → "
|
|
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
|
-
"Sonenta → Org Settings → API Keys."
|
|
60
|
-
) from exc
|
|
61
|
-
raise RuntimeError(
|
|
62
|
-
f"Sonenta 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"sonenta-mcp {__version__} starting (api={config.api_base})")
|
|
74
|
-
server: Server = Server("sonenta")
|
|
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="sonenta",
|
|
85
|
-
server_version=__version__,
|
|
86
|
-
capabilities=ServerCapabilities(tools=ToolsCapability(listChanged=False)),
|
|
87
|
-
),
|
|
88
|
-
)
|