reviewzy 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.
Files changed (46) hide show
  1. package/.env.example +30 -0
  2. package/LICENSE +661 -0
  3. package/README.md +170 -0
  4. package/bin/reviewzy +7 -0
  5. package/package.json +29 -0
  6. package/src/config.ts +76 -0
  7. package/src/daemon/app.ts +159 -0
  8. package/src/daemon/main.ts +103 -0
  9. package/src/daemon/origin.ts +60 -0
  10. package/src/dashboard/auth.ts +122 -0
  11. package/src/dashboard/mount.ts +373 -0
  12. package/src/dashboard/static/components.css +1116 -0
  13. package/src/dashboard/static/dashboard.css +182 -0
  14. package/src/dashboard/static/dashboard.js +169 -0
  15. package/src/dashboard/static/fonts/OFL-JetBrainsMono.txt +93 -0
  16. package/src/dashboard/static/fonts/OFL-Onest.txt +93 -0
  17. package/src/dashboard/static/fonts/jetbrainsmono-latin-ext.woff2 +0 -0
  18. package/src/dashboard/static/fonts/jetbrainsmono-latin.woff2 +0 -0
  19. package/src/dashboard/static/fonts/onest-cyrillic.woff2 +0 -0
  20. package/src/dashboard/static/fonts/onest-latin-ext.woff2 +0 -0
  21. package/src/dashboard/static/fonts/onest-latin.woff2 +0 -0
  22. package/src/dashboard/static/htmx.min.js +2 -0
  23. package/src/dashboard/static/tokens.css +213 -0
  24. package/src/dashboard/views.tsx +1388 -0
  25. package/src/db/human-save.ts +242 -0
  26. package/src/db/migrate.ts +71 -0
  27. package/src/db/migrations.ts +149 -0
  28. package/src/db/queries.ts +425 -0
  29. package/src/db/store.ts +83 -0
  30. package/src/db/style-guide.ts +236 -0
  31. package/src/db/sweep.ts +88 -0
  32. package/src/mcp/await-approved.ts +156 -0
  33. package/src/mcp/fetch-approved.ts +132 -0
  34. package/src/mcp/file-entries.ts +219 -0
  35. package/src/mcp/list-entries.ts +107 -0
  36. package/src/mcp/mark-applied.ts +103 -0
  37. package/src/mcp/route.ts +88 -0
  38. package/src/mcp/server.ts +59 -0
  39. package/src/mcp/style-guide-resource.ts +40 -0
  40. package/src/notify.ts +142 -0
  41. package/src/shim/daemon.ts +318 -0
  42. package/src/shim/lockfile.ts +153 -0
  43. package/src/shim/main.ts +172 -0
  44. package/src/shim/semver.ts +29 -0
  45. package/src/version.ts +4 -0
  46. package/tsconfig.json +26 -0
