ipinfo-mcp-server 0.1.9__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.
- ipinfo_mcp/__init__.py +0 -0
- ipinfo_mcp/__main__.py +3 -0
- ipinfo_mcp/auth.py +20 -0
- ipinfo_mcp/cache.py +81 -0
- ipinfo_mcp/client.py +89 -0
- ipinfo_mcp/errors.py +121 -0
- ipinfo_mcp/landing.html +1613 -0
- ipinfo_mcp/logging.py +44 -0
- ipinfo_mcp/pagination.py +45 -0
- ipinfo_mcp/server.py +151 -0
- ipinfo_mcp/tools/__init__.py +1 -0
- ipinfo_mcp/tools/asn.py +153 -0
- ipinfo_mcp/tools/geolocate.py +166 -0
- ipinfo_mcp/tools/lookup.py +124 -0
- ipinfo_mcp/tools/privacy.py +158 -0
- ipinfo_mcp/tools/quota.py +49 -0
- ipinfo_mcp/tools/resproxy.py +143 -0
- ipinfo_mcp/types.py +125 -0
- ipinfo_mcp/validation.py +109 -0
- ipinfo_mcp_server-0.1.9.dist-info/METADATA +86 -0
- ipinfo_mcp_server-0.1.9.dist-info/RECORD +24 -0
- ipinfo_mcp_server-0.1.9.dist-info/WHEEL +4 -0
- ipinfo_mcp_server-0.1.9.dist-info/entry_points.txt +2 -0
- ipinfo_mcp_server-0.1.9.dist-info/licenses/LICENSE.txt +201 -0
ipinfo_mcp/__init__.py
ADDED
|
File without changes
|
ipinfo_mcp/__main__.py
ADDED
ipinfo_mcp/auth.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
|
|
3
|
+
from fastmcp import Context
|
|
4
|
+
from fastmcp.server.dependencies import get_http_request
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_request_token(ctx: Context) -> str | None:
|
|
8
|
+
"""
|
|
9
|
+
Get the API token for the current request.
|
|
10
|
+
|
|
11
|
+
In HTTP mode: reads Bearer token from the Authorization request header.
|
|
12
|
+
In stdio mode: falls back to the IPINFO_TOKEN env var stored in lifespan context.
|
|
13
|
+
"""
|
|
14
|
+
with contextlib.suppress(RuntimeError):
|
|
15
|
+
request = get_http_request()
|
|
16
|
+
auth = request.headers.get("authorization", "")
|
|
17
|
+
if auth.lower().startswith("bearer "):
|
|
18
|
+
return auth[7:].strip() or None
|
|
19
|
+
return None
|
|
20
|
+
return ctx.lifespan_context.get("api_token")
|
ipinfo_mcp/cache.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from ipinfo_mcp.types import LiteResponse, LookupResponse, ResproxyResponse
|
|
5
|
+
|
|
6
|
+
# Any single-IP response that can be cached
|
|
7
|
+
CachedResponse = LiteResponse | LookupResponse | ResproxyResponse | dict[str, object]
|
|
8
|
+
|
|
9
|
+
# How long an entry stays fresh, in seconds.
|
|
10
|
+
DEFAULT_TTL = 3600.0
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class IPCache:
|
|
14
|
+
"""
|
|
15
|
+
In-memory cache of per-IP responses, scoped by token and namespace.
|
|
16
|
+
|
|
17
|
+
Namespaces: "lite", "lookup", "resproxy".
|
|
18
|
+
|
|
19
|
+
Entries are scoped also using the token that fetched them. The cache is process
|
|
20
|
+
wide, so if we don't use the token as part of the key we would hand one token's
|
|
21
|
+
results to another.
|
|
22
|
+
|
|
23
|
+
Entries expire after ttl seconds. Expiry is checked on read, and an expired
|
|
24
|
+
entry is dropped as we find it, so nothing sweeps the store in the background.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, ttl: float = DEFAULT_TTL) -> None:
|
|
28
|
+
self._ttl = ttl
|
|
29
|
+
# Each entry is (stored_at, data), stored_at coming from a monotonic clock
|
|
30
|
+
# so that a system clock change can't make an entry look fresh forever.
|
|
31
|
+
self._store: dict[tuple[str, str, str], tuple[float, CachedResponse]] = {}
|
|
32
|
+
|
|
33
|
+
@staticmethod
|
|
34
|
+
def _hash_token(token: str) -> str:
|
|
35
|
+
"""
|
|
36
|
+
We don't want to store the token directly, though we still need
|
|
37
|
+
to scope by token so we derive an hash from it and use it as part
|
|
38
|
+
of the hash key.
|
|
39
|
+
"""
|
|
40
|
+
return hashlib.sha256(token.encode()).hexdigest()[:32]
|
|
41
|
+
|
|
42
|
+
def _get_fresh(self, key: tuple[str, str, str]) -> CachedResponse | None:
|
|
43
|
+
"""Read an entry by key, dropping and reporting it as a miss if it expired."""
|
|
44
|
+
entry = self._store.get(key)
|
|
45
|
+
if entry is None:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
stored_at, data = entry
|
|
49
|
+
if time.monotonic() > stored_at + self._ttl:
|
|
50
|
+
del self._store[key]
|
|
51
|
+
return None
|
|
52
|
+
|
|
53
|
+
return data
|
|
54
|
+
|
|
55
|
+
def get(self, token: str, namespace: str, ip: str) -> CachedResponse | None:
|
|
56
|
+
"""Get this token's cached data for an IP in a namespace. Returns None on miss."""
|
|
57
|
+
return self._get_fresh((self._hash_token(token), namespace, ip))
|
|
58
|
+
|
|
59
|
+
def put(self, token: str, namespace: str, ip: str, data: CachedResponse) -> None:
|
|
60
|
+
"""Store data for an IP in a namespace, scoped to this token."""
|
|
61
|
+
self._store[(self._hash_token(token), namespace, ip)] = (time.monotonic(), data)
|
|
62
|
+
|
|
63
|
+
def get_many(self, token: str, namespace: str, ips: list[str]) -> tuple[dict[str, CachedResponse], list[str]]:
|
|
64
|
+
"""Look up multiple IPs for this token. Returns (cached_results, cache_misses)."""
|
|
65
|
+
token_hash = self._hash_token(token)
|
|
66
|
+
cached: dict[str, CachedResponse] = {}
|
|
67
|
+
misses: list[str] = []
|
|
68
|
+
for ip in ips:
|
|
69
|
+
data = self._get_fresh((token_hash, namespace, ip))
|
|
70
|
+
if data is not None:
|
|
71
|
+
cached[ip] = data
|
|
72
|
+
else:
|
|
73
|
+
misses.append(ip)
|
|
74
|
+
return cached, misses
|
|
75
|
+
|
|
76
|
+
def put_many(self, token: str, namespace: str, items: dict[str, CachedResponse]) -> None:
|
|
77
|
+
"""Store multiple IP results in a namespace, scoped to this token."""
|
|
78
|
+
token_hash = self._hash_token(token)
|
|
79
|
+
stored_at = time.monotonic()
|
|
80
|
+
for ip, data in items.items():
|
|
81
|
+
self._store[(token_hash, namespace, ip)] = (stored_at, data)
|
ipinfo_mcp/client.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from importlib.metadata import version
|
|
3
|
+
from typing import cast
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from ipinfo_mcp.types import BatchResponse, MeResponse
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# Maximum number of IPs the batch endpoint accepts
|
|
12
|
+
MAX_BATCH_SIZE = 1000
|
|
13
|
+
|
|
14
|
+
USER_AGENT = f"IPinfoClient/MCP/{version('ipinfo-mcp-server')}"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IPinfoClient:
|
|
18
|
+
"""
|
|
19
|
+
Async HTTP client for the IPinfo API.
|
|
20
|
+
|
|
21
|
+
Uses the unified /batch endpoint
|
|
22
|
+
(e.g. "lite/8.8.8.8", "1.1.1.1", "resproxy/2.2.2.2").
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, base_url: str, legacy_base_url: str) -> None:
|
|
26
|
+
self._base_url: str = base_url.rstrip("/")
|
|
27
|
+
self._legacy_base_url: str = legacy_base_url.rstrip("/")
|
|
28
|
+
self._http: httpx.AsyncClient | None = None
|
|
29
|
+
|
|
30
|
+
async def __aenter__(self) -> "IPinfoClient":
|
|
31
|
+
self._http = httpx.AsyncClient(
|
|
32
|
+
base_url=self._base_url,
|
|
33
|
+
headers={"Accept": "application/json", "User-Agent": USER_AGENT},
|
|
34
|
+
timeout=httpx.Timeout(30.0, connect=10.0),
|
|
35
|
+
)
|
|
36
|
+
return self
|
|
37
|
+
|
|
38
|
+
async def __aexit__(self, *args: object) -> None:
|
|
39
|
+
if self._http:
|
|
40
|
+
await self._http.aclose()
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def http(self) -> httpx.AsyncClient:
|
|
44
|
+
if self._http is None:
|
|
45
|
+
raise RuntimeError("Client not initialized. Use 'async with' context manager.")
|
|
46
|
+
return self._http
|
|
47
|
+
|
|
48
|
+
async def batch(self, keys: list[str], *, token: str | None = None) -> BatchResponse:
|
|
49
|
+
"""
|
|
50
|
+
Send a batch request.
|
|
51
|
+
|
|
52
|
+
Keys can be prefixed with the endpoint type:
|
|
53
|
+
- "lite/8.8.8.8" for lite lookups
|
|
54
|
+
- "8.8.8.8" for full lookups
|
|
55
|
+
- "resproxy/8.8.8.8" for residential proxy checks
|
|
56
|
+
|
|
57
|
+
Response keys match input keys.
|
|
58
|
+
HTTP errors (403, 429, etc.) are propagated as httpx.HTTPStatusError.
|
|
59
|
+
"""
|
|
60
|
+
headers: dict[str, str] = {}
|
|
61
|
+
if token:
|
|
62
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
63
|
+
logger.info("POST /batch keys=%d", len(keys))
|
|
64
|
+
response = await self.http.post("/batch", json=keys, headers=headers)
|
|
65
|
+
_ = response.raise_for_status()
|
|
66
|
+
result: BatchResponse = cast(BatchResponse, response.json())
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
async def me(self, *, token: str | None = None) -> MeResponse:
|
|
70
|
+
"""
|
|
71
|
+
Get account info and quota via GET /me.
|
|
72
|
+
|
|
73
|
+
Note: /me only exists on ipinfo.io, not api.ipinfo.io,
|
|
74
|
+
so this uses a direct request to the legacy base URL.
|
|
75
|
+
HTTP errors are propagated as httpx.HTTPStatusError.
|
|
76
|
+
"""
|
|
77
|
+
headers: dict[str, str] = {"Accept": "application/json", "User-Agent": USER_AGENT}
|
|
78
|
+
if token:
|
|
79
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
80
|
+
logger.info("GET /me")
|
|
81
|
+
async with httpx.AsyncClient(
|
|
82
|
+
base_url=self._legacy_base_url,
|
|
83
|
+
headers=headers,
|
|
84
|
+
timeout=httpx.Timeout(30.0, connect=10.0),
|
|
85
|
+
) as http:
|
|
86
|
+
response = await http.get("/me")
|
|
87
|
+
_ = response.raise_for_status()
|
|
88
|
+
result: MeResponse = cast(MeResponse, response.json())
|
|
89
|
+
return result
|
ipinfo_mcp/errors.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from typing import Literal, TypedDict, cast
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from ipinfo_mcp.cache import CachedResponse
|
|
6
|
+
|
|
7
|
+
ErrorCode = Literal[
|
|
8
|
+
"ACCESS_DENIED",
|
|
9
|
+
"RATE_LIMITED",
|
|
10
|
+
"INVALID_TOKEN",
|
|
11
|
+
"NO_TOKEN",
|
|
12
|
+
"API_ERROR",
|
|
13
|
+
"UNKNOWN",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ErrorResponse(TypedDict, total=False):
|
|
18
|
+
code: ErrorCode
|
|
19
|
+
message: str
|
|
20
|
+
suggestion: str
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# We use this mapping to try and infer an error code from the error message
|
|
24
|
+
# received by the REST API.
|
|
25
|
+
# Getting an error code makes it easier to give the model suggestions on how
|
|
26
|
+
# to communicate the error to the user.
|
|
27
|
+
_MESSAGE_ERROR_CODES_MAPPING: list[tuple[str, ErrorCode]] = [
|
|
28
|
+
("does not have access", "ACCESS_DENIED"),
|
|
29
|
+
("not allowed access from this source", "ACCESS_DENIED"),
|
|
30
|
+
("do not have permission", "ACCESS_DENIED"),
|
|
31
|
+
("reached your limit", "RATE_LIMITED"),
|
|
32
|
+
("too many requests", "RATE_LIMITED"),
|
|
33
|
+
("entered your token correctly", "INVALID_TOKEN"),
|
|
34
|
+
("unknown token", "INVALID_TOKEN"),
|
|
35
|
+
("invalid token", "INVALID_TOKEN"),
|
|
36
|
+
("token required", "NO_TOKEN"),
|
|
37
|
+
("authentication required", "NO_TOKEN"),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
_SUGGESTIONS: dict[ErrorCode, str] = {
|
|
41
|
+
"ACCESS_DENIED": "Tell the user they can get access by upgrading at https://ipinfo.io/pricing",
|
|
42
|
+
"RATE_LIMITED": "Tell the user they can upgrade their tier at https://ipinfo.io/pricing",
|
|
43
|
+
"INVALID_TOKEN": "Tell the user to check their token at https://ipinfo.io/account/token",
|
|
44
|
+
"NO_TOKEN": "The user didn't set IPINFO_TOKEN. They can get a free token at https://ipinfo.io/signup",
|
|
45
|
+
"UNKNOWN": "An unforseen error happened, tell the user to report it to https://ipinfo.io/",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _find_error_code(message: str) -> ErrorCode:
|
|
50
|
+
for chunk, code in _MESSAGE_ERROR_CODES_MAPPING:
|
|
51
|
+
if chunk in message:
|
|
52
|
+
return code
|
|
53
|
+
return "UNKNOWN"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def extract_error(entry: CachedResponse) -> ErrorResponse | None:
|
|
57
|
+
"""
|
|
58
|
+
Pull the error message out of a single batch endpoint response.
|
|
59
|
+
|
|
60
|
+
Return None if no error is found.
|
|
61
|
+
|
|
62
|
+
The batch endpoint embeds each sub-request's response body verbatim. Nearly all
|
|
63
|
+
of them carry a flat "error" string, but the generic error handler and the
|
|
64
|
+
invalid-response guard reply with a bare "message" instead, so fall back to it.
|
|
65
|
+
"""
|
|
66
|
+
payload = cast(dict[str, str], entry)
|
|
67
|
+
message = payload.get("error") or payload.get("message")
|
|
68
|
+
if not message:
|
|
69
|
+
return None
|
|
70
|
+
error_code = _find_error_code(message)
|
|
71
|
+
return ErrorResponse(
|
|
72
|
+
code=error_code,
|
|
73
|
+
message=message,
|
|
74
|
+
suggestion=_SUGGESTIONS[error_code],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def handle_api_error(
|
|
79
|
+
exc: httpx.HTTPStatusError,
|
|
80
|
+
*,
|
|
81
|
+
feature_name: str = "",
|
|
82
|
+
) -> ErrorResponse:
|
|
83
|
+
"""
|
|
84
|
+
Convert an HTTP error into a user-friendly error response dict.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
exc: The HTTP status error from httpx.
|
|
88
|
+
feature_name: Human-readable name of the feature that failed.
|
|
89
|
+
"""
|
|
90
|
+
status = exc.response.status_code
|
|
91
|
+
|
|
92
|
+
if status == 403:
|
|
93
|
+
message = (
|
|
94
|
+
f"You don't have access to {feature_name}." if feature_name else "You don't have access to this feature."
|
|
95
|
+
)
|
|
96
|
+
return ErrorResponse(
|
|
97
|
+
code="ACCESS_DENIED",
|
|
98
|
+
message=message,
|
|
99
|
+
suggestion="Tell the user they can get access by upgrading at https://ipinfo.io/pricing",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
if status == 429:
|
|
103
|
+
return ErrorResponse(
|
|
104
|
+
code="RATE_LIMITED",
|
|
105
|
+
message="Rate limit exceeded.",
|
|
106
|
+
suggestion="Tell the user they can upgrade their tier at https://ipinfo.io/pricing",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
return ErrorResponse(
|
|
110
|
+
code="API_ERROR",
|
|
111
|
+
message=f"IPinfo API returned status {status}.",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def no_token_error() -> ErrorResponse:
|
|
116
|
+
"""Error response when no IPINFO_TOKEN is configured."""
|
|
117
|
+
return ErrorResponse(
|
|
118
|
+
code="NO_TOKEN",
|
|
119
|
+
message="No API token configured.",
|
|
120
|
+
suggestion="The user didn't set IPINFO_TOKEN. They can get a free token at https://ipinfo.io/signup",
|
|
121
|
+
)
|