Compare commits

..

8 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
Michal
386029d052 feat: implement MCP registry client with multi-source search
Add registry client that queries Official, Glama, and Smithery MCP
registries with caching, request deduplication, retry logic, and
result ranking/dedup. Includes 53 tests covering all components.

Also fix null priority values in cancelled tasks (19-21) that broke
Task Master, and add new tasks 25-27 for registry completion and
CLI discover/install commands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 03:46:14 +00:00
27 changed files with 2352 additions and 3 deletions

View File

@@ -15,6 +15,35 @@ services:
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 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: postgres-test:
image: postgres:16-alpine image: postgres:16-alpine
@@ -32,6 +61,15 @@ services:
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
profiles:
- test
networks:
mcpctl:
driver: bridge
mcp-servers:
driver: bridge
internal: true
volumes: volumes:
mcpctl-pgdata: mcpctl-pgdata:

View File

@@ -23,6 +23,7 @@
}, },
"packageManager": "pnpm@9.15.0", "packageManager": "pnpm@9.15.0",
"devDependencies": { "devDependencies": {
"@types/node": "^25.3.0",
"@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/eslint-plugin": "^8.56.0",
"@typescript-eslint/parser": "^8.56.0", "@typescript-eslint/parser": "^8.56.0",
"@vitest/coverage-v8": "^4.0.18", "@vitest/coverage-v8": "^4.0.18",

434
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.: .:
devDependencies: devDependencies:
'@types/node':
specifier: ^25.3.0
version: 25.3.0
'@typescript-eslint/eslint-plugin': '@typescript-eslint/eslint-plugin':
specifier: ^8.56.0 specifier: ^8.56.0
version: 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) version: 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)
@@ -109,6 +112,9 @@ importers:
'@prisma/client': '@prisma/client':
specifier: ^6.0.0 specifier: ^6.0.0
version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) 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: fastify:
specifier: ^5.0.0 specifier: ^5.0.0
version: 5.7.4 version: 5.7.4
@@ -116,6 +122,9 @@ importers:
specifier: ^3.24.0 specifier: ^3.24.0
version: 3.25.76 version: 3.25.76
devDependencies: devDependencies:
'@types/dockerode':
specifier: ^4.0.1
version: 4.0.1
'@types/node': '@types/node':
specifier: ^25.3.0 specifier: ^25.3.0
version: 25.3.0 version: 25.3.0
@@ -145,6 +154,9 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
'@balena/dockerignore@1.0.2':
resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==}
'@bcoe/v8-coverage@1.0.2': '@bcoe/v8-coverage@1.0.2':
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -362,6 +374,20 @@ packages:
'@fastify/rate-limit@10.3.0': '@fastify/rate-limit@10.3.0':
resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} 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': '@hono/node-server@1.19.9':
resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==}
engines: {node: '>=18.14.1'} engines: {node: '>=18.14.1'}
@@ -528,6 +554,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31': '@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} 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': '@lukeed/ms@2.0.2':
resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -575,6 +604,36 @@ packages:
'@prisma/get-platform@6.19.2': '@prisma/get-platform@6.19.2':
resolution: {integrity: sha512-PGLr06JUSTqIvztJtAzIxOwtWKtJm5WwOG6xpsgD37Rc84FpfUBGLKz65YpJBGtkRQGXTYEFie7pYALocC3MtA==} 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': '@rollup/rollup-android-arm-eabi@4.58.0':
resolution: {integrity: sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==} resolution: {integrity: sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==}
cpu: [arm] cpu: [arm]
@@ -709,6 +768,12 @@ packages:
'@types/deep-eql@4.0.2': '@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} 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': '@types/esrecurse@4.3.1':
resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==}
@@ -721,9 +786,15 @@ packages:
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
'@types/node@25.3.0': '@types/node@25.3.0':
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
'@types/ssh2@1.15.5':
resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==}
'@typescript-eslint/eslint-plugin@8.56.0': '@typescript-eslint/eslint-plugin@8.56.0':
resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -863,6 +934,9 @@ packages:
argparse@2.0.1: argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} 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: assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -884,6 +958,15 @@ packages:
resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==} resolution: {integrity: sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g==}
engines: {node: 20 || >=22} 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: body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'} engines: {node: '>=18'}
@@ -895,6 +978,13 @@ packages:
resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==}
engines: {node: 20 || >=22} 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: bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -930,6 +1020,9 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'} engines: {node: '>= 14.16.0'}
chownr@1.1.4:
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
citty@0.1.6: citty@0.1.6:
resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
@@ -940,6 +1033,10 @@ packages:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'} 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: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -982,6 +1079,10 @@ packages:
resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
engines: {node: '>= 0.10'} 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: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -1016,6 +1117,14 @@ packages:
destr@2.0.5: destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} 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: dotenv@16.6.1:
resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
engines: {node: '>=12'} engines: {node: '>=12'}
@@ -1041,6 +1150,9 @@ packages:
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
end-of-stream@1.4.5:
resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==}
es-define-property@1.0.1: es-define-property@1.0.1:
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1061,6 +1173,10 @@ packages:
engines: {node: '>=18'} engines: {node: '>=18'}
hasBin: true hasBin: true
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
escape-html@1.0.3: escape-html@1.0.3:
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
@@ -1222,6 +1338,9 @@ packages:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
fsevents@2.3.3: fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1230,6 +1349,10 @@ packages:
function-bind@1.1.2: function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} 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: get-intrinsic@1.3.0:
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -1288,6 +1411,9 @@ packages:
resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
ieee754@1.2.1:
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@@ -1400,6 +1526,12 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} 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: lru-cache@11.2.6:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
@@ -1446,6 +1578,9 @@ packages:
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
engines: {node: '>=16 || 14 >=14.17'} engines: {node: '>=16 || 14 >=14.17'}
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mnemonist@0.40.0: mnemonist@0.40.0:
resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==} resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==}
@@ -1456,6 +1591,9 @@ packages:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0} engines: {node: ^18.17.0 || >=20.5.0}
nan@2.25.0:
resolution: {integrity: sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==}
nanoid@3.3.11: nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -1592,10 +1730,17 @@ packages:
process-warning@5.0.0: process-warning@5.0.0:
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} 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: proxy-addr@2.0.7:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
pump@3.0.3:
resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==}
punycode@2.3.1: punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
@@ -1621,6 +1766,10 @@ packages:
rc9@2.1.2: rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
readdirp@4.1.2: readdirp@4.1.2:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'} engines: {node: '>= 14.18.0'}
@@ -1629,6 +1778,10 @@ packages:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'} 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: require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@@ -1668,6 +1821,9 @@ packages:
rxjs@7.8.2: rxjs@7.8.2:
resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==}
safe-buffer@5.2.1:
resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
safe-regex2@5.0.0: safe-regex2@5.0.0:
resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==} resolution: {integrity: sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw==}
@@ -1738,10 +1894,17 @@ packages:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
split-ca@1.0.1:
resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==}
split2@4.2.0: split2@4.2.0:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'} engines: {node: '>= 10.x'}
ssh2@1.17.0:
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
engines: {node: '>=10.16.0'}
stackback@0.0.2: stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
@@ -1756,6 +1919,9 @@ packages:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'} engines: {node: '>=8'}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strip-ansi@6.0.1: strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -1764,6 +1930,13 @@ packages:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'} 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: thread-stream@4.0.0:
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
engines: {node: '>=20'} engines: {node: '>=20'}
@@ -1805,6 +1978,9 @@ packages:
engines: {node: '>=18.0.0'} engines: {node: '>=18.0.0'}
hasBin: true hasBin: true
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
type-check@0.4.0: type-check@0.4.0:
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
engines: {node: '>= 0.8.0'} engines: {node: '>= 0.8.0'}
@@ -1818,6 +1994,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
undici-types@7.18.2: undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
@@ -1828,6 +2007,13 @@ packages:
uri-js@4.4.1: uri-js@4.4.1:
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} 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: vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -1924,9 +2110,25 @@ packages:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'} engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
wrappy@1.0.2: wrappy@1.0.2:
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} 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: yocto-queue@0.1.0:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1958,6 +2160,8 @@ snapshots:
'@babel/helper-string-parser': 7.27.1 '@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5 '@babel/helper-validator-identifier': 7.28.5
'@balena/dockerignore@1.0.2': {}
'@bcoe/v8-coverage@1.0.2': {} '@bcoe/v8-coverage@1.0.2': {}
'@esbuild/aix-ppc64@0.27.3': '@esbuild/aix-ppc64@0.27.3':
@@ -2107,6 +2311,25 @@ snapshots:
fastify-plugin: 5.1.0 fastify-plugin: 5.1.0
toad-cache: 3.7.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)': '@hono/node-server@1.19.9(hono@4.12.0)':
dependencies: dependencies:
hono: 4.12.0 hono: 4.12.0
@@ -2256,6 +2479,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2 '@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@js-sdsl/ordered-map@4.4.2': {}
'@lukeed/ms@2.0.2': {} '@lukeed/ms@2.0.2': {}
'@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)': '@modelcontextprotocol/sdk@1.26.0(zod@3.25.76)':
@@ -2317,6 +2542,29 @@ snapshots:
dependencies: dependencies:
'@prisma/debug': 6.19.2 '@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': '@rollup/rollup-android-arm-eabi@4.58.0':
optional: true optional: true
@@ -2401,6 +2649,17 @@ snapshots:
'@types/deep-eql@4.0.2': {} '@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/esrecurse@4.3.1': {}
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
@@ -2409,10 +2668,18 @@ snapshots:
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/node@18.19.130':
dependencies:
undici-types: 5.26.5
'@types/node@25.3.0': '@types/node@25.3.0':
dependencies: dependencies:
undici-types: 7.18.2 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)': '@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: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -2596,6 +2863,10 @@ snapshots:
argparse@2.0.1: {} argparse@2.0.1: {}
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
assertion-error@2.0.1: {} assertion-error@2.0.1: {}
ast-v8-to-istanbul@0.3.11: ast-v8-to-istanbul@0.3.11:
@@ -2615,6 +2886,18 @@ snapshots:
balanced-match@4.0.3: {} 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: body-parser@2.2.2:
dependencies: dependencies:
bytes: 3.1.2 bytes: 3.1.2
@@ -2637,6 +2920,14 @@ snapshots:
dependencies: dependencies:
balanced-match: 4.0.3 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: {} bytes@3.1.2: {}
c12@3.1.0: c12@3.1.0:
@@ -2674,6 +2965,8 @@ snapshots:
dependencies: dependencies:
readdirp: 4.1.2 readdirp: 4.1.2
chownr@1.1.4: {}
citty@0.1.6: citty@0.1.6:
dependencies: dependencies:
consola: 3.4.2 consola: 3.4.2
@@ -2682,6 +2975,12 @@ snapshots:
cli-width@4.1.0: {} 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: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -2709,6 +3008,12 @@ snapshots:
object-assign: 4.1.1 object-assign: 4.1.1
vary: 1.1.2 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: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -2731,6 +3036,27 @@ snapshots:
destr@2.0.5: {} 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: {} dotenv@16.6.1: {}
dunder-proto@1.0.1: dunder-proto@1.0.1:
@@ -2752,6 +3078,10 @@ snapshots:
encodeurl@2.0.0: {} encodeurl@2.0.0: {}
end-of-stream@1.4.5:
dependencies:
once: 1.4.0
es-define-property@1.0.1: {} es-define-property@1.0.1: {}
es-errors@1.3.0: {} es-errors@1.3.0: {}
@@ -2791,6 +3121,8 @@ snapshots:
'@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-ia32': 0.27.3
'@esbuild/win32-x64': 0.27.3 '@esbuild/win32-x64': 0.27.3
escalade@3.2.0: {}
escape-html@1.0.3: {} escape-html@1.0.3: {}
escape-string-regexp@4.0.0: {} escape-string-regexp@4.0.0: {}
@@ -3011,11 +3343,15 @@ snapshots:
fresh@2.0.0: {} fresh@2.0.0: {}
fs-constants@1.0.0: {}
fsevents@2.3.3: fsevents@2.3.3:
optional: true optional: true
function-bind@1.1.2: {} function-bind@1.1.2: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0: get-intrinsic@1.3.0:
dependencies: dependencies:
call-bind-apply-helpers: 1.0.2 call-bind-apply-helpers: 1.0.2
@@ -3085,6 +3421,8 @@ snapshots:
dependencies: dependencies:
safer-buffer: 2.1.2 safer-buffer: 2.1.2
ieee754@1.2.1: {}
ignore@5.3.2: {} ignore@5.3.2: {}
ignore@7.0.5: {} ignore@7.0.5: {}
@@ -3179,6 +3517,10 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.camelcase@4.3.0: {}
long@5.3.2: {}
lru-cache@11.2.6: {} lru-cache@11.2.6: {}
magic-string@0.30.21: magic-string@0.30.21:
@@ -3217,6 +3559,8 @@ snapshots:
minipass@7.1.3: {} minipass@7.1.3: {}
mkdirp-classic@0.5.3: {}
mnemonist@0.40.0: mnemonist@0.40.0:
dependencies: dependencies:
obliterator: 2.0.5 obliterator: 2.0.5
@@ -3225,6 +3569,9 @@ snapshots:
mute-stream@2.0.0: {} mute-stream@2.0.0: {}
nan@2.25.0:
optional: true
nanoid@3.3.11: {} nanoid@3.3.11: {}
natural-compare@1.4.0: {} natural-compare@1.4.0: {}
@@ -3348,11 +3695,31 @@ snapshots:
process-warning@5.0.0: {} 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: proxy-addr@2.0.7:
dependencies: dependencies:
forwarded: 0.2.0 forwarded: 0.2.0
ipaddr.js: 1.9.1 ipaddr.js: 1.9.1
pump@3.0.3:
dependencies:
end-of-stream: 1.4.5
once: 1.4.0
punycode@2.3.1: {} punycode@2.3.1: {}
pure-rand@6.1.0: {} pure-rand@6.1.0: {}
@@ -3377,10 +3744,18 @@ snapshots:
defu: 6.1.4 defu: 6.1.4
destr: 2.0.5 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: {} readdirp@4.1.2: {}
real-require@0.2.0: {} real-require@0.2.0: {}
require-directory@2.1.1: {}
require-from-string@2.0.2: {} require-from-string@2.0.2: {}
resolve-pkg-maps@1.0.0: {} resolve-pkg-maps@1.0.0: {}
@@ -3443,6 +3818,8 @@ snapshots:
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
safe-buffer@5.2.1: {}
safe-regex2@5.0.0: safe-regex2@5.0.0:
dependencies: dependencies:
ret: 0.5.0 ret: 0.5.0
@@ -3528,8 +3905,18 @@ snapshots:
source-map-js@1.2.1: {} source-map-js@1.2.1: {}
split-ca@1.0.1: {}
split2@4.2.0: {} 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: {} stackback@0.0.2: {}
statuses@2.0.2: {} statuses@2.0.2: {}
@@ -3542,6 +3929,10 @@ snapshots:
is-fullwidth-code-point: 3.0.0 is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1 strip-ansi: 6.0.1
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strip-ansi@6.0.1: strip-ansi@6.0.1:
dependencies: dependencies:
ansi-regex: 5.0.1 ansi-regex: 5.0.1
@@ -3550,6 +3941,21 @@ snapshots:
dependencies: dependencies:
has-flag: 4.0.0 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: thread-stream@4.0.0:
dependencies: dependencies:
real-require: 0.2.0 real-require: 0.2.0
@@ -3582,6 +3988,8 @@ snapshots:
optionalDependencies: optionalDependencies:
fsevents: 2.3.3 fsevents: 2.3.3
tweetnacl@0.14.5: {}
type-check@0.4.0: type-check@0.4.0:
dependencies: dependencies:
prelude-ls: 1.2.1 prelude-ls: 1.2.1
@@ -3594,6 +4002,8 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
undici-types@5.26.5: {}
undici-types@7.18.2: {} undici-types@7.18.2: {}
unpipe@1.0.0: {} unpipe@1.0.0: {}
@@ -3602,6 +4012,10 @@ snapshots:
dependencies: dependencies:
punycode: 2.3.1 punycode: 2.3.1
util-deprecate@1.0.2: {}
uuid@10.0.0: {}
vary@1.1.2: {} vary@1.1.2: {}
vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0): vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0):
@@ -3672,8 +4086,28 @@ snapshots:
string-width: 4.2.3 string-width: 4.2.3
strip-ansi: 6.0.1 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: {} 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: {} yocto-queue@0.1.0: {}
yoctocolors-cjs@2.1.3: {} yoctocolors-cjs@2.1.3: {}

View File

@@ -0,0 +1,105 @@
import type { RegistryServer, SearchOptions, RegistryClientConfig, RegistryName } from './types.js';
import { RegistrySource } from './base.js';
import { OfficialRegistrySource } from './sources/official.js';
import { GlamaRegistrySource } from './sources/glama.js';
import { SmitheryRegistrySource } from './sources/smithery.js';
import { RegistryCache } from './cache.js';
import { deduplicateResults } from './dedup.js';
import { rankResults } from './ranking.js';
export class RegistryClient {
private sources: Map<RegistryName, RegistrySource>;
private cache: RegistryCache;
private enabledRegistries: RegistryName[];
private metrics = {
queryLatencies: new Map<string, number[]>(),
errorCounts: new Map<string, number>(),
};
constructor(config: RegistryClientConfig = {}) {
this.enabledRegistries = config.registries ?? ['official', 'glama', 'smithery'];
this.cache = new RegistryCache(config.cacheTTLMs);
this.sources = new Map<RegistryName, RegistrySource>([
['official', new OfficialRegistrySource()],
['glama', new GlamaRegistrySource()],
['smithery', new SmitheryRegistrySource()],
]);
}
async search(options: SearchOptions): Promise<RegistryServer[]> {
// Check cache
const cached = this.cache.get(options.query, options);
if (cached !== null) {
return cached;
}
const registries = options.registries ?? this.enabledRegistries;
const limit = options.limit ?? 20;
// Query all enabled registries in parallel
const promises = registries
.map((name) => this.sources.get(name))
.filter((source): source is RegistrySource => source !== undefined)
.map(async (source) => {
const start = Date.now();
try {
const results = await source.search(options.query, limit);
this.recordLatency(source.name, Date.now() - start);
return results;
} catch (error) {
this.recordError(source.name);
// Graceful degradation: log and continue
return [];
}
});
const settled = await Promise.all(promises);
let combined = settled.flat();
// Apply filters
if (options.verified === true) {
combined = combined.filter((s) => s.verified);
}
if (options.transport !== undefined) {
combined = combined.filter((s) => s.transport === options.transport);
}
// Deduplicate, rank, and limit
const deduped = deduplicateResults(combined);
const ranked = rankResults(deduped, options.query);
const results = ranked.slice(0, limit);
// Cache results
this.cache.set(options.query, options, results);
return results;
}
getCacheMetrics(): { hits: number; misses: number; ratio: number } {
return this.cache.getHitRatio();
}
getQueryLatencies(): Map<string, number[]> {
return new Map(this.metrics.queryLatencies);
}
getErrorCounts(): Map<string, number> {
return new Map(this.metrics.errorCounts);
}
clearCache(): void {
this.cache.clear();
}
private recordLatency(source: string, ms: number): void {
const existing = this.metrics.queryLatencies.get(source) ?? [];
existing.push(ms);
this.metrics.queryLatencies.set(source, existing);
}
private recordError(source: string): void {
const count = this.metrics.errorCounts.get(source) ?? 0;
this.metrics.errorCounts.set(source, count + 1);
}
}

