@thenavidm/slipway 0.1.2 → 0.1.4

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,19 @@
2
2
 
3
3
  What changed in Slipway, newest first.
4
4
 
5
+ ## 0.1.4, 2026-10-04: faster starts, cheaper results in Codex
6
+
7
+ - **A JSON Schema compiles on its tool's first call.** `jsonSchema()` compiled its validator as soon as a tool was defined, so a server paid for every schema before it could answer. On Teachable's 123 contract tools that held the first answer back by 118 ms. Building Stripe's 611 OpenAPI tools took 1,745 ms and now takes 69; GitHub's 1,230 took 646 ms and now take 60 (medians of three runs on one Mac). A tool's first call now compiles its own schema, a median of 3 ms on Stripe's and under 1 ms on GitHub's.
8
+ - **`slipway check` compiles every schema.** A schema that cannot compile, such as one with a broken `$ref`, used to stop the server at startup. It now fails the check, before release, and names the tool.
9
+ - **`--version` prints the bare version.** It printed `notes 1.0.0 (slipway 0.1.3)`, where every server built before Slipway prints `1.0.0`, so a script comparing versions broke on migration. `agent-context` still names the framework and its version.
10
+ - **`structuredContent` only for a tool with an output schema.** An object result went out as JSON text and again as `structuredContent`. Codex hands a model the structured copy in place of the text, as one escaped string, so on a measured Teachable call it read 211 more tokens than for the same JSON as text, and the copy also hid a `render` text or an image. An untyped result is now text alone; a typed one still carries its validated copy. `resultData()` in `@thenavidm/slipway/testing` reads either.
11
+ - **A built-in command explains itself with `--help`.** `install --help` failed asking for a client; it now lists the clients and flags. Every other built-in prints the general help instead of running.
12
+ - **Shorter CLI screens, measured in tokens.** An agent pays for every line it reads. On Teachable's 26 commands, counted with OpenAI's tokenizer: the command list went from 413 tokens to 335, a command's `--help` from 368 to 230, the general help from 802 to 639, and `agent-context --brief` from 2,200 to 912. A command's help now shows only the output flags that command can use, and the command list names the setting that turns hidden commands on.
13
+
14
+ ## 0.1.3, 2026-10-04: npx picks the server by name
15
+
16
+ - **`slipway check` matches npm's real rule.** npx picks a binary named after the package only when the binaries point to different files. When they share one file it starts whichever one the registry lists first, and the registry does not keep the published order: 23 published servers listed their MCP binary first and still started the CLI. 0.1.2's order check could not catch that. The check now requires a binary named after the package on a file of its own, and the README shows the one-line `src/npx.ts` it runs.
17
+
5
18
  ## 0.1.2, 2026-10-04: clients always start the server
6
19
 
7
20
  - **`slipway check` fails a package whose `npx -y` default is the CLI.** With several binaries on one file, npx starts the first one listed. A package that lists its CLI first hands every client launched with `npx -y <package>` the command list instead of a server. The check reads `package.json` and names the fix: list the MCP binary first.
package/README.md CHANGED
@@ -83,7 +83,7 @@ They are the same program reading the same tool definitions, so anything one can
83
83
  - **Local data.** Reads can opt in to a cache, kept per account and cleared by any write. `data sync`, `data search` and `data sql` keep an offline, searchable copy of any list, in one private SQLite file with nothing to install.
84
84
  - **OpenAPI to tools.** `fromOpenAPI()` turns every operation in a document into a tool, with the risk its method implies, its tags as toolsets, and a hash pin that refuses a changed document. It turns 611 of the 612 operations in Stripe's API into tools, and 1,230 of GitHub's 1,232; the rest are file uploads or raw text.
85
85
  - **One command to install.** `<cli> install codex`, or `claude-code`, `claude-desktop`, `cursor`, `vscode` or `gemini`, adds the server to that client's own configuration and passes credentials on instead of writing them down.
86
- - **Typed results.** Declare an output schema and results go out as validated `structuredContent`. Object results are structured even without one.
86
+ - **Typed results.** Declare an output schema and results also go out as validated `structuredContent`. Without one, a result is compact text alone: Codex reads a structured copy in place of the text, and on a measured call that cost 211 more tokens.
87
87
  - **Contract tools.** A tool built from a pinned JSON contract joins the same list as a hand-written Zod tool, with `jsonSchema({...})`.
88
88
  - **Toolsets and a search surface** for large catalogs, so a client loads only what a person turns on.
89
89
  - **Typed errors and exit codes.** Every error carries its exit code and a hint: JSON on stderr in a terminal, a readable error result over MCP.
@@ -124,7 +124,7 @@ npm install --save-dev ajv
124
124
 
125
125
  ## 2. Build a server
126
126
 
127
- A server is three files.
127
+ A server is three files, plus a one-line fourth for npx.
128
128
 
129
129
  **`src/tools.ts`** says what the server can do. `toolkit<Context>()` binds the context type once, so every handler gets `ctx.api` typed:
130
130
 
@@ -179,15 +179,24 @@ import { app } from "./app.js";
179
179
  await app.main();
180
180
  ```
181
181
 
182
+ **`src/npx.ts`** is what `npx -y @you/notes-mcp-cli` runs:
183
+
184
+ ```ts
185
+ #!/usr/bin/env node
186
+ import "./index.js";
187
+ ```
188
+
182
189
  ```json
183
190
  {
184
- "bin": { "notes-mcp": "dist/index.js", "notes-cli": "dist/index.js" }
191
+ "bin": { "notes-mcp": "dist/index.js", "notes-cli": "dist/index.js", "notes-mcp-cli": "dist/npx.js" }
185
192
  }
