Skip to content

GLOOK-26: Glooker MCP server (read-only data + analysis over MCP) - #59

Merged
msogin merged 22 commits into
mainfrom
feat/glook-26-mcp-server
Jul 23, 2026
Merged

GLOOK-26: Glooker MCP server (read-only data + analysis over MCP)#59
msogin merged 22 commits into
mainfrom
feat/glook-26-mcp-server

Conversation

@msogin

@msogin msogin commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a read-only MCP server exposing Glooker's data and analysis so managers, execs, and engineers can query it from Claude Code / Claude.ai — including cross-report analysis over time, not just a single report snapshot.

  • New /api/mcp endpoint — stateless Streamable-HTTP, hand-rolled JSON-RPC (no @modelcontextprotocol/sdk, no new dependencies)
  • 14 read-only tools (JSON-Schema, matching the existing chat/tools.ts pattern):
    • Discovery: list_reports, get_org_summary
    • Raw entities: query_commits, query_jira_issues, query_developer_stats, query_unmerged_work
    • LLM/semantic: get_project_insights, get_project_details, get_highlights, get_team_pulse, get_developer_summary, get_release_notes, get_epic_summaries
    • Time-series: get_metric_timeseries
  • Cross-report dedup + week/month/dimension bucketing for "over time" questions
  • Refactored project-insights and release-notes routes into shared services (getProjectInsights, getReleaseNotes) so the route and MCP tool share one implementation
  • .mcp.json for local connection + .env.example proxy docs

Architecture

route.ts (transport) → protocol.ts (JSON-RPC) → tools.ts (registry) → queries.ts + existing services

Auth is handled out-of-band by the Smartling mcp-okta-proxy sidecar in AWS (set OKTA_MCP_PROXY_USERINFO_HEADER_NAME=x-amzn-oidc-data); the route reads identity only for request logging. Deployment (Terraform/ECS/Okta) is tracked separately.

Testing

  • Full suite: 801 passing, tsc --noEmit clean
  • Unit tests for the JSON-RPC protocol, tool registry, query functions (dedup/bucketing pure helpers, both DB dialects via mocked DB), and the route
  • Verified via local podman deploy against real MySQL data: 14 tools listed; list_reports / get_org_summary / get_metric_timeseries return accurate results; commits/week returns the full 23-week range with no truncation

Notes

  • get_metric_timeseries dedup is done in SQL (GROUP BY + MIN(timestamp)) so it stays bounded on production-sized data; group_by=report returns per-report totals
  • Local testing surfaced a data issue (not in this PR): jira_issues.resolved_at are zero-dates in the current DB, so the jira_resolved time-series is empty until ingestion is fixed — tracked in GLOOK-33

Design spec: docs/superpowers/specs/2026-07-21-glook-26-mcp-server-design.md

🤖 Generated with Claude Code

msogin and others added 20 commits July 21, 2026 16:43
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d JSON-RPC, JS-side dedup)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
queryCommits unconditionally called reportOrg, wasting a DB query in
report-scoped mode where org is never used. Moved the reportOrg call to
only execute in the cross-report branch, matching queryJiraIssues behavior.

Also updated the report-scoped test case to remove the mock for the org
lookup that no longer happens.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The clampLimit helper now validates that the limit is a positive integer,
falling back to the default for negative, zero, NaN, or non-finite inputs.
Fractional limits are floored to integers. This prevents LIMIT -1 (which
means "no limit" on SQLite and is a syntax error on MySQL) and other edge
cases from bypassing the MAX_ROWS cap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- narrow query-result unions in mcp-queries.test.ts via type assertions
  (ts-jest skips full type-check due to isolatedModules, so tsc --noEmit
  was the first gate to catch these)
- give getHandler an unused Request param so GET's exported type matches
  the (req: Request) => Promise<Response> shape used in mcp-route.test.ts
…GLOOK-26)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…per-report totals (GLOOK-26)

Row-metric timeseries deduped raw rows in JS after a pre-dedup LIMIT, so on
production-sized data it capped at 20k rows (ORDER BY committed_at ASC) and
silently dropped the most recent buckets. Move dedup into SQL (GROUP BY the
entity key with MIN(timestamp)) so Node receives one row per distinct
commit/PR/issue — accurate counts, bounded by real activity. group_by=report
now returns true per-report totals (no cross-report dedup, its natural meaning).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…objects) (GLOOK-26)

