Compare commits

...

7 Commits

Author SHA1 Message Date
Michal
d1390313a3 feat: add Docker container management for MCP servers
McpOrchestrator interface with DockerContainerManager implementation,
instance service for lifecycle management, instance API routes,
and docker-compose with mcpd service. 127 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:52:12 +00:00
Michal
0ff5c85cf6 feat: add project management APIs with MCP config generation
Project CRUD, profile association, and MCP config generation that
filters secret env vars. 104 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:35:00 +00:00
Michal
3fa2bc5ffa feat: add MCP server and profile management API
Add validation schemas (Zod), repository pattern with Prisma, service layer
with business logic (NotFoundError, ConflictError), and REST routes for
MCP server and profile CRUD. 86 mcpd tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:35:00 +00:00
Michal
47f10f62c7 feat: implement mcpd core server framework with Fastify
Add Fastify server with config validation (Zod), health/healthz endpoints,
auth middleware (Bearer token + session lookup), security plugins (CORS,
Helmet, rate limiting), error handler, audit logging, and graceful shutdown.
36 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:35:00 +00:00
Michal
247b4967e5 feat: build CLI core framework with Commander.js
Add CLI entry point with Commander.js, config management (~/.mcpctl/config.json
with Zod validation), output formatters (table/json/yaml), config and status
commands with dependency injection for testing. Fix sanitizeString regex ordering.
67 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:35:00 +00:00
Michal
dc45f5981b feat: implement database schema with Prisma ORM
Add PostgreSQL schema with 8 models (User, Session, McpServer, McpProfile,
Project, ProjectMcpProfile, McpInstance, AuditLog), comprehensive model
tests (31 passing), seed data for default MCP servers, and package exports.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 04:34:05 +00:00
f5fae2936a Merge pull request 'feat: MCP registry client with multi-source search' (#1) from feat/mcp-registry-client into main
Reviewed-on: #1
2026-02-21 03:49:13 +00:00
79 changed files with 5245 additions and 69 deletions

File diff suppressed because one or more lines are too long

View File

@@ -15,6 +15,35 @@ services:
interval: 5s
timeout: 5s
retries: 5
networks:
- mcpctl
mcpd:
build:
context: ..
dockerfile: deploy/Dockerfile.mcpd
container_name: mcpctl-mcpd
ports:
- "3100:3100"
environment:
DATABASE_URL: postgresql://mcpctl:mcpctl_dev@postgres:5432/mcpctl
PORT: "3100"
HOST: "0.0.0.0"
LOG_LEVEL: info
depends_on:
postgres:
condition: service_healthy
volumes:
- /var/run/docker.sock:/var/run/docker.sock
networks:
- mcpctl
- mcp-servers
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:3100/healthz || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 10s
postgres-test:
image: postgres:16-alpine
@@ -32,6 +61,15 @@ services:
interval: 5s
timeout: 5s
retries: 5
profiles:
- test
networks:
mcpctl:
driver: bridge
mcp-servers:
driver: bridge
internal: true
volumes:
mcpctl-pgdata:

450
pnpm-lock.yaml generated
View File

@@ -62,6 +62,13 @@ importers:
zod:
specifier: ^3.24.0
version: 3.25.76
devDependencies:
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
'@types/node':
specifier: ^25.3.0
version: 25.3.0
src/db:
dependencies:
@@ -102,12 +109,25 @@ importers:
'@mcpctl/shared':
specifier: workspace:*
version: link:../shared
'@prisma/client':
specifier: ^6.0.0
version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)
dockerode:
specifier: ^4.0.9
version: 4.0.9
fastify:
specifier: ^5.0.0
version: 5.7.4
zod:
specifier: ^3.24.0
version: 3.25.76
devDependencies:
'@types/dockerode':
specifier: ^4.0.1
version: 4.0.1
'@types/node':
specifier: ^25.3.0
version: 25.3.0
src/shared:
dependencies:
@@ -134,6 +154,9 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
'@balena/dockerignore@1.0.2':
resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==}
'@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
@@ -351,6 +374,20 @@ packages:
'@fastify/rate-limit@10.3.0':
resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==}
'@grpc/grpc-js@1.14.3':
resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==}
engines: {node: '>=12.10.0'}
'@grpc/proto-loader@0.7.15':
resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==}
engines: {node: '>=6'}
hasBin: true
'@grpc/proto-loader@0.8.0':
resolution: {integrity: sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ==}
engines: {node: '>=6'}
hasBin: true
'@hono/node-server@1.19.9':
resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
engines: {node: '>=18.14.1'}
@@ -517,6 +554,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@js-sdsl/ordered-map@4.4.2':
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
'@lukeed/ms@2.0.2':
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'}
@@ -564,6 +604,36 @@ packages:
'@prisma/get-platform@6.19.2':
resolution: {integrity: sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==}
'@protobufjs/aspromise@1.1.2':
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
'@protobufjs/base64@1.1.2':
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
'@protobufjs/codegen@2.0.4':
resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==}
'@protobufjs/eventemitter@1.1.0':
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
'@protobufjs/fetch@1.1.0':
resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
'@protobufjs/float@1.0.2':
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
'@protobufjs/inquire@1.1.0':
resolution: {integrity: sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==}
'@protobufjs/path@1.1.2':
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
'@protobufjs/pool@1.1.0':
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
'@protobufjs/utf8@1.1.0':
resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==}
'@rollup/rollup-android-arm-eabi@4.58.0':
resolution: {integrity: sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==}
cpu: [arm]
@@ -698,18 +768,33 @@ packages:
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/docker-modem@3.0.6':
resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==}
'@types/dockerode@4.0.1':
resolution: {integrity: sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==}
'@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@25.3.0':
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
'@types/ssh2@1.15.5':
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
'@typescript-eslint/eslint-plugin@8.56.0':
resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -849,6 +934,9 @@ packages:
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@@ -870,6 +958,15 @@ packages:
resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==}
engines: {node: 20 || >=22}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
bl@4.1.0:
resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==}
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
@@ -881,6 +978,13 @@ packages:
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
engines: {node: 20 || >=22}
buffer@5.7.1:
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
buildcheck@0.0.7:
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
engines: {node: '>=10.0.0'}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@@ -916,6 +1020,9 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
citty@0.1.6:
resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
@@ -926,6 +1033,10 @@ packages:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
@@ -968,6 +1079,10 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'}
cpu-features@0.0.10:
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
engines: {node: '>=10.0.0'}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
@@ -1002,6 +1117,14 @@ packages:
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
docker-modem@5.0.6:
resolution: {integrity: sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ==}
engines: {node: '>= 8.0'}
dockerode@4.0.9:
resolution: {integrity: sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==}
engines: {node: '>= 8.0'}
dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'}
@@ -1027,6 +1150,9 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'}
@@ -1047,6 +1173,10 @@ packages:
engines: {node: '>=18'}
hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
@@ -1208,6 +1338,9 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1216,6 +1349,10 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'}
@@ -1274,6 +1411,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'}
@@ -1386,6 +1526,12 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
lodash.camelcase@4.3.0:
resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==}
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
lru-cache@11.2.6:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22}
@@ -1432,6 +1578,9 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mnemonist@0.40.0:
resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==}
@@ -1442,6 +1591,9 @@ packages:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0}
nan@2.25.0:
resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -1578,10 +1730,17 @@ packages:
process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
protobufjs@7.5.4:
resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==}
engines: {node: '>=12.0.0'}
proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
@@ -1607,6 +1766,10 @@ packages:
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
@@ -1615,6 +1778,10 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
@@ -1654,6 +1821,9 @@ packages:
rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-regex2@5.0.0:
resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==}
@@ -1724,10 +1894,17 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
split-ca@1.0.1:
resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==}
split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
ssh2@1.17.0:
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
engines: {node: '>=10.16.0'}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -1742,6 +1919,9 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@@ -1750,6 +1930,13 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
tar-fs@2.1.4:
resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==}
tar-stream@2.2.0:
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
engines: {node: '>=6'}
thread-stream@4.0.0:
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
engines: {node: '>=20'}
@@ -1791,6 +1978,9 @@ packages:
engines: {node: '>=18.0.0'}
hasBin: true
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'}
@@ -1804,6 +1994,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
@@ -1814,6 +2007,13 @@ packages:
uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
hasBin: true
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
@@ -1910,9 +2110,25 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@17.7.2:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
@@ -1944,6 +2160,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
'@balena/dockerignore@1.0.2': {}
'@bcoe/v8-coverage@1.0.2': {}
'@esbuild/aix-ppc64@0.27.3':
@@ -2093,6 +2311,25 @@ snapshots:
fastify-plugin: 5.1.0
toad-cache: 3.7.0
'@grpc/grpc-js@1.14.3':
dependencies:
'@grpc/proto-loader': 0.8.0
'@js-sdsl/ordered-map': 4.4.2
'@grpc/proto-loader@0.7.15':
dependencies:
lodash.camelcase: 4.3.0
long: 5.3.2
protobufjs: 7.5.4
yargs: 17.7.2
'@grpc/proto-loader@0.8.0':
dependencies:
lodash.camelcase: 4.3.0
long: 5.3.2
protobufjs: 7.5.4
yargs: 17.7.2
'@hono/node-server@1.19.9(hono@4.12.0)':
dependencies:
hono: 4.12.0
@@ -2242,6 +2479,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@js-sdsl/ordered-map@4.4.2': {}
'@lukeed/ms@2.0.2': {}
'@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)':
@@ -2303,6 +2542,29 @@ snapshots:
dependencies:
'@prisma/debug': 6.19.2
'@protobufjs/aspromise@1.1.2': {}
'@protobufjs/base64@1.1.2': {}
'@protobufjs/codegen@2.0.4': {}
'@protobufjs/eventemitter@1.1.0': {}
'@protobufjs/fetch@1.1.0':
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/inquire': 1.1.0
'@protobufjs/float@1.0.2': {}
'@protobufjs/inquire@1.1.0': {}
'@protobufjs/path@1.1.2': {}
'@protobufjs/pool@1.1.0': {}
'@protobufjs/utf8@1.1.0': {}
'@rollup/rollup-android-arm-eabi@4.58.0':
optional: true
@@ -2387,16 +2649,37 @@ snapshots:
'@types/deep-eql@4.0.2': {}
'@types/docker-modem@3.0.6':
dependencies:
'@types/node': 25.3.0
'@types/ssh2': 1.15.5
'@types/dockerode@4.0.1':
dependencies:
'@types/docker-modem': 3.0.6
'@types/node': 25.3.0
'@types/ssh2': 1.15.5
'@types/esrecurse@4.3.1': {}
'@types/estree@1.0.8': {}
'@types/js-yaml@4.0.9': {}
'@types/json-schema@7.0.15': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@25.3.0':
dependencies:
undici-types: 7.18.2
'@types/ssh2@1.15.5':
dependencies:
'@types/node': 18.19.130
'@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies:
'@eslint-community/regexpp': 4.12.2
@@ -2580,6 +2863,10 @@ snapshots:
argparse@2.0.1: {}
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
assertion-error@2.0.1: {}
ast-v8-to-istanbul@0.3.11:
@@ -2599,6 +2886,18 @@ snapshots:
balanced-match@4.0.3: {}
base64-js@1.5.1: {}
bcrypt-pbkdf@1.0.2:
dependencies:
tweetnacl: 0.14.5
bl@4.1.0:
dependencies:
buffer: 5.7.1
inherits: 2.0.4
readable-stream: 3.6.2
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
@@ -2621,6 +2920,14 @@ snapshots:
dependencies:
balanced-match: 4.0.3
buffer@5.7.1:
dependencies:
base64-js: 1.5.1
ieee754: 1.2.1
buildcheck@0.0.7:
optional: true
bytes@3.1.2: {}
c12@3.1.0:
@@ -2658,6 +2965,8 @@ snapshots:
dependencies:
readdirp: 4.1.2
chownr@1.1.4: {}
citty@0.1.6:
dependencies:
consola: 3.4.2
@@ -2666,6 +2975,12 @@ snapshots:
cli-width@4.1.0: {}
cliui@8.0.1:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 7.0.0
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
@@ -2693,6 +3008,12 @@ snapshots:
object-assign: 4.1.1
vary: 1.1.2
cpu-features@0.0.10:
dependencies:
buildcheck: 0.0.7
nan: 2.25.0
optional: true
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
@@ -2715,6 +3036,27 @@ snapshots:
destr@2.0.5: {}
docker-modem@5.0.6:
dependencies:
debug: 4.4.3
readable-stream: 3.6.2
split-ca: 1.0.1
ssh2: 1.17.0
transitivePeerDependencies:
- supports-color
dockerode@4.0.9:
dependencies:
'@balena/dockerignore': 1.0.2
'@grpc/grpc-js': 1.14.3
'@grpc/proto-loader': 0.7.15
docker-modem: 5.0.6
protobufjs: 7.5.4
tar-fs: 2.1.4
uuid: 10.0.0
transitivePeerDependencies:
- supports-color
dotenv@16.6.1: {}
dunder-proto@1.0.1:
@@ -2736,6 +3078,10 @@ snapshots:
encodeurl@2.0.0: {}
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
es-define-property@1.0.1: {}
es-errors@1.3.0: {}
@@ -2775,6 +3121,8 @@ snapshots:
'@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3
escalade@3.2.0: {}
escape-html@1.0.3: {}
escape-string-regexp@4.0.0: {}
@@ -2995,11 +3343,15 @@ snapshots:
fresh@2.0.0: {}
fs-constants@1.0.0: {}
fsevents@2.3.3:
optional: true
function-bind@1.1.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -3069,6 +3421,8 @@ snapshots:
dependencies:
safer-buffer: 2.1.2
ieee754@1.2.1: {}
ignore@5.3.2: {}
ignore@7.0.5: {}
@@ -3163,6 +3517,10 @@ snapshots:
dependencies:
p-locate: 5.0.0
lodash.camelcase@4.3.0: {}
long@5.3.2: {}
lru-cache@11.2.6: {}
magic-string@0.30.21:
@@ -3201,6 +3559,8 @@ snapshots:
minipass@7.1.3: {}
mkdirp-classic@0.5.3: {}
mnemonist@0.40.0:
dependencies:
obliterator: 2.0.5
@@ -3209,6 +3569,9 @@ snapshots:
mute-stream@2.0.0: {}
nan@2.25.0:
optional: true
nanoid@3.3.11: {}
natural-compare@1.4.0: {}
@@ -3332,11 +3695,31 @@ snapshots:
process-warning@5.0.0: {}
protobufjs@7.5.4:
dependencies:
'@protobufjs/aspromise': 1.1.2
'@protobufjs/base64': 1.1.2
'@protobufjs/codegen': 2.0.4
'@protobufjs/eventemitter': 1.1.0
'@protobufjs/fetch': 1.1.0
'@protobufjs/float': 1.0.2
'@protobufjs/inquire': 1.1.0
'@protobufjs/path': 1.1.2
'@protobufjs/pool': 1.1.0
'@protobufjs/utf8': 1.1.0
'@types/node': 25.3.0
long: 5.3.2
proxy-addr@2.0.7:
dependencies:
forwarded: 0.2.0
ipaddr.js: 1.9.1
pump@3.0.3:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
punycode@2.3.1: {}
pure-rand@6.1.0: {}
@@ -3361,10 +3744,18 @@ snapshots:
defu: 6.1.4
destr: 2.0.5
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
readdirp@4.1.2: {}
real-require@0.2.0: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {}
resolve-pkg-maps@1.0.0: {}
@@ -3427,6 +3818,8 @@ snapshots:
dependencies:
tslib: 2.8.1
safe-buffer@5.2.1: {}
safe-regex2@5.0.0:
dependencies:
ret: 0.5.0
@@ -3512,8 +3905,18 @@ snapshots:
source-map-js@1.2.1: {}
split-ca@1.0.1: {}
split2@4.2.0: {}
ssh2@1.17.0:
dependencies:
asn1: 0.2.6
bcrypt-pbkdf: 1.0.2
optionalDependencies:
cpu-features: 0.0.10
nan: 2.25.0
stackback@0.0.2: {}
statuses@2.0.2: {}
@@ -3526,6 +3929,10 @@ snapshots:
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
@@ -3534,6 +3941,21 @@ snapshots:
dependencies:
has-flag: 4.0.0
tar-fs@2.1.4:
dependencies:
chownr: 1.1.4
mkdirp-classic: 0.5.3
pump: 3.0.3
tar-stream: 2.2.0
tar-stream@2.2.0:
dependencies:
bl: 4.1.0
end-of-stream: 1.4.5
fs-constants: 1.0.0
inherits: 2.0.4
readable-stream: 3.6.2
thread-stream@4.0.0:
dependencies:
real-require: 0.2.0
@@ -3566,6 +3988,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
tweetnacl@0.14.5: {}
type-check@0.4.0:
dependencies:
prelude-ls: 1.2.1
@@ -3578,6 +4002,8 @@ snapshots:
typescript@5.9.3: {}
undici-types@5.26.5: {}
undici-types@7.18.2: {}
unpipe@1.0.0: {}
@@ -3586,6 +4012,10 @@ snapshots:
dependencies:
punycode: 2.3.1
util-deprecate@1.0.2: {}
uuid@10.0.0: {}
vary@1.1.2: {}
vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0):
@@ -3656,8 +4086,28 @@ snapshots:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrappy@1.0.2: {}
y18n@5.0.8: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
dependencies:
cliui: 8.0.1
escalade: 3.2.0
get-caller-file: 2.0.5
require-directory: 2.1.1
string-width: 4.2.3
y18n: 5.0.8
yargs-parser: 21.1.1
yocto-queue@0.1.0: {}
yoctocolors-cjs@2.1.3: {}

View File

@@ -23,5 +23,9 @@
"inquirer": "^12.0.0",
"js-yaml": "^4.1.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.3.0"
}
}

View File

@@ -0,0 +1,69 @@
import { Command } from 'commander';
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js';
import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js';
import { formatJson, formatYaml } from '../formatters/index.js';
export interface ConfigCommandDeps {
configDeps: Partial<ConfigLoaderDeps>;
log: (...args: string[]) => void;
}
const defaultDeps: ConfigCommandDeps = {
configDeps: {},
log: (...args) => console.log(...args),
};
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>): Command {
const { configDeps, log } = { ...defaultDeps, ...deps };
const config = new Command('config').description('Manage mcpctl configuration');
config
.command('view')
.description('Show current configuration')
.option('-o, --output <format>', 'output format (json, yaml)', 'json')
.action((opts: { output: string }) => {
const cfg = loadConfig(configDeps);
const out = opts.output === 'yaml' ? formatYaml(cfg) : formatJson(cfg);
log(out);
});
config
.command('set')
.description('Set a configuration value')
.argument('<key>', 'configuration key (e.g., daemonUrl, outputFormat)')
.argument('<value>', 'value to set')
.action((key: string, value: string) => {
const updates: Record<string, unknown> = {};
// Handle typed conversions
if (key === 'cacheTTLMs') {
updates[key] = parseInt(value, 10);
} else if (key === 'registries') {
updates[key] = value.split(',').map((s) => s.trim());
} else {
updates[key] = value;
}
const updated = mergeConfig(updates as Partial<McpctlConfig>, configDeps);
saveConfig(updated, configDeps);
log(`Set ${key} = ${value}`);
});
config
.command('path')
.description('Show configuration file path')
.action(() => {
log(getConfigPath(configDeps?.configDir));
});
config
.command('reset')
.description('Reset configuration to defaults')
.action(() => {
saveConfig(DEFAULT_CONFIG, configDeps);
log('Configuration reset to defaults');
});
return config;
}

View File

@@ -0,0 +1,63 @@
import { Command } from 'commander';
import http from 'node:http';
import { loadConfig } from '../config/index.js';
import type { ConfigLoaderDeps } from '../config/index.js';
import { formatJson, formatYaml } from '../formatters/index.js';
import { APP_VERSION } from '@mcpctl/shared';
export interface StatusCommandDeps {
configDeps: Partial<ConfigLoaderDeps>;
log: (...args: string[]) => void;
checkDaemon: (url: string) => Promise<boolean>;
}
function defaultCheckDaemon(url: string): Promise<boolean> {
return new Promise((resolve) => {
const req = http.get(`${url}/health`, { timeout: 3000 }, (res) => {
resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 400);
res.resume();
});
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
const defaultDeps: StatusCommandDeps = {
configDeps: {},
log: (...args) => console.log(...args),
checkDaemon: defaultCheckDaemon,
};
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
const { configDeps, log, checkDaemon } = { ...defaultDeps, ...deps };
return new Command('status')
.description('Show mcpctl status and connectivity')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.action(async (opts: { output: string }) => {
const config = loadConfig(configDeps);
const daemonReachable = await checkDaemon(config.daemonUrl);
const status = {
version: APP_VERSION,
daemonUrl: config.daemonUrl,
daemonReachable,
registries: config.registries,
outputFormat: config.outputFormat,
};
if (opts.output === 'json') {
log(formatJson(status));
} else if (opts.output === 'yaml') {
log(formatYaml(status));
} else {
log(`mcpctl v${status.version}`);
log(`Daemon: ${status.daemonUrl} (${daemonReachable ? 'connected' : 'unreachable'})`);
log(`Registries: ${status.registries.join(', ')}`);
log(`Output: ${status.outputFormat}`);
}
});
}

View File

@@ -0,0 +1,4 @@
export { McpctlConfigSchema, DEFAULT_CONFIG } from './schema.js';
export type { McpctlConfig } from './schema.js';
export { loadConfig, saveConfig, mergeConfig, getConfigPath } from './loader.js';
export type { ConfigLoaderDeps } from './loader.js';

View File

@@ -0,0 +1,45 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { McpctlConfigSchema, DEFAULT_CONFIG } from './schema.js';
import type { McpctlConfig } from './schema.js';
export interface ConfigLoaderDeps {
configDir: string;
}
function defaultConfigDir(): string {
return join(homedir(), '.mcpctl');
}
export function getConfigPath(configDir?: string): string {
return join(configDir ?? defaultConfigDir(), 'config.json');
}
export function loadConfig(deps?: Partial<ConfigLoaderDeps>): McpctlConfig {
const configPath = getConfigPath(deps?.configDir);
if (!existsSync(configPath)) {
return DEFAULT_CONFIG;
}
const raw = readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw) as unknown;
return McpctlConfigSchema.parse(parsed);
}
export function saveConfig(config: McpctlConfig, deps?: Partial<ConfigLoaderDeps>): void {
const dir = deps?.configDir ?? defaultConfigDir();
const configPath = getConfigPath(dir);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
}
export function mergeConfig(overrides: Partial<McpctlConfig>, deps?: Partial<ConfigLoaderDeps>): McpctlConfig {
const current = loadConfig(deps);
return McpctlConfigSchema.parse({ ...current, ...overrides });
}

View File

@@ -0,0 +1,22 @@
import { z } from 'zod';
export const McpctlConfigSchema = z.object({
/** mcpd daemon endpoint */
daemonUrl: z.string().default('http://localhost:3000'),
/** Active registries for search */
registries: z.array(z.enum(['official', 'glama', 'smithery'])).default(['official', 'glama', 'smithery']),
/** Cache TTL in milliseconds */
cacheTTLMs: z.number().int().positive().default(3_600_000),
/** HTTP proxy URL */
httpProxy: z.string().optional(),
/** HTTPS proxy URL */
httpsProxy: z.string().optional(),
/** Default output format */
outputFormat: z.enum(['table', 'json', 'yaml']).default('table'),
/** Smithery API key */
smitheryApiKey: z.string().optional(),
});
export type McpctlConfig = z.infer<typeof McpctlConfigSchema>;
export const DEFAULT_CONFIG: McpctlConfig = McpctlConfigSchema.parse({});

View File

@@ -0,0 +1,4 @@
export { formatTable } from './table.js';
export type { Column } from './table.js';
export { formatJson, formatYaml } from './output.js';
export type { OutputFormat } from './output.js';

View File

@@ -0,0 +1,11 @@
import yaml from 'js-yaml';
export type OutputFormat = 'table' | 'json' | 'yaml';
export function formatJson(data: unknown): string {
return JSON.stringify(data, null, 2);
}
export function formatYaml(data: unknown): string {
return yaml.dump(data, { lineWidth: 120, noRefs: true }).trimEnd();
}

View File

@@ -0,0 +1,44 @@
export interface Column<T> {
header: string;
key: keyof T | ((row: T) => string);
width?: number;
align?: 'left' | 'right';
}
export function formatTable<T>(rows: T[], columns: Column<T>[]): string {
if (rows.length === 0) {
return 'No results found.';
}
const getValue = (row: T, col: Column<T>): string => {
if (typeof col.key === 'function') {
return col.key(row);
}
const val = row[col.key];
return val == null ? '' : String(val);
};
// Calculate column widths
const widths = columns.map((col) => {
if (col.width !== undefined) return col.width;
const headerLen = col.header.length;
const maxDataLen = rows.reduce((max, row) => {
const val = getValue(row, col);
return Math.max(max, val.length);
}, 0);
return Math.max(headerLen, maxDataLen);
});
const pad = (text: string, width: number, align: 'left' | 'right' = 'left'): string => {
const truncated = text.length > width ? text.slice(0, width - 1) + '\u2026' : text;
return align === 'right' ? truncated.padStart(width) : truncated.padEnd(width);
};
const headerLine = columns.map((col, i) => pad(col.header, widths[i] ?? 0, col.align ?? 'left')).join(' ');
const separator = widths.map((w) => '-'.repeat(w)).join(' ');
const dataLines = rows.map((row) =>
columns.map((col, i) => pad(getValue(row, col), widths[i] ?? 0, col.align ?? 'left')).join(' '),
);
return [headerLine, separator, ...dataLines].join('\n');
}

View File

@@ -1,2 +1,29 @@
// mcpctl CLI entry point
// Will be implemented in Task 7
#!/usr/bin/env node
import { Command } from 'commander';
import { APP_NAME, APP_VERSION } from '@mcpctl/shared';
import { createConfigCommand } from './commands/config.js';
import { createStatusCommand } from './commands/status.js';
export function createProgram(): Command {
const program = new Command()
.name(APP_NAME)
.description('Manage MCP servers like kubectl manages containers')
.version(APP_VERSION, '-v, --version')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.option('--daemon-url <url>', 'mcpd daemon URL');
program.addCommand(createConfigCommand());
program.addCommand(createStatusCommand());
return program;
}
// Run when invoked directly
const isDirectRun =
typeof process !== 'undefined' &&
process.argv[1] !== undefined &&
import.meta.url === `file://${process.argv[1]}`;
if (isDirectRun) {
createProgram().parseAsync(process.argv);
}