186
193
  ```
187
194
 
195
+ npx picks a binary named after the package only when the binaries point to different files. When they all share one file it starts whichever one the registry lists first, and the registry does not keep the order they were published in, so a client could get the CLI's command list instead of a server. The fourth binary, on its own file, is picked every time, and `slipway check` fails a package without it.
196
+
188
197
  `notes-mcp` with no arguments serves MCP over stdio and stays silent on stdout. `notes-cli` with no arguments lists the commands. Any argument on either binary is a command, so a typo is reported instead of starting a server that waits on stdin.
189
198
 
190
- The context is built on the first call that needs it, never at startup. `--help` works with nothing configured, and the server answers a client at once and explains what is missing instead of exiting.
199
+ The context is built on the first call that needs it, never at startup, and so is each tool's JSON Schema validator: Stripe's 611 generated tools are ready in about 70 ms. `--help` works with nothing configured, and the server answers a client at once and explains what is missing instead of exiting.
191
200
 
192
201
  ## 3. Tools
193
202
 
@@ -215,7 +224,7 @@ The context is built on the first call that needs it, never at startup. `--help`
215
224
  | `render` | Text for the result when JSON is not the best way to read it |
216
225
  | `handler` | `(args, ctx) => result`. `ctx` is your context plus `signal`, `surface`, `env`, `progress`, `log` and `secrets` |
217
226
 
218
- A handler returns plain data. An object goes out as compact JSON text and as typed `structuredContent`. Return `content([...], data)` with `image()`, `audio()`, `file()` or `resourceLink()` for anything that is not text.
227
+ A handler returns plain data. An object goes out as compact JSON text, and as typed `structuredContent` too when the tool declares `output`. Return `content([...], data)` with `image()`, `audio()`, `file()` or `resourceLink()` for anything that is not text.
219
228
 
220
229
  Throw one of the error classes and the caller gets its exit code. `httpError(status, message)` maps an HTTP status in one line, and `UsageError`, `NotFoundError`, `AuthError`, `RateLimitError`, `ApiError` and `NotConfiguredError` cover the rest.
221
230
 
@@ -374,7 +383,7 @@ Override any operation's name or risk with `names` and `risk`, keep a subset wit
374
383
  | `<cli> <command> --help` | Its flags, choices, defaults, examples and risk |
375
384
  | `<cli> which <words>` | Find the command for a task, by what it does |
376
385
  | `<cli> schema <command>` | The JSON Schema an MCP client receives. `--output` for the result's |
377
- | `<cli> agent-context` | Commands, flags, risk, examples, exit codes and settings as JSON. `--brief` for names only |
386
+ | `<cli> agent-context` | Commands, flags, risk, examples, exit codes and settings as JSON. `--brief` for just the commands, which ones write or need `--confirm`, and the exit codes |
378
387
  | `<cli> doctor` | Check the setup. `--network` also calls the service |
379
388
  | `<cli> login` | How to connect an account |
380
389
  | `<cli> install <client>` | Add the MCP server to a client. See [Add it to a client](#10-add-it-to-a-client) |
@@ -483,10 +492,10 @@ Parity runs on both protocol revisions a client may open with. `slipway docs dis
483
492
  ## 13. Testing
484
493
 
485
494
  ```ts
486
- import { checkApp, cli, connect } from "@thenavidm/slipway/testing";
495
+ import { checkApp, cli, connect, resultData } from "@thenavidm/slipway/testing";
487
496
 
488
497
  const mcp = await connect(app, { env: { NOTES_API_KEY: "test" } });
489
- const result = await mcp.callTool("get_note", { id: 7 });
498
+ const note = resultData(await mcp.callTool("get_note", { id: 7 }));
490
499
  await mcp.close();
491
500
 
492
501
  const { code } = await cli(app, ["delete-note", "7"], { env: {} });
@@ -495,7 +504,7 @@ const { code } = await cli(app, ["delete-note", "7"], { env: {} });
495
504
  const report = await checkApp(app, { env: {} });
496
505
  ```
497
506
 
498
- `connect` talks to the real server over an in-memory transport, through the same stdio entry the binary runs. `cli` runs the real CLI with captured output. To stub the network, build the app with a context that returns a fake client.
507
+ `connect` talks to the real server over an in-memory transport, through the same stdio entry the binary runs. `resultData` reads what a call returned, typed or not. `cli` runs the real CLI with captured output. To stub the network, build the app with a context that returns a fake client.
499
508
 
500
509
  To test approval by a person, give `connect` an `elicit` answer, and pass `era: "modern"` for the 2026-07-28 revision or `clientInfo` to be a particular client:
501
510
 
@@ -664,7 +673,7 @@ Return `content([image(bytes, "image/png")], data)`. `audio()`, `file()` and `re
664
673
  <details>
665
674
  <summary><b>Do I need an output schema?</b></summary>
666
675
 
667
- No. An object result already goes out as `structuredContent`. Declare `output` when you want the result validated before it leaves the server and its shape advertised to clients, which lets a client use the data without parsing text.
676
+ No. Without one, an object result goes out as compact JSON text, which every client reads. Declare `output` when you want the result validated before it leaves the server, its shape advertised, and a typed `structuredContent` copy sent for clients that use data without parsing text. Codex reads that copy in place of the text, so a schema is worth declaring when something uses the shape.
668
677
 
669
678
  </details>
670
679
 
package/SKILL.md CHANGED
@@ -23,8 +23,9 @@ Run `npm ls @thenavidm/slipway` in the repo. If it does not list a version, STOP
23
23
  | `src/tools.ts` | The tools, from `toolkit<Context>().defineTool` | One `defineTool` per action |
24
24
  | `src/app.ts` | `export const app = slipway({...})` | Describes only. Never calls `main()`, so checks and tests can import it |
25
25
  | `src/index.ts` | `await app.main()` | The only file that starts anything. Both binaries point at it |
26
+ | `src/npx.ts` | `import "./index.js";` | What `npx -y <package>` runs. Its binary is named after the package |
26
27
 
27
- `package.json` declares both binaries on the same file: `"<name>-mcp"` and `"<name>-cli"`, both `dist/index.js`.
28
+ `package.json` declares `"<name>-mcp"` and `"<name>-cli"` on `dist/index.js`, and a third binary named after the package (`"<name>-mcp-cli"`) on `dist/npx.js`. npx only picks a binary by name when they point to different files; otherwise it takes whichever one the registry lists first, which may be the CLI.
28
29
 
29
30
  ## Defining a tool
30
31
 
@@ -58,7 +59,7 @@ For a tool generated from an API contract, pass the operation's JSON Schema thro
58
59
 
59
60
  ## Results and errors
60
61
 
61
- - Return plain data. An object goes out as compact JSON text and as `structuredContent`.
62
+ - Return plain data. An object goes out as compact JSON text; with `output` declared, also as validated `structuredContent`.
62
63
  - Add `output` when the shape is stable, so results are validated and typed for clients.
63
64
  - Return `content([image(bytes, "image/png")], data)` for images, audio, files and links.
64
65
  - Throw `httpError(status, message)` for upstream failures, or `UsageError`, `NotFoundError`, `AuthError`, `RateLimitError`, `ApiError`, `NotConfiguredError`. Each carries its exit code.
package/dist/check.js CHANGED
@@ -81,6 +81,15 @@ export async function checkApp(app, options = {}) {
81
81
  const problem = meta?.(schema);
82
82
  if (problem)
83
83
  add("error", "schema", `Not valid JSON Schema 2020-12: ${problem}`, tool.name);
84
+ // A JSON Schema validator compiles on the tool's first call, so compile each one here instead.
85
+ for (const [which, candidate] of [["input", tool.schema], ["output", tool.output]]) {
86
+ try {
87
+ await candidate?.["~standard"].validate({});
88
+ }
89
+ catch (error) {
90
+ add("error", "schema", `The ${which} schema does not compile: ${error.message}`, tool.name);
91
+ }
92
+ }
84
93
  const bytes = schemaBytes(schema);
85
94
  totalBytes += bytes;
86
95
  if (!largest || bytes > largest.bytes)
@@ -219,9 +228,10 @@ async function checkParityIn(era, app, env, tools, add) {
219
228
  }
220
229
  /**
221
230
  * `npx -y <package>` is how most people install a server, and npx starts one
222
- * binary without being told which. With several binaries on one file it runs
223
- * the first listed, so a package that lists its CLI first hands every client
224
- * the command list instead of a server.
231
+ * binary without being told which. npm's rule: when every binary points to
232
+ * the same file, it starts whichever one the registry lists first, and the
233
+ * registry does not keep the order they were published in. Only a binary
234
+ * named after the package, on a file of its own, is picked every time.
225
235
  */
226
236
  function checkBins(app, file, add) {
227
237
  let pkg;
@@ -238,19 +248,20 @@ function checkBins(app, file, add) {
238
248
  if (!pkg.bin || typeof pkg.bin === "string")
239
249
  return;
240
250
  const bins = pkg.bin;
241
- const keys = Object.keys(bins);
242
- if (!keys.includes(app.bins.mcp)) {
251
+ if (!(app.bins.mcp in bins)) {
243
252
  add("error", "install", `package.json has no ${app.bins.mcp} binary, so clients cannot start the server by name.`);
244
253
  return;
245
254
  }
246
255
  const unscoped = (pkg.name ?? "").split("/").pop() ?? "";
247
- // npx's own order: a binary named after the package, then the only file all binaries share.
248
- const chosen = keys.includes(unscoped) ? unscoped : new Set(Object.values(bins)).size === 1 ? keys[0] : undefined;
249
- if (chosen === undefined) {
250
- add("error", "install", `npx -y ${pkg.name} cannot choose between ${keys.join(" and ")}. Point them at one file and list ${app.bins.mcp} first.`);
256
+ const fix = `Add "${unscoped}": "dist/npx.js" to bin, where src/npx.ts holds only: import "./index.js";`;
257
+ if (new Set(Object.values(bins)).size === 1) {
258
+ add("error", "install", `Every binary runs the same file, so npx -y ${pkg.name} starts whichever one the registry lists first, which may be ${app.bins.cli}. ${fix}`);
259
+ }
260
+ else if (!(unscoped in bins)) {
261
+ add("error", "install", `npx -y ${pkg.name} cannot choose between ${Object.keys(bins).join(", ")}. ${fix}`);
251
262
  }
252
- else if (chosen !== app.bins.mcp && bins[chosen] === bins[app.bins.mcp] && chosen === app.bins.cli) {
253
- add("error", "install", `npx -y ${pkg.name} starts ${chosen}, the CLI, so a client launched that way gets the command list instead of a server. List ${app.bins.mcp} first in package.json's bin.`);
263
+ else if (unscoped === app.bins.cli || unscoped.startsWith(app.bins.cli)) {
264
+ add("error", "install", `npx -y ${pkg.name} starts ${unscoped}, which runs as the CLI. Name the package so it does not start with ${app.bins.cli}.`);
254
265
  }
