Skip to content

Commit d630722

Browse files
committed
PR feedback updates
1 parent a43e267 commit d630722

File tree

24 files changed

+698
-206
lines changed

24 files changed

+698
-206
lines changed

.gitignore

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,6 @@ dmypy.json
4848
# Ruff
4949
.ruff_cache/
5050

51-
# Distribution / packaging
52-
*.egg-info/
53-
5451
# Proto source files (downloaded during build, not committed)
5552
tmp/
5653

Makefile

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
1-
.PHONY: help
1+
.PHONY: all help
2+
all: help ## Default target
23
help: ## Show this help message
34
@echo "Available targets:"
45
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
56

67
.PHONY: ensure-scripts-exec
78
ensure-scripts-exec: ## Make scripts executable
8-
chmod +x scripts/*
9+
@if [ -d scripts ]; then chmod +x scripts/*.sh 2>/dev/null || true; fi
910

1011
.PHONY: setup
1112
setup: ensure-scripts-exec ## Setup development environment (installs uv and syncs dependencies)
1213
./scripts/setup_uv.sh
1314

1415
.PHONY: test
15-
test: ## Run tests with pytest
16+
test: setup ## Run tests with pytest
1617
uv run pytest tests/ -v
1718

1819
.PHONY: lint
19-
lint: ## Run pre-commit hooks on all files
20+
lint: setup ## Run pre-commit hooks on all files
2021
uv run pre-commit run --all-files
2122

2223
.PHONY: generate-protos
@@ -39,12 +40,20 @@ build-plugin-prod: ensure-scripts-exec ## Build a plugin with Nuitka for product
3940
fi
4041
./scripts/build_plugin.sh $(PLUGIN) --nuitka
4142

42-
.PHONY: clean
43-
clean: ## Clean generated files and caches
44-
rm -rf tmp/
45-
find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
46-
find . -type f -name "*.pyc" -delete
43+
.PHONY: clean clean-build clean-caches clean-pyc
44+
clean: clean-build clean-caches clean-pyc ## Clean generated files and caches
45+
46+
.PHONY: clean-build
47+
clean-build: ## Clean build artifacts
48+
rm -rf build/ dist/ tmp/
49+
find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true
50+
51+
.PHONY: clean-caches
52+
clean-caches: ## Clean cache directories
4753
find . -type d -name ".pytest_cache" -exec rm -rf {} + 2>/dev/null || true
4854
find . -type d -name ".ruff_cache" -exec rm -rf {} + 2>/dev/null || true
49-
find . -type d -name "*.egg-info" -exec rm -rf {} + 2>/dev/null || true
50-
rm -rf build/ dist/
55+
find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
56+
57+
.PHONY: clean-pyc
58+
clean-pyc: ## Clean Python bytecode files
59+
find . -type f -name "*.pyc" -delete

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ When handling requests or responses, you can:
116116
The SDK includes five example plugins demonstrating common patterns:
117117

118118
### 1. Simple Plugin
119+
119120
Adds a custom header to all requests.
120121

121122
```bash
@@ -124,6 +125,7 @@ uv run python main.py
124125
```
125126

126127
### 2. Auth Plugin
128+
127129
Validates Bearer token authentication and rejects unauthorized requests.
128130

129131
```bash
@@ -133,6 +135,7 @@ uv run python main.py
133135
```
134136

135137
### 3. Logging Plugin
138+
136139
Logs HTTP request and response details for observability.
137140

138141
```bash
@@ -141,6 +144,7 @@ uv run python main.py
141144
```
142145

143146
### 4. Rate Limit Plugin
147+
144148
Implements token bucket rate limiting per client IP.
145149

146150
```bash
@@ -149,6 +153,7 @@ uv run python main.py
149153
```
150154

151155
### 5. Transform Plugin
156+
152157
Transforms JSON request bodies by adding metadata fields.
153158

154159
```bash
@@ -214,24 +219,22 @@ class BasePlugin(PluginServicer):
214219
async def CheckHealth(self, request: Empty, context) -> Empty
215220
async def CheckReady(self, request: Empty, context) -> Empty
216221
async def HandleRequest(self, request: HTTPRequest, context) -> HTTPResponse
217-
async def HandleResponse(self, request: HTTPResponse, context) -> HTTPResponse
222+
async def HandleResponse(self, response: HTTPResponse, context) -> HTTPResponse
218223
```
219224

220-
### serve()
225+
### `serve()`
221226

222227
```python
223228
async def serve(
224229
plugin: BasePlugin,
225230
args: Optional[list[str]] = None, # Command-line arguments (typically sys.argv)
226-
max_workers: int = 10,
227231
grace_period: float = 5.0,
228232
) -> None
229233
```
230234

231235
**Parameters:**
232236
- `plugin`: The plugin instance to serve
233237
- `args`: Command-line arguments. When provided (e.g., `sys.argv`), enables mcpd compatibility by parsing `--address` and `--network` flags. When `None`, runs in standalone mode on TCP port 50051.
234-
- `max_workers`: Maximum number of concurrent gRPC workers
235238
- `grace_period`: Seconds to wait during graceful shutdown
236239

237240
**Command-line flags** (when `args` is provided):

examples/auth_plugin/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
"""Authentication plugin example."""
2+
3+
from .main import AuthPlugin
4+
5+
__all__ = ["AuthPlugin"]

examples/auth_plugin/main.py

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
"""
55

66
import asyncio
7+
import json
78
import logging
89
import os
910
import sys
1011

1112
from google.protobuf.empty_pb2 import Empty
12-
from grpc import ServicerContext
13+
from grpc.aio import ServicerContext
1314

1415
from mcpd_plugins import BasePlugin, serve
1516
from mcpd_plugins.v1.plugins.plugin_pb2 import (
@@ -23,11 +24,14 @@
2324
logging.basicConfig(level=logging.INFO)
2425
logger = logging.getLogger(__name__)
2526

27+
# Authentication scheme.
28+
BEARER_SCHEME = "Bearer"
29+
2630

2731
class AuthPlugin(BasePlugin):
2832
"""Plugin that validates Bearer token authentication."""
2933

30-
def __init__(self):
34+
def __init__(self) -> None:
3135
"""Initialize the auth plugin with expected token."""
3236
super().__init__()
3337
self.expected_token = os.getenv("AUTH_TOKEN", "secret-token-123")
@@ -46,17 +50,17 @@ async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Cap
4650

4751
async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse:
4852
"""Validate Bearer token in Authorization header."""
49-
logger.info(f"Authenticating request: {request.method} {request.url}")
53+
logger.info("Authenticating request: %s %s", request.method, request.url)
5054

5155
# Check for Authorization header.
5256
auth_header = request.headers.get("Authorization", "")
5357

54-
if not auth_header.startswith("Bearer "):
58+
if not auth_header.startswith(f"{BEARER_SCHEME} "):
5559
logger.warning("Missing or invalid Authorization header")
5660
return self._unauthorized_response("Missing or invalid Authorization header")
5761

5862
# Extract and validate token.
59-
token = auth_header[7:] # Remove "Bearer " prefix
63+
token = auth_header.removeprefix(f"{BEARER_SCHEME} ")
6064
if token != self.expected_token:
6165
logger.warning("Invalid token")
6266
return self._unauthorized_response("Invalid token")
@@ -69,11 +73,13 @@ def _unauthorized_response(self, message: str) -> HTTPResponse:
6973
"""Create a 401 Unauthorized response."""
7074
response = HTTPResponse(
7175
status_code=401,
72-
body=f'{{"error": "{message}"}}'.encode(),
76+
body=json.dumps({"error": message}).encode(),
7377
**{"continue": False},
7478
)
7579
response.headers["Content-Type"] = "application/json"
76-
response.headers["WWW-Authenticate"] = "Bearer"
80+
response.headers["WWW-Authenticate"] = (
81+
f'{BEARER_SCHEME} realm="mcpd", error="invalid_token", error_description="{message}"'
82+
)
7783
return response
7884

7985

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
"""Logging plugin example."""
2+
3+
from .main import LoggingPlugin
4+
5+
__all__ = ["LoggingPlugin"]

examples/logging_plugin/main.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import sys
1010

1111
from google.protobuf.empty_pb2 import Empty
12-
from grpc import ServicerContext
12+
from grpc.aio import ServicerContext
1313

1414
from mcpd_plugins import BasePlugin, serve
1515
from mcpd_plugins.v1.plugins.plugin_pb2 import (
@@ -33,6 +33,7 @@ class LoggingPlugin(BasePlugin):
3333

3434
async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadata:
3535
"""Return plugin metadata."""
36+
_ = (request, context)
3637
return Metadata(
3738
name="logging-plugin",
3839
version="1.0.0",
@@ -41,48 +42,51 @@ async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadat
4142

4243
async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Capabilities:
4344
"""Declare support for both request and response flows."""
45+
_ = (request, context)
4446
return Capabilities(flows=[FLOW_REQUEST, FLOW_RESPONSE])
4547

4648
async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse:
4749
"""Log incoming request details."""
50+
_ = context
4851
logger.info("=" * 80)
4952
logger.info("INCOMING REQUEST")
50-
logger.info(f"Method: {request.method}")
51-
logger.info(f"URL: {request.url}")
52-
logger.info(f"Path: {request.path}")
53-
logger.info(f"Remote Address: {request.remote_addr}")
53+
logger.info("Method: %s", request.method)
54+
logger.info("URL: %s", request.url)
55+
logger.info("Path: %s", request.path)
56+
logger.info("Remote Address: %s", request.remote_addr)
5457

5558
# Log headers.
5659
logger.info("Headers:")
5760
for key, value in request.headers.items():
5861
# Mask sensitive headers.
5962
if key.lower() in ("authorization", "cookie"):
6063
value = "***REDACTED***"
61-
logger.info(f" {key}: {value}")
64+
logger.info(" %s: %s", key, value)
6265

6366
# Log body size.
6467
if request.body:
65-
logger.info(f"Body size: {len(request.body)} bytes")
68+
logger.info("Body size: %s bytes", len(request.body))
6669

6770
logger.info("=" * 80)
6871

6972
# Continue processing.
7073
return HTTPResponse(**{"continue": True})
7174

72-
async def HandleResponse(self, request: HTTPResponse, context: ServicerContext) -> HTTPResponse:
75+
async def HandleResponse(self, response: HTTPResponse, context: ServicerContext) -> HTTPResponse:
7376
"""Log outgoing response details."""
77+
_ = context
7478
logger.info("=" * 80)
7579
logger.info("OUTGOING RESPONSE")
76-
logger.info(f"Status Code: {request.status_code}")
80+
logger.info("Status Code: %s", response.status_code)
7781

7882
# Log headers.
7983
logger.info("Headers:")
80-
for key, value in request.headers.items():
81-
logger.info(f" {key}: {value}")
84+
for key, value in response.headers.items():
85+
logger.info(" %s: %s", key, value)
8286

8387
# Log body size.
84-
if request.body:
85-
logger.info(f"Body size: {len(request.body)} bytes")
88+
if response.body:
89+
logger.info("Body size: %s bytes", len(response.body))
8690

8791
logger.info("=" * 80)
8892

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
"""Rate limiting plugin example."""
2+
3+
from .main import RateLimitPlugin
4+
5+
__all__ = ["RateLimitPlugin"]

examples/rate_limit_plugin/main.py

Lines changed: 31 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from collections import defaultdict
1111

1212
from google.protobuf.empty_pb2 import Empty
13-
from grpc import ServicerContext
13+
from grpc.aio import ServicerContext
1414

1515
from mcpd_plugins import BasePlugin, serve
1616
from mcpd_plugins.v1.plugins.plugin_pb2 import (
@@ -28,7 +28,7 @@
2828
class RateLimitPlugin(BasePlugin):
2929
"""Plugin that implements rate limiting using token bucket algorithm."""
3030

31-
def __init__(self, requests_per_minute: int = 60):
31+
def __init__(self, requests_per_minute: int = 60) -> None:
3232
"""Initialize the rate limiter.
3333
3434
Args:
@@ -41,6 +41,7 @@ def __init__(self, requests_per_minute: int = 60):
4141
# Track tokens for each client IP.
4242
self.buckets: dict[str, float] = defaultdict(lambda: float(requests_per_minute))
4343
self.last_update: dict[str, float] = defaultdict(time.time)
44+
self.locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
4445

4546
async def GetMetadata(self, request: Empty, context: ServicerContext) -> Metadata:
4647
"""Return plugin metadata."""
@@ -57,33 +58,34 @@ async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Cap
5758
async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) -> HTTPResponse:
5859
"""Apply rate limiting based on client IP."""
5960
client_ip = request.remote_addr or "unknown"
60-
logger.info(f"Rate limit check for {client_ip}: {request.method} {request.url}")
61-
62-
# Refill tokens based on time elapsed.
63-
now = time.time()
64-
elapsed = now - self.last_update[client_ip]
65-
self.buckets[client_ip] = min(
66-
self.requests_per_minute,
67-
self.buckets[client_ip] + elapsed * self.rate_per_second,
68-
)
69-
self.last_update[client_ip] = now
70-
71-
# Check if client has tokens available.
72-
if self.buckets[client_ip] < 1.0:
73-
logger.warning(f"Rate limit exceeded for {client_ip}")
74-
return self._rate_limit_response(client_ip)
75-
76-
# Consume one token.
77-
self.buckets[client_ip] -= 1.0
78-
logger.info(f"Request allowed for {client_ip} (tokens remaining: {self.buckets[client_ip]:.2f})")
79-
80-
# Add rate limit headers to response.
81-
response = HTTPResponse(**{"continue": True})
82-
response.modified_request.CopyFrom(request)
83-
response.headers["X-RateLimit-Limit"] = str(self.requests_per_minute)
84-
response.headers["X-RateLimit-Remaining"] = str(int(self.buckets[client_ip]))
85-
86-
return response
61+
logger.info("Rate limit check for %s: %s %s", client_ip, request.method, request.url)
62+
63+
async with self.locks[client_ip]:
64+
# Refill tokens based on time elapsed.
65+
now = time.time()
66+
elapsed = now - self.last_update[client_ip]
67+
self.buckets[client_ip] = min(
68+
self.requests_per_minute,
69+
self.buckets[client_ip] + elapsed * self.rate_per_second,
70+
)
71+
self.last_update[client_ip] = now
72+
73+
# Check if client has tokens available.
74+
if self.buckets[client_ip] < 1.0:
75+
logger.warning("Rate limit exceeded for %s", client_ip)
76+
return self._rate_limit_response(client_ip)
77+
78+
# Consume one token.
79+
self.buckets[client_ip] -= 1.0
80+
logger.info("Request allowed for %s (tokens remaining: %.2f)", client_ip, self.buckets[client_ip])
81+
82+
# Add rate limit headers to response.
83+
response = HTTPResponse(**{"continue": True})
84+
response.modified_request.CopyFrom(request)
85+
response.headers["X-RateLimit-Limit"] = str(self.requests_per_minute)
86+
response.headers["X-RateLimit-Remaining"] = str(int(self.buckets[client_ip]))
87+
88+
return response
8789

8890
def _rate_limit_response(self, client_ip: str) -> HTTPResponse:
8991
"""Create a 429 Too Many Requests response."""

examples/simple_plugin/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
11
"""Simple plugin example."""
2+
3+
from .main import SimplePlugin
4+
5+
__all__ = ["SimplePlugin"]

0 commit comments

Comments
 (0)