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.
@@ -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,3 @@
1
+ """Pier MCP Server - A scalable Model Context Protocol server."""
2
+
3
+ __version__ = "0.1.0"
@@ -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,3 @@
1
+ """Tool implementations - add your tools here."""
2
+
3
+ # Import and register tools in main.py
@@ -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)