@thenavidm/slipway 0.1.10 → 0.1.12

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,14 @@
2
2
 
3
3
  What changed in Slipway, newest first.
4
4
 
5
+ ## 0.1.12, 2026-10-05: what the Meta Ad Library needed
6
+
7
+ - **The write switches appear only where they act.** `<PREFIX>_READ_ONLY` and `<PREFIX>_AUDIT_LOG` are listed in the general help, `agent-context` and the generated settings table only when a tool writes, and `<PREFIX>_ALLOW_DESTRUCTIVE` and `<PREFIX>_CONFIRM` only when a tool can be irreversible, spends money or needs confirming. The Meta Ad Library server only reads, and its help offered to "refuse the irreversible writes"; it is now 40 tokens shorter, 399 against 439. The switches still work if set.
8
+
9
+ ## 0.1.11, 2026-10-05: what WordPress needed
10
+
11
+ - **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.
12
+
5
13
  ## 0.1.10, 2026-10-05: what TikTok needed
6
14
 
7
15
  - **`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.
package/README.md CHANGED
@@ -577,10 +577,14 @@ See [CHANGELOG.md](CHANGELOG.md).
577
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 |
578
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 |
579
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
+ | [Podcast Index](https://github.com/thenavidm/podcastindex-mcp-cli) | [`@thenavidm/podcastindex-mcp-cli`](https://www.npmjs.com/package/@thenavidm/podcastindex-mcp-cli) 2.0.0 | The open podcast directory, with the transcripts and chapters it links to fetched and read, guest histories, feed health and value-for-value |
580
581
  | [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 |
581
582
  | [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 |
582
583
  | [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 |
584
+ | [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 |
583
585
  | [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 |
586
+ | [WordPress](https://github.com/thenavidm/wordpress-mcp-cli) | [`@thenavidm/wordpress-mcp-cli`](https://www.npmjs.com/package/@thenavidm/wordpress-mcp-cli) 2.0.0 | Posts, pages, custom post types, media, terms, users and comments across several sites, plus Elementor, Rank Math, redirects and bulk edits through a helper plugin |
587
+ | [YouTube](https://github.com/thenavidm/youtube-mcp-cli) | [`@thenavidm/youtube-mcp-cli`](https://www.npmjs.com/package/@thenavidm/youtube-mcp-cli) 3.0.1 | Transcripts of any public video, channel research against each channel's own median, and your own channels' videos, comments and analytics |
584
588
 
585
589
  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.
586
590
 
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { createRequire } from "node:module";
9
9
  import { EXIT } from "../errors.js";
10
- import { policyEnvNames } from "../policy.js";
10
+ import { policyEnvNames, switchesThatApply } from "../policy.js";
11
11
  import { outputJsonSchema } from "../schema.js";
12
12
  import { exampleCommand, GLOBAL_FLAGS } from "./help.js";
13
13
  import { flagsFor } from "./flags.js";
@@ -26,6 +26,7 @@ export const EXIT_MEANINGS = {
26
26
  export function agentContext(app, env, bin, options = {}) {
27
27
  const policy = app.policy(env);
28
28
  const names = policyEnvNames(app.envPrefix);
29
+ const applies = switchesThatApply(app.allTools);
29
30
  const tools = app.tools(env);
30
31
  const hidden = app.allTools.length - tools.length;
31
32
  // Terminal commands the app adds beside its tools, such as logout, and a sign-in flow that says what it takes.
@@ -78,15 +79,19 @@ export function agentContext(app, env, bin, options = {}) {
78
79
  ...(setting.secret ? { secret: true } : {}),
79
80
  description: setting.description,
80
81
  })),
81
- { env: names.readOnly, value: policy.readOnly, description: "hide and refuse every write" },
82
- { env: names.allowDestructive, value: policy.allowDestructive, description: app.allTools.some((tool) => tool.spends) ? "allow public or irreversible writes and paid calls" : "allow public or irreversible writes" },
82
+ ...(applies.readOnly ? [{ env: names.readOnly, value: policy.readOnly, description: "hide and refuse every write" }] : []),
83
+ ...(applies.allowDestructive
84
+ ? [{ env: names.allowDestructive, value: policy.allowDestructive, description: app.allTools.some((tool) => tool.spends) ? "allow public or irreversible writes and paid calls" : "allow public or irreversible writes" }]
85
+ : []),
83
86
  ...(app.allTools.some((tool) => tool.tags.length > 0)
84
87
  ? [{ env: names.toolsets, value: policy.toolsets === "all" ? "all" : [...policy.toolsets], description: "toolsets that are on" }]
85
88
  : []),
86
89
  { env: names.surface, value: policy.surface, description: "full tool list, or search for very large catalogs" },
87
- { env: names.auditLog, value: policy.auditLog ?? null, description: "file that records every attempted write" },
90
+ ...(applies.auditLog ? [{ env: names.auditLog, value: policy.auditLog ?? null, description: "file that records every attempted write" }] : []),
88
91
  { env: names.toolTimeoutMs, value: policy.toolTimeoutMs ?? null, description: "deadline for any tool" },
89
- { env: names.confirm, value: policy.confirm, description: "who confirms a confirmed call over MCP: human asks a person where the client can, model accepts confirm: true" },
92
+ ...(applies.confirm
93
+ ? [{ env: names.confirm, value: policy.confirm, description: "who confirms a confirmed call over MCP: human asks a person where the client can, model accepts confirm: true" }]
94
+ : []),
90
95
  { env: `${app.envPrefix}_HTTP_PORT`, value: env[`${app.envPrefix}_HTTP_PORT`] ?? null, description: `port for --http, ${app.definition.httpPort ?? 8787} when unset` },
91
96
  { env: `${app.envPrefix}_HTTP_HOST`, value: env[`${app.envPrefix}_HTTP_HOST`] ?? null, description: "address for --http, 127.0.0.1 when unset; any other needs a token" },
92
97
  { env: `${app.envPrefix}_HTTP_TOKEN`, set: Boolean(env[`${app.envPrefix}_HTTP_TOKEN`]), secret: true, description: "bearer token --http requires" },
package/dist/cli/help.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * shows what applies and points to the rest instead of repeating it.
6
6
  */
7
7
  import { EXIT } from "../errors.js";
8
- import { policyEnvNames, riskMark, visibility } from "../policy.js";
8
+ import { policyEnvNames, riskMark, switchesThatApply, visibility } from "../policy.js";
9
9
  import { firstSentence } from "../search.js";
10
10
  import { flagsFor } from "./flags.js";
11
11
  const COLUMN = 30;
@@ -205,6 +205,7 @@ function table(rows) {
205
205
  }
206
206
  export function renderGeneralHelp(app, bin) {
207
207
  const names = policyEnvNames(app.envPrefix);
208
+ const applies = switchesThatApply(app.allTools);
208
209
  const cache = app.allTools.some((tool) => tool.cache);
209
210
  const sync = app.allTools.some((tool) => tool.sync);
210
211
  const jobs = app.allTools.some((tool) => tool.job);
@@ -236,20 +237,24 @@ export function renderGeneralHelp(app, bin) {
236
237
  const tuning = [
237
238
  ...(app.definition.settings ?? []).filter((setting) => setting.tuning).map((setting) => setting.env),
238
239
  names.surface,
239
- names.auditLog,
240
+ ...(applies.auditLog ? [names.auditLog] : []),
240
241
  names.toolTimeoutMs,
241
- names.confirm,
242
+ ...(applies.confirm ? [names.confirm] : []),
242
243
  ...(cache ? [names.cache] : []),
243
244
  ...(cache || sync ? [names.dataDir] : []),
244
245
  `${app.envPrefix}_DEBUG`,
245
246
  ];
246
247
  const settings = [
247
248
  ...(app.definition.settings ?? []).filter((setting) => !setting.tuning).map((setting) => [setting.env, setting.description]),
248
- [`${names.readOnly}=1`, "hide and refuse every write"],
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
- ],
249
+ ...(applies.readOnly ? [[`${names.readOnly}=1`, "hide and refuse every write"]] : []),
250
+ ...(applies.allowDestructive
251
+ ? [
252
+ [
253
+ `${names.allowDestructive}=0`,
254
+ `${app.definition.defaults?.destructiveOff === "hide" ? "hide and refuse" : "refuse"} the irreversible writes${app.allTools.some((tool) => tool.spends) ? " and paid calls" : ""}`,
255
+ ],
256
+ ]
257
+ : []),
253
258
  // With no tagged tool every tool is always on, so the switch would do nothing.
254
259
  ...(app.allTools.some((tool) => tool.tags.length > 0) ? [[`${names.toolsets}=a,b`, "only these toolsets, or all"]] : []),
255
260
  [`${app.envPrefix}_HTTP_PORT / _HOST / _TOKEN / _ALLOWED_ORIGINS`, "for --http"],
package/dist/docs.js CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { flagsFor } from "./cli/flags.js";
9
9
  import { exampleCommand } from "./cli/help.js";
10
- import { policyEnvNames } from "./policy.js";
10
+ import { policyEnvNames, switchesThatApply } from "./policy.js";
11
11
  function cell(text) {
12
12
  return text.replace(/\|/g, "\\|").replace(/\s*\n\s*/g, " ").trim() || "None";
13
13
  }
@@ -47,17 +47,22 @@ export function toolReference(app, tools, heading = "###") {
47
47
  }
48
48
  export function settingsTable(app) {
49
49
  const names = policyEnvNames(app.envPrefix);
50
+ const applies = switchesThatApply(app.allTools);
50
51
  return [
51
52
  "| Variable | What it does |",
52
53
  "|---|---|",
53
54
  ...(app.definition.settings ?? []).map((setting) => `| \`${setting.env}\` | ${cell(setting.description)}${setting.secret ? " Keep it private." : ""} |`),
