@honua/mcp-server 0.1.12 → 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 CHANGED
@@ -256,9 +256,34 @@ atomic approval-use store can opt into two additional tools:
256
256
  expiry, single-use, cancellation, and plan-identity checks. The envelope is required for effects and for every
257
257
  read step that names a `sourceId`; only non-source inspection may use read-only auto-execution.
258
258
 
259
- ```ts
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";
260
277
  import { createNlMapControlMcpHost, createServer } from "@honua/mcp-server";
261
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
+
262
287
  const nlMapControl = createNlMapControlMcpHost({
263
288
  control: {
264
289
  tools: { runtime },
@@ -311,6 +336,24 @@ For each tool the server advertises (over an in-memory MCP transport) it:
311
336
  It emits a stable machine-readable JSON report plus a human-readable Markdown
312
337
  summary, and exits non-zero on any conformance/round-trip failure.
313
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
+
314
357
  ```bash
315
358
  # Run the certifier against the offline fixture backend and write artifacts:
316
359
  npm run certify
@@ -336,9 +379,13 @@ apportionment layer from `services.arcgis.com`, with **no** Honua surfaces. It
336
379
  proves the tools certify **green with honest skips** against a non-Honua endpoint:
337
380
  the data tools round-trip against real recorded data, the Honua-only style tools
338
381
  degrade to structured "not available" results, and the auth/mutation/job contracts
339
- 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:
340
386
 
341
387
  ```bash
388
+ unset HONUA_BASE_URL
342
389
  npm run certify:standalone # platform-free cert (plain FeatureServer fixture)
343
390
  npm run test:certification:standalone # CI gate variant
344
391
  ```
@@ -390,8 +437,8 @@ The honua server exposes one MCP catalog over streamable-HTTP/SSE at `/mcp`.
390
437
  Claude-Desktop-style clients speak **stdio**. Rather than reimplement that
391
438
  catalog (which is how the HTTP and stdio surfaces historically drifted apart),
392
439
  this package ships a **stdio proxy** (`honua-mcp-proxy`) that bridges a local
393
- stdio MCP client to the remote HTTP-SSE MCP server. It connects upstream as an
394
- MCP client and re-exposes the *same* catalog downstream over stdio — identical
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
395
442
  tools, identical input/output schemas, identical resources and prompts, and live
396
443
  `tools/list_changed` notifications. There is one source-of-truth catalog (the
397
444
  server's `/mcp`); the SDK proxies it, so the two transports are symmetric by
@@ -417,8 +464,33 @@ or `[::1]` HTTP development endpoints. Userinfo, query parameters, fragments,
417
464
  non-HTTP(S) URLs are rejected before connect, and redirect targets are never
418
465
  followed or sent credentials.
419
466
 
420
- A parity test (`test/proxy.test.ts`) asserts the tool/resource/template catalog
421
- the downstream client sees is byte-identical to the upstream surface, that
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
422
494
  `tools/call` and resource reads round-trip identically, and that `list_changed`
423
495
  notifications are forwarded.
424
496
 
@@ -527,6 +599,12 @@ Shared env for every live run:
527
599
  Set exactly one of `HONUA_MCP_AUTH_TOKEN` or `HONUA_API_KEY` for authenticated
528
600
  live runs; ambiguous authentication fails closed.
529
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" -->
530
608
  ```bash
531
609
  # Anthropic Claude (default claude-opus-4-8; override with HONUA_EVAL_ANTHROPIC_MODEL):
532
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 { connectUpstream, createProxyServer, resolveProxyOptions } from "../proxy.js";
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 upstream = await connectUpstream(resolveProxyOptions(env));
206
- const proxy = createProxyServer(upstream);
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 proxy.connect(serverTransport);
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
  }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
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: it connects upstream
12
- * as an MCP client, then re-exposes the *same* catalog downstream over stdio.
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, the stdio
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
- * Build a low-level MCP server that forwards every request and `list_changed`
35
- * notification to the already-connected upstream client. The proxy advertises
36
- * exactly the upstream's name, version, and capabilities, so the downstream
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 createProxyServer(upstream: Client): Server;
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
- * Run the stdio proxy end-to-end: resolve the remote `/mcp` from the
42
- * environment, connect upstream, and expose the mirrored catalog over stdio.
43
- * Also used by the deprecated `honua-mcp` bin, which now delegates here.
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
@@ -1 +1 @@
1
- {"version":3,"file":"proxy.d.ts","sourceRoot":"","sources":["../../src/proxy.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAEnE,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AA0BnE;;;;;;;;;;;;;;;GAeG;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;AAED,oFAAoF;AACpF,wBAAsB,eAAe,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,MAAM,CAAC,CAS5E;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CA+C1D;AAOD;;;;GAIG;AACH,wBAAsB,QAAQ,CAAC,GAAG,GAAE,MAAM,CAAC,UAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAgBlF"}
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 { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourceTemplatesRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, PromptListChangedNotificationSchema, ReadResourceRequestSchema, ResourceListChangedNotificationSchema, ToolListChangedNotificationSchema, } from "@modelcontextprotocol/sdk/types.js";
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
- /** Connect an upstream MCP client to the remote honua /mcp over streamable HTTP. */
62
- export async function connectUpstream(options) {
54
+ function createUpstreamTransport(options) {
63
55
  const headers = buildUpstreamHeaders(options);
64
- const remoteUrl = validateProxyOptions(options);
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(transport);
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
- * Build a low-level MCP server that forwards every request and `list_changed`
74
- * notification to the already-connected upstream client. The proxy advertises
75
- * exactly the upstream's name, version, and capabilities, so the downstream
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
- export function createProxyServer(upstream) {
79
- const upstreamInfo = upstream.getServerVersion() ?? { name: "honua", version: SERVER_VERSION };
80
- const upstreamCapabilities = upstream.getServerCapabilities() ?? {};
81
- const server = new Server({ name: upstreamInfo.name, version: upstreamInfo.version }, {
82
- capabilities: upstreamCapabilities,
83
- instructions: upstream.getInstructions(),
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
- * Run the stdio proxy end-to-end: resolve the remote `/mcp` from the
121
- * environment, connect upstream, and expose the mirrored catalog over stdio.
122
- * Also used by the deprecated `honua-mcp` bin, which now delegates here.
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 runProxy(env = process.env) {
125
- const options = resolveProxyOptions(env);
126
- const upstream = await connectUpstream(options);
127
- const server = createProxyServer(upstream);
128
- // Tear down both ends together so a dropped upstream surfaces to the client.
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
- await server.close().catch(() => { });
131
- await upstream.close().catch(() => { });
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
- upstream.onclose = () => {
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
- await server.connect(transport);
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.12",
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.6",
88
+ "fast-uri": "^3.1.8",
89
89
  "qs": "^6.16.0"
90
90
  },
91
91
  "peerDependencies": {
92
- "@honua/sdk-js": "^0.1.12"
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.11-beta.0", "honua-mcp-proxy"],
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.11-beta.0", "honua-mcp-proxy"],
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.11-beta.0", "honua-mcp-proxy"],
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
  }