38
src/cli/tests/cli.test.ts Normal file
View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { createProgram } from '../src/index.js';
describe('createProgram', () => {
it('creates a Commander program', () => {
const program = createProgram();
expect(program.name()).toBe('mcpctl');
});
it('has version flag', () => {
const program = createProgram();
expect(program.version()).toBe('0.1.0');
});
it('has config subcommand', () => {
const program = createProgram();
const config = program.commands.find((c) => c.name() === 'config');
expect(config).toBeDefined();
});
it('has status subcommand', () => {
const program = createProgram();
const status = program.commands.find((c) => c.name() === 'status');
expect(status).toBeDefined();
});
it('has output option', () => {
const program = createProgram();
const opt = program.options.find((o) => o.long === '--output');
expect(opt).toBeDefined();
});
it('has daemon-url option', () => {
const program = createProgram();
const opt = program.options.find((o) => o.long === '--daemon-url');
expect(opt).toBeDefined();
});
});

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.js';
import { loadConfig, saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js';
let tempDir: string;
let output: string[];
function log(...args: string[]) {
output.push(args.join(' '));
}
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-test-'));
output = [];
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function makeCommand() {
return createConfigCommand({
configDeps: { configDir: tempDir },
log,
});
}
describe('config view', () => {
it('outputs default config as JSON', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['view'], { from: 'user' });
expect(output).toHaveLength(1);
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['daemonUrl']).toBe('http://localhost:3000');
});
it('outputs config as YAML with --output yaml', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['view', '-o', 'yaml'], { from: 'user' });
expect(output[0]).toContain('daemonUrl:');
});
});
describe('config set', () => {
it('sets a string value', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['set', 'daemonUrl', 'http://new:9000'], { from: 'user' });
expect(output[0]).toContain('daemonUrl');
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe('http://new:9000');
});
it('sets cacheTTLMs as integer', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['set', 'cacheTTLMs', '60000'], { from: 'user' });
const config = loadConfig({ configDir: tempDir });
expect(config.cacheTTLMs).toBe(60000);
});
it('sets registries as comma-separated list', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['set', 'registries', 'official,glama'], { from: 'user' });
const config = loadConfig({ configDir: tempDir });
expect(config.registries).toEqual(['official', 'glama']);
});
it('sets outputFormat', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['set', 'outputFormat', 'json'], { from: 'user' });
const config = loadConfig({ configDir: tempDir });
expect(config.outputFormat).toBe('json');
});
});
describe('config path', () => {
it('shows config file path', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['path'], { from: 'user' });
expect(output[0]).toContain(tempDir);
expect(output[0]).toContain('config.json');
});
});
describe('config reset', () => {
it('resets to defaults', async () => {
// First set a custom value
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom' }, { configDir: tempDir });
const cmd = makeCommand();
await cmd.parseAsync(['reset'], { from: 'user' });
expect(output[0]).toContain('reset');
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe(DEFAULT_CONFIG.daemonUrl);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createStatusCommand } from '../../src/commands/status.js';
import { saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js';
let tempDir: string;
let output: string[];
function log(...args: string[]) {
output.push(args.join(' '));
}
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-status-test-'));
output = [];
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe('status command', () => {
it('shows status in table format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => true,
});
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('mcpctl v');
expect(output.join('\n')).toContain('connected');
});
it('shows unreachable when daemon is down', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => false,
});
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('unreachable');
});
it('shows status in JSON format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => true,
});
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['version']).toBe('0.1.0');
expect(parsed['daemonReachable']).toBe(true);
});
it('shows status in YAML format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => false,
});
await cmd.parseAsync(['-o', 'yaml'], { from: 'user' });
expect(output[0]).toContain('daemonReachable: false');
});
it('uses custom daemon URL from config', async () => {
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom:5555' }, { configDir: tempDir });
let checkedUrl = '';
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async (url) => {
checkedUrl = url;
return false;
},
});
await cmd.parseAsync([], { from: 'user' });
expect(checkedUrl).toBe('http://custom:5555');
});
it('shows registries from config', async () => {
saveConfig({ ...DEFAULT_CONFIG, registries: ['official'] }, { configDir: tempDir });
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => true,
});
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('official');
expect(output.join('\n')).not.toContain('glama');
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../../src/config/index.js';
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-test-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe('getConfigPath', () => {
it('returns path within config dir', () => {
const path = getConfigPath('/tmp/mcpctl');
expect(path).toBe('/tmp/mcpctl/config.json');
});
});
describe('loadConfig', () => {
it('returns defaults when no config file exists', () => {
const config = loadConfig({ configDir: tempDir });
expect(config).toEqual(DEFAULT_CONFIG);
});
it('loads config from file', () => {
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom:5000' }, { configDir: tempDir });
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe('http://custom:5000');
});
it('applies defaults for missing fields', () => {
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
writeFileSync(join(tempDir, 'config.json'), '{"daemonUrl":"http://x:1"}');
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe('http://x:1');
expect(config.registries).toEqual(['official', 'glama', 'smithery']);
});
});
describe('saveConfig', () => {
it('creates config file', () => {
saveConfig(DEFAULT_CONFIG, { configDir: tempDir });
expect(existsSync(join(tempDir, 'config.json'))).toBe(true);
});
it('creates config directory if missing', () => {
const nested = join(tempDir, 'nested', 'dir');
saveConfig(DEFAULT_CONFIG, { configDir: nested });
expect(existsSync(join(nested, 'config.json'))).toBe(true);
});
it('round-trips configuration', () => {
const custom = {
...DEFAULT_CONFIG,
daemonUrl: 'http://custom:9000',
registries: ['official' as const],
outputFormat: 'json' as const,
};
saveConfig(custom, { configDir: tempDir });
const loaded = loadConfig({ configDir: tempDir });
expect(loaded).toEqual(custom);
});
});
describe('mergeConfig', () => {
it('merges overrides into existing config', () => {
saveConfig(DEFAULT_CONFIG, { configDir: tempDir });
const merged = mergeConfig({ daemonUrl: 'http://new:1234' }, { configDir: tempDir });
expect(merged.daemonUrl).toBe('http://new:1234');
expect(merged.registries).toEqual(DEFAULT_CONFIG.registries);
});
it('works when no config file exists', () => {
const merged = mergeConfig({ outputFormat: 'yaml' }, { configDir: tempDir });
expect(merged.outputFormat).toBe('yaml');
expect(merged.daemonUrl).toBe('http://localhost:3000');
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { McpctlConfigSchema, DEFAULT_CONFIG } from '../../src/config/schema.js';
describe('McpctlConfigSchema', () => {
it('provides sensible defaults from empty object', () => {
const config = McpctlConfigSchema.parse({});
expect(config.daemonUrl).toBe('http://localhost:3000');
expect(config.registries).toEqual(['official', 'glama', 'smithery']);
expect(config.cacheTTLMs).toBe(3_600_000);
expect(config.outputFormat).toBe('table');
expect(config.httpProxy).toBeUndefined();
expect(config.httpsProxy).toBeUndefined();
expect(config.smitheryApiKey).toBeUndefined();
});
it('validates a full config', () => {
const config = McpctlConfigSchema.parse({
daemonUrl: 'http://custom:4000',
registries: ['official'],
cacheTTLMs: 60_000,
httpProxy: 'http://proxy:8080',
httpsProxy: 'http://proxy:8443',
outputFormat: 'json',
smitheryApiKey: 'sk-test',
});
expect(config.daemonUrl).toBe('http://custom:4000');
expect(config.registries).toEqual(['official']);
expect(config.outputFormat).toBe('json');
});
it('rejects invalid registry names', () => {
expect(() => McpctlConfigSchema.parse({ registries: ['invalid'] })).toThrow();
});
it('rejects invalid output format', () => {
expect(() => McpctlConfigSchema.parse({ outputFormat: 'xml' })).toThrow();
});
it('rejects negative cacheTTLMs', () => {
expect(() => McpctlConfigSchema.parse({ cacheTTLMs: -1 })).toThrow();
});
it('rejects non-integer cacheTTLMs', () => {
expect(() => McpctlConfigSchema.parse({ cacheTTLMs: 1.5 })).toThrow();
});
});
describe('DEFAULT_CONFIG', () => {
it('matches schema defaults', () => {
expect(DEFAULT_CONFIG).toEqual(McpctlConfigSchema.parse({}));
});
});

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { formatJson, formatYaml } from '../../src/formatters/output.js';
describe('formatJson', () => {
it('formats object as indented JSON', () => {
const result = formatJson({ key: 'value', num: 42 });
expect(JSON.parse(result)).toEqual({ key: 'value', num: 42 });
expect(result).toContain('\n'); // indented
});
it('formats arrays', () => {
const result = formatJson([1, 2, 3]);
expect(JSON.parse(result)).toEqual([1, 2, 3]);
});
it('handles null and undefined values', () => {
const result = formatJson({ a: null, b: undefined });
const parsed = JSON.parse(result) as Record<string, unknown>;
expect(parsed['a']).toBeNull();
expect('b' in parsed).toBe(false); // undefined stripped by JSON
});
});
describe('formatYaml', () => {
it('formats object as YAML', () => {
const result = formatYaml({ key: 'value', num: 42 });
expect(result).toContain('key: value');
expect(result).toContain('num: 42');
});
it('formats arrays', () => {
const result = formatYaml(['a', 'b']);
expect(result).toContain('- a');
expect(result).toContain('- b');
});
it('does not end with trailing newline', () => {
const result = formatYaml({ x: 1 });
expect(result.endsWith('\n')).toBe(false);
});
});

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { formatTable } from '../../src/formatters/table.js';
import type { Column } from '../../src/formatters/table.js';
interface TestRow {
name: string;
age: number;
city: string;
}
const columns: Column<TestRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'AGE', key: 'age', align: 'right' },
{ header: 'CITY', key: 'city' },
];
describe('formatTable', () => {
it('returns empty message for no rows', () => {
expect(formatTable([], columns)).toBe('No results found.');
});
it('formats a single row', () => {
const rows = [{ name: 'Alice', age: 30, city: 'NYC' }];
const result = formatTable(rows, columns);
const lines = result.split('\n');
expect(lines).toHaveLength(3); // header, separator, data
expect(lines[0]).toContain('NAME');
expect(lines[0]).toContain('AGE');
expect(lines[0]).toContain('CITY');
expect(lines[2]).toContain('Alice');
expect(lines[2]).toContain('NYC');
});
it('right-aligns numeric columns', () => {
const rows = [{ name: 'Bob', age: 5, city: 'LA' }];
const result = formatTable(rows, columns);
const lines = result.split('\n');
// AGE column should be right-aligned: " 5" or "5" padded
const ageLine = lines[2];
// The age value should have leading space(s) for right alignment
expect(ageLine).toMatch(/\s+5/);
});
it('auto-sizes columns to content', () => {
const rows = [
{ name: 'A', age: 1, city: 'X' },
{ name: 'LongName', age: 100, city: 'LongCityName' },
];
const result = formatTable(rows, columns);
const lines = result.split('\n');
// Header should be at least as wide as longest data
expect(lines[0]).toContain('NAME');
expect(lines[2]).toContain('A');
expect(lines[3]).toContain('LongName');
expect(lines[3]).toContain('LongCityName');
});
it('truncates long values when width is fixed', () => {
const narrowCols: Column<TestRow>[] = [
{ header: 'NAME', key: 'name', width: 5 },
];
const rows = [{ name: 'VeryLongName', age: 0, city: '' }];
const result = formatTable(rows, narrowCols);
const lines = result.split('\n');
// Should be truncated with ellipsis
expect(lines[2].trim().length).toBeLessThanOrEqual(5);
expect(lines[2]).toContain('\u2026');
});
it('supports function-based column keys', () => {
const fnCols: Column<TestRow>[] = [
{ header: 'INFO', key: (row) => `${row.name} (${row.age})` },
];
const rows = [{ name: 'Eve', age: 25, city: 'SF' }];
const result = formatTable(rows, fnCols);
expect(result).toContain('Eve (25)');
});
it('handles separator line matching column widths', () => {
const rows = [{ name: 'Test', age: 1, city: 'Here' }];
const result = formatTable(rows, columns);
const lines = result.split('\n');
const separator = lines[1];
// Separator should consist of dashes and spaces
expect(separator).toMatch(/^[-\s]+$/);
});
});

View File

@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"references": [

172
src/db/prisma/schema.prisma Normal file
View File

@@ -0,0 +1,172 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ── Users ──
model User {
id String @id @default(cuid())
email String @unique
name String?
role Role @default(USER)
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
auditLogs AuditLog[]
projects Project[]
@@index([email])
}
enum Role {
USER
ADMIN
}
// ── Sessions ──
model Session {
id String @id @default(cuid())
token String @unique
userId String
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([token])
@@index([userId])
@@index([expiresAt])
}
// ── MCP Servers ──
model McpServer {
id String @id @default(cuid())
name String @unique
description String @default("")
packageName String?
dockerImage String?
transport Transport @default(STDIO)
repositoryUrl String?
envTemplate Json @default("[]")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profiles McpProfile[]
instances McpInstance[]
@@index([name])
}
enum Transport {
STDIO
SSE
STREAMABLE_HTTP
}
// ── MCP Profiles ──
model McpProfile {
id String @id @default(cuid())
name String
serverId String
permissions Json @default("[]")
envOverrides Json @default("{}")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
projects ProjectMcpProfile[]
@@unique([name, serverId])
@@index([serverId])
}
// ── Projects ──
model Project {
id String @id @default(cuid())
name String @unique
description String @default("")
ownerId String
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
profiles ProjectMcpProfile[]
@@index([name])
@@index([ownerId])
}
// ── Project <-> Profile join table ──
model ProjectMcpProfile {
id String @id @default(cuid())
projectId String
profileId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
profile McpProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
@@unique([projectId, profileId])
@@index([projectId])
@@index([profileId])
}
// ── MCP Instances (running containers) ──
model McpInstance {
id String @id @default(cuid())
serverId String
containerId String?
status InstanceStatus @default(STOPPED)
port Int?
metadata Json @default("{}")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@index([serverId])
@@index([status])
}
enum InstanceStatus {
STARTING
RUNNING
STOPPING
STOPPED
ERROR
}
// ── Audit Logs ──
model AuditLog {
id String @id @default(cuid())
userId String
action String
resource String
resourceId String?
details Json @default("{}")
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([action])
@@index([resource])
@@index([createdAt])
}

View File

@@ -1,2 +1,18 @@
// Database package - Prisma client and utilities
// Will be implemented in Task 2
export { PrismaClient } from '@prisma/client';
export type {
User,
Session,
McpServer,
McpProfile,
Project,
ProjectMcpProfile,
McpInstance,
AuditLog,
Role,
Transport,
InstanceStatus,
} from '@prisma/client';
export { seedMcpServers, defaultServers } from './seed/index.js';
export type { SeedServer } from './seed/index.js';

131
src/db/src/seed/index.ts Normal file
View File

@@ -0,0 +1,131 @@
import { PrismaClient } from '@prisma/client';
export interface SeedServer {
name: string;
description: string;
packageName: string;
transport: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP';
repositoryUrl: string;
envTemplate: Array<{
name: string;
description: string;
isSecret: boolean;
setupUrl?: string;
}>;
}
export const defaultServers: SeedServer[] = [
{
name: 'slack',
description: 'Slack MCP server for reading channels, messages, and user info',
packageName: '@anthropic/slack-mcp',
transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/slack',
envTemplate: [
{
name: 'SLACK_BOT_TOKEN',
description: 'Slack Bot User OAuth Token (xoxb-...)',
isSecret: true,
setupUrl: 'https://api.slack.com/apps',
},
{
name: 'SLACK_TEAM_ID',
description: 'Slack Workspace Team ID',
isSecret: false,
},
],
},
{
name: 'jira',
description: 'Jira MCP server for issues, projects, and boards',
packageName: '@anthropic/jira-mcp',
transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/jira',
envTemplate: [
{
name: 'JIRA_URL',
description: 'Jira instance URL (e.g., https://company.atlassian.net)',
isSecret: false,
},
{
name: 'JIRA_EMAIL',
description: 'Jira account email',
isSecret: false,
},
{
name: 'JIRA_API_TOKEN',
description: 'Jira API token',
isSecret: true,
setupUrl: 'https://id.atlassian.com/manage-profile/security/api-tokens',
},
],
},
{
name: 'github',
description: 'GitHub MCP server for repos, issues, PRs, and code search',
packageName: '@anthropic/github-mcp',
transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/github',
envTemplate: [
{
name: 'GITHUB_TOKEN',
description: 'GitHub Personal Access Token',
isSecret: true,
setupUrl: 'https://github.com/settings/tokens',
},
],
},
{
name: 'terraform',
description: 'Terraform MCP server for infrastructure documentation and state',
packageName: '@anthropic/terraform-mcp',
transport: 'STDIO',
repositoryUrl: 'https://github.com/modelcontextprotocol/servers/tree/main/src/terraform',
envTemplate: [],
},
];
export async function seedMcpServers(
prisma: PrismaClient,
servers: SeedServer[] = defaultServers,
): Promise<number> {
let created = 0;
for (const server of servers) {
await prisma.mcpServer.upsert({
where: { name: server.name },
update: {
description: server.description,
packageName: server.packageName,
transport: server.transport,
repositoryUrl: server.repositoryUrl,
envTemplate: server.envTemplate,
},
create: {
name: server.name,
description: server.description,
packageName: server.packageName,
transport: server.transport,
repositoryUrl: server.repositoryUrl,
envTemplate: server.envTemplate,
},
});
created++;
}
return created;
}
// CLI entry point
if (import.meta.url === `file://${process.argv[1]}`) {
const prisma = new PrismaClient();
seedMcpServers(prisma)
.then((count) => {
console.log(`Seeded ${count} MCP servers`);
return prisma.$disconnect();
})
.catch((e) => {
console.error(e);
return prisma.$disconnect().then(() => process.exit(1));
});
}

58
src/db/tests/helpers.ts Normal file
View File

@@ -0,0 +1,58 @@
import { PrismaClient } from '@prisma/client';
import { execSync } from 'node:child_process';
const TEST_DATABASE_URL = process.env['DATABASE_URL'] ??
'postgresql://mcpctl:mcpctl_test@localhost:5433/mcpctl_test';
let prisma: PrismaClient | undefined;
let schemaReady = false;
export function getTestClient(): PrismaClient {
if (!prisma) {
prisma = new PrismaClient({
datasources: { db: { url: TEST_DATABASE_URL } },
});
}
return prisma;
}
export async function setupTestDb(): Promise<PrismaClient> {
const client = getTestClient();
// Only push schema once per process (multiple test files share the worker)
if (!schemaReady) {
execSync('npx prisma db push --force-reset --skip-generate', {
cwd: new URL('..', import.meta.url).pathname,
env: {
...process.env,
DATABASE_URL: TEST_DATABASE_URL,
// Consent required when Prisma detects AI agent context.
// This targets the ephemeral test database (tmpfs-backed, port 5433).
PRISMA_USER_CONSENT_FOR_DANGEROUS_AI_ACTION: 'yes',
},
stdio: 'pipe',
});
schemaReady = true;
}
return client;
}
export async function cleanupTestDb(): Promise<void> {
if (prisma) {
await prisma.$disconnect();
prisma = undefined;
}
}
export async function clearAllTables(client: PrismaClient): Promise<void> {
// Delete in order respecting foreign keys
await client.auditLog.deleteMany();
await client.projectMcpProfile.deleteMany();
await client.mcpInstance.deleteMany();
await client.mcpProfile.deleteMany();
await client.session.deleteMany();
await client.project.deleteMany();
await client.mcpServer.deleteMany();
await client.user.deleteMany();
}

364
src/db/tests/models.test.ts Normal file
View File

@@ -0,0 +1,364 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import { setupTestDb, cleanupTestDb, clearAllTables, getTestClient } from './helpers.js';
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDb();
}, 30_000);
afterAll(async () => {
await cleanupTestDb();
});
beforeEach(async () => {
await clearAllTables(prisma);
});
// ── Helper factories ──
async function createUser(overrides: { email?: string; name?: string; role?: 'USER' | 'ADMIN' } = {}) {
return prisma.user.create({
data: {
email: overrides.email ?? `test-${Date.now()}@example.com`,
name: overrides.name ?? 'Test User',
role: overrides.role ?? 'USER',
},
});
}
async function createServer(overrides: { name?: string; transport?: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP' } = {}) {
return prisma.mcpServer.create({
data: {
name: overrides.name ?? `server-${Date.now()}`,
description: 'Test server',
packageName: '@test/mcp-server',
transport: overrides.transport ?? 'STDIO',
},
});
}
// ── User model ──
describe('User', () => {
it('creates a user with defaults', async () => {
const user = await createUser();
expect(user.id).toBeDefined();
expect(user.role).toBe('USER');
expect(user.version).toBe(1);
expect(user.createdAt).toBeInstanceOf(Date);
expect(user.updatedAt).toBeInstanceOf(Date);
});
it('enforces unique email', async () => {
await createUser({ email: 'dup@test.com' });
await expect(createUser({ email: 'dup@test.com' })).rejects.toThrow();
});
it('allows ADMIN role', async () => {
const admin = await createUser({ role: 'ADMIN' });
expect(admin.role).toBe('ADMIN');
});
it('updates updatedAt on change', async () => {
const user = await createUser();
const original = user.updatedAt;
// Small delay to ensure different timestamp
await new Promise((r) => setTimeout(r, 50));
const updated = await prisma.user.update({
where: { id: user.id },
data: { name: 'Updated' },
});
expect(updated.updatedAt.getTime()).toBeGreaterThanOrEqual(original.getTime());
});
});
// ── Session model ──
describe('Session', () => {
it('creates a session linked to user', async () => {
const user = await createUser();
const session = await prisma.session.create({
data: {
token: 'test-token-123',
userId: user.id,
expiresAt: new Date(Date.now() + 86400_000),
},
});
expect(session.token).toBe('test-token-123');
expect(session.userId).toBe(user.id);
});
it('enforces unique token', async () => {
const user = await createUser();
const data = {
token: 'unique-token',
userId: user.id,
expiresAt: new Date(Date.now() + 86400_000),
};
await prisma.session.create({ data });
await expect(prisma.session.create({ data })).rejects.toThrow();
});
it('cascades delete when user is deleted', async () => {
const user = await createUser();
await prisma.session.create({
data: {
token: 'cascade-token',
userId: user.id,
expiresAt: new Date(Date.now() + 86400_000),
},
});
await prisma.user.delete({ where: { id: user.id } });
const sessions = await prisma.session.findMany({ where: { userId: user.id } });
expect(sessions).toHaveLength(0);
});
});
// ── McpServer model ──
describe('McpServer', () => {
it('creates a server with defaults', async () => {
const server = await createServer();
expect(server.transport).toBe('STDIO');
expect(server.version).toBe(1);
expect(server.envTemplate).toEqual([]);
});
it('enforces unique name', async () => {
await createServer({ name: 'slack' });
await expect(createServer({ name: 'slack' })).rejects.toThrow();
});
it('stores envTemplate as JSON', async () => {
const server = await prisma.mcpServer.create({
data: {
name: 'with-env',
envTemplate: [
{ name: 'API_KEY', description: 'Key', isSecret: true },
],
},
});
const envTemplate = server.envTemplate as Array<{ name: string }>;
expect(envTemplate).toHaveLength(1);
expect(envTemplate[0].name).toBe('API_KEY');
});
it('supports SSE transport', async () => {
const server = await createServer({ transport: 'SSE' });
expect(server.transport).toBe('SSE');
});
});
// ── McpProfile model ──
describe('McpProfile', () => {
it('creates a profile linked to server', async () => {
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: {
name: 'readonly',
serverId: server.id,
permissions: ['read'],
},
});
expect(profile.name).toBe('readonly');
expect(profile.serverId).toBe(server.id);
});
it('enforces unique name per server', async () => {
const server = await createServer();
const data = { name: 'default', serverId: server.id };
await prisma.mcpProfile.create({ data });
await expect(prisma.mcpProfile.create({ data })).rejects.toThrow();
});
it('allows same profile name on different servers', async () => {
const server1 = await createServer({ name: 'server-1' });
const server2 = await createServer({ name: 'server-2' });
await prisma.mcpProfile.create({ data: { name: 'default', serverId: server1.id } });
const profile2 = await prisma.mcpProfile.create({ data: { name: 'default', serverId: server2.id } });
expect(profile2.name).toBe('default');
});
it('cascades delete when server is deleted', async () => {
const server = await createServer();
await prisma.mcpProfile.create({ data: { name: 'test', serverId: server.id } });
await prisma.mcpServer.delete({ where: { id: server.id } });
const profiles = await prisma.mcpProfile.findMany({ where: { serverId: server.id } });
expect(profiles).toHaveLength(0);
});
});
// ── Project model ──
describe('Project', () => {
it('creates a project with owner', async () => {
const user = await createUser();
const project = await prisma.project.create({
data: { name: 'weekly-reports', ownerId: user.id },
});
expect(project.name).toBe('weekly-reports');
expect(project.ownerId).toBe(user.id);
});
it('enforces unique project name', async () => {
const user = await createUser();
await prisma.project.create({ data: { name: 'dup', ownerId: user.id } });
await expect(
prisma.project.create({ data: { name: 'dup', ownerId: user.id } }),
).rejects.toThrow();
});
it('cascades delete when owner is deleted', async () => {
const user = await createUser();
await prisma.project.create({ data: { name: 'orphan', ownerId: user.id } });
await prisma.user.delete({ where: { id: user.id } });
const projects = await prisma.project.findMany({ where: { ownerId: user.id } });
expect(projects).toHaveLength(0);
});
});
// ── ProjectMcpProfile (join table) ──
describe('ProjectMcpProfile', () => {
it('links project to profile', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'default', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'test-project', ownerId: user.id },
});
const link = await prisma.projectMcpProfile.create({
data: { projectId: project.id, profileId: profile.id },
});
expect(link.projectId).toBe(project.id);
expect(link.profileId).toBe(profile.id);
});
it('enforces unique project+profile combination', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'default', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'test-project', ownerId: user.id },
});
const data = { projectId: project.id, profileId: profile.id };
await prisma.projectMcpProfile.create({ data });
await expect(prisma.projectMcpProfile.create({ data })).rejects.toThrow();
});
it('loads profiles through project include', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'slack-ro', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'reports', ownerId: user.id },
});
await prisma.projectMcpProfile.create({
data: { projectId: project.id, profileId: profile.id },
});
const loaded = await prisma.project.findUnique({
where: { id: project.id },
include: { profiles: { include: { profile: true } } },
});
expect(loaded!.profiles).toHaveLength(1);
expect(loaded!.profiles[0].profile.name).toBe('slack-ro');
});
});
// ── McpInstance model ──
describe('McpInstance', () => {
it('creates an instance linked to server', async () => {
const server = await createServer();
const instance = await prisma.mcpInstance.create({
data: { serverId: server.id },
});
expect(instance.status).toBe('STOPPED');
expect(instance.serverId).toBe(server.id);
});
it('tracks instance status transitions', async () => {
const server = await createServer();
const instance = await prisma.mcpInstance.create({
data: { serverId: server.id, status: 'STARTING' },
});
const running = await prisma.mcpInstance.update({
where: { id: instance.id },
data: { status: 'RUNNING', containerId: 'abc123', port: 8080 },
});
expect(running.status).toBe('RUNNING');
expect(running.containerId).toBe('abc123');
expect(running.port).toBe(8080);
});
it('cascades delete when server is deleted', async () => {
const server = await createServer();
await prisma.mcpInstance.create({ data: { serverId: server.id } });
await prisma.mcpServer.delete({ where: { id: server.id } });
const instances = await prisma.mcpInstance.findMany({ where: { serverId: server.id } });
expect(instances).toHaveLength(0);
});
});
// ── AuditLog model ──
describe('AuditLog', () => {
it('creates an audit log entry', async () => {
const user = await createUser();
const log = await prisma.auditLog.create({
data: {
userId: user.id,
action: 'CREATE',
resource: 'McpServer',
resourceId: 'server-123',
details: { name: 'slack' },
},
});
expect(log.action).toBe('CREATE');
expect(log.resource).toBe('McpServer');
expect(log.createdAt).toBeInstanceOf(Date);
});
it('supports querying by action and resource', async () => {
const user = await createUser();
await prisma.auditLog.createMany({
data: [
{ userId: user.id, action: 'CREATE', resource: 'McpServer' },
{ userId: user.id, action: 'UPDATE', resource: 'McpServer' },
{ userId: user.id, action: 'CREATE', resource: 'Project' },
],
});
const creates = await prisma.auditLog.findMany({
where: { action: 'CREATE' },
});
expect(creates).toHaveLength(2);
const serverLogs = await prisma.auditLog.findMany({
where: { resource: 'McpServer' },
});
expect(serverLogs).toHaveLength(2);
});
it('cascades delete when user is deleted', async () => {
const user = await createUser();
await prisma.auditLog.create({
data: { userId: user.id, action: 'TEST', resource: 'Test' },
});
await prisma.user.delete({ where: { id: user.id } });
const logs = await prisma.auditLog.findMany({ where: { userId: user.id } });
expect(logs).toHaveLength(0);
});
});

