@thenavidm/slipway 0.1.3 → 0.1.5

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,24 @@
2
2
 
3
3
  What changed in Slipway, newest first.
4
4
 
5
+ ## 0.1.5, 2026-10-04: what Bluesky's move found
6
+
7
+ - **A request that never got an answer exits 5.** A failed fetch, a refused connection or a DNS failure mapped to exit 1, "unexpected error", so a script that retries on 5 gave up instead. Bluesky 1.2.3 exited 5 for an unreachable host, 0.1.4 made it 1, and it is 5 again.
8
+ - **`--help` and `agent-context` list every variable Slipway reads**, `<PREFIX>_HTTP_PORT`, `_HOST`, `_TOKEN` and `_DEBUG` included. Bluesky's own help listed the HTTP ones before it moved.
9
+ - **A shorter general help.** An agent often reads it first and pays for it again on every later step. The header drops the description, the rarely needed commands share one line, and Slipway's own settings say only what they do: Bluesky's went from 780 tokens to 667.
10
+ - **The command list says `!` needs `--confirm`** when that is true of every command it lists.
11
+ - **The entry turns on Node's compile cache.** The README's `src/index.ts` loads the app after `module.enableCompileCache()`, so every launch after the first skips compiling it: Bluesky answers a client in 183 ms instead of 204. Node before 22.8 starts as before.
12
+ - **The README says what happens to a piped request after stdin closes.** The server stops without answering, as the MCP stdio binding asks; keep stdin open until you read the answer.
13
+
14
+ ## 0.1.4, 2026-10-04: faster starts, cheaper results in Codex
15
+
16
+ - **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.
17
+ - **`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.
18
+ - **`--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.
19
+ - **`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.
20
+ - **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.
21
+ - **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.
22
+
5
23
  ## 0.1.3, 2026-10-04: npx picks the server by name
6
24
 
7
25
  - **`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.
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.
@@ -174,11 +174,15 @@ export const app = slipway<Context>({
174
174
 
175
175
  ```ts
176
176
  #!/usr/bin/env node
177
- import { app } from "./app.js";
177
+ import * as nodeModule from "node:module";
178
178
 
179
+ nodeModule.enableCompileCache?.();
180
+ const { app } = await import("./app.js");
179
181
  await app.main();
180
182
  ```
181
183
 
184
+ The app loads after Node's compile cache goes on, so every launch after the first skips compiling it again: Bluesky answers a client in 183 ms instead of 204. Node before 22.8 has no compile cache and starts as before, and `NODE_DISABLE_COMPILE_CACHE=1` turns it off.
185
+
182
186
  **`src/npx.ts`** is what `npx -y @you/notes-mcp-cli` runs:
183
187
 
184
188
  ```ts
@@ -196,7 +200,7 @@ npx picks a binary named after the package only when the binaries point to diffe
196
200
 
197
201
  `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.
198
202
 
199
- 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.
203
+ 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.
200
204
 
201
205
  ## 3. Tools
202
206
 
@@ -224,7 +228,7 @@ The context is built on the first call that needs it, never at startup. `--help`
224
228
  | `render` | Text for the result when JSON is not the best way to read it |
225
229
  | `handler` | `(args, ctx) => result`. `ctx` is your context plus `signal`, `surface`, `env`, `progress`, `log` and `secrets` |
226
230
 
227
- 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.
231
+ 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.
228
232
 
229
233
  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.
230
234
 
@@ -383,7 +387,7 @@ Override any operation's name or risk with `names` and `risk`, keep a subset wit
383
387
  | `<cli> <command> --help` | Its flags, choices, defaults, examples and risk |
384
388
  | `<cli> which <words>` | Find the command for a task, by what it does |
385
389
  | `<cli> schema <command>` | The JSON Schema an MCP client receives. `--output` for the result's |
386
- | `<cli> agent-context` | Commands, flags, risk, examples, exit codes and settings as JSON. `--brief` for names only |
390
+ | `<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 |
387
391
  | `<cli> doctor` | Check the setup. `--network` also calls the service |
388
392
  | `<cli> login` | How to connect an account |
389
393
  | `<cli> install <client>` | Add the MCP server to a client. See [Add it to a client](#10-add-it-to-a-client) |
@@ -492,10 +496,10 @@ Parity runs on both protocol revisions a client may open with. `slipway docs dis
492
496
  ## 13. Testing
493
497
 
494
498
  ```ts
495
- import { checkApp, cli, connect } from "@thenavidm/slipway/testing";
499
+ import { checkApp, cli, connect, resultData } from "@thenavidm/slipway/testing";
496
500
 
497
501
  const mcp = await connect(app, { env: { NOTES_API_KEY: "test" } });
498
- const result = await mcp.callTool("get_note", { id: 7 });
502
+ const note = resultData(await mcp.callTool("get_note", { id: 7 }));
499
503
  await mcp.close();
500
504
 
501
505
  const { code } = await cli(app, ["delete-note", "7"], { env: {} });
@@ -504,7 +508,7 @@ const { code } = await cli(app, ["delete-note", "7"], { env: {} });
504
508
  const report = await checkApp(app, { env: {} });
505
509
  ```
506
510
 
507
- `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.
511
+ `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.
508
512
 
509
513
  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:
510
514
 
@@ -530,6 +534,7 @@ const mcp = await connect(app, { era: "modern", elicit: () => ({ action: "accept
530
534
  | Codex shows the server as failed at startup | The first npx download outlasted 10 seconds | `install codex` sets `startup_timeout_sec = 60`; add it by hand to an older entry |
531
535
  | `slipway check` warns about schema size | One tool's schema is large or repeats its definitions | Send the body schema once, or advertise a short one and validate the full one in the handler |
532
536
  | `slipway check` cannot load the app | The module starts the server when imported | Export the app from `app.ts` and call `app.main()` only in `index.ts` |
537
+ | A request piped to the server gets no answer | Stdin closed before the answer, and the MCP stdio binding stops a server when its input ends | Keep stdin open until you read the answer, as clients do, or run the command from the CLI |
533
538
  | `npx slipway` prints something unexpected | Slipway is not installed in this folder, so npx fetched an unrelated package called `slipway` | Run `npm install @thenavidm/slipway`, or `npx -p @thenavidm/slipway slipway <command>` |
534
539
 
535
540
  ## Environment variables
@@ -556,6 +561,14 @@ Every server reads these, under its own prefix: the app name in capitals, `NOTES
556
561
 
557
562
  See [CHANGELOG.md](CHANGELOG.md).
558
563
 
564
+ ## Servers built on Slipway
565
+
566
+ | Server | Package | Covers |
567
+ | --- | --- | --- |
568
+ | [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 |
569
+
570
+ 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.
571
+
559
572
  ## 15. FAQ ❓
560
573
 
561
574
  <details>
@@ -673,7 +686,7 @@ Return `content([image(bytes, "image/png")], data)`. `audio()`, `file()` and `re
673
686
  <details>
674
687
  <summary><b>Do I need an output schema?</b></summary>
675
688
 
676
- 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.
689
+ 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.
677
690
 
678
691
  </details>
679
692
 
package/SKILL.md CHANGED
@@ -22,7 +22,7 @@ Run `npm ls @thenavidm/slipway` in the repo. If it does not list a version, STOP
22
22
  |---|---|---|
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
- | `src/index.ts` | `await app.main()` | The only file that starts anything. Both binaries point at it |
25
+ | `src/index.ts` | `nodeModule.enableCompileCache?.()`, then `await import("./app.js")` and `app.main()` | The only file that starts anything. Both binaries point at it. The cache goes on before the app loads, so later launches skip compiling it |
26
26
  | `src/npx.ts` | `import "./index.js";` | What `npx -y <package>` runs. Its binary is named after the package |
27
27
 
28
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.
@@ -59,7 +59,7 @@ For a tool generated from an API contract, pass the operation's JSON Schema thro
59
59
 
60
60
  ## Results and errors
61
61
 
62
- - 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`.
63
63
  - Add `output` when the shape is stable, so results are validated and typed for clients.
64
64
  - Return `content([image(bytes, "image/png")], data)` for images, audio, files and links.
65
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)
@@ -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 })),
@@ -57,38 +76,40 @@ export function agentContext(app, env, bin, options = {}) {
57
76
  { env: names.auditLog, value: policy.auditLog ?? null, description: "file that records every attempted write" },
58
77
  { env: names.toolTimeoutMs, value: policy.toolTimeoutMs ?? null, description: "deadline for any tool" },
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" },
79
+ { env: `${app.envPrefix}_HTTP_PORT`, value: env[`${app.envPrefix}_HTTP_PORT`] ?? null, description: "port for --http, 8787 when unset" },
80
+ { 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" },
81
+ { env: `${app.envPrefix}_HTTP_TOKEN`, set: Boolean(env[`${app.envPrefix}_HTTP_TOKEN`]), secret: true, description: "bearer token --http requires" },
82
+ { env: `${app.envPrefix}_DEBUG`, value: /^(1|true|yes)$/i.test(env[`${app.envPrefix}_DEBUG`] ?? ""), description: "print debug lines on stderr" },
60
83
  ],
61
84
  ...(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
- }),
85
+ hidden_commands: hidden,
86
+ commands: tools.map((tool) => ({
87
+ command: tool.command,
88
+ tool: tool.name,
89
+ title: tool.title,
90
+ description: tool.description,
91
+ risk: tool.risk,
92
+ requires_confirm: tool.requireConfirm,
93
+ ...(tool.tags.length ? { toolsets: tool.tags } : {}),
94
+ ...(tool.positional.length ? { positional: tool.positional } : {}),
95
+ flags: flagsFor(tool.jsonSchema)
96
+ .filter((flag) => flag.key !== "confirm")
97
+ .map((flag) => ({
98
+ flag: flag.flag,
99
+ type: flag.kind,
100
+ required: flag.required,
101
+ ...(flag.repeatable ? { repeatable: true } : {}),
102
+ ...(flag.choices ? { choices: flag.choices } : {}),
103
+ ...(flag.default !== undefined ? { default: flag.default } : {}),
104
+ ...(flag.help ? { description: flag.help } : {}),
105
+ })),
106
+ ...(tool.output ? { output_schema: outputJsonSchema(tool.output) } : {}),
107
+ ...(tool.paginate ? { paginates: true } : {}),
108
+ ...(tool.job && !tool.statusOf ? { job: { status_command: `${tool.command}-status`, background: "background" in tool.job } } : {}),
109
+ ...(tool.statusOf ? { checks_jobs_of: tool.statusOf.replace(/_/g, "-") } : {}),
110
+ ...(tool.examples.length
111
+ ? { examples: tool.examples.map((example) => ({ description: example.description, command: exampleCommand(bin, tool, example.args) })) }
112
+ : {}),
113
+ })),
93
114
  };
94
115
  }
@@ -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,17 @@ 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
+ // The legend says `!` needs --confirm only when that holds for every listed command.
98
+ const confirmByRisk = tools.every((tool) => tool.requireConfirm === (tool.risk === "destructive"));
99
+ lines.push(``, ` * writes ! public or irreversible${confirmByRisk ? ", needs --confirm" : ""}`, ``, ` ${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
100
  return lines.join("\n");
60
101
  }
61
102
  function shellQuote(value) {
@@ -108,9 +149,10 @@ export function renderToolHelp(tool, bin) {
108
149
  ]
109
150
  .filter(Boolean)
110
151
  .join(" ");
111
- const lines = [``, `${tool.title}`, ``, tool.description, ``, `Usage:`, ` ${usage}`, ``];
112
- const describe = (list, heading) => {
113
- if (!list.length)
152
+ // The title is already on the command list; the description says more.
153
+ const lines = [``, tool.description, ``, `Usage:`, ` ${usage}`, ``];
154
+ const describe = (list, heading, after = []) => {
155
+ if (!list.length && !after.length)
114
156
  return;
115
157
  lines.push(`${heading}:`);
116
158
  for (const flag of list) {
@@ -119,13 +161,10 @@ export function renderToolHelp(tool, bin) {
119
161
  .join(" ");
120
162
  lines.push(...line(` ${flag.flag}${placeholder(flag)}`, [flag.help, extra].filter(Boolean).join(" ")));
121
163
  }
122
- lines.push(``);
164
+ lines.push(...after, ``);
123
165
  };
124
- describe(required, "Required");
166
+ describe(required, "Required", tool.requireConfirm ? line(" --confirm", "it runs only with this; --agent never adds it") : []);
125
167
  describe(optional, "Options");
126
- if (tool.requireConfirm) {
127
- lines.push(`Safety:`, ...line(" --confirm", "required: this runs only when you mean it"), ``);
128
- }
129
168
  if (tool.paginate) {
130
169
  lines.push(`Pages:`, ...line(" --all", "follow every page and print all items"), ...line(" --max-items <n>", "stop after this many items"), ``);
131
170
  }
@@ -139,68 +178,66 @@ export function renderToolHelp(tool, bin) {
139
178
  lines.push(` # ${example.description}`, ` ${exampleCommand(bin, tool, example.args)}`, ``);
140
179
  }
141
180
  lines.push(`Output:`);
142
- for (const [flag, help] of GLOBAL_FLAGS)
181
+ for (const [flag, help] of outputFlagsFor(tool))
143
182
  lines.push(...line(` ${flag}`, help));
144
183
  lines.push(``, `Risk: ${riskWords(tool)}`, ``);
145
184
  return lines.join("\n");
146
185
  }
147
186
  export function renderGeneralHelp(app, bin) {
148
187
  const names = policyEnvNames(app.envPrefix);
188
+ const cache = app.allTools.some((tool) => tool.cache);
189
+ const sync = app.allTools.some((tool) => tool.sync);
190
+ const jobs = app.allTools.some((tool) => tool.job);
191
+ // An agent often reads this first and pays for it again on every later step, so the
192
+ // rarely needed commands share one line and Slipway's own settings say only what they do.
193
+ const commands = [
194
+ [bin, "list the commands"],
195
+ [`${bin} <command> --help`, "what one takes, with examples"],
196
+ [`${bin} which <words>`, "find the command for a task"],
197
+ [`${bin} doctor [--network]`, "check the setup and say what is wrong"],
198
+ [`${bin} login`, "how to connect an account"],
199
+ [`${bin} install <client>`, "add the server to an MCP client; install --help lists them"],
200
+ ...(cache || sync ? [[`${bin} data`, "what is kept on this machine; data clear [<command>] deletes it"]] : []),
201
+ ...(sync
202
+ ? [
203
+ [`${bin} data sync <command>`, "copy every page of a list to this machine"],
204
+ [`${bin} data search <words>`, "search synced records offline (--in <command>)"],
205
+ [`${bin} data sql "<select>"`, "query local data with read-only SQL"],
206
+ ]
207
+ : []),
208
+ [app.bins.mcp, "the MCP server over stdio; --http [--port N] for HTTP"],
209
+ ];
210
+ const settings = [
211
+ ...(app.definition.settings ?? []).map((setting) => [setting.env, setting.description]),
212
+ [`${names.readOnly}=1`, "hide and refuse every write"],
213
+ [`${names.allowDestructive}=0`, "refuse the irreversible writes"],
214
+ [`${names.toolsets}=a,b`, "only these toolsets, or all"],
215
+ [`${names.surface}=search`, "MCP lists three finder tools instead"],
216
+ [`${names.auditLog}=<file>`, "log every attempted write"],
217
+ [`${names.toolTimeoutMs}=<ms>`, "deadline for any tool"],
218
+ [`${names.confirm}=model`, "confirm: true alone confirms over MCP"],
219
+ ...(cache ? [[`${names.cache}=0`, "never answer from the local cache"]] : []),
220
+ ...(cache || sync ? [[`${names.dataDir}=<dir>`, "keep local data in this folder"]] : []),
221
+ [`${app.envPrefix}_HTTP_PORT / _HOST / _TOKEN`, "for --http"],
222
+ [`${app.envPrefix}_DEBUG=1`, "debug lines on stderr"],
223
+ ];
224
+ // Flags that cannot apply here (jobs, the cache) are left out; agent-context lists every one.
225
+ const flags = GLOBAL_FLAGS.map(([flag]) => flag).filter((flag) => flag !== "--agent" && (flag !== "--wait" || jobs) && (flag !== "--refresh" || cache));
226
+ const width = Math.max(...[...commands, ...settings].map(([left]) => left.length)) + 3;
227
+ const row = ([left, help]) => ` ${left.padEnd(width)}${help}`;
149
228
  const lines = [
150
229
  ``,
151
- `${app.title} ${app.version}${app.description ? `: ${app.description}` : ""}`,
230
+ `${app.title} ${app.version}`,
152
231
  ``,
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`,
232
+ ...commands.map(row),
233
+ ` Also: schema <command>, agent-context [--brief] (all of this as JSON), completion <shell>.`,
158
234
  ``,
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"),
235
+ `Flags: ${flags.join(", ")}, and --agent: compact JSON, no prompts, never confirms a write.`,
169
236
  ``,
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
237
  `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") : []),
238
+ ...settings.map(row),
201
239
  ``,
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`,
240
+ `Exit codes: ${EXIT.ok} ok, ${EXIT.error} unexpected, ${EXIT.usage} usage or refused, ${EXIT.notFound} not found, ${EXIT.auth} auth, ${EXIT.api} API, ${EXIT.rateLimited} rate limited, ${EXIT.notConfigured} not configured`,
204
241
  ``,
205
242
  ];
206
243
  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/errors.js CHANGED
@@ -149,5 +149,10 @@ export function toSlipwayError(error) {
149
149
  return new AuthError(message, { cause: error });
150
150
  if (/\b404\b|not found|does not exist/.test(text))
151
151
  return new NotFoundError(message, { cause: error });
152
+ // A request that never got an answer is the service's failure, not a bug here: exit 5, which a script may retry.
153
+ const codes = [error?.code, error?.cause?.code];
154
+ const network = codes.some((code) => typeof code === "string" && /^(ECONN(REFUSED|RESET|ABORTED)|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|E(HOST|NET)UNREACH|EPIPE|UND_ERR_\w+)$/.test(code));
155
+ if (network || /fetch failed|could not reach|network error|socket hang up/.test(text))
156
+ return new ApiError(message, { cause: error });
152
157
  return new SlipwayError(message, "internal", EXIT.error, { cause: error });
153
158
  }
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.3",
3
+ "version": "0.1.5",
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",