From c7ace7e7d7f56a7190df4bda00226a50d55cb997 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Wed, 9 Sep 2026 15:35:01 -0300 Subject: [PATCH 1/4] fix(mcp-analytics): let users select their server before setup Suggest local MCP server entry points before authentication and let users correct the directory or choose a file. Keep agent discovery available for custom servers, and explain the separate command for connecting PostHog to a coding agent. Run against the selected project directory and ask the agent to verify the chosen entry point. Use language-appropriate SDK instructions and finish with first-event steps and a link to the authenticated project and region. Record selection outcomes without including local paths in the new events. Validation: - Build and smoke checks passed. - All 2,595 tests passed with vitest run --maxWorkers=4. - Real Ink keyboard checks passed for directory/file recovery, custom server discovery, client guidance, and duplicate submissions. - pnpm fix and the targeted ESLint checks passed. - Typecheck reports 26 diagnostics identical to the clean base commit. The initial unrestricted test run hit a CLI timeout; the isolated CLI suite and full four-worker suite passed. Event ingestion verification and live activation measurement remain outside this change. Installation completion is not proof that data has arrived. --- docs/mcp-analytics-onboarding.md | 25 ++ e2e-harness/action-registry.ts | 1 + e2e-harness/e2e-profile.ts | 1 + scripts/check-mcp-onboarding.no-jest.tsx | 176 +++++++++++++ scripts/check-screens.tsx | 8 + .../__tests__/mcp-analytics-detect.test.ts | 109 ++++++++ .../programs/__tests__/mcp-analytics.test.ts | 66 ++++- src/lib/programs/mcp-analytics/detect.ts | 113 ++++++++ src/lib/programs/mcp-analytics/index.ts | 95 ++++++- src/lib/programs/mcp-analytics/setup.ts | 24 ++ src/ui/tui/screen-registry.tsx | 9 + src/ui/tui/screen-sequences.ts | 1 + .../tui/screens/McpAnalyticsIntroScreen.tsx | 242 ++++++++++++++++++ src/ui/tui/store.ts | 5 + 14 files changed, 864 insertions(+), 11 deletions(-) create mode 100644 docs/mcp-analytics-onboarding.md create mode 100644 scripts/check-mcp-onboarding.no-jest.tsx create mode 100644 src/lib/programs/__tests__/mcp-analytics-detect.test.ts create mode 100644 src/lib/programs/mcp-analytics/detect.ts create mode 100644 src/lib/programs/mcp-analytics/setup.ts create mode 100644 src/ui/tui/screens/McpAnalyticsIntroScreen.tsx diff --git a/docs/mcp-analytics-onboarding.md b/docs/mcp-analytics-onboarding.md new file mode 100644 index 000000000..52285d186 --- /dev/null +++ b/docs/mcp-analytics-onboarding.md @@ -0,0 +1,25 @@ +# MCP analytics onboarding + +Run `npx @posthog/wizard@latest mcp-analytics` to instrument an existing MCP server. +To connect the PostHog MCP server to a coding agent instead, run +`npx @posthog/wizard@latest mcp add`. + +Before authentication, the wizard performs a bounded local scan for JavaScript, +TypeScript, and Python server entry points. Select a suggested file, enter another +directory or entry-point file, or let the agent search the current directory for +a custom server. The quick scan is a suggestion: no matches does not rule out a +supported server. Tests, dependencies, and symlinked files are excluded. + +Entering a directory scans that location again. Selecting a file uses its closest +ancestor project directory and asks the agent to verify the selected entry point. +Review the directory and server before confirming. Invalid paths stay on the +selection screen and can be corrected without restarting authentication. + +Installation completion does not verify event ingestion. Set the environment +variables in `posthog-mcp-analytics-report.md`, start or redeploy the server, and +invoke a tool that is safe to run. The completion screen links to MCP analytics +in the authenticated project and region so you can check that the call arrived. + +Selection telemetry records scan outcomes, candidate counts, selection method, +and whether a file or directory was selected. These new events do not include +local paths or source code. diff --git a/e2e-harness/action-registry.ts b/e2e-harness/action-registry.ts index 71918cdca..1de6f5b58 100644 --- a/e2e-harness/action-registry.ts +++ b/e2e-harness/action-registry.ts @@ -106,6 +106,7 @@ export const ACTION_REGISTRY: Partial> = { [ScreenId.SourceMapsIntro]: [confirmSetupAction], [ScreenId.MigrationIntro]: [confirmSetupAction], [ScreenId.AgentSkillIntro]: [confirmSetupAction], + [ScreenId.McpAnalyticsIntro]: [confirmSetupAction], [ScreenId.AiObservabilityIntro]: [confirmSetupAction], [ScreenId.MetricsIntro]: [confirmSetupAction], [ScreenId.AuditIntro]: [confirmSetupAction], diff --git a/e2e-harness/e2e-profile.ts b/e2e-harness/e2e-profile.ts index 360bd3ec6..add8a11b1 100644 --- a/e2e-harness/e2e-profile.ts +++ b/e2e-harness/e2e-profile.ts @@ -281,6 +281,7 @@ export function decideE2eAction( case ScreenId.RevenueIntro: case ScreenId.MigrationIntro: case ScreenId.AgentSkillIntro: + case ScreenId.McpAnalyticsIntro: case ScreenId.AiObservabilityIntro: case ScreenId.MetricsIntro: case ScreenId.AuditIntro: diff --git a/scripts/check-mcp-onboarding.no-jest.tsx b/scripts/check-mcp-onboarding.no-jest.tsx new file mode 100644 index 000000000..bac750ce9 --- /dev/null +++ b/scripts/check-mcp-onboarding.no-jest.tsx @@ -0,0 +1,176 @@ +import React from 'react'; +import assert from 'node:assert/strict'; +import { + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + realpathSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { render } from 'ink-testing-library'; +import { WizardStore } from '@ui/tui/store'; +import { McpAnalyticsIntroScreen } from '@ui/tui/screens/McpAnalyticsIntroScreen'; +import { buildSession } from '@lib/wizard-session'; +import { HostResolution } from '@lib/host-resolution'; +import { + findMcpServers, + resolveMcpTarget, +} from '@lib/programs/mcp-analytics/detect'; +import { + MCP_SCAN_KEY, + MCP_TARGET_KEY, +} from '@lib/programs/mcp-analytics/setup'; +import { analytics } from '@utils/analytics'; + +export async function checkMcpOnboarding(): Promise { + const root = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-onboarding-'))); + const server = join(root, 'server'); + mkdirSync(join(server, 'src'), { recursive: true }); + writeFileSync(join(server, 'package.json'), '{}'); + writeFileSync( + join(server, 'src/index.ts'), + "import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; new McpServer({});", + ); + const capture = analytics.wizardCapture; + const events: { event: string; properties?: Record }[] = []; + analytics.wizardCapture = (event, properties) => { + events.push({ event, properties }); + }; + const store = new WizardStore('mcp-analytics'); + store.session = buildSession({ installDir: root }); + store.session.credentials = { + projectId: 42, + projectApiKey: 'phc_example', + accessToken: 'example', + host: HostResolution.fromApiHost('https://eu.i.posthog.com'), + }; + store.setFrameworkContext(MCP_SCAN_KEY, { directory: root, candidates: [] }); + let scans = 0; + const screen = render( + { + scans++; + await delay(50); + return findMcpServers(directory); + }, + }} + />, + ); + const press = async (key: string): Promise => { + screen.stdin.write(key); + await delay(60); + }; + const shows = (text: string): void => { + assert.ok( + screen.lastFrame()?.includes(text), + `Missing ${text}\n${screen.lastFrame()}`, + ); + }; + try { + await delay(60); + shows('No recognized entry point'); + await press('\r'); + shows('Enter your server directory'); + await press('missing'); + await press('\r'); + shows('This path could not be opened'); + assert.equal(store.session.setupConfirmed, false); + await press('\u001b'); + await press('\r'); + await press('server'); + screen.stdin.write('\r'); + screen.stdin.write('\r'); + await delay(200); + assert.equal(scans, 1, 'Repeated Enter must not duplicate a scan'); + shows('src/index.ts'); + await press('\r'); + shows('Instrument this server'); + assert.equal(store.session.setupConfirmed, false); + await press('\r'); + assert.equal(store.session.installDir, server); + assert.deepEqual(store.session.frameworkContext[MCP_TARGET_KEY], { + directory: server, + entryPoint: join(server, 'src/index.ts'), + }); + assert.equal(store.session.credentials?.projectId, 42); + assert.equal(store.session.setupConfirmed, true); + await press('\r'); + assert.equal( + events.filter(({ event }) => event === 'mcp analytics target selected') + .length, + 1, + ); + const reset = async (key: string): Promise => { + store.session = buildSession({ installDir: root }); + store.setFrameworkContext(MCP_SCAN_KEY, { + directory: root, + candidates: [], + }); + screen.rerender( + , + ); + await delay(60); + }; + await reset('manual-file'); + await press('\r'); + await press('server/src/index.ts'); + await press('\r'); + shows('Server: src/index.ts'); + await press('\r'); + assert.equal(store.session.installDir, server); + assert.equal( + events + .filter(({ event }) => event === 'mcp analytics target selected') + .at(-1)?.properties?.selection_source, + 'manual', + ); + + await reset('connect'); + await press('\u001b[B'); + await press('\u001b[B'); + await press('\r'); + shows('npx @posthog/wizard@latest mcp add'); + assert.equal(store.session.setupConfirmed, false); + await press('\r'); + shows('No recognized entry point'); + await press('\u001b[B'); + await press('\r'); + shows('The agent will find the entry point'); + await press('\r'); + assert.equal(store.session.installDir, root); + assert.deepEqual(store.session.frameworkContext[MCP_TARGET_KEY], { + directory: root, + }); + assert.equal( + events + .filter(({ event }) => event === 'mcp analytics target selected') + .at(-1)?.properties?.selection_source, + 'agent_search', + ); + assert.ok( + !JSON.stringify(events).includes(root), + 'Selection telemetry must not include local paths', + ); + console.log( + '\nMCP onboarding directory/file recovery, custom-server fallback, client guidance and duplicate-submit checks passed', + ); + console.log(screen.lastFrame()); + } finally { + screen.unmount(); + analytics.wizardCapture = capture; + rmSync(root, { recursive: true, force: true }); + } +} diff --git a/scripts/check-screens.tsx b/scripts/check-screens.tsx index bfc2871b5..2f524aa8b 100644 --- a/scripts/check-screens.tsx +++ b/scripts/check-screens.tsx @@ -16,6 +16,7 @@ import { ManagedSettingsScreen } from '@ui/tui/screens/ManagedSettingsScreen'; import { SettingsOverrideScreen } from '@ui/tui/screens/SettingsOverrideScreen'; import { WizardAskScreen } from '@ui/tui/screens/WizardAskScreen'; import type { SettingsConflict } from '@lib/agent/agent-interface'; +import { checkMcpOnboarding } from './check-mcp-onboarding.no-jest'; function fakeStore(session: Record): any { return { @@ -182,6 +183,13 @@ check( ['Database host?', 'ESC', 'skip'], ); +try { + await checkMcpOnboarding(); +} catch (error) { + console.error(error); + failures++; +} + if (failures > 0) { console.error(`\n${failures} screen check(s) failed`); process.exit(1); diff --git a/src/lib/programs/__tests__/mcp-analytics-detect.test.ts b/src/lib/programs/__tests__/mcp-analytics-detect.test.ts new file mode 100644 index 000000000..03eb61be5 --- /dev/null +++ b/src/lib/programs/__tests__/mcp-analytics-detect.test.ts @@ -0,0 +1,109 @@ +import { + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + symlinkSync, + realpathSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { findMcpServers, resolveMcpTarget } from '../mcp-analytics/detect'; + +vi.mock('@utils/analytics', () => ({ + analytics: { captureException: vi.fn() }, +})); + +let root: string; +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-selection-'))); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); + +function file(name: string, contents: string): string { + const target = join(root, name); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, contents); + return target; +} + +describe('findMcpServers', () => { + it.each([ + [ + 'apps/tools/src/server.ts', + "import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; const server = new McpServer({name: 'example'});", + ], + [ + 'packages/tools/src/main.mts', + "import { McpServer } from '@modelcontextprotocol/server'; const server = new McpServer({name: 'example'});", + ], + [ + 'python/server.py', + 'from mcp.server.fastmcp import FastMCP\nmcp = FastMCP("example")', + ], + ['python/main.py', 'from fastmcp import FastMCP\nmcp = FastMCP("example")'], + [ + 'edge/server.ts', + 'const handlers = { "tools/call": callTool, "tools/list": listTools };', + ], + ])('suggests a server at %s', async (name, source) => { + file(name, source); + const result = await findMcpServers(root); + expect(result.candidates).toEqual([name]); + }); + + it('does not suggest clients, tests, dependencies or linked outside files', async () => { + const source = + "import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; new McpServer({});"; + file( + 'src/client.ts', + "import { Client } from '@modelcontextprotocol/sdk/client/index.js'; new Client({});", + ); + file('node_modules/example/server.ts', source); + file('tests/server.ts', source); + file('src/server.test.ts', source); + file('server.py', '# just an ordinary script'); + symlinkSync( + join(root, 'node_modules/example/server.ts'), + join(root, 'linked.ts'), + ); + expect((await findMcpServers(root)).candidates).toEqual([]); + }); + + it('leaves an unrecognized custom server eligible for agent search', async () => { + file('server.ts', 'startCustomDispatcher()'); + const result = await findMcpServers(root); + expect(result.candidates).toEqual([]); + expect(resolveMcpTarget(root, '.')).toEqual({ directory: root }); + }); +}); + +describe('resolveMcpTarget', () => { + it('runs a selected entry file from its package root, not its src directory', () => { + file('package.json', '{}'); + file('packages/server/package.json', '{}'); + const entryPoint = file('packages/server/src/index.ts', 'startServer()'); + expect(resolveMcpTarget(root, entryPoint)).toEqual({ + directory: join(root, 'packages/server'), + entryPoint, + }); + }); + + it('accepts an explicit directory override', () => { + mkdirSync(join(root, 'another-server')); + expect(resolveMcpTarget(root, 'another-server')).toEqual({ + directory: join(root, 'another-server'), + }); + }); + + it.each(['', 'missing', '.env', 'report.md'])( + 'rejects invalid or non-source input %j', + (input) => { + file('.env', 'EXAMPLE=value'); + file('report.md', 'example'); + expect(() => resolveMcpTarget(root, input)).toThrow(); + }, + ); +}); diff --git a/src/lib/programs/__tests__/mcp-analytics.test.ts b/src/lib/programs/__tests__/mcp-analytics.test.ts index e9a8546dc..d9731039b 100644 --- a/src/lib/programs/__tests__/mcp-analytics.test.ts +++ b/src/lib/programs/__tests__/mcp-analytics.test.ts @@ -2,6 +2,29 @@ import { MCP_ANALYTICS_ABORT_CASES, mcpAnalyticsConfig, } from '@lib/programs/mcp-analytics/index'; +import { + buildSession, + OutroKind, + type WizardSession, +} from '@lib/wizard-session'; +import type { ProgramRun } from '@lib/agent/agent-runner'; +import { HostResolution } from '@lib/host-resolution'; +import { MCP_TARGET_KEY } from '@lib/programs/mcp-analytics/setup'; + +const credentials = { + projectId: 42, + projectApiKey: 'phc_example', + accessToken: 'example', + host: HostResolution.fromApiHost('https://eu.i.posthog.com'), +}; + +async function runConfig( + session: WizardSession = buildSession({}), +): Promise { + const run = mcpAnalyticsConfig.run; + if (!run) throw new Error('Expected an MCP analytics run'); + return typeof run === 'function' ? run(session) : run; +} describe('MCP_ANALYTICS_ABORT_CASES', () => { // These are the exact `[ABORT] ` strings the mcp-analytics skill @@ -38,13 +61,42 @@ describe('MCP_ANALYTICS_ABORT_CASES', () => { }); describe('mcpAnalyticsConfig', () => { - it('wires the mcp-analytics abort cases into the run config', () => { - // `run` is statically a defined object for this program (createSkillProgram - // always sets it, and never uses the session-derived function form). - const run = mcpAnalyticsConfig.run; - if (!run || typeof run === 'function') { - throw new Error('expected a static run object'); - } + it('wires the mcp-analytics abort cases into the run config', async () => { + const run = await runConfig(); expect(run.abortCases).toBe(MCP_ANALYTICS_ABORT_CASES); }); + + it('asks the agent to verify the selected server relative to its project', async () => { + const session = buildSession({ installDir: '/example/server' }); + session.frameworkContext[MCP_TARGET_KEY] = { + directory: '/example/server', + entryPoint: '/example/server/src/tools.ts', + }; + const run = await runConfig(session); + const prompt = run.customPrompt?.(credentials); + expect(prompt).toContain('"src/tools.ts"'); + expect(prompt).toContain('Verify it and instrument this server'); + expect(prompt).toContain('Make only additive changes'); + }); + + it('keeps agent discovery available without a selected entry point', async () => { + const run = await runConfig(); + const prompt = run.customPrompt?.(credentials); + expect(prompt).toContain('detect the server style'); + expect(prompt).not.toContain('The user selected'); + }); + + it('links completion to the authenticated project and explains how to send data', async () => { + const session = buildSession({}); + const run = await runConfig(session); + const outro = run.buildOutroData?.(session, credentials); + expect(outro?.kind).toBe(OutroKind.Success); + expect(outro?.primaryLink?.url).toBe( + 'https://eu.posthog.com/project/42/mcp-analytics', + ); + expect(outro?.nextSteps?.items.join(' ')).toContain('start or redeploy'); + expect(outro?.nextSteps?.items.join(' ')).toContain( + 'check that the tool call arrived', + ); + }); }); diff --git a/src/lib/programs/mcp-analytics/detect.ts b/src/lib/programs/mcp-analytics/detect.ts new file mode 100644 index 000000000..6d08245d3 --- /dev/null +++ b/src/lib/programs/mcp-analytics/detect.ts @@ -0,0 +1,113 @@ +import { accessSync, constants, existsSync, realpathSync, statSync } from 'fs'; +import { homedir } from 'os'; +import { dirname, extname, join, resolve } from 'path'; +import { boundedGlob, readFileHead } from '@utils/bounded-fs'; + +const SOURCE_EXTENSIONS = new Set([ + '.ts', + '.tsx', + '.mts', + '.cts', + '.js', + '.jsx', + '.mjs', + '.cjs', + '.py', +]); +const PROJECT_MARKERS = [ + 'package.json', + 'pyproject.toml', + 'requirements.txt', + 'setup.py', + '.git', +]; + +export type McpTarget = { directory: string; entryPoint?: string }; +export type McpServerScan = { directory: string; candidates: string[] }; + +export function resolveMcpTarget( + baseDirectory: string, + input: string, +): McpTarget { + const value = input.trim(); + if (!value) + throw new Error( + 'Enter a directory or a JavaScript, TypeScript or Python server file.', + ); + const expanded = + value === '~' + ? homedir() + : value.startsWith('~/') + ? join(homedir(), value.slice(2)) + : value; + let target: string; + try { + target = realpathSync(resolve(baseDirectory, expanded)); + accessSync(target, constants.R_OK); + } catch { + throw new Error( + 'This path could not be opened. Check that it exists and is readable.', + ); + } + + const stat = statSync(target); + if (stat.isDirectory()) { + accessSync(target, constants.X_OK); + return { directory: target }; + } + if (!stat.isFile() || !SOURCE_EXTENSIONS.has(extname(target))) { + throw new Error( + 'Choose a directory or a JavaScript, TypeScript or Python server file.', + ); + } + + let directory = dirname(target); + while (!PROJECT_MARKERS.some((name) => existsSync(join(directory, name)))) { + const parent = dirname(directory); + if (parent === directory) + return { directory: dirname(target), entryPoint: target }; + directory = parent; + } + return { directory, entryPoint: target }; +} + +// These are suggestions, not an eligibility check: custom dispatchers and +// aliased constructors still have the explicit-path and agent-search routes. +function hasServerSignals(source: string): boolean { + return ( + (/\bnew\s+(?:McpServer|Server)\s*\(/.test(source) && + /@modelcontextprotocol\/(?:sdk\/server|server)/.test(source)) || + (/\b(?:FastMCP|Server)\s*\(/.test(source) && + /\bfrom\s+(?:mcp\.server(?:\.[\w.]+)?|fastmcp)\s+import\b/.test( + source, + )) || + /\bcreateMcpHandler\s*\(/.test(source) || + (/["']tools\/call["']/.test(source) && /["']tools\/list["']/.test(source)) + ); +} + +export async function findMcpServers( + directory: string, +): Promise { + const target = resolveMcpTarget(directory, '.'); + const files = await boundedGlob('**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,py}', { + cwd: target.directory, + deep: 6, + limit: 500, + extraIgnore: [ + '**/tests/**', + '**/__tests__/**', + '**/e2e/**', + '**/*.test.*', + '**/*.spec.*', + '**/*.d.ts', + ], + }); + const candidates = files + .filter((file) => { + const source = readFileHead(join(target.directory, file), 64 * 1024); + return source !== null && hasServerSignals(source); + }) + .sort(); + return { directory: target.directory, candidates }; +} diff --git a/src/lib/programs/mcp-analytics/index.ts b/src/lib/programs/mcp-analytics/index.ts index d5ed1812c..03bd3083f 100644 --- a/src/lib/programs/mcp-analytics/index.ts +++ b/src/lib/programs/mcp-analytics/index.ts @@ -1,6 +1,17 @@ import type { AbortCase } from '@lib/agent/agent-runner'; +import type { ProgramRun } from '@lib/agent/agent-runner'; +import type { ProgramConfig } from '@lib/programs/program-step'; +import { OutroKind, type WizardSession } from '@lib/wizard-session'; +import { relative } from 'path'; import { ErrorCodes } from '@lib/errors'; import { createSkillProgram } from '@lib/programs/agent-skill/index'; +import type { McpTarget } from './detect'; +import { + MCP_SCAN_KEY, + MCP_SCAN_ERROR_KEY, + MCP_TARGET_KEY, + scanMcpAnalyticsProject, +} from './setup'; const MCP_ANALYTICS_REPORT_FILE = 'posthog-mcp-analytics-report.md'; @@ -54,7 +65,7 @@ export const MCP_ANALYTICS_ABORT_CASES: AbortCase[] = [ * 'mcp-analytics'` from context-mill — a deliberate breaking change, done then, * not pre-emptively. */ -export const mcpAnalyticsConfig = createSkillProgram({ +const baseConfig = createSkillProgram({ skillId: 'mcp-analytics', command: 'mcp-analytics', id: 'mcp-analytics', @@ -63,14 +74,90 @@ export const mcpAnalyticsConfig = createSkillProgram({ customPrompt: "Instrument this project's MCP server with PostHog MCP analytics. Run the " + '`mcp-analytics` skill end-to-end: detect the server style, install ' + - '`@posthog/mcp` and `posthog-node`, wrap the server (or use `PostHogMCP` ' + - 'for a custom dispatcher), wire the project API key and host, and verify. ' + + 'the appropriate SDK (`@posthog/mcp` and `posthog-node` for JavaScript/' + + 'TypeScript, or `posthog.mcp` from the `posthog` package for Python), ' + + 'instrument the server using its supported integration, wire the project ' + + 'API key and host, and verify. ' + 'Make only additive changes — do not alter tool behavior. The final report ' + `is written to ./${MCP_ANALYTICS_REPORT_FILE}.`, - successMessage: `MCP analytics configured! View the report at ./${MCP_ANALYTICS_REPORT_FILE}`, + successMessage: 'MCP analytics installed. Send your first tool call next.', reportFile: MCP_ANALYTICS_REPORT_FILE, docsUrl: 'https://posthog.com/docs/mcp-analytics', spinnerMessage: 'Setting up MCP analytics...', estimatedDurationMinutes: 5, abortCases: MCP_ANALYTICS_ABORT_CASES, + buildOutroData: (_session, credentials) => ({ + kind: OutroKind.Success, + message: 'MCP analytics installed. Send your first tool call next.', + reportFile: MCP_ANALYTICS_REPORT_FILE, + primaryLink: { + label: 'Open MCP analytics', + url: `${credentials.host.appHost.replace(/\/$/, '')}/project/${ + credentials.projectId + }/mcp-analytics`, + }, + nextSteps: { + heading: 'Get your first data', + items: [ + 'Set the environment variables listed in the setup report, then start or redeploy your server.', + 'Connect your agent and invoke a tool that is safe to run.', + 'Open MCP analytics in the selected project to check that the tool call arrived.', + ], + }, + docsUrl: 'https://posthog.com/docs/mcp-analytics/installation', + }), }); + +async function buildRun(session: WizardSession): Promise { + if (!baseConfig.run) + throw new Error('Missing MCP analytics run configuration'); + const run = + typeof baseConfig.run === 'function' + ? await baseConfig.run(session) + : baseConfig.run; + const target = session.frameworkContext[MCP_TARGET_KEY] as + | McpTarget + | undefined; + return { + ...run, + customPrompt: (ctx) => + [ + run.customPrompt?.(ctx), + target?.entryPoint + ? `The user selected this server entry point: ${JSON.stringify( + relative(session.installDir, target.entryPoint), + )}. Verify it and instrument this server.` + : undefined, + ] + .filter(Boolean) + .join('\n'), + }; +} + +export const mcpAnalyticsConfig: ProgramConfig = { + ...baseConfig, + steps: baseConfig.steps.map((step) => + step.id === 'intro' + ? { + ...step, + screenId: 'mcp-analytics-intro', + onReady: async (ctx) => { + try { + ctx.setFrameworkContext( + MCP_SCAN_KEY, + await scanMcpAnalyticsProject(ctx.session.installDir), + ); + } catch (error) { + ctx.setFrameworkContext( + MCP_SCAN_ERROR_KEY, + error instanceof Error + ? error.message + : 'Could not read this directory.', + ); + } + }, + } + : step, + ), + run: buildRun, +}; diff --git a/src/lib/programs/mcp-analytics/setup.ts b/src/lib/programs/mcp-analytics/setup.ts new file mode 100644 index 000000000..92534d607 --- /dev/null +++ b/src/lib/programs/mcp-analytics/setup.ts @@ -0,0 +1,24 @@ +import { analytics } from '@utils/analytics'; +import { findMcpServers, type McpServerScan } from './detect'; + +export const MCP_SCAN_KEY = 'mcpAnalyticsScan'; +export const MCP_SCAN_ERROR_KEY = 'mcpAnalyticsScanError'; +export const MCP_TARGET_KEY = 'mcpAnalyticsTarget'; + +export async function scanMcpAnalyticsProject( + directory: string, +): Promise { + try { + const scan = await findMcpServers(directory); + analytics.wizardCapture('mcp analytics server scan', { + candidate_count: scan.candidates.length, + outcome: scan.candidates.length ? 'candidates_found' : 'no_candidates', + }); + return scan; + } catch (error) { + analytics.wizardCapture('mcp analytics server scan', { + outcome: 'unreadable_path', + }); + throw error; + } +} diff --git a/src/ui/tui/screen-registry.tsx b/src/ui/tui/screen-registry.tsx index 08d21b3bc..e9d74e258 100644 --- a/src/ui/tui/screen-registry.tsx +++ b/src/ui/tui/screen-registry.tsx @@ -28,6 +28,9 @@ import { SourceMapsIntroScreen } from './screens/SourceMapsIntroScreen.js'; import { SourceMapsDetectScreen } from './screens/SourceMapsDetectScreen.js'; import { SourceMapsOutroScreen } from './screens/SourceMapsOutroScreen.js'; import { AgentSkillIntroScreen } from './screens/AgentSkillIntroScreen.js'; +import { McpAnalyticsIntroScreen } from './screens/McpAnalyticsIntroScreen.js'; +import { resolveMcpTarget } from '@lib/programs/mcp-analytics/detect'; +import { scanMcpAnalyticsProject } from '@lib/programs/mcp-analytics/setup'; import { AiObservabilityIntroScreen } from './screens/AiObservabilityIntroScreen.js'; import { MetricsIntroScreen } from './screens/MetricsIntroScreen.js'; import { SelfDrivingIntroScreen } from './screens/SelfDrivingIntroScreen.js'; @@ -92,6 +95,12 @@ export function createScreens( [ScreenId.SourceMapsOutro]: , [ScreenId.MigrationIntro]: , [ScreenId.AgentSkillIntro]: , + [ScreenId.McpAnalyticsIntro]: ( + + ), [ScreenId.AiObservabilityIntro]: ( ), diff --git a/src/ui/tui/screen-sequences.ts b/src/ui/tui/screen-sequences.ts index 6b6b36cfb..084f16a54 100644 --- a/src/ui/tui/screen-sequences.ts +++ b/src/ui/tui/screen-sequences.ts @@ -24,6 +24,7 @@ export enum ScreenId { SourceMapsOutro = 'source-maps-outro', MigrationIntro = 'migration-intro', AgentSkillIntro = 'agent-skill-intro', + McpAnalyticsIntro = 'mcp-analytics-intro', AiObservabilityIntro = 'ai-observability-intro', MetricsIntro = 'metrics-intro', SelfDrivingIntro = 'self-driving-intro', diff --git a/src/ui/tui/screens/McpAnalyticsIntroScreen.tsx b/src/ui/tui/screens/McpAnalyticsIntroScreen.tsx new file mode 100644 index 000000000..a096d3701 --- /dev/null +++ b/src/ui/tui/screens/McpAnalyticsIntroScreen.tsx @@ -0,0 +1,242 @@ +import { Box, Text, useInput } from 'ink'; +import { TextInput } from '@inkjs/ui'; +import { + useRef, + useState, + useSyncExternalStore, + type ReactElement, +} from 'react'; +import { relative } from 'path'; +import type { WizardStore } from '@ui/tui/store'; +import type { + McpTarget, + McpServerScan, + resolveMcpTarget, +} from '@lib/programs/mcp-analytics/detect'; +import { + MCP_SCAN_KEY, + MCP_SCAN_ERROR_KEY, + MCP_TARGET_KEY, +} from '@lib/programs/mcp-analytics/setup'; +import { analytics } from '@utils/analytics'; +import { IntroScreenLayout } from './IntroScreenLayout'; + +enum View { + Select, + Path, + Review, + Connect, +} +type SelectionSource = 'suggested' | 'manual' | 'agent_search'; + +type Props = { + store: WizardStore; + services: { + scan: (directory: string) => Promise; + resolve: typeof resolveMcpTarget; + }; +}; + +export function McpAnalyticsIntroScreen({ + store, + services, +}: Props): ReactElement { + useSyncExternalStore( + (cb) => store.subscribe(cb), + () => store.getSnapshot(), + ); + const [view, setView] = useState(View.Select); + const [target, setTarget] = useState(null); + const [source, setSource] = useState('suggested'); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const submitting = useRef(false); + const scan = store.session.frameworkContext[MCP_SCAN_KEY] as + | McpServerScan + | undefined; + const scanError = store.session.frameworkContext[MCP_SCAN_ERROR_KEY] as + | string + | undefined; + const directory = scan?.directory ?? store.session.installDir; + + useInput((_input, key) => { + if (key.escape && !submitting.current) { + setError(null); + setView(View.Select); + } + }); + + const selectPath = async ( + input: string, + selectionSource: SelectionSource, + ): Promise => { + if (submitting.current) return; + submitting.current = true; + setBusy(true); + setError(null); + try { + const resolved = services.resolve(directory, input); + if (selectionSource === 'manual' && !resolved.entryPoint) { + store.setFrameworkContext( + MCP_SCAN_KEY, + await services.scan(resolved.directory), + ); + store.setFrameworkContext(MCP_SCAN_ERROR_KEY, undefined); + setView(View.Select); + } else { + setTarget(resolved); + setSource(selectionSource); + setView(View.Review); + } + } catch (err) { + setError( + err instanceof Error ? err.message : 'This path could not be opened.', + ); + analytics.wizardCapture('mcp analytics target rejected', { + selection_source: selectionSource, + }); + } finally { + submitting.current = false; + setBusy(false); + } + }; + + const confirm = (): void => { + if (!target || submitting.current || store.session.setupConfirmed) return; + store.setInstallDir(target.directory); + store.setFrameworkContext(MCP_TARGET_KEY, target); + analytics.wizardCapture('mcp analytics target selected', { + selection_source: source, + target_kind: target.entryPoint ? 'file' : 'directory', + candidate_count: scan?.candidates.length ?? 0, + }); + store.completeSetup(); + }; + + const cancel = async (): Promise => { + if (submitting.current) return; + submitting.current = true; + setBusy(true); + analytics.wizardCapture('mcp analytics setup cancelled'); + try { + await analytics.shutdown('cancelled'); + } finally { + process.exit(0); + } + }; + + const waiting = !scan && !scanError; + const menuOptions = + busy || waiting || view === View.Path + ? null + : view === View.Review + ? [ + { label: 'Instrument this server', value: 'confirm' }, + { label: 'Choose another location', value: 'back' }, + ] + : view === View.Connect + ? [{ label: 'Back', value: 'back' }] + : [ + ...(scan?.candidates ?? []).map((file) => ({ + label: file, + value: `file:${file}`, + })), + { label: 'Enter a directory or server file', value: 'path' }, + ...(!scanError + ? [ + { + label: 'Let the agent search this directory', + value: 'search', + }, + ] + : []), + { label: 'I want to connect PostHog to my agent', value: 'connect' }, + { label: 'Cancel', value: 'cancel' }, + ]; + + return ( + { + if (value.startsWith('file:')) + void selectPath(value.slice(5), 'suggested'); + else if (value === 'path') { + setError(null); + setView(View.Path); + } else if (value === 'search') void selectPath('.', 'agent_search'); + else if (value === 'connect') setView(View.Connect); + else if (value === 'back') { + setError(null); + setView(View.Select); + } else if (value === 'confirm') confirm(); + else if (value === 'cancel') void cancel(); + }} + body={ + + {view === View.Connect ? ( + <> + To use PostHog from your coding agent, run: + npx @posthog/wizard@latest mcp add + + MCP analytics is for measuring calls to a server you build. + + + ) : view === View.Review && target ? ( + <> + Directory: {target.directory} + + Server:{' '} + {target.entryPoint + ? relative(target.directory, target.entryPoint) + : 'The agent will find the entry point'} + + + The agent will verify this server before changing its code. + + + ) : view === View.Path ? ( + <> + Enter your server directory or entry-point file. + Relative paths start from {directory} + {!busy && ( + { + void selectPath(value, 'manual'); + }} + /> + )} + Enter to check the path. Esc to go back. + + ) : ( + <> + Add analytics to an existing MCP server you build. + Choose its location before signing in. + Directory: {directory} + {waiting ? ( + Looking for MCP server entry points... + ) : scanError ? ( + {scanError} + ) : scan?.candidates.length ? ( + Possible server entry points: + ) : ( + + No recognized entry point found in this quick scan. Choose + another location or let the agent search your custom setup. + + )} + + )} + {busy && Checking...} + {error && {error}} + + } + /> + ); +} diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index b8507ca0d..33ee0b298 100644 --- a/src/ui/tui/store.ts +++ b/src/ui/tui/store.ts @@ -936,6 +936,11 @@ export class WizardStore { this.emitChange(); } + setInstallDir(installDir: string): void { + this.$session.setKey('installDir', installDir); + this.emitChange(); + } + // ── Derived state ─────────────────────────────────────────────── /** From 43f524bdc1cade7da59225faeb39b1537558a28e Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Wed, 9 Sep 2026 16:07:52 -0300 Subject: [PATCH 2/4] fix(mcp-analytics): default to suggested servers and discovery Start setup directly from a single detected server and make agent discovery primary when the scan is inconclusive. Keep manual paths optional and review ambiguous selections. Recognize the official Python v2 MCPServer constructor. Validation: build and smoke checks passed; 2,596 tests passed with four workers; real Ink keyboard checks and pnpm fix passed. Typecheck diagnostics match the clean base. Full authenticated wizard installs remain untested. --- docs/mcp-analytics-onboarding.md | 34 ++++++---- scripts/check-mcp-onboarding.no-jest.tsx | 45 +++++++++--- .../__tests__/mcp-analytics-detect.test.ts | 4 ++ src/lib/programs/mcp-analytics/detect.ts | 2 +- .../tui/screens/McpAnalyticsIntroScreen.tsx | 68 +++++++++++++------ 5 files changed, 107 insertions(+), 46 deletions(-) diff --git a/docs/mcp-analytics-onboarding.md b/docs/mcp-analytics-onboarding.md index 52285d186..066e954ac 100644 --- a/docs/mcp-analytics-onboarding.md +++ b/docs/mcp-analytics-onboarding.md @@ -1,24 +1,32 @@ # MCP analytics onboarding -Run `npx @posthog/wizard@latest mcp-analytics` to instrument an existing MCP server. -To connect the PostHog MCP server to a coding agent instead, run +Run `npx @posthog/wizard@latest mcp-analytics` to instrument an existing MCP +server. To connect the PostHog MCP server to a coding agent instead, run `npx @posthog/wizard@latest mcp add`. Before authentication, the wizard performs a bounded local scan for JavaScript, -TypeScript, and Python server entry points. Select a suggested file, enter another -directory or entry-point file, or let the agent search the current directory for -a custom server. The quick scan is a suggestion: no matches does not rule out a -supported server. Tests, dependencies, and symlinked files are excluded. +TypeScript, and Python server entry points. A single match is preselected: +choose "Set up MCP analytics" to continue. Multiple matches offer a choice. When +the scan is inconclusive, the default action lets the agent find the server and +set up analytics in the current directory. "Choose another location" is an +optional escape hatch for a different directory or entry-point file. The quick +scan is a suggestion: no matches does not rule out a supported server. Tests, +dependencies, and symlinked files are excluded. Python suggestions include the +official SDK v2 `MCPServer` constructor alongside the older `FastMCP` and +low-level `Server` constructors. -Entering a directory scans that location again. Selecting a file uses its closest -ancestor project directory and asks the agent to verify the selected entry point. -Review the directory and server before confirming. Invalid paths stay on the -selection screen and can be corrected without restarting authentication. +Entering a directory scans that location again. Selecting a file uses its +closest ancestor project directory and asks the agent to verify the selected +entry point. Manually selected files and ambiguous matches get a review before +confirming; a single suggestion and automatic discovery need no second +confirmation. Invalid paths stay on the selection screen and can be corrected +without restarting authentication. Installation completion does not verify event ingestion. Set the environment -variables in `posthog-mcp-analytics-report.md`, start or redeploy the server, and -invoke a tool that is safe to run. The completion screen links to MCP analytics -in the authenticated project and region so you can check that the call arrived. +variables in `posthog-mcp-analytics-report.md`, start or redeploy the server, +and invoke a tool that is safe to run. The completion screen links to MCP +analytics in the authenticated project and region so you can check that the call +arrived. Selection telemetry records scan outcomes, candidate counts, selection method, and whether a file or directory was selected. These new events do not include diff --git a/scripts/check-mcp-onboarding.no-jest.tsx b/scripts/check-mcp-onboarding.no-jest.tsx index bac750ce9..72ce303bd 100644 --- a/scripts/check-mcp-onboarding.no-jest.tsx +++ b/scripts/check-mcp-onboarding.no-jest.tsx @@ -74,7 +74,8 @@ export async function checkMcpOnboarding(): Promise { }; try { await delay(60); - shows('No recognized entry point'); + shows('The quick scan didn’t find a server'); + await press('\u001b[B'); await press('\r'); shows('Enter your server directory'); await press('missing'); @@ -82,6 +83,7 @@ export async function checkMcpOnboarding(): Promise { shows('This path could not be opened'); assert.equal(store.session.setupConfirmed, false); await press('\u001b'); + await press('\u001b[B'); await press('\r'); await press('server'); screen.stdin.write('\r'); @@ -90,9 +92,11 @@ export async function checkMcpOnboarding(): Promise { assert.equal(scans, 1, 'Repeated Enter must not duplicate a scan'); shows('src/index.ts'); await press('\r'); - shows('Instrument this server'); - assert.equal(store.session.setupConfirmed, false); - await press('\r'); + assert.equal( + store.session.setupConfirmed, + true, + 'A single suggestion starts setup with one Enter', + ); assert.equal(store.session.installDir, server); assert.deepEqual(store.session.frameworkContext[MCP_TARGET_KEY], { directory: server, @@ -106,11 +110,14 @@ export async function checkMcpOnboarding(): Promise { .length, 1, ); - const reset = async (key: string): Promise => { + const reset = async ( + key: string, + candidates: string[] = [], + ): Promise => { store.session = buildSession({ installDir: root }); store.setFrameworkContext(MCP_SCAN_KEY, { directory: root, - candidates: [], + candidates, }); screen.rerender( { ); await delay(60); }; + await reset('single-suggestion', ['server/src/index.ts']); + shows('Server: server/src/index.ts'); + assert.equal(store.session.setupConfirmed, false); + await press('\r'); + assert.equal(store.session.setupConfirmed, true); + assert.equal(store.session.installDir, server); + + await reset('multiple-suggestions', ['server/src/index.ts', 'another.ts']); + shows('Found several possible servers'); + await press('\r'); + shows('Instrument this server'); + assert.equal(store.session.setupConfirmed, false); + await press('\r'); + assert.equal(store.session.setupConfirmed, true); + await reset('manual-file'); + await press('\u001b[B'); await press('\r'); await press('server/src/index.ts'); await press('\r'); @@ -145,11 +168,13 @@ export async function checkMcpOnboarding(): Promise { shows('npx @posthog/wizard@latest mcp add'); assert.equal(store.session.setupConfirmed, false); await press('\r'); - shows('No recognized entry point'); - await press('\u001b[B'); - await press('\r'); - shows('The agent will find the entry point'); + shows('The quick scan didn’t find a server'); await press('\r'); + assert.equal( + store.session.setupConfirmed, + true, + 'An inconclusive scan defaults to agent discovery', + ); assert.equal(store.session.installDir, root); assert.deepEqual(store.session.frameworkContext[MCP_TARGET_KEY], { directory: root, diff --git a/src/lib/programs/__tests__/mcp-analytics-detect.test.ts b/src/lib/programs/__tests__/mcp-analytics-detect.test.ts index 03eb61be5..ddfe3904e 100644 --- a/src/lib/programs/__tests__/mcp-analytics-detect.test.ts +++ b/src/lib/programs/__tests__/mcp-analytics-detect.test.ts @@ -44,6 +44,10 @@ describe('findMcpServers', () => { 'from mcp.server.fastmcp import FastMCP\nmcp = FastMCP("example")', ], ['python/main.py', 'from fastmcp import FastMCP\nmcp = FastMCP("example")'], + [ + 'python/v2.py', + 'from mcp.server.mcpserver import MCPServer\nmcp = MCPServer("example")', + ], [ 'edge/server.ts', 'const handlers = { "tools/call": callTool, "tools/list": listTools };', diff --git a/src/lib/programs/mcp-analytics/detect.ts b/src/lib/programs/mcp-analytics/detect.ts index 6d08245d3..763e9085b 100644 --- a/src/lib/programs/mcp-analytics/detect.ts +++ b/src/lib/programs/mcp-analytics/detect.ts @@ -77,7 +77,7 @@ function hasServerSignals(source: string): boolean { return ( (/\bnew\s+(?:McpServer|Server)\s*\(/.test(source) && /@modelcontextprotocol\/(?:sdk\/server|server)/.test(source)) || - (/\b(?:FastMCP|Server)\s*\(/.test(source) && + (/\b(?:FastMCP|MCPServer|Server)\s*\(/.test(source) && /\bfrom\s+(?:mcp\.server(?:\.[\w.]+)?|fastmcp)\s+import\b/.test( source, )) || diff --git a/src/ui/tui/screens/McpAnalyticsIntroScreen.tsx b/src/ui/tui/screens/McpAnalyticsIntroScreen.tsx index a096d3701..a665f4f53 100644 --- a/src/ui/tui/screens/McpAnalyticsIntroScreen.tsx +++ b/src/ui/tui/screens/McpAnalyticsIntroScreen.tsx @@ -66,9 +66,25 @@ export function McpAnalyticsIntroScreen({ } }); + const completeTarget = ( + selected: McpTarget, + selectionSource: SelectionSource, + ): void => { + if (store.session.setupConfirmed) return; + store.setInstallDir(selected.directory); + store.setFrameworkContext(MCP_TARGET_KEY, selected); + analytics.wizardCapture('mcp analytics target selected', { + selection_source: selectionSource, + target_kind: selected.entryPoint ? 'file' : 'directory', + candidate_count: scan?.candidates.length ?? 0, + }); + store.completeSetup(); + }; + const selectPath = async ( input: string, selectionSource: SelectionSource, + startSetup = false, ): Promise => { if (submitting.current) return; submitting.current = true; @@ -83,6 +99,8 @@ export function McpAnalyticsIntroScreen({ ); store.setFrameworkContext(MCP_SCAN_ERROR_KEY, undefined); setView(View.Select); + } else if (startSetup) { + completeTarget(resolved, selectionSource); } else { setTarget(resolved); setSource(selectionSource); @@ -103,14 +121,7 @@ export function McpAnalyticsIntroScreen({ const confirm = (): void => { if (!target || submitting.current || store.session.setupConfirmed) return; - store.setInstallDir(target.directory); - store.setFrameworkContext(MCP_TARGET_KEY, target); - analytics.wizardCapture('mcp analytics target selected', { - selection_source: source, - target_kind: target.entryPoint ? 'file' : 'directory', - candidate_count: scan?.candidates.length ?? 0, - }); - store.completeSetup(); + completeTarget(target, source); }; const cancel = async (): Promise => { @@ -126,6 +137,8 @@ export function McpAnalyticsIntroScreen({ }; const waiting = !scan && !scanError; + const candidates = scan?.candidates ?? []; + const suggestedFile = candidates.length === 1 ? candidates[0] : undefined; const menuOptions = busy || waiting || view === View.Path ? null @@ -137,19 +150,21 @@ export function McpAnalyticsIntroScreen({ : view === View.Connect ? [{ label: 'Back', value: 'back' }] : [ - ...(scan?.candidates ?? []).map((file) => ({ - label: file, - value: `file:${file}`, - })), - { label: 'Enter a directory or server file', value: 'path' }, - ...(!scanError + ...(suggestedFile + ? [{ label: 'Set up MCP analytics', value: 'start' }] + : candidates.map((file) => ({ + label: file, + value: `file:${file}`, + }))), + ...(!scanError && !suggestedFile ? [ { - label: 'Let the agent search this directory', + label: 'Find my server and set up analytics', value: 'search', }, ] : []), + { label: 'Choose another location', value: 'path' }, { label: 'I want to connect PostHog to my agent', value: 'connect' }, { label: 'Cancel', value: 'cancel' }, ]; @@ -164,12 +179,15 @@ export function McpAnalyticsIntroScreen({ menuAlign="left" menuOptions={menuOptions} onSelect={(value) => { - if (value.startsWith('file:')) + if (value === 'start' && suggestedFile) + void selectPath(suggestedFile, 'suggested', true); + else if (value.startsWith('file:')) void selectPath(value.slice(5), 'suggested'); else if (value === 'path') { setError(null); setView(View.Path); - } else if (value === 'search') void selectPath('.', 'agent_search'); + } else if (value === 'search') + void selectPath('.', 'agent_search', true); else if (value === 'connect') setView(View.Connect); else if (value === 'back') { setError(null); @@ -217,18 +235,24 @@ export function McpAnalyticsIntroScreen({ ) : ( <> Add analytics to an existing MCP server you build. - Choose its location before signing in. + + We’ll find the integration and configure it for you. + Directory: {directory} {waiting ? ( Looking for MCP server entry points... ) : scanError ? ( {scanError} - ) : scan?.candidates.length ? ( - Possible server entry points: + ) : suggestedFile ? ( + Server: {suggestedFile} + ) : candidates.length ? ( + + Found several possible servers. Choose one to set up: + ) : ( - No recognized entry point found in this quick scan. Choose - another location or let the agent search your custom setup. + The quick scan didn’t find a server. The agent will search + this directory and verify your setup before changing code. )} From c2c8d6516226faccffb0ad3a9df562f1da17cad0 Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Wed, 9 Sep 2026 16:27:13 -0300 Subject: [PATCH 3/4] fix(mcp-analytics): improve suggestions in large repositories Recognize generic FastMCP and Mastra constructors. Reserve a bounded scan pass for likely server paths so unrelated files cannot consume the whole budget. Filter test fixtures and comment-only examples, and order application packages before examples and templates. Validation: 2,600 unit tests, build/security smoke checks, screen checks and pnpm fix passed. Twenty real-repository keyboard flows passed across nine starting directories. Typecheck matches all 26 clean-base diagnostics. Validation covers discovery and selection, not full agent installation or runtime instrumentation support for the recognized wrapper classes. --- docs/mcp-analytics-onboarding.md | 15 ++++-- .../__tests__/mcp-analytics-detect.test.ts | 51 +++++++++++++++++++ src/lib/programs/mcp-analytics/detect.ts | 39 +++++++++++--- 3 files changed, 94 insertions(+), 11 deletions(-) diff --git a/docs/mcp-analytics-onboarding.md b/docs/mcp-analytics-onboarding.md index 066e954ac..2d3e3956c 100644 --- a/docs/mcp-analytics-onboarding.md +++ b/docs/mcp-analytics-onboarding.md @@ -11,9 +11,18 @@ the scan is inconclusive, the default action lets the agent find the server and set up analytics in the current directory. "Choose another location" is an optional escape hatch for a different directory or entry-point file. The quick scan is a suggestion: no matches does not rule out a supported server. Tests, -dependencies, and symlinked files are excluded. Python suggestions include the -official SDK v2 `MCPServer` constructor alongside the older `FastMCP` and -low-level `Server` constructors. +dependencies, and symlinked files are excluded. Suggestions recognize the +official SDK constructors, TypeScript FastMCP (including generic constructors), +and Mastra's MCPServer. Recognition does not guarantee that a wrapper supports +direct instrumentation: the agent still verifies its integration path. + +The scan reserves up to 500 matches for MCP-named directories and server-named +files, alongside up to 500 general source matches. Both passes are bounded to +six directory levels and read at most 64 KiB per unique file. This keeps +unrelated application code from consuming the entire suggestion budget in large +monorepos. Application packages sort before examples and templates. Test +fixtures and comment-only examples do not become suggested servers. Aliases, +deeper trees, and files beyond these bounds can still require agent discovery. Entering a directory scans that location again. Selecting a file uses its closest ancestor project directory and asks the agent to verify the selected diff --git a/src/lib/programs/__tests__/mcp-analytics-detect.test.ts b/src/lib/programs/__tests__/mcp-analytics-detect.test.ts index ddfe3904e..747474812 100644 --- a/src/lib/programs/__tests__/mcp-analytics-detect.test.ts +++ b/src/lib/programs/__tests__/mcp-analytics-detect.test.ts @@ -39,6 +39,14 @@ describe('findMcpServers', () => { 'packages/tools/src/main.mts', "import { McpServer } from '@modelcontextprotocol/server'; const server = new McpServer({name: 'example'});", ], + [ + 'src/index.ts', + "import { FastMCP } from 'fastmcp'; const server = new FastMCP({name: 'example'});", + ], + [ + 'packages/docs/src/index.ts', + "import { MCPServer } from '@mastra/mcp'; const server = new MCPServer({name: 'example'});", + ], [ 'python/server.py', 'from mcp.server.fastmcp import FastMCP\nmcp = FastMCP("example")', @@ -67,8 +75,24 @@ describe('findMcpServers', () => { ); file('node_modules/example/server.ts', source); file('tests/server.ts', source); + file('test/server.ts', source); + file('src/__fixtures__/server.ts', source); + file('integration-tests/server.ts', source); + file('server-adapters/_test-utils/src/server.ts', source); + file( + 'src/test_server.py', + 'from mcp.server import Server\nServer("example")', + ); file('src/server.test.ts', source); file('server.py', '# just an ordinary script'); + file( + 'src/documented-server.ts', + `/**\n * ${source}\n */\nexport const helper = () => {};`, + ); + file( + 'src/commented-server.py', + '# from fastmcp import FastMCP\n# server = FastMCP("example")', + ); symlinkSync( join(root, 'node_modules/example/server.ts'), join(root, 'linked.ts'), @@ -76,6 +100,33 @@ describe('findMcpServers', () => { expect((await findMcpServers(root)).candidates).toEqual([]); }); + it('suggests application packages before examples and templates', async () => { + const source = + "import { McpServer } from '@modelcontextprotocol/server'; new McpServer({});"; + for (const name of [ + 'examples/server.ts', + 'packages/tools/server.ts', + 'templates/server.ts', + ]) + file(name, source); + expect((await findMcpServers(root)).candidates).toEqual([ + 'packages/tools/server.ts', + 'examples/server.ts', + 'templates/server.ts', + ]); + }); + + it('finds MCP packages beyond a large unrelated source tree', async () => { + for (let i = 0; i < 600; i++) + file(`unrelated-${i}.ts`, 'export const page = {};'); + const entry = 'packages/example-mcp/src/index.ts'; + file( + entry, + "import { McpServer } from '@modelcontextprotocol/server'; new McpServer({});", + ); + expect((await findMcpServers(root)).candidates).toContain(entry); + }); + it('leaves an unrecognized custom server eligible for agent search', async () => { file('server.ts', 'startCustomDispatcher()'); const result = await findMcpServers(root); diff --git a/src/lib/programs/mcp-analytics/detect.ts b/src/lib/programs/mcp-analytics/detect.ts index 763e9085b..5ff7e3ffc 100644 --- a/src/lib/programs/mcp-analytics/detect.ts +++ b/src/lib/programs/mcp-analytics/detect.ts @@ -73,10 +73,15 @@ export function resolveMcpTarget( // These are suggestions, not an eligibility check: custom dispatchers and // aliased constructors still have the explicit-path and agent-search routes. -function hasServerSignals(source: string): boolean { +function hasServerSignals(contents: string): boolean { + const source = contents.replace(/^\s*(?:\/[/*]|\*|#).*$/gm, ''); return ( - (/\bnew\s+(?:McpServer|Server)\s*\(/.test(source) && + (/\bnew\s+(?:McpServer|Server)\s*(?:<[^;]+?>\s*)?\(/.test(source) && /@modelcontextprotocol\/(?:sdk\/server|server)/.test(source)) || + (/\bnew\s+FastMCP\s*(?:<[^;]+?>\s*)?\(/.test(source) && + /from\s+['"]fastmcp['"]/.test(source)) || + (/\bnew\s+MCPServer\s*(?:<[^;]+?>\s*)?\(/.test(source) && + /from\s+['"]@mastra\/mcp['"]/.test(source)) || (/\b(?:FastMCP|MCPServer|Server)\s*\(/.test(source) && /\bfrom\s+(?:mcp\.server(?:\.[\w.]+)?|fastmcp)\s+import\b/.test( source, @@ -90,24 +95,42 @@ export async function findMcpServers( directory: string, ): Promise { const target = resolveMcpTarget(directory, '.'); - const files = await boundedGlob('**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,py}', { + const sourceExtensions = '{ts,tsx,mts,cts,js,jsx,mjs,cjs,py}'; + const options = { cwd: target.directory, deep: 6, limit: 500, extraIgnore: [ - '**/tests/**', - '**/__tests__/**', - '**/e2e/**', + '**/{test,tests,__tests__,__fixtures__,fixtures,integration-tests,e2e,_test-utils}/**', '**/*.test.*', '**/*.spec.*', + '**/test_*.py', + '**/*_test.py', '**/*.d.ts', ], - }); + }; + const [likelyFiles, otherFiles] = await Promise.all([ + boundedGlob( + [ + `**/*{mcp,Mcp,MCP}*/**/*.${sourceExtensions}`, + `**/*{server,Server}*.${sourceExtensions}`, + ], + options, + ), + boundedGlob(`**/*.${sourceExtensions}`, options), + ]); + const files = [...new Set([...likelyFiles, ...otherFiles])]; const candidates = files .filter((file) => { const source = readFileHead(join(target.directory, file), 64 * 1024); return source !== null && hasServerSignals(source); }) - .sort(); + .sort((left, right) => { + const examplePath = /(?:^|\/)(?:examples?|templates?|demos?)(?:\/|$)/; + return ( + Number(examplePath.test(left)) - Number(examplePath.test(right)) || + left.localeCompare(right) + ); + }); return { directory: target.directory, candidates }; } From 90863ee27c0dc9b5e672e3eddaa1ae3cebed691f Mon Sep 17 00:00:00 2001 From: Lucas Faria Date: Wed, 9 Sep 2026 16:54:58 -0300 Subject: [PATCH 4/4] fix(mcp-analytics): suggest apps using shared server factories Group JavaScript entry points by runnable application package and follow runtime workspace dependencies to include factory consumers. Exclude client apps and development-only relationships. Route shared-library-only scans and MCP launchers through guided discovery instead of selecting a helper file. Pass scoped app/source-tracing instructions to the agent and document the bounded metadata scan. Keep Python file suggestions and manual recovery. Validation: 2,612 unit tests, build/security smoke checks, lint and screen checks passed. 35 keyboard flows passed on six public repos across eight starting directories. Typecheck matches all 26 clean-base diagnostics. Full agent installation, runtime wrapper compatibility and ingestion remain outside this validation. --- docs/mcp-analytics-onboarding.md | 17 ++ scripts/check-mcp-onboarding.no-jest.tsx | 31 +++ .../__tests__/mcp-analytics-packages.test.ts | 192 ++++++++++++++++++ .../programs/__tests__/mcp-analytics.test.ts | 44 +++- src/lib/programs/mcp-analytics/detect.ts | 46 +++-- src/lib/programs/mcp-analytics/index.ts | 15 +- src/lib/programs/mcp-analytics/packages.ts | 180 ++++++++++++++++ .../tui/screens/McpAnalyticsIntroScreen.tsx | 44 ++-- 8 files changed, 538 insertions(+), 31 deletions(-) create mode 100644 src/lib/programs/__tests__/mcp-analytics-packages.test.ts create mode 100644 src/lib/programs/mcp-analytics/packages.ts diff --git a/docs/mcp-analytics-onboarding.md b/docs/mcp-analytics-onboarding.md index 2d3e3956c..4a5660c75 100644 --- a/docs/mcp-analytics-onboarding.md +++ b/docs/mcp-analytics-onboarding.md @@ -24,6 +24,23 @@ monorepos. Application packages sort before examples and templates. Test fixtures and comment-only examples do not become suggested servers. Aliases, deeper trees, and files beyond these bounds can still require agent discovery. +The wizard also reads up to 500 package/deployment metadata paths. For +JavaScript workspaces, it groups related server files into one application +suggestion using `package.json` executable/start metadata or a Wrangler config, +server/factory usage, and runtime package dependencies. Development-only +dependencies and client-only consumers do not establish server applications. +Package names are shown beside their locations; choosing an application lets the +agent resolve its entry points and follow workspace imports while keeping +changes scoped to that application. Python entry-point suggestions remain +file-based. + +Shared libraries help identify their consumers rather than becoming the default +installation target. If only shared code is found, agent discovery finds the +runnable app first. An MCP launcher with no local implementation gets a source +tracing path: the agent checks imports and package metadata, avoids editing +installed dependencies, and explains the source location when it is outside the +checkout. These hints are local heuristics, not proof of runtime support. + Entering a directory scans that location again. Selecting a file uses its closest ancestor project directory and asks the agent to verify the selected entry point. Manually selected files and ambiguous matches get a review before diff --git a/scripts/check-mcp-onboarding.no-jest.tsx b/scripts/check-mcp-onboarding.no-jest.tsx index 72ce303bd..0ce15e606 100644 --- a/scripts/check-mcp-onboarding.no-jest.tsx +++ b/scripts/check-mcp-onboarding.no-jest.tsx @@ -24,6 +24,10 @@ import { MCP_TARGET_KEY, } from '@lib/programs/mcp-analytics/setup'; import { analytics } from '@utils/analytics'; +import { + McpDiscoveryHint, + type McpPackageSuggestions, +} from '@lib/programs/mcp-analytics/packages'; export async function checkMcpOnboarding(): Promise { const root = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-onboarding-'))); @@ -113,11 +117,13 @@ export async function checkMcpOnboarding(): Promise { const reset = async ( key: string, candidates: string[] = [], + extras: Partial = {}, ): Promise => { store.session = buildSession({ installDir: root }); store.setFrameworkContext(MCP_SCAN_KEY, { directory: root, candidates, + ...extras, }); screen.rerender( { await press('\r'); assert.equal(store.session.setupConfirmed, true); + await reset('application-package', ['server'], { + packageNames: { server: 'example-tools' }, + }); + shows('example-tools (server)'); + await press('\r'); + assert.deepEqual(store.session.frameworkContext[MCP_TARGET_KEY], { + directory: server, + }); + assert.equal(store.session.setupConfirmed, true); + + await reset('launcher', [], { discoveryHint: McpDiscoveryHint.Launcher }); + shows('This looks like an MCP launcher'); + shows('Find server source and set up analytics'); + await press('\r'); + assert.equal(store.session.setupConfirmed, true); + assert.equal(store.session.installDir, root); + + await reset('shared-library', [], { + discoveryHint: McpDiscoveryHint.SharedLibrary, + }); + shows('Found shared MCP code'); + await press('\r'); + assert.equal(store.session.setupConfirmed, true); + assert.equal(store.session.installDir, root); + await reset('manual-file'); await press('\u001b[B'); await press('\r'); diff --git a/src/lib/programs/__tests__/mcp-analytics-packages.test.ts b/src/lib/programs/__tests__/mcp-analytics-packages.test.ts new file mode 100644 index 000000000..230d7d4ac --- /dev/null +++ b/src/lib/programs/__tests__/mcp-analytics-packages.test.ts @@ -0,0 +1,192 @@ +import { + mkdtempSync, + mkdirSync, + writeFileSync, + rmSync, + realpathSync, +} from 'fs'; +import { tmpdir } from 'os'; +import { dirname, join } from 'path'; +import { findMcpServers } from '../mcp-analytics/detect'; + +vi.mock('@utils/analytics', () => ({ + analytics: { captureException: vi.fn() }, +})); +let root: string; +beforeEach(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), 'mcp-packages-'))); +}); +afterEach(() => { + rmSync(root, { recursive: true, force: true }); +}); +function file(name: string, contents: string): void { + mkdirSync(dirname(join(root, name)), { recursive: true }); + writeFileSync(join(root, name), contents); +} +function manifest(directory: string, data: Record): void { + file(`${directory}/package.json`, JSON.stringify(data)); +} +const server = + "import { Server } from '@modelcontextprotocol/server'; export const createServer = () => new Server({name: 'example'});"; + +describe('MCP application suggestions', () => { + it('suggests deployable workspace consumers instead of their shared factory', async () => { + manifest('packages/common', { + name: '@example/common', + exports: './src/server.ts', + }); + file('packages/common/src/server.ts', server); + manifest('packages/bridge', { + name: '@example/bridge', + dependencies: { '@example/common': 'workspace:*' }, + }); + for (const app of ['search', 'reports']) { + manifest(`apps/${app}`, { + name: `example-${app}`, + dependencies: { '@example/bridge': 'workspace:*' }, + }); + file(`apps/${app}/wrangler.jsonc`, '{"main":"src/app.ts"}'); + file( + `apps/${app}/src/app.ts`, + "import { createMcpApp } from '@example/bridge'; export default createMcpApp();", + ); + } + manifest('apps/runtime-client', { + name: 'example-client', + scripts: { start: 'node index.js' }, + dependencies: { '@example/common': 'workspace:*' }, + }); + file( + 'apps/runtime-client/src/index.ts', + "import { MCPClient } from '@example/common'; new MCPClient({});", + ); + manifest('apps/client', { + name: 'client', + scripts: { start: 'node index.js' }, + devDependencies: { '@example/common': 'workspace:*' }, + }); + const scan = await findMcpServers(root); + expect(scan.candidates).toEqual(['apps/reports', 'apps/search']); + expect(scan.packageNames).toEqual({ + 'apps/reports': 'example-reports', + 'apps/search': 'example-search', + }); + }); + + it('groups transport files and includes a sibling server that calls a shared factory', async () => { + manifest('packages/common', { + name: '@example/common', + main: './dist/index.js', + }); + file('packages/common/src/server.ts', server); + for (const app of ['database', 'api']) { + manifest(`packages/${app}`, { + name: `@example/${app}`, + bin: `dist/cli.js`, + dependencies: { '@example/common': 'workspace:^' }, + }); + file( + `packages/${app}/src/cli.ts`, + "import { createMcpServer } from '@example/common'; createMcpServer();", + ); + } + file( + 'packages/database/src/http.ts', + 'createMcpHandler(() => makeServer())', + ); + file( + 'packages/database/src/local.ts', + 'createMcpHandler(() => makeServer())', + ); + expect((await findMcpServers(root)).candidates).toEqual([ + 'packages/api', + 'packages/database', + ]); + }); + + it('does not present a shared library as an unambiguous server app', async () => { + manifest('.', { name: '@example/common', main: './dist/index.js' }); + file('src/server.ts', server); + const scan = await findMcpServers(root); + expect(scan.candidates).toEqual([]); + expect(scan.discoveryHint).toBe('shared_library'); + }); + + it('explains MCP launchers whose implementation comes from a dependency', async () => { + manifest('.', { + name: '@example/mcp', + mcpName: 'example/mcp', + bin: 'cli.js', + dependencies: { 'example-engine': '^1.0.0' }, + }); + file('cli.js', "const { serve } = require('example-engine/mcp'); serve();"); + const scan = await findMcpServers(root); + expect(scan.candidates).toEqual([]); + expect(scan.discoveryHint).toBe('launcher'); + }); + + it('keeps an ordinary client out of the server suggestions', async () => { + manifest('.', { + name: 'example-app', + scripts: { start: 'node src/client.js' }, + dependencies: { '@modelcontextprotocol/sdk': '^1.29.0' }, + }); + file( + 'src/client.js', + "import { Client } from '@modelcontextprotocol/sdk/client/index.js'; new Client({});", + ); + const scan = await findMcpServers(root); + expect(scan.candidates).toEqual([]); + expect(scan.discoveryHint).toBeUndefined(); + }); + + it('groups a standalone runnable server without requiring generated bin files', async () => { + manifest('.', { + name: 'example-server', + bin: { 'example-mcp': 'dist/main.js' }, + }); + file('src/index.ts', server); + expect((await findMcpServers(root)).candidates).toEqual(['.']); + }); + + it('recognizes a nested worker whose workspace dependency is outside the scan', async () => { + manifest('.', { + name: 'example-worker', + dependencies: { '@example/mcp-common': 'workspace:*' }, + }); + file('wrangler.toml', 'main = "src/app.ts"'); + file( + 'src/app.ts', + "import { createMcpApp } from '@example/mcp-common'; export default createMcpApp();", + ); + expect((await findMcpServers(root)).candidates).toEqual(['.']); + }); + + it('resolves consumers across cyclic workspace dependencies without suggesting clients', async () => { + manifest('packages/one', { + name: '@example/one', + dependencies: { '@example/two': 'workspace:*' }, + }); + manifest('packages/two', { + name: '@example/two', + dependencies: { '@example/one': 'workspace:*' }, + }); + file('packages/one/src/server.ts', server); + manifest('apps/tools', { + name: 'example-tools', + bin: 'dist/cli.js', + dependencies: { '@example/two': 'workspace:*' }, + }); + file( + 'apps/tools/src/cli.ts', + "import { createMcpServer } from '@example/two'; createMcpServer();", + ); + expect((await findMcpServers(root)).candidates).toEqual(['apps/tools']); + }); + + it('keeps discovery useful when a manifest is malformed', async () => { + file('package.json', '{'); + file('src/server.ts', server); + expect((await findMcpServers(root)).candidates).toEqual(['src/server.ts']); + }); +}); diff --git a/src/lib/programs/__tests__/mcp-analytics.test.ts b/src/lib/programs/__tests__/mcp-analytics.test.ts index d9731039b..62d5bd32f 100644 --- a/src/lib/programs/__tests__/mcp-analytics.test.ts +++ b/src/lib/programs/__tests__/mcp-analytics.test.ts @@ -9,7 +9,10 @@ import { } from '@lib/wizard-session'; import type { ProgramRun } from '@lib/agent/agent-runner'; import { HostResolution } from '@lib/host-resolution'; -import { MCP_TARGET_KEY } from '@lib/programs/mcp-analytics/setup'; +import { + MCP_TARGET_KEY, + MCP_SCAN_KEY, +} from '@lib/programs/mcp-analytics/setup'; const credentials = { projectId: 42, @@ -86,6 +89,45 @@ describe('mcpAnalyticsConfig', () => { expect(prompt).not.toContain('The user selected'); }); + it('keeps a selected application scoped while following workspace imports', async () => { + const session = buildSession({ installDir: '/example/apps/search' }); + session.frameworkContext[MCP_TARGET_KEY] = { + directory: '/example/apps/search', + }; + const prompt = (await runConfig(session)).customPrompt?.(credentials); + expect(prompt).toContain('follow local workspace imports'); + expect(prompt).toContain('do not instrument every consumer'); + }); + + it.each([ + [ + 'launcher', + 'Trace its imports and package metadata', + 'Do not edit node_modules', + ], + [ + 'shared_library', + 'Find the runnable consumer', + 'Do not treat the shared constructor', + ], + ])( + 'passes %s discovery guidance to the agent', + async (discoveryHint, expected, guard) => { + const session = buildSession({ installDir: '/example/server' }); + session.frameworkContext[MCP_SCAN_KEY] = { + directory: '/example/server', + candidates: [], + discoveryHint, + }; + session.frameworkContext[MCP_TARGET_KEY] = { + directory: '/example/server', + }; + const prompt = (await runConfig(session)).customPrompt?.(credentials); + expect(prompt).toContain(expected); + expect(prompt).toContain(guard); + }, + ); + it('links completion to the authenticated project and explains how to send data', async () => { const session = buildSession({}); const run = await runConfig(session); diff --git a/src/lib/programs/mcp-analytics/detect.ts b/src/lib/programs/mcp-analytics/detect.ts index 5ff7e3ffc..939df533b 100644 --- a/src/lib/programs/mcp-analytics/detect.ts +++ b/src/lib/programs/mcp-analytics/detect.ts @@ -2,6 +2,7 @@ import { accessSync, constants, existsSync, realpathSync, statSync } from 'fs'; import { homedir } from 'os'; import { dirname, extname, join, resolve } from 'path'; import { boundedGlob, readFileHead } from '@utils/bounded-fs'; +import { suggestMcpPackages, type McpPackageSuggestions } from './packages'; const SOURCE_EXTENSIONS = new Set([ '.ts', @@ -23,7 +24,7 @@ const PROJECT_MARKERS = [ ]; export type McpTarget = { directory: string; entryPoint?: string }; -export type McpServerScan = { directory: string; candidates: string[] }; +export type McpServerScan = { directory: string } & McpPackageSuggestions; export function resolveMcpTarget( baseDirectory: string, @@ -73,8 +74,7 @@ export function resolveMcpTarget( // These are suggestions, not an eligibility check: custom dispatchers and // aliased constructors still have the explicit-path and agent-search routes. -function hasServerSignals(contents: string): boolean { - const source = contents.replace(/^\s*(?:\/[/*]|\*|#).*$/gm, ''); +function hasServerSignals(source: string): boolean { return ( (/\bnew\s+(?:McpServer|Server)\s*(?:<[^;]+?>\s*)?\(/.test(source) && /@modelcontextprotocol\/(?:sdk\/server|server)/.test(source)) || @@ -120,17 +120,31 @@ export async function findMcpServers( boundedGlob(`**/*.${sourceExtensions}`, options), ]); const files = [...new Set([...likelyFiles, ...otherFiles])]; - const candidates = files - .filter((file) => { - const source = readFileHead(join(target.directory, file), 64 * 1024); - return source !== null && hasServerSignals(source); - }) - .sort((left, right) => { - const examplePath = /(?:^|\/)(?:examples?|templates?|demos?)(?:\/|$)/; - return ( - Number(examplePath.test(left)) - Number(examplePath.test(right)) || - left.localeCompare(right) - ); - }); - return { directory: target.directory, candidates }; + const sourceCandidates: string[] = []; + const factoryFiles: string[] = []; + for (const file of files) { + const contents = readFileHead(join(target.directory, file), 64 * 1024); + if (contents === null) continue; + const source = contents.replace(/^\s*(?:\/[/*]|\*|#).*$/gm, ''); + if (hasServerSignals(source)) sourceCandidates.push(file); + if ( + /\bcreate\w*(?:Mcp|MCP)(?:Server|App|Handler)\s*(?:<[^;]+?>\s*)?\(/.test( + source, + ) + ) + factoryFiles.push(file); + } + const suggestions = await suggestMcpPackages( + sourceCandidates, + factoryFiles, + options, + ); + const candidates = suggestions.candidates.sort((left, right) => { + const examplePath = /(?:^|\/)(?:examples?|templates?|demos?)(?:\/|$)/; + return ( + Number(examplePath.test(left)) - Number(examplePath.test(right)) || + left.localeCompare(right) + ); + }); + return { ...suggestions, directory: target.directory, candidates }; } diff --git a/src/lib/programs/mcp-analytics/index.ts b/src/lib/programs/mcp-analytics/index.ts index 03bd3083f..90d7fdaf0 100644 --- a/src/lib/programs/mcp-analytics/index.ts +++ b/src/lib/programs/mcp-analytics/index.ts @@ -5,7 +5,8 @@ import { OutroKind, type WizardSession } from '@lib/wizard-session'; import { relative } from 'path'; import { ErrorCodes } from '@lib/errors'; import { createSkillProgram } from '@lib/programs/agent-skill/index'; -import type { McpTarget } from './detect'; +import type { McpTarget, McpServerScan } from './detect'; +import { McpDiscoveryHint } from './packages'; import { MCP_SCAN_KEY, MCP_SCAN_ERROR_KEY, @@ -118,6 +119,9 @@ async function buildRun(session: WizardSession): Promise { const target = session.frameworkContext[MCP_TARGET_KEY] as | McpTarget | undefined; + const scan = session.frameworkContext[MCP_SCAN_KEY] as + | McpServerScan + | undefined; return { ...run, customPrompt: (ctx) => @@ -127,6 +131,15 @@ async function buildRun(session: WizardSession): Promise { ? `The user selected this server entry point: ${JSON.stringify( relative(session.installDir, target.entryPoint), )}. Verify it and instrument this server.` + : target + ? 'The user selected this application directory. Resolve its launch and deployment entry points and follow local workspace imports. Keep instrumentation scoped to this application; do not instrument every consumer of a shared factory.' + : undefined, + !target?.entryPoint && scan?.discoveryHint === McpDiscoveryHint.Launcher + ? 'This project appears to be an MCP launcher. Trace its imports and package metadata to locate the implementation source. Do not edit node_modules or generated dependency code. If the implementation is outside this checkout, explain where its source lives and what is needed to instrument it; do not report that no MCP server exists merely because this checkout delegates to a dependency.' + : undefined, + !target?.entryPoint && + scan?.discoveryHint === McpDiscoveryHint.SharedLibrary + ? 'The quick scan found shared MCP library code, not an unambiguous server application. Find the runnable consumer before choosing where to instrument. Do not treat the shared constructor as consent to instrument all consumers.' : undefined, ] .filter(Boolean) diff --git a/src/lib/programs/mcp-analytics/packages.ts b/src/lib/programs/mcp-analytics/packages.ts new file mode 100644 index 000000000..23223a1bc --- /dev/null +++ b/src/lib/programs/mcp-analytics/packages.ts @@ -0,0 +1,180 @@ +import { dirname, extname, join } from 'path'; +import { + boundedGlob, + readProjectFile, + type BoundedGlobOptions, +} from '@utils/bounded-fs'; + +export enum McpDiscoveryHint { + SharedLibrary = 'shared_library', + Launcher = 'launcher', +} + +export type McpPackageSuggestions = { + candidates: string[]; + packageNames?: Record; + discoveryHint?: McpDiscoveryHint; +}; + +type ProjectPackage = { + directory: string; + name?: string; + dependencies: Record; + runnable: boolean; + library: boolean; + mcpIdentity: boolean; +}; + +function record(value: unknown): Record { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : {}; +} + +function strings(value: unknown): Record { + return Object.fromEntries( + Object.entries(record(value)).filter( + (entry): entry is [string, string] => typeof entry[1] === 'string', + ), + ); +} + +function readPackage( + directory: string, + path: string, + deployments: Set, +): ProjectPackage | undefined { + const source = readProjectFile(join(directory, path), 64 * 1024); + if (source === null) return; + try { + const data = record(JSON.parse(source)); + const packageDir = dirname(path); + const name = typeof data.name === 'string' ? data.name : undefined; + const bin = + typeof data.bin === 'string' + ? [data.bin] + : Object.values(strings(data.bin)); + const commands = strings(data.scripts); + return { + directory: packageDir, + name, + dependencies: { + ...strings(data.dependencies), + ...strings(data.optionalDependencies), + ...strings(data.peerDependencies), + }, + runnable: + bin.length > 0 || !!commands.start || deployments.has(packageDir), + library: !!data.main || !!data.exports, + mcpIdentity: + typeof data.mcpName === 'string' || + /(?:^|[-/@])mcp(?:[-/]|$)/i.test(name ?? '') || + Object.keys(record(data.bin)).some((key) => /mcp/i.test(key)), + }; + } catch { + return; + } +} + +export async function suggestMcpPackages( + sourceCandidates: string[], + factoryFiles: string[], + options: BoundedGlobOptions, +): Promise { + const metadata = await boundedGlob( + ['**/package.json', '**/wrangler.{json,jsonc,toml}'], + options, + ); + const deployments = new Set( + metadata.filter((path) => !path.endsWith('package.json')).map(dirname), + ); + const packages = metadata + .filter((path) => path.endsWith('package.json')) + .map((path) => readPackage(options.cwd, path, deployments)) + .filter((pkg): pkg is ProjectPackage => !!pkg) + .sort((a, b) => b.directory.length - a.directory.length); + const owner = (file: string): ProjectPackage | undefined => + extname(file) === '.py' + ? undefined + : packages.find( + (pkg) => + pkg.directory === '.' || file.startsWith(`${pkg.directory}/`), + ); + const byName = new Map( + packages.filter((pkg) => pkg.name).map((pkg) => [pkg.name, pkg]), + ); + const direct = new Set( + sourceCandidates.map(owner).filter((pkg): pkg is ProjectPackage => !!pkg), + ); + const factories = new Set(factoryFiles.map(owner)); + const related = new Set(direct); + for (const pkg of packages) { + const dependencies = Object.entries(pkg.dependencies); + if ( + pkg.runnable && + ((pkg.mcpIdentity && + dependencies.some( + ([name]) => name === '@modelcontextprotocol/server', + )) || + dependencies.some( + ([name, version]) => + version.startsWith('workspace:') && + /mcp/i.test(name) && + !byName.has(name), + )) + ) + related.add(pkg); + } + let changed = true; + while (changed) { + changed = false; + for (const pkg of packages) { + if ( + !related.has(pkg) && + Object.keys(pkg.dependencies).some((name) => { + const dependency = byName.get(name); + return dependency && related.has(dependency); + }) + ) { + related.add(pkg); + changed = true; + } + } + } + const apps = new Set( + [...related].filter( + (pkg) => pkg.runnable && (direct.has(pkg) || factories.has(pkg)), + ), + ); + const consumedNames = new Set( + packages.flatMap((pkg) => Object.keys(pkg.dependencies)), + ); + const shared = new Set( + [...related].filter( + (pkg) => + !pkg.runnable && + (pkg.library || (pkg.name && consumedNames.has(pkg.name))), + ), + ); + const candidates = new Set(); + for (const file of sourceCandidates) { + const pkg = owner(file); + if (pkg && apps.has(pkg)) candidates.add(pkg.directory); + else if (!pkg || !shared.has(pkg)) candidates.add(file); + } + const packageNames: Record = {}; + for (const pkg of apps) { + candidates.add(pkg.directory); + if (pkg.name) packageNames[pkg.directory] = pkg.name; + } + const root = packages.find((pkg) => pkg.directory === '.'); + return { + candidates: [...candidates], + ...(Object.keys(packageNames).length ? { packageNames } : {}), + ...(!candidates.size && shared.size + ? { discoveryHint: McpDiscoveryHint.SharedLibrary } + : !candidates.size && root?.runnable && root.mcpIdentity + ? { discoveryHint: McpDiscoveryHint.Launcher } + : {}), + }; +} diff --git a/src/ui/tui/screens/McpAnalyticsIntroScreen.tsx b/src/ui/tui/screens/McpAnalyticsIntroScreen.tsx index a665f4f53..dfe7f2dc5 100644 --- a/src/ui/tui/screens/McpAnalyticsIntroScreen.tsx +++ b/src/ui/tui/screens/McpAnalyticsIntroScreen.tsx @@ -19,6 +19,7 @@ import { MCP_TARGET_KEY, } from '@lib/programs/mcp-analytics/setup'; import { analytics } from '@utils/analytics'; +import { McpDiscoveryHint } from '@lib/programs/mcp-analytics/packages'; import { IntroScreenLayout } from './IntroScreenLayout'; enum View { @@ -138,7 +139,11 @@ export function McpAnalyticsIntroScreen({ const waiting = !scan && !scanError; const candidates = scan?.candidates ?? []; - const suggestedFile = candidates.length === 1 ? candidates[0] : undefined; + const suggestedPath = candidates.length === 1 ? candidates[0] : undefined; + const candidateLabel = (path: string): string => { + const name = scan?.packageNames?.[path]; + return name ? (path === '.' ? name : `${name} (${path})`) : path; + }; const menuOptions = busy || waiting || view === View.Path ? null @@ -150,16 +155,19 @@ export function McpAnalyticsIntroScreen({ : view === View.Connect ? [{ label: 'Back', value: 'back' }] : [ - ...(suggestedFile + ...(suggestedPath ? [{ label: 'Set up MCP analytics', value: 'start' }] : candidates.map((file) => ({ - label: file, - value: `file:${file}`, + label: candidateLabel(file), + value: `target:${file}`, }))), - ...(!scanError && !suggestedFile + ...(!scanError && !suggestedPath ? [ { - label: 'Find my server and set up analytics', + label: + scan?.discoveryHint === McpDiscoveryHint.Launcher + ? 'Find server source and set up analytics' + : 'Find my server and set up analytics', value: 'search', }, ] @@ -179,10 +187,10 @@ export function McpAnalyticsIntroScreen({ menuAlign="left" menuOptions={menuOptions} onSelect={(value) => { - if (value === 'start' && suggestedFile) - void selectPath(suggestedFile, 'suggested', true); - else if (value.startsWith('file:')) - void selectPath(value.slice(5), 'suggested'); + if (value === 'start' && suggestedPath) + void selectPath(suggestedPath, 'suggested', true); + else if (value.startsWith('target:')) + void selectPath(value.slice(7), 'suggested'); else if (value === 'path') { setError(null); setView(View.Path); @@ -212,7 +220,7 @@ export function McpAnalyticsIntroScreen({ Server:{' '} {target.entryPoint ? relative(target.directory, target.entryPoint) - : 'The agent will find the entry point'} + : 'The agent will resolve this app’s entry points'} The agent will verify this server before changing its code. @@ -243,8 +251,18 @@ export function McpAnalyticsIntroScreen({ Looking for MCP server entry points... ) : scanError ? ( {scanError} - ) : suggestedFile ? ( - Server: {suggestedFile} + ) : scan?.discoveryHint === McpDiscoveryHint.Launcher ? ( + + This looks like an MCP launcher. The agent will trace the + server’s source before choosing where to install analytics. + + ) : scan?.discoveryHint === McpDiscoveryHint.SharedLibrary ? ( + + Found shared MCP code. The agent will find the app that uses + it before choosing where to install analytics. + + ) : suggestedPath ? ( + Server: {candidateLabel(suggestedPath)} ) : candidates.length ? ( Found several possible servers. Choose one to set up: