fix(mcp): make the metric and lab deep links land on their pages

MCP `search` and `fetch` link a metric as `/insights?metric=<id>` and a lab
value as `/labs?analyte=<name>`. Neither page read its query, so every link
opened the overview or the full list.

`/insights` now resolves the id server-side, in whichever spelling the
result carries (a Coach source key such as `hrv` or `bp`, a signal or
metric-status key such as `GRIP_STRENGTH`, a measurement type, or a
sub-page slug), and redirects to the metric's page, or to its value list
when no page focuses it. An id it cannot place renders the overview as
before.

`/labs` asks for the newest reading stored under the exact analyte name
and opens its marker's page; a name with no reading keeps the list and
drops the query.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 09:40:12 +02:00
1 parent b8c7eda89c
commit 196c331068
8 files changed
+298 -3

No files matched your search

@@ -0,0 +1,44 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
/**
* MCP deep links (`/insights?metric=<id>`) used to land on the overview,
* whatever the id. The RSC now redirects a placeable id to the metric's page
* and renders the overview for anything else.
*/
const redirect = vi.hoisted(() =>
vi.fn((href: string) => {
throw new Error(`REDIRECT ${href}`);
}),
);
vi.mock("next/navigation", () => ({ redirect }));
vi.mock("@/lib/auth/acting-carrier", () => ({
getUnswitchedSession: vi.fn().mockResolvedValue(null),
}));
vi.mock("@/lib/dashboard/snapshot-read", () => ({
readDashboardSnapshotCached: vi.fn(),
}));
vi.mock("../page-client", () => ({ default: () => null }));
import InsightsPage from "../page";
beforeEach(() => {
redirect.mockClear();
});
describe("/insights?metric=", () => {
it("redirects a metric id to its page", async () => {
await expect(
InsightsPage({ searchParams: Promise.resolve({ metric: "hrv" }) }),
).rejects.toThrow("REDIRECT /insights/hrv");
});
it("renders the overview for an id it cannot place, or none", async () => {
await expect(
InsightsPage({ searchParams: Promise.resolve({ metric: "nonsense" }) }),
).resolves.toBeTruthy();
await expect(
InsightsPage({ searchParams: Promise.resolve({}) }),
).resolves.toBeTruthy();
expect(redirect).not.toHaveBeenCalled();
});
});
+16 -1
View File
@@ -4,7 +4,10 @@ import {
QueryClient,
} from "@tanstack/react-query";
import { redirect } from "next/navigation";
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link";
import { readDashboardSnapshotCached } from "@/lib/dashboard/snapshot-read";
import { queryKeys } from "@/lib/query-keys";
@@ -51,7 +54,19 @@ import InsightsPageClient from "./page-client";
* - Fail-soft: no session or a builder hiccup renders the page exactly as
* before this wrapper existed — the client cells own the fetch.
*/
export default async function InsightsPage() {
export default async function InsightsPage({
searchParams,
}: {
searchParams?: Promise<Record<string, string | string[] | undefined>>;
} = {}) {
// MCP deep links (`/insights?metric=<id>`) go to the metric's own page. An
// id that names no metric leaves the overview as it was.
const { metric } = (await searchParams) ?? {};
const target = insightsHrefForMetric(
typeof metric === "string" ? metric : null,
);
if (target) redirect(target);
// Global SSR-prefetch kill-switch shared with the dashboard wrapper. The e2e
// server sets `DASHBOARD_SSR_PREFETCH=false` so Playwright route mocks — which
// only see CLIENT fetches — keep governing what every prefetched page paints.
+28 -2
View File
@@ -2,8 +2,8 @@
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { useRouter, useSearchParams } from "next/navigation";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowRight,
FolderOpen,
@@ -38,6 +38,12 @@ import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { usePullToRefresh } from "@/hooks/use-pull-to-refresh";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiGet } from "@/lib/api/api-fetch";
import type { LabResultListResponse } from "@/components/labs/types";
import {
analyteFromQuery,
labHrefForAnalyteReading,
} from "@/lib/labs/analyte-deep-link";
export default function LabsPage() {
const { user, isAuthenticated, isLoading } = useAuth();
@@ -89,6 +95,26 @@ export default function LabsPage() {
disabled: dialogOpen,
});
// MCP deep links (`/labs?analyte=<name>`) open the marker's own page. The
// newest reading under that exact name says which marker it is; a name
// with no reading keeps the list and drops the query.
const searchParams = useSearchParams();
const linkedAnalyte = analyteFromQuery(searchParams.get("analyte"));
const analyteLink = useQuery({
queryKey: queryKeys.labAnalyteLink(linkedAnalyte ?? ""),
queryFn: () =>
apiGet<LabResultListResponse>(
`/api/labs?analyte=${encodeURIComponent(linkedAnalyte ?? "")}&limit=1&sortDir=desc`,
),
enabled: !!linkedAnalyte && isAuthenticated && enabled,
});
useEffect(() => {
if (!linkedAnalyte || !analyteLink.isFetched) return;
router.replace(
labHrefForAnalyteReading(analyteLink.data?.results?.[0]) ?? "/labs",
);
}, [linkedAnalyte, analyteLink.isFetched, analyteLink.data, router]);
useEffect(() => {
if (isLoading) return;
if (!isAuthenticated) {
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import { insightsHrefForMetric } from "@/lib/insights/metric-deep-link";
import { SUB_PAGE_SLUGS } from "@/lib/insights/sub-page-metric";
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
import { existsSync } from "node:fs";
import { join } from "node:path";
/**
* The MCP `search` / `fetch` results link a metric as `/insights?metric=<id>`
* in several spellings. Every one must land on the metric's own page; an id
* that names nothing leaves the overview alone.
*/
describe("insightsHrefForMetric", () => {
it("routes the Coach source keys the data inventory links carry", () => {
expect(insightsHrefForMetric("hrv")).toBe("/insights/hrv");
expect(insightsHrefForMetric("resting_hr")).toBe("/insights/resting-pulse");
expect(insightsHrefForMetric("bp")).toBe("/insights/blood-pressure");
expect(insightsHrefForMetric("weight")).toBe("/insights/weight");
expect(insightsHrefForMetric("glucose")).toBe("/insights/blood-glucose");
expect(insightsHrefForMetric("mood")).toBe("/insights/mood");
expect(insightsHrefForMetric("workouts")).toBe("/insights/workouts");
});
it("routes signal keys and measurement types", () => {
expect(insightsHrefForMetric("GRIP_STRENGTH")).toBe(
"/insights/grip-strength",
);
expect(insightsHrefForMetric("CARDIO_RECOVERY")).toBe(
"/insights/cardio-recovery",
);
expect(insightsHrefForMetric("HRV_RMSSD")).toBe("/insights/hrv");
});
it("falls back to the value list for a type no page focuses", () => {
expect(insightsHrefForMetric("DAY_STRAIN")).toBe(
"/insights/values/DAY_STRAIN",
);
});
it("routes a sub-page slug in either separator", () => {
expect(insightsHrefForMetric("walking-speed")).toBe(
"/insights/walking-speed",
);
expect(insightsHrefForMetric("walking_speed")).toBe(
"/insights/walking-speed",
);
});
it("leaves the overview for an id it cannot place", () => {
for (const id of [null, "", " ", "nonsense", "../admin", "a".repeat(80)]) {
expect(insightsHrefForMetric(id)).toBeNull();
}
});
it("only ever points at a page that exists", () => {
const ids = [
...SUB_PAGE_SLUGS,
...Object.keys(COACH_SOURCE_MEASUREMENT_TYPES),
];
for (const id of ids) {
const href = insightsHrefForMetric(id);
if (!href || href.startsWith("/insights/values/")) continue;
const dir = join(process.cwd(), "src/app", href);
expect(existsSync(join(dir, "page.tsx")), `${id} → ${href}`).toBe(true);
}
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Where `/insights?metric=<id>` goes (MCP deep links).
*
* The MCP `search` and `fetch` results link a metric as
* `/insights?metric=<id>`, with the id in whichever spelling that result
* carries: a Coach source key (`hrv`, `resting_hr`, `bp`), a signal or
* metric-status key (`GRIP_STRENGTH`, `CARDIO_RECOVERY`), or a sub-page slug.
* The overview read none of them, so every such link landed on the overview.
* This resolves the id to the metric's own page, or to its value list when no
* page focuses it, and to nothing for an id it cannot place (the overview
* then renders as before). Pure: no I/O.
*/
import { COACH_SOURCE_MEASUREMENT_TYPES } from "@/lib/ai/coach/source-measurement-types";
import type { CoachScopeSource } from "@/lib/ai/coach/types";
import {
SUB_PAGE_SLUGS,
subPageSlugForType,
type SubPageSlug,
} from "@/lib/insights/sub-page-metric";
import { SIGNALS } from "@/lib/signals/registry";
import { measurementTypeEnum } from "@/lib/validations/measurement";
/** Coach sources that read a model other than `Measurement`. */
const NON_MEASUREMENT_SOURCES: Readonly<Record<string, string>> = {
mood: "/insights/mood",
workouts: "/insights/workouts",
compliance: "/insights/medications",
};
const SLUGS: ReadonlySet<string> = new Set(SUB_PAGE_SLUGS);
function hrefForType(type: string): string {
const slug = subPageSlugForType(type);
return slug
? `/insights/${slug}`
: `/insights/values/${encodeURIComponent(type)}`;
}
export function insightsHrefForMetric(
raw: string | null | undefined,
): string | null {
const id = raw?.trim() ?? "";
if (id === "" || id.length > 64 || !/^[A-Za-z0-9_-]+$/.test(id)) {
return null;
}
const lower = id.toLowerCase();
// A sub-page slug, in either separator.
const slug = lower.replace(/_/g, "-");
if (SLUGS.has(slug)) return `/insights/${slug as SubPageSlug}`;
// A Coach source key, the spelling the data inventory links carry.
if (lower in NON_MEASUREMENT_SOURCES) return NON_MEASUREMENT_SOURCES[lower]!;
const sourceTypes =
COACH_SOURCE_MEASUREMENT_TYPES[lower as CoachScopeSource] ?? [];
if (sourceTypes.length > 0) return hrefForType(sourceTypes[0]!);
// A signal key, then a measurement type itself.
const upper = id.toUpperCase();
const signal = SIGNALS[upper];
if (signal && (signal.kind === "measurement" || signal.kind === "score")) {
return hrefForType(signal.source.measurementType);
}
if (measurementTypeEnum.safeParse(upper).success) return hrefForType(upper);
return null;
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
analyteFromQuery,
labHrefForAnalyteReading,
} from "@/lib/labs/analyte-deep-link";
import { queryKeys } from "@/lib/query-keys";
/**
* `/labs?analyte=<name>` (MCP deep links) opens the marker page of the newest
* reading stored under that name, and keeps the list otherwise.
*/
describe("labs analyte deep link", () => {
it("follows a named analyte and ignores an empty or oversized one", () => {
expect(analyteFromQuery(" Ferritin ")).toBe("Ferritin");
expect(analyteFromQuery(null)).toBeNull();
expect(analyteFromQuery(" ")).toBeNull();
expect(analyteFromQuery("x".repeat(121))).toBeNull();
});
it("opens the marker page of the reading, and nothing without one", () => {
expect(labHrefForAnalyteReading({ biomarkerId: "bm_1" })).toBe(
"/labs/bm_1",
);
expect(labHrefForAnalyteReading({ biomarkerId: null })).toBeNull();
expect(labHrefForAnalyteReading(undefined)).toBeNull();
});
it("keeps its read under the lab-results prefix, apart from the list", () => {
const key = queryKeys.labAnalyteLink("Ferritin");
expect(key[0]).toBe("lab-results");
expect(key).not.toEqual(
queryKeys.labResultsList({
analyte: "Ferritin",
panel: undefined,
from: undefined,
to: undefined,
page: 0,
sortDir: "desc",
}),
);
});
});
+26
View File
@@ -0,0 +1,26 @@
/**
* Where `/labs?analyte=<name>` goes (MCP deep links).
*
* MCP `search` and `fetch` link a lab value as `/labs?analyte=<name>`, the
* analyte name exactly as stored on the reading. The labs page read nothing
* from the query, so every such link showed the whole list. The page now asks
* for the newest reading under that name and opens its marker's page; a name
* with no reading, or one whose reading carries no marker, keeps the list.
*/
/** The analyte named in the query, or null when there is none to follow. */
export function analyteFromQuery(
raw: string | null | undefined,
): string | null {
const analyte = raw?.trim() ?? "";
return analyte.length > 0 && analyte.length <= 120 ? analyte : null;
}
/** The marker page for the newest reading under that name, or null. */
export function labHrefForAnalyteReading(
reading: { biomarkerId: string | null } | undefined,
): string | null {
return reading?.biomarkerId
? `/labs/${encodeURIComponent(reading.biomarkerId)}`
: null;
}
+7
View File
@@ -29,6 +29,13 @@ export const labKeys = {
params.sortDir,
] as const,
// `/labs?analyte=<name>` (MCP deep links): the newest reading stored under
// that exact analyte name, one row, to find its marker's page. Its own key
// because the page size differs from every list read; the `lab-results`
// prefix lets a result mutation evict it too.
labAnalyteLink: (analyte: string) =>
["lab-results", "analyte-link", analyte] as const,
// The visit form's lab picker: newest results, its own page size. Its own
// key because the Labs list reads a different page size, and a shared key
// would serve one read's page to the other.