standup-mr 0.4.1 → 0.5.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 CHANGED
@@ -4,6 +4,25 @@ All notable changes to standup-mr are recorded here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
5
  [semantic versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.5.0] - 2026-09-02
8
+
9
+ ### Added
10
+
11
+ - **`post_standup_note`** — posts a finished note to a Slack or Discord
12
+ webhook, so an MCP client can deliver the note it just wrote instead of
13
+ handing it back as text. The webhook URL comes from `STANDUP_WEBHOOK_URL`
14
+ and is never a tool argument: anyone holding that URL can post to the
15
+ channel, which makes it a credential. The payload shape is inferred from the
16
+ URL host, so `kind` is only needed when a proxy hides it. A missing URL, an
17
+ unrecognisable host, or a webhook that rejects the message is reported as an
18
+ error — a note is never announced as sent when it was not — and the error
19
+ text never repeats the URL.
20
+ - **`get_note_instructions`** — returns the note-writing rules the Claude Code
21
+ skill carries, so a non-Claude assistant can write the note the same way
22
+ instead of inventing a format. Same content as `standup instructions`.
23
+
24
+ Both were already in the CLI. Only the MCP server was missing them.
25
+
7
26
  ## [0.4.1] - 2026-09-02
8
27
 
9
28
  ### Changed
