Skip to content

Commit 6fde652

Browse files
authored
Add local mcp access monitor module (#37)
1 parent cc7a756 commit 6fde652

File tree

5 files changed

+528
-4
lines changed

5 files changed

+528
-4
lines changed

pyproject.toml

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,4 @@ select = ["F", "E", "W", "I"]
5656
fixable = ["I", "F401"]
5757

5858
[dependency-groups]
59-
dev = [
60-
"ipython>=8.34.0",
61-
"pytest>=8.3.5",
62-
]
59+
dev = ["ipython>=8.34.0", "pytest>=8.3.5"]

src/mcpm/monitor/__init__.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
"""
2+
Monitoring functionality for MCPM
3+
"""
4+
5+
from typing import Optional
6+
7+
# Re-export base interfaces
8+
from .base import AccessEventType, AccessMonitor
9+
10+
# Re-export implementations
11+
from .duckdb import DuckDBAccessMonitor
12+
13+
14+
# Convenience function to get a monitor instance
15+
def get_monitor(db_path: Optional[str] = None) -> AccessMonitor:
16+
"""
17+
Get a configured access monitor instance
18+
19+
Args:
20+
db_path: Optional custom path to the database file
21+
22+
Returns:
23+
Configured AccessMonitor instance
24+
"""
25+
monitor = DuckDBAccessMonitor(db_path) if db_path else DuckDBAccessMonitor()
26+
monitor.initialize_storage()
27+
return monitor
28+
29+
30+
# Exports
31+
__all__ = [
32+
"AccessEventType",
33+
"AccessMonitor",
34+
"DuckDBAccessMonitor",
35+
"get_monitor",
36+
]

src/mcpm/monitor/base.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""
2+
Core interfaces for MCPM monitoring functionality
3+
"""
4+
5+
from abc import ABC, abstractmethod
6+
from datetime import datetime
7+
from enum import Enum, auto
8+
from typing import Any, Dict, Optional, Union
9+
10+
11+
class AccessEventType(Enum):
12+
"""Type of MCP access event"""
13+
14+
TOOL_INVOCATION = auto() # Tool was invoked (modifying content)
15+
RESOURCE_ACCESS = auto() # Resource was accessed (reading content)
16+
PROMPT_EXECUTION = auto() # Prompt was executed (generating content)
17+
18+
19+
class AccessMonitor(ABC):
20+
"""Abstract interface for monitoring MCP access events"""
21+
22+
@abstractmethod
23+
def track_event(
24+
self,
25+
event_type: AccessEventType,
26+
server_id: str,
27+
resource_id: str,
28+
client_id: Optional[str] = None,
29+
timestamp: Optional[datetime] = None,
30+
duration_ms: Optional[int] = None,
31+
request_size: Optional[int] = None,
32+
response_size: Optional[int] = None,
33+
success: bool = True,
34+
error_message: Optional[str] = None,
35+
metadata: Optional[Dict[str, Any]] = None,
36+
raw_request: Optional[Union[Dict[str, Any], str]] = None,
37+
raw_response: Optional[Union[Dict[str, Any], str]] = None,
38+
) -> None:
39+
"""
40+
Track an MCP access event
41+
42+
Args:
43+
event_type: Type of access event (tool, resource, or prompt)
44+
server_id: ID of the MCP server
45+
resource_id: ID of the specific resource/tool/prompt
46+
client_id: ID of the client (if available)
47+
timestamp: When the event occurred (defaults to now if None)
48+
duration_ms: Duration of the operation in milliseconds
49+
request_size: Size of the request in bytes
50+
response_size: Size of the response in bytes
51+
success: Whether the operation succeeded
52+
error_message: Error message if operation failed
53+
metadata: Additional metadata about the event
54+
raw_request: Raw request data as JSON object or string
55+
raw_response: Raw response data as JSON object or string
56+
"""
57+
pass
58+
59+
@abstractmethod
60+
def initialize_storage(self) -> bool:
61+
"""
62+
Initialize the storage backend for tracking events
63+
64+
Returns:
65+
True if initialization succeeded, False otherwise
66+
"""
67+
pass
68+
69+
@abstractmethod
70+
def close(self) -> None:
71+
"""
72+
Close any open connections to the storage backend
73+
"""
74+
pass

src/mcpm/monitor/duckdb.py

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""
2+
DuckDB implementation of the access monitor
3+
"""
4+
5+
import json
6+
import os
7+
from datetime import datetime
8+
from typing import Any, Dict, Optional, Union
9+
10+
import duckdb
11+
12+
from mcpm.monitor.base import AccessEventType, AccessMonitor
13+
14+
15+
class DuckDBAccessMonitor(AccessMonitor):
16+
"""DuckDB implementation of MCP access monitoring"""
17+
18+
def __init__(self, db_path: str = "~/.config/mcpm/monitor.duckdb"):
19+
"""
20+
Initialize the DuckDB access monitor
21+
22+
Args:
23+
db_path: Path to the DuckDB database file
24+
"""
25+
self.db_path = os.path.expanduser(db_path)
26+
self.db_dir = os.path.dirname(self.db_path)
27+
self.connection = None
28+
29+
def initialize_storage(self) -> bool:
30+
"""Initialize the DuckDB database and tables"""
31+
try:
32+
# Create directory if it doesn't exist
33+
os.makedirs(self.db_dir, exist_ok=True)
34+
35+
# Connect to database
36+
self.connection = duckdb.connect(self.db_path)
37+
38+
# Create a sequence for auto-incrementing IDs
39+
self.connection.execute("""
40+
CREATE SEQUENCE IF NOT EXISTS monitor_events_id_seq START 1;
41+
""")
42+
43+
# Create monitor_events table if it doesn't exist
44+
self.connection.execute("""
45+
CREATE TABLE IF NOT EXISTS monitor_events (
46+
id INTEGER DEFAULT nextval('monitor_events_id_seq') PRIMARY KEY,
47+
event_type VARCHAR NOT NULL,
48+
server_id VARCHAR NOT NULL,
49+
resource_id VARCHAR NOT NULL,
50+
client_id VARCHAR,
51+
timestamp TIMESTAMP NOT NULL,
52+
duration_ms INTEGER,
53+
request_size INTEGER,
54+
response_size INTEGER,
55+
success BOOLEAN NOT NULL,
56+
error_message VARCHAR,
57+
metadata JSON,
58+
raw_request JSON,
59+
raw_response JSON
60+
)
61+
""")
62+
63+
# Create index on timestamp for efficient time-based queries
64+
self.connection.execute("""
65+
CREATE INDEX IF NOT EXISTS idx_monitor_events_timestamp
66+
ON monitor_events (timestamp)
67+
""")
68+
69+
# Create index on server_id for filtering by server
70+
self.connection.execute("""
71+
CREATE INDEX IF NOT EXISTS idx_monitor_events_server
72+
ON monitor_events (server_id)
73+
""")
74+
75+
# Create index on event_type for filtering by event type
76+
self.connection.execute("""
77+
CREATE INDEX IF NOT EXISTS idx_monitor_events_type
78+
ON monitor_events (event_type)
79+
""")
80+
81+
# For backward compatibility, create a view that maps to the old table name
82+
self.connection.execute("""
83+
CREATE VIEW IF NOT EXISTS access_events AS
84+
SELECT * FROM monitor_events
85+
""")
86+
87+
return True
88+
except Exception as e:
89+
print(f"Error initializing DuckDB storage: {e}")
90+
return False
91+
92+
def track_event(
93+
self,
94+
event_type: AccessEventType,
95+
server_id: str,
96+
resource_id: str,
97+
client_id: Optional[str] = None,
98+
timestamp: Optional[datetime] = None,
99+
duration_ms: Optional[int] = None,
100+
request_size: Optional[int] = None,
101+
response_size: Optional[int] = None,
102+
success: bool = True,
103+
error_message: Optional[str] = None,
104+
metadata: Optional[Dict[str, Any]] = None,
105+
raw_request: Optional[Union[Dict[str, Any], str]] = None,
106+
raw_response: Optional[Union[Dict[str, Any], str]] = None,
107+
) -> None:
108+
"""Track an MCP access event"""
109+
# Initialize connection if needed
110+
if self.connection is None:
111+
self.initialize_storage()
112+
113+
# Use current time if no timestamp provided
114+
if timestamp is None:
115+
timestamp = datetime.now()
116+
117+
# Convert metadata to JSON string if provided
118+
metadata_json = json.dumps(metadata) if metadata else None
119+
120+
# Convert raw request and response to JSON strings
121+
# If they're already dictionaries, convert them to JSON strings
122+
# If they're strings, try to parse as JSON first, if that fails, store as JSON-encoded strings
123+
request_json = None
124+
if raw_request is not None:
125+
if isinstance(raw_request, dict):
126+
request_json = json.dumps(raw_request)
127+
else:
128+
try:
129+
# Try to parse as JSON first
130+
json.loads(raw_request)
131+
request_json = raw_request # It's already a valid JSON string
132+
except json.JSONDecodeError:
133+
# Not valid JSON, encode as a JSON string
134+
request_json = json.dumps(raw_request)
135+
136+
response_json = None
137+
if raw_response is not None:
138+
if isinstance(raw_response, dict):
139+
response_json = json.dumps(raw_response)
140+
else:
141+
try:
142+
# Try to parse as JSON first
143+
json.loads(raw_response)
144+
response_json = raw_response # It's already a valid JSON string
145+
except json.JSONDecodeError:
146+
# Not valid JSON, encode as a JSON string
147+
response_json = json.dumps(raw_response)
148+
149+
# Insert event into database
150+
try:
151+
self.connection.execute(
152+
"""
153+
INSERT INTO monitor_events (
154+
event_type, server_id, resource_id, client_id, timestamp,
155+
duration_ms, request_size, response_size,
156+
success, error_message, metadata, raw_request, raw_response
157+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
158+
""",
159+
[
160+
event_type.name,
161+
server_id,
162+
resource_id,
163+
client_id,
164+
timestamp,
165+
duration_ms,
166+
request_size,
167+
response_size,
168+
success,
169+
error_message,
170+
metadata_json,
171+
request_json,
172+
response_json,
173+
],
174+
)
175+
except Exception as e:
176+
print(f"Error tracking event: {e}")
177+
178+
def close(self) -> None:
179+
"""Close the database connection"""
180+
if self.connection:
181+
self.connection.close()
182+
self.connection = None
183+
184+
def __del__(self):
185+
"""Ensure connection is closed when object is deleted"""
186+
self.close()

0 commit comments

Comments
 (0)