@honua/mcp-server 0.0.10-alpha.0 → 0.1.0-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.
- package/README.md +259 -15
- package/certification/geospatial-mcp-schemas/PROVENANCE.md +41 -0
- package/certification/geospatial-mcp-schemas/README.md +90 -0
- package/certification/geospatial-mcp-schemas/common/geoprocessing-error.schema.json +45 -0
- package/certification/geospatial-mcp-schemas/conformance/manifest.schema.json +79 -0
- package/certification/geospatial-mcp-schemas/index.json +390 -0
- package/certification/geospatial-mcp-schemas/resources/app-package.schema.json +20 -0
- package/certification/geospatial-mcp-schemas/resources/app-template.schema.json +15 -0
- package/certification/geospatial-mcp-schemas/resources/artifact.schema.json +69 -0
- package/certification/geospatial-mcp-schemas/resources/deployment.schema.json +27 -0
- package/certification/geospatial-mcp-schemas/resources/map-package.schema.json +21 -0
- package/certification/geospatial-mcp-schemas/resources/map-template.schema.json +15 -0
- package/certification/geospatial-mcp-schemas/resources/provenance.schema.json +50 -0
- package/certification/geospatial-mcp-schemas/resources/published-service.schema.json +21 -0
- package/certification/geospatial-mcp-schemas/resources/result-package.schema.json +64 -0
- package/certification/geospatial-mcp-schemas/resources/style.schema.json +12 -0
- package/certification/geospatial-mcp-schemas/resources/theme.schema.json +12 -0
- package/certification/geospatial-mcp-schemas/resources/workspace.schema.json +63 -0
- package/certification/geospatial-mcp-schemas/tools/apply_style_preset.schema.json +27 -0
- package/certification/geospatial-mcp-schemas/tools/buffer_features.schema.json +60 -0
- package/certification/geospatial-mcp-schemas/tools/cancel_job.schema.json +15 -0
- package/certification/geospatial-mcp-schemas/tools/clarify_intent.schema.json +69 -0
- package/certification/geospatial-mcp-schemas/tools/compose_mixed_protocol_map.schema.json +21 -0
- package/certification/geospatial-mcp-schemas/tools/create_app_package.schema.json +33 -0
- package/certification/geospatial-mcp-schemas/tools/create_map_package.schema.json +41 -0
- package/certification/geospatial-mcp-schemas/tools/edit_features.schema.json +74 -0
- package/certification/geospatial-mcp-schemas/tools/execute_plan.schema.json +64 -0
- package/certification/geospatial-mcp-schemas/tools/export_dataset.schema.json +60 -0
- package/certification/geospatial-mcp-schemas/tools/geocode_address.schema.json +36 -0
- package/certification/geospatial-mcp-schemas/tools/geocode_addresses.schema.json +34 -0
- package/certification/geospatial-mcp-schemas/tools/get_style.schema.json +40 -0
- package/certification/geospatial-mcp-schemas/tools/ground_candidates.schema.json +66 -0
- package/certification/geospatial-mcp-schemas/tools/ingest_dataset.schema.json +45 -0
- package/certification/geospatial-mcp-schemas/tools/join_features.schema.json +73 -0
- package/certification/geospatial-mcp-schemas/tools/list_capabilities.schema.json +21 -0
- package/certification/geospatial-mcp-schemas/tools/list_layers.schema.json +14 -0
- package/certification/geospatial-mcp-schemas/tools/overlay_features.schema.json +53 -0
- package/certification/geospatial-mcp-schemas/tools/plan_analysis.schema.json +19 -0
- package/certification/geospatial-mcp-schemas/tools/preview_app_package.schema.json +15 -0
- package/certification/geospatial-mcp-schemas/tools/preview_map_package.schema.json +17 -0
- package/certification/geospatial-mcp-schemas/tools/preview_package.schema.json +40 -0
- package/certification/geospatial-mcp-schemas/tools/propose_operation.schema.json +28 -0
- package/certification/geospatial-mcp-schemas/tools/publish_result.schema.json +29 -0
- package/certification/geospatial-mcp-schemas/tools/publish_service.schema.json +59 -0
- package/certification/geospatial-mcp-schemas/tools/query_features.schema.json +74 -0
- package/certification/geospatial-mcp-schemas/tools/refine_map_package.schema.json +22 -0
- package/certification/geospatial-mcp-schemas/tools/render_map.schema.json +60 -0
- package/certification/geospatial-mcp-schemas/tools/reproject_features.schema.json +49 -0
- package/certification/geospatial-mcp-schemas/tools/resolve_entity.schema.json +30 -0
- package/certification/geospatial-mcp-schemas/tools/solve_route.schema.json +39 -0
- package/certification/geospatial-mcp-schemas/tools/summarize_statistics.schema.json +71 -0
- package/certification/geospatial-mcp-schemas/tools/validate_package.schema.json +35 -0
- package/certification/geospatial-mcp-schemas/tools/validate_plan.schema.json +61 -0
- package/dist/src/capability.d.ts +48 -0
- package/dist/src/capability.d.ts.map +1 -0
- package/dist/src/capability.js +44 -0
- package/dist/src/certification/census-data.d.ts +33 -0
- package/dist/src/certification/census-data.d.ts.map +1 -0
- package/dist/src/certification/census-data.js +506 -0
- package/dist/src/certification/census-fixture-client.d.ts +7 -0
- package/dist/src/certification/census-fixture-client.d.ts.map +1 -0
- package/dist/src/certification/census-fixture-client.js +226 -0
- package/dist/src/certification/certifier.d.ts +125 -0
- package/dist/src/certification/certifier.d.ts.map +1 -0
- package/dist/src/certification/certifier.js +455 -0
- package/dist/src/certification/cli.d.ts +3 -0
- package/dist/src/certification/cli.d.ts.map +1 -0
- package/dist/src/certification/cli.js +63 -0
- package/dist/src/certification/contracts.d.ts +103 -0
- package/dist/src/certification/contracts.d.ts.map +1 -0
- package/dist/src/certification/contracts.js +245 -0
- package/dist/src/certification/fixture-client.d.ts +4 -0
- package/dist/src/certification/fixture-client.d.ts.map +1 -0
- package/dist/src/certification/fixture-client.js +113 -0
- package/dist/src/certification/fixtures.d.ts +45 -0
- package/dist/src/certification/fixtures.d.ts.map +1 -0
- package/dist/src/certification/fixtures.js +92 -0
- package/dist/src/certification/json-schema.d.ts +71 -0
- package/dist/src/certification/json-schema.d.ts.map +1 -0
- package/dist/src/certification/json-schema.js +172 -0
- package/dist/src/certification/lifecycle.d.ts +14 -0
- package/dist/src/certification/lifecycle.d.ts.map +1 -0
- package/dist/src/certification/lifecycle.js +326 -0
- package/dist/src/certification/mock-upstream.d.ts +29 -0
- package/dist/src/certification/mock-upstream.d.ts.map +1 -0
- package/dist/src/certification/mock-upstream.js +96 -0
- package/dist/src/certification/operator-catalog.d.ts +90 -0
- package/dist/src/certification/operator-catalog.d.ts.map +1 -0
- package/dist/src/certification/operator-catalog.js +650 -0
- package/dist/src/certification/report.d.ts +4 -0
- package/dist/src/certification/report.d.ts.map +1 -0
- package/dist/src/certification/report.js +103 -0
- package/dist/src/certification/run.d.ts +28 -0
- package/dist/src/certification/run.d.ts.map +1 -0
- package/dist/src/certification/run.js +32 -0
- package/dist/src/certification/schema-index.d.ts +94 -0
- package/dist/src/certification/schema-index.d.ts.map +1 -0
- package/dist/src/certification/schema-index.js +109 -0
- package/dist/src/certification/target.d.ts +48 -0
- package/dist/src/certification/target.d.ts.map +1 -0
- package/dist/src/certification/target.js +219 -0
- package/dist/src/eval/cli.d.ts +3 -0
- package/dist/src/eval/cli.d.ts.map +1 -0
- package/dist/src/eval/cli.js +118 -0
- package/dist/src/eval/corpus.d.ts +34 -0
- package/dist/src/eval/corpus.d.ts.map +1 -0
- package/dist/src/eval/corpus.js +150 -0
- package/dist/src/eval/drivers/anthropic.d.ts +14 -0
- package/dist/src/eval/drivers/anthropic.d.ts.map +1 -0
- package/dist/src/eval/drivers/anthropic.js +120 -0
- package/dist/src/eval/drivers/bedrock.d.ts +54 -0
- package/dist/src/eval/drivers/bedrock.d.ts.map +1 -0
- package/dist/src/eval/drivers/bedrock.js +178 -0
- package/dist/src/eval/drivers/deterministic.d.ts +17 -0
- package/dist/src/eval/drivers/deterministic.d.ts.map +1 -0
- package/dist/src/eval/drivers/deterministic.js +64 -0
- package/dist/src/eval/drivers/index.d.ts +34 -0
- package/dist/src/eval/drivers/index.d.ts.map +1 -0
- package/dist/src/eval/drivers/index.js +74 -0
- package/dist/src/eval/drivers/openai.d.ts +14 -0
- package/dist/src/eval/drivers/openai.d.ts.map +1 -0
- package/dist/src/eval/drivers/openai.js +136 -0
- package/dist/src/eval/grade.d.ts +14 -0
- package/dist/src/eval/grade.d.ts.map +1 -0
- package/dist/src/eval/grade.js +75 -0
- package/dist/src/eval/northstar-corpus.d.ts +3 -0
- package/dist/src/eval/northstar-corpus.d.ts.map +1 -0
- package/dist/src/eval/northstar-corpus.js +194 -0
- package/dist/src/eval/operator-corpus.d.ts +3 -0
- package/dist/src/eval/operator-corpus.d.ts.map +1 -0
- package/dist/src/eval/operator-corpus.js +193 -0
- package/dist/src/eval/report.d.ts +109 -0
- package/dist/src/eval/report.d.ts.map +1 -0
- package/dist/src/eval/report.js +133 -0
- package/dist/src/eval/runner.d.ts +28 -0
- package/dist/src/eval/runner.d.ts.map +1 -0
- package/dist/src/eval/runner.js +130 -0
- package/dist/src/eval/standalone-corpus.d.ts +3 -0
- package/dist/src/eval/standalone-corpus.d.ts.map +1 -0
- package/dist/src/eval/standalone-corpus.js +699 -0
- package/dist/src/eval/types.d.ts +118 -0
- package/dist/src/eval/types.d.ts.map +1 -0
- package/dist/src/eval/types.js +10 -0
- package/dist/src/helpers.d.ts +11 -0
- package/dist/src/helpers.d.ts.map +1 -1
- package/dist/src/helpers.js +22 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +50 -4
- package/dist/src/provenance.d.ts +44 -0
- package/dist/src/provenance.d.ts.map +1 -0
- package/dist/src/provenance.js +37 -0
- package/dist/src/proxy.d.ts +46 -0
- package/dist/src/proxy.d.ts.map +1 -0
- package/dist/src/proxy.js +124 -0
- package/dist/src/resources/styles.d.ts +29 -0
- package/dist/src/resources/styles.d.ts.map +1 -0
- package/dist/src/resources/styles.js +76 -0
- package/dist/src/styles.d.ts +112 -0
- package/dist/src/styles.d.ts.map +1 -0
- package/dist/src/styles.js +144 -0
- package/dist/src/tools/apply-style-preset.d.ts +27 -0
- package/dist/src/tools/apply-style-preset.d.ts.map +1 -0
- package/dist/src/tools/apply-style-preset.js +41 -0
- package/dist/src/tools/count-features.d.ts.map +1 -1
- package/dist/src/tools/count-features.js +5 -4
- package/dist/src/tools/get-extent.d.ts.map +1 -1
- package/dist/src/tools/get-extent.js +6 -4
- package/dist/src/tools/get-style.d.ts +24 -0
- package/dist/src/tools/get-style.d.ts.map +1 -0
- package/dist/src/tools/get-style.js +39 -0
- package/dist/src/tools/list-services.d.ts.map +1 -1
- package/dist/src/tools/list-services.js +16 -1
- package/dist/src/tools/query-features.d.ts.map +1 -1
- package/dist/src/tools/query-features.js +4 -1
- package/package.json +28 -4
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
2
|
+
import draft07MetaSchema from "ajv/dist/refs/json-schema-draft-07.json" with { type: "json" };
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic JSON Schema utilities for certification.
|
|
5
|
+
*
|
|
6
|
+
* Two dialects appear in practice:
|
|
7
|
+
* - The vendored geospatial-mcp standard schemas declare draft 2020-12.
|
|
8
|
+
* - MCP tool `inputSchema`s emitted via zod-to-json-schema declare draft-07.
|
|
9
|
+
*
|
|
10
|
+
* A single Ajv 2020-12 core handles both: we register the draft-07 meta-schema
|
|
11
|
+
* so a schema that declares `$schema: ".../draft-07/schema#"` resolves instead
|
|
12
|
+
* of erroring with "no schema with key/ref". All work is in-process: no network
|
|
13
|
+
* access, no model calls.
|
|
14
|
+
*
|
|
15
|
+
* `strict: false` tolerates non-standard annotation keywords (e.g.
|
|
16
|
+
* `x-honua-reference-shape`); `validateFormats: false` keeps well-formedness
|
|
17
|
+
* purely structural and offline.
|
|
18
|
+
*/
|
|
19
|
+
function createAjv() {
|
|
20
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true, validateFormats: false });
|
|
21
|
+
ajv.addMetaSchema(draft07MetaSchema);
|
|
22
|
+
return ajv;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Check that a value is a structurally well-formed JSON Schema by asking Ajv to
|
|
26
|
+
* compile it. A schema that compiles is usable as a validator; a schema Ajv
|
|
27
|
+
* refuses to compile is malformed.
|
|
28
|
+
*/
|
|
29
|
+
export function checkWellFormed(schema) {
|
|
30
|
+
if (schema === undefined || schema === null) {
|
|
31
|
+
return { wellFormed: false, errors: ["schema is missing (null/undefined)"] };
|
|
32
|
+
}
|
|
33
|
+
if (typeof schema !== "object") {
|
|
34
|
+
return { wellFormed: false, errors: [`schema must be an object, got ${typeof schema}`] };
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const ajv = createAjv();
|
|
38
|
+
ajv.compile(schema);
|
|
39
|
+
return { wellFormed: true, errors: [] };
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
return { wellFormed: false, errors: [err instanceof Error ? err.message : String(err)] };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function formatErrors(errors) {
|
|
46
|
+
if (!errors) {
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
return errors.map((e) => `${e.instancePath || "(root)"} ${e.message ?? "is invalid"}`.trim());
|
|
50
|
+
}
|
|
51
|
+
/** Compile a schema into a reusable validator, or throw a descriptive error. */
|
|
52
|
+
export function compileValidator(schema) {
|
|
53
|
+
const ajv = createAjv();
|
|
54
|
+
return ajv.compile(schema);
|
|
55
|
+
}
|
|
56
|
+
/** Validate a value against a JSON Schema and collect human-readable errors. */
|
|
57
|
+
export function validateAgainstSchema(schema, value) {
|
|
58
|
+
try {
|
|
59
|
+
const validate = compileValidator(schema);
|
|
60
|
+
const valid = validate(value);
|
|
61
|
+
return { valid, errors: valid ? [] : formatErrors(validate.errors) };
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
return { valid: false, errors: [err instanceof Error ? err.message : String(err)] };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Extract the top-level property names a JSON Schema accepts, when discoverable
|
|
69
|
+
* from a plain `type: object` shape. Returns undefined when the property set is
|
|
70
|
+
* not statically enumerable (e.g. additionalProperties / combinators only).
|
|
71
|
+
*/
|
|
72
|
+
function objectPropertyNames(schema) {
|
|
73
|
+
const props = schema.properties;
|
|
74
|
+
if (props && typeof props === "object") {
|
|
75
|
+
return new Set(Object.keys(props));
|
|
76
|
+
}
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
function requiredPropertyNames(schema) {
|
|
80
|
+
const required = schema.required;
|
|
81
|
+
return Array.isArray(required) ? required.filter((r) => typeof r === "string") : [];
|
|
82
|
+
}
|
|
83
|
+
/** Normalize a JSON Schema `type` (string or array) into a set. */
|
|
84
|
+
function schemaTypes(schema) {
|
|
85
|
+
if (!schema) {
|
|
86
|
+
return new Set();
|
|
87
|
+
}
|
|
88
|
+
const t = schema.type;
|
|
89
|
+
if (typeof t === "string") {
|
|
90
|
+
return new Set([t]);
|
|
91
|
+
}
|
|
92
|
+
if (Array.isArray(t)) {
|
|
93
|
+
return new Set(t.filter((x) => typeof x === "string"));
|
|
94
|
+
}
|
|
95
|
+
return new Set();
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Deterministically compare an advertised tool inputSchema against a standard
|
|
99
|
+
* geospatial-mcp tool schema.
|
|
100
|
+
*
|
|
101
|
+
* Conformance contract (intentionally permissive to avoid false negatives — we
|
|
102
|
+
* fail only on demonstrable non-conformance, never on the absence of a tool):
|
|
103
|
+
*
|
|
104
|
+
* 1. Every property the standard marks `required` MUST be present in the
|
|
105
|
+
* advertised schema's properties (a client that follows the standard will
|
|
106
|
+
* send it, so the tool must accept it).
|
|
107
|
+
* 2. For each such required property, when both schemas declare a `type`, the
|
|
108
|
+
* advertised type must be compatible (equal, or a superset, or `integer`
|
|
109
|
+
* accepted where the standard says `number`).
|
|
110
|
+
*
|
|
111
|
+
* Extra advertised properties and looser-but-compatible types are recorded as
|
|
112
|
+
* notes, not violations: a richer reference shape is allowed to extend the
|
|
113
|
+
* bare standard.
|
|
114
|
+
*/
|
|
115
|
+
export function checkConformance(advertised, standard, options = {}) {
|
|
116
|
+
const violations = [];
|
|
117
|
+
const notes = [];
|
|
118
|
+
const matchedRequired = [];
|
|
119
|
+
// Required standard properties this surface may legitimately shed as a
|
|
120
|
+
// documented platform-gated degradation. See {@link ConformanceOptions.shedRequired}.
|
|
121
|
+
const shedRequired = new Set(options.shedRequired ?? []);
|
|
122
|
+
const standardRequired = requiredPropertyNames(standard);
|
|
123
|
+
const standardProps = (standard.properties ?? {});
|
|
124
|
+
const advertisedProps = (advertised.properties ?? {});
|
|
125
|
+
const advertisedNames = objectPropertyNames(advertised);
|
|
126
|
+
for (const name of standardRequired) {
|
|
127
|
+
const present = advertisedNames ? advertisedNames.has(name) : name in advertisedProps;
|
|
128
|
+
if (!present) {
|
|
129
|
+
if (shedRequired.has(name)) {
|
|
130
|
+
notes.push(`degrades platform binding: omits required "${name}" (platform-free target has no publishing surface)`);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
violations.push(`missing required standard property "${name}"`);
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const standardTypes = schemaTypes(standardProps[name]);
|
|
138
|
+
const advertisedTypes = schemaTypes(advertisedProps[name]);
|
|
139
|
+
if (standardTypes.size > 0 && advertisedTypes.size > 0) {
|
|
140
|
+
const compatible = [...standardTypes].every((st) => {
|
|
141
|
+
if (advertisedTypes.has(st)) {
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
// `integer` is a refinement of `number`: a standard `number` is
|
|
145
|
+
// satisfied by an advertised `integer`, and vice versa is acceptable.
|
|
146
|
+
if (st === "number" && advertisedTypes.has("integer")) {
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
if (st === "integer" && advertisedTypes.has("number")) {
|
|
150
|
+
notes.push(`property "${name}" widens standard integer to number`);
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
});
|
|
155
|
+
if (!compatible) {
|
|
156
|
+
violations.push(`property "${name}" type ${[...advertisedTypes].join("|")} is incompatible with standard ${[...standardTypes].join("|")}`);
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
matchedRequired.push(name);
|
|
161
|
+
}
|
|
162
|
+
// Record advertised-only properties as informational coverage notes.
|
|
163
|
+
if (advertisedNames) {
|
|
164
|
+
const standardNames = new Set(Object.keys(standardProps));
|
|
165
|
+
for (const name of advertisedNames) {
|
|
166
|
+
if (!standardNames.has(name)) {
|
|
167
|
+
notes.push(`advertises non-standard property "${name}"`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { conformant: violations.length === 0, matchedRequired, violations, notes };
|
|
172
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
|
+
import type { ContractCheck } from "./certifier.js";
|
|
3
|
+
export interface DeepContractOptions {
|
|
4
|
+
client: Client;
|
|
5
|
+
advertisedToolNames: Set<string>;
|
|
6
|
+
/** Whether the backend is the disposable offline mock (auto-allows mutation). */
|
|
7
|
+
isDisposableBackend: boolean;
|
|
8
|
+
/** Open a fresh unauthenticated client (for the permission-denied case). */
|
|
9
|
+
connectUnauthenticated?: (() => Promise<Client>) | undefined;
|
|
10
|
+
env?: NodeJS.ProcessEnv;
|
|
11
|
+
}
|
|
12
|
+
/** Run all deep contracts, returning one ContractCheck per contract (never throws). */
|
|
13
|
+
export declare function runDeepContracts(options: DeepContractOptions): Promise<ContractCheck[]>;
|
|
14
|
+
//# sourceMappingURL=lifecycle.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"lifecycle.d.ts","sourceRoot":"","sources":["../../../src/certification/lifecycle.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AACxE,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAoCpD,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,mBAAmB,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IACjC,iFAAiF;IACjF,mBAAmB,EAAE,OAAO,CAAC;IAC7B,4EAA4E;IAC5E,sBAAsB,CAAC,EAAE,CAAC,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC,GAAG,SAAS,CAAC;IAC7D,GAAG,CAAC,EAAE,MAAM,CAAC,UAAU,CAAC;CACzB;AAcD,uFAAuF;AACvF,wBAAsB,gBAAgB,CAAC,OAAO,EAAE,mBAAmB,GAAG,OAAO,CAAC,aAAa,EAAE,CAAC,CAW7F"}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { checkAuthContract, checkFeatureRoundTrip, checkJobLifecycle, checkPaginationContract, } from "./contracts.js";
|
|
3
|
+
import { resolveEditTarget } from "./fixtures.js";
|
|
4
|
+
/**
|
|
5
|
+
* Deep, stateful certification contracts (A5 provability): the mutating-tool
|
|
6
|
+
* round-trip, the async job lifecycle, and query pagination. These extend the
|
|
7
|
+
* read-side contracts in {@link certifier} into the parts of a real deployment
|
|
8
|
+
* that were "never certified" — writes and asynchronous execution.
|
|
9
|
+
*
|
|
10
|
+
* Safety: mutating contracts only auto-run against the offline mock (a disposable
|
|
11
|
+
* fixture). Against a live/remote target they are gated behind
|
|
12
|
+
* `HONUA_MCP_CERT_ALLOW_MUTATION=1` + a caller-supplied scratch service, so a
|
|
13
|
+
* scheduled cert against a shared demo never mutates real data. When a target
|
|
14
|
+
* lacks the tool, the contract SKIPS with a loud reason rather than failing — so
|
|
15
|
+
* the suite stays runnable against any revision. Note: the reference honua-server
|
|
16
|
+
* `/mcp` surface does NOT advertise `EDIT_TOOL` — Honua does not support AI
|
|
17
|
+
* operational data editing (honua-server ADR-0028) — so against it these mutating
|
|
18
|
+
* contracts skip. `EDIT_TOOL` here represents the standard's OPTIONAL `mutation`
|
|
19
|
+
* profile, exercised via a mutation-profile-adopter fixture.
|
|
20
|
+
*/
|
|
21
|
+
const MUTATION_ALLOW_ENV = "HONUA_MCP_CERT_ALLOW_MUTATION";
|
|
22
|
+
const EDIT_TOOL = "honua_edit_features";
|
|
23
|
+
const QUERY_TOOL = "honua_query_features";
|
|
24
|
+
const EXECUTE_TOOL = "honua_execute_plan";
|
|
25
|
+
const MAX_JOB_POLLS = 12;
|
|
26
|
+
const PAGE_LIMIT = 2;
|
|
27
|
+
const MAX_FEATURE_PAGES = 20;
|
|
28
|
+
function isTruthy(value) {
|
|
29
|
+
if (typeof value !== "string") {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const v = value.trim().toLowerCase();
|
|
33
|
+
return v.length > 0 && v !== "0" && v !== "false" && v !== "no" && v !== "off";
|
|
34
|
+
}
|
|
35
|
+
function skip(contract, target, detail) {
|
|
36
|
+
return { contract, target, status: "skipped", detail };
|
|
37
|
+
}
|
|
38
|
+
/** Run all deep contracts, returning one ContractCheck per contract (never throws). */
|
|
39
|
+
export async function runDeepContracts(options) {
|
|
40
|
+
const env = options.env ?? process.env;
|
|
41
|
+
const target = resolveEditTarget(env);
|
|
42
|
+
const mutationAllowed = options.isDisposableBackend || isTruthy(env[MUTATION_ALLOW_ENV]);
|
|
43
|
+
return [
|
|
44
|
+
await runMutatingRoundTrip(options, target, mutationAllowed),
|
|
45
|
+
await runMutatingPermissionDenied(options),
|
|
46
|
+
await runJobLifecycle(options, mutationAllowed),
|
|
47
|
+
await runQueryPagination(options, target),
|
|
48
|
+
];
|
|
49
|
+
}
|
|
50
|
+
async function callTool(client, name, args) {
|
|
51
|
+
const result = (await client.callTool({ name, arguments: args }));
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
async function readJsonResource(client, uri) {
|
|
55
|
+
const res = (await client.readResource({ uri }));
|
|
56
|
+
const text = res.contents?.[0]?.text;
|
|
57
|
+
return typeof text === "string" ? JSON.parse(text) : {};
|
|
58
|
+
}
|
|
59
|
+
function featureAttributes(result) {
|
|
60
|
+
const features = (result.structuredContent?.features ?? []);
|
|
61
|
+
return features.map((f) => f.attributes ?? {});
|
|
62
|
+
}
|
|
63
|
+
// ── Contract 1: mutating-tool insert→verify→update→verify→delete→verify ──────
|
|
64
|
+
async function runMutatingRoundTrip(options, target, mutationAllowed) {
|
|
65
|
+
const contract = "mutating-round-trip";
|
|
66
|
+
if (!options.advertisedToolNames.has(EDIT_TOOL) || !options.advertisedToolNames.has(QUERY_TOOL)) {
|
|
67
|
+
return skip(contract, EDIT_TOOL, `${EDIT_TOOL}/${QUERY_TOOL} not advertised by this surface (pre-P1 or read-only)`);
|
|
68
|
+
}
|
|
69
|
+
if (!mutationAllowed) {
|
|
70
|
+
return skip(contract, EDIT_TOOL, `mutating contract disabled for this target — set ${MUTATION_ALLOW_ENV}=1 with a scratch service to certify writes`);
|
|
71
|
+
}
|
|
72
|
+
const { client } = options;
|
|
73
|
+
const marker = `cert-${randomUUID().slice(0, 8)}`;
|
|
74
|
+
const updatedMarker = `${marker}-updated`;
|
|
75
|
+
let objectId;
|
|
76
|
+
try {
|
|
77
|
+
const inserted = await callTool(client, EDIT_TOOL, {
|
|
78
|
+
serviceId: target.serviceId,
|
|
79
|
+
layerId: target.layerId,
|
|
80
|
+
adds: [{ attributes: { NAME: marker } }],
|
|
81
|
+
});
|
|
82
|
+
const addResults = (inserted.structuredContent?.addResults ?? []);
|
|
83
|
+
objectId = typeof addResults[0]?.objectId === "number" ? addResults[0].objectId : undefined;
|
|
84
|
+
const presentAfterInsert = objectId !== undefined &&
|
|
85
|
+
featureAttributes(await callTool(client, QUERY_TOOL, {
|
|
86
|
+
serviceId: target.serviceId,
|
|
87
|
+
layerId: target.layerId,
|
|
88
|
+
objectIds: [objectId],
|
|
89
|
+
})).some((a) => Number(a.OBJECTID) === objectId);
|
|
90
|
+
if (objectId !== undefined) {
|
|
91
|
+
await callTool(client, EDIT_TOOL, {
|
|
92
|
+
serviceId: target.serviceId,
|
|
93
|
+
layerId: target.layerId,
|
|
94
|
+
updates: [{ objectId, attributes: { NAME: updatedMarker } }],
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const attributeAfterUpdate = objectId === undefined
|
|
98
|
+
? undefined
|
|
99
|
+
: featureAttributes(await callTool(client, QUERY_TOOL, {
|
|
100
|
+
serviceId: target.serviceId,
|
|
101
|
+
layerId: target.layerId,
|
|
102
|
+
objectIds: [objectId],
|
|
103
|
+
}))[0]?.NAME;
|
|
104
|
+
if (objectId !== undefined) {
|
|
105
|
+
await callTool(client, EDIT_TOOL, { serviceId: target.serviceId, layerId: target.layerId, deletes: [objectId] });
|
|
106
|
+
}
|
|
107
|
+
const absentAfterDelete = objectId !== undefined &&
|
|
108
|
+
featureAttributes(await callTool(client, QUERY_TOOL, {
|
|
109
|
+
serviceId: target.serviceId,
|
|
110
|
+
layerId: target.layerId,
|
|
111
|
+
objectIds: [objectId],
|
|
112
|
+
})).every((a) => Number(a.OBJECTID) !== objectId);
|
|
113
|
+
const outcome = checkFeatureRoundTrip({
|
|
114
|
+
insertedObjectId: objectId,
|
|
115
|
+
presentAfterInsert,
|
|
116
|
+
attributeAfterUpdate,
|
|
117
|
+
expectedAttribute: updatedMarker,
|
|
118
|
+
absentAfterDelete,
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
contract,
|
|
122
|
+
target: EDIT_TOOL,
|
|
123
|
+
status: outcome.ok ? "passed" : "failed",
|
|
124
|
+
detail: outcome.ok
|
|
125
|
+
? `insert→verify→update→verify→delete→verify round-trip succeeded (objectId ${objectId})`
|
|
126
|
+
: outcome.errors.join("; "),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
catch (err) {
|
|
130
|
+
return {
|
|
131
|
+
contract,
|
|
132
|
+
target: EDIT_TOOL,
|
|
133
|
+
status: "failed",
|
|
134
|
+
detail: `mutating round-trip raised: ${err instanceof Error ? err.message : String(err)}`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
finally {
|
|
138
|
+
// Guaranteed cleanup: remove the scratch feature even if an assertion threw.
|
|
139
|
+
if (objectId !== undefined) {
|
|
140
|
+
await callTool(client, EDIT_TOOL, {
|
|
141
|
+
serviceId: target.serviceId,
|
|
142
|
+
layerId: target.layerId,
|
|
143
|
+
deletes: [objectId],
|
|
144
|
+
}).catch(() => { });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// ── Contract 1b: mutating call without credentials → structured auth error ───
|
|
149
|
+
async function runMutatingPermissionDenied(options) {
|
|
150
|
+
const contract = "mutating-permission-denied";
|
|
151
|
+
if (!options.advertisedToolNames.has(EDIT_TOOL)) {
|
|
152
|
+
return skip(contract, EDIT_TOOL, `${EDIT_TOOL} not advertised by this surface (pre-P1)`);
|
|
153
|
+
}
|
|
154
|
+
if (!options.connectUnauthenticated) {
|
|
155
|
+
return skip(contract, EDIT_TOOL, "target does not support an unauthenticated pass");
|
|
156
|
+
}
|
|
157
|
+
const target = resolveEditTarget(options.env ?? process.env);
|
|
158
|
+
let unauth;
|
|
159
|
+
try {
|
|
160
|
+
unauth = await options.connectUnauthenticated();
|
|
161
|
+
// An unauthenticated write MUST be refused with a structured auth signal. If
|
|
162
|
+
// the boundary holds, nothing is mutated.
|
|
163
|
+
let result;
|
|
164
|
+
try {
|
|
165
|
+
result = await callTool(unauth, EDIT_TOOL, {
|
|
166
|
+
serviceId: target.serviceId,
|
|
167
|
+
layerId: target.layerId,
|
|
168
|
+
adds: [{ attributes: { NAME: "cert-should-be-denied" } }],
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
const outcome = checkAuthContract(err);
|
|
173
|
+
return {
|
|
174
|
+
contract,
|
|
175
|
+
target: EDIT_TOOL,
|
|
176
|
+
status: outcome.ok ? "passed" : "failed",
|
|
177
|
+
detail: outcome.ok ? "unauthenticated write refused with a structured auth error" : outcome.errors.join("; "),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
if (result.isError !== true) {
|
|
181
|
+
return {
|
|
182
|
+
contract,
|
|
183
|
+
target: EDIT_TOOL,
|
|
184
|
+
status: "failed",
|
|
185
|
+
detail: "unauthenticated write returned a non-error result — the write auth boundary is not enforced",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const outcome = checkAuthContract(result);
|
|
189
|
+
return {
|
|
190
|
+
contract,
|
|
191
|
+
target: EDIT_TOOL,
|
|
192
|
+
status: outcome.ok ? "passed" : "failed",
|
|
193
|
+
detail: outcome.ok ? "unauthenticated write refused with a structured auth error" : outcome.errors.join("; "),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
return {
|
|
198
|
+
contract,
|
|
199
|
+
target: EDIT_TOOL,
|
|
200
|
+
status: "failed",
|
|
201
|
+
detail: `permission-denied pass could not be established: ${err instanceof Error ? err.message : String(err)}`,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
if (unauth) {
|
|
206
|
+
await unauth.close().catch(() => { });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// ── Contract 2: async job lifecycle execute → poll → result ──────────────────
|
|
211
|
+
async function runJobLifecycle(options, mutationAllowed) {
|
|
212
|
+
const contract = "async-job-lifecycle";
|
|
213
|
+
if (!options.advertisedToolNames.has(EXECUTE_TOOL)) {
|
|
214
|
+
return skip(contract, EXECUTE_TOOL, `${EXECUTE_TOOL} not advertised by this surface (pre-P1)`);
|
|
215
|
+
}
|
|
216
|
+
if (!mutationAllowed) {
|
|
217
|
+
return skip(contract, EXECUTE_TOOL, `job lifecycle disabled for this target — set ${MUTATION_ALLOW_ENV}=1 to certify execution against a scratch target`);
|
|
218
|
+
}
|
|
219
|
+
const { client } = options;
|
|
220
|
+
try {
|
|
221
|
+
const submitted = await callTool(client, EXECUTE_TOOL, {
|
|
222
|
+
plan: { planId: "cert-plan", steps: [{ stepId: "s1", kind: "QueryFeatures" }] },
|
|
223
|
+
});
|
|
224
|
+
const jobId = submitted.structuredContent?.jobId;
|
|
225
|
+
const initialStatus = String(submitted.structuredContent?.status ?? "Unknown");
|
|
226
|
+
if (typeof jobId !== "string") {
|
|
227
|
+
return { contract, target: EXECUTE_TOOL, status: "failed", detail: "execute did not return a jobId" };
|
|
228
|
+
}
|
|
229
|
+
const jobUri = `honua://jobs/${jobId}`;
|
|
230
|
+
const polledStatuses = [];
|
|
231
|
+
const progressFractions = [];
|
|
232
|
+
let finalStatus = initialStatus;
|
|
233
|
+
let resultUri;
|
|
234
|
+
for (let i = 0; i < MAX_JOB_POLLS; i++) {
|
|
235
|
+
const state = await readJsonResource(client, jobUri);
|
|
236
|
+
const status = String(state.status ?? "Unknown");
|
|
237
|
+
polledStatuses.push(status);
|
|
238
|
+
const fraction = state.progress?.fraction;
|
|
239
|
+
if (typeof fraction === "number") {
|
|
240
|
+
progressFractions.push(fraction);
|
|
241
|
+
}
|
|
242
|
+
finalStatus = status;
|
|
243
|
+
if (typeof state.resultUri === "string") {
|
|
244
|
+
resultUri = state.resultUri;
|
|
245
|
+
}
|
|
246
|
+
if (["Succeeded", "Failed", "Cancelled"].includes(status)) {
|
|
247
|
+
break;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
let resultReady = false;
|
|
251
|
+
if (resultUri) {
|
|
252
|
+
const pkg = await readJsonResource(client, resultUri);
|
|
253
|
+
resultReady = String(pkg.status ?? "") === "Ready";
|
|
254
|
+
}
|
|
255
|
+
const outcome = checkJobLifecycle({
|
|
256
|
+
initialStatus,
|
|
257
|
+
polledStatuses,
|
|
258
|
+
progressFractions,
|
|
259
|
+
finalStatus,
|
|
260
|
+
resultReady,
|
|
261
|
+
});
|
|
262
|
+
return {
|
|
263
|
+
contract,
|
|
264
|
+
target: EXECUTE_TOOL,
|
|
265
|
+
status: outcome.ok ? "passed" : "failed",
|
|
266
|
+
detail: outcome.ok
|
|
267
|
+
? `execute→poll→result honored the state machine (${[initialStatus, ...polledStatuses].join("→")}; result ready)`
|
|
268
|
+
: outcome.errors.join("; "),
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
return {
|
|
273
|
+
contract,
|
|
274
|
+
target: EXECUTE_TOOL,
|
|
275
|
+
status: "failed",
|
|
276
|
+
detail: `job lifecycle raised: ${err instanceof Error ? err.message : String(err)}`,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
// ── Contract 3: query_features pagination (read-only, safe against any target) ─
|
|
281
|
+
async function runQueryPagination(options, target) {
|
|
282
|
+
const contract = "query-pagination";
|
|
283
|
+
if (!options.advertisedToolNames.has(QUERY_TOOL)) {
|
|
284
|
+
return skip(contract, QUERY_TOOL, `${QUERY_TOOL} not advertised by this surface`);
|
|
285
|
+
}
|
|
286
|
+
const { client } = options;
|
|
287
|
+
try {
|
|
288
|
+
const pages = [];
|
|
289
|
+
let cursor;
|
|
290
|
+
for (let i = 0; i < MAX_FEATURE_PAGES; i++) {
|
|
291
|
+
const args = { serviceId: target.serviceId, layerId: target.layerId, limit: PAGE_LIMIT };
|
|
292
|
+
if (cursor) {
|
|
293
|
+
args.cursor = cursor;
|
|
294
|
+
}
|
|
295
|
+
const result = await callTool(client, QUERY_TOOL, args);
|
|
296
|
+
const keys = featureAttributes(result).map((a) => String(a.OBJECTID));
|
|
297
|
+
const nextCursor = result.structuredContent?.nextCursor;
|
|
298
|
+
pages.push({ keys, nextCursor: typeof nextCursor === "string" ? nextCursor : undefined });
|
|
299
|
+
if (typeof nextCursor !== "string") {
|
|
300
|
+
break;
|
|
301
|
+
}
|
|
302
|
+
cursor = nextCursor;
|
|
303
|
+
}
|
|
304
|
+
const paginated = pages.length > 1 || pages[0]?.nextCursor !== undefined;
|
|
305
|
+
if (!paginated) {
|
|
306
|
+
return skip(contract, QUERY_TOOL, `surface returned a single page with no nextCursor (pagination not exercised — pre-P1 surface or fewer than ${PAGE_LIMIT} features)`);
|
|
307
|
+
}
|
|
308
|
+
const outcome = checkPaginationContract(pages);
|
|
309
|
+
return {
|
|
310
|
+
contract,
|
|
311
|
+
target: QUERY_TOOL,
|
|
312
|
+
status: outcome.ok ? "passed" : "failed",
|
|
313
|
+
detail: outcome.ok
|
|
314
|
+
? `query_features paginated across ${pages.length} pages; nextCursor honored and terminated`
|
|
315
|
+
: outcome.errors.join("; "),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
catch (err) {
|
|
319
|
+
return {
|
|
320
|
+
contract,
|
|
321
|
+
target: QUERY_TOOL,
|
|
322
|
+
status: "failed",
|
|
323
|
+
detail: `query pagination raised: ${err instanceof Error ? err.message : String(err)}`,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A real, in-process streamable-HTTP mock of the honua `/mcp` operator surface.
|
|
3
|
+
*
|
|
4
|
+
* The certification harness connects to this over the SAME client transport it
|
|
5
|
+
* uses for a live server (`StreamableHTTPClientTransport`), so the offline CI
|
|
6
|
+
* path exercises the identical connect/negotiate/tools-call code as the remote
|
|
7
|
+
* path — only the upstream is swapped for a deterministic fixture.
|
|
8
|
+
*
|
|
9
|
+
* Auth is enforced per request: the listener parses the bearer token / API key
|
|
10
|
+
* and attaches `req.auth` when present. Requests without a credential reach the
|
|
11
|
+
* operator server as unauthenticated, so the certifier's auth-contract pass sees
|
|
12
|
+
* the same structured `unauthenticated` signal a real deployment would emit.
|
|
13
|
+
*/
|
|
14
|
+
export interface MockUpstreamOptions {
|
|
15
|
+
/** Bearer token the mock accepts; default `mock-cert-token`. */
|
|
16
|
+
authToken?: string;
|
|
17
|
+
/** Whether tools/list and resources/list paginate (default true). */
|
|
18
|
+
paginate?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface MockUpstream {
|
|
21
|
+
/** Absolute URL of the mock `/mcp` endpoint (loopback, ephemeral port). */
|
|
22
|
+
url: string;
|
|
23
|
+
/** Bearer token the mock accepts. */
|
|
24
|
+
authToken: string;
|
|
25
|
+
close(): Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
/** Start the mock operator upstream and resolve once it is listening. */
|
|
28
|
+
export declare function startMockUpstream(options?: MockUpstreamOptions): Promise<MockUpstream>;
|
|
29
|
+
//# sourceMappingURL=mock-upstream.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mock-upstream.d.ts","sourceRoot":"","sources":["../../../src/certification/mock-upstream.ts"],"names":[],"mappings":"AAQA;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,mBAAmB;IAClC,gEAAgE;IAChE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qEAAqE;IACrE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,WAAW,YAAY;IAC3B,2EAA2E;IAC3E,GAAG,EAAE,MAAM,CAAC;IACZ,qCAAqC;IACrC,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAcD,yEAAyE;AACzE,wBAAsB,iBAAiB,CAAC,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,YAAY,CAAC,CA4BhG"}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
4
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
5
|
+
import { createOperatorCatalogServer } from "./operator-catalog.js";
|
|
6
|
+
function readBearer(req) {
|
|
7
|
+
const header = req.headers.authorization;
|
|
8
|
+
if (typeof header === "string" && header.toLowerCase().startsWith("bearer ")) {
|
|
9
|
+
return header.slice(7).trim();
|
|
10
|
+
}
|
|
11
|
+
const apiKey = req.headers["x-api-key"];
|
|
12
|
+
if (typeof apiKey === "string" && apiKey.length > 0) {
|
|
13
|
+
return apiKey;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
/** Start the mock operator upstream and resolve once it is listening. */
|
|
18
|
+
export async function startMockUpstream(options = {}) {
|
|
19
|
+
const authToken = options.authToken ?? "mock-cert-token";
|
|
20
|
+
const paginate = options.paginate ?? true;
|
|
21
|
+
// One transport + server per session (the documented multi-session pattern),
|
|
22
|
+
// so multiple certifier clients — the authenticated pass and the
|
|
23
|
+
// unauthenticated auth-contract pass — can connect concurrently.
|
|
24
|
+
const sessions = new Map();
|
|
25
|
+
const httpServer = createServer((req, res) => {
|
|
26
|
+
void handleHttpRequest(req, res, sessions, authToken, paginate);
|
|
27
|
+
});
|
|
28
|
+
await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve));
|
|
29
|
+
const address = httpServer.address();
|
|
30
|
+
const url = `http://127.0.0.1:${address.port}/mcp`;
|
|
31
|
+
return {
|
|
32
|
+
url,
|
|
33
|
+
authToken,
|
|
34
|
+
async close() {
|
|
35
|
+
for (const transport of sessions.values()) {
|
|
36
|
+
await transport.close().catch(() => { });
|
|
37
|
+
}
|
|
38
|
+
sessions.clear();
|
|
39
|
+
await new Promise((resolve) => httpServer.close(() => resolve()));
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
async function readJsonBody(req) {
|
|
44
|
+
const chunks = [];
|
|
45
|
+
for await (const chunk of req) {
|
|
46
|
+
chunks.push(chunk);
|
|
47
|
+
}
|
|
48
|
+
if (chunks.length === 0) {
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function handleHttpRequest(req, res, sessions, authToken, paginate) {
|
|
59
|
+
const token = readBearer(req);
|
|
60
|
+
if (token && token === authToken) {
|
|
61
|
+
// AuthInfo the transport threads through to request handlers (per request).
|
|
62
|
+
req.auth = { token, clientId: "cert-client", scopes: ["operator"] };
|
|
63
|
+
}
|
|
64
|
+
const sessionId = req.headers["mcp-session-id"];
|
|
65
|
+
const existing = typeof sessionId === "string" ? sessions.get(sessionId) : undefined;
|
|
66
|
+
if (existing) {
|
|
67
|
+
await existing.handleRequest(req, res);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// No session yet — only an initialize request may open one.
|
|
71
|
+
const body = await readJsonBody(req);
|
|
72
|
+
if (!isInitializeRequest(body)) {
|
|
73
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
74
|
+
res.end(JSON.stringify({
|
|
75
|
+
jsonrpc: "2.0",
|
|
76
|
+
error: { code: -32000, message: "Bad Request: no valid session ID provided" },
|
|
77
|
+
id: null,
|
|
78
|
+
}));
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const transport = new StreamableHTTPServerTransport({
|
|
82
|
+
sessionIdGenerator: () => randomUUID(),
|
|
83
|
+
enableJsonResponse: true,
|
|
84
|
+
onsessioninitialized: (id) => {
|
|
85
|
+
sessions.set(id, transport);
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
transport.onclose = () => {
|
|
89
|
+
if (transport.sessionId) {
|
|
90
|
+
sessions.delete(transport.sessionId);
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const server = createOperatorCatalogServer({ paginate });
|
|
94
|
+
await server.connect(transport);
|
|
95
|
+
await transport.handleRequest(req, res, body);
|
|
96
|
+
}
|