sentinel-mcp 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6,6 +6,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
6
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
7
  import { z } from "zod";
8
8
  import { WsClient, decodeInstallationId } from "./ws-client.js";
9
+ import { EventQueue } from "./queue.js";
9
10
  // ---------------------------------------------------------------------------
10
11
  // Config from environment
11
12
  // ---------------------------------------------------------------------------
@@ -36,6 +37,8 @@ try {
36
37
  catch {
37
38
  log("WARNING: Could not resolve repo name from git remote — events will not be filtered by repo");
38
39
  }
40
+ const MAX_CONCURRENCY = parseInt(process.env.SENTINEL_MAX_CONCURRENCY ?? "3", 10);
41
+ const eventQueue = new EventQueue({ maxConcurrency: MAX_CONCURRENCY, recoverActive: true });
39
42
  // ---------------------------------------------------------------------------
40
43
  // MCP server setup
41
44
  // ---------------------------------------------------------------------------
@@ -59,6 +62,21 @@ function pushNotification(content, meta) {
59
62
  log(`Failed to push notification: ${err.message}`);
60
63
  });
61
64
  }
65
+ function updateEventStatus(usageId, status) {
66
+ if (!usageId)
67
+ return;
68
+ const httpUrl = RELAY_URL.replace("wss://", "https://").replace("ws://", "http://");
69
+ fetch(`${httpUrl}/api/dashboard/events/${usageId}/status`, {
70
+ method: "PUT",
71
+ headers: {
72
+ "Content-Type": "application/json",
73
+ Authorization: `Bearer ${SENTINEL_TOKEN}`,
74
+ },
75
+ body: JSON.stringify({ status }),
76
+ }).catch((err) => {
77
+ log(`Failed to update event status: ${err.message}`);
78
+ });
79
+ }
62
80
  // ---------------------------------------------------------------------------
63
81
  // GitHub token management
64
82
  // ---------------------------------------------------------------------------
