kritya 0.8.15-beta → 0.8.17-beta

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.
@@ -10,7 +10,8 @@ import { isPrivateOrLoopbackHost } from "../net/urlSafety.js";
10
10
  import { checkToolsShape, serverFingerprint } from "../trust/mcpTrust.js";
11
11
  import { probeStdioEra, probeHttpEra } from "./eraDetect.js";
12
12
  import { ModernMcpConnection } from "./clientModern.js";
13
- import { ModernHttpTransport, ReusedProcessTransport } from "./transportModern.js";
13
+ import { ModernHttpTransport, ReusedProcessTransport, validateToolHeaders, } from "./transportModern.js";
14
+ import { checkSchemaSafety } from "./schemaSafety.js";
14
15
  /**
15
16
  * Thrown when a server's live tool shape no longer matches what was recorded
16
17
  * the first time its config was trusted — see checkToolsShape.
@@ -659,6 +660,53 @@ function exposedToolName(server, toolName) {
659
660
  const suffix = `_${shortHash(identity)}`;
660
661
  return base.slice(0, MAX_TOOL_NAME_LEN - suffix.length) + suffix;
661
662
  }
663
+ /**
664
+ * Sub-project 3 (`x-mcp-header` parameter mirroring, HTTP only): validate
665
+ * every modern tool's `inputSchema` for `x-mcp-header` annotations and drop
666
+ * any tool with an invalid one, per spec ("clients MUST exclude the invalid
667
+ * tool from the result of tools/list"). A valid tool's resolved header map
668
+ * is registered on the transport so `tools/call` can mirror argument values
669
+ * into `Mcp-Param-{Name}` headers later — see `ModernHttpTransport.
670
+ * setToolHeaderMap`/`send`. Logged the same way other "skip with a warning"
671
+ * MCP failures are (`process.stderr.write("kritya: ...")`).
672
+ */
673
+ function filterHeaderAnnotatedTools(server, specs, transport) {
674
+ const kept = [];
675
+ for (const spec of specs) {
676
+ const result = validateToolHeaders(spec.inputSchema);
677
+ if (!result.ok) {
678
+ process.stderr.write(`kritya: MCP server "${server}" tool "${spec.name}" excluded — invalid x-mcp-header ` +
679
+ `annotation: ${result.reason}\n`);
680
+ continue;
681
+ }
682
+ transport.setToolHeaderMap(spec.name, result.entries);
683
+ kept.push(spec);
684
+ }
685
+ return kept;
686
+ }
687
+ /**
688
+ * JSON-Schema safety hardening: exclude any tool whose declared `inputSchema`
689
+ * fails `checkSchemaSafety` (an unsupported dialect, a `$ref` that would
690
+ * require a network fetch, or a schema that busts the depth/node-count
691
+ * bounds) before it's ever exposed to the model or the user. Applies to
692
+ * every MCP tool from every server — legacy and modern, stdio and HTTP —
693
+ * unlike `filterHeaderAnnotatedTools`, which is HTTP-only and checks a
694
+ * narrower, unrelated thing. Logged the same way other "skip with a
695
+ * warning" MCP failures are.
696
+ */
697
+ function filterSchemaSafeTools(server, specs) {
698
+ const kept = [];
699
+ for (const spec of specs) {
700
+ const result = checkSchemaSafety(spec.inputSchema);
701
+ if (!result.ok) {
702
+ process.stderr.write(`kritya: MCP server "${server}" tool "${spec.name}" excluded — unsafe inputSchema: ` +
703
+ `${result.reason}\n`);
704
+ continue;
705
+ }
706
+ kept.push(spec);
707
+ }
708
+ return kept;
709
+ }
662
710
  /**
663
711
  * Connect to all configured MCP servers and return their tools as ToolDefs.
664
712
  * Resilient: a server that fails to start is skipped with a warning (and shows
@@ -728,12 +776,20 @@ export async function connectServer(name, cfg, trace) {
728
776
  throw new Error(`server "${name}" sets both "command" and "url"; pick one`);
729
777
  }
730
778
  let modernConn;
779
+ // Set only for the modern+HTTP case — the one combination x-mcp-header
780
+ // mirroring applies to (stdio MAY ignore it per spec, and legacy HTTP
781
+ // never speaks the modern per-request wire format at all).
782
+ let modernHttpTransport;
731
783
  if (cfg.url) {
732
784
  assertSafeUrl(name, cfg.url);
733
785
  const probe = await probeHttpEra(cfg.url, cfg.headers ?? {});
734
786
  if (probe.era === "modern") {
735
787
  if (probe.discover) {
736
- modernConn = new ModernMcpConnection(name, new ModernHttpTransport(cfg.url, cfg.headers ?? {}));
788
+ modernHttpTransport = new ModernHttpTransport(cfg.url, cfg.headers ?? {});
789
+ modernConn = new ModernMcpConnection(name, modernHttpTransport, workspace, {
790
+ onSampling: trace?.onSampling,
791
+ onElicitation: trace?.onElicitation,
792
+ });
737
793
  }
738
794
  else {
739
795
  throw new Error(`server "${name}" speaks the modern MCP protocol but rejected protocol version ` +
@@ -752,7 +808,7 @@ export async function connectServer(name, cfg, trace) {
752
808
  // speak our version, producing a misleading "method not found"-style
753
809
  // error instead of naming the real problem.
754
810
  if (probe.process) {
755
- modernConn = new ModernMcpConnection(name, new ReusedProcessTransport(probe.process));
811
+ modernConn = new ModernMcpConnection(name, new ReusedProcessTransport(probe.process), workspace, { onSampling: trace?.onSampling, onElicitation: trace?.onElicitation });
756
812
  }
757
813
  else {
758
814
  throw new Error(`server "${name}" speaks the modern MCP protocol but rejected protocol version ` +
@@ -767,7 +823,10 @@ export async function connectServer(name, cfg, trace) {
767
823
  onElicitation: trace?.onElicitation,
768
824
  });
769
825
  const listed = await conn.initialize();
770
- const specs = listed.tools;
826
+ const headerFiltered = modernHttpTransport
827
+ ? filterHeaderAnnotatedTools(name, listed.tools, modernHttpTransport)
828
+ : listed.tools;
829
+ const specs = filterSchemaSafeTools(name, headerFiltered);
771
830
  const shape = checkToolsShape(serverFingerprint(cfg), specs, trace?.mcpTrustFile);
772
831
  if (!shape.ok)
773
832
  throw new McpShapeChangedError(name);
@@ -925,7 +984,7 @@ export function replaceStatus(status) {
925
984
  * ElicitationFields the UI can render. Anything with nesting or an
926
985
  * unrecognized type is rejected outright, rather than guessed at.
927
986
  */
