tport-cloud-mcp 0.0.0-stage → 0.1.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 Biz59 LLC
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 CHANGED
@@ -1,3 +1,66 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # tport-cloud-mcp
2
+
3
+ MCP server for [tport.cloud](https://tport.cloud): order and manage servers, managed databases and free DNS from
4
+ Claude, Cursor or any MCP client. It runs on your machine over stdio and calls the tport.cloud API with your API key.
5
+
6
+ ## Set up
7
+
8
+ 1. In the console (https://app.tport.cloud) make an API key and tick only the scopes you want the assistant to have.
9
+ A key can only do what its scopes allow; the API refuses the rest (403 naming the missing scope).
10
+ 2. Add the server to your MCP client.
11
+
12
+ Claude Desktop (`claude_desktop_config.json`) or Cursor (`~/.cursor/mcp.json`):
13
+
14
+ ```json
15
+ {
16
+ "mcpServers": {
17
+ "tport-cloud": {
18
+ "command": "npx",
19
+ "args": ["-y", "tport-cloud-mcp"],
20
+ "env": { "TPORT_API_KEY": "tpct_..." }
21
+ }
22
+ }
23
+ }
24
+ ```
25
+
26
+ Claude Code: `claude mcp add tport-cloud -e TPORT_API_KEY=tpct_... -- npx -y tport-cloud-mcp`
27
+
28
+ | Env | Meaning |
29
+ | --- | --- |
30
+ | `TPORT_API_KEY` | Your API key (`tpct_...`). Needed for everything except `list_plans`. |
31
+ | `TPORT_API_URL` | Optional, default `https://api.tport.cloud`. |
32
+
33
+ ## Tools
34
+
35
+ | Tool | Does | Scope |
36
+ | --- | --- | --- |
37
+ | `list_plans` | Plans, prices, regions, images, installs, availability | none |
38
+ | `list_servers`, `get_server`, `get_order` | Your servers and shared accounts, one in detail, an order's progress | `servers:read` |
39
+ | `create_server` | Order a root server or shared account (charged from the balance) | `servers:create` |
40
+ | `resize_server` | Move to another plan of the same kind | `servers:resize` |
41
+ | `power` | Start, stop, restart | `servers:power` |
42
+ | `delete_server` | Delete (typed name to confirm; unused days credited) | `servers:delete` |
43
+ | `data_catalogue` | Database engines, add-ons, prices, sizes per region, availability | none |
44
+ | `data_list`, `data_get` | Your managed databases, one in detail (the login, once) | `databases:read` |
45
+ | `data_create`, `data_set_allow`, `data_resize`, `data_delete` | Order, allow list, resize, delete | `databases:write` |
46
+ | `databases_list`, `databases_get` | Primary + standby PostgreSQL clusters | `databases:read` |
47
+ | `databases_create`, `databases_set_allow`, `databases_delete` | Order, allow list, delete | `databases:write` |
48
+ | `domains_list` | A server's own domains and the records to set | `dns:read` |
49
+ | `domains_add`, `domains_check`, `domains_remove` | Your own domain on a small server | `dns:write` |
50
+ | `dns_list_zones`, `dns_get_zone` | Zones and records on the free nameservers | `dns:read` |
51
+ | `dns_add_zone`, `dns_delete_zone`, `dns_add_record`, `dns_delete_record` | Change zones and records | `dns:write` |
52
+ | `get_billing` | Balance and ledger | `billing:read` |
53
+
54
+ A new database's login is shown once, by the first `data_get` after setup, so your assistant sees it and it can't be
55
+ read again. Deleting asks for the item's name in `confirm`, as the console does. Errors come back as the API's own message.
56
+
57
+ ## Develop
58
+
59
+ ```sh
60
+ npm install
61
+ npm test # stub API on 127.0.0.1, end to end through an MCP client
62
+ TPORT_LIVE=1 npm test # also read-only checks against the real API (add TPORT_API_KEY to list your servers)
63
+ npm run build # build/index.js, the `tport-cloud-mcp` bin
64
+ ```
65
+
66
+ MIT licence. (c) Biz59 LLC.
package/build/api.js ADDED
@@ -0,0 +1,63 @@
1
+ // Thin client for the tport.cloud API. Every call sends the API key as a Bearer token; the API checks the key's
2
+ // scopes and answers 403 naming the missing one, so this client never second-guesses permissions.
3
+ export const DEFAULT_API_URL = "https://api.tport.cloud";
4
+ export class ApiError extends Error {
5
+ status;
6
+ constructor(status, message) {
7
+ super(message);
8
+ this.status = status;
9
+ }
10
+ }
11
+ export class TportApi {
12
+ key;
13
+ base;
14
+ constructor(key, base = DEFAULT_API_URL) {
15
+ this.key = key;
16
+ this.base = base;
17
+ this.base = base.replace(/\/+$/, "");
18
+ }
19
+ async request(method, path, opts = {}) {
20
+ const url = new URL(this.base + path);
21
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
22
+ if (v !== undefined && v !== "")
23
+ url.searchParams.set(k, String(v));
24
+ }
25
+ const headers = { Accept: "application/json", "User-Agent": "tport-cloud-mcp" };
26
+ if (this.key)
27
+ headers.Authorization = `Bearer ${this.key}`;
28
+ if (opts.body !== undefined)
29
+ headers["Content-Type"] = "application/json";
30
+ const res = await fetch(url, {
31
+ method,
32
+ headers,
33
+ body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
34
+ });
35
+ const text = await res.text();
36
+ let data = text;
37
+ try {
38
+ data = text ? JSON.parse(text) : null;
39
+ }
40
+ catch {
41
+ // not JSON (a proxy error page): keep the text
42
+ }
43
+ if (!res.ok)
44
+ throw new ApiError(res.status, errorText(res.status, data));
45
+ return data;
46
+ }
47
+ get = (path, query) => this.request("GET", path, { query });
48
+ post = (path, body = {}) => this.request("POST", path, { body });
49
+ put = (path, body) => this.request("PUT", path, { body });
50
+ del = (path, query) => this.request("DELETE", path, { query });
51
+ }
52
+ // FastAPI sends {"detail": "..."} (or a list of validation errors); show the API's own words.
53
+ function errorText(status, data) {
54
+ const detail = data?.detail;
55
+ if (typeof detail === "string")
56
+ return `${status}: ${detail}`;
57
+ if (Array.isArray(detail)) {
58
+ return `${status}: ` + detail.map((d) => `${(d.loc ?? []).slice(1).join(".")} ${d.msg ?? ""}`.trim()).join("; ");
59
+ }
60
+ if (status === 401)
61
+ return "401: the API key was refused (check TPORT_API_KEY; make one at https://app.tport.cloud)";
62
+ return `${status}: ${typeof data === "string" ? data.slice(0, 300) : JSON.stringify(data)}`;
63
+ }
package/build/index.js ADDED
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // tport.cloud MCP server over stdio: `npx tport-cloud-mcp` with TPORT_API_KEY set (TPORT_API_URL to point elsewhere).
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { DEFAULT_API_URL, TportApi } from "./api.js";
5
+ import { makeServer } from "./server.js";
6
+ if (!process.env.TPORT_API_KEY) {
7
+ console.error("tport-cloud-mcp: set TPORT_API_KEY (make a key with the scopes you want at https://app.tport.cloud)");
8
+ }
9
+ const api = new TportApi(process.env.TPORT_API_KEY ?? "", process.env.TPORT_API_URL || DEFAULT_API_URL);
10
+ await makeServer(api).connect(new StdioServerTransport());
@@ -0,0 +1,25 @@
1
+ // The MCP server: lists TOOLS and runs them against the API; API errors come back as tool errors in the API's words.
2
+ import { readFileSync } from "fs";
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import { TOOLS } from "./tools.js";
6
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8"));
7
+ export function makeServer(api) {
8
+ const server = new Server({ name: "tport-cloud-mcp", version: pkg.version }, { capabilities: { tools: {} } });
9
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
10
+ tools: TOOLS.map(({ name, description, inputSchema, annotations }) => ({ name, description, inputSchema, annotations })),
11
+ }));
12
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
13
+ const tool = TOOLS.find((t) => t.name === req.params.name);
14
+ if (!tool)
15
+ return { isError: true, content: [{ type: "text", text: `Unknown tool ${req.params.name}` }] };
16
+ try {
17
+ const out = await tool.run(api, req.params.arguments ?? {});
18
+ return { content: [{ type: "text", text: JSON.stringify(out, null, 2) }] };
19
+ }
20
+ catch (e) {
21
+ return { isError: true, content: [{ type: "text", text: e instanceof Error ? e.message : String(e) }] };
22
+ }
23
+ });
24
+ return server;
25
+ }
package/build/tools.js ADDED
@@ -0,0 +1,306 @@
1
+ const str = (description) => ({ type: "string", description });
2
+ const int = (description) => ({ type: "integer", description });
3
+ const strs = (description) => ({ type: "array", items: { type: "string" }, description });
4
+ const obj = (properties, required = []) => ({ type: "object", properties, ...(required.length ? { required } : {}) });
5
+ const id = (v) => encodeURIComponent(String(v ?? ""));
6
+ const READ = { readOnlyHint: true };
7
+ const DESTROY = { destructiveHint: true };
8
+ const SERVER_ID = str("Server id from list_servers: srv-<n> (root server) or sh-<n> (shared account)");
9
+ const CLUSTER_ID = str("Cluster id from databases_list: pg-<n>");
10
+ const DATA_ID = str("Database id from data_list: db-<n>");
11
+ const DATA_PRICE = int("Monthly price in cents from data_catalogue, without add-ons, e.g. 800");
12
+ const REGION = str("Region id from list_plans, e.g. eu (Germany), us (US Central), uk, us-east, us-west, sg");
13
+ // Only what was given goes in the body, so the API's own defaults apply to the rest.
14
+ function pick(args, keys) {
15
+ return Object.fromEntries(keys.filter((k) => args[k] !== undefined).map((k) => [k, args[k]]));
16
+ }
17
+ export const TOOLS = [
18
+ {
19
+ name: "list_plans",
20
+ description: "What can be ordered: plans with monthly price (cents), vCPU, RAM, disk; regions; images; installs " +
21
+ "(python, node, postgresql...); which plan is available in which region. Managed databases by engine are in " +
22
+ "data_catalogue. No key needed.",
23
+ inputSchema: obj({ kind: str("Only plans of this kind: shared, root or pg") }),
24
+ annotations: READ,
25
+ run: async (api, a) => {
26
+ const cat = (await api.get("/api/catalogue"));
27
+ return a.kind && cat.plans ? { ...cat, plans: cat.plans.filter((p) => p.kind === a.kind) } : cat;
28
+ },
29
+ },
30
+ {
31
+ name: "list_servers",
32
+ description: "Your servers and shared accounts with status, plan, region and IP. Scope servers:read.",
33
+ inputSchema: obj({}),
34
+ annotations: READ,
35
+ run: (api) => api.get("/api/servers"),
36
+ },
37
+ {
38
+ name: "get_server",
39
+ description: "One server or shared account in detail: status, IP, plan, price, paid until, recent events. " +
40
+ "Scope servers:read.",
41
+ inputSchema: obj({ id: SERVER_ID }, ["id"]),
42
+ annotations: READ,
43
+ run: (api, a) => api.get(`/api/servers/${id(a.id)}`),
44
+ },
45
+ {
46
+ name: "create_server",
47
+ description: "Order a server (kind server = own root server) or a shared account (kind shared). The first 30 " +
48
+ "days are charged from the account balance; setup takes a few minutes (watch it with get_order or " +
49
+ "get_server). Ubuntu 24.04 only. A root server without ssh_keys answers a one-time root password once. " +
50
+ "Scope servers:create.",
51
+ inputSchema: obj({
52
+ kind: { type: "string", enum: ["server", "shared"], description: "server (default) or shared" },
53
+ plan: str("Plan id from list_plans"),
54
+ region: REGION,
55
+ name: str("Server name: lower-case letters, digits and dashes"),
56
+ ssh_keys: strs("Public SSH keys to install"),
57
+ installs: strs("Software to set up, from list_plans installs, optionally with a version: python, node:22, postgresql:18"),
58
+ access_mode: { type: "string", enum: ["root", "noroot"], description: "root (default) or noroot: your key on " +
59
+ "your own user, no sudo, root login off. noroot needs user and confirm_no_root" },
60
+ user: str("noroot only: the login user name"),
61
+ confirm_no_root: str("noroot only: type the server name again to confirm you will have no root access"),
62
+ }, ["plan", "region", "name"]),
63
+ run: (api, a) => api.post("/api/orders", {
64
+ kind: a.kind ?? "server",
65
+ ...pick(a, ["plan", "region", "name", "ssh_keys", "installs", "access_mode", "user", "confirm_no_root"]),
66
+ }),
67
+ },
68
+ {
69
+ name: "get_order",
70
+ description: "An order's progress (queued, creating, ready, failed) with its item id. Not for data_create, " +
71
+ "which has no order: use data_list. Scope servers:read.",
72
+ inputSchema: obj({ order_id: int("Order id returned by create_server or databases_create") }, ["order_id"]),
73
+ annotations: READ,
74
+ run: (api, a) => api.get(`/api/orders/${id(a.order_id)}`),
75
+ },
76
+ {
77
+ name: "resize_server",
78
+ description: "Move a server or shared account to another plan of the same kind. Bigger: the difference for the " +
79
+ "days already paid is charged from the balance now. Smaller: the unused difference goes back, and only when " +
80
+ "the data fits in 80% of the new disk. Then the new price applies. Scope servers:resize.",
81
+ inputSchema: obj({ id: SERVER_ID, plan: str("The new plan id from list_plans") }, ["id", "plan"]),
82
+ run: (api, a) => api.post(`/api/servers/${id(a.id)}/resize`, { plan: a.plan }),
83
+ },
84
+ {
85
+ name: "power",
86
+ description: "Start, stop or restart a server. Scope servers:power.",
87
+ inputSchema: obj({
88
+ id: SERVER_ID,
89
+ action: { type: "string", enum: ["start", "stop", "restart"] },
90
+ }, ["id", "action"]),
91
+ run: (api, a) => api.post(`/api/servers/${id(a.id)}/power`, { action: a.action }),
92
+ },
93
+ {
94
+ name: "delete_server",
95
+ description: "Delete a server or shared account and everything on it; unused days go back to the balance. " +
96
+ "Cannot be undone. Scope servers:delete.",
97
+ inputSchema: obj({ id: SERVER_ID, confirm: str("The server's name, typed to confirm") }, ["id", "confirm"]),
98
+ annotations: DESTROY,
99
+ run: (api, a) => api.request("DELETE", `/api/servers/${id(a.id)}`, { body: { confirm: a.confirm } }),
100
+ },
101
+ {
102
+ name: "databases_list",
103
+ description: "Your primary + standby PostgreSQL clusters with status, plan and region. Databases of every engine are in " +
104
+ "data_list. Scope databases:read.",
105
+ inputSchema: obj({}),
106
+ annotations: READ,
107
+ run: (api) => api.get("/api/clusters"),
108
+ },
109
+ {
110
+ name: "databases_get",
111
+ description: "One PostgreSQL cluster: status, connection details, pooler, standby, allow list. " +
112
+ "Scope databases:read.",
113
+ inputSchema: obj({ id: CLUSTER_ID }, ["id"]),
114
+ annotations: READ,
115
+ run: (api, a) => api.get(`/api/clusters/${id(a.id)}`),
116
+ },
117
+ {
118
+ name: "databases_create",
119
+ description: "Order a primary + standby PostgreSQL cluster (plan kind pg from list_plans). For one database of " +
120
+ "any engine use data_create. The first 30 days are charged from the balance. Scope databases:write.",
121
+ inputSchema: obj({
122
+ plan: str("pg plan id from list_plans"),
123
+ region: REGION,
124
+ name: str("Cluster name: lower-case letters, digits and dashes"),
125
+ version: str("PostgreSQL major version (default: the newest offered)"),
126
+ allow: strs("IP addresses or CIDRs allowed to connect, e.g. 203.0.113.7/32"),
127
+ }, ["plan", "region", "name"]),
128
+ run: (api, a) => api.post("/api/orders", { kind: "pg", ...pick(a, ["plan", "region", "name", "version", "allow"]) }),
129
+ },
130
+ {
131
+ name: "databases_set_allow",
132
+ description: "Replace a cluster's allow list (who may connect). An empty list closes it. Scope databases:write.",
133
+ inputSchema: obj({ id: CLUSTER_ID, allow: strs("IP addresses or CIDRs") }, ["id", "allow"]),
134
+ run: (api, a) => api.put(`/api/clusters/${id(a.id)}/allow`, { allow: a.allow }),
135
+ },
136
+ {
137
+ name: "databases_delete",
138
+ description: "Delete a PostgreSQL cluster and its data; unused days go back to the balance. " +
139
+ "Cannot be undone. Scope databases:write.",
140
+ inputSchema: obj({ id: CLUSTER_ID, confirm: str("The cluster's name, typed to confirm") }, ["id", "confirm"]),
141
+ annotations: DESTROY,
142
+ run: (api, a) => api.request("DELETE", `/api/clusters/${id(a.id)}`, { body: { confirm: a.confirm } }),
143
+ },
144
+ {
145
+ name: "data_catalogue",
146
+ description: "Managed databases that can be ordered: PostgreSQL, MariaDB, CouchDB, SQLite, FAISS, Chroma, Qdrant, " +
147
+ "Weaviate, Milvus. For each engine its add-ons and prices, what each price buys per region (vCPU, RAM, disk) " +
148
+ "and whether it can be ordered there now. No key needed.",
149
+ inputSchema: obj({ engine: str("Only this engine, by its id, e.g. pgsql, mariadb, qdrant") }),
150
+ annotations: READ,
151
+ run: async (api, a) => {
152
+ const cat = (await api.get("/api/data/catalogue"));
153
+ return a.engine && cat.engines ? { ...cat, engines: cat.engines.filter((e) => e.id === a.engine) } : cat;
154
+ },
155
+ },
156
+ {
157
+ name: "data_list",
158
+ description: "Your managed databases with engine, status, size, region, address and allow list, never the login. " +
159
+ "Use it to watch a new database until its status is running. Scope databases:read.",
160
+ inputSchema: obj({}),
161
+ annotations: READ,
162
+ run: (api) => api.get("/api/data/services"),
163
+ },
164
+ {
165
+ // Not marked read-only: the first read after setup hands over the login and removes it from the API.
166
+ name: "data_get",
167
+ description: "One managed database in detail with recent events. The first call after setup also answers its login " +
168
+ "in credentials, once: it can't be read again, so give it to the user right away. Scope databases:read.",
169
+ inputSchema: obj({ id: DATA_ID }, ["id"]),
170
+ run: (api, a) => api.get(`/api/data/services/${id(a.id)}`),
171
+ },
172
+ {
173
+ name: "data_create",
174
+ description: "Order a managed database. The first 30 days, add-ons included, are charged from the balance; setup " +
175
+ "takes a few minutes (watch it with data_list). Nothing can connect until an IP is on its allow list. " +
176
+ "Scope databases:write.",
177
+ inputSchema: obj({
178
+ engine: str("Engine id from data_catalogue, e.g. pgsql"),
179
+ price_cents: DATA_PRICE,
180
+ region: str("Region id from data_catalogue: eu (Germany), us or in (India)"),
181
+ name: str("Database name: 2-31 characters, lower-case letters, digits and single dashes, starting with a letter"),
182
+ addons: strs("Add-on ids for this engine from data_catalogue, e.g. pgvector, postgis, spatialite"),
183
+ allow: strs("IP addresses or CIDRs allowed to connect, e.g. 203.0.113.7/32"),
184
+ }, ["engine", "price_cents", "region", "name"]),
185
+ run: (api, a) => api.post("/api/data/services", pick(a, ["engine", "price_cents", "region", "name", "addons", "allow"])),
186
+ },
187
+ {
188
+ name: "data_set_allow",
189
+ description: "Replace a managed database's allow list (who may connect). An empty list closes it. " +
190
+ "Scope databases:write.",
191
+ inputSchema: obj({ id: DATA_ID, allow: strs("IP addresses or CIDRs") }, ["id", "allow"]),
192
+ run: (api, a) => api.put(`/api/data/services/${id(a.id)}/allow`, { allow: a.allow }),
193
+ },
194
+ {
195
+ name: "data_resize",
196
+ description: "Move a running database to another price for its engine and region. Bigger: the difference for the " +
197
+ "days already paid is charged from the balance now. Smaller: the unused difference goes back, and only when the " +
198
+ "data fits in 80% of the new disk. Some steps can't be done in place and answer 409; pick a smaller step. " +
199
+ "Scope databases:write.",
200
+ inputSchema: obj({ id: DATA_ID, price_cents: DATA_PRICE }, ["id", "price_cents"]),
201
+ run: (api, a) => api.post(`/api/data/services/${id(a.id)}/resize`, { price_cents: a.price_cents }),
202
+ },
203
+ {
204
+ name: "data_delete",
205
+ description: "Delete a managed database and its data; unused days go back to the balance. Cannot be undone. " +
206
+ "Scope databases:write.",
207
+ inputSchema: obj({ id: DATA_ID, confirm: str("The database's name, typed to confirm") }, ["id", "confirm"]),
208
+ annotations: DESTROY,
209
+ run: (api, a) => api.request("DELETE", `/api/data/services/${id(a.id)}`, { body: { confirm: a.confirm } }),
210
+ },
211
+ {
212
+ name: "domains_list",
213
+ description: "A server's own domains: each one's status and the two records to set. A server with its own address " +
214
+ "(own_address true) needs only an A record at your DNS. Scope dns:read.",
215
+ inputSchema: obj({ server_id: SERVER_ID }, ["server_id"]),
216
+ annotations: READ,
217
+ run: (api, a) => api.get(`/api/servers/${id(a.server_id)}/domains`),
218
+ },
219
+ {
220
+ name: "domains_add",
221
+ description: "Add your own domain to a small server. Set the A and TXT records in the answer; we check them, turn on " +
222
+ "HTTPS and route the domain to the server. Scope dns:write.",
223
+ inputSchema: obj({ server_id: SERVER_ID, domain: str("Domain, e.g. shop.example.com") }, ["server_id", "domain"]),
224
+ run: (api, a) => api.post(`/api/servers/${id(a.server_id)}/domains`, { domain: a.domain }),
225
+ },
226
+ {
227
+ name: "domains_check",
228
+ description: "Check a domain's records again now, or restart one that stopped trying. Scope dns:write.",
229
+ inputSchema: obj({ server_id: SERVER_ID, domain_id: int("Domain id from domains_list") }, ["server_id", "domain_id"]),
230
+ run: (api, a) => api.post(`/api/servers/${id(a.server_id)}/domains/${id(a.domain_id)}/check`, {}),
231
+ },
232
+ {
233
+ name: "domains_remove",
234
+ description: "Take a domain off a server. Scope dns:write.",
235
+ inputSchema: obj({ server_id: SERVER_ID, domain_id: int("Domain id from domains_list") }, ["server_id", "domain_id"]),
236
+ annotations: DESTROY,
237
+ run: (api, a) => api.del(`/api/servers/${id(a.server_id)}/domains/${id(a.domain_id)}`),
238
+ },
239
+ {
240
+ name: "dns_list_zones",
241
+ description: "Your DNS zones on the free tport.cloud nameservers, the nameservers to set at your registrar and " +
242
+ "the record types offered. Scope dns:read.",
243
+ inputSchema: obj({}),
244
+ annotations: READ,
245
+ run: (api) => api.get("/api/dns"),
246
+ },
247
+ {
248
+ name: "dns_get_zone",
249
+ description: "All records of one zone. Scope dns:read.",
250
+ inputSchema: obj({ zone: str("Domain, e.g. example.com") }, ["zone"]),
251
+ annotations: READ,
252
+ run: (api, a) => api.get(`/api/dns/zones/${id(a.zone)}`),
253
+ },
254
+ {
255
+ name: "dns_add_zone",
256
+ description: "Host a domain on the free tport.cloud nameservers, optionally with an apex A/AAAA record. " +
257
+ "Scope dns:write.",
258
+ inputSchema: obj({ zone: str("Domain, e.g. example.com"), ip: str("Optional IPv4/IPv6 for the apex record") }, ["zone"]),
259
+ run: (api, a) => api.post("/api/dns/zones", { name: a.zone, ...pick(a, ["ip"]) }),
260
+ },
261
+ {
262
+ name: "dns_delete_zone",
263
+ description: "Delete a zone and all its records. Scope dns:write.",
264
+ inputSchema: obj({ zone: str("Domain"), confirm: str("The domain again, typed to confirm") }, ["zone", "confirm"]),
265
+ annotations: DESTROY,
266
+ run: (api, a) => api.del(`/api/dns/zones/${id(a.zone)}`, { confirm: a.confirm }),
267
+ },
268
+ {
269
+ name: "dns_add_record",
270
+ description: "Add a record (A, AAAA, CNAME, MX, TXT, SRV, CAA, NS...), or edit one by passing the old value in " +
271
+ "replace. Scope dns:write.",
272
+ inputSchema: obj({
273
+ zone: str("Domain"),
274
+ name: str("Record name: @ for the apex, or a label like www"),
275
+ type: str("Record type, e.g. A, CNAME, MX, TXT"),
276
+ value: str("Record value, e.g. an IP, a host name or the TXT text"),
277
+ ttl: int("TTL in seconds"),
278
+ priority: int("MX and SRV priority"),
279
+ weight: int("SRV weight"),
280
+ port: int("SRV port"),
281
+ flags: int("CAA flags (default 0)"),
282
+ tag: str("CAA tag: issue, issuewild or iodef"),
283
+ replace: str("Edit: the current value of the record this one replaces"),
284
+ }, ["zone", "type", "value"]),
285
+ run: (api, a) => api.post(`/api/dns/zones/${id(a.zone)}/records`, pick(a, ["name", "type", "value", "ttl", "priority", "weight", "port", "flags", "tag", "replace"])),
286
+ },
287
+ {
288
+ name: "dns_delete_record",
289
+ description: "Delete records by name and type, or just the one with this value. Scope dns:write.",
290
+ inputSchema: obj({
291
+ zone: str("Domain"),
292
+ name: str("Record name (@ for the apex)"),
293
+ type: str("Record type"),
294
+ content: str("Only the record with this value (otherwise every record of that name and type)"),
295
+ }, ["zone", "name", "type"]),
296
+ annotations: DESTROY,
297
+ run: (api, a) => api.del(`/api/dns/zones/${id(a.zone)}/records`, { name: a.name, type: a.type, content: a.content }),
298
+ },
299
+ {
300
+ name: "get_billing",
301
+ description: "Account balance, monthly spend and recent ledger lines. Scope billing:read.",
302
+ inputSchema: obj({}),
303
+ annotations: READ,
304
+ run: (api) => api.get("/api/billing"),
305
+ },
306
+ ];
package/package.json CHANGED
@@ -1,6 +1,35 @@
1
- {
2
- "name": "tport-cloud-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "tport-cloud-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for tport.cloud: servers, managed databases and free DNS from Claude, Cursor or any MCP client, with a scoped API key",
5
+ "type": "module",
6
+ "main": "build/index.js",
7
+ "bin": {
8
+ "tport-cloud-mcp": "build/index.js"
9
+ },
10
+ "files": ["build", "README.md", "LICENSE"],
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "prepare": "tsc",
14
+ "test": "vitest run",
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.32.2"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.0.0",
22
+ "typescript": "~5.9.0",
23
+ "vitest": "^3.2.0"
24
+ },
25
+ "engines": {
26
+ "node": ">=18.0.0"
27
+ },
28
+ "keywords": ["mcp", "model-context-protocol", "tport.cloud", "vps", "postgresql", "mariadb", "qdrant", "dns", "cloud"],
29
+ "homepage": "https://tport.cloud",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "https://github.com/icedsg/tport-cloud-mcp"
33
+ },
34
+ "license": "MIT"
35
+ }