@agimon-ai/mcp-proxy 0.9.0 → 0.9.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.
@@ -1,10 +1,7 @@
1
- import { coerceArgs, formatZodError, jsonSchemaToZod } from "@agimon-ai/foundation-validator";
2
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
- import { CallToolRequestSchema, ElicitRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
4
- import { z } from "zod";
5
1
  import { existsSync } from "node:fs";
6
2
  import { access, mkdir, readFile, readdir, rm, stat, unlink, watch, writeFile } from "node:fs/promises";
7
3
  import yaml from "js-yaml";
4
+ import { z } from "zod";
8
5
  import { createHash, randomBytes, randomUUID } from "node:crypto";
9
6
  import { homedir, tmpdir } from "node:os";
10
7
  import { dirname, isAbsolute, join, resolve } from "node:path";
@@ -15,16 +12,19 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
15
12
  import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
16
13
  import { spawn } from "node:child_process";
17
14
  import { Liquid } from "liquidjs";
15
+ import { coerceArgs, formatZodError, jsonSchemaToZod } from "@agimon-ai/foundation-validator";
18
16
  import { once } from "node:events";
19
17
  import { createServer } from "node:http";
20
18
  import { promisify } from "node:util";
21
19
  import { getRequestListener } from "@hono/node-server";
22
20
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
21
+ import { CallToolRequestSchema, ElicitRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
23
22
  import { Hono } from "hono";
24
23
  import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
25
24
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
26
26
  //#region package.json
27
- var version = "0.8.0";
27
+ var version = "0.9.0";
28
28
  //#endregion
29
29
  //#region src/utils/mcpConfigSchema.ts
30
30
  /**
@@ -943,25 +943,68 @@ function findConfigFile() {
943
943
  return null;
944
944
  }
945
945
  //#endregion
946
- //#region src/utils/parseToolName.ts
946
+ //#region src/utils/generateServerId.ts
947
947
  /**
948
- * Parse tool name to extract server and actual tool name
949
- * Supports both plain tool names and prefixed format: {serverName}__{toolName}
948
+ * generateServerId Utilities
950
949
  *
951
- * @param toolName - The tool name to parse (e.g., "my_tool" or "server__my_tool")
952
- * @returns Parsed result with optional serverName and actualToolName
950
+ * DESIGN PATTERNS:
951
+ * - Pure functions with no side effects
952
+ * - Single responsibility per function
953
+ * - Functional programming approach
954
+ *
955
+ * CODING STANDARDS:
956
+ * - Export individual functions, not classes
957
+ * - Use descriptive function names with verbs
958
+ * - Add JSDoc comments for complex logic
959
+ * - Keep functions small and focused
960
+ *
961
+ * AVOID:
962
+ * - Side effects (mutating external state)
963
+ * - Stateful logic (use services for state)
964
+ * - Complex external dependencies
965
+ */
966
+ /**
967
+ * Character set for generating human-readable IDs.
968
+ * Excludes confusing characters: 0, O, 1, l, I
969
+ */
970
+ const CHARSET = "23456789abcdefghjkmnpqrstuvwxyz";
971
+ /**
972
+ * Default length for generated server IDs (6 characters)
973
+ */
974
+ const DEFAULT_ID_LENGTH = 6;
975
+ /**
976
+ * Generate a short, human-readable server ID.
977
+ *
978
+ * Uses Node.js crypto.randomBytes for cryptographically secure randomness
979
+ * with rejection sampling to avoid modulo bias.
980
+ *
981
+ * The generated ID:
982
+ * - Is 6 characters long by default
983
+ * - Uses only lowercase alphanumeric characters
984
+ * - Excludes confusing characters (0, O, 1, l, I)
985
+ *
986
+ * @param length - Length of the ID to generate (default: 6)
987
+ * @returns A random, human-readable ID
953
988
  *
954
989
  * @example
955
- * parseToolName("my_tool") // { actualToolName: "my_tool" }
956
- * parseToolName("server__my_tool") // { serverName: "server", actualToolName: "my_tool" }
990
+ * generateServerId() // "abc234"
991
+ * generateServerId(4) // "x7mn"
957
992
  */
958
- function parseToolName(toolName) {
959
- const separatorIndex = toolName.indexOf("__");
960
- if (separatorIndex > 0) return {
961
- serverName: toolName.substring(0, separatorIndex),
962
- actualToolName: toolName.substring(separatorIndex + 2)
963
- };
964
- return { actualToolName: toolName };
993
+ function generateServerId(length = DEFAULT_ID_LENGTH) {
994
+ const charsetLength = 31;
995
+ const maxUnbiased = Math.floor(256 / charsetLength) * charsetLength - 1;
996
+ let result = "";
997
+ let remaining = length;
998
+ while (remaining > 0) {
999
+ const bytes = randomBytes(remaining);
1000
+ for (let i = 0; i < bytes.length && remaining > 0; i++) {
1001
+ const byte = bytes[i];
1002
+ if (byte > maxUnbiased) continue;
1003
+ result += CHARSET[byte % charsetLength];
1004
+ remaining--;
1005
+ }
1006
+ }
1007
+ return result;
965
1008
  }
966
1009
  //#endregion
967
1010
  //#region src/utils/parseFrontMatter.ts
@@ -1097,68 +1140,25 @@ function extractSkillFrontMatter(content) {
1097
1140
  return null;
1098
1141
  }
1099
1142
  //#endregion
1100
- //#region src/utils/generateServerId.ts
1101
- /**
1102
- * generateServerId Utilities
1103
- *
1104
- * DESIGN PATTERNS:
1105
- * - Pure functions with no side effects
1106
- * - Single responsibility per function
1107
- * - Functional programming approach
1108
- *
1109
- * CODING STANDARDS:
1110
- * - Export individual functions, not classes
1111
- * - Use descriptive function names with verbs
1112
- * - Add JSDoc comments for complex logic
1113
- * - Keep functions small and focused
1114
- *
1115
- * AVOID:
1116
- * - Side effects (mutating external state)
1117
- * - Stateful logic (use services for state)
1118
- * - Complex external dependencies
1119
- */
1120
- /**
1121
- * Character set for generating human-readable IDs.
1122
- * Excludes confusing characters: 0, O, 1, l, I
1123
- */
1124
- const CHARSET = "23456789abcdefghjkmnpqrstuvwxyz";
1125
- /**
1126
- * Default length for generated server IDs (6 characters)
1127
- */
1128
- const DEFAULT_ID_LENGTH = 6;
1143
+ //#region src/utils/parseToolName.ts
1129
1144
  /**
1130
- * Generate a short, human-readable server ID.
1131
- *
1132
- * Uses Node.js crypto.randomBytes for cryptographically secure randomness
1133
- * with rejection sampling to avoid modulo bias.
1134
- *
1135
- * The generated ID:
1136
- * - Is 6 characters long by default
1137
- * - Uses only lowercase alphanumeric characters
1138
- * - Excludes confusing characters (0, O, 1, l, I)
1145
+ * Parse tool name to extract server and actual tool name
1146
+ * Supports both plain tool names and prefixed format: {serverName}__{toolName}
1139
1147
  *
1140
- * @param length - Length of the ID to generate (default: 6)
1141
- * @returns A random, human-readable ID
1148
+ * @param toolName - The tool name to parse (e.g., "my_tool" or "server__my_tool")
1149
+ * @returns Parsed result with optional serverName and actualToolName
1142
1150
  *
1143
1151
  * @example
1144
- * generateServerId() // "abc234"
1145
- * generateServerId(4) // "x7mn"
1152
+ * parseToolName("my_tool") // { actualToolName: "my_tool" }
1153
+ * parseToolName("server__my_tool") // { serverName: "server", actualToolName: "my_tool" }
1146
1154
  */
1147
- function generateServerId(length = DEFAULT_ID_LENGTH) {
1148
- const charsetLength = 31;
1149
- const maxUnbiased = Math.floor(256 / charsetLength) * charsetLength - 1;
1150
- let result = "";
1151
- let remaining = length;
1152
- while (remaining > 0) {
1153
- const bytes = randomBytes(remaining);
1154
- for (let i = 0; i < bytes.length && remaining > 0; i++) {
1155
- const byte = bytes[i];
1156
- if (byte > maxUnbiased) continue;
1157
- result += CHARSET[byte % charsetLength];
1158
- remaining--;
1159
- }
1160
- }
1161
- return result;
1155
+ function parseToolName(toolName) {
1156
+ const separatorIndex = toolName.indexOf("__");
1157
+ if (separatorIndex > 0) return {
1158
+ serverName: toolName.substring(0, separatorIndex),
1159
+ actualToolName: toolName.substring(separatorIndex + 2)
1160
+ };
1161
+ return { actualToolName: toolName };
1162
1162
  }
1163
1163
  //#endregion
1164
1164
  //#region src/services/DefinitionsCacheService.ts
@@ -1871,304 +1871,440 @@ var McpClientManagerService = class {
1871
1871
  return this.clients.has(serverName);
1872
1872
  }
1873
1873
  };
1874
+ /** pnpx command name (pnpm's npx equivalent) */
1875
+ const COMMAND_PNPX = "pnpx";
1876
+ /** pnpm command name */
1877
+ const COMMAND_PNPM = "pnpm";
1878
+ /** Tool subcommand for uv */
1879
+ const ARG_TOOL = "tool";
1880
+ /** Install subcommand for uv tool and npm/pnpm */
1881
+ const ARG_INSTALL = "install";
1882
+ /**
1883
+ * Regex pattern for valid package names (npm, pnpm, uvx, uv)
1884
+ * Allows: @scope/package-name@version, package-name, package_name
1885
+ * Prevents shell metacharacters that could enable command injection
1886
+ * @example
1887
+ * // Valid: '@scope/package@1.0.0', 'my-package', 'my_package', '@org/pkg'
1888
+ * // Invalid: 'pkg; rm -rf /', 'pkg$(cmd)', 'pkg`whoami`', 'pkg|cat /etc/passwd'
1889
+ */
1890
+ const VALID_PACKAGE_NAME_PATTERN = /^(@[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
1891
+ /** Windows platform identifier */
1892
+ const PLATFORM_WIN32 = "win32";
1893
+ /** Stdio option to ignore stream */
1894
+ const STDIO_IGNORE = "ignore";
1895
+ /** Stdio option to pipe stream */
1896
+ const STDIO_PIPE = "pipe";
1874
1897
  //#endregion
1875
- //#region src/services/RuntimeStateService.ts
1898
+ //#region src/services/PrefetchService/PrefetchService.ts
1876
1899
  /**
1877
- * RuntimeStateService
1900
+ * PrefetchService
1878
1901
  *
1879
- * Persists runtime metadata for HTTP mcp-proxy instances so external commands
1880
- * (for example `mcp-proxy stop`) can discover and target the correct server.
1902
+ * DESIGN PATTERNS:
1903
+ * - Service pattern for business logic encapsulation
1904
+ * - Single responsibility principle
1905
+ *
1906
+ * CODING STANDARDS:
1907
+ * - Use async/await for asynchronous operations
1908
+ * - Throw descriptive errors for error cases
1909
+ * - Keep methods focused and well-named
1910
+ * - Document complex logic with comments
1911
+ *
1912
+ * AVOID:
1913
+ * - Mixing concerns (keep focused on single domain)
1914
+ * - Direct tool implementation (services should be tool-agnostic)
1881
1915
  */
1882
- const RUNTIME_DIR_NAME = "runtimes";
1883
- const RUNTIME_FILE_SUFFIX = ".runtime.json";
1884
- function isObject(value) {
1885
- return typeof value === "object" && value !== null;
1886
- }
1887
- function isRuntimeStateRecord(value) {
1888
- if (!isObject(value)) return false;
1889
- return typeof value.serverId === "string" && typeof value.host === "string" && typeof value.port === "number" && value.transport === "http" && typeof value.shutdownToken === "string" && typeof value.startedAt === "string" && typeof value.pid === "number" && (value.configPath === void 0 || typeof value.configPath === "string");
1890
- }
1891
- function toErrorMessage$2(error) {
1892
- return error instanceof Error ? error.message : String(error);
1916
+ /**
1917
+ * Type guard to check if a config object is an McpStdioConfig
1918
+ * @param config - Config object to check
1919
+ * @returns True if config has required McpStdioConfig properties
1920
+ */
1921
+ function isMcpStdioConfig(config) {
1922
+ return typeof config === "object" && config !== null && "command" in config;
1893
1923
  }
1894
1924
  /**
1895
- * Runtime state persistence implementation.
1925
+ * PrefetchService handles pre-downloading packages used by MCP servers.
1926
+ * Supports npx (Node.js), uvx (Python/uv), and uv run commands.
1927
+ *
1928
+ * @example
1929
+ * ```typescript
1930
+ * const service = new PrefetchService({
1931
+ * mcpConfig: await configFetcher.fetchConfiguration(),
1932
+ * parallel: true,
1933
+ * });
1934
+ * const packages = service.extractPackages();
1935
+ * const summary = await service.prefetch();
1936
+ * ```
1896
1937
  */
1897
- var RuntimeStateService = class RuntimeStateService {
1898
- runtimeDir;
1899
- logger;
1900
- constructor(runtimeDir, logger = console) {
1901
- this.runtimeDir = runtimeDir ?? RuntimeStateService.getDefaultRuntimeDir();
1902
- this.logger = logger;
1903
- }
1904
- /**
1905
- * Resolve default runtime directory under the user's home cache path.
1906
- * @returns Absolute runtime directory path
1907
- */
1908
- static getDefaultRuntimeDir() {
1909
- return join(homedir(), ".aicode-toolkit", "mcp-proxy", RUNTIME_DIR_NAME);
1910
- }
1938
+ var PrefetchService = class {
1939
+ config;
1911
1940
  /**
1912
- * Build runtime state file path for a given server ID.
1913
- * @param serverId - Target mcp-proxy server identifier
1914
- * @returns Absolute runtime file path
1941
+ * Creates a new PrefetchService instance
1942
+ * @param config - Service configuration options
1915
1943
  */
1916
- getRecordPath(serverId) {
1917
- return join(this.runtimeDir, `${serverId}${RUNTIME_FILE_SUFFIX}`);
1944
+ constructor(config) {
1945
+ this.config = config;
1918
1946
  }
1919
1947
  /**
1920
- * Persist a runtime state record.
1921
- * @param record - Runtime metadata to persist
1922
- * @returns Promise that resolves when write completes
1948
+ * Extract all prefetchable packages from the MCP configuration
1949
+ * @returns Array of package info objects
1923
1950
  */
1924
- async write(record) {
1925
- await mkdir(this.runtimeDir, { recursive: true });
1926
- await writeFile(this.getRecordPath(record.serverId), JSON.stringify(record, null, 2), "utf-8");
1951
+ extractPackages() {
1952
+ const packages = [];
1953
+ const { mcpConfig, filter } = this.config;
1954
+ for (const [serverName, serverConfig] of Object.entries(mcpConfig.mcpServers)) {
1955
+ if (serverConfig.disabled) continue;
1956
+ if (serverConfig.transport !== "stdio") continue;
1957
+ if (!isMcpStdioConfig(serverConfig.config)) continue;
1958
+ const packageInfo = this.extractPackageInfo(serverName, serverConfig.config);
1959
+ if (packageInfo) {
1960
+ if (filter && packageInfo.packageManager !== filter) continue;
1961
+ packages.push(packageInfo);
1962
+ }
1963
+ }
1964
+ return packages;
1927
1965
  }
1928
1966
  /**
1929
- * Read a runtime state record by server ID.
1930
- * @param serverId - Target mcp-proxy server identifier
1931
- * @returns Matching runtime record, or null when no record exists
1967
+ * Prefetch all packages from the configuration
1968
+ * @returns Summary of prefetch results
1969
+ * @throws Error if prefetch operation fails unexpectedly
1932
1970
  */
1933
- async read(serverId) {
1934
- const filePath = this.getRecordPath(serverId);
1971
+ async prefetch() {
1935
1972
  try {
1936
- const content = await readFile(filePath, "utf-8");
1937
- const parsed = JSON.parse(content);
1938
- return isRuntimeStateRecord(parsed) ? parsed : null;
1973
+ const packages = this.extractPackages();
1974
+ const results = [];
1975
+ if (packages.length === 0) return {
1976
+ totalPackages: 0,
1977
+ successful: 0,
1978
+ failed: 0,
1979
+ results: []
1980
+ };
1981
+ if (this.config.parallel) {
1982
+ const promises = packages.map(async (pkg) => this.prefetchPackage(pkg));
1983
+ results.push(...await Promise.all(promises));
1984
+ } else for (const pkg of packages) {
1985
+ const result = await this.prefetchPackage(pkg);
1986
+ results.push(result);
1987
+ }
1988
+ const successful = results.filter((r) => r.success).length;
1989
+ const failed = results.filter((r) => !r.success).length;
1990
+ return {
1991
+ totalPackages: packages.length,
1992
+ successful,
1993
+ failed,
1994
+ results
1995
+ };
1939
1996
  } catch (error) {
1940
- if (isObject(error) && "code" in error && error.code === "ENOENT") return null;
1941
- throw new Error(`Failed to read runtime state for server '${serverId}' from '${filePath}': ${toErrorMessage$2(error)}`);
1997
+ throw new Error(`Failed to prefetch packages: ${error instanceof Error ? error.message : String(error)}`);
1942
1998
  }
1943
1999
  }
1944
2000
  /**
1945
- * List all persisted runtime records.
1946
- * @returns Array of runtime records
2001
+ * Prefetch a single package
2002
+ * @param pkg - Package info to prefetch
2003
+ * @returns Result of the prefetch operation
1947
2004
  */
1948
- async list() {
2005
+ async prefetchPackage(pkg) {
1949
2006
  try {
1950
- const files = (await readdir(this.runtimeDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(RUNTIME_FILE_SUFFIX));
1951
- return (await Promise.all(files.map(async (file) => {
1952
- try {
1953
- const content = await readFile(join(this.runtimeDir, file.name), "utf-8");
1954
- const parsed = JSON.parse(content);
1955
- return isRuntimeStateRecord(parsed) ? parsed : null;
1956
- } catch (error) {
1957
- this.logger.debug(`Skipping unreadable runtime state file ${file.name}`, error);
1958
- return null;
1959
- }
1960
- }))).filter((record) => record !== null);
2007
+ const [command, ...args] = pkg.fullCommand;
2008
+ const result = await this.runCommand(command, args);
2009
+ return {
2010
+ package: pkg,
2011
+ success: result.success,
2012
+ output: result.output
2013
+ };
1961
2014
  } catch (error) {
1962
- if (isObject(error) && "code" in error && error.code === "ENOENT") return [];
1963
- throw new Error(`Failed to list runtime states from '${this.runtimeDir}': ${toErrorMessage$2(error)}`);
2015
+ return {
2016
+ package: pkg,
2017
+ success: false,
2018
+ output: error instanceof Error ? error.message : String(error)
2019
+ };
1964
2020
  }
1965
2021
  }
1966
2022
  /**
1967
- * Remove a runtime state record by server ID.
1968
- * @param serverId - Target mcp-proxy server identifier
1969
- * @returns Promise that resolves when delete completes
2023
+ * Validate package name to prevent command injection
2024
+ * @param packageName - Package name to validate
2025
+ * @returns True if package name is safe, false otherwise
2026
+ * @remarks Rejects package names containing shell metacharacters
2027
+ * @example
2028
+ * isValidPackageName('@scope/package') // true
2029
+ * isValidPackageName('my-package@1.0.0') // true
2030
+ * isValidPackageName('pkg; rm -rf /') // false (shell injection)
2031
+ * isValidPackageName('pkg$(whoami)') // false (command substitution)
1970
2032
  */
1971
- async remove(serverId) {
1972
- await rm(this.getRecordPath(serverId), { force: true });
2033
+ isValidPackageName(packageName) {
2034
+ return VALID_PACKAGE_NAME_PATTERN.test(packageName);
2035
+ }
2036
+ /**
2037
+ * Extract package info from a server's stdio config
2038
+ * @param serverName - Name of the MCP server
2039
+ * @param config - Stdio configuration for the server
2040
+ * @returns Package info if extractable, null otherwise
2041
+ */
2042
+ extractPackageInfo(serverName, config) {
2043
+ const command = config.command.toLowerCase();
2044
+ const args = config.args || [];
2045
+ if (command === "npx" || command.endsWith("/npx")) {
2046
+ const packageName = this.extractNpxPackage(args);
2047
+ if (packageName && this.isValidPackageName(packageName)) return {
2048
+ serverName,
2049
+ packageManager: "npx",
2050
+ packageName,
2051
+ fullCommand: [
2052
+ "npm",
2053
+ ARG_INSTALL,
2054
+ "-g",
2055
+ packageName
2056
+ ]
2057
+ };
2058
+ }
2059
+ if (command === "pnpx" || command.endsWith("/pnpx")) {
2060
+ const packageName = this.extractNpxPackage(args);
2061
+ if (packageName && this.isValidPackageName(packageName)) return {
2062
+ serverName,
2063
+ packageManager: COMMAND_PNPX,
2064
+ packageName,
2065
+ fullCommand: [
2066
+ COMMAND_PNPM,
2067
+ "add",
2068
+ "-g",
2069
+ packageName
2070
+ ]
2071
+ };
2072
+ }
2073
+ if (command === "uvx" || command.endsWith("/uvx")) {
2074
+ const packageName = this.extractUvxPackage(args);
2075
+ if (packageName && this.isValidPackageName(packageName)) return {
2076
+ serverName,
2077
+ packageManager: "uvx",
2078
+ packageName,
2079
+ fullCommand: ["uvx", packageName]
2080
+ };
2081
+ }
2082
+ if ((command === "uv" || command.endsWith("/uv")) && args.includes("run")) {
2083
+ const packageName = this.extractUvRunPackage(args);
2084
+ if (packageName && this.isValidPackageName(packageName)) return {
2085
+ serverName,
2086
+ packageManager: "uv",
2087
+ packageName,
2088
+ fullCommand: [
2089
+ "uv",
2090
+ ARG_TOOL,
2091
+ ARG_INSTALL,
2092
+ packageName
2093
+ ]
2094
+ };
2095
+ }
2096
+ return null;
2097
+ }
2098
+ /**
2099
+ * Extract package name from npx command args
2100
+ * @param args - Command arguments
2101
+ * @returns Package name or null
2102
+ * @remarks Handles --package=value, --package value, -p value patterns.
2103
+ * Falls back to first non-flag argument if no --package/-p flag found.
2104
+ * Returns null if flag has no value or is followed by another flag.
2105
+ * When multiple --package flags exist, returns the first valid one.
2106
+ * @example
2107
+ * extractNpxPackage(['--package=@scope/pkg']) // returns '@scope/pkg'
2108
+ * extractNpxPackage(['--package', 'pkg-name']) // returns 'pkg-name'
2109
+ * extractNpxPackage(['-p', 'pkg']) // returns 'pkg'
2110
+ * extractNpxPackage(['-y', 'pkg-name', '--flag']) // returns 'pkg-name' (fallback)
2111
+ * extractNpxPackage(['--package=']) // returns null (empty value)
2112
+ */
2113
+ extractNpxPackage(args) {
2114
+ for (let i = 0; i < args.length; i++) {
2115
+ const arg = args[i];
2116
+ if (arg.startsWith("--package=")) return arg.slice(10) || null;
2117
+ if (arg === "--package" && i + 1 < args.length) {
2118
+ const nextArg = args[i + 1];
2119
+ if (!nextArg.startsWith("-")) return nextArg;
2120
+ }
2121
+ if (arg === "-p" && i + 1 < args.length) {
2122
+ const nextArg = args[i + 1];
2123
+ if (!nextArg.startsWith("-")) return nextArg;
2124
+ }
2125
+ }
2126
+ for (const arg of args) {
2127
+ if (arg.startsWith("-")) continue;
2128
+ return arg;
2129
+ }
2130
+ return null;
2131
+ }
2132
+ /**
2133
+ * Extract package name from uvx command args
2134
+ * @param args - Command arguments
2135
+ * @returns Package name or null
2136
+ * @remarks Assumes the first non-flag argument is the package name.
2137
+ * Handles both single (-) and double (--) dash flags.
2138
+ * @example
2139
+ * extractUvxPackage(['mcp-server-fetch']) // returns 'mcp-server-fetch'
2140
+ * extractUvxPackage(['--quiet', 'pkg-name']) // returns 'pkg-name'
2141
+ */
2142
+ extractUvxPackage(args) {
2143
+ for (const arg of args) {
2144
+ if (arg.startsWith("-")) continue;
2145
+ return arg;
2146
+ }
2147
+ return null;
2148
+ }
2149
+ /**
2150
+ * Extract package name from uv run command args
2151
+ * @param args - Command arguments
2152
+ * @returns Package name or null
2153
+ * @remarks Looks for the first non-flag argument after the 'run' subcommand.
2154
+ * Returns null if 'run' is not found in args.
2155
+ * @example
2156
+ * extractUvRunPackage(['run', 'mcp-server']) // returns 'mcp-server'
2157
+ * extractUvRunPackage(['run', '--verbose', 'pkg']) // returns 'pkg'
2158
+ * extractUvRunPackage(['install', 'pkg']) // returns null (no 'run')
2159
+ */
2160
+ extractUvRunPackage(args) {
2161
+ const runIndex = args.indexOf("run");
2162
+ if (runIndex === -1) return null;
2163
+ for (let i = runIndex + 1; i < args.length; i++) {
2164
+ const arg = args[i];
2165
+ if (arg.startsWith("-")) continue;
2166
+ return arg;
2167
+ }
2168
+ return null;
2169
+ }
2170
+ /**
2171
+ * Run a shell command and capture output
2172
+ * @param command - Command to run
2173
+ * @param args - Command arguments
2174
+ * @returns Promise with success status and output
2175
+ */
2176
+ runCommand(command, args) {
2177
+ return new Promise((resolve) => {
2178
+ const proc = spawn(command, args, {
2179
+ stdio: [
2180
+ STDIO_IGNORE,
2181
+ STDIO_PIPE,
2182
+ STDIO_PIPE
2183
+ ],
2184
+ shell: process.platform === PLATFORM_WIN32
2185
+ });
2186
+ let stdout = "";
2187
+ let stderr = "";
2188
+ proc.stdout?.on("data", (data) => {
2189
+ stdout += data.toString();
2190
+ });
2191
+ proc.stderr?.on("data", (data) => {
2192
+ stderr += data.toString();
2193
+ });
2194
+ proc.on("close", (code) => {
2195
+ resolve({
2196
+ success: code === 0,
2197
+ output: stdout || stderr
2198
+ });
2199
+ });
2200
+ proc.on("error", (error) => {
2201
+ resolve({
2202
+ success: false,
2203
+ output: error.message
2204
+ });
2205
+ });
2206
+ });
1973
2207
  }
1974
2208
  };
1975
- /** Path for the runtime health check endpoint. */
1976
- const HEALTH_CHECK_PATH = "/health";
1977
- /** Path for the authenticated admin shutdown endpoint. */
1978
- const ADMIN_SHUTDOWN_PATH = "/admin/shutdown";
1979
- /** HTTP POST method identifier. */
1980
- const HTTP_METHOD_POST = "POST";
1981
- /** HTTP header name for bearer token authorization. */
1982
- const AUTHORIZATION_HEADER_NAME = "Authorization";
1983
- /** Prefix for bearer token values in the Authorization header. */
1984
- const BEARER_TOKEN_PREFIX = "Bearer ";
1985
- /** HTTP protocol scheme prefix for URL construction. */
1986
- const HTTP_PROTOCOL = "http://";
1987
- /** Hosts that are safe to send admin requests to (loopback only). */
1988
- const ALLOWED_HOSTS = new Set([
1989
- "localhost",
1990
- "127.0.0.1",
1991
- "::1"
1992
- ]);
1993
2209
  //#endregion
1994
- //#region src/services/StopServerService/types.ts
1995
- /**
1996
- * Safely cast a non-null object to a string-keyed record for property access.
1997
- * @param value - Object value already verified as non-null
1998
- * @returns The same value typed as a record
1999
- */
2000
- function toRecord(value) {
2001
- return value;
2002
- }
2003
- /**
2004
- * Type guard for health responses.
2005
- * @param value - Candidate payload to validate
2006
- * @returns True when payload matches health response shape
2007
- */
2008
- function isHealthResponse(value) {
2009
- if (typeof value !== "object" || value === null) return false;
2010
- const record = toRecord(value);
2011
- return "status" in record && record["status"] === "ok" && "transport" in record && record["transport"] === "http" && (!("serverId" in record) || record["serverId"] === void 0 || typeof record["serverId"] === "string");
2012
- }
2210
+ //#region src/services/RuntimeStateService.ts
2013
2211
  /**
2014
- * Type guard for shutdown responses.
2015
- * @param value - Candidate payload to validate
2016
- * @returns True when payload matches shutdown response shape
2212
+ * RuntimeStateService
2213
+ *
2214
+ * Persists runtime metadata for HTTP mcp-proxy instances so external commands
2215
+ * (for example `mcp-proxy stop`) can discover and target the correct server.
2017
2216
  */
2018
- function isShutdownResponse(value) {
2019
- if (typeof value !== "object" || value === null) return false;
2020
- const record = toRecord(value);
2021
- return "ok" in record && typeof record["ok"] === "boolean" && "message" in record && typeof record["message"] === "string" && (!("serverId" in record) || record["serverId"] === void 0 || typeof record["serverId"] === "string");
2217
+ const RUNTIME_DIR_NAME = "runtimes";
2218
+ const RUNTIME_FILE_SUFFIX = ".runtime.json";
2219
+ function isObject(value) {
2220
+ return typeof value === "object" && value !== null;
2022
2221
  }
2023
- //#endregion
2024
- //#region src/services/StopServerService/StopServerService.ts
2025
- /**
2026
- * Format runtime endpoint URL after validating the host is a loopback address.
2027
- * Rejects non-loopback hosts to prevent SSRF via tampered runtime state files.
2028
- * @param runtime - Runtime record to format
2029
- * @param path - Request path to append
2030
- * @returns Full runtime URL
2031
- */
2032
- function buildRuntimeUrl(runtime, path) {
2033
- if (!ALLOWED_HOSTS.has(runtime.host)) throw new Error(`Refusing to connect to non-loopback host '${runtime.host}'. Only ${Array.from(ALLOWED_HOSTS).join(", ")} are allowed.`);
2034
- return `${HTTP_PROTOCOL}${runtime.host}:${runtime.port}${path}`;
2222
+ function isRuntimeStateRecord(value) {
2223
+ if (!isObject(value)) return false;
2224
+ return typeof value.serverId === "string" && typeof value.host === "string" && typeof value.port === "number" && value.transport === "http" && typeof value.shutdownToken === "string" && typeof value.startedAt === "string" && typeof value.pid === "number" && (value.configPath === void 0 || typeof value.configPath === "string");
2035
2225
  }
2036
- function toErrorMessage$1(error) {
2226
+ function toErrorMessage$2(error) {
2037
2227
  return error instanceof Error ? error.message : String(error);
2038
2228
  }
2039
- function sleep(delayMs) {
2040
- return new Promise((resolve) => {
2041
- setTimeout(resolve, delayMs);
2042
- });
2043
- }
2044
2229
  /**
2045
- * Service for resolving runtime targets and stopping them safely.
2230
+ * Runtime state persistence implementation.
2046
2231
  */
2047
- var StopServerService = class {
2048
- runtimeStateService;
2232
+ var RuntimeStateService = class RuntimeStateService {
2233
+ runtimeDir;
2049
2234
  logger;
2050
- constructor(runtimeStateService = new RuntimeStateService(), logger = console) {
2051
- this.runtimeStateService = runtimeStateService;
2235
+ constructor(runtimeDir, logger = console) {
2236
+ this.runtimeDir = runtimeDir ?? RuntimeStateService.getDefaultRuntimeDir();
2052
2237
  this.logger = logger;
2053
2238
  }
2054
2239
  /**
2055
- * Resolve a target runtime and stop it cooperatively.
2056
- * @param request - Stop request options
2057
- * @returns Stop result payload
2240
+ * Resolve default runtime directory under the user's home cache path.
2241
+ * @returns Absolute runtime directory path
2058
2242
  */
2059
- async stop(request) {
2060
- const timeoutMs = request.timeoutMs ?? 5e3;
2061
- const runtime = await this.resolveRuntime(request);
2062
- const health = await this.fetchHealth(runtime, timeoutMs);
2063
- if (!health.reachable) {
2064
- await this.runtimeStateService.remove(runtime.serverId);
2065
- throw new Error(`Runtime '${runtime.serverId}' is not reachable at http://${runtime.host}:${runtime.port}. Removed stale runtime record.`);
2066
- }
2067
- if (!request.force && health.payload?.serverId && health.payload.serverId !== runtime.serverId) throw new Error(`Refusing to stop runtime at http://${runtime.host}:${runtime.port}: expected server ID '${runtime.serverId}' but health endpoint reported '${health.payload.serverId}'. Use --force to override.`);
2068
- const shutdownToken = request.token ?? runtime.shutdownToken;
2069
- if (!shutdownToken) throw new Error(`No shutdown token available for runtime '${runtime.serverId}'.`);
2070
- const shutdownResponse = await this.requestShutdown(runtime, shutdownToken, timeoutMs);
2071
- await this.waitForShutdown(runtime, timeoutMs);
2072
- await this.runtimeStateService.remove(runtime.serverId);
2073
- return {
2074
- ok: true,
2075
- serverId: runtime.serverId,
2076
- host: runtime.host,
2077
- port: runtime.port,
2078
- message: shutdownResponse.message
2079
- };
2243
+ static getDefaultRuntimeDir() {
2244
+ return join(homedir(), ".aicode-toolkit", "mcp-proxy", RUNTIME_DIR_NAME);
2080
2245
  }
2081
2246
  /**
2082
- * Resolve a runtime record from explicit ID or a unique host/port pair.
2083
- * @param request - Stop request options
2084
- * @returns Matching runtime record
2247
+ * Build runtime state file path for a given server ID.
2248
+ * @param serverId - Target mcp-proxy server identifier
2249
+ * @returns Absolute runtime file path
2085
2250
  */
2086
- async resolveRuntime(request) {
2087
- if (request.serverId) {
2088
- const runtime = await this.runtimeStateService.read(request.serverId);
2089
- if (!runtime) throw new Error(`No runtime record found for server ID '${request.serverId}'. Start the server with 'mcp-proxy mcp-serve --type http' first.`);
2090
- return runtime;
2091
- }
2092
- if (request.host === void 0 || request.port === void 0) throw new Error("Provide --id or both --host and --port to select a runtime.");
2093
- const matches = (await this.runtimeStateService.list()).filter((runtime) => runtime.host === request.host && runtime.port === request.port);
2094
- if (matches.length === 0) throw new Error(`No runtime record found for http://${request.host}:${request.port}. Start the server with 'mcp-proxy mcp-serve --type http' first.`);
2095
- if (matches.length > 1) throw new Error(`Multiple runtime records match http://${request.host}:${request.port}. Retry with --id to avoid stopping the wrong server.`);
2096
- return matches[0];
2251
+ getRecordPath(serverId) {
2252
+ return join(this.runtimeDir, `${serverId}${RUNTIME_FILE_SUFFIX}`);
2097
2253
  }
2098
2254
  /**
2099
- * Read the runtime health payload.
2100
- * @param runtime - Runtime to query
2101
- * @param timeoutMs - Request timeout in milliseconds
2102
- * @returns Reachability status and optional payload
2255
+ * Persist a runtime state record.
2256
+ * @param record - Runtime metadata to persist
2257
+ * @returns Promise that resolves when write completes
2103
2258
  */
2104
- async fetchHealth(runtime, timeoutMs) {
2105
- try {
2106
- const response = await this.fetchWithTimeout(buildRuntimeUrl(runtime, HEALTH_CHECK_PATH), { method: "GET" }, timeoutMs);
2107
- if (!response.ok) return { reachable: false };
2108
- const payload = await response.json();
2109
- if (!isHealthResponse(payload)) throw new Error("Received invalid health response payload.");
2110
- return {
2111
- reachable: true,
2112
- payload
2113
- };
2114
- } catch (error) {
2115
- this.logger.debug(`Health check failed for ${runtime.serverId}`, error);
2116
- return { reachable: false };
2117
- }
2259
+ async write(record) {
2260
+ await mkdir(this.runtimeDir, { recursive: true });
2261
+ await writeFile(this.getRecordPath(record.serverId), JSON.stringify(record, null, 2), "utf-8");
2118
2262
  }
2119
2263
  /**
2120
- * Send authenticated shutdown request to the admin endpoint.
2121
- * @param runtime - Runtime to stop
2122
- * @param shutdownToken - Bearer token for the admin endpoint
2123
- * @param timeoutMs - Request timeout in milliseconds
2124
- * @returns Parsed shutdown response payload
2264
+ * Read a runtime state record by server ID.
2265
+ * @param serverId - Target mcp-proxy server identifier
2266
+ * @returns Matching runtime record, or null when no record exists
2125
2267
  */
2126
- async requestShutdown(runtime, shutdownToken, timeoutMs) {
2127
- const response = await this.fetchWithTimeout(buildRuntimeUrl(runtime, ADMIN_SHUTDOWN_PATH), {
2128
- method: HTTP_METHOD_POST,
2129
- headers: { [AUTHORIZATION_HEADER_NAME]: `${BEARER_TOKEN_PREFIX}${shutdownToken}` }
2130
- }, timeoutMs);
2131
- const payload = await response.json();
2132
- if (!isShutdownResponse(payload)) throw new Error("Received invalid shutdown response payload.");
2133
- if (!response.ok || !payload.ok) throw new Error(payload.message);
2134
- return payload;
2268
+ async read(serverId) {
2269
+ const filePath = this.getRecordPath(serverId);
2270
+ try {
2271
+ const content = await readFile(filePath, "utf-8");
2272
+ const parsed = JSON.parse(content);
2273
+ return isRuntimeStateRecord(parsed) ? parsed : null;
2274
+ } catch (error) {
2275
+ if (isObject(error) && "code" in error && error.code === "ENOENT") return null;
2276
+ throw new Error(`Failed to read runtime state for server '${serverId}' from '${filePath}': ${toErrorMessage$2(error)}`);
2277
+ }
2135
2278
  }
2136
2279
  /**
2137
- * Poll until the target runtime is no longer reachable.
2138
- * @param runtime - Runtime expected to stop
2139
- * @param timeoutMs - Maximum wait time in milliseconds
2140
- * @returns Promise that resolves when shutdown is observed
2280
+ * List all persisted runtime records.
2281
+ * @returns Array of runtime records
2141
2282
  */
2142
- async waitForShutdown(runtime, timeoutMs) {
2143
- const deadline = Date.now() + timeoutMs;
2144
- while (Date.now() < deadline) {
2145
- if (!(await this.fetchHealth(runtime, Math.max(250, deadline - Date.now()))).reachable) return;
2146
- await sleep(200);
2283
+ async list() {
2284
+ try {
2285
+ const files = (await readdir(this.runtimeDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(RUNTIME_FILE_SUFFIX));
2286
+ return (await Promise.all(files.map(async (file) => {
2287
+ try {
2288
+ const content = await readFile(join(this.runtimeDir, file.name), "utf-8");
2289
+ const parsed = JSON.parse(content);
2290
+ return isRuntimeStateRecord(parsed) ? parsed : null;
2291
+ } catch (error) {
2292
+ this.logger.debug(`Skipping unreadable runtime state file ${file.name}`, error);
2293
+ return null;
2294
+ }
2295
+ }))).filter((record) => record !== null);
2296
+ } catch (error) {
2297
+ if (isObject(error) && "code" in error && error.code === "ENOENT") return [];
2298
+ throw new Error(`Failed to list runtime states from '${this.runtimeDir}': ${toErrorMessage$2(error)}`);
2147
2299
  }
2148
- throw new Error(`Timed out waiting for runtime '${runtime.serverId}' to stop at http://${runtime.host}:${runtime.port}.`);
2149
2300
  }
2150
2301
  /**
2151
- * Perform a fetch with an abort timeout.
2152
- * @param url - Target URL
2153
- * @param init - Fetch options
2154
- * @param timeoutMs - Timeout in milliseconds
2155
- * @returns Fetch response
2302
+ * Remove a runtime state record by server ID.
2303
+ * @param serverId - Target mcp-proxy server identifier
2304
+ * @returns Promise that resolves when delete completes
2156
2305
  */
2157
- async fetchWithTimeout(url, init, timeoutMs) {
2158
- const controller = new AbortController();
2159
- const timeoutId = setTimeout(() => {
2160
- controller.abort();
2161
- }, timeoutMs);
2162
- try {
2163
- return await fetch(url, {
2164
- ...init,
2165
- signal: controller.signal
2166
- });
2167
- } catch (error) {
2168
- throw new Error(`Request to '${url}' failed: ${toErrorMessage$1(error)}`);
2169
- } finally {
2170
- clearTimeout(timeoutId);
2171
- }
2306
+ async remove(serverId) {
2307
+ await rm(this.getRecordPath(serverId), { force: true });
2172
2308
  }
2173
2309
  };
2174
2310
  //#endregion
@@ -2527,339 +2663,203 @@ var SkillService = class {
2527
2663
  };
2528
2664
  }
2529
2665
  };
2530
- /** pnpx command name (pnpm's npx equivalent) */
2531
- const COMMAND_PNPX = "pnpx";
2532
- /** pnpm command name */
2533
- const COMMAND_PNPM = "pnpm";
2534
- /** Tool subcommand for uv */
2535
- const ARG_TOOL = "tool";
2536
- /** Install subcommand for uv tool and npm/pnpm */
2537
- const ARG_INSTALL = "install";
2666
+ /** Path for the runtime health check endpoint. */
2667
+ const HEALTH_CHECK_PATH = "/health";
2668
+ /** Path for the authenticated admin shutdown endpoint. */
2669
+ const ADMIN_SHUTDOWN_PATH = "/admin/shutdown";
2670
+ /** HTTP POST method identifier. */
2671
+ const HTTP_METHOD_POST = "POST";
2672
+ /** HTTP header name for bearer token authorization. */
2673
+ const AUTHORIZATION_HEADER_NAME = "Authorization";
2674
+ /** Prefix for bearer token values in the Authorization header. */
2675
+ const BEARER_TOKEN_PREFIX = "Bearer ";
2676
+ /** HTTP protocol scheme prefix for URL construction. */
2677
+ const HTTP_PROTOCOL = "http://";
2678
+ /** Hosts that are safe to send admin requests to (loopback only). */
2679
+ const ALLOWED_HOSTS = new Set([
2680
+ "localhost",
2681
+ "127.0.0.1",
2682
+ "::1"
2683
+ ]);
2684
+ //#endregion
2685
+ //#region src/services/StopServerService/types.ts
2538
2686
  /**
2539
- * Regex pattern for valid package names (npm, pnpm, uvx, uv)
2540
- * Allows: @scope/package-name@version, package-name, package_name
2541
- * Prevents shell metacharacters that could enable command injection
2542
- * @example
2543
- * // Valid: '@scope/package@1.0.0', 'my-package', 'my_package', '@org/pkg'
2544
- * // Invalid: 'pkg; rm -rf /', 'pkg$(cmd)', 'pkg`whoami`', 'pkg|cat /etc/passwd'
2687
+ * Safely cast a non-null object to a string-keyed record for property access.
2688
+ * @param value - Object value already verified as non-null
2689
+ * @returns The same value typed as a record
2545
2690
  */
2546
- const VALID_PACKAGE_NAME_PATTERN = /^(@[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9._-]+(@[a-zA-Z0-9._-]+)?$/;
2547
- /** Windows platform identifier */
2548
- const PLATFORM_WIN32 = "win32";
2549
- /** Stdio option to ignore stream */
2550
- const STDIO_IGNORE = "ignore";
2551
- /** Stdio option to pipe stream */
2552
- const STDIO_PIPE = "pipe";
2553
- //#endregion
2554
- //#region src/services/PrefetchService/PrefetchService.ts
2691
+ function toRecord(value) {
2692
+ return value;
2693
+ }
2555
2694
  /**
2556
- * PrefetchService
2557
- *
2558
- * DESIGN PATTERNS:
2559
- * - Service pattern for business logic encapsulation
2560
- * - Single responsibility principle
2561
- *
2562
- * CODING STANDARDS:
2563
- * - Use async/await for asynchronous operations
2564
- * - Throw descriptive errors for error cases
2565
- * - Keep methods focused and well-named
2566
- * - Document complex logic with comments
2567
- *
2568
- * AVOID:
2569
- * - Mixing concerns (keep focused on single domain)
2570
- * - Direct tool implementation (services should be tool-agnostic)
2695
+ * Type guard for health responses.
2696
+ * @param value - Candidate payload to validate
2697
+ * @returns True when payload matches health response shape
2571
2698
  */
2699
+ function isHealthResponse(value) {
2700
+ if (typeof value !== "object" || value === null) return false;
2701
+ const record = toRecord(value);
2702
+ return "status" in record && record["status"] === "ok" && "transport" in record && record["transport"] === "http" && (!("serverId" in record) || record["serverId"] === void 0 || typeof record["serverId"] === "string");
2703
+ }
2572
2704
  /**
2573
- * Type guard to check if a config object is an McpStdioConfig
2574
- * @param config - Config object to check
2575
- * @returns True if config has required McpStdioConfig properties
2705
+ * Type guard for shutdown responses.
2706
+ * @param value - Candidate payload to validate
2707
+ * @returns True when payload matches shutdown response shape
2576
2708
  */
2577
- function isMcpStdioConfig(config) {
2578
- return typeof config === "object" && config !== null && "command" in config;
2709
+ function isShutdownResponse(value) {
2710
+ if (typeof value !== "object" || value === null) return false;
2711
+ const record = toRecord(value);
2712
+ return "ok" in record && typeof record["ok"] === "boolean" && "message" in record && typeof record["message"] === "string" && (!("serverId" in record) || record["serverId"] === void 0 || typeof record["serverId"] === "string");
2579
2713
  }
2714
+ //#endregion
2715
+ //#region src/services/StopServerService/StopServerService.ts
2580
2716
  /**
2581
- * PrefetchService handles pre-downloading packages used by MCP servers.
2582
- * Supports npx (Node.js), uvx (Python/uv), and uv run commands.
2583
- *
2584
- * @example
2585
- * ```typescript
2586
- * const service = new PrefetchService({
2587
- * mcpConfig: await configFetcher.fetchConfiguration(),
2588
- * parallel: true,
2589
- * });
2590
- * const packages = service.extractPackages();
2591
- * const summary = await service.prefetch();
2592
- * ```
2717
+ * Format runtime endpoint URL after validating the host is a loopback address.
2718
+ * Rejects non-loopback hosts to prevent SSRF via tampered runtime state files.
2719
+ * @param runtime - Runtime record to format
2720
+ * @param path - Request path to append
2721
+ * @returns Full runtime URL
2593
2722
  */
2594
- var PrefetchService = class {
2595
- config;
2596
- /**
2597
- * Creates a new PrefetchService instance
2598
- * @param config - Service configuration options
2599
- */
2600
- constructor(config) {
2601
- this.config = config;
2602
- }
2603
- /**
2604
- * Extract all prefetchable packages from the MCP configuration
2605
- * @returns Array of package info objects
2606
- */
2607
- extractPackages() {
2608
- const packages = [];
2609
- const { mcpConfig, filter } = this.config;
2610
- for (const [serverName, serverConfig] of Object.entries(mcpConfig.mcpServers)) {
2611
- if (serverConfig.disabled) continue;
2612
- if (serverConfig.transport !== "stdio") continue;
2613
- if (!isMcpStdioConfig(serverConfig.config)) continue;
2614
- const packageInfo = this.extractPackageInfo(serverName, serverConfig.config);
2615
- if (packageInfo) {
2616
- if (filter && packageInfo.packageManager !== filter) continue;
2617
- packages.push(packageInfo);
2618
- }
2619
- }
2620
- return packages;
2621
- }
2622
- /**
2623
- * Prefetch all packages from the configuration
2624
- * @returns Summary of prefetch results
2625
- * @throws Error if prefetch operation fails unexpectedly
2626
- */
2627
- async prefetch() {
2628
- try {
2629
- const packages = this.extractPackages();
2630
- const results = [];
2631
- if (packages.length === 0) return {
2632
- totalPackages: 0,
2633
- successful: 0,
2634
- failed: 0,
2635
- results: []
2636
- };
2637
- if (this.config.parallel) {
2638
- const promises = packages.map(async (pkg) => this.prefetchPackage(pkg));
2639
- results.push(...await Promise.all(promises));
2640
- } else for (const pkg of packages) {
2641
- const result = await this.prefetchPackage(pkg);
2642
- results.push(result);
2643
- }
2644
- const successful = results.filter((r) => r.success).length;
2645
- const failed = results.filter((r) => !r.success).length;
2646
- return {
2647
- totalPackages: packages.length,
2648
- successful,
2649
- failed,
2650
- results
2651
- };
2652
- } catch (error) {
2653
- throw new Error(`Failed to prefetch packages: ${error instanceof Error ? error.message : String(error)}`);
2654
- }
2723
+ function buildRuntimeUrl(runtime, path) {
2724
+ if (!ALLOWED_HOSTS.has(runtime.host)) throw new Error(`Refusing to connect to non-loopback host '${runtime.host}'. Only ${Array.from(ALLOWED_HOSTS).join(", ")} are allowed.`);
2725
+ return `${HTTP_PROTOCOL}${runtime.host}:${runtime.port}${path}`;
2726
+ }
2727
+ function toErrorMessage$1(error) {
2728
+ return error instanceof Error ? error.message : String(error);
2729
+ }
2730
+ function sleep(delayMs) {
2731
+ return new Promise((resolve) => {
2732
+ setTimeout(resolve, delayMs);
2733
+ });
2734
+ }
2735
+ /**
2736
+ * Service for resolving runtime targets and stopping them safely.
2737
+ */
2738
+ var StopServerService = class {
2739
+ runtimeStateService;
2740
+ logger;
2741
+ constructor(runtimeStateService = new RuntimeStateService(), logger = console) {
2742
+ this.runtimeStateService = runtimeStateService;
2743
+ this.logger = logger;
2655
2744
  }
2656
2745
  /**
2657
- * Prefetch a single package
2658
- * @param pkg - Package info to prefetch
2659
- * @returns Result of the prefetch operation
2746
+ * Resolve a target runtime and stop it cooperatively.
2747
+ * @param request - Stop request options
2748
+ * @returns Stop result payload
2660
2749
  */
2661
- async prefetchPackage(pkg) {
2662
- try {
2663
- const [command, ...args] = pkg.fullCommand;
2664
- const result = await this.runCommand(command, args);
2665
- return {
2666
- package: pkg,
2667
- success: result.success,
2668
- output: result.output
2669
- };
2670
- } catch (error) {
2671
- return {
2672
- package: pkg,
2673
- success: false,
2674
- output: error instanceof Error ? error.message : String(error)
2675
- };
2750
+ async stop(request) {
2751
+ const timeoutMs = request.timeoutMs ?? 5e3;
2752
+ const runtime = await this.resolveRuntime(request);
2753
+ const health = await this.fetchHealth(runtime, timeoutMs);
2754
+ if (!health.reachable) {
2755
+ await this.runtimeStateService.remove(runtime.serverId);
2756
+ throw new Error(`Runtime '${runtime.serverId}' is not reachable at http://${runtime.host}:${runtime.port}. Removed stale runtime record.`);
2676
2757
  }
2758
+ if (!request.force && health.payload?.serverId && health.payload.serverId !== runtime.serverId) throw new Error(`Refusing to stop runtime at http://${runtime.host}:${runtime.port}: expected server ID '${runtime.serverId}' but health endpoint reported '${health.payload.serverId}'. Use --force to override.`);
2759
+ const shutdownToken = request.token ?? runtime.shutdownToken;
2760
+ if (!shutdownToken) throw new Error(`No shutdown token available for runtime '${runtime.serverId}'.`);
2761
+ const shutdownResponse = await this.requestShutdown(runtime, shutdownToken, timeoutMs);
2762
+ await this.waitForShutdown(runtime, timeoutMs);
2763
+ await this.runtimeStateService.remove(runtime.serverId);
2764
+ return {
2765
+ ok: true,
2766
+ serverId: runtime.serverId,
2767
+ host: runtime.host,
2768
+ port: runtime.port,
2769
+ message: shutdownResponse.message
2770
+ };
2677
2771
  }
2678
2772
  /**
2679
- * Validate package name to prevent command injection
2680
- * @param packageName - Package name to validate
2681
- * @returns True if package name is safe, false otherwise
2682
- * @remarks Rejects package names containing shell metacharacters
2683
- * @example
2684
- * isValidPackageName('@scope/package') // true
2685
- * isValidPackageName('my-package@1.0.0') // true
2686
- * isValidPackageName('pkg; rm -rf /') // false (shell injection)
2687
- * isValidPackageName('pkg$(whoami)') // false (command substitution)
2688
- */
2689
- isValidPackageName(packageName) {
2690
- return VALID_PACKAGE_NAME_PATTERN.test(packageName);
2691
- }
2692
- /**
2693
- * Extract package info from a server's stdio config
2694
- * @param serverName - Name of the MCP server
2695
- * @param config - Stdio configuration for the server
2696
- * @returns Package info if extractable, null otherwise
2773
+ * Resolve a runtime record from explicit ID or a unique host/port pair.
2774
+ * @param request - Stop request options
2775
+ * @returns Matching runtime record
2697
2776
  */
2698
- extractPackageInfo(serverName, config) {
2699
- const command = config.command.toLowerCase();
2700
- const args = config.args || [];
2701
- if (command === "npx" || command.endsWith("/npx")) {
2702
- const packageName = this.extractNpxPackage(args);
2703
- if (packageName && this.isValidPackageName(packageName)) return {
2704
- serverName,
2705
- packageManager: "npx",
2706
- packageName,
2707
- fullCommand: [
2708
- "npm",
2709
- ARG_INSTALL,
2710
- "-g",
2711
- packageName
2712
- ]
2713
- };
2714
- }
2715
- if (command === "pnpx" || command.endsWith("/pnpx")) {
2716
- const packageName = this.extractNpxPackage(args);
2717
- if (packageName && this.isValidPackageName(packageName)) return {
2718
- serverName,
2719
- packageManager: COMMAND_PNPX,
2720
- packageName,
2721
- fullCommand: [
2722
- COMMAND_PNPM,
2723
- "add",
2724
- "-g",
2725
- packageName
2726
- ]
2727
- };
2728
- }
2729
- if (command === "uvx" || command.endsWith("/uvx")) {
2730
- const packageName = this.extractUvxPackage(args);
2731
- if (packageName && this.isValidPackageName(packageName)) return {
2732
- serverName,
2733
- packageManager: "uvx",
2734
- packageName,
2735
- fullCommand: ["uvx", packageName]
2736
- };
2737
- }
2738
- if ((command === "uv" || command.endsWith("/uv")) && args.includes("run")) {
2739
- const packageName = this.extractUvRunPackage(args);
2740
- if (packageName && this.isValidPackageName(packageName)) return {
2741
- serverName,
2742
- packageManager: "uv",
2743
- packageName,
2744
- fullCommand: [
2745
- "uv",
2746
- ARG_TOOL,
2747
- ARG_INSTALL,
2748
- packageName
2749
- ]
2750
- };
2777
+ async resolveRuntime(request) {
2778
+ if (request.serverId) {
2779
+ const runtime = await this.runtimeStateService.read(request.serverId);
2780
+ if (!runtime) throw new Error(`No runtime record found for server ID '${request.serverId}'. Start the server with 'mcp-proxy mcp-serve --type http' first.`);
2781
+ return runtime;
2751
2782
  }
2752
- return null;
2783
+ if (request.host === void 0 || request.port === void 0) throw new Error("Provide --id or both --host and --port to select a runtime.");
2784
+ const matches = (await this.runtimeStateService.list()).filter((runtime) => runtime.host === request.host && runtime.port === request.port);
2785
+ if (matches.length === 0) throw new Error(`No runtime record found for http://${request.host}:${request.port}. Start the server with 'mcp-proxy mcp-serve --type http' first.`);
2786
+ if (matches.length > 1) throw new Error(`Multiple runtime records match http://${request.host}:${request.port}. Retry with --id to avoid stopping the wrong server.`);
2787
+ return matches[0];
2753
2788
  }
2754
2789
  /**
2755
- * Extract package name from npx command args
2756
- * @param args - Command arguments
2757
- * @returns Package name or null
2758
- * @remarks Handles --package=value, --package value, -p value patterns.
2759
- * Falls back to first non-flag argument if no --package/-p flag found.
2760
- * Returns null if flag has no value or is followed by another flag.
2761
- * When multiple --package flags exist, returns the first valid one.
2762
- * @example
2763
- * extractNpxPackage(['--package=@scope/pkg']) // returns '@scope/pkg'
2764
- * extractNpxPackage(['--package', 'pkg-name']) // returns 'pkg-name'
2765
- * extractNpxPackage(['-p', 'pkg']) // returns 'pkg'
2766
- * extractNpxPackage(['-y', 'pkg-name', '--flag']) // returns 'pkg-name' (fallback)
2767
- * extractNpxPackage(['--package=']) // returns null (empty value)
2790
+ * Read the runtime health payload.
2791
+ * @param runtime - Runtime to query
2792
+ * @param timeoutMs - Request timeout in milliseconds
2793
+ * @returns Reachability status and optional payload
2768
2794
  */
2769
- extractNpxPackage(args) {
2770
- for (let i = 0; i < args.length; i++) {
2771
- const arg = args[i];
2772
- if (arg.startsWith("--package=")) return arg.slice(10) || null;
2773
- if (arg === "--package" && i + 1 < args.length) {
2774
- const nextArg = args[i + 1];
2775
- if (!nextArg.startsWith("-")) return nextArg;
2776
- }
2777
- if (arg === "-p" && i + 1 < args.length) {
2778
- const nextArg = args[i + 1];
2779
- if (!nextArg.startsWith("-")) return nextArg;
2780
- }
2781
- }
2782
- for (const arg of args) {
2783
- if (arg.startsWith("-")) continue;
2784
- return arg;
2795
+ async fetchHealth(runtime, timeoutMs) {
2796
+ try {
2797
+ const response = await this.fetchWithTimeout(buildRuntimeUrl(runtime, HEALTH_CHECK_PATH), { method: "GET" }, timeoutMs);
2798
+ if (!response.ok) return { reachable: false };
2799
+ const payload = await response.json();
2800
+ if (!isHealthResponse(payload)) throw new Error("Received invalid health response payload.");
2801
+ return {
2802
+ reachable: true,
2803
+ payload
2804
+ };
2805
+ } catch (error) {
2806
+ this.logger.debug(`Health check failed for ${runtime.serverId}`, error);
2807
+ return { reachable: false };
2785
2808
  }
2786
- return null;
2787
2809
  }
2788
2810
  /**
2789
- * Extract package name from uvx command args
2790
- * @param args - Command arguments
2791
- * @returns Package name or null
2792
- * @remarks Assumes the first non-flag argument is the package name.
2793
- * Handles both single (-) and double (--) dash flags.
2794
- * @example
2795
- * extractUvxPackage(['mcp-server-fetch']) // returns 'mcp-server-fetch'
2796
- * extractUvxPackage(['--quiet', 'pkg-name']) // returns 'pkg-name'
2811
+ * Send authenticated shutdown request to the admin endpoint.
2812
+ * @param runtime - Runtime to stop
2813
+ * @param shutdownToken - Bearer token for the admin endpoint
2814
+ * @param timeoutMs - Request timeout in milliseconds
2815
+ * @returns Parsed shutdown response payload
2797
2816
  */
2798
- extractUvxPackage(args) {
2799
- for (const arg of args) {
2800
- if (arg.startsWith("-")) continue;
2801
- return arg;
2802
- }
2803
- return null;
2817
+ async requestShutdown(runtime, shutdownToken, timeoutMs) {
2818
+ const response = await this.fetchWithTimeout(buildRuntimeUrl(runtime, ADMIN_SHUTDOWN_PATH), {
2819
+ method: HTTP_METHOD_POST,
2820
+ headers: { [AUTHORIZATION_HEADER_NAME]: `${BEARER_TOKEN_PREFIX}${shutdownToken}` }
2821
+ }, timeoutMs);
2822
+ const payload = await response.json();
2823
+ if (!isShutdownResponse(payload)) throw new Error("Received invalid shutdown response payload.");
2824
+ if (!response.ok || !payload.ok) throw new Error(payload.message);
2825
+ return payload;
2804
2826
  }
2805
2827
  /**
2806
- * Extract package name from uv run command args
2807
- * @param args - Command arguments
2808
- * @returns Package name or null
2809
- * @remarks Looks for the first non-flag argument after the 'run' subcommand.
2810
- * Returns null if 'run' is not found in args.
2811
- * @example
2812
- * extractUvRunPackage(['run', 'mcp-server']) // returns 'mcp-server'
2813
- * extractUvRunPackage(['run', '--verbose', 'pkg']) // returns 'pkg'
2814
- * extractUvRunPackage(['install', 'pkg']) // returns null (no 'run')
2828
+ * Poll until the target runtime is no longer reachable.
2829
+ * @param runtime - Runtime expected to stop
2830
+ * @param timeoutMs - Maximum wait time in milliseconds
2831
+ * @returns Promise that resolves when shutdown is observed
2815
2832
  */
2816
- extractUvRunPackage(args) {
2817
- const runIndex = args.indexOf("run");
2818
- if (runIndex === -1) return null;
2819
- for (let i = runIndex + 1; i < args.length; i++) {
2820
- const arg = args[i];
2821
- if (arg.startsWith("-")) continue;
2822
- return arg;
2833
+ async waitForShutdown(runtime, timeoutMs) {
2834
+ const deadline = Date.now() + timeoutMs;
2835
+ while (Date.now() < deadline) {
2836
+ if (!(await this.fetchHealth(runtime, Math.max(250, deadline - Date.now()))).reachable) return;
2837
+ await sleep(200);
2823
2838
  }
2824
- return null;
2839
+ throw new Error(`Timed out waiting for runtime '${runtime.serverId}' to stop at http://${runtime.host}:${runtime.port}.`);
2825
2840
  }
2826
2841
  /**
2827
- * Run a shell command and capture output
2828
- * @param command - Command to run
2829
- * @param args - Command arguments
2830
- * @returns Promise with success status and output
2842
+ * Perform a fetch with an abort timeout.
2843
+ * @param url - Target URL
2844
+ * @param init - Fetch options
2845
+ * @param timeoutMs - Timeout in milliseconds
2846
+ * @returns Fetch response
2831
2847
  */
2832
- runCommand(command, args) {
2833
- return new Promise((resolve) => {
2834
- const proc = spawn(command, args, {
2835
- stdio: [
2836
- STDIO_IGNORE,
2837
- STDIO_PIPE,
2838
- STDIO_PIPE
2839
- ],
2840
- shell: process.platform === PLATFORM_WIN32
2841
- });
2842
- let stdout = "";
2843
- let stderr = "";
2844
- proc.stdout?.on("data", (data) => {
2845
- stdout += data.toString();
2846
- });
2847
- proc.stderr?.on("data", (data) => {
2848
- stderr += data.toString();
2849
- });
2850
- proc.on("close", (code) => {
2851
- resolve({
2852
- success: code === 0,
2853
- output: stdout || stderr
2854
- });
2855
- });
2856
- proc.on("error", (error) => {
2857
- resolve({
2858
- success: false,
2859
- output: error.message
2860
- });
2848
+ async fetchWithTimeout(url, init, timeoutMs) {
2849
+ const controller = new AbortController();
2850
+ const timeoutId = setTimeout(() => {
2851
+ controller.abort();
2852
+ }, timeoutMs);
2853
+ try {
2854
+ return await fetch(url, {
2855
+ ...init,
2856
+ signal: controller.signal
2861
2857
  });
2862
- });
2858
+ } catch (error) {
2859
+ throw new Error(`Request to '${url}' failed: ${toErrorMessage$1(error)}`);
2860
+ } finally {
2861
+ clearTimeout(timeoutId);
2862
+ }
2863
2863
  }
2864
2864
  };
2865
2865
  //#endregion
@@ -2906,8 +2906,6 @@ var DescribeToolsTool = class DescribeToolsTool {
2906
2906
  skillService;
2907
2907
  definitionsCacheService;
2908
2908
  liquid = new Liquid();
2909
- /** Cache for auto-detected skills from prompt front-matter */
2910
- autoDetectedSkillsCache = null;
2911
2909
  /** Unique server identifier for this mcp-proxy instance */
2912
2910
  serverId;
2913
2911
  /**
@@ -2928,67 +2926,9 @@ var DescribeToolsTool = class DescribeToolsTool {
2928
2926
  * the skill service cache is invalidated.
2929
2927
  */
2930
2928
  clearAutoDetectedSkillsCache() {
2931
- this.autoDetectedSkillsCache = null;
2932
2929
  this.definitionsCacheService.clearLiveCache();
2933
2930
  }
2934
2931
  /**
2935
- * Detects and caches skills from prompt front-matter across all connected MCP servers.
2936
- * Fetches all prompts and checks their content for YAML front-matter with name/description.
2937
- * Results are cached to avoid repeated fetches.
2938
- *
2939
- * Error Handling Strategy:
2940
- * - Errors are logged to stderr but do not fail the overall detection process
2941
- * - This ensures partial results are returned even if some servers/prompts fail
2942
- * - Common failure reasons: server temporarily unavailable, prompt requires arguments,
2943
- * network timeout, or server doesn't support listPrompts
2944
- * - Errors are prefixed with [skill-detection] for easy filtering in logs
2945
- *
2946
- * @returns Array of auto-detected skills from prompt front-matter
2947
- */
2948
- async detectSkillsFromPromptFrontMatter() {
2949
- if (this.autoDetectedSkillsCache !== null) return this.autoDetectedSkillsCache;
2950
- const clients = this.clientManager.getAllClients();
2951
- let listPromptsFailures = 0;
2952
- let fetchPromptFailures = 0;
2953
- const autoDetectedSkills = (await Promise.all(clients.map(async (client) => {
2954
- const detectedSkills = [];
2955
- const configuredPromptNames = new Set(client.prompts ? Object.keys(client.prompts) : []);
2956
- try {
2957
- const prompts = await client.listPrompts();
2958
- if (!prompts || prompts.length === 0) return detectedSkills;
2959
- const promptResults = await Promise.all(prompts.map(async (promptInfo) => {
2960
- if (configuredPromptNames.has(promptInfo.name)) return null;
2961
- try {
2962
- const skillExtraction = extractSkillFrontMatter(((await client.getPrompt(promptInfo.name)).messages || []).map((m) => {
2963
- const content = m.content;
2964
- if (typeof content === "string") return content;
2965
- if (content && typeof content === "object" && "text" in content) return String(content.text);
2966
- return "";
2967
- }).join("\n"));
2968
- if (skillExtraction) return {
2969
- serverName: client.serverName,
2970
- promptName: promptInfo.name,
2971
- skill: skillExtraction.skill
2972
- };
2973
- return null;
2974
- } catch (error) {
2975
- fetchPromptFailures++;
2976
- console.error(`${LOG_PREFIX_SKILL_DETECTION} Failed to fetch prompt '${promptInfo.name}' from ${client.serverName}: ${error instanceof Error ? error.message : "Unknown error"}`);
2977
- return null;
2978
- }
2979
- }));
2980
- for (const result of promptResults) if (result) detectedSkills.push(result);
2981
- } catch (error) {
2982
- listPromptsFailures++;
2983
- console.error(`${LOG_PREFIX_SKILL_DETECTION} Failed to list prompts from ${client.serverName}: ${error instanceof Error ? error.message : "Unknown error"}`);
2984
- }
2985
- return detectedSkills;
2986
- }))).flat();
2987
- if (listPromptsFailures > 0 || fetchPromptFailures > 0) console.error(`${LOG_PREFIX_SKILL_DETECTION} Completed with ${listPromptsFailures} server failure(s) and ${fetchPromptFailures} prompt failure(s). Detected ${autoDetectedSkills.length} skill(s).`);
2988
- this.autoDetectedSkillsCache = autoDetectedSkills;
2989
- return autoDetectedSkills;
2990
- }
2991
- /**
2992
2932
  * Collects skills derived from prompt configurations across all connected MCP servers.
2993
2933
  * Includes both explicitly configured prompts and auto-detected skills from front-matter.
2994
2934
  *
@@ -3197,7 +3137,20 @@ var DescribeToolsTool = class DescribeToolsTool {
3197
3137
  if (serverName) {
3198
3138
  const serverTools = serverToolsMap.get(serverName);
3199
3139
  if (!serverTools) {
3200
- result.notFound = requestedName;
3140
+ try {
3141
+ const tool = (await (await this.clientManager.ensureConnected(serverName)).listTools()).find((t) => t.name === actualToolName);
3142
+ if (tool) result.tools.push({
3143
+ server: serverName,
3144
+ tool: {
3145
+ name: tool.name,
3146
+ description: tool.description,
3147
+ inputSchema: tool.inputSchema
3148
+ }
3149
+ });
3150
+ else result.notFound = requestedName;
3151
+ } catch {
3152
+ result.notFound = requestedName;
3153
+ }
3201
3154
  return result;
3202
3155
  }
3203
3156
  const tool = serverTools.find((t) => t.name === actualToolName);
@@ -4975,4 +4928,4 @@ const TRANSPORT_MODE = {
4975
4928
  SSE: "sse"
4976
4929
  };
4977
4930
  //#endregion
4978
- export { DefinitionsCacheService as C, version as D, ConfigFetcherService as E, createProxyLogger as S, findConfigFile as T, DescribeToolsTool as _, createProxyContainer as a, RuntimeStateService as b, createStdioHttpTransportHandler as c, StdioHttpTransportHandler as d, StdioTransportHandler as f, SearchListToolsTool as g, UseToolTool as h, createHttpTransportHandler as i, createStdioTransportHandler as l, HttpTransportHandler as m, createServer$1 as n, createProxyIoCContainer as o, SseTransportHandler as p, createSessionServer as r, createSseTransportHandler as s, TRANSPORT_MODE as t, initializeSharedServices as u, SkillService as v, generateServerId as w, McpClientManagerService as x, StopServerService as y };
4931
+ export { DefinitionsCacheService as C, version as D, ConfigFetcherService as E, createProxyLogger as S, findConfigFile as T, DescribeToolsTool as _, createProxyContainer as a, RuntimeStateService as b, createStdioHttpTransportHandler as c, StdioHttpTransportHandler as d, StdioTransportHandler as f, SearchListToolsTool as g, UseToolTool as h, createHttpTransportHandler as i, createStdioTransportHandler as l, HttpTransportHandler as m, createServer$1 as n, createProxyIoCContainer as o, SseTransportHandler as p, createSessionServer as r, createSseTransportHandler as s, TRANSPORT_MODE as t, initializeSharedServices as u, StopServerService as v, generateServerId as w, McpClientManagerService as x, SkillService as y };