kritya 0.8.13-beta → 0.8.15-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/README.md CHANGED
@@ -3,6 +3,11 @@
3
3
  [![CI](https://github.com/GenAICloudDevOps/Kritya/actions/workflows/ci.yml/badge.svg)](https://github.com/GenAICloudDevOps/Kritya/actions/workflows/ci.yml)
4
4
  [![npm version](https://img.shields.io/npm/v/kritya/beta)](https://www.npmjs.com/package/kritya)
5
5
  [![License](https://img.shields.io/github/license/GenAICloudDevOps/Kritya)](LICENSE)
6
+ [![Socket](https://img.shields.io/badge/Socket-scanned-brightgreen)](https://socket.dev/npm/package/kritya)
7
+
8
+ Dependency risk is scanned on [Socket](https://socket.dev/npm/package/kritya);
9
+ see [SECURITY.md](SECURITY.md#dependency-scanning) for how flagged findings
10
+ are triaged.
6
11
 
7
12
  > ⚠️ **Beta** — APIs, flags, and behavior may still change between releases.
8
13
 
@@ -8,6 +8,9 @@ import { missingVars } from "./servers.js";
8
8
  import { HttpTransport, StdioTransport } from "./transport.js";
9
9
  import { isPrivateOrLoopbackHost } from "../net/urlSafety.js";
10
10
  import { checkToolsShape, serverFingerprint } from "../trust/mcpTrust.js";
11
+ import { probeStdioEra, probeHttpEra } from "./eraDetect.js";
12
+ import { ModernMcpConnection } from "./clientModern.js";
13
+ import { ModernHttpTransport, ReusedProcessTransport } from "./transportModern.js";
11
14
  /**
12
15
  * Thrown when a server's live tool shape no longer matches what was recorded
13
16
  * the first time its config was trusted — see checkToolsShape.
@@ -717,10 +720,52 @@ export async function connectServer(name, cfg, trace) {
717
720
  if (missing.length) {
718
721
  throw new Error(`missing env var${missing.length > 1 ? "s" : ""} ${missing.join(", ")}`);
719
722
  }
720
- conn = new McpConnection(name, makeTransport(name, cfg, workspace), workspace, {
721
- onSampling: trace?.onSampling,
722
- onElicitation: trace?.onElicitation,
723
- });
723
+ // Checked here, before era detection branches on cfg.url, so a config
724
+ // setting both never quietly takes the modern-HTTP path — makeTransport
725
+ // (the legacy path's own guard) is skipped entirely whenever era
726
+ // detection resolves to modern, so this can't be left to run there alone.
727
+ if (cfg.url && cfg.command) {
728
+ throw new Error(`server "${name}" sets both "command" and "url"; pick one`);
729
+ }
730
+ let modernConn;
731
+ if (cfg.url) {
732
+ assertSafeUrl(name, cfg.url);
733
+ const probe = await probeHttpEra(cfg.url, cfg.headers ?? {});
734
+ if (probe.era === "modern") {
735
+ if (probe.discover) {
736
+ modernConn = new ModernMcpConnection(name, new ModernHttpTransport(cfg.url, cfg.headers ?? {}));
737
+ }
738
+ else {
739
+ throw new Error(`server "${name}" speaks the modern MCP protocol but rejected protocol version ` +
740
+ `"2026-07-28" — this server may require a newer or older kritya`);
741
+ }
742
+ }
743
+ }
744
+ else if (cfg.command) {
745
+ const cwd = cfg.cwd ? path.resolve(workspace, cfg.cwd) : workspace;
746
+ const probe = await probeStdioEra(cfg.command, cfg.args ?? [], cfg.env, cwd);
747
+ if (probe.era === "modern") {
748
+ // Reuse the already-spawned probe process when the server kept it
749
+ // alive. A modern server that rejected our protocol version keeps no
750
+ // process alive — don't fall through to the legacy path, which would
751
+ // spawn a second process against a server that just told us it can't
752
+ // speak our version, producing a misleading "method not found"-style
753
+ // error instead of naming the real problem.
754
+ if (probe.process) {
755
+ modernConn = new ModernMcpConnection(name, new ReusedProcessTransport(probe.process));
756
+ }
757
+ else {
758
+ throw new Error(`server "${name}" speaks the modern MCP protocol but rejected protocol version ` +
759
+ `"2026-07-28" — this server may require a newer or older kritya`);
760
+ }
761
+ }
762
+ }
763
+ conn =
764
+ modernConn ??
765
+ new McpConnection(name, makeTransport(name, cfg, workspace), workspace, {
766
+ onSampling: trace?.onSampling,
767
+ onElicitation: trace?.onElicitation,
768
+ });
724
769
  const listed = await conn.initialize();
725
770
  const specs = listed.tools;
726
771
  const shape = checkToolsShape(serverFingerprint(cfg), specs, trace?.mcpTrustFile);
@@ -0,0 +1,141 @@
1
+ import { modernMeta } from "./eraDetect.js";
2
+ /**
3
+ * Split the same way legacy McpConnection splits its timeouts: connect-time
4
+ * calls (the three list calls during initialize()) get a short ceiling since
5
+ * a hung server there should fail fast, while tools/call — which can
6
+ * legitimately run long — gets the generous one.
7
+ */
8
+ const CONNECT_TIMEOUT_MS = 15_000;
9
+ const CALL_TIMEOUT_MS = 120_000;
10
+ /**
11
+ * Modern (2026-07-28+) MCP client: no `initialize`, every request carries
12
+ * its own `_meta`. Implements the same minimal surface as the legacy
13
+ * `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.
17
+ */
18
+ export class ModernMcpConnection {
19
+ name;
20
+ transport;
21
+ nextId = 1;
22
+ pending = new Map();
23
+ closed = false;
24
+ constructor(name, transport) {
25
+ this.name = name;
26
+ this.transport = transport;
27
+ transport.onMessage = (msg) => this.onMessage(msg);
28
+ transport.onError = (err) => this.fail(new Error(`MCP server "${name}": ${err.message}`));
29
+ }
30
+ fail(err) {
31
+ if (this.closed)
32
+ return;
33
+ this.closed = true;
34
+ for (const p of this.pending.values()) {
35
+ clearTimeout(p.timer);
36
+ p.reject(err);
37
+ }
38
+ this.pending.clear();
39
+ }
40
+ onMessage(msg) {
41
+ if (typeof msg.id !== "number")
42
+ return;
43
+ const p = this.pending.get(msg.id);
44
+ if (!p)
45
+ return;
46
+ this.pending.delete(msg.id);
47
+ clearTimeout(p.timer);
48
+ if (msg.error)
49
+ p.reject(new Error(msg.error.message ?? "MCP error"));
50
+ else
51
+ p.resolve(msg.result);
52
+ }
53
+ request(method, params, timeoutMs, signal) {
54
+ if (this.closed)
55
+ return Promise.reject(new Error(`MCP server "${this.name}" is not running`));
56
+ const id = this.nextId++;
57
+ return new Promise((resolve, reject) => {
58
+ const timer = setTimeout(() => {
59
+ this.pending.delete(id);
60
+ reject(new Error(`MCP request "${method}" timed out`));
61
+ }, timeoutMs);
62
+ this.pending.set(id, { resolve, reject, timer });
63
+ this.transport
64
+ .send({ jsonrpc: "2.0", id, method, params: { ...params, _meta: modernMeta() } }, timeoutMs, signal)
65
+ .catch((err) => {
66
+ const p = this.pending.get(id);
67
+ if (!p)
68
+ return;
69
+ this.pending.delete(id);
70
+ clearTimeout(p.timer);
71
+ p.reject(err);
72
+ });
73
+ });
74
+ }
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") {
83
+ throw new Error(`MCP server "${this.name}" returned an unrecognized resultType "${r.resultType}"`);
84
+ }
85
+ return result;
86
+ }
87
+ async initialize() {
88
+ const listed = this.unwrap(await this.request("tools/list", {}, CONNECT_TIMEOUT_MS));
89
+ let prompts = [];
90
+ let resources = [];
91
+ try {
92
+ const p = this.unwrap(await this.request("prompts/list", {}, CONNECT_TIMEOUT_MS));
93
+ prompts = p.prompts ?? [];
94
+ }
95
+ catch {
96
+ // prompts unsupported by this server — non-fatal, same as legacy behavior
97
+ }
98
+ try {
99
+ const r = this.unwrap(await this.request("resources/list", {}, CONNECT_TIMEOUT_MS));
100
+ resources = r.resources ?? [];
101
+ }
102
+ catch {
103
+ // resources unsupported — non-fatal
104
+ }
105
+ return { tools: listed.tools ?? [], prompts, resources };
106
+ }
107
+ async getPrompt(name, args) {
108
+ const result = this.unwrap(await this.request("prompts/get", { name, arguments: args }, CALL_TIMEOUT_MS));
109
+ const messages = result.messages ?? [];
110
+ const multiRole = new Set(messages.map((m) => m.role ?? "user")).size > 1;
111
+ return messages
112
+ .map((m) => {
113
+ const text = m.content?.text ?? "";
114
+ return multiRole ? `[${m.role ?? "user"}] ${text}` : text;
115
+ })
116
+ .filter(Boolean)
117
+ .join("\n\n");
118
+ }
119
+ async readResource(uri) {
120
+ const result = this.unwrap(await this.request("resources/read", { uri }, CALL_TIMEOUT_MS));
121
+ return (result.contents ?? []).map((c) => c.text ?? "").join("\n");
122
+ }
123
+ async callTool(toolName, args, signal, _tasksEnabled, _onProgress) {
124
+ // Tasks-under-modern is out of scope for this plan (see spec's "Out of
125
+ // scope"); the params exist only so this signature matches
126
+ // McpConnection.callTool's, which callers invoke uniformly through the
127
+ // 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));
129
+ const text = (result.content ?? [])
130
+ .filter((b) => b.type === "text")
131
+ .map((b) => b.text ?? "")
132
+ .join("\n");
133
+ if (result.isError)
134
+ throw new Error(text || "MCP tool reported an error");
135
+ return text || "(no output)";
136
+ }
137
+ close() {
138
+ this.fail(new Error(`MCP server "${this.name}" connection closed`));
139
+ this.transport.close();
140
+ }
141
+ }
@@ -0,0 +1,209 @@
1
+ import { VERSION } from "../version.js";
2
+ /**
3
+ * Era detection and the modern (2026-07-28+) per-request `_meta` shape.
4
+ *
5
+ * Modern MCP has no `initialize` handshake: every request carries its
6
+ * protocol version, capabilities, and identity in `_meta`, and the server
7
+ * answers each request independently. See
8
+ * /specification/2026-07-28/basic/versioning for the era model this file
9
+ * implements detection for.
10
+ */
11
+ export const MODERN_PROTOCOL_VERSION = "2026-07-28";
12
+ /** The `_meta` block every modern request carries, per spec's "Per-request protocol fields". */
13
+ export function modernMeta(clientCapabilities = {}) {
14
+ return {
15
+ "io.modelcontextprotocol/protocolVersion": MODERN_PROTOCOL_VERSION,
16
+ "io.modelcontextprotocol/clientInfo": { name: "kritya", version: VERSION },
17
+ "io.modelcontextprotocol/clientCapabilities": clientCapabilities,
18
+ };
19
+ }
20
+ /** The three error codes the 2026-07-28 spec reserves for version/capability/header mismatches. */
21
+ const MODERN_ERROR_CODES = new Set([-32020, -32021, -32022]);
22
+ export function isRecognizedModernError(err) {
23
+ return typeof err?.code === "number" && MODERN_ERROR_CODES.has(err.code);
24
+ }
25
+ /** Parse a raw JSON-RPC `result` into a DiscoverResult; undefined if the shape doesn't match. */
26
+ export function parseDiscoverResult(result) {
27
+ if (!result || typeof result !== "object")
28
+ return undefined;
29
+ const r = result;
30
+ if (!Array.isArray(r.supportedVersions) ||
31
+ !r.supportedVersions.every((v) => typeof v === "string")) {
32
+ return undefined;
33
+ }
34
+ if (!r.capabilities || typeof r.capabilities !== "object")
35
+ return undefined;
36
+ return {
37
+ supportedVersions: r.supportedVersions,
38
+ capabilities: r.capabilities,
39
+ serverInfo: r._meta?.["io.modelcontextprotocol/serverInfo"],
40
+ instructions: typeof r.instructions === "string" ? r.instructions : undefined,
41
+ };
42
+ }
43
+ import { spawn } from "node:child_process";
44
+ import { planSpawn } from "./spawnWin.js";
45
+ import { minimalEnv } from "./transport.js";
46
+ import { OAuthSession } from "./oauth.js";
47
+ const DEFAULT_PROBE_TIMEOUT_MS = 5_000;
48
+ /**
49
+ * Probe a stdio server with `server/discover`, per
50
+ * /specification/2026-07-28/basic/transports/stdio#backward-compatibility.
51
+ * Three outcomes: a DiscoverResult (modern, process kept alive for reuse), a
52
+ * recognized modern error (modern, but report — don't fall back), or
53
+ * anything else / a timeout (legacy — the caller launches its own process).
54
+ */
55
+ export function probeStdioEra(command, args, env, cwd, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS) {
56
+ return new Promise((resolve) => {
57
+ const plan = planSpawn(command, args);
58
+ const proc = spawn(plan.command, plan.args, {
59
+ // Minimal env, not the full process env — see StdioTransport's
60
+ // minimalEnv() doc comment: a probe process is still a third-party MCP
61
+ // server and must not automatically receive kritya's own API keys.
62
+ env: { ...minimalEnv(), ...env },
63
+ cwd,
64
+ stdio: ["pipe", "pipe", "pipe"],
65
+ windowsVerbatimArguments: plan.windowsVerbatimArguments,
66
+ });
67
+ // Draining stderr is not optional here either: a server that logs more
68
+ // than the OS pipe buffer blocks in its own write() forever if nobody
69
+ // reads it, even during just the probe's short wait. Kept attached
70
+ // (never removed) so a process handed off to ReusedProcessTransport
71
+ // keeps draining seamlessly across that handoff.
72
+ proc.stderr?.setEncoding("utf8");
73
+ proc.stderr?.on("data", () => { });
74
+ let buffer = "";
75
+ let settled = false;
76
+ const finish = (result) => {
77
+ if (settled)
78
+ return;
79
+ settled = true;
80
+ clearTimeout(timer);
81
+ proc.stdout?.removeAllListeners("data");
82
+ if (result.era === "legacy")
83
+ proc.kill();
84
+ resolve(result);
85
+ };
86
+ const timer = setTimeout(() => finish({ era: "legacy" }), timeoutMs);
87
+ timer.unref?.();
88
+ proc.on("error", () => finish({ era: "legacy" }));
89
+ proc.stdout?.setEncoding("utf8");
90
+ proc.stdout?.on("data", (chunk) => {
91
+ buffer += chunk;
92
+ let idx;
93
+ while ((idx = buffer.indexOf("\n")) >= 0) {
94
+ const line = buffer.slice(0, idx).trim();
95
+ buffer = buffer.slice(idx + 1);
96
+ if (!line)
97
+ continue;
98
+ let msg;
99
+ try {
100
+ msg = JSON.parse(line);
101
+ }
102
+ catch {
103
+ // Some servers log to stdout — tolerate a non-JSON line the same
104
+ // way StdioTransport does, and keep listening for the real reply.
105
+ continue;
106
+ }
107
+ if (msg.result !== undefined) {
108
+ const discover = parseDiscoverResult(msg.result);
109
+ if (discover) {
110
+ finish({ era: "modern", process: proc, discover });
111
+ return;
112
+ }
113
+ }
114
+ if (isRecognizedModernError(msg.error)) {
115
+ proc.kill();
116
+ finish({ era: "modern" });
117
+ return;
118
+ }
119
+ finish({ era: "legacy" });
120
+ return;
121
+ }
122
+ });
123
+ proc.stdin?.write(JSON.stringify({
124
+ jsonrpc: "2.0",
125
+ id: "discover-probe",
126
+ method: "server/discover",
127
+ params: { _meta: modernMeta() },
128
+ }) + "\n");
129
+ });
130
+ }
131
+ const DEFAULT_HTTP_PROBE_TIMEOUT_MS = 10_000;
132
+ /**
133
+ * Probe an HTTP server by attempting a modern `server/discover` POST, per
134
+ * /specification/2026-07-28/basic/transports/streamable-http#backward-compatibility.
135
+ *
136
+ * OAuth-aware: without this, an OAuth-protected modern server answers with a
137
+ * 401 (neither a DiscoverResult nor a recognized modern error), which used to
138
+ * be classified as "legacy" — permanently hiding a modern server behind auth,
139
+ * since the real ModernHttpTransport connection (which *does* handle OAuth)
140
+ * would then never run against it. A bearer token is injected the same way
141
+ * `ModernHttpTransport.buildHeaders` does, and a 401 is given one
142
+ * refresh-and-retry before falling back to "legacy" — matching the retry
143
+ * shape `ModernHttpTransport.send`/`HttpTransport.send` use post-connect.
144
+ */
145
+ export async function probeHttpEra(url, headers, timeoutMs = DEFAULT_HTTP_PROBE_TIMEOUT_MS) {
146
+ const oauth = new OAuthSession(url);
147
+ const hasExplicitAuth = Object.keys(headers).some((k) => k.toLowerCase() === "authorization");
148
+ const buildHeaders = async () => {
149
+ const built = {
150
+ ...headers,
151
+ "content-type": "application/json",
152
+ accept: "application/json, text/event-stream",
153
+ "mcp-protocol-version": MODERN_PROTOCOL_VERSION,
154
+ "mcp-method": "server/discover",
155
+ };
156
+ if (!hasExplicitAuth) {
157
+ const token = await oauth.accessToken();
158
+ if (token)
159
+ built.authorization = `Bearer ${token}`;
160
+ }
161
+ return built;
162
+ };
163
+ const post = async () => fetch(url, {
164
+ method: "POST",
165
+ headers: await buildHeaders(),
166
+ body: JSON.stringify({
167
+ jsonrpc: "2.0",
168
+ id: "discover-probe",
169
+ method: "server/discover",
170
+ params: { _meta: modernMeta() },
171
+ }),
172
+ redirect: "manual",
173
+ signal: AbortSignal.timeout(timeoutMs),
174
+ });
175
+ let res;
176
+ try {
177
+ res = await post();
178
+ if (res.status === 401 && !hasExplicitAuth) {
179
+ // Give a stored (or freshly refreshed) token a fair shot before
180
+ // concluding legacy — a server that only needed auth deserves a real
181
+ // DiscoverResult or recognized modern error, not a guess.
182
+ const refreshed = await oauth.handleUnauthorized();
183
+ if (refreshed) {
184
+ await res.body?.cancel().catch(() => { });
185
+ res = await post();
186
+ }
187
+ }
188
+ }
189
+ catch {
190
+ return { era: "legacy" };
191
+ }
192
+ let body;
193
+ try {
194
+ body = await res.json();
195
+ }
196
+ catch {
197
+ return { era: "legacy" };
198
+ }
199
+ const msg = body;
200
+ if (res.ok) {
201
+ const discover = parseDiscoverResult(msg.result);
202
+ if (discover)
203
+ return { era: "modern", discover };
204
+ return { era: "legacy" };
205
+ }
206
+ if (isRecognizedModernError(msg.error))
207
+ return { era: "modern" };
208
+ return { era: "legacy" };
209
+ }
@@ -22,7 +22,7 @@ const PASSTHROUGH_ENV_VARS = [
22
22
  "LOCALAPPDATA",
23
23
  "NODE_PATH",
24
24
  ];
25
- function minimalEnv() {
25
+ export function minimalEnv() {
26
26
  const env = {};
27
27
  for (const key of PASSTHROUGH_ENV_VARS) {
28
28
  const value = process.env[key];
@@ -36,7 +36,7 @@ function minimalEnv() {
36
36
  * `AbortSignal.any` only landed in Node 20 and we support 18, hence the manual
37
37
  * bridge.
38
38
  */
39
- function withTimeout(timeoutMs, signal) {
39
+ export function withTimeout(timeoutMs, signal) {
40
40
  const timeout = AbortSignal.timeout(timeoutMs);
41
41
  if (!signal)
42
42
  return timeout;
@@ -0,0 +1,267 @@
1
+ import { McpAuthRequiredError, OAuthSession, parseWwwAuthenticate } from "./oauth.js";
2
+ import { withTimeout } from "./transport.js";
3
+ import { modernMeta, MODERN_PROTOCOL_VERSION } from "./eraDetect.js";
4
+ /** Same-origin redirects we'll follow before calling it a loop. */
5
+ const MAX_REDIRECTS = 5;
6
+ /** How much of a server's stderr to keep for diagnostics, and how much to report. */
7
+ const STDERR_KEEP_BYTES = 8_192;
8
+ const STDERR_REPORT_LINES = 5;
9
+ function isRedirect(status) {
10
+ return status === 301 || status === 302 || status === 303 || status === 307 || status === 308;
11
+ }
12
+ /**
13
+ * True when a string is safe to send verbatim as an HTTP header value:
14
+ * visible ASCII (0x21-0x7E), space, and horizontal tab only, no
15
+ * leading/trailing whitespace, per RFC 9110 field-value rules.
16
+ */
17
+ function isPlainAsciiHeaderSafe(s) {
18
+ if (s !== s.trim())
19
+ return false;
20
+ return /^[\t\x20-\x7E]*$/.test(s);
21
+ }
22
+ const BASE64_SENTINEL_RE = /^=\?base64\?[A-Za-z0-9+/=]*\?=$/;
23
+ /**
24
+ * Encode a value for an `Mcp-Name`/`Mcp-Param-*` header per the 2026-07-28
25
+ * Streamable HTTP spec's Value Encoding rules: plain ASCII as-is, otherwise
26
+ * (or if it collides with the sentinel pattern itself) base64-sentinel the
27
+ * UTF-8 bytes.
28
+ */
29
+ export function encodeHeaderValue(value) {
30
+ if (isPlainAsciiHeaderSafe(value) && !BASE64_SENTINEL_RE.test(value))
31
+ return value;
32
+ return `=?base64?${Buffer.from(value, "utf8").toString("base64")}?=`;
33
+ }
34
+ /** Pull the `Mcp-Name` source value (params.name or params.uri) for a request, if any. */
35
+ function mcpNameFor(msg) {
36
+ const params = msg.params;
37
+ if (typeof params?.name === "string")
38
+ return params.name;
39
+ if (typeof params?.uri === "string")
40
+ return params.uri;
41
+ return undefined;
42
+ }
43
+ /**
44
+ * Streamable HTTP transport for the modern (2026-07-28+), stateless,
45
+ * per-request era: no `Mcp-Session-Id`, no `initialize`, every POST carries
46
+ * `_meta` in the body and mirrors `MCP-Protocol-Version`/`Mcp-Method`/
47
+ * `Mcp-Name` into headers. See
48
+ * /specification/2026-07-28/basic/transports/streamable-http.
49
+ */
50
+ export class ModernHttpTransport {
51
+ url;
52
+ headers;
53
+ onMessage = () => { };
54
+ onError = () => { };
55
+ oauth;
56
+ constructor(url, headers) {
57
+ this.url = url;
58
+ this.headers = headers;
59
+ this.oauth = new OAuthSession(url);
60
+ }
61
+ async buildHeaders(msg) {
62
+ const headers = {
63
+ ...this.headers,
64
+ "content-type": "application/json",
65
+ accept: "application/json, text/event-stream",
66
+ "mcp-protocol-version": MODERN_PROTOCOL_VERSION,
67
+ "mcp-method": msg.method ?? "",
68
+ };
69
+ const name = mcpNameFor(msg);
70
+ if (name !== undefined)
71
+ headers["mcp-name"] = encodeHeaderValue(name);
72
+ const hasExplicitAuth = Object.keys(headers).some((k) => k.toLowerCase() === "authorization");
73
+ if (!hasExplicitAuth) {
74
+ const token = await this.oauth.accessToken();
75
+ if (token)
76
+ headers.authorization = `Bearer ${token}`;
77
+ }
78
+ return headers;
79
+ }
80
+ withMeta(msg) {
81
+ const params = (msg.params ?? {});
82
+ return { ...msg, params: { ...params, _meta: modernMeta() } };
83
+ }
84
+ async send(msg, timeoutMs, signal) {
85
+ const body = this.withMeta(msg);
86
+ let res = await this.post(body, timeoutMs, signal);
87
+ if (res.status === 401) {
88
+ const refreshed = await this.oauth.handleUnauthorized();
89
+ if (refreshed) {
90
+ await res.body?.cancel().catch(() => { });
91
+ res = await this.post(body, timeoutMs, signal);
92
+ }
93
+ if (res.status === 401) {
94
+ const challenge = parseWwwAuthenticate(res.headers.get("www-authenticate"));
95
+ await res.body?.cancel().catch(() => { });
96
+ throw new McpAuthRequiredError(this.url, challenge);
97
+ }
98
+ }
99
+ if (!res.ok) {
100
+ const text = await res.text().catch(() => "");
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(text);
104
+ }
105
+ catch {
106
+ // not JSON — fall through to the generic HTTP error below
107
+ }
108
+ if (parsed?.error) {
109
+ this.onMessage(parsed);
110
+ return;
111
+ }
112
+ throw new Error(`HTTP ${res.status}${text ? `: ${text.slice(0, 200)}` : ""}`);
113
+ }
114
+ const contentType = res.headers.get("content-type") ?? "";
115
+ if (contentType.includes("application/json")) {
116
+ const parsed = (await res.json());
117
+ this.onMessage(parsed);
118
+ }
119
+ else if (contentType.includes("text/event-stream")) {
120
+ await this.readSse(res);
121
+ }
122
+ }
123
+ async post(msg, timeoutMs, signal) {
124
+ const body = JSON.stringify(msg);
125
+ let url = this.url;
126
+ for (let hop = 0;; hop++) {
127
+ const res = await fetch(url, {
128
+ method: "POST",
129
+ headers: await this.buildHeaders(msg),
130
+ body,
131
+ redirect: "manual",
132
+ signal: withTimeout(timeoutMs, signal),
133
+ });
134
+ if (!isRedirect(res.status))
135
+ return res;
136
+ const location = res.headers.get("location");
137
+ await res.body?.cancel().catch(() => { });
138
+ if (!location)
139
+ throw new Error(`HTTP ${res.status} redirect with no Location header`);
140
+ if (hop >= MAX_REDIRECTS)
141
+ throw new Error(`too many redirects from ${this.url}`);
142
+ const target = new URL(location, url);
143
+ if (target.origin !== new URL(url).origin) {
144
+ throw new Error(`refusing redirect to a different origin (${target.origin}) — ` +
145
+ `it would send this server's credentials there.`);
146
+ }
147
+ url = target.toString();
148
+ }
149
+ }
150
+ async readSse(res) {
151
+ if (!res.body)
152
+ return;
153
+ const decoder = new TextDecoder();
154
+ let buffer = "";
155
+ const dispatch = (rawEvent) => {
156
+ const data = rawEvent
157
+ .split(/\r?\n/)
158
+ .filter((l) => l.startsWith("data:"))
159
+ .map((l) => l.slice(5).trimStart())
160
+ .join("\n");
161
+ if (!data)
162
+ return;
163
+ try {
164
+ this.onMessage(JSON.parse(data));
165
+ }
166
+ catch {
167
+ // ignore non-JSON events (comments, keep-alives)
168
+ }
169
+ };
170
+ for await (const chunk of res.body) {
171
+ buffer += decoder.decode(chunk, { stream: true });
172
+ let idx;
173
+ while ((idx = buffer.search(/\r?\n\r?\n/)) >= 0) {
174
+ const rawEvent = buffer.slice(0, idx);
175
+ buffer = buffer.slice(idx).replace(/^\r?\n\r?\n/, "");
176
+ dispatch(rawEvent);
177
+ }
178
+ }
179
+ if (buffer.trim())
180
+ dispatch(buffer);
181
+ }
182
+ close() {
183
+ // No session to terminate — modern mode never sends DELETE.
184
+ }
185
+ }
186
+ /**
187
+ * Wraps an already-spawned stdio child process — one `probeStdioEra` kept
188
+ * alive after a successful `server/discover` — as a `Transport`, instead of
189
+ * paying for a second process launch.
190
+ *
191
+ * Mirrors `StdioTransport`'s framing and lifecycle handling (line buffering
192
+ * across chunk boundaries, `onError` wired to `close`/`exit`/`error`) rather
193
+ * than a one-shot "resolve on next data event" adapter: `ModernMcpConnection`
194
+ * can have more than one request in flight (e.g. concurrent tool calls), and
195
+ * a naive adapter would resolve the wrong `send()` call, or drop data split
196
+ * across multiple chunks/lines.
197
+ */
198
+ export class ReusedProcessTransport {
199
+ proc;
200
+ onMessage = () => { };
201
+ onError = () => { };
202
+ buffer = "";
203
+ /** Tail of the server's stderr, kept for the exit message (see StdioTransport). */
204
+ stderrTail = "";
205
+ constructor(proc) {
206
+ this.proc = proc;
207
+ // Attached first, before anything else: draining stderr is not optional
208
+ // (see StdioTransport's doc comment) — a server that logs more than the
209
+ // OS pipe buffer blocks in its own write() forever if nobody reads it,
210
+ // and the process may already be mid-flight from probeStdioEra's own
211
+ // (brief) probe window.
212
+ this.proc.stderr?.setEncoding("utf8");
213
+ this.proc.stderr?.on("data", (chunk) => {
214
+ this.stderrTail = (this.stderrTail + chunk).slice(-STDERR_KEEP_BYTES);
215
+ });
216
+ this.proc.stdout?.setEncoding("utf8");
217
+ this.proc.stdout?.on("data", (chunk) => this.onData(chunk));
218
+ let reported = false;
219
+ const report = (code, signal) => {
220
+ if (reported)
221
+ return;
222
+ reported = true;
223
+ const how = signal ? `killed by ${signal}` : `exited with code ${code ?? 0}`;
224
+ this.onError(new Error(`server process ${how}${this.stderrDetail()}`));
225
+ };
226
+ // Same close/exit split as StdioTransport: "close" waits for stdio to
227
+ // flush, "exit" arms a short fallback in case a grandchild holds the
228
+ // pipes open.
229
+ this.proc.on("close", report);
230
+ this.proc.on("exit", (code, signal) => {
231
+ setTimeout(() => report(code, signal), 200).unref();
232
+ });
233
+ this.proc.on("error", (err) => this.onError(new Error(`${err.message}${this.stderrDetail()}`)));
234
+ }
235
+ /** The last few non-blank stderr lines, formatted for an error message. */
236
+ stderrDetail() {
237
+ const lines = this.stderrTail
238
+ .split(/\r?\n/)
239
+ .map((l) => l.trim())
240
+ .filter(Boolean)
241
+ .slice(-STDERR_REPORT_LINES);
242
+ return lines.length ? ` — stderr: ${lines.join(" | ")}` : "";
243
+ }
244
+ onData(chunk) {
245
+ this.buffer += chunk;
246
+ let idx;
247
+ while ((idx = this.buffer.indexOf("\n")) >= 0) {
248
+ const line = this.buffer.slice(0, idx).trim();
249
+ this.buffer = this.buffer.slice(idx + 1);
250
+ if (!line)
251
+ continue;
252
+ try {
253
+ this.onMessage(JSON.parse(line));
254
+ }
255
+ catch {
256
+ // ignore non-JSON (some servers log to stdout)
257
+ }
258
+ }
259
+ }
260
+ send(msg) {
261
+ this.proc.stdin?.write(JSON.stringify(msg) + "\n");
262
+ return Promise.resolve();
263
+ }
264
+ close() {
265
+ this.proc.kill();
266
+ }
267
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kritya",
3
- "version": "0.8.13-beta",
3
+ "version": "0.8.15-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": {