String(date).slice(0,10) yielded 'Mon Mar 16' for mysql2 Date objects instead
of an ISO date, producing malformed/colliding bucket labels in group_by=report
and the impact_score/ai_percentage branch. Add isoDate() normalizing Date|string.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@msogin msogin left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Combined Review Summary — 3 reviews (Sr. Architect persona, Security Engineer persona, standard Smartling fullstack review prompt)

Findings: 13 inline comments — 1 critical, 6 warnings, 3 suggestions, 3 questions (overlapping findings from different reviewers collapsed into one comment where they landed on the same line).

Highest priority

  • 🔴 No in-app auth (route.ts) — every MCP tool, including per-developer cost/performance data and cross-org commit/Jira data, is reachable with zero authentication enforced in this codebase. All enforcement is delegated to a separate mcp-okta-proxy sidecar that is explicitly out of scope for this PR and not verifiable from this diff. Recommend an explicit sign-off (not just documentation) that the proxy lands in the same deploy window and that the app port cannot be reached except through it, in every environment this deploys to.
  • 🟡 Dedup-after-limit bug in queryCommits / queryJiraIssues (found independently by two of the three reviews) — cross-report queries can silently return fewer than limit distinct results. The same bug class was already found and fixed for getMetricTimeseries in this same PR; the fix just needs to be back-ported to its two siblings.

Also worth a look (not tied to one line)

  • No rate limiting on /api/mcp beyond per-call row caps — an automated client can loop across all reports/orgs; a natural consequence of exposing a general-purpose query interface to agents.
  • docs/superpowers/plans/2026-07-21-glook-26-mcp-server.md (the committed plan doc) describes the old JS-side dedup approach for getMetricTimeseries that the shipped code already improved on (moved to SQL-side dedup) — the doc is now stale relative to the code it describes.
  • No top-level README section documenting the new /api/mcp capability for engineers who don't read the internal planning docs.

Per-reviewer verdict

  • Architecture (Sr. Architect + Gaps): Safe to merge — layering is sound, the project-insights/release-notes extraction is a faithful mechanical refactor, no findings rose to "Rethink" level.
  • Security (Security Engineer + Security): Does not clearly meet "safe to merge" as currently scoped — wants explicit confirmation the proxy-only auth model is airtight before this deploys anywhere reachable outside localhost.
  • Standard Smartling review: Ready to merge with fixes — the dedup bug should be fixed, and the proxy-only auth model needs an explicit sign-off before deploying outside localhost.

Posted from local review tooling (review-loop personas + the standard Smartling claude-pr-review fullstack prompt, run locally) — no code changes were made as part of this review.

Comment thread src/app/api/mcp/route.ts
import { handleJsonRpc } from '@/lib/mcp/protocol';
import { extractUser } from '@/lib/auth';

async function postHandler(req: Request) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 critical
No authentication or authorization is enforced in this handler (or anywhere in the MCP request path). Every one of the 14 MCP tools — including per-developer cost/performance data (impact_score, cc_total_cost, ai_percentage) and cross-org commit/Jira/epic data — is reachable by anyone who can POST to /api/mcp. Enforcement is entirely delegated to a separate mcp-okta-proxy sidecar that is explicitly "tracked separately" per the design doc and is not part of this PR.

Fix: add an in-app fail-closed check (e.g. require a shared-secret header from the proxy, or reject if no identity header is present) so this doesn't silently become a fully open read API if the proxy is missing, misconfigured, or the app port becomes reachable another way before/without it.

Comment thread src/app/api/mcp/route.ts Outdated

async function postHandler(req: Request) {
// Identity is read for request-log attribution only (read-only server, no gating).
try { extractUser(req.headers); } catch { /* no-op */ }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion
extractUser(req.headers) here has its return value discarded and is wrapped in a swallowing catch — it has no observable effect. withRequestLog (from @/lib/logger) already calls extractUser independently to populate userEmail in the log entry, so this duplicate call does nothing.

Fix: remove this line, or if it's meant as future-proofing for per-user gating, add a comment clarifying that.

Comment thread src/app/api/mcp/route.ts
}

async function optionsHandler() {
return new Response(null, { status: 204 });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 question
optionsHandler returns a bare 204 with no Access-Control-Allow-* headers. Is this endpoint intentionally server-to-server only (no browser CORS preflight expected)? If any client ever calls this from a browser context, the preflight will fail silently.

Comment thread src/lib/mcp/queries.ts
return rows[0]?.org ?? null;
}

