@honua/mcp-server 0.1.2-beta.0 → 0.1.4-beta.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.
Files changed (77) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +202 -7
  3. package/dist/src/certification/census-fixture-client.d.ts.map +1 -1
  4. package/dist/src/certification/census-fixture-client.js +5 -0
  5. package/dist/src/certification/certifier.d.ts +1 -1
  6. package/dist/src/certification/certifier.d.ts.map +1 -1
  7. package/dist/src/certification/certifier.js +28 -20
  8. package/dist/src/certification/cli.js +1 -1
  9. package/dist/src/certification/cql2.d.ts +24 -0
  10. package/dist/src/certification/cql2.d.ts.map +1 -0
  11. package/dist/src/certification/cql2.js +354 -0
  12. package/dist/src/certification/fixtures.d.ts +8 -0
  13. package/dist/src/certification/fixtures.d.ts.map +1 -1
  14. package/dist/src/certification/fixtures.js +26 -10
  15. package/dist/src/certification/ogc-data.d.ts +30 -0
  16. package/dist/src/certification/ogc-data.d.ts.map +1 -0
  17. package/dist/src/certification/ogc-data.js +726 -0
  18. package/dist/src/certification/ogc-fixture-client.d.ts +4 -0
  19. package/dist/src/certification/ogc-fixture-client.d.ts.map +1 -0
  20. package/dist/src/certification/ogc-fixture-client.js +190 -0
  21. package/dist/src/certification/target.d.ts +10 -1
  22. package/dist/src/certification/target.d.ts.map +1 -1
  23. package/dist/src/certification/target.js +45 -2
  24. package/dist/src/eval/cli.js +6 -3
  25. package/dist/src/eval/corpus.d.ts +7 -0
  26. package/dist/src/eval/corpus.d.ts.map +1 -1
  27. package/dist/src/eval/corpus.js +12 -0
  28. package/dist/src/eval/ogc-corpus.d.ts +3 -0
  29. package/dist/src/eval/ogc-corpus.d.ts.map +1 -0
  30. package/dist/src/eval/ogc-corpus.js +437 -0
  31. package/dist/src/eval/runner.d.ts +6 -0
  32. package/dist/src/eval/runner.d.ts.map +1 -1
  33. package/dist/src/eval/runner.js +21 -7
  34. package/dist/src/helpers.d.ts +8 -13
  35. package/dist/src/helpers.d.ts.map +1 -1
  36. package/dist/src/helpers.js +7 -35
  37. package/dist/src/index.d.ts +14 -7
  38. package/dist/src/index.d.ts.map +1 -1
  39. package/dist/src/index.js +24 -15
  40. package/dist/src/neutral/errors.d.ts +73 -0
  41. package/dist/src/neutral/errors.d.ts.map +1 -0
  42. package/dist/src/neutral/errors.js +112 -0
  43. package/dist/src/neutral/filter.d.ts +108 -0
  44. package/dist/src/neutral/filter.d.ts.map +1 -0
  45. package/dist/src/neutral/filter.js +271 -0
  46. package/dist/src/neutral/query.d.ts +101 -0
  47. package/dist/src/neutral/query.d.ts.map +1 -0
  48. package/dist/src/neutral/query.js +180 -0
  49. package/dist/src/neutral/result.d.ts +33 -0
  50. package/dist/src/neutral/result.d.ts.map +1 -0
  51. package/dist/src/neutral/result.js +38 -0
  52. package/dist/src/neutral/source-ref.d.ts +126 -0
  53. package/dist/src/neutral/source-ref.d.ts.map +1 -0
  54. package/dist/src/neutral/source-ref.js +267 -0
  55. package/dist/src/nl-map-control.d.ts.map +1 -1
  56. package/dist/src/tools/count-features.d.ts +85 -11
  57. package/dist/src/tools/count-features.d.ts.map +1 -1
  58. package/dist/src/tools/count-features.js +49 -31
  59. package/dist/src/tools/describe-layer.d.ts +30 -6
  60. package/dist/src/tools/describe-layer.d.ts.map +1 -1
  61. package/dist/src/tools/describe-layer.js +99 -8
  62. package/dist/src/tools/get-extent.d.ts +69 -11
  63. package/dist/src/tools/get-extent.d.ts.map +1 -1
  64. package/dist/src/tools/get-extent.js +28 -46
  65. package/dist/src/tools/list-services.d.ts +7 -0
  66. package/dist/src/tools/list-services.d.ts.map +1 -1
  67. package/dist/src/tools/list-services.js +8 -1
  68. package/dist/src/tools/list-sources.d.ts +44 -0
  69. package/dist/src/tools/list-sources.d.ts.map +1 -0
  70. package/dist/src/tools/list-sources.js +138 -0
  71. package/dist/src/tools/query-features.d.ts +99 -19
  72. package/dist/src/tools/query-features.d.ts.map +1 -1
  73. package/dist/src/tools/query-features.js +38 -45
  74. package/dist/src/tools/statistics.d.ts +80 -12
  75. package/dist/src/tools/statistics.d.ts.map +1 -1
  76. package/dist/src/tools/statistics.js +56 -23
  77. package/package.json +31 -7
package/dist/src/index.js CHANGED
@@ -15,6 +15,7 @@ import * as explainCapabilityGap from "./tools/explain-capability-gap.js";
15
15
  import * as getExtent from "./tools/get-extent.js";
16
16
  import * as getStyle from "./tools/get-style.js";
