clevertech-mcp-server 0.1.0__py3-none-any.whl
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.
- clevertech_mcp/__init__.py +0 -0
- clevertech_mcp/auth.py +69 -0
- clevertech_mcp/client.py +54 -0
- clevertech_mcp/config.py +35 -0
- clevertech_mcp/rate_limit.py +100 -0
- clevertech_mcp/server.py +81 -0
- clevertech_mcp/tools/__init__.py +53 -0
- clevertech_mcp/tools/building.py +95 -0
- clevertech_mcp/tools/business.py +60 -0
- clevertech_mcp/tools/dls.py +119 -0
- clevertech_mcp/tools/geo.py +43 -0
- clevertech_mcp/tools/meta.py +49 -0
- clevertech_mcp/tools/property.py +167 -0
- clevertech_mcp/tools/zoning.py +58 -0
- clevertech_mcp_server-0.1.0.dist-info/METADATA +232 -0
- clevertech_mcp_server-0.1.0.dist-info/RECORD +19 -0
- clevertech_mcp_server-0.1.0.dist-info/WHEEL +4 -0
- clevertech_mcp_server-0.1.0.dist-info/entry_points.txt +2 -0
- clevertech_mcp_server-0.1.0.dist-info/licenses/LICENSE +21 -0
|
File without changes
|
clevertech_mcp/auth.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication helpers for the CleverTech MCP server.
|
|
3
|
+
|
|
4
|
+
Handles extraction of client IP, user API keys from request headers,
|
|
5
|
+
and resolution of the upstream API key.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional, TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
if TYPE_CHECKING:
|
|
11
|
+
from starlette.requests import Request
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# MCP tools receive a *context* object (starlette Request or similar).
|
|
15
|
+
# We use duck-typing to support both Starlette's Request and MCP's
|
|
16
|
+
# RequestContext / MiddlewareContext.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _extract_client_ip(context) -> str:
|
|
20
|
+
"""Extract the client IP address from an MCP/starlette context.
|
|
21
|
+
|
|
22
|
+
Prefers X-Forwarded-For (first entry) when present; falls back to
|
|
23
|
+
``context.request.client.host`` or ``"unknown"``.
|
|
24
|
+
"""
|
|
25
|
+
# Starlette-style request
|
|
26
|
+
request: Optional["Request"] = getattr(context, "request", None)
|
|
27
|
+
if request is not None:
|
|
28
|
+
forwarded = request.headers.get("X-Forwarded-For")
|
|
29
|
+
if forwarded:
|
|
30
|
+
# X-Forwarded-For can contain a comma-separated list
|
|
31
|
+
return forwarded.split(",")[0].strip()
|
|
32
|
+
if request.client is not None:
|
|
33
|
+
return request.client.host
|
|
34
|
+
return "unknown"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_user_api_key(context) -> Optional[str]:
|
|
38
|
+
"""Extract a user-supplied API key from the request context.
|
|
39
|
+
|
|
40
|
+
Looks for an ``Authorization: Bearer <token>`` header. Returns
|
|
41
|
+
``None`` if no key is present.
|
|
42
|
+
"""
|
|
43
|
+
import os
|
|
44
|
+
|
|
45
|
+
request = getattr(context, "request", None)
|
|
46
|
+
if request is not None:
|
|
47
|
+
auth_header: Optional[str] = request.headers.get("Authorization", "")
|
|
48
|
+
if auth_header.startswith("Bearer "):
|
|
49
|
+
return auth_header[7:] # strip "Bearer "
|
|
50
|
+
return os.getenv("CLEVERTECH_API_KEY")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def get_upstream_key(
|
|
54
|
+
user_api_key: Optional[str],
|
|
55
|
+
server_api_key: Optional[str],
|
|
56
|
+
) -> Optional[str]:
|
|
57
|
+
"""Return the API key to use for upstream CleverTech API calls.
|
|
58
|
+
|
|
59
|
+
Prefers the user-provided key (authenticated user) over the server-
|
|
60
|
+
wide key (anonymous / default). Returns ``None`` if neither is set.
|
|
61
|
+
"""
|
|
62
|
+
if user_api_key:
|
|
63
|
+
return user_api_key
|
|
64
|
+
return server_api_key
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def is_authenticated(user_api_key: Optional[str]) -> bool:
|
|
68
|
+
"""Check if a user API key was provided."""
|
|
69
|
+
return bool(user_api_key) # Empty string is not authenticated
|
clevertech_mcp/client.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CleverTech API client — async httpx wrapper.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CleverTechClient:
|
|
10
|
+
"""Async HTTP client for the CleverTech API."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, base_url: str, api_key: Optional[str] = None) -> None:
|
|
13
|
+
self._base_url = base_url.rstrip("/")
|
|
14
|
+
self._api_key = api_key
|
|
15
|
+
self._client: Optional[httpx.AsyncClient] = None
|
|
16
|
+
|
|
17
|
+
def _get_client(self) -> httpx.AsyncClient:
|
|
18
|
+
"""Lazily initialise and return the underlying httpx.AsyncClient."""
|
|
19
|
+
if self._client is None:
|
|
20
|
+
headers: dict[str, str] = {
|
|
21
|
+
"Accept": "application/json",
|
|
22
|
+
}
|
|
23
|
+
if self._api_key:
|
|
24
|
+
headers["Authorization"] = f"Bearer {self._api_key}"
|
|
25
|
+
self._client = httpx.AsyncClient(
|
|
26
|
+
base_url=self._base_url,
|
|
27
|
+
headers=headers,
|
|
28
|
+
timeout=30.0,
|
|
29
|
+
)
|
|
30
|
+
return self._client
|
|
31
|
+
|
|
32
|
+
async def get(self, path: str, params: Optional[dict[str, Any]] = None) -> Any:
|
|
33
|
+
"""Perform an async GET request and return the parsed JSON body."""
|
|
34
|
+
client = self._get_client()
|
|
35
|
+
response = await client.get(path, params=params)
|
|
36
|
+
response.raise_for_status()
|
|
37
|
+
return response.json()
|
|
38
|
+
|
|
39
|
+
async def post(
|
|
40
|
+
self,
|
|
41
|
+
path: str,
|
|
42
|
+
json: Optional[dict[str, Any]] = None,
|
|
43
|
+
) -> Any:
|
|
44
|
+
"""Perform an async POST request and return the parsed JSON body."""
|
|
45
|
+
client = self._get_client()
|
|
46
|
+
response = await client.post(path, json=json)
|
|
47
|
+
response.raise_for_status()
|
|
48
|
+
return response.json()
|
|
49
|
+
|
|
50
|
+
async def close(self) -> None:
|
|
51
|
+
"""Close the underlying HTTP client, if initialised."""
|
|
52
|
+
if self._client is not None:
|
|
53
|
+
await self._client.aclose()
|
|
54
|
+
self._client = None
|
clevertech_mcp/config.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CleverTech MCP Server — configuration loader.
|
|
3
|
+
|
|
4
|
+
Loads settings from environment variables via python-dotenv.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
from dotenv import load_dotenv
|
|
9
|
+
|
|
10
|
+
load_dotenv()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_config() -> dict:
|
|
14
|
+
"""Load and return the server configuration as a dict.
|
|
15
|
+
|
|
16
|
+
Environment variables read:
|
|
17
|
+
CLEVERTECH_API_URL — base URL for the CleverTech API
|
|
18
|
+
CLEVERTECH_API_KEY — API key for authenticated requests
|
|
19
|
+
PORT — port for SSE transport
|
|
20
|
+
TRANSPORT — transport mode (stdio or sse)
|
|
21
|
+
LOG_LEVEL — logging level
|
|
22
|
+
RATE_LIMIT_ANON_DAILY — max anonymous requests per day
|
|
23
|
+
RATE_LIMIT_ANON_BURST — max anonymous requests per minute
|
|
24
|
+
SENTRY_DSN — Sentry DSN for error tracking
|
|
25
|
+
"""
|
|
26
|
+
return {
|
|
27
|
+
"api_url": os.getenv("CLEVERTECH_API_URL", "https://clevertech.ca"),
|
|
28
|
+
"api_key": os.getenv("CLEVERTECH_API_KEY"),
|
|
29
|
+
"port": int(os.getenv("PORT", "8001")),
|
|
30
|
+
"transport": os.getenv("TRANSPORT", "stdio"),
|
|
31
|
+
"log_level": os.getenv("LOG_LEVEL", "INFO"),
|
|
32
|
+
"rate_limit_anon_daily": int(os.getenv("RATE_LIMIT_ANON_DAILY", "50")),
|
|
33
|
+
"rate_limit_anon_burst": int(os.getenv("RATE_LIMIT_ANON_BURST", "10")),
|
|
34
|
+
"sentry_dsn": os.getenv("SENTRY_DSN"),
|
|
35
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Local in-memory rate limiter for anonymous users.
|
|
3
|
+
|
|
4
|
+
Tracks requests per source IP using a sliding-window approach:
|
|
5
|
+
- Daily limit (24-hour sliding window)
|
|
6
|
+
- Burst limit (1-minute sliding window)
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
import threading
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LocalRateLimiter:
|
|
15
|
+
"""Thread-safe in-memory rate limiter keyed by source IP."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
daily_limit: int = 50,
|
|
20
|
+
burst_per_minute: int = 10,
|
|
21
|
+
) -> None:
|
|
22
|
+
self._daily_limit = daily_limit
|
|
23
|
+
self._burst_per_minute = burst_per_minute
|
|
24
|
+
self._storage: dict[str, list[tuple[float, int]]] = {}
|
|
25
|
+
self._lock = threading.Lock()
|
|
26
|
+
|
|
27
|
+
# ------------------------------------------------------------------
|
|
28
|
+
# Internal helpers
|
|
29
|
+
# ------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
def _prune(self, key: str, now: float) -> None:
|
|
32
|
+
"""Remove entries older than 24 hours from *key*'s history."""
|
|
33
|
+
if key not in self._storage:
|
|
34
|
+
return
|
|
35
|
+
cutoff = now - 86400.0 # 24 hours
|
|
36
|
+
self._storage[key] = [
|
|
37
|
+
(ts, cost) for ts, cost in self._storage[key] if ts > cutoff
|
|
38
|
+
]
|
|
39
|
+
if not self._storage[key]:
|
|
40
|
+
del self._storage[key]
|
|
41
|
+
|
|
42
|
+
def _daily_usage(self, key: str) -> int:
|
|
43
|
+
"""Total cost accumulated by *key* in the last 24 hours."""
|
|
44
|
+
return sum(cost for _, cost in self._storage.get(key, []))
|
|
45
|
+
|
|
46
|
+
def _burst_usage(self, key: str, now: float) -> int:
|
|
47
|
+
"""Total cost accumulated by *key* in the last 60 seconds."""
|
|
48
|
+
cutoff = now - 60.0
|
|
49
|
+
return sum(
|
|
50
|
+
cost
|
|
51
|
+
for ts, cost in self._storage.get(key, [])
|
|
52
|
+
if ts > cutoff
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
# Public API
|
|
57
|
+
# ------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
def check(self, source_ip: str, cost: int = 1) -> tuple[bool, str]:
|
|
60
|
+
"""Check whether *source_ip* is allowed to proceed.
|
|
61
|
+
|
|
62
|
+
Returns ``(True, "")`` if allowed, or ``(False, message)`` if
|
|
63
|
+
the request would exceed a rate limit.
|
|
64
|
+
"""
|
|
65
|
+
now = time.time()
|
|
66
|
+
with self._lock:
|
|
67
|
+
self._prune(source_ip, now)
|
|
68
|
+
|
|
69
|
+
daily = self._daily_usage(source_ip)
|
|
70
|
+
if daily + cost > self._daily_limit:
|
|
71
|
+
remaining = self._daily_limit - daily
|
|
72
|
+
return False, (
|
|
73
|
+
f"Daily rate limit exceeded. "
|
|
74
|
+
f"{max(0, remaining)} of {self._daily_limit} requests remaining."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
burst = self._burst_usage(source_ip, now)
|
|
78
|
+
if burst + cost > self._burst_per_minute:
|
|
79
|
+
return False, (
|
|
80
|
+
f"Burst rate limit exceeded. "
|
|
81
|
+
f"Max {self._burst_per_minute} requests per minute."
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Record the request
|
|
85
|
+
self._storage.setdefault(source_ip, []).append((now, cost))
|
|
86
|
+
return True, ""
|
|
87
|
+
|
|
88
|
+
def remaining(self, source_ip: str) -> dict[str, int]:
|
|
89
|
+
"""Return a summary of remaining capacity for *source_ip*."""
|
|
90
|
+
now = time.time()
|
|
91
|
+
with self._lock:
|
|
92
|
+
self._prune(source_ip, now)
|
|
93
|
+
daily = self._daily_usage(source_ip)
|
|
94
|
+
burst = self._burst_usage(source_ip, now)
|
|
95
|
+
return {
|
|
96
|
+
"daily_used": daily,
|
|
97
|
+
"daily_remaining": max(0, self._daily_limit - daily),
|
|
98
|
+
"daily_limit": self._daily_limit,
|
|
99
|
+
"burst_remaining": max(0, self._burst_per_minute - burst),
|
|
100
|
+
}
|
clevertech_mcp/server.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CleverTech MCP Server — main entry point.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import argparse
|
|
8
|
+
import json
|
|
9
|
+
from mcp.server.fastmcp import FastMCP
|
|
10
|
+
from starlette.responses import JSONResponse
|
|
11
|
+
from starlette.routing import Route
|
|
12
|
+
|
|
13
|
+
from clevertech_mcp.config import load_config
|
|
14
|
+
from clevertech_mcp.client import CleverTechClient
|
|
15
|
+
from clevertech_mcp.tools import register_all_tools
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def health_endpoint(request):
|
|
19
|
+
"""Health check endpoint for Railway deployment."""
|
|
20
|
+
return JSONResponse({"status": "ok", "service": "clevertech-mcp"})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def create_server(config: dict) -> FastMCP:
|
|
24
|
+
"""Build and return a fully-configured FastMCP server instance."""
|
|
25
|
+
mcp = FastMCP("clevertech-mcp")
|
|
26
|
+
|
|
27
|
+
client = CleverTechClient(
|
|
28
|
+
base_url=config["api_url"],
|
|
29
|
+
api_key=config.get("api_key"),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
register_all_tools(mcp, client, config)
|
|
33
|
+
return mcp
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main():
|
|
37
|
+
"""CLI entry point for the CleverTech MCP server."""
|
|
38
|
+
parser = argparse.ArgumentParser(
|
|
39
|
+
description="CleverTech MCP Server — Canadian government data APIs"
|
|
40
|
+
)
|
|
41
|
+
parser.add_argument(
|
|
42
|
+
"--transport",
|
|
43
|
+
choices=["stdio", "sse"],
|
|
44
|
+
default=os.getenv("TRANSPORT", "stdio"),
|
|
45
|
+
help="Transport mode (default: %(default)s)",
|
|
46
|
+
)
|
|
47
|
+
parser.add_argument(
|
|
48
|
+
"--host",
|
|
49
|
+
default=os.getenv("HOST", "0.0.0.0"),
|
|
50
|
+
help="Host to bind when using SSE transport (default: %(default)s)",
|
|
51
|
+
)
|
|
52
|
+
parser.add_argument(
|
|
53
|
+
"--port",
|
|
54
|
+
type=int,
|
|
55
|
+
default=int(os.getenv("PORT", "8001")),
|
|
56
|
+
help="Port to bind when using SSE transport (default: %(default)s)",
|
|
57
|
+
)
|
|
58
|
+
args = parser.parse_args()
|
|
59
|
+
|
|
60
|
+
config = load_config()
|
|
61
|
+
config["host"] = args.host
|
|
62
|
+
config["port"] = args.port
|
|
63
|
+
|
|
64
|
+
mcp = create_server(config)
|
|
65
|
+
|
|
66
|
+
if args.transport == "sse":
|
|
67
|
+
# Register /health endpoint for Railway health checks
|
|
68
|
+
sse_app = mcp.sse_app()
|
|
69
|
+
if not any(
|
|
70
|
+
getattr(r, "path", "") == "/health"
|
|
71
|
+
for r in getattr(sse_app, "routes", [])
|
|
72
|
+
):
|
|
73
|
+
sse_app.router.routes.insert(0, Route("/health", endpoint=health_endpoint))
|
|
74
|
+
import uvicorn
|
|
75
|
+
uvicorn.run(sse_app, host=args.host, port=args.port)
|
|
76
|
+
else:
|
|
77
|
+
mcp.run(transport="stdio")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
if __name__ == "__main__":
|
|
81
|
+
main()
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Aggregate all tool registrations onto a single FastMCP instance.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP
|
|
6
|
+
from clevertech_mcp.client import CleverTechClient
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def register_all_tools(mcp: FastMCP, client: CleverTechClient, config: dict) -> None:
|
|
10
|
+
"""Wire up every tool category to the FastMCP server."""
|
|
11
|
+
# Lazy imports so that individual tool modules can be worked on in
|
|
12
|
+
# parallel without one missing module breaking the whole scaffold.
|
|
13
|
+
try:
|
|
14
|
+
from clevertech_mcp.tools.property import register_property_tools
|
|
15
|
+
register_property_tools(mcp, client, config)
|
|
16
|
+
except ImportError:
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from clevertech_mcp.tools.dls import register_dls_tools
|
|
21
|
+
register_dls_tools(mcp, client, config)
|
|
22
|
+
except ImportError:
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from clevertech_mcp.tools.building import register_building_tools
|
|
27
|
+
register_building_tools(mcp, client, config)
|
|
28
|
+
except ImportError:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
from clevertech_mcp.tools.zoning import register_zoning_tools
|
|
33
|
+
register_zoning_tools(mcp, client, config)
|
|
34
|
+
except ImportError:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
from clevertech_mcp.tools.business import register_business_tools
|
|
39
|
+
register_business_tools(mcp, client, config)
|
|
40
|
+
except ImportError:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
from clevertech_mcp.tools.geo import register_geo_tools
|
|
45
|
+
register_geo_tools(mcp, client, config)
|
|
46
|
+
except ImportError:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
from clevertech_mcp.tools.meta import register_meta_tools
|
|
51
|
+
register_meta_tools(mcp, client, config)
|
|
52
|
+
except ImportError:
|
|
53
|
+
pass
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Building permit MCP tools."""
|
|
2
|
+
|
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
|
4
|
+
from clevertech_mcp.client import CleverTechClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def register_building_tools(mcp: FastMCP, client: CleverTechClient, config: dict):
|
|
8
|
+
"""Register building permit tools."""
|
|
9
|
+
|
|
10
|
+
@mcp.tool(
|
|
11
|
+
name="building_permit_search",
|
|
12
|
+
description="Search building permits by address, contractor, applicant, or permit number across 13+ Canadian cities. Returns permit details including type, value, status, and dates.",
|
|
13
|
+
)
|
|
14
|
+
async def building_permit_search(
|
|
15
|
+
city: str,
|
|
16
|
+
q: str,
|
|
17
|
+
permit_type: str = None,
|
|
18
|
+
limit: int = 20,
|
|
19
|
+
) -> str:
|
|
20
|
+
"""Search building permits.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
city: City slug
|
|
24
|
+
q: Search query (address, contractor, applicant, or permit number)
|
|
25
|
+
permit_type: Filter by permit type (e.g., 'Building', 'Demolition', 'Electrical')
|
|
26
|
+
limit: Max results (1-200, default 20)
|
|
27
|
+
"""
|
|
28
|
+
params = {"q": q, "limit": min(limit, 200)}
|
|
29
|
+
if permit_type:
|
|
30
|
+
params["permit_type"] = permit_type
|
|
31
|
+
|
|
32
|
+
data = await client.get(f"/api/{city}/building/search", params=params)
|
|
33
|
+
|
|
34
|
+
results = data.get("results", [])
|
|
35
|
+
total = data.get("total", 0)
|
|
36
|
+
message = data.get("_message", "")
|
|
37
|
+
|
|
38
|
+
lines = [f"Found {len(results)} of {total} building permits"]
|
|
39
|
+
if message:
|
|
40
|
+
lines.append(f"\n{message}")
|
|
41
|
+
|
|
42
|
+
for r in results:
|
|
43
|
+
lines.extend([
|
|
44
|
+
"",
|
|
45
|
+
"---",
|
|
46
|
+
f"Permit: {r.get('permit_number', 'N/A')}",
|
|
47
|
+
f"Type: {r.get('permit_type', 'N/A')}",
|
|
48
|
+
f"Status: {r.get('status', 'N/A')}",
|
|
49
|
+
f"Address: {r.get('address', 'N/A')}",
|
|
50
|
+
f"Applicant: {r.get('applicant', 'N/A')}",
|
|
51
|
+
f"Value: ${r.get('job_value', 0):,.0f}" if r.get('job_value') is not None else "Value: N/A",
|
|
52
|
+
f"Issued: {r.get('issued_date', 'N/A')}",
|
|
53
|
+
])
|
|
54
|
+
|
|
55
|
+
return "\n".join(lines)
|
|
56
|
+
|
|
57
|
+
@mcp.tool(
|
|
58
|
+
name="building_permit_recent",
|
|
59
|
+
description="Get the most recently issued building permits for a city. Useful for monitoring new construction activity.",
|
|
60
|
+
)
|
|
61
|
+
async def building_permit_recent(
|
|
62
|
+
city: str,
|
|
63
|
+
limit: int = 20,
|
|
64
|
+
) -> str:
|
|
65
|
+
"""Get recently issued permits.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
city: City slug
|
|
69
|
+
limit: Max results (1-100, default 20)
|
|
70
|
+
"""
|
|
71
|
+
data = await client.get(
|
|
72
|
+
f"/api/{city}/building/recent",
|
|
73
|
+
params={"limit": min(limit, 100)},
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
results = data.get("results", [])
|
|
77
|
+
message = data.get("_message", "")
|
|
78
|
+
|
|
79
|
+
lines = [f"Recent building permits ({len(results)})"]
|
|
80
|
+
if message:
|
|
81
|
+
lines.append(f"\n{message}")
|
|
82
|
+
|
|
83
|
+
for r in results:
|
|
84
|
+
lines.extend([
|
|
85
|
+
"",
|
|
86
|
+
"---",
|
|
87
|
+
f"Permit: {r.get('permit_number', 'N/A')}",
|
|
88
|
+
f"Type: {r.get('permit_type', 'N/A')}",
|
|
89
|
+
f"Status: {r.get('status', 'N/A')}",
|
|
90
|
+
f"Address: {r.get('address', 'N/A')}",
|
|
91
|
+
f"Value: ${r.get('job_value', 0):,.0f}" if r.get('job_value') is not None else "Value: N/A",
|
|
92
|
+
f"Issued: {r.get('issued_date', 'N/A')}",
|
|
93
|
+
])
|
|
94
|
+
|
|
95
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Business registry MCP tools."""
|
|
2
|
+
|
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
|
4
|
+
from clevertech_mcp.client import CleverTechClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def register_business_tools(mcp: FastMCP, client: CleverTechClient, config: dict):
|
|
8
|
+
"""Register business registry tools."""
|
|
9
|
+
|
|
10
|
+
@mcp.tool(
|
|
11
|
+
name="business_registry_search",
|
|
12
|
+
description="Search the Canadian federal business registry. Find corporations by name, province, city, status, or industry act.",
|
|
13
|
+
)
|
|
14
|
+
async def business_registry_search(
|
|
15
|
+
q: str,
|
|
16
|
+
province: str = None,
|
|
17
|
+
city: str = None,
|
|
18
|
+
status: str = None,
|
|
19
|
+
limit: int = 25,
|
|
20
|
+
) -> str:
|
|
21
|
+
"""Search the business registry.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
q: Corporation name or keyword
|
|
25
|
+
province: Two-letter province code (AB, BC, ON, QC, etc.)
|
|
26
|
+
city: City name
|
|
27
|
+
status: Corporation status (Active, Dissolved, etc.)
|
|
28
|
+
limit: Max results (1-100, default 25)
|
|
29
|
+
"""
|
|
30
|
+
params = {"q": q, "limit": min(limit, 100)}
|
|
31
|
+
if province:
|
|
32
|
+
params["province"] = province
|
|
33
|
+
if city:
|
|
34
|
+
params["city"] = city
|
|
35
|
+
if status:
|
|
36
|
+
params["status"] = status
|
|
37
|
+
|
|
38
|
+
data = await client.get("/api/registry/search", params=params)
|
|
39
|
+
|
|
40
|
+
results = data.get("results", [])
|
|
41
|
+
total = data.get("total", len(results))
|
|
42
|
+
message = data.get("_message", "")
|
|
43
|
+
|
|
44
|
+
lines = [f"Found {len(results)} of {total} corporations"]
|
|
45
|
+
if message:
|
|
46
|
+
lines.append(f"\n{message}")
|
|
47
|
+
|
|
48
|
+
for r in results:
|
|
49
|
+
lines.extend([
|
|
50
|
+
"",
|
|
51
|
+
"---",
|
|
52
|
+
f"Name: {r.get('corporation_name', r.get('name', 'N/A'))}",
|
|
53
|
+
f"Number: {r.get('corporation_number', r.get('number', 'N/A'))}",
|
|
54
|
+
f"Status: {r.get('status', 'N/A')}",
|
|
55
|
+
f"Jurisdiction: {r.get('jurisdiction', r.get('province', 'N/A'))}",
|
|
56
|
+
])
|
|
57
|
+
if r.get('registered_address'):
|
|
58
|
+
lines.append(f"Address: {r['registered_address']}")
|
|
59
|
+
|
|
60
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""DLS conversion MCP tools.
|
|
2
|
+
|
|
3
|
+
Provides GPS ↔ Dominion Land Survey (DLS) coordinate conversion for Western
|
|
4
|
+
Canadian provinces (Alberta, Saskatchewan, Manitoba).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from mcp.server.fastmcp import FastMCP
|
|
10
|
+
from clevertech_mcp.client import CleverTechClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register_dls_tools(mcp: FastMCP, client: CleverTechClient, config: dict) -> None:
|
|
14
|
+
"""Register DLS conversion tools on the FastMCP server."""
|
|
15
|
+
|
|
16
|
+
@mcp.tool(
|
|
17
|
+
name="dls_convert",
|
|
18
|
+
description=(
|
|
19
|
+
"Convert between GPS coordinates and Dominion Land Survey (DLS) "
|
|
20
|
+
"grid system used in Western Canada. Supports GPS→DLS and "
|
|
21
|
+
"DLS→GPS directions."
|
|
22
|
+
),
|
|
23
|
+
)
|
|
24
|
+
async def dls_convert(
|
|
25
|
+
direction: str,
|
|
26
|
+
lat: Optional[float] = None,
|
|
27
|
+
lon: Optional[float] = None,
|
|
28
|
+
dls_string: Optional[str] = None,
|
|
29
|
+
province: Optional[str] = None,
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Convert a single coordinate between GPS and DLS.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
direction: 'gps_to_dls' or 'dls_to_gps'.
|
|
35
|
+
lat: Latitude (required for gps_to_dls).
|
|
36
|
+
lon: Longitude (required for gps_to_dls).
|
|
37
|
+
dls_string: DLS grid reference (required for dls_to_gps).
|
|
38
|
+
province: Province code AB/SK/MB (optional, auto-detected).
|
|
39
|
+
"""
|
|
40
|
+
if direction == "gps_to_dls":
|
|
41
|
+
if lat is None or lon is None:
|
|
42
|
+
return "Error: lat and lon are required for gps_to_dls direction."
|
|
43
|
+
payload: dict = {"lat": lat, "lon": lon}
|
|
44
|
+
if province:
|
|
45
|
+
payload["province"] = province
|
|
46
|
+
data = await client.post("/api/v1/convert/gps-to-dls", json=payload)
|
|
47
|
+
else:
|
|
48
|
+
if not dls_string:
|
|
49
|
+
return "Error: dls_string is required for dls_to_gps direction."
|
|
50
|
+
payload = {"dls_string": dls_string}
|
|
51
|
+
if province:
|
|
52
|
+
payload["province"] = province
|
|
53
|
+
data = await client.post("/api/v1/convert/dls-to-gps", json=payload)
|
|
54
|
+
|
|
55
|
+
lines: list[str] = []
|
|
56
|
+
if data.get("dls"):
|
|
57
|
+
lines.append(f"DLS: {data['dls']}")
|
|
58
|
+
if data.get("province"):
|
|
59
|
+
lines.append(f"Province: {data['province']}")
|
|
60
|
+
if "lat" in data and "lon" in data:
|
|
61
|
+
lines.append(f"GPS: {data['lat']}, {data['lon']}")
|
|
62
|
+
if data.get("confidence"):
|
|
63
|
+
lines.append(f"Confidence: {data['confidence']}")
|
|
64
|
+
if data.get("distance_km"):
|
|
65
|
+
lines.append(f"Distance to grid center: {data['distance_km']} km")
|
|
66
|
+
if data.get("_message"):
|
|
67
|
+
lines.append(f"\n{data['message']}")
|
|
68
|
+
|
|
69
|
+
return "\n".join(lines) if lines else "No result returned."
|
|
70
|
+
|
|
71
|
+
@mcp.tool(
|
|
72
|
+
name="dls_batch",
|
|
73
|
+
description=(
|
|
74
|
+
"Convert multiple GPS coordinates to DLS or multiple DLS strings "
|
|
75
|
+
"to GPS in a single batch request. Up to 100 items per batch."
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
async def dls_batch(
|
|
79
|
+
direction: str,
|
|
80
|
+
items: list[dict],
|
|
81
|
+
province: Optional[str] = None,
|
|
82
|
+
) -> str:
|
|
83
|
+
"""Batch convert coordinates.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
direction: 'gps_to_dls' or 'dls_to_gps'.
|
|
87
|
+
items: List of coordinate objects.
|
|
88
|
+
gps_to_dls: [{"lat": 51.0, "lon": -114.0}, ...]
|
|
89
|
+
dls_to_gps: [{"dls_string": "NW-16-24-1-W5"}, ...]
|
|
90
|
+
province: Province code (optional).
|
|
91
|
+
"""
|
|
92
|
+
if not items:
|
|
93
|
+
return "Error: items list is empty."
|
|
94
|
+
if len(items) > 100:
|
|
95
|
+
return "Error: batch limited to 100 items."
|
|
96
|
+
|
|
97
|
+
payload: dict = {"direction": direction, "items": items}
|
|
98
|
+
if province:
|
|
99
|
+
payload["province"] = province
|
|
100
|
+
|
|
101
|
+
data = await client.post("/api/v1/convert/batch", json=payload)
|
|
102
|
+
|
|
103
|
+
results = data.get("results", [])
|
|
104
|
+
lines: list[str] = [f"Batch result: {data.get('count', 0)} conversions"]
|
|
105
|
+
|
|
106
|
+
for i, r in enumerate(results[:20]):
|
|
107
|
+
if r.get("dls"):
|
|
108
|
+
line = f"{i + 1}. {r['dls']}"
|
|
109
|
+
if "lat" in r and "lon" in r:
|
|
110
|
+
line += f" → ({r['lat']}, {r['lon']})"
|
|
111
|
+
lines.append(line)
|
|
112
|
+
|
|
113
|
+
if len(results) > 20:
|
|
114
|
+
lines.append(f"\n... and {len(results) - 20} more")
|
|
115
|
+
|
|
116
|
+
if data.get("_message"):
|
|
117
|
+
lines.append(f"\n{data['message']}")
|
|
118
|
+
|
|
119
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Geocoding MCP tools."""
|
|
2
|
+
|
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
|
4
|
+
from clevertech_mcp.client import CleverTechClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def register_geo_tools(mcp: FastMCP, client: CleverTechClient, config: dict):
|
|
8
|
+
"""Register geocoding tools."""
|
|
9
|
+
|
|
10
|
+
@mcp.tool(
|
|
11
|
+
name="reverse_geocode",
|
|
12
|
+
description="Convert GPS coordinates to a human-readable address, city, neighborhood, province, and DLS grid reference. Works across all Canadian cities in the CleverTech database.",
|
|
13
|
+
)
|
|
14
|
+
async def reverse_geocode(lat: float, lon: float) -> str:
|
|
15
|
+
"""Reverse geocode GPS coordinates.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
lat: Latitude
|
|
19
|
+
lon: Longitude
|
|
20
|
+
"""
|
|
21
|
+
data = await client.get(
|
|
22
|
+
"/api/v1/geocode/reverse",
|
|
23
|
+
params={"lat": lat, "lon": lon},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
lines = [
|
|
27
|
+
f"Address: {data.get('address', 'N/A')}",
|
|
28
|
+
f"City: {data.get('city', 'N/A')}",
|
|
29
|
+
f"Province: {data.get('province', 'N/A')}",
|
|
30
|
+
f"Postal Code: {data.get('postal_code', data.get('postcode', 'N/A'))}",
|
|
31
|
+
f"Neighborhood: {data.get('neighborhood', data.get('neighbourhood', 'N/A'))}",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
if data.get("dls"):
|
|
35
|
+
lines.append(f"DLS: {data['dls']}")
|
|
36
|
+
if data.get("lat") and data.get("lon"):
|
|
37
|
+
lines.append(f"GPS: {data['lat']}, {data['lon']}")
|
|
38
|
+
if data.get("confidence"):
|
|
39
|
+
lines.append(f"Confidence: {data['confidence']}")
|
|
40
|
+
if data.get("_message"):
|
|
41
|
+
lines.append(f"\n{data['_message']}")
|
|
42
|
+
|
|
43
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Meta MCP tools — server info, capabilities, city listing.
|
|
2
|
+
|
|
3
|
+
Discovery endpoints so callers can learn which cities and services are
|
|
4
|
+
available before making domain-specific calls.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from mcp.server.fastmcp import FastMCP
|
|
10
|
+
from clevertech_mcp.client import CleverTechClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register_meta_tools(mcp: FastMCP, client: CleverTechClient, config: dict) -> None:
|
|
14
|
+
"""Register meta-discovery tools on the FastMCP server."""
|
|
15
|
+
|
|
16
|
+
@mcp.tool(
|
|
17
|
+
name="list_cities",
|
|
18
|
+
description=(
|
|
19
|
+
"List all available cities with their slugs, API capabilities, "
|
|
20
|
+
"and property counts. Use this first to discover which cities are "
|
|
21
|
+
"available and what services each supports."
|
|
22
|
+
),
|
|
23
|
+
)
|
|
24
|
+
async def list_cities() -> str:
|
|
25
|
+
"""List available cities and their capabilities."""
|
|
26
|
+
data = await client.get("/api/cities")
|
|
27
|
+
|
|
28
|
+
cities = data.get("cities", data.get("results", []))
|
|
29
|
+
if not cities:
|
|
30
|
+
return json.dumps(data, indent=2, default=str)
|
|
31
|
+
|
|
32
|
+
lines: list[str] = ["# Available Cities\n"]
|
|
33
|
+
for city in cities:
|
|
34
|
+
name = city.get("name", city.get("city", "Unknown"))
|
|
35
|
+
slug = city.get("slug", city.get("city_slug", "?"))
|
|
36
|
+
props = city.get("property_count", city.get("count", "?"))
|
|
37
|
+
services = city.get("services", city.get("capabilities", []))
|
|
38
|
+
|
|
39
|
+
lines.append(f"## {name}")
|
|
40
|
+
lines.append(f"- Slug: {slug}")
|
|
41
|
+
if isinstance(props, int):
|
|
42
|
+
lines.append(f"- Properties: {props:,}")
|
|
43
|
+
else:
|
|
44
|
+
lines.append(f"- Properties: {props}")
|
|
45
|
+
if services:
|
|
46
|
+
lines.append(f"- Services: {', '.join(services)}")
|
|
47
|
+
lines.append("")
|
|
48
|
+
|
|
49
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Property-related MCP tools.
|
|
2
|
+
|
|
3
|
+
Search, lookup, and report on properties across Canadian cities.
|
|
4
|
+
Provides assessment data, building permits, zoning, and DLS coordinates.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from mcp.server.fastmcp import FastMCP
|
|
10
|
+
from clevertech_mcp.client import CleverTechClient
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def register_property_tools(
|
|
14
|
+
mcp: FastMCP, client: CleverTechClient, config: dict
|
|
15
|
+
) -> None:
|
|
16
|
+
"""Register property search, by-roll, and report tools."""
|
|
17
|
+
|
|
18
|
+
@mcp.tool(
|
|
19
|
+
name="property_search",
|
|
20
|
+
description=(
|
|
21
|
+
"Search for properties by address across 13+ Canadian cities. "
|
|
22
|
+
"Returns assessment data including value, lot size, year built, "
|
|
23
|
+
"and DLS coordinates."
|
|
24
|
+
),
|
|
25
|
+
)
|
|
26
|
+
async def property_search(
|
|
27
|
+
city: str,
|
|
28
|
+
address: str,
|
|
29
|
+
limit: int = 10,
|
|
30
|
+
offset: int = 0,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Search for properties by address.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
city: City slug (calgary, toronto, vancouver, montreal, edmonton,
|
|
36
|
+
etc.).
|
|
37
|
+
address: Street address to search (partial matches supported).
|
|
38
|
+
limit: Max results (1-200, default 10).
|
|
39
|
+
offset: Pagination offset.
|
|
40
|
+
"""
|
|
41
|
+
data = await client.get(
|
|
42
|
+
f"/api/{city}/property/search",
|
|
43
|
+
params={
|
|
44
|
+
"address": address,
|
|
45
|
+
"limit": min(limit, 200),
|
|
46
|
+
"offset": offset,
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
results = data.get("results", [])
|
|
51
|
+
total = data.get("total", 0)
|
|
52
|
+
message = data.get("_message", "")
|
|
53
|
+
|
|
54
|
+
lines: list[str] = [f"Found {len(results)} of {total} properties"]
|
|
55
|
+
if message:
|
|
56
|
+
lines.append(f"\n{message}")
|
|
57
|
+
|
|
58
|
+
for r in results:
|
|
59
|
+
lines.extend(
|
|
60
|
+
[
|
|
61
|
+
"",
|
|
62
|
+
"---",
|
|
63
|
+
f"Address: {r.get('address', 'N/A')}",
|
|
64
|
+
f"Roll: {r.get('roll_number', 'N/A')}",
|
|
65
|
+
]
|
|
66
|
+
)
|
|
67
|
+
if r.get("assessed_value") is not None:
|
|
68
|
+
lines.append(f"Assessed Value: ${r['assessed_value']:,.0f}")
|
|
69
|
+
else:
|
|
70
|
+
lines.append("Assessed Value: N/A")
|
|
71
|
+
lines.append(f"Type: {r.get('property_type', 'N/A')}")
|
|
72
|
+
if r.get("dls"):
|
|
73
|
+
lines.append(f"DLS: {r['dls']}")
|
|
74
|
+
|
|
75
|
+
return "\n".join(lines)
|
|
76
|
+
|
|
77
|
+
@mcp.tool(
|
|
78
|
+
name="property_report",
|
|
79
|
+
description=(
|
|
80
|
+
"Get a consolidated property report — assessment data, building "
|
|
81
|
+
"permits at this address, zoning information, and DLS coordinates "
|
|
82
|
+
"in a single response."
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
async def property_report(city: str, roll_number: str) -> str:
|
|
86
|
+
"""Get a consolidated property report.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
city: City slug.
|
|
90
|
+
roll_number: Property roll number.
|
|
91
|
+
"""
|
|
92
|
+
data = await client.get(f"/api/{city}/property/report/{roll_number}")
|
|
93
|
+
|
|
94
|
+
message = data.get("_message", "")
|
|
95
|
+
lines: list[str] = [
|
|
96
|
+
"# Property Report",
|
|
97
|
+
f"**Address:** {data.get('address', 'N/A')}",
|
|
98
|
+
f"**Roll:** {data.get('roll_number', 'N/A')}",
|
|
99
|
+
f"**DLS:** {data.get('dls', 'N/A')}",
|
|
100
|
+
f"**Generated:** {data.get('generated_at', 'N/A')}",
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
prop = data.get("property", {})
|
|
104
|
+
if prop:
|
|
105
|
+
lines.append("")
|
|
106
|
+
lines.append("## Assessment")
|
|
107
|
+
if prop.get("assessed_value") is not None:
|
|
108
|
+
lines.append(f"- Assessed Value: ${prop['assessed_value']:,.0f}")
|
|
109
|
+
else:
|
|
110
|
+
lines.append("- Assessed Value: N/A")
|
|
111
|
+
if prop.get("land_value") is not None:
|
|
112
|
+
lines.append(f"- Land: ${prop['land_value']:,.0f}")
|
|
113
|
+
else:
|
|
114
|
+
lines.append("- Land: N/A")
|
|
115
|
+
if prop.get("improvement_value") is not None:
|
|
116
|
+
lines.append(f"- Building: ${prop['improvement_value']:,.0f}")
|
|
117
|
+
else:
|
|
118
|
+
lines.append("- Building: N/A")
|
|
119
|
+
lines.append(f"- Year Built: {prop.get('year_built', 'N/A')}")
|
|
120
|
+
lines.append(f"- Lot Size: {prop.get('lot_size_sqm', 'N/A')} m²")
|
|
121
|
+
|
|
122
|
+
permits = data.get("permits", [])
|
|
123
|
+
if permits:
|
|
124
|
+
lines.append(f"\n## Building Permits ({len(permits)})")
|
|
125
|
+
for p in permits[:10]:
|
|
126
|
+
job_value = (
|
|
127
|
+
f"${p.get('job_value', 0):,.0f}"
|
|
128
|
+
if p.get("job_value") is not None
|
|
129
|
+
else "N/A"
|
|
130
|
+
)
|
|
131
|
+
lines.append(
|
|
132
|
+
f"- {p.get('permit_number', 'N/A')}: "
|
|
133
|
+
f"{p.get('permit_type', 'N/A')} — {job_value}"
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
zoning = data.get("zoning")
|
|
137
|
+
if zoning:
|
|
138
|
+
lines.extend(
|
|
139
|
+
[
|
|
140
|
+
"",
|
|
141
|
+
"## Zoning",
|
|
142
|
+
f"- Zone: {zoning.get('zone_code', 'N/A')}",
|
|
143
|
+
f"- Description: {zoning.get('description', 'N/A')}",
|
|
144
|
+
]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
if message:
|
|
148
|
+
lines.append(f"\n{message}")
|
|
149
|
+
|
|
150
|
+
return "\n".join(lines)
|
|
151
|
+
|
|
152
|
+
@mcp.tool(
|
|
153
|
+
name="property_by_roll",
|
|
154
|
+
description=(
|
|
155
|
+
"Get a single property assessment record by its roll number. "
|
|
156
|
+
"Faster than search when you already know the roll number."
|
|
157
|
+
),
|
|
158
|
+
)
|
|
159
|
+
async def property_by_roll(city: str, roll_number: str) -> str:
|
|
160
|
+
"""Get a property by its roll number.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
city: City slug.
|
|
164
|
+
roll_number: Property roll number.
|
|
165
|
+
"""
|
|
166
|
+
data = await client.get(f"/api/{city}/property/by-roll/{roll_number}")
|
|
167
|
+
return json.dumps(data, indent=2, default=str)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Zoning MCP tools."""
|
|
2
|
+
|
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
|
4
|
+
from clevertech_mcp.client import CleverTechClient
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def register_zoning_tools(mcp: FastMCP, client: CleverTechClient, config: dict):
|
|
8
|
+
"""Register zoning lookup tools."""
|
|
9
|
+
|
|
10
|
+
@mcp.tool(
|
|
11
|
+
name="zoning_lookup",
|
|
12
|
+
description="Look up zoning district information for a GPS point or address in a Canadian city. Returns zone code, description, and district boundaries.",
|
|
13
|
+
)
|
|
14
|
+
async def zoning_lookup(
|
|
15
|
+
city: str,
|
|
16
|
+
lat: float = None,
|
|
17
|
+
lon: float = None,
|
|
18
|
+
address: str = None,
|
|
19
|
+
) -> str:
|
|
20
|
+
"""Get zoning information.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
city: City slug
|
|
24
|
+
lat: Latitude (alternative to address)
|
|
25
|
+
lon: Longitude (alternative to address)
|
|
26
|
+
address: Street address (alternative to lat/lon)
|
|
27
|
+
"""
|
|
28
|
+
if lat is not None and lon is not None:
|
|
29
|
+
params = {"lat": lat, "lon": lon}
|
|
30
|
+
data = await client.get(f"/api/{city}/zoning/by-gps", params=params)
|
|
31
|
+
elif address:
|
|
32
|
+
data = await client.get(f"/api/{city}/zoning/by-gps", params={"address": address})
|
|
33
|
+
else:
|
|
34
|
+
return "Error: Provide either lat+lon or address."
|
|
35
|
+
|
|
36
|
+
message = data.get("_message", "")
|
|
37
|
+
zoning = data.get("zoning", data)
|
|
38
|
+
if isinstance(zoning, list):
|
|
39
|
+
zoning = zoning[0] if zoning else {}
|
|
40
|
+
|
|
41
|
+
lines = [
|
|
42
|
+
f"Zone: {zoning.get('zone_code', zoning.get('zone', 'N/A'))}",
|
|
43
|
+
f"Description: {zoning.get('description', 'N/A')}",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
if zoning.get("district_name"):
|
|
47
|
+
lines.append(f"District: {zoning['district_name']}")
|
|
48
|
+
if zoning.get("land_use"):
|
|
49
|
+
lines.append(f"Land Use: {zoning['land_use']}")
|
|
50
|
+
|
|
51
|
+
bounds = zoning.get("boundaries", zoning.get("geometry"))
|
|
52
|
+
if bounds:
|
|
53
|
+
lines.append(f"Boundaries: Available ({type(bounds).__name__})")
|
|
54
|
+
|
|
55
|
+
if message:
|
|
56
|
+
lines.append(f"\n{message}")
|
|
57
|
+
|
|
58
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: clevertech-mcp-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP Server for CleverTech Canadian government data APIs
|
|
5
|
+
Author-email: CleverTech <dev@clevertech.ca>
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: canada,dls,gis,land-survey,mcp,property
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Requires-Python: >=3.11
|
|
15
|
+
Requires-Dist: httpx>=0.27.0
|
|
16
|
+
Requires-Dist: mcp>=1.0.0
|
|
17
|
+
Requires-Dist: pydantic>=2.0.0
|
|
18
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
19
|
+
Requires-Dist: starlette>=0.36.0
|
|
20
|
+
Requires-Dist: uvicorn>=0.27.0
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# CleverTech MCP Server
|
|
24
|
+
|
|
25
|
+
**Canadian government data for AI agents.** Property assessments, building permits, zoning, business registry, DLS grid conversion — 50 free queries/day, no signup.
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx @clevertech/mcp-server
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
[](https://pypi.org/project/clevertech-mcp-server/)
|
|
32
|
+
[](https://www.npmjs.com/package/@clevertech/mcp-server)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## Quick Start
|
|
38
|
+
|
|
39
|
+
### Claude Code
|
|
40
|
+
|
|
41
|
+
Add to your `~/.claude/claude_desktop_config.json`:
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"clevertech": {
|
|
47
|
+
"command": "npx",
|
|
48
|
+
"args": ["-y", "@clevertech/mcp-server"]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Cursor / Cline
|
|
55
|
+
|
|
56
|
+
Add to your MCP configuration:
|
|
57
|
+
|
|
58
|
+
```json
|
|
59
|
+
{
|
|
60
|
+
"mcpServers": {
|
|
61
|
+
"clevertech": {
|
|
62
|
+
"command": "uvx",
|
|
63
|
+
"args": ["clevertech-mcp-server"]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### GitHub Copilot
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"mcpServers": {
|
|
74
|
+
"clevertech": {
|
|
75
|
+
"command": "npx",
|
|
76
|
+
"args": ["-y", "@clevertech/mcp-server"]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### Direct CLI
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
# npm (Node.js)
|
|
86
|
+
npx @clevertech/mcp-server
|
|
87
|
+
|
|
88
|
+
# uv / PyPI
|
|
89
|
+
uvx clevertech-mcp-server
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Available Tools (11)
|
|
95
|
+
|
|
96
|
+
| Tool | Description |
|
|
97
|
+
|------|-------------|
|
|
98
|
+
| `dls_convert` | GPS ↔ Dominion Land Survey (DLS) coordinate conversion for Western Canada |
|
|
99
|
+
| `dls_batch` | Batch GPS ↔ DLS conversion — up to 100 coordinates at once |
|
|
100
|
+
| `property_search` | Property assessment search by address across 13+ Canadian cities |
|
|
101
|
+
| `property_report` | Consolidated property report — assessment, permits, zoning, and DLS in one call |
|
|
102
|
+
| `property_by_roll` | Direct property lookup by roll number — faster than address search |
|
|
103
|
+
| `building_permit_search` | Building permit search by address, contractor, applicant, or permit number |
|
|
104
|
+
| `building_permit_recent` | Recently issued building permits feed — monitor new construction activity |
|
|
105
|
+
| `zoning_lookup` | Zoning district lookup by GPS coordinates or street address |
|
|
106
|
+
| `business_registry_search` | Federal corporation search — find Canadian businesses by name, province, or city |
|
|
107
|
+
| `reverse_geocode` | GPS → address, city, neighborhood, province, and DLS grid reference |
|
|
108
|
+
| `list_cities` | Discover available cities, their API capabilities, and property counts |
|
|
109
|
+
|
|
110
|
+
### What Agents Can Do
|
|
111
|
+
|
|
112
|
+
- **Ask**: *"What's 1532 14 Ave SW in Calgary worth?"* — Gets assessed value, land value, building value, year built, lot size, and DLS coordinates.
|
|
113
|
+
- **Ask**: *"Show me recent building permits in Vancouver"* — Returns the latest construction permits with type, value, status, and issue dates.
|
|
114
|
+
- **Ask**: *"What zone is this GPS point in?"* — Looks up zoning district, land use, and boundary info.
|
|
115
|
+
- **Ask**: *"Convert these 50 GPS points to DLS"* — Batch converts in a single API call.
|
|
116
|
+
- **Ask**: *"Is Shopify still an active corporation?"* — Searches the federal business registry.
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Cities Supported
|
|
121
|
+
|
|
122
|
+
**20 cities** across Canada — and growing:
|
|
123
|
+
|
|
124
|
+
| Province | Cities |
|
|
125
|
+
|----------|--------|
|
|
126
|
+
| Alberta | Calgary, Edmonton, High River |
|
|
127
|
+
| British Columbia | Vancouver, Victoria |
|
|
128
|
+
| Ontario | Toronto, Ottawa, Hamilton, Kitchener, London, Mississauga, Markham, Vaughan |
|
|
129
|
+
| Quebec | Montreal, Quebec City |
|
|
130
|
+
| Manitoba | Winnipeg |
|
|
131
|
+
| Saskatchewan | Saskatoon, Regina |
|
|
132
|
+
| Nova Scotia | Halifax |
|
|
133
|
+
| Newfoundland | St. John's |
|
|
134
|
+
|
|
135
|
+
Use `list_cities` to see live availability, API capabilities per city, and property counts.
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
## Pricing
|
|
140
|
+
|
|
141
|
+
No signup required to start. Free tier gives you 50 queries/day out of the box.
|
|
142
|
+
|
|
143
|
+
| Tier | Queries/Day | Price | API Key |
|
|
144
|
+
|------|-------------|-------|---------|
|
|
145
|
+
| **Free** | 50 | $0 | Not required |
|
|
146
|
+
| **Developer** | 200 | Free | [Sign up →](https://clevertech.ca/keys) |
|
|
147
|
+
| **Pro** | 2,000 | $10/mo | [Sign up →](https://clevertech.ca/keys) |
|
|
148
|
+
| **Enterprise** | 10,000 | $40/mo | [Sign up →](https://clevertech.ca/keys) |
|
|
149
|
+
|
|
150
|
+
[Get your API key →](https://clevertech.ca/keys)
|
|
151
|
+
|
|
152
|
+
### Using an API Key
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
npx @clevertech/mcp-server --api-key YOUR_KEY
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Or via environment variable:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
export CLEVERTECH_API_KEY=your_key_here
|
|
162
|
+
npx @clevertech/mcp-server
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Features
|
|
168
|
+
|
|
169
|
+
- **No signup required** — 50 free queries/day out of the box
|
|
170
|
+
- **11 MCP tools** covering property, permits, zoning, business registry, and DLS
|
|
171
|
+
- **Batch DLS conversion** — convert up to 100 coordinates in one call
|
|
172
|
+
- **Consolidated property reports** — assessment + permits + zoning + DLS in a single response
|
|
173
|
+
- **stdio + SSE transport** — works with Claude Code, Cursor, Cline, GitHub Copilot, and any MCP-compatible agent
|
|
174
|
+
- **Clean text output** — formatted for easy LLM parsing and reformatting
|
|
175
|
+
- **20 Canadian cities** — urban and rural coverage with regular additions
|
|
176
|
+
- **MIT licensed** — use it anywhere, no restrictions
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Development
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
# Clone and set up
|
|
184
|
+
git clone https://github.com/anteriorclever/clevertech-mcp-server.git
|
|
185
|
+
cd clevertech-mcp-server
|
|
186
|
+
uv sync
|
|
187
|
+
|
|
188
|
+
# Run locally (stdio transport)
|
|
189
|
+
uv run clevertech-mcp-server --transport stdio
|
|
190
|
+
|
|
191
|
+
# Run with SSE transport (for HTTP-based agents)
|
|
192
|
+
uv run clevertech-mcp-server --transport sse --port 8000
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
### Run Tests
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
uv run pytest
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Project Structure
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
src/clevertech_mcp/
|
|
205
|
+
├── tools/
|
|
206
|
+
│ ├── property.py # property_search, property_report, property_by_roll
|
|
207
|
+
│ ├── building.py # building_permit_search, building_permit_recent
|
|
208
|
+
│ ├── zoning.py # zoning_lookup
|
|
209
|
+
│ ├── dls.py # dls_convert, dls_batch
|
|
210
|
+
│ ├── business.py # business_registry_search
|
|
211
|
+
│ ├── geo.py # reverse_geocode
|
|
212
|
+
│ └── meta.py # list_cities
|
|
213
|
+
├── server.py # FastMCP server entry point
|
|
214
|
+
├── client.py # HTTP client for CleverTech API
|
|
215
|
+
├── auth.py # API key authentication
|
|
216
|
+
├── rate_limit.py # Rate limiting
|
|
217
|
+
└── config.py # Configuration management
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Documentation
|
|
223
|
+
|
|
224
|
+
- [Tool Reference](docs/tools.md)
|
|
225
|
+
- [MCP Protocol Specification](https://modelcontextprotocol.io)
|
|
226
|
+
- [CleverTech API Keys](https://clevertech.ca/keys)
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## License
|
|
231
|
+
|
|
232
|
+
MIT © 2026 CleverTech — see [LICENSE](LICENSE) for details.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
clevertech_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
clevertech_mcp/auth.py,sha256=xgmWMnBfqB5Mf2AbAeM4L1G0URj2rYT29UCiH8UFyFA,2247
|
|
3
|
+
clevertech_mcp/client.py,sha256=THID5xn-1yCC6dDIK9GZab94Q37Qfw1K-wZEQzNOCGw,1823
|
|
4
|
+
clevertech_mcp/config.py,sha256=XOnv9AZxSfIhDo16k8CSES0_TmNXXqu3Jdiqvf6rI-Q,1331
|
|
5
|
+
clevertech_mcp/rate_limit.py,sha256=9qmAqNwK9hprNpvTAYlI8BIBehhuaygoYojM5WKxmPg,3567
|
|
6
|
+
clevertech_mcp/server.py,sha256=JhRpKOT5ImJauzpW8mf6CGk8t8MRgpsDBsw0qt3esCQ,2280
|
|
7
|
+
clevertech_mcp/tools/__init__.py,sha256=oC66yJC3AwG7VFsFW7mWLNlIJ-frPh3FzmwKCVL9GeE,1624
|
|
8
|
+
clevertech_mcp/tools/building.py,sha256=fHToQxUsZbiS1xlXyPwbTo0SCh2qcUY-VY_TpOqsjYg,3331
|
|
9
|
+
clevertech_mcp/tools/business.py,sha256=dPESy1X7fotYVr8zyzpt2_5FHlQ5JjlQYtcloVrvLwc,2069
|
|
10
|
+
clevertech_mcp/tools/dls.py,sha256=RaqxRy3zLfAz5n4hQUm2rvpCy5a6HWS2vMvBbJicXJc,4356
|
|
11
|
+
clevertech_mcp/tools/geo.py,sha256=8BNg8HH2R37XKZoLKqg_TChmzGXs3djrwdN4zVOvHss,1555
|
|
12
|
+
clevertech_mcp/tools/meta.py,sha256=K9WeOmYPoQpqEvw1KQ8bJa8r0eyiP8vJM_sON1HAz9w,1788
|
|
13
|
+
clevertech_mcp/tools/property.py,sha256=r9vYZBvKUN90xf4PQldgJ_9pJNj-a4ZdeEH6zXr2upI,5677
|
|
14
|
+
clevertech_mcp/tools/zoning.py,sha256=rwKYt1BhCgHRR18th2hhY8vI2koIG6rb05-cOscNmHA,2020
|
|
15
|
+
clevertech_mcp_server-0.1.0.dist-info/METADATA,sha256=z-lxFnN2LGXQYZq0yi93MnI1t-mKZFW27nBpgG8oThU,7039
|
|
16
|
+
clevertech_mcp_server-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
17
|
+
clevertech_mcp_server-0.1.0.dist-info/entry_points.txt,sha256=5DBsT1PeZ75Kwp2MCi6RFU4_704NTddkpXqyO85hWyo,69
|
|
18
|
+
clevertech_mcp_server-0.1.0.dist-info/licenses/LICENSE,sha256=nm-6Sfd2weKWBQsLq4qTjKEzdAKVIA4Y11gUniTqcIg,1067
|
|
19
|
+
clevertech_mcp_server-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CleverTech
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|