shipnoted-mcp 0.0.0-stage → 0.2.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 (3) hide show
  1. package/README.md +12 -2
  2. package/index.mjs +143 -0
  3. package/package.json +8 -4
package/README.md CHANGED
@@ -1,3 +1,13 @@
1
- # Temporary Holding Version
1
+ # shipnoted-mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Let your coding agent draft and publish release notes, and read what your users are asking for.
4
+
5
+ ```sh
6
+ claude mcp add shipnoted \
7
+ -e SHIPNOTED_API_KEY=snk_your_key \
8
+ -e SHIPNOTED_URL=https://shipnoted.com \
9
+ -e SHIPNOTED_PROJECT=your-slug \
10
+ -- npx -y shipnoted-mcp
11
+ ```
12
+
13
+ Tools: `list_releases`, `draft_release`, `publish_release`, `top_feedback`, `set_feedback_status`. Needs a Pro or Team plan (API keys).
package/index.mjs ADDED
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env node
2
+ // Shipnoted MCP server. Speaks Model Context Protocol over stdio (newline-delimited JSON-RPC 2.0) with no dependencies.
3
+ // Env: SHIPNOTED_API_KEY (snk_...), SHIPNOTED_URL (default https://shipnoted.com), SHIPNOTED_PROJECT (your project slug).
4
+ import readline from "node:readline"
5
+
6
+ const KEY = process.env.SHIPNOTED_API_KEY ?? ""
7
+ const URL_ = (process.env.SHIPNOTED_URL ?? "https://shipnoted.com").replace(/\/$/, "")
8
+ const PROJECT = process.env.SHIPNOTED_PROJECT ?? ""
9
+ const PROTOCOL = "2025-06-18"
10
+
11
+ async function call(method, path, body) {
12
+ if (!KEY) throw new Error("SHIPNOTED_API_KEY is not set. Create a key in your Shipnoted dashboard under Install & connect.")
13
+ if (!PROJECT) throw new Error("SHIPNOTED_PROJECT is not set.")
14
+ const res = await fetch(`${URL_}/api/v1/${encodeURIComponent(PROJECT)}${path}`, {
15
+ method,
16
+ headers: { Authorization: `Bearer ${KEY}`, ...(body ? { "content-type": "application/json" } : {}) },
17
+ body: body ? JSON.stringify(body) : undefined,
18
+ })
19
+ const data = await res.json().catch(() => ({}))
20
+ if (!res.ok || data.ok === false) throw new Error(data.error ?? `Shipnoted returned ${res.status}`)
21
+ return data
22
+ }
23
+
24
+ const TOOLS = [
25
+ {
26
+ name: "list_releases",
27
+ description: "List this project's recent releases, newest first, with their status (draft, scheduled, published).",
28
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
29
+ run: async () => (await call("GET", "/releases")).releases.slice(0, 20).map((r) => `${r.status.toUpperCase()} ${r.version || "-"} ${r.title} (${r.id})`).join("\n") || "No releases yet.",
30
+ },
31
+ {
32
+ name: "draft_release",
33
+ description: "Write a DRAFT release from a list of changes (pull request titles or commit messages). The draft is not published; review it in the dashboard or publish it with publish_release.",
34
+ inputSchema: { type: "object", properties: { changes: { type: "array", items: { type: "string" }, description: "One entry per change, e.g. 'feat: dark mode'" } }, required: ["changes"], additionalProperties: false },
35
+ run: async ({ changes }) => {
36
+ const r = await call("POST", "/draft", { items: (changes ?? []).map((title) => ({ title: String(title) })) })
37
+ return `Draft created (${r.source}). Release id: ${r.releaseId}. Review it in the dashboard before publishing.`
38
+ },
39
+ },
40
+ {
41
+ name: "publish_release",
42
+ description: "Create and publish a release immediately. Set email=true to email confirmed subscribers. Only do this when the user has approved the text.",
43
+ inputSchema: { type: "object", properties: { title: { type: "string" }, body: { type: "string", description: "Markdown" }, version: { type: "string" }, tags: { type: "array", items: { type: "string", enum: ["new", "improved", "fixed", "breaking"] } }, email: { type: "boolean" } }, required: ["title", "body"], additionalProperties: false },
44
+ run: async (a) => {
45
+ const r = await call("POST", "/releases", { ...a, publish: true })
46
+ return `Published: ${r.release.url}${r.emailed ? ` (${r.emailed} subscribers emailed)` : ""}`
47
+ },
48
+ },
49
+ {
50
+ name: "top_feedback",
51
+ description: "List the most-voted open feedback requests, to decide what to build next.",
52
+ inputSchema: { type: "object", properties: { status: { type: "string", enum: ["open", "planned", "progress", "shipped", "declined", "all"] } }, additionalProperties: false },
53
+ run: async ({ status }) => {
54
+ const r = await call("GET", `/feedback?sort=top${status && status !== "all" ? `&status=${status}` : ""}`)
55
+ return r.feedback.slice(0, 15).map((f) => `${String(f.votes).padStart(4)} votes [${f.status}] ${f.title} (${f.id})`).join("\n") || "No feedback yet."
56
+ },
57
+ },
58
+ {
59
+ name: "get_request",
60
+ description: "Read one feedback request by its reference (SN-42) or id: its text, votes, revenue behind it, status, linked GitHub issues and public comments.",
61
+ inputSchema: { type: "object", properties: { ref: { type: "string", description: "SN-42 or a request id" } }, required: ["ref"], additionalProperties: false },
62
+ run: async ({ ref }) => {
63
+ const { request: r } = await call("GET", `/feedback/${encodeURIComponent(ref)}`)
64
+ return `${r.ref} [${r.status}] ${r.title}\n${r.votes} votes${r.mrrCents ? `, $${(r.mrrCents / 100).toLocaleString()}/mo behind it` : ""}\n\n${r.body || "(no description)"}${r.issues.length ? `\n\nIssues: ${r.issues.map((i) => `${i.repo}#${i.number} (${i.state})`).join(", ")}` : ""}${r.comments.length ? `\n\nComments:\n${r.comments.map((c) => `- ${c.author}${c.team ? " (team)" : ""}: ${c.body}`).join("\n")}` : ""}`
65
+ },
66
+ },
67
+ {
68
+ name: "comment_on_request",
69
+ description: "Reply to a request as the team (public), or leave an internal note only the team sees.",
70
+ inputSchema: { type: "object", properties: { ref: { type: "string" }, body: { type: "string" }, internal: { type: "boolean" } }, required: ["ref", "body"], additionalProperties: false },
71
+ run: async ({ ref, body, internal }) => {
72
+ await call("POST", `/feedback/${encodeURIComponent(ref)}/comments`, { body, internal: internal === true })
73
+ return internal ? "Internal note added." : "Reply posted."
74
+ },
75
+ },
76
+ {
77
+ name: "loop_status",
78
+ description: "How the feedback loop is doing: requests shipped, median days to ship, people told, and pull requests waiting for confirmation.",
79
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
80
+ run: async () => {
81
+ const { stats: s, suggestions } = await call("GET", "/loop")
82
+ return `Last 90 days: ${s.shipped} shipped of ${s.received} received. Median ${s.medianDaysToShip ?? "-"} days to ship. ${s.notified} people told.\nNext release draft holds ${s.waiting.draftItems} merged pull requests.${suggestions.length ? `\nWaiting for confirmation:\n${suggestions.map((x) => `- PR #${x.pullRequest ?? "?"} "${x.title}" may answer "${x.request}"`).join("\n")}` : ""}`
83
+ },
84
+ },
85
+ {
86
+ name: "themes",
87
+ description: "Open requests grouped into themes (the same wish in different words), biggest by revenue then votes. Use it to decide what to build.",
88
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
89
+ run: async () => (await call("GET", "/themes")).themes.slice(0, 10).map((t) => `${String(t.votes).padStart(4)} votes${t.mrrCents ? ` $${(t.mrrCents / 100).toLocaleString()}/mo` : ""} ${t.label} (${t.requests.length} request${t.requests.length === 1 ? "" : "s"}: ${t.requests.slice(0, 3).map((q) => q.id).join(", ")})`).join("\n") || "No themes yet.",
90
+ },
91
+ {
92
+ name: "publish_draft",
93
+ description: "Publish an existing draft release by id (for example the rolling draft from merged pull requests). Linked requests become Shipped and their voters are emailed. Only do this when the user has approved the text.",
94
+ inputSchema: { type: "object", properties: { id: { type: "string" }, email: { type: "boolean", description: "Also email subscribers" } }, required: ["id"], additionalProperties: false },
95
+ run: async ({ id, email }) => {
96
+ const r = await call("POST", `/releases/${encodeURIComponent(id)}/publish`, { email: email === true })
97
+ return `Published: ${r.url}. ${r.requestsShipped} requests shipped, ${r.votersNotified} voters told${r.emailed ? `, ${r.emailed} subscribers emailed` : ""}.`
98
+ },
99
+ },
100
+ {
101
+ name: "set_feedback_status",
102
+ description: "Move a feedback request to a new status so the public roadmap stays accurate.",
103
+ inputSchema: { type: "object", properties: { id: { type: "string" }, status: { type: "string", enum: ["open", "planned", "progress", "shipped", "declined"] } }, required: ["id", "status"], additionalProperties: false },
104
+ run: async ({ id, status }) => {
105
+ await call("POST", "/feedback", { id, status })
106
+ return `Moved ${id} to ${status}.`
107
+ },
108
+ },
109
+ ]
110
+
111
+ const send = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n")
112
+
113
+ async function handle(msg) {
114
+ const { id, method, params } = msg
115
+ if (id === undefined) return // notifications need no reply
116
+ try {
117
+ if (method === "initialize") return send({ id, result: { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: "shipnoted", version: "0.2.0" } } })
118
+ if (method === "ping") return send({ id, result: {} })
119
+ if (method === "tools/list") return send({ id, result: { tools: TOOLS.map(({ run, ...t }) => t) } })
120
+ if (method === "tools/call") {
121
+ const tool = TOOLS.find((t) => t.name === params?.name)
122
+ if (!tool) return send({ id, error: { code: -32602, message: `Unknown tool: ${params?.name}` } })
123
+ try {
124
+ const text = await tool.run(params.arguments ?? {})
125
+ return send({ id, result: { content: [{ type: "text", text: String(text) }] } })
126
+ } catch (e) {
127
+ return send({ id, result: { isError: true, content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }] } })
128
+ }
129
+ }
130
+ send({ id, error: { code: -32601, message: `Method not found: ${method}` } })
131
+ } catch (e) {
132
+ send({ id, error: { code: -32603, message: e instanceof Error ? e.message : "Internal error" } })
133
+ }
134
+ }
135
+
136
+ readline.createInterface({ input: process.stdin }).on("line", (line) => {
137
+ if (!line.trim()) return
138
+ try {
139
+ handle(JSON.parse(line))
140
+ } catch {
141
+ send({ id: null, error: { code: -32700, message: "Parse error" } })
142
+ }
143
+ })
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "shipnoted-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "MCP server for Shipnoted: let your coding agent draft and publish release notes and read your users' feedback.",
5
+ "type": "module",
6
+ "bin": { "shipnoted-mcp": "./index.mjs" },
7
+ "files": ["index.mjs", "README.md"],
8
+ "engines": { "node": ">=18" },
9
+ "license": "MIT"
10
+ }