shipnoted-mcp 0.0.0-stage → 0.3.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 +12 -2
- package/index.mjs +192 -0
- package/package.json +15 -4
package/README.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# shipnoted-mcp
|
|
2
2
|
|
|
3
|
-
|
|
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: `project_info`, `top_feedback`, `search_feedback`, `segments`, `get_request`, `themes`, `loop_status`, `list_releases`, `draft_release`, `set_feedback_status`, `file_request`, `publish_release`, `publish_draft`, `update_release`, `comment_on_request`. Read-only tools are marked `readOnlyHint`, and the ones customers can see have `destructiveHint`, so clients can ask before running them. Needs a Pro or Team plan (API keys). Give the key only the scopes the agent needs (a Read only key makes every write tool fail). Docs: https://shipnoted.com/docs/mcp
|
package/index.mjs
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
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
|
+
name: "project_info",
|
|
111
|
+
description: "Which project this is: name, public address, the widget script tag, and counts of releases, requests and subscribers. A good first call to check the connection.",
|
|
112
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
113
|
+
run: async () => {
|
|
114
|
+
const { project: p } = await call("GET", "/project")
|
|
115
|
+
return `${p.name} (${p.slug}), ${p.live ? "live" : "offline"}\n${p.url}\n${p.counts.releases} releases, ${p.counts.requests} requests, ${p.counts.subscribers} subscribers\nWidget: ${p.widget}`
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
name: "search_feedback",
|
|
120
|
+
description: "Search requests by words or by customer group, for example everything the beta customers asked for. Returns the reference, votes, revenue and status of each.",
|
|
121
|
+
inputSchema: { type: "object", properties: { q: { type: "string" }, segment: { type: "string", description: 'A group such as "plan:Pro", "company:Acme" or "beta:true". See the segments tool.' }, status: { type: "string", enum: ["open", "planned", "progress", "shipped", "declined"] }, sort: { type: "string", enum: ["top", "revenue", "new"] } }, additionalProperties: false },
|
|
122
|
+
run: async ({ q, segment, status, sort }) => {
|
|
123
|
+
const p = new URLSearchParams()
|
|
124
|
+
for (const [k, v] of Object.entries({ q, segment, status, sort })) if (v) p.set(k, String(v))
|
|
125
|
+
const r = await call("GET", `/feedback${p.size ? `?${p}` : ""}`)
|
|
126
|
+
return r.feedback.slice(0, 25).map((f) => `${(f.ref || "").padEnd(7)} ${String(f.votes).padStart(4)} votes${f.mrrCents ? ` $${(f.mrrCents / 100).toLocaleString()}/mo` : ""} [${f.status}] ${f.title}`).join("\n") || "Nothing matches."
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "segments",
|
|
131
|
+
description: "The customer groups that have voted (plan, company, custom attributes like beta:true) with their votes and revenue. Use the segment values with search_feedback.",
|
|
132
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
133
|
+
run: async () => (await call("GET", "/segments")).segments.map((s) => `${s.segment} ${s.votes} votes${s.mrrCents ? `, $${(s.mrrCents / 100).toLocaleString()}/mo` : ""}`).join("\n") || "No identified customers have voted yet.",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: "file_request",
|
|
137
|
+
description: "File a new feedback request on the board, for example from a support conversation. Send a stable idempotency_key (a ticket id) so a retry never files it twice.",
|
|
138
|
+
inputSchema: { type: "object", properties: { title: { type: "string" }, body: { type: "string" }, email: { type: "string" }, name: { type: "string" }, source: { type: "string", description: "Where it came from, such as Intercom" }, idempotency_key: { type: "string" } }, required: ["title"], additionalProperties: false },
|
|
139
|
+
run: async ({ title, body, email, name, source, idempotency_key }) => {
|
|
140
|
+
const r = await call("POST", "/feedback", { title, body, email, name, source, idempotencyKey: idempotency_key })
|
|
141
|
+
return r.duplicate ? `Already filed as ${r.ref}.` : `Filed as ${r.ref}.`
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "update_release",
|
|
146
|
+
description: "Edit a release (draft or published): title, version, notes, tags, or who it is for. Changes to a published release go live at once.",
|
|
147
|
+
inputSchema: { type: "object", properties: { id: { type: "string" }, title: { type: "string" }, version: { type: "string" }, body: { type: "string", description: "Markdown" }, tags: { type: "array", items: { type: "string", enum: ["new", "improved", "fixed", "breaking"] } }, audience: { type: "string", description: 'Aim it at a group, such as "plan:pro", or "" for everyone' } }, required: ["id"], additionalProperties: false },
|
|
148
|
+
run: async ({ id, ...fields }) => {
|
|
149
|
+
const r = await call("PATCH", `/releases/${encodeURIComponent(id)}`, fields)
|
|
150
|
+
return `Updated ${r.release.title} (${r.release.status}).`
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
]
|
|
154
|
+
|
|
155
|
+
const READ_ONLY = new Set(["list_releases", "top_feedback", "get_request", "loop_status", "themes", "project_info", "search_feedback", "segments"])
|
|
156
|
+
// Tools that change what customers see or get emailed. Clients can use this to ask before running them.
|
|
157
|
+
const PUBLIC_EFFECT = new Set(["publish_release", "publish_draft", "comment_on_request", "update_release"])
|
|
158
|
+
const annotate = (name) => ({ title: name.replace(/_/g, " "), readOnlyHint: READ_ONLY.has(name), destructiveHint: PUBLIC_EFFECT.has(name), idempotentHint: READ_ONLY.has(name) || name === "set_feedback_status", openWorldHint: false })
|
|
159
|
+
|
|
160
|
+
const send = (msg) => process.stdout.write(JSON.stringify({ jsonrpc: "2.0", ...msg }) + "\n")
|
|
161
|
+
|
|
162
|
+
async function handle(msg) {
|
|
163
|
+
const { id, method, params } = msg
|
|
164
|
+
if (id === undefined) return // notifications need no reply
|
|
165
|
+
try {
|
|
166
|
+
if (method === "initialize") return send({ id, result: { protocolVersion: PROTOCOL, capabilities: { tools: {} }, serverInfo: { name: "shipnoted", version: "0.3.0" } } })
|
|
167
|
+
if (method === "ping") return send({ id, result: {} })
|
|
168
|
+
if (method === "tools/list") return send({ id, result: { tools: TOOLS.map(({ run, ...t }) => ({ ...t, annotations: annotate(t.name) })) } })
|
|
169
|
+
if (method === "tools/call") {
|
|
170
|
+
const tool = TOOLS.find((t) => t.name === params?.name)
|
|
171
|
+
if (!tool) return send({ id, error: { code: -32602, message: `Unknown tool: ${params?.name}` } })
|
|
172
|
+
try {
|
|
173
|
+
const text = await tool.run(params.arguments ?? {})
|
|
174
|
+
return send({ id, result: { content: [{ type: "text", text: String(text) }] } })
|
|
175
|
+
} catch (e) {
|
|
176
|
+
return send({ id, result: { isError: true, content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }] } })
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
send({ id, error: { code: -32601, message: `Method not found: ${method}` } })
|
|
180
|
+
} catch (e) {
|
|
181
|
+
send({ id, error: { code: -32603, message: e instanceof Error ? e.message : "Internal error" } })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
|
186
|
+
if (!line.trim()) return
|
|
187
|
+
try {
|
|
188
|
+
handle(JSON.parse(line))
|
|
189
|
+
} catch {
|
|
190
|
+
send({ id: null, error: { code: -32700, message: "Parse error" } })
|
|
191
|
+
}
|
|
192
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shipnoted-mcp",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.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": {
|
|
7
|
+
"shipnoted-mcp": "index.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"index.mjs",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT"
|
|
17
|
+
}
|