export async function listReports(args: { org?: string; status?: string; limit?: number }) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 question
listReports with no org filter returns reports across all orgs, and none of the query tools in this file scope results by the caller's identity/tenant — access is effectively "any authenticated user sees everything." Is this the intended trust model (single company, all engineers see all org analytics), or should this be scoped per-caller the way the existing requireAdmin-gated report-creation route restricts writes?

Comment thread src/lib/mcp/queries.ts Outdated
FROM commit_analyses ca
WHERE ${conditions.join(' AND ')}
ORDER BY ca.committed_at DESC
LIMIT ?`,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning
queryCommits's cross-report path applies LIMIT ? to the raw, pre-dedup rows, then dedups by commit_sha afterward in JS (line 120). If the same commit appears in multiple overlapping report windows, a caller requesting limit: 100 can silently get back fewer than 100 distinct commits, with no truncated flag to signal it happened.

This exact bug class was found and fixed in getMetricTimeseries further down this file (moved dedup into SQL via GROUP BY before LIMIT — see the comment above the row-based branch) — worth applying the same pattern here.

Fix: GROUP BY commit_sha with MIN(committed_at) (or similar) before the LIMIT, instead of limiting raw rows and deduping after.

Comment thread src/lib/mcp/queries.ts Outdated
const params: any[] = [org];
if (args.since) { conditions.push(`${alias}.${tsCol} >= ?`); params.push(args.since); }
if (args.until) { conditions.push(`${alias}.${tsCol} <= ?`); params.push(args.until); }
if (args.since === undefined && args.until === undefined) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning
The default 180-day window only applies when args.since === undefined && args.until === undefined. A caller passing since: '' (empty string) skips both the explicit filter above (if (args.since) is falsy) and this default-window fallback (since '' is not undefined), resulting in an unbounded query up to TIMESERIES_ROW_CAP (20000 rows).

Fix: normalize/trim since/until before this check, or treat falsy strings the same as missing values.

Comment thread src/lib/mcp/tools.ts

// get_project_details: filter the insights payload down to one project by name.
async function getProjectDetails(args: { project_name: string; report_id?: string }) {
const insights: any = await getProjectInsights(args.report_id);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion
getProjectDetails (here), getTeamPulseTool (line 34), and getDeveloperSummaryTool (line 47) are net-new, non-trivial handlers (case-insensitive project matching + "not found" list; team-membership lookup + "team not found" branch), but mcp-tools.test.ts only exercises list_reports end-to-end and the registry shape — none of these three branches, including their error paths, have dedicated tests, unlike every pure helper/query function elsewhere in this PR.

Worth adding unit tests for these three handlers, especially the not-found branches.

Comment thread src/lib/mcp/tools.ts
return await tool.handler(args ?? {});
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning
On handler failure, the raw err.message (or String(err)) is returned to the MCP caller as tool content. This can leak internal implementation detail (DB error text, occasionally table/column names) to whatever client/agent is talking to the MCP server — a new, broader surface purpose-built for machine consumption vs. the existing HTTP routes with the same pattern.

Consider returning a generic error message to the caller and logging the real err server-side instead.

Comment thread src/lib/mcp/protocol.ts
case 'tools/list':
return ok(id, { tools: MCP_TOOLS.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })) });
case 'tools/call': {
const name = params?.name;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 suggestion
Several tools declare required fields in their JSON-Schema inputSchema (e.g. get_project_details.project_name, get_team_pulse.team/org, get_developer_summary.login, get_metric_timeseries.metric — see tools.ts), but neither this dispatch nor callTool validates presence before invoking the handler. A tools/call missing a required arg flows straight into query builders with undefined, which can produce a confusing downstream error (or wrong result, e.g. "project not found") instead of a clean -32602 Invalid params.

Consider a small required-field check here (or in callTool) driven by each tool's inputSchema.required.


return { available: true as const, summary, commitCount: commits.length, generatedAt: new Date().toISOString(), latestSha, cached: false };
} catch (err) {
console.error('[release-notes]', err);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 warning
Every failure mode here (missing token, GitHub API error, LLM error, DB error) collapses into the same { available: false } as "no new notes yet." This was pre-existing behavior in the original route, but it's now also reachable via the get_release_notes MCP tool, where an autonomous caller has no way to distinguish "nothing to report" from "this is broken."

Consider including an error field (distinct from the normal empty case) when the failure is unexpected (API/DB error) vs. expected (no commits in window).

msogin and others added 2 commits July 22, 2026 17:20
…r masking, validation (GLOOK-26)

- queryCommits/queryJiraIssues: dedup cross-report results in SQL (GROUP BY key)
  so LIMIT bounds distinct entities, not raw rows (could under-return before)
- getEpicSummaries: add limit + LIMIT clause (was unbounded)
- get_metric_timeseries: treat empty/whitespace since/until as absent so the
  default 180-day window isn't bypassed by since:''
- callTool: mask raw error messages to the MCP caller, log real error server-side
- protocol: validate declared required fields → clean -32602 before dispatch
- release-notes service: distinguish unexpected failures (error field) from the
  normal empty case
- route: drop redundant extractUser call (withRequestLog already attributes logs)
- tests for the not-found handler branches, required-field validation, error masking

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…K-26)

- /api/mcp returns 401 when AUTH_ENABLED=true and the proxy identity header is
  absent — defense-in-depth so a missing/misconfigured proxy can't expose an
  open read API. Off when AUTH_ENABLED unset (local dev/mock).
- Document the single-company read-only trust model (any authenticated employee
  sees org-wide analytics incl. per-dev cost/impact; how to restrict later).
- README: new MCP Server section (tools, connect commands, auth).
- .env.example: MCP auth backstop + trust model.
- Plan doc: note shipped code moved dedup to SQL (supersedes Task 4-5 blocks).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@msogin

msogin commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Review findings addressed

Pushed two commits (ddce651, and the fixes commit before it). Full suite 809 passing, tsc clean. Mapping each comment to its resolution:

🔴 Critical

  • No in-app auth (route.ts) → Added a fail-closed backstop: when AUTH_ENABLED=true, /api/mcp returns 401 unless the proxy-injected identity header (AUTH_HEADER, default x-amzn-oidc-data) is present. Off when AUTH_ENABLED is unset (local dev / mock). The mcp-okta-proxy remains the primary auth layer; this ensures a missing/misconfigured proxy can't silently expose an open read API. Documented the "set AUTH_ENABLED=true wherever the app port is reachable" requirement in .env.example + README. Tests cover the 401 and the authenticated-passes paths.

🟡 Warnings

  • Dedup-after-limit in queryCommits / queryJiraIssues → back-ported the SQL-side dedup fix: cross-report path now GROUP BY the entity key (with MIN() columns) before LIMIT, so limit bounds distinct entities. Report-scoped path stays a plain SELECT (key is unique per report). Tests assert the GROUP BY/no-GROUP BY split.
  • getEpicSummaries unbounded → added a limit arg + clampLimit/LIMIT like the sibling tools.
  • Empty-string since bypasses the default windowsince/until are now trimmed and treated as absent when empty, so the 180-day default still applies. Test added.
  • callTool leaks raw err.message → now logs the real error server-side and returns a generic Tool "<name>" failed. See server logs. to the caller. Test asserts internal detail isn't surfaced.
  • release-notes collapses all failures to {available:false} → unexpected failures (no token, GitHub API error, LLM/DB error) now carry an error field, distinct from the genuine empty case.

🔵 Suggestions

  • Dead extractUser call in route.ts → removed (withRequestLog already attributes the log).
  • No tests for the net-new handlers → added unit tests for get_project_details (not-found + available list), get_team_pulse (no-members error), get_developer_summary (no-report error).
  • No required-field validation before dispatchprotocol.ts now validates each tool's inputSchema.required and returns a clean -32602 for a missing arg. Test added.

🟣 Questions

  • CORS on optionsHandler → intentional: this is a server-to-server endpoint behind the proxy; no browser CORS preflight is expected, so no Access-Control-Allow-* headers. Left as 204.
  • Tenant scoping / who sees per-developer cost & performance → deliberate single-company, read-only trust model: any authenticated employee reads org-wide analytics (same data the web UI shows). Documented in the spec + .env.example, with a note on how to gate per-developer cost/perf on AUTH_ADMIN_GROUP if that changes later. (The new auth backstop already surfaces the identity/groups needed to do so.)

Also-worth-a-look items

  • Stale plan doc → added a header note that the shipped code moved dedup to SQL, superseding the Task 4–5 JS-dedup blocks; the spec + source are authoritative.
  • No README section → added a MCP Server section (tool list, claude mcp add commands, auth model).
  • No rate limiting → not added in this PR; it's a proxy/infra concern (per-call row caps bound individual queries). Can file a follow-up if we want app-level limits.

Separately, local testing against real data surfaced a data issue (not code): jira_issues.resolved_at are zero-dates, so the jira_resolved time-series is empty until ingestion is fixed — tracked in GLOOK-33.

@msogin
msogin merged commit aa25b63 into main Jul 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant