vpsnet-mcp 1.0.9 → 2.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,59 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
+ /** No package-cache or ancestor lookup: the launch directory is the default root. */
4
+ export function createApiConfiguration(environment, launchDirectory) {
5
+ const configuredDirectory = environment.VPSNET_CONFIG_DIR?.trim();
6
+ const configuredKeyFile = environment.VPSNET_API_KEY_FILE?.trim();
7
+ if (configuredDirectory && !isAbsolute(configuredDirectory)) {
8
+ throw new Error("VPSNET_CONFIG_DIR must be an absolute directory path.");
9
+ }
10
+ if (configuredKeyFile && !isAbsolute(configuredKeyFile)) {
11
+ throw new Error("VPSNET_API_KEY_FILE must be an absolute file path.");
12
+ }
13
+ const root = configuredDirectory || resolve(launchDirectory);
14
+ const paths = {
15
+ root,
16
+ keyFile: configuredKeyFile || join(root, ".vpsnet-mcp-key"),
17
+ mcpJson: join(root, ".mcp.json"),
18
+ };
19
+ function read(path) {
20
+ try {
21
+ return readFileSync(path, "utf8");
22
+ }
23
+ catch {
24
+ return "";
25
+ }
26
+ }
27
+ function projectEnvironment() {
28
+ try {
29
+ const value = JSON.parse(read(paths.mcpJson))?.mcpServers?.vpsnet?.env;
30
+ return value && typeof value === "object" && !Array.isArray(value) ? value : {};
31
+ }
32
+ catch {
33
+ return {};
34
+ }
35
+ }
36
+ function stringValue(value) {
37
+ return typeof value === "string" ? value.trim() : "";
38
+ }
39
+ function apiKey() {
40
+ const explicit = stringValue(environment.VPSNET_API_KEY);
41
+ if (explicit)
42
+ return { key: explicit, source: "VPSNET_API_KEY environment variable" };
43
+ const file = read(paths.keyFile).split(/\r?\n/)[0].trim();
44
+ if (file.startsWith("vpsnet_"))
45
+ return { key: file, source: paths.keyFile };
46
+ // An explicitly selected credential must never silently fall back to another account.
47
+ if (configuredKeyFile)
48
+ throw new Error(`No valid VPSnet API key in VPSNET_API_KEY_FILE: ${paths.keyFile}`);
49
+ const json = stringValue(projectEnvironment().VPSNET_API_KEY);
50
+ if (json.startsWith("vpsnet_"))
51
+ return { key: json, source: paths.mcpJson };
52
+ return { key: "", source: "" };
53
+ }
54
+ return {
55
+ paths,
56
+ apiKey,
57
+ apiUrl: () => stringValue(projectEnvironment().VPSNET_API_URL),
58
+ };
59
+ }
@@ -0,0 +1,26 @@
1
+ /** Reject URL normalization and delimiter tricks before credentials or transport. */
2
+ export function validateApiPath(path) {
3
+ if (typeof path !== "string" ||
4
+ path.length > 8192 ||
5
+ !path.startsWith("/") ||
6
+ path.startsWith("//") ||
7
+ /[\\#\s\x00-\x1f\x7f]/u.test(path)) {
8
+ throw new Error("Invalid VPSnet API route.");
9
+ }
10
+ const pathname = path.split("?", 1)[0];
11
+ for (const segment of pathname.slice(1).split("/")) {
12
+ let decoded;
13
+ try {
14
+ decoded = decodeURIComponent(segment);
15
+ }
16
+ catch {
17
+ throw new Error("Invalid VPSnet API route encoding.");
18
+ }
19
+ if (!decoded ||
20
+ decoded === "." ||
21
+ decoded === ".." ||
22
+ /[/%\\?#\s\x00-\x1f\x7f]/u.test(decoded)) {
23
+ throw new Error("Invalid VPSnet API route segment.");
24
+ }
25
+ }
26
+ }
package/build/api.js CHANGED
@@ -1,30 +1,204 @@
1
- const API_BASE = process.env.VPSNET_API_URL || "https://api.vpsnet.com";
2
- const API_KEY = process.env.VPSNET_API_KEY || "";
3
- if (!API_KEY) {
4
- console.error("VPSNET_API_KEY environment variable is required");
1
+ import { annotateAuthFailure } from "./auth-failure.js";
2
+ import { createApiConfiguration } from "./api-configuration.js";
3
+ import { validateApiPath } from "./api-path.js";
4
+ const DEFAULT_API_TIMEOUT_MS = 45_000;
5
+ const configuration = createApiConfiguration(process.env, process.cwd());
6
+ const { keyFile: SIDECAR_KEY_FILE, mcpJson: PROJECT_MCP_JSON } = configuration.paths;
7
+ const resolveApiKeyWithSource = () => configuration.apiKey();
8
+ const resolveApiKey = () => resolveApiKeyWithSource().key;
9
+ const sidecarApiUrl = () => configuration.apiUrl();
10
+ // The resolved base is where a full-access `vpsnet_` key gets sent, so it is
11
+ // validated rather than concatenated. Without this a malformed or hostile value
12
+ // -- from the environment or from a sidecar file next to the checkout -- would
13
+ // ship the key to an arbitrary host with no protocol, credential or host check.
14
+ const TRUSTED_API_HOST = /(^|\.)vpsnet\.com$/;
15
+ // Loopback is exempt from the https and allowlist rules, and only loopback.
16
+ // The control exists to stop a full-access key being shipped to an arbitrary
17
+ // *remote* host; a base that resolves to this machine cannot exfiltrate it, and
18
+ // refusing loopback bought no security while breaking every local harness and
19
+ // every developer running the server against a local API.
20
+ function isLoopbackHost(hostname) {
21
+ const host = hostname.toLowerCase();
22
+ if (host === "localhost" || host === "::1" || host === "[::1]") {
23
+ return true;
24
+ }
25
+ // 127.0.0.0/8, and only in dotted-quad form -- no DNS name is trusted here.
26
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
27
+ if (v4 === null) {
28
+ return false;
29
+ }
30
+ const octets = v4.slice(1).map((part) => Number.parseInt(part, 10));
31
+ if (octets.some((octet) => Number.isFinite(octet) === false || octet < 0 || octet > 255)) {
32
+ return false;
33
+ }
34
+ return octets[0] === 127;
35
+ }
36
+ function resolveApiBase() {
37
+ const raw = (process.env.VPSNET_API_URL || "").trim() || sidecarApiUrl() || "https://api.vpsnet.com";
38
+ let url;
39
+ try {
40
+ url = new URL(raw);
41
+ }
42
+ catch {
43
+ throw new Error(`Invalid VPSNET_API_URL: ${raw}`);
44
+ }
45
+ const loopback = isLoopbackHost(url.hostname);
46
+ if (url.protocol !== "https:" && (loopback === false || url.protocol !== "http:")) {
47
+ throw new Error("VPSNET_API_URL must use https.");
48
+ }
49
+ if (url.username || url.password || url.search || url.hash) {
50
+ throw new Error("VPSNET_API_URL must not carry credentials, query or fragment.");
51
+ }
52
+ // `url.origin` drops any path, so a base with one would silently send requests
53
+ // somewhere other than where it says. Refuse it instead of quietly rewriting.
54
+ if (url.pathname !== "" && url.pathname !== "/") {
55
+ throw new Error(`VPSNET_API_URL must not carry a path: ${url.pathname}`);
56
+ }
57
+ if (loopback === false && TRUSTED_API_HOST.test(url.hostname) === false) {
58
+ throw new Error(`Refusing to send the API key to untrusted host ${url.hostname}.`);
59
+ }
60
+ return url.origin;
61
+ }
62
+ const startupKey = resolveApiKeyWithSource();
63
+ if (!startupKey.key) {
64
+ console.error("No VPSnet API key found. Provide one of: the VPSNET_API_KEY environment variable, " +
65
+ `a key file at ${SIDECAR_KEY_FILE}, or mcpServers.vpsnet.env.VPSNET_API_KEY in ${PROJECT_MCP_JSON}.`);
5
66
  process.exit(1);
6
67
  }
7
- export async function apiRequest(method, path, body) {
8
- const url = `${API_BASE}${path}`;
68
+ // Say out loud where the key came from; key provenance must never be silent.
69
+ console.error(`VPSnet MCP: API key loaded from ${startupKey.source}.`);
70
+ console.error(`VPSnet MCP: API base resolved to ${resolveApiBase()}.`);
71
+ function resolveApiTimeoutMs() {
72
+ const raw = Number.parseInt(process.env.VPSNET_API_TIMEOUT_MS || "", 10);
73
+ if (Number.isFinite(raw) === false || raw <= 0) {
74
+ return DEFAULT_API_TIMEOUT_MS;
75
+ }
76
+ return raw;
77
+ }
78
+ export async function apiRequest(method, path, body, extraHeaders,
79
+ // Explicitly annotate reviewed POST reads; other non-read methods fail closed.
80
+ semantics) {
81
+ if (!["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"].includes(method)) {
82
+ throw new Error("Invalid VPSnet API request method.");
83
+ }
84
+ validateApiPath(path);
85
+ const url = `${resolveApiBase()}${path}`;
9
86
  const headers = {
10
- "X-API-KEY": API_KEY,
87
+ "X-API-KEY": resolveApiKey(),
11
88
  Accept: "application/json",
89
+ ...(extraHeaders || {}),
12
90
  };
13
91
  const init = { method, headers };
14
92
  if (body) {
15
93
  headers["Content-Type"] = "application/json";
16
94
  init.body = JSON.stringify(body);
17
95
  }
18
- const res = await fetch(url, init);
19
- const text = await res.text();
96
+ const timeoutMs = resolveApiTimeoutMs();
97
+ const controller = new AbortController();
98
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
99
+ init.signal = controller.signal;
100
+ let res;
101
+ let text;
102
+ try {
103
+ res = await fetch(url, init);
104
+ text = await res.text();
105
+ }
106
+ catch (error) {
107
+ if (error instanceof Error && error.name === "AbortError") {
108
+ throw new Error(`VPSnet API request timed out after ${timeoutMs}ms: ${method} ${path}`);
109
+ }
110
+ throw error;
111
+ }
112
+ finally {
113
+ clearTimeout(timeout);
114
+ }
20
115
  let data;
116
+ let parsed = false;
21
117
  try {
22
118
  data = text ? JSON.parse(text) : null;
119
+ parsed = true;
23
120
  }
24
121
  catch {
25
122
  data = { error: text || res.statusText };
26
123
  }
27
- return { status: res.status, data };
124
+ if (res.ok === false) {
125
+ if (parsed === false || (text.trim() === "" && [502, 503, 504].includes(res.status))) {
126
+ // Not an API answer at all: an HTML or plain-text page from the web
127
+ // server in front of the API (an IP allowlist 403, a 502 from an
128
+ // overloaded upstream). Never hand that page to the model as if it were
129
+ // the API speaking; say what happened, with the status, in one line.
130
+ return {
131
+ status: res.status,
132
+ data: describeNonApiResponse(res.status, res.headers.get("content-type"), text, new URL(url).host, semantics?.readOnly === true || ["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase())),
133
+ };
134
+ }
135
+ data = withHttpStatus(res.status, data, res.headers.get("retry-after"));
136
+ }
137
+ // The account API signals auth problems with a bare status plus a message.
138
+ // Explain them here, at the single choke point, so every tool reports the
139
+ // real cause instead of an unactionable "Unauthorized".
140
+ return { status: res.status, data: annotateAuthFailure(res.status, data) };
141
+ }
142
+ /**
143
+ * Structured tool error for a non-2xx answer whose body is not JSON.
144
+ */
145
+ export function describeNonApiResponse(status, contentType, body, host, readOnly = false) {
146
+ const isHtml = /html/i.test(contentType || "") || /^\s*</.test(body);
147
+ const excerpt = body
148
+ .replace(/<[^>]*>/g, " ")
149
+ .replace(/\s+/g, " ")
150
+ .trim()
151
+ .slice(0, 160);
152
+ let reason;
153
+ const gatewayFailure = status === 502 || status === 503 || status === 504;
154
+ if (status === 403 && isHtml) {
155
+ reason =
156
+ `HTTP 403 from the web server in front of ${host}, not from the VPSnet API: ` +
157
+ "the address this MCP server calls from is not allowed to reach this host " +
158
+ "(IP allowlist). Run the MCP server from an allowed address, or ask for the address to be allowed.";
159
+ }
160
+ else if (gatewayFailure) {
161
+ reason =
162
+ `HTTP ${status} from the web server in front of ${host}: no conclusive API response was received. ` +
163
+ (readOnly
164
+ ? "This read-only request can be retried after a short pause."
165
+ : "The request outcome is unknown. Do not automatically repeat it. " +
166
+ "Inspect the current resource state or any returned event/status first. " +
167
+ "If the operation supports idempotency, follow its recovery contract using the original key and unchanged payload; do not create a new key.");
168
+ }
169
+ else if (status === 404) {
170
+ reason =
171
+ `HTTP 404 from ${host}: no such route. The API may be older or newer than this MCP server build.`;
172
+ }
173
+ else {
174
+ reason = `HTTP ${status} from ${host} with a non-JSON body; the VPSnet API did not produce this answer.`;
175
+ }
176
+ return {
177
+ success: false,
178
+ error: reason,
179
+ http_status: status,
180
+ non_api_response: true,
181
+ ...(gatewayFailure ? {
182
+ outcome_unknown: !readOnly,
183
+ retry_guidance: readOnly ? "retry_read" : "inspect_state_before_retry",
184
+ } : {}),
185
+ ...(excerpt ? { body_excerpt: excerpt } : {}),
186
+ };
187
+ }
188
+ function withHttpStatus(status, data, retryAfter) {
189
+ if (data === null || typeof data !== "object" || Array.isArray(data)) {
190
+ return { success: false, http_status: status, body: data };
191
+ }
192
+ const record = data;
193
+ const extra = {};
194
+ if (record.http_status === undefined) {
195
+ extra.http_status = status;
196
+ }
197
+ const retry = Number.parseInt(retryAfter || "", 10);
198
+ if (Number.isFinite(retry) && retry > 0 && record.retry_after === undefined) {
199
+ extra.retry_after = retry;
200
+ }
201
+ return Object.keys(extra).length === 0 ? data : { ...record, ...extra };
28
202
  }
29
203
  export function formatJson(data) {
30
204
  return JSON.stringify(data, null, 2);