View File

@@ -0,0 +1,17 @@
export { RegistryClient } from './client.js';
export { RegistryCache } from './cache.js';
export { RegistrySource } from './base.js';
export { deduplicateResults } from './dedup.js';
export { rankResults } from './ranking.js';
export { withRetry } from './retry.js';
export { OfficialRegistrySource } from './sources/official.js';
export { GlamaRegistrySource } from './sources/glama.js';
export { SmitheryRegistrySource } from './sources/smithery.js';
export type {
RegistryServer,
SearchOptions,
RegistryClientConfig,
RegistryName,
EnvVar,
} from './types.js';
export { sanitizeString } from './types.js';

View File

@@ -0,0 +1,63 @@
import type { RegistryServer } from './types.js';
const WEIGHT_RELEVANCE = 0.4;
const WEIGHT_POPULARITY = 0.3;
const WEIGHT_VERIFIED = 0.2;
const WEIGHT_RECENCY = 0.1;
function textRelevance(server: RegistryServer, query: string): number {
const q = query.toLowerCase();
const name = server.name.toLowerCase();
const desc = server.description.toLowerCase();
// Exact name match
if (name === q) return 1.0;
// Name starts with query
if (name.startsWith(q)) return 0.9;
// Name contains query
if (name.includes(q)) return 0.7;
// Description contains query
if (desc.includes(q)) return 0.4;
// Word-level matching
const queryWords = q.split(/\s+/);
const matchCount = queryWords.filter(
(w) => name.includes(w) || desc.includes(w),
).length;
return queryWords.length > 0 ? (matchCount / queryWords.length) * 0.3 : 0;
}
function popularityScore(server: RegistryServer): number {
// Normalize to 0-1 range; use log scale since popularity can vary hugely
if (server.popularityScore <= 0) return 0;
// Log scale: log10(1) = 0, log10(10000) ≈ 4 → normalize to 0-1 with cap at 100k
return Math.min(Math.log10(server.popularityScore + 1) / 5, 1.0);
}
function verifiedScore(server: RegistryServer): number {
return server.verified ? 1.0 : 0;
}
function recencyScore(server: RegistryServer): number {
if (server.lastUpdated === undefined) return 0.5; // Unknown = middle score
const ageMs = Date.now() - server.lastUpdated.getTime();
const ageDays = ageMs / (1000 * 60 * 60 * 24);
// Less than 30 days = 1.0, decays to 0 at 365 days
return Math.max(0, 1 - ageDays / 365);
}
function computeScore(server: RegistryServer, query: string): number {
return (
WEIGHT_RELEVANCE * textRelevance(server, query) +
WEIGHT_POPULARITY * popularityScore(server) +
WEIGHT_VERIFIED * verifiedScore(server) +
WEIGHT_RECENCY * recencyScore(server)
);
}
export function rankResults(
results: RegistryServer[],
query: string,
): RegistryServer[] {
return [...results].sort((a, b) => computeScore(b, query) - computeScore(a, query));
}

