pcp-mcp 0.1.0__py3-none-any.whl → 1.0.1__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/AGENTS.md +70 -0
- pcp_mcp/__init__.py +4 -0
- pcp_mcp/client.py +28 -0
- pcp_mcp/config.py +58 -1
- pcp_mcp/context.py +48 -1
- pcp_mcp/icons.py +31 -0
- pcp_mcp/middleware.py +75 -0
- pcp_mcp/models.py +10 -0
- pcp_mcp/prompts/__init__.py +18 -5
- pcp_mcp/py.typed +0 -0
- pcp_mcp/resources/catalog.py +76 -2
- pcp_mcp/resources/health.py +70 -27
- pcp_mcp/server.py +20 -0
- pcp_mcp/tools/AGENTS.md +61 -0
- pcp_mcp/tools/metrics.py +126 -88
- pcp_mcp/tools/system.py +311 -59
- pcp_mcp/utils/__init__.py +0 -4
- pcp_mcp/utils/extractors.py +18 -0
- {pcp_mcp-0.1.0.dist-info → pcp_mcp-1.0.1.dist-info}/METADATA +22 -10
- pcp_mcp-1.0.1.dist-info/RECORD +26 -0
- pcp_mcp/utils/decorators.py +0 -38
- pcp_mcp-0.1.0.dist-info/RECORD +0 -22
- {pcp_mcp-0.1.0.dist-info → pcp_mcp-1.0.1.dist-info}/WHEEL +0 -0
- {pcp_mcp-0.1.0.dist-info → pcp_mcp-1.0.1.dist-info}/entry_points.txt +0 -0
pcp_mcp/AGENTS.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# pcp_mcp Core Package
|
|
2
|
+
|
|
3
|
+
## OVERVIEW
|
|
4
|
+
|
|
5
|
+
Core MCP server package. Entry point, HTTP client, configuration, models, error handling.
|
|
6
|
+
|
|
7
|
+
## STRUCTURE
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
pcp_mcp/
|
|
11
|
+
├── __init__.py # CLI entry (main() with argparse)
|
|
12
|
+
├── server.py # FastMCP setup, lifespan context
|
|
13
|
+
├── client.py # PCPClient async httpx wrapper
|
|
14
|
+
├── config.py # PCPMCPSettings (Pydantic)
|
|
15
|
+
├── models.py # Response models (SystemSnapshot, ProcessInfo, etc.)
|
|
16
|
+
├── errors.py # Exception → ToolError mapping
|
|
17
|
+
├── context.py # get_client(), get_settings() helpers
|
|
18
|
+
├── middleware.py # Request caching middleware
|
|
19
|
+
├── icons.py # System assessment icons (emoji mappings)
|
|
20
|
+
├── tools/ # MCP tools (see tools/AGENTS.md)
|
|
21
|
+
├── resources/ # MCP resources (health.py, catalog.py)
|
|
22
|
+
├── utils/ # Extractors, builders
|
|
23
|
+
└── prompts/ # LLM system prompts
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## KEY PATTERNS
|
|
27
|
+
|
|
28
|
+
### Server Lifespan
|
|
29
|
+
```python
|
|
30
|
+
@asynccontextmanager
|
|
31
|
+
async def lifespan(mcp: FastMCP) -> AsyncIterator[dict]:
|
|
32
|
+
async with PCPClient(...) as client:
|
|
33
|
+
yield {"client": client, "settings": settings}
|
|
34
|
+
```
|
|
35
|
+
Tools access via `ctx.request_context.lifespan_context["client"]`.
|
|
36
|
+
|
|
37
|
+
### Client Rate Calculation
|
|
38
|
+
`fetch_with_rates()` takes two samples, calculates per-second rates for counters.
|
|
39
|
+
Handles counter wrap-around (reset to 0) gracefully.
|
|
40
|
+
|
|
41
|
+
### Error Mapping
|
|
42
|
+
```python
|
|
43
|
+
try:
|
|
44
|
+
result = await client.fetch(names)
|
|
45
|
+
except Exception as e:
|
|
46
|
+
raise handle_pcp_error(e, "fetching metrics") from e
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Configuration
|
|
50
|
+
Pydantic settings with `env_prefix="PCP_"`. Computed properties: `base_url`, `auth`.
|
|
51
|
+
|
|
52
|
+
## WHERE TO LOOK
|
|
53
|
+
|
|
54
|
+
| Task | File | Notes |
|
|
55
|
+
|------|------|-------|
|
|
56
|
+
| Add CLI flag | `__init__.py` | argparse in `main()` |
|
|
57
|
+
| Change transport | `__init__.py` | `server.run(transport=...)` |
|
|
58
|
+
| Add env var | `config.py` | Add field with `Field(default=...)` |
|
|
59
|
+
| New response type | `models.py` | Inherit `BaseModel` |
|
|
60
|
+
| Map new exception | `errors.py` | Add case to `handle_pcp_error()` |
|
|
61
|
+
| Access client in tool | `context.py` | Use `get_client(ctx)` |
|
|
62
|
+
| Add caching | `middleware.py` | Request caching layer |
|
|
63
|
+
| System icons | `icons.py` | Assessment emoji mappings |
|
|
64
|
+
|
|
65
|
+
## ANTI-PATTERNS
|
|
66
|
+
|
|
67
|
+
- **NEVER** call `client.fetch()` for counter metrics expecting rates
|
|
68
|
+
- **NEVER** use client outside `async with` context
|
|
69
|
+
- **NEVER** log credentials from settings
|
|
70
|
+
- **ALWAYS** use `handle_pcp_error()` for exception wrapping
|
pcp_mcp/__init__.py
CHANGED
|
@@ -17,9 +17,13 @@ Environment Variables:
|
|
|
17
17
|
PCP_PORT pmproxy port (default: 44322)
|
|
18
18
|
PCP_TARGET_HOST Target pmcd host to monitor (default: localhost)
|
|
19
19
|
PCP_USE_TLS Use HTTPS for pmproxy connection (default: false)
|
|
20
|
+
PCP_TLS_VERIFY Verify TLS certificates (default: true)
|
|
21
|
+
PCP_TLS_CA_BUNDLE Path to custom CA bundle for TLS (optional)
|
|
20
22
|
PCP_TIMEOUT Request timeout in seconds (default: 30)
|
|
21
23
|
PCP_USERNAME HTTP basic auth user (optional)
|
|
22
24
|
PCP_PASSWORD HTTP basic auth password (optional)
|
|
25
|
+
PCP_ALLOWED_HOSTS Comma-separated hostspecs allowed via host parameter (optional)
|
|
26
|
+
If not set, only target_host is allowed. Use '*' for any host.
|
|
23
27
|
|
|
24
28
|
Examples:
|
|
25
29
|
# Monitor localhost (default)
|
pcp_mcp/client.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import sys
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
7
8
|
|
|
8
9
|
if sys.version_info >= (3, 11):
|
|
9
10
|
from typing import Self
|
|
@@ -12,6 +13,12 @@ else:
|
|
|
12
13
|
|
|
13
14
|
import httpx
|
|
14
15
|
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from collections.abc import Callable, Coroutine
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
ProgressCallback = Callable[[float, float, str], Coroutine[Any, Any, None]]
|
|
21
|
+
|
|
15
22
|
|
|
16
23
|
class PCPClient:
|
|
17
24
|
"""Async client for pmproxy REST API.
|
|
@@ -24,6 +31,7 @@ class PCPClient:
|
|
|
24
31
|
target_host: Which pmcd host to connect to (passed as hostspec).
|
|
25
32
|
auth: Optional HTTP basic auth tuple (username, password).
|
|
26
33
|
timeout: Request timeout in seconds.
|
|
34
|
+
verify: TLS verification (True, False, or path to CA bundle).
|
|
27
35
|
"""
|
|
28
36
|
|
|
29
37
|
def __init__(
|
|
@@ -32,12 +40,14 @@ class PCPClient:
|
|
|
32
40
|
target_host: str = "localhost",
|
|
33
41
|
auth: tuple[str, str] | None = None,
|
|
34
42
|
timeout: float = 30.0,
|
|
43
|
+
verify: bool | str = True,
|
|
35
44
|
) -> None:
|
|
36
45
|
"""Initialize the PCP client."""
|
|
37
46
|
self._base_url = base_url
|
|
38
47
|
self._target_host = target_host
|
|
39
48
|
self._auth = auth
|
|
40
49
|
self._timeout = timeout
|
|
50
|
+
self._verify = verify
|
|
41
51
|
self._client: httpx.AsyncClient | None = None
|
|
42
52
|
self._context_id: int | None = None
|
|
43
53
|
|
|
@@ -47,6 +57,7 @@ class PCPClient:
|
|
|
47
57
|
base_url=self._base_url,
|
|
48
58
|
auth=self._auth,
|
|
49
59
|
timeout=self._timeout,
|
|
60
|
+
verify=self._verify,
|
|
50
61
|
)
|
|
51
62
|
resp = await self._client.get(
|
|
52
63
|
"/pmapi/context",
|
|
@@ -185,6 +196,7 @@ class PCPClient:
|
|
|
185
196
|
metric_names: list[str],
|
|
186
197
|
counter_metrics: set[str],
|
|
187
198
|
sample_interval: float = 1.0,
|
|
199
|
+
progress_callback: ProgressCallback | None = None,
|
|
188
200
|
) -> dict[str, dict]:
|
|
189
201
|
"""Fetch metrics, calculating rates for counters.
|
|
190
202
|
|
|
@@ -196,15 +208,31 @@ class PCPClient:
|
|
|
196
208
|
metric_names: List of PCP metric names to fetch.
|
|
197
209
|
counter_metrics: Set of metric names that are counters.
|
|
198
210
|
sample_interval: Seconds between samples for rate calculation.
|
|
211
|
+
progress_callback: Optional async callback for progress updates.
|
|
212
|
+
Called with (current, total, message) during long operations.
|
|
199
213
|
|
|
200
214
|
Returns:
|
|
201
215
|
Dict mapping metric name to {value, instances} where value/instances
|
|
202
216
|
contain the rate (for counters) or instant value (for gauges).
|
|
203
217
|
"""
|
|
218
|
+
if progress_callback:
|
|
219
|
+
await progress_callback(0, 100, "Collecting first sample...")
|
|
220
|
+
|
|
204
221
|
t1 = await self.fetch(metric_names)
|
|
222
|
+
|
|
223
|
+
if progress_callback:
|
|
224
|
+
await progress_callback(20, 100, f"Waiting {sample_interval}s for rate calculation...")
|
|
225
|
+
|
|
205
226
|
await asyncio.sleep(sample_interval)
|
|
227
|
+
|
|
228
|
+
if progress_callback:
|
|
229
|
+
await progress_callback(70, 100, "Collecting second sample...")
|
|
230
|
+
|
|
206
231
|
t2 = await self.fetch(metric_names)
|
|
207
232
|
|
|
233
|
+
if progress_callback:
|
|
234
|
+
await progress_callback(90, 100, "Computing rates...")
|
|
235
|
+
|
|
208
236
|
ts1 = t1.get("timestamp", 0.0)
|
|
209
237
|
ts2 = t2.get("timestamp", 0.0)
|
|
210
238
|
if isinstance(ts1, dict):
|
pcp_mcp/config.py
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from pydantic import Field
|
|
5
|
+
from pydantic import Field, computed_field
|
|
6
6
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
7
|
|
|
8
8
|
|
|
@@ -28,6 +28,14 @@ class PCPMCPSettings(BaseSettings):
|
|
|
28
28
|
host: str = Field(default="localhost", description="pmproxy host")
|
|
29
29
|
port: int = Field(default=44322, description="pmproxy port")
|
|
30
30
|
use_tls: bool = Field(default=False, description="Use HTTPS for pmproxy connection")
|
|
31
|
+
tls_verify: bool = Field(
|
|
32
|
+
default=True,
|
|
33
|
+
description="Verify TLS certificates when use_tls is enabled",
|
|
34
|
+
)
|
|
35
|
+
tls_ca_bundle: str | None = Field(
|
|
36
|
+
default=None,
|
|
37
|
+
description="Path to custom CA bundle for TLS verification",
|
|
38
|
+
)
|
|
31
39
|
timeout: float = Field(default=30.0, description="Request timeout in seconds")
|
|
32
40
|
target_host: str = Field(
|
|
33
41
|
default="localhost",
|
|
@@ -35,16 +43,65 @@ class PCPMCPSettings(BaseSettings):
|
|
|
35
43
|
)
|
|
36
44
|
username: str | None = Field(default=None, description="HTTP basic auth user")
|
|
37
45
|
password: str | None = Field(default=None, description="HTTP basic auth password")
|
|
46
|
+
allowed_hosts: list[str] | None = Field(
|
|
47
|
+
default=None,
|
|
48
|
+
description=(
|
|
49
|
+
"Allowlist of hostspecs that can be queried via the host parameter. "
|
|
50
|
+
"If None, only the configured target_host is allowed (default). "
|
|
51
|
+
"Set to ['*'] to allow any host (use with caution)."
|
|
52
|
+
),
|
|
53
|
+
)
|
|
38
54
|
|
|
55
|
+
@computed_field
|
|
39
56
|
@property
|
|
40
57
|
def base_url(self) -> str:
|
|
41
58
|
"""URL for connecting to pmproxy."""
|
|
42
59
|
scheme = "https" if self.use_tls else "http"
|
|
43
60
|
return f"{scheme}://{self.host}:{self.port}"
|
|
44
61
|
|
|
62
|
+
@computed_field
|
|
45
63
|
@property
|
|
46
64
|
def auth(self) -> tuple[str, str] | None:
|
|
47
65
|
"""Auth tuple for httpx, or None if no auth configured."""
|
|
48
66
|
if self.username and self.password:
|
|
49
67
|
return (self.username, self.password)
|
|
50
68
|
return None
|
|
69
|
+
|
|
70
|
+
@computed_field
|
|
71
|
+
@property
|
|
72
|
+
def verify(self) -> bool | str:
|
|
73
|
+
"""TLS verification setting for httpx.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
False if verification disabled, path to CA bundle if specified,
|
|
77
|
+
or True for default system verification.
|
|
78
|
+
"""
|
|
79
|
+
if not self.tls_verify:
|
|
80
|
+
return False
|
|
81
|
+
if self.tls_ca_bundle:
|
|
82
|
+
return self.tls_ca_bundle
|
|
83
|
+
return True
|
|
84
|
+
|
|
85
|
+
def is_host_allowed(self, host: str) -> bool:
|
|
86
|
+
"""Check if a host is allowed by the allowlist.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
host: The hostspec to validate.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
True if the host is allowed, False otherwise.
|
|
93
|
+
"""
|
|
94
|
+
# Always allow the configured target_host
|
|
95
|
+
if host == self.target_host:
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
# If no allowlist configured, only target_host is allowed
|
|
99
|
+
if self.allowed_hosts is None:
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
# Wildcard allows everything
|
|
103
|
+
if "*" in self.allowed_hosts:
|
|
104
|
+
return True
|
|
105
|
+
|
|
106
|
+
# Check exact match in allowlist
|
|
107
|
+
return host in self.allowed_hosts
|
pcp_mcp/context.py
CHANGED
|
@@ -2,13 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from collections.abc import AsyncIterator
|
|
6
|
+
from contextlib import asynccontextmanager
|
|
5
7
|
from typing import TYPE_CHECKING
|
|
6
8
|
|
|
7
9
|
from fastmcp import Context
|
|
8
10
|
from fastmcp.exceptions import ToolError
|
|
9
11
|
|
|
12
|
+
from pcp_mcp.client import PCPClient
|
|
13
|
+
|
|
10
14
|
if TYPE_CHECKING:
|
|
11
|
-
from pcp_mcp.client import PCPClient
|
|
12
15
|
from pcp_mcp.config import PCPMCPSettings
|
|
13
16
|
|
|
14
17
|
|
|
@@ -38,6 +41,8 @@ def get_client(ctx: Context) -> PCPClient:
|
|
|
38
41
|
ToolError: If context is not available.
|
|
39
42
|
"""
|
|
40
43
|
_validate_context(ctx)
|
|
44
|
+
assert ctx.request_context is not None
|
|
45
|
+
assert ctx.request_context.lifespan_context is not None
|
|
41
46
|
return ctx.request_context.lifespan_context["client"]
|
|
42
47
|
|
|
43
48
|
|
|
@@ -54,4 +59,46 @@ def get_settings(ctx: Context) -> PCPMCPSettings:
|
|
|
54
59
|
ToolError: If context is not available.
|
|
55
60
|
"""
|
|
56
61
|
_validate_context(ctx)
|
|
62
|
+
assert ctx.request_context is not None
|
|
63
|
+
assert ctx.request_context.lifespan_context is not None
|
|
57
64
|
return ctx.request_context.lifespan_context["settings"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@asynccontextmanager
|
|
68
|
+
async def get_client_for_host(ctx: Context, host: str | None = None) -> AsyncIterator[PCPClient]:
|
|
69
|
+
"""Get a PCPClient for the specified host.
|
|
70
|
+
|
|
71
|
+
If host is None or matches the configured target_host, yields the existing
|
|
72
|
+
lifespan client. Otherwise, creates a new ad-hoc client for the specified
|
|
73
|
+
hostspec and cleans it up on exit.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
ctx: MCP context.
|
|
77
|
+
host: Target pmcd hostspec to query. None uses the default.
|
|
78
|
+
|
|
79
|
+
Yields:
|
|
80
|
+
PCPClient connected to the specified host.
|
|
81
|
+
|
|
82
|
+
Raises:
|
|
83
|
+
ToolError: If context is not available, host is not allowed, or host is unreachable.
|
|
84
|
+
"""
|
|
85
|
+
settings = get_settings(ctx)
|
|
86
|
+
|
|
87
|
+
if host is None or host == settings.target_host:
|
|
88
|
+
yield get_client(ctx)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if not settings.is_host_allowed(host):
|
|
92
|
+
raise ToolError(
|
|
93
|
+
f"Host '{host}' is not in the allowed hosts list. "
|
|
94
|
+
f"Configure PCP_ALLOWED_HOSTS to permit additional hosts."
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async with PCPClient(
|
|
98
|
+
base_url=settings.base_url,
|
|
99
|
+
target_host=host,
|
|
100
|
+
auth=settings.auth,
|
|
101
|
+
timeout=settings.timeout,
|
|
102
|
+
verify=settings.verify,
|
|
103
|
+
) as client:
|
|
104
|
+
yield client
|
pcp_mcp/icons.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Centralized icons and tags for MCP components."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mcp.types import Icon
|
|
6
|
+
|
|
7
|
+
ICON_METRICS = Icon(src="data:,📊", mimeType="text/plain")
|
|
8
|
+
ICON_SEARCH = Icon(src="data:,🔍", mimeType="text/plain")
|
|
9
|
+
ICON_INFO = Icon(src="data:,📋", mimeType="text/plain")
|
|
10
|
+
ICON_SYSTEM = Icon(src="data:,💻", mimeType="text/plain")
|
|
11
|
+
ICON_PROCESS = Icon(src="data:,⚙️", mimeType="text/plain")
|
|
12
|
+
ICON_HEALTH = Icon(src="data:,💚", mimeType="text/plain")
|
|
13
|
+
ICON_CATALOG = Icon(src="data:,📚", mimeType="text/plain")
|
|
14
|
+
ICON_NAMESPACE = Icon(src="data:,🗂️", mimeType="text/plain")
|
|
15
|
+
ICON_DIAGNOSE = Icon(src="data:,🔬", mimeType="text/plain")
|
|
16
|
+
ICON_CPU = Icon(src="data:,🖥️", mimeType="text/plain")
|
|
17
|
+
ICON_MEMORY = Icon(src="data:,🧠", mimeType="text/plain")
|
|
18
|
+
ICON_DISK = Icon(src="data:,💾", mimeType="text/plain")
|
|
19
|
+
ICON_NETWORK = Icon(src="data:,🌐", mimeType="text/plain")
|
|
20
|
+
|
|
21
|
+
TAGS_METRICS = {"metrics", "pcp"}
|
|
22
|
+
TAGS_SYSTEM = {"system", "monitoring", "performance"}
|
|
23
|
+
TAGS_PROCESS = {"processes", "monitoring"}
|
|
24
|
+
TAGS_HEALTH = {"health", "status", "summary"}
|
|
25
|
+
TAGS_CATALOG = {"catalog", "reference", "documentation"}
|
|
26
|
+
TAGS_DISCOVERY = {"discovery", "namespace", "exploration"}
|
|
27
|
+
TAGS_CPU = {"cpu", "troubleshooting", "performance"}
|
|
28
|
+
TAGS_MEMORY = {"memory", "troubleshooting", "performance"}
|
|
29
|
+
TAGS_DISK = {"disk", "io", "troubleshooting", "performance"}
|
|
30
|
+
TAGS_NETWORK = {"network", "troubleshooting", "performance"}
|
|
31
|
+
TAGS_DIAGNOSE = {"diagnosis", "troubleshooting", "workflow"}
|
pcp_mcp/middleware.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Custom middleware for pcp-mcp server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from cachetools import TTLCache
|
|
8
|
+
from fastmcp.server.middleware import Middleware
|
|
9
|
+
from fastmcp.tools.tool import ToolResult
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from fastmcp.server.middleware.middleware import CallNext, MiddlewareContext
|
|
13
|
+
from mcp import types as mt
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
CACHEABLE_TOOLS = frozenset({"describe_metric", "search_metrics"})
|
|
17
|
+
DEFAULT_TTL_SECONDS = 300
|
|
18
|
+
DEFAULT_MAX_SIZE = 100
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MetricCacheMiddleware(Middleware):
|
|
22
|
+
"""Cache responses for describe_metric and search_metrics tools.
|
|
23
|
+
|
|
24
|
+
These tools query PCP metric metadata which changes infrequently.
|
|
25
|
+
Caching reduces pmproxy load and improves LLM response times.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self, ttl_seconds: int = DEFAULT_TTL_SECONDS, maxsize: int = DEFAULT_MAX_SIZE
|
|
30
|
+
) -> None:
|
|
31
|
+
"""Initialize the cache middleware.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
ttl_seconds: Time-to-live for cached entries in seconds.
|
|
35
|
+
maxsize: Maximum number of entries in the cache.
|
|
36
|
+
"""
|
|
37
|
+
self._cache: TTLCache[str, ToolResult] = TTLCache(maxsize=maxsize, ttl=ttl_seconds)
|
|
38
|
+
|
|
39
|
+
def _make_cache_key(self, tool_name: str, arguments: dict | None) -> str:
|
|
40
|
+
args_str = str(sorted((arguments or {}).items()))
|
|
41
|
+
return f"{tool_name}:{args_str}"
|
|
42
|
+
|
|
43
|
+
async def on_call_tool(
|
|
44
|
+
self,
|
|
45
|
+
context: MiddlewareContext[mt.CallToolRequestParams],
|
|
46
|
+
call_next: CallNext[mt.CallToolRequestParams, ToolResult],
|
|
47
|
+
) -> ToolResult:
|
|
48
|
+
"""Intercept tool calls and cache describe_metric/search_metrics responses."""
|
|
49
|
+
tool_name = context.message.name
|
|
50
|
+
arguments = context.message.arguments
|
|
51
|
+
|
|
52
|
+
if tool_name not in CACHEABLE_TOOLS:
|
|
53
|
+
return await call_next(context)
|
|
54
|
+
|
|
55
|
+
if arguments and arguments.get("host"):
|
|
56
|
+
return await call_next(context)
|
|
57
|
+
|
|
58
|
+
cache_key = self._make_cache_key(tool_name, arguments)
|
|
59
|
+
|
|
60
|
+
cached = self._cache.get(cache_key)
|
|
61
|
+
if cached is not None:
|
|
62
|
+
return cached
|
|
63
|
+
|
|
64
|
+
result = await call_next(context)
|
|
65
|
+
self._cache[cache_key] = result
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def cache_size(self) -> int:
|
|
70
|
+
"""Number of entries in the cache."""
|
|
71
|
+
return len(self._cache)
|
|
72
|
+
|
|
73
|
+
def clear_cache(self) -> None:
|
|
74
|
+
"""Clear all cached entries."""
|
|
75
|
+
self._cache.clear()
|
pcp_mcp/models.py
CHANGED
|
@@ -140,3 +140,13 @@ class ProcessTopResult(BaseModel):
|
|
|
140
140
|
total_memory_bytes: int = Field(description="Total system memory")
|
|
141
141
|
ncpu: int = Field(description="Number of CPUs")
|
|
142
142
|
assessment: str = Field(description="Brief interpretation of top processes")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class DiagnosisResult(BaseModel):
|
|
146
|
+
"""LLM-powered system diagnosis."""
|
|
147
|
+
|
|
148
|
+
timestamp: str = Field(description="ISO8601 timestamp")
|
|
149
|
+
hostname: str = Field(description="Target host name")
|
|
150
|
+
diagnosis: str = Field(description="LLM-generated analysis of system health")
|
|
151
|
+
severity: str = Field(description="Severity level: healthy, warning, or critical")
|
|
152
|
+
recommendations: list[str] = Field(description="Actionable recommendations")
|
pcp_mcp/prompts/__init__.py
CHANGED
|
@@ -4,6 +4,19 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from typing import TYPE_CHECKING
|
|
6
6
|
|
|
7
|
+
from pcp_mcp.icons import (
|
|
8
|
+
ICON_CPU,
|
|
9
|
+
ICON_DIAGNOSE,
|
|
10
|
+
ICON_DISK,
|
|
11
|
+
ICON_MEMORY,
|
|
12
|
+
ICON_NETWORK,
|
|
13
|
+
TAGS_CPU,
|
|
14
|
+
TAGS_DIAGNOSE,
|
|
15
|
+
TAGS_DISK,
|
|
16
|
+
TAGS_MEMORY,
|
|
17
|
+
TAGS_NETWORK,
|
|
18
|
+
)
|
|
19
|
+
|
|
7
20
|
if TYPE_CHECKING:
|
|
8
21
|
from fastmcp import FastMCP
|
|
9
22
|
|
|
@@ -15,7 +28,7 @@ def register_prompts(mcp: FastMCP) -> None:
|
|
|
15
28
|
mcp: The FastMCP server instance.
|
|
16
29
|
"""
|
|
17
30
|
|
|
18
|
-
@mcp.prompt()
|
|
31
|
+
@mcp.prompt(icons=[ICON_DIAGNOSE], tags=TAGS_DIAGNOSE)
|
|
19
32
|
def diagnose_slow_system() -> str:
|
|
20
33
|
"""Diagnose why a system is running slowly.
|
|
21
34
|
|
|
@@ -61,7 +74,7 @@ def register_prompts(mcp: FastMCP) -> None:
|
|
|
61
74
|
- Recommendations (kill process, add RAM, optimize queries, etc.)
|
|
62
75
|
"""
|
|
63
76
|
|
|
64
|
-
@mcp.prompt()
|
|
77
|
+
@mcp.prompt(icons=[ICON_MEMORY], tags=TAGS_MEMORY)
|
|
65
78
|
def investigate_memory_usage() -> str:
|
|
66
79
|
"""Investigate memory consumption and identify memory pressure.
|
|
67
80
|
|
|
@@ -113,7 +126,7 @@ def register_prompts(mcp: FastMCP) -> None:
|
|
|
113
126
|
* Single process consuming >50% = Investigate for memory leak
|
|
114
127
|
"""
|
|
115
128
|
|
|
116
|
-
@mcp.prompt()
|
|
129
|
+
@mcp.prompt(icons=[ICON_DISK], tags=TAGS_DISK)
|
|
117
130
|
def find_io_bottleneck() -> str:
|
|
118
131
|
"""Find disk I/O bottlenecks and identify processes causing high I/O.
|
|
119
132
|
|
|
@@ -174,7 +187,7 @@ def register_prompts(mcp: FastMCP) -> None:
|
|
|
174
187
|
* Backup/batch jobs during business hours → Reschedule
|
|
175
188
|
"""
|
|
176
189
|
|
|
177
|
-
@mcp.prompt()
|
|
190
|
+
@mcp.prompt(icons=[ICON_CPU], tags=TAGS_CPU)
|
|
178
191
|
def analyze_cpu_usage() -> str:
|
|
179
192
|
"""Analyze CPU utilization patterns and identify CPU-bound processes.
|
|
180
193
|
|
|
@@ -235,7 +248,7 @@ def register_prompts(mcp: FastMCP) -> None:
|
|
|
235
248
|
* Many small processes → Reduce process spawning overhead
|
|
236
249
|
"""
|
|
237
250
|
|
|
238
|
-
@mcp.prompt()
|
|
251
|
+
@mcp.prompt(icons=[ICON_NETWORK], tags=TAGS_NETWORK)
|
|
239
252
|
def check_network_performance() -> str:
|
|
240
253
|
"""Check network performance and identify bandwidth/error issues.
|
|
241
254
|
|
pcp_mcp/py.typed
ADDED
|
File without changes
|
pcp_mcp/resources/catalog.py
CHANGED
|
@@ -6,6 +6,16 @@ from typing import TYPE_CHECKING
|
|
|
6
6
|
|
|
7
7
|
from fastmcp import Context
|
|
8
8
|
|
|
9
|
+
from pcp_mcp.icons import (
|
|
10
|
+
ICON_CATALOG,
|
|
11
|
+
ICON_INFO,
|
|
12
|
+
ICON_NAMESPACE,
|
|
13
|
+
TAGS_CATALOG,
|
|
14
|
+
TAGS_DISCOVERY,
|
|
15
|
+
TAGS_METRICS,
|
|
16
|
+
)
|
|
17
|
+
from pcp_mcp.utils.extractors import extract_help_text, format_units
|
|
18
|
+
|
|
9
19
|
if TYPE_CHECKING:
|
|
10
20
|
from fastmcp import FastMCP
|
|
11
21
|
|
|
@@ -17,7 +27,71 @@ def register_catalog_resources(mcp: FastMCP) -> None:
|
|
|
17
27
|
mcp: The FastMCP server instance.
|
|
18
28
|
"""
|
|
19
29
|
|
|
20
|
-
@mcp.resource(
|
|
30
|
+
@mcp.resource(
|
|
31
|
+
"pcp://metric/{metric_name}/info",
|
|
32
|
+
icons=[ICON_INFO],
|
|
33
|
+
tags=TAGS_METRICS | TAGS_DISCOVERY,
|
|
34
|
+
)
|
|
35
|
+
async def metric_info(ctx: Context, metric_name: str) -> str:
|
|
36
|
+
"""Detailed metadata for a specific PCP metric.
|
|
37
|
+
|
|
38
|
+
Returns type, semantics, units, and help text. Use to understand
|
|
39
|
+
what a metric measures and how to interpret its values.
|
|
40
|
+
"""
|
|
41
|
+
from pcp_mcp.context import get_client
|
|
42
|
+
from pcp_mcp.errors import handle_pcp_error
|
|
43
|
+
|
|
44
|
+
client = get_client(ctx)
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
info = await client.describe(metric_name)
|
|
48
|
+
except Exception as e:
|
|
49
|
+
raise handle_pcp_error(e, "describing metric") from e
|
|
50
|
+
|
|
51
|
+
if not info:
|
|
52
|
+
return f"# Metric Not Found\n\nNo metric named `{metric_name}` was found."
|
|
53
|
+
|
|
54
|
+
semantics = info.get("sem", "unknown")
|
|
55
|
+
metric_type = info.get("type", "unknown")
|
|
56
|
+
units = format_units(info)
|
|
57
|
+
help_text = extract_help_text(info) or "No description available."
|
|
58
|
+
indom = info.get("indom")
|
|
59
|
+
|
|
60
|
+
is_counter = semantics == "counter"
|
|
61
|
+
counter_warning = (
|
|
62
|
+
"\n\n> **Warning**: This is a counter metric (cumulative since boot). "
|
|
63
|
+
"Use `get_system_snapshot()` or `get_process_top()` for rate calculation."
|
|
64
|
+
if is_counter
|
|
65
|
+
else ""
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
instances_info = (
|
|
69
|
+
f"\n- **Instance Domain**: {indom} (has per-instance values)"
|
|
70
|
+
if indom and indom != "PM_INDOM_NULL"
|
|
71
|
+
else ""
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return f"""# Metric: {metric_name}
|
|
75
|
+
|
|
76
|
+
{help_text}{counter_warning}
|
|
77
|
+
|
|
78
|
+
## Properties
|
|
79
|
+
- **Type**: {metric_type}
|
|
80
|
+
- **Semantics**: {semantics}
|
|
81
|
+
- **Units**: {units}{instances_info}
|
|
82
|
+
|
|
83
|
+
## Usage
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
# Query current value
|
|
87
|
+
query_metrics(["{metric_name}"])
|
|
88
|
+
|
|
89
|
+
# Search related metrics
|
|
90
|
+
search_metrics("{".".join(metric_name.split(".")[:2])}")
|
|
91
|
+
```
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
@mcp.resource("pcp://metrics/common", icons=[ICON_CATALOG], tags=TAGS_CATALOG)
|
|
21
95
|
def common_metrics_catalog() -> str:
|
|
22
96
|
"""Catalog of commonly used metric groups.
|
|
23
97
|
|
|
@@ -96,7 +170,7 @@ def register_catalog_resources(mcp: FastMCP) -> None:
|
|
|
96
170
|
(counter) = Cumulative counter since boot
|
|
97
171
|
"""
|
|
98
172
|
|
|
99
|
-
@mcp.resource("pcp://namespaces")
|
|
173
|
+
@mcp.resource("pcp://namespaces", icons=[ICON_NAMESPACE], tags=TAGS_DISCOVERY)
|
|
100
174
|
async def metric_namespaces(ctx: Context) -> str:
|
|
101
175
|
"""List available PCP metric namespaces discovered from the live system.
|
|
102
176
|
|