Skip to main content
Glama
README.md
# 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](../grounded-kql-mcp), applying the same
"grounded, not free-form" discipline to network identity resolution instead
of KQL.

## Quickstart (local, no Azure needed)

```bash
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 stdio
```

## The tools

`resolve_ips(ips: list[str])` - up to 50 IPs per call. For each IP, returns:

```json
{
  "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:

```json
{
  "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`:

```python
_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 a `timeout` result 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 a `resolved`/`no_ptr_record`
  answer 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:

1. **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.

2. **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.

3. **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.

4. **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.

5. **Read the status the tool already gives you before assuming it's ambiguous.**
   `no_ptr_record` proves 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. `timeout` proves 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 a `timeout` as "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 `data` block (see
  `data.tf`) - it never creates one. If you don't already have one, create it
  first (a plain `azurerm_container_app_environment` resource, the CLI, or
  the Portal), then point `container_app_environment_name` /
  `container_app_environment_resource_group_name` at 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.md` is
  gitignored, not part of this repo, so that's not a citation you can open;
  the reasoning that matters is inline here and in `CLAUDE.md`). Local
  `.tfstate` isn't enough once anything
  other than your own machine needs to run `terraform 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:

```bash
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
- `:latest` is 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 `master`** runs `build-and-plan` automatically: builds and pushes
  `ipenrichmcp:<sha>` (skipped if that tag already exists, so a re-run never
  re-pushes an existing tag with a different digest), then a read-only
  `terraform plan`, summarized on the run page. Nothing is deployed.
- **"Run workflow" in the Actions tab** (`workflow_dispatch`, `master` only)
  runs the same build-and-plan and then `apply`: a fresh plan and `terraform
  apply` in one job, followed by live verification - Azure must report the
  Container App on this build's sha tag with its newest revision ready, and
  `verify_deployment.py` calls both tools through a real MCP client against
  the live endpoint, asserting on content (a real Azure IP gets a service tag
  whose `matched_prefix` actually contains it, `AzureCloud` has prefixes,
  and `service_tags_synced_at` is 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).

TDQS

A4.2/5.0

Scored across 2 tools

Disambiguation5/5

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.

Naming Consistency5/5

Both tools use snake_case and follow a verb_noun pattern (resolve_ips, list_service_tag_ranges), providing a consistent and predictable naming convention.

Tool Count3/5

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).

Completeness4/5

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

ActivityMaintained
ResponsivenessNo issues