Skip to content

Commit f8ee937

Browse files
zahidzorbazclaude
andcommitted
feat(agentic): add --base to devframe connect port probes
`--port` probes always looked for `__connection.json` at the root, so a devframe or hub mounted below it (Vite DevTools at `/__devtools/`) could not be reached, and the advertised MCP path was resolved against `/`. Add a `base` connector option and a `--base <path>` CLI flag (default `/`) for the explicit port probes, and resolve the MCP path against that base, the same way the instance shell does for registry records. `probePort` is exported for focused tests. Document the flag in the CLI help and the MCP adapter's Discovery section. Closes #403 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent b2574ce commit f8ee937

7 files changed

Lines changed: 83 additions & 15 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
/**

‎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)