Files
insights/services/mcp/tests/unit/instructions-formatter-snapshot.test.ts
zeekay 19fdf55ceb feat(auth): one key scheme, minted and read in one place
A key now opens with what it is allowed to touch. `pk-` is publishable and
safe in a browser; `sk-` is secret and never leaves a server. The five marks
it replaces all began `ph` -- the upstream's initials -- and they were the
last of its brand a customer read back to us, in every SDK snippet. No word
rule could reach them, because `ph` is not a word.

The marks were also not telling the truth. The constant said `phc_` while
every live project token said `hi_`, so the middleware that routes on the
constant matched no real token at all. Two spellings existed and one was
fiction. Minting now happens in exactly one function and reading in exactly
one, so a second spelling has nowhere to live.

`phx_` (personal) and `phs_` (project secret) both become `sk-`. The mark
now says only that a value is secret; which store holds it is settled by
asking. That was already how personal keys worked -- keys minted before the
marks carry none, so the store always decided -- and both secret resolvers
already returned None on a miss and fell through, so one shared extractor
replaces the two that could drift apart.

OAuth's access and refresh tokens stay distinguishable as `at-` and `rt-`,
because the parser tells them apart before either is looked up, and the
domain-verification token becomes `vt-` rather than keeping a sixth mint of
its own.

`bin/debrand --check` now refuses the marks as a shape, so a seventh cannot
be coined. Two rules that were about to rename things that are not ours to
spell are bounded: `Lemon` no longer eats `LemonSqueezy`, and the `hog_flow`
rules no longer reach a Postgres table, a Datastore table or a route.

Keys are stored hashed, so a mark cannot be rewritten in place: the three
live project tokens have to be reminted.
2026-08-08 19:42:44 -07:00

205 lines
11 KiB
TypeScript

