vpsnet-mcp 2.1.1 → 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.
package/build/api.js CHANGED
@@ -1,3 +1,5 @@
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";
3
5
  import { validateApiPath } from "./api-path.js";
@@ -33,7 +35,7 @@ function isLoopbackHost(hostname) {
33
35
  }
34
36
  return octets[0] === 127;
35
37
  }
36
- function resolveApiBase() {
38
+ export function resolveApiBase() {
37
39
  const raw = (process.env.VPSNET_API_URL || "").trim() || sidecarApiUrl() || "https://api.vpsnet.com";
38
40
  let url;
39
41
  try {
@@ -59,15 +61,17 @@ function resolveApiBase() {
59
61
  }
60
62
  return url.origin;
61
63
  }
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}.`);
66
- 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()}.`);
67
74
  }
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
75
  function resolveApiTimeoutMs() {
72
76
  const raw = Number.parseInt(process.env.VPSNET_API_TIMEOUT_MS || "", 10);
73
77
  if (Number.isFinite(raw) === false || raw <= 0) {
@@ -76,7 +80,7 @@ function resolveApiTimeoutMs() {
76
80
  return raw;
77
81
  }
78
82
  export async function apiRequest(method, path, body, extraHeaders,
79
- // 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.
80
84
  semantics) {
81
85
  if (!["GET", "HEAD", "OPTIONS", "POST", "PUT", "PATCH", "DELETE"].includes(method)) {
82
86
  throw new Error("Invalid VPSnet API request method.");
@@ -84,11 +88,12 @@ semantics) {
84
88
  validateApiPath(path);
85
89
  const url = `${resolveApiBase()}${path}`;
86
90
  const headers = {
87
- "X-API-KEY": resolveApiKey(),
88
91
  Accept: "application/json",
89
92
  ...(extraHeaders || {}),
93
+ ...apiAuthorization(resolveApiKey),
90
94
  };
91
- const init = { method, headers };
95
+ // Credentials must never follow a redirect to a different endpoint.
96
+ const init = { method, headers, redirect: "error" };
92
97
  if (body) {
93
98
  headers["Content-Type"] = "application/json";
94
99
  init.body = JSON.stringify(body);
@@ -121,6 +126,15 @@ semantics) {
121
126
  catch {
122
127
  data = { error: text || res.statusText };
123
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
+ }
124
138
  if (res.ok === false) {
125
139
  if (parsed === false || (text.trim() === "" && [502, 503, 504].includes(res.status))) {
126
140
  // Not an API answer at all: an HTML or plain-text page from the web
@@ -129,7 +143,20 @@ semantics) {
129
143
  // the API speaking; say what happened, with the status, in one line.
130
144
  return {
131
145
  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())),
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.",
133
160
  };
134
161
  }
135
162
  data = withHttpStatus(res.status, data, res.headers.get("retry-after"));
@@ -137,7 +164,7 @@ semantics) {
137
164
  // The account API signals auth problems with a bare status plus a message.
138
165
  // Explain them here, at the single choke point, so every tool reports the
139
166
  // real cause instead of an unactionable "Unauthorized".
140
- return { status: res.status, data: annotateAuthFailure(res.status, data) };
167
+ return { status: res.status, data: authorizationContext() ? data : annotateAuthFailure(res.status, data) };
141
168
  }
142
169
  /**
143
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({
@@ -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
+ }