View File

@@ -0,0 +1,16 @@
export async function withRetry<T>(
fn: () => Promise<T>,
maxRetries = 3,
baseDelay = 1000,
): Promise<T> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === maxRetries - 1) throw error;
const delay = baseDelay * Math.pow(2, attempt) + Math.random() * 1000;
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error('Unreachable');
}

View File

@@ -0,0 +1,92 @@
import { RegistrySource } from '../base.js';
import {
GlamaRegistryResponseSchema,
sanitizeString,
type GlamaServerEntry,
type RegistryServer,
} from '../types.js';
import { withRetry } from '../retry.js';
const BASE_URL = 'https://glama.ai/api/mcp/v1/servers';
export class GlamaRegistrySource extends RegistrySource {
readonly name = 'glama' as const;
async search(query: string, limit: number): Promise<RegistryServer[]> {
const results: RegistryServer[] = [];
let cursor: string | null | undefined;
while (results.length < limit) {
const url = new URL(BASE_URL);
url.searchParams.set('query', query);
if (cursor !== undefined && cursor !== null) {
url.searchParams.set('after', cursor);
}
const response = await withRetry(() => fetch(url.toString()));
if (!response.ok) {
throw new Error(`Glama registry returned ${String(response.status)}`);
}
const raw: unknown = await response.json();
const parsed = GlamaRegistryResponseSchema.parse(raw);
for (const entry of parsed.servers) {
results.push(this.normalizeResult(entry));
}
if (!parsed.pageInfo.hasNextPage || parsed.servers.length === 0) break;
cursor = parsed.pageInfo.endCursor;
}
return results.slice(0, limit);
}
protected normalizeResult(raw: unknown): RegistryServer {
const entry = raw as GlamaServerEntry;
// Extract env vars from JSON Schema
const props = entry.environmentVariablesJsonSchema?.properties ?? {};
const envTemplate = Object.entries(props).map(([name, schemaProp]) => {
const envVar: import('../types.js').EnvVar = {
name,
description: sanitizeString(schemaProp.description ?? ''),
isSecret: name.toLowerCase().includes('token') ||
name.toLowerCase().includes('secret') ||
name.toLowerCase().includes('password') ||
name.toLowerCase().includes('key'),
};
if (schemaProp.default !== undefined) {
envVar.defaultValue = schemaProp.default;
}
return envVar;
});
// Determine transport from attributes
const attrs = entry.attributes;
let transport: RegistryServer['transport'] = 'stdio';
if (attrs.includes('hosting:remote-capable') || attrs.includes('hosting:hybrid')) {
transport = 'sse';
}
const packages: RegistryServer['packages'] = {};
if (entry.slug !== '') {
packages.npm = entry.slug;
}
const result: RegistryServer = {
name: sanitizeString(entry.name),
description: sanitizeString(entry.description),
packages,
envTemplate,
transport,
popularityScore: 0, // Glama has no popularity metrics in list
verified: attrs.includes('author:official'),
sourceRegistry: 'glama',
};
if (entry.repository?.url !== undefined) {
result.repositoryUrl = entry.repository.url;
}
return result;
}
}

View File

@@ -0,0 +1,106 @@
import { RegistrySource } from '../base.js';
import {
OfficialRegistryResponseSchema,
sanitizeString,
type OfficialServerEntry,
type RegistryServer,
} from '../types.js';
import { withRetry } from '../retry.js';
const BASE_URL = 'https://registry.modelcontextprotocol.io/v0/servers';
export class OfficialRegistrySource extends RegistrySource {
readonly name = 'official' as const;
async search(query: string, limit: number): Promise<RegistryServer[]> {
const results: RegistryServer[] = [];
let cursor: string | null | undefined;
while (results.length < limit) {
const url = new URL(BASE_URL);
url.searchParams.set('search', query);
url.searchParams.set('limit', String(Math.min(limit - results.length, 100)));
if (cursor !== undefined && cursor !== null) {
url.searchParams.set('cursor', cursor);
}
const response = await withRetry(() => fetch(url.toString()));
if (!response.ok) {
throw new Error(`Official registry returned ${String(response.status)}`);
}
const raw: unknown = await response.json();
const parsed = OfficialRegistryResponseSchema.parse(raw);
for (const entry of parsed.servers) {
results.push(this.normalizeResult(entry));
}
cursor = parsed.metadata?.nextCursor;
if (cursor === null || cursor === undefined || parsed.servers.length === 0) break;
}
return results.slice(0, limit);
}
protected normalizeResult(raw: unknown): RegistryServer {
const entry = raw as OfficialServerEntry;
const server = entry.server;
// Extract env vars from packages
const envTemplate = server.packages.flatMap((pkg: { environmentVariables: Array<{ name: string; description?: string; isSecret?: boolean }> }) =>
pkg.environmentVariables.map((ev: { name: string; description?: string; isSecret?: boolean }) => ({
name: ev.name,
description: sanitizeString(ev.description ?? ''),
isSecret: ev.isSecret ?? false,
})),
);
// Determine transport from packages or remotes
let transport: RegistryServer['transport'] = 'stdio';
if (server.packages.length > 0) {
const pkgTransport = server.packages[0]?.transport?.type;
if (pkgTransport === 'stdio') transport = 'stdio';
}
if (server.remotes.length > 0) {
const remoteType = server.remotes[0]?.type;
if (remoteType === 'sse') transport = 'sse';
else if (remoteType === 'streamable-http') transport = 'streamable-http';
}
// Extract npm package identifier
const npmPkg = server.packages.find((p: { registryType: string }) => p.registryType === 'npm');
const dockerPkg = server.packages.find((p: { registryType: string }) => p.registryType === 'oci');
// Extract dates from _meta
const meta = entry._meta as Record<string, Record<string, unknown>> | undefined;
const officialMeta = meta?.['io.modelcontextprotocol.registry/official'];
const updatedAt = officialMeta?.['updatedAt'];
const packages: RegistryServer['packages'] = {};
if (npmPkg !== undefined) {
packages.npm = npmPkg.identifier;
}
if (dockerPkg !== undefined) {
packages.docker = dockerPkg.identifier;
}
const result: RegistryServer = {
name: sanitizeString(server.title ?? server.name),
description: sanitizeString(server.description),
packages,
envTemplate,
transport,
popularityScore: 0, // Official registry has no popularity data
verified: false, // Official registry has no verified badges
sourceRegistry: 'official',
};
if (server.repository?.url !== undefined) {
result.repositoryUrl = server.repository.url;
}
if (typeof updatedAt === 'string') {
result.lastUpdated = new Date(updatedAt);
}
return result;
}
}

View File

@@ -0,0 +1,62 @@
import { RegistrySource } from '../base.js';
import {
SmitheryRegistryResponseSchema,
sanitizeString,
type SmitheryServerEntry,
type RegistryServer,
} from '../types.js';
import { withRetry } from '../retry.js';
const BASE_URL = 'https://registry.smithery.ai/servers';
export class SmitheryRegistrySource extends RegistrySource {
readonly name = 'smithery' as const;
async search(query: string, limit: number): Promise<RegistryServer[]> {
const results: RegistryServer[] = [];
let page = 1;
while (results.length < limit) {
const url = new URL(BASE_URL);
url.searchParams.set('q', query);
url.searchParams.set('pageSize', String(Math.min(limit - results.length, 50)));
url.searchParams.set('page', String(page));
const response = await withRetry(() => fetch(url.toString()));
if (!response.ok) {
throw new Error(`Smithery registry returned ${String(response.status)}`);
}
const raw: unknown = await response.json();
const parsed = SmitheryRegistryResponseSchema.parse(raw);
for (const entry of parsed.servers) {
results.push(this.normalizeResult(entry));
}
if (page >= parsed.pagination.totalPages || parsed.servers.length === 0) break;
page++;
}
return results.slice(0, limit);
}
protected normalizeResult(raw: unknown): RegistryServer {
const entry = raw as SmitheryServerEntry;
const result: RegistryServer = {
name: sanitizeString(entry.displayName !== '' ? entry.displayName : entry.qualifiedName),
description: sanitizeString(entry.description),
packages: {},
envTemplate: [], // Smithery doesn't include env vars in list view
transport: entry.remote ? 'sse' : 'stdio',
popularityScore: entry.useCount,
verified: entry.verified,
sourceRegistry: 'smithery',
};
if (entry.createdAt !== undefined) {
result.lastUpdated = new Date(entry.createdAt);
}
return result;
}
}

