@urbankitstudio/mcp-atlas 0.2.6 → 0.2.8
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/README.md +27 -6
- package/dist/search-policy.d.ts +90 -0
- package/dist/search-policy.js +123 -0
- package/dist/server.d.ts +2 -12
- package/dist/server.js +68 -75
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
[](https://glama.ai/mcp/servers/LEOyrh/mcp-atlas)
|
|
6
6
|
|
|
7
|
-
Query the verified parcel ArcGIS REST endpoints of
|
|
7
|
+
Query the verified parcel ArcGIS REST endpoints of 246 counties across 51 US states (50 states plus DC), 258 endpoints total, for owner, APN and address lookup via the Model Context Protocol (MCP).
|
|
8
8
|
|
|
9
9
|
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.
|
|
10
10
|
|
|
11
|
-
**Coverage:**
|
|
11
|
+
**Coverage:** 246 counties across 51 US states (50 states plus DC), 258 verified endpoints (atlas 0.6.14).
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|
|
@@ -96,6 +96,16 @@ IL | DuPage | dupage-county | owner+APN
|
|
|
96
96
|
...
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
+
What each Coverage label promises:
|
|
100
|
+
|
|
101
|
+
| Label | Meaning |
|
|
102
|
+
|-------|---------|
|
|
103
|
+
| `owner+APN` | `build_owner_query` returns a URL for at least one endpoint |
|
|
104
|
+
| `APN only (county publishes no owner name)` | A reviewed record says the county serves no usable owner name |
|
|
105
|
+
| `location queries only` | No endpoint answers attribute searches; parcels are reached by geometry, which also returns owner fields |
|
|
106
|
+
| `APN only (owner column not searchable)` | An owner column exists but the county marks it unsearchable by name; a parcel-id or location query returns it |
|
|
107
|
+
| `APN only` | No endpoint documents an owner column |
|
|
108
|
+
|
|
99
109
|
---
|
|
100
110
|
|
|
101
111
|
### `find_county`
|
|
@@ -117,8 +127,10 @@ endpoint URLs, searchable field names, owner field, sample query, license.
|
|
|
117
127
|
### `get_parcel_endpoint`
|
|
118
128
|
|
|
119
129
|
The default lookup once the county is known. Returns the full ArcGIS REST URL,
|
|
120
|
-
layer index, searchable fields, owner field
|
|
121
|
-
|
|
130
|
+
layer index, searchable fields, owner field (or why no owner query is offered),
|
|
131
|
+
the county's `Scope:` predicate when the layer is shared statewide or
|
|
132
|
+
regionally, a generic sample `?where=…&f=json` query and the UrbanKit deep-link
|
|
133
|
+
for one county. For a named owner, use
|
|
122
134
|
`build_owner_query` rather than editing the sample query by hand.
|
|
123
135
|
|
|
124
136
|
| Parameter | Type | Required | Description |
|
|
@@ -135,8 +147,17 @@ query and the UrbanKit deep-link for one county. For a named owner, use
|
|
|
135
147
|
The only tool that searches for a named owner. Fills a person or company name
|
|
136
148
|
into the county's verified owner/taxpayer field as
|
|
137
149
|
`UPPER(field) LIKE UPPER('%NAME%')`, a case-insensitive partial match, and
|
|
138
|
-
returns a URL you can fetch or open.
|
|
139
|
-
|
|
150
|
+
returns a URL you can fetch or open. On a shared statewide or regional layer
|
|
151
|
+
(Connecticut's regional composites, for example) the county's scope predicate
|
|
152
|
+
is ANDed in front, as
|
|
153
|
+
`(scope) AND UPPER(field) LIKE …`, so only that county's rows come back.
|
|
154
|
+
|
|
155
|
+
An endpoint is refused, with the reason, rather than handed a query that finds
|
|
156
|
+
nothing or hangs:
|
|
157
|
+
|
|
158
|
+
- a reviewed record says the county publishes no usable owner name;
|
|
159
|
+
- the layer serves no attribute search at all (query it by location instead);
|
|
160
|
+
- the owner column is not searchable by name (use a parcel-id or location query).
|
|
140
161
|
|
|
141
162
|
| Parameter | Type | Required | Description |
|
|
142
163
|
|-----------|------|----------|-------------|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-search policy: the ONE rule for whether this server offers an
|
|
3
|
+
* owner-name query on a county endpoint, and how that query is scoped.
|
|
4
|
+
*
|
|
5
|
+
* Every tool (list_counties, find_county, get_parcel_endpoint,
|
|
6
|
+
* build_owner_query) asks this module and nothing else, so no county can be
|
|
7
|
+
* offered by one tool and refused by another. Written 2026-10-03 on Leo's
|
|
8
|
+
* directive to surface as much of the county data as the registry knows,
|
|
9
|
+
* with clean, commented SOLID/DRY/KISS code.
|
|
10
|
+
*
|
|
11
|
+
* Decision order, first match wins:
|
|
12
|
+
* 1. reviewed_unservable: a human-reviewed capability record says the owner
|
|
13
|
+
* name cannot be served. New Jersey's statewide layer publishes OWNER_NAME
|
|
14
|
+
* blank on 3.4M rows; only the reviewed record can say so, and it outranks
|
|
15
|
+
* anything the field list implies.
|
|
16
|
+
* 2. attribute_search_unsupported: the registry marks the layer
|
|
17
|
+
* `attributeSearch: "unsupported"`. It cannot answer a `where` on any
|
|
18
|
+
* column. In atlas 0.6.9 all 67 such endpoints are Florida's FDOR
|
|
19
|
+
* statewide layer. Owner and address values still come back from
|
|
20
|
+
* location queries, so the reason says that instead of "no owner".
|
|
21
|
+
* 3. owner_unsearchable: an owner-name column exists but the county marks
|
|
22
|
+
* every such column `searchable: false`. A query on it hangs or errors
|
|
23
|
+
* (Orleans Parish OWNERNME1, where a column scan takes ~44 s). The owner
|
|
24
|
+
* is still returned by a parcel-id or location query.
|
|
25
|
+
* 4. offered: the first owner-name column that is searchable (the column
|
|
26
|
+
* test is the UKS classifier's; see OWNER_RE below).
|
|
27
|
+
* 5. no_owner_column: the layer documents no owner column at all.
|
|
28
|
+
*
|
|
29
|
+
* Separately from the branch, `scopeWhere` is returned whenever the registry
|
|
30
|
+
* carries one. A shared statewide or regional layer holds every county's rows
|
|
31
|
+
* (FDOR `CO_NO=23` is Miami-Dade), so an unscoped query returns the whole
|
|
32
|
+
* state. ownerWhereClause ANDs the scope in front of every owner match.
|
|
33
|
+
*/
|
|
34
|
+
import type { CountyRecord, EndpointRecord } from "@urbankitstudio/atlas";
|
|
35
|
+
/**
|
|
36
|
+
* The endpoint fields this policy reads, as @urbankitstudio/atlas declares them
|
|
37
|
+
* (`scopeWhere` and `attributeSearch` since 0.6.11; before that this file
|
|
38
|
+
* carried a local copy of the two).
|
|
39
|
+
*/
|
|
40
|
+
export type EndpointSearchPolicyFields = Pick<EndpointRecord, "searchFields" | "scopeWhere" | "attributeSearch">;
|
|
41
|
+
type CountyPolicyFields = Pick<CountyRecord, "capabilityOverrides">;
|
|
42
|
+
export type OwnerSearchKind = "offered" | "reviewed_unservable" | "attribute_search_unsupported" | "owner_unsearchable" | "no_owner_column";
|
|
43
|
+
export interface OwnerSearchPolicy {
|
|
44
|
+
kind: OwnerSearchKind;
|
|
45
|
+
/** The owner column to query. Set only when kind is "offered". */
|
|
46
|
+
field: string | null;
|
|
47
|
+
/** Why no owner query is offered. Null when offered or when there is simply no owner column. */
|
|
48
|
+
reason: string | null;
|
|
49
|
+
/** SQL predicate scoping a shared layer to this county, or null. */
|
|
50
|
+
scopeWhere: string | null;
|
|
51
|
+
}
|
|
52
|
+
export declare const ATTRIBUTE_SEARCH_UNSUPPORTED_REASON = "this layer does not serve attribute searches; owner and address fields are answered only by location queries";
|
|
53
|
+
export declare const OWNER_UNSEARCHABLE_REASON = "the owner column exists but the county marks it unsearchable";
|
|
54
|
+
export declare function ownerSearchPolicy(county: CountyPolicyFields, endpoint: EndpointSearchPolicyFields): OwnerSearchPolicy;
|
|
55
|
+
/**
|
|
56
|
+
* An ArcGIS `where` is SQL, and a single quote closes the string literal.
|
|
57
|
+
* encodeURIComponent does not help: `'` and `)` are both in its unreserved set, so
|
|
58
|
+
* `A') OR 1=1 --` passes through encoding intact and lands OUTSIDE the quotes as a
|
|
59
|
+
* live predicate against a county's server. Doubling the quote is the SQL-standard
|
|
60
|
+
* escape and keeps the whole input inside the literal where it belongs.
|
|
61
|
+
*
|
|
62
|
+
* Every caller that puts user text in a `where` goes through this, including the
|
|
63
|
+
* clause rendered for a human to copy: escaping only the URL would leave the same
|
|
64
|
+
* payload one paste away from running.
|
|
65
|
+
*/
|
|
66
|
+
export declare function escapeSqlLiteral(value: string): string;
|
|
67
|
+
/**
|
|
68
|
+
* The raw SQL owner match, scoped when the layer is shared. Both the query URL
|
|
69
|
+
* (which encodes this string) and the clause printed for a human come from
|
|
70
|
+
* here, so the two can never disagree about the scope or the escaping. The
|
|
71
|
+
* scope is curated registry text, not user input, so it is not escaped.
|
|
72
|
+
*/
|
|
73
|
+
export declare function ownerWhereClause(field: string, scopeWhere: string | null, ownerName: string): string;
|
|
74
|
+
/**
|
|
75
|
+
* The list_counties coverage label, summarised across a county's endpoints.
|
|
76
|
+
* What each label promises a caller deciding whether to spend a request:
|
|
77
|
+
* "owner+APN" build_owner_query returns a URL for at least one endpoint.
|
|
78
|
+
* "APN only (county publishes no owner name)"
|
|
79
|
+
* a reviewed record says owner names are not served.
|
|
80
|
+
* "location queries only"
|
|
81
|
+
* no endpoint answers attribute searches; parcels are
|
|
82
|
+
* reached by geometry, which also returns owner fields.
|
|
83
|
+
* "APN only (owner column not searchable)"
|
|
84
|
+
* an owner column exists but the county marks it unsearchable.
|
|
85
|
+
* "APN only" no endpoint documents an owner column.
|
|
86
|
+
*/
|
|
87
|
+
export declare function ownerCoverageLabel(county: CountyPolicyFields & {
|
|
88
|
+
endpoints: readonly EndpointSearchPolicyFields[];
|
|
89
|
+
}): string;
|
|
90
|
+
export {};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner-search policy: the ONE rule for whether this server offers an
|
|
3
|
+
* owner-name query on a county endpoint, and how that query is scoped.
|
|
4
|
+
*
|
|
5
|
+
* Every tool (list_counties, find_county, get_parcel_endpoint,
|
|
6
|
+
* build_owner_query) asks this module and nothing else, so no county can be
|
|
7
|
+
* offered by one tool and refused by another. Written 2026-10-03 on Leo's
|
|
8
|
+
* directive to surface as much of the county data as the registry knows,
|
|
9
|
+
* with clean, commented SOLID/DRY/KISS code.
|
|
10
|
+
*
|
|
11
|
+
* Decision order, first match wins:
|
|
12
|
+
* 1. reviewed_unservable: a human-reviewed capability record says the owner
|
|
13
|
+
* name cannot be served. New Jersey's statewide layer publishes OWNER_NAME
|
|
14
|
+
* blank on 3.4M rows; only the reviewed record can say so, and it outranks
|
|
15
|
+
* anything the field list implies.
|
|
16
|
+
* 2. attribute_search_unsupported: the registry marks the layer
|
|
17
|
+
* `attributeSearch: "unsupported"`. It cannot answer a `where` on any
|
|
18
|
+
* column. In atlas 0.6.9 all 67 such endpoints are Florida's FDOR
|
|
19
|
+
* statewide layer. Owner and address values still come back from
|
|
20
|
+
* location queries, so the reason says that instead of "no owner".
|
|
21
|
+
* 3. owner_unsearchable: an owner-name column exists but the county marks
|
|
22
|
+
* every such column `searchable: false`. A query on it hangs or errors
|
|
23
|
+
* (Orleans Parish OWNERNME1, where a column scan takes ~44 s). The owner
|
|
24
|
+
* is still returned by a parcel-id or location query.
|
|
25
|
+
* 4. offered: the first owner-name column that is searchable (the column
|
|
26
|
+
* test is the UKS classifier's; see OWNER_RE below).
|
|
27
|
+
* 5. no_owner_column: the layer documents no owner column at all.
|
|
28
|
+
*
|
|
29
|
+
* Separately from the branch, `scopeWhere` is returned whenever the registry
|
|
30
|
+
* carries one. A shared statewide or regional layer holds every county's rows
|
|
31
|
+
* (FDOR `CO_NO=23` is Miami-Dade), so an unscoped query returns the whole
|
|
32
|
+
* state. ownerWhereClause ANDs the scope in front of every owner match.
|
|
33
|
+
*/
|
|
34
|
+
import { isReviewedUnservable, reviewedCapability } from "@urbankitstudio/atlas";
|
|
35
|
+
// THE OWNER-COLUMN TEST, copied VERBATIM from the UKS canonical classifier:
|
|
36
|
+
// urbankitstudio/src/lib/enrich-core.ts lines 48 (OWNER_RE) and 58
|
|
37
|
+
// (OWNER_EXCLUDE), applied there by firstMatch to `${name} ${label}`. The two
|
|
38
|
+
// copies must stay byte-equal until @urbankitstudio/atlas exports the
|
|
39
|
+
// classifier (a queued UKS follow-up); then import it and delete these lines.
|
|
40
|
+
// Testing the label as well as the name is what makes Mahoning's
|
|
41
|
+
// "OWNNAME1 / Owner Name 1" an owner column. The exclude keeps owner ADDRESS
|
|
42
|
+
// and locale columns (OWNER_ADDR, OWNERCITY) from being offered as a name.
|
|
43
|
+
const OWNER_RE = /owner|taxpayer|tax.?name|grantor|\bown\b|ownnme|ownernme/i;
|
|
44
|
+
const OWNER_EXCLUDE = /addr|address|\bcity\b|\bstate\b|\bzip\b/i;
|
|
45
|
+
function isOwnerNameColumn(sf) {
|
|
46
|
+
const hay = `${sf.name} ${sf.label}`;
|
|
47
|
+
return !OWNER_EXCLUDE.test(hay) && OWNER_RE.test(hay);
|
|
48
|
+
}
|
|
49
|
+
export const ATTRIBUTE_SEARCH_UNSUPPORTED_REASON = "this layer does not serve attribute searches; owner and address fields are answered only by location queries";
|
|
50
|
+
export const OWNER_UNSEARCHABLE_REASON = "the owner column exists but the county marks it unsearchable";
|
|
51
|
+
export function ownerSearchPolicy(county, endpoint) {
|
|
52
|
+
const scopeWhere = endpoint.scopeWhere?.trim() || null;
|
|
53
|
+
const refuse = (kind, reason) => ({
|
|
54
|
+
kind,
|
|
55
|
+
field: null,
|
|
56
|
+
reason,
|
|
57
|
+
scopeWhere,
|
|
58
|
+
});
|
|
59
|
+
if (isReviewedUnservable(county, "owner_name")) {
|
|
60
|
+
return refuse("reviewed_unservable", reviewedCapability(county, "owner_name")?.basis?.note ??
|
|
61
|
+
"this county publishes no usable owner name on its public endpoint");
|
|
62
|
+
}
|
|
63
|
+
if (endpoint.attributeSearch === "unsupported") {
|
|
64
|
+
return refuse("attribute_search_unsupported", ATTRIBUTE_SEARCH_UNSUPPORTED_REASON);
|
|
65
|
+
}
|
|
66
|
+
const named = endpoint.searchFields.filter(isOwnerNameColumn);
|
|
67
|
+
const usable = named.find((sf) => sf.searchable !== false);
|
|
68
|
+
if (usable)
|
|
69
|
+
return { kind: "offered", field: usable.name, reason: null, scopeWhere };
|
|
70
|
+
if (named.length > 0)
|
|
71
|
+
return refuse("owner_unsearchable", OWNER_UNSEARCHABLE_REASON);
|
|
72
|
+
return refuse("no_owner_column", null);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* An ArcGIS `where` is SQL, and a single quote closes the string literal.
|
|
76
|
+
* encodeURIComponent does not help: `'` and `)` are both in its unreserved set, so
|
|
77
|
+
* `A') OR 1=1 --` passes through encoding intact and lands OUTSIDE the quotes as a
|
|
78
|
+
* live predicate against a county's server. Doubling the quote is the SQL-standard
|
|
79
|
+
* escape and keeps the whole input inside the literal where it belongs.
|
|
80
|
+
*
|
|
81
|
+
* Every caller that puts user text in a `where` goes through this, including the
|
|
82
|
+
* clause rendered for a human to copy: escaping only the URL would leave the same
|
|
83
|
+
* payload one paste away from running.
|
|
84
|
+
*/
|
|
85
|
+
export function escapeSqlLiteral(value) {
|
|
86
|
+
return value.replace(/'/g, "''");
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The raw SQL owner match, scoped when the layer is shared. Both the query URL
|
|
90
|
+
* (which encodes this string) and the clause printed for a human come from
|
|
91
|
+
* here, so the two can never disagree about the scope or the escaping. The
|
|
92
|
+
* scope is curated registry text, not user input, so it is not escaped.
|
|
93
|
+
*/
|
|
94
|
+
export function ownerWhereClause(field, scopeWhere, ownerName) {
|
|
95
|
+
const match = `UPPER(${field}) LIKE UPPER('%${escapeSqlLiteral(ownerName)}%')`;
|
|
96
|
+
return scopeWhere ? `(${scopeWhere}) AND ${match}` : match;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The list_counties coverage label, summarised across a county's endpoints.
|
|
100
|
+
* What each label promises a caller deciding whether to spend a request:
|
|
101
|
+
* "owner+APN" build_owner_query returns a URL for at least one endpoint.
|
|
102
|
+
* "APN only (county publishes no owner name)"
|
|
103
|
+
* a reviewed record says owner names are not served.
|
|
104
|
+
* "location queries only"
|
|
105
|
+
* no endpoint answers attribute searches; parcels are
|
|
106
|
+
* reached by geometry, which also returns owner fields.
|
|
107
|
+
* "APN only (owner column not searchable)"
|
|
108
|
+
* an owner column exists but the county marks it unsearchable.
|
|
109
|
+
* "APN only" no endpoint documents an owner column.
|
|
110
|
+
*/
|
|
111
|
+
export function ownerCoverageLabel(county) {
|
|
112
|
+
const kinds = county.endpoints.map((ep) => ownerSearchPolicy(county, ep).kind);
|
|
113
|
+
if (kinds.includes("offered"))
|
|
114
|
+
return "owner+APN";
|
|
115
|
+
if (kinds.includes("reviewed_unservable"))
|
|
116
|
+
return "APN only (county publishes no owner name)";
|
|
117
|
+
if (kinds.length > 0 && kinds.every((k) => k === "attribute_search_unsupported")) {
|
|
118
|
+
return "location queries only";
|
|
119
|
+
}
|
|
120
|
+
if (kinds.includes("owner_unsearchable"))
|
|
121
|
+
return "APN only (owner column not searchable)";
|
|
122
|
+
return "APN only";
|
|
123
|
+
}
|
package/dist/server.d.ts
CHANGED
|
@@ -11,15 +11,5 @@
|
|
|
11
11
|
* get_parcel_endpoint – return the full REST service URL + ready sample query
|
|
12
12
|
* build_owner_query – construct exact ArcGIS REST query for an owner name
|
|
13
13
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
* encodeURIComponent does not help: `'` and `)` are both in its unreserved set, so
|
|
17
|
-
* `A') OR 1=1 --` passes through encoding intact and lands OUTSIDE the quotes as a
|
|
18
|
-
* live predicate against a county's server. Doubling the quote is the SQL-standard
|
|
19
|
-
* escape and keeps the whole input inside the literal where it belongs.
|
|
20
|
-
*
|
|
21
|
-
* Every caller that puts user text in a `where` goes through this, including the
|
|
22
|
-
* clause rendered for a human to copy: escaping only the URL would leave the same
|
|
23
|
-
* payload one paste away from running.
|
|
24
|
-
*/
|
|
25
|
-
export declare function escapeSqlLiteral(value: string): string;
|
|
14
|
+
import { escapeSqlLiteral } from "./search-policy.js";
|
|
15
|
+
export { escapeSqlLiteral };
|
package/dist/server.js
CHANGED
|
@@ -17,57 +17,42 @@ import { z } from "zod/v3";
|
|
|
17
17
|
import { readFileSync } from "node:fs";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { resolve, dirname } from "node:path";
|
|
20
|
-
import { atlas, atlasIndex, slugify, countySlugFromName,
|
|
20
|
+
import { atlas, atlasIndex, slugify, countySlugFromName, } from "@urbankitstudio/atlas";
|
|
21
|
+
import { ownerSearchPolicy, ownerWhereClause, ownerCoverageLabel, escapeSqlLiteral, } from "./search-policy.js";
|
|
22
|
+
// Re-exported so the package's public surface is unchanged by the move.
|
|
23
|
+
export { escapeSqlLiteral };
|
|
21
24
|
const PKG_VERSION = JSON.parse(readFileSync(resolve(dirname(fileURLToPath(import.meta.url)), "../package.json"), "utf8")).version;
|
|
22
25
|
// ---------------------------------------------------------------------------
|
|
23
26
|
// Helpers
|
|
24
27
|
// ---------------------------------------------------------------------------
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
*/
|
|
36
|
-
function ownerFieldFor(county, endpoint) {
|
|
37
|
-
if (isReviewedUnservable(county, "owner_name")) {
|
|
38
|
-
const reviewed = reviewedCapability(county, "owner_name");
|
|
39
|
-
return {
|
|
40
|
-
field: null,
|
|
41
|
-
unavailableReason: reviewed?.basis?.note ??
|
|
42
|
-
"this county publishes no usable owner name on its public endpoint",
|
|
43
|
-
};
|
|
28
|
+
// Whether an owner query is offered, and how it is scoped, is decided ONLY in
|
|
29
|
+
// ./search-policy.ts. Matching the column name is not enough, and that gap cost
|
|
30
|
+
// a real trial user: they paid for owner data, ran Los Angeles County, got
|
|
31
|
+
// nothing back, and the atlas had said the field was there the whole time.
|
|
32
|
+
/** The Owner/Attribute line shared by find_county and get_parcel_endpoint. */
|
|
33
|
+
function ownerLine(owner) {
|
|
34
|
+
if (owner.field)
|
|
35
|
+
return `Owner field: ${owner.field}`;
|
|
36
|
+
if (owner.kind === "attribute_search_unsupported") {
|
|
37
|
+
return `Attribute search: not offered (location queries only) - ${owner.reason}`;
|
|
44
38
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
39
|
+
if (owner.kind === "owner_unsearchable") {
|
|
40
|
+
return "Owner field: not searchable by name (column exists; use a parcel-id or location query)";
|
|
41
|
+
}
|
|
42
|
+
return owner.reason
|
|
43
|
+
? `Owner field: NOT AVAILABLE - ${owner.reason}`
|
|
44
|
+
: "Owner field: NONE - this layer publishes no owner column";
|
|
51
45
|
}
|
|
52
|
-
/**
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
* escape and keeps the whole input inside the literal where it belongs.
|
|
58
|
-
*
|
|
59
|
-
* Every caller that puts user text in a `where` goes through this, including the
|
|
60
|
-
* clause rendered for a human to copy: escaping only the URL would leave the same
|
|
61
|
-
* payload one paste away from running.
|
|
62
|
-
*/
|
|
63
|
-
export function escapeSqlLiteral(value) {
|
|
64
|
-
return value.replace(/'/g, "''");
|
|
46
|
+
/** A shared layer's county predicate, printed so a caller ANDs it into any query of their own. */
|
|
47
|
+
function scopeLine(owner) {
|
|
48
|
+
return owner.scopeWhere
|
|
49
|
+
? `Scope: ${owner.scopeWhere} (shared layer; AND this into every query)`
|
|
50
|
+
: null;
|
|
65
51
|
}
|
|
66
|
-
function buildArcgisOwnerQuery(
|
|
67
|
-
|
|
68
|
-
if (!field)
|
|
52
|
+
function buildArcgisOwnerQuery(endpoint, owner, ownerQuery) {
|
|
53
|
+
if (!owner.field)
|
|
69
54
|
return "";
|
|
70
|
-
const where =
|
|
55
|
+
const where = encodeURIComponent(ownerWhereClause(owner.field, owner.scopeWhere, ownerQuery));
|
|
71
56
|
const liveFields = endpoint.searchFields
|
|
72
57
|
.filter((sf) => sf.searchable)
|
|
73
58
|
.map((sf) => sf.name)
|
|
@@ -84,20 +69,19 @@ function formatCountySummary(c) {
|
|
|
84
69
|
? "no REST endpoint mapped"
|
|
85
70
|
: c.endpoints
|
|
86
71
|
.map((ep) => {
|
|
87
|
-
const owner =
|
|
72
|
+
const owner = ownerSearchPolicy(c, ep);
|
|
88
73
|
const searchable = ep.searchFields
|
|
89
74
|
.filter((sf) => sf.searchable)
|
|
90
75
|
.map((sf) => `${sf.name} (${sf.label})`)
|
|
91
76
|
.join(", ");
|
|
77
|
+
const scope = scopeLine(owner);
|
|
92
78
|
return [
|
|
93
79
|
` URL: ${ep.url}`,
|
|
94
80
|
` Service: ${ep.serviceType}/layer ${ep.layerIndex}`,
|
|
95
81
|
` Status: ${ep.status} (verified ${ep.lastVerified})`,
|
|
82
|
+
...(scope ? [` ${scope}`] : []),
|
|
96
83
|
` Searchable fields: ${searchable || "none"}`,
|
|
97
|
-
`
|
|
98
|
-
(owner.unavailableReason
|
|
99
|
-
? `NOT AVAILABLE - ${owner.unavailableReason}`
|
|
100
|
-
: "none (this layer publishes no owner column)")}`,
|
|
84
|
+
` ${ownerLine(owner)}`,
|
|
101
85
|
` License: ${ep.license}`,
|
|
102
86
|
].join("\n");
|
|
103
87
|
})
|
|
@@ -153,17 +137,10 @@ server.registerTool("list_counties", {
|
|
|
153
137
|
continue;
|
|
154
138
|
const covered = stateFile.counties.filter((c) => c.endpoints.length > 0);
|
|
155
139
|
for (const c of covered) {
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
|
|
160
|
-
const anyOwner = c.endpoints.some((ep) => ownerFieldFor(c, ep).field);
|
|
161
|
-
const ownerWithheld = c.endpoints.some((ep) => ownerFieldFor(c, ep).unavailableReason);
|
|
162
|
-
const ownerCoverage = anyOwner
|
|
163
|
-
? "owner+APN"
|
|
164
|
-
: ownerWithheld
|
|
165
|
-
? "APN only (county publishes no owner name)"
|
|
166
|
-
: "APN only";
|
|
140
|
+
// A caller scanning this column is deciding whether to spend a request.
|
|
141
|
+
// ownerCoverageLabel documents what each label promises; "owner+APN"
|
|
142
|
+
// means build_owner_query will actually return a URL.
|
|
143
|
+
const ownerCoverage = ownerCoverageLabel(c);
|
|
167
144
|
rows.push(`${c.state} | ${c.county.padEnd(20)} | ${c.countySlug.padEnd(24)} | ${ownerCoverage}`);
|
|
168
145
|
}
|
|
169
146
|
}
|
|
@@ -272,7 +249,7 @@ server.registerTool("find_county", {
|
|
|
272
249
|
// ---------------------------------------------------------------------------
|
|
273
250
|
server.registerTool("get_parcel_endpoint", {
|
|
274
251
|
title: "Get one county's parcel endpoint record",
|
|
275
|
-
description: "The default lookup once the county is known. Takes an exact state and county and returns that county's ArcGIS REST service URL, layer index, searchable field names, verified owner/taxpayer field, a generic sample ?where=…&f=json query, and the UrbanKit deep-link. If the county name is uncertain, misspelled, or you hold only a FIPS code, call find_county first. To search for a named person or company, call build_owner_query rather than editing the sample query by hand.",
|
|
252
|
+
description: "The default lookup once the county is known. Takes an exact state and county and returns that county's ArcGIS REST service URL, layer index, searchable field names, verified owner/taxpayer field (or why no owner query is offered), the county's Scope predicate when the layer is shared statewide or regionally (AND it into any query of your own), a generic sample ?where=…&f=json query, and the UrbanKit deep-link. If the county name is uncertain, misspelled, or you hold only a FIPS code, call find_county first. To search for a named person or company, call build_owner_query rather than editing the sample query by hand.",
|
|
276
253
|
inputSchema: {
|
|
277
254
|
state: z
|
|
278
255
|
.string()
|
|
@@ -328,11 +305,12 @@ server.registerTool("get_parcel_endpoint", {
|
|
|
328
305
|
"",
|
|
329
306
|
];
|
|
330
307
|
countyRecord.endpoints.forEach((ep, i) => {
|
|
331
|
-
const owner =
|
|
308
|
+
const owner = ownerSearchPolicy(countyRecord, ep);
|
|
332
309
|
const ownerField = owner.field;
|
|
333
310
|
const sampleOwnerUrl = ownerField
|
|
334
|
-
? buildArcgisOwnerQuery(
|
|
311
|
+
? buildArcgisOwnerQuery(ep, owner, "SMITH")
|
|
335
312
|
: null;
|
|
313
|
+
const scope = scopeLine(owner);
|
|
336
314
|
lines.push(`Endpoint ${i + 1}:`);
|
|
337
315
|
lines.push(` URL: ${ep.url}`);
|
|
338
316
|
lines.push(` Service: ${ep.serviceType}`);
|
|
@@ -341,16 +319,15 @@ server.registerTool("get_parcel_endpoint", {
|
|
|
341
319
|
lines.push(` Status: ${ep.status} (verified ${ep.lastVerified})`);
|
|
342
320
|
lines.push(` CORS: ${ep.corsEnabled === null ? "unknown" : ep.corsEnabled}`);
|
|
343
321
|
lines.push(` License: ${ep.license}${ep.licenseUrl ? ` (${ep.licenseUrl})` : ""}`);
|
|
322
|
+
if (scope)
|
|
323
|
+
lines.push(` ${scope}`);
|
|
344
324
|
lines.push("");
|
|
345
325
|
lines.push(" Searchable fields:");
|
|
346
326
|
ep.searchFields
|
|
347
327
|
.filter((sf) => sf.searchable)
|
|
348
328
|
.forEach((sf) => lines.push(` ${sf.name.padEnd(20)} – ${sf.label}`));
|
|
349
329
|
lines.push("");
|
|
350
|
-
lines.push(`
|
|
351
|
-
(owner.unavailableReason
|
|
352
|
-
? `NOT AVAILABLE - ${owner.unavailableReason}`
|
|
353
|
-
: "NONE - this layer publishes no owner column")}`);
|
|
330
|
+
lines.push(` ${ownerLine(owner)}`);
|
|
354
331
|
if (ep.sampleQuery) {
|
|
355
332
|
lines.push("");
|
|
356
333
|
lines.push(" Sample query (from atlas):");
|
|
@@ -375,7 +352,7 @@ server.registerTool("get_parcel_endpoint", {
|
|
|
375
352
|
// ---------------------------------------------------------------------------
|
|
376
353
|
server.registerTool("build_owner_query", {
|
|
377
354
|
title: "Build an owner-name search URL for one county",
|
|
378
|
-
description: "The only tool that searches for a named owner. Fills a person or company name into that county's verified owner/taxpayer field as UPPER(field) LIKE UPPER('%NAME%'), a case-insensitive partial match, and returns a URL you can fetch or open in a browser. get_parcel_endpoint returns the endpoint and a generic sample query, not a name search, so come here for the name. This server does not execute the query and returns no parcel records: fetch the returned URL yourself.
|
|
355
|
+
description: "The only tool that searches for a named owner. Fills a person or company name into that county's verified owner/taxpayer field as UPPER(field) LIKE UPPER('%NAME%'), a case-insensitive partial match, and returns a URL you can fetch or open in a browser. On a shared statewide or regional layer the county's scope predicate is ANDed in front, as (scope) AND UPPER(field) LIKE …, so only that county's rows come back. get_parcel_endpoint returns the endpoint and a generic sample query, not a name search, so come here for the name. This server does not execute the query and returns no parcel records: fetch the returned URL yourself. An endpoint is refused, with the reason, when a reviewed record says the county publishes no owner name, when the layer serves no attribute search (query it by location instead), or when its owner column is not searchable by name (use a parcel-id or location query instead).",
|
|
379
356
|
inputSchema: {
|
|
380
357
|
state: z
|
|
381
358
|
.string()
|
|
@@ -419,22 +396,38 @@ server.registerTool("build_owner_query", {
|
|
|
419
396
|
}
|
|
420
397
|
const results = [];
|
|
421
398
|
for (const ep of countyRecord.endpoints) {
|
|
422
|
-
const owner =
|
|
399
|
+
const owner = ownerSearchPolicy(countyRecord, ep);
|
|
423
400
|
const ownerField = owner.field;
|
|
424
401
|
if (!ownerField) {
|
|
425
402
|
// Refusing with the reason beats handing back a query that returns zero
|
|
426
|
-
// rows forever. The caller can then choose a
|
|
427
|
-
// different
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
403
|
+
// rows forever, or one that hangs. The caller can then choose a
|
|
404
|
+
// different county or query instead of concluding the owner is absent.
|
|
405
|
+
const place = `${countyRecord.county}, ${countyRecord.stateName}`;
|
|
406
|
+
let refusal;
|
|
407
|
+
switch (owner.kind) {
|
|
408
|
+
case "attribute_search_unsupported":
|
|
409
|
+
refusal = `OWNER SEARCH NOT OFFERED on this layer for ${place}: ${owner.reason}\nNo where-clause query is possible here. Query the layer by location (a point or envelope geometry) to read the owner fields of the parcels there.`;
|
|
410
|
+
break;
|
|
411
|
+
case "owner_unsearchable":
|
|
412
|
+
// The owner IS published; only a name search on it is unsupported.
|
|
413
|
+
refusal = `OWNER COLUMN NOT SEARCHABLE BY NAME for ${place}: ${owner.reason}\nUse a parcel-id or location query instead; either returns the owner of the matching parcel.`;
|
|
414
|
+
break;
|
|
415
|
+
case "reviewed_unservable":
|
|
416
|
+
refusal = `OWNER NAME NOT AVAILABLE for ${place}: ${owner.reason}\nNo owner query is possible here. Search by parcel number or address instead, or pick a county whose coverage reads owner+APN in list_counties.`;
|
|
417
|
+
break;
|
|
418
|
+
default:
|
|
419
|
+
refusal = "Note: this layer publishes no owner or taxpayer column - PIN-only lookup. Try searching by parcel number instead.";
|
|
420
|
+
}
|
|
421
|
+
results.push(`Endpoint: ${ep.url}\n${refusal}`);
|
|
431
422
|
continue;
|
|
432
423
|
}
|
|
433
|
-
const queryUrl = buildArcgisOwnerQuery(
|
|
434
|
-
const where =
|
|
424
|
+
const queryUrl = buildArcgisOwnerQuery(ep, owner, owner_name);
|
|
425
|
+
const where = ownerWhereClause(ownerField, owner.scopeWhere, owner_name);
|
|
426
|
+
const scope = scopeLine(owner);
|
|
435
427
|
results.push([
|
|
436
428
|
`County: ${countyRecord.county}, ${countyRecord.stateName}`,
|
|
437
429
|
`Owner field: ${ownerField}`,
|
|
430
|
+
...(scope ? [scope] : []),
|
|
438
431
|
`WHERE clause: ${where}`,
|
|
439
432
|
``,
|
|
440
433
|
`Query URL:`,
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@urbankitstudio/mcp-atlas",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.8",
|
|
4
4
|
"mcpName": "io.github.LEOyrh/mcp-atlas",
|
|
5
|
-
"description": "Query the verified parcel ArcGIS REST endpoints of
|
|
5
|
+
"description": "Query the verified parcel ArcGIS REST endpoints of 246 counties across 51 US states (50 states plus DC), 258 endpoints total, for owner, APN and address lookup via the Model Context Protocol (MCP).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"mcp",
|
|
8
8
|
"mcp-server",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"bugs": {
|
|
25
25
|
"url": "https://github.com/urbankitstudio/mcp-atlas/issues"
|
|
26
26
|
},
|
|
27
|
-
"author": "UrbanKit Studio <urbankitstudio
|
|
27
|
+
"author": "UrbanKit Studio <contact@urbankitstudio.com>",
|
|
28
28
|
"license": "MIT",
|
|
29
29
|
"type": "module",
|
|
30
30
|
"main": "./dist/server.js",
|
|
@@ -43,11 +43,12 @@
|
|
|
43
43
|
"typecheck": "tsc --noEmit",
|
|
44
44
|
"smoke": "node test/smoke.mjs",
|
|
45
45
|
"test:currency": "node --test test/atlas-currency.test.mjs",
|
|
46
|
-
"
|
|
46
|
+
"test:policy": "node --test test/search-policy.test.mjs",
|
|
47
|
+
"prepublishOnly": "npm run typecheck && npm run build && npm run smoke && npm run test:policy"
|
|
47
48
|
},
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
50
|
-
"@urbankitstudio/atlas": "^0.6.
|
|
51
|
+
"@urbankitstudio/atlas": "^0.6.14"
|
|
51
52
|
},
|
|
52
53
|
"devDependencies": {
|
|
53
54
|
"@types/node": "^26.0.0",
|