71
src/db/tests/seed.test.ts Normal file
View File

@@ -0,0 +1,71 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
import { seedMcpServers, defaultServers } from '../src/seed/index.js';
let prisma: PrismaClient;
beforeAll(async () => {
prisma = await setupTestDb();
}, 30_000);
afterAll(async () => {
await cleanupTestDb();
});
beforeEach(async () => {
await clearAllTables(prisma);
});
describe('seedMcpServers', () => {
it('seeds all default servers', async () => {
const count = await seedMcpServers(prisma);
expect(count).toBe(defaultServers.length);
const servers = await prisma.mcpServer.findMany({ orderBy: { name: 'asc' } });
expect(servers).toHaveLength(defaultServers.length);
const names = servers.map((s) => s.name);
expect(names).toContain('slack');
expect(names).toContain('github');
expect(names).toContain('jira');
expect(names).toContain('terraform');
});
it('is idempotent (upsert)', async () => {
await seedMcpServers(prisma);
const count = await seedMcpServers(prisma);
expect(count).toBe(defaultServers.length);
const servers = await prisma.mcpServer.findMany();
expect(servers).toHaveLength(defaultServers.length);
});
it('seeds envTemplate correctly', async () => {
await seedMcpServers(prisma);
const slack = await prisma.mcpServer.findUnique({ where: { name: 'slack' } });
const envTemplate = slack!.envTemplate as Array<{ name: string; isSecret: boolean }>;
expect(envTemplate).toHaveLength(2);
expect(envTemplate[0].name).toBe('SLACK_BOT_TOKEN');
expect(envTemplate[0].isSecret).toBe(true);
});
it('accepts custom server list', async () => {
const custom = [
{
name: 'custom-server',
description: 'Custom test server',
packageName: '@test/custom',
transport: 'STDIO' as const,
repositoryUrl: 'https://example.com',
envTemplate: [],
},
];
const count = await seedMcpServers(prisma, custom);
expect(count).toBe(1);
const servers = await prisma.mcpServer.findMany();
expect(servers).toHaveLength(1);
expect(servers[0].name).toBe('custom-server');
});
});

View File

@@ -4,5 +4,7 @@ export default defineProject({
test: {
name: 'db',
include: ['tests/**/*.test.ts'],
// Test files share the same database — run sequentially
fileParallelism: false,
},
});

View File

@@ -14,12 +14,18 @@
"test:run": "vitest run"
},
"dependencies": {
"fastify": "^5.0.0",
"@fastify/cors": "^10.0.0",
"@fastify/helmet": "^12.0.0",
"@fastify/rate-limit": "^10.0.0",
"zod": "^3.24.0",
"@mcpctl/db": "workspace:*",
"@mcpctl/shared": "workspace:*",
"@mcpctl/db": "workspace:*"
"@prisma/client": "^6.0.0",
"dockerode": "^4.0.9",
"fastify": "^5.0.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/dockerode": "^4.0.1",
"@types/node": "^25.3.0"
}
}

View File

@@ -0,0 +1,2 @@
export { McpdConfigSchema, loadConfigFromEnv } from './schema.js';
export type { McpdConfig } from './schema.js';

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
export const McpdConfigSchema = z.object({
port: z.number().int().positive().default(3000),
host: z.string().default('0.0.0.0'),
databaseUrl: z.string().min(1),
logLevel: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'),
corsOrigins: z.array(z.string()).default(['*']),
rateLimitMax: z.number().int().positive().default(100),
rateLimitWindowMs: z.number().int().positive().default(60_000),
});
export type McpdConfig = z.infer<typeof McpdConfigSchema>;
export function loadConfigFromEnv(env: Record<string, string | undefined> = process.env): McpdConfig {
return McpdConfigSchema.parse({
port: env['MCPD_PORT'] !== undefined ? parseInt(env['MCPD_PORT'], 10) : undefined,
host: env['MCPD_HOST'],
databaseUrl: env['DATABASE_URL'],
logLevel: env['MCPD_LOG_LEVEL'],
corsOrigins: env['MCPD_CORS_ORIGINS']?.split(',').map((s) => s.trim()),
rateLimitMax: env['MCPD_RATE_LIMIT_MAX'] !== undefined ? parseInt(env['MCPD_RATE_LIMIT_MAX'], 10) : undefined,
rateLimitWindowMs: env['MCPD_RATE_LIMIT_WINDOW_MS'] !== undefined ? parseInt(env['MCPD_RATE_LIMIT_WINDOW_MS'], 10) : undefined,
});
}

View File

@@ -1,2 +1,15 @@
// mcpd daemon server entry point
// Will be implemented in Task 3
export { createServer } from './server.js';
export type { ServerDeps } from './server.js';
export { McpdConfigSchema, loadConfigFromEnv } from './config/index.js';
export type { McpdConfig } from './config/index.js';
export {
createAuthMiddleware,
registerSecurityPlugins,
errorHandler,
registerAuditHook,
} from './middleware/index.js';
export type { AuthDeps, AuditDeps, ErrorResponse } from './middleware/index.js';
export { registerHealthRoutes } from './routes/index.js';
export type { HealthDeps } from './routes/index.js';
export { setupGracefulShutdown } from './utils/index.js';
export type { ShutdownDeps } from './utils/index.js';

View File

@@ -0,0 +1,59 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
export interface AuditDeps {
createAuditLog: (entry: {
userId: string;
action: string;
resource: string;
resourceId?: string;
details?: Record<string, unknown>;
}) => Promise<void>;
}
export function registerAuditHook(app: FastifyInstance, deps: AuditDeps): void {
app.addHook('onResponse', async (request: FastifyRequest, reply: FastifyReply) => {
// Only audit mutating methods on authenticated requests
if (request.userId === undefined) return;
if (request.method === 'GET' || request.method === 'HEAD' || request.method === 'OPTIONS') return;
const action = methodToAction(request.method);
const { resource, resourceId } = parseRoute(request.url);
const entry: Parameters<typeof deps.createAuditLog>[0] = {
userId: request.userId,
action,
resource,
details: {
method: request.method,
url: request.url,
statusCode: reply.statusCode,
},
};
if (resourceId !== undefined) {
entry.resourceId = resourceId;
}
await deps.createAuditLog(entry);
});
}
function methodToAction(method: string): string {
switch (method) {
case 'POST': return 'CREATE';
case 'PUT':
case 'PATCH': return 'UPDATE';
case 'DELETE': return 'DELETE';
default: return method;
}
}
function parseRoute(url: string): { resource: string; resourceId: string | undefined } {
const parts = url.split('?')[0]?.split('/').filter(Boolean) ?? [];
// Pattern: /api/v1/resource/:id
if (parts.length >= 3 && parts[0] === 'api') {
return { resource: parts[2] ?? 'unknown', resourceId: parts[3] };
}
if (parts.length >= 1) {
return { resource: parts[0] ?? 'unknown', resourceId: parts[1] };
}
return { resource: 'unknown', resourceId: undefined };
}

View File

@@ -0,0 +1,40 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
export interface AuthDeps {
findSession: (token: string) => Promise<{ userId: string; expiresAt: Date } | null>;
}
declare module 'fastify' {
interface FastifyRequest {
userId?: string;
}
}
export function createAuthMiddleware(deps: AuthDeps) {
return async function authMiddleware(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const header = request.headers.authorization;
if (header === undefined || !header.startsWith('Bearer ')) {
reply.code(401).send({ error: 'Missing or invalid Authorization header' });
return;
}
const token = header.slice(7);
if (token.length === 0) {
reply.code(401).send({ error: 'Empty token' });
return;
}
const session = await deps.findSession(token);
if (session === null) {
reply.code(401).send({ error: 'Invalid token' });
return;
}
if (session.expiresAt < new Date()) {
reply.code(401).send({ error: 'Token expired' });
return;
}
request.userId = session.userId;
};
}

View File

@@ -0,0 +1,60 @@
import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify';
import { ZodError } from 'zod';
export interface ErrorResponse {
error: string;
statusCode: number;
details?: unknown;
}
export function errorHandler(
error: FastifyError,
_request: FastifyRequest,
reply: FastifyReply,
): void {
// Zod validation errors
if (error instanceof ZodError) {
reply.code(400).send({
error: 'Validation error',
statusCode: 400,
details: error.issues,
} satisfies ErrorResponse);
return;
}
// Fastify validation errors (from schema validation)
if (error.validation !== undefined) {
reply.code(400).send({
error: 'Validation error',
statusCode: 400,
details: error.validation,
} satisfies ErrorResponse);
return;
}
// Rate limit exceeded
if (error.statusCode === 429) {
reply.code(429).send({
error: 'Rate limit exceeded',
statusCode: 429,
} satisfies ErrorResponse);
return;
}
// Known HTTP errors (includes service errors like NotFoundError, ConflictError)
const statusCode = error.statusCode ?? 500;
if (statusCode < 500) {
reply.code(statusCode).send({
error: error.message,
statusCode,
} satisfies ErrorResponse);
return;
}
// Internal server errors — don't leak details
reply.log.error(error);
reply.code(500).send({
error: 'Internal server error',
statusCode: 500,
} satisfies ErrorResponse);
}

View File

@@ -0,0 +1,7 @@
export { createAuthMiddleware } from './auth.js';
export type { AuthDeps } from './auth.js';
export { registerSecurityPlugins } from './security.js';
export { errorHandler } from './error-handler.js';
export type { ErrorResponse } from './error-handler.js';
export { registerAuditHook } from './audit.js';
export type { AuditDeps } from './audit.js';

View File

@@ -0,0 +1,24 @@
import type { FastifyInstance } from 'fastify';
import cors from '@fastify/cors';
import helmet from '@fastify/helmet';
import rateLimit from '@fastify/rate-limit';
import type { McpdConfig } from '../config/index.js';
export async function registerSecurityPlugins(
app: FastifyInstance,
config: McpdConfig,
): Promise<void> {
await app.register(cors, {
origin: config.corsOrigins,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
});
await app.register(helmet, {
contentSecurityPolicy: false, // API server, no HTML
});
await app.register(rateLimit, {
max: config.rateLimitMax,
timeWindow: config.rateLimitWindowMs,
});
}

View File

@@ -0,0 +1,6 @@
export type { IMcpServerRepository, IMcpProfileRepository, IMcpInstanceRepository } from './interfaces.js';
export { McpServerRepository } from './mcp-server.repository.js';
export { McpProfileRepository } from './mcp-profile.repository.js';
export type { IProjectRepository } from './project.repository.js';
export { ProjectRepository } from './project.repository.js';
export { McpInstanceRepository } from './mcp-instance.repository.js';

View File

@@ -0,0 +1,30 @@
import type { McpServer, McpProfile, McpInstance, InstanceStatus } from '@prisma/client';
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
export interface IMcpServerRepository {
findAll(): Promise<McpServer[]>;
findById(id: string): Promise<McpServer | null>;
findByName(name: string): Promise<McpServer | null>;
create(data: CreateMcpServerInput): Promise<McpServer>;
update(id: string, data: UpdateMcpServerInput): Promise<McpServer>;
delete(id: string): Promise<void>;
}
export interface IMcpInstanceRepository {
findAll(serverId?: string): Promise<McpInstance[]>;
findById(id: string): Promise<McpInstance | null>;
findByContainerId(containerId: string): Promise<McpInstance | null>;
create(data: { serverId: string; containerId?: string; status?: InstanceStatus; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> }): Promise<McpInstance>;
delete(id: string): Promise<void>;
}
export interface IMcpProfileRepository {
findAll(serverId?: string): Promise<McpProfile[]>;
findById(id: string): Promise<McpProfile | null>;
findByServerAndName(serverId: string, name: string): Promise<McpProfile | null>;
create(data: CreateMcpProfileInput): Promise<McpProfile>;
update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile>;
delete(id: string): Promise<void>;
}

View File

