@thenavidm/slipway 0.1.9 → 0.1.11
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 +11 -0
- package/README.md +6 -1
- package/dist/app.d.ts +5 -0
- package/dist/app.js +5 -0
- package/dist/cli/help.js +40 -20
- package/dist/cli/run.js +7 -1
- package/dist/confirm.js +4 -4
- package/dist/guard.d.ts +2 -0
- package/dist/guard.js +5 -1
- package/dist/policy.d.ts +13 -2
- package/dist/policy.js +5 -1
- package/dist/schema.js +20 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
What changed in Slipway, newest first.
|
|
4
4
|
|
|
5
|
+
## 0.1.11, 2026-10-05: what WordPress needed
|
|
6
|
+
|
|
7
|
+
- **Records no longer advertise `propertyNames`.** Zod 4 gives every `z.record` `propertyNames: { type: "string" }`, which every JSON object key already is, and the Zod 3 converters never wrote it. Slipway leaves it out of what clients receive, as it does the safe-integer bounds, and validation still runs on the schema itself. Each one cost 8 tokens; WordPress has nine. An argument that happens to be named `propertyNames` is a name, so it stays.
|
|
8
|
+
|
|
9
|
+
## 0.1.10, 2026-10-05: what TikTok needed
|
|
10
|
+
|
|
11
|
+
- **`destructiveOff: "hide"`: a server can take its irreversible tools off the list when they are off.** By default `<PREFIX>_ALLOW_DESTRUCTIVE=0` keeps them listed and refuses each call. With `defaults: { destructiveOff: "hide" }` they leave the MCP tool list and the command list, as read-only mode does with every write, and calling one anyway is refused with the setting to unset. TikTok 1.1 hid its publishing tools this way, and keeps doing so.
|
|
12
|
+
- **A shorter general help, for every server.** An agent reads it first and carries it through every later step. Slipway's settings past the two safety switches are named on the "Also" line with the app's tuning settings, the list formats and `--out` and `--timeout` are left to each command's help and `agent-context`, and the command lines say less. On TikTok it went from 530 tokens to 412, which cut Codex's CLI task by about 350 tokens.
|
|
13
|
+
- **`hidden: true` keeps a command out of the general help**, such as `auth`, the name an older release used for `login`. It still runs, and `agent-context` still lists it.
|
|
14
|
+
- **A refusal no longer doubles a period.** A summary that ends its own sentence, as TikTok's "Publish a video at SELF_ONLY." does, read "About to: Publish a video at SELF_ONLY.." in the refusal and the approval messages.
|
|
15
|
+
|
|
5
16
|
## 0.1.9, 2026-10-05: what Substack needed
|
|
6
17
|
|
|
7
18
|
- **A resource can wait for an account.** `listed(env)` leaves a resource out until it returns true. Substack 2.2.3 offered its two resources only once a publication was connected; on Slipway they were always offered, and Claude Code then adds its own two resource tools to every message: 40 tokens in tool search, for reads that could only fail.
|
package/README.md
CHANGED
|
@@ -550,7 +550,7 @@ Every server reads these, under its own prefix: the app name in capitals, `NOTES
|
|
|
550
550
|
| Variable | Default | What it does |
|
|
551
551
|
|---|---|---|
|
|
552
552
|
| `<PREFIX>_READ_ONLY` | `0` | `1` hides and refuses every write |
|
|
553
|
-
| `<PREFIX>_ALLOW_DESTRUCTIVE` | `1` | `0` keeps writes and refuses the irreversible ones |
|
|
553
|
+
| `<PREFIX>_ALLOW_DESTRUCTIVE` | `1` | `0` keeps writes and refuses the irreversible ones; an app with `defaults: { destructiveOff: "hide" }` also leaves them out of the list |
|
|
554
554
|
| `<PREFIX>_AUDIT_LOG` | none | File that records every attempted write |
|
|
555
555
|
| `<PREFIX>_CONFIRM` | `human` | `model` lets `confirm: true` alone confirm, for an agent with no person to ask |
|
|
556
556
|
| `<PREFIX>_CACHE` | `1` | `0` never answers from the local cache |
|
|
@@ -572,12 +572,17 @@ See [CHANGELOG.md](CHANGELOG.md).
|
|
|
572
572
|
|
|
573
573
|
| Server | Package | Covers |
|
|
574
574
|
| --- | --- | --- |
|
|
575
|
+
| [Apple Podcasts](https://github.com/thenavidm/apple-podcasts-mcp-cli) | [`@thenavidm/apple-podcasts-mcp-cli`](https://www.npmjs.com/package/@thenavidm/apple-podcasts-mcp-cli) 2.0.0 | The catalog, charts per country, reviews and feeds, your library on this Mac with its transcript excerpts, and analytics for a show you own |
|
|
575
576
|
| [Bluesky](https://github.com/thenavidm/bluesky-mcp-cli) | [`@thenavidm/bluesky-mcp-cli`](https://www.npmjs.com/package/@thenavidm/bluesky-mcp-cli) 2.0.0 | Posting, threads, replies, the timeline, search, feeds, lists, notifications and the social graph |
|
|
577
|
+
| [Google Photos](https://github.com/thenavidm/google-photos-mcp-cli) | [`@thenavidm/google-photos-mcp-cli`](https://www.npmjs.com/package/@thenavidm/google-photos-mcp-cli) 2.0.0 | The photo picker, uploads, albums and their captions, places and maps, and media this server uploaded, across several Google accounts |
|
|
576
578
|
| [Mastodon](https://github.com/thenavidm/mastodon-mcp-cli) | [`@thenavidm/mastodon-mcp-cli`](https://www.npmjs.com/package/@thenavidm/mastodon-mcp-cli) 2.0.0 | Posting, editing, threads, timelines, search, lists, notifications and following, on any instance |
|
|
577
579
|
| [Midjourney](https://github.com/thenavidm/midjourney-mcp-cli) | [`@thenavidm/midjourney-mcp-cli`](https://www.npmjs.com/package/@thenavidm/midjourney-mcp-cli) 2.0.0 | Generating images and video, following jobs, downloads, moodboards, the account's library and the explore feeds, through a signed-in browser |
|
|
580
|
+
| [Substack](https://github.com/thenavidm/substack-mcp-cli) | [`@thenavidm/substack-mcp-cli`](https://www.npmjs.com/package/@thenavidm/substack-mcp-cli) 3.0.0 | Drafts, publishing and scheduling, Notes, subscribers, analytics, tags, comments and researching other publications |
|
|
578
581
|
| [Teachable](https://github.com/thenavidm/teachable-mcp-cli) | [`@thenavidm/teachable-mcp-cli`](https://www.npmjs.com/package/@thenavidm/teachable-mcp-cli) 3.0.0 | Courses, users, enrollments, pricing, coupons and transactions |
|
|
579
582
|
| [Threads](https://github.com/thenavidm/threads-mcp-cli) | [`@thenavidm/threads-mcp-cli`](https://www.npmjs.com/package/@thenavidm/threads-mcp-cli) 2.0.0 | Posting, threads, carousels, replies and reply approvals, insights and keyword search |
|
|
583
|
+
| [TikTok](https://github.com/thenavidm/tiktok-mcp-cli) | [`@thenavidm/tiktok-mcp-cli`](https://www.npmjs.com/package/@thenavidm/tiktok-mcp-cli) 2.0.0 | Your own account's profile and videos, ranked by any metric, posting and drafts, through TikTok's official API |
|
|
580
584
|
| [ThriveCart](https://github.com/thenavidm/thrivecart-mcp-cli) | [`@thenavidm/thrivecart-mcp-cli`](https://www.npmjs.com/package/@thenavidm/thrivecart-mcp-cli) 3.0.0 | Products and pricing, transactions and revenue, customers, subscriptions and affiliates, across several carts |
|
|
585
|
+
| [YouTube](https://github.com/thenavidm/youtube-mcp-cli) | [`@thenavidm/youtube-mcp-cli`](https://www.npmjs.com/package/@thenavidm/youtube-mcp-cli) 3.0.0 | Transcripts of any public video, channel research against each channel's own median, and your own channels' videos, comments and analytics |
|
|
581
586
|
|
|
582
587
|
Each server was measured against its previous release before it moved: startup, what a client receives, CLI exit codes, and tokens in Claude Code and Codex. Its README has the numbers.
|
|
583
588
|
|
package/dist/app.d.ts
CHANGED
|
@@ -90,6 +90,11 @@ export type CliCommand = {
|
|
|
90
90
|
* taken as Slipway's global flag of the same name before the command sees it.
|
|
91
91
|
*/
|
|
92
92
|
flags?: readonly string[];
|
|
93
|
+
/**
|
|
94
|
+
* Left out of the general help, as for the name an older release used for
|
|
95
|
+
* `login`. It still runs, and `agent-context` still lists it.
|
|
96
|
+
*/
|
|
97
|
+
hidden?: boolean;
|
|
93
98
|
run: (io: CliIO, args: string[]) => number | Promise<number>;
|
|
94
99
|
};
|
|
95
100
|
export type AppDefinition<Ctx> = {
|
package/dist/app.js
CHANGED
|
@@ -329,6 +329,11 @@ function assertVisible(tool, policy, envPrefix) {
|
|
|
329
329
|
hint: `Unset ${names.readOnly} to allow writes.`,
|
|
330
330
|
});
|
|
331
331
|
}
|
|
332
|
+
if (seen.reason === "destructive") {
|
|
333
|
+
throw new RefusedError(`${tool.name} is unavailable: this server is running with ${names.allowDestructive}=0.`, {
|
|
334
|
+
hint: `Unset ${names.allowDestructive} to allow irreversible writes.`,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
332
337
|
throw new UsageError(`${tool.name} is in a toolset that is off: ${tool.tags.join(", ")}.`, {
|
|
333
338
|
hint: `Add one of them to ${names.toolsets}, or set ${names.toolsets}=all.`,
|
|
334
339
|
});
|
package/dist/cli/help.js
CHANGED
|
@@ -57,12 +57,15 @@ function hiddenNote(app, env) {
|
|
|
57
57
|
const off = new Set();
|
|
58
58
|
let byToolset = 0;
|
|
59
59
|
let byReadOnly = 0;
|
|
60
|
+
let byDestructive = 0;
|
|
60
61
|
for (const tool of app.allTools) {
|
|
61
62
|
const seen = visibility(tool, policy);
|
|
62
63
|
if (seen.visible)
|
|
63
64
|
continue;
|
|
64
65
|
if (seen.reason === "read-only")
|
|
65
66
|
byReadOnly += 1;
|
|
67
|
+
else if (seen.reason === "destructive")
|
|
68
|
+
byDestructive += 1;
|
|
66
69
|
else {
|
|
67
70
|
byToolset += 1;
|
|
68
71
|
for (const tag of tool.tags)
|
|
@@ -77,6 +80,8 @@ function hiddenNote(app, env) {
|
|
|
77
80
|
}
|
|
78
81
|
if (byReadOnly)
|
|
79
82
|
lines.push(` ${byReadOnly} ${byReadOnly === 1 ? "write is" : "writes are"} hidden by ${names.readOnly}=1.`);
|
|
83
|
+
if (byDestructive)
|
|
84
|
+
lines.push(` ${byDestructive} irreversible ${byDestructive === 1 ? "write is" : "writes are"} hidden by ${names.allowDestructive}=0.`);
|
|
80
85
|
return lines.length ? [...lines, ``] : [];
|
|
81
86
|
}
|
|
82
87
|
export function renderList(app, tools, bin, env = process.env) {
|
|
@@ -204,17 +209,18 @@ export function renderGeneralHelp(app, bin) {
|
|
|
204
209
|
const sync = app.allTools.some((tool) => tool.sync);
|
|
205
210
|
const jobs = app.allTools.some((tool) => tool.job);
|
|
206
211
|
// An agent often reads this first and pays for it again on every later step, so the
|
|
207
|
-
// rarely needed commands share one line
|
|
212
|
+
// rarely needed commands share one line, an older name for a command is left out, and
|
|
213
|
+
// Slipway's settings past the two safety switches are named on one line.
|
|
208
214
|
const commands = [
|
|
209
215
|
[app.bins.cli, "list the commands"],
|
|
210
|
-
[`${bin} <command> --help`, "what one takes
|
|
216
|
+
[`${bin} <command> --help`, "what one takes"],
|
|
211
217
|
[`${bin} which <words>`, "find the command for a task"],
|
|
212
|
-
[`${bin} doctor${app.definition.doctorNetwork ? "" : " [--network]"}`, "check the setup
|
|
218
|
+
[`${bin} doctor${app.definition.doctorNetwork ? "" : " [--network]"}`, "check the setup"],
|
|
213
219
|
typeof app.definition.login === "object"
|
|
214
220
|
? [`${bin} ${app.definition.login.usage ?? "login"}`, app.definition.login.help]
|
|
215
221
|
: [`${bin} login`, "how to connect an account"],
|
|
216
|
-
...(app.definition.commands ?? []).map((command) => [`${bin} ${command.usage ?? command.name}`, command.help]),
|
|
217
|
-
[`${bin} install <client>`, "add the server to an MCP client
|
|
222
|
+
...(app.definition.commands ?? []).filter((command) => !command.hidden).map((command) => [`${bin} ${command.usage ?? command.name}`, command.help]),
|
|
223
|
+
[`${bin} install <client>`, "add the server to an MCP client"],
|
|
218
224
|
...(cache || sync ? [[`${bin} data`, "what is kept on this machine; data clear [<command>] deletes it"]] : []),
|
|
219
225
|
...(sync
|
|
220
226
|
? [
|
|
@@ -223,27 +229,41 @@ export function renderGeneralHelp(app, bin) {
|
|
|
223
229
|
[`${bin} data sql "<select>"`, "query local data with read-only SQL"],
|
|
224
230
|
]
|
|
225
231
|
: []),
|
|
226
|
-
[app.bins.mcp, "the MCP server
|
|
232
|
+
[app.bins.mcp, "the MCP server; --http [--port N] serves HTTP"],
|
|
233
|
+
];
|
|
234
|
+
// Tuning keeps a working default, so it is named on one line with Slipway's own settings past
|
|
235
|
+
// the two safety switches; agent-context says what each does.
|
|
236
|
+
const tuning = [
|
|
237
|
+
...(app.definition.settings ?? []).filter((setting) => setting.tuning).map((setting) => setting.env),
|
|
238
|
+
names.surface,
|
|
239
|
+
names.auditLog,
|
|
240
|
+
names.toolTimeoutMs,
|
|
241
|
+
names.confirm,
|
|
242
|
+
...(cache ? [names.cache] : []),
|
|
243
|
+
...(cache || sync ? [names.dataDir] : []),
|
|
244
|
+
`${app.envPrefix}_DEBUG`,
|
|
227
245
|
];
|
|
228
|
-
// Tuning keeps a working default, so it is named on one line; agent-context says what each does.
|
|
229
|
-
const tuning = (app.definition.settings ?? []).filter((setting) => setting.tuning).map((setting) => setting.env);
|
|
230
246
|
const settings = [
|
|
231
247
|
...(app.definition.settings ?? []).filter((setting) => !setting.tuning).map((setting) => [setting.env, setting.description]),
|
|
232
248
|
[`${names.readOnly}=1`, "hide and refuse every write"],
|
|
233
|
-
[
|
|
249
|
+
[
|
|
250
|
+
`${names.allowDestructive}=0`,
|
|
251
|
+
`${app.definition.defaults?.destructiveOff === "hide" ? "hide and refuse" : "refuse"} the irreversible writes${app.allTools.some((tool) => tool.spends) ? " and paid calls" : ""}`,
|
|
252
|
+
],
|
|
234
253
|
// With no tagged tool every tool is always on, so the switch would do nothing.
|
|
235
254
|
...(app.allTools.some((tool) => tool.tags.length > 0) ? [[`${names.toolsets}=a,b`, "only these toolsets, or all"]] : []),
|
|
236
|
-
[`${names.surface}=search`, "MCP lists three finder tools instead"],
|
|
237
|
-
[`${names.auditLog}=<file>`, "log every attempted write"],
|
|
238
|
-
[`${names.toolTimeoutMs}=<ms>`, "deadline for any tool"],
|
|
239
|
-
[`${names.confirm}=model`, "confirm: true alone confirms over MCP"],
|
|
240
|
-
...(cache ? [[`${names.cache}=0`, "never answer from the local cache"]] : []),
|
|
241
|
-
...(cache || sync ? [[`${names.dataDir}=<dir>`, "keep local data in this folder"]] : []),
|
|
242
255
|
[`${app.envPrefix}_HTTP_PORT / _HOST / _TOKEN / _ALLOWED_ORIGINS`, "for --http"],
|
|
243
|
-
[`${app.envPrefix}_DEBUG=1`, "debug lines on stderr"],
|
|
244
256
|
];
|
|
245
|
-
//
|
|
246
|
-
|
|
257
|
+
// The list formats appear in the help of the commands that list; flags that cannot apply
|
|
258
|
+
// here (jobs, the cache) are left out. agent-context lists every one.
|
|
259
|
+
const flags = GLOBAL_FLAGS.map(([flag]) => flag).filter((flag) => flag !== "--agent" &&
|
|
260
|
+
flag !== "--jsonl" &&
|
|
261
|
+
flag !== "--csv / --tsv" &&
|
|
262
|
+
flag !== "--quiet" &&
|
|
263
|
+
flag !== "--out <file>" &&
|
|
264
|
+
flag !== "--timeout <ms>" &&
|
|
265
|
+
(flag !== "--wait" || jobs) &&
|
|
266
|
+
(flag !== "--refresh" || cache));
|
|
247
267
|
const commandRow = table(commands);
|
|
248
268
|
const settingRow = table(settings);
|
|
249
269
|
const lines = [
|
|
@@ -251,13 +271,13 @@ export function renderGeneralHelp(app, bin) {
|
|
|
251
271
|
`${app.title} ${app.version}`,
|
|
252
272
|
``,
|
|
253
273
|
...commands.map(commandRow),
|
|
254
|
-
` Also: schema <command>, agent-context
|
|
274
|
+
` Also: schema <command>, agent-context (all of this as JSON), completion <shell>.`,
|
|
255
275
|
``,
|
|
256
276
|
`Flags: ${flags.join(", ")}, and --agent: compact JSON, no prompts, never confirms a write.`,
|
|
257
277
|
``,
|
|
258
278
|
`Settings:`,
|
|
259
279
|
...settings.map(settingRow),
|
|
260
|
-
|
|
280
|
+
` Also: ${tuning.join(", ")}, described in agent-context.`,
|
|
261
281
|
``,
|
|
262
282
|
`Exit codes: ${EXIT.ok} ok, ${EXIT.error} unexpected, ${EXIT.usage} usage or refused, ${EXIT.notFound} not found, ${EXIT.auth} auth, ${EXIT.api} API, ${EXIT.rateLimited} rate limited, ${EXIT.notConfigured} not configured`,
|
|
263
283
|
``,
|
package/dist/cli/run.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { writeFileSync } from "node:fs";
|
|
10
10
|
import { runDoctor } from "../doctor.js";
|
|
11
|
-
import { EXIT, UsageError, toSlipwayError } from "../errors.js";
|
|
11
|
+
import { EXIT, RefusedError, UsageError, toSlipwayError } from "../errors.js";
|
|
12
12
|
import { eachPage } from "../pages.js";
|
|
13
13
|
import { visibility, policyEnvNames } from "../policy.js";
|
|
14
14
|
import { outputJsonSchema } from "../schema.js";
|
|
@@ -169,6 +169,12 @@ export async function runCli(app, argv, partial = {}) {
|
|
|
169
169
|
});
|
|
170
170
|
}
|
|
171
171
|
const seen = visibility(tool, app.policy(io.env));
|
|
172
|
+
if (!seen.visible && seen.reason === "destructive") {
|
|
173
|
+
const names = policyEnvNames(app.envPrefix);
|
|
174
|
+
throw new RefusedError(`${tool.command} is unavailable: ${names.allowDestructive}=0 hides the irreversible writes.`, {
|
|
175
|
+
hint: `Unset ${names.allowDestructive} to allow them.`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
172
178
|
if (!seen.visible) {
|
|
173
179
|
const names = policyEnvNames(app.envPrefix);
|
|
174
180
|
throw new UsageError(seen.reason === "read-only"
|
package/dist/confirm.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
21
21
|
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, createRequestStateCodec, inputRequired, inputResponse, } from "@modelcontextprotocol/server";
|
|
22
22
|
import { RefusedError } from "./errors.js";
|
|
23
|
-
import { consequence, Guard } from "./guard.js";
|
|
23
|
+
import { consequence, Guard, sentenceBody } from "./guard.js";
|
|
24
24
|
import { phrase, sha256, stableJson, versionAtLeast } from "./util.js";
|
|
25
25
|
/** The key of Slipway's approval form among a call's input requests. */
|
|
26
26
|
export const APPROVAL_KEY = "slipway_approval";
|
|
@@ -149,18 +149,18 @@ export async function personApproval(app, tool, rawArgs, ctx, env) {
|
|
|
149
149
|
return undefined;
|
|
150
150
|
if (answer.kind === "elicit" && answer.action === "accept") {
|
|
151
151
|
guard.record(tool, summary, "blocked: person declined");
|
|
152
|
-
throw new RefusedError(`The approval form was accepted without ticking Approve, so ${tool.name} did not run. About to: ${summary}.`, {
|
|
152
|
+
throw new RefusedError(`The approval form was accepted without ticking Approve, so ${tool.name} did not run. About to: ${sentenceBody(summary)}.`, {
|
|
153
153
|
hint: "Ask the user whether they want this, and call again only if they do.",
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
156
|
if (answer.kind === "elicit" && answer.action === "cancel") {
|
|
157
157
|
guard.record(tool, summary, "blocked: no answer");
|
|
158
|
-
throw new RefusedError(`The approval form was closed without an answer, so ${tool.name} did not run. About to: ${summary}.`, {
|
|
158
|
+
throw new RefusedError(`The approval form was closed without an answer, so ${tool.name} did not run. About to: ${sentenceBody(summary)}.`, {
|
|
159
159
|
hint: "Ask the user whether they want this, and call again only if they do.",
|
|
160
160
|
});
|
|
161
161
|
}
|
|
162
162
|
guard.record(tool, summary, "blocked: person declined");
|
|
163
|
-
throw new RefusedError(`The approval was declined, so ${tool.name} did not run. About to: ${summary}.`, {
|
|
163
|
+
throw new RefusedError(`The approval was declined, so ${tool.name} did not run. About to: ${sentenceBody(summary)}.`, {
|
|
164
164
|
hint: "Do not call it again unless the user asks for it.",
|
|
165
165
|
});
|
|
166
166
|
}
|
package/dist/guard.d.ts
CHANGED
|
@@ -44,3 +44,5 @@ export declare class Guard {
|
|
|
44
44
|
}
|
|
45
45
|
/** Why a tool needs confirming, in the words a refusal and an approval form both use. */
|
|
46
46
|
export declare function consequence(tool: Pick<Tool, "risk" | "consequence" | "spends">): string;
|
|
47
|
+
/** A summary that ends its own sentence, "Publish a video.", is not given a second period. */
|
|
48
|
+
export declare function sentenceBody(text: string): string;
|
package/dist/guard.js
CHANGED
|
@@ -58,7 +58,7 @@ export class Guard {
|
|
|
58
58
|
}
|
|
59
59
|
if (tool.requireConfirm && !options.confirmedBy) {
|
|
60
60
|
this.record(tool, options.summary, "blocked: no confirm");
|
|
61
|
-
throw new RefusedError(`${tool.name} ${consequence(tool)}, so it will not run without ${this.confirmFlag}. About to: ${options.summary}. Call again with ${this.confirmFlag} if that is what was asked for.`, { hint: `Pass ${this.confirmFlag} only when the user asked for this exact action.` });
|
|
61
|
+
throw new RefusedError(`${tool.name} ${consequence(tool)}, so it will not run without ${this.confirmFlag}. About to: ${sentenceBody(options.summary)}. Call again with ${this.confirmFlag} if that is what was asked for.`, { hint: `Pass ${this.confirmFlag} only when the user asked for this exact action.` });
|
|
62
62
|
}
|
|
63
63
|
this.record(tool, options.summary, "allowed", options.confirmedBy);
|
|
64
64
|
}
|
|
@@ -91,3 +91,7 @@ export function consequence(tool) {
|
|
|
91
91
|
return "spends money and cannot be refunded";
|
|
92
92
|
return tool.risk === "destructive" ? "is public or cannot be undone" : "has an effect that cannot be taken back";
|
|
93
93
|
}
|
|
94
|
+
/** A summary that ends its own sentence, "Publish a video.", is not given a second period. */
|
|
95
|
+
export function sentenceBody(text) {
|
|
96
|
+
return text.trim().replace(/[.!?]+$/, "");
|
|
97
|
+
}
|
package/dist/policy.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ export type Policy = {
|
|
|
31
31
|
confirm: ConfirmMode;
|
|
32
32
|
/** Whether reads that opted in may answer from the local cache. */
|
|
33
33
|
cache: boolean;
|
|
34
|
+
/** Whether the irreversible tools and paid calls are left out of the list, not only refused. */
|
|
35
|
+
hideDestructive: boolean;
|
|
34
36
|
};
|
|
35
37
|
export type PolicyDefaults = {
|
|
36
38
|
/**
|
|
@@ -41,6 +43,13 @@ export type PolicyDefaults = {
|
|
|
41
43
|
toolsets?: readonly string[] | "all" | ((env: NodeJS.ProcessEnv) => readonly string[] | "all");
|
|
42
44
|
surface?: ToolSurface;
|
|
43
45
|
confirm?: ConfirmMode;
|
|
46
|
+
/**
|
|
47
|
+
* What `<PREFIX>_ALLOW_DESTRUCTIVE=0` does to the irreversible tools and paid
|
|
48
|
+
* calls: `refuse` each call (the default), or `hide` them from the list, as
|
|
49
|
+
* read-only mode hides every write. A server that hid them before it moved
|
|
50
|
+
* keeps doing so.
|
|
51
|
+
*/
|
|
52
|
+
destructiveOff?: "refuse" | "hide";
|
|
44
53
|
};
|
|
45
54
|
export type PolicyEnv = {
|
|
46
55
|
readOnly: string;
|
|
@@ -59,10 +68,12 @@ export type Visibility = {
|
|
|
59
68
|
visible: true;
|
|
60
69
|
} | {
|
|
61
70
|
visible: false;
|
|
62
|
-
reason: "read-only" | "toolset";
|
|
71
|
+
reason: "read-only" | "destructive" | "toolset";
|
|
63
72
|
};
|
|
64
73
|
/** 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"
|
|
74
|
+
export declare function visibility(tool: Pick<Tool, "risk" | "tags"> & {
|
|
75
|
+
spends?: boolean;
|
|
76
|
+
}, policy: Policy): Visibility;
|
|
66
77
|
export declare function riskMark(tool: {
|
|
67
78
|
risk: Risk;
|
|
68
79
|
spends?: boolean;
|
package/dist/policy.js
CHANGED
|
@@ -47,21 +47,25 @@ export function readPolicy(env, prefix, defaults = {}) {
|
|
|
47
47
|
const surface = env[names.surface]?.trim().toLowerCase();
|
|
48
48
|
const timeout = Number(env[names.toolTimeoutMs]);
|
|
49
49
|
const confirm = env[names.confirm]?.trim().toLowerCase();
|
|
50
|
+
const allowDestructive = flag(env[names.allowDestructive], true);
|
|
50
51
|
return {
|
|
51
52
|
readOnly: flag(env[names.readOnly], false),
|
|
52
|
-
allowDestructive
|
|
53
|
+
allowDestructive,
|
|
53
54
|
auditLog: env[names.auditLog]?.trim() || undefined,
|
|
54
55
|
toolsets,
|
|
55
56
|
surface: surface === "search" || surface === "full" ? surface : (defaults.surface ?? "full"),
|
|
56
57
|
toolTimeoutMs: Number.isFinite(timeout) && timeout > 0 ? timeout : undefined,
|
|
57
58
|
confirm: confirm === "human" || confirm === "model" ? confirm : (defaults.confirm ?? "human"),
|
|
58
59
|
cache: flag(env[names.cache], true),
|
|
60
|
+
hideDestructive: !allowDestructive && defaults.destructiveOff === "hide",
|
|
59
61
|
};
|
|
60
62
|
}
|
|
61
63
|
/** Whether a tool is on under this policy, and if not, why, so a refusal can say how to turn it on. */
|
|
62
64
|
export function visibility(tool, policy) {
|
|
63
65
|
if (policy.readOnly && tool.risk !== "read")
|
|
64
66
|
return { visible: false, reason: "read-only" };
|
|
67
|
+
if (policy.hideDestructive && (tool.risk === "destructive" || tool.spends === true))
|
|
68
|
+
return { visible: false, reason: "destructive" };
|
|
65
69
|
if (policy.toolsets !== "all" && tool.tags.length > 0) {
|
|
66
70
|
const on = policy.toolsets;
|
|
67
71
|
if (!tool.tags.some((tag) => on.has(tag)))
|
package/dist/schema.js
CHANGED
|
@@ -83,25 +83,36 @@ export const CONTROL_NAMES = ["confirm", "wait_seconds"];
|
|
|
83
83
|
export const CONFIRM_DESCRIPTION = "Set true only when the user asked for exactly this action.";
|
|
84
84
|
/** The schema already carries the range and the default, so the words only say what the number is for. */
|
|
85
85
|
export const WAIT_DESCRIPTION = "Seconds to wait for the job to finish before returning it to check later.";
|
|
86
|
+
/** Keywords whose keys are names the author chose, not keywords. */
|
|
87
|
+
const NAMED = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]);
|
|
86
88
|
/**
|
|
87
89
|
* Zod 4 gives every whole number the safe-integer bounds, `maximum:
|
|
88
|
-
* 9007199254740991` and its negative, unless the schema sets its own
|
|
89
|
-
*
|
|
90
|
-
*
|
|
90
|
+
* 9007199254740991` and its negative, unless the schema sets its own, and
|
|
91
|
+
* every record `propertyNames: { type: "string" }`, which every JSON object key
|
|
92
|
+
* already is. They say nothing a client can use, so they are left out of what
|
|
93
|
+
* it receives. Validation still runs on the schema itself. An argument that
|
|
94
|
+
* happens to be named `propertyNames` or `maximum` is a name, so it stays.
|
|
91
95
|
*/
|
|
92
|
-
function
|
|
96
|
+
function withoutNoise(node, named = false) {
|
|
93
97
|
if (Array.isArray(node))
|
|
94
|
-
return node.map(
|
|
98
|
+
return node.map((item) => withoutNoise(item));
|
|
95
99
|
if (node === null || typeof node !== "object")
|
|
96
100
|
return node;
|
|
97
101
|
const out = {};
|
|
98
102
|
for (const [key, value] of Object.entries(node)) {
|
|
99
|
-
if (
|
|
100
|
-
|
|
101
|
-
|
|
103
|
+
if (!named) {
|
|
104
|
+
if ((key === "maximum" && value === Number.MAX_SAFE_INTEGER) || (key === "minimum" && value === Number.MIN_SAFE_INTEGER))
|
|
105
|
+
continue;
|
|
106
|
+
if (key === "propertyNames" && isPlainString(value))
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
out[key] = withoutNoise(value, !named && NAMED.has(key));
|
|
102
110
|
}
|
|
103
111
|
return out;
|
|
104
112
|
}
|
|
113
|
+
function isPlainString(value) {
|
|
114
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 1 && value.type === "string";
|
|
115
|
+
}
|
|
105
116
|
/**
|
|
106
117
|
* A schema as clients receive it, without the `$schema` line that names its
|
|
107
118
|
* dialect. A client reads JSON Schema 2020-12 when no dialect is named, so
|
|
@@ -111,7 +122,7 @@ export function advertised(schema) {
|
|
|
111
122
|
const std = schema["~standard"];
|
|
112
123
|
const plain = (json) => {
|
|
113
124
|
const { $schema: _dialect, ...rest } = json;
|
|
114
|
-
return
|
|
125
|
+
return withoutNoise(rest);
|
|
115
126
|
};
|
|
116
127
|
return {
|
|
117
128
|
"~standard": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thenavidm/slipway",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Slipway, the TypeScript framework for MCP servers and agent-native CLIs. One tool definition ships an MCP server and a CLI, with write safety, typed results and release checks built in.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|