View File

@@ -173,7 +173,7 @@ export type SmitheryServerEntry = z.infer<typeof SmitheryServerSchema>;
// ── Security utilities ── // ── Security utilities ──
const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*[a-zA-Z]|[\x00-\x08\x0B\x0C\x0E-\x1F]/g; const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*[a-zA-Z]|[\x00-\x08\x0B\x0C\x0E-\x1A\x1C-\x1F]|\x1b/g;
export function sanitizeString(text: string): string { export function sanitizeString(text: string): string {
return text.replace(ANSI_ESCAPE_RE, ''); return text.replace(ANSI_ESCAPE_RE, '');

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { RegistryCache } from '../../src/registry/cache.js';
import type { RegistryServer, SearchOptions } from '../../src/registry/types.js';
function makeServer(name: string): RegistryServer {
return {
name,
description: `${name} server`,
packages: {},
envTemplate: [],
transport: 'stdio',
popularityScore: 0,
verified: false,
sourceRegistry: 'official',
};
}
const defaultOptions: SearchOptions = { query: 'test' };
describe('RegistryCache', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns null for cache miss', () => {
const cache = new RegistryCache();
expect(cache.get('unknown', defaultOptions)).toBeNull();
});
it('returns data for cache hit within TTL', () => {
const cache = new RegistryCache();
const data = [makeServer('test')];
cache.set('test', defaultOptions, data);
expect(cache.get('test', defaultOptions)).toEqual(data);
});
it('returns null after TTL expires', () => {
const cache = new RegistryCache(1000); // 1 second TTL
cache.set('test', defaultOptions, [makeServer('test')]);
vi.advanceTimersByTime(1001);
expect(cache.get('test', defaultOptions)).toBeNull();
});
it('generates deterministic cache keys', () => {
const cache = new RegistryCache();
const data = [makeServer('test')];
cache.set('query', { query: 'query', limit: 10 }, data);
expect(cache.get('query', { query: 'query', limit: 10 })).toEqual(data);
});
it('generates different keys for different queries', () => {
const cache = new RegistryCache();
cache.set('a', { query: 'a' }, [makeServer('a')]);
expect(cache.get('b', { query: 'b' })).toBeNull();
});
it('tracks hits and misses correctly', () => {
const cache = new RegistryCache();
cache.set('test', defaultOptions, [makeServer('test')]);
cache.get('test', defaultOptions); // hit
cache.get('test', defaultOptions); // hit
cache.get('miss', { query: 'miss' }); // miss
const ratio = cache.getHitRatio();
expect(ratio.hits).toBe(2);
expect(ratio.misses).toBe(1);
expect(ratio.ratio).toBeCloseTo(2 / 3);
});
it('returns 0 ratio when no accesses', () => {
const cache = new RegistryCache();
expect(cache.getHitRatio().ratio).toBe(0);
});
it('clears all entries and resets metrics', () => {
const cache = new RegistryCache();
cache.set('a', { query: 'a' }, [makeServer('a')]);
cache.get('a', { query: 'a' }); // hit
cache.clear();
expect(cache.get('a', { query: 'a' })).toBeNull();
expect(cache.size).toBe(0);
expect(cache.getHitRatio().hits).toBe(0);
});
});

View File