@@ -0,0 +1,71 @@
import type { PrismaClient, McpInstance, InstanceStatus, Prisma } from '@prisma/client';
import type { IMcpInstanceRepository } from './interfaces.js';
export class McpInstanceRepository implements IMcpInstanceRepository {
constructor(private prisma: PrismaClient) {}
async findAll(serverId?: string): Promise<McpInstance[]> {
const where: Prisma.McpInstanceWhereInput = {};
if (serverId) {
where.serverId = serverId;
}
return this.prisma.mcpInstance.findMany({
where,
orderBy: { createdAt: 'desc' },
});
}
async findById(id: string): Promise<McpInstance | null> {
return this.prisma.mcpInstance.findUnique({ where: { id } });
}
async findByContainerId(containerId: string): Promise<McpInstance | null> {
return this.prisma.mcpInstance.findFirst({ where: { containerId } });
}
async create(data: {
serverId: string;
containerId?: string;
status?: InstanceStatus;
port?: number;
metadata?: Record<string, unknown>;
}): Promise<McpInstance> {
return this.prisma.mcpInstance.create({
data: {
serverId: data.serverId,
containerId: data.containerId ?? null,
status: data.status ?? 'STOPPED',
port: data.port ?? null,
metadata: (data.metadata ?? {}) as Prisma.InputJsonValue,
},
});
}
async updateStatus(
id: string,
status: InstanceStatus,
fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> },
): Promise<McpInstance> {
const updateData: Prisma.McpInstanceUpdateInput = {
status,
version: { increment: 1 },
};
if (fields?.containerId !== undefined) {
updateData.containerId = fields.containerId;
}
if (fields?.port !== undefined) {
updateData.port = fields.port;
}
if (fields?.metadata !== undefined) {
updateData.metadata = fields.metadata as Prisma.InputJsonValue;
}
return this.prisma.mcpInstance.update({
where: { id },
data: updateData,
});
}
async delete(id: string): Promise<void> {
await this.prisma.mcpInstance.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,46 @@
import type { PrismaClient, McpProfile } from '@prisma/client';
import type { IMcpProfileRepository } from './interfaces.js';
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
export class McpProfileRepository implements IMcpProfileRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(serverId?: string): Promise<McpProfile[]> {
const where = serverId !== undefined ? { serverId } : {};
return this.prisma.mcpProfile.findMany({ where, orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<McpProfile | null> {
return this.prisma.mcpProfile.findUnique({ where: { id } });
}
async findByServerAndName(serverId: string, name: string): Promise<McpProfile | null> {
return this.prisma.mcpProfile.findUnique({
where: { name_serverId: { name, serverId } },
});
}
async create(data: CreateMcpProfileInput): Promise<McpProfile> {
return this.prisma.mcpProfile.create({
data: {
name: data.name,
serverId: data.serverId,
permissions: data.permissions,
envOverrides: data.envOverrides,
},
});
}
async update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile> {
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData['name'] = data.name;
if (data.permissions !== undefined) updateData['permissions'] = data.permissions;
if (data.envOverrides !== undefined) updateData['envOverrides'] = data.envOverrides;
return this.prisma.mcpProfile.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.mcpProfile.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,49 @@
import type { PrismaClient, McpServer } from '@prisma/client';
import type { IMcpServerRepository } from './interfaces.js';
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
export class McpServerRepository implements IMcpServerRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<McpServer[]> {
return this.prisma.mcpServer.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<McpServer | null> {
return this.prisma.mcpServer.findUnique({ where: { id } });
}
async findByName(name: string): Promise<McpServer | null> {
return this.prisma.mcpServer.findUnique({ where: { name } });
}
async create(data: CreateMcpServerInput): Promise<McpServer> {
return this.prisma.mcpServer.create({
data: {
name: data.name,
description: data.description,
packageName: data.packageName ?? null,
dockerImage: data.dockerImage ?? null,
transport: data.transport,
repositoryUrl: data.repositoryUrl ?? null,
envTemplate: data.envTemplate,
},
});
}
async update(id: string, data: UpdateMcpServerInput): Promise<McpServer> {
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
if (data.packageName !== undefined) updateData['packageName'] = data.packageName;
if (data.dockerImage !== undefined) updateData['dockerImage'] = data.dockerImage;
if (data.transport !== undefined) updateData['transport'] = data.transport;
if (data.repositoryUrl !== undefined) updateData['repositoryUrl'] = data.repositoryUrl;
if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate;
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.mcpServer.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,69 @@
import type { PrismaClient, Project } from '@prisma/client';
import type { CreateProjectInput, UpdateProjectInput } from '../validation/project.schema.js';
export interface IProjectRepository {
findAll(ownerId?: string): Promise<Project[]>;
findById(id: string): Promise<Project | null>;
findByName(name: string): Promise<Project | null>;
create(data: CreateProjectInput & { ownerId: string }): Promise<Project>;
update(id: string, data: UpdateProjectInput): Promise<Project>;
delete(id: string): Promise<void>;
setProfiles(projectId: string, profileIds: string[]): Promise<void>;
getProfileIds(projectId: string): Promise<string[]>;
}
export class ProjectRepository implements IProjectRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(ownerId?: string): Promise<Project[]> {
const where = ownerId !== undefined ? { ownerId } : {};
return this.prisma.project.findMany({ where, orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<Project | null> {
return this.prisma.project.findUnique({ where: { id } });
}
async findByName(name: string): Promise<Project | null> {
return this.prisma.project.findUnique({ where: { name } });
}
async create(data: CreateProjectInput & { ownerId: string }): Promise<Project> {
return this.prisma.project.create({
data: {
name: data.name,
description: data.description,
ownerId: data.ownerId,
},
});
}
async update(id: string, data: UpdateProjectInput): Promise<Project> {
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
return this.prisma.project.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.project.delete({ where: { id } });
}
async setProfiles(projectId: string, profileIds: string[]): Promise<void> {
await this.prisma.$transaction([
this.prisma.projectMcpProfile.deleteMany({ where: { projectId } }),
...profileIds.map((profileId) =>
this.prisma.projectMcpProfile.create({
data: { projectId, profileId },
}),
),
]);
}
async getProfileIds(projectId: string): Promise<string[]> {
const links = await this.prisma.projectMcpProfile.findMany({
where: { projectId },
select: { profileId: true },
});
return links.map((l) => l.profileId);
}
}

View File

@@ -0,0 +1,30 @@
import type { FastifyInstance } from 'fastify';
import { APP_VERSION } from '@mcpctl/shared';
export interface HealthDeps {
checkDb: () => Promise<boolean>;
}
export function registerHealthRoutes(app: FastifyInstance, deps: HealthDeps): void {
app.get('/health', async (_request, reply) => {
const dbOk = await deps.checkDb().catch(() => false);
const status = dbOk ? 'healthy' : 'degraded';
const statusCode = dbOk ? 200 : 503;
reply.code(statusCode).send({
status,
version: APP_VERSION,
uptime: process.uptime(),
timestamp: new Date().toISOString(),
checks: {
database: dbOk ? 'ok' : 'error',
},
});
});
// Simple liveness probe
app.get('/healthz', async (_request, reply) => {
reply.code(200).send({ status: 'ok' });
});
}

View File

@@ -0,0 +1,6 @@
export { registerHealthRoutes } from './health.js';
export type { HealthDeps } from './health.js';
export { registerMcpServerRoutes } from './mcp-servers.js';
export { registerMcpProfileRoutes } from './mcp-profiles.js';
export { registerProjectRoutes } from './projects.js';
export { registerInstanceRoutes } from './instances.js';

View File

@@ -0,0 +1,49 @@
import type { FastifyInstance } from 'fastify';
import type { InstanceService } from '../services/instance.service.js';
export function registerInstanceRoutes(app: FastifyInstance, service: InstanceService): void {
app.get<{ Querystring: { serverId?: string } }>('/api/v1/instances', async (request) => {
return service.list(request.query.serverId);
});
app.get<{ Params: { id: string } }>('/api/v1/instances/:id', async (request) => {
return service.getById(request.params.id);
});
app.post<{ Body: { serverId: string; env?: Record<string, string>; hostPort?: number } }>(
'/api/v1/instances',
async (request, reply) => {
const { serverId } = request.body;
const opts: { env?: Record<string, string>; hostPort?: number } = {};
if (request.body.env) {
opts.env = request.body.env;
}
if (request.body.hostPort !== undefined) {
opts.hostPort = request.body.hostPort;
}
const instance = await service.start(serverId, opts);
reply.code(201);
return instance;
},
);
app.post<{ Params: { id: string } }>('/api/v1/instances/:id/stop', async (request) => {
return service.stop(request.params.id);
});
app.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => {
await service.remove(request.params.id);
reply.code(204);
});
app.get<{ Params: { id: string }; Querystring: { tail?: string } }>(
'/api/v1/instances/:id/logs',
async (request) => {
const opts: { tail?: number } = {};
if (request.query.tail) {
opts.tail = parseInt(request.query.tail, 10);
}
return service.getLogs(request.params.id, opts);
},
);
}

View File

@@ -0,0 +1,27 @@
import type { FastifyInstance } from 'fastify';
import type { McpProfileService } from '../services/mcp-profile.service.js';
export function registerMcpProfileRoutes(app: FastifyInstance, service: McpProfileService): void {
app.get<{ Querystring: { serverId?: string } }>('/api/v1/profiles', async (request) => {
return service.list(request.query.serverId);
});
app.get<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request) => {
return service.getById(request.params.id);
});
app.post('/api/v1/profiles', async (request, reply) => {
const profile = await service.create(request.body);
reply.code(201);
return profile;
});
app.put<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/profiles/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
}

View File

@@ -0,0 +1,27 @@
import type { FastifyInstance } from 'fastify';
import type { McpServerService } from '../services/mcp-server.service.js';
export function registerMcpServerRoutes(app: FastifyInstance, service: McpServerService): void {
app.get('/api/v1/servers', async () => {
return service.list();
});
app.get<{ Params: { id: string } }>('/api/v1/servers/:id', async (request) => {
return service.getById(request.params.id);
});
app.post('/api/v1/servers', async (request, reply) => {
const server = await service.create(request.body);
reply.code(201);
return server;
});
app.put<{ Params: { id: string } }>('/api/v1/servers/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/servers/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
}

View File

@@ -0,0 +1,43 @@
import type { FastifyInstance } from 'fastify';
import type { ProjectService } from '../services/project.service.js';
export function registerProjectRoutes(app: FastifyInstance, service: ProjectService): void {
app.get('/api/v1/projects', async (request) => {
// If authenticated, filter by owner; otherwise list all
return service.list(request.userId);
});
app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
return service.getById(request.params.id);
});
app.post('/api/v1/projects', async (request, reply) => {
const ownerId = request.userId ?? 'anonymous';
const project = await service.create(request.body, ownerId);
reply.code(201);
return project;
});
app.put<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/projects/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
// Profile associations
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => {
return service.getProfiles(request.params.id);
});
app.put<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => {
return service.setProfiles(request.params.id, request.body);
});
// MCP config generation
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/mcp-config', async (request) => {
return service.getMcpConfig(request.params.id);
});
}

34
src/mcpd/src/server.ts Normal file
View File

@@ -0,0 +1,34 @@
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import type { McpdConfig } from './config/index.js';
import { registerSecurityPlugins } from './middleware/security.js';
import { errorHandler } from './middleware/error-handler.js';
import { registerHealthRoutes } from './routes/health.js';
import type { HealthDeps } from './routes/health.js';
import type { AuthDeps } from './middleware/auth.js';
import type { AuditDeps } from './middleware/audit.js';
export interface ServerDeps {
health: HealthDeps;
auth?: AuthDeps;
audit?: AuditDeps;
}
export async function createServer(config: McpdConfig, deps: ServerDeps): Promise<FastifyInstance> {
const app = Fastify({
logger: {
level: config.logLevel,
},
});
// Error handler
app.setErrorHandler(errorHandler);
// Security plugins
await registerSecurityPlugins(app, config);
// Health routes (no auth required)
registerHealthRoutes(app, deps.health);
return app;
}

View File

@@ -0,0 +1,156 @@
import Docker from 'dockerode';
import type {
McpOrchestrator,
ContainerSpec,
ContainerInfo,
ContainerLogs,
} from '../orchestrator.js';
import { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from '../orchestrator.js';
const MCPCTL_LABEL = 'mcpctl.managed';
function mapState(state: string | undefined): ContainerInfo['state'] {
switch (state?.toLowerCase()) {
case 'running':
return 'running';
case 'created':
case 'restarting':
return 'starting';
case 'exited':
case 'dead':
case 'paused':
return 'stopped';
default:
return 'unknown';
}
}
export class DockerContainerManager implements McpOrchestrator {
private docker: Docker;
constructor(opts?: Docker.DockerOptions) {
this.docker = new Docker(opts);
}
async ping(): Promise<boolean> {
try {
await this.docker.ping();
return true;
} catch {
return false;
}
}
async pullImage(image: string): Promise<void> {
const stream = await this.docker.pull(image);
// Wait for pull to complete
await new Promise<void>((resolve, reject) => {
this.docker.modem.followProgress(stream, (err: Error | null) => {
if (err) reject(err);
else resolve();
});
});
}
async createContainer(spec: ContainerSpec): Promise<ContainerInfo> {
const memoryLimit = spec.memoryLimit ?? DEFAULT_MEMORY_LIMIT;
const nanoCpus = spec.nanoCpus ?? DEFAULT_NANO_CPUS;
const portBindings: Record<string, Array<{ HostPort: string }>> = {};
const exposedPorts: Record<string, Record<string, never>> = {};
if (spec.containerPort) {
const key = `${spec.containerPort}/tcp`;
exposedPorts[key] = {};
portBindings[key] = [{ HostPort: spec.hostPort ? String(spec.hostPort) : '0' }];
}
const labels: Record<string, string> = {
[MCPCTL_LABEL]: 'true',
...spec.labels,
};
const envArr = spec.env
? Object.entries(spec.env).map(([k, v]) => `${k}=${v}`)
: undefined;
const container = await this.docker.createContainer({
Image: spec.image,
name: spec.name,
Env: envArr,
ExposedPorts: exposedPorts,
Labels: labels,
HostConfig: {
PortBindings: portBindings,
Memory: memoryLimit,
NanoCpus: nanoCpus,
NetworkMode: spec.network ?? 'bridge',
},
});
await container.start();
return this.inspectContainer(container.id);
}
async stopContainer(containerId: string, timeoutSeconds = 10): Promise<void> {
const container = this.docker.getContainer(containerId);
await container.stop({ t: timeoutSeconds });
}
async removeContainer(containerId: string, force = false): Promise<void> {
const container = this.docker.getContainer(containerId);
await container.remove({ force, v: true });
}
async inspectContainer(containerId: string): Promise<ContainerInfo> {
const container = this.docker.getContainer(containerId);
const info = await container.inspect();
let port: number | undefined;
const ports = info.NetworkSettings?.Ports;
if (ports) {
for (const bindings of Object.values(ports)) {
const arr = bindings as Array<{ HostIp: string; HostPort: string }> | undefined;
if (arr && arr.length > 0 && arr[0]) {
port = parseInt(arr[0].HostPort, 10);
break;
}
}
}
const result: ContainerInfo = {
containerId: info.Id,
name: info.Name.replace(/^\//, ''),
state: mapState(info.State?.Status),
createdAt: new Date(info.Created),
};
if (port !== undefined) {
result.port = port;
}
return result;
}
async getContainerLogs(
containerId: string,
opts?: { tail?: number; since?: number },
): Promise<ContainerLogs> {
const container = this.docker.getContainer(containerId);
const logOpts: Record<string, unknown> = {
stdout: true,
stderr: true,
follow: false,
tail: opts?.tail ?? 100,
};
if (opts?.since) {
logOpts['since'] = opts.since;
}
const buffer = await container.logs(logOpts) as unknown as Buffer;
const raw = buffer.toString('utf-8');
// Docker multiplexes stdout/stderr with 8-byte headers.
// For simplicity we return everything as stdout.
return { stdout: raw, stderr: '' };
}
}

View File

@@ -0,0 +1,9 @@
export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js';
export { McpProfileService } from './mcp-profile.service.js';
export { ProjectService } from './project.service.js';
export { InstanceService } from './instance.service.js';
export { generateMcpConfig } from './mcp-config-generator.js';
export type { McpConfig, McpConfigServer, ProfileWithServer } from './mcp-config-generator.js';
export type { McpOrchestrator, ContainerSpec, ContainerInfo, ContainerLogs } from './orchestrator.js';
export { DEFAULT_MEMORY_LIMIT, DEFAULT_NANO_CPUS } from './orchestrator.js';
export { DockerContainerManager } from './docker/container-manager.js';

View File

@@ -0,0 +1,112 @@
import type { McpInstance } from '@prisma/client';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import type { McpOrchestrator, ContainerSpec } from './orchestrator.js';
import { NotFoundError } from './mcp-server.service.js';
export class InstanceService {
constructor(
private instanceRepo: IMcpInstanceRepository,
private serverRepo: IMcpServerRepository,
private orchestrator: McpOrchestrator,
) {}
async list(serverId?: string): Promise<McpInstance[]> {
return this.instanceRepo.findAll(serverId);
}
async getById(id: string): Promise<McpInstance> {
const instance = await this.instanceRepo.findById(id);
if (!instance) throw new NotFoundError(`Instance '${id}' not found`);
return instance;
}
async start(serverId: string, opts?: { env?: Record<string, string>; hostPort?: number }): Promise<McpInstance> {
const server = await this.serverRepo.findById(serverId);
if (!server) throw new NotFoundError(`McpServer '${serverId}' not found`);
const image = server.dockerImage ?? server.packageName ?? server.name;
// Create DB record first in STARTING state
let instance = await this.instanceRepo.create({
serverId,
status: 'STARTING',
});
try {
const spec: ContainerSpec = {
image,
name: `mcpctl-${server.name}-${instance.id}`,
hostPort: opts?.hostPort ?? null,
labels: {
'mcpctl.server-id': serverId,
'mcpctl.instance-id': instance.id,
},
};
if (server.transport === 'SSE' || server.transport === 'STREAMABLE_HTTP') {
spec.containerPort = 3000;
}
if (opts?.env) {
spec.env = opts.env;
}
const containerInfo = await this.orchestrator.createContainer(spec);
const updateFields: { containerId: string; port?: number } = {
containerId: containerInfo.containerId,
};
if (containerInfo.port !== undefined) {
updateFields.port = containerInfo.port;
}
instance = await this.instanceRepo.updateStatus(instance.id, 'RUNNING', updateFields);
} catch (err) {
// Mark as ERROR if container creation fails
instance = await this.instanceRepo.updateStatus(instance.id, 'ERROR', {
metadata: { error: err instanceof Error ? err.message : String(err) },
});
}
return instance;
}
async stop(id: string): Promise<McpInstance> {
const instance = await this.getById(id);
if (!instance.containerId) {
return this.instanceRepo.updateStatus(id, 'STOPPED');
}
await this.instanceRepo.updateStatus(id, 'STOPPING');
try {
await this.orchestrator.stopContainer(instance.containerId);
return await this.instanceRepo.updateStatus(id, 'STOPPED');
} catch (err) {
return await this.instanceRepo.updateStatus(id, 'ERROR', {
metadata: { error: err instanceof Error ? err.message : String(err) },
});
}
}
async remove(id: string): Promise<void> {
const instance = await this.getById(id);
if (instance.containerId) {
try {
await this.orchestrator.removeContainer(instance.containerId, true);
} catch {
// Container may already be gone, proceed with DB cleanup
}
}
await this.instanceRepo.delete(id);
}
async getLogs(id: string, opts?: { tail?: number }): Promise<{ stdout: string; stderr: string }> {
const instance = await this.getById(id);
if (!instance.containerId) {
return { stdout: '', stderr: '' };
}
return this.orchestrator.getContainerLogs(instance.containerId, opts);
}
}

View File

@@ -0,0 +1,59 @@
import type { McpServer, McpProfile } from '@prisma/client';
export interface McpConfigServer {
command: string;
args: string[];
env?: Record<string, string>;
}
export interface McpConfig {
mcpServers: Record<string, McpConfigServer>;
}
export interface ProfileWithServer {
profile: McpProfile;
server: McpServer;
}
/**
* Generate .mcp.json config from a project's profiles.
* Secret env vars are excluded from the output — they must be injected at runtime.
*/
export function generateMcpConfig(profiles: ProfileWithServer[]): McpConfig {
const mcpServers: Record<string, McpConfigServer> = {};
for (const { profile, server } of profiles) {
const key = `${server.name}--${profile.name}`;
const envTemplate = server.envTemplate as Array<{
name: string;
isSecret: boolean;
defaultValue?: string;
}>;
const envOverrides = profile.envOverrides as Record<string, string>;
// Build env: only include non-secret env vars
const env: Record<string, string> = {};
for (const entry of envTemplate) {
if (entry.isSecret) continue; // Never include secrets in config output
const override = envOverrides[entry.name];
if (override !== undefined) {
env[entry.name] = override;
} else if (entry.defaultValue !== undefined) {
env[entry.name] = entry.defaultValue;
}
}
const config: McpConfigServer = {
command: 'npx',
args: ['-y', server.packageName ?? server.name],
};
if (Object.keys(env).length > 0) {
config.env = env;
}
mcpServers[key] = config;
}
return { mcpServers };
}

View File

@@ -0,0 +1,62 @@
import type { McpProfile } from '@prisma/client';
import type { IMcpProfileRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import { CreateMcpProfileSchema, UpdateMcpProfileSchema } from '../validation/mcp-profile.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
export class McpProfileService {
constructor(
private readonly profileRepo: IMcpProfileRepository,
private readonly serverRepo: IMcpServerRepository,
) {}
async list(serverId?: string): Promise<McpProfile[]> {
return this.profileRepo.findAll(serverId);
}
async getById(id: string): Promise<McpProfile> {
const profile = await this.profileRepo.findById(id);
if (profile === null) {
throw new NotFoundError(`Profile not found: ${id}`);
}
return profile;
}
async create(input: unknown): Promise<McpProfile> {
const data = CreateMcpProfileSchema.parse(input);
// Verify server exists
const server = await this.serverRepo.findById(data.serverId);
if (server === null) {
throw new NotFoundError(`Server not found: ${data.serverId}`);
}
// Check unique name per server
const existing = await this.profileRepo.findByServerAndName(data.serverId, data.name);
if (existing !== null) {
throw new ConflictError(`Profile "${data.name}" already exists for server "${server.name}"`);
}
return this.profileRepo.create(data);
}
async update(id: string, input: unknown): Promise<McpProfile> {
const data = UpdateMcpProfileSchema.parse(input);
const profile = await this.getById(id);
// If renaming, check uniqueness
if (data.name !== undefined && data.name !== profile.name) {
const existing = await this.profileRepo.findByServerAndName(profile.serverId, data.name);
if (existing !== null) {
throw new ConflictError(`Profile "${data.name}" already exists for this server`);
}
}
return this.profileRepo.update(id, data);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.profileRepo.delete(id);
}
}

View File

@@ -0,0 +1,69 @@
import type { McpServer } from '@prisma/client';
import type { IMcpServerRepository } from '../repositories/interfaces.js';
import { CreateMcpServerSchema, UpdateMcpServerSchema } from '../validation/mcp-server.schema.js';
export class McpServerService {
constructor(private readonly repo: IMcpServerRepository) {}
async list(): Promise<McpServer[]> {
return this.repo.findAll();
}
async getById(id: string): Promise<McpServer> {
const server = await this.repo.findById(id);
if (server === null) {
throw new NotFoundError(`Server not found: ${id}`);
}
return server;
}
async getByName(name: string): Promise<McpServer> {
const server = await this.repo.findByName(name);
if (server === null) {
throw new NotFoundError(`Server not found: ${name}`);
}
return server;
}
async create(input: unknown): Promise<McpServer> {
const data = CreateMcpServerSchema.parse(input);
const existing = await this.repo.findByName(data.name);
if (existing !== null) {
throw new ConflictError(`Server already exists: ${data.name}`);
}
return this.repo.create(data);
}
async update(id: string, input: unknown): Promise<McpServer> {
const data = UpdateMcpServerSchema.parse(input);
// Verify exists
await this.getById(id);
return this.repo.update(id, data);
}
async delete(id: string): Promise<void> {
// Verify exists
await this.getById(id);
await this.repo.delete(id);
}
}
export class NotFoundError extends Error {
readonly statusCode = 404;
constructor(message: string) {
super(message);
this.name = 'NotFoundError';
}
}
export class ConflictError extends Error {
readonly statusCode = 409;
constructor(message: string) {
super(message);
this.name = 'ConflictError';
}
}

View File

@@ -0,0 +1,64 @@
/**
* Container orchestrator abstraction. Implementations can back onto Docker, Podman, or Kubernetes.
*/
export interface ContainerSpec {
/** Docker/OCI image reference */
image: string;
/** Human-readable name (used as container name prefix) */
name: string;
/** Environment variables */
env?: Record<string, string>;
/** Host port to bind (null = auto-assign) */
hostPort?: number | null;
/** Container port to expose */
containerPort?: number;
/** Memory limit in bytes (default: 512 MB) */
memoryLimit?: number;
/** CPU period quota (nanoCPUs, default: 0.5 CPU) */
nanoCpus?: number;
/** Labels for identification / filtering */
labels?: Record<string, string>;
/** Network name to attach to */
network?: string;
}
export interface ContainerInfo {
containerId: string;
name: string;
state: 'running' | 'stopped' | 'starting' | 'error' | 'unknown';
port?: number;
createdAt: Date;
}
export interface ContainerLogs {
stdout: string;
stderr: string;
}
export interface McpOrchestrator {
/** Pull an image if not present locally */
pullImage(image: string): Promise<void>;
/** Create and start a container */
createContainer(spec: ContainerSpec): Promise<ContainerInfo>;
/** Stop a running container */
stopContainer(containerId: string, timeoutSeconds?: number): Promise<void>;
/** Remove a stopped container */
removeContainer(containerId: string, force?: boolean): Promise<void>;
/** Get container info */
inspectContainer(containerId: string): Promise<ContainerInfo>;
/** Get container logs */
getContainerLogs(containerId: string, opts?: { tail?: number; since?: number }): Promise<ContainerLogs>;
/** Check if the orchestrator runtime is available */
ping(): Promise<boolean>;
}
/** Default resource limits */
export const DEFAULT_MEMORY_LIMIT = 512 * 1024 * 1024; // 512 MB
export const DEFAULT_NANO_CPUS = 500_000_000; // 0.5 CPU

View File

@@ -0,0 +1,86 @@
import type { Project } from '@prisma/client';
import type { IProjectRepository } from '../repositories/project.repository.js';
import type { IMcpProfileRepository, IMcpServerRepository } from '../repositories/interfaces.js';
import { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from '../validation/project.schema.js';
import { NotFoundError, ConflictError } from './mcp-server.service.js';
import { generateMcpConfig } from './mcp-config-generator.js';
import type { McpConfig, ProfileWithServer } from './mcp-config-generator.js';
export class ProjectService {
constructor(
private readonly projectRepo: IProjectRepository,
private readonly profileRepo: IMcpProfileRepository,
private readonly serverRepo: IMcpServerRepository,
) {}
async list(ownerId?: string): Promise<Project[]> {
return this.projectRepo.findAll(ownerId);
}
async getById(id: string): Promise<Project> {
const project = await this.projectRepo.findById(id);
if (project === null) {
throw new NotFoundError(`Project not found: ${id}`);
}
return project;
}
async create(input: unknown, ownerId: string): Promise<Project> {
const data = CreateProjectSchema.parse(input);
const existing = await this.projectRepo.findByName(data.name);
if (existing !== null) {
throw new ConflictError(`Project already exists: ${data.name}`);
}
return this.projectRepo.create({ ...data, ownerId });
}
async update(id: string, input: unknown): Promise<Project> {
const data = UpdateProjectSchema.parse(input);
await this.getById(id);
return this.projectRepo.update(id, data);
}
async delete(id: string): Promise<void> {
await this.getById(id);
await this.projectRepo.delete(id);
}
async setProfiles(projectId: string, input: unknown): Promise<string[]> {
const { profileIds } = UpdateProjectProfilesSchema.parse(input);
await this.getById(projectId);
// Verify all profiles exist
for (const profileId of profileIds) {
const profile = await this.profileRepo.findById(profileId);
if (profile === null) {
throw new NotFoundError(`Profile not found: ${profileId}`);
}
}
await this.projectRepo.setProfiles(projectId, profileIds);
return profileIds;
}
async getProfiles(projectId: string): Promise<string[]> {
await this.getById(projectId);
return this.projectRepo.getProfileIds(projectId);
}
async getMcpConfig(projectId: string): Promise<McpConfig> {
await this.getById(projectId);
const profileIds = await this.projectRepo.getProfileIds(projectId);
const profilesWithServers: ProfileWithServer[] = [];
for (const profileId of profileIds) {
const profile = await this.profileRepo.findById(profileId);
if (profile === null) continue;
const server = await this.serverRepo.findById(profile.serverId);
if (server === null) continue;
profilesWithServers.push({ profile, server });
}
return generateMcpConfig(profilesWithServers);
}
}

View File

@@ -0,0 +1,2 @@
export { setupGracefulShutdown } from './shutdown.js';
export type { ShutdownDeps } from './shutdown.js';

View File

@@ -0,0 +1,33 @@
import type { FastifyInstance } from 'fastify';
export interface ShutdownDeps {
disconnectDb: () => Promise<void>;
}
export function setupGracefulShutdown(
app: FastifyInstance,
deps: ShutdownDeps,
processRef: NodeJS.Process = process,
): void {
let shuttingDown = false;
const shutdown = async (signal: string): Promise<void> => {
if (shuttingDown) return;
shuttingDown = true;
app.log.info(`Received ${signal}, shutting down gracefully...`);
try {
await app.close();
await deps.disconnectDb();
app.log.info('Server shut down successfully');
} catch (err) {
app.log.error(err, 'Error during shutdown');
}
processRef.exit(0);
};
processRef.on('SIGTERM', () => { void shutdown('SIGTERM'); });
processRef.on('SIGINT', () => { void shutdown('SIGINT'); });
}

View File

@@ -0,0 +1,6 @@
export { CreateMcpServerSchema, UpdateMcpServerSchema } from './mcp-server.schema.js';
export type { CreateMcpServerInput, UpdateMcpServerInput } from './mcp-server.schema.js';
export { CreateMcpProfileSchema, UpdateMcpProfileSchema } from './mcp-profile.schema.js';
export type { CreateMcpProfileInput, UpdateMcpProfileInput } from './mcp-profile.schema.js';
export { CreateProjectSchema, UpdateProjectSchema, UpdateProjectProfilesSchema } from './project.schema.js';
export type { CreateProjectInput, UpdateProjectInput, UpdateProjectProfilesInput } from './project.schema.js';

View File

@@ -0,0 +1,17 @@
import { z } from 'zod';
export const CreateMcpProfileSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
serverId: z.string().min(1),
permissions: z.array(z.string()).default([]),
envOverrides: z.record(z.string()).default({}),
});
export const UpdateMcpProfileSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/).optional(),
permissions: z.array(z.string()).optional(),
envOverrides: z.record(z.string()).optional(),
});
export type CreateMcpProfileInput = z.infer<typeof CreateMcpProfileSchema>;
export type UpdateMcpProfileInput = z.infer<typeof UpdateMcpProfileSchema>;

View File

@@ -0,0 +1,30 @@
import { z } from 'zod';
const EnvTemplateEntrySchema = z.object({
name: z.string().min(1).max(100),
description: z.string().max(500).default(''),
isSecret: z.boolean().default(false),
setupUrl: z.string().url().optional(),
});
export const CreateMcpServerSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
description: z.string().max(1000).default(''),
packageName: z.string().max(200).optional(),
dockerImage: z.string().max(200).optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().url().optional(),
envTemplate: z.array(EnvTemplateEntrySchema).default([]),
});
export const UpdateMcpServerSchema = z.object({
description: z.string().max(1000).optional(),
packageName: z.string().max(200).nullable().optional(),
dockerImage: z.string().max(200).nullable().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).optional(),
repositoryUrl: z.string().url().nullable().optional(),
envTemplate: z.array(EnvTemplateEntrySchema).optional(),
});
export type CreateMcpServerInput = z.infer<typeof CreateMcpServerSchema>;
export type UpdateMcpServerInput = z.infer<typeof UpdateMcpServerSchema>;

View File

@@ -0,0 +1,18 @@
import { z } from 'zod';
export const CreateProjectSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'),
description: z.string().max(1000).default(''),
});
export const UpdateProjectSchema = z.object({
description: z.string().max(1000).optional(),
});
export const UpdateProjectProfilesSchema = z.object({
profileIds: z.array(z.string().min(1)).min(0),
});
export type CreateProjectInput = z.infer<typeof CreateProjectSchema>;
export type UpdateProjectInput = z.infer<typeof UpdateProjectSchema>;
export type UpdateProjectProfilesInput = z.infer<typeof UpdateProjectProfilesSchema>;

View File

@@ -0,0 +1,102 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerAuditHook } from '../src/middleware/audit.js';
let app: FastifyInstance;
afterEach(async () => {
if (app) await app.close();
});
describe('audit middleware', () => {
it('logs mutating requests from authenticated users', async () => {
const createAuditLog = vi.fn(async () => {});
app = Fastify({ logger: false });
// Simulate authenticated request
app.addHook('preHandler', async (request) => {
request.userId = 'user-1';
});
registerAuditHook(app, { createAuditLog });
app.post('/api/v1/servers', async () => ({ ok: true }));
await app.ready();
await app.inject({ method: 'POST', url: '/api/v1/servers', payload: {} });
expect(createAuditLog).toHaveBeenCalledOnce();
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({
userId: 'user-1',
action: 'CREATE',
resource: 'servers',
}));
});
it('does not log GET requests', async () => {
const createAuditLog = vi.fn(async () => {});
app = Fastify({ logger: false });
app.addHook('preHandler', async (request) => {
request.userId = 'user-1';
});
registerAuditHook(app, { createAuditLog });
app.get('/api/v1/servers', async () => []);
await app.ready();
await app.inject({ method: 'GET', url: '/api/v1/servers' });
expect(createAuditLog).not.toHaveBeenCalled();
});
it('does not log unauthenticated requests', async () => {
const createAuditLog = vi.fn(async () => {});
app = Fastify({ logger: false });
registerAuditHook(app, { createAuditLog });
app.post('/api/v1/servers', async () => ({ ok: true }));
await app.ready();
await app.inject({ method: 'POST', url: '/api/v1/servers', payload: {} });
expect(createAuditLog).not.toHaveBeenCalled();
});
it('maps DELETE method to DELETE action', async () => {
const createAuditLog = vi.fn(async () => {});
app = Fastify({ logger: false });
app.addHook('preHandler', async (request) => {
request.userId = 'user-1';
});
registerAuditHook(app, { createAuditLog });
app.delete('/api/v1/servers/:id', async () => ({ ok: true }));
await app.ready();
await app.inject({ method: 'DELETE', url: '/api/v1/servers/srv-123' });
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({
action: 'DELETE',
resource: 'servers',
resourceId: 'srv-123',
}));
});
it('maps PUT/PATCH to UPDATE action', async () => {
const createAuditLog = vi.fn(async () => {});
app = Fastify({ logger: false });
app.addHook('preHandler', async (request) => {
request.userId = 'user-1';
});
registerAuditHook(app, { createAuditLog });
app.put('/api/v1/servers/:id', async () => ({ ok: true }));
await app.ready();
await app.inject({ method: 'PUT', url: '/api/v1/servers/srv-1', payload: {} });
expect(createAuditLog).toHaveBeenCalledWith(expect.objectContaining({
action: 'UPDATE',
}));
});
});

101
src/mcpd/tests/auth.test.ts Normal file
View File

@@ -0,0 +1,101 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { createAuthMiddleware } from '../src/middleware/auth.js';
let app: FastifyInstance;
afterEach(async () => {
if (app) await app.close();
});
function setupApp(findSession: (token: string) => Promise<{ userId: string; expiresAt: Date } | null>) {
app = Fastify({ logger: false });
const authMiddleware = createAuthMiddleware({ findSession });
app.addHook('preHandler', authMiddleware);
app.get('/protected', async (request) => {
return { userId: request.userId };
});
return app.ready();
}
describe('auth middleware', () => {
it('returns 401 when no Authorization header', async () => {
await setupApp(async () => null);
const res = await app.inject({ method: 'GET', url: '/protected' });
expect(res.statusCode).toBe(401);
expect(res.json<{ error: string }>().error).toContain('Authorization');
});
it('returns 401 when header is not Bearer', async () => {
await setupApp(async () => null);
const res = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: 'Basic abc123' },
});
expect(res.statusCode).toBe(401);
});
it('returns 401 when token is empty', async () => {
await setupApp(async () => null);
const res = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: 'Bearer ' },
});
expect(res.statusCode).toBe(401);
expect(res.json<{ error: string }>().error).toContain('Empty');
});
it('returns 401 when token not found', async () => {
await setupApp(async () => null);
const res = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: 'Bearer invalid-token' },
});
expect(res.statusCode).toBe(401);
expect(res.json<{ error: string }>().error).toContain('Invalid');
});
it('returns 401 when token is expired', async () => {
const pastDate = new Date(Date.now() - 86400_000);
await setupApp(async () => ({ userId: 'user-1', expiresAt: pastDate }));
const res = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: 'Bearer expired-token' },
});
expect(res.statusCode).toBe(401);
expect(res.json<{ error: string }>().error).toContain('expired');
});
it('passes valid token and sets userId', async () => {
const futureDate = new Date(Date.now() + 86400_000);
await setupApp(async () => ({ userId: 'user-42', expiresAt: futureDate }));
const res = await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: 'Bearer valid-token' },
});
expect(res.statusCode).toBe(200);
expect(res.json<{ userId: string }>().userId).toBe('user-42');
});
it('calls findSession with the token', async () => {
const findSession = vi.fn(async () => ({
userId: 'user-1',
expiresAt: new Date(Date.now() + 86400_000),
}));
await setupApp(findSession);
await app.inject({
method: 'GET',
url: '/protected',
headers: { authorization: 'Bearer my-token' },
});
expect(findSession).toHaveBeenCalledWith('my-token');
});
});

View File

@@ -0,0 +1,81 @@
import { describe, it, expect } from 'vitest';
import { McpdConfigSchema, loadConfigFromEnv } from '../src/config/index.js';
describe('McpdConfigSchema', () => {
it('requires databaseUrl', () => {
expect(() => McpdConfigSchema.parse({})).toThrow();
});
it('provides defaults with minimal input', () => {
const config = McpdConfigSchema.parse({ databaseUrl: 'postgresql://localhost/test' });
expect(config.port).toBe(3000);
expect(config.host).toBe('0.0.0.0');
expect(config.logLevel).toBe('info');
expect(config.corsOrigins).toEqual(['*']);
expect(config.rateLimitMax).toBe(100);
expect(config.rateLimitWindowMs).toBe(60_000);
});
it('validates full config', () => {
const config = McpdConfigSchema.parse({
port: 4000,
host: '127.0.0.1',
databaseUrl: 'postgresql://localhost/test',
logLevel: 'debug',
corsOrigins: ['http://localhost:3000'],
rateLimitMax: 50,
rateLimitWindowMs: 30_000,
});
expect(config.port).toBe(4000);
expect(config.logLevel).toBe('debug');
});
it('rejects invalid log level', () => {
expect(() => McpdConfigSchema.parse({
databaseUrl: 'postgresql://localhost/test',
logLevel: 'verbose',
})).toThrow();
});
it('rejects zero port', () => {
expect(() => McpdConfigSchema.parse({
databaseUrl: 'postgresql://localhost/test',
port: 0,
})).toThrow();
});
});
describe('loadConfigFromEnv', () => {
it('loads config from environment variables', () => {
const config = loadConfigFromEnv({
DATABASE_URL: 'postgresql://localhost/test',
MCPD_PORT: '4000',
MCPD_HOST: '127.0.0.1',
MCPD_LOG_LEVEL: 'debug',
});
expect(config.port).toBe(4000);
expect(config.host).toBe('127.0.0.1');
expect(config.databaseUrl).toBe('postgresql://localhost/test');
expect(config.logLevel).toBe('debug');
});
it('uses defaults for missing env vars', () => {
const config = loadConfigFromEnv({
DATABASE_URL: 'postgresql://localhost/test',
});
expect(config.port).toBe(3000);
expect(config.host).toBe('0.0.0.0');
});
it('parses CORS origins from comma-separated string', () => {
const config = loadConfigFromEnv({
DATABASE_URL: 'postgresql://localhost/test',
MCPD_CORS_ORIGINS: 'http://a.com, http://b.com',
});
expect(config.corsOrigins).toEqual(['http://a.com', 'http://b.com']);
});
it('throws when DATABASE_URL is missing', () => {
expect(() => loadConfigFromEnv({})).toThrow();
});
});

View File

@@ -0,0 +1,125 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DockerContainerManager } from '../src/services/docker/container-manager.js';
import type { ContainerSpec } from '../src/services/orchestrator.js';
// Mock dockerode
vi.mock('dockerode', () => {
const mockContainer = {
id: 'ctr-abc123',
start: vi.fn(async () => {}),
stop: vi.fn(async () => {}),
remove: vi.fn(async () => {}),
inspect: vi.fn(async () => ({
Id: 'ctr-abc123',
Name: '/mcpctl-test',
State: { Status: 'running' },
Created: '2024-01-01T00:00:00Z',
NetworkSettings: {
Ports: {
'3000/tcp': [{ HostIp: '0.0.0.0', HostPort: '32768' }],
},
},
})),
logs: vi.fn(async () => Buffer.from('test log output')),
};
const mockModem = {
followProgress: vi.fn((_stream: unknown, onFinished: (err: Error | null) => void) => {
onFinished(null);
}),
};
class MockDocker {
modem = mockModem;
ping = vi.fn(async () => 'OK');
pull = vi.fn(async () => ({}));
createContainer = vi.fn(async () => mockContainer);
getContainer = vi.fn(() => mockContainer);
}
return { default: MockDocker };
});
describe('DockerContainerManager', () => {
let manager: DockerContainerManager;
beforeEach(() => {
vi.clearAllMocks();
manager = new DockerContainerManager();
});
describe('ping', () => {
it('returns true when Docker is available', async () => {
expect(await manager.ping()).toBe(true);
});
});
describe('pullImage', () => {
it('pulls an image', async () => {
await manager.pullImage('node:20-alpine');
// No error = success
});
});
describe('createContainer', () => {
it('creates and starts a container', async () => {
const spec: ContainerSpec = {
image: 'ghcr.io/slack-mcp:latest',
name: 'mcpctl-slack-inst1',
env: { SLACK_TOKEN: 'xoxb-test' },
containerPort: 3000,
hostPort: null,
labels: { 'mcpctl.server-id': 'srv-1' },
};
const result = await manager.createContainer(spec);
expect(result.containerId).toBe('ctr-abc123');
expect(result.name).toBe('mcpctl-test');
expect(result.state).toBe('running');
expect(result.port).toBe(32768);
});
it('applies resource limits', async () => {
const spec: ContainerSpec = {
image: 'test:latest',
name: 'test-container',
memoryLimit: 256 * 1024 * 1024,
nanoCpus: 250_000_000,
};
await manager.createContainer(spec);
// The mock captures the call - we verify it doesn't throw
});
});
describe('stopContainer', () => {
it('stops a container', async () => {
await manager.stopContainer('ctr-abc123');
// No error = success
});
});
describe('removeContainer', () => {
it('removes a container', async () => {
await manager.removeContainer('ctr-abc123', true);
// No error = success
});
});
describe('inspectContainer', () => {
it('returns container info with mapped state', async () => {
const info = await manager.inspectContainer('ctr-abc123');
expect(info.containerId).toBe('ctr-abc123');
expect(info.state).toBe('running');
expect(info.port).toBe(32768);
});
});
describe('getContainerLogs', () => {
it('returns container logs', async () => {
const logs = await manager.getContainerLogs('ctr-abc123', { tail: 50 });
expect(logs.stdout).toBe('test log output');
});
});
});

View File

@@ -0,0 +1,72 @@
import { describe, it, expect, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { ZodError, z } from 'zod';
import { errorHandler } from '../src/middleware/error-handler.js';
let app: FastifyInstance;
afterEach(async () => {
if (app) await app.close();
});
function setupApp() {
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
return app;
}
describe('errorHandler', () => {
it('returns 400 for ZodError', async () => {
const a = setupApp();
a.get('/test', async () => {
z.object({ name: z.string() }).parse({});
});
await a.ready();
const res = await a.inject({ method: 'GET', url: '/test' });
expect(res.statusCode).toBe(400);
const body = res.json<{ error: string; details: unknown[] }>();
expect(body.error).toBe('Validation error');
expect(body.details).toBeDefined();
});
it('returns 500 for unknown errors and hides details', async () => {
const a = setupApp();
a.get('/test', async () => {
throw new Error('secret database password leaked');
});
await a.ready();
const res = await a.inject({ method: 'GET', url: '/test' });
expect(res.statusCode).toBe(500);
const body = res.json<{ error: string }>();
expect(body.error).toBe('Internal server error');
expect(JSON.stringify(body)).not.toContain('secret');
});
it('returns correct status for HTTP errors', async () => {
const a = setupApp();
a.get('/test', async (_req, reply) => {
reply.code(404).send({ error: 'Not found', statusCode: 404 });
});
await a.ready();
const res = await a.inject({ method: 'GET', url: '/test' });
expect(res.statusCode).toBe(404);
});
it('returns 429 for rate limit errors', async () => {
const a = setupApp();
a.get('/test', async () => {
const err = new Error('Rate limit') as Error & { statusCode: number };
err.statusCode = 429;
throw err;
});
await a.ready();
const res = await a.inject({ method: 'GET', url: '/test' });
expect(res.statusCode).toBe(429);
expect(res.json<{ error: string }>().error).toBe('Rate limit exceeded');
});
});

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, afterEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerHealthRoutes } from '../src/routes/health.js';
let app: FastifyInstance;
afterEach(async () => {
if (app) await app.close();
});
describe('GET /health', () => {
it('returns healthy when DB is up', async () => {
app = Fastify({ logger: false });
registerHealthRoutes(app, { checkDb: async () => true });
await app.ready();
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
const body = res.json<{ status: string; version: string; checks: { database: string } }>();
expect(body.status).toBe('healthy');
expect(body.version).toBeDefined();
expect(body.checks.database).toBe('ok');
});
it('returns degraded when DB is down', async () => {
app = Fastify({ logger: false });
registerHealthRoutes(app, { checkDb: async () => false });
await app.ready();
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(503);
const body = res.json<{ status: string; checks: { database: string } }>();
expect(body.status).toBe('degraded');
expect(body.checks.database).toBe('error');
});
it('returns degraded when DB check throws', async () => {
app = Fastify({ logger: false });
registerHealthRoutes(app, {
checkDb: async () => { throw new Error('connection refused'); },
});
await app.ready();
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(503);
});
it('includes uptime and timestamp', async () => {
app = Fastify({ logger: false });
registerHealthRoutes(app, { checkDb: async () => true });
await app.ready();
const res = await app.inject({ method: 'GET', url: '/health' });
const body = res.json<{ uptime: number; timestamp: string }>();
expect(body.uptime).toBeGreaterThan(0);
expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
});
describe('GET /healthz', () => {
it('returns ok (liveness probe)', async () => {
app = Fastify({ logger: false });
registerHealthRoutes(app, { checkDb: async () => true });
await app.ready();
const res = await app.inject({ method: 'GET', url: '/healthz' });
expect(res.statusCode).toBe(200);
expect(res.json<{ status: string }>().status).toBe('ok');
});
});

View File

@@ -0,0 +1,253 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { InstanceService } from '../src/services/instance.service.js';
import { NotFoundError } from '../src/services/mcp-server.service.js';
import type { IMcpInstanceRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
import type { McpOrchestrator } from '../src/services/orchestrator.js';
function mockInstanceRepo(): IMcpInstanceRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByContainerId: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'inst-1',
serverId: data.serverId,
containerId: data.containerId ?? null,
status: data.status ?? 'STOPPED',
port: data.port ?? null,
metadata: data.metadata ?? {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
})),
updateStatus: vi.fn(async (id, status, fields) => ({
id,
serverId: 'srv-1',
containerId: fields?.containerId ?? 'ctr-abc',
status,
port: fields?.port ?? null,
metadata: fields?.metadata ?? {},
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
};
}
function mockServerRepo(): IMcpServerRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
function mockOrchestrator(): McpOrchestrator {
return {
ping: vi.fn(async () => true),
pullImage: vi.fn(async () => {}),
createContainer: vi.fn(async (spec) => ({
containerId: 'ctr-abc123',
name: spec.name,
state: 'running' as const,
port: 3000,
createdAt: new Date(),
})),
stopContainer: vi.fn(async () => {}),
removeContainer: vi.fn(async () => {}),
inspectContainer: vi.fn(async () => ({
containerId: 'ctr-abc123',
name: 'test',
state: 'running' as const,
createdAt: new Date(),
})),
getContainerLogs: vi.fn(async () => ({ stdout: 'log output', stderr: '' })),
};
}
describe('InstanceService', () => {
let instanceRepo: ReturnType<typeof mockInstanceRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
let orchestrator: ReturnType<typeof mockOrchestrator>;
let service: InstanceService;
beforeEach(() => {
instanceRepo = mockInstanceRepo();
serverRepo = mockServerRepo();
orchestrator = mockOrchestrator();
service = new InstanceService(instanceRepo, serverRepo, orchestrator);
});
describe('list', () => {
it('lists all instances', async () => {
const result = await service.list();
expect(instanceRepo.findAll).toHaveBeenCalledWith(undefined);
expect(result).toEqual([]);
});
it('filters by serverId', async () => {
await service.list('srv-1');
expect(instanceRepo.findAll).toHaveBeenCalledWith('srv-1');
});
});
describe('getById', () => {
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
it('returns instance when found', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({ id: 'inst-1' } as never);
const result = await service.getById('inst-1');
expect(result.id).toBe('inst-1');
});
});
describe('start', () => {
it('throws NotFoundError for unknown server', async () => {
await expect(service.start('missing')).rejects.toThrow(NotFoundError);
});
it('creates instance and starts container', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest',
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.start('srv-1');
expect(instanceRepo.create).toHaveBeenCalledWith({
serverId: 'srv-1',
status: 'STARTING',
});
expect(orchestrator.createContainer).toHaveBeenCalled();
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
'inst-1', 'RUNNING',
expect.objectContaining({ containerId: 'ctr-abc123' }),
);
expect(result.status).toBe('RUNNING');
});
it('marks instance as ERROR on container failure', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'ghcr.io/slack-mcp:latest',
packageName: null, transport: 'STDIO', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(orchestrator.createContainer).mockRejectedValue(new Error('Docker unavailable'));
const result = await service.start('srv-1');
expect(instanceRepo.updateStatus).toHaveBeenCalledWith(
'inst-1', 'ERROR',
expect.objectContaining({ metadata: { error: 'Docker unavailable' } }),
);
expect(result.status).toBe('ERROR');
});
it('uses dockerImage for container spec', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 'srv-1', name: 'slack', dockerImage: 'myregistry.com/slack:v1',
packageName: '@slack/mcp', transport: 'SSE', description: '', repositoryUrl: null,
envTemplate: [], version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.start('srv-1');
const spec = vi.mocked(orchestrator.createContainer).mock.calls[0]?.[0];
expect(spec?.image).toBe('myregistry.com/slack:v1');
expect(spec?.containerPort).toBe(3000); // SSE transport
});
});
describe('stop', () => {
it('throws NotFoundError for missing instance', async () => {
await expect(service.stop('missing')).rejects.toThrow(NotFoundError);
});
it('stops a running container', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
serverId: 'srv-1', port: 3000, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.stop('inst-1');
expect(orchestrator.stopContainer).toHaveBeenCalledWith('ctr-abc');
expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED');
});
it('handles stop without containerId', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: null, status: 'ERROR',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.stop('inst-1');
expect(orchestrator.stopContainer).not.toHaveBeenCalled();
expect(instanceRepo.updateStatus).toHaveBeenCalledWith('inst-1', 'STOPPED');
});
});
describe('remove', () => {
it('removes container and DB record', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
await service.remove('inst-1');
expect(orchestrator.removeContainer).toHaveBeenCalledWith('ctr-abc', true);
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
});
it('removes DB record even if container is already gone', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'STOPPED',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(orchestrator.removeContainer).mockRejectedValue(new Error('No such container'));
await service.remove('inst-1');
expect(instanceRepo.delete).toHaveBeenCalledWith('inst-1');
});
});
describe('getLogs', () => {
it('returns empty logs for instance without container', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: null, status: 'ERROR',
serverId: 'srv-1', port: null, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.getLogs('inst-1');
expect(result).toEqual({ stdout: '', stderr: '' });
});
it('returns container logs', async () => {
vi.mocked(instanceRepo.findById).mockResolvedValue({
id: 'inst-1', containerId: 'ctr-abc', status: 'RUNNING',
serverId: 'srv-1', port: 3000, metadata: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.getLogs('inst-1', { tail: 50 });
expect(orchestrator.getContainerLogs).toHaveBeenCalledWith('ctr-abc', { tail: 50 });
expect(result.stdout).toBe('log output');
});
});
});

View File

