@sproobo/mcp 0.1.1 → 0.1.3
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 +129 -0
- package/package.json +5 -1
- package/dist/__tests__/client.test.d.ts +0 -2
- package/dist/__tests__/client.test.d.ts.map +0 -1
- package/dist/__tests__/client.test.js +0 -81
- package/dist/__tests__/redact.test.d.ts +0 -2
- package/dist/__tests__/redact.test.d.ts.map +0 -1
- package/dist/__tests__/redact.test.js +0 -74
- package/dist/__tests__/startup.test.d.ts +0 -2
- package/dist/__tests__/startup.test.d.ts.map +0 -1
- package/dist/__tests__/startup.test.js +0 -29
- package/src/__tests__/client.test.ts +0 -133
- package/src/__tests__/redact.test.ts +0 -81
- package/src/__tests__/startup.test.ts +0 -42
- package/src/client.ts +0 -146
- package/src/index.ts +0 -46
- package/src/redact.ts +0 -76
- package/src/tools/deployments.ts +0 -98
- package/src/tools/firewall.ts +0 -82
- package/src/tools/nginx.ts +0 -65
- package/src/tools/servers.ts +0 -36
- package/src/tools/services.ts +0 -21
- package/src/tools/sites.ts +0 -26
- package/tsconfig.json +0 -15
- package/vitest.config.ts +0 -7
package/README.md
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# @sproobo/mcp
|
|
2
|
+
|
|
3
|
+
[Model Context Protocol](https://modelcontextprotocol.io/) server for [Sproobo](https://sproobo.com) — deploy sites, manage servers, and configure infrastructure from AI agents like Claude Desktop, Cursor, Windsurf, and any MCP-compatible client.
|
|
4
|
+
|
|
5
|
+
## Quick start
|
|
6
|
+
|
|
7
|
+
### Claude Desktop
|
|
8
|
+
|
|
9
|
+
Add to your Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`):
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"mcpServers": {
|
|
14
|
+
"sproobo": {
|
|
15
|
+
"command": "npx",
|
|
16
|
+
"args": ["-y", "@sproobo/mcp"],
|
|
17
|
+
"env": {
|
|
18
|
+
"SPROOBO_API_KEY": "spb_your_key_here"
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
### Cursor
|
|
26
|
+
|
|
27
|
+
Add to `.cursor/mcp.json` in your project:
|
|
28
|
+
|
|
29
|
+
```json
|
|
30
|
+
{
|
|
31
|
+
"mcpServers": {
|
|
32
|
+
"sproobo": {
|
|
33
|
+
"command": "npx",
|
|
34
|
+
"args": ["-y", "@sproobo/mcp"],
|
|
35
|
+
"env": {
|
|
36
|
+
"SPROOBO_API_KEY": "spb_your_key_here"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
### Claude Code
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
claude mcp add sproobo -- npx -y @sproobo/mcp
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Then set your API key in the environment:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
export SPROOBO_API_KEY="spb_your_key_here"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Authentication
|
|
56
|
+
|
|
57
|
+
Create an API key in the Sproobo dashboard under **Settings > API Keys**. See the [API Keys docs](https://sproobo.com/docs/api-keys) for details.
|
|
58
|
+
|
|
59
|
+
| Variable | Required | Description |
|
|
60
|
+
| --- | --- | --- |
|
|
61
|
+
| `SPROOBO_API_KEY` | Yes | Your API key (e.g. `spb_...`) |
|
|
62
|
+
| `SPROOBO_API_URL` | No | Custom API base URL (defaults to `https://dashboard.sproobo.com`) |
|
|
63
|
+
|
|
64
|
+
> **Never** commit API keys to source control or shared config files. Use your client's secret store or environment variables.
|
|
65
|
+
|
|
66
|
+
## Available tools
|
|
67
|
+
|
|
68
|
+
### Servers
|
|
69
|
+
|
|
70
|
+
| Tool | Description |
|
|
71
|
+
| --- | --- |
|
|
72
|
+
| `list_servers` | List all servers with status, IP, and provider info |
|
|
73
|
+
| `get_server` | Get detailed info about a specific server |
|
|
74
|
+
| `get_server_metrics` | Get latest CPU, memory, disk, and network metrics |
|
|
75
|
+
|
|
76
|
+
### Sites
|
|
77
|
+
|
|
78
|
+
| Tool | Description |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| `list_sites` | List all sites deployed on a server |
|
|
81
|
+
| `get_site` | Get site details including domains and deployment type |
|
|
82
|
+
|
|
83
|
+
### Deployments
|
|
84
|
+
|
|
85
|
+
| Tool | Description |
|
|
86
|
+
| --- | --- |
|
|
87
|
+
| `deploy_site` | Trigger a new deployment, optionally for a specific commit |
|
|
88
|
+
| `list_deployments` | List deployment history, most recent first |
|
|
89
|
+
| `get_deployment` | Get deployment details including status and timing |
|
|
90
|
+
| `get_deployment_logs` | Stream build and deploy logs for a deployment |
|
|
91
|
+
| `rollback_site` | Rollback to a previous deployment (fast or safe mode) |
|
|
92
|
+
|
|
93
|
+
### Services
|
|
94
|
+
|
|
95
|
+
| Tool | Description |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| `list_services` | List installed services (Redis, PostgreSQL, etc.) |
|
|
98
|
+
|
|
99
|
+
### Nginx
|
|
100
|
+
|
|
101
|
+
| Tool | Description |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `list_nginx_templates` | List available nginx configuration templates |
|
|
104
|
+
| `get_nginx_config_files` | Get nginx config files on a server |
|
|
105
|
+
| `test_nginx_config` | Test nginx configuration for syntax errors |
|
|
106
|
+
| `apply_nginx_template` | Apply an nginx template to a site |
|
|
107
|
+
|
|
108
|
+
### Firewall
|
|
109
|
+
|
|
110
|
+
| Tool | Description |
|
|
111
|
+
| --- | --- |
|
|
112
|
+
| `list_firewall_rules` | List all firewall rules on a server |
|
|
113
|
+
| `create_firewall_rule` | Create a new firewall rule |
|
|
114
|
+
| `delete_firewall_rule` | Delete a firewall rule |
|
|
115
|
+
| `apply_firewall_rules` | Apply configured rules to the server |
|
|
116
|
+
|
|
117
|
+
## Security
|
|
118
|
+
|
|
119
|
+
- Sensitive fields (env vars, passwords, tokens, keys, credentials) are automatically redacted from all API responses
|
|
120
|
+
- API keys are transmitted as Bearer tokens over HTTPS
|
|
121
|
+
- The server never stores credentials — it reads `SPROOBO_API_KEY` from the environment at startup
|
|
122
|
+
|
|
123
|
+
## Documentation
|
|
124
|
+
|
|
125
|
+
Full documentation is available at [sproobo.com/docs/mcp](https://sproobo.com/docs/mcp).
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sproobo/mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Sproobo MCP server — deploy sites, manage servers, and configure infrastructure from AI agents",
|
|
@@ -15,6 +15,10 @@
|
|
|
15
15
|
"default": "./dist/index.js"
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
18
22
|
"dependencies": {
|
|
19
23
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
20
24
|
"zod": "^3.25.76"
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"client.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/client.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { SprooboClient } from "../client.js";
|
|
3
|
-
describe("SprooboClient", () => {
|
|
4
|
-
let client;
|
|
5
|
-
beforeEach(() => {
|
|
6
|
-
client = new SprooboClient("spb_testapikey123", "https://test.sproobo.com");
|
|
7
|
-
vi.restoreAllMocks();
|
|
8
|
-
});
|
|
9
|
-
it("sends Authorization header with API key", async () => {
|
|
10
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
11
|
-
ok: true,
|
|
12
|
-
json: () => Promise.resolve({ servers: [] }),
|
|
13
|
-
});
|
|
14
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
15
|
-
await client.get("/api/servers");
|
|
16
|
-
expect(mockFetch).toHaveBeenCalledWith("https://test.sproobo.com/api/servers", expect.objectContaining({
|
|
17
|
-
headers: expect.objectContaining({
|
|
18
|
-
Authorization: "Bearer spb_testapikey123",
|
|
19
|
-
}),
|
|
20
|
-
}));
|
|
21
|
-
});
|
|
22
|
-
it("strips trailing slash from base URL", async () => {
|
|
23
|
-
const clientWithSlash = new SprooboClient("spb_key", "https://test.sproobo.com/");
|
|
24
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
25
|
-
ok: true,
|
|
26
|
-
json: () => Promise.resolve({}),
|
|
27
|
-
});
|
|
28
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
29
|
-
await clientWithSlash.get("/api/servers");
|
|
30
|
-
expect(mockFetch).toHaveBeenCalledWith("https://test.sproobo.com/api/servers", expect.anything());
|
|
31
|
-
});
|
|
32
|
-
it("throws on 401 with helpful message", async () => {
|
|
33
|
-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
34
|
-
ok: false,
|
|
35
|
-
status: 401,
|
|
36
|
-
statusText: "Unauthorized",
|
|
37
|
-
json: () => Promise.resolve({ error: "Unauthorized" }),
|
|
38
|
-
}));
|
|
39
|
-
await expect(client.get("/api/servers")).rejects.toThrow("Invalid or revoked API key");
|
|
40
|
-
});
|
|
41
|
-
it("throws on 403 with permission message", async () => {
|
|
42
|
-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
43
|
-
ok: false,
|
|
44
|
-
status: 403,
|
|
45
|
-
statusText: "Forbidden",
|
|
46
|
-
json: () => Promise.resolve({ error: "You don't have access to this server" }),
|
|
47
|
-
}));
|
|
48
|
-
await expect(client.get("/api/servers/abc")).rejects.toThrow("Insufficient permissions");
|
|
49
|
-
});
|
|
50
|
-
it("throws on 429 with retry-after", async () => {
|
|
51
|
-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
52
|
-
ok: false,
|
|
53
|
-
status: 429,
|
|
54
|
-
statusText: "Too Many Requests",
|
|
55
|
-
headers: new Headers({ "Retry-After": "30" }),
|
|
56
|
-
json: () => Promise.resolve({ error: "Rate limited" }),
|
|
57
|
-
}));
|
|
58
|
-
await expect(client.get("/api/servers")).rejects.toThrow("Rate limit exceeded. Retry after 30 seconds");
|
|
59
|
-
});
|
|
60
|
-
it("sends POST body as JSON", async () => {
|
|
61
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
62
|
-
ok: true,
|
|
63
|
-
json: () => Promise.resolve({ success: true }),
|
|
64
|
-
});
|
|
65
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
66
|
-
await client.post("/api/sites/abc/deployments", { commitSha: "abc123" });
|
|
67
|
-
expect(mockFetch).toHaveBeenCalledWith("https://test.sproobo.com/api/sites/abc/deployments", expect.objectContaining({
|
|
68
|
-
method: "POST",
|
|
69
|
-
body: JSON.stringify({ commitSha: "abc123" }),
|
|
70
|
-
}));
|
|
71
|
-
});
|
|
72
|
-
it("handles 404 errors", async () => {
|
|
73
|
-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
|
74
|
-
ok: false,
|
|
75
|
-
status: 404,
|
|
76
|
-
statusText: "Not Found",
|
|
77
|
-
json: () => Promise.resolve({ error: "Server not found" }),
|
|
78
|
-
}));
|
|
79
|
-
await expect(client.get("/api/servers/nonexistent")).rejects.toThrow("Not found: Server not found");
|
|
80
|
-
});
|
|
81
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"redact.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/redact.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { redact, safeJson } from "../redact.js";
|
|
3
|
-
describe("redact", () => {
|
|
4
|
-
it("redacts known sensitive keys", () => {
|
|
5
|
-
const input = {
|
|
6
|
-
name: "my-site",
|
|
7
|
-
envVars: { DB_URL: "postgres://...", API_KEY: "secret" },
|
|
8
|
-
status: "healthy",
|
|
9
|
-
};
|
|
10
|
-
const result = redact(input);
|
|
11
|
-
expect(result.name).toBe("my-site");
|
|
12
|
-
expect(result.envVars).toBe("[REDACTED]");
|
|
13
|
-
expect(result.status).toBe("healthy");
|
|
14
|
-
});
|
|
15
|
-
it("redacts keys ending with _KEY, _SECRET, _TOKEN, _PASSWORD", () => {
|
|
16
|
-
const input = {
|
|
17
|
-
STRIPE_SECRET_KEY: "sk_test_xxx",
|
|
18
|
-
database_password: "hunter2",
|
|
19
|
-
auth_token: "tok_xxx",
|
|
20
|
-
slack_credentials: "xoxb-xxx",
|
|
21
|
-
};
|
|
22
|
-
const result = redact(input);
|
|
23
|
-
expect(result.STRIPE_SECRET_KEY).toBe("[REDACTED]");
|
|
24
|
-
expect(result.database_password).toBe("[REDACTED]");
|
|
25
|
-
expect(result.auth_token).toBe("[REDACTED]");
|
|
26
|
-
expect(result.slack_credentials).toBe("[REDACTED]");
|
|
27
|
-
});
|
|
28
|
-
it("redacts nested objects", () => {
|
|
29
|
-
const input = {
|
|
30
|
-
site: {
|
|
31
|
-
name: "app",
|
|
32
|
-
envVars: { SECRET: "xxx" },
|
|
33
|
-
config: {
|
|
34
|
-
password: "hunter2",
|
|
35
|
-
port: 3000,
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
};
|
|
39
|
-
const result = redact(input);
|
|
40
|
-
expect(result.site.name).toBe("app");
|
|
41
|
-
expect(result.site.envVars).toBe("[REDACTED]");
|
|
42
|
-
const config = result.site.config;
|
|
43
|
-
expect(config.password).toBe("[REDACTED]");
|
|
44
|
-
expect(config.port).toBe(3000);
|
|
45
|
-
});
|
|
46
|
-
it("redacts items in arrays", () => {
|
|
47
|
-
const input = [
|
|
48
|
-
{ name: "site-a", envVars: { KEY: "val" } },
|
|
49
|
-
{ name: "site-b", token: "xxx" },
|
|
50
|
-
];
|
|
51
|
-
const result = redact(input);
|
|
52
|
-
expect(result[0].name).toBe("site-a");
|
|
53
|
-
expect(result[0].envVars).toBe("[REDACTED]");
|
|
54
|
-
expect(result[1].token).toBe("[REDACTED]");
|
|
55
|
-
});
|
|
56
|
-
it("leaves non-sensitive data untouched", () => {
|
|
57
|
-
const input = { id: "abc", name: "test", status: "healthy", port: 3000 };
|
|
58
|
-
expect(redact(input)).toEqual(input);
|
|
59
|
-
});
|
|
60
|
-
it("handles null and primitives", () => {
|
|
61
|
-
expect(redact(null)).toBeNull();
|
|
62
|
-
expect(redact(undefined)).toBeUndefined();
|
|
63
|
-
expect(redact("hello")).toBe("hello");
|
|
64
|
-
expect(redact(42)).toBe(42);
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
describe("safeJson", () => {
|
|
68
|
-
it("returns redacted JSON string", () => {
|
|
69
|
-
const input = { name: "site", envVars: { SECRET: "xxx" } };
|
|
70
|
-
const result = JSON.parse(safeJson(input));
|
|
71
|
-
expect(result.name).toBe("site");
|
|
72
|
-
expect(result.envVars).toBe("[REDACTED]");
|
|
73
|
-
});
|
|
74
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"startup.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/startup.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { execFile } from "child_process";
|
|
3
|
-
import { resolve } from "path";
|
|
4
|
-
describe("MCP server startup", () => {
|
|
5
|
-
const originalEnv = process.env;
|
|
6
|
-
beforeEach(() => {
|
|
7
|
-
process.env = { ...originalEnv };
|
|
8
|
-
});
|
|
9
|
-
afterEach(() => {
|
|
10
|
-
process.env = originalEnv;
|
|
11
|
-
vi.restoreAllMocks();
|
|
12
|
-
});
|
|
13
|
-
it("exits with error when SPROOBO_API_KEY is missing", async () => {
|
|
14
|
-
const entryPoint = resolve(__dirname, "../../dist/index.js");
|
|
15
|
-
const result = await new Promise((resolve) => {
|
|
16
|
-
const child = execFile("node", [entryPoint], {
|
|
17
|
-
env: { ...process.env, SPROOBO_API_KEY: "" },
|
|
18
|
-
timeout: 5000,
|
|
19
|
-
}, (error, _stdout, stderr) => {
|
|
20
|
-
resolve({
|
|
21
|
-
code: error?.code !== undefined ? Number(error.code) : child.exitCode,
|
|
22
|
-
stderr,
|
|
23
|
-
});
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
expect(result.code).toBe(1);
|
|
27
|
-
expect(result.stderr).toContain("SPROOBO_API_KEY");
|
|
28
|
-
});
|
|
29
|
-
});
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { SprooboClient } from "../client.js";
|
|
3
|
-
|
|
4
|
-
describe("SprooboClient", () => {
|
|
5
|
-
let client: SprooboClient;
|
|
6
|
-
|
|
7
|
-
beforeEach(() => {
|
|
8
|
-
client = new SprooboClient("spb_testapikey123", "https://test.sproobo.com");
|
|
9
|
-
vi.restoreAllMocks();
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it("sends Authorization header with API key", async () => {
|
|
13
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
14
|
-
ok: true,
|
|
15
|
-
json: () => Promise.resolve({ servers: [] }),
|
|
16
|
-
});
|
|
17
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
18
|
-
|
|
19
|
-
await client.get("/api/servers");
|
|
20
|
-
|
|
21
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
22
|
-
"https://test.sproobo.com/api/servers",
|
|
23
|
-
expect.objectContaining({
|
|
24
|
-
headers: expect.objectContaining({
|
|
25
|
-
Authorization: "Bearer spb_testapikey123",
|
|
26
|
-
}),
|
|
27
|
-
}),
|
|
28
|
-
);
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("strips trailing slash from base URL", async () => {
|
|
32
|
-
const clientWithSlash = new SprooboClient(
|
|
33
|
-
"spb_key",
|
|
34
|
-
"https://test.sproobo.com/",
|
|
35
|
-
);
|
|
36
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
37
|
-
ok: true,
|
|
38
|
-
json: () => Promise.resolve({}),
|
|
39
|
-
});
|
|
40
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
41
|
-
|
|
42
|
-
await clientWithSlash.get("/api/servers");
|
|
43
|
-
|
|
44
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
45
|
-
"https://test.sproobo.com/api/servers",
|
|
46
|
-
expect.anything(),
|
|
47
|
-
);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("throws on 401 with helpful message", async () => {
|
|
51
|
-
vi.stubGlobal(
|
|
52
|
-
"fetch",
|
|
53
|
-
vi.fn().mockResolvedValue({
|
|
54
|
-
ok: false,
|
|
55
|
-
status: 401,
|
|
56
|
-
statusText: "Unauthorized",
|
|
57
|
-
json: () => Promise.resolve({ error: "Unauthorized" }),
|
|
58
|
-
}),
|
|
59
|
-
);
|
|
60
|
-
|
|
61
|
-
await expect(client.get("/api/servers")).rejects.toThrow(
|
|
62
|
-
"Invalid or revoked API key",
|
|
63
|
-
);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("throws on 403 with permission message", async () => {
|
|
67
|
-
vi.stubGlobal(
|
|
68
|
-
"fetch",
|
|
69
|
-
vi.fn().mockResolvedValue({
|
|
70
|
-
ok: false,
|
|
71
|
-
status: 403,
|
|
72
|
-
statusText: "Forbidden",
|
|
73
|
-
json: () =>
|
|
74
|
-
Promise.resolve({ error: "You don't have access to this server" }),
|
|
75
|
-
}),
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
await expect(client.get("/api/servers/abc")).rejects.toThrow(
|
|
79
|
-
"Insufficient permissions",
|
|
80
|
-
);
|
|
81
|
-
});
|
|
82
|
-
|
|
83
|
-
it("throws on 429 with retry-after", async () => {
|
|
84
|
-
vi.stubGlobal(
|
|
85
|
-
"fetch",
|
|
86
|
-
vi.fn().mockResolvedValue({
|
|
87
|
-
ok: false,
|
|
88
|
-
status: 429,
|
|
89
|
-
statusText: "Too Many Requests",
|
|
90
|
-
headers: new Headers({ "Retry-After": "30" }),
|
|
91
|
-
json: () => Promise.resolve({ error: "Rate limited" }),
|
|
92
|
-
}),
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
await expect(client.get("/api/servers")).rejects.toThrow(
|
|
96
|
-
"Rate limit exceeded. Retry after 30 seconds",
|
|
97
|
-
);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it("sends POST body as JSON", async () => {
|
|
101
|
-
const mockFetch = vi.fn().mockResolvedValue({
|
|
102
|
-
ok: true,
|
|
103
|
-
json: () => Promise.resolve({ success: true }),
|
|
104
|
-
});
|
|
105
|
-
vi.stubGlobal("fetch", mockFetch);
|
|
106
|
-
|
|
107
|
-
await client.post("/api/sites/abc/deployments", { commitSha: "abc123" });
|
|
108
|
-
|
|
109
|
-
expect(mockFetch).toHaveBeenCalledWith(
|
|
110
|
-
"https://test.sproobo.com/api/sites/abc/deployments",
|
|
111
|
-
expect.objectContaining({
|
|
112
|
-
method: "POST",
|
|
113
|
-
body: JSON.stringify({ commitSha: "abc123" }),
|
|
114
|
-
}),
|
|
115
|
-
);
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
it("handles 404 errors", async () => {
|
|
119
|
-
vi.stubGlobal(
|
|
120
|
-
"fetch",
|
|
121
|
-
vi.fn().mockResolvedValue({
|
|
122
|
-
ok: false,
|
|
123
|
-
status: 404,
|
|
124
|
-
statusText: "Not Found",
|
|
125
|
-
json: () => Promise.resolve({ error: "Server not found" }),
|
|
126
|
-
}),
|
|
127
|
-
);
|
|
128
|
-
|
|
129
|
-
await expect(client.get("/api/servers/nonexistent")).rejects.toThrow(
|
|
130
|
-
"Not found: Server not found",
|
|
131
|
-
);
|
|
132
|
-
});
|
|
133
|
-
});
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { redact, safeJson } from "../redact.js";
|
|
3
|
-
|
|
4
|
-
describe("redact", () => {
|
|
5
|
-
it("redacts known sensitive keys", () => {
|
|
6
|
-
const input = {
|
|
7
|
-
name: "my-site",
|
|
8
|
-
envVars: { DB_URL: "postgres://...", API_KEY: "secret" },
|
|
9
|
-
status: "healthy",
|
|
10
|
-
};
|
|
11
|
-
const result = redact(input) as Record<string, unknown>;
|
|
12
|
-
expect(result.name).toBe("my-site");
|
|
13
|
-
expect(result.envVars).toBe("[REDACTED]");
|
|
14
|
-
expect(result.status).toBe("healthy");
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("redacts keys ending with _KEY, _SECRET, _TOKEN, _PASSWORD", () => {
|
|
18
|
-
const input = {
|
|
19
|
-
STRIPE_SECRET_KEY: "sk_test_xxx",
|
|
20
|
-
database_password: "hunter2",
|
|
21
|
-
auth_token: "tok_xxx",
|
|
22
|
-
slack_credentials: "xoxb-xxx",
|
|
23
|
-
};
|
|
24
|
-
const result = redact(input) as Record<string, unknown>;
|
|
25
|
-
expect(result.STRIPE_SECRET_KEY).toBe("[REDACTED]");
|
|
26
|
-
expect(result.database_password).toBe("[REDACTED]");
|
|
27
|
-
expect(result.auth_token).toBe("[REDACTED]");
|
|
28
|
-
expect(result.slack_credentials).toBe("[REDACTED]");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
it("redacts nested objects", () => {
|
|
32
|
-
const input = {
|
|
33
|
-
site: {
|
|
34
|
-
name: "app",
|
|
35
|
-
envVars: { SECRET: "xxx" },
|
|
36
|
-
config: {
|
|
37
|
-
password: "hunter2",
|
|
38
|
-
port: 3000,
|
|
39
|
-
},
|
|
40
|
-
},
|
|
41
|
-
};
|
|
42
|
-
const result = redact(input) as { site: Record<string, unknown> };
|
|
43
|
-
expect(result.site.name).toBe("app");
|
|
44
|
-
expect(result.site.envVars).toBe("[REDACTED]");
|
|
45
|
-
const config = result.site.config as Record<string, unknown>;
|
|
46
|
-
expect(config.password).toBe("[REDACTED]");
|
|
47
|
-
expect(config.port).toBe(3000);
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("redacts items in arrays", () => {
|
|
51
|
-
const input = [
|
|
52
|
-
{ name: "site-a", envVars: { KEY: "val" } },
|
|
53
|
-
{ name: "site-b", token: "xxx" },
|
|
54
|
-
];
|
|
55
|
-
const result = redact(input) as Array<Record<string, unknown>>;
|
|
56
|
-
expect(result[0].name).toBe("site-a");
|
|
57
|
-
expect(result[0].envVars).toBe("[REDACTED]");
|
|
58
|
-
expect(result[1].token).toBe("[REDACTED]");
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it("leaves non-sensitive data untouched", () => {
|
|
62
|
-
const input = { id: "abc", name: "test", status: "healthy", port: 3000 };
|
|
63
|
-
expect(redact(input)).toEqual(input);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
it("handles null and primitives", () => {
|
|
67
|
-
expect(redact(null)).toBeNull();
|
|
68
|
-
expect(redact(undefined)).toBeUndefined();
|
|
69
|
-
expect(redact("hello")).toBe("hello");
|
|
70
|
-
expect(redact(42)).toBe(42);
|
|
71
|
-
});
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
describe("safeJson", () => {
|
|
75
|
-
it("returns redacted JSON string", () => {
|
|
76
|
-
const input = { name: "site", envVars: { SECRET: "xxx" } };
|
|
77
|
-
const result = JSON.parse(safeJson(input));
|
|
78
|
-
expect(result.name).toBe("site");
|
|
79
|
-
expect(result.envVars).toBe("[REDACTED]");
|
|
80
|
-
});
|
|
81
|
-
});
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { execFile } from "child_process";
|
|
3
|
-
import { resolve } from "path";
|
|
4
|
-
|
|
5
|
-
describe("MCP server startup", () => {
|
|
6
|
-
const originalEnv = process.env;
|
|
7
|
-
|
|
8
|
-
beforeEach(() => {
|
|
9
|
-
process.env = { ...originalEnv };
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
afterEach(() => {
|
|
13
|
-
process.env = originalEnv;
|
|
14
|
-
vi.restoreAllMocks();
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("exits with error when SPROOBO_API_KEY is missing", async () => {
|
|
18
|
-
const entryPoint = resolve(__dirname, "../../dist/index.js");
|
|
19
|
-
|
|
20
|
-
const result = await new Promise<{ code: number | null; stderr: string }>(
|
|
21
|
-
(resolve) => {
|
|
22
|
-
const child = execFile(
|
|
23
|
-
"node",
|
|
24
|
-
[entryPoint],
|
|
25
|
-
{
|
|
26
|
-
env: { ...process.env, SPROOBO_API_KEY: "" },
|
|
27
|
-
timeout: 5000,
|
|
28
|
-
},
|
|
29
|
-
(error, _stdout, stderr) => {
|
|
30
|
-
resolve({
|
|
31
|
-
code: error?.code !== undefined ? Number(error.code) : child.exitCode,
|
|
32
|
-
stderr,
|
|
33
|
-
});
|
|
34
|
-
},
|
|
35
|
-
);
|
|
36
|
-
},
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
expect(result.code).toBe(1);
|
|
40
|
-
expect(result.stderr).toContain("SPROOBO_API_KEY");
|
|
41
|
-
});
|
|
42
|
-
});
|
package/src/client.ts
DELETED
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP client for the Sproobo REST API.
|
|
3
|
-
* Wraps fetch with base URL, auth header, and error mapping.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
export class SprooboClient {
|
|
7
|
-
private baseUrl: string;
|
|
8
|
-
private apiKey: string;
|
|
9
|
-
|
|
10
|
-
constructor(apiKey: string, baseUrl?: string) {
|
|
11
|
-
this.apiKey = apiKey;
|
|
12
|
-
this.baseUrl = (baseUrl || "https://dashboard.sproobo.com").replace(
|
|
13
|
-
/\/$/,
|
|
14
|
-
"",
|
|
15
|
-
);
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
private headers(): Record<string, string> {
|
|
19
|
-
return {
|
|
20
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
21
|
-
"Content-Type": "application/json",
|
|
22
|
-
};
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
async get<T = unknown>(path: string): Promise<T> {
|
|
26
|
-
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
27
|
-
method: "GET",
|
|
28
|
-
headers: this.headers(),
|
|
29
|
-
});
|
|
30
|
-
return this.handleResponse<T>(res);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async post<T = unknown>(path: string, body?: unknown): Promise<T> {
|
|
34
|
-
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
35
|
-
method: "POST",
|
|
36
|
-
headers: this.headers(),
|
|
37
|
-
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
38
|
-
});
|
|
39
|
-
return this.handleResponse<T>(res);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async delete<T = unknown>(path: string): Promise<T> {
|
|
43
|
-
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
44
|
-
method: "DELETE",
|
|
45
|
-
headers: this.headers(),
|
|
46
|
-
});
|
|
47
|
-
return this.handleResponse<T>(res);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Consume an SSE stream endpoint and return all event data as concatenated text.
|
|
52
|
-
*/
|
|
53
|
-
async getStream(path: string, timeoutMs = 30000): Promise<string> {
|
|
54
|
-
const controller = new AbortController();
|
|
55
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
56
|
-
|
|
57
|
-
try {
|
|
58
|
-
const res = await fetch(`${this.baseUrl}${path}`, {
|
|
59
|
-
method: "GET",
|
|
60
|
-
headers: {
|
|
61
|
-
Authorization: `Bearer ${this.apiKey}`,
|
|
62
|
-
Accept: "text/event-stream",
|
|
63
|
-
},
|
|
64
|
-
signal: controller.signal,
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
if (!res.ok) {
|
|
68
|
-
await this.handleErrorResponse(res);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const reader = res.body?.getReader();
|
|
72
|
-
if (!reader) {
|
|
73
|
-
return "";
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const decoder = new TextDecoder();
|
|
77
|
-
const lines: string[] = [];
|
|
78
|
-
|
|
79
|
-
while (true) {
|
|
80
|
-
const { done, value } = await reader.read();
|
|
81
|
-
if (done) break;
|
|
82
|
-
|
|
83
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
84
|
-
for (const line of chunk.split("\n")) {
|
|
85
|
-
if (line.startsWith("data: ")) {
|
|
86
|
-
const data = line.slice(6);
|
|
87
|
-
if (data === "[DONE]") continue;
|
|
88
|
-
try {
|
|
89
|
-
const parsed = JSON.parse(data);
|
|
90
|
-
if (parsed.message || parsed.log || parsed.data) {
|
|
91
|
-
lines.push(parsed.message || parsed.log || parsed.data);
|
|
92
|
-
} else if (typeof parsed === "string") {
|
|
93
|
-
lines.push(parsed);
|
|
94
|
-
} else {
|
|
95
|
-
lines.push(JSON.stringify(parsed));
|
|
96
|
-
}
|
|
97
|
-
} catch {
|
|
98
|
-
lines.push(data);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return lines.join("\n");
|
|
105
|
-
} finally {
|
|
106
|
-
clearTimeout(timeout);
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
private async handleResponse<T>(res: Response): Promise<T> {
|
|
111
|
-
if (!res.ok) {
|
|
112
|
-
await this.handleErrorResponse(res);
|
|
113
|
-
}
|
|
114
|
-
return (await res.json()) as T;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
private async handleErrorResponse(res: Response): Promise<never> {
|
|
118
|
-
let message: string;
|
|
119
|
-
try {
|
|
120
|
-
const body = (await res.json()) as Record<string, string>;
|
|
121
|
-
message =
|
|
122
|
-
body.error || body.message || `HTTP ${res.status}: ${res.statusText}`;
|
|
123
|
-
} catch {
|
|
124
|
-
message = `HTTP ${res.status}: ${res.statusText}`;
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
switch (res.status) {
|
|
128
|
-
case 401:
|
|
129
|
-
throw new Error(
|
|
130
|
-
"Invalid or revoked API key. Create one at your Sproobo dashboard under Settings > API Keys.",
|
|
131
|
-
);
|
|
132
|
-
case 403:
|
|
133
|
-
throw new Error(`Insufficient permissions: ${message}`);
|
|
134
|
-
case 404:
|
|
135
|
-
throw new Error(`Not found: ${message}`);
|
|
136
|
-
case 429: {
|
|
137
|
-
const retryAfter = res.headers.get("Retry-After") || "60";
|
|
138
|
-
throw new Error(
|
|
139
|
-
`Rate limit exceeded. Retry after ${retryAfter} seconds.`,
|
|
140
|
-
);
|
|
141
|
-
}
|
|
142
|
-
default:
|
|
143
|
-
throw new Error(message);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
-
import { SprooboClient } from "./client.js";
|
|
6
|
-
import { registerServerTools } from "./tools/servers.js";
|
|
7
|
-
import { registerSiteTools } from "./tools/sites.js";
|
|
8
|
-
import { registerDeploymentTools } from "./tools/deployments.js";
|
|
9
|
-
import { registerNginxTools } from "./tools/nginx.js";
|
|
10
|
-
import { registerFirewallTools } from "./tools/firewall.js";
|
|
11
|
-
import { registerServiceTools } from "./tools/services.js";
|
|
12
|
-
|
|
13
|
-
const apiKey = process.env.SPROOBO_API_KEY;
|
|
14
|
-
if (!apiKey) {
|
|
15
|
-
console.error(
|
|
16
|
-
"Error: SPROOBO_API_KEY environment variable is required.\n" +
|
|
17
|
-
"Create an API key at your Sproobo dashboard under Settings > API Keys,\n" +
|
|
18
|
-
'then set it: SPROOBO_API_KEY="spb_xxx"',
|
|
19
|
-
);
|
|
20
|
-
process.exit(1);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const apiUrl = process.env.SPROOBO_API_URL;
|
|
24
|
-
const client = new SprooboClient(apiKey, apiUrl);
|
|
25
|
-
|
|
26
|
-
const server = new McpServer({
|
|
27
|
-
name: "sproobo",
|
|
28
|
-
version: "0.1.0",
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
registerServerTools(server, client);
|
|
32
|
-
registerSiteTools(server, client);
|
|
33
|
-
registerDeploymentTools(server, client);
|
|
34
|
-
registerNginxTools(server, client);
|
|
35
|
-
registerFirewallTools(server, client);
|
|
36
|
-
registerServiceTools(server, client);
|
|
37
|
-
|
|
38
|
-
async function main() {
|
|
39
|
-
const transport = new StdioServerTransport();
|
|
40
|
-
await server.connect(transport);
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
main().catch((error) => {
|
|
44
|
-
console.error("Fatal error:", error);
|
|
45
|
-
process.exit(1);
|
|
46
|
-
});
|
package/src/redact.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Redact sensitive fields from API responses before returning to MCP clients.
|
|
3
|
-
* Prevents credentials, env vars, and tokens from leaking into agent context.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
const SENSITIVE_KEYS = new Set([
|
|
7
|
-
"envVars",
|
|
8
|
-
"env_vars",
|
|
9
|
-
"environmentVariables",
|
|
10
|
-
"environment_variables",
|
|
11
|
-
"envTemplate",
|
|
12
|
-
"env_template",
|
|
13
|
-
"password",
|
|
14
|
-
"secret",
|
|
15
|
-
"token",
|
|
16
|
-
"apiKey",
|
|
17
|
-
"api_key",
|
|
18
|
-
"accessToken",
|
|
19
|
-
"access_token",
|
|
20
|
-
"refreshToken",
|
|
21
|
-
"refresh_token",
|
|
22
|
-
"privateKey",
|
|
23
|
-
"private_key",
|
|
24
|
-
"credentials",
|
|
25
|
-
"connectionString",
|
|
26
|
-
"connection_string",
|
|
27
|
-
"databaseUrl",
|
|
28
|
-
"database_url",
|
|
29
|
-
]);
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Whether a key looks like it holds a secret value.
|
|
33
|
-
* Matches exact keys in SENSITIVE_KEYS, plus any key ending with
|
|
34
|
-
* _KEY, _SECRET, _TOKEN, _PASSWORD, or _CREDENTIALS (case-insensitive).
|
|
35
|
-
*/
|
|
36
|
-
function isSensitiveKey(key: string): boolean {
|
|
37
|
-
if (SENSITIVE_KEYS.has(key)) return true;
|
|
38
|
-
|
|
39
|
-
const upper = key.toUpperCase();
|
|
40
|
-
return (
|
|
41
|
-
upper.endsWith("_KEY") ||
|
|
42
|
-
upper.endsWith("_SECRET") ||
|
|
43
|
-
upper.endsWith("_TOKEN") ||
|
|
44
|
-
upper.endsWith("_PASSWORD") ||
|
|
45
|
-
upper.endsWith("_CREDENTIALS")
|
|
46
|
-
);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Deep-clone a value, replacing sensitive fields with "[REDACTED]".
|
|
51
|
-
*/
|
|
52
|
-
export function redact(value: unknown): unknown {
|
|
53
|
-
if (value === null || value === undefined) return value;
|
|
54
|
-
if (typeof value !== "object") return value;
|
|
55
|
-
|
|
56
|
-
if (Array.isArray(value)) {
|
|
57
|
-
return value.map((item) => redact(item));
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const result: Record<string, unknown> = {};
|
|
61
|
-
for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
|
|
62
|
-
if (isSensitiveKey(key)) {
|
|
63
|
-
result[key] = "[REDACTED]";
|
|
64
|
-
} else {
|
|
65
|
-
result[key] = redact(val);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
return result;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Redact and stringify for MCP tool responses.
|
|
73
|
-
*/
|
|
74
|
-
export function safeJson(data: unknown): string {
|
|
75
|
-
return JSON.stringify(redact(data), null, 2);
|
|
76
|
-
}
|
package/src/tools/deployments.ts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import type { SprooboClient } from "../client.js";
|
|
4
|
-
import { safeJson } from "../redact.js";
|
|
5
|
-
|
|
6
|
-
export function registerDeploymentTools(
|
|
7
|
-
server: McpServer,
|
|
8
|
-
client: SprooboClient,
|
|
9
|
-
) {
|
|
10
|
-
server.tool(
|
|
11
|
-
"deploy_site",
|
|
12
|
-
"Trigger a new deployment for a site. Optionally specify a commit SHA to deploy a specific version.",
|
|
13
|
-
{
|
|
14
|
-
siteId: z.string().describe("The site ID"),
|
|
15
|
-
commitSha: z
|
|
16
|
-
.string()
|
|
17
|
-
.optional()
|
|
18
|
-
.describe("Specific commit SHA to deploy (optional, defaults to latest)"),
|
|
19
|
-
},
|
|
20
|
-
async ({ siteId, commitSha }) => {
|
|
21
|
-
const body: Record<string, string> = {};
|
|
22
|
-
if (commitSha) body.commitSha = commitSha;
|
|
23
|
-
const data = await client.post(`/api/sites/${siteId}/deployments`, body);
|
|
24
|
-
return {
|
|
25
|
-
content: [{ type: "text", text: safeJson(data) }],
|
|
26
|
-
};
|
|
27
|
-
},
|
|
28
|
-
);
|
|
29
|
-
|
|
30
|
-
server.tool(
|
|
31
|
-
"list_deployments",
|
|
32
|
-
"List deployment history for a site, ordered by most recent first",
|
|
33
|
-
{ siteId: z.string().describe("The site ID") },
|
|
34
|
-
async ({ siteId }) => {
|
|
35
|
-
const data = await client.get(`/api/sites/${siteId}/deployments`);
|
|
36
|
-
return {
|
|
37
|
-
content: [{ type: "text", text: safeJson(data) }],
|
|
38
|
-
};
|
|
39
|
-
},
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
server.tool(
|
|
43
|
-
"get_deployment",
|
|
44
|
-
"Get detailed information about a specific deployment including status, commit, and timing",
|
|
45
|
-
{
|
|
46
|
-
siteId: z.string().describe("The site ID"),
|
|
47
|
-
deploymentId: z.string().describe("The deployment ID"),
|
|
48
|
-
},
|
|
49
|
-
async ({ siteId, deploymentId }) => {
|
|
50
|
-
const data = await client.get(
|
|
51
|
-
`/api/sites/${siteId}/deployments/${deploymentId}`,
|
|
52
|
-
);
|
|
53
|
-
return {
|
|
54
|
-
content: [{ type: "text", text: safeJson(data) }],
|
|
55
|
-
};
|
|
56
|
-
},
|
|
57
|
-
);
|
|
58
|
-
|
|
59
|
-
server.tool(
|
|
60
|
-
"get_deployment_logs",
|
|
61
|
-
"Get the build and deploy logs for a specific deployment",
|
|
62
|
-
{
|
|
63
|
-
siteId: z.string().describe("The site ID"),
|
|
64
|
-
deploymentId: z.string().describe("The deployment ID"),
|
|
65
|
-
},
|
|
66
|
-
async ({ siteId, deploymentId }) => {
|
|
67
|
-
const logs = await client.getStream(
|
|
68
|
-
`/api/sites/${siteId}/deployments/${deploymentId}/stream`,
|
|
69
|
-
);
|
|
70
|
-
return {
|
|
71
|
-
content: [{ type: "text", text: logs || "(No logs available)" }],
|
|
72
|
-
};
|
|
73
|
-
},
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
server.tool(
|
|
77
|
-
"rollback_site",
|
|
78
|
-
"Rollback a site to a previous deployment. Use 'fast' mode for instant symlink switch or 'safe' mode for a full rebuild.",
|
|
79
|
-
{
|
|
80
|
-
siteId: z.string().describe("The site ID"),
|
|
81
|
-
mode: z
|
|
82
|
-
.enum(["fast", "safe"])
|
|
83
|
-
.optional()
|
|
84
|
-
.describe("Rollback mode: 'fast' (symlink switch) or 'safe' (full rebuild). Defaults to 'safe'."),
|
|
85
|
-
},
|
|
86
|
-
async ({ siteId, mode }) => {
|
|
87
|
-
const body: Record<string, string> = {};
|
|
88
|
-
if (mode) body.mode = mode;
|
|
89
|
-
const data = await client.post(
|
|
90
|
-
`/api/sites/${siteId}/deployments/rollback`,
|
|
91
|
-
body,
|
|
92
|
-
);
|
|
93
|
-
return {
|
|
94
|
-
content: [{ type: "text", text: safeJson(data) }],
|
|
95
|
-
};
|
|
96
|
-
},
|
|
97
|
-
);
|
|
98
|
-
}
|
package/src/tools/firewall.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import type { SprooboClient } from "../client.js";
|
|
4
|
-
import { safeJson } from "../redact.js";
|
|
5
|
-
|
|
6
|
-
export function registerFirewallTools(
|
|
7
|
-
server: McpServer,
|
|
8
|
-
client: SprooboClient,
|
|
9
|
-
) {
|
|
10
|
-
server.tool(
|
|
11
|
-
"list_firewall_rules",
|
|
12
|
-
"List all firewall rules configured on a server",
|
|
13
|
-
{ serverId: z.string().describe("The server ID") },
|
|
14
|
-
async ({ serverId }) => {
|
|
15
|
-
const data = await client.get(`/api/servers/${serverId}/firewall`);
|
|
16
|
-
return {
|
|
17
|
-
content: [{ type: "text", text: safeJson(data) }],
|
|
18
|
-
};
|
|
19
|
-
},
|
|
20
|
-
);
|
|
21
|
-
|
|
22
|
-
server.tool(
|
|
23
|
-
"create_firewall_rule",
|
|
24
|
-
"Create a new firewall rule on a server (does not apply it automatically — use apply_firewall_rules after)",
|
|
25
|
-
{
|
|
26
|
-
serverId: z.string().describe("The server ID"),
|
|
27
|
-
port: z.string().describe("Port or port range (e.g. '80', '8000:8100')"),
|
|
28
|
-
protocol: z
|
|
29
|
-
.enum(["tcp", "udp"])
|
|
30
|
-
.describe("Protocol: 'tcp' or 'udp'"),
|
|
31
|
-
source: z
|
|
32
|
-
.string()
|
|
33
|
-
.optional()
|
|
34
|
-
.describe("Source CIDR (e.g. '0.0.0.0/0' for any). Defaults to any."),
|
|
35
|
-
action: z
|
|
36
|
-
.enum(["allow", "deny"])
|
|
37
|
-
.describe("Action: 'allow' or 'deny'"),
|
|
38
|
-
},
|
|
39
|
-
async ({ serverId, port, protocol, source, action }) => {
|
|
40
|
-
const body: Record<string, string> = { port, protocol, action };
|
|
41
|
-
if (source) body.source = source;
|
|
42
|
-
const data = await client.post(
|
|
43
|
-
`/api/servers/${serverId}/firewall`,
|
|
44
|
-
body,
|
|
45
|
-
);
|
|
46
|
-
return {
|
|
47
|
-
content: [{ type: "text", text: safeJson(data) }],
|
|
48
|
-
};
|
|
49
|
-
},
|
|
50
|
-
);
|
|
51
|
-
|
|
52
|
-
server.tool(
|
|
53
|
-
"delete_firewall_rule",
|
|
54
|
-
"Delete a firewall rule from a server",
|
|
55
|
-
{
|
|
56
|
-
serverId: z.string().describe("The server ID"),
|
|
57
|
-
ruleId: z.string().describe("The firewall rule ID to delete"),
|
|
58
|
-
},
|
|
59
|
-
async ({ serverId, ruleId }) => {
|
|
60
|
-
const data = await client.delete(
|
|
61
|
-
`/api/servers/${serverId}/firewall/${ruleId}`,
|
|
62
|
-
);
|
|
63
|
-
return {
|
|
64
|
-
content: [{ type: "text", text: safeJson(data) }],
|
|
65
|
-
};
|
|
66
|
-
},
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
server.tool(
|
|
70
|
-
"apply_firewall_rules",
|
|
71
|
-
"Apply all configured firewall rules to the server (syncs UFW with the database rules)",
|
|
72
|
-
{ serverId: z.string().describe("The server ID") },
|
|
73
|
-
async ({ serverId }) => {
|
|
74
|
-
const data = await client.post(
|
|
75
|
-
`/api/servers/${serverId}/firewall/apply-to-server`,
|
|
76
|
-
);
|
|
77
|
-
return {
|
|
78
|
-
content: [{ type: "text", text: safeJson(data) }],
|
|
79
|
-
};
|
|
80
|
-
},
|
|
81
|
-
);
|
|
82
|
-
}
|
package/src/tools/nginx.ts
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import type { SprooboClient } from "../client.js";
|
|
4
|
-
import { safeJson } from "../redact.js";
|
|
5
|
-
|
|
6
|
-
export function registerNginxTools(server: McpServer, client: SprooboClient) {
|
|
7
|
-
server.tool(
|
|
8
|
-
"list_nginx_templates",
|
|
9
|
-
"List nginx configuration templates available on a server",
|
|
10
|
-
{ serverId: z.string().describe("The server ID") },
|
|
11
|
-
async ({ serverId }) => {
|
|
12
|
-
const data = await client.get(
|
|
13
|
-
`/api/servers/${serverId}/nginx-templates`,
|
|
14
|
-
);
|
|
15
|
-
return {
|
|
16
|
-
content: [{ type: "text", text: safeJson(data) }],
|
|
17
|
-
};
|
|
18
|
-
},
|
|
19
|
-
);
|
|
20
|
-
|
|
21
|
-
server.tool(
|
|
22
|
-
"get_nginx_config_files",
|
|
23
|
-
"Get the nginx configuration files on a server (sites-available, sites-enabled, etc.)",
|
|
24
|
-
{ serverId: z.string().describe("The server ID") },
|
|
25
|
-
async ({ serverId }) => {
|
|
26
|
-
const data = await client.get(
|
|
27
|
-
`/api/servers/${serverId}/nginx-config/files`,
|
|
28
|
-
);
|
|
29
|
-
return {
|
|
30
|
-
content: [{ type: "text", text: safeJson(data) }],
|
|
31
|
-
};
|
|
32
|
-
},
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
server.tool(
|
|
36
|
-
"test_nginx_config",
|
|
37
|
-
"Test the nginx configuration on a server for syntax errors (runs nginx -t)",
|
|
38
|
-
{ serverId: z.string().describe("The server ID") },
|
|
39
|
-
async ({ serverId }) => {
|
|
40
|
-
const data = await client.post(
|
|
41
|
-
`/api/servers/${serverId}/nginx-config/test`,
|
|
42
|
-
);
|
|
43
|
-
return {
|
|
44
|
-
content: [{ type: "text", text: safeJson(data) }],
|
|
45
|
-
};
|
|
46
|
-
},
|
|
47
|
-
);
|
|
48
|
-
|
|
49
|
-
server.tool(
|
|
50
|
-
"apply_nginx_template",
|
|
51
|
-
"Apply an nginx configuration template to a site on the server",
|
|
52
|
-
{
|
|
53
|
-
serverId: z.string().describe("The server ID"),
|
|
54
|
-
templateId: z.string().describe("The nginx template ID to apply"),
|
|
55
|
-
},
|
|
56
|
-
async ({ serverId, templateId }) => {
|
|
57
|
-
const data = await client.post(
|
|
58
|
-
`/api/servers/${serverId}/nginx-templates/${templateId}/apply`,
|
|
59
|
-
);
|
|
60
|
-
return {
|
|
61
|
-
content: [{ type: "text", text: safeJson(data) }],
|
|
62
|
-
};
|
|
63
|
-
},
|
|
64
|
-
);
|
|
65
|
-
}
|
package/src/tools/servers.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import type { SprooboClient } from "../client.js";
|
|
4
|
-
import { safeJson } from "../redact.js";
|
|
5
|
-
|
|
6
|
-
export function registerServerTools(server: McpServer, client: SprooboClient) {
|
|
7
|
-
server.tool(
|
|
8
|
-
"list_servers",
|
|
9
|
-
"List all servers you have access to, with their status, IP, and provider info",
|
|
10
|
-
{},
|
|
11
|
-
async () => {
|
|
12
|
-
const data = await client.get("/api/servers");
|
|
13
|
-
return { content: [{ type: "text", text: safeJson(data) }] };
|
|
14
|
-
},
|
|
15
|
-
);
|
|
16
|
-
|
|
17
|
-
server.tool(
|
|
18
|
-
"get_server",
|
|
19
|
-
"Get detailed information about a specific server including status, IP, OS, and installed services",
|
|
20
|
-
{ serverId: z.string().describe("The server ID") },
|
|
21
|
-
async ({ serverId }) => {
|
|
22
|
-
const data = await client.get(`/api/servers/${serverId}`);
|
|
23
|
-
return { content: [{ type: "text", text: safeJson(data) }] };
|
|
24
|
-
},
|
|
25
|
-
);
|
|
26
|
-
|
|
27
|
-
server.tool(
|
|
28
|
-
"get_server_metrics",
|
|
29
|
-
"Get the latest CPU, memory, disk, and network metrics for a server",
|
|
30
|
-
{ serverId: z.string().describe("The server ID") },
|
|
31
|
-
async ({ serverId }) => {
|
|
32
|
-
const data = await client.get(`/api/servers/${serverId}/metrics/latest`);
|
|
33
|
-
return { content: [{ type: "text", text: safeJson(data) }] };
|
|
34
|
-
},
|
|
35
|
-
);
|
|
36
|
-
}
|
package/src/tools/services.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import type { SprooboClient } from "../client.js";
|
|
4
|
-
import { safeJson } from "../redact.js";
|
|
5
|
-
|
|
6
|
-
export function registerServiceTools(
|
|
7
|
-
server: McpServer,
|
|
8
|
-
client: SprooboClient,
|
|
9
|
-
) {
|
|
10
|
-
server.tool(
|
|
11
|
-
"list_services",
|
|
12
|
-
"List all installed services on a server (e.g. Redis, PostgreSQL, WordPress, Nginx)",
|
|
13
|
-
{ serverId: z.string().describe("The server ID") },
|
|
14
|
-
async ({ serverId }) => {
|
|
15
|
-
const data = await client.get(`/api/servers/${serverId}/services`);
|
|
16
|
-
return {
|
|
17
|
-
content: [{ type: "text", text: safeJson(data) }],
|
|
18
|
-
};
|
|
19
|
-
},
|
|
20
|
-
);
|
|
21
|
-
}
|
package/src/tools/sites.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import { z } from "zod";
|
|
2
|
-
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
-
import type { SprooboClient } from "../client.js";
|
|
4
|
-
import { safeJson } from "../redact.js";
|
|
5
|
-
|
|
6
|
-
export function registerSiteTools(server: McpServer, client: SprooboClient) {
|
|
7
|
-
server.tool(
|
|
8
|
-
"list_sites",
|
|
9
|
-
"List all sites deployed on a specific server",
|
|
10
|
-
{ serverId: z.string().describe("The server ID") },
|
|
11
|
-
async ({ serverId }) => {
|
|
12
|
-
const data = await client.get(`/api/servers/${serverId}/sites`);
|
|
13
|
-
return { content: [{ type: "text", text: safeJson(data) }] };
|
|
14
|
-
},
|
|
15
|
-
);
|
|
16
|
-
|
|
17
|
-
server.tool(
|
|
18
|
-
"get_site",
|
|
19
|
-
"Get detailed information about a site including domains, deployment type, and status",
|
|
20
|
-
{ siteId: z.string().describe("The site ID") },
|
|
21
|
-
async ({ siteId }) => {
|
|
22
|
-
const data = await client.get(`/api/sites/${siteId}`);
|
|
23
|
-
return { content: [{ type: "text", text: safeJson(data) }] };
|
|
24
|
-
},
|
|
25
|
-
);
|
|
26
|
-
}
|
package/tsconfig.json
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"compilerOptions": {
|
|
3
|
-
"target": "ES2022",
|
|
4
|
-
"module": "NodeNext",
|
|
5
|
-
"lib": ["ES2022"],
|
|
6
|
-
"moduleResolution": "NodeNext",
|
|
7
|
-
"outDir": "./dist",
|
|
8
|
-
"rootDir": "./src",
|
|
9
|
-
"declaration": true,
|
|
10
|
-
"declarationMap": true,
|
|
11
|
-
"strict": true,
|
|
12
|
-
"skipLibCheck": true
|
|
13
|
-
},
|
|
14
|
-
"include": ["src/**/*"]
|
|
15
|
-
}
|