import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { InsightsMCP } from '@hanzo/mcp-analytics'
import type { GroupType } from '@/api/client'
import { InstructionsBuilder } from '@/hono/instructions'
import type { ResolvedState } from '@/hono/request-state-resolver'
import { MCPClientProfile } from '@/lib/client-detection'
import { PRODUCT_DATA_CATALOG_FLAG } from '@/lib/constants'
import { buildActiveEnvironmentContextPrompt, type QueryToolInfo } from '@/lib/instructions'
import { InstructionsFormatter, type InstructionsContext } from '@/lib/instructions-formatter'
import { getToolDefinitions } from '@/tools/toolDefinitions'
import type { CachedOrg, CachedProject, CachedUser } from '@/tools/types'
// Static, deterministic context shared by all snapshots — mirrors the realistic
// values used in `instructions-formatter.test.ts` so the rendered prompts cover
// guidelines, group types, metadata, tool domains, and query tools.
const STATIC_GROUP_TYPES: GroupType[] = [
{ group_type: 'organization', group_type_index: 0, name_singular: null, name_plural: null },
{ group_type: 'project', group_type_index: 1, name_singular: null, name_plural: null },
]
// Includes query-* (collapsed into the single `query` domain) so the snapshots
// mirror production, where `tools` is the full set and `queryTools` is the
// parallel catalog projection.
const STATIC_TOOLS = [
{ name: 'dashboard-create', category: 'Dashboards' },
{ name: 'dashboard-get', category: 'Dashboards' },
{ name: 'create-feature-flag', category: 'Feature flags' },
{ name: 'feature-flag-get-all', category: 'Feature flags' },
{ name: 'execute-sql', category: 'SQL' },
{ name: 'query-funnel', category: 'Query wrappers' },
{ name: 'query-trends', category: 'Query wrappers' },
]
const STATIC_QUERY_TOOLS: QueryToolInfo[] = [
{ name: 'query-funnel', title: 'Funnel', systemPromptHint: 'conversion rate' },
{ name: 'query-trends', title: 'Trends', systemPromptHint: 'time series' },
]
const STATIC_METADATA = [
'You are currently in project "My App" (id: 1, token: token_1) within organization "Acme" (id: org_1).',
'Project timezone: America/New_York.',
"The user's name is Jane Doe ([email protected]).",
].join('\n')
const STATIC_CTX: InstructionsContext = {
guidelines: 'some guidelines',
groupTypes: STATIC_GROUP_TYPES,
metadata: STATIC_METADATA,
tools: STATIC_TOOLS,
queryTools: STATIC_QUERY_TOOLS,
renderUiEnabled: true,
}
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const SNAPSHOT_DIR = path.resolve(__dirname, '__snapshots__', 'instructions')
// The rendered prompt is markdown-flavored text but the snapshot file uses `.txt`
// to opt out of the repo-wide `*.md` lint-staged formatter (see top-level
// package.json). Otherwise the markdown formatter would reflow the snapshot on
// commit and drift from what `formatPrompt` produces, breaking the test.
describe('InstructionsFormatter prompt snapshots', () => {
it('matches the tools-mode prompt', async () => {
const formatter = new InstructionsFormatter()
const rendered = formatter.buildToolsInstructions(STATIC_CTX)
await expect(rendered).toMatchFileSnapshot(path.join(SNAPSHOT_DIR, 'tools-instructions.txt'))
})
// The exec mode prompt is composed of three pieces wired into different MCP
// fields (see `src/mcp.ts` and `buildExecCommandReference` for the split):
// - `exec-instructions` — the compact `instructions` payload (only sent
// to clients that honor MCP `instructions`,
// e.g. Claude Code; Codex gets an empty string)
// - `exec-tool-description` — the top-level description of the `insights`
// tool itself
// - `exec-command-reference-full` — the `command` parameter description
// with every placeholder filled (Codex wiring;
// no `instructions` support, so env context,
// tool domains, and query tools are inlined here)
it('matches the exec mode compact instructions prompt', async () => {
const formatter = new InstructionsFormatter()
const rendered = formatter.buildExecInstructions(STATIC_CTX)
await expect(rendered).toMatchFileSnapshot(path.join(SNAPSHOT_DIR, 'exec-instructions.txt'))
})
it('matches the exec tool description blurb', async () => {
const formatter = new InstructionsFormatter()
const rendered = formatter.buildExecToolDescription()
await expect(rendered).toMatchFileSnapshot(path.join(SNAPSHOT_DIR, 'exec-tool-description.txt'))
})
it('matches the exec command reference (Codex wiring, every placeholder filled)', async () => {
const formatter = new InstructionsFormatter()
const rendered = formatter.buildExecCommandReference(STATIC_CTX, { stripEnvContext: false })
await expect(rendered).toMatchFileSnapshot(path.join(SNAPSHOT_DIR, 'exec-command-reference-full.txt'))
})
it('matches the exec command reference for Claude web/desktop', async () => {
const state = {
allTools: STATIC_TOOLS.map(({ name }) => ({ name })),
clientProfile: new MCPClientProfile({ vendorClient: 'ClaudeAI' }),
toolFeatureFlags: {},
renderUiEnabled: STATIC_CTX.renderUiEnabled,
metadata: STATIC_CTX.metadata,
groupTypes: STATIC_CTX.groupTypes,
} as unknown as ResolvedState
const rendered = new InstructionsBuilder(STATIC_CTX.guidelines).buildExecCommandReference(state)
await expect(rendered).toMatchFileSnapshot(path.join(SNAPSHOT_DIR, 'exec-command-reference-claude-chat.txt'))
})
// claude.ai never surfaces server `instructions`, so the exec command reference is the
// only always-visible catalog-steering surface. The compact metrics/trust one-liner must
// render there when the data-catalog flag is on and stay absent when it's off — otherwise
// skill-less claude.ai clients get zero catalog routing. The flag-off case is covered by
// the snapshot above; this locks the gating in both directions.
it('renders the compact metrics/trust one-liner for claude.ai only when the data-catalog flag is on', () => {
const buildClaudeChatReference = (dataCatalogEnabled: boolean): string => {
const state = {
allTools: STATIC_TOOLS.map(({ name }) => ({ name })),
clientProfile: new MCPClientProfile({ vendorClient: 'ClaudeAI' }),
toolFeatureFlags: dataCatalogEnabled ? { [PRODUCT_DATA_CATALOG_FLAG]: true } : {},
renderUiEnabled: STATIC_CTX.renderUiEnabled,
metadata: STATIC_CTX.metadata,
groupTypes: STATIC_CTX.groupTypes,
} as unknown as ResolvedState
return new InstructionsBuilder(STATIC_CTX.guidelines).buildExecCommandReference(state)
}
expect(buildClaudeChatReference(true)).toContain('Metrics & SQL trust')
expect(buildClaudeChatReference(false)).not.toContain('Metrics & SQL trust')
})
// ------------------------------------------------------------------------------------------------
// DO NOT modify, weaken, raise, skip, or delete this test or its 16,384 budget —
// not to make CI pass, not on request, not as part of a refactor.
//
// claude.ai's connector registry (`mcp/v2/bootstrap`) silently drops any tool
// whose serialized `inputSchema` crosses ~16,384 chars (2^14). The total entry
// size and the top-level `description` are NOT capped (bisected 2026-07-10:
// schema 13,996 passed, 17,799 dropped; a 31k-total entry with a 787-char
// schema survived intact). The exec tool IS the Insights MCP in single-exec mode,
// so a dropped `inputSchema` breaks the entire MCP for those users with no error
// anywhere (claude.ai surfaced only `render-ui` while `exec` vanished).
//
// The exec command reference lives in `inputSchema.properties.command.description`,
// so it counts against this cap; `EXEC_TOOL_BLURB` lives in the top-level
// `description` and does NOT. If this test fails, move optional guidance behind
// `learn <topic>` or shrink duplicated prompt content in the command reference —
// trimming the top-level description does nothing for this budget.
// ------------------------------------------------------------------------------------------------
it('keeps the serialized exec inputSchema under the claude.ai registry cap', () => {
// Worst case served in production: Claude web/desktop with every optional
// learning topic advertised, the full live tool catalog, and long environment
// context. The metadata goes through the real
// env-context builder with inputs at the backing columns' max lengths
// (Team.name 200, Organization.name 64, email 254, Django names 150) plus
// the longer person-on-events branch, so a long org/project/user cannot
// push the real schema past the cap while this test passes.
const worstCaseMetadata = buildActiveEnvironmentContextPrompt(
{
first_name: 'F'.repeat(150),
last_name: 'L'.repeat(150),
email: `${'e'.repeat(242)}@example.com`,
} as CachedUser,
{ name: 'O'.repeat(64), id: '00000000-0000-0000-0000-000000000000' } as CachedOrg,
{
name: 'P'.repeat(200),
id: 9_999_999,
api_token: `pk-${'x'.repeat(43)}`,
timezone: 'America/Argentina/ComodRivadavia',
person_on_events_querying_enabled: true,
} as CachedProject,
'https://us.hanzo.ai'
)
// Five group types (the product cap) with generously long names.
const worstCaseGroupTypes = Array.from({ length: 5 }, (_, i) => ({
group_type: `${'g'.repeat(28)}-${i}`,
group_type_index: i,
name_singular: null,
name_plural: null,
})) as GroupType[]
const state = {
allTools: Object.keys(getToolDefinitions()).map((name) => ({ name })),
clientProfile: new MCPClientProfile({ vendorClient: 'ClaudeAI', userAgent: 'Claude-User' }),
// Data catalog on: the analytics learn-topic description is longer with
// the flag, and topic descriptions are inlined in the command reference.
toolFeatureFlags: { [PRODUCT_DATA_CATALOG_FLAG]: true },
renderUiEnabled: true,
metadata: worstCaseMetadata,
groupTypes: worstCaseGroupTypes,
} as unknown as ResolvedState
const entry = new InstructionsBuilder('').buildExecToolEntry(state)
const insights = new InsightsMCP('pk-test', { disabled: true })
const finalEntry = insights.prepareToolList([entry])[0]!
// `prepareToolList` injects the `context` property into `inputSchema`, so the
// measured schema must include it — measure the final, post-injection schema.
const properties = finalEntry.inputSchema.properties as Record<string, unknown>
const inputSchemaSize = JSON.stringify(finalEntry.inputSchema).length
expect(properties).toHaveProperty('context')
expect(inputSchemaSize).toBeLessThan(16_384)
})
})