From d1390313a38dc53c8a481537ab4818a4d0b1efcd Mon Sep 17 00:00:00 2001 From: Michal Date: Sat, 21 Feb 2026 04:52:12 +0000 Subject: [PATCH] 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 --- deploy/docker-compose.yml | 38 ++ pnpm-lock.yaml | 431 ++++++++++++++++++ src/mcpd/package.json | 2 + src/mcpd/src/repositories/index.ts | 3 +- src/mcpd/src/repositories/interfaces.ts | 11 +- .../repositories/mcp-instance.repository.ts | 71 +++ src/mcpd/src/routes/index.ts | 1 + src/mcpd/src/routes/instances.ts | 49 ++ .../src/services/docker/container-manager.ts | 156 +++++++ src/mcpd/src/services/index.ts | 4 + src/mcpd/src/services/instance.service.ts | 112 +++++ src/mcpd/src/services/orchestrator.ts | 64 +++ src/mcpd/tests/container-manager.test.ts | 125 +++++ src/mcpd/tests/instance-service.test.ts | 253 ++++++++++ 14 files changed, 1318 insertions(+), 2 deletions(-) create mode 100644 src/mcpd/src/repositories/mcp-instance.repository.ts create mode 100644 src/mcpd/src/routes/instances.ts create mode 100644 src/mcpd/src/services/docker/container-manager.ts create mode 100644 src/mcpd/src/services/instance.service.ts create mode 100644 src/mcpd/src/services/orchestrator.ts create mode 100644 src/mcpd/tests/container-manager.test.ts create mode 100644 src/mcpd/tests/instance-service.test.ts diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 892a7f9..5437c2b 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -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: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4cc270..226b8c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,6 +112,9 @@ importers: '@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 @@ -119,6 +122,9 @@ importers: 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 @@ -148,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'} @@ -365,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'} @@ -531,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'} @@ -578,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] @@ -712,6 +768,12 @@ 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==} @@ -724,9 +786,15 @@ packages: '@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} @@ -866,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'} @@ -887,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'} @@ -898,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'} @@ -933,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==} @@ -943,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'} @@ -985,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'} @@ -1019,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'} @@ -1044,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'} @@ -1064,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==} @@ -1225,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} @@ -1233,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'} @@ -1291,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'} @@ -1403,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} @@ -1449,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==} @@ -1459,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} @@ -1595,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'} @@ -1624,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'} @@ -1632,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'} @@ -1671,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==} @@ -1741,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==} @@ -1759,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'} @@ -1767,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'} @@ -1808,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'} @@ -1821,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==} @@ -1831,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'} @@ -1927,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'} @@ -1961,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': @@ -2110,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 @@ -2259,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)': @@ -2320,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 @@ -2404,6 +2649,17 @@ 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': {} @@ -2412,10 +2668,18 @@ snapshots: '@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 @@ -2599,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: @@ -2618,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 @@ -2640,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: @@ -2677,6 +2965,8 @@ snapshots: dependencies: readdirp: 4.1.2 + chownr@1.1.4: {} + citty@0.1.6: dependencies: consola: 3.4.2 @@ -2685,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 @@ -2712,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 @@ -2734,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: @@ -2755,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: {} @@ -2794,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: {} @@ -3014,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 @@ -3088,6 +3421,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3182,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: @@ -3220,6 +3559,8 @@ snapshots: minipass@7.1.3: {} + mkdirp-classic@0.5.3: {} + mnemonist@0.40.0: dependencies: obliterator: 2.0.5 @@ -3228,6 +3569,9 @@ snapshots: mute-stream@2.0.0: {} + nan@2.25.0: + optional: true + nanoid@3.3.11: {} natural-compare@1.4.0: {} @@ -3351,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: {} @@ -3380,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: {} @@ -3446,6 +3818,8 @@ snapshots: dependencies: tslib: 2.8.1 + safe-buffer@5.2.1: {} + safe-regex2@5.0.0: dependencies: ret: 0.5.0 @@ -3531,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: {} @@ -3545,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 @@ -3553,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 @@ -3585,6 +3988,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -3597,6 +4002,8 @@ snapshots: typescript@5.9.3: {} + undici-types@5.26.5: {} + undici-types@7.18.2: {} unpipe@1.0.0: {} @@ -3605,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): @@ -3675,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: {} diff --git a/src/mcpd/package.json b/src/mcpd/package.json index 0635ea7..1750f80 100644 --- a/src/mcpd/package.json +++ b/src/mcpd/package.json @@ -20,10 +20,12 @@ "@mcpctl/db": "workspace:*", "@mcpctl/shared": "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" } } diff --git a/src/mcpd/src/repositories/index.ts b/src/mcpd/src/repositories/index.ts index 497d881..8e42467 100644 --- a/src/mcpd/src/repositories/index.ts +++ b/src/mcpd/src/repositories/index.ts @@ -1,5 +1,6 @@ -export type { IMcpServerRepository, IMcpProfileRepository } from './interfaces.js'; +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'; diff --git a/src/mcpd/src/repositories/interfaces.ts b/src/mcpd/src/repositories/interfaces.ts index ff71f23..313ce72 100644 --- a/src/mcpd/src/repositories/interfaces.ts +++ b/src/mcpd/src/repositories/interfaces.ts @@ -1,4 +1,4 @@ -import type { McpServer, McpProfile } from '@prisma/client'; +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'; @@ -11,6 +11,15 @@ export interface IMcpServerRepository { delete(id: string): Promise; } +export interface IMcpInstanceRepository { + findAll(serverId?: string): Promise; + findById(id: string): Promise; + findByContainerId(containerId: string): Promise; + create(data: { serverId: string; containerId?: string; status?: InstanceStatus; port?: number; metadata?: Record }): Promise; + updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record }): Promise; + delete(id: string): Promise; +} + export interface IMcpProfileRepository { findAll(serverId?: string): Promise; findById(id: string): Promise; diff --git a/src/mcpd/src/repositories/mcp-instance.repository.ts b/src/mcpd/src/repositories/mcp-instance.repository.ts new file mode 100644 index 0000000..5472fd2 --- /dev/null +++ b/src/mcpd/src/repositories/mcp-instance.repository.ts @@ -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 { + const where: Prisma.McpInstanceWhereInput = {}; + if (serverId) { + where.serverId = serverId; + } + return this.prisma.mcpInstance.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.mcpInstance.findUnique({ where: { id } }); + } + + async findByContainerId(containerId: string): Promise { + return this.prisma.mcpInstance.findFirst({ where: { containerId } }); + } + + async create(data: { + serverId: string; + containerId?: string; + status?: InstanceStatus; + port?: number; + metadata?: Record; + }): Promise { + 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 }, + ): Promise { + 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 { + await this.prisma.mcpInstance.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/routes/index.ts b/src/mcpd/src/routes/index.ts index 0c0c777..90bf7c7 100644 --- a/src/mcpd/src/routes/index.ts +++ b/src/mcpd/src/routes/index.ts @@ -3,3 +3,4 @@ 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'; diff --git a/src/mcpd/src/routes/instances.ts b/src/mcpd/src/routes/instances.ts new file mode 100644 index 0000000..a410f35 --- /dev/null +++ b/src/mcpd/src/routes/instances.ts @@ -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; hostPort?: number } }>( + '/api/v1/instances', + async (request, reply) => { + const { serverId } = request.body; + const opts: { env?: Record; 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); + }, + ); +} diff --git a/src/mcpd/src/services/docker/container-manager.ts b/src/mcpd/src/services/docker/container-manager.ts new file mode 100644 index 0000000..28443ab --- /dev/null +++ b/src/mcpd/src/services/docker/container-manager.ts @@ -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 { + try { + await this.docker.ping(); + return true; + } catch { + return false; + } + } + + async pullImage(image: string): Promise { + const stream = await this.docker.pull(image); + // Wait for pull to complete + await new Promise((resolve, reject) => { + this.docker.modem.followProgress(stream, (err: Error | null) => { + if (err) reject(err); + else resolve(); + }); + }); + } + + async createContainer(spec: ContainerSpec): Promise { + const memoryLimit = spec.memoryLimit ?? DEFAULT_MEMORY_LIMIT; + const nanoCpus = spec.nanoCpus ?? DEFAULT_NANO_CPUS; + + const portBindings: Record> = {}; + const exposedPorts: Record> = {}; + + if (spec.containerPort) { + const key = `${spec.containerPort}/tcp`; + exposedPorts[key] = {}; + portBindings[key] = [{ HostPort: spec.hostPort ? String(spec.hostPort) : '0' }]; + } + + const labels: Record = { + [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 { + const container = this.docker.getContainer(containerId); + await container.stop({ t: timeoutSeconds }); + } + + async removeContainer(containerId: string, force = false): Promise { + const container = this.docker.getContainer(containerId); + await container.remove({ force, v: true }); + } + + async inspectContainer(containerId: string): Promise { + 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 { + const container = this.docker.getContainer(containerId); + const logOpts: Record = { + 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: '' }; + } +} diff --git a/src/mcpd/src/services/index.ts b/src/mcpd/src/services/index.ts index f199f69..7c6a457 100644 --- a/src/mcpd/src/services/index.ts +++ b/src/mcpd/src/services/index.ts @@ -1,5 +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'; diff --git a/src/mcpd/src/services/instance.service.ts b/src/mcpd/src/services/instance.service.ts new file mode 100644 index 0000000..db07133 --- /dev/null +++ b/src/mcpd/src/services/instance.service.ts @@ -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 { + return this.instanceRepo.findAll(serverId); + } + + async getById(id: string): Promise { + 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; hostPort?: number }): Promise { + 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 { + 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 { + 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); + } +} diff --git a/src/mcpd/src/services/orchestrator.ts b/src/mcpd/src/services/orchestrator.ts new file mode 100644 index 0000000..ef906a8 --- /dev/null +++ b/src/mcpd/src/services/orchestrator.ts @@ -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; + /** 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; + /** 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; + + /** Create and start a container */ + createContainer(spec: ContainerSpec): Promise; + + /** Stop a running container */ + stopContainer(containerId: string, timeoutSeconds?: number): Promise; + + /** Remove a stopped container */ + removeContainer(containerId: string, force?: boolean): Promise; + + /** Get container info */ + inspectContainer(containerId: string): Promise; + + /** Get container logs */ + getContainerLogs(containerId: string, opts?: { tail?: number; since?: number }): Promise; + + /** Check if the orchestrator runtime is available */ + ping(): Promise; +} + +/** Default resource limits */ +export const DEFAULT_MEMORY_LIMIT = 512 * 1024 * 1024; // 512 MB +export const DEFAULT_NANO_CPUS = 500_000_000; // 0.5 CPU diff --git a/src/mcpd/tests/container-manager.test.ts b/src/mcpd/tests/container-manager.test.ts new file mode 100644 index 0000000..92dd4eb --- /dev/null +++ b/src/mcpd/tests/container-manager.test.ts @@ -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'); + }); + }); +}); diff --git a/src/mcpd/tests/instance-service.test.ts b/src/mcpd/tests/instance-service.test.ts new file mode 100644 index 0000000..0dad241 --- /dev/null +++ b/src/mcpd/tests/instance-service.test.ts @@ -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; + let serverRepo: ReturnType; + let orchestrator: ReturnType; + 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'); + }); + }); +});