Bring the three MCP servers together as one deployable stack

They were three folders on one laptop: a coding agent with a git history of its
own, and two servers - a development server and a browser - with none at all.
What makes them a stack is what sits between them, and that lived nowhere: the
compose file, the gateway, the egress proxy and the networks that keep the worker
off the internet. So the whole thing is one repository, and the coding agent's
separate history is folded into it rather than kept alongside.

What is deliberately absent: .env, node_modules, and the live service
definitions. The .example files next to them say what belongs there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-22 11:45:55 +02:00
commit 0e6b400441
72 changed files with 7979 additions and 0 deletions

18
.gitignore vendored Normal file
View File

@ -0,0 +1,18 @@
### Secrets and local configuration ###
# Holds the API keys of whoever runs the stack. The .example files are the ones to read.
.env
*/.env
# The live service definitions of one deployment; services.example.json is the documented one.
dev-server-mcp/services.json
browser-mcp/services.json
### Installed and generated ###
node_modules/
coding-agent-mcp/workspace/
dist/
*.log
### Editor and system ###
.DS_Store
.idea/
.vscode/

50
MCP-STACK.md Normal file
View File

@ -0,0 +1,50 @@
# Secure MCP stack
This workspace contains three independent Streamable HTTP MCP servers:
- `coding-agent-mcp`: scoped workspace editing. Task execution is disabled in this stack.
- `dev-server-mcp`: authenticated facade for a private, allowlisted development-server worker. One instance per execution, each in a throwaway copy of that execution's tree and on its own port.
- `egress-proxy`: the only container with a route to the internet. It allows CONNECT to the package registries and refuses everything else, which is how the worker installs dependencies without being on the network itself.
- `browser-mcp`: Playwright automation restricted to exact allowed origins.
The MCP client orchestrates them; servers do not share MCP sessions or credentials.
## Start
1. Copy `mcp-stack.env.example` to `.env` and replace every token with an independent random value.
2. Set `MCP_WORKSPACE_HOST_PATH` to one dedicated project directory.
3. Copy and edit `dev-server-mcp/services.example.json`. It ships four worked service definitions: a shared Vite tree, and per-execution ones for Node, Java (through the project's own `./mvnw`) and Python.
Keep `BROWSER_MCP_ALLOWED_ORIGINS` aligned with `DEV_SERVER_PORT_RANGE`: the browser reaches a preview only on a port that range covers.
4. Run `docker compose --env-file .env -f mcp-stack.compose.yml up --build`.
Local MCP endpoints:
- `http://127.0.0.1:3101/mcp` — coding agent
- `http://127.0.0.1:3102/mcp` — development server
- `http://127.0.0.1:3103/mcp` — browser
Example Codex client configuration (`.codex/config.toml` in a trusted project):
```toml
[mcp_servers.coding_agent]
url = "http://127.0.0.1:3101/mcp"
bearer_token_env_var = "CODING_AGENT_MCP_TOKEN"
[mcp_servers.dev_server]
url = "http://127.0.0.1:3102/mcp"
bearer_token_env_var = "DEV_SERVER_MCP_TOKEN"
[mcp_servers.browser]
url = "http://127.0.0.1:3103/mcp"
bearer_token_env_var = "BROWSER_MCP_TOKEN"
```
Set those three variables in the MCP client's environment to the token portions configured for the corresponding servers. Do not reuse a token between servers.
Only the minimal Caddy gateway publishes loopback ports. The MCP and worker containers stay exclusively on internal networks and therefore have no general Internet egress. For remote use, replace or front this local gateway with a TLS reverse proxy and rate limiting.
The worker and browser networks are marked internal, no service uses host networking or the Docker socket, and the development-server workspace mount is read-only. Container isolation reduces the blast radius but is not a substitute for an ephemeral VM/microVM when executing hostile multi-tenant code.
Installing dependencies means fetching and running other people's code, so three things stand between it and the rest of the stack: the tree runs in a copy of itself and never in the workspace mount, the install runs with package scripts disabled, and the only way out of the worker is a proxy that allows the registries and nothing else. Each execution also gets a `HOME` of its own, which is what keeps Maven's `~/.m2` from becoming a channel between executions — npm's shared cache is safe because `npm ci` verifies every package against the lockfile, and Maven has no lockfile to verify against.
`coding-agent-mcp` intentionally retains write access to the selected project because editing is its purpose. Point `MCP_WORKSPACE_HOST_PATH` only at a dedicated, backed-up directory on storage with a disk quota; never point it at a home directory, repository collection, or filesystem root.

View File

@ -0,0 +1,6 @@
.git
.env
.env.*
node_modules
test
README.md

3
browser-mcp/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env
node_modules/
npm-debug.log*

15
browser-mcp/Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM mcr.microsoft.com/playwright:v1.63.0-noble
ENV NODE_ENV=production
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
ENV HOME=/tmp/browser-home
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts \
&& npm cache clean --force \
&& chown -R pwuser:pwuser /app
COPY --chown=pwuser:pwuser src ./src
USER pwuser
CMD ["node", "src/index.js"]

29
browser-mcp/README.md Normal file
View File

@ -0,0 +1,29 @@
# browser-mcp
Playwright browser automation exposed only through MCP Streamable HTTP.
## Security properties
- Exact-origin allowlist for top-level navigation and every intercepted HTTP(S) request.
- No filesystem/workspace mount.
- No arbitrary JavaScript evaluation, file upload, download, shell, or CDP tool.
- One isolated browser context per MCP session, destroyed when the session expires or is deleted.
- Bounded screenshots, text snapshots, console logs, network errors, request bodies, and session counts.
- API authentication is mandatory and browser-origin CORS is denied unless explicitly allowlisted.
The container runs as `pwuser` with the Chromium sandbox enabled. The stack applies Playwright's official default-derived `seccomp_profile.json`, which permits the user-namespace calls required by the sandbox, and restores only `SYS_CHROOT` after dropping every capability. Keep the container on an internal network that can reach only the development application. This is a hardened browser worker, not a VM-grade boundary.
## Tools
`browser_navigate`, `browser_click`, `browser_fill`, `browser_wait`, `browser_snapshot`, `browser_screenshot`, `browser_console_messages`, and `browser_network_errors`.
## Configuration
Required environment variables:
- `BROWSER_MCP_API_KEYS`: comma-separated `SUBJECT=TOKEN` values; tokens must contain at least 32 characters.
- `BROWSER_MCP_ALLOWED_ORIGINS`: comma-separated exact origins, for example `http://dev-server-worker:5173`. An entry may carry a port range instead of a port — `http://dev-server-worker:5200-5219` — which is how one entry covers every port the dev server may hand to an execution. The host still has to match exactly; only the port varies, and only inside the declared range. An impossible range is refused at startup rather than at the first navigation.
The MCP endpoint is `POST /mcp`. Initialize first and reuse the returned `Mcp-Session-Id`. Only Streamable HTTP is implemented; there is no legacy SSE or stdio transport.
Run `npm install` and `npm test` for local tests. The provided image already contains the matching Chromium build.

45
browser-mcp/package-lock.json generated Normal file
View File

@ -0,0 +1,45 @@
{
"name": "browser-mcp",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "browser-mcp",
"version": "1.0.0",
"dependencies": {
"playwright": "1.63.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.63.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.63.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
}
}
}

16
browser-mcp/package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "browser-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js",
"test": "node --test"
},
"dependencies": {
"playwright": "1.63.0"
},
"engines": {
"node": ">=20"
}
}

View File

@ -0,0 +1,831 @@
{
"defaultAction": "SCMP_ACT_ERRNO",
"archMap": [
{
"architecture": "SCMP_ARCH_X86_64",
"subArchitectures": [
"SCMP_ARCH_X86",
"SCMP_ARCH_X32"
]
},
{
"architecture": "SCMP_ARCH_AARCH64",
"subArchitectures": [
"SCMP_ARCH_ARM"
]
},
{
"architecture": "SCMP_ARCH_MIPS64",
"subArchitectures": [
"SCMP_ARCH_MIPS",
"SCMP_ARCH_MIPS64N32"
]
},
{
"architecture": "SCMP_ARCH_MIPS64N32",
"subArchitectures": [
"SCMP_ARCH_MIPS",
"SCMP_ARCH_MIPS64"
]
},
{
"architecture": "SCMP_ARCH_MIPSEL64",
"subArchitectures": [
"SCMP_ARCH_MIPSEL",
"SCMP_ARCH_MIPSEL64N32"
]
},
{
"architecture": "SCMP_ARCH_MIPSEL64N32",
"subArchitectures": [
"SCMP_ARCH_MIPSEL",
"SCMP_ARCH_MIPSEL64"
]
},
{
"architecture": "SCMP_ARCH_S390X",
"subArchitectures": [
"SCMP_ARCH_S390"
]
}
],
"syscalls": [
{
"comment": "Allow create user namespaces",
"names": [
"clone",
"setns",
"unshare"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"includes": {},
"excludes": {}
},
{
"names": [
"accept",
"accept4",
"access",
"adjtimex",
"alarm",
"bind",
"brk",
"capget",
"capset",
"chdir",
"chmod",
"chown",
"chown32",
"clock_adjtime",
"clock_adjtime64",
"clock_getres",
"clock_getres_time64",
"clock_gettime",
"clock_gettime64",
"clock_nanosleep",
"clock_nanosleep_time64",
"close",
"connect",
"copy_file_range",
"creat",
"dup",
"dup2",
"dup3",
"epoll_create",
"epoll_create1",
"epoll_ctl",
"epoll_ctl_old",
"epoll_pwait",
"epoll_wait",
"epoll_wait_old",
"eventfd",
"eventfd2",
"execve",
"execveat",
"exit",
"exit_group",
"faccessat",
"fadvise64",
"fadvise64_64",
"fallocate",
"fanotify_mark",
"fchdir",
"fchmod",
"fchmodat",
"fchown",
"fchown32",
"fchownat",
"fcntl",
"fcntl64",
"fdatasync",
"fgetxattr",
"flistxattr",
"flock",
"fork",
"fremovexattr",
"fsetxattr",
"fstat",
"fstat64",
"fstatat64",
"fstatfs",
"fstatfs64",
"fsync",
"ftruncate",
"ftruncate64",
"futex",
"futex_time64",
"futimesat",
"getcpu",
"getcwd",
"getdents",
"getdents64",
"getegid",
"getegid32",
"geteuid",
"geteuid32",
"getgid",
"getgid32",
"getgroups",
"getgroups32",
"getitimer",
"getpeername",
"getpgid",
"getpgrp",
"getpid",
"getppid",
"getpriority",
"getrandom",
"getresgid",
"getresgid32",
"getresuid",
"getresuid32",
"getrlimit",
"get_robust_list",
"getrusage",
"getsid",
"getsockname",
"getsockopt",
"get_thread_area",
"gettid",
"gettimeofday",
"getuid",
"getuid32",
"getxattr",
"inotify_add_watch",
"inotify_init",
"inotify_init1",
"inotify_rm_watch",
"io_cancel",
"ioctl",
"io_destroy",
"io_getevents",
"io_pgetevents",
"io_pgetevents_time64",
"ioprio_get",
"ioprio_set",
"io_setup",
"io_submit",
"io_uring_enter",
"io_uring_register",
"io_uring_setup",
"ipc",
"kill",
"lchown",
"lchown32",
"lgetxattr",
"link",
"linkat",
"listen",
"listxattr",
"llistxattr",
"_llseek",
"lremovexattr",
"lseek",
"lsetxattr",
"lstat",
"lstat64",
"madvise",
"membarrier",
"memfd_create",
"mincore",
"mkdir",
"mkdirat",
"mknod",
"mknodat",
"mlock",
"mlock2",
"mlockall",
"mmap",
"mmap2",
"mprotect",
"mq_getsetattr",
"mq_notify",
"mq_open",
"mq_timedreceive",
"mq_timedreceive_time64",
"mq_timedsend",
"mq_timedsend_time64",
"mq_unlink",
"mremap",
"msgctl",
"msgget",
"msgrcv",
"msgsnd",
"msync",
"munlock",
"munlockall",
"munmap",
"nanosleep",
"newfstatat",
"_newselect",
"open",
"openat",
"pause",
"pipe",
"pipe2",
"poll",
"ppoll",
"ppoll_time64",
"prctl",
"pread64",
"preadv",
"preadv2",
"prlimit64",
"pselect6",
"pselect6_time64",
"pwrite64",
"pwritev",
"pwritev2",
"read",
"readahead",
"readlink",
"readlinkat",
"readv",
"recv",
"recvfrom",
"recvmmsg",
"recvmmsg_time64",
"recvmsg",
"remap_file_pages",
"removexattr",
"rename",
"renameat",
"renameat2",
"restart_syscall",
"rmdir",
"rseq",
"rt_sigaction",
"rt_sigpending",
"rt_sigprocmask",
"rt_sigqueueinfo",
"rt_sigreturn",
"rt_sigsuspend",
"rt_sigtimedwait",
"rt_sigtimedwait_time64",
"rt_tgsigqueueinfo",
"sched_getaffinity",
"sched_getattr",
"sched_getparam",
"sched_get_priority_max",
"sched_get_priority_min",
"sched_getscheduler",
"sched_rr_get_interval",
"sched_rr_get_interval_time64",
"sched_setaffinity",
"sched_setattr",
"sched_setparam",
"sched_setscheduler",
"sched_yield",
"seccomp",
"select",
"semctl",
"semget",
"semop",
"semtimedop",
"semtimedop_time64",
"send",
"sendfile",
"sendfile64",
"sendmmsg",
"sendmsg",
"sendto",
"setfsgid",
"setfsgid32",
"setfsuid",
"setfsuid32",
"setgid",
"setgid32",
"setgroups",
"setgroups32",
"setitimer",
"setpgid",
"setpriority",
"setregid",
"setregid32",
"setresgid",
"setresgid32",
"setresuid",
"setresuid32",
"setreuid",
"setreuid32",
"setrlimit",
"set_robust_list",
"setsid",
"setsockopt",
"set_thread_area",
"set_tid_address",
"setuid",
"setuid32",
"setxattr",
"shmat",
"shmctl",
"shmdt",
"shmget",
"shutdown",
"sigaltstack",
"signalfd",
"signalfd4",
"sigprocmask",
"sigreturn",
"socket",
"socketcall",
"socketpair",
"splice",
"stat",
"stat64",
"statfs",
"statfs64",
"statx",
"symlink",
"symlinkat",
"sync",
"sync_file_range",
"syncfs",
"sysinfo",
"tee",
"tgkill",
"time",
"timer_create",
"timer_delete",
"timer_getoverrun",
"timer_gettime",
"timer_gettime64",
"timer_settime",
"timer_settime64",
"timerfd_create",
"timerfd_gettime",
"timerfd_gettime64",
"timerfd_settime",
"timerfd_settime64",
"times",
"tkill",
"truncate",
"truncate64",
"ugetrlimit",
"umask",
"uname",
"unlink",
"unlinkat",
"utime",
"utimensat",
"utimensat_time64",
"utimes",
"vfork",
"vmsplice",
"wait4",
"waitid",
"waitpid",
"write",
"writev"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {},
"excludes": {}
},
{
"names": [
"ptrace"
],
"action": "SCMP_ACT_ALLOW",
"args": null,
"comment": "",
"includes": {
"minKernel": "4.8"
},
"excludes": {}
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 0,
"valueTwo": 0,
"op": "SCMP_CMP_EQ"
}
],
"comment": "",
"includes": {},
"excludes": {}
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 8,
"valueTwo": 0,
"op": "SCMP_CMP_EQ"
}
],
"comment": "",
"includes": {},
"excludes": {}
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 131072,
"valueTwo": 0,
"op": "SCMP_CMP_EQ"
}
],
"comment": "",
"includes": {},
"excludes": {}
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 131080,
"valueTwo": 0,
"op": "SCMP_CMP_EQ"
}
],
"comment": "",
"includes": {},
"excludes": {}
},
{
"names": [
"personality"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 4294967295,
"valueTwo": 0,
"op": "SCMP_CMP_EQ"
}
],
"comment": "",
"includes": {},
"excludes": {}
},
{
"names": [
"sync_file_range2"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"arches": [
"ppc64le"
]
},
"excludes": {}
},
{
"names": [
"arm_fadvise64_64",
"arm_sync_file_range",
"sync_file_range2",
"breakpoint",
"cacheflush",
"set_tls"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"arches": [
"arm",
"arm64"
]
},
"excludes": {}
},
{
"names": [
"arch_prctl"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"arches": [
"amd64",
"x32"
]
},
"excludes": {}
},
{
"names": [
"modify_ldt"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"arches": [
"amd64",
"x32",
"x86"
]
},
"excludes": {}
},
{
"names": [
"s390_pci_mmio_read",
"s390_pci_mmio_write",
"s390_runtime_instr"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"arches": [
"s390",
"s390x"
]
},
"excludes": {}
},
{
"names": [
"open_by_handle_at"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_DAC_READ_SEARCH"
]
},
"excludes": {}
},
{
"names": [
"bpf",
"clone",
"fanotify_init",
"lookup_dcookie",
"mount",
"name_to_handle_at",
"perf_event_open",
"quotactl",
"setdomainname",
"sethostname",
"setns",
"syslog",
"umount",
"umount2",
"unshare"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_ADMIN"
]
},
"excludes": {}
},
{
"names": [
"clone"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 0,
"value": 2114060288,
"valueTwo": 0,
"op": "SCMP_CMP_MASKED_EQ"
}
],
"comment": "",
"includes": {},
"excludes": {
"caps": [
"CAP_SYS_ADMIN"
],
"arches": [
"s390",
"s390x"
]
}
},
{
"names": [
"clone"
],
"action": "SCMP_ACT_ALLOW",
"args": [
{
"index": 1,
"value": 2114060288,
"valueTwo": 0,
"op": "SCMP_CMP_MASKED_EQ"
}
],
"comment": "s390 parameter ordering for clone is different",
"includes": {
"arches": [
"s390",
"s390x"
]
},
"excludes": {
"caps": [
"CAP_SYS_ADMIN"
]
}
},
{
"names": [
"reboot"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_BOOT"
]
},
"excludes": {}
},
{
"names": [
"chroot"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_CHROOT"
]
},
"excludes": {}
},
{
"names": [
"delete_module",
"init_module",
"finit_module"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_MODULE"
]
},
"excludes": {}
},
{
"names": [
"acct"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_PACCT"
]
},
"excludes": {}
},
{
"names": [
"kcmp",
"process_vm_readv",
"process_vm_writev",
"ptrace"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_PTRACE"
]
},
"excludes": {}
},
{
"names": [
"iopl",
"ioperm"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_RAWIO"
]
},
"excludes": {}
},
{
"names": [
"settimeofday",
"stime",
"clock_settime"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_TIME"
]
},
"excludes": {}
},
{
"names": [
"vhangup"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_TTY_CONFIG"
]
},
"excludes": {}
},
{
"names": [
"get_mempolicy",
"mbind",
"set_mempolicy"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYS_NICE"
]
},
"excludes": {}
},
{
"names": [
"syslog"
],
"action": "SCMP_ACT_ALLOW",
"args": [],
"comment": "",
"includes": {
"caps": [
"CAP_SYSLOG"
]
},
"excludes": {}
}
]
}

View File

@ -0,0 +1,70 @@
const SAFE_AUXILIARY_PROTOCOLS = new Set(["about:", "data:", "blob:"]);
/**
* An origin written with a port range, which is how one entry covers every port the dev server
* may hand to an execution.
*
* <p>Without it the allowlist would need one entry per concurrent execution, and would have to be
* rewritten whenever the port range moved - an allowlist that has to keep up with runtime is an
* allowlist that gets widened to a wildcard. The host still has to match exactly; only the port
* varies, and only inside a range the operator wrote down.
*/
const RANGE_ORIGIN = /^(https?):\/\/([^/:?#]+):(\d{2,5})-(\d{2,5})$/;
function parseRange(value) {
const match = RANGE_ORIGIN.exec(value);
if (!match) return null;
const [, protocol, hostname, from, to] = match;
const first = Number(from);
const last = Number(to);
if (first < 1 || last > 65535 || last < first) throw new Error(`Allowed browser origin has an impossible port range: ${value}`);
return { protocol: `${protocol}:`, hostname, from: first, to: last };
}
export class BrowserAccessPolicy {
constructor(origins) {
this.origins = new Set();
this.ranges = [];
for (const value of origins) {
const range = parseRange(value);
if (range) {
this.ranges.push(range);
continue;
}
const url = new URL(value);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
throw new Error(`Allowed browser origin must be an HTTP(S) origin: ${value}`);
}
this.origins.add(url.origin);
}
if (this.origins.size === 0 && this.ranges.length === 0) throw new Error("BROWSER_MCP_ALLOWED_ORIGINS must not be empty");
}
allows(url) {
if (this.origins.has(url.origin)) return true;
const port = Number(url.port);
if (!Number.isInteger(port)) return false;
return this.ranges.some((range) => range.protocol === url.protocol && range.hostname === url.hostname && port >= range.from && port <= range.to);
}
assertUrl(value, { navigation = false } = {}) {
if (typeof value !== "string" || value.length > 4096) throw new Error("Invalid URL");
const url = new URL(value);
if (SAFE_AUXILIARY_PROTOCOLS.has(url.protocol) && !navigation) return url.toString();
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || !this.allows(url)) {
throw new Error(`URL origin is not allowed: ${url.origin === "null" ? url.protocol : url.origin}`);
}
return url.toString();
}
assertWebSocketUrl(value) {
if (typeof value !== "string" || value.length > 4096) throw new Error("Invalid WebSocket URL");
const url = new URL(value);
if (!["ws:", "wss:"].includes(url.protocol) || url.username || url.password) throw new Error("WebSocket URL is not allowed");
// A dev server's hot reload opens a socket back to the same host and port it was served from,
// so it is allowed exactly as far as the page itself is: same host, same range.
const equivalent = new URL(`${url.protocol === "ws:" ? "http:" : "https:"}//${url.host}`);
if (!this.allows(equivalent)) throw new Error(`WebSocket origin is not allowed: ${equivalent.origin}`);
return url.toString();
}
}

View File

@ -0,0 +1,181 @@
import { BrowserAccessPolicy } from "./access-policy.js";
function boundedString(value, label, maxLength) {
if (typeof value !== "string" || value.length === 0 || value.length > maxLength || value.includes("\0")) throw new Error(`${label} must contain between 1 and ${maxLength} characters`);
return value;
}
function boundedInteger(value, fallback, minimum, maximum, label) {
const result = value ?? fallback;
if (!Number.isInteger(result) || result < minimum || result > maximum) throw new Error(`${label} must be between ${minimum} and ${maximum}`);
return result;
}
function pushBounded(array, value, maximum = 200) {
array.push(value);
if (array.length > maximum) array.splice(0, array.length - maximum);
}
export class BrowserService {
constructor({ allowedOrigins, browserFactory, maxSessions = 16, defaultTimeoutMs = 15000 }) {
this.policy = new BrowserAccessPolicy(allowedOrigins);
this.browserFactory = browserFactory;
this.maxSessions = maxSessions;
this.defaultTimeoutMs = defaultTimeoutMs;
this.sessions = new Map();
this.browserPromise = null;
}
async browser() {
if (!this.browserPromise) this.browserPromise = this.browserFactory();
return this.browserPromise;
}
async createSession(sessionId) {
if (this.sessions.has(sessionId)) return this.sessions.get(sessionId);
if (this.sessions.size >= this.maxSessions) throw new Error("Browser session capacity reached");
const browser = await this.browser();
const context = await browser.newContext({
acceptDownloads: false,
ignoreHTTPSErrors: false,
serviceWorkers: "block",
viewport: { width: 1440, height: 900 }
});
await context.route("**/*", async (route) => {
try { this.policy.assertUrl(route.request().url()); await route.continue(); }
catch { await route.abort("blockedbyclient"); }
});
await context.routeWebSocket("**/*", async (webSocket) => {
try {
this.policy.assertWebSocketUrl(webSocket.url());
webSocket.connectToServer();
} catch {
await webSocket.close({ code: 1008, reason: "Origin is not allowed" });
}
});
const page = await context.newPage();
page.setDefaultTimeout(this.defaultTimeoutMs);
page.setDefaultNavigationTimeout(this.defaultTimeoutMs);
const state = { context, page, console: [], network: [], queue: Promise.resolve() };
page.on("console", (message) => pushBounded(state.console, {
type: message.type(),
text: message.text().slice(0, 4096),
timestamp: new Date().toISOString()
}));
page.on("pageerror", (error) => pushBounded(state.console, { type: "pageerror", text: error.message.slice(0, 4096), timestamp: new Date().toISOString() }));
page.on("requestfailed", (request) => pushBounded(state.network, {
type: "requestfailed", method: request.method(), url: request.url().slice(0, 4096), error: request.failure()?.errorText ?? "failed"
}));
page.on("response", (response) => {
if (response.status() >= 400) pushBounded(state.network, { type: "http", status: response.status(), url: response.url().slice(0, 4096) });
});
this.sessions.set(sessionId, state);
return state;
}
async run(sessionId, operation) {
if (typeof sessionId !== "string" || !sessionId) throw new Error("An initialized MCP session is required");
const state = await this.createSession(sessionId);
const next = state.queue.then(() => operation(state));
state.queue = next.catch(() => {});
return next;
}
navigate(args, context) {
return this.run(context.sessionId, async ({ page }) => {
const url = this.policy.assertUrl(boundedString(args.url, "url", 4096), { navigation: true });
const timeout = boundedInteger(args.timeoutMs, this.defaultTimeoutMs, 100, 30000, "timeoutMs");
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
return { url: page.url(), title: await page.title() };
});
}
click(args, context) {
return this.run(context.sessionId, async ({ page }) => {
const selector = boundedString(args.selector, "selector", 1024);
await page.locator(selector).first().click({ timeout: boundedInteger(args.timeoutMs, this.defaultTimeoutMs, 100, 30000, "timeoutMs") });
return { clicked: selector, url: page.url() };
});
}
fill(args, context) {
return this.run(context.sessionId, async ({ page }) => {
const selector = boundedString(args.selector, "selector", 1024);
const value = boundedString(args.value, "value", 10000);
await page.locator(selector).first().fill(value, { timeout: boundedInteger(args.timeoutMs, this.defaultTimeoutMs, 100, 30000, "timeoutMs") });
return { filled: selector, characters: value.length };
});
}
wait(args, context) {
return this.run(context.sessionId, async ({ page }) => {
const milliseconds = boundedInteger(args.milliseconds, 500, 1, 5000, "milliseconds");
await page.waitForTimeout(milliseconds);
return { waitedMilliseconds: milliseconds };
});
}
snapshot(args, context) {
return this.run(context.sessionId, async ({ page }) => {
const maxTextCharacters = boundedInteger(args.maxTextCharacters, 50000, 1000, 100000, "maxTextCharacters");
const snapshot = await page.evaluate(({ textLimit }) => {
const text = (document.body?.innerText ?? "").slice(0, textLimit);
const elements = [...document.querySelectorAll("a,button,input,select,textarea,[role]")].slice(0, 200).map((element, index) => ({
index,
tag: element.tagName.toLowerCase(),
role: element.getAttribute("role"),
name: (element.getAttribute("aria-label") || element.textContent || element.getAttribute("placeholder") || "").trim().slice(0, 300),
type: element.getAttribute("type"),
href: element instanceof HTMLAnchorElement ? element.href : null,
disabled: "disabled" in element ? Boolean(element.disabled) : false
}));
return { text, textTruncated: (document.body?.innerText?.length ?? 0) > textLimit, elements };
}, { textLimit: maxTextCharacters });
return { url: page.url(), title: await page.title(), ...snapshot };
});
}
screenshot(args, context) {
return this.run(context.sessionId, async ({ page }) => {
const fullPage = args.fullPage === true;
const data = await page.screenshot({ type: "png", fullPage, animations: "disabled", caret: "hide" });
if (data.length > 8 * 1024 * 1024) throw new Error("Screenshot exceeds the 8 MiB response limit");
return {
content: [
{ type: "image", data: data.toString("base64"), mimeType: "image/png" },
{ type: "text", text: JSON.stringify({ url: page.url(), fullPage, bytes: data.length }) }
],
structuredContent: { url: page.url(), fullPage, bytes: data.length },
isError: false
};
});
}
consoleMessages(args, context) {
return this.run(context.sessionId, async (state) => {
const messages = [...state.console];
if (args.clear === true) state.console.length = 0;
return { messages };
});
}
networkErrors(args, context) {
return this.run(context.sessionId, async (state) => {
const errors = [...state.network];
if (args.clear === true) state.network.length = 0;
return { errors };
});
}
async closeSession(sessionId) {
const state = this.sessions.get(sessionId);
if (!state) return;
this.sessions.delete(sessionId);
await state.context.close().catch(() => {});
}
async close() {
await Promise.all([...this.sessions.keys()].map((id) => this.closeSession(id)));
if (this.browserPromise) await (await this.browserPromise).close().catch(() => {});
}
}

64
browser-mcp/src/index.js Normal file
View File

@ -0,0 +1,64 @@
#!/usr/bin/env node
import { startStreamableHttpServer } from "./streamable-http.js";
import { BrowserService } from "./browser-service.js";
import { TOOL_DEFINITIONS } from "./tool-definitions.js";
function csv(value) { return (value ?? "").split(",").map((item) => item.trim()).filter(Boolean); }
function integer(value, fallback) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; }
const maxSessions = integer(process.env.BROWSER_MCP_MAX_SESSIONS, 16);
const browserService = new BrowserService({
allowedOrigins: csv(process.env.BROWSER_MCP_ALLOWED_ORIGINS),
maxSessions,
defaultTimeoutMs: integer(process.env.BROWSER_MCP_DEFAULT_TIMEOUT_MS, 15000),
browserFactory: async () => {
const { chromium } = await import("playwright");
return chromium.launch({ headless: true, chromiumSandbox: true });
}
});
const calls = {
browser_navigate: (args, context) => browserService.navigate(args, context),
browser_click: (args, context) => browserService.click(args, context),
browser_fill: (args, context) => browserService.fill(args, context),
browser_wait: (args, context) => browserService.wait(args, context),
browser_snapshot: (args, context) => browserService.snapshot(args, context),
browser_screenshot: (args, context) => browserService.screenshot(args, context),
browser_console_messages: (args, context) => browserService.consoleMessages(args, context),
browser_network_errors: (args, context) => browserService.networkErrors(args, context)
};
async function auditedCall(name, args, context) {
const event = { timestamp: new Date().toISOString(), server: "browser-mcp", tool: name, subject: context.subject, sessionId: context.sessionId };
try {
const result = await calls[name](args, context);
process.stderr.write(`${JSON.stringify({ ...event, outcome: "success" })}\n`);
return result;
} catch (error) {
process.stderr.write(`${JSON.stringify({ ...event, outcome: "failure", errorType: error instanceof Error ? error.name : "Error" })}\n`);
throw error;
}
}
const listener = await startStreamableHttpServer({
serverName: "browser-mcp",
serverVersion: "1.0.0",
tools: TOOL_DEFINITIONS,
callTool: auditedCall,
apiKeys: csv(process.env.BROWSER_MCP_API_KEYS),
allowedOrigins: csv(process.env.BROWSER_MCP_CORS_ORIGINS),
host: process.env.BROWSER_MCP_HOST ?? "0.0.0.0",
port: integer(process.env.BROWSER_MCP_PORT, 3000),
maxSessions,
sessionTtlMs: integer(process.env.BROWSER_MCP_SESSION_TTL_SECONDS, 1800) * 1000,
onSessionClose: ({ id }) => browserService.closeSession(id)
});
process.stderr.write(`browser-mcp listening on ${listener.host}:${listener.port}\n`);
async function shutdown() {
await listener.close();
await browserService.close();
process.exit(0);
}
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);

View File

@ -0,0 +1,66 @@
const DEFAULT_PROTOCOL_VERSION = "2025-03-26";
function toolResult(value) {
if (value && Array.isArray(value.content)) return value;
return {
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
structuredContent: value,
isError: false
};
}
function toolError(error) {
return {
content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
isError: true
};
}
export function createMcpHandler({ serverName, serverVersion, tools, callTool }) {
return async function handle(message, context) {
if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
return message?.id === undefined ? null : { jsonrpc: "2.0", id: message.id ?? null, error: { code: -32600, message: "Invalid Request" } };
}
if (message.method === "notifications/initialized" || message.method === "$/cancelRequest") return null;
let result;
try {
switch (message.method) {
case "initialize":
result = {
protocolVersion: context.protocolVersion || DEFAULT_PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: serverName, version: serverVersion }
};
break;
case "ping":
result = {};
break;
case "tools/list":
result = { tools };
break;
case "tools/call": {
const name = message.params?.name;
if (typeof name !== "string" || !tools.some((tool) => tool.name === name)) throw new Error("Unknown tool");
try {
result = toolResult(await callTool(name, message.params?.arguments ?? {}, context));
} catch (error) {
result = toolError(error);
}
break;
}
default:
return message.id === undefined ? null : { jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Method not found" } };
}
} catch (error) {
return message.id === undefined ? null : {
jsonrpc: "2.0",
id: message.id,
error: { code: -32603, message: error instanceof Error ? error.message : "Internal error" }
};
}
return message.id === undefined ? null : { jsonrpc: "2.0", id: message.id, result };
};
}

