@ketch.to/mcp 0.0.0-stage → 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/README.md +33 -2
- package/dist/src/cli.d.ts +3 -0
- package/dist/src/cli.js +98 -0
- package/dist/src/client.d.ts +38 -0
- package/dist/src/client.js +67 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +2 -0
- package/dist/src/server.d.ts +64 -0
- package/dist/src/server.js +130 -0
- package/package.json +42 -4
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ketch.to/mcp
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
MCP server for [Ketch](https://ketch.to), the one place your agents ask you things. It lets any MCP client (Claude Code, Claude Desktop, Cursor, scripts) put a card in your Ketch queue and read your answer back.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Claude Code:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
claude mcp add ketch -e KETCH_API_KEY=ketch_sk_… -- npx -y @ketch.to/mcp
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Claude Desktop, Cursor and other clients (`mcpServers` config):
|
|
14
|
+
|
|
15
|
+
```json
|
|
16
|
+
{ "mcpServers": { "ketch": { "command": "npx", "args": ["-y", "@ketch.to/mcp"], "env": { "KETCH_API_KEY": "ketch_sk_…" } } } }
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Streamable HTTP instead of stdio:
|
|
20
|
+
|
|
21
|
+
```sh
|
|
22
|
+
KETCH_API_KEY=ketch_sk_… npx -y @ketch.to/mcp --http 3333 # http://127.0.0.1:3333/mcp
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Create the API key in the Ketch app under Settings. `KETCH_API_URL` overrides the API base (default `https://api.ketch.to/v1`).
|
|
26
|
+
|
|
27
|
+
## Tools
|
|
28
|
+
|
|
29
|
+
| Tool | What it does |
|
|
30
|
+
|---|---|
|
|
31
|
+
| `create_card` | Ask the person something. One card, one answer. |
|
|
32
|
+
| `list_cards` | List open, snoozed or done cards, so you don't ask twice. |
|
|
33
|
+
| `get_outcome` | The answer to one card, if there is one yet. |
|
|
34
|
+
| `list_outcomes_since` | Every answer since a timestamp. Treat `not_good` as "don't send me this kind again". |
|
package/dist/src/cli.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ketch-mcp — MCP server for Ketch.
|
|
4
|
+
*
|
|
5
|
+
* KETCH_API_KEY=ketch_sk_… npx @ketch.to/mcp # stdio (Claude Desktop, Claude Code, Cursor…)
|
|
6
|
+
* KETCH_API_KEY=ketch_sk_… npx @ketch.to/mcp --http 3333 # streamable HTTP on http://127.0.0.1:3333/mcp
|
|
7
|
+
*
|
|
8
|
+
* Env: KETCH_API_KEY (required), KETCH_API_URL (default https://api.ketch.to/v1).
|
|
9
|
+
*/
|
|
10
|
+
import { realpathSync } from "node:fs";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { createServer } from "node:http";
|
|
13
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
15
|
+
import { KetchClient } from "./client.js";
|
|
16
|
+
import { createKetchMcpServer } from "./server.js";
|
|
17
|
+
export const DEFAULT_API_URL = "https://api.ketch.to/v1";
|
|
18
|
+
function usage(msg) {
|
|
19
|
+
if (msg)
|
|
20
|
+
console.error(msg);
|
|
21
|
+
console.error("usage: KETCH_API_KEY=ketch_sk_… ketch-mcp [--http [port]] [--api-url URL]");
|
|
22
|
+
process.exit(2);
|
|
23
|
+
}
|
|
24
|
+
export async function main(argv = process.argv.slice(2), env = process.env) {
|
|
25
|
+
let http = null;
|
|
26
|
+
let apiUrl = env.KETCH_API_URL ?? DEFAULT_API_URL;
|
|
27
|
+
for (let i = 0; i < argv.length; i++) {
|
|
28
|
+
const a = argv[i];
|
|
29
|
+
if (a === "--http") {
|
|
30
|
+
const next = argv[i + 1];
|
|
31
|
+
if (next && /^\d+$/.test(next)) {
|
|
32
|
+
http = Number(next);
|
|
33
|
+
i++;
|
|
34
|
+
}
|
|
35
|
+
else
|
|
36
|
+
http = 3333;
|
|
37
|
+
}
|
|
38
|
+
else if (a === "--api-url") {
|
|
39
|
+
apiUrl = argv[++i] ?? usage("--api-url needs a value");
|
|
40
|
+
}
|
|
41
|
+
else if (a === "-h" || a === "--help")
|
|
42
|
+
usage();
|
|
43
|
+
else
|
|
44
|
+
usage(`unknown argument: ${a}`);
|
|
45
|
+
}
|
|
46
|
+
const apiKey = env.KETCH_API_KEY;
|
|
47
|
+
if (!apiKey)
|
|
48
|
+
usage("KETCH_API_KEY is required (create one in the app's Settings, or POST /keys)");
|
|
49
|
+
const client = new KetchClient({ baseUrl: apiUrl, apiKey });
|
|
50
|
+
if (http === null) {
|
|
51
|
+
const server = createKetchMcpServer(client);
|
|
52
|
+
await server.connect(new StdioServerTransport());
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Stateless streamable HTTP: one server+transport per request, nothing to clean up.
|
|
56
|
+
const httpServer = createServer(async (req, res) => {
|
|
57
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
58
|
+
if (url.pathname !== "/mcp") {
|
|
59
|
+
res.writeHead(404, { "content-type": "application/json" }).end(JSON.stringify({ error: "POST /mcp" }));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const server = createKetchMcpServer(client);
|
|
63
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
64
|
+
res.on("close", () => {
|
|
65
|
+
transport.close();
|
|
66
|
+
server.close();
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
await server.connect(transport);
|
|
70
|
+
await transport.handleRequest(req, res);
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
console.error(err);
|
|
74
|
+
if (!res.headersSent)
|
|
75
|
+
res.writeHead(500).end();
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
await new Promise((r) => httpServer.listen(http, "127.0.0.1", r));
|
|
79
|
+
console.error(`ketch-mcp: streamable HTTP on http://127.0.0.1:${http}/mcp → ${apiUrl}`);
|
|
80
|
+
}
|
|
81
|
+
// npx and global installs run this file through a symlink named after the bin, so compare real paths.
|
|
82
|
+
function isEntryPoint() {
|
|
83
|
+
if (!process.argv[1])
|
|
84
|
+
return false;
|
|
85
|
+
try {
|
|
86
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const invokedDirectly = isEntryPoint();
|
|
93
|
+
if (invokedDirectly) {
|
|
94
|
+
main().catch((err) => {
|
|
95
|
+
console.error(err);
|
|
96
|
+
process.exit(1);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { Card, NewCard, Outcome } from "@ketch.to/schema";
|
|
2
|
+
export interface ClientOptions {
|
|
3
|
+
/** e.g. https://api.ketch.to/v1 or http://127.0.0.1:8787/v1 */
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
apiKey: string;
|
|
6
|
+
fetch?: typeof fetch;
|
|
7
|
+
}
|
|
8
|
+
export declare class KetchApiError extends Error {
|
|
9
|
+
status: number;
|
|
10
|
+
code: string;
|
|
11
|
+
details?: unknown | undefined;
|
|
12
|
+
constructor(status: number, code: string, message: string, details?: unknown | undefined);
|
|
13
|
+
}
|
|
14
|
+
export interface OutcomeItem {
|
|
15
|
+
card: Card;
|
|
16
|
+
outcome: Outcome;
|
|
17
|
+
}
|
|
18
|
+
/** Thin, typed client over the v1 API. Producers only need these calls. */
|
|
19
|
+
export declare class KetchClient {
|
|
20
|
+
private baseUrl;
|
|
21
|
+
private apiKey;
|
|
22
|
+
private fetchImpl;
|
|
23
|
+
constructor(opts: ClientOptions);
|
|
24
|
+
private call;
|
|
25
|
+
createCard(card: NewCard, idempotencyKey?: string): Promise<Card>;
|
|
26
|
+
listCards(opts?: {
|
|
27
|
+
status?: "open" | "snoozed" | "done" | "all";
|
|
28
|
+
since?: string;
|
|
29
|
+
limit?: number;
|
|
30
|
+
}): Promise<Card[]>;
|
|
31
|
+
getCard(id: string): Promise<Card>;
|
|
32
|
+
getOutcome(id: string): Promise<Outcome | null>;
|
|
33
|
+
listOutcomes(opts?: {
|
|
34
|
+
since?: string;
|
|
35
|
+
action?: string;
|
|
36
|
+
limit?: number;
|
|
37
|
+
}): Promise<OutcomeItem[]>;
|
|
38
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
export class KetchApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
details;
|
|
5
|
+
constructor(status, code, message, details) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.details = details;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** Thin, typed client over the v1 API. Producers only need these calls. */
|
|
13
|
+
export class KetchClient {
|
|
14
|
+
baseUrl;
|
|
15
|
+
apiKey;
|
|
16
|
+
fetchImpl;
|
|
17
|
+
constructor(opts) {
|
|
18
|
+
this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
|
|
19
|
+
this.apiKey = opts.apiKey;
|
|
20
|
+
this.fetchImpl = opts.fetch ?? fetch;
|
|
21
|
+
}
|
|
22
|
+
async call(method, path, body, headers = {}) {
|
|
23
|
+
const res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
24
|
+
method,
|
|
25
|
+
headers: { authorization: `Bearer ${this.apiKey}`, ...(body !== undefined ? { "content-type": "application/json" } : {}), ...headers },
|
|
26
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
27
|
+
});
|
|
28
|
+
const text = await res.text();
|
|
29
|
+
const json = text ? JSON.parse(text) : null;
|
|
30
|
+
if (!res.ok) {
|
|
31
|
+
const e = json?.error ?? {};
|
|
32
|
+
throw new KetchApiError(res.status, e.code ?? "http_error", e.message ?? `HTTP ${res.status}`, e.details);
|
|
33
|
+
}
|
|
34
|
+
return json;
|
|
35
|
+
}
|
|
36
|
+
createCard(card, idempotencyKey) {
|
|
37
|
+
return this.call("POST", "/cards", card, idempotencyKey ? { "idempotency-key": idempotencyKey } : {});
|
|
38
|
+
}
|
|
39
|
+
async listCards(opts = {}) {
|
|
40
|
+
const q = new URLSearchParams();
|
|
41
|
+
if (opts.status)
|
|
42
|
+
q.set("status", opts.status);
|
|
43
|
+
if (opts.since)
|
|
44
|
+
q.set("since", opts.since);
|
|
45
|
+
if (opts.limit)
|
|
46
|
+
q.set("limit", String(opts.limit));
|
|
47
|
+
const s = q.toString();
|
|
48
|
+
return (await this.call("GET", `/cards${s ? `?${s}` : ""}`)).cards;
|
|
49
|
+
}
|
|
50
|
+
getCard(id) {
|
|
51
|
+
return this.call("GET", `/cards/${encodeURIComponent(id)}`);
|
|
52
|
+
}
|
|
53
|
+
async getOutcome(id) {
|
|
54
|
+
return (await this.call("GET", `/cards/${encodeURIComponent(id)}/outcome`)).outcome;
|
|
55
|
+
}
|
|
56
|
+
async listOutcomes(opts = {}) {
|
|
57
|
+
const q = new URLSearchParams();
|
|
58
|
+
if (opts.since)
|
|
59
|
+
q.set("since", opts.since);
|
|
60
|
+
if (opts.action)
|
|
61
|
+
q.set("action", opts.action);
|
|
62
|
+
if (opts.limit)
|
|
63
|
+
q.set("limit", String(opts.limit));
|
|
64
|
+
const s = q.toString();
|
|
65
|
+
return (await this.call("GET", `/outcomes${s ? `?${s}` : ""}`)).outcomes;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { type KetchClient } from "./client.js";
|
|
4
|
+
export declare const SERVER_NAME = "ketch";
|
|
5
|
+
export declare const SERVER_VERSION = "0.1.0";
|
|
6
|
+
export declare const NewCardShape: {
|
|
7
|
+
title: z.ZodString;
|
|
8
|
+
body: z.ZodOptional<z.ZodString>;
|
|
9
|
+
source: z.ZodOptional<z.ZodObject<{
|
|
10
|
+
app: z.ZodOptional<z.ZodString>;
|
|
11
|
+
context: z.ZodOptional<z.ZodString>;
|
|
12
|
+
}, "strip", z.ZodTypeAny, {
|
|
13
|
+
app?: string | undefined;
|
|
14
|
+
context?: string | undefined;
|
|
15
|
+
}, {
|
|
16
|
+
app?: string | undefined;
|
|
17
|
+
context?: string | undefined;
|
|
18
|
+
}>>;
|
|
19
|
+
link: z.ZodOptional<z.ZodString>;
|
|
20
|
+
priority: z.ZodOptional<z.ZodNumber>;
|
|
21
|
+
kind: z.ZodOptional<z.ZodEnum<[string, ...string[]]>>;
|
|
22
|
+
actions: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
23
|
+
id: z.ZodString;
|
|
24
|
+
label: z.ZodString;
|
|
25
|
+
style: z.ZodOptional<z.ZodEnum<["positive", "snooze", "neutral"]>>;
|
|
26
|
+
input: z.ZodOptional<z.ZodEnum<["optional", "required"]>>;
|
|
27
|
+
snooze_for: z.ZodOptional<z.ZodString>;
|
|
28
|
+
}, "strip", z.ZodTypeAny, {
|
|
29
|
+
id: string;
|
|
30
|
+
label: string;
|
|
31
|
+
style?: "positive" | "snooze" | "neutral" | undefined;
|
|
32
|
+
input?: "optional" | "required" | undefined;
|
|
33
|
+
snooze_for?: string | undefined;
|
|
34
|
+
}, {
|
|
35
|
+
id: string;
|
|
36
|
+
label: string;
|
|
37
|
+
style?: "positive" | "snooze" | "neutral" | undefined;
|
|
38
|
+
input?: "optional" | "required" | undefined;
|
|
39
|
+
snooze_for?: string | undefined;
|
|
40
|
+
}>, "many">>;
|
|
41
|
+
input: z.ZodOptional<z.ZodObject<{
|
|
42
|
+
placeholder: z.ZodOptional<z.ZodString>;
|
|
43
|
+
spawns: z.ZodOptional<z.ZodBoolean>;
|
|
44
|
+
}, "strip", z.ZodTypeAny, {
|
|
45
|
+
placeholder?: string | undefined;
|
|
46
|
+
spawns?: boolean | undefined;
|
|
47
|
+
}, {
|
|
48
|
+
placeholder?: string | undefined;
|
|
49
|
+
spawns?: boolean | undefined;
|
|
50
|
+
}>>;
|
|
51
|
+
recurrence: z.ZodOptional<z.ZodObject<{
|
|
52
|
+
every: z.ZodString;
|
|
53
|
+
}, "strip", z.ZodTypeAny, {
|
|
54
|
+
every: string;
|
|
55
|
+
}, {
|
|
56
|
+
every: string;
|
|
57
|
+
}>>;
|
|
58
|
+
show_after: z.ZodOptional<z.ZodString>;
|
|
59
|
+
expires_at: z.ZodOptional<z.ZodString>;
|
|
60
|
+
meta: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
61
|
+
idempotency_key: z.ZodOptional<z.ZodString>;
|
|
62
|
+
};
|
|
63
|
+
/** The four tools from CLAUDE.md. Everything else a producer might want goes through the HTTP API. */
|
|
64
|
+
export declare function createKetchMcpServer(client: KetchClient): McpServer;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { DURATION_RE, KINDS, KIND_PRESETS, validateNewCard } from "@ketch.to/schema";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { KetchApiError } from "./client.js";
|
|
5
|
+
export const SERVER_NAME = "ketch";
|
|
6
|
+
export const SERVER_VERSION = "0.1.0";
|
|
7
|
+
const Duration = z.string().regex(DURATION_RE, "duration like 1h, 3d, 14d, 2w");
|
|
8
|
+
const ActionShape = z.object({
|
|
9
|
+
id: z.string().regex(/^[a-z][a-z0-9_]{0,31}$/).describe("Echoed back as outcome.action. 'snooze' and 'not_good' are reserved and always available."),
|
|
10
|
+
label: z.string().min(1).max(24).describe("Button text."),
|
|
11
|
+
style: z.enum(["positive", "snooze", "neutral"]).optional().describe("positive = swipe-right target; snooze = tap 1h / hold for days; neutral (default) = plain button."),
|
|
12
|
+
input: z.enum(["optional", "required"]).optional().describe("Open a text sheet before posting this action."),
|
|
13
|
+
snooze_for: Duration.optional().describe("Only with style 'snooze': default snooze on tap (default 1h)."),
|
|
14
|
+
});
|
|
15
|
+
const presetHelp = KINDS.map((k) => `${k}: ${KIND_PRESETS[k].actions.map((a) => a.label).join(" / ")}`).join("; ");
|
|
16
|
+
export const NewCardShape = {
|
|
17
|
+
title: z.string().min(1).max(140).describe("What you're asking. Short, specific, one line."),
|
|
18
|
+
body: z.string().max(4000).optional().describe("Context the person needs to answer without opening anything. Plain text or light markdown."),
|
|
19
|
+
source: z
|
|
20
|
+
.object({
|
|
21
|
+
app: z.string().min(1).max(40).optional().describe("Defaults to this API key's name."),
|
|
22
|
+
context: z.string().min(1).max(120).optional().describe("Where this came from, e.g. the thread, meeting or project name."),
|
|
23
|
+
})
|
|
24
|
+
.optional(),
|
|
25
|
+
link: z.string().regex(/^(https:\/\/|shortcuts:\/\/)\S+$/).optional().describe("https:// or shortcuts:// link opened from the card."),
|
|
26
|
+
priority: z.number().int().min(1).max(5).optional().describe("1 urgent … 5 ambient. Default 3. 1–2 push a notification; use sparingly."),
|
|
27
|
+
kind: z.enum(KINDS).optional().describe(`Preset that fills in default actions. ${presetHelp}. Default do.`),
|
|
28
|
+
actions: z.array(ActionShape).min(1).max(4).optional().describe("Override the preset's buttons (1–4, in order)."),
|
|
29
|
+
input: z
|
|
30
|
+
.object({
|
|
31
|
+
placeholder: z.string().max(80).optional(),
|
|
32
|
+
spawns: z.boolean().optional().describe("Each entry the person types becomes a new card."),
|
|
33
|
+
})
|
|
34
|
+
.optional()
|
|
35
|
+
.describe("Optional free-text field on the card."),
|
|
36
|
+
recurrence: z.object({ every: Duration }).optional().describe("Re-show after an action instead of retiring (checkin cards default to 14d)."),
|
|
37
|
+
show_after: z.string().datetime({ offset: true }).optional().describe("Hide until this ISO 8601 time."),
|
|
38
|
+
expires_at: z.string().datetime({ offset: true }).optional().describe("Retire automatically at this ISO 8601 time if unanswered."),
|
|
39
|
+
meta: z.record(z.unknown()).optional().describe("Your own bag (ids, rule names). Echoed back untouched in outcomes and webhooks."),
|
|
40
|
+
idempotency_key: z.string().min(1).max(255).optional().describe("Send the same key on retries to avoid duplicate cards. Strongly recommended, e.g. '<rule>-<thread id>'."),
|
|
41
|
+
};
|
|
42
|
+
const summarizeCard = (c) => {
|
|
43
|
+
const src = c.source.context ? `${c.source.app} · ${c.source.context}` : c.source.app;
|
|
44
|
+
const extra = c.status === "snoozed" && c.show_after ? ` until ${c.show_after}` : c.outcome ? ` → ${c.outcome.action}${c.outcome.text ? `: ${c.outcome.text}` : ""}` : "";
|
|
45
|
+
return `${c.id} [${c.status}${extra}] p${c.priority} ${c.kind} "${c.title}" (${src})`;
|
|
46
|
+
};
|
|
47
|
+
const summarizeOutcome = (o) => `${o.action}${o.text ? ` — "${o.text}"` : ""}${o.snoozed_until ? ` (until ${o.snoozed_until})` : ""} at ${o.at}`;
|
|
48
|
+
function errorResult(err) {
|
|
49
|
+
if (err instanceof KetchApiError) {
|
|
50
|
+
const details = err.details ? `\n${JSON.stringify(err.details, null, 2)}` : "";
|
|
51
|
+
return { isError: true, content: [{ type: "text", text: `${err.code} (${err.status}): ${err.message}${details}` }] };
|
|
52
|
+
}
|
|
53
|
+
return { isError: true, content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }] };
|
|
54
|
+
}
|
|
55
|
+
/** The four tools from CLAUDE.md. Everything else a producer might want goes through the HTTP API. */
|
|
56
|
+
export function createKetchMcpServer(client) {
|
|
57
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
58
|
+
server.registerTool("create_card", {
|
|
59
|
+
title: "Create a card",
|
|
60
|
+
description: "Ask the person something. Creates one card in their Ketch queue; they answer with one tap and the outcome comes back via get_outcome / list_outcomes_since. " +
|
|
61
|
+
"Use a kind preset instead of custom actions whenever one fits. Keep titles short and bodies self-contained. " +
|
|
62
|
+
"Always pass idempotency_key when you might retry.",
|
|
63
|
+
inputSchema: NewCardShape,
|
|
64
|
+
}, async ({ idempotency_key, ...input }) => {
|
|
65
|
+
const v = validateNewCard(input);
|
|
66
|
+
if (!v.ok)
|
|
67
|
+
return { isError: true, content: [{ type: "text", text: `Invalid card:\n${v.errors.map((e) => `- ${e.path || "/"}: ${e.message}`).join("\n")}` }] };
|
|
68
|
+
try {
|
|
69
|
+
const card = await client.createCard(v.value, idempotency_key);
|
|
70
|
+
return { content: [{ type: "text", text: `Created ${summarizeCard(card)}` }], structuredContent: { card } };
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
return errorResult(err);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
server.registerTool("list_cards", {
|
|
77
|
+
title: "List cards",
|
|
78
|
+
description: "List the person's cards. Default: what's in the queue now (status open). Use status=done to see what they've answered, snoozed for deferred ones, all for everything. " +
|
|
79
|
+
"Use since (ISO 8601) to see only cards updated after a time, e.g. to avoid creating duplicates.",
|
|
80
|
+
inputSchema: {
|
|
81
|
+
status: z.enum(["open", "snoozed", "done", "all"]).optional(),
|
|
82
|
+
since: z.string().datetime({ offset: true }).optional(),
|
|
83
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
84
|
+
},
|
|
85
|
+
}, async (args) => {
|
|
86
|
+
try {
|
|
87
|
+
const cards = await client.listCards(args);
|
|
88
|
+
const text = cards.length ? cards.map(summarizeCard).join("\n") : `No ${args.status ?? "open"} cards.`;
|
|
89
|
+
return { content: [{ type: "text", text }], structuredContent: { cards } };
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
return errorResult(err);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
server.registerTool("get_outcome", {
|
|
96
|
+
title: "Get a card's outcome",
|
|
97
|
+
description: "What the person did with a card: the action id, any text they typed, when, and the snoozed-until time if they snoozed. Null if they haven't answered yet.",
|
|
98
|
+
inputSchema: { card_id: z.string().regex(/^card_[0-9A-HJKMNP-TV-Z]{26}$/) },
|
|
99
|
+
}, async ({ card_id }) => {
|
|
100
|
+
try {
|
|
101
|
+
const outcome = await client.getOutcome(card_id);
|
|
102
|
+
return { content: [{ type: "text", text: outcome ? summarizeOutcome(outcome) : "No outcome yet." }], structuredContent: { outcome } };
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
return errorResult(err);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
server.registerTool("list_outcomes_since", {
|
|
109
|
+
title: "List outcomes since a time",
|
|
110
|
+
description: "Every answer the person gave since a time, oldest first, each with its card (including your meta). This is your feedback loop: " +
|
|
111
|
+
"action=not_good lists the cards they said 'not a good one' to — stop sending those. Store the last `at` you processed and pass it as since next time.",
|
|
112
|
+
inputSchema: {
|
|
113
|
+
since: z.string().datetime({ offset: true }).describe("ISO 8601. Outcomes at or after this time."),
|
|
114
|
+
action: z.string().optional().describe("Only this action id, e.g. not_good."),
|
|
115
|
+
limit: z.number().int().min(1).max(500).optional(),
|
|
116
|
+
},
|
|
117
|
+
}, async (args) => {
|
|
118
|
+
try {
|
|
119
|
+
const outcomes = await client.listOutcomes(args);
|
|
120
|
+
const text = outcomes.length
|
|
121
|
+
? outcomes.map(({ card, outcome }) => `${card.id} "${card.title}" (${card.source.app}${card.source.context ? ` · ${card.source.context}` : ""})\n ${summarizeOutcome(outcome)}${Object.keys(card.meta ?? {}).length ? `\n meta: ${JSON.stringify(card.meta)}` : ""}`).join("\n")
|
|
122
|
+
: "No outcomes since then.";
|
|
123
|
+
return { content: [{ type: "text", text }], structuredContent: { outcomes } };
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
return errorResult(err);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
return server;
|
|
130
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,44 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ketch.to/mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server for Ketch — the one place your agents ask you things",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"ketch-mcp": "./dist/src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"main": "./dist/src/index.js",
|
|
10
|
+
"types": "./dist/src/index.d.ts",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist/src",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsc -p tsconfig.json",
|
|
17
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"start": "tsx src/cli.ts",
|
|
20
|
+
"prepublishOnly": "npm run build && npm test"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
24
|
+
"@ketch.to/schema": "0.1.0",
|
|
25
|
+
"zod": "^3.23.8"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@ketch.to/api": "0.1.0"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"homepage": "https://ketch.to",
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"keywords": [
|
|
38
|
+
"mcp",
|
|
39
|
+
"model-context-protocol",
|
|
40
|
+
"claude",
|
|
41
|
+
"ketch",
|
|
42
|
+
"agents"
|
|
43
|
+
]
|
|
44
|
+
}
|