54
- `| \`${names.readOnly}=1\` | Hide and refuse every write |`,
55
- `| \`${names.allowDestructive}=0\` | Keep writes, refuse the public or irreversible ones${app.allTools.some((tool) => tool.spends) ? " and paid calls" : ""} |`,
55
+ ...(applies.readOnly ? [`| \`${names.readOnly}=1\` | Hide and refuse every write |`] : []),
56
+ ...(applies.allowDestructive
57
+ ? [`| \`${names.allowDestructive}=0\` | Keep writes, refuse the public or irreversible ones${app.allTools.some((tool) => tool.spends) ? " and paid calls" : ""} |`]
58
+ : []),
56
59
  `| \`${names.toolsets}\` | Comma-separated toolsets to turn on, or \`all\` |`,
57
60
  `| \`${names.surface}=search\` | List three tools that find, describe and run the rest |`,
58
- `| \`${names.auditLog}\` | File that records every attempted write |`,
61
+ ...(applies.auditLog ? [`| \`${names.auditLog}\` | File that records every attempted write |`] : []),
59
62
  `| \`${names.toolTimeoutMs}\` | Give up on any tool after this many milliseconds |`,
60
- `| \`${names.confirm}=model\` | Let \`confirm: true\` alone confirm a call, for an agent with no person to ask. The default, \`human\`, asks a person wherever the client can |`,
63
+ ...(applies.confirm
64
+ ? [`| \`${names.confirm}=model\` | Let \`confirm: true\` alone confirm a call, for an agent with no person to ask. The default, \`human\`, asks a person wherever the client can |`]
65
+ : []),
61
66
  ].join("\n");
