plurity-mcp 0.1.0__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.
- plurity_mcp/__init__.py +3 -0
- plurity_mcp/client.py +286 -0
- plurity_mcp/config.py +278 -0
- plurity_mcp/server.py +69 -0
- plurity_mcp/setup.py +118 -0
- plurity_mcp/tools/__init__.py +0 -0
- plurity_mcp/tools/audit.py +126 -0
- plurity_mcp/tools/intelligence.py +239 -0
- plurity_mcp/tools/toll.py +329 -0
- plurity_mcp-0.1.0.dist-info/METADATA +162 -0
- plurity_mcp-0.1.0.dist-info/RECORD +14 -0
- plurity_mcp-0.1.0.dist-info/WHEEL +4 -0
- plurity_mcp-0.1.0.dist-info/entry_points.txt +3 -0
- plurity_mcp-0.1.0.dist-info/licenses/LICENSE +21 -0
plurity_mcp/__init__.py
ADDED
plurity_mcp/client.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
"""Synchronous HTTP clients for each Plurity service.
|
|
2
|
+
|
|
3
|
+
All clients share the same auth header (Bearer plt_*) and error model
|
|
4
|
+
(:class:`PlurityAPIError`). Service-specific methods live on each subclass.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
_DEFAULT_TIMEOUT = 30.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class PlurityAPIError(Exception):
|
|
18
|
+
"""Raised when a Plurity service returns an error response."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, status_code: int | None = None) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status_code = status_code
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _BaseClient:
|
|
26
|
+
"""Shared httpx wrapper used by all service clients."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, api_key: str, base_url: str, timeout: float = _DEFAULT_TIMEOUT) -> None:
|
|
29
|
+
self._base_url = base_url.rstrip("/")
|
|
30
|
+
self._client = httpx.Client(
|
|
31
|
+
base_url=self._base_url,
|
|
32
|
+
headers={
|
|
33
|
+
"Authorization": f"Bearer {api_key}",
|
|
34
|
+
"Content-Type": "application/json",
|
|
35
|
+
"Accept": "application/json",
|
|
36
|
+
},
|
|
37
|
+
timeout=timeout,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def _raise_for_status(self, response: httpx.Response) -> None:
|
|
41
|
+
if response.is_success:
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
body = response.json()
|
|
45
|
+
detail = body.get("error") or body.get("message") or body.get("detail", "")
|
|
46
|
+
except Exception:
|
|
47
|
+
detail = response.text or "(no body)"
|
|
48
|
+
raise PlurityAPIError(
|
|
49
|
+
f"API error {response.status_code}: {detail}",
|
|
50
|
+
status_code=response.status_code,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
def _get(self, path: str, **params: Any) -> dict[str, Any]:
|
|
54
|
+
response = self._client.get(
|
|
55
|
+
path, params={k: v for k, v in params.items() if v is not None}
|
|
56
|
+
)
|
|
57
|
+
self._raise_for_status(response)
|
|
58
|
+
return response.json()
|
|
59
|
+
|
|
60
|
+
def _post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
61
|
+
response = self._client.post(path, json=body)
|
|
62
|
+
self._raise_for_status(response)
|
|
63
|
+
return response.json()
|
|
64
|
+
|
|
65
|
+
def _patch(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
66
|
+
response = self._client.patch(path, json=body)
|
|
67
|
+
self._raise_for_status(response)
|
|
68
|
+
return response.json()
|
|
69
|
+
|
|
70
|
+
def _delete(self, path: str, **params: Any) -> dict[str, Any]:
|
|
71
|
+
response = self._client.delete(
|
|
72
|
+
path, params={k: v for k, v in params.items() if v is not None}
|
|
73
|
+
)
|
|
74
|
+
self._raise_for_status(response)
|
|
75
|
+
return response.json()
|
|
76
|
+
|
|
77
|
+
def close(self) -> None:
|
|
78
|
+
self._client.close()
|
|
79
|
+
|
|
80
|
+
def __enter__(self) -> "_BaseClient":
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def __exit__(self, *_: object) -> None:
|
|
84
|
+
self.close()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# Audit client
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class AuditClient(_BaseClient):
|
|
93
|
+
"""Client for the Plurity GEO Audit API."""
|
|
94
|
+
|
|
95
|
+
def submit_scan(self, url: str, webhook_url: str = "") -> dict[str, Any]:
|
|
96
|
+
body: dict[str, Any] = {"url": url}
|
|
97
|
+
if webhook_url:
|
|
98
|
+
body["webhook_url"] = webhook_url
|
|
99
|
+
return self._post("/api/v1/scans", body)
|
|
100
|
+
|
|
101
|
+
def get_scan(self, scan_id: str) -> dict[str, Any]:
|
|
102
|
+
return self._get(f"/api/v1/scans/{scan_id}")
|
|
103
|
+
|
|
104
|
+
def get_scan_by_url(self, url: str) -> dict[str, Any]:
|
|
105
|
+
return self._get("/api/v1/scans", url=url)
|
|
106
|
+
|
|
107
|
+
def wait_for_scan(
|
|
108
|
+
self,
|
|
109
|
+
scan_id: str,
|
|
110
|
+
timeout_seconds: int = 300,
|
|
111
|
+
poll_interval: float = 5.0,
|
|
112
|
+
) -> dict[str, Any]:
|
|
113
|
+
_TERMINAL = {"complete", "failed"}
|
|
114
|
+
deadline = time.monotonic() + timeout_seconds
|
|
115
|
+
last: dict[str, Any] = {}
|
|
116
|
+
while time.monotonic() < deadline:
|
|
117
|
+
last = self.get_scan(scan_id)
|
|
118
|
+
if last.get("status") in _TERMINAL:
|
|
119
|
+
return last
|
|
120
|
+
remaining = deadline - time.monotonic()
|
|
121
|
+
time.sleep(min(poll_interval, max(remaining, 0)))
|
|
122
|
+
return last
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# Toll client
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class TollClient(_BaseClient):
|
|
131
|
+
"""Client for the Plurity Toll API (agent traffic + llms.txt management)."""
|
|
132
|
+
|
|
133
|
+
# Sites
|
|
134
|
+
def list_sites(self) -> dict[str, Any]:
|
|
135
|
+
return self._get("/api/v1/sites")
|
|
136
|
+
|
|
137
|
+
def create_site(self, name: str, domain: str) -> dict[str, Any]:
|
|
138
|
+
return self._post("/api/v1/sites", {"name": name, "domain": domain})
|
|
139
|
+
|
|
140
|
+
def get_site(self, site_id: str) -> dict[str, Any]:
|
|
141
|
+
return self._get(f"/api/v1/sites/{site_id}")
|
|
142
|
+
|
|
143
|
+
def update_site(
|
|
144
|
+
self,
|
|
145
|
+
site_id: str,
|
|
146
|
+
name: Optional[str] = None,
|
|
147
|
+
domain: Optional[str] = None,
|
|
148
|
+
cache_ttl_secs: Optional[int] = None,
|
|
149
|
+
llms_txt_mode: Optional[str] = None,
|
|
150
|
+
) -> dict[str, Any]:
|
|
151
|
+
body: dict[str, Any] = {}
|
|
152
|
+
if name is not None:
|
|
153
|
+
body["name"] = name
|
|
154
|
+
if domain is not None:
|
|
155
|
+
body["domain"] = domain
|
|
156
|
+
if cache_ttl_secs is not None:
|
|
157
|
+
body["cache_ttl_secs"] = cache_ttl_secs
|
|
158
|
+
if llms_txt_mode is not None:
|
|
159
|
+
body["llms_txt_mode"] = llms_txt_mode
|
|
160
|
+
return self._patch(f"/api/v1/sites/{site_id}", body)
|
|
161
|
+
|
|
162
|
+
# Q&A pairs
|
|
163
|
+
def list_qa_pairs(self, site_id: str) -> dict[str, Any]:
|
|
164
|
+
return self._get(f"/api/v1/sites/{site_id}/qa-pairs")
|
|
165
|
+
|
|
166
|
+
def create_qa_pair(
|
|
167
|
+
self,
|
|
168
|
+
site_id: str,
|
|
169
|
+
question: str,
|
|
170
|
+
answer_url: str,
|
|
171
|
+
answer_summary: Optional[str] = None,
|
|
172
|
+
) -> dict[str, Any]:
|
|
173
|
+
body: dict[str, Any] = {"question": question, "answer_url": answer_url}
|
|
174
|
+
if answer_summary is not None:
|
|
175
|
+
body["answer_summary"] = answer_summary
|
|
176
|
+
return self._post(f"/api/v1/sites/{site_id}/qa-pairs", body)
|
|
177
|
+
|
|
178
|
+
def update_qa_pair(
|
|
179
|
+
self,
|
|
180
|
+
site_id: str,
|
|
181
|
+
pair_id: str,
|
|
182
|
+
question: Optional[str] = None,
|
|
183
|
+
answer_url: Optional[str] = None,
|
|
184
|
+
answer_summary: Optional[str] = None,
|
|
185
|
+
is_published: Optional[bool] = None,
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
body: dict[str, Any] = {}
|
|
188
|
+
if question is not None:
|
|
189
|
+
body["question"] = question
|
|
190
|
+
if answer_url is not None:
|
|
191
|
+
body["answer_url"] = answer_url
|
|
192
|
+
if answer_summary is not None:
|
|
193
|
+
body["answer_summary"] = answer_summary
|
|
194
|
+
if is_published is not None:
|
|
195
|
+
body["is_published"] = is_published
|
|
196
|
+
return self._patch(f"/api/v1/sites/{site_id}/qa-pairs/{pair_id}", body)
|
|
197
|
+
|
|
198
|
+
def delete_qa_pair(self, site_id: str, pair_id: str) -> dict[str, Any]:
|
|
199
|
+
return self._delete(f"/api/v1/sites/{site_id}/qa-pairs/{pair_id}")
|
|
200
|
+
|
|
201
|
+
# Traffic
|
|
202
|
+
def get_traffic(
|
|
203
|
+
self,
|
|
204
|
+
site_id: str,
|
|
205
|
+
period: str = "week",
|
|
206
|
+
agents: Optional[str] = None,
|
|
207
|
+
) -> dict[str, Any]:
|
|
208
|
+
return self._get(
|
|
209
|
+
f"/api/v1/sites/{site_id}/events/chart",
|
|
210
|
+
period=period,
|
|
211
|
+
agents=agents,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# ---------------------------------------------------------------------------
|
|
216
|
+
# Intelligence client
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class IntelligenceClient(_BaseClient):
|
|
221
|
+
"""Client for the Plurity Intelligence API (question + topic monitoring)."""
|
|
222
|
+
|
|
223
|
+
# Sources
|
|
224
|
+
def list_sources(
|
|
225
|
+
self,
|
|
226
|
+
type: Optional[str] = None,
|
|
227
|
+
q: Optional[str] = None,
|
|
228
|
+
) -> dict[str, Any]:
|
|
229
|
+
return self._get("/api/v1/sources", type=type, q=q)
|
|
230
|
+
|
|
231
|
+
def list_subscriptions(self) -> dict[str, Any]:
|
|
232
|
+
return self._get("/api/v1/subscriptions")
|
|
233
|
+
|
|
234
|
+
def subscribe_source(
|
|
235
|
+
self,
|
|
236
|
+
source_id: str,
|
|
237
|
+
start_date: Optional[str] = None,
|
|
238
|
+
) -> dict[str, Any]:
|
|
239
|
+
body: dict[str, Any] = {}
|
|
240
|
+
if start_date is not None:
|
|
241
|
+
body["start_date"] = start_date
|
|
242
|
+
return self._post(f"/api/v1/sources/{source_id}/subscribe", body)
|
|
243
|
+
|
|
244
|
+
def unsubscribe_source(self, source_id: str) -> dict[str, Any]:
|
|
245
|
+
return self._delete(f"/api/v1/sources/{source_id}/subscribe")
|
|
246
|
+
|
|
247
|
+
def request_source(self, url: str) -> dict[str, Any]:
|
|
248
|
+
return self._post("/api/v1/sources/request", {"url": url})
|
|
249
|
+
|
|
250
|
+
# Source content (documents)
|
|
251
|
+
def list_source_content(
|
|
252
|
+
self,
|
|
253
|
+
source_id: Optional[str] = None,
|
|
254
|
+
date_from: Optional[str] = None,
|
|
255
|
+
date_to: Optional[str] = None,
|
|
256
|
+
content_type: Optional[str] = None,
|
|
257
|
+
) -> dict[str, Any]:
|
|
258
|
+
return self._get(
|
|
259
|
+
"/api/v1/source-content",
|
|
260
|
+
source_id=source_id,
|
|
261
|
+
date_from=date_from,
|
|
262
|
+
date_to=date_to,
|
|
263
|
+
content_type=content_type,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Q&A feed
|
|
267
|
+
def list_qa_pairs(
|
|
268
|
+
self,
|
|
269
|
+
source_id: Optional[str] = None,
|
|
270
|
+
date_from: Optional[str] = None,
|
|
271
|
+
date_to: Optional[str] = None,
|
|
272
|
+
status: Optional[str] = None,
|
|
273
|
+
) -> dict[str, Any]:
|
|
274
|
+
return self._get(
|
|
275
|
+
"/api/v1/feed",
|
|
276
|
+
source_id=source_id,
|
|
277
|
+
date_from=date_from,
|
|
278
|
+
date_to=date_to,
|
|
279
|
+
status=status,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
def approve_qa_pair(self, qa_pair_id: str) -> dict[str, Any]:
|
|
283
|
+
return self._post(f"/api/v1/feed/{qa_pair_id}/approve", {})
|
|
284
|
+
|
|
285
|
+
def skip_qa_pair(self, qa_pair_id: str) -> dict[str, Any]:
|
|
286
|
+
return self._post(f"/api/v1/feed/{qa_pair_id}/skip", {})
|
plurity_mcp/config.py
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Configuration loading and API key validation for plurity-mcp.
|
|
2
|
+
|
|
3
|
+
Resolution order (highest to lowest priority):
|
|
4
|
+
1. Environment variables
|
|
5
|
+
2. ~/.config/plurity/config.toml [mcp] section
|
|
6
|
+
3. Built-in defaults
|
|
7
|
+
|
|
8
|
+
After loading, the key is validated against the accounts API and the
|
|
9
|
+
enabled service set is intersected with the key's scopes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import tomllib
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
_CONFIG_PATH = Path.home() / ".config" / "plurity" / "config.toml"
|
|
24
|
+
|
|
25
|
+
_DEFAULT_ACCOUNTS_URL = "https://account.plurity.ai"
|
|
26
|
+
_DEFAULT_AUDIT_URL = "https://audit.plurity.ai"
|
|
27
|
+
_DEFAULT_TOLL_URL = "https://toll.plurity.ai"
|
|
28
|
+
_DEFAULT_INTELLIGENCE_URL = "https://intelligence.plurity.ai"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Scope helpers (mirrors the TypeScript hasScope in @plurity/auth)
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def has_scope(key_scopes: list[str], required: str) -> bool:
|
|
37
|
+
"""Return True if *key_scopes* grants *required*.
|
|
38
|
+
|
|
39
|
+
Supports wildcards:
|
|
40
|
+
["*"] -> full access
|
|
41
|
+
["audit:*"] -> all audit scopes
|
|
42
|
+
["audit:read"] -> exact match only
|
|
43
|
+
["audit"] -> exact namespace match (convention: bare name = full access to service)
|
|
44
|
+
"""
|
|
45
|
+
for scope in key_scopes:
|
|
46
|
+
if scope == "*":
|
|
47
|
+
return True
|
|
48
|
+
if scope.endswith(":*"):
|
|
49
|
+
ns = scope[:-2]
|
|
50
|
+
if required == ns or required.startswith(ns + ":"):
|
|
51
|
+
return True
|
|
52
|
+
if scope == required:
|
|
53
|
+
return True
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Data classes
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class ServiceConfig:
|
|
64
|
+
"""Resolved configuration for one Plurity service."""
|
|
65
|
+
|
|
66
|
+
enabled: bool
|
|
67
|
+
"""Whether this service's tools are registered."""
|
|
68
|
+
|
|
69
|
+
base_url: str
|
|
70
|
+
"""Base URL for the service REST API."""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True, slots=True)
|
|
74
|
+
class PlurityMCPConfig:
|
|
75
|
+
"""Fully resolved configuration for plurity-mcp."""
|
|
76
|
+
|
|
77
|
+
api_key: str
|
|
78
|
+
accounts_url: str
|
|
79
|
+
org_id: str
|
|
80
|
+
scopes: list[str]
|
|
81
|
+
audit: ServiceConfig
|
|
82
|
+
toll: ServiceConfig
|
|
83
|
+
intelligence: ServiceConfig
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
# TOML helpers
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _load_toml() -> dict:
|
|
92
|
+
if not _CONFIG_PATH.exists():
|
|
93
|
+
return {}
|
|
94
|
+
with _CONFIG_PATH.open("rb") as fh:
|
|
95
|
+
return tomllib.load(fh)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _bool_env(name: str, default: bool) -> bool:
|
|
99
|
+
val = os.environ.get(name, "").strip().lower()
|
|
100
|
+
if val in ("0", "false", "no", "off"):
|
|
101
|
+
return False
|
|
102
|
+
if val in ("1", "true", "yes", "on"):
|
|
103
|
+
return True
|
|
104
|
+
return default
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# ---------------------------------------------------------------------------
|
|
108
|
+
# Key validation
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _validate_key(api_key: str, accounts_url: str) -> tuple[str, str, list[str]]:
|
|
113
|
+
"""Validate *api_key* against the accounts API.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
(org_id, key_id, scopes) on success.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
RuntimeError with a descriptive message on failure.
|
|
120
|
+
"""
|
|
121
|
+
url = f"{accounts_url.rstrip('/')}/api/v1/validate-key"
|
|
122
|
+
try:
|
|
123
|
+
response = httpx.post(
|
|
124
|
+
url,
|
|
125
|
+
json={"key": api_key},
|
|
126
|
+
timeout=10.0,
|
|
127
|
+
headers={"Content-Type": "application/json"},
|
|
128
|
+
)
|
|
129
|
+
except httpx.ConnectError:
|
|
130
|
+
raise RuntimeError(
|
|
131
|
+
f"Could not connect to the Plurity accounts API at {accounts_url}. "
|
|
132
|
+
"Check your network connection or PLURITY_ACCOUNTS_URL."
|
|
133
|
+
)
|
|
134
|
+
except httpx.TimeoutException:
|
|
135
|
+
raise RuntimeError(
|
|
136
|
+
f"Timed out connecting to the Plurity accounts API at {accounts_url}."
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if response.status_code == 401:
|
|
140
|
+
raise RuntimeError(
|
|
141
|
+
"Invalid API key. Check your PLURITY_API_KEY or run 'plurity-mcp-setup' "
|
|
142
|
+
"to save a new key."
|
|
143
|
+
)
|
|
144
|
+
if response.status_code == 403:
|
|
145
|
+
body = response.json()
|
|
146
|
+
raise RuntimeError(f"API key rejected: {body.get('error', 'Forbidden')}")
|
|
147
|
+
if not response.is_success:
|
|
148
|
+
raise RuntimeError(
|
|
149
|
+
f"Accounts API returned HTTP {response.status_code} when validating key."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
body = response.json()
|
|
153
|
+
return body["org_id"], body["key_id"], body.get("scopes", [])
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Main resolver
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_config() -> PlurityMCPConfig:
|
|
162
|
+
"""Load, validate, and return the resolved :class:`PlurityMCPConfig`.
|
|
163
|
+
|
|
164
|
+
Validates the API key against the accounts service and intersects the
|
|
165
|
+
key's scopes with any user-level enable/disable overrides.
|
|
166
|
+
|
|
167
|
+
Raises:
|
|
168
|
+
RuntimeError if no API key is found or the key is invalid.
|
|
169
|
+
"""
|
|
170
|
+
toml = _load_toml()
|
|
171
|
+
mcp_section: dict = toml.get("mcp", {})
|
|
172
|
+
|
|
173
|
+
# --- API key ---
|
|
174
|
+
api_key = (
|
|
175
|
+
os.environ.get("PLURITY_API_KEY", "").strip()
|
|
176
|
+
or mcp_section.get("api_key", "").strip()
|
|
177
|
+
)
|
|
178
|
+
if not api_key:
|
|
179
|
+
raise RuntimeError(
|
|
180
|
+
"No Plurity API key found. "
|
|
181
|
+
"Set the PLURITY_API_KEY environment variable or run 'plurity-mcp-setup'."
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# --- Accounts URL ---
|
|
185
|
+
accounts_url = (
|
|
186
|
+
os.environ.get("PLURITY_ACCOUNTS_URL", "").strip()
|
|
187
|
+
or mcp_section.get("accounts_url", "").strip()
|
|
188
|
+
or _DEFAULT_ACCOUNTS_URL
|
|
189
|
+
).rstrip("/")
|
|
190
|
+
|
|
191
|
+
# --- Validate key ---
|
|
192
|
+
org_id, key_id, scopes = _validate_key(api_key, accounts_url)
|
|
193
|
+
|
|
194
|
+
# --- Service base URLs ---
|
|
195
|
+
audit_url = (
|
|
196
|
+
os.environ.get("PLURITY_AUDIT_URL", "").strip()
|
|
197
|
+
or mcp_section.get("audit_base_url", "").strip()
|
|
198
|
+
or _DEFAULT_AUDIT_URL
|
|
199
|
+
).rstrip("/")
|
|
200
|
+
|
|
201
|
+
toll_url = (
|
|
202
|
+
os.environ.get("PLURITY_TOLL_URL", "").strip()
|
|
203
|
+
or mcp_section.get("toll_base_url", "").strip()
|
|
204
|
+
or _DEFAULT_TOLL_URL
|
|
205
|
+
).rstrip("/")
|
|
206
|
+
|
|
207
|
+
intelligence_url = (
|
|
208
|
+
os.environ.get("PLURITY_INTELLIGENCE_URL", "").strip()
|
|
209
|
+
or mcp_section.get("intelligence_base_url", "").strip()
|
|
210
|
+
or _DEFAULT_INTELLIGENCE_URL
|
|
211
|
+
).rstrip("/")
|
|
212
|
+
|
|
213
|
+
# --- Service enablement ---
|
|
214
|
+
# Scope check: does the key have access to each service?
|
|
215
|
+
audit_scope_ok = has_scope(scopes, "audit")
|
|
216
|
+
toll_scope_ok = has_scope(scopes, "toll")
|
|
217
|
+
intelligence_scope_ok = has_scope(scopes, "intelligence")
|
|
218
|
+
|
|
219
|
+
# User overrides (env vars or TOML); can only disable, not grant beyond scope
|
|
220
|
+
audit_user_enabled = _bool_env("PLURITY_AUDIT_ENABLED", mcp_section.get("audit_enabled", True))
|
|
221
|
+
toll_user_enabled = _bool_env("PLURITY_TOLL_ENABLED", mcp_section.get("toll_enabled", True))
|
|
222
|
+
intelligence_user_enabled = _bool_env(
|
|
223
|
+
"PLURITY_INTELLIGENCE_ENABLED", mcp_section.get("intelligence_enabled", True)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return PlurityMCPConfig(
|
|
227
|
+
api_key=api_key,
|
|
228
|
+
accounts_url=accounts_url,
|
|
229
|
+
org_id=org_id,
|
|
230
|
+
scopes=scopes,
|
|
231
|
+
audit=ServiceConfig(
|
|
232
|
+
enabled=audit_scope_ok and audit_user_enabled,
|
|
233
|
+
base_url=audit_url,
|
|
234
|
+
),
|
|
235
|
+
toll=ServiceConfig(
|
|
236
|
+
enabled=toll_scope_ok and toll_user_enabled,
|
|
237
|
+
base_url=toll_url,
|
|
238
|
+
),
|
|
239
|
+
intelligence=ServiceConfig(
|
|
240
|
+
enabled=intelligence_scope_ok and intelligence_user_enabled,
|
|
241
|
+
base_url=intelligence_url,
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def save_config(
|
|
247
|
+
api_key: str,
|
|
248
|
+
accounts_url: str = _DEFAULT_ACCOUNTS_URL,
|
|
249
|
+
audit_base_url: str = _DEFAULT_AUDIT_URL,
|
|
250
|
+
toll_base_url: str = _DEFAULT_TOLL_URL,
|
|
251
|
+
intelligence_base_url: str = _DEFAULT_INTELLIGENCE_URL,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Persist config to ``~/.config/plurity/config.toml`` under the ``[mcp]`` section."""
|
|
254
|
+
_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
255
|
+
|
|
256
|
+
existing: dict = {}
|
|
257
|
+
if _CONFIG_PATH.exists():
|
|
258
|
+
with _CONFIG_PATH.open("rb") as fh:
|
|
259
|
+
existing = tomllib.load(fh)
|
|
260
|
+
|
|
261
|
+
existing.setdefault("mcp", {})
|
|
262
|
+
existing["mcp"]["api_key"] = api_key
|
|
263
|
+
existing["mcp"]["accounts_url"] = accounts_url
|
|
264
|
+
existing["mcp"]["audit_base_url"] = audit_base_url
|
|
265
|
+
existing["mcp"]["toll_base_url"] = toll_base_url
|
|
266
|
+
existing["mcp"]["intelligence_base_url"] = intelligence_base_url
|
|
267
|
+
|
|
268
|
+
lines: list[str] = []
|
|
269
|
+
for section, values in existing.items():
|
|
270
|
+
lines.append(f"[{section}]")
|
|
271
|
+
for k, v in values.items():
|
|
272
|
+
if isinstance(v, bool):
|
|
273
|
+
lines.append(f"{k} = {'true' if v else 'false'}")
|
|
274
|
+
else:
|
|
275
|
+
lines.append(f'{k} = "{v}"')
|
|
276
|
+
lines.append("")
|
|
277
|
+
|
|
278
|
+
_CONFIG_PATH.write_text("\n".join(lines), encoding="utf-8")
|
plurity_mcp/server.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Unified Plurity MCP server.
|
|
2
|
+
|
|
3
|
+
Validates the API key against plurity-accounts on startup, then registers
|
|
4
|
+
only the tools for services the key has access to (and that are not
|
|
5
|
+
explicitly disabled in config).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from mcp.server.fastmcp import FastMCP
|
|
13
|
+
|
|
14
|
+
mcp = FastMCP("Plurity")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main() -> None:
|
|
18
|
+
"""Validate config and start the MCP server."""
|
|
19
|
+
from .config import get_config
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
config = get_config()
|
|
23
|
+
except RuntimeError as exc:
|
|
24
|
+
# Print a clear message to stderr so the MCP client (e.g. Claude
|
|
25
|
+
# Desktop) can surface it to the user.
|
|
26
|
+
print(f"[plurity-mcp] Configuration error: {exc}", file=sys.stderr, flush=True)
|
|
27
|
+
sys.exit(1)
|
|
28
|
+
|
|
29
|
+
enabled: list[str] = []
|
|
30
|
+
disabled_by_scope: list[str] = []
|
|
31
|
+
|
|
32
|
+
if config.audit.enabled:
|
|
33
|
+
from .tools.audit import register_audit_tools
|
|
34
|
+
register_audit_tools(mcp, config)
|
|
35
|
+
enabled.append("audit")
|
|
36
|
+
else:
|
|
37
|
+
disabled_by_scope.append("audit")
|
|
38
|
+
|
|
39
|
+
if config.toll.enabled:
|
|
40
|
+
from .tools.toll import register_toll_tools
|
|
41
|
+
register_toll_tools(mcp, config)
|
|
42
|
+
enabled.append("toll")
|
|
43
|
+
else:
|
|
44
|
+
disabled_by_scope.append("toll")
|
|
45
|
+
|
|
46
|
+
if config.intelligence.enabled:
|
|
47
|
+
from .tools.intelligence import register_intelligence_tools
|
|
48
|
+
register_intelligence_tools(mcp, config)
|
|
49
|
+
enabled.append("intelligence")
|
|
50
|
+
else:
|
|
51
|
+
disabled_by_scope.append("intelligence")
|
|
52
|
+
|
|
53
|
+
if not enabled:
|
|
54
|
+
print(
|
|
55
|
+
"[plurity-mcp] No services are enabled. "
|
|
56
|
+
"Your key's scopes may not grant access to any service, "
|
|
57
|
+
"or all services were manually disabled.\n"
|
|
58
|
+
f"Key scopes: {config.scopes}",
|
|
59
|
+
file=sys.stderr,
|
|
60
|
+
flush=True,
|
|
61
|
+
)
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
|
|
64
|
+
summary = f"[plurity-mcp] Starting — services: {', '.join(enabled)}"
|
|
65
|
+
if disabled_by_scope:
|
|
66
|
+
summary += f" | disabled: {', '.join(disabled_by_scope)}"
|
|
67
|
+
print(summary, file=sys.stderr, flush=True)
|
|
68
|
+
|
|
69
|
+
mcp.run()
|