Skip to content

Commit d7345a7

Browse files
committed
fix(mcp): addresses review findings across all reviewers
1 parent d911d2d commit d7345a7

File tree

8 files changed

+218
-108
lines changed

8 files changed

+218
-108
lines changed

.env.example

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ VITE_GITHUB_CLIENT_ID=your_oauth_app_client_id_here
1010
GITHUB_TOKEN=your_github_token_here
1111

1212
# Port for the WebSocket relay server (MCP ↔ browser dashboard bridge).
13-
# Default: 3001
14-
# MCP_WS_PORT=3001
13+
# Default: 9876
14+
# MCP_WS_PORT=9876

mcp/src/data-source.ts

Lines changed: 115 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,8 @@ interface WorkflowRunRaw {
163163
created_at: string;
164164
updated_at: string;
165165
run_started_at: string;
166+
// BUG-007: completed_at is present in GitHub API response but was missing from the interface.
167+
completed_at: string | null;
166168
run_attempt: number;
167169
display_title: string;
168170
actor: { login: string } | null;
@@ -187,7 +189,7 @@ function mapWorkflowRun(raw: WorkflowRunRaw, repoFullName: string): WorkflowRun
187189
repoFullName,
188190
isPrRun: Array.isArray(raw.pull_requests) && raw.pull_requests.length > 0,
189191
runStartedAt: raw.run_started_at ?? raw.created_at,
190-
completedAt: null,
192+
completedAt: raw.completed_at ?? null,
191193
runAttempt: raw.run_attempt ?? 1,
192194
displayTitle: raw.display_title ?? raw.name ?? "",
193195
actorLogin: raw.actor?.login ?? "",
@@ -207,12 +209,11 @@ export class OctokitDataSource implements DataSource {
207209

208210
private async getLogin(): Promise<string> {
209211
if (this._login) return this._login;
210-
try {
211-
const { data } = await this.octokit.request("GET /user");
212-
this._login = (data as { login: string }).login;
213-
} catch {
214-
this._login = "";
215-
}
212+
// BUG-006: Throw if login cannot be determined to prevent empty `involves:` query strings.
213+
const { data } = await this.octokit.request("GET /user");
214+
const login = (data as { login: string }).login;
215+
if (!login) throw new Error("Could not determine authenticated user login from GET /user");
216+
this._login = login;
216217
return this._login;
217218
}
218219

@@ -221,21 +222,38 @@ export class OctokitDataSource implements DataSource {
221222
const repos = resolveRepos(repo);
222223
const results: PullRequest[] = [];
223224

224-
for (const r of repos) {
225-
const q = `is:pr+is:open+involves:${login}+repo:${r.owner}/${r.name}`;
226-
try {
227-
const { data } = await this.octokit.request("GET /search/issues", {
228-
q,
229-
per_page: 100,
230-
});
231-
const items = (data as { items: SearchItem[] }).items ?? [];
232-
for (const item of items) {
233-
if (item.pull_request !== undefined) {
234-
results.push(mapSearchItemToPR(item, r.fullName));
225+
// PERF-001: Batch repos into groups of 20 to avoid N+1 REST calls.
226+
// GitHub search supports multiple repo: qualifiers in a single query.
227+
const BATCH_SIZE = 20;
228+
const batches: RepoRef[][] = [];
229+
for (let i = 0; i < repos.length; i += BATCH_SIZE) {
230+
batches.push(repos.slice(i, i + BATCH_SIZE));
231+
}
232+
233+
const batchResults = await Promise.allSettled(
234+
batches.map((batch) => {
235+
const repoFilter = batch.map((r) => `repo:${r.owner}/${r.name}`).join("+");
236+
const q = `is:pr+is:open+involves:${login}+${repoFilter}`;
237+
return this.octokit.request("GET /search/issues", { q, per_page: 100 }).then(({ data }) => {
238+
const items = (data as { items: SearchItem[] }).items ?? [];
239+
const prs: PullRequest[] = [];
240+
for (const item of items) {
241+
if (item.pull_request !== undefined) {
242+
// Derive repo from repository_url (last two segments: owner/name)
243+
const repoFullName = item.repository_url.replace("https://api.github.com/repos/", "");
244+
prs.push(mapSearchItemToPR(item, repoFullName));
245+
}
235246
}
236-
}
237-
} catch (err) {
238-
console.error(`[mcp] getOpenPRs error for ${r.fullName}:`, err instanceof Error ? err.message : String(err));
247+
return prs;
248+
});
249+
})
250+
);
251+
252+
for (const settled of batchResults) {
253+
if (settled.status === "fulfilled") {
254+
results.push(...settled.value);
255+
} else {
256+
console.error("[mcp] getOpenPRs batch error:", settled.reason instanceof Error ? settled.reason.message : String(settled.reason));
239257
}
240258
}
241259

@@ -259,22 +277,37 @@ export class OctokitDataSource implements DataSource {
259277
const repos = resolveRepos(repo);
260278
const results: Issue[] = [];
261279

262-
for (const r of repos) {
263-
const q = `is:issue+is:open+involves:${login}+repo:${r.owner}/${r.name}`;
264-
try {
265-
const { data } = await this.octokit.request("GET /search/issues", {
266-
q,
267-
per_page: 100,
268-
});
269-
const items = (data as { items: SearchItem[] }).items ?? [];
270-
for (const item of items) {
271-
// Filter out PRs from issue search
272-
if (item.pull_request === undefined) {
273-
results.push(mapSearchItemToIssue(item, r.fullName));
280+
// PERF-002: Batch repos into groups of 20 to avoid N+1 REST calls.
281+
const BATCH_SIZE = 20;
282+
const batches: RepoRef[][] = [];
283+
for (let i = 0; i < repos.length; i += BATCH_SIZE) {
284+
batches.push(repos.slice(i, i + BATCH_SIZE));
285+
}
286+
287+
const batchResults = await Promise.allSettled(
288+
batches.map((batch) => {
289+
const repoFilter = batch.map((r) => `repo:${r.owner}/${r.name}`).join("+");
290+
const q = `is:issue+is:open+involves:${login}+${repoFilter}`;
291+
return this.octokit.request("GET /search/issues", { q, per_page: 100 }).then(({ data }) => {
292+
const items = (data as { items: SearchItem[] }).items ?? [];
293+
const issues: Issue[] = [];
294+
for (const item of items) {
295+
// Filter out PRs from issue search
296+
if (item.pull_request === undefined) {
297+
const repoFullName = item.repository_url.replace("https://api.github.com/repos/", "");
298+
issues.push(mapSearchItemToIssue(item, repoFullName));
299+
}
274300
}
275-
}
276-
} catch (err) {
277-
console.error(`[mcp] getOpenIssues error for ${r.fullName}:`, err instanceof Error ? err.message : String(err));
301+
return issues;
302+
});
303+
})
304+
);
305+
306+
for (const settled of batchResults) {
307+
if (settled.status === "fulfilled") {
308+
results.push(...settled.value);
309+
} else {
310+
console.error("[mcp] getOpenIssues batch error:", settled.reason instanceof Error ? settled.reason.message : String(settled.reason));
278311
}
279312
}
280313

@@ -283,22 +316,32 @@ export class OctokitDataSource implements DataSource {
283316

284317
async getFailingActions(repo?: string): Promise<WorkflowRun[]> {
285318
const repos = resolveRepos(repo);
286-
const results: WorkflowRun[] = [];
287319

288-
for (const r of repos) {
289-
for (const status of ["in_progress", "failure"] as const) {
290-
try {
291-
const { data } = await this.octokit.request(
292-
"GET /repos/{owner}/{repo}/actions/runs",
293-
{ owner: r.owner, repo: r.name, status, per_page: 20 }
294-
);
320+
// PERF-003: Collect all {repo, status} pairs and run them in parallel.
321+
const pairs = repos.flatMap((r) =>
322+
(["in_progress", "failure"] as const).map((status) => ({ r, status }))
323+
);
324+
325+
const settled = await Promise.allSettled(
326+
pairs.map(({ r, status }) =>
327+
this.octokit.request(
328+
"GET /repos/{owner}/{repo}/actions/runs",
329+
{ owner: r.owner, repo: r.name, status, per_page: 20 }
330+
).then(({ data }) => {
295331
const runs = (data as { workflow_runs: WorkflowRunRaw[] }).workflow_runs ?? [];
296-
for (const run of runs) {
297-
results.push(mapWorkflowRun(run, r.fullName));
298-
}
299-
} catch (err) {
300-
console.error(`[mcp] getFailingActions error for ${r.fullName} (${status}):`, err instanceof Error ? err.message : String(err));
301-
}
332+
return runs.map((run) => mapWorkflowRun(run, r.fullName));
333+
})
334+
)
335+
);
336+
337+
const results: WorkflowRun[] = [];
338+
for (let i = 0; i < settled.length; i++) {
339+
const result = settled[i];
340+
if (result.status === "fulfilled") {
341+
results.push(...result.value);
342+
} else {
343+
const { r, status } = pairs[i];
344+
console.error(`[mcp] getFailingActions error for ${r.fullName} (${status}):`, result.reason instanceof Error ? result.reason.message : String(result.reason));
302345
}
303346
}
304347

@@ -420,16 +463,19 @@ export class OctokitDataSource implements DataSource {
420463
console.error("[mcp] getDashboardSummary review count error:", err instanceof Error ? err.message : String(err));
421464
}
422465

423-
// Failing runs: count across all repos
424-
for (const r of repos) {
425-
try {
426-
const { data: runData } = await this.octokit.request(
466+
// Failing runs: count across all repos (BUG-008: use total_count, not repo presence).
467+
// Run in parallel with Promise.allSettled for performance (PERF-003).
468+
const failingRunResults = await Promise.allSettled(
469+
repos.map((r) =>
470+
this.octokit.request(
427471
"GET /repos/{owner}/{repo}/actions/runs",
428472
{ owner: r.owner, repo: r.name, status: "failure", per_page: 5 }
429-
);
430-
failingRunCount += (runData as { total_count: number }).total_count > 0 ? 1 : 0;
431-
} catch {
432-
// best-effort
473+
)
474+
)
475+
);
476+
for (const settled of failingRunResults) {
477+
if (settled.status === "fulfilled") {
478+
failingRunCount += (settled.value.data as { total_count: number }).total_count;
433479
}
434480
}
435481

@@ -472,11 +518,18 @@ export class WebSocketDataSource implements DataSource {
472518
}
473519

474520
async getRateLimit(): Promise<RateLimitInfo> {
475-
const raw = await sendRelayRequest(METHODS.GET_RATE_LIMIT, {}) as { limit: number; remaining: number; resetAt: string };
521+
// BUG-002: SPA relay returns { core: {...}, graphql: {...} } — unwrap the core property.
522+
const raw = await sendRelayRequest(METHODS.GET_RATE_LIMIT, {}) as {
523+
core?: { limit: number; remaining: number; resetAt: string };
524+
limit?: number;
525+
remaining?: number;
526+
resetAt?: string;
527+
};
528+
const core = raw.core ?? (raw as { limit: number; remaining: number; resetAt: string });
476529
return {
477-
limit: raw.limit,
478-
remaining: raw.remaining,
479-
resetAt: new Date(raw.resetAt),
530+
limit: core.limit,
531+
remaining: core.remaining,
532+
resetAt: new Date(core.resetAt),
480533
};
481534
}
482535

mcp/src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// FIX-008: All imports are at the top of the file.
12
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
23
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
34
import { z } from "zod";
@@ -8,6 +9,8 @@ import {
89
CompositeDataSource,
910
setCachedConfig,
1011
} from "./data-source.js";
12+
import type { DataSource } from "./data-source.js";
13+
import type { DashboardSummary, Issue, PullRequest, RateLimitInfo, RepoRef, WorkflowRun } from "../../src/shared/types.js";
1114
import { registerTools } from "./tools.js";
1215
import { registerResources } from "./resources.js";
1316
import { startWebSocketServer, closeWebSocketServer, onNotification } from "./ws-relay.js";
@@ -102,9 +105,6 @@ async function main() {
102105
// ── Unavailable data source stub ──────────────────────────────────────────────
103106
// Used when no GITHUB_TOKEN is set — all methods throw a clear error.
104107

105-
import type { DataSource } from "./data-source.js";
106-
import type { DashboardSummary, Issue, PullRequest, RateLimitInfo, RepoRef, WorkflowRun } from "../../src/shared/types.js";
107-
108108
function createUnavailableDataSource(): DataSource {
109109
const err = () => Promise.reject(new Error(
110110
"No GITHUB_TOKEN set and SPA relay is not connected. " +

mcp/src/tools.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ import { isRelayConnected } from "./ws-relay.js";
1717
// ── Formatting helpers ────────────────────────────────────────────────────────
1818

1919
function stalenessLine(): string {
20+
// FIX-009: When relay is connected, ideally we'd show "Data as of X ago." using
21+
// lastUpdatedAt from the SPA snapshot. However, lastUpdatedAt lives in the SPA's
22+
// RelaySnapshot (mcp-relay.ts) and is not currently forwarded to the MCP server.
23+
// Exposing it would require a new relay request or pushing it via a notification.
24+
// For now, keep the hint-based approach: relay mode = no staleness note (data is live),
25+
// Octokit mode = note that data comes from the GitHub API directly.
2026
return isRelayConnected()
2127
? ""
2228
: "\n_(data via GitHub API — connect SPA for live dashboard data)_";

mcp/src/ws-relay.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ let _wss: WebSocketServer | null = null;
2424
let _client: WebSocket | null = null;
2525
let _isAlive = false;
2626
let _heartbeatTimer: ReturnType<typeof setInterval> | null = null;
27+
// PERF-005: Track the pong-timeout handle so we can clear it on disconnect/stop.
28+
let _pongTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
2729
let _idCounter = 0;
2830

2931
interface PendingRequest {
@@ -56,12 +58,14 @@ function buildAllowedOrigins(): Set<string> {
5658
return combined;
5759
}
5860

61+
// PERF-004: Compute allowed origins once at module scope rather than per connection.
62+
const ALLOWED_ORIGINS = buildAllowedOrigins();
63+
5964
function isOriginAllowed(origin: string | undefined): boolean {
6065
// Non-browser clients (e.g. CLI tools) do not send Origin — allow them.
6166
if (origin === undefined) return true;
6267

63-
const allowed = buildAllowedOrigins();
64-
if (allowed.has(origin)) return true;
68+
if (ALLOWED_ORIGINS.has(origin)) return true;
6569

6670
// Allow any localhost/127.0.0.1 origin with any port
6771
try {
@@ -89,8 +93,10 @@ function startHeartbeat(): void {
8993
_isAlive = false;
9094
client.ping();
9195

92-
// Mark stalled if pong not received within timeout
93-
setTimeout(() => {
96+
// PERF-005: Store the pong-timeout handle so stopHeartbeat() can clear it.
97+
if (_pongTimeoutTimer !== null) clearTimeout(_pongTimeoutTimer);
98+
_pongTimeoutTimer = setTimeout(() => {
99+
_pongTimeoutTimer = null;
94100
if (!_isAlive && _client === client && client.readyState === WebSocket.OPEN) {
95101
console.error("[mcp/ws] Pong timeout. Terminating stalled client.");
96102
client.terminate();
@@ -104,6 +110,11 @@ function stopHeartbeat(): void {
104110
clearInterval(_heartbeatTimer);
105111
_heartbeatTimer = null;
106112
}
113+
// PERF-005: Also clear any pending pong-timeout timer.
114+
if (_pongTimeoutTimer !== null) {
115+
clearTimeout(_pongTimeoutTimer);
116+
_pongTimeoutTimer = null;
117+
}
107118
}
108119

109120
// ── Pending request cleanup ───────────────────────────────────────────────────

0 commit comments

Comments
 (0)