Repository navigation
Feature/valkey admin mcp server #592
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
nassery318
wants to merge
7
commits into
main
Choose a base branch
from
feature/valkey-admin-mcp-server
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
279248a
implement valkey admin server and configure docker deployment
nassery318 f9329cf
add tests for the valkey admin mcp server
nassery318 b496964
add mcp related docs and update the doc site
nassery318 0c331b8
clean up and organize comments
nassery318 b5782a6
cleanup package-lock.json file
nassery318 4e29014
Use connection JSON as MCP client cache key
nassery318 3b38b5d
Tighten MCP read-only ACL and fix Docker examples in docs
nassery318 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| .git | ||
| **/node_modules | ||
| **/dist | ||
| **/.DS_Store | ||
| **/.env | ||
| apps/server/data | ||
| apps/metrics/data |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| # Valkey Admin MCP Server | ||
|
|
||
| A read-only [MCP](https://modelcontextprotocol.io) server that lets AI assistants such as Claude Code and Cursor read Valkey data through Valkey Admin. | ||
|
|
||
| ``` | ||
| AI assistant --HTTP--> MCP server --websocket--> Valkey Admin server --> Valkey | ||
| ``` | ||
|
|
||
| The MCP server never connects to Valkey directly. It behaves like one more Valkey Admin frontend, and the Valkey Admin server does the Valkey work. | ||
|
|
||
| ## Two ways to choose the Valkey deployment | ||
|
|
||
| | | Targets (Docker and any exposed server) | Headers (local development) | | ||
| |---|---|---| | ||
| | Who chooses the deployment | The operator, with `VALKEY_ADMIN_MCP_TARGETS` | The AI client, with `X-Valkey-*` headers | | ||
| | URL | `/mcp/<target>`, such as `/mcp/prod` | `/mcp` | | ||
| | Valkey credentials | `VALKEY_ADMIN_MCP_USERNAME` and `VALKEY_ADMIN_MCP_PASSWORD`, on the server | Headers, or the `VALKEY_*` variables | | ||
| | `X-Valkey-*` headers | Refused with `400` | Used | | ||
|
|
||
| When `VALKEY_ADMIN_MCP_TARGETS` is set, the MCP server uses targets. Otherwise it uses headers. | ||
|
|
||
| ## Start the server | ||
|
|
||
| Valkey Admin must be running in Web mode. The desktop app is not supported. | ||
|
|
||
| ### Docker | ||
|
|
||
| Set `VALKEY_ADMIN_MCP_ENABLED=true`, a token of at least 32 characters and the targets. The Valkey Admin server starts the MCP server and serves it under `/mcp/` on its own port: | ||
|
|
||
| ```bash | ||
| MCP_TOKEN="$(openssl rand -hex 32)" | ||
| echo "$MCP_TOKEN" # keep it: the assistant sends this token | ||
|
|
||
| docker run -d --name valkey-admin -p 127.0.0.1:8080:8080 \ | ||
| -e VALKEY_ADMIN_MCP_ENABLED=true \ | ||
| -e VALKEY_ADMIN_MCP_TOKEN="$MCP_TOKEN" \ | ||
| -e VALKEY_ADMIN_MCP_TARGETS="prod=valkey-prod:6379,cache=valkeys://valkey-cache:6380" \ | ||
| -e VALKEY_ADMIN_MCP_USERNAME=mcp_readonly \ | ||
| -e VALKEY_ADMIN_MCP_PASSWORD="<password>" \ | ||
| valkey/valkey-admin | ||
| ``` | ||
|
|
||
| The URLs are `http://localhost:8080/mcp/prod` and `http://localhost:8080/mcp/cache`, and every request must send `Authorization: Bearer <token>`, with the token printed above. The server listens on all interfaces in the container, so it does not start the MCP server unless both the token and the targets are set, and logs which one is missing. | ||
|
|
||
| Target hosts are resolved inside the container, where `localhost` is the container itself. Use `host.docker.internal` for Valkey on your machine, or the service name for Valkey in the same Compose project. | ||
|
|
||
| ### Development | ||
|
|
||
| From the repository root, this starts the frontend, the Valkey Admin server and the MCP server together, using headers: | ||
|
|
||
| ```bash | ||
| npm run dev | ||
| ``` | ||
|
|
||
| ### Standalone process | ||
|
|
||
| Build and start only the MCP server. The Valkey Admin server must already be running, and its `VALKEY_ADMIN_ALLOWED_WS_ORIGINS` must include `valkey-admin-mcp://localhost`. | ||
|
|
||
| ```bash | ||
| npm run build:common | ||
| npm run build:mcp | ||
| npm --workspace apps/mcp run start | ||
| ``` | ||
|
|
||
| ### How the Valkey Admin server runs it | ||
|
|
||
| With `VALKEY_ADMIN_MCP_ENABLED=true`, the server starts the MCP server as a child process and gives it a per-launch websocket token, so its origin does not need to be in `VALKEY_ADMIN_ALLOWED_WS_ORIGINS`. The server forwards `/mcp` and `/mcp/*` on its own port to it. A server bound to loopback needs neither the token nor the targets; any other bind, as in Docker, needs both. | ||
|
|
||
| The MCP server itself listens on port `8081` and binds to `127.0.0.1` only. | ||
|
|
||
| ### Environment variables | ||
|
|
||
| | Variable | Default | Purpose | | ||
| |---|---|---| | ||
| | `VALKEY_ADMIN_MCP_ENABLED` | none | Set to `true` on the Valkey Admin server to have it start the MCP server | | ||
| | `VALKEY_ADMIN_MCP_TOKEN` | none | Bearer token callers must send, at least 32 characters | | ||
| | `VALKEY_ADMIN_MCP_TARGETS` | none | Comma-separated `name=host:port` entries. Use `name=valkeys://host:port` for TLS. Names use letters, digits, `-` and `_` | | ||
| | `VALKEY_ADMIN_MCP_USERNAME` | none | Valkey username for every target | | ||
| | `VALKEY_ADMIN_MCP_PASSWORD` | none | Valkey password for every target | | ||
| | `VALKEY_ADMIN_MCP_PORT` | `8081` | Port the MCP server listens on | | ||
| | `VALKEY_ADMIN_URL` | `ws://localhost:8080` | Valkey Admin server websocket | | ||
| | `VALKEY_ADMIN_MCP_ORIGIN` | `valkey-admin-mcp://localhost` | Origin sent to the Valkey Admin server | | ||
| | `VALKEY_ADMIN_WS_TOKEN` | none | Token sent to the Valkey Admin server. Set by the server when it starts the MCP server; do not set it yourself | | ||
| | `VALKEY_HOST` | `localhost` | Header mode: default Valkey host | | ||
| | `VALKEY_PORT` | `6379` | Header mode: default Valkey port | | ||
| | `VALKEY_USERNAME` | none | Header mode: default Valkey username | | ||
| | `VALKEY_PASSWORD` | none | Header mode: default Valkey password | | ||
| | `VALKEY_TLS` | `false` | Header mode: default TLS setting | | ||
|
|
||
| The `VALKEY_*` connection variables apply only in header mode, to requests that send no `X-Valkey-*` headers. When the Valkey Admin server starts the MCP server, the MCP server inherits them from the server's own environment. | ||
|
|
||
| ## Tools | ||
|
|
||
| | Tool | Returns | Parameters | Standalone | Cluster | | ||
| |---|---|---|---|---| | ||
| | `get_server_info` | INFO and memory data | None | Yes | Connected node only | | ||
| | `get_big_keys` | Largest keys by memory | `topN` (default 20), `scanLimit` per node (default 10000) | Yes | All nodes | | ||
| | `get_hot_keys` | Most frequently accessed keys | `count` (default 20) | No | All nodes | | ||
|
|
||
| `get_hot_keys` requires cluster mode, `cluster-slot-stats-enabled yes` and an LFU `maxmemory-policy` such as `allkeys-lfu`. | ||
|
|
||
| `get_big_keys` adds load to each node it scans. `scanLimit` applies per node, so a three-node cluster with the default limit can scan up to about 30,000 keys. | ||
|
|
||
| ## Add it to your AI assistant | ||
|
|
||
| Create one MCP entry for each Valkey deployment you want to reach. For a cluster, one entry is enough: point it at any node, and Valkey Admin detects the cluster and finds the other nodes. | ||
|
|
||
| ### Targets | ||
|
|
||
| Each target has its own URL, and the only header is the token: | ||
|
|
||
| ```bash | ||
| claude mcp add --transport http valkey-prod http://localhost:8080/mcp/prod \ | ||
| --header "Authorization: Bearer <token>" | ||
| ``` | ||
|
|
||
| ### Headers | ||
|
|
||
| All entries use the same URL, and the `X-Valkey-*` headers choose the deployment: | ||
|
|
||
| ```bash | ||
| claude mcp add --transport http valkey-standalone http://localhost:8081/mcp \ | ||
| --header "X-Valkey-Port: 6379" | ||
|
|
||
| claude mcp add --transport http valkey-cluster http://localhost:8081/mcp \ | ||
| --header "X-Valkey-Port: 7001" | ||
| ``` | ||
|
|
||
| ### Other assistants | ||
|
|
||
| Most assistants accept the same settings as JSON, though the file location and exact format vary by assistant: | ||
|
|
||
| ```json | ||
| { | ||
| "mcpServers": { | ||
| "valkey-prod": { | ||
| "type": "http", | ||
| "url": "http://localhost:8080/mcp/prod", | ||
| "headers": { "Authorization": "Bearer <token>" } | ||
| }, | ||
| "valkey-standalone": { | ||
| "type": "http", | ||
| "url": "http://localhost:8081/mcp", | ||
| "headers": { "X-Valkey-Port": "6379" } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Connection headers | ||
|
|
||
| Header mode only. | ||
|
|
||
| | Header | Default | Purpose | | ||
| |---|---|---| | ||
| | `X-Valkey-Host` | `localhost` | Valkey host, as seen by the Valkey Admin server | | ||
| | `X-Valkey-Port` | `6379` | Valkey port | | ||
| | `X-Valkey-Username` | none | Valkey username | | ||
| | `X-Valkey-Password` | none | Valkey password | | ||
| | `X-Valkey-Tls` | `false` | Set to `true` to use TLS | | ||
|
|
||
| If a request sends any `X-Valkey-*` header, the headers replace the `VALKEY_*` connection variables completely. Unset headers use the defaults above, not the environment values. | ||
|
|
||
| Assistants may store headers in plain text in their config files. To keep passwords out of them, use targets. | ||
|
|
||
| ## Read-only Valkey user | ||
|
|
||
| Connect as a dedicated user that can read but not write. The rule grants `@read` and `@connection`, then removes `@dangerous`, which takes away commands such as `KEYS`, `CLIENT KILL` and `CLIENT LIST`. It then adds back only what the tools and the metrics collector use: `INFO`, `CONFIG GET` and `COMMANDLOG GET`, which are in `@dangerous`, and `MEMORY STATS`, `CLUSTER SLOTS` and `CLUSTER SLOT-STATS`, which are not in `@read`. Rules apply in order, so keep `-@dangerous` after the category grants: | ||
|
|
||
| ``` | ||
| ACL SETUSER mcp_readonly on >password ~* +@read +@connection -@dangerous +info +memory|stats +config|get +cluster|slots +cluster|slot-stats +commandlog|get +command|info | ||
| ``` | ||
|
|
||
| | Allowed | Used for | | ||
| |---|---| | ||
| | `@read`, `@connection` | Big keys (`SCAN`, `TYPE`, `TTL`, `MEMORY USAGE`, `OBJECT FREQ`), connecting | | ||
| | `INFO`, `MEMORY STATS` | Server info and the dashboard data | | ||
| | `CONFIG GET` | Reading the eviction policy and `cluster-slot-stats-enabled` | | ||
| | `CLUSTER SLOTS`, `CLUSTER SLOT-STATS` | Finding cluster nodes, hot keys | | ||
| | `COMMANDLOG GET`, `COMMAND INFO` | The metrics collector | | ||
|
|
||
| Writes, `FLUSHALL`, `CONFIG SET`, `KEYS`, `MONITOR`, `CLIENT KILL` and the other admin commands stay blocked. `CONFIG GET` can read any setting, including `requirepass`, because an ACL cannot limit it to some settings. | ||
|
|
||
| - Create the user on every node. A cluster does not replicate ACL users. | ||
| - Persist it with `ACL SAVE` when the node uses an ACL file, or `CONFIG REWRITE` when users live in `valkey.conf`. Otherwise it is lost on restart. | ||
| - Give the MCP server the same username and password for every target, with `VALKEY_ADMIN_MCP_USERNAME` and `VALKEY_ADMIN_MCP_PASSWORD`. | ||
| - If a tool fails with `NOPERM`, the error names the command; add it with `+<command>`. | ||
|
|
||
| ## Check the connection | ||
|
|
||
| In Claude Code, run `/mcp` and confirm the entry is connected, then ask: | ||
|
|
||
| > Get the big keys for my cluster. | ||
|
|
||
| If a cluster result is correct, `nodeId` shows keys from more than one node and `nodeErrors` is empty. Nodes can legitimately be missing from the top results when many keys tie on size. | ||
|
|
||
| ## Troubleshooting | ||
|
|
||
| | Error | What to do | | ||
| |---|---| | ||
| | `401 Unauthorized` | Send `Authorization: Bearer <token>` with the value of `VALKEY_ADMIN_MCP_TOKEN` | | ||
| | `404 Unknown MCP target` | With targets, use `/mcp/<name>` with a name from `VALKEY_ADMIN_MCP_TARGETS`. Without them, use `/mcp` | | ||
| | `400 X-Valkey-* headers are not accepted ...` | Remove the `X-Valkey-*` headers from the client. With targets, the URL chooses the deployment | | ||
| | `503` from `/mcp` | The MCP server is not running. Check the Valkey Admin server log: a missing token or targets, a short token or an invalid target entry stops it | | ||
| | `Cannot reach Valkey Admin at ...` | Start the Valkey Admin server and check `VALKEY_ADMIN_URL`. If it is running, add the MCP origin to its `VALKEY_ADMIN_ALLOWED_WS_ORIGINS`, because a rejected origin shows this same error | | ||
| | `No reply from Valkey Admin for ...` | The request passed the 10-second limit. Lower `scanLimit` and try again | | ||
| | `Hot keys use cluster slot stats, which need ...` | Apply the settings listed in the error | | ||
| | `Metrics server not started` | Metrics are still starting. Tools retry for a few seconds, so try again shortly | | ||
| | `NOPERM ...` | The Valkey user lacks a command the tools use. See [Read-only Valkey user](#read-only-valkey-user) | | ||
|
|
||
| ## Security | ||
|
|
||
| - **Read-only:** tools take no raw commands, and the client refuses any websocket action that isn't on its allowlist. | ||
| - **Caller check:** the server binds to `127.0.0.1` and refuses non-local `Origin` headers. With `VALKEY_ADMIN_MCP_TOKEN` set, every request must send the token. Without it, the `Host` header must be local, and any local program can call it. | ||
| - **Server-chosen targets:** with `VALKEY_ADMIN_MCP_TARGETS`, a caller can reach only the listed deployments, with the MCP server's own credentials, and never sends a host or password. In header mode, a caller can point the Valkey Admin server at any host it can reach, which is why an exposed server requires targets. | ||
| - **Least privilege:** connect with a dedicated read-only Valkey user, not `default`, as in [Read-only Valkey user](#read-only-valkey-user). Valkey's ACL is the real access control. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| { | ||
| "name": "mcp", | ||
| "private": true, | ||
| "type": "module", | ||
| "scripts": { | ||
| "dev": "npx tsx --enable-source-maps src/index.ts", | ||
| "build": "tsup src/index.ts --format esm --out-dir dist --clean", | ||
| "start": "node dist/index.js", | ||
| "test": "npx tsx --test src/__tests__/**/*.test.ts", | ||
| "typecheck":"tsc --noEmit -p tsconfig.json", | ||
| "lint": "eslint ." | ||
| }, | ||
| "dependencies": { | ||
| "@modelcontextprotocol/node": "^2.1.2", | ||
| "@modelcontextprotocol/server": "^2.4.0", | ||
| "valkey-common": "*", | ||
| "ws": "^8.18.3", | ||
| "zod": "^4.3.5" | ||
| }, | ||
| "devDependencies": { | ||
| "@types/node": "^24.2.1", | ||
| "@types/ws": "^8.18.1", | ||
| "tsup": "^8.5.1", | ||
| "typescript": "^6.0.3" | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { after, before, describe, it } from "node:test" | ||
| import assert from "node:assert" | ||
| import { createServer } from "node:net" | ||
| import { VALKEY, buildConnectionId } from "valkey-common" | ||
| import { type WebSocket, WebSocketServer } from "ws" | ||
| import { createAdminClient } from "../admin-client" | ||
| import type { IncomingMessage } from "node:http" | ||
|
|
||
| const valkey = { host: "valkey-prod", port: 6379, username: "mcp_readonly", password: "secret", useTLS: false } | ||
|
|
||
| const freePort = () => | ||
| new Promise<number>((resolve) => { | ||
| const server = createServer().listen(0, "127.0.0.1", () => { | ||
| const { port } = server.address() as { port: number } | ||
| server.close(() => resolve(port)) | ||
| }) | ||
| }) | ||
|
|
||
| describe("createAdminClient", () => { | ||
| let wss: WebSocketServer | ||
| let url: string | ||
| const upgrades: IncomingMessage[] = [] | ||
| const received: { type: string; payload?: Record<string, unknown> }[] = [] | ||
|
|
||
| before(async () => { | ||
| wss = new WebSocketServer({ port: 0, host: "127.0.0.1" }) | ||
| await new Promise((resolve) => wss.once("listening", resolve)) | ||
| url = `ws://127.0.0.1:${(wss.address() as { port: number }).port}` | ||
| wss.on("connection", (ws: WebSocket, req) => { | ||
| upgrades.push(req) | ||
| ws.on("message", (data) => { | ||
| const action = JSON.parse(data.toString()) | ||
| received.push(action) | ||
| ws.send(JSON.stringify({ | ||
| type: VALKEY.CONNECTION.connectRejected, | ||
| payload: { connectionId: action.payload.connectionId, errorMessage: "WRONGPASS from test" }, | ||
| })) | ||
| }) | ||
| }) | ||
| }) | ||
|
|
||
| after(() => { | ||
| wss.clients.forEach((client) => client.terminate()) | ||
| wss.close() | ||
| }) | ||
|
|
||
| it("refuses actions outside the read-only allowlist without opening a websocket", async () => { | ||
| const client = createAdminClient(valkey, { url, origin: "valkey-admin-mcp://localhost" }) | ||
|
|
||
| for (const type of [VALKEY.COMMAND.sendRequested, VALKEY.CONNECTION.connectPending, "anything"]) { | ||
| await assert.rejects(client.request({ type }, []), /not on the read-only allowlist/) | ||
| } | ||
| assert.strictEqual(upgrades.length, 0) | ||
| }) | ||
|
|
||
| it("connects with the MCP origin and token, sends the Valkey credentials and reports a rejection", async () => { | ||
| const client = createAdminClient(valkey, { url, origin: "valkey-admin-mcp://localhost", token: "internal-token" }) | ||
|
|
||
| await assert.rejects(client.request({ type: VALKEY.STATS.setData }, []), /WRONGPASS from test/) | ||
|
|
||
| const upgrade = upgrades.at(-1) | ||
| assert.strictEqual(upgrade?.headers.origin, "valkey-admin-mcp://localhost") | ||
| assert.strictEqual(new URL(upgrade?.url ?? "", "http://x").searchParams.get("token"), "internal-token") | ||
|
|
||
| const connect = received.at(-1) | ||
| assert.strictEqual(connect?.type, VALKEY.CONNECTION.connectPending) | ||
| assert.strictEqual(connect?.payload?.connectionId, buildConnectionId(valkey.host, valkey.port)) | ||
| const details = connect?.payload?.connectionDetails as Record<string, unknown> | ||
| assert.strictEqual(details.host, "valkey-prod") | ||
| assert.strictEqual(details.username, "mcp_readonly") | ||
| assert.strictEqual(details.password, "secret") | ||
| client.close() | ||
| }) | ||
|
|
||
| it("does not put the token in the error when Valkey Admin is unreachable", async () => { | ||
| const port = await freePort() | ||
| const client = createAdminClient(valkey, { | ||
| url: `ws://127.0.0.1:${port}`, | ||
| origin: "valkey-admin-mcp://localhost", | ||
| token: "internal-token", | ||
| }) | ||
|
|
||
| await assert.rejects( | ||
| client.request({ type: VALKEY.STATS.setData }, []), | ||
| (error: Error) => /Cannot reach Valkey Admin/.test(error.message) && !error.message.includes("internal-token"), | ||
| ) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Build
valkey-commonbefore the tests in CI.valkey-commonresolves tocommon/dist/index.js, andcommon/distis not tracked. The MCP test command does not build it, so module loading can fail on a fresh checkout. Theapps/servertests importvalkey-commontoo, and the test workflow runs them before the MCP step. Because the workflow does not buildcommon, the same prerequisite applies to every workspace that imports it. Add the build step to.github/workflows/test.ymlrather than only toapps/mcp/package.json.🛠️ Suggested fix
- name: Install dependencies run: npm ci + - name: Build Common + run: npm run build:common - name: Test Server run: npm test --workspace=apps/server