diff --git a/docs/mcp-analytics-onboarding.md b/docs/mcp-analytics-onboarding.md new file mode 100644 index 00000000..4a5660c7 --- /dev/null +++ b/docs/mcp-analytics-onboarding.md @@ -0,0 +1,59 @@ +# 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. 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. 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. + +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 +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. + +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 71918cdc..1de6f5b5 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 360bd3ec..add8a11b 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 00000000..0ce15e60 --- /dev/null +++ b/scripts/check-mcp-onboarding.no-jest.tsx @@ -0,0 +1,232 @@ +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'; +import { + McpDiscoveryHint, + type McpPackageSuggestions, +} from '@lib/programs/mcp-analytics/packages'; + +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('The quick scan didn’t find a server'); + await press('\u001b[B'); + 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('\u001b[B'); + 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'); + 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, + 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, + candidates: string[] = [], + extras: Partial = {}, + ): Promise => { + store.session = buildSession({ installDir: root }); + store.setFrameworkContext(MCP_SCAN_KEY, { + directory: root, + candidates, + ...extras, + }); + 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('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'); + 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('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, + }); + 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 bfc2871b..2f524aa8 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 00000000..74747481 --- /dev/null +++ b/src/lib/programs/__tests__/mcp-analytics-detect.test.ts @@ -0,0 +1,164 @@ +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'});", + ], + [ + '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")', + ], + ['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 };', + ], + ])('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('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'), + ); + 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); + 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-packages.test.ts b/src/lib/programs/__tests__/mcp-analytics-packages.test.ts new file mode 100644 index 00000000..230d7d4a --- /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 e9a8546d..62d5bd32 100644 --- a/src/lib/programs/__tests__/mcp-analytics.test.ts +++ b/src/lib/programs/__tests__/mcp-analytics.test.ts @@ -2,6 +2,32 @@ 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, + MCP_SCAN_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 +64,81 @@ 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('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); + 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 00000000..939df533 --- /dev/null +++ b/src/lib/programs/mcp-analytics/detect.ts @@ -0,0 +1,150 @@ +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', + '.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 } & McpPackageSuggestions; + +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*(?:<[^;]+?>\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, + )) || + /\bcreateMcpHandler\s*\(/.test(source) || + (/["']tools\/call["']/.test(source) && /["']tools\/list["']/.test(source)) + ); +} + +export async function findMcpServers( + directory: string, +): Promise { + const target = resolveMcpTarget(directory, '.'); + const sourceExtensions = '{ts,tsx,mts,cts,js,jsx,mjs,cjs,py}'; + const options = { + cwd: target.directory, + deep: 6, + limit: 500, + extraIgnore: [ + '**/{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 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 d5ed1812..90d7fdaf 100644 --- a/src/lib/programs/mcp-analytics/index.ts +++ b/src/lib/programs/mcp-analytics/index.ts @@ -1,6 +1,18 @@ 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, McpServerScan } from './detect'; +import { McpDiscoveryHint } from './packages'; +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 +66,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 +75,102 @@ 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; + const scan = session.frameworkContext[MCP_SCAN_KEY] as + | McpServerScan + | 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.` + : 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) + .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/packages.ts b/src/lib/programs/mcp-analytics/packages.ts new file mode 100644 index 00000000..23223a1b --- /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/lib/programs/mcp-analytics/setup.ts b/src/lib/programs/mcp-analytics/setup.ts new file mode 100644 index 00000000..92534d60 --- /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 08d21b3b..e9d74e25 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 6b6b36cf..084f16a5 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 00000000..dfe7f2dc --- /dev/null +++ b/src/ui/tui/screens/McpAnalyticsIntroScreen.tsx @@ -0,0 +1,284 @@ +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 { McpDiscoveryHint } from '@lib/programs/mcp-analytics/packages'; +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 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; + 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 if (startSetup) { + completeTarget(resolved, selectionSource); + } 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; + completeTarget(target, source); + }; + + 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 candidates = scan?.candidates ?? []; + 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 + : view === View.Review + ? [ + { label: 'Instrument this server', value: 'confirm' }, + { label: 'Choose another location', value: 'back' }, + ] + : view === View.Connect + ? [{ label: 'Back', value: 'back' }] + : [ + ...(suggestedPath + ? [{ label: 'Set up MCP analytics', value: 'start' }] + : candidates.map((file) => ({ + label: candidateLabel(file), + value: `target:${file}`, + }))), + ...(!scanError && !suggestedPath + ? [ + { + label: + scan?.discoveryHint === McpDiscoveryHint.Launcher + ? 'Find server source and set up analytics' + : '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' }, + ]; + + return ( + { + 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); + } else if (value === 'search') + void selectPath('.', 'agent_search', true); + 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 resolve this app’s entry points'} + + + 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. + + We’ll find the integration and configure it for you. + + Directory: {directory} + {waiting ? ( + Looking for MCP server entry points... + ) : scanError ? ( + {scanError} + ) : 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: + + ) : ( + + The quick scan didn’t find a server. The agent will search + this directory and verify your setup before changing code. + + )} + + )} + {busy && Checking...} + {error && {error}} + + } + /> + ); +} diff --git a/src/ui/tui/store.ts b/src/ui/tui/store.ts index b8507ca0..33ee0b29 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 ─────────────────────────────────────────────── /**