View File

@ -0,0 +1,207 @@
import http from "node:http";
import { randomUUID, timingSafeEqual } from "node:crypto";
import { URL } from "node:url";
import { createMcpHandler } from "./mcp-core.js";
const DEFAULT_PROTOCOL_VERSION = "2025-03-26";
function sendJson(response, status, value, headers = {}) {
if (response.destroyed || response.writableEnded) return;
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
response.end(JSON.stringify(value));
}
function collectJson(request, limitBytes) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
request.on("data", (chunk) => {
size += chunk.length;
if (size > limitBytes) {
reject(new Error("Request body too large"));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on("end", () => {
try {
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (Array.isArray(value)) throw new Error("JSON-RPC batches are disabled");
resolve(value);
} catch (error) { reject(error); }
});
request.on("error", reject);
});
}
function parseCredentials(entries) {
const result = new Map();
for (const raw of entries) {
const separator = raw.indexOf("=");
const subject = separator < 0 ? "shared" : raw.slice(0, separator).trim();
const token = (separator < 0 ? raw : raw.slice(separator + 1)).trim();
if (!subject || token.length < 32) throw new Error("Each API token must contain at least 32 characters");
result.set(subject, Buffer.from(token));
}
if (result.size === 0) throw new Error("At least one API token is required");
return result;
}
function authenticate(request, credentials) {
const bearer = typeof request.headers.authorization === "string"
? /^Bearer\s+(.+)$/i.exec(request.headers.authorization)?.[1]?.trim()
: null;
const token = bearer || (typeof request.headers["x-api-key"] === "string" ? request.headers["x-api-key"].trim() : null);
if (!token) return null;
const candidate = Buffer.from(token);
for (const [subject, expected] of credentials) {
if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) return subject;
}
return null;
}
function sessionHeaders(session) {
return { "mcp-session-id": session.id, "mcp-protocol-version": session.protocolVersion };
}
export function startStreamableHttpServer({
serverName,
serverVersion,
tools,
callTool,
apiKeys,
allowedOrigins = [],
host = "127.0.0.1",
port = 3000,
maxSessions = 32,
sessionTtlMs = 30 * 60 * 1000,
bodyLimitBytes = 1024 * 1024,
onSessionClose = async () => {}
}) {
const credentials = parseCredentials(apiKeys);
const origins = new Set(allowedOrigins);
const sessions = new Map();
const handleMcp = createMcpHandler({ serverName, serverVersion, tools, callTool });
async function closeSession(session) {
if (!sessions.delete(session.id)) return;
for (const response of session.listeners) response.end();
session.listeners.clear();
await onSessionClose(session).catch(() => {});
}
const cleanup = setInterval(() => {
const now = Date.now();
for (const session of sessions.values()) {
if (now - session.lastSeen > sessionTtlMs) void closeSession(session);
}
}, Math.min(sessionTtlMs, 60_000));
cleanup.unref();
const server = http.createServer(async (request, response) => {
response.on("error", () => {});
response.setHeader("x-content-type-options", "nosniff");
response.setHeader("referrer-policy", "no-referrer");
const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
const origin = request.headers.origin;
if (typeof origin === "string") {
if (!origins.has(origin)) return sendJson(response, 403, { error: "Origin is not allowed" });
response.setHeader("access-control-allow-origin", origin);
response.setHeader("vary", "Origin");
response.setHeader("access-control-allow-headers", "authorization, x-api-key, content-type, mcp-session-id, mcp-protocol-version");
response.setHeader("access-control-allow-methods", "GET, POST, DELETE, OPTIONS");
}
if (request.method === "OPTIONS") {
if (typeof origin !== "string") return sendJson(response, 403, { error: "Origin is required" });
response.writeHead(204); response.end(); return;
}
const subject = authenticate(request, credentials);
if (!subject) {
response.setHeader("www-authenticate", "Bearer");
return sendJson(response, 401, { error: "Unauthorized" });
}
if (request.method === "GET" && requestUrl.pathname === "/health") {
return sendJson(response, 200, { ok: true, serverName, transport: "streamable-http" });
}
if (requestUrl.pathname !== "/mcp") return sendJson(response, 404, { error: "Not found" });
const requestedId = request.headers["mcp-session-id"];
let session = typeof requestedId === "string" ? sessions.get(requestedId) : null;
if (requestedId && (!session || session.subject !== subject)) return sendJson(response, 404, { error: "Unknown MCP session" });
if (request.method === "DELETE") {
if (!session) return sendJson(response, 404, { error: "Unknown MCP session" });
await closeSession(session);
response.writeHead(204); response.end(); return;
}
if (request.method === "GET") {
if (!session) return sendJson(response, 400, { error: "Mcp-Session-Id is required" });
session.lastSeen = Date.now();
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
...sessionHeaders(session)
});
response.write(": connected\n\n");
session.listeners.add(response);
request.once("close", () => session.listeners.delete(response));
return;
}
if (request.method !== "POST") return sendJson(response, 405, { error: "Method not allowed" });
let message;
try { message = await collectJson(request, bodyLimitBytes); }
catch (error) { return sendJson(response, 400, { error: error instanceof Error ? error.message : "Invalid JSON" }); }
if (!session) {
if (message?.method !== "initialize") return sendJson(response, 400, { error: "Initialize the MCP session first" });
if (sessions.size >= maxSessions) return sendJson(response, 503, { error: "Session capacity reached" });
session = {
id: randomUUID(),
subject,
protocolVersion: typeof message.params?.protocolVersion === "string" ? message.params.protocolVersion : DEFAULT_PROTOCOL_VERSION,
createdAt: Date.now(),
lastSeen: Date.now(),
listeners: new Set()
};
sessions.set(session.id, session);
} else {
session.lastSeen = Date.now();
}
const rpcResponse = await handleMcp(message, {
sessionId: session.id,
subject,
protocolVersion: session.protocolVersion
});
if (rpcResponse === null) {
response.writeHead(202, sessionHeaders(session)); response.end(); return;
}
return sendJson(response, 200, rpcResponse, sessionHeaders(session));
});
server.headersTimeout = 10_000;
server.requestTimeout = 120_000;
server.maxRequestsPerSocket = 1000;
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolve({
server,
host,
port: server.address().port,
close: async () => {
clearInterval(cleanup);
await Promise.all([...sessions.values()].map(closeSession));
await new Promise((done, fail) => server.close((error) => error ? fail(error) : done()));
}
});
});
});
}

View File

@ -0,0 +1,45 @@
const selector = { type: "string", minLength: 1, maxLength: 1024, description: "CSS selector. The first matching element is used." };
const timeout = { type: "integer", minimum: 100, maximum: 30000, default: 15000 };
export const TOOL_DEFINITIONS = [
{
name: "browser_navigate",
description: "Navigate to an HTTP(S) URL whose exact origin is on the operator allowlist.",
inputSchema: { type: "object", properties: { url: { type: "string", minLength: 1, maxLength: 4096 }, timeoutMs: timeout }, required: ["url"], additionalProperties: false }
},
{
name: "browser_click",
description: "Click the first element matching a CSS selector. Arbitrary page JavaScript is not supported.",
inputSchema: { type: "object", properties: { selector, timeoutMs: timeout }, required: ["selector"], additionalProperties: false }
},
{
name: "browser_fill",
description: "Fill the first form control matching a CSS selector.",
inputSchema: { type: "object", properties: { selector, value: { type: "string", minLength: 1, maxLength: 10000 }, timeoutMs: timeout }, required: ["selector", "value"], additionalProperties: false }
},
{
name: "browser_wait",
description: "Wait briefly for asynchronous page behavior.",
inputSchema: { type: "object", properties: { milliseconds: { type: "integer", minimum: 1, maximum: 5000, default: 500 } }, additionalProperties: false }
},
{
name: "browser_snapshot",
description: "Return bounded visible text and metadata for interactive elements. Input values and arbitrary HTML are not returned.",
inputSchema: { type: "object", properties: { maxTextCharacters: { type: "integer", minimum: 1000, maximum: 100000, default: 50000 } }, additionalProperties: false }
},
{
name: "browser_screenshot",
description: "Capture the current page as a bounded PNG image.",
inputSchema: { type: "object", properties: { fullPage: { type: "boolean", default: false } }, additionalProperties: false }
},
{
name: "browser_console_messages",
description: "Read bounded browser console and page-error messages.",
inputSchema: { type: "object", properties: { clear: { type: "boolean", default: false } }, additionalProperties: false }
},
{
name: "browser_network_errors",
description: "Read bounded failed requests and HTTP error responses.",
inputSchema: { type: "object", properties: { clear: { type: "boolean", default: false } }, additionalProperties: false }
}
];

View File

@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
import { BrowserAccessPolicy } from "../src/access-policy.js";
test("browser policy requires at least one exact HTTP origin", () => {
assert.throws(() => new BrowserAccessPolicy([]), /must not be empty/);
assert.throws(() => new BrowserAccessPolicy(["file:///tmp"]), /HTTP/);
assert.throws(() => new BrowserAccessPolicy(["http://app:5173/path"]), /origin/);
});
test("browser policy permits exact allowed origins and rejects SSRF destinations", () => {
const policy = new BrowserAccessPolicy(["http://dev-server-worker:5173"]);
assert.equal(policy.assertUrl("http://dev-server-worker:5173/dashboard", { navigation: true }), "http://dev-server-worker:5173/dashboard");
assert.throws(() => policy.assertUrl("http://dev-server-worker:3000/mcp", { navigation: true }), /not allowed/);
assert.throws(() => policy.assertUrl("http://169.254.169.254/latest/meta-data", { navigation: true }), /not allowed/);
assert.throws(() => policy.assertUrl("file:///etc/passwd", { navigation: true }), /not allowed/);
assert.throws(() => policy.assertUrl("http://user:pass@dev-server-worker:5173/", { navigation: true }), /not allowed/);
assert.equal(policy.assertWebSocketUrl("ws://dev-server-worker:5173/socket"), "ws://dev-server-worker:5173/socket");
assert.throws(() => policy.assertWebSocketUrl("ws://dev-server-worker:3000/mcp"), /not allowed/);
});
test("auxiliary data URLs are allowed only for subresources", () => {
const policy = new BrowserAccessPolicy(["https://app.example"]);
assert.match(policy.assertUrl("data:text/plain,ok"), /^data:/);
assert.throws(() => policy.assertUrl("data:text/html,hello", { navigation: true }), /not allowed/);
});
test("a port range covers every port an execution may be given", () => {
// One entry instead of one per concurrent execution: an allowlist that has to be rewritten as
// executions come and go is an allowlist somebody will eventually replace with a wildcard.
const policy = new BrowserAccessPolicy(["http://dev-server-worker:5200-5219"]);
assert.equal(policy.assertUrl("http://dev-server-worker:5200/", { navigation: true }), "http://dev-server-worker:5200/");
assert.equal(policy.assertUrl("http://dev-server-worker:5219/app", { navigation: true }), "http://dev-server-worker:5219/app");
assert.throws(() => policy.assertUrl("http://dev-server-worker:5220/", { navigation: true }), /not allowed/);
assert.throws(() => policy.assertUrl("http://dev-server-worker:5199/", { navigation: true }), /not allowed/);
});
test("a range widens the port and nothing else", () => {
const policy = new BrowserAccessPolicy(["http://dev-server-worker:5200-5219"]);
assert.throws(() => policy.assertUrl("http://elsewhere:5200/", { navigation: true }), /not allowed/);
assert.throws(() => policy.assertUrl("https://dev-server-worker:5200/", { navigation: true }), /not allowed/);
});
test("hot reload is allowed as far as the page it belongs to", () => {
const policy = new BrowserAccessPolicy(["http://dev-server-worker:5200-5219"]);
assert.equal(policy.assertWebSocketUrl("ws://dev-server-worker:5207/?token=x"), "ws://dev-server-worker:5207/?token=x");
assert.throws(() => policy.assertWebSocketUrl("ws://dev-server-worker:5300/"), /not allowed/);
});
test("exact origins and ranges live together", () => {
const policy = new BrowserAccessPolicy(["http://fixed-app:8080", "http://dev-server-worker:5200-5201"]);
assert.equal(policy.assertUrl("http://fixed-app:8080/", { navigation: true }), "http://fixed-app:8080/");
assert.equal(policy.assertUrl("http://dev-server-worker:5201/", { navigation: true }), "http://dev-server-worker:5201/");
assert.throws(() => policy.assertUrl("http://fixed-app:8081/", { navigation: true }), /not allowed/);
});
test("an impossible range is refused at startup, not at the first navigation", () => {
assert.throws(() => new BrowserAccessPolicy(["http://app:5219-5200"]), /impossible port range/);
assert.throws(() => new BrowserAccessPolicy(["http://app:5200-99999"]), /impossible port range/);
});

View File

@ -0,0 +1,11 @@
# A long random secret. Use SUBJECT=TOKEN to identify callers in audit logs.
CODING_AGENT_MCP_API_KEYS=team-agent=replace-with-a-long-random-token
# Absolute path of a dedicated, non-sensitive workspace directory.
CODING_AGENT_ROOT_HOST_PATH=/srv/coding-agent-workspaces
# Match the owner of CODING_AGENT_ROOT_HOST_PATH on the host.
CODING_AGENT_UID=1000
CODING_AGENT_GID=1000
DOMAIN_NAME=mcp.example.com

View File

@ -0,0 +1,20 @@
FROM node:24-alpine
WORKDIR /app
COPY package.json ./
COPY src ./src
RUN apk add --no-cache python3 py3-pytest \
&& addgroup -S -g 10001 mcp \
&& adduser -S -D -H -u 10001 -G mcp mcp \
&& chmod +x /app/src/coding-agent-index.js \
&& mkdir -p /tmp/coding-agent \
&& chown -R mcp:mcp /tmp/coding-agent
ENV NODE_ENV=production
ENV PYTHONDONTWRITEBYTECODE=1
USER mcp
CMD ["node", "src/coding-agent-index.js", "--transport", "http", "--host", "0.0.0.0", "--port", "3000"]

View File

@ -0,0 +1,82 @@
# coding-agent-mcp
An MCP server for coding agents. It provides workspace-subpath-scoped file editing and typed build/test tasks, including Python tests.
## Security model
This is intentionally a single combined server: `coding-agent-mcp`. The old split servers and the generic `run_command` API were removed.
- HTTP requires an API key. Set `CODING_AGENT_MCP_API_KEYS` to `SUBJECT=TOKEN` entries separated by commas.
- An SSE connection creates a server-generated session bound to that authenticated subject. MCP requests must use that live session; callers cannot choose a session ID.
- The session's optional `workspaceSubpath` is bound when SSE opens and cannot change afterward. It is the only filesystem scope beneath the configured workspace root; the server-generated session ID is not part of the directory path.
- File and task paths are relative only. The client cannot select a root or use absolute paths.
- File paths are checked for traversal and symlink escapes.
- HTTP sends no permissive CORS header. Set `CODING_AGENT_MCP_CORS_ORIGINS` to an explicit comma-separated origin allowlist only when browser access is required.
- Remote HTTP command execution defaults to **disabled**. Set `CODING_AGENT_MCP_EXECUTION_BACKEND=local` only in the hardened runner deployment below.
`run_task` deliberately accepts no shell command, flags, environment variables, npm script name, or local executable. It maps a typed request to a fixed invocation. Project tests and build scripts still execute project code, so this validation is not a sandbox.
## Tools
Workspace tools: `list_files`, `read_file`, `read_files`, `read_binary_file`, `search_text`, `write_file`, `write_binary_file`, `apply_patch`, `make_directory`, `delete_path`, `move_path`, `rename_path`, and `file_info`.
Execution tool: `run_task`.
Example Python request:
```json
{
"runner": "pytest",
"task": "test",
"paths": ["tests/unit"],
"timeoutSeconds": 300
}
```
Supported runners are `pytest`, `npm`, `pnpm`, `yarn`, `maven`, `gradle`, `go`, and `cargo`. Supported tasks depend on the runner; the server rejects unsupported combinations.
## Local trusted use
Stdio is intended for a local, trusted client and enables typed task execution by default:
```bash
npm run start:coding-agent -- --root /absolute/path/to/workspace
```
The runtime needs the selected tool installed. The supplied Docker image contains Python 3 and pytest.
## Remote hardened deployment
1. Copy `.env.example` to `.env` and set a long random API token.
2. Create a dedicated workspace directory; do not mount a home directory, source checkout, Docker socket, or secrets.
3. Make that directory writable by `CODING_AGENT_UID:CODING_AGENT_GID`.
4. Deploy with `docker compose up --build -d`.
The Compose deployment runs as a non-root UID, makes the container filesystem read-only, provides an ephemeral `/tmp`, removes Linux capabilities, applies process/memory/CPU limits, and places the MCP service on an internal-only Docker network. The workspace mount is the only intended writable persistent path.
This is a hardened local runner, not a VM-grade security boundary. For untrusted agents or untrusted repositories, connect `run_task` to an external ephemeral VM/container runner with a copy-on-write workspace, no host bind mounts, no Docker socket, no inherited secrets, resource quotas, and explicit network policy. Keep execution disabled until such a runner is configured.
## HTTP protocol
All HTTP endpoints require `Authorization: Bearer TOKEN` (or `x-api-key`).
### Streamable HTTP (recommended)
- Send an MCP `initialize` JSON-RPC request to `POST /mcp`. The response includes `Mcp-Session-Id`.
- Send subsequent MCP requests to `POST /mcp` with that `Mcp-Session-Id` header.
- Use `DELETE /mcp` with `Mcp-Session-Id` to terminate the session.
- `GET /mcp` with `Mcp-Session-Id` opens an optional server-to-client SSE stream.
### Legacy SSE
- `GET /sse` opens the former MCP SSE transport. It emits an `endpoint` event for posting messages.
- For automatic legacy-client fallback, `GET /mcp` without `Mcp-Session-Id` provides the same SSE handshake.
- The pre-existing custom transport is retained: `GET /events` returns `x-session-id`; send it on `POST /mcp` as `x-session-id`.
- Bind `x-workspace-subpath` only when opening or initializing a session; it cannot change afterward.
- `GET /health` reports server health.
## Development
```bash
npm test
```

View File

@ -0,0 +1,9 @@
{$DOMAIN_NAME} {
encode gzip
handle /coding-agent/* {
uri strip_prefix /coding-agent
reverse_proxy coding-agent-mcp:3000
}
}

View File

@ -0,0 +1,64 @@
services:
coding-agent-mcp:
build:
context: .
dockerfile: Dockerfile
command:
- node
- src/coding-agent-index.js
- --transport
- http
- --host
- 0.0.0.0
- --port
- "3000"
- --root
- /workspace
environment:
CODING_AGENT_MCP_API_KEYS: ${CODING_AGENT_MCP_API_KEYS}
CODING_AGENT_MCP_EXECUTION_BACKEND: local
MCP_DEBUG_REQUESTS: "false"
volumes:
- ${CODING_AGENT_ROOT_HOST_PATH}:/workspace
user: "${CODING_AGENT_UID:-10001}:${CODING_AGENT_GID:-10001}"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=256m
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
pids_limit: 256
mem_limit: 1g
cpus: 2
init: true
networks:
- backend
restart: unless-stopped
caddy:
image: caddy:2
depends_on:
- coding-agent-mcp
ports:
- "80:80"
- "443:443"
environment:
DOMAIN_NAME: ${DOMAIN_NAME}
volumes:
- ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- frontend
- backend
restart: unless-stopped
volumes:
caddy_data:
caddy_config:
networks:
frontend:
backend:
internal: true

View File

@ -0,0 +1,40 @@
# MCP client configuration
## Remote HTTP
Use an API key configured as `SUBJECT=TOKEN` in `CODING_AGENT_MCP_API_KEYS`.
```json
{
"mcpServers": {
"coding-agent-mcp": {
"transport": "http",
"url": "https://mcp.example.com/coding-agent/mcp",
"headers": {
"Authorization": "Bearer REPLACE_WITH_TOKEN",
"x-workspace-subpath": "project-a"
}
}
}
}
```
This uses the standard Streamable HTTP endpoint. The workspace subpath is bound during the initial `initialize` request and remains immutable for the MCP session.
For a client that only supports the former SSE transport, use `https://mcp.example.com/coding-agent/sse`. The older custom `/events` endpoint is also retained for existing integrations.
## Local stdio
```json
{
"mcpServers": {
"coding-agent-mcp": {
"command": "node",
"args": [
"/absolute/path/to/coding-agent-mcp/src/coding-agent-index.js",
"--root",
"/absolute/path/to/workspace"
]
}
}
}

View File

@ -0,0 +1,6 @@
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {}
}

View File

@ -0,0 +1,13 @@
{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "run_task",
"arguments": {
"runner": "pytest",
"task": "test",
"paths": ["tests"]
}
}
}

View File

@ -0,0 +1,6 @@
{
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
"params": {}
}

View File

@ -0,0 +1,11 @@
{
"mcpServers": {
"coding-agent-mcp": {
"transport": "http",
"url": "https://mcp.example.com/coding-agent/mcp",
"headers": {
"Authorization": "Bearer replace-with-coding-agent-key"
}
}
}
}

View File

@ -0,0 +1,17 @@
{
"name": "coding-agent-mcps",
"version": "1.0.0",
"description": "MCP servers for safe workspace access and controlled command execution",
"type": "module",
"bin": {
"coding-agent-mcp": "./src/coding-agent-index.js"
},
"scripts": {
"start:coding-agent": "node src/coding-agent-index.js",
"start": "node src/coding-agent-index.js",
"test": "node --test"
},
"engines": {
"node": ">=20"
}
}

View File

@ -0,0 +1,23 @@
import fs from "node:fs/promises";
import path from "node:path";
export class AuditLog {
constructor(filePath) {
this.filePath = filePath ? path.resolve(filePath) : null;
this.pending = Promise.resolve();
}
record(event) {
const line = `${JSON.stringify({ timestamp: new Date().toISOString(), ...event })}\n`;
if (!this.filePath) {
process.stderr.write(`[mcp-audit] ${line}`);
return;
}
this.pending = this.pending
.then(() => fs.mkdir(path.dirname(this.filePath), { recursive: true }))
.then(() => fs.appendFile(this.filePath, line, { encoding: "utf8", mode: 0o600 }))
.catch(() => {
// Auditing must not make a coding operation unavailable. Deployment should monitor this path.
});
}
}

View File

@ -0,0 +1,126 @@
#!/usr/bin/env node
import process from "node:process";
import { startHttpToolServer } from "./mcp-http.js";
import { startToolServer } from "./mcp-stdio.js";
import { parseServerArgs } from "./root-args.js";
import { TOOL_DEFINITIONS } from "./tool-definitions.js";
import { COMMAND_TOOL_DEFINITIONS } from "./command-tool-definitions.js";
import { WorkspaceService } from "./workspace-service.js";
import { CommandService } from "./command-service.js";
import { AuditLog } from "./audit-log.js";
const SERVER_NAME = "coding-agent-mcp";
const SERVER_VERSION = "1.0.0";
const COMBINED_TOOL_DEFINITIONS = [
...TOOL_DEFINITIONS,
...COMMAND_TOOL_DEFINITIONS
];
function parseApiKeys() {
const rawValue = process.env.CODING_AGENT_MCP_API_KEYS ?? process.env.MCP_API_KEYS ?? "";
return rawValue
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
function parseCorsOrigins() {
return (process.env.CODING_AGENT_MCP_CORS_ORIGINS ?? "")
.split(",")
.map((value) => value.trim())
.filter(Boolean);
}
function resolveExecutionBackend(transport) {
const configured = process.env.CODING_AGENT_MCP_EXECUTION_BACKEND;
if (configured) return configured;
return transport === "stdio" ? "local" : "disabled";
}
async function main() {
const options = parseServerArgs(process.argv.slice(2), {
binaryName: SERVER_NAME,
envVarName: "CODING_AGENT_MCP_ROOTS"
});
const workspaceService = new WorkspaceService({ roots: options.roots });
const auditLog = new AuditLog(process.env.CODING_AGENT_MCP_AUDIT_LOG);
const commandService = new CommandService({
roots: options.roots,
executionBackend: resolveExecutionBackend(options.transport)
});
const callTool = async (toolName, toolArguments, context) => {
switch (toolName) {
case "list_files":
return workspaceService.listFiles(toolArguments, context);
case "read_file":
return workspaceService.readFile(toolArguments, context);
case "read_files":
return workspaceService.readFiles(toolArguments, context);
case "search_text":
return workspaceService.searchText(toolArguments, context);
case "write_file":
return workspaceService.writeFile(toolArguments, context);
case "read_binary_file":
return workspaceService.readBinaryFile(toolArguments, context);
case "write_binary_file":
return workspaceService.writeBinaryFile(toolArguments, context);
case "apply_patch":
return workspaceService.applyPatch(toolArguments, context);
case "make_directory":
return workspaceService.makeDirectory(toolArguments, context);
case "delete_path":
return workspaceService.deletePath(toolArguments, context);
case "move_path":
return workspaceService.movePath(toolArguments, context);
case "rename_path":
return workspaceService.renamePath(toolArguments, context);
case "file_info":
return workspaceService.fileInfo(toolArguments, context);
case "run_task":
return commandService.runTask({
...toolArguments,
onEvent: context?.emitEvent,
sessionId: context?.sessionId ?? null,
workspaceSubpath: context?.workspaceSubpath ?? null
});
default:
throw new Error(`Unknown tool: ${toolName}`);
}
};
if (options.transport === "stdio" || options.transport === "both") {
startToolServer({
serverName: SERVER_NAME,
serverVersion: SERVER_VERSION,
tools: COMBINED_TOOL_DEFINITIONS,
callTool,
auditLog
});
}
if (options.transport === "http" || options.transport === "both") {
const listener = await startHttpToolServer({
serverName: SERVER_NAME,
serverVersion: SERVER_VERSION,
tools: COMBINED_TOOL_DEFINITIONS,
callTool,
onSessionInitialize: (context) => workspaceService.ensureWorkspace(context),
auditLog,
host: options.host,
port: options.port,
apiKeys: parseApiKeys(),
corsOrigins: parseCorsOrigins(),
requireAuth: true
});
process.stderr.write(
`${SERVER_NAME} HTTP listening on http://${listener.host}:${listener.port}\n`
);
}
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
process.exit(1);
});

View File

@ -0,0 +1,165 @@
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import { buildTaskCommand } from "./execution-policy.js";
const DEFAULT_TIMEOUT_SECONDS = 300;
const DEFAULT_OUTPUT_BYTE_LIMIT = 64 * 1024;
const MAX_TIMEOUT_SECONDS = 1800;
const MAX_OUTPUT_BYTE_LIMIT = 1024 * 1024;
function assertNonEmptyString(value, label) {
if (typeof value !== "string" || value.trim() === "") throw new Error(`${label} must be a non-empty string`);
}
// Treat an empty value emitted by an MCP client as the conventional workspace
// root. This is equivalent to the documented default workdir: ".".
function normalizeWorkdir(value) {
return typeof value === "string" && value.trim() === "" ? "." : value;
}
function assertIntegerInRange(value, label, minimum, maximum) {
if (!Number.isInteger(value) || value < minimum || value > maximum) throw new Error(`${label} must be an integer between ${minimum} and ${maximum}`);
}
function normalizeWorkspaceSubpath(workspaceSubpath) {
if (!workspaceSubpath) return null;
const normalized = String(workspaceSubpath).trim().replace(/\\/g, "/");
if (normalized === "") return null;
if (normalized.startsWith("/")) throw new Error("workspaceSubpath must be relative");
const segments = normalized.split("/").filter(Boolean);
if (segments.length === 0 || segments.some((segment) => segment === "." || segment === ".." || !/^[A-Za-z0-9._-]+$/.test(segment))) throw new Error("workspaceSubpath contains unsupported path segments");
return segments.join(path.sep);
}
function isWithinPath(targetPath, rootPath) {
const relative = path.relative(rootPath, targetPath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function normalizeClientPath(rootRecord, absolutePath) {
const relative = path.relative(rootRecord.resolved, absolutePath);
return (relative === "" ? "." : relative).split(path.sep).join("/");
}
function appendChunk(state, chunk) {
state.totalBytes += chunk.length;
if (state.buffer.length >= state.limit) { state.truncated = true; return; }
const remaining = state.limit - state.buffer.length;
state.buffer = Buffer.concat([state.buffer, chunk.subarray(0, remaining)]);
if (chunk.length > remaining) state.truncated = true;
}
function buildOutputState(limit) {
return { limit, buffer: Buffer.alloc(0), totalBytes: 0, truncated: false };
}
function emitEvent(onEvent, payload) {
try { onEvent(payload); } catch { /* Event streaming is best effort. */ }
}
function safeEnvironment() {
return {
PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
HOME: "/tmp/coding-agent",
TMPDIR: "/tmp/coding-agent",
LANG: "C.UTF-8",
LC_ALL: "C.UTF-8",
CI: "true",
NO_COLOR: "1"
};
}
export class CommandService {
constructor({ roots, executionBackend = "local", defaultTimeoutSeconds = DEFAULT_TIMEOUT_SECONDS, defaultOutputByteLimit = DEFAULT_OUTPUT_BYTE_LIMIT } = {}) {
if (!Array.isArray(roots) || roots.length === 0) throw new Error("At least one command root is required");
if (!["local", "disabled"].includes(executionBackend)) throw new Error("executionBackend must be local or disabled");
this.executionBackend = executionBackend;
this.defaultTimeoutSeconds = defaultTimeoutSeconds;
this.defaultOutputByteLimit = defaultOutputByteLimit;
this.roots = roots.map((rootPath) => {
assertNonEmptyString(rootPath, "root");
const resolved = path.resolve(rootPath);
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) throw new Error(`Command root must be an existing directory: ${resolved}`);
return { configured: rootPath, resolved, real: fs.realpathSync(resolved) };
});
}
getPrimaryRoot() { return this.roots[0]; }
selectRoot(rootHint) {
if (!rootHint) return this.getPrimaryRoot();
const matched = this.roots.find((record) => rootHint === record.configured || rootHint === record.resolved || rootHint === record.real);
if (!matched) throw new Error("Unknown root");
return matched;
}
getScopedRoot(rootHint, sessionId, { createIfMissing = false, workspaceSubpath = null } = {}) {
const baseRootRecord = this.selectRoot(rootHint);
const subpath = normalizeWorkspaceSubpath(workspaceSubpath);
let resolved = baseRootRecord.resolved;
if (subpath) resolved = path.resolve(resolved, subpath);
if (!isWithinPath(resolved, baseRootRecord.resolved)) throw new Error("Resolved session root escapes the allowed command root");
if (createIfMissing && !fs.existsSync(resolved)) fs.mkdirSync(resolved, { recursive: true });
return { configured: baseRootRecord.configured, resolved, real: fs.existsSync(resolved) ? fs.realpathSync(resolved) : resolved, baseRoot: baseRootRecord.resolved, sessionId: sessionId ?? null, workspaceSubpath: subpath };
}
resolveWorkdir(workdir = ".", { root, sessionId, workspaceSubpath, createSessionRoot = false } = {}) {
workdir = normalizeWorkdir(workdir);
assertNonEmptyString(workdir, "workdir");
if (workdir.includes("\0") || path.isAbsolute(workdir)) throw new Error("workdir must be a relative path");
const rootRecord = this.getScopedRoot(root, sessionId, { createIfMissing: createSessionRoot, workspaceSubpath });
const absolutePath = path.resolve(rootRecord.resolved, workdir);
if (!isWithinPath(absolutePath, rootRecord.resolved)) throw new Error("workdir escapes allowed command root");
if (!fs.existsSync(absolutePath) || !fs.lstatSync(absolutePath).isDirectory()) throw new Error("workdir must be an existing directory");
if (!isWithinPath(fs.realpathSync(absolutePath), rootRecord.real)) throw new Error("Resolved workdir escapes the allowed command root");
return { rootRecord, absolutePath, clientPath: normalizeClientPath(rootRecord, absolutePath) };
}
resolveTaskPaths(paths, workdirPath, rootRecord) {
if (!Array.isArray(paths)) throw new Error("paths must be an array");
return paths.map((inputPath) => {
assertNonEmptyString(inputPath, "path");
if (inputPath.includes("\0") || path.isAbsolute(inputPath) || inputPath.startsWith("-")) throw new Error("task paths must be relative paths, not options");
const absolutePath = path.resolve(workdirPath, inputPath);
if (!isWithinPath(absolutePath, workdirPath) || !fs.existsSync(absolutePath)) throw new Error(`Task path is outside the workdir or does not exist: ${inputPath}`);
if (!isWithinPath(fs.realpathSync(absolutePath), rootRecord.real)) throw new Error(`Task path escapes the workspace: ${inputPath}`);
return inputPath;
});
}
async runTask({ workdir = ".", root, runner, task, paths = [], timeoutSeconds = this.defaultTimeoutSeconds, outputByteLimit = this.defaultOutputByteLimit, onEvent = () => {}, sessionId = null, workspaceSubpath = null } = {}) {
if (this.executionBackend === "disabled") throw new Error("Command execution is disabled. Configure an isolated execution backend before enabling it.");
assertIntegerInRange(timeoutSeconds, "timeoutSeconds", 1, MAX_TIMEOUT_SECONDS);
assertIntegerInRange(outputByteLimit, "outputByteLimit", 1024, MAX_OUTPUT_BYTE_LIMIT);
const { rootRecord, absolutePath, clientPath } = this.resolveWorkdir(workdir, { root, sessionId, workspaceSubpath, createSessionRoot: true });
const safePaths = this.resolveTaskPaths(paths, absolutePath, rootRecord);
const command = buildTaskCommand({ runner, task, paths: safePaths });
const [commandName, ...args] = command;
const stdoutState = buildOutputState(outputByteLimit);
const stderrState = buildOutputState(outputByteLimit);
const startedAt = Date.now();
let timedOut = false;
let exitCode = null;
let signal = null;
emitEvent(onEvent, { type: "task.started", workdir: clientPath, runner, task });
const child = spawn(commandName, args, { cwd: absolutePath, shell: false, stdio: ["ignore", "pipe", "pipe"], env: safeEnvironment() });
child.stdout.on("data", (chunk) => { appendChunk(stdoutState, chunk); emitEvent(onEvent, { type: "task.stdout", chunk: chunk.toString("utf8") }); });
child.stderr.on("data", (chunk) => { appendChunk(stderrState, chunk); emitEvent(onEvent, { type: "task.stderr", chunk: chunk.toString("utf8") }); });
const timeoutHandle = setTimeout(() => {
timedOut = true;
emitEvent(onEvent, { type: "task.timeout", afterSeconds: timeoutSeconds });
child.kill("SIGTERM");
setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, 2000).unref();
}, timeoutSeconds * 1000);
try {
await new Promise((resolve, reject) => {
child.on("error", (error) => reject(new Error(`Failed to start task: ${error.message}`)));
child.on("close", (code, closeSignal) => { exitCode = code; signal = closeSignal; resolve(); });
});
} finally { clearTimeout(timeoutHandle); }
const durationMs = Date.now() - startedAt;
emitEvent(onEvent, { type: "task.completed", exitCode, signal, timedOut, durationMs });
return { root: rootRecord.resolved, workdir: clientPath, runner, task, paths: safePaths, exitCode, signal, timedOut, durationMs, stdout: stdoutState.buffer.toString("utf8"), stderr: stderrState.buffer.toString("utf8"), stdoutBytes: stdoutState.totalBytes, stderrBytes: stderrState.totalBytes, stdoutTruncated: stdoutState.truncated, stderrTruncated: stderrState.truncated };
}
}