@@ -0,0 +1,282 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { RegistryClient } from '../../src/registry/client.js';
import type { RegistryServer } from '../../src/registry/types.js';
function makeServer(name: string, source: 'official' | 'glama' | 'smithery'): RegistryServer {
return {
name,
description: `${name} description`,
packages: { npm: `@test/${name}` },
envTemplate: [],
transport: 'stdio',
popularityScore: 50,
verified: source === 'smithery',
sourceRegistry: source,
};
}
// Mock fetch globally
const mockFetch = vi.fn();
beforeEach(() => {
vi.stubGlobal('fetch', mockFetch);
mockFetch.mockReset();
});
function mockRegistryResponse(source: string, servers: RegistryServer[]): void {
mockFetch.mockImplementation((url: string) => {
if (url.includes('registry.modelcontextprotocol.io')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
servers: servers
.filter((s) => s.sourceRegistry === 'official')
.map((s) => ({
server: {
name: s.name,
description: s.description,
packages: s.packages.npm !== undefined ? [{
registryType: 'npm',
identifier: s.packages.npm,
transport: { type: 'stdio' },
environmentVariables: [],
}] : [],
remotes: [],
},
})),
metadata: { nextCursor: null, count: 1 },
}),
});
}
if (url.includes('glama.ai')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
servers: servers
.filter((s) => s.sourceRegistry === 'glama')
.map((s) => ({
id: s.name,
name: s.name,
description: s.description,
attributes: [],
slug: s.packages.npm ?? '',
})),
pageInfo: { hasNextPage: false, hasPreviousPage: false },
}),
});
}
if (url.includes('registry.smithery.ai')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
servers: servers
.filter((s) => s.sourceRegistry === 'smithery')
.map((s) => ({
qualifiedName: s.name,
displayName: s.name,
description: s.description,
verified: s.verified,
useCount: s.popularityScore,
remote: false,
})),
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 1 },
}),
});
}
return Promise.reject(new Error(`Unexpected URL: ${url}`));
});
}
describe('RegistryClient', () => {
it('queries all enabled registries', async () => {
const testServers = [
makeServer('slack-official', 'official'),
makeServer('slack-glama', 'glama'),
makeServer('slack-smithery', 'smithery'),
];
mockRegistryResponse('all', testServers);
const client = new RegistryClient();
const results = await client.search({ query: 'slack' });
expect(results.length).toBeGreaterThan(0);
expect(mockFetch).toHaveBeenCalledTimes(3);
});
it('uses cached results on second call', async () => {
mockRegistryResponse('all', [makeServer('slack', 'official')]);
const client = new RegistryClient();
await client.search({ query: 'slack' });
mockFetch.mockClear();
await client.search({ query: 'slack' });
expect(mockFetch).not.toHaveBeenCalled();
});
it('filters by registry when specified', async () => {
mockRegistryResponse('all', [makeServer('test', 'official')]);
const client = new RegistryClient();
await client.search({ query: 'test', registries: ['official'] });
expect(mockFetch).toHaveBeenCalledTimes(1);
const calledUrl = mockFetch.mock.calls[0]?.[0] as string;
expect(calledUrl).toContain('modelcontextprotocol.io');
});
it('handles partial failures gracefully', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.includes('glama.ai')) {
return Promise.reject(new Error('Network error'));
}
if (url.includes('registry.smithery.ai')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
servers: [{
qualifiedName: 'slack',
displayName: 'Slack',
description: 'Slack',
verified: true,
useCount: 100,
remote: false,
}],
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 1 },
}),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
servers: [],
metadata: { nextCursor: null },
}),
});
});
const client = new RegistryClient();
const results = await client.search({ query: 'slack' });
// Should still return results from successful sources
expect(results.length).toBeGreaterThan(0);
});
it('records error counts on failures', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.includes('glama.ai')) {
return Promise.reject(new Error('fail'));
}
// Return empty for others
if (url.includes('modelcontextprotocol')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ servers: [], metadata: { nextCursor: null } }),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
servers: [],
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 0 },
}),
});
});
const client = new RegistryClient();
await client.search({ query: 'test' });
const errors = client.getErrorCounts();
expect(errors.get('glama')).toBe(1);
});
it('filters by verified when specified', async () => {
mockFetch.mockImplementation((url: string) => {
if (url.includes('registry.smithery.ai')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
servers: [
{ qualifiedName: 'verified', displayName: 'Verified', description: '', verified: true, useCount: 100, remote: false },
{ qualifiedName: 'unverified', displayName: 'Unverified', description: '', verified: false, useCount: 50, remote: false },
],
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 2 },
}),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ servers: [], metadata: { nextCursor: null } }),
});
});
// Mock glama too
mockFetch.mockImplementation((url: string) => {
if (url.includes('registry.smithery.ai')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({
servers: [
{ qualifiedName: 'verified', displayName: 'Verified', description: '', verified: true, useCount: 100, remote: false },
{ qualifiedName: 'unverified', displayName: 'Unverified', description: '', verified: false, useCount: 50, remote: false },
],
pagination: { currentPage: 1, pageSize: 20, totalPages: 1, totalCount: 2 },
}),
});
}
if (url.includes('glama.ai')) {
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ servers: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } }),
});
}
return Promise.resolve({
ok: true,
json: () => Promise.resolve({ servers: [], metadata: { nextCursor: null } }),
});
});
const client = new RegistryClient();
const results = await client.search({ query: 'test', verified: true });
for (const r of results) {
expect(r.verified).toBe(true);
}
});
it('respects limit option', async () => {
mockRegistryResponse('all', [
makeServer('a', 'official'),
makeServer('b', 'glama'),
makeServer('c', 'smithery'),
]);
const client = new RegistryClient();
const results = await client.search({ query: 'test', limit: 1 });
expect(results.length).toBeLessThanOrEqual(1);
});
it('records latency metrics', async () => {
mockRegistryResponse('all', [makeServer('test', 'official')]);
const client = new RegistryClient();
await client.search({ query: 'test' });
const latencies = client.getQueryLatencies();
expect(latencies.size).toBeGreaterThan(0);
});
it('clearCache empties cache', async () => {
mockRegistryResponse('all', [makeServer('test', 'official')]);
const client = new RegistryClient();
await client.search({ query: 'test' });
client.clearCache();
mockFetch.mockClear();
mockRegistryResponse('all', [makeServer('test', 'official')]);
await client.search({ query: 'test' });
// Should have fetched again after cache clear
expect(mockFetch).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,105 @@
import { describe, it, expect } from 'vitest';
import { deduplicateResults } from '../../src/registry/dedup.js';
import type { RegistryServer } from '../../src/registry/types.js';
function makeServer(overrides: Partial<RegistryServer> = {}): RegistryServer {
return {
name: 'test-server',
description: 'A test server',
packages: {},
envTemplate: [],
transport: 'stdio',
popularityScore: 0,
verified: false,
sourceRegistry: 'official',
...overrides,
};
}
describe('deduplicateResults', () => {
it('keeps unique servers', () => {
const servers = [
makeServer({ name: 'server-a', packages: { npm: 'pkg-a' } }),
makeServer({ name: 'server-b', packages: { npm: 'pkg-b' } }),
];
expect(deduplicateResults(servers)).toHaveLength(2);
});
it('deduplicates by npm package name, keeps higher popularity', () => {
const servers = [
makeServer({ name: 'low', packages: { npm: '@test/slack' }, popularityScore: 10, sourceRegistry: 'official' }),
makeServer({ name: 'high', packages: { npm: '@test/slack' }, popularityScore: 100, sourceRegistry: 'smithery' }),
];
const result = deduplicateResults(servers);
expect(result).toHaveLength(1);
expect(result[0]?.name).toBe('high');
expect(result[0]?.popularityScore).toBe(100);
});
it('deduplicates by GitHub URL with different formats', () => {
const servers = [
makeServer({ name: 'a', repositoryUrl: 'https://github.com/org/repo', popularityScore: 5 }),
makeServer({ name: 'b', repositoryUrl: 'git@github.com:org/repo.git', popularityScore: 50 }),
];
const result = deduplicateResults(servers);
expect(result).toHaveLength(1);
expect(result[0]?.name).toBe('b');
});
it('merges envTemplate from both sources', () => {
const servers = [
makeServer({
name: 'a',
packages: { npm: 'pkg' },
envTemplate: [{ name: 'TOKEN', description: 'API token', isSecret: true }],
popularityScore: 10,
}),
makeServer({
name: 'b',
packages: { npm: 'pkg' },
envTemplate: [{ name: 'URL', description: 'Base URL', isSecret: false }],
popularityScore: 5,
}),
];
const result = deduplicateResults(servers);
expect(result).toHaveLength(1);
expect(result[0]?.envTemplate).toHaveLength(2);
expect(result[0]?.envTemplate.map((e) => e.name)).toContain('TOKEN');
expect(result[0]?.envTemplate.map((e) => e.name)).toContain('URL');
});
it('deduplicates envTemplate by var name', () => {
const servers = [
makeServer({
packages: { npm: 'pkg' },
envTemplate: [{ name: 'TOKEN', description: 'from a', isSecret: true }],
popularityScore: 10,
}),
makeServer({
packages: { npm: 'pkg' },
envTemplate: [{ name: 'TOKEN', description: 'from b', isSecret: true }],
popularityScore: 5,
}),
];
const result = deduplicateResults(servers);
expect(result[0]?.envTemplate).toHaveLength(1);
});
it('merges verified status (OR)', () => {
const servers = [
makeServer({ packages: { npm: 'pkg' }, verified: true, popularityScore: 10 }),
makeServer({ packages: { npm: 'pkg' }, verified: false, popularityScore: 5 }),
];
const result = deduplicateResults(servers);
expect(result[0]?.verified).toBe(true);
});
it('handles servers with no npm or repo', () => {
const servers = [
makeServer({ name: 'a' }),
makeServer({ name: 'b' }),
];
// No matching key → no dedup
expect(deduplicateResults(servers)).toHaveLength(2);
});
});

View File

@@ -0,0 +1,91 @@
import { describe, it, expect } from 'vitest';
import { rankResults } from '../../src/registry/ranking.js';
import type { RegistryServer } from '../../src/registry/types.js';
function makeServer(overrides: Partial<RegistryServer> = {}): RegistryServer {
return {
name: 'test-server',
description: 'A test server',
packages: {},
envTemplate: [],
transport: 'stdio',
popularityScore: 0,
verified: false,
sourceRegistry: 'official',
...overrides,
};
}
describe('rankResults', () => {
it('puts exact name match first', () => {
const servers = [
makeServer({ name: 'slack-extended-tools' }),
makeServer({ name: 'slack' }),
makeServer({ name: 'my-slack-bot' }),
];
const ranked = rankResults(servers, 'slack');
expect(ranked[0]?.name).toBe('slack');
});
it('ranks verified servers higher than unverified', () => {
const servers = [
makeServer({ name: 'server-a', verified: false }),
makeServer({ name: 'server-b', verified: true }),
];
const ranked = rankResults(servers, 'server');
expect(ranked[0]?.name).toBe('server-b');
});
it('ranks popular servers higher', () => {
const servers = [
makeServer({ name: 'unpopular', popularityScore: 1 }),
makeServer({ name: 'popular', popularityScore: 10000 }),
];
const ranked = rankResults(servers, 'test');
expect(ranked[0]?.name).toBe('popular');
});
it('considers recency', () => {
const recent = new Date();
const old = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const servers = [
makeServer({ name: 'old-server', lastUpdated: old }),
makeServer({ name: 'new-server', lastUpdated: recent }),
];
const ranked = rankResults(servers, 'test');
expect(ranked[0]?.name).toBe('new-server');
});
it('handles missing lastUpdated gracefully', () => {
const servers = [
makeServer({ name: 'no-date' }),
makeServer({ name: 'has-date', lastUpdated: new Date() }),
];
// Should not throw
const ranked = rankResults(servers, 'test');
expect(ranked).toHaveLength(2);
});
it('produces stable ordering for identical scores', () => {
const servers = Array.from({ length: 10 }, (_, i) =>
makeServer({ name: `server-${String(i)}` }),
);
const ranked1 = rankResults(servers, 'test');
const ranked2 = rankResults(servers, 'test');
expect(ranked1.map((s) => s.name)).toEqual(ranked2.map((s) => s.name));
});
it('returns empty array for empty input', () => {
expect(rankResults([], 'test')).toEqual([]);
});
it('does not mutate original array', () => {
const servers = [
makeServer({ name: 'b' }),
makeServer({ name: 'a' }),
];
const original = [...servers];
rankResults(servers, 'test');
expect(servers.map((s) => s.name)).toEqual(original.map((s) => s.name));
});
});

View File

@@ -20,10 +20,12 @@
"@mcpctl/db": "workspace:*", "@mcpctl/db": "workspace:*",
"@mcpctl/shared": "workspace:*", "@mcpctl/shared": "workspace:*",
"@prisma/client": "^6.0.0", "@prisma/client": "^6.0.0",
"dockerode": "^4.0.9",
"fastify": "^5.0.0", "fastify": "^5.0.0",
"zod": "^3.24.0" "zod": "^3.24.0"
}, },
"devDependencies": { "devDependencies": {
"@types/dockerode": "^4.0.1",
"@types/node": "^25.3.0" "@types/node": "^25.3.0"
} }
} }

