klyro 1.0.4 → 1.0.6
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 +29 -0
- package/dist/agent/custom-agents.d.ts +3 -0
- package/dist/agent/custom-agents.js +96 -0
- package/dist/agent/orchestrator.d.ts +22 -2
- package/dist/agent/orchestrator.js +30 -4
- package/dist/agent/runtime.d.ts +5 -0
- package/dist/agent/runtime.js +174 -51
- package/dist/checkpoints/store.d.ts +9 -0
- package/dist/checkpoints/store.js +20 -0
- package/dist/cli/auth.js +11 -3
- package/dist/cli/completion.js +63 -10
- package/dist/cli/config.d.ts +4 -4
- package/dist/cli/doctor.js +13 -0
- package/dist/cli/eval.d.ts +15 -1
- package/dist/cli/eval.js +34 -2
- package/dist/cli/hooks.d.ts +54 -5
- package/dist/cli/hooks.js +85 -6
- package/dist/cli/init.d.ts +6 -0
- package/dist/cli/init.js +60 -0
- package/dist/cli/repl.js +261 -34
- package/dist/cli/run.d.ts +7 -1
- package/dist/cli/run.js +97 -41
- package/dist/cli/slash/custom.d.ts +25 -0
- package/dist/cli/slash/custom.js +166 -0
- package/dist/cli/slash/parser.d.ts +16 -2
- package/dist/cli/slash/parser.js +67 -18
- package/dist/cli/update.d.ts +8 -4
- package/dist/cli/update.js +50 -7
- package/dist/context/accounting.d.ts +8 -0
- package/dist/context/accounting.js +18 -1
- package/dist/context/compaction.d.ts +1 -0
- package/dist/context/compaction.js +2 -1
- package/dist/context/memory.js +18 -1
- package/dist/eval/harness.d.ts +21 -3
- package/dist/eval/harness.js +31 -3
- package/dist/eval/judge.d.ts +32 -0
- package/dist/eval/judge.js +63 -0
- package/dist/eval/tasks.js +134 -0
- package/dist/index.js +225 -132
- package/dist/mcp/client.d.ts +15 -0
- package/dist/mcp/client.js +42 -2
- package/dist/mcp/config.d.ts +10 -1
- package/dist/mcp/config.js +64 -1
- package/dist/mcp/registry.d.ts +13 -0
- package/dist/mcp/registry.js +47 -5
- package/dist/mcp/remote.d.ts +29 -0
- package/dist/mcp/remote.js +153 -0
- package/dist/mcp/serve.d.ts +23 -0
- package/dist/mcp/serve.js +111 -0
- package/dist/policy/approval.d.ts +15 -1
- package/dist/policy/approval.js +8 -0
- package/dist/policy/engine.d.ts +11 -1
- package/dist/policy/engine.js +14 -1
- package/dist/policy/secret-redactor.js +4 -1
- package/dist/providers/endpoints.d.ts +43 -0
- package/dist/providers/endpoints.js +104 -0
- package/dist/providers.js +13 -10
- package/dist/shared/error-map.d.ts +19 -0
- package/dist/shared/error-map.js +58 -0
- package/dist/tools/lsp/diagnostics.d.ts +35 -4
- package/dist/tools/lsp/diagnostics.js +88 -9
- package/dist/tools/normalize.d.ts +3 -0
- package/dist/tools/normalize.js +8 -5
- package/dist/tools/search/dependencies.d.ts +2 -2
- package/dist/tools/shell/background.d.ts +6 -0
- package/dist/tools/shell/background.js +17 -0
- package/dist/tools/symbols/find-symbol.d.ts +1 -1
- package/dist/tools/symbols/find-symbol.js +8 -6
- package/dist/tools/types.d.ts +8 -1
- package/dist/tui/app.d.ts +2 -0
- package/dist/tui/app.js +454 -53
- package/dist/tui/app.test.js +66 -3
- package/dist/tui/approval.js +53 -1
- package/dist/tui/markdown.js +9 -0
- package/dist/tui/mouse.d.ts +26 -1
- package/dist/tui/mouse.js +104 -6
- package/dist/tui/scroll-flow.test.js +3 -1
- package/dist/tui/tokens.d.ts +6 -6
- package/dist/tui/tokens.js +9 -6
- package/package.json +1 -1
package/dist/mcp/config.d.ts
CHANGED
|
@@ -7,9 +7,11 @@ export declare const McpServerPolicySchema: z.ZodObject<{
|
|
|
7
7
|
}, z.core.$strip>;
|
|
8
8
|
export type McpServerPolicy = z.infer<typeof McpServerPolicySchema>;
|
|
9
9
|
export declare const McpServerSpecSchema: z.ZodObject<{
|
|
10
|
-
command: z.ZodString
|
|
10
|
+
command: z.ZodOptional<z.ZodString>;
|
|
11
11
|
args: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
12
12
|
env: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
13
|
+
url: z.ZodOptional<z.ZodString>;
|
|
14
|
+
headers: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
13
15
|
disabled: z.ZodOptional<z.ZodBoolean>;
|
|
14
16
|
policy: z.ZodOptional<z.ZodObject<{
|
|
15
17
|
allowTools: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -38,3 +40,10 @@ export declare function expandEnv(value: string): string;
|
|
|
38
40
|
export declare function loadMcpServers(cwd: string): McpServersConfig;
|
|
39
41
|
/** Servers eligible for connection (configured and not disabled). */
|
|
40
42
|
export declare function enabledServers(cfg: McpServersConfig): Record<string, McpServerSpec>;
|
|
43
|
+
/** Server names are bounded: the registry builds `mcp__<server>__<tool>` (≤64 chars). */
|
|
44
|
+
export declare const MCP_NAME_RE: RegExp;
|
|
45
|
+
export declare function projectMcpPath(cwd: string): string;
|
|
46
|
+
/** Add (or reject duplicates of) a project-level MCP server. Throws on invalid input. */
|
|
47
|
+
export declare function addProjectServer(cwd: string, name: string, spec: unknown): void;
|
|
48
|
+
/** Remove a project-level MCP server. Returns false when absent. */
|
|
49
|
+
export declare function removeProjectServer(cwd: string, name: string): boolean;
|
package/dist/mcp/config.js
CHANGED
|
@@ -19,11 +19,23 @@ export const McpServerPolicySchema = z.object({
|
|
|
19
19
|
timeoutMs: z.number().int().positive().optional(),
|
|
20
20
|
});
|
|
21
21
|
export const McpServerSpecSchema = z.object({
|
|
22
|
-
|
|
22
|
+
/** stdio transport: command to spawn. Required unless `url` is set. */
|
|
23
|
+
command: z.string().min(1).optional(),
|
|
23
24
|
args: z.array(z.string()).optional(),
|
|
24
25
|
env: z.record(z.string(), z.string()).optional(),
|
|
26
|
+
/** Remote transport: Streamable-HTTP JSON-RPC endpoint. Required unless `command` is set. */
|
|
27
|
+
url: z.string().url().optional(),
|
|
28
|
+
/** Extra HTTP headers for remote transport (`${env:VAR}` expanded). */
|
|
29
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
25
30
|
disabled: z.boolean().optional(),
|
|
26
31
|
policy: McpServerPolicySchema.optional(),
|
|
32
|
+
}).superRefine((s, ctx) => {
|
|
33
|
+
if (!s.command && !s.url) {
|
|
34
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'MCP server needs either "command" (stdio) or "url" (remote HTTP)' });
|
|
35
|
+
}
|
|
36
|
+
if (s.command && s.url) {
|
|
37
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'MCP server takes either "command" or "url", not both' });
|
|
38
|
+
}
|
|
27
39
|
});
|
|
28
40
|
/** Upper bound for per-server timeouts — larger values are clamped, not rejected. */
|
|
29
41
|
export const MAX_MCP_TIMEOUT_MS = 600_000;
|
|
@@ -82,6 +94,12 @@ export function loadMcpServers(cwd) {
|
|
|
82
94
|
env[k] = expandEnv(v);
|
|
83
95
|
spec.env = env;
|
|
84
96
|
}
|
|
97
|
+
if (spec.headers) {
|
|
98
|
+
const headers = {};
|
|
99
|
+
for (const [k, v] of Object.entries(spec.headers))
|
|
100
|
+
headers[k] = expandEnv(v);
|
|
101
|
+
spec.headers = headers;
|
|
102
|
+
}
|
|
85
103
|
servers[name] = spec;
|
|
86
104
|
sources[name] = label;
|
|
87
105
|
}
|
|
@@ -97,3 +115,48 @@ export function enabledServers(cfg) {
|
|
|
97
115
|
}
|
|
98
116
|
return out;
|
|
99
117
|
}
|
|
118
|
+
/** Server names are bounded: the registry builds `mcp__<server>__<tool>` (≤64 chars). */
|
|
119
|
+
export const MCP_NAME_RE = /^[A-Za-z0-9_-]{1,20}$/;
|
|
120
|
+
export function projectMcpPath(cwd) {
|
|
121
|
+
return path.join(cwd, '.mcp.json');
|
|
122
|
+
}
|
|
123
|
+
function readProjectDoc(cwd) {
|
|
124
|
+
const raw = readJsonFile(projectMcpPath(cwd));
|
|
125
|
+
const doc = (raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {});
|
|
126
|
+
const servers = normalizeRaw(doc);
|
|
127
|
+
return { doc, servers: { ...servers } };
|
|
128
|
+
}
|
|
129
|
+
function writeProjectDoc(cwd, doc, servers) {
|
|
130
|
+
const next = { ...doc };
|
|
131
|
+
if ('mcpServers' in next)
|
|
132
|
+
next['mcpServers'] = servers;
|
|
133
|
+
else
|
|
134
|
+
next['servers'] = servers;
|
|
135
|
+
if (Object.keys(next).length === 0)
|
|
136
|
+
next['mcpServers'] = servers;
|
|
137
|
+
const tmp = `${projectMcpPath(cwd)}.tmp-${process.pid}`;
|
|
138
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n', 'utf-8');
|
|
139
|
+
fs.renameSync(tmp, projectMcpPath(cwd));
|
|
140
|
+
}
|
|
141
|
+
/** Add (or reject duplicates of) a project-level MCP server. Throws on invalid input. */
|
|
142
|
+
export function addProjectServer(cwd, name, spec) {
|
|
143
|
+
if (!MCP_NAME_RE.test(name))
|
|
144
|
+
throw new Error(`invalid server name "${name}" (want 1-20 chars of A-Za-z0-9_-)`);
|
|
145
|
+
const parsed = McpServerSpecSchema.safeParse(spec);
|
|
146
|
+
if (!parsed.success)
|
|
147
|
+
throw new Error(`invalid server spec: ${parsed.error.issues.map((i) => i.message).join('; ')}`);
|
|
148
|
+
const { doc, servers } = readProjectDoc(cwd);
|
|
149
|
+
if (name in servers)
|
|
150
|
+
throw new Error(`server "${name}" already configured in ${projectMcpPath(cwd)} (remove it first)`);
|
|
151
|
+
servers[name] = parsed.data;
|
|
152
|
+
writeProjectDoc(cwd, doc, servers);
|
|
153
|
+
}
|
|
154
|
+
/** Remove a project-level MCP server. Returns false when absent. */
|
|
155
|
+
export function removeProjectServer(cwd, name) {
|
|
156
|
+
const { doc, servers } = readProjectDoc(cwd);
|
|
157
|
+
if (!(name in servers))
|
|
158
|
+
return false;
|
|
159
|
+
delete servers[name];
|
|
160
|
+
writeProjectDoc(cwd, doc, servers);
|
|
161
|
+
return true;
|
|
162
|
+
}
|
package/dist/mcp/registry.d.ts
CHANGED
|
@@ -30,6 +30,11 @@ export declare const MCP_SUCCESS_MAX_CHARS = 12000;
|
|
|
30
30
|
* behind a plausible-looking name.
|
|
31
31
|
*/
|
|
32
32
|
export declare function sanitizeMcpName(server: string, tool: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Construct the right client for a spec: remote HTTP when `url` is set,
|
|
35
|
+
* else stdio. Used by registration, probe, and prompt runs.
|
|
36
|
+
*/
|
|
37
|
+
export declare function makeMcpClient(name: string, spec: McpServerSpec): McpClientLike;
|
|
33
38
|
export declare function registerMcpServers(cfg: {
|
|
34
39
|
servers: Record<string, McpServerSpec>;
|
|
35
40
|
}, opts: RegisterMcpOpts): Promise<McpRegisterResult>;
|
|
@@ -52,3 +57,11 @@ export declare function loadAndRegisterMcp(opts: {
|
|
|
52
57
|
source: 'global' | 'project';
|
|
53
58
|
}) => Promise<boolean>;
|
|
54
59
|
}): Promise<McpRegisterResult>;
|
|
60
|
+
/**
|
|
61
|
+
* Run one MCP prompt (`/mcp__<server>__<prompt>`) on demand: fresh stdio
|
|
62
|
+
* connection per invocation (no client lifecycle to manage), redacted
|
|
63
|
+
* output. Typing the prompt name is explicit consent, so project servers
|
|
64
|
+
* connect without the registration-time approval gate. Throws McpError /
|
|
65
|
+
* Error on unknown server, disabled server, or prompt failure.
|
|
66
|
+
*/
|
|
67
|
+
export declare function runMcpPrompt(cwd: string, server: string, prompt: string, args: Record<string, string>): Promise<string>;
|
package/dist/mcp/registry.js
CHANGED
|
@@ -11,10 +11,12 @@
|
|
|
11
11
|
* 3. `requireApproval` servers add an ask-rule to the PolicyEngine so the
|
|
12
12
|
* runtime loop prompts before executing.
|
|
13
13
|
*
|
|
14
|
-
* Permission class:
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
14
|
+
* Permission class: the runtime passes each tool's `permission` into
|
|
15
|
+
* `PolicyEngine.evaluate`, and `execute`/`admin` tools with no explicit
|
|
16
|
+
* allow rule fall through to ask (interactive) or deny (headless). We
|
|
17
|
+
* pick the most restrictive class, 'admin' — the same class as
|
|
18
|
+
* `spawn_agent`, since MCP tools execute arbitrary external side effects
|
|
19
|
+
* (read/write/network) outside our control.
|
|
18
20
|
*
|
|
19
21
|
* Debug capture: when `KLYRO_MCP_DEBUG=1` is set, every MCP tool success
|
|
20
22
|
* AND error ALSO writes the UNREDACTED raw JSON payload (pre-redaction,
|
|
@@ -28,6 +30,7 @@ import * as fs from 'node:fs';
|
|
|
28
30
|
import * as os from 'node:os';
|
|
29
31
|
import * as path from 'node:path';
|
|
30
32
|
import { McpClient, McpError } from './client.js';
|
|
33
|
+
import { RemoteMcpClient } from './remote.js';
|
|
31
34
|
import { loadMcpServers } from './config.js';
|
|
32
35
|
import { evaluateMcpPolicy } from './policy.js';
|
|
33
36
|
import { jsonSchemaToZod } from './schema.js';
|
|
@@ -139,9 +142,20 @@ async function executeMcpTool(server, spec, toolDef, client, input, ctx) {
|
|
|
139
142
|
return { ok: false, error: { code: 'TOOL_ERROR', message: redact(errMessage(err)) } };
|
|
140
143
|
}
|
|
141
144
|
}
|
|
145
|
+
/**
|
|
146
|
+
* Construct the right client for a spec: remote HTTP when `url` is set,
|
|
147
|
+
* else stdio. Used by registration, probe, and prompt runs.
|
|
148
|
+
*/
|
|
149
|
+
export function makeMcpClient(name, spec) {
|
|
150
|
+
if (spec.url)
|
|
151
|
+
return new RemoteMcpClient(name, spec);
|
|
152
|
+
if (!spec.command)
|
|
153
|
+
throw new McpError(`mcp server "${name}" has neither command nor url`, 'INVALID_SPEC');
|
|
154
|
+
return new McpClient(name, spec);
|
|
155
|
+
}
|
|
142
156
|
export async function registerMcpServers(cfg, opts) {
|
|
143
157
|
const { registry, policy } = opts;
|
|
144
|
-
const factory = opts.clientFactory ??
|
|
158
|
+
const factory = opts.clientFactory ?? makeMcpClient;
|
|
145
159
|
const registered = [];
|
|
146
160
|
const errors = [];
|
|
147
161
|
const skipped = [];
|
|
@@ -289,3 +303,31 @@ export async function loadAndRegisterMcp(opts) {
|
|
|
289
303
|
};
|
|
290
304
|
}
|
|
291
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* Run one MCP prompt (`/mcp__<server>__<prompt>`) on demand: fresh stdio
|
|
308
|
+
* connection per invocation (no client lifecycle to manage), redacted
|
|
309
|
+
* output. Typing the prompt name is explicit consent, so project servers
|
|
310
|
+
* connect without the registration-time approval gate. Throws McpError /
|
|
311
|
+
* Error on unknown server, disabled server, or prompt failure.
|
|
312
|
+
*/
|
|
313
|
+
export async function runMcpPrompt(cwd, server, prompt, args) {
|
|
314
|
+
const cfg = loadMcpServers(cwd);
|
|
315
|
+
const spec = cfg.servers[server];
|
|
316
|
+
if (!spec)
|
|
317
|
+
throw new Error(`mcp server not found: ${server}`);
|
|
318
|
+
if (spec.disabled)
|
|
319
|
+
throw new Error(`mcp server disabled: ${server}`);
|
|
320
|
+
const client = makeMcpClient(server, spec);
|
|
321
|
+
if (!client.promptsGet)
|
|
322
|
+
throw new Error(`mcp server "${server}" does not support prompts`);
|
|
323
|
+
try {
|
|
324
|
+
const withConnect = client;
|
|
325
|
+
if (typeof withConnect.connect === 'function')
|
|
326
|
+
await withConnect.connect();
|
|
327
|
+
const text = await client.promptsGet(prompt, args);
|
|
328
|
+
return redact(text).slice(0, 8000);
|
|
329
|
+
}
|
|
330
|
+
finally {
|
|
331
|
+
await client.close().catch(() => undefined);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote MCP transport — JSON-RPC 2.0 over HTTP POST (Streamable HTTP).
|
|
3
|
+
*
|
|
4
|
+
* Covers servers that speak plain request/response JSON-RPC at an HTTPS
|
|
5
|
+
* endpoint (the common self-hosted shape). Pure SSE-stream servers that
|
|
6
|
+
* require a persistent GET event stream are NOT supported — `probe` reports
|
|
7
|
+
* that explicitly. Custom headers (e.g. Authorization) come from the spec
|
|
8
|
+
* with `${env:VAR}` expansion applied at load time.
|
|
9
|
+
*/
|
|
10
|
+
import type { McpServerSpec } from './config.js';
|
|
11
|
+
import { type McpClientLike, type McpToolDef, type McpCallResult, type McpResource, type McpPromptDef } from './client.js';
|
|
12
|
+
/** Parse a Streamable-HTTP SSE body (`data: {...}` lines) into JSON payloads. */
|
|
13
|
+
export declare function parseSseBody(text: string): unknown[];
|
|
14
|
+
export declare class RemoteMcpClient implements McpClientLike {
|
|
15
|
+
readonly name: string;
|
|
16
|
+
private readonly spec;
|
|
17
|
+
private nextId;
|
|
18
|
+
private readonly headers;
|
|
19
|
+
private readonly timeoutMs;
|
|
20
|
+
constructor(name: string, spec: McpServerSpec);
|
|
21
|
+
connect(): Promise<void>;
|
|
22
|
+
listTools(signal?: AbortSignal): Promise<McpToolDef[]>;
|
|
23
|
+
callTool(name: string, args: unknown, signal?: AbortSignal): Promise<McpCallResult>;
|
|
24
|
+
listResources(signal?: AbortSignal): Promise<McpResource[]>;
|
|
25
|
+
promptsList(signal?: AbortSignal): Promise<McpPromptDef[]>;
|
|
26
|
+
promptsGet(name: string, args?: Record<string, string>, signal?: AbortSignal): Promise<string>;
|
|
27
|
+
close(): Promise<void>;
|
|
28
|
+
private request;
|
|
29
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { McpError } from './client.js';
|
|
2
|
+
const PROTOCOL_VERSION = '2024-11-05';
|
|
3
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
4
|
+
/** Parse a Streamable-HTTP SSE body (`data: {...}` lines) into JSON payloads. */
|
|
5
|
+
export function parseSseBody(text) {
|
|
6
|
+
const out = [];
|
|
7
|
+
for (const line of text.split('\n')) {
|
|
8
|
+
const t = line.trim();
|
|
9
|
+
if (!t.startsWith('data:'))
|
|
10
|
+
continue;
|
|
11
|
+
const payload = t.slice(5).trim();
|
|
12
|
+
if (payload === '[DONE]')
|
|
13
|
+
continue;
|
|
14
|
+
try {
|
|
15
|
+
out.push(JSON.parse(payload));
|
|
16
|
+
}
|
|
17
|
+
catch { /* ignore malformed lines */ }
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
export class RemoteMcpClient {
|
|
22
|
+
name;
|
|
23
|
+
spec;
|
|
24
|
+
nextId = 1;
|
|
25
|
+
headers;
|
|
26
|
+
timeoutMs;
|
|
27
|
+
constructor(name, spec) {
|
|
28
|
+
this.name = name;
|
|
29
|
+
this.spec = spec;
|
|
30
|
+
if (!spec.url)
|
|
31
|
+
throw new McpError(`mcp server "${name}" has no url`, 'INVALID_SPEC');
|
|
32
|
+
this.headers = { 'Content-Type': 'application/json', Accept: 'application/json, text/event-stream', ...(spec.headers ?? {}) };
|
|
33
|
+
this.timeoutMs = spec.policy?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
34
|
+
}
|
|
35
|
+
async connect() {
|
|
36
|
+
// Handshake doubles as reachability probe; failures surface typed errors.
|
|
37
|
+
await this.request('initialize', {
|
|
38
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
39
|
+
capabilities: {},
|
|
40
|
+
clientInfo: { name: 'klyro', version: 'remote' },
|
|
41
|
+
});
|
|
42
|
+
// Best-effort initialized notification (no id → no response expected).
|
|
43
|
+
try {
|
|
44
|
+
await fetch(this.spec.url, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: this.headers,
|
|
47
|
+
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
|
|
48
|
+
signal: AbortSignal.timeout(5000),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch { /* notification is best-effort */ }
|
|
52
|
+
}
|
|
53
|
+
async listTools(signal) {
|
|
54
|
+
const res = (await this.request('tools/list', {}, this.timeoutMs, signal));
|
|
55
|
+
return Array.isArray(res.tools) ? res.tools : [];
|
|
56
|
+
}
|
|
57
|
+
async callTool(name, args, signal) {
|
|
58
|
+
const res = (await this.request('tools/call', { name, arguments: args ?? {} }, this.timeoutMs, signal));
|
|
59
|
+
const blocks = Array.isArray(res.content) ? res.content : [];
|
|
60
|
+
const text = blocks.map((b) => (typeof b.text === 'string' ? b.text : JSON.stringify(b))).join('\n');
|
|
61
|
+
return { text, isError: res.isError === true, raw: res };
|
|
62
|
+
}
|
|
63
|
+
async listResources(signal) {
|
|
64
|
+
const res = (await this.request('resources/list', {}, this.timeoutMs, signal));
|
|
65
|
+
return Array.isArray(res.resources) ? res.resources : [];
|
|
66
|
+
}
|
|
67
|
+
async promptsList(signal) {
|
|
68
|
+
try {
|
|
69
|
+
const res = (await this.request('prompts/list', {}, this.timeoutMs, signal));
|
|
70
|
+
return Array.isArray(res.prompts) ? res.prompts : [];
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
if (err instanceof McpError && err.code === 'SERVER_ERROR' && err.details?.code === -32601)
|
|
74
|
+
return [];
|
|
75
|
+
throw err;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
async promptsGet(name, args, signal) {
|
|
79
|
+
const res = (await this.request('prompts/get', { name, arguments: args ?? {} }, this.timeoutMs, signal));
|
|
80
|
+
const parts = [];
|
|
81
|
+
if (typeof res.description === 'string' && res.description)
|
|
82
|
+
parts.push(res.description);
|
|
83
|
+
for (const m of Array.isArray(res.messages) ? res.messages : []) {
|
|
84
|
+
const c = m.content;
|
|
85
|
+
if (c && typeof c.text === 'string')
|
|
86
|
+
parts.push(c.text);
|
|
87
|
+
else if (c !== undefined)
|
|
88
|
+
parts.push(JSON.stringify(c));
|
|
89
|
+
}
|
|
90
|
+
return parts.join('\n\n');
|
|
91
|
+
}
|
|
92
|
+
async close() {
|
|
93
|
+
// Stateless HTTP — nothing to tear down.
|
|
94
|
+
}
|
|
95
|
+
async request(method, params, timeoutMs = this.timeoutMs, signal) {
|
|
96
|
+
if (signal?.aborted)
|
|
97
|
+
throw new McpError(`mcp call "${method}" aborted`, 'ABORTED');
|
|
98
|
+
const ctrl = new AbortController();
|
|
99
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
100
|
+
const onAbort = () => ctrl.abort();
|
|
101
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
102
|
+
const id = this.nextId++;
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(this.spec.url, {
|
|
105
|
+
method: 'POST',
|
|
106
|
+
headers: this.headers,
|
|
107
|
+
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
|
108
|
+
signal: ctrl.signal,
|
|
109
|
+
});
|
|
110
|
+
const ctype = res.headers.get('content-type') ?? '';
|
|
111
|
+
const text = await res.text();
|
|
112
|
+
if (!res.ok)
|
|
113
|
+
throw new McpError(`mcp server "${this.name}" HTTP ${res.status}: ${text.slice(0, 300)}`, 'HTTP_ERROR', { status: res.status });
|
|
114
|
+
let body;
|
|
115
|
+
if (ctype.includes('text/event-stream')) {
|
|
116
|
+
const events = parseSseBody(text);
|
|
117
|
+
const last = [...events].reverse().find((e) => typeof e === 'object' && e !== null && 'result' in e);
|
|
118
|
+
const errs = [...events].reverse().find((e) => typeof e === 'object' && e !== null && 'error' in e);
|
|
119
|
+
if (!last && errs?.error)
|
|
120
|
+
throw new McpError(`mcp server "${this.name}" error: ${errs.error.message ?? 'unknown'}`, 'SERVER_ERROR', errs.error);
|
|
121
|
+
if (!last)
|
|
122
|
+
throw new McpError(`mcp server "${this.name}" empty SSE stream`, 'PROTOCOL_ERROR');
|
|
123
|
+
body = last.result;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
try {
|
|
127
|
+
body = JSON.parse(text);
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
throw new McpError(`mcp server "${this.name}" returned non-JSON`, 'PROTOCOL_ERROR');
|
|
131
|
+
}
|
|
132
|
+
const env = body;
|
|
133
|
+
if (env && typeof env === 'object' && 'error' in env && env.error) {
|
|
134
|
+
throw new McpError(`mcp server "${this.name}" error: ${env.error.message ?? 'unknown'}`, 'SERVER_ERROR', env.error);
|
|
135
|
+
}
|
|
136
|
+
body = env && typeof env === 'object' && 'result' in env ? env.result : body;
|
|
137
|
+
}
|
|
138
|
+
return body;
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (err instanceof McpError)
|
|
142
|
+
throw err;
|
|
143
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
144
|
+
throw new McpError(`mcp call "${method}" timed out after ${timeoutMs}ms`, 'TIMEOUT');
|
|
145
|
+
}
|
|
146
|
+
throw new McpError(`mcp call "${method}" failed: ${err instanceof Error ? err.message : String(err)}`, 'IO_ERROR');
|
|
147
|
+
}
|
|
148
|
+
finally {
|
|
149
|
+
clearTimeout(timer);
|
|
150
|
+
signal?.removeEventListener('abort', onAbort);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ToolRegistry } from '../tools/registry.js';
|
|
2
|
+
import type { ToolContext } from '../tools/types.js';
|
|
3
|
+
import { PolicyEngine } from '../policy/engine.js';
|
|
4
|
+
interface JsonRpcRequest {
|
|
5
|
+
jsonrpc?: string;
|
|
6
|
+
id?: string | number | null;
|
|
7
|
+
method?: string;
|
|
8
|
+
params?: {
|
|
9
|
+
name?: string;
|
|
10
|
+
arguments?: unknown;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export interface ServeDeps {
|
|
14
|
+
registry?: ToolRegistry;
|
|
15
|
+
policy?: PolicyEngine;
|
|
16
|
+
ctx?: ToolContext;
|
|
17
|
+
}
|
|
18
|
+
export declare function makeServeDeps(cwd: string, overrides?: ServeDeps): Required<ServeDeps>;
|
|
19
|
+
/** Pure request handler — unit-tested without stdio. Returns null for notifications. */
|
|
20
|
+
export declare function handleMcpRequest(deps: Required<ServeDeps>, msg: JsonRpcRequest): Promise<Record<string, unknown> | null>;
|
|
21
|
+
/** Stdio loop: one JSON-RPC message per line on stdin, responses on stdout. */
|
|
22
|
+
export declare function serveStdio(cwd: string): Promise<number>;
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `klyro mcp serve` — expose the builtin tool registry as a minimal MCP
|
|
3
|
+
* server over stdio (newline-delimited JSON-RPC 2.0).
|
|
4
|
+
*
|
|
5
|
+
* Supported methods: `initialize`, `ping`, `tools/list`, `tools/call`.
|
|
6
|
+
* Every call is gated by the PolicyEngine (default config + builtin rules)
|
|
7
|
+
* in headless mode: `allow` runs, `deny`/`ask` are refused as tool errors
|
|
8
|
+
* (serve cannot prompt, so nothing privileged runs without an explicit
|
|
9
|
+
* allow rule) — serving never bypasses policy.
|
|
10
|
+
*/
|
|
11
|
+
import * as readline from 'node:readline';
|
|
12
|
+
import { builtinRegistry } from '../tools/registry.js';
|
|
13
|
+
import { PolicyEngine, builtinRules, DEFAULT_POLICY_CONFIG } from '../policy/engine.js';
|
|
14
|
+
import { readVersion } from '../version.js';
|
|
15
|
+
export function makeServeDeps(cwd, overrides = {}) {
|
|
16
|
+
return {
|
|
17
|
+
registry: overrides.registry ?? builtinRegistry(),
|
|
18
|
+
policy: overrides.policy ?? new PolicyEngine(builtinRules(), DEFAULT_POLICY_CONFIG),
|
|
19
|
+
ctx: overrides.ctx ?? { cwd, env: process.env, nonInteractive: true },
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
/** Pure request handler — unit-tested without stdio. Returns null for notifications. */
|
|
23
|
+
export async function handleMcpRequest(deps, msg) {
|
|
24
|
+
const id = msg.id ?? null;
|
|
25
|
+
if (msg.method === undefined || typeof msg.method !== 'string') {
|
|
26
|
+
if (id === null || id === undefined)
|
|
27
|
+
return null;
|
|
28
|
+
return { jsonrpc: '2.0', id, error: { code: -32600, message: 'Invalid Request' } };
|
|
29
|
+
}
|
|
30
|
+
if (id === null || id === undefined)
|
|
31
|
+
return null; // notification — no response
|
|
32
|
+
if (msg.method === 'initialize') {
|
|
33
|
+
return {
|
|
34
|
+
jsonrpc: '2.0', id,
|
|
35
|
+
result: {
|
|
36
|
+
protocolVersion: '2024-11-05',
|
|
37
|
+
capabilities: { tools: {} },
|
|
38
|
+
serverInfo: { name: 'klyro', version: readVersion() },
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
if (msg.method === 'ping')
|
|
43
|
+
return { jsonrpc: '2.0', id, result: {} };
|
|
44
|
+
if (msg.method === 'tools/list') {
|
|
45
|
+
const schemas = deps.registry.jsonSchemas();
|
|
46
|
+
return {
|
|
47
|
+
jsonrpc: '2.0', id,
|
|
48
|
+
result: {
|
|
49
|
+
tools: deps.registry.list().map((t) => ({
|
|
50
|
+
name: t.name,
|
|
51
|
+
description: t.description,
|
|
52
|
+
inputSchema: schemas[t.name] ?? { type: 'object' },
|
|
53
|
+
})),
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (msg.method === 'tools/call') {
|
|
58
|
+
const name = msg.params?.name;
|
|
59
|
+
const args = msg.params?.arguments ?? {};
|
|
60
|
+
if (typeof name !== 'string' || !deps.registry.get(name)) {
|
|
61
|
+
return { jsonrpc: '2.0', id, error: { code: -32602, message: `Unknown tool: ${String(name)}` } };
|
|
62
|
+
}
|
|
63
|
+
const decision = await deps.policy.evaluate({ name, input: (args ?? {}), permission: deps.registry.get(name)?.permission }, { cwd: deps.ctx.cwd, nonInteractive: true });
|
|
64
|
+
if (decision.action !== 'allow') {
|
|
65
|
+
return {
|
|
66
|
+
jsonrpc: '2.0', id,
|
|
67
|
+
result: {
|
|
68
|
+
content: [{ type: 'text', text: `POLICY_DENIED: ${decision.action === 'deny' ? decision.reason ?? 'denied' : 'approval required (non-interactive serve cannot prompt)'}` }],
|
|
69
|
+
isError: true,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const res = await deps.registry.execute(name, args, deps.ctx);
|
|
74
|
+
if (!res.ok) {
|
|
75
|
+
const e = res.error;
|
|
76
|
+
return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: `${e.code ?? 'ERROR'}: ${e.message ?? ''}` }], isError: true } };
|
|
77
|
+
}
|
|
78
|
+
return { jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: typeof res.value === 'string' ? res.value : JSON.stringify(res.value) }] } };
|
|
79
|
+
}
|
|
80
|
+
return { jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${msg.method}` } };
|
|
81
|
+
}
|
|
82
|
+
/** Stdio loop: one JSON-RPC message per line on stdin, responses on stdout. */
|
|
83
|
+
export async function serveStdio(cwd) {
|
|
84
|
+
const deps = makeServeDeps(cwd);
|
|
85
|
+
const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
86
|
+
for await (const line of rl) {
|
|
87
|
+
const trimmed = line.trim();
|
|
88
|
+
if (!trimmed)
|
|
89
|
+
continue;
|
|
90
|
+
let msg;
|
|
91
|
+
try {
|
|
92
|
+
msg = JSON.parse(trimmed);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'Parse error' } }) + '\n');
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const res = await handleMcpRequest(deps, msg);
|
|
100
|
+
if (res)
|
|
101
|
+
process.stdout.write(JSON.stringify(res) + '\n');
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
const id = msg.id ?? null;
|
|
105
|
+
if (id !== null && id !== undefined) {
|
|
106
|
+
process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32603, message: err instanceof Error ? err.message : String(err) } }) + '\n');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return 0;
|
|
111
|
+
}
|
|
@@ -11,7 +11,17 @@ export type ApprovalChoice =
|
|
|
11
11
|
/** Yes, and auto-allow this pattern for the rest of the session. */
|
|
12
12
|
| 'always'
|
|
13
13
|
/** Yes, session-allow AND persist the pattern to settings (survives restarts). */
|
|
14
|
-
| 'always-persist'
|
|
14
|
+
| 'always-persist'
|
|
15
|
+
/**
|
|
16
|
+
* Yes, but with edited input. Produced by the TUI modal (`e`): the
|
|
17
|
+
* runtime re-validates + re-evaluates policy on `editedInput` (bounded
|
|
18
|
+
* re-prompt loop) instead of running the original call. The stdin prompt
|
|
19
|
+
* has no editor — `e` there stays `deny`.
|
|
20
|
+
*/
|
|
21
|
+
| {
|
|
22
|
+
kind: 'edit';
|
|
23
|
+
editedInput: Record<string, unknown>;
|
|
24
|
+
};
|
|
15
25
|
export interface ApprovalRequest {
|
|
16
26
|
toolName: string;
|
|
17
27
|
reason: string;
|
|
@@ -37,6 +47,10 @@ export declare function sanitizeForPrompt(s: string): string;
|
|
|
37
47
|
* so stdin stays consistent): lowercase `a` = session-only, UPPERCASE `A` =
|
|
38
48
|
* persist to settings. Case MUST be checked before lowercasing — the old TUI
|
|
39
49
|
* code lowercased first, so `A` silently became session-only `always`.
|
|
50
|
+
*
|
|
51
|
+
* NOTE: `e` maps to `deny` here, but the TUI modal intercepts `e` BEFORE
|
|
52
|
+
* this mapper to open inline edit mode (edit-and-retry). Only callers
|
|
53
|
+
* without an editor (stdin prompt) should rely on the `e`→`deny` mapping.
|
|
40
54
|
*/
|
|
41
55
|
export declare function approvalChoiceForKey(inputStr: string): ApprovalChoice | 'expand' | 'explain' | null;
|
|
42
56
|
/** Default prompt backed by readline on stdin/stdout. */
|
package/dist/policy/approval.js
CHANGED
|
@@ -30,6 +30,10 @@ export function sanitizeForPrompt(s) {
|
|
|
30
30
|
* so stdin stays consistent): lowercase `a` = session-only, UPPERCASE `A` =
|
|
31
31
|
* persist to settings. Case MUST be checked before lowercasing — the old TUI
|
|
32
32
|
* code lowercased first, so `A` silently became session-only `always`.
|
|
33
|
+
*
|
|
34
|
+
* NOTE: `e` maps to `deny` here, but the TUI modal intercepts `e` BEFORE
|
|
35
|
+
* this mapper to open inline edit mode (edit-and-retry). Only callers
|
|
36
|
+
* without an editor (stdin prompt) should rely on the `e`→`deny` mapping.
|
|
33
37
|
*/
|
|
34
38
|
export function approvalChoiceForKey(inputStr) {
|
|
35
39
|
if (inputStr === 'A')
|
|
@@ -101,6 +105,10 @@ export class PatternApprovalCache {
|
|
|
101
105
|
if (key && this.session.has(key))
|
|
102
106
|
return 'allow';
|
|
103
107
|
const choice = await this.inner.ask(req);
|
|
108
|
+
// Edited input is a new call, not an approval of the pattern — pass
|
|
109
|
+
// through without caching so the re-evaluated call prompts on its own.
|
|
110
|
+
if (typeof choice === 'object')
|
|
111
|
+
return choice;
|
|
104
112
|
if ((choice === 'always' || choice === 'always-persist') && key) {
|
|
105
113
|
this.session.add(key);
|
|
106
114
|
}
|
package/dist/policy/engine.d.ts
CHANGED
|
@@ -20,6 +20,13 @@ export interface ToolCallLike {
|
|
|
20
20
|
name: string;
|
|
21
21
|
/** Parsed tool input. */
|
|
22
22
|
input: Record<string, unknown>;
|
|
23
|
+
/**
|
|
24
|
+
* Tool risk class from the registry (`read|edit|execute|admin`).
|
|
25
|
+
* The runtime always passes this; when present, `execute`/`admin`
|
|
26
|
+
* tools fall through to ask/deny instead of the legacy default-allow
|
|
27
|
+
* (see evaluate). Omitted in unit tests → legacy default-allow.
|
|
28
|
+
*/
|
|
29
|
+
permission?: 'read' | 'edit' | 'execute' | 'admin';
|
|
23
30
|
}
|
|
24
31
|
export interface PolicyContext {
|
|
25
32
|
cwd: string;
|
|
@@ -55,7 +62,10 @@ export interface PolicyRule {
|
|
|
55
62
|
export declare const DEFAULT_POLICY_CONFIG: PolicyConfig;
|
|
56
63
|
/**
|
|
57
64
|
* Compose multiple rules. The first rule to return a Decision wins.
|
|
58
|
-
* If none return a Decision,
|
|
65
|
+
* If none return a Decision, privileged tools (`execute`/`admin`, when
|
|
66
|
+
* the caller passes `permission`) fall through to ask (interactive) or
|
|
67
|
+
* deny (headless) instead of allow; everything else defaults to `allow`.
|
|
68
|
+
* `auto` mode keeps the legacy allow-everything behavior.
|
|
59
69
|
*/
|
|
60
70
|
export declare class PolicyEngine {
|
|
61
71
|
private readonly rules;
|
package/dist/policy/engine.js
CHANGED
|
@@ -44,7 +44,10 @@ export const DEFAULT_POLICY_CONFIG = {
|
|
|
44
44
|
};
|
|
45
45
|
/**
|
|
46
46
|
* Compose multiple rules. The first rule to return a Decision wins.
|
|
47
|
-
* If none return a Decision,
|
|
47
|
+
* If none return a Decision, privileged tools (`execute`/`admin`, when
|
|
48
|
+
* the caller passes `permission`) fall through to ask (interactive) or
|
|
49
|
+
* deny (headless) instead of allow; everything else defaults to `allow`.
|
|
50
|
+
* `auto` mode keeps the legacy allow-everything behavior.
|
|
48
51
|
*/
|
|
49
52
|
export class PolicyEngine {
|
|
50
53
|
rules;
|
|
@@ -116,6 +119,16 @@ export class PolicyEngine {
|
|
|
116
119
|
if (d)
|
|
117
120
|
return d;
|
|
118
121
|
}
|
|
122
|
+
// Privileged-class default: an `execute`/`admin` tool that no rule
|
|
123
|
+
// explicitly allowed must not run silently. Interactive sessions get
|
|
124
|
+
// an approval prompt; headless sessions get a denial naming the
|
|
125
|
+
// escape hatch (an explicit `tool`/`tool(glob)` allow rule).
|
|
126
|
+
if (ctx.config.mode !== 'auto' && (call.permission === 'execute' || call.permission === 'admin')) {
|
|
127
|
+
if (ctx.nonInteractive) {
|
|
128
|
+
return { action: 'deny', reason: `${call.name} is a privileged ${call.permission} tool — pre-approve with an allow rule (e.g. "${call.name}")` };
|
|
129
|
+
}
|
|
130
|
+
return { action: 'ask', reason: `${call.name} is a privileged ${call.permission} tool and needs approval` };
|
|
131
|
+
}
|
|
119
132
|
return { action: 'allow' };
|
|
120
133
|
}
|
|
121
134
|
evaluateGlobRules(call) {
|
|
@@ -12,7 +12,10 @@ import { Transform } from 'node:stream';
|
|
|
12
12
|
const PATTERNS = [
|
|
13
13
|
{ name: 'aws-key', re: /AKIA[0-9A-Z]{16}/g },
|
|
14
14
|
{ name: 'aws-secret', re: /(?:aws_secret_access_key|secret)\s*[:=]\s*[A-Za-z0-9/+=]{40}/gi },
|
|
15
|
-
|
|
15
|
+
// Long base64-ish runs must BOTH contain a +/= (excludes pure-hex SHAs
|
|
16
|
+
// and hashes) AND a digit (excludes letter-only words/sentences that
|
|
17
|
+
// happen to be long). Genuine secrets mix classes; prose rarely does.
|
|
18
|
+
{ name: 'aws-secret-b64', re: /(?<![A-Za-z0-9/+=])(?=[A-Za-z0-9/+=]*[+/=])(?=[A-Za-z0-9/+=]*[0-9])[A-Za-z0-9/+=]{40,}={0,2}(?![A-Za-z0-9/+=])/g, },
|
|
16
19
|
{ name: 'pem-block', re: /-----BEGIN [A-Z ]+PRIVATE KEY-----[\s\S]*?-----END [A-Z ]+PRIVATE KEY-----/g },
|
|
17
20
|
{ name: 'github-token', re: /gh[pousr]_[A-Za-z0-9]{36,255}/g },
|
|
18
21
|
{ name: 'slack-token', re: /xox[abprs]-[A-Za-z0-9-]{10,}/g },
|