Skip to content

Commit b072154

Browse files
Merge pull request #5 from InstaNode-dev/feat/private-deploy-mcp-fresh
mcp: create_deploy gains private + allowed_ips for Pro-tier private deploys (v0.10.0)
2 parents be5da4a + b8c230a commit b072154

6 files changed

Lines changed: 158 additions & 9 deletions

File tree

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,37 @@ params, which the agent host may log.
175175
`get_deployment({ id: deploy_id })` every few seconds until status flips to
176176
`"running"` (typical: ~30s). At that point the `url` field is the live URL.
177177

178+
### Private deploys
179+
180+
Set `private: true` and pass `allowed_ips` to restrict access to specific IPs
181+
or CIDR blocks at the Ingress. Useful when the agent is asked to deploy a
182+
CRM, internal dashboard, or staging app that should only be reachable by the
183+
user.
184+
185+
**Pro tier or higher required.** Hobby callers will see HTTP 402 with an
186+
`agent_action` field — the MCP server surfaces the upgrade URL so the agent
187+
can prompt the user to upgrade.
188+
189+
**Example prompt** (paste into Claude Code):
190+
191+
> "Deploy my CRM as a private app, only accessible from 1.2.3.4 and my office
192+
> subnet 10.0.0.0/8"
193+
194+
The agent will then call:
195+
196+
```json
197+
{
198+
"tarball_base64": "...",
199+
"name": "my-crm",
200+
"private": true,
201+
"allowed_ips": ["1.2.3.4", "10.0.0.0/8"]
202+
}
203+
```
204+
205+
`get_deployment` and `list_deployments` surface `private` + `allowed_ips`
206+
back to the agent so it can confirm the policy to the user. To turn a
207+
private deploy public, redeploy without the flags.
208+
178209
### How anonymous → claimed works
179210

180211
Every `create_*` tool returns three fields the agent should treat as

package-lock.json

Lines changed: 8 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "instanode-mcp",
3-
"version": "0.9.0",
3+
"version": "0.10.0",
44
"description": "MCP server for instanode.dev \u2014 lets AI coding agents provision ephemeral Postgres, Redis, MongoDB, NATS queues, S3-compatible object storage, webhook receivers, and deploy containerized apps over HTTPS, with optional bearer-token auth for paid users.",
55
"keywords": [
66
"mcp",

src/client.ts

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,21 @@ export interface Deployment {
130130
created_at?: string;
131131
updated_at?: string;
132132
team_id?: string;
133+
/**
134+
* Private deploy flag — when true, the deploy's Ingress only accepts traffic
135+
* from `allowed_ips`. Pro tier or higher required (anonymous + hobby are 402).
136+
*/
137+
private?: boolean;
138+
/**
139+
* IP / CIDR allowlist enforced at the Ingress when `private` is true.
140+
* Strings like "1.2.3.4" or "10.0.0.0/8". Empty / undefined when
141+
* `private` is false.
142+
*
143+
* Track A's backend contract uses `allowed_ips`. If Track A ends up renaming
144+
* to `allowed_cidrs`, reconcile post-merge with a one-line schema rename
145+
* (see PR body).
146+
*/
147+
allowed_ips?: string[];
133148
}
134149

135150
/**
@@ -200,6 +215,19 @@ export interface CreateDeployParams {
200215
* tool params, which the agent host may log.
201216
*/
202217
resource_bindings?: Record<string, string>;
218+
/**
219+
* Private deploy flag. When true, the Ingress only accepts traffic from
220+
* `allowed_ips`. Requires Pro tier or higher (api returns 402 on hobby
221+
* with an `agent_action` upgrade prompt).
222+
*/
223+
private?: boolean;
224+
/**
225+
* IP / CIDR allowlist (required when `private=true`). Strings like
226+
* "1.2.3.4" or "10.0.0.0/8". The MCP client forwards this as-is to
227+
* Track A's backend contract; if Track A ships with a slightly different
228+
* shape (e.g. `allowed_cidrs`), reconcile post-merge.
229+
*/
230+
allowed_ips?: string[];
203231
}
204232

205233
export interface ClaimResult {
@@ -286,7 +314,7 @@ export class InstantClient {
286314
private headers(): Record<string, string> {
287315
const h: Record<string, string> = {
288316
"Content-Type": "application/json",
289-
"User-Agent": "instanode-mcp/0.9.0",
317+
"User-Agent": "instanode-mcp/0.10.0",
290318
};
291319
const tok = this.bearerToken();
292320
if (tok) {
@@ -301,7 +329,7 @@ export class InstantClient {
301329
*/
302330
private authHeaders(): Record<string, string> {
303331
const h: Record<string, string> = {
304-
"User-Agent": "instanode-mcp/0.9.0",
332+
"User-Agent": "instanode-mcp/0.10.0",
305333
};
306334
const tok = this.bearerToken();
307335
if (tok) {
@@ -510,6 +538,17 @@ export class InstantClient {
510538
if (typeof params.port === "number") form.append("port", String(params.port));
511539
if (params.env) form.append("env", params.env);
512540

541+
// Private deploy + IP allowlist (Track A backend contract). Booleans and
542+
// arrays go through multipart as strings — the api parses them back. We
543+
// intentionally forward `private` even when false so the server can
544+
// distinguish "explicitly public" from "field omitted".
545+
if (typeof params.private === "boolean") {
546+
form.append("private", params.private ? "true" : "false");
547+
}
548+
if (params.allowed_ips && params.allowed_ips.length > 0) {
549+
form.append("allowed_ips", JSON.stringify(params.allowed_ips));
550+
}
551+
513552
// Merge resource_bindings into env_vars. The api treats every value
514553
// either as plaintext, a vault://env/KEY ref, or — for deploy bindings —
515554
// a raw resource token that the server resolves to a connection URL

src/index.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,9 @@ rotating an expiring token.`,
614614

615615
server.tool(
616616
"create_deploy",
617-
`Deploy a containerized application on instanode.dev (POST /deploy/new).
617+
`Create a new deploy. Optionally set \`private: true\` + \`allowed_ips: ['1.2.3.4', '10.0.0.0/8']\` to restrict access to specific IPs. Requires Pro tier or higher. Useful when an agent is asked to deploy a CRM, internal dashboard, or staging app that should only be reachable by the user.
618+
619+
Deploys a containerized application on instanode.dev (POST /deploy/new).
618620
619621
The agent base64-encodes a gzip tarball of the user's project (must contain a
620622
Dockerfile at the root), passes it as 'tarball_base64', and the API builds +
@@ -642,6 +644,10 @@ vault is per-team, per-env; rotate without redeploying). 'env_vars' and
642644
'resource_bindings' are merged before being sent to the API; on collision,
643645
'resource_bindings' wins.
644646
647+
Private deploys: set 'private: true' and pass 'allowed_ips' (IPs or CIDR
648+
blocks) to restrict access at the Ingress. Pro tier or higher is required —
649+
hobby tier returns 402 with an agent_action prompting the user to upgrade.
650+
645651
Requires INSTANODE_TOKEN (anonymous tier cannot deploy).`,
646652
{
647653
tarball_base64: z
@@ -681,6 +687,18 @@ Requires INSTANODE_TOKEN (anonymous tier cannot deploy).`,
681687
.describe(
682688
"Map of env var name → resource token UUID (e.g. { DATABASE_URL: '<postgres token>' }). The API resolves each token to its connection URL server-side. DO NOT pass raw connection URLs here — use create_postgres/create_cache/etc. to get tokens, then bind them."
683689
),
690+
private: z
691+
.boolean()
692+
.optional()
693+
.describe(
694+
"When true, the deploy is only reachable from IPs in 'allowed_ips'. Requires Pro tier or higher — anonymous and hobby callers get HTTP 402 with an agent_action prompting the user to upgrade. Use for CRMs, internal dashboards, staging apps."
695+
),
696+
allowed_ips: z
697+
.array(z.string().min(1))
698+
.optional()
699+
.describe(
700+
"IP / CIDR allowlist enforced at the Ingress when 'private' is true. Examples: ['1.2.3.4', '10.0.0.0/8', '203.0.113.42/32']. Required when private=true; ignored otherwise. If Track A's backend lands with a renamed field (e.g. 'allowed_cidrs'), this MCP tool will surface the 400 verbatim — see PR body."
701+
),
684702
},
685703
async (params) => {
686704
try {
@@ -694,6 +712,13 @@ Requires INSTANODE_TOKEN (anonymous tier cannot deploy).`,
694712
: `URL: (pending — poll get_deployment until status="running")`,
695713
`Build logs: ${result.build_logs_url}`,
696714
];
715+
if (result.item.private) {
716+
lines.push(`Private: true`);
717+
const ips = result.item.allowed_ips ?? params.allowed_ips ?? [];
718+
if (ips.length > 0) {
719+
lines.push(`Allowed IPs: ${ips.join(", ")}`);
720+
}
721+
}
697722
appendUpgradeBlock(lines, result);
698723
lines.push(
699724
``,
@@ -736,6 +761,10 @@ Requires INSTANODE_TOKEN.`,
736761
` port: ${d.port}`,
737762
];
738763
if (d.environment) parts.push(` env: ${d.environment}`);
764+
if (d.private) {
765+
const ips = d.allowed_ips && d.allowed_ips.length > 0 ? ` (${d.allowed_ips.join(", ")})` : "";
766+
parts.push(` private: true${ips}`);
767+
}
739768
if (d.created_at) parts.push(` created: ${d.created_at}`);
740769
if (d.error) parts.push(` error: ${d.error}`);
741770
return parts.join("\n");
@@ -778,6 +807,12 @@ Requires INSTANODE_TOKEN.`,
778807
`Port: ${d.port}`,
779808
];
780809
if (d.environment) lines.push(`Environment: ${d.environment}`);
810+
if (d.private) {
811+
lines.push(`Private: true`);
812+
if (d.allowed_ips && d.allowed_ips.length > 0) {
813+
lines.push(`Allowed IPs: ${d.allowed_ips.join(", ")}`);
814+
}
815+
}
781816
if (d.error) lines.push(`Error: ${d.error}`);
782817
if (d.created_at) lines.push(`Created: ${d.created_at}`);
783818
if (d.updated_at) lines.push(`Updated: ${d.updated_at}`);

test.sh

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,47 @@ assert 'INSTANODE_TOKEN' in text, f'expected auth-required text, got: {text}'
114114
" || fail "create_deploy without token failed to surface auth message"
115115
pass "create_deploy without token returns auth-required message"
116116

117+
# Test 5c: create_deploy schema accepts the new private + allowed_ips fields.
118+
# tools/list returns the JSON schema; check both fields are advertised.
119+
RESP=$(printf "%s\n%s\n" "$INIT" "$TOOLS_LIST" | INSTANODE_API_URL="$BASE_URL" $MCP 2>/dev/null | tail -1)
120+
echo "$RESP" | python3 -c "
121+
import sys, json
122+
d = json.loads(sys.stdin.read())
123+
tools = {t['name']: t for t in d['result']['tools']}
124+
schema = tools['create_deploy']['inputSchema']
125+
props = schema.get('properties', {})
126+
assert 'private' in props, f'create_deploy missing private property: {list(props.keys())}'
127+
assert 'allowed_ips' in props, f'create_deploy missing allowed_ips property: {list(props.keys())}'
128+
priv = props['private']
129+
priv_type = priv.get('type') or [t for t in priv.get('anyOf', []) if 'type' in t]
130+
assert priv_type in ('boolean', [{'type': 'boolean'}]) or priv.get('type') == 'boolean', f'private should be boolean, got: {priv}'
131+
ips = props['allowed_ips']
132+
assert ips.get('type') == 'array', f'allowed_ips should be an array, got: {ips}'
133+
desc = tools['create_deploy'].get('description', '')
134+
assert 'Pro tier' in desc or 'pro tier' in desc.lower(), f'create_deploy description missing tier-gate note'
135+
assert 'private' in desc.lower(), f'create_deploy description missing private note'
136+
" || fail "create_deploy schema missing private + allowed_ips properties"
137+
pass "create_deploy schema advertises private + allowed_ips with tier-gate note"
138+
139+
# Test 5d: create_deploy with private=true + allowed_ips but no INSTANODE_TOKEN
140+
# should still pass schema validation and surface the auth-required message
141+
# (not a Zod validation error).
142+
DEPLOY_PRIVATE='{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"create_deploy","arguments":{"tarball_base64":"H4sIAAAAAAAA","name":"my-crm","private":true,"allowed_ips":["1.2.3.4","10.0.0.0/8"]}}}'
143+
RESP=$(printf "%s\n%s\n" "$INIT" "$DEPLOY_PRIVATE" | INSTANODE_API_URL="$BASE_URL" $MCP 2>/dev/null | tail -1)
144+
echo "$RESP" | python3 -c "
145+
import sys, json
146+
d = json.loads(sys.stdin.read())
147+
# Either auth-required text (no token set, schema accepted the input) or a
148+
# clean ApiError from the upstream — both prove the schema accepted private +
149+
# allowed_ips. A Zod validation error would come back as d['error'] or
150+
# isError=True with 'Invalid' in the text.
151+
assert 'error' not in d or not d['error'], f'unexpected JSON-RPC error: {d}'
152+
text = d['result']['content'][0]['text']
153+
# Must not be a Zod validation failure on the new fields.
154+
assert 'private' not in text or 'INSTANODE_TOKEN' in text or 'instanode.dev' in text.lower(), f'schema rejected private+allowed_ips: {text}'
155+
" || fail "create_deploy with private+allowed_ips was rejected at the schema layer"
156+
pass "create_deploy accepts private+allowed_ips (forwards through to api / auth gate)"
157+
117158
# Test 6: claim_resource accepts a full /start?t= URL and re-extracts the JWT.
118159
CLAIM_URL='{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"claim_resource","arguments":{"upgrade_jwt":"https://instanode.dev/start?t=ey.url.jwt"}}}'
119160
RESP=$(printf "%s\n%s\n" "$INIT" "$CLAIM_URL" | INSTANODE_API_URL="$BASE_URL" $MCP 2>/dev/null | tail -1)

0 commit comments

Comments
 (0)