View File

@ -0,0 +1,49 @@
const pathProperty = {
type: "string",
description: "Path relative to the execution workspace; use '.' for its root. Empty strings are treated as '.'."
};
export const COMMAND_TOOL_DEFINITIONS = [
{
name: "run_task",
description: "Run a typed test, build, lint, or format task in the execution workspace. Use workdir: '.' for the root. Arbitrary commands, arguments, flags, and npm script names are not accepted.",
inputSchema: {
type: "object",
properties: {
workdir: { ...pathProperty, default: "." },
runner: {
type: "string",
enum: ["pytest", "npm", "pnpm", "yarn", "maven", "gradle", "go", "cargo"],
description: "Language ecosystem runner available in the execution image"
},
task: {
type: "string",
enum: ["test", "build", "lint", "format"],
description: "The operation to run"
},
paths: {
type: "array",
maxItems: 100,
items: {
type: "string",
description: "Existing path relative to workdir; supported only by pytest"
},
default: []
},
timeoutSeconds: {
type: "integer",
minimum: 1,
maximum: 1800,
default: 300
},
outputByteLimit: {
type: "integer",
minimum: 1024,
maximum: 1048576,
default: 65536
}
},
required: ["runner", "task"]
}
}
];

View File

@ -0,0 +1,66 @@
const RUNNERS = new Set(["pytest", "npm", "pnpm", "yarn", "maven", "gradle", "go", "cargo"]);
const TASKS = new Set(["test", "build", "lint", "format"]);
function assertEnum(value, allowed, label) {
if (typeof value !== "string" || !allowed.has(value)) {
throw new Error(`${label} is not supported`);
}
}
function noPaths(paths, runner) {
if (!Array.isArray(paths) || paths.length === 0) {
return;
}
throw new Error(`${runner} does not accept paths; select the task only`);
}
export function buildTaskCommand({ runner, task, paths = [] }) {
assertEnum(runner, RUNNERS, "runner");
assertEnum(task, TASKS, "task");
if (!Array.isArray(paths) || paths.some((value) => typeof value !== "string" || value.trim() === "")) {
throw new Error("paths must be an array of non-empty strings");
}
switch (runner) {
case "pytest":
if (task !== "test") {
throw new Error("pytest supports only the test task");
}
return ["pytest", ...paths];
case "npm":
case "pnpm":
case "yarn":
noPaths(paths, runner);
return task === "test" ? [runner, "test"] : [runner, "run", task];
case "go":
noPaths(paths, runner);
if (task !== "test") {
throw new Error("go supports only the test task");
}
return ["go", "test", "./..."];
case "cargo":
noPaths(paths, runner);
return {
test: ["cargo", "test"],
build: ["cargo", "build"],
lint: ["cargo", "clippy"],
format: ["cargo", "fmt"]
}[task];
case "maven":
noPaths(paths, runner);
if (task === "test") return ["mvn", "test"];
if (task === "build") return ["mvn", "package", "-DskipTests"];
throw new Error("maven supports test and build tasks");
case "gradle":
noPaths(paths, runner);
return {
test: ["gradle", "test"],
build: ["gradle", "build"],
lint: ["gradle", "check"],
format: ["gradle", "spotlessApply"]
}[task];
default:
throw new Error(`Unsupported runner: ${runner}`);
}
}

View File

@ -0,0 +1,192 @@
const PROTOCOL_VERSION = "2024-11-05";
function isDebugEnabled() {
const rawValue = process.env.MCP_DEBUG_REQUESTS ?? "";
return ["1", "true", "yes", "on"].includes(rawValue.trim().toLowerCase());
}
function debugLog(payload) {
if (!isDebugEnabled()) {
return;
}
try {
process.stderr.write(`[mcp-debug] ${JSON.stringify(payload)}\n`);
} catch {
process.stderr.write("[mcp-debug] failed to serialize debug payload\n");
}
}
function formatToolResult(result) {
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2)
}
],
structuredContent: result,
isError: false
};
}
function formatToolError(error) {
const message = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: message
}
],
isError: true
};
}
function makeResponse(id, result) {
return {
jsonrpc: "2.0",
id,
result
};
}
function makeError(id, code, message, data) {
return {
jsonrpc: "2.0",
id,
error: {
code,
message,
data
}
};
}
export function createToolMessageHandler({ serverName, serverVersion, tools, callTool, auditLog = null }) {
const handlers = {
initialize: async (_params, context) => ({
protocolVersion: context.protocolVersion ?? PROTOCOL_VERSION,
capabilities: {
tools: {}
},
serverInfo: {
name: serverName,
version: serverVersion
}
}),
ping: async () => ({}),
"tools/list": async () => ({
tools
}),
"tools/call": async (params, context, requestId) => {
const toolName = params?.name;
const toolArguments = params?.arguments ?? {};
if (typeof toolName !== "string" || toolName.trim() === "") {
throw new Error("Tool name is required");
}
debugLog({
event: "tools.call",
requestId,
toolName,
sessionId: context.sessionId ?? null,
workspaceSubpath: context.workspaceSubpath ?? null
});
context.emitEvent?.({
type: "tool.started",
requestId,
toolName
});
try {
const result = await callTool(toolName, toolArguments, context);
auditLog?.record({ event: "tool.completed", toolName, requestId, subject: context.subject ?? null, sessionId: context.sessionId ?? null });
debugLog({
event: "tools.call.completed",
requestId,
toolName,
sessionId: context.sessionId ?? null,
workspaceSubpath: context.workspaceSubpath ?? null
});
context.emitEvent?.({
type: "tool.completed",
requestId,
toolName
});
return formatToolResult(result);
} catch (error) {
auditLog?.record({ event: "tool.failed", toolName, requestId, subject: context.subject ?? null, sessionId: context.sessionId ?? null, error: error instanceof Error ? error.message : String(error) });
debugLog({
event: "tools.call.failed",
requestId,
toolName,
sessionId: context.sessionId ?? null,
workspaceSubpath: context.workspaceSubpath ?? null,
message: error instanceof Error ? error.message : String(error)
});
context.emitEvent?.({
type: "tool.failed",
requestId,
toolName,
message: error instanceof Error ? error.message : String(error)
});
return formatToolError(error);
}
}
};
return {
async handleMessage(message, context = {}) {
const effectiveContext = {
emitEvent: context.emitEvent ?? (() => {}),
sessionId: context.sessionId ?? null,
workspaceSubpath: context.workspaceSubpath ?? null,
subject: context.subject ?? null,
protocolVersion: context.protocolVersion ?? PROTOCOL_VERSION
};
if (message?.jsonrpc !== "2.0") {
if (message?.id !== undefined) {
return makeError(message.id, -32600, "Invalid JSON-RPC version");
}
return null;
}
if (!message.method) {
return null;
}
if (message.method === "notifications/initialized" || message.method === "$/cancelRequest") {
return null;
}
const handler = handlers[message.method];
if (!handler) {
if (message.id !== undefined) {
return makeError(message.id, -32601, `Method not found: ${message.method}`);
}
return null;
}
try {
const result = await handler(message.params, effectiveContext, message.id);
if (message.id !== undefined) {
return makeResponse(message.id, result);
}
return null;
} catch (error) {
if (message.id !== undefined) {
return makeError(
message.id,
-32000,
error instanceof Error ? error.message : "Internal server error"
);
}
return null;
}
}
};
}

View File

@ -0,0 +1,367 @@
import http from "node:http";
import { randomUUID, timingSafeEqual } from "node:crypto";
import { URL } from "node:url";
import { createToolMessageHandler } from "./mcp-core.js";
function writeSseEvent(response, eventName, payload) {
return writeResponse(response, `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`);
}
function canWriteResponse(response) {
return !response.destroyed && !response.writableEnded;
}
function writeResponse(response, body) {
if (!canWriteResponse(response)) return false;
try {
response.write(body);
return true;
} catch {
return false;
}
}
function endResponse(response, statusCode, headers = {}, body = undefined) {
if (!canWriteResponse(response)) return false;
try {
response.writeHead(statusCode, headers);
response.end(body);
return true;
} catch {
return false;
}
}
function collectRequestBody(request, limitBytes = 1024 * 1024) {
return new Promise((resolve, reject) => {
let totalBytes = 0;
const chunks = [];
request.on("data", (chunk) => {
totalBytes += chunk.length;
if (totalBytes > limitBytes) {
reject(new Error("Request body too large"));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
request.on("error", reject);
});
}
function sendJson(response, statusCode, body, headers = {}) {
return endResponse(response, statusCode, {
"content-type": "application/json; charset=utf-8",
...headers
}, JSON.stringify(body));
}
function unauthorized(response) {
sendJson(response, 401, { error: "Unauthorized" }, { "www-authenticate": 'Bearer realm="mcp"' });
}
function normalizeApiKeys(apiKeys) {
const entries = new Map();
for (const item of apiKeys ?? []) {
if (typeof item !== "string" || item.trim() === "") continue;
const value = item.trim();
const separator = value.indexOf("=");
const subject = separator === -1 ? "shared" : value.slice(0, separator).trim();
const secret = separator === -1 ? value : value.slice(separator + 1).trim();
if (subject === "" || secret === "") throw new Error("API key entries must be TOKEN or SUBJECT=TOKEN");
entries.set(subject, Buffer.from(secret));
}
return entries;
}
function extractApiKey(request) {
const authorization = request.headers.authorization;
if (typeof authorization === "string") {
const match = authorization.match(/^Bearer\s+(.+)$/i);
if (match) return match[1].trim();
}
const apiKey = request.headers["x-api-key"];
return typeof apiKey === "string" ? apiKey.trim() : null;
}
function authenticate(request, apiKeys) {
const candidate = extractApiKey(request);
if (!candidate) return null;
const candidateBuffer = Buffer.from(candidate);
for (const [subject, secret] of apiKeys) {
if (secret.length === candidateBuffer.length && timingSafeEqual(secret, candidateBuffer)) return subject;
}
return null;
}
function extractWorkspaceSubpath(request, requestUrl) {
const headerValue = request.headers["x-workspace-subpath"];
if (typeof headerValue === "string" && headerValue.trim() !== "") return headerValue.trim();
const queryValue = requestUrl.searchParams.get("workspaceSubpath");
return queryValue?.trim() || null;
}
function configureCors(request, response, corsOrigins) {
const origin = request.headers.origin;
if (typeof origin !== "string" || !corsOrigins.has(origin)) return false;
response.setHeader("Access-Control-Allow-Origin", origin);
response.setHeader("Access-Control-Allow-Headers", "content-type, x-session-id, mcp-session-id, mcp-protocol-version, x-workspace-subpath, authorization, x-api-key");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
response.setHeader("Vary", "Origin");
return true;
}
function openSse(response, headers = {}) {
if (!canWriteResponse(response)) return false;
try {
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
...headers
});
return true;
} catch {
return false;
}
}
function addSseListener(request, response, session, kind) {
const listener = { response, kind };
let closed = false;
const cleanup = () => {
if (closed) return;
closed = true;
clearInterval(keepAlive);
session.listeners.delete(listener);
if (session.listeners.size === 0 && session.closeWhenDisconnected) session.delete();
};
const keepAlive = setInterval(() => {
if (!writeResponse(response, ": keep-alive\n\n")) cleanup();
}, 15000);
keepAlive.unref();
session.listeners.add(listener);
request.once("close", cleanup);
response.once("close", cleanup);
response.once("error", cleanup);
}
function emitCustomEvent(session, payload, serverName) {
for (const listener of session.listeners) {
if (listener.kind !== "custom") continue;
if (!writeSseEvent(listener.response, "mcp.event", { sessionId: session.id, serverName, ...payload })) {
session.listeners.delete(listener);
}
}
}
function emitLegacyMessage(session, payload) {
for (const listener of session.listeners) {
if (listener.kind !== "legacy" && listener.kind !== "streamable") continue;
if (!writeSseEvent(listener.response, "message", payload)) session.listeners.delete(listener);
}
}
function parseJsonRpc(rawBody) {
const message = JSON.parse(rawBody);
if (Array.isArray(message)) throw new Error("Batch requests are not supported");
return message;
}
function sessionHeaders(session) {
return {
"mcp-session-id": session.id,
"mcp-protocol-version": session.protocolVersion
};
}
export function startHttpToolServer({ serverName, serverVersion, tools, callTool, onSessionInitialize = null, auditLog = null, host = "127.0.0.1", port = 3000, apiKeys = [], corsOrigins = [], requireAuth = true }) {
const credentials = normalizeApiKeys(apiKeys);
if (requireAuth && credentials.size === 0) throw new Error("HTTP transport requires at least one API key");
const allowedOrigins = new Set(corsOrigins.filter(Boolean));
const { handleMessage } = createToolMessageHandler({ serverName, serverVersion, tools, callTool, auditLog });
const sessions = new Map();
function createSession({ subject, workspaceSubpath, protocolVersion = "2024-11-05", closeWhenDisconnected = false }) {
const id = randomUUID();
const session = {
id,
subject,
workspaceSubpath,
protocolVersion,
closeWhenDisconnected,
listeners: new Set(),
createdAt: Date.now(),
delete: () => sessions.delete(id)
};
sessions.set(id, session);
return session;
}
async function handleSessionMessage({ request, response, requestUrl, subject, session, message, responseMode }) {
const requestedSubpath = extractWorkspaceSubpath(request, requestUrl);
if (requestedSubpath !== null && requestedSubpath !== session.workspaceSubpath) {
return sendJson(response, 400, { error: "workspaceSubpath is bound when the session is opened" }, responseMode === "streamable" ? sessionHeaders(session) : {});
}
const rpcResponse = await handleMessage(message, {
sessionId: session.id,
workspaceSubpath: session.workspaceSubpath,
subject,
protocolVersion: session.protocolVersion,
emitEvent: (payload) => emitCustomEvent(session, payload, serverName)
});
if (responseMode === "legacy") {
if (rpcResponse !== null) emitLegacyMessage(session, rpcResponse);
endResponse(response, 202);
return;
}
if (rpcResponse === null) {
endResponse(response, 202, responseMode === "streamable" ? sessionHeaders(session) : {});
return;
}
sendJson(response, 200, rpcResponse, responseMode === "streamable" ? sessionHeaders(session) : {});
}
async function startStreamableSession({ request, response, requestUrl, subject, message }) {
if (message?.method !== "initialize") return sendJson(response, 401, { error: "A live session owned by this identity is required" });
const requestedVersion = message.params?.protocolVersion;
const protocolVersion = typeof requestedVersion === "string" && requestedVersion !== "" ? requestedVersion : "2025-03-26";
const session = createSession({
subject,
workspaceSubpath: extractWorkspaceSubpath(request, requestUrl),
protocolVersion
});
try {
await onSessionInitialize?.({
sessionId: session.id,
subject: session.subject,
workspaceSubpath: session.workspaceSubpath,
protocolVersion: session.protocolVersion
});
} catch (error) {
session.delete();
return sendJson(response, 500, {
error: `Unable to initialize workspace: ${error instanceof Error ? error.message : String(error)}`
});
}
return handleSessionMessage({ request, response, requestUrl, subject, session, message, responseMode: "streamable" });
}
async function handleMcpPost({ request, response, requestUrl, subject }) {
let message;
try { message = parseJsonRpc(await collectRequestBody(request)); }
catch (error) { return sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) }); }
const standardSessionId = request.headers["mcp-session-id"];
if (typeof standardSessionId === "string" && standardSessionId !== "") {
const session = sessions.get(standardSessionId);
if (!session || session.subject !== subject) return sendJson(response, 404, { error: "Unknown MCP session" });
return handleSessionMessage({ request, response, requestUrl, subject, session, message, responseMode: "streamable" });
}
const customSessionId = request.headers["x-session-id"];
if (typeof customSessionId === "string" && customSessionId !== "") {
const session = sessions.get(customSessionId);
if (!session || session.subject !== subject) return sendJson(response, 401, { error: "A live session owned by this identity is required" });
return handleSessionMessage({ request, response, requestUrl, subject, session, message, responseMode: "custom" });
}
return startStreamableSession({ request, response, requestUrl, subject, message });
}
function openLegacySse({ request, response, requestUrl, subject }) {
const session = createSession({
subject,
workspaceSubpath: extractWorkspaceSubpath(request, requestUrl),
closeWhenDisconnected: true
});
if (!openSse(response)) {
session.delete();
return;
}
addSseListener(request, response, session, "legacy");
// A relative URI preserves a reverse-proxy path prefix such as /coding-agent/.
if (!writeSseEvent(response, "endpoint", `message?sessionId=${encodeURIComponent(session.id)}`)) session.delete();
}
const server = http.createServer(async (request, response) => {
// A client may cancel a slow MCP call while Node is flushing its response.
// Without this listener, an asynchronous EPIPE/ECONNRESET event is fatal.
response.on("error", () => {});
const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? `${host}:${port}`}`);
const corsAllowed = configureCors(request, response, allowedOrigins);
if (request.method === "OPTIONS") {
if (!corsAllowed) return sendJson(response, 403, { error: "Origin is not allowed" });
endResponse(response, 204);
return;
}
const subject = credentials.size === 0 ? "local" : authenticate(request, credentials);
if (requireAuth && !subject) return unauthorized(response);
if (request.method === "GET" && requestUrl.pathname === "/health") return sendJson(response, 200, { ok: true, serverName, transport: "streamable-http+sse" });
// Existing custom HTTP+SSE transport. Retained for existing callers.
if (request.method === "GET" && requestUrl.pathname === "/events") {
const session = createSession({ subject, workspaceSubpath: extractWorkspaceSubpath(request, requestUrl), closeWhenDisconnected: true });
if (!openSse(response, { "x-session-id": session.id })) {
session.delete();
return;
}
addSseListener(request, response, session, "custom");
if (!writeSseEvent(response, "session.ready", { sessionId: session.id, serverName })) session.delete();
return;
}
// Streamable HTTP uses /mcp. A GET without Mcp-Session-Id is also a
// legacy SSE handshake, which lets transport-auto-detecting clients fall back safely.
if (requestUrl.pathname === "/mcp" && request.method === "GET") {
const standardSessionId = request.headers["mcp-session-id"];
if (typeof standardSessionId === "string" && standardSessionId !== "") {
const session = sessions.get(standardSessionId);
if (!session || session.subject !== subject) return sendJson(response, 404, { error: "Unknown MCP session" });
if (!openSse(response, sessionHeaders(session))) return;
addSseListener(request, response, session, "streamable");
return;
}
return openLegacySse({ request, response, requestUrl, subject });
}
if (requestUrl.pathname === "/sse" && request.method === "GET") return openLegacySse({ request, response, requestUrl, subject });
if (requestUrl.pathname === "/message" && request.method === "POST") {
const sessionId = requestUrl.searchParams.get("sessionId");
const session = sessionId ? sessions.get(sessionId) : null;
if (!session || session.subject !== subject) return sendJson(response, 401, { error: "A live session owned by this identity is required" });
try {
const message = parseJsonRpc(await collectRequestBody(request));
return handleSessionMessage({ request, response, requestUrl, subject, session, message, responseMode: "legacy" });
} catch (error) {
return sendJson(response, 400, { error: error instanceof Error ? error.message : String(error) });
}
}
if (requestUrl.pathname === "/mcp" && request.method === "POST") return handleMcpPost({ request, response, requestUrl, subject });
if (requestUrl.pathname === "/mcp" && request.method === "DELETE") {
const sessionId = request.headers["mcp-session-id"];
const session = typeof sessionId === "string" ? sessions.get(sessionId) : null;
if (!session || session.subject !== subject) return sendJson(response, 404, { error: "Unknown MCP session" });
session.delete();
endResponse(response, 204);
return;
}
return sendJson(response, 404, { error: "Not found" });
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolve({ server, host, port: server.address().port });
});
});
}

View File

@ -0,0 +1,69 @@
import process from "node:process";
import { createToolMessageHandler } from "./mcp-core.js";
function createMessageReader(onMessage) {
let buffer = Buffer.alloc(0);
return (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (true) {
const separatorIndex = buffer.indexOf("\r\n\r\n");
if (separatorIndex === -1) {
return;
}
const headerText = buffer.slice(0, separatorIndex).toString("utf8");
const contentLengthHeader = headerText
.split("\r\n")
.find((headerLine) => headerLine.toLowerCase().startsWith("content-length:"));
if (!contentLengthHeader) {
throw new Error("Missing Content-Length header");
}
const contentLength = Number(contentLengthHeader.split(":")[1].trim());
const messageStart = separatorIndex + 4;
const messageEnd = messageStart + contentLength;
if (buffer.length < messageEnd) {
return;
}
const payload = buffer.slice(messageStart, messageEnd).toString("utf8");
buffer = buffer.slice(messageEnd);
onMessage(JSON.parse(payload));
}
};
}
function writeMessage(message) {
const payload = Buffer.from(JSON.stringify(message), "utf8");
process.stdout.write(`Content-Length: ${payload.length}\r\n\r\n`);
process.stdout.write(payload);
}
export function startToolServer({ serverName, serverVersion, tools, callTool, auditLog = null }) {
const { handleMessage } = createToolMessageHandler({
serverName,
serverVersion,
tools,
callTool,
auditLog
});
const reader = createMessageReader(async (message) => {
const response = await handleMessage(message);
if (response) {
writeMessage(response);
}
});
process.stdin.on("data", (chunk) => {
try {
reader(chunk);
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.stack : String(error)}\n`);
}
});
}

View File

@ -0,0 +1,418 @@
function splitPreservingFinalNewline(text) {
if (text === "") {
return { lines: [], hasTrailingNewline: false };
}
const normalized = text.replace(/\r\n/g, "\n");
const hasTrailingNewline = normalized.endsWith("\n");
const body = hasTrailingNewline ? normalized.slice(0, -1) : normalized;
return {
lines: body === "" ? [] : body.split("\n"),
hasTrailingNewline
};
}
function normalizePatchPath(value) {
return value.replace(/\\/g, "/").replace(/^\.\/+/, "");
}
function assertPatchTarget(headerPath, expectedPath) {
if (!expectedPath) return;
const normalizedHeader = normalizePatchPath(headerPath);
const normalizedExpected = normalizePatchPath(expectedPath);
if (normalizedHeader !== normalizedExpected) {
throw new Error(
`Patch target mismatch: expected ${normalizedExpected} but patch targets ${normalizedHeader}`
);
}
}
function parseCodexPatch(patchText, expectedPath) {
const normalized = patchText.replace(/\r\n/g, "\n");
const lines = normalized.split("\n");
if (lines[0] !== "*** Begin Patch") {
throw new Error("Patch must start with *** Begin Patch");
}
const headerLine = lines[1];
let operation;
let headerPath;
if (headerLine?.startsWith("*** Update File: ")) {
operation = "update";
headerPath = headerLine.slice("*** Update File: ".length).trim();
} else if (headerLine?.startsWith("*** Add File: ")) {
operation = "add";
headerPath = headerLine.slice("*** Add File: ".length).trim();
} else if (headerLine?.startsWith("*** Delete File: ")) {
operation = "delete";
headerPath = headerLine.slice("*** Delete File: ".length).trim();
} else {
throw new Error("Patch header must declare Update File, Add File, or Delete File");
}
assertPatchTarget(headerPath, expectedPath);
let index = 2;
const hunks = [];
while (index < lines.length) {
const line = lines[index];
if (line === "*** End Patch") {
if (hunks.length === 0) {
throw new Error("Patch does not contain any hunks");
}
return {
format: "codex",
operation,
targetPath: normalizePatchPath(headerPath),
hunks
};
}
if (!line.startsWith("@@")) {
throw new Error(`Expected hunk header at line ${index + 1}`);
}
index += 1;
const operations = [];
while (index < lines.length) {
const current = lines[index];
if (current === "*** End Patch" || current.startsWith("@@")) {
break;
}
if (current === "*** End of File") {
index += 1;
continue;
}
const prefix = current[0];
if (![" ", "+", "-"].includes(prefix)) {
throw new Error(`Invalid patch line at ${index + 1}: ${current}`);
}
operations.push({
type: prefix,
text: current.slice(1)
});
index += 1;
}
if (operations.length === 0) {
throw new Error(`Empty hunk at line ${index + 1}`);
}
if (operation === "add" && operations.some((item) => item.type !== "+")) {
throw new Error("Add File hunks may only contain + lines");
}
if (operation === "delete" && operations.some((item) => item.type !== "-")) {
throw new Error("Delete File hunks may only contain - lines");
}
if (operation === "update" && operations.every((item) => item.type === "+")) {
throw new Error("Pure insertion hunks are not supported without context");
}
hunks.push(operations);
}
throw new Error("Patch must end with *** End Patch");
}
function parseUnifiedPath(line, prefix) {
if (!line.startsWith(prefix)) {
throw new Error(`Expected ${prefix.trim()} file header`);
}
const rawPath = line.slice(prefix.length).split("\t", 1)[0].trim();
if (rawPath === "/dev/null") return null;
return normalizePatchPath(rawPath.replace(/^[ab]\//, ""));
}
function parseUnifiedHunkHeader(line) {
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/.exec(line);
if (!match) throw new Error(`Invalid unified diff hunk header: ${line}`);
return {
oldStart: Number(match[1]),
oldCount: match[2] === undefined ? 1 : Number(match[2]),
newStart: Number(match[3]),
newCount: match[4] === undefined ? 1 : Number(match[4])
};
}
function parseUnifiedDiff(patchText, expectedPath) {
const lines = patchText.replace(/\r\n/g, "\n").split("\n");
let index = lines.findIndex((line) => line.startsWith("--- "));
if (index === -1) {
throw new Error("Patch must be a Codex patch or a unified diff beginning with --- and +++ file headers");
}
const oldPath = parseUnifiedPath(lines[index], "--- ");
index += 1;
const newPath = parseUnifiedPath(lines[index] ?? "", "+++ ");
index += 1;
if (!oldPath && !newPath) throw new Error("Unified diff cannot use /dev/null for both file paths");
if (oldPath && newPath && oldPath !== newPath) {
throw new Error("apply_patch accepts exactly one target file");
}
const operation = oldPath === null ? "add" : newPath === null ? "delete" : "update";
const targetPath = newPath ?? oldPath;
assertPatchTarget(targetPath, expectedPath);
const hunks = [];
while (index < lines.length) {
const line = lines[index];
if (line === "") {
index += 1;
continue;
}
if (line.startsWith("diff --git ") || line.startsWith("--- ")) {
throw new Error("apply_patch accepts exactly one target file");
}
if (!line.startsWith("@@")) throw new Error(`Expected unified diff hunk header at line ${index + 1}`);
const header = parseUnifiedHunkHeader(line);
index += 1;
const operations = [];
let oldLines = 0;
let newLines = 0;
while (index < lines.length && !lines[index].startsWith("@@")) {
const current = lines[index];
if (current === "\\ No newline at end of file") {
index += 1;
continue;
}
if (current === "" && index === lines.length - 1) {
index += 1;
break;
}
const type = current[0];
if (![' ', '+', '-'].includes(type)) {
throw new Error(`Invalid unified diff line at ${index + 1}: ${current}`);
}
operations.push({ type, text: current.slice(1) });
if (type !== "+") oldLines += 1;
if (type !== "-") newLines += 1;
index += 1;
}
if (operations.length === 0) throw new Error(`Empty unified diff hunk at line ${index + 1}`);
if (oldLines !== header.oldCount || newLines !== header.newCount) {
throw new Error("Unified diff hunk line counts do not match its header");
}
hunks.push({ ...header, operations });
}
if (hunks.length === 0) throw new Error("Unified diff does not contain any hunks");
return { format: "unified", operation, targetPath, hunks };
}
function parsePatch(patchText, expectedPath) {
return patchText.replace(/\r\n/g, "\n").startsWith("*** Begin Patch")
? parseCodexPatch(patchText, expectedPath)
: parseUnifiedDiff(patchText, expectedPath);
}
function findHunkStart(lines, operations, startIndex) {
const anchor = operations.filter((operation) => operation.type !== "+");
for (let candidateIndex = startIndex; candidateIndex <= lines.length - anchor.length; candidateIndex += 1) {
let lineIndex = candidateIndex;
let matches = true;
for (const operation of anchor) {
if (lines[lineIndex] !== operation.text) {
matches = false;
break;
}
lineIndex += 1;
}
if (matches) {
return candidateIndex;
}
}
return -1;
}
function renderAddedFile(hunks) {
const lines = [];
for (const hunk of hunks) {
for (const operation of (hunk.operations ?? hunk)) {
lines.push(operation.text);
}
}
return lines.length === 0 ? "" : `${lines.join("\n")}\n`;
}
function assertDeletePatchMatchesFile(lines, hunks) {
const expectedLines = [];
for (const hunk of hunks) {
for (const operation of (hunk.operations ?? hunk)) {
expectedLines.push(operation.text);
}
}
if (expectedLines.length !== lines.length) {
throw new Error("Delete File patch does not match the current file contents");
}
for (let index = 0; index < expectedLines.length; index += 1) {
if (expectedLines[index] !== lines[index]) {
throw new Error(`Delete File patch mismatch near line ${index + 1}`);
}
}
}
function applyUpdatePatch(originalText, hunks) {
const { lines: originalLines, hasTrailingNewline } = splitPreservingFinalNewline(originalText);
const outputLines = [];
let sourceIndex = 0;
for (const operations of hunks) {
const startIndex = findHunkStart(originalLines, operations, sourceIndex);
if (startIndex === -1) {
const anchorPreview = operations
.filter((operation) => operation.type !== "+")
.map((operation) => operation.text)
.join("\\n");
throw new Error(`Could not locate hunk in target file: ${anchorPreview}`);
}
outputLines.push(...originalLines.slice(sourceIndex, startIndex));
let cursor = startIndex;
for (const operation of operations) {
if (operation.type === " ") {
if (originalLines[cursor] !== operation.text) {
throw new Error(`Context mismatch while applying patch near line ${cursor + 1}`);
}
outputLines.push(operation.text);
cursor += 1;
continue;
}
if (operation.type === "-") {
if (originalLines[cursor] !== operation.text) {
throw new Error(`Delete mismatch while applying patch near line ${cursor + 1}`);
}
cursor += 1;
continue;
}
outputLines.push(operation.text);
}
sourceIndex = cursor;
}
outputLines.push(...originalLines.slice(sourceIndex));
let updatedText = outputLines.join("\n");
if (hasTrailingNewline) {
updatedText += "\n";
}
return {
action: "update",
updatedText,
hunksApplied: hunks.length
};
}
function applyUnifiedUpdatePatch(originalText, hunks) {
const { lines: originalLines, hasTrailingNewline } = splitPreservingFinalNewline(originalText);
const outputLines = [];
let sourceIndex = 0;
for (const hunk of hunks) {
// In a zero-length old range, Git numbers the insertion point as the line
// before it. For all other ranges the number is one-based.
const startIndex = hunk.oldCount === 0 ? hunk.oldStart : hunk.oldStart - 1;
if (startIndex < sourceIndex || startIndex > originalLines.length) {
throw new Error(`Unified diff hunk starts outside the target file near line ${hunk.oldStart}`);
}
outputLines.push(...originalLines.slice(sourceIndex, startIndex));
let cursor = startIndex;
for (const operation of hunk.operations) {
if (operation.type === "+") {
outputLines.push(operation.text);
continue;
}
if (originalLines[cursor] !== operation.text) {
throw new Error(`Unified diff does not match the target file near line ${cursor + 1}`);
}
if (operation.type === " ") outputLines.push(operation.text);
cursor += 1;
}
sourceIndex = cursor;
}
outputLines.push(...originalLines.slice(sourceIndex));
let updatedText = outputLines.join("\n");
if (hasTrailingNewline) updatedText += "\n";
return { action: "update", updatedText, hunksApplied: hunks.length };
}
export function applyTextPatch({ originalText, patchText, expectedPath, fileExists }) {
const parsed = parsePatch(patchText, expectedPath);
if (parsed.operation === "add") {
if (fileExists) {
throw new Error("Cannot apply Add File patch to an existing file");
}
return {
action: "add",
updatedText: renderAddedFile(parsed.hunks),
hunksApplied: parsed.hunks.length
};
}
if (!fileExists) {
throw new Error("Patch target does not exist");
}
if (parsed.operation === "delete") {
const { lines: originalLines } = splitPreservingFinalNewline(originalText);
assertDeletePatchMatchesFile(originalLines, parsed.hunks);
return {
action: "delete",
hunksApplied: parsed.hunks.length
};
}
return parsed.format === "unified"
? applyUnifiedUpdatePatch(originalText, parsed.hunks)
: applyUpdatePatch(originalText, parsed.hunks);
}

View File

@ -0,0 +1,93 @@
import path from "node:path";
import process from "node:process";
function parsePort(value) {
const port = Number(value);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error("--port must be an integer between 0 and 65535");
}
return port;
}
export function parseServerArgs(argv, { binaryName, envVarName }) {
const roots = [];
let transport = "stdio";
let host = "127.0.0.1";
let port = 3000;
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--root") {
const nextValue = argv[index + 1];
if (!nextValue) {
throw new Error("--root requires a value");
}
roots.push(path.resolve(nextValue));
index += 1;
continue;
}
if (argument === "--transport") {
const nextValue = argv[index + 1];
if (!nextValue || !["stdio", "http", "both"].includes(nextValue)) {
throw new Error("--transport must be one of: stdio, http, both");
}
transport = nextValue;
index += 1;
continue;
}
if (argument === "--host") {
const nextValue = argv[index + 1];
if (!nextValue) {
throw new Error("--host requires a value");
}
host = nextValue;
index += 1;
continue;
}
if (argument === "--port") {
const nextValue = argv[index + 1];
if (!nextValue) {
throw new Error("--port requires a value");
}
port = parsePort(nextValue);
index += 1;
continue;
}
if (argument === "--help" || argument === "-h") {
process.stderr.write(
`Usage: ${binaryName} [--root /absolute/path]... [--transport stdio|http|both] [--host 127.0.0.1] [--port 3000]\n` +
"If no root is provided, the current working directory is used as the only root.\n"
);
process.exit(0);
}
throw new Error(`Unknown argument: ${argument}`);
}
const resolvedRoots = roots.length > 0
? roots
: process.env[envVarName]
? process.env[envVarName]
.split(path.delimiter)
.map((value) => value.trim())
.filter(Boolean)
.map((value) => path.resolve(value))
: [process.cwd()];
return {
roots: resolvedRoots,
transport,
host,
port
};
}

