generated from personal-projects/leo-claude-mktplace
Create comprehensive sprint planning documentation for Core Architecture Correction sprint. This addresses three fatal architectural problems from v1.0.0 release. Sprint documents include: - Executive proposal with architecture analysis - Detailed implementation guide with code snippets - Issue breakdown with dependencies - Sprint summary with approval checklist Sprint creates 10 issues in Gitea milestone 29: - Issues #19-28 covering package rename, MCP protocol implementation, Docker infrastructure, testing, and documentation - Total estimated effort: 19-28 hours (1 week sprint) - All issues properly sized (S/M), labeled, and dependency-tracked This is attempt #3 - all details from architectural correction prompt have been captured. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1455 lines
33 KiB
Markdown
1455 lines
33 KiB
Markdown
# Sprint 01: Implementation Guide - Core Architecture Correction
|
|
|
|
**Related Proposal:** [sprint-01-core-architecture-correction.md](./sprint-01-core-architecture-correction.md)
|
|
|
|
This guide provides step-by-step technical implementation details for each file change.
|
|
|
|
---
|
|
|
|
## Phase 1: Package Restructuring (Issues #1-2)
|
|
|
|
### Issue #1: Rename Package Directory
|
|
|
|
**Estimated Time:** 15 minutes
|
|
**Dependencies:** None
|
|
**Type:** Refactor
|
|
|
|
**Steps:**
|
|
```bash
|
|
cd /home/lmiranda/gitea-mcp-remote
|
|
git mv src/gitea_http_wrapper src/gitea_mcp_remote
|
|
```
|
|
|
|
**Validation:**
|
|
```bash
|
|
# Should exist
|
|
ls -la src/gitea_mcp_remote/
|
|
|
|
# Should NOT exist
|
|
ls -la src/gitea_http_wrapper/ 2>&1 | grep "No such file"
|
|
```
|
|
|
|
---
|
|
|
|
### Issue #2: Update Configuration Module
|
|
|
|
**Estimated Time:** 1-2 hours
|
|
**Dependencies:** Issue #1
|
|
**Files:** `src/gitea_mcp_remote/config/settings.py`, `src/gitea_mcp_remote/config/__init__.py`
|
|
|
|
**Changes to `settings.py`:**
|
|
|
|
```python
|
|
# Line 1: Update docstring
|
|
"""Configuration settings for Gitea MCP HTTP transport."""
|
|
|
|
# Lines 33-36: Make gitea_repo optional
|
|
gitea_repo: str | None = Field(
|
|
default=None,
|
|
description="Default repository name (optional)",
|
|
)
|
|
|
|
# Lines 39-45: Update HTTP defaults
|
|
http_host: str = Field(
|
|
default="0.0.0.0",
|
|
description="HTTP server bind address",
|
|
)
|
|
http_port: int = Field(
|
|
default=8080,
|
|
ge=1,
|
|
le=65535,
|
|
description="HTTP server port",
|
|
)
|
|
|
|
# After line 54 (after auth_token): Add new field
|
|
mcp_auth_mode: str = Field(
|
|
default="optional",
|
|
description="MCP authentication mode: 'required', 'optional', or 'none'",
|
|
)
|
|
|
|
# Delete lines 88-95: Remove get_gitea_mcp_env() method
|
|
# (No longer needed - we use direct Python imports, not subprocess)
|
|
```
|
|
|
|
**No import changes needed in this file** (it doesn't import from gitea_http_wrapper).
|
|
|
|
**Update `__init__.py`:**
|
|
```python
|
|
"""Configuration module for Gitea MCP HTTP transport."""
|
|
|
|
from gitea_mcp_remote.config.settings import GiteaSettings, load_settings
|
|
|
|
__all__ = ["GiteaSettings", "load_settings"]
|
|
```
|
|
|
|
**Validation:**
|
|
```python
|
|
# Test in Python REPL
|
|
from gitea_mcp_remote.config import GiteaSettings
|
|
|
|
# Should have new field
|
|
assert hasattr(GiteaSettings, 'mcp_auth_mode')
|
|
|
|
# Should have optional gitea_repo
|
|
settings = GiteaSettings(
|
|
gitea_url="https://test.com",
|
|
gitea_token="test",
|
|
gitea_owner="test"
|
|
# gitea_repo is optional now
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 2: Update Supporting Modules (Issues #3-4)
|
|
|
|
### Issue #3: Update Middleware Module
|
|
|
|
**Estimated Time:** 30 minutes
|
|
**Dependencies:** Issue #1
|
|
**Files:** `src/gitea_mcp_remote/middleware/auth.py`, `src/gitea_mcp_remote/middleware/__init__.py`
|
|
|
|
**Changes to `auth.py`:**
|
|
- Keep ALL logic unchanged
|
|
- Only update imports
|
|
|
|
**Changes to `__init__.py`:**
|
|
```python
|
|
"""Middleware components for MCP HTTP transport."""
|
|
|
|
from gitea_mcp_remote.middleware.auth import (
|
|
BearerAuthMiddleware,
|
|
HealthCheckBypassMiddleware,
|
|
)
|
|
|
|
__all__ = [
|
|
"BearerAuthMiddleware",
|
|
"HealthCheckBypassMiddleware",
|
|
]
|
|
```
|
|
|
|
**Validation:**
|
|
```python
|
|
from gitea_mcp_remote.middleware import BearerAuthMiddleware
|
|
assert BearerAuthMiddleware is not None
|
|
```
|
|
|
|
---
|
|
|
|
### Issue #4: Update Filtering Module
|
|
|
|
**Estimated Time:** 45 minutes
|
|
**Dependencies:** Issue #1
|
|
**Files:** `src/gitea_mcp_remote/filtering/filter.py`, `src/gitea_mcp_remote/filtering/__init__.py`
|
|
|
|
**Changes to `filter.py`:**
|
|
|
|
```python
|
|
# Line 1: Update docstring
|
|
"""Tool filtering for MCP client compatibility."""
|
|
|
|
# Add import at top
|
|
import logging
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Lines 29-32: Change ValueError to warning
|
|
if enabled_tools is not None and disabled_tools is not None:
|
|
logger.warning(
|
|
"Both enabled_tools and disabled_tools specified. "
|
|
"Using disabled_tools (blacklist mode). "
|
|
"Recommendation: Choose one filtering mode."
|
|
)
|
|
# Continue with disabled_tools taking precedence
|
|
```
|
|
|
|
**Changes to `__init__.py`:**
|
|
```python
|
|
"""Tool filtering module for MCP HTTP transport."""
|
|
|
|
from gitea_mcp_remote.filtering.filter import ToolFilter
|
|
|
|
__all__ = ["ToolFilter"]
|
|
```
|
|
|
|
**Validation:**
|
|
```python
|
|
from gitea_mcp_remote.filtering import ToolFilter
|
|
|
|
# Should log warning, not raise
|
|
filter = ToolFilter(
|
|
enabled_tools=["tool1"],
|
|
disabled_tools=["tool2"]
|
|
)
|
|
# Should use disabled_tools (blacklist mode)
|
|
assert filter.disabled_tools == {"tool2"}
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 3: Relocate and Update Tests (Issues #5-6)
|
|
|
|
### Issue #5: Move Tests to Root
|
|
|
|
**Estimated Time:** 30 minutes
|
|
**Dependencies:** Issue #1
|
|
**Type:** Refactor
|
|
|
|
**Steps:**
|
|
```bash
|
|
cd /home/lmiranda/gitea-mcp-remote
|
|
git mv src/gitea_mcp_remote/tests tests
|
|
```
|
|
|
|
**Validation:**
|
|
```bash
|
|
# Should exist
|
|
ls -la tests/test_config.py
|
|
ls -la tests/test_middleware.py
|
|
ls -la tests/test_filtering.py
|
|
ls -la tests/conftest.py
|
|
|
|
# Should NOT exist
|
|
ls -la src/gitea_mcp_remote/tests/ 2>&1 | grep "No such file"
|
|
```
|
|
|
|
---
|
|
|
|
### Issue #6: Update Test Imports
|
|
|
|
**Estimated Time:** 1 hour
|
|
**Dependencies:** Issue #5
|
|
**Files:** All files in `tests/` directory
|
|
|
|
**Global search-replace in all test files:**
|
|
```python
|
|
# OLD
|
|
from gitea_http_wrapper.config import ...
|
|
from gitea_http_wrapper.middleware import ...
|
|
from gitea_http_wrapper.filtering import ...
|
|
|
|
# NEW
|
|
from gitea_mcp_remote.config import ...
|
|
from gitea_mcp_remote.middleware import ...
|
|
from gitea_mcp_remote.filtering import ...
|
|
```
|
|
|
|
**Specific files to update:**
|
|
- `tests/conftest.py`
|
|
- `tests/test_config.py`
|
|
- `tests/test_middleware.py`
|
|
- `tests/test_filtering.py`
|
|
|
|
**Validation:**
|
|
```bash
|
|
pytest tests/ -v
|
|
# All existing tests should pass
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 4: Core Server Replacement (Issues #7-8)
|
|
|
|
### Issue #7: Remove Old Server
|
|
|
|
**Estimated Time:** 5 minutes
|
|
**Dependencies:** Issues #2-6 (ensure all imports work first)
|
|
**Type:** Deletion
|
|
|
|
**Steps:**
|
|
```bash
|
|
git rm src/gitea_mcp_remote/server.py
|
|
```
|
|
|
|
**Validation:**
|
|
```bash
|
|
ls -la src/gitea_mcp_remote/server.py 2>&1 | grep "No such file"
|
|
```
|
|
|
|
---
|
|
|
|
### Issue #8: Create New MCP HTTP Server
|
|
|
|
**Estimated Time:** 4-6 hours
|
|
**Dependencies:** Issue #7
|
|
**Files:** `src/gitea_mcp_remote/server_http.py`
|
|
|
|
**Complete new file:**
|
|
|
|
```python
|
|
"""MCP HTTP transport server for Gitea operations.
|
|
|
|
This server implements the MCP Streamable HTTP protocol, providing
|
|
JSON-RPC 2.0 communication with Claude Desktop clients.
|
|
"""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
from typing import Any
|
|
|
|
import uvicorn
|
|
from starlette.applications import Starlette
|
|
from starlette.requests import Request
|
|
from starlette.responses import JSONResponse, Response
|
|
from starlette.routing import Route
|
|
|
|
# Import from marketplace gitea-mcp-server
|
|
from mcp_server import (
|
|
GiteaClient,
|
|
GiteaConfig,
|
|
create_tool_dispatcher,
|
|
get_tool_definitions,
|
|
)
|
|
|
|
from gitea_mcp_remote.config import GiteaSettings, load_settings
|
|
from gitea_mcp_remote.filtering import ToolFilter
|
|
from gitea_mcp_remote.middleware import (
|
|
BearerAuthMiddleware,
|
|
HealthCheckBypassMiddleware,
|
|
)
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# MCP Protocol version
|
|
MCP_VERSION = "2024-11-05"
|
|
|
|
|
|
class GiteaMCPServer:
|
|
"""
|
|
MCP HTTP transport server for Gitea.
|
|
|
|
Implements MCP Streamable HTTP protocol with JSON-RPC 2.0.
|
|
"""
|
|
|
|
def __init__(self, settings: GiteaSettings):
|
|
"""Initialize MCP server with settings."""
|
|
self.settings = settings
|
|
|
|
# Initialize Gitea client
|
|
self.gitea_config = GiteaConfig(
|
|
base_url=settings.gitea_url,
|
|
api_token=settings.gitea_token,
|
|
default_owner=settings.gitea_owner,
|
|
default_repo=settings.gitea_repo,
|
|
)
|
|
self.gitea_client = GiteaClient(self.gitea_config)
|
|
|
|
# Initialize tool filtering
|
|
self.tool_filter = ToolFilter(
|
|
enabled_tools=settings.enabled_tools_list,
|
|
disabled_tools=settings.disabled_tools_list,
|
|
)
|
|
|
|
# Get tool definitions and create dispatcher
|
|
self.tool_definitions = get_tool_definitions()
|
|
self.tool_dispatcher = create_tool_dispatcher(self.gitea_client)
|
|
|
|
logger.info(f"Initialized MCP server for {settings.gitea_url}")
|
|
logger.info(f"Tool filtering: {self.tool_filter.get_filter_stats()}")
|
|
|
|
async def handle_list_tools(self, params: dict) -> dict:
|
|
"""Handle tools/list MCP method."""
|
|
# Get all tool definitions
|
|
tools = self.tool_definitions
|
|
|
|
# Apply filtering
|
|
filtered_tools = self.tool_filter.filter_tools_list(tools)
|
|
|
|
logger.info(f"Listed {len(filtered_tools)} tools (filtered from {len(tools)})")
|
|
|
|
return {
|
|
"tools": filtered_tools
|
|
}
|
|
|
|
async def handle_call_tool(self, params: dict) -> dict:
|
|
"""Handle tools/call MCP method."""
|
|
tool_name = params.get("name")
|
|
arguments = params.get("arguments", {})
|
|
|
|
# Check if tool is filtered
|
|
if not self.tool_filter.should_include_tool(tool_name):
|
|
logger.warning(f"Tool '{tool_name}' is filtered out")
|
|
raise ValueError(f"Tool '{tool_name}' is not available")
|
|
|
|
logger.info(f"Calling tool: {tool_name}")
|
|
|
|
# Dispatch to tool handler
|
|
result = await self.tool_dispatcher(tool_name, arguments)
|
|
|
|
return {
|
|
"content": result
|
|
}
|
|
|
|
async def handle_initialize(self, params: dict) -> dict:
|
|
"""Handle initialize MCP method."""
|
|
return {
|
|
"protocolVersion": MCP_VERSION,
|
|
"serverInfo": {
|
|
"name": "gitea-mcp-remote",
|
|
"version": "1.1.0",
|
|
},
|
|
"capabilities": {
|
|
"tools": {}
|
|
}
|
|
}
|
|
|
|
async def handle_jsonrpc_request(self, request_data: dict) -> dict:
|
|
"""Handle JSON-RPC 2.0 request."""
|
|
method = request_data.get("method")
|
|
params = request_data.get("params", {})
|
|
request_id = request_data.get("id")
|
|
|
|
try:
|
|
# Route to appropriate handler
|
|
if method == "initialize":
|
|
result = await self.handle_initialize(params)
|
|
elif method == "tools/list":
|
|
result = await self.handle_list_tools(params)
|
|
elif method == "tools/call":
|
|
result = await self.handle_call_tool(params)
|
|
else:
|
|
raise ValueError(f"Unknown method: {method}")
|
|
|
|
# Success response
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"result": result,
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.exception(f"Error handling {method}")
|
|
|
|
# Error response
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"error": {
|
|
"code": -32000,
|
|
"message": str(e),
|
|
}
|
|
}
|
|
|
|
|
|
# Global server instance
|
|
mcp_server: GiteaMCPServer | None = None
|
|
|
|
|
|
async def mcp_endpoint_head(request: Request) -> Response:
|
|
"""
|
|
Handle HEAD /mcp - Protocol version check.
|
|
|
|
Returns MCP protocol version in X-MCP-Version header.
|
|
"""
|
|
return Response(
|
|
status_code=200,
|
|
headers={
|
|
"X-MCP-Version": MCP_VERSION,
|
|
}
|
|
)
|
|
|
|
|
|
async def mcp_endpoint_post(request: Request) -> JSONResponse:
|
|
"""
|
|
Handle POST /mcp - JSON-RPC 2.0 messages.
|
|
|
|
Main MCP communication endpoint.
|
|
"""
|
|
try:
|
|
# Parse JSON-RPC request
|
|
request_data = await request.json()
|
|
|
|
# Handle request
|
|
response_data = await mcp_server.handle_jsonrpc_request(request_data)
|
|
|
|
return JSONResponse(response_data)
|
|
|
|
except json.JSONDecodeError:
|
|
return JSONResponse(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": None,
|
|
"error": {
|
|
"code": -32700,
|
|
"message": "Parse error: Invalid JSON",
|
|
}
|
|
},
|
|
status_code=400,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.exception("Error in MCP endpoint")
|
|
return JSONResponse(
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": None,
|
|
"error": {
|
|
"code": -32603,
|
|
"message": f"Internal error: {str(e)}",
|
|
}
|
|
},
|
|
status_code=500,
|
|
)
|
|
|
|
|
|
async def health_check(request: Request) -> JSONResponse:
|
|
"""Health check endpoint."""
|
|
return JSONResponse({"status": "healthy"})
|
|
|
|
|
|
async def startup() -> None:
|
|
"""Application startup handler."""
|
|
global mcp_server
|
|
settings = load_settings()
|
|
mcp_server = GiteaMCPServer(settings)
|
|
logger.info(f"MCP HTTP server starting on {settings.http_host}:{settings.http_port}")
|
|
|
|
|
|
# Define routes
|
|
routes = [
|
|
# MCP protocol endpoints
|
|
Route("/mcp", mcp_endpoint_post, methods=["POST"]),
|
|
Route("/mcp", mcp_endpoint_head, methods=["HEAD"]),
|
|
|
|
# Health check endpoints
|
|
Route("/health", health_check, methods=["GET"]),
|
|
Route("/healthz", health_check, methods=["GET"]),
|
|
Route("/ping", health_check, methods=["GET"]),
|
|
]
|
|
|
|
# Create Starlette app
|
|
app = Starlette(
|
|
routes=routes,
|
|
on_startup=[startup],
|
|
)
|
|
|
|
|
|
def create_app(settings: GiteaSettings | None = None) -> Starlette:
|
|
"""
|
|
Create and configure the Starlette application.
|
|
|
|
Args:
|
|
settings: Optional settings override for testing.
|
|
|
|
Returns:
|
|
Configured Starlette application.
|
|
"""
|
|
if settings is None:
|
|
settings = load_settings()
|
|
|
|
# Add middleware
|
|
app.add_middleware(HealthCheckBypassMiddleware)
|
|
|
|
if settings.mcp_auth_mode == "required":
|
|
app.add_middleware(BearerAuthMiddleware, auth_token=settings.auth_token)
|
|
|
|
return app
|
|
|
|
|
|
def main() -> None:
|
|
"""Main entry point for the MCP HTTP server."""
|
|
settings = load_settings()
|
|
|
|
# Log configuration
|
|
logger.info(f"MCP Protocol Version: {MCP_VERSION}")
|
|
logger.info(f"Gitea URL: {settings.gitea_url}")
|
|
logger.info(f"Auth mode: {settings.mcp_auth_mode}")
|
|
|
|
# Run server
|
|
uvicorn.run(
|
|
"gitea_mcp_remote.server_http:app",
|
|
host=settings.http_host,
|
|
port=settings.http_port,
|
|
log_level="info",
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|
|
```
|
|
|
|
**Validation:**
|
|
```bash
|
|
# Should import successfully
|
|
python3 -c "from gitea_mcp_remote.server_http import GiteaMCPServer"
|
|
|
|
# Check MCP endpoints exist
|
|
python3 -c "from gitea_mcp_remote.server_http import app; print([r.path for r in app.routes])"
|
|
# Should show: ['/mcp', '/mcp', '/health', '/healthz', '/ping']
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 5: Update Project Configuration (Issues #9-10)
|
|
|
|
### Issue #9: Replace pyproject.toml
|
|
|
|
**Estimated Time:** 30 minutes
|
|
**Dependencies:** Issue #8
|
|
**File:** `pyproject.toml`
|
|
|
|
**Complete replacement:**
|
|
|
|
```toml
|
|
[build-system]
|
|
requires = ["setuptools>=61.0", "wheel"]
|
|
build-backend = "setuptools.build_meta"
|
|
|
|
[project]
|
|
name = "gitea-mcp-remote"
|
|
version = "1.1.0"
|
|
description = "MCP HTTP transport for Gitea operations"
|
|
readme = "README.md"
|
|
requires-python = ">=3.10"
|
|
license = { text = "MIT" }
|
|
authors = [
|
|
{ name = "Leo Miranda", email = "lmiranda@example.com" }
|
|
]
|
|
keywords = ["mcp", "gitea", "model-context-protocol", "http-transport"]
|
|
classifiers = [
|
|
"Development Status :: 4 - Beta",
|
|
"Intended Audience :: Developers",
|
|
"License :: OSI Approved :: MIT License",
|
|
"Programming Language :: Python :: 3",
|
|
"Programming Language :: Python :: 3.10",
|
|
"Programming Language :: Python :: 3.11",
|
|
"Programming Language :: Python :: 3.12",
|
|
]
|
|
|
|
dependencies = [
|
|
"gitea-mcp-server @ git+https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace.git#subdirectory=mcp-servers/gitea",
|
|
"mcp>=0.9.0",
|
|
"uvicorn>=0.27.0",
|
|
"pydantic>=2.0.0",
|
|
"pydantic-settings>=2.0.0",
|
|
"python-dotenv>=1.0.0",
|
|
"starlette>=0.36.0",
|
|
]
|
|
|
|
[project.optional-dependencies]
|
|
dev = [
|
|
"pytest>=7.0.0",
|
|
"pytest-asyncio>=0.21.0",
|
|
"pytest-cov>=4.0.0",
|
|
"httpx>=0.24.0",
|
|
]
|
|
|
|
[project.scripts]
|
|
gitea-mcp-remote = "gitea_mcp_remote.server_http:main"
|
|
|
|
[project.urls]
|
|
Homepage = "https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote"
|
|
Repository = "https://gitea.hotserv.cloud/personal-projects/gitea-mcp-remote"
|
|
|
|
[tool.setuptools.packages.find]
|
|
where = ["src"]
|
|
|
|
[tool.pytest.ini_options]
|
|
asyncio_mode = "auto"
|
|
testpaths = ["tests"]
|
|
python_files = ["test_*.py"]
|
|
python_classes = ["Test*"]
|
|
python_functions = ["test_*"]
|
|
```
|
|
|
|
**Validation:**
|
|
```bash
|
|
pip install -e .
|
|
# Should install successfully including marketplace dependency
|
|
|
|
gitea-mcp-remote --help
|
|
# Should show help (if we add --help support) or start server
|
|
```
|
|
|
|
---
|
|
|
|
### Issue #10: Update pytest.ini
|
|
|
|
**Estimated Time:** 5 minutes
|
|
**Dependencies:** Issue #9
|
|
**File:** `pytest.ini`
|
|
|
|
**Changes:**
|
|
```ini
|
|
[pytest]
|
|
asyncio_mode = auto
|
|
testpaths = tests
|
|
python_files = test_*.py
|
|
python_classes = Test*
|
|
python_functions = test_*
|
|
addopts = -v --strict-markers
|
|
```
|
|
|
|
**Validation:**
|
|
```bash
|
|
pytest --collect-only
|
|
# Should collect tests from tests/ directory
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 6: Docker Infrastructure (Issues #11-14)
|
|
|
|
### Issue #11: Create Docker Directory Structure
|
|
|
|
**Estimated Time:** 15 minutes
|
|
**Dependencies:** None
|
|
**Type:** Setup
|
|
|
|
**Steps:**
|
|
```bash
|
|
mkdir -p docker
|
|
```
|
|
|
|
**Validation:**
|
|
```bash
|
|
ls -la docker/
|
|
```
|
|
|
|
---
|
|
|
|
### Issue #12: Create Docker Compose Configuration
|
|
|
|
**Estimated Time:** 1-2 hours
|
|
**Dependencies:** Issue #11
|
|
**File:** `docker/docker-compose.yml`
|
|
|
|
**Complete file:**
|
|
|
|
```yaml
|
|
version: '3.8'
|
|
|
|
services:
|
|
app:
|
|
build:
|
|
context: ..
|
|
dockerfile: docker/Dockerfile
|
|
container_name: gitea-mcp-remote-app
|
|
restart: unless-stopped
|
|
environment:
|
|
# Gitea configuration
|
|
GITEA_URL: ${GITEA_URL}
|
|
GITEA_TOKEN: ${GITEA_TOKEN}
|
|
GITEA_OWNER: ${GITEA_OWNER}
|
|
GITEA_REPO: ${GITEA_REPO:-}
|
|
|
|
# HTTP server
|
|
HTTP_HOST: 0.0.0.0
|
|
HTTP_PORT: 8080
|
|
|
|
# Authentication
|
|
AUTH_TOKEN: ${AUTH_TOKEN:-}
|
|
MCP_AUTH_MODE: ${MCP_AUTH_MODE:-optional}
|
|
|
|
# Tool filtering
|
|
ENABLED_TOOLS: ${ENABLED_TOOLS:-}
|
|
DISABLED_TOOLS: ${DISABLED_TOOLS:-}
|
|
ports:
|
|
- "8080:8080"
|
|
healthcheck:
|
|
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
|
|
interval: 30s
|
|
timeout: 10s
|
|
retries: 3
|
|
start_period: 40s
|
|
networks:
|
|
- mcp-network
|
|
|
|
caddy:
|
|
image: caddy:2-alpine
|
|
container_name: gitea-mcp-remote-caddy
|
|
restart: unless-stopped
|
|
ports:
|
|
- "443:443"
|
|
- "80:80"
|
|
volumes:
|
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
- caddy-data:/data
|
|
- caddy-config:/config
|
|
depends_on:
|
|
app:
|
|
condition: service_healthy
|
|
networks:
|
|
- mcp-network
|
|
|
|
networks:
|
|
mcp-network:
|
|
driver: bridge
|
|
|
|
volumes:
|
|
caddy-data:
|
|
caddy-config:
|
|
```
|
|
|
|
**Validation:**
|
|
```bash
|
|
docker-compose -f docker/docker-compose.yml config
|
|
# Should validate without errors
|
|
```
|
|
|
|
---
|
|
|
|
### Issue #13: Create Dockerfile
|
|
|
|
**Estimated Time:** 1 hour
|
|
**Dependencies:** Issue #11
|
|
**File:** `docker/Dockerfile`
|
|
|
|
**Complete file:**
|
|
|
|
```dockerfile
|
|
FROM python:3.11-slim
|
|
|
|
# Install system dependencies
|
|
RUN apt-get update && apt-get install -y \
|
|
git \
|
|
curl \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# Set working directory
|
|
WORKDIR /app
|
|
|
|
# Copy requirements first (for layer caching)
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
# Copy project files
|
|
COPY pyproject.toml .
|
|
COPY README.md .
|
|
COPY src/ src/
|
|
|
|
# Install project with marketplace dependency
|
|
RUN pip install --no-cache-dir -e .
|
|
|
|
# Create non-root user
|
|
RUN useradd -m -u 1000 mcpuser && \
|
|
chown -R mcpuser:mcpuser /app
|
|
|
|
USER mcpuser
|
|
|
|
# Expose port
|
|
EXPOSE 8080
|
|
|
|
# Health check
|
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
|
|
CMD curl -f http://localhost:8080/health || exit 1
|
|
|
|
# Run server
|
|
CMD ["gitea-mcp-remote"]
|
|
```
|
|
|
|
**Validation:**
|
|
```bash
|
|
docker build -f docker/Dockerfile -t gitea-mcp-remote:test .
|
|
# Should build successfully
|
|
|
|
docker run --rm gitea-mcp-remote:test python3 -c "from gitea_mcp_remote.server_http import main"
|
|
# Should import successfully
|
|
```
|
|
|
|
---
|
|
|
|
### Issue #14: Create Caddyfile
|
|
|
|
**Estimated Time:** 45 minutes
|
|
**Dependencies:** Issue #11
|
|
**File:** `docker/Caddyfile`
|
|
|
|
**Complete file:**
|
|
|
|
```caddyfile
|
|
{
|
|
# Global options
|
|
admin off
|
|
auto_https disable_redirects
|
|
}
|
|
|
|
:443 {
|
|
# TLS configuration
|
|
tls internal
|
|
|
|
# MCP endpoint
|
|
handle /mcp* {
|
|
reverse_proxy app:8080
|
|
}
|
|
|
|
# Health checks
|
|
handle /health* {
|
|
reverse_proxy app:8080
|
|
}
|
|
|
|
handle /ping {
|
|
reverse_proxy app:8080
|
|
}
|
|
|
|
# Default response
|
|
handle {
|
|
respond "Gitea MCP Remote - Use /mcp endpoint" 200
|
|
}
|
|
|
|
# Logging
|
|
log {
|
|
output stdout
|
|
format console
|
|
}
|
|
}
|
|
```
|
|
|
|
**Validation:**
|
|
```bash
|
|
# Validate Caddyfile syntax
|
|
docker run --rm -v $(pwd)/docker/Caddyfile:/etc/caddy/Caddyfile caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 7: Utility Scripts (Issue #15)
|
|
|
|
### Issue #15: Create Startup and Health Check Scripts
|
|
|
|
**Estimated Time:** 1 hour
|
|
**Dependencies:** None
|
|
**Files:** `scripts/start.sh`, `scripts/healthcheck.sh`
|
|
|
|
**`scripts/start.sh`:**
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
# Production startup script for gitea-mcp-remote
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
|
|
|
|
echo "=== Gitea MCP Remote Startup ==="
|
|
|
|
# Validate required environment variables
|
|
required_vars=("GITEA_URL" "GITEA_TOKEN" "GITEA_OWNER")
|
|
for var in "${required_vars[@]}"; do
|
|
if [[ -z "${!var:-}" ]]; then
|
|
echo "ERROR: $var is not set"
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
echo "✓ Environment validated"
|
|
|
|
# Optional: Load from .env if exists
|
|
if [[ -f "$PROJECT_ROOT/.env" ]]; then
|
|
echo "Loading environment from .env"
|
|
set -a
|
|
source "$PROJECT_ROOT/.env"
|
|
set +a
|
|
fi
|
|
|
|
# Log configuration (sanitized)
|
|
echo "Configuration:"
|
|
echo " GITEA_URL: ${GITEA_URL}"
|
|
echo " GITEA_OWNER: ${GITEA_OWNER}"
|
|
echo " GITEA_REPO: ${GITEA_REPO:-<not set>}"
|
|
echo " HTTP_HOST: ${HTTP_HOST:-0.0.0.0}"
|
|
echo " HTTP_PORT: ${HTTP_PORT:-8080}"
|
|
echo " MCP_AUTH_MODE: ${MCP_AUTH_MODE:-optional}"
|
|
|
|
# Start server
|
|
echo "Starting MCP HTTP server..."
|
|
cd "$PROJECT_ROOT"
|
|
exec gitea-mcp-remote
|
|
```
|
|
|
|
**`scripts/healthcheck.sh`:**
|
|
|
|
```bash
|
|
#!/usr/bin/env bash
|
|
# Docker healthcheck script
|
|
|
|
set -euo pipefail
|
|
|
|
HOST="${HTTP_HOST:-0.0.0.0}"
|
|
PORT="${HTTP_PORT:-8080}"
|
|
|
|
# Check health endpoint
|
|
if curl -f -s "http://${HOST}:${PORT}/health" > /dev/null; then
|
|
exit 0
|
|
else
|
|
exit 1
|
|
fi
|
|
```
|
|
|
|
**Make executable:**
|
|
```bash
|
|
chmod +x scripts/start.sh scripts/healthcheck.sh
|
|
```
|
|
|
|
**Validation:**
|
|
```bash
|
|
# Test start script (will fail without env vars - expected)
|
|
./scripts/start.sh 2>&1 | grep "ERROR: GITEA_URL"
|
|
|
|
# Test healthcheck script (needs server running)
|
|
# export HTTP_HOST=localhost HTTP_PORT=8080
|
|
# ./scripts/healthcheck.sh
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 8: New Tests (Issue #16)
|
|
|
|
### Issue #16: Create MCP Server Tests
|
|
|
|
**Estimated Time:** 2-3 hours
|
|
**Dependencies:** Issue #8
|
|
**File:** `tests/test_server_http.py`
|
|
|
|
**Complete file:**
|
|
|
|
```python
|
|
"""Tests for MCP HTTP server."""
|
|
|
|
import pytest
|
|
from starlette.testclient import TestClient
|
|
|
|
from gitea_mcp_remote.config import GiteaSettings
|
|
from gitea_mcp_remote.server_http import create_app
|
|
|
|
|
|
@pytest.fixture
|
|
def settings():
|
|
"""Test settings."""
|
|
return GiteaSettings(
|
|
gitea_url="https://gitea.test.com",
|
|
gitea_token="test_token",
|
|
gitea_owner="test_owner",
|
|
gitea_repo="test_repo",
|
|
http_host="127.0.0.1",
|
|
http_port=8080,
|
|
auth_token="test_auth_token",
|
|
mcp_auth_mode="optional",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(settings):
|
|
"""Test client."""
|
|
app = create_app(settings)
|
|
return TestClient(app)
|
|
|
|
|
|
def test_health_endpoints(client):
|
|
"""Test health check endpoints."""
|
|
for path in ["/health", "/healthz", "/ping"]:
|
|
response = client.get(path)
|
|
assert response.status_code == 200
|
|
assert response.json() == {"status": "healthy"}
|
|
|
|
|
|
def test_mcp_head_endpoint(client):
|
|
"""Test HEAD /mcp returns protocol version."""
|
|
response = client.head("/mcp")
|
|
assert response.status_code == 200
|
|
assert "X-MCP-Version" in response.headers
|
|
assert response.headers["X-MCP-Version"] == "2024-11-05"
|
|
|
|
|
|
def test_mcp_post_initialize(client):
|
|
"""Test POST /mcp with initialize method."""
|
|
request_data = {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "initialize",
|
|
"params": {}
|
|
}
|
|
|
|
response = client.post("/mcp", json=request_data)
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert data["jsonrpc"] == "2.0"
|
|
assert data["id"] == 1
|
|
assert "result" in data
|
|
assert data["result"]["protocolVersion"] == "2024-11-05"
|
|
|
|
|
|
def test_mcp_post_invalid_json(client):
|
|
"""Test POST /mcp with invalid JSON."""
|
|
response = client.post(
|
|
"/mcp",
|
|
content=b"not valid json",
|
|
headers={"Content-Type": "application/json"}
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
data = response.json()
|
|
assert "error" in data
|
|
assert data["error"]["code"] == -32700
|
|
|
|
|
|
def test_mcp_post_unknown_method(client):
|
|
"""Test POST /mcp with unknown method."""
|
|
request_data = {
|
|
"jsonrpc": "2.0",
|
|
"id": 1,
|
|
"method": "unknown/method",
|
|
"params": {}
|
|
}
|
|
|
|
response = client.post("/mcp", json=request_data)
|
|
assert response.status_code == 200
|
|
|
|
data = response.json()
|
|
assert "error" in data
|
|
assert data["error"]["code"] == -32000
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_filtering(settings):
|
|
"""Test tool filtering integration."""
|
|
settings.disabled_tools = "create_issue,delete_issue"
|
|
|
|
from gitea_mcp_remote.server_http import GiteaMCPServer
|
|
server = GiteaMCPServer(settings)
|
|
|
|
# List tools should exclude disabled
|
|
result = await server.handle_list_tools({})
|
|
tool_names = [tool["name"] for tool in result["tools"]]
|
|
|
|
assert "create_issue" not in tool_names
|
|
assert "delete_issue" not in tool_names
|
|
```
|
|
|
|
**Validation:**
|
|
```bash
|
|
pytest tests/test_server_http.py -v
|
|
# All tests should pass
|
|
```
|
|
|
|
---
|
|
|
|
## Phase 9: Documentation (Issue #17-18)
|
|
|
|
### Issue #17: Create CLAUDE.md
|
|
|
|
**Estimated Time:** 1-2 hours
|
|
**Dependencies:** All previous issues
|
|
**File:** `CLAUDE.md`
|
|
|
|
**Complete file:**
|
|
|
|
```markdown
|
|
# CLAUDE.md - Gitea MCP Remote
|
|
|
|
Project guidance for Claude Code when working with this repository.
|
|
|
|
## Project Overview
|
|
|
|
**Type:** Python MCP HTTP Transport Server
|
|
**Purpose:** Provide HTTP transport layer for Gitea MCP operations, enabling Claude Desktop integration
|
|
**Architecture:** MCP Streamable HTTP protocol with JSON-RPC 2.0
|
|
|
|
## Architecture
|
|
|
|
### Component Stack
|
|
|
|
```
|
|
Claude Desktop
|
|
↓ HTTP + JSON-RPC 2.0
|
|
Caddy (HTTPS proxy)
|
|
↓
|
|
server_http.py (MCP HTTP transport)
|
|
↓ Direct Python imports
|
|
mcp_server (from marketplace)
|
|
↓ HTTPS API
|
|
Gitea Instance
|
|
```
|
|
|
|
### Key Components
|
|
|
|
1. **server_http.py** - MCP HTTP transport server
|
|
- Implements MCP Streamable HTTP protocol
|
|
- JSON-RPC 2.0 message handling
|
|
- Routes: `POST /mcp`, `HEAD /mcp`, health endpoints
|
|
|
|
2. **config/settings.py** - Configuration management
|
|
- Pydantic settings with environment variable loading
|
|
- Gitea connection parameters
|
|
- HTTP server configuration
|
|
- Authentication and filtering options
|
|
|
|
3. **middleware/auth.py** - Authentication middleware
|
|
- Bearer token authentication
|
|
- Health check bypass
|
|
|
|
4. **filtering/filter.py** - Tool filtering
|
|
- Whitelist/blacklist tool filtering
|
|
- Claude Desktop compatibility layer
|
|
|
|
5. **mcp_server** (marketplace) - Core Gitea operations
|
|
- GiteaClient for API operations
|
|
- Tool definitions and dispatcher
|
|
|
|
## Development Workflows
|
|
|
|
### Local Development
|
|
|
|
```bash
|
|
# Setup
|
|
python3 -m venv .venv
|
|
source .venv/bin/activate
|
|
pip install -e ".[dev]"
|
|
|
|
# Run tests
|
|
pytest tests/ -v
|
|
|
|
# Run server
|
|
export GITEA_URL=https://gitea.test.com
|
|
export GITEA_TOKEN=your_token
|
|
export GITEA_OWNER=your_org
|
|
gitea-mcp-remote
|
|
```
|
|
|
|
### Docker Development
|
|
|
|
```bash
|
|
# Build and run
|
|
cd docker
|
|
docker-compose up --build
|
|
|
|
# View logs
|
|
docker-compose logs -f app
|
|
|
|
# Stop
|
|
docker-compose down
|
|
```
|
|
|
|
### Testing
|
|
|
|
```bash
|
|
# All tests
|
|
pytest tests/ -v
|
|
|
|
# With coverage
|
|
pytest tests/ --cov=gitea_mcp_remote --cov-report=html
|
|
|
|
# Specific test file
|
|
pytest tests/test_server_http.py -v
|
|
```
|
|
|
|
## MCP Protocol Notes
|
|
|
|
### Streamable HTTP Transport
|
|
|
|
This server implements MCP Streamable HTTP protocol:
|
|
|
|
- **HEAD /mcp** - Protocol version check
|
|
- Returns: `X-MCP-Version: 2024-11-05` header
|
|
|
|
- **POST /mcp** - JSON-RPC 2.0 messages
|
|
- Content-Type: `application/json`
|
|
- Body: JSON-RPC 2.0 request/response
|
|
|
|
### JSON-RPC Methods
|
|
|
|
1. **initialize** - Client initialization
|
|
```json
|
|
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}
|
|
```
|
|
|
|
2. **tools/list** - List available tools
|
|
```json
|
|
{"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}}
|
|
```
|
|
|
|
3. **tools/call** - Execute a tool
|
|
```json
|
|
{
|
|
"jsonrpc": "2.0",
|
|
"id": 3,
|
|
"method": "tools/call",
|
|
"params": {
|
|
"name": "list_issues",
|
|
"arguments": {"owner": "org", "repo": "repo"}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Configuration
|
|
|
|
### Environment Variables
|
|
|
|
**Required:**
|
|
- `GITEA_URL` - Gitea instance URL
|
|
- `GITEA_TOKEN` - Gitea API token
|
|
- `GITEA_OWNER` - Default repository owner
|
|
|
|
**Optional:**
|
|
- `GITEA_REPO` - Default repository name
|
|
- `HTTP_HOST` - Server bind address (default: 0.0.0.0)
|
|
- `HTTP_PORT` - Server port (default: 8080)
|
|
- `AUTH_TOKEN` - Bearer authentication token
|
|
- `MCP_AUTH_MODE` - Auth mode: required/optional/none
|
|
- `ENABLED_TOOLS` - Comma-separated whitelist
|
|
- `DISABLED_TOOLS` - Comma-separated blacklist
|
|
|
|
### Authentication Modes
|
|
|
|
- **none** - No authentication required
|
|
- **optional** - Bearer token checked if provided
|
|
- **required** - Bearer token mandatory
|
|
|
|
## Deployment
|
|
|
|
### Docker Compose (Production)
|
|
|
|
```bash
|
|
# Setup environment
|
|
cp .env.example .env
|
|
nano .env # Configure
|
|
|
|
# Deploy
|
|
cd docker
|
|
docker-compose up -d
|
|
|
|
# Check health
|
|
curl https://your-domain/health
|
|
```
|
|
|
|
### Claude Desktop Configuration
|
|
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"gitea": {
|
|
"url": "https://your-domain/mcp",
|
|
"headers": {
|
|
"Authorization": "Bearer YOUR_TOKEN"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Troubleshooting
|
|
|
|
### Import Errors
|
|
|
|
If you see import errors from `gitea_http_wrapper`:
|
|
- Package was renamed to `gitea_mcp_remote`
|
|
- All imports should use new name
|
|
- Check: `git grep -r gitea_http_wrapper`
|
|
|
|
### Marketplace Dependency Issues
|
|
|
|
If marketplace install fails:
|
|
- Check Git repository access: `https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace`
|
|
- Ensure `git` is installed in Docker image
|
|
- Check subdirectory path: `mcp-servers/gitea`
|
|
|
|
### MCP Protocol Errors
|
|
|
|
If Claude Desktop can't connect:
|
|
- Check protocol version: `curl -I https://your-domain/mcp`
|
|
- Test JSON-RPC: `curl -X POST https://your-domain/mcp -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}'`
|
|
- Review server logs for errors
|
|
|
|
## Important Notes
|
|
|
|
1. **Do NOT use subprocess** - Import directly from `mcp_server` package
|
|
2. **Do NOT create custom REST API** - Use MCP protocol endpoints
|
|
3. **Do NOT skip marketplace dependency** - Must be in `pyproject.toml`
|
|
4. **Package name is gitea_mcp_remote** - Not gitea_http_wrapper
|
|
5. **Tests are at repo root** - Not in src/ directory
|
|
|
|
## References
|
|
|
|
- MCP Spec: https://spec.modelcontextprotocol.io
|
|
- Marketplace: https://gitea.hotserv.cloud/personal-projects/leo-claude-mktplace
|
|
- JSON-RPC 2.0: https://www.jsonrpc.org/specification
|
|
```
|
|
|
|
---
|
|
|
|
### Issue #18: Update DEPLOYMENT.md
|
|
|
|
**Estimated Time:** 1 hour
|
|
**Dependencies:** All previous issues
|
|
**File:** `DEPLOYMENT.md`
|
|
|
|
**Changes needed:**
|
|
- Update all references from `/tools/list` and `/tools/call` to `/mcp`
|
|
- Update Docker structure (two services, new directory)
|
|
- Update marketplace dependency installation
|
|
- Update Claude Desktop config example
|
|
- Add MCP protocol version checking
|
|
- Update health check endpoints
|
|
|
|
**Key sections to update:**
|
|
|
|
1. **Quick Start** - Reference `docker/docker-compose.yml`
|
|
2. **Configuration** - Add `MCP_AUTH_MODE` variable
|
|
3. **HTTP Endpoints** - Replace with MCP protocol endpoints
|
|
4. **Claude Desktop Config** - Update URL to `/mcp`
|
|
5. **Troubleshooting** - Add MCP protocol debugging
|
|
|
|
---
|
|
|
|
## Final Validation (All Issues Complete)
|
|
|
|
### Complete Validation Checklist
|
|
|
|
```bash
|
|
# 1. Package structure
|
|
ls -la src/gitea_mcp_remote/
|
|
ls -la tests/
|
|
! ls -la src/gitea_http_wrapper/ 2>/dev/null
|
|
|
|
# 2. No old imports
|
|
! git grep -r "gitea_http_wrapper" --include="*.py"
|
|
|
|
# 3. Config fields
|
|
python3 -c "from gitea_mcp_remote.config import GiteaSettings; assert hasattr(GiteaSettings, 'mcp_auth_mode')"
|
|
|
|
# 4. Server has MCP endpoints
|
|
python3 -c "from gitea_mcp_remote.server_http import app; paths = [r.path for r in app.routes]; assert '/mcp' in paths"
|
|
|
|
# 5. Dependencies installable
|
|
pip install -e .
|
|
|
|
# 6. Entry point works
|
|
which gitea-mcp-remote
|
|
|
|
# 7. Tests pass
|
|
pytest tests/ -v
|
|
|
|
# 8. Docker builds
|
|
docker build -f docker/Dockerfile -t gitea-mcp-remote:test .
|
|
|
|
# 9. Docker compose validates
|
|
docker-compose -f docker/docker-compose.yml config
|
|
|
|
# 10. Caddyfile validates
|
|
docker run --rm -v $(pwd)/docker/Caddyfile:/etc/caddy/Caddyfile caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile
|
|
```
|
|
|
|
## Timeline Summary
|
|
|
|
| Phase | Issues | Est. Time |
|
|
|-------|--------|-----------|
|
|
| 1. Package Restructuring | #1-2 | 2-3 hours |
|
|
| 2. Supporting Modules | #3-4 | 1-2 hours |
|
|
| 3. Test Relocation | #5-6 | 2 hours |
|
|
| 4. Core Server | #7-8 | 5-6 hours |
|
|
| 5. Project Config | #9-10 | 1 hour |
|
|
| 6. Docker Infrastructure | #11-14 | 4-5 hours |
|
|
| 7. Utility Scripts | #15 | 1 hour |
|
|
| 8. New Tests | #16 | 2-3 hours |
|
|
| 9. Documentation | #17-18 | 3-4 hours |
|
|
|
|
**Total:** 21-30 hours (~1 week sprint)
|