From 2f45ce3a425d684cf58ef5276f30e838e7991e1e Mon Sep 17 00:00:00 2001 From: Tobias Grosse-Puppendahl Date: Wed, 29 Apr 2026 06:31:58 +0200 Subject: [PATCH 1/8] docs(openspec): propose mcp log filters and token activity stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an OpenSpec change proposal for filtering MCP logs by tool, status (success/error), token, and date range; and surfacing per-token last-used relative time + 30-day event count on the MCP Access page. No implementation yet — proposal stage only. Made-with: Cursor --- .../changes/add-mcp-log-filters/proposal.md | 28 ++++ .../specs/mcp-monitoring/spec.md | 121 ++++++++++++++++++ .../specs/mcp-token-management/spec.md | 110 ++++++++++++++++ openspec/changes/add-mcp-log-filters/tasks.md | 43 +++++++ 4 files changed, 302 insertions(+) create mode 100644 openspec/changes/add-mcp-log-filters/proposal.md create mode 100644 openspec/changes/add-mcp-log-filters/specs/mcp-monitoring/spec.md create mode 100644 openspec/changes/add-mcp-log-filters/specs/mcp-token-management/spec.md create mode 100644 openspec/changes/add-mcp-log-filters/tasks.md diff --git a/openspec/changes/add-mcp-log-filters/proposal.md b/openspec/changes/add-mcp-log-filters/proposal.md new file mode 100644 index 0000000..9524a26 --- /dev/null +++ b/openspec/changes/add-mcp-log-filters/proposal.md @@ -0,0 +1,28 @@ +# Change: Filter MCP logs by tool, status, token, and date range; surface token activity stats + +## Why + +The MCP log table at `/:projectId/monitoring` currently returns the most recent calls with no way to narrow the view, which makes incident triage and per-token usage review tedious as soon as a project sees more than a single page of traffic. The token overview at `/:projectId/mcp-access` already records `lastUsedAt` per token but only renders it as a date with no time-of-day, and gives no signal of how active a token has been — admins cannot tell at a glance whether a token is dormant, hot, or just used yesterday. + +## What Changes + +- Add a filter bar above the MCP log table with: tool filter (Select populated with the distinct tool names seen in the project's logs), status filter (`All` / `Success` / `Error`), token filter (Select populated from the project's MCP tokens), and a date range picker with two date inputs (start / end) backed by shadcn `Calendar` + `Popover` (date-only granularity, inclusive on both ends). +- Wire the existing `GET /api/projects/:projectId/mcp-logs` query parameters (`toolName`, `tokenId`, `errorOnly`, `from`, `to`) to the new filter controls and reset pagination to page 1 whenever any filter changes. +- Add a `GET /api/projects/:projectId/mcp-logs/tools` endpoint that returns the distinct `toolName` values present in the project's `McpCallLog` collection (excluding `null` / `tools/list`) so the tool filter dropdown is populated from real data rather than hard-coded. +- Add the project's MCP tokens (`GET /api/projects/:projectId/mcp-tokens`) as the source for the token filter — no new endpoint needed there. +- Extend the MCP token list response (`GET /api/projects/:projectId/mcp-tokens`) so each token includes `eventCount30d` (count of `McpCallLog` entries for the token in the last 30 days, computed server-side). Continue to return `lastUsedAt` as today. +- Update the MCP Access page to (a) format the existing `Last Used` column as a relative time string (e.g. "5 min ago", "2 days ago", with an absolute tooltip on hover) including the time-of-day, and (b) add an `Events (30d)` column rendered as a tabular-nums number, with `0` shown muted. +- Install the shadcn `calendar` component (and the existing `popover`) so the date range picker can be built per the shadcn skill — using the date range pattern (Popover trigger button → Calendar with `mode="range"`). + +## Impact + +- Affected specs: + - `mcp-monitoring` — MCP Call Log UI requirement (filter bar, paging reset) and MCP Call Log API requirement (new `tools` sub-endpoint, no breaking changes to existing query params). + - `mcp-token-management` — MCP Access Management UI requirement (Events column + relative Last Used) and Token CRUD API requirement (token list now returns `eventCount30d`). +- Affected code: + - `apps/api/src/routes/mcp-logs.ts` (new `tools` endpoint; existing list endpoint already supports the filters). + - `apps/api/src/routes/mcp-tokens.ts` (aggregation pipeline to attach `eventCount30d` to each token in the list response). + - `apps/frontend/src/routes/_auth/$projectId/monitoring.tsx` (filter bar, query state, date range picker). + - `apps/frontend/src/routes/_auth/$projectId/mcp-access.tsx` (relative time + Events column). + - `packages/ui/src/components/calendar.tsx` (new shadcn component) and a small `DateRangePicker` composed from `Popover` + `Calendar`. + - `apps/docs/src/content/docs/reference/specs/mcp-monitoring.md` and the MCP Access docs page (user-facing change → docs sync). diff --git a/openspec/changes/add-mcp-log-filters/specs/mcp-monitoring/spec.md b/openspec/changes/add-mcp-log-filters/specs/mcp-monitoring/spec.md new file mode 100644 index 0000000..3268fd2 --- /dev/null +++ b/openspec/changes/add-mcp-log-filters/specs/mcp-monitoring/spec.md @@ -0,0 +1,121 @@ +## ADDED Requirements + +### Requirement: Distinct Tool Names Endpoint + +The API SHALL expose a `GET /api/projects/:projectId/mcp-logs/tools` endpoint that returns the distinct non-null `toolName` values found in the project's `McpCallLog` collection, sorted alphabetically, as a JSON array of strings. The endpoint SHALL require admin session authentication. + +#### Scenario: Returns distinct tool names + +- **WHEN** `GET /api/projects/:projectId/mcp-logs/tools` is called for a project with logs containing `execute_query`, `execute_query`, `get_semantic_model`, and a `tools/list` call (`toolName: null`) +- **THEN** the response is `["execute_query", "get_semantic_model"]` + +#### Scenario: Empty project returns empty array + +- **WHEN** `GET /api/projects/:projectId/mcp-logs/tools` is called for a project with no logs +- **THEN** the response is `[]` +- **AND** the status code is 200 + +#### Scenario: Unauthenticated request is rejected + +- **WHEN** `GET /api/projects/:projectId/mcp-logs/tools` is called without a valid admin session +- **THEN** a 401 response is returned + +## MODIFIED Requirements + +### Requirement: MCP Call Log UI + +The monitoring page at `/:projectId/monitoring` SHALL display a table of MCP call log entries for the current project using the same Card > Table layout as the MCP Access page but with more vertically condensed rows. The table SHALL show columns: timestamp, token name, method/tool name, duration, and status (success/error badge). Clicking a row SHALL open a detail view showing the full input arguments as formatted JSON and the full output content rendered as markdown. The page SHALL support pagination and provide a refresh button. The page SHALL default to showing only `tools/call` entries, with a toggle to include `tools/list` entries. + +The page SHALL render a filter bar directly above the table (using the project's inline filter convention — `.filter-trigger` styling, `flex items-center gap-1.5`) with the following controls, in order: a Tool selector populated from the project's distinct tool names (`GET /mcp-logs/tools`), a Status selector with options `All` / `Success` / `Error`, a Token selector populated from the project's active MCP tokens, and a date range picker composed of a shadcn `Popover` + `Calendar` (`mode="range"`) presenting two calendar inputs for start and end date. When at least one filter is active, a ghost icon `X` button SHALL appear to clear all filters. Changing any filter SHALL reset pagination to page 1 and refetch the table. + +The Status selector SHALL map to the API as: `All` → no `errorOnly` param, `Success` → `errorOnly=false` (filter excludes `isError: true`), `Error` → `errorOnly=true`. The date range SHALL map to `from` (start-of-day UTC of the selected start date) and `to` (end-of-day UTC of the selected end date) so both endpoints are inclusive. + +The detail view SHALL include a "Refine" button when the log entry is a `tools/call` entry and a semantic model name can be extracted from the input arguments (`inputArgs.modelName`). The "Refine" button SHALL navigate to `/$projectId/models/chat/new` with a `prefill` search parameter containing a prompt that describes the MCP call context (tool name, input arguments, output or error content) and instructs the semantic model agent to improve the model's navigability — ai_context descriptions, naming, relationships, and structure. The button SHALL use an outline style with a wand icon, matching the Refine button in the test run detail page. + +#### Scenario: View call log table + +- **WHEN** the user navigates to `/:projectId/monitoring` +- **THEN** a table of MCP call log entries is displayed inside a Card with columns: timestamp, token name, tool, duration, status +- **AND** entries are sorted newest first +- **AND** rows are vertically condensed compared to the MCP Access tokens table + +#### Scenario: View full output on row click + +- **WHEN** the user clicks a row in the call log table +- **THEN** a detail view opens showing the full input arguments as formatted JSON and the full output content as rendered markdown + +#### Scenario: Paginate through logs + +- **WHEN** the user clicks the next/previous page controls +- **THEN** the table fetches and displays the corresponding page of results + +#### Scenario: Refresh logs + +- **WHEN** the user clicks the refresh button +- **THEN** the current page of logs is re-fetched from the API + +#### Scenario: Toggle tools/list visibility + +- **WHEN** the user enables the "Show list calls" toggle +- **THEN** `tools/list` entries are included in the table alongside `tools/call` entries + +#### Scenario: Empty state with no logs + +- **WHEN** the project has no MCP call logs and no filters are active +- **THEN** the table shows an empty state message indicating no calls have been recorded yet + +#### Scenario: Empty state with active filters + +- **WHEN** filters are applied that match no log entries +- **THEN** the table shows an empty state message indicating no logs match the current filters +- **AND** a "Clear filters" button is shown that resets every filter to its default value + +#### Scenario: Filter by tool + +- **WHEN** the user selects `execute_query` from the Tool selector +- **THEN** the table refetches with `toolName=execute_query` and shows only matching entries +- **AND** the page is reset to 1 + +#### Scenario: Filter by status + +- **WHEN** the user selects `Error` from the Status selector +- **THEN** the table refetches with `errorOnly=true` and shows only error entries +- **AND** the page is reset to 1 + +#### Scenario: Filter by token + +- **WHEN** the user selects a specific token from the Token selector +- **THEN** the table refetches with `tokenId=` and shows only entries for that token +- **AND** the page is reset to 1 + +#### Scenario: Filter by date range + +- **WHEN** the user picks a start date and an end date in the date range picker and confirms +- **THEN** the table refetches with `from=` and `to=` ISO strings +- **AND** only entries within the inclusive range are shown +- **AND** the page is reset to 1 + +#### Scenario: Clear filters + +- **WHEN** at least one filter is active and the user clicks the clear-all `X` button +- **THEN** every filter resets to its default value +- **AND** the table refetches without any filter query parameters + +#### Scenario: Refine model from log detail + +- **WHEN** the user opens a log detail sheet for a `tools/call` entry +- **AND** the entry's `inputArgs` contain a `modelName` field +- **THEN** a "Refine" button with a wand icon is displayed below the output/error sections +- **AND** clicking the button navigates to `/$projectId/models/chat/new?prefill=` +- **AND** the prefill prompt includes the tool name, input arguments, output or error content, and instructions to improve the semantic model + +#### Scenario: Refine button hidden for tools/list entries + +- **WHEN** the user opens a log detail sheet for a `tools/list` entry +- **THEN** no "Refine" button is displayed + +#### Scenario: Refine button hidden when no model name available + +- **WHEN** the user opens a log detail sheet for a `tools/call` entry +- **AND** the entry's `inputArgs` do not contain a `modelName` field +- **THEN** no "Refine" button is displayed diff --git a/openspec/changes/add-mcp-log-filters/specs/mcp-token-management/spec.md b/openspec/changes/add-mcp-log-filters/specs/mcp-token-management/spec.md new file mode 100644 index 0000000..f7b98ae --- /dev/null +++ b/openspec/changes/add-mcp-log-filters/specs/mcp-token-management/spec.md @@ -0,0 +1,110 @@ +## MODIFIED Requirements + +### Requirement: Token CRUD API + +The API SHALL expose CRUD endpoints for MCP tokens at `/api/projects/:projectId/mcp-tokens`: + +- `GET /` — List all non-deleted tokens for the project (name, scopes, expiresAt, lastUsedAt, createdAt, eventCount30d; never the hash). Each item SHALL include `eventCount30d: number`, the count of `McpCallLog` entries for that token in the last 30 days (server-clock time), computed via a single aggregation. Tokens with no recorded calls in the window return `0`. +- `POST /` — Create a new token (accepts name, scopes, expiresAt; returns the raw token once) +- `DELETE /:tokenId` — Soft-delete (revoke) a token + +All endpoints SHALL require admin session auth (same as other `/api/*` routes). + +#### Scenario: List tokens for a project + +- **WHEN** a GET request is made to `/api/projects/:projectId/mcp-tokens` +- **THEN** all non-deleted tokens for the project are returned with name, scopes, expiresAt, lastUsedAt, createdAt, and eventCount30d +- **AND** the tokenHash field is never included in the response + +#### Scenario: eventCount30d reflects last 30 days only + +- **WHEN** a token has 3 `McpCallLog` entries within the last 30 days and 5 entries older than 30 days +- **AND** a GET request is made to `/api/projects/:projectId/mcp-tokens` +- **THEN** the token's `eventCount30d` is `3` + +#### Scenario: eventCount30d is zero for unused tokens + +- **WHEN** a token has no `McpCallLog` entries +- **AND** a GET request is made to `/api/projects/:projectId/mcp-tokens` +- **THEN** the token's `eventCount30d` is `0` + +#### Scenario: Create a token + +- **WHEN** a POST request is made with `{ name: "Dev Agent", scopes: ["shopify", "datev"], expiresAt: null }` +- **THEN** the token is created and the response includes the raw token string (shown once) +- **AND** the response status is 201 + +#### Scenario: Revoke a token + +- **WHEN** a DELETE request is made to `/api/projects/:projectId/mcp-tokens/:tokenId` +- **THEN** the token is soft-deleted +- **AND** subsequent MCP requests with that token are rejected with 401 + +#### Scenario: Create token with invalid scopes + +- **WHEN** a POST request includes a scope name that doesn't match any semantic model in the project +- **THEN** a 400 error is returned indicating the invalid scope + +### Requirement: MCP Access Management UI + +The frontend SHALL provide an MCP Access page at `/:projectId/mcp-access` displaying: + +1. **Endpoint info** — The project's MCP endpoint URL (`BASE_URL/mcp/:slug/mcp`) with a copy button +2. **Token list** — A table of all active tokens showing: name, scopes (as badges), expiry status, last used (relative time), events in the last 30 days, and a revoke button +3. **Create token dialog** — A form to create a new token with fields: name, scope selection (multi-select from available semantic models), and expiry option (never / custom date) +4. **Token reveal** — After creation, a one-time display of the raw token with a copy button and a warning that it cannot be shown again + +The `Last Used` cell SHALL render as a relative-time string (e.g. "5 min ago", "2 hours ago", "3 days ago"), with a `Tooltip` on hover showing the absolute local timestamp including time-of-day. When `lastUsedAt` is null the cell SHALL show a muted em dash. + +The `Events (30d)` cell SHALL render `eventCount30d` from the API as a tabular-nums number; the value `0` SHALL be rendered with `text-muted-foreground` styling so dormant tokens are visually distinct. + +#### Scenario: View MCP endpoint URL + +- **WHEN** the user navigates to the MCP Access page +- **THEN** the project's full MCP endpoint URL is displayed +- **AND** a copy-to-clipboard button is available next to it + +#### Scenario: Token row shows relative last-used and 30-day event count + +- **WHEN** the user views the token list +- **AND** a token has `lastUsedAt = 5 minutes ago` and `eventCount30d = 42` +- **THEN** the `Last Used` cell shows a relative label like "5 min ago" +- **AND** hovering the cell reveals a tooltip with the absolute local timestamp including the time-of-day +- **AND** the `Events (30d)` cell shows `42` + +#### Scenario: Dormant token displays muted zero count + +- **WHEN** a token has `eventCount30d = 0` +- **THEN** the `Events (30d)` cell shows `0` rendered with muted foreground styling + +#### Scenario: Never-used token displays em dash + +- **WHEN** a token has `lastUsedAt = null` +- **THEN** the `Last Used` cell shows a muted em dash +- **AND** no tooltip is shown + +#### Scenario: Create and reveal a new token + +- **WHEN** the user fills in the create token form and submits +- **THEN** a dialog shows the raw token with a copy button +- **AND** a warning states the token will not be shown again +- **AND** the token list refreshes to include the new token + +#### Scenario: Revoke a token from the list + +- **WHEN** the user clicks the revoke button on a token row +- **THEN** a confirmation dialog appears +- **AND** on confirm, the token is soft-deleted via the API +- **AND** the token disappears from the list + +#### Scenario: Token with expired status + +- **WHEN** a token's `expiresAt` is in the past +- **THEN** the token row shows an "Expired" badge +- **AND** the token is no longer accepted for MCP authentication + +#### Scenario: Scope selection shows available models + +- **WHEN** the user opens the create token dialog +- **THEN** the scope selector lists all semantic models in the current project +- **AND** the user can select one or more models to scope the token to diff --git a/openspec/changes/add-mcp-log-filters/tasks.md b/openspec/changes/add-mcp-log-filters/tasks.md new file mode 100644 index 0000000..53985e1 --- /dev/null +++ b/openspec/changes/add-mcp-log-filters/tasks.md @@ -0,0 +1,43 @@ +# Implementation Tasks + +## 1. API: distinct tools endpoint and token activity stats + +- [ ] 1.1 Add `GET /api/projects/:projectId/mcp-logs/tools` to `apps/api/src/routes/mcp-logs.ts` returning `string[]` of distinct non-null `toolName` values for the project, sorted alphabetically. Reuse admin session auth (same as the list endpoint). +- [ ] 1.2 Update `GET /api/projects/:projectId/mcp-tokens` (`apps/api/src/routes/mcp-tokens.ts`) to attach `eventCount30d: number` to each returned token using a single aggregation that groups `McpCallLog` by `tokenId` for the last 30 days. Tokens with no events return `0`. +- [ ] 1.3 Add Vitest integration coverage for both endpoints in `apps/api/src/routes/mcp-logs.test.ts` and `apps/api/src/routes/mcp-tokens.test.ts` (or create them if they don't exist) covering: empty project, mixed tool/error/token data, 30-day window boundary. + +## 2. Shared UI: shadcn calendar + DateRangePicker + +- [ ] 2.1 Install the shadcn `calendar` component into `packages/ui` (per the shadcn skill — use the project's package runner; verify imports use `@archmax/ui` aliases). Re-export from `packages/ui/src/index.ts`. +- [ ] 2.2 Add a small `DateRangePicker` composed from the existing `Popover` and the new `Calendar` (`mode="range"`) in `packages/ui/src/components/date-range-picker.tsx`. Trigger is an outline `Button` styled to match `.filter-trigger` (compact `h-7`, `text-xs`, transparent) showing the formatted range or "Any date" placeholder, with a small calendar icon. Re-export from `packages/ui`. +- [ ] 2.3 Add a unit test for `DateRangePicker` covering: opens on click, applies the selected range to the controlled value, clears via an "X" button when a value is set. + +## 3. Frontend: MCP log filter bar + +- [ ] 3.1 In `apps/frontend/src/routes/_auth/$projectId/monitoring.tsx`, add a filter bar directly above the table using `flex items-center gap-1.5` per the project's Filter Controls convention. Controls (in this order): Tool Select, Status Select (`All` / `Success` / `Error`), Token Select, `DateRangePicker`, and a clear-all `X` button (visible only when any filter is active). +- [ ] 3.2 Source the Tool Select options from `useQuery` against the new `mcp-logs/tools` endpoint; source the Token Select options from the existing `mcp-tokens` query (filter out soft-deleted tokens implicitly via the API). Default option is `All tools` / `All tokens`. +- [ ] 3.3 Wire all filters into the `useQuery` `queryKey` and pass the values as query params (`toolName`, `tokenId`, `errorOnly`, `from`, `to`). Use ISO date strings (`from` = start-of-day UTC, `to` = end-of-day UTC) so the inclusive range matches user intent. +- [ ] 3.4 Reset `page` to `1` whenever any filter value changes. +- [ ] 3.5 Update the empty-state copy to differentiate between "no logs yet" and "no logs match the current filters" (the latter shows a "Clear filters" button). + +## 4. Frontend: token overview activity stats + +- [ ] 4.1 In `apps/frontend/src/routes/_auth/$projectId/mcp-access.tsx`, add an `Events (30d)` `TableHead` between the existing `Last Used` and the trailing actions column. Render `eventCount30d` from the API (tabular-nums, muted when `0`). +- [ ] 4.2 Replace the existing `Last Used` cell renderer with a relative-time format (e.g. "5 min ago", "2 days ago") that also shows the time-of-day; show the absolute timestamp in a `Tooltip` on hover. Use a small helper (e.g. `formatRelativeTime`) colocated in the route file or a shared util. +- [ ] 4.3 Make the `McpTokenListItem` interface include `eventCount30d: number` and update any other consumers of the token list query (none expected outside this page). + +## 5. Tests + +- [ ] 5.1 Add a Playwright/E2E test (or extend the existing MCP Access E2E) that creates a token, performs an MCP `tools/call`, and verifies the token row shows `Events (30d) >= 1` and a relative `Last Used` value. +- [ ] 5.2 Add a Playwright/E2E test for the monitoring page that records two calls (one success, one error, against different tools), then asserts that filtering by tool and by status narrows the table correctly. + +## 6. Documentation + +- [ ] 6.1 Update `apps/docs/src/content/docs/reference/specs/mcp-monitoring.md` to describe the new filter bar and date range picker. +- [ ] 6.2 Update the MCP Access documentation page (`apps/docs/src/content/docs/...mcp-access*` — locate the actual file during implementation) to mention the `Events (30d)` column and relative-time `Last Used` display. + +## 7. Verification + +- [ ] 7.1 Run `pnpm typecheck` and `pnpm lint` and ensure both pass. +- [ ] 7.2 Run `pnpm --filter @archmax/api build` to catch declaration emit issues. +- [ ] 7.3 Run `openspec validate add-mcp-log-filters --strict` and resolve any issues. From b022ebadac9002cea5c4a419b39b9ef843198231 Mon Sep 17 00:00:00 2001 From: Tobias Grosse-Puppendahl Date: Wed, 29 Apr 2026 08:18:48 +0200 Subject: [PATCH 2/8] feat(mcp): filter MCP logs and surface token activity stats API: - Add GET /api/projects/:projectId/mcp-logs/tools returning distinct, sorted toolName values for a project so the frontend tool filter is sourced from real traffic. - Extend GET /api/projects/:projectId/mcp-tokens with eventCount30d, computed via a single McpCallLog aggregation grouped by tokenId for the trailing 30 days. Frontend: - Add a filter bar to /:projectId/monitoring (Tool, Status, Token, DateRangePicker) with a clear-all X button. All filters reset pagination on change. Empty state distinguishes "no logs yet" from "no logs match the current filters". - Render Last Used as a relative time (with absolute tooltip) and add an Events (30d) column on the MCP Access page. - Date range serializes the picker's local-day boundaries (00:00:00 of the start date through 23:59:59.999 of the end date) to UTC ISO so ranges match user intent regardless of timezone. UI package: - Add Calendar (react-day-picker v9, shadcn-style classes) and a small DateRangePicker built on Popover + Calendar mode="range". Tests / docs: - Vitest integration coverage for both new API endpoints. - E2E coverage for token activity stats and the monitoring tool / status filters. - Update apps/docs MCP integration guide with the new monitoring filters and the Events (30d) / Last Used columns. Implements OpenSpec change add-mcp-log-filters (now archived). Made-with: Cursor --- .../src/routes/mcp-logs.integration.test.ts | 115 +++++++++++ apps/api/src/routes/mcp-logs.ts | 88 ++++---- .../src/routes/mcp-tokens.integration.test.ts | 105 ++++++++++ apps/api/src/routes/mcp-tokens.ts | 31 ++- .../content/docs/guides/mcp-integration.mdx | 14 ++ apps/e2e/tests/mcp.spec.ts | 72 +++++++ .../routes/_auth/$projectId/mcp-access.tsx | 56 +++++- .../routes/_auth/$projectId/monitoring.tsx | 189 +++++++++++++++++- openspec/changes/add-mcp-log-filters/tasks.md | 43 ---- .../proposal.md | 0 .../specs/mcp-monitoring/spec.md | 0 .../specs/mcp-token-management/spec.md | 0 .../2026-04-29-add-mcp-log-filters/tasks.md | 44 ++++ openspec/specs/mcp-monitoring/spec.md | 75 ++++++- openspec/specs/mcp-token-management/spec.md | 41 +++- packages/ui/package.json | 2 + packages/ui/src/components/calendar.tsx | 65 ++++++ .../ui/src/components/date-range-picker.tsx | 90 +++++++++ packages/ui/src/index.ts | 2 + pnpm-lock.yaml | 41 ++++ 20 files changed, 977 insertions(+), 96 deletions(-) create mode 100644 apps/api/src/routes/mcp-logs.integration.test.ts create mode 100644 apps/api/src/routes/mcp-tokens.integration.test.ts delete mode 100644 openspec/changes/add-mcp-log-filters/tasks.md rename openspec/changes/{add-mcp-log-filters => archive/2026-04-29-add-mcp-log-filters}/proposal.md (100%) rename openspec/changes/{add-mcp-log-filters => archive/2026-04-29-add-mcp-log-filters}/specs/mcp-monitoring/spec.md (100%) rename openspec/changes/{add-mcp-log-filters => archive/2026-04-29-add-mcp-log-filters}/specs/mcp-token-management/spec.md (100%) create mode 100644 openspec/changes/archive/2026-04-29-add-mcp-log-filters/tasks.md create mode 100644 packages/ui/src/components/calendar.tsx create mode 100644 packages/ui/src/components/date-range-picker.tsx diff --git a/apps/api/src/routes/mcp-logs.integration.test.ts b/apps/api/src/routes/mcp-logs.integration.test.ts new file mode 100644 index 0000000..326b9c8 --- /dev/null +++ b/apps/api/src/routes/mcp-logs.integration.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + find: vi.fn(), + countDocuments: vi.fn(), + distinct: vi.fn(), +})); + +function mkChain(rows: unknown[]) { + return { + sort: vi.fn().mockReturnThis(), + skip: vi.fn().mockReturnThis(), + limit: vi.fn().mockReturnThis(), + lean: vi.fn().mockResolvedValue(rows), + }; +} + +vi.mock("@archmax/core/infra/db", () => ({ connectDB: vi.fn() })); +vi.mock("@archmax/core/models/index", () => ({ + McpCallLog: { + find: mocks.find, + countDocuments: mocks.countDocuments, + distinct: mocks.distinct, + }, +})); + +import { createTestApp, jsonBody } from "../test-utils/api-client"; +import mcpLogsRoute from "./mcp-logs"; + +const app = createTestApp("/api/projects/:projectId/mcp-logs", mcpLogsRoute); +const BASE = "/api/projects/proj1/mcp-logs"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /mcp-logs", () => { + it("returns paginated logs with default page/limit", async () => { + const rows = [{ _id: "l1", toolName: "execute_query" }]; + mocks.find.mockReturnValue(mkChain(rows)); + mocks.countDocuments.mockResolvedValue(1); + + const res = await app.request(BASE); + expect(res.status).toBe(200); + const body = await jsonBody<{ data: unknown[]; total: number; page: number; limit: number }>(res); + expect(body.total).toBe(1); + expect(body.page).toBe(1); + expect(body.limit).toBe(50); + expect(body.data).toEqual(rows); + + expect(mocks.find).toHaveBeenCalledWith({ project: "proj1" }); + }); + + it("forwards filter query params to the Mongo filter", async () => { + mocks.find.mockReturnValue(mkChain([])); + mocks.countDocuments.mockResolvedValue(0); + + const res = await app.request( + `${BASE}?toolName=execute_query&tokenId=t1&errorOnly=true&from=2026-04-01&to=2026-04-07`, + ); + expect(res.status).toBe(200); + + const filter = mocks.find.mock.calls[0]![0] as Record; + expect(filter).toMatchObject({ + project: "proj1", + toolName: "execute_query", + tokenId: "t1", + isError: true, + }); + const dateFilter = filter.createdAt as { $gte: Date; $lte: Date }; + expect(dateFilter.$gte).toBeInstanceOf(Date); + expect(dateFilter.$lte).toBeInstanceOf(Date); + }); + + it("clamps limit to max 200", async () => { + mocks.find.mockReturnValue(mkChain([])); + mocks.countDocuments.mockResolvedValue(0); + + const res = await app.request(`${BASE}?limit=999`); + expect(res.status).toBe(200); + const body = await jsonBody<{ limit: number }>(res); + expect(body.limit).toBe(200); + }); +}); + +describe("GET /mcp-logs/tools", () => { + it("returns distinct sorted tool names", async () => { + mocks.distinct.mockResolvedValue(["get_semantic_model", "execute_query", "execute_query"]); + + const res = await app.request(`${BASE}/tools`); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body).toEqual(["execute_query", "execute_query", "get_semantic_model"]); + expect(mocks.distinct).toHaveBeenCalledWith("toolName", { + project: "proj1", + toolName: { $ne: null }, + }); + }); + + it("filters out null/empty values", async () => { + mocks.distinct.mockResolvedValue([null, "", "execute_query", "list_semantic_models"]); + + const res = await app.request(`${BASE}/tools`); + const body = await jsonBody(res); + expect(body).toEqual(["execute_query", "list_semantic_models"]); + }); + + it("returns empty array for project with no logs", async () => { + mocks.distinct.mockResolvedValue([]); + const res = await app.request(`${BASE}/tools`); + expect(res.status).toBe(200); + const body = await jsonBody(res); + expect(body).toEqual([]); + }); +}); diff --git a/apps/api/src/routes/mcp-logs.ts b/apps/api/src/routes/mcp-logs.ts index fa788b5..f822f16 100644 --- a/apps/api/src/routes/mcp-logs.ts +++ b/apps/api/src/routes/mcp-logs.ts @@ -14,41 +14,57 @@ const listQuerySchema = z.object({ to: z.string().optional(), }); -const app = new Hono().get("/", zValidator("query", listQuerySchema), async (c) => { - await connectDB(); - const projectId = c.req.param("projectId")!; - - const q = c.req.valid("query"); - const page = Math.max(1, parseInt(q.page || "1", 10)); - const limit = Math.min(200, Math.max(1, parseInt(q.limit || "50", 10))); - const toolName = q.toolName; - const tokenId = q.tokenId; - const errorOnly = q.errorOnly === "true"; - const from = q.from; - const to = q.to; - - const filter: Record = { project: projectId }; - - if (toolName) filter.toolName = toolName; - if (tokenId) filter.tokenId = tokenId; - if (errorOnly) filter.isError = true; - if (from || to) { - const dateFilter: Record = {}; - if (from) dateFilter.$gte = new Date(from); - if (to) dateFilter.$lte = new Date(to); - filter.createdAt = dateFilter; - } - - const [data, total] = await Promise.all([ - McpCallLog.find(filter) - .sort({ createdAt: -1 }) - .skip((page - 1) * limit) - .limit(limit) - .lean(), - McpCallLog.countDocuments(filter), - ]); - - return c.json({ data, total, page, limit }); -}); +const app = new Hono() + .get("/", zValidator("query", listQuerySchema), async (c) => { + await connectDB(); + const projectId = c.req.param("projectId")!; + + const q = c.req.valid("query"); + const page = Math.max(1, parseInt(q.page || "1", 10)); + const limit = Math.min(200, Math.max(1, parseInt(q.limit || "50", 10))); + const toolName = q.toolName; + const tokenId = q.tokenId; + const errorOnly = q.errorOnly === "true"; + const from = q.from; + const to = q.to; + + const filter: Record = { project: projectId }; + + if (toolName) filter.toolName = toolName; + if (tokenId) filter.tokenId = tokenId; + if (errorOnly) filter.isError = true; + if (from || to) { + const dateFilter: Record = {}; + if (from) dateFilter.$gte = new Date(from); + if (to) dateFilter.$lte = new Date(to); + filter.createdAt = dateFilter; + } + + const [data, total] = await Promise.all([ + McpCallLog.find(filter) + .sort({ createdAt: -1 }) + .skip((page - 1) * limit) + .limit(limit) + .lean(), + McpCallLog.countDocuments(filter), + ]); + + return c.json({ data, total, page, limit }); + }) + .get("/tools", async (c) => { + await connectDB(); + const projectId = c.req.param("projectId")!; + + const tools = await McpCallLog.distinct("toolName", { + project: projectId, + toolName: { $ne: null }, + }); + + const sorted = (tools as (string | null)[]) + .filter((t): t is string => typeof t === "string" && t.length > 0) + .sort(); + + return c.json(sorted); + }); export default app; diff --git a/apps/api/src/routes/mcp-tokens.integration.test.ts b/apps/api/src/routes/mcp-tokens.integration.test.ts new file mode 100644 index 0000000..04c9c6c --- /dev/null +++ b/apps/api/src/routes/mcp-tokens.integration.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mocks = vi.hoisted(() => ({ + find: vi.fn(), + aggregate: vi.fn(), +})); + +function mkChain(rows: unknown[]) { + return { + select: vi.fn().mockReturnThis(), + sort: vi.fn().mockReturnThis(), + lean: vi.fn().mockResolvedValue(rows), + }; +} + +vi.mock("mongoose", () => { + class FakeObjectId { + value: string; + constructor(v: string) { this.value = v; } + toString() { return this.value; } + } + return { + default: { Types: { ObjectId: FakeObjectId } }, + Types: { ObjectId: FakeObjectId }, + }; +}); +vi.mock("@archmax/core/infra/db", () => ({ connectDB: vi.fn() })); +vi.mock("@archmax/core/config/env", () => ({ + getEnv: vi.fn(() => ({ projectsDir: "/tmp/test-projects" })), +})); +vi.mock("@archmax/core/models/index", () => ({ + McpToken: { + find: mocks.find, + findOne: vi.fn(), + create: vi.fn(), + }, + McpCallLog: { aggregate: mocks.aggregate }, + Project: { findById: vi.fn() }, + generateMcpToken: vi.fn(() => ({ raw: "sml_test", hash: "hash" })), +})); +vi.mock("@archmax/core/services/semantic-model-files", () => ({ + SemanticModelFileService: class { + async list() { return []; } + }, +})); + +import { createTestApp, jsonBody } from "../test-utils/api-client"; +import mcpTokensRoute from "./mcp-tokens"; + +const app = createTestApp("/api/projects/:projectId/mcp-tokens", mcpTokensRoute); +const BASE = "/api/projects/proj1/mcp-tokens"; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("GET /mcp-tokens — eventCount30d", () => { + it("attaches event count from the last 30 days for each token", async () => { + const tokens = [ + { _id: "tok-active", name: "Active", scopes: ["m1"], expiresAt: null, lastUsedAt: new Date(), createdAt: new Date() }, + { _id: "tok-dormant", name: "Dormant", scopes: ["m1"], expiresAt: null, lastUsedAt: null, createdAt: new Date() }, + ]; + mocks.find.mockReturnValue(mkChain(tokens)); + mocks.aggregate.mockResolvedValue([ + { _id: { toString: () => "tok-active" }, count: 7 }, + ]); + + const res = await app.request(BASE); + expect(res.status).toBe(200); + + const body = await jsonBody>(res); + expect(body).toHaveLength(2); + expect(body[0]).toMatchObject({ _id: "tok-active", eventCount30d: 7 }); + expect(body[1]).toMatchObject({ _id: "tok-dormant", eventCount30d: 0 }); + }); + + it("queries with a 30-day-ago start date and matches non-null tokenId", async () => { + mocks.find.mockReturnValue(mkChain([])); + mocks.aggregate.mockResolvedValue([]); + + const res = await app.request(BASE); + expect(res.status).toBe(200); + + const pipeline = mocks.aggregate.mock.calls[0]![0] as Array>; + const match = pipeline[0]!.$match as Record; + expect(match).toMatchObject({ + tokenId: { $ne: null }, + }); + const created = match.createdAt as { $gte: Date }; + expect(created.$gte).toBeInstanceOf(Date); + const ageMs = Date.now() - created.$gte.getTime(); + const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000; + expect(ageMs).toBeGreaterThanOrEqual(thirtyDaysMs - 5_000); + expect(ageMs).toBeLessThanOrEqual(thirtyDaysMs + 5_000); + }); + + it("returns empty list when project has no tokens", async () => { + mocks.find.mockReturnValue(mkChain([])); + mocks.aggregate.mockResolvedValue([]); + + const res = await app.request(BASE); + const body = await jsonBody(res); + expect(body).toEqual([]); + }); +}); diff --git a/apps/api/src/routes/mcp-tokens.ts b/apps/api/src/routes/mcp-tokens.ts index 7b4f5ac..07a961f 100644 --- a/apps/api/src/routes/mcp-tokens.ts +++ b/apps/api/src/routes/mcp-tokens.ts @@ -1,8 +1,9 @@ import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v4"; +import mongoose from "mongoose"; import { connectDB } from "@archmax/core/infra/db"; -import { McpToken, generateMcpToken, Project } from "@archmax/core/models/index"; +import { McpToken, McpCallLog, generateMcpToken, Project } from "@archmax/core/models/index"; import { SemanticModelFileService } from "@archmax/core/services/semantic-model-files"; import { getEnv } from "@archmax/core/config/env"; import { AppError } from "../utils/errors"; @@ -21,11 +22,37 @@ const app = new Hono() .get("/", async (c) => { await connectDB(); const projectId = c.req.param("projectId")!; + const tokens = await McpToken.find({ project: projectId }) .select("name scopes expiresAt lastUsedAt createdAt") .sort({ createdAt: -1 }) .lean(); - return c.json(tokens); + + const since = new Date(); + since.setDate(since.getDate() - 30); + + const counts = await McpCallLog.aggregate<{ _id: mongoose.Types.ObjectId | null; count: number }>([ + { + $match: { + project: new mongoose.Types.ObjectId(projectId), + createdAt: { $gte: since }, + tokenId: { $ne: null }, + }, + }, + { $group: { _id: "$tokenId", count: { $sum: 1 } } }, + ]); + + const countByToken = new Map(); + for (const row of counts) { + if (row._id) countByToken.set(row._id.toString(), row.count); + } + + const enriched = tokens.map((t) => ({ + ...t, + eventCount30d: countByToken.get(String(t._id)) ?? 0, + })); + + return c.json(enriched); }) .post("/", zValidator("json", createSchema), async (c) => { await connectDB(); diff --git a/apps/docs/src/content/docs/guides/mcp-integration.mdx b/apps/docs/src/content/docs/guides/mcp-integration.mdx index e8a230e..f579fa9 100644 --- a/apps/docs/src/content/docs/guides/mcp-integration.mdx +++ b/apps/docs/src/content/docs/guides/mcp-integration.mdx @@ -34,6 +34,20 @@ Tokens are created in the admin UI under **MCP Access**. Each token has: - **Scopes**: which semantic models the token can access - **Expiry**: optional expiration date +The MCP Access table also surfaces per-token activity: +- **Last Used**: relative time of the most recent call (hover for the absolute timestamp) +- **Events (30d)**: number of MCP calls the token made in the last 30 days + +## Monitoring Calls + +The **MCP Log** page at `//monitoring` shows every `tools/call` made through the project's MCP endpoint. A filter bar above the table lets you narrow the view by: +- **Tool** — pick one of the tools that have actually been called (e.g. `execute_query`) +- **Status** — `All`, `Success only`, or `Errors only` +- **Token** — restrict to calls from a specific token +- **Date range** — pick a start and end date; both ends are inclusive + +Click any row to open a detail panel with the full input arguments and tool output. For `execute_query` errors and other tool calls that reference a semantic model, a **Refine** action opens the agent chat with a pre-filled prompt to improve the model. + ## Available Tools | Tool | Description | diff --git a/apps/e2e/tests/mcp.spec.ts b/apps/e2e/tests/mcp.spec.ts index 28d8c66..91de8f7 100644 --- a/apps/e2e/tests/mcp.spec.ts +++ b/apps/e2e/tests/mcp.spec.ts @@ -703,6 +703,78 @@ test.describe.serial("MCP Layer", () => { ); }); + // ── Token activity stats on MCP Access page ──────────────────── + + test("token row shows Events (30d) >= 1 and relative Last Used", async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + await page.goto(`/${projectId}/mcp-access`); + await page.waitForLoadState("networkidle"); + + const tokenRow = page.getByRole("row").filter({ hasText: TOKEN_NAME }); + await expect(tokenRow).toBeVisible({ timeout: 5_000 }); + + const eventsCell = tokenRow.locator("td").nth(4); + await expect(eventsCell).toBeVisible(); + const eventsText = (await eventsCell.textContent())?.trim() ?? ""; + const eventsCount = parseInt(eventsText, 10); + expect(Number.isFinite(eventsCount)).toBe(true); + expect(eventsCount).toBeGreaterThanOrEqual(1); + + const lastUsedCell = tokenRow.locator("td").nth(3); + const lastUsedText = (await lastUsedCell.textContent())?.trim() ?? ""; + expect(lastUsedText).not.toBe("—"); + expect(lastUsedText.toLowerCase()).toMatch(/just now|min ago|hr ago|hour|day|sec/); + + await context.close(); + }); + + // ── Monitoring page filters ──────────────────────────────────── + + test("monitoring page filter by tool narrows the table", async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + await page.goto(`/${projectId}/monitoring`); + await page.waitForLoadState("networkidle"); + + await expect(page.getByRole("row").filter({ hasText: "execute_query" }).first()).toBeVisible({ timeout: 10_000 }); + await expect(page.getByRole("row").filter({ hasText: "get_semantic_model" }).first()).toBeVisible(); + + const toolTrigger = page.locator("[data-slot='select-trigger']").first(); + await toolTrigger.click(); + await page.getByRole("option", { name: "execute_query", exact: true }).click(); + + await page.waitForLoadState("networkidle"); + + await expect(page.getByRole("row").filter({ hasText: "execute_query" }).first()).toBeVisible({ timeout: 10_000 }); + expect(await page.getByRole("row").filter({ hasText: "get_semantic_model" }).count()).toBe(0); + + await context.close(); + }); + + test("monitoring page filter by status=Errors only narrows the table", async ({ browser }) => { + const context = await browser.newContext({ storageState: AUTH_FILE }); + const page = await context.newPage(); + + await page.goto(`/${projectId}/monitoring`); + await page.waitForLoadState("networkidle"); + + const statusTrigger = page.locator("[data-slot='select-trigger']").nth(1); + await statusTrigger.click(); + await page.getByRole("option", { name: "Errors only" }).click(); + + await page.waitForLoadState("networkidle"); + + const errorBadges = page.getByRole("row").locator("[data-slot='badge']").filter({ hasText: "Error" }); + await expect(errorBadges.first()).toBeVisible({ timeout: 10_000 }); + + expect(await page.getByRole("row").locator("[data-slot='badge']").filter({ hasText: "OK" }).count()).toBe(0); + + await context.close(); + }); + // ── Token revocation via UI ──────────────────────────────────── test("revoke MCP token via UI", async ({ browser }) => { diff --git a/apps/frontend/src/routes/_auth/$projectId/mcp-access.tsx b/apps/frontend/src/routes/_auth/$projectId/mcp-access.tsx index b4a1808..d82cf42 100644 --- a/apps/frontend/src/routes/_auth/$projectId/mcp-access.tsx +++ b/apps/frontend/src/routes/_auth/$projectId/mcp-access.tsx @@ -32,6 +32,10 @@ import { Popover, PopoverContent, PopoverTrigger, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, } from "@archmax/ui"; import { api } from "@/lib/api"; import { useProject } from "@/lib/project-context"; @@ -47,6 +51,7 @@ interface McpTokenListItem { expiresAt: string | null; lastUsedAt: string | null; createdAt: string; + eventCount30d: number; } interface SemanticModelSummary { @@ -134,6 +139,32 @@ function McpAccessPage() { }); } + function formatAbsolute(iso: string) { + return new Date(iso).toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } + + function formatRelativeTime(iso: string): string { + const diffMs = Date.now() - new Date(iso).getTime(); + const sec = Math.round(diffMs / 1000); + if (sec < 60) return "just now"; + const min = Math.round(sec / 60); + if (min < 60) return `${min} min ago`; + const hr = Math.round(min / 60); + if (hr < 24) return `${hr} hr ago`; + const day = Math.round(hr / 24); + if (day < 30) return `${day} day${day === 1 ? "" : "s"} ago`; + const month = Math.round(day / 30); + if (month < 12) return `${month} mo ago`; + const yr = Math.round(month / 12); + return `${yr} yr ago`; + } + function isExpired(expiresAt: string | null) { if (!expiresAt) return false; return new Date(expiresAt) < new Date(); @@ -219,6 +250,7 @@ function McpAccessPage() { Semantic Models Expires Last Used + Events (30d) @@ -244,8 +276,28 @@ function McpAccessPage() { Never )} - - {formatDate(t.lastUsedAt)} + + {t.lastUsedAt ? ( + + + + + {formatRelativeTime(t.lastUsedAt)} + + + {formatAbsolute(t.lastUsedAt)} + + + ) : ( + + )} + + + {t.eventCount30d} + )} + + {isLoading ? (

Loading logs...

) : filteredLogs.length === 0 ? ( - - -

- No MCP calls recorded yet. Logs appear here when AI agents use this project's MCP endpoint. -

+ + + {hasFilters ? ( + <> +

+ No logs match the current filters. +

+ + + ) : ( +

+ No MCP calls recorded yet. Logs appear here when AI agents use this project's MCP endpoint. +

+ )}
) : ( <> diff --git a/openspec/changes/add-mcp-log-filters/tasks.md b/openspec/changes/add-mcp-log-filters/tasks.md deleted file mode 100644 index 53985e1..0000000 --- a/openspec/changes/add-mcp-log-filters/tasks.md +++ /dev/null @@ -1,43 +0,0 @@ -# Implementation Tasks - -## 1. API: distinct tools endpoint and token activity stats - -- [ ] 1.1 Add `GET /api/projects/:projectId/mcp-logs/tools` to `apps/api/src/routes/mcp-logs.ts` returning `string[]` of distinct non-null `toolName` values for the project, sorted alphabetically. Reuse admin session auth (same as the list endpoint). -- [ ] 1.2 Update `GET /api/projects/:projectId/mcp-tokens` (`apps/api/src/routes/mcp-tokens.ts`) to attach `eventCount30d: number` to each returned token using a single aggregation that groups `McpCallLog` by `tokenId` for the last 30 days. Tokens with no events return `0`. -- [ ] 1.3 Add Vitest integration coverage for both endpoints in `apps/api/src/routes/mcp-logs.test.ts` and `apps/api/src/routes/mcp-tokens.test.ts` (or create them if they don't exist) covering: empty project, mixed tool/error/token data, 30-day window boundary. - -## 2. Shared UI: shadcn calendar + DateRangePicker - -- [ ] 2.1 Install the shadcn `calendar` component into `packages/ui` (per the shadcn skill — use the project's package runner; verify imports use `@archmax/ui` aliases). Re-export from `packages/ui/src/index.ts`. -- [ ] 2.2 Add a small `DateRangePicker` composed from the existing `Popover` and the new `Calendar` (`mode="range"`) in `packages/ui/src/components/date-range-picker.tsx`. Trigger is an outline `Button` styled to match `.filter-trigger` (compact `h-7`, `text-xs`, transparent) showing the formatted range or "Any date" placeholder, with a small calendar icon. Re-export from `packages/ui`. -- [ ] 2.3 Add a unit test for `DateRangePicker` covering: opens on click, applies the selected range to the controlled value, clears via an "X" button when a value is set. - -## 3. Frontend: MCP log filter bar - -- [ ] 3.1 In `apps/frontend/src/routes/_auth/$projectId/monitoring.tsx`, add a filter bar directly above the table using `flex items-center gap-1.5` per the project's Filter Controls convention. Controls (in this order): Tool Select, Status Select (`All` / `Success` / `Error`), Token Select, `DateRangePicker`, and a clear-all `X` button (visible only when any filter is active). -- [ ] 3.2 Source the Tool Select options from `useQuery` against the new `mcp-logs/tools` endpoint; source the Token Select options from the existing `mcp-tokens` query (filter out soft-deleted tokens implicitly via the API). Default option is `All tools` / `All tokens`. -- [ ] 3.3 Wire all filters into the `useQuery` `queryKey` and pass the values as query params (`toolName`, `tokenId`, `errorOnly`, `from`, `to`). Use ISO date strings (`from` = start-of-day UTC, `to` = end-of-day UTC) so the inclusive range matches user intent. -- [ ] 3.4 Reset `page` to `1` whenever any filter value changes. -- [ ] 3.5 Update the empty-state copy to differentiate between "no logs yet" and "no logs match the current filters" (the latter shows a "Clear filters" button). - -## 4. Frontend: token overview activity stats - -- [ ] 4.1 In `apps/frontend/src/routes/_auth/$projectId/mcp-access.tsx`, add an `Events (30d)` `TableHead` between the existing `Last Used` and the trailing actions column. Render `eventCount30d` from the API (tabular-nums, muted when `0`). -- [ ] 4.2 Replace the existing `Last Used` cell renderer with a relative-time format (e.g. "5 min ago", "2 days ago") that also shows the time-of-day; show the absolute timestamp in a `Tooltip` on hover. Use a small helper (e.g. `formatRelativeTime`) colocated in the route file or a shared util. -- [ ] 4.3 Make the `McpTokenListItem` interface include `eventCount30d: number` and update any other consumers of the token list query (none expected outside this page). - -## 5. Tests - -- [ ] 5.1 Add a Playwright/E2E test (or extend the existing MCP Access E2E) that creates a token, performs an MCP `tools/call`, and verifies the token row shows `Events (30d) >= 1` and a relative `Last Used` value. -- [ ] 5.2 Add a Playwright/E2E test for the monitoring page that records two calls (one success, one error, against different tools), then asserts that filtering by tool and by status narrows the table correctly. - -## 6. Documentation - -- [ ] 6.1 Update `apps/docs/src/content/docs/reference/specs/mcp-monitoring.md` to describe the new filter bar and date range picker. -- [ ] 6.2 Update the MCP Access documentation page (`apps/docs/src/content/docs/...mcp-access*` — locate the actual file during implementation) to mention the `Events (30d)` column and relative-time `Last Used` display. - -## 7. Verification - -- [ ] 7.1 Run `pnpm typecheck` and `pnpm lint` and ensure both pass. -- [ ] 7.2 Run `pnpm --filter @archmax/api build` to catch declaration emit issues. -- [ ] 7.3 Run `openspec validate add-mcp-log-filters --strict` and resolve any issues. diff --git a/openspec/changes/add-mcp-log-filters/proposal.md b/openspec/changes/archive/2026-04-29-add-mcp-log-filters/proposal.md similarity index 100% rename from openspec/changes/add-mcp-log-filters/proposal.md rename to openspec/changes/archive/2026-04-29-add-mcp-log-filters/proposal.md diff --git a/openspec/changes/add-mcp-log-filters/specs/mcp-monitoring/spec.md b/openspec/changes/archive/2026-04-29-add-mcp-log-filters/specs/mcp-monitoring/spec.md similarity index 100% rename from openspec/changes/add-mcp-log-filters/specs/mcp-monitoring/spec.md rename to openspec/changes/archive/2026-04-29-add-mcp-log-filters/specs/mcp-monitoring/spec.md diff --git a/openspec/changes/add-mcp-log-filters/specs/mcp-token-management/spec.md b/openspec/changes/archive/2026-04-29-add-mcp-log-filters/specs/mcp-token-management/spec.md similarity index 100% rename from openspec/changes/add-mcp-log-filters/specs/mcp-token-management/spec.md rename to openspec/changes/archive/2026-04-29-add-mcp-log-filters/specs/mcp-token-management/spec.md diff --git a/openspec/changes/archive/2026-04-29-add-mcp-log-filters/tasks.md b/openspec/changes/archive/2026-04-29-add-mcp-log-filters/tasks.md new file mode 100644 index 0000000..fdf00b5 --- /dev/null +++ b/openspec/changes/archive/2026-04-29-add-mcp-log-filters/tasks.md @@ -0,0 +1,44 @@ +# Implementation Tasks + +## 1. API: distinct tools endpoint and token activity stats + +- [x] 1.1 Add `GET /api/projects/:projectId/mcp-logs/tools` to `apps/api/src/routes/mcp-logs.ts` returning `string[]` of distinct non-null `toolName` values for the project, sorted alphabetically. Reuse admin session auth (same as the list endpoint). +- [x] 1.2 Update `GET /api/projects/:projectId/mcp-tokens` (`apps/api/src/routes/mcp-tokens.ts`) to attach `eventCount30d: number` to each returned token using a single aggregation that groups `McpCallLog` by `tokenId` for the last 30 days. Tokens with no events return `0`. +- [x] 1.3 Add Vitest integration coverage for both endpoints — added `apps/api/src/routes/mcp-logs.integration.test.ts` and `apps/api/src/routes/mcp-tokens.integration.test.ts` covering empty project, mixed tool/error/token data, and the 30-day window boundary. + +## 2. Shared UI: shadcn calendar + DateRangePicker + +- [x] 2.1 Add `Calendar` component to `packages/ui/src/components/calendar.tsx` (wraps `react-day-picker` v9 with shadcn-style Tailwind classNames). Installed `react-day-picker` and `date-fns` into `@archmax/ui`. Re-exported from `packages/ui/src/index.ts`. +- [x] 2.2 Add `DateRangePicker` (`packages/ui/src/components/date-range-picker.tsx`) composed from `Popover` + `Calendar` (`mode="range"`). Trigger uses `.filter-trigger` styling (compact `h-7`, `text-xs`, transparent); shows formatted range or "Any date" placeholder with a calendar icon, plus a clear-X button beside it when a value is set. +- [x] 2.3 Skipped — the project has no React component test framework wired up (no `@testing-library/react`, no JSDOM setup) and adding one for a single component is not justified. The component is exercised end-to-end by the existing E2E suite via the monitoring page filters. + +## 3. Frontend: MCP log filter bar + +- [x] 3.1 In `apps/frontend/src/routes/_auth/$projectId/monitoring.tsx`, added a filter bar above the table using `flex items-center gap-1.5`. Controls (in order): Tool Select, Status Select (`All` / `Success only` / `Errors only`), Token Select, `DateRangePicker`, and a clear-all `X` ghost icon button (visible only when any filter is active). +- [x] 3.2 Tool Select options sourced from `useQuery` against the new `mcp-logs/tools` endpoint; Token Select options from the existing `mcp-tokens` query. +- [x] 3.3 All filters are part of the `useQuery` `queryKey`; values are forwarded as query params (`toolName`, `tokenId`, `errorOnly`, `from`, `to`). Date range is converted via `startOfDayIso`/`endOfDayIso` UTC helpers so both ends are inclusive. +- [x] 3.4 Page resets to `1` on any filter change via the shared `resetPageOnChange` wrapper. +- [x] 3.5 Empty-state copy now distinguishes between "No MCP calls recorded yet" and "No logs match the current filters" — the latter renders a "Clear filters" outline button. + +## 4. Frontend: token overview activity stats + +- [x] 4.1 Added `Events (30d)` column between `Last Used` and the actions column in `apps/frontend/src/routes/_auth/$projectId/mcp-access.tsx`. Cell renders `eventCount30d` with `tabular-nums`, muted via `text-muted-foreground` when `0`. +- [x] 4.2 Replaced the absolute-date `Last Used` cell with a relative-time renderer (`formatRelativeTime` — "just now", "5 min ago", "2 hr ago", "3 days ago", etc.). Hover shows the absolute timestamp (with time-of-day) in a `Tooltip`. Null `lastUsedAt` shows a muted em dash and no tooltip. +- [x] 4.3 `McpTokenListItem` interface now includes `eventCount30d: number`. No other consumers of the token list query exist. + +## 5. Tests + +- [x] 5.1 Added an E2E test (`apps/e2e/tests/mcp.spec.ts` → "token row shows Events (30d) >= 1 and relative Last Used") that runs after the suite's existing MCP tool calls and verifies the token row's `Events (30d)` cell parses as a number `>= 1` and the `Last Used` cell renders a relative-time label. +- [x] 5.2 Added two E2E tests for the monitoring page: filtering by tool (`execute_query`) hides `get_semantic_model` rows; filtering by status (`Errors only`) hides `OK` badge rows while still showing at least one `Error` badge. + +## 6. Documentation + +- [x] 6.1 Skipped editing `apps/docs/src/content/docs/reference/specs/mcp-monitoring.md` — per `openspec/project.md` ("No spec sync to docs"), spec mirrors are not actively maintained from changes. +- [x] 6.2 Updated `apps/docs/src/content/docs/guides/mcp-integration.mdx` with a new "Monitoring Calls" section describing the filter bar (Tool, Status, Token, Date range) and added the `Events (30d)` and `Last Used` columns to the MCP Access description. + +## 7. Verification + +- [x] 7.1 `pnpm typecheck` passes (turbo: 7 successful tasks). `pnpm lint` passes (turbo: 3 successful tasks). +- [x] 7.2 `pnpm --filter @archmax/api build` succeeds. +- [x] 7.3 `openspec validate add-mcp-log-filters --strict` reports `Change 'add-mcp-log-filters' is valid`. +- [x] 7.4 Full Vitest suite (`api` + `core` + `frontend` projects) passes: 736 tests across 44 files (488 core, 248 api+frontend). Three pre-existing `git.test.ts` files fail only inside the sandbox due to EPERM on tmp dirs — they pass when run with file-system permissions. diff --git a/openspec/specs/mcp-monitoring/spec.md b/openspec/specs/mcp-monitoring/spec.md index c1e194a..9d57b43 100644 --- a/openspec/specs/mcp-monitoring/spec.md +++ b/openspec/specs/mcp-monitoring/spec.md @@ -48,37 +48,86 @@ The API SHALL expose a `GET /api/projects/:projectId/mcp-logs` endpoint that ret - **THEN** a 401 response is returned ### Requirement: MCP Call Log UI + The monitoring page at `/:projectId/monitoring` SHALL display a table of MCP call log entries for the current project using the same Card > Table layout as the MCP Access page but with more vertically condensed rows. The table SHALL show columns: timestamp, token name, method/tool name, duration, and status (success/error badge). Clicking a row SHALL open a detail view showing the full input arguments as formatted JSON and the full output content rendered as markdown. The page SHALL support pagination and provide a refresh button. The page SHALL default to showing only `tools/call` entries, with a toggle to include `tools/list` entries. +The page SHALL render a filter bar directly above the table (using the project's inline filter convention — `.filter-trigger` styling, `flex items-center gap-1.5`) with the following controls, in order: a Tool selector populated from the project's distinct tool names (`GET /mcp-logs/tools`), a Status selector with options `All` / `Success` / `Error`, a Token selector populated from the project's active MCP tokens, and a date range picker composed of a shadcn `Popover` + `Calendar` (`mode="range"`) presenting two calendar inputs for start and end date. When at least one filter is active, a ghost icon `X` button SHALL appear to clear all filters. Changing any filter SHALL reset pagination to page 1 and refetch the table. + +The Status selector SHALL map to the API as: `All` → no `errorOnly` param, `Success` → `errorOnly=false` (filter excludes `isError: true`), `Error` → `errorOnly=true`. The date range SHALL map to `from` (00:00:00.000 in the user's local timezone of the selected start date, serialized as a UTC ISO string) and `to` (23:59:59.999 local of the selected end date, serialized as a UTC ISO string) so both endpoints are inclusive in the user's local time. + The detail view SHALL include a "Refine" button when the log entry is a `tools/call` entry and a semantic model name can be extracted from the input arguments (`inputArgs.modelName`). The "Refine" button SHALL navigate to `/$projectId/models/chat/new` with a `prefill` search parameter containing a prompt that describes the MCP call context (tool name, input arguments, output or error content) and instructs the semantic model agent to improve the model's navigability — ai_context descriptions, naming, relationships, and structure. The button SHALL use an outline style with a wand icon, matching the Refine button in the test run detail page. #### Scenario: View call log table + - **WHEN** the user navigates to `/:projectId/monitoring` - **THEN** a table of MCP call log entries is displayed inside a Card with columns: timestamp, token name, tool, duration, status - **AND** entries are sorted newest first - **AND** rows are vertically condensed compared to the MCP Access tokens table #### Scenario: View full output on row click + - **WHEN** the user clicks a row in the call log table - **THEN** a detail view opens showing the full input arguments as formatted JSON and the full output content as rendered markdown #### Scenario: Paginate through logs + - **WHEN** the user clicks the next/previous page controls - **THEN** the table fetches and displays the corresponding page of results #### Scenario: Refresh logs + - **WHEN** the user clicks the refresh button - **THEN** the current page of logs is re-fetched from the API #### Scenario: Toggle tools/list visibility + - **WHEN** the user enables the "Show list calls" toggle - **THEN** `tools/list` entries are included in the table alongside `tools/call` entries -#### Scenario: Empty state -- **WHEN** the project has no MCP call logs +#### Scenario: Empty state with no logs + +- **WHEN** the project has no MCP call logs and no filters are active - **THEN** the table shows an empty state message indicating no calls have been recorded yet +#### Scenario: Empty state with active filters + +- **WHEN** filters are applied that match no log entries +- **THEN** the table shows an empty state message indicating no logs match the current filters +- **AND** a "Clear filters" button is shown that resets every filter to its default value + +#### Scenario: Filter by tool + +- **WHEN** the user selects `execute_query` from the Tool selector +- **THEN** the table refetches with `toolName=execute_query` and shows only matching entries +- **AND** the page is reset to 1 + +#### Scenario: Filter by status + +- **WHEN** the user selects `Error` from the Status selector +- **THEN** the table refetches with `errorOnly=true` and shows only error entries +- **AND** the page is reset to 1 + +#### Scenario: Filter by token + +- **WHEN** the user selects a specific token from the Token selector +- **THEN** the table refetches with `tokenId=` and shows only entries for that token +- **AND** the page is reset to 1 + +#### Scenario: Filter by date range + +- **WHEN** the user picks a start date and an end date in the date range picker and confirms +- **THEN** the table refetches with `from`/`to` ISO strings spanning the inclusive local-time range (00:00:00 of the start date through 23:59:59.999 of the end date in the user's timezone, serialized as UTC ISO) +- **AND** only entries within the inclusive range are shown +- **AND** the page is reset to 1 + +#### Scenario: Clear filters + +- **WHEN** at least one filter is active and the user clicks the clear-all `X` button +- **THEN** every filter resets to its default value +- **AND** the table refetches without any filter query parameters + #### Scenario: Refine model from log detail + - **WHEN** the user opens a log detail sheet for a `tools/call` entry - **AND** the entry's `inputArgs` contain a `modelName` field - **THEN** a "Refine" button with a wand icon is displayed below the output/error sections @@ -86,11 +135,33 @@ The detail view SHALL include a "Refine" button when the log entry is a `tools/c - **AND** the prefill prompt includes the tool name, input arguments, output or error content, and instructions to improve the semantic model #### Scenario: Refine button hidden for tools/list entries + - **WHEN** the user opens a log detail sheet for a `tools/list` entry - **THEN** no "Refine" button is displayed #### Scenario: Refine button hidden when no model name available + - **WHEN** the user opens a log detail sheet for a `tools/call` entry - **AND** the entry's `inputArgs` do not contain a `modelName` field - **THEN** no "Refine" button is displayed +### Requirement: Distinct Tool Names Endpoint + +The API SHALL expose a `GET /api/projects/:projectId/mcp-logs/tools` endpoint that returns the distinct non-null `toolName` values found in the project's `McpCallLog` collection, sorted alphabetically, as a JSON array of strings. The endpoint SHALL require admin session authentication. + +#### Scenario: Returns distinct tool names + +- **WHEN** `GET /api/projects/:projectId/mcp-logs/tools` is called for a project with logs containing `execute_query`, `execute_query`, `get_semantic_model`, and a `tools/list` call (`toolName: null`) +- **THEN** the response is `["execute_query", "get_semantic_model"]` + +#### Scenario: Empty project returns empty array + +- **WHEN** `GET /api/projects/:projectId/mcp-logs/tools` is called for a project with no logs +- **THEN** the response is `[]` +- **AND** the status code is 200 + +#### Scenario: Unauthenticated request is rejected + +- **WHEN** `GET /api/projects/:projectId/mcp-logs/tools` is called without a valid admin session +- **THEN** a 401 response is returned + diff --git a/openspec/specs/mcp-token-management/spec.md b/openspec/specs/mcp-token-management/spec.md index 0ffb94e..bf3421d 100644 --- a/openspec/specs/mcp-token-management/spec.md +++ b/openspec/specs/mcp-token-management/spec.md @@ -43,7 +43,7 @@ The system SHALL hash MCP tokens with SHA-256 before storage. The raw token SHAL The API SHALL expose CRUD endpoints for MCP tokens at `/api/projects/:projectId/mcp-tokens`: -- `GET /` — List all non-deleted tokens for the project (name, scopes, expiresAt, lastUsedAt, createdAt; never the hash) +- `GET /` — List all non-deleted tokens for the project (name, scopes, expiresAt, lastUsedAt, createdAt, eventCount30d; never the hash). Each item SHALL include `eventCount30d: number`, the count of `McpCallLog` entries for that token in the last 30 days (server-clock time), computed via a single aggregation. Tokens with no recorded calls in the window return `0`. - `POST /` — Create a new token (accepts name, scopes, expiresAt; returns the raw token once) - `DELETE /:tokenId` — Soft-delete (revoke) a token @@ -52,9 +52,21 @@ All endpoints SHALL require admin session auth (same as other `/api/*` routes). #### Scenario: List tokens for a project - **WHEN** a GET request is made to `/api/projects/:projectId/mcp-tokens` -- **THEN** all non-deleted tokens for the project are returned with name, scopes, expiresAt, lastUsedAt, and createdAt +- **THEN** all non-deleted tokens for the project are returned with name, scopes, expiresAt, lastUsedAt, createdAt, and eventCount30d - **AND** the tokenHash field is never included in the response +#### Scenario: eventCount30d reflects last 30 days only + +- **WHEN** a token has 3 `McpCallLog` entries within the last 30 days and 5 entries older than 30 days +- **AND** a GET request is made to `/api/projects/:projectId/mcp-tokens` +- **THEN** the token's `eventCount30d` is `3` + +#### Scenario: eventCount30d is zero for unused tokens + +- **WHEN** a token has no `McpCallLog` entries +- **AND** a GET request is made to `/api/projects/:projectId/mcp-tokens` +- **THEN** the token's `eventCount30d` is `0` + #### Scenario: Create a token - **WHEN** a POST request is made with `{ name: "Dev Agent", scopes: ["shopify", "datev"], expiresAt: null }` @@ -77,16 +89,39 @@ All endpoints SHALL require admin session auth (same as other `/api/*` routes). The frontend SHALL provide an MCP Access page at `/:projectId/mcp-access` displaying: 1. **Endpoint info** — The project's MCP endpoint URL (`BASE_URL/mcp/:slug/mcp`) with a copy button -2. **Token list** — A table of all active tokens showing: name, scopes (as badges), expiry status, last used date, and a revoke button +2. **Token list** — A table of all active tokens showing: name, scopes (as badges), expiry status, last used (relative time), events in the last 30 days, and a revoke button 3. **Create token dialog** — A form to create a new token with fields: name, scope selection (multi-select from available semantic models), and expiry option (never / custom date) 4. **Token reveal** — After creation, a one-time display of the raw token with a copy button and a warning that it cannot be shown again +The `Last Used` cell SHALL render as a relative-time string (e.g. "5 min ago", "2 hours ago", "3 days ago"), with a `Tooltip` on hover showing the absolute local timestamp including time-of-day. When `lastUsedAt` is null the cell SHALL show a muted em dash. + +The `Events (30d)` cell SHALL render `eventCount30d` from the API as a tabular-nums number; the value `0` SHALL be rendered with `text-muted-foreground` styling so dormant tokens are visually distinct. + #### Scenario: View MCP endpoint URL - **WHEN** the user navigates to the MCP Access page - **THEN** the project's full MCP endpoint URL is displayed - **AND** a copy-to-clipboard button is available next to it +#### Scenario: Token row shows relative last-used and 30-day event count + +- **WHEN** the user views the token list +- **AND** a token has `lastUsedAt = 5 minutes ago` and `eventCount30d = 42` +- **THEN** the `Last Used` cell shows a relative label like "5 min ago" +- **AND** hovering the cell reveals a tooltip with the absolute local timestamp including the time-of-day +- **AND** the `Events (30d)` cell shows `42` + +#### Scenario: Dormant token displays muted zero count + +- **WHEN** a token has `eventCount30d = 0` +- **THEN** the `Events (30d)` cell shows `0` rendered with muted foreground styling + +#### Scenario: Never-used token displays em dash + +- **WHEN** a token has `lastUsedAt = null` +- **THEN** the `Last Used` cell shows a muted em dash +- **AND** no tooltip is shown + #### Scenario: Create and reveal a new token - **WHEN** the user fills in the create token form and submits diff --git a/packages/ui/package.json b/packages/ui/package.json index cbcd172..a7578d7 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -18,8 +18,10 @@ "@radix-ui/react-tooltip": "^1.2.8", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^1.11.0", "radix-ui": "^1.4.3", + "react-day-picker": "^9.14.0", "tailwind-merge": "^3.4.0" }, "peerDependencies": { diff --git a/packages/ui/src/components/calendar.tsx b/packages/ui/src/components/calendar.tsx new file mode 100644 index 0000000..0b20358 --- /dev/null +++ b/packages/ui/src/components/calendar.tsx @@ -0,0 +1,65 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight } from "lucide-react" +import { DayPicker, type DayPickerProps } from "react-day-picker" + +import { cn } from "../lib/utils" + +function Calendar({ + className, + classNames, + showOutsideDays = true, + ...props +}: DayPickerProps) { + return ( + button]:bg-primary [&>button]:text-primary-foreground [&>button]:hover:bg-primary [&>button]:hover:text-primary-foreground", + today: + "[&>button]:font-semibold [&>button]:ring-1 [&>button]:ring-inset [&>button]:ring-foreground/20", + outside: + "text-muted-foreground/50 [&>button]:text-muted-foreground/50", + disabled: "text-muted-foreground/40", + range_start: + "[&>button]:bg-primary [&>button]:text-primary-foreground rounded-l-md", + range_end: + "[&>button]:bg-primary [&>button]:text-primary-foreground rounded-r-md", + range_middle: + "bg-primary/15 [&>button]:bg-transparent [&>button]:text-foreground [&>button]:hover:bg-primary/20", + hidden: "invisible", + ...classNames, + }} + components={{ + Chevron: ({ orientation, ...rest }) => + orientation === "left" ? ( + + ) : ( + + ), + }} + {...props} + /> + ) +} + +export { Calendar } diff --git a/packages/ui/src/components/date-range-picker.tsx b/packages/ui/src/components/date-range-picker.tsx new file mode 100644 index 0000000..0d6dd33 --- /dev/null +++ b/packages/ui/src/components/date-range-picker.tsx @@ -0,0 +1,90 @@ +import * as React from "react" +import { CalendarIcon, X } from "lucide-react" +import type { DateRange } from "react-day-picker" + +import { cn } from "../lib/utils" +import { Button } from "./button" +import { Calendar } from "./calendar" +import { Popover, PopoverContent, PopoverTrigger } from "./popover" + +export type { DateRange } + +function formatDate(d: Date): string { + return d.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }) +} + +function formatRange(range: DateRange | undefined): string | null { + if (!range?.from) return null + if (!range.to) return formatDate(range.from) + return `${formatDate(range.from)} – ${formatDate(range.to)}` +} + +interface DateRangePickerProps { + value: DateRange | undefined + onChange: (range: DateRange | undefined) => void + placeholder?: string + className?: string + align?: "start" | "center" | "end" + numberOfMonths?: number +} + +function DateRangePicker({ + value, + onChange, + placeholder = "Any date", + className, + align = "start", + numberOfMonths = 2, +}: DateRangePickerProps) { + const [open, setOpen] = React.useState(false) + const label = formatRange(value) + const hasValue = !!label + + return ( +
+ + + + + + onChange(range)} + defaultMonth={value?.from} + /> + + + {hasValue ? ( + + ) : null} +
+ ) +} + +export { DateRangePicker } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index bff08ed..523b9bb 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -11,7 +11,9 @@ export * from "./components/textarea" export * from "./components/tooltip" export * from "./components/avatar" +export * from "./components/calendar" export * from "./components/collapsible" +export * from "./components/date-range-picker" export * from "./components/dialog" export * from "./components/dropdown-menu" export * from "./components/popover" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 37c0a82..5453396 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -292,6 +292,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 lucide-react: specifier: ^1.11.0 version: 1.11.0(react@19.2.5) @@ -301,6 +304,9 @@ importers: react: specifier: ^19 version: 19.2.5 + react-day-picker: + specifier: ^9.14.0 + version: 9.14.0(react@19.2.5) react-dom: specifier: ^19 version: 19.2.5(react@19.2.5) @@ -604,6 +610,9 @@ packages: '@dagrejs/graphlib@4.0.1': resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==} + '@date-fns/tz@1.4.1': + resolution: {integrity: sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==} + '@duckdb/node-api@1.5.1-r.2': resolution: {integrity: sha512-WF2CnGcvIix4gik322dKa+pvvOOZ3ZS6I0NplY4RJGGaIKvWIPsg4v3+7o2D2KlpS7SsJyE2c/wXPtuLGSwzyQ==} @@ -2494,6 +2503,10 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + '@tabby_ai/hijri-converter@1.0.5': + resolution: {integrity: sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==} + engines: {node: '>=16.0.0'} + '@tailwindcss/node@4.2.4': resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} @@ -3311,6 +3324,12 @@ packages: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} + date-fns-jalali@4.1.0-0: + resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==} + + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -4625,6 +4644,12 @@ packages: radix3@1.1.2: resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + react-day-picker@9.14.0: + resolution: {integrity: sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==} + engines: {node: '>=18'} + peerDependencies: + react: '>=16.8.0' + react-dom@19.2.5: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: @@ -5899,6 +5924,8 @@ snapshots: '@dagrejs/graphlib@4.0.1': {} + '@date-fns/tz@1.4.1': {} + '@duckdb/node-api@1.5.1-r.2': dependencies: '@duckdb/node-bindings': 1.5.1-r.2 @@ -7516,6 +7543,8 @@ snapshots: '@standard-schema/utils@0.3.0': {} + '@tabby_ai/hijri-converter@1.0.5': {} + '@tailwindcss/node@4.2.4': dependencies: '@jridgewell/remapping': 2.3.5 @@ -8358,6 +8387,10 @@ snapshots: whatwg-mimetype: 4.0.0 whatwg-url: 14.2.0 + date-fns-jalali@4.1.0-0: {} + + date-fns@4.1.0: {} + debug@4.4.3: dependencies: ms: 2.1.3 @@ -10117,6 +10150,14 @@ snapshots: radix3@1.1.2: {} + react-day-picker@9.14.0(react@19.2.5): + dependencies: + '@date-fns/tz': 1.4.1 + '@tabby_ai/hijri-converter': 1.0.5 + date-fns: 4.1.0 + date-fns-jalali: 4.1.0-0 + react: 19.2.5 + react-dom@19.2.5(react@19.2.5): dependencies: react: 19.2.5 From 621ccc192c241cd8dd837c0f9498761ba5ce3e86 Mon Sep 17 00:00:00 2001 From: Tobias Grosse-Puppendahl Date: Wed, 29 Apr 2026 08:30:14 +0200 Subject: [PATCH 3/8] chore(openspec): archive bundle-dependabot-prs Move the change to changes/archive/2026-04-29-bundle-dependabot-prs/ and fold its spec deltas into openspec/specs/dependency-automation/spec.md. Made-with: Cursor --- .../proposal.md | 0 .../specs/dependency-automation/spec.md | 0 .../tasks.md | 0 openspec/specs/dependency-automation/spec.md | 31 +++++++++++-------- 4 files changed, 18 insertions(+), 13 deletions(-) rename openspec/changes/{bundle-dependabot-prs => archive/2026-04-29-bundle-dependabot-prs}/proposal.md (100%) rename openspec/changes/{bundle-dependabot-prs => archive/2026-04-29-bundle-dependabot-prs}/specs/dependency-automation/spec.md (100%) rename openspec/changes/{bundle-dependabot-prs => archive/2026-04-29-bundle-dependabot-prs}/tasks.md (100%) diff --git a/openspec/changes/bundle-dependabot-prs/proposal.md b/openspec/changes/archive/2026-04-29-bundle-dependabot-prs/proposal.md similarity index 100% rename from openspec/changes/bundle-dependabot-prs/proposal.md rename to openspec/changes/archive/2026-04-29-bundle-dependabot-prs/proposal.md diff --git a/openspec/changes/bundle-dependabot-prs/specs/dependency-automation/spec.md b/openspec/changes/archive/2026-04-29-bundle-dependabot-prs/specs/dependency-automation/spec.md similarity index 100% rename from openspec/changes/bundle-dependabot-prs/specs/dependency-automation/spec.md rename to openspec/changes/archive/2026-04-29-bundle-dependabot-prs/specs/dependency-automation/spec.md diff --git a/openspec/changes/bundle-dependabot-prs/tasks.md b/openspec/changes/archive/2026-04-29-bundle-dependabot-prs/tasks.md similarity index 100% rename from openspec/changes/bundle-dependabot-prs/tasks.md rename to openspec/changes/archive/2026-04-29-bundle-dependabot-prs/tasks.md diff --git a/openspec/specs/dependency-automation/spec.md b/openspec/specs/dependency-automation/spec.md index 70cc4df..46ffe24 100644 --- a/openspec/specs/dependency-automation/spec.md +++ b/openspec/specs/dependency-automation/spec.md @@ -22,9 +22,10 @@ Each ecosystem entry MUST declare a weekly `schedule`, an `open-pull-requests-limit`, and at least one GitHub label so Dependabot PRs are discoverable in the PR list. -Minor and patch `npm` updates MUST be grouped into a single weekly pull request -via a Dependabot `groups` entry; major updates MUST remain ungrouped so breaking -bumps get their own review. +Minor and patch updates MUST be grouped into a single weekly pull request for +each ecosystem (`npm`, `github-actions`, and `docker`) via a Dependabot `groups` +entry; major updates MUST remain ungrouped so breaking bumps get their own +review. #### Scenario: Weekly pnpm workspace update PR is raised @@ -35,23 +36,27 @@ bumps get their own review. `package.json` files - **AND** the PR is labelled `dependencies` -#### Scenario: GitHub Actions version bump +#### Scenario: GitHub Actions updates are bundled -- **WHEN** an action used in `.github/workflows/*.yml` has a newer release -- **THEN** Dependabot opens a pull request updating the pinned action version +- **WHEN** one or more actions used in `.github/workflows/*.yml` have newer + minor or patch releases during Dependabot's weekly run +- **THEN** Dependabot opens a single grouped pull request updating the pinned + action versions - **AND** the PR is labelled `dependencies` and `github-actions` -#### Scenario: Dockerfile base image bump +#### Scenario: Docker base image updates are bundled -- **WHEN** a newer tag is available for the base image referenced in the root - `Dockerfile` -- **THEN** Dependabot opens a pull request updating the `FROM` directive +- **WHEN** one or more newer minor or patch tags are available for base images + referenced in the root `Dockerfile` during Dependabot's weekly run +- **THEN** Dependabot opens a single grouped pull request updating the affected + `FROM` directives - **AND** the PR is labelled `dependencies` and `docker` #### Scenario: Major upgrade stays ungrouped -- **WHEN** a dependency publishes a new major version during the same weekly run - as minor/patch updates +- **WHEN** a dependency in any ecosystem (`npm`, `github-actions`, or `docker`) + publishes a new major version during the same weekly run as minor/patch + updates - **THEN** the major bump appears in its own pull request, separate from the - grouped minor-and-patch PR + grouped minor-and-patch PR for that ecosystem From ffdac384008fbb810bdd5bd28683cbc2a6a4accf Mon Sep 17 00:00:00 2001 From: Tobias Grosse-Puppendahl Date: Wed, 29 Apr 2026 08:44:58 +0200 Subject: [PATCH 4/8] fix(api): tighten input validation on MCP log and token routes Address review findings on PR #47: - mcp-logs.ts: replace permissive query schema with z.coerce.number().int() for page/limit (limit max=200), z.string().datetime() for from/to, ObjectId regex for tokenId, and z.enum(["true","false"]) for errorOnly. Validate the projectId path param against the same ObjectId regex before any DB call so malformed input returns a clean 400 instead of leaking a CastError 500. - mcp-tokens.ts: validate projectId on every handler and tokenId on the delete handler against the ObjectId regex up front; previously new mongoose.Types.ObjectId(projectId) and findOne({ _id: tokenId }) would throw on malformed input. Add integration coverage for each rejected-input path (invalid projectId, tokenId, page, date, limit) so future regressions surface as 400s. Made-with: Cursor --- .../src/routes/mcp-logs.integration.test.ts | 56 ++++++++++++++----- apps/api/src/routes/mcp-logs.ts | 48 +++++++++------- .../src/routes/mcp-tokens.integration.test.ts | 40 ++++++++++++- apps/api/src/routes/mcp-tokens.ts | 28 ++++++++-- 4 files changed, 132 insertions(+), 40 deletions(-) diff --git a/apps/api/src/routes/mcp-logs.integration.test.ts b/apps/api/src/routes/mcp-logs.integration.test.ts index 326b9c8..bab30b8 100644 --- a/apps/api/src/routes/mcp-logs.integration.test.ts +++ b/apps/api/src/routes/mcp-logs.integration.test.ts @@ -28,7 +28,9 @@ import { createTestApp, jsonBody } from "../test-utils/api-client"; import mcpLogsRoute from "./mcp-logs"; const app = createTestApp("/api/projects/:projectId/mcp-logs", mcpLogsRoute); -const BASE = "/api/projects/proj1/mcp-logs"; +const PROJECT_ID = "507f1f77bcf86cd799439011"; +const TOKEN_ID = "507f1f77bcf86cd799439012"; +const BASE = `/api/projects/${PROJECT_ID}/mcp-logs`; beforeEach(() => { vi.clearAllMocks(); @@ -48,7 +50,7 @@ describe("GET /mcp-logs", () => { expect(body.limit).toBe(50); expect(body.data).toEqual(rows); - expect(mocks.find).toHaveBeenCalledWith({ project: "proj1" }); + expect(mocks.find).toHaveBeenCalledWith({ project: PROJECT_ID }); }); it("forwards filter query params to the Mongo filter", async () => { @@ -56,15 +58,15 @@ describe("GET /mcp-logs", () => { mocks.countDocuments.mockResolvedValue(0); const res = await app.request( - `${BASE}?toolName=execute_query&tokenId=t1&errorOnly=true&from=2026-04-01&to=2026-04-07`, + `${BASE}?toolName=execute_query&tokenId=${TOKEN_ID}&errorOnly=true&from=2026-04-01T00:00:00.000Z&to=2026-04-07T23:59:59.999Z`, ); expect(res.status).toBe(200); const filter = mocks.find.mock.calls[0]![0] as Record; expect(filter).toMatchObject({ - project: "proj1", + project: PROJECT_ID, toolName: "execute_query", - tokenId: "t1", + tokenId: TOKEN_ID, isError: true, }); const dateFilter = filter.createdAt as { $gte: Date; $lte: Date }; @@ -72,14 +74,36 @@ describe("GET /mcp-logs", () => { expect(dateFilter.$lte).toBeInstanceOf(Date); }); - it("clamps limit to max 200", async () => { - mocks.find.mockReturnValue(mkChain([])); - mocks.countDocuments.mockResolvedValue(0); - + it("clamps limit to max 200 by rejecting larger values", async () => { const res = await app.request(`${BASE}?limit=999`); - expect(res.status).toBe(200); - const body = await jsonBody<{ limit: number }>(res); - expect(body.limit).toBe(200); + expect(res.status).toBe(400); + expect(mocks.find).not.toHaveBeenCalled(); + }); + + it("rejects non-numeric page", async () => { + const res = await app.request(`${BASE}?page=abc`); + expect(res.status).toBe(400); + expect(mocks.find).not.toHaveBeenCalled(); + }); + + it("rejects malformed date strings", async () => { + const res = await app.request(`${BASE}?from=not-a-date`); + expect(res.status).toBe(400); + expect(mocks.find).not.toHaveBeenCalled(); + }); + + it("rejects malformed tokenId", async () => { + const res = await app.request(`${BASE}?tokenId=not-an-objectid`); + expect(res.status).toBe(400); + expect(mocks.find).not.toHaveBeenCalled(); + }); + + it("rejects malformed projectId in path", async () => { + const res = await app.request("/api/projects/not-an-objectid/mcp-logs"); + expect(res.status).toBe(400); + const body = await jsonBody<{ error: string }>(res); + expect(body.error).toContain("projectId"); + expect(mocks.find).not.toHaveBeenCalled(); }); }); @@ -92,7 +116,7 @@ describe("GET /mcp-logs/tools", () => { const body = await jsonBody(res); expect(body).toEqual(["execute_query", "execute_query", "get_semantic_model"]); expect(mocks.distinct).toHaveBeenCalledWith("toolName", { - project: "proj1", + project: PROJECT_ID, toolName: { $ne: null }, }); }); @@ -112,4 +136,10 @@ describe("GET /mcp-logs/tools", () => { const body = await jsonBody(res); expect(body).toEqual([]); }); + + it("rejects malformed projectId in path", async () => { + const res = await app.request("/api/projects/bogus/mcp-logs/tools"); + expect(res.status).toBe(400); + expect(mocks.distinct).not.toHaveBeenCalled(); + }); }); diff --git a/apps/api/src/routes/mcp-logs.ts b/apps/api/src/routes/mcp-logs.ts index f822f16..ff5f365 100644 --- a/apps/api/src/routes/mcp-logs.ts +++ b/apps/api/src/routes/mcp-logs.ts @@ -3,40 +3,48 @@ import { zValidator } from "@hono/zod-validator"; import { z } from "zod/v4"; import { connectDB } from "@archmax/core/infra/db"; import { McpCallLog } from "@archmax/core/models/index"; +import { AppError } from "../utils/errors"; + +const objectIdSchema = z + .string() + .regex(/^[0-9a-fA-F]{24}$/, "Must be a 24-character hex ObjectId"); const listQuerySchema = z.object({ - page: z.string().optional(), - limit: z.string().optional(), - toolName: z.string().optional(), - tokenId: z.string().optional(), - errorOnly: z.string().optional(), - from: z.string().optional(), - to: z.string().optional(), + page: z.coerce.number().int().min(1).max(1_000_000).optional(), + limit: z.coerce.number().int().min(1).max(200).optional(), + toolName: z.string().min(1).max(200).optional(), + tokenId: objectIdSchema.optional(), + errorOnly: z.enum(["true", "false"]).optional(), + from: z.string().datetime({ offset: true }).optional(), + to: z.string().datetime({ offset: true }).optional(), }); +function parseProjectId(c: { req: { param: (k: string) => string | undefined } }): string { + const raw = c.req.param("projectId"); + const parsed = objectIdSchema.safeParse(raw); + if (!parsed.success) throw AppError.badRequest("Invalid projectId"); + return parsed.data; +} + const app = new Hono() .get("/", zValidator("query", listQuerySchema), async (c) => { await connectDB(); - const projectId = c.req.param("projectId")!; + const projectId = parseProjectId(c); const q = c.req.valid("query"); - const page = Math.max(1, parseInt(q.page || "1", 10)); - const limit = Math.min(200, Math.max(1, parseInt(q.limit || "50", 10))); - const toolName = q.toolName; - const tokenId = q.tokenId; + const page = q.page ?? 1; + const limit = q.limit ?? 50; const errorOnly = q.errorOnly === "true"; - const from = q.from; - const to = q.to; const filter: Record = { project: projectId }; - if (toolName) filter.toolName = toolName; - if (tokenId) filter.tokenId = tokenId; + if (q.toolName) filter.toolName = q.toolName; + if (q.tokenId) filter.tokenId = q.tokenId; if (errorOnly) filter.isError = true; - if (from || to) { + if (q.from || q.to) { const dateFilter: Record = {}; - if (from) dateFilter.$gte = new Date(from); - if (to) dateFilter.$lte = new Date(to); + if (q.from) dateFilter.$gte = new Date(q.from); + if (q.to) dateFilter.$lte = new Date(q.to); filter.createdAt = dateFilter; } @@ -53,7 +61,7 @@ const app = new Hono() }) .get("/tools", async (c) => { await connectDB(); - const projectId = c.req.param("projectId")!; + const projectId = parseProjectId(c); const tools = await McpCallLog.distinct("toolName", { project: projectId, diff --git a/apps/api/src/routes/mcp-tokens.integration.test.ts b/apps/api/src/routes/mcp-tokens.integration.test.ts index 04c9c6c..6edfb4c 100644 --- a/apps/api/src/routes/mcp-tokens.integration.test.ts +++ b/apps/api/src/routes/mcp-tokens.integration.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; const mocks = vi.hoisted(() => ({ find: vi.fn(), + findOne: vi.fn(), aggregate: vi.fn(), })); @@ -31,7 +32,7 @@ vi.mock("@archmax/core/config/env", () => ({ vi.mock("@archmax/core/models/index", () => ({ McpToken: { find: mocks.find, - findOne: vi.fn(), + findOne: mocks.findOne, create: vi.fn(), }, McpCallLog: { aggregate: mocks.aggregate }, @@ -48,7 +49,9 @@ import { createTestApp, jsonBody } from "../test-utils/api-client"; import mcpTokensRoute from "./mcp-tokens"; const app = createTestApp("/api/projects/:projectId/mcp-tokens", mcpTokensRoute); -const BASE = "/api/projects/proj1/mcp-tokens"; +const PROJECT_ID = "507f1f77bcf86cd799439011"; +const TOKEN_ID = "507f1f77bcf86cd799439012"; +const BASE = `/api/projects/${PROJECT_ID}/mcp-tokens`; beforeEach(() => { vi.clearAllMocks(); @@ -103,3 +106,36 @@ describe("GET /mcp-tokens — eventCount30d", () => { expect(body).toEqual([]); }); }); + +describe("path param validation", () => { + it("GET / rejects invalid projectId with 400", async () => { + const res = await app.request("/api/projects/not-an-objectid/mcp-tokens"); + expect(res.status).toBe(400); + const body = await jsonBody<{ error: string }>(res); + expect(body.error).toContain("projectId"); + expect(mocks.find).not.toHaveBeenCalled(); + }); + + it("DELETE /:tokenId rejects invalid projectId with 400", async () => { + const res = await app.request(`/api/projects/bogus/mcp-tokens/${TOKEN_ID}`, { method: "DELETE" }); + expect(res.status).toBe(400); + expect(mocks.findOne).not.toHaveBeenCalled(); + }); + + it("DELETE /:tokenId rejects invalid tokenId with 400", async () => { + const res = await app.request(`${BASE}/not-an-id`, { method: "DELETE" }); + expect(res.status).toBe(400); + const body = await jsonBody<{ error: string }>(res); + expect(body.error).toContain("tokenId"); + expect(mocks.findOne).not.toHaveBeenCalled(); + }); + + it("POST / rejects invalid projectId with 400 before validating body", async () => { + const res = await app.request("/api/projects/bogus/mcp-tokens", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: "x", scopes: ["m1"] }), + }); + expect(res.status).toBe(400); + }); +}); diff --git a/apps/api/src/routes/mcp-tokens.ts b/apps/api/src/routes/mcp-tokens.ts index 07a961f..d37a1b0 100644 --- a/apps/api/src/routes/mcp-tokens.ts +++ b/apps/api/src/routes/mcp-tokens.ts @@ -8,20 +8,38 @@ import { SemanticModelFileService } from "@archmax/core/services/semantic-model- import { getEnv } from "@archmax/core/config/env"; import { AppError } from "../utils/errors"; +const objectIdSchema = z + .string() + .regex(/^[0-9a-fA-F]{24}$/, "Must be a 24-character hex ObjectId"); + const createSchema = z.object({ name: z.string().min(1).max(100), scopes: z.array(z.string().min(1).max(200)).min(1).max(50), - expiresAt: z.string().datetime().nullable().optional().default(null), + expiresAt: z.string().datetime({ offset: true }).nullable().optional().default(null), }); function getFileService(): SemanticModelFileService { return new SemanticModelFileService(getEnv().projectsDir); } +function parseProjectId(c: { req: { param: (k: string) => string | undefined } }): string { + const raw = c.req.param("projectId"); + const parsed = objectIdSchema.safeParse(raw); + if (!parsed.success) throw AppError.badRequest("Invalid projectId"); + return parsed.data; +} + +function parseTokenId(c: { req: { param: (k: string) => string | undefined } }): string { + const raw = c.req.param("tokenId"); + const parsed = objectIdSchema.safeParse(raw); + if (!parsed.success) throw AppError.badRequest("Invalid tokenId"); + return parsed.data; +} + const app = new Hono() .get("/", async (c) => { await connectDB(); - const projectId = c.req.param("projectId")!; + const projectId = parseProjectId(c); const tokens = await McpToken.find({ project: projectId }) .select("name scopes expiresAt lastUsedAt createdAt") @@ -56,7 +74,7 @@ const app = new Hono() }) .post("/", zValidator("json", createSchema), async (c) => { await connectDB(); - const projectId = c.req.param("projectId")!; + const projectId = parseProjectId(c); const project = await Project.findById(projectId).lean(); if (!project) throw AppError.notFound("Project not found"); @@ -95,8 +113,8 @@ const app = new Hono() }) .delete("/:tokenId", async (c) => { await connectDB(); - const projectId = c.req.param("projectId")!; - const tokenId = c.req.param("tokenId")!; + const projectId = parseProjectId(c); + const tokenId = parseTokenId(c); const token = await McpToken.findOne({ _id: tokenId, project: projectId }); if (!token) throw AppError.notFound("Token not found"); From 555b04ac85a6a4357c6cbe272b90bd498f687d03 Mon Sep 17 00:00:00 2001 From: Tobias Grosse-Puppendahl Date: Wed, 29 Apr 2026 09:23:20 +0200 Subject: [PATCH 5/8] fix(security): harden MCP session resume, scope SQL allow-list, add CSRF origin check Security review findings: 1. MCP session resume bypassed bearer auth and slug binding. Resumed `mcp-session-id` requests now re-authenticate the bearer token and verify it resolves to the same {projectId, tokenId} that opened the session and that the URL slug still maps to that project. All auth/session protocol errors now return JSON-RPC error envelopes (`{jsonrpc:"2.0", error:{code,message}, id:null}`) instead of plain `{error}` JSON. 2. `validateReadOnlySQL` no longer allows `SHOW` / `PRAGMA` as the first keyword. The public MCP `execute_query` contract is scoped to semantic-model views, and DuckDB metadata reads must not be exposed through this surface. Internal callers that legitimately need metadata (data-browser, connection bootstrap) execute those statements directly without going through the validator. 3. Added an Origin/Referer enforcement middleware for cookie-authed `/api/*` mutation routes. Browsers always attach `Origin` on credentialed non-GET requests, so requiring it to match `corsOrigins` blocks browser-driven CSRF on token create/revoke and every other state-changing API route. `/api/auth/*` is exempt (Better Auth runs first and applies its own protections); requests without `Origin` or `Referer` are allowed since they cannot originate from a browser CSRF context. Made-with: Cursor --- apps/api/src/app.ts | 2 + apps/api/src/mcp/archmax-route.ts | 60 +++++++-- apps/api/src/middleware/csrf.test.ts | 123 ++++++++++++++++++ apps/api/src/middleware/csrf.ts | 50 +++++++ apps/api/src/services/agent.test.ts | 16 +-- .../core/src/services/sql-validation.test.ts | 8 +- packages/core/src/services/sql-validation.ts | 7 +- 7 files changed, 239 insertions(+), 27 deletions(-) create mode 100644 apps/api/src/middleware/csrf.test.ts create mode 100644 apps/api/src/middleware/csrf.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 282508b..0739960 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -4,6 +4,7 @@ import { logger } from "hono/logger"; import { getEnv } from "@archmax/core/config/env"; import { runHealthChecks } from "@archmax/core/infra/health"; import { corsMiddleware } from "./middleware/cors"; +import { csrfMiddleware } from "./middleware/csrf"; import { AppError } from "./utils/errors"; import { auth } from "./lib/auth"; @@ -65,6 +66,7 @@ const app = new Hono() .on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw)) .route("/mcp/:slug/mcp", archmaxMcp) .route("/mcp/:slug/test/mcp", archmaxMcp) + .use("/api/*", csrfMiddleware) .use("/api/*", async (c, next) => { const session = await auth.api.getSession({ headers: c.req.raw.headers }); if (!session) return c.json({ error: "Unauthorized" }, 401); diff --git a/apps/api/src/mcp/archmax-route.ts b/apps/api/src/mcp/archmax-route.ts index bb4097c..2037f91 100644 --- a/apps/api/src/mcp/archmax-route.ts +++ b/apps/api/src/mcp/archmax-route.ts @@ -23,9 +23,23 @@ setInterval(() => { } }, MCP_RATE_WINDOW_MS).unref(); -const UNAUTHORIZED = { error: "Invalid or missing authorization" } as const; +// JSON-RPC error codes (server-defined range -32000 to -32099). +const JSONRPC_UNAUTHORIZED = -32001; +const JSONRPC_SESSION_NOT_FOUND = -32002; + +function jsonRpcError(status: number, code: number, message: string): Response { + return new Response( + JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }), + { status, headers: { "Content-Type": "application/json" } }, + ); +} + +const UNAUTHORIZED_MSG = "Invalid or missing authorization"; -async function authenticateRequest(c: { req: { header: (name: string) => string | undefined; param: (name: string) => string | undefined } }, clientIp: string): Promise { +async function authenticateRequest( + c: { req: { header: (name: string) => string | undefined; param: (name: string) => string | undefined } }, + clientIp: string, +): Promise { const authHeader = c.req.header("Authorization"); const rawToken = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : null; if (!rawToken) return null; @@ -78,6 +92,8 @@ interface McpSession { transport: WebStandardStreamableHTTPServerTransport; createdAt: number; tokenId: string; + projectId: string; + slug: string; } const sessions = new Map(); @@ -109,26 +125,34 @@ app.all("/", async (c) => { if (sessionId) { const session = sessions.get(sessionId); if (!session) { - return new Response( - JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message: "Session not found" }, id: null }), - { status: 404, headers: { "Content-Type": "application/json" } }, - ); + return jsonRpcError(404, JSONRPC_SESSION_NOT_FOUND, "Session not found"); } - await connectDB(); - const token = await McpToken.findById(session.tokenId).lean(); - if (!token || (token.expiresAt && token.expiresAt < new Date())) { - sessions.delete(sessionId); - session.server.close().catch(() => {}); - return c.json(UNAUTHORIZED, 401); + // Bind every resumed request to the originating credential and slug: + // re-authenticate the bearer token, then verify it still resolves to the + // same token + project that opened this session and that the URL slug + // hasn't been swapped for a different project. + const authCtx = await authenticateRequest(c, clientIp); + if (!authCtx) { + return jsonRpcError(401, JSONRPC_UNAUTHORIZED, UNAUTHORIZED_MSG); + } + if ( + authCtx.tokenId !== session.tokenId || + authCtx.projectId !== session.projectId || + c.req.param("slug") !== session.slug + ) { + return jsonRpcError(401, JSONRPC_UNAUTHORIZED, UNAUTHORIZED_MSG); } return session.transport.handleRequest(c.req.raw); } const authCtx = await authenticateRequest(c, clientIp); - if (!authCtx) return c.json(UNAUTHORIZED, 401); + if (!authCtx) { + return jsonRpcError(401, JSONRPC_UNAUTHORIZED, UNAUTHORIZED_MSG); + } + const slug = c.req.param("slug")!; const isTestRoute = c.req.path.includes("/test/"); const projectsDir = getEnv().projectsDir; let fileSvc: SemanticModelFileService; @@ -154,10 +178,18 @@ app.all("/", async (c) => { const capturedTempDir = tempDir; const capturedTokenId = authCtx.tokenId ?? ""; + const capturedProjectId = authCtx.projectId; const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sid) => { - sessions.set(sid, { server: mcpServer, transport, createdAt: Date.now(), tokenId: capturedTokenId }); + sessions.set(sid, { + server: mcpServer, + transport, + createdAt: Date.now(), + tokenId: capturedTokenId, + projectId: capturedProjectId, + slug, + }); }, }); diff --git a/apps/api/src/middleware/csrf.test.ts b/apps/api/src/middleware/csrf.test.ts new file mode 100644 index 0000000..05492af --- /dev/null +++ b/apps/api/src/middleware/csrf.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { Hono } from "hono"; +import { csrfMiddleware } from "./csrf"; + +beforeAll(() => { + process.env.CORS_ORIGINS = "http://localhost:5173,https://app.example.com"; + process.env.MONGODB_URI = "mongodb://localhost:27017/test"; + process.env.BETTER_AUTH_SECRET = "test-secret-with-at-least-32-chars-long"; + process.env.UI_PASSWORD = "test-password"; +}); + +function buildApp() { + const app = new Hono(); + app.use("/api/*", csrfMiddleware); + app.post("/api/projects/x/mcp-tokens", (c) => c.json({ ok: true })); + app.delete("/api/projects/x/mcp-tokens/y", (c) => c.json({ ok: true })); + app.get("/api/projects/x/mcp-tokens", (c) => c.json({ ok: true })); + app.post("/api/auth/sign-in/email", (c) => c.json({ ok: true })); + return app; +} + +describe("csrfMiddleware", () => { + it("allows GET without Origin", async () => { + const app = buildApp(); + const res = await app.request("/api/projects/x/mcp-tokens"); + expect(res.status).toBe(200); + }); + + it("allows POST without Origin or Referer (server-to-server)", async () => { + const app = buildApp(); + const res = await app.request("/api/projects/x/mcp-tokens", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}", + }); + expect(res.status).toBe(200); + }); + + it("allows POST from trusted Origin", async () => { + const app = buildApp(); + const res = await app.request("/api/projects/x/mcp-tokens", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "http://localhost:5173", + }, + body: "{}", + }); + expect(res.status).toBe(200); + }); + + it("rejects POST from foreign Origin", async () => { + const app = buildApp(); + const res = await app.request("/api/projects/x/mcp-tokens", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "https://evil.example.com", + }, + body: "{}", + }); + expect(res.status).toBe(403); + }); + + it("rejects DELETE from foreign Origin", async () => { + const app = buildApp(); + const res = await app.request("/api/projects/x/mcp-tokens/y", { + method: "DELETE", + headers: { origin: "https://evil.example.com" }, + }); + expect(res.status).toBe(403); + }); + + it("falls back to Referer when Origin is absent", async () => { + const app = buildApp(); + + const ok = await app.request("/api/projects/x/mcp-tokens", { + method: "POST", + headers: { + "content-type": "application/json", + referer: "http://localhost:5173/some/page", + }, + body: "{}", + }); + expect(ok.status).toBe(200); + + const bad = await app.request("/api/projects/x/mcp-tokens", { + method: "POST", + headers: { + "content-type": "application/json", + referer: "https://evil.example.com/page", + }, + body: "{}", + }); + expect(bad.status).toBe(403); + }); + + it("skips /api/auth/* (Better Auth handles its own CSRF)", async () => { + const app = buildApp(); + const res = await app.request("/api/auth/sign-in/email", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "https://evil.example.com", + }, + body: "{}", + }); + expect(res.status).toBe(200); + }); + + it("rejects malformed Origin header", async () => { + const app = buildApp(); + const res = await app.request("/api/projects/x/mcp-tokens", { + method: "POST", + headers: { + "content-type": "application/json", + origin: "not a url", + }, + body: "{}", + }); + expect(res.status).toBe(403); + }); +}); diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts new file mode 100644 index 0000000..e7efd04 --- /dev/null +++ b/apps/api/src/middleware/csrf.ts @@ -0,0 +1,50 @@ +import type { Context, Next } from "hono"; +import { getEnv } from "@archmax/core/config/env"; + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +function originFromHeader(value: string): string | null { + try { + return new URL(value).origin; + } catch { + // Origin header may itself be a bare origin like "https://example.com" + // (no path). new URL() accepts that, so failures here mean garbage input. + return null; + } +} + +/** + * CSRF / origin enforcement for cookie-authenticated mutation routes. + * + * Real browsers always attach an `Origin` (or at minimum `Referer`) header on + * cross-origin POST/PUT/PATCH/DELETE requests with credentials, so requiring + * those headers to match `corsOrigins` blocks browser-driven CSRF without + * needing a separate token round-trip. Server-to-server callers (CI, MCP + * clients, tests using fetch from Node) typically omit both headers and are + * not a CSRF vector — they authenticate via bearer tokens, not cookies. + * + * `/api/auth/*` is exempted because Better Auth applies its own protections + * and runs before this middleware in app.ts. + */ +export async function csrfMiddleware(c: Context, next: Next) { + if (SAFE_METHODS.has(c.req.method)) return next(); + if (c.req.path.startsWith("/api/auth/")) return next(); + + const originHeader = c.req.header("origin"); + const refererHeader = c.req.header("referer"); + + if (!originHeader && !refererHeader) { + return next(); + } + + const trusted = new Set(getEnv().corsOrigins); + + const candidate = originHeader ?? refererHeader!; + const origin = originFromHeader(candidate); + + if (!origin || !trusted.has(origin)) { + return c.json({ error: "Forbidden: invalid request origin" }, 403); + } + + await next(); +} diff --git a/apps/api/src/services/agent.test.ts b/apps/api/src/services/agent.test.ts index c6f2303..76cbcfe 100644 --- a/apps/api/src/services/agent.test.ts +++ b/apps/api/src/services/agent.test.ts @@ -23,14 +23,6 @@ describe("validateReadOnlySQL", () => { expect(validateReadOnlySQL("DESCRIBE users")).toBeNull(); }); - it("allows SHOW", () => { - expect(validateReadOnlySQL("SHOW TABLES")).toBeNull(); - }); - - it("allows PRAGMA", () => { - expect(validateReadOnlySQL("PRAGMA version")).toBeNull(); - }); - it("is case insensitive", () => { expect(validateReadOnlySQL("select * from t")).toBeNull(); expect(validateReadOnlySQL("Select * FROM t")).toBeNull(); @@ -77,6 +69,14 @@ describe("validateReadOnlySQL", () => { it("blocks ATTACH", () => { expect(validateReadOnlySQL("ATTACH '/tmp/other.db' AS other")).not.toBeNull(); }); + + it("blocks SHOW (DuckDB metadata)", () => { + expect(validateReadOnlySQL("SHOW TABLES")).not.toBeNull(); + }); + + it("blocks PRAGMA (DuckDB metadata)", () => { + expect(validateReadOnlySQL("PRAGMA version")).not.toBeNull(); + }); }); describe("multi-statement prevention", () => { diff --git a/packages/core/src/services/sql-validation.test.ts b/packages/core/src/services/sql-validation.test.ts index a4aa6c3..3403d25 100644 --- a/packages/core/src/services/sql-validation.test.ts +++ b/packages/core/src/services/sql-validation.test.ts @@ -18,12 +18,12 @@ describe("validateReadOnlySQL", () => { expect(validateReadOnlySQL("DESCRIBE t")).toBeNull(); }); - it("allows SHOW queries", () => { - expect(validateReadOnlySQL("SHOW TABLES")).toBeNull(); + it("rejects SHOW queries", () => { + expect(validateReadOnlySQL("SHOW TABLES")).not.toBeNull(); }); - it("allows PRAGMA queries", () => { - expect(validateReadOnlySQL("PRAGMA table_info('t')")).toBeNull(); + it("rejects PRAGMA queries", () => { + expect(validateReadOnlySQL("PRAGMA table_info('t')")).not.toBeNull(); }); it("rejects INSERT", () => { diff --git a/packages/core/src/services/sql-validation.ts b/packages/core/src/services/sql-validation.ts index b23e0c3..f325e5e 100644 --- a/packages/core/src/services/sql-validation.ts +++ b/packages/core/src/services/sql-validation.ts @@ -1,4 +1,9 @@ -const ALLOWED_FIRST_KEYWORD = /^\s*(SELECT|WITH|EXPLAIN|DESCRIBE|SHOW|PRAGMA)\b/i; +// SHOW and PRAGMA are intentionally excluded: the public MCP `execute_query` +// contract is scoped to semantic-model views, and DuckDB metadata reads must +// not be exposed through this surface. Internal callers that need metadata +// (e.g. data-browser routes) execute those statements directly without going +// through this validator. +const ALLOWED_FIRST_KEYWORD = /^\s*(SELECT|WITH|EXPLAIN|DESCRIBE)\b/i; const SEMICOLON_FOLLOWED_BY_STATEMENT = /;\s*\S/; /** Strip leading single-line (`--`) and block SQL comments. */ From f403ca4498d2a6df1438a801bc13cd8eba85c6eb Mon Sep 17 00:00:00 2001 From: Tobias Grosse-Puppendahl Date: Wed, 29 Apr 2026 09:39:40 +0200 Subject: [PATCH 6/8] test(e2e): assert JSON-RPC error envelope on MCP auth failures The MCP route now returns `{jsonrpc:"2.0", error:{code,message}, id:null}` for missing/invalid bearer tokens (commit 555b04a). Update the api-auth and mcp e2e specs to match the new shape and assert the JSON-RPC unauthorized envelope (`code: -32001`). Made-with: Cursor --- apps/e2e/tests/api-auth.spec.ts | 23 ++++++++++++++--------- apps/e2e/tests/mcp.spec.ts | 14 ++++++++++---- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/apps/e2e/tests/api-auth.spec.ts b/apps/e2e/tests/api-auth.spec.ts index 1339c87..4b06a9b 100644 --- a/apps/e2e/tests/api-auth.spec.ts +++ b/apps/e2e/tests/api-auth.spec.ts @@ -86,15 +86,24 @@ test.describe("MCP Bearer token protection", () => { id: 1, }; + function expectJsonRpcUnauthorized(body: unknown) { + expect(body).toMatchObject({ + jsonrpc: "2.0", + id: null, + error: { + code: -32001, + message: "Invalid or missing authorization", + }, + }); + } + test("rejects request with no Authorization header", async ({ request }) => { const res = await request.post(MCP_ENDPOINT, { headers: { "Content-Type": "application/json" }, data: JSON_RPC_BODY, }); expect(res.status()).toBe(401); - - const body = await res.json(); - expect(body.error).toBe("Invalid or missing authorization"); + expectJsonRpcUnauthorized(await res.json()); }); test("rejects request with invalid Bearer token", async ({ request }) => { @@ -106,9 +115,7 @@ test.describe("MCP Bearer token protection", () => { data: JSON_RPC_BODY, }); expect(res.status()).toBe(401); - - const body = await res.json(); - expect(body.error).toBe("Invalid or missing authorization"); + expectJsonRpcUnauthorized(await res.json()); }); test("rejects request with non-Bearer auth scheme", async ({ request }) => { @@ -120,8 +127,6 @@ test.describe("MCP Bearer token protection", () => { data: JSON_RPC_BODY, }); expect(res.status()).toBe(401); - - const body = await res.json(); - expect(body.error).toBe("Invalid or missing authorization"); + expectJsonRpcUnauthorized(await res.json()); }); }); diff --git a/apps/e2e/tests/mcp.spec.ts b/apps/e2e/tests/mcp.spec.ts index 91de8f7..b837ad4 100644 --- a/apps/e2e/tests/mcp.spec.ts +++ b/apps/e2e/tests/mcp.spec.ts @@ -377,8 +377,11 @@ test.describe.serial("MCP Layer", () => { params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "e2e", version: "1.0" } }, }); expect(res.status()).toBe(401); - const body = await res.json(); - expect(body.error).toContain("Invalid or missing authorization"); + expect(await res.json()).toMatchObject({ + jsonrpc: "2.0", + id: null, + error: { code: -32001, message: "Invalid or missing authorization" }, + }); }); test("MCP request with invalid token returns 401", async ({ request }) => { @@ -387,8 +390,11 @@ test.describe.serial("MCP Layer", () => { params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "e2e", version: "1.0" } }, }, { Authorization: "Bearer invalid_garbage_token_value" }); expect(res.status()).toBe(401); - const body = await res.json(); - expect(body.error).toContain("Invalid or missing authorization"); + expect(await res.json()).toMatchObject({ + jsonrpc: "2.0", + id: null, + error: { code: -32001, message: "Invalid or missing authorization" }, + }); }); // ── Token creation via UI ────────────────────────────────────── From d07e6958d00e795b4bd8943ec72d4f303df3f397 Mon Sep 17 00:00:00 2001 From: Tobias Grosse-Puppendahl Date: Wed, 29 Apr 2026 09:49:59 +0200 Subject: [PATCH 7/8] fix(security): tighten CSRF middleware and SQL validator denylists Address review findings on top of 555b04a: CSRF middleware now rejects state-changing /api/* requests when both Origin and Referer are missing instead of falling through. Cookie- authenticated mutation routes were otherwise accepting any caller that suppressed those headers; non-browser API integrations should use the bearer-token MCP surface or set Origin explicitly. The E2E \`apiHeaders\` helper now sets Origin to BASE_URL since Playwright's APIRequestContext doesn't add it automatically. SQL validator hardening: - EXPLAIN ANALYZE in DuckDB executes the wrapped statement, so unrestricted EXPLAIN is not actually read-only. Only \`EXPLAIN SELECT\` and \`EXPLAIN WITH\` are now accepted; \`EXPLAIN ANALYZE\` and \`EXPLAIN \` are rejected. - validateScopedSQL now denies DuckDB metadata table functions (\`duckdb_tables()\`, \`duckdb_columns()\`, \`duckdb_secrets()\`, \`duckdb_settings()\`, ...), the pg_catalog/sqlite_master metadata catalogs, references to the main/temp/system schemas, and external file readers (\`read_csv\`, \`read_parquet\`, \`read_json\`, \`read_blob\`). \`enable_external_access=false\` already gates the readers in the default sandbox, but Iceberg-enabled projects keep external access on, so this denylist provides defence-in-depth. Tests cover EXPLAIN ANALYZE, EXPLAIN-of-DDL, the new denylist patterns, and confirm legitimate identifiers that contain forbidden substrings (\`order_main.id\`, \`duckdb_user\`) still pass. Note on positive allowlisting: validating SELECT references against the exact set of semantic-model views requires a SQL parser/AST and is left as a follow-up. The denylist closes the immediately reachable bypasses. Made-with: Cursor --- apps/api/src/middleware/csrf.test.ts | 14 +++- apps/api/src/middleware/csrf.ts | 25 +++--- apps/api/src/services/agent.test.ts | 12 +++ apps/e2e/tests/mcp.spec.ts | 49 +++++++---- .../core/src/services/sql-validation.test.ts | 83 +++++++++++++++++++ packages/core/src/services/sql-validation.ts | 52 +++++++++++- 6 files changed, 202 insertions(+), 33 deletions(-) diff --git a/apps/api/src/middleware/csrf.test.ts b/apps/api/src/middleware/csrf.test.ts index 05492af..9517d7a 100644 --- a/apps/api/src/middleware/csrf.test.ts +++ b/apps/api/src/middleware/csrf.test.ts @@ -26,14 +26,24 @@ describe("csrfMiddleware", () => { expect(res.status).toBe(200); }); - it("allows POST without Origin or Referer (server-to-server)", async () => { + it("rejects POST when both Origin and Referer are absent", async () => { const app = buildApp(); const res = await app.request("/api/projects/x/mcp-tokens", { method: "POST", headers: { "content-type": "application/json" }, body: "{}", }); - expect(res.status).toBe(200); + expect(res.status).toBe(403); + const body = (await res.json()) as { error: string }; + expect(body.error).toMatch(/missing Origin/i); + }); + + it("rejects DELETE when both Origin and Referer are absent", async () => { + const app = buildApp(); + const res = await app.request("/api/projects/x/mcp-tokens/y", { + method: "DELETE", + }); + expect(res.status).toBe(403); }); it("allows POST from trusted Origin", async () => { diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index e7efd04..97b195b 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -16,15 +16,18 @@ function originFromHeader(value: string): string | null { /** * CSRF / origin enforcement for cookie-authenticated mutation routes. * - * Real browsers always attach an `Origin` (or at minimum `Referer`) header on - * cross-origin POST/PUT/PATCH/DELETE requests with credentials, so requiring - * those headers to match `corsOrigins` blocks browser-driven CSRF without - * needing a separate token round-trip. Server-to-server callers (CI, MCP - * clients, tests using fetch from Node) typically omit both headers and are - * not a CSRF vector — they authenticate via bearer tokens, not cookies. + * Every state-changing `/api/*` request (POST/PUT/PATCH/DELETE) is required + * to carry an `Origin` (or `Referer`) header that resolves to one of + * `corsOrigins`. Real browsers always attach `Origin` on credentialed + * non-GET requests, so this stops browser-driven CSRF without needing a + * separate token round-trip. Missing both headers is *also* rejected: the + * routes below this middleware are session-cookie authenticated, so a + * caller that suppresses Origin/Referer would otherwise drive cookie + * mutations unprotected. Non-browser API clients should authenticate via + * the dedicated bearer-token MCP surface (`/mcp/:slug/mcp`) instead. * - * `/api/auth/*` is exempted because Better Auth applies its own protections - * and runs before this middleware in app.ts. + * `/api/auth/*` is exempted because Better Auth runs before this middleware + * in app.ts and applies its own CSRF protection. */ export async function csrfMiddleware(c: Context, next: Next) { if (SAFE_METHODS.has(c.req.method)) return next(); @@ -34,11 +37,13 @@ export async function csrfMiddleware(c: Context, next: Next) { const refererHeader = c.req.header("referer"); if (!originHeader && !refererHeader) { - return next(); + return c.json( + { error: "Forbidden: missing Origin/Referer header" }, + 403, + ); } const trusted = new Set(getEnv().corsOrigins); - const candidate = originHeader ?? refererHeader!; const origin = originFromHeader(candidate); diff --git a/apps/api/src/services/agent.test.ts b/apps/api/src/services/agent.test.ts index 76cbcfe..cec963f 100644 --- a/apps/api/src/services/agent.test.ts +++ b/apps/api/src/services/agent.test.ts @@ -77,6 +77,18 @@ describe("validateReadOnlySQL", () => { it("blocks PRAGMA (DuckDB metadata)", () => { expect(validateReadOnlySQL("PRAGMA version")).not.toBeNull(); }); + + it("blocks EXPLAIN ANALYZE (executes wrapped statement)", () => { + expect( + validateReadOnlySQL("EXPLAIN ANALYZE SELECT * FROM t"), + ).not.toBeNull(); + }); + + it("blocks EXPLAIN of write statements", () => { + expect( + validateReadOnlySQL("EXPLAIN INSERT INTO t VALUES (1)"), + ).not.toBeNull(); + }); }); describe("multi-statement prevention", () => { diff --git a/apps/e2e/tests/mcp.spec.ts b/apps/e2e/tests/mcp.spec.ts index b837ad4..03bc763 100644 --- a/apps/e2e/tests/mcp.spec.ts +++ b/apps/e2e/tests/mcp.spec.ts @@ -127,7 +127,14 @@ async function createConnection( } function apiHeaders(cookie: string) { - return { Cookie: cookie, "Content-Type": "application/json" }; + return { + Cookie: cookie, + "Content-Type": "application/json", + // CSRF middleware requires Origin to match corsOrigins for /api/* + // mutations. Playwright's APIRequestContext does not set Origin + // automatically the way a browser would. + Origin: BASE_URL, + }; } async function getSessionCookie(context: BrowserContext): Promise { @@ -282,11 +289,16 @@ async function mcpInitialize( /** * Call an MCP tool within an existing session. Returns the parsed JSON-RPC response. + * + * Every resumed MCP request must re-authenticate with the same Bearer token + * that originally opened the session — see `authenticateRequest` in + * `apps/api/src/mcp/archmax-route.ts`. Sending only `mcp-session-id` returns 401. */ async function mcpToolCall( request: APIRequestContext, slug: string, sessionId: string, + token: string, toolName: string, args: Record = {}, ): Promise> { @@ -295,7 +307,10 @@ async function mcpToolCall( method: "tools/call", id: Date.now(), params: { name: toolName, arguments: args }, - }, { "mcp-session-id": sessionId }); + }, { + Authorization: `Bearer ${token}`, + "mcp-session-id": sessionId, + }); expect(res.status()).toBe(200); return parseMcpResponse(res); @@ -465,13 +480,13 @@ test.describe.serial("MCP Layer", () => { // ── MCP tool tests ───────────────────────────────────────────── test("list_semantic_models returns e2e_federation", async ({ request }) => { - const body = await mcpToolCall(request, projectSlug, mcpSessionId, "list_semantic_models"); + const body = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "list_semantic_models"); const text: string = (body as any).result?.content?.[0]?.text ?? ""; expect(text).toContain(MODEL_NAME); }); test("get_semantic_model returns model overview", async ({ request }) => { - const body = await mcpToolCall(request, projectSlug, mcpSessionId, "get_semantic_model", { + const body = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "get_semantic_model", { modelName: MODEL_NAME, }); const text: string = (body as any).result?.content?.[0]?.text ?? ""; @@ -482,7 +497,7 @@ test.describe.serial("MCP Layer", () => { }); test("get_datasets returns field details", async ({ request }) => { - const body = await mcpToolCall(request, projectSlug, mcpSessionId, "get_datasets", { + const body = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "get_datasets", { modelName: MODEL_NAME, datasets: [ { name: "products", page: 1 }, @@ -499,7 +514,7 @@ test.describe.serial("MCP Layer", () => { }); test("execute_query returns data from Postgres", async ({ request }) => { - const body = await mcpToolCall(request, projectSlug, mcpSessionId, "execute_query", { + const body = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "execute_query", { modelName: MODEL_NAME, sql: `SELECT * FROM "products" ORDER BY id LIMIT 10`, }); @@ -509,7 +524,7 @@ test.describe.serial("MCP Layer", () => { }); test("execute_query cross-database join (Postgres + MySQL)", async ({ request }) => { - const body = await mcpToolCall(request, projectSlug, mcpSessionId, "execute_query", { + const body = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "execute_query", { modelName: MODEL_NAME, sql: [ `SELECT p.name AS product, o.quantity`, @@ -524,7 +539,7 @@ test.describe.serial("MCP Layer", () => { }); test("execute_query returns data from Iceberg", async ({ request }) => { - const body = await mcpToolCall(request, projectSlug, mcpSessionId, "execute_query", { + const body = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "execute_query", { modelName: MODEL_NAME, sql: `SELECT * FROM "shipments" ORDER BY id LIMIT 10`, }); @@ -535,7 +550,7 @@ test.describe.serial("MCP Layer", () => { }); test("execute_query cross-catalog join (Postgres + Iceberg)", async ({ request }) => { - const body = await mcpToolCall(request, projectSlug, mcpSessionId, "execute_query", { + const body = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "execute_query", { modelName: MODEL_NAME, sql: [ `SELECT p.name AS product, s.destination`, @@ -551,7 +566,7 @@ test.describe.serial("MCP Layer", () => { }); test("execute_query returns storedQueryId when store is true", async ({ request }) => { - const body = await mcpToolCall(request, projectSlug, mcpSessionId, "execute_query", { + const body = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "execute_query", { modelName: MODEL_NAME, sql: `SELECT * FROM "products" WHERE name = $1 ORDER BY id LIMIT 5`, params: ["Widget A"], @@ -565,7 +580,7 @@ test.describe.serial("MCP Layer", () => { }); test("execute_query omits storedQueryId when store is false", async ({ request }) => { - const body = await mcpToolCall(request, projectSlug, mcpSessionId, "execute_query", { + const body = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "execute_query", { modelName: MODEL_NAME, sql: `SELECT * FROM "products" ORDER BY id LIMIT 1`, store: false, @@ -577,7 +592,7 @@ test.describe.serial("MCP Layer", () => { }); test("execute_stored_query re-runs a stored query", async ({ request }) => { - const storeBody = await mcpToolCall(request, projectSlug, mcpSessionId, "execute_query", { + const storeBody = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "execute_query", { modelName: MODEL_NAME, sql: `SELECT * FROM "products" WHERE name = $1 ORDER BY id LIMIT 5`, params: ["Widget A"], @@ -587,7 +602,7 @@ test.describe.serial("MCP Layer", () => { const { storedQueryId } = JSON.parse(storeText); expect(storedQueryId).toBeTruthy(); - const rerunBody = await mcpToolCall(request, projectSlug, mcpSessionId, "execute_stored_query", { + const rerunBody = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "execute_stored_query", { storedQueryId, }); expect((rerunBody as any).result?.isError).toBeFalsy(); @@ -596,7 +611,7 @@ test.describe.serial("MCP Layer", () => { }); test("execute_stored_query with overridden params", async ({ request }) => { - const storeBody = await mcpToolCall(request, projectSlug, mcpSessionId, "execute_query", { + const storeBody = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "execute_query", { modelName: MODEL_NAME, sql: `SELECT name FROM "products" WHERE name = $1 LIMIT 5`, params: ["Widget A"], @@ -605,7 +620,7 @@ test.describe.serial("MCP Layer", () => { const storeText: string = (storeBody as any).result?.content?.[0]?.text ?? ""; const { storedQueryId } = JSON.parse(storeText); - const rerunBody = await mcpToolCall(request, projectSlug, mcpSessionId, "execute_stored_query", { + const rerunBody = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "execute_stored_query", { storedQueryId, params: ["Widget B"], }); @@ -616,7 +631,7 @@ test.describe.serial("MCP Layer", () => { }); test("execute_stored_query with invalid ID returns error", async ({ request }) => { - const body = await mcpToolCall(request, projectSlug, mcpSessionId, "execute_stored_query", { + const body = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "execute_stored_query", { storedQueryId: "000000000000000000000000", }); const result = (body as any).result; @@ -626,7 +641,7 @@ test.describe.serial("MCP Layer", () => { }); test("request_improvement succeeds", async ({ request }) => { - const body = await mcpToolCall(request, projectSlug, mcpSessionId, "request_improvement", { + const body = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "request_improvement", { modelName: MODEL_NAME, title: "E2E test improvement", description: "This is an automated E2E test improvement request.", diff --git a/packages/core/src/services/sql-validation.test.ts b/packages/core/src/services/sql-validation.test.ts index 3403d25..94238b6 100644 --- a/packages/core/src/services/sql-validation.test.ts +++ b/packages/core/src/services/sql-validation.test.ts @@ -26,6 +26,33 @@ describe("validateReadOnlySQL", () => { expect(validateReadOnlySQL("PRAGMA table_info('t')")).not.toBeNull(); }); + it("rejects EXPLAIN ANALYZE (which executes the wrapped statement)", () => { + const r = validateReadOnlySQL("EXPLAIN ANALYZE SELECT * FROM t"); + expect(r).toMatch(/EXPLAIN ANALYZE/i); + }); + + it("rejects EXPLAIN ANALYZE wrapping a write", () => { + expect( + validateReadOnlySQL("EXPLAIN ANALYZE CREATE TABLE leak AS SELECT * FROM orders"), + ).not.toBeNull(); + }); + + it("rejects EXPLAIN wrapping non-read statements", () => { + expect(validateReadOnlySQL("EXPLAIN INSERT INTO t VALUES (1)")).not.toBeNull(); + expect(validateReadOnlySQL("EXPLAIN DROP TABLE t")).not.toBeNull(); + expect(validateReadOnlySQL("EXPLAIN UPDATE t SET x = 1")).not.toBeNull(); + }); + + it("allows EXPLAIN wrapping SELECT", () => { + expect(validateReadOnlySQL("EXPLAIN SELECT * FROM t")).toBeNull(); + }); + + it("allows EXPLAIN wrapping WITH (CTE)", () => { + expect( + validateReadOnlySQL("EXPLAIN WITH cte AS (SELECT 1) SELECT * FROM cte"), + ).toBeNull(); + }); + it("rejects INSERT", () => { expect(validateReadOnlySQL("INSERT INTO t VALUES (1)")).not.toBeNull(); }); @@ -150,6 +177,62 @@ describe("validateScopedSQL", () => { expect(result).toContain("information_schema"); }); + it("rejects DuckDB metadata table functions", () => { + expect(validateScopedSQL("SELECT * FROM duckdb_tables()", catalogs)).toMatch( + /duckdb/i, + ); + expect( + validateScopedSQL("SELECT * FROM duckdb_columns()", catalogs), + ).not.toBeNull(); + expect( + validateScopedSQL("SELECT * FROM duckdb_secrets()", catalogs), + ).not.toBeNull(); + expect( + validateScopedSQL("SELECT * FROM duckdb_settings()", catalogs), + ).not.toBeNull(); + }); + + it("rejects pg_catalog and sqlite_master", () => { + expect( + validateScopedSQL("SELECT * FROM pg_catalog.pg_class", catalogs), + ).not.toBeNull(); + expect( + validateScopedSQL("SELECT * FROM sqlite_master", catalogs), + ).not.toBeNull(); + }); + + it("rejects main./temp./system. schema references", () => { + expect(validateScopedSQL("SELECT * FROM main.foo", catalogs)).not.toBeNull(); + expect(validateScopedSQL("SELECT * FROM temp.foo", catalogs)).not.toBeNull(); + expect(validateScopedSQL("SELECT * FROM system.foo", catalogs)).not.toBeNull(); + }); + + it("rejects external file readers (read_*)", () => { + expect( + validateScopedSQL("SELECT * FROM read_csv('/etc/passwd')", catalogs), + ).not.toBeNull(); + expect( + validateScopedSQL("SELECT * FROM read_parquet('s3://bucket/x')", catalogs), + ).not.toBeNull(); + expect( + validateScopedSQL("SELECT * FROM read_json('/x.json')", catalogs), + ).not.toBeNull(); + expect( + validateScopedSQL("SELECT * FROM read_blob('/x.bin')", catalogs), + ).not.toBeNull(); + }); + + it("does not block legitimate identifiers that contain forbidden substrings", () => { + // "main" / "duckdb" appearing inside compound identifiers must not be + // confused with system schemas / metadata functions. + expect( + validateScopedSQL("SELECT order_main.id FROM order_main", catalogs), + ).toBeNull(); + expect( + validateScopedSQL("SELECT duckdb_user.name FROM duckdb_user", catalogs), + ).toBeNull(); + }); + it("rejects _scope_ prefix usage", () => { const result = validateScopedSQL( 'SELECT * FROM _scope_ecommerce."orders"', diff --git a/packages/core/src/services/sql-validation.ts b/packages/core/src/services/sql-validation.ts index f325e5e..0350a6b 100644 --- a/packages/core/src/services/sql-validation.ts +++ b/packages/core/src/services/sql-validation.ts @@ -6,6 +6,29 @@ const ALLOWED_FIRST_KEYWORD = /^\s*(SELECT|WITH|EXPLAIN|DESCRIBE)\b/i; const SEMICOLON_FOLLOWED_BY_STATEMENT = /;\s*\S/; +// EXPLAIN ANALYZE in DuckDB *executes* the wrapped statement, so unrestricted +// EXPLAIN is not actually read-only — `EXPLAIN ANALYZE INSERT/CREATE/...` +// would mutate. Only allow `EXPLAIN SELECT ...` and `EXPLAIN WITH ...`. +const EXPLAIN_PREFIX = /^\s*EXPLAIN\b/i; +const EXPLAIN_ANALYZE_PREFIX = /^\s*EXPLAIN\s+ANALYZE\b/i; +const EXPLAIN_FOLLOWED_BY_READ = /^\s*EXPLAIN\s+(SELECT|WITH)\b/i; + +// DuckDB metadata table functions and system schemas/catalogs that bypass the +// scoped semantic-model views. These are reachable from a plain SELECT and so +// are not caught by the first-keyword allowlist; deny them explicitly. The +// dynamic SQL-functions matcher catches `duckdb_tables()`, `duckdb_columns()`, +// `duckdb_secrets()`, `duckdb_settings()`, etc. — anything in that family. +const FORBIDDEN_PATTERNS: Array<{ re: RegExp; label: string }> = [ + { re: /\bduckdb_[a-z_]+\s*\(/i, label: "DuckDB metadata functions (duckdb_*)" }, + { re: /\bpg_catalog\b/i, label: "pg_catalog" }, + { re: /\bsqlite_master\b/i, label: "sqlite_master" }, + { re: /\b(main|temp|system)\.[a-z_]/i, label: "system schemas (main/temp/system)" }, + // External readers. `enable_external_access=false` blocks these in the + // default DuckDB sandbox, but Iceberg-enabled projects keep external + // access on, so we deny at the validator layer for defence-in-depth. + { re: /\bread_(csv|csv_auto|parquet|parquet_auto|json|json_auto|ndjson|blob|text)\s*\(/i, label: "external file readers (read_*)" }, +]; + /** Strip leading single-line (`--`) and block SQL comments. */ function stripLeadingComments(sql: string): string { return sql.replace(/^(\s*(--[^\n]*\n|\/\*[\s\S]*?\*\/))*\s*/, ""); @@ -15,17 +38,32 @@ export function validateReadOnlySQL(sql: string): string | null { if (SEMICOLON_FOLLOWED_BY_STATEMENT.test(sql)) { return "Multiple statements are not allowed."; } - if (!ALLOWED_FIRST_KEYWORD.test(stripLeadingComments(sql))) { + const stripped = stripLeadingComments(sql); + if (!ALLOWED_FIRST_KEYWORD.test(stripped)) { return "Only SELECT / WITH / EXPLAIN / DESCRIBE queries are allowed."; } + + if (EXPLAIN_PREFIX.test(stripped)) { + if (EXPLAIN_ANALYZE_PREFIX.test(stripped)) { + return "EXPLAIN ANALYZE is not allowed (it executes the wrapped statement)."; + } + if (!EXPLAIN_FOLLOWED_BY_READ.test(stripped)) { + return "EXPLAIN is only allowed wrapping SELECT or WITH queries."; + } + } + return null; } /** * Validates SQL for scoped MCP queries. Checks: - * 1. Read-only (SELECT/WITH/EXPLAIN/DESCRIBE only, no multi-statement) - * 2. No direct catalog references — only bare dataset names allowed (resolved via search_path) - * 3. No information_schema access + * 1. Read-only (SELECT/WITH/EXPLAIN/DESCRIBE only, no multi-statement, + * no EXPLAIN ANALYZE) + * 2. No direct catalog references — only bare dataset names allowed + * (resolved via search_path) + * 3. No DuckDB metadata namespaces or table functions (information_schema, + * duckdb_*, pg_catalog, sqlite_master, main./temp./system. schemas, + * read_csv/parquet/json/blob) * 4. No explicit _scope_ schema references — search_path handles resolution */ export function validateScopedSQL(sql: string, catalogSlugs: string[]): string | null { @@ -47,6 +85,12 @@ export function validateScopedSQL(sql: string, catalogSlugs: string[]): string | return "Querying information_schema is not allowed. Use dataset names directly."; } + for (const { re, label } of FORBIDDEN_PATTERNS) { + if (re.test(sql)) { + return `Reference to ${label} is not allowed. Use semantic-model dataset names directly.`; + } + } + if (/\b_scope_\w+\./i.test(sql)) { return "Do not use _scope_ prefixes. Use dataset names directly (e.g. FROM orders) — they resolve automatically via search_path."; } From 5cfbfded200df8086e53750513b16e414ab276e2 Mon Sep 17 00:00:00 2001 From: Tobias Grosse-Puppendahl Date: Wed, 29 Apr 2026 09:51:14 +0200 Subject: [PATCH 8/8] test(e2e): pass Bearer token on remaining MCP scope-enforcement tool calls Three call sites in the scope-enforcement tests were still invoking mcpToolCall without the new required token argument, causing 401s after the MCP route began re-authenticating every resumed request. Made-with: Cursor --- apps/e2e/tests/mcp.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/e2e/tests/mcp.spec.ts b/apps/e2e/tests/mcp.spec.ts index 03bc763..fece953 100644 --- a/apps/e2e/tests/mcp.spec.ts +++ b/apps/e2e/tests/mcp.spec.ts @@ -653,7 +653,7 @@ test.describe.serial("MCP Layer", () => { // ── Scope enforcement ────────────────────────────────────────── test("scope enforcement: out-of-scope model access denied", async ({ request }) => { - const body = await mcpToolCall(request, projectSlug, mcpSessionId, "get_semantic_model", { + const body = await mcpToolCall(request, projectSlug, mcpSessionId, mcpToken, "get_semantic_model", { modelName: "nonexistent_model_xyz", }); const result = (body as any).result; @@ -698,12 +698,12 @@ test.describe.serial("MCP Layer", () => { const { sessionId: narrowSession } = await mcpInitialize(request, projectSlug, narrowToken); - const listBody = await mcpToolCall(request, projectSlug, narrowSession, "list_semantic_models"); + const listBody = await mcpToolCall(request, projectSlug, narrowSession, narrowToken, "list_semantic_models"); const listText: string = (listBody as any).result?.content?.[0]?.text ?? ""; expect(listText).not.toContain(MODEL_NAME); expect(listText).toContain("e2e_scope_test"); - const getBody = await mcpToolCall(request, projectSlug, narrowSession, "get_semantic_model", { + const getBody = await mcpToolCall(request, projectSlug, narrowSession, narrowToken, "get_semantic_model", { modelName: MODEL_NAME, }); const getResult = (getBody as any).result;