@thenavidm/slipway 0.1.0

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +4 -0
  4. package/README.md +757 -0
  5. package/SECURITY.md +33 -0
  6. package/SKILL.md +136 -0
  7. package/dist/app.d.ts +210 -0
  8. package/dist/app.js +364 -0
  9. package/dist/bin.d.ts +14 -0
  10. package/dist/bin.js +178 -0
  11. package/dist/check.d.ts +52 -0
  12. package/dist/check.js +313 -0
  13. package/dist/cli/completion.d.ts +5 -0
  14. package/dist/cli/completion.js +72 -0
  15. package/dist/cli/context.d.ts +97 -0
  16. package/dist/cli/context.js +94 -0
  17. package/dist/cli/data.d.ts +17 -0
  18. package/dist/cli/data.js +120 -0
  19. package/dist/cli/flags.d.ts +35 -0
  20. package/dist/cli/flags.js +208 -0
  21. package/dist/cli/help.d.ts +15 -0
  22. package/dist/cli/help.js +213 -0
  23. package/dist/cli/install.d.ts +10 -0
  24. package/dist/cli/install.js +65 -0
  25. package/dist/cli/output.d.ts +22 -0
  26. package/dist/cli/output.js +159 -0
  27. package/dist/cli/run.d.ts +10 -0
  28. package/dist/cli/run.js +343 -0
  29. package/dist/confirm.d.ts +90 -0
  30. package/dist/confirm.js +166 -0
  31. package/dist/data.d.ts +109 -0
  32. package/dist/data.js +324 -0
  33. package/dist/docs.d.ts +13 -0
  34. package/dist/docs.js +66 -0
  35. package/dist/doctor.d.ts +12 -0
  36. package/dist/doctor.js +100 -0
  37. package/dist/entry.d.ts +11 -0
  38. package/dist/entry.js +34 -0
  39. package/dist/errors.d.ts +96 -0
  40. package/dist/errors.js +153 -0
  41. package/dist/guard.d.ts +46 -0
  42. package/dist/guard.js +89 -0
  43. package/dist/index.d.ts +27 -0
  44. package/dist/index.js +16 -0
  45. package/dist/install.d.ts +98 -0
  46. package/dist/install.js +325 -0
  47. package/dist/jobs.d.ts +143 -0
  48. package/dist/jobs.js +247 -0
  49. package/dist/openapi.d.ts +127 -0
  50. package/dist/openapi.js +549 -0
  51. package/dist/pages.d.ts +22 -0
  52. package/dist/pages.js +61 -0
  53. package/dist/policy.d.ts +66 -0
  54. package/dist/policy.js +74 -0
  55. package/dist/redact.d.ts +18 -0
  56. package/dist/redact.js +60 -0
  57. package/dist/result.d.ts +44 -0
  58. package/dist/result.js +74 -0
  59. package/dist/rpc.d.ts +69 -0
  60. package/dist/rpc.js +120 -0
  61. package/dist/schema.d.ts +99 -0
  62. package/dist/schema.js +192 -0
  63. package/dist/search.d.ts +18 -0
  64. package/dist/search.js +119 -0
  65. package/dist/serve.d.ts +30 -0
  66. package/dist/serve.js +149 -0
  67. package/dist/server.d.ts +20 -0
  68. package/dist/server.js +244 -0
  69. package/dist/sync.d.ts +35 -0
  70. package/dist/sync.js +119 -0
  71. package/dist/testing.d.ts +35 -0
  72. package/dist/testing.js +41 -0
  73. package/dist/tool.d.ts +194 -0
  74. package/dist/tool.js +151 -0
  75. package/dist/util.d.ts +12 -0
  76. package/dist/util.js +35 -0
  77. package/package.json +89 -0
