@pinmark/mcp 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 ADDED
@@ -0,0 +1,93 @@
1
+ # @pinmark/mcp
2
+
3
+ An [MCP](https://modelcontextprotocol.io) server for **PinMark** — let AI agents create, search,
4
+ update, and organize sticky notes on your Mac. **Fully local**: notes never leave your machine,
5
+ there's no network backend, and no third-party analytics. The agent reads and writes the notes
6
+ you choose to expose to it.
7
+
8
+ It's a thin adapter over the `pinmark` command-line tool: the PinMark app remains the single
9
+ source of truth for storage, validation, and the free/Pro boundary.
10
+
11
+ ## Requirements
12
+
13
+ - macOS with **PinMark** installed.
14
+ - The `pinmark` CLI installed (PinMark → Settings → Command Line Tool). The server looks for
15
+ `~/.local/bin/pinmark`, then falls back to your `PATH`.
16
+
17
+ ## Use with an MCP client
18
+
19
+ Add to your client config (e.g. Claude Desktop `claude_desktop_config.json`, or Cursor):
20
+
21
+ ```json
22
+ {
23
+ "mcpServers": {
24
+ "pinmark": {
25
+ "command": "npx",
26
+ "args": ["-y", "@pinmark/mcp"]
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ The server runs over stdio and is launched on demand by the client.
33
+
34
+ ## Tools
35
+
36
+ | Tool | What it does |
37
+ | --- | --- |
38
+ | `create_note` | Create a text note (Markdown). |
39
+ | `append_to_note` | Append text to an existing note. |
40
+ | `update_note` | Update content / title / color / caption. |
41
+ | `get_note` | Fetch one note with full content. |
42
+ | `list_notes` | List notes (excludes trash unless asked). |
43
+ | `search_notes` | Search by title, content, caption, or image filename. |
44
+ | `delete_note` | Move a note to the trash (recoverable). |
45
+ | `restore_note` | Restore a note from the trash. |
46
+ | `pin_note` / `unpin_note` | Pin / unpin a note. |
47
+ | `open_note` | Bring a note to the foreground (this one activates PinMark). |
48
+
49
+ Note ids come from `list_notes` / `search_notes` / `create_note`. Content is Markdown.
50
+
51
+ ## Behavior notes
52
+
53
+ - **Non-disruptive**: every operation reaches PinMark without stealing focus, except `open_note`
54
+ (and `*_open`), which intentionally brings the note to the foreground.
55
+ - **Pro features**: custom note colors (non-preset) and a few limits are enforced by the app;
56
+ if a call hits one, the tool returns a clear error (e.g. `custom_color_requires_pro`).
57
+ - **Trash safety**: `delete_note` is a recoverable trash move; there is no permanent delete.
58
+ Other mutations don't act on trashed notes.
59
+
60
+ ## Development
61
+
62
+ ```bash
63
+ npm install
64
+ npm run build # tsc -> dist/
65
+ npm test # vitest (invocation-layer unit tests)
66
+ ```
67
+
68
+ ## Publishing
69
+
70
+ This is a **public** scoped package. `publishConfig.access` is set to `public` in
71
+ `package.json`, so `npm publish` does not need an explicit `--access public` flag.
72
+
73
+ First-time setup:
74
+
75
+ - An npm account, and membership in the `pinmark` org (the `@pinmark` scope). Create the org at
76
+ npmjs.com if it doesn't exist yet — a free org can publish unlimited public packages.
77
+
78
+ Each release:
79
+
80
+ ```bash
81
+ npm login # or use an automation token in CI
82
+ npm publish # prepack runs the build; add --otp=<code> if 2FA is on
83
+ npm view @pinmark/mcp version # verify the published version
84
+ npx -y @pinmark/mcp # smoke test: should start the stdio server
85
+ ```
86
+
87
+ `npx -y @pinmark/mcp` always pulls the latest published version, so keep the published version's
88
+ CLI protocol compatible with the PinMark app that's shipping. Bump the package version whenever
89
+ the `pinmark` CLI contract changes.
90
+
91
+ ## License
92
+
93
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+ // pinmark-mcp entry: resolve the CLI, warm up the app, and serve MCP over stdio.
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { resolveCliPath, runPinmark, PinmarkError } from "./pinmark.js";
5
+ import { createServer } from "./server.js";
6
+ async function main() {
7
+ // Layer 1 — CLI health check: is the `pinmark` binary installed?
8
+ let cliPath;
9
+ try {
10
+ cliPath = await resolveCliPath();
11
+ }
12
+ catch (error) {
13
+ const message = error instanceof PinmarkError ? error.message : String(error);
14
+ process.stderr.write(`pinmark-mcp: ${message}\n`);
15
+ process.exit(1);
16
+ }
17
+ // Layer 2 — App warm-up (best-effort): a non-activating ping so the first real tool call hits
18
+ // a warm app. Never blocks startup; if the app isn't running it will cold-launch on first use.
19
+ void runPinmark(cliPath, { kind: "ping" }, 6000).catch(() => {
20
+ process.stderr.write("pinmark-mcp: PinMark not responding to warm-up ping; it will launch on first use.\n");
21
+ });
22
+ const server = createServer(cliPath);
23
+ await server.connect(new StdioServerTransport());
24
+ process.stderr.write("pinmark-mcp: ready (stdio)\n");
25
+ }
26
+ main().catch((error) => {
27
+ process.stderr.write(`pinmark-mcp: fatal: ${error instanceof Error ? error.message : String(error)}\n`);
28
+ process.exit(1);
29
+ });
@@ -0,0 +1,194 @@
1
+ // Invocation layer: translate an MCP tool call into a `pinmark` CLI invocation and run it.
2
+ // Thin adapter — the CLI + PinMark app remain the single source of truth for validation,
3
+ // Pro gating, and note storage. See doc/requirement/pinmark-mcp-*.md.
4
+ import { spawn } from "node:child_process";
5
+ import { existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ /** Query commands accept `--format json`; mutation commands reject it and always return JSON. */
9
+ const QUERY_COMMANDS = new Set(["list", "search", "show", "open"]);
10
+ /**
11
+ * Builds the CLI invocation for a command. Hard rules (kept in sync with the CLI contract):
12
+ * - No shell: caller uses spawn/execFile with this argv array.
13
+ * - Note body (create/append/update) goes via `--from-file -` + stdin, never argv (length /
14
+ * escaping / process-list exposure).
15
+ * - `--format json` only for query commands; mutation commands must not receive it.
16
+ * - `--no-activate` is appended to every call (wake layer); only `open` / `--open` activate the
17
+ * app at the execution layer, which `--no-activate` does not suppress.
18
+ */
19
+ export function buildInvocation(cmd) {
20
+ const argv = [];
21
+ let stdin;
22
+ const pushBody = (content) => {
23
+ if (content !== undefined) {
24
+ argv.push("--from-file", "-");
25
+ stdin = content;
26
+ }
27
+ };
28
+ switch (cmd.kind) {
29
+ case "create":
30
+ argv.push("create", "--type", "text");
31
+ if (cmd.title !== undefined)
32
+ argv.push("--title", cmd.title);
33
+ if (cmd.color !== undefined)
34
+ argv.push("--color", cmd.color);
35
+ if (cmd.open)
36
+ argv.push("--open");
37
+ pushBody(cmd.content);
38
+ break;
39
+ case "append":
40
+ argv.push("append", cmd.noteId);
41
+ if (cmd.separator !== undefined)
42
+ argv.push("--separator", cmd.separator);
43
+ if (cmd.open)
44
+ argv.push("--open");
45
+ pushBody(cmd.content);
46
+ break;
47
+ case "update":
48
+ argv.push("update", cmd.noteId);
49
+ if (cmd.title !== undefined)
50
+ argv.push("--title", cmd.title);
51
+ if (cmd.color !== undefined)
52
+ argv.push("--color", cmd.color);
53
+ if (cmd.caption !== undefined)
54
+ argv.push("--caption", cmd.caption);
55
+ pushBody(cmd.content);
56
+ break;
57
+ case "list":
58
+ argv.push("list");
59
+ if (cmd.type !== undefined)
60
+ argv.push("--type", cmd.type);
61
+ if (cmd.limit !== undefined)
62
+ argv.push("--limit", String(cmd.limit));
63
+ if (cmd.includeTrash)
64
+ argv.push("--include-trash");
65
+ break;
66
+ case "search":
67
+ argv.push("search", cmd.query);
68
+ if (cmd.type !== undefined)
69
+ argv.push("--type", cmd.type);
70
+ if (cmd.limit !== undefined)
71
+ argv.push("--limit", String(cmd.limit));
72
+ if (cmd.includeTrash)
73
+ argv.push("--include-trash");
74
+ break;
75
+ case "get":
76
+ argv.push("show", cmd.noteId);
77
+ break;
78
+ case "open":
79
+ argv.push("open", cmd.noteId);
80
+ break;
81
+ case "delete":
82
+ case "restore":
83
+ case "pin":
84
+ case "unpin":
85
+ argv.push(cmd.kind, cmd.noteId);
86
+ break;
87
+ case "ping":
88
+ argv.push("ping");
89
+ break;
90
+ }
91
+ if (QUERY_COMMANDS.has(argv[0])) {
92
+ argv.push("--format", "json");
93
+ }
94
+ argv.push("--no-activate");
95
+ return { argv, stdin };
96
+ }
97
+ /** Error carrying the CLI/app response code so the MCP layer can surface a stable code. */
98
+ export class PinmarkError extends Error {
99
+ code;
100
+ constructor(code, message) {
101
+ super(message);
102
+ this.code = code;
103
+ this.name = "PinmarkError";
104
+ }
105
+ }
106
+ /** Resolves the installed `pinmark` binary: prefer ~/.local/bin, else PATH. */
107
+ export async function resolveCliPath() {
108
+ const preferred = join(homedir(), ".local", "bin", "pinmark");
109
+ if (existsSync(preferred))
110
+ return preferred;
111
+ const fromPath = await which("pinmark");
112
+ if (fromPath)
113
+ return fromPath;
114
+ throw new PinmarkError("cli_not_installed", "The `pinmark` CLI was not found. Install it from PinMark → Settings → Command Line Tool.");
115
+ }
116
+ function which(bin) {
117
+ return new Promise((resolve) => {
118
+ const child = spawn("/usr/bin/which", [bin], { stdio: ["ignore", "pipe", "ignore"] });
119
+ let out = "";
120
+ child.stdout.on("data", (d) => (out += d));
121
+ child.on("error", () => resolve(undefined));
122
+ child.on("close", (code) => resolve(code === 0 && out.trim() ? out.trim() : undefined));
123
+ });
124
+ }
125
+ export function execFilePinmark(cliPath, argv, stdin, timeoutMs) {
126
+ return new Promise((resolve) => {
127
+ const child = spawn(cliPath, argv, { stdio: ["pipe", "pipe", "pipe"] });
128
+ let stdout = "";
129
+ let stderr = "";
130
+ let timedOut = false;
131
+ const timer = setTimeout(() => {
132
+ timedOut = true;
133
+ child.kill("SIGKILL");
134
+ }, timeoutMs);
135
+ child.stdin.on("error", () => { });
136
+ child.stdout.on("error", () => { });
137
+ child.stderr.on("error", () => { });
138
+ child.stdout.on("data", (d) => (stdout += d));
139
+ child.stderr.on("data", (d) => (stderr += d));
140
+ child.on("error", (e) => {
141
+ clearTimeout(timer);
142
+ resolve({ stdout, stderr: stderr || String(e), code: 127 });
143
+ });
144
+ child.on("close", (code) => {
145
+ clearTimeout(timer);
146
+ resolve({ stdout, stderr, code: code ?? (timedOut ? 124 : 1), timedOut });
147
+ });
148
+ try {
149
+ if (stdin !== undefined)
150
+ child.stdin.write(stdin);
151
+ child.stdin.end();
152
+ }
153
+ catch {
154
+ // The child may have exited before consuming stdin; stream error handlers above keep the
155
+ // server alive and the close/error events still produce the command result.
156
+ }
157
+ });
158
+ }
159
+ function codeFromExit(code, stderr = "") {
160
+ switch (code) {
161
+ case 2: return "invalid_request";
162
+ case 3: return /timed out|timeout/i.test(stderr) ? "timeout" : "app_not_available";
163
+ case 4: return "note_not_found";
164
+ case 5: return "permission_denied";
165
+ case 124: return "timeout";
166
+ default: return "operation_failed";
167
+ }
168
+ }
169
+ /**
170
+ * Runs a command and returns the response payload. Throws `PinmarkError` on failure.
171
+ * The CLI prints the full JSON response to stdout for JSON commands even on app errors (then
172
+ * exits non-zero), so we parse stdout first and fall back to stderr + exit code for usage errors.
173
+ */
174
+ export async function runPinmark(cliPath, cmd, timeoutMs = 20000) {
175
+ const { argv, stdin } = buildInvocation(cmd);
176
+ const { stdout, stderr, code, timedOut } = await execFilePinmark(cliPath, argv, stdin, timeoutMs);
177
+ let parsed;
178
+ try {
179
+ parsed = stdout.trim() ? JSON.parse(stdout) : undefined;
180
+ }
181
+ catch {
182
+ parsed = undefined;
183
+ }
184
+ if (parsed && typeof parsed === "object" && "ok" in parsed) {
185
+ const response = parsed;
186
+ if (response.ok)
187
+ return response.payload ?? {};
188
+ throw new PinmarkError(response.error?.code ?? "operation_failed", response.error?.message ?? "PinMark command failed.");
189
+ }
190
+ if (code === 0)
191
+ return {};
192
+ const message = (stderr || stdout || "PinMark command failed.").trim();
193
+ throw new PinmarkError(timedOut ? "timeout" : codeFromExit(code, message), message);
194
+ }
package/dist/server.js ADDED
@@ -0,0 +1,107 @@
1
+ // MCP server: registers PinMark note tools and maps each to a `pinmark` CLI invocation.
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { z } from "zod";
4
+ import { runPinmark, PinmarkError } from "./pinmark.js";
5
+ function ok(payload) {
6
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
7
+ }
8
+ function fail(error) {
9
+ if (error instanceof PinmarkError) {
10
+ return { content: [{ type: "text", text: `Error [${error.code}]: ${error.message}` }], isError: true };
11
+ }
12
+ const message = error instanceof Error ? error.message : String(error);
13
+ return { content: [{ type: "text", text: `Error: ${message}` }], isError: true };
14
+ }
15
+ const noteIdSchema = z.string().describe("The note's UUID, as returned by list_notes / search_notes / create_note.");
16
+ export function createServer(cliPath) {
17
+ const server = new McpServer({ name: "pinmark-mcp", version: "0.1.0" });
18
+ const run = async (cmd) => {
19
+ try {
20
+ return ok(await runPinmark(cliPath, cmd));
21
+ }
22
+ catch (error) {
23
+ return fail(error);
24
+ }
25
+ };
26
+ server.registerTool("create_note", {
27
+ title: "Create note",
28
+ description: "Create a new text note on the desktop. Content is Markdown. Returns the new note's id.",
29
+ inputSchema: {
30
+ content: z.string().optional().describe("Markdown body of the note."),
31
+ title: z.string().optional().describe("Optional title; if omitted PinMark derives one from the content."),
32
+ color: z.string().optional().describe("Preset color name (yellow, blue, green, pink, purple, gray, white) or hex; custom colors require Pro."),
33
+ open: z.boolean().optional().describe("Bring the new note to the foreground after creating it."),
34
+ },
35
+ }, async (args) => run({ kind: "create", ...args }));
36
+ server.registerTool("append_to_note", {
37
+ title: "Append to note",
38
+ description: "Append Markdown text to an existing text note (e.g. to maintain a running log).",
39
+ inputSchema: {
40
+ noteId: noteIdSchema,
41
+ content: z.string().describe("Markdown text to append."),
42
+ separator: z.string().optional().describe("Separator inserted before the new text when the note is non-empty. Defaults to a blank line."),
43
+ open: z.boolean().optional().describe("Bring the note to the foreground after appending."),
44
+ },
45
+ }, async (args) => run({ kind: "append", ...args }));
46
+ server.registerTool("update_note", {
47
+ title: "Update note",
48
+ description: "Update an existing note. Provide at least one of content (text notes), title, color (text notes), or caption (image notes).",
49
+ inputSchema: {
50
+ noteId: noteIdSchema,
51
+ content: z.string().optional().describe("Replace the body (text notes only)."),
52
+ title: z.string().optional().describe("Set the title (text or image notes)."),
53
+ color: z.string().optional().describe("Background color (text notes only); custom colors require Pro."),
54
+ caption: z.string().optional().describe("Set the caption (image notes only)."),
55
+ },
56
+ }, async (args) => run({ kind: "update", ...args }));
57
+ server.registerTool("get_note", {
58
+ title: "Get note",
59
+ description: "Fetch one note by id, including its full content / caption.",
60
+ inputSchema: { noteId: noteIdSchema },
61
+ }, async (args) => run({ kind: "get", ...args }));
62
+ server.registerTool("list_notes", {
63
+ title: "List notes",
64
+ description: "List notes. Excludes trash unless includeTrash is set.",
65
+ inputSchema: {
66
+ type: z.enum(["text", "image"]).optional().describe("Filter by note type."),
67
+ limit: z.number().int().min(1).max(200).optional().describe("Max results (1-200, default 20)."),
68
+ includeTrash: z.boolean().optional().describe("Include notes in the trash."),
69
+ },
70
+ }, async (args) => run({ kind: "list", ...args }));
71
+ server.registerTool("search_notes", {
72
+ title: "Search notes",
73
+ description: "Search notes by title, content, caption, or image filename.",
74
+ inputSchema: {
75
+ query: z.string().describe("Search query."),
76
+ type: z.enum(["text", "image"]).optional().describe("Filter by note type."),
77
+ limit: z.number().int().min(1).max(200).optional().describe("Max results (1-200, default 20)."),
78
+ includeTrash: z.boolean().optional().describe("Include notes in the trash."),
79
+ },
80
+ }, async (args) => run({ kind: "search", ...args }));
81
+ server.registerTool("delete_note", {
82
+ title: "Delete note",
83
+ description: "Move a note to the trash. Recoverable with restore_note; not a permanent delete.",
84
+ inputSchema: { noteId: noteIdSchema },
85
+ }, async (args) => run({ kind: "delete", ...args }));
86
+ server.registerTool("restore_note", {
87
+ title: "Restore note",
88
+ description: "Restore a note from the trash.",
89
+ inputSchema: { noteId: noteIdSchema },
90
+ }, async (args) => run({ kind: "restore", ...args }));
91
+ server.registerTool("pin_note", {
92
+ title: "Pin note",
93
+ description: "Pin a note so it stays above others.",
94
+ inputSchema: { noteId: noteIdSchema },
95
+ }, async (args) => run({ kind: "pin", ...args }));
96
+ server.registerTool("unpin_note", {
97
+ title: "Unpin note",
98
+ description: "Unpin a note.",
99
+ inputSchema: { noteId: noteIdSchema },
100
+ }, async (args) => run({ kind: "unpin", ...args }));
101
+ server.registerTool("open_note", {
102
+ title: "Open note",
103
+ description: "Bring a note to the foreground on the desktop (this one intentionally activates PinMark).",
104
+ inputSchema: { noteId: noteIdSchema },
105
+ }, async (args) => run({ kind: "open", ...args }));
106
+ return server;
107
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@pinmark/mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for PinMark — let AI agents create, search, and organize notes on your Mac, fully local.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "pinmark-mcp": "dist/index.js"
9
+ },
10
+ "files": ["dist", "README.md"],
11
+ "publishConfig": { "access": "public" },
12
+ "engines": { "node": ">=18" },
13
+ "scripts": {
14
+ "build": "tsc -p tsconfig.json",
15
+ "prepack": "npm run build",
16
+ "test": "vitest run",
17
+ "test:watch": "vitest",
18
+ "start": "node dist/index.js"
19
+ },
20
+ "dependencies": {
21
+ "@modelcontextprotocol/sdk": "^1.0.0",
22
+ "zod": "^3.23.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^22.0.0",
26
+ "typescript": "^5.5.0",
27
+ "vitest": "^3.2.4"
28
+ }
29
+ }