@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest';
import { generateMcpConfig } from '../src/services/mcp-config-generator.js';
import type { ProfileWithServer } from '../src/services/mcp-config-generator.js';
function makeProfile(overrides: Partial<ProfileWithServer['profile']> = {}): ProfileWithServer['profile'] {
return {
id: 'p1',
name: 'default',
serverId: 's1',
permissions: [],
envOverrides: {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
function makeServer(overrides: Partial<ProfileWithServer['server']> = {}): ProfileWithServer['server'] {
return {
id: 's1',
name: 'slack',
description: 'Slack MCP',
packageName: '@anthropic/slack-mcp',
dockerImage: null,
transport: 'STDIO',
repositoryUrl: null,
envTemplate: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
};
}
describe('generateMcpConfig', () => {
it('returns empty mcpServers for empty profiles', () => {
const result = generateMcpConfig([]);
expect(result).toEqual({ mcpServers: {} });
});
it('generates config for a single profile', () => {
const result = generateMcpConfig([
{ profile: makeProfile(), server: makeServer() },
]);
expect(result.mcpServers['slack--default']).toBeDefined();
expect(result.mcpServers['slack--default']?.command).toBe('npx');
expect(result.mcpServers['slack--default']?.args).toEqual(['-y', '@anthropic/slack-mcp']);
});
it('excludes secret env vars from output', () => {
const server = makeServer({
envTemplate: [
{ name: 'SLACK_BOT_TOKEN', description: 'Token', isSecret: true },
{ name: 'SLACK_TEAM_ID', description: 'Team', isSecret: false, defaultValue: 'T123' },
] as never,
});
const result = generateMcpConfig([
{ profile: makeProfile(), server },
]);
const config = result.mcpServers['slack--default'];
expect(config?.env).toBeDefined();
expect(config?.env?.['SLACK_TEAM_ID']).toBe('T123');
expect(config?.env?.['SLACK_BOT_TOKEN']).toBeUndefined();
});
it('applies env overrides from profile (non-secret only)', () => {
const server = makeServer({
envTemplate: [
{ name: 'API_URL', description: 'URL', isSecret: false },
] as never,
});
const profile = makeProfile({
envOverrides: { API_URL: 'https://staging.example.com' } as never,
});
const result = generateMcpConfig([{ profile, server }]);
expect(result.mcpServers['slack--default']?.env?.['API_URL']).toBe('https://staging.example.com');
});
it('generates multiple server configs', () => {
const result = generateMcpConfig([
{ profile: makeProfile({ name: 'readonly' }), server: makeServer({ name: 'slack' }) },
{ profile: makeProfile({ name: 'default', id: 'p2' }), server: makeServer({ name: 'github', id: 's2', packageName: '@anthropic/github-mcp' }) },
]);
expect(Object.keys(result.mcpServers)).toHaveLength(2);
expect(result.mcpServers['slack--readonly']).toBeDefined();
expect(result.mcpServers['github--default']).toBeDefined();
});
it('omits env when no non-secret vars have values', () => {
const server = makeServer({
envTemplate: [
{ name: 'TOKEN', description: 'Secret', isSecret: true },
] as never,
});
const result = generateMcpConfig([
{ profile: makeProfile(), server },
]);
expect(result.mcpServers['slack--default']?.env).toBeUndefined();
});
it('uses server name as fallback when packageName is null', () => {
const server = makeServer({ packageName: null });
const result = generateMcpConfig([
{ profile: makeProfile(), server },
]);
expect(result.mcpServers['slack--default']?.args).toEqual(['-y', 'slack']);
});
});

View File

@@ -0,0 +1,128 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpProfileService } from '../src/services/mcp-profile.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IMcpProfileRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
function mockProfileRepo(): IMcpProfileRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByServerAndName: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'new-id',
name: data.name,
serverId: data.serverId,
permissions: data.permissions ?? [],
envOverrides: data.envOverrides ?? {},
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
})),
update: vi.fn(async (id, data) => ({
id,
name: data.name ?? 'test',
serverId: 'srv-1',
permissions: data.permissions ?? [],
envOverrides: data.envOverrides ?? {},
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
};
}
function mockServerRepo(): IMcpServerRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
describe('McpProfileService', () => {
let profileRepo: ReturnType<typeof mockProfileRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
let service: McpProfileService;
beforeEach(() => {
profileRepo = mockProfileRepo();
serverRepo = mockServerRepo();
service = new McpProfileService(profileRepo, serverRepo);
});
describe('list', () => {
it('returns all profiles', async () => {
await service.list();
expect(profileRepo.findAll).toHaveBeenCalledWith(undefined);
});
it('filters by serverId', async () => {
await service.list('srv-1');
expect(profileRepo.findAll).toHaveBeenCalledWith('srv-1');
});
});
describe('getById', () => {
it('returns profile when found', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'test' } as never);
const result = await service.getById('1');
expect(result.id).toBe('1');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
it('creates a profile when server exists', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'test' } as never);
const result = await service.create({ name: 'readonly', serverId: 'srv-1' });
expect(result.name).toBe('readonly');
});
it('throws NotFoundError when server does not exist', async () => {
await expect(service.create({ name: 'test', serverId: 'missing' })).rejects.toThrow(NotFoundError);
});
it('throws ConflictError when profile name exists for server', async () => {
vi.mocked(serverRepo.findById).mockResolvedValue({ id: 'srv-1', name: 'test' } as never);
vi.mocked(profileRepo.findByServerAndName).mockResolvedValue({ id: '1' } as never);
await expect(service.create({ name: 'dup', serverId: 'srv-1' })).rejects.toThrow(ConflictError);
});
});
describe('update', () => {
it('updates an existing profile', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'old', serverId: 'srv-1' } as never);
await service.update('1', { permissions: ['read'] });
expect(profileRepo.update).toHaveBeenCalled();
});
it('checks uniqueness when renaming', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1', name: 'old', serverId: 'srv-1' } as never);
vi.mocked(profileRepo.findByServerAndName).mockResolvedValue({ id: '2' } as never);
await expect(service.update('1', { name: 'taken' })).rejects.toThrow(ConflictError);
});
it('throws NotFoundError when profile does not exist', async () => {
await expect(service.update('missing', {})).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('deletes an existing profile', async () => {
vi.mocked(profileRepo.findById).mockResolvedValue({ id: '1' } as never);
await service.delete('1');
expect(profileRepo.delete).toHaveBeenCalledWith('1');
});
it('throws NotFoundError when profile does not exist', async () => {
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
});
});
});

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest';
import Fastify from 'fastify';
import type { FastifyInstance } from 'fastify';
import { registerMcpServerRoutes } from '../src/routes/mcp-servers.js';
import { McpServerService, NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import { errorHandler } from '../src/middleware/error-handler.js';
import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
let app: FastifyInstance;
function mockRepo(): IMcpServerRepository {
return {
findAll: vi.fn(async () => [
{ id: '1', name: 'slack', description: 'Slack server', transport: 'STDIO' },
]),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'new-id',
name: data.name,
description: data.description ?? '',
packageName: data.packageName ?? null,
dockerImage: null,
transport: data.transport ?? 'STDIO',
repositoryUrl: null,
envTemplate: [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
})),
update: vi.fn(async (id, data) => ({
id,
name: 'slack',
description: (data.description as string) ?? 'Slack server',
packageName: null,
dockerImage: null,
transport: 'STDIO',
repositoryUrl: null,
envTemplate: [],
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
};
}
afterEach(async () => {
if (app) await app.close();
});
function createApp(repo: IMcpServerRepository) {
app = Fastify({ logger: false });
app.setErrorHandler(errorHandler);
const service = new McpServerService(repo);
registerMcpServerRoutes(app, service);
return app.ready();
}
describe('MCP Server Routes', () => {
describe('GET /api/v1/servers', () => {
it('returns server list', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/servers' });
expect(res.statusCode).toBe(200);
const body = res.json<Array<{ name: string }>>();
expect(body).toHaveLength(1);
expect(body[0]?.name).toBe('slack');
});
});
describe('GET /api/v1/servers/:id', () => {
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/servers/missing' });
expect(res.statusCode).toBe(404);
});
it('returns server when found', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'slack' } as never);
await createApp(repo);
const res = await app.inject({ method: 'GET', url: '/api/v1/servers/1' });
expect(res.statusCode).toBe(200);
});
});
describe('POST /api/v1/servers', () => {
it('creates a server and returns 201', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: { name: 'new-server' },
});
expect(res.statusCode).toBe(201);
expect(res.json<{ name: string }>().name).toBe('new-server');
});
it('returns 400 for invalid input', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: { name: '' },
});
expect(res.statusCode).toBe(400);
});
it('returns 409 when name already exists', async () => {
const repo = mockRepo();
vi.mocked(repo.findByName).mockResolvedValue({ id: '1' } as never);
await createApp(repo);
const res = await app.inject({
method: 'POST',
url: '/api/v1/servers',
payload: { name: 'existing' },
});
expect(res.statusCode).toBe(409);
});
});
describe('PUT /api/v1/servers/:id', () => {
it('updates a server', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'slack' } as never);
await createApp(repo);
const res = await app.inject({
method: 'PUT',
url: '/api/v1/servers/1',
payload: { description: 'Updated' },
});
expect(res.statusCode).toBe(200);
});
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({
method: 'PUT',
url: '/api/v1/servers/missing',
payload: { description: 'x' },
});
expect(res.statusCode).toBe(404);
});
});
describe('DELETE /api/v1/servers/:id', () => {
it('deletes a server and returns 204', async () => {
const repo = mockRepo();
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'slack' } as never);
await createApp(repo);
const res = await app.inject({ method: 'DELETE', url: '/api/v1/servers/1' });
expect(res.statusCode).toBe(204);
});
it('returns 404 when not found', async () => {
const repo = mockRepo();
await createApp(repo);
const res = await app.inject({ method: 'DELETE', url: '/api/v1/servers/missing' });
expect(res.statusCode).toBe(404);
});
});
});

View File

@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { McpServerService, NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IMcpServerRepository } from '../src/repositories/interfaces.js';
function mockRepo(): IMcpServerRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'new-id',
name: data.name,
description: data.description ?? '',
packageName: data.packageName ?? null,
dockerImage: null,
transport: data.transport ?? 'STDIO',
repositoryUrl: data.repositoryUrl ?? null,
envTemplate: data.envTemplate ?? [],
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
})),
update: vi.fn(async (id, data) => ({
id,
name: 'test',
description: (data.description as string) ?? '',
packageName: null,
dockerImage: null,
transport: 'STDIO' as const,
repositoryUrl: null,
envTemplate: [],
version: 2,
createdAt: new Date(),
updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
};
}
describe('McpServerService', () => {
let repo: ReturnType<typeof mockRepo>;
let service: McpServerService;
beforeEach(() => {
repo = mockRepo();
service = new McpServerService(repo);
});
describe('list', () => {
it('returns all servers', async () => {
const servers = await service.list();
expect(repo.findAll).toHaveBeenCalled();
expect(servers).toEqual([]);
});
});
describe('getById', () => {
it('returns server when found', async () => {
const server = { id: '1', name: 'test' };
vi.mocked(repo.findById).mockResolvedValue(server as never);
const result = await service.getById('1');
expect(result.id).toBe('1');
});
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
});
describe('create', () => {
it('creates a server with valid input', async () => {
const result = await service.create({ name: 'my-server' });
expect(result.name).toBe('my-server');
expect(repo.create).toHaveBeenCalled();
});
it('throws ConflictError when name exists', async () => {
vi.mocked(repo.findByName).mockResolvedValue({ id: '1', name: 'existing' } as never);
await expect(service.create({ name: 'existing' })).rejects.toThrow(ConflictError);
});
it('throws on invalid input', async () => {
await expect(service.create({ name: '' })).rejects.toThrow();
});
});
describe('update', () => {
it('updates an existing server', async () => {
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'test' } as never);
await service.update('1', { description: 'updated' });
expect(repo.update).toHaveBeenCalledWith('1', { description: 'updated' });
});
it('throws NotFoundError when server does not exist', async () => {
await expect(service.update('missing', {})).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('deletes an existing server', async () => {
vi.mocked(repo.findById).mockResolvedValue({ id: '1', name: 'test' } as never);
await service.delete('1');
expect(repo.delete).toHaveBeenCalledWith('1');
});
it('throws NotFoundError when server does not exist', async () => {
await expect(service.delete('missing')).rejects.toThrow(NotFoundError);
});
});
});

View File

@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ProjectService } from '../src/services/project.service.js';
import { NotFoundError, ConflictError } from '../src/services/mcp-server.service.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
import type { IMcpProfileRepository, IMcpServerRepository } from '../src/repositories/interfaces.js';
function mockProjectRepo(): IProjectRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async (data) => ({
id: 'proj-1',
name: data.name,
description: data.description ?? '',
ownerId: data.ownerId,
version: 1,
createdAt: new Date(),
updatedAt: new Date(),
})),
update: vi.fn(async (id) => ({
id, name: 'test', description: '', ownerId: 'u1', version: 2,
createdAt: new Date(), updatedAt: new Date(),
})),
delete: vi.fn(async () => {}),
setProfiles: vi.fn(async () => {}),
getProfileIds: vi.fn(async () => []),
};
}
function mockProfileRepo(): IMcpProfileRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByServerAndName: vi.fn(async () => null),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
function mockServerRepo(): IMcpServerRepository {
return {
findAll: vi.fn(async () => []),
findById: vi.fn(async () => null),
findByName: vi.fn(async () => null),
create: vi.fn(async () => ({} as never)),
update: vi.fn(async () => ({} as never)),
delete: vi.fn(async () => {}),
};
}
describe('ProjectService', () => {
let projectRepo: ReturnType<typeof mockProjectRepo>;
let profileRepo: ReturnType<typeof mockProfileRepo>;
let serverRepo: ReturnType<typeof mockServerRepo>;
let service: ProjectService;
beforeEach(() => {
projectRepo = mockProjectRepo();
profileRepo = mockProfileRepo();
serverRepo = mockServerRepo();
service = new ProjectService(projectRepo, profileRepo, serverRepo);
});
describe('create', () => {
it('creates a project', async () => {
const result = await service.create({ name: 'my-project' }, 'user-1');
expect(result.name).toBe('my-project');
expect(result.ownerId).toBe('user-1');
});
it('throws ConflictError when name exists', async () => {
vi.mocked(projectRepo.findByName).mockResolvedValue({ id: '1' } as never);
await expect(service.create({ name: 'taken' }, 'u1')).rejects.toThrow(ConflictError);
});
it('validates input', async () => {
await expect(service.create({ name: '' }, 'u1')).rejects.toThrow();
});
});
describe('getById', () => {
it('throws NotFoundError when not found', async () => {
await expect(service.getById('missing')).rejects.toThrow(NotFoundError);
});
});
describe('setProfiles', () => {
it('sets profile associations', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
vi.mocked(profileRepo.findById).mockResolvedValue({ id: 'prof-1' } as never);
const result = await service.setProfiles('p1', { profileIds: ['prof-1'] });
expect(result).toEqual(['prof-1']);
expect(projectRepo.setProfiles).toHaveBeenCalledWith('p1', ['prof-1']);
});
it('throws NotFoundError for missing profile', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
await expect(service.setProfiles('p1', { profileIds: ['missing'] })).rejects.toThrow(NotFoundError);
});
it('throws NotFoundError for missing project', async () => {
await expect(service.setProfiles('missing', { profileIds: [] })).rejects.toThrow(NotFoundError);
});
});
describe('getMcpConfig', () => {
it('returns empty config for project with no profiles', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
const result = await service.getMcpConfig('p1');
expect(result).toEqual({ mcpServers: {} });
});
it('generates config from profiles', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
vi.mocked(projectRepo.getProfileIds).mockResolvedValue(['prof-1']);
vi.mocked(profileRepo.findById).mockResolvedValue({
id: 'prof-1', name: 'default', serverId: 's1',
permissions: [], envOverrides: {},
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
vi.mocked(serverRepo.findById).mockResolvedValue({
id: 's1', name: 'slack', description: '', packageName: '@anthropic/slack-mcp',
dockerImage: null, transport: 'STDIO', repositoryUrl: null, envTemplate: [],
version: 1, createdAt: new Date(), updatedAt: new Date(),
});
const result = await service.getMcpConfig('p1');
expect(result.mcpServers['slack--default']).toBeDefined();
});
it('throws NotFoundError for missing project', async () => {
await expect(service.getMcpConfig('missing')).rejects.toThrow(NotFoundError);
});
});
describe('delete', () => {
it('deletes project', async () => {
vi.mocked(projectRepo.findById).mockResolvedValue({ id: 'p1' } as never);
await service.delete('p1');
expect(projectRepo.delete).toHaveBeenCalledWith('p1');
});
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, afterEach } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { createServer } from '../src/server.js';
import type { McpdConfig } from '../src/config/index.js';
let app: FastifyInstance;
afterEach(async () => {
if (app) await app.close();
});
const testConfig: McpdConfig = {
port: 3000,
host: '0.0.0.0',
databaseUrl: 'postgresql://localhost/test',
logLevel: 'fatal', // suppress logs in tests
corsOrigins: ['*'],
rateLimitMax: 100,
rateLimitWindowMs: 60_000,
};
describe('createServer', () => {
it('creates a Fastify instance', async () => {
app = await createServer(testConfig, {
health: { checkDb: async () => true },
});
expect(app).toBeDefined();
});
it('registers health endpoint', async () => {
app = await createServer(testConfig, {
health: { checkDb: async () => true },
});
await app.ready();
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.statusCode).toBe(200);
});
it('registers healthz endpoint', async () => {
app = await createServer(testConfig, {
health: { checkDb: async () => true },
});
await app.ready();
const res = await app.inject({ method: 'GET', url: '/healthz' });
expect(res.statusCode).toBe(200);
});
it('returns 404 for unknown routes', async () => {
app = await createServer(testConfig, {
health: { checkDb: async () => true },
});
await app.ready();
const res = await app.inject({ method: 'GET', url: '/nonexistent' });
expect(res.statusCode).toBe(404);
});
it('includes CORS headers', async () => {
app = await createServer(testConfig, {
health: { checkDb: async () => true },
});
await app.ready();
const res = await app.inject({
method: 'OPTIONS',
url: '/health',
headers: { origin: 'http://localhost:3000' },
});
expect(res.headers['access-control-allow-origin']).toBeDefined();
});
it('includes security headers from Helmet', async () => {
app = await createServer(testConfig, {
health: { checkDb: async () => true },
});
await app.ready();
const res = await app.inject({ method: 'GET', url: '/health' });
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
});

View File

@@ -0,0 +1,124 @@
import { describe, it, expect } from 'vitest';
import {
CreateMcpServerSchema,
UpdateMcpServerSchema,
CreateMcpProfileSchema,
UpdateMcpProfileSchema,
} from '../src/validation/index.js';
describe('CreateMcpServerSchema', () => {
it('validates valid input', () => {
const result = CreateMcpServerSchema.parse({
name: 'my-server',
description: 'A test server',
transport: 'STDIO',
});
expect(result.name).toBe('my-server');
expect(result.envTemplate).toEqual([]);
});
it('rejects empty name', () => {
expect(() => CreateMcpServerSchema.parse({ name: '' })).toThrow();
});
it('rejects name with spaces', () => {
expect(() => CreateMcpServerSchema.parse({ name: 'my server' })).toThrow();
});
it('rejects uppercase name', () => {
expect(() => CreateMcpServerSchema.parse({ name: 'MyServer' })).toThrow();
});
it('allows hyphens in name', () => {
const result = CreateMcpServerSchema.parse({ name: 'my-mcp-server' });
expect(result.name).toBe('my-mcp-server');
});
it('defaults transport to STDIO', () => {
const result = CreateMcpServerSchema.parse({ name: 'test' });
expect(result.transport).toBe('STDIO');
});
it('validates envTemplate entries', () => {
const result = CreateMcpServerSchema.parse({
name: 'test',
envTemplate: [
{ name: 'API_KEY', description: 'The key', isSecret: true },
],
});
expect(result.envTemplate).toHaveLength(1);
expect(result.envTemplate[0]?.isSecret).toBe(true);
});
it('rejects invalid transport', () => {
expect(() => CreateMcpServerSchema.parse({ name: 'test', transport: 'HTTP' })).toThrow();
});
it('rejects invalid repository URL', () => {
expect(() => CreateMcpServerSchema.parse({ name: 'test', repositoryUrl: 'not-a-url' })).toThrow();
});
});
describe('UpdateMcpServerSchema', () => {
it('allows partial updates', () => {
const result = UpdateMcpServerSchema.parse({ description: 'updated' });
expect(result.description).toBe('updated');
expect(result.transport).toBeUndefined();
});
it('allows empty object', () => {
const result = UpdateMcpServerSchema.parse({});
expect(Object.keys(result)).toHaveLength(0);
});
it('allows nullable fields', () => {
const result = UpdateMcpServerSchema.parse({ packageName: null, dockerImage: null });
expect(result.packageName).toBeNull();
expect(result.dockerImage).toBeNull();
});
});
describe('CreateMcpProfileSchema', () => {
it('validates valid input', () => {
const result = CreateMcpProfileSchema.parse({
name: 'readonly',
serverId: 'server-123',
});
expect(result.name).toBe('readonly');
expect(result.permissions).toEqual([]);
expect(result.envOverrides).toEqual({});
});
it('rejects empty name', () => {
expect(() => CreateMcpProfileSchema.parse({ name: '', serverId: 'x' })).toThrow();
});
it('accepts permissions array', () => {
const result = CreateMcpProfileSchema.parse({
name: 'admin',
serverId: 'x',
permissions: ['read', 'write', 'delete'],
});
expect(result.permissions).toHaveLength(3);
});
it('accepts envOverrides', () => {
const result = CreateMcpProfileSchema.parse({
name: 'staging',
serverId: 'x',
envOverrides: { API_URL: 'https://staging.example.com' },
});
expect(result.envOverrides['API_URL']).toBe('https://staging.example.com');
});
});
describe('UpdateMcpProfileSchema', () => {
it('allows partial updates', () => {
const result = UpdateMcpProfileSchema.parse({ permissions: ['read'] });
expect(result.permissions).toEqual(['read']);
});
it('allows empty object', () => {
expect(UpdateMcpProfileSchema.parse({})).toBeDefined();
});
});

View File

@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts"],
"references": [