vpsnet-mcp 2.1.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,5 +1,8 @@
1
+ import { RemoteAuthError } from './remote-auth-error.js';
2
+ import { apiAuthorization, authorizationContext } from "./request-context.js";
1
3
  import { annotateAuthFailure } from "./auth-failure.js";
2
4
  import { createApiConfiguration } from "./api-configuration.js";
5
+ import { validateApiPath } from "./api-path.js";
3
6
  const DEFAULT_API_TIMEOUT_MS = 45_000;
4
7
  const configuration = createApiConfiguration(process.env, process.cwd());
5
8
  const { keyFile: SIDECAR_KEY_FILE, mcpJson: PROJECT_MCP_JSON } = configuration.paths;
@@ -32,7 +35,7 @@ function isLoopbackHost(hostname) {
32
35
  }
33
36
  return octets[0] === 127;
34
37
  }
35
- function resolveApiBase() {
38
+ export function resolveApiBase() {
36
39
  const raw = (process.env.VPSNET_API_URL || "").trim() || sidecarApiUrl() || "https://api.vpsnet.com";
37
40
  let url;
38
41
  try {
@@ -58,15 +61,17 @@ function resolveApiBase() {
58
61
  }
59
62
  return url.origin;
60
63
  }
61
- const startupKey = resolveApiKeyWithSource();
62
- if (!startupKey.key) {
63
- console.error("No VPSnet API key found. Provide one of: the VPSNET_API_KEY environment variable, " +
64
- `a key file at ${SIDECAR_KEY_FILE}, or mcpServers.vpsnet.env.VPSNET_API_KEY in ${PROJECT_MCP_JSON}.`);
65
- process.exit(1);
64
+ export function initializeStdioApi() {
65
+ const startupKey = resolveApiKeyWithSource();
66
+ if (!startupKey.key) {
67
+ console.error("No VPSnet API key found. Provide one of: the VPSNET_API_KEY environment variable, " +
68
+ `a key file at ${SIDECAR_KEY_FILE}, or mcpServers.vpsnet.env.VPSNET_API_KEY in ${PROJECT_MCP_JSON}.`);
69
+ throw new Error("No VPSnet API key found.");
70
+ }
71
+ // Say out loud where the key came from; key provenance must never be silent.
72
+ console.error(`VPSnet MCP: API key loaded from ${startupKey.source}.`);
73
+ console.error(`VPSnet MCP: API base resolved to ${resolveApiBase()}.`);
66
74
  }
67
- // Say out loud where the key came from; key provenance must never be silent.
68
- console.error(`VPSnet MCP: API key loaded from ${startupKey.source}.`);
69
- console.error(`VPSnet MCP: API base resolved to ${resolveApiBase()}.`);
70
75
  function resolveApiTimeoutMs() {
71
76
  const raw = Number.parseInt(process.env.VPSNET_API_TIMEOUT_MS || "", 10);
72
77
  if (Number.isFinite(raw) === false || raw <= 0) {
@@ -75,15 +80,20 @@ function resolveApiTimeoutMs() {
75
80
  return raw;
76
81
  }
77
82
  export async function apiRequest(method, path, body, extraHeaders,
78
- // Explicitly annotate reviewed POST reads; other non-read methods fail closed.
83
+ // Explicitly annotate reviewed POST reads and consuming GETs; other non-read methods fail closed.
79
84
  semantics) {
85
+ if (!["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"].includes(method)) {
86
+ throw new Error("Invalid VPSnet API request method.");
87
+ }
88
+ validateApiPath(path);
80
89
  const url = `${resolveApiBase()}${path}`;
81
90
  const headers = {
82
- "X-API-KEY": resolveApiKey(),
83
91
  Accept: "application/json",
84
92
  ...(extraHeaders || {}),
93
+ ...apiAuthorization(resolveApiKey),
85
94
  };
86
- const init = { method, headers };
95
+ // Credentials must never follow a redirect to a different endpoint.
96
+ const init = { method, headers, redirect: "error" };
87
97
  if (body) {
88
98
  headers["Content-Type"] = "application/json";
89
99
  init.body = JSON.stringify(body);
@@ -116,6 +126,15 @@ semantics) {
116
126
  catch {
117
127
  data = { error: text || res.statusText };
118
128
  }
129
+ if (authorizationContext()) {
130
+ if (res.status === 401)
131
+ throw new RemoteAuthError('invalid_token');
132
+ const failure = data;
133
+ if (res.status === 403 && failure?.error === 'insufficient_scope') {
134
+ const scope = typeof failure.required_scope === 'string' && /^[a-z_]+(?::[a-z_]+){1,2}$/.test(failure.required_scope) ? failure.required_scope : undefined;
135
+ throw new RemoteAuthError('insufficient_scope', scope);
136
+ }
137
+ }
119
138
  if (res.ok === false) {
120
139
  if (parsed === false || (text.trim() === "" && [502, 503, 504].includes(res.status))) {
121
140
  // Not an API answer at all: an HTML or plain-text page from the web
@@ -124,7 +143,20 @@ semantics) {
124
143
  // the API speaking; say what happened, with the status, in one line.
125
144
  return {
126
145
  status: res.status,
127
- data: describeNonApiResponse(res.status, res.headers.get("content-type"), text, new URL(url).host, semantics?.readOnly === true || ["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase())),
146
+ data: describeNonApiResponse(res.status, res.headers.get("content-type"), text, new URL(url).host, semantics?.readOnly ?? ["GET", "HEAD", "OPTIONS"].includes(method.toUpperCase())),
147
+ };
148
+ }
149
+ if (res.status === 503 && method === "POST"
150
+ && /^\/account\/firecracker\/functions\/\d+\/invoke$/.test(path)
151
+ && data !== null && typeof data === "object" && !Array.isArray(data)
152
+ && data.noActiveServer === true) {
153
+ data = {
154
+ ...data,
155
+ code: "function_execution_unavailable",
156
+ message: "VPSnet Functions cannot currently accept this code execution. "
157
+ + "This response describes the Functions service, not the status of a persistent Firecracker VPS.",
158
+ next_action: "Try again when Functions is available, using the same invocation idempotency key and input, "
159
+ + "or contact VPSnet support. No server restart, new VPS or permission change is required by this error.",
128
160
  };
129
161
  }
130
162
  data = withHttpStatus(res.status, data, res.headers.get("retry-after"));
@@ -132,7 +164,7 @@ semantics) {
132
164
  // The account API signals auth problems with a bare status plus a message.
133
165
  // Explain them here, at the single choke point, so every tool reports the
134
166
  // real cause instead of an unactionable "Unauthorized".
135
- return { status: res.status, data: annotateAuthFailure(res.status, data) };
167
+ return { status: res.status, data: authorizationContext() ? data : annotateAuthFailure(res.status, data) };
136
168
  }
137
169
  /**
138
170
  * Structured tool error for a non-2xx answer whose body is not JSON.
@@ -72,6 +72,23 @@ const externalHttpsUrlSchema = z.string().max(500).refine((value) => {
72
72
  return false;
73
73
  }
74
74
  }, "External access must be a credential-free HTTPS URL without a query or fragment");
75
+ /**
76
+ * An additional address of a managed_https endpoint: another name in one of the
77
+ * customer's own VPSnet DNS zones that leads to the same application. An empty
78
+ * name or "@" is the zone's bare domain (for example `example.com` next to
79
+ * `www.example.com`). `redirect` answers 301 to the main address and keeps the
80
+ * path and query; `serve` opens the same application. Wire shape: design
81
+ * app-additional-addresses-design-1005 section 2.3.
82
+ */
83
+ export const applicationAdditionalAddressSchema = z.object({
84
+ zone_id: z.number().int().min(1),
85
+ name: z
86
+ .string()
87
+ .max(190)
88
+ .regex(/^(?:@|(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*)?)$/),
89
+ action: z.enum(["redirect", "serve"]).default("redirect"),
90
+ approve_dns: z.literal(true),
91
+ }).strict();
75
92
  export const applicationSingleAccessSchema = z.discriminatedUnion("mode", [
76
93
  z.object({ mode: z.literal("platform_https") }).strict(),
77
94
  z.object({ mode: z.literal("private") }).strict(),
@@ -85,6 +102,10 @@ export const applicationSingleAccessSchema = z.discriminatedUnion("mode", [
85
102
  .max(190)
86
103
  .regex(/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/),
87
104
  approve_dns: z.literal(true),
105
+ additional_addresses: z
106
+ .array(applicationAdditionalAddressSchema)
107
+ .max(4)
108
+ .optional(),
88
109
  }).strict(),
89
110
  ]);
90
111
  const applicationAccessEndpointSchema = z.object({
@@ -7,16 +7,19 @@ export const certificateProductIdSchema = z
7
7
  .number()
8
8
  .int()
9
9
  .positive()
10
+ .max(Number.MAX_SAFE_INTEGER)
10
11
  .describe("Published product ID returned by list_certificate_catalog");
11
12
  export const certificateOfferIdSchema = z
12
13
  .number()
13
14
  .int()
14
15
  .positive()
16
+ .max(Number.MAX_SAFE_INTEGER)
15
17
  .describe("Published EUR offer ID returned by the selected catalog product");
16
18
  export const certificateOfferGenerationSchema = z
17
19
  .number()
18
20
  .int()
19
21
  .positive()
22
+ .max(Number.MAX_SAFE_INTEGER)
20
23
  .describe("Exact offer generation returned by the catalog; stale generations are rejected");
21
24
  export const certificateSubscriptionIdSchema = z
22
25
  .string()
@@ -172,6 +175,7 @@ const freeCertificateTargetOrderIdSchema = z
172
175
  .number()
173
176
  .int()
174
177
  .positive()
178
+ .max(Number.MAX_SAFE_INTEGER)
175
179
  .nullable()
176
180
  .optional()
177
181
  .describe("Optional owned VPS or Cloud VPS order ID returned by service tools");
@@ -263,16 +267,19 @@ export const certificateOrderInputShape = {
263
267
  .number()
264
268
  .int()
265
269
  .positive()
270
+ .max(Number.MAX_SAFE_INTEGER)
266
271
  .describe("Owned administrator contact ID selected in the VPSnet certificate order form"),
267
272
  technical_contact_id: z
268
273
  .number()
269
274
  .int()
270
275
  .positive()
276
+ .max(Number.MAX_SAFE_INTEGER)
271
277
  .describe("Owned technical contact ID selected in the VPSnet certificate order form"),
272
278
  organization_contact_id: z
273
279
  .number()
274
280
  .int()
275
281
  .positive()
282
+ .max(Number.MAX_SAFE_INTEGER)
276
283
  .nullable()
277
284
  .optional()
278
285
  .describe("Owned organization contact ID when required for OV or EV validation"),
@@ -18,6 +18,7 @@ export const fileBrowsePointIdSchema = z
18
18
  .number()
19
19
  .int()
20
20
  .positive()
21
+ .max(Number.MAX_SAFE_INTEGER)
21
22
  .describe("Backup point ID returned by list_restore_file_points");
22
23
  export const fileBrowseIdSchema = z
23
24
  .string()
@@ -0,0 +1,123 @@
1
+ import { createServer } from "node:http";
2
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
3
+ import { createVpsnetServer } from "./index.js";
4
+ import { resolveApiBase } from "./api.js";
5
+ import { requireRequestAuthorization, withAuthorization } from "./request-context.js";
6
+ import { supportedScopes, orderScopes } from "./remote-policy.js";
7
+ function publicUrl(value) {
8
+ const url = new URL(value);
9
+ if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash)
10
+ throw new Error("MCP public URLs must use HTTPS without credentials, query or fragment.");
11
+ return url;
12
+ }
13
+ function json(res, status, body) {
14
+ res.writeHead(status, { "Content-Type": "application/json", "Cache-Control": "no-store" });
15
+ res.end(JSON.stringify(body));
16
+ }
17
+ export function createRemoteServer(options) {
18
+ requireRequestAuthorization();
19
+ const resource = publicUrl(options.resource);
20
+ const issuer = publicUrl(options.issuer).href.replace(/\/$/, "");
21
+ if (resource.pathname !== "/mcp")
22
+ throw new Error("The MCP resource path must be /mcp.");
23
+ const allowedScopes = options.ordersEnabled ? [...supportedScopes, ...orderScopes] : supportedScopes;
24
+ const apiBase = resolveApiBase();
25
+ const origins = new Set(options.allowedOrigins ?? []);
26
+ for (const origin of origins)
27
+ if (publicUrl(origin).origin !== origin)
28
+ throw new Error("Allowed origins must be HTTPS origins.");
29
+ async function authenticate(req) {
30
+ const match = /^Bearer (vpsnet_[A-Za-z0-9_-]{43})$/.exec(req.headers.authorization ?? "");
31
+ if (!match)
32
+ return null;
33
+ const response = await fetch(`${apiBase}/oauth/mcp/introspect`, {
34
+ method: "POST", headers: { Authorization: `Bearer ${match[1]}`, Accept: "application/json" },
35
+ redirect: "error", signal: AbortSignal.timeout(10_000),
36
+ });
37
+ if (response.status === 401 || response.status === 403)
38
+ return null;
39
+ if (!response.ok)
40
+ throw new Error("VPSnet authorization is unavailable.");
41
+ const value = await response.json();
42
+ if (value.active !== true || value.aud !== resource.href || value.iss !== issuer
43
+ || typeof value.exp !== "number" || value.exp <= Date.now() / 1000
44
+ || typeof value.sub !== "string" || typeof value.grant_id !== "string" || typeof value.scope !== "string"
45
+ || !Array.isArray(value.services) || !value.services.every(x => typeof x === "string"))
46
+ return null;
47
+ return { accessToken: match[1], subject: value.sub, grantId: value.grant_id,
48
+ expiresAt: value.exp, ordersAvailable: options.ordersEnabled === true && value.orders_available === true, scopes: value.scope.split(" ").filter(x => (allowedScopes.includes(x) || x === "vm:files")), services: value.services };
49
+ }
50
+ const http = createServer(async (req, res) => {
51
+ // TLS terminates at the supplied reverse proxy, which must preserve Host.
52
+ if (req.headers.host !== resource.host)
53
+ return json(res, 421, { error: "invalid_host" });
54
+ if (req.headers.origin && !origins.has(req.headers.origin))
55
+ return json(res, 403, { error: "invalid_origin" });
56
+ if (req.headers.origin) {
57
+ res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
58
+ res.setHeader("Vary", "Origin");
59
+ res.setHeader("Access-Control-Expose-Headers", "WWW-Authenticate, MCP-Protocol-Version");
60
+ }
61
+ if (req.method === "OPTIONS") {
62
+ res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
63
+ res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, MCP-Protocol-Version, Last-Event-ID");
64
+ res.writeHead(204);
65
+ res.end();
66
+ return;
67
+ }
68
+ if (req.method === 'GET' && ['/.well-known/oauth-protected-resource', '/.well-known/oauth-protected-resource/mcp'].includes(req.url ?? '')) {
69
+ return json(res, 200, { resource: resource.href, authorization_servers: [issuer], scopes_supported: allowedScopes, bearer_methods_supported: ['header'], resource_name: 'VPSnet' });
70
+ }
71
+ if (req.url !== '/mcp')
72
+ return json(res, 404, { error: 'not_found' });
73
+ try {
74
+ const context = await authenticate(req);
75
+ if (!context) {
76
+ res.setHeader("WWW-Authenticate", `Bearer resource_metadata="${resource.origin}/.well-known/oauth-protected-resource${resource.pathname}"`);
77
+ return json(res, 401, { error: "invalid_token" });
78
+ }
79
+ if (req.method !== "POST") {
80
+ res.setHeader("Allow", "POST");
81
+ return json(res, 405, { error: "method_not_allowed" });
82
+ }
83
+ if (req.headers["mcp-session-id"])
84
+ return json(res, 400, { error: "stateless_transport" });
85
+ if (!req.headers["content-type"]?.split(";")[0].trim().match(/^application\/json$/i))
86
+ return json(res, 415, { error: "unsupported_media_type" });
87
+ let size = 0;
88
+ const chunks = [];
89
+ for await (const chunk of req) {
90
+ size += chunk.length;
91
+ if (size > 1024 * 1024)
92
+ return json(res, 413, { error: "request_too_large" });
93
+ chunks.push(chunk);
94
+ }
95
+ let body;
96
+ try {
97
+ body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
98
+ }
99
+ catch {
100
+ return json(res, 400, { error: "invalid_json" });
101
+ }
102
+ if (Array.isArray(body))
103
+ return json(res, 400, { error: "batch_not_supported" });
104
+ await withAuthorization(context, async () => {
105
+ const server = createVpsnetServer({ remoteScopes: context.scopes, remoteProfile: context.ordersAvailable ? 'orders' : 'directory' });
106
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
107
+ res.once("close", () => { void transport.close(); void server.close(); });
108
+ await server.connect(transport);
109
+ await transport.handleRequest(req, res, body);
110
+ });
111
+ }
112
+ catch {
113
+ // Never emit upstream exceptions, tokens or request arguments into logs.
114
+ if (!res.headersSent)
115
+ json(res, 503, { error: "temporarily_unavailable" });
116
+ else
117
+ res.end();
118
+ }
119
+ });
120
+ http.requestTimeout = 30_000;
121
+ http.headersTimeout = 10_000;
122
+ return http;
123
+ }