Skip to content

Commit 2300395

Browse files
committed
PR feedback updates
1 parent a43e267 commit 2300395

File tree

24 files changed

+636
-192
lines changed

24 files changed

+636
-192
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: 8 additions & 5 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 (
@@ -23,6 +23,9 @@
2323
logging.basicConfig(level=logging.INFO)
2424
logger = logging.getLogger(__name__)
2525

26+
# Authentication scheme.
27+
BEARER_SCHEME = "Bearer"
28+
2629

2730
class AuthPlugin(BasePlugin):
2831
"""Plugin that validates Bearer token authentication."""
@@ -46,17 +49,17 @@ async def GetCapabilities(self, request: Empty, context: ServicerContext) -> Cap
4649

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

5154
# Check for Authorization header.
5255
auth_header = request.headers.get("Authorization", "")
5356

54-
if not auth_header.startswith("Bearer "):
57+
if not auth_header.startswith(f"{BEARER_SCHEME} "):
5558
logger.warning("Missing or invalid Authorization header")
5659
return self._unauthorized_response("Missing or invalid Authorization header")
5760

5861
# Extract and validate token.
59-
token = auth_header[7:] # Remove "Bearer " prefix
62+
token = auth_header.removeprefix(f"{BEARER_SCHEME} ")
6063
if token != self.expected_token:
6164
logger.warning("Invalid token")
6265
return self._unauthorized_response("Invalid token")
@@ -73,7 +76,7 @@ def _unauthorized_response(self, message: str) -> HTTPResponse:
7376
**{"continue": False},
7477
)
7578
response.headers["Content-Type"] = "application/json"
76-
response.headers["WWW-Authenticate"] = "Bearer"
79+
response.headers["WWW-Authenticate"] = BEARER_SCHEME
7780
return response
7881

7982

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: 13 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 (
@@ -47,42 +47,42 @@ async def HandleRequest(self, request: HTTPRequest, context: ServicerContext) ->
4747
"""Log incoming request details."""
4848
logger.info("=" * 80)
4949
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}")
50+
logger.info("Method: %s", request.method)
51+
logger.info("URL: %s", request.url)
52+
logger.info("Path: %s", request.path)
53+
logger.info("Remote Address: %s", request.remote_addr)
5454

5555
# Log headers.
5656
logger.info("Headers:")
5757
for key, value in request.headers.items():
5858
# Mask sensitive headers.
5959
if key.lower() in ("authorization", "cookie"):
6060
value = "***REDACTED***"
61-
logger.info(f" {key}: {value}")
61+
logger.info(" %s: %s", key, value)
6262

6363
# Log body size.
6464
if request.body:
65-
logger.info(f"Body size: {len(request.body)} bytes")
65+
logger.info("Body size: %s bytes", len(request.body))
6666

6767
logger.info("=" * 80)
6868

6969
# Continue processing.
7070
return HTTPResponse(**{"continue": True})
7171

72-
async def HandleResponse(self, request: HTTPResponse, context: ServicerContext) -> HTTPResponse:
72+
async def HandleResponse(self, response: HTTPResponse, context: ServicerContext) -> HTTPResponse:
7373
"""Log outgoing response details."""
7474
logger.info("=" * 80)
7575
logger.info("OUTGOING RESPONSE")
76-
logger.info(f"Status Code: {request.status_code}")
76+
logger.info("Status Code: %s", response.status_code)
7777

7878
# Log headers.
7979
logger.info("Headers:")
80-
for key, value in request.headers.items():
81-
logger.info(f" {key}: {value}")
80+
for key, value in response.headers.items():
81+
logger.info(" %s: %s", key, value)
8282

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

8787
logger.info("=" * 80)
8888

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: 30 additions & 28 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 (
@@ -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)