@vrdmr/fnx-test 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/cli.js +29 -7
- package/lib/host-launcher.js +12 -2
- package/lib/live-mcp-server.js +92 -94
- package/package.json +5 -2
package/lib/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolve as resolvePath, dirname, join } from 'node:path';
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { createServer } from 'node:net';
|
|
3
4
|
import { fileURLToPath } from 'node:url';
|
|
4
5
|
import { resolveProfile, listProfiles, setProfilesSource } from './profile-resolver.js';
|
|
5
6
|
import { ensureHost, ensureBundle } from './host-manager.js';
|
|
@@ -7,6 +8,21 @@ import { launchHost, createHostState } from './host-launcher.js';
|
|
|
7
8
|
import { startLiveMcpServer } from './live-mcp-server.js';
|
|
8
9
|
import { detectDotnetModel, printInProcessError } from './dotnet-detector.js';
|
|
9
10
|
|
|
11
|
+
function isPortFree(port) {
|
|
12
|
+
return new Promise((resolve) => {
|
|
13
|
+
const srv = createServer();
|
|
14
|
+
srv.once('error', () => resolve(false));
|
|
15
|
+
srv.listen(port, '127.0.0.1', () => { srv.close(() => resolve(true)); });
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function findOpenPort(start, maxRetries = 10) {
|
|
20
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
21
|
+
if (await isPortFree(start + i)) return start + i;
|
|
22
|
+
}
|
|
23
|
+
return start; // fall through — let the host report the error
|
|
24
|
+
}
|
|
25
|
+
|
|
10
26
|
export async function main(args) {
|
|
11
27
|
const cmd = args[0];
|
|
12
28
|
|
|
@@ -43,8 +59,12 @@ export async function main(args) {
|
|
|
43
59
|
}
|
|
44
60
|
|
|
45
61
|
const scriptRoot = getFlag(args, '--scriptroot') || process.cwd();
|
|
46
|
-
const
|
|
47
|
-
const
|
|
62
|
+
const requestedPort = parseInt(getFlag(args, '--port') || '7071');
|
|
63
|
+
const port = await findOpenPort(requestedPort);
|
|
64
|
+
if (port !== requestedPort) {
|
|
65
|
+
console.log(` Port ${requestedPort} in use, using ${port} instead.`);
|
|
66
|
+
}
|
|
67
|
+
const mcpPort = getFlag(args, '--mcp-port') || String(port + 1);
|
|
48
68
|
const verbose = args.includes('--verbose');
|
|
49
69
|
const noMcp = args.includes('--no-mcp');
|
|
50
70
|
const noAzurite = args.includes('--no-azurite');
|
|
@@ -138,10 +158,12 @@ export async function main(args) {
|
|
|
138
158
|
const hostState = createHostState();
|
|
139
159
|
|
|
140
160
|
if (!noMcp) {
|
|
141
|
-
startLiveMcpServer(hostState, parseInt(mcpPort))
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
161
|
+
startLiveMcpServer(hostState, parseInt(mcpPort))
|
|
162
|
+
.then((server) => { hostState._mcpServer = server; })
|
|
163
|
+
.catch((err) => {
|
|
164
|
+
console.error(` ⚠️ MCP server failed to start on port ${mcpPort}: ${err.message}`);
|
|
165
|
+
console.error(` Use --no-mcp to disable, or --mcp-port <port> to change port.`);
|
|
166
|
+
});
|
|
145
167
|
// Don't await — host startup should not depend on MCP server
|
|
146
168
|
}
|
|
147
169
|
|
|
@@ -294,7 +316,7 @@ MCP server (for VS Code Copilot / AI assistants):
|
|
|
294
316
|
# .vscode/mcp.json — live host data (when fnx start is running):
|
|
295
317
|
# {
|
|
296
318
|
# "servers": {
|
|
297
|
-
# "fnx-
|
|
319
|
+
# "fnx-functions-debug": {
|
|
298
320
|
# "type": "http",
|
|
299
321
|
# "url": "http://127.0.0.1:7072/mcp"
|
|
300
322
|
# }
|
package/lib/host-launcher.js
CHANGED
|
@@ -382,8 +382,18 @@ export async function launchHost(hostDir, opts) {
|
|
|
382
382
|
});
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
-
process.on('SIGINT', () => {
|
|
386
|
-
|
|
385
|
+
process.on('SIGINT', () => {
|
|
386
|
+
stopAzurite();
|
|
387
|
+
child.kill('SIGINT');
|
|
388
|
+
if (hostState._mcpServer) hostState._mcpServer.close();
|
|
389
|
+
setTimeout(() => process.exit(0), 500);
|
|
390
|
+
});
|
|
391
|
+
process.on('SIGTERM', () => {
|
|
392
|
+
stopAzurite();
|
|
393
|
+
child.kill('SIGTERM');
|
|
394
|
+
if (hostState._mcpServer) hostState._mcpServer.close();
|
|
395
|
+
setTimeout(() => process.exit(0), 500);
|
|
396
|
+
});
|
|
387
397
|
|
|
388
398
|
return new Promise((resolve, reject) => {
|
|
389
399
|
child.on('error', (err) => {
|
package/lib/live-mcp-server.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Functions Debug MCP Server — stateless Streamable HTTP transport.
|
|
3
3
|
* Started automatically when `fnx start` launches.
|
|
4
4
|
*
|
|
5
|
+
* Follows the official MCP SDK stateless pattern: a fresh McpServer + transport
|
|
6
|
+
* is created per POST request, cleaned up on response close.
|
|
7
|
+
* Reference: https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/server/src/simpleStatelessStreamableHttp.ts
|
|
8
|
+
*
|
|
5
9
|
* Tools:
|
|
6
10
|
* get_host_status — host version, state, uptime, PID, SKU, worker runtime
|
|
7
11
|
* get_functions — list of functions with trigger types and routes
|
|
@@ -12,15 +16,6 @@
|
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import { createServer as createHttpServer } from 'node:http';
|
|
15
|
-
import { dirname, join } from 'node:path';
|
|
16
|
-
import { fileURLToPath } from 'node:url';
|
|
17
|
-
import { createRequire } from 'node:module';
|
|
18
|
-
|
|
19
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
20
|
-
const templatesMcpDir = join(__dirname, '..', 'templates-mcp');
|
|
21
|
-
|
|
22
|
-
// Resolve MCP SDK from templates-mcp's node_modules
|
|
23
|
-
const require = createRequire(join(templatesMcpDir, 'package.json'));
|
|
24
19
|
|
|
25
20
|
// ─── Tool registration (called per session) ─────────────────────────
|
|
26
21
|
|
|
@@ -245,30 +240,26 @@ Quick health check without digging through verbose logs.`,
|
|
|
245
240
|
);
|
|
246
241
|
}
|
|
247
242
|
|
|
248
|
-
// ─── Start
|
|
243
|
+
// ─── Start Functions Debug MCP Server (stateless) ───────────────────
|
|
249
244
|
|
|
250
245
|
export async function startLiveMcpServer(hostState, mcpPort) {
|
|
251
|
-
const { McpServer } = await import(
|
|
246
|
+
const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
|
|
252
247
|
const { StreamableHTTPServerTransport } = await import(
|
|
253
|
-
|
|
248
|
+
'@modelcontextprotocol/sdk/server/streamableHttp.js'
|
|
254
249
|
);
|
|
255
|
-
const { z } = await import(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
});
|
|
264
|
-
|
|
250
|
+
const { z } = await import('zod/v4');
|
|
251
|
+
|
|
252
|
+
// Factory: creates a fresh McpServer per request (stateless pattern)
|
|
253
|
+
function getServer() {
|
|
254
|
+
const server = new McpServer(
|
|
255
|
+
{ name: 'fnx-functions-debug', version: '0.1.0' },
|
|
256
|
+
{ capabilities: { logging: {} } },
|
|
257
|
+
);
|
|
265
258
|
registerTools(server, hostState, z);
|
|
266
259
|
return server;
|
|
267
260
|
}
|
|
268
261
|
|
|
269
|
-
// ───
|
|
270
|
-
|
|
271
|
-
const transports = new Map(); // sessionId → { transport, server }
|
|
262
|
+
// ─── HTTP server with Streamable HTTP transport ─────────────────────
|
|
272
263
|
|
|
273
264
|
const httpServer = createHttpServer(async (req, res) => {
|
|
274
265
|
// CORS headers for browser-based MCP clients
|
|
@@ -283,79 +274,54 @@ export async function startLiveMcpServer(hostState, mcpPort) {
|
|
|
283
274
|
return;
|
|
284
275
|
}
|
|
285
276
|
|
|
286
|
-
// Health check
|
|
277
|
+
// Health check
|
|
287
278
|
if (req.url === '/health') {
|
|
288
279
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
289
280
|
res.end(JSON.stringify({ status: 'ok', hostState: hostState.state }));
|
|
290
281
|
return;
|
|
291
282
|
}
|
|
292
283
|
|
|
293
|
-
// MCP endpoint
|
|
284
|
+
// MCP endpoint — stateless: new server + transport per POST
|
|
294
285
|
if (req.url === '/mcp' || req.url?.startsWith('/mcp?')) {
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
if (req.method === 'POST') {
|
|
299
|
-
// Existing session: reuse transport
|
|
300
|
-
if (sessionId && transports.has(sessionId)) {
|
|
301
|
-
const { transport } = transports.get(sessionId);
|
|
302
|
-
await transport.handleRequest(req, res);
|
|
303
|
-
return;
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
// New session: create server + transport pair
|
|
307
|
-
const mcpServer = createMcpServer();
|
|
286
|
+
if (req.method === 'POST') {
|
|
287
|
+
const server = getServer();
|
|
288
|
+
try {
|
|
308
289
|
const transport = new StreamableHTTPServerTransport({
|
|
309
|
-
sessionIdGenerator:
|
|
310
|
-
onsessioninitialized: (sid) => {
|
|
311
|
-
transports.set(sid, { transport, server: mcpServer });
|
|
312
|
-
},
|
|
290
|
+
sessionIdGenerator: undefined, // stateless — no sessions
|
|
313
291
|
});
|
|
314
|
-
|
|
315
|
-
transport.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
await transport.handleRequest(req, res);
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
333
|
-
res.end(JSON.stringify({ error: 'Missing or invalid session ID for GET (SSE) request' }));
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
if (req.method === 'DELETE') {
|
|
338
|
-
if (sessionId && transports.has(sessionId)) {
|
|
339
|
-
const { transport, server: mcpServer } = transports.get(sessionId);
|
|
340
|
-
await transport.handleRequest(req, res);
|
|
341
|
-
transports.delete(sessionId);
|
|
342
|
-
await mcpServer.close();
|
|
343
|
-
return;
|
|
292
|
+
await server.connect(transport);
|
|
293
|
+
await transport.handleRequest(req, res, await readBody(req));
|
|
294
|
+
res.on('close', () => {
|
|
295
|
+
transport.close();
|
|
296
|
+
server.close();
|
|
297
|
+
});
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error('[MCP] Error handling request:', err.message);
|
|
300
|
+
if (!res.headersSent) {
|
|
301
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
302
|
+
res.end(JSON.stringify({
|
|
303
|
+
jsonrpc: '2.0',
|
|
304
|
+
error: { code: -32603, message: 'Internal server error' },
|
|
305
|
+
id: null,
|
|
306
|
+
}));
|
|
344
307
|
}
|
|
345
|
-
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
346
|
-
res.end(JSON.stringify({ error: 'Session not found' }));
|
|
347
|
-
return;
|
|
348
308
|
}
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
349
311
|
|
|
312
|
+
// GET and DELETE not supported in stateless mode
|
|
313
|
+
if (req.method === 'GET' || req.method === 'DELETE') {
|
|
350
314
|
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
351
|
-
res.end(JSON.stringify({
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
}
|
|
315
|
+
res.end(JSON.stringify({
|
|
316
|
+
jsonrpc: '2.0',
|
|
317
|
+
error: { code: -32000, message: 'Method not allowed.' },
|
|
318
|
+
id: null,
|
|
319
|
+
}));
|
|
320
|
+
return;
|
|
358
321
|
}
|
|
322
|
+
|
|
323
|
+
res.writeHead(405, { 'Content-Type': 'application/json' });
|
|
324
|
+
res.end(JSON.stringify({ error: 'Method not allowed. Use POST.' }));
|
|
359
325
|
return;
|
|
360
326
|
}
|
|
361
327
|
|
|
@@ -365,18 +331,50 @@ export async function startLiveMcpServer(hostState, mcpPort) {
|
|
|
365
331
|
});
|
|
366
332
|
|
|
367
333
|
return new Promise((resolve, reject) => {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
334
|
+
const maxRetries = 10;
|
|
335
|
+
let attempt = 0;
|
|
336
|
+
let port = mcpPort;
|
|
337
|
+
|
|
338
|
+
function tryListen() {
|
|
339
|
+
httpServer.once('error', onError);
|
|
340
|
+
httpServer.listen(port, '127.0.0.1', () => {
|
|
341
|
+
httpServer.removeListener('error', onError);
|
|
342
|
+
// Runtime errors after startup
|
|
343
|
+
httpServer.on('error', (err) => {
|
|
344
|
+
console.error(` ⚠️ MCP server error: ${err.message}`);
|
|
345
|
+
});
|
|
346
|
+
console.log(` Functions Debug MCP Server: http://127.0.0.1:${port}/mcp`);
|
|
347
|
+
resolve(httpServer);
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function onError(err) {
|
|
352
|
+
if (err.code === 'EADDRINUSE' && attempt < maxRetries) {
|
|
353
|
+
attempt++;
|
|
354
|
+
port++;
|
|
355
|
+
tryListen();
|
|
372
356
|
} else {
|
|
373
|
-
|
|
357
|
+
reject(err);
|
|
374
358
|
}
|
|
375
|
-
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
tryListen();
|
|
362
|
+
});
|
|
363
|
+
}
|
|
376
364
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
365
|
+
// ─── Helpers ──────────────────────────────────────────────────────────
|
|
366
|
+
|
|
367
|
+
function readBody(req) {
|
|
368
|
+
return new Promise((resolve, reject) => {
|
|
369
|
+
const chunks = [];
|
|
370
|
+
req.on('data', (c) => chunks.push(c));
|
|
371
|
+
req.on('end', () => {
|
|
372
|
+
try {
|
|
373
|
+
resolve(JSON.parse(Buffer.concat(chunks).toString()));
|
|
374
|
+
} catch {
|
|
375
|
+
resolve(undefined);
|
|
376
|
+
}
|
|
380
377
|
});
|
|
378
|
+
req.on('error', reject);
|
|
381
379
|
});
|
|
382
380
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vrdmr/fnx-test",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "SKU-aware Azure Functions local emulator",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -32,5 +32,8 @@
|
|
|
32
32
|
"engines": {
|
|
33
33
|
"node": ">=18"
|
|
34
34
|
},
|
|
35
|
-
"dependencies": {
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
37
|
+
"zod": "^4.3.6"
|
|
38
|
+
}
|
|
36
39
|
}
|