17
17
  import * as listServices from "./tools/list-services.js";
18
+ import * as listSources from "./tools/list-sources.js";
18
19
  import * as queryFeatures from "./tools/query-features.js";
19
20
  import * as statistics from "./tools/statistics.js";
20
21
  export { createNlMapControlMcpHost, registerNlMapControlMcpTools, } from "./nl-map-control.js";
@@ -121,13 +122,20 @@ export function createClientFromEnv(env = process.env) {
121
122
  /**
122
123
  * The PLATFORM-FREE standalone MCP surface (issue #369).
123
124
  *
124
- * This is the direct-SDK operator surface: nine read-only geospatial tools wired
125
- * straight onto `@honua/sdk-js`, which speaks plain Esri GeoServices / OGC API
126
- * over standard REST paths (`/rest/services`, `/FeatureServer/{id}/query`, …).
127
- * Point it at ANY public FeatureServer / OGC endpoint (e.g. a `services.arcgis.com`
128
- * FeatureServer) with ZERO Honua-server assumptions — no admin API, no `/healthz`,
129
- * no `/mcp` catalog. This is what the `honua-mcp` bin runs (see {@link runStandalone}),
130
- * and what the platform-free certification lane + eval corpus exercise.
125
+ * This is the direct-SDK operator surface: ten read-only geospatial tools wired
126
+ * straight onto `@honua/sdk-js`. Point it at ANY public FeatureServer / OGC API /
127
+ * STAC / WFS / OData endpoint with ZERO Honua-server assumptions — no admin API,
128
+ * no `/healthz`, no `/mcp` catalog. This is what the `honua-mcp` bin runs (see
129
+ * {@link runStandalone}), and what the platform-free certification lanes + eval
130
+ * corpora exercise.
131
+ *
132
+ * The tool contract is PROTOCOL-NEUTRAL (issue #1005): sources are addressed by
133
+ * a `<protocol>:<address>` reference (`honua_list_sources` emits them), filters
134
+ * are the SDK's typed semantic filter, geometry is GeoJSON, and execution goes
135
+ * through the canonical `Dataset`/`Source`/`Query`/`Result` contract so one call
136
+ * means the same thing on every protocol. The GeoServices `serviceId`/`layerId`
137
+ * pair is still accepted as a deprecated compatibility input; no tool schema
138
+ * requires an Esri-only field.
131
139
  *
132
140
  * Tools that need a Honua-only surface (server-side styling via OGC API – Styles,
133
141
  * a `/rest/services` catalog) degrade gracefully on a plain target: they return a
@@ -145,23 +153,24 @@ export function createServer(client, options = {}) {
145
153
  version: SERVER_VERSION,
146
154
  });
147
155
  // ── Tools ──────────────────────────────────────────────────────
148
- server.tool("honua_list_services", "Discover all available feature services on any FeatureServer folder. Set includeDetails=true for descriptions, layer counts, and spatial references. Degrades gracefully when the target has no service catalog.", listServices.schema.shape, async (args) => listServices.execute(client, listServices.schema.parse(args)));
156
+ server.tool("honua_list_sources", 'Discover queryable sources on this endpoint, across protocols. Returns protocol-neutral `<protocol>:<address>` source references (e.g. "ogc-features:hotels", "geoservices-feature-service:Parks/0") to pass verbatim to the query tools, plus each source\'s capabilities. Each protocol family degrades independently with a reason.', listSources.schema.shape, async (args) => listSources.execute(client, listSources.schema.parse(args)));
157
+ server.tool("honua_list_services", "[GeoServices-specific; prefer honua_list_sources] Discover feature services on a FeatureServer folder. Set includeDetails=true for descriptions, layer counts, and spatial references. Degrades gracefully when the target has no service catalog.", listServices.schema.shape, async (args) => listServices.execute(client, listServices.schema.parse(args)));
149
158
  if (options.nlMapControl) {
150
159
  registerNlMapControlMcpTools(server, options.nlMapControl);
151
160
  }
152
- server.tool("honua_describe_layer", "Get full schema for a layer — fields, geometry type, extent, relationships.", describeLayer.schema.shape, async (args) => describeLayer.execute(client, describeLayer.schema.parse(args)));
153
- server.tool("honua_query_features", "Query features with attribute filters, spatial filters, field selection, and pagination. returnGeometry defaults to false to save tokens.", queryFeatures.schema.shape, async (args) => queryFeatures.execute(client, queryFeatures.schema.parse(args)));
161
+ server.tool("honua_describe_layer", "Describe a source: its protocol, the capabilities it actually supports, field schema, geometry type, and extent. Address it with `source` (from honua_list_sources); the serviceId/layerId pair is deprecated but still accepted.", describeLayer.schema.shape, async (args) => describeLayer.execute(client, describeLayer.schema.parse(args)));
162
+ server.tool("honua_query_features", "Query features from any supported protocol. Address the source with `source`; filter with the typed protocol-neutral `filter`, a GeoJSON `geometry` or `bbox` plus `spatialRel`, and `temporal`. returnGeometry defaults to false to save tokens. A construct the backing protocol cannot express returns a structured capability error, never a silently empty result.", queryFeatures.schema.shape, async (args) => queryFeatures.execute(client, queryFeatures.schema.parse(args)));
154
163
  server.tool("honua_count_features", "Count features matching a filter without returning data. Use before querying to check cardinality.", countFeatures.schema.shape, async (args) => countFeatures.execute(client, countFeatures.schema.parse(args)));
155
164
  server.tool("honua_get_extent", "Get the spatial bounding box of features matching a filter.", getExtent.schema.shape, async (args) => getExtent.execute(client, getExtent.schema.parse(args)));
156
- server.tool("honua_statistics", "Compute aggregate statistics (count, sum, avg, min, max, stddev) on a field, optionally grouped.", statistics.schema.shape, async (args) => statistics.execute(client, statistics.schema.parse(args)));
165
+ server.tool("honua_statistics", "Compute aggregate statistics (count, sum, avg, min, max, stddev, var) on a field, optionally grouped. Protocols without server-side aggregation answer client-side and report the degradation.", statistics.schema.shape, async (args) => statistics.execute(client, statistics.schema.parse(args)));
157
166
  server.tool("honua_explain_capability_gap", "Explain whether a source/protocol supports a capability and suggest a safe fallback when it does not.", explainCapabilityGap.schema.shape, async (args) => explainCapabilityGap.execute(client, explainCapabilityGap.schema.parse(args)));
158
167
  server.tool("honua_get_style", "Resolve a style from a server's OGC API – Styles surface. Pass styleId for the canonical StyleRef (style_id, title, encodings incl. inlined MapLibre); omit it to list available styles. On a plain FeatureServer with no styling surface, reports a structured 'not available on this target' result.", getStyle.schema.shape, async (args) => getStyle.execute(client, getStyle.schema.parse(args)));
159
168
  server.tool("honua_apply_style_preset", "Resolve a style preset and its MapLibre stylesheet for client-side application. Read-only: returns the stylesheet to apply locally; it does not mutate server state. Degrades gracefully on a target with no styling surface.", applyStylePreset.schema.shape, async (args) => applyStylePreset.execute(client, applyStylePreset.schema.parse(args)));
160
169
  // ── Resources ──────────────────────────────────────────────────
161
- server.resource("services-catalog", servicesResource.uri, async (uri) => servicesResource.read(client));
162
- server.resource("layer-schema", new ResourceTemplate(layerSchemaResource.uriTemplate, { list: undefined }), async (uri, params) => layerSchemaResource.read(client, params.encodedServiceId, params.layerId));
163
- server.resource("styles-catalog", stylesResource.uri, async (uri) => stylesResource.readCatalog(client));
164
- server.resource("style", new ResourceTemplate(stylesResource.uriTemplate, { list: undefined }), async (uri, params) => stylesResource.read(client, params.styleId));
170
+ server.resource("services-catalog", servicesResource.uri, async (_uri) => servicesResource.read(client));
171
+ server.resource("layer-schema", new ResourceTemplate(layerSchemaResource.uriTemplate, { list: undefined }), async (_uri, params) => layerSchemaResource.read(client, params.encodedServiceId, params.layerId));
172
+ server.resource("styles-catalog", stylesResource.uri, async (_uri) => stylesResource.readCatalog(client));
173
+ server.resource("style", new ResourceTemplate(stylesResource.uriTemplate, { list: undefined }), async (_uri, params) => stylesResource.read(client, params.styleId));
165
174
  return server;
166
175
  }
167
176
  /* v8 ignore start -- live-process entry: wires the stdio transport to a real
@@ -0,0 +1,73 @@
1
+ import { HonuaCapabilityNotSupportedError } from "@honua/sdk-js";
2
+ /**
3
+ * CAPABILITY HONESTY on the tool surface (#1005).
4
+ *
5
+ * The SDK's contract is that a capability gap throws
6
+ * `HonuaCapabilityNotSupportedError` rather than returning empty data. That
7
+ * guarantee is worthless if the MCP layer swallows the throw and hands the
8
+ * model an empty feature list — the model would report "no features match"
9
+ * for what is actually "this protocol cannot express that request".
10
+ *
11
+ * So every capability refusal becomes an explicit `isError` tool result
12
+ * carrying a machine-actionable `code`, the refused capability/construct, the
13
+ * protocol that refused it, and — for real capability identifiers — the same
14
+ * explanation `honua_explain_capability_gap` would give, including what to do
15
+ * instead. Degradations that DID produce an answer travel the other channel,
16
+ * `Result.degraded`, and are reported alongside the data.
17
+ */
18
+ /**
19
+ * `kind` values come from the vendored geospatial-mcp `GeoprocessingError`
20
+ * taxonomy — the standard forbids a parallel MCP-local error vocabulary, so a
21
+ * capability refusal is an `ExecutionFailed` (a well-formed request the target
22
+ * cannot run) and a bad argument is a `ValidationFailed`. The precise reason
23
+ * travels in the envelope's machine-actionable `code` plus the typed fields.
24
+ */
25
+ export type ToolErrorKind = "ValidationFailed" | "ExecutionFailed";
26
+ export interface ToolErrorViolation {
27
+ readonly code: string;
28
+ readonly message: string;
29
+ readonly fieldPath?: string;
30
+ }
31
+ export interface ToolErrorPayload {
32
+ readonly code: string;
33
+ readonly error: {
34
+ readonly kind: ToolErrorKind;
35
+ readonly message: string;
36
+ readonly capability?: string;
37
+ readonly protocol?: string;
38
+ readonly source?: string;
39
+ readonly guidance?: string;
40
+ readonly explanation?: unknown;
41
+ readonly violations?: readonly ToolErrorViolation[];
42
+ };
43
+ }
44
+ /** Build the structured payload for a capability refusal. */
45
+ export declare function capabilityErrorPayload(error: HonuaCapabilityNotSupportedError): ToolErrorPayload;
46
+ /** Build the structured payload for an invalid tool argument. */
47
+ export declare function validationErrorPayload(code: string, message: string, violations?: readonly ToolErrorViolation[]): ToolErrorPayload;
48
+ /**
49
+ * Wrap a payload as an `isError` MCP tool result.
50
+ *
51
+ * The payload rides in both channels: `structuredContent` for clients (and the
52
+ * certification error-shape contract) that branch on machine-readable fields,
53
+ * and the text block for models that only read text.
54
+ */
55
+ export declare function toolErrorResult(payload: ToolErrorPayload): {
56
+ structuredContent: Record<string, unknown>;
57
+ isError: true;
58
+ content: Array<{
59
+ type: "text";
60
+ text: string;
61
+ }>;
62
+ };
63
+ /** Map any thrown value onto a structured tool-error payload. */
64
+ export declare function toToolErrorPayload(error: unknown): ToolErrorPayload;
65
+ /**
66
+ * Run a tool body, converting capability refusals and argument-validation
67
+ * failures into structured `isError` results. Anything else is rethrown so a
68
+ * genuine transport/server fault is not disguised as a data answer.
69
+ */
70
+ export declare function withCapabilityHonesty<T extends {
71
+ content: unknown;
72
+ }>(run: () => Promise<T>): Promise<T | ReturnType<typeof toolErrorResult>>;
73
+ //# sourceMappingURL=errors.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../../../src/neutral/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gCAAgC,EAAE,MAAM,eAAe,CAAC;AASjE;;;;;;;;;;;;;;;GAeG;AAEH;;;;;;GAMG;AACH,MAAM,MAAM,aAAa,GAAG,kBAAkB,GAAG,iBAAiB,CAAC;AAEnE,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,EAAE;QACd,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC;QAC7B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;QACzB,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAC7B,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAC3B,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;QACzB,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAC3B,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;QAC/B,QAAQ,CAAC,UAAU,CAAC,EAAE,SAAS,kBAAkB,EAAE,CAAC;KACrD,CAAC;CACH;AAUD,6DAA6D;AAC7D,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,gCAAgC,GAAG,gBAAgB,CAkChG;AAED,iEAAiE;AACjE,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,MAAM,EACf,UAAU,GAAE,SAAS,kBAAkB,EAAwB,GAC9D,gBAAgB,CAKlB;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,gBAAgB;uBAGZ,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;;;;;EAGnE;AAED,iEAAiE;AACjE,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,GAAG,gBAAgB,CA4BnE;AAED;;;;GAIG;AACH,wBAAsB,qBAAqB,CAAC,CAAC,SAAS;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,EACxE,GAAG,EAAE,MAAM,OAAO,CAAC,CAAC,CAAC,GACpB,OAAO,CAAC,CAAC,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC,CAcjD"}
@@ -0,0 +1,112 @@
1
+ import { HonuaCapabilityNotSupportedError } from "@honua/sdk-js";
2
+ import { explainHonuaCapabilityGap } from "@honua/sdk-js/agent-tools";
3
+ import { CAPABILITIES, PROTOCOLS } from "@honua/sdk-js/contract";
4
+ import { z } from "zod";
5
+ import { jsonText } from "../helpers.js";
6
+ import { FilterInputError } from "./filter.js";
7
+ import { SourceRefError } from "./source-ref.js";
8
+ function isKnownCapability(value) {
9
+ return CAPABILITIES.includes(value);
10
+ }
11
+ function isKnownProtocol(value) {
12
+ return PROTOCOLS.includes(value);
13
+ }
14
+ /** Build the structured payload for a capability refusal. */
15
+ export function capabilityErrorPayload(error) {
16
+ // Filter refusals name a construct (`filter.spatial.multiple`) rather than a
17
+ // registry capability; only real capability identifiers can be explained
18
+ // against the protocol capability registry.
19
+ const explanation = isKnownCapability(error.capability) && (error.protocol === undefined || isKnownProtocol(error.protocol))
20
+ ? explainHonuaCapabilityGap({
21
+ capability: error.capability,
22
+ ...(isKnownProtocol(error.protocol) ? { protocol: error.protocol } : {}),
23
+ ...(error.sourceId ? { sourceId: error.sourceId } : {}),
24
+ })
25
+ : undefined;
26
+ return {
27
+ code: "capability_not_supported",
28
+ error: {
29
+ kind: "ExecutionFailed",
30
+ message: error.message,
31
+ capability: error.capability,
32
+ protocol: error.protocol,
33
+ ...(error.sourceId ? { source: error.sourceId } : {}),
34
+ violations: [
35
+ {
36
+ code: "capability_not_supported",
37
+ message: error.message,
38
+ fieldPath: error.capability.startsWith("filter.") ? "filter" : "source",
39
+ },
40
+ ],
41
+ guidance: explanation?.suggestedAction ??
42
+ "This protocol cannot express the request exactly. Re-issue it against a source whose protocol advertises the capability, or restate the request within what this protocol supports — do not treat this as an empty result.",
43
+ ...(explanation ? { explanation } : {}),
44
+ },
45
+ };
46
+ }
47
+ /** Build the structured payload for an invalid tool argument. */
48
+ export function validationErrorPayload(code, message, violations = [{ code, message }]) {
49
+ return {
50
+ code,
51
+ error: { kind: "ValidationFailed", message, violations },
52
+ };
53
+ }
54
+ /**
55
+ * Wrap a payload as an `isError` MCP tool result.
56
+ *
57
+ * The payload rides in both channels: `structuredContent` for clients (and the
58
+ * certification error-shape contract) that branch on machine-readable fields,
59
+ * and the text block for models that only read text.
60
+ */
61
+ export function toolErrorResult(payload) {
62
+ return {
63
+ ...jsonText(payload),
64
+ structuredContent: payload,
65
+ isError: true,
66
+ };
67
+ }
68
+ /** Map any thrown value onto a structured tool-error payload. */
69
+ export function toToolErrorPayload(error) {
70
+ if (error instanceof HonuaCapabilityNotSupportedError) {
71
+ return capabilityErrorPayload(error);
72
+ }
73
+ if (error instanceof SourceRefError) {
74
+ const code = error.code.toLowerCase();
75
+ return validationErrorPayload(code, error.message, [{ code, message: error.message, fieldPath: "source" }]);
76
+ }
77
+ if (error instanceof FilterInputError) {
78
+ const code = error.code.toLowerCase();
79
+ return validationErrorPayload(code, error.message, [{ code, message: error.message, fieldPath: "filter" }]);
80
+ }
81
+ if (error instanceof z.ZodError) {
82
+ return validationErrorPayload("invalid_arguments", "Tool arguments failed validation.", error.issues.map((issue) => ({
83
+ code: issue.code,
84
+ message: issue.message,
85
+ fieldPath: issue.path.join("."),
86
+ })));
87
+ }
88
+ const message = error instanceof Error ? error.message : String(error);
89
+ return {
90
+ code: "tool_execution_failed",
91
+ error: { kind: "ExecutionFailed", message, violations: [{ code: "tool_execution_failed", message }] },
92
+ };
93
+ }
94
+ /**
95
+ * Run a tool body, converting capability refusals and argument-validation
96
+ * failures into structured `isError` results. Anything else is rethrown so a
97
+ * genuine transport/server fault is not disguised as a data answer.
98
+ */
99
+ export async function withCapabilityHonesty(run) {
100
+ try {
101
+ return await run();
102
+ }
103
+ catch (error) {
104
+ if (error instanceof HonuaCapabilityNotSupportedError ||
105
+ error instanceof SourceRefError ||
106
+ error instanceof FilterInputError ||
107
+ error instanceof z.ZodError) {
108
+ return toolErrorResult(toToolErrorPayload(error));
109
+ }
110
+ throw error;
111
+ }
112
+ }
@@ -0,0 +1,108 @@
1
+ import type { QueryFilterExpression, QueryFilterGeometryOperand, QueryTemporalFilter } from "@honua/sdk-js/contract";
2
+ import { z } from "zod";
3
+ import { GEOMETRY_TYPES } from "../helpers.js";
4
+ /** Protocol-neutral spatial predicates (`QueryFilterSpatialPredicate`). */
5
+ export declare const SPATIAL_PREDICATES: readonly ["intersects", "contains", "within", "crosses", "touches", "overlaps", "bbox-intersects"];
6
+ declare const TEMPORAL_PREDICATES: readonly ["before", "after", "during", "time-intersects"];
7
+ /** GeoJSON geometry accepted anywhere a spatial constraint is expressed. */
8
+ export declare const geoJsonGeometrySchema: z.ZodObject<{
9
+ type: z.ZodEnum<["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"]>;
10
+ coordinates: z.ZodArray<z.ZodUnknown, "many">;
11
+ }, "passthrough", z.ZodTypeAny, z.objectOutputType<{
12
+ type: z.ZodEnum<["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"]>;
13
+ coordinates: z.ZodArray<z.ZodUnknown, "many">;
14
+ }, z.ZodTypeAny, "passthrough">, z.objectInputType<{
15
+ type: z.ZodEnum<["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"]>;
16
+ coordinates: z.ZodArray<z.ZodUnknown, "many">;
17
+ }, z.ZodTypeAny, "passthrough">>;
18
+ /**
19
+ * A geometry operand: GeoJSON (canonical) or Esri-JSON (accepted for
20
+ * compatibility). Kept as a loose record so an Esri envelope / rings payload
21
+ * from an existing client still validates.
22
+ */
23
+ export declare const geometrySchema: z.ZodRecord<z.ZodString, z.ZodUnknown>;
24
+ export declare const bboxSchema: z.ZodArray<z.ZodNumber, "many">;
25
+ export type FilterNode = {
26
+ op: "and" | "or";
27
+ args: FilterNode[];
28
+ } | {
29
+ op: "not";
30
+ arg: FilterNode;
31
+ } | {
32
+ op: "eq" | "ne" | "lt" | "lte" | "gt" | "gte";
33
+ field: string;
34
+ value: string | number | boolean | null;
35
+ } | {
36
+ op: "in";
37
+ field: string;
38
+ values: (string | number | boolean | null)[];
39
+ } | {
40
+ op: "between";
41
+ field: string;
42
+ lower: string | number | boolean | null;
43
+ upper: string | number | boolean | null;
44
+ } | {
45
+ op: "is-null" | "is-not-null";
46
+ field: string;
47
+ } | {
48
+ op: "like";
49
+ field: string;
50
+ pattern: string;
51
+ caseSensitive?: boolean;
52
+ } | {
53
+ op: (typeof SPATIAL_PREDICATES)[number];
54
+ geometry?: Record<string, unknown>;
55
+ bbox?: number[];
56
+ field?: string;
57
+ geometryType?: (typeof GEOMETRY_TYPES)[number];
58
+ } | {
59
+ op: (typeof TEMPORAL_PREDICATES)[number];
60
+ field: string;
61
+ value: string | string[];
62
+ };
63
+ /**
64
+ * Recursive zod schema for {@link FilterNode}. `z.lazy` keeps `and`/`or`/`not`
65
+ * genuinely recursive; zod-to-json-schema emits it as a `$ref`, which stays
66
+ * well-formed JSON Schema for MCP clients and for the certification harness.
67
+ */
68
+ export declare const filterSchema: z.ZodType<FilterNode>;
69
+ export declare class FilterInputError extends Error {
70
+ readonly code = "INVALID_FILTER";
71
+ constructor(message: string);
72
+ }
73
+ /** Build an Esri envelope from a `[minX, minY, maxX, maxY]` bbox. */
74
+ export declare function bboxToOperand(bbox: readonly number[], srid?: number): QueryFilterGeometryOperand;
75
+ /**
76
+ * Normalize a geometry input onto the canonical geometry operand.
77
+ *
78
+ * GeoJSON is the documented input; Esri-JSON is accepted so existing clients
79
+ * keep working. The canonical operand is Esri-shaped inside the SDK — that is
80
+ * an SDK implementation detail, not part of this tool contract.
81
+ */
82
+ export declare function toGeometryOperand(geometry: Record<string, unknown>, esriGeometryType?: (typeof GEOMETRY_TYPES)[number], srid?: number): QueryFilterGeometryOperand;
83
+ /** Lower the wire filter DSL onto the SDK's canonical typed filter expression. */
84
+ export declare function toQueryFilter(node: FilterNode): QueryFilterExpression;
85
+ /** Conjoin filter expressions; `undefined` when nothing was supplied. */
86
+ export declare function andFilters(parts: readonly QueryFilterExpression[]): QueryFilterExpression | undefined;
87
+ /** Canonical time constraint accepted on the tool surface. */
88
+ export declare const temporalSchema: z.ZodObject<{
89
+ instant: z.ZodOptional<z.ZodString>;
90
+ start: z.ZodOptional<z.ZodNullable<z.ZodString>>;
91
+ end: z.ZodOptional<z.ZodNullable<z.ZodString>>;
92
+ field: z.ZodOptional<z.ZodString>;
93
+ }, "strip", z.ZodTypeAny, {
94
+ field?: string | undefined;
95
+ instant?: string | undefined;
96
+ start?: string | null | undefined;
97
+ end?: string | null | undefined;
98
+ }, {
99
+ field?: string | undefined;
100
+ instant?: string | undefined;
101
+ start?: string | null | undefined;
102
+ end?: string | null | undefined;
103
+ }>;
104
+ export type TemporalInput = z.infer<typeof temporalSchema>;
105
+ /** Lower the wire temporal input onto `Query.temporalFilter`. */
106
+ export declare function toTemporalFilter(input: TemporalInput): QueryTemporalFilter;
107
+ export {};
108
+ //# sourceMappingURL=filter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"filter.d.ts","sourceRoot":"","sources":["../../../src/neutral/filter.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,271 @@
1
+ import { geoJsonToEsri } from "@honua/sdk-js/geometry";
2
+ import { z } from "zod";
3
+ import { GEOMETRY_TYPES, resolveGeometryType } from "../helpers.js";
4
+ /**
5
+ * PROTOCOL-NEUTRAL filter + geometry vocabulary for the MCP tool contract (#1005).
6
+ *
7
+ * The SDK's `Query.filter` is a typed semantic filter that compiles to
8
+ * GeoServices SQL-92, CQL2 (OGC API Features / STAC), FES 2.0 (WFS), OData
9
+ * `$filter`, and DuckDB SQL — and refuses, naming the construct and the
10
+ * protocol, when a target cannot express a construct exactly. Exposing it on
11
+ * the tool surface is what lets an agent write ONE filter and have it mean the
12
+ * same thing on every backend, instead of hand-writing Esri SQL.
13
+ *
14
+ * The wire shape here is a flattened projection of that AST: one `op`
15
+ * discriminator per node instead of the SDK's `kind` + `operator` pair, because
16
+ * a flat discriminated union is markedly easier for a model to emit correctly.
17
+ * {@link toQueryFilter} lowers it onto the canonical expression losslessly.
18
+ *
19
+ * Geometry is canonical **GeoJSON** (RFC 7946). Esri-JSON geometry is still
20
+ * accepted for compatibility with existing clients and is converted on the way
21
+ * in; nothing on the surface requires it.
22
+ */
23
+ const scalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
24
+ /** Protocol-neutral spatial predicates (`QueryFilterSpatialPredicate`). */
25
+ export const SPATIAL_PREDICATES = [
26
+ "intersects",
27
+ "contains",
28
+ "within",
29
+ "crosses",
30
+ "touches",
31
+ "overlaps",
32
+ "bbox-intersects",
33
+ ];
34
+ const TEMPORAL_PREDICATES = ["before", "after", "during", "time-intersects"];
35
+ /** GeoJSON geometry accepted anywhere a spatial constraint is expressed. */
36
+ export const geoJsonGeometrySchema = z
37
+ .object({
38
+ type: z.enum(["Point", "MultiPoint", "LineString", "MultiLineString", "Polygon", "MultiPolygon"]),
39
+ coordinates: z.array(z.unknown()),
40
+ })
41
+ .passthrough();
42
+ /**
43
+ * A geometry operand: GeoJSON (canonical) or Esri-JSON (accepted for
44
+ * compatibility). Kept as a loose record so an Esri envelope / rings payload
45
+ * from an existing client still validates.
46
+ */
47
+ export const geometrySchema = z.record(z.unknown());
48
+ export const bboxSchema = z
49
+ .array(z.number())
50
+ .length(4)
51
+ .describe("Bounding box [minX, minY, maxX, maxY]. Interpreted in the source CRS (WGS84 unless bboxSrid says otherwise).");
52
+ /**
53
+ * Recursive zod schema for {@link FilterNode}. `z.lazy` keeps `and`/`or`/`not`
54
+ * genuinely recursive; zod-to-json-schema emits it as a `$ref`, which stays
55
+ * well-formed JSON Schema for MCP clients and for the certification harness.
56
+ */
57
+ export const filterSchema = z.lazy(() => z.union([
58
+ z.object({
59
+ op: z.enum(["and", "or"]),
60
+ args: z.array(filterSchema).min(1),
61
+ }),
62
+ z.object({ op: z.literal("not"), arg: filterSchema }),
63
+ z.object({
64
+ op: z.enum(["eq", "ne", "lt", "lte", "gt", "gte"]),
65
+ field: z.string().min(1),
66
+ value: scalarSchema,
67
+ }),
68
+ z.object({ op: z.literal("in"), field: z.string().min(1), values: z.array(scalarSchema).min(1) }),
69
+ z.object({
70
+ op: z.literal("between"),
71
+ field: z.string().min(1),
72
+ lower: scalarSchema,
73
+ upper: scalarSchema,
74
+ }),
75
+ z.object({ op: z.enum(["is-null", "is-not-null"]), field: z.string().min(1) }),
76
+ z.object({
77
+ op: z.literal("like"),
78
+ field: z.string().min(1),
79
+ pattern: z.string(),
80
+ caseSensitive: z.boolean().optional(),
81
+ }),
82
+ z.object({
83
+ op: z.enum(SPATIAL_PREDICATES),
84
+ geometry: geometrySchema.optional(),
85
+ bbox: z.array(z.number()).length(4).optional(),
86
+ field: z.string().optional(),
87
+ geometryType: z.enum(GEOMETRY_TYPES).optional(),
88
+ }),
89
+ z.object({
90
+ op: z.enum(TEMPORAL_PREDICATES),
91
+ field: z.string().min(1),
92
+ // An interval is a 2-element array rather than a JSON Schema tuple: the
93
+ // draft-07 tuple form (`items: [...]`) is invalid under 2020-12, and the
94
+ // certification harness validates advertised schemas under both dialects.
95
+ value: z.union([z.string(), z.array(z.string()).length(2)]),
96
+ }),
97
+ ]));
98
+ export class FilterInputError extends Error {
99
+ code = "INVALID_FILTER";
100
+ constructor(message) {
101
+ super(message);
102
+ this.name = "FilterInputError";
103
+ }
104
+ }
105
+ function isGeoJsonGeometry(value) {
106
+ return typeof value.type === "string" && Array.isArray(value.coordinates);
107
+ }
108
+ /** Build an Esri envelope from a `[minX, minY, maxX, maxY]` bbox. */
109
+ export function bboxToOperand(bbox, srid) {
110
+ const [xmin, ymin, xmax, ymax] = bbox;
111
+ const geometry = { xmin, ymin, xmax, ymax };
112
+ if (srid !== undefined) {
113
+ geometry.spatialReference = { wkid: srid };
114
+ }
115
+ return { geometry, geometryType: "esriGeometryEnvelope" };
116
+ }
117
+ /**
118
+ * Normalize a geometry input onto the canonical geometry operand.
119
+ *
120
+ * GeoJSON is the documented input; Esri-JSON is accepted so existing clients
121
+ * keep working. The canonical operand is Esri-shaped inside the SDK — that is
122
+ * an SDK implementation detail, not part of this tool contract.
123
+ */
124
+ export function toGeometryOperand(geometry, esriGeometryType, srid) {
125
+ if (isGeoJsonGeometry(geometry)) {
126
+ const converted = geoJsonToEsri(geometry, srid !== undefined ? { wkid: srid } : undefined);
127
+ if (!converted) {
128
+ throw new FilterInputError(`GeoJSON geometry of type "${String(geometry.type)}" could not be converted to a query geometry.`);
129
+ }
130
+ const asRecord = converted;
131
+ const geometryType = resolveGeometryType(asRecord, undefined);
132
+ if (!geometryType) {
133
+ throw new FilterInputError("Converted geometry has no recognizable geometry type.");
134
+ }
135
+ return { geometry: asRecord, geometryType };
136
+ }
137
+ const geometryType = resolveGeometryType(geometry, esriGeometryType);
138
+ if (!geometryType) {
139
+ throw new FilterInputError('geometry is neither GeoJSON (an object with "type" and "coordinates") nor a recognizable Esri-JSON geometry. Pass GeoJSON, a bbox, or an explicit geometryType.');
140
+ }
141
+ return { geometry, geometryType };
142
+ }
143
+ function spatialOperand(node) {
144
+ if (node.geometry && node.bbox) {
145
+ throw new FilterInputError(`filter node "${node.op}" carries both geometry and bbox; pass exactly one.`);
146
+ }
147
+ if (node.bbox) {
148
+ return bboxToOperand(node.bbox);
149
+ }
150
+ if (node.geometry) {
151
+ return toGeometryOperand(node.geometry, node.geometryType);
152
+ }
153
+ throw new FilterInputError(`filter node "${node.op}" requires either geometry (GeoJSON) or bbox.`);
154
+ }
155
+ function isSpatialOp(op) {
156
+ return SPATIAL_PREDICATES.includes(op);
157
+ }
158
+ function isTemporalOp(op) {
159
+ return TEMPORAL_PREDICATES.includes(op);
160
+ }
161
+ /** Lower the wire filter DSL onto the SDK's canonical typed filter expression. */
162
+ export function toQueryFilter(node) {
163
+ switch (node.op) {
164
+ case "and":
165
+ case "or":
166
+ return { kind: "boolean", operator: node.op, args: node.args.map(toQueryFilter) };
167
+ case "not":
168
+ return { kind: "not", arg: toQueryFilter(node.arg) };
169
+ case "eq":
170
+ case "ne":
171
+ case "lt":
172
+ case "lte":
173
+ case "gt":
174
+ case "gte":
175
+ return {
176
+ kind: "comparison",
177
+ operator: node.op,
178
+ left: { kind: "property", name: node.field },
179
+ right: { kind: "literal", value: node.value },
180
+ };
181
+ case "in":
182
+ return {
183
+ kind: "list",
184
+ operator: "in",
185
+ operand: { kind: "property", name: node.field },
186
+ values: node.values.map((value) => ({ kind: "literal", value })),
187
+ };
188
+ case "between":
189
+ return {
190
+ kind: "range",
191
+ operator: "between",
192
+ operand: { kind: "property", name: node.field },
193
+ lower: { kind: "literal", value: node.lower },
194
+ upper: { kind: "literal", value: node.upper },
195
+ };
196
+ case "is-null":
197
+ case "is-not-null":
198
+ return { kind: "null", operator: node.op, operand: { kind: "property", name: node.field } };
199
+ case "like":
200
+ return {
201
+ kind: "pattern",
202
+ operator: "like",
203
+ operand: { kind: "property", name: node.field },
204
+ pattern: node.pattern,
205
+ ...(node.caseSensitive === undefined ? {} : { caseSensitive: node.caseSensitive }),
206
+ };
207
+ default:
208
+ break;
209
+ }
210
+ if (isSpatialOp(node.op)) {
211
+ const spatial = node;
212
+ return {
213
+ kind: "spatial",
214
+ operator: spatial.op,
215
+ geometry: spatialOperand(spatial),
216
+ ...(spatial.field ? { property: spatial.field } : {}),
217
+ };
218
+ }
219
+ if (isTemporalOp(node.op)) {
220
+ const temporal = node;
221
+ const value = temporal.value;
222
+ return {
223
+ kind: "temporal",
224
+ operator: temporal.op,
225
+ operand: { kind: "property", name: temporal.field },
226
+ value: typeof value === "string"
227
+ ? { kind: "temporal-literal", valueType: "instant", value }
228
+ : { kind: "temporal-literal", valueType: "interval", value: [value[0], value[1]] },
229
+ };
230
+ }
231
+ throw new FilterInputError(`unknown filter operator "${String(node.op)}".`);
232
+ }
233
+ /** Conjoin filter expressions; `undefined` when nothing was supplied. */
234
+ export function andFilters(parts) {
235
+ if (parts.length === 0)
236
+ return undefined;
237
+ if (parts.length === 1)
238
+ return parts[0];
239
+ return { kind: "boolean", operator: "and", args: [...parts] };
240
+ }
241
+ /** Canonical time constraint accepted on the tool surface. */
242
+ export const temporalSchema = z
243
+ .object({
244
+ instant: z.string().optional().describe("RFC 3339 instant, e.g. 2026-05-01T00:00:00Z."),
245
+ start: z.string().nullable().optional().describe("Interval start (null = open ended)."),
246
+ end: z.string().nullable().optional().describe("Interval end (null = open ended)."),
247
+ field: z
248
+ .string()
249
+ .optional()
250
+ .describe("Time column to constrain. Omit to target the source's own time dimension (GeoServices time=, OGC/STAC datetime=); OData, WFS, and GeoParquet require an explicit field."),
251
+ })
252
+ .describe("Protocol-neutral time constraint: either `instant`, or `start`/`end` for an interval.");
253
+ /** Lower the wire temporal input onto `Query.temporalFilter`. */
254
+ export function toTemporalFilter(input) {
255
+ const field = input.field ? { field: input.field } : {};
256
+ if (input.instant !== undefined) {
257
+ if (input.start !== undefined || input.end !== undefined) {
258
+ throw new FilterInputError("temporal accepts either `instant` or `start`/`end`, not both.");
259
+ }
260
+ return { kind: "instant", instant: input.instant, ...field };
261
+ }
262
+ if (input.start === undefined && input.end === undefined) {
263
+ throw new FilterInputError("temporal requires `instant`, or at least one of `start` / `end`.");
264
+ }
265
+ return {
266
+ kind: "interval",
267
+ start: input.start ?? null,
268
+ end: input.end ?? null,
269
+ ...field,
270
+ };
271
+ }