255
266
  }
256
267
  /** Every command and flag a README or SKILL.md tells someone to type must exist. */
@@ -11,6 +11,26 @@ export declare const EXIT_MEANINGS: Record<number, string>;
11
11
  export declare function agentContext(app: App, env: NodeJS.ProcessEnv, bin: string, options?: {
12
12
  brief?: boolean;
13
13
  }): {
14
+ name: string;
15
+ version: string;
16
+ description?: string | undefined;
17
+ usage: {
18
+ run: string;
19
+ help: string;
20
+ note: string;
21
+ agent_mode?: undefined;
22
+ confirm_flag?: undefined;
23
+ };
24
+ exit_codes: Record<number, string>;
25
+ hidden_commands?: number | undefined;
26
+ toolsets?: Record<string, string> | undefined;
27
+ commands: {
28
+ command: string;
29
+ title: string;
30
+ risk?: "destructive" | "write" | undefined;
31
+ requires_confirm?: boolean | undefined;
32
+ }[];
33
+ } | {
14
34
  name: string;
15
35
  title: string;
16
36
  version: string;
@@ -59,12 +79,7 @@ export declare function agentContext(app: App, env: NodeJS.ProcessEnv, bin: stri
59
79
  })[];
60
80
  toolsets?: Record<string, string> | undefined;
61
81
  hidden_commands: number;
62
- commands: ({
63
- command: string;
64
- title: string;
65
- risk: import("../tool.js").Risk;
66
- requires_confirm: boolean;
67
- } | {
82
+ commands: {
68
83
  command: string;
69
84
  tool: string;
70
85
  title: string;
@@ -93,5 +108,5 @@ export declare function agentContext(app: App, env: NodeJS.ProcessEnv, bin: stri
93
108
  description: string;
94
109
  command: string;
95
110
  }[] | undefined;
96
- })[];
111
+ }[];
97
112
  };
