ip-enrichment-mcp
Click on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@ip-enrichment-mcpwhat is 13.68.163.33 and what Azure service tag is it?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
ip-enrichment-mcp
An MCP server that answers "what is this IP" from two independently-reported,
honestly-labeled sources: a synced local copy of Azure service tags, and live
reverse DNS. It never fabricates a hostname or a service tag - a source that
didn't resolve reports null and its own status, and every answer says which
source (if any) actually produced it, with a timestamp so staleness is
visible.
A companion to grounded-kql-mcp, applying the same "grounded, not free-form" discipline to network identity resolution instead of KQL.
Quickstart (local, no Azure needed)
python -m venv .venv
.venv/Scripts/activate # or source .venv/bin/activate on Linux/macOS
pip install -e ".[azure,dotenv,dev]"
python sync_service_tags.py --source fixture # builds data/out/service_tags.sqlite
python smoke_test.py
python -m ipenrichmcp.server --transport stdioRelated MCP server: PeerGlass
The tools
resolve_ips(ips: list[str]) - up to 50 IPs per call. For each IP, returns:
{
"ip": "13.68.163.33",
"service_tag": {"name": "Storage.EastUS", "region": "eastus", "platform": "Azure", "matched_prefix": "13.68.163.32/28"},
"service_tags_synced_at": "2026-09-11T12:00:00+00:00",
"reverse_dns": null,
"reverse_dns_status": "no_ptr_record"
}list_service_tag_ranges(tag: str) - the reverse direction: given one exact
Azure service-tag name, return every IPv4 CIDR prefix the synced local index
has for it (e.g. "AzureAutomation.WestEurope", "Storage.EastUS", or the
whole-cloud "AzureCloud"). Reads the same index resolve_ips uses; never
re-fetches from Azure. A tag not present in the current sync comes back as
found: false with an empty list - never a guessed or partial list standing
in for "I don't know." A casing mismatch ("sql.westeurope" vs the real
"Sql.WestEurope") is resolved and reported via case_insensitive_match,
not silently matched to a possibly-different tag:
{
"requested_tag": "AzureAutomation.WestEurope",
"found": true,
"tag_name": "AzureAutomation.WestEurope",
"case_insensitive_match": false,
"region": "westeurope",
"platform": null,
"address_prefixes": ["...", "..."],
"prefix_count": 12,
"service_tags_synced_at": "2026-09-11T12:00:00+00:00",
"note": "ipv4_only - this index does not store IPv6 prefixes (see CLAUDE.md known gaps)"
}Adjusting reserved-range handling for your environment
resolve_ips short-circuits IANA-reserved documentation/test ranges (RFC 5737
192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24, and RFC 3849 IPv6
2001:db8::/32) straight to a reserved_range status, with no network call and
no cache write - these ranges can never have a real PTR answer by definition, so
there's nothing to wait on.
That list is intentionally hardcoded, not config-driven (see CLAUDE.md D-003) -
it's the well-known IANA set, not anything specific to a given deployment.
If your own environment has other ranges that will predictably never resolve (a
benchmarking block you use internally, a known black-holed segment), add them to
_RESERVED_TEST_RANGES in src/ipenrichmcp/reverse_dns.py:
_RESERVED_TEST_RANGES = [
ipaddress.ip_network("192.0.2.0/24"),
ipaddress.ip_network("198.51.100.0/24"),
ipaddress.ip_network("203.0.113.0/24"),
ipaddress.ip_network("2001:db8::/32"),
# add your own known-non-resolving ranges here, e.g.:
# ipaddress.ip_network("198.18.0.0/15"),
]Do not add RFC1918 private ranges (10.0.0.0/8, 172.16.0.0/12,
192.168.0.0/16) here - those routinely have real, meaningful internal PTR
records, and short-circuiting them would silence a genuine answer, not skip a
pointless one.
Two related constants in the same file are also worth knowing about if you're tuning this for your network:
TIMEOUT_CACHE_TTL_SECONDS(default 5 min) - how long atimeoutresult is cached before being retried. Kept short deliberately, since a timeout means "we don't know," not a confirmed fact - see D-003.CACHE_TTL_SECONDS(default 24h) - how long aresolved/no_ptr_recordanswer is cached. These are resolver-confirmed facts, safe to trust longer.
Before escalating a private-name resolution failure
If resolve_ips can't resolve a private IP the way you expect, check these before
opening a network ticket - most "the network is broken" reports turn out to be one
of these instead:
Public or private? Public internet names resolve through the platform's default DNS with no special setup, on any host or PaaS compute, integrated into a VNet or not. If the failing name is a public FQDN, this list doesn't apply - that's a different kind of problem.
Is the compute actually on the network path to reach it? A private name only resolves if whatever's running this server has an actual route into the network that can answer for it. On Azure PaaS compute (Container Apps, App Service), that means VNet integration - without it, there is no path to a private resolver at all, not just a misconfigured one.
Does anything actually serve this name? "We have a private DNS zone" or "we have VNet integration" doesn't mean every private name is covered. An Azure Private DNS Zone linked to a VNet only answers for what's actually registered in it - PaaS auto-registration records, or A/PTR records someone created by hand. It has no knowledge of an on-prem or AD-integrated namespace unless something is explicitly forwarding for that domain suffix - an Azure DNS Private Resolver with conditional forwarding to the real on-prem servers, or a self-hosted DNS layer genuinely kept in sync with the on-prem upstream. Confirm the specific suffix you're querying is actually covered by one of these, not just that some private DNS infrastructure exists somewhere in the environment.
Test from the same network context as the deployment, not your laptop and not a jump box on a different subnet. A resolution that works from your machine and fails from the server's actual VNet/subnet is a real, specific difference worth handing to the network team - a resolution nobody has actually tried from that exact vantage point isn't evidence of anything yet.
Read the status the tool already gives you before assuming it's ambiguous.
no_ptr_recordproves the query reached a resolver and got an authoritative "nothing here" - connectivity and routing are fine, the record simply doesn't exist or isn't in scope.timeoutproves nothing about which of those is true - it means no answer came back in time, and that's consistent with "resolver is slow," "resolver is unreachable," or "a firewall/NSG silently dropped the query" equally. Don't read atimeoutas "the resolver is slow" - check reachability to the resolver itself before assuming that.
If you've checked 1-4 and it's still failing, that's a real network ticket - and one with much more to hand over than "DNS doesn't work": which specific name, which specific compute/VNet/subnet it's running from, and what a manual resolution test from that exact location actually showed.
Deploying to Azure
Terraform lives in infra/terraform/. Two things are prerequisites you bring
yourself, not something this module creates for you:
An existing Azure Container Apps Environment. This module deploys its Container App into an environment referenced via a
datablock (seedata.tf) - it never creates one. If you don't already have one, create it first (a plainazurerm_container_app_environmentresource, the CLI, or the Portal), then pointcontainer_app_environment_name/container_app_environment_resource_group_nameat it.A resource group for the new resources this module does create (ACR, a managed identity, the Container App itself).
A Storage Account for Terraform's remote state (decision D-011, recorded in the author's own local working notes -
internal/decisions.mdis gitignored, not part of this repo, so that's not a citation you can open; the reasoning that matters is inline here and inCLAUDE.md). Local.tfstateisn't enough once anything other than your own machine needs to runterraform apply- a CI runner in particular starts with no state at all. Create (or reuse) a Storage Account and a blob container for it yourself; this module doesn't manage its own backend, for the same chicken-and-egg reason it doesn't create its own Container Apps Environment.
None of these three has a default in variables.tf or versions.tf -
there's nothing generic a shared repo could guess about your own
subscription, so all three are required. Copy terraform.tfvars.example to
terraform.tfvars, and backend.hcl.example to backend.hcl (both
gitignored, never commit the real ones), fill in your own values, then
deploy by hand - for this repo that is the fallback path, for when the
pipeline below itself needs debugging, not the normal way to ship:
az acr build --registry <your-acr-name> --image ipenrichmcp:<a-tag> .
terraform -chdir=infra/terraform init -backend-config=backend.hcl
terraform -chdir=infra/terraform apply -var image_tag=<the-same-tag>Authentication to the state backend is via Azure AD (use_azuread_auth = true), not a storage account key - your own az login session locally,
the CI managed identity's federated credential in a pipeline. Either way,
whatever identity is running terraform needs Storage Blob Data Contributor on that container specifically - a role assignment scoped to
your app's resource group does not reach a state storage account that
lives elsewhere.
CI/CD (GitHub Actions)
This repo's own deployment runs through GitHub Actions rather than manual
terraform apply calls, authenticating to Azure via OIDC (a federated
identity credential on a user-assigned managed identity), not a stored
client secret - no long-lived Azure credential exists in GitHub at all. Every
build gets tagged with the git commit sha it was built from and nothing else
:latestis deliberately not used as a deploy target (a mutable tag plus a scale-to-zero app made a deploy's timing accidental rather than controlled; decision D-007, same caveat as above - the author's own local notes, not something in this repo). The workflow is.github/workflows/deploy.yml:Push to
masterrunsbuild-and-planautomatically: builds and pushesipenrichmcp:<sha>(skipped if that tag already exists, so a re-run never re-pushes an existing tag with a different digest), then a read-onlyterraform plan, summarized on the run page. Nothing is deployed."Run workflow" in the Actions tab (
workflow_dispatch,masteronly) runs the same build-and-plan and thenapply: a fresh plan andterraform applyin one job, followed by live verification - Azure must report the Container App on this build's sha tag with its newest revision ready, andverify_deployment.pycalls both tools through a real MCP client against the live endpoint, asserting on content (a real Azure IP gets a service tag whosematched_prefixactually contains it,AzureCloudhas prefixes, andservice_tags_synced_atis newer than the apply - i.e. the answer came from a container started by this deploy). A green run means verified, not just applied.
That manual trigger is the approval gate - a human still signs off on every production change; the pipeline automates the mechanics around that gate, not the gate itself. (GitHub's native Environment "required reviewers" protection needs a paid plan for a private repo, so this repo uses a manual trigger instead - decision D-013, if you're wondering why it's not a green approval banner; again, the full writeup is the author's own local notes, not something this repo carries.) The plan file is never uploaded as an artifact between jobs: a saved Terraform plan contains sensitive values (the API key) in plaintext.
verify_deployment.py also works by hand against any deployment:
IPENRICHMCP_API_KEY=... python verify_deployment.py https://<fqdn>/mcp.
Setting this up for your own fork means creating your own dedicated managed
identity and federated credential (documented, not automated by this repo -
identity/credential setup is a decision only you should make for your own
subscription), granting it both roles it needs (Contributor on your app
resource group, and Storage Blob Data Contributor on your state
container - two separate scopes, easy to grant the first and forget the
second), and populating your fork's own GitHub Secrets (the API key) and
Variables (client ID, tenant ID, subscription ID, and the
resource-group/environment/backend names above) accordingly - all scoped to a
GitHub Environment named production, which both jobs declare. The manual
az acr build + terraform apply commands above still work unchanged if
you'd rather deploy by hand instead.
See CLAUDE.md for the full design and the standing invariants this repo does
not relax. Remaining known gaps: the service-tags index is IPv4-only by design,
so an IPv6 input gets the reverse-DNS side only and always reports
service_tag: null (correctly null, never fabricated - the earlier bug where an
IPv6 address raised out of resolve_ips and killed the whole batch is fixed,
see D-002); and there is no scheduled sync - sync_service_tags.py is run
explicitly, with the server warning at startup if the local index is stale.
Reverse DNS resolution also depends entirely on whatever resolver the host or
platform this server runs on is configured with - the server has no DNS
configuration of its own, by design, since reverse_dns.py goes through the
system resolver via the stdlib socket.gethostbyaddr() (see that module's
docstring).
Available Tools
2 toolslist_service_tag_rangesA
List every IPv4 CIDR prefix stored under one exact Azure service-tag name (e.g. "AzureAutomation.WestEurope", "Storage.EastUS", or the whole-cloud "AzureCloud"). Reads the same synced local index resolve_ips uses - never re-fetches from Azure. If the tag isn't in the current index, found is false and address_prefixes is empty - never a guessed or partial list. A near-miss on casing is reported (case_insensitive_match) rather than silently matched.
| Name | Required | Description | Default |
|---|---|---|---|
| tag | Yes |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full burden and does so well: it discloses the local-index/never-refetch behavior, the negative-result contract (found=false with empty address_prefixes, never guessed or partial), and the case-insensitive near-miss signal. These are exactly the behavioral traits an agent needs to interpret a result correctly.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Four sentences, front-loaded with the core action and examples before the behavioral caveats. Dense but every sentence carries information; nothing is padding, though the length is near the upper bound for a one-parameter read.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists, so return values need not be described, and the description instead fills the gaps an output schema cannot: data freshness, failure semantics, and casing policy. Nothing needed to invoke or interpret this tool is missing.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 0% and the single 'tag' parameter has no description, so the description must compensate. It does: it specifies that the name must be exact, demonstrates the dotted naming convention with three examples, and flags the casing-mismatch behavior, all of which shape how the argument should be supplied.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a precise verb+resource (list every IPv4 CIDR prefix under one exact service-tag name) and grounds it with concrete example tag values including the whole-cloud case. It is clearly distinguishable from the sibling resolve_ips, which performs the inverse (IP-to-tag) lookup.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
The description ties itself to the shared local index that resolve_ips also uses, which implies the family relationship, but it never states explicitly when to choose this tool over resolve_ips or any prerequisites for calling it. Usage is inferable rather than spelled out.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
resolve_ipsA
Resolve what each IP is, from two independently-reported sources: a synced copy of Azure service tags (service_tag) and live reverse DNS (reverse_dns). Each result says which source(s) actually resolved and when the service-tags copy was last synced - never a fabricated hostname or tag. Accepts up to 50 IPs per call.
| Name | Required | Description | Default |
|---|---|---|---|
| ips | Yes |
Output Schema
| Name | Required | Description |
|---|---|---|
| result | Yes |
TDQS
Does the description disclose side effects, auth requirements, rate limits, or destructive behavior?
With no annotations, the description carries the full burden and does notable work: it discloses that results come from two independently-reported sources, that each result indicates which source resolved and the sync timestamp, and that it never fabricates a hostname or tag. It omits auth requirements and rate limits, keeping it short of a 5.
Agents need to know what a tool does to the world before calling it. Descriptions should go beyond structured annotations to explain consequences.
Is the description appropriately sized, front-loaded, and free of redundancy?
Three tight sentences that front-load the verb and core behavior, then layer in trust semantics and the batch limit. No filler or redundancy.
Shorter descriptions cost fewer tokens and are easier for agents to parse. Every sentence should earn its place.
Given the tool's complexity, does the description cover enough for an agent to succeed on first attempt?
An output schema exists, so return-value details are rightly omitted, and the description covers inputs, sources of truth, and call limits. What remains missing (accepted IP formats, failure behavior for unresolvable inputs) is minor given the structured schema.
Complex tools with many parameters or behaviors need more documentation. Simple tools need less. This dimension scales expectations accordingly.
Does the description clarify parameter syntax, constraints, interactions, or defaults beyond what the schema provides?
Schema coverage is 0% and the single 'ips' parameter has no in-schema description, so the description must compensate - and it does, specifying the batch cap ('up to 50 IPs per call') and that the input is IPs. It does not clarify accepted formats (IPv4/IPv6, CIDR), leaving a small gap.
Input schemas describe structure but not intent. Descriptions should explain non-obvious parameter relationships and valid value ranges.
Does the description clearly state what the tool does and how it differs from similar tools?
States a specific verb and resource ('resolve what each IP is') and names the two data sources (service_tag and reverse_dns), which is a concrete operational description rather than a restatement of the name. It does not explicitly differentiate itself from the sibling list_service_tag_ranges, so it stops short of a 5.
Agents choose between tools based on descriptions. A clear purpose with a specific verb and resource helps agents select the right tool.
Does the description explain when to use this tool, when not to, or what alternatives exist?
Usage is implied (feed it IPs to learn what they are), and the 'up to 50 IPs per call' constraint gives practical context. However, there is no explicit statement of when to use this versus the sibling or any prerequisites/exclusions, so guidance remains inferred.
Agents often have multiple tools that could apply. Explicit usage guidance like "use X instead of Y when Z" prevents misuse.
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
2 tool updates
v0.1.0- First observed
list_service_tag_ranges - First observed
resolve_ips
TDQS
Scored across 2 tools
resolve_ips operates on IP addresses, while list_service_tag_ranges operates on a tag name; the two tools have clearly distinct inputs and outputs, with no overlap in purpose.
Both tools use snake_case and follow a verb_noun pattern (resolve_ips, list_service_tag_ranges), providing a consistent and predictable naming convention.
Only two tools are provided; while each is distinct, the surface feels thin for an enrichment server that could reasonably support additional query or discovery operations (e.g., listing all tags, sync status).
The core enrichment workflow (IP resolution) is well-covered, and the auxiliary tag-range lookup adds value. However, minor gaps exist, such as no tool to list available service tags or explicitly check/trigger index synchronization.
Maintenance
Related MCP Connectors
Free IPv4 lookups against a distributed attacker-observation corpus.
Live BGP routing table and registry lookups: IP origin, ASN prefixes, transit, org search.
1Free no-key IP intelligence: geolocation, VPN detection, DNS, WHOIS, blacklists, breach checks
Query WHOIS/RDAP information for domains, IP addresses, CIDR prefixes and ASNs. Self-hostable.
Related MCP Servers
- AlicenseAqualityDmaintenanceProvides threat intelligence lookups against the AbuseIPDB database, enabling IP reputation checks, CIDR block analysis, and log enrichment. It features intelligent caching and rate limiting to efficiently manage API usage for security analysis and automated workflows.5MIT
- AlicenseAqualityCmaintenanceProvides global internet resource intelligence by querying RIRs for IP and ASN data, routing visibility, and network health. It enables users to perform RPKI validation, BGP inspection, and historical allocation analysis through natural language or a REST API.421MIT
- AlicenseAqualityCmaintenanceMCP server providing DNS resolution, reverse DNS, RDAP-based WHOIS, and IP geolocation lookups. No API keys required , and all upstreams are public.4MIT
- AlicenseNot gradedqualityAmaintenanceQuery WHOIS/RDAP information for domains, IP addresses, CIDR prefixes and ASNs. Results are normalized to RDAP-style (RFC 9083) JSON. Public instance of the open-source KincaidYang/whois server, which can also be self-hosted.64MIT