View File

@@ -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 { McpServerRepository } from './mcp-server.repository.js';
export { McpProfileRepository } from './mcp-profile.repository.js'; export { McpProfileRepository } from './mcp-profile.repository.js';
export type { IProjectRepository } from './project.repository.js'; export type { IProjectRepository } from './project.repository.js';
export { ProjectRepository } from './project.repository.js'; export { ProjectRepository } from './project.repository.js';
export { McpInstanceRepository } from './mcp-instance.repository.js';

View File

@@ -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 { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js'; import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
@@ -11,6 +11,15 @@ export interface IMcpServerRepository {
delete(id: string): Promise<void>; 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 { export interface IMcpProfileRepository {
findAll(serverId?: string): Promise<McpProfile[]>; findAll(serverId?: string): Promise<McpProfile[]>;
findById(id: string): Promise<McpProfile | null>; findById(id: string): Promise<McpProfile | null>;

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

@@ -3,3 +3,4 @@ export type { HealthDeps } from './health.js';
export { registerMcpServerRoutes } from './mcp-servers.js'; export { registerMcpServerRoutes } from './mcp-servers.js';
export { registerMcpProfileRoutes } from './mcp-profiles.js'; export { registerMcpProfileRoutes } from './mcp-profiles.js';
export { registerProjectRoutes } from './projects.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,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

@@ -1,5 +1,9 @@
export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js'; export { McpServerService, NotFoundError, ConflictError } from './mcp-server.service.js';
export { McpProfileService } from './mcp-profile.service.js'; export { McpProfileService } from './mcp-profile.service.js';
export { ProjectService } from './project.service.js'; export { ProjectService } from './project.service.js';
export { InstanceService } from './instance.service.js';
export { generateMcpConfig } from './mcp-config-generator.js'; export { generateMcpConfig } from './mcp-config-generator.js';
export type { McpConfig, McpConfigServer, ProfileWithServer } 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,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,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,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');
});
});
});