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.
@@ -0,0 +1,3 @@
1
+ """Plurity unified MCP server."""
2
+
3
+ __version__ = "0.1.0"
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()