sentinel-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.
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ export declare function isAllowedUrl(url: string): boolean;
3
+ export declare function fetchImage(url: string): Promise<{
4
+ ok: true;
5
+ data: string;
6
+ mimeType: string;
7
+ } | {
8
+ ok: false;
9
+ error: string;
10
+ }>;
@@ -0,0 +1,98 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ const MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10MB
6
+ const ALLOWED_PATTERNS = [
7
+ /^https:\/\/github\.com\/user-attachments\/assets\//,
8
+ /^https:\/\/[a-z0-9-]+\.githubusercontent\.com\//,
9
+ ];
10
+ export function isAllowedUrl(url) {
11
+ try {
12
+ const parsed = new URL(url);
13
+ if (parsed.protocol !== "https:")
14
+ return false;
15
+ return ALLOWED_PATTERNS.some((p) => p.test(url));
16
+ }
17
+ catch {
18
+ return false;
19
+ }
20
+ }
21
+ export async function fetchImage(url) {
22
+ if (!isAllowedUrl(url)) {
23
+ return {
24
+ ok: false,
25
+ error: "URL not allowed. Only github.com/user-attachments/assets/* and *.githubusercontent.com/* URLs are permitted.",
26
+ };
27
+ }
28
+ let res;
29
+ try {
30
+ res = await fetch(url);
31
+ }
32
+ catch (err) {
33
+ return { ok: false, error: `Failed to fetch image: ${err.message}` };
34
+ }
35
+ if (!res.ok) {
36
+ return { ok: false, error: `Failed to fetch image: HTTP ${res.status}` };
37
+ }
38
+ // Early size check from Content-Length header (avoid buffering large responses)
39
+ const contentLength = res.headers.get("content-length");
40
+ if (contentLength && parseInt(contentLength) > MAX_IMAGE_SIZE) {
41
+ return {
42
+ ok: false,
43
+ error: `Image too large (${(parseInt(contentLength) / 1024 / 1024).toFixed(1)}MB). Max allowed: 10MB.`,
44
+ };
45
+ }
46
+ const contentType = res.headers.get("content-type") ?? "image/png";
47
+ if (!contentType.startsWith("image/")) {
48
+ return {
49
+ ok: false,
50
+ error: `Response is not an image (content-type: ${contentType})`,
51
+ };
52
+ }
53
+ const buffer = await res.arrayBuffer();
54
+ if (buffer.byteLength > MAX_IMAGE_SIZE) {
55
+ return {
56
+ ok: false,
57
+ error: `Image too large (${(buffer.byteLength / 1024 / 1024).toFixed(1)}MB). Max allowed: 10MB.`,
58
+ };
59
+ }
60
+ const base64 = Buffer.from(buffer).toString("base64");
61
+ const mimeType = contentType.split(";")[0].trim();
62
+ return { ok: true, data: base64, mimeType };
63
+ }
64
+ const server = new McpServer({
65
+ name: "sentinel-image",
66
+ version: "0.1.0",
67
+ });
68
+ server.registerTool("fetch_image", {
69
+ description: "Fetch an image from a GitHub issue. Pass the image URL from markdown ![alt](url) syntax.",
70
+ inputSchema: { url: z.string().url() },
71
+ }, async ({ url }) => {
72
+ const result = await fetchImage(url);
73
+ if (!result.ok) {
74
+ return {
75
+ content: [{ type: "text", text: result.error }],
76
+ isError: true,
77
+ };
78
+ }
79
+ return {
80
+ content: [
81
+ {
82
+ type: "image",
83
+ data: result.data,
84
+ mimeType: result.mimeType,
85
+ },
86
+ ],
87
+ };
88
+ });
89
+ // Only start the server when run as a script, not when imported for testing
90
+ const isMain = process.argv[1] &&
91
+ import.meta.url === new URL(process.argv[1], "file://").href;
92
+ if (isMain) {
93
+ const transport = new StdioServerTransport();
94
+ server.connect(transport).catch((err) => {
95
+ console.error(`Fatal: ${err.message}`);
96
+ process.exit(1);
97
+ });
98
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env node
2
+ import { execSync } from "child_process";
3
+ import fs from "fs";
4
+ import path from "path";
5
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
+ import { z } from "zod";
8
+ import { WsClient, decodeInstallationId } from "./ws-client.js";
9
+ // ---------------------------------------------------------------------------
10
+ // Config from environment
11
+ // ---------------------------------------------------------------------------
12
+ const SENTINEL_TOKEN = process.env.SENTINEL_TOKEN;
13
+ if (!SENTINEL_TOKEN) {
14
+ console.error("FATAL: SENTINEL_TOKEN environment variable is required");
15
+ process.exit(1);
16
+ }
17
+ const RELAY_URL = process.env.SENTINEL_RELAY_URL ?? "wss://api.usesentinel.tools";
18
+ const REPO_PATH = process.env.SENTINEL_REPO_PATH ?? process.cwd();
19
+ // ---------------------------------------------------------------------------
20
+ // Logger — must use stderr (stdout is reserved for MCP stdio transport)
21
+ // ---------------------------------------------------------------------------
22
+ function log(msg) {
23
+ console.error(`[sentinel] ${msg}`);
24
+ }
25
+ // ---------------------------------------------------------------------------
26
+ // Resolve repo name (owner/repo) from git remote
27
+ // ---------------------------------------------------------------------------
28
+ let REPO_NAME = "";
29
+ try {
30
+ const remoteUrl = execSync("git remote get-url origin", { cwd: REPO_PATH, encoding: "utf-8" }).trim();
31
+ const match = remoteUrl.match(/github\.com[:/]([^/]+\/[^/.]+)/);
32
+ if (match) {
33
+ REPO_NAME = match[1];
34
+ }
35
+ }
36
+ catch {
37
+ log("WARNING: Could not resolve repo name from git remote — events will not be filtered by repo");
38
+ }
39
+ // ---------------------------------------------------------------------------
40
+ // MCP server setup
41
+ // ---------------------------------------------------------------------------
42
+ const server = new McpServer({ name: "sentinel", version: "0.2.0" }, {
43
+ capabilities: {
44
+ experimental: { "claude/channel": {} },
45
+ },
46
+ instructions: "Events from this channel arrive as <channel source=\"sentinel\" ...>. They contain GitHub issue and PR events for repos you monitor. Act on them according to the instructions in the event content.",
47
+ });
48
+ const transport = new StdioServerTransport();
49
+ // ---------------------------------------------------------------------------
50
+ // Channel notifications — push events from relay → Claude Code via stdio
51
+ // ---------------------------------------------------------------------------
52
+ function pushNotification(content, meta) {
53
+ server.server
54
+ .notification({
55
+ method: "notifications/claude/channel",
56
+ params: { content, meta },
57
+ })
58
+ .catch((err) => {
59
+ log(`Failed to push notification: ${err.message}`);
60
+ });
61
+ }
62
+ // ---------------------------------------------------------------------------
63
+ // GitHub token management
64
+ // ---------------------------------------------------------------------------
65
+ let installationId;
66
+ try {
67
+ installationId = decodeInstallationId(SENTINEL_TOKEN);
68
+ }
69
+ catch (err) {
70
+ console.error(`FATAL: Cannot decode installation ID from token: ${err.message}`);
71
+ process.exit(1);
72
+ }
73
+ let githubToken = "";
74
+ let tokenRefreshTimer = null;
75
+ async function refreshGithubToken() {
76
+ const httpUrl = RELAY_URL.replace("wss://", "https://").replace("ws://", "http://");
77
+ const tokenUrl = `${httpUrl}/api/token/${installationId}`;
78
+ try {
79
+ const res = await fetch(tokenUrl, {
80
+ headers: { Authorization: `Bearer ${SENTINEL_TOKEN}` },
81
+ });
82
+ if (!res.ok) {
83
+ throw new Error(`HTTP ${res.status}: ${await res.text()}`);
84
+ }
85
+ const data = (await res.json());
86
+ githubToken = data.token;
87
+ // Write token to ~/.sentinel/github-token for CLI tools
88
+ const dir = path.join(process.env.HOME ?? "~", ".sentinel");
89
+ fs.mkdirSync(dir, { recursive: true });
90
+ fs.writeFileSync(path.join(dir, "github-token"), githubToken, { mode: 0o600 });
91
+ log("GitHub token refreshed");
92
+ }
93
+ catch (err) {
94
+ log(`Failed to refresh GitHub token: ${err.message}`);
95
+ }
96
+ }
97
+ // ---------------------------------------------------------------------------
98
+ // API helper — proxy tool calls to remote worker API
99
+ // ---------------------------------------------------------------------------
100
+ async function apiCall(endpoint, body) {
101
+ const httpUrl = RELAY_URL.replace("wss://", "https://").replace("ws://", "http://");
102
+ const res = await fetch(`${httpUrl}${endpoint}`, {
103
+ method: "POST",
104
+ headers: {
105
+ "Content-Type": "application/json",
106
+ Authorization: `Bearer ${SENTINEL_TOKEN}`,
107
+ },
108
+ body: JSON.stringify(body),
109
+ });
110
+ if (!res.ok) {
111
+ const text = await res.text();
112
+ throw new Error(`API error ${res.status}: ${text}`);
113
+ }
114
+ return res.json();
115
+ }
116
+ // ---------------------------------------------------------------------------
117
+ // WebSocket client — receives events from relay, pushes as channel notifications
118
+ // ---------------------------------------------------------------------------
119
+ const wsClient = new WsClient({
120
+ token: SENTINEL_TOKEN,
121
+ relayUrl: RELAY_URL,
122
+ repo: REPO_NAME || undefined,
123
+ log,
124
+ onEvent: (id, payload, notification) => {
125
+ // Use pre-built notification from relay if available, otherwise build a simple one
126
+ if (notification) {
127
+ pushNotification(notification.content, notification.meta);
128
+ }
129
+ else {
130
+ const issueNum = payload.type === "pr_merged" ? payload.issue_number : payload.issue?.number;
131
+ pushNotification(`${payload.type} event for #${issueNum} in ${payload.repo}`, { type: payload.type, repo: payload.repo, issue: String(issueNum) });
132
+ }
133
+ wsClient.ack(id);
134
+ },
135
+ onDisplaced: (reason) => {
136
+ pushNotification(`Sentinel disconnected: ${reason}`, { type: "displaced" });
137
+ },
138
+ onLimitReached: (message) => {
139
+ pushNotification(message, { type: "limit_reached" });
140
+ },
141
+ });
142
+ // ---------------------------------------------------------------------------
143
+ // MCP tools — proxy to remote API
144
+ // ---------------------------------------------------------------------------
145
+ server.registerTool("sentinel_status", {
146
+ description: "Get Sentinel connection status",
147
+ }, async () => {
148
+ return {
149
+ content: [{
150
+ type: "text",
151
+ text: JSON.stringify({
152
+ connected: wsClient.isConnected(),
153
+ relayUrl: RELAY_URL,
154
+ installationId,
155
+ repo: REPO_NAME,
156
+ }, null, 2),
157
+ }],
158
+ };
159
+ });
160
+ server.registerTool("post_issue_comment", {
161
+ description: "Post a comment on a GitHub issue",
162
+ inputSchema: {
163
+ repo: z.string().describe("Repository in owner/repo format"),
164
+ issue_number: z.number().describe("Issue number"),
165
+ body: z.string().describe("Comment body (markdown)"),
166
+ },
167
+ }, async ({ repo, issue_number, body }) => {
168
+ const result = await apiCall("/api/mcp/tools/post_issue_comment", { repo, issue_number, body });
169
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
170
+ });
171
+ server.registerTool("create_pull_request", {
172
+ description: "Create a pull request on GitHub",
173
+ inputSchema: {
174
+ repo: z.string().describe("Repository in owner/repo format"),
175
+ title: z.string().describe("PR title"),
176
+ body: z.string().describe("PR body (markdown)"),
177
+ head: z.string().describe("Branch name to merge from"),
178
+ base: z.string().describe("Branch name to merge into").default("main"),
179
+ },
180
+ }, async ({ repo, title, body, head, base }) => {
181
+ const result = await apiCall("/api/mcp/tools/create_pull_request", { repo, title, body, head, base });
182
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
183
+ });
184
+ server.registerTool("update_worktree", {
185
+ description: "Update worktree status for an issue (active, pr_open, cleaned_up)",
186
+ inputSchema: {
187
+ repo: z.string().describe("Repository in owner/repo format"),
188
+ issue_number: z.number().describe("Issue number"),
189
+ branch: z.string().describe("Branch name"),
190
+ worktree_path: z.string().describe("Local worktree path"),
191
+ status: z.enum(["active", "pr_open", "cleaned_up"]).describe("Worktree status"),
192
+ pr_number: z.number().optional().describe("PR number if status is pr_open"),
193
+ },
194
+ }, async ({ repo, issue_number, branch, worktree_path, status, pr_number }) => {
195
+ const result = await apiCall("/api/mcp/tools/update_worktree", {
196
+ repo, issue_number, branch, worktree_path, status, pr_number,
197
+ });
198
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
199
+ });
200
+ server.registerTool("get_skill", {
201
+ description: "Load a Sentinel skill's full instructions by name",
202
+ inputSchema: {
203
+ name: z.string().describe("Skill name"),
204
+ },
205
+ }, async ({ name }) => {
206
+ const result = await apiCall("/api/mcp/tools/get_skill", { name });
207
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
208
+ });
209
+ // ---------------------------------------------------------------------------
210
+ // Startup
211
+ // ---------------------------------------------------------------------------
212
+ async function main() {
213
+ await server.connect(transport);
214
+ log("MCP server connected via stdio");
215
+ await refreshGithubToken();
216
+ tokenRefreshTimer = setInterval(refreshGithubToken, 50 * 60 * 1000);
217
+ wsClient.connect();
218
+ log("Sentinel MCP bridge running");
219
+ }
220
+ main().catch((err) => {
221
+ log(`Fatal: ${err.message}`);
222
+ process.exit(1);
223
+ });
224
+ // ---------------------------------------------------------------------------
225
+ // Graceful shutdown
226
+ // ---------------------------------------------------------------------------
227
+ function shutdown() {
228
+ log("Shutting down...");
229
+ if (tokenRefreshTimer)
230
+ clearInterval(tokenRefreshTimer);
231
+ wsClient.disconnect();
232
+ server.close().catch(() => { });
233
+ process.exit(0);
234
+ }
235
+ process.on("SIGINT", shutdown);
236
+ process.on("SIGTERM", shutdown);
@@ -0,0 +1,19 @@
1
+ import type { SentinelEvent } from "./types.js";
2
+ export interface PromptSettings {
3
+ behavior: "pr" | "push";
4
+ autoClose: boolean;
5
+ branchPattern: string;
6
+ claudeMdTemplate?: string;
7
+ }
8
+ export interface IssuePromptInput {
9
+ repo: string;
10
+ issue: SentinelEvent["issue"];
11
+ settings: PromptSettings;
12
+ }
13
+ export interface CommentPromptInput {
14
+ repo: string;
15
+ issue: SentinelEvent["issue"];
16
+ comment: NonNullable<SentinelEvent["comment"]>;
17
+ }
18
+ export declare function buildIssuePrompt(input: IssuePromptInput): string;
19
+ export declare function buildCommentPrompt(input: CommentPromptInput): string;
@@ -0,0 +1,68 @@
1
+ export function buildIssuePrompt(input) {
2
+ const { repo, issue, settings } = input;
3
+ const { behavior, autoClose, branchPattern, claudeMdTemplate } = settings;
4
+ const branchName = branchPattern.replace("{number}", String(issue.number));
5
+ const labelsText = issue.labels.length > 0 ? issue.labels.join(", ") : "(none)";
6
+ const deliveryInstructions = behavior === "pr"
7
+ ? `- Create a new branch named \`${branchName}\` from the default branch.
8
+ - Commit your changes to that branch.
9
+ - Open a pull request using \`gh pr create\` targeting the default branch with a clear title and description linking to issue #${issue.number}.`
10
+ : `- Commit your changes and push directly to the default branch (do not open a pull request).`;
11
+ const closeInstructions = autoClose
12
+ ? `- After the fix is merged/pushed, close the issue with \`gh issue close ${issue.number}\`.`
13
+ : `- After the fix is merged/pushed, post a brief summary comment on the issue explaining what was changed. Do NOT close the issue.`;
14
+ const claudeMdSection = claudeMdTemplate
15
+ ? `\n## Project Guidelines (CLAUDE.md)\n\n${claudeMdTemplate}\n`
16
+ : "";
17
+ const toolsSection = `## Available Tools
18
+
19
+ - **fetch_image**: Before beginning your investigation, check if the issue body or comments contain any image markdown (\`![...](...)\`). If so, call \`fetch_image\` for each image URL to view screenshots or visuals that help you understand the bug report.
20
+
21
+ `;
22
+ return `You are Sentinel, an autonomous bug-fixing agent. Your job is to investigate and fix the GitHub issue described below, then deliver the fix according to the configured workflow.
23
+ ${claudeMdSection}
24
+ ## Issue Details
25
+
26
+ - **Repository:** ${repo}
27
+ - **Issue:** #${issue.number} — ${issue.title}
28
+ - **Author:** @${issue.author}
29
+ - **Labels:** ${labelsText}
30
+ - **URL:** ${issue.url}
31
+
32
+ ### Issue Body
33
+
34
+ ${issue.body}
35
+
36
+ ## Workflow Instructions
37
+
38
+ ${deliveryInstructions}
39
+ ${closeInstructions}
40
+
41
+ ${toolsSection}
42
+ ## General Rules
43
+
44
+ - If you need clarification before proceeding, ask ONE focused question by posting a comment with \`gh issue comment ${issue.number} --body "<your question>"\`. Never post more than one comment per turn.
45
+ - Do not ask for clarification if you already have enough information to begin.
46
+ - Always work inside the repository at \`${repo}\`.
47
+ - Write clean, minimal changes that address only the reported issue.
48
+
49
+ Begin by exploring the codebase to understand the problem, then implement the fix.`;
50
+ }
51
+ export function buildCommentPrompt(input) {
52
+ const { repo, issue, comment } = input;
53
+ return `You are Sentinel, an autonomous bug-fixing agent. You are continuing work on issue #${issue.number} in repository ${repo}.
54
+
55
+ ## Human Reply
56
+
57
+ **@${comment.author}** commented:
58
+
59
+ ${comment.body}
60
+
61
+ ---
62
+
63
+ ## Available Tools
64
+
65
+ - **fetch_image**: If the comment contains images (markdown \`![...](...)\`), call \`fetch_image\` with the image URL to view them.
66
+
67
+ Review the comment above and continue fixing the issue. If the comment answers a previous question or provides new information, incorporate it into your approach and proceed with the fix. If you still need one more clarification, ask a single focused question using \`gh issue comment ${issue.number} --body "<your question>"\`. Never post more than one comment per turn.`;
68
+ }
@@ -0,0 +1,30 @@
1
+ import type { SentinelEvent } from "./types.js";
2
+ export interface SessionManagerOptions {
3
+ maxConcurrent: number;
4
+ repoPath: string;
5
+ githubToken: string;
6
+ log: (msg: string) => void;
7
+ onSessionUpdate?: (issue: number, sessionId: string) => void;
8
+ onSessionComplete?: (issue: number, success: boolean) => void;
9
+ }
10
+ export declare class SessionManager {
11
+ private active;
12
+ private issueQueues;
13
+ private globalQueue;
14
+ private opts;
15
+ constructor(opts: SessionManagerOptions);
16
+ setGithubToken(token: string): void;
17
+ canSpawn(): boolean;
18
+ getActiveSessions(): Array<{
19
+ issue: number;
20
+ sessionId: string | null;
21
+ status: string;
22
+ }>;
23
+ getQueueLength(): number;
24
+ getIssueQueueLength(issue: number): number;
25
+ enqueue(event: SentinelEvent): void;
26
+ spawnSession(event: SentinelEvent): void;
27
+ killSession(issueNumber: number): void;
28
+ private drainGlobalQueue;
29
+ private postErrorComment;
30
+ }
@@ -0,0 +1,221 @@
1
+ import { spawn } from "child_process";
2
+ import fs from "fs";
3
+ import os from "os";
4
+ import path from "path";
5
+ import { fileURLToPath } from "url";
6
+ import { buildIssuePrompt, buildCommentPrompt } from "./prompts.js";
7
+ export class SessionManager {
8
+ active = new Map();
9
+ issueQueues = new Map();
10
+ globalQueue = [];
11
+ opts;
12
+ constructor(opts) {
13
+ this.opts = opts;
14
+ }
15
+ setGithubToken(token) {
16
+ this.opts.githubToken = token;
17
+ }
18
+ canSpawn() {
19
+ return this.active.size < this.opts.maxConcurrent;
20
+ }
21
+ getActiveSessions() {
22
+ return Array.from(this.active.entries()).map(([issue, session]) => ({
23
+ issue,
24
+ sessionId: session.sessionId,
25
+ status: "running",
26
+ }));
27
+ }
28
+ getQueueLength() {
29
+ return this.globalQueue.length;
30
+ }
31
+ getIssueQueueLength(issue) {
32
+ return this.issueQueues.get(issue)?.length ?? 0;
33
+ }
34
+ enqueue(event) {
35
+ const issueNumber = event.issue.number;
36
+ // Handle issue_closed: kill session + cleanup
37
+ if (event.type === "issue_closed") {
38
+ if (this.active.has(issueNumber)) {
39
+ this.killSession(issueNumber);
40
+ }
41
+ return;
42
+ }
43
+ // If issue has an active session, queue per-issue
44
+ if (this.active.has(issueNumber)) {
45
+ const queue = this.issueQueues.get(issueNumber) ?? [];
46
+ queue.push(event);
47
+ this.issueQueues.set(issueNumber, queue);
48
+ this.opts.log(`Queued event per-issue for #${issueNumber} (queue size: ${queue.length})`);
49
+ return;
50
+ }
51
+ // If at capacity, queue globally
52
+ if (!this.canSpawn()) {
53
+ this.globalQueue.push(event);
54
+ this.opts.log(`Queued event globally for #${issueNumber} (global queue size: ${this.globalQueue.length})`);
55
+ return;
56
+ }
57
+ // Otherwise spawn
58
+ this.spawnSession(event);
59
+ }
60
+ spawnSession(event) {
61
+ const issueNumber = event.issue.number;
62
+ const settings = {
63
+ behavior: "pr",
64
+ autoClose: true,
65
+ branchPattern: "sentinel/issue-{number}",
66
+ };
67
+ // Build prompt based on event type
68
+ let prompt;
69
+ if (event.type === "issue_comment" && event.comment) {
70
+ prompt = buildCommentPrompt({
71
+ repo: event.repo,
72
+ issue: event.issue,
73
+ comment: event.comment,
74
+ });
75
+ }
76
+ else {
77
+ prompt = buildIssuePrompt({
78
+ repo: event.repo,
79
+ issue: event.issue,
80
+ settings,
81
+ });
82
+ }
83
+ // Write temp MCP config for image server
84
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
85
+ const imageServerPath = path.join(__dirname, "image-server.js");
86
+ const mcpConfigPath = path.join(os.tmpdir(), `sentinel-mcp-${issueNumber}.json`);
87
+ fs.writeFileSync(mcpConfigPath, JSON.stringify({
88
+ mcpServers: {
89
+ "sentinel-image": {
90
+ command: "node",
91
+ args: [imageServerPath],
92
+ },
93
+ },
94
+ }));
95
+ // Build spawn args
96
+ const args = [
97
+ "-p", prompt,
98
+ "--output-format", "json",
99
+ "--mcp-config", mcpConfigPath,
100
+ ];
101
+ // Resume if we have a session ID from a previous run
102
+ const existing = this.active.get(issueNumber);
103
+ if (existing?.sessionId) {
104
+ args.push("--resume", existing.sessionId);
105
+ }
106
+ this.opts.log(`Spawning claude session for issue #${issueNumber}`);
107
+ const proc = spawn("claude", args, {
108
+ cwd: this.opts.repoPath,
109
+ env: {
110
+ ...process.env,
111
+ GITHUB_TOKEN: this.opts.githubToken,
112
+ },
113
+ });
114
+ const session = {
115
+ process: proc,
116
+ sessionId: existing?.sessionId ?? null,
117
+ issueNumber,
118
+ stdout: "",
119
+ };
120
+ this.active.set(issueNumber, session);
121
+ // Collect stdout
122
+ proc.stdout?.on("data", (data) => {
123
+ session.stdout += data.toString();
124
+ });
125
+ proc.stderr?.on("data", (data) => {
126
+ this.opts.log(`[issue #${issueNumber} stderr] ${data.toString()}`);
127
+ });
128
+ proc.on("close", (code) => {
129
+ // Clean up temp MCP config
130
+ try {
131
+ fs.unlinkSync(mcpConfigPath);
132
+ }
133
+ catch {
134
+ // best-effort
135
+ }
136
+ const success = code === 0;
137
+ // Try to parse session_id from stdout
138
+ try {
139
+ const output = JSON.parse(session.stdout);
140
+ if (output.session_id) {
141
+ session.sessionId = output.session_id;
142
+ this.opts.onSessionUpdate?.(issueNumber, output.session_id);
143
+ }
144
+ }
145
+ catch {
146
+ // stdout may not be valid JSON, that's fine
147
+ }
148
+ // Remove from active
149
+ this.active.delete(issueNumber);
150
+ this.opts.log(`Session for issue #${issueNumber} closed (code=${code}, success=${success})`);
151
+ // Notify completion
152
+ this.opts.onSessionComplete?.(issueNumber, success);
153
+ // On failure, post error comment (best-effort)
154
+ if (!success) {
155
+ this.postErrorComment(issueNumber, event.repo);
156
+ }
157
+ // Process per-issue queue first (priority)
158
+ const issueQueue = this.issueQueues.get(issueNumber);
159
+ if (issueQueue && issueQueue.length > 0) {
160
+ const next = issueQueue.shift();
161
+ if (issueQueue.length === 0) {
162
+ this.issueQueues.delete(issueNumber);
163
+ }
164
+ this.spawnSession(next);
165
+ return;
166
+ }
167
+ // Then drain global queue
168
+ this.drainGlobalQueue();
169
+ });
170
+ }
171
+ killSession(issueNumber) {
172
+ const session = this.active.get(issueNumber);
173
+ if (session) {
174
+ session.process.kill();
175
+ // Clean up temp MCP config (belt-and-suspenders — close handler also cleans up)
176
+ const configPath = path.join(os.tmpdir(), `sentinel-mcp-${issueNumber}.json`);
177
+ try {
178
+ fs.unlinkSync(configPath);
179
+ }
180
+ catch {
181
+ // best-effort
182
+ }
183
+ this.active.delete(issueNumber);
184
+ this.opts.log(`Killed session for issue #${issueNumber}`);
185
+ }
186
+ // Clear per-issue queue
187
+ this.issueQueues.delete(issueNumber);
188
+ // Drain global queue since a slot opened
189
+ this.drainGlobalQueue();
190
+ }
191
+ drainGlobalQueue() {
192
+ while (this.canSpawn() && this.globalQueue.length > 0) {
193
+ const next = this.globalQueue.shift();
194
+ this.spawnSession(next);
195
+ }
196
+ }
197
+ postErrorComment(issueNumber, repo) {
198
+ try {
199
+ const proc = spawn("gh", [
200
+ "issue",
201
+ "comment",
202
+ String(issueNumber),
203
+ "--repo",
204
+ repo,
205
+ "--body",
206
+ `Sentinel encountered an error while processing this issue. A maintainer may need to investigate.`,
207
+ ], {
208
+ env: {
209
+ ...process.env,
210
+ GITHUB_TOKEN: this.opts.githubToken,
211
+ },
212
+ });
213
+ proc.on("error", () => {
214
+ // best-effort, ignore
215
+ });
216
+ }
217
+ catch {
218
+ // best-effort, ignore
219
+ }
220
+ }
221
+ }
@@ -0,0 +1,58 @@
1
+ export interface IssueEvent {
2
+ type: "issue_opened" | "issue_comment" | "issue_closed";
3
+ repo: string;
4
+ issue: {
5
+ number: number;
6
+ title: string;
7
+ body: string;
8
+ author: string;
9
+ labels: string[];
10
+ url: string;
11
+ };
12
+ comment?: {
13
+ body: string;
14
+ author: string;
15
+ url: string;
16
+ };
17
+ timestamp: string;
18
+ }
19
+ export interface PrMergedEvent {
20
+ type: "pr_merged";
21
+ repo: string;
22
+ pr_number: number;
23
+ branch: string;
24
+ issue_number: number;
25
+ timestamp: string;
26
+ }
27
+ export type SentinelEvent = IssueEvent | PrMergedEvent;
28
+ export type ServerMessage = {
29
+ type: "event";
30
+ id: string;
31
+ payload: SentinelEvent;
32
+ notification?: {
33
+ content: string;
34
+ meta: Record<string, string>;
35
+ };
36
+ } | {
37
+ type: "ping";
38
+ } | {
39
+ type: "displaced";
40
+ reason: string;
41
+ } | {
42
+ type: "limit_reached";
43
+ message: string;
44
+ };
45
+ export type ClientMessage = {
46
+ type: "auth";
47
+ token: string;
48
+ repo?: string;
49
+ } | {
50
+ type: "ack";
51
+ id: string;
52
+ } | {
53
+ type: "pong";
54
+ } | {
55
+ type: "session_update";
56
+ issue: number;
57
+ session_id: string;
58
+ };
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ // Mirror the server types — keep in sync with sentinel-worker/src/types.ts
2
+ export {};
@@ -0,0 +1,43 @@
1
+ import type { ClientMessage, ServerMessage } from "./types.js";
2
+ /**
3
+ * Decode the installation ID from a JWT token.
4
+ * Extracts the `sub` claim from the JWT payload without verifying the signature.
5
+ */
6
+ export declare function decodeInstallationId(jwt: string): string;
7
+ /**
8
+ * Decode the user ID from a JWT token.
9
+ * Extracts the `uid` claim from the JWT payload without verifying the signature.
10
+ */
11
+ export declare function decodeUserId(jwt: string): string;
12
+ /**
13
+ * Parse a raw JSON string into a typed ServerMessage.
14
+ */
15
+ export declare function parseServerMessage(raw: string): ServerMessage;
16
+ export interface WsClientOptions {
17
+ token: string;
18
+ relayUrl: string;
19
+ repo?: string;
20
+ onEvent: (id: string, payload: any, notification?: {
21
+ content: string;
22
+ meta: Record<string, string>;
23
+ }) => void;
24
+ onDisplaced: (reason: string) => void;
25
+ onLimitReached?: (message: string) => void;
26
+ log: (msg: string) => void;
27
+ }
28
+ export declare class WsClient {
29
+ private ws;
30
+ private reconnectDelay;
31
+ private maxReconnectDelay;
32
+ private shouldReconnect;
33
+ private userId;
34
+ private opts;
35
+ constructor(opts: WsClientOptions);
36
+ connect(): void;
37
+ send(msg: ClientMessage): void;
38
+ ack(eventId: string): void;
39
+ updateSession(issue: number, sessionId: string): void;
40
+ isConnected(): boolean;
41
+ disconnect(): void;
42
+ private scheduleReconnect;
43
+ }
@@ -0,0 +1,152 @@
1
+ import WebSocket from "ws";
2
+ /**
3
+ * Decode the installation ID from a JWT token.
4
+ * Extracts the `sub` claim from the JWT payload without verifying the signature.
5
+ */
6
+ export function decodeInstallationId(jwt) {
7
+ const parts = jwt.split(".");
8
+ if (parts.length !== 3) {
9
+ throw new Error(`Invalid JWT format: expected 3 parts separated by '.', got ${parts.length}`);
10
+ }
11
+ const payloadPart = parts[1];
12
+ let parsed;
13
+ try {
14
+ const decoded = Buffer.from(payloadPart, "base64").toString("utf-8");
15
+ parsed = JSON.parse(decoded);
16
+ }
17
+ catch {
18
+ throw new Error("Invalid JWT: failed to decode or parse payload");
19
+ }
20
+ if (typeof parsed !== "object" ||
21
+ parsed === null ||
22
+ !("sub" in parsed) ||
23
+ typeof parsed.sub !== "string") {
24
+ throw new Error("Invalid JWT: missing or invalid 'sub' claim");
25
+ }
26
+ return parsed.sub;
27
+ }
28
+ /**
29
+ * Decode the user ID from a JWT token.
30
+ * Extracts the `uid` claim from the JWT payload without verifying the signature.
31
+ */
32
+ export function decodeUserId(jwt) {
33
+ const parts = jwt.split(".");
34
+ if (parts.length !== 3) {
35
+ throw new Error(`Invalid JWT format: expected 3 parts separated by '.', got ${parts.length}`);
36
+ }
37
+ const payloadPart = parts[1];
38
+ let parsed;
39
+ try {
40
+ const decoded = Buffer.from(payloadPart, "base64").toString("utf-8");
41
+ parsed = JSON.parse(decoded);
42
+ }
43
+ catch {
44
+ throw new Error("Invalid JWT: failed to decode or parse payload");
45
+ }
46
+ if (typeof parsed !== "object" ||
47
+ parsed === null ||
48
+ !("uid" in parsed) ||
49
+ typeof parsed.uid !== "string") {
50
+ throw new Error("Invalid JWT: missing or invalid 'uid' claim");
51
+ }
52
+ return parsed.uid;
53
+ }
54
+ /**
55
+ * Parse a raw JSON string into a typed ServerMessage.
56
+ */
57
+ export function parseServerMessage(raw) {
58
+ return JSON.parse(raw);
59
+ }
60
+ export class WsClient {
61
+ ws = null;
62
+ reconnectDelay = 1000;
63
+ maxReconnectDelay = 60000;
64
+ shouldReconnect = true;
65
+ userId;
66
+ opts;
67
+ constructor(opts) {
68
+ this.opts = opts;
69
+ this.userId = decodeUserId(opts.token);
70
+ }
71
+ connect() {
72
+ const url = `${this.opts.relayUrl}/api/connect/${this.userId}`;
73
+ this.opts.log(`Connecting to ${url}`);
74
+ const ws = new WebSocket(url);
75
+ this.ws = ws;
76
+ ws.on("open", () => {
77
+ this.opts.log("WebSocket connected");
78
+ this.reconnectDelay = 1000;
79
+ this.send({ type: "auth", token: this.opts.token, repo: this.opts.repo });
80
+ });
81
+ ws.on("message", (data) => {
82
+ const raw = data.toString();
83
+ let msg;
84
+ try {
85
+ msg = parseServerMessage(raw);
86
+ }
87
+ catch {
88
+ this.opts.log(`Failed to parse server message: ${raw}`);
89
+ return;
90
+ }
91
+ if (msg.type === "event") {
92
+ this.opts.onEvent(msg.id, msg.payload, msg.notification);
93
+ }
94
+ else if (msg.type === "ping") {
95
+ this.send({ type: "pong" });
96
+ }
97
+ else if (msg.type === "displaced") {
98
+ this.opts.log(`Displaced: ${msg.reason}`);
99
+ this.shouldReconnect = false;
100
+ this.opts.onDisplaced(msg.reason);
101
+ }
102
+ else if (msg.type === "limit_reached") {
103
+ this.opts.onLimitReached?.(msg.message);
104
+ }
105
+ });
106
+ ws.on("close", (code, reason) => {
107
+ this.ws = null;
108
+ if (code === 4001) {
109
+ this.opts.log(`Connection closed with displacement code 4001: ${reason.toString()}`);
110
+ this.shouldReconnect = false;
111
+ this.opts.onDisplaced(reason.toString() || "Displaced by server (code 4001)");
112
+ return;
113
+ }
114
+ this.opts.log(`Connection closed (code ${code}). shouldReconnect=${this.shouldReconnect}`);
115
+ if (this.shouldReconnect) {
116
+ this.scheduleReconnect();
117
+ }
118
+ });
119
+ ws.on("error", (err) => {
120
+ this.opts.log(`WebSocket error: ${err.message}`);
121
+ });
122
+ }
123
+ send(msg) {
124
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
125
+ this.ws.send(JSON.stringify(msg));
126
+ }
127
+ }
128
+ ack(eventId) {
129
+ this.send({ type: "ack", id: eventId });
130
+ }
131
+ updateSession(issue, sessionId) {
132
+ this.send({ type: "session_update", issue, session_id: sessionId });
133
+ }
134
+ isConnected() {
135
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
136
+ }
137
+ disconnect() {
138
+ this.shouldReconnect = false;
139
+ if (this.ws) {
140
+ this.ws.close();
141
+ }
142
+ }
143
+ scheduleReconnect() {
144
+ this.opts.log(`Reconnecting in ${this.reconnectDelay}ms...`);
145
+ setTimeout(() => {
146
+ if (this.shouldReconnect) {
147
+ this.connect();
148
+ }
149
+ }, this.reconnectDelay);
150
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay);
151
+ }
152
+ }
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "sentinel-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Sentinel MCP server — connects GitHub issues to Claude Code sessions",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "sentinel-mcp": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "scripts": {
14
+ "build": "tsc",
15
+ "dev": "tsx src/index.ts",
16
+ "prepublishOnly": "npm run build",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
19
+ },
20
+ "keywords": ["mcp", "sentinel", "claude", "github", "automation"],
21
+ "license": "MIT",
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "^1.27.1",
24
+ "ws": "^8.18.0",
25
+ "zod": "^4.3.6"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^22.13.0",
29
+ "@types/ws": "^8.5.0",
30
+ "tsx": "^4.19.0",
31
+ "typescript": "^5.7.0",
32
+ "vitest": "^3.1.0"
33
+ }
34
+ }