package/README.md CHANGED
@@ -117,9 +117,20 @@ if a snippet above doesn't work, check [Cursor's MCP
117
117
  docs](https://docs.cursor.com/context/mcp) or Codex's own config
118
118
  documentation for the current format rather than trusting this file blindly.
119
119
 
120
- The tool only returns data; the assistant still needs the note-writing rules
121
- that the Claude Code skill carries. Paste them into whatever instructions
122
- file your assistant reads (e.g. `AGENTS.md`):
120
+ The server exposes three tools:
121
+
122
+ | Tool | What it does |
123
+ |---|---|
124
+ | `get_standup_data` | Reads the provider and returns the report as JSON. Optional `provider`, `host`, `lang`. |
125
+ | `get_note_instructions` | Returns the note-writing rules, so the assistant can write the note the way the skill would. |
126
+ | `post_standup_note` | Posts a finished note to a Slack or Discord webhook. |
127
+
128
+ `post_standup_note` reads the webhook URL from `STANDUP_WEBHOOK_URL` and never
129
+ takes it as an argument — anyone holding that URL can post to the channel, so
130
+ it belongs with the tokens, not in a transcript. The payload shape is inferred
131
+ from the URL host; `kind` is only needed when a proxy hides it.
132
+
133
+ Outside MCP, the same rules are available on stdout:
123
134
 
124
135
  ```bash
125
136
  npx standup-mr instructions >> AGENTS.md
@@ -206,6 +206,20 @@ async function postWebhook(url, text, kind = "slack", fetchImpl = fetch) {
206
206
  throw new Error(`Webhook rejected the message: HTTP ${response.status}.`);
207
207
  }
208
208
  }
209
+ var WEBHOOK_HOSTS = {
210
+ "hooks.slack.com": "slack",
211
+ "discord.com": "discord",
212
+ "discordapp.com": "discord",
213
+ "ptb.discord.com": "discord",
214
+ "canary.discord.com": "discord"
215
+ };
216
+ function inferWebhookKind(url) {
217
+ try {
218
+ return WEBHOOK_HOSTS[new URL(url).hostname] ?? null;
219
+ } catch {
220
+ return null;
221
+ }
222
+ }
209
223
 
210
224
  // src/providers/base/http.ts
211
225
  function buildUrl(api, path, params) {
@@ -1133,6 +1147,37 @@ async function buildReport(provider, today, lang = "en", lookbackDays = LOOKBACK
1133
1147
  };
1134
1148
  }
1135
1149
 
1150
+ // src/manifest/manifest.ts
1151
+ import { existsSync, readFileSync } from "fs";
1152
+ import { dirname, join } from "path";
1153
+ import { fileURLToPath } from "url";
1154
+ function findPackageRoot(startDir) {
1155
+ let dir = startDir;
1156
+ while (!existsSync(join(dir, "package.json"))) {
1157
+ const parent = dirname(dir);
1158
+ if (parent === dir) {
1159
+ throw new Error(`Could not locate package.json above ${startDir}`);
1160
+ }
1161
+ dir = parent;
1162
+ }
1163
+ return dir;
1164
+ }
1165
+
1166
+ // src/skill/skill.ts
1167
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
1168
+ import { dirname as dirname2, join as join2 } from "path";
1169
+ import { fileURLToPath as fileURLToPath2 } from "url";
1170
+ function readStandupSkillBody() {
1171
+ const moduleDir = dirname2(fileURLToPath2(import.meta.url));
1172
+ const skillPath = join2(findPackageRoot(moduleDir), "skills", "standup", "SKILL.md");
1173
+ if (!existsSync2(skillPath)) {
1174
+ throw new Error(`Cannot find the standup skill file at ${skillPath}`);
1175
+ }
1176
+ const content = readFileSync2(skillPath, "utf8");
1177
+ const withoutFrontmatter = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
1178
+ return withoutFrontmatter.replace(/^\n+/, "");
1179
+ }
1180
+
1136
1181
  export {
1137
1182
  STALE_DAYS,
1138
1183
  isoDay,
@@ -1153,6 +1198,7 @@ export {
1153
1198
  ghHosts,
1154
1199
  ghToken,
1155
1200
  postWebhook,
1201
+ inferWebhookKind,
1156
1202
  buildUrl,
1157
1203
  ApiError,
1158
1204
  assertUsable,
@@ -1175,5 +1221,7 @@ export {
1175
1221
  chooseKind,
1176
1222
  connect,
1177
1223
  toMarkdown,
1178
- buildReport
1224
+ buildReport,
1225
+ findPackageRoot,
1226
+ readStandupSkillBody
1179
1227
  };
package/dist/cli.js CHANGED
@@ -3,14 +3,16 @@ import {
3
3
  ConfigError,
4
4
  buildReport,
5
5
  connect,
6
+ findPackageRoot,
6
7
  postWebhook,
8
+ readStandupSkillBody,
7
9
  toMarkdown
8
- } from "./chunk-2X2XEEUY.js";
10
+ } from "./chunk-S2PFIUNI.js";
9
11
 
10
12
  // src/cli/cli.ts
11
- import { existsSync as existsSync2, readFileSync as readFileSync2, realpathSync } from "fs";
12
- import { dirname as dirname2, join as join2 } from "path";
13
- import { fileURLToPath as fileURLToPath2, pathToFileURL } from "url";
13
+ import { realpathSync } from "fs";
14
+ import { dirname, join } from "path";
15
+ import { fileURLToPath, pathToFileURL } from "url";
14
16
  import { parseArgs } from "util";
15
17
 
16
18
  // src/cli/cli.constants.ts
@@ -30,43 +32,18 @@ Credentials resolve as: flag, then GITHUB_HOST / GITHUB_TOKEN (or GITLAB_HOST /
30
32
  GITLAB_TOKEN), then the gh or glab config.
31
33
  --text defaults to '-', meaning read stdin.
32
34
 
33
- standup mcp starts the stdio MCP server, exposing get_standup_data.
35
+ standup mcp starts the stdio MCP server, exposing get_standup_data,
36
+ post_standup_note and get_note_instructions.
34
37
  standup instructions prints the note-writing playbook (the standup skill body)
35
38
  to stdout, for teaching a non-Claude assistant the same rules, e.g.:
36
39
  npx standup-mr instructions >> AGENTS.md`;
37
40
 
38
- // src/manifest/manifest.ts
39
- import { existsSync, readFileSync } from "fs";
40
- import { dirname, join } from "path";
41
- import { fileURLToPath } from "url";
42
- function findPackageRoot(startDir) {
43
- let dir = startDir;
44
- while (!existsSync(join(dir, "package.json"))) {
45
- const parent = dirname(dir);
46
- if (parent === dir) {
47
- throw new Error(`Could not locate package.json above ${startDir}`);
48
- }
49
- dir = parent;
50
- }
51
- return dir;
52
- }
53
-
54
41
  // src/cli/cli.ts
55
42
  async function readStdin() {
56
43
  const chunks = [];
57
44
  for await (const chunk of process.stdin) chunks.push(Buffer.from(chunk));
58
45
  return Buffer.concat(chunks).toString("utf8");
59
46
  }
60
- function readStandupSkillBody() {
61
- const moduleDir = dirname2(fileURLToPath2(import.meta.url));
62
- const skillPath = join2(findPackageRoot(moduleDir), "skills", "standup", "SKILL.md");
63
- if (!existsSync2(skillPath)) {
64
- throw new Error(`Cannot find the standup skill file at ${skillPath}`);
65
- }
66
- const content = readFileSync2(skillPath, "utf8");
67
- const withoutFrontmatter = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
68
- return withoutFrontmatter.replace(/^\n+/, "");
69
- }
70
47
  async function main(argv) {
71
48
  const [command, ...rest] = argv;
72
49
  if (!command || command === "--help" || command === "-h") {
@@ -115,8 +92,8 @@ async function main(argv) {
115
92
  return 0;
116
93
  }
117
94
  if (command === "mcp") {
118
- const moduleDir = dirname2(fileURLToPath2(import.meta.url));
119
- const serverPath = join2(findPackageRoot(moduleDir), "dist", "mcp", "server.js");
95
+ const moduleDir = dirname(fileURLToPath(import.meta.url));
96
+ const serverPath = join(findPackageRoot(moduleDir), "dist", "mcp", "server.js");
120
97
  const serverUrl = pathToFileURL(serverPath).href;
121
98
  const { main: startMcpServer } = await import(serverUrl);
122
99
  await startMcpServer();
package/dist/index.d.ts CHANGED
@@ -130,6 +130,7 @@ declare const PAYLOAD_FIELD: {
130
130
  type WebhookKind = keyof typeof PAYLOAD_FIELD;
131
131
 
132
132
  declare function postWebhook(url: string, text: string, kind?: WebhookKind, fetchImpl?: FetchLike): Promise<void>;
133
+ declare function inferWebhookKind(url: string): WebhookKind | null;
133
134
 
134
135
  interface Provider {
135
136
  readonly kind: ProviderKind;
@@ -279,6 +280,8 @@ declare function toMarkdown(report: StandupReport, lang?: string): string;
279
280
 
280
281
  declare function buildReport(provider: Provider, today: Date, lang?: string, lookbackDays?: number): Promise<StandupReport>;
281
282
 
283
+ declare function readStandupSkillBody(): string;
284
+
282
285
  declare function extractErrors(rawTrace: string, limit?: number): string[];
283
286
 
284
- export { type ActiveDay, type ActivityEvent, ApiError, type Blocker, type Bucket, ConfigError, DIAGNOSIS_UNAVAILABLE, type FetchLike, GITHUB_LABELS, GITLAB_LABELS, GitHubProvider, GitLabProvider, type Identity, type MergeRequest, type Provider, type ProviderKind, type ProviderLabels, RETRY_ATTEMPTS, RETRY_BACKOFF_MS, type Review, STALE_DAYS, type SelectOptions, type StandupReport, approvedBy, assertUsable, buildReport, buildUrl, chooseKind, classify, connect, countChangesRequested, degradable, extractErrors, ghHosts, ghToken, glabHosts, glabToken, isoDay, label, latestStateByReviewer, localAt, mapEvent, markMissingPipelines, normalizeChecks, parseGlabHosts, parseLoggedInHosts, postWebhook, previousActiveDays, repoFromUrl, resolveHost, resolveToken, sendWithRetry, toMarkdown, undiagnosed, unreachable };
287
+ export { type ActiveDay, type ActivityEvent, ApiError, type Blocker, type Bucket, ConfigError, DIAGNOSIS_UNAVAILABLE, type FetchLike, GITHUB_LABELS, GITLAB_LABELS, GitHubProvider, GitLabProvider, type Identity, type MergeRequest, type Provider, type ProviderKind, type ProviderLabels, RETRY_ATTEMPTS, RETRY_BACKOFF_MS, type Review, STALE_DAYS, type SelectOptions, type StandupReport, approvedBy, assertUsable, buildReport, buildUrl, chooseKind, classify, connect, countChangesRequested, degradable, extractErrors, ghHosts, ghToken, glabHosts, glabToken, inferWebhookKind, isoDay, label, latestStateByReviewer, localAt, mapEvent, markMissingPipelines, normalizeChecks, parseGlabHosts, parseLoggedInHosts, postWebhook, previousActiveDays, readStandupSkillBody, repoFromUrl, resolveHost, resolveToken, sendWithRetry, toMarkdown, undiagnosed, unreachable };
package/dist/index.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  ghToken,
25
25
  glabHosts,
26
26
  glabToken,
27
+ inferWebhookKind,
27
28
  isoDay,
28
29
  label,
29
30
  latestStateByReviewer,
@@ -35,6 +36,7 @@ import {
35
36
  parseLoggedInHosts,
36
37
  postWebhook,
37
38
  previousActiveDays,
39
+ readStandupSkillBody,
38
40
  repoFromUrl,
39
41
  resolveHost,
40
42
  resolveToken,
@@ -42,7 +44,7 @@ import {
42
44
  toMarkdown,
43
45
  undiagnosed,
44
46
  unreachable
45
- } from "./chunk-2X2XEEUY.js";
47
+ } from "./chunk-S2PFIUNI.js";
46
48
  export {
47
49
  ApiError,
48
50
  ConfigError,
@@ -68,6 +70,7 @@ export {
68
70
  ghToken,
69
71
  glabHosts,
70
72
  glabToken,
73
+ inferWebhookKind,
71
74
  isoDay,
72
75
  label,
73
76
  latestStateByReviewer,
@@ -79,6 +82,7 @@ export {
79
82
  parseLoggedInHosts,
80
83
  postWebhook,
81
84
  previousActiveDays,
85
+ readStandupSkillBody,
82
86
  repoFromUrl,
83
87
  resolveHost,
84
88
  resolveToken,
@@ -31,6 +31,41 @@ function packageVersion(moduleUrl) {
31
31
  return manifest.version;
32
32
  }
33
33
 
34
+ // src/notify/notify.constants.ts
35
+ var PAYLOAD_FIELD = { slack: "text", discord: "content" };
36
+
37
+ // src/notify/notify.ts
38
+ async function postWebhook(url, text2, kind = "slack", fetchImpl = fetch) {
39
+ const field = PAYLOAD_FIELD[kind];
40
+ if (!field) {
41
+ throw new Error(
42
+ `Unknown webhook kind "${kind}". Use one of: ${Object.keys(PAYLOAD_FIELD).join(", ")}.`
43
+ );
44
+ }
45
+ const response = await fetchImpl(url, {
46
+ method: "POST",
47
+ headers: { "Content-Type": "application/json" },
48
+ body: JSON.stringify({ [field]: text2 })
49
+ });
50
+ if (!response.ok) {
51
+ throw new Error(`Webhook rejected the message: HTTP ${response.status}.`);
52
+ }
53
+ }
54
+ var WEBHOOK_HOSTS = {
55
+ "hooks.slack.com": "slack",
56
+ "discord.com": "discord",
57
+ "discordapp.com": "discord",
58
+ "ptb.discord.com": "discord",
59
+ "canary.discord.com": "discord"
60
+ };
61
+ function inferWebhookKind(url) {
62
+ try {
63
+ return WEBHOOK_HOSTS[new URL(url).hostname] ?? null;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
34
69
  // src/config/config.ts
35
70
  import { execFileSync } from "child_process";
36
71
 
@@ -1002,6 +1037,21 @@ function connect(options = {}) {
1002
1037
  return new GitLabProvider(host, token);
1003
1038
  }
1004
1039
 
1040
+ // src/skill/skill.ts
1041
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
1042
+ import { dirname as dirname2, join as join2 } from "path";
1043
+ import { fileURLToPath as fileURLToPath2 } from "url";
1044
+ function readStandupSkillBody() {
1045
+ const moduleDir = dirname2(fileURLToPath2(import.meta.url));
1046
+ const skillPath = join2(findPackageRoot(moduleDir), "skills", "standup", "SKILL.md");
1047
+ if (!existsSync2(skillPath)) {
1048
+ throw new Error(`Cannot find the standup skill file at ${skillPath}`);
1049
+ }
1050
+ const content = readFileSync2(skillPath, "utf8");
1051
+ const withoutFrontmatter = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
1052
+ return withoutFrontmatter.replace(/^\n+/, "");
1053
+ }
1054
+
1005
1055
  // src/report/report.constants.ts
1006
1056
  var LOOKBACK_DAYS = 21;
1007
1057
 
@@ -1064,6 +1114,51 @@ async function runStandupTool(args, collector = collect) {
1064
1114
  });
1065
1115
  return { content: [{ type: "text", text: JSON.stringify(report) }] };
1066
1116
  }
1117
+ var WEBHOOK_URL_ENV = "STANDUP_WEBHOOK_URL";
1118
+ var POST_TOOL_DESCRIPTION = `Posts a finished standup note to a chat webhook. This one has a side effect: it sends a message other people will see, so only call it on a note the user has agreed to send.
1119
+
1120
+ The webhook URL is read from ${WEBHOOK_URL_ENV}, never from an argument \u2014 a webhook URL is a credential, since anyone holding it can post to the channel. Slack and Discord payload shapes are supported; the shape is inferred from the URL host, and \`kind\` is only needed for a proxied or self-hosted endpoint whose host gives nothing away. A missing URL, an unrecognised host, or a webhook that rejects the message comes back as an error rather than a silent success.`;
1121
+ var POST_TOOL_SCHEMA = {
1122
+ text: z.string().min(1).describe(
1123
+ "The note to post, as the chat should render it. Slack and Discord both take Markdown, so send the written note rather than the raw JSON from get_standup_data."
1124
+ ),
1125
+ kind: z.enum(["slack", "discord"]).optional().describe(
1126
+ 'Which payload shape to send: slack posts {"text"}, discord posts {"content"}. Omit it \u2014 the shape is inferred from the webhook host. Pass it only when the host is not recognisable, e.g. a proxy in front of the real webhook.'
1127
+ )
1128
+ };
1129
+ var INSTRUCTIONS_TOOL_DESCRIPTION = "Returns the note-writing rules \u2014 the playbook for turning get_standup_data output into a standup note a person would actually say out loud: how to group the previous day by theme, how to derive today from open merge requests, and how to report a blocker from its job log. Read-only, no arguments, no network. Call it once before writing the first note; the rules do not change between calls.";
1130
+ function text(body) {
1131
+ return { content: [{ type: "text", text: body }] };
1132
+ }
1133
+ function failure(body) {
1134
+ return { content: [{ type: "text", text: body }], isError: true };
1135
+ }
1136
+ async function runPostTool(args, deps = {}) {
1137
+ const env = deps.env ?? process.env;
1138
+ const post = deps.post ?? postWebhook;
1139
+ const url = env[WEBHOOK_URL_ENV];
1140
+ if (!url) {
1141
+ return failure(
1142
+ `No webhook configured. Set ${WEBHOOK_URL_ENV} to the Slack or Discord webhook URL; it is deliberately not accepted as a tool argument.`
1143
+ );
1144
+ }
1145
+ const kind = args.kind ?? inferWebhookKind(url);
1146
+ if (!kind) {
1147
+ return failure(
1148
+ `Could not tell from the ${WEBHOOK_URL_ENV} host whether this is a Slack or a Discord webhook. Pass kind explicitly.`
1149
+ );
1150
+ }
1151
+ try {
1152
+ await post(url, args.text, kind);
1153
+ } catch (cause) {
1154
+ const detail = cause instanceof Error ? cause.message : String(cause);
1155
+ return failure(`The ${kind} webhook did not accept the note: ${detail}`);
1156
+ }
1157
+ return text(`Posted the note to the configured ${kind} webhook.`);
1158
+ }
1159
+ async function runInstructionsTool() {
1160
+ return text(readStandupSkillBody());
1161
+ }
1067
1162
  async function main() {
1068
1163
  const server = new McpServer({ name: "standup-mr", version: packageVersion(import.meta.url) });
1069
1164
  server.tool(
@@ -1072,6 +1167,18 @@ async function main() {
1072
1167
  STANDUP_TOOL_SCHEMA,
1073
1168
  async (args) => await runStandupTool(args)
1074
1169
  );
1170
+ server.tool(
1171
+ "post_standup_note",
1172
+ POST_TOOL_DESCRIPTION,
1173
+ POST_TOOL_SCHEMA,
1174
+ async (args) => await runPostTool(args)
1175
+ );
1176
+ server.tool(
1177
+ "get_note_instructions",
1178
+ INSTRUCTIONS_TOOL_DESCRIPTION,
1179
+ {},
1180
+ async () => await runInstructionsTool()
1181
+ );
1075
1182
  await server.connect(new StdioServerTransport());
1076
1183
  }
1077
1184
  var entry = process.argv[1];
@@ -1089,9 +1196,15 @@ if (entryUrl && import.meta.url === entryUrl) {
1089
1196
  });
1090
1197
  }
1091
1198
  export {
1199
+ INSTRUCTIONS_TOOL_DESCRIPTION,
1200
+ POST_TOOL_DESCRIPTION,
1201
+ POST_TOOL_SCHEMA,
1092
1202
  STANDUP_TOOL_DESCRIPTION,
1093
1203
  STANDUP_TOOL_SCHEMA,
1204
+ WEBHOOK_URL_ENV,
1094
1205
  collect,
1095
1206
  main,
1207
+ runInstructionsTool,
1208
+ runPostTool,
1096
1209
  runStandupTool
1097
1210
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "standup-mr",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "mcpName": "io.github.Jubstaaa/standup-mr",
5
5
  "description": "Standup notes from merge request state, not commit logs.",
6
6
  "keywords": [