@@ -0,0 +1,219 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { McpServer } from "@modelcontextprotocol/server";
3
+ import { z } from "zod";
4
+ import { fileEntries, type NewEntry } from "../db/queries.ts";
5
+ import type { Store } from "../db/store.ts";
6
+ import type { Notifier } from "../notify.ts";
7
+
8
+ /**
9
+ * A project slug is 1-63 chars of lowercase letters, digits, and inner hyphens. Lowercase-only is
10
+ * the load-bearing half: slugs name resource URIs (`reviewzy://projects/{slug}/style-guide`) and
11
+ * dashboard paths, and a case pair would fold into one path on a case-insensitive mount while
12
+ * sqlite holds two projects.
13
+ */
14
+ const SLUG = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
15
+
16
+ /** sha256 as 64 lowercase hex chars: the shape of `anchor_hash` and of a caller-supplied `file_hash`. */
17
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
18
+
19
+ /** `context` is free-form notes for the human; the only shape it must have is "a json object". */
20
+ const ContextJson = z.record(z.string(), z.json());
21
+
22
+ /**
23
+ * The contract's constraint fields, strict so a misspelling (`maxLen`) is refused by name instead
24
+ * of silently dropping the constraint it was meant to carry.
25
+ */
26
+ const ConstraintsJson = z.strictObject({
27
+ max_len: z.number().int().positive().optional(),
28
+ placeholders: z
29
+ .array(z.string().min(1))
30
+ .refine((items) => new Set(items).size === items.length, "placeholder names must be unique")
31
+ .optional(),
32
+ tone: z.string().optional(),
33
+ notes: z.string().optional(),
34
+ });
35
+
36
+ /**
37
+ * Held loose on purpose. The SDK's own schema check would refuse a malformed constraints with an
38
+ * isError tool result naming the tool and the field path ("entries.0.constraints"). The handler's
39
+ * refusal adds what that cannot: the entry (file and anchor) and the fix. So `context` and
40
+ * `constraints` stay untyped here and are validated below.
41
+ */
42
+ const FileEntryArgs = z.object({
43
+ repo: z.string().min(1),
44
+ file: z.string().min(1),
45
+ anchor_text: z.string().min(1),
46
+ anchor_before: z.string().optional(),
47
+ anchor_after: z.string().optional(),
48
+ agent_draft: z.string().optional(),
49
+ file_content: z.string().optional(),
50
+ file_hash: z.string().optional(),
51
+ context: z.unknown().optional(),
52
+ constraints: z.unknown().optional(),
53
+ });
54
+
55
+ const FiledOutput = z.object({
56
+ batch_id: z.string(),
57
+ results: z.array(
58
+ z.object({
59
+ id: z.string(),
60
+ status: z.enum(["draft", "approved", "applied", "rejected"]),
61
+ deduped: z.boolean(),
62
+ updated: z.boolean(),
63
+ }),
64
+ ),
65
+ dashboard_url: z.string(),
66
+ });
67
+
68
+ const sha256 = (text: string) => createHash("sha256").update(text).digest("hex");
69
+
70
+ /** The `where` of a refusal: the entry's place in the call plus enough of it to spot it in a repo. */
71
+ function entryName(index: number, file: string, anchorText: string): string {
72
+ return `entries[${index}] (${file}, anchor "${anchorText.slice(0, 40)}")`;
73
+ }
74
+
75
+ /** A refusal is an ordinary thrown Error: the SDK answers it as a tool result with `isError: true`, never as a json-rpc protocol error. */
76
+ function refuse(where: string, problem: string, fix: string): never {
77
+ throw new Error(`file_entries refused: ${where}: ${problem}. fix: ${fix}`);
78
+ }
79
+
80
+ const CONSTRAINT_FIX: Record<string, string> = {
81
+ max_len: "send constraints.max_len as an integer greater than 0",
82
+ placeholders: "send constraints.placeholders as an array of unique non-empty strings",
83
+ tone: "send constraints.tone as a string",
84
+ notes: "send constraints.notes as a string",
85
+ };
86
+
87
+ /** Turns zod's issues into the named-field refusal the contract's error row promises. */
88
+ function refuseConstraints(index: number, file: string, anchorText: string, issues: readonly z.ZodIssue[]): never {
89
+ const where = entryName(index, file, anchorText);
90
+ const problem = issues
91
+ .map((issue) => `constraints.${issue.path.join(".") || "(root)"}: ${issue.message}`)
92
+ .join("; ");
93
+ const firstField = String(issues[0]?.path[0] ?? "");
94
+ const unrecognized = issues.some((issue) => issue.code === "unrecognized_keys");
95
+ const fix = unrecognized
96
+ ? "constraints takes only max_len, placeholders, tone, and notes; drop the extra key or spell it snake_case"
97
+ : (CONSTRAINT_FIX[firstField] ?? "send constraints as an object of max_len, placeholders, tone, and notes");
98
+ return refuse(where, problem, fix);
99
+ }
100
+
101
+ /**
102
+ * One entry from wire shape to store shape: json validated at this boundary, `anchor_hash` always
103
+ * computed here, and file provenance taken from exactly one of `file_content` (the server hashes
104
+ * it) or `file_hash` (the caller already did). The server never reads a repo, so it can never
105
+ * invent this value: an entry carrying neither is refused rather than filed with a fake hash.
106
+ */
107
+ function validateEntry(index: number, raw: z.output<typeof FileEntryArgs>): NewEntry {
108
+ const where = entryName(index, raw.file, raw.anchor_text);
109
+
110
+ const context = ContextJson.safeParse(raw.context ?? {});
111
+ if (!context.success) {
112
+ refuse(
113
+ where,
114
+ `context must be a json object of free-form notes for the human (where the string renders, surrounding code), got ${JSON.stringify(
115
+ raw.context,
116
+ )?.slice(0, 60)}`,
117
+ 'send an object such as {"where": "hero button"}, or omit context entirely',
118
+ );
119
+ }
120
+
121
+ const constraints = ConstraintsJson.safeParse(raw.constraints ?? {});
122
+ if (!constraints.success) {
123
+ refuseConstraints(index, raw.file, raw.anchor_text, constraints.error.issues);
124
+ }
125
+
126
+ let fileHash: string;
127
+ if (raw.file_content !== undefined && raw.file_hash !== undefined) {
128
+ refuse(
129
+ where,
130
+ "file_content and file_hash both sent, so the server cannot tell which is authoritative",
131
+ "send file_content alone (preferred: the server hashes it) and drop file_hash",
132
+ );
133
+ } else if (raw.file_content !== undefined) {
134
+ fileHash = sha256(raw.file_content);
135
+ } else if (raw.file_hash !== undefined) {
136
+ if (!SHA256_HEX.test(raw.file_hash)) {
137
+ refuse(
138
+ where,
139
+ `file_hash must be sha256 as 64 lowercase hex characters, got "${raw.file_hash.slice(0, 20)}"`,
140
+ "send the sha256 hex of the whole file, or send file_content and let the server hash it",
141
+ );
142
+ }
143
+ fileHash = raw.file_hash;
144
+ } else {
145
+ refuse(
146
+ where,
147
+ "no file provenance: the server never reads repos, so it cannot know what the file looked like",
148
+ "send file_content (the whole file at filing time) or file_hash (its sha256), exactly one",
149
+ );
150
+ }
151
+
152
+ return {
153
+ repo: raw.repo,
154
+ file: raw.file,
155
+ anchorText: raw.anchor_text,
156
+ anchorBefore: raw.anchor_before ?? "",
157
+ anchorAfter: raw.anchor_after ?? "",
158
+ anchorHash: sha256(raw.anchor_text),
159
+ fileHash,
160
+ agentDraft: raw.agent_draft ?? null,
161
+ // null (absent or explicit null) means "the newer call does not carry this field": a draft
162
+ // re-file then keeps the stored value, per the COALESCE in src/db/queries.ts.
163
+ contextJson: raw.context == null ? null : JSON.stringify(context.data),
164
+ constraintsJson: raw.constraints == null ? null : JSON.stringify(constraints.data),
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Registers the contract's one writing tool. Every entry in one call shares the minted batch id,
170
+ * and each resolves against the identity key per the frozen re-file table: a known `draft` is
171
+ * overwritten in place, anything past `draft` comes back untouched — which is what silences a
172
+ * re-file against a rejected anchor.
173
+ */
174
+ export function registerFileEntriesTool(server: McpServer, baseUrl: string, store: Store, notifier: Notifier): void {
175
+ server.registerTool(
176
+ "file_entries",
177
+ {
178
+ title: "File entries for review",
179
+ description:
180
+ "File draft entries for a human to author or approve. Each entry names one exact string to replace: repo (git remote url preferred), file path, anchor_text, and anchor_before/anchor_after context lines. Identity is (project, repo, file, sha256(anchor_text)): re-filing a known draft overwrites the agent draft, context, and constraints in place; an entry already approved, applied, or rejected is returned untouched with its own status, and a rejected anchor stays rejected — stop proposing a turned-down line. file provenance: send exactly one of file_content (the whole file at filing time; the server hashes it) or file_hash (its sha256 hex). constraints: {max_len?, placeholders?: string[], tone?, notes?} — a save breaking max_len or dropping a placeholder is refused later, so declare what the copy must keep. Keys the schema does not list are ignored.",
181
+ inputSchema: z.object({
182
+ project: z.string().min(1),
183
+ entries: z.array(FileEntryArgs).min(1),
184
+ filed_by: z.string().min(1).optional(),
185
+ }),
186
+ outputSchema: FiledOutput,
187
+ },
188
+ async (args) => {
189
+ if (!SLUG.test(args.project)) {
190
+ refuse(
191
+ `project "${args.project}"`,
192
+ "a project slug is 1-63 chars of lowercase letters, digits, and inner hyphens",
193
+ 'lower-case the name and separate words with hyphens, e.g. "my-app"',
194
+ );
195
+ }
196
+
197
+ const entries = args.entries.map((raw, index) => validateEntry(index, raw));
198
+ const batch = fileEntries(store, args.project, args.filed_by ?? null, entries);
199
+
200
+ // The ping announces work for the human, so only a batch that changed the store fires: an
201
+ // all-dedupe no-op re-file (the agent loop re-proposing known lines) stays silent, and a
202
+ // refused call throws before reaching this line.
203
+ const changed = batch.results.filter((r) => !r.deduped || r.updated).length;
204
+ if (changed > 0) notifier.notifyBatch(args.project, batch.batchId, changed);
205
+
206
+ const output = {
207
+ batch_id: batch.batchId,
208
+ results: batch.results,
209
+ // The project-filtered list view: `/projects/{slug}` never existed as a route, so the old
210
+ // link 404ed (flagged by the task-13 browser pass).
211
+ dashboard_url: `${baseUrl}/?project=${encodeURIComponent(args.project)}`,
212
+ };
213
+ return {
214
+ content: [{ type: "text" as const, text: JSON.stringify(output) }],
215
+ structuredContent: output,
216
+ };
217
+ },
218
+ );
219
+ }
@@ -0,0 +1,107 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
+ import { z } from "zod";
3
+ import { listEntries, projectIdBySlug } from "../db/queries.ts";
4
+ import type { Store } from "../db/store.ts";
5
+
6
+ /**
7
+ * Every filter optional, `limit` bounded at the boundary rather than clamped: a caller asking for
8
+ * 201 rows gets a refusal naming the field, never a silently smaller page. The `ids` and `q`
9
+ * minimums refuse the degenerate inputs (an empty list, an empty search) instead of guessing what
10
+ * they meant.
11
+ */
12
+ const ListEntriesArgs = z.object({
13
+ project: z.string().min(1).optional(),
14
+ status: z.enum(["draft", "approved", "applied", "rejected"]).optional(),
15
+ ids: z.array(z.string().min(1)).min(1).optional(),
16
+ q: z.string().min(1).optional(),
17
+ limit: z.number().int().min(1).max(200).default(50),
18
+ cursor: z.string().min(1).optional(),
19
+ });
20
+
21
+ /** The wire row is the stored row, `context` and `constraints` kept as their stored JSON strings — no re-parse on the way out. */
22
+ const Entry = z.object({
23
+ id: z.string(),
24
+ project_id: z.string(),
25
+ batch_id: z.string(),
26
+ repo: z.string(),
27
+ file: z.string(),
28
+ anchor_text: z.string(),
29
+ anchor_before: z.string(),
30
+ anchor_after: z.string(),
31
+ anchor_hash: z.string(),
32
+ file_hash: z.string(),
33
+ agent_draft: z.string().nullable(),
34
+ human_text: z.string().nullable(),
35
+ status: z.enum(["draft", "approved", "applied", "rejected"]),
36
+ context: z.string(),
37
+ constraints: z.string(),
38
+ filed_by: z.string().nullable(),
39
+ stale_note: z.string().nullable(),
40
+ applied_hash: z.string().nullable(),
41
+ created_at: z.number(),
42
+ updated_at: z.number(),
43
+ applied_at: z.number().nullable(),
44
+ archived_at: z.number().nullable(),
45
+ });
46
+
47
+ const ListEntriesOutput = z.object({
48
+ entries: z.array(Entry),
49
+ next_cursor: z.string().optional(),
50
+ });
51
+
52
+ /** A refusal is an ordinary thrown Error: the SDK answers it as a tool result with `isError: true`, never as a json-rpc protocol error. */
53
+ function refuse(where: string, problem: string, fix: string): never {
54
+ throw new Error(`list_entries refused: ${where}: ${problem}. fix: ${fix}`);
55
+ }
56
+
57
+ /**
58
+ * Registers the contract's read tool. The project lookup and the refusal sit here, not in the
59
+ * query: reads never auto-create a project, so an unknown slug must surface as a business refusal
60
+ * naming the slug and the fix before any query runs.
61
+ */
62
+ export function registerListEntriesTool(server: McpServer, store: Store): void {
63
+ server.registerTool(
64
+ "list_entries",
65
+ {
66
+ title: "List entries for review",
67
+ description:
68
+ "List entries in the review queue, ordered by ascending entry id (ulid, i.e. filing order) — the same order a cursor walk covers. Every filter is optional and they combine with AND: project (a slug with no project is refused: read tools never create one), status (draft, approved, applied, rejected), ids (exact entry ids), q (case-insensitive substring, ASCII-only case fold: a row matches when any of file, anchor_text, agent_draft, human_text contains it), limit (default 50, max 200 — a higher value is refused), cursor (keyset pagination: pass the previous page's next_cursor to continue from after the last entry returned). next_cursor is present only when the page returned exactly limit rows, meaning more may exist; absent means the walk is exhausted — stop paging then, never craft a cursor of your own. Each entry carries its stored fields as stored: id, project_id, batch_id, repo, file, anchor_text, anchor_before, anchor_after, anchor_hash, file_hash, agent_draft, human_text, status, context, constraints, filed_by, stale_note, applied_hash, created_at, updated_at, applied_at, archived_at — with context and constraints as their stored JSON strings, not parsed objects.",
69
+ inputSchema: ListEntriesArgs,
70
+ outputSchema: ListEntriesOutput,
71
+ },
72
+ async (args) => {
73
+ let projectId: string | undefined;
74
+ if (args.project !== undefined) {
75
+ const id = projectIdBySlug(store, args.project);
76
+ if (id === null) {
77
+ refuse(
78
+ `project "${args.project}"`,
79
+ "no such project, and a read tool never creates one",
80
+ "file an entry into it with file_entries first, then list again",
81
+ );
82
+ }
83
+ projectId = id;
84
+ }
85
+
86
+ const { rows, nextCursor } = listEntries(store, {
87
+ projectId,
88
+ status: args.status,
89
+ ids: args.ids,
90
+ q: args.q,
91
+ limit: args.limit,
92
+ cursor: args.cursor,
93
+ });
94
+
95
+ // An exhausted walk carries no `next_cursor` key at all: absent is the signal to stop, so an
96
+ // empty page must never smuggle one in.
97
+ const output = {
98
+ entries: rows,
99
+ ...(nextCursor === null ? {} : { next_cursor: nextCursor }),
100
+ };
101
+ return {
102
+ content: [{ type: "text" as const, text: JSON.stringify(output) }],
103
+ structuredContent: output,
104
+ };
105
+ },
106
+ );
107
+ }
@@ -0,0 +1,103 @@
1
+ import type { McpServer } from "@modelcontextprotocol/server";
2
+ import { z } from "zod";
3
+ import { markApplied, type EntryStatus } from "../db/queries.ts";
4
+ import type { Store } from "../db/store.ts";
5
+
6
+ /** sha256 as 64 lowercase hex chars: the same shape file_entries checks on a caller-supplied `file_hash`. */
7
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
8
+
9
+ /**
10
+ * The contract's four params. `applied_hash` and `found_text` are optional, and each is refused at
11
+ * this boundary when malformed: the hash must be the sha256 hex shape, and an empty `found_text`
12
+ * says nothing about what the anchor matched instead.
13
+ */
14
+ const MarkAppliedArgs = z.object({
15
+ id: z.string().min(1),
16
+ result: z.enum(["applied", "anchor_stale"]),
17
+ applied_hash: z.string().regex(SHA256_HEX).optional(),
18
+ found_text: z.string().min(1).optional(),
19
+ });
20
+
21
+ /** The loop-close answer: the id and the resulting status, nothing more. */
22
+ const MarkAppliedOutput = z.object({
23
+ id: z.string(),
24
+ status: z.enum(["applied", "approved"]),
25
+ });
26
+
27
+ /** A refusal is an ordinary thrown Error: the SDK answers it as a tool result with `isError: true`, never as a json-rpc protocol error. */
28
+ function refuse(where: string, problem: string, fix: string): never {
29
+ throw new Error(`mark_applied refused: ${where}: ${problem}. fix: ${fix}`);
30
+ }
31
+
32
+ /**
33
+ * Registers the contract's loop-close tool: the agent reports that it applied the approved text,
34
+ * or that the anchor no longer matches. Wire shapes are validated here, the status machine lives
35
+ * in `markApplied` (src/db/queries.ts), and this layer only formats the refusals the store returns
36
+ * — naming the entry, its status, and the fix. The one rule that needs no store read, `applied_hash`
37
+ * only ever being set by result "applied", is refused here naming the field.
38
+ */
39
+ export function registerMarkAppliedTool(server: McpServer, store: Store): void {
40
+ server.registerTool(
41
+ "mark_applied",
42
+ {
43
+ title: "Report an applied entry or a stale anchor",
44
+ description:
45
+ "Report the outcome of applying one entry's approved text, closing the loop: result \"applied\" moves an approved entry to applied and stamps applied_at; result \"anchor_stale\" returns the entry to approved and records found_text (optional — what the anchor matched instead; an empty string is refused, so send the matched text or omit the field) in stale_note for re-anchoring on the dashboard. applied_hash (optional) stores the sha256 hex of the text you applied — 64 lowercase hex characters, refused at the boundary when malformed — and is only ever accepted with result \"applied\". Returns {id, status} with the resulting status. Refused, naming the entry, its status, and the fix: an unknown id; an entry in draft (inert until a human approves or rejects it — an agent can never move a draft); an entry in rejected (terminal); an applied entry reported applied again; applied_hash sent with anchor_stale. approved + anchor_stale stays approved; applied + anchor_stale returns to approved; those and approved + applied are the whole agent-side surface.",
46
+ inputSchema: MarkAppliedArgs,
47
+ outputSchema: MarkAppliedOutput,
48
+ },
49
+ async (args) => {
50
+ if (args.result === "anchor_stale" && args.applied_hash !== undefined) {
51
+ refuse(
52
+ `entry "${args.id}"`,
53
+ 'applied_hash is only ever set by result "applied", got result "anchor_stale"',
54
+ 'drop applied_hash, or report result "applied"',
55
+ );
56
+ }
57
+
58
+ const outcome = markApplied(store, {
59
+ id: args.id,
60
+ result: args.result,
61
+ appliedHash: args.applied_hash ?? null,
62
+ foundText: args.found_text ?? null,
63
+ });
64
+ if (!outcome.ok) {
65
+ if (outcome.refusal.kind === "unknown") {
66
+ refuse(
67
+ `entry "${args.id}"`,
68
+ "no such entry",
69
+ "file it with file_entries first, or list entries to see the ids that exist",
70
+ );
71
+ }
72
+ const status: EntryStatus = outcome.refusal.status;
73
+ if (status === "draft") {
74
+ refuse(
75
+ `entry "${args.id}"`,
76
+ "status draft: a draft is inert until a human approves or rejects it, and an agent can never move it",
77
+ "approve or reject it on the dashboard first, then report again",
78
+ );
79
+ }
80
+ if (status === "rejected") {
81
+ refuse(
82
+ `entry "${args.id}"`,
83
+ "status rejected: rejected is terminal and silences the agent",
84
+ "file a new entry with a new anchor if the line still needs changing",
85
+ );
86
+ }
87
+ // The only pair left is an applied entry reported applied again: anchor_stale on an
88
+ // applied entry is the third legal transition, so it never reaches this branch.
89
+ refuse(
90
+ `entry "${args.id}"`,
91
+ "status applied: the entry is already applied",
92
+ "nothing to do — re-file the entry only if the text changed and needs a fresh review",
93
+ );
94
+ }
95
+
96
+ const output = { id: args.id, status: outcome.status };
97
+ return {
98
+ content: [{ type: "text" as const, text: JSON.stringify(output) }],
99
+ structuredContent: output,
100
+ };
101
+ },
102
+ );
103
+ }
@@ -0,0 +1,88 @@
1
+ import {
2
+ bearerAuthChallengeResponse,
3
+ createMcpHandler,
4
+ OAuthError,
5
+ OAuthErrorCode,
6
+ } from "@modelcontextprotocol/server";
7
+ import type { Hono } from "hono";
8
+ import { timingSafeEqual } from "hono/utils/buffer";
9
+ import type { Config } from "../config.ts";
10
+ import type { Store } from "../db/store.ts";
11
+ import { Notifier } from "../notify.ts";
12
+ import { createMcpServer } from "./server.ts";
13
+
14
+ /** One endpoint, POST only. `docs/mcp-contract.md` freezes the surface. */
15
+ const MCP_PATH = "/mcp";
16
+
17
+ /**
18
+ * A refusal made before the SDK sees the request. `-32000` matches the SDK's own 403 and 405 down
19
+ * to the message string, which is why those two rungs use it.
20
+ */
21
+ function refuse(status: 403 | 405, message: string, headers: Record<string, string> = {}) {
22
+ return new Response(JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null }), {
23
+ status,
24
+ headers: { "content-type": "application/json", ...headers },
25
+ });
26
+ }
27
+
28
+ /**
29
+ * `hono/bearer-auth` answers a malformed `Authorization` header with 400, which collides with the
30
+ * `-32020` header-mismatch rung below this one; every credential failure here is one 401 instead.
31
+ */
32
+ async function tokenAccepted(header: string | undefined, token: string): Promise<boolean> {
33
+ // RFC 7235 makes the scheme case-insensitive.
34
+ const presented = /^bearer +(.+)$/i.exec(header ?? "")?.[1];
35
+ if (presented === undefined) return false;
36
+ return timingSafeEqual(token, presented);
37
+ }
38
+
39
+ /**
40
+ * The reference implementation's own 401: an OAuth challenge body plus `WWW-Authenticate`, the
41
+ * shape `bearerAuthChallengeResponse` emits. Built from its own exports so the two cannot drift.
42
+ */
43
+ function authRequired(description: string): Response {
44
+ return bearerAuthChallengeResponse(new OAuthError(OAuthErrorCode.InvalidToken, description));
45
+ }
46
+
47
+ /**
48
+ * Mounts the stateless MCP endpoint. The checks run in a pinned order, and the order is the
49
+ * contract: a request refused by a lower rung must never reveal that a higher one would have
50
+ * passed. Everything past the gate is the SDK's: `_meta` validation, header-versus-body agreement,
51
+ * revision negotiation, and the method registry.
52
+ */
53
+ // The default notifier serves direct mounts (tests): built over the same config `createApp` uses,
54
+ // so a mount with no transports armed stays silent exactly like the daemon's.
55
+ export function mountMcp(
56
+ app: Hono,
57
+ config: Config,
58
+ store: Store,
59
+ notifier: Notifier = new Notifier({ config, baseUrl: config.baseUrl }),
60
+ ): void {
61
+ // `legacy: 'reject'` is the whole of the revision guard, so the endpoint serves only the one
62
+ // revision `docs/mcp-contract.md` freezes. `supportedProtocolVersions` on the server cannot do
63
+ // it and is deliberately unset: the SDK's `installDiscoverHandler` unions its served modern
64
+ // revisions back into that array regardless of what was passed, and on a legacy leg
65
+ // (`Server._oninitialize`) the list only picks WHICH 2025-era revision to answer with —
66
+ // `legacyVersions[0] ?? LATEST_PROTOCOL_VERSION`, and `LATEST_PROTOCOL_VERSION` is
67
+ // `"2025-11-25"`. A modern-only list empties `legacyVersions`, which is exactly what reaches
68
+ // that hardcoded fallback, so narrowing it is what would serve one.
69
+ // The store is the daemon's own open handle (`startDaemon` opens it before the port binds), so
70
+ // every tool call writes through the one connection the daemon will close on shutdown.
71
+ const handler = createMcpHandler(() => createMcpServer(config, store, notifier), { legacy: "reject" });
72
+
73
+ // The `Origin` rung is `originGate`, mounted app-wide in `createApp`; it runs before this
74
+ // handler, so the pinned order (origin, then method, then credential) holds without a repeat of
75
+ // the check here.
76
+ app.all(MCP_PATH, async (c) => {
77
+ if (c.req.method !== "POST") {
78
+ return refuse(405, "Method not allowed.", { allow: "POST" });
79
+ }
80
+
81
+ const token = config.REVIEWZY_TOKEN;
82
+ if (token !== undefined && !(await tokenAccepted(c.req.header("Authorization"), token))) {
83
+ return authRequired("REVIEWZY_TOKEN is set; a bearer token is required");
84
+ }
85
+
86
+ return handler.fetch(c.req.raw);
87
+ });
88
+ }
@@ -0,0 +1,59 @@
1
+ import { McpServer } from "@modelcontextprotocol/server";
2
+ import type { Config } from "../config.ts";
3
+ import type { Store } from "../db/store.ts";
4
+ import { registerAwaitApprovedTool } from "./await-approved.ts";
5
+ import { registerFetchApprovedTool } from "./fetch-approved.ts";
6
+ import { registerFileEntriesTool } from "./file-entries.ts";
7
+ import { registerListEntriesTool } from "./list-entries.ts";
8
+ import { registerMarkAppliedTool } from "./mark-applied.ts";
9
+ import { registerStyleGuideResource } from "./style-guide-resource.ts";
10
+ import type { Notifier } from "../notify.ts";
11
+ import { NAME, VERSION } from "../version.ts";
12
+
13
+ /**
14
+ * Read by a client before it calls anything, so it states the rule an agent cannot discover from a
15
+ * tool signature: prose belongs to the human, and filing a draft is not shipping it.
16
+ */
17
+ const INSTRUCTIONS = `reviewzy is a review queue for user-facing text. Agents draft, a human signs off.
18
+
19
+ File the strings you want to change as draft entries against a project. A human authors or approves the wording on the dashboard, then you fetch those words back and apply them in place. You can create a draft, move an approved entry to applied, and report an anchor that no longer matches. You can never approve or reject an entry, and a rejected anchor stays rejected: re-filing it returns the existing entry instead of proposing the line again.`;
20
+
21
+ /**
22
+ * A fresh server per request, since the 2026-07-28 revision is stateless and `createMcpHandler`
23
+ * builds one instance per HTTP request. Every instance registers the tools over the same store the
24
+ * daemon opened at boot, so the write path never opens a connection of its own; later queue tasks
25
+ * register theirs the same way.
26
+ */
27
+ export function createMcpServer(config: Config, store: Store, notifier: Notifier): McpServer {
28
+ const server: McpServer = new McpServer(
29
+ { name: NAME, version: VERSION },
30
+ {
31
+ // The keys do not gate handler registration: `registerTool` and `registerResource` install
32
+ // the `tools/*` and `resources/*` handlers regardless, and merge a dropped key back in with
33
+ // `listChanged` defaulted to `true` (measured on this SDK). The keys' real job is pinning
34
+ // that `listChanged` to `false`: v1 has no notification stream to carry the change
35
+ // notification, and an advertised `true` is what makes the SDK honor a client's
36
+ // `toolsListChanged`/`resourcesListChanged` subscription request.
37
+ capabilities: { tools: { listChanged: false }, resources: { listChanged: false } },
38
+ instructions: INSTRUCTIONS,
39
+ // The SDK's own default is `ttlMs: 0`, which tells a client the answer is already stale. Both
40
+ // of these are fixed for a running daemon, so they are good for as long as the process lives.
41
+ // `private` because the endpoint can be bearer-guarded, and a `public` result may be served to
42
+ // any caller by a shared cache. `resources/templates/list` is pinned to 60s by the contract,
43
+ // and `resources/read` carries the same values from the resource's own cacheHint.
44
+ cacheHints: {
45
+ "server/discover": { ttlMs: 3_600_000, cacheScope: "private" },
46
+ "tools/list": { ttlMs: 3_600_000, cacheScope: "private" },
47
+ "resources/templates/list": { ttlMs: 60_000, cacheScope: "private" },
48
+ },
49
+ },
50
+ );
51
+
52
+ registerFileEntriesTool(server, config.baseUrl, store, notifier);
53
+ registerListEntriesTool(server, store);
54
+ registerFetchApprovedTool(server, store);
55
+ registerMarkAppliedTool(server, store);
56
+ registerAwaitApprovedTool(server, store);
57
+ registerStyleGuideResource(server, store);
58
+ return server;
59
+ }
@@ -0,0 +1,40 @@
1
+ import { ResourceNotFoundError, ResourceTemplate } from "@modelcontextprotocol/server";
2
+ import type { McpServer } from "@modelcontextprotocol/server";
3
+ import { projectIdBySlug } from "../db/queries.ts";
4
+ import { mergedStyleGuide } from "../db/style-guide.ts";
5
+ import type { Store } from "../db/store.ts";
6
+
7
+ /** The pinned resource of `docs/mcp-contract.md`'s resource section; `{slug}` is the only variable. */
8
+ const STYLE_GUIDE_TEMPLATE = "reviewzy://projects/{slug}/style-guide";
9
+
10
+ /**
11
+ * Registers the contract's one resource: the merged style guide, served to a drafting agent before
12
+ * it writes a word. `list` stays undefined so the template is advertised through
13
+ * `resources/templates/list` only — v1 has no notification stream, and a client enumerating
14
+ * concrete `resources/list` entries would be fed a list it cannot subscribe to.
15
+ */
16
+ export function registerStyleGuideResource(server: McpServer, store: Store): void {
17
+ server.registerResource(
18
+ "style-guide",
19
+ new ResourceTemplate(STYLE_GUIDE_TEMPLATE, { list: undefined }),
20
+ {
21
+ title: "Style guide",
22
+ description:
23
+ "The merged style guide for a project: the global guide, then the project's own rules below it, then the union of banned words and the glossary (a term defined in both resolves to the project). Read this before drafting any entry.",
24
+ mimeType: "text/markdown",
25
+ // The read is a pure function of the store, so a 60s TTL is safe; `private` because the
26
+ // endpoint can be bearer-guarded and a shared cache may not serve one tenant's copy to
27
+ // another. The same values must appear on `resources/templates/list` via the server-level
28
+ // cacheHints entry in src/mcp/server.ts.
29
+ cacheHint: { ttlMs: 60_000, cacheScope: "private" },
30
+ },
31
+ (uri, variables) => {
32
+ const slug = String(variables.slug);
33
+ if (projectIdBySlug(store, slug) === null) {
34
+ // The SDK's own miss shape: `-32602` with `data.uri`, never a business tool result.
35
+ throw new ResourceNotFoundError(uri.href);
36
+ }
37
+ return { contents: [{ uri: uri.href, mimeType: "text/markdown", text: mergedStyleGuide(store, slug) }] };
38
+ },
39
+ );
40
+ }