diff --git a/docs/content/2.adapters/7.mcp.md b/docs/content/2.adapters/7.mcp.md index b45adf13..8a51d898 100644 --- a/docs/content/2.adapters/7.mcp.md +++ b/docs/content/2.adapters/7.mcp.md @@ -107,7 +107,7 @@ Two gateway tools (`devframe:connect:*` ids; see [tool ids and wire names](/guid - **`devframe_connect_list-instances`**: list running dev servers and their MCP tools. - **`devframe_connect_call-tool`**: invoke one tool on a running devframe (`{ port, tool, args }`) over Streamable-HTTP. -Discovery reads the **instance registry**: every `createDevServer` writes `~/.devframe/instances/-.json`, dialed with a loopback origin. In-process host frameworks register via `registerDevframeInstance` (`devframe/node`). `--port ` probes a port; `DEVFRAME_INSTANCES_DIR` relocates the registry, `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts out. +Discovery reads the **instance registry**: every `createDevServer` writes `~/.devframe/instances/-.json`, dialed with a loopback origin. In-process host frameworks register via `registerDevframeInstance` (`devframe/node`). `--port ` probes a port for `__connection.json` at its root; add `--base ` for a devframe mounted below the root (e.g. `devframe connect --port 5173 --base /__devtools/` for Vite DevTools). A probe only counts a JSON object as connection meta, so an SPA fallback page on the wrong path reads as "no instance". `DEVFRAME_INSTANCES_DIR` relocates the registry, `DEVFRAME_DISABLE_INSTANCE_REGISTRY=1` opts out. The connector needs the same optional `@devframes/agentic` peer as the adapter; `devframe connect` without it throws [DF0046](/errors/DF0046). diff --git a/packages/agentic/src/connect/__tests__/connect.test.ts b/packages/agentic/src/connect/__tests__/connect.test.ts index 6eadea7c..c86cf827 100644 --- a/packages/agentic/src/connect/__tests__/connect.test.ts +++ b/packages/agentic/src/connect/__tests__/connect.test.ts @@ -1,9 +1,12 @@ import type { DevframeInstanceRecord, StartedServer } from 'devframe/internal' import type { DevframeDefinition } from 'devframe/types' +import type { Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { createServer } from 'node:http' import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client' import { createDevServer } from 'devframe/adapters/dev' import { afterEach, describe, expect, it } from 'vitest' -import { buildInstanceRequestHeaders, resolveAuthToken } from '../index' +import { buildInstanceRequestHeaders, probePort, resolveAuthToken } from '../index' const TOKEN = 'a-high-entropy-connect-test-token' @@ -59,6 +62,52 @@ describe('buildInstanceRequestHeaders', () => { }) }) +describe('probePort', () => { + let server: Server | undefined + + afterEach(async () => { + await new Promise(resolve => (server ? server.close(() => resolve()) : resolve())) + server = undefined + }) + + // A Vite-like host: the hub's meta lives under `/__devtools/` and every + // other path falls back to the app's `index.html` with `200 text/html`. + async function startBasedHub(): Promise { + server = createServer((req, res) => { + if (req.url === '/__devtools/__connection.json') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{"backend":"websocket","mcp":{"path":"__mcp"}}') + return + } + res.writeHead(200, { 'content-type': 'text/html' }) + res.end('') + }) + await new Promise(resolve => server!.listen(0, '127.0.0.1', resolve)) + return (server.address() as AddressInfo).port + } + + it('finds a hub mounted under a base and resolves its MCP path against it', async () => { + const port = await startBasedHub() + const record = await probePort(port, '/__devtools/', 2000) + expect(record).toMatchObject({ + port, + basePath: '/__devtools/', + mcp: { path: '/__devtools/__mcp' }, + }) + }) + + it('normalizes a base given without slashes', async () => { + const port = await startBasedHub() + const record = await probePort(port, '__devtools', 2000) + expect(record).toMatchObject({ basePath: '/__devtools/', mcp: { path: '/__devtools/__mcp' } }) + }) + + it('reports no instance (not an MCP-less one) when the root only serves the SPA fallback', async () => { + const port = await startBasedHub() + expect(await probePort(port, undefined, 2000)).toBeNull() + }) +}) + describe('connector bearer against a live authenticated MCP route', () => { let server: StartedServer | undefined diff --git a/packages/agentic/src/connect/index.ts b/packages/agentic/src/connect/index.ts index 0bf38806..24761213 100644 --- a/packages/agentic/src/connect/index.ts +++ b/packages/agentic/src/connect/index.ts @@ -6,15 +6,24 @@ import { StdioServerTransport } from '@modelcontextprotocol/server/stdio' import { diagnostics, listLiveDevframeInstances, probeDevframeOrigin } from 'devframe/internal' import { toAgentToolName } from 'devframe/utils/agent-tool-name' import { Diagnostic } from 'devframe/utils/nostics' -import { joinURL } from 'devframe/utils/url' +import { joinURL, withLeadingSlash, withTrailingSlash } from 'devframe/utils/url' export interface ConnectServerOptions { /** * Explicit ports to probe besides the registry, for instances started * before the registry existed, or reachable only by convention. Each port - * is probed at `/` (`http://localhost:/__connection.json`). + * is probed at {@link ConnectServerOptions.base} (default `/`, i.e. + * `http://localhost:/__connection.json`). */ ports?: number[] + /** + * Base path the explicit {@link ConnectServerOptions.ports} probes look for + * `__connection.json` under, for a devframe or hub mounted below the root + * of its host (e.g. `/__devtools/` for Vite DevTools). The advertised MCP + * path is resolved against it. Default `/`. Registry records carry their + * own base and ignore this. + */ + base?: string /** Override the registry directory (`DEVFRAME_INSTANCES_DIR` also applies). */ instancesDir?: string /** Probe timeout per instance, ms. Default 1000. */ @@ -164,7 +173,7 @@ async function index(options: ConnectServerOptions): Promise { for (const port of options.ports ?? []) { if (records.some(r => r.port === port)) continue - const probed = await probePort(port, options.timeoutMs) + const probed = await probePort(port, options.base, options.timeoutMs) if (probed) records.push(probed) } @@ -189,26 +198,29 @@ async function index(options: ConnectServerOptions): Promise { return { instances, ...(instances.length === 0 - ? { hint: 'No running devframe instances found. Start a devframe dev server (with --mcp for tools), or pass --port to devframe connect if the instance predates the registry.' } + ? { hint: 'No running devframe instances found. Start a devframe dev server (with --mcp for tools), or pass --port to devframe connect if the instance predates the registry (plus --base when it is mounted below the root, e.g. --base /__devtools/).' } : {}), } } /** - * Probe an explicit port for a devframe serving `__connection.json` at `/`, - * reusing the registry's origin-candidate probe (a `localhost`-bound server - * may listen on either address family). + * Probe an explicit port for a devframe serving `__connection.json` under + * `base` (default `/`), reusing the registry's origin-candidate probe (a + * `localhost`-bound server may listen on either address family). The + * advertised MCP path is relative to that base, as in the registry records + * the instance shell writes. Exported for focused tests. */ -async function probePort(port: number, timeoutMs?: number): Promise { - const probed = await probeDevframeOrigin(`http://localhost:${port}`, '/', timeoutMs) +export async function probePort(port: number, base = '/', timeoutMs?: number): Promise { + const basePath = withTrailingSlash(withLeadingSlash(base)) + const probed = await probeDevframeOrigin(`http://localhost:${port}`, basePath, timeoutMs) if (!probed) return null - const mcpPath = probed.meta.mcp ? joinURL('/', probed.meta.mcp.path) : null + const mcpPath = probed.meta.mcp ? joinURL(basePath, probed.meta.mcp.path) : null return { pid: -1, port, origin: probed.origin, - basePath: '/', + basePath, id: `port-${port}`, rootDir: '', mcp: mcpPath ? { path: mcpPath } : null, @@ -232,7 +244,7 @@ async function call( timeoutMs: options.timeoutMs, }) const record = live.find(record => record.port === args.port && record.mcp) - ?? await probePort(args.port, options.timeoutMs) + ?? await probePort(args.port, options.base, options.timeoutMs) if (!record) throw diagnostics.DF0050({ port: args.port }) if (!record.mcp) diff --git a/packages/devframe/src/cli/main.test.ts b/packages/devframe/src/cli/main.test.ts index b18e2ee5..650b0d0a 100644 --- a/packages/devframe/src/cli/main.test.ts +++ b/packages/devframe/src/cli/main.test.ts @@ -33,5 +33,6 @@ describe('runDevframeCli', () => { await runDevframeCli(['node', 'devframe', 'connect', '--help']) expect(info).toHaveBeenCalledTimes(1) expect(info.mock.calls[0]![0]).toContain('--port') + expect(info.mock.calls[0]![0]).toContain('--base ') }) }) diff --git a/packages/devframe/src/cli/main.ts b/packages/devframe/src/cli/main.ts index 984758ef..fd28c210 100644 --- a/packages/devframe/src/cli/main.ts +++ b/packages/devframe/src/cli/main.ts @@ -7,6 +7,7 @@ import { importRuntimeModule } from '../node/import-runtime-module' interface AgenticConnectModule { startConnectServer: (options: { ports?: number[] + base?: string instancesDir?: string timeoutMs?: number authToken?: string @@ -46,12 +47,14 @@ export async function runDevframeCli(argv: string[] = process.argv): Promise', 'Probe an explicit port besides the instance registry (repeatable)') + .option('--base ', 'Base path the --port probes look for __connection.json under, for a devframe mounted below the root (e.g. /__devtools/ for Vite DevTools)', { default: '/' }) .option('--instances-dir ', 'Override the instance registry directory (default: ~/.devframe/instances, or $DEVFRAME_INSTANCES_DIR)') .option('--timeout ', 'Probe timeout per instance in milliseconds', { default: 1000 }) - .action(async (options: { port?: unknown, instancesDir?: string, timeout?: number }) => { + .action(async (options: { port?: unknown, base?: unknown, instancesDir?: string, timeout?: number }) => { const { startConnectServer } = await importConnect() await startConnectServer({ ports: parsePortsFlag(options.port), + base: typeof options.base === 'string' ? options.base : undefined, instancesDir: options.instancesDir, timeoutMs: options.timeout, /** diff --git a/packages/devframe/src/node/instance-registry.test.ts b/packages/devframe/src/node/instance-registry.test.ts index 6134949b..7aa5cbe8 100644 --- a/packages/devframe/src/node/instance-registry.test.ts +++ b/packages/devframe/src/node/instance-registry.test.ts @@ -7,6 +7,7 @@ import { join } from 'pathe' import { beforeEach, describe, expect, it, vi } from 'vitest' import { listLiveDevframeInstances, + probeDevframeOrigin, readDevframeInstances, registerDevframeInstance, } from './instance-registry' @@ -142,3 +143,82 @@ describe('instance registry', () => { } }) }) + +describe('probeDevframeOrigin', () => { + // A host with an SPA fallback: every unknown path answers `200 text/html` + // (Vite serving `index.html`); the real meta lives under `/__devtools/`. + async function startSpaFallbackServer(): Promise<{ origin: string, port: number, close: () => Promise }> { + const server = createServer((req, res) => { + if (req.url === '/__devtools/__connection.json') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('{"backend":"websocket","mcp":{"path":"__mcp"}}') + return + } + if (req.url === '/array/__connection.json') { + res.writeHead(200, { 'content-type': 'application/json' }) + res.end('[]') + return + } + res.writeHead(200, { 'content-type': 'text/html' }) + res.end('
') + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const port = (server.address() as AddressInfo).port + return { + origin: `http://127.0.0.1:${port}`, + port, + close: () => new Promise(resolve => server.close(() => resolve())), + } + } + + it('does not take an HTML SPA fallback for a devframe', async () => { + const spa = await startSpaFallbackServer() + try { + expect(await probeDevframeOrigin(spa.origin, '/', 2000)).toBeNull() + } + finally { + await spa.close() + } + }) + + it('does not take a non-object JSON body for connection meta', async () => { + const spa = await startSpaFallbackServer() + try { + expect(await probeDevframeOrigin(spa.origin, '/array/', 2000)).toBeNull() + } + finally { + await spa.close() + } + }) + + it('finds the connection meta under a non-root base', async () => { + const spa = await startSpaFallbackServer() + try { + const probed = await probeDevframeOrigin(spa.origin, '/__devtools/', 2000) + expect(probed).toEqual({ origin: spa.origin, meta: { backend: 'websocket', mcp: { path: '__mcp' } } }) + } + finally { + await spa.close() + } + }) + + it('prunes a registry record whose port now serves an unrelated SPA', async () => { + const dir = mkdtempSync(join(tmpdir(), 'devframe-registry-')) + const spa = await startSpaFallbackServer() + try { + registerDevframeInstance(makeRecord({ + pid: 2000, + port: spa.port, + origin: spa.origin, + }), { instancesDir: dir }) + + const { live, pruned } = await listLiveDevframeInstances({ instancesDir: dir, timeoutMs: 2000 }) + expect(live).toEqual([]) + expect(pruned.map(r => r.pid)).toEqual([2000]) + expect(readdirSync(dir)).toEqual([]) + } + finally { + await spa.close() + } + }) +}) diff --git a/packages/devframe/src/node/instance-registry.ts b/packages/devframe/src/node/instance-registry.ts index e5bf27ea..fcd4f954 100644 --- a/packages/devframe/src/node/instance-registry.ts +++ b/packages/devframe/src/node/instance-registry.ts @@ -180,7 +180,7 @@ function originCandidates(origin: string): string[] { export interface ProbedDevframeOrigin { /** The origin that answered (may be an explicit address family for a `localhost` bind). */ origin: string - /** The parsed `__connection.json` payload (`{}` when unparseable). */ + /** The parsed `__connection.json` payload (always a JSON object). */ meta: { mcp?: { path: string, port?: number } } } @@ -188,7 +188,9 @@ export interface ProbedDevframeOrigin { * Probe `__connection.json`, trying each dialable * candidate for the origin (see {@link originCandidates}). The single * probe primitive behind both registry liveness checks and the - * connector's explicit `--port` probes. + * connector's explicit `--port` probes. A candidate counts only when it + * answers `2xx` with a JSON object; anything else (an HTML SPA fallback, a + * JSON array, an unparseable body) is treated as "no devframe here". * * @internal */ @@ -205,8 +207,14 @@ export async function probeDevframeOrigin( }) if (!response.ok) continue - const meta = await response.json().catch(() => ({})) as ProbedDevframeOrigin['meta'] - return { origin: candidate, meta } + // Only a JSON object is connection meta. Host frameworks with an SPA + // fallback (Vite serving `index.html` for any unknown path) answer a + // wrong base with `200 text/html`; that is not a devframe, so it must + // not pass as a live instance with no MCP route. + const meta: unknown = await response.json().catch(() => undefined) + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) + continue + return { origin: candidate, meta: meta as ProbedDevframeOrigin['meta'] } } catch { // Try the next candidate. diff --git a/tests/__snapshots__/tsnapi/@devframes/agentic/connect.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/agentic/connect.snapshot.d.ts index 72bbef30..5838ca7a 100644 --- a/tests/__snapshots__/tsnapi/@devframes/agentic/connect.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/agentic/connect.snapshot.d.ts @@ -7,6 +7,7 @@ export interface ConnectServerHandle { } export interface ConnectServerOptions { ports?: number[]; + base?: string; instancesDir?: string; timeoutMs?: number; authToken?: string | ((_: DevframeInstanceRecord) => string | undefined); @@ -15,6 +16,7 @@ export interface ConnectServerOptions { // #region Functions export declare function buildInstanceRequestHeaders(_: string, _: string | undefined): Record; +export declare function probePort(_: number, _?: string, _?: number): Promise; export declare function resolveAuthToken(_: ConnectServerOptions['authToken'], _: DevframeInstanceRecord): string | undefined; export declare function startConnectServer(_?: ConnectServerOptions): Promise; // #endregion \ No newline at end of file diff --git a/tests/__snapshots__/tsnapi/@devframes/agentic/connect.snapshot.js b/tests/__snapshots__/tsnapi/@devframes/agentic/connect.snapshot.js index d0892824..ba76c40f 100644 --- a/tests/__snapshots__/tsnapi/@devframes/agentic/connect.snapshot.js +++ b/tests/__snapshots__/tsnapi/@devframes/agentic/connect.snapshot.js @@ -3,6 +3,7 @@ */ // #region Functions export function buildInstanceRequestHeaders(_, _) {} +export async function probePort(_, _, _) {} export function resolveAuthToken(_, _) {} export async function startConnectServer(_) {} // #endregion \ No newline at end of file