Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/content/2.adapters/7.mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>-<port>.json`, dialed with a loopback origin. In-process host frameworks register via `registerDevframeInstance` (`devframe/node`). `--port <n>` 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/<pid>-<port>.json`, dialed with a loopback origin. In-process host frameworks register via `registerDevframeInstance` (`devframe/node`). `--port <n>` probes a port for `__connection.json` at its root; add `--base <path>` 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).

Expand Down
51 changes: 50 additions & 1 deletion packages/agentic/src/connect/__tests__/connect.test.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -59,6 +62,52 @@ describe('buildInstanceRequestHeaders', () => {
})
})

describe('probePort', () => {
let server: Server | undefined

afterEach(async () => {
await new Promise<void>(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<number> {
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('<!doctype html><html><body></body></html>')
})
await new Promise<void>(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

Expand Down
36 changes: 24 additions & 12 deletions packages/agentic/src/connect/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>/__connection.json`).
* is probed at {@link ConnectServerOptions.base} (default `/`, i.e.
* `http://localhost:<port>/__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. */
Expand Down Expand Up @@ -164,7 +173,7 @@ async function index(options: ConnectServerOptions): Promise<unknown> {
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)
}
Expand All @@ -189,26 +198,29 @@ async function index(options: ConnectServerOptions): Promise<unknown> {
return {
instances,
...(instances.length === 0
? { hint: 'No running devframe instances found. Start a devframe dev server (with --mcp for tools), or pass --port <n> 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 <n> to devframe connect if the instance predates the registry (plus --base <path> 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<DevframeInstanceRecord | null> {
const probed = await probeDevframeOrigin(`http://localhost:${port}`, '/', timeoutMs)
export async function probePort(port: number, base = '/', timeoutMs?: number): Promise<DevframeInstanceRecord | null> {
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,
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/devframe/src/cli/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>')
})
})
5 changes: 4 additions & 1 deletion packages/devframe/src/cli/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -46,12 +47,14 @@ export async function runDevframeCli(argv: string[] = process.argv): Promise<voi
cli
.command('connect', 'Run the devframe MCP connector on stdio (discovers running devframe dev servers and proxies their tools)')
.option('--port <port>', 'Probe an explicit port besides the instance registry (repeatable)')
.option('--base <path>', '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 <dir>', 'Override the instance registry directory (default: ~/.devframe/instances, or $DEVFRAME_INSTANCES_DIR)')
.option('--timeout <ms>', '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,
/**
Expand Down
80 changes: 80 additions & 0 deletions packages/devframe/src/node/instance-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { join } from 'pathe'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
listLiveDevframeInstances,
probeDevframeOrigin,
readDevframeInstances,
registerDevframeInstance,
} from './instance-registry'
Expand Down Expand Up @@ -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<void> }> {
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('<!doctype html><html><body><div id="app"></div></body></html>')
})
await new Promise<void>(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<void>(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()
}
})
})
16 changes: 12 additions & 4 deletions packages/devframe/src/node/instance-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,15 +180,17 @@ 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 } }
}

/**
* Probe `<origin><basePath>__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
*/
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface ConnectServerHandle {
}
export interface ConnectServerOptions {
ports?: number[];
base?: string;
instancesDir?: string;
timeoutMs?: number;
authToken?: string | ((_: DevframeInstanceRecord) => string | undefined);
Expand All @@ -15,6 +16,7 @@ export interface ConnectServerOptions {

// #region Functions
export declare function buildInstanceRequestHeaders(_: string, _: string | undefined): Record<string, string>;
export declare function probePort(_: number, _?: string, _?: number): Promise<DevframeInstanceRecord | null>;
export declare function resolveAuthToken(_: ConnectServerOptions['authToken'], _: DevframeInstanceRecord): string | undefined;
export declare function startConnectServer(_?: ConnectServerOptions): Promise<ConnectServerHandle>;
// #endregion
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
// #region Functions
export function buildInstanceRequestHeaders(_, _) {}
export async function probePort(_, _, _) {}
export function resolveAuthToken(_, _) {}
export async function startConnectServer(_) {}
// #endregion
Loading