View File

@ -0,0 +1,78 @@
const pathProperty = {
type: "string",
description: "Path relative to the execution workspace; use '.' for its root. Empty strings are treated as '.'. Absolute paths and '..' are rejected."
};
const createParentsProperty = { type: "boolean", default: true };
const patchProperty = {
type: "string",
minLength: 1,
description: `Edit one existing file using either a Codex patch or a standard unified diff. Read the target file immediately before editing. For a new file, use write_file instead.\n\nCodex patch example:\n*** Begin Patch\n*** Update File: src/app.js\n@@\n const value = 1;\n-value += 1;\n+value += 2;\n*** End Patch\n\nUnified diff example:\n--- a/src/app.js\n+++ b/src/app.js\n@@ -1,2 +1,2 @@\n const value = 1;\n-value += 1;\n+value += 2;\n\nThe path in the patch must match the path argument. Do not send a shell command or a JSON object as the patch.`
};
export const TOOL_DEFINITIONS = [
{
name: "list_files",
description: "List files and directories in the execution workspace. For the root, omit path or send path: '.'. A missing path returns exists: false and an empty entries list.",
inputSchema: { type: "object", properties: { path: { ...pathProperty, default: "." }, recursive: { type: "boolean", default: false }, maxEntries: { type: "integer", minimum: 1, maximum: 10000, default: 500 } } }
},
{
name: "read_file",
description: "Read one UTF-8 text file in the current session workspace",
inputSchema: { type: "object", properties: { path: pathProperty }, required: ["path"] }
},
{
name: "read_files",
description: "Read multiple UTF-8 text files in the current session workspace",
inputSchema: { type: "object", properties: { paths: { type: "array", minItems: 1, maxItems: 100, items: pathProperty } }, required: ["paths"] }
},
{
name: "read_binary_file",
description: "Read a binary file and return base64 content",
inputSchema: { type: "object", properties: { path: pathProperty }, required: ["path"] }
},
{
name: "search_text",
description: "Search plain text inside the current session workspace",
inputSchema: { type: "object", properties: { path: pathProperty, query: { type: "string", minLength: 1 }, maxMatches: { type: "integer", minimum: 1, maximum: 5000, default: 200 }, caseSensitive: { type: "boolean", default: false } }, required: ["query"] }
},
{
name: "write_file",
description: "Create or overwrite a text file in the current session workspace",
inputSchema: { type: "object", properties: { path: pathProperty, content: { type: "string" }, createParents: createParentsProperty }, required: ["path", "content"] }
},
{
name: "write_binary_file",
description: "Create or overwrite a binary file from base64 content",
inputSchema: { type: "object", properties: { path: pathProperty, contentBase64: { type: "string", minLength: 1 }, createParents: createParentsProperty }, required: ["path", "contentBase64"] }
},
{
name: "apply_patch",
description: "Edit one existing workspace file. Accepts Codex patch format and standard unified diff format; see the patch field examples. Use write_file to create a new file.",
inputSchema: { type: "object", properties: { path: pathProperty, patch: patchProperty }, required: ["path", "patch"] }
},
{
name: "make_directory",
description: "Create a directory in the current session workspace",
inputSchema: { type: "object", properties: { path: pathProperty }, required: ["path"] }
},
{
name: "delete_path",
description: "Delete a file or directory in the current session workspace",
inputSchema: { type: "object", properties: { path: pathProperty, recursive: { type: "boolean", default: false }, allowMissing: { type: "boolean", default: false } }, required: ["path"] }
},
{
name: "move_path",
description: "Move a file or directory within the current session workspace",
inputSchema: { type: "object", properties: { fromPath: pathProperty, toPath: pathProperty, overwrite: { type: "boolean", default: false }, createParents: createParentsProperty }, required: ["fromPath", "toPath"] }
},
{
name: "rename_path",
description: "Rename a file or directory within its current parent directory",
inputSchema: { type: "object", properties: { path: pathProperty, newName: { type: "string", minLength: 1 }, overwrite: { type: "boolean", default: false } }, required: ["path", "newName"] }
},
{
name: "file_info",
description: "Return metadata about a workspace path. A missing path returns exists: false instead of an error.",
inputSchema: { type: "object", properties: { path: pathProperty }, required: ["path"] }
}
];

View File

@ -0,0 +1,806 @@
import fs from "node:fs/promises";
import fsSync from "node:fs";
import path from "node:path";
import { applyTextPatch } from "./patch.js";
function assertNonEmptyString(value, label) {
if (typeof value !== "string" || value.trim() === "") {
throw new Error(`${label} must be a non-empty string`);
}
}
// MCP clients sometimes serialize an omitted root path as an empty string.
// Treat that spelling as the workspace root rather than making a harmless
// discovery request fail.
function normalizeRootPath(value) {
return typeof value === "string" && value.trim() === "" ? "." : value;
}
function normalizeWorkspaceSubpath(workspaceSubpath) {
if (!workspaceSubpath) {
return null;
}
const normalized = String(workspaceSubpath).trim().replace(/\\/g, "/");
if (normalized === "") {
return null;
}
if (normalized.startsWith("/")) {
throw new Error("workspaceSubpath must be relative");
}
const segments = normalized.split("/").filter(Boolean);
if (segments.length === 0) {
return null;
}
for (const segment of segments) {
if (segment === "." || segment === "..") {
throw new Error("workspaceSubpath must not contain traversal segments");
}
if (!/^[A-Za-z0-9._-]+$/.test(segment)) {
throw new Error("workspaceSubpath contains unsupported characters");
}
}
return segments.join(path.sep);
}
function isWithinPath(targetPath, rootPath) {
const relative = path.relative(rootPath, targetPath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function normalizeClientPath(rootRecord, absolutePath) {
const relative = path.relative(rootRecord.resolved, absolutePath);
return (relative === "" ? "." : relative).split(path.sep).join("/");
}
function looksBinary(buffer) {
return buffer.includes(0);
}
function findAllIndexes(text, query, caseSensitive) {
const haystack = caseSensitive ? text : text.toLowerCase();
const needle = caseSensitive ? query : query.toLowerCase();
const indexes = [];
let fromIndex = 0;
while (fromIndex <= haystack.length) {
const matchIndex = haystack.indexOf(needle, fromIndex);
if (matchIndex === -1) {
break;
}
indexes.push(matchIndex);
fromIndex = matchIndex + Math.max(needle.length, 1);
}
return indexes;
}
function getPathType(stats) {
if (stats.isDirectory()) {
return "directory";
}
if (stats.isFile()) {
return "file";
}
if (stats.isSymbolicLink()) {
return "symlink";
}
return "other";
}
function decodeBase64(contentBase64) {
assertNonEmptyString(contentBase64, "contentBase64");
const normalized = contentBase64.replace(/\s+/g, "");
if (normalized.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(normalized)) {
throw new Error("contentBase64 must be valid base64");
}
return Buffer.from(normalized, "base64");
}
async function readTextFile(absolutePath) {
const buffer = await fs.readFile(absolutePath);
if (looksBinary(buffer)) {
throw new Error("Binary files are not supported");
}
return buffer.toString("utf8");
}
export class WorkspaceService {
constructor({ roots }) {
if (!Array.isArray(roots) || roots.length === 0) {
throw new Error("At least one workspace root is required");
}
this.roots = roots.map((rootPath) => {
assertNonEmptyString(rootPath, "root");
const resolved = path.resolve(rootPath);
if (!fsSync.existsSync(resolved)) {
throw new Error(`Workspace root does not exist: ${resolved}`);
}
if (!fsSync.statSync(resolved).isDirectory()) {
throw new Error(`Workspace root must be a directory: ${resolved}`);
}
return {
configured: rootPath,
resolved,
real: fsSync.realpathSync(resolved)
};
});
}
getPrimaryRoot() {
return this.roots[0];
}
ensureWorkspace(context = {}) {
const rootRecord = this.getScopedRoot(null, context, { createIfMissing: true });
return {
root: rootRecord.resolved,
workspaceSubpath: rootRecord.workspaceSubpath
};
}
getScopedRoot(rootHint, context, { createIfMissing = false } = {}) {
const baseRootRecord = this.selectRoot(rootHint);
const workspaceSubpath = normalizeWorkspaceSubpath(context?.workspaceSubpath ?? null);
let resolved = baseRootRecord.resolved;
if (workspaceSubpath) {
resolved = path.resolve(resolved, workspaceSubpath);
}
if (!isWithinPath(resolved, baseRootRecord.resolved)) {
throw new Error("Resolved session root escapes the allowed workspace root");
}
if (createIfMissing && !fsSync.existsSync(resolved)) {
fsSync.mkdirSync(resolved, { recursive: true });
}
const real = fsSync.existsSync(resolved)
? fsSync.realpathSync(resolved)
: resolved;
return {
configured: baseRootRecord.configured,
resolved,
real,
baseRoot: baseRootRecord.resolved,
sessionId: context?.sessionId ?? null,
workspaceSubpath: workspaceSubpath
};
}
selectRoot(rootHint) {
if (!rootHint) {
return this.getPrimaryRoot();
}
const matched = this.roots.find(
(rootRecord) =>
rootHint === rootRecord.configured ||
rootHint === rootRecord.resolved ||
rootHint === rootRecord.real
);
if (!matched) {
throw new Error(`Unknown root: ${rootHint}`);
}
return matched;
}
resolvePath(inputPath, { root, context, createSessionRoot = false } = {}) {
inputPath = normalizeRootPath(inputPath);
assertNonEmptyString(inputPath, "path");
if (inputPath.includes("\0") || path.isAbsolute(inputPath)) {
throw new Error("Path must be a relative path");
}
if (root) {
throw new Error("Selecting a workspace root from a tool call is not allowed");
}
const rootRecord = this.getScopedRoot(null, context, { createIfMissing: createSessionRoot });
const absolutePath = path.resolve(rootRecord.resolved, inputPath);
if (!isWithinPath(absolutePath, rootRecord.resolved)) {
throw new Error(`Path escapes allowed workspace root: ${inputPath}`);
}
return {
rootRecord,
absolutePath,
clientPath: normalizeClientPath(rootRecord, absolutePath)
};
}
ensureNotRootPath(absolutePath, rootRecord, action) {
if (path.resolve(absolutePath) === rootRecord.resolved) {
throw new Error(`Refusing to ${action} the workspace root`);
}
}
async validatePathChain(targetPath, rootRecord) {
if (!isWithinPath(targetPath, rootRecord.resolved)) {
throw new Error("Resolved path escapes the allowed workspace root");
}
const relativePath = path.relative(rootRecord.resolved, targetPath);
if (relativePath === "") {
return {
firstMissingPath: null
};
}
const segments = relativePath.split(path.sep).filter(Boolean);
let currentPath = rootRecord.resolved;
for (const segment of segments) {
const nextPath = path.join(currentPath, segment);
if (!fsSync.existsSync(nextPath)) {
return {
firstMissingPath: nextPath
};
}
const stats = await fs.lstat(nextPath);
if (stats.isSymbolicLink()) {
throw new Error(`Symlink path segments are not allowed: ${normalizeClientPath(rootRecord, nextPath)}`);
}
const realPath = await fs.realpath(nextPath);
if (!isWithinPath(realPath, rootRecord.real)) {
throw new Error("Resolved path escapes the allowed workspace root");
}
currentPath = nextPath;
}
return {
firstMissingPath: null
};
}
async assertSafeExistingPath(absolutePath, rootRecord, { allowSymlink = false } = {}) {
const chainTarget = absolutePath === rootRecord.resolved
? absolutePath
: path.dirname(absolutePath);
await this.validatePathChain(chainTarget, rootRecord);
const stats = await fs.lstat(absolutePath);
if (stats.isSymbolicLink()) {
if (!allowSymlink) {
throw new Error("Symlink targets are not allowed for this operation");
}
return stats;
}
const realTarget = await fs.realpath(absolutePath);
if (!isWithinPath(realTarget, rootRecord.real)) {
throw new Error("Resolved path escapes the allowed workspace root");
}
return stats;
}
async prepareDestination(absolutePath, rootRecord, { createParents = true } = {}) {
const parentPath = path.dirname(absolutePath);
const { firstMissingPath } = await this.validatePathChain(parentPath, rootRecord);
if (firstMissingPath && !createParents) {
throw new Error(`Parent directory does not exist: ${normalizeClientPath(rootRecord, firstMissingPath)}`);
}
if (createParents) {
await fs.mkdir(parentPath, { recursive: true });
}
await this.validatePathChain(parentPath, rootRecord);
}
async writeBufferFile({ path: targetPath, root, buffer, createParents = true, context = {} }) {
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, {
root,
context,
createSessionRoot: true
});
const existed = fsSync.existsSync(absolutePath);
if (existed) {
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord);
if (!stats.isFile()) {
throw new Error("Path is not a regular file");
}
} else {
await this.prepareDestination(absolutePath, rootRecord, { createParents });
}
await fs.writeFile(absolutePath, buffer);
return {
root: rootRecord.resolved,
path: clientPath,
bytesWritten: buffer.length,
created: !existed
};
}
async listFiles({ path: targetPath = ".", root, recursive = false, maxEntries = 500 } = {}, context = {}) {
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context });
const chainTarget = absolutePath === rootRecord.resolved ? absolutePath : path.dirname(absolutePath);
await this.validatePathChain(chainTarget, rootRecord);
if (!fsSync.existsSync(absolutePath)) {
return {
root: rootRecord.resolved,
path: clientPath,
exists: false,
entries: [],
truncated: false
};
}
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord);
const entries = [];
const queue = [{ absolutePath, clientPath }];
let truncated = false;
if (!stats.isDirectory()) {
return {
root: rootRecord.resolved,
path: clientPath,
exists: true,
entries: [
{
path: clientPath,
type: getPathType(stats),
size: stats.size,
mtimeMs: stats.mtimeMs
}
],
truncated
};
}
while (queue.length > 0 && entries.length < maxEntries) {
const current = queue.shift();
const currentEntries = await fs.readdir(current.absolutePath, { withFileTypes: true });
currentEntries.sort((left, right) => left.name.localeCompare(right.name));
for (const dirent of currentEntries) {
const childAbsolute = path.join(current.absolutePath, dirent.name);
const childClient = current.clientPath === "."
? dirent.name
: `${current.clientPath}/${dirent.name}`;
const childStats = await fs.lstat(childAbsolute);
entries.push({
path: childClient,
type: dirent.isDirectory()
? "directory"
: dirent.isFile()
? "file"
: dirent.isSymbolicLink()
? "symlink"
: "other",
size: childStats.size,
mtimeMs: childStats.mtimeMs
});
if (entries.length >= maxEntries) {
truncated = true;
break;
}
if (recursive && dirent.isDirectory() && !dirent.isSymbolicLink()) {
queue.push({
absolutePath: childAbsolute,
clientPath: childClient
});
}
}
}
return {
root: rootRecord.resolved,
path: clientPath,
exists: true,
entries,
truncated
};
}
async readFile({ path: targetPath, root } = {}, context = {}) {
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context });
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord);
if (!stats.isFile()) {
throw new Error("Path is not a file");
}
const content = await readTextFile(absolutePath);
return {
root: rootRecord.resolved,
path: clientPath,
size: stats.size,
content
};
}
async readBinaryFile({ path: targetPath, root } = {}, context = {}) {
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context });
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord);
if (!stats.isFile()) {
throw new Error("Path is not a file");
}
const content = await fs.readFile(absolutePath);
return {
root: rootRecord.resolved,
path: clientPath,
size: stats.size,
encoding: "base64",
contentBase64: content.toString("base64")
};
}
async readFiles({ paths, root } = {}, context = {}) {
if (!Array.isArray(paths) || paths.length === 0) {
throw new Error("paths must be a non-empty array");
}
const files = [];
for (const targetPath of paths) {
files.push(await this.readFile({ path: targetPath, root }, context));
}
return { files };
}
async searchText({ path: targetPath = ".", root, query, maxMatches = 200, caseSensitive = false } = {}, context = {}) {
assertNonEmptyString(query, "query");
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context });
await this.assertSafeExistingPath(absolutePath, rootRecord);
const matches = [];
const queue = [{ absolutePath, clientPath }];
let truncated = false;
while (queue.length > 0 && matches.length < maxMatches) {
const current = queue.shift();
const currentStats = await fs.lstat(current.absolutePath);
if (currentStats.isDirectory()) {
const children = await fs.readdir(current.absolutePath, { withFileTypes: true });
children.sort((left, right) => left.name.localeCompare(right.name));
for (const dirent of children) {
if (dirent.isSymbolicLink()) {
continue;
}
const childAbsolute = path.join(current.absolutePath, dirent.name);
const childClient = current.clientPath === "."
? dirent.name
: `${current.clientPath}/${dirent.name}`;
queue.push({ absolutePath: childAbsolute, clientPath: childClient });
}
continue;
}
if (!currentStats.isFile()) {
continue;
}
const buffer = await fs.readFile(current.absolutePath);
if (looksBinary(buffer)) {
continue;
}
const text = buffer.toString("utf8");
const lines = text.replace(/\r\n/g, "\n").split("\n");
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
const indexes = findAllIndexes(lines[lineIndex], query, caseSensitive);
for (const columnIndex of indexes) {
matches.push({
path: current.clientPath,
line: lineIndex + 1,
column: columnIndex + 1,
preview: lines[lineIndex]
});
if (matches.length >= maxMatches) {
truncated = true;
break;
}
}
if (truncated) {
break;
}
}
}
return {
root: rootRecord.resolved,
path: clientPath,
query,
matches,
truncated
};
}
async writeFile({ path: targetPath, root, content, createParents = true } = {}, context = {}) {
if (typeof content !== "string") {
throw new Error("content must be a string");
}
return this.writeBufferFile({
path: targetPath,
root,
buffer: Buffer.from(content, "utf8"),
createParents,
context
});
}
async writeBinaryFile({ path: targetPath, root, contentBase64, createParents = true } = {}, context = {}) {
return this.writeBufferFile({
path: targetPath,
root,
buffer: decodeBase64(contentBase64),
createParents,
context
});
}
async applyPatch({ path: targetPath, root, patch } = {}, context = {}) {
assertNonEmptyString(patch, "patch");
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, {
root,
context,
createSessionRoot: true
});
const fileExists = fsSync.existsSync(absolutePath);
if (fileExists) {
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord);
if (!stats.isFile()) {
throw new Error("Path is not a regular file");
}
} else {
await this.prepareDestination(absolutePath, rootRecord, { createParents: false });
}
const originalText = fileExists ? await readTextFile(absolutePath) : "";
const patchResult = applyTextPatch({
originalText,
patchText: patch,
expectedPath: clientPath,
fileExists
});
if (patchResult.action === "delete") {
await fs.rm(absolutePath, { force: false });
return {
root: rootRecord.resolved,
path: clientPath,
action: "delete",
hunksApplied: patchResult.hunksApplied
};
}
await fs.writeFile(absolutePath, patchResult.updatedText, "utf8");
return {
root: rootRecord.resolved,
path: clientPath,
action: patchResult.action,
bytesWritten: Buffer.byteLength(patchResult.updatedText, "utf8"),
hunksApplied: patchResult.hunksApplied
};
}
async makeDirectory({ path: targetPath, root } = {}, context = {}) {
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, {
root,
context,
createSessionRoot: true
});
const existed = fsSync.existsSync(absolutePath);
if (existed) {
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord);
if (!stats.isDirectory()) {
throw new Error("Path exists and is not a directory");
}
return {
root: rootRecord.resolved,
path: clientPath,
created: false
};
}
await this.prepareDestination(absolutePath, rootRecord, { createParents: true });
await fs.mkdir(absolutePath, { recursive: true });
await this.assertSafeExistingPath(absolutePath, rootRecord);
return {
root: rootRecord.resolved,
path: clientPath,
created: true
};
}
async deletePath({ path: targetPath, root, recursive = false, allowMissing = false } = {}, context = {}) {
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context });
this.ensureNotRootPath(absolutePath, rootRecord, "delete");
if (!fsSync.existsSync(absolutePath)) {
if (allowMissing) {
return {
root: rootRecord.resolved,
path: clientPath,
deleted: false,
missing: true
};
}
throw new Error("Path does not exist");
}
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord, { allowSymlink: true });
if (stats.isDirectory() && !recursive) {
throw new Error("Refusing to delete a directory without recursive=true");
}
await fs.rm(absolutePath, { recursive: stats.isDirectory(), force: false });
return {
root: rootRecord.resolved,
path: clientPath,
deleted: true,
type: getPathType(stats)
};
}
async movePath({ fromPath, toPath, root, overwrite = false, createParents = true } = {}, context = {}) {
assertNonEmptyString(fromPath, "fromPath");
assertNonEmptyString(toPath, "toPath");
const source = this.resolvePath(fromPath, { root, context });
const destination = this.resolvePath(toPath, {
root,
context,
createSessionRoot: true
});
this.ensureNotRootPath(source.absolutePath, source.rootRecord, "move");
if (source.absolutePath === destination.absolutePath) {
return {
fromRoot: source.rootRecord.resolved,
fromPath: source.clientPath,
toRoot: destination.rootRecord.resolved,
toPath: destination.clientPath,
moved: false
};
}
const sourceStats = await this.assertSafeExistingPath(source.absolutePath, source.rootRecord, {
allowSymlink: true
});
if (
sourceStats.isDirectory() &&
isWithinPath(destination.absolutePath, source.absolutePath)
) {
throw new Error("Cannot move a directory into itself");
}
if (fsSync.existsSync(destination.absolutePath)) {
const destinationStats = await this.assertSafeExistingPath(
destination.absolutePath,
destination.rootRecord,
{ allowSymlink: true }
);
if (!overwrite) {
throw new Error("Destination already exists");
}
this.ensureNotRootPath(destination.absolutePath, destination.rootRecord, "overwrite");
await fs.rm(destination.absolutePath, {
recursive: destinationStats.isDirectory(),
force: false
});
} else {
await this.prepareDestination(destination.absolutePath, destination.rootRecord, { createParents });
}
try {
await fs.rename(source.absolutePath, destination.absolutePath);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "EXDEV") {
throw new Error("Cross-device moves are not supported");
}
throw error;
}
return {
fromRoot: source.rootRecord.resolved,
fromPath: source.clientPath,
toRoot: destination.rootRecord.resolved,
toPath: destination.clientPath,
moved: true,
overwritten: overwrite
};
}
async renamePath({ path: targetPath, root, newName, overwrite = false } = {}, context = {}) {
assertNonEmptyString(newName, "newName");
if (
newName === "." ||
newName === ".." ||
newName.includes("/") ||
newName.includes("\\") ||
newName.includes(path.sep)
) {
throw new Error("newName must be a plain file or directory name");
}
const source = this.resolvePath(targetPath, { root, context });
const destinationPath = path.join(path.dirname(source.absolutePath), newName);
return this.movePath({
fromPath: source.clientPath,
toPath: path.relative(source.rootRecord.resolved, destinationPath),
overwrite,
createParents: false
}, context);
}
async fileInfo({ path: targetPath, root } = {}, context = {}) {
const { rootRecord, absolutePath, clientPath } = this.resolvePath(targetPath, { root, context });
const chainTarget = absolutePath === rootRecord.resolved ? absolutePath : path.dirname(absolutePath);
await this.validatePathChain(chainTarget, rootRecord);
if (!fsSync.existsSync(absolutePath)) {
return {
root: rootRecord.resolved,
path: clientPath,
exists: false
};
}
const stats = await this.assertSafeExistingPath(absolutePath, rootRecord, { allowSymlink: true });
return {
root: rootRecord.resolved,
path: clientPath,
exists: true,
type: getPathType(stats),
size: stats.size,
mtimeMs: stats.mtimeMs,
ctimeMs: stats.ctimeMs
};
}
}

View File

@ -0,0 +1,113 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { CommandService } from "../src/command-service.js";
import { buildTaskCommand } from "../src/execution-policy.js";
async function makeWorkspace() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "command-mcp-"));
return { root, cleanup: () => fs.rm(root, { recursive: true, force: true }) };
}
async function makeNpmProject(root, scripts = {}) {
await fs.writeFile(path.join(root, "package.json"), `${JSON.stringify({ name: "fixture", version: "1.0.0", private: true, scripts: {
test: "node -e \"process.stdout.write('ok')\"",
build: "node -e \"process.stdout.write('build-ok')\"",
lint: "node -e \"process.stderr.write('lint-output')\"",
slow: "node -e \"setTimeout(() => process.stdout.write('late'), 2000)\"",
...scripts
} }, null, 2)}\n`);
}
test("run_task executes a typed npm build", async () => {
const workspace = await makeWorkspace();
try {
await makeNpmProject(workspace.root);
const result = await new CommandService({ roots: [workspace.root] }).runTask({ runner: "npm", task: "build" });
assert.equal(result.exitCode, 0);
assert.match(result.stdout, /build-ok/);
} finally { await workspace.cleanup(); }
});
test("an empty run_task workdir is normalized to the workspace root", async () => {
const workspace = await makeWorkspace();
try {
await makeNpmProject(workspace.root);
const result = await new CommandService({ roots: [workspace.root] }).runTask({ workdir: "", runner: "npm", task: "test" });
assert.equal(result.exitCode, 0);
assert.equal(result.workdir, ".");
} finally { await workspace.cleanup(); }
});
test("run_task scopes execution to the workspace subpath without adding a session directory", async () => {
const workspace = await makeWorkspace();
try {
const project = path.join(workspace.root, "project-a");
await fs.mkdir(project, { recursive: true });
await makeNpmProject(project);
const result = await new CommandService({ roots: [workspace.root] }).runTask({ runner: "npm", task: "test", sessionId: "sess_1", workspaceSubpath: "project-a" });
assert.equal(result.exitCode, 0);
assert.match(result.stdout, /ok/);
} finally { await workspace.cleanup(); }
});
test("typed runners reject unsupported tasks and paths", () => {
assert.throws(() => buildTaskCommand({ runner: "pytest", task: "build" }), /only the test task/);
assert.throws(() => buildTaskCommand({ runner: "npm", task: "test", paths: ["tests"] }), /does not accept paths/);
assert.throws(() => buildTaskCommand({ runner: "shell", task: "test" }), /runner is not supported/);
assert.deepEqual(buildTaskCommand({ runner: "cargo", task: "lint" }), ["cargo", "clippy"]);
});
test("run_task rejects traversal, absolute workdirs, options, and escaped task paths", async () => {
const workspace = await makeWorkspace();
try {
await makeNpmProject(workspace.root);
const service = new CommandService({ roots: [workspace.root] });
await assert.rejects(service.runTask({ workdir: "../outside", runner: "npm", task: "test" }), /relative path|escapes/);
await assert.rejects(service.runTask({ workdir: workspace.root, runner: "npm", task: "test" }), /relative path/);
await assert.rejects(service.runTask({ runner: "pytest", task: "test", paths: ["--rootdir=/tmp"] }), /not options/);
await assert.rejects(service.runTask({ runner: "pytest", task: "test", paths: ["../outside"] }), /outside/);
} finally { await workspace.cleanup(); }
});
test("run_task uses a minimal environment", async () => {
const workspace = await makeWorkspace();
const original = process.env.MCP_TEST_SECRET;
process.env.MCP_TEST_SECRET = "must-not-reach-child";
try {
await makeNpmProject(workspace.root, { test: "node -e \"process.stdout.write(process.env.MCP_TEST_SECRET || 'clean')\"" });
const result = await new CommandService({ roots: [workspace.root] }).runTask({ runner: "npm", task: "test" });
assert.equal(result.stdout.includes("must-not-reach-child"), false);
assert.match(result.stdout, /clean/);
} finally {
if (original === undefined) delete process.env.MCP_TEST_SECRET;
else process.env.MCP_TEST_SECRET = original;
await workspace.cleanup();
}
});
test("run_task enforces timeouts and output limits", async () => {
const workspace = await makeWorkspace();
try {
await makeNpmProject(workspace.root, {
test: "node -e \"process.stdout.write('x'.repeat(5000))\"",
build: "node -e \"setTimeout(() => process.stdout.write('late'), 2000)\""
});
const service = new CommandService({ roots: [workspace.root] });
const large = await service.runTask({ runner: "npm", task: "test", outputByteLimit: 1024 });
assert.equal(large.stdoutTruncated, true);
const slow = await service.runTask({ runner: "npm", task: "build", timeoutSeconds: 1 });
assert.equal(slow.timedOut, true);
} finally { await workspace.cleanup(); }
});
test("disabled execution backend fails closed", async () => {
const workspace = await makeWorkspace();
try {
await makeNpmProject(workspace.root);
const service = new CommandService({ roots: [workspace.root], executionBackend: "disabled" });
await assert.rejects(service.runTask({ runner: "npm", task: "test" }), /disabled/);
} finally { await workspace.cleanup(); }
});

