@vrdmr/fnx-test 0.1.1

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.
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Live MCP server — exposes running host data over HTTP Streamable transport.
3
+ * Started automatically when `fnx start` launches.
4
+ *
5
+ * Tools:
6
+ * get_host_status — host version, state, uptime, PID, SKU, worker runtime
7
+ * get_functions — list of functions with trigger types and routes
8
+ * get_invocations — recent invocation log (ring buffer)
9
+ * invoke_function — trigger an HTTP function via its route
10
+ * get_app_settings — merged config with secrets redacted
11
+ * get_errors — recent host errors
12
+ */
13
+
14
+ 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
+
25
+ // ─── Tool registration (called per session) ─────────────────────────
26
+
27
+ function registerTools(server, hostState, z) {
28
+ server.registerTool(
29
+ 'get_host_status',
30
+ {
31
+ title: 'Get Host Status',
32
+ description: `Get the current state of the running Azure Functions host.
33
+ Returns: host version, state, uptime, PID, SKU, extension bundle version, worker runtime, port.
34
+ Use this first to check if the host is healthy before querying functions or invocations.`,
35
+ inputSchema: z.object({}),
36
+ },
37
+ async () => {
38
+ const uptimeMs = Date.now() - hostState.startedAt;
39
+ const uptimeMin = (uptimeMs / 60000).toFixed(1);
40
+ const totalInvocations = hostState.invocations.length;
41
+ const failedCount = hostState.invocations.filter(i => i.status !== 'Succeeded').length;
42
+
43
+ let text = `# Host Status\n\n`;
44
+ text += `| Property | Value |\n|---|---|\n`;
45
+ text += `| State | ${hostState.state} |\n`;
46
+ text += `| PID | ${hostState.pid || 'N/A'} |\n`;
47
+ text += `| Uptime | ${uptimeMin} minutes |\n`;
48
+ text += `| Host Version | ${hostState.hostVersion} |\n`;
49
+ text += `| SKU | ${hostState.skuName} |\n`;
50
+ text += `| Extension Bundle | ${hostState.extensionBundleVersion} |\n`;
51
+ text += `| Worker Runtime | ${hostState.workerRuntime} |\n`;
52
+ text += `| Port | ${hostState.port} |\n`;
53
+ text += `| Base URL | ${hostState.baseUrl || 'not yet listening'} |\n`;
54
+ text += `| Total Invocations | ${totalInvocations} |\n`;
55
+ text += `| Failed Invocations | ${failedCount} |\n`;
56
+ text += `| Errors | ${hostState.errors.length} |\n`;
57
+
58
+ return { content: [{ type: 'text', text }] };
59
+ }
60
+ );
61
+
62
+ server.registerTool(
63
+ 'get_functions',
64
+ {
65
+ title: 'Get Functions',
66
+ description: `List all functions in the running app with trigger types, routes, and methods.
67
+ Returns HTTP functions (with route and allowed methods) and non-HTTP functions (blob, timer, queue triggers).`,
68
+ inputSchema: z.object({}),
69
+ },
70
+ async () => {
71
+ const http = hostState.httpFunctions || [];
72
+ const nonHttp = hostState.nonHttpFunctions || [];
73
+
74
+ if (http.length === 0 && nonHttp.length === 0) {
75
+ return { content: [{ type: 'text', text: 'No functions discovered yet. The host may still be starting.' }] };
76
+ }
77
+
78
+ let text = `# Functions (${http.length + nonHttp.length} total)\n\n`;
79
+
80
+ if (http.length > 0) {
81
+ text += `## HTTP Functions\n\n`;
82
+ text += `| Function | Methods | Route | URL |\n|---|---|---|---|\n`;
83
+ for (const fn of http) {
84
+ const url = hostState.baseUrl ? `${hostState.baseUrl}/${fn.route}` : fn.route;
85
+ text += `| ${fn.name} | ${fn.methods} | /${fn.route} | ${url} |\n`;
86
+ }
87
+ text += `\n`;
88
+ }
89
+
90
+ if (nonHttp.length > 0) {
91
+ text += `## Non-HTTP Functions\n\n`;
92
+ text += `| Function | Trigger Type |\n|---|---|\n`;
93
+ for (const fn of nonHttp) {
94
+ text += `| ${fn.name} | ${fn.triggerType} |\n`;
95
+ }
96
+ }
97
+
98
+ return { content: [{ type: 'text', text }] };
99
+ }
100
+ );
101
+
102
+ server.registerTool(
103
+ 'get_invocations',
104
+ {
105
+ title: 'Get Invocations',
106
+ description: `Get recent function invocations with status, duration, and trigger reason.
107
+ Filterable by function name and status. Returns newest first (up to 200 stored).`,
108
+ inputSchema: z.object({
109
+ functionName: z.string().optional().describe('Filter by function name'),
110
+ status: z.string().optional().describe('Filter by status: Succeeded, Failed'),
111
+ limit: z.number().optional().describe('Max results to return (default: 20)'),
112
+ }),
113
+ },
114
+ async (args) => {
115
+ let invocations = [...hostState.invocations].reverse();
116
+
117
+ if (args.functionName) {
118
+ invocations = invocations.filter(i => i.functionName === args.functionName);
119
+ }
120
+ if (args.status) {
121
+ invocations = invocations.filter(i => i.status === args.status);
122
+ }
123
+
124
+ const limit = args.limit || 20;
125
+ invocations = invocations.slice(0, limit);
126
+
127
+ if (invocations.length === 0) {
128
+ return { content: [{ type: 'text', text: 'No invocations recorded yet.' }] };
129
+ }
130
+
131
+ let text = `# Recent Invocations (${invocations.length})\n\n`;
132
+ text += `| Function | Status | Duration | Reason | Time |\n|---|---|---|---|---|\n`;
133
+ for (const inv of invocations) {
134
+ text += `| ${inv.functionName} | ${inv.status} | ${inv.durationMs}ms | ${inv.reason} | ${inv.timestamp} |\n`;
135
+ }
136
+
137
+ const succeeded = hostState.invocations.filter(i => i.status === 'Succeeded').length;
138
+ const failed = hostState.invocations.filter(i => i.status !== 'Succeeded').length;
139
+ const avgDuration = hostState.invocations.length > 0
140
+ ? (hostState.invocations.reduce((sum, i) => sum + i.durationMs, 0) / hostState.invocations.length).toFixed(0)
141
+ : 0;
142
+
143
+ text += `\n**Summary**: ${succeeded} succeeded, ${failed} failed, avg ${avgDuration}ms\n`;
144
+
145
+ return { content: [{ type: 'text', text }] };
146
+ }
147
+ );
148
+
149
+ server.registerTool(
150
+ 'invoke_function',
151
+ {
152
+ title: 'Invoke Function',
153
+ description: `Invoke an HTTP function by name. Sends a request to the function's route and returns the response.
154
+ Only HTTP functions can be invoked. For non-HTTP, upload data to the trigger source (e.g., blob to storage).`,
155
+ inputSchema: z.object({
156
+ functionName: z.string().describe('Name of the HTTP function to invoke'),
157
+ method: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional().describe('HTTP method (default: GET)'),
158
+ body: z.string().optional().describe('Request body (for POST/PUT)'),
159
+ queryString: z.string().optional().describe('Query string to append (e.g., "name=World")'),
160
+ }),
161
+ },
162
+ async (args) => {
163
+ const fn = (hostState.httpFunctions || []).find(f => f.name === args.functionName);
164
+ if (!fn) {
165
+ const available = (hostState.httpFunctions || []).map(f => f.name).join(', ');
166
+ return {
167
+ content: [{ type: 'text', text: `Function "${args.functionName}" not found or not an HTTP function. Available: ${available || 'none'}` }],
168
+ isError: true,
169
+ };
170
+ }
171
+
172
+ if (!hostState.baseUrl) {
173
+ return { content: [{ type: 'text', text: 'Host is not yet listening. Wait for startup to complete.' }], isError: true };
174
+ }
175
+
176
+ let url = `${hostState.baseUrl}/${fn.route}`;
177
+ if (args.queryString) url += `?${args.queryString}`;
178
+
179
+ const method = args.method || 'GET';
180
+ const fetchOpts = { method };
181
+ if (args.body && (method === 'POST' || method === 'PUT')) {
182
+ fetchOpts.body = args.body;
183
+ fetchOpts.headers = { 'Content-Type': 'application/json' };
184
+ }
185
+
186
+ try {
187
+ const response = await fetch(url, fetchOpts);
188
+ const responseText = await response.text();
189
+ let text = `# Invocation Result\n\n`;
190
+ text += `**URL**: ${method} ${url}\n`;
191
+ text += `**Status**: ${response.status} ${response.statusText}\n\n`;
192
+ text += `**Response**:\n\`\`\`\n${responseText}\n\`\`\`\n`;
193
+ return { content: [{ type: 'text', text }] };
194
+ } catch (err) {
195
+ return {
196
+ content: [{ type: 'text', text: `Failed to invoke: ${err.message}` }],
197
+ isError: true,
198
+ };
199
+ }
200
+ }
201
+ );
202
+
203
+ server.registerTool(
204
+ 'get_app_settings',
205
+ {
206
+ title: 'Get App Settings',
207
+ description: `Get merged app settings (app.config.json + local.settings.json) with secrets redacted.
208
+ Shows environment variables injected into the host process.`,
209
+ inputSchema: z.object({}),
210
+ },
211
+ async () => {
212
+ const settings = hostState.appSettings || {};
213
+ if (Object.keys(settings).length === 0) {
214
+ return { content: [{ type: 'text', text: 'No app settings available.' }] };
215
+ }
216
+
217
+ let text = `# App Settings\n\n`;
218
+ text += `| Key | Value |\n|---|---|\n`;
219
+ for (const [key, value] of Object.entries(settings)) {
220
+ text += `| ${key} | ${value} |\n`;
221
+ }
222
+ return { content: [{ type: 'text', text }] };
223
+ }
224
+ );
225
+
226
+ server.registerTool(
227
+ 'get_errors',
228
+ {
229
+ title: 'Get Errors',
230
+ description: `Get recent host errors and failures with timestamps.
231
+ Quick health check without digging through verbose logs.`,
232
+ inputSchema: z.object({}),
233
+ },
234
+ async () => {
235
+ if (hostState.errors.length === 0) {
236
+ return { content: [{ type: 'text', text: '✅ No errors recorded.' }] };
237
+ }
238
+
239
+ let text = `# Host Errors (${hostState.errors.length})\n\n`;
240
+ for (const err of hostState.errors.slice(-20)) {
241
+ text += `- **${err.timestamp}**: ${err.message}\n`;
242
+ }
243
+ return { content: [{ type: 'text', text }] };
244
+ }
245
+ );
246
+ }
247
+
248
+ // ─── Start live MCP server ──────────────────────────────────────────
249
+
250
+ export async function startLiveMcpServer(hostState, mcpPort) {
251
+ const { McpServer } = await import(require.resolve('@modelcontextprotocol/sdk/server/mcp.js'));
252
+ const { StreamableHTTPServerTransport } = await import(
253
+ require.resolve('@modelcontextprotocol/sdk/server/streamableHttp.js')
254
+ );
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
+
265
+ registerTools(server, hostState, z);
266
+ return server;
267
+ }
268
+
269
+ // ─── Start HTTP server with Streamable HTTP transport ───────────────
270
+
271
+ const transports = new Map(); // sessionId → { transport, server }
272
+
273
+ const httpServer = createHttpServer(async (req, res) => {
274
+ // CORS headers for browser-based MCP clients
275
+ res.setHeader('Access-Control-Allow-Origin', '*');
276
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
277
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, Accept');
278
+ res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
279
+
280
+ if (req.method === 'OPTIONS') {
281
+ res.writeHead(204);
282
+ res.end();
283
+ return;
284
+ }
285
+
286
+ // Health check endpoint
287
+ if (req.url === '/health') {
288
+ res.writeHead(200, { 'Content-Type': 'application/json' });
289
+ res.end(JSON.stringify({ status: 'ok', hostState: hostState.state }));
290
+ return;
291
+ }
292
+
293
+ // MCP endpoint at /mcp
294
+ 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();
308
+ const transport = new StreamableHTTPServerTransport({
309
+ sessionIdGenerator: () => randomUUID(),
310
+ onsessioninitialized: (sid) => {
311
+ transports.set(sid, { transport, server: mcpServer });
312
+ },
313
+ });
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;
344
+ }
345
+ res.writeHead(404, { 'Content-Type': 'application/json' });
346
+ res.end(JSON.stringify({ error: 'Session not found' }));
347
+ return;
348
+ }
349
+
350
+ 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
+ }
358
+ }
359
+ return;
360
+ }
361
+
362
+ // 404 for everything else
363
+ res.writeHead(404, { 'Content-Type': 'application/json' });
364
+ res.end(JSON.stringify({ error: 'Not found. MCP endpoint is at /mcp' }));
365
+ });
366
+
367
+ 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);
372
+ } else {
373
+ console.error(` ⚠️ MCP server error: ${err.message}`);
374
+ }
375
+ });
376
+
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);
380
+ });
381
+ });
382
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Hand-rolled JSON-RPC 2.0 MCP server over stdio.
3
+ * Zero npm dependencies — uses only Node.js builtins.
4
+ *
5
+ * Supports: initialize, notifications/initialized, tools/list, tools/call, ping
6
+ */
7
+
8
+ import { createInterface } from 'node:readline';
9
+
10
+ const PROTOCOL_VERSION = '2024-11-05';
11
+
12
+ /**
13
+ * Creates and runs an MCP server over stdio.
14
+ *
15
+ * @param {object} opts
16
+ * @param {string} opts.name - Server name
17
+ * @param {string} opts.version - Server version
18
+ * @param {Array<{name: string, description: string, inputSchema: object, handler: function}>} opts.tools - Tool definitions
19
+ */
20
+ export async function runStdioMcpServer({ name, version, tools }) {
21
+ const toolMap = new Map();
22
+ for (const tool of tools) {
23
+ toolMap.set(tool.name, tool);
24
+ }
25
+
26
+ function sendResponse(response) {
27
+ const json = JSON.stringify(response);
28
+ return new Promise((resolve) => {
29
+ process.stdout.write(json + '\n', resolve);
30
+ });
31
+ }
32
+
33
+ // Track pending async tool calls so we don't exit before they complete
34
+ const pending = new Set();
35
+
36
+ function handleMessage(msg) {
37
+ // Notifications have no id — no response needed
38
+ if (msg.id === undefined || msg.id === null) {
39
+ return; // e.g. notifications/initialized
40
+ }
41
+
42
+ const { id, method, params } = msg;
43
+
44
+ if (method === 'initialize') {
45
+ sendResponse({
46
+ jsonrpc: '2.0',
47
+ id,
48
+ result: {
49
+ protocolVersion: PROTOCOL_VERSION,
50
+ capabilities: { tools: {} },
51
+ serverInfo: { name, version },
52
+ },
53
+ });
54
+ return;
55
+ }
56
+
57
+ if (method === 'ping') {
58
+ sendResponse({ jsonrpc: '2.0', id, result: {} });
59
+ return;
60
+ }
61
+
62
+ if (method === 'tools/list') {
63
+ const toolList = tools.map((t) => ({
64
+ name: t.name,
65
+ description: t.description,
66
+ inputSchema: t.inputSchema || { type: 'object', properties: {} },
67
+ }));
68
+ sendResponse({ jsonrpc: '2.0', id, result: { tools: toolList } });
69
+ return;
70
+ }
71
+
72
+ if (method === 'tools/call') {
73
+ const toolName = params?.name;
74
+ const toolArgs = params?.arguments || {};
75
+ const tool = toolMap.get(toolName);
76
+
77
+ if (!tool) {
78
+ sendResponse({
79
+ jsonrpc: '2.0',
80
+ id,
81
+ result: {
82
+ content: [{ type: 'text', text: `Unknown tool: ${toolName}` }],
83
+ isError: true,
84
+ },
85
+ });
86
+ return;
87
+ }
88
+
89
+ // Call handler async, track the promise
90
+ const p = Promise.resolve(tool.handler(toolArgs))
91
+ .then((result) => {
92
+ sendResponse({ jsonrpc: '2.0', id, result });
93
+ })
94
+ .catch((err) => {
95
+ sendResponse({
96
+ jsonrpc: '2.0',
97
+ id,
98
+ result: {
99
+ content: [{ type: 'text', text: `Tool error: ${err.message}` }],
100
+ isError: true,
101
+ },
102
+ });
103
+ })
104
+ .finally(() => pending.delete(p));
105
+ pending.add(p);
106
+ return;
107
+ }
108
+
109
+ // Unknown method
110
+ sendResponse({
111
+ jsonrpc: '2.0',
112
+ id,
113
+ error: { code: -32601, message: `Method not found: ${method}` },
114
+ });
115
+ }
116
+
117
+ // Read newline-delimited JSON from stdin
118
+ const rl = createInterface({ input: process.stdin });
119
+ let stdinClosed = false;
120
+
121
+ rl.on('line', (line) => {
122
+ const trimmed = line.trim();
123
+ if (!trimmed) return;
124
+ try {
125
+ const msg = JSON.parse(trimmed);
126
+ handleMessage(msg);
127
+ } catch (err) {
128
+ process.stderr.write(`[MCP] Parse error: ${err.message}\n`);
129
+ }
130
+ });
131
+
132
+ rl.on('close', async () => {
133
+ stdinClosed = true;
134
+ // Wait for any in-flight tool calls to complete before exiting
135
+ if (pending.size > 0) {
136
+ await Promise.allSettled([...pending]);
137
+ }
138
+ process.exit(0);
139
+ });
140
+
141
+ // Handle signals gracefully
142
+ process.on('SIGINT', () => process.exit(0));
143
+ process.on('SIGTERM', () => process.exit(0));
144
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * MCP SKU tool definitions for the hand-rolled MCP server.
3
+ * Uses profile-resolver to read SKU profiles (bundled/cached/CDN).
4
+ *
5
+ * Tools: get_sku_profile, compare_skus
6
+ */
7
+
8
+ import { resolveProfile, listProfiles } from '../profile-resolver.js';
9
+ import { readFile } from 'node:fs/promises';
10
+ import { dirname, join } from 'node:path';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const __dirname = dirname(fileURLToPath(import.meta.url));
14
+ const BUNDLED_PROFILES_PATH = join(__dirname, '..', '..', 'profiles', 'sku-profiles.json');
15
+
16
+ async function loadAllProfiles() {
17
+ // Try bundled profiles directly (fast, no network)
18
+ const raw = await readFile(BUNDLED_PROFILES_PATH, 'utf-8');
19
+ return JSON.parse(raw);
20
+ }
21
+
22
+ export function getSkuTools() {
23
+ return [
24
+ {
25
+ name: 'get_sku_profile',
26
+ description:
27
+ `Get SKU profile details for Azure Functions hosting plans.\n\n` +
28
+ `Returns host version, extension bundle version, status, and notes.\n` +
29
+ `Call with no arguments to list all SKUs, or provide a SKU name for details.\n` +
30
+ `Use this to check SKU compatibility before suggesting features.`,
31
+ inputSchema: {
32
+ type: 'object',
33
+ properties: {
34
+ sku: {
35
+ type: 'string',
36
+ description:
37
+ 'SKU name (e.g., flex, linux-premium, windows-consumption). Omit to list all.',
38
+ },
39
+ },
40
+ },
41
+ async handler(args) {
42
+ try {
43
+ if (args.sku) {
44
+ const profile = await resolveProfile(args.sku);
45
+ let text = `# SKU Profile: ${profile.displayName}\n\n`;
46
+ text += `| Property | Value |\n|---|---|\n`;
47
+ text += `| SKU Key | ${args.sku} |\n`;
48
+ text += `| Display Name | ${profile.displayName} |\n`;
49
+ text += `| Host Version | ${profile.hostVersion} |\n`;
50
+ text += `| Host Git Tag | ${profile.hostGitTag} |\n`;
51
+ text += `| Extension Bundle | ${profile.extensionBundleVersion} |\n`;
52
+ text += `| Max Bundle Version | ${profile.maxExtensionBundleVersion || 'n/a'} |\n`;
53
+ text += `| Status | ${profile.status} |\n`;
54
+ if (profile.retirementDate) {
55
+ text += `| Retirement Date | ${profile.retirementDate} |\n`;
56
+ }
57
+ text += `| Notes | ${profile.notes} |\n`;
58
+ return { content: [{ type: 'text', text }] };
59
+ }
60
+
61
+ // List all profiles
62
+ const registry = await loadAllProfiles();
63
+ let text = `# Azure Functions SKU Profiles\n\n`;
64
+ text += `| SKU | Display Name | Host Version | Bundle Version | Max Bundle | Status |\n`;
65
+ text += `|-----|-------------|-------------|---------------|-----------|--------|\n`;
66
+ for (const [key, p] of Object.entries(registry.profiles)) {
67
+ text += `| ${key} | ${p.displayName} | ${p.hostVersion} | ${p.extensionBundleVersion} | ${p.maxExtensionBundleVersion || 'n/a'} | ${p.status} |\n`;
68
+ }
69
+ text += `\n*Last updated: ${registry.updatedAt}*\n`;
70
+ return { content: [{ type: 'text', text }] };
71
+ } catch (err) {
72
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
73
+ }
74
+ },
75
+ },
76
+ {
77
+ name: 'compare_skus',
78
+ description:
79
+ `Compare two Azure Functions SKU profiles side by side.\n\n` +
80
+ `Shows differences in host version, bundle version, status, and capabilities.\n` +
81
+ `Use this to understand deployment target differences.`,
82
+ inputSchema: {
83
+ type: 'object',
84
+ properties: {
85
+ sku1: { type: 'string', description: 'First SKU name (e.g., flex)' },
86
+ sku2: { type: 'string', description: 'Second SKU name (e.g., windows-consumption)' },
87
+ },
88
+ required: ['sku1', 'sku2'],
89
+ },
90
+ async handler(args) {
91
+ try {
92
+ const [p1, p2] = await Promise.all([
93
+ resolveProfile(args.sku1),
94
+ resolveProfile(args.sku2),
95
+ ]);
96
+
97
+ let text = `# SKU Comparison: ${args.sku1} vs ${args.sku2}\n\n`;
98
+ text += `| Property | ${p1.displayName} | ${p2.displayName} |\n`;
99
+ text += `|----------|---|---|\n`;
100
+ text += `| Host Version | ${p1.hostVersion} | ${p2.hostVersion} |\n`;
101
+ text += `| Extension Bundle | ${p1.extensionBundleVersion} | ${p2.extensionBundleVersion} |\n`;
102
+ text += `| Max Bundle | ${p1.maxExtensionBundleVersion || 'n/a'} | ${p2.maxExtensionBundleVersion || 'n/a'} |\n`;
103
+ text += `| Status | ${p1.status} | ${p2.status} |\n`;
104
+
105
+ // Highlight differences
106
+ const diffs = [];
107
+ if (p1.hostVersion !== p2.hostVersion) {
108
+ diffs.push(`Host version differs: ${p1.hostVersion} vs ${p2.hostVersion}`);
109
+ }
110
+ if (p1.extensionBundleVersion !== p2.extensionBundleVersion) {
111
+ diffs.push(`Bundle version range differs: ${p1.extensionBundleVersion} vs ${p2.extensionBundleVersion}`);
112
+ }
113
+ if (p1.maxExtensionBundleVersion !== p2.maxExtensionBundleVersion) {
114
+ diffs.push(`Max bundle cap differs: ${p1.maxExtensionBundleVersion || 'n/a'} vs ${p2.maxExtensionBundleVersion || 'n/a'}`);
115
+ }
116
+ if (p1.status !== p2.status) {
117
+ diffs.push(`Status differs: ${p1.status} vs ${p2.status}`);
118
+ }
119
+
120
+ if (diffs.length > 0) {
121
+ text += `\n## Key Differences\n\n`;
122
+ for (const d of diffs) {
123
+ text += `- ⚠️ ${d}\n`;
124
+ }
125
+ } else {
126
+ text += `\n✅ These SKUs have identical configurations.\n`;
127
+ }
128
+
129
+ return { content: [{ type: 'text', text }] };
130
+ } catch (err) {
131
+ return { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true };
132
+ }
133
+ },
134
+ },
135
+ ];
136
+ }