@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 CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  [![mcp-atlas MCP server](https://glama.ai/mcp/servers/LEOyrh/mcp-atlas/badges/card.svg)](https://glama.ai/mcp/servers/LEOyrh/mcp-atlas)
6
6
 
7
- Query the verified parcel ArcGIS REST endpoints of 235 counties across 51 US states (50 states plus DC), 247 endpoints total, for owner, APN and address lookup via the Model Context Protocol (MCP).
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:** 235 counties across 51 US states (50 states plus DC), 247 verified endpoints (atlas 0.6.9).
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, a generic sample `?where=…&f=json`
121
- query and the UrbanKit deep-link for one county. For a named owner, use
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. A county that publishes no owner name is
139
- refused here, with the reason, rather than handed a query that finds nothing.
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
- * An ArcGIS `where` is SQL, and a single quote closes the string literal.
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, isReviewedUnservable, reviewedCapability, } from "@urbankitstudio/atlas";
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
- * The owner column a caller can actually USE, or null with a reason.
27
- *
28
- * Matching the column name is not enough, and that gap cost a real trial user:
29
- * they paid for owner data, ran Los Angeles County, got nothing back, and the
30
- * atlas had said the field was there the whole time. Fourteen counties in the
31
- * registry document an owner column that is present and empty on every row -
32
- * New Jersey's statewide layer publishes OWNER_NAME blank across 3,481,240
33
- * rows, New York's across 3,827,530 - and a reviewed capability record says so.
34
- * Consult it BEFORE promising the field.
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
- const f = endpoint.searchFields.find((sf) => /owner|taxpayer|taxname/i.test(sf.name));
46
- return { field: f?.name ?? null, unavailableReason: null };
47
- }
48
- /** Back-compat shim for call sites that only need the column. */
49
- function ownerFieldFrom(county, endpoint) {
50
- return ownerFieldFor(county, endpoint).field;
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
- * An ArcGIS `where` is SQL, and a single quote closes the string literal.
54
- * encodeURIComponent does not help: `'` and `)` are both in its unreserved set, so
55
- * `A') OR 1=1 --` passes through encoding intact and lands OUTSIDE the quotes as a
56
- * live predicate against a county's server. Doubling the quote is the SQL-standard
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(county, endpoint, ownerQuery) {
67
- const field = ownerFieldFrom(county, endpoint);
68
- if (!field)
52
+ function buildArcgisOwnerQuery(endpoint, owner, ownerQuery) {
53
+ if (!owner.field)
69
54
  return "";
70
- const where = `UPPER(${field}) LIKE UPPER('%25${encodeURIComponent(escapeSqlLiteral(ownerQuery))}%25')`;
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 = ownerFieldFor(c, ep);
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
- ` Owner field: ${owner.field ??
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
- // "APN only" is not the same claim as "owner+APN minus the owner".
157
- // A caller scanning this column is deciding whether to spend a request,
158
- // so a county whose owner column exists and is empty must not read as
159
- // though owners are simply absent from the schema.
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 = ownerFieldFor(countyRecord, ep);
308
+ const owner = ownerSearchPolicy(countyRecord, ep);
332
309
  const ownerField = owner.field;
333
310
  const sampleOwnerUrl = ownerField
334
- ? buildArcgisOwnerQuery(countyRecord, ep, "SMITH")
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(` Owner field: ${ownerField ??
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. Counties that publish no owner name are refused here with that reason.",
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 = ownerFieldFor(countyRecord, ep);
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 different county or a
427
- // different field instead of concluding the owner simply is not there.
428
- results.push(owner.unavailableReason
429
- ? `Endpoint: ${ep.url}\nOWNER NAME NOT AVAILABLE for ${countyRecord.county}, ${countyRecord.stateName}: ${owner.unavailableReason}\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.`
430
- : `Endpoint: ${ep.url}\nNote: this layer publishes no owner or taxpayer column - PIN-only lookup. Try searching by parcel number instead.`);
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(countyRecord, ep, owner_name);
434
- const where = `UPPER(${ownerField}) LIKE UPPER('%${escapeSqlLiteral(owner_name)}%')`;
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.6",
3
+ "version": "0.2.8",
4
4
  "mcpName": "io.github.LEOyrh/mcp-atlas",
5
- "description": "Query the verified parcel ArcGIS REST endpoints of 235 counties across 51 US states (50 states plus DC), 247 endpoints total, for owner, APN and address lookup via the Model Context Protocol (MCP).",
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@gmail.com>",
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
- "prepublishOnly": "npm run typecheck && npm run build && npm run smoke"
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.9"
51
+ "@urbankitstudio/atlas": "^0.6.14"
51
52
  },
52
53
  "devDependencies": {
53
54
  "@types/node": "^26.0.0",