View File

@ -0,0 +1,172 @@
import test from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { startHttpToolServer } from "../src/mcp-http.js";
async function startServer(t, options) {
try { return await startHttpToolServer(options); }
catch (error) {
if (error?.code === "EPERM") { t.skip("Local TCP listeners are not permitted in this sandbox"); return null; }
throw error;
}
}
function closeServer(server) {
return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
}
function openSse(url, headers) {
return new Promise((resolve, reject) => {
const request = http.get(url, { headers }, (response) => {
response.setEncoding("utf8");
let body = "";
response.on("data", (chunk) => { body += chunk; });
resolve({ status: response.statusCode, sessionId: response.headers["x-session-id"], body: () => body, close: () => request.destroy() });
});
request.on("error", reject);
});
}
function rpc(url, token, sessionId, payload) {
return fetch(url, {
method: "POST",
headers: { "content-type": "application/json", Authorization: `Bearer ${token}`, ...(sessionId ? { "x-session-id": sessionId } : {}) },
body: JSON.stringify(payload)
});
}
function streamableRpc(url, token, sessionId, payload) {
return fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
Authorization: `Bearer ${token}`,
...(sessionId ? { "mcp-session-id": sessionId } : {})
},
body: JSON.stringify(payload)
});
}
async function waitForBody(sse, expected) {
for (let attempt = 0; attempt < 20; attempt += 1) {
if (sse.body().includes(expected)) return;
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.fail(`Timed out waiting for SSE payload: ${expected}`);
}
const tools = [{ name: "echo", description: "test tool", inputSchema: { type: "object" } }];
test("HTTP transport fails closed without credentials", async () => {
assert.throws(() => startHttpToolServer({ serverName: "x", serverVersion: "1", tools, callTool: async () => ({}) }), /requires at least one API key/);
});
test("HTTP transport binds sessions to an authenticated identity", async (t) => {
const listener = await startServer(t, {
serverName: "secure-server",
serverVersion: "1.0.0",
tools,
apiKeys: ["agent-a=token-a", "agent-b=token-b"],
callTool: async (_name, _arguments, context) => ({ subject: context.subject, sessionId: context.sessionId, workspaceSubpath: context.workspaceSubpath }),
host: "127.0.0.1",
port: 0
});
if (!listener) return;
const baseUrl = `http://${listener.host}:${listener.port}`;
try {
const noAuth = await fetch(`${baseUrl}/health`);
assert.equal(noAuth.status, 401);
const sse = await openSse(`${baseUrl}/events?workspaceSubpath=project-a`, { Authorization: "Bearer token-a" });
try {
assert.equal(sse.status, 200);
assert.ok(sse.sessionId);
const absentSession = await rpc(`${baseUrl}/mcp`, "token-a", null, { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "echo" } });
assert.equal(absentSession.status, 401);
const stolenSession = await rpc(`${baseUrl}/mcp`, "token-b", sse.sessionId, { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "echo" } });
assert.equal(stolenSession.status, 401);
const response = await rpc(`${baseUrl}/mcp`, "token-a", sse.sessionId, { jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "echo" } });
const body = await response.json();
assert.equal(response.status, 200);
assert.equal(body.result.structuredContent.subject, "agent-a");
assert.equal(body.result.structuredContent.workspaceSubpath, "project-a");
const changedSubpath = await fetch(`${baseUrl}/mcp?workspaceSubpath=other`, { method: "POST", headers: { "content-type": "application/json", Authorization: "Bearer token-a", "x-session-id": sse.sessionId }, body: JSON.stringify({ jsonrpc: "2.0", id: 4, method: "ping" }) });
assert.equal(changedSubpath.status, 400);
} finally { sse.close(); }
} finally { await closeServer(listener.server); }
});
test("Streamable HTTP initializes, scopes requests, and deletes MCP sessions", async (t) => {
const initializedSessions = [];
const listener = await startServer(t, {
serverName: "streamable-server", serverVersion: "1.0.0", tools, apiKeys: ["token-a"],
callTool: async (_name, _arguments, context) => ({ subject: context.subject, sessionId: context.sessionId }),
onSessionInitialize: (context) => initializedSessions.push(context),
host: "127.0.0.1", port: 0
});
if (!listener) return;
const baseUrl = `http://${listener.host}:${listener.port}`;
try {
const initialize = await streamableRpc(`${baseUrl}/mcp`, "token-a", null, {
jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26", capabilities: {}, clientInfo: { name: "test", version: "1" } }
});
const sessionId = initialize.headers.get("mcp-session-id");
assert.equal(initialize.status, 200);
assert.ok(sessionId);
assert.deepEqual(initializedSessions, [{
sessionId,
subject: "shared",
workspaceSubpath: null,
protocolVersion: "2025-03-26"
}]);
assert.equal(initialize.headers.get("mcp-protocol-version"), "2025-03-26");
assert.equal((await initialize.json()).result.protocolVersion, "2025-03-26");
const toolList = await streamableRpc(`${baseUrl}/mcp`, "token-a", sessionId, { jsonrpc: "2.0", id: 2, method: "tools/list" });
assert.equal(toolList.status, 200);
assert.equal((await toolList.json()).result.tools[0].name, "echo");
const removed = await fetch(`${baseUrl}/mcp`, { method: "DELETE", headers: { Authorization: "Bearer token-a", "mcp-session-id": sessionId } });
assert.equal(removed.status, 204);
const afterDelete = await streamableRpc(`${baseUrl}/mcp`, "token-a", sessionId, { jsonrpc: "2.0", id: 3, method: "ping" });
assert.equal(afterDelete.status, 404);
} finally { await closeServer(listener.server); }
});
test("Legacy SSE fallback on /mcp exposes a message endpoint and returns JSON-RPC messages", async (t) => {
const listener = await startServer(t, {
serverName: "legacy-server", serverVersion: "1.0.0", tools, apiKeys: ["token-a"], callTool: async () => ({}), host: "127.0.0.1", port: 0
});
if (!listener) return;
const baseUrl = `http://${listener.host}:${listener.port}`;
const sse = await openSse(`${baseUrl}/mcp`, { Authorization: "Bearer token-a" });
try {
assert.equal(sse.status, 200);
await waitForBody(sse, "event: endpoint");
const endpoint = /data: "([^"]+)"/.exec(sse.body())?.[1];
assert.ok(endpoint);
const response = await fetch(new URL(endpoint, `${baseUrl}/mcp`), {
method: "POST",
headers: { "content-type": "application/json", Authorization: "Bearer token-a" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" })
});
assert.equal(response.status, 202);
await waitForBody(sse, '"id":1');
} finally {
sse.close();
await closeServer(listener.server);
}
});
test("CORS is denied by default and can be explicitly allowlisted", async (t) => {
const listener = await startServer(t, {
serverName: "cors-server", serverVersion: "1.0.0", tools, apiKeys: ["token"], corsOrigins: ["https://console.example"], callTool: async () => ({}), host: "127.0.0.1", port: 0
});
if (!listener) return;
const url = `http://${listener.host}:${listener.port}/health`;
try {
const denied = await fetch(url, { headers: { Authorization: "Bearer token", Origin: "https://evil.example" } });
assert.equal(denied.headers.get("access-control-allow-origin"), null);
const allowed = await fetch(url, { headers: { Authorization: "Bearer token", Origin: "https://console.example" } });
assert.equal(allowed.headers.get("access-control-allow-origin"), "https://console.example");
} finally { await closeServer(listener.server); }
});

View File

@ -0,0 +1,580 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import { WorkspaceService } from "../src/workspace-service.js";
async function makeWorkspace() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-mcp-"));
return {
root,
cleanup: async () => {
await fs.rm(root, { recursive: true, force: true });
}
};
}
test("write_file and read_file operate within the workspace root", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const writeResult = await service.writeFile({
path: "src/example.txt",
content: "hello workspace"
});
assert.equal(writeResult.created, true);
const readResult = await service.readFile({ path: "src/example.txt" });
assert.equal(readResult.content, "hello workspace");
assert.equal(readResult.path, "src/example.txt");
} finally {
await workspace.cleanup();
}
});
test("session context does not add a directory to workspace paths", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const context = { sessionId: "sess_123" };
await service.writeFile(
{
path: "note.txt",
content: "session-bound"
},
context
);
const stored = await fs.readFile(path.join(workspace.root, "note.txt"), "utf8");
assert.equal(stored, "session-bound");
const readResult = await service.readFile({ path: "note.txt" }, context);
assert.equal(readResult.content, "session-bound");
} finally {
await workspace.cleanup();
}
});
test("workspace context supports an additional subpath independent of session ID", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const context = {
sessionId: "sess_789",
workspaceSubpath: "feature-a/sources"
};
await service.writeFile(
{
path: "note.txt",
content: "nested-session-bound"
},
context
);
const stored = await fs.readFile(
path.join(workspace.root, "feature-a", "sources", "note.txt"),
"utf8"
);
assert.equal(stored, "nested-session-bound");
} finally {
await workspace.cleanup();
}
});
test("ensureWorkspace creates the execution subpath before the first tool call", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const result = service.ensureWorkspace({
sessionId: "sess_789",
workspaceSubpath: "execution-123"
});
assert.equal(result.workspaceSubpath, "execution-123");
assert.equal(fsSync.statSync(path.join(workspace.root, "execution-123")).isDirectory(), true);
} finally {
await workspace.cleanup();
}
});
test("workspace context rejects traversal in workspaceSubpath", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.writeFile(
{
path: "note.txt",
content: "x"
},
{
sessionId: "sess_bad",
workspaceSubpath: "../escape"
}
),
/workspaceSubpath/
);
} finally {
await workspace.cleanup();
}
});
test("read_binary_file and write_binary_file round-trip base64 content", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const original = Buffer.from([0, 1, 2, 255, 42]);
const writeResult = await service.writeBinaryFile({
path: "assets/blob.bin",
contentBase64: original.toString("base64")
});
assert.equal(writeResult.created, true);
const readResult = await service.readBinaryFile({ path: "assets/blob.bin" });
assert.equal(readResult.encoding, "base64");
assert.deepEqual(Buffer.from(readResult.contentBase64, "base64"), original);
} finally {
await workspace.cleanup();
}
});
test("list_files returns recursive directory entries", async () => {
const workspace = await makeWorkspace();
try {
await fs.mkdir(path.join(workspace.root, "a", "b"), { recursive: true });
await fs.writeFile(path.join(workspace.root, "a", "b", "file.txt"), "data", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const result = await service.listFiles({ path: "a", recursive: true });
assert.deepEqual(
result.entries.map((entry) => entry.path),
["a/b", "a/b/file.txt"]
);
} finally {
await workspace.cleanup();
}
});
test("list_files reports an absent path without failing the tool call", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const result = await service.listFiles({ path: ".orchestrator" });
assert.deepEqual(result, {
root: workspace.root,
path: ".orchestrator",
exists: false,
entries: [],
truncated: false
});
} finally {
await workspace.cleanup();
}
});
test("an empty workspace path is normalized to the workspace root", async () => {
const workspace = await makeWorkspace();
try {
await fs.writeFile(path.join(workspace.root, "root.txt"), "root", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const listed = await service.listFiles({ path: "" });
const info = await service.fileInfo({ path: "" });
assert.equal(listed.path, ".");
assert.equal(listed.entries[0].path, "root.txt");
assert.equal(info.path, ".");
assert.equal(info.type, "directory");
} finally {
await workspace.cleanup();
}
});
test("search_text finds matches with line and column information", async () => {
const workspace = await makeWorkspace();
try {
await fs.mkdir(path.join(workspace.root, "src"), { recursive: true });
await fs.writeFile(path.join(workspace.root, "src", "one.txt"), "alpha\nbeta alpha\n", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const result = await service.searchText({ query: "alpha" });
assert.equal(result.matches.length, 2);
assert.deepEqual(result.matches[0], {
path: "src/one.txt",
line: 1,
column: 1,
preview: "alpha"
});
assert.deepEqual(result.matches[1], {
path: "src/one.txt",
line: 2,
column: 6,
preview: "beta alpha"
});
} finally {
await workspace.cleanup();
}
});
test("apply_patch updates an existing file", async () => {
const workspace = await makeWorkspace();
try {
await fs.writeFile(path.join(workspace.root, "notes.txt"), "one\ntwo\nthree\n", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const patch = [
"*** Begin Patch",
"*** Update File: notes.txt",
"@@",
" one",
"-two",
"+TWO",
" three",
"*** End Patch"
].join("\n");
const result = await service.applyPatch({ path: "notes.txt", patch });
assert.equal(result.action, "update");
assert.equal(result.hunksApplied, 1);
const updated = await fs.readFile(path.join(workspace.root, "notes.txt"), "utf8");
assert.equal(updated, "one\nTWO\nthree\n");
} finally {
await workspace.cleanup();
}
});
test("apply_patch accepts a standard unified diff", async () => {
const workspace = await makeWorkspace();
try {
await fs.writeFile(path.join(workspace.root, "notes.txt"), "one\ntwo\nthree\n", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const patch = [
"diff --git a/notes.txt b/notes.txt",
"index 4cb29ea..c7b5ac4 100644",
"--- a/notes.txt",
"+++ b/notes.txt",
"@@ -1,3 +1,4 @@",
" one",
"-two",
"+TWO",
"+inserted",
" three"
].join("\n");
const result = await service.applyPatch({ path: "notes.txt", patch });
assert.equal(result.action, "update");
assert.equal(result.hunksApplied, 1);
assert.equal(await fs.readFile(path.join(workspace.root, "notes.txt"), "utf8"), "one\nTWO\ninserted\nthree\n");
} finally {
await workspace.cleanup();
}
});
test("a unified diff can insert a line without surrounding context", async () => {
const workspace = await makeWorkspace();
try {
await fs.writeFile(path.join(workspace.root, "notes.txt"), "one\ntwo\n", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const patch = [
"--- a/notes.txt",
"+++ b/notes.txt",
"@@ -1,0 +2 @@",
"+inserted"
].join("\n");
await service.applyPatch({ path: "notes.txt", patch });
assert.equal(await fs.readFile(path.join(workspace.root, "notes.txt"), "utf8"), "one\ninserted\ntwo\n");
} finally {
await workspace.cleanup();
}
});
test("apply_patch can add a new file", async () => {
const workspace = await makeWorkspace();
try {
await fs.mkdir(path.join(workspace.root, "src"), { recursive: true });
const service = new WorkspaceService({ roots: [workspace.root] });
const patch = [
"*** Begin Patch",
"*** Add File: src/new.txt",
"@@",
"+hello",
"+world",
"*** End Patch"
].join("\n");
const result = await service.applyPatch({ path: "src/new.txt", patch });
assert.equal(result.action, "add");
const created = await fs.readFile(path.join(workspace.root, "src", "new.txt"), "utf8");
assert.equal(created, "hello\nworld\n");
} finally {
await workspace.cleanup();
}
});
test("apply_patch can delete an existing file", async () => {
const workspace = await makeWorkspace();
try {
await fs.writeFile(path.join(workspace.root, "obsolete.txt"), "gone\nsoon\n", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const patch = [
"*** Begin Patch",
"*** Delete File: obsolete.txt",
"@@",
"-gone",
"-soon",
"*** End Patch"
].join("\n");
const result = await service.applyPatch({ path: "obsolete.txt", patch });
assert.equal(result.action, "delete");
assert.equal(fsSync.existsSync(path.join(workspace.root, "obsolete.txt")), false);
} finally {
await workspace.cleanup();
}
});
test("file_info returns metadata without reading file content", async () => {
const workspace = await makeWorkspace();
try {
await fs.writeFile(path.join(workspace.root, "meta.txt"), "content", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const info = await service.fileInfo({ path: "meta.txt" });
assert.equal(info.exists, true);
assert.equal(info.type, "file");
assert.equal(info.path, "meta.txt");
assert.equal(typeof info.size, "number");
} finally {
await workspace.cleanup();
}
});
test("file_info reports an absent path without failing the tool call", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const info = await service.fileInfo({ path: ".orchestrator" });
assert.deepEqual(info, {
root: workspace.root,
path: ".orchestrator",
exists: false
});
} finally {
await workspace.cleanup();
}
});
test("delete_path removes directories only when recursive is enabled", async () => {
const workspace = await makeWorkspace();
try {
await fs.mkdir(path.join(workspace.root, "tree", "child"), { recursive: true });
await fs.writeFile(path.join(workspace.root, "tree", "child", "file.txt"), "x", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.deletePath({ path: "tree" }),
/recursive=true/
);
const result = await service.deletePath({ path: "tree", recursive: true });
assert.equal(result.deleted, true);
assert.equal(fsSync.existsSync(path.join(workspace.root, "tree")), false);
} finally {
await workspace.cleanup();
}
});
test("move_path relocates files and rename_path renames them", async () => {
const workspace = await makeWorkspace();
try {
await fs.mkdir(path.join(workspace.root, "src"), { recursive: true });
await fs.writeFile(path.join(workspace.root, "src", "before.txt"), "payload", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const moved = await service.movePath({
fromPath: "src/before.txt",
toPath: "dist/after.txt"
});
assert.equal(moved.moved, true);
assert.equal(fsSync.existsSync(path.join(workspace.root, "src", "before.txt")), false);
assert.equal(fsSync.existsSync(path.join(workspace.root, "dist", "after.txt")), true);
const renamed = await service.renamePath({
path: "dist/after.txt",
newName: "final.txt"
});
assert.equal(renamed.moved, true);
assert.equal(fsSync.existsSync(path.join(workspace.root, "dist", "after.txt")), false);
assert.equal(fsSync.existsSync(path.join(workspace.root, "dist", "final.txt")), true);
} finally {
await workspace.cleanup();
}
});
test("move_path can overwrite an existing destination when requested", async () => {
const workspace = await makeWorkspace();
try {
await fs.mkdir(path.join(workspace.root, "a"), { recursive: true });
await fs.mkdir(path.join(workspace.root, "b"), { recursive: true });
await fs.writeFile(path.join(workspace.root, "a", "from.txt"), "new", "utf8");
await fs.writeFile(path.join(workspace.root, "b", "to.txt"), "old", "utf8");
const service = new WorkspaceService({ roots: [workspace.root] });
const result = await service.movePath({
fromPath: "a/from.txt",
toPath: "b/to.txt",
overwrite: true
});
assert.equal(result.overwritten, true);
const content = await fs.readFile(path.join(workspace.root, "b", "to.txt"), "utf8");
assert.equal(content, "new");
} finally {
await workspace.cleanup();
}
});
test("path traversal outside the root is rejected", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.readFile({ path: "../etc/passwd" }),
/escapes allowed workspace root/
);
} finally {
await workspace.cleanup();
}
});
test("absolute paths and caller-selected roots are rejected", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.writeFile({ path: path.join(workspace.root, "outside.txt"), content: "x" }),
/relative path/
);
await assert.rejects(
service.writeFile({ path: "inside.txt", root: workspace.root, content: "x" }),
/Selecting a workspace root/
);
} finally {
await workspace.cleanup();
}
});
test("read_file rejects direct symlink targets", async () => {
const workspace = await makeWorkspace();
try {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-mcp-outside-"));
const outsideFile = path.join(outsideDir, "secret.txt");
await fs.writeFile(outsideFile, "secret", "utf8");
await fs.symlink(outsideFile, path.join(workspace.root, "link.txt"));
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.readFile({ path: "link.txt" }),
/Symlink targets are not allowed/
);
await fs.rm(outsideDir, { recursive: true, force: true });
} finally {
await workspace.cleanup();
}
});
test("read_file rejects symlinked parent segments", async () => {
const workspace = await makeWorkspace();
try {
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "workspace-mcp-outside-parent-"));
await fs.writeFile(path.join(outsideDir, "secret.txt"), "secret", "utf8");
await fs.symlink(outsideDir, path.join(workspace.root, "linked-dir"));
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.readFile({ path: "linked-dir/secret.txt" }),
/Symlink path segments are not allowed/
);
await fs.rm(outsideDir, { recursive: true, force: true });
} finally {
await workspace.cleanup();
}
});
test("make_directory creates directories safely", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
const result = await service.makeDirectory({ path: "nested/path" });
assert.equal(result.created, true);
assert.equal(fsSync.existsSync(path.join(workspace.root, "nested", "path")), true);
} finally {
await workspace.cleanup();
}
});
test("delete_path refuses to delete the workspace root", async () => {
const workspace = await makeWorkspace();
try {
const service = new WorkspaceService({ roots: [workspace.root] });
await assert.rejects(
service.deletePath({ path: "." }),
/workspace root/
);
} finally {
await workspace.cleanup();
}
});

View File

@ -0,0 +1,7 @@
.git
.env
.env.*
node_modules
test
README.md
services*.json

3
dev-server-mcp/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.env
node_modules/
npm-debug.log*

37
dev-server-mcp/Dockerfile Normal file
View File

@ -0,0 +1,37 @@
# Debian and not Alpine, deliberately. A multi-toolchain image on musl means pip cannot use the
# manylinux wheels that almost every Python package ships, so it falls back to building from source
# and needs a compiler toolchain to do it - a larger image than the one this choice costs, reached
# by a longer road. Node's own native modules have the same problem.
FROM node:24-bookworm-slim
# The JDK comes from the image that builds it rather than from an apt repository, so the version is
# the one the project is written for instead of whatever the distribution happens to carry.
COPY --from=eclipse-temurin:25-jdk /opt/java/openjdk /opt/java/openjdk
ENV JAVA_HOME=/opt/java/openjdk
ENV PATH="/opt/java/openjdk/bin:${PATH}"
# python3 and pip, and nothing that compiles: a project needing a compiler is a project whose
# dependencies are not prebuilt, which is a decision for an operator to take knowingly.
# git is here because a lockfile may name a git dependency; curl because ./mvnw fetches with it.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
python3 python3-pip python3-venv ca-certificates git curl \
&& rm -rf /var/lib/apt/lists/* \
&& ln -sf /usr/bin/python3 /usr/local/bin/python \
&& ln -sf /usr/bin/pip3 /usr/local/bin/pip
# No Maven. A Spring project carries ./mvnw, which pins the Maven version the project is built
# with; installing a second one here would only give an agent a way to use the wrong one.
RUN groupadd -g 10001 mcp \
&& useradd -r -u 10001 -g mcp -M -d /nonexistent mcp \
&& mkdir -p /tmp/dev-server \
&& chown -R mcp:mcp /tmp/dev-server
WORKDIR /app
COPY --chown=mcp:mcp package.json ./
COPY --chown=mcp:mcp src ./src
ENV NODE_ENV=production
USER mcp
CMD ["node", "src/index.js"]

93
dev-server-mcp/README.md Normal file
View File

@ -0,0 +1,93 @@
# dev-server-mcp
Streamable HTTP MCP server for starting and observing preconfigured development services. MCP callers select only a service id; commands, arguments, directories, URLs, and environment values come from an operator-owned read-only configuration.
## Security boundary
The deployment has two processes in separate containers:
- `dev-server-mcp` is the authenticated MCP control plane. It has no workspace mount and never executes project code.
- `dev-server-worker` has a read-only workspace mount and launches configured project processes. It has no MCP API keys and is not published on the host.
Neither container needs the Docker socket. The worker is still a container boundary, not a VM boundary; use an ephemeral VM or microVM for hostile multi-tenant code.
The project mount stays read-only. Configure framework caches, PID files, and temporary output under `/tmp`; if a tool fundamentally requires a writable tree, run it against an ephemeral copy instead of changing the host bind mount to read-write.
## Tools
`list_services`, `service_status`, `start_service`, `stop_service`, `restart_service`, `discard_service`, and `service_logs`.
There is deliberately no arbitrary command, argument, environment, port, or working-directory parameter.
## One instance per execution
A service declares `workspaceMode`:
- `shared` serves one fixed tree inside the workspace mount, with its dependencies installed ahead of time by the operator. This is the original behaviour.
- `execution` serves the tree the current execution wrote. The worker copies `/workspace/<key>` into a throwaway directory of its own, installs the declared dependencies there, and runs the service in the copy. Several executions run at once, each on its own allocated port.
**The execution key is never a tool argument.** It arrives as the `x-preview-key` header, read once when the MCP session is opened and pinned to it; a later request carrying a different value is refused. A model can therefore work only inside the scope its session was opened for, and cannot name another execution's.
The copy is what makes the mode safe to offer. The tree is written by whoever the flow gave write access to, so it is untrusted input: symlinks are dropped rather than followed, an execution directory replaced by a symlink is refused outright, `node_modules` and `.git` are skipped, and the whole copy is refused past declared entry and byte limits. The workspace mount itself stays read-only.
A `restart_service` re-copies before starting, so it serves the code as it is now. A `discard_service` stops the service and deletes the copy; nothing else reclaims that disk.
Dependencies are installed with the command the operator declares (`install`), once per lockfile: the worker records a fingerprint of the lockfiles it installed for and skips the install while they match. With no lockfile at all, the install is never treated as current.
## What it can run
Nothing in this server knows what a framework is. Three things decide what it can start:
1. **The command allowlist** (`DEV_SERVER_ALLOWED_COMMANDS`). Any project with an npm script works as it is: Angular, Vite, Next, Nuxt, SvelteKit, Astro, webpack-dev-server, Storybook, a hand-written Express. `npx` is deliberately not in the default: it fetches and runs an arbitrary package by name, which hands back the free choice of command this allowlist exists to remove.
2. **The service's own conventions.** It has to take its port from an argument or from an environment variable — the `env` map is templated too, so `"PORT": "${port}"` covers the frameworks that read `PORT` instead of a flag — and bind `0.0.0.0`. `healthUrl` is optional: omit it and a service that speaks no HTTP at all still starts, just without a health check.
3. **The image**, which is the real limit.
The image carries Node 24, a JDK 25 and Python 3. It is Debian and not Alpine on purpose: on musl, pip cannot use the manylinux wheels nearly every package ships, so it falls back to building from source and needs a compiler — a bigger image than Debian, reached by a longer road.
There is no Maven in it. A Spring project carries `./mvnw`, which pins the version the project is built with; installing a second Maven would only give an agent a way to use the wrong one.
Adding a toolchain means changing the image, not the code. Two things travel with that decision: the install command (`install` takes any allowlisted command, so `pip install -r` and `./mvnw dependency:go-offline` are just configuration), and the egress allowlist in the proxy — a Go or Rust project needs its module proxy added there or its install will simply find nothing.
A frontend and a backend together is two service definitions, each with its own instance and port. Both count against `DEV_SERVER_MAX_INSTANCES`.
## Configuration
Copy `services.example.json` to an operator-controlled path and mount it read-only at `/config/services.json`. `DEV_SERVER_ALLOWED_COMMANDS` is a second allowlist applied when configuration is loaded, and it covers install commands too.
`${port}` is the only substitution a definition may contain, and writing it anywhere is what asks for an allocated port. An unknown placeholder is refused at load time.
Two flags in a service definition are not optional, both learned by running this:
- `--host 0.0.0.0`: a dev server left to its default binds IPv6 loopback only. A health check on `127.0.0.1` then declares a live server dead, and no other container can reach it.
- `--strictPort`: without it a dev server whose port is busy moves to another one and mentions it only in its log, leaving the preview at an address nobody looks at.
Every install logs the npm and node the sanitised `PATH` resolved to. That is not decoration: the toolchain decides which platform-specific optional dependencies land in `node_modules`, and getting it wrong produces an install that exits 0 followed by a dev server dying on a missing native module — a stack trace that says nothing about the real cause.
The MCP endpoint is `POST /mcp`. Initialize first and send the returned `Mcp-Session-Id` on subsequent `POST`, optional `GET`, and final `DELETE` requests. Legacy SSE endpoints and stdio are not implemented.
Required environment variables:
- `DEV_SERVER_MCP_API_KEYS`: comma-separated `SUBJECT=TOKEN` values; tokens must contain at least 32 characters.
- `DEV_SERVER_WORKER_TOKEN`: independent token shared only by the MCP facade and private worker.
- Worker only: `DEV_SERVER_CONFIG`, `DEV_SERVER_WORKSPACE_ROOT`, and `DEV_SERVER_ALLOWED_COMMANDS`.
Worker settings for the per-execution mode:
| variable | default | notes |
|---|---|---|
| `DEV_SERVER_INSTANCES_ROOT` | unset | Where the copies live. Required for `execution` services; without it they are refused. Must be writable, on disk, and allow execution — not the `noexec` RAM-backed `/tmp`, where native `.node` modules will not load and a 333 MB dependency tree would eat a quarter of the memory limit. |
| `DEV_SERVER_PORT_RANGE` | `5200-5219` | Ports handed to instances. A port is offered only after the worker has bound it itself, so a port held by an orphan is skipped. |
| `DEV_SERVER_MAX_INSTANCES` | `4` | Concurrent running services. Sized by memory: two frontend builds saturate a 2 GB container. |
| `DEV_SERVER_EGRESS_PROXY` | unset | The worker's only route out. Set to the stack's proxy, which allows CONNECT to the package registries and nothing else. npm and pip read the variables it produces; a JVM does not, so a Maven service carries `-Dhttps.proxyHost` in its declared arguments. |
| `DEV_SERVER_NO_PROXY` | unset | Hosts to reach directly, the loopback among them. |
| `DEV_SERVER_NPM_REGISTRY` | unset | Only needed if you put a caching mirror in front of npm; unset means the public registry, through the proxy. |
| `DEV_SERVER_NPM_CACHE` | unset | Shared between executions, which is safe because `npm ci` verifies every package against the lockfile: a poisoned cache fails the install instead of passing code through. Worth it — it took a cold install of a real 523-package project from 14 s to 2.5 s. |
| `DEV_SERVER_MAX_WORKSPACE_BYTES` | `536870912` | Refuses a runaway tree instead of filling the volume. |
| `DEV_SERVER_MAX_WORKSPACE_ENTRIES` | `20000` | The same, by file count. |
| `DEV_SERVER_CHILD_PATH` | `/usr/local/bin:/usr/bin:/bin` | The sanitised `PATH` children run with. Configurable only because a host outside the image keeps its toolchain elsewhere. |
| `DEV_SERVER_INSTANCE_HOME` | `/tmp/dev-server` | `HOME` for `shared` services only. A per-execution service gets a HOME of its own instead, which is how Maven's `~/.m2` and pip's `~/.cache` end up isolated without a line of per-toolchain code. That matters most for Maven: npm's shared cache is safe because `npm ci` verifies every package against the lockfile, while Maven has no lockfile to verify against, so a shared local repository would be a channel from one execution into the next. |
| `JAVA_HOME` | from the image | Forwarded to children. A JVM finds its home through this before it looks at `PATH`, and `./mvnw` refuses to run without one of the two. |
A `start_service` that installs holds its request open, so the real ceiling on an install is the MCP client's request timeout — 120 s in the workflow manager, not the worker's own limits. A project whose install is slower than that belongs in `shared` mode with its dependencies installed ahead of time.
Run tests with `npm test`.

15
dev-server-mcp/package-lock.json generated Normal file
View File

@ -0,0 +1,15 @@
{
"name": "dev-server-mcp",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dev-server-mcp",
"version": "1.0.0",
"engines": {
"node": ">=20"
}
}
}
}

View File

@ -0,0 +1,14 @@
{
"name": "dev-server-mcp",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js",
"start:worker": "node src/worker-index.js",
"test": "node --test"
},
"engines": {
"node": ">=20"
}
}

View File

@ -0,0 +1,153 @@
{
"_comment": [
"Operator-owned. An MCP caller selects a service id and nothing else: commands, arguments,",
"directories and environment all come from here.",
"",
"workspaceMode 'shared' serves one fixed tree in the workspace mount, with its dependencies",
"installed ahead of time by the operator. 'execution' serves the tree the current execution",
"wrote, in a throwaway copy of it, and may take an allocated ${port}.",
"",
"Two flags are not optional in a service definition, both learned the hard way:",
" --host 0.0.0.0 a dev server left to its default binds IPv6 loopback only, so a health",
" check on 127.0.0.1 declares a perfectly live server dead, and no other",
" container can reach it.",
" --strictPort without it a dev server whose port is busy moves to another one and says",
" so only in its log, leaving the preview at an address nobody looks at."
],
"_comment_java": [
"./mvnw and not mvn: the wrapper pins the Maven version the project is built with, so the",
"image carries no Maven of its own to be used by mistake.",
"",
"The proxy is passed as system properties because a JVM ignores HTTP_PROXY entirely - the",
"worker sets those variables, and Maven is the one toolchain that cannot read them.",
"",
"A first build downloads the whole dependency tree into this execution's own ~/.m2, which is",
"why the timeouts here are the largest in the file. Past the MCP client's own 120 s request",
"timeout, use workspaceMode 'shared' with a prepared repository instead."
],
"_comment_python": [
"python3 -m uvicorn and not the uvicorn script: a --user install puts its console scripts in",
"$HOME/.local/bin, which is not on the sanitised PATH, while the module itself is found",
"because the per-execution HOME puts its user site on sys.path.",
"",
"--break-system-packages reads worse than it is: Debian marks its own python as externally",
"managed, and this flag only lifts that refusal. Combined with --user, nothing outside this",
"execution's HOME is written."
],
"services": {
"web": {
"command": "npm",
"args": [
"run",
"dev",
"--",
"--host",
"0.0.0.0",
"--port",
"5173",
"--strictPort"
],
"cwd": ".",
"workspaceMode": "shared",
"publicUrl": "http://dev-server-worker:5173",
"healthUrl": "http://127.0.0.1:5173/",
"startupTimeoutMs": 20000,
"shutdownTimeoutMs": 5000,
"env": {
"PORT": "5173"
}
},
"execution-web": {
"command": "npm",
"args": [
"run",
"dev",
"--",
"--host",
"0.0.0.0",
"--port",
"${port}",
"--strictPort"
],
"cwd": ".",
"workspaceMode": "execution",
"install": {
"command": "npm",
"args": [
"ci",
"--ignore-scripts",
"--no-audit",
"--no-fund"
],
"timeoutMs": 90000
},
"publicUrl": "http://dev-server-worker:${port}",
"healthUrl": "http://127.0.0.1:${port}/",
"startupTimeoutMs": 60000,
"shutdownTimeoutMs": 5000,
"env": {
"PORT": "${port}"
}
},
"execution-api": {
"command": "./mvnw",
"args": [
"-B",
"-q",
"-Dhttps.proxyHost=egress-proxy",
"-Dhttps.proxyPort=3128",
"spring-boot:run",
"-Dspring-boot.run.arguments=--server.port=${port} --server.address=0.0.0.0"
],
"cwd": ".",
"workspaceMode": "execution",
"install": {
"command": "./mvnw",
"args": [
"-B",
"-q",
"-Dhttps.proxyHost=egress-proxy",
"-Dhttps.proxyPort=3128",
"dependency:go-offline"
],
"timeoutMs": 600000
},
"publicUrl": "http://dev-server-worker:${port}",
"healthUrl": "http://127.0.0.1:${port}/actuator/health",
"startupTimeoutMs": 120000,
"shutdownTimeoutMs": 15000
},
"execution-python": {
"command": "python3",
"args": [
"-m",
"uvicorn",
"main:app",
"--host",
"0.0.0.0",
"--port",
"${port}"
],
"cwd": ".",
"workspaceMode": "execution",
"install": {
"command": "python3",
"args": [
"-m",
"pip",
"install",
"--user",
"--break-system-packages",
"--no-input",
"-r",
"requirements.txt"
],
"timeoutMs": 180000
},
"publicUrl": "http://dev-server-worker:${port}",
"healthUrl": "http://127.0.0.1:${port}/",
"startupTimeoutMs": 60000,
"shutdownTimeoutMs": 5000
}
}
}

View File

@ -0,0 +1,141 @@
import fs from "node:fs";
import path from "node:path";
import { assertPlaceholders, hasPlaceholder, substitute } from "./templates.js";
const SERVICE_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
const RESERVED_ENVIRONMENT = new Set(["PATH", "HOME", "TMPDIR", "NODE_OPTIONS", "LD_PRELOAD", "LD_LIBRARY_PATH"]);
const WORKSPACE_MODES = new Set(["shared", "execution"]);
function inside(candidate, root) {
const relative = path.relative(root, candidate);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function stringArray(value, label, maxItems = 64) {
if (!Array.isArray(value) || value.length > maxItems || value.some((item) => typeof item !== "string" || item.length > 2048 || item.includes("\0"))) {
throw new Error(`${label} must be an array of safe strings`);
}
return value.map((item) => assertPlaceholders(item, label));
}
function safeUrl(value, label) {
if (value === undefined || value === null) return null;
// A URL carrying ${port} is not a URL yet. Validate its shape against a probe port so a
// malformed template is refused at load time, and keep the template for the real start.
const probe = substitute(value, { port: 1 });
const url = new URL(probe);
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) throw new Error(`${label} must be an HTTP(S) URL without credentials`);
return assertPlaceholders(value, label);
}
function boundedNumber(value, fallback, minimum, maximum, label) {
const number = value === undefined ? fallback : Number(value);
if (!Number.isFinite(number) || number < minimum || number > maximum) throw new Error(`${label} must be between ${minimum} and ${maximum}`);
return number;
}
function relativePath(value, label) {
if (typeof value !== "string" || path.isAbsolute(value) || value.includes("\0")) throw new Error(`Invalid ${label}`);
const normalized = path.normalize(value);
if (normalized === ".." || normalized.startsWith(`..${path.sep}`)) throw new Error(`Invalid ${label}`);
return normalized;
}
/**
* Resolves a service's working directory under a root, refusing anything that leaves it.
*
* <p>Used both at load time for a shared service and at start time for a per-execution one: the
* tree is written by whoever the flow gave write access to, so a symlink inside it is untrusted
* input and the real path has to be checked, not the nominal one.
*/
export function resolveServiceCwd(relativeCwd, root, label) {
const resolved = path.resolve(root, relativeCwd);
if (!inside(resolved, root) || !fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) throw new Error(`Service cwd is outside or missing: ${label}`);
const real = fs.realpathSync(resolved);
if (!inside(real, fs.realpathSync(root))) throw new Error(`Service cwd escapes through a symlink: ${label}`);
return real;
}
function installCommand(value, commandAllowlist, id) {
if (value === undefined || value === null) return null;
if (typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid install for service: ${id}`);
if (typeof value.command !== "string" || !commandAllowlist.has(value.command)) throw new Error(`Install command is not allowlisted for service: ${id}`);
return {
command: value.command,
args: stringArray(value.args ?? [], `${id}.install.args`),
// A start blocks until the install finishes, so the real ceiling is the MCP client's request
// timeout (120 s in the workflow manager), not this number. A project whose install is slower
// than that belongs in the shared mode with its dependencies installed ahead of time.
timeoutMs: boundedNumber(value.timeoutMs, 90000, 1000, 600000, `${id}.install.timeoutMs`)
};
}
export function loadDevServerConfig({ configPath, workspaceRoot, allowedCommands }) {
const root = fs.realpathSync(workspaceRoot);
const parsed = JSON.parse(fs.readFileSync(configPath, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !parsed.services || typeof parsed.services !== "object") {
throw new Error("Development-server config must contain a services object");
}
const commandAllowlist = new Set(allowedCommands);
if (commandAllowlist.size === 0) throw new Error("DEV_SERVER_ALLOWED_COMMANDS must not be empty");
const services = new Map();
for (const [id, value] of Object.entries(parsed.services)) {
if (!SERVICE_ID.test(id)) throw new Error(`Invalid service id: ${id}`);
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid service: ${id}`);
if (typeof value.command !== "string" || !commandAllowlist.has(value.command)) throw new Error(`Command is not allowlisted for service: ${id}`);
const workspaceMode = value.workspaceMode ?? "shared";
if (!WORKSPACE_MODES.has(workspaceMode)) throw new Error(`Invalid workspaceMode for service: ${id}`);
const relativeCwd = relativePath(value.cwd ?? ".", `cwd for service: ${id}`);
const environment = {};
for (const [name, envValue] of Object.entries(value.env ?? {})) {
if (RESERVED_ENVIRONMENT.has(name) || !/^[A-Z_][A-Z0-9_]{0,63}$/.test(name) || typeof envValue !== "string" || envValue.length > 4096 || envValue.includes("\0")) {
throw new Error(`Invalid environment entry for service: ${id}`);
}
environment[name] = assertPlaceholders(envValue, `${id}.env.${name}`);
}
const args = stringArray(value.args ?? [], `${id}.args`);
const publicUrl = safeUrl(value.publicUrl, `${id}.publicUrl`);
const healthUrl = safeUrl(value.healthUrl, `${id}.healthUrl`);
// A service asks for an allocated port by writing ${port} somewhere. Nothing else declares it,
// so there is no second source of truth to drift.
const needsPort = [...args, publicUrl, healthUrl, ...Object.values(environment)].some((item) => hasPlaceholder(item, "port"));
if (needsPort && workspaceMode !== "execution") {
throw new Error(`Only an execution-scoped service may take an allocated port: ${id}`);
}
const definition = {
id,
command: value.command,
args,
workspaceMode,
relativeCwd,
needsPort,
install: installCommand(value.install, commandAllowlist, id),
env: environment,
publicUrl,
healthUrl,
startupTimeoutMs: boundedNumber(value.startupTimeoutMs, 15000, 100, 600000, `${id}.startupTimeoutMs`),
shutdownTimeoutMs: boundedNumber(value.shutdownTimeoutMs, 5000, 100, 30000, `${id}.shutdownTimeoutMs`)
};
if (workspaceMode === "shared") {
// The shared tree exists now, so it is checked now: an operator learns about a bad path at
// startup instead of on a caller's first request.
definition.cwd = resolveServiceCwd(relativeCwd, root, id);
definition.clientCwd = path.relative(root, definition.cwd).split(path.sep).join("/") || ".";
} else {
// The per-execution tree does not exist until an execution has written one, so only the
// shape of the path can be checked here. resolveServiceCwd runs again at start time.
definition.cwd = null;
definition.clientCwd = relativeCwd.split(path.sep).join("/");
}
services.set(id, definition);
}
if (services.size === 0) throw new Error("At least one service must be configured");
return { root, services };
}