62
67
  }
63
68
  export function renderDocs(app, env = process.env) {
package/dist/policy.d.ts CHANGED
@@ -62,6 +62,19 @@ export type PolicyEnv = {
62
62
  cache: string;
63
63
  dataDir: string;
64
64
  };
65
+ /**
66
+ * Which of the write switches can change anything for these tools. A server
67
+ * whose tools all read has nothing for read-only mode to hide or the audit log
68
+ * to record, and one with no irreversible, paid or confirmed call has nothing
69
+ * for the destructive and confirm switches to act on, so help, agent-context
70
+ * and the generated docs leave those out. They still work if set.
71
+ */
72
+ export declare function switchesThatApply(tools: readonly Tool[]): {
73
+ readOnly: boolean;
74
+ allowDestructive: boolean;
75
+ auditLog: boolean;
76
+ confirm: boolean;
77
+ };
65
78
  export declare function policyEnvNames(prefix: string): PolicyEnv;
66
79
  export declare function readPolicy(env: NodeJS.ProcessEnv, prefix: string, defaults?: PolicyDefaults): Policy;
67
80
  export type Visibility = {
package/dist/policy.js CHANGED
@@ -6,6 +6,22 @@
6
6
  * block the irreversible ones, keep a log, load fewer tools. They live under
7
7
  * the server's own prefix, so two servers in one client never share a switch.
8
8
  */
9
+ /**
10
+ * Which of the write switches can change anything for these tools. A server
11
+ * whose tools all read has nothing for read-only mode to hide or the audit log
12
+ * to record, and one with no irreversible, paid or confirmed call has nothing
13
+ * for the destructive and confirm switches to act on, so help, agent-context
14
+ * and the generated docs leave those out. They still work if set.
15
+ */
16
+ export function switchesThatApply(tools) {
17
+ const writes = tools.some((tool) => tool.risk !== "read");
18
+ return {
19
+ readOnly: writes,
20
+ allowDestructive: tools.some((tool) => tool.risk === "destructive" || tool.spends),
21
+ auditLog: writes,
22
+ confirm: tools.some((tool) => tool.requireConfirm),
23
+ };
24
+ }
9
25
  export function policyEnvNames(prefix) {
10
26
  return {
11
27
  readOnly: `${prefix}_READ_ONLY`,
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. They say
89
- * nothing a client can use, so they are left out of what it receives.
90
- * Validation still runs on the schema itself.
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 withoutSafeIntegerBounds(node) {
96
+ function withoutNoise(node, named = false) {
93
97
  if (Array.isArray(node))
94
- return node.map(withoutSafeIntegerBounds);
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 ((key === "maximum" && value === Number.MAX_SAFE_INTEGER) || (key === "minimum" && value === Number.MIN_SAFE_INTEGER))
100
- continue;
101
- out[key] = withoutSafeIntegerBounds(value);
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 withoutSafeIntegerBounds(rest);
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.10",
3
+ "version": "0.1.12",
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",