@@ -131,16 +149,22 @@ const wsClient = new WsClient({
131
149
  relayUrl: RELAY_URL,
132
150
  repo: REPO_NAME || undefined,
133
151
  log,
134
- onEvent: (id, payload, notification) => {
135
- // Use pre-built notification from relay if available, otherwise build a simple one
136
- if (notification) {
137
- pushNotification(notification.content, notification.meta);
152
+ onEvent: (id, payload, notification, usageId) => {
153
+ const added = eventQueue.enqueue(id, usageId ?? null, payload, notification ?? null);
154
+ if (!added) {
155
+ log(`Duplicate event ${id}, skipping`);
138
156
  }
139
157
  else {
140
- const issueNum = payload.type === "pr_merged" ? payload.issue_number : payload.issue?.number;
141
- pushNotification(`${payload.type} event for #${issueNum} in ${payload.repo}`, { type: payload.type, repo: payload.repo, issue: String(issueNum) });
158
+ updateEventStatus(usageId ?? null, "pending");
159
+ log(`Queued event ${id} (${payload.type} #${payload.type === "pr_merged" ? payload.issue_number : payload.issue?.number}) — queue: ${eventQueue.queueLength()}`);
142
160
  }
161
+ // ACK immediately (relay considers it delivered to MCP client)
143
162
  wsClient.ack(id);
163
+ // If capacity available, nudge Claude
164
+ if (eventQueue.hasCapacity() && eventQueue.queueLength() > 0) {
165
+ const issueNum = payload.type === "pr_merged" ? payload.issue_number : payload.issue?.number;
166
+ pushNotification(`New event queued: issue #${issueNum} in ${payload.repo} (${payload.type}). Call claim_event to start processing.`, { type: "queue_nudge", queueLength: String(eventQueue.queueLength()) });
167
+ }
144
168
  },
145
169
  onDisplaced: (reason) => {
146
170
  pushNotification(`Sentinel disconnected: ${reason}`, { type: "displaced" });
@@ -235,6 +259,51 @@ server.registerTool("get_skill", {
235
259
  const result = await mcpToolCall("get_skill", { name });
236
260
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
237
261
  });
262
+ server.registerTool("claim_event", {
263
+ description: "Claim the next queued event for processing. Returns event details or null if queue is empty.",
264
+ }, async () => {
265
+ const event = eventQueue.claim();
266
+ if (!event) {
267
+ return { content: [{ type: "text", text: JSON.stringify(null) }] };
268
+ }
269
+ updateEventStatus(event.usageId, "processing");
270
+ return {
271
+ content: [{
272
+ type: "text",
273
+ text: JSON.stringify({
274
+ id: event.id,
275
+ usageId: event.usageId,
276
+ notification: event.notification,
277
+ payload: event.payload,
278
+ }),
279
+ }],
280
+ };
281
+ });
282
+ server.registerTool("release_event", {
283
+ description: "Release an event after processing completes (success or failure). Triggers next event if queue is non-empty.",
284
+ inputSchema: {
285
+ id: z.string().describe("Event ID returned by claim_event"),
286
+ },
287
+ }, async ({ id }) => {
288
+ const released = eventQueue.release(id);
289
+ if (!released) {
290
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Event not found in active list" }) }] };
291
+ }
292
+ // If queue has items and capacity available, nudge Claude
293
+ if (eventQueue.hasCapacity() && eventQueue.queueLength() > 0) {
294
+ const status = eventQueue.status();
295
+ const next = status.queue[0];
296
+ pushNotification(`Event released. Next in queue: issue #${next.issueNumber} in ${next.repo} (${next.eventType}). Call claim_event to process.`, { type: "queue_nudge", queueLength: String(status.queueLength) });
297
+ }
298
+ return { content: [{ type: "text", text: JSON.stringify({ released: true }) }] };
299
+ });
300
+ server.registerTool("queue_status", {
301
+ description: "Get the current event queue status — queue length, active count, and details.",
302
+ }, async () => {
303
+ return {
304
+ content: [{ type: "text", text: JSON.stringify(eventQueue.status(), null, 2) }],
305
+ };
306
+ });
238
307
  // ---------------------------------------------------------------------------
239
308
  // Startup
240
309
  // ---------------------------------------------------------------------------
@@ -0,0 +1,40 @@
1
+ import type { QueuedEvent, SentinelEvent } from "./types.js";
2
+ export interface EventQueueOptions {
3
+ queueFilePath?: string;
4
+ maxConcurrency?: number;
5
+ recoverActive?: boolean;
6
+ }
7
+ export declare class EventQueue {
8
+ private state;
9
+ private filePath;
10
+ constructor(opts?: EventQueueOptions);
11
+ enqueue(id: string, usageId: string | null, payload: SentinelEvent, notification: {
12
+ content: string;
13
+ meta: Record<string, string>;
14
+ } | null): boolean;
15
+ claim(): QueuedEvent | null;
16
+ release(id: string): boolean;
17
+ hasCapacity(): boolean;
18
+ queueLength(): number;
19
+ status(): {
20
+ queueLength: number;
21
+ activeCount: number;
22
+ maxConcurrency: number;
23
+ queue: Array<{
24
+ id: string;
25
+ eventType: string;
26
+ repo: string;
27
+ issueNumber: number;
28
+ priority: string;
29
+ enqueuedAt: string;
30
+ }>;
31
+ active: Array<{
32
+ id: string;
33
+ repo: string;
34
+ issueNumber: number;
35
+ startedAt: string;
36
+ }>;
37
+ };
38
+ private load;
39
+ private persist;
40
+ }
package/dist/queue.js ADDED
@@ -0,0 +1,123 @@
1
+ import fs from "fs";
2
+ import path from "path";
3
+ import os from "os";
4
+ export class EventQueue {
5
+ state;
6
+ filePath;
7
+ constructor(opts = {}) {
8
+ this.filePath = opts.queueFilePath ?? path.join(os.homedir(), ".sentinel", "queue.json");
9
+ const maxConcurrency = opts.maxConcurrency ?? 3;
10
+ this.state = this.load(maxConcurrency);
11
+ if (opts.recoverActive && this.state.active.length > 0) {
12
+ for (const active of this.state.active) {
13
+ this.state.queue.unshift({
14
+ id: active.id,
15
+ usageId: active.usageId,
16
+ priority: "high",
17
+ eventType: active.payload.type,
18
+ repo: active.repo,
19
+ issueNumber: active.issueNumber,
20
+ notification: active.notification,
21
+ payload: active.payload,
22
+ enqueuedAt: new Date().toISOString(),
23
+ });
24
+ }
25
+ this.state.active = [];
26
+ this.persist();
27
+ }
28
+ }
29
+ enqueue(id, usageId, payload, notification) {
30
+ if (this.state.queue.some((e) => e.id === id) || this.state.active.some((e) => e.id === id)) {
31
+ return false;
32
+ }
33
+ const issueNumber = payload.type === "pr_merged" ? payload.issue_number : payload.issue.number;
34
+ const isFollowUp = this.state.active.some((a) => a.issueNumber === issueNumber && a.repo === payload.repo);
35
+ const priority = isFollowUp ? "high" : "normal";
36
+ const entry = {
37
+ id,
38
+ usageId,
39
+ priority,
40
+ eventType: payload.type,
41
+ repo: payload.repo,
42
+ issueNumber,
43
+ notification,
44
+ payload,
45
+ enqueuedAt: new Date().toISOString(),
46
+ };
47
+ if (priority === "high") {
48
+ const firstNormal = this.state.queue.findIndex((e) => e.priority === "normal");
49
+ if (firstNormal === -1) {
50
+ this.state.queue.push(entry);
51
+ }
52
+ else {
53
+ this.state.queue.splice(firstNormal, 0, entry);
54
+ }
55
+ }
56
+ else {
57
+ this.state.queue.push(entry);
58
+ }
59
+ this.persist();
60
+ return true;
61
+ }
62
+ claim() {
63
+ if (this.state.queue.length === 0)
64
+ return null;
65
+ const event = this.state.queue.shift();
66
+ this.state.active.push({
67
+ id: event.id,
68
+ usageId: event.usageId,
69
+ issueNumber: event.issueNumber,
70
+ repo: event.repo,
71
+ notification: event.notification,
72
+ payload: event.payload,
73
+ startedAt: new Date().toISOString(),
74
+ });
75
+ this.persist();
76
+ return event;
77
+ }
78
+ release(id) {
79
+ const idx = this.state.active.findIndex((e) => e.id === id);
80
+ if (idx === -1)
81
+ return false;
82
+ this.state.active.splice(idx, 1);
83
+ this.persist();
84
+ return true;
85
+ }
86
+ hasCapacity() {
87
+ return this.state.active.length < this.state.maxConcurrency;
88
+ }
89
+ queueLength() {
90
+ return this.state.queue.length;
91
+ }
92
+ status() {
93
+ return {
94
+ queueLength: this.state.queue.length,
95
+ activeCount: this.state.active.length,
96
+ maxConcurrency: this.state.maxConcurrency,
97
+ queue: this.state.queue.map((e) => ({
98
+ id: e.id, eventType: e.eventType, repo: e.repo,
99
+ issueNumber: e.issueNumber, priority: e.priority, enqueuedAt: e.enqueuedAt,
100
+ })),
101
+ active: this.state.active.map((e) => ({
102
+ id: e.id, repo: e.repo, issueNumber: e.issueNumber, startedAt: e.startedAt,
103
+ })),
104
+ };
105
+ }
106
+ load(maxConcurrency) {
107
+ try {
108
+ const data = fs.readFileSync(this.filePath, "utf-8");
109
+ const parsed = JSON.parse(data);
110
+ return { ...parsed, maxConcurrency: parsed.maxConcurrency ?? maxConcurrency };
111
+ }
112
+ catch {
113
+ return { queue: [], active: [], maxConcurrency };
114
+ }
115
+ }
116
+ persist() {
117
+ const dir = path.dirname(this.filePath);
118
+ fs.mkdirSync(dir, { recursive: true });
119
+ const tmpPath = this.filePath + ".tmp";
120
+ fs.writeFileSync(tmpPath, JSON.stringify(this.state, null, 2));
121
+ fs.renameSync(tmpPath, this.filePath);
122
+ }
123
+ }
package/dist/types.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export interface IssueEvent {
2
- type: "issue_opened" | "issue_comment" | "issue_closed";
2
+ type: "issue_opened" | "issue_labeled" | "issue_comment" | "issue_closed";
3
3
  repo: string;
4
4
  issue: {
5
5
  number: number;
@@ -29,6 +29,7 @@ export type ServerMessage = {
29
29
  type: "event";
30
30
  id: string;
31
31
  payload: SentinelEvent;
32
+ usageId?: string;
32
33
  notification?: {
33
34
  content: string;
34
35
  meta: Record<string, string>;
@@ -56,3 +57,34 @@ export type ClientMessage = {
56
57
  issue: number;
57
58
  session_id: string;
58
59
  };
60
+ export interface QueuedEvent {
61
+ id: string;
62
+ usageId: string | null;
63
+ priority: "high" | "normal";
64
+ eventType: string;
65
+ repo: string;
66
+ issueNumber: number;
67
+ notification: {
68
+ content: string;
69
+ meta: Record<string, string>;
70
+ } | null;
71
+ payload: SentinelEvent;
72
+ enqueuedAt: string;
73
+ }
74
+ export interface ActiveEvent {
75
+ id: string;
76
+ usageId: string | null;
77
+ issueNumber: number;
78
+ repo: string;
79
+ notification: {
80
+ content: string;
81
+ meta: Record<string, string>;
82
+ } | null;
83
+ payload: SentinelEvent;
84
+ startedAt: string;
85
+ }
86
+ export interface QueueState {
87
+ queue: QueuedEvent[];
88
+ active: ActiveEvent[];
89
+ maxConcurrency: number;
90
+ }
@@ -20,7 +20,7 @@ export interface WsClientOptions {
20
20
  onEvent: (id: string, payload: any, notification?: {
21
21
  content: string;
22
22
  meta: Record<string, string>;
23
- }) => void;
23
+ }, usageId?: string) => void;
24
24
  onDisplaced: (reason: string) => void;
25
25
  onLimitReached?: (message: string) => void;
26
26
  log: (msg: string) => void;
package/dist/ws-client.js CHANGED
@@ -89,7 +89,7 @@ export class WsClient {
89
89
  return;
90
90
  }
91
91
  if (msg.type === "event") {
92
- this.opts.onEvent(msg.id, msg.payload, msg.notification);
92
+ this.opts.onEvent(msg.id, msg.payload, msg.notification, msg.usageId);
93
93
  }
94
94
  else if (msg.type === "ping") {
95
95
  this.send({ type: "pong" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sentinel-mcp",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Sentinel MCP server — connects GitHub issues to Claude Code sessions",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",