@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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +202 -0
- package/NOTICE +4 -0
- package/README.md +757 -0
- package/SECURITY.md +33 -0
- package/SKILL.md +136 -0
- package/dist/app.d.ts +210 -0
- package/dist/app.js +364 -0
- package/dist/bin.d.ts +14 -0
- package/dist/bin.js +178 -0
- package/dist/check.d.ts +52 -0
- package/dist/check.js +313 -0
- package/dist/cli/completion.d.ts +5 -0
- package/dist/cli/completion.js +72 -0
- package/dist/cli/context.d.ts +97 -0
- package/dist/cli/context.js +94 -0
- package/dist/cli/data.d.ts +17 -0
- package/dist/cli/data.js +120 -0
- package/dist/cli/flags.d.ts +35 -0
- package/dist/cli/flags.js +208 -0
- package/dist/cli/help.d.ts +15 -0
- package/dist/cli/help.js +213 -0
- package/dist/cli/install.d.ts +10 -0
- package/dist/cli/install.js +65 -0
- package/dist/cli/output.d.ts +22 -0
- package/dist/cli/output.js +159 -0
- package/dist/cli/run.d.ts +10 -0
- package/dist/cli/run.js +343 -0
- package/dist/confirm.d.ts +90 -0
- package/dist/confirm.js +166 -0
- package/dist/data.d.ts +109 -0
- package/dist/data.js +324 -0
- package/dist/docs.d.ts +13 -0
- package/dist/docs.js +66 -0
- package/dist/doctor.d.ts +12 -0
- package/dist/doctor.js +100 -0
- package/dist/entry.d.ts +11 -0
- package/dist/entry.js +34 -0
- package/dist/errors.d.ts +96 -0
- package/dist/errors.js +153 -0
- package/dist/guard.d.ts +46 -0
- package/dist/guard.js +89 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.js +16 -0
- package/dist/install.d.ts +98 -0
- package/dist/install.js +325 -0
- package/dist/jobs.d.ts +143 -0
- package/dist/jobs.js +247 -0
- package/dist/openapi.d.ts +127 -0
- package/dist/openapi.js +549 -0
- package/dist/pages.d.ts +22 -0
- package/dist/pages.js +61 -0
- package/dist/policy.d.ts +66 -0
- package/dist/policy.js +74 -0
- package/dist/redact.d.ts +18 -0
- package/dist/redact.js +60 -0
- package/dist/result.d.ts +44 -0
- package/dist/result.js +74 -0
- package/dist/rpc.d.ts +69 -0
- package/dist/rpc.js +120 -0
- package/dist/schema.d.ts +99 -0
- package/dist/schema.js +192 -0
- package/dist/search.d.ts +18 -0
- package/dist/search.js +119 -0
- package/dist/serve.d.ts +30 -0
- package/dist/serve.js +149 -0
- package/dist/server.d.ts +20 -0
- package/dist/server.js +244 -0
- package/dist/sync.d.ts +35 -0
- package/dist/sync.js +119 -0
- package/dist/testing.d.ts +35 -0
- package/dist/testing.js +41 -0
- package/dist/tool.d.ts +194 -0
- package/dist/tool.js +151 -0
- package/dist/util.d.ts +12 -0
- package/dist/util.js +35 -0
- package/package.json +89 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
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 } from "@modelcontextprotocol/server";
|
|
9
|
+
import * as z from "zod";
|
|
10
|
+
import { clientView, confirmRoute, personApproval, verifyApprovalState } from "./confirm.js";
|
|
11
|
+
import { toSlipwayError, UsageError } from "./errors.js";
|
|
12
|
+
import { errorResult, isPlainObject, toCallToolResult } from "./result.js";
|
|
13
|
+
import { outputJsonSchema } from "./schema.js";
|
|
14
|
+
import { firstSentence, searchTools } from "./search.js";
|
|
15
|
+
/** Claude Code shows a person a permission prompt on every call to a tool carrying this, in any mode. */
|
|
16
|
+
export const REQUIRES_USER_INTERACTION = "anthropic/requiresUserInteraction";
|
|
17
|
+
/** Claude Code raises its result-size limit for a tool carrying this. */
|
|
18
|
+
export const MAX_RESULT_SIZE_CHARS = "anthropic/maxResultSizeChars";
|
|
19
|
+
export function annotationsFor(tool) {
|
|
20
|
+
return {
|
|
21
|
+
title: tool.title,
|
|
22
|
+
readOnlyHint: tool.risk === "read",
|
|
23
|
+
destructiveHint: tool.risk === "destructive",
|
|
24
|
+
idempotentHint: tool.idempotent,
|
|
25
|
+
openWorldHint: tool.openWorld,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function metaFor(tool, policy) {
|
|
29
|
+
const meta = { ...(tool.meta ?? {}) };
|
|
30
|
+
if (tool.requireConfirm && policy.confirm === "human")
|
|
31
|
+
meta[REQUIRES_USER_INTERACTION] = true;
|
|
32
|
+
if (tool.maxResultChars)
|
|
33
|
+
meta[MAX_RESULT_SIZE_CHARS] = tool.maxResultChars;
|
|
34
|
+
return Object.keys(meta).length > 0 ? meta : undefined;
|
|
35
|
+
}
|
|
36
|
+
/** The `_meta` key that marks a result served from the local cache, with its age. */
|
|
37
|
+
export const CACHE_META = "slipway/cache";
|
|
38
|
+
function withCacheNote(result, cached) {
|
|
39
|
+
return cached ? { ...result, _meta: { ...(result._meta ?? {}), [CACHE_META]: { age_seconds: cached.ageSeconds } } } : result;
|
|
40
|
+
}
|
|
41
|
+
/** Progress goes to a client only when its request asked for it with a progress token. */
|
|
42
|
+
function progressFor(ctx) {
|
|
43
|
+
const token = ctx.mcpReq._meta?.progressToken;
|
|
44
|
+
if (token === undefined)
|
|
45
|
+
return undefined;
|
|
46
|
+
return async ({ progress, total, message }) => {
|
|
47
|
+
await ctx.mcpReq.notify({
|
|
48
|
+
method: "notifications/progress",
|
|
49
|
+
params: { progressToken: token, progress, ...(total === undefined ? {} : { total }), ...(message ? { message } : {}) },
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
export function buildServer(app, env) {
|
|
54
|
+
const policy = app.policy(env);
|
|
55
|
+
const server = new McpServer({
|
|
56
|
+
name: app.name,
|
|
57
|
+
title: app.title,
|
|
58
|
+
version: app.version,
|
|
59
|
+
...(app.definition.icons ? { icons: app.definition.icons } : {}),
|
|
60
|
+
}, {
|
|
61
|
+
...(app.instructions ? { instructions: app.instructions } : {}),
|
|
62
|
+
// Approval forms carry signed state; anything Slipway did not sign is refused before a handler sees it.
|
|
63
|
+
requestState: { verify: verifyApprovalState },
|
|
64
|
+
});
|
|
65
|
+
if (policy.surface === "search")
|
|
66
|
+
registerSearchSurface(server, app, env, policy);
|
|
67
|
+
else
|
|
68
|
+
for (const tool of app.tools(env))
|
|
69
|
+
registerTool(server, app, tool, env, policy);
|
|
70
|
+
for (const resource of app.definition.resources ?? [])
|
|
71
|
+
registerResource(server, app, resource, env);
|
|
72
|
+
for (const prompt of app.definition.prompts ?? [])
|
|
73
|
+
registerPrompt(server, app, prompt, env);
|
|
74
|
+
return server;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Confirm a call the way this client allows, before it runs.
|
|
78
|
+
*
|
|
79
|
+
* Returns the approval form to send while a person has not answered, or the
|
|
80
|
+
* options `run` needs once the call may go ahead. `listedForPerson` is true
|
|
81
|
+
* when the tool's own `tools/list` entry asked the client for a person, which
|
|
82
|
+
* is what makes Claude Code prompt for it.
|
|
83
|
+
*/
|
|
84
|
+
async function confirmFirst(server, app, tool, args, ctx, env, policy, listedForPerson) {
|
|
85
|
+
if (!tool.requireConfirm)
|
|
86
|
+
return {};
|
|
87
|
+
const route = confirmRoute(policy.confirm, clientView(server, ctx), listedForPerson);
|
|
88
|
+
if (route === "client")
|
|
89
|
+
return { approvedBy: "client" };
|
|
90
|
+
if (route === "flag")
|
|
91
|
+
return {};
|
|
92
|
+
const ask = await personApproval(app, tool, args, ctx, env);
|
|
93
|
+
return ask ? { ask } : { approvedBy: "person" };
|
|
94
|
+
}
|
|
95
|
+
function registerTool(server, app, tool, env, policy) {
|
|
96
|
+
const meta = metaFor(tool, policy);
|
|
97
|
+
server.registerTool.bind(server)(tool.name, {
|
|
98
|
+
title: tool.title,
|
|
99
|
+
description: tool.description,
|
|
100
|
+
inputSchema: tool.schema,
|
|
101
|
+
...(tool.output ? { outputSchema: tool.output } : {}),
|
|
102
|
+
annotations: annotationsFor(tool),
|
|
103
|
+
...(tool.icons ? { icons: tool.icons } : {}),
|
|
104
|
+
...(meta ? { _meta: meta } : {}),
|
|
105
|
+
}, async (args, ctx) => {
|
|
106
|
+
try {
|
|
107
|
+
const raw = (args ?? {});
|
|
108
|
+
const confirmed = await confirmFirst(server, app, tool, raw, ctx, env, policy, meta?.[REQUIRES_USER_INTERACTION] === true);
|
|
109
|
+
if ("ask" in confirmed)
|
|
110
|
+
return confirmed.ask;
|
|
111
|
+
let cached;
|
|
112
|
+
const value = await app.run(tool, raw, {
|
|
113
|
+
surface: "mcp",
|
|
114
|
+
approvedBy: confirmed.approvedBy,
|
|
115
|
+
signal: ctx.mcpReq.signal,
|
|
116
|
+
env,
|
|
117
|
+
onProgress: progressFor(ctx),
|
|
118
|
+
onCache: (hit) => (cached = hit),
|
|
119
|
+
});
|
|
120
|
+
return withCacheNote(toCallToolResult(tool, value, app.secrets), cached);
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return errorResult(toSlipwayError(error), app.secrets);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Three tools that stand in for a large catalog.
|
|
129
|
+
*
|
|
130
|
+
* A client that loads every tool definition up front pays for all of them on
|
|
131
|
+
* every message. With `<PREFIX>_SURFACE=search` the model finds a tool by what
|
|
132
|
+
* it does, reads that one schema, and calls it through the same guard.
|
|
133
|
+
*/
|
|
134
|
+
function registerSearchSurface(server, app, env, policy) {
|
|
135
|
+
const register = server.registerTool.bind(server);
|
|
136
|
+
const visible = () => app.tools(env);
|
|
137
|
+
register("search_tools", {
|
|
138
|
+
title: "Find a tool",
|
|
139
|
+
description: `Search ${app.title}'s tools by what they do. Returns names, titles and risk. Call describe_tool on a result for its arguments, then call_tool to run it.`,
|
|
140
|
+
inputSchema: z.object({
|
|
141
|
+
query: z.string().min(1).describe("What you want to do, in plain words: 'schedule a post', 'list subscribers'."),
|
|
142
|
+
limit: z.number().int().min(1).max(50).optional().describe("How many results, 1-50. Defaults to 10."),
|
|
143
|
+
}),
|
|
144
|
+
annotations: { title: "Find a tool", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
145
|
+
}, async ({ query, limit }) => {
|
|
146
|
+
const matches = searchTools(visible(), query, limit ?? 10).map(({ tool }) => ({
|
|
147
|
+
name: tool.name,
|
|
148
|
+
title: tool.title,
|
|
149
|
+
risk: tool.risk,
|
|
150
|
+
requires_confirm: tool.requireConfirm,
|
|
151
|
+
summary: firstSentence(tool.description),
|
|
152
|
+
}));
|
|
153
|
+
const data = { query, count: matches.length, tools: matches };
|
|
154
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data };
|
|
155
|
+
});
|
|
156
|
+
register("describe_tool", {
|
|
157
|
+
title: "Describe a tool",
|
|
158
|
+
description: "The full description, argument schema and examples of one tool found with search_tools.",
|
|
159
|
+
inputSchema: z.object({ name: z.string().min(1).describe("The tool name from search_tools.") }),
|
|
160
|
+
annotations: { title: "Describe a tool", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
161
|
+
}, async ({ name }) => {
|
|
162
|
+
const tool = visible().find((candidate) => candidate.name === name);
|
|
163
|
+
if (!tool)
|
|
164
|
+
return errorResult(new UsageError(`No tool named '${name}'. Use search_tools to find one.`), app.secrets);
|
|
165
|
+
const data = {
|
|
166
|
+
name: tool.name,
|
|
167
|
+
title: tool.title,
|
|
168
|
+
description: tool.description,
|
|
169
|
+
risk: tool.risk,
|
|
170
|
+
requires_confirm: tool.requireConfirm,
|
|
171
|
+
input_schema: tool.jsonSchema,
|
|
172
|
+
...(tool.output ? { output_schema: outputJsonSchema(tool.output) } : {}),
|
|
173
|
+
...(tool.examples.length ? { examples: tool.examples } : {}),
|
|
174
|
+
};
|
|
175
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data };
|
|
176
|
+
});
|
|
177
|
+
register("call_tool", {
|
|
178
|
+
title: "Run a tool",
|
|
179
|
+
description: "Run a tool found with search_tools, with arguments that match its schema from describe_tool. A tool marked requires_confirm needs confirming: the user is asked to approve it where the client can ask, and otherwise it runs only with confirm: true, which you pass only when the user asked for that exact action.",
|
|
180
|
+
inputSchema: z.object({
|
|
181
|
+
name: z.string().min(1).describe("The tool name."),
|
|
182
|
+
arguments: z.record(z.string(), z.unknown()).optional().describe("The tool's arguments, as its schema describes them."),
|
|
183
|
+
confirm: z.boolean().optional().describe("Required for tools marked requires_confirm."),
|
|
184
|
+
}),
|
|
185
|
+
annotations: { title: "Run a tool", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
186
|
+
}, async ({ name, arguments: args, confirm }, ctx) => {
|
|
187
|
+
const tool = visible().find((candidate) => candidate.name === name);
|
|
188
|
+
if (!tool)
|
|
189
|
+
return errorResult(new UsageError(`No tool named '${name}'. Use search_tools to find one.`), app.secrets);
|
|
190
|
+
try {
|
|
191
|
+
const parsed = await app.parse(tool, args ?? {});
|
|
192
|
+
// call_tool's own entry carries no request for a person, so Claude Code did not prompt for this tool.
|
|
193
|
+
const confirmed = await confirmFirst(server, app, tool, parsed, ctx, env, policy, false);
|
|
194
|
+
if ("ask" in confirmed)
|
|
195
|
+
return confirmed.ask;
|
|
196
|
+
let cached;
|
|
197
|
+
const value = await app.run(tool, parsed, {
|
|
198
|
+
surface: "mcp",
|
|
199
|
+
confirmed: confirm === true,
|
|
200
|
+
approvedBy: confirmed.approvedBy,
|
|
201
|
+
signal: ctx.mcpReq.signal,
|
|
202
|
+
env,
|
|
203
|
+
onProgress: progressFor(ctx),
|
|
204
|
+
onCache: (hit) => (cached = hit),
|
|
205
|
+
});
|
|
206
|
+
const result = withCacheNote(toCallToolResult(tool, value, app.secrets), cached);
|
|
207
|
+
// This tool declares no output schema, so only an object may travel as structured content.
|
|
208
|
+
if (result.structuredContent !== undefined && !isPlainObject(result.structuredContent))
|
|
209
|
+
delete result.structuredContent;
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
return errorResult(toSlipwayError(error), app.secrets);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
function registerResource(server, app, resource, env) {
|
|
218
|
+
const mimeType = resource.mimeType ?? "application/json";
|
|
219
|
+
server.registerResource(resource.name, resource.uri, {
|
|
220
|
+
...(resource.title ? { title: resource.title } : {}),
|
|
221
|
+
...(resource.description ? { description: resource.description } : {}),
|
|
222
|
+
mimeType,
|
|
223
|
+
}, async (uri) => {
|
|
224
|
+
const value = await resource.read(await app.context(env));
|
|
225
|
+
const body = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
|
226
|
+
return { contents: [{ uri: uri.href, mimeType, text: app.secrets.redact(body) }] };
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
function registerPrompt(server, app, prompt, env) {
|
|
230
|
+
const respond = async (args) => {
|
|
231
|
+
const text = await prompt.render(args, await app.context(env));
|
|
232
|
+
return { messages: [{ role: "user", content: { type: "text", text } }] };
|
|
233
|
+
};
|
|
234
|
+
const config = {
|
|
235
|
+
...(prompt.title ? { title: prompt.title } : {}),
|
|
236
|
+
...(prompt.description ? { description: prompt.description } : {}),
|
|
237
|
+
};
|
|
238
|
+
const register = server.registerPrompt.bind(server);
|
|
239
|
+
// Without an argument schema the SDK passes only its context, so the callback shape differs.
|
|
240
|
+
if (prompt.args)
|
|
241
|
+
register(prompt.name, { ...config, argsSchema: prompt.args }, (args) => respond(args ?? {}));
|
|
242
|
+
else
|
|
243
|
+
register(prompt.name, config, () => respond({}));
|
|
244
|
+
}
|
package/dist/sync.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copying lists to this machine, and the MCP tools that search and refresh them.
|
|
3
|
+
*
|
|
4
|
+
* `data sync <command>` in a terminal and `local_sync` over MCP do the same
|
|
5
|
+
* thing: follow every page of a list tool and keep each item, keyed by its id,
|
|
6
|
+
* in the local data file. A sync with no filters mirrors the list, so records
|
|
7
|
+
* the service no longer lists are removed. A filtered sync only adds and
|
|
8
|
+
* updates, because what it did not see may still exist outside the filter.
|
|
9
|
+
*/
|
|
10
|
+
import type { App, InvokeOptions } from "./app.js";
|
|
11
|
+
import { type Tool } from "./tool.js";
|
|
12
|
+
export type SyncReport = {
|
|
13
|
+
tool: string;
|
|
14
|
+
command: string;
|
|
15
|
+
/** Records kept from this run. */
|
|
16
|
+
records: number;
|
|
17
|
+
/** Items with no id at the sync's id path, which could not be kept. */
|
|
18
|
+
skipped: number;
|
|
19
|
+
/** Records removed because a complete, unfiltered sync no longer saw them. */
|
|
20
|
+
removed: number;
|
|
21
|
+
pages: number;
|
|
22
|
+
/** The list ended, rather than the run stopping early. */
|
|
23
|
+
complete: boolean;
|
|
24
|
+
/** Filters were given, so nothing was removed. */
|
|
25
|
+
filtered: boolean;
|
|
26
|
+
seconds: number;
|
|
27
|
+
};
|
|
28
|
+
export declare function syncTool(app: App, tool: Tool, args: Record<string, unknown>, options: InvokeOptions & {
|
|
29
|
+
env: NodeJS.ProcessEnv;
|
|
30
|
+
}): Promise<SyncReport>;
|
|
31
|
+
/**
|
|
32
|
+
* `local_search` and `local_sync`, for an app with at least one synced list.
|
|
33
|
+
* They are in the `local` toolset, so an operator can switch them off.
|
|
34
|
+
*/
|
|
35
|
+
export declare function localDataTools<Ctx>(synced: readonly Tool<Ctx>[], getApp: () => App<Ctx>): Tool<Ctx>[];
|
package/dist/sync.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copying lists to this machine, and the MCP tools that search and refresh them.
|
|
3
|
+
*
|
|
4
|
+
* `data sync <command>` in a terminal and `local_sync` over MCP do the same
|
|
5
|
+
* thing: follow every page of a list tool and keep each item, keyed by its id,
|
|
6
|
+
* in the local data file. A sync with no filters mirrors the list, so records
|
|
7
|
+
* the service no longer lists are removed. A filtered sync only adds and
|
|
8
|
+
* updates, because what it did not see may still exist outside the filter.
|
|
9
|
+
*/
|
|
10
|
+
import * as z from "zod";
|
|
11
|
+
import { UsageError } from "./errors.js";
|
|
12
|
+
import { eachPage, getPath } from "./pages.js";
|
|
13
|
+
import { defineTool } from "./tool.js";
|
|
14
|
+
import { stableJson } from "./util.js";
|
|
15
|
+
/** A sync's filters: its arguments without the cursor and page size, which only say which page. */
|
|
16
|
+
function filtersOf(tool, args) {
|
|
17
|
+
const { [tool.paginate?.cursorArg ?? "\0"]: _cursor, [tool.paginate?.limitArg ?? "\0"]: _limit, ...filters } = args;
|
|
18
|
+
return filters;
|
|
19
|
+
}
|
|
20
|
+
export async function syncTool(app, tool, args, options) {
|
|
21
|
+
if (!tool.sync) {
|
|
22
|
+
throw new UsageError(`${tool.command} is not a list that can be synced.`, { hint: `Synced lists: ${app.allTools.filter((candidate) => candidate.sync).map((candidate) => candidate.command).join(", ") || "none"}.` });
|
|
23
|
+
}
|
|
24
|
+
const sync = tool.sync;
|
|
25
|
+
const store = await app.localData(options.env);
|
|
26
|
+
const scope = app.dataScope(await app.context(options.env));
|
|
27
|
+
const filters = filtersOf(tool, args);
|
|
28
|
+
const startedAt = Date.now();
|
|
29
|
+
let skipped = 0;
|
|
30
|
+
let kept = 0;
|
|
31
|
+
// A copy made from cached pages would be as old as the cache, so a sync always asks the service.
|
|
32
|
+
const run = await eachPage(app, tool, args, { ...options, refresh: true }, sync.items, Number.POSITIVE_INFINITY, async (items) => {
|
|
33
|
+
const records = [];
|
|
34
|
+
for (const item of items) {
|
|
35
|
+
const id = getPath(item, sync.id);
|
|
36
|
+
if ((typeof id !== "string" || !id) && typeof id !== "number") {
|
|
37
|
+
skipped++;
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
// Records are kept as clients would see them, with credentials masked.
|
|
41
|
+
records.push({ id: String(id), data: app.secrets.redactDeep(item) });
|
|
42
|
+
}
|
|
43
|
+
store.putRecords(scope, tool.name, records, startedAt);
|
|
44
|
+
kept += records.length;
|
|
45
|
+
await options.onProgress?.({ progress: kept, message: `${kept} records` });
|
|
46
|
+
});
|
|
47
|
+
const filtered = Object.keys(filters).length > 0;
|
|
48
|
+
const removed = run.complete && !filtered ? store.removeUnseen(scope, tool.name, startedAt) : 0;
|
|
49
|
+
store.recordSync(scope, tool.name, stableJson(filters), { startedAt, records: kept, pages: run.pages, complete: run.complete });
|
|
50
|
+
return {
|
|
51
|
+
tool: tool.name,
|
|
52
|
+
command: tool.command,
|
|
53
|
+
records: kept,
|
|
54
|
+
skipped,
|
|
55
|
+
removed,
|
|
56
|
+
pages: run.pages,
|
|
57
|
+
complete: run.complete,
|
|
58
|
+
filtered,
|
|
59
|
+
seconds: Math.round((Date.now() - startedAt) / 100) / 10,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* `local_search` and `local_sync`, for an app with at least one synced list.
|
|
64
|
+
* They are in the `local` toolset, so an operator can switch them off.
|
|
65
|
+
*/
|
|
66
|
+
export function localDataTools(synced, getApp) {
|
|
67
|
+
const names = synced.map((tool) => tool.name);
|
|
68
|
+
const listed = names.join(", ");
|
|
69
|
+
const searchInput = z.object({
|
|
70
|
+
query: z.string().min(1).describe("The words to find. Every word must appear; the last may be the start of a word."),
|
|
71
|
+
tool: z.enum(names).optional().describe("Only records synced from this tool."),
|
|
72
|
+
limit: z.number().int().min(1).max(50).optional().describe("How many results, 1-50. Defaults to 10."),
|
|
73
|
+
});
|
|
74
|
+
const search = defineTool({
|
|
75
|
+
name: "local_search",
|
|
76
|
+
title: "Search synced data",
|
|
77
|
+
description: `Search the records synced to this machine from ${listed}. Fast, offline, and costs no API calls. If nothing is found, or the data may be old, run local_sync first.`,
|
|
78
|
+
input: searchInput,
|
|
79
|
+
risk: "read",
|
|
80
|
+
openWorld: false,
|
|
81
|
+
tags: ["local"],
|
|
82
|
+
handler: async ({ query, tool, limit }, ctx) => {
|
|
83
|
+
const app = getApp();
|
|
84
|
+
const store = await app.localData(ctx.env);
|
|
85
|
+
const hits = store.search(app.dataScope(ctx), query, { ...(tool ? { tool } : {}), limit: limit ?? 10 });
|
|
86
|
+
return {
|
|
87
|
+
query,
|
|
88
|
+
count: hits.length,
|
|
89
|
+
results: hits,
|
|
90
|
+
...(hits.length ? {} : { hint: `Nothing synced matches. Sync with local_sync first, from one of: ${listed}.` }),
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
const syncInput = z.object({
|
|
95
|
+
tool: z.enum(names).describe("The list tool to copy."),
|
|
96
|
+
arguments: z.record(z.string(), z.unknown()).optional().describe("Filters for that tool, as its own arguments. With none, records the service no longer lists are removed."),
|
|
97
|
+
});
|
|
98
|
+
const syncData = defineTool({
|
|
99
|
+
name: "local_sync",
|
|
100
|
+
title: "Sync data locally",
|
|
101
|
+
description: `Copy every record one of ${listed} lists to this machine, page by page, so local_search can find them offline. Runs in the background, since a long list takes a while.`,
|
|
102
|
+
input: syncInput,
|
|
103
|
+
risk: "read",
|
|
104
|
+
idempotent: true,
|
|
105
|
+
tags: ["local"],
|
|
106
|
+
job: { background: true },
|
|
107
|
+
handler: async ({ tool, arguments: args }, ctx) => {
|
|
108
|
+
const app = getApp();
|
|
109
|
+
const target = app.find(tool);
|
|
110
|
+
return syncTool(app, target, await app.parse(target, args ?? {}), {
|
|
111
|
+
surface: ctx.surface,
|
|
112
|
+
signal: ctx.signal,
|
|
113
|
+
env: ctx.env,
|
|
114
|
+
onProgress: ({ progress, total, message }) => ctx.progress(progress, total, message),
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
return [search, syncData];
|
|
119
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for testing an app the way its users reach it.
|
|
3
|
+
*
|
|
4
|
+
* import { connect, cli } from "@thenavidm/slipway/testing";
|
|
5
|
+
*
|
|
6
|
+
* const mcp = await connect(app, { env });
|
|
7
|
+
* const result = await mcp.callTool("get_profile", { actor: "alice" });
|
|
8
|
+
*
|
|
9
|
+
* const { code, stdout } = await cli(app, ["get-profile", "alice", "--json"], { env });
|
|
10
|
+
*/
|
|
11
|
+
import type { App } from "./app.js";
|
|
12
|
+
import { type ConnectOptions, type RpcClient } from "./rpc.js";
|
|
13
|
+
export { checkApp, type CheckOptions, type CheckReport, type Finding } from "./check.js";
|
|
14
|
+
export type { ConnectOptions, ElicitAnswer, ElicitRequest, ListedTool, RpcClient } from "./rpc.js";
|
|
15
|
+
/**
|
|
16
|
+
* An MCP client connected to the app's real server, in memory.
|
|
17
|
+
*
|
|
18
|
+
* // A person who approves every form the server shows them:
|
|
19
|
+
* const mcp = await connect(app, { elicit: () => ({ action: "accept", content: { approve: true } }) });
|
|
20
|
+
*/
|
|
21
|
+
export declare function connect(app: App, options?: ConnectOptions & {
|
|
22
|
+
env?: NodeJS.ProcessEnv;
|
|
23
|
+
}): Promise<RpcClient>;
|
|
24
|
+
export type CliRun = {
|
|
25
|
+
code: number;
|
|
26
|
+
stdout: string;
|
|
27
|
+
stderr: string;
|
|
28
|
+
};
|
|
29
|
+
/** Run the CLI with captured output. Nothing reaches the real terminal. */
|
|
30
|
+
export declare function cli(app: App, argv: string[], options?: {
|
|
31
|
+
env?: NodeJS.ProcessEnv;
|
|
32
|
+
stdin?: string;
|
|
33
|
+
isTTY?: boolean;
|
|
34
|
+
cwd?: string;
|
|
35
|
+
}): Promise<CliRun>;
|
package/dist/testing.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for testing an app the way its users reach it.
|
|
3
|
+
*
|
|
4
|
+
* import { connect, cli } from "@thenavidm/slipway/testing";
|
|
5
|
+
*
|
|
6
|
+
* const mcp = await connect(app, { env });
|
|
7
|
+
* const result = await mcp.callTool("get_profile", { actor: "alice" });
|
|
8
|
+
*
|
|
9
|
+
* const { code, stdout } = await cli(app, ["get-profile", "alice", "--json"], { env });
|
|
10
|
+
*/
|
|
11
|
+
import { connectInMemory } from "./rpc.js";
|
|
12
|
+
export { checkApp } from "./check.js";
|
|
13
|
+
/**
|
|
14
|
+
* An MCP client connected to the app's real server, in memory.
|
|
15
|
+
*
|
|
16
|
+
* // A person who approves every form the server shows them:
|
|
17
|
+
* const mcp = await connect(app, { elicit: () => ({ action: "accept", content: { approve: true } }) });
|
|
18
|
+
*/
|
|
19
|
+
export function connect(app, options = {}) {
|
|
20
|
+
const { env, ...rest } = options;
|
|
21
|
+
return connectInMemory(app, env ?? {}, rest);
|
|
22
|
+
}
|
|
23
|
+
/** Run the CLI with captured output. Nothing reaches the real terminal. */
|
|
24
|
+
export async function cli(app, argv, options = {}) {
|
|
25
|
+
let stdout = "";
|
|
26
|
+
let stderr = "";
|
|
27
|
+
const code = await app.runCli(argv, {
|
|
28
|
+
stdout: (text) => {
|
|
29
|
+
stdout += text;
|
|
30
|
+
},
|
|
31
|
+
stderr: (text) => {
|
|
32
|
+
stderr += text;
|
|
33
|
+
},
|
|
34
|
+
stdin: async () => options.stdin ?? "",
|
|
35
|
+
env: options.env ?? {},
|
|
36
|
+
isTTY: options.isTTY ?? false,
|
|
37
|
+
bin: app.bins.cli,
|
|
38
|
+
...(options.cwd ? { cwd: options.cwd } : {}),
|
|
39
|
+
});
|
|
40
|
+
return { code, stdout, stderr };
|
|
41
|
+
}
|
package/dist/tool.d.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one definition both surfaces read.
|
|
3
|
+
*
|
|
4
|
+
* A tool is described once: its name, what it is for, the shape of its input,
|
|
5
|
+
* how risky it is and what it does. The MCP server and the CLI are both
|
|
6
|
+
* generated from that object, so a tool added today is a command today, and
|
|
7
|
+
* neither surface can describe it differently from the other.
|
|
8
|
+
*/
|
|
9
|
+
import type { Icon } from "@modelcontextprotocol/server";
|
|
10
|
+
import { type JobDefinition } from "./jobs.js";
|
|
11
|
+
import type { Secrets } from "./redact.js";
|
|
12
|
+
import { type InferInput, type InferOutput, type JsonSchema, type Schema } from "./schema.js";
|
|
13
|
+
/**
|
|
14
|
+
* How much a call can change.
|
|
15
|
+
*
|
|
16
|
+
* - `read` changes nothing.
|
|
17
|
+
* - `write` changes something that is easy to undo: a like, a label, a draft.
|
|
18
|
+
* - `destructive` is public the moment it runs, cannot be undone, or both.
|
|
19
|
+
*/
|
|
20
|
+
export type Risk = "read" | "write" | "destructive";
|
|
21
|
+
export type Surface = "mcp" | "cli";
|
|
22
|
+
export type Logger = {
|
|
23
|
+
debug(message: string, data?: unknown): void;
|
|
24
|
+
info(message: string, data?: unknown): void;
|
|
25
|
+
warn(message: string, data?: unknown): void;
|
|
26
|
+
error(message: string, data?: unknown): void;
|
|
27
|
+
};
|
|
28
|
+
/** What Slipway hands every handler, next to the app's own context. */
|
|
29
|
+
export type RunContext = {
|
|
30
|
+
/** Aborts when the client cancels, the call times out, or Ctrl-C reaches the CLI. Pass it to fetch. */
|
|
31
|
+
readonly signal: AbortSignal;
|
|
32
|
+
readonly surface: Surface;
|
|
33
|
+
/** The environment this call runs with: the one `context(env)` was built from. */
|
|
34
|
+
readonly env: NodeJS.ProcessEnv;
|
|
35
|
+
/** True when the CLI ran with --dry-run. Handlers only see it through `preview`. */
|
|
36
|
+
readonly dryRun: boolean;
|
|
37
|
+
readonly tool: {
|
|
38
|
+
readonly name: string;
|
|
39
|
+
readonly risk: Risk;
|
|
40
|
+
};
|
|
41
|
+
/** Register credentials here and they are masked in every result and error. */
|
|
42
|
+
readonly secrets: Secrets;
|
|
43
|
+
/** Writes to stderr. stdout is the protocol channel on stdio, so nothing may print there. */
|
|
44
|
+
readonly log: Logger;
|
|
45
|
+
/** Report progress. Sent to an MCP client that asked for it, shown on stderr in a terminal. */
|
|
46
|
+
progress(progress: number, total?: number, message?: string): Promise<void>;
|
|
47
|
+
};
|
|
48
|
+
export type ToolContext<Ctx> = Ctx & RunContext;
|
|
49
|
+
export type ToolExample = {
|
|
50
|
+
/** What the example does, in the words a person would use to ask for it. */
|
|
51
|
+
description: string;
|
|
52
|
+
/** Arguments, exactly as an MCP client would send them. Validated by `slipway check`. */
|
|
53
|
+
args: Record<string, unknown>;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* How a cursor-paginated tool pages, so the CLI can follow every page with
|
|
57
|
+
* `--all` instead of leaving the loop to a script or a model.
|
|
58
|
+
*/
|
|
59
|
+
export type Paginate = {
|
|
60
|
+
/** The input argument that takes the cursor. */
|
|
61
|
+
cursorArg: string;
|
|
62
|
+
/** Dotted path to the next cursor in a result. Empty or missing means the last page. */
|
|
63
|
+
nextCursor: string;
|
|
64
|
+
/** Dotted path to the array of items in a result. */
|
|
65
|
+
items: string;
|
|
66
|
+
/** The input argument for page size, set to the largest page when following every page. */
|
|
67
|
+
limitArg?: string;
|
|
68
|
+
/** The largest page the API serves. */
|
|
69
|
+
maxLimit?: number;
|
|
70
|
+
};
|
|
71
|
+
/** Keep results of a read on this machine for a while, so asking twice costs one call. */
|
|
72
|
+
export type CacheOptions = {
|
|
73
|
+
/** How long a result stays fresh, in seconds. Any write through the same app clears it sooner. */
|
|
74
|
+
ttlSeconds: number;
|
|
75
|
+
};
|
|
76
|
+
/** Copy every item a list tool returns into local data, where it can be searched offline. */
|
|
77
|
+
export type SyncOptions = {
|
|
78
|
+
/** Dotted path to each item's id: `id`, `uri`, `user.id`. */
|
|
79
|
+
id: string;
|
|
80
|
+
/** Dotted path to the list in a result. Defaults to the `paginate.items` path. */
|
|
81
|
+
items?: string;
|
|
82
|
+
};
|
|
83
|
+
type Returns<O> = O extends Schema ? InferInput<O> : unknown;
|
|
84
|
+
export type ToolDefinition<Ctx, I extends Schema, O extends Schema | undefined> = {
|
|
85
|
+
/** snake_case. The MCP tool name; the CLI command is the same name with dashes. */
|
|
86
|
+
name: string;
|
|
87
|
+
/** A few words for pickers and the command list: "Delete a post". */
|
|
88
|
+
title: string;
|
|
89
|
+
/** What it does and when to reach for it. The only documentation a model reads before calling. */
|
|
90
|
+
description: string;
|
|
91
|
+
/** A Zod object, or `jsonSchema({...})` for a tool generated from an API contract. */
|
|
92
|
+
input?: I;
|
|
93
|
+
/** Declare it and results are validated and sent as typed `structuredContent`. */
|
|
94
|
+
output?: O;
|
|
95
|
+
risk: Risk;
|
|
96
|
+
/** Calling twice has the same effect as once. Defaults to true for reads. */
|
|
97
|
+
idempotent?: boolean;
|
|
98
|
+
/** Reaches outside this machine. Defaults to true; set false for local helpers. */
|
|
99
|
+
openWorld?: boolean;
|
|
100
|
+
/**
|
|
101
|
+
* Require `confirm: true` (MCP) or `--confirm` (CLI). Defaults to true for
|
|
102
|
+
* destructive tools. Set it on a write that spends money, such as a paid
|
|
103
|
+
* generation.
|
|
104
|
+
*/
|
|
105
|
+
requireConfirm?: boolean;
|
|
106
|
+
/** Toolsets this tool belongs to. A tool with no tags is always on. */
|
|
107
|
+
tags?: string[];
|
|
108
|
+
/** One line for the audit log and the refusal message: "post 'Hello' as @alice". */
|
|
109
|
+
summary?: (args: InferOutput<I>) => string;
|
|
110
|
+
/** What --dry-run prints instead of running. Defaults to the validated arguments. */
|
|
111
|
+
preview?: (args: InferOutput<I>, ctx: ToolContext<Ctx>) => unknown;
|
|
112
|
+
examples?: ToolExample[];
|
|
113
|
+
/** Input names that may be given as bare words on the command line, in order. */
|
|
114
|
+
positional?: string[];
|
|
115
|
+
/** Abort the call after this long. */
|
|
116
|
+
timeoutMs?: number;
|
|
117
|
+
/** Results this tool returns are legitimately large. Raises Claude Code's limit for this tool. */
|
|
118
|
+
maxResultChars?: number;
|
|
119
|
+
icons?: Icon[];
|
|
120
|
+
/** Extra `_meta` for the tool's `tools/list` entry. */
|
|
121
|
+
meta?: Record<string, unknown>;
|
|
122
|
+
paginate?: Paginate;
|
|
123
|
+
/**
|
|
124
|
+
* The tool starts work that outlasts one call. Slipway adds `wait_seconds`,
|
|
125
|
+
* waits that long, and generates `<name>_status` to check on the job later.
|
|
126
|
+
* Describe a job the service runs with `id`, `status` and `done`, or pass
|
|
127
|
+
* `{ background: true }` for a handler that is slow on its own.
|
|
128
|
+
*/
|
|
129
|
+
job?: JobDefinition<Ctx>;
|
|
130
|
+
/** Cache this read's results locally. Only for reads whose results can be a little stale. */
|
|
131
|
+
cache?: CacheOptions;
|
|
132
|
+
/** This read lists records worth keeping locally: `data sync` copies every page, `data search` finds them. */
|
|
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`. */
|
|
135
|
+
render?: (result: Returns<O>) => string;
|
|
136
|
+
handler: (args: InferOutput<I>, ctx: ToolContext<Ctx>) => Returns<O> | Promise<Returns<O>>;
|
|
137
|
+
};
|
|
138
|
+
export type Tool<Ctx = any> = {
|
|
139
|
+
readonly kind: "slipway.tool";
|
|
140
|
+
readonly name: string;
|
|
141
|
+
/** The CLI command: the name with dashes. */
|
|
142
|
+
readonly command: string;
|
|
143
|
+
readonly title: string;
|
|
144
|
+
readonly description: string;
|
|
145
|
+
readonly risk: Risk;
|
|
146
|
+
readonly idempotent: boolean;
|
|
147
|
+
readonly openWorld: boolean;
|
|
148
|
+
readonly requireConfirm: boolean;
|
|
149
|
+
readonly tags: readonly string[];
|
|
150
|
+
readonly examples: readonly ToolExample[];
|
|
151
|
+
readonly positional: readonly string[];
|
|
152
|
+
readonly timeoutMs?: number;
|
|
153
|
+
readonly maxResultChars?: number;
|
|
154
|
+
readonly icons?: Icon[];
|
|
155
|
+
readonly meta?: Record<string, unknown>;
|
|
156
|
+
readonly paginate?: Paginate;
|
|
157
|
+
readonly job?: JobDefinition<Ctx>;
|
|
158
|
+
readonly cache?: CacheOptions;
|
|
159
|
+
/** The sync settings, with `items` always filled in. */
|
|
160
|
+
readonly sync?: Required<SyncOptions>;
|
|
161
|
+
/** On the status tool Slipway generates for a job tool: the job tool's name. */
|
|
162
|
+
readonly statusOf?: string;
|
|
163
|
+
/** The author's input schema. */
|
|
164
|
+
readonly input: Schema;
|
|
165
|
+
/** What clients see: the input plus `confirm` when the tool requires it. */
|
|
166
|
+
readonly schema: Schema;
|
|
167
|
+
readonly output?: Schema;
|
|
168
|
+
readonly summary?: (args: any) => string;
|
|
169
|
+
readonly preview?: (args: any, ctx: ToolContext<Ctx>) => unknown;
|
|
170
|
+
readonly render?: (result: any) => string;
|
|
171
|
+
readonly handler: (args: any, ctx: ToolContext<Ctx>) => unknown;
|
|
172
|
+
/** The advertised input as JSON Schema, computed once. */
|
|
173
|
+
readonly jsonSchema: JsonSchema;
|
|
174
|
+
};
|
|
175
|
+
/** Define a tool. Throws at load time on a definition that cannot work, not on the first call. */
|
|
176
|
+
export declare function defineTool<Ctx = unknown, I extends Schema = Schema<Record<string, never>>, O extends Schema | undefined = undefined>(definition: ToolDefinition<Ctx, I, O>): Tool<Ctx>;
|
|
177
|
+
/**
|
|
178
|
+
* Bind `defineTool` to an app's context type once, so every handler in the
|
|
179
|
+
* repo gets `ctx.client` typed without repeating the generic:
|
|
180
|
+
*
|
|
181
|
+
* export const { defineTool } = toolkit<Context>();
|
|
182
|
+
*/
|
|
183
|
+
export declare function toolkit<Ctx>(): {
|
|
184
|
+
defineTool: <I extends Schema = Schema<Record<string, never>>, O extends Schema | undefined = undefined>(definition: ToolDefinition<Ctx, I, O>) => Tool<Ctx>;
|
|
185
|
+
};
|
|
186
|
+
export declare function isTool(value: unknown): value is Tool;
|
|
187
|
+
/**
|
|
188
|
+
* One line saying what a call is about to do, for refusals, approval forms and
|
|
189
|
+
* the audit log. The tool's own `summary`, or its title as a phrase: "delete a
|
|
190
|
+
* note". A summary that throws falls back too, because it runs before the
|
|
191
|
+
* arguments have been anywhere near the handler.
|
|
192
|
+
*/
|
|
193
|
+
export declare function summarize(tool: Pick<Tool, "summary" | "title">, args: Record<string, unknown>): string;
|
|
194
|
+
export {};
|