@urbankitstudio/mcp-atlas 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.js +410 -0
- package/package.json +60 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Leo Yong
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# @urbankitstudio/mcp-atlas
|
|
2
|
+
|
|
3
|
+
Query 137 verified US county parcel ArcGIS REST endpoints — owner/APN lookup — via MCP.
|
|
4
|
+
|
|
5
|
+
An [MCP](https://modelcontextprotocol.io) server that gives AI assistants direct access to UrbanKit Studio's atlas of manually verified county parcel GIS services. Ask Claude or Cursor to find the ArcGIS REST endpoint for any covered county, get the exact owner-search query URL, and look up parcel data — without needing to know anything about ArcGIS REST API conventions.
|
|
6
|
+
|
|
7
|
+
**Coverage:** 128+ counties across 39 US states (v0.4.0 atlas, updated May 2026).
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Quick start
|
|
12
|
+
|
|
13
|
+
### Claude Desktop
|
|
14
|
+
|
|
15
|
+
Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%\Claude\claude_desktop_config.json` (Windows):
|
|
16
|
+
|
|
17
|
+
```json
|
|
18
|
+
{
|
|
19
|
+
"mcpServers": {
|
|
20
|
+
"mcp-atlas": {
|
|
21
|
+
"command": "npx",
|
|
22
|
+
"args": ["-y", "@urbankitstudio/mcp-atlas"]
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### Cursor
|
|
29
|
+
|
|
30
|
+
Add to `.cursor/mcp.json` in your project root (or `~/.cursor/mcp.json` globally):
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"mcp-atlas": {
|
|
36
|
+
"command": "npx",
|
|
37
|
+
"args": ["-y", "@urbankitstudio/mcp-atlas"]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Install globally (optional)
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
npm install -g @urbankitstudio/mcp-atlas
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then use `mcp-atlas` as the command instead of `npx -y @urbankitstudio/mcp-atlas`.
|
|
50
|
+
|
|
51
|
+
---
|
|
52
|
+
|
|
53
|
+
## Tools
|
|
54
|
+
|
|
55
|
+
### `list_counties`
|
|
56
|
+
|
|
57
|
+
Lists all counties with a verified parcel REST endpoint.
|
|
58
|
+
|
|
59
|
+
| Parameter | Type | Required | Description |
|
|
60
|
+
|-----------|------|----------|-------------|
|
|
61
|
+
| `state` | string | No | Two-letter abbreviation (`IL`) or full name (`Illinois`) |
|
|
62
|
+
|
|
63
|
+
**Example prompt:** "List all covered counties in Illinois"
|
|
64
|
+
|
|
65
|
+
**Example output:**
|
|
66
|
+
```
|
|
67
|
+
ST | County | Slug | Coverage
|
|
68
|
+
--------------------------------------------------------------------
|
|
69
|
+
IL | Kane | kane-county | owner+APN
|
|
70
|
+
IL | Cook | cook-county | APN only
|
|
71
|
+
IL | DuPage | dupage-county | owner+APN
|
|
72
|
+
...
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
### `find_county`
|
|
78
|
+
|
|
79
|
+
Fuzzy-matches a county by name or 5-digit FIPS code. Returns endpoint URLs, searchable field names, owner field, sample query, and license info.
|
|
80
|
+
|
|
81
|
+
| Parameter | Type | Required | Description |
|
|
82
|
+
|-----------|------|----------|-------------|
|
|
83
|
+
| `query` | string | Yes | County name (`Kane`), name+state (`Kane IL`), or FIPS (`17089`) |
|
|
84
|
+
|
|
85
|
+
**Example prompt:** "Find the parcel endpoint for Kane County Illinois"
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
### `get_parcel_endpoint`
|
|
90
|
+
|
|
91
|
+
Returns the full ArcGIS REST URL, layer index, searchable fields, owner field, and a ready sample `?where=…&f=json` query for a specific county.
|
|
92
|
+
|
|
93
|
+
| Parameter | Type | Required | Description |
|
|
94
|
+
|-----------|------|----------|-------------|
|
|
95
|
+
| `state` | string | Yes | Two-letter abbreviation or full name |
|
|
96
|
+
| `county` | string | Yes | County name (`Kane` or `Kane County`) |
|
|
97
|
+
|
|
98
|
+
**Example prompt:** "Give me the ArcGIS REST endpoint for Cook County Illinois"
|
|
99
|
+
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
### `build_owner_query`
|
|
103
|
+
|
|
104
|
+
Constructs the exact ArcGIS REST query URL using the county's verified owner/taxpayer field. Uses `UPPER(field) LIKE UPPER('%NAME%')` — case-insensitive partial match.
|
|
105
|
+
|
|
106
|
+
| Parameter | Type | Required | Description |
|
|
107
|
+
|-----------|------|----------|-------------|
|
|
108
|
+
| `state` | string | Yes | Two-letter abbreviation or full name |
|
|
109
|
+
| `county` | string | Yes | County name |
|
|
110
|
+
| `owner_name` | string | Yes | Owner/taxpayer name (partial match) |
|
|
111
|
+
|
|
112
|
+
**Example prompt:** "Build an ArcGIS query for properties owned by 'Smith' in Kane County IL"
|
|
113
|
+
|
|
114
|
+
**Example output:**
|
|
115
|
+
```
|
|
116
|
+
County: Kane, Illinois
|
|
117
|
+
Owner field: TaxName
|
|
118
|
+
WHERE clause: UPPER(TaxName) LIKE UPPER('%SMITH%')
|
|
119
|
+
|
|
120
|
+
Query URL:
|
|
121
|
+
https://gistech.countyofkane.org/arcgis/rest/services/KanePINList/MapServer/0/query
|
|
122
|
+
?where=UPPER(TaxName)%20LIKE%20UPPER('%25SMITH%25')
|
|
123
|
+
&outFields=PIN,TaxName,SiteAddress,SiteCity,MailingAddress
|
|
124
|
+
&returnGeometry=false&f=json&resultRecordCount=25
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Example conversation
|
|
130
|
+
|
|
131
|
+
> **User:** I'm doing due diligence on properties in Kane County, Illinois. Can you find all parcels owned by "Blackstone"?
|
|
132
|
+
|
|
133
|
+
> **Claude (using mcp-atlas):**
|
|
134
|
+
> 1. Calls `get_parcel_endpoint` → gets the `gistech.countyofkane.org` URL and confirms the owner field is `TaxName`
|
|
135
|
+
> 2. Calls `build_owner_query` with `owner_name=Blackstone` → returns a ready fetch URL
|
|
136
|
+
> 3. Optionally fetches the URL and formats the parcel results
|
|
137
|
+
|
|
138
|
+
---
|
|
139
|
+
|
|
140
|
+
## Atlas coverage
|
|
141
|
+
|
|
142
|
+
The atlas is maintained by [UrbanKit Studio](https://urbankitstudio.com/parcel-atlas). All endpoints are manually verified. Counties with an owner/taxpayer field support full name-based lookups; PIN-only counties support APN/parcel-number queries.
|
|
143
|
+
|
|
144
|
+
Full coverage map: https://urbankitstudio.com/parcel-atlas
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Data
|
|
149
|
+
|
|
150
|
+
Atlas data is embedded in the package (no network calls at startup). The underlying `@urbankitstudio/atlas` SDK is also published separately for programmatic use.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## License
|
|
155
|
+
|
|
156
|
+
MIT — © Leo Yong / UrbanKit Studio
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @urbankitstudio/mcp-atlas
|
|
4
|
+
*
|
|
5
|
+
* MCP server exposing UrbanKit Studio's verified county parcel ArcGIS REST atlas.
|
|
6
|
+
* Runs over stdio — compatible with Claude Desktop, Cursor, and any MCP client.
|
|
7
|
+
*
|
|
8
|
+
* Tools:
|
|
9
|
+
* list_counties – list covered counties (optional state filter)
|
|
10
|
+
* find_county – fuzzy-match a county, return endpoints + searchable fields
|
|
11
|
+
* get_parcel_endpoint – return the full REST service URL + ready sample query
|
|
12
|
+
* build_owner_query – construct exact ArcGIS REST query for an owner name
|
|
13
|
+
*/
|
|
14
|
+
export {};
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @urbankitstudio/mcp-atlas
|
|
4
|
+
*
|
|
5
|
+
* MCP server exposing UrbanKit Studio's verified county parcel ArcGIS REST atlas.
|
|
6
|
+
* Runs over stdio — compatible with Claude Desktop, Cursor, and any MCP client.
|
|
7
|
+
*
|
|
8
|
+
* Tools:
|
|
9
|
+
* list_counties – list covered counties (optional state filter)
|
|
10
|
+
* find_county – fuzzy-match a county, return endpoints + searchable fields
|
|
11
|
+
* get_parcel_endpoint – return the full REST service URL + ready sample query
|
|
12
|
+
* build_owner_query – construct exact ArcGIS REST query for an owner name
|
|
13
|
+
*/
|
|
14
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
15
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
16
|
+
import { z } from "zod/v3";
|
|
17
|
+
import { atlas, atlasIndex, slugify, countySlugFromName, } from "@urbankitstudio/atlas";
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Helpers
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
function ownerFieldFrom(endpoint) {
|
|
22
|
+
const f = endpoint.searchFields.find((sf) => /owner|taxpayer|taxname/i.test(sf.name));
|
|
23
|
+
return f?.name ?? null;
|
|
24
|
+
}
|
|
25
|
+
function buildArcgisOwnerQuery(endpoint, ownerQuery) {
|
|
26
|
+
const field = ownerFieldFrom(endpoint);
|
|
27
|
+
if (!field)
|
|
28
|
+
return "";
|
|
29
|
+
const where = `UPPER(${field}) LIKE UPPER('%25${encodeURIComponent(ownerQuery)}%25')`;
|
|
30
|
+
const liveFields = endpoint.searchFields
|
|
31
|
+
.filter((sf) => sf.searchable)
|
|
32
|
+
.map((sf) => sf.name)
|
|
33
|
+
.join(",");
|
|
34
|
+
return (`${endpoint.url}/query` +
|
|
35
|
+
`?where=${where}` +
|
|
36
|
+
`&outFields=${liveFields}` +
|
|
37
|
+
`&returnGeometry=false` +
|
|
38
|
+
`&f=json` +
|
|
39
|
+
`&resultRecordCount=25`);
|
|
40
|
+
}
|
|
41
|
+
function formatCountySummary(c) {
|
|
42
|
+
const epSummary = c.endpoints.length === 0
|
|
43
|
+
? "no REST endpoint mapped"
|
|
44
|
+
: c.endpoints
|
|
45
|
+
.map((ep) => {
|
|
46
|
+
const ownerField = ownerFieldFrom(ep);
|
|
47
|
+
const searchable = ep.searchFields
|
|
48
|
+
.filter((sf) => sf.searchable)
|
|
49
|
+
.map((sf) => `${sf.name} (${sf.label})`)
|
|
50
|
+
.join(", ");
|
|
51
|
+
return [
|
|
52
|
+
` URL: ${ep.url}`,
|
|
53
|
+
` Service: ${ep.serviceType}/layer ${ep.layerIndex}`,
|
|
54
|
+
` Status: ${ep.status}`,
|
|
55
|
+
` Searchable fields: ${searchable || "none"}`,
|
|
56
|
+
` Owner field: ${ownerField ?? "none (PIN-only county)"}`,
|
|
57
|
+
` License: ${ep.license}`,
|
|
58
|
+
].join("\n");
|
|
59
|
+
})
|
|
60
|
+
.join("\n---\n");
|
|
61
|
+
return [
|
|
62
|
+
`${c.county}, ${c.stateName} (${c.state})`,
|
|
63
|
+
`FIPS: ${c.countyFips ?? "n/a"}`,
|
|
64
|
+
`Endpoints (${c.endpoints.length}):`,
|
|
65
|
+
epSummary,
|
|
66
|
+
c.notes ? `Notes: ${c.notes}` : "",
|
|
67
|
+
]
|
|
68
|
+
.filter(Boolean)
|
|
69
|
+
.join("\n");
|
|
70
|
+
}
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Server
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
const server = new McpServer({ name: "mcp-atlas", version: "0.1.0" }, {
|
|
75
|
+
instructions: "UrbanKit Atlas MCP server. Use list_counties to discover coverage, find_county or get_parcel_endpoint to get the ArcGIS REST URL, and build_owner_query to construct a ready-to-fire owner-name lookup URL.",
|
|
76
|
+
});
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Tool: list_counties
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
server.registerTool("list_counties", {
|
|
81
|
+
title: "List covered counties",
|
|
82
|
+
description: "Returns all counties in the UrbanKit Atlas that have a verified ArcGIS REST parcel endpoint. Pass a state abbreviation (e.g. 'IL') or state name (e.g. 'Illinois') to filter by state. Omit state to list all ~137 counties.",
|
|
83
|
+
inputSchema: {
|
|
84
|
+
state: z
|
|
85
|
+
.string()
|
|
86
|
+
.optional()
|
|
87
|
+
.describe("Optional: two-letter state abbreviation (e.g. 'IL') or full state name (e.g. 'Illinois')"),
|
|
88
|
+
},
|
|
89
|
+
}, ({ state }) => {
|
|
90
|
+
const stateFilter = state?.trim().toLowerCase();
|
|
91
|
+
const rows = [];
|
|
92
|
+
for (const stateEntry of atlasIndex.states) {
|
|
93
|
+
if (!stateEntry.populated)
|
|
94
|
+
continue;
|
|
95
|
+
// Filter by state if provided
|
|
96
|
+
if (stateFilter) {
|
|
97
|
+
const matchAbbrev = stateEntry.abbrev.toLowerCase() === stateFilter;
|
|
98
|
+
const matchName = stateEntry.name.toLowerCase() === stateFilter;
|
|
99
|
+
const matchSlug = stateEntry.slug === slugify(stateFilter);
|
|
100
|
+
if (!matchAbbrev && !matchName && !matchSlug)
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
const stateFile = atlas.byStateSlug.get(stateEntry.slug);
|
|
104
|
+
if (!stateFile)
|
|
105
|
+
continue;
|
|
106
|
+
const covered = stateFile.counties.filter((c) => c.endpoints.length > 0);
|
|
107
|
+
for (const c of covered) {
|
|
108
|
+
const ownerCoverage = c.endpoints.some((ep) => ownerFieldFrom(ep))
|
|
109
|
+
? "owner+APN"
|
|
110
|
+
: "APN only";
|
|
111
|
+
rows.push(`${c.state} | ${c.county.padEnd(20)} | ${c.countySlug.padEnd(24)} | ${ownerCoverage}`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (rows.length === 0) {
|
|
115
|
+
return {
|
|
116
|
+
content: [
|
|
117
|
+
{
|
|
118
|
+
type: "text",
|
|
119
|
+
text: stateFilter
|
|
120
|
+
? `No covered counties found for state "${state}". Check state name/abbreviation.`
|
|
121
|
+
: "No counties found (unexpected — check atlas data).",
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const header = "ST | County | Slug | Coverage";
|
|
127
|
+
const divider = "-".repeat(header.length);
|
|
128
|
+
const totals = `\nTotal: ${rows.length} counties`;
|
|
129
|
+
return {
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: "text",
|
|
133
|
+
text: [header, divider, ...rows, divider, totals].join("\n"),
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
});
|
|
138
|
+
// ---------------------------------------------------------------------------
|
|
139
|
+
// Tool: find_county
|
|
140
|
+
// ---------------------------------------------------------------------------
|
|
141
|
+
server.registerTool("find_county", {
|
|
142
|
+
title: "Find a county by name or FIPS",
|
|
143
|
+
description: "Fuzzy-matches a county by name (e.g. 'Kane', 'Cook County', 'Cook County IL') or by 5-digit FIPS code. Returns endpoint URLs, searchable field names, owner field, sample query URL, and license info.",
|
|
144
|
+
inputSchema: {
|
|
145
|
+
query: z
|
|
146
|
+
.string()
|
|
147
|
+
.min(2)
|
|
148
|
+
.describe("County name, 'County Name State' (e.g. 'Kane IL'), or 5-digit FIPS code"),
|
|
149
|
+
},
|
|
150
|
+
}, ({ query }) => {
|
|
151
|
+
const q = query.trim().toLowerCase();
|
|
152
|
+
// FIPS lookup
|
|
153
|
+
const isFips = /^\d{5}$/.test(q);
|
|
154
|
+
const matches = [];
|
|
155
|
+
for (const stateEntry of atlasIndex.states) {
|
|
156
|
+
if (!stateEntry.populated)
|
|
157
|
+
continue;
|
|
158
|
+
const stateFile = atlas.byStateSlug.get(stateEntry.slug);
|
|
159
|
+
if (!stateFile)
|
|
160
|
+
continue;
|
|
161
|
+
for (const county of stateFile.counties) {
|
|
162
|
+
if (isFips) {
|
|
163
|
+
if (county.countyFips === q)
|
|
164
|
+
matches.push(county);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
// Parse optional state suffix: "Kane IL" or "Kane Illinois"
|
|
168
|
+
let countyPart = q;
|
|
169
|
+
let statePart = null;
|
|
170
|
+
const spaceIdx = q.lastIndexOf(" ");
|
|
171
|
+
if (spaceIdx > 0) {
|
|
172
|
+
const last = q.slice(spaceIdx + 1);
|
|
173
|
+
if (last.length === 2 || last.length > 3) {
|
|
174
|
+
countyPart = q.slice(0, spaceIdx);
|
|
175
|
+
statePart = last;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (statePart) {
|
|
179
|
+
const stateOk = stateEntry.abbrev.toLowerCase() === statePart ||
|
|
180
|
+
stateEntry.name.toLowerCase() === statePart ||
|
|
181
|
+
stateEntry.slug === slugify(statePart);
|
|
182
|
+
if (!stateOk)
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const slug = countySlugFromName(countyPart);
|
|
186
|
+
if (county.countySlug === slug ||
|
|
187
|
+
county.county.toLowerCase() === countyPart ||
|
|
188
|
+
county.county.toLowerCase().startsWith(countyPart)) {
|
|
189
|
+
matches.push(county);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (matches.length === 0) {
|
|
194
|
+
return {
|
|
195
|
+
content: [
|
|
196
|
+
{
|
|
197
|
+
type: "text",
|
|
198
|
+
text: `No county matched "${query}".\n` +
|
|
199
|
+
`Try: "Kane IL", "Cook County IL", "17031" (FIPS), or use list_counties to browse.`,
|
|
200
|
+
},
|
|
201
|
+
],
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const text = matches.map(formatCountySummary).join("\n\n" + "=".repeat(60) + "\n\n");
|
|
205
|
+
return {
|
|
206
|
+
content: [
|
|
207
|
+
{
|
|
208
|
+
type: "text",
|
|
209
|
+
text: matches.length > 1 ? `${matches.length} matches:\n\n${text}` : text,
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
});
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Tool: get_parcel_endpoint
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
server.registerTool("get_parcel_endpoint", {
|
|
218
|
+
title: "Get parcel ArcGIS REST endpoint",
|
|
219
|
+
description: "Returns the full ArcGIS REST service URL, layer index, searchable field names, owner field, a ready sample ?where=…&f=json query, and the UrbanKit deep-link for a specific county.",
|
|
220
|
+
inputSchema: {
|
|
221
|
+
state: z
|
|
222
|
+
.string()
|
|
223
|
+
.describe("Two-letter state abbreviation (e.g. 'IL') or full state name"),
|
|
224
|
+
county: z
|
|
225
|
+
.string()
|
|
226
|
+
.describe("County name (e.g. 'Kane' or 'Kane County')"),
|
|
227
|
+
},
|
|
228
|
+
}, ({ state, county }) => {
|
|
229
|
+
const stateSlug = slugify(state.trim());
|
|
230
|
+
const countySlug = countySlugFromName(county.trim());
|
|
231
|
+
// Try exact slug match first, then abbrev match
|
|
232
|
+
let countyRecord;
|
|
233
|
+
for (const stateEntry of atlasIndex.states) {
|
|
234
|
+
if (!stateEntry.populated)
|
|
235
|
+
continue;
|
|
236
|
+
const abbrevMatch = stateEntry.abbrev.toLowerCase() === state.trim().toLowerCase();
|
|
237
|
+
const slugMatch = stateEntry.slug === stateSlug;
|
|
238
|
+
const nameMatch = stateEntry.name.toLowerCase() === state.trim().toLowerCase();
|
|
239
|
+
if (!abbrevMatch && !slugMatch && !nameMatch)
|
|
240
|
+
continue;
|
|
241
|
+
const stateFile = atlas.byStateSlug.get(stateEntry.slug);
|
|
242
|
+
if (!stateFile)
|
|
243
|
+
continue;
|
|
244
|
+
countyRecord = stateFile.counties.find((c) => c.countySlug === countySlug);
|
|
245
|
+
if (countyRecord)
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
if (!countyRecord) {
|
|
249
|
+
return {
|
|
250
|
+
content: [
|
|
251
|
+
{
|
|
252
|
+
type: "text",
|
|
253
|
+
text: `County "${county}" not found in state "${state}".\n` +
|
|
254
|
+
`Use list_counties or find_county to verify the name.`,
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
if (countyRecord.endpoints.length === 0) {
|
|
260
|
+
return {
|
|
261
|
+
content: [
|
|
262
|
+
{
|
|
263
|
+
type: "text",
|
|
264
|
+
text: `${countyRecord.county}, ${countyRecord.stateName} is in the atlas but has no verified REST endpoint yet.`,
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
const lines = [
|
|
270
|
+
`${countyRecord.county} County, ${countyRecord.stateName} (${countyRecord.state})`,
|
|
271
|
+
`FIPS: ${countyRecord.countyFips ?? "n/a"}`,
|
|
272
|
+
"",
|
|
273
|
+
];
|
|
274
|
+
countyRecord.endpoints.forEach((ep, i) => {
|
|
275
|
+
const ownerField = ownerFieldFrom(ep);
|
|
276
|
+
const sampleOwnerUrl = ownerField
|
|
277
|
+
? buildArcgisOwnerQuery(ep, "SMITH")
|
|
278
|
+
: null;
|
|
279
|
+
lines.push(`Endpoint ${i + 1}:`);
|
|
280
|
+
lines.push(` URL: ${ep.url}`);
|
|
281
|
+
lines.push(` Service: ${ep.serviceType}`);
|
|
282
|
+
lines.push(` Layer index: ${ep.layerIndex}`);
|
|
283
|
+
lines.push(` Layer name: ${ep.layerName}`);
|
|
284
|
+
lines.push(` Status: ${ep.status} (verified ${ep.lastVerified})`);
|
|
285
|
+
lines.push(` CORS: ${ep.corsEnabled === null ? "unknown" : ep.corsEnabled}`);
|
|
286
|
+
lines.push(` License: ${ep.license}${ep.licenseUrl ? ` (${ep.licenseUrl})` : ""}`);
|
|
287
|
+
lines.push("");
|
|
288
|
+
lines.push(" Searchable fields:");
|
|
289
|
+
ep.searchFields
|
|
290
|
+
.filter((sf) => sf.searchable)
|
|
291
|
+
.forEach((sf) => lines.push(` ${sf.name.padEnd(20)} – ${sf.label}`));
|
|
292
|
+
lines.push("");
|
|
293
|
+
lines.push(` Owner field: ${ownerField ?? "NONE — PIN-only county"}`);
|
|
294
|
+
if (ep.sampleQuery) {
|
|
295
|
+
lines.push("");
|
|
296
|
+
lines.push(" Sample query (from atlas):");
|
|
297
|
+
lines.push(` ${ep.sampleQuery}`);
|
|
298
|
+
}
|
|
299
|
+
if (sampleOwnerUrl) {
|
|
300
|
+
lines.push("");
|
|
301
|
+
lines.push(' Sample owner query (SMITH — replace with target name):');
|
|
302
|
+
lines.push(` ${sampleOwnerUrl}`);
|
|
303
|
+
}
|
|
304
|
+
lines.push("");
|
|
305
|
+
lines.push(` UrbanKit deep-link: https://urbankitstudio.com/tools/parcel-lookup?endpoint=${encodeURIComponent(ep.url)}${ownerField ? `&fieldHint=${ownerField}` : ""}`);
|
|
306
|
+
if (i < countyRecord.endpoints.length - 1)
|
|
307
|
+
lines.push("\n" + "-".repeat(40));
|
|
308
|
+
});
|
|
309
|
+
return {
|
|
310
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
311
|
+
};
|
|
312
|
+
});
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// Tool: build_owner_query
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
server.registerTool("build_owner_query", {
|
|
317
|
+
title: "Build ArcGIS owner name query URL",
|
|
318
|
+
description: "Constructs the exact ArcGIS REST query URL using that county's verified owner/taxpayer field. Returns a URL you can open in a browser or fetch directly. The query uses UPPER(field) LIKE UPPER('%NAME%') — case-insensitive partial match.",
|
|
319
|
+
inputSchema: {
|
|
320
|
+
state: z
|
|
321
|
+
.string()
|
|
322
|
+
.describe("Two-letter state abbreviation (e.g. 'IL') or full state name"),
|
|
323
|
+
county: z
|
|
324
|
+
.string()
|
|
325
|
+
.describe("County name (e.g. 'Kane' or 'Kane County')"),
|
|
326
|
+
owner_name: z
|
|
327
|
+
.string()
|
|
328
|
+
.min(2)
|
|
329
|
+
.describe("Owner/taxpayer name to search for (partial match, case-insensitive)"),
|
|
330
|
+
},
|
|
331
|
+
}, ({ state, county, owner_name }) => {
|
|
332
|
+
const stateInput = state.trim();
|
|
333
|
+
const countySlug = countySlugFromName(county.trim());
|
|
334
|
+
let countyRecord;
|
|
335
|
+
for (const stateEntry of atlasIndex.states) {
|
|
336
|
+
if (!stateEntry.populated)
|
|
337
|
+
continue;
|
|
338
|
+
const abbrevMatch = stateEntry.abbrev.toLowerCase() === stateInput.toLowerCase();
|
|
339
|
+
const slugMatch = stateEntry.slug === slugify(stateInput);
|
|
340
|
+
const nameMatch = stateEntry.name.toLowerCase() === stateInput.toLowerCase();
|
|
341
|
+
if (!abbrevMatch && !slugMatch && !nameMatch)
|
|
342
|
+
continue;
|
|
343
|
+
const stateFile = atlas.byStateSlug.get(stateEntry.slug);
|
|
344
|
+
if (!stateFile)
|
|
345
|
+
continue;
|
|
346
|
+
countyRecord = stateFile.counties.find((c) => c.countySlug === countySlug);
|
|
347
|
+
if (countyRecord)
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
if (!countyRecord) {
|
|
351
|
+
return {
|
|
352
|
+
content: [
|
|
353
|
+
{
|
|
354
|
+
type: "text",
|
|
355
|
+
text: `County "${county}" not found in state "${state}". Use find_county to verify.`,
|
|
356
|
+
},
|
|
357
|
+
],
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
const results = [];
|
|
361
|
+
for (const ep of countyRecord.endpoints) {
|
|
362
|
+
const ownerField = ownerFieldFrom(ep);
|
|
363
|
+
if (!ownerField) {
|
|
364
|
+
results.push(`Endpoint: ${ep.url}\nNote: No owner/taxpayer field available in this county — PIN-only lookup. Try searching by parcel number instead.`);
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
const queryUrl = buildArcgisOwnerQuery(ep, owner_name);
|
|
368
|
+
const where = `UPPER(${ownerField}) LIKE UPPER('%${owner_name}%')`;
|
|
369
|
+
results.push([
|
|
370
|
+
`County: ${countyRecord.county}, ${countyRecord.stateName}`,
|
|
371
|
+
`Owner field: ${ownerField}`,
|
|
372
|
+
`WHERE clause: ${where}`,
|
|
373
|
+
``,
|
|
374
|
+
`Query URL:`,
|
|
375
|
+
queryUrl,
|
|
376
|
+
``,
|
|
377
|
+
`Notes:`,
|
|
378
|
+
` - Returns up to 25 records`,
|
|
379
|
+
` - Partial name match (e.g. "SMITH" matches "SMITH JOHN" and "BLACKSMITH LLC")`,
|
|
380
|
+
` - Case-insensitive`,
|
|
381
|
+
` - Add &token=<your-token> if the service requires auth (this county is public)`,
|
|
382
|
+
].join("\n"));
|
|
383
|
+
}
|
|
384
|
+
if (results.length === 0) {
|
|
385
|
+
return {
|
|
386
|
+
content: [
|
|
387
|
+
{
|
|
388
|
+
type: "text",
|
|
389
|
+
text: `${countyRecord.county}, ${countyRecord.stateName} has no REST endpoints in the atlas yet.`,
|
|
390
|
+
},
|
|
391
|
+
],
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
return {
|
|
395
|
+
content: [{ type: "text", text: results.join("\n\n" + "=".repeat(60) + "\n\n") }],
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
// Connect and run
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
async function main() {
|
|
402
|
+
const transport = new StdioServerTransport();
|
|
403
|
+
await server.connect(transport);
|
|
404
|
+
// MCP protocol runs over stdin/stdout; stderr is safe for diagnostics
|
|
405
|
+
process.stderr.write("mcp-atlas server started (stdio)\n");
|
|
406
|
+
}
|
|
407
|
+
main().catch((err) => {
|
|
408
|
+
process.stderr.write(`mcp-atlas fatal error: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
409
|
+
process.exit(1);
|
|
410
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@urbankitstudio/mcp-atlas",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Query 137 verified US county parcel ArcGIS REST endpoints — owner/APN lookup — via MCP",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"mcp",
|
|
7
|
+
"mcp-server",
|
|
8
|
+
"parcel",
|
|
9
|
+
"gis",
|
|
10
|
+
"arcgis",
|
|
11
|
+
"county",
|
|
12
|
+
"apn",
|
|
13
|
+
"address-to-parcel",
|
|
14
|
+
"property-data",
|
|
15
|
+
"real-estate",
|
|
16
|
+
"esri"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://urbankitstudio.com/parcel-atlas",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/LEOyrh/urbankitstudio.git",
|
|
22
|
+
"directory": "packages/mcp-atlas"
|
|
23
|
+
},
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/LEOyrh/urbankitstudio/issues"
|
|
26
|
+
},
|
|
27
|
+
"author": "Leo Yong <leoyrhbiz@gmail.com>",
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"type": "module",
|
|
30
|
+
"main": "./dist/server.js",
|
|
31
|
+
"types": "./dist/server.d.ts",
|
|
32
|
+
"bin": {
|
|
33
|
+
"mcp-atlas": "./dist/server.js"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc -p tsconfig.build.json",
|
|
42
|
+
"typecheck": "tsc --noEmit",
|
|
43
|
+
"smoke": "node test/smoke.mjs",
|
|
44
|
+
"prepublishOnly": "npm run typecheck && npm run build && npm run smoke"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
|
+
"@urbankitstudio/atlas": "^0.4.0"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/node": "^26.0.0",
|
|
52
|
+
"typescript": "^5.8.3"
|
|
53
|
+
},
|
|
54
|
+
"publishConfig": {
|
|
55
|
+
"access": "public"
|
|
56
|
+
},
|
|
57
|
+
"engines": {
|
|
58
|
+
"node": ">=18"
|
|
59
|
+
}
|
|
60
|
+
}
|