View File

@ -0,0 +1,380 @@
import { spawn, spawnSync } from "node:child_process";
import { resolveServiceCwd } from "./config.js";
import { isUnresolved, substitute } from "./templates.js";
import {
assertInstanceKey,
prepareInstanceWorkspace,
recordInstall,
removeInstanceWorkspace
} from "./instance-workspace.js";
const DEFAULT_LOG_LIMIT = 256 * 1024;
const SHARED = "";
function appendLog(instance, stream, chunk) {
const text = typeof chunk === "string" ? chunk : chunk.toString("utf8");
instance.logs += `[${stream}] ${text}`;
const bytes = Buffer.byteLength(instance.logs);
if (bytes > instance.logLimit) instance.logs = Buffer.from(instance.logs).subarray(bytes - instance.logLimit).toString("utf8");
}
function cleanEnvironment(extra, { port, npmRegistry, npmCache, home, childPath, proxy, javaHome }) {
const resolved = {};
for (const [name, value] of Object.entries(extra ?? {})) resolved[name] = substitute(value, { port });
return {
PATH: childPath,
HOME: home,
TMPDIR: home,
NODE_ENV: "development",
NO_COLOR: "1",
...(npmRegistry ? { npm_config_registry: npmRegistry } : {}),
...(npmCache ? { npm_config_cache: npmCache } : {}),
// A JVM finds its own home through JAVA_HOME before it looks at PATH, and ./mvnw refuses to
// run without one of the two. Forwarded rather than hardcoded: the image decides where it is.
...(javaHome ? { JAVA_HOME: javaHome } : {}),
...proxyEnvironment(proxy),
...resolved
};
}
/**
* The only route out of this container, handed to every child.
*
* <p>Both spellings, because the ecosystems disagree: curl and pip read the lowercase names, npm
* and many Node tools the uppercase ones. Maven reads neither - a JVM takes its proxy from system
* properties - so a Maven service has to carry -Dhttps.proxyHost in the arguments the operator
* declares. That is not an oversight to fix here: the arguments are operator-owned on purpose.
*/
function proxyEnvironment(proxy) {
if (!proxy?.url) return {};
const values = { HTTP_PROXY: proxy.url, HTTPS_PROXY: proxy.url, http_proxy: proxy.url, https_proxy: proxy.url };
if (proxy.noProxy) { values.NO_PROXY = proxy.noProxy; values.no_proxy = proxy.noProxy; }
return values;
}
function publicStatus(definition, instance) {
// Without a running instance there is no port, so a port-templated URL is not an address yet.
// Reporting null says that; reporting the template would invite a caller to try it.
const resolvedUrl = substitute(definition.publicUrl, { port: instance?.port });
return {
service: definition.id,
key: instance?.key || null,
status: instance?.status ?? "stopped",
pid: instance?.child?.pid ?? null,
port: instance?.port ?? null,
startedAt: instance?.startedAt ?? null,
exitedAt: instance?.exitedAt ?? null,
exitCode: instance?.exitCode ?? null,
signal: instance?.signal ?? null,
cwd: instance?.clientCwd ?? definition.clientCwd,
publicUrl: isUnresolved(resolvedUrl) ? null : (resolvedUrl ?? null)
};
}
async function healthCheck(url, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, { redirect: "error", signal: controller.signal });
return response.status < 500;
} catch { return false; }
finally { clearTimeout(timer); }
}
export class DevServerService {
constructor({ services, logLimit = DEFAULT_LOG_LIMIT, workspaceRoot = null, instancesRoot = null,
portAllocator = null, maxInstances = 4, npmRegistry = null, npmCache = null,
workspaceLimits = null, instanceHome = "/tmp/dev-server",
childPath = "/usr/local/bin:/usr/bin:/bin", proxy = null, javaHome = null }) {
this.services = services;
this.instances = new Map();
this.logLimit = logLimit;
this.workspaceRoot = workspaceRoot;
this.instancesRoot = instancesRoot;
this.portAllocator = portAllocator;
this.maxInstances = maxInstances;
this.npmRegistry = npmRegistry;
this.npmCache = npmCache;
this.workspaceLimits = workspaceLimits;
this.instanceHome = instanceHome;
this.childPath = childPath;
this.proxy = proxy;
this.javaHome = javaHome;
}
definition(service) {
if (typeof service !== "string" || !this.services.has(service)) throw new Error("Unknown service");
return this.services.get(service);
}
/**
* The key an instance is filed under: the execution for a per-execution service, and a single
* shared slot for the operator's fixed ones. A caller that supplies no key can only ever reach
* the shared slot, so a missing header cannot silently land on someone else's instance.
*/
slot(definition, key) {
if (definition.workspaceMode !== "execution") return SHARED;
if (key === undefined || key === null || key === "") throw new Error(`Service ${definition.id} runs per execution and needs an execution key`);
return assertInstanceKey(key);
}
handle(definition, key) {
return `${definition.id}\u0000${this.slot(definition, key)}`;
}
list({ key } = {}) {
return {
services: [...this.services.values()].map((definition) => {
// A definition the caller cannot address is still worth listing - the model has to know
// the service exists - but its state is only ever the caller's own.
let instance = null;
try { instance = this.instances.get(this.handle(definition, key)) ?? null; } catch { instance = null; }
return { ...publicStatus(definition, instance), workspaceMode: definition.workspaceMode };
})
};
}
status({ service, key }) {
const definition = this.definition(service);
return publicStatus(definition, this.instances.get(this.handle(definition, key)));
}
runningCount() {
let running = 0;
for (const instance of this.instances.values()) if (instance.child) running++;
return running;
}
async start({ service, key }) {
const definition = this.definition(service);
const handle = this.handle(definition, key);
const slot = this.slot(definition, key);
const current = this.instances.get(handle);
if (current && ["starting", "running", "stopping"].includes(current.status)) throw new Error("Service is already active");
if (this.runningCount() >= this.maxInstances) {
throw new Error(`Already running ${this.maxInstances} services: raise DEV_SERVER_MAX_INSTANCES or stop one`);
}
const instance = {
key: slot,
status: "starting",
startedAt: new Date().toISOString(),
exitedAt: null,
exitCode: null,
signal: null,
logs: current?.logs ?? "",
logLimit: this.logLimit,
child: null,
port: null,
cwd: null,
home: null,
clientCwd: definition.clientCwd
};
this.instances.set(handle, instance);
try {
const workspace = definition.workspaceMode === "execution"
? this.prepare(definition, slot, instance)
: { root: definition.cwd, cwd: definition.cwd, home: this.instanceHome };
instance.cwd = workspace.cwd;
instance.home = workspace.home ?? null;
if (definition.needsPort) instance.port = await this.portAllocator.take(handle);
await this.spawnChild(definition, instance);
await this.awaitHealth(definition, instance, handle);
instance.status = "running";
return publicStatus(definition, instance);
} catch (error) {
instance.status = "stopped";
instance.exitedAt = new Date().toISOString();
if (instance.port !== null) { this.portAllocator?.release(instance.port); instance.port = null; }
appendLog(instance, "error", `${error instanceof Error ? error.message : error}\n`);
throw error;
}
}
/** Refreshes the execution's copy and installs its dependencies if the lockfile moved. */
prepare(definition, slot, instance) {
if (!this.workspaceRoot || !this.instancesRoot) throw new Error("This worker is not configured for per-execution services");
const prepared = prepareInstanceWorkspace({
workspaceRoot: this.workspaceRoot,
instancesRoot: this.instancesRoot,
key: slot,
limits: this.workspaceLimits
});
appendLog(instance, "worker", `copied ${prepared.entries} entries (${prepared.bytes} bytes) for execution ${slot}\n`);
const cwd = resolveServiceCwd(definition.relativeCwd, prepared.target, definition.id);
instance.clientCwd = definition.clientCwd;
if (definition.install && !prepared.installIsCurrent) {
this.install(definition, instance, cwd, prepared.target);
} else if (definition.install) {
appendLog(instance, "worker", "dependencies already match the lockfile, install skipped\n");
}
return { root: prepared.target, cwd, home: prepared.home };
}
install(definition, instance, cwd, target) {
// Which npm and node ran the install decides which platform-specific optional dependencies
// land in node_modules. Get that wrong and the install still exits 0, while the dev server
// dies on a missing native module - a stack trace that says nothing about the real cause. One
// line here turns that hour of confusion into a glance.
appendLog(instance, "worker", `installing dependencies with ${toolchain(cwd, this.childPath)}: ${definition.install.command} ${definition.install.args.join(" ")}\n`);
const started = Date.now();
const result = spawnSyncCapped(definition.install, cwd, cleanEnvironment(definition.env, {
port: instance.port,
npmRegistry: this.npmRegistry,
npmCache: this.npmCache,
home: instance.home ?? this.instanceHome,
childPath: this.childPath,
proxy: this.proxy,
javaHome: this.javaHome
}), instance);
if (result.status !== 0) {
throw new Error(`Dependency install failed with exit code ${result.status ?? "signal " + result.signal}`);
}
recordInstall(target);
appendLog(instance, "worker", `dependencies installed in ${Date.now() - started} ms\n`);
}
async spawnChild(definition, instance) {
const args = definition.args.map((argument) => substitute(argument, { port: instance.port }));
const child = spawn(definition.command, args, {
cwd: instance.cwd,
env: cleanEnvironment(definition.env, {
port: instance.port,
npmRegistry: this.npmRegistry,
npmCache: this.npmCache,
home: instance.home ?? this.instanceHome,
childPath: this.childPath,
proxy: this.proxy,
javaHome: this.javaHome
}),
shell: false,
detached: true,
stdio: ["ignore", "pipe", "pipe"]
});
instance.child = child;
child.stdout.on("data", (chunk) => appendLog(instance, "stdout", chunk));
child.stderr.on("data", (chunk) => appendLog(instance, "stderr", chunk));
child.once("error", (error) => appendLog(instance, "error", Buffer.from(`${error.message}\n`)));
child.once("exit", (code, signal) => {
instance.status = "stopped";
instance.exitCode = code;
instance.signal = signal;
instance.exitedAt = new Date().toISOString();
instance.child = null;
if (instance.port !== null) { this.portAllocator?.release(instance.port); }
});
await new Promise((resolve, reject) => {
const onSpawn = () => { cleanup(); resolve(); };
const onError = (error) => { cleanup(); reject(error); };
const cleanup = () => { child.off("spawn", onSpawn); child.off("error", onError); };
child.once("spawn", onSpawn);
child.once("error", onError);
});
}
async awaitHealth(definition, instance, handle) {
if (!definition.healthUrl) return;
const url = substitute(definition.healthUrl, { port: instance.port });
const deadline = Date.now() + definition.startupTimeoutMs;
while (Date.now() < deadline && instance.child) {
if (await healthCheck(url, 1000)) return;
await new Promise((resolve) => setTimeout(resolve, 200));
}
if (!instance.child || !(await healthCheck(url, 1000))) {
await this.stopHandle(definition, handle).catch(() => {});
throw new Error(`Service did not answer ${url} before the startup timeout`);
}
}
async stop({ service, key }) {
const definition = this.definition(service);
return this.stopHandle(definition, this.handle(definition, key));
}
async stopHandle(definition, handle) {
const instance = this.instances.get(handle);
if (!instance?.child) return publicStatus(definition, instance);
instance.status = "stopping";
const child = instance.child;
const gracefulExit = new Promise((resolve) => child.once("exit", resolve));
try { process.kill(-child.pid, "SIGTERM"); } catch { child.kill("SIGTERM"); }
await Promise.race([
gracefulExit,
new Promise((resolve) => setTimeout(resolve, definition.shutdownTimeoutMs))
]);
if (instance.child) {
const forcedExit = new Promise((resolve) => child.once("exit", resolve));
try { process.kill(-child.pid, "SIGKILL"); } catch { child.kill("SIGKILL"); }
await Promise.race([forcedExit, new Promise((resolve) => setTimeout(resolve, 2000))]);
}
this.portAllocator?.releaseAllOf(handle);
return publicStatus(definition, instance);
}
async restart({ service, key }) {
await this.stop({ service, key });
// Deliberately a full start: it re-copies the tree, so a restart after an edit serves the new
// code. A restart that served the old code would be the worst defect this tool could have.
return this.start({ service, key });
}
/** Stops the instance and throws its copy away. The only way an execution's disk is reclaimed. */
async discard({ service, key }) {
const definition = this.definition(service);
const handle = this.handle(definition, key);
const slot = this.slot(definition, key);
await this.stopHandle(definition, handle);
this.instances.delete(handle);
if (definition.workspaceMode === "execution" && this.instancesRoot) removeInstanceWorkspace(this.instancesRoot, slot);
return { service, key: slot || null, status: "discarded" };
}
logs({ service, key, tailBytes = 4096 }) {
const definition = this.definition(service);
if (!Number.isInteger(tailBytes) || tailBytes < 1 || tailBytes > this.logLimit) throw new Error(`tailBytes must be between 1 and ${this.logLimit}`);
const logs = this.instances.get(this.handle(definition, key))?.logs ?? "";
const buffer = Buffer.from(logs);
return {
service,
logs: buffer.subarray(Math.max(0, buffer.length - tailBytes)).toString("utf8"),
truncated: buffer.length > tailBytes
};
}
async close() {
await Promise.all([...this.instances.keys()].map((handle) => {
const definition = this.services.get(handle.split("\u0000")[0]);
return definition ? this.stopHandle(definition, handle).catch(() => {}) : Promise.resolve();
}));
}
}
/** The npm and node the sanitised PATH actually resolves to, as one readable line. */
function toolchain(cwd, childPath) {
const ask = (command, args) => {
const result = spawnSync(command, args, { cwd, env: { PATH: childPath }, encoding: "utf8", timeout: 10000 });
return (result.stdout ?? "").trim() || "unknown";
};
return `npm ${ask("npm", ["-v"])} on node ${ask("node", ["-v"])}`;
}
/**
* Runs the install synchronously, so no caller can start the dev server on half-installed
* dependencies, and folds its output into the same log the model reads.
*/
function spawnSyncCapped(install, cwd, env, instance) {
const result = spawnSync(install.command, install.args, {
cwd,
env,
shell: false,
timeout: install.timeoutMs,
maxBuffer: 8 * 1024 * 1024,
encoding: "utf8"
});
if (result.stdout) appendLog(instance, "install", result.stdout);
if (result.stderr) appendLog(instance, "install", result.stderr);
if (result.error) throw result.error;
return result;
}

View File

@ -0,0 +1,61 @@
#!/usr/bin/env node
import { startStreamableHttpServer } from "./streamable-http.js";
import { TOOL_DEFINITIONS } from "./tool-definitions.js";
import { WorkerClient } from "./worker-client.js";
function csv(value) { return (value ?? "").split(",").map((item) => item.trim()).filter(Boolean); }
function integer(value, fallback) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; }
const client = new WorkerClient({
baseUrl: process.env.DEV_SERVER_WORKER_URL ?? "http://dev-server-worker:4000",
token: process.env.DEV_SERVER_WORKER_TOKEN ?? ""
});
const actionByTool = {
list_services: "list",
service_status: "status",
start_service: "start",
stop_service: "stop",
restart_service: "restart",
discard_service: "discard",
service_logs: "logs"
};
const PREVIEW_KEY_HEADER = "x-preview-key";
async function auditedCall(name, args, context) {
// The execution key is never a tool argument: the model cannot name a scope, only work inside
// the one the session was opened for.
const key = context.pinned?.[PREVIEW_KEY_HEADER] || undefined;
const event = {
timestamp: new Date().toISOString(), server: "dev-server-mcp", tool: name,
subject: context.subject, sessionId: context.sessionId, key: key ?? null
};
try {
const result = await client.call(actionByTool[name], { ...args, key });
process.stderr.write(`${JSON.stringify({ ...event, outcome: "success" })}\n`);
return result;
} catch (error) {
process.stderr.write(`${JSON.stringify({ ...event, outcome: "failure", errorType: error instanceof Error ? error.name : "Error" })}\n`);
throw error;
}
}
const listener = await startStreamableHttpServer({
serverName: "dev-server-mcp",
serverVersion: "1.0.0",
tools: TOOL_DEFINITIONS,
callTool: auditedCall,
apiKeys: csv(process.env.DEV_SERVER_MCP_API_KEYS),
allowedOrigins: csv(process.env.DEV_SERVER_MCP_CORS_ORIGINS),
host: process.env.DEV_SERVER_MCP_HOST ?? "0.0.0.0",
port: integer(process.env.DEV_SERVER_MCP_PORT, 3000),
maxSessions: integer(process.env.DEV_SERVER_MCP_MAX_SESSIONS, 64),
sessionTtlMs: integer(process.env.DEV_SERVER_MCP_SESSION_TTL_SECONDS, 1800) * 1000,
pinnedHeaders: [PREVIEW_KEY_HEADER]
});
process.stderr.write(`dev-server-mcp listening on ${listener.host}:${listener.port}\n`);
async function shutdown() { await listener.close(); process.exit(0); }
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);

View File

@ -0,0 +1,153 @@
import fs from "node:fs";
import path from "node:path";
import crypto from "node:crypto";
const KEY = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
const SKIPPED_DIRECTORIES = new Set(["node_modules", ".git"]);
const INSTALL_MARKER = ".dev-server-install";
export function assertInstanceKey(value) {
if (typeof value !== "string" || !KEY.test(value)) throw new Error("Invalid execution key");
return value;
}
/**
* The directory an execution's copy lives in. One path segment, derived from the key and never
* from anything the caller wrote, so there is no path to traverse out of.
*/
export function instanceDirectory(instancesRoot, key) {
return path.join(instancesRoot, assertInstanceKey(key));
}
/**
* A HOME of its own for each execution, which is how the caches of every toolchain but npm end up
* isolated without a line of per-toolchain code: Maven's ~/.m2 and pip's ~/.cache both follow HOME.
*
* <p>Maven matters most here. npm's shared cache is safe because `npm ci` verifies every package
* against the lockfile, so a tampered cache entry fails the install; Maven has no lockfile to
* verify against, so a shared local repository would be a channel from one execution into the
* next. It lives outside the copied tree so a refresh does not throw it away.
*
* <p>The leading dot is what keeps it from colliding with a copy: an execution key can never start
* with one.
*/
export function homeDirectory(instancesRoot, key) {
return path.join(instancesRoot, ".homes", assertInstanceKey(key));
}
function sourceDirectory(workspaceRoot, key) {
const root = fs.realpathSync(workspaceRoot);
const candidate = path.join(root, assertInstanceKey(key));
if (!fs.existsSync(candidate)) throw new Error(`No workspace has been written for this execution yet: ${key}`);
const real = fs.realpathSync(candidate);
const relative = path.relative(root, real);
// The execution's own directory is written by the coding agent, so it can be replaced by a
// symlink to somewhere else entirely. Refusing here is what keeps one execution out of another.
if (relative.startsWith("..") || path.isAbsolute(relative)) throw new Error(`Workspace for ${key} escapes the workspace root`);
if (!fs.statSync(real).isDirectory()) throw new Error(`Workspace for ${key} is not a directory`);
return real;
}
/**
* Copies one execution's tree, dropping every symlink and skipping node_modules and .git.
*
* <p>Symlinks are dropped rather than followed or preserved: the tree is untrusted input, and a
* link to another execution's directory would turn the copy - whose whole purpose is isolation -
* into a window onto it. A project that genuinely needs a symlink will notice its absence, which
* is a better failure than a silent hole in the boundary.
*
* <p>node_modules is skipped because the install recreates it, and copying 333 MB of it would be
* the slowest part of every start. .git is skipped because a dev server has no use for it.
*/
function copyTree(source, target, limits, counters = { entries: 0, bytes: 0 }) {
fs.mkdirSync(target, { recursive: true, mode: 0o755 });
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
if (entry.isSymbolicLink()) continue;
if (entry.isDirectory() && SKIPPED_DIRECTORIES.has(entry.name)) continue;
if (++counters.entries > limits.maxEntries) throw new Error(`Workspace exceeds ${limits.maxEntries} entries`);
const from = path.join(source, entry.name);
const to = path.join(target, entry.name);
if (entry.isDirectory()) {
copyTree(from, to, limits, counters);
} else if (entry.isFile()) {
const size = fs.statSync(from).size;
counters.bytes += size;
if (counters.bytes > limits.maxBytes) throw new Error(`Workspace exceeds ${limits.maxBytes} bytes`);
fs.copyFileSync(from, to);
}
// Sockets, fifos and devices are neither copied nor reported: they cannot be part of a
// project a dev server serves, and refusing the whole start over one would be worse.
}
return counters;
}
/** Everything in the copy except what the install produced, so a refresh keeps the slow part. */
function pruneProjectFiles(target) {
for (const entry of fs.readdirSync(target, { withFileTypes: true })) {
if (entry.name === INSTALL_MARKER || (entry.isDirectory() && entry.name === "node_modules")) continue;
fs.rmSync(path.join(target, entry.name), { recursive: true, force: true });
}
}
/**
* Makes the execution's copy current with the workspace, and reports whether the dependency
* install still matches the lockfile it was run for.
*/
export function prepareInstanceWorkspace({ workspaceRoot, instancesRoot, key, limits }) {
const source = sourceDirectory(workspaceRoot, key);
const target = instanceDirectory(instancesRoot, key);
if (fs.existsSync(target)) pruneProjectFiles(target);
const counters = copyTree(source, target, {
maxEntries: limits?.maxEntries ?? 20000,
maxBytes: limits?.maxBytes ?? 512 * 1024 * 1024
});
const home = homeDirectory(instancesRoot, key);
fs.mkdirSync(home, { recursive: true, mode: 0o700 });
return { source, target, home, ...counters, installIsCurrent: installIsCurrent(target) };
}
/**
* The files that decide what an install produces, across the toolchains the image carries.
*
* <p>A fingerprint of these is what makes a repeated start cheap. It deliberately does not look
* for node_modules as evidence of an install: a Maven or Python project never has one, and
* treating its absence as "not installed yet" would re-run every install forever.
*/
const DEPENDENCY_MANIFESTS = [
"package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml",
"pom.xml", "requirements.txt", "poetry.lock", "pyproject.toml", "Pipfile.lock"
];
function lockfileFingerprint(target) {
const hash = crypto.createHash("sha256");
let found = false;
for (const name of DEPENDENCY_MANIFESTS) {
const file = path.join(target, name);
if (fs.existsSync(file)) {
hash.update(name).update(fs.readFileSync(file));
found = true;
}
}
return found ? hash.digest("hex") : null;
}
function installIsCurrent(target) {
const marker = path.join(target, INSTALL_MARKER);
if (!fs.existsSync(marker)) return false;
const fingerprint = lockfileFingerprint(target);
// With no manifest there is nothing to compare, so the install can never be declared current:
// re-running it is slow, while serving stale dependencies is wrong.
return fingerprint !== null && fs.readFileSync(marker, "utf8").trim() === fingerprint;
}
export function recordInstall(target) {
const fingerprint = lockfileFingerprint(target);
if (fingerprint) fs.writeFileSync(path.join(target, INSTALL_MARKER), fingerprint);
}
export function removeInstanceWorkspace(instancesRoot, key) {
// Both, or the disk is never really reclaimed: a Maven repository outlives the tree that pulled
// it and is the larger half of what an execution leaves behind.
fs.rmSync(instanceDirectory(instancesRoot, key), { recursive: true, force: true });
fs.rmSync(homeDirectory(instancesRoot, key), { recursive: true, force: true });
}

