@thenavidm/slipway 0.1.6 → 0.1.7
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 +9 -0
- package/README.md +6 -1
- package/dist/app.d.ts +7 -0
- package/dist/app.js +9 -7
- package/dist/cli/context.js +3 -1
- package/dist/cli/help.js +2 -1
- package/dist/cli/run.js +5 -1
- package/dist/guard.d.ts +1 -1
- package/dist/guard.js +2 -0
- package/dist/serve.d.ts +6 -0
- package/dist/serve.js +18 -5
- package/dist/server.d.ts +1 -1
- package/dist/server.js +5 -2
- package/dist/tool.d.ts +25 -0
- package/dist/tool.js +31 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
What changed in Slipway, newest first.
|
|
4
4
|
|
|
5
|
+
## 0.1.7, 2026-10-05: what Threads and ThriveCart needed, and what Substack and WordPress will
|
|
6
|
+
|
|
7
|
+
- **`riskFor`: a write whose arguments decide its risk.** Publishing is destructive and saving a draft is a write, from the same tool. `risk` stays the highest a call can be, which is what clients see in annotations and listings; the guard, the approval and the audit log go by the call, and only a destructive call needs confirming. WordPress's post tools need it: 1.1 confirmed publishing and not drafting.
|
|
8
|
+
- **`onServe`: work that belongs to a running server.** It runs once the server is answering, over stdio or HTTP, and never for a CLI command, with the context and the server's logger; a throw is logged and the server keeps serving. Threads uses it to warn that a token expires this week, and Substack's queue of scheduled Notes needs it.
|
|
9
|
+
- **A tool's own consequence.** `consequence` replaces "is public or cannot be undone" in the refusal and the approval form. ThriveCart's refund said it "is public" on Slipway and now says it "moves money or ends a customer's access and cannot be undone", as its own release did.
|
|
10
|
+
- **`which` lists only the close matches.** Results scoring at least half of the best one, and always the top three. On Threads, "publish a post staged earlier" printed ten lines, 1,077 characters, where three carried the answer.
|
|
11
|
+
- **`<PREFIX>_TOOLSETS` is listed only when some tool has a toolset.** With none, every tool is always on and the switch does nothing, so help and `agent-context` stop offering it.
|
|
12
|
+
- **Tests from the servers that moved.** ThriveCart's cases for `--select` paths that share a head and for a list of choices typed as repeated words now run here, where that code lives.
|
|
13
|
+
|
|
5
14
|
## 0.1.6, 2026-10-05: what Mastodon's move needed
|
|
6
15
|
|
|
7
16
|
- **`login` runs the app's own sign-in, with the words after it.** `mastodon-cli login mastodon.social --oob` hands `mastodon.social --oob` to Mastodon's flow, which registers an app on that instance and signs in. A flow given with `usage` and `help` shows them in help, `login --help` and `agent-context`, so nobody has to guess that it takes an instance.
|
package/README.md
CHANGED
|
@@ -213,6 +213,8 @@ The context is built on the first call that needs it, never at startup, and so i
|
|
|
213
213
|
| `output` | Optional. Results are validated against it and sent as `structuredContent` |
|
|
214
214
|
| `risk` | `read`, `write` (easy to undo) or `destructive` (public, irreversible, or both) |
|
|
215
215
|
| `requireConfirm` | Defaults to true for destructive tools. Set it on a write that spends money |
|
|
216
|
+
| `riskFor` | `(args) => risk`, when the arguments decide it: publishing is destructive, saving a draft is a write. `risk` stays the highest, which clients see; the guard, confirmation and audit log go by the call |
|
|
217
|
+
| `consequence` | What a confirmed call does, in the tool's own words for the refusal and the approval form: "moves money and cannot be undone". Defaults to "is public or cannot be undone" |
|
|
216
218
|
| `idempotent`, `openWorld` | Annotation hints. Reads are idempotent by default; every tool is open world unless it never leaves the machine |
|
|
217
219
|
| `tags` | Toolsets this tool belongs to. A tool with no tags is always on |
|
|
218
220
|
| `summary` | One line for the refusal message and the audit log: "delete note 7" |
|
|
@@ -431,6 +433,8 @@ Errors are JSON on stderr, always, with `error`, `code` and a `hint` that names
|
|
|
431
433
|
|
|
432
434
|
HTTP binds `127.0.0.1` and checks the Host header, so a web page cannot reach it through a name that resolves to localhost. It refuses to listen on any other address without `<PREFIX>_HTTP_TOKEN`, because anyone who reached the port would act as your account.
|
|
433
435
|
|
|
436
|
+
Work that belongs to a running server goes in `onServe(ctx, log)`, which runs once the server is answering over either transport and never for a CLI command: a queue that publishes on time, or a warning that a token expires this week. A throw there is logged and the server keeps serving.
|
|
437
|
+
|
|
434
438
|
Resources and prompts are optional and take a few lines each:
|
|
435
439
|
|
|
436
440
|
```ts
|
|
@@ -550,7 +554,7 @@ Every server reads these, under its own prefix: the app name in capitals, `NOTES
|
|
|
550
554
|
| `<PREFIX>_CONFIRM` | `human` | `model` lets `confirm: true` alone confirm, for an agent with no person to ask |
|
|
551
555
|
| `<PREFIX>_CACHE` | `1` | `0` never answers from the local cache |
|
|
552
556
|
| `<PREFIX>_DATA_DIR` | the system's data folder | Where the local data file lives |
|
|
553
|
-
| `<PREFIX>_TOOLSETS` | `all` | Comma-separated toolsets to turn on |
|
|
557
|
+
| `<PREFIX>_TOOLSETS` | `all` | Comma-separated toolsets to turn on. Listed only when some tool has a toolset |
|
|
554
558
|
| `<PREFIX>_SURFACE` | `full` | `search` lists three tools that find, describe and run the rest |
|
|
555
559
|
| `<PREFIX>_TOOL_TIMEOUT_MS` | none | Give up on any tool after this long |
|
|
556
560
|
| `<PREFIX>_HTTP_PORT` | `8787`, or the app's `httpPort` | For `--http` |
|
|
@@ -567,6 +571,7 @@ See [CHANGELOG.md](CHANGELOG.md).
|
|
|
567
571
|
| Server | Package | Covers |
|
|
568
572
|
| --- | --- | --- |
|
|
569
573
|
| [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 |
|
|
574
|
+
| [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 |
|
|
570
575
|
| [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 |
|
|
571
576
|
|
|
572
577
|
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.
|
package/dist/app.d.ts
CHANGED
|
@@ -129,6 +129,13 @@ export type AppDefinition<Ctx> = {
|
|
|
129
129
|
* a scope.
|
|
130
130
|
*/
|
|
131
131
|
doctorNetwork?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* Runs once the server is answering, over stdio or HTTP, and never for a CLI
|
|
134
|
+
* command. For work that belongs to a running server, such as a queue that
|
|
135
|
+
* publishes on time, or a warning such as a token about to expire. It gets
|
|
136
|
+
* the context and the server's stderr logger; a throw is logged, never fatal.
|
|
137
|
+
*/
|
|
138
|
+
onServe?: (ctx: Ctx, log: Logger) => void | Promise<void>;
|
|
132
139
|
/**
|
|
133
140
|
* How to sign in: printed instructions, or an interactive flow that returns an
|
|
134
141
|
* exit code. A flow gets the words after `login`: `mastodon-cli login mastodon.social`.
|
package/dist/app.js
CHANGED
|
@@ -18,7 +18,7 @@ import { isContentResult } from "./result.js";
|
|
|
18
18
|
import { formatIssues, validate } from "./schema.js";
|
|
19
19
|
import { buildServer } from "./server.js";
|
|
20
20
|
import { localDataTools } from "./sync.js";
|
|
21
|
-
import { defineTool, isTool, summarize } from "./tool.js";
|
|
21
|
+
import { defineTool, forCall, isTool, summarize } from "./tool.js";
|
|
22
22
|
const SLUG = /^[a-z][a-z0-9-]{0,40}$/;
|
|
23
23
|
export function stderrLogger(prefix, env) {
|
|
24
24
|
const debug = /^(1|true|yes)$/i.test(env[`${prefix}_DEBUG`] ?? "");
|
|
@@ -140,7 +140,7 @@ export function slipway(definition) {
|
|
|
140
140
|
assertVisible(tool, policy, envPrefix);
|
|
141
141
|
const { confirm: _confirm, ...args } = rawArgs;
|
|
142
142
|
const summary = summarize(tool, args);
|
|
143
|
-
new Guard(policy, options.surface, envPrefix).preflight(tool, summary);
|
|
143
|
+
new Guard(policy, options.surface, envPrefix).preflight(forCall(tool, args), summary);
|
|
144
144
|
return summary;
|
|
145
145
|
},
|
|
146
146
|
async run(tool, rawArgs, options) {
|
|
@@ -152,8 +152,10 @@ export function slipway(definition) {
|
|
|
152
152
|
const confirmedBy = options.approvedBy ?? (options.confirmed === true || confirm === true ? "flag" : undefined);
|
|
153
153
|
const dryRun = options.dryRun === true;
|
|
154
154
|
const summary = summarize(tool, args);
|
|
155
|
+
// What this call risks, which `riskFor` may put below the tool's declared risk.
|
|
156
|
+
const call = forCall(tool, args);
|
|
155
157
|
const guard = new Guard(policy, options.surface, envPrefix);
|
|
156
|
-
guard.check(
|
|
158
|
+
guard.check(call, { confirmedBy, dryRun, summary });
|
|
157
159
|
const ctx = await app.context(env);
|
|
158
160
|
const timeoutMs = tool.timeoutMs ?? policy.toolTimeoutMs;
|
|
159
161
|
const withDeadline = () => deadlineSignal(options.signal, timeoutMs);
|
|
@@ -168,7 +170,7 @@ export function slipway(definition) {
|
|
|
168
170
|
surface: options.surface,
|
|
169
171
|
env,
|
|
170
172
|
dryRun,
|
|
171
|
-
tool: { name: tool.name, risk:
|
|
173
|
+
tool: { name: tool.name, risk: call.risk },
|
|
172
174
|
secrets,
|
|
173
175
|
log,
|
|
174
176
|
async progress(progress, total, message) {
|
|
@@ -182,7 +184,7 @@ export function slipway(definition) {
|
|
|
182
184
|
const wouldRun = tool.preview ? await tool.preview(args, toolContext) : args;
|
|
183
185
|
return { dry_run: true, tool: tool.name, summary, would_run: secrets.redactDeep(wouldRun) };
|
|
184
186
|
}
|
|
185
|
-
const writes =
|
|
187
|
+
const writes = call.risk !== "read" || call.requireConfirm;
|
|
186
188
|
const cache = tool.cache && policy.cache ? { scope: app.dataScope(ctx), key: cacheKey(args), ttlSeconds: tool.cache.ttlSeconds } : undefined;
|
|
187
189
|
if (cache && !options.refresh) {
|
|
188
190
|
const hit = await quietly(log, async () => (await app.localData(env)).cacheGet(cache.scope, tool.name, cache.key));
|
|
@@ -227,7 +229,7 @@ export function slipway(definition) {
|
|
|
227
229
|
result = await untilAborted(Promise.resolve(tool.handler(args, toolContext)), signal, timeoutMs, tool.name);
|
|
228
230
|
}
|
|
229
231
|
if (writes)
|
|
230
|
-
guard.record(
|
|
232
|
+
guard.record(call, summary, result?.done === false ? "started" : "done");
|
|
231
233
|
await forget();
|
|
232
234
|
// Only data is cached. Images and files are fetched again.
|
|
233
235
|
if (cache && !isContentResult(result)) {
|
|
@@ -238,7 +240,7 @@ export function slipway(definition) {
|
|
|
238
240
|
}
|
|
239
241
|
catch (error) {
|
|
240
242
|
if (writes)
|
|
241
|
-
guard.record(
|
|
243
|
+
guard.record(call, summary, "failed");
|
|
242
244
|
await forget();
|
|
243
245
|
throw toSlipwayError(error);
|
|
244
246
|
}
|
package/dist/cli/context.js
CHANGED
|
@@ -79,7 +79,9 @@ export function agentContext(app, env, bin, options = {}) {
|
|
|
79
79
|
})),
|
|
80
80
|
{ env: names.readOnly, value: policy.readOnly, description: "hide and refuse every write" },
|
|
81
81
|
{ env: names.allowDestructive, value: policy.allowDestructive, description: "allow public or irreversible writes" },
|
|
82
|
-
|
|
82
|
+
...(app.allTools.some((tool) => tool.tags.length > 0)
|
|
83
|
+
? [{ env: names.toolsets, value: policy.toolsets === "all" ? "all" : [...policy.toolsets], description: "toolsets that are on" }]
|
|
84
|
+
: []),
|
|
83
85
|
{ env: names.surface, value: policy.surface, description: "full tool list, or search for very large catalogs" },
|
|
84
86
|
{ env: names.auditLog, value: policy.auditLog ?? null, description: "file that records every attempted write" },
|
|
85
87
|
{ env: names.toolTimeoutMs, value: policy.toolTimeoutMs ?? null, description: "deadline for any tool" },
|
package/dist/cli/help.js
CHANGED
|
@@ -216,7 +216,8 @@ export function renderGeneralHelp(app, bin) {
|
|
|
216
216
|
...(app.definition.settings ?? []).filter((setting) => !setting.tuning).map((setting) => [setting.env, setting.description]),
|
|
217
217
|
[`${names.readOnly}=1`, "hide and refuse every write"],
|
|
218
218
|
[`${names.allowDestructive}=0`, "refuse the irreversible writes"],
|
|
219
|
-
|
|
219
|
+
// With no tagged tool every tool is always on, so the switch would do nothing.
|
|
220
|
+
...(app.allTools.some((tool) => tool.tags.length > 0) ? [[`${names.toolsets}=a,b`, "only these toolsets, or all"]] : []),
|
|
220
221
|
[`${names.surface}=search`, "MCP lists three finder tools instead"],
|
|
221
222
|
[`${names.auditLog}=<file>`, "log every attempted write"],
|
|
222
223
|
[`${names.toolTimeoutMs}=<ms>`, "deadline for any tool"],
|
package/dist/cli/run.js
CHANGED
|
@@ -238,7 +238,11 @@ async function runBuiltin(app, io, command, rest, globals) {
|
|
|
238
238
|
const query = rest.filter((token) => !token.startsWith("-")).join(" ");
|
|
239
239
|
if (!query)
|
|
240
240
|
throw new UsageError("which expects the words for what you want to do: which schedule a post");
|
|
241
|
-
|
|
241
|
+
// Only the close matches: on Threads the right command scored 20 and the eighth 9, and the
|
|
242
|
+
// ten-line list cost an agent more to read than the answer was worth.
|
|
243
|
+
const found = searchTools(app.tools(io.env), query, 10);
|
|
244
|
+
const best = found[0]?.score ?? 0;
|
|
245
|
+
const matches = found.filter(({ score }, index) => index < 3 || score >= best / 2);
|
|
242
246
|
if (globals.format !== "auto") {
|
|
243
247
|
return print(io, json(globals, matches.map(({ tool, score }) => ({ command: tool.command, title: tool.title, risk: tool.risk, score: Number(score.toFixed(2)) }))));
|
|
244
248
|
}
|
package/dist/guard.d.ts
CHANGED
|
@@ -43,4 +43,4 @@ export declare class Guard {
|
|
|
43
43
|
record(tool: Tool, summary: string, outcome: GuardOutcome | "failed" | "done" | "started", confirmedBy?: ConfirmedBy): void;
|
|
44
44
|
}
|
|
45
45
|
/** Why a tool needs confirming, in the words a refusal and an approval form both use. */
|
|
46
|
-
export declare function consequence(tool: Pick<Tool, "risk">): string;
|
|
46
|
+
export declare function consequence(tool: Pick<Tool, "risk" | "consequence">): string;
|
package/dist/guard.js
CHANGED
|
@@ -85,5 +85,7 @@ export class Guard {
|
|
|
85
85
|
}
|
|
86
86
|
/** Why a tool needs confirming, in the words a refusal and an approval form both use. */
|
|
87
87
|
export function consequence(tool) {
|
|
88
|
+
if (tool.consequence)
|
|
89
|
+
return tool.consequence;
|
|
88
90
|
return tool.risk === "destructive" ? "is public or cannot be undone" : "has an effect that cannot be taken back";
|
|
89
91
|
}
|
package/dist/serve.d.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Serving an app over stdio, which every local client launches, or HTTP.
|
|
3
3
|
*/
|
|
4
4
|
import { type App } from "./app.js";
|
|
5
|
+
import type { Logger } from "./tool.js";
|
|
5
6
|
/**
|
|
6
7
|
* Serve over stdio.
|
|
7
8
|
*
|
|
@@ -11,6 +12,11 @@ import { type App } from "./app.js";
|
|
|
11
12
|
* set of tools that explain what to configure.
|
|
12
13
|
*/
|
|
13
14
|
export declare function serveStdioApp(app: App, env: NodeJS.ProcessEnv): Promise<void>;
|
|
15
|
+
/**
|
|
16
|
+
* Once the server is answering: say if nothing is configured, then run the
|
|
17
|
+
* app's `onServe`. Nothing here can hold up the handshake or stop the server.
|
|
18
|
+
*/
|
|
19
|
+
export declare function afterStart(app: App, env: NodeJS.ProcessEnv, log: Logger): Promise<void>;
|
|
14
20
|
export type HttpOptions = {
|
|
15
21
|
host: string;
|
|
16
22
|
port: number;
|
package/dist/serve.js
CHANGED
|
@@ -27,17 +27,29 @@ export async function serveStdioApp(app, env) {
|
|
|
27
27
|
};
|
|
28
28
|
process.on("SIGINT", shutdown);
|
|
29
29
|
process.on("SIGTERM", shutdown);
|
|
30
|
-
void
|
|
30
|
+
void afterStart(app, env, log);
|
|
31
31
|
}
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Once the server is answering: say if nothing is configured, then run the
|
|
34
|
+
* app's `onServe`. Nothing here can hold up the handshake or stop the server.
|
|
35
|
+
*/
|
|
36
|
+
export async function afterStart(app, env, log) {
|
|
37
|
+
let ctx;
|
|
33
38
|
try {
|
|
34
|
-
|
|
39
|
+
ctx = await app.context(env);
|
|
35
40
|
if (app.definition.configured && !(await app.definition.configured(ctx))) {
|
|
36
|
-
warn(`Nothing is configured yet. Tools that need an account will say what is missing. Run \`${app.bins.cli} doctor\`.`);
|
|
41
|
+
log.warn(`Nothing is configured yet. Tools that need an account will say what is missing. Run \`${app.bins.cli} doctor\`.`);
|
|
37
42
|
}
|
|
38
43
|
}
|
|
39
44
|
catch (error) {
|
|
40
|
-
warn(`Setup is incomplete: ${error.message} Run \`${app.bins.cli} doctor\`.`);
|
|
45
|
+
log.warn(`Setup is incomplete: ${error.message} Run \`${app.bins.cli} doctor\`.`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
await app.definition.onServe?.(ctx, log);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
log.warn(app.secrets.redact(error?.message ?? String(error)));
|
|
41
53
|
}
|
|
42
54
|
}
|
|
43
55
|
export function httpOptions(app, env, argv) {
|
|
@@ -83,6 +95,7 @@ export async function serveHttpApp(app, env, options) {
|
|
|
83
95
|
const port = address && typeof address === "object" ? address.port : options.port;
|
|
84
96
|
const url = `http://${options.host.includes(":") && !options.host.startsWith("[") ? `[${options.host}]` : options.host}:${port}/mcp`;
|
|
85
97
|
log.info(`listening on ${url}${options.token ? " (bearer token required)" : ""}`);
|
|
98
|
+
void afterStart(app, env, log);
|
|
86
99
|
return {
|
|
87
100
|
url,
|
|
88
101
|
close: async () => {
|
package/dist/server.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { McpServer, type ToolAnnotations } from "@modelcontextprotocol/server";
|
|
9
9
|
import type { App } from "./app.js";
|
|
10
10
|
import type { Policy } from "./policy.js";
|
|
11
|
-
import type
|
|
11
|
+
import { type Tool } from "./tool.js";
|
|
12
12
|
/** Claude Code shows a person a permission prompt on every call to a tool carrying this, in any mode. */
|
|
13
13
|
export declare const REQUIRES_USER_INTERACTION = "anthropic/requiresUserInteraction";
|
|
14
14
|
/** Claude Code raises its result-size limit for a tool carrying this. */
|
package/dist/server.js
CHANGED
|
@@ -12,6 +12,7 @@ import { toSlipwayError, UsageError } from "./errors.js";
|
|
|
12
12
|
import { errorResult, isPlainObject, toCallToolResult } from "./result.js";
|
|
13
13
|
import { outputJsonSchema } from "./schema.js";
|
|
14
14
|
import { firstSentence, searchTools } from "./search.js";
|
|
15
|
+
import { forCall } from "./tool.js";
|
|
15
16
|
/** Claude Code shows a person a permission prompt on every call to a tool carrying this, in any mode. */
|
|
16
17
|
export const REQUIRES_USER_INTERACTION = "anthropic/requiresUserInteraction";
|
|
17
18
|
/** Claude Code raises its result-size limit for a tool carrying this. */
|
|
@@ -82,14 +83,16 @@ export function buildServer(app, env) {
|
|
|
82
83
|
* is what makes Claude Code prompt for it.
|
|
83
84
|
*/
|
|
84
85
|
async function confirmFirst(server, app, tool, args, ctx, env, policy, listedForPerson) {
|
|
85
|
-
|
|
86
|
+
// A call whose arguments make it a plain write needs nobody's approval, even on a tool that can publish.
|
|
87
|
+
const call = forCall(tool, args);
|
|
88
|
+
if (!call.requireConfirm)
|
|
86
89
|
return {};
|
|
87
90
|
const route = confirmRoute(policy.confirm, clientView(server, ctx), listedForPerson);
|
|
88
91
|
if (route === "client")
|
|
89
92
|
return { approvedBy: "client" };
|
|
90
93
|
if (route === "flag")
|
|
91
94
|
return {};
|
|
92
|
-
const ask = await personApproval(app,
|
|
95
|
+
const ask = await personApproval(app, call, args, ctx, env);
|
|
93
96
|
return ask ? { ask } : { approvedBy: "person" };
|
|
94
97
|
}
|
|
95
98
|
function registerTool(server, app, tool, env, policy) {
|
package/dist/tool.d.ts
CHANGED
|
@@ -103,6 +103,20 @@ export type ToolDefinition<Ctx, I extends Schema, O extends Schema | undefined>
|
|
|
103
103
|
* generation.
|
|
104
104
|
*/
|
|
105
105
|
requireConfirm?: boolean;
|
|
106
|
+
/**
|
|
107
|
+
* The risk of one call, when its arguments decide it: publishing is
|
|
108
|
+
* destructive, saving a draft is a write. `risk` stays the highest a call can
|
|
109
|
+
* be, which is what clients see in annotations and listings. The guard, the
|
|
110
|
+
* confirmation and the audit log go by this, and with it only a destructive
|
|
111
|
+
* call needs confirming.
|
|
112
|
+
*/
|
|
113
|
+
riskFor?: (args: InferOutput<I>) => Risk;
|
|
114
|
+
/**
|
|
115
|
+
* What a confirmed call does that cannot be taken back, as the refusal and
|
|
116
|
+
* the approval form put it: "moves money and cannot be undone". Defaults to
|
|
117
|
+
* "is public or cannot be undone" for a destructive tool.
|
|
118
|
+
*/
|
|
119
|
+
consequence?: string;
|
|
106
120
|
/** Toolsets this tool belongs to. A tool with no tags is always on. */
|
|
107
121
|
tags?: string[];
|
|
108
122
|
/** One line for the audit log and the refusal message: "post 'Hello' as @alice". */
|
|
@@ -146,6 +160,10 @@ export type Tool<Ctx = any> = {
|
|
|
146
160
|
readonly idempotent: boolean;
|
|
147
161
|
readonly openWorld: boolean;
|
|
148
162
|
readonly requireConfirm: boolean;
|
|
163
|
+
/** The risk of one call, from its arguments. `forCall` applies it. */
|
|
164
|
+
readonly riskFor?: (args: any) => Risk;
|
|
165
|
+
/** What a confirmed call does that cannot be taken back, in the tool's own words. */
|
|
166
|
+
readonly consequence?: string;
|
|
149
167
|
readonly tags: readonly string[];
|
|
150
168
|
readonly examples: readonly ToolExample[];
|
|
151
169
|
readonly positional: readonly string[];
|
|
@@ -191,4 +209,11 @@ export declare function isTool(value: unknown): value is Tool;
|
|
|
191
209
|
* arguments have been anywhere near the handler.
|
|
192
210
|
*/
|
|
193
211
|
export declare function summarize(tool: Pick<Tool, "summary" | "title">, args: Record<string, unknown>): string;
|
|
212
|
+
/**
|
|
213
|
+
* The tool as one call sees it. With `riskFor`, the call's risk comes from its
|
|
214
|
+
* arguments, never above the declared one, and only a destructive call needs
|
|
215
|
+
* confirming. Like `summarize`, it runs before validation, so a `riskFor` that
|
|
216
|
+
* throws on odd arguments counts as the declared risk.
|
|
217
|
+
*/
|
|
218
|
+
export declare function forCall<Ctx>(tool: Tool<Ctx>, args: Record<string, unknown>): Tool<Ctx>;
|
|
194
219
|
export {};
|
package/dist/tool.js
CHANGED
|
@@ -36,6 +36,12 @@ export function defineTool(definition) {
|
|
|
36
36
|
}
|
|
37
37
|
if (typeof definition.handler !== "function")
|
|
38
38
|
throw new Error(`${where}: handler is required.`);
|
|
39
|
+
if (definition.riskFor !== undefined) {
|
|
40
|
+
if (typeof definition.riskFor !== "function")
|
|
41
|
+
throw new Error(`${where}: riskFor must be a function of the arguments.`);
|
|
42
|
+
if (definition.risk === "read")
|
|
43
|
+
throw new Error(`${where}: riskFor is for a write whose arguments decide how far it reaches; a read has nothing to decide.`);
|
|
44
|
+
}
|
|
39
45
|
const job = definition.job;
|
|
40
46
|
if (job) {
|
|
41
47
|
if (definition.name.length > 57)
|
|
@@ -99,6 +105,8 @@ export function defineTool(definition) {
|
|
|
99
105
|
idempotent: definition.idempotent ?? definition.risk === "read",
|
|
100
106
|
openWorld: definition.openWorld ?? true,
|
|
101
107
|
requireConfirm,
|
|
108
|
+
...(definition.riskFor ? { riskFor: definition.riskFor } : {}),
|
|
109
|
+
...(definition.consequence?.trim() ? { consequence: definition.consequence.trim().replace(/\.$/, "") } : {}),
|
|
102
110
|
tags: Object.freeze([...(definition.tags ?? [])]),
|
|
103
111
|
examples: Object.freeze([...(definition.examples ?? [])]),
|
|
104
112
|
positional: Object.freeze([...(definition.positional ?? [])]),
|
|
@@ -149,3 +157,26 @@ export function summarize(tool, args) {
|
|
|
149
157
|
return fallback;
|
|
150
158
|
}
|
|
151
159
|
}
|
|
160
|
+
const REACH = { read: 0, write: 1, destructive: 2 };
|
|
161
|
+
/**
|
|
162
|
+
* The tool as one call sees it. With `riskFor`, the call's risk comes from its
|
|
163
|
+
* arguments, never above the declared one, and only a destructive call needs
|
|
164
|
+
* confirming. Like `summarize`, it runs before validation, so a `riskFor` that
|
|
165
|
+
* throws on odd arguments counts as the declared risk.
|
|
166
|
+
*/
|
|
167
|
+
export function forCall(tool, args) {
|
|
168
|
+
if (!tool.riskFor)
|
|
169
|
+
return tool;
|
|
170
|
+
let risk = tool.risk;
|
|
171
|
+
try {
|
|
172
|
+
const asked = tool.riskFor(args);
|
|
173
|
+
if (asked in REACH && REACH[asked] < REACH[tool.risk])
|
|
174
|
+
risk = asked;
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// The declared risk is the safe answer.
|
|
178
|
+
}
|
|
179
|
+
if (risk === tool.risk)
|
|
180
|
+
return tool;
|
|
181
|
+
return { ...tool, risk, requireConfirm: tool.requireConfirm && risk === "destructive" };
|
|
182
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thenavidm/slipway",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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",
|