@@ -27,6 +27,25 @@ export function agentContext(app, env, bin, options = {}) {
27
27
  const policy = app.policy(env);
28
28
  const names = policyEnvNames(app.envPrefix);
29
29
  const tools = app.tools(env);
30
+ const hidden = app.allTools.length - tools.length;
31
+ const note = "--agent never confirms a write. A command that requires --confirm runs only when it is passed explicitly.";
32
+ if (options.brief) {
33
+ // Enough to pick a command: what each one is, and which write or need --confirm. The full read adds flags and settings.
34
+ return {
35
+ name: app.name,
36
+ version: app.version,
37
+ ...(app.description ? { description: app.description } : {}),
38
+ usage: { run: `${bin} <command> [flags]`, help: `${bin} <command> --help`, note },
39
+ exit_codes: EXIT_MEANINGS,
40
+ ...(hidden ? { hidden_commands: hidden, ...(app.definition.toolsets ? { toolsets: app.definition.toolsets } : {}) } : {}),
41
+ commands: tools.map((tool) => ({
42
+ command: tool.command,
43
+ title: tool.title,
44
+ ...(tool.risk !== "read" ? { risk: tool.risk } : {}),
45
+ ...(tool.requireConfirm ? { requires_confirm: true } : {}),
46
+ })),
47
+ };
48
+ }
30
49
  return {
31
50
  name: app.name,
32
51
  title: app.title,
@@ -39,7 +58,7 @@ export function agentContext(app, env, bin, options = {}) {
39
58
  help: `${bin} <command> --help`,
40
59
  agent_mode: "--agent",
41
60
  confirm_flag: "--confirm",
42
- note: "--agent never confirms a write. A command that requires --confirm runs only when it is passed explicitly.",
61
+ note,
43
62
  },
44
63
  exit_codes: EXIT_MEANINGS,
45
64
  global_flags: GLOBAL_FLAGS.map(([flag, description]) => ({ flag, description })),
@@ -59,36 +78,34 @@ export function agentContext(app, env, bin, options = {}) {
59
78
  { 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" },
60
79
  ],
61
80
  ...(app.definition.toolsets ? { toolsets: app.definition.toolsets } : {}),
62
- hidden_commands: app.allTools.length - tools.length,
63
- commands: tools.map((tool) => options.brief
64
- ? { command: tool.command, title: tool.title, risk: tool.risk, requires_confirm: tool.requireConfirm }
65
- : {
66
- command: tool.command,
67
- tool: tool.name,
68
- title: tool.title,
69
- description: tool.description,
70
- risk: tool.risk,
71
- requires_confirm: tool.requireConfirm,
72
- ...(tool.tags.length ? { toolsets: tool.tags } : {}),
73
- ...(tool.positional.length ? { positional: tool.positional } : {}),
74
- flags: flagsFor(tool.jsonSchema)
75
- .filter((flag) => flag.key !== "confirm")
76
- .map((flag) => ({
77
- flag: flag.flag,
78
- type: flag.kind,
79
- required: flag.required,
80
- ...(flag.repeatable ? { repeatable: true } : {}),
81
- ...(flag.choices ? { choices: flag.choices } : {}),
82
- ...(flag.default !== undefined ? { default: flag.default } : {}),
83
- ...(flag.help ? { description: flag.help } : {}),
84
- })),
85
- ...(tool.output ? { output_schema: outputJsonSchema(tool.output) } : {}),
86
- ...(tool.paginate ? { paginates: true } : {}),
87
- ...(tool.job && !tool.statusOf ? { job: { status_command: `${tool.command}-status`, background: "background" in tool.job } } : {}),
88
- ...(tool.statusOf ? { checks_jobs_of: tool.statusOf.replace(/_/g, "-") } : {}),
89
- ...(tool.examples.length
90
- ? { examples: tool.examples.map((example) => ({ description: example.description, command: exampleCommand(bin, tool, example.args) })) }
91
- : {}),
92
- }),
81
+ hidden_commands: hidden,
82
+ commands: tools.map((tool) => ({
83
+ command: tool.command,
84
+ tool: tool.name,
85
+ title: tool.title,
86
+ description: tool.description,
87
+ risk: tool.risk,
88
+ requires_confirm: tool.requireConfirm,
89
+ ...(tool.tags.length ? { toolsets: tool.tags } : {}),
90
+ ...(tool.positional.length ? { positional: tool.positional } : {}),
91
+ flags: flagsFor(tool.jsonSchema)
92
+ .filter((flag) => flag.key !== "confirm")
93
+ .map((flag) => ({
94
+ flag: flag.flag,
95
+ type: flag.kind,
96
+ required: flag.required,
97
+ ...(flag.repeatable ? { repeatable: true } : {}),
98
+ ...(flag.choices ? { choices: flag.choices } : {}),
99
+ ...(flag.default !== undefined ? { default: flag.default } : {}),
100
+ ...(flag.help ? { description: flag.help } : {}),
101
+ })),
102
+ ...(tool.output ? { output_schema: outputJsonSchema(tool.output) } : {}),
103
+ ...(tool.paginate ? { paginates: true } : {}),
104
+ ...(tool.job && !tool.statusOf ? { job: { status_command: `${tool.command}-status`, background: "background" in tool.job } } : {}),
105
+ ...(tool.statusOf ? { checks_jobs_of: tool.statusOf.replace(/_/g, "-") } : {}),
106
+ ...(tool.examples.length
107
+ ? { examples: tool.examples.map((example) => ({ description: example.description, command: exampleCommand(bin, tool, example.args) })) }
108
+ : {}),
109
+ })),
93
110
  };
94
111
  }
@@ -1,12 +1,15 @@
1
1
  /**
2
2
  * What a person sees before they know what to type.
3
+ *
4
+ * An agent reads this too, and pays for every token of it, so each screen
5
+ * shows what applies and points to the rest instead of repeating it.
3
6
  */
4
7
  import type { App } from "../app.js";
5
8
  import type { Tool } from "../tool.js";
6
9
  /** The words the CLI owns, which no tool command may take. */
7
10
  export declare const BUILTINS: readonly ["help", "tools", "schema", "agent-context", "which", "doctor", "login", "completion", "version", "data", "install"];
8
11
  export declare const GLOBAL_FLAGS: Array<[string, string]>;
9
- export declare function renderList(app: App, tools: readonly Tool[], bin: string): string;
12
+ export declare function renderList(app: App, tools: readonly Tool[], bin: string, env?: NodeJS.ProcessEnv): string;
10
13
  /** An example's arguments as the command someone would type. */
11
14
  export declare function exampleCommand(bin: string, tool: Tool, args: Record<string, unknown>): string;
12
15
  export declare function renderToolHelp(tool: Tool, bin: string): string;
package/dist/cli/help.js CHANGED
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * What a person sees before they know what to type.
3
+ *
4
+ * An agent reads this too, and pays for every token of it, so each screen
5
+ * shows what applies and points to the rest instead of repeating it.
3
6
  */
4
7
  import { EXIT } from "../errors.js";
5
- import { policyEnvNames, riskMark } from "../policy.js";
8
+ import { policyEnvNames, riskMark, visibility } from "../policy.js";
6
9
  import { firstSentence } from "../search.js";
7
10
  import { flagsFor } from "./flags.js";
8
11
  const COLUMN = 30;
@@ -29,12 +32,52 @@ function line(left, help) {
29
32
  return left.length < COLUMN ? [`${left.padEnd(COLUMN)}${help}`] : [left, `${" ".repeat(COLUMN)}${help}`];
30
33
  }
31
34
  function riskWords(tool) {
32
- const base = tool.risk === "read" ? "read only" : tool.risk === "write" ? "writes, reversible" : "public or irreversible";
33
- return tool.requireConfirm ? `${base}, runs only with --confirm` : base;
35
+ return tool.risk === "read" ? "read only" : tool.risk === "write" ? "writes, reversible" : "public or irreversible";
34
36
  }
35
- export function renderList(app, tools, bin) {
37
+ /** The output flags a command can use: the four every command takes, and those its kind adds. */
38
+ function outputFlagsFor(tool) {
39
+ const wanted = new Set(["--json", "--compact", "--select <a,b.c>", "--agent"]);
40
+ if (tool.risk !== "read")
41
+ wanted.add("--dry-run");
42
+ if (tool.cache)
43
+ wanted.add("--refresh");
44
+ if (tool.paginate || tool.sync)
45
+ for (const flag of ["--jsonl", "--csv / --tsv", "--quiet"])
46
+ wanted.add(flag);
47
+ return GLOBAL_FLAGS.filter(([flag]) => wanted.has(flag));
48
+ }
49
+ /** Why some commands are not listed, and the setting that lists them. */
50
+ function hiddenNote(app, env) {
51
+ const policy = app.policy(env);
52
+ const names = policyEnvNames(app.envPrefix);
53
+ const off = new Set();
54
+ let byToolset = 0;
55
+ let byReadOnly = 0;
56
+ for (const tool of app.allTools) {
57
+ const seen = visibility(tool, policy);
58
+ if (seen.visible)
59
+ continue;
60
+ if (seen.reason === "read-only")
61
+ byReadOnly += 1;
62
+ else {
63
+ byToolset += 1;
64
+ for (const tag of tool.tags)
65
+ if (policy.toolsets === "all" || !policy.toolsets.has(tag))
66
+ off.add(tag);
67
+ }
68
+ }
69
+ const lines = [];
70
+ if (byToolset) {
71
+ const sets = [...off].sort();
72
+ lines.push(` ${byToolset} more ${byToolset === 1 ? "command is" : "commands are"} in ${sets.join(", ")}, off: ${names.toolsets}=${sets.join(",")} turns ${byToolset === 1 ? "it" : "them"} on.`);
73
+ }
74
+ if (byReadOnly)
75
+ lines.push(` ${byReadOnly} ${byReadOnly === 1 ? "write is" : "writes are"} hidden by ${names.readOnly}=1.`);
76
+ return lines.length ? [...lines, ``] : [];
77
+ }
78
+ export function renderList(app, tools, bin, env = process.env) {
36
79
  const width = Math.max(10, ...tools.map((tool) => tool.command.length)) + 2;
37
- const lines = [``, `${app.title} ${app.version}${app.description ? `: ${app.description}` : ""}`, ``];
80
+ const lines = [``, `${bin} ${app.version}: ${tools.length} ${tools.length === 1 ? "command" : "commands"}`];
38
81
  const toolsets = app.definition.toolsets ?? {};
39
82
  const groups = new Map();
40
83
  for (const tool of tools) {
@@ -43,19 +86,15 @@ export function renderList(app, tools, bin) {
43
86
  }
44
87
  const ordered = [...groups.entries()].sort(([a], [b]) => (a === "" ? -1 : b === "" ? 1 : a.localeCompare(b)));
45
88
  const grouped = ordered.length > 1 || (ordered[0]?.[0] ?? "") !== "";
46
- lines.push(`Commands (${tools.length})`);
89
+ if (!grouped)
90
+ lines.push(``);
47
91
  for (const [group, members] of ordered) {
48
92
  if (grouped)
49
93
  lines.push(``, ` ${group || "general"}${group && toolsets[group] ? `: ${toolsets[group]}` : ""}`);
50
94
  for (const tool of members)
51
95
  lines.push(` ${riskMark(tool.risk)} ${tool.command.padEnd(width)}${tool.title}`);
52
96
  }
53
- lines.push(``, ` * writes ! public or irreversible`, ``, ` ${bin} <command> --help what a command takes, with examples`, ` ${bin} which <words> find the command for a task`, ` ${bin} schema <command> the JSON Schema an MCP client sees`, ` ${bin} agent-context everything above, as JSON for an agent`, ` ${bin} doctor check the setup`, ` ${bin} install <client> add it to an MCP client: codex, claude-code, cursor…`, ``);
54
- const hidden = app.allTools.length - tools.length;
55
- if (hidden > 0) {
56
- const names = policyEnvNames(app.envPrefix);
57
- lines.push(` ${hidden} more ${hidden === 1 ? "command is" : "commands are"} off: see ${names.readOnly} and ${names.toolsets} in \`${bin} help\`.`, ``);
58
- }
97
+ lines.push(``, ` * writes ! public or irreversible`, ``, ` ${bin} <command> --help what one takes, with examples`, ` ${bin} which <words> find the command for a task`, ` ${bin} --help flags, settings and setup`, ``, ...hiddenNote(app, env));
59
98
  return lines.join("\n");
60
99
  }
61
100
  function shellQuote(value) {
@@ -108,9 +147,10 @@ export function renderToolHelp(tool, bin) {
108
147
  ]
109
148
  .filter(Boolean)
110
149
  .join(" ");
111
- const lines = [``, `${tool.title}`, ``, tool.description, ``, `Usage:`, ` ${usage}`, ``];
112
- const describe = (list, heading) => {
113
- if (!list.length)
150
+ // The title is already on the command list; the description says more.
151
+ const lines = [``, tool.description, ``, `Usage:`, ` ${usage}`, ``];
152
+ const describe = (list, heading, after = []) => {
153
+ if (!list.length && !after.length)
114
154
  return;
115
155
  lines.push(`${heading}:`);
116
156
  for (const flag of list) {
@@ -119,13 +159,10 @@ export function renderToolHelp(tool, bin) {
119
159
  .join(" ");
120
160
  lines.push(...line(` ${flag.flag}${placeholder(flag)}`, [flag.help, extra].filter(Boolean).join(" ")));
121
161
  }
122
- lines.push(``);
162
+ lines.push(...after, ``);
123
163
  };
124
- describe(required, "Required");
164
+ describe(required, "Required", tool.requireConfirm ? line(" --confirm", "it runs only with this; --agent never adds it") : []);
125
165
  describe(optional, "Options");
126
- if (tool.requireConfirm) {
127
- lines.push(`Safety:`, ...line(" --confirm", "required: this runs only when you mean it"), ``);
128
- }
129
166
  if (tool.paginate) {
130
167
  lines.push(`Pages:`, ...line(" --all", "follow every page and print all items"), ...line(" --max-items <n>", "stop after this many items"), ``);
131
168
  }
@@ -139,68 +176,64 @@ export function renderToolHelp(tool, bin) {
139
176
  lines.push(` # ${example.description}`, ` ${exampleCommand(bin, tool, example.args)}`, ``);
140
177
  }
141
178
  lines.push(`Output:`);
142
- for (const [flag, help] of GLOBAL_FLAGS)
179
+ for (const [flag, help] of outputFlagsFor(tool))
143
180
  lines.push(...line(` ${flag}`, help));
144
181
  lines.push(``, `Risk: ${riskWords(tool)}`, ``);
145
182
  return lines.join("\n");
146
183
  }
147
184
  export function renderGeneralHelp(app, bin) {
148
185
  const names = policyEnvNames(app.envPrefix);
186
+ const cache = app.allTools.some((tool) => tool.cache);
187
+ const sync = app.allTools.some((tool) => tool.sync);
188
+ const jobs = app.allTools.some((tool) => tool.job);
189
+ const commands = [
190
+ [bin, "list the commands"],
191
+ [`${bin} <command> --help`, "what one takes, with examples"],
192
+ [`${bin} which <words>`, "find the command for a task"],
193
+ [`${bin} schema <command>`, "its JSON Schema; --output for the result's"],
194
+ [`${bin} agent-context`, "all of this as JSON; --brief for less"],
195
+ [`${bin} doctor [--network]`, "check the setup and say what is wrong"],
196
+ [`${bin} login`, "how to connect an account"],
197
+ [`${bin} install <client>`, "add the server to claude-code, codex, claude-desktop, cursor, vscode or gemini"],
198
+ [`${bin} completion <shell>`, "tab completion for bash, zsh or fish"],
199
+ ...(cache || sync ? [[`${bin} data`, "what is kept on this machine; data clear [<command>] deletes it"]] : []),
200
+ ...(sync
201
+ ? [
202
+ [`${bin} data sync <command>`, "copy every page of a list to this machine"],
203
+ [`${bin} data search <words>`, "search synced records offline (--in <command>)"],
204
+ [`${bin} data sql "<select>"`, "query local data with read-only SQL"],
205
+ ]
206
+ : []),
207
+ [app.bins.mcp, "the MCP server over stdio; --http [--port N] for HTTP"],
208
+ ];
209
+ const settings = [
210
+ ...(app.definition.settings ?? []).map((setting) => [setting.env, setting.description]),
211
+ [`${names.readOnly}=1`, "hide and refuse every write"],
212
+ [`${names.allowDestructive}=0`, "keep writes, refuse the irreversible ones"],
213
+ [`${names.toolsets}=a,b`, "only these toolsets, or all"],
214
+ [`${names.surface}=search`, "MCP serves three tools that find, describe and run the rest"],
215
+ [`${names.auditLog}=<file>`, "log every attempted write to this file"],
216
+ [`${names.toolTimeoutMs}=<ms>`, "give up on any tool after this long"],
217
+ [`${names.confirm}=model`, "confirm: true alone confirms, for an agent with no person to ask"],
218
+ ...(cache ? [[`${names.cache}=0`, "never answer from the local cache"]] : []),
219
+ ...(cache || sync ? [[`${names.dataDir}=<dir>`, "keep local data in this folder"]] : []),
220
+ ];
221
+ // Flags that cannot apply here (jobs, the cache) are left out; agent-context lists every one.
222
+ const flags = GLOBAL_FLAGS.map(([flag]) => flag).filter((flag) => flag !== "--agent" && (flag !== "--wait" || jobs) && (flag !== "--refresh" || cache));
223
+ const width = Math.max(...[...commands, ...settings].map(([left]) => left.length)) + 3;
224
+ const row = ([left, help]) => ` ${left.padEnd(width)}${help}`;
149
225
  const lines = [
150
226
  ``,
151
227
  `${app.title} ${app.version}${app.description ? `: ${app.description}` : ""}`,
152
228
  ``,
153
- `Usage:`,
154
- ` ${app.bins.mcp} run the MCP server over stdio (what an MCP client launches)`,
155
- ` ${app.bins.mcp} --http [--port N] run it over HTTP`,
156
- ` ${bin} list every command`,
157
- ` ${bin} <command> [flags] run one`,
229
+ ...commands.map(row),
158
230
  ``,
159
- `Commands:`,
160
- ...line(" <command> --help", "what a command takes, with examples"),
161
- ...line(" which <words>", "find the command for a task"),
162
- ...line(" schema <command>", "the JSON Schema an MCP client sees (--output for the result's)"),
163
- ...line(" agent-context", "commands, flags, risk, exit codes and settings as JSON"),
164
- ...line(" doctor [--network]", "check the setup and say what is wrong"),
165
- ...line(" login", "how to connect an account"),
166
- ...line(" install <client>", "add this server to claude-code, codex, claude-desktop, cursor, vscode or gemini"),
167
- ...line(" completion <shell>", "tab completion for bash, zsh or fish"),
168
- ...line(" version", "print the version"),
231
+ `Flags: ${flags.join(", ")}, and --agent for compact JSON with no prompts, which never confirms a write.`,
169
232
  ``,
170
- ...(app.allTools.some((tool) => tool.cache || tool.sync)
171
- ? [
172
- `Local data:`,
173
- ...line(" data", "what is kept on this machine, and where"),
174
- ...(app.allTools.some((tool) => tool.sync)
175
- ? [
176
- ...line(" data sync <command>", "copy every page of a list to this machine"),
177
- ...line(" data search <words>", "search synced records offline (--in <command>)"),
178
- ...line(' data sql "<select>"', "query local data with read-only SQL"),
179
- ]
180
- : []),
181
- ...line(" data clear [<command>]", "delete this account's local data (--cache for cached results only)"),
182
- ``,
183
- ]
184
- : []),
185
- `Output flags, on any command:`,
186
- ...GLOBAL_FLAGS.flatMap(([flag, help]) => line(` ${flag}`, help)),
187
- ``,
188
- ...(app.definition.settings?.length
189
- ? [`${app.title} settings:`, ...app.definition.settings.flatMap((setting) => line(` ${setting.env}`, setting.description)), ``]
190
- : []),
191
233
  `Settings:`,
192
- ...line(` ${names.readOnly}=1`, "hide and refuse every write"),
193
- ...line(` ${names.allowDestructive}=0`, "keep writes, refuse the irreversible ones"),
194
- ...line(` ${names.toolsets}=a,b`, "only these toolsets (or all)"),
195
- ...line(` ${names.surface}=search`, "MCP lists three tools that find, describe and run the rest"),
196
- ...line(` ${names.auditLog}=<file>`, "append every attempted write to this file"),
197
- ...line(` ${names.toolTimeoutMs}=<ms>`, "give up on any tool after this long"),
198
- ...line(` ${names.confirm}=model`, "let confirm: true alone confirm, for an agent with no person to ask"),
199
- ...(app.allTools.some((tool) => tool.cache) ? line(` ${names.cache}=0`, "never answer from the local cache") : []),
200
- ...(app.allTools.some((tool) => tool.cache || tool.sync) ? line(` ${names.dataDir}=<dir>`, "keep local data in this folder") : []),
234
+ ...settings.map(row),
201
235
  ``,
202
- `Exit codes:`,
203
- ` ${EXIT.ok} ok ${EXIT.usage} usage or refused write ${EXIT.notFound} not found ${EXIT.auth} auth ${EXIT.api} API ${EXIT.rateLimited} rate limited ${EXIT.notConfigured} nothing configured`,
236
+ `Exit codes: ${EXIT.ok} ok, ${EXIT.error} unexpected error, ${EXIT.usage} usage or refused write, ${EXIT.notFound} not found, ${EXIT.auth} auth, ${EXIT.api} API, ${EXIT.rateLimited} rate limited, ${EXIT.notConfigured} nothing configured`,
204
237
  ``,
205
238
  ];
206
239
  if (app.definition.links?.repository)
@@ -3,6 +3,8 @@
3
3
  */
4
4
  import type { App, CliIO } from "../app.js";
5
5
  import type { Format } from "./output.js";
6
+ /** What `install --help` prints. */
7
+ export declare function installHelp(app: App, bin: string): string;
6
8
  export declare function runInstall(app: App, io: CliIO, tokens: string[], options: {
7
9
  agent: boolean;
8
10
  dryRun: boolean;
@@ -21,6 +21,25 @@ function describe(plan) {
21
21
  lines.push(...(typeof plan.entry === "string" ? plan.entry : JSON.stringify({ [plan.name]: plan.entry }, null, 2)).split("\n").map((line) => ` ${line}`));
22
22
  return lines.join("\n");
23
23
  }
24
+ /** What `install --help` prints. */
25
+ export function installHelp(app, bin) {
26
+ return [
27
+ ``,
28
+ `Usage: ${bin} install <client> [flags]`,
29
+ ``,
30
+ `Adds ${app.title} to an MCP client in that client's own format, and keeps a backup of any file it changes.`,
31
+ ``,
32
+ `Clients: ${Object.keys(CLIENTS).join(", ")}`,
33
+ ``,
34
+ `Flags:`,
35
+ ` --scope user|project where the client keeps it, for a client that has both`,
36
+ ` --name <name> the server's name in the client (default: ${app.name})`,
37
+ ` --copy-env copy credentials into the client's file, for a client that sees no environment`,
38
+ ` --local start this copy on disk instead of the published package`,
39
+ ` --dry-run show the change without making it`,
40
+ ``,
41
+ ].join("\n");
42
+ }
24
43
  export async function runInstall(app, io, tokens, options) {
25
44
  const client = tokens.find((token) => !token.startsWith("--") && !["--scope", "--name"].includes(tokens[tokens.indexOf(token) - 1] ?? ""));
26
45
  const ids = Object.keys(CLIENTS);
package/dist/cli/run.js CHANGED
@@ -14,7 +14,7 @@ import { visibility, policyEnvNames } from "../policy.js";
14
14
  import { outputJsonSchema } from "../schema.js";
15
15
  import { didYouMean, searchTools } from "../search.js";
16
16
  import { completionScript } from "./completion.js";
17
- import { agentContext, SLIPWAY_VERSION } from "./context.js";
17
+ import { agentContext } from "./context.js";
18
18
  import { flagsFor, missingRequired, parseJsonValue, parseToolArgs } from "./flags.js";
19
19
  import { BUILTINS, renderGeneralHelp, renderList, renderToolHelp, toolLine } from "./help.js";
20
20
  import { formatOutput } from "./output.js";
@@ -151,7 +151,7 @@ export async function runCli(app, argv, partial = {}) {
151
151
  return printVersion(app, io);
152
152
  if (globals.help)
153
153
  return print(io, renderGeneralHelp(app, io.bin));
154
- return print(io, renderList(app, app.tools(io.env), io.bin));
154
+ return print(io, renderList(app, app.tools(io.env), io.bin, io.env));
155
155
  }
156
156
  if (builtin)
157
157
  return await runBuiltin(app, io, command, rest, globals);
@@ -185,17 +185,23 @@ function print(io, text) {
185
185
  io.stdout(text.endsWith("\n") ? text : `${text}\n`);
186
186
  return EXIT.ok;
187
187
  }
188
+ /** The bare version, which scripts compare. `agent-context` names the framework and its version. */
188
189
  function printVersion(app, io) {
189
- return print(io, `${app.name} ${app.version} (slipway ${SLIPWAY_VERSION})`);
190
+ return print(io, app.version);
190
191
  }
191
192
  function json(globals, value) {
192
193
  return globals.format === "compact" ? JSON.stringify(value) : JSON.stringify(value, null, 2);
193
194
  }
194
195
  async function runBuiltin(app, io, command, rest, globals) {
196
+ // `<built-in> --help` explains the command instead of running it.
197
+ if (globals.help && command === "install")
198
+ return print(io, (await import("./install.js")).installHelp(app, io.bin));
199
+ if (globals.help && command !== "help")
200
+ return print(io, renderGeneralHelp(app, io.bin));
195
201
  const target = rest.find((token) => !token.startsWith("-"));
196
202
  switch (command) {
197
203
  case "tools":
198
- return print(io, renderList(app, app.tools(io.env), io.bin));
204
+ return print(io, renderList(app, app.tools(io.env), io.bin, io.env));
199
205
  case "version":
200
206
  return printVersion(app, io);
201
207
  case "help": {
package/dist/result.d.ts CHANGED
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Turning what a handler returns into what a client receives.
3
3
  *
4
- * A handler returns plain data. An object goes out twice: as compact JSON text,
5
- * which every client can show a model, and as `structuredContent`, which a
6
- * client can use without parsing text. Compact, because the reader of that text
7
- * is usually a model paying for every character, not a person.
4
+ * A handler returns plain data. An object goes out as compact JSON text, which
5
+ * every client can show a model. Compact, because the reader of that text is
6
+ * usually a model paying for every character, not a person.
7
+ *
8
+ * Only a tool that declares an output schema also sends `structuredContent`.
9
+ * Codex hands a model the structured copy in place of the text, as one escaped
10
+ * string: on a 700-character result that cost 211 more tokens than the text,
11
+ * and it hides a `render` text or an image the same way. With a schema the
12
+ * copy is typed and validated data a client can use; without one it only costs.
8
13
  */
9
14
  import type { CallToolResult, ContentBlock } from "@modelcontextprotocol/server";
10
15
  import type { SlipwayError } from "./errors.js";
package/dist/result.js CHANGED
@@ -1,10 +1,15 @@
1
1
  /**
2
2
  * Turning what a handler returns into what a client receives.
3
3
  *
4
- * A handler returns plain data. An object goes out twice: as compact JSON text,
5
- * which every client can show a model, and as `structuredContent`, which a
6
- * client can use without parsing text. Compact, because the reader of that text
7
- * is usually a model paying for every character, not a person.
4
+ * A handler returns plain data. An object goes out as compact JSON text, which
5
+ * every client can show a model. Compact, because the reader of that text is
6
+ * usually a model paying for every character, not a person.
7
+ *
8
+ * Only a tool that declares an output schema also sends `structuredContent`.
9
+ * Codex hands a model the structured copy in place of the text, as one escaped
10
+ * string: on a 700-character result that cost 211 more tokens than the text,
11
+ * and it hides a `render` text or an image the same way. With a schema the
12
+ * copy is typed and validated data a client can use; without one it only costs.
8
13
  */
9
14
  const CONTENT = Symbol.for("slipway.content");
10
15
  export function content(parts, data) {
@@ -48,7 +53,8 @@ export function toCallToolResult(tool, value, secrets) {
48
53
  if (isContentResult(value)) {
49
54
  const parts = value.parts.map((part) => part.type === "text" ? { ...part, text: secrets.redact(part.text) } : part);
50
55
  const data = secrets.redactDeep(value.data);
51
- const structured = data !== undefined && (isPlainObject(data) || tool.output !== undefined);
56
+ // Without a schema the parts are the result, and the data is for a terminal.
57
+ const structured = data !== undefined && tool.output !== undefined;
52
58
  return structured ? { content: parts, structuredContent: data } : { content: parts };
53
59
  }
54
60
  const data = secrets.redactDeep(value);
@@ -62,12 +68,9 @@ export function toCallToolResult(tool, value, secrets) {
62
68
  : { content: [text(rendered ?? data)] };
63
69
  }
64
70
  const body = rendered ?? (typeof data === "object" ? JSON.stringify(data) : String(data));
65
- // An object is always typed data. Anything else only is when the tool declares
66
- // an output schema, because the protocol needs a schema to describe it.
67
- if (isPlainObject(data) || tool.output !== undefined) {
68
- return { content: [text(body)], structuredContent: data };
69
- }
70
- return { content: [text(body)] };
71
+ return tool.output !== undefined
72
+ ? { content: [text(body)], structuredContent: data }
73
+ : { content: [text(body)] };
71
74
  }
72
75
  export function errorResult(error, secrets) {
73
76
  return { isError: true, content: [text(secrets.redact(JSON.stringify(secrets.redactDeep(error.toJSON()))))] };
package/dist/schema.d.ts CHANGED
@@ -33,6 +33,11 @@ export type Issue = {
33
33
  *
34
34
  * This is how a tool generated from an OpenAPI document or a pinned contract
35
35
  * joins the same tool list as hand-written ones.
36
+ *
37
+ * The validator compiles on the tool's first call, not when the server starts.
38
+ * Compiling all 123 schemas of one server up front held its first answer back
39
+ * by 118 ms, for tools most sessions never call. `slipway check` compiles every
40
+ * one, so a schema that cannot compile still fails before release.
36
41
  */
37
42
  export declare function jsonSchema<T = Record<string, unknown>>(schema: JsonSchema): Schema<T, T>;
38
43
  /** The input of a tool that takes nothing. */
package/dist/schema.js CHANGED
@@ -14,9 +14,22 @@ const TARGET = { target: "draft-2020-12" };
14
14
  *
15
15
  * This is how a tool generated from an OpenAPI document or a pinned contract
16
16
  * joins the same tool list as hand-written ones.
17
+ *
18
+ * The validator compiles on the tool's first call, not when the server starts.
19
+ * Compiling all 123 schemas of one server up front held its first answer back
20
+ * by 118 ms, for tools most sessions never call. `slipway check` compiles every
21
+ * one, so a schema that cannot compile still fails before release.
17
22
  */
18
23
  export function jsonSchema(schema) {
19
- return fromJsonSchema(schema);
24
+ let compiled;
25
+ return {
26
+ "~standard": {
27
+ version: 1,
28
+ vendor: "mcp",
29
+ jsonSchema: { input: () => schema, output: () => schema },
30
+ validate: (value) => (compiled ??= fromJsonSchema(schema))["~standard"].validate(value),
31
+ },
32
+ };
20
33
  }
21
34
  /** The input of a tool that takes nothing. */
22
35
  export function emptyInput() {
package/dist/server.js CHANGED
@@ -151,7 +151,7 @@ function registerSearchSurface(server, app, env, policy) {
151
151
  summary: firstSentence(tool.description),
152
152
  }));
153
153
  const data = { query, count: matches.length, tools: matches };
154
- return { content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data };
154
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
155
155
  });
156
156
  register("describe_tool", {
157
157
  title: "Describe a tool",
@@ -172,7 +172,7 @@ function registerSearchSurface(server, app, env, policy) {
172
172
  ...(tool.output ? { output_schema: outputJsonSchema(tool.output) } : {}),
173
173
  ...(tool.examples.length ? { examples: tool.examples } : {}),
174
174
  };
175
- return { content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data };
175
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
176
176
  });
177
177
  register("call_tool", {
178
178
  title: "Run a tool",
package/dist/testing.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Helpers for testing an app the way its users reach it.
3
3
  *
4
- * import { connect, cli } from "@thenavidm/slipway/testing";
4
+ * import { connect, cli, resultData } from "@thenavidm/slipway/testing";
5
5
  *
6
6
  * const mcp = await connect(app, { env });
7
- * const result = await mcp.callTool("get_profile", { actor: "alice" });
7
+ * const profile = resultData(await mcp.callTool("get_profile", { actor: "alice" }));
8
8
  *
9
9
  * const { code, stdout } = await cli(app, ["get-profile", "alice", "--json"], { env });
10
10
  */
@@ -21,6 +21,17 @@ export type { ConnectOptions, ElicitAnswer, ElicitRequest, ListedTool, RpcClient
21
21
  export declare function connect(app: App, options?: ConnectOptions & {
22
22
  env?: NodeJS.ProcessEnv;
23
23
  }): Promise<RpcClient>;
24
+ /**
25
+ * The data a tool call returned: its `structuredContent` when the tool declares
26
+ * an output schema, otherwise the JSON in its first text block.
27
+ */
28
+ export declare function resultData<T = any>(result: {
29
+ content?: ReadonlyArray<{
30
+ type: string;
31
+ text?: string;
32
+ }>;
33
+ structuredContent?: unknown;
34
+ }): T;
24
35
  export type CliRun = {
25
36
  code: number;
26
37
  stdout: string;
package/dist/testing.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Helpers for testing an app the way its users reach it.
3
3
  *
4
- * import { connect, cli } from "@thenavidm/slipway/testing";
4
+ * import { connect, cli, resultData } from "@thenavidm/slipway/testing";
5
5
  *
6
6
  * const mcp = await connect(app, { env });
7
- * const result = await mcp.callTool("get_profile", { actor: "alice" });
7
+ * const profile = resultData(await mcp.callTool("get_profile", { actor: "alice" }));
8
8
  *
9
9
  * const { code, stdout } = await cli(app, ["get-profile", "alice", "--json"], { env });
10
10
  */
@@ -20,6 +20,18 @@ export function connect(app, options = {}) {
20
20
  const { env, ...rest } = options;
21
21
  return connectInMemory(app, env ?? {}, rest);
22
22
  }
23
+ /**
24
+ * The data a tool call returned: its `structuredContent` when the tool declares
25
+ * an output schema, otherwise the JSON in its first text block.
26
+ */
27
+ export function resultData(result) {
28
+ if (result.structuredContent !== undefined)
29
+ return result.structuredContent;
30
+ const first = result.content?.find((part) => part.type === "text");
31
+ if (first?.text === undefined)
32
+ throw new Error("The result has no structured content and no text.");
33
+ return JSON.parse(first.text);
34
+ }
23
35
  /** Run the CLI with captured output. Nothing reaches the real terminal. */
24
36
  export async function cli(app, argv, options = {}) {
25
37
  let stdout = "";
package/dist/tool.d.ts CHANGED
@@ -131,7 +131,7 @@ export type ToolDefinition<Ctx, I extends Schema, O extends Schema | undefined>
131
131
  cache?: CacheOptions;
132
132
  /** This read lists records worth keeping locally: `data sync` copies every page, `data search` finds them. */
133
133
  sync?: SyncOptions;
134
- /** Text for the result, when JSON is not the best way to read it. Typed data still goes out as `structuredContent`. */
134
+ /** Text for the result, when JSON is not the best way to read it. With `output` declared, the data also goes out as `structuredContent`. */
135
135
  render?: (result: Returns<O>) => string;
136
136
  handler: (args: InferOutput<I>, ctx: ToolContext<Ctx>) => Returns<O> | Promise<Returns<O>>;
137
137
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thenavidm/slipway",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",