928
- function toElicitationFields(schema) {
987
+ export function toElicitationFields(schema) {
929
988
  const props = schema.properties ?? {};
930
989
  return Object.entries(props).map(([name, prop]) => {
931
990
  const label = prop.title ?? name;
@@ -1,4 +1,7 @@
1
+ import path from "node:path";
2
+ import { pathToFileURL } from "node:url";
1
3
  import { modernMeta } from "./eraDetect.js";
4
+ import { toElicitationFields } from "./client.js";
2
5
  /**
3
6
  * Split the same way legacy McpConnection splits its timeouts: connect-time
4
7
  * calls (the three list calls during initialize()) get a short ceiling since
@@ -7,23 +10,38 @@ import { modernMeta } from "./eraDetect.js";
7
10
  */
8
11
  const CONNECT_TIMEOUT_MS = 15_000;
9
12
  const CALL_TIMEOUT_MS = 120_000;
13
+ /** MRTR round-trip ceiling — a misbehaving server can't loop us forever. */
14
+ const MAX_MRTR_ROUNDS = 10;
10
15
  /**
11
16
  * Modern (2026-07-28+) MCP client: no `initialize`, every request carries
12
17
  * its own `_meta`. Implements the same minimal surface as the legacy
13
18
  * `McpConnection` (see docs/superpowers/specs/2026-08-30-mcp-modern-protocol-design.md)
14
- * so `connectServer()` can use either interchangeably. `resultType:
15
- * "input_required"` (MRTR — sampling/elicitation/roots) is rejected with a
16
- * clear error here; Sub-project 2 replaces this with the real retry loop.
19
+ * so `connectServer()` can use either interchangeably.
20
+ *
21
+ * MRTR (sampling/elicitation/roots): a modern server never sends its own
22
+ * JSON-RPC request for these — instead a response comes back with
23
+ * `resultType: "input_required"` and an `inputRequests` map. `requestWithMRTR`
24
+ * answers each entry (via `answerInputRequired`, modeled on legacy's
25
+ * `onServerRequest`/`McpConnection.answerInputRequired`) and resends the
26
+ * original request with `inputResponses` attached, repeating until the
27
+ * server returns `resultType: "complete"` (or errors) — bounded by
28
+ * `MAX_MRTR_ROUNDS` so a misbehaving server can't loop forever.
17
29
  */
18
30
  export class ModernMcpConnection {
19
31
  name;
20
32
  transport;
33
+ workspace;
34
+ options;
21
35
  nextId = 1;
22
36
  pending = new Map();
23
37
  closed = false;
24
- constructor(name, transport) {
38
+ constructor(name, transport,
39
+ /** The workspace this session is working on — what `roots/list` reports. */
40
+ workspace = process.cwd(), options = {}) {
25
41
  this.name = name;
26
42
  this.transport = transport;
43
+ this.workspace = workspace;
44
+ this.options = options;
27
45
  transport.onMessage = (msg) => this.onMessage(msg);
28
46
  transport.onError = (err) => this.fail(new Error(`MCP server "${name}": ${err.message}`));
29
47
  }
@@ -50,6 +68,16 @@ export class ModernMcpConnection {
50
68
  else
51
69
  p.resolve(msg.result);
52
70
  }
71
+ /** The capabilities this connection can actually back, for `_meta.clientCapabilities` —
72
+ * only claim what a callback is actually configured to answer, plus roots (always local). */
73
+ clientCapabilities() {
74
+ const caps = { roots: {} };
75
+ if (this.options.onSampling)
76
+ caps.sampling = {};
77
+ if (this.options.onElicitation)
78
+ caps.elicitation = {};
79
+ return caps;
80
+ }
53
81
  request(method, params, timeoutMs, signal) {
54
82
  if (this.closed)
55
83
  return Promise.reject(new Error(`MCP server "${this.name}" is not running`));
@@ -61,7 +89,12 @@ export class ModernMcpConnection {
61
89
  }, timeoutMs);
62
90
  this.pending.set(id, { resolve, reject, timer });
63
91
  this.transport
64
- .send({ jsonrpc: "2.0", id, method, params: { ...params, _meta: modernMeta() } }, timeoutMs, signal)
92
+ .send({
93
+ jsonrpc: "2.0",
94
+ id,
95
+ method,
96
+ params: { ...params, _meta: modernMeta(this.clientCapabilities()) },
97
+ }, timeoutMs, signal)
65
98
  .catch((err) => {
66
99
  const p = this.pending.get(id);
67
100
  if (!p)
@@ -72,31 +105,105 @@ export class ModernMcpConnection {
72
105
  });
73
106
  });
74
107
  }
75
- /** Unwrap a modern result's resultType, throwing for input_required (MRTR — Sub-project 2). */
76
- unwrap(result) {
77
- const r = result;
78
- if (r.resultType === "input_required") {
79
- throw new Error(`MCP server "${this.name}" requires an interactive capability (sampling, elicitation, ` +
80
- `or roots) that modern-mode kritya does not yet support`);
81
- }
82
- if (r.resultType !== undefined && r.resultType !== "complete") {
108
+ /**
109
+ * Send `method`/`params`, answering any `input_required` rounds (MRTR) and
110
+ * resending with `inputResponses` until the server completes or errors.
111
+ */
112
+ async requestWithMRTR(method, params, timeoutMs, signal) {
113
+ let currentParams = params;
114
+ for (let round = 0; round < MAX_MRTR_ROUNDS; round++) {
115
+ const result = await this.request(method, currentParams, timeoutMs, signal);
116
+ const r = result;
117
+ if (r.resultType === undefined || r.resultType === "complete") {
118
+ return result;
119
+ }
120
+ if (r.resultType === "input_required") {
121
+ const inputRequired = result;
122
+ const inputResponses = await this.answerInputRequired(inputRequired.inputRequests ?? {});
123
+ currentParams = { ...params, inputResponses };
124
+ continue;
125
+ }
83
126
  throw new Error(`MCP server "${this.name}" returned an unrecognized resultType "${r.resultType}"`);
84
127
  }
85
- return result;
128
+ throw new Error(`MCP server "${this.name}" requested input more than ${MAX_MRTR_ROUNDS} times in a row for ` +
129
+ `"${method}" — giving up`);
130
+ }
131
+ /**
132
+ * Answer every `inputRequests` entry (sampling/elicitation/roots), same
133
+ * capability dispatch and field/result shapes as legacy's
134
+ * `McpConnection.onServerRequest` — but building an `inputResponses` map
135
+ * to send back in the retry rather than replying to a server-sent request.
136
+ */
137
+ async answerInputRequired(inputRequests) {
138
+ const inputResponses = {};
139
+ for (const [key, req] of Object.entries(inputRequests)) {
140
+ if (req.method === "roots/list") {
141
+ inputResponses[key] = {
142
+ roots: [{ uri: pathToFileURL(this.workspace).href, name: path.basename(this.workspace) }],
143
+ };
144
+ continue;
145
+ }
146
+ if (req.method === "sampling/createMessage") {
147
+ if (!this.options.onSampling) {
148
+ throw new Error(`MCP server "${this.name}" requires sampling, but sampling is not supported here`);
149
+ }
150
+ const p = req.params;
151
+ const samplingReq = {
152
+ server: this.name,
153
+ messages: (p?.messages ?? []).map((m) => ({
154
+ role: m.role === "assistant" ? "assistant" : "user",
155
+ content: m.content?.text ?? "",
156
+ })),
157
+ systemPrompt: p?.systemPrompt,
158
+ maxTokens: p?.maxTokens,
159
+ };
160
+ const result = await this.options.onSampling(this.name, samplingReq);
161
+ if (!result.ok) {
162
+ inputResponses[key] = { error: { code: -32603, message: result.reason } };
163
+ continue;
164
+ }
165
+ inputResponses[key] = {
166
+ role: "assistant",
167
+ content: { type: "text", text: result.content },
168
+ model: result.model,
169
+ stopReason: result.stopReason,
170
+ };
171
+ continue;
172
+ }
173
+ if (req.method === "elicitation/create") {
174
+ if (!this.options.onElicitation) {
175
+ throw new Error(`MCP server "${this.name}" requires elicitation, but elicitation is not supported here`);
176
+ }
177
+ const p = req.params;
178
+ let fields;
179
+ try {
180
+ fields = toElicitationFields(p?.requestedSchema ?? {});
181
+ }
182
+ catch (err) {
183
+ throw new Error(`MCP server "${this.name}" sent an unsupported elicitation schema: ` +
184
+ (err instanceof Error ? err.message : String(err)), { cause: err });
185
+ }
186
+ const result = await this.options.onElicitation(this.name, p?.message ?? "", fields);
187
+ inputResponses[key] = result;
188
+ continue;
189
+ }
190
+ throw new Error(`MCP server "${this.name}" requires unsupported input method "${req.method}"`);
191
+ }
192
+ return inputResponses;
86
193
  }
87
194
  async initialize() {
88
- const listed = this.unwrap(await this.request("tools/list", {}, CONNECT_TIMEOUT_MS));
195
+ const listed = await this.requestWithMRTR("tools/list", {}, CONNECT_TIMEOUT_MS);
89
196
  let prompts = [];
90
197
  let resources = [];
91
198
  try {
92
- const p = this.unwrap(await this.request("prompts/list", {}, CONNECT_TIMEOUT_MS));
199
+ const p = await this.requestWithMRTR("prompts/list", {}, CONNECT_TIMEOUT_MS);
93
200
  prompts = p.prompts ?? [];
94
201
  }
95
202
  catch {
96
203
  // prompts unsupported by this server — non-fatal, same as legacy behavior
97
204
  }
98
205
  try {
99
- const r = this.unwrap(await this.request("resources/list", {}, CONNECT_TIMEOUT_MS));
206
+ const r = await this.requestWithMRTR("resources/list", {}, CONNECT_TIMEOUT_MS);
100
207
  resources = r.resources ?? [];
101
208
  }
102
209
  catch {
@@ -105,7 +212,7 @@ export class ModernMcpConnection {
105
212
  return { tools: listed.tools ?? [], prompts, resources };
106
213
  }
107
214
  async getPrompt(name, args) {
108
- const result = this.unwrap(await this.request("prompts/get", { name, arguments: args }, CALL_TIMEOUT_MS));
215
+ const result = await this.requestWithMRTR("prompts/get", { name, arguments: args }, CALL_TIMEOUT_MS);
109
216
  const messages = result.messages ?? [];
110
217
  const multiRole = new Set(messages.map((m) => m.role ?? "user")).size > 1;
111
218
  return messages
@@ -117,7 +224,7 @@ export class ModernMcpConnection {
117
224
  .join("\n\n");
118
225
  }
119
226
  async readResource(uri) {
120
- const result = this.unwrap(await this.request("resources/read", { uri }, CALL_TIMEOUT_MS));
227
+ const result = await this.requestWithMRTR("resources/read", { uri }, CALL_TIMEOUT_MS);
121
228
  return (result.contents ?? []).map((c) => c.text ?? "").join("\n");
122
229
  }
123
230
  async callTool(toolName, args, signal, _tasksEnabled, _onProgress) {
@@ -125,7 +232,7 @@ export class ModernMcpConnection {
125
232
  // scope"); the params exist only so this signature matches
126
233
  // McpConnection.callTool's, which callers invoke uniformly through the
127
234
  // McpConnection | ModernMcpConnection union built in Task 6.
128
- const result = this.unwrap(await this.request("tools/call", { name: toolName, arguments: args }, CALL_TIMEOUT_MS, signal));
235
+ const result = await this.requestWithMRTR("tools/call", { name: toolName, arguments: args }, CALL_TIMEOUT_MS, signal);
129
236
  const text = (result.content ?? [])
130
237
  .filter((b) => b.type === "text")
131
238
  .map((b) => b.text ?? "")
@@ -0,0 +1,178 @@
1
+ /**
2
+ * JSON-Schema dialect / `$ref` / composition-keyword safety hardening for
3
+ * MCP-declared tool `inputSchema`s.
4
+ *
5
+ * kritya never runs a JSON Schema *validator* against tool-call arguments —
6
+ * the model fills tool arguments and kritya forwards `inputSchema` to the
7
+ * provider's function-calling API as opaque JSON. What kritya does expose,
8
+ * unfiltered, is every MCP-declared tool's `inputSchema` to the agent and to
9
+ * the user. This module is the registration-time gate that keeps an unsafe
10
+ * schema from ever reaching either: see the spec's "JSON Schema Usage"
11
+ * section (2026-07-28/basic) for the three requirements this implements.
12
+ *
13
+ * This is a separate, general-purpose check from `validateToolHeaders` in
14
+ * transportModern.ts, which walks a schema for a narrow, HTTP-only purpose
15
+ * (finding/validating `x-mcp-header` annotations) and only runs for
16
+ * modern+HTTP servers. This module applies to every tool from every server,
17
+ * legacy or modern, stdio or HTTP.
18
+ */
19
+ /**
20
+ * Dialect strings accepted as "JSON Schema 2020-12", the spec's default and
21
+ * required dialect. The canonical URI is
22
+ * "https://json-schema.org/draft/2020-12/schema"; real-world schemas vary in
23
+ * small, harmless ways (a trailing slash, a trailing `#` fragment, or the
24
+ * legacy `http://` scheme some tooling still emits for a `https://`-canonical
25
+ * URI), so this list is deliberately a little lenient about exact string
26
+ * matching without accepting anything that names a genuinely different
27
+ * dialect (draft-07, draft-04, an unrecognized string, etc.).
28
+ */
29
+ const ACCEPTED_2020_12_DIALECTS = new Set([
30
+ "https://json-schema.org/draft/2020-12/schema",
31
+ "https://json-schema.org/draft/2020-12/schema#",
32
+ "https://json-schema.org/draft/2020-12/schema/",
33
+ "http://json-schema.org/draft/2020-12/schema",
34
+ "http://json-schema.org/draft/2020-12/schema#",
35
+ "http://json-schema.org/draft/2020-12/schema/",
36
+ ]);
37
+ /**
38
+ * Bounds on the untrusted-schema walk below, in the same spirit as (and with
39
+ * the same values as) validateToolHeaders's walk in transportModern.ts. A
40
+ * malicious or buggy server's inputSchema must not be able to hang or crash
41
+ * kritya, or run a validator out of memory, while it's inspected.
42
+ */
43
+ const MAX_SCHEMA_DEPTH = 50;
44
+ const MAX_SCHEMA_NODES = 5000;
45
+ const COMPOSITION_KEYWORDS = ["oneOf", "anyOf", "allOf"];
46
+ const CONDITIONAL_KEYWORDS = ["if", "then", "else"];
47
+ function isPlainObject(v) {
48
+ return v !== null && typeof v === "object" && !Array.isArray(v);
49
+ }
50
+ /** True when a `$ref` value would require a network fetch to resolve. */
51
+ function isNetworkRef(ref) {
52
+ try {
53
+ const url = new URL(ref);
54
+ return url.protocol === "http:" || url.protocol === "https:";
55
+ }
56
+ catch {
57
+ // Not an absolute URI at all (e.g. "#/$defs/foo", a relative path) — no
58
+ // network fetch is implied.
59
+ return false;
60
+ }
61
+ }
62
+ /**
63
+ * Check one MCP tool's declared `inputSchema` (or `outputSchema`, or any
64
+ * other JSON-Schema-shaped value) for the three safety requirements the MCP
65
+ * spec calls out under "JSON Schema Usage":
66
+ *
67
+ * 1. Dialect: an absent `$schema` defaults to 2020-12 (fine); a `$schema`
68
+ * naming anything other than 2020-12 is rejected, since kritya only
69
+ * supports the required dialect today.
70
+ * 2. `$ref` resolution: kritya never dereferences a remote `$ref` (there is
71
+ * no such code anywhere in this codebase, and this check doesn't add
72
+ * any) — so a `$ref` that names an http(s) URI is rejected outright
73
+ * rather than silently treated as permissive. A local pointer (`#...`)
74
+ * is normal same-document JSON Schema and is left alone.
75
+ * 3. Bounds: the walk into `properties`, `items`/`prefixItems`,
76
+ * `oneOf`/`anyOf`/`allOf`, `not`, `if`/`then`/`else`, and
77
+ * `$defs`/`definitions` is capped on depth and total node count, so a
78
+ * pathological schema can't act as a DoS vector.
79
+ */
80
+ export function checkSchemaSafety(schema) {
81
+ if (!isPlainObject(schema))
82
+ return { ok: true };
83
+ const dialect = schema["$schema"];
84
+ if (dialect !== undefined) {
85
+ if (typeof dialect !== "string") {
86
+ return { ok: false, reason: `"$schema" must be a string, got ${typeof dialect}` };
87
+ }
88
+ if (!ACCEPTED_2020_12_DIALECTS.has(dialect)) {
89
+ return {
90
+ ok: false,
91
+ reason: `unsupported JSON Schema dialect "${dialect}" — kritya only supports 2020-12`,
92
+ };
93
+ }
94
+ }
95
+ let nodeCount = 0;
96
+ function visit(node, depth) {
97
+ if (!isPlainObject(node))
98
+ return undefined;
99
+ nodeCount++;
100
+ if (nodeCount > MAX_SCHEMA_NODES) {
101
+ return `inputSchema exceeds the maximum node count (${MAX_SCHEMA_NODES})`;
102
+ }
103
+ if (depth > MAX_SCHEMA_DEPTH) {
104
+ return `inputSchema exceeds the maximum nesting depth (${MAX_SCHEMA_DEPTH})`;
105
+ }
106
+ if (Object.prototype.hasOwnProperty.call(node, "$ref")) {
107
+ const ref = node["$ref"];
108
+ if (typeof ref === "string" && isNetworkRef(ref)) {
109
+ return (`"$ref" resolves to a network URI ("${ref}") — kritya never dereferences a remote ` +
110
+ `$ref, so this schema can't be validated`);
111
+ }
112
+ }
113
+ if (isPlainObject(node.properties)) {
114
+ for (const val of Object.values(node.properties)) {
115
+ const err = visit(val, depth + 1);
116
+ if (err)
117
+ return err;
118
+ }
119
+ }
120
+ if ("items" in node) {
121
+ const items = node.items;
122
+ if (Array.isArray(items)) {
123
+ for (const it of items) {
124
+ const err = visit(it, depth + 1);
125
+ if (err)
126
+ return err;
127
+ }
128
+ }
129
+ else {
130
+ const err = visit(items, depth + 1);
131
+ if (err)
132
+ return err;
133
+ }
134
+ }
135
+ if (Array.isArray(node.prefixItems)) {
136
+ for (const it of node.prefixItems) {
137
+ const err = visit(it, depth + 1);
138
+ if (err)
139
+ return err;
140
+ }
141
+ }
142
+ for (const kw of COMPOSITION_KEYWORDS) {
143
+ if (Array.isArray(node[kw])) {
144
+ for (const sub of node[kw]) {
145
+ const err = visit(sub, depth + 1);
146
+ if (err)
147
+ return err;
148
+ }
149
+ }
150
+ }
151
+ if ("not" in node) {
152
+ const err = visit(node.not, depth + 1);
153
+ if (err)
154
+ return err;
155
+ }
156
+ for (const kw of CONDITIONAL_KEYWORDS) {
157
+ if (kw in node) {
158
+ const err = visit(node[kw], depth + 1);
159
+ if (err)
160
+ return err;
161
+ }
162
+ }
163
+ for (const defsKey of ["$defs", "definitions"]) {
164
+ if (isPlainObject(node[defsKey])) {
165
+ for (const val of Object.values(node[defsKey])) {
166
+ const err = visit(val, depth + 1);
167
+ if (err)
168
+ return err;
169
+ }
170
+ }
171
+ }
172
+ return undefined;
173
+ }
174
+ const err = visit(schema, 0);
175
+ if (err)
176
+ return { ok: false, reason: err };
177
+ return { ok: true };
178
+ }
@@ -31,6 +31,192 @@ export function encodeHeaderValue(value) {
31
31
  return value;
32
32
  return `=?base64?${Buffer.from(value, "utf8").toString("base64")}?=`;
33
33
  }
34
+ // ---------------------------------------------------------------------------
35
+ // x-mcp-header parameter mirroring (Sub-project 3, HTTP only)
36
+ //
37
+ // See /specification/2026-07-28/basic/transports/streamable-http, "Custom
38
+ // Headers from Tool Parameters". A server MAY annotate a primitive tool
39
+ // parameter with `x-mcp-header: "Name"` inside `inputSchema`; a conforming
40
+ // client mirrors that argument's value into an `Mcp-Param-{Name}` header on
41
+ // `tools/call`. This is entirely a client-side, HTTP-transport-only concern
42
+ // (stdio MAY ignore it) — none of it touches `ModernMcpConnection`'s
43
+ // request/MRTR internals.
44
+ // ---------------------------------------------------------------------------
45
+ /** `1*tchar` per RFC 9110 §5.1 — the token syntax HTTP field names must satisfy. */
46
+ const TCHAR_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
47
+ /**
48
+ * Bounds on the untrusted-schema walk below: a malicious or buggy server's
49
+ * `inputSchema` must not be able to hang or crash the client while we scan
50
+ * it for `x-mcp-header` annotations. Either bound being exceeded rejects the
51
+ * schema (same as an invalid annotation — the whole tool is excluded).
52
+ */
53
+ const MAX_SCHEMA_DEPTH = 50;
54
+ const MAX_SCHEMA_NODES = 5000;
55
+ /** Validate one `x-mcp-header` value against every constraint the spec lists. */
56
+ function checkHeaderName(name) {
57
+ if (name.length === 0)
58
+ return `x-mcp-header value must not be empty`;
59
+ for (let i = 0; i < name.length; i++) {
60
+ const code = name.charCodeAt(i);
61
+ if (code <= 0x1f || code === 0x7f) {
62
+ return `x-mcp-header value "${name}" contains a control character`;
63
+ }
64
+ }
65
+ if (!TCHAR_RE.test(name)) {
66
+ return `x-mcp-header value "${name}" is not a valid HTTP field-name token (RFC 9110 §5.1)`;
67
+ }
68
+ return undefined;
69
+ }
70
+ const COMPOSITION_KEYWORDS = ["oneOf", "anyOf", "allOf"];
71
+ const CONDITIONAL_KEYWORDS = ["if", "then", "else"];
72
+ /**
73
+ * Walk a tool's `inputSchema` looking for `x-mcp-header` annotations,
74
+ * validating every constraint from the spec's Schema Extension section:
75
+ * non-empty, HTTP token syntax, no control chars, case-insensitively unique,
76
+ * primitive type only, and statically reachable from the root via a chain of
77
+ * only `properties` keys (no `items`, composition/conditional keywords, or
78
+ * `$ref` anywhere in the path). Bounded recursion/node-count guards against
79
+ * a pathological schema from an untrusted server.
80
+ *
81
+ * Returns every valid annotation found, or the first reason the whole tool
82
+ * must be rejected (per spec: one invalid annotation invalidates the tool).
83
+ */
84
+ export function validateToolHeaders(inputSchema) {
85
+ const entries = [];
86
+ let nodeCount = 0;
87
+ function visit(node, path, reachable, depth) {
88
+ if (node === null || typeof node !== "object" || Array.isArray(node))
89
+ return undefined;
90
+ nodeCount++;
91
+ if (nodeCount > MAX_SCHEMA_NODES)
92
+ return `inputSchema exceeds the maximum node count (${MAX_SCHEMA_NODES})`;
93
+ if (depth > MAX_SCHEMA_DEPTH)
94
+ return `inputSchema exceeds the maximum nesting depth (${MAX_SCHEMA_DEPTH})`;
95
+ const obj = node;
96
+ if (Object.prototype.hasOwnProperty.call(obj, "x-mcp-header")) {
97
+ const raw = obj["x-mcp-header"];
98
+ if (typeof raw !== "string")
99
+ return `x-mcp-header must be a string`;
100
+ if (!reachable) {
101
+ return (`x-mcp-header "${raw}" is not statically reachable from the schema root via a ` +
102
+ `chain of only "properties" keys`);
103
+ }
104
+ if (Object.prototype.hasOwnProperty.call(obj, "$ref")) {
105
+ return (`x-mcp-header "${raw}" is co-located with "$ref" on the same schema node; the ` +
106
+ `resolved shape comes from the ref target, so this annotation is not statically ` +
107
+ `reachable`);
108
+ }
109
+ const nameErr = checkHeaderName(raw);
110
+ if (nameErr)
111
+ return nameErr;
112
+ const type = obj["type"];
113
+ if (type !== "string" && type !== "integer" && type !== "boolean") {
114
+ return (`x-mcp-header "${raw}" is applied to a property of type ` +
115
+ `${typeof type === "string" ? `"${type}"` : "unknown"}, but only string/integer/boolean are permitted`);
116
+ }
117
+ entries.push({ path, header: raw });
118
+ }
119
+ if (obj.properties && typeof obj.properties === "object" && !Array.isArray(obj.properties)) {
120
+ for (const [key, val] of Object.entries(obj.properties)) {
121
+ const err = visit(val, [...path, key], reachable, depth + 1);
122
+ if (err)
123
+ return err;
124
+ }
125
+ }
126
+ if ("items" in obj) {
127
+ const items = obj.items;
128
+ if (Array.isArray(items)) {
129
+ for (const it of items) {
130
+ const err = visit(it, path, false, depth + 1);
131
+ if (err)
132
+ return err;
133
+ }
134
+ }
135
+ else {
136
+ const err = visit(items, path, false, depth + 1);
137
+ if (err)
138
+ return err;
139
+ }
140
+ }
141
+ if (Array.isArray(obj.prefixItems)) {
142
+ for (const it of obj.prefixItems) {
143
+ const err = visit(it, path, false, depth + 1);
144
+ if (err)
145
+ return err;
146
+ }
147
+ }
148
+ for (const kw of COMPOSITION_KEYWORDS) {
149
+ if (Array.isArray(obj[kw])) {
150
+ for (const sub of obj[kw]) {
151
+ const err = visit(sub, path, false, depth + 1);
152
+ if (err)
153
+ return err;
154
+ }
155
+ }
156
+ }
157
+ if ("not" in obj) {
158
+ const err = visit(obj.not, path, false, depth + 1);
159
+ if (err)
160
+ return err;
161
+ }
162
+ for (const kw of CONDITIONAL_KEYWORDS) {
163
+ if (kw in obj) {
164
+ const err = visit(obj[kw], path, false, depth + 1);
165
+ if (err)
166
+ return err;
167
+ }
168
+ }
169
+ return undefined;
170
+ }
171
+ const err = visit(inputSchema, [], true, 0);
172
+ if (err)
173
+ return { ok: false, reason: err };
174
+ const seenLower = new Set();
175
+ for (const e of entries) {
176
+ const lower = e.header.toLowerCase();
177
+ if (seenLower.has(lower)) {
178
+ return {
179
+ ok: false,
180
+ reason: `x-mcp-header value "${e.header}" collides case-insensitively with another property in the same inputSchema`,
181
+ };
182
+ }
183
+ seenLower.add(lower);
184
+ }
185
+ return { ok: true, entries };
186
+ }
187
+ /**
188
+ * Read the value at each annotated property's exact path in a `tools/call`
189
+ * request's arguments, convert it per the spec's Value Encoding type-
190
+ * conversion rules, and encode it via `encodeHeaderValue`. A `null` value or
191
+ * an absent property is omitted entirely (not sent as an empty header), per
192
+ * the "Server Behavior for Custom Headers" table.
193
+ */
194
+ export function buildParamHeaders(entries, args) {
195
+ const headers = {};
196
+ for (const { path, header } of entries) {
197
+ let cur = args;
198
+ for (const key of path) {
199
+ if (cur === null || typeof cur !== "object" || Array.isArray(cur)) {
200
+ cur = undefined;
201
+ break;
202
+ }
203
+ cur = cur[key];
204
+ }
205
+ if (cur === undefined || cur === null)
206
+ continue;
207
+ let str;
208
+ if (typeof cur === "string")
209
+ str = cur;
210
+ else if (typeof cur === "boolean")
211
+ str = cur ? "true" : "false";
212
+ else if (typeof cur === "number" && Number.isInteger(cur))
213
+ str = String(cur);
214
+ else
215
+ continue; // schema promised a primitive; a non-conforming value is skipped, not sent malformed
216
+ headers[`mcp-param-${header.toLowerCase()}`] = encodeHeaderValue(str);
217
+ }
218
+ return headers;
219
+ }
34
220
  /** Pull the `Mcp-Name` source value (params.name or params.uri) for a request, if any. */
35
221
  function mcpNameFor(msg) {
36
222
  const params = msg.params;
@@ -53,11 +239,27 @@ export class ModernHttpTransport {
53
239
  onMessage = () => { };
54
240
  onError = () => { };
55
241
  oauth;
242
+ /** Per-tool `x-mcp-header` maps, set at tool-registration time (see client.ts). */
243
+ toolHeaderMaps = new Map();
56
244
  constructor(url, headers) {
57
245
  this.url = url;
58
246
  this.headers = headers;
59
247
  this.oauth = new OAuthSession(url);
60
248
  }
249
+ /**
250
+ * Record a tool's valid `x-mcp-header` annotations (schema-path → header
251
+ * name), computed once at `tools/list` time by `validateToolHeaders`.
252
+ * `send()` consults this map on every `tools/call` for that tool name to
253
+ * mirror argument values into `Mcp-Param-{Name}` headers, without
254
+ * `ModernMcpConnection`'s request/MRTR internals needing to know about
255
+ * `x-mcp-header` at all.
256
+ */
257
+ setToolHeaderMap(toolName, entries) {
258
+ if (entries.length === 0)
259
+ this.toolHeaderMaps.delete(toolName);
260
+ else
261
+ this.toolHeaderMaps.set(toolName, entries);
262
+ }
61
263
  async buildHeaders(msg) {
62
264
  const headers = {
63
265
  ...this.headers,
@@ -69,6 +271,13 @@ export class ModernHttpTransport {
69
271
  const name = mcpNameFor(msg);
70
272
  if (name !== undefined)
71
273
  headers["mcp-name"] = encodeHeaderValue(name);
274
+ if (msg.method === "tools/call") {
275
+ const params = msg.params;
276
+ const toolName = typeof params?.name === "string" ? params.name : undefined;
277
+ const entries = toolName !== undefined ? this.toolHeaderMaps.get(toolName) : undefined;
278
+ if (entries)
279
+ Object.assign(headers, buildParamHeaders(entries, params?.arguments));
280
+ }
72
281
  const hasExplicitAuth = Object.keys(headers).some((k) => k.toLowerCase() === "authorization");
73
282
  if (!hasExplicitAuth) {
74
283
  const token = await this.oauth.accessToken();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.15-beta",
3
+ "version": "0.8.17-beta",
4
4
  "description": "Kritya — a lean, provider-agnostic terminal coding agent (NVIDIA, OpenAI, OpenRouter, Groq, Ollama, and more)",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -74,7 +74,7 @@
74
74
  "@types/node": "^26.4.0",
75
75
  "@types/react": "^19.2.18",
76
76
  "c8": "^12.0.0",
77
- "electron": "^43.4.1",
77
+ "electron": "^44.0.0",
78
78
  "electron-builder": "^26.15.3",
79
79
  "eslint": "^10.9.1",
80
80
  "globals": "^17.11.0",