package/dist/schema.js ADDED
@@ -0,0 +1,192 @@
1
+ /**
2
+ * One schema interface for every tool, whatever wrote it.
3
+ *
4
+ * A hand-written tool uses Zod. A tool generated from an API contract uses the
5
+ * contract's JSON Schema. Both become a Standard Schema object here, which is
6
+ * what the MCP SDK validates and advertises, and what the CLI derives its
7
+ * flags from. Because both surfaces read the same object, an argument one
8
+ * accepts the other accepts too.
9
+ */
10
+ import { fromJsonSchema } from "@modelcontextprotocol/server";
11
+ const TARGET = { target: "draft-2020-12" };
12
+ /**
13
+ * Wrap a raw JSON Schema so it validates and converts like a Zod schema.
14
+ *
15
+ * This is how a tool generated from an OpenAPI document or a pinned contract
16
+ * joins the same tool list as hand-written ones.
17
+ */
18
+ export function jsonSchema(schema) {
19
+ return fromJsonSchema(schema);
20
+ }
21
+ /** The input of a tool that takes nothing. */
22
+ export function emptyInput() {
23
+ return jsonSchema({ type: "object", properties: {}, additionalProperties: false });
24
+ }
25
+ export function isSchema(value) {
26
+ const std = value?.["~standard"];
27
+ return typeof std?.validate === "function" && typeof std.jsonSchema?.input === "function";
28
+ }
29
+ /** The JSON Schema an MCP client receives for this input. */
30
+ export function inputJsonSchema(schema) {
31
+ return schema["~standard"].jsonSchema.input(TARGET);
32
+ }
33
+ export function outputJsonSchema(schema) {
34
+ return schema["~standard"].jsonSchema.output(TARGET);
35
+ }
36
+ /** Validate with the schema's own validator, sync or async, and flatten the issues. */
37
+ export async function validate(schema, value) {
38
+ const result = await schema["~standard"].validate(value);
39
+ if (result.issues) {
40
+ return {
41
+ ok: false,
42
+ issues: result.issues.map((issue) => ({
43
+ path: (issue.path ?? [])
44
+ .map((part) => (typeof part === "object" && part !== null && "key" in part ? String(part.key) : String(part)))
45
+ .join("."),
46
+ message: plainMessage(issue.message),
47
+ })),
48
+ };
49
+ }
50
+ return { ok: true, value: result.value };
51
+ }
52
+ /**
53
+ * A JSON Schema validator names fields as `data/a/b`. A person typing flags,
54
+ * and a model reading the error, both know the field as `a.b`.
55
+ */
56
+ function plainMessage(message) {
57
+ return message
58
+ .replace(/\bdata\/([A-Za-z0-9_.\-/]+)/g, (_match, path) => path.replace(/\//g, "."))
59
+ .replace(/\bdata (must|should)\b/g, "input $1");
60
+ }
61
+ export function formatIssues(issues) {
62
+ return issues.map((issue) => (issue.path ? `${issue.path}: ${issue.message}` : issue.message)).join("; ");
63
+ }
64
+ /** The names Slipway adds, which a tool's own input may not use. */
65
+ export const CONTROL_NAMES = ["confirm", "wait_seconds"];
66
+ /**
67
+ * Short on purpose: it is repeated in every confirmed tool a client lists.
68
+ * What the effect is lives in the tool's own description and annotations.
69
+ */
70
+ export const CONFIRM_DESCRIPTION = "Set true only when the user asked for exactly this action.";
71
+ /** The schema already carries the range and the default, so the words only say what the number is for. */
72
+ export const WAIT_DESCRIPTION = "Seconds to wait for the job to finish before returning it to check later.";
73
+ /**
74
+ * A schema as clients receive it, without the `$schema` line that names its
75
+ * dialect. A client reads JSON Schema 2020-12 when no dialect is named, so
76
+ * the line only adds bytes to every tool in every listing.
77
+ */
78
+ export function advertised(schema) {
79
+ const std = schema["~standard"];
80
+ const plain = (json) => {
81
+ if (!("$schema" in json))
82
+ return json;
83
+ const { $schema: _dialect, ...rest } = json;
84
+ return rest;
85
+ };
86
+ return {
87
+ "~standard": {
88
+ version: std.version,
89
+ vendor: std.vendor,
90
+ validate: (value) => std.validate(value),
91
+ ...(std.types ? { types: std.types } : {}),
92
+ jsonSchema: {
93
+ input: (options) => plain(std.jsonSchema.input(options)),
94
+ output: (options) => plain(std.jsonSchema.output(options)),
95
+ },
96
+ },
97
+ };
98
+ }
99
+ /**
100
+ * Add Slipway's own arguments to any schema: `confirm` for a tool that needs
101
+ * confirming, `wait_seconds` for a job.
102
+ *
103
+ * Validation takes them out before the author's schema sees the rest and puts
104
+ * them back afterwards, so a strict contract schema with
105
+ * `additionalProperties: false` still accepts them, and the author never
106
+ * declares them by hand.
107
+ */
108
+ export function withControls(schema, controls) {
109
+ if (!controls.confirm && !controls.wait)
110
+ return schema;
111
+ const std = schema["~standard"];
112
+ const wait = controls.wait;
113
+ return {
114
+ "~standard": {
115
+ version: 1,
116
+ vendor: "slipway",
117
+ validate: (value) => {
118
+ const record = value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
119
+ if (!record)
120
+ return std.validate(value);
121
+ const { confirm, wait_seconds: waitSeconds, ...rest } = record;
122
+ const issues = [];
123
+ if (controls.confirm && confirm !== undefined && typeof confirm !== "boolean")
124
+ issues.push({ message: "Expected true or false", path: ["confirm"] });
125
+ if (wait && waitSeconds !== undefined && (typeof waitSeconds !== "number" || !Number.isInteger(waitSeconds) || waitSeconds < 0 || waitSeconds > wait.maxSeconds)) {
126
+ issues.push({ message: `Expected a whole number of seconds from 0 to ${wait.maxSeconds}`, path: ["wait_seconds"] });
127
+ }
128
+ if (issues.length)
129
+ return { issues };
130
+ // A control this tool does not take stays in the input, for the author's schema to judge.
131
+ const passOn = { ...rest, ...(!controls.confirm && confirm !== undefined ? { confirm } : {}), ...(!wait && waitSeconds !== undefined ? { wait_seconds: waitSeconds } : {}) };
132
+ const finish = (result) => result.issues
133
+ ? result
134
+ : {
135
+ value: {
136
+ ...result.value,
137
+ ...(controls.confirm && confirm !== undefined ? { confirm } : {}),
138
+ ...(wait && waitSeconds !== undefined ? { wait_seconds: waitSeconds } : {}),
139
+ },
140
+ };
141
+ const result = std.validate(passOn);
142
+ return (result instanceof Promise ? result.then(finish) : finish(result));
143
+ },
144
+ jsonSchema: {
145
+ input: (options) => {
146
+ const base = std.jsonSchema.input(options);
147
+ const properties = { ...(base.properties ?? {}) };
148
+ if (controls.confirm)
149
+ properties.confirm = { type: "boolean", description: CONFIRM_DESCRIPTION };
150
+ if (wait) {
151
+ properties.wait_seconds = { type: "integer", minimum: 0, maximum: wait.maxSeconds, default: wait.defaultSeconds, description: WAIT_DESCRIPTION };
152
+ }
153
+ return { ...base, properties };
154
+ },
155
+ output: (options) => std.jsonSchema.output(options),
156
+ },
157
+ },
158
+ };
159
+ }
160
+ /** Serialized size of a schema, the number that decides what a client pays to load the tool. */
161
+ export function schemaBytes(schema) {
162
+ return Buffer.byteLength(JSON.stringify(schema));
163
+ }
164
+ /**
165
+ * Find `$defs` blocks that repeat inside one schema.
166
+ *
167
+ * A generator that inlines a referenced body and also keeps it under `$defs`
168
+ * ships the same definitions twice; a schema of a few hundred kilobytes is
169
+ * often half repetition.
170
+ */
171
+ export function repeatedDefinitions(schema) {
172
+ const seen = new Map();
173
+ const visit = (node) => {
174
+ if (node === null || typeof node !== "object")
175
+ return;
176
+ if (Array.isArray(node))
177
+ return node.forEach(visit);
178
+ const record = node;
179
+ for (const key of ["$defs", "definitions"]) {
180
+ const defs = record[key];
181
+ if (defs && typeof defs === "object") {
182
+ for (const [name, value] of Object.entries(defs)) {
183
+ const fingerprint = `${name}:${JSON.stringify(value).length}`;
184
+ seen.set(fingerprint, (seen.get(fingerprint) ?? 0) + 1);
185
+ }
186
+ }
187
+ }
188
+ Object.values(record).forEach(visit);
189
+ };
190
+ visit(schema);
191
+ return [...seen.entries()].filter(([, count]) => count > 1).map(([fingerprint]) => fingerprint.split(":")[0]);
192
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Finding the tool for a task by what it does, not by its name.
3
+ *
4
+ * A server with a hundred tools is only usable when the right one can be found
5
+ * from the words someone would use to ask for it. The same ranking answers
6
+ * `which` in a terminal and `search_tools` for a model.
7
+ */
8
+ import type { Tool } from "./tool.js";
9
+ export type Match = {
10
+ tool: Tool;
11
+ score: number;
12
+ };
13
+ export declare function searchTools(tools: readonly Tool[], query: string, limit?: number): Match[];
14
+ /** Edit distance, for "did you mean" on a mistyped command. */
15
+ export declare function distance(a: string, b: string): number;
16
+ export declare function didYouMean(input: string, candidates: readonly string[]): string | undefined;
17
+ /** The first sentence of a description, for one-line listings. */
18
+ export declare function firstSentence(text: string, max?: number): string;
package/dist/search.js ADDED
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Finding the tool for a task by what it does, not by its name.
3
+ *
4
+ * A server with a hundred tools is only usable when the right one can be found
5
+ * from the words someone would use to ask for it. The same ranking answers
6
+ * `which` in a terminal and `search_tools` for a model.
7
+ */
8
+ function words(text) {
9
+ return text
10
+ .toLowerCase()
11
+ .split(/[^a-z0-9]+/)
12
+ .filter((word) => word.length > 1)
13
+ .map(stem);
14
+ }
15
+ /** Close enough for matching "posts" to "post" and "scheduling" to "schedul". */
16
+ function stem(word) {
17
+ if (word.length > 4 && word.endsWith("ies"))
18
+ return `${word.slice(0, -3)}y`;
19
+ if (word.length > 5 && word.endsWith("ing"))
20
+ return word.slice(0, -3);
21
+ if (word.length > 4 && word.endsWith("es"))
22
+ return word.slice(0, -2);
23
+ if (word.length > 3 && word.endsWith("s"))
24
+ return word.slice(0, -1);
25
+ return word;
26
+ }
27
+ /**
28
+ * Words people use for the same action. Tool names settle on one verb, and the
29
+ * person searching rarely picks the same one: they "remove" what the API calls
30
+ * "delete".
31
+ */
32
+ const SYNONYMS = [
33
+ ["delete", "remove", "erase", "trash", "destroy", "drop", "unpublish"],
34
+ ["create", "add", "new", "make", "insert", "write"],
35
+ ["update", "edit", "change", "modify", "rename", "set"],
36
+ ["list", "show", "browse", "all", "index", "enumerate"],
37
+ ["get", "read", "fetch", "view", "lookup", "retrieve", "open"],
38
+ ["search", "find", "query", "lookup", "filter"],
39
+ ["send", "email", "message", "notify", "deliver"],
40
+ ["post", "publish", "share", "tweet", "status"],
41
+ ["schedule", "queue", "plan", "later"],
42
+ ["upload", "attach", "import"],
43
+ ["download", "export", "save", "backup"],
44
+ ["stat", "analytic", "metric", "insight", "report", "count"],
45
+ ["user", "member", "account", "person", "contact", "customer", "student", "subscriber"],
46
+ ].map((group) => group.map(stem));
47
+ const RELATED = new Map();
48
+ for (const group of SYNONYMS)
49
+ for (const word of group)
50
+ RELATED.set(word, [...new Set([...(RELATED.get(word) ?? []), ...group])]);
51
+ function hits(query, haystack) {
52
+ const direct = exact(query, haystack);
53
+ if (direct > 0)
54
+ return direct;
55
+ // A synonym counts for a little less than the word itself, so an exact match still wins.
56
+ return (RELATED.get(query) ?? []).some((word) => word !== query && exact(word, haystack) === 1) ? 0.75 : 0;
57
+ }
58
+ function exact(query, haystack) {
59
+ if (haystack.includes(query))
60
+ return 1;
61
+ return haystack.some((word) => word.startsWith(query) || query.startsWith(word)) ? 0.5 : 0;
62
+ }
63
+ export function searchTools(tools, query, limit = 10) {
64
+ const terms = [...new Set(words(query))];
65
+ if (terms.length === 0)
66
+ return [];
67
+ const phrase = query.trim().toLowerCase();
68
+ const scored = tools.map((tool) => {
69
+ const name = words(tool.name);
70
+ const title = words(tool.title);
71
+ const tags = tool.tags.flatMap(words);
72
+ const description = words(tool.description);
73
+ let score = 0;
74
+ let matched = 0;
75
+ for (const term of terms) {
76
+ const got = hits(term, name) * 5 + hits(term, title) * 4 + hits(term, tags) * 3 + hits(term, description);
77
+ if (got > 0)
78
+ matched++;
79
+ score += got;
80
+ }
81
+ // A tool that matches every word beats one that matches one word strongly.
82
+ score *= matched / terms.length;
83
+ if (phrase.length > 3 && (tool.title.toLowerCase().includes(phrase) || tool.description.toLowerCase().includes(phrase)))
84
+ score += 3;
85
+ return { tool, score };
86
+ });
87
+ return scored
88
+ .filter((match) => match.score > 0)
89
+ .sort((a, b) => b.score - a.score || a.tool.name.localeCompare(b.tool.name))
90
+ .slice(0, limit);
91
+ }
92
+ /** Edit distance, for "did you mean" on a mistyped command. */
93
+ export function distance(a, b) {
94
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
95
+ for (let j = 1; j <= b.length; j++)
96
+ rows[0][j] = j;
97
+ for (let i = 1; i <= a.length; i++) {
98
+ for (let j = 1; j <= b.length; j++) {
99
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
100
+ rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
101
+ }
102
+ }
103
+ return rows[a.length][b.length];
104
+ }
105
+ export function didYouMean(input, candidates) {
106
+ const target = input.toLowerCase();
107
+ let best;
108
+ for (const candidate of candidates) {
109
+ const score = candidate.startsWith(target) ? 0 : distance(target, candidate);
110
+ if (score <= Math.max(2, Math.floor(target.length / 4)) && (!best || score < best.score))
111
+ best = { candidate, score };
112
+ }
113
+ return best?.candidate;
114
+ }
115
+ /** The first sentence of a description, for one-line listings. */
116
+ export function firstSentence(text, max = 100) {
117
+ const first = text.trim().split(/(?<=[.!?])\s/)[0] ?? "";
118
+ return first.length > max ? `${first.slice(0, max - 1).trimEnd()}…` : first;
119
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Serving an app over stdio, which every local client launches, or HTTP.
3
+ */
4
+ import { type App } from "./app.js";
5
+ /**
6
+ * Serve over stdio.
7
+ *
8
+ * The setup check runs after the server is already answering. A client gives a
9
+ * server a few seconds to start, and a server that waits on a slow check, or
10
+ * exits because nothing is configured yet, shows up as broken instead of as a
11
+ * set of tools that explain what to configure.
12
+ */
13
+ export declare function serveStdioApp(app: App, env: NodeJS.ProcessEnv): Promise<void>;
14
+ export type HttpOptions = {
15
+ host: string;
16
+ port: number;
17
+ token?: string;
18
+ };
19
+ export declare function httpOptions(app: App, env: NodeJS.ProcessEnv, argv: string[]): HttpOptions;
20
+ /**
21
+ * Serve over Streamable HTTP, for a machine that is always on.
22
+ *
23
+ * Bound to 127.0.0.1 by default. The server acts with whatever account it was
24
+ * given, so it refuses to listen anywhere else without a bearer token rather
25
+ * than trusting that nobody will find the port.
26
+ */
27
+ export declare function serveHttpApp(app: App, env: NodeJS.ProcessEnv, options: HttpOptions): Promise<{
28
+ close: () => Promise<void>;
29
+ url: string;
30
+ }>;
package/dist/serve.js ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Serving an app over stdio, which every local client launches, or HTTP.
3
+ */
4
+ import { createServer } from "node:http";
5
+ import { Readable } from "node:stream";
6
+ import { createMcpHandler } from "@modelcontextprotocol/server";
7
+ import { serveStdio } from "@modelcontextprotocol/server/stdio";
8
+ import { stderrLogger } from "./app.js";
9
+ import { UsageError } from "./errors.js";
10
+ /**
11
+ * Serve over stdio.
12
+ *
13
+ * The setup check runs after the server is already answering. A client gives a
14
+ * server a few seconds to start, and a server that waits on a slow check, or
15
+ * exits because nothing is configured yet, shows up as broken instead of as a
16
+ * set of tools that explain what to configure.
17
+ */
18
+ export async function serveStdioApp(app, env) {
19
+ const log = stderrLogger(app.envPrefix, env);
20
+ const handle = serveStdio(() => app.createServer(env));
21
+ let closing = false;
22
+ const shutdown = () => {
23
+ if (closing)
24
+ return;
25
+ closing = true;
26
+ void handle.close().finally(() => process.exit(0));
27
+ };
28
+ process.on("SIGINT", shutdown);
29
+ process.on("SIGTERM", shutdown);
30
+ void warnIfUnconfigured(app, env, log.warn);
31
+ }
32
+ async function warnIfUnconfigured(app, env, warn) {
33
+ try {
34
+ const ctx = await app.context(env);
35
+ if (app.definition.configured && !(await app.definition.configured(ctx))) {
36
+ warn(`Nothing is configured yet. Tools that need an account will say what is missing. Run \`${app.bins.cli} doctor\`.`);
37
+ }
38
+ }
39
+ catch (error) {
40
+ warn(`Setup is incomplete: ${error.message} Run \`${app.bins.cli} doctor\`.`);
41
+ }
42
+ }
43
+ export function httpOptions(app, env, argv) {
44
+ const at = argv.findIndex((token) => token === "--port" || token.startsWith("--port="));
45
+ const raw = at === -1 ? env[`${app.envPrefix}_HTTP_PORT`] : argv[at].includes("=") ? argv[at].split("=")[1] : argv[at + 1];
46
+ const port = Number(raw ?? 8787);
47
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
48
+ throw new UsageError(`--port expects a port number, got '${raw}'.`);
49
+ return {
50
+ host: env[`${app.envPrefix}_HTTP_HOST`]?.trim() || "127.0.0.1",
51
+ port,
52
+ token: env[`${app.envPrefix}_HTTP_TOKEN`]?.trim() || undefined,
53
+ };
54
+ }
55
+ const LOOPBACK = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
56
+ /**
57
+ * Serve over Streamable HTTP, for a machine that is always on.
58
+ *
59
+ * Bound to 127.0.0.1 by default. The server acts with whatever account it was
60
+ * given, so it refuses to listen anywhere else without a bearer token rather
61
+ * than trusting that nobody will find the port.
62
+ */
63
+ export async function serveHttpApp(app, env, options) {
64
+ const loopback = LOOPBACK.has(options.host);
65
+ if (!loopback && !options.token) {
66
+ throw new UsageError(`Refusing to listen on ${options.host} without ${app.envPrefix}_HTTP_TOKEN. Anyone who can reach the port would act as your account.`);
67
+ }
68
+ const log = stderrLogger(app.envPrefix, env);
69
+ const handler = createMcpHandler(() => app.createServer(env));
70
+ const server = createServer((req, res) => {
71
+ void handle(app, handler.fetch, options, loopback, req, res).catch((error) => {
72
+ if (!res.headersSent)
73
+ res.writeHead(500, { "content-type": "application/json" });
74
+ res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: error?.message ?? "internal error" }, id: null }));
75
+ });
76
+ });
77
+ await new Promise((resolve, reject) => {
78
+ server.once("error", reject);
79
+ server.listen(options.port, options.host, () => resolve());
80
+ });
81
+ // Port 0 asks the system for a free port, so the real one is read back after listening.
82
+ const address = server.address();
83
+ const port = address && typeof address === "object" ? address.port : options.port;
84
+ const url = `http://${options.host.includes(":") && !options.host.startsWith("[") ? `[${options.host}]` : options.host}:${port}/mcp`;
85
+ log.info(`listening on ${url}${options.token ? " (bearer token required)" : ""}`);
86
+ return {
87
+ url,
88
+ close: async () => {
89
+ await handler.close();
90
+ await new Promise((resolve) => server.close(() => resolve()));
91
+ },
92
+ };
93
+ }
94
+ async function handle(app, fetchMcp, options, loopback, req, res) {
95
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
96
+ // A page in a browser can make requests to localhost. Checking the Host header
97
+ // stops a site that resolves its own name to 127.0.0.1 from reaching this server.
98
+ if (loopback && !LOOPBACK.has(url.hostname)) {
99
+ res.writeHead(403, { "content-type": "application/json" }).end(JSON.stringify({ error: "forbidden host" }));
100
+ return;
101
+ }
102
+ if (url.pathname === "/health") {
103
+ res.writeHead(200, { "content-type": "application/json" });
104
+ res.end(JSON.stringify({ ok: true, name: app.name, version: app.version, tools: app.tools().length }));
105
+ return;
106
+ }
107
+ if (url.pathname !== "/mcp") {
108
+ res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "not found" }));
109
+ return;
110
+ }
111
+ if (options.token && req.headers.authorization !== `Bearer ${options.token}`) {
112
+ res.writeHead(401, { "content-type": "application/json", "www-authenticate": "Bearer" }).end(JSON.stringify({ error: "unauthorized" }));
113
+ return;
114
+ }
115
+ const controller = new AbortController();
116
+ res.on("close", () => controller.abort());
117
+ const headers = new Headers();
118
+ for (const [key, value] of Object.entries(req.headers)) {
119
+ if (Array.isArray(value))
120
+ for (const item of value)
121
+ headers.append(key, item);
122
+ else if (value !== undefined)
123
+ headers.set(key, value);
124
+ }
125
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
126
+ const request = new Request(url, {
127
+ method: req.method,
128
+ headers,
129
+ body: hasBody ? Readable.toWeb(req) : undefined,
130
+ signal: controller.signal,
131
+ duplex: "half",
132
+ });
133
+ const response = await fetchMcp(request);
134
+ const out = {};
135
+ response.headers.forEach((value, key) => {
136
+ out[key] = value;
137
+ });
138
+ res.writeHead(response.status, out);
139
+ if (response.body) {
140
+ const reader = response.body.getReader();
141
+ for (;;) {
142
+ const { done, value } = await reader.read();
143
+ if (done)
144
+ break;
145
+ res.write(value);
146
+ }
147
+ }
148
+ res.end();
149
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The MCP surface.
3
+ *
4
+ * Every tool is registered from its definition with the annotations its risk
5
+ * implies, so a client that auto-approves reads or prompts on writes gets an
6
+ * honest answer from every tool, not only the ones someone remembered to mark.
7
+ */
8
+ import { McpServer, type ToolAnnotations } from "@modelcontextprotocol/server";
9
+ import type { App } from "./app.js";
10
+ import type { Policy } from "./policy.js";
11
+ import type { Tool } from "./tool.js";
12
+ /** Claude Code shows a person a permission prompt on every call to a tool carrying this, in any mode. */
13
+ export declare const REQUIRES_USER_INTERACTION = "anthropic/requiresUserInteraction";
14
+ /** Claude Code raises its result-size limit for a tool carrying this. */
15
+ export declare const MAX_RESULT_SIZE_CHARS = "anthropic/maxResultSizeChars";
16
+ export declare function annotationsFor(tool: Tool): ToolAnnotations;
17
+ export declare function metaFor(tool: Tool, policy: Policy): Record<string, unknown> | undefined;
18
+ /** The `_meta` key that marks a result served from the local cache, with its age. */
19
+ export declare const CACHE_META = "slipway/cache";
20
+ export declare function buildServer<Ctx>(app: App<Ctx>, env: NodeJS.ProcessEnv): McpServer;