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.
- package/README.md +539 -248
- package/build/api-path.js +26 -0
- package/build/api.js +46 -14
- package/build/application-contract.js +21 -0
- package/build/certificate-contract.js +7 -0
- package/build/file-restore-contract.js +1 -0
- package/build/http-server.js +123 -0
- package/build/index.js +4107 -4058
- package/build/networking-tools.js +228 -0
- package/build/remote-auth-error.js +13 -0
- package/build/remote-orders.js +45 -0
- package/build/remote-policy.js +250 -0
- package/build/remote-tools.js +149 -0
- package/build/remote.js +15 -0
- package/build/request-context.js +19 -0
- package/build/temp-vm-contract.js +2 -0
- package/build/tool-result.js +49 -0
- package/package.json +19 -5
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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"),
|
|
@@ -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
|
+
}
|