@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 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(tool, { confirmedBy, dryRun, summary });
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: tool.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 = tool.risk !== "read" || tool.requireConfirm;
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(tool, summary, result?.done === false ? "started" : "done");
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(tool, summary, "failed");
243
+ guard.record(call, summary, "failed");
242
244
  await forget();
243
245
  throw toSlipwayError(error);
244
246
  }
@@ -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
- { env: names.toolsets, value: policy.toolsets === "all" ? "all" : [...policy.toolsets], description: "toolsets that are on" },
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
- [`${names.toolsets}=a,b`, "only these toolsets, or all"],
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
- const matches = searchTools(app.tools(io.env), query, 10);
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 warnIfUnconfigured(app, env, log.warn);
30
+ void afterStart(app, env, log);
31
31
  }
32
- async function warnIfUnconfigured(app, env, warn) {
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
- const ctx = await app.context(env);
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 { Tool } from "./tool.js";
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
- if (!tool.requireConfirm)
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, tool, args, ctx, env);
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.6",
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",