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 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")
@@ -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
@@ -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("pcp://metrics/common")
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