pcp-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.
pcp_mcp/__init__.py ADDED
@@ -0,0 +1,59 @@
1
+ """PCP MCP Server - Performance Co-Pilot metrics via Model Context Protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+
8
+
9
+ def main() -> None:
10
+ """Run the PCP MCP server."""
11
+ parser = argparse.ArgumentParser(
12
+ description="PCP MCP Server - Performance Co-Pilot Metrics",
13
+ formatter_class=argparse.RawDescriptionHelpFormatter,
14
+ epilog="""
15
+ Environment Variables:
16
+ PCP_HOST pmproxy host (default: localhost)
17
+ PCP_PORT pmproxy port (default: 44322)
18
+ PCP_TARGET_HOST Target pmcd host to monitor (default: localhost)
19
+ PCP_USE_TLS Use HTTPS for pmproxy connection (default: false)
20
+ PCP_TIMEOUT Request timeout in seconds (default: 30)
21
+ PCP_USERNAME HTTP basic auth user (optional)
22
+ PCP_PASSWORD HTTP basic auth password (optional)
23
+
24
+ Examples:
25
+ # Monitor localhost (default)
26
+ pcp-mcp
27
+
28
+ # Monitor a remote host
29
+ PCP_TARGET_HOST=webserver1.example.com pcp-mcp
30
+
31
+ # Connect to pmproxy on a different host
32
+ PCP_HOST=metrics.example.com pcp-mcp
33
+
34
+ # Use SSE transport
35
+ pcp-mcp --transport sse
36
+ """,
37
+ )
38
+ parser.add_argument(
39
+ "--target-host",
40
+ help="Target pmcd host to monitor (overrides PCP_TARGET_HOST)",
41
+ )
42
+ parser.add_argument(
43
+ "--transport",
44
+ choices=["stdio", "sse", "streamable-http"],
45
+ default="stdio",
46
+ help="Transport protocol (default: stdio)",
47
+ )
48
+ args = parser.parse_args()
49
+
50
+ if args.target_host:
51
+ os.environ["PCP_TARGET_HOST"] = args.target_host
52
+
53
+ from pcp_mcp.server import create_server
54
+
55
+ server = create_server()
56
+ server.run(transport=args.transport)
57
+
58
+
59
+ __all__ = ["main"]
pcp_mcp/client.py ADDED
@@ -0,0 +1,246 @@
1
+ """Async client for pmproxy REST API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+
8
+ if sys.version_info >= (3, 11):
9
+ from typing import Self
10
+ else:
11
+ from typing_extensions import Self
12
+
13
+ import httpx
14
+
15
+
16
+ class PCPClient:
17
+ """Async client for pmproxy REST API.
18
+
19
+ Handles PMAPI context management and metric fetching via the pmproxy
20
+ REST API endpoints.
21
+
22
+ Args:
23
+ base_url: Base URL for pmproxy (e.g., http://localhost:44322).
24
+ target_host: Which pmcd host to connect to (passed as hostspec).
25
+ auth: Optional HTTP basic auth tuple (username, password).
26
+ timeout: Request timeout in seconds.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ base_url: str,
32
+ target_host: str = "localhost",
33
+ auth: tuple[str, str] | None = None,
34
+ timeout: float = 30.0,
35
+ ) -> None:
36
+ """Initialize the PCP client."""
37
+ self._base_url = base_url
38
+ self._target_host = target_host
39
+ self._auth = auth
40
+ self._timeout = timeout
41
+ self._client: httpx.AsyncClient | None = None
42
+ self._context_id: int | None = None
43
+
44
+ async def __aenter__(self) -> Self:
45
+ """Enter async context and establish pmapi context."""
46
+ self._client = httpx.AsyncClient(
47
+ base_url=self._base_url,
48
+ auth=self._auth,
49
+ timeout=self._timeout,
50
+ )
51
+ resp = await self._client.get(
52
+ "/pmapi/context",
53
+ params={"hostspec": self._target_host},
54
+ )
55
+ resp.raise_for_status()
56
+ self._context_id = resp.json()["context"]
57
+ return self
58
+
59
+ async def __aexit__(
60
+ self,
61
+ exc_type: type[BaseException] | None,
62
+ exc_val: BaseException | None,
63
+ exc_tb: object,
64
+ ) -> None:
65
+ """Exit async context and close httpx client."""
66
+ if self._client:
67
+ await self._client.aclose()
68
+ self._client = None
69
+
70
+ @property
71
+ def target_host(self) -> str:
72
+ """The pmcd host this client is connected to."""
73
+ return self._target_host
74
+
75
+ @property
76
+ def context_id(self) -> int | None:
77
+ """The pmapi context ID, or None if not connected."""
78
+ return self._context_id
79
+
80
+ async def _recreate_context(self) -> None:
81
+ """Recreate the pmapi context after expiration."""
82
+ if self._client is None:
83
+ raise RuntimeError("Client not connected. Use async with context.")
84
+ resp = await self._client.get(
85
+ "/pmapi/context",
86
+ params={"hostspec": self._target_host},
87
+ )
88
+ resp.raise_for_status()
89
+ self._context_id = resp.json()["context"]
90
+
91
+ async def _request_with_retry(self, method: str, **kwargs) -> httpx.Response:
92
+ """Make a request, recreating context on expiration errors.
93
+
94
+ Args:
95
+ method: HTTP method to call.
96
+ **kwargs: Arguments to pass to the request.
97
+
98
+ Returns:
99
+ The HTTP response.
100
+
101
+ Raises:
102
+ RuntimeError: If client is not connected.
103
+ httpx.HTTPStatusError: If the request fails after retry.
104
+ """
105
+ if self._client is None:
106
+ raise RuntimeError("Client not connected. Use async with context.")
107
+
108
+ resp = await self._client.request(method, **kwargs)
109
+
110
+ if resp.status_code == 400:
111
+ data = resp.json()
112
+ if "unknown context identifier" in data.get("message", ""):
113
+ await self._recreate_context()
114
+ kwargs["params"]["context"] = self._context_id
115
+ resp = await self._client.request(method, **kwargs)
116
+
117
+ return resp
118
+
119
+ async def fetch(self, metric_names: list[str]) -> dict:
120
+ """Fetch current values for metrics.
121
+
122
+ Args:
123
+ metric_names: List of PCP metric names to fetch.
124
+
125
+ Returns:
126
+ Raw JSON response from pmproxy /pmapi/fetch endpoint.
127
+
128
+ Raises:
129
+ RuntimeError: If client is not connected.
130
+ httpx.HTTPStatusError: If the request fails.
131
+ """
132
+ resp = await self._request_with_retry(
133
+ "GET",
134
+ url="/pmapi/fetch",
135
+ params={"context": self._context_id, "names": ",".join(metric_names)},
136
+ )
137
+ resp.raise_for_status()
138
+ return resp.json()
139
+
140
+ async def search(self, pattern: str) -> list[dict]:
141
+ """Search for metrics matching pattern.
142
+
143
+ Args:
144
+ pattern: Metric name prefix to search for (e.g., "kernel.all").
145
+
146
+ Returns:
147
+ List of metric metadata dicts from pmproxy.
148
+
149
+ Raises:
150
+ RuntimeError: If client is not connected.
151
+ httpx.HTTPStatusError: If the request fails.
152
+ """
153
+ resp = await self._request_with_retry(
154
+ "GET",
155
+ url="/pmapi/metric",
156
+ params={"context": self._context_id, "prefix": pattern},
157
+ )
158
+ resp.raise_for_status()
159
+ return resp.json().get("metrics", [])
160
+
161
+ async def describe(self, metric_name: str) -> dict:
162
+ """Get metric metadata.
163
+
164
+ Args:
165
+ metric_name: Full PCP metric name.
166
+
167
+ Returns:
168
+ Metric metadata dict, or empty dict if not found.
169
+
170
+ Raises:
171
+ RuntimeError: If client is not connected.
172
+ httpx.HTTPStatusError: If the request fails.
173
+ """
174
+ resp = await self._request_with_retry(
175
+ "GET",
176
+ url="/pmapi/metric",
177
+ params={"context": self._context_id, "names": metric_name},
178
+ )
179
+ resp.raise_for_status()
180
+ metrics = resp.json().get("metrics", [])
181
+ return metrics[0] if metrics else {}
182
+
183
+ async def fetch_with_rates(
184
+ self,
185
+ metric_names: list[str],
186
+ counter_metrics: set[str],
187
+ sample_interval: float = 1.0,
188
+ ) -> dict[str, dict]:
189
+ """Fetch metrics, calculating rates for counters.
190
+
191
+ Takes two samples separated by sample_interval seconds.
192
+ Counter metrics are converted to per-second rates.
193
+ Gauge metrics return the second sample's value.
194
+
195
+ Args:
196
+ metric_names: List of PCP metric names to fetch.
197
+ counter_metrics: Set of metric names that are counters.
198
+ sample_interval: Seconds between samples for rate calculation.
199
+
200
+ Returns:
201
+ Dict mapping metric name to {value, instances} where value/instances
202
+ contain the rate (for counters) or instant value (for gauges).
203
+ """
204
+ t1 = await self.fetch(metric_names)
205
+ await asyncio.sleep(sample_interval)
206
+ t2 = await self.fetch(metric_names)
207
+
208
+ ts1 = t1.get("timestamp", 0.0)
209
+ ts2 = t2.get("timestamp", 0.0)
210
+ if isinstance(ts1, dict):
211
+ ts1 = ts1.get("s", 0) + ts1.get("us", 0) / 1e6
212
+ if isinstance(ts2, dict):
213
+ ts2 = ts2.get("s", 0) + ts2.get("us", 0) / 1e6
214
+ elapsed = ts2 - ts1 if ts2 > ts1 else sample_interval
215
+
216
+ results: dict[str, dict] = {}
217
+
218
+ values_t1 = {v.get("name"): v for v in t1.get("values", [])}
219
+ values_t2 = {v.get("name"): v for v in t2.get("values", [])}
220
+
221
+ for metric_name in metric_names:
222
+ v1_data = values_t1.get(metric_name, {})
223
+ v2_data = values_t2.get(metric_name, {})
224
+
225
+ instances_t1 = {
226
+ inst.get("instance", -1): inst.get("value", 0)
227
+ for inst in v1_data.get("instances", [])
228
+ }
229
+ instances_t2 = {
230
+ inst.get("instance", -1): inst.get("value", 0)
231
+ for inst in v2_data.get("instances", [])
232
+ }
233
+
234
+ if metric_name in counter_metrics:
235
+ computed: dict[str | int, float] = {}
236
+ for inst_id, val2 in instances_t2.items():
237
+ val1 = instances_t1.get(inst_id, val2)
238
+ delta = val2 - val1
239
+ if delta < 0:
240
+ delta = val2
241
+ computed[inst_id] = delta / elapsed
242
+ results[metric_name] = {"instances": computed, "is_rate": True}
243
+ else:
244
+ results[metric_name] = {"instances": instances_t2, "is_rate": False}
245
+
246
+ return results
pcp_mcp/config.py ADDED
@@ -0,0 +1,50 @@
1
+ """Configuration for the PCP MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import Field
6
+ from pydantic_settings import BaseSettings, SettingsConfigDict
7
+
8
+
9
+ class PCPMCPSettings(BaseSettings):
10
+ """Configuration for the PCP MCP server.
11
+
12
+ Attributes:
13
+ host: pmproxy host.
14
+ port: pmproxy port.
15
+ use_tls: Use HTTPS for pmproxy connection.
16
+ timeout: Request timeout in seconds.
17
+ target_host: Target pmcd host to monitor (can be remote hostname).
18
+ username: HTTP basic auth user.
19
+ password: HTTP basic auth password.
20
+ """
21
+
22
+ model_config = SettingsConfigDict(
23
+ env_file=".env",
24
+ env_prefix="PCP_",
25
+ extra="ignore",
26
+ )
27
+
28
+ host: str = Field(default="localhost", description="pmproxy host")
29
+ port: int = Field(default=44322, description="pmproxy port")
30
+ use_tls: bool = Field(default=False, description="Use HTTPS for pmproxy connection")
31
+ timeout: float = Field(default=30.0, description="Request timeout in seconds")
32
+ target_host: str = Field(
33
+ default="localhost",
34
+ description="Target pmcd host to monitor (can be remote hostname)",
35
+ )
36
+ username: str | None = Field(default=None, description="HTTP basic auth user")
37
+ password: str | None = Field(default=None, description="HTTP basic auth password")
38
+
39
+ @property
40
+ def base_url(self) -> str:
41
+ """URL for connecting to pmproxy."""
42
+ scheme = "https" if self.use_tls else "http"
43
+ return f"{scheme}://{self.host}:{self.port}"
44
+
45
+ @property
46
+ def auth(self) -> tuple[str, str] | None:
47
+ """Auth tuple for httpx, or None if no auth configured."""
48
+ if self.username and self.password:
49
+ return (self.username, self.password)
50
+ return None
pcp_mcp/context.py ADDED
@@ -0,0 +1,57 @@
1
+ """Context helpers for safe lifespan context access."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ from fastmcp import Context
8
+ from fastmcp.exceptions import ToolError
9
+
10
+ if TYPE_CHECKING:
11
+ from pcp_mcp.client import PCPClient
12
+ from pcp_mcp.config import PCPMCPSettings
13
+
14
+
15
+ def _validate_context(ctx: Context) -> None:
16
+ """Validate context has lifespan_context available.
17
+
18
+ Args:
19
+ ctx: MCP context.
20
+
21
+ Raises:
22
+ ToolError: If context is not available.
23
+ """
24
+ if ctx.request_context is None or ctx.request_context.lifespan_context is None:
25
+ raise ToolError("Server context not available")
26
+
27
+
28
+ def get_client(ctx: Context) -> PCPClient:
29
+ """Get PCPClient from context.
30
+
31
+ Args:
32
+ ctx: MCP context.
33
+
34
+ Returns:
35
+ The PCPClient instance.
36
+
37
+ Raises:
38
+ ToolError: If context is not available.
39
+ """
40
+ _validate_context(ctx)
41
+ return ctx.request_context.lifespan_context["client"]
42
+
43
+
44
+ def get_settings(ctx: Context) -> PCPMCPSettings:
45
+ """Get settings from context.
46
+
47
+ Args:
48
+ ctx: MCP context.
49
+
50
+ Returns:
51
+ The PCPMCPSettings instance.
52
+
53
+ Raises:
54
+ ToolError: If context is not available.
55
+ """
56
+ _validate_context(ctx)
57
+ return ctx.request_context.lifespan_context["settings"]
pcp_mcp/errors.py ADDED
@@ -0,0 +1,47 @@
1
+ """Error mapping from httpx to MCP ToolErrors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import httpx
6
+ from fastmcp.exceptions import ToolError
7
+
8
+
9
+ class PCPError(Exception):
10
+ """Base PCP error."""
11
+
12
+
13
+ class PCPConnectionError(PCPError):
14
+ """Cannot connect to pmproxy."""
15
+
16
+
17
+ class PCPMetricNotFoundError(PCPError):
18
+ """Metric does not exist."""
19
+
20
+
21
+ def handle_pcp_error(e: Exception, operation: str) -> ToolError:
22
+ """Convert PCP/httpx exceptions to MCP ToolErrors.
23
+
24
+ Args:
25
+ e: The exception to convert.
26
+ operation: Description of the operation that failed.
27
+
28
+ Returns:
29
+ A ToolError with an appropriate message.
30
+ """
31
+ match e:
32
+ case httpx.ConnectError():
33
+ return ToolError("Cannot connect to pmproxy. Is it running? (systemctl start pmproxy)")
34
+ case httpx.HTTPStatusError() as he if he.response.status_code == 400:
35
+ return ToolError(f"Bad request during {operation}: {he.response.text}")
36
+ case httpx.HTTPStatusError() as he if he.response.status_code == 404:
37
+ return ToolError(f"Metric not found during {operation}")
38
+ case httpx.HTTPStatusError() as he:
39
+ return ToolError(f"pmproxy error ({he.response.status_code}): {he.response.text}")
40
+ case httpx.TimeoutException():
41
+ return ToolError(f"Request timed out during {operation}")
42
+ case PCPConnectionError():
43
+ return ToolError(str(e))
44
+ case PCPMetricNotFoundError():
45
+ return ToolError(f"Metric not found: {e}")
46
+ case _:
47
+ return ToolError(f"Error during {operation}: {e}")
pcp_mcp/models.py ADDED
@@ -0,0 +1,142 @@
1
+ """Pydantic response models for strict output schemas."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pydantic import BaseModel, Field
6
+
7
+
8
+ class MetricValue(BaseModel):
9
+ """A single metric value from PCP."""
10
+
11
+ name: str = Field(description="Full metric name")
12
+ value: float | int | str | None = Field(description="Metric value")
13
+ units: str | None = Field(default=None, description="Unit of measurement")
14
+ semantics: str | None = Field(default=None, description="counter, instant, or discrete")
15
+ instance: str | None = Field(default=None, description="Instance name if per-instance metric")
16
+
17
+
18
+ class MetricInfo(BaseModel):
19
+ """Metadata about a PCP metric."""
20
+
21
+ name: str = Field(description="Full metric name")
22
+ type: str = Field(description="Data type (u32, u64, float, string, etc.)")
23
+ semantics: str = Field(description="counter, instant, or discrete")
24
+ units: str = Field(description="Unit of measurement")
25
+ help_text: str | None = Field(default=None, description="Metric help text")
26
+ indom: str | None = Field(default=None, description="Instance domain if per-instance")
27
+
28
+
29
+ class MetricSearchResult(BaseModel):
30
+ """Result from searching for metrics."""
31
+
32
+ name: str = Field(description="Full metric name")
33
+ help_text: str | None = Field(default=None, description="Brief description")
34
+
35
+
36
+ class InstancedMetric(BaseModel):
37
+ """Metric with per-instance values (e.g., per-CPU, per-disk)."""
38
+
39
+ name: str = Field(description="Metric name")
40
+ instances: dict[str, float | int] = Field(
41
+ description="Per-instance values, e.g., {'cpu0': 15.2, 'sda': 1000}"
42
+ )
43
+ aggregate: float | None = Field(
44
+ default=None, description="Optional rollup (sum/avg) for quick reference"
45
+ )
46
+
47
+
48
+ class CPUMetrics(BaseModel):
49
+ """CPU utilization summary."""
50
+
51
+ user_percent: float = Field(description="User CPU time percentage")
52
+ system_percent: float = Field(description="System CPU time percentage")
53
+ idle_percent: float = Field(description="Idle CPU time percentage")
54
+ iowait_percent: float = Field(description="I/O wait percentage")
55
+ ncpu: int = Field(description="Number of CPUs")
56
+ assessment: str = Field(description="Brief interpretation of CPU state")
57
+
58
+
59
+ class MemoryMetrics(BaseModel):
60
+ """Memory utilization summary."""
61
+
62
+ total_bytes: int = Field(description="Total physical memory")
63
+ used_bytes: int = Field(description="Used memory")
64
+ free_bytes: int = Field(description="Free memory")
65
+ available_bytes: int = Field(description="Available memory for applications")
66
+ cached_bytes: int = Field(description="Cached memory")
67
+ buffers_bytes: int = Field(description="Buffer memory")
68
+ swap_used_bytes: int = Field(description="Used swap space")
69
+ swap_total_bytes: int = Field(description="Total swap space")
70
+ used_percent: float = Field(description="Memory usage percentage")
71
+ assessment: str = Field(description="Brief interpretation of memory state")
72
+
73
+
74
+ class DiskMetrics(BaseModel):
75
+ """Disk I/O summary."""
76
+
77
+ read_bytes_per_sec: float = Field(description="Read throughput in bytes/sec")
78
+ write_bytes_per_sec: float = Field(description="Write throughput in bytes/sec")
79
+ reads_per_sec: float = Field(description="Read operations per second")
80
+ writes_per_sec: float = Field(description="Write operations per second")
81
+ assessment: str = Field(description="Brief interpretation of disk I/O state")
82
+
83
+
84
+ class NetworkMetrics(BaseModel):
85
+ """Network I/O summary."""
86
+
87
+ in_bytes_per_sec: float = Field(description="Inbound throughput in bytes/sec")
88
+ out_bytes_per_sec: float = Field(description="Outbound throughput in bytes/sec")
89
+ in_packets_per_sec: float = Field(description="Inbound packets per second")
90
+ out_packets_per_sec: float = Field(description="Outbound packets per second")
91
+ assessment: str = Field(description="Brief interpretation of network state")
92
+
93
+
94
+ class LoadMetrics(BaseModel):
95
+ """System load summary."""
96
+
97
+ load_1m: float = Field(description="1-minute load average")
98
+ load_5m: float = Field(description="5-minute load average")
99
+ load_15m: float = Field(description="15-minute load average")
100
+ runnable: int = Field(description="Number of runnable processes")
101
+ nprocs: int = Field(description="Total number of processes")
102
+ assessment: str = Field(description="Brief interpretation of load state")
103
+
104
+
105
+ class SystemSnapshot(BaseModel):
106
+ """Point-in-time system health overview."""
107
+
108
+ timestamp: str = Field(description="ISO8601 timestamp")
109
+ hostname: str = Field(description="Target host name")
110
+ cpu: CPUMetrics | None = Field(default=None, description="CPU metrics if requested")
111
+ memory: MemoryMetrics | None = Field(default=None, description="Memory metrics if requested")
112
+ disk: DiskMetrics | None = Field(default=None, description="Disk I/O metrics if requested")
113
+ network: NetworkMetrics | None = Field(
114
+ default=None, description="Network I/O metrics if requested"
115
+ )
116
+ load: LoadMetrics | None = Field(default=None, description="Load metrics if requested")
117
+
118
+
119
+ class ProcessInfo(BaseModel):
120
+ """A process with resource consumption details."""
121
+
122
+ pid: int = Field(description="Process ID")
123
+ command: str = Field(description="Command name")
124
+ cmdline: str = Field(description="Full command line (truncated)")
125
+ cpu_percent: float | None = Field(default=None, description="CPU usage percentage")
126
+ rss_bytes: int = Field(description="Resident set size in bytes")
127
+ rss_percent: float = Field(description="RSS as percentage of total memory")
128
+ io_read_bytes_per_sec: float | None = Field(default=None, description="Read bytes/sec")
129
+ io_write_bytes_per_sec: float | None = Field(default=None, description="Write bytes/sec")
130
+
131
+
132
+ class ProcessTopResult(BaseModel):
133
+ """Top processes by resource consumption."""
134
+
135
+ timestamp: str = Field(description="ISO8601 timestamp")
136
+ hostname: str = Field(description="Target host name")
137
+ sort_by: str = Field(description="Resource used for sorting")
138
+ sample_interval: float = Field(description="Sampling interval used")
139
+ processes: list[ProcessInfo] = Field(description="Top processes sorted by requested resource")
140
+ total_memory_bytes: int = Field(description="Total system memory")
141
+ ncpu: int = Field(description="Number of CPUs")
142
+ assessment: str = Field(description="Brief interpretation of top processes")