staticx-mcp-server 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 StaticX
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,146 @@
1
+ # StaticX MCP Server
2
+
3
+ [![npm](https://img.shields.io/npm/v/staticx-mcp-server)](https://www.npmjs.com/package/staticx-mcp-server)
4
+ [![license](https://img.shields.io/npm/l/staticx-mcp-server)](./LICENSE)
5
+
6
+ Official Model Context Protocol server for [StaticX](https://staticx.site), the deployment infrastructure for static websites.
7
+
8
+ Give Claude Code, Cursor, Codex, Cline, Windsurf, Claude Desktop, Zed, Continue, or another MCP client a scoped StaticX API token. The agent can then create sites, upload files, publish immutable releases, inspect logs, connect domains, and roll back with explicit confirmation.
9
+
10
+ StaticX intentionally uses scoped API tokens instead of OAuth because it is designed for developers, CI/CD, automation tools, and AI agents.
11
+
12
+ ## Install
13
+
14
+ The package runs directly through `npx`; a global install is not required.
15
+
16
+ ```json
17
+ {
18
+ "mcpServers": {
19
+ "staticx": {
20
+ "command": "npx",
21
+ "args": ["-y", "staticx-mcp-server"],
22
+ "env": {
23
+ "STATICX_API_TOKEN": "sx_replace_with_your_token",
24
+ "STATICX_API_BASE_URL": "https://staticx.site/api/v1"
25
+ }
26
+ }
27
+ }
28
+ }
29
+ ```
30
+
31
+ Create the narrowest token that fits the job. A short-lived, site-scoped token is the safest default for one-site deployments.
32
+
33
+ Full client-specific snippets are available in the [StaticX MCP documentation](https://staticx.site/documentation/mcp-clients).
34
+
35
+ ## Claude Code
36
+
37
+ ```bash
38
+ claude mcp add staticx --scope user \
39
+ --env STATICX_API_TOKEN=sx_replace_with_your_token \
40
+ --env STATICX_API_BASE_URL=https://staticx.site/api/v1 \
41
+ -- npx -y staticx-mcp-server
42
+ ```
43
+
44
+ ## Codex
45
+
46
+ ```bash
47
+ codex mcp add staticx \
48
+ --env STATICX_API_TOKEN=sx_replace_with_your_token \
49
+ --env STATICX_API_BASE_URL=https://staticx.site/api/v1 \
50
+ -- npx -y staticx-mcp-server
51
+ ```
52
+
53
+ ## Local HTTP debugging
54
+
55
+ ```bash
56
+ STATICX_API_TOKEN=sx_replace_with_your_token npx staticx-mcp-server http
57
+ ```
58
+
59
+ Then connect a Streamable HTTP client or the MCP Inspector to:
60
+
61
+ ```text
62
+ http://localhost:3100/mcp
63
+ ```
64
+
65
+ HTTP mode listens on `127.0.0.1` by default. Do not expose it publicly without a secure reverse proxy and a deliberate authentication policy.
66
+
67
+ ## Environment variables
68
+
69
+ | Variable | Required | Purpose |
70
+ | --- | --- | --- |
71
+ | `STATICX_API_TOKEN` | Yes | Scoped StaticX API token. |
72
+ | `STATICX_API_BASE_URL` | No | Defaults to `https://staticx.site/api/v1`. |
73
+ | `STATICX_PROJECT_ID` | No | Default site ID for site-specific tools. |
74
+ | `STATICX_TIMEOUT_MS` | No | API request timeout in milliseconds. |
75
+ | `STATICX_MCP_PORT` | No | Local HTTP port, default `3100`. |
76
+ | `STATICX_MCP_HOST` | No | Local HTTP bind host, default `127.0.0.1`. |
77
+
78
+ Never paste a real token into documentation, source control, screenshots, prompts, or issue reports.
79
+
80
+ ## Tools
81
+
82
+ | Tool | Purpose |
83
+ | --- | --- |
84
+ | `staticx_config` | Show configuration state without exposing secrets. |
85
+ | `staticx_auth_check` | Validate the configured token. |
86
+ | `staticx_list_workspaces` | List accessible workspaces. |
87
+ | `staticx_create_workspace` | Create a workspace. |
88
+ | `staticx_list_projects` | List accessible sites. |
89
+ | `staticx_get_project` | Read one site. |
90
+ | `staticx_create_project` | Create a site. |
91
+ | `staticx_upload_zip` | Upload a static build ZIP. |
92
+ | `staticx_import_url` | Import a public website URL. |
93
+ | `staticx_deploy_project` | Publish the current workspace. |
94
+ | `staticx_deploy_zip` | Upload and deploy a ZIP end to end. |
95
+ | `staticx_list_deployments` | List immutable releases. |
96
+ | `staticx_rollback_deployment` | Roll back with exact confirmation text. |
97
+ | `staticx_delete_deployment` | Delete an inactive release with exact confirmation text. |
98
+ | `staticx_get_logs` | Read recent site activity. |
99
+ | `staticx_connect_custom_domain` | Start one-record custom domain setup. |
100
+ | `staticx_get_custom_domain_status` | Read domain activation status. |
101
+ | `staticx_set_environment_variables` | Sync site environment variables. |
102
+ | `staticx_agent_guide` | Return the built-in safe deployment guide. |
103
+
104
+ ## Safety contract
105
+
106
+ - Tokens are never returned by a tool.
107
+ - Write tools explain their effect in their descriptions.
108
+ - Rollback requires `ROLLBACK <deployment_id>`.
109
+ - Deployment deletion requires `DELETE <deployment_id>`.
110
+ - The server only calls the public StaticX `/api/v1` contract.
111
+ - Use a site-scoped token for one-site agents and revoke it when the task is complete.
112
+
113
+ ## Recommended agent prompt
114
+
115
+ ```text
116
+ Use StaticX MCP to deploy this static website.
117
+
118
+ Before deploying:
119
+ - build the project
120
+ - verify index.html and 404.html exist at the build root
121
+ - explain what you will publish
122
+
123
+ After deploying:
124
+ - return the live URL and release
125
+ - inspect logs if anything fails
126
+ - do not roll back or delete anything without asking me first
127
+ ```
128
+
129
+ ## Development
130
+
131
+ ```bash
132
+ git clone https://github.com/madprodworks-coder/staticx-mcp-server.git
133
+ cd staticx-mcp-server
134
+ npm install
135
+ npm test
136
+ ```
137
+
138
+ The MCP package must continue to use the public StaticX API. It must not import Laravel controllers, services, or internal engine classes.
139
+
140
+ ## Security
141
+
142
+ Please report vulnerabilities privately as described in [SECURITY.md](./SECURITY.md). Do not open a public issue containing tokens, private URLs, or customer data.
143
+
144
+ ## License
145
+
146
+ MIT
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ await import('../src/server.mjs');
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "staticx-mcp-server",
3
+ "version": "1.0.0",
4
+ "mcpName": "io.github.madprodworks-coder/staticx-mcp-server",
5
+ "description": "Official API-token-first MCP server for deploying and operating StaticX static sites.",
6
+ "type": "module",
7
+ "publishConfig": {
8
+ "access": "public"
9
+ },
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/madprodworks-coder/staticx-mcp-server.git"
13
+ },
14
+ "homepage": "https://staticx.site/documentation/mcp",
15
+ "bugs": {
16
+ "url": "https://github.com/madprodworks-coder/staticx-mcp-server/issues"
17
+ },
18
+ "bin": {
19
+ "staticx-mcp-server": "./bin/staticx-mcp-server.mjs"
20
+ },
21
+ "files": [
22
+ "bin",
23
+ "src",
24
+ "README.md",
25
+ "LICENSE",
26
+ "server.json"
27
+ ],
28
+ "scripts": {
29
+ "test": "node --test tests/*.test.mjs",
30
+ "prepublishOnly": "npm test"
31
+ },
32
+ "keywords": [
33
+ "staticx",
34
+ "mcp",
35
+ "model-context-protocol",
36
+ "ai-agent",
37
+ "static-sites",
38
+ "deployment"
39
+ ],
40
+ "license": "MIT",
41
+ "author": "StaticX",
42
+ "engines": {
43
+ "node": ">=18.17"
44
+ },
45
+ "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.29.0",
47
+ "zod": "^4.4.3"
48
+ }
49
+ }
package/server.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json",
3
+ "name": "io.github.madprodworks-coder/staticx-mcp-server",
4
+ "description": "Deploy and operate versioned StaticX static sites with scoped API tokens.",
5
+ "version": "1.0.0",
6
+ "repository": {
7
+ "url": "https://github.com/madprodworks-coder/staticx-mcp-server",
8
+ "source": "github"
9
+ },
10
+ "websiteUrl": "https://staticx.site/documentation/mcp",
11
+ "packages": [
12
+ {
13
+ "registryType": "npm",
14
+ "registryBaseUrl": "https://registry.npmjs.org",
15
+ "identifier": "staticx-mcp-server",
16
+ "version": "1.0.0",
17
+ "transport": {
18
+ "type": "stdio"
19
+ },
20
+ "environmentVariables": [
21
+ {
22
+ "name": "STATICX_API_TOKEN",
23
+ "description": "Scoped StaticX API token.",
24
+ "isRequired": true,
25
+ "isSecret": true
26
+ },
27
+ {
28
+ "name": "STATICX_API_BASE_URL",
29
+ "description": "StaticX API base URL.",
30
+ "default": "https://staticx.site/api/v1",
31
+ "isRequired": false,
32
+ "isSecret": false
33
+ },
34
+ {
35
+ "name": "STATICX_PROJECT_ID",
36
+ "description": "Optional default StaticX site ID.",
37
+ "isRequired": false,
38
+ "isSecret": false
39
+ }
40
+ ]
41
+ }
42
+ ]
43
+ }
package/src/server.mjs ADDED
@@ -0,0 +1,736 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFile, stat } from 'node:fs/promises';
4
+ import { basename } from 'node:path';
5
+ import { AsyncLocalStorage } from 'node:async_hooks';
6
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
9
+ import { createMcpExpressApp } from '@modelcontextprotocol/sdk/server/express.js';
10
+ import * as z from 'zod/v4';
11
+
12
+ const SERVER_VERSION = '1.0.0';
13
+ const DEFAULT_API_BASE_URL = 'https://staticx.site/api/v1';
14
+ const DEFAULT_HTTP_PORT = 3100;
15
+ const requestToken = new AsyncLocalStorage();
16
+
17
+ class StaticxApiError extends Error {
18
+ constructor(message, status, payload) {
19
+ super(message);
20
+ this.name = 'StaticxApiError';
21
+ this.status = status;
22
+ this.payload = payload;
23
+ }
24
+ }
25
+
26
+ function apiBaseUrl() {
27
+ return (process.env.STATICX_API_BASE_URL || DEFAULT_API_BASE_URL).replace(/\/+$/, '');
28
+ }
29
+
30
+ function apiToken() {
31
+ const token = requestToken.getStore() || process.env.STATICX_API_TOKEN || '';
32
+
33
+ if (token.trim() === '') {
34
+ throw new Error('STATICX_API_TOKEN is required. Create a scoped API token in StaticX and pass it as an MCP environment variable.');
35
+ }
36
+
37
+ return token;
38
+ }
39
+
40
+ function defaultProjectId() {
41
+ const value = process.env.STATICX_PROJECT_ID || '';
42
+
43
+ return value.trim() === '' ? null : value.trim();
44
+ }
45
+
46
+ function resolveProjectId(args = {}) {
47
+ const projectId = args.project_id ?? defaultProjectId();
48
+
49
+ if (projectId === null || projectId === undefined || String(projectId).trim() === '') {
50
+ throw new Error('A project_id is required. Pass project_id to the tool or set STATICX_PROJECT_ID.');
51
+ }
52
+
53
+ return String(projectId).trim();
54
+ }
55
+
56
+ function timeoutMs() {
57
+ const value = Number.parseInt(process.env.STATICX_TIMEOUT_MS || '120000', 10);
58
+
59
+ return Number.isFinite(value) && value > 0 ? value : 120000;
60
+ }
61
+
62
+ async function parseResponse(response) {
63
+ const contentType = response.headers.get('content-type') || '';
64
+
65
+ if (contentType.includes('application/json')) {
66
+ return response.json();
67
+ }
68
+
69
+ const text = await response.text();
70
+
71
+ return text === '' ? null : { message: text };
72
+ }
73
+
74
+ function normalizeApiError(payload, status) {
75
+ if (payload && typeof payload === 'object') {
76
+ const message = typeof payload.message === 'string' && payload.message.trim() !== ''
77
+ ? payload.message
78
+ : `StaticX API request failed with HTTP ${status}.`;
79
+
80
+ return message;
81
+ }
82
+
83
+ return `StaticX API request failed with HTTP ${status}.`;
84
+ }
85
+
86
+ async function apiRequest(path, options = {}) {
87
+ const controller = new AbortController();
88
+ const timer = setTimeout(() => controller.abort(), timeoutMs());
89
+ const headers = {
90
+ Accept: 'application/json',
91
+ Authorization: `Bearer ${apiToken()}`,
92
+ ...(options.headers || {}),
93
+ };
94
+
95
+ try {
96
+ const response = await fetch(`${apiBaseUrl()}${path}`, {
97
+ ...options,
98
+ headers,
99
+ signal: controller.signal,
100
+ });
101
+ const payload = await parseResponse(response);
102
+
103
+ if (!response.ok) {
104
+ throw new StaticxApiError(normalizeApiError(payload, response.status), response.status, payload);
105
+ }
106
+
107
+ return payload;
108
+ } catch (error) {
109
+ if (error?.name === 'AbortError') {
110
+ throw new Error(`StaticX API request timed out after ${timeoutMs()}ms.`);
111
+ }
112
+
113
+ throw error;
114
+ } finally {
115
+ clearTimeout(timer);
116
+ }
117
+ }
118
+
119
+ async function jsonRequest(path, method, body = {}) {
120
+ return apiRequest(path, {
121
+ method,
122
+ headers: { 'Content-Type': 'application/json' },
123
+ body: JSON.stringify(body),
124
+ });
125
+ }
126
+
127
+ async function zipBlob(zipPath) {
128
+ const fileStat = await stat(zipPath);
129
+
130
+ if (!fileStat.isFile()) {
131
+ throw new Error(`ZIP path is not a file: ${zipPath}`);
132
+ }
133
+
134
+ if (!zipPath.toLowerCase().endsWith('.zip')) {
135
+ throw new Error(`ZIP path must end with .zip: ${zipPath}`);
136
+ }
137
+
138
+ return new Blob([await readFile(zipPath)], { type: 'application/zip' });
139
+ }
140
+
141
+ function textResult(title, data) {
142
+ return {
143
+ content: [
144
+ {
145
+ type: 'text',
146
+ text: `${title}\n\n${JSON.stringify(data, null, 2)}`,
147
+ },
148
+ ],
149
+ };
150
+ }
151
+
152
+ function textOnly(text) {
153
+ return {
154
+ content: [
155
+ {
156
+ type: 'text',
157
+ text,
158
+ },
159
+ ],
160
+ };
161
+ }
162
+
163
+ function errorResult(error) {
164
+ const payload = error instanceof StaticxApiError ? error.payload : null;
165
+ const status = error instanceof StaticxApiError ? error.status : null;
166
+
167
+ return {
168
+ isError: true,
169
+ content: [
170
+ {
171
+ type: 'text',
172
+ text: JSON.stringify({
173
+ message: error?.message || 'StaticX MCP tool failed.',
174
+ status,
175
+ payload,
176
+ }, null, 2),
177
+ },
178
+ ],
179
+ };
180
+ }
181
+
182
+ function tool(handler) {
183
+ return async (args) => {
184
+ try {
185
+ return await handler(args || {});
186
+ } catch (error) {
187
+ return errorResult(error);
188
+ }
189
+ };
190
+ }
191
+
192
+ function agentGuideText() {
193
+ return [
194
+ '# StaticX MCP',
195
+ '',
196
+ 'This MCP server gives AI agents a safe tool contract over the StaticX `/api/v1` API.',
197
+ '',
198
+ 'Required environment:',
199
+ '- `STATICX_API_TOKEN`, a scoped StaticX API token created from Settings.',
200
+ '- `STATICX_API_BASE_URL`, optional and defaults to `https://staticx.site/api/v1`.',
201
+ '- `STATICX_PROJECT_ID`, optional default site for site tools.',
202
+ '',
203
+ 'Safety rules:',
204
+ '- Never print, log, or return the API token.',
205
+ '- Explain the intended change before using a write tool.',
206
+ '- Ask for explicit user confirmation before rollback or deletion.',
207
+ '- Prefer a site-scoped, short-lived token for one-site jobs.',
208
+ '',
209
+ 'Recommended deployment flow:',
210
+ '1. Build the website locally.',
211
+ '2. Confirm the build output contains `index.html` or `index.htm` plus `404.html` at the root.',
212
+ '3. Zip the build output so those required files are at the archive root.',
213
+ '4. Call `staticx_deploy_zip` with the ZIP path and project ID.',
214
+ '5. If the user wants a custom domain, call `staticx_connect_custom_domain` and return the DNS record to create.',
215
+ '6. Return the live URL, custom domain status, or the exact MCP/API error.',
216
+ '',
217
+ 'The MCP server does not call engine classes directly. It uses the same API contract as external tools.',
218
+ ].join('\n');
219
+ }
220
+
221
+ function createServer() {
222
+ const server = new McpServer({
223
+ name: 'staticx',
224
+ version: SERVER_VERSION,
225
+ }, {
226
+ capabilities: {
227
+ resources: {},
228
+ prompts: {},
229
+ tools: {},
230
+ },
231
+ });
232
+
233
+ server.registerResource(
234
+ 'staticx-agent-guide',
235
+ 'staticx://agent-guide',
236
+ {
237
+ title: 'StaticX Agent Guide',
238
+ description: 'How agents should deploy static sites safely through StaticX MCP.',
239
+ mimeType: 'text/markdown',
240
+ },
241
+ async (uri) => ({
242
+ contents: [
243
+ {
244
+ uri: uri.href,
245
+ mimeType: 'text/markdown',
246
+ text: agentGuideText(),
247
+ },
248
+ ],
249
+ }),
250
+ );
251
+
252
+ server.registerPrompt('deploy-static-site', {
253
+ title: 'Deploy static site',
254
+ description: 'Instruction template for deploying a local static build through StaticX MCP.',
255
+ argsSchema: {
256
+ project_id: z.string().optional().describe('StaticX site ID. If omitted, STATICX_PROJECT_ID must be configured.'),
257
+ output_dir: z.string().default('dist').describe('Local build output directory that should be zipped before upload.'),
258
+ },
259
+ }, async ({ project_id: projectId, output_dir: outputDir }) => ({
260
+ messages: [
261
+ {
262
+ role: 'user',
263
+ content: {
264
+ type: 'text',
265
+ text: [
266
+ 'Deploy this static website using the StaticX MCP server.',
267
+ '',
268
+ `Project ID: ${projectId || 'use STATICX_PROJECT_ID'}`,
269
+ `Build output directory: ${outputDir}`,
270
+ '',
271
+ 'Steps:',
272
+ '1. Build the project locally.',
273
+ '2. Confirm the build output contains index.html or index.htm plus 404.html at the root.',
274
+ '3. Zip the contents of the build output directory so those required files stay at the ZIP root.',
275
+ '4. Call staticx_deploy_zip with the project_id and zip_path.',
276
+ '5. If a custom domain is requested, call staticx_connect_custom_domain with domain_name.',
277
+ '6. Check deployment status and logs if the deploy fails.',
278
+ '7. Return the live URL, custom domain DNS record, or the exact API error message.',
279
+ ].join('\n'),
280
+ },
281
+ },
282
+ ],
283
+ }));
284
+
285
+ server.registerTool('staticx_config', {
286
+ title: 'StaticX MCP config',
287
+ description: 'Show the MCP server configuration state without exposing token values.',
288
+ inputSchema: {},
289
+ annotations: {
290
+ readOnlyHint: true,
291
+ destructiveHint: false,
292
+ openWorldHint: false,
293
+ },
294
+ }, tool(async () => textResult('StaticX MCP configuration', {
295
+ api_base_url: apiBaseUrl(),
296
+ has_api_token: Boolean(process.env.STATICX_API_TOKEN),
297
+ default_project_id: defaultProjectId(),
298
+ timeout_ms: timeoutMs(),
299
+ })));
300
+
301
+ server.registerTool('staticx_auth_check', {
302
+ title: 'Check StaticX API auth',
303
+ description: 'Validate the configured API token against GET /user.',
304
+ inputSchema: {},
305
+ annotations: {
306
+ readOnlyHint: true,
307
+ destructiveHint: false,
308
+ openWorldHint: true,
309
+ },
310
+ }, tool(async () => textResult('StaticX API token is valid', await apiRequest('/user'))));
311
+
312
+ server.registerTool('staticx_list_workspaces', {
313
+ title: 'List workspaces',
314
+ description: 'List workspaces visible to the configured API token.',
315
+ inputSchema: {},
316
+ annotations: {
317
+ readOnlyHint: true,
318
+ destructiveHint: false,
319
+ openWorldHint: true,
320
+ },
321
+ }, tool(async () => textResult('Visible workspaces', await apiRequest('/workspaces'))));
322
+
323
+ server.registerTool('staticx_create_workspace', {
324
+ title: 'Create workspace',
325
+ description: 'Create a workspace for the authenticated account.',
326
+ inputSchema: {
327
+ name: z.string().min(1).max(255).describe('Workspace name.'),
328
+ },
329
+ annotations: {
330
+ readOnlyHint: false,
331
+ destructiveHint: false,
332
+ openWorldHint: true,
333
+ },
334
+ }, tool(async ({ name }) => textResult('Workspace created', await jsonRequest('/workspaces', 'POST', { name }))));
335
+
336
+ server.registerTool('staticx_list_projects', {
337
+ title: 'List projects',
338
+ description: 'List projects visible to the configured API token, optionally filtered by workspace.',
339
+ inputSchema: {
340
+ workspace_id: z.number().int().positive().optional().describe('Optional workspace ID.'),
341
+ },
342
+ annotations: {
343
+ readOnlyHint: true,
344
+ destructiveHint: false,
345
+ openWorldHint: true,
346
+ },
347
+ }, tool(async ({ workspace_id: workspaceId }) => {
348
+ const query = workspaceId ? `?workspace_id=${encodeURIComponent(String(workspaceId))}` : '';
349
+
350
+ return textResult('Visible projects', await apiRequest(`/projects${query}`));
351
+ }));
352
+
353
+ server.registerTool('staticx_get_project', {
354
+ title: 'Get project',
355
+ description: 'Fetch one StaticX project by ID.',
356
+ inputSchema: {
357
+ project_id: z.union([z.string(), z.number()]).optional().describe('Project ID. Uses STATICX_PROJECT_ID when omitted.'),
358
+ },
359
+ annotations: {
360
+ readOnlyHint: true,
361
+ destructiveHint: false,
362
+ openWorldHint: true,
363
+ },
364
+ }, tool(async (args) => textResult('Project', await apiRequest(`/projects/${resolveProjectId(args)}`))));
365
+
366
+ server.registerTool('staticx_connect_custom_domain', {
367
+ title: 'Connect custom domain',
368
+ description: 'Move a project to a custom domain and return the one DNS record the user must create.',
369
+ inputSchema: {
370
+ project_id: z.union([z.string(), z.number()]).optional().describe('Project ID. Uses STATICX_PROJECT_ID when omitted.'),
371
+ domain_name: z.string().min(1).max(253).describe('Custom domain, for example app.example.com.'),
372
+ },
373
+ annotations: {
374
+ readOnlyHint: false,
375
+ destructiveHint: false,
376
+ openWorldHint: true,
377
+ },
378
+ }, tool(async (args) => textResult('Custom domain setup', await jsonRequest(
379
+ `/projects/${resolveProjectId(args)}/domain`,
380
+ 'POST',
381
+ {
382
+ domain: args.domain_name,
383
+ },
384
+ ))));
385
+
386
+ server.registerTool('staticx_get_custom_domain_status', {
387
+ title: 'Get custom domain status',
388
+ description: 'Read DNS, SSL, activation, and setup instructions for the project custom domain.',
389
+ inputSchema: {
390
+ project_id: z.union([z.string(), z.number()]).optional().describe('Project ID. Uses STATICX_PROJECT_ID when omitted.'),
391
+ },
392
+ annotations: {
393
+ readOnlyHint: true,
394
+ destructiveHint: false,
395
+ openWorldHint: true,
396
+ },
397
+ }, tool(async (args) => textResult('Custom domain status', await apiRequest(`/projects/${resolveProjectId(args)}/domain`))));
398
+
399
+ server.registerTool('staticx_create_project', {
400
+ title: 'Create site',
401
+ description: 'Create an empty StaticX site. Upload files or deploy a ZIP afterwards.',
402
+ inputSchema: {
403
+ name: z.string().max(255).optional().describe('Optional project display name.'),
404
+ description: z.string().max(1000).optional().describe('Optional project description.'),
405
+ workspace_id: z.number().int().positive().optional().describe('Optional workspace ID.'),
406
+ },
407
+ annotations: {
408
+ readOnlyHint: false,
409
+ destructiveHint: false,
410
+ openWorldHint: true,
411
+ },
412
+ }, tool(async (args) => textResult('Project created', await jsonRequest('/projects', 'POST', args))));
413
+
414
+ server.registerTool('staticx_upload_zip', {
415
+ title: 'Upload ZIP',
416
+ description: 'Upload a built static site ZIP into a project workspace. The ZIP root should contain index.html or index.htm plus 404.html.',
417
+ inputSchema: {
418
+ project_id: z.union([z.string(), z.number()]).optional().describe('Project ID. Uses STATICX_PROJECT_ID when omitted.'),
419
+ zip_path: z.string().min(1).describe('Absolute or current-working-directory-relative path to the ZIP file.'),
420
+ path: z.string().optional().describe('Optional workspace path. Usually leave empty for project root.'),
421
+ overwrite_confirmed: z.boolean().default(true).describe('Confirm overwriting existing files.'),
422
+ },
423
+ annotations: {
424
+ readOnlyHint: false,
425
+ destructiveHint: false,
426
+ openWorldHint: true,
427
+ },
428
+ }, tool(async (args) => {
429
+ const form = new FormData();
430
+ form.set('mode', 'zip');
431
+ form.set('overwrite_confirmed', args.overwrite_confirmed === false ? '0' : '1');
432
+
433
+ if (args.path) {
434
+ form.set('path', args.path);
435
+ }
436
+
437
+ form.set('archive', await zipBlob(args.zip_path), basename(args.zip_path));
438
+
439
+ return textResult('ZIP uploaded', await apiRequest(`/projects/${resolveProjectId(args)}/files`, {
440
+ method: 'POST',
441
+ body: form,
442
+ }));
443
+ }));
444
+
445
+ server.registerTool('staticx_import_url', {
446
+ title: 'Import URL',
447
+ description: 'Queue a public URL import into the project workspace.',
448
+ inputSchema: {
449
+ project_id: z.union([z.string(), z.number()]).optional().describe('Project ID. Uses STATICX_PROJECT_ID when omitted.'),
450
+ source_url: z.string().url().describe('Public URL to import.'),
451
+ path: z.string().optional().describe('Optional workspace path. Usually leave empty for project root.'),
452
+ },
453
+ annotations: {
454
+ readOnlyHint: false,
455
+ destructiveHint: false,
456
+ openWorldHint: true,
457
+ },
458
+ }, tool(async (args) => {
459
+ const form = new FormData();
460
+ form.set('mode', 'url');
461
+ form.set('source_url', args.source_url);
462
+
463
+ if (args.path) {
464
+ form.set('path', args.path);
465
+ }
466
+
467
+ return textResult('URL import queued', await apiRequest(`/projects/${resolveProjectId(args)}/files`, {
468
+ method: 'POST',
469
+ body: form,
470
+ }));
471
+ }));
472
+
473
+ server.registerTool('staticx_deploy_project', {
474
+ title: 'Deploy project',
475
+ description: 'Publish a project.',
476
+ inputSchema: {
477
+ project_id: z.union([z.string(), z.number()]).optional().describe('Project ID. Uses STATICX_PROJECT_ID when omitted.'),
478
+ },
479
+ annotations: {
480
+ readOnlyHint: false,
481
+ destructiveHint: false,
482
+ openWorldHint: true,
483
+ },
484
+ }, tool(async (args) => textResult('Deployment completed', await jsonRequest(
485
+ `/projects/${resolveProjectId(args)}/deployments`,
486
+ 'POST',
487
+ ))));
488
+
489
+ server.registerTool('staticx_list_deployments', {
490
+ title: 'List deployments',
491
+ description: 'List published deployment versions for a project.',
492
+ inputSchema: {
493
+ project_id: z.union([z.string(), z.number()]).optional().describe('Project ID. Uses STATICX_PROJECT_ID when omitted.'),
494
+ },
495
+ annotations: {
496
+ readOnlyHint: true,
497
+ destructiveHint: false,
498
+ openWorldHint: true,
499
+ },
500
+ }, tool(async (args) => textResult('Deployments', await apiRequest(`/projects/${resolveProjectId(args)}/deployments`))));
501
+
502
+ server.registerTool('staticx_rollback_deployment', {
503
+ title: 'Roll back deployment',
504
+ description: 'Make a previous deployment live. Explain the target first and require exact user confirmation.',
505
+ inputSchema: {
506
+ project_id: z.union([z.string(), z.number()]).optional().describe('Site ID. Uses STATICX_PROJECT_ID when omitted.'),
507
+ deployment_id: z.union([z.string(), z.number()]).describe('Deployment ID that should become live.'),
508
+ confirmation: z.string().describe('Exact text: ROLLBACK <deployment_id>'),
509
+ },
510
+ annotations: {
511
+ readOnlyHint: false,
512
+ destructiveHint: true,
513
+ openWorldHint: true,
514
+ },
515
+ }, tool(async (args) => {
516
+ const deploymentId = String(args.deployment_id).trim();
517
+ const expected = `ROLLBACK ${deploymentId}`;
518
+
519
+ if (args.confirmation !== expected) {
520
+ throw new Error(`Rollback requires explicit confirmation. Pass confirmation exactly as: ${expected}`);
521
+ }
522
+
523
+ return textResult('Deployment rolled back', await jsonRequest(
524
+ `/projects/${resolveProjectId(args)}/deployments/${encodeURIComponent(deploymentId)}/rollback`,
525
+ 'POST',
526
+ ));
527
+ }));
528
+
529
+ server.registerTool('staticx_delete_deployment', {
530
+ title: 'Delete deployment',
531
+ description: 'Permanently delete one inactive deployment. Explain the impact first and require exact user confirmation.',
532
+ inputSchema: {
533
+ project_id: z.union([z.string(), z.number()]).optional().describe('Site ID. Uses STATICX_PROJECT_ID when omitted.'),
534
+ deployment_id: z.union([z.string(), z.number()]).describe('Inactive deployment ID to permanently delete.'),
535
+ confirmation: z.string().describe('Exact text: DELETE <deployment_id>'),
536
+ },
537
+ annotations: {
538
+ readOnlyHint: false,
539
+ destructiveHint: true,
540
+ openWorldHint: true,
541
+ },
542
+ }, tool(async (args) => {
543
+ const deploymentId = String(args.deployment_id).trim();
544
+ const expected = `DELETE ${deploymentId}`;
545
+
546
+ if (args.confirmation !== expected) {
547
+ throw new Error(`Deletion requires explicit confirmation. Pass confirmation exactly as: ${expected}`);
548
+ }
549
+
550
+ await apiRequest(`/projects/${resolveProjectId(args)}/deployments/${encodeURIComponent(deploymentId)}`, {
551
+ method: 'DELETE',
552
+ });
553
+
554
+ return textOnly(`Deployment ${deploymentId} was deleted.`);
555
+ }));
556
+
557
+ server.registerTool('staticx_get_logs', {
558
+ title: 'Get project logs',
559
+ description: 'Read recent project activity logs.',
560
+ inputSchema: {
561
+ project_id: z.union([z.string(), z.number()]).optional().describe('Project ID. Uses STATICX_PROJECT_ID when omitted.'),
562
+ },
563
+ annotations: {
564
+ readOnlyHint: true,
565
+ destructiveHint: false,
566
+ openWorldHint: true,
567
+ },
568
+ }, tool(async (args) => textResult('Project logs', await apiRequest(`/projects/${resolveProjectId(args)}/logs`))));
569
+
570
+ server.registerTool('staticx_deploy_zip', {
571
+ title: 'Deploy ZIP end to end',
572
+ description: 'Upload a ZIP, deploy it directly, and return project, deployment, and logs.',
573
+ inputSchema: {
574
+ project_id: z.union([z.string(), z.number()]).optional().describe('Project ID. Uses STATICX_PROJECT_ID when omitted.'),
575
+ zip_path: z.string().min(1).describe('Path to ZIP file whose root contains index.html or index.htm plus 404.html.'),
576
+ path: z.string().optional().describe('Optional workspace path. Usually leave empty for project root.'),
577
+ overwrite_confirmed: z.boolean().default(true).describe('Confirm overwriting existing files.'),
578
+ },
579
+ annotations: {
580
+ readOnlyHint: false,
581
+ destructiveHint: false,
582
+ openWorldHint: true,
583
+ },
584
+ }, tool(async (args) => {
585
+ const projectId = resolveProjectId(args);
586
+ const form = new FormData();
587
+ form.set('mode', 'zip');
588
+ form.set('overwrite_confirmed', args.overwrite_confirmed === false ? '0' : '1');
589
+
590
+ if (args.path) {
591
+ form.set('path', args.path);
592
+ }
593
+
594
+ form.set('archive', await zipBlob(args.zip_path), basename(args.zip_path));
595
+
596
+ const upload = await apiRequest(`/projects/${projectId}/files`, {
597
+ method: 'POST',
598
+ body: form,
599
+ });
600
+ const deployment = await jsonRequest(`/projects/${projectId}/deployments`, 'POST');
601
+ const project = await apiRequest(`/projects/${projectId}`);
602
+ const deployments = await apiRequest(`/projects/${projectId}/deployments`);
603
+ const logs = await apiRequest(`/projects/${projectId}/logs`);
604
+
605
+ return textResult('ZIP deployed end to end', {
606
+ upload,
607
+ deployment,
608
+ project,
609
+ deployments,
610
+ logs,
611
+ live_url: project?.data?.public_url || null,
612
+ });
613
+ }));
614
+
615
+ server.registerTool('staticx_set_environment_variables', {
616
+ title: 'Set environment variables',
617
+ description: 'Sync site environment variables through the StaticX API.',
618
+ inputSchema: {
619
+ project_id: z.union([z.string(), z.number()]).optional().describe('Project ID. Uses STATICX_PROJECT_ID when omitted.'),
620
+ variables: z.record(z.string(), z.string()).describe('Key-value environment variables to store.'),
621
+ },
622
+ annotations: {
623
+ readOnlyHint: false,
624
+ destructiveHint: false,
625
+ openWorldHint: true,
626
+ },
627
+ }, tool(async (args) => textResult('Environment variables synced', await jsonRequest(
628
+ `/projects/${resolveProjectId(args)}/environment-variables`,
629
+ 'PUT',
630
+ {
631
+ variables: Object.entries(args.variables).map(([key, value]) => ({
632
+ key: key.toUpperCase(),
633
+ value,
634
+ is_secret: true,
635
+ })),
636
+ },
637
+ ))));
638
+
639
+ server.registerTool('staticx_agent_guide', {
640
+ title: 'StaticX agent guide',
641
+ description: 'Return concise instructions for an agent using this MCP server.',
642
+ inputSchema: {},
643
+ annotations: {
644
+ readOnlyHint: true,
645
+ destructiveHint: false,
646
+ openWorldHint: false,
647
+ },
648
+ }, tool(async () => textOnly(agentGuideText())));
649
+
650
+ return server;
651
+ }
652
+
653
+ function bearerToken(request) {
654
+ const authorization = request.headers.authorization || '';
655
+
656
+ return authorization.toLowerCase().startsWith('bearer ')
657
+ ? authorization.slice(7).trim()
658
+ : '';
659
+ }
660
+
661
+ async function startStdio() {
662
+ const server = createServer();
663
+ const transport = new StdioServerTransport();
664
+ await server.connect(transport);
665
+ }
666
+
667
+ async function startHttp() {
668
+ const app = createMcpExpressApp();
669
+ const port = Number.parseInt(process.env.STATICX_MCP_PORT || String(DEFAULT_HTTP_PORT), 10);
670
+ const host = process.env.STATICX_MCP_HOST || '127.0.0.1';
671
+
672
+ app.post('/mcp', async (request, response) => {
673
+ const server = createServer();
674
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
675
+ const token = bearerToken(request);
676
+
677
+ try {
678
+ await requestToken.run(token, async () => {
679
+ await server.connect(transport);
680
+ await transport.handleRequest(request, response, request.body);
681
+ });
682
+ } catch (error) {
683
+ if (!response.headersSent) {
684
+ response.status(500).json({
685
+ jsonrpc: '2.0',
686
+ error: {
687
+ code: -32603,
688
+ message: error?.message || 'StaticX MCP request failed.',
689
+ },
690
+ id: null,
691
+ });
692
+ }
693
+ } finally {
694
+ response.on('close', () => {
695
+ transport.close();
696
+ server.close();
697
+ });
698
+ }
699
+ });
700
+
701
+ app.get('/mcp', (_request, response) => {
702
+ response.status(405).json({
703
+ jsonrpc: '2.0',
704
+ error: { code: -32000, message: 'Use POST for MCP requests.' },
705
+ id: null,
706
+ });
707
+ });
708
+
709
+ app.delete('/mcp', (_request, response) => {
710
+ response.status(405).json({
711
+ jsonrpc: '2.0',
712
+ error: { code: -32000, message: 'This stateless MCP endpoint has no session to close.' },
713
+ id: null,
714
+ });
715
+ });
716
+
717
+ app.listen(port, host, (error) => {
718
+ if (error) {
719
+ console.error('StaticX MCP HTTP server failed to start:', error);
720
+ process.exit(1);
721
+ }
722
+
723
+ console.error(`StaticX MCP HTTP server listening at http://${host}:${port}/mcp`);
724
+ });
725
+ }
726
+
727
+ const mode = process.argv[2] || 'stdio';
728
+
729
+ if (mode === 'http') {
730
+ await startHttp();
731
+ } else if (mode === 'stdio') {
732
+ await startStdio();
733
+ } else {
734
+ console.error(`Unknown StaticX MCP mode "${mode}". Use "stdio" or "http".`);
735
+ process.exit(1);
736
+ }