@thenavidm/slipway 0.1.0
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/CHANGELOG.md +21 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +757 -0
- package/SECURITY.md +33 -0
- package/SKILL.md +136 -0
- package/dist/app.d.ts +210 -0
- package/dist/app.js +364 -0
- package/dist/bin.d.ts +14 -0
- package/dist/bin.js +178 -0
- package/dist/check.d.ts +52 -0
- package/dist/check.js +313 -0
- package/dist/cli/completion.d.ts +5 -0
- package/dist/cli/completion.js +72 -0
- package/dist/cli/context.d.ts +97 -0
- package/dist/cli/context.js +94 -0
- package/dist/cli/data.d.ts +17 -0
- package/dist/cli/data.js +120 -0
- package/dist/cli/flags.d.ts +35 -0
- package/dist/cli/flags.js +208 -0
- package/dist/cli/help.d.ts +15 -0
- package/dist/cli/help.js +213 -0
- package/dist/cli/install.d.ts +10 -0
- package/dist/cli/install.js +65 -0
- package/dist/cli/output.d.ts +22 -0
- package/dist/cli/output.js +159 -0
- package/dist/cli/run.d.ts +10 -0
- package/dist/cli/run.js +343 -0
- package/dist/confirm.d.ts +90 -0
- package/dist/confirm.js +166 -0
- package/dist/data.d.ts +109 -0
- package/dist/data.js +324 -0
- package/dist/docs.d.ts +13 -0
- package/dist/docs.js +66 -0
- package/dist/doctor.d.ts +12 -0
- package/dist/doctor.js +100 -0
- package/dist/entry.d.ts +11 -0
- package/dist/entry.js +34 -0
- package/dist/errors.d.ts +96 -0
- package/dist/errors.js +153 -0
- package/dist/guard.d.ts +46 -0
- package/dist/guard.js +89 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +16 -0
- package/dist/install.d.ts +98 -0
- package/dist/install.js +325 -0
- package/dist/jobs.d.ts +143 -0
- package/dist/jobs.js +247 -0
- package/dist/openapi.d.ts +127 -0
- package/dist/openapi.js +549 -0
- package/dist/pages.d.ts +22 -0
- package/dist/pages.js +61 -0
- package/dist/policy.d.ts +66 -0
- package/dist/policy.js +74 -0
- package/dist/redact.d.ts +18 -0
- package/dist/redact.js +60 -0
- package/dist/result.d.ts +44 -0
- package/dist/result.js +74 -0
- package/dist/rpc.d.ts +69 -0
- package/dist/rpc.js +120 -0
- package/dist/schema.d.ts +99 -0
- package/dist/schema.js +192 -0
- package/dist/search.d.ts +18 -0
- package/dist/search.js +119 -0
- package/dist/serve.d.ts +30 -0
- package/dist/serve.js +149 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +244 -0
- package/dist/sync.d.ts +35 -0
- package/dist/sync.js +119 -0
- package/dist/testing.d.ts +35 -0
- package/dist/testing.js +41 -0
- package/dist/tool.d.ts +194 -0
- package/dist/tool.js +151 -0
- package/dist/util.d.ts +12 -0
- package/dist/util.js +35 -0
- package/package.json +89 -0
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a person running the server decides, read from the environment.
|
|
3
|
+
*
|
|
4
|
+
* The person who installs a server is not always the person who wrote it, and
|
|
5
|
+
* the switches that matter to them are the same on every server: hide writes,
|
|
6
|
+
* block the irreversible ones, keep a log, load fewer tools. They live under
|
|
7
|
+
* the server's own prefix, so two servers in one client never share a switch.
|
|
8
|
+
*/
|
|
9
|
+
import type { Risk, Tool } from "./tool.js";
|
|
10
|
+
export type ToolSurface = "full" | "search";
|
|
11
|
+
/**
|
|
12
|
+
* Who confirms a call that needs confirmation.
|
|
13
|
+
*
|
|
14
|
+
* - `human`: a person, wherever the client can ask one. Claude Code shows its
|
|
15
|
+
* own approval prompt, other clients that support elicitation show an
|
|
16
|
+
* approval form, and only a client that can do neither falls back to the
|
|
17
|
+
* model passing `confirm: true`.
|
|
18
|
+
* - `model`: `confirm: true` from the model is enough. For a headless agent
|
|
19
|
+
* with no person to ask.
|
|
20
|
+
*/
|
|
21
|
+
export type ConfirmMode = "human" | "model";
|
|
22
|
+
export type Policy = {
|
|
23
|
+
readOnly: boolean;
|
|
24
|
+
allowDestructive: boolean;
|
|
25
|
+
auditLog?: string;
|
|
26
|
+
/** `all`, or the toolsets that are on. Tools with no tags are always on. */
|
|
27
|
+
toolsets: "all" | ReadonlySet<string>;
|
|
28
|
+
/** `search` replaces the tool list with three tools that find, describe and call the rest. */
|
|
29
|
+
surface: ToolSurface;
|
|
30
|
+
toolTimeoutMs?: number;
|
|
31
|
+
confirm: ConfirmMode;
|
|
32
|
+
/** Whether reads that opted in may answer from the local cache. */
|
|
33
|
+
cache: boolean;
|
|
34
|
+
};
|
|
35
|
+
export type PolicyDefaults = {
|
|
36
|
+
/**
|
|
37
|
+
* The toolsets on when `<PREFIX>_TOOLSETS` is unset. A function receives the
|
|
38
|
+
* environment, so a server can keep an older switch working, such as a
|
|
39
|
+
* `<PREFIX>_ENABLE_BETA=1` that predates toolsets.
|
|
40
|
+
*/
|
|
41
|
+
toolsets?: readonly string[] | "all" | ((env: NodeJS.ProcessEnv) => readonly string[] | "all");
|
|
42
|
+
surface?: ToolSurface;
|
|
43
|
+
confirm?: ConfirmMode;
|
|
44
|
+
};
|
|
45
|
+
export type PolicyEnv = {
|
|
46
|
+
readOnly: string;
|
|
47
|
+
allowDestructive: string;
|
|
48
|
+
auditLog: string;
|
|
49
|
+
toolsets: string;
|
|
50
|
+
surface: string;
|
|
51
|
+
toolTimeoutMs: string;
|
|
52
|
+
confirm: string;
|
|
53
|
+
cache: string;
|
|
54
|
+
dataDir: string;
|
|
55
|
+
};
|
|
56
|
+
export declare function policyEnvNames(prefix: string): PolicyEnv;
|
|
57
|
+
export declare function readPolicy(env: NodeJS.ProcessEnv, prefix: string, defaults?: PolicyDefaults): Policy;
|
|
58
|
+
export type Visibility = {
|
|
59
|
+
visible: true;
|
|
60
|
+
} | {
|
|
61
|
+
visible: false;
|
|
62
|
+
reason: "read-only" | "toolset";
|
|
63
|
+
};
|
|
64
|
+
/** Whether a tool is on under this policy, and if not, why, so a refusal can say how to turn it on. */
|
|
65
|
+
export declare function visibility(tool: Pick<Tool, "risk" | "tags">, policy: Policy): Visibility;
|
|
66
|
+
export declare function riskMark(risk: Risk): string;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a person running the server decides, read from the environment.
|
|
3
|
+
*
|
|
4
|
+
* The person who installs a server is not always the person who wrote it, and
|
|
5
|
+
* the switches that matter to them are the same on every server: hide writes,
|
|
6
|
+
* block the irreversible ones, keep a log, load fewer tools. They live under
|
|
7
|
+
* the server's own prefix, so two servers in one client never share a switch.
|
|
8
|
+
*/
|
|
9
|
+
export function policyEnvNames(prefix) {
|
|
10
|
+
return {
|
|
11
|
+
readOnly: `${prefix}_READ_ONLY`,
|
|
12
|
+
allowDestructive: `${prefix}_ALLOW_DESTRUCTIVE`,
|
|
13
|
+
auditLog: `${prefix}_AUDIT_LOG`,
|
|
14
|
+
toolsets: `${prefix}_TOOLSETS`,
|
|
15
|
+
surface: `${prefix}_SURFACE`,
|
|
16
|
+
toolTimeoutMs: `${prefix}_TOOL_TIMEOUT_MS`,
|
|
17
|
+
confirm: `${prefix}_CONFIRM`,
|
|
18
|
+
cache: `${prefix}_CACHE`,
|
|
19
|
+
dataDir: `${prefix}_DATA_DIR`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const TRUE = /^(1|true|yes|on)$/i;
|
|
23
|
+
const FALSE = /^(0|false|no|off)$/i;
|
|
24
|
+
function flag(value, fallback) {
|
|
25
|
+
if (value === undefined || value.trim() === "")
|
|
26
|
+
return fallback;
|
|
27
|
+
if (TRUE.test(value.trim()))
|
|
28
|
+
return true;
|
|
29
|
+
if (FALSE.test(value.trim()))
|
|
30
|
+
return false;
|
|
31
|
+
return fallback;
|
|
32
|
+
}
|
|
33
|
+
export function readPolicy(env, prefix, defaults = {}) {
|
|
34
|
+
const names = policyEnvNames(prefix);
|
|
35
|
+
const rawToolsets = env[names.toolsets]?.trim();
|
|
36
|
+
const fromDefaults = (typeof defaults.toolsets === "function" ? defaults.toolsets(env) : defaults.toolsets) ?? "all";
|
|
37
|
+
const toolsets = rawToolsets === undefined || rawToolsets === ""
|
|
38
|
+
? fromDefaults === "all"
|
|
39
|
+
? "all"
|
|
40
|
+
: new Set(fromDefaults)
|
|
41
|
+
: rawToolsets.toLowerCase() === "all"
|
|
42
|
+
? "all"
|
|
43
|
+
: new Set(rawToolsets
|
|
44
|
+
.split(",")
|
|
45
|
+
.map((part) => part.trim().toLowerCase())
|
|
46
|
+
.filter(Boolean));
|
|
47
|
+
const surface = env[names.surface]?.trim().toLowerCase();
|
|
48
|
+
const timeout = Number(env[names.toolTimeoutMs]);
|
|
49
|
+
const confirm = env[names.confirm]?.trim().toLowerCase();
|
|
50
|
+
return {
|
|
51
|
+
readOnly: flag(env[names.readOnly], false),
|
|
52
|
+
allowDestructive: flag(env[names.allowDestructive], true),
|
|
53
|
+
auditLog: env[names.auditLog]?.trim() || undefined,
|
|
54
|
+
toolsets,
|
|
55
|
+
surface: surface === "search" || surface === "full" ? surface : (defaults.surface ?? "full"),
|
|
56
|
+
toolTimeoutMs: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
|
|
57
|
+
confirm: confirm === "human" || confirm === "model" ? confirm : (defaults.confirm ?? "human"),
|
|
58
|
+
cache: flag(env[names.cache], true),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
/** Whether a tool is on under this policy, and if not, why, so a refusal can say how to turn it on. */
|
|
62
|
+
export function visibility(tool, policy) {
|
|
63
|
+
if (policy.readOnly && tool.risk !== "read")
|
|
64
|
+
return { visible: false, reason: "read-only" };
|
|
65
|
+
if (policy.toolsets !== "all" && tool.tags.length > 0) {
|
|
66
|
+
const on = policy.toolsets;
|
|
67
|
+
if (!tool.tags.some((tag) => on.has(tag)))
|
|
68
|
+
return { visible: false, reason: "toolset" };
|
|
69
|
+
}
|
|
70
|
+
return { visible: true };
|
|
71
|
+
}
|
|
72
|
+
export function riskMark(risk) {
|
|
73
|
+
return risk === "read" ? " " : risk === "destructive" ? "!" : "*";
|
|
74
|
+
}
|
package/dist/redact.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps credentials out of everything a tool returns.
|
|
3
|
+
*
|
|
4
|
+
* An upstream API will happily echo a key back in an error message or a
|
|
5
|
+
* response body, and from there it lands in a model's context, a terminal
|
|
6
|
+
* scrollback or an audit log. Every result and every error on both surfaces
|
|
7
|
+
* passes through here, so a secret registered once is masked everywhere.
|
|
8
|
+
*/
|
|
9
|
+
export declare class Secrets {
|
|
10
|
+
private readonly values;
|
|
11
|
+
/** Register values that must never appear in output: keys, tokens, passwords. */
|
|
12
|
+
add(...values: Array<string | undefined | null>): void;
|
|
13
|
+
get size(): number;
|
|
14
|
+
redact(text: string): string;
|
|
15
|
+
/** Walk a value and mask registered secrets in strings and every credential-named field. */
|
|
16
|
+
redactDeep<T>(value: T): T;
|
|
17
|
+
private walk;
|
|
18
|
+
}
|
package/dist/redact.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keeps credentials out of everything a tool returns.
|
|
3
|
+
*
|
|
4
|
+
* An upstream API will happily echo a key back in an error message or a
|
|
5
|
+
* response body, and from there it lands in a model's context, a terminal
|
|
6
|
+
* scrollback or an audit log. Every result and every error on both surfaces
|
|
7
|
+
* passes through here, so a secret registered once is masked everywhere.
|
|
8
|
+
*/
|
|
9
|
+
const MASK = "[redacted]";
|
|
10
|
+
/** Field names whose values are credentials whatever they contain. */
|
|
11
|
+
const SECRET_KEYS = /^(authorization|proxy[-_]?authorization|cookie|set[-_]?cookie|password|passwd|secret|client[-_]?secret|api[-_]?key|apikey|access[-_]?token|refresh[-_]?token|id[-_]?token|token|private[-_]?key|session[-_]?token)$/i;
|
|
12
|
+
/**
|
|
13
|
+
* Values shorter than this are not masked, because a short "secret" such as a
|
|
14
|
+
* default region code would turn every matching word in a response into noise.
|
|
15
|
+
*/
|
|
16
|
+
const MIN_LENGTH = 6;
|
|
17
|
+
export class Secrets {
|
|
18
|
+
values = new Set();
|
|
19
|
+
/** Register values that must never appear in output: keys, tokens, passwords. */
|
|
20
|
+
add(...values) {
|
|
21
|
+
for (const value of values) {
|
|
22
|
+
if (typeof value === "string" && value.length >= MIN_LENGTH)
|
|
23
|
+
this.values.add(value);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
get size() {
|
|
27
|
+
return this.values.size;
|
|
28
|
+
}
|
|
29
|
+
redact(text) {
|
|
30
|
+
let out = text;
|
|
31
|
+
// Longest first, so a secret that contains another is masked whole.
|
|
32
|
+
for (const value of [...this.values].sort((a, b) => b.length - a.length)) {
|
|
33
|
+
if (out.includes(value))
|
|
34
|
+
out = out.split(value).join(MASK);
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
/** Walk a value and mask registered secrets in strings and every credential-named field. */
|
|
39
|
+
redactDeep(value) {
|
|
40
|
+
return this.walk(value, new WeakSet());
|
|
41
|
+
}
|
|
42
|
+
walk(value, seen) {
|
|
43
|
+
if (typeof value === "string")
|
|
44
|
+
return this.redact(value);
|
|
45
|
+
if (value === null || typeof value !== "object")
|
|
46
|
+
return value;
|
|
47
|
+
if (value instanceof Uint8Array)
|
|
48
|
+
return value;
|
|
49
|
+
if (seen.has(value))
|
|
50
|
+
return "[circular]";
|
|
51
|
+
seen.add(value);
|
|
52
|
+
if (Array.isArray(value))
|
|
53
|
+
return value.map((item) => this.walk(item, seen));
|
|
54
|
+
const out = {};
|
|
55
|
+
for (const [key, inner] of Object.entries(value)) {
|
|
56
|
+
out[key] = SECRET_KEYS.test(key) && inner !== null && inner !== undefined && inner !== "" ? MASK : this.walk(inner, seen);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
}
|
package/dist/result.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning what a handler returns into what a client receives.
|
|
3
|
+
*
|
|
4
|
+
* A handler returns plain data. An object goes out twice: as compact JSON text,
|
|
5
|
+
* which every client can show a model, and as `structuredContent`, which a
|
|
6
|
+
* client can use without parsing text. Compact, because the reader of that text
|
|
7
|
+
* is usually a model paying for every character, not a person.
|
|
8
|
+
*/
|
|
9
|
+
import type { CallToolResult, ContentBlock } from "@modelcontextprotocol/server";
|
|
10
|
+
import type { SlipwayError } from "./errors.js";
|
|
11
|
+
import type { Secrets } from "./redact.js";
|
|
12
|
+
import type { Tool } from "./tool.js";
|
|
13
|
+
declare const CONTENT: unique symbol;
|
|
14
|
+
/** A result that is already content blocks: images, audio, files, links, or several texts. */
|
|
15
|
+
export type ContentResult = {
|
|
16
|
+
readonly [CONTENT]: true;
|
|
17
|
+
readonly parts: readonly ContentBlock[];
|
|
18
|
+
/** Typed data to send alongside, and to print in a terminal. */
|
|
19
|
+
readonly data?: unknown;
|
|
20
|
+
};
|
|
21
|
+
export declare function content(parts: ContentBlock | readonly ContentBlock[], data?: unknown): ContentResult;
|
|
22
|
+
export declare function isContentResult(value: unknown): value is ContentResult;
|
|
23
|
+
/** An image. Pass bytes, or a base64 string you already have. */
|
|
24
|
+
export declare function image(data: Uint8Array | string, mimeType: string): ContentBlock;
|
|
25
|
+
export declare function audio(data: Uint8Array | string, mimeType: string): ContentBlock;
|
|
26
|
+
/** A file embedded in the result, for a client that saves or renders it. */
|
|
27
|
+
export declare function file(data: Uint8Array | string, options: {
|
|
28
|
+
uri: string;
|
|
29
|
+
mimeType: string;
|
|
30
|
+
}): ContentBlock;
|
|
31
|
+
/** A pointer to something the client can fetch, without its bytes in the result. */
|
|
32
|
+
export declare function resourceLink(uri: string, options: {
|
|
33
|
+
name: string;
|
|
34
|
+
title?: string;
|
|
35
|
+
description?: string;
|
|
36
|
+
mimeType?: string;
|
|
37
|
+
}): ContentBlock;
|
|
38
|
+
export declare function text(value: string): ContentBlock;
|
|
39
|
+
export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
|
|
40
|
+
/** What a handler returned, as data: the value itself, or a content result's data. */
|
|
41
|
+
export declare function dataOf(value: unknown): unknown;
|
|
42
|
+
export declare function toCallToolResult(tool: Tool, value: unknown, secrets: Secrets): CallToolResult;
|
|
43
|
+
export declare function errorResult(error: SlipwayError, secrets: Secrets): CallToolResult;
|
|
44
|
+
export {};
|
package/dist/result.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning what a handler returns into what a client receives.
|
|
3
|
+
*
|
|
4
|
+
* A handler returns plain data. An object goes out twice: as compact JSON text,
|
|
5
|
+
* which every client can show a model, and as `structuredContent`, which a
|
|
6
|
+
* client can use without parsing text. Compact, because the reader of that text
|
|
7
|
+
* is usually a model paying for every character, not a person.
|
|
8
|
+
*/
|
|
9
|
+
const CONTENT = Symbol.for("slipway.content");
|
|
10
|
+
export function content(parts, data) {
|
|
11
|
+
return { [CONTENT]: true, parts: Array.isArray(parts) ? [...parts] : [parts], data };
|
|
12
|
+
}
|
|
13
|
+
export function isContentResult(value) {
|
|
14
|
+
return value !== null && typeof value === "object" && value[CONTENT] === true;
|
|
15
|
+
}
|
|
16
|
+
function base64(data) {
|
|
17
|
+
return typeof data === "string" ? data : Buffer.from(data).toString("base64");
|
|
18
|
+
}
|
|
19
|
+
/** An image. Pass bytes, or a base64 string you already have. */
|
|
20
|
+
export function image(data, mimeType) {
|
|
21
|
+
return { type: "image", data: base64(data), mimeType };
|
|
22
|
+
}
|
|
23
|
+
export function audio(data, mimeType) {
|
|
24
|
+
return { type: "audio", data: base64(data), mimeType };
|
|
25
|
+
}
|
|
26
|
+
/** A file embedded in the result, for a client that saves or renders it. */
|
|
27
|
+
export function file(data, options) {
|
|
28
|
+
return { type: "resource", resource: { uri: options.uri, mimeType: options.mimeType, blob: base64(data) } };
|
|
29
|
+
}
|
|
30
|
+
/** A pointer to something the client can fetch, without its bytes in the result. */
|
|
31
|
+
export function resourceLink(uri, options) {
|
|
32
|
+
return { type: "resource_link", uri, ...options };
|
|
33
|
+
}
|
|
34
|
+
export function text(value) {
|
|
35
|
+
return { type: "text", text: value };
|
|
36
|
+
}
|
|
37
|
+
export function isPlainObject(value) {
|
|
38
|
+
if (value === null || typeof value !== "object")
|
|
39
|
+
return false;
|
|
40
|
+
const proto = Object.getPrototypeOf(value);
|
|
41
|
+
return proto === Object.prototype || proto === null;
|
|
42
|
+
}
|
|
43
|
+
/** What a handler returned, as data: the value itself, or a content result's data. */
|
|
44
|
+
export function dataOf(value) {
|
|
45
|
+
return isContentResult(value) ? value.data : value;
|
|
46
|
+
}
|
|
47
|
+
export function toCallToolResult(tool, value, secrets) {
|
|
48
|
+
if (isContentResult(value)) {
|
|
49
|
+
const parts = value.parts.map((part) => part.type === "text" ? { ...part, text: secrets.redact(part.text) } : part);
|
|
50
|
+
const data = secrets.redactDeep(value.data);
|
|
51
|
+
const structured = data !== undefined && (isPlainObject(data) || tool.output !== undefined);
|
|
52
|
+
return structured ? { content: parts, structuredContent: data } : { content: parts };
|
|
53
|
+
}
|
|
54
|
+
const data = secrets.redactDeep(value);
|
|
55
|
+
if (data === undefined || data === null) {
|
|
56
|
+
return { content: [text("Done.")] };
|
|
57
|
+
}
|
|
58
|
+
const rendered = tool.render ? secrets.redact(tool.render(data)) : undefined;
|
|
59
|
+
if (typeof data === "string") {
|
|
60
|
+
return tool.output
|
|
61
|
+
? { content: [text(rendered ?? data)], structuredContent: data }
|
|
62
|
+
: { content: [text(rendered ?? data)] };
|
|
63
|
+
}
|
|
64
|
+
const body = rendered ?? (typeof data === "object" ? JSON.stringify(data) : String(data));
|
|
65
|
+
// An object is always typed data. Anything else only is when the tool declares
|
|
66
|
+
// an output schema, because the protocol needs a schema to describe it.
|
|
67
|
+
if (isPlainObject(data) || tool.output !== undefined) {
|
|
68
|
+
return { content: [text(body)], structuredContent: data };
|
|
69
|
+
}
|
|
70
|
+
return { content: [text(body)] };
|
|
71
|
+
}
|
|
72
|
+
export function errorResult(error, secrets) {
|
|
73
|
+
return { isError: true, content: [text(secrets.redact(JSON.stringify(secrets.redactDeep(error.toJSON()))))] };
|
|
74
|
+
}
|
package/dist/rpc.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal MCP client over an in-memory pair, for checks and tests.
|
|
3
|
+
*
|
|
4
|
+
* It speaks raw JSON-RPC rather than wrapping a full client library, which
|
|
5
|
+
* keeps the client package out of every server's install and shows exactly
|
|
6
|
+
* what a client receives on the wire. The server side is the same stdio entry
|
|
7
|
+
* the binary runs, so both protocol eras are served exactly as a real client
|
|
8
|
+
* reaches them: the 2025 handshake, and the 2026-07-28 revision where every
|
|
9
|
+
* request carries the client's details and a call can come back asking for
|
|
10
|
+
* input before it finishes.
|
|
11
|
+
*/
|
|
12
|
+
import { type CallToolResult } from "@modelcontextprotocol/server";
|
|
13
|
+
import type { App } from "./app.js";
|
|
14
|
+
export type ListedTool = {
|
|
15
|
+
name: string;
|
|
16
|
+
title?: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
inputSchema: Record<string, unknown>;
|
|
19
|
+
outputSchema?: Record<string, unknown>;
|
|
20
|
+
annotations?: Record<string, unknown>;
|
|
21
|
+
icons?: unknown[];
|
|
22
|
+
_meta?: Record<string, unknown>;
|
|
23
|
+
};
|
|
24
|
+
export type InitializeResult = {
|
|
25
|
+
protocolVersion: string;
|
|
26
|
+
serverInfo: {
|
|
27
|
+
name: string;
|
|
28
|
+
version: string;
|
|
29
|
+
title?: string;
|
|
30
|
+
};
|
|
31
|
+
capabilities: Record<string, unknown>;
|
|
32
|
+
instructions?: string;
|
|
33
|
+
};
|
|
34
|
+
/** What a server asks a person through the client: a message and the fields of a form. */
|
|
35
|
+
export type ElicitRequest = {
|
|
36
|
+
message: string;
|
|
37
|
+
requestedSchema?: Record<string, unknown>;
|
|
38
|
+
mode?: string;
|
|
39
|
+
};
|
|
40
|
+
export type ElicitAnswer = {
|
|
41
|
+
action: "accept" | "decline" | "cancel";
|
|
42
|
+
content?: Record<string, unknown>;
|
|
43
|
+
};
|
|
44
|
+
export type ConnectOptions = {
|
|
45
|
+
/** `legacy` opens with the 2025 handshake, `modern` with the 2026-07-28 revision. */
|
|
46
|
+
era?: "legacy" | "modern";
|
|
47
|
+
/** Who the client says it is. Some behavior depends on it: Claude Code prompts for confirmed tools itself. */
|
|
48
|
+
clientInfo?: {
|
|
49
|
+
name: string;
|
|
50
|
+
version: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Answer the server's approval forms and other questions, as a person would.
|
|
54
|
+
* Setting it declares that the client can ask a person.
|
|
55
|
+
*/
|
|
56
|
+
elicit?: (request: ElicitRequest) => ElicitAnswer | Promise<ElicitAnswer>;
|
|
57
|
+
};
|
|
58
|
+
export type RpcClient = {
|
|
59
|
+
era: "legacy" | "modern";
|
|
60
|
+
initialize: InitializeResult;
|
|
61
|
+
/** A request, answered and retried for as long as the server asks for input, the way a client does. */
|
|
62
|
+
request(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
63
|
+
/** One request and the server's first answer, with no retry: for testing what the protocol carries. */
|
|
64
|
+
send(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
65
|
+
listTools(): Promise<ListedTool[]>;
|
|
66
|
+
callTool(name: string, args?: Record<string, unknown>): Promise<CallToolResult>;
|
|
67
|
+
close(): Promise<void>;
|
|
68
|
+
};
|
|
69
|
+
export declare function connectInMemory(app: App, env?: NodeJS.ProcessEnv, options?: ConnectOptions): Promise<RpcClient>;
|
package/dist/rpc.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A minimal MCP client over an in-memory pair, for checks and tests.
|
|
3
|
+
*
|
|
4
|
+
* It speaks raw JSON-RPC rather than wrapping a full client library, which
|
|
5
|
+
* keeps the client package out of every server's install and shows exactly
|
|
6
|
+
* what a client receives on the wire. The server side is the same stdio entry
|
|
7
|
+
* the binary runs, so both protocol eras are served exactly as a real client
|
|
8
|
+
* reaches them: the 2025 handshake, and the 2026-07-28 revision where every
|
|
9
|
+
* request carries the client's details and a call can come back asking for
|
|
10
|
+
* input before it finishes.
|
|
11
|
+
*/
|
|
12
|
+
import { InMemoryTransport } from "@modelcontextprotocol/server";
|
|
13
|
+
import { serveStdio } from "@modelcontextprotocol/server/stdio";
|
|
14
|
+
const MODERN = "2026-07-28";
|
|
15
|
+
const LEGACY = "2025-11-25";
|
|
16
|
+
const KEY = {
|
|
17
|
+
protocolVersion: "io.modelcontextprotocol/protocolVersion",
|
|
18
|
+
clientInfo: "io.modelcontextprotocol/clientInfo",
|
|
19
|
+
clientCapabilities: "io.modelcontextprotocol/clientCapabilities",
|
|
20
|
+
serverInfo: "io.modelcontextprotocol/serverInfo",
|
|
21
|
+
};
|
|
22
|
+
/** How many times one call may come back asking for input before the client gives up, as the SDK's own client does. */
|
|
23
|
+
const MAX_ROUNDS = 8;
|
|
24
|
+
export async function connectInMemory(app, env = process.env, options = {}) {
|
|
25
|
+
const era = options.era ?? "legacy";
|
|
26
|
+
const clientInfo = options.clientInfo ?? { name: "slipway-check", version: "0" };
|
|
27
|
+
const capabilities = options.elicit ? { elicitation: { form: {} } } : {};
|
|
28
|
+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
|
|
29
|
+
const handle = serveStdio(() => app.createServer(env), { transport: serverSide });
|
|
30
|
+
let nextId = 0;
|
|
31
|
+
const pending = new Map();
|
|
32
|
+
/** What this client answers when the server asks it something, in either era. */
|
|
33
|
+
const answer = async (method, params) => {
|
|
34
|
+
if (method === "elicitation/create" && options.elicit)
|
|
35
|
+
return options.elicit(params);
|
|
36
|
+
if (method === "ping")
|
|
37
|
+
return {};
|
|
38
|
+
if (method === "roots/list")
|
|
39
|
+
return { roots: [] };
|
|
40
|
+
throw Object.assign(new Error(`This client does not answer ${method}.`), { code: -32601 });
|
|
41
|
+
};
|
|
42
|
+
clientSide.onmessage = (raw) => {
|
|
43
|
+
const message = raw;
|
|
44
|
+
if (message.method !== undefined) {
|
|
45
|
+
// A request from the server, which only the 2025 handshake sends this way.
|
|
46
|
+
if (message.id === undefined)
|
|
47
|
+
return;
|
|
48
|
+
const id = message.id;
|
|
49
|
+
void answer(message.method, message.params).then((result) => clientSide.send({ jsonrpc: "2.0", id, result: result }), (error) => clientSide.send({ jsonrpc: "2.0", id, error: { code: error.code ?? -32603, message: error.message } }));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (message.id === undefined || !pending.has(message.id))
|
|
53
|
+
return;
|
|
54
|
+
const waiter = pending.get(message.id);
|
|
55
|
+
pending.delete(message.id);
|
|
56
|
+
if (message.error)
|
|
57
|
+
waiter.reject(Object.assign(new Error(message.error.message), { code: message.error.code }));
|
|
58
|
+
else
|
|
59
|
+
waiter.resolve(message.result);
|
|
60
|
+
};
|
|
61
|
+
await clientSide.start();
|
|
62
|
+
const envelope = { [KEY.protocolVersion]: MODERN, [KEY.clientInfo]: clientInfo, [KEY.clientCapabilities]: capabilities };
|
|
63
|
+
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
|
64
|
+
const id = ++nextId;
|
|
65
|
+
pending.set(id, { resolve, reject });
|
|
66
|
+
const body = era === "modern" ? { ...params, _meta: { ...(params._meta ?? {}), ...envelope } } : params;
|
|
67
|
+
void clientSide.send({ jsonrpc: "2.0", id, method, params: body });
|
|
68
|
+
});
|
|
69
|
+
/** A request, retried with answers for as long as the server comes back asking for input. */
|
|
70
|
+
const request = async (method, params = {}) => {
|
|
71
|
+
let result = (await send(method, params));
|
|
72
|
+
for (let round = 0; result?.resultType === "input_required"; round++) {
|
|
73
|
+
if (round >= MAX_ROUNDS)
|
|
74
|
+
throw new Error(`${method} still asked for input after ${MAX_ROUNDS} rounds.`);
|
|
75
|
+
const inputResponses = {};
|
|
76
|
+
for (const [key, entry] of Object.entries(result.inputRequests ?? {})) {
|
|
77
|
+
inputResponses[key] = await answer(entry.method, entry.params);
|
|
78
|
+
}
|
|
79
|
+
result = (await send(method, { ...params, inputResponses, ...(result.requestState === undefined ? {} : { requestState: result.requestState }) }));
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
};
|
|
83
|
+
let initialize;
|
|
84
|
+
if (era === "modern") {
|
|
85
|
+
const found = (await send("server/discover"));
|
|
86
|
+
initialize = {
|
|
87
|
+
protocolVersion: MODERN,
|
|
88
|
+
serverInfo: found._meta?.[KEY.serverInfo] ?? { name: "", version: "" },
|
|
89
|
+
capabilities: found.capabilities ?? {},
|
|
90
|
+
...(found.instructions === undefined ? {} : { instructions: found.instructions }),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
initialize = (await send("initialize", { protocolVersion: LEGACY, capabilities, clientInfo }));
|
|
95
|
+
await clientSide.send({ jsonrpc: "2.0", method: "notifications/initialized" });
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
era,
|
|
99
|
+
initialize,
|
|
100
|
+
request,
|
|
101
|
+
send,
|
|
102
|
+
async listTools() {
|
|
103
|
+
const tools = [];
|
|
104
|
+
let cursor;
|
|
105
|
+
do {
|
|
106
|
+
const page = (await request("tools/list", cursor ? { cursor } : {}));
|
|
107
|
+
tools.push(...page.tools);
|
|
108
|
+
cursor = page.nextCursor;
|
|
109
|
+
} while (cursor);
|
|
110
|
+
return tools;
|
|
111
|
+
},
|
|
112
|
+
async callTool(name, args = {}) {
|
|
113
|
+
return (await request("tools/call", { name, arguments: args }));
|
|
114
|
+
},
|
|
115
|
+
async close() {
|
|
116
|
+
await handle.close();
|
|
117
|
+
await clientSide.close().catch(() => undefined);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One schema interface for every tool, whatever wrote it.
|
|
3
|
+
*
|
|
4
|
+
* A hand-written tool uses Zod. A tool generated from an API contract uses the
|
|
5
|
+
* contract's JSON Schema. Both become a Standard Schema object here, which is
|
|
6
|
+
* what the MCP SDK validates and advertises, and what the CLI derives its
|
|
7
|
+
* flags from. Because both surfaces read the same object, an argument one
|
|
8
|
+
* accepts the other accepts too.
|
|
9
|
+
*/
|
|
10
|
+
import { type StandardSchemaWithJSON } from "@modelcontextprotocol/server";
|
|
11
|
+
export type JsonSchema = Record<string, unknown>;
|
|
12
|
+
export type Schema<Input = unknown, Output = Input> = StandardSchemaWithJSON<Input, Output>;
|
|
13
|
+
/** What the Standard Schema spec says a schema declares about its own types. */
|
|
14
|
+
type Types<S> = S extends {
|
|
15
|
+
readonly "~standard": {
|
|
16
|
+
readonly types?: infer T;
|
|
17
|
+
};
|
|
18
|
+
} ? NonNullable<T> : never;
|
|
19
|
+
/** What a caller passes. */
|
|
20
|
+
export type InferInput<S> = Types<S> extends {
|
|
21
|
+
readonly input: infer I;
|
|
22
|
+
} ? I : Record<string, unknown>;
|
|
23
|
+
/** What a handler receives, after defaults and transforms. */
|
|
24
|
+
export type InferOutput<S> = Types<S> extends {
|
|
25
|
+
readonly output: infer O;
|
|
26
|
+
} ? O : Record<string, unknown>;
|
|
27
|
+
export type Issue = {
|
|
28
|
+
path: string;
|
|
29
|
+
message: string;
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Wrap a raw JSON Schema so it validates and converts like a Zod schema.
|
|
33
|
+
*
|
|
34
|
+
* This is how a tool generated from an OpenAPI document or a pinned contract
|
|
35
|
+
* joins the same tool list as hand-written ones.
|
|
36
|
+
*/
|
|
37
|
+
export declare function jsonSchema<T = Record<string, unknown>>(schema: JsonSchema): Schema<T, T>;
|
|
38
|
+
/** The input of a tool that takes nothing. */
|
|
39
|
+
export declare function emptyInput(): Schema<Record<string, never>>;
|
|
40
|
+
export declare function isSchema(value: unknown): value is Schema;
|
|
41
|
+
/** The JSON Schema an MCP client receives for this input. */
|
|
42
|
+
export declare function inputJsonSchema(schema: Schema): JsonSchema;
|
|
43
|
+
export declare function outputJsonSchema(schema: Schema): JsonSchema;
|
|
44
|
+
/** Validate with the schema's own validator, sync or async, and flatten the issues. */
|
|
45
|
+
export declare function validate<T>(schema: Schema<unknown, T>, value: unknown): Promise<{
|
|
46
|
+
ok: true;
|
|
47
|
+
value: T;
|
|
48
|
+
} | {
|
|
49
|
+
ok: false;
|
|
50
|
+
issues: Issue[];
|
|
51
|
+
}>;
|
|
52
|
+
export declare function formatIssues(issues: Issue[]): string;
|
|
53
|
+
/** Arguments Slipway adds to a tool's input. They never reach the tool author's schema or handler. */
|
|
54
|
+
export type Controls = {
|
|
55
|
+
confirm?: boolean;
|
|
56
|
+
wait_seconds?: number;
|
|
57
|
+
};
|
|
58
|
+
/** The names Slipway adds, which a tool's own input may not use. */
|
|
59
|
+
export declare const CONTROL_NAMES: readonly ["confirm", "wait_seconds"];
|
|
60
|
+
/**
|
|
61
|
+
* Short on purpose: it is repeated in every confirmed tool a client lists.
|
|
62
|
+
* What the effect is lives in the tool's own description and annotations.
|
|
63
|
+
*/
|
|
64
|
+
export declare const CONFIRM_DESCRIPTION = "Set true only when the user asked for exactly this action.";
|
|
65
|
+
/** The schema already carries the range and the default, so the words only say what the number is for. */
|
|
66
|
+
export declare const WAIT_DESCRIPTION = "Seconds to wait for the job to finish before returning it to check later.";
|
|
67
|
+
/**
|
|
68
|
+
* A schema as clients receive it, without the `$schema` line that names its
|
|
69
|
+
* dialect. A client reads JSON Schema 2020-12 when no dialect is named, so
|
|
70
|
+
* the line only adds bytes to every tool in every listing.
|
|
71
|
+
*/
|
|
72
|
+
export declare function advertised<I, O>(schema: Schema<I, O>): Schema<I, O>;
|
|
73
|
+
/**
|
|
74
|
+
* Add Slipway's own arguments to any schema: `confirm` for a tool that needs
|
|
75
|
+
* confirming, `wait_seconds` for a job.
|
|
76
|
+
*
|
|
77
|
+
* Validation takes them out before the author's schema sees the rest and puts
|
|
78
|
+
* them back afterwards, so a strict contract schema with
|
|
79
|
+
* `additionalProperties: false` still accepts them, and the author never
|
|
80
|
+
* declares them by hand.
|
|
81
|
+
*/
|
|
82
|
+
export declare function withControls<I, O>(schema: Schema<I, O>, controls: {
|
|
83
|
+
confirm: boolean;
|
|
84
|
+
wait?: {
|
|
85
|
+
defaultSeconds: number;
|
|
86
|
+
maxSeconds: number;
|
|
87
|
+
};
|
|
88
|
+
}): Schema<I & Controls, O & Controls>;
|
|
89
|
+
/** Serialized size of a schema, the number that decides what a client pays to load the tool. */
|
|
90
|
+
export declare function schemaBytes(schema: JsonSchema): number;
|
|
91
|
+
/**
|
|
92
|
+
* Find `$defs` blocks that repeat inside one schema.
|
|
93
|
+
*
|
|
94
|
+
* A generator that inlines a referenced body and also keeps it under `$defs`
|
|
95
|
+
* ships the same definitions twice; a schema of a few hundred kilobytes is
|
|
96
|
+
* often half repetition.
|
|
97
|
+
*/
|
|
98
|
+
export declare function repeatedDefinitions(schema: JsonSchema): string[];
|
|
99
|
+
export {};
|