@honua/mcp-server 0.1.11-beta.0 → 0.1.13
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 +87 -6
- package/dist/src/certification/admin-parity.js +5 -5
- package/dist/src/proxy.d.ts +30 -14
- package/dist/src/proxy.d.ts.map +1 -1
- package/dist/src/proxy.js +150 -69
- package/package.json +3 -3
- package/release/zero-to-map/configs/claude-code.mcp.json +1 -1
- package/release/zero-to-map/configs/claude-desktop.json +1 -1
- package/release/zero-to-map/configs/cursor.json +1 -1
package/README.md
CHANGED
|
@@ -8,6 +8,9 @@ bound to their own platform; this one is bound to none.
|
|
|
8
8
|
|
|
9
9
|
**Release status: beta.** Tool contracts are certified against live and fixture
|
|
10
10
|
targets on every release; remaining pre-1.0 work is hardening, not surface change.
|
|
11
|
+
"Beta" is a maturity label, not part of the version: releases are stable `0.x`
|
|
12
|
+
versions, and the `@honua/sdk-js` peer is a caret range (for example `^0.1.12`),
|
|
13
|
+
so a later `@honua/sdk-js` patch installs beside this package without a republish.
|
|
11
14
|
|
|
12
15
|
**Scores are published, not claimed.** How well different client models actually
|
|
13
16
|
drive this surface — and where they fail — is in the
|
|
@@ -253,9 +256,34 @@ atomic approval-use store can opt into two additional tools:
|
|
|
253
256
|
expiry, single-use, cancellation, and plan-identity checks. The envelope is required for effects and for every
|
|
254
257
|
read step that names a `sourceId`; only non-source inspection may use read-only auto-execution.
|
|
255
258
|
|
|
256
|
-
|
|
259
|
+
Embedding the server in your own Node.js host needs the packages as local
|
|
260
|
+
dependencies of that host project:
|
|
261
|
+
|
|
262
|
+
```bash
|
|
263
|
+
npm install @honua/mcp-server @honua/sdk-js
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
The map runtime, model callback, approval verifier, receipt signer, and
|
|
267
|
+
approval-use store are yours to supply; the `declare const` lines stand in for them:
|
|
268
|
+
|
|
269
|
+
```ts doc-test=compile
|
|
270
|
+
import { HonuaClient } from "@honua/sdk-js";
|
|
271
|
+
import type {
|
|
272
|
+
AgentApprovalUseConsumer,
|
|
273
|
+
AgentEnvelopeSigner,
|
|
274
|
+
AgentEnvelopeVerifier,
|
|
275
|
+
} from "@honua/sdk-js/agent-safety";
|
|
276
|
+
import type { CreateNlMapControlOptions, NlMapControlToolsOptions } from "@honua/sdk-js/nl-map-control";
|
|
257
277
|
import { createNlMapControlMcpHost, createServer } from "@honua/mcp-server";
|
|
258
278
|
|
|
279
|
+
declare const runtime: NlMapControlToolsOptions["runtime"];
|
|
280
|
+
declare const callYourModel: CreateNlMapControlOptions["llm"];
|
|
281
|
+
declare const approvalVerifier: AgentEnvelopeVerifier;
|
|
282
|
+
declare const receiptSigner: AgentEnvelopeSigner;
|
|
283
|
+
declare const approvalUseConsumer: AgentApprovalUseConsumer;
|
|
284
|
+
|
|
285
|
+
const honuaClient = new HonuaClient({ baseUrl: "https://services.arcgis.com/P3ePLMYs2RVChkJx/arcgis" });
|
|
286
|
+
|
|
259
287
|
const nlMapControl = createNlMapControlMcpHost({
|
|
260
288
|
control: {
|
|
261
289
|
tools: { runtime },
|
|
@@ -308,6 +336,24 @@ For each tool the server advertises (over an in-memory MCP transport) it:
|
|
|
308
336
|
It emits a stable machine-readable JSON report plus a human-readable Markdown
|
|
309
337
|
summary, and exits non-zero on any conformance/round-trip failure.
|
|
310
338
|
|
|
339
|
+
The certification and eval scripts in this section and the next run from a clone
|
|
340
|
+
of this repository (Node.js `>=20.19` and git). `mcp/` links to the SDK at the
|
|
341
|
+
repository root, so build the SDK first:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
git clone --depth 1 https://github.com/honua-io/honua-sdk-js.git
|
|
345
|
+
cd honua-sdk-js
|
|
346
|
+
npm ci
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
```bash
|
|
350
|
+
npm run build # the SDK that mcp/ links to; takes a few minutes
|
|
351
|
+
cd mcp
|
|
352
|
+
npm ci
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
From `mcp/`:
|
|
356
|
+
|
|
311
357
|
```bash
|
|
312
358
|
# Run the certifier against the offline fixture backend and write artifacts:
|
|
313
359
|
npm run certify
|
|
@@ -333,9 +379,13 @@ apportionment layer from `services.arcgis.com`, with **no** Honua surfaces. It
|
|
|
333
379
|
proves the tools certify **green with honest skips** against a non-Honua endpoint:
|
|
334
380
|
the data tools round-trip against real recorded data, the Honua-only style tools
|
|
335
381
|
degrade to structured "not available" results, and the auth/mutation/job contracts
|
|
336
|
-
skip-with-reason. Fixture-backed and deterministic — no network
|
|
382
|
+
skip-with-reason. Fixture-backed and deterministic — no network — as long as
|
|
383
|
+
`HONUA_BASE_URL` is unset: when it is set, this lane certifies that live endpoint
|
|
384
|
+
instead (the scheduled live variant). Unset it if you exported it for `honua-mcp`
|
|
385
|
+
above:
|
|
337
386
|
|
|
338
387
|
```bash
|
|
388
|
+
unset HONUA_BASE_URL
|
|
339
389
|
npm run certify:standalone # platform-free cert (plain FeatureServer fixture)
|
|
340
390
|
npm run test:certification:standalone # CI gate variant
|
|
341
391
|
```
|
|
@@ -387,8 +437,8 @@ The honua server exposes one MCP catalog over streamable-HTTP/SSE at `/mcp`.
|
|
|
387
437
|
Claude-Desktop-style clients speak **stdio**. Rather than reimplement that
|
|
388
438
|
catalog (which is how the HTTP and stdio surfaces historically drifted apart),
|
|
389
439
|
this package ships a **stdio proxy** (`honua-mcp-proxy`) that bridges a local
|
|
390
|
-
stdio MCP client to the remote HTTP-SSE MCP server. It
|
|
391
|
-
|
|
440
|
+
stdio MCP client to the remote HTTP-SSE MCP server. It forwards MCP messages
|
|
441
|
+
between the transports and exposes the *same* catalog downstream over stdio — identical
|
|
392
442
|
tools, identical input/output schemas, identical resources and prompts, and live
|
|
393
443
|
`tools/list_changed` notifications. There is one source-of-truth catalog (the
|
|
394
444
|
server's `/mcp`); the SDK proxies it, so the two transports are symmetric by
|
|
@@ -414,8 +464,33 @@ or `[::1]` HTTP development endpoints. Userinfo, query parameters, fragments,
|
|
|
414
464
|
non-HTTP(S) URLs are rejected before connect, and redirect targets are never
|
|
415
465
|
followed or sent credentials.
|
|
416
466
|
|
|
417
|
-
|
|
418
|
-
|
|
467
|
+
The proxy forwards the caller's `initialize` request before opening an initialized
|
|
468
|
+
upstream session, including `_meta["honua.io/workflow-view"]`. That HTTP session
|
|
469
|
+
is retained for subsequent requests, so initializing with `setup` makes a later
|
|
470
|
+
selector-free `tools/list` return the server's setup catalog. A temporary
|
|
471
|
+
per-request catalog override leaves the initialized view intact; separate proxy
|
|
472
|
+
processes have separate sessions.
|
|
473
|
+
|
|
474
|
+
The view selector is the only `initialize` metadata the server reads, and the
|
|
475
|
+
only metadata the proxy forwards: other `_meta` keys stay local rather than
|
|
476
|
+
entering the credentialed upstream session. The handshake fails closed. A
|
|
477
|
+
request before `initialize`, an `initialize` the server's selector contract
|
|
478
|
+
would refuse (a non-string or longer-than-64-character view), or a second
|
|
479
|
+
`initialize` is answered with JSON-RPC `-32600` without reaching the server,
|
|
480
|
+
and the proxy exits. If `initialize` does not arrive, or the server does not
|
|
481
|
+
answer it, within 30 seconds, the proxy exits as well.
|
|
482
|
+
|
|
483
|
+
Executable protocol tests (`test/proxy-session.test.ts`) exercise setup/default
|
|
484
|
+
initialization, concurrent sessions, authenticated full-catalog override and
|
|
485
|
+
restoration, `tools/list_changed` relay, and the fail-closed handshake against a
|
|
486
|
+
loopback fixture. The `MCP session contract` workflow also runs them against the
|
|
487
|
+
`honua-mcp-proxy` executable npm installs from the packed tarballs. The
|
|
488
|
+
published 0.1.12 reproduction and exact JSON wire captures are under
|
|
489
|
+
`test/fixtures/initialize-view/`.
|
|
490
|
+
|
|
491
|
+
A parity test (`test/proxy.test.ts`) runs the same bridge against the canonical
|
|
492
|
+
server over streamable HTTP. It asserts the tool/resource/template catalog the
|
|
493
|
+
downstream client sees is byte-identical to a direct HTTP client's, that
|
|
419
494
|
`tools/call` and resource reads round-trip identically, and that `list_changed`
|
|
420
495
|
notifications are forwarded.
|
|
421
496
|
|
|
@@ -524,6 +599,12 @@ Shared env for every live run:
|
|
|
524
599
|
Set exactly one of `HONUA_MCP_AUTH_TOKEN` or `HONUA_API_KEY` for authenticated
|
|
525
600
|
live runs; ambiguous authentication fails closed.
|
|
526
601
|
|
|
602
|
+
The commands below read three values you supply from your own accounts: `HONUA_TOKEN`
|
|
603
|
+
(a bearer token your operator deployment issued), and the model provider's
|
|
604
|
+
`ANTHROPIC_API_KEY` or `OPENAI_API_KEY` (the Bedrock driver uses the AWS credential
|
|
605
|
+
chain instead). Export the ones your driver needs before you run it, and run from `mcp/`:
|
|
606
|
+
|
|
607
|
+
<!-- doc-run: skip reason="billable live-model lane: needs the reader's own operator token and Anthropic, OpenAI or AWS model credentials; the offline control above (npm run eval:offline) is the runnable lane" -->
|
|
527
608
|
```bash
|
|
528
609
|
# Anthropic Claude (default claude-opus-4-8; override with HONUA_EVAL_ANTHROPIC_MODEL):
|
|
529
610
|
HONUA_MCP_REMOTE_URL="https://demo.honua.io/mcp" \
|
|
@@ -4,7 +4,7 @@ import { ADMIN_MCP_CONTRACT_SERVER_SHA, ADMIN_MCP_COVERAGE_SHA256, ADMIN_MCP_EXC
|
|
|
4
4
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
5
5
|
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
6
6
|
import { isMainEntrypoint } from "../entrypoint.js";
|
|
7
|
-
import {
|
|
7
|
+
import { connectProxyTransport, connectUpstream, resolveProxyOptions } from "../proxy.js";
|
|
8
8
|
const DEFAULT_EXPECTATION = {
|
|
9
9
|
publishedToolNames: ADMIN_MCP_PUBLISHED_TOOL_NAMES,
|
|
10
10
|
excludedToolNames: ADMIN_MCP_EXCLUDED_OPERATIONS.map((operation) => operation.toolName),
|
|
@@ -202,12 +202,13 @@ export async function runAdminParityCertification(env = process.env, outputPath
|
|
|
202
202
|
if (ADMIN_MCP_CONTRACT_SERVER_SHA === null) {
|
|
203
203
|
throw new Error("Admin MCP live certification is blocked until config/admin-client.v1.json pins the final server head.");
|
|
204
204
|
}
|
|
205
|
-
const
|
|
206
|
-
const
|
|
205
|
+
const options = resolveProxyOptions(env);
|
|
206
|
+
const upstream = await connectUpstream(options);
|
|
207
|
+
// The proxied side runs through the same bridge the stdio executable uses.
|
|
207
208
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
208
209
|
const proxied = new Client({ name: "honua-admin-parity", version: "1.0.0" });
|
|
209
210
|
try {
|
|
210
|
-
await
|
|
211
|
+
await connectProxyTransport(options, serverTransport);
|
|
211
212
|
await proxied.connect(clientTransport);
|
|
212
213
|
const directTools = await listAllTools(upstream);
|
|
213
214
|
const proxiedTools = await listAllTools(proxied);
|
|
@@ -219,7 +220,6 @@ export async function runAdminParityCertification(env = process.env, outputPath
|
|
|
219
220
|
}
|
|
220
221
|
finally {
|
|
221
222
|
await proxied.close().catch(() => { });
|
|
222
|
-
await proxy.close().catch(() => { });
|
|
223
223
|
await upstream.close().catch(() => { });
|
|
224
224
|
}
|
|
225
225
|
}
|
package/dist/src/proxy.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
-
import {
|
|
3
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
4
|
+
import { type InitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
4
5
|
/**
|
|
5
6
|
* Transport-symmetric stdio proxy for the honua MCP surface (honua-server #1950).
|
|
6
7
|
*
|
|
@@ -8,10 +9,12 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
8
9
|
* `/mcp`. Claude-Desktop-style clients speak stdio. Rather than reimplementing
|
|
9
10
|
* the tool/resource catalog (the older `@honua/mcp-server` discovery surface did
|
|
10
11
|
* exactly that, which is how the two halves drifted apart), this proxy bridges a
|
|
11
|
-
* local stdio MCP client to the remote HTTP-SSE MCP server:
|
|
12
|
-
*
|
|
12
|
+
* local stdio MCP client to the remote HTTP-SSE MCP server: each stdio client
|
|
13
|
+
* gets one upstream streamable-HTTP session, opened by the client's own
|
|
14
|
+
* initialize, and every later message is relayed over that session.
|
|
13
15
|
*
|
|
14
|
-
* Because every request and notification is forwarded verbatim
|
|
16
|
+
* Because every request and notification is forwarded verbatim (initialize's
|
|
17
|
+
* `_meta` is narrowed to the workflow-view selector), the stdio
|
|
15
18
|
* surface is transport-symmetric with the HTTP-SSE surface by construction —
|
|
16
19
|
* identical tools, identical input/output schemas, identical resources and
|
|
17
20
|
* prompts, and live `list_changed` notifications. There is one source-of-truth
|
|
@@ -28,19 +31,32 @@ export interface ProxyOptions {
|
|
|
28
31
|
export declare function resolveProxyOptions(env?: NodeJS.ProcessEnv): ProxyOptions;
|
|
29
32
|
/** Build request headers for the upstream connection from the resolved options. */
|
|
30
33
|
export declare function buildUpstreamHeaders(options: ProxyOptions): Record<string, string>;
|
|
31
|
-
/** Connect an upstream MCP client to the remote honua /mcp over streamable HTTP. */
|
|
32
|
-
export declare function connectUpstream(options: ProxyOptions): Promise<Client>;
|
|
33
34
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* (stdio) surface mirrors the upstream (HTTP-SSE) surface.
|
|
35
|
+
* Connect a direct MCP client to the remote honua /mcp over streamable HTTP.
|
|
36
|
+
* Certification and evals use it as the HTTP reference side; the stdio proxy
|
|
37
|
+
* does not, because Client.connect would send its own initialize.
|
|
38
38
|
*/
|
|
39
|
-
export declare function
|
|
39
|
+
export declare function connectUpstream(options: ProxyOptions): Promise<Client>;
|
|
40
|
+
/** The server's only initialize metadata key; the session view selector. */
|
|
41
|
+
export declare const WORKFLOW_VIEW_META_KEY = "honua.io/workflow-view";
|
|
42
|
+
/** Read the optional view selector under the server's own contract. */
|
|
43
|
+
export declare function readWorkflowView(request: InitializeRequest): string | undefined;
|
|
44
|
+
export interface ProxyBridgeOptions {
|
|
45
|
+
/** Bound on initialize arriving and its upstream response; defaults to 30 s. */
|
|
46
|
+
initializeTimeoutMs?: number;
|
|
47
|
+
}
|
|
40
48
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
49
|
+
* Bridge one downstream transport to one upstream HTTP session. In particular,
|
|
50
|
+
* forward the caller's initialize instead of letting Client.connect synthesize
|
|
51
|
+
* an earlier initialize without its metadata. The server owns view selection
|
|
52
|
+
* and request overrides; the proxy never supplies a tools/list selector.
|
|
53
|
+
*
|
|
54
|
+
* The handshake fails closed: traffic before initialize, an initialize the
|
|
55
|
+
* server's selector contract would refuse, a second initialize, or a handshake
|
|
56
|
+
* that does not complete within the bound gets a JSON-RPC error (when it has
|
|
57
|
+
* an id) and closes both transports. Nothing invalid reaches the server.
|
|
44
58
|
*/
|
|
59
|
+
export declare function connectProxyTransport(options: ProxyOptions, downstream: Transport, bridge?: ProxyBridgeOptions): Promise<Transport>;
|
|
60
|
+
/** Run the published stdio executable with the caller's initialize intact. */
|
|
45
61
|
export declare function runProxy(env?: NodeJS.ProcessEnv): Promise<void>;
|
|
46
62
|
//# sourceMappingURL=proxy.d.ts.map
|
package/dist/src/proxy.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/proxy.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;
|
|
1
|
+
{"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/proxy.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAGnE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,+CAA+C,CAAC;AAC/E,OAAO,EAEL,KAAK,iBAAiB,EAQvB,MAAM,oCAAoC,CAAC;AAK5C;;;;;;;;;;;;;;;;;GAiBG;AAEH,MAAM,WAAW,YAAY;IAC3B,sFAAsF;IACtF,SAAS,EAAE,MAAM,CAAC;IAClB,wDAAwD;IACxD,SAAS,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/B,4EAA4E;IAC5E,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CAC7B;AAsBD,wBAAgB,mBAAmB,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,YAAY,CAqBtF;AAED,mFAAmF;AACnF,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUlF;AASD;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAI5E;AAED,4EAA4E;AAC5E,eAAO,MAAM,sBAAsB,2BAA2B,CAAC;AAI/D,uEAAuE;AACvE,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,iBAAiB,GAAG,MAAM,GAAG,SAAS,CAO/E;AAgBD,MAAM,WAAW,kBAAkB;IACjC,gFAAgF;IAChF,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,EAAE,YAAY,EACrB,UAAU,EAAE,SAAS,EACrB,MAAM,GAAE,kBAAuB,GAC9B,OAAO,CAAC,SAAS,CAAC,CA+FpB;AAED,8EAA8E;AAC9E,wBAAsB,QAAQ,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAelF"}
|
package/dist/src/proxy.js
CHANGED
|
@@ -1,18 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
3
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
5
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
-
import {
|
|
5
|
+
import { ErrorCode, InitializeRequestSchema, isInitializedNotification, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, } from "@modelcontextprotocol/sdk/types.js";
|
|
7
6
|
import { requireSecureCredentialEndpoint } from "./credential-endpoint.js";
|
|
8
7
|
import { isMainEntrypoint } from "./entrypoint.js";
|
|
9
8
|
import { SERVER_VERSION } from "./index.js";
|
|
10
|
-
// MCP permits server extensions such as Honua's tools/list `view`. The SDK's
|
|
11
|
-
// nested params schema strips unknown keys by default, so preserve them before
|
|
12
|
-
// forwarding or a stdio caller silently receives the default catalog instead.
|
|
13
|
-
const ForwardedListToolsRequestSchema = ListToolsRequestSchema.extend({
|
|
14
|
-
params: ListToolsRequestSchema.shape.params.unwrap().passthrough().optional(),
|
|
15
|
-
});
|
|
16
9
|
function isConfigured(value) {
|
|
17
10
|
return value !== undefined && value.length > 0;
|
|
18
11
|
}
|
|
@@ -58,84 +51,172 @@ export function buildUpstreamHeaders(options) {
|
|
|
58
51
|
}
|
|
59
52
|
return headers;
|
|
60
53
|
}
|
|
61
|
-
|
|
62
|
-
export async function connectUpstream(options) {
|
|
54
|
+
function createUpstreamTransport(options) {
|
|
63
55
|
const headers = buildUpstreamHeaders(options);
|
|
64
|
-
|
|
65
|
-
const transport = new StreamableHTTPClientTransport(remoteUrl, {
|
|
56
|
+
return new StreamableHTTPClientTransport(validateProxyOptions(options), {
|
|
66
57
|
requestInit: { ...(Object.keys(headers).length > 0 ? { headers } : {}), redirect: "manual" },
|
|
67
58
|
});
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Connect a direct MCP client to the remote honua /mcp over streamable HTTP.
|
|
62
|
+
* Certification and evals use it as the HTTP reference side; the stdio proxy
|
|
63
|
+
* does not, because Client.connect would send its own initialize.
|
|
64
|
+
*/
|
|
65
|
+
export async function connectUpstream(options) {
|
|
68
66
|
const client = new Client({ name: "honua-mcp-stdio-proxy", version: SERVER_VERSION });
|
|
69
|
-
await client.connect(
|
|
67
|
+
await client.connect(createUpstreamTransport(options));
|
|
70
68
|
return client;
|
|
71
69
|
}
|
|
70
|
+
/** The server's only initialize metadata key; the session view selector. */
|
|
71
|
+
export const WORKFLOW_VIEW_META_KEY = "honua.io/workflow-view";
|
|
72
|
+
const MAX_WORKFLOW_VIEW_LENGTH = 64;
|
|
73
|
+
const INITIALIZE_TIMEOUT_MS = 30_000;
|
|
74
|
+
/** Read the optional view selector under the server's own contract. */
|
|
75
|
+
export function readWorkflowView(request) {
|
|
76
|
+
const value = request.params._meta?.[WORKFLOW_VIEW_META_KEY];
|
|
77
|
+
if (value === undefined || value === null)
|
|
78
|
+
return undefined;
|
|
79
|
+
if (typeof value !== "string" || value.length > MAX_WORKFLOW_VIEW_LENGTH) {
|
|
80
|
+
throw new Error("Invalid workflow view selector");
|
|
81
|
+
}
|
|
82
|
+
return value.trim().length === 0 ? undefined : value;
|
|
83
|
+
}
|
|
72
84
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
* (stdio) surface mirrors the upstream (HTTP-SSE) surface.
|
|
85
|
+
* Validate the caller's initialize and narrow its `_meta` to the recognised
|
|
86
|
+
* view selector. Every other field is the caller's own and passes unchanged;
|
|
87
|
+
* other metadata stays local rather than entering the credentialed session.
|
|
77
88
|
*/
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
// ── Tools ──────────────────────────────────────────────────────
|
|
86
|
-
if (upstreamCapabilities.tools) {
|
|
87
|
-
server.setRequestHandler(ForwardedListToolsRequestSchema, async (request) => upstream.listTools(request.params));
|
|
88
|
-
server.setRequestHandler(CallToolRequestSchema, async (request) => upstream.callTool(request.params));
|
|
89
|
-
}
|
|
90
|
-
// ── Resources ──────────────────────────────────────────────────
|
|
91
|
-
if (upstreamCapabilities.resources) {
|
|
92
|
-
server.setRequestHandler(ListResourcesRequestSchema, async (request) => upstream.listResources(request.params));
|
|
93
|
-
server.setRequestHandler(ListResourceTemplatesRequestSchema, async (request) => upstream.listResourceTemplates(request.params));
|
|
94
|
-
server.setRequestHandler(ReadResourceRequestSchema, async (request) => upstream.readResource(request.params));
|
|
95
|
-
}
|
|
96
|
-
// ── Prompts ────────────────────────────────────────────────────
|
|
97
|
-
if (upstreamCapabilities.prompts) {
|
|
98
|
-
server.setRequestHandler(ListPromptsRequestSchema, async (request) => upstream.listPrompts(request.params));
|
|
99
|
-
server.setRequestHandler(GetPromptRequestSchema, async (request) => upstream.getPrompt(request.params));
|
|
100
|
-
}
|
|
101
|
-
// ── list_changed notification forwarding ───────────────────────
|
|
102
|
-
// Keeps the stdio surface live: when the server's catalog changes, the
|
|
103
|
-
// downstream client is told, exactly as an HTTP-SSE client would be.
|
|
104
|
-
upstream.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
|
|
105
|
-
await server.sendToolListChanged();
|
|
106
|
-
});
|
|
107
|
-
upstream.setNotificationHandler(ResourceListChangedNotificationSchema, async () => {
|
|
108
|
-
await server.sendResourceListChanged();
|
|
109
|
-
});
|
|
110
|
-
upstream.setNotificationHandler(PromptListChangedNotificationSchema, async () => {
|
|
111
|
-
await server.sendPromptListChanged();
|
|
112
|
-
});
|
|
113
|
-
return server;
|
|
89
|
+
function upstreamInitialize(message) {
|
|
90
|
+
const view = readWorkflowView(InitializeRequestSchema.parse(message));
|
|
91
|
+
const { _meta: _ignored, ...params } = message.params ?? {};
|
|
92
|
+
return {
|
|
93
|
+
...message,
|
|
94
|
+
params: { ...params, ...(view === undefined ? {} : { _meta: { [WORKFLOW_VIEW_META_KEY]: view } }) },
|
|
95
|
+
};
|
|
114
96
|
}
|
|
115
|
-
/* v8 ignore start -- live-process entry: wires the stdio transport to a real
|
|
116
|
-
remote /mcp upstream; exercised by running the proxy, not by unit tests. The
|
|
117
|
-
unit-testable logic (option/header resolution, upstream connect, catalog
|
|
118
|
-
forwarding) lives in the exported functions above and is covered there. */
|
|
119
97
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
98
|
+
* Bridge one downstream transport to one upstream HTTP session. In particular,
|
|
99
|
+
* forward the caller's initialize instead of letting Client.connect synthesize
|
|
100
|
+
* an earlier initialize without its metadata. The server owns view selection
|
|
101
|
+
* and request overrides; the proxy never supplies a tools/list selector.
|
|
102
|
+
*
|
|
103
|
+
* The handshake fails closed: traffic before initialize, an initialize the
|
|
104
|
+
* server's selector contract would refuse, a second initialize, or a handshake
|
|
105
|
+
* that does not complete within the bound gets a JSON-RPC error (when it has
|
|
106
|
+
* an id) and closes both transports. Nothing invalid reaches the server.
|
|
123
107
|
*/
|
|
124
|
-
export async function
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
108
|
+
export async function connectProxyTransport(options, downstream, bridge = {}) {
|
|
109
|
+
const upstream = createUpstreamTransport(options);
|
|
110
|
+
let closed = false;
|
|
111
|
+
let refused = false;
|
|
112
|
+
let initializeSeen = false;
|
|
113
|
+
let initializeId;
|
|
114
|
+
let initialized = Promise.resolve();
|
|
129
115
|
const shutdown = async () => {
|
|
130
|
-
|
|
131
|
-
|
|
116
|
+
if (closed)
|
|
117
|
+
return;
|
|
118
|
+
closed = true;
|
|
119
|
+
clearTimeout(handshake);
|
|
120
|
+
await Promise.all([downstream.close().catch(() => { }), upstream.close().catch(() => { })]);
|
|
121
|
+
};
|
|
122
|
+
// Reply before closing so the caller learns why its session ended. The
|
|
123
|
+
// message is the proxy's own; nothing from upstream is echoed.
|
|
124
|
+
const refuse = (id, code, reason) => {
|
|
125
|
+
if (closed || refused)
|
|
126
|
+
return;
|
|
127
|
+
refused = true;
|
|
128
|
+
const reply = id === undefined ? Promise.resolve() : downstream.send({ jsonrpc: "2.0", id, error: { code, message: reason } });
|
|
129
|
+
void reply.catch(() => { }).finally(() => shutdown());
|
|
130
|
+
};
|
|
131
|
+
const handshake = setTimeout(() => refuse(initializeId, ErrorCode.RequestTimeout, "Proxy initialization timed out"), bridge.initializeTimeoutMs ?? INITIALIZE_TIMEOUT_MS);
|
|
132
|
+
downstream.onclose = upstream.onclose = () => {
|
|
133
|
+
void shutdown();
|
|
132
134
|
};
|
|
133
|
-
|
|
135
|
+
// HTTP/SSE errors can contain credential-bearing response bodies. Close
|
|
136
|
+
// both transports on synchronous or background failures without echoing
|
|
137
|
+
// those details. In particular, a failed SSE reader must not strand stdio.
|
|
138
|
+
upstream.onerror = () => {
|
|
134
139
|
void shutdown();
|
|
135
140
|
};
|
|
141
|
+
downstream.onmessage = (message) => {
|
|
142
|
+
if (closed || refused)
|
|
143
|
+
return;
|
|
144
|
+
let forwarded = message;
|
|
145
|
+
const isInitialize = isJSONRPCRequest(message) && message.method === "initialize";
|
|
146
|
+
if (isJSONRPCRequest(message) && message.method === "initialize") {
|
|
147
|
+
if (initializeSeen) {
|
|
148
|
+
refuse(message.id, ErrorCode.InvalidRequest, "Duplicate initialize is not permitted");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
forwarded = upstreamInitialize(message);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
refuse(message.id, ErrorCode.InvalidRequest, "Invalid initialize request or workflow view selector");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
initializeSeen = true;
|
|
159
|
+
initializeId = message.id;
|
|
160
|
+
}
|
|
161
|
+
else if (!initializeSeen) {
|
|
162
|
+
refuse(isJSONRPCRequest(message) ? message.id : undefined, ErrorCode.InvalidRequest, "Initialize must be the first request");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const sending = initialized.then(() => upstream.send(forwarded));
|
|
166
|
+
// HTTP POSTs may otherwise overtake initialize (which establishes the
|
|
167
|
+
// session) or the initialized notification. Await their acceptance before
|
|
168
|
+
// subsequent traffic, while allowing later tool calls and cancellation
|
|
169
|
+
// notifications to proceed concurrently.
|
|
170
|
+
if (isInitialize || isInitializedNotification(message))
|
|
171
|
+
initialized = sending;
|
|
172
|
+
void sending.catch(() => {
|
|
173
|
+
void shutdown();
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
upstream.onmessage = (message) => {
|
|
177
|
+
if (initializeId !== undefined &&
|
|
178
|
+
(isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) &&
|
|
179
|
+
message.id === initializeId) {
|
|
180
|
+
clearTimeout(handshake);
|
|
181
|
+
initializeId = undefined;
|
|
182
|
+
if (isJSONRPCResultResponse(message) && typeof message.result.protocolVersion === "string") {
|
|
183
|
+
upstream.setProtocolVersion(message.result.protocolVersion);
|
|
184
|
+
downstream.setProtocolVersion?.(message.result.protocolVersion);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
void downstream.send(message).catch(() => {
|
|
188
|
+
void shutdown();
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
try {
|
|
192
|
+
await upstream.start();
|
|
193
|
+
await downstream.start();
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
await shutdown();
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
return upstream;
|
|
200
|
+
}
|
|
201
|
+
/** Run the published stdio executable with the caller's initialize intact. */
|
|
202
|
+
export async function runProxy(env = process.env) {
|
|
203
|
+
const options = resolveProxyOptions(env);
|
|
136
204
|
const transport = new StdioServerTransport();
|
|
137
|
-
|
|
205
|
+
const close = () => {
|
|
206
|
+
void transport.close().catch(() => { });
|
|
207
|
+
};
|
|
208
|
+
process.stdin.once("end", close);
|
|
209
|
+
process.stdin.once("close", close);
|
|
210
|
+
try {
|
|
211
|
+
await connectProxyTransport(options, transport);
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
process.stdin.off("end", close);
|
|
215
|
+
process.stdin.off("close", close);
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
138
218
|
}
|
|
219
|
+
/* v8 ignore start -- process entry; the transport bridge is tested above. */
|
|
139
220
|
if (isMainEntrypoint(import.meta.url)) {
|
|
140
221
|
runProxy().catch((err) => {
|
|
141
222
|
process.stderr.write(`Fatal: ${err instanceof Error ? err.message : String(err)}\n`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@honua/mcp-server",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
4
|
"description": "Platform-free MCP server for geospatial services: expose discovery, query, and analysis over any public ArcGIS/Esri FeatureServer or OGC API endpoint to any MCP client, with no platform lock-in",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Honua",
|
|
@@ -85,11 +85,11 @@
|
|
|
85
85
|
"overrides": {
|
|
86
86
|
"hono": "^4.13.7",
|
|
87
87
|
"@hono/node-server": "^2.0.5",
|
|
88
|
-
"fast-uri": "^3.1.
|
|
88
|
+
"fast-uri": "^3.1.8",
|
|
89
89
|
"qs": "^6.16.0"
|
|
90
90
|
},
|
|
91
91
|
"peerDependencies": {
|
|
92
|
-
"@honua/sdk-js": "^0.1.
|
|
92
|
+
"@honua/sdk-js": "^0.1.13"
|
|
93
93
|
},
|
|
94
94
|
"devDependencies": {
|
|
95
95
|
"@biomejs/biome": "1.9.4",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"mcpServers": {
|
|
3
3
|
"honua": {
|
|
4
4
|
"command": "npx",
|
|
5
|
-
"args": ["-y", "--package", "@honua/mcp-server@0.1.
|
|
5
|
+
"args": ["-y", "--package", "@honua/mcp-server@0.1.12", "honua-mcp-proxy"],
|
|
6
6
|
"env": {
|
|
7
7
|
"HONUA_MCP_REMOTE_URL": "http://localhost:8080/mcp"
|
|
8
8
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"mcpServers": {
|
|
3
3
|
"honua": {
|
|
4
4
|
"command": "npx",
|
|
5
|
-
"args": ["-y", "--package", "@honua/mcp-server@0.1.
|
|
5
|
+
"args": ["-y", "--package", "@honua/mcp-server@0.1.12", "honua-mcp-proxy"],
|
|
6
6
|
"env": {
|
|
7
7
|
"HONUA_MCP_REMOTE_URL": "http://localhost:8080/mcp"
|
|
8
8
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"mcpServers": {
|
|
3
3
|
"honua": {
|
|
4
4
|
"command": "npx",
|
|
5
|
-
"args": ["-y", "--package", "@honua/mcp-server@0.1.
|
|
5
|
+
"args": ["-y", "--package", "@honua/mcp-server@0.1.12", "honua-mcp-proxy"],
|
|
6
6
|
"env": {
|
|
7
7
|
"HONUA_MCP_REMOTE_URL": "http://localhost:8080/mcp"
|
|
8
8
|
}
|