piercloud-mcp 0.1.0__tar.gz
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.
- piercloud_mcp-0.1.0/PKG-INFO +106 -0
- piercloud_mcp-0.1.0/README.md +81 -0
- piercloud_mcp-0.1.0/pyproject.toml +34 -0
- piercloud_mcp-0.1.0/src/pier_mcp/__init__.py +3 -0
- piercloud_mcp-0.1.0/src/pier_mcp/client.py +81 -0
- piercloud_mcp-0.1.0/src/pier_mcp/main.py +35 -0
- piercloud_mcp-0.1.0/src/pier_mcp/rest_api.py +112 -0
- piercloud_mcp-0.1.0/src/pier_mcp/server.py +104 -0
- piercloud_mcp-0.1.0/src/pier_mcp/sse_server.py +41 -0
- piercloud_mcp-0.1.0/src/pier_mcp/tools/__init__.py +3 -0
- piercloud_mcp-0.1.0/src/pier_mcp/tools/__pycache__/__init__.cpython-313.pyc +0 -0
- piercloud_mcp-0.1.0/src/pier_mcp/tools/__pycache__/example.cpython-313.pyc +0 -0
- piercloud_mcp-0.1.0/src/pier_mcp/tools/__pycache__/rebilling.cpython-313.pyc +0 -0
- piercloud_mcp-0.1.0/src/pier_mcp/tools/example.py +30 -0
- piercloud_mcp-0.1.0/src/pier_mcp/tools/rebilling.py +65 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: piercloud-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A scalable MCP server with clean architecture
|
|
5
|
+
Home-page: https://github.com/piercloud/piercloud-mcp
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: mcp,model-context-protocol,ai,llm
|
|
8
|
+
Author: Pier Team
|
|
9
|
+
Author-email: engineering@piercloud.com
|
|
10
|
+
Requires-Python: >=3.10,<4.0
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Requires-Dist: httpx (>=0.27.0,<0.28.0)
|
|
18
|
+
Requires-Dist: mcp (>=1.0.0,<2.0.0)
|
|
19
|
+
Requires-Dist: pydantic (>=2.0.0,<3.0.0)
|
|
20
|
+
Requires-Dist: starlette (>=0.37.0,<0.38.0)
|
|
21
|
+
Requires-Dist: uvicorn (>=0.30.0,<0.31.0)
|
|
22
|
+
Project-URL: Repository, https://github.com/piercloud/piercloud-mcp
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# Pier Cloud MCP Server
|
|
26
|
+
|
|
27
|
+
A scalable Model Context Protocol server with clean architecture.
|
|
28
|
+
|
|
29
|
+
## Quick Start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Setup
|
|
33
|
+
poetry install
|
|
34
|
+
|
|
35
|
+
# Local mode (stdio)
|
|
36
|
+
poetry run piercloud-mcp
|
|
37
|
+
|
|
38
|
+
# Production mode (HTTP/SSE)
|
|
39
|
+
poetry run piercloud-mcp --sse --port 8000
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Production Deployment
|
|
43
|
+
|
|
44
|
+
Deploy as HTTP server at `https://mcp.piercloud.io`:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# Run server
|
|
48
|
+
poetry run piercloud-mcp --sse --port 8000
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### MCP Protocol (Claude, Cline, etc)
|
|
52
|
+
```json
|
|
53
|
+
{
|
|
54
|
+
"mcpServers": {
|
|
55
|
+
"pier": {
|
|
56
|
+
"url": "https://mcp.piercloud.io/mcp/sse"
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### REST API (ChatGPT, Gemini, any LLM)
|
|
63
|
+
```bash
|
|
64
|
+
# List tools
|
|
65
|
+
curl https://mcp.piercloud.io/api/tools
|
|
66
|
+
|
|
67
|
+
# Execute tool
|
|
68
|
+
curl -X POST https://mcp.piercloud.io/api/tools/echo \
|
|
69
|
+
-H "Content-Type: application/json" \
|
|
70
|
+
-d '{"message": "hello", "uppercase": true}'
|
|
71
|
+
|
|
72
|
+
# OpenAPI spec
|
|
73
|
+
curl https://mcp.piercloud.io/openapi.json
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Adding Tools
|
|
77
|
+
|
|
78
|
+
Create a new file in `src/pier_mcp/tools/` and register:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from pydantic import BaseModel, Field
|
|
82
|
+
|
|
83
|
+
class MyToolInput(BaseModel):
|
|
84
|
+
param: str = Field(description="Parameter description")
|
|
85
|
+
|
|
86
|
+
def register_my_tools(mcp):
|
|
87
|
+
@mcp.tool("my_tool", "Tool description", MyToolInput)
|
|
88
|
+
async def my_tool(args: MyToolInput) -> str:
|
|
89
|
+
return f"Result: {args.param}"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Then import in `main.py`:
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
from pier_mcp.tools.my_tools import register_my_tools
|
|
96
|
+
register_my_tools(mcp)
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Architecture
|
|
100
|
+
|
|
101
|
+
- `server.py` - Core MCP server with tool registry
|
|
102
|
+
- `tools/` - Tool implementations (one file per domain)
|
|
103
|
+
- `main.py` - Entry point and tool registration
|
|
104
|
+
|
|
105
|
+
Clean, minimal, scalable.
|
|
106
|
+
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Pier Cloud MCP Server
|
|
2
|
+
|
|
3
|
+
A scalable Model Context Protocol server with clean architecture.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Setup
|
|
9
|
+
poetry install
|
|
10
|
+
|
|
11
|
+
# Local mode (stdio)
|
|
12
|
+
poetry run piercloud-mcp
|
|
13
|
+
|
|
14
|
+
# Production mode (HTTP/SSE)
|
|
15
|
+
poetry run piercloud-mcp --sse --port 8000
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Production Deployment
|
|
19
|
+
|
|
20
|
+
Deploy as HTTP server at `https://mcp.piercloud.io`:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
# Run server
|
|
24
|
+
poetry run piercloud-mcp --sse --port 8000
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### MCP Protocol (Claude, Cline, etc)
|
|
28
|
+
```json
|
|
29
|
+
{
|
|
30
|
+
"mcpServers": {
|
|
31
|
+
"pier": {
|
|
32
|
+
"url": "https://mcp.piercloud.io/mcp/sse"
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### REST API (ChatGPT, Gemini, any LLM)
|
|
39
|
+
```bash
|
|
40
|
+
# List tools
|
|
41
|
+
curl https://mcp.piercloud.io/api/tools
|
|
42
|
+
|
|
43
|
+
# Execute tool
|
|
44
|
+
curl -X POST https://mcp.piercloud.io/api/tools/echo \
|
|
45
|
+
-H "Content-Type: application/json" \
|
|
46
|
+
-d '{"message": "hello", "uppercase": true}'
|
|
47
|
+
|
|
48
|
+
# OpenAPI spec
|
|
49
|
+
curl https://mcp.piercloud.io/openapi.json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Adding Tools
|
|
53
|
+
|
|
54
|
+
Create a new file in `src/pier_mcp/tools/` and register:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from pydantic import BaseModel, Field
|
|
58
|
+
|
|
59
|
+
class MyToolInput(BaseModel):
|
|
60
|
+
param: str = Field(description="Parameter description")
|
|
61
|
+
|
|
62
|
+
def register_my_tools(mcp):
|
|
63
|
+
@mcp.tool("my_tool", "Tool description", MyToolInput)
|
|
64
|
+
async def my_tool(args: MyToolInput) -> str:
|
|
65
|
+
return f"Result: {args.param}"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Then import in `main.py`:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from pier_mcp.tools.my_tools import register_my_tools
|
|
72
|
+
register_my_tools(mcp)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Architecture
|
|
76
|
+
|
|
77
|
+
- `server.py` - Core MCP server with tool registry
|
|
78
|
+
- `tools/` - Tool implementations (one file per domain)
|
|
79
|
+
- `main.py` - Entry point and tool registration
|
|
80
|
+
|
|
81
|
+
Clean, minimal, scalable.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "piercloud-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A scalable MCP server with clean architecture"
|
|
5
|
+
authors = ["Pier Team <engineering@piercloud.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
homepage = "https://github.com/piercloud/piercloud-mcp"
|
|
9
|
+
repository = "https://github.com/piercloud/piercloud-mcp"
|
|
10
|
+
keywords = ["mcp", "model-context-protocol", "ai", "llm"]
|
|
11
|
+
packages = [{include = "pier_mcp", from = "src"}]
|
|
12
|
+
|
|
13
|
+
[tool.poetry.dependencies]
|
|
14
|
+
python = "^3.10"
|
|
15
|
+
mcp = "^1.0.0"
|
|
16
|
+
pydantic = "^2.0.0"
|
|
17
|
+
uvicorn = "^0.30.0"
|
|
18
|
+
starlette = "^0.37.0"
|
|
19
|
+
httpx = "^0.27.0"
|
|
20
|
+
|
|
21
|
+
[tool.poetry.group.dev.dependencies]
|
|
22
|
+
pytest = "^7.0.0"
|
|
23
|
+
ruff = "^0.1.0"
|
|
24
|
+
|
|
25
|
+
[tool.poetry.scripts]
|
|
26
|
+
piercloud-mcp = "pier_mcp.main:main"
|
|
27
|
+
|
|
28
|
+
[build-system]
|
|
29
|
+
requires = ["poetry-core"]
|
|
30
|
+
build-backend = "poetry.core.masonry.api"
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
line-length = 100
|
|
34
|
+
target-version = "py310"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""HTTP client for Pier Cloud API."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import httpx
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PierCloudClient:
|
|
10
|
+
"""Client for Pier Cloud API with JWT authentication."""
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
self.base_url = os.getenv("PIERCLOUD_API_URL", "https://api.piercloud.io/lake")
|
|
14
|
+
self.client_id = os.getenv("PIERCLOUD_CLIENT_ID")
|
|
15
|
+
self.client_secret = os.getenv("PIERCLOUD_CLIENT_SECRET")
|
|
16
|
+
|
|
17
|
+
if not self.client_id or not self.client_secret:
|
|
18
|
+
raise ValueError(
|
|
19
|
+
"PIERCLOUD_CLIENT_ID and PIERCLOUD_CLIENT_SECRET are required. "
|
|
20
|
+
"Set them as environment variables."
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
self.client = httpx.AsyncClient(base_url=self.base_url, timeout=30.0)
|
|
24
|
+
self.access_token: Optional[str] = None
|
|
25
|
+
self.token_expires_at: Optional[datetime] = None
|
|
26
|
+
|
|
27
|
+
async def _authenticate(self):
|
|
28
|
+
"""Authenticate and get JWT token."""
|
|
29
|
+
response = await self.client.post(
|
|
30
|
+
"https://api.piercloud.io/auth",
|
|
31
|
+
json={"client_id": self.client_id, "client_secret": self.client_secret},
|
|
32
|
+
)
|
|
33
|
+
response.raise_for_status()
|
|
34
|
+
|
|
35
|
+
data = response.json()["data"]
|
|
36
|
+
self.access_token = data["access_token"]
|
|
37
|
+
|
|
38
|
+
# Set expiration (with 60s buffer)
|
|
39
|
+
expires_in = data["expires_in"] - 60
|
|
40
|
+
self.token_expires_at = datetime.now() + timedelta(seconds=expires_in)
|
|
41
|
+
|
|
42
|
+
async def _ensure_authenticated(self):
|
|
43
|
+
"""Ensure we have a valid token."""
|
|
44
|
+
if not self.access_token or not self.token_expires_at:
|
|
45
|
+
await self._authenticate()
|
|
46
|
+
elif datetime.now() >= self.token_expires_at:
|
|
47
|
+
await self._authenticate()
|
|
48
|
+
|
|
49
|
+
async def get(self, path: str) -> Any:
|
|
50
|
+
"""GET request with authentication."""
|
|
51
|
+
await self._ensure_authenticated()
|
|
52
|
+
response = await self.client.get(
|
|
53
|
+
path, headers={"Authorization": f"Bearer {self.access_token}"}
|
|
54
|
+
)
|
|
55
|
+
response.raise_for_status()
|
|
56
|
+
return response.json()
|
|
57
|
+
|
|
58
|
+
async def post(self, path: str, data: dict) -> Any:
|
|
59
|
+
"""POST request with authentication."""
|
|
60
|
+
await self._ensure_authenticated()
|
|
61
|
+
response = await self.client.post(
|
|
62
|
+
path, json=data, headers={"Authorization": f"Bearer {self.access_token}"}
|
|
63
|
+
)
|
|
64
|
+
response.raise_for_status()
|
|
65
|
+
return response.json()
|
|
66
|
+
|
|
67
|
+
async def close(self):
|
|
68
|
+
"""Close client."""
|
|
69
|
+
await self.client.aclose()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# Singleton instance
|
|
73
|
+
_client = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_client() -> PierCloudClient:
|
|
77
|
+
"""Get or create client instance."""
|
|
78
|
+
global _client
|
|
79
|
+
if _client is None:
|
|
80
|
+
_client = PierCloudClient()
|
|
81
|
+
return _client
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""MCP server entry point - register your tools here."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
import uvicorn
|
|
6
|
+
from pier_mcp.server import MCPServer
|
|
7
|
+
from pier_mcp.sse_server import create_sse_server
|
|
8
|
+
from pier_mcp.tools.rebilling import register_rebilling_tools
|
|
9
|
+
from pier_mcp import __version__
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Initialize server
|
|
13
|
+
mcp = MCPServer("piercloud-mcp-server", __version__)
|
|
14
|
+
|
|
15
|
+
# Register tools
|
|
16
|
+
register_rebilling_tools(mcp)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main():
|
|
20
|
+
"""Run the MCP server."""
|
|
21
|
+
if "--sse" in sys.argv:
|
|
22
|
+
# Production mode: HTTP server
|
|
23
|
+
port = 8000
|
|
24
|
+
if "--port" in sys.argv:
|
|
25
|
+
port = int(sys.argv[sys.argv.index("--port") + 1])
|
|
26
|
+
|
|
27
|
+
app = create_sse_server(mcp)
|
|
28
|
+
uvicorn.run(app, host="0.0.0.0", port=port)
|
|
29
|
+
else:
|
|
30
|
+
# Local mode: stdio
|
|
31
|
+
asyncio.run(mcp.run())
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
if __name__ == "__main__":
|
|
35
|
+
main()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""REST API layer for universal LLM integration."""
|
|
2
|
+
|
|
3
|
+
from starlette.routing import Route
|
|
4
|
+
from starlette.responses import JSONResponse
|
|
5
|
+
from starlette.requests import Request
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def create_rest_routes(mcp_server):
|
|
9
|
+
"""Create REST API routes that reuse the existing ToolRegistry."""
|
|
10
|
+
|
|
11
|
+
async def list_tools(request: Request):
|
|
12
|
+
"""GET /api/tools - List all available tools."""
|
|
13
|
+
tools = []
|
|
14
|
+
for name, config in mcp_server.registry._tools.items():
|
|
15
|
+
tool_def = {
|
|
16
|
+
"name": name,
|
|
17
|
+
"description": config["description"],
|
|
18
|
+
}
|
|
19
|
+
if config["schema"]:
|
|
20
|
+
tool_def["parameters"] = config["schema"].model_json_schema()
|
|
21
|
+
tools.append(tool_def)
|
|
22
|
+
|
|
23
|
+
return JSONResponse({"tools": tools})
|
|
24
|
+
|
|
25
|
+
async def execute_tool(request: Request):
|
|
26
|
+
"""POST /api/tools/{name} - Execute a tool."""
|
|
27
|
+
tool_name = request.path_params["name"]
|
|
28
|
+
body = await request.json()
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
result = await mcp_server.registry.execute(tool_name, body)
|
|
32
|
+
return JSONResponse({"success": True, "result": result[0].text})
|
|
33
|
+
except Exception as e:
|
|
34
|
+
return JSONResponse({"success": False, "error": str(e)}, status_code=400)
|
|
35
|
+
|
|
36
|
+
async def openapi_spec(request: Request):
|
|
37
|
+
"""GET /openapi.json - OpenAPI specification."""
|
|
38
|
+
paths = {}
|
|
39
|
+
|
|
40
|
+
for name, config in mcp_server.registry._tools.items():
|
|
41
|
+
schema = {}
|
|
42
|
+
if config["schema"]:
|
|
43
|
+
schema = config["schema"].model_json_schema()
|
|
44
|
+
|
|
45
|
+
paths[f"/api/tools/{name}"] = {
|
|
46
|
+
"post": {
|
|
47
|
+
"summary": config["description"],
|
|
48
|
+
"requestBody": {
|
|
49
|
+
"content": {"application/json": {"schema": schema}}
|
|
50
|
+
},
|
|
51
|
+
"responses": {
|
|
52
|
+
"200": {
|
|
53
|
+
"description": "Success",
|
|
54
|
+
"content": {
|
|
55
|
+
"application/json": {
|
|
56
|
+
"schema": {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"properties": {
|
|
59
|
+
"success": {"type": "boolean"},
|
|
60
|
+
"result": {"type": "string"},
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
spec = {
|
|
71
|
+
"openapi": "3.0.0",
|
|
72
|
+
"info": {
|
|
73
|
+
"title": "Pier Cloud MCP Server",
|
|
74
|
+
"version": mcp_server.version,
|
|
75
|
+
"description": "Universal tool server for any LLM",
|
|
76
|
+
},
|
|
77
|
+
"servers": [{"url": "https://mcp.piercloud.io"}],
|
|
78
|
+
"paths": {
|
|
79
|
+
"/api/tools": {
|
|
80
|
+
"get": {
|
|
81
|
+
"summary": "List all tools",
|
|
82
|
+
"responses": {
|
|
83
|
+
"200": {
|
|
84
|
+
"description": "List of tools",
|
|
85
|
+
"content": {
|
|
86
|
+
"application/json": {
|
|
87
|
+
"schema": {
|
|
88
|
+
"type": "object",
|
|
89
|
+
"properties": {
|
|
90
|
+
"tools": {
|
|
91
|
+
"type": "array",
|
|
92
|
+
"items": {"type": "object"},
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
**paths,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return JSONResponse(spec)
|
|
107
|
+
|
|
108
|
+
return [
|
|
109
|
+
Route("/api/tools", endpoint=list_tools),
|
|
110
|
+
Route("/api/tools/{name}", endpoint=execute_tool, methods=["POST"]),
|
|
111
|
+
Route("/openapi.json", endpoint=openapi_spec),
|
|
112
|
+
]
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Core MCP server implementation with tool registry."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Callable
|
|
4
|
+
from mcp.server import Server
|
|
5
|
+
from mcp.server.stdio import stdio_server
|
|
6
|
+
from mcp.types import Tool, TextContent
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolRegistry:
|
|
11
|
+
"""Manages tool registration and execution with automatic schema generation."""
|
|
12
|
+
|
|
13
|
+
def __init__(self):
|
|
14
|
+
self._tools: dict[str, dict[str, Any]] = {}
|
|
15
|
+
|
|
16
|
+
def tool(
|
|
17
|
+
self,
|
|
18
|
+
name: str,
|
|
19
|
+
description: str,
|
|
20
|
+
schema: type[BaseModel] | None = None
|
|
21
|
+
):
|
|
22
|
+
"""Decorator to register a tool with automatic schema validation.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
@registry.tool("tool_name", "Description", InputSchema)
|
|
26
|
+
async def my_tool(args: InputSchema) -> str:
|
|
27
|
+
return "result"
|
|
28
|
+
"""
|
|
29
|
+
def decorator(func: Callable) -> Callable:
|
|
30
|
+
self._tools[name] = {
|
|
31
|
+
"description": description,
|
|
32
|
+
"schema": schema,
|
|
33
|
+
"handler": func,
|
|
34
|
+
}
|
|
35
|
+
return func
|
|
36
|
+
return decorator
|
|
37
|
+
|
|
38
|
+
def get_tool_definitions(self) -> list[Tool]:
|
|
39
|
+
"""Generate MCP tool definitions from registered tools."""
|
|
40
|
+
tools = []
|
|
41
|
+
for name, config in self._tools.items():
|
|
42
|
+
input_schema = {}
|
|
43
|
+
if config["schema"]:
|
|
44
|
+
input_schema = config["schema"].model_json_schema()
|
|
45
|
+
|
|
46
|
+
tools.append(Tool(
|
|
47
|
+
name=name,
|
|
48
|
+
description=config["description"],
|
|
49
|
+
inputSchema=input_schema,
|
|
50
|
+
))
|
|
51
|
+
return tools
|
|
52
|
+
|
|
53
|
+
async def execute(self, name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
54
|
+
"""Execute a registered tool with validation."""
|
|
55
|
+
if name not in self._tools:
|
|
56
|
+
raise ValueError(f"Tool '{name}' not found")
|
|
57
|
+
|
|
58
|
+
config = self._tools[name]
|
|
59
|
+
|
|
60
|
+
# Validate and parse arguments if schema exists
|
|
61
|
+
if config["schema"]:
|
|
62
|
+
args = config["schema"](**arguments)
|
|
63
|
+
else:
|
|
64
|
+
args = arguments
|
|
65
|
+
|
|
66
|
+
# Execute handler
|
|
67
|
+
result = await config["handler"](args)
|
|
68
|
+
|
|
69
|
+
return [TextContent(type="text", text=str(result))]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MCPServer:
|
|
73
|
+
"""Main MCP server with tool registry."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, name: str, version: str):
|
|
76
|
+
self.server = Server(name)
|
|
77
|
+
self.registry = ToolRegistry()
|
|
78
|
+
self.name = name
|
|
79
|
+
self.version = version
|
|
80
|
+
self._setup_handlers()
|
|
81
|
+
|
|
82
|
+
def _setup_handlers(self):
|
|
83
|
+
"""Configure MCP protocol handlers."""
|
|
84
|
+
|
|
85
|
+
@self.server.list_tools()
|
|
86
|
+
async def list_tools() -> list[Tool]:
|
|
87
|
+
return self.registry.get_tool_definitions()
|
|
88
|
+
|
|
89
|
+
@self.server.call_tool()
|
|
90
|
+
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
|
91
|
+
return await self.registry.execute(name, arguments)
|
|
92
|
+
|
|
93
|
+
def tool(self, name: str, description: str, schema: type[BaseModel] | None = None):
|
|
94
|
+
"""Register a tool (delegates to registry)."""
|
|
95
|
+
return self.registry.tool(name, description, schema)
|
|
96
|
+
|
|
97
|
+
async def run(self):
|
|
98
|
+
"""Start the MCP server."""
|
|
99
|
+
async with stdio_server() as (read_stream, write_stream):
|
|
100
|
+
await self.server.run(
|
|
101
|
+
read_stream,
|
|
102
|
+
write_stream,
|
|
103
|
+
self.server.create_initialization_options()
|
|
104
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""SSE server for production deployment."""
|
|
2
|
+
|
|
3
|
+
from mcp.server.sse import SseServerTransport
|
|
4
|
+
from starlette.applications import Starlette
|
|
5
|
+
from starlette.routing import Route
|
|
6
|
+
from starlette.responses import Response
|
|
7
|
+
from pier_mcp.rest_api import create_rest_routes
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_sse_server(mcp_server):
|
|
11
|
+
"""Create Starlette app with SSE transport and REST API."""
|
|
12
|
+
|
|
13
|
+
sse = SseServerTransport("/mcp/sse")
|
|
14
|
+
|
|
15
|
+
async def handle_sse(request):
|
|
16
|
+
async with sse.connect_sse(
|
|
17
|
+
request.scope,
|
|
18
|
+
request.receive,
|
|
19
|
+
request._send,
|
|
20
|
+
) as streams:
|
|
21
|
+
await mcp_server.server.run(
|
|
22
|
+
streams[0],
|
|
23
|
+
streams[1],
|
|
24
|
+
mcp_server.server.create_initialization_options(),
|
|
25
|
+
)
|
|
26
|
+
return Response()
|
|
27
|
+
|
|
28
|
+
async def handle_messages(request):
|
|
29
|
+
await sse.handle_post_message(request.scope, request.receive, request._send)
|
|
30
|
+
return Response()
|
|
31
|
+
|
|
32
|
+
# Combine MCP and REST routes
|
|
33
|
+
routes = [
|
|
34
|
+
Route("/mcp/sse", endpoint=handle_sse),
|
|
35
|
+
Route("/mcp/messages", endpoint=handle_messages, methods=["POST"]),
|
|
36
|
+
*create_rest_routes(mcp_server),
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
app = Starlette(routes=routes)
|
|
40
|
+
|
|
41
|
+
return app
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Example tools demonstrating the registration pattern."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class EchoInput(BaseModel):
|
|
7
|
+
"""Input schema for echo tool."""
|
|
8
|
+
message: str = Field(description="Message to echo back")
|
|
9
|
+
uppercase: bool = Field(default=False, description="Return in uppercase")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AddInput(BaseModel):
|
|
13
|
+
"""Input schema for add tool."""
|
|
14
|
+
a: float = Field(description="First number")
|
|
15
|
+
b: float = Field(description="Second number")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def register_example_tools(mcp):
|
|
19
|
+
"""Register example tools to demonstrate the pattern."""
|
|
20
|
+
|
|
21
|
+
@mcp.tool("echo", "Echoes a message back, optionally in uppercase", EchoInput)
|
|
22
|
+
async def echo(args: EchoInput) -> str:
|
|
23
|
+
result = args.message
|
|
24
|
+
if args.uppercase:
|
|
25
|
+
result = result.upper()
|
|
26
|
+
return result
|
|
27
|
+
|
|
28
|
+
@mcp.tool("add", "Adds two numbers together", AddInput)
|
|
29
|
+
async def add(args: AddInput) -> str:
|
|
30
|
+
return str(args.a + args.b)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Rebilling tools for querying cloud cost data."""
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, Field
|
|
4
|
+
from pier_mcp.client import get_client
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class GetSchemaInput(BaseModel):
|
|
8
|
+
"""Input for get_rebilling_schema tool."""
|
|
9
|
+
|
|
10
|
+
provider: str = Field(description="Cloud provider: 'aws', 'azure', or 'gcp'")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class QueryRebillingInput(BaseModel):
|
|
14
|
+
"""Input for query_rebilling tool."""
|
|
15
|
+
|
|
16
|
+
provider: str = Field(description="Cloud provider: 'aws', 'azure', or 'gcp'")
|
|
17
|
+
query: str = Field(description="Trino SQL query to execute on rebilling data")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def register_rebilling_tools(mcp):
|
|
21
|
+
"""Register rebilling tools."""
|
|
22
|
+
|
|
23
|
+
@mcp.tool(
|
|
24
|
+
"get_rebilling_schema",
|
|
25
|
+
"Get the current schema (table name, columns, types, descriptions) for rebilling table. "
|
|
26
|
+
"Call this first to know the table name and available columns before querying.",
|
|
27
|
+
GetSchemaInput,
|
|
28
|
+
)
|
|
29
|
+
async def get_rebilling_schema(args: GetSchemaInput) -> str:
|
|
30
|
+
client = get_client()
|
|
31
|
+
schema = await client.get(f"/mcp/{args.provider}-rebilling/schema")
|
|
32
|
+
|
|
33
|
+
# Format schema for LLM
|
|
34
|
+
result = f"Schema for {args.provider}-rebilling:\n\n"
|
|
35
|
+
result += f"Table name: {schema['table']['name']}\n\n"
|
|
36
|
+
result += "Columns:\n"
|
|
37
|
+
for col in schema["columns"]:
|
|
38
|
+
result += f" - {col['name']} ({col['type']})\n"
|
|
39
|
+
|
|
40
|
+
result += "\nDescriptions:\n"
|
|
41
|
+
for meta in schema["meta"]:
|
|
42
|
+
result += f" - {meta['name']}: {meta['description']}\n"
|
|
43
|
+
|
|
44
|
+
if "sample" in schema and schema["sample"]:
|
|
45
|
+
result += "\nSample data (first row):\n"
|
|
46
|
+
sample = schema["sample"][0]
|
|
47
|
+
for key, value in sample.items():
|
|
48
|
+
result += f" - {key}: {value}\n"
|
|
49
|
+
|
|
50
|
+
return result
|
|
51
|
+
|
|
52
|
+
@mcp.tool(
|
|
53
|
+
"query_rebilling",
|
|
54
|
+
"Execute a Trino SQL query on rebilling data. "
|
|
55
|
+
"Use get_rebilling_schema first to see available columns.",
|
|
56
|
+
QueryRebillingInput,
|
|
57
|
+
)
|
|
58
|
+
async def query_rebilling(args: QueryRebillingInput) -> str:
|
|
59
|
+
client = get_client()
|
|
60
|
+
result = await client.post(
|
|
61
|
+
f"/mcp/{args.provider}-rebilling/query", {"query": args.query}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# Format result
|
|
65
|
+
return str(result)
|