Compare commits

...

88 Commits

Author SHA1 Message Date
Michal
11da8b1fbf feat: persistent Gemini ACP provider + status spinner
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Replace per-call gemini CLI spawning (~10s cold start each time) with
persistent ACP (Agent Client Protocol) subprocess. First call absorbs
the cold start, subsequent calls are near-instant over JSON-RPC stdio.

- Add AcpClient: manages persistent gemini --experimental-acp subprocess
  with lazy init, auto-restart on crash/timeout, NDJSON framing
- Add GeminiAcpProvider: LlmProvider wrapper with serial queue for
  concurrent calls, same interface as GeminiCliProvider
- Add dispose() to LlmProvider interface + disposeAll() to registry
- Wire provider disposal into mcplocal shutdown handler
- Add status command spinner with progressive output and color-coded
  LLM health check results (green checkmark/red cross)
- 25 new tests (17 ACP client + 8 provider)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:52:04 +00:00
Michal
848868d45f feat: auto-detect gemini binary path, LLM health check in status
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
- Setup wizard auto-detects gemini binary via `which`, saves full path
  so systemd service can find it without user PATH
- `mcpctl status` tests LLM provider health (gemini: quick prompt test,
  ollama: health check, API providers: key stored confirmation)
- Shows error details inline: "gemini-cli / gemini-2.5-flash (not authenticated)"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:24:31 +00:00
Michal
869217a07a fix: exactOptionalPropertyTypes and ResponsePaginator type errors
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 23:15:15 +00:00
04d115933b Merge pull request 'feat: LLM provider configuration, secret store, and setup wizard' (#39) from feat/llm-config-and-secrets into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-24 22:48:39 +00:00
Michal
7c23da10c6 feat: LLM provider configuration, secret store, and setup wizard
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Add secure credential storage (GNOME Keyring + file fallback),
LLM provider config in ~/.mcpctl/config.json, interactive setup
wizard (mcpctl config setup), and wire configured provider into
mcplocal for smart pagination summaries.

- Secret store: SecretStore interface, GnomeKeyringStore, FileSecretStore
- Config schema: LlmConfigSchema with provider/model/url/binaryPath
- Setup wizard: arrow-key provider/model selection, dynamic model fetch
- Provider factory: creates ProviderRegistry from config + secrets
- Status: shows LLM line with hint when not configured
- 572 tests passing across all packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 22:48:17 +00:00
32b4de4343 Merge pull request 'feat: smart response pagination for large MCP tool results' (#38) from feat/response-pagination into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-24 21:40:53 +00:00
Michal
e06db9afba feat: smart response pagination for large MCP tool results
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Intercepts oversized tool responses (>80K chars), caches them, and returns
a page index. LLM can fetch specific pages via _resultId/_page params.
Supports LLM-generated smart summaries with simple fallback.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 21:40:33 +00:00
Michal
a25809b84a fix: auto-read user credentials for mcpd auth
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
mcplocal now reads ~/.mcpctl/credentials automatically when
MCPLOCAL_MCPD_TOKEN env var is not set, matching CLI behavior.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:14:56 +00:00
f5a902d3e0 Merge pull request 'fix: STDIO transport stdout flush and MCP notification handling' (#37) from fix/stdio-flush-and-notifications into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-24 19:10:03 +00:00
Michal
9cb0c5ce24 fix: STDIO transport stdout flush and MCP notification handling
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Wait for stdout.write callback before process.exit in STDIO transport
  to prevent truncation of large responses (e.g. grafana tools/list)
- Handle MCP notification methods (notifications/initialized, etc.) in
  router instead of returning "Method not found" error
- Use -p shorthand in config claude output

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 19:09:47 +00:00
06230ec034 Merge pull request 'feat: prompt resources, proxy transport fix, enriched descriptions' (#36) from feat/prompt-resources-and-proxy-transport into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-24 14:53:24 +00:00
Michal
079c7b3dfa feat: add prompt resources, fix MCP proxy transport, enrich tool descriptions
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Fix MCP proxy to support SSE and STDIO transports (not just HTTP POST)
- Enrich tool descriptions with server context for LLM clarity
- Add Prompt and PromptRequest resources with two-resource RBAC model
- Add propose_prompt MCP tool for LLM to create pending prompt requests
- Add prompt resources visible in MCP resources/list (approved + session's pending)
- Add project-level prompt/instructions in MCP initialize response
- Add ServiceAccount subject type for RBAC (SA identity from X-Service-Account header)
- Add CLI commands: create prompt, get prompts/promptrequests, approve promptrequest
- Add prompts to apply config schema
- 956 tests passing across all packages

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 14:53:00 +00:00
Michal
7829f4fb92 fix: handle SSE responses in MCP bridge and add Commander-level tests
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
The bridge now parses SSE text/event-stream responses (extracting data:
lines) in addition to plain JSON. Also sends correct Accept header
per MCP streamable HTTP spec. Added tests for SSE handling and
command option parsing (-p/--project).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:17:45 +00:00
Michal
fa6240107f fix: mcp command accepts --project directly for Claude spawned processes
The mcp subcommand now has its own -p/--project option with
passThroughOptions(), so `mcpctl mcp --project NAME` works when Claude
spawns the process. Updated config claude to generate
args: ['mcp', '--project', project] and added Commander-level tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 10:14:16 +00:00
b34ea63d3d Merge pull request 'feat: add mcpctl mcp STDIO bridge, rework config claude' (#35) from feat/mcp-stdio-bridge into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-24 00:52:21 +00:00
Michal
e17a2282e8 feat: add mcpctl mcp STDIO bridge, rework config claude
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- New `mcpctl mcp -p PROJECT` command: STDIO-to-StreamableHTTP bridge
  that reads JSON-RPC from stdin and forwards to mcplocal project endpoint
- Rework `config claude` to write mcpctl mcp entry instead of fetching
  server configs from API (no secrets in .mcp.json)
- Keep `config claude-generate` as backward-compat alias
- Fix discovery.ts auth token not being forwarded to mcpd (RBAC bypass)
- Update fish/bash completions for new commands
- 10 new MCP bridge tests, updated claude tests, fixed project-discovery test

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 00:52:05 +00:00
01d3c4e02d Merge pull request 'fix: don't send Content-Type on bodyless DELETE, include full server data in project queries' (#34) from fix/delete-content-type-and-project-servers into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:55:35 +00:00
Michal
e4affe5962 fix: don't send Content-Type on bodyless DELETE, include full server data in project queries
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Only set Content-Type: application/json when request body is present (fixes
  Fastify rejecting empty DELETE with "Body cannot be empty" 400 error)
- Changed PROJECT_INCLUDE to return full server objects instead of just {id, name}
  so project server listings show transport, package, image columns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:54:34 +00:00
c75e7cdf4d Merge pull request 'fix: prevent attach/detach-server from repeating server arg on tab' (#33) from fix/completion-no-repeat-server-arg into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:36:53 +00:00
Michal
65c340a03c fix: prevent attach/detach-server from repeating server arg on tab
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Added __mcpctl_needs_server_arg guard in fish and position check in
bash so completions stop after one server name is selected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:36:45 +00:00
677d34b868 Merge pull request 'fix: instance completions use server.name, smart attach/detach' (#32) from fix/completion-instances-attach-detach into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:32:34 +00:00
Michal
c5b8cb60b7 fix: instance completions use server.name, smart attach/detach
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Instances have no name field — use server.name for completions
- attach-server: show only servers NOT in the project
- detach-server: show only servers IN the project
- Add helper functions for project-aware server completion
- 5 new tests covering all three fixes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:32:18 +00:00
9a5deffb8f Merge pull request 'fix: use .[][].name in jq for wrapped JSON response' (#31) from fix/completion-jq-wrapped-json into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:27:02 +00:00
Michal
ec7ada5383 fix: use .[][].name in jq for wrapped JSON response
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
API returns { "resources": [...] } not bare arrays, so .[].name
produced no output. Use .[][].name to unwrap the outer object first.
Also auto-load .env in pr.sh.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:26:47 +00:00
b81d3be2d5 Merge pull request 'fix: use jq for completion name extraction to avoid nested matches' (#30) from fix/completion-nested-names into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:23:48 +00:00
Michal
e2c54bfc5c fix: use jq for completion name extraction to avoid nested matches
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
The regex "name":\s*"..." on JSON matched nested server names inside
project objects, mixing resource types in completions. Switch to
jq -r '.[].name' for proper top-level extraction. Add jq as RPM
dependency. Add pr.sh for PR creation via Gitea API.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:23:21 +00:00
7b7854b007 Merge pull request 'feat: erase stale fish completions and add completion tests' (#29) from feat/completions-stale-erase-and-tests into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:17:00 +00:00
Michal
f23dd99662 feat: erase stale fish completions and add completion tests
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Fish completions are additive — sourcing a new file doesn't remove old
rules. Add `complete -c mcpctl -e` at the top to clear stale entries.
Also add 12 structural tests to prevent completion regressions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:16:36 +00:00
43af85cb58 Merge pull request 'feat: context-aware completions with dynamic resource names' (#28) from feat/completions-project-scope-dynamic into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:08:45 +00:00
Michal
6d2e3c2eb3 feat: context-aware completions with dynamic resource names
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Hide attach-server/detach-server from --help (only relevant with --project)
- --project shows only project-scoped commands in tab completion
- Tab after resource type fetches live resource names from API
- --project value auto-completes from existing project names
- Stop offering resource types after one is already selected

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:08:29 +00:00
ce21db3853 Merge pull request 'feat: --project scopes get servers/instances' (#27) from feat/project-scoped-get into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 19:03:23 +00:00
Michal
767725023e feat: --project flag scopes get servers/instances to project
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
mcpctl --project NAME get servers — shows only servers attached to the project
mcpctl --project NAME get instances — shows only instances of project servers

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 19:03:07 +00:00
2bd1b55fe8 Merge pull request 'feat: add tests.sh runner and project routes tests' (#26) from feat/tests-sh-and-project-routes-tests into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 18:58:06 +00:00
Michal
0f2a93f2f0 feat: add tests.sh runner and project routes integration tests
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- tests.sh: run all tests with `bash tests.sh`, summary with `--short`
- tests.sh --filter mcpd/cli: run specific package
- project-routes.test.ts: 17 new route-level tests covering CRUD,
  attach/detach, and the ownerId filtering bug fix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 18:57:46 +00:00
ce81d9d616 Merge pull request 'fix: project list uses RBAC filtering instead of ownerId' (#25) from fix/project-list-rbac into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 18:52:29 +00:00
Michal
c6cc39c6f7 fix: project list should use RBAC filtering, not ownerId
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
The list endpoint was filtering by ownerId before RBAC could include
projects the user has view access to via name-scoped bindings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 18:52:13 +00:00
de074d9a90 Merge pull request 'feat: remove ProjectMember, add expose RBAC role, attach/detach-server' (#24) from feat/project-improvements into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 17:50:24 +00:00
Michal
783cf15179 feat: remove ProjectMember, add expose RBAC role, attach/detach-server commands
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Remove ProjectMember model entirely (RBAC manages project access)
- Add 'expose' RBAC role for /mcp-config endpoint access (edit implies expose)
- Rename CLI flags: --llm-provider → --proxy-mode-llm-provider, --llm-model → --proxy-mode-llm-model
- Add attach-server / detach-server CLI commands (mcpctl --project NAME attach-server SERVER)
- Add POST/DELETE /api/v1/projects/:id/servers endpoints for server attach/detach
- Remove members from backup/restore, apply, get, describe
- Prisma migration to drop ProjectMember table

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 17:50:01 +00:00
5844d6c73f Merge pull request 'fix: RBAC name-scoped access — CUID resolution + list filtering' (#23) from fix/rbac-name-scoped-access into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 12:27:48 +00:00
Michal
604bd76d60 fix: RBAC name-scoped access — CUID resolution + list filtering
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Two bugs fixed:
- GET /api/v1/servers/:cuid now resolves CUID→name before RBAC check,
  so name-scoped bindings match correctly
- List endpoints now filter responses via preSerialization hook using
  getAllowedScope(), so name-scoped users only see their resources

Also adds fulldeploy.sh orchestrator script.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:26:37 +00:00
da14bb8c23 Merge pull request 'fix: update shell completions for current CLI commands' (#22) from fix/update-shell-completions into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 12:00:50 +00:00
Michal
9e9a2f4a54 fix: update shell completions for current CLI commands
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Add users, groups, rbac, secrets, templates to resource completions.
Remove stale profiles references. Add login, logout, create, edit,
delete, logs commands. Update config subcommands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 12:00:31 +00:00
c8cdd7f514 Merge pull request 'fix: migrate legacy admin role at startup' (#21) from fix/migrate-legacy-admin-role into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 11:31:31 +00:00
Michal
ec1dfe7438 fix: migrate legacy admin role to granular roles at startup
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Add migrateAdminRole() that runs on mcpd boot
- Converts { role: 'admin', resource: X } → edit + run bindings
- Adds operation bindings for wildcard admin (impersonate, logs, etc.)
- Add tests verifying unknown/legacy roles are denied by canAccess

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:31:15 +00:00
50b4112398 Merge pull request 'fix: resolve tsc --build type errors' (#20) from fix/build-type-errors into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 11:08:08 +00:00
Michal
bb17a892d6 fix: resolve tsc --build type errors (exactOptionalPropertyTypes)
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Fix resourceName assignment in mapUrlToPermission for strictness
- Use RbacRoleBinding type in restore-service instead of loose cast
- Remove stale ProjectMemberInput export from validation index

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:07:46 +00:00
a8117091a1 Merge pull request 'feat: granular RBAC with resource/operation bindings, users, groups' (#19) from feat/projects-rbac-users-groups into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 11:05:51 +00:00
Michal
dcda93d179 feat: granular RBAC with resource/operation bindings, users, groups
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Replace admin role with granular roles: view, create, delete, edit, run
- Two binding types: resource bindings (role+resource+optional name) and
  operation bindings (role:run + action like backup, logs, impersonate)
- Name-scoped resource bindings for per-instance access control
- Remove role from project members (all permissions via RBAC)
- Add users, groups, RBAC CRUD endpoints and CLI commands
- describe user/group shows all RBAC access (direct + inherited)
- create rbac supports --subject, --binding, --operation flags
- Backup/restore handles users, groups, RBAC definitions
- mcplocal project-based MCP endpoint discovery
- Full test coverage for all new functionality

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 11:05:19 +00:00
a6b5e24a8d Merge pull request 'fix: add missing passwordHash to DB test user factory' (#18) from fix/db-tests-passwordhash into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 01:03:11 +00:00
Michal
3a6e58274c fix: add missing passwordHash to DB test user factory
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 01:02:41 +00:00
Michal
c819b65175 fix: SSE health probe uses proper SSE protocol (GET /sse + POST /messages)
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
SSE-transport MCP servers (like ha-mcp) use a different protocol flow:
GET /sse to establish event stream, read endpoint event, then POST
JSON-RPC messages to /messages?session_id=... URL. Previously was
POSTing to root which returned 404.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:55:25 +00:00
Michal
c3ef5a664f chore: remove accidentally committed logs.sh
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:52:31 +00:00
Michal
4c2927a16e fix: HTTP health probes use container IP for internal network communication
mcpd and MCP containers share the mcp-servers Docker network. HTTP probes
must use the container's internal IP + containerPort instead of localhost
+ host-mapped port. Also extracts container IP from Docker inspect.

Updated home-assistant template to use ghcr.io/homeassistant-ai/ha-mcp
Docker image (SSE transport) instead of broken npm package.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:52:17 +00:00
79dd6e723d Merge pull request 'feat: MCP health probe runner with tool-call probes' (#17) from feat/health-probe-runner into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 00:39:09 +00:00
Michal
cde1c59fd6 feat: MCP health probe runner — periodic tool-call probes for instances
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Implements Kubernetes-style liveness probes that call MCP tools defined
in server healthCheck configs. For STDIO servers, uses docker exec to
spawn a disposable MCP client that sends initialize + tool call. For
HTTP/SSE servers, sends JSON-RPC directly.

- HealthProbeRunner service with configurable interval/threshold/timeout
- execInContainer added to orchestrator interface + Docker implementation
- Instance findById now includes server relation (fixes describe showing IDs)
- Events appended to instance (last 50), healthStatus tracked as
  healthy/degraded/unhealthy
- 12 unit tests covering probing, thresholds, intervals, cleanup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:38:48 +00:00
daa5860ed2 Merge pull request 'fix: stdin open for STDIO servers + describe instance resolution' (#16) from fix/stdin-describe-instance into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 00:26:49 +00:00
Michal
ecbf48dd49 fix: keep stdin open for STDIO servers + describe instance resolves server names
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
STDIO MCP servers read from stdin and exit on EOF. Docker containers close
stdin by default, causing all STDIO servers to crash immediately. Added
OpenStdin: true to container creation.

Describe instance now resolves server names (like logs command), preferring
RUNNING instances. Added 7 new describe tests covering server name resolution,
healthcheck display, events section, and template detail.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:26:28 +00:00
d38b5aac60 Merge pull request 'feat: container liveness sync + node-runner slim base' (#15) from feat/container-liveness-sync into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 00:18:41 +00:00
Michal
d07d4d11dd feat: container liveness sync + node-runner slim base
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Add syncStatus() to InstanceService: detects crashed/stopped containers,
  marks them ERROR with last log line as context
- Reconcile now syncs container status first (detect dead before counting)
- Add 30s periodic sync loop in main.ts
- Switch node-runner from alpine to slim (Debian) for npm compatibility
  (fixes home-assistant-mcp-server binary not found on Alpine)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:18:28 +00:00
fa58c1b5ed Merge pull request 'fix: logs resolves server names + replica handling + tests' (#14) from fix/logs-resolve-and-tests into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 00:12:50 +00:00
Michal
dd1dfc629d fix: logs command resolves server names, proper replica handling
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- `mcpctl logs <server-name>` resolves to first RUNNING instance
- `mcpctl logs <server-name> -i <N>` selects specific replica
- Shows "instance N/M" hint when server has multiple replicas
- Added 5 proper tests: server name resolution, RUNNING preference,
  replica selection, out-of-range error, no instances error

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:12:39 +00:00
7b3dab142e Merge pull request 'fix: show server name in instances, logs by server name' (#13) from fix/instance-ux into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 00:07:57 +00:00
Michal
4c127a7dc3 fix: show server name in instances table, allow logs by server name
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Instance list now shows server NAME instead of cryptic server ID
- Include server relation in findAll query (Prisma include)
- Logs command accepts server name, server ID, or instance ID
  (resolves server name → first RUNNING instance)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:07:42 +00:00
c1e3e4aed6 Merge pull request 'feat: auto-pull images + registry path for node-runner' (#12) from feat/node-runner-registry-pull into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-23 00:03:19 +00:00
Michal
e45c6079c1 feat: pull images before container creation, use registry path for node-runner
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Default node-runner image now uses mysources.co.uk registry path
- Add pullImage() call before createContainer() to auto-pull missing images
- Update stack/docker-compose.yml with MCPD_NODE_RUNNER_IMAGE and
  MCPD_MCP_NETWORK env vars, fix mcp-servers network naming

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 00:03:01 +00:00
e4aef3acf1 Merge pull request 'feat: add node-runner base image for npm-based MCP servers' (#11) from feat/node-runner-base-image into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-22 23:41:36 +00:00
Michal
a2cda38850 feat: add node-runner base image for npm-based MCP servers
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
STDIO servers with packageName (e.g. @leval/mcp-grafana) need a Node.js
container that runs `npx -y <package>`. Previously, packageName was used
as a Docker image reference causing "invalid reference format" errors.

- Add Dockerfile.node-runner: minimal node:20-alpine with npx entrypoint
- Update instance.service.ts: detect npm-based servers and use node-runner
  image with npx command instead of treating packageName as image name
- Fix NanoCPUs: only set when explicitly provided (kernel CFS not available
  on all hosts)
- Add mcp-servers network with explicit name for container isolation
- Configure MCPD_NODE_RUNNER_IMAGE and MCPD_MCP_NETWORK env vars

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:41:16 +00:00
081e90de0f Merge pull request 'fix: error handling and --force flag for create commands' (#10) from fix/create-error-handling into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-22 23:06:52 +00:00
Michal
4e3d896ef6 fix: proper error handling and --force flag for create commands
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Add global error handler: clean messages instead of stack traces
- Add --force flag to create server/secret/project: updates on 409 conflict
- Strip null values and template-only fields from --from-template payload
- Add tests: 409 handling, --force update, null-stripping from templates

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 23:06:33 +00:00
0823e965bf Merge pull request 'feat: MCP healthcheck probes + new templates' (#9) from feat/healthcheck-probes into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-22 22:50:10 +00:00
Michal
c97219f85e feat: add MCP healthcheck probes and new templates (grafana, home-assistant, node-red)
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- Add healthCheck spec to templates and servers (tool, arguments, interval, timeout, failureThreshold)
- Add healthStatus, lastHealthCheck, events fields to instances
- Create grafana, home-assistant, node-red templates with healthcheck probes
- Add healthcheck probes to existing templates (github, slack, postgres, jira)
- Show HEALTH column in `get instances` and Events section in `describe instance`
- Display healthCheck details in `describe server` and `describe template`
- Schema + storage + display only; actual probe runner is future work

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:48:59 +00:00
93adcd4be7 Merge pull request 'feat: add MCP server templates and deployment infrastructure' (#8) from feat/mcp-templates into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-22 22:25:02 +00:00
Michal
d58e6e153f feat: add MCP server templates and deployment infrastructure
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Introduce a Helm-chart-like template system for MCP servers. Templates are
YAML files in templates/ that get seeded into the DB on startup. Users can
browse them with `mcpctl get templates`, inspect with `mcpctl describe
template`, and instantiate with `mcpctl create server --from-template=`.

Also adds Portainer deployment scripts, mcplocal systemd service,
Streamable HTTP MCP endpoint, and RPM packaging for mcpctl-local.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 22:24:35 +00:00
Michal
1e8847bb63 fix: remove unused variables from profile cleanup
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:43:32 +00:00
Michal
2a0deaa225 fix: unused deps parameter in project command
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:42:16 +00:00
4eef6e38a2 Merge pull request 'feat: replace profiles with kubernetes-style secrets' (#7) from feat/replace-profiles-with-secrets into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-22 18:41:44 +00:00
Michal
ca02340a4c feat: replace profiles with kubernetes-style secrets
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Replace the confused Profile abstraction with a dedicated Secret resource
following Kubernetes conventions. Servers now have env entries with inline
values or secretRef references. Env vars are resolved and passed to
containers at startup (fixes existing gap).

- Add Secret CRUD (model, repo, service, routes, CLI commands)
- Server env: {name, value} or {name, valueFrom: {secretRef: {name, key}}}
- Add env-resolver utility shared by instance startup and config generation
- Remove all profile-related code (models, services, routes, CLI, tests)
- Update backup/restore for secrets instead of profiles
- describe secret masks values by default, --show-values to reveal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 18:40:58 +00:00
Michal
02254f2aac fix: enable positional options so -o works on subcommands
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Remove global -o/--output from parent program and enable
enablePositionalOptions() so -o yaml/json is parsed by subcommands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:43:35 +00:00
Michal
540dd6fd63 fix: remove unused Project interface from project.ts
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:41:14 +00:00
a05a4c4816 Merge pull request 'feat: create/edit commands, apply-compatible output, better describe' (#6) from feat/create-edit-commands into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
2026-02-22 16:40:36 +00:00
Michal
97ade470df fix: resolve resource names in get/describe (not just IDs)
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
fetchResource and fetchSingleResource now use resolveNameOrId so
`mcpctl get server ha-mcp` works by name, not just by ID.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 16:39:21 +00:00
Michal
b25ff98374 feat: add create/edit commands, apply-compatible output, better describe
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
- `create server/profile/project` with all CLI flags (kubectl parity)
- `edit server/profile/project` opens $EDITOR for in-flight editing
- `get -o yaml/json` now outputs apply-compatible format (strips internal fields, wraps in resource key)
- `describe` shows visually clean sectioned output with aligned columns
- Extract shared utilities (resolveResource, resolveNameOrId, stripInternalFields)
- Instances are immutable (no create/edit, like pods)
- Full test coverage for create, edit, and updated describe/get

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 14:33:25 +00:00
Michal
22fe9c3435 fix: add replicas to restore-service server creation
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:47:03 +00:00
72643fceda Merge pull request 'feat: kubectl-style CLI + Deployment/Pod model' (#5) from feat/kubectl-deployment-model into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Reviewed-on: #5
2026-02-22 13:39:02 +00:00
Michal
467357c2c6 feat: kubectl-style CLI + Deployment/Pod model for servers/instances
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Server = Deployment (defines what to run + desired replicas)
Instance = Pod (ephemeral, auto-created by reconciliation)

Backend:
- Add replicas field to McpServer schema
- Add reconcile() to InstanceService (scales instances to match replicas)
- Remove manual start/stop/restart - instances are auto-managed
- Cascade: deleting server stops all containers then cascades DB
- Server create/update auto-triggers reconciliation

CLI:
- Add top-level delete command (servers, instances, profiles, projects)
- Add top-level logs command
- Remove instance compound command (use get/delete/logs instead)
- Clean up project command (list/show/delete → top-level get/describe/delete)
- Enhance describe for instances with container inspect info
- Add replicas to apply command's ServerSpec

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 13:30:46 +00:00
d6a80fc03d Merge pull request 'feat: external MCP server support + HA MCP PoC' (#4) from feat/external-mcp-servers into main
Some checks are pending
CI / lint (push) Waiting to run
CI / typecheck (push) Waiting to run
CI / test (push) Waiting to run
CI / build (push) Blocked by required conditions
CI / package (push) Blocked by required conditions
Reviewed-on: #4
2026-02-22 12:39:19 +00:00
Michal
c07da826a0 test: add integration test for full MCP server flow
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Tests the complete lifecycle through Fastify routes with in-memory
repositories and a fake streamable-http MCP server:
- External server: register → start virtual instance → proxy tools/list
- Managed server: register with dockerImage → start container → verify spec
- Full lifecycle: register → start → list → stop → remove → delete
- Proxy auth enforcement
- Server update flow
- Error handling (Docker failure → ERROR status)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:34:55 +00:00
Michal
0482944056 feat: add external MCP server support with streamable-http proxy
Some checks failed
CI / lint (pull_request) Has been cancelled
CI / typecheck (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build (pull_request) Has been cancelled
CI / package (pull_request) Has been cancelled
Support non-containerized MCP servers via externalUrl field and add
streamable-http session management for HA MCP proof of concept.

- Add externalUrl, command, containerPort fields to McpServer schema
- Skip Docker orchestration for external servers (virtual instances)
- Implement streamable-http proxy with Mcp-Session-Id session management
- Parse SSE-framed responses from streamable-http endpoints
- Add command passthrough to Docker container creation
- Create HA MCP example manifest (examples/ha-mcp.yaml)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 12:21:25 +00:00
216 changed files with 21307 additions and 3445 deletions

3
.gitignore vendored
View File

@@ -9,6 +9,8 @@ dist/
.env
.env.local
.env.*.local
stack/.env
.portainer_password
# Logs
logs/
@@ -35,3 +37,4 @@ pgdata/
# Prisma
src/db/prisma/migrations/*.sql.backup
logs.sh

View File

@@ -2,92 +2,167 @@ _mcpctl() {
local cur prev words cword
_init_completion || return
local commands="config status get describe instance instances apply setup claude project projects backup restore help"
local global_opts="-v --version -o --output --daemon-url -h --help"
local resources="servers profiles projects instances"
local commands="status login logout config get describe delete logs create edit apply backup restore mcp help"
local project_commands="attach-server detach-server get describe delete logs create edit help"
local global_opts="-v --version --daemon-url --direct --project -h --help"
local resources="servers instances secrets templates projects users groups rbac"
case "${words[1]}" in
# Check if --project was given
local has_project=false
local i
for ((i=1; i < cword; i++)); do
if [[ "${words[i]}" == "--project" ]]; then
has_project=true
break
fi
done
# Find the first subcommand (skip --project and its argument, skip flags)
local subcmd=""
local subcmd_pos=0
for ((i=1; i < cword; i++)); do
if [[ "${words[i]}" == "--project" || "${words[i]}" == "--daemon-url" ]]; then
((i++)) # skip the argument
continue
fi
if [[ "${words[i]}" != -* ]]; then
subcmd="${words[i]}"
subcmd_pos=$i
break
fi
done
# Find the resource type after get/describe/delete/edit
local resource_type=""
if [[ -n "$subcmd_pos" ]] && [[ $subcmd_pos -gt 0 ]]; then
for ((i=subcmd_pos+1; i < cword; i++)); do
if [[ "${words[i]}" != -* ]] && [[ " $resources " == *" ${words[i]} "* ]]; then
resource_type="${words[i]}"
break
fi
done
fi
# If completing the --project value
if [[ "$prev" == "--project" ]]; then
local names
names=$(mcpctl get projects -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
COMPREPLY=($(compgen -W "$names" -- "$cur"))
return
fi
# Fetch resource names dynamically (jq extracts only top-level names)
_mcpctl_resource_names() {
local rt="$1"
if [[ -n "$rt" ]]; then
# Instances don't have a name field — use server.name instead
if [[ "$rt" == "instances" ]]; then
mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null
else
mcpctl get "$rt" -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
fi
fi
}
# Get the --project value from the command line
_mcpctl_get_project_value() {
local i
for ((i=1; i < cword; i++)); do
if [[ "${words[i]}" == "--project" ]] && (( i+1 < cword )); then
echo "${words[i+1]}"
return
fi
done
}
case "$subcmd" in
config)
COMPREPLY=($(compgen -W "view set path reset help" -- "$cur"))
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
COMPREPLY=($(compgen -W "view set path reset claude impersonate help" -- "$cur"))
fi
return ;;
status)
COMPREPLY=($(compgen -W "--daemon-url -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "-h --help" -- "$cur"))
return ;;
get)
if [[ $cword -eq 2 ]]; then
login)
COMPREPLY=($(compgen -W "--url --email --password -h --help" -- "$cur"))
return ;;
logout)
return ;;
mcp)
return ;;
get|describe|delete)
if [[ -z "$resource_type" ]]; then
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
else
COMPREPLY=($(compgen -W "-o --output --daemon-url -h --help" -- "$cur"))
local names
names=$(_mcpctl_resource_names "$resource_type")
COMPREPLY=($(compgen -W "$names -o --output -h --help" -- "$cur"))
fi
return ;;
describe)
if [[ $cword -eq 2 ]]; then
COMPREPLY=($(compgen -W "$resources" -- "$cur"))
edit)
if [[ -z "$resource_type" ]]; then
COMPREPLY=($(compgen -W "servers projects" -- "$cur"))
else
COMPREPLY=($(compgen -W "-o --output --daemon-url -h --help" -- "$cur"))
local names
names=$(_mcpctl_resource_names "$resource_type")
COMPREPLY=($(compgen -W "$names -h --help" -- "$cur"))
fi
return ;;
instance|instances)
if [[ $cword -eq 2 ]]; then
COMPREPLY=($(compgen -W "list ls start stop restart remove rm logs inspect help" -- "$cur"))
else
case "${words[2]}" in
logs)
COMPREPLY=($(compgen -W "--tail --since -h --help" -- "$cur"))
;;
start)
COMPREPLY=($(compgen -W "--env --image -h --help" -- "$cur"))
;;
list|ls)
COMPREPLY=($(compgen -W "--server-id -o --output -h --help" -- "$cur"))
;;
esac
fi
COMPREPLY=($(compgen -W "--tail --since -f --follow -h --help" -- "$cur"))
return ;;
claude)
if [[ $cword -eq 2 ]]; then
COMPREPLY=($(compgen -W "generate show add remove help" -- "$cur"))
else
case "${words[2]}" in
generate|show|add|remove)
COMPREPLY=($(compgen -W "--path -p -h --help" -- "$cur"))
;;
esac
fi
return ;;
project|projects)
if [[ $cword -eq 2 ]]; then
COMPREPLY=($(compgen -W "list ls create delete rm show profiles set-profiles help" -- "$cur"))
else
case "${words[2]}" in
create)
COMPREPLY=($(compgen -W "--description -d -h --help" -- "$cur"))
;;
list|ls)
COMPREPLY=($(compgen -W "-o --output -h --help" -- "$cur"))
;;
esac
if [[ $((cword - subcmd_pos)) -eq 1 ]]; then
COMPREPLY=($(compgen -W "server secret project user group rbac help" -- "$cur"))
fi
return ;;
apply)
COMPREPLY=($(compgen -f -- "$cur"))
return ;;
backup)
COMPREPLY=($(compgen -W "-o --output -p --password -r --resources -h --help" -- "$cur"))
COMPREPLY=($(compgen -W "-o --output -p --password -h --help" -- "$cur"))
return ;;
restore)
COMPREPLY=($(compgen -W "-i --input -p --password -c --conflict -h --help" -- "$cur"))
return ;;
setup)
attach-server)
# Only complete if no server arg given yet (first arg after subcmd)
if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi
local proj names all_servers proj_servers
proj=$(_mcpctl_get_project_value)
if [[ -n "$proj" ]]; then
all_servers=$(mcpctl get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
proj_servers=$(mcpctl --project "$proj" get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
names=$(comm -23 <(echo "$all_servers" | sort) <(echo "$proj_servers" | sort))
else
names=$(_mcpctl_resource_names "servers")
fi
COMPREPLY=($(compgen -W "$names" -- "$cur"))
return ;;
detach-server)
# Only complete if no server arg given yet (first arg after subcmd)
if [[ $((cword - subcmd_pos)) -ne 1 ]]; then return; fi
local proj names
proj=$(_mcpctl_get_project_value)
if [[ -n "$proj" ]]; then
names=$(mcpctl --project "$proj" get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
fi
COMPREPLY=($(compgen -W "$names" -- "$cur"))
return ;;
help)
COMPREPLY=($(compgen -W "$commands" -- "$cur"))
return ;;
esac
if [[ $cword -eq 1 ]]; then
# No subcommand yet — offer commands based on context
if [[ -z "$subcmd" ]]; then
if $has_project; then
COMPREPLY=($(compgen -W "$project_commands $global_opts" -- "$cur"))
else
COMPREPLY=($(compgen -W "$commands $global_opts" -- "$cur"))
fi
fi
}
complete -F _mcpctl mcpctl

View File

@@ -1,73 +1,226 @@
# mcpctl fish completions
set -l commands config status get describe instance instances apply setup claude project projects backup restore help
# Erase any stale completions from previous versions
complete -c mcpctl -e
set -l commands status login logout config get describe delete logs create edit apply backup restore mcp help
set -l project_commands attach-server detach-server get describe delete logs create edit help
# Disable file completions by default
complete -c mcpctl -f
# Global options
complete -c mcpctl -s v -l version -d 'Show version'
complete -c mcpctl -s o -l output -d 'Output format' -xa 'table json yaml'
complete -c mcpctl -l daemon-url -d 'mcpd daemon URL' -x
complete -c mcpctl -l daemon-url -d 'mcplocal daemon URL' -x
complete -c mcpctl -l direct -d 'Bypass mcplocal, connect directly to mcpd'
complete -c mcpctl -l project -d 'Target project context' -x
complete -c mcpctl -s h -l help -d 'Show help'
# Top-level commands
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a config -d 'Manage configuration'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a status -d 'Show status and connectivity'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a get -d 'List resources'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a describe -d 'Show resource details'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a instance -d 'Manage instances'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a setup -d 'Interactive setup wizard'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a claude -d 'Manage Claude .mcp.json'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a project -d 'Manage projects'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup'
complete -c mcpctl -n "not __fish_seen_subcommand_from $commands" -a help -d 'Show help'
# Helper: check if --project was given
function __mcpctl_has_project
set -l tokens (commandline -opc)
for i in (seq (count $tokens))
if test "$tokens[$i]" = "--project"
return 0
end
end
return 1
end
# get/describe resources
complete -c mcpctl -n "__fish_seen_subcommand_from get describe" -a 'servers profiles projects instances' -d 'Resource type'
# Helper: check if a resource type has been selected after get/describe/delete/edit
set -l resources servers instances secrets templates projects users groups rbac
function __mcpctl_needs_resource_type
set -l tokens (commandline -opc)
set -l found_cmd false
for tok in $tokens
if $found_cmd
# Check if next token after get/describe/delete/edit is a resource type
if contains -- $tok servers instances secrets templates projects users groups rbac
return 1 # resource type already present
end
end
if contains -- $tok get describe delete edit
set found_cmd true
end
end
if $found_cmd
return 0 # command found but no resource type yet
end
return 1
end
function __mcpctl_get_resource_type
set -l tokens (commandline -opc)
set -l found_cmd false
for tok in $tokens
if $found_cmd
if contains -- $tok servers instances secrets templates projects users groups rbac
echo $tok
return
end
end
if contains -- $tok get describe delete edit
set found_cmd true
end
end
end
# Fetch resource names dynamically from the API (jq extracts only top-level names)
function __mcpctl_resource_names
set -l resource (__mcpctl_get_resource_type)
if test -z "$resource"
return
end
# Instances don't have a name field — use server.name instead
if test "$resource" = "instances"
mcpctl get instances -o json 2>/dev/null | jq -r '.[][].server.name' 2>/dev/null
else
mcpctl get $resource -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
end
end
# Fetch project names for --project value
function __mcpctl_project_names
mcpctl get projects -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
end
# Helper: get the --project value from the command line
function __mcpctl_get_project_value
set -l tokens (commandline -opc)
for i in (seq (count $tokens))
if test "$tokens[$i]" = "--project"; and test $i -lt (count $tokens)
echo $tokens[(math $i + 1)]
return
end
end
end
# Servers currently attached to the project (for detach-server)
function __mcpctl_project_servers
set -l proj (__mcpctl_get_project_value)
if test -z "$proj"
return
end
mcpctl --project $proj get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
end
# Servers NOT attached to the project (for attach-server)
function __mcpctl_available_servers
set -l proj (__mcpctl_get_project_value)
if test -z "$proj"
# No project — show all servers
mcpctl get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null
return
end
set -l all (mcpctl get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
set -l attached (mcpctl --project $proj get servers -o json 2>/dev/null | jq -r '.[][].name' 2>/dev/null)
for s in $all
if not contains -- $s $attached
echo $s
end
end
end
# --project value completion
complete -c mcpctl -l project -xa '(__mcpctl_project_names)'
# Top-level commands (without --project)
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a status -d 'Show status and connectivity'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a login -d 'Authenticate with mcpd'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logout -d 'Log out'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a config -d 'Manage configuration'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a get -d 'List resources'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a describe -d 'Show resource details'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logs -d 'Get instance logs'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a apply -d 'Apply configuration from file'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Backup configuration'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a restore -d 'Restore from backup'
complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a help -d 'Show help'
# Project-scoped commands (with --project)
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a attach-server -d 'Attach a server to the project'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a detach-server -d 'Detach a server from the project'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (scoped to project)'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a describe -d 'Show resource details'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a delete -d 'Delete a resource'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get instance logs'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource'
complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a help -d 'Show help'
# Resource types — only when resource type not yet selected
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete; and __mcpctl_needs_resource_type" -a "$resources" -d 'Resource type'
complete -c mcpctl -n "__fish_seen_subcommand_from edit; and __mcpctl_needs_resource_type" -a 'servers projects' -d 'Resource type'
# Resource names — after resource type is selected
complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete edit; and not __mcpctl_needs_resource_type" -a '(__mcpctl_resource_names)' -d 'Resource name'
# Helper: check if attach-server/detach-server already has a server argument
function __mcpctl_needs_server_arg
set -l tokens (commandline -opc)
set -l found_cmd false
for tok in $tokens
if $found_cmd
if not string match -q -- '-*' $tok
return 1 # server arg already present
end
end
if contains -- $tok attach-server detach-server
set found_cmd true
end
end
if $found_cmd
return 0 # command found but no server arg yet
end
return 1
end
# attach-server: show servers NOT in the project (only if no server arg yet)
complete -c mcpctl -n "__fish_seen_subcommand_from attach-server; and __mcpctl_needs_server_arg" -a '(__mcpctl_available_servers)' -d 'Server'
# detach-server: show servers IN the project (only if no server arg yet)
complete -c mcpctl -n "__fish_seen_subcommand_from detach-server; and __mcpctl_needs_server_arg" -a '(__mcpctl_project_servers)' -d 'Server'
# get/describe options
complete -c mcpctl -n "__fish_seen_subcommand_from get" -s o -l output -d 'Output format' -xa 'table json yaml'
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -s o -l output -d 'Output format' -xa 'detail json yaml'
complete -c mcpctl -n "__fish_seen_subcommand_from describe" -l show-values -d 'Show secret values'
# login options
complete -c mcpctl -n "__fish_seen_subcommand_from login" -l url -d 'mcpd URL' -x
complete -c mcpctl -n "__fish_seen_subcommand_from login" -l email -d 'Email address' -x
complete -c mcpctl -n "__fish_seen_subcommand_from login" -l password -d 'Password' -x
# config subcommands
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from view set path reset" -a view -d 'Show configuration'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from view set path reset" -a set -d 'Set a config value'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from view set path reset" -a path -d 'Show config file path'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from view set path reset" -a reset -d 'Reset to defaults'
set -l config_cmds view set path reset claude claude-generate impersonate
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a view -d 'Show configuration'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a config value'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show config file path'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset to defaults'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json for project'
complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate a user'
# instance subcommands
set -l instance_cmds list ls start stop restart remove rm logs inspect
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a list -d 'List instances'
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a start -d 'Start instance'
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a stop -d 'Stop instance'
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a restart -d 'Restart instance'
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a remove -d 'Remove instance'
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a logs -d 'Get logs'
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and not __fish_seen_subcommand_from $instance_cmds" -a inspect -d 'Inspect container'
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and __fish_seen_subcommand_from logs" -l tail -d 'Number of lines' -x
complete -c mcpctl -n "__fish_seen_subcommand_from instance instances; and __fish_seen_subcommand_from logs" -l since -d 'Since timestamp' -x
# create subcommands
set -l create_cmds server secret project user group rbac
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create a server'
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secret -d 'Create a secret'
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a project -d 'Create a project'
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a user -d 'Create a user'
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a group -d 'Create a group'
complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a rbac -d 'Create an RBAC binding'
# claude subcommands
set -l claude_cmds generate show add remove
complete -c mcpctl -n "__fish_seen_subcommand_from claude; and not __fish_seen_subcommand_from $claude_cmds" -a generate -d 'Generate .mcp.json'
complete -c mcpctl -n "__fish_seen_subcommand_from claude; and not __fish_seen_subcommand_from $claude_cmds" -a show -d 'Show .mcp.json'
complete -c mcpctl -n "__fish_seen_subcommand_from claude; and not __fish_seen_subcommand_from $claude_cmds" -a add -d 'Add server entry'
complete -c mcpctl -n "__fish_seen_subcommand_from claude; and not __fish_seen_subcommand_from $claude_cmds" -a remove -d 'Remove server entry'
complete -c mcpctl -n "__fish_seen_subcommand_from claude; and __fish_seen_subcommand_from $claude_cmds" -s p -l path -d 'Path to .mcp.json' -rF
# project subcommands
set -l project_cmds list ls create delete rm show profiles set-profiles
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and not __fish_seen_subcommand_from $project_cmds" -a list -d 'List projects'
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and not __fish_seen_subcommand_from $project_cmds" -a create -d 'Create project'
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and not __fish_seen_subcommand_from $project_cmds" -a delete -d 'Delete project'
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and not __fish_seen_subcommand_from $project_cmds" -a show -d 'Show project'
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and not __fish_seen_subcommand_from $project_cmds" -a profiles -d 'List profiles'
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and not __fish_seen_subcommand_from $project_cmds" -a set-profiles -d 'Set profiles'
complete -c mcpctl -n "__fish_seen_subcommand_from project projects; and __fish_seen_subcommand_from create" -s d -l description -d 'Description' -x
# logs options
complete -c mcpctl -n "__fish_seen_subcommand_from logs" -l tail -d 'Number of lines' -x
complete -c mcpctl -n "__fish_seen_subcommand_from logs" -l since -d 'Since timestamp' -x
complete -c mcpctl -n "__fish_seen_subcommand_from logs" -s f -l follow -d 'Follow log output'
# backup options
complete -c mcpctl -n "__fish_seen_subcommand_from backup" -s o -l output -d 'Output file' -rF
complete -c mcpctl -n "__fish_seen_subcommand_from backup" -s p -l password -d 'Encryption password' -x
complete -c mcpctl -n "__fish_seen_subcommand_from backup" -s r -l resources -d 'Resources to backup' -xa 'servers profiles projects'
# restore options
complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s i -l input -d 'Input file' -rF
@@ -75,6 +228,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s p -l password -d
complete -c mcpctl -n "__fish_seen_subcommand_from restore" -s c -l conflict -d 'Conflict strategy' -xa 'skip overwrite fail'
# apply takes a file
complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Configuration file' -rF
complete -c mcpctl -n "__fish_seen_subcommand_from apply" -F
# help completions

398
deploy.sh Executable file
View File

@@ -0,0 +1,398 @@
#!/bin/bash
# Deploy mcpctl stack to Portainer
# Usage: ./deploy.sh [--dry-run]
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STACK_DIR="$SCRIPT_DIR/stack"
COMPOSE_FILE="$STACK_DIR/docker-compose.yml"
ENV_FILE="$STACK_DIR/.env"
# Portainer configuration
PORTAINER_URL="${PORTAINER_URL:-http://10.0.0.194:9000}"
PORTAINER_USER="${PORTAINER_USER:-michal}"
STACK_NAME="mcpctl"
ENDPOINT_ID="2"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1" >&2; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1" >&2; }
log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
check_files() {
if [[ ! -f "$COMPOSE_FILE" ]]; then
log_error "Compose file not found: $COMPOSE_FILE"
exit 1
fi
if [[ ! -f "$ENV_FILE" ]]; then
log_error "Environment file not found: $ENV_FILE"
exit 1
fi
log_info "Found compose file: $COMPOSE_FILE"
log_info "Found env file: $ENV_FILE"
}
get_password() {
if [[ -n "$PORTAINER_PASSWORD" ]]; then
echo "$PORTAINER_PASSWORD"
return
fi
if [[ -f "$SCRIPT_DIR/.portainer_password" ]]; then
cat "$SCRIPT_DIR/.portainer_password"
return
fi
if [[ -f "$HOME/.portainer_password" ]]; then
cat "$HOME/.portainer_password"
return
fi
read -s -p "Enter Portainer password for $PORTAINER_USER: " password
echo >&2
echo "$password"
}
get_jwt_token() {
local password="$1"
log_info "Authenticating to Portainer..."
local escaped_password
escaped_password=$(printf '%s' "$password" | jq -Rs .)
local response
response=$(curl -s -X POST "$PORTAINER_URL/api/auth" \
-H "Content-Type: application/json" \
-d "{\"Username\":\"$PORTAINER_USER\",\"Password\":$escaped_password}")
local token
token=$(echo "$response" | jq -r '.jwt // empty')
if [[ -z "$token" ]]; then
log_error "Authentication failed: $(echo "$response" | jq -r '.message // "Unknown error"')"
exit 1
fi
echo "$token"
}
parse_env_to_json() {
local env_file="$1"
local json_array="["
local first=true
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" =~ ^#.*$ ]] && continue
[[ -z "$line" ]] && continue
local name="${line%%=*}"
local value="${line#*=}"
[[ "$name" == "$line" ]] && continue
if [[ "$first" == "true" ]]; then
first=false
else
json_array+=","
fi
value=$(echo "$value" | sed 's/\\/\\\\/g; s/"/\\"/g')
json_array+="{\"name\":\"$name\",\"value\":\"$value\"}"
done < "$env_file"
json_array+="]"
echo "$json_array"
}
# Find existing stack by name
find_stack_id() {
local token="$1"
local response
response=$(curl -s -X GET "$PORTAINER_URL/api/stacks" \
-H "Authorization: Bearer $token")
echo "$response" | jq -r --arg name "$STACK_NAME" \
'.[] | select(.Name == $name) | .Id // empty'
}
get_stack_info() {
local token="$1"
local stack_id="$2"
curl -s -X GET "$PORTAINER_URL/api/stacks/$stack_id" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json"
}
get_stack_file() {
local token="$1"
local stack_id="$2"
local response
response=$(curl -s -X GET "$PORTAINER_URL/api/stacks/$stack_id/file" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json")
if echo "$response" | jq -e '.StackFileContent' > /dev/null 2>&1; then
echo "$response" | jq -r '.StackFileContent'
else
echo "# Could not retrieve current compose file"
fi
}
show_diff() {
local token="$1"
local stack_id="$2"
local env_json="$3"
log_info "Fetching current state from Portainer..."
local current_compose
current_compose=$(get_stack_file "$token" "$stack_id")
local current_env
local stack_info
stack_info=$(get_stack_info "$token" "$stack_id")
current_env=$(echo "$stack_info" | jq -r 'if .Env then .Env[] | "\(.name)=\(.value)" else empty end' 2>/dev/null | sort)
local new_env
new_env=$(echo "$env_json" | jq -r '.[] | "\(.name)=\(.value)"' | sort)
local tmp_dir
tmp_dir=$(mktemp -d)
echo "$current_compose" > "$tmp_dir/current_compose.yml"
cat "$COMPOSE_FILE" > "$tmp_dir/new_compose.yml"
echo "$current_env" > "$tmp_dir/current_env.txt"
echo "$new_env" > "$tmp_dir/new_env.txt"
echo ""
echo "=== ENVIRONMENT VARIABLES DIFF ==="
echo ""
if diff -u "$tmp_dir/current_env.txt" "$tmp_dir/new_env.txt" > "$tmp_dir/env_diff.txt" 2>&1; then
echo -e "${GREEN}No changes in environment variables${NC}"
else
while IFS= read -r line; do
if [[ "$line" == ---* ]] || [[ "$line" == +++* ]] || [[ "$line" == @@* ]]; then
echo -e "${YELLOW}$line${NC}"
elif [[ "$line" == -* ]]; then
echo -e "${RED}$line${NC}"
elif [[ "$line" == +* ]]; then
echo -e "${GREEN}$line${NC}"
else
echo "$line"
fi
done < "$tmp_dir/env_diff.txt"
fi
echo ""
echo "=== COMPOSE FILE DIFF ==="
echo ""
if diff -u "$tmp_dir/current_compose.yml" "$tmp_dir/new_compose.yml" > "$tmp_dir/compose_diff.txt" 2>&1; then
echo -e "${GREEN}No changes in compose file${NC}"
else
while IFS= read -r line; do
if [[ "$line" == ---* ]] || [[ "$line" == +++* ]] || [[ "$line" == @@* ]]; then
echo -e "${YELLOW}$line${NC}"
elif [[ "$line" == -* ]]; then
echo -e "${RED}$line${NC}"
elif [[ "$line" == +* ]]; then
echo -e "${GREEN}$line${NC}"
else
echo "$line"
fi
done < "$tmp_dir/compose_diff.txt"
fi
rm -rf "$tmp_dir"
}
create_stack() {
local token="$1"
local env_json="$2"
local compose_content
compose_content=$(cat "$COMPOSE_FILE")
local compose_escaped
compose_escaped=$(echo "$compose_content" | jq -Rs .)
log_info "Creating new stack '$STACK_NAME'..."
local payload
payload=$(jq -n \
--arg name "$STACK_NAME" \
--argjson env "$env_json" \
--argjson stackFileContent "$compose_escaped" \
'{
"name": $name,
"env": $env,
"stackFileContent": $stackFileContent
}')
local response
response=$(curl -s -X POST "$PORTAINER_URL/api/stacks?type=2&method=string&endpointId=$ENDPOINT_ID" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d "$payload")
local error_msg
error_msg=$(echo "$response" | jq -r '.message // empty')
if [[ -n "$error_msg" ]]; then
log_error "Stack creation failed: $error_msg"
echo "$response" | jq .
exit 1
fi
local new_id
new_id=$(echo "$response" | jq -r '.Id')
log_info "Stack created successfully! (ID: $new_id)"
echo "$response" | jq '{Id, Name, Status, CreationDate}'
}
update_stack() {
local token="$1"
local stack_id="$2"
local dry_run="$3"
local compose_content
compose_content=$(cat "$COMPOSE_FILE")
local env_json
env_json=$(parse_env_to_json "$ENV_FILE")
if [[ "$dry_run" == "true" ]]; then
log_warn "DRY RUN - Not actually deploying"
show_diff "$token" "$stack_id" "$env_json"
echo ""
log_warn "DRY RUN complete - no changes made"
log_info "Run without --dry-run to apply these changes"
return 0
fi
local env_count
env_count=$(echo "$env_json" | jq 'length')
log_info "Deploying $env_count environment variables"
log_info "Updating stack '$STACK_NAME' (ID: $stack_id)..."
local compose_escaped
compose_escaped=$(echo "$compose_content" | jq -Rs .)
local payload
payload=$(jq -n \
--argjson env "$env_json" \
--argjson stackFileContent "$compose_escaped" \
'{
"env": $env,
"stackFileContent": $stackFileContent,
"prune": true,
"pullImage": true
}')
local response
response=$(curl -s -X PUT "$PORTAINER_URL/api/stacks/$stack_id?endpointId=$ENDPOINT_ID" \
-H "Authorization: Bearer $token" \
-H "Content-Type: application/json" \
-d "$payload")
local error_msg
error_msg=$(echo "$response" | jq -r '.message // empty')
if [[ -n "$error_msg" ]]; then
log_error "Deployment failed: $error_msg"
echo "$response" | jq .
exit 1
fi
log_info "Stack updated successfully!"
echo "$response" | jq '{Id, Name, Status, CreationDate, UpdateDate}'
}
main() {
local dry_run=false
while [[ $# -gt 0 ]]; do
case $1 in
--dry-run)
dry_run=true
shift
;;
--help|-h)
echo "Usage: $0 [--dry-run]"
echo ""
echo "Deploy mcpctl stack to Portainer"
echo ""
echo "Options:"
echo " --dry-run Show what would be deployed without actually deploying"
echo " --help Show this help message"
echo ""
echo "Environment variables:"
echo " PORTAINER_URL Portainer URL (default: http://10.0.0.194:9000)"
echo " PORTAINER_USER Portainer username (default: michal)"
echo " PORTAINER_PASSWORD Portainer password (or store in ~/.portainer_password)"
exit 0
;;
*)
log_error "Unknown option: $1"
exit 1
;;
esac
done
echo "========================================"
echo " mcpctl Stack Deployment"
echo "========================================"
echo ""
check_files
local password
password=$(get_password)
local token
token=$(get_jwt_token "$password")
log_info "Authentication successful"
# Find or create stack
local stack_id
stack_id=$(find_stack_id "$token")
if [[ -z "$stack_id" ]]; then
if [[ "$dry_run" == "true" ]]; then
log_warn "Stack '$STACK_NAME' does not exist yet"
log_info "A real deploy would create it"
return 0
fi
log_info "Stack '$STACK_NAME' not found, creating..."
local env_json
env_json=$(parse_env_to_json "$ENV_FILE")
create_stack "$token" "$env_json"
else
local stack_info
stack_info=$(get_stack_info "$token" "$stack_id")
local status_code
status_code=$(echo "$stack_info" | jq -r '.Status // 0')
local status_text="Unknown"
case "$status_code" in
1) status_text="Active" ;;
2) status_text="Inactive" ;;
esac
log_info "Current stack status: $status_text (ID: $stack_id, Env vars: $(echo "$stack_info" | jq '.Env | length'))"
echo ""
update_stack "$token" "$stack_id" "$dry_run"
fi
echo ""
log_info "Done!"
if [[ "$dry_run" == "false" ]]; then
log_info "Check Portainer UI to verify containers are running"
log_info "URL: $PORTAINER_URL/#!/$ENDPOINT_ID/docker/stacks/$STACK_NAME"
fi
}
main "$@"

View File

@@ -49,6 +49,9 @@ COPY --from=builder /app/src/shared/dist/ src/shared/dist/
COPY --from=builder /app/src/db/dist/ src/db/dist/
COPY --from=builder /app/src/mcpd/dist/ src/mcpd/dist/
# Copy templates for seeding
COPY templates/ templates/
# Copy entrypoint
COPY deploy/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

View File

@@ -0,0 +1,13 @@
# Base container for npm-based MCP servers (STDIO transport).
# mcpd uses this image to run `npx -y <packageName>` when a server
# has packageName but no dockerImage.
# Using slim (Debian) instead of alpine for better npm package compatibility.
FROM node:20-slim
WORKDIR /mcp
# Pre-warm npx cache directory
RUN mkdir -p /root/.npm
# Default entrypoint — overridden by mcpd via container command
ENTRYPOINT ["npx", "-y"]

View File

@@ -30,6 +30,8 @@ services:
MCPD_PORT: "3100"
MCPD_HOST: "0.0.0.0"
MCPD_LOG_LEVEL: info
MCPD_NODE_RUNNER_IMAGE: mcpctl-node-runner:latest
MCPD_MCP_NETWORK: mcp-servers
depends_on:
postgres:
condition: service_healthy
@@ -48,6 +50,16 @@ services:
retries: 3
start_period: 10s
# Base image for npm-based MCP servers (built once, used by mcpd)
node-runner:
build:
context: ..
dockerfile: deploy/Dockerfile.node-runner
image: mcpctl-node-runner:latest
profiles:
- build
entrypoint: ["echo", "Image built successfully"]
postgres-test:
image: postgres:16-alpine
container_name: mcpctl-postgres-test
@@ -71,8 +83,11 @@ networks:
mcpctl:
driver: bridge
mcp-servers:
name: mcp-servers
driver: bridge
internal: true
# Not internal — MCP servers need outbound access to reach external APIs
# (e.g., Grafana, Home Assistant). Isolation is enforced by not binding
# host ports on MCP server containers; only mcpd can reach them.
volumes:
mcpctl-pgdata:

View File

@@ -4,8 +4,8 @@ set -e
echo "mcpd: pushing database schema..."
pnpm -F @mcpctl/db exec prisma db push --schema=prisma/schema.prisma --accept-data-loss 2>&1
echo "mcpd: seeding default data..."
node src/mcpd/dist/seed-runner.js
echo "mcpd: seeding templates..."
TEMPLATES_DIR=templates node src/mcpd/dist/seed-runner.js
echo "mcpd: starting server..."
exec node src/mcpd/dist/main.js

15
deploy/mcplocal.service Normal file
View File

@@ -0,0 +1,15 @@
[Unit]
Description=mcpctl local MCP proxy
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/mcpctl-local
Restart=on-failure
RestartSec=5
Environment=MCPLOCAL_MCPD_URL=http://10.0.0.194:3100
Environment=MCPLOCAL_HTTP_PORT=3200
Environment=MCPLOCAL_HTTP_HOST=127.0.0.1
[Install]
WantedBy=default.target

View File

@@ -96,10 +96,12 @@ servers:
description: Slack MCP server
transport: STDIO
packageName: "@anthropic/slack-mcp"
envTemplate:
env:
- name: SLACK_TOKEN
description: Slack bot token
isSecret: true
valueFrom:
secretRef:
name: slack-secrets
key: token
- name: github
description: GitHub MCP server

28
examples/ha-mcp.yaml Normal file
View File

@@ -0,0 +1,28 @@
servers:
- name: ha-mcp
description: "Home Assistant MCP - smart home control via MCP"
dockerImage: "ghcr.io/homeassistant-ai/ha-mcp:2.4"
transport: STREAMABLE_HTTP
containerPort: 3000
# For mcpd-managed containers:
command:
- python
- "-c"
- "from ha_mcp.server import HomeAssistantSmartMCPServer; s = HomeAssistantSmartMCPServer(); s.mcp.run(transport='sse', host='0.0.0.0', port=3000)"
# For connecting to an already-running instance (host.containers.internal for container-to-host):
externalUrl: "http://host.containers.internal:8086/mcp"
env:
- name: HOMEASSISTANT_URL
value: ""
- name: HOMEASSISTANT_TOKEN
valueFrom:
secretRef:
name: ha-secrets
key: token
profiles:
- name: production
server: ha-mcp
envOverrides:
HOMEASSISTANT_URL: "https://ha.itaz.eu"
HOMEASSISTANT_TOKEN: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiIyNjFlZTRhOWI2MGM0YTllOGJkNTIxN2Q3YmVmZDkzNSIsImlhdCI6MTc3MDA3NjYzOCwiZXhwIjoyMDg1NDM2NjM4fQ.17mAQxIrCBrQx3ogqAUetwEt-cngRmJiH-e7sLt-3FY"

35
fulldeploy.sh Executable file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
# Full deployment: Docker image → Portainer stack → RPM build/publish/install
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Load .env
if [ -f .env ]; then
set -a; source .env; set +a
fi
echo "========================================"
echo " mcpctl Full Deploy"
echo "========================================"
echo ""
echo ">>> Step 1/3: Build & push mcpd Docker image"
echo ""
bash scripts/build-mcpd.sh "$@"
echo ""
echo ">>> Step 2/3: Deploy stack to production"
echo ""
bash deploy.sh
echo ""
echo ">>> Step 3/3: Build, publish & install RPM"
echo ""
bash scripts/release.sh
echo ""
echo "========================================"
echo " Full deploy complete!"
echo "========================================"

26
installlocal.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# Build (if needed) and install mcpctl RPM locally
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
RPM_FILE=$(ls dist/mcpctl-*.rpm 2>/dev/null | head -1)
# Build if no RPM exists or if source is newer than the RPM
if [[ -z "$RPM_FILE" ]] || [[ $(find src/ -name '*.ts' -newer "$RPM_FILE" 2>/dev/null | head -1) ]]; then
echo "==> Building RPM..."
bash scripts/build-rpm.sh
RPM_FILE=$(ls dist/mcpctl-*.rpm 2>/dev/null | head -1)
else
echo "==> RPM is up to date: $RPM_FILE"
fi
echo "==> Installing $RPM_FILE..."
sudo rpm -Uvh --force "$RPM_FILE"
echo "==> Reloading systemd user units..."
systemctl --user daemon-reload
echo "==> Done!"
echo " Enable mcplocal: systemctl --user enable --now mcplocal"

View File

@@ -5,11 +5,21 @@ release: "1"
maintainer: michal
description: kubectl-like CLI for managing MCP servers
license: MIT
depends:
- jq
contents:
- src: ./dist/mcpctl
dst: /usr/bin/mcpctl
file_info:
mode: 0755
- src: ./dist/mcpctl-local
dst: /usr/bin/mcpctl-local
file_info:
mode: 0755
- src: ./deploy/mcplocal.service
dst: /usr/lib/systemd/user/mcplocal.service
file_info:
mode: 0644
- src: ./completions/mcpctl.bash
dst: /usr/share/bash-completion/completions/mcpctl
file_info:

View File

@@ -18,7 +18,11 @@
"typecheck": "tsc --build",
"rpm:build": "bash scripts/build-rpm.sh",
"rpm:publish": "bash scripts/publish-rpm.sh",
"release": "bash scripts/release.sh"
"release": "bash scripts/release.sh",
"mcpd:build": "bash scripts/build-mcpd.sh",
"mcpd:deploy": "bash deploy.sh",
"mcpd:deploy-dry": "bash deploy.sh --dry-run",
"mcpd:logs": "bash logs.sh"
},
"engines": {
"node": ">=20.0.0",

6
pnpm-lock.yaml generated
View File

@@ -112,6 +112,9 @@ importers:
fastify:
specifier: ^5.0.0
version: 5.7.4
js-yaml:
specifier: ^4.1.0
version: 4.1.1
zod:
specifier: ^3.24.0
version: 3.25.76
@@ -122,6 +125,9 @@ importers:
'@types/dockerode':
specifier: ^4.0.1
version: 4.0.1
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
'@types/node':
specifier: ^25.3.0
version: 25.3.0

55
pr.sh Executable file
View File

@@ -0,0 +1,55 @@
#!/usr/bin/env bash
# Usage: bash pr.sh "PR title" "PR body"
# Loads GITEA_TOKEN from .env automatically
set -euo pipefail
# Load .env if GITEA_TOKEN not already exported
if [ -z "${GITEA_TOKEN:-}" ] && [ -f .env ]; then
set -a
source .env
set +a
fi
GITEA_URL="${GITEA_URL:-http://10.0.0.194:3012}"
REPO="${GITEA_OWNER:-michal}/mcpctl"
TITLE="${1:?Usage: pr.sh <title> [body]}"
BODY="${2:-}"
BASE="${3:-main}"
HEAD=$(git rev-parse --abbrev-ref HEAD)
if [ "$HEAD" = "$BASE" ]; then
echo "Error: already on $BASE, switch to a feature branch first" >&2
exit 1
fi
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "Error: GITEA_TOKEN not set and .env not found" >&2
exit 1
fi
# Push if needed
if ! git rev-parse --verify "origin/$HEAD" &>/dev/null; then
git push -u origin "$HEAD"
else
git push
fi
# Create PR
RESPONSE=$(curl -s -X POST "$GITEA_URL/api/v1/repos/$REPO/pulls" \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n --arg t "$TITLE" --arg b "$BODY" --arg h "$HEAD" --arg base "$BASE" \
'{title: $t, body: $b, head: $h, base: $base}')")
PR_NUM=$(echo "$RESPONSE" | jq -r '.number // empty')
PR_URL=$(echo "$RESPONSE" | jq -r '.html_url // empty')
if [ -z "$PR_NUM" ]; then
echo "Error creating PR:" >&2
echo "$RESPONSE" | jq . 2>/dev/null || echo "$RESPONSE" >&2
exit 1
fi
echo "PR #$PR_NUM: https://mysources.co.uk/$REPO/pulls/$PR_NUM"

32
scripts/build-mcpd.sh Executable file
View File

@@ -0,0 +1,32 @@
#!/bin/bash
# Build mcpd Docker image and push to Gitea container registry
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
# Load .env for GITEA_TOKEN
if [ -f .env ]; then
set -a; source .env; set +a
fi
# Push directly to internal address (external proxy has body size limit)
REGISTRY="10.0.0.194:3012"
IMAGE="mcpd"
TAG="${1:-latest}"
echo "==> Building mcpd image..."
podman build -t "$IMAGE:$TAG" -f deploy/Dockerfile.mcpd .
echo "==> Tagging as $REGISTRY/michal/$IMAGE:$TAG..."
podman tag "$IMAGE:$TAG" "$REGISTRY/michal/$IMAGE:$TAG"
echo "==> Logging in to $REGISTRY..."
podman login --tls-verify=false -u michal -p "$GITEA_TOKEN" "$REGISTRY"
echo "==> Pushing to $REGISTRY/michal/$IMAGE:$TAG..."
podman push --tls-verify=false "$REGISTRY/michal/$IMAGE:$TAG"
echo "==> Done!"
echo " Image: $REGISTRY/michal/$IMAGE:$TAG"

View File

@@ -16,10 +16,11 @@ export PATH="$HOME/.npm-global/bin:$HOME/.bun/bin:$HOME/.local/bin:$PATH"
echo "==> Building TypeScript..."
pnpm build
echo "==> Bundling standalone binary..."
echo "==> Bundling standalone binaries..."
mkdir -p dist
rm -f dist/mcpctl dist/mcpctl-*.rpm
rm -f dist/mcpctl dist/mcpctl-local dist/mcpctl-*.rpm
bun build src/cli/src/index.ts --compile --outfile dist/mcpctl
bun build src/mcplocal/src/main.ts --compile --outfile dist/mcpctl-local
echo "==> Packaging RPM..."
nfpm pkg --packager rpm --target dist/

View File

@@ -24,7 +24,10 @@ export class ApiError extends Error {
function request<T>(method: string, url: string, timeout: number, body?: unknown, token?: string): Promise<ApiResponse<T>> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const headers: Record<string, string> = {};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}

View File

@@ -4,6 +4,22 @@ import yaml from 'js-yaml';
import { z } from 'zod';
import type { ApiClient } from '../api-client.js';
const HealthCheckSchema = z.object({
tool: z.string().min(1),
arguments: z.record(z.unknown()).default({}),
intervalSeconds: z.number().int().min(5).max(3600).default(60),
timeoutSeconds: z.number().int().min(1).max(120).default(10),
failureThreshold: z.number().int().min(1).max(20).default(3),
});
const ServerEnvEntrySchema = z.object({
name: z.string().min(1),
value: z.string().optional(),
valueFrom: z.object({
secretRef: z.object({ name: z.string(), key: z.string() }),
}).optional(),
});
const ServerSpecSchema = z.object({
name: z.string().min(1),
description: z.string().default(''),
@@ -11,31 +27,114 @@ const ServerSpecSchema = z.object({
dockerImage: z.string().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().url().optional(),
envTemplate: z.array(z.object({
name: z.string(),
description: z.string().default(''),
isSecret: z.boolean().default(false),
})).default([]),
externalUrl: z.string().url().optional(),
command: z.array(z.string()).optional(),
containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1),
env: z.array(ServerEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
});
const ProfileSpecSchema = z.object({
const SecretSpecSchema = z.object({
name: z.string().min(1),
server: z.string().min(1),
permissions: z.array(z.string()).default([]),
envOverrides: z.record(z.string()).default({}),
data: z.record(z.string()).default({}),
});
const TemplateEnvEntrySchema = z.object({
name: z.string().min(1),
description: z.string().optional(),
required: z.boolean().optional(),
defaultValue: z.string().optional(),
});
const TemplateSpecSchema = z.object({
name: z.string().min(1),
version: z.string().default('1.0.0'),
description: z.string().default(''),
packageName: z.string().optional(),
dockerImage: z.string().optional(),
transport: z.enum(['STDIO', 'SSE', 'STREAMABLE_HTTP']).default('STDIO'),
repositoryUrl: z.string().optional(),
externalUrl: z.string().optional(),
command: z.array(z.string()).optional(),
containerPort: z.number().int().min(1).max(65535).optional(),
replicas: z.number().int().min(0).max(10).default(1),
env: z.array(TemplateEnvEntrySchema).default([]),
healthCheck: HealthCheckSchema.optional(),
});
const UserSpecSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().optional(),
});
const GroupSpecSchema = z.object({
name: z.string().min(1),
description: z.string().default(''),
members: z.array(z.string().email()).default([]),
});
const RbacSubjectSchema = z.object({
kind: z.enum(['User', 'Group', 'ServiceAccount']),
name: z.string().min(1),
});
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers', instance: 'instances', secret: 'secrets',
project: 'projects', template: 'templates', user: 'users', group: 'groups',
prompt: 'prompts', promptrequest: 'promptrequests',
};
const RbacRoleBindingSchema = z.union([
z.object({
role: z.enum(['edit', 'view', 'create', 'delete', 'run', 'expose']),
resource: z.string().min(1).transform((r) => RESOURCE_ALIASES[r] ?? r),
name: z.string().min(1).optional(),
}),
z.object({
role: z.literal('run'),
action: z.string().min(1),
}),
]);
const RbacBindingSpecSchema = z.object({
name: z.string().min(1),
subjects: z.array(RbacSubjectSchema).default([]),
roleBindings: z.array(RbacRoleBindingSchema).default([]),
});
const PromptSpecSchema = z.object({
name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/),
content: z.string().min(1).max(50000),
projectId: z.string().optional(),
});
const ProjectSpecSchema = z.object({
name: z.string().min(1),
description: z.string().default(''),
profiles: z.array(z.string()).default([]),
prompt: z.string().max(10000).default(''),
proxyMode: z.enum(['direct', 'filtered']).default('direct'),
llmProvider: z.string().optional(),
llmModel: z.string().optional(),
servers: z.array(z.string()).default([]),
});
const ApplyConfigSchema = z.object({
secrets: z.array(SecretSpecSchema).default([]),
servers: z.array(ServerSpecSchema).default([]),
profiles: z.array(ProfileSpecSchema).default([]),
users: z.array(UserSpecSchema).default([]),
groups: z.array(GroupSpecSchema).default([]),
projects: z.array(ProjectSpecSchema).default([]),
});
templates: z.array(TemplateSpecSchema).default([]),
rbacBindings: z.array(RbacBindingSpecSchema).default([]),
rbac: z.array(RbacBindingSpecSchema).default([]),
prompts: z.array(PromptSpecSchema).default([]),
}).transform((data) => ({
...data,
// Merge rbac into rbacBindings so both keys work
rbacBindings: [...data.rbacBindings, ...data.rbac],
}));
export type ApplyConfig = z.infer<typeof ApplyConfigSchema>;
@@ -49,16 +148,26 @@ export function createApplyCommand(deps: ApplyCommandDeps): Command {
return new Command('apply')
.description('Apply declarative configuration from a YAML or JSON file')
.argument('<file>', 'Path to config file (.yaml, .yml, or .json)')
.argument('[file]', 'Path to config file (.yaml, .yml, or .json)')
.option('-f, --file <file>', 'Path to config file (alternative to positional arg)')
.option('--dry-run', 'Validate and show changes without applying')
.action(async (file: string, opts: { dryRun?: boolean }) => {
.action(async (fileArg: string | undefined, opts: { file?: string; dryRun?: boolean }) => {
const file = fileArg ?? opts.file;
if (!file) {
throw new Error('File path required. Usage: mcpctl apply <file> or mcpctl apply -f <file>');
}
const config = loadConfigFile(file);
if (opts.dryRun) {
log('Dry run - would apply:');
if (config.secrets.length > 0) log(` ${config.secrets.length} secret(s)`);
if (config.servers.length > 0) log(` ${config.servers.length} server(s)`);
if (config.profiles.length > 0) log(` ${config.profiles.length} profile(s)`);
if (config.users.length > 0) log(` ${config.users.length} user(s)`);
if (config.groups.length > 0) log(` ${config.groups.length} group(s)`);
if (config.projects.length > 0) log(` ${config.projects.length} project(s)`);
if (config.templates.length > 0) log(` ${config.templates.length} template(s)`);
if (config.rbacBindings.length > 0) log(` ${config.rbacBindings.length} rbacBinding(s)`);
if (config.prompts.length > 0) log(` ${config.prompts.length} prompt(s)`);
return;
}
@@ -80,7 +189,25 @@ function loadConfigFile(path: string): ApplyConfig {
}
async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args: unknown[]) => void): Promise<void> {
// Apply servers first (profiles depend on servers)
// Apply order: secrets, servers, users, groups, projects, templates, rbacBindings
// Apply secrets
for (const secret of config.secrets) {
try {
const existing = await findByName(client, 'secrets', secret.name);
if (existing) {
await client.put(`/api/v1/secrets/${(existing as { id: string }).id}`, { data: secret.data });
log(`Updated secret: ${secret.name}`);
} else {
await client.post('/api/v1/secrets', secret);
log(`Created secret: ${secret.name}`);
}
} catch (err) {
log(`Error applying secret '${secret.name}': ${err instanceof Error ? err.message : err}`);
}
}
// Apply servers
for (const server of config.servers) {
try {
const existing = await findByName(client, 'servers', server.name);
@@ -96,57 +223,101 @@ async function applyConfig(client: ApiClient, config: ApplyConfig, log: (...args
}
}
// Apply profiles (need server IDs)
for (const profile of config.profiles) {
// Apply users (matched by email)
for (const user of config.users) {
try {
const server = await findByName(client, 'servers', profile.server);
if (!server) {
log(`Skipping profile '${profile.name}': server '${profile.server}' not found`);
continue;
}
const serverId = (server as { id: string }).id;
const existing = await findProfile(client, serverId, profile.name);
const existing = await findByField(client, 'users', 'email', user.email);
if (existing) {
await client.put(`/api/v1/profiles/${(existing as { id: string }).id}`, {
permissions: profile.permissions,
envOverrides: profile.envOverrides,
});
log(`Updated profile: ${profile.name} (server: ${profile.server})`);
await client.put(`/api/v1/users/${(existing as { id: string }).id}`, user);
log(`Updated user: ${user.email}`);
} else {
await client.post('/api/v1/profiles', {
name: profile.name,
serverId,
permissions: profile.permissions,
envOverrides: profile.envOverrides,
});
log(`Created profile: ${profile.name} (server: ${profile.server})`);
await client.post('/api/v1/users', user);
log(`Created user: ${user.email}`);
}
} catch (err) {
log(`Error applying profile '${profile.name}': ${err instanceof Error ? err.message : err}`);
log(`Error applying user '${user.email}': ${err instanceof Error ? err.message : err}`);
}
}
// Apply projects
// Apply groups
for (const group of config.groups) {
try {
const existing = await findByName(client, 'groups', group.name);
if (existing) {
await client.put(`/api/v1/groups/${(existing as { id: string }).id}`, group);
log(`Updated group: ${group.name}`);
} else {
await client.post('/api/v1/groups', group);
log(`Created group: ${group.name}`);
}
} catch (err) {
log(`Error applying group '${group.name}': ${err instanceof Error ? err.message : err}`);
}
}
// Apply projects (send full spec including servers)
for (const project of config.projects) {
try {
const existing = await findByName(client, 'projects', project.name);
if (existing) {
await client.put(`/api/v1/projects/${(existing as { id: string }).id}`, {
description: project.description,
});
await client.put(`/api/v1/projects/${(existing as { id: string }).id}`, project);
log(`Updated project: ${project.name}`);
} else {
await client.post('/api/v1/projects', {
name: project.name,
description: project.description,
});
await client.post('/api/v1/projects', project);
log(`Created project: ${project.name}`);
}
} catch (err) {
log(`Error applying project '${project.name}': ${err instanceof Error ? err.message : err}`);
}
}
// Apply templates
for (const template of config.templates) {
try {
const existing = await findByName(client, 'templates', template.name);
if (existing) {
await client.put(`/api/v1/templates/${(existing as { id: string }).id}`, template);
log(`Updated template: ${template.name}`);
} else {
await client.post('/api/v1/templates', template);
log(`Created template: ${template.name}`);
}
} catch (err) {
log(`Error applying template '${template.name}': ${err instanceof Error ? err.message : err}`);
}
}
// Apply RBAC bindings
for (const rbacBinding of config.rbacBindings) {
try {
const existing = await findByName(client, 'rbac', rbacBinding.name);
if (existing) {
await client.put(`/api/v1/rbac/${(existing as { id: string }).id}`, rbacBinding);
log(`Updated rbacBinding: ${rbacBinding.name}`);
} else {
await client.post('/api/v1/rbac', rbacBinding);
log(`Created rbacBinding: ${rbacBinding.name}`);
}
} catch (err) {
log(`Error applying rbacBinding '${rbacBinding.name}': ${err instanceof Error ? err.message : err}`);
}
}
// Apply prompts
for (const prompt of config.prompts) {
try {
const existing = await findByName(client, 'prompts', prompt.name);
if (existing) {
await client.put(`/api/v1/prompts/${(existing as { id: string }).id}`, { content: prompt.content });
log(`Updated prompt: ${prompt.name}`);
} else {
await client.post('/api/v1/prompts', prompt);
log(`Created prompt: ${prompt.name}`);
}
} catch (err) {
log(`Error applying prompt '${prompt.name}': ${err instanceof Error ? err.message : err}`);
}
}
}
async function findByName(client: ApiClient, resource: string, name: string): Promise<unknown | null> {
@@ -158,12 +329,10 @@ async function findByName(client: ApiClient, resource: string, name: string): Pr
}
}
async function findProfile(client: ApiClient, serverId: string, name: string): Promise<unknown | null> {
async function findByField<T extends string>(client: ApiClient, resource: string, field: T, value: string): Promise<unknown | null> {
try {
const profiles = await client.get<Array<{ name: string; serverId: string }>>(
`/api/v1/profiles?serverId=${serverId}`,
);
return profiles.find((p) => p.name === name) ?? null;
const items = await client.get<Array<Record<string, unknown>>>(`/api/v1/${resource}`);
return items.find((item) => item[field] === value) ?? null;
} catch {
return null;
}

View File

@@ -10,6 +10,10 @@ export interface PromptDeps {
password(message: string): Promise<string>;
}
export interface StatusResponse {
hasUsers: boolean;
}
export interface AuthCommandDeps {
configDeps: Partial<ConfigLoaderDeps>;
credentialsDeps: Partial<CredentialsDeps>;
@@ -17,6 +21,8 @@ export interface AuthCommandDeps {
log: (...args: string[]) => void;
loginRequest: (mcpdUrl: string, email: string, password: string) => Promise<LoginResponse>;
logoutRequest: (mcpdUrl: string, token: string) => Promise<void>;
statusRequest: (mcpdUrl: string) => Promise<StatusResponse>;
bootstrapRequest: (mcpdUrl: string, email: string, password: string, name?: string) => Promise<LoginResponse>;
}
interface LoginResponse {
@@ -80,6 +86,70 @@ function defaultLogoutRequest(mcpdUrl: string, token: string): Promise<void> {
});
}
function defaultStatusRequest(mcpdUrl: string): Promise<StatusResponse> {
return new Promise((resolve, reject) => {
const url = new URL('/api/v1/auth/status', mcpdUrl);
const opts: http.RequestOptions = {
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'GET',
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
};
const req = http.request(opts, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
if ((res.statusCode ?? 0) >= 400) {
reject(new Error(`Status check failed (${res.statusCode}): ${raw}`));
return;
}
resolve(JSON.parse(raw) as StatusResponse);
});
});
req.on('error', (err) => reject(new Error(`Cannot reach mcpd: ${err.message}`)));
req.on('timeout', () => { req.destroy(); reject(new Error('Status request timed out')); });
req.end();
});
}
function defaultBootstrapRequest(mcpdUrl: string, email: string, password: string, name?: string): Promise<LoginResponse> {
return new Promise((resolve, reject) => {
const url = new URL('/api/v1/auth/bootstrap', mcpdUrl);
const payload: Record<string, string> = { email, password };
if (name) {
payload['name'] = name;
}
const body = JSON.stringify(payload);
const opts: http.RequestOptions = {
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
timeout: 10000,
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
};
const req = http.request(opts, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf-8');
if ((res.statusCode ?? 0) >= 400) {
reject(new Error(`Bootstrap failed (${res.statusCode}): ${raw}`));
return;
}
resolve(JSON.parse(raw) as LoginResponse);
});
});
req.on('error', (err) => reject(new Error(`Cannot reach mcpd: ${err.message}`)));
req.on('timeout', () => { req.destroy(); reject(new Error('Bootstrap request timed out')); });
req.write(body);
req.end();
});
}
async function defaultInput(message: string): Promise<string> {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]);
@@ -99,10 +169,12 @@ const defaultDeps: AuthCommandDeps = {
log: (...args) => console.log(...args),
loginRequest: defaultLoginRequest,
logoutRequest: defaultLogoutRequest,
statusRequest: defaultStatusRequest,
bootstrapRequest: defaultBootstrapRequest,
};
export function createLoginCommand(deps?: Partial<AuthCommandDeps>): Command {
const { configDeps, credentialsDeps, prompt, log, loginRequest } = { ...defaultDeps, ...deps };
const { configDeps, credentialsDeps, prompt, log, loginRequest, statusRequest, bootstrapRequest } = { ...defaultDeps, ...deps };
return new Command('login')
.description('Authenticate with mcpd')
@@ -111,10 +183,28 @@ export function createLoginCommand(deps?: Partial<AuthCommandDeps>): Command {
const config = loadConfig(configDeps);
const mcpdUrl = opts.mcpdUrl ?? config.mcpdUrl;
try {
const status = await statusRequest(mcpdUrl);
if (!status.hasUsers) {
log('No users configured. Creating first admin account.');
const email = await prompt.input('Email:');
const password = await prompt.password('Password:');
const name = await prompt.input('Name (optional):');
const result = name
? await bootstrapRequest(mcpdUrl, email, password, name)
: await bootstrapRequest(mcpdUrl, email, password);
saveCredentials({
token: result.token,
mcpdUrl,
user: result.user.email,
}, credentialsDeps);
log(`Logged in as ${result.user.email} (admin)`);
} else {
const email = await prompt.input('Email:');
const password = await prompt.password('Password:');
try {
const result = await loginRequest(mcpdUrl, email, password);
saveCredentials({
token: result.token,
@@ -122,6 +212,7 @@ export function createLoginCommand(deps?: Partial<AuthCommandDeps>): Command {
user: result.user.email,
}, credentialsDeps);
log(`Logged in as ${result.user.email}`);
}
} catch (err) {
log(`Login failed: ${(err as Error).message}`);
process.exitCode = 1;

View File

@@ -1,155 +0,0 @@
import { Command } from 'commander';
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
import { resolve } from 'node:path';
import type { ApiClient } from '../api-client.js';
interface McpConfig {
mcpServers: Record<string, { command: string; args: string[]; env?: Record<string, string> }>;
}
export interface ClaudeCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createClaudeCommand(deps: ClaudeCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('claude')
.description('Manage Claude MCP configuration (.mcp.json)');
cmd
.command('generate <projectId>')
.description('Generate .mcp.json from a project configuration')
.option('-o, --output <path>', 'Output file path', '.mcp.json')
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
.option('--stdout', 'Print to stdout instead of writing a file')
.action(async (projectId: string, opts: { output: string; merge?: boolean; stdout?: boolean }) => {
const config = await client.get<McpConfig>(`/api/v1/projects/${projectId}/mcp-config`);
if (opts.stdout) {
log(JSON.stringify(config, null, 2));
return;
}
const outputPath = resolve(opts.output);
let finalConfig = config;
if (opts.merge && existsSync(outputPath)) {
try {
const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig;
finalConfig = {
mcpServers: {
...existing.mcpServers,
...config.mcpServers,
},
};
} catch {
// If existing file is invalid, just overwrite
}
}
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
const serverCount = Object.keys(finalConfig.mcpServers).length;
log(`Wrote ${outputPath} (${serverCount} server(s))`);
});
cmd
.command('show')
.description('Show current .mcp.json configuration')
.option('-p, --path <path>', 'Path to .mcp.json', '.mcp.json')
.action((opts: { path: string }) => {
const filePath = resolve(opts.path);
if (!existsSync(filePath)) {
log(`No .mcp.json found at ${filePath}`);
return;
}
const content = readFileSync(filePath, 'utf-8');
try {
const config = JSON.parse(content) as McpConfig;
const servers = Object.entries(config.mcpServers ?? {});
if (servers.length === 0) {
log('No MCP servers configured.');
return;
}
log(`MCP servers in ${filePath}:\n`);
for (const [name, server] of servers) {
log(` ${name}`);
log(` command: ${server.command} ${server.args.join(' ')}`);
if (server.env) {
const envKeys = Object.keys(server.env);
log(` env: ${envKeys.join(', ')}`);
}
}
} catch {
log(`Invalid JSON in ${filePath}`);
}
});
cmd
.command('add <name>')
.description('Add an MCP server entry to .mcp.json')
.requiredOption('-c, --command <cmd>', 'Command to run')
.option('-a, --args <args...>', 'Command arguments')
.option('-e, --env <key=value...>', 'Environment variables')
.option('-p, --path <path>', 'Path to .mcp.json', '.mcp.json')
.action((name: string, opts: { command: string; args?: string[]; env?: string[]; path: string }) => {
const filePath = resolve(opts.path);
let config: McpConfig = { mcpServers: {} };
if (existsSync(filePath)) {
try {
config = JSON.parse(readFileSync(filePath, 'utf-8')) as McpConfig;
} catch {
// Start fresh
}
}
const entry: { command: string; args: string[]; env?: Record<string, string> } = {
command: opts.command,
args: opts.args ?? [],
};
if (opts.env && opts.env.length > 0) {
const env: Record<string, string> = {};
for (const pair of opts.env) {
const eqIdx = pair.indexOf('=');
if (eqIdx > 0) {
env[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1);
}
}
entry.env = env;
}
config.mcpServers[name] = entry;
writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
log(`Added '${name}' to ${filePath}`);
});
cmd
.command('remove <name>')
.description('Remove an MCP server entry from .mcp.json')
.option('-p, --path <path>', 'Path to .mcp.json', '.mcp.json')
.action((name: string, opts: { path: string }) => {
const filePath = resolve(opts.path);
if (!existsSync(filePath)) {
log(`No .mcp.json found at ${filePath}`);
return;
}
try {
const config = JSON.parse(readFileSync(filePath, 'utf-8')) as McpConfig;
if (!(name in config.mcpServers)) {
log(`Server '${name}' not found in ${filePath}`);
return;
}
delete config.mcpServers[name];
writeFileSync(filePath, JSON.stringify(config, null, 2) + '\n');
log(`Removed '${name}' from ${filePath}`);
} catch {
log(`Invalid JSON in ${filePath}`);
}
});
return cmd;
}

View File

@@ -0,0 +1,347 @@
import { Command } from 'commander';
import http from 'node:http';
import https from 'node:https';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { loadConfig, saveConfig } from '../config/index.js';
import type { ConfigLoaderDeps, McpctlConfig, LlmConfig, LlmProviderName } from '../config/index.js';
import type { SecretStore } from '@mcpctl/shared';
import { createSecretStore } from '@mcpctl/shared';
const execFileAsync = promisify(execFile);
export interface ConfigSetupPrompt {
select<T>(message: string, choices: Array<{ name: string; value: T; description?: string }>): Promise<T>;
input(message: string, defaultValue?: string): Promise<string>;
password(message: string): Promise<string>;
confirm(message: string, defaultValue?: boolean): Promise<boolean>;
}
export interface ConfigSetupDeps {
configDeps: Partial<ConfigLoaderDeps>;
secretStore: SecretStore;
log: (...args: string[]) => void;
prompt: ConfigSetupPrompt;
fetchModels: (url: string, path: string) => Promise<string[]>;
whichBinary: (name: string) => Promise<string | null>;
}
interface ProviderChoice {
name: string;
value: LlmProviderName;
description: string;
}
const PROVIDER_CHOICES: ProviderChoice[] = [
{ name: 'Gemini CLI', value: 'gemini-cli', description: 'Google Gemini via local CLI (free, no API key)' },
{ name: 'Ollama', value: 'ollama', description: 'Local models via Ollama' },
{ name: 'Anthropic (Claude)', value: 'anthropic', description: 'Claude API (requires API key)' },
{ name: 'vLLM', value: 'vllm', description: 'Self-hosted vLLM (OpenAI-compatible)' },
{ name: 'OpenAI', value: 'openai', description: 'OpenAI API (requires API key)' },
{ name: 'DeepSeek', value: 'deepseek', description: 'DeepSeek API (requires API key)' },
{ name: 'None (disable)', value: 'none', description: 'Disable LLM features' },
];
const GEMINI_MODELS = ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash'];
const ANTHROPIC_MODELS = ['claude-haiku-3-5-20241022', 'claude-sonnet-4-20250514', 'claude-opus-4-20250514'];
const DEEPSEEK_MODELS = ['deepseek-chat', 'deepseek-reasoner'];
function defaultFetchModels(baseUrl: string, path: string): Promise<string[]> {
return new Promise((resolve) => {
const url = new URL(path, baseUrl);
const isHttps = url.protocol === 'https:';
const transport = isHttps ? https : http;
const req = transport.get({
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
timeout: 5000,
}, (res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
try {
const raw = Buffer.concat(chunks).toString('utf-8');
const data = JSON.parse(raw) as { models?: Array<{ name: string }>; data?: Array<{ id: string }> };
// Ollama format: { models: [{ name }] }
if (data.models) {
resolve(data.models.map((m) => m.name));
return;
}
// OpenAI/vLLM format: { data: [{ id }] }
if (data.data) {
resolve(data.data.map((m) => m.id));
return;
}
resolve([]);
} catch {
resolve([]);
}
});
});
req.on('error', () => resolve([]));
req.on('timeout', () => { req.destroy(); resolve([]); });
});
}
async function defaultSelect<T>(message: string, choices: Array<{ name: string; value: T; description?: string }>): Promise<T> {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{
type: 'list',
name: 'answer',
message,
choices: choices.map((c) => ({
name: c.description ? `${c.name}${c.description}` : c.name,
value: c.value,
short: c.name,
})),
}]);
return answer as T;
}
async function defaultInput(message: string, defaultValue?: string): Promise<string> {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{
type: 'input',
name: 'answer',
message,
default: defaultValue,
}]);
return answer as string;
}
async function defaultPassword(message: string): Promise<string> {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'password', name: 'answer', message }]);
return answer as string;
}
async function defaultConfirm(message: string, defaultValue?: boolean): Promise<boolean> {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{
type: 'confirm',
name: 'answer',
message,
default: defaultValue ?? true,
}]);
return answer as boolean;
}
const defaultPrompt: ConfigSetupPrompt = {
select: defaultSelect,
input: defaultInput,
password: defaultPassword,
confirm: defaultConfirm,
};
async function defaultWhichBinary(name: string): Promise<string | null> {
try {
const { stdout } = await execFileAsync('which', [name], { timeout: 3000 });
const path = stdout.trim();
return path || null;
} catch {
return null;
}
}
export function createConfigSetupCommand(deps?: Partial<ConfigSetupDeps>): Command {
return new Command('setup')
.description('Interactive LLM provider setup wizard')
.action(async () => {
const configDeps = deps?.configDeps ?? {};
const log = deps?.log ?? ((...args: string[]) => console.log(...args));
const prompt = deps?.prompt ?? defaultPrompt;
const fetchModels = deps?.fetchModels ?? defaultFetchModels;
const whichBinary = deps?.whichBinary ?? defaultWhichBinary;
const secretStore = deps?.secretStore ?? await createSecretStore();
const config = loadConfig(configDeps);
const currentLlm = config.llm;
// Annotate current provider in choices
const choices = PROVIDER_CHOICES.map((c) => {
if (currentLlm?.provider === c.value) {
return { ...c, name: `${c.name} (current)` };
}
return c;
});
const provider = await prompt.select<LlmProviderName>('Select LLM provider:', choices);
if (provider === 'none') {
const updated: McpctlConfig = { ...config, llm: { provider: 'none' } };
saveConfig(updated, configDeps);
log('LLM disabled. Restart mcplocal: systemctl --user restart mcplocal');
return;
}
let llmConfig: LlmConfig;
switch (provider) {
case 'gemini-cli':
llmConfig = await setupGeminiCli(prompt, log, whichBinary, currentLlm);
break;
case 'ollama':
llmConfig = await setupOllama(prompt, fetchModels, currentLlm);
break;
case 'anthropic':
llmConfig = await setupApiKeyProvider(prompt, secretStore, 'anthropic', 'anthropic-api-key', ANTHROPIC_MODELS, currentLlm);
break;
case 'vllm':
llmConfig = await setupVllm(prompt, fetchModels, currentLlm);
break;
case 'openai':
llmConfig = await setupApiKeyProvider(prompt, secretStore, 'openai', 'openai-api-key', [], currentLlm);
break;
case 'deepseek':
llmConfig = await setupApiKeyProvider(prompt, secretStore, 'deepseek', 'deepseek-api-key', DEEPSEEK_MODELS, currentLlm);
break;
default:
return;
}
const updated: McpctlConfig = { ...config, llm: llmConfig };
saveConfig(updated, configDeps);
log(`\nLLM configured: ${llmConfig.provider}${llmConfig.model ? ` / ${llmConfig.model}` : ''}`);
log('Restart mcplocal: systemctl --user restart mcplocal');
});
}
async function setupGeminiCli(
prompt: ConfigSetupPrompt,
log: (...args: string[]) => void,
whichBinary: (name: string) => Promise<string | null>,
current?: LlmConfig,
): Promise<LlmConfig> {
const model = await prompt.select<string>('Select model:', [
...GEMINI_MODELS.map((m) => ({
name: m === current?.model ? `${m} (current)` : m,
value: m,
})),
{ name: 'Custom...', value: '__custom__' },
]);
const finalModel = model === '__custom__'
? await prompt.input('Model name:', current?.model)
: model;
// Auto-detect gemini binary path
let binaryPath: string | undefined;
const detected = await whichBinary('gemini');
if (detected) {
log(`Found gemini at: ${detected}`);
binaryPath = detected;
} else {
log('Warning: gemini binary not found in PATH');
const manualPath = await prompt.input('Binary path (or install with: npm i -g @google/gemini-cli):');
if (manualPath) binaryPath = manualPath;
}
return { provider: 'gemini-cli', model: finalModel, binaryPath };
}
async function setupOllama(prompt: ConfigSetupPrompt, fetchModels: ConfigSetupDeps['fetchModels'], current?: LlmConfig): Promise<LlmConfig> {
const url = await prompt.input('Ollama URL:', current?.url ?? 'http://localhost:11434');
// Try to fetch models from Ollama
const models = await fetchModels(url, '/api/tags');
let model: string;
if (models.length > 0) {
const choices = models.map((m) => ({
name: m === current?.model ? `${m} (current)` : m,
value: m,
}));
choices.push({ name: 'Custom...', value: '__custom__' });
model = await prompt.select<string>('Select model:', choices);
if (model === '__custom__') {
model = await prompt.input('Model name:', current?.model);
}
} else {
model = await prompt.input('Model name (could not fetch models):', current?.model ?? 'llama3.2');
}
return { provider: 'ollama', model, url };
}
async function setupVllm(prompt: ConfigSetupPrompt, fetchModels: ConfigSetupDeps['fetchModels'], current?: LlmConfig): Promise<LlmConfig> {
const url = await prompt.input('vLLM URL:', current?.url ?? 'http://localhost:8000');
// Try to fetch models from vLLM (OpenAI-compatible)
const models = await fetchModels(url, '/v1/models');
let model: string;
if (models.length > 0) {
const choices = models.map((m) => ({
name: m === current?.model ? `${m} (current)` : m,
value: m,
}));
choices.push({ name: 'Custom...', value: '__custom__' });
model = await prompt.select<string>('Select model:', choices);
if (model === '__custom__') {
model = await prompt.input('Model name:', current?.model);
}
} else {
model = await prompt.input('Model name (could not fetch models):', current?.model ?? 'default');
}
return { provider: 'vllm', model, url };
}
async function setupApiKeyProvider(
prompt: ConfigSetupPrompt,
secretStore: SecretStore,
provider: LlmProviderName,
secretKey: string,
hardcodedModels: string[],
current?: LlmConfig,
): Promise<LlmConfig> {
// Check for existing API key
const existingKey = await secretStore.get(secretKey);
let apiKey: string;
if (existingKey) {
const masked = `****${existingKey.slice(-4)}`;
const changeKey = await prompt.confirm(`API key stored (${masked}). Change it?`, false);
if (changeKey) {
apiKey = await prompt.password('API key:');
} else {
apiKey = existingKey;
}
} else {
apiKey = await prompt.password('API key:');
}
// Store API key
if (apiKey !== existingKey) {
await secretStore.set(secretKey, apiKey);
}
// Model selection
let model: string;
if (hardcodedModels.length > 0) {
const choices = hardcodedModels.map((m) => ({
name: m === current?.model ? `${m} (current)` : m,
value: m,
}));
choices.push({ name: 'Custom...', value: '__custom__' });
model = await prompt.select<string>('Select model:', choices);
if (model === '__custom__') {
model = await prompt.input('Model name:', current?.model);
}
} else {
model = await prompt.input('Model name:', current?.model ?? 'gpt-4o');
}
// Optional custom URL for openai
let url: string | undefined;
if (provider === 'openai') {
const customUrl = await prompt.confirm('Use custom API endpoint?', false);
if (customUrl) {
url = await prompt.input('API URL:', current?.url ?? 'https://api.openai.com');
}
}
return { provider, model, url };
}

View File

@@ -1,19 +1,36 @@
import { Command } from 'commander';
import { writeFileSync, readFileSync, existsSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { homedir } from 'node:os';
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js';
import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js';
import { formatJson, formatYaml } from '../formatters/index.js';
import { saveCredentials, loadCredentials } from '../auth/index.js';
import { createConfigSetupCommand } from './config-setup.js';
import type { CredentialsDeps, StoredCredentials } from '../auth/index.js';
import type { ApiClient } from '../api-client.js';
interface McpConfig {
mcpServers: Record<string, { command?: string; args?: string[]; url?: string; env?: Record<string, string> }>;
}
export interface ConfigCommandDeps {
configDeps: Partial<ConfigLoaderDeps>;
log: (...args: string[]) => void;
}
export interface ConfigApiDeps {
client: ApiClient;
credentialsDeps: Partial<CredentialsDeps>;
log: (...args: string[]) => void;
}
const defaultDeps: ConfigCommandDeps = {
configDeps: {},
log: (...args) => console.log(...args),
};
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>): Command {
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>, apiDeps?: ConfigApiDeps): Command {
const { configDeps, log } = { ...defaultDeps, ...deps };
const config = new Command('config').description('Manage mcpctl configuration');
@@ -68,5 +85,134 @@ export function createConfigCommand(deps?: Partial<ConfigCommandDeps>): Command
log('Configuration reset to defaults');
});
// claude/claude-generate: generate .mcp.json pointing at mcpctl mcp bridge
function registerClaudeCommand(name: string, hidden: boolean): void {
const cmd = config
.command(name)
.description(hidden ? '' : 'Generate .mcp.json that connects a project via mcpctl mcp bridge')
.requiredOption('--project <name>', 'Project name')
.option('-o, --output <path>', 'Output file path', '.mcp.json')
.option('--merge', 'Merge with existing .mcp.json instead of overwriting')
.option('--stdout', 'Print to stdout instead of writing a file')
.action((opts: { project: string; output: string; merge?: boolean; stdout?: boolean }) => {
const mcpConfig: McpConfig = {
mcpServers: {
[opts.project]: {
command: 'mcpctl',
args: ['mcp', '-p', opts.project],
},
},
};
if (opts.stdout) {
log(JSON.stringify(mcpConfig, null, 2));
return;
}
const outputPath = resolve(opts.output);
let finalConfig = mcpConfig;
if (opts.merge && existsSync(outputPath)) {
try {
const existing = JSON.parse(readFileSync(outputPath, 'utf-8')) as McpConfig;
finalConfig = {
mcpServers: {
...existing.mcpServers,
...mcpConfig.mcpServers,
},
};
} catch {
// If existing file is invalid, just overwrite
}
}
writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n');
const serverCount = Object.keys(finalConfig.mcpServers).length;
log(`Wrote ${outputPath} (${serverCount} server(s))`);
});
if (hidden) {
// Commander shows empty-description commands but they won't clutter help output
void cmd; // suppress unused lint
}
}
registerClaudeCommand('claude', false);
registerClaudeCommand('claude-generate', true); // backward compat
config.addCommand(createConfigSetupCommand({ configDeps }));
if (apiDeps) {
const { client, credentialsDeps, log: apiLog } = apiDeps;
config
.command('impersonate')
.description('Impersonate another user or return to original identity')
.argument('[email]', 'Email of user to impersonate')
.option('--quit', 'Stop impersonating and return to original identity')
.action(async (email: string | undefined, opts: { quit?: boolean }) => {
const configDir = credentialsDeps?.configDir ?? join(homedir(), '.mcpctl');
const backupPath = join(configDir, 'credentials-backup');
if (opts.quit) {
if (!existsSync(backupPath)) {
apiLog('No impersonation session to quit');
process.exitCode = 1;
return;
}
const backupRaw = readFileSync(backupPath, 'utf-8');
const backup = JSON.parse(backupRaw) as StoredCredentials;
saveCredentials(backup, credentialsDeps);
// Remove backup file
const { unlinkSync } = await import('node:fs');
unlinkSync(backupPath);
apiLog(`Returned to ${backup.user}`);
return;
}
if (!email) {
apiLog('Email is required when not using --quit');
process.exitCode = 1;
return;
}
// Save current credentials as backup
const currentCreds = loadCredentials(credentialsDeps);
if (!currentCreds) {
apiLog('Not logged in. Run "mcpctl login" first.');
process.exitCode = 1;
return;
}
writeFileSync(backupPath, JSON.stringify(currentCreds, null, 2) + '\n', 'utf-8');
try {
const result = await client.post<{ token: string; user: { email: string } }>(
'/api/v1/auth/impersonate',
{ email },
);
saveCredentials({
token: result.token,
mcpdUrl: currentCreds.mcpdUrl,
user: result.user.email,
}, credentialsDeps);
apiLog(`Impersonating ${result.user.email}. Use 'mcpctl config impersonate --quit' to return.`);
} catch (err) {
// Restore backup on failure
const backup = JSON.parse(readFileSync(backupPath, 'utf-8')) as StoredCredentials;
saveCredentials(backup, credentialsDeps);
const { unlinkSync } = await import('node:fs');
unlinkSync(backupPath);
apiLog(`Impersonate failed: ${(err as Error).message}`);
process.exitCode = 1;
}
});
}
return config;
}

View File

@@ -0,0 +1,383 @@
import { Command } from 'commander';
import { type ApiClient, ApiError } from '../api-client.js';
export interface CreateCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
function collect(value: string, prev: string[]): string[] {
return [...prev, value];
}
interface ServerEnvEntry {
name: string;
value?: string;
valueFrom?: { secretRef: { name: string; key: string } };
}
function parseServerEnv(entries: string[]): ServerEnvEntry[] {
return entries.map((entry) => {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) {
throw new Error(`Invalid env format '${entry}'. Expected KEY=value or KEY=secretRef:SECRET:KEY`);
}
const envName = entry.slice(0, eqIdx);
const rhs = entry.slice(eqIdx + 1);
if (rhs.startsWith('secretRef:')) {
const parts = rhs.split(':');
if (parts.length !== 3) {
throw new Error(`Invalid secretRef format '${entry}'. Expected KEY=secretRef:SECRET_NAME:SECRET_KEY`);
}
return {
name: envName,
valueFrom: { secretRef: { name: parts[1]!, key: parts[2]! } },
};
}
return { name: envName, value: rhs };
});
}
function parseEnvEntries(entries: string[]): Record<string, string> {
const result: Record<string, string> = {};
for (const entry of entries) {
const eqIdx = entry.indexOf('=');
if (eqIdx === -1) {
throw new Error(`Invalid env format '${entry}'. Expected KEY=value`);
}
result[entry.slice(0, eqIdx)] = entry.slice(eqIdx + 1);
}
return result;
}
export function createCreateCommand(deps: CreateCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('create')
.description('Create a resource (server, secret, project, user, group, rbac)');
// --- create server ---
cmd.command('server')
.description('Create an MCP server definition')
.argument('<name>', 'Server name (lowercase, hyphens allowed)')
.option('-d, --description <text>', 'Server description')
.option('--package-name <name>', 'NPM package name')
.option('--docker-image <image>', 'Docker image')
.option('--transport <type>', 'Transport type (STDIO, SSE, STREAMABLE_HTTP)')
.option('--repository-url <url>', 'Source repository URL')
.option('--external-url <url>', 'External endpoint URL')
.option('--command <arg>', 'Command argument (repeat for multiple)', collect, [])
.option('--container-port <port>', 'Container port number')
.option('--replicas <count>', 'Number of replicas')
.option('--env <entry>', 'Env var: KEY=value (inline) or KEY=secretRef:SECRET:KEY (secret ref, repeat for multiple)', collect, [])
.option('--from-template <name>', 'Create from template (name or name:version)')
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
let base: Record<string, unknown> = {};
// If --from-template, fetch template and use as base
if (opts.fromTemplate) {
const tplRef = opts.fromTemplate as string;
const [tplName, tplVersion] = tplRef.includes(':')
? [tplRef.slice(0, tplRef.indexOf(':')), tplRef.slice(tplRef.indexOf(':') + 1)]
: [tplRef, undefined];
const templates = await client.get<Array<Record<string, unknown>>>(`/api/v1/templates?name=${encodeURIComponent(tplName)}`);
let template: Record<string, unknown> | undefined;
if (tplVersion) {
template = templates.find((t) => t.name === tplName && t.version === tplVersion);
if (!template) throw new Error(`Template '${tplName}' version '${tplVersion}' not found`);
} else {
template = templates.find((t) => t.name === tplName);
if (!template) throw new Error(`Template '${tplName}' not found`);
}
// Copy template fields as base (strip template-only, internal, and null fields)
const { id: _id, createdAt: _c, updatedAt: _u, version: _v, name: _n, ...tplFields } = template;
base = {};
for (const [k, v] of Object.entries(tplFields)) {
if (v !== null && v !== undefined) base[k] = v;
}
// Convert template env (description/required) to server env (name/value/valueFrom)
const tplEnv = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
if (tplEnv && tplEnv.length > 0) {
base.env = tplEnv.map((e) => ({ name: e.name, value: e.defaultValue ?? '' }));
}
// Track template origin
base.templateName = tplName;
base.templateVersion = (template.version as string) ?? '1.0.0';
}
// Build body: template base → CLI overrides (last wins)
const body: Record<string, unknown> = {
...base,
name,
};
if (opts.description !== undefined) body.description = opts.description;
if (opts.transport) body.transport = opts.transport;
if (opts.replicas) body.replicas = parseInt(opts.replicas, 10);
if (opts.packageName) body.packageName = opts.packageName;
if (opts.dockerImage) body.dockerImage = opts.dockerImage;
if (opts.repositoryUrl) body.repositoryUrl = opts.repositoryUrl;
if (opts.externalUrl) body.externalUrl = opts.externalUrl;
if (opts.command.length > 0) body.command = opts.command;
if (opts.containerPort) body.containerPort = parseInt(opts.containerPort, 10);
if (opts.env.length > 0) {
// Merge: CLI env entries override template env entries by name
const cliEnv = parseServerEnv(opts.env);
const existing = (body.env as ServerEnvEntry[] | undefined) ?? [];
const merged = [...existing];
for (const entry of cliEnv) {
const idx = merged.findIndex((e) => e.name === entry.name);
if (idx >= 0) {
merged[idx] = entry;
} else {
merged.push(entry);
}
}
body.env = merged;
}
// Defaults when no template
if (!opts.fromTemplate) {
if (body.description === undefined) body.description = '';
if (!body.transport) body.transport = 'STDIO';
if (!body.replicas) body.replicas = 1;
}
try {
const server = await client.post<{ id: string; name: string }>('/api/v1/servers', body);
log(`server '${server.name}' created (id: ${server.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/servers')).find((s) => s.name === name);
if (!existing) throw err;
const { name: _n, ...updateBody } = body;
await client.put(`/api/v1/servers/${existing.id}`, updateBody);
log(`server '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
// --- create secret ---
cmd.command('secret')
.description('Create a secret')
.argument('<name>', 'Secret name (lowercase, hyphens allowed)')
.option('--data <entry>', 'Secret data KEY=value (repeat for multiple)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
const data = parseEnvEntries(opts.data);
try {
const secret = await client.post<{ id: string; name: string }>('/api/v1/secrets', {
name,
data,
});
log(`secret '${secret.name}' created (id: ${secret.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/secrets')).find((s) => s.name === name);
if (!existing) throw err;
await client.put(`/api/v1/secrets/${existing.id}`, { data });
log(`secret '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
// --- create project ---
cmd.command('project')
.description('Create a project')
.argument('<name>', 'Project name')
.option('-d, --description <text>', 'Project description', '')
.option('--proxy-mode <mode>', 'Proxy mode (direct, filtered)')
.option('--proxy-mode-llm-provider <name>', 'LLM provider name (for filtered proxy mode)')
.option('--proxy-mode-llm-model <name>', 'LLM model name (for filtered proxy mode)')
.option('--prompt <text>', 'Project-level prompt / instructions for the LLM')
.option('--server <name>', 'Server name (repeat for multiple)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
const body: Record<string, unknown> = {
name,
description: opts.description,
proxyMode: opts.proxyMode ?? 'direct',
};
if (opts.prompt) body.prompt = opts.prompt;
if (opts.proxyModeLlmProvider) body.llmProvider = opts.proxyModeLlmProvider;
if (opts.proxyModeLlmModel) body.llmModel = opts.proxyModeLlmModel;
if (opts.server.length > 0) body.servers = opts.server;
try {
const project = await client.post<{ id: string; name: string }>('/api/v1/projects', body);
log(`project '${project.name}' created (id: ${project.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/projects')).find((p) => p.name === name);
if (!existing) throw err;
const { name: _n, ...updateBody } = body;
await client.put(`/api/v1/projects/${existing.id}`, updateBody);
log(`project '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
// --- create user ---
cmd.command('user')
.description('Create a user')
.argument('<email>', 'User email address')
.option('--password <pass>', 'User password')
.option('--name <name>', 'User display name')
.option('--force', 'Update if already exists')
.action(async (email: string, opts) => {
if (!opts.password) {
throw new Error('--password is required');
}
const body: Record<string, unknown> = {
email,
password: opts.password,
};
if (opts.name) body.name = opts.name;
try {
const user = await client.post<{ id: string; email: string }>('/api/v1/users', body);
log(`user '${user.email}' created (id: ${user.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; email: string }>>('/api/v1/users')).find((u) => u.email === email);
if (!existing) throw err;
const { email: _e, ...updateBody } = body;
await client.put(`/api/v1/users/${existing.id}`, updateBody);
log(`user '${email}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
// --- create group ---
cmd.command('group')
.description('Create a group')
.argument('<name>', 'Group name')
.option('--description <text>', 'Group description')
.option('--member <email>', 'Member email (repeat for multiple)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
const body: Record<string, unknown> = {
name,
members: opts.member,
};
if (opts.description) body.description = opts.description;
try {
const group = await client.post<{ id: string; name: string }>('/api/v1/groups', body);
log(`group '${group.name}' created (id: ${group.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/groups')).find((g) => g.name === name);
if (!existing) throw err;
const { name: _n, ...updateBody } = body;
await client.put(`/api/v1/groups/${existing.id}`, updateBody);
log(`group '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
// --- create rbac ---
cmd.command('rbac')
.description('Create an RBAC binding definition')
.argument('<name>', 'RBAC binding name')
.option('--subject <entry>', 'Subject as Kind:name (repeat for multiple)', collect, [])
.option('--binding <entry>', 'Role binding as role:resource (e.g. edit:servers, run:projects)', collect, [])
.option('--operation <action>', 'Operation binding (e.g. logs, backup)', collect, [])
.option('--force', 'Update if already exists')
.action(async (name: string, opts) => {
const subjects = (opts.subject as string[]).map((entry: string) => {
const colonIdx = entry.indexOf(':');
if (colonIdx === -1) {
throw new Error(`Invalid subject format '${entry}'. Expected Kind:name (e.g. User:alice@example.com)`);
}
return { kind: entry.slice(0, colonIdx), name: entry.slice(colonIdx + 1) };
});
const roleBindings: Array<Record<string, string>> = [];
// Resource bindings from --binding flag (role:resource or role:resource:name)
for (const entry of opts.binding as string[]) {
const parts = entry.split(':');
if (parts.length === 2) {
roleBindings.push({ role: parts[0]!, resource: parts[1]! });
} else if (parts.length === 3) {
roleBindings.push({ role: parts[0]!, resource: parts[1]!, name: parts[2]! });
} else {
throw new Error(`Invalid binding format '${entry}'. Expected role:resource or role:resource:name (e.g. edit:servers, view:servers:my-ha)`);
}
}
// Operation bindings from --operation flag
for (const action of opts.operation as string[]) {
roleBindings.push({ role: 'run', action });
}
const body: Record<string, unknown> = {
name,
subjects,
roleBindings,
};
try {
const rbac = await client.post<{ id: string; name: string }>('/api/v1/rbac', body);
log(`rbac '${rbac.name}' created (id: ${rbac.id})`);
} catch (err) {
if (err instanceof ApiError && err.status === 409 && opts.force) {
const existing = (await client.get<Array<{ id: string; name: string }>>('/api/v1/rbac')).find((r) => r.name === name);
if (!existing) throw err;
const { name: _n, ...updateBody } = body;
await client.put(`/api/v1/rbac/${existing.id}`, updateBody);
log(`rbac '${name}' updated (id: ${existing.id})`);
} else {
throw err;
}
}
});
// --- create prompt ---
cmd.command('prompt')
.description('Create an approved prompt')
.argument('<name>', 'Prompt name (lowercase alphanumeric with hyphens)')
.option('--project <name>', 'Project name to scope the prompt to')
.option('--content <text>', 'Prompt content text')
.option('--content-file <path>', 'Read prompt content from file')
.action(async (name: string, opts) => {
let content = opts.content as string | undefined;
if (opts.contentFile) {
const fs = await import('node:fs/promises');
content = await fs.readFile(opts.contentFile as string, 'utf-8');
}
if (!content) {
throw new Error('--content or --content-file is required');
}
const body: Record<string, unknown> = { name, content };
if (opts.project) {
// Resolve project name to ID
const projects = await client.get<Array<{ id: string; name: string }>>('/api/v1/projects');
const project = projects.find((p) => p.name === opts.project);
if (!project) throw new Error(`Project '${opts.project as string}' not found`);
body.projectId = project.id;
}
const prompt = await client.post<{ id: string; name: string }>('/api/v1/prompts', body);
log(`prompt '${prompt.name}' created (id: ${prompt.id})`);
});
return cmd;
}

View File

@@ -0,0 +1,33 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
import { resolveResource, resolveNameOrId } from './shared.js';
export interface DeleteCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createDeleteCommand(deps: DeleteCommandDeps): Command {
const { client, log } = deps;
return new Command('delete')
.description('Delete a resource (server, instance, secret, project, user, group, rbac)')
.argument('<resource>', 'resource type')
.argument('<id>', 'resource ID or name')
.action(async (resourceArg: string, idOrName: string) => {
const resource = resolveResource(resourceArg);
// Resolve name → ID for any resource type
let id: string;
try {
id = await resolveNameOrId(client, resource, idOrName);
} catch {
id = idOrName; // Fall through with original
}
await client.delete(`/api/v1/${resource}/${id}`);
const singular = resource.replace(/s$/, '');
log(`${singular} '${idOrName}' deleted.`);
});
}

View File

@@ -1,74 +1,618 @@
import { Command } from 'commander';
import { formatJson, formatYaml } from '../formatters/output.js';
import { resolveResource, resolveNameOrId } from './shared.js';
import type { ApiClient } from '../api-client.js';
export interface DescribeCommandDeps {
client: ApiClient;
fetchResource: (resource: string, id: string) => Promise<unknown>;
fetchInspect?: (id: string) => Promise<unknown>;
log: (...args: string[]) => void;
}
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
function pad(label: string, width = 18): string {
return label.padEnd(width);
}
function formatDetail(obj: Record<string, unknown>, indent = 0): string {
const pad = ' '.repeat(indent);
function formatServerDetail(server: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Server: ${server.name} ===`);
lines.push(`${pad('Name:')}${server.name}`);
lines.push(`${pad('Transport:')}${server.transport ?? '-'}`);
lines.push(`${pad('Replicas:')}${server.replicas ?? 1}`);
if (server.dockerImage) lines.push(`${pad('Docker Image:')}${server.dockerImage}`);
if (server.packageName) lines.push(`${pad('Package:')}${server.packageName}`);
if (server.externalUrl) lines.push(`${pad('External URL:')}${server.externalUrl}`);
if (server.repositoryUrl) lines.push(`${pad('Repository:')}${server.repositoryUrl}`);
if (server.containerPort) lines.push(`${pad('Container Port:')}${server.containerPort}`);
if (server.description) lines.push(`${pad('Description:')}${server.description}`);
const command = server.command as string[] | null;
if (command && command.length > 0) {
lines.push('');
lines.push('Command:');
lines.push(` ${command.join(' ')}`);
}
const env = server.env as Array<{ name: string; value?: string; valueFrom?: { secretRef: { name: string; key: string } } }> | undefined;
if (env && env.length > 0) {
lines.push('');
lines.push('Environment:');
const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
lines.push(` ${'NAME'.padEnd(nameW)}SOURCE`);
for (const e of env) {
if (e.value !== undefined) {
lines.push(` ${e.name.padEnd(nameW)}${e.value}`);
} else if (e.valueFrom?.secretRef) {
const ref = e.valueFrom.secretRef;
lines.push(` ${e.name.padEnd(nameW)}secret:${ref.name}/${ref.key}`);
}
}
}
const hc = server.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
if (hc) {
lines.push('');
lines.push('Health Check:');
lines.push(` ${pad('Tool:', 22)}${hc.tool}`);
if (hc.arguments && Object.keys(hc.arguments).length > 0) {
lines.push(` ${pad('Arguments:', 22)}${JSON.stringify(hc.arguments)}`);
}
lines.push(` ${pad('Interval:', 22)}${hc.intervalSeconds ?? 60}s`);
lines.push(` ${pad('Timeout:', 22)}${hc.timeoutSeconds ?? 10}s`);
lines.push(` ${pad('Failure Threshold:', 22)}${hc.failureThreshold ?? 3}`);
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${server.id}`);
if (server.createdAt) lines.push(` ${pad('Created:', 12)}${server.createdAt}`);
if (server.updatedAt) lines.push(` ${pad('Updated:', 12)}${server.updatedAt}`);
return lines.join('\n');
}
function formatInstanceDetail(instance: Record<string, unknown>, inspect?: Record<string, unknown>): string {
const lines: string[] = [];
const server = instance.server as { name: string } | undefined;
lines.push(`=== Instance: ${server?.name ?? instance.id} ===`);
lines.push(`${pad('Status:')}${instance.status}`);
lines.push(`${pad('Server:')}${server?.name ?? String(instance.serverId)}`);
lines.push(`${pad('Container ID:')}${instance.containerId ?? '-'}`);
lines.push(`${pad('Port:')}${instance.port ?? '-'}`);
// Health section
const healthStatus = instance.healthStatus as string | null;
const lastHealthCheck = instance.lastHealthCheck as string | null;
if (healthStatus || lastHealthCheck) {
lines.push('');
lines.push('Health:');
lines.push(` ${pad('Status:', 16)}${healthStatus ?? 'unknown'}`);
if (lastHealthCheck) lines.push(` ${pad('Last Check:', 16)}${lastHealthCheck}`);
}
const metadata = instance.metadata as Record<string, unknown> | undefined;
if (metadata && Object.keys(metadata).length > 0) {
lines.push('');
lines.push('Metadata:');
for (const [key, value] of Object.entries(metadata)) {
lines.push(` ${pad(key + ':', 16)}${String(value)}`);
}
}
if (inspect) {
lines.push('');
lines.push('Container:');
for (const [key, value] of Object.entries(inspect)) {
if (typeof value === 'object' && value !== null) {
lines.push(` ${key}: ${JSON.stringify(value)}`);
} else {
lines.push(` ${pad(key + ':', 16)}${String(value)}`);
}
}
}
// Events section (k8s-style)
const events = instance.events as Array<{ timestamp: string; type: string; message: string }> | undefined;
if (events && events.length > 0) {
lines.push('');
lines.push('Events:');
const tsW = 26;
const typeW = 10;
lines.push(` ${'TIMESTAMP'.padEnd(tsW)}${'TYPE'.padEnd(typeW)}MESSAGE`);
for (const ev of events) {
lines.push(` ${(ev.timestamp ?? '').padEnd(tsW)}${(ev.type ?? '').padEnd(typeW)}${ev.message ?? ''}`);
}
}
lines.push('');
lines.push(` ${pad('ID:', 12)}${instance.id}`);
if (instance.createdAt) lines.push(` ${pad('Created:', 12)}${instance.createdAt}`);
if (instance.updatedAt) lines.push(` ${pad('Updated:', 12)}${instance.updatedAt}`);
return lines.join('\n');
}
function formatProjectDetail(project: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Project: ${project.name} ===`);
lines.push(`${pad('Name:')}${project.name}`);
if (project.description) lines.push(`${pad('Description:')}${project.description}`);
// Proxy config section
const proxyMode = project.proxyMode as string | undefined;
const llmProvider = project.llmProvider as string | undefined;
const llmModel = project.llmModel as string | undefined;
if (proxyMode || llmProvider || llmModel) {
lines.push('');
lines.push('Proxy Config:');
lines.push(` ${pad('Mode:', 18)}${proxyMode ?? 'direct'}`);
if (llmProvider) lines.push(` ${pad('LLM Provider:', 18)}${llmProvider}`);
if (llmModel) lines.push(` ${pad('LLM Model:', 18)}${llmModel}`);
}
// Servers section
const servers = project.servers as Array<{ server: { name: string } }> | undefined;
if (servers && servers.length > 0) {
lines.push('');
lines.push('Servers:');
lines.push(' NAME');
for (const s of servers) {
lines.push(` ${s.server.name}`);
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${project.id}`);
if (project.ownerId) lines.push(` ${pad('Owner:', 12)}${project.ownerId}`);
if (project.createdAt) lines.push(` ${pad('Created:', 12)}${project.createdAt}`);
if (project.updatedAt) lines.push(` ${pad('Updated:', 12)}${project.updatedAt}`);
return lines.join('\n');
}
function formatSecretDetail(secret: Record<string, unknown>, showValues: boolean): string {
const lines: string[] = [];
lines.push(`=== Secret: ${secret.name} ===`);
lines.push(`${pad('Name:')}${secret.name}`);
const data = secret.data as Record<string, string> | undefined;
if (data && Object.keys(data).length > 0) {
lines.push('');
lines.push('Data:');
const keyW = Math.max(4, ...Object.keys(data).map((k) => k.length)) + 2;
for (const [key, value] of Object.entries(data)) {
const display = showValues ? value : '***';
lines.push(` ${key.padEnd(keyW)}${display}`);
}
if (!showValues) {
lines.push('');
lines.push(' (use --show-values to reveal)');
}
} else {
lines.push(`${pad('Data:')}(empty)`);
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${secret.id}`);
if (secret.createdAt) lines.push(` ${pad('Created:', 12)}${secret.createdAt}`);
if (secret.updatedAt) lines.push(` ${pad('Updated:', 12)}${secret.updatedAt}`);
return lines.join('\n');
}
function formatTemplateDetail(template: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== Template: ${template.name} ===`);
lines.push(`${pad('Name:')}${template.name}`);
lines.push(`${pad('Version:')}${template.version ?? '1.0.0'}`);
lines.push(`${pad('Transport:')}${template.transport ?? 'STDIO'}`);
lines.push(`${pad('Replicas:')}${template.replicas ?? 1}`);
if (template.dockerImage) lines.push(`${pad('Docker Image:')}${template.dockerImage}`);
if (template.packageName) lines.push(`${pad('Package:')}${template.packageName}`);
if (template.externalUrl) lines.push(`${pad('External URL:')}${template.externalUrl}`);
if (template.repositoryUrl) lines.push(`${pad('Repository:')}${template.repositoryUrl}`);
if (template.containerPort) lines.push(`${pad('Container Port:')}${template.containerPort}`);
if (template.description) lines.push(`${pad('Description:')}${template.description}`);
const command = template.command as string[] | null;
if (command && command.length > 0) {
lines.push('');
lines.push('Command:');
lines.push(` ${command.join(' ')}`);
}
const env = template.env as Array<{ name: string; description?: string; required?: boolean; defaultValue?: string }> | undefined;
if (env && env.length > 0) {
lines.push('');
lines.push('Environment Variables:');
const nameW = Math.max(6, ...env.map((e) => e.name.length)) + 2;
lines.push(` ${'NAME'.padEnd(nameW)}${'REQUIRED'.padEnd(10)}DESCRIPTION`);
for (const e of env) {
const req = e.required ? 'yes' : 'no';
const desc = e.description ?? '';
lines.push(` ${e.name.padEnd(nameW)}${req.padEnd(10)}${desc}`);
}
}
const hc = template.healthCheck as { tool: string; arguments?: Record<string, unknown>; intervalSeconds?: number; timeoutSeconds?: number; failureThreshold?: number } | null;
if (hc) {
lines.push('');
lines.push('Health Check:');
lines.push(` ${pad('Tool:', 22)}${hc.tool}`);
if (hc.arguments && Object.keys(hc.arguments).length > 0) {
lines.push(` ${pad('Arguments:', 22)}${JSON.stringify(hc.arguments)}`);
}
lines.push(` ${pad('Interval:', 22)}${hc.intervalSeconds ?? 60}s`);
lines.push(` ${pad('Timeout:', 22)}${hc.timeoutSeconds ?? 10}s`);
lines.push(` ${pad('Failure Threshold:', 22)}${hc.failureThreshold ?? 3}`);
}
lines.push('');
lines.push('Usage:');
lines.push(` mcpctl create server my-${template.name} --from-template=${template.name}`);
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${template.id}`);
if (template.createdAt) lines.push(` ${pad('Created:', 12)}${template.createdAt}`);
if (template.updatedAt) lines.push(` ${pad('Updated:', 12)}${template.updatedAt}`);
return lines.join('\n');
}
interface RbacBinding { role: string; resource?: string; action?: string; name?: string }
interface RbacDef { name: string; subjects: Array<{ kind: string; name: string }>; roleBindings: RbacBinding[] }
interface PermissionSet { source: string; bindings: RbacBinding[] }
function formatPermissionSections(sections: PermissionSet[]): string[] {
const lines: string[] = [];
for (const section of sections) {
const bindings = section.bindings;
if (bindings.length === 0) continue;
const resourceBindings = bindings.filter((b) => 'resource' in b && b.resource !== undefined);
const operationBindings = bindings.filter((b) => 'action' in b && b.action !== undefined);
if (resourceBindings.length > 0) {
lines.push('');
lines.push(`${section.source} — Resources:`);
const roleW = Math.max(6, ...resourceBindings.map((b) => b.role.length)) + 2;
const resW = Math.max(10, ...resourceBindings.map((b) => (b.resource ?? '').length)) + 2;
const hasName = resourceBindings.some((b) => b.name);
if (hasName) {
lines.push(` ${'ROLE'.padEnd(roleW)}${'RESOURCE'.padEnd(resW)}NAME`);
} else {
lines.push(` ${'ROLE'.padEnd(roleW)}RESOURCE`);
}
for (const b of resourceBindings) {
if (hasName) {
lines.push(` ${b.role.padEnd(roleW)}${(b.resource ?? '').padEnd(resW)}${b.name ?? '*'}`);
} else {
lines.push(` ${b.role.padEnd(roleW)}${b.resource}`);
}
}
}
if (operationBindings.length > 0) {
lines.push('');
lines.push(`${section.source} — Operations:`);
lines.push(` ${'ACTION'.padEnd(20)}ROLE`);
for (const b of operationBindings) {
lines.push(` ${(b.action ?? '').padEnd(20)}${b.role}`);
}
}
}
return lines;
}
function collectBindingsForSubject(
rbacDefs: RbacDef[],
kind: string,
name: string,
): { rbacName: string; bindings: RbacBinding[] }[] {
const results: { rbacName: string; bindings: RbacBinding[] }[] = [];
for (const def of rbacDefs) {
const matched = def.subjects.some((s) => s.kind === kind && s.name === name);
if (matched) {
results.push({ rbacName: def.name, bindings: def.roleBindings });
}
}
return results;
}
function formatUserDetail(
user: Record<string, unknown>,
rbacDefs?: RbacDef[],
userGroups?: string[],
): string {
const lines: string[] = [];
lines.push(`=== User: ${user.email} ===`);
lines.push(`${pad('Email:')}${user.email}`);
lines.push(`${pad('Name:')}${(user.name as string | null) ?? '-'}`);
lines.push(`${pad('Provider:')}${(user.provider as string | null) ?? 'local'}`);
if (userGroups && userGroups.length > 0) {
lines.push(`${pad('Groups:')}${userGroups.join(', ')}`);
}
if (rbacDefs) {
const email = user.email as string;
// Direct permissions (User:email subjects)
const directMatches = collectBindingsForSubject(rbacDefs, 'User', email);
const directBindings = directMatches.flatMap((m) => m.bindings);
const directSources = directMatches.map((m) => m.rbacName).join(', ');
// Inherited permissions (Group:name subjects)
const inheritedSections: PermissionSet[] = [];
if (userGroups) {
for (const groupName of userGroups) {
const groupMatches = collectBindingsForSubject(rbacDefs, 'Group', groupName);
const groupBindings = groupMatches.flatMap((m) => m.bindings);
if (groupBindings.length > 0) {
inheritedSections.push({ source: `Inherited (${groupName})`, bindings: groupBindings });
}
}
}
const sections: PermissionSet[] = [];
if (directBindings.length > 0) {
sections.push({ source: `Direct (${directSources})`, bindings: directBindings });
}
sections.push(...inheritedSections);
if (sections.length > 0) {
lines.push('');
lines.push('Access:');
lines.push(...formatPermissionSections(sections));
} else {
lines.push('');
lines.push('Access: (none)');
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${user.id}`);
if (user.createdAt) lines.push(` ${pad('Created:', 12)}${user.createdAt}`);
if (user.updatedAt) lines.push(` ${pad('Updated:', 12)}${user.updatedAt}`);
return lines.join('\n');
}
function formatGroupDetail(group: Record<string, unknown>, rbacDefs?: RbacDef[]): string {
const lines: string[] = [];
lines.push(`=== Group: ${group.name} ===`);
lines.push(`${pad('Name:')}${group.name}`);
if (group.description) lines.push(`${pad('Description:')}${group.description}`);
const members = group.members as Array<{ user: { email: string }; createdAt?: string }> | undefined;
if (members && members.length > 0) {
lines.push('');
lines.push('Members:');
const emailW = Math.max(6, ...members.map((m) => m.user.email.length)) + 2;
lines.push(` ${'EMAIL'.padEnd(emailW)}ADDED`);
for (const m of members) {
const added = (m.createdAt as string | undefined) ?? '-';
lines.push(` ${m.user.email.padEnd(emailW)}${added}`);
}
}
if (rbacDefs) {
const groupName = group.name as string;
const matches = collectBindingsForSubject(rbacDefs, 'Group', groupName);
const allBindings = matches.flatMap((m) => m.bindings);
const sources = matches.map((m) => m.rbacName).join(', ');
if (allBindings.length > 0) {
const sections: PermissionSet[] = [{ source: `Granted (${sources})`, bindings: allBindings }];
lines.push('');
lines.push('Access:');
lines.push(...formatPermissionSections(sections));
} else {
lines.push('');
lines.push('Access: (none)');
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${group.id}`);
if (group.createdAt) lines.push(` ${pad('Created:', 12)}${group.createdAt}`);
if (group.updatedAt) lines.push(` ${pad('Updated:', 12)}${group.updatedAt}`);
return lines.join('\n');
}
function formatRbacDetail(rbac: Record<string, unknown>): string {
const lines: string[] = [];
lines.push(`=== RBAC: ${rbac.name} ===`);
lines.push(`${pad('Name:')}${rbac.name}`);
const subjects = rbac.subjects as Array<{ kind: string; name: string }> | undefined;
if (subjects && subjects.length > 0) {
lines.push('');
lines.push('Subjects:');
const kindW = Math.max(6, ...subjects.map((s) => s.kind.length)) + 2;
lines.push(` ${'KIND'.padEnd(kindW)}NAME`);
for (const s of subjects) {
lines.push(` ${s.kind.padEnd(kindW)}${s.name}`);
}
}
const roleBindings = rbac.roleBindings as Array<{ role: string; resource?: string; action?: string; name?: string }> | undefined;
if (roleBindings && roleBindings.length > 0) {
// Separate resource bindings from operation bindings
const resourceBindings = roleBindings.filter((b) => 'resource' in b && b.resource !== undefined);
const operationBindings = roleBindings.filter((b) => 'action' in b && b.action !== undefined);
if (resourceBindings.length > 0) {
lines.push('');
lines.push('Resource Bindings:');
const roleW = Math.max(6, ...resourceBindings.map((b) => b.role.length)) + 2;
const resW = Math.max(10, ...resourceBindings.map((b) => (b.resource ?? '').length)) + 2;
const hasName = resourceBindings.some((b) => b.name);
if (hasName) {
lines.push(` ${'ROLE'.padEnd(roleW)}${'RESOURCE'.padEnd(resW)}NAME`);
} else {
lines.push(` ${'ROLE'.padEnd(roleW)}RESOURCE`);
}
for (const b of resourceBindings) {
if (hasName) {
lines.push(` ${b.role.padEnd(roleW)}${(b.resource ?? '').padEnd(resW)}${b.name ?? '*'}`);
} else {
lines.push(` ${b.role.padEnd(roleW)}${b.resource}`);
}
}
}
if (operationBindings.length > 0) {
lines.push('');
lines.push('Operations:');
lines.push(` ${'ACTION'.padEnd(20)}ROLE`);
for (const b of operationBindings) {
lines.push(` ${(b.action ?? '').padEnd(20)}${b.role}`);
}
}
}
lines.push('');
lines.push('Metadata:');
lines.push(` ${pad('ID:', 12)}${rbac.id}`);
if (rbac.createdAt) lines.push(` ${pad('Created:', 12)}${rbac.createdAt}`);
if (rbac.updatedAt) lines.push(` ${pad('Updated:', 12)}${rbac.updatedAt}`);
return lines.join('\n');
}
function formatGenericDetail(obj: Record<string, unknown>): string {
const lines: string[] = [];
for (const [key, value] of Object.entries(obj)) {
if (value === null || value === undefined) {
lines.push(`${pad}${key}: -`);
lines.push(`${pad(key + ':')} -`);
} else if (Array.isArray(value)) {
if (value.length === 0) {
lines.push(`${pad}${key}: []`);
} else if (typeof value[0] === 'object') {
lines.push(`${pad}${key}:`);
for (const item of value) {
lines.push(`${pad} - ${JSON.stringify(item)}`);
}
lines.push(`${pad(key + ':')} []`);
} else {
lines.push(`${pad}${key}: ${value.join(', ')}`);
lines.push(`${key}:`);
for (const item of value) {
lines.push(` - ${typeof item === 'object' ? JSON.stringify(item) : String(item)}`);
}
}
} else if (typeof value === 'object') {
lines.push(`${pad}${key}:`);
lines.push(formatDetail(value as Record<string, unknown>, indent + 1));
lines.push(`${key}:`);
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
lines.push(` ${pad(k + ':')}${String(v)}`);
}
} else {
lines.push(`${pad}${key}: ${String(value)}`);
lines.push(`${pad(key + ':')}${String(value)}`);
}
}
return lines.join('\n');
}
export function createDescribeCommand(deps: DescribeCommandDeps): Command {
return new Command('describe')
.description('Show detailed information about a resource')
.argument('<resource>', 'resource type (server, profile, project, instance)')
.argument('<id>', 'resource ID')
.argument('<resource>', 'resource type (server, project, instance)')
.argument('<id>', 'resource ID or name')
.option('-o, --output <format>', 'output format (detail, json, yaml)', 'detail')
.action(async (resourceArg: string, id: string, opts: { output: string }) => {
.option('--show-values', 'Show secret values (default: masked)')
.action(async (resourceArg: string, idOrName: string, opts: { output: string; showValues?: boolean }) => {
const resource = resolveResource(resourceArg);
const item = await deps.fetchResource(resource, id);
// Resolve name → ID
let id: string;
if (resource === 'instances') {
// Instances: accept instance ID or server name (resolve to first running instance)
try {
id = await resolveNameOrId(deps.client, resource, idOrName);
} catch {
// Not an instance ID — try as server name
const servers = await deps.client.get<Array<{ id: string; name: string }>>('/api/v1/servers');
const server = servers.find((s) => s.name === idOrName || s.id === idOrName);
if (server) {
const instances = await deps.client.get<Array<{ id: string; status: string }>>(`/api/v1/instances?serverId=${server.id}`);
const running = instances.find((i) => i.status === 'RUNNING') ?? instances[0];
if (running) {
id = running.id;
} else {
throw new Error(`No instances found for server '${idOrName}'`);
}
} else {
id = idOrName;
}
}
} else {
try {
id = await resolveNameOrId(deps.client, resource, idOrName);
} catch {
id = idOrName;
}
}
const item = await deps.fetchResource(resource, id) as Record<string, unknown>;
// Enrich instances with container inspect data
let inspect: Record<string, unknown> | undefined;
if (resource === 'instances' && deps.fetchInspect && item.containerId) {
try {
inspect = await deps.fetchInspect(id) as Record<string, unknown>;
item.containerInspect = inspect;
} catch {
// Container may not be available
}
}
if (opts.output === 'json') {
deps.log(formatJson(item));
} else if (opts.output === 'yaml') {
deps.log(formatYaml(item));
} else {
const typeName = resource.replace(/s$/, '').charAt(0).toUpperCase() + resource.replace(/s$/, '').slice(1);
deps.log(`--- ${typeName} ---`);
deps.log(formatDetail(item as Record<string, unknown>));
// Visually clean sectioned output
switch (resource) {
case 'servers':
deps.log(formatServerDetail(item));
break;
case 'instances':
deps.log(formatInstanceDetail(item, inspect));
break;
case 'secrets':
deps.log(formatSecretDetail(item, opts.showValues === true));
break;
case 'templates':
deps.log(formatTemplateDetail(item));
break;
case 'projects':
deps.log(formatProjectDetail(item));
break;
case 'users': {
// Fetch RBAC definitions and groups to show permissions
const [rbacDefsForUser, allGroupsForUser] = await Promise.all([
deps.client.get<RbacDef[]>('/api/v1/rbac').catch(() => [] as RbacDef[]),
deps.client.get<Array<{ name: string; members?: Array<{ user: { email: string } }> }>>('/api/v1/groups').catch(() => []),
]);
const userEmail = item.email as string;
const userGroupNames = allGroupsForUser
.filter((g) => g.members?.some((m) => m.user.email === userEmail))
.map((g) => g.name);
deps.log(formatUserDetail(item, rbacDefsForUser, userGroupNames));
break;
}
case 'groups': {
const rbacDefsForGroup = await deps.client.get<RbacDef[]>('/api/v1/rbac').catch(() => [] as RbacDef[]);
deps.log(formatGroupDetail(item, rbacDefsForGroup));
break;
}
case 'rbac':
deps.log(formatRbacDetail(item));
break;
default:
deps.log(formatGenericDetail(item));
}
}
});
}

View File

@@ -0,0 +1,114 @@
import { Command } from 'commander';
import { writeFileSync, readFileSync, unlinkSync, mkdtempSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { execSync } from 'node:child_process';
import yaml from 'js-yaml';
import type { ApiClient } from '../api-client.js';
import { resolveResource, resolveNameOrId, stripInternalFields } from './shared.js';
export interface EditCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
/** Override for testing — return editor binary name. */
getEditor?: () => string;
/** Override for testing — simulate opening the editor. */
openEditor?: (filePath: string, editor: string) => void;
}
function getEditor(deps: EditCommandDeps): string {
if (deps.getEditor) return deps.getEditor();
return process.env.VISUAL ?? process.env.EDITOR ?? 'vi';
}
function openEditor(filePath: string, editor: string, deps: EditCommandDeps): void {
if (deps.openEditor) {
deps.openEditor(filePath, editor);
return;
}
execSync(`${editor} "${filePath}"`, { stdio: 'inherit' });
}
export function createEditCommand(deps: EditCommandDeps): Command {
const { client, log } = deps;
return new Command('edit')
.description('Edit a resource in your default editor (server, project)')
.argument('<resource>', 'Resource type (server, project)')
.argument('<name-or-id>', 'Resource name or ID')
.action(async (resourceArg: string, nameOrId: string) => {
const resource = resolveResource(resourceArg);
// Instances are immutable
if (resource === 'instances') {
log('Error: instances are immutable and cannot be edited.');
log('To change an instance, update the server definition and let reconciliation handle it.');
process.exitCode = 1;
return;
}
const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac'];
if (!validResources.includes(resource)) {
log(`Error: unknown resource type '${resourceArg}'`);
process.exitCode = 1;
return;
}
// Resolve name → ID
const id = await resolveNameOrId(client, resource, nameOrId);
// Fetch current state
const current = await client.get<Record<string, unknown>>(`/api/v1/${resource}/${id}`);
// Strip read-only fields for editor
const editable = stripInternalFields(current);
// Serialize to YAML
const singular = resource.replace(/s$/, '');
const header = `# Editing ${singular}: ${nameOrId}\n# Save and close to apply changes. Clear the file to cancel.\n`;
const originalYaml = yaml.dump(editable, { lineWidth: 120, noRefs: true });
const content = header + originalYaml;
// Write to temp file
const tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-edit-'));
const tmpFile = join(tmpDir, `${singular}-${nameOrId}.yaml`);
writeFileSync(tmpFile, content, 'utf-8');
try {
// Open editor
const editor = getEditor(deps);
openEditor(tmpFile, editor, deps);
// Read back
const modified = readFileSync(tmpFile, 'utf-8');
// Strip comments for comparison
const modifiedClean = modified
.split('\n')
.filter((line) => !line.startsWith('#'))
.join('\n')
.trim();
if (!modifiedClean) {
log('Edit cancelled (empty file).');
return;
}
if (modifiedClean === originalYaml.trim()) {
log(`${singular} '${nameOrId}' unchanged.`);
return;
}
// Parse and apply
const updates = yaml.load(modifiedClean) as Record<string, unknown>;
await client.put(`/api/v1/${resource}/${id}`, updates);
log(`${singular} '${nameOrId}' updated.`);
} finally {
try {
unlinkSync(tmpFile);
} catch {
// Ignore cleanup errors
}
}
});
}

View File

@@ -2,6 +2,7 @@ import { Command } from 'commander';
import { formatTable } from '../formatters/table.js';
import { formatJson, formatYaml } from '../formatters/output.js';
import type { Column } from '../formatters/table.js';
import { resolveResource, stripInternalFields } from './shared.js';
export interface GetCommandDeps {
fetchResource: (resource: string, id?: string) => Promise<unknown[]>;
@@ -16,41 +17,38 @@ interface ServerRow {
dockerImage: string | null;
}
interface ProfileRow {
id: string;
name: string;
serverId: string;
}
interface ProjectRow {
id: string;
name: string;
description: string;
proxyMode: string;
ownerId: string;
servers?: Array<{ server: { name: string } }>;
}
interface SecretRow {
id: string;
name: string;
data: Record<string, string>;
}
interface TemplateRow {
id: string;
name: string;
version: string;
transport: string;
packageName: string | null;
description: string;
}
interface InstanceRow {
id: string;
serverId: string;
server?: { name: string };
status: string;
containerId: string | null;
port: number | null;
}
const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
profile: 'profiles',
prof: 'profiles',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
};
function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
healthStatus: string | null;
}
const serverColumns: Column<ServerRow>[] = [
@@ -61,22 +59,111 @@ const serverColumns: Column<ServerRow>[] = [
{ header: 'ID', key: 'id' },
];
const profileColumns: Column<ProfileRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'SERVER ID', key: 'serverId' },
{ header: 'ID', key: 'id' },
];
interface UserRow {
id: string;
email: string;
name: string | null;
provider: string | null;
}
interface GroupRow {
id: string;
name: string;
description: string;
members?: Array<{ user: { email: string } }>;
}
interface RbacRow {
id: string;
name: string;
subjects: Array<{ kind: string; name: string }>;
roleBindings: Array<{ role: string; resource?: string; action?: string; name?: string }>;
}
const projectColumns: Column<ProjectRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'MODE', key: (r) => r.proxyMode ?? 'direct', width: 10 },
{ header: 'SERVERS', key: (r) => r.servers ? String(r.servers.length) : '0', width: 8 },
{ header: 'DESCRIPTION', key: 'description', width: 30 },
{ header: 'ID', key: 'id' },
];
const userColumns: Column<UserRow>[] = [
{ header: 'EMAIL', key: 'email' },
{ header: 'NAME', key: (r) => r.name ?? '-' },
{ header: 'PROVIDER', key: (r) => r.provider ?? 'local', width: 10 },
{ header: 'ID', key: 'id' },
];
const groupColumns: Column<GroupRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'MEMBERS', key: (r) => r.members ? String(r.members.length) : '0', width: 8 },
{ header: 'DESCRIPTION', key: 'description', width: 40 },
{ header: 'OWNER', key: 'ownerId' },
{ header: 'ID', key: 'id' },
];
const rbacColumns: Column<RbacRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'SUBJECTS', key: (r) => r.subjects.map((s) => `${s.kind}:${s.name}`).join(', '), width: 30 },
{ header: 'BINDINGS', key: (r) => r.roleBindings.map((b) => {
if ('action' in b && b.action !== undefined) return `run>${b.action}`;
if ('resource' in b && b.resource !== undefined) {
const base = `${b.role}:${b.resource}`;
return b.name ? `${base}:${b.name}` : base;
}
return b.role;
}).join(', '), width: 40 },
{ header: 'ID', key: 'id' },
];
const secretColumns: Column<SecretRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'KEYS', key: (r) => Object.keys(r.data).join(', ') || '-', width: 40 },
{ header: 'ID', key: 'id' },
];
const templateColumns: Column<TemplateRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'VERSION', key: 'version', width: 10 },
{ header: 'TRANSPORT', key: 'transport', width: 16 },
{ header: 'PACKAGE', key: (r) => r.packageName ?? '-' },
{ header: 'DESCRIPTION', key: 'description', width: 50 },
];
interface PromptRow {
id: string;
name: string;
projectId: string | null;
createdAt: string;
}
interface PromptRequestRow {
id: string;
name: string;
projectId: string | null;
createdBySession: string | null;
createdAt: string;
}
const promptColumns: Column<PromptRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'PROJECT', key: (r) => r.projectId ?? '-', width: 20 },
{ header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 },
{ header: 'ID', key: 'id' },
];
const promptRequestColumns: Column<PromptRequestRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'PROJECT', key: (r) => r.projectId ?? '-', width: 20 },
{ header: 'SESSION', key: (r) => r.createdBySession ? r.createdBySession.slice(0, 12) : '-', width: 14 },
{ header: 'CREATED', key: (r) => new Date(r.createdAt).toLocaleString(), width: 20 },
{ header: 'ID', key: 'id' },
];
const instanceColumns: Column<InstanceRow>[] = [
{ header: 'NAME', key: (r) => r.server?.name ?? '-', width: 20 },
{ header: 'STATUS', key: 'status', width: 10 },
{ header: 'SERVER ID', key: 'serverId' },
{ header: 'HEALTH', key: (r) => r.healthStatus ?? '-', width: 10 },
{ header: 'PORT', key: (r) => r.port != null ? String(r.port) : '-', width: 6 },
{ header: 'CONTAINER', key: (r) => r.containerId ? r.containerId.slice(0, 12) : '-', width: 14 },
{ header: 'ID', key: 'id' },
@@ -86,12 +173,24 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
switch (resource) {
case 'servers':
return serverColumns as unknown as Column<Record<string, unknown>>[];
case 'profiles':
return profileColumns as unknown as Column<Record<string, unknown>>[];
case 'projects':
return projectColumns as unknown as Column<Record<string, unknown>>[];
case 'secrets':
return secretColumns as unknown as Column<Record<string, unknown>>[];
case 'templates':
return templateColumns as unknown as Column<Record<string, unknown>>[];
case 'instances':
return instanceColumns as unknown as Column<Record<string, unknown>>[];
case 'users':
return userColumns as unknown as Column<Record<string, unknown>>[];
case 'groups':
return groupColumns as unknown as Column<Record<string, unknown>>[];
case 'rbac':
return rbacColumns as unknown as Column<Record<string, unknown>>[];
case 'prompts':
return promptColumns as unknown as Column<Record<string, unknown>>[];
case 'promptrequests':
return promptRequestColumns as unknown as Column<Record<string, unknown>>[];
default:
return [
{ header: 'ID', key: 'id' as keyof Record<string, unknown> },
@@ -100,21 +199,38 @@ function getColumnsForResource(resource: string): Column<Record<string, unknown>
}
}
/**
* Transform API response items into apply-compatible format.
* Strips internal fields and wraps in the resource key.
*/
function toApplyFormat(resource: string, items: unknown[]): Record<string, unknown[]> {
const cleaned = items.map((item) => {
return stripInternalFields(item as Record<string, unknown>);
});
return { [resource]: cleaned };
}
export function createGetCommand(deps: GetCommandDeps): Command {
return new Command('get')
.description('List resources (servers, profiles, projects, instances)')
.argument('<resource>', 'resource type (servers, profiles, projects, instances)')
.argument('[id]', 'specific resource ID')
.description('List resources (servers, projects, instances)')
.argument('<resource>', 'resource type (servers, projects, instances)')
.argument('[id]', 'specific resource ID or name')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.action(async (resourceArg: string, id: string | undefined, opts: { output: string }) => {
const resource = resolveResource(resourceArg);
const items = await deps.fetchResource(resource, id);
if (opts.output === 'json') {
deps.log(formatJson(items.length === 1 ? items[0] : items));
// Apply-compatible JSON wrapped in resource key
deps.log(formatJson(toApplyFormat(resource, items)));
} else if (opts.output === 'yaml') {
deps.log(formatYaml(items.length === 1 ? items[0] : items));
// Apply-compatible YAML wrapped in resource key
deps.log(formatYaml(toApplyFormat(resource, items)));
} else {
if (items.length === 0) {
deps.log(`No ${resource} found.`);
return;
}
const columns = getColumnsForResource(resource);
deps.log(formatTable(items as Record<string, unknown>[], columns));
}

View File

@@ -1,123 +0,0 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
interface Instance {
id: string;
serverId: string;
status: string;
containerId: string | null;
port: number | null;
createdAt: string;
}
export interface InstanceCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createInstanceCommands(deps: InstanceCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('instance')
.alias('instances')
.alias('inst')
.description('Manage MCP server instances');
cmd
.command('list')
.alias('ls')
.description('List running instances')
.option('-s, --server <id>', 'Filter by server ID')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (opts: { server?: string; output: string }) => {
let url = '/api/v1/instances';
if (opts.server) {
url += `?serverId=${encodeURIComponent(opts.server)}`;
}
const instances = await client.get<Instance[]>(url);
if (opts.output === 'json') {
log(JSON.stringify(instances, null, 2));
return;
}
if (instances.length === 0) {
log('No instances found.');
return;
}
log('ID\tSERVER\tSTATUS\tPORT\tCONTAINER');
for (const inst of instances) {
const cid = inst.containerId ? inst.containerId.slice(0, 12) : '-';
const port = inst.port ?? '-';
log(`${inst.id}\t${inst.serverId}\t${inst.status}\t${port}\t${cid}`);
}
});
cmd
.command('start <serverId>')
.description('Start a new MCP server instance')
.option('-p, --port <port>', 'Host port to bind')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (serverId: string, opts: { port?: string; output: string }) => {
const body: Record<string, unknown> = { serverId };
if (opts.port !== undefined) {
body.hostPort = parseInt(opts.port, 10);
}
const instance = await client.post<Instance>('/api/v1/instances', body);
if (opts.output === 'json') {
log(JSON.stringify(instance, null, 2));
return;
}
log(`Instance ${instance.id} started (status: ${instance.status})`);
});
cmd
.command('stop <id>')
.description('Stop a running instance')
.action(async (id: string) => {
const instance = await client.post<Instance>(`/api/v1/instances/${id}/stop`);
log(`Instance ${id} stopped (status: ${instance.status})`);
});
cmd
.command('restart <id>')
.description('Restart an instance (stop, remove, start fresh)')
.action(async (id: string) => {
const instance = await client.post<Instance>(`/api/v1/instances/${id}/restart`);
log(`Instance restarted as ${instance.id} (status: ${instance.status})`);
});
cmd
.command('remove <id>')
.alias('rm')
.description('Remove an instance and its container')
.action(async (id: string) => {
await client.delete(`/api/v1/instances/${id}`);
log(`Instance ${id} removed.`);
});
cmd
.command('logs <id>')
.description('Get logs from an instance')
.option('-t, --tail <lines>', 'Number of lines to show')
.action(async (id: string, opts: { tail?: string }) => {
let url = `/api/v1/instances/${id}/logs`;
if (opts.tail) {
url += `?tail=${opts.tail}`;
}
const logs = await client.get<{ stdout: string; stderr: string }>(url);
if (logs.stdout) {
log(logs.stdout);
}
if (logs.stderr) {
process.stderr.write(logs.stderr);
}
});
cmd
.command('inspect <id>')
.description('Get detailed container info for an instance')
.action(async (id: string) => {
const info = await client.get(`/api/v1/instances/${id}/inspect`);
log(JSON.stringify(info, null, 2));
});
return cmd;
}

View File

@@ -0,0 +1,98 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
export interface LogsCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
interface InstanceInfo {
id: string;
status: string;
containerId: string | null;
}
/**
* Resolve a name/ID to an instance ID.
* Accepts: instance ID, server name, or server ID.
* For servers with multiple replicas, picks by --instance index or first RUNNING.
*/
async function resolveInstance(
client: ApiClient,
nameOrId: string,
instanceIndex?: number,
): Promise<{ instanceId: string; serverName?: string; replicaInfo?: string }> {
// Try as instance ID first
try {
await client.get(`/api/v1/instances/${nameOrId}`);
return { instanceId: nameOrId };
} catch {
// Not a valid instance ID
}
// Try as server name/ID → find its instances
const servers = await client.get<Array<{ id: string; name: string }>>('/api/v1/servers');
const server = servers.find((s) => s.name === nameOrId || s.id === nameOrId);
if (!server) {
throw new Error(`Instance or server '${nameOrId}' not found`);
}
const instances = await client.get<InstanceInfo[]>(`/api/v1/instances?serverId=${server.id}`);
if (instances.length === 0) {
throw new Error(`No instances found for server '${server.name}'`);
}
// Select by index or pick first running
let selected: InstanceInfo | undefined;
if (instanceIndex !== undefined) {
if (instanceIndex < 0 || instanceIndex >= instances.length) {
throw new Error(`Instance index ${instanceIndex} out of range (server '${server.name}' has ${instances.length} instance${instances.length > 1 ? 's' : ''})`);
}
selected = instances[instanceIndex];
} else {
selected = instances.find((i) => i.status === 'RUNNING') ?? instances[0];
}
if (!selected) {
throw new Error(`No instances found for server '${server.name}'`);
}
const result: { instanceId: string; serverName?: string; replicaInfo?: string } = {
instanceId: selected.id,
serverName: server.name,
};
if (instances.length > 1) {
result.replicaInfo = `instance ${instances.indexOf(selected) + 1}/${instances.length}`;
}
return result;
}
export function createLogsCommand(deps: LogsCommandDeps): Command {
const { client, log } = deps;
return new Command('logs')
.description('Get logs from an MCP server instance')
.argument('<name>', 'Server name, server ID, or instance ID')
.option('-t, --tail <lines>', 'Number of lines to show')
.option('-i, --instance <index>', 'Instance/replica index (0-based, for servers with multiple replicas)')
.action(async (nameOrId: string, opts: { tail?: string; instance?: string }) => {
const instanceIndex = opts.instance !== undefined ? parseInt(opts.instance, 10) : undefined;
const { instanceId, serverName, replicaInfo } = await resolveInstance(client, nameOrId, instanceIndex);
if (replicaInfo) {
process.stderr.write(`Showing logs for ${serverName} (${replicaInfo})\n`);
}
let url = `/api/v1/instances/${instanceId}/logs`;
if (opts.tail) {
url += `?tail=${opts.tail}`;
}
const logs = await client.get<{ stdout: string; stderr: string }>(url);
if (logs.stdout) {
log(logs.stdout);
}
if (logs.stderr) {
process.stderr.write(logs.stderr);
}
});
}

224
src/cli/src/commands/mcp.ts Normal file
View File

@@ -0,0 +1,224 @@
import { Command } from 'commander';
import http from 'node:http';
import { createInterface } from 'node:readline';
export interface McpBridgeOptions {
projectName: string;
mcplocalUrl: string;
token?: string | undefined;
stdin: NodeJS.ReadableStream;
stdout: NodeJS.WritableStream;
stderr: NodeJS.WritableStream;
}
function postJsonRpc(
url: string,
body: string,
sessionId: string | undefined,
token: string | undefined,
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
};
if (sessionId) {
headers['mcp-session-id'] = sessionId;
}
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const req = http.request(
{
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname,
method: 'POST',
headers,
timeout: 30_000,
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
resolve({
status: res.statusCode ?? 0,
headers: res.headers,
body: Buffer.concat(chunks).toString('utf-8'),
});
});
},
);
req.on('error', reject);
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out'));
});
req.write(body);
req.end();
});
}
function sendDelete(
url: string,
sessionId: string,
token: string | undefined,
): Promise<void> {
return new Promise((resolve) => {
const parsed = new URL(url);
const headers: Record<string, string> = {
'mcp-session-id': sessionId,
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const req = http.request(
{
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname,
method: 'DELETE',
headers,
timeout: 5_000,
},
() => resolve(),
);
req.on('error', () => resolve()); // Best effort cleanup
req.on('timeout', () => {
req.destroy();
resolve();
});
req.end();
});
}
/**
* Extract JSON-RPC messages from an HTTP response body.
* Handles both plain JSON and SSE (text/event-stream) formats.
*/
function extractJsonRpcMessages(contentType: string | undefined, body: string): string[] {
if (contentType?.includes('text/event-stream')) {
// Parse SSE: extract data: lines
const messages: string[] = [];
for (const line of body.split('\n')) {
if (line.startsWith('data: ')) {
messages.push(line.slice(6));
}
}
return messages;
}
// Plain JSON response
return [body];
}
/**
* STDIO-to-Streamable-HTTP MCP bridge.
*
* Reads JSON-RPC messages line-by-line from stdin, POSTs them to
* mcplocal's project endpoint, and writes responses to stdout.
*/
export async function runMcpBridge(opts: McpBridgeOptions): Promise<void> {
const { projectName, mcplocalUrl, token, stdin, stdout, stderr } = opts;
const endpointUrl = `${mcplocalUrl.replace(/\/$/, '')}/projects/${encodeURIComponent(projectName)}/mcp`;
let sessionId: string | undefined;
const rl = createInterface({ input: stdin, crlfDelay: Infinity });
for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const result = await postJsonRpc(endpointUrl, trimmed, sessionId, token);
// Capture session ID from first response
if (!sessionId) {
const sid = result.headers['mcp-session-id'];
if (typeof sid === 'string') {
sessionId = sid;
}
}
if (result.status >= 400) {
stderr.write(`MCP bridge error: HTTP ${result.status}: ${result.body}\n`);
}
// Handle both plain JSON and SSE responses
const messages = extractJsonRpcMessages(result.headers['content-type'], result.body);
for (const msg of messages) {
const trimmedMsg = msg.trim();
if (trimmedMsg) {
stdout.write(trimmedMsg + '\n');
}
}
} catch (err) {
stderr.write(`MCP bridge error: ${err instanceof Error ? err.message : String(err)}\n`);
}
}
// stdin closed — cleanup session
if (sessionId) {
await sendDelete(endpointUrl, sessionId, token);
}
}
export interface McpCommandDeps {
getProject: () => string | undefined;
configLoader?: () => { mcplocalUrl: string };
credentialsLoader?: () => { token: string } | null;
}
export function createMcpCommand(deps: McpCommandDeps): Command {
const cmd = new Command('mcp')
.description('MCP STDIO transport bridge — connects stdin/stdout to a project MCP endpoint')
.passThroughOptions()
.option('-p, --project <name>', 'Project name')
.action(async (opts: { project?: string }) => {
// Accept -p/--project on the command itself, or fall back to global --project
const projectName = opts.project ?? deps.getProject();
if (!projectName) {
process.stderr.write('Error: --project is required for the mcp command\n');
process.exitCode = 1;
return;
}
let mcplocalUrl = 'http://localhost:3200';
if (deps.configLoader) {
mcplocalUrl = deps.configLoader().mcplocalUrl;
} else {
try {
const { loadConfig } = await import('../config/index.js');
mcplocalUrl = loadConfig().mcplocalUrl;
} catch {
// Use default
}
}
let token: string | undefined;
if (deps.credentialsLoader) {
token = deps.credentialsLoader()?.token;
} else {
try {
const { loadCredentials } = await import('../auth/index.js');
token = loadCredentials()?.token;
} catch {
// No credentials
}
}
await runMcpBridge({
projectName,
mcplocalUrl,
token,
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
});
});
return cmd;
}

View File

@@ -0,0 +1,66 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
import { resolveNameOrId, resolveResource } from './shared.js';
export interface ProjectOpsDeps {
client: ApiClient;
log: (...args: string[]) => void;
getProject: () => string | undefined;
}
function requireProject(deps: ProjectOpsDeps): string {
const project = deps.getProject();
if (!project) {
deps.log('Error: --project <name> is required for this command.');
process.exitCode = 1;
throw new Error('--project required');
}
return project;
}
export function createAttachServerCommand(deps: ProjectOpsDeps): Command {
const { client, log } = deps;
return new Command('attach-server')
.description('Attach a server to a project (requires --project)')
.argument('<server-name>', 'Server name to attach')
.action(async (serverName: string) => {
const projectName = requireProject(deps);
const projectId = await resolveNameOrId(client, 'projects', projectName);
await client.post(`/api/v1/projects/${projectId}/servers`, { server: serverName });
log(`server '${serverName}' attached to project '${projectName}'`);
});
}
export function createDetachServerCommand(deps: ProjectOpsDeps): Command {
const { client, log } = deps;
return new Command('detach-server')
.description('Detach a server from a project (requires --project)')
.argument('<server-name>', 'Server name to detach')
.action(async (serverName: string) => {
const projectName = requireProject(deps);
const projectId = await resolveNameOrId(client, 'projects', projectName);
await client.delete(`/api/v1/projects/${projectId}/servers/${serverName}`);
log(`server '${serverName}' detached from project '${projectName}'`);
});
}
export function createApproveCommand(deps: ProjectOpsDeps): Command {
const { client, log } = deps;
return new Command('approve')
.description('Approve a pending prompt request (atomic: delete request, create prompt)')
.argument('<resource>', 'Resource type (promptrequest)')
.argument('<name>', 'Prompt request name or ID')
.action(async (resourceArg: string, nameOrId: string) => {
const resource = resolveResource(resourceArg);
if (resource !== 'promptrequests') {
throw new Error(`approve is only supported for 'promptrequest', got '${resourceArg}'`);
}
const id = await resolveNameOrId(client, 'promptrequests', nameOrId);
const prompt = await client.post<{ id: string; name: string }>(`/api/v1/promptrequests/${id}/approve`, {});
log(`prompt request approved → prompt '${prompt.name}' created (id: ${prompt.id})`);
});
}

View File

@@ -1,129 +0,0 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
interface Project {
id: string;
name: string;
description: string;
ownerId: string;
createdAt: string;
}
interface Profile {
id: string;
name: string;
serverId: string;
}
export interface ProjectCommandDeps {
client: ApiClient;
log: (...args: unknown[]) => void;
}
export function createProjectCommand(deps: ProjectCommandDeps): Command {
const { client, log } = deps;
const cmd = new Command('project')
.alias('projects')
.alias('proj')
.description('Manage mcpctl projects');
cmd
.command('list')
.alias('ls')
.description('List all projects')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (opts: { output: string }) => {
const projects = await client.get<Project[]>('/api/v1/projects');
if (opts.output === 'json') {
log(JSON.stringify(projects, null, 2));
return;
}
if (projects.length === 0) {
log('No projects found.');
return;
}
log('ID\tNAME\tDESCRIPTION');
for (const p of projects) {
log(`${p.id}\t${p.name}\t${p.description || '-'}`);
}
});
cmd
.command('create <name>')
.description('Create a new project')
.option('-d, --description <text>', 'Project description', '')
.action(async (name: string, opts: { description: string }) => {
const project = await client.post<Project>('/api/v1/projects', {
name,
description: opts.description,
});
log(`Project '${project.name}' created (id: ${project.id})`);
});
cmd
.command('delete <id>')
.alias('rm')
.description('Delete a project')
.action(async (id: string) => {
await client.delete(`/api/v1/projects/${id}`);
log(`Project '${id}' deleted.`);
});
cmd
.command('show <id>')
.description('Show project details')
.action(async (id: string) => {
const project = await client.get<Project>(`/api/v1/projects/${id}`);
log(`Name: ${project.name}`);
log(`ID: ${project.id}`);
log(`Description: ${project.description || '-'}`);
log(`Owner: ${project.ownerId}`);
log(`Created: ${project.createdAt}`);
try {
const profiles = await client.get<Profile[]>(`/api/v1/projects/${id}/profiles`);
if (profiles.length > 0) {
log('\nProfiles:');
for (const p of profiles) {
log(` - ${p.name} (id: ${p.id})`);
}
} else {
log('\nNo profiles assigned.');
}
} catch {
// Profiles endpoint may not be available
}
});
cmd
.command('profiles <id>')
.description('List profiles assigned to a project')
.option('-o, --output <format>', 'Output format (table, json)', 'table')
.action(async (id: string, opts: { output: string }) => {
const profiles = await client.get<Profile[]>(`/api/v1/projects/${id}/profiles`);
if (opts.output === 'json') {
log(JSON.stringify(profiles, null, 2));
return;
}
if (profiles.length === 0) {
log('No profiles assigned.');
return;
}
log('ID\tNAME\tSERVER');
for (const p of profiles) {
log(`${p.id}\t${p.name}\t${p.serverId}`);
}
});
cmd
.command('set-profiles <id>')
.description('Set the profiles assigned to a project')
.argument('<profileIds...>', 'Profile IDs to assign')
.action(async (id: string, profileIds: string[]) => {
await client.put(`/api/v1/projects/${id}/profiles`, { profileIds });
log(`Set ${profileIds.length} profile(s) for project '${id}'.`);
});
return cmd;
}

View File

@@ -1,103 +0,0 @@
import { Command } from 'commander';
import type { ApiClient } from '../api-client.js';
export interface SetupPromptDeps {
input: (message: string) => Promise<string>;
password: (message: string) => Promise<string>;
select: <T extends string>(message: string, choices: Array<{ name: string; value: T }>) => Promise<T>;
confirm: (message: string) => Promise<boolean>;
}
export interface SetupCommandDeps {
client: ApiClient;
prompt: SetupPromptDeps;
log: (...args: unknown[]) => void;
}
export function createSetupCommand(deps: SetupCommandDeps): Command {
const { client, prompt, log } = deps;
return new Command('setup')
.description('Interactive wizard for configuring an MCP server')
.argument('[server-name]', 'Server name to set up (will prompt if not given)')
.action(async (serverName?: string) => {
log('MCP Server Setup Wizard\n');
// Step 1: Server name
const name = serverName ?? await prompt.input('Server name (lowercase, hyphens allowed):');
if (!name) {
log('Setup cancelled.');
return;
}
// Step 2: Transport
const transport = await prompt.select('Transport type:', [
{ name: 'STDIO (command-line process)', value: 'STDIO' as const },
{ name: 'SSE (Server-Sent Events over HTTP)', value: 'SSE' as const },
{ name: 'Streamable HTTP', value: 'STREAMABLE_HTTP' as const },
]);
// Step 3: Package or image
const packageName = await prompt.input('NPM package name (or leave empty):');
const dockerImage = await prompt.input('Docker image (or leave empty):');
// Step 4: Description
const description = await prompt.input('Description:');
// Step 5: Create the server
const serverData: Record<string, unknown> = {
name,
transport,
description,
};
if (packageName) serverData.packageName = packageName;
if (dockerImage) serverData.dockerImage = dockerImage;
let server: { id: string; name: string };
try {
server = await client.post<{ id: string; name: string }>('/api/v1/servers', serverData);
log(`\nServer '${server.name}' created.`);
} catch (err) {
log(`\nFailed to create server: ${err instanceof Error ? err.message : err}`);
return;
}
// Step 6: Create a profile with env vars
const createProfile = await prompt.confirm('Create a profile with environment variables?');
if (!createProfile) {
log('\nSetup complete!');
return;
}
const profileName = await prompt.input('Profile name:') || 'default';
// Collect env vars
const envOverrides: Record<string, string> = {};
let addMore = true;
while (addMore) {
const envName = await prompt.input('Environment variable name (empty to finish):');
if (!envName) break;
const isSecret = await prompt.confirm(`Is '${envName}' a secret (e.g., API key)?`);
const envValue = isSecret
? await prompt.password(`Value for ${envName}:`)
: await prompt.input(`Value for ${envName}:`);
envOverrides[envName] = envValue;
addMore = await prompt.confirm('Add another environment variable?');
}
try {
await client.post('/api/v1/profiles', {
name: profileName,
serverId: server.id,
envOverrides,
});
log(`Profile '${profileName}' created for server '${name}'.`);
} catch (err) {
log(`Failed to create profile: ${err instanceof Error ? err.message : err}`);
}
log('\nSetup complete!');
});
}

View File

@@ -0,0 +1,68 @@
import type { ApiClient } from '../api-client.js';
export const RESOURCE_ALIASES: Record<string, string> = {
server: 'servers',
srv: 'servers',
project: 'projects',
proj: 'projects',
instance: 'instances',
inst: 'instances',
secret: 'secrets',
sec: 'secrets',
template: 'templates',
tpl: 'templates',
user: 'users',
group: 'groups',
rbac: 'rbac',
'rbac-definition': 'rbac',
'rbac-binding': 'rbac',
prompt: 'prompts',
prompts: 'prompts',
promptrequest: 'promptrequests',
promptrequests: 'promptrequests',
pr: 'promptrequests',
};
export function resolveResource(name: string): string {
const lower = name.toLowerCase();
return RESOURCE_ALIASES[lower] ?? lower;
}
/** Resolve a name-or-ID to an ID. CUIDs pass through; names are looked up. */
export async function resolveNameOrId(
client: ApiClient,
resource: string,
nameOrId: string,
): Promise<string> {
// CUIDs start with 'c' followed by 24+ alphanumeric chars
if (/^c[a-z0-9]{24}/.test(nameOrId)) {
return nameOrId;
}
// Users resolve by email, not name
if (resource === 'users') {
const items = await client.get<Array<{ id: string; email: string }>>(`/api/v1/${resource}`);
const match = items.find((item) => item.email === nameOrId);
if (match) return match.id;
throw new Error(`user '${nameOrId}' not found`);
}
const items = await client.get<Array<Record<string, unknown>>>(`/api/v1/${resource}`);
const match = items.find((item) => {
// Instances use server.name, other resources use name directly
if (resource === 'instances') {
const server = item.server as { name?: string } | undefined;
return server?.name === nameOrId;
}
return item.name === nameOrId;
});
if (match) return match.id as string;
throw new Error(`${resource.replace(/s$/, '')} '${nameOrId}' not found`);
}
/** Strip internal/read-only fields from an API response to make it apply-compatible. */
export function stripInternalFields(obj: Record<string, unknown>): Record<string, unknown> {
const result = { ...obj };
for (const key of ['id', 'createdAt', 'updatedAt', 'version', 'ownerId']) {
delete result[key];
}
return result;
}

View File

@@ -1,17 +1,31 @@
import { Command } from 'commander';
import http from 'node:http';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { loadConfig } from '../config/index.js';
import type { ConfigLoaderDeps } from '../config/index.js';
import type { ConfigLoaderDeps, LlmConfig } from '../config/index.js';
import { loadCredentials } from '../auth/index.js';
import type { CredentialsDeps } from '../auth/index.js';
import { formatJson, formatYaml } from '../formatters/index.js';
import { APP_VERSION } from '@mcpctl/shared';
const execFileAsync = promisify(execFile);
// ANSI helpers
const GREEN = '\x1b[32m';
const RED = '\x1b[31m';
const DIM = '\x1b[2m';
const RESET = '\x1b[0m';
const CLEAR_LINE = '\x1b[2K\r';
export interface StatusCommandDeps {
configDeps: Partial<ConfigLoaderDeps>;
credentialsDeps: Partial<CredentialsDeps>;
log: (...args: string[]) => void;
write: (text: string) => void;
checkHealth: (url: string) => Promise<boolean>;
checkLlm: (llm: LlmConfig) => Promise<string>;
isTTY: boolean;
}
function defaultCheckHealth(url: string): Promise<boolean> {
@@ -28,15 +42,51 @@ function defaultCheckHealth(url: string): Promise<boolean> {
});
}
/**
* Quick LLM health check. Returns 'ok', 'binary not found', 'auth error', etc.
*/
async function defaultCheckLlm(llm: LlmConfig): Promise<string> {
if (llm.provider === 'gemini-cli') {
const bin = llm.binaryPath ?? 'gemini';
try {
const { stdout } = await execFileAsync(bin, ['-p', 'respond with exactly: ok', '-m', llm.model ?? 'gemini-2.5-flash', '-o', 'text'], { timeout: 15000 });
return stdout.trim().toLowerCase().includes('ok') ? 'ok' : 'unexpected response';
} catch (err) {
const msg = (err as Error).message;
if (msg.includes('ENOENT')) return 'binary not found';
if (msg.includes('auth') || msg.includes('token') || msg.includes('login') || msg.includes('401')) return 'not authenticated';
return `error: ${msg.slice(0, 80)}`;
}
}
if (llm.provider === 'ollama') {
const url = llm.url ?? 'http://localhost:11434';
try {
const ok = await defaultCheckHealth(url);
return ok ? 'ok' : 'unreachable';
} catch {
return 'unreachable';
}
}
// For API-key providers, we don't want to make a billable call on every status check
return 'ok (key stored)';
}
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
const defaultDeps: StatusCommandDeps = {
configDeps: {},
credentialsDeps: {},
log: (...args) => console.log(...args),
write: (text) => process.stdout.write(text),
checkHealth: defaultCheckHealth,
checkLlm: defaultCheckLlm,
isTTY: process.stdout.isTTY ?? false,
};
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
const { configDeps, credentialsDeps, log, checkHealth } = { ...defaultDeps, ...deps };
const { configDeps, credentialsDeps, log, write, checkHealth, checkLlm, isTTY } = { ...defaultDeps, ...deps };
return new Command('status')
.description('Show mcpctl status and connectivity')
@@ -45,11 +95,22 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
const config = loadConfig(configDeps);
const creds = loadCredentials(credentialsDeps);
const [mcplocalReachable, mcpdReachable] = await Promise.all([
const llmLabel = config.llm && config.llm.provider !== 'none'
? `${config.llm.provider}${config.llm.model ? ` / ${config.llm.model}` : ''}`
: null;
if (opts.output !== 'table') {
// JSON/YAML: run everything in parallel, wait, output at once
const [mcplocalReachable, mcpdReachable, llmStatus] = await Promise.all([
checkHealth(config.mcplocalUrl),
checkHealth(config.mcpdUrl),
llmLabel ? checkLlm(config.llm!) : Promise.resolve(null),
]);
const llm = llmLabel
? llmStatus === 'ok' ? llmLabel : `${llmLabel} (${llmStatus})`
: null;
const status = {
version: APP_VERSION,
mcplocalUrl: config.mcplocalUrl,
@@ -59,19 +120,60 @@ export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command
auth: creds ? { user: creds.user } : null,
registries: config.registries,
outputFormat: config.outputFormat,
llm,
llmStatus,
};
if (opts.output === 'json') {
log(formatJson(status));
} else if (opts.output === 'yaml') {
log(formatYaml(status));
} else {
log(`mcpctl v${status.version}`);
log(`mcplocal: ${status.mcplocalUrl} (${mcplocalReachable ? 'connected' : 'unreachable'})`);
log(`mcpd: ${status.mcpdUrl} (${mcpdReachable ? 'connected' : 'unreachable'})`);
log(opts.output === 'json' ? formatJson(status) : formatYaml(status));
return;
}
// Table format: print lines progressively, LLM last with spinner
// Fast health checks first
const [mcplocalReachable, mcpdReachable] = await Promise.all([
checkHealth(config.mcplocalUrl),
checkHealth(config.mcpdUrl),
]);
log(`mcpctl v${APP_VERSION}`);
log(`mcplocal: ${config.mcplocalUrl} (${mcplocalReachable ? 'connected' : 'unreachable'})`);
log(`mcpd: ${config.mcpdUrl} (${mcpdReachable ? 'connected' : 'unreachable'})`);
log(`Auth: ${creds ? `logged in as ${creds.user}` : 'not logged in'}`);
log(`Registries: ${status.registries.join(', ')}`);
log(`Output: ${status.outputFormat}`);
log(`Registries: ${config.registries.join(', ')}`);
log(`Output: ${config.outputFormat}`);
if (!llmLabel) {
log(`LLM: not configured (run 'mcpctl config setup')`);
return;
}
// LLM check with spinner
const llmPromise = checkLlm(config.llm!);
if (isTTY) {
let frame = 0;
const interval = setInterval(() => {
write(`${CLEAR_LINE}LLM: ${llmLabel} ${DIM}${SPINNER_FRAMES[frame % SPINNER_FRAMES.length]} checking...${RESET}`);
frame++;
}, 80);
const llmStatus = await llmPromise;
clearInterval(interval);
if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') {
write(`${CLEAR_LINE}LLM: ${llmLabel} ${GREEN}${llmStatus}${RESET}\n`);
} else {
write(`${CLEAR_LINE}LLM: ${llmLabel} ${RED}${llmStatus}${RESET}\n`);
}
} else {
// Non-TTY: no spinner, just wait and print
const llmStatus = await llmPromise;
if (llmStatus === 'ok' || llmStatus === 'ok (key stored)') {
log(`LLM: ${llmLabel}${llmStatus}`);
} else {
log(`LLM: ${llmLabel}${llmStatus}`);
}
}
});
}

View File

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

View File

@@ -1,5 +1,21 @@
import { z } from 'zod';
export const LLM_PROVIDERS = ['gemini-cli', 'ollama', 'anthropic', 'openai', 'deepseek', 'vllm', 'none'] as const;
export type LlmProviderName = typeof LLM_PROVIDERS[number];
export const LlmConfigSchema = z.object({
/** LLM provider name */
provider: z.enum(LLM_PROVIDERS),
/** Model name */
model: z.string().optional(),
/** Provider URL (for ollama, vllm, openai with custom endpoint) */
url: z.string().optional(),
/** Binary path override (for gemini-cli) */
binaryPath: z.string().optional(),
}).strict();
export type LlmConfig = z.infer<typeof LlmConfigSchema>;
export const McpctlConfigSchema = z.object({
/** mcplocal daemon endpoint (local LLM pre-processing proxy) */
mcplocalUrl: z.string().default('http://localhost:3200'),
@@ -19,6 +35,8 @@ export const McpctlConfigSchema = z.object({
outputFormat: z.enum(['table', 'json', 'yaml']).default('table'),
/** Smithery API key */
smitheryApiKey: z.string().optional(),
/** LLM provider configuration for smart features (pagination summaries, etc.) */
llm: LlmConfigSchema.optional(),
}).transform((cfg) => {
// Backward compatibility: if old daemonUrl is set but mcplocalUrl wasn't explicitly changed,
// use daemonUrl as mcplocalUrl

View File

@@ -5,27 +5,30 @@ import { createConfigCommand } from './commands/config.js';
import { createStatusCommand } from './commands/status.js';
import { createGetCommand } from './commands/get.js';
import { createDescribeCommand } from './commands/describe.js';
import { createInstanceCommands } from './commands/instances.js';
import { createDeleteCommand } from './commands/delete.js';
import { createLogsCommand } from './commands/logs.js';
import { createApplyCommand } from './commands/apply.js';
import { createSetupCommand } from './commands/setup.js';
import { createClaudeCommand } from './commands/claude.js';
import { createProjectCommand } from './commands/project.js';
import { createCreateCommand } from './commands/create.js';
import { createEditCommand } from './commands/edit.js';
import { createBackupCommand, createRestoreCommand } from './commands/backup.js';
import { createLoginCommand, createLogoutCommand } from './commands/auth.js';
import { ApiClient } from './api-client.js';
import { createAttachServerCommand, createDetachServerCommand, createApproveCommand } from './commands/project-ops.js';
import { createMcpCommand } from './commands/mcp.js';
import { ApiClient, ApiError } from './api-client.js';
import { loadConfig } from './config/index.js';
import { loadCredentials } from './auth/index.js';
import { resolveNameOrId } from './commands/shared.js';
export function createProgram(): Command {
const program = new Command()
.name(APP_NAME)
.description('Manage MCP servers like kubectl manages containers')
.version(APP_VERSION, '-v, --version')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.enablePositionalOptions()
.option('--daemon-url <url>', 'mcplocal daemon URL')
.option('--direct', 'bypass mcplocal and connect directly to mcpd');
.option('--direct', 'bypass mcplocal and connect directly to mcpd')
.option('--project <name>', 'Target project for project commands');
program.addCommand(createConfigCommand());
program.addCommand(createStatusCommand());
program.addCommand(createLoginCommand());
program.addCommand(createLogoutCommand());
@@ -45,15 +48,52 @@ export function createProgram(): Command {
const client = new ApiClient({ baseUrl, token: creds?.token ?? undefined });
const fetchResource = async (resource: string, id?: string): Promise<unknown[]> => {
if (id) {
program.addCommand(createConfigCommand(undefined, {
client,
credentialsDeps: {},
log: (...args) => console.log(...args),
}));
const fetchResource = async (resource: string, nameOrId?: string): Promise<unknown[]> => {
const projectName = program.opts().project as string | undefined;
// --project scoping for servers and instances
if (projectName && !nameOrId && (resource === 'servers' || resource === 'instances')) {
const projectId = await resolveNameOrId(client, 'projects', projectName);
if (resource === 'servers') {
return client.get<unknown[]>(`/api/v1/projects/${projectId}/servers`);
}
// instances: fetch project servers, then filter instances by serverId
const projectServers = await client.get<Array<{ id: string }>>(`/api/v1/projects/${projectId}/servers`);
const serverIds = new Set(projectServers.map((s) => s.id));
const allInstances = await client.get<Array<{ serverId: string }>>(`/api/v1/instances`);
return allInstances.filter((inst) => serverIds.has(inst.serverId));
}
if (nameOrId) {
// Glob pattern — use query param filtering
if (nameOrId.includes('*')) {
return client.get<unknown[]>(`/api/v1/${resource}?name=${encodeURIComponent(nameOrId)}`);
}
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);
} catch {
id = nameOrId;
}
const item = await client.get(`/api/v1/${resource}/${id}`);
return [item];
}
return client.get<unknown[]>(`/api/v1/${resource}`);
};
const fetchSingleResource = async (resource: string, id: string): Promise<unknown> => {
const fetchSingleResource = async (resource: string, nameOrId: string): Promise<unknown> => {
let id: string;
try {
id = await resolveNameOrId(client, resource, nameOrId);
} catch {
id = nameOrId;
}
return client.get(`/api/v1/${resource}/${id}`);
};
@@ -63,11 +103,28 @@ export function createProgram(): Command {
}));
program.addCommand(createDescribeCommand({
client,
fetchResource: fetchSingleResource,
fetchInspect: async (id: string) => client.get(`/api/v1/instances/${id}/inspect`),
log: (...args) => console.log(...args),
}));
program.addCommand(createInstanceCommands({
program.addCommand(createDeleteCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createLogsCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createCreateCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createEditCommand({
client,
log: (...args) => console.log(...args),
}));
@@ -77,43 +134,6 @@ export function createProgram(): Command {
log: (...args) => console.log(...args),
}));
program.addCommand(createSetupCommand({
client,
prompt: {
async input(message) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'input', name: 'answer', message }]);
return answer as string;
},
async password(message) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'password', name: 'answer', message }]);
return answer as string;
},
async select(message, choices) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'list', name: 'answer', message, choices }]);
return answer;
},
async confirm(message) {
const { default: inquirer } = await import('inquirer');
const { answer } = await inquirer.prompt([{ type: 'confirm', name: 'answer', message }]);
return answer as boolean;
},
},
log: (...args) => console.log(...args),
}));
program.addCommand(createClaudeCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createProjectCommand({
client,
log: (...args) => console.log(...args),
}));
program.addCommand(createBackupCommand({
client,
log: (...args) => console.log(...args),
@@ -124,6 +144,18 @@ export function createProgram(): Command {
log: (...args) => console.log(...args),
}));
const projectOpsDeps = {
client,
log: (...args: string[]) => console.log(...args),
getProject: () => program.opts().project as string | undefined,
};
program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true });
program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true });
program.addCommand(createApproveCommand(projectOpsDeps));
program.addCommand(createMcpCommand({
getProject: () => program.opts().project as string | undefined,
}), { hidden: true });
return program;
}
@@ -134,5 +166,35 @@ const isDirectRun =
import.meta.url === `file://${process.argv[1]}`;
if (isDirectRun) {
createProgram().parseAsync(process.argv);
createProgram().parseAsync(process.argv).catch((err: unknown) => {
if (err instanceof ApiError) {
if (err.status === 401) {
console.error("Error: you need to log in. Run 'mcpctl login' to authenticate.");
} else if (err.status === 403) {
console.error('Error: permission denied. You do not have access to this resource.');
} else {
let msg: string;
try {
const parsed = JSON.parse(err.body) as { error?: string; message?: string; details?: unknown };
msg = parsed.error ?? parsed.message ?? err.body;
if (parsed.details && Array.isArray(parsed.details)) {
const issues = parsed.details as Array<{ message?: string; path?: string[] }>;
const detail = issues.map((i) => {
const path = i.path?.join('.') ?? '';
return path ? `${path}: ${i.message}` : (i.message ?? '');
}).filter(Boolean).join('; ');
if (detail) msg += `: ${detail}`;
}
} catch {
msg = err.body;
}
console.error(`Error: ${msg}`);
}
} else if (err instanceof Error) {
console.error(`Error: ${err.message}`);
} else {
console.error(`Error: ${String(err)}`);
}
process.exit(1);
});
}

View File

@@ -21,6 +21,16 @@ beforeAll(async () => {
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 'srv-new', ...body }));
});
} else if (req.url === '/api/v1/servers/srv-1' && req.method === 'DELETE') {
// Fastify rejects empty body with Content-Type: application/json
const ct = req.headers['content-type'] ?? '';
if (ct.includes('application/json')) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: "Body cannot be empty when content-type is set to 'application/json'" }));
} else {
res.writeHead(204);
res.end();
}
} else if (req.url === '/api/v1/missing' && req.method === 'GET') {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
@@ -75,6 +85,12 @@ describe('ApiClient', () => {
await expect(client.get('/anything')).rejects.toThrow();
});
it('performs DELETE without Content-Type header', async () => {
const client = new ApiClient({ baseUrl: `http://localhost:${port}` });
// Should succeed (204) because no Content-Type is sent on bodyless DELETE
await expect(client.delete('/api/v1/servers/srv-1')).resolves.toBeUndefined();
});
it('sends Authorization header when token provided', async () => {
// We need a separate server to check the header
let receivedAuth = '';

View File

@@ -24,9 +24,10 @@ describe('createProgram', () => {
expect(status).toBeDefined();
});
it('has output option', () => {
it('subcommands have output option', () => {
const program = createProgram();
const opt = program.options.find((o) => o.long === '--output');
const get = program.commands.find((c) => c.name() === 'get');
const opt = get?.options.find((o) => o.long === '--output');
expect(opt).toBeDefined();
});

View File

@@ -86,9 +86,6 @@ servers:
servers:
- name: test
transport: STDIO
profiles:
- name: default
server: test
`);
const cmd = createApplyCommand({ client, log });
@@ -97,52 +94,51 @@ profiles:
expect(client.post).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('Dry run');
expect(output.join('\n')).toContain('1 server(s)');
expect(output.join('\n')).toContain('1 profile(s)');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies profiles with server lookup', async () => {
it('applies secrets', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
secrets:
- name: ha-creds
data:
TOKEN: abc123
URL: https://ha.local
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', expect.objectContaining({
name: 'ha-creds',
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
}));
expect(output.join('\n')).toContain('Created secret: ha-creds');
rmSync(tmpDir, { recursive: true, force: true });
});
it('updates existing secrets', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url === '/api/v1/servers') return [{ id: 'srv-1', name: 'slack' }];
if (url === '/api/v1/secrets') return [{ id: 'sec-1', name: 'ha-creds' }];
return [];
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
profiles:
- name: default
server: slack
envOverrides:
SLACK_TOKEN: "xoxb-test"
secrets:
- name: ha-creds
data:
TOKEN: new-token
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/profiles', expect.objectContaining({
name: 'default',
serverId: 'srv-1',
envOverrides: { SLACK_TOKEN: 'xoxb-test' },
}));
expect(output.join('\n')).toContain('Created profile: default');
rmSync(tmpDir, { recursive: true, force: true });
});
it('skips profiles when server not found', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
profiles:
- name: default
server: nonexistent
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).not.toHaveBeenCalled();
expect(output.join('\n')).toContain("Skipping profile 'default'");
expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { TOKEN: 'new-token' } });
expect(output.join('\n')).toContain('Updated secret: ha-creds');
rmSync(tmpDir, { recursive: true, force: true });
});
@@ -163,4 +159,347 @@ projects:
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies users (no role field)', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
users:
- email: alice@test.com
password: password123
name: Alice
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
const callBody = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
expect(callBody).toEqual(expect.objectContaining({
email: 'alice@test.com',
password: 'password123',
name: 'Alice',
}));
expect(callBody).not.toHaveProperty('role');
expect(output.join('\n')).toContain('Created user: alice@test.com');
rmSync(tmpDir, { recursive: true, force: true });
});
it('updates existing users matched by email', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url === '/api/v1/users') return [{ id: 'usr-1', email: 'alice@test.com' }];
return [];
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
users:
- email: alice@test.com
password: newpassword
name: Alice Updated
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/users/usr-1', expect.objectContaining({
email: 'alice@test.com',
name: 'Alice Updated',
}));
expect(output.join('\n')).toContain('Updated user: alice@test.com');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies groups', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
groups:
- name: dev-team
description: Development team
members:
- alice@test.com
- bob@test.com
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/groups', expect.objectContaining({
name: 'dev-team',
description: 'Development team',
members: ['alice@test.com', 'bob@test.com'],
}));
expect(output.join('\n')).toContain('Created group: dev-team');
rmSync(tmpDir, { recursive: true, force: true });
});
it('updates existing groups', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url === '/api/v1/groups') return [{ id: 'grp-1', name: 'dev-team' }];
return [];
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
groups:
- name: dev-team
description: Updated devs
members:
- new@test.com
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/groups/grp-1', expect.objectContaining({
name: 'dev-team',
description: 'Updated devs',
}));
expect(output.join('\n')).toContain('Updated group: dev-team');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies rbacBindings', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbac:
- name: developers
subjects:
- kind: User
name: alice@test.com
- kind: Group
name: dev-team
roleBindings:
- role: edit
resource: servers
- role: view
resource: instances
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
name: 'developers',
subjects: [
{ kind: 'User', name: 'alice@test.com' },
{ kind: 'Group', name: 'dev-team' },
],
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'view', resource: 'instances' },
],
}));
expect(output.join('\n')).toContain('Created rbacBinding: developers');
rmSync(tmpDir, { recursive: true, force: true });
});
it('updates existing rbacBindings', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url === '/api/v1/rbac') return [{ id: 'rbac-1', name: 'developers' }];
return [];
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbacBindings:
- name: developers
subjects:
- kind: User
name: new@test.com
roleBindings:
- role: edit
resource: "*"
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/rbac/rbac-1', expect.objectContaining({
name: 'developers',
}));
expect(output.join('\n')).toContain('Updated rbacBinding: developers');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies projects with servers', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
projects:
- name: smart-home
description: Home automation
proxyMode: filtered
llmProvider: gemini-cli
llmModel: gemini-2.0-flash
servers:
- my-grafana
- my-ha
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
name: 'smart-home',
proxyMode: 'filtered',
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
servers: ['my-grafana', 'my-ha'],
}));
expect(output.join('\n')).toContain('Created project: smart-home');
rmSync(tmpDir, { recursive: true, force: true });
});
it('dry-run shows all new resource types', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
secrets:
- name: creds
data:
TOKEN: abc
users:
- email: alice@test.com
password: password123
groups:
- name: dev-team
members: []
projects:
- name: my-proj
description: A project
rbacBindings:
- name: admins
subjects:
- kind: User
name: admin@test.com
roleBindings:
- role: edit
resource: "*"
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath, '--dry-run'], { from: 'user' });
expect(client.post).not.toHaveBeenCalled();
const text = output.join('\n');
expect(text).toContain('Dry run');
expect(text).toContain('1 secret(s)');
expect(text).toContain('1 user(s)');
expect(text).toContain('1 group(s)');
expect(text).toContain('1 project(s)');
expect(text).toContain('1 rbacBinding(s)');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies resources in correct order', async () => {
const callOrder: string[] = [];
vi.mocked(client.post).mockImplementation(async (url: string) => {
callOrder.push(url);
return { id: 'new-id', name: 'test' };
});
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbacBindings:
- name: admins
subjects:
- kind: User
name: admin@test.com
roleBindings:
- role: edit
resource: "*"
users:
- email: admin@test.com
password: password123
secrets:
- name: creds
data:
KEY: val
groups:
- name: dev-team
servers:
- name: my-server
transport: STDIO
projects:
- name: my-proj
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
// Apply order: secrets → servers → users → groups → projects → templates → rbacBindings
expect(callOrder[0]).toBe('/api/v1/secrets');
expect(callOrder[1]).toBe('/api/v1/servers');
expect(callOrder[2]).toBe('/api/v1/users');
expect(callOrder[3]).toBe('/api/v1/groups');
expect(callOrder[4]).toBe('/api/v1/projects');
expect(callOrder[5]).toBe('/api/v1/rbac');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies rbac with operation bindings', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbac:
- name: ops-team
subjects:
- kind: Group
name: ops
roleBindings:
- role: edit
resource: servers
- role: run
action: backup
- role: run
action: logs
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
name: 'ops-team',
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'run', action: 'backup' },
{ role: 'run', action: 'logs' },
],
}));
expect(output.join('\n')).toContain('Created rbacBinding: ops-team');
rmSync(tmpDir, { recursive: true, force: true });
});
it('applies rbac with name-scoped resource binding', async () => {
const configPath = join(tmpDir, 'config.yaml');
writeFileSync(configPath, `
rbac:
- name: ha-viewer
subjects:
- kind: User
name: alice@test.com
roleBindings:
- role: view
resource: servers
name: my-ha
`);
const cmd = createApplyCommand({ client, log });
await cmd.parseAsync([configPath], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', expect.objectContaining({
name: 'ha-viewer',
roleBindings: [
{ role: 'view', resource: 'servers', name: 'my-ha' },
],
}));
rmSync(tmpDir, { recursive: true, force: true });
});
});

View File

@@ -37,6 +37,8 @@ describe('login command', () => {
user: { email },
}),
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
});
await cmd.parseAsync([], { from: 'user' });
expect(output[0]).toContain('Logged in as alice@test.com');
@@ -58,6 +60,8 @@ describe('login command', () => {
log,
loginRequest: async () => { throw new Error('Invalid credentials'); },
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
});
await cmd.parseAsync([], { from: 'user' });
expect(output[0]).toContain('Login failed');
@@ -83,6 +87,8 @@ describe('login command', () => {
return { token: 'tok', user: { email } };
},
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
});
await cmd.parseAsync([], { from: 'user' });
expect(capturedUrl).toBe('http://custom:3100');
@@ -103,12 +109,74 @@ describe('login command', () => {
return { token: 'tok', user: { email } };
},
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
});
await cmd.parseAsync(['--mcpd-url', 'http://override:3100'], { from: 'user' });
expect(capturedUrl).toBe('http://override:3100');
});
});
describe('login bootstrap flow', () => {
it('bootstraps first admin when no users exist', async () => {
let bootstrapCalled = false;
const cmd = createLoginCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
prompt: {
input: async (msg) => {
if (msg.includes('Name')) return 'Admin User';
return 'admin@test.com';
},
password: async () => 'admin-pass',
},
log,
loginRequest: async () => ({ token: '', user: { email: '' } }),
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: false }),
bootstrapRequest: async (_url, email, _password) => {
bootstrapCalled = true;
return { token: 'admin-token', user: { email } };
},
});
await cmd.parseAsync([], { from: 'user' });
expect(bootstrapCalled).toBe(true);
expect(output.join('\n')).toContain('No users configured');
expect(output.join('\n')).toContain('admin@test.com');
expect(output.join('\n')).toContain('admin');
const creds = loadCredentials({ configDir: tempDir });
expect(creds).not.toBeNull();
expect(creds!.token).toBe('admin-token');
expect(creds!.user).toBe('admin@test.com');
});
it('falls back to normal login when users exist', async () => {
let loginCalled = false;
const cmd = createLoginCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
prompt: {
input: async () => 'alice@test.com',
password: async () => 'secret',
},
log,
loginRequest: async (_url, email) => {
loginCalled = true;
return { token: 'session-tok', user: { email } };
},
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => { throw new Error('Should not be called'); },
});
await cmd.parseAsync([], { from: 'user' });
expect(loginCalled).toBe(true);
expect(output.join('\n')).not.toContain('No users configured');
});
});
describe('logout command', () => {
it('removes credentials on logout', async () => {
saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice' }, { configDir: tempDir });
@@ -120,6 +188,8 @@ describe('logout command', () => {
log,
loginRequest: async () => ({ token: '', user: { email: '' } }),
logoutRequest: async () => { logoutCalled = true; },
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
});
await cmd.parseAsync([], { from: 'user' });
expect(output[0]).toContain('Logged out successfully');
@@ -137,6 +207,8 @@ describe('logout command', () => {
log,
loginRequest: async () => ({ token: '', user: { email: '' } }),
logoutRequest: async () => {},
statusRequest: async () => ({ hasUsers: true }),
bootstrapRequest: async () => ({ token: '', user: { email: '' } }),
});
await cmd.parseAsync([], { from: 'user' });
expect(output[0]).toContain('Not logged in');

View File

@@ -1,56 +1,67 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createClaudeCommand } from '../../src/commands/claude.js';
import { createConfigCommand } from '../../src/commands/config.js';
import type { ApiClient } from '../../src/api-client.js';
import { saveCredentials, loadCredentials } from '../../src/auth/index.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => ({
mcpServers: {
'slack--default': { command: 'npx', args: ['-y', '@anthropic/slack-mcp'], env: { WORKSPACE: 'test' } },
'github--default': { command: 'npx', args: ['-y', '@anthropic/github-mcp'] },
},
})),
post: vi.fn(async () => ({})),
get: vi.fn(async () => ({})),
post: vi.fn(async () => ({ token: 'impersonated-tok', user: { email: 'other@test.com' } })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('claude command', () => {
describe('config claude', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
let tmpDir: string;
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
const log = (...args: string[]) => output.push(args.join(' '));
beforeEach(() => {
client = mockClient();
output = [];
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-claude-'));
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-claude-'));
});
describe('generate', () => {
it('generates .mcp.json from project config', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createClaudeCommand({ client, log });
await cmd.parseAsync(['generate', 'proj-1', '-o', outPath], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/mcp-config');
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['slack--default']).toBeDefined();
expect(output.join('\n')).toContain('2 server(s)');
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('generates .mcp.json with mcpctl mcp bridge entry', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--project', 'homeautomation', '-o', outPath], { from: 'user' });
// No API call should be made
expect(client.get).not.toHaveBeenCalled();
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['homeautomation']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'homeautomation'],
});
expect(output.join('\n')).toContain('1 server(s)');
});
it('prints to stdout with --stdout', async () => {
const cmd = createClaudeCommand({ client, log });
await cmd.parseAsync(['generate', 'proj-1', '--stdout'], { from: 'user' });
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--project', 'myproj', '--stdout'], { from: 'user' });
expect(output[0]).toContain('mcpServers');
rmSync(tmpDir, { recursive: true, force: true });
const parsed = JSON.parse(output[0]);
expect(parsed.mcpServers['myproj']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'myproj'],
});
});
it('merges with existing .mcp.json', async () => {
@@ -59,100 +70,123 @@ describe('claude command', () => {
mcpServers: { 'existing--server': { command: 'echo', args: [] } },
}));
const cmd = createClaudeCommand({ client, log });
await cmd.parseAsync(['generate', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--project', 'proj-1', '-o', outPath, '--merge'], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['existing--server']).toBeDefined();
expect(written.mcpServers['slack--default']).toBeDefined();
expect(output.join('\n')).toContain('3 server(s)');
expect(written.mcpServers['proj-1']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'proj-1'],
});
expect(output.join('\n')).toContain('2 server(s)');
});
rmSync(tmpDir, { recursive: true, force: true });
it('backward compat: claude-generate still works', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude-generate', '--project', 'proj-1', '-o', outPath], { from: 'user' });
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(written.mcpServers['proj-1']).toEqual({
command: 'mcpctl',
args: ['mcp', '-p', 'proj-1'],
});
});
describe('show', () => {
it('shows servers in .mcp.json', () => {
const filePath = join(tmpDir, '.mcp.json');
writeFileSync(filePath, JSON.stringify({
mcpServers: {
'slack': { command: 'npx', args: ['-y', '@anthropic/slack-mcp'], env: { TOKEN: 'x' } },
},
}));
it('uses project name as the server key', async () => {
const outPath = join(tmpDir, '.mcp.json');
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['claude', '--project', 'my-fancy-project', '-o', outPath], { from: 'user' });
const cmd = createClaudeCommand({ client, log });
cmd.parseAsync(['show', '-p', filePath], { from: 'user' });
expect(output.join('\n')).toContain('slack');
expect(output.join('\n')).toContain('npx -y @anthropic/slack-mcp');
expect(output.join('\n')).toContain('TOKEN');
rmSync(tmpDir, { recursive: true, force: true });
});
it('handles missing file', () => {
const cmd = createClaudeCommand({ client, log });
cmd.parseAsync(['show', '-p', join(tmpDir, 'nonexistent.json')], { from: 'user' });
expect(output.join('\n')).toContain('No .mcp.json found');
rmSync(tmpDir, { recursive: true, force: true });
});
});
describe('add', () => {
it('adds a server entry', () => {
const filePath = join(tmpDir, '.mcp.json');
const cmd = createClaudeCommand({ client, log });
cmd.parseAsync(['add', 'my-server', '-c', 'npx', '-a', '-y', 'my-pkg', '-p', filePath], { from: 'user' });
const written = JSON.parse(readFileSync(filePath, 'utf-8'));
expect(written.mcpServers['my-server']).toEqual({
command: 'npx',
args: ['-y', 'my-pkg'],
});
rmSync(tmpDir, { recursive: true, force: true });
});
it('adds server with env vars', () => {
const filePath = join(tmpDir, '.mcp.json');
const cmd = createClaudeCommand({ client, log });
cmd.parseAsync(['add', 'my-server', '-c', 'node', '-e', 'KEY=val', 'SECRET=abc', '-p', filePath], { from: 'user' });
const written = JSON.parse(readFileSync(filePath, 'utf-8'));
expect(written.mcpServers['my-server'].env).toEqual({ KEY: 'val', SECRET: 'abc' });
rmSync(tmpDir, { recursive: true, force: true });
});
});
describe('remove', () => {
it('removes a server entry', () => {
const filePath = join(tmpDir, '.mcp.json');
writeFileSync(filePath, JSON.stringify({
mcpServers: { 'slack': { command: 'npx', args: [] }, 'github': { command: 'npx', args: [] } },
}));
const cmd = createClaudeCommand({ client, log });
cmd.parseAsync(['remove', 'slack', '-p', filePath], { from: 'user' });
const written = JSON.parse(readFileSync(filePath, 'utf-8'));
expect(written.mcpServers['slack']).toBeUndefined();
expect(written.mcpServers['github']).toBeDefined();
expect(output.join('\n')).toContain("Removed 'slack'");
rmSync(tmpDir, { recursive: true, force: true });
});
it('reports when server not found', () => {
const filePath = join(tmpDir, '.mcp.json');
writeFileSync(filePath, JSON.stringify({ mcpServers: {} }));
const cmd = createClaudeCommand({ client, log });
cmd.parseAsync(['remove', 'nonexistent', '-p', filePath], { from: 'user' });
expect(output.join('\n')).toContain('not found');
rmSync(tmpDir, { recursive: true, force: true });
});
const written = JSON.parse(readFileSync(outPath, 'utf-8'));
expect(Object.keys(written.mcpServers)).toEqual(['my-fancy-project']);
});
});
describe('config impersonate', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
let tmpDir: string;
const log = (...args: string[]) => output.push(args.join(' '));
beforeEach(() => {
client = mockClient();
output = [];
tmpDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-impersonate-'));
});
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true });
});
it('impersonates a user and saves backup', async () => {
saveCredentials({ token: 'admin-tok', mcpdUrl: 'http://localhost:3100', user: 'admin@test.com' }, { configDir: tmpDir });
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['impersonate', 'other@test.com'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/auth/impersonate', { email: 'other@test.com' });
expect(output.join('\n')).toContain('Impersonating other@test.com');
const creds = loadCredentials({ configDir: tmpDir });
expect(creds!.user).toBe('other@test.com');
expect(creds!.token).toBe('impersonated-tok');
// Backup exists
const backup = JSON.parse(readFileSync(join(tmpDir, 'credentials-backup'), 'utf-8'));
expect(backup.user).toBe('admin@test.com');
});
it('quits impersonation and restores backup', async () => {
// Set up current (impersonated) credentials
saveCredentials({ token: 'impersonated-tok', mcpdUrl: 'http://localhost:3100', user: 'other@test.com' }, { configDir: tmpDir });
// Set up backup (original) credentials
writeFileSync(join(tmpDir, 'credentials-backup'), JSON.stringify({
token: 'admin-tok', mcpdUrl: 'http://localhost:3100', user: 'admin@test.com',
}));
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['impersonate', '--quit'], { from: 'user' });
expect(output.join('\n')).toContain('Returned to admin@test.com');
const creds = loadCredentials({ configDir: tmpDir });
expect(creds!.user).toBe('admin@test.com');
expect(creds!.token).toBe('admin-tok');
});
it('errors when not logged in', async () => {
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['impersonate', 'other@test.com'], { from: 'user' });
expect(output.join('\n')).toContain('Not logged in');
});
it('errors when quitting with no backup', async () => {
const cmd = createConfigCommand(
{ configDeps: { configDir: tmpDir }, log },
{ client, credentialsDeps: { configDir: tmpDir }, log },
);
await cmd.parseAsync(['impersonate', '--quit'], { from: 'user' });
expect(output.join('\n')).toContain('No impersonation session to quit');
});
});

View File

@@ -0,0 +1,293 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createConfigSetupCommand } from '../../src/commands/config-setup.js';
import type { ConfigSetupDeps, ConfigSetupPrompt } from '../../src/commands/config-setup.js';
import type { SecretStore } from '@mcpctl/shared';
import { mkdtempSync, rmSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
let tempDir: string;
let logs: string[];
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-setup-test-'));
logs = [];
});
function cleanup() {
rmSync(tempDir, { recursive: true, force: true });
}
function mockSecretStore(secrets: Record<string, string> = {}): SecretStore {
const store: Record<string, string> = { ...secrets };
return {
get: vi.fn(async (key: string) => store[key] ?? null),
set: vi.fn(async (key: string, value: string) => { store[key] = value; }),
delete: vi.fn(async () => true),
backend: () => 'mock',
};
}
function mockPrompt(answers: unknown[]): ConfigSetupPrompt {
let callIndex = 0;
return {
select: vi.fn(async () => answers[callIndex++]),
input: vi.fn(async () => answers[callIndex++] as string),
password: vi.fn(async () => answers[callIndex++] as string),
confirm: vi.fn(async () => answers[callIndex++] as boolean),
};
}
function buildDeps(overrides: {
secrets?: Record<string, string>;
answers?: unknown[];
fetchModels?: ConfigSetupDeps['fetchModels'];
whichBinary?: ConfigSetupDeps['whichBinary'];
} = {}): ConfigSetupDeps {
return {
configDeps: { configDir: tempDir },
secretStore: mockSecretStore(overrides.secrets),
log: (...args: string[]) => logs.push(args.join(' ')),
prompt: mockPrompt(overrides.answers ?? []),
fetchModels: overrides.fetchModels ?? vi.fn(async () => []),
whichBinary: overrides.whichBinary ?? vi.fn(async () => '/usr/bin/gemini'),
};
}
function readConfig(): Record<string, unknown> {
const raw = readFileSync(join(tempDir, 'config.json'), 'utf-8');
return JSON.parse(raw) as Record<string, unknown>;
}
async function runSetup(deps: ConfigSetupDeps): Promise<void> {
const cmd = createConfigSetupCommand(deps);
await cmd.parseAsync([], { from: 'user' });
}
describe('config setup wizard', () => {
describe('provider: none', () => {
it('disables LLM and saves config', async () => {
const deps = buildDeps({ answers: ['none'] });
await runSetup(deps);
const config = readConfig();
expect(config.llm).toEqual({ provider: 'none' });
expect(logs.some((l) => l.includes('LLM disabled'))).toBe(true);
cleanup();
});
});
describe('provider: gemini-cli', () => {
it('auto-detects binary path and saves config', async () => {
// Answers: select provider, select model (no binary prompt — auto-detected)
const deps = buildDeps({
answers: ['gemini-cli', 'gemini-2.5-flash'],
whichBinary: vi.fn(async () => '/home/user/.npm-global/bin/gemini'),
});
await runSetup(deps);
const config = readConfig();
const llm = config.llm as Record<string, unknown>;
expect(llm.provider).toBe('gemini-cli');
expect(llm.model).toBe('gemini-2.5-flash');
expect(llm.binaryPath).toBe('/home/user/.npm-global/bin/gemini');
expect(logs.some((l) => l.includes('Found gemini at'))).toBe(true);
cleanup();
});
it('prompts for manual path when binary not found', async () => {
// Answers: select provider, select model, enter manual path
const deps = buildDeps({
answers: ['gemini-cli', 'gemini-2.5-flash', '/opt/gemini'],
whichBinary: vi.fn(async () => null),
});
await runSetup(deps);
const config = readConfig();
const llm = config.llm as Record<string, unknown>;
expect(llm.binaryPath).toBe('/opt/gemini');
expect(logs.some((l) => l.includes('not found'))).toBe(true);
cleanup();
});
it('saves gemini-cli with custom model', async () => {
// Answers: select provider, select custom, enter model name
const deps = buildDeps({
answers: ['gemini-cli', '__custom__', 'gemini-3.0-flash'],
whichBinary: vi.fn(async () => '/usr/bin/gemini'),
});
await runSetup(deps);
const config = readConfig();
const llm = config.llm as Record<string, unknown>;
expect(llm.model).toBe('gemini-3.0-flash');
cleanup();
});
});
describe('provider: ollama', () => {
it('fetches models and allows selection', async () => {
const fetchModels = vi.fn(async () => ['llama3.2', 'codellama', 'mistral']);
// Answers: select provider, enter URL, select model
const deps = buildDeps({
answers: ['ollama', 'http://localhost:11434', 'codellama'],
fetchModels,
});
await runSetup(deps);
expect(fetchModels).toHaveBeenCalledWith('http://localhost:11434', '/api/tags');
const config = readConfig();
const llm = config.llm as Record<string, unknown>;
expect(llm.provider).toBe('ollama');
expect(llm.model).toBe('codellama');
expect(llm.url).toBe('http://localhost:11434');
cleanup();
});
it('falls back to manual input when fetch fails', async () => {
const fetchModels = vi.fn(async () => []);
// Answers: select provider, enter URL, enter model manually
const deps = buildDeps({
answers: ['ollama', 'http://localhost:11434', 'llama3.2'],
fetchModels,
});
await runSetup(deps);
const config = readConfig();
expect((config.llm as Record<string, unknown>).model).toBe('llama3.2');
cleanup();
});
});
describe('provider: anthropic', () => {
it('prompts for API key and saves to secret store', async () => {
// Answers: select provider, enter API key, select model
const deps = buildDeps({
answers: ['anthropic', 'sk-ant-new-key', 'claude-haiku-3-5-20241022'],
});
await runSetup(deps);
expect(deps.secretStore.set).toHaveBeenCalledWith('anthropic-api-key', 'sk-ant-new-key');
const config = readConfig();
const llm = config.llm as Record<string, unknown>;
expect(llm.provider).toBe('anthropic');
expect(llm.model).toBe('claude-haiku-3-5-20241022');
// API key should NOT be in config file
expect(llm).not.toHaveProperty('apiKey');
cleanup();
});
it('shows existing key masked and allows keeping it', async () => {
// Answers: select provider, confirm change=false, select model
const deps = buildDeps({
secrets: { 'anthropic-api-key': 'sk-ant-existing-key-1234' },
answers: ['anthropic', false, 'claude-sonnet-4-20250514'],
});
await runSetup(deps);
// Should NOT have called set (kept existing key)
expect(deps.secretStore.set).not.toHaveBeenCalled();
const config = readConfig();
expect((config.llm as Record<string, unknown>).model).toBe('claude-sonnet-4-20250514');
cleanup();
});
it('allows replacing existing key', async () => {
// Answers: select provider, confirm change=true, enter new key, select model
const deps = buildDeps({
secrets: { 'anthropic-api-key': 'sk-ant-old' },
answers: ['anthropic', true, 'sk-ant-new', 'claude-haiku-3-5-20241022'],
});
await runSetup(deps);
expect(deps.secretStore.set).toHaveBeenCalledWith('anthropic-api-key', 'sk-ant-new');
cleanup();
});
});
describe('provider: vllm', () => {
it('fetches models from vLLM and allows selection', async () => {
const fetchModels = vi.fn(async () => ['my-model', 'llama-70b']);
// Answers: select provider, enter URL, select model
const deps = buildDeps({
answers: ['vllm', 'http://gpu:8000', 'llama-70b'],
fetchModels,
});
await runSetup(deps);
expect(fetchModels).toHaveBeenCalledWith('http://gpu:8000', '/v1/models');
const config = readConfig();
const llm = config.llm as Record<string, unknown>;
expect(llm.provider).toBe('vllm');
expect(llm.url).toBe('http://gpu:8000');
expect(llm.model).toBe('llama-70b');
cleanup();
});
});
describe('provider: openai', () => {
it('prompts for key, model, and optional custom endpoint', async () => {
// Answers: select provider, enter key, enter model, confirm custom URL=true, enter URL
const deps = buildDeps({
answers: ['openai', 'sk-openai-key', 'gpt-4o', true, 'https://custom.api.com'],
});
await runSetup(deps);
expect(deps.secretStore.set).toHaveBeenCalledWith('openai-api-key', 'sk-openai-key');
const config = readConfig();
const llm = config.llm as Record<string, unknown>;
expect(llm.provider).toBe('openai');
expect(llm.model).toBe('gpt-4o');
expect(llm.url).toBe('https://custom.api.com');
cleanup();
});
it('skips custom URL when not requested', async () => {
// Answers: select provider, enter key, enter model, confirm custom URL=false
const deps = buildDeps({
answers: ['openai', 'sk-openai-key', 'gpt-4o-mini', false],
});
await runSetup(deps);
const config = readConfig();
const llm = config.llm as Record<string, unknown>;
expect(llm.url).toBeUndefined();
cleanup();
});
});
describe('provider: deepseek', () => {
it('prompts for key and model', async () => {
// Answers: select provider, enter key, select model
const deps = buildDeps({
answers: ['deepseek', 'sk-ds-key', 'deepseek-chat'],
});
await runSetup(deps);
expect(deps.secretStore.set).toHaveBeenCalledWith('deepseek-api-key', 'sk-ds-key');
const config = readConfig();
const llm = config.llm as Record<string, unknown>;
expect(llm.provider).toBe('deepseek');
expect(llm.model).toBe('deepseek-chat');
cleanup();
});
});
describe('output messages', () => {
it('shows restart instruction', async () => {
const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash'] });
await runSetup(deps);
expect(logs.some((l) => l.includes('systemctl --user restart mcplocal'))).toBe(true);
cleanup();
});
it('shows configured provider and model', async () => {
const deps = buildDeps({ answers: ['gemini-cli', 'gemini-2.5-flash'] });
await runSetup(deps);
expect(logs.some((l) => l.includes('gemini-cli') && l.includes('gemini-2.5-flash'))).toBe(true);
cleanup();
});
});
});

View File

@@ -0,0 +1,450 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createCreateCommand } from '../../src/commands/create.js';
import { type ApiClient, ApiError } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('create command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
describe('create server', () => {
it('creates a server with minimal flags', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['server', 'my-server'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
name: 'my-server',
transport: 'STDIO',
replicas: 1,
}));
expect(output.join('\n')).toContain("server 'test' created");
});
it('creates a server with all flags', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'server', 'ha-mcp',
'-d', 'Home Assistant MCP',
'--docker-image', 'ghcr.io/ha-mcp:latest',
'--transport', 'STREAMABLE_HTTP',
'--external-url', 'http://localhost:8086/mcp',
'--container-port', '3000',
'--replicas', '2',
'--command', 'python',
'--command', '-c',
'--command', 'print("hello")',
'--env', 'API_KEY=secretRef:creds:API_KEY',
'--env', 'BASE_URL=http://localhost',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', {
name: 'ha-mcp',
description: 'Home Assistant MCP',
dockerImage: 'ghcr.io/ha-mcp:latest',
transport: 'STREAMABLE_HTTP',
externalUrl: 'http://localhost:8086/mcp',
containerPort: 3000,
replicas: 2,
command: ['python', '-c', 'print("hello")'],
env: [
{ name: 'API_KEY', valueFrom: { secretRef: { name: 'creds', key: 'API_KEY' } } },
{ name: 'BASE_URL', value: 'http://localhost' },
],
});
});
it('defaults transport to STDIO', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
transport: 'STDIO',
}));
});
it('strips null values from template when using --from-template', async () => {
vi.mocked(client.get).mockResolvedValueOnce([{
id: 'tpl-1',
name: 'grafana',
version: '1.0.0',
description: 'Grafana MCP',
packageName: '@leval/mcp-grafana',
dockerImage: null,
transport: 'STDIO',
repositoryUrl: 'https://github.com/test',
externalUrl: null,
command: null,
containerPort: null,
replicas: 1,
env: [{ name: 'TOKEN', required: true, description: 'A token' }],
healthCheck: { tool: 'test', arguments: {} },
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
}] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'server', 'my-grafana', '--from-template=grafana',
'--env', 'TOKEN=secretRef:creds:TOKEN',
], { from: 'user' });
const call = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
// null fields from template should NOT be in the body
expect(call).not.toHaveProperty('dockerImage');
expect(call).not.toHaveProperty('externalUrl');
expect(call).not.toHaveProperty('command');
expect(call).not.toHaveProperty('containerPort');
// non-null fields should be present
expect(call.packageName).toBe('@leval/mcp-grafana');
expect(call.healthCheck).toEqual({ tool: 'test', arguments: {} });
expect(call.templateName).toBe('grafana');
});
it('throws on 409 without --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists: my-server"}'));
const cmd = createCreateCommand({ client, log });
await expect(cmd.parseAsync(['server', 'my-server'], { from: 'user' })).rejects.toThrow('API error 409');
});
it('updates existing server on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Server already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'srv-1', name: 'my-server' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['server', 'my-server', '--force'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({
transport: 'STDIO',
}));
expect(output.join('\n')).toContain("server 'my-server' updated");
});
});
describe('create secret', () => {
it('creates a secret with --data flags', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'secret', 'ha-creds',
'--data', 'TOKEN=abc123',
'--data', 'URL=https://ha.local',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
name: 'ha-creds',
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
});
expect(output.join('\n')).toContain("secret 'test' created");
});
it('creates a secret with empty data', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['secret', 'empty-secret'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/secrets', {
name: 'empty-secret',
data: {},
});
});
it('throws on 409 without --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists: my-creds"}'));
const cmd = createCreateCommand({ client, log });
await expect(cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val'], { from: 'user' })).rejects.toThrow('API error 409');
});
it('updates existing secret on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Secret already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'sec-1', name: 'my-creds' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['secret', 'my-creds', '--data', 'KEY=val', '--force'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/secrets/sec-1', { data: { KEY: 'val' } });
expect(output.join('\n')).toContain("secret 'my-creds' updated");
});
});
describe('create project', () => {
it('creates a project', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'my-project', '-d', 'A test project'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'my-project',
description: 'A test project',
proxyMode: 'direct',
});
expect(output.join('\n')).toContain("project 'test' created");
});
it('creates a project with no description', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'minimal'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'minimal',
description: '',
proxyMode: 'direct',
});
});
it('updates existing project on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Project already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'proj-1', name: 'my-proj' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'my-proj', '-d', 'updated', '--force'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1', { description: 'updated', proxyMode: 'direct' });
expect(output.join('\n')).toContain("project 'my-proj' updated");
});
});
describe('create user', () => {
it('creates a user with password and name', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'usr-1', email: 'alice@test.com' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'user', 'alice@test.com',
'--password', 'secret123',
'--name', 'Alice',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/users', {
email: 'alice@test.com',
password: 'secret123',
name: 'Alice',
});
expect(output.join('\n')).toContain("user 'alice@test.com' created");
});
it('does not send role field (RBAC is the auth mechanism)', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'usr-1', email: 'admin@test.com' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'user', 'admin@test.com',
'--password', 'pass123',
], { from: 'user' });
const callBody = vi.mocked(client.post).mock.calls[0]![1] as Record<string, unknown>;
expect(callBody).not.toHaveProperty('role');
});
it('requires --password', async () => {
const cmd = createCreateCommand({ client, log });
await expect(cmd.parseAsync(['user', 'alice@test.com'], { from: 'user' })).rejects.toThrow('--password is required');
});
it('throws on 409 without --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"User already exists"}'));
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['user', 'alice@test.com', '--password', 'pass'], { from: 'user' }),
).rejects.toThrow('API error 409');
});
it('updates existing user on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"User already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'usr-1', email: 'alice@test.com' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'user', 'alice@test.com', '--password', 'newpass', '--name', 'Alice New', '--force',
], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/users/usr-1', {
password: 'newpass',
name: 'Alice New',
});
expect(output.join('\n')).toContain("user 'alice@test.com' updated");
});
});
describe('create group', () => {
it('creates a group with members', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'grp-1', name: 'dev-team' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'group', 'dev-team',
'--description', 'Development team',
'--member', 'alice@test.com',
'--member', 'bob@test.com',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/groups', {
name: 'dev-team',
description: 'Development team',
members: ['alice@test.com', 'bob@test.com'],
});
expect(output.join('\n')).toContain("group 'dev-team' created");
});
it('creates a group with no members', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'grp-1', name: 'empty-group' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['group', 'empty-group'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/groups', {
name: 'empty-group',
members: [],
});
});
it('throws on 409 without --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Group already exists"}'));
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['group', 'dev-team'], { from: 'user' }),
).rejects.toThrow('API error 409');
});
it('updates existing group on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"Group already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'grp-1', name: 'dev-team' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'group', 'dev-team', '--member', 'new@test.com', '--force',
], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/groups/grp-1', {
members: ['new@test.com'],
});
expect(output.join('\n')).toContain("group 'dev-team' updated");
});
});
describe('create rbac', () => {
it('creates an RBAC definition with subjects and bindings', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'developers' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'rbac', 'developers',
'--subject', 'User:alice@test.com',
'--subject', 'Group:dev-team',
'--binding', 'edit:servers',
'--binding', 'view:instances',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
name: 'developers',
subjects: [
{ kind: 'User', name: 'alice@test.com' },
{ kind: 'Group', name: 'dev-team' },
],
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'view', resource: 'instances' },
],
});
expect(output.join('\n')).toContain("rbac 'developers' created");
});
it('creates an RBAC definition with wildcard resource', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'admins' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'rbac', 'admins',
'--subject', 'User:admin@test.com',
'--binding', 'edit:*',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
name: 'admins',
subjects: [{ kind: 'User', name: 'admin@test.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
});
});
it('creates an RBAC definition with empty subjects and bindings', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'empty' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['rbac', 'empty'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
name: 'empty',
subjects: [],
roleBindings: [],
});
});
it('throws on invalid subject format', async () => {
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['rbac', 'bad', '--subject', 'no-colon'], { from: 'user' }),
).rejects.toThrow('Invalid subject format');
});
it('throws on invalid binding format', async () => {
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['rbac', 'bad', '--binding', 'no-colon'], { from: 'user' }),
).rejects.toThrow('Invalid binding format');
});
it('throws on 409 without --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"RBAC already exists"}'));
const cmd = createCreateCommand({ client, log });
await expect(
cmd.parseAsync(['rbac', 'developers', '--subject', 'User:a@b.com', '--binding', 'edit:servers'], { from: 'user' }),
).rejects.toThrow('API error 409');
});
it('updates existing RBAC on 409 with --force', async () => {
vi.mocked(client.post).mockRejectedValueOnce(new ApiError(409, '{"error":"RBAC already exists"}'));
vi.mocked(client.get).mockResolvedValueOnce([{ id: 'rbac-1', name: 'developers' }] as never);
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'rbac', 'developers',
'--subject', 'User:new@test.com',
'--binding', 'edit:*',
'--force',
], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/rbac/rbac-1', {
subjects: [{ kind: 'User', name: 'new@test.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
});
expect(output.join('\n')).toContain("rbac 'developers' updated");
});
it('creates an RBAC definition with operation bindings', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'ops' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'rbac', 'ops',
'--subject', 'Group:ops-team',
'--binding', 'edit:servers',
'--operation', 'logs',
'--operation', 'backup',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
name: 'ops',
subjects: [{ kind: 'Group', name: 'ops-team' }],
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'run', action: 'logs' },
{ role: 'run', action: 'backup' },
],
});
expect(output.join('\n')).toContain("rbac 'ops' created");
});
it('creates an RBAC definition with name-scoped binding', async () => {
vi.mocked(client.post).mockResolvedValueOnce({ id: 'rbac-1', name: 'ha-viewer' });
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'rbac', 'ha-viewer',
'--subject', 'User:alice@test.com',
'--binding', 'view:servers:my-ha',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/rbac', {
name: 'ha-viewer',
subjects: [{ kind: 'User', name: 'alice@test.com' }],
roleBindings: [
{ role: 'view', resource: 'servers', name: 'my-ha' },
],
});
});
});
});

View File

@@ -1,42 +1,59 @@
import { describe, it, expect, vi } from 'vitest';
import { createDescribeCommand } from '../../src/commands/describe.js';
import type { DescribeCommandDeps } from '../../src/commands/describe.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
function makeDeps(item: unknown = {}): DescribeCommandDeps & { output: string[] } {
const output: string[] = [];
return {
output,
client: mockClient(),
fetchResource: vi.fn(async () => item),
log: (...args: string[]) => output.push(args.join(' ')),
};
}
describe('describe command', () => {
it('shows detailed server info', async () => {
it('shows detailed server info with sections', async () => {
const deps = makeDeps({
id: 'srv-1',
name: 'slack',
transport: 'STDIO',
packageName: '@slack/mcp',
dockerImage: null,
envTemplate: [],
env: [],
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
const text = deps.output.join('\n');
expect(text).toContain('--- Server ---');
expect(text).toContain('name: slack');
expect(text).toContain('transport: STDIO');
expect(text).toContain('dockerImage: -');
expect(text).toContain('=== Server: slack ===');
expect(text).toContain('Name:');
expect(text).toContain('slack');
expect(text).toContain('Transport:');
expect(text).toContain('STDIO');
expect(text).toContain('Package:');
expect(text).toContain('@slack/mcp');
expect(text).toContain('Metadata:');
expect(text).toContain('ID:');
});
it('resolves resource aliases', async () => {
const deps = makeDeps({ id: 'p1' });
const deps = makeDeps({ id: 's1' });
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'prof', 'p1']);
expect(deps.fetchResource).toHaveBeenCalledWith('profiles', 'p1');
await cmd.parseAsync(['node', 'test', 'sec', 's1']);
expect(deps.fetchResource).toHaveBeenCalledWith('secrets', 's1');
});
it('outputs JSON format', async () => {
@@ -55,31 +72,625 @@ describe('describe command', () => {
expect(deps.output[0]).toContain('name: slack');
});
it('formats nested objects', async () => {
it('shows project detail', async () => {
const deps = makeDeps({
id: 'proj-1',
name: 'my-project',
description: 'A test project',
ownerId: 'user-1',
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'project', 'proj-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Project: my-project ===');
expect(text).toContain('A test project');
expect(text).toContain('user-1');
});
it('shows secret detail with masked values', async () => {
const deps = makeDeps({
id: 'sec-1',
name: 'ha-creds',
data: { TOKEN: 'abc123', URL: 'https://ha.local' },
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'secret', 'sec-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Secret: ha-creds ===');
expect(text).toContain('TOKEN');
expect(text).toContain('***');
expect(text).not.toContain('abc123');
expect(text).toContain('use --show-values to reveal');
});
it('shows secret detail with revealed values when --show-values', async () => {
const deps = makeDeps({
id: 'sec-1',
name: 'ha-creds',
data: { TOKEN: 'abc123' },
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'secret', 'sec-1', '--show-values']);
const text = deps.output.join('\n');
expect(text).toContain('abc123');
expect(text).not.toContain('***');
});
it('shows instance detail with container info', async () => {
const deps = makeDeps({
id: 'inst-1',
serverId: 'srv-1',
status: 'RUNNING',
containerId: 'abc123',
port: 3000,
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Instance: inst-1 ===');
expect(text).toContain('RUNNING');
expect(text).toContain('abc123');
});
it('resolves server name to instance for describe instance', async () => {
const deps = makeDeps({
id: 'inst-1',
serverId: 'srv-1',
server: { name: 'my-grafana' },
status: 'RUNNING',
containerId: 'abc123',
port: 3000,
});
// resolveNameOrId will throw (not a CUID, name won't match instances)
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never) // instances list (no name match)
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-grafana' }] as never) // servers list
.mockResolvedValueOnce([{ id: 'inst-1', status: 'RUNNING' }] as never); // instances for server
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'my-grafana']);
expect(deps.fetchResource).toHaveBeenCalledWith('instances', 'inst-1');
});
it('resolves server name and picks running instance over stopped', async () => {
const deps = makeDeps({
id: 'inst-2',
serverId: 'srv-1',
server: { name: 'my-ha' },
status: 'RUNNING',
containerId: 'def456',
});
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never) // instances list
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-ha' }] as never)
.mockResolvedValueOnce([
{ id: 'inst-1', status: 'ERROR' },
{ id: 'inst-2', status: 'RUNNING' },
] as never);
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'my-ha']);
expect(deps.fetchResource).toHaveBeenCalledWith('instances', 'inst-2');
});
it('throws when no instances found for server name', async () => {
const deps = makeDeps();
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never) // instances list
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-server' }] as never)
.mockResolvedValueOnce([] as never); // no instances
const cmd = createDescribeCommand(deps);
await expect(cmd.parseAsync(['node', 'test', 'instance', 'my-server'])).rejects.toThrow(
/No instances found/,
);
});
it('shows instance with server name in header', async () => {
const deps = makeDeps({
id: 'inst-1',
serverId: 'srv-1',
server: { name: 'my-grafana' },
status: 'RUNNING',
containerId: 'abc123',
port: 3000,
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Instance: my-grafana ===');
});
it('shows instance health and events', async () => {
const deps = makeDeps({
id: 'inst-1',
serverId: 'srv-1',
server: { name: 'my-grafana' },
status: 'RUNNING',
containerId: 'abc123',
healthStatus: 'healthy',
lastHealthCheck: '2025-01-15T10:30:00Z',
events: [
{ timestamp: '2025-01-15T10:30:00Z', type: 'Normal', message: 'Health check passed (45ms)' },
],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'instance', 'inst-1']);
const text = deps.output.join('\n');
expect(text).toContain('Health:');
expect(text).toContain('healthy');
expect(text).toContain('Events:');
expect(text).toContain('Health check passed');
});
it('shows server healthCheck section', async () => {
const deps = makeDeps({
id: 'srv-1',
name: 'slack',
metadata: { version: '1.0', nested: { deep: true } },
name: 'my-grafana',
transport: 'STDIO',
healthCheck: {
tool: 'list_datasources',
arguments: {},
intervalSeconds: 60,
timeoutSeconds: 10,
failureThreshold: 3,
},
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
const text = deps.output.join('\n');
expect(text).toContain('metadata:');
expect(text).toContain('version: 1.0');
expect(text).toContain('Health Check:');
expect(text).toContain('list_datasources');
expect(text).toContain('60s');
expect(text).toContain('Failure Threshold:');
});
it('formats arrays correctly', async () => {
it('shows template detail with healthCheck and usage', async () => {
const deps = makeDeps({
id: 'srv-1',
permissions: ['read', 'write'],
envTemplate: [],
id: 'tpl-1',
name: 'grafana',
transport: 'STDIO',
version: '1.0.0',
packageName: '@leval/mcp-grafana',
env: [
{ name: 'GRAFANA_URL', required: true, description: 'Grafana instance URL' },
],
healthCheck: {
tool: 'list_datasources',
arguments: {},
intervalSeconds: 60,
timeoutSeconds: 10,
failureThreshold: 3,
},
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'server', 'srv-1']);
await cmd.parseAsync(['node', 'test', 'template', 'tpl-1']);
const text = deps.output.join('\n');
expect(text).toContain('permissions: read, write');
expect(text).toContain('envTemplate: []');
expect(text).toContain('=== Template: grafana ===');
expect(text).toContain('@leval/mcp-grafana');
expect(text).toContain('GRAFANA_URL');
expect(text).toContain('Health Check:');
expect(text).toContain('list_datasources');
expect(text).toContain('mcpctl create server my-grafana --from-template=grafana');
});
it('shows user detail (no Role field — RBAC is the auth mechanism)', async () => {
const deps = makeDeps({
id: 'usr-1',
email: 'alice@test.com',
name: 'Alice Smith',
provider: null,
createdAt: '2025-01-01',
updatedAt: '2025-01-15',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('users', 'usr-1');
const text = deps.output.join('\n');
expect(text).toContain('=== User: alice@test.com ===');
expect(text).toContain('Email:');
expect(text).toContain('alice@test.com');
expect(text).toContain('Name:');
expect(text).toContain('Alice Smith');
expect(text).not.toContain('Role:');
expect(text).toContain('Provider:');
expect(text).toContain('local');
expect(text).toContain('ID:');
expect(text).toContain('usr-1');
});
it('shows user with no name as dash', async () => {
const deps = makeDeps({
id: 'usr-2',
email: 'bob@test.com',
name: null,
provider: 'oidc',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'user', 'usr-2']);
const text = deps.output.join('\n');
expect(text).toContain('=== User: bob@test.com ===');
expect(text).toContain('Name:');
expect(text).toContain('-');
expect(text).not.toContain('Role:');
expect(text).toContain('oidc');
});
it('shows group detail with members', async () => {
const deps = makeDeps({
id: 'grp-1',
name: 'dev-team',
description: 'Development team',
members: [
{ user: { email: 'alice@test.com' }, createdAt: '2025-01-01' },
{ user: { email: 'bob@test.com' }, createdAt: '2025-01-02' },
],
createdAt: '2025-01-01',
updatedAt: '2025-01-15',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'group', 'grp-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('groups', 'grp-1');
const text = deps.output.join('\n');
expect(text).toContain('=== Group: dev-team ===');
expect(text).toContain('Name:');
expect(text).toContain('dev-team');
expect(text).toContain('Description:');
expect(text).toContain('Development team');
expect(text).toContain('Members:');
expect(text).toContain('EMAIL');
expect(text).toContain('ADDED');
expect(text).toContain('alice@test.com');
expect(text).toContain('bob@test.com');
expect(text).toContain('ID:');
expect(text).toContain('grp-1');
});
it('shows group detail with no members', async () => {
const deps = makeDeps({
id: 'grp-2',
name: 'empty-group',
description: '',
members: [],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'group', 'grp-2']);
const text = deps.output.join('\n');
expect(text).toContain('=== Group: empty-group ===');
// No Members section when empty
expect(text).not.toContain('EMAIL');
});
it('shows RBAC detail with subjects and bindings', async () => {
const deps = makeDeps({
id: 'rbac-1',
name: 'developers',
subjects: [
{ kind: 'User', name: 'alice@test.com' },
{ kind: 'Group', name: 'dev-team' },
],
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'view', resource: 'instances' },
{ role: 'view', resource: 'projects' },
],
createdAt: '2025-01-01',
updatedAt: '2025-01-15',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']);
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', 'rbac-1');
const text = deps.output.join('\n');
expect(text).toContain('=== RBAC: developers ===');
expect(text).toContain('Name:');
expect(text).toContain('developers');
// Subjects section
expect(text).toContain('Subjects:');
expect(text).toContain('KIND');
expect(text).toContain('NAME');
expect(text).toContain('User');
expect(text).toContain('alice@test.com');
expect(text).toContain('Group');
expect(text).toContain('dev-team');
// Role Bindings section
expect(text).toContain('Resource Bindings:');
expect(text).toContain('ROLE');
expect(text).toContain('RESOURCE');
expect(text).toContain('edit');
expect(text).toContain('servers');
expect(text).toContain('view');
expect(text).toContain('instances');
expect(text).toContain('projects');
expect(text).toContain('ID:');
expect(text).toContain('rbac-1');
});
it('shows RBAC detail with wildcard resource', async () => {
const deps = makeDeps({
id: 'rbac-2',
name: 'admins',
subjects: [{ kind: 'User', name: 'admin@test.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-2']);
const text = deps.output.join('\n');
expect(text).toContain('=== RBAC: admins ===');
expect(text).toContain('edit');
expect(text).toContain('*');
});
it('shows RBAC detail with empty subjects and bindings', async () => {
const deps = makeDeps({
id: 'rbac-3',
name: 'empty-rbac',
subjects: [],
roleBindings: [],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-3']);
const text = deps.output.join('\n');
expect(text).toContain('=== RBAC: empty-rbac ===');
// No Subjects or Role Bindings sections when empty
expect(text).not.toContain('KIND');
expect(text).not.toContain('ROLE');
expect(text).not.toContain('RESOURCE');
});
it('shows RBAC detail with mixed resource and operation bindings', async () => {
const deps = makeDeps({
id: 'rbac-1',
name: 'admin-access',
subjects: [{ kind: 'Group', name: 'admin' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'run', resource: 'projects' },
{ role: 'run', action: 'logs' },
{ role: 'run', action: 'backup' },
],
createdAt: '2025-01-01',
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']);
const text = deps.output.join('\n');
expect(text).toContain('Resource Bindings:');
expect(text).toContain('edit');
expect(text).toContain('*');
expect(text).toContain('run');
expect(text).toContain('projects');
expect(text).toContain('Operations:');
expect(text).toContain('ACTION');
expect(text).toContain('logs');
expect(text).toContain('backup');
});
it('shows RBAC detail with name-scoped resource binding', async () => {
const deps = makeDeps({
id: 'rbac-1',
name: 'ha-viewer',
subjects: [{ kind: 'User', name: 'alice@test.com' }],
roleBindings: [
{ role: 'view', resource: 'servers', name: 'my-ha' },
{ role: 'edit', resource: 'secrets' },
],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1']);
const text = deps.output.join('\n');
expect(text).toContain('Resource Bindings:');
expect(text).toContain('NAME');
expect(text).toContain('my-ha');
expect(text).toContain('view');
expect(text).toContain('servers');
});
it('shows user with direct RBAC permissions', async () => {
const deps = makeDeps({
id: 'usr-1',
email: 'alice@test.com',
name: 'Alice',
provider: null,
});
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never) // users list (resolveNameOrId)
.mockResolvedValueOnce([ // RBAC defs
{
name: 'dev-access',
subjects: [{ kind: 'User', name: 'alice@test.com' }],
roleBindings: [
{ role: 'edit', resource: 'servers' },
{ role: 'run', action: 'logs' },
],
},
] as never)
.mockResolvedValueOnce([] as never); // groups
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== User: alice@test.com ===');
expect(text).toContain('Access:');
expect(text).toContain('Direct (dev-access)');
expect(text).toContain('Resources:');
expect(text).toContain('edit');
expect(text).toContain('servers');
expect(text).toContain('Operations:');
expect(text).toContain('logs');
});
it('shows user with inherited group permissions', async () => {
const deps = makeDeps({
id: 'usr-1',
email: 'bob@test.com',
name: 'Bob',
provider: null,
});
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never) // users list
.mockResolvedValueOnce([ // RBAC defs
{
name: 'team-perms',
subjects: [{ kind: 'Group', name: 'dev-team' }],
roleBindings: [
{ role: 'view', resource: '*' },
{ role: 'run', action: 'backup' },
],
},
] as never)
.mockResolvedValueOnce([ // groups
{ name: 'dev-team', members: [{ user: { email: 'bob@test.com' } }] },
] as never);
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
const text = deps.output.join('\n');
expect(text).toContain('Groups:');
expect(text).toContain('dev-team');
expect(text).toContain('Access:');
expect(text).toContain('Inherited (dev-team)');
expect(text).toContain('view');
expect(text).toContain('*');
expect(text).toContain('backup');
});
it('shows user with no permissions', async () => {
const deps = makeDeps({
id: 'usr-1',
email: 'nobody@test.com',
name: null,
provider: null,
});
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([] as never);
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'user', 'usr-1']);
const text = deps.output.join('\n');
expect(text).toContain('Access: (none)');
});
it('shows group with RBAC permissions', async () => {
const deps = makeDeps({
id: 'grp-1',
name: 'admin',
description: 'Admin group',
members: [{ user: { email: 'alice@test.com' } }],
});
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never) // groups list (resolveNameOrId)
.mockResolvedValueOnce([ // RBAC defs
{
name: 'admin-access',
subjects: [{ kind: 'Group', name: 'admin' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'run', action: 'backup' },
{ role: 'run', action: 'restore' },
],
},
] as never);
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'group', 'grp-1']);
const text = deps.output.join('\n');
expect(text).toContain('=== Group: admin ===');
expect(text).toContain('Access:');
expect(text).toContain('Granted (admin-access)');
expect(text).toContain('edit');
expect(text).toContain('*');
expect(text).toContain('backup');
expect(text).toContain('restore');
});
it('shows group with name-scoped permissions', async () => {
const deps = makeDeps({
id: 'grp-1',
name: 'ha-team',
description: 'HA team',
members: [],
});
vi.mocked(deps.client.get)
.mockResolvedValueOnce([] as never)
.mockResolvedValueOnce([ // RBAC defs
{
name: 'ha-access',
subjects: [{ kind: 'Group', name: 'ha-team' }],
roleBindings: [
{ role: 'edit', resource: 'servers', name: 'my-ha' },
{ role: 'view', resource: 'secrets' },
],
},
] as never);
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'group', 'grp-1']);
const text = deps.output.join('\n');
expect(text).toContain('Access:');
expect(text).toContain('Granted (ha-access)');
expect(text).toContain('my-ha');
expect(text).toContain('NAME');
});
it('outputs user detail as JSON', async () => {
const deps = makeDeps({ id: 'usr-1', email: 'alice@test.com', name: 'Alice', role: 'ADMIN' });
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'user', 'usr-1', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '');
expect(parsed.email).toBe('alice@test.com');
expect(parsed.role).toBe('ADMIN');
});
it('outputs group detail as YAML', async () => {
const deps = makeDeps({ id: 'grp-1', name: 'dev-team', description: 'Devs' });
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'group', 'grp-1', '-o', 'yaml']);
expect(deps.output[0]).toContain('name: dev-team');
});
it('outputs rbac detail as JSON', async () => {
const deps = makeDeps({
id: 'rbac-1',
name: 'devs',
subjects: [{ kind: 'User', name: 'a@b.com' }],
roleBindings: [{ role: 'edit', resource: 'servers' }],
});
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac', 'rbac-1', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '');
expect(parsed.subjects).toHaveLength(1);
expect(parsed.roleBindings[0].role).toBe('edit');
});
});

View File

@@ -0,0 +1,153 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { readFileSync, writeFileSync } from 'node:fs';
import yaml from 'js-yaml';
import { createEditCommand } from '../../src/commands/edit.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => ({})),
post: vi.fn(async () => ({})),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('edit command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
it('fetches server, opens editor, applies changes on save', async () => {
// GET /api/v1/servers returns list for resolveNameOrId
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') {
return [{ id: 'srv-1', name: 'ha-mcp' }];
}
// GET /api/v1/servers/srv-1 returns full server
return {
id: 'srv-1',
name: 'ha-mcp',
description: 'Old desc',
transport: 'STDIO',
replicas: 1,
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
// Simulate user editing the file
const content = readFileSync(filePath, 'utf-8');
const modified = content
.replace('Old desc', 'New desc')
.replace('replicas: 1', 'replicas: 3');
writeFileSync(filePath, modified, 'utf-8');
},
});
await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/servers/srv-1', expect.objectContaining({
description: 'New desc',
replicas: 3,
}));
expect(output.join('\n')).toContain("server 'ha-mcp' updated");
});
it('detects no changes and skips PUT', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return {
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: () => {
// Don't modify the file
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain("unchanged");
});
it('handles empty file as cancel', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return { id: 'srv-1', name: 'test', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 };
});
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
writeFileSync(filePath, '', 'utf-8');
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('cancelled');
});
it('strips read-only fields from editor content', async () => {
vi.mocked(client.get).mockImplementation(async (path: string) => {
if (path === '/api/v1/servers') return [{ id: 'srv-1', name: 'test' }];
return {
id: 'srv-1', name: 'test', description: '', transport: 'STDIO',
createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1,
};
});
let editorContent = '';
const cmd = createEditCommand({
client,
log,
getEditor: () => 'vi',
openEditor: (filePath) => {
editorContent = readFileSync(filePath, 'utf-8');
},
});
await cmd.parseAsync(['server', 'test'], { from: 'user' });
// The editor content should NOT contain read-only fields
expect(editorContent).not.toContain('id:');
expect(editorContent).not.toContain('createdAt');
expect(editorContent).not.toContain('updatedAt');
expect(editorContent).not.toContain('version');
// But should contain editable fields
expect(editorContent).toContain('name:');
});
it('rejects edit instance with error message', async () => {
const cmd = createEditCommand({ client, log });
await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
expect(client.get).not.toHaveBeenCalled();
expect(client.put).not.toHaveBeenCalled();
expect(output.join('\n')).toContain('immutable');
});
});

View File

@@ -41,39 +41,41 @@ describe('get command', () => {
expect(deps.fetchResource).toHaveBeenCalledWith('servers', 'srv-1');
});
it('outputs JSON format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
it('outputs apply-compatible JSON format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01', updatedAt: '2025-01-01', version: 1 }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'json']);
const parsed = JSON.parse(deps.output[0] ?? '');
expect(parsed).toEqual({ id: 'srv-1', name: 'slack' });
// Wrapped in resource key, internal fields stripped
expect(parsed).toHaveProperty('servers');
expect(parsed.servers[0].name).toBe('slack');
expect(parsed.servers[0]).not.toHaveProperty('id');
expect(parsed.servers[0]).not.toHaveProperty('createdAt');
expect(parsed.servers[0]).not.toHaveProperty('updatedAt');
expect(parsed.servers[0]).not.toHaveProperty('version');
});
it('outputs YAML format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack' }]);
it('outputs apply-compatible YAML format', async () => {
const deps = makeDeps([{ id: 'srv-1', name: 'slack', createdAt: '2025-01-01' }]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers', '-o', 'yaml']);
expect(deps.output[0]).toContain('name: slack');
});
it('lists profiles with correct columns', async () => {
const deps = makeDeps([
{ id: 'p1', name: 'default', serverId: 'srv-1' },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'profiles']);
expect(deps.output[0]).toContain('NAME');
expect(deps.output[0]).toContain('SERVER ID');
const text = deps.output[0];
expect(text).toContain('servers:');
expect(text).toContain('name: slack');
expect(text).not.toContain('id:');
expect(text).not.toContain('createdAt:');
});
it('lists instances with correct columns', async () => {
const deps = makeDeps([
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
{ id: 'inst-1', serverId: 'srv-1', server: { name: 'my-grafana' }, status: 'RUNNING', containerId: 'abc123def456', port: 3000 },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'instances']);
expect(deps.output[0]).toContain('NAME');
expect(deps.output[0]).toContain('STATUS');
expect(deps.output.join('\n')).toContain('my-grafana');
expect(deps.output.join('\n')).toContain('RUNNING');
});
@@ -81,6 +83,172 @@ describe('get command', () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'servers']);
expect(deps.output[0]).toContain('No results');
expect(deps.output[0]).toContain('No servers found');
});
it('lists users with correct columns (no ROLE column)', async () => {
const deps = makeDeps([
{ id: 'usr-1', email: 'alice@test.com', name: 'Alice', provider: null },
{ id: 'usr-2', email: 'bob@test.com', name: null, provider: 'oidc' },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'users']);
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
const text = deps.output.join('\n');
expect(text).toContain('EMAIL');
expect(text).toContain('NAME');
expect(text).not.toContain('ROLE');
expect(text).toContain('PROVIDER');
expect(text).toContain('alice@test.com');
expect(text).toContain('Alice');
expect(text).toContain('bob@test.com');
expect(text).toContain('oidc');
});
it('resolves user alias', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'user']);
expect(deps.fetchResource).toHaveBeenCalledWith('users', undefined);
});
it('lists groups with correct columns', async () => {
const deps = makeDeps([
{
id: 'grp-1',
name: 'dev-team',
description: 'Developers',
members: [{ user: { email: 'alice@test.com' } }, { user: { email: 'bob@test.com' } }],
},
{ id: 'grp-2', name: 'ops-team', description: 'Operations', members: [] },
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'groups']);
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
const text = deps.output.join('\n');
expect(text).toContain('NAME');
expect(text).toContain('MEMBERS');
expect(text).toContain('DESCRIPTION');
expect(text).toContain('dev-team');
expect(text).toContain('2');
expect(text).toContain('ops-team');
expect(text).toContain('0');
});
it('resolves group alias', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'group']);
expect(deps.fetchResource).toHaveBeenCalledWith('groups', undefined);
});
it('lists rbac definitions with correct columns', async () => {
const deps = makeDeps([
{
id: 'rbac-1',
name: 'admins',
subjects: [{ kind: 'User', name: 'admin@test.com' }],
roleBindings: [{ role: 'edit', resource: '*' }],
},
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac']);
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
const text = deps.output.join('\n');
expect(text).toContain('NAME');
expect(text).toContain('SUBJECTS');
expect(text).toContain('BINDINGS');
expect(text).toContain('admins');
expect(text).toContain('User:admin@test.com');
expect(text).toContain('edit:*');
});
it('resolves rbac-definition alias', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac-definition']);
expect(deps.fetchResource).toHaveBeenCalledWith('rbac', undefined);
});
it('lists projects with new columns', async () => {
const deps = makeDeps([{
id: 'proj-1',
name: 'smart-home',
description: 'Home automation',
proxyMode: 'filtered',
ownerId: 'usr-1',
servers: [{ server: { name: 'grafana' } }],
}]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'projects']);
const text = deps.output.join('\n');
expect(text).toContain('MODE');
expect(text).toContain('SERVERS');
expect(text).toContain('smart-home');
expect(text).toContain('filtered');
expect(text).toContain('1');
});
it('displays mixed resource and operation bindings', async () => {
const deps = makeDeps([
{
id: 'rbac-1',
name: 'admin-access',
subjects: [{ kind: 'Group', name: 'admin' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'run', action: 'logs' },
{ role: 'run', action: 'backup' },
],
},
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac']);
const text = deps.output.join('\n');
expect(text).toContain('edit:*');
expect(text).toContain('run>logs');
expect(text).toContain('run>backup');
});
it('displays name-scoped resource bindings', async () => {
const deps = makeDeps([
{
id: 'rbac-1',
name: 'ha-viewer',
subjects: [{ kind: 'User', name: 'alice@test.com' }],
roleBindings: [{ role: 'view', resource: 'servers', name: 'my-ha' }],
},
]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac']);
const text = deps.output.join('\n');
expect(text).toContain('view:servers:my-ha');
});
it('shows no results message for empty users list', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'users']);
expect(deps.output[0]).toContain('No users found');
});
it('shows no results message for empty groups list', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'groups']);
expect(deps.output[0]).toContain('No groups found');
});
it('shows no results message for empty rbac list', async () => {
const deps = makeDeps([]);
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'rbac']);
expect(deps.output[0]).toContain('No rbac found');
});
});

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createInstanceCommands } from '../../src/commands/instances.js';
import { createDeleteCommand } from '../../src/commands/delete.js';
import { createLogsCommand } from '../../src/commands/logs.js';
import type { ApiClient } from '../../src/api-client.js';
function mockClient(): ApiClient {
@@ -11,7 +12,7 @@ function mockClient(): ApiClient {
} as unknown as ApiClient;
}
describe('instance commands', () => {
describe('delete command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
@@ -21,107 +22,127 @@ describe('instance commands', () => {
output = [];
});
describe('list', () => {
it('shows no instances message when empty', async () => {
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('No instances found');
});
it('shows instance table', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'inst-1', serverId: 'srv-1', status: 'RUNNING', containerId: 'ctr-abc123def', port: 3000, createdAt: '2025-01-01' },
]);
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('inst-1');
expect(output.join('\n')).toContain('RUNNING');
});
it('filters by server', async () => {
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list', '-s', 'srv-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith(expect.stringContaining('serverId=srv-1'));
});
it('outputs json', async () => {
vi.mocked(client.get).mockResolvedValue([{ id: 'inst-1' }]);
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' });
expect(output[0]).toContain('"id"');
});
});
describe('start', () => {
it('starts an instance', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['start', 'srv-1'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1' });
expect(output.join('\n')).toContain('started');
});
it('passes host port', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-new', status: 'RUNNING' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['start', 'srv-1', '-p', '8080'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances', { serverId: 'srv-1', hostPort: 8080 });
});
});
describe('stop', () => {
it('stops an instance', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-1', status: 'STOPPED' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['stop', 'inst-1'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/stop');
expect(output.join('\n')).toContain('stopped');
});
});
describe('restart', () => {
it('restarts an instance', async () => {
vi.mocked(client.post).mockResolvedValue({ id: 'inst-2', status: 'RUNNING' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['restart', 'inst-1'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/instances/inst-1/restart');
expect(output.join('\n')).toContain('restarted');
});
});
describe('remove', () => {
it('removes an instance', async () => {
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['remove', 'inst-1'], { from: 'user' });
it('deletes an instance by ID', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['instance', 'inst-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/instances/inst-1');
expect(output.join('\n')).toContain('removed');
});
expect(output.join('\n')).toContain('deleted');
});
describe('logs', () => {
it('shows logs', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: 'hello world\n', stderr: '' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['logs', 'inst-1'], { from: 'user' });
it('deletes a server by ID', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['server', 'srv-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1');
expect(output.join('\n')).toContain('deleted');
});
it('resolves server name to ID', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'srv-abc', name: 'ha-mcp' },
]);
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['server', 'ha-mcp'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-abc');
});
it('deletes a project', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['project', 'proj-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1');
});
it('accepts resource aliases', async () => {
const cmd = createDeleteCommand({ client, log });
await cmd.parseAsync(['srv', 'srv-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/servers/srv-1');
});
});
describe('logs command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
it('shows logs by instance ID', async () => {
vi.mocked(client.get)
.mockResolvedValueOnce({ id: 'inst-1', status: 'RUNNING' } as never) // instance lookup
.mockResolvedValueOnce({ stdout: 'hello world\n', stderr: '' } as never); // logs
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['inst-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1');
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
expect(output.join('\n')).toContain('hello world');
});
it('passes tail option', async () => {
vi.mocked(client.get).mockResolvedValue({ stdout: '', stderr: '' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['logs', 'inst-1', '-t', '50'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
});
it('resolves server name to instance ID', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found')) // instance lookup fails
.mockResolvedValueOnce([{ id: 'srv-1', name: 'my-grafana' }] as never) // servers list
.mockResolvedValueOnce([{ id: 'inst-1', status: 'RUNNING', containerId: 'abc' }] as never) // instances for server
.mockResolvedValueOnce({ stdout: 'grafana logs\n', stderr: '' } as never); // logs
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['my-grafana'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
expect(output.join('\n')).toContain('grafana logs');
});
describe('inspect', () => {
it('shows container info as json', async () => {
vi.mocked(client.get).mockResolvedValue({ containerId: 'ctr-abc', state: 'running' });
const cmd = createInstanceCommands({ client, log });
await cmd.parseAsync(['inspect', 'inst-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/inspect');
expect(output[0]).toContain('ctr-abc');
it('picks RUNNING instance over others', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
.mockResolvedValueOnce([
{ id: 'inst-err', status: 'ERROR', containerId: null },
{ id: 'inst-ok', status: 'RUNNING', containerId: 'abc' },
] as never)
.mockResolvedValueOnce({ stdout: 'running instance\n', stderr: '' } as never);
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['ha-mcp'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-ok/logs');
});
it('selects specific replica with --instance', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
.mockResolvedValueOnce([
{ id: 'inst-0', status: 'RUNNING', containerId: 'a' },
{ id: 'inst-1', status: 'RUNNING', containerId: 'b' },
] as never)
.mockResolvedValueOnce({ stdout: 'replica 1\n', stderr: '' } as never);
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['ha-mcp', '-i', '1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs');
});
it('throws on out-of-range --instance index', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce([{ id: 'srv-1', name: 'ha-mcp' }] as never)
.mockResolvedValueOnce([{ id: 'inst-0', status: 'RUNNING' }] as never);
const cmd = createLogsCommand({ client, log });
await expect(cmd.parseAsync(['ha-mcp', '-i', '5'], { from: 'user' })).rejects.toThrow('out of range');
});
it('throws when server has no instances', async () => {
vi.mocked(client.get)
.mockRejectedValueOnce(new Error('not found'))
.mockResolvedValueOnce([{ id: 'srv-1', name: 'empty-srv' }] as never)
.mockResolvedValueOnce([] as never);
const cmd = createLogsCommand({ client, log });
await expect(cmd.parseAsync(['empty-srv'], { from: 'user' })).rejects.toThrow('No instances found');
});
it('passes tail option', async () => {
vi.mocked(client.get)
.mockResolvedValueOnce({ id: 'inst-1' } as never)
.mockResolvedValueOnce({ stdout: '', stderr: '' } as never);
const cmd = createLogsCommand({ client, log });
await cmd.parseAsync(['inst-1', '-t', '50'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/instances/inst-1/logs?tail=50');
});
});

View File

@@ -0,0 +1,481 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import http from 'node:http';
import { Readable, Writable } from 'node:stream';
import { runMcpBridge, createMcpCommand } from '../../src/commands/mcp.js';
// ---- Mock MCP server (simulates mcplocal project endpoint) ----
interface RecordedRequest {
method: string;
url: string;
headers: http.IncomingHttpHeaders;
body: string;
}
let mockServer: http.Server;
let mockPort: number;
const recorded: RecordedRequest[] = [];
let sessionCounter = 0;
function makeInitializeResponse(id: number | string) {
return JSON.stringify({
jsonrpc: '2.0',
id,
result: {
protocolVersion: '2024-11-05',
capabilities: { tools: {} },
serverInfo: { name: 'test-server', version: '1.0.0' },
},
});
}
function makeToolsListResponse(id: number | string) {
return JSON.stringify({
jsonrpc: '2.0',
id,
result: {
tools: [
{ name: 'grafana/query', description: 'Query Grafana', inputSchema: { type: 'object', properties: {} } },
],
},
});
}
function makeToolCallResponse(id: number | string) {
return JSON.stringify({
jsonrpc: '2.0',
id,
result: {
content: [{ type: 'text', text: 'tool result' }],
},
});
}
beforeAll(async () => {
mockServer = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => {
const body = Buffer.concat(chunks).toString('utf-8');
recorded.push({ method: req.method ?? '', url: req.url ?? '', headers: req.headers, body });
if (req.method === 'DELETE') {
res.writeHead(200);
res.end();
return;
}
if (req.method === 'POST' && req.url?.startsWith('/projects/')) {
let sessionId = req.headers['mcp-session-id'] as string | undefined;
// Assign session ID on first request
if (!sessionId) {
sessionCounter++;
sessionId = `session-${sessionCounter}`;
}
res.setHeader('mcp-session-id', sessionId);
// Parse JSON-RPC and respond based on method
try {
const rpc = JSON.parse(body) as { id: number | string; method: string };
let responseBody: string;
switch (rpc.method) {
case 'initialize':
responseBody = makeInitializeResponse(rpc.id);
break;
case 'tools/list':
responseBody = makeToolsListResponse(rpc.id);
break;
case 'tools/call':
responseBody = makeToolCallResponse(rpc.id);
break;
default:
responseBody = JSON.stringify({ jsonrpc: '2.0', id: rpc.id, error: { code: -32601, message: 'Method not found' } });
}
// Respond in SSE format for /projects/sse-project/mcp
if (req.url?.includes('sse-project')) {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
res.end(`event: message\ndata: ${responseBody}\n\n`);
} else {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(responseBody);
}
} catch {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid JSON' }));
}
return;
}
res.writeHead(404);
res.end();
});
});
await new Promise<void>((resolve) => {
mockServer.listen(0, () => {
const addr = mockServer.address();
if (addr && typeof addr === 'object') {
mockPort = addr.port;
}
resolve();
});
});
});
afterAll(() => {
mockServer.close();
});
// ---- Helper to run bridge with mock streams ----
function createMockStreams() {
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
const stdout = new Writable({
write(chunk: Buffer, _encoding, callback) {
stdoutChunks.push(chunk.toString());
callback();
},
});
const stderr = new Writable({
write(chunk: Buffer, _encoding, callback) {
stderrChunks.push(chunk.toString());
callback();
},
});
return { stdout, stderr, stdoutChunks, stderrChunks };
}
function pushAndEnd(stdin: Readable, lines: string[]) {
for (const line of lines) {
stdin.push(line + '\n');
}
stdin.push(null); // EOF
}
// ---- Tests ----
describe('MCP STDIO Bridge', () => {
beforeAll(() => {
recorded.length = 0;
sessionCounter = 0;
});
it('forwards initialize request and returns response', async () => {
recorded.length = 0;
const stdin = new Readable({ read() {} });
const { stdout, stdoutChunks } = createMockStreams();
const initMsg = JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
});
pushAndEnd(stdin, [initMsg]);
await runMcpBridge({
projectName: 'test-project',
mcplocalUrl: `http://localhost:${mockPort}`,
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
});
// Verify request was made to correct URL
expect(recorded.some((r) => r.url === '/projects/test-project/mcp' && r.method === 'POST')).toBe(true);
// Verify response on stdout
const output = stdoutChunks.join('');
const parsed = JSON.parse(output.trim());
expect(parsed.result.serverInfo.name).toBe('test-server');
expect(parsed.result.protocolVersion).toBe('2024-11-05');
});
it('sends session ID on subsequent requests', async () => {
recorded.length = 0;
const stdin = new Readable({ read() {} });
const { stdout, stdoutChunks } = createMockStreams();
const initMsg = JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
});
const toolsListMsg = JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/list', params: {} });
pushAndEnd(stdin, [initMsg, toolsListMsg]);
await runMcpBridge({
projectName: 'test-project',
mcplocalUrl: `http://localhost:${mockPort}`,
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
});
// First POST should NOT have mcp-session-id header
const firstPost = recorded.find((r) => r.method === 'POST' && r.body.includes('initialize'));
expect(firstPost).toBeDefined();
expect(firstPost!.headers['mcp-session-id']).toBeUndefined();
// Second POST SHOULD have mcp-session-id header
const secondPost = recorded.find((r) => r.method === 'POST' && r.body.includes('tools/list'));
expect(secondPost).toBeDefined();
expect(secondPost!.headers['mcp-session-id']).toMatch(/^session-/);
// Verify tools/list response
const lines = stdoutChunks.join('').trim().split('\n');
expect(lines.length).toBe(2);
const toolsResponse = JSON.parse(lines[1]);
expect(toolsResponse.result.tools[0].name).toBe('grafana/query');
});
it('forwards tools/call and returns result', async () => {
recorded.length = 0;
const stdin = new Readable({ read() {} });
const { stdout, stdoutChunks } = createMockStreams();
const initMsg = JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
});
const callMsg = JSON.stringify({
jsonrpc: '2.0', id: 2, method: 'tools/call',
params: { name: 'grafana/query', arguments: { query: 'test' } },
});
pushAndEnd(stdin, [initMsg, callMsg]);
await runMcpBridge({
projectName: 'test-project',
mcplocalUrl: `http://localhost:${mockPort}`,
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
});
const lines = stdoutChunks.join('').trim().split('\n');
expect(lines.length).toBe(2);
const callResponse = JSON.parse(lines[1]);
expect(callResponse.result.content[0].text).toBe('tool result');
});
it('forwards Authorization header when token provided', async () => {
recorded.length = 0;
const stdin = new Readable({ read() {} });
const { stdout } = createMockStreams();
const initMsg = JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
});
pushAndEnd(stdin, [initMsg]);
await runMcpBridge({
projectName: 'test-project',
mcplocalUrl: `http://localhost:${mockPort}`,
token: 'my-secret-token',
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
});
const post = recorded.find((r) => r.method === 'POST');
expect(post).toBeDefined();
expect(post!.headers['authorization']).toBe('Bearer my-secret-token');
});
it('does not send Authorization header when no token', async () => {
recorded.length = 0;
const stdin = new Readable({ read() {} });
const { stdout } = createMockStreams();
const initMsg = JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
});
pushAndEnd(stdin, [initMsg]);
await runMcpBridge({
projectName: 'test-project',
mcplocalUrl: `http://localhost:${mockPort}`,
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
});
const post = recorded.find((r) => r.method === 'POST');
expect(post).toBeDefined();
expect(post!.headers['authorization']).toBeUndefined();
});
it('sends DELETE to clean up session on stdin EOF', async () => {
recorded.length = 0;
const stdin = new Readable({ read() {} });
const { stdout } = createMockStreams();
const initMsg = JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
});
pushAndEnd(stdin, [initMsg]);
await runMcpBridge({
projectName: 'test-project',
mcplocalUrl: `http://localhost:${mockPort}`,
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
});
// Should have a DELETE request for session cleanup
const deleteReq = recorded.find((r) => r.method === 'DELETE');
expect(deleteReq).toBeDefined();
expect(deleteReq!.headers['mcp-session-id']).toMatch(/^session-/);
});
it('does not send DELETE if no session was established', async () => {
recorded.length = 0;
const stdin = new Readable({ read() {} });
const { stdout } = createMockStreams();
// Push EOF immediately with no messages
stdin.push(null);
await runMcpBridge({
projectName: 'test-project',
mcplocalUrl: `http://localhost:${mockPort}`,
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
});
expect(recorded.filter((r) => r.method === 'DELETE')).toHaveLength(0);
});
it('writes errors to stderr, not stdout', async () => {
recorded.length = 0;
const stdin = new Readable({ read() {} });
const { stdout, stdoutChunks, stderr, stderrChunks } = createMockStreams();
// Send to a non-existent port to trigger connection error
const badMsg = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} });
pushAndEnd(stdin, [badMsg]);
await runMcpBridge({
projectName: 'test-project',
mcplocalUrl: 'http://localhost:1', // will fail to connect
stdin, stdout, stderr,
});
// Error should be on stderr
expect(stderrChunks.join('')).toContain('MCP bridge error');
// stdout should be empty (no corrupted output)
expect(stdoutChunks.join('')).toBe('');
});
it('skips blank lines in stdin', async () => {
recorded.length = 0;
const stdin = new Readable({ read() {} });
const { stdout, stdoutChunks } = createMockStreams();
const initMsg = JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
});
pushAndEnd(stdin, ['', ' ', initMsg, '']);
await runMcpBridge({
projectName: 'test-project',
mcplocalUrl: `http://localhost:${mockPort}`,
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
});
// Only one POST (for the actual message)
const posts = recorded.filter((r) => r.method === 'POST');
expect(posts).toHaveLength(1);
// One response line
const lines = stdoutChunks.join('').trim().split('\n');
expect(lines).toHaveLength(1);
});
it('handles SSE (text/event-stream) responses', async () => {
recorded.length = 0;
const stdin = new Readable({ read() {} });
const { stdout, stdoutChunks } = createMockStreams();
const initMsg = JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
});
pushAndEnd(stdin, [initMsg]);
await runMcpBridge({
projectName: 'sse-project', // triggers SSE response from mock server
mcplocalUrl: `http://localhost:${mockPort}`,
stdin, stdout, stderr: new Writable({ write(_, __, cb) { cb(); } }),
});
// Should extract JSON from SSE data: lines
const output = stdoutChunks.join('').trim();
const parsed = JSON.parse(output);
expect(parsed.result.serverInfo.name).toBe('test-server');
});
it('URL-encodes project name', async () => {
recorded.length = 0;
const stdin = new Readable({ read() {} });
const { stdout } = createMockStreams();
const { stderr } = createMockStreams();
const initMsg = JSON.stringify({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1.0' } },
});
pushAndEnd(stdin, [initMsg]);
await runMcpBridge({
projectName: 'my project',
mcplocalUrl: `http://localhost:${mockPort}`,
stdin, stdout, stderr,
});
const post = recorded.find((r) => r.method === 'POST');
expect(post?.url).toBe('/projects/my%20project/mcp');
});
});
describe('createMcpCommand', () => {
it('accepts --project option directly', () => {
const cmd = createMcpCommand({
getProject: () => undefined,
configLoader: () => ({ mcplocalUrl: 'http://localhost:3200' }),
credentialsLoader: () => null,
});
const opt = cmd.options.find((o) => o.long === '--project');
expect(opt).toBeDefined();
expect(opt!.short).toBe('-p');
});
it('parses --project from command args', async () => {
let capturedProject: string | undefined;
const cmd = createMcpCommand({
getProject: () => undefined,
configLoader: () => ({ mcplocalUrl: `http://localhost:${mockPort}` }),
credentialsLoader: () => null,
});
// Override the action to capture what project was parsed
// We test by checking the option parsing works, not by running the full bridge
const parsed = cmd.parse(['--project', 'test-proj'], { from: 'user' });
capturedProject = parsed.opts().project;
expect(capturedProject).toBe('test-proj');
});
it('parses -p shorthand from command args', () => {
const cmd = createMcpCommand({
getProject: () => undefined,
configLoader: () => ({ mcplocalUrl: `http://localhost:${mockPort}` }),
credentialsLoader: () => null,
});
const parsed = cmd.parse(['-p', 'my-project'], { from: 'user' });
expect(parsed.opts().project).toBe('my-project');
});
});

View File

@@ -1,17 +1,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createProjectCommand } from '../../src/commands/project.js';
import type { ApiClient } from '../../src/api-client.js';
import { createCreateCommand } from '../../src/commands/create.js';
import { createGetCommand } from '../../src/commands/get.js';
import { createDescribeCommand } from '../../src/commands/describe.js';
import { type ApiClient, ApiError } from '../../src/api-client.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({ id: 'proj-1', name: 'my-project' })),
post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
describe('project command', () => {
describe('project with new fields', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
@@ -21,91 +23,94 @@ describe('project command', () => {
output = [];
});
describe('list', () => {
it('shows no projects message when empty', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('No projects found');
describe('create project with enhanced options', () => {
it('creates project with proxy mode and servers', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync([
'project', 'smart-home',
'-d', 'Smart home project',
'--proxy-mode', 'filtered',
'--proxy-mode-llm-provider', 'gemini-cli',
'--proxy-mode-llm-model', 'gemini-2.0-flash',
'--server', 'my-grafana',
'--server', 'my-ha',
], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
name: 'smart-home',
description: 'Smart home project',
proxyMode: 'filtered',
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
servers: ['my-grafana', 'my-ha'],
}));
});
it('shows project table', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' },
]);
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['list'], { from: 'user' });
expect(output.join('\n')).toContain('proj-1');
expect(output.join('\n')).toContain('dev');
});
it('defaults proxy mode to direct', async () => {
const cmd = createCreateCommand({ client, log });
await cmd.parseAsync(['project', 'basic'], { from: 'user' });
it('outputs json', async () => {
vi.mocked(client.get).mockResolvedValue([{ id: 'proj-1', name: 'dev' }]);
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['list', '-o', 'json'], { from: 'user' });
expect(output[0]).toContain('"id"');
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', expect.objectContaining({
proxyMode: 'direct',
}));
});
});
describe('create', () => {
it('creates a project', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['create', 'my-project', '-d', 'A test project'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/projects', {
name: 'my-project',
description: 'A test project',
});
expect(output.join('\n')).toContain("Project 'my-project' created");
describe('get projects shows new columns', () => {
it('shows MODE and SERVERS columns', async () => {
const deps = {
output: [] as string[],
fetchResource: vi.fn(async () => [{
id: 'proj-1',
name: 'smart-home',
description: 'Test',
proxyMode: 'filtered',
ownerId: 'user-1',
servers: [{ server: { name: 'grafana' } }, { server: { name: 'ha' } }],
}]),
log: (...args: string[]) => deps.output.push(args.join(' ')),
};
const cmd = createGetCommand(deps);
await cmd.parseAsync(['node', 'test', 'projects']);
const text = deps.output.join('\n');
expect(text).toContain('MODE');
expect(text).toContain('SERVERS');
expect(text).toContain('smart-home');
});
});
describe('delete', () => {
it('deletes a project', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['delete', 'proj-1'], { from: 'user' });
expect(client.delete).toHaveBeenCalledWith('/api/v1/projects/proj-1');
expect(output.join('\n')).toContain('deleted');
});
});
describe('describe project shows full detail', () => {
it('shows servers and proxy config', async () => {
const deps = {
output: [] as string[],
client: mockClient(),
fetchResource: vi.fn(async () => ({
id: 'proj-1',
name: 'smart-home',
description: 'Smart home',
proxyMode: 'filtered',
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
ownerId: 'user-1',
servers: [
{ server: { name: 'my-grafana' } },
{ server: { name: 'my-ha' } },
],
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
})),
log: (...args: string[]) => deps.output.push(args.join(' ')),
};
const cmd = createDescribeCommand(deps);
await cmd.parseAsync(['node', 'test', 'project', 'proj-1']);
describe('show', () => {
it('shows project details', async () => {
vi.mocked(client.get).mockImplementation(async (url: string) => {
if (url.endsWith('/profiles')) return [];
return { id: 'proj-1', name: 'dev', description: 'Dev project', ownerId: 'user-1', createdAt: '2025-01-01' };
});
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['show', 'proj-1'], { from: 'user' });
expect(output.join('\n')).toContain('Name: dev');
expect(output.join('\n')).toContain('ID: proj-1');
});
});
describe('profiles', () => {
it('lists profiles for a project', async () => {
vi.mocked(client.get).mockResolvedValue([
{ id: 'prof-1', name: 'default', serverId: 'srv-1' },
]);
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' });
expect(client.get).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles');
expect(output.join('\n')).toContain('default');
});
it('shows empty message when no profiles', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['profiles', 'proj-1'], { from: 'user' });
expect(output.join('\n')).toContain('No profiles assigned');
});
});
describe('set-profiles', () => {
it('sets profiles for a project', async () => {
const cmd = createProjectCommand({ client, log });
await cmd.parseAsync(['set-profiles', 'proj-1', 'prof-1', 'prof-2'], { from: 'user' });
expect(client.put).toHaveBeenCalledWith('/api/v1/projects/proj-1/profiles', {
profileIds: ['prof-1', 'prof-2'],
});
expect(output.join('\n')).toContain('2 profile(s)');
const text = deps.output.join('\n');
expect(text).toContain('=== Project: smart-home ===');
expect(text).toContain('filtered');
expect(text).toContain('gemini-cli');
expect(text).toContain('my-grafana');
expect(text).toContain('my-ha');
});
});
});

View File

@@ -1,141 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { createSetupCommand } from '../../src/commands/setup.js';
import type { ApiClient } from '../../src/api-client.js';
import type { SetupPromptDeps } from '../../src/commands/setup.js';
function mockClient(): ApiClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => ({ id: 'new-id', name: 'test' })),
put: vi.fn(async () => ({})),
delete: vi.fn(async () => {}),
} as unknown as ApiClient;
}
function mockPrompt(answers: Record<string, string | boolean>): SetupPromptDeps {
const answersQueue = { ...answers };
return {
input: vi.fn(async (message: string) => {
for (const [key, val] of Object.entries(answersQueue)) {
if (message.toLowerCase().includes(key.toLowerCase()) && typeof val === 'string') {
delete answersQueue[key];
return val;
}
}
return '';
}),
password: vi.fn(async () => 'secret-value'),
select: vi.fn(async () => 'STDIO') as SetupPromptDeps['select'],
confirm: vi.fn(async (message: string) => {
if (message.includes('profile')) return true;
if (message.includes('secret')) return false;
if (message.includes('another')) return false;
return false;
}),
};
}
describe('setup command', () => {
let client: ReturnType<typeof mockClient>;
let output: string[];
const log = (...args: unknown[]) => output.push(args.map(String).join(' '));
beforeEach(() => {
client = mockClient();
output = [];
});
it('creates server with prompted values', async () => {
const prompt = mockPrompt({
'transport': 'STDIO',
'npm package': '@anthropic/slack-mcp',
'docker image': '',
'description': 'Slack server',
'profile name': 'default',
'environment variable name': '',
});
const cmd = createSetupCommand({ client, prompt, log });
await cmd.parseAsync(['slack'], { from: 'user' });
expect(client.post).toHaveBeenCalledWith('/api/v1/servers', expect.objectContaining({
name: 'slack',
transport: 'STDIO',
}));
expect(output.join('\n')).toContain("Server 'test' created");
});
it('creates profile with env vars', async () => {
vi.mocked(client.post)
.mockResolvedValueOnce({ id: 'srv-1', name: 'slack' }) // server create
.mockResolvedValueOnce({ id: 'prof-1', name: 'default' }); // profile create
const prompt = mockPrompt({
'transport': 'STDIO',
'npm package': '',
'docker image': '',
'description': '',
'profile name': 'default',
});
// Override confirm to create profile and add one env var
let confirmCallCount = 0;
vi.mocked(prompt.confirm).mockImplementation(async (msg: string) => {
confirmCallCount++;
if (msg.includes('profile')) return true;
if (msg.includes('secret')) return true;
if (msg.includes('another')) return false;
return false;
});
// Override input to provide env var name then empty to stop
let inputCallCount = 0;
vi.mocked(prompt.input).mockImplementation(async (msg: string) => {
inputCallCount++;
if (msg.includes('Profile name')) return 'default';
if (msg.includes('variable name') && inputCallCount <= 8) return 'API_KEY';
if (msg.includes('variable name')) return '';
return '';
});
const cmd = createSetupCommand({ client, prompt, log });
await cmd.parseAsync(['slack'], { from: 'user' });
expect(client.post).toHaveBeenCalledTimes(2);
const profileCall = vi.mocked(client.post).mock.calls[1];
expect(profileCall?.[0]).toBe('/api/v1/profiles');
expect(profileCall?.[1]).toEqual(expect.objectContaining({
name: 'default',
serverId: 'srv-1',
}));
});
it('exits if server creation fails', async () => {
vi.mocked(client.post).mockRejectedValue(new Error('conflict'));
const prompt = mockPrompt({
'npm package': '',
'docker image': '',
'description': '',
});
const cmd = createSetupCommand({ client, prompt, log });
await cmd.parseAsync(['slack'], { from: 'user' });
expect(output.join('\n')).toContain('Failed to create server');
expect(client.post).toHaveBeenCalledTimes(1); // Only server create, no profile
});
it('skips profile creation when declined', async () => {
const prompt = mockPrompt({
'npm package': '',
'docker image': '',
'description': '',
});
vi.mocked(prompt.confirm).mockResolvedValue(false);
const cmd = createSetupCommand({ client, prompt, log });
await cmd.parseAsync(['test-server'], { from: 'user' });
expect(client.post).toHaveBeenCalledTimes(1); // Only server create
expect(output.join('\n')).toContain('Setup complete');
});
});

View File

@@ -3,19 +3,38 @@ import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createStatusCommand } from '../../src/commands/status.js';
import type { StatusCommandDeps } from '../../src/commands/status.js';
import { saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js';
import { saveCredentials } from '../../src/auth/index.js';
let tempDir: string;
let output: string[];
let written: string[];
function log(...args: string[]) {
output.push(args.join(' '));
}
function write(text: string) {
written.push(text);
}
function baseDeps(overrides?: Partial<StatusCommandDeps>): Partial<StatusCommandDeps> {
return {
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
write,
checkHealth: async () => true,
isTTY: false,
...overrides,
};
}
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-status-test-'));
output = [];
written = [];
});
afterEach(() => {
@@ -24,12 +43,7 @@ afterEach(() => {
describe('status command', () => {
it('shows status in table format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
const cmd = createStatusCommand(baseDeps());
await cmd.parseAsync([], { from: 'user' });
const out = output.join('\n');
expect(out).toContain('mcpctl v');
@@ -39,46 +53,26 @@ describe('status command', () => {
});
it('shows unreachable when daemons are down', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => false,
});
const cmd = createStatusCommand(baseDeps({ checkHealth: async () => false }));
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('unreachable');
});
it('shows not logged in when no credentials', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
const cmd = createStatusCommand(baseDeps());
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('not logged in');
});
it('shows logged in user when credentials exist', async () => {
saveCredentials({ token: 'tok', mcpdUrl: 'http://x:3100', user: 'alice@example.com' }, { configDir: tempDir });
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
const cmd = createStatusCommand(baseDeps());
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('logged in as alice@example.com');
});
it('shows status in JSON format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
const cmd = createStatusCommand(baseDeps());
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['version']).toBe('0.1.0');
@@ -87,12 +81,7 @@ describe('status command', () => {
});
it('shows status in YAML format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => false,
});
const cmd = createStatusCommand(baseDeps({ checkHealth: async () => false }));
await cmd.parseAsync(['-o', 'yaml'], { from: 'user' });
expect(output[0]).toContain('mcplocalReachable: false');
});
@@ -100,15 +89,12 @@ describe('status command', () => {
it('checks correct URLs from config', async () => {
saveConfig({ ...DEFAULT_CONFIG, mcplocalUrl: 'http://local:3200', mcpdUrl: 'http://remote:3100' }, { configDir: tempDir });
const checkedUrls: string[] = [];
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
const cmd = createStatusCommand(baseDeps({
checkHealth: async (url) => {
checkedUrls.push(url);
return false;
},
});
}));
await cmd.parseAsync([], { from: 'user' });
expect(checkedUrls).toContain('http://local:3200');
expect(checkedUrls).toContain('http://remote:3100');
@@ -116,14 +102,90 @@ describe('status command', () => {
it('shows registries from config', async () => {
saveConfig({ ...DEFAULT_CONFIG, registries: ['official'] }, { configDir: tempDir });
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
credentialsDeps: { configDir: tempDir },
log,
checkHealth: async () => true,
});
const cmd = createStatusCommand(baseDeps());
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('official');
expect(output.join('\n')).not.toContain('glama');
});
it('shows LLM not configured hint when no LLM is set', async () => {
const cmd = createStatusCommand(baseDeps());
await cmd.parseAsync([], { from: 'user' });
const out = output.join('\n');
expect(out).toContain('LLM:');
expect(out).toContain('not configured');
expect(out).toContain('mcpctl config setup');
});
it('shows green check when LLM is healthy (non-TTY)', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'anthropic', model: 'claude-haiku-3-5-20241022' } }, { configDir: tempDir });
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'ok' }));
await cmd.parseAsync([], { from: 'user' });
const out = output.join('\n');
expect(out).toContain('anthropic / claude-haiku-3-5-20241022');
expect(out).toContain('✓ ok');
});
it('shows red cross when LLM check fails (non-TTY)', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'not authenticated' }));
await cmd.parseAsync([], { from: 'user' });
const out = output.join('\n');
expect(out).toContain('✗ not authenticated');
});
it('shows binary not found error', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'binary not found' }));
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('✗ binary not found');
});
it('uses spinner on TTY and writes final result', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand(baseDeps({
isTTY: true,
checkLlm: async () => 'ok',
}));
await cmd.parseAsync([], { from: 'user' });
// On TTY, the final LLM line goes through write(), not log()
const finalWrite = written[written.length - 1];
expect(finalWrite).toContain('gemini-cli / gemini-2.5-flash');
expect(finalWrite).toContain('✓ ok');
});
it('uses spinner on TTY and shows failure', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand(baseDeps({
isTTY: true,
checkLlm: async () => 'not authenticated',
}));
await cmd.parseAsync([], { from: 'user' });
const finalWrite = written[written.length - 1];
expect(finalWrite).toContain('✗ not authenticated');
});
it('shows not configured when LLM provider is none', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'none' } }, { configDir: tempDir });
const cmd = createStatusCommand(baseDeps());
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('not configured');
});
it('includes llm and llmStatus in JSON output', async () => {
saveConfig({ ...DEFAULT_CONFIG, llm: { provider: 'gemini-cli', model: 'gemini-2.5-flash' } }, { configDir: tempDir });
const cmd = createStatusCommand(baseDeps({ checkLlm: async () => 'ok' }));
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['llm']).toBe('gemini-cli / gemini-2.5-flash');
expect(parsed['llmStatus']).toBe('ok');
});
it('includes null llm in JSON output when not configured', async () => {
const cmd = createStatusCommand(baseDeps());
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['llm']).toBeNull();
expect(parsed['llmStatus']).toBeNull();
});
});

View File

@@ -0,0 +1,176 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
const fishFile = readFileSync(join(root, 'completions', 'mcpctl.fish'), 'utf-8');
const bashFile = readFileSync(join(root, 'completions', 'mcpctl.bash'), 'utf-8');
describe('fish completions', () => {
it('erases stale completions at the top', () => {
const lines = fishFile.split('\n');
const firstComplete = lines.findIndex((l) => l.startsWith('complete '));
expect(lines[firstComplete]).toContain('-e');
});
it('does not offer resource types without __mcpctl_needs_resource_type guard', () => {
const resourceTypes = ['servers', 'instances', 'secrets', 'templates', 'projects', 'users', 'groups', 'rbac'];
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete '));
for (const line of lines) {
// Find lines that offer resource types as positional args
const offersResourceType = resourceTypes.some((r) => {
// Match `-a "...servers..."` or `-a 'servers projects'`
const aMatch = line.match(/-a\s+['"]([^'"]+)['"]/);
if (!aMatch) return false;
return aMatch[1].split(/\s+/).includes(r);
});
if (!offersResourceType) continue;
// Skip the help completions line and the -e line
if (line.includes('__fish_seen_subcommand_from help')) continue;
// Skip project-scoped command offerings (those offer commands, not resource types)
if (line.includes('attach-server') || line.includes('detach-server')) continue;
// Skip lines that offer commands (not resource types)
if (line.includes("-d 'Show") || line.includes("-d 'Manage") || line.includes("-d 'Authenticate") ||
line.includes("-d 'Log out'") || line.includes("-d 'Get instance") || line.includes("-d 'Create a resource'") ||
line.includes("-d 'Edit a resource'") || line.includes("-d 'Apply") || line.includes("-d 'Backup") ||
line.includes("-d 'Restore") || line.includes("-d 'List resources") || line.includes("-d 'Delete a resource'")) continue;
// Lines offering resource types MUST have __mcpctl_needs_resource_type in their condition
expect(line, `Resource type completion missing guard: ${line}`).toContain('__mcpctl_needs_resource_type');
}
});
it('resource name completions require resource type to be selected', () => {
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes('__mcpctl_resource_names'));
expect(lines.length).toBeGreaterThan(0);
for (const line of lines) {
expect(line).toContain('not __mcpctl_needs_resource_type');
}
});
it('defines --project option', () => {
expect(fishFile).toContain("complete -c mcpctl -l project");
});
it('attach-server command only shows with --project', () => {
// Only check lines that OFFER attach-server as a command (via -a attach-server), not argument completions
const lines = fishFile.split('\n').filter((l) =>
l.startsWith('complete') && l.includes("-a attach-server"));
expect(lines.length).toBeGreaterThan(0);
for (const line of lines) {
expect(line).toContain('__mcpctl_has_project');
}
});
it('detach-server command only shows with --project', () => {
const lines = fishFile.split('\n').filter((l) =>
l.startsWith('complete') && l.includes("-a detach-server"));
expect(lines.length).toBeGreaterThan(0);
for (const line of lines) {
expect(line).toContain('__mcpctl_has_project');
}
});
it('resource name functions use jq .[][].name to unwrap wrapped JSON and avoid nested matches', () => {
// API returns { "resources": [...] } not [...], so .[].name fails silently.
// Must use .[][].name to unwrap the outer object then iterate the array.
// Also must not use string match regex which matches nested name fields.
const resourceNamesFn = fishFile.match(/function __mcpctl_resource_names[\s\S]*?^end/m)?.[0] ?? '';
const projectNamesFn = fishFile.match(/function __mcpctl_project_names[\s\S]*?^end/m)?.[0] ?? '';
expect(resourceNamesFn, '__mcpctl_resource_names must use jq .[][].name').toContain("jq -r '.[][].name'");
expect(resourceNamesFn, '__mcpctl_resource_names must not use string match on name').not.toMatch(/string match.*"name"/);
expect(projectNamesFn, '__mcpctl_project_names must use jq .[][].name').toContain("jq -r '.[][].name'");
expect(projectNamesFn, '__mcpctl_project_names must not use string match on name').not.toMatch(/string match.*"name"/);
});
it('instances use server.name instead of name', () => {
const resourceNamesFn = fishFile.match(/function __mcpctl_resource_names[\s\S]*?^end/m)?.[0] ?? '';
expect(resourceNamesFn, 'must handle instances via server.name').toContain('.server.name');
});
it('attach-server completes with available (unattached) servers and guards against repeat', () => {
const attachLine = fishFile.split('\n').find((l) =>
l.startsWith('complete') && l.includes('__fish_seen_subcommand_from attach-server'));
expect(attachLine, 'attach-server argument completion must exist').toBeDefined();
expect(attachLine, 'attach-server must use __mcpctl_available_servers').toContain('__mcpctl_available_servers');
expect(attachLine, 'attach-server must guard with __mcpctl_needs_server_arg').toContain('__mcpctl_needs_server_arg');
});
it('detach-server completes with project servers and guards against repeat', () => {
const detachLine = fishFile.split('\n').find((l) =>
l.startsWith('complete') && l.includes('__fish_seen_subcommand_from detach-server'));
expect(detachLine, 'detach-server argument completion must exist').toBeDefined();
expect(detachLine, 'detach-server must use __mcpctl_project_servers').toContain('__mcpctl_project_servers');
expect(detachLine, 'detach-server must guard with __mcpctl_needs_server_arg').toContain('__mcpctl_needs_server_arg');
});
it('non-project commands do not show with --project', () => {
const nonProjectCmds = ['status', 'login', 'logout', 'config', 'apply', 'backup', 'restore'];
const lines = fishFile.split('\n').filter((l) => l.startsWith('complete') && l.includes('-a '));
for (const cmd of nonProjectCmds) {
const cmdLines = lines.filter((l) => {
const aMatch = l.match(/-a\s+(\S+)/);
return aMatch && aMatch[1].replace(/['"]/g, '') === cmd;
});
for (const line of cmdLines) {
expect(line, `${cmd} should require 'not __mcpctl_has_project'`).toContain('not __mcpctl_has_project');
}
}
});
});
describe('bash completions', () => {
it('separates project commands from regular commands', () => {
expect(bashFile).toContain('project_commands=');
expect(bashFile).toContain('attach-server detach-server');
});
it('checks has_project before offering project commands', () => {
expect(bashFile).toContain('if $has_project');
expect(bashFile).toContain('$project_commands');
});
it('fetches resource names dynamically after resource type', () => {
expect(bashFile).toContain('_mcpctl_resource_names');
// get/describe/delete should use resource_names when resource_type is set
expect(bashFile).toMatch(/get\|describe\|delete\)[\s\S]*?_mcpctl_resource_names/);
});
it('attach-server filters out already-attached servers and guards against repeat', () => {
const attachBlock = bashFile.match(/attach-server\)[\s\S]*?return ;;/)?.[0] ?? '';
expect(attachBlock, 'attach-server must use _mcpctl_get_project_value').toContain('_mcpctl_get_project_value');
expect(attachBlock, 'attach-server must query project servers to exclude').toContain('--project');
expect(attachBlock, 'attach-server must check position to prevent repeat').toContain('cword - subcmd_pos');
});
it('detach-server shows only project servers and guards against repeat', () => {
const detachBlock = bashFile.match(/detach-server\)[\s\S]*?return ;;/)?.[0] ?? '';
expect(detachBlock, 'detach-server must use _mcpctl_get_project_value').toContain('_mcpctl_get_project_value');
expect(detachBlock, 'detach-server must query project servers').toContain('--project');
expect(detachBlock, 'detach-server must check position to prevent repeat').toContain('cword - subcmd_pos');
});
it('instances use server.name instead of name', () => {
const fnMatch = bashFile.match(/_mcpctl_resource_names\(\)[\s\S]*?\n\s*\}/)?.[0] ?? '';
expect(fnMatch, 'must handle instances via .server.name').toContain('.server.name');
});
it('defines --project option', () => {
expect(bashFile).toContain('--project');
});
it('resource name function uses jq .[][].name to unwrap wrapped JSON and avoid nested matches', () => {
const fnMatch = bashFile.match(/_mcpctl_resource_names\(\)[\s\S]*?\n\s*\}/)?.[0] ?? '';
expect(fnMatch, '_mcpctl_resource_names must use jq .[][].name').toContain("jq -r '.[][].name'");
expect(fnMatch, '_mcpctl_resource_names must not use grep on name').not.toMatch(/grep.*"name"/);
// Guard against .[].name (single bracket) which fails on wrapped JSON
expect(fnMatch, '_mcpctl_resource_names must not use .[].name (needs .[][].name)').not.toMatch(/jq.*'\.\[\]\.name'/);
});
});

View File

@@ -16,52 +16,49 @@ describe('CLI command registration (e2e)', () => {
expect(commandNames).toContain('logout');
expect(commandNames).toContain('get');
expect(commandNames).toContain('describe');
expect(commandNames).toContain('instance');
expect(commandNames).toContain('delete');
expect(commandNames).toContain('logs');
expect(commandNames).toContain('apply');
expect(commandNames).toContain('setup');
expect(commandNames).toContain('claude');
expect(commandNames).toContain('project');
expect(commandNames).toContain('create');
expect(commandNames).toContain('edit');
expect(commandNames).toContain('backup');
expect(commandNames).toContain('restore');
});
it('instance command has lifecycle subcommands', () => {
it('old project and claude top-level commands are removed', () => {
const program = createProgram();
const instance = program.commands.find((c) => c.name() === 'instance');
expect(instance).toBeDefined();
const subcommands = instance!.commands.map((c) => c.name());
expect(subcommands).toContain('list');
expect(subcommands).toContain('start');
expect(subcommands).toContain('stop');
expect(subcommands).toContain('restart');
expect(subcommands).toContain('remove');
expect(subcommands).toContain('logs');
expect(subcommands).toContain('inspect');
const commandNames = program.commands.map((c) => c.name());
expect(commandNames).not.toContain('claude');
expect(commandNames).not.toContain('project');
expect(commandNames).not.toContain('instance');
});
it('claude command has config management subcommands', () => {
it('config command has claude-generate and impersonate subcommands', () => {
const program = createProgram();
const claude = program.commands.find((c) => c.name() === 'claude');
expect(claude).toBeDefined();
const config = program.commands.find((c) => c.name() === 'config');
expect(config).toBeDefined();
const subcommands = claude!.commands.map((c) => c.name());
expect(subcommands).toContain('generate');
expect(subcommands).toContain('show');
expect(subcommands).toContain('add');
expect(subcommands).toContain('remove');
const subcommands = config!.commands.map((c) => c.name());
expect(subcommands).toContain('claude-generate');
expect(subcommands).toContain('impersonate');
expect(subcommands).toContain('view');
expect(subcommands).toContain('set');
expect(subcommands).toContain('path');
expect(subcommands).toContain('reset');
});
it('project command has CRUD subcommands', () => {
it('create command has user, group, rbac subcommands', () => {
const program = createProgram();
const project = program.commands.find((c) => c.name() === 'project');
expect(project).toBeDefined();
const create = program.commands.find((c) => c.name() === 'create');
expect(create).toBeDefined();
const subcommands = project!.commands.map((c) => c.name());
expect(subcommands).toContain('list');
expect(subcommands).toContain('create');
expect(subcommands).toContain('delete');
expect(subcommands).toContain('show');
expect(subcommands).toContain('profiles');
expect(subcommands).toContain('set-profiles');
const subcommands = create!.commands.map((c) => c.name());
expect(subcommands).toContain('server');
expect(subcommands).toContain('secret');
expect(subcommands).toContain('project');
expect(subcommands).toContain('user');
expect(subcommands).toContain('group');
expect(subcommands).toContain('rbac');
});
it('displays version', () => {

View File

@@ -0,0 +1,204 @@
-- CreateEnum
CREATE TYPE "Role" AS ENUM ('USER', 'ADMIN');
-- CreateEnum
CREATE TYPE "Transport" AS ENUM ('STDIO', 'SSE', 'STREAMABLE_HTTP');
-- CreateEnum
CREATE TYPE "InstanceStatus" AS ENUM ('STARTING', 'RUNNING', 'STOPPING', 'STOPPED', 'ERROR');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT,
"passwordHash" TEXT NOT NULL,
"role" "Role" NOT NULL DEFAULT 'USER',
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"token" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "McpServer" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"packageName" TEXT,
"dockerImage" TEXT,
"transport" "Transport" NOT NULL DEFAULT 'STDIO',
"repositoryUrl" TEXT,
"externalUrl" TEXT,
"command" JSONB,
"containerPort" INTEGER,
"envTemplate" JSONB NOT NULL DEFAULT '[]',
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "McpServer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "McpProfile" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"permissions" JSONB NOT NULL DEFAULT '[]',
"envOverrides" JSONB NOT NULL DEFAULT '{}',
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "McpProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Project" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT NOT NULL DEFAULT '',
"ownerId" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ProjectMcpProfile" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"profileId" TEXT NOT NULL,
CONSTRAINT "ProjectMcpProfile_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "McpInstance" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"containerId" TEXT,
"status" "InstanceStatus" NOT NULL DEFAULT 'STOPPED',
"port" INTEGER,
"metadata" JSONB NOT NULL DEFAULT '{}',
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "McpInstance_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"resource" TEXT NOT NULL,
"resourceId" TEXT,
"details" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Session_token_key" ON "Session"("token");
-- CreateIndex
CREATE INDEX "Session_token_idx" ON "Session"("token");
-- CreateIndex
CREATE INDEX "Session_userId_idx" ON "Session"("userId");
-- CreateIndex
CREATE INDEX "Session_expiresAt_idx" ON "Session"("expiresAt");
-- CreateIndex
CREATE UNIQUE INDEX "McpServer_name_key" ON "McpServer"("name");
-- CreateIndex
CREATE INDEX "McpServer_name_idx" ON "McpServer"("name");
-- CreateIndex
CREATE INDEX "McpProfile_serverId_idx" ON "McpProfile"("serverId");
-- CreateIndex
CREATE UNIQUE INDEX "McpProfile_name_serverId_key" ON "McpProfile"("name", "serverId");
-- CreateIndex
CREATE UNIQUE INDEX "Project_name_key" ON "Project"("name");
-- CreateIndex
CREATE INDEX "Project_name_idx" ON "Project"("name");
-- CreateIndex
CREATE INDEX "Project_ownerId_idx" ON "Project"("ownerId");
-- CreateIndex
CREATE INDEX "ProjectMcpProfile_projectId_idx" ON "ProjectMcpProfile"("projectId");
-- CreateIndex
CREATE INDEX "ProjectMcpProfile_profileId_idx" ON "ProjectMcpProfile"("profileId");
-- CreateIndex
CREATE UNIQUE INDEX "ProjectMcpProfile_projectId_profileId_key" ON "ProjectMcpProfile"("projectId", "profileId");
-- CreateIndex
CREATE INDEX "McpInstance_serverId_idx" ON "McpInstance"("serverId");
-- CreateIndex
CREATE INDEX "McpInstance_status_idx" ON "McpInstance"("status");
-- CreateIndex
CREATE INDEX "AuditLog_userId_idx" ON "AuditLog"("userId");
-- CreateIndex
CREATE INDEX "AuditLog_action_idx" ON "AuditLog"("action");
-- CreateIndex
CREATE INDEX "AuditLog_resource_idx" ON "AuditLog"("resource");
-- CreateIndex
CREATE INDEX "AuditLog_createdAt_idx" ON "AuditLog"("createdAt");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "McpProfile" ADD CONSTRAINT "McpProfile_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Project" ADD CONSTRAINT "Project_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectMcpProfile" ADD CONSTRAINT "ProjectMcpProfile_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ProjectMcpProfile" ADD CONSTRAINT "ProjectMcpProfile_profileId_fkey" FOREIGN KEY ("profileId") REFERENCES "McpProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "McpInstance" ADD CONSTRAINT "McpInstance_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "McpServer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "AuditLog" ADD CONSTRAINT "AuditLog_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,8 @@
-- DropForeignKey
ALTER TABLE "ProjectMember" DROP CONSTRAINT IF EXISTS "ProjectMember_projectId_fkey";
-- DropForeignKey
ALTER TABLE "ProjectMember" DROP CONSTRAINT IF EXISTS "ProjectMember_userId_fkey";
-- DropTable
DROP TABLE IF EXISTS "ProjectMember";

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -15,13 +15,16 @@ model User {
name String?
passwordHash String
role Role @default(USER)
provider String?
externalId String?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
auditLogs AuditLog[]
projects Project[]
ownedProjects Project[]
groupMemberships GroupMember[]
@@index([email])
}
@@ -57,13 +60,21 @@ model McpServer {
dockerImage String?
transport Transport @default(STDIO)
repositoryUrl String?
envTemplate Json @default("[]")
externalUrl String?
command Json?
containerPort Int?
replicas Int @default(1)
env Json @default("[]")
healthCheck Json?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
profiles McpProfile[]
templateName String?
templateVersion String?
instances McpInstance[]
projects ProjectServer[]
@@index([name])
}
@@ -74,23 +85,83 @@ enum Transport {
STREAMABLE_HTTP
}
// ── MCP Profiles ──
// ── MCP Templates ──
model McpProfile {
model McpTemplate {
id String @id @default(cuid())
name String
serverId String
permissions Json @default("[]")
envOverrides Json @default("{}")
name String @unique
version String @default("1.0.0")
description String @default("")
packageName String?
dockerImage String?
transport Transport @default(STDIO)
repositoryUrl String?
externalUrl String?
command Json?
containerPort Int?
replicas Int @default(1)
env Json @default("[]")
healthCheck Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
}
// ── Secrets ──
model Secret {
id String @id @default(cuid())
name String @unique
data Json @default("{}")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
projects ProjectMcpProfile[]
@@index([name])
}
@@unique([name, serverId])
@@index([serverId])
// ── Groups ──
model Group {
id String @id @default(cuid())
name String @unique
description String @default("")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
members GroupMember[]
@@index([name])
}
model GroupMember {
id String @id @default(cuid())
groupId String
userId String
createdAt DateTime @default(now())
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([groupId, userId])
@@index([groupId])
@@index([userId])
}
// ── RBAC Definitions ──
model RbacDefinition {
id String @id @default(cuid())
name String @unique
subjects Json @default("[]")
roleBindings Json @default("[]")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([name])
}
// ── Projects ──
@@ -99,31 +170,34 @@ model Project {
id String @id @default(cuid())
name String @unique
description String @default("")
prompt String @default("")
proxyMode String @default("direct")
llmProvider String?
llmModel String?
ownerId String
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
profiles ProjectMcpProfile[]
servers ProjectServer[]
prompts Prompt[]
promptRequests PromptRequest[]
@@index([name])
@@index([ownerId])
}
// ── Project <-> Profile join table ──
model ProjectMcpProfile {
model ProjectServer {
id String @id @default(cuid())
projectId String
profileId String
serverId String
createdAt DateTime @default(now())
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
profile McpProfile @relation(fields: [profileId], references: [id], onDelete: Cascade)
server McpServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@unique([projectId, profileId])
@@index([projectId])
@@index([profileId])
@@unique([projectId, serverId])
}
// ── MCP Instances (running containers) ──
@@ -135,6 +209,9 @@ model McpInstance {
status InstanceStatus @default(STOPPED)
port Int?
metadata Json @default("{}")
healthStatus String?
lastHealthCheck DateTime?
events Json @default("[]")
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -153,6 +230,41 @@ enum InstanceStatus {
ERROR
}
// ── Prompts (approved content resources) ──
model Prompt {
id String @id @default(cuid())
name String
content String @db.Text
projectId String?
version Int @default(1)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([name, projectId])
@@index([projectId])
}
// ── Prompt Requests (pending proposals from LLM sessions) ──
model PromptRequest {
id String @id @default(cuid())
name String
content String @db.Text
projectId String?
createdBySession String?
createdByUserId String?
createdAt DateTime @default(now())
project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade)
@@unique([name, projectId])
@@index([projectId])
@@index([createdBySession])
}
// ── Audit Logs ──
model AuditLog {

View File

@@ -4,9 +4,9 @@ export type {
User,
Session,
McpServer,
McpProfile,
McpTemplate,
Secret,
Project,
ProjectMcpProfile,
McpInstance,
AuditLog,
Role,
@@ -14,5 +14,5 @@ export type {
InstanceStatus,
} from '@prisma/client';
export { seedMcpServers, defaultServers } from './seed/index.js';
export type { SeedServer } from './seed/index.js';
export { seedTemplates } from './seed/index.js';
export type { SeedTemplate, TemplateEnvEntry, HealthCheckSpec } from './seed/index.js';

View File

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

View File

@@ -48,11 +48,16 @@ export async function cleanupTestDb(): Promise<void> {
export async function clearAllTables(client: PrismaClient): Promise<void> {
// Delete in order respecting foreign keys
await client.auditLog.deleteMany();
await client.projectMcpProfile.deleteMany();
await client.mcpInstance.deleteMany();
await client.mcpProfile.deleteMany();
await client.projectServer.deleteMany();
await client.projectMember.deleteMany();
await client.secret.deleteMany();
await client.session.deleteMany();
await client.project.deleteMany();
await client.mcpServer.deleteMany();
await client.mcpTemplate.deleteMany();
await client.groupMember.deleteMany();
await client.group.deleteMany();
await client.rbacDefinition.deleteMany();
await client.user.deleteMany();
}

View File

@@ -23,11 +23,35 @@ async function createUser(overrides: { email?: string; name?: string; role?: 'US
data: {
email: overrides.email ?? `test-${Date.now()}@example.com`,
name: overrides.name ?? 'Test User',
passwordHash: '$2b$10$test-hash-placeholder',
role: overrides.role ?? 'USER',
},
});
}
async function createGroup(overrides: { name?: string; description?: string } = {}) {
return prisma.group.create({
data: {
name: overrides.name ?? `group-${Date.now()}`,
description: overrides.description ?? 'Test group',
},
});
}
async function createProject(overrides: { name?: string; ownerId?: string } = {}) {
let ownerId = overrides.ownerId;
if (!ownerId) {
const user = await createUser();
ownerId = user.id;
}
return prisma.project.create({
data: {
name: overrides.name ?? `project-${Date.now()}`,
ownerId,
},
});
}
async function createServer(overrides: { name?: string; transport?: 'STDIO' | 'SSE' | 'STREAMABLE_HTTP' } = {}) {
return prisma.mcpServer.create({
data: {
@@ -123,7 +147,7 @@ describe('McpServer', () => {
const server = await createServer();
expect(server.transport).toBe('STDIO');
expect(server.version).toBe(1);
expect(server.envTemplate).toEqual([]);
expect(server.env).toEqual([]);
});
it('enforces unique name', async () => {
@@ -131,18 +155,18 @@ describe('McpServer', () => {
await expect(createServer({ name: 'slack' })).rejects.toThrow();
});
it('stores envTemplate as JSON', async () => {
it('stores env as JSON', async () => {
const server = await prisma.mcpServer.create({
data: {
name: 'with-env',
envTemplate: [
{ name: 'API_KEY', description: 'Key', isSecret: true },
env: [
{ name: 'API_KEY', value: 'test-key' },
],
},
});
const envTemplate = server.envTemplate as Array<{ name: string }>;
expect(envTemplate).toHaveLength(1);
expect(envTemplate[0].name).toBe('API_KEY');
const env = server.env as Array<{ name: string }>;
expect(env).toHaveLength(1);
expect(env[0].name).toBe('API_KEY');
});
it('supports SSE transport', async () => {
@@ -151,43 +175,46 @@ describe('McpServer', () => {
});
});
// ── McpProfile model ──
// ── Secret model ──
describe('McpProfile', () => {
it('creates a profile linked to server', async () => {
const server = await createServer();
const profile = await prisma.mcpProfile.create({
describe('Secret', () => {
it('creates a secret with defaults', async () => {
const secret = await prisma.secret.create({
data: { name: 'my-secret' },
});
expect(secret.name).toBe('my-secret');
expect(secret.data).toEqual({});
expect(secret.version).toBe(1);
});
it('stores key-value data as JSON', async () => {
const secret = await prisma.secret.create({
data: {
name: 'readonly',
serverId: server.id,
permissions: ['read'],
name: 'api-keys',
data: { API_KEY: 'test-key', API_SECRET: 'test-secret' },
},
});
expect(profile.name).toBe('readonly');
expect(profile.serverId).toBe(server.id);
const data = secret.data as Record<string, string>;
expect(data['API_KEY']).toBe('test-key');
expect(data['API_SECRET']).toBe('test-secret');
});
it('enforces unique name per server', async () => {
const server = await createServer();
const data = { name: 'default', serverId: server.id };
await prisma.mcpProfile.create({ data });
await expect(prisma.mcpProfile.create({ data })).rejects.toThrow();
it('enforces unique name', async () => {
await prisma.secret.create({ data: { name: 'dup-secret' } });
await expect(prisma.secret.create({ data: { name: 'dup-secret' } })).rejects.toThrow();
});
it('allows same profile name on different servers', async () => {
const server1 = await createServer({ name: 'server-1' });
const server2 = await createServer({ name: 'server-2' });
await prisma.mcpProfile.create({ data: { name: 'default', serverId: server1.id } });
const profile2 = await prisma.mcpProfile.create({ data: { name: 'default', serverId: server2.id } });
expect(profile2.name).toBe('default');
it('updates data', async () => {
const secret = await prisma.secret.create({
data: { name: 'updatable', data: { KEY: 'old' } },
});
it('cascades delete when server is deleted', async () => {
const server = await createServer();
await prisma.mcpProfile.create({ data: { name: 'test', serverId: server.id } });
await prisma.mcpServer.delete({ where: { id: server.id } });
const profiles = await prisma.mcpProfile.findMany({ where: { serverId: server.id } });
expect(profiles).toHaveLength(0);
const updated = await prisma.secret.update({
where: { id: secret.id },
data: { data: { KEY: 'new', EXTRA: 'added' } },
});
const data = updated.data as Record<string, string>;
expect(data['KEY']).toBe('new');
expect(data['EXTRA']).toBe('added');
});
});
@@ -220,62 +247,6 @@ describe('Project', () => {
});
});
// ── ProjectMcpProfile (join table) ──
describe('ProjectMcpProfile', () => {
it('links project to profile', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'default', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'test-project', ownerId: user.id },
});
const link = await prisma.projectMcpProfile.create({
data: { projectId: project.id, profileId: profile.id },
});
expect(link.projectId).toBe(project.id);
expect(link.profileId).toBe(profile.id);
});
it('enforces unique project+profile combination', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'default', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'test-project', ownerId: user.id },
});
const data = { projectId: project.id, profileId: profile.id };
await prisma.projectMcpProfile.create({ data });
await expect(prisma.projectMcpProfile.create({ data })).rejects.toThrow();
});
it('loads profiles through project include', async () => {
const user = await createUser();
const server = await createServer();
const profile = await prisma.mcpProfile.create({
data: { name: 'slack-ro', serverId: server.id },
});
const project = await prisma.project.create({
data: { name: 'reports', ownerId: user.id },
});
await prisma.projectMcpProfile.create({
data: { projectId: project.id, profileId: profile.id },
});
const loaded = await prisma.project.findUnique({
where: { id: project.id },
include: { profiles: { include: { profile: true } } },
});
expect(loaded!.profiles).toHaveLength(1);
expect(loaded!.profiles[0].profile.name).toBe('slack-ro');
});
});
// ── McpInstance model ──
@@ -362,3 +333,236 @@ describe('AuditLog', () => {
expect(logs).toHaveLength(0);
});
});
// ── User SSO fields ──
describe('User SSO fields', () => {
it('stores provider and externalId', async () => {
const user = await prisma.user.create({
data: {
email: 'sso@example.com',
passwordHash: 'hash',
provider: 'oidc',
externalId: 'ext-123',
},
});
expect(user.provider).toBe('oidc');
expect(user.externalId).toBe('ext-123');
});
it('defaults provider and externalId to null', async () => {
const user = await createUser();
expect(user.provider).toBeNull();
expect(user.externalId).toBeNull();
});
});
// ── Group model ──
describe('Group', () => {
it('creates a group with defaults', async () => {
const group = await createGroup();
expect(group.id).toBeDefined();
expect(group.version).toBe(1);
});
it('enforces unique name', async () => {
await createGroup({ name: 'devs' });
await expect(createGroup({ name: 'devs' })).rejects.toThrow();
});
it('creates group members', async () => {
const group = await createGroup();
const user = await createUser();
const member = await prisma.groupMember.create({
data: { groupId: group.id, userId: user.id },
});
expect(member.groupId).toBe(group.id);
expect(member.userId).toBe(user.id);
});
it('enforces unique group-user pair', async () => {
const group = await createGroup();
const user = await createUser();
await prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } });
await expect(
prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } }),
).rejects.toThrow();
});
it('cascades delete when group is deleted', async () => {
const group = await createGroup();
const user = await createUser();
await prisma.groupMember.create({ data: { groupId: group.id, userId: user.id } });
await prisma.group.delete({ where: { id: group.id } });
const members = await prisma.groupMember.findMany({ where: { groupId: group.id } });
expect(members).toHaveLength(0);
});
});
// ── RbacDefinition model ──
describe('RbacDefinition', () => {
it('creates with defaults', async () => {
const rbac = await prisma.rbacDefinition.create({
data: { name: 'test-rbac' },
});
expect(rbac.subjects).toEqual([]);
expect(rbac.roleBindings).toEqual([]);
expect(rbac.version).toBe(1);
});
it('enforces unique name', async () => {
await prisma.rbacDefinition.create({ data: { name: 'dup-rbac' } });
await expect(prisma.rbacDefinition.create({ data: { name: 'dup-rbac' } })).rejects.toThrow();
});
it('stores subjects as JSON', async () => {
const rbac = await prisma.rbacDefinition.create({
data: {
name: 'with-subjects',
subjects: [{ kind: 'User', name: 'alice@test.com' }, { kind: 'Group', name: 'devs' }],
},
});
const subjects = rbac.subjects as Array<{ kind: string; name: string }>;
expect(subjects).toHaveLength(2);
expect(subjects[0].kind).toBe('User');
});
it('stores roleBindings as JSON', async () => {
const rbac = await prisma.rbacDefinition.create({
data: {
name: 'with-bindings',
roleBindings: [{ role: 'editor', resource: 'servers' }],
},
});
const bindings = rbac.roleBindings as Array<{ role: string; resource: string }>;
expect(bindings).toHaveLength(1);
expect(bindings[0].role).toBe('editor');
});
it('updates subjects and roleBindings', async () => {
const rbac = await prisma.rbacDefinition.create({ data: { name: 'updatable-rbac' } });
const updated = await prisma.rbacDefinition.update({
where: { id: rbac.id },
data: {
subjects: [{ kind: 'User', name: 'bob@test.com' }],
roleBindings: [{ role: 'admin', resource: '*' }],
},
});
expect((updated.subjects as unknown[]).length).toBe(1);
expect((updated.roleBindings as unknown[]).length).toBe(1);
});
});
// ── ProjectServer model ──
describe('ProjectServer', () => {
it('links project to server', async () => {
const project = await createProject();
const server = await createServer();
const ps = await prisma.projectServer.create({
data: { projectId: project.id, serverId: server.id },
});
expect(ps.projectId).toBe(project.id);
expect(ps.serverId).toBe(server.id);
});
it('enforces unique project-server pair', async () => {
const project = await createProject();
const server = await createServer();
await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } });
await expect(
prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } }),
).rejects.toThrow();
});
it('cascades delete when project is deleted', async () => {
const project = await createProject();
const server = await createServer();
await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } });
await prisma.project.delete({ where: { id: project.id } });
const links = await prisma.projectServer.findMany({ where: { projectId: project.id } });
expect(links).toHaveLength(0);
});
it('cascades delete when server is deleted', async () => {
const project = await createProject();
const server = await createServer();
await prisma.projectServer.create({ data: { projectId: project.id, serverId: server.id } });
await prisma.mcpServer.delete({ where: { id: server.id } });
const links = await prisma.projectServer.findMany({ where: { serverId: server.id } });
expect(links).toHaveLength(0);
});
});
// ── ProjectMember model ──
describe('ProjectMember', () => {
it('links project to user with role', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
const pm = await prisma.projectMember.create({
data: { projectId: project.id, userId: user.id, role: 'admin' },
});
expect(pm.role).toBe('admin');
});
it('defaults role to member', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
const pm = await prisma.projectMember.create({
data: { projectId: project.id, userId: user.id },
});
expect(pm.role).toBe('member');
});
it('enforces unique project-user pair', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } });
await expect(
prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } }),
).rejects.toThrow();
});
it('cascades delete when project is deleted', async () => {
const user = await createUser();
const project = await createProject({ ownerId: user.id });
await prisma.projectMember.create({ data: { projectId: project.id, userId: user.id } });
await prisma.project.delete({ where: { id: project.id } });
const members = await prisma.projectMember.findMany({ where: { projectId: project.id } });
expect(members).toHaveLength(0);
});
});
// ── Project new fields ──
describe('Project new fields', () => {
it('defaults proxyMode to direct', async () => {
const project = await createProject();
expect(project.proxyMode).toBe('direct');
});
it('stores proxyMode, llmProvider, llmModel', async () => {
const user = await createUser();
const project = await prisma.project.create({
data: {
name: 'filtered-project',
ownerId: user.id,
proxyMode: 'filtered',
llmProvider: 'gemini-cli',
llmModel: 'gemini-2.0-flash',
},
});
expect(project.proxyMode).toBe('filtered');
expect(project.llmProvider).toBe('gemini-cli');
expect(project.llmModel).toBe('gemini-2.0-flash');
});
it('defaults llmProvider and llmModel to null', async () => {
const project = await createProject();
expect(project.llmProvider).toBeNull();
expect(project.llmModel).toBeNull();
});
});

View File

@@ -1,7 +1,8 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import type { PrismaClient } from '@prisma/client';
import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js';
import { seedMcpServers, defaultServers } from '../src/seed/index.js';
import { seedTemplates } from '../src/seed/index.js';
import type { SeedTemplate } from '../src/seed/index.js';
let prisma: PrismaClient;
@@ -17,55 +18,69 @@ beforeEach(async () => {
await clearAllTables(prisma);
});
describe('seedMcpServers', () => {
it('seeds all default servers', async () => {
const count = await seedMcpServers(prisma);
expect(count).toBe(defaultServers.length);
const testTemplates: SeedTemplate[] = [
{
name: 'github',
version: '1.0.0',
description: 'GitHub MCP server',
packageName: '@anthropic/github-mcp',
transport: 'STDIO',
env: [{ name: 'GITHUB_TOKEN', description: 'Personal access token', required: true }],
},
{
name: 'slack',
version: '1.0.0',
description: 'Slack MCP server',
packageName: '@anthropic/slack-mcp',
transport: 'STDIO',
env: [],
},
];
const servers = await prisma.mcpServer.findMany({ orderBy: { name: 'asc' } });
expect(servers).toHaveLength(defaultServers.length);
describe('seedTemplates', () => {
it('seeds templates', async () => {
const count = await seedTemplates(prisma, testTemplates);
expect(count).toBe(2);
const names = servers.map((s) => s.name);
expect(names).toContain('slack');
expect(names).toContain('github');
expect(names).toContain('jira');
expect(names).toContain('terraform');
const templates = await prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } });
expect(templates).toHaveLength(2);
expect(templates.map((t) => t.name)).toEqual(['github', 'slack']);
});
it('is idempotent (upsert)', async () => {
await seedMcpServers(prisma);
const count = await seedMcpServers(prisma);
expect(count).toBe(defaultServers.length);
await seedTemplates(prisma, testTemplates);
const count = await seedTemplates(prisma, testTemplates);
expect(count).toBe(2);
const servers = await prisma.mcpServer.findMany();
expect(servers).toHaveLength(defaultServers.length);
const templates = await prisma.mcpTemplate.findMany();
expect(templates).toHaveLength(2);
});
it('seeds envTemplate correctly', async () => {
await seedMcpServers(prisma);
const slack = await prisma.mcpServer.findUnique({ where: { name: 'slack' } });
const envTemplate = slack!.envTemplate as Array<{ name: string; isSecret: boolean }>;
expect(envTemplate).toHaveLength(2);
expect(envTemplate[0].name).toBe('SLACK_BOT_TOKEN');
expect(envTemplate[0].isSecret).toBe(true);
it('seeds env correctly', async () => {
await seedTemplates(prisma, testTemplates);
const github = await prisma.mcpTemplate.findUnique({ where: { name: 'github' } });
const env = github!.env as Array<{ name: string; description?: string; required?: boolean }>;
expect(env).toHaveLength(1);
expect(env[0].name).toBe('GITHUB_TOKEN');
expect(env[0].required).toBe(true);
});
it('accepts custom server list', async () => {
const custom = [
it('accepts custom template list', async () => {
const custom: SeedTemplate[] = [
{
name: 'custom-server',
description: 'Custom test server',
name: 'custom-template',
version: '2.0.0',
description: 'Custom test template',
packageName: '@test/custom',
transport: 'STDIO' as const,
repositoryUrl: 'https://example.com',
envTemplate: [],
transport: 'STDIO',
env: [],
},
];
const count = await seedMcpServers(prisma, custom);
const count = await seedTemplates(prisma, custom);
expect(count).toBe(1);
const servers = await prisma.mcpServer.findMany();
expect(servers).toHaveLength(1);
expect(servers[0].name).toBe('custom-server');
const templates = await prisma.mcpTemplate.findMany();
expect(templates).toHaveLength(1);
expect(templates[0].name).toBe('custom-template');
});
});

View File

@@ -23,11 +23,13 @@
"bcrypt": "^5.1.1",
"dockerode": "^4.0.9",
"fastify": "^5.0.0",
"js-yaml": "^4.1.0",
"zod": "^3.24.0"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
"@types/dockerode": "^4.0.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.3.0"
}
}

View File

@@ -1,18 +1,28 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PrismaClient } from '@prisma/client';
import { seedMcpServers } from '@mcpctl/db';
import yaml from 'js-yaml';
import { seedTemplates } from '@mcpctl/db';
import type { SeedTemplate } from '@mcpctl/db';
import { loadConfigFromEnv } from './config/index.js';
import { createServer } from './server.js';
import { setupGracefulShutdown } from './utils/index.js';
import {
McpServerRepository,
McpProfileRepository,
SecretRepository,
McpInstanceRepository,
ProjectRepository,
AuditLogRepository,
TemplateRepository,
RbacDefinitionRepository,
UserRepository,
GroupRepository,
} from './repositories/index.js';
import { PromptRepository } from './repositories/prompt.repository.js';
import { PromptRequestRepository } from './repositories/prompt-request.repository.js';
import {
McpServerService,
McpProfileService,
SecretService,
InstanceService,
ProjectService,
AuditLogService,
@@ -23,10 +33,19 @@ import {
RestoreService,
AuthService,
McpProxyService,
TemplateService,
HealthProbeRunner,
RbacDefinitionService,
RbacService,
UserService,
GroupService,
} from './services/index.js';
import type { RbacAction } from './services/index.js';
import type { UpdateRbacDefinitionInput } from './validation/rbac-definition.schema.js';
import { createAuthMiddleware } from './middleware/auth.js';
import {
registerMcpServerRoutes,
registerMcpProfileRoutes,
registerSecretRoutes,
registerInstanceRoutes,
registerProjectRoutes,
registerAuditLogRoutes,
@@ -34,7 +53,156 @@ import {
registerBackupRoutes,
registerAuthRoutes,
registerMcpProxyRoutes,
registerTemplateRoutes,
registerRbacRoutes,
registerUserRoutes,
registerGroupRoutes,
} from './routes/index.js';
import { registerPromptRoutes } from './routes/prompts.js';
import { PromptService } from './services/prompt.service.js';
type PermissionCheck =
| { kind: 'resource'; resource: string; action: RbacAction; resourceName?: string }
| { kind: 'operation'; operation: string }
| { kind: 'skip' };
/**
* Map an HTTP method + URL to a permission check.
* Returns 'skip' for URLs that should not be RBAC-checked.
*/
function mapUrlToPermission(method: string, url: string): PermissionCheck {
const match = url.match(/^\/api\/v1\/([a-z-]+)/);
if (!match) return { kind: 'skip' };
const segment = match[1] as string;
// Operations (non-resource endpoints)
if (segment === 'backup') return { kind: 'operation', operation: 'backup' };
if (segment === 'restore') return { kind: 'operation', operation: 'restore' };
if (segment === 'audit-logs' && method === 'DELETE') return { kind: 'operation', operation: 'audit-purge' };
const resourceMap: Record<string, string | undefined> = {
'servers': 'servers',
'instances': 'instances',
'secrets': 'secrets',
'projects': 'projects',
'templates': 'templates',
'users': 'users',
'groups': 'groups',
'rbac': 'rbac',
'audit-logs': 'rbac',
'mcp': 'servers',
'prompts': 'prompts',
'promptrequests': 'promptrequests',
};
const resource = resourceMap[segment];
if (resource === undefined) return { kind: 'skip' };
// Special case: /api/v1/promptrequests/:id/approve → needs both delete+promptrequests and create+prompts
// We check delete on promptrequests (the harder permission); create on prompts is checked in the service layer
const approveMatch = url.match(/^\/api\/v1\/promptrequests\/([^/?]+)\/approve/);
if (approveMatch?.[1]) {
return { kind: 'resource', resource: 'promptrequests', action: 'delete', resourceName: approveMatch[1] };
}
// Special case: /api/v1/projects/:name/prompts/visible → view prompts
const visiblePromptsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/prompts\/visible/);
if (visiblePromptsMatch?.[1]) {
return { kind: 'resource', resource: 'prompts', action: 'view' };
}
// Special case: /api/v1/projects/:name/promptrequests → create promptrequests
const projectPromptrequestsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/promptrequests/);
if (projectPromptrequestsMatch?.[1] && method === 'POST') {
return { kind: 'resource', resource: 'promptrequests', action: 'create' };
}
// Special case: /api/v1/projects/:id/instructions → view projects
const instructionsMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/instructions/);
if (instructionsMatch?.[1]) {
return { kind: 'resource', resource: 'projects', action: 'view', resourceName: instructionsMatch[1] };
}
// Special case: /api/v1/projects/:id/mcp-config → requires 'expose' permission
const mcpConfigMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/mcp-config/);
if (mcpConfigMatch?.[1]) {
return { kind: 'resource', resource: 'projects', action: 'expose', resourceName: mcpConfigMatch[1] };
}
// Special case: /api/v1/projects/:id/servers — attach/detach requires 'edit'
const projectServersMatch = url.match(/^\/api\/v1\/projects\/([^/?]+)\/servers/);
if (projectServersMatch?.[1] && method !== 'GET') {
return { kind: 'resource', resource: 'projects', action: 'edit', resourceName: projectServersMatch[1] };
}
// Map HTTP method to action
let action: RbacAction;
switch (method) {
case 'GET':
case 'HEAD':
action = 'view';
break;
case 'POST':
action = 'create';
break;
case 'DELETE':
action = 'delete';
break;
default: // PUT, PATCH
action = 'edit';
break;
}
// Extract resource name/ID from URL (3rd segment: /api/v1/servers/:nameOrId)
const nameMatch = url.match(/^\/api\/v1\/[a-z-]+\/([^/?]+)/);
const resourceName = nameMatch?.[1];
const check: PermissionCheck = { kind: 'resource', resource, action };
if (resourceName !== undefined) (check as { resourceName: string }).resourceName = resourceName;
return check;
}
/**
* Migrate legacy 'admin' role bindings → granular roles.
* Old format: { role: 'admin', resource: '*' }
* New format: { role: 'edit', resource: '*' }, { role: 'run', resource: '*' },
* plus operation bindings for impersonate, logs, backup, restore, audit-purge
*/
async function migrateAdminRole(rbacRepo: InstanceType<typeof RbacDefinitionRepository>): Promise<void> {
const definitions = await rbacRepo.findAll();
for (const def of definitions) {
const bindings = def.roleBindings as Array<Record<string, unknown>>;
const hasAdminRole = bindings.some((b) => b['role'] === 'admin');
if (!hasAdminRole) continue;
// Replace admin bindings with granular equivalents
const newBindings: Array<Record<string, string>> = [];
for (const b of bindings) {
if (b['role'] === 'admin') {
const resource = b['resource'] as string;
newBindings.push({ role: 'edit', resource });
newBindings.push({ role: 'run', resource });
} else {
newBindings.push(b as Record<string, string>);
}
}
// Add operation bindings (idempotent — only for wildcard admin)
const hasWildcard = bindings.some((b) => b['role'] === 'admin' && b['resource'] === '*');
if (hasWildcard) {
const ops = ['impersonate', 'logs', 'backup', 'restore', 'audit-purge'];
for (const op of ops) {
if (!newBindings.some((b) => b['action'] === op)) {
newBindings.push({ role: 'run', action: op });
}
}
}
await rbacRepo.update(def.id, { roleBindings: newBindings as UpdateRbacDefinitionInput['roleBindings'] });
// eslint-disable-next-line no-console
console.log(`mcpd: migrated RBAC '${def.name}' from admin → granular roles`);
}
}
async function main(): Promise<void> {
const config = loadConfigFromEnv();
@@ -45,31 +213,80 @@ async function main(): Promise<void> {
});
await prisma.$connect();
// Seed default servers (upsert, safe to repeat)
await seedMcpServers(prisma);
// Seed templates from YAML files
const templatesDir = process.env.TEMPLATES_DIR ?? 'templates';
const templateFiles = (() => {
try {
return readdirSync(templatesDir).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
} catch {
return [];
}
})();
const templates: SeedTemplate[] = templateFiles.map((f) => {
const content = readFileSync(join(templatesDir, f), 'utf-8');
const parsed = yaml.load(content) as SeedTemplate;
return {
...parsed,
transport: parsed.transport ?? 'STDIO',
version: parsed.version ?? '1.0.0',
description: parsed.description ?? '',
...(parsed.healthCheck ? { healthCheck: parsed.healthCheck } : {}),
};
});
await seedTemplates(prisma, templates);
// Repositories
const serverRepo = new McpServerRepository(prisma);
const profileRepo = new McpProfileRepository(prisma);
const secretRepo = new SecretRepository(prisma);
const instanceRepo = new McpInstanceRepository(prisma);
const projectRepo = new ProjectRepository(prisma);
const auditLogRepo = new AuditLogRepository(prisma);
const templateRepo = new TemplateRepository(prisma);
const rbacDefinitionRepo = new RbacDefinitionRepository(prisma);
const userRepo = new UserRepository(prisma);
const groupRepo = new GroupRepository(prisma);
// CUID detection for RBAC name resolution
const CUID_RE = /^c[^\s-]{8,}$/i;
const nameResolvers: Record<string, { findById(id: string): Promise<{ name: string } | null> }> = {
servers: serverRepo,
secrets: secretRepo,
projects: projectRepo,
groups: groupRepo,
};
// Migrate legacy 'admin' role → granular roles
await migrateAdminRole(rbacDefinitionRepo);
// Orchestrator
const orchestrator = new DockerContainerManager();
// Services
const serverService = new McpServerService(serverRepo);
const profileService = new McpProfileService(profileRepo, serverRepo);
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator);
const projectService = new ProjectService(projectRepo, profileRepo, serverRepo);
const instanceService = new InstanceService(instanceRepo, serverRepo, orchestrator, secretRepo);
serverService.setInstanceService(instanceService);
const secretService = new SecretService(secretRepo);
const projectService = new ProjectService(projectRepo, serverRepo, secretRepo);
const auditLogService = new AuditLogService(auditLogRepo);
const metricsCollector = new MetricsCollector();
const healthAggregator = new HealthAggregator(metricsCollector, orchestrator);
const backupService = new BackupService(serverRepo, profileRepo, projectRepo);
const restoreService = new RestoreService(serverRepo, profileRepo, projectRepo);
const backupService = new BackupService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
const restoreService = new RestoreService(serverRepo, projectRepo, secretRepo, userRepo, groupRepo, rbacDefinitionRepo);
const authService = new AuthService(prisma);
const mcpProxyService = new McpProxyService(instanceRepo);
const templateService = new TemplateService(templateRepo);
const mcpProxyService = new McpProxyService(instanceRepo, serverRepo, orchestrator);
const rbacDefinitionService = new RbacDefinitionService(rbacDefinitionRepo);
const rbacService = new RbacService(rbacDefinitionRepo, prisma);
const userService = new UserService(userRepo);
const groupService = new GroupService(groupRepo, userRepo);
const promptRepo = new PromptRepository(prisma);
const promptRequestRepo = new PromptRequestRepository(prisma);
const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo);
// Auth middleware for global hooks
const authMiddleware = createAuthMiddleware({
findSession: (token) => authService.findSession(token),
});
// Server
const app = await createServer(config, {
@@ -85,28 +302,120 @@ async function main(): Promise<void> {
},
});
// ── Global auth hook ──
// Runs on all /api/v1/* routes EXCEPT auth endpoints and health checks.
// Tests that use createServer() directly are NOT affected — this hook
// is only registered here in main.ts.
app.addHook('preHandler', async (request, reply) => {
const url = request.url;
// Skip auth for health, auth, and root
if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return;
if (!url.startsWith('/api/v1/')) return;
// Run auth middleware
await authMiddleware(request, reply);
});
// ── Global RBAC hook ──
// Runs after the auth hook. Maps URL to resource+action and checks permissions.
app.addHook('preHandler', async (request, reply) => {
if (reply.sent) return; // Auth hook already rejected
const url = request.url;
if (url.startsWith('/api/v1/auth/') || url === '/healthz' || url === '/health') return;
if (!url.startsWith('/api/v1/')) return;
if (request.userId === undefined) return; // Auth hook will handle 401
const check = mapUrlToPermission(request.method, url);
if (check.kind === 'skip') return;
// Extract service account identity from header (sent by mcplocal)
const saHeader = request.headers['x-service-account'];
const serviceAccountName = typeof saHeader === 'string' ? saHeader : undefined;
let allowed: boolean;
if (check.kind === 'operation') {
allowed = await rbacService.canRunOperation(request.userId, check.operation, serviceAccountName);
} else {
// Resolve CUID → human name for name-scoped RBAC bindings
if (check.resourceName !== undefined && CUID_RE.test(check.resourceName)) {
const resolver = nameResolvers[check.resource];
if (resolver) {
const entity = await resolver.findById(check.resourceName);
if (entity) check.resourceName = entity.name;
}
}
allowed = await rbacService.canAccess(request.userId, check.action, check.resource, check.resourceName, serviceAccountName);
// Compute scope for list filtering (used by preSerialization hook)
if (allowed && check.resourceName === undefined) {
request.rbacScope = await rbacService.getAllowedScope(request.userId, check.action, check.resource, serviceAccountName);
}
}
if (!allowed) {
reply.code(403).send({ error: 'Forbidden' });
}
});
// Routes
registerMcpServerRoutes(app, serverService);
registerMcpProfileRoutes(app, profileService);
registerMcpServerRoutes(app, serverService, instanceService);
registerTemplateRoutes(app, templateService);
registerSecretRoutes(app, secretService);
registerInstanceRoutes(app, instanceService);
registerProjectRoutes(app, projectService);
registerAuditLogRoutes(app, auditLogService);
registerHealthMonitoringRoutes(app, { healthAggregator, metricsCollector });
registerBackupRoutes(app, { backupService, restoreService });
registerAuthRoutes(app, { authService });
registerAuthRoutes(app, { authService, userService, groupService, rbacDefinitionService, rbacService });
registerMcpProxyRoutes(app, {
mcpProxyService,
auditLogService,
authDeps: { findSession: (token) => authService.findSession(token) },
});
registerRbacRoutes(app, rbacDefinitionService);
registerUserRoutes(app, userService);
registerGroupRoutes(app, groupService);
registerPromptRoutes(app, promptService, projectRepo);
// ── RBAC list filtering hook ──
// Filters array responses to only include resources the user is allowed to see.
app.addHook('preSerialization', async (request, _reply, payload) => {
if (!request.rbacScope || request.rbacScope.wildcard) return payload;
if (!Array.isArray(payload)) return payload;
return (payload as Array<Record<string, unknown>>).filter((item) => {
const name = item['name'];
return typeof name === 'string' && request.rbacScope!.names.has(name);
});
});
// Start
await app.listen({ port: config.port, host: config.host });
app.log.info(`mcpd listening on ${config.host}:${config.port}`);
// Periodic container liveness sync — detect crashed containers
const SYNC_INTERVAL_MS = 30_000; // 30s
const syncTimer = setInterval(async () => {
try {
await instanceService.syncStatus();
} catch (err) {
app.log.error({ err }, 'Container status sync failed');
}
}, SYNC_INTERVAL_MS);
// Health probe runner — periodic MCP tool-call probes (like k8s livenessProbe)
const healthProbeRunner = new HealthProbeRunner(
instanceRepo,
serverRepo,
orchestrator,
{ info: (msg) => app.log.info(msg), error: (obj, msg) => app.log.error(obj, msg) },
);
healthProbeRunner.start(15_000);
// Graceful shutdown
setupGracefulShutdown(app, {
disconnectDb: () => prisma.$disconnect(),
disconnectDb: async () => {
clearInterval(syncTimer);
healthProbeRunner.stop();
await prisma.$disconnect();
},
});
}

View File

@@ -7,6 +7,7 @@ export interface AuthDeps {
declare module 'fastify' {
interface FastifyRequest {
userId?: string;
rbacScope?: { wildcard: boolean; names: Set<string> };
}
}

View File

@@ -0,0 +1,36 @@
import type { FastifyRequest, FastifyReply } from 'fastify';
import type { RbacService, RbacAction } from '../services/rbac.service.js';
export function createRbacMiddleware(rbacService: RbacService) {
function requirePermission(resource: string, action: RbacAction, resourceName?: string) {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (request.userId === undefined) {
reply.code(401).send({ error: 'Authentication required' });
return;
}
const allowed = await rbacService.canAccess(request.userId, action, resource, resourceName);
if (!allowed) {
reply.code(403).send({ error: 'Forbidden' });
return;
}
};
}
function requireOperation(operation: string) {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (request.userId === undefined) {
reply.code(401).send({ error: 'Authentication required' });
return;
}
const allowed = await rbacService.canRunOperation(request.userId, operation);
if (!allowed) {
reply.code(403).send({ error: 'Forbidden' });
return;
}
};
}
return { requirePermission, requireOperation };
}

View File

@@ -0,0 +1,93 @@
import type { PrismaClient, Group } from '@prisma/client';
export interface GroupWithMembers extends Group {
members: Array<{ id: string; user: { id: string; email: string; name: string | null } }>;
}
export interface IGroupRepository {
findAll(): Promise<GroupWithMembers[]>;
findById(id: string): Promise<GroupWithMembers | null>;
findByName(name: string): Promise<GroupWithMembers | null>;
create(data: { name: string; description?: string }): Promise<Group>;
update(id: string, data: { description?: string }): Promise<Group>;
delete(id: string): Promise<void>;
setMembers(groupId: string, userIds: string[]): Promise<void>;
findGroupsForUser(userId: string): Promise<Array<{ id: string; name: string }>>;
}
const MEMBERS_INCLUDE = {
members: {
select: {
id: true,
user: {
select: { id: true, email: true, name: true },
},
},
},
} as const;
export class GroupRepository implements IGroupRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<GroupWithMembers[]> {
return this.prisma.group.findMany({
orderBy: { name: 'asc' },
include: MEMBERS_INCLUDE,
});
}
async findById(id: string): Promise<GroupWithMembers | null> {
return this.prisma.group.findUnique({
where: { id },
include: MEMBERS_INCLUDE,
});
}
async findByName(name: string): Promise<GroupWithMembers | null> {
return this.prisma.group.findUnique({
where: { name },
include: MEMBERS_INCLUDE,
});
}
async create(data: { name: string; description?: string }): Promise<Group> {
const createData: Record<string, unknown> = { name: data.name };
if (data.description !== undefined) createData['description'] = data.description;
return this.prisma.group.create({
data: createData as Parameters<PrismaClient['group']['create']>[0]['data'],
});
}
async update(id: string, data: { description?: string }): Promise<Group> {
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
return this.prisma.group.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.group.delete({ where: { id } });
}
async setMembers(groupId: string, userIds: string[]): Promise<void> {
await this.prisma.$transaction(async (tx) => {
await tx.groupMember.deleteMany({ where: { groupId } });
if (userIds.length > 0) {
await tx.groupMember.createMany({
data: userIds.map((userId) => ({ groupId, userId })),
});
}
});
}
async findGroupsForUser(userId: string): Promise<Array<{ id: string; name: string }>> {
const memberships = await this.prisma.groupMember.findMany({
where: { userId },
select: {
group: {
select: { id: true, name: true },
},
},
});
return memberships.map((m) => m.group);
}
}

View File

@@ -1,7 +1,15 @@
export type { IMcpServerRepository, IMcpProfileRepository, IMcpInstanceRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js';
export type { IMcpServerRepository, IMcpInstanceRepository, ISecretRepository, IAuditLogRepository, AuditLogFilter } from './interfaces.js';
export { McpServerRepository } from './mcp-server.repository.js';
export { McpProfileRepository } from './mcp-profile.repository.js';
export type { IProjectRepository } from './project.repository.js';
export { SecretRepository } from './secret.repository.js';
export type { IProjectRepository, ProjectWithRelations } from './project.repository.js';
export { ProjectRepository } from './project.repository.js';
export { McpInstanceRepository } from './mcp-instance.repository.js';
export { AuditLogRepository } from './audit-log.repository.js';
export type { ITemplateRepository } from './template.repository.js';
export { TemplateRepository } from './template.repository.js';
export type { IRbacDefinitionRepository } from './rbac-definition.repository.js';
export { RbacDefinitionRepository } from './rbac-definition.repository.js';
export type { IUserRepository, SafeUser } from './user.repository.js';
export { UserRepository } from './user.repository.js';
export type { IGroupRepository, GroupWithMembers } from './group.repository.js';
export { GroupRepository } from './group.repository.js';

View File

@@ -1,6 +1,6 @@
import type { McpServer, McpProfile, McpInstance, AuditLog, InstanceStatus } from '@prisma/client';
import type { McpServer, McpInstance, AuditLog, Secret, InstanceStatus } from '@prisma/client';
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
import type { CreateMcpProfileInput, UpdateMcpProfileInput } from '../validation/mcp-profile.schema.js';
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
export interface IMcpServerRepository {
findAll(): Promise<McpServer[]>;
@@ -16,16 +16,16 @@ export interface IMcpInstanceRepository {
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>;
updateStatus(id: string, status: InstanceStatus, fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown>; healthStatus?: string; lastHealthCheck?: Date; events?: unknown[] }): Promise<McpInstance>;
delete(id: string): Promise<void>;
}
export interface IMcpProfileRepository {
findAll(serverId?: string): Promise<McpProfile[]>;
findById(id: string): Promise<McpProfile | null>;
findByServerAndName(serverId: string, name: string): Promise<McpProfile | null>;
create(data: CreateMcpProfileInput): Promise<McpProfile>;
update(id: string, data: UpdateMcpProfileInput): Promise<McpProfile>;
export interface ISecretRepository {
findAll(): Promise<Secret[]>;
findById(id: string): Promise<Secret | null>;
findByName(name: string): Promise<Secret | null>;
create(data: CreateSecretInput): Promise<Secret>;
update(id: string, data: UpdateSecretInput): Promise<Secret>;
delete(id: string): Promise<void>;
}

View File

@@ -11,12 +11,16 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
}
return this.prisma.mcpInstance.findMany({
where,
include: { server: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
});
}
async findById(id: string): Promise<McpInstance | null> {
return this.prisma.mcpInstance.findUnique({ where: { id } });
return this.prisma.mcpInstance.findUnique({
where: { id },
include: { server: { select: { name: true } } },
});
}
async findByContainerId(containerId: string): Promise<McpInstance | null> {
@@ -44,7 +48,7 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
async updateStatus(
id: string,
status: InstanceStatus,
fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown> },
fields?: { containerId?: string; port?: number; metadata?: Record<string, unknown>; healthStatus?: string; lastHealthCheck?: Date; events?: unknown[] },
): Promise<McpInstance> {
const updateData: Prisma.McpInstanceUpdateInput = {
status,
@@ -59,6 +63,15 @@ export class McpInstanceRepository implements IMcpInstanceRepository {
if (fields?.metadata !== undefined) {
updateData.metadata = fields.metadata as Prisma.InputJsonValue;
}
if (fields?.healthStatus !== undefined) {
updateData.healthStatus = fields.healthStatus;
}
if (fields?.lastHealthCheck !== undefined) {
updateData.lastHealthCheck = fields.lastHealthCheck;
}
if (fields?.events !== undefined) {
updateData.events = fields.events as unknown as Prisma.InputJsonValue;
}
return this.prisma.mcpInstance.update({
where: { id },
data: updateData,

View File

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

View File

@@ -1,4 +1,4 @@
import type { PrismaClient, McpServer } from '@prisma/client';
import { type PrismaClient, type McpServer, Prisma } from '@prisma/client';
import type { IMcpServerRepository } from './interfaces.js';
import type { CreateMcpServerInput, UpdateMcpServerInput } from '../validation/mcp-server.schema.js';
@@ -26,7 +26,12 @@ export class McpServerRepository implements IMcpServerRepository {
dockerImage: data.dockerImage ?? null,
transport: data.transport,
repositoryUrl: data.repositoryUrl ?? null,
envTemplate: data.envTemplate,
externalUrl: data.externalUrl ?? null,
command: data.command ?? Prisma.DbNull,
containerPort: data.containerPort ?? null,
replicas: data.replicas,
env: data.env,
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
},
});
}
@@ -38,7 +43,12 @@ export class McpServerRepository implements IMcpServerRepository {
if (data.dockerImage !== undefined) updateData['dockerImage'] = data.dockerImage;
if (data.transport !== undefined) updateData['transport'] = data.transport;
if (data.repositoryUrl !== undefined) updateData['repositoryUrl'] = data.repositoryUrl;
if (data.envTemplate !== undefined) updateData['envTemplate'] = data.envTemplate;
if (data.externalUrl !== undefined) updateData['externalUrl'] = data.externalUrl;
if (data.command !== undefined) updateData['command'] = data.command;
if (data.containerPort !== undefined) updateData['containerPort'] = data.containerPort;
if (data.replicas !== undefined) updateData['replicas'] = data.replicas;
if (data.env !== undefined) updateData['env'] = data.env;
if (data.healthCheck !== undefined) updateData['healthCheck'] = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
return this.prisma.mcpServer.update({ where: { id }, data: updateData });
}

View File

@@ -1,69 +1,92 @@
import type { PrismaClient, Project } from '@prisma/client';
import type { CreateProjectInput, UpdateProjectInput } from '../validation/project.schema.js';
export interface ProjectWithRelations extends Project {
servers: Array<{ id: string; projectId: string; serverId: string; server: Record<string, unknown> & { id: string; name: string } }>;
}
const PROJECT_INCLUDE = {
servers: { include: { server: true } },
} as const;
export interface IProjectRepository {
findAll(ownerId?: string): Promise<Project[]>;
findById(id: string): Promise<Project | null>;
findByName(name: string): Promise<Project | null>;
create(data: CreateProjectInput & { ownerId: string }): Promise<Project>;
update(id: string, data: UpdateProjectInput): Promise<Project>;
findAll(ownerId?: string): Promise<ProjectWithRelations[]>;
findById(id: string): Promise<ProjectWithRelations | null>;
findByName(name: string): Promise<ProjectWithRelations | null>;
create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations>;
update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations>;
delete(id: string): Promise<void>;
setProfiles(projectId: string, profileIds: string[]): Promise<void>;
getProfileIds(projectId: string): Promise<string[]>;
setServers(projectId: string, serverIds: string[]): Promise<void>;
addServer(projectId: string, serverId: string): Promise<void>;
removeServer(projectId: string, serverId: string): Promise<void>;
}
export class ProjectRepository implements IProjectRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(ownerId?: string): Promise<Project[]> {
async findAll(ownerId?: string): Promise<ProjectWithRelations[]> {
const where = ownerId !== undefined ? { ownerId } : {};
return this.prisma.project.findMany({ where, orderBy: { name: 'asc' } });
return this.prisma.project.findMany({ where, orderBy: { name: 'asc' }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations[]>;
}
async findById(id: string): Promise<Project | null> {
return this.prisma.project.findUnique({ where: { id } });
async findById(id: string): Promise<ProjectWithRelations | null> {
return this.prisma.project.findUnique({ where: { id }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>;
}
async findByName(name: string): Promise<Project | null> {
return this.prisma.project.findUnique({ where: { name } });
async findByName(name: string): Promise<ProjectWithRelations | null> {
return this.prisma.project.findUnique({ where: { name }, include: PROJECT_INCLUDE }) as unknown as Promise<ProjectWithRelations | null>;
}
async create(data: CreateProjectInput & { ownerId: string }): Promise<Project> {
return this.prisma.project.create({
data: {
async create(data: { name: string; description: string; prompt?: string; ownerId: string; proxyMode: string; llmProvider?: string; llmModel?: string }): Promise<ProjectWithRelations> {
const createData: Record<string, unknown> = {
name: data.name,
description: data.description,
ownerId: data.ownerId,
},
});
proxyMode: data.proxyMode,
};
if (data.prompt !== undefined) createData['prompt'] = data.prompt;
if (data.llmProvider !== undefined) createData['llmProvider'] = data.llmProvider;
if (data.llmModel !== undefined) createData['llmModel'] = data.llmModel;
return this.prisma.project.create({
data: createData as Parameters<PrismaClient['project']['create']>[0]['data'],
include: PROJECT_INCLUDE,
}) as unknown as Promise<ProjectWithRelations>;
}
async update(id: string, data: UpdateProjectInput): Promise<Project> {
const updateData: Record<string, unknown> = {};
if (data.description !== undefined) updateData['description'] = data.description;
return this.prisma.project.update({ where: { id }, data: updateData });
async update(id: string, data: Record<string, unknown>): Promise<ProjectWithRelations> {
return this.prisma.project.update({
where: { id },
data,
include: PROJECT_INCLUDE,
}) as unknown as Promise<ProjectWithRelations>;
}
async delete(id: string): Promise<void> {
await this.prisma.project.delete({ where: { id } });
}
async setProfiles(projectId: string, profileIds: string[]): Promise<void> {
await this.prisma.$transaction([
this.prisma.projectMcpProfile.deleteMany({ where: { projectId } }),
...profileIds.map((profileId) =>
this.prisma.projectMcpProfile.create({
data: { projectId, profileId },
}),
),
]);
async setServers(projectId: string, serverIds: string[]): Promise<void> {
await this.prisma.$transaction(async (tx) => {
await tx.projectServer.deleteMany({ where: { projectId } });
if (serverIds.length > 0) {
await tx.projectServer.createMany({
data: serverIds.map((serverId) => ({ projectId, serverId })),
});
}
});
}
async getProfileIds(projectId: string): Promise<string[]> {
const links = await this.prisma.projectMcpProfile.findMany({
where: { projectId },
select: { profileId: true },
async addServer(projectId: string, serverId: string): Promise<void> {
await this.prisma.projectServer.upsert({
where: { projectId_serverId: { projectId, serverId } },
create: { projectId, serverId },
update: {},
});
}
async removeServer(projectId: string, serverId: string): Promise<void> {
await this.prisma.projectServer.deleteMany({
where: { projectId, serverId },
});
return links.map((l) => l.profileId);
}
}

View File

@@ -0,0 +1,53 @@
import type { PrismaClient, PromptRequest } from '@prisma/client';
export interface IPromptRequestRepository {
findAll(projectId?: string): Promise<PromptRequest[]>;
findById(id: string): Promise<PromptRequest | null>;
findByNameAndProject(name: string, projectId: string | null): Promise<PromptRequest | null>;
findBySession(sessionId: string, projectId?: string): Promise<PromptRequest[]>;
create(data: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string }): Promise<PromptRequest>;
delete(id: string): Promise<void>;
}
export class PromptRequestRepository implements IPromptRequestRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(projectId?: string): Promise<PromptRequest[]> {
if (projectId !== undefined) {
return this.prisma.promptRequest.findMany({
where: { OR: [{ projectId }, { projectId: null }] },
orderBy: { createdAt: 'desc' },
});
}
return this.prisma.promptRequest.findMany({ orderBy: { createdAt: 'desc' } });
}
async findById(id: string): Promise<PromptRequest | null> {
return this.prisma.promptRequest.findUnique({ where: { id } });
}
async findByNameAndProject(name: string, projectId: string | null): Promise<PromptRequest | null> {
return this.prisma.promptRequest.findUnique({
where: { name_projectId: { name, projectId: projectId ?? '' } },
});
}
async findBySession(sessionId: string, projectId?: string): Promise<PromptRequest[]> {
const where: Record<string, unknown> = { createdBySession: sessionId };
if (projectId !== undefined) {
where['OR'] = [{ projectId }, { projectId: null }];
}
return this.prisma.promptRequest.findMany({
where,
orderBy: { createdAt: 'desc' },
});
}
async create(data: { name: string; content: string; projectId?: string; createdBySession?: string; createdByUserId?: string }): Promise<PromptRequest> {
return this.prisma.promptRequest.create({ data });
}
async delete(id: string): Promise<void> {
await this.prisma.promptRequest.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,47 @@
import type { PrismaClient, Prompt } from '@prisma/client';
export interface IPromptRepository {
findAll(projectId?: string): Promise<Prompt[]>;
findById(id: string): Promise<Prompt | null>;
findByNameAndProject(name: string, projectId: string | null): Promise<Prompt | null>;
create(data: { name: string; content: string; projectId?: string }): Promise<Prompt>;
update(id: string, data: { content?: string }): Promise<Prompt>;
delete(id: string): Promise<void>;
}
export class PromptRepository implements IPromptRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(projectId?: string): Promise<Prompt[]> {
if (projectId !== undefined) {
// Project-scoped + global prompts
return this.prisma.prompt.findMany({
where: { OR: [{ projectId }, { projectId: null }] },
orderBy: { name: 'asc' },
});
}
return this.prisma.prompt.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<Prompt | null> {
return this.prisma.prompt.findUnique({ where: { id } });
}
async findByNameAndProject(name: string, projectId: string | null): Promise<Prompt | null> {
return this.prisma.prompt.findUnique({
where: { name_projectId: { name, projectId: projectId ?? '' } },
});
}
async create(data: { name: string; content: string; projectId?: string }): Promise<Prompt> {
return this.prisma.prompt.create({ data });
}
async update(id: string, data: { content?: string }): Promise<Prompt> {
return this.prisma.prompt.update({ where: { id }, data });
}
async delete(id: string): Promise<void> {
await this.prisma.prompt.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,48 @@
import type { PrismaClient, RbacDefinition } from '@prisma/client';
import type { CreateRbacDefinitionInput, UpdateRbacDefinitionInput } from '../validation/rbac-definition.schema.js';
export interface IRbacDefinitionRepository {
findAll(): Promise<RbacDefinition[]>;
findById(id: string): Promise<RbacDefinition | null>;
findByName(name: string): Promise<RbacDefinition | null>;
create(data: CreateRbacDefinitionInput): Promise<RbacDefinition>;
update(id: string, data: UpdateRbacDefinitionInput): Promise<RbacDefinition>;
delete(id: string): Promise<void>;
}
export class RbacDefinitionRepository implements IRbacDefinitionRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<RbacDefinition[]> {
return this.prisma.rbacDefinition.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<RbacDefinition | null> {
return this.prisma.rbacDefinition.findUnique({ where: { id } });
}
async findByName(name: string): Promise<RbacDefinition | null> {
return this.prisma.rbacDefinition.findUnique({ where: { name } });
}
async create(data: CreateRbacDefinitionInput): Promise<RbacDefinition> {
return this.prisma.rbacDefinition.create({
data: {
name: data.name,
subjects: data.subjects,
roleBindings: data.roleBindings,
},
});
}
async update(id: string, data: UpdateRbacDefinitionInput): Promise<RbacDefinition> {
const updateData: Record<string, unknown> = {};
if (data.subjects !== undefined) updateData['subjects'] = data.subjects;
if (data.roleBindings !== undefined) updateData['roleBindings'] = data.roleBindings;
return this.prisma.rbacDefinition.update({ where: { id }, data: updateData });
}
async delete(id: string): Promise<void> {
await this.prisma.rbacDefinition.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,39 @@
import { type PrismaClient, type Secret } from '@prisma/client';
import type { ISecretRepository } from './interfaces.js';
import type { CreateSecretInput, UpdateSecretInput } from '../validation/secret.schema.js';
export class SecretRepository implements ISecretRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<Secret[]> {
return this.prisma.secret.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<Secret | null> {
return this.prisma.secret.findUnique({ where: { id } });
}
async findByName(name: string): Promise<Secret | null> {
return this.prisma.secret.findUnique({ where: { name } });
}
async create(data: CreateSecretInput): Promise<Secret> {
return this.prisma.secret.create({
data: {
name: data.name,
data: data.data,
},
});
}
async update(id: string, data: UpdateSecretInput): Promise<Secret> {
return this.prisma.secret.update({
where: { id },
data: { data: data.data },
});
}
async delete(id: string): Promise<void> {
await this.prisma.secret.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,82 @@
import { type PrismaClient, type McpTemplate, Prisma } from '@prisma/client';
import type { CreateTemplateInput, UpdateTemplateInput } from '../validation/template.schema.js';
export interface ITemplateRepository {
findAll(): Promise<McpTemplate[]>;
findById(id: string): Promise<McpTemplate | null>;
findByName(name: string): Promise<McpTemplate | null>;
search(pattern: string): Promise<McpTemplate[]>;
create(data: CreateTemplateInput): Promise<McpTemplate>;
update(id: string, data: UpdateTemplateInput): Promise<McpTemplate>;
delete(id: string): Promise<void>;
}
export class TemplateRepository implements ITemplateRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<McpTemplate[]> {
return this.prisma.mcpTemplate.findMany({ orderBy: { name: 'asc' } });
}
async findById(id: string): Promise<McpTemplate | null> {
return this.prisma.mcpTemplate.findUnique({ where: { id } });
}
async findByName(name: string): Promise<McpTemplate | null> {
return this.prisma.mcpTemplate.findUnique({ where: { name } });
}
async search(pattern: string): Promise<McpTemplate[]> {
// Convert glob * to SQL %
const sqlPattern = pattern.replace(/\*/g, '%');
return this.prisma.mcpTemplate.findMany({
where: { name: { contains: sqlPattern.replace(/%/g, ''), mode: 'insensitive' } },
orderBy: { name: 'asc' },
});
}
async create(data: CreateTemplateInput): Promise<McpTemplate> {
return this.prisma.mcpTemplate.create({
data: {
name: data.name,
version: data.version,
description: data.description,
packageName: data.packageName ?? null,
dockerImage: data.dockerImage ?? null,
transport: data.transport,
repositoryUrl: data.repositoryUrl ?? null,
externalUrl: data.externalUrl ?? null,
command: (data.command ?? Prisma.JsonNull) as Prisma.InputJsonValue,
containerPort: data.containerPort ?? null,
replicas: data.replicas,
env: (data.env ?? []) as unknown as Prisma.InputJsonValue,
healthCheck: (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue,
},
});
}
async update(id: string, data: UpdateTemplateInput): Promise<McpTemplate> {
const updateData: Record<string, unknown> = {};
if (data.version !== undefined) updateData.version = data.version;
if (data.description !== undefined) updateData.description = data.description;
if (data.packageName !== undefined) updateData.packageName = data.packageName;
if (data.dockerImage !== undefined) updateData.dockerImage = data.dockerImage;
if (data.transport !== undefined) updateData.transport = data.transport;
if (data.repositoryUrl !== undefined) updateData.repositoryUrl = data.repositoryUrl;
if (data.externalUrl !== undefined) updateData.externalUrl = data.externalUrl;
if (data.command !== undefined) updateData.command = (data.command ?? Prisma.JsonNull) as Prisma.InputJsonValue;
if (data.containerPort !== undefined) updateData.containerPort = data.containerPort;
if (data.replicas !== undefined) updateData.replicas = data.replicas;
if (data.env !== undefined) updateData.env = (data.env ?? []) as Prisma.InputJsonValue;
if (data.healthCheck !== undefined) updateData.healthCheck = (data.healthCheck ?? Prisma.JsonNull) as Prisma.InputJsonValue;
return this.prisma.mcpTemplate.update({
where: { id },
data: updateData,
});
}
async delete(id: string): Promise<void> {
await this.prisma.mcpTemplate.delete({ where: { id } });
}
}

View File

@@ -0,0 +1,76 @@
import type { PrismaClient, User } from '@prisma/client';
/** User without the passwordHash field — safe for API responses. */
export type SafeUser = Omit<User, 'passwordHash'>;
export interface IUserRepository {
findAll(): Promise<SafeUser[]>;
findById(id: string): Promise<SafeUser | null>;
findByEmail(email: string, includeHash?: boolean): Promise<SafeUser | null> | Promise<User | null>;
create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise<SafeUser>;
delete(id: string): Promise<void>;
count(): Promise<number>;
}
/** Fields to select when passwordHash must be excluded. */
const safeSelect = {
id: true,
email: true,
name: true,
role: true,
provider: true,
externalId: true,
version: true,
createdAt: true,
updatedAt: true,
} as const;
export class UserRepository implements IUserRepository {
constructor(private readonly prisma: PrismaClient) {}
async findAll(): Promise<SafeUser[]> {
return this.prisma.user.findMany({
select: safeSelect,
orderBy: { email: 'asc' },
});
}
async findById(id: string): Promise<SafeUser | null> {
return this.prisma.user.findUnique({
where: { id },
select: safeSelect,
});
}
async findByEmail(email: string, includeHash?: boolean): Promise<User | SafeUser | null> {
if (includeHash === true) {
return this.prisma.user.findUnique({ where: { email } });
}
return this.prisma.user.findUnique({
where: { email },
select: safeSelect,
});
}
async create(data: { email: string; passwordHash: string; name?: string; role?: string }): Promise<SafeUser> {
const createData: Record<string, unknown> = {
email: data.email,
passwordHash: data.passwordHash,
};
if (data.name !== undefined) createData['name'] = data.name;
if (data.role !== undefined) createData['role'] = data.role;
return this.prisma.user.create({
data: createData as Parameters<PrismaClient['user']['create']>[0]['data'],
select: safeSelect,
});
}
async delete(id: string): Promise<void> {
await this.prisma.user.delete({ where: { id } });
}
async count(): Promise<number> {
return this.prisma.user.count();
}
}

View File

@@ -1,15 +1,76 @@
import type { FastifyInstance } from 'fastify';
import type { AuthService } from '../services/auth.service.js';
import type { UserService } from '../services/user.service.js';
import type { GroupService } from '../services/group.service.js';
import type { RbacDefinitionService } from '../services/rbac-definition.service.js';
import type { RbacService } from '../services/rbac.service.js';
import { createAuthMiddleware } from '../middleware/auth.js';
import { createRbacMiddleware } from '../middleware/rbac.js';
export interface AuthRouteDeps {
authService: AuthService;
userService: UserService;
groupService: GroupService;
rbacDefinitionService: RbacDefinitionService;
rbacService: RbacService;
}
export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): void {
const authMiddleware = createAuthMiddleware({
findSession: (token) => deps.authService.findSession(token),
});
const { requireOperation } = createRbacMiddleware(deps.rbacService);
// GET /api/v1/auth/status — unauthenticated, returns whether any users exist
app.get('/api/v1/auth/status', async () => {
const count = await deps.userService.count();
return { hasUsers: count > 0 };
});
// POST /api/v1/auth/bootstrap — only works when no users exist (first-run setup)
app.post('/api/v1/auth/bootstrap', async (request, reply) => {
const count = await deps.userService.count();
if (count > 0) {
reply.code(409).send({ error: 'Users already exist. Use login instead.' });
return;
}
const { email, password, name } = request.body as { email: string; password: string; name?: string };
// Create the first admin user
await deps.userService.create({
email,
password,
...(name !== undefined ? { name } : {}),
});
// Create "admin" group and add the first user to it
await deps.groupService.create({
name: 'admin',
description: 'Bootstrap admin group',
members: [email],
});
// Create bootstrap RBAC: full resource access + all operations
await deps.rbacDefinitionService.create({
name: 'bootstrap-admin',
subjects: [{ kind: 'Group', name: 'admin' }],
roleBindings: [
{ role: 'edit', resource: '*' },
{ role: 'run', resource: '*' },
{ role: 'run', action: 'impersonate' },
{ role: 'run', action: 'logs' },
{ role: 'run', action: 'backup' },
{ role: 'run', action: 'restore' },
{ role: 'run', action: 'audit-purge' },
],
});
// Auto-login so the caller gets a token immediately
const session = await deps.authService.login(email, password);
reply.code(201);
return session;
});
// POST /api/v1/auth/login — no auth required
app.post<{
@@ -28,4 +89,15 @@ export function registerAuthRoutes(app: FastifyInstance, deps: AuthRouteDeps): v
await deps.authService.logout(token);
return { success: true };
});
// POST /api/v1/auth/impersonate — requires auth + run:impersonate operation
app.post(
'/api/v1/auth/impersonate',
{ preHandler: [authMiddleware, requireOperation('impersonate')] },
async (request) => {
const { email } = request.body as { email: string };
const result = await deps.authService.impersonate(email);
return result;
},
);
}

View File

@@ -13,7 +13,7 @@ export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): vo
app.post<{
Body: {
password?: string;
resources?: Array<'servers' | 'profiles' | 'projects'>;
resources?: Array<'servers' | 'secrets' | 'projects' | 'users' | 'groups' | 'rbac'>;
};
}>('/api/v1/backup', async (request) => {
const opts: BackupOptions = {};
@@ -51,7 +51,7 @@ export function registerBackupRoutes(app: FastifyInstance, deps: BackupDeps): vo
const result = await deps.restoreService.restore(bundle, restoreOpts);
if (result.errors.length > 0 && result.serversCreated === 0 && result.profilesCreated === 0 && result.projectsCreated === 0) {
if (result.errors.length > 0 && result.serversCreated === 0 && result.secretsCreated === 0 && result.projectsCreated === 0 && result.usersCreated === 0 && result.groupsCreated === 0 && result.rbacCreated === 0) {
reply.code(422);
}

View File

@@ -0,0 +1,35 @@
import type { FastifyInstance } from 'fastify';
import type { GroupService } from '../services/group.service.js';
export function registerGroupRoutes(
app: FastifyInstance,
service: GroupService,
): void {
app.get('/api/v1/groups', async () => {
return service.list();
});
app.get<{ Params: { id: string } }>('/api/v1/groups/:id', async (request) => {
// Try by ID first, fall back to name lookup
try {
return await service.getById(request.params.id);
} catch {
return service.getByName(request.params.id);
}
});
app.post('/api/v1/groups', async (request, reply) => {
const group = await service.create(request.body);
reply.code(201);
return group;
});
app.put<{ Params: { id: string } }>('/api/v1/groups/:id', async (request) => {
return service.update(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/groups/:id', async (request, reply) => {
await service.delete(request.params.id);
reply.code(204);
});
}

View File

@@ -1,7 +1,7 @@
export { registerHealthRoutes } from './health.js';
export type { HealthDeps } from './health.js';
export { registerMcpServerRoutes } from './mcp-servers.js';
export { registerMcpProfileRoutes } from './mcp-profiles.js';
export { registerSecretRoutes } from './secrets.js';
export { registerProjectRoutes } from './projects.js';
export { registerInstanceRoutes } from './instances.js';
export { registerAuditLogRoutes } from './audit-logs.js';
@@ -13,3 +13,7 @@ export { registerAuthRoutes } from './auth.js';
export type { AuthRouteDeps } from './auth.js';
export { registerMcpProxyRoutes } from './mcp-proxy.js';
export type { McpProxyRouteDeps } from './mcp-proxy.js';
export { registerTemplateRoutes } from './templates.js';
export { registerRbacRoutes } from './rbac-definitions.js';
export { registerUserRoutes } from './users.js';
export { registerGroupRoutes } from './groups.js';

View File

@@ -10,40 +10,17 @@ export function registerInstanceRoutes(app: FastifyInstance, service: InstanceSe
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.post<{ Params: { id: string } }>('/api/v1/instances/:id/restart', async (request) => {
return service.restart(request.params.id);
app.delete<{ Params: { id: string } }>('/api/v1/instances/:id', async (request, reply) => {
const { serverId } = await service.remove(request.params.id);
// Reconcile: server will auto-create a replacement if replicas > 0
await service.reconcile(serverId);
reply.code(204);
});
app.get<{ Params: { id: string } }>('/api/v1/instances/:id/inspect', async (request) => {
return service.inspect(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) => {

View File

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

View File

@@ -1,7 +1,12 @@
import type { FastifyInstance } from 'fastify';
import type { McpServerService } from '../services/mcp-server.service.js';
import type { InstanceService } from '../services/instance.service.js';
export function registerMcpServerRoutes(app: FastifyInstance, service: McpServerService): void {
export function registerMcpServerRoutes(
app: FastifyInstance,
service: McpServerService,
instanceService: InstanceService,
): void {
app.get('/api/v1/servers', async () => {
return service.list();
});
@@ -12,12 +17,17 @@ export function registerMcpServerRoutes(app: FastifyInstance, service: McpServer
app.post('/api/v1/servers', async (request, reply) => {
const server = await service.create(request.body);
// Auto-reconcile: create instances to match replicas
await instanceService.reconcile(server.id);
reply.code(201);
return server;
});
app.put<{ Params: { id: string } }>('/api/v1/servers/:id', async (request) => {
return service.update(request.params.id, request.body);
const server = await service.update(request.params.id, request.body);
// Re-reconcile after update (replicas may have changed)
await instanceService.reconcile(server.id);
return server;
});
app.delete<{ Params: { id: string } }>('/api/v1/servers/:id', async (request, reply) => {

View File

@@ -2,13 +2,13 @@ import type { FastifyInstance } from 'fastify';
import type { ProjectService } from '../services/project.service.js';
export function registerProjectRoutes(app: FastifyInstance, service: ProjectService): void {
app.get('/api/v1/projects', async (request) => {
// If authenticated, filter by owner; otherwise list all
return service.list(request.userId);
app.get('/api/v1/projects', async () => {
// RBAC preSerialization hook handles access filtering
return service.list();
});
app.get<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
return service.getById(request.params.id);
return service.resolveAndGet(request.params.id);
});
app.post('/api/v1/projects', async (request, reply) => {
@@ -19,25 +19,51 @@ export function registerProjectRoutes(app: FastifyInstance, service: ProjectServ
});
app.put<{ Params: { id: string } }>('/api/v1/projects/:id', async (request) => {
return service.update(request.params.id, request.body);
const project = await service.resolveAndGet(request.params.id);
return service.update(project.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/projects/:id', async (request, reply) => {
await service.delete(request.params.id);
const project = await service.resolveAndGet(request.params.id);
await service.delete(project.id);
reply.code(204);
});
// Profile associations
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => {
return service.getProfiles(request.params.id);
});
app.put<{ Params: { id: string } }>('/api/v1/projects/:id/profiles', async (request) => {
return service.setProfiles(request.params.id, request.body);
});
// MCP config generation
// Generate .mcp.json for a project
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/mcp-config', async (request) => {
return service.getMcpConfig(request.params.id);
return service.generateMcpConfig(request.params.id);
});
// Attach a server to a project
app.post<{ Params: { id: string }; Body: { server: string } }>('/api/v1/projects/:id/servers', async (request) => {
const body = request.body as { server?: string };
if (!body.server) {
throw Object.assign(new Error('Missing "server" in request body'), { statusCode: 400 });
}
return service.addServer(request.params.id, body.server);
});
// Detach a server from a project
app.delete<{ Params: { id: string; serverName: string } }>('/api/v1/projects/:id/servers/:serverName', async (request, reply) => {
await service.removeServer(request.params.id, request.params.serverName);
reply.code(204);
});
// List servers in a project (for mcplocal discovery)
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/servers', async (request) => {
const project = await service.resolveAndGet(request.params.id);
return project.servers.map((ps) => ps.server);
});
// Get project instructions for LLM (prompt + server list)
app.get<{ Params: { id: string } }>('/api/v1/projects/:id/instructions', async (request) => {
const project = await service.resolveAndGet(request.params.id);
return {
prompt: project.prompt,
servers: project.servers.map((ps) => ({
name: (ps.server as Record<string, unknown>).name as string,
description: (ps.server as Record<string, unknown>).description as string,
})),
};
});
}

View File

@@ -0,0 +1,86 @@
import type { FastifyInstance } from 'fastify';
import type { PromptService } from '../services/prompt.service.js';
import type { IProjectRepository } from '../repositories/project.repository.js';
export function registerPromptRoutes(
app: FastifyInstance,
service: PromptService,
projectRepo: IProjectRepository,
): void {
// ── Prompts (approved) ──
app.get('/api/v1/prompts', async () => {
return service.listPrompts();
});
app.get<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request) => {
return service.getPrompt(request.params.id);
});
app.post('/api/v1/prompts', async (request, reply) => {
const prompt = await service.createPrompt(request.body);
reply.code(201);
return prompt;
});
app.put<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request) => {
return service.updatePrompt(request.params.id, request.body);
});
app.delete<{ Params: { id: string } }>('/api/v1/prompts/:id', async (request, reply) => {
await service.deletePrompt(request.params.id);
reply.code(204);
});
// ── Prompt Requests (pending proposals) ──
app.get('/api/v1/promptrequests', async () => {
return service.listPromptRequests();
});
app.get<{ Params: { id: string } }>('/api/v1/promptrequests/:id', async (request) => {
return service.getPromptRequest(request.params.id);
});
app.delete<{ Params: { id: string } }>('/api/v1/promptrequests/:id', async (request, reply) => {
await service.deletePromptRequest(request.params.id);
reply.code(204);
});
// Approve: atomic delete request → create prompt
app.post<{ Params: { id: string } }>('/api/v1/promptrequests/:id/approve', async (request) => {
return service.approve(request.params.id);
});
// ── Project-scoped endpoints (for mcplocal) ──
// Visible prompts: approved + session's pending requests
app.get<{ Params: { name: string }; Querystring: { session?: string } }>(
'/api/v1/projects/:name/prompts/visible',
async (request) => {
const project = await projectRepo.findByName(request.params.name);
if (!project) {
throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 });
}
return service.getVisiblePrompts(project.id, request.query.session);
},
);
// LLM propose: create a PromptRequest for a project
app.post<{ Params: { name: string } }>(
'/api/v1/projects/:name/promptrequests',
async (request, reply) => {
const project = await projectRepo.findByName(request.params.name);
if (!project) {
throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 });
}
const body = request.body as Record<string, unknown>;
const req = await service.propose({
...body,
projectId: project.id,
});
reply.code(201);
return req;
},
);
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,31 @@
import type { FastifyInstance } from 'fastify';
import type { UserService } from '../services/user.service.js';
export function registerUserRoutes(
app: FastifyInstance,
service: UserService,
): void {
app.get('/api/v1/users', async () => {
return service.list();
});
app.get<{ Params: { id: string } }>('/api/v1/users/:id', async (request) => {
// Support lookup by email (contains @) or by id
const idOrEmail = request.params.id;
if (idOrEmail.includes('@')) {
return service.getByEmail(idOrEmail);
}
return service.getById(idOrEmail);
});
app.post('/api/v1/users', async (request, reply) => {
const user = await service.create(request.body);
reply.code(201);
return user;
});
app.delete<{ Params: { id: string } }>('/api/v1/users/:id', async (_request, reply) => {
await service.delete(_request.params.id);
reply.code(204);
});
}

View File

@@ -1,11 +1,45 @@
import { readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PrismaClient } from '@prisma/client';
import { seedMcpServers } from '@mcpctl/db';
import yaml from 'js-yaml';
import { seedTemplates } from '@mcpctl/db';
import type { SeedTemplate } from '@mcpctl/db';
function loadTemplatesFromDir(dir: string): SeedTemplate[] {
let files: string[];
try {
files = readdirSync(dir).filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
} catch {
console.warn(`Templates directory not found: ${dir}`);
return [];
}
const templates: SeedTemplate[] = [];
for (const file of files) {
const content = readFileSync(join(dir, file), 'utf-8');
const parsed = yaml.load(content) as SeedTemplate;
if (parsed?.name) {
templates.push({
...parsed,
transport: parsed.transport ?? 'STDIO',
version: parsed.version ?? '1.0.0',
description: parsed.description ?? '',
...(parsed.healthCheck ? { healthCheck: parsed.healthCheck } : {}),
});
}
}
return templates;
}
async function run(): Promise<void> {
const prisma = new PrismaClient();
try {
const count = await seedMcpServers(prisma);
console.log(`Seeded ${count} MCP servers`);
// Look for templates in common locations
const templatesDir = process.env.TEMPLATES_DIR ?? 'templates';
const templates = loadTemplatesFromDir(templatesDir);
const count = await seedTemplates(prisma, templates);
console.log(`Seeded ${count} templates from ${templatesDir}`);
} finally {
await prisma.$disconnect();
}

View File

@@ -63,4 +63,32 @@ export class AuthService {
}
return { userId: session.userId, expiresAt: session.expiresAt };
}
/**
* Create a session for a user by email without requiring their password.
* Used for admin impersonation.
*/
async impersonate(email: string): Promise<LoginResult> {
const user = await this.prisma.user.findUnique({ where: { email } });
if (user === null) {
throw new AuthenticationError('User not found');
}
const token = randomUUID();
const expiresAt = new Date(Date.now() + SESSION_TTL_MS);
await this.prisma.session.create({
data: {
token,
userId: user.id,
expiresAt,
},
});
return {
token,
expiresAt,
user: { id: user.id, email: user.email, role: user.role },
};
}
}

View File

@@ -1,5 +1,8 @@
import type { IMcpServerRepository, IMcpProfileRepository } from '../../repositories/interfaces.js';
import type { IMcpServerRepository, ISecretRepository } from '../../repositories/interfaces.js';
import type { IProjectRepository } from '../../repositories/project.repository.js';
import type { IUserRepository } from '../../repositories/user.repository.js';
import type { IGroupRepository } from '../../repositories/group.repository.js';
import type { IRbacDefinitionRepository } from '../../repositories/rbac-definition.repository.js';
import { encrypt, isSensitiveKey } from './crypto.js';
import type { EncryptedPayload } from './crypto.js';
import { APP_VERSION } from '@mcpctl/shared';
@@ -10,8 +13,11 @@ export interface BackupBundle {
createdAt: string;
encrypted: boolean;
servers: BackupServer[];
profiles: BackupProfile[];
secrets: BackupSecret[];
projects: BackupProject[];
users?: BackupUser[];
groups?: BackupGroup[];
rbacBindings?: BackupRbacBinding[];
encryptedSecrets?: EncryptedPayload;
}
@@ -22,40 +28,66 @@ export interface BackupServer {
dockerImage: string | null;
transport: string;
repositoryUrl: string | null;
envTemplate: unknown;
env: unknown;
}
export interface BackupProfile {
export interface BackupSecret {
name: string;
serverName: string;
permissions: unknown;
envOverrides: unknown;
data: Record<string, string>;
}
export interface BackupProject {
name: string;
description: string;
profileNames: string[];
proxyMode?: string;
llmProvider?: string | null;
llmModel?: string | null;
serverNames?: string[];
}
export interface BackupUser {
email: string;
name: string | null;
role: string;
provider: string | null;
}
export interface BackupGroup {
name: string;
description: string;
memberEmails: string[];
}
export interface BackupRbacBinding {
name: string;
subjects: unknown;
roleBindings: unknown;
}
export interface BackupOptions {
password?: string;
resources?: Array<'servers' | 'profiles' | 'projects'>;
resources?: Array<'servers' | 'secrets' | 'projects' | 'users' | 'groups' | 'rbac'>;
}
export class BackupService {
constructor(
private serverRepo: IMcpServerRepository,
private profileRepo: IMcpProfileRepository,
private projectRepo: IProjectRepository,
private secretRepo: ISecretRepository,
private userRepo?: IUserRepository,
private groupRepo?: IGroupRepository,
private rbacRepo?: IRbacDefinitionRepository,
) {}
async createBackup(options?: BackupOptions): Promise<BackupBundle> {
const resources = options?.resources ?? ['servers', 'profiles', 'projects'];
const resources = options?.resources ?? ['servers', 'secrets', 'projects', 'users', 'groups', 'rbac'];
let servers: BackupServer[] = [];
let profiles: BackupProfile[] = [];
let secrets: BackupSecret[] = [];
let projects: BackupProject[] = [];
let users: BackupUser[] = [];
let groups: BackupGroup[] = [];
let rbacBindings: BackupRbacBinding[] = [];
if (resources.includes('servers')) {
const allServers = await this.serverRepo.findAll();
@@ -66,44 +98,56 @@ export class BackupService {
dockerImage: s.dockerImage,
transport: s.transport,
repositoryUrl: s.repositoryUrl,
envTemplate: s.envTemplate,
env: s.env,
}));
}
if (resources.includes('profiles')) {
const allProfiles = await this.profileRepo.findAll();
const serverMap = new Map<string, string>();
const allServers = await this.serverRepo.findAll();
for (const s of allServers) {
serverMap.set(s.id, s.name);
}
profiles = allProfiles.map((p) => ({
name: p.name,
serverName: serverMap.get(p.serverId) ?? p.serverId,
permissions: p.permissions,
envOverrides: p.envOverrides,
if (resources.includes('secrets')) {
const allSecrets = await this.secretRepo.findAll();
secrets = allSecrets.map((s) => ({
name: s.name,
data: s.data as Record<string, string>,
}));
}
if (resources.includes('projects')) {
const allProjects = await this.projectRepo.findAll();
const allProfiles = await this.profileRepo.findAll();
const profileMap = new Map<string, string>();
for (const p of allProfiles) {
profileMap.set(p.id, p.name);
}
projects = await Promise.all(
allProjects.map(async (proj) => {
const profileIds = await this.projectRepo.getProfileIds(proj.id);
return {
projects = allProjects.map((proj) => ({
name: proj.name,
description: proj.description,
profileNames: profileIds.map((id) => profileMap.get(id) ?? id),
};
}),
);
proxyMode: proj.proxyMode,
llmProvider: proj.llmProvider,
llmModel: proj.llmModel,
serverNames: proj.servers.map((ps) => ps.server.name),
}));
}
if (resources.includes('users') && this.userRepo) {
const allUsers = await this.userRepo.findAll();
users = allUsers.map((u) => ({
email: u.email,
name: u.name,
role: u.role,
provider: u.provider,
}));
}
if (resources.includes('groups') && this.groupRepo) {
const allGroups = await this.groupRepo.findAll();
groups = allGroups.map((g) => ({
name: g.name,
description: g.description,
memberEmails: g.members.map((m) => m.user.email),
}));
}
if (resources.includes('rbac') && this.rbacRepo) {
const allRbac = await this.rbacRepo.findAll();
rbacBindings = allRbac.map((r) => ({
name: r.name,
subjects: r.subjects,
roleBindings: r.roleBindings,
}));
}
const bundle: BackupBundle = {
@@ -112,29 +156,29 @@ export class BackupService {
createdAt: new Date().toISOString(),
encrypted: false,
servers,
profiles,
secrets,
projects,
users,
groups,
rbacBindings,
};
if (options?.password) {
// Collect sensitive values and encrypt them
const secrets: Record<string, string> = {};
for (const profile of profiles) {
const overrides = profile.envOverrides as Record<string, string> | null;
if (overrides) {
for (const [key, value] of Object.entries(overrides)) {
if (options?.password && secrets.length > 0) {
// Collect sensitive values from secrets and encrypt them
const sensitiveData: Record<string, string> = {};
for (const secret of secrets) {
for (const [key, value] of Object.entries(secret.data)) {
if (isSensitiveKey(key)) {
const secretKey = `profile:${profile.name}:${key}`;
secrets[secretKey] = value;
(overrides as Record<string, string>)[key] = `__ENCRYPTED:${secretKey}__`;
}
const secretKey = `secret:${secret.name}:${key}`;
sensitiveData[secretKey] = value;
secret.data[key] = `__ENCRYPTED:${secretKey}__`;
}
}
}
if (Object.keys(secrets).length > 0) {
if (Object.keys(sensitiveData).length > 0) {
bundle.encrypted = true;
bundle.encryptedSecrets = encrypt(JSON.stringify(secrets), options.password);
bundle.encryptedSecrets = encrypt(JSON.stringify(sensitiveData), options.password);
}
}

Some files were not shown because too many files have changed in this diff Show More