@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 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 port = getFlag(args, '--port') || '7071';
47
- const mcpPort = getFlag(args, '--mcp-port') || String(parseInt(port) + 1);
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)).catch((err) => {
142
- console.error(` ⚠️ MCP server failed to start on port ${mcpPort}: ${err.message}`);
143
- console.error(` Use --no-mcp to disable, or --mcp-port <port> to change port.`);
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-live": {
319
+ # "fnx-functions-debug": {
298
320
  # "type": "http",
299
321
  # "url": "http://127.0.0.1:7072/mcp"
300
322
  # }
@@ -382,8 +382,18 @@ export async function launchHost(hostDir, opts) {
382
382
  });
383
383
  }
384
384
 
385
- process.on('SIGINT', () => { stopAzurite(); child.kill('SIGINT'); });
386
- process.on('SIGTERM', () => { stopAzurite(); child.kill('SIGTERM'); });
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) => {
@@ -1,7 +1,11 @@
1
1
  /**
2
- * Live MCP serverexposes running host data over HTTP Streamable transport.
2
+ * Functions Debug MCP Serverstateless 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 live MCP server ──────────────────────────────────────────
243
+ // ─── Start Functions Debug MCP Server (stateless) ───────────────────
249
244
 
250
245
  export async function startLiveMcpServer(hostState, mcpPort) {
251
- const { McpServer } = await import(require.resolve('@modelcontextprotocol/sdk/server/mcp.js'));
246
+ const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
252
247
  const { StreamableHTTPServerTransport } = await import(
253
- require.resolve('@modelcontextprotocol/sdk/server/streamableHttp.js')
248
+ '@modelcontextprotocol/sdk/server/streamableHttp.js'
254
249
  );
255
- const { z } = await import(require.resolve('zod'));
256
- const { randomUUID } = await import('node:crypto');
257
-
258
- // Factory: create a new McpServer per session (SDK requirement)
259
- function createMcpServer() {
260
- const server = new McpServer({
261
- name: 'fnx-live',
262
- version: '0.1.0',
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
- // ─── Start HTTP server with Streamable HTTP transport ───────────────
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 endpoint
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 at /mcp
284
+ // MCP endpoint stateless: new server + transport per POST
294
285
  if (req.url === '/mcp' || req.url?.startsWith('/mcp?')) {
295
- try {
296
- const sessionId = req.headers['mcp-session-id'];
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: () => randomUUID(),
310
- onsessioninitialized: (sid) => {
311
- transports.set(sid, { transport, server: mcpServer });
312
- },
290
+ sessionIdGenerator: undefined, // stateless — no sessions
313
291
  });
314
-
315
- transport.onclose = () => {
316
- const sid = transport.sessionId;
317
- if (sid) transports.delete(sid);
318
- };
319
-
320
- await mcpServer.connect(transport);
321
- await transport.handleRequest(req, res);
322
- return;
323
- }
324
-
325
- if (req.method === 'GET') {
326
- // SSE stream for notifications
327
- if (sessionId && transports.has(sessionId)) {
328
- const { transport } = transports.get(sessionId);
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({ error: 'Method not allowed. Use POST, GET, or DELETE.' }));
352
- } catch (err) {
353
- console.error('[MCP] Error handling request:', err.message);
354
- if (!res.headersSent) {
355
- res.writeHead(500, { 'Content-Type': 'application/json' });
356
- res.end(JSON.stringify({ error: 'Internal server error' }));
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
- httpServer.on('error', (err) => {
369
- // Only reject during startup; after that, log and continue
370
- if (!httpServer.listening) {
371
- reject(err);
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
- console.error(` ⚠️ MCP server error: ${err.message}`);
357
+ reject(err);
374
358
  }
375
- });
359
+ }
360
+
361
+ tryListen();
362
+ });
363
+ }
376
364
 
377
- httpServer.listen(mcpPort, '127.0.0.1', () => {
378
- console.log(` MCP Server: http://127.0.0.1:${mcpPort}/mcp (Streamable HTTP)`);
379
- resolve(httpServer);
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.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
  }