Skip to content

Commit f37e49d

Browse files
zahidzorbazclaude
andauthored
feat(agentic): add --base to devframe connect port probes (#405)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent c13797b commit f37e49d

9 files changed

Lines changed: 175 additions & 19 deletions

File tree

‎docs/content/2.adapters/7.mcp.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ Two gateway tools (`devframe:connect:*` ids; see [tool ids and wire names](/guid
107107
- **`devframe_connect_list-instances`**: list running dev servers and their MCP tools.
108108
- **`devframe_connect_call-tool`**: invoke one tool on a running devframe (`{ port, tool, args }`) over Streamable-HTTP.
109109

110-
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.
110+
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.
111111

112112
The connector needs the same optional `@devframes/agentic` peer as the adapter; `devframe connect` without it throws [DF0046](/errors/DF0046).
113113

‎packages/agentic/src/connect/__tests__/connect.test.ts‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import type { DevframeInstanceRecord, StartedServer } from 'devframe/internal'
22
import type { DevframeDefinition } from 'devframe/types'
3+
import type { Server } from 'node:http'
4+
import type { AddressInfo } from 'node:net'
5+
import { createServer } from 'node:http'
36
import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'
47
import { createDevServer } from 'devframe/adapters/dev'
58
import { afterEach, describe, expect, it } from 'vitest'
6-
import { buildInstanceRequestHeaders, resolveAuthToken } from '../index'
9+
import { buildInstanceRequestHeaders, probePort, resolveAuthToken } from '../index'
710

811
const TOKEN = 'a-high-entropy-connect-test-token'
912

@@ -59,6 +62,52 @@ describe('buildInstanceRequestHeaders', () => {
5962
})
6063
})
6164

65+
describe('probePort', () => {
66+
let server: Server | undefined
67+
68+
afterEach(async () => {
69+
await new Promise<void>(resolve => (server ? server.close(() => resolve()) : resolve()))
70+
server = undefined
71+
})
72+
73+
// A Vite-like host: the hub's meta lives under `/__devtools/` and every
74+
// other path falls back to the app's `index.html` with `200 text/html`.
75+
async function startBasedHub(): Promise<number> {
76+
server = createServer((req, res) => {
77+
if (req.url === '/__devtools/__connection.json') {
78+
res.writeHead(200, { 'content-type': 'application/json' })
79+
res.end('{"backend":"websocket","mcp":{"path":"__mcp"}}')
80+
return
81+
}
82+
res.writeHead(200, { 'content-type': 'text/html' })
83+
res.end('<!doctype html><html><body></body></html>')
84+
})
85+
await new Promise<void>(resolve => server!.listen(0, '127.0.0.1', resolve))
86+
return (server.address() as AddressInfo).port
87+
}
88+
89+
it('finds a hub mounted under a base and resolves its MCP path against it', async () => {
90+
const port = await startBasedHub()
91+
const record = await probePort(port, '/__devtools/', 2000)
92+
expect(record).toMatchObject({
93+
port,
94+
basePath: '/__devtools/',
95+
mcp: { path: '/__devtools/__mcp' },
96+
})
97+
})
98+
99+
it('normalizes a base given without slashes', async () => {
100+
const port = await startBasedHub()
101+
const record = await probePort(port, '__devtools', 2000)
102+
expect(record).toMatchObject({ basePath: '/__devtools/', mcp: { path: '/__devtools/__mcp' } })
103+
})
104+
105+
it('reports no instance (not an MCP-less one) when the root only serves the SPA fallback', async () => {
106+
const port = await startBasedHub()
107+
expect(await probePort(port, undefined, 2000)).toBeNull()
108+
})
109+
})
110+
62111
describe('connector bearer against a live authenticated MCP route', () => {
63112
let server: StartedServer | undefined
64113

‎packages/agentic/src/connect/index.ts‎

Lines changed: 24 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,24 @@ import { StdioServerTransport } from '@modelcontextprotocol/server/stdio'
66
import { diagnostics, listLiveDevframeInstances, probeDevframeOrigin } from 'devframe/internal'
77
import { toAgentToolName } from 'devframe/utils/agent-tool-name'
88
import { Diagnostic } from 'devframe/utils/nostics'
9-
import { joinURL } from 'devframe/utils/url'
9+
import { joinURL, withLeadingSlash, withTrailingSlash } from 'devframe/utils/url'
1010

1111
export interface ConnectServerOptions {
1212
/**
1313
* Explicit ports to probe besides the registry, for instances started
1414
* before the registry existed, or reachable only by convention. Each port
15-
* is probed at `/` (`http://localhost:<port>/__connection.json`).
15+
* is probed at {@link ConnectServerOptions.base} (default `/`, i.e.
16+
* `http://localhost:<port>/__connection.json`).
1617
*/
1718
ports?: number[]
19+
/**
20+
* Base path the explicit {@link ConnectServerOptions.ports} probes look for
21+
* `__connection.json` under, for a devframe or hub mounted below the root
22+
* of its host (e.g. `/__devtools/` for Vite DevTools). The advertised MCP
23+
* path is resolved against it. Default `/`. Registry records carry their
24+
* own base and ignore this.
25+
*/
26+
base?: string
1827
/** Override the registry directory (`DEVFRAME_INSTANCES_DIR` also applies). */
1928
instancesDir?: string
2029
/** Probe timeout per instance, ms. Default 1000. */
@@ -164,7 +173,7 @@ async function index(options: ConnectServerOptions): Promise<unknown> {
164173
for (const port of options.ports ?? []) {
165174
if (records.some(r => r.port === port))
166175
continue
167-
const probed = await probePort(port, options.timeoutMs)
176+
const probed = await probePort(port, options.base, options.timeoutMs)
168177
if (probed)
169178
records.push(probed)
170179
}
@@ -189,26 +198,29 @@ async function index(options: ConnectServerOptions): Promise<unknown> {
189198
return {
190199
instances,
191200
...(instances.length === 0
192-
? { 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.' }
201+
? { 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/).' }
193202
: {}),
194203
}
195204
}
196205

197206
/**
198-
* Probe an explicit port for a devframe serving `__connection.json` at `/`,
199-
* reusing the registry's origin-candidate probe (a `localhost`-bound server
200-
* may listen on either address family).
207+
* Probe an explicit port for a devframe serving `__connection.json` under
208+
* `base` (default `/`), reusing the registry's origin-candidate probe (a
209+
* `localhost`-bound server may listen on either address family). The
210+
* advertised MCP path is relative to that base, as in the registry records
211+
* the instance shell writes. Exported for focused tests.
201212
*/
202-
async function probePort(port: number, timeoutMs?: number): Promise<DevframeInstanceRecord | null> {
203-
const probed = await probeDevframeOrigin(`http://localhost:${port}`, '/', timeoutMs)
213+
export async function probePort(port: number, base = '/', timeoutMs?: number): Promise<DevframeInstanceRecord | null> {
214+
const basePath = withTrailingSlash(withLeadingSlash(base))
215+
const probed = await probeDevframeOrigin(`http://localhost:${port}`, basePath, timeoutMs)
204216
if (!probed)
205217
return null
206-
const mcpPath = probed.meta.mcp ? joinURL('/', probed.meta.mcp.path) : null
218+
const mcpPath = probed.meta.mcp ? joinURL(basePath, probed.meta.mcp.path) : null
207219
return {
208220
pid: -1,
209221
port,
210222
origin: probed.origin,
211-
basePath: '/',
223+
basePath,
212224
id: `port-${port}`,
213225
rootDir: '',
214226
mcp: mcpPath ? { path: mcpPath } : null,
@@ -232,7 +244,7 @@ async function call(
232244
timeoutMs: options.timeoutMs,
233245
})
234246
const record = live.find(record => record.port === args.port && record.mcp)
235-
?? await probePort(args.port, options.timeoutMs)
247+
?? await probePort(args.port, options.base, options.timeoutMs)
236248
if (!record)
237249
throw diagnostics.DF0050({ port: args.port })
238250
if (!record.mcp)

‎packages/devframe/src/cli/main.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,5 +33,6 @@ describe('runDevframeCli', () => {
3333
await runDevframeCli(['node', 'devframe', 'connect', '--help'])
3434
expect(info).toHaveBeenCalledTimes(1)
3535
expect(info.mock.calls[0]![0]).toContain('--port')
36+
expect(info.mock.calls[0]![0]).toContain('--base <path>')
3637
})
3738
})

‎packages/devframe/src/cli/main.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { importRuntimeModule } from '../node/import-runtime-module'
77
interface AgenticConnectModule {
88
startConnectServer: (options: {
99
ports?: number[]
10+
base?: string
1011
instancesDir?: string
1112
timeoutMs?: number
1213
authToken?: string
@@ -46,12 +47,14 @@ export async function runDevframeCli(argv: string[] = process.argv): Promise<voi
4647
cli
4748
.command('connect', 'Run the devframe MCP connector on stdio (discovers running devframe dev servers and proxies their tools)')
4849
.option('--port <port>', 'Probe an explicit port besides the instance registry (repeatable)')
50+
.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: '/' })
4951
.option('--instances-dir <dir>', 'Override the instance registry directory (default: ~/.devframe/instances, or $DEVFRAME_INSTANCES_DIR)')
5052
.option('--timeout <ms>', 'Probe timeout per instance in milliseconds', { default: 1000 })
51-
.action(async (options: { port?: unknown, instancesDir?: string, timeout?: number }) => {
53+
.action(async (options: { port?: unknown, base?: unknown, instancesDir?: string, timeout?: number }) => {
5254
const { startConnectServer } = await importConnect()
5355
await startConnectServer({
5456
ports: parsePortsFlag(options.port),
57+
base: typeof options.base === 'string' ? options.base : undefined,
5558
instancesDir: options.instancesDir,
5659
timeoutMs: options.timeout,
5760
/**

‎packages/devframe/src/node/instance-registry.test.ts‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { join } from 'pathe'
77
import { beforeEach, describe, expect, it, vi } from 'vitest'
88
import {
99
listLiveDevframeInstances,
10+
probeDevframeOrigin,
1011
readDevframeInstances,
1112
registerDevframeInstance,
1213
} from './instance-registry'
@@ -142,3 +143,82 @@ describe('instance registry', () => {
142143
}
143144
})
144145
})
146+
147+
describe('probeDevframeOrigin', () => {
148+
// A host with an SPA fallback: every unknown path answers `200 text/html`
149+
// (Vite serving `index.html`); the real meta lives under `/__devtools/`.
150+
async function startSpaFallbackServer(): Promise<{ origin: string, port: number, close: () => Promise<void> }> {
151+
const server = createServer((req, res) => {
152+
if (req.url === '/__devtools/__connection.json') {
153+
res.writeHead(200, { 'content-type': 'application/json' })
154+
res.end('{"backend":"websocket","mcp":{"path":"__mcp"}}')
155+
return
156+
}
157+
if (req.url === '/array/__connection.json') {
158+
res.writeHead(200, { 'content-type': 'application/json' })
159+
res.end('[]')
160+
return
161+
}
162+
res.writeHead(200, { 'content-type': 'text/html' })
163+
res.end('<!doctype html><html><body><div id="app"></div></body></html>')
164+
})
165+
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
166+
const port = (server.address() as AddressInfo).port
167+
return {
168+
origin: `http://127.0.0.1:${port}`,
169+
port,
170+
close: () => new Promise<void>(resolve => server.close(() => resolve())),
171+
}
172+
}
173+
174+
it('does not take an HTML SPA fallback for a devframe', async () => {
175+
const spa = await startSpaFallbackServer()
176+
try {
177+
expect(await probeDevframeOrigin(spa.origin, '/', 2000)).toBeNull()
178+
}
179+
finally {
180+
await spa.close()
181+
}
182+
})
183+
184+
it('does not take a non-object JSON body for connection meta', async () => {
185+
const spa = await startSpaFallbackServer()
186+
try {
187+
expect(await probeDevframeOrigin(spa.origin, '/array/', 2000)).toBeNull()
188+
}
189+
finally {
190+
await spa.close()
191+
}
192+
})
193+
194+
it('finds the connection meta under a non-root base', async () => {
195+
const spa = await startSpaFallbackServer()
196+
try {
197+
const probed = await probeDevframeOrigin(spa.origin, '/__devtools/', 2000)
198+
expect(probed).toEqual({ origin: spa.origin, meta: { backend: 'websocket', mcp: { path: '__mcp' } } })
199+
}
200+
finally {
201+
await spa.close()
202+
}
203+
})
204+
205+
it('prunes a registry record whose port now serves an unrelated SPA', async () => {
206+
const dir = mkdtempSync(join(tmpdir(), 'devframe-registry-'))
207+
const spa = await startSpaFallbackServer()
208+
try {
209+
registerDevframeInstance(makeRecord({
210+
pid: 2000,
211+
port: spa.port,
212+
origin: spa.origin,
213+
}), { instancesDir: dir })
214+
215+
const { live, pruned } = await listLiveDevframeInstances({ instancesDir: dir, timeoutMs: 2000 })
216+
expect(live).toEqual([])
217+
expect(pruned.map(r => r.pid)).toEqual([2000])
218+
expect(readdirSync(dir)).toEqual([])
219+
}
220+
finally {
221+
await spa.close()
222+
}
223+
})
224+
})

‎packages/devframe/src/node/instance-registry.ts‎

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -180,15 +180,17 @@ function originCandidates(origin: string): string[] {
180180
export interface ProbedDevframeOrigin {
181181
/** The origin that answered (may be an explicit address family for a `localhost` bind). */
182182
origin: string
183-
/** The parsed `__connection.json` payload (`{}` when unparseable). */
183+
/** The parsed `__connection.json` payload (always a JSON object). */
184184
meta: { mcp?: { path: string, port?: number } }
185185
}
186186

187187
/**
188188
* Probe `<origin><basePath>__connection.json`, trying each dialable
189189
* candidate for the origin (see {@link originCandidates}). The single
190190
* probe primitive behind both registry liveness checks and the
191-
* connector's explicit `--port` probes.
191+
* connector's explicit `--port` probes. A candidate counts only when it
192+
* answers `2xx` with a JSON object; anything else (an HTML SPA fallback, a
193+
* JSON array, an unparseable body) is treated as "no devframe here".
192194
*
193195
* @internal
194196
*/
@@ -205,8 +207,14 @@ export async function probeDevframeOrigin(
205207
})
206208
if (!response.ok)
207209
continue
208-
const meta = await response.json().catch(() => ({})) as ProbedDevframeOrigin['meta']
209-
return { origin: candidate, meta }
210+
// Only a JSON object is connection meta. Host frameworks with an SPA
211+
// fallback (Vite serving `index.html` for any unknown path) answer a
212+
// wrong base with `200 text/html`; that is not a devframe, so it must
213+
// not pass as a live instance with no MCP route.
214+
const meta: unknown = await response.json().catch(() => undefined)
215+
if (!meta || typeof meta !== 'object' || Array.isArray(meta))
216+
continue
217+
return { origin: candidate, meta: meta as ProbedDevframeOrigin['meta'] }
210218
}
211219
catch {
212220
// Try the next candidate.

‎tests/__snapshots__/tsnapi/@devframes/agentic/connect.snapshot.d.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export interface ConnectServerHandle {
77
}
88
export interface ConnectServerOptions {
99
ports?: number[];
10+
base?: string;
1011
instancesDir?: string;
1112
timeoutMs?: number;
1213
authToken?: string | ((_: DevframeInstanceRecord) => string | undefined);
@@ -15,6 +16,7 @@ export interface ConnectServerOptions {
1516

1617
// #region Functions
1718
export declare function buildInstanceRequestHeaders(_: string, _: string | undefined): Record<string, string>;
19+
export declare function probePort(_: number, _?: string, _?: number): Promise<DevframeInstanceRecord | null>;
1820
export declare function resolveAuthToken(_: ConnectServerOptions['authToken'], _: DevframeInstanceRecord): string | undefined;
1921
export declare function startConnectServer(_?: ConnectServerOptions): Promise<ConnectServerHandle>;
2022
// #endregion

‎tests/__snapshots__/tsnapi/@devframes/agentic/connect.snapshot.js‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44
// #region Functions
55
export function buildInstanceRequestHeaders(_, _) {}
6+
export async function probePort(_, _, _) {}
67
export function resolveAuthToken(_, _) {}
78
export async function startConnectServer(_) {}
89
// #endregion

0 commit comments

Comments
 (0)