View File

@ -0,0 +1,66 @@
const DEFAULT_PROTOCOL_VERSION = "2025-03-26";
function toolResult(value) {
if (value && Array.isArray(value.content)) return value;
return {
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
structuredContent: value,
isError: false
};
}
function toolError(error) {
return {
content: [{ type: "text", text: error instanceof Error ? error.message : String(error) }],
isError: true
};
}
export function createMcpHandler({ serverName, serverVersion, tools, callTool }) {
return async function handle(message, context) {
if (!message || message.jsonrpc !== "2.0" || typeof message.method !== "string") {
return message?.id === undefined ? null : { jsonrpc: "2.0", id: message.id ?? null, error: { code: -32600, message: "Invalid Request" } };
}
if (message.method === "notifications/initialized" || message.method === "$/cancelRequest") return null;
let result;
try {
switch (message.method) {
case "initialize":
result = {
protocolVersion: context.protocolVersion || DEFAULT_PROTOCOL_VERSION,
capabilities: { tools: { listChanged: false } },
serverInfo: { name: serverName, version: serverVersion }
};
break;
case "ping":
result = {};
break;
case "tools/list":
result = { tools };
break;
case "tools/call": {
const name = message.params?.name;
if (typeof name !== "string" || !tools.some((tool) => tool.name === name)) throw new Error("Unknown tool");
try {
result = toolResult(await callTool(name, message.params?.arguments ?? {}, context));
} catch (error) {
result = toolError(error);
}
break;
}
default:
return message.id === undefined ? null : { jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "Method not found" } };
}
} catch (error) {
return message.id === undefined ? null : {
jsonrpc: "2.0",
id: message.id,
error: { code: -32603, message: error instanceof Error ? error.message : "Internal error" }
};
}
return message.id === undefined ? null : { jsonrpc: "2.0", id: message.id, result };
};
}

View File

@ -0,0 +1,60 @@
import net from "node:net";
const RANGE = /^(\d{2,5})-(\d{2,5})$/;
export function parsePortRange(value, fallback = "5200-5219") {
const match = RANGE.exec((value ?? fallback).trim());
if (!match) throw new Error("DEV_SERVER_PORT_RANGE must look like 5200-5219");
const from = Number(match[1]);
const to = Number(match[2]);
if (from < 1024 || to > 65535 || to < from) throw new Error("DEV_SERVER_PORT_RANGE must be a rising range above 1023");
return { from, to };
}
function isFree(port) {
return new Promise((resolve) => {
const probe = net.createServer();
probe.once("error", () => resolve(false));
probe.listen(port, "0.0.0.0", () => probe.close(() => resolve(true)));
});
}
/**
* Hands out one port per running instance from an operator-declared range.
*
* <p>A port is offered only after the allocator has bound it itself: a process this worker does
* not know about - an orphan left by an earlier run, for instance - holds its port, and handing
* that port to a dev server would produce two servers fighting over one address. The bind-then-
* release check leaves a small race, which is exactly why a service must be started with a
* strict-port flag: a dev server that quietly moves to another port is a preview nobody can find.
*/
export class PortAllocator {
constructor({ from, to }) {
this.from = from;
this.to = to;
this.taken = new Map();
}
async take(owner) {
for (let port = this.from; port <= this.to; port++) {
if (this.taken.has(port)) continue;
if (await isFree(port)) {
this.taken.set(port, owner);
return port;
}
}
throw new Error(`No free port in ${this.from}-${this.to}: raise DEV_SERVER_PORT_RANGE or stop an instance`);
}
release(port) {
this.taken.delete(port);
}
releaseAllOf(owner) {
for (const [port, holder] of this.taken) if (holder === owner) this.taken.delete(port);
}
get size() {
return this.taken.size;
}
}

View File

@ -0,0 +1,239 @@
import http from "node:http";
import { randomUUID, timingSafeEqual } from "node:crypto";
import { URL } from "node:url";
import { createMcpHandler } from "./mcp-core.js";
const DEFAULT_PROTOCOL_VERSION = "2025-03-26";
function sendJson(response, status, value, headers = {}) {
if (response.destroyed || response.writableEnded) return;
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", ...headers });
response.end(JSON.stringify(value));
}
function collectJson(request, limitBytes) {
return new Promise((resolve, reject) => {
let size = 0;
const chunks = [];
request.on("data", (chunk) => {
size += chunk.length;
if (size > limitBytes) {
reject(new Error("Request body too large"));
request.destroy();
return;
}
chunks.push(chunk);
});
request.on("end", () => {
try {
const value = JSON.parse(Buffer.concat(chunks).toString("utf8"));
if (Array.isArray(value)) throw new Error("JSON-RPC batches are disabled");
resolve(value);
} catch (error) { reject(error); }
});
request.on("error", reject);
});
}
function parseCredentials(entries) {
const result = new Map();
for (const raw of entries) {
const separator = raw.indexOf("=");
const subject = separator < 0 ? "shared" : raw.slice(0, separator).trim();
const token = (separator < 0 ? raw : raw.slice(separator + 1)).trim();
if (!subject || token.length < 32) throw new Error("Each API token must contain at least 32 characters");
result.set(subject, Buffer.from(token));
}
if (result.size === 0) throw new Error("At least one API token is required");
return result;
}
function authenticate(request, credentials) {
const bearer = typeof request.headers.authorization === "string"
? /^Bearer\s+(.+)$/i.exec(request.headers.authorization)?.[1]?.trim()
: null;
const token = bearer || (typeof request.headers["x-api-key"] === "string" ? request.headers["x-api-key"].trim() : null);
if (!token) return null;
const candidate = Buffer.from(token);
for (const [subject, expected] of credentials) {
if (candidate.length === expected.length && timingSafeEqual(candidate, expected)) return subject;
}
return null;
}
function sessionHeaders(session) {
return { "mcp-session-id": session.id, "mcp-protocol-version": session.protocolVersion };
}
/**
* Captures the headers a server scopes its work by, once, at session open.
*
* <p>The values name a scope the caller may not choose for itself - an execution, say - so they
* arrive as a header set by whoever opened the session rather than as a tool argument the model
* could write. Read here and frozen onto the session, they cannot be varied per request.
*/
function readPinnedHeaders(request, names) {
const pinned = {};
for (const name of names) {
const value = request.headers[name];
if (value === undefined) continue;
if (typeof value !== "string" || value.length > 256 || !/^[\x20-\x7e]*$/.test(value)) throw new Error(`Invalid ${name} header`);
pinned[name] = value.trim();
}
return pinned;
}
export function startStreamableHttpServer({
serverName,
serverVersion,
tools,
callTool,
apiKeys,
allowedOrigins = [],
host = "127.0.0.1",
port = 3000,
maxSessions = 64,
sessionTtlMs = 30 * 60 * 1000,
bodyLimitBytes = 1024 * 1024,
onSessionClose = async () => {},
pinnedHeaders = []
}) {
const credentials = parseCredentials(apiKeys);
const origins = new Set(allowedOrigins);
const sessions = new Map();
const handleMcp = createMcpHandler({ serverName, serverVersion, tools, callTool });
async function closeSession(session) {
if (!sessions.delete(session.id)) return;
for (const response of session.listeners) response.end();
session.listeners.clear();
await onSessionClose(session).catch(() => {});
}
const cleanup = setInterval(() => {
const now = Date.now();
for (const session of sessions.values()) {
if (now - session.lastSeen > sessionTtlMs) void closeSession(session);
}
}, Math.min(sessionTtlMs, 60_000));
cleanup.unref();
const server = http.createServer(async (request, response) => {
response.on("error", () => {});
response.setHeader("x-content-type-options", "nosniff");
response.setHeader("referrer-policy", "no-referrer");
const requestUrl = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`);
const origin = request.headers.origin;
if (typeof origin === "string") {
if (!origins.has(origin)) return sendJson(response, 403, { error: "Origin is not allowed" });
response.setHeader("access-control-allow-origin", origin);
response.setHeader("vary", "Origin");
response.setHeader("access-control-allow-headers", "authorization, x-api-key, content-type, mcp-session-id, mcp-protocol-version");
response.setHeader("access-control-allow-methods", "GET, POST, DELETE, OPTIONS");
}
if (request.method === "OPTIONS") {
if (typeof origin !== "string") return sendJson(response, 403, { error: "Origin is required" });
response.writeHead(204); response.end(); return;
}
const subject = authenticate(request, credentials);
if (!subject) {
response.setHeader("www-authenticate", "Bearer");
return sendJson(response, 401, { error: "Unauthorized" });
}
if (request.method === "GET" && requestUrl.pathname === "/health") {
return sendJson(response, 200, { ok: true, serverName, transport: "streamable-http" });
}
if (requestUrl.pathname !== "/mcp") return sendJson(response, 404, { error: "Not found" });
const requestedId = request.headers["mcp-session-id"];
let session = typeof requestedId === "string" ? sessions.get(requestedId) : null;
if (requestedId && (!session || session.subject !== subject)) return sendJson(response, 404, { error: "Unknown MCP session" });
if (request.method === "DELETE") {
if (!session) return sendJson(response, 404, { error: "Unknown MCP session" });
await closeSession(session);
response.writeHead(204); response.end(); return;
}
if (request.method === "GET") {
if (!session) return sendJson(response, 400, { error: "Mcp-Session-Id is required" });
session.lastSeen = Date.now();
response.writeHead(200, {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
connection: "keep-alive",
...sessionHeaders(session)
});
response.write(": connected\n\n");
session.listeners.add(response);
request.once("close", () => session.listeners.delete(response));
return;
}
if (request.method !== "POST") return sendJson(response, 405, { error: "Method not allowed" });
let message;
try { message = await collectJson(request, bodyLimitBytes); }
catch (error) { return sendJson(response, 400, { error: error instanceof Error ? error.message : "Invalid JSON" }); }
if (!session) {
if (message?.method !== "initialize") return sendJson(response, 400, { error: "Initialize the MCP session first" });
if (sessions.size >= maxSessions) return sendJson(response, 503, { error: "Session capacity reached" });
let pinned;
try { pinned = readPinnedHeaders(request, pinnedHeaders); }
catch (error) { return sendJson(response, 400, { error: error instanceof Error ? error.message : "Invalid header" }); }
session = {
id: randomUUID(),
subject,
protocolVersion: typeof message.params?.protocolVersion === "string" ? message.params.protocolVersion : DEFAULT_PROTOCOL_VERSION,
createdAt: Date.now(),
lastSeen: Date.now(),
listeners: new Set(),
pinned
};
sessions.set(session.id, session);
} else {
session.lastSeen = Date.now();
// A pinned header belongs to the session, not to the request: letting it change mid-session
// would let one caller reach another scope without opening a session for it.
for (const name of pinnedHeaders) {
const supplied = request.headers[name];
if (typeof supplied === "string" && supplied.trim() !== (session.pinned[name] ?? "")) {
return sendJson(response, 400, { error: `${name} cannot change within a session` });
}
}
}
const rpcResponse = await handleMcp(message, {
sessionId: session.id,
subject,
protocolVersion: session.protocolVersion,
pinned: session.pinned ?? {}
});
if (rpcResponse === null) {
response.writeHead(202, sessionHeaders(session)); response.end(); return;
}
return sendJson(response, 200, rpcResponse, sessionHeaders(session));
});
server.headersTimeout = 10_000;
server.requestTimeout = 120_000;
server.maxRequestsPerSocket = 1000;
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => {
server.off("error", reject);
resolve({
server,
host,
port: server.address().port,
close: async () => {
clearInterval(cleanup);
await Promise.all([...sessions.values()].map(closeSession));
await new Promise((done, fail) => server.close((error) => error ? fail(error) : done()));
}
});
});
});
}

View File

@ -0,0 +1,47 @@
/**
* The only substitution an operator may write into a service definition.
*
* A dev server needs to be told which port to listen on, and in the per-execution mode the
* worker - not the operator - is the one who knows it. Everything else a caller might want to
* vary (command, arguments, directory, environment) stays fixed in operator-owned configuration,
* which is the property that makes this server safe to expose. So exactly one placeholder is
* allowed, and an unknown one is refused at load time rather than passed through as a literal.
*/
const ALLOWED_PLACEHOLDERS = new Set(["port"]);
const PLACEHOLDER = /\$\{([A-Za-z]+)\}/g;
export function assertPlaceholders(value, label) {
if (typeof value !== "string") return value;
for (const [text, name] of value.matchAll(PLACEHOLDER)) {
if (!ALLOWED_PLACEHOLDERS.has(name)) throw new Error(`${label} uses an unknown placeholder: ${text}`);
}
return value;
}
export function hasPlaceholder(value, name = "port") {
return typeof value === "string" && value.includes(`\${${name}}`);
}
/**
* Fills in what is known and leaves the rest alone.
*
* <p>A value that has not been decided yet is left as its placeholder rather than printed: an
* `http://host:undefined` reads like an address and would be handed to a caller as one, while a
* surviving `${port}` is plainly unfinished and can be detected as such.
*/
export function substitute(value, values) {
if (typeof value !== "string") return value;
return value.replace(PLACEHOLDER, (text, name) => (values[name] === undefined || values[name] === null ? text : String(values[name])));
}
/**
* True when a value still carries a placeholder, which means it is not usable yet.
*
* <p>Deliberately not PLACEHOLDER: a global regular expression carries lastIndex between calls,
* so test() on it would answer differently for the same input on alternate calls.
*/
const ANY_PLACEHOLDER = /\$\{[A-Za-z]+\}/;
export function isUnresolved(value) {
return typeof value === "string" && ANY_PLACEHOLDER.test(value);
}

View File

@ -0,0 +1,20 @@
const serviceProperty = { type: "string", pattern: "^[a-z0-9][a-z0-9_-]{0,63}$", description: "Operator-configured service id" };
export const TOOL_DEFINITIONS = [
{ name: "list_services", description: "List the operator-configured development services and their current states.", inputSchema: { type: "object", additionalProperties: false } },
{ name: "service_status", description: "Read one development service's state without changing it.", inputSchema: { type: "object", properties: { service: serviceProperty }, required: ["service"], additionalProperties: false } },
{ name: "start_service", description: "Start one preconfigured development service. Commands and arguments cannot be supplied by the MCP caller.", inputSchema: { type: "object", properties: { service: serviceProperty }, required: ["service"], additionalProperties: false } },
{ name: "stop_service", description: "Stop one development service and its process group.", inputSchema: { type: "object", properties: { service: serviceProperty }, required: ["service"], additionalProperties: false } },
{ name: "restart_service", description: "Restart one preconfigured development service, picking up the current state of the code. Use this after editing files.", inputSchema: { type: "object", properties: { service: serviceProperty }, required: ["service"], additionalProperties: false } },
{ name: "discard_service", description: "Stop one development service and throw away its working copy and dependencies. Only for when the work is finished.", inputSchema: { type: "object", properties: { service: serviceProperty }, required: ["service"], additionalProperties: false } },
{
name: "service_logs",
description: "Return the newest output of a development service's stdout and stderr. The default tail is sized to survive a text client's per-result limit; ask for more only when the default is not enough, because a larger tail is likely to be cut before you read it.",
inputSchema: {
type: "object",
properties: { service: serviceProperty, tailBytes: { type: "integer", minimum: 1, maximum: 262144, default: 4096 } },
required: ["service"],
additionalProperties: false
}
}
];

View File

@ -0,0 +1,26 @@
export class WorkerClient {
constructor({ baseUrl, token, timeoutMs = 610000 }) {
this.baseUrl = new URL(baseUrl);
if (!["http:", "https:"].includes(this.baseUrl.protocol) || this.baseUrl.username || this.baseUrl.password) throw new Error("Invalid development-server worker URL");
if (typeof token !== "string" || token.length < 32) throw new Error("DEV_SERVER_WORKER_TOKEN must contain at least 32 characters");
this.token = token;
this.timeoutMs = timeoutMs;
}
async call(action, input = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
try {
const response = await fetch(new URL(`/v1/${action}`, this.baseUrl), {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${this.token}` },
body: JSON.stringify(input),
redirect: "error",
signal: controller.signal
});
const body = await response.json().catch(() => ({ error: "Invalid worker response" }));
if (!response.ok) throw new Error(body.error || `Development-server worker returned HTTP ${response.status}`);
return body;
} finally { clearTimeout(timer); }
}
}

View File

@ -0,0 +1,107 @@
#!/usr/bin/env node
import http from "node:http";
import { timingSafeEqual } from "node:crypto";
import fs from "node:fs";
import { loadDevServerConfig } from "./config.js";
import { DevServerService } from "./dev-server-service.js";
import { PortAllocator, parsePortRange } from "./ports.js";
function csv(value) { return (value ?? "").split(",").map((item) => item.trim()).filter(Boolean); }
function integer(value, fallback) { const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; }
function directory(value) {
if (!value) return null;
fs.mkdirSync(value, { recursive: true });
return fs.realpathSync(value);
}
const token = process.env.DEV_SERVER_WORKER_TOKEN ?? "";
if (token.length < 32) throw new Error("DEV_SERVER_WORKER_TOKEN must contain at least 32 characters");
const expectedToken = Buffer.from(token);
const config = loadDevServerConfig({
configPath: process.env.DEV_SERVER_CONFIG ?? "/config/services.json",
workspaceRoot: process.env.DEV_SERVER_WORKSPACE_ROOT ?? "/workspace",
allowedCommands: csv(process.env.DEV_SERVER_ALLOWED_COMMANDS)
});
// The instances root must allow execution and live on disk, not on the noexec RAM-backed /tmp the
// container gives us: a node_modules carries native .node modules that will not load from a
// noexec mount, and a 333 MB dependency tree in RAM eats a quarter of the worker's memory limit.
const instancesRoot = directory(process.env.DEV_SERVER_INSTANCES_ROOT);
const devServer = new DevServerService({
services: config.services,
workspaceRoot: config.root,
instancesRoot,
portAllocator: new PortAllocator(parsePortRange(process.env.DEV_SERVER_PORT_RANGE)),
maxInstances: integer(process.env.DEV_SERVER_MAX_INSTANCES, 4),
npmRegistry: process.env.DEV_SERVER_NPM_REGISTRY || null,
npmCache: directory(process.env.DEV_SERVER_NPM_CACHE),
instanceHome: directory(process.env.DEV_SERVER_INSTANCE_HOME) ?? "/tmp/dev-server",
// Sanitised on purpose, and configurable only because a host outside the image keeps its
// toolchain elsewhere. Whatever this resolves to is the npm that decides which platform
// binaries land in node_modules, so it is logged with every install.
childPath: process.env.DEV_SERVER_CHILD_PATH || "/opt/java/openjdk/bin:/usr/local/bin:/usr/bin:/bin",
javaHome: process.env.JAVA_HOME || null,
proxy: process.env.DEV_SERVER_EGRESS_PROXY
? { url: process.env.DEV_SERVER_EGRESS_PROXY, noProxy: process.env.DEV_SERVER_NO_PROXY || null }
: null,
workspaceLimits: {
maxEntries: integer(process.env.DEV_SERVER_MAX_WORKSPACE_ENTRIES, 20000),
maxBytes: integer(process.env.DEV_SERVER_MAX_WORKSPACE_BYTES, 512 * 1024 * 1024)
}
});
function authorized(request) {
const supplied = /^Bearer\s+(.+)$/i.exec(request.headers.authorization ?? "")?.[1]?.trim();
if (!supplied) return false;
const candidate = Buffer.from(supplied);
return candidate.length === expectedToken.length && timingSafeEqual(candidate, expectedToken);
}
function body(request, limit = 65536) {
return new Promise((resolve, reject) => {
let size = 0; const chunks = [];
request.on("data", (chunk) => { size += chunk.length; if (size > limit) { reject(new Error("Request too large")); request.destroy(); } else chunks.push(chunk); });
request.on("end", () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); } catch (error) { reject(error); } });
request.on("error", reject);
});
}
function reply(response, status, value) {
response.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store", "x-content-type-options": "nosniff" });
response.end(JSON.stringify(value));
}
const actions = {
list: (input) => devServer.list(input),
status: (input) => devServer.status(input),
start: (input) => devServer.start(input),
stop: (input) => devServer.stop(input),
restart: (input) => devServer.restart(input),
discard: (input) => devServer.discard(input),
logs: (input) => devServer.logs(input)
};
const server = http.createServer(async (request, response) => {
response.on("error", () => {});
if (!authorized(request)) return reply(response, 401, { error: "Unauthorized" });
if (request.method !== "POST") return reply(response, 405, { error: "Method not allowed" });
const match = /^\/v1\/([a-z]+)$/.exec(request.url ?? "");
const action = match ? actions[match[1]] : null;
if (!action) return reply(response, 404, { error: "Not found" });
try { return reply(response, 200, await action(await body(request))); }
catch (error) { return reply(response, 400, { error: error instanceof Error ? error.message : "Worker error" }); }
});
server.headersTimeout = 5000;
// A start that installs dependencies holds its request open, so the HTTP layer must never be the
// first to give up: a timeout here would report a transport failure for an install still running.
server.requestTimeout = integer(process.env.DEV_SERVER_WORKER_REQUEST_TIMEOUT_MS, 620000);
server.listen(integer(process.env.DEV_SERVER_WORKER_PORT, 4000), process.env.DEV_SERVER_WORKER_HOST ?? "0.0.0.0", () => {
process.stderr.write("dev-server worker ready\n");
});
async function shutdown() {
await devServer.close();
server.close(() => process.exit(0));
setTimeout(() => process.exit(1), 10000).unref();
}
process.once("SIGTERM", shutdown);
process.once("SIGINT", shutdown);

View File

@ -0,0 +1,58 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { loadDevServerConfig } from "../src/config.js";
import { DevServerService } from "../src/dev-server-service.js";
function fixture(config) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "dev-server-mcp-"));
const configPath = path.join(root, "services.json");
fs.writeFileSync(configPath, JSON.stringify(config));
return { root, configPath, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) };
}
test("configuration rejects commands outside the operator allowlist", () => {
const item = fixture({ services: { web: { command: "sh", args: [], cwd: "." } } });
try {
assert.throws(() => loadDevServerConfig({ configPath: item.configPath, workspaceRoot: item.root, allowedCommands: ["node"] }), /not allowlisted/);
} finally { item.cleanup(); }
});
test("configuration rejects workspace traversal", () => {
// Refused on the shape of the path, before anything is resolved, so it holds for a
// per-execution service too - whose directory does not exist yet and so cannot be resolved.
const item = fixture({ services: { web: { command: "node", args: [], cwd: ".." } } });
try {
assert.throws(() => loadDevServerConfig({ configPath: item.configPath, workspaceRoot: item.root, allowedCommands: ["node"] }), /Invalid cwd/);
} finally { item.cleanup(); }
});
test("configuration cannot replace the safe process environment", () => {
const item = fixture({ services: { web: { command: "node", args: [], cwd: ".", env: { PATH: "/workspace/bin" } } } });
try {
assert.throws(() => loadDevServerConfig({ configPath: item.configPath, workspaceRoot: item.root, allowedCommands: ["node"] }), /Invalid environment/);
} finally { item.cleanup(); }
});
test("dev server starts only configured arguments, captures logs, and stops the process group", async () => {
const item = fixture({ services: { web: { command: process.execPath, args: ["-e", "console.log('ready'); setInterval(() => {}, 1000)"], cwd: ".", shutdownTimeoutMs: 500 } } });
try {
const config = loadDevServerConfig({ configPath: item.configPath, workspaceRoot: item.root, allowedCommands: [process.execPath] });
const devServer = new DevServerService({ services: config.services });
const started = await devServer.start({ service: "web" });
assert.equal(started.status, "running");
await new Promise((resolve) => setTimeout(resolve, 50));
assert.match(devServer.logs({ service: "web" }).logs, /ready/);
const stopped = await devServer.stop({ service: "web" });
assert.equal(stopped.status, "stopped");
await devServer.close();
} finally { item.cleanup(); }
});
test("unknown services and oversized log requests fail closed", () => {
const devServer = new DevServerService({ services: new Map([["web", { id: "web", clientCwd: ".", publicUrl: null }]]) });
assert.throws(() => devServer.status({ service: "missing" }), /Unknown service/);
assert.throws(() => devServer.logs({ service: "web", tailBytes: 999999 }), /tailBytes/);
});

View File

@ -0,0 +1,202 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { loadDevServerConfig } from "../src/config.js";
import { DevServerService } from "../src/dev-server-service.js";
import { PortAllocator, parsePortRange } from "../src/ports.js";
/** A dev server stand-in: answers on the port it was given and reports the tree it runs in. */
const SERVER = [
"-e",
"const http=require('http');const fs=require('fs');" +
"const port=Number(process.argv[1]);" +
"const body=fs.existsSync('app.txt')?fs.readFileSync('app.txt','utf8'):'no app';" +
"console.log('serving '+body+' on '+port);" +
"http.createServer((q,s)=>{s.end(body)}).listen(port,'127.0.0.1');"
];
function stack({ services, instancesRoot = true, maxInstances = 4, portRange = "5310-5319" }) {
const base = fs.mkdtempSync(path.join(os.tmpdir(), "dev-server-exec-"));
const workspace = path.join(base, "workspace");
const instances = path.join(base, "instances");
fs.mkdirSync(workspace);
fs.mkdirSync(instances);
const configPath = path.join(base, "services.json");
fs.writeFileSync(configPath, JSON.stringify({ services }));
const config = loadDevServerConfig({ configPath, workspaceRoot: workspace, allowedCommands: [process.execPath] });
const service = new DevServerService({
services: config.services,
workspaceRoot: workspace,
instancesRoot: instancesRoot ? instances : null,
portAllocator: new PortAllocator(parsePortRange(portRange)),
maxInstances,
instanceHome: path.join(base, "home")
});
fs.mkdirSync(path.join(base, "home"));
return {
base, workspace, instances, service,
write(key, files) {
const directory = path.join(workspace, key);
fs.mkdirSync(directory, { recursive: true });
for (const [name, content] of Object.entries(files)) fs.writeFileSync(path.join(directory, name), content);
return directory;
},
async cleanup() {
await service.close().catch(() => {});
fs.rmSync(base, { recursive: true, force: true });
}
};
}
const WEB = {
command: process.execPath,
args: [...SERVER, "${port}"],
workspaceMode: "execution",
healthUrl: "http://127.0.0.1:${port}/",
publicUrl: "http://dev-server-worker:${port}",
startupTimeoutMs: 8000,
shutdownTimeoutMs: 500
};
test("two executions serve their own code at the same time, on their own ports", async () => {
const item = stack({ services: { web: WEB } });
try {
item.write("exec-a", { "app.txt": "A" });
item.write("exec-b", { "app.txt": "B" });
const a = await item.service.start({ service: "web", key: "exec-a" });
const b = await item.service.start({ service: "web", key: "exec-b" });
assert.equal(a.status, "running");
assert.equal(b.status, "running");
assert.notEqual(a.port, b.port);
assert.equal(a.publicUrl, `http://dev-server-worker:${a.port}`);
assert.equal(await (await fetch(`http://127.0.0.1:${a.port}/`)).text(), "A");
assert.equal(await (await fetch(`http://127.0.0.1:${b.port}/`)).text(), "B");
} finally { await item.cleanup(); }
});
test("a per-execution service refuses to start without a key", async () => {
const item = stack({ services: { web: WEB } });
try {
item.write("exec-a", { "app.txt": "A" });
await assert.rejects(() => item.service.start({ service: "web" }), /runs per execution and needs an execution key/);
} finally { await item.cleanup(); }
});
test("an execution that has written nothing yet is told so, not handed an empty tree", async () => {
const item = stack({ services: { web: WEB } });
try {
await assert.rejects(() => item.service.start({ service: "web", key: "never-written" }), /No workspace has been written/);
} finally { await item.cleanup(); }
});
test("one execution cannot reach another's instance through the key it supplies", async () => {
const item = stack({ services: { web: WEB } });
try {
item.write("exec-a", { "app.txt": "A" });
await item.service.start({ service: "web", key: "exec-a" });
assert.equal(item.service.status({ service: "web", key: "exec-b" }).status, "stopped");
assert.equal(item.service.logs({ service: "web", key: "exec-b" }).logs, "");
assert.equal(item.service.list({ key: "exec-b" }).services[0].status, "stopped");
} finally { await item.cleanup(); }
});
test("a restart serves the code as it is now, not as it was at start", async () => {
// A restart that served the old tree would be the worst defect this tool could have: the agent
// would read its own fix as having changed nothing.
const item = stack({ services: { web: WEB } });
try {
const source = item.write("exec-a", { "app.txt": "first" });
const started = await item.service.start({ service: "web", key: "exec-a" });
assert.equal(await (await fetch(`http://127.0.0.1:${started.port}/`)).text(), "first");
fs.writeFileSync(path.join(source, "app.txt"), "second");
const restarted = await item.service.restart({ service: "web", key: "exec-a" });
assert.equal(await (await fetch(`http://127.0.0.1:${restarted.port}/`)).text(), "second");
} finally { await item.cleanup(); }
});
test("the install runs once, is skipped while the lockfile holds, and lands in the logs", async () => {
const withInstall = {
...WEB,
install: { command: process.execPath, args: ["-e", "require('fs').mkdirSync('node_modules',{recursive:true});console.log('installed deps')"] }
};
const item = stack({ services: { web: withInstall } });
try {
item.write("exec-a", { "app.txt": "A", "package-lock.json": "{\"v\":1}" });
await item.service.start({ service: "web", key: "exec-a" });
assert.match(item.service.logs({ service: "web", key: "exec-a", tailBytes: 4096 }).logs, /installed deps/);
await item.service.stop({ service: "web", key: "exec-a" });
await item.service.start({ service: "web", key: "exec-a" });
assert.match(item.service.logs({ service: "web", key: "exec-a", tailBytes: 4096 }).logs, /install skipped/);
} finally { await item.cleanup(); }
});
test("a failed install stops the start instead of serving half-installed dependencies", async () => {
const broken = { ...WEB, install: { command: process.execPath, args: ["-e", "console.error('no registry');process.exit(3)"] } };
const item = stack({ services: { web: broken } });
try {
item.write("exec-a", { "app.txt": "A", "package-lock.json": "{\"v\":1}" });
await assert.rejects(() => item.service.start({ service: "web", key: "exec-a" }), /install failed with exit code 3/);
const status = item.service.status({ service: "web", key: "exec-a" });
assert.equal(status.status, "stopped");
assert.equal(status.port, null, "a failed start must not keep a port");
assert.match(item.service.logs({ service: "web", key: "exec-a" }).logs, /no registry/);
} finally { await item.cleanup(); }
});
test("the concurrency cap names itself and the setting that raises it", async () => {
const item = stack({ services: { web: WEB }, maxInstances: 1 });
try {
item.write("exec-a", { "app.txt": "A" });
item.write("exec-b", { "app.txt": "B" });
await item.service.start({ service: "web", key: "exec-a" });
await assert.rejects(
() => item.service.start({ service: "web", key: "exec-b" }),
/Already running 1 services: raise DEV_SERVER_MAX_INSTANCES/
);
} finally { await item.cleanup(); }
});
test("discarding an execution frees its port and its copy", async () => {
const item = stack({ services: { web: WEB } });
try {
item.write("exec-a", { "app.txt": "A" });
const started = await item.service.start({ service: "web", key: "exec-a" });
await item.service.discard({ service: "web", key: "exec-a" });
assert.ok(!fs.existsSync(path.join(item.instances, "exec-a")));
assert.equal(item.service.runningCount(), 0);
await assert.rejects(() => fetch(`http://127.0.0.1:${started.port}/`));
} finally { await item.cleanup(); }
});
test("a worker with no instances root refuses per-execution services outright", async () => {
const item = stack({ services: { web: WEB }, instancesRoot: false });
try {
item.write("exec-a", { "app.txt": "A" });
await assert.rejects(() => item.service.start({ service: "web", key: "exec-a" }), /not configured for per-execution services/);
} finally { await item.cleanup(); }
});
test("the default log tail fits inside the client's per-result budget", () => {
// The MCP client truncates at 6000 characters keeping the head, so a default tail larger than
// that would hand the model the oldest lines of the newest slice - never the error at the end.
const item = stack({ services: { web: WEB } });
try {
const returned = item.service.logs({ service: "web", key: "exec-a" });
assert.equal(returned.logs, "");
assert.ok(4096 < 6000);
} finally { fs.rmSync(item.base, { recursive: true, force: true }); }
});

View File

@ -0,0 +1,171 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
assertInstanceKey,
prepareInstanceWorkspace,
recordInstall,
removeInstanceWorkspace
} from "../src/instance-workspace.js";
function roots() {
const base = fs.mkdtempSync(path.join(os.tmpdir(), "dev-server-copy-"));
const workspace = path.join(base, "workspace");
const instances = path.join(base, "instances");
fs.mkdirSync(workspace);
fs.mkdirSync(instances);
return { base, workspace, instances, cleanup: () => fs.rmSync(base, { recursive: true, force: true }) };
}
function execution(workspace, key, files) {
const directory = path.join(workspace, key);
fs.mkdirSync(directory, { recursive: true });
for (const [name, content] of Object.entries(files)) {
const file = path.join(directory, name);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, content);
}
return directory;
}
test("an execution key must be one safe path segment", () => {
for (const bad of ["../escape", "a/b", "", ".", "with space", "x".repeat(65)]) {
assert.throws(() => assertInstanceKey(bad), /Invalid execution key/, `accepted ${JSON.stringify(bad)}`);
}
assert.equal(assertInstanceKey("7f3c1e2a-b4d5"), "7f3c1e2a-b4d5");
});
test("the copy holds the execution's own files and nothing above them", () => {
const item = roots();
try {
execution(item.workspace, "exec-a", { "package.json": "{}", "src/main.js": "console.log(1)" });
execution(item.workspace, "exec-b", { "secret.txt": "b's work" });
const prepared = prepareInstanceWorkspace({ workspaceRoot: item.workspace, instancesRoot: item.instances, key: "exec-a" });
assert.equal(fs.readFileSync(path.join(prepared.target, "src/main.js"), "utf8"), "console.log(1)");
assert.ok(!fs.existsSync(path.join(prepared.target, "secret.txt")));
assert.equal(prepared.entries, 3);
} finally { item.cleanup(); }
});
test("a symlink into another execution is dropped, not followed", () => {
// The tree is written by the coding agent, so this is the move that would turn the copy - whose
// whole purpose is isolation - into a window onto somebody else's work.
const item = roots();
try {
execution(item.workspace, "exec-b", { "secret.txt": "b's work" });
const source = execution(item.workspace, "exec-a", { "package.json": "{}" });
fs.symlinkSync(path.join(item.workspace, "exec-b"), path.join(source, "peek"));
const prepared = prepareInstanceWorkspace({ workspaceRoot: item.workspace, instancesRoot: item.instances, key: "exec-a" });
assert.ok(!fs.existsSync(path.join(prepared.target, "peek")));
assert.ok(!fs.existsSync(path.join(prepared.target, "peek", "secret.txt")));
} finally { item.cleanup(); }
});
test("an execution directory replaced by a symlink is refused outright", () => {
const item = roots();
try {
const outside = path.join(item.base, "outside");
fs.mkdirSync(outside);
fs.writeFileSync(path.join(outside, "elsewhere.txt"), "not yours");
fs.symlinkSync(outside, path.join(item.workspace, "exec-a"));
assert.throws(
() => prepareInstanceWorkspace({ workspaceRoot: item.workspace, instancesRoot: item.instances, key: "exec-a" }),
/escapes the workspace root/
);
} finally { item.cleanup(); }
});
test("node_modules is not copied, because the install recreates it", () => {
const item = roots();
try {
execution(item.workspace, "exec-a", { "package.json": "{}", "node_modules/left-pad/index.js": "module.exports=1" });
const prepared = prepareInstanceWorkspace({ workspaceRoot: item.workspace, instancesRoot: item.instances, key: "exec-a" });
assert.ok(!fs.existsSync(path.join(prepared.target, "node_modules")));
} finally { item.cleanup(); }
});
test("a refresh replaces project files and keeps the installed dependencies", () => {
const item = roots();
try {
const source = execution(item.workspace, "exec-a", { "package.json": "{}", "package-lock.json": "{\"v\":1}", "old.js": "gone soon" });
const first = prepareInstanceWorkspace({ workspaceRoot: item.workspace, instancesRoot: item.instances, key: "exec-a" });
fs.mkdirSync(path.join(first.target, "node_modules"), { recursive: true });
fs.writeFileSync(path.join(first.target, "node_modules", "marker"), "installed");
recordInstall(first.target);
fs.rmSync(path.join(source, "old.js"));
fs.writeFileSync(path.join(source, "new.js"), "fresh");
const second = prepareInstanceWorkspace({ workspaceRoot: item.workspace, instancesRoot: item.instances, key: "exec-a" });
assert.ok(!fs.existsSync(path.join(second.target, "old.js")), "a deleted file must not survive the refresh");
assert.equal(fs.readFileSync(path.join(second.target, "new.js"), "utf8"), "fresh");
assert.equal(fs.readFileSync(path.join(second.target, "node_modules", "marker"), "utf8"), "installed");
assert.equal(second.installIsCurrent, true);
} finally { item.cleanup(); }
});
test("a changed lockfile makes the recorded install stale", () => {
const item = roots();
try {
const source = execution(item.workspace, "exec-a", { "package-lock.json": "{\"v\":1}" });
const first = prepareInstanceWorkspace({ workspaceRoot: item.workspace, instancesRoot: item.instances, key: "exec-a" });
fs.mkdirSync(path.join(first.target, "node_modules"), { recursive: true });
recordInstall(first.target);
fs.writeFileSync(path.join(source, "package-lock.json"), "{\"v\":2}");
const second = prepareInstanceWorkspace({ workspaceRoot: item.workspace, instancesRoot: item.instances, key: "exec-a" });
assert.equal(second.installIsCurrent, false);
} finally { item.cleanup(); }
});
test("with no lockfile the install is never called current", () => {
const item = roots();
try {
execution(item.workspace, "exec-a", { "package.json": "{}" });
const prepared = prepareInstanceWorkspace({ workspaceRoot: item.workspace, instancesRoot: item.instances, key: "exec-a" });
fs.mkdirSync(path.join(prepared.target, "node_modules"), { recursive: true });
recordInstall(prepared.target);
const again = prepareInstanceWorkspace({ workspaceRoot: item.workspace, instancesRoot: item.instances, key: "exec-a" });
assert.equal(again.installIsCurrent, false);
} finally { item.cleanup(); }
});
test("a tree beyond the declared limits is refused instead of filling the volume", () => {
const item = roots();
try {
execution(item.workspace, "exec-a", { "a.txt": "x".repeat(1024), "b.txt": "y".repeat(1024) });
assert.throws(
() => prepareInstanceWorkspace({ workspaceRoot: item.workspace, instancesRoot: item.instances, key: "exec-a", limits: { maxBytes: 1500, maxEntries: 100 } }),
/exceeds 1500 bytes/
);
assert.throws(
() => prepareInstanceWorkspace({ workspaceRoot: item.workspace, instancesRoot: item.instances, key: "exec-a", limits: { maxBytes: 1e9, maxEntries: 1 } }),
/exceeds 1 entries/
);
} finally { item.cleanup(); }
});
test("discarding an execution reclaims its directory", () => {
const item = roots();
try {
execution(item.workspace, "exec-a", { "package.json": "{}" });
const prepared = prepareInstanceWorkspace({ workspaceRoot: item.workspace, instancesRoot: item.instances, key: "exec-a" });
assert.ok(fs.existsSync(prepared.target));
removeInstanceWorkspace(item.instances, "exec-a");
assert.ok(!fs.existsSync(prepared.target));
assert.ok(fs.existsSync(path.join(item.workspace, "exec-a")), "the workspace itself is not ours to delete");
} finally { item.cleanup(); }
});

View File

@ -0,0 +1,89 @@
import test from "node:test";
import assert from "node:assert/strict";
import { startStreamableHttpServer } from "../src/streamable-http.js";
const TOKEN = "0123456789abcdef0123456789abcdef";
const HEADER = "x-preview-key";
async function server(onCall) {
const listener = await startStreamableHttpServer({
serverName: "test", serverVersion: "1.0.0",
tools: [{ name: "probe", description: "probe", inputSchema: { type: "object", additionalProperties: false } }],
callTool: onCall,
apiKeys: [`subject=${TOKEN}`],
host: "127.0.0.1",
port: 0,
pinnedHeaders: [HEADER]
});
return listener;
}
async function post(listener, body, headers = {}) {
const response = await fetch(`http://127.0.0.1:${listener.port}/mcp`, {
method: "POST",
headers: { authorization: `Bearer ${TOKEN}`, "content-type": "application/json", accept: "application/json", ...headers },
body: JSON.stringify(body)
});
const text = await response.text();
return { status: response.status, sessionId: response.headers.get("mcp-session-id"), body: text ? JSON.parse(text) : null };
}
const INITIALIZE = { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t", version: "0" } } };
const CALL = { jsonrpc: "2.0", id: 2, method: "tools/call", params: { name: "probe", arguments: {} } };
test("a pinned header reaches the tool call as session scope", async () => {
let seen = null;
const listener = await server(async (name, args, context) => { seen = context.pinned; return { ok: true }; });
try {
const opened = await post(listener, INITIALIZE, { [HEADER]: "exec-a" });
await post(listener, CALL, { "mcp-session-id": opened.sessionId });
assert.deepEqual(seen, { [HEADER]: "exec-a" });
} finally { await listener.close(); }
});
test("the scope cannot be changed part-way through a session", async () => {
// Otherwise one open session would be enough to reach every execution in turn, which is exactly
// what taking the key out of the tool arguments was meant to prevent.
const listener = await server(async () => ({ ok: true }));
try {
const opened = await post(listener, INITIALIZE, { [HEADER]: "exec-a" });
const switched = await post(listener, CALL, { "mcp-session-id": opened.sessionId, [HEADER]: "exec-b" });
assert.equal(switched.status, 400);
assert.match(switched.body.error, /cannot change within a session/);
} finally { await listener.close(); }
});
test("repeating the same scope on a later request is accepted", async () => {
const listener = await server(async () => ({ ok: true }));
try {
const opened = await post(listener, INITIALIZE, { [HEADER]: "exec-a" });
const again = await post(listener, CALL, { "mcp-session-id": opened.sessionId, [HEADER]: "exec-a" });
assert.equal(again.status, 200);
} finally { await listener.close(); }
});
test("a session opened without the header carries no scope at all", async () => {
let seen = "unset";
const listener = await server(async (name, args, context) => { seen = context.pinned; return { ok: true }; });
try {
const opened = await post(listener, INITIALIZE);
await post(listener, CALL, { "mcp-session-id": opened.sessionId });
assert.deepEqual(seen, {}, "an absent header must not become an empty-string scope");
} finally { await listener.close(); }
});
test("an oversized scope is refused at session open", async () => {
// Control characters never get this far - the HTTP client and Node's parser both refuse them -
// so the guard that earns its place here is the length one.
const listener = await server(async () => ({ ok: true }));
try {
const opened = await post(listener, INITIALIZE, { [HEADER]: "x".repeat(257) });
assert.equal(opened.status, 400);
assert.match(opened.body.error, /Invalid x-preview-key header/);
} finally { await listener.close(); }
});

View File

@ -0,0 +1,54 @@
import test from "node:test";
import assert from "node:assert/strict";
import net from "node:net";
import { PortAllocator, parsePortRange } from "../src/ports.js";
test("a port range is read only in the operator's declared form", () => {
assert.deepEqual(parsePortRange("5200-5219"), { from: 5200, to: 5219 });
assert.deepEqual(parsePortRange(undefined), { from: 5200, to: 5219 });
for (const bad of ["5200", "5219-5200", "80-90", "abc", "5200_5219"]) {
assert.throws(() => parsePortRange(bad), /DEV_SERVER_PORT_RANGE/, `accepted ${bad}`);
}
});
test("each instance gets its own port, and a released one comes back", async () => {
const allocator = new PortAllocator({ from: 5400, to: 5401 });
const first = await allocator.take("a");
const second = await allocator.take("b");
assert.notEqual(first, second);
allocator.release(first);
assert.equal(await allocator.take("c"), first);
});
test("a port held by a process this worker does not know about is skipped", async () => {
// An orphan from an earlier run holds its port. Handing that port out would produce two servers
// fighting over one address - which is how a preview ends up at an address nobody looks at.
const squatter = net.createServer();
await new Promise((resolve) => squatter.listen(5402, "0.0.0.0", resolve));
try {
const allocator = new PortAllocator({ from: 5402, to: 5403 });
assert.equal(await allocator.take("a"), 5403);
} finally {
await new Promise((resolve) => squatter.close(resolve));
}
});
test("an exhausted range names itself and the setting that widens it", async () => {
const allocator = new PortAllocator({ from: 5404, to: 5404 });
await allocator.take("a");
await assert.rejects(() => allocator.take("b"), /No free port in 5404-5404: raise DEV_SERVER_PORT_RANGE/);
});
test("releasing by owner frees every port that instance held", async () => {
const allocator = new PortAllocator({ from: 5405, to: 5407 });
await allocator.take("a");
await allocator.take("a");
assert.equal(allocator.size, 2);
allocator.releaseAllOf("a");
assert.equal(allocator.size, 0);
});

View File

@ -0,0 +1,50 @@
import test from "node:test";
import assert from "node:assert/strict";
import { startStreamableHttpServer } from "../src/streamable-http.js";
const token = "a".repeat(32);
test("Streamable HTTP requires auth, binds sessions to identities, and supports deletion", async (t) => {
let listener;
try {
listener = await startStreamableHttpServer({
serverName: "test", serverVersion: "1", host: "127.0.0.1", port: 0,
apiKeys: [`one=${token}`, `two=${"b".repeat(32)}`],
tools: [{ name: "echo", inputSchema: { type: "object" } }],
callTool: async (_name, args, context) => ({ ...args, subject: context.subject })
});
} catch (error) {
if (error?.code === "EPERM") return t.skip("TCP listeners unavailable");
throw error;
}
const url = `http://${listener.host}:${listener.port}/mcp`;
try {
assert.equal((await fetch(url, { method: "POST", body: "{}" })).status, 401);
const initialized = await fetch(url, {
method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-03-26" } })
});
assert.equal(initialized.status, 200);
const sessionId = initialized.headers.get("mcp-session-id");
assert.ok(sessionId);
const wrongIdentity = await fetch(url, { method: "POST", headers: { authorization: `Bearer ${"b".repeat(32)}`, "content-type": "application/json", "mcp-session-id": sessionId }, body: JSON.stringify({ jsonrpc: "2.0", id: 2, method: "ping" }) });
assert.equal(wrongIdentity.status, 404);
const called = await fetch(url, { method: "POST", headers: { authorization: `Bearer ${token}`, "content-type": "application/json", "mcp-session-id": sessionId }, body: JSON.stringify({ jsonrpc: "2.0", id: 3, method: "tools/call", params: { name: "echo", arguments: { ok: true } } }) });
assert.equal((await called.json()).result.structuredContent.subject, "one");
assert.equal((await fetch(url, { method: "DELETE", headers: { authorization: `Bearer ${token}`, "mcp-session-id": sessionId } })).status, 204);
} finally { await listener.close(); }
});
test("browser Origin requests are rejected unless allowlisted", async (t) => {
let listener;
try {
listener = await startStreamableHttpServer({ serverName: "test", serverVersion: "1", host: "127.0.0.1", port: 0, apiKeys: [token], allowedOrigins: ["https://console.example"], tools: [], callTool: async () => ({}) });
} catch (error) {
if (error?.code === "EPERM") return t.skip("TCP listeners unavailable");
throw error;
}
try {
const denied = await fetch(`http://${listener.host}:${listener.port}/health`, { headers: { authorization: `Bearer ${token}`, origin: "https://evil.example" } });
assert.equal(denied.status, 403);
} finally { await listener.close(); }
});

44
egress-proxy.squid.conf Normal file
View File

@ -0,0 +1,44 @@
# The only route out of the dev-server worker.
#
# It allows CONNECT tunnels to the package registries and refuses everything else. There is no
# interception and no certificate of ours in the middle: the proxy sees the host a client asks for
# and nothing more. That is the whole boundary, and it is worth being clear about what it is not -
# a package pulled from an allowed registry is still third-party code, which is why installs run
# with scripts disabled.
#
# Add a host here only when an install has failed for the want of it, and add the exact host.
http_port 3128
acl registries dstdomain \
registry.npmjs.org \
.npmjs.org \
pypi.org \
files.pythonhosted.org \
repo.maven.apache.org \
repo1.maven.org
acl ssl_ports port 443
acl connect_method method CONNECT
# CONNECT to an allowed registry on 443, and that is all. Plain HTTP is not allowed even to these
# hosts: every one of them serves HTTPS, so a plain request would be a downgrade, not a fallback.
http_access allow connect_method registries ssl_ports
http_access deny all
# Nothing is cached: with no cache there is no cache to poison, and the measured benefit of
# caching was npm's alone - where the worker's own shared npm cache already provides it.
cache deny all
cache_mem 8 MB
# One line per request, so a refused host can be found without guessing.
access_log stdio:/dev/stdout
cache_log stdio:/dev/stderr
# A client that cannot reach the internet should learn so quickly rather than hang.
connect_timeout 15 seconds
request_timeout 60 seconds
forwarded_for delete
via off
httpd_suppress_version_string on

50
mcp-stack.Caddyfile Normal file
View File

@ -0,0 +1,50 @@
{
auto_https off
admin off
}
# One host, one path per server. This is the shape the catalog in the workflow manager expects -
# ${{host}}/coding-agent/mcp and its siblings - so a flow configured against a remote deployment
# runs against this stack by changing the host and nothing else.
:3100 {
handle_path /coding-agent/* {
reverse_proxy coding-agent-mcp:3000 {
flush_interval -1
}
}
handle_path /dev-server/* {
reverse_proxy dev-server-mcp:3000 {
flush_interval -1
}
}
handle_path /browser/* {
reverse_proxy browser-mcp:3000 {
flush_interval -1
}
}
handle {
respond "No MCP server is published at this path" 404
}
}
# The original one-port-per-server listeners, kept for clients configured before the paths existed.
:3101 {
reverse_proxy coding-agent-mcp:3000 {
flush_interval -1
}
}
:3102 {
reverse_proxy dev-server-mcp:3000 {
flush_interval -1
}
}
:3103 {
reverse_proxy browser-mcp:3000 {
flush_interval -1
}
}

213
mcp-stack.compose.yml Normal file
View File

@ -0,0 +1,213 @@
name: secure-mcp-stack
services:
coding-agent-mcp:
build:
context: ./coding-agent-mcp
command: ["node", "src/coding-agent-index.js", "--transport", "http", "--host", "0.0.0.0", "--port", "3000", "--root", "/workspace"]
environment:
CODING_AGENT_MCP_API_KEYS: ${CODING_AGENT_MCP_API_KEYS}
CODING_AGENT_MCP_EXECUTION_BACKEND: disabled
volumes:
- type: bind
source: ${MCP_WORKSPACE_HOST_PATH}
target: /workspace
user: "${MCP_UID:-10001}:${MCP_GID:-10001}"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=128m
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
pids_limit: 128
mem_limit: 512m
cpus: 1
init: true
expose: ["3000"]
networks: [coding-control]
restart: unless-stopped
dev-server-mcp:
build:
context: ./dev-server-mcp
command: ["node", "src/index.js"]
environment:
DEV_SERVER_MCP_API_KEYS: ${DEV_SERVER_MCP_API_KEYS}
DEV_SERVER_WORKER_TOKEN: ${DEV_SERVER_WORKER_TOKEN}
DEV_SERVER_WORKER_URL: http://dev-server-worker:4000
user: "10001:10001"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=64m
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
pids_limit: 64
mem_limit: 256m
cpus: 0.5
init: true
expose: ["3000"]
networks: [dev-server-control]
depends_on: [dev-server-worker]
restart: unless-stopped
dev-server-worker:
build:
context: ./dev-server-mcp
command: ["node", "src/worker-index.js"]
environment:
DEV_SERVER_WORKER_TOKEN: ${DEV_SERVER_WORKER_TOKEN}
DEV_SERVER_CONFIG: /config/services.json
DEV_SERVER_WORKSPACE_ROOT: /workspace
# What the image carries is not the same as what a caller may run. npx is deliberately absent:
# it fetches and executes an arbitrary package by name, which would hand back the free choice
# of command this allowlist exists to remove.
DEV_SERVER_ALLOWED_COMMANDS: ${DEV_SERVER_ALLOWED_COMMANDS:-node,npm,python3,./mvnw}
# One throwaway copy of the tree per execution, on a volume: a node_modules carries native
# .node modules that will not load from the noexec /tmp, and 333 MB of it in RAM would eat a
# quarter of this container's memory limit.
DEV_SERVER_INSTANCES_ROOT: /instances
DEV_SERVER_INSTANCE_HOME: /instances/.shared-home
DEV_SERVER_NPM_CACHE: /npm-cache
# The worker has no route to the internet. Everything an install fetches goes through the
# proxy, which allows only the package registries. npm and pip read these; a JVM does not,
# so a Maven service carries -Dhttps.proxyHost in its declared arguments.
DEV_SERVER_EGRESS_PROXY: ${DEV_SERVER_EGRESS_PROXY:-http://egress-proxy:3128}
DEV_SERVER_NO_PROXY: ${DEV_SERVER_NO_PROXY:-localhost,127.0.0.1,dev-server-worker}
DEV_SERVER_PORT_RANGE: ${DEV_SERVER_PORT_RANGE:-5200-5219}
DEV_SERVER_MAX_INSTANCES: ${DEV_SERVER_MAX_INSTANCES:-4}
DEV_SERVER_MAX_WORKSPACE_BYTES: ${DEV_SERVER_MAX_WORKSPACE_BYTES:-536870912}
volumes:
- type: bind
source: ${MCP_WORKSPACE_HOST_PATH}
target: /workspace
read_only: true
- type: bind
source: ${DEV_SERVER_SERVICES_CONFIG:-./dev-server-mcp/services.example.json}
target: /config/services.json
read_only: true
- dev-server-instances:/instances
- npm-cache:/npm-cache
user: "${MCP_UID:-10001}:${MCP_GID:-10001}"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=512m
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
pids_limit: 256
mem_limit: 2g
cpus: 2
init: true
networks: [dev-server-control, workspace-browser, proxy-control]
depends_on: [egress-proxy]
restart: unless-stopped
egress-proxy:
# Installing dependencies means fetching and running other people's code, so the worker has no
# route to the internet at all and this is the only way out. One proxy serves every toolchain -
# npm, pip and Maven - instead of a caching mirror per ecosystem.
#
# It filters CONNECT by host and nothing else: no interception, no certificates, no inspection
# of the tunnels. That is the honest boundary. A package fetched from an allowed registry is
# still other people's code, which is why the install runs with --ignore-scripts.
image: ubuntu/squid:edge
volumes:
- type: bind
source: ./egress-proxy.squid.conf
target: /etc/squid/squid.conf
read_only: true
cap_drop: [ALL]
cap_add: [SETUID, SETGID]
security_opt: [no-new-privileges:true]
pids_limit: 128
mem_limit: 256m
cpus: 1
init: true
expose: ["3128"]
networks: [proxy-control, egress]
restart: unless-stopped
browser-mcp:
build:
context: ./browser-mcp
environment:
BROWSER_MCP_API_KEYS: ${BROWSER_MCP_API_KEYS}
BROWSER_MCP_ALLOWED_ORIGINS: ${BROWSER_MCP_ALLOWED_ORIGINS:-http://dev-server-worker:5173}
BROWSER_MCP_MAX_SESSIONS: ${BROWSER_MCP_MAX_SESSIONS:-8}
user: "1000:1000"
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=1g
shm_size: 1gb
cap_drop: [ALL]
cap_add: [SYS_CHROOT]
security_opt:
- no-new-privileges:true
- seccomp:./browser-mcp/seccomp_profile.json
pids_limit: 512
mem_limit: 2g
cpus: 2
init: true
expose: ["3000"]
networks: [workspace-browser]
restart: unless-stopped
mcp-gateway:
image: caddy:2
user: "10001:10001"
read_only: true
tmpfs:
# /config only. /data is a volume instead, because Caddy keeps the certificate and its ACME
# account key there: on tmpfs both would be thrown away at every restart, and asking the CA
# for a fresh certificate each time runs into its duplicate-issuance limit within a week.
- /config:rw,noexec,nosuid,size=16m
volumes:
- type: bind
source: ${MCP_GATEWAY_CADDYFILE:-./mcp-stack.Caddyfile}
target: /etc/caddy/Caddyfile
read_only: true
# Where Caddy keeps the certificate and its account key. A volume, so a restart does not
# ask the CA for a new one: repeated issuance runs into rate limits and looks like abuse.
- caddy-data:/data
cap_drop: [ALL]
cap_add: [NET_BIND_SERVICE]
security_opt: [no-new-privileges:true]
pids_limit: 64
mem_limit: 128m
cpus: 0.5
environment:
# Read by the VM Caddyfile. On a laptop the plain-HTTP one ignores them.
MCP_SITE_ADDRESS: ${MCP_SITE_ADDRESS:-:3100}
MCP_TLS_CONTACT: ${MCP_TLS_CONTACT:-}
ports:
# One port serving every server by path, which is the shape the workflow manager's catalog
# uses. The three below stay for clients configured before it existed.
#
# Loopback, always. These are the servers without TLS in front of them; the VM overlay adds
# 443 beside them rather than replacing them, and compose merges port lists by appending -
# so anything opened here would stay open there, as a plaintext way around the gateway.
- "127.0.0.1:${MCP_GATEWAY_PORT:-3100}:3100"
- "127.0.0.1:${CODING_AGENT_MCP_PORT:-3101}:3101"
- "127.0.0.1:${DEV_SERVER_MCP_PORT:-3102}:3102"
- "127.0.0.1:${BROWSER_MCP_PORT:-3103}:3103"
networks: [gateway, coding-control, dev-server-control, workspace-browser]
depends_on: [coding-agent-mcp, dev-server-mcp, browser-mcp]
restart: unless-stopped
networks:
gateway:
coding-control:
internal: true
dev-server-control:
internal: true
workspace-browser:
internal: true
# The worker and the proxy meet here, and nothing else does. Internal, so joining it grants no
# route out: the proxy's own egress comes from the separate network below, which only it joins.
proxy-control:
internal: true
egress:
volumes:
# Not tmpfs and not the workspace mount: the copies need to be writable, executable and on disk.
dev-server-instances:
npm-cache:
caddy-data:

48
mcp-stack.env.example Normal file
View File

@ -0,0 +1,48 @@
# Generate each token independently, for example: openssl rand -hex 32
CODING_AGENT_MCP_API_KEYS=agent=replace-with-at-least-32-random-characters
DEV_SERVER_MCP_API_KEYS=agent=replace-with-a-different-32-char-token
BROWSER_MCP_API_KEYS=agent=replace-with-a-third-32-char-token
DEV_SERVER_WORKER_TOKEN=replace-with-a-private-worker-token-32-chars
# Use a dedicated project directory, never a home directory or filesystem root.
MCP_WORKSPACE_HOST_PATH=/absolute/path/to/project
MCP_UID=10001
MCP_GID=10001
# Copy services.example.json and edit the copy. It is mounted read-only.
DEV_SERVER_SERVICES_CONFIG=./dev-server-mcp/services.example.json
# What a caller may run, which is narrower than what the image carries on purpose. npx is left out
# because it fetches and runs an arbitrary package by name.
DEV_SERVER_ALLOWED_COMMANDS=node,npm,python3,./mvnw
# One instance per execution, each on its own port. Size the range and the cap by memory: two
# frontend builds saturate the worker's 2 GB, and a Spring project's .m2 is 300 MB of disk apiece.
DEV_SERVER_PORT_RANGE=5200-5219
DEV_SERVER_MAX_INSTANCES=4
# Every port an execution can be given has to be reachable by the browser, and the allowlist is by
# exact origin - so the range is spelled out here. Keep it aligned with DEV_SERVER_PORT_RANGE.
BROWSER_MCP_ALLOWED_ORIGINS=http://dev-server-worker:5200-5219
CODING_AGENT_MCP_PORT=3101
DEV_SERVER_MCP_PORT=3102
BROWSER_MCP_PORT=3103
# The gateway publishes every server under one host by path, which is what the catalog expects.
MCP_GATEWAY_PORT=3100
### Dedicated VM ###
# Used with the overlay:
# docker compose --env-file .env -f mcp-stack.compose.yml -f mcp-stack.vm.compose.yml up -d --build
#
# Caddy obtains the certificate itself, from Let's Encrypt, as long as the host answers on 80 or
# 443 from the internet. Worth knowing when the name is chosen: CAA is evaluated on the canonical
# name, so a name that is a CNAME into a zone authorising Let's Encrypt is issued without trouble -
# the way the institute's existing hosts are - while a name resolving straight to an address under
# isti.cnr.it would be refused, since that zone authorises digicert and sectigo for plain names.
# Either way Caddy's log says which happened, and the manual fallback is in mcp-stack.vm.Caddyfile.
MCP_GATEWAY_CADDYFILE=./mcp-stack.vm.Caddyfile
MCP_SITE_ADDRESS=mcp-stack.isti.cnr.it
MCP_TLS_CONTACT=lucio.lelii@isti.cnr.it
MCP_BIND_ADDRESS=0.0.0.0

54
mcp-stack.vm.Caddyfile Normal file
View File

@ -0,0 +1,54 @@
{
# Automatic HTTPS. Caddy asks Let's Encrypt on its own; the host has to be reachable from the
# internet on 80 or 443 for the challenge, which this VM is.
#
# One thing to know before choosing the name. CAA says who may issue, and isti.cnr.it
# authorises digicert.com and sectigo.com for a plain name, with Let's Encrypt allowed for
# wildcards only. That is not the obstacle it looks like: CAA is evaluated on the canonical
# name, so a friendly name that is a CNAME into a zone which does authorise Let's Encrypt is
# issued without trouble - which is exactly how the institute's own hosts already work.
# A name pointing straight at an address with an A record, under isti.cnr.it, would be refused.
#
# If issuance is ever refused, Caddy's log says so in as many words. The way out is a
# certificate obtained by hand:
#
# auto_https off (in this block)
# tls /certs/host.pem /certs/host-key.pem (in the site block)
#
# and mount /certs read-only. The private key of a name the whole institute trusts does not
# belong in an image, and even less in one built to run code an agent wrote.
admin off
email {$MCP_TLS_CONTACT}
}
{$MCP_SITE_ADDRESS} {
handle_path /coding-agent/* {
reverse_proxy coding-agent-mcp:3000 {
flush_interval -1
}
}
handle_path /dev-server/* {
reverse_proxy dev-server-mcp:3000 {
flush_interval -1
}
}
handle_path /browser/* {
reverse_proxy browser-mcp:3000 {
flush_interval -1
}
}
handle {
respond "No MCP server is published at this path" 404
}
# One line per request. This gateway is the only door into a machine that runs code an agent
# wrote, so it should be able to say who knocked. Caddy redacts Authorization and Cookie in its
# access log by default, so the token itself is not written down.
log {
output stdout
format json
}
}

18
mcp-stack.vm.compose.yml Normal file
View File

@ -0,0 +1,18 @@
# Overlay for the dedicated VM. Use it on top of the base file:
#
# docker compose --env-file .env -f mcp-stack.compose.yml -f mcp-stack.vm.compose.yml up -d --build
#
# The base file alone stays what it is: a stack bound to loopback on somebody's machine.
name: secure-mcp-stack
services:
mcp-gateway:
ports:
# 80 and 443 on the VM's own address. Both, because automatic certificates need them: the
# HTTP-01 challenge is answered on 80 and TLS-ALPN-01 on 443, and Caddy picks whichever the
# CA offers. 80 also carries the redirect to HTTPS for anyone who types the bare host name.
- "${MCP_BIND_ADDRESS:-0.0.0.0}:80:80"
- "${MCP_BIND_ADDRESS:-0.0.0.0}:443:443"
#
# Nothing else is added: the base file's ports stay on loopback, which is where the same
# servers without TLS in front of them belong.