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.
- package/dist/mcp/client.js +64 -5
- package/dist/mcp/clientModern.js +127 -20
- package/dist/mcp/schemaSafety.js +178 -0
- package/dist/mcp/transportModern.js +209 -0
- package/package.json +2 -2
package/dist/mcp/client.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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;
|
package/dist/mcp/clientModern.js
CHANGED
|
@@ -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.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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({
|
|
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
|
-
/**
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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.
|
|
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": "^
|
|
77
|
+
"electron": "^44.0.0",
|
|
78
78
|
"electron-builder": "^26.15.3",
|
|
79
79
|
"eslint": "^10.9.1",
|
|
80
80
|
"globals": "^17.11.0",
|