Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/plenty-tables-talk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@prodisco/mcp-server": patch
---

Re-create reaped sandboxes transparently on getClient instead of throwing an error
1 change: 1 addition & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ on:
push:
branches:
- main
workflow_dispatch:

concurrency: ${{ github.workflow }}-${{ github.ref }}

Expand Down
9 changes: 9 additions & 0 deletions examples/prodisco.postgres-real.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
libraries:
- name: "pg"
description: |
PostgreSQL client for Node.js. Connect to the PostgreSQL server running in the cluster.
Quick start: `const { Client } = require("pg"); const client = new Client({ host: "postgresql.prodisco.svc.cluster.local", port: 5432, user: "prodisco", password: "prodisco", database: "prodisco" }); await client.connect();`
Queries: `const res = await client.query("SELECT * FROM my_table");` returns `{ rows, rowCount, fields }`.
Parameterized: `await client.query("INSERT INTO users(name, age) VALUES($1, $2)", ["alice", 30]);`
Always call `await client.end();` when done.
IMPORTANT: Before querying or modifying any table, ALWAYS discover the schema first. List tables with `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'` and inspect columns with `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'my_table'`. Never assume column names or types exist — verify them first.
207 changes: 207 additions & 0 deletions k8s/mcp-server-postgres.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
---
apiVersion: v1
kind: Namespace
metadata:
name: prodisco
---
# PostgreSQL database
apiVersion: v1
kind: Pod
metadata:
name: postgresql
namespace: prodisco
labels:
app: postgresql
spec:
containers:
- name: postgresql
image: postgres:16
ports:
- containerPort: 5432
protocol: TCP
env:
- name: POSTGRES_USER
value: "prodisco"
- name: POSTGRES_PASSWORD
value: "prodisco"
- name: POSTGRES_DB
value: "prodisco"
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: postgresql
namespace: prodisco
labels:
app: postgresql
spec:
type: ClusterIP
ports:
- port: 5432
targetPort: 5432
protocol: TCP
name: postgres
selector:
app: postgresql
---
# ConfigMap with ProDisco configuration
apiVersion: v1
kind: ConfigMap
metadata:
name: prodisco-config
namespace: prodisco
data:
.prodisco-config.yaml: |
libraries:
- name: "pg"
description: |
PostgreSQL client for Node.js. Connect to the PostgreSQL server running in the cluster.
Quick start: `const { Client } = require("pg"); const client = new Client({ host: "postgresql.prodisco.svc.cluster.local", port: 5432, user: "prodisco", password: "prodisco", database: "prodisco" }); await client.connect();`
Queries: `const res = await client.query("SELECT * FROM my_table");` returns `{ rows, rowCount, fields }`.
Parameterized: `await client.query("INSERT INTO users(name, age) VALUES($1, $2)", ["alice", 30]);`
Always call `await client.end();` when done.
IMPORTANT: Before querying or modifying any table, ALWAYS discover the schema first. List tables with `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'` and inspect columns with `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'my_table'`. Never assume column names or types exist — verify them first.
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: mcp-server
namespace: prodisco
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: sandbox-server
namespace: prodisco
---
# RBAC for MCP server to manage Sandbox CRDs (multi-sandbox mode)
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: mcp-server-sandbox-manager
rules:
- apiGroups: ["agents.x-k8s.io"]
resources:
- sandboxes
verbs: ["get", "list", "create", "delete", "watch"]
- apiGroups: ["agents.x-k8s.io"]
resources:
- sandboxes/status
verbs: ["get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: mcp-server-sandbox-manager
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: mcp-server-sandbox-manager
subjects:
- kind: ServiceAccount
name: mcp-server
namespace: prodisco
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-server
namespace: prodisco
labels:
app: mcp-server
spec:
replicas: 1
selector:
matchLabels:
app: mcp-server
template:
metadata:
labels:
app: mcp-server
spec:
serviceAccountName: mcp-server
containers:
- name: mcp-server
image: prodisco/mcp-server:test
imagePullPolicy: IfNotPresent
ports:
- containerPort: 3000
name: http
protocol: TCP
env:
- name: MCP_TRANSPORT
value: "http"
- name: MCP_HOST
value: "0.0.0.0"
- name: MCP_PORT
value: "3000"
- name: SCRIPTS_CACHE_DIR
value: "/tmp/prodisco-scripts"
- name: PRODISCO_ENABLE_APPS
value: "true"
# Multi-sandbox mode: each MCP session gets its own Sandbox CRD
- name: SANDBOX_MODE
value: "multi"
- name: SANDBOX_NAMESPACE
value: "prodisco"
- name: SANDBOX_TCP_PORT
value: "50051"
- name: SANDBOX_IMAGE
value: "prodisco/sandbox-server:test"
- name: PRODISCO_CONFIG_PATH
value: "/config/.prodisco-config.yaml"
resources:
requests:
memory: "256Mi"
cpu: "100m"
limits:
memory: "1Gi"
cpu: "1000m"
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 10
periodSeconds: 5
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 15
periodSeconds: 30
volumeMounts:
- name: scripts-cache
mountPath: /tmp/prodisco-scripts
- name: prodisco-config
mountPath: /config
readOnly: true
volumes:
- name: scripts-cache
emptyDir: {}
- name: prodisco-config
configMap:
name: prodisco-config
---
apiVersion: v1
kind: Service
metadata:
name: mcp-server
namespace: prodisco
labels:
app: mcp-server
spec:
type: ClusterIP
ports:
- port: 3000
targetPort: 3000
protocol: TCP
name: http
selector:
app: mcp-server
17 changes: 11 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"type": "module",
"main": "dist/server.js",
"workspaces": [
".",
"packages/search-libs",
"packages/loki-client",
"packages/prometheus-client",
Expand All @@ -28,7 +29,7 @@
"test": "vitest run",
"test:integration": "scripts/integration/run-kind-integration.sh",
"test:examples": "bash scripts/ci/examples-smoke.sh",
"prepare": "husky"
"prepare": "husky || true"
},
"keywords": [
"kubernetes",
Expand Down
1 change: 0 additions & 1 deletion scripts/ci/changeset-publish.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/bin/bash
set -e
npx changeset publish
npm publish || true
1 change: 0 additions & 1 deletion scripts/ci/changeset-version.sh
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
#!/bin/bash
set -e
npx changeset version
npm version patch --no-git-tag-version
14 changes: 8 additions & 6 deletions src/__tests__/sandbox-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,17 @@ describe('SandboxManager - Multi Mode Session Tracking', () => {
await manager.shutdown();
});

it('rejects getClient for unknown session', async () => {
const manager = new SandboxManager({ mode: 'multi' });
it('rejects getClient for unknown session (re-creation fails without K8s)', async () => {
const manager = new SandboxManager({
mode: 'multi',
readyTimeoutMs: 1000,
});

await expect(manager.getClient('nonexistent')).rejects.toThrow(
'no sandbox for session nonexistent',
);
// Without a real sandbox pod, re-creation attempt will fail
await expect(manager.getClient('nonexistent')).rejects.toThrow();

await manager.shutdown();
});
}, 10000);

it('onSessionClosed is idempotent', async () => {
const manager = new SandboxManager({ mode: 'multi' });
Expand Down
10 changes: 8 additions & 2 deletions src/sandbox-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,15 @@ export class SandboxManager {
await pending;
}

const session = this.sessions.get(sessionId);
let session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`SandboxManager: no sandbox for session ${sessionId}`);
// Sandbox was reaped due to idle timeout — re-create it transparently
logger.info(`Re-creating sandbox for session ${sessionId} (previously reaped)`);
await this.onSessionInitialized(sessionId);
session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`SandboxManager: failed to re-create sandbox for session ${sessionId}`);
}
}

session.lastActivityMs = Date.now();
Expand Down
Loading