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/README.md +533 -248
- package/build/api.js +41 -14
- package/build/application-contract.js +21 -0
- package/build/http-server.js +123 -0
- package/build/index.js +4098 -4064
- 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/package.json +17 -5
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|