MCP Workstation
by anshace
README.md
# π₯οΈ MCP Workstation
**One MCP endpoint. Every MCP server. Your own multi-user platform.**
MCP Workstation is a single MCP server that acts as an **aggregator**: your AI client
connects to it **once** and gets every tool from every MCP server you've registered β
plus a dense set of built-in tools β behind one connection.
Run it in **platform mode** (set `BETTER_AUTH_SECRET`) and it becomes a full product:
- **Sign-in with Google or GitHub** (Better Auth) at a built-in dashboard (`/`)
- **Per-user MCP servers** β each user registers their own stdio/HTTP MCP servers,
with secrets stored **encrypted** (AES-256-GCM), and toggles them on/off
- **API tokens** per user; every `/mcp` request must carry `Authorization: Bearer <token>`
- **Per-user catalogs** β tool lists are built per request from *that user's* enabled
servers and module prefs. Users only ever see their own servers.
- **Categorized modules & tools** β everything ships on by default, grouped by category
(Development, Data, Finance & Crypto, β¦), with **per-tool toggles**: turn off a whole
module *or* a single tool (e.g. keep `crypto_price` but hide `crypto_trending`).
- **A Skills Hub** β reusable agent instruction sets (debugging, code review, security
audits, β¦) shipped alongside the MCP tools, browsable in the dashboard and loadable by
any connected client via `skills_list` / `skills_get`. One hub, MCP + skills together.
It speaks the **2026-07-28 MCP specification** (the newest release): a **stateless
protocol core**. There is no `initialize` handshake, no `Mcp-Session-Id`, no connection
state β every request is self-contained and independently authenticated, so the server
scales behind a plain round-robin load balancer. Tools are namespaced
(`github_create_issue`, `mybox_read_file`, β¦) so nothing collides and routing is automatic.
```
ββββββββββββββββ Bearer token + stateless request βββββββββββββββββββββββββββββββββββ
β Claude / β ββββββββββββββββββββββββββββββββββββΆ β MCP Workstation β
β Cursor / β http://localhost:3125/mcp β βββββββββββββββββββββββββββββ β
β VS Code β ββββββββββββββββββββββββββββββββββββ β β Built-in modules (shared) β β
ββββββββββββββββ β β time uuid memory githubβ¦ β β
ββββββββββββββββ β βββββββββββββββββββββββββββββ β
β Browser β / (dashboard) β βββββββββββββββββββββββββββββ β
β Google / β βββββββββββββββββββββββββββββββββββΆ β β Per-user proxy engine β β
β GitHub β /api/auth/*, /api/* β β βΆ user's stdio servers β β
ββββββββββββββββ β β βΆ user's HTTP servers β β
β βββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββ
```
## Quick start
Requires **Node.js β₯ 22.5** (for the built-in `node:sqlite`).
**1. Run with platform mode (recommended):**
```bash
npm install
npm run build
cp .env.example .env # then set BETTER_AUTH_SECRET + OAuth keys (see below)
npm start
```
**2. Or run single-user, no auth (everything is open):**
```bash
npm start # platform mode stays off without BETTER_AUTH_SECRET
```
You'll see a startup report listing active modules, connected upstream servers, and
whether platform mode is on:
```
[mcp-workstation] β time: 2 tools
[mcp-workstation] β github: GITHUB_TOKEN not set
[mcp-workstation] 22 tools available
[mcp-workstation] platform mode: ON (multi-user auth)
[mcp-workstation] dashboard: http://localhost:3125/
```
## Platform mode β turning it on
Add to `.env`:
```bash
BETTER_AUTH_SECRET=$(openssl rand -base64 32) # REQUIRED β turns platform mode on
# Google OAuth (redirect URI: {BETTER_AUTH_URL}/api/auth/callback/google)
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
# GitHub OAuth (callback URL: {BETTER_AUTH_URL}/api/auth/callback/github)
GITHUB_CLIENT_ID=...
GITHUB_CLIENT_SECRET=...
```
Then open **http://localhost:3125/** and sign in with Google or GitHub. Email/password
sign-in is on by default for development (`ALLOW_EMAIL_AUTH=false` to disable).
From the dashboard you can:
- **Add MCP servers** β pick `stdio` (a local command) or `http` (a remote endpoint),
name it (tools appear as `name_*`), and set env vars / headers. Secrets are encrypted
at rest and never returned by the API.
- **Toggle servers on/off** β disabled servers stop appearing in your endpoint instantly.
- **Mint API tokens** β name a token (e.g. "Claude Code"), copy it once, revoke anytime.
- **Toggle modules & individual tools** β everything is on by default; drill into any
module and switch off single tools, or hide whole categories you don't use.
- **Browse the Skills Hub** β enable/disable skills, read their full instructions, and
let your clients pull them over MCP.
## Connect your AI client
First create an **API token** in the dashboard. Then point your client at the endpoint
with the token as a Bearer header:
| Client | Configuration |
|---|---|
| **Claude Code** | `claude mcp add --transport http workstation http://localhost:3125/mcp` + set the `Authorization: Bearer <token>` header on the connection |
| **Cursor** | Settings β MCP β Add new MCP server β Type: `http`, URL: `http://localhost:3125/mcp`, Headers: `{ "Authorization": "Bearer <token>" }` |
| **VS Code / Copilot** | `.vscode/mcp.json` β `"type": "http"`, URL + `Authorization` header |
The endpoint serves the **2026-07-28 stateless protocol**, falls back to serving
**2025-era streamable-HTTP** requests automatically, and still bridges the deprecated
**legacy HTTP+SSE** transport for older clients (in platform mode its message POSTs are
authenticated too). Requests without a valid token get a proper `401` + `WWW-Authenticate`
challenge.
Local apps can also use stdio (single-user mode):
```bash
npm run stdio
```
## What's new β 2026-07-28 features wired in
- **Stateless core** β no handshake, no sessions. Each request carries its protocol
version, client identity, and capabilities in a `_meta` envelope. The old
session-recovery/session-reaping code is gone entirely; the server is just a handler.
- **`server/discover`** β clients can probe capabilities up front (optional).
- **Cacheable list results** β `tools/list` and `server/discover` return
`ttlMs` + `cacheScope` hints (tool catalogs only change on reload).
- **Header-based routing** β requests carry `Mcp-Method` / `Mcp-Name` headers, so
gateways, rate limiters, and WAFs can route and meter without parsing JSON bodies
(missing headers are rejected with a spec-compliant error).
- **`resultType: "complete"`** results with `io.modelcontextprotocol/serverInfo` in `_meta`.
- Built on **SDK v2** (`@modelcontextprotocol/server` + `@modelcontextprotocol/client`),
web-standards based, with the client auto-negotiating protocol era against upstreams.
## MCP Registry integration
- **Discover & import:** the dashboard Directory searches the **official MCP
Registry** (`registry.modelcontextprotocol.io`) live β one click adds any
remote server to your account as a namespaced upstream (`/api/registry` is a
read-only, session-gated proxy; no keys leave the server side).
- **Publish our hub:** `registry/server.json` is a valid entry
(`io.github.anshace/mcp-workstation`, validated offline against the vendored
official schema via `npm run validate:registry`). To list it publicly: point
the remote `url` at your deployed instance, claim the namespace via GitHub,
and submit with the official [`mcp-publisher`](https://github.com/modelcontextprotocol/registry) CLI.
## Built-in tools (no API keys needed for the core set)
> In platform mode the key-gated modules (`github`, `jira`, `search`, `notion`,
> `slack`) are **per-user**: each account can store its own credentials on the
> dashboard's **Credentials** page (API: `/api/secrets`, encrypted at rest,
> never displayed back) and those shadow the server's process env β so one
> user's `gh_*` calls never run with another's token.
| Module | Tools | Enabled by |
|---|---|---|
| `time` | `get_current_time`, `convert_timezone` | always |
| `uuid` | `uuid_generate` | always |
| `fetch` | `fetch_url` (timeout, size cap, domain allowlist) | always |
| `memory` | `memory_set/get/delete/list/search/clear` β persistent key-value store | always |
| `filesystem` | `fs_read/write/list/mkdir/remove/stat/search` β **sandboxed** to `FILESYSTEM_ROOTS` | always |
| `sqlite` | `sqlite_list_tables/query/execute` β via `node:sqlite` | always |
| `knowledge` | `knowledge_index/search/fts_search/vector_search/index_workspace/status/clear` β **full-text (FTS5/BM25) + semantic vector search**, zero config | always |
| `github` | 27 tools β `gh_get_user/get_repo/create_repo/list_repos/search_repos`, issues (`list/get/create/update/comment/search`), PRs (`list/get/create/merge/review`), files (`get/write/delete`), `list_commits/branches/releases/create_release`, Actions (`trigger_workflow/list_workflow_runs`), `rate_limit` | `GITHUB_TOKEN` |
| `jira` | 15 tools β `jira_search_issues` (JQL), `get/create/update_issue`, `list_transitions/transition_issue`, `add_comment/get_comments`, `add_worklog`, `list_projects/get_project`, `list_boards/list_sprints`, `list_issue_types/list_assignable_users` | `JIRA_BASE_URL` + `JIRA_API_TOKEN` (+`JIRA_EMAIL`) |
| `search` | `web_search`, `web_extract` (Brave / Tavily / Exa) | any of `BRAVE_API_KEY`, `TAVILY_API_KEY`, `EXA_API_KEY` |
| `crypto` | `crypto_price`, `crypto_market`, `crypto_trending`, `crypto_search`, `crypto_convert` β live prices, market data and conversions (CoinGecko) | always |
| `hn` | `hn_top/new/ask/show`, `hn_item`, `hn_search` β Hacker News stories, threads and full-text search | always |
| `weather` | `weather_current`, `weather_forecast`, `weather_geocode` β conditions & forecasts (Open-Meteo) | always |
## Lite catalog β search-first, token-frugal (on by default for new users)
Every agent pays for `tools/list` in its context window β a full workstation
catalog is 60+ tools β tens of thousands of tokens. **Lite mode** lists only
five Tier-0 tools and keeps everything else fully reachable behind them:
| Tool | Role |
|---|---|
| `hub_search_tools` | BM25 search over the whole hidden catalog (name + description + module synonyms) |
| `hub_get_tool` | fetch the exact input schema of any catalog tool |
| `hub_call` | invoke any catalog tool by name β rate limits and audit apply identically |
| `workstation_status` / `workstation_reload` | introspection + reload |
Toggle per user on the dashboard (**Modules & Tools β Lite catalog**) or via
`PUT /api/prefs {"liteCatalog":false}` for clients that want the full static
list. Retrieval reuses our tool-index work (`src/toolsearch.ts`, unit-tested
against a 20-probe intent set at β₯90% accuracy).
Oversized tool results (default >200KB, `MAX_RESULT_BYTES`) are spilled to a
file in the workspace and replaced by a preview + `fs_read` pointer, so one
chatty upstream never floods the agent's context.
| `skills` | `skills_list`, `skills_get` β pull your enabled skills' instructions over MCP | always |
> GitHub and Jira both support **enterprise/self-hosted instances** via `GITHUB_API_URL`
> and `JIRA_BASE_URL`. Jira accepts an API token (Basic auth with `JIRA_EMAIL`) or a PAT.
| `postgres` | `pg_list_tables/describe_table/query` | `DATABASE_URL` |
| `notion` | `notion_search/get_page/list_block_children/create_page/append_blocks` | `NOTION_TOKEN` |
| `slack` | `slack_post_message/list_channels/channel_history/list_users` | `SLACK_BOT_TOKEN` |
| `workstation` | `workstation_status`, `workstation_reload` | always |
Copy `.env.example` to `.env`, fill in the keys you have, and restart. Missing keys simply
disable that module β everything else keeps working.
### π§ Knowledge base β full-text + vector search (zero config)
The `knowledge` module gives your AI a searchable memory, out of the box:
- **Full-text search** β SQLite FTS5 with BM25 ranking (`knowledge_fts_search`)
- **Semantic vector search** β local embeddings (`all-MiniLM-L6-v2`, 384-dim),
downloaded once and cached; runs fully offline afterwards (`knowledge_vector_search`)
- **Hybrid search** β FTS + vector merged and ranked (`knowledge_search`)
- **Auto-fallback** β if the embedding model can't load, a TF-IDF vectorizer takes
over, so search always works
- **Workspace indexing** β `knowledge_index_workspace` crawls the sandboxed
filesystem roots and indexes text/code files in chunks
```bash
# add documents, then search
knowledge_index { "text": "...", "title": "...", "source": "..." }
knowledge_search { "query": "feline companions that purr" } # semantic
knowledge_fts_search { "query": "javascript server" } # exact/BM25
```
The embedding model downloads on first vector-search use (one-time, ~23 MB),
stored in the local HuggingFace cache. No API keys required.
### π‘ Skills Hub β instructions your agents can follow
The workstation ships a library of **skills**: markdown playbooks for recurring work
(`debugging`, `code-review`, `git-workflow`, `sql-querying`, `web-research`,
`documentation`, `deployment-checklist`, `security-audit` β each with a description,
category and version). They sit **next to** the MCP tools in one hub:
- Everything is **on by default** β no setup.
- The dashboard has a dedicated **Skills** page: browse by category, read any skill's
full instructions in a preview, and toggle skills on/off per user.
- Connected clients pull them over MCP: `skills_list` (names, descriptions, categories)
then `skills_get { "name": "debugging" }` for the full content.
- Skills live in `skills/*.md` β add one with the same frontmatter (name, description,
category, version) and rebuild.
## Adding your own MCP servers (the aggregator part)
Copy `config/servers.example.json` to `config/servers.json` and list the servers you want
aggregated. The workstation connects to each at startup (via the v2 client, which
auto-negotiates with both modern **and** legacy upstream servers) and merges their tools
into its own list, prefixed with the server's `key`.
```jsonc
{
"servers": [
{
"key": "myfiles", // β tools appear as myfiles_*
"type": "stdio",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "C:/path/to/folder"],
"env": { "SOME_TOKEN": "..." } // optional extra env for the child process
},
{
"key": "remote",
"type": "http", // remote Streamable HTTP / SSE servers
"url": "https://your-mcp-server.example.com/mcp",
"headers": { "Authorization": "Bearer ..." }
}
]
}
```
- `enabled: false` (or a bad entry) skips the server; a server that fails to start is
reported in `workstation_status` instead of crashing the workstation.
- After editing `servers.json`, call the `workstation_reload` tool β because every HTTP
request builds a fresh tool catalog from the live registry, the new tools appear on the
very next `tools/list`.
## Operations
- **`workstation_status`** β which modules are active (and why others aren't), which
upstream servers are connected, total tool count, protocol version.
- **`workstation_reload`** β re-reads `servers.json`, reconnects upstreams, refreshes the
tool list.
- Endpoint path and port are configurable: `MCP_PATH` (default `/mcp`), `PORT` (default `3125`).
## Security notes
- **Every `/mcp` request is authenticated in platform mode.** No token, no tools. Tokens
are stored as SHA-256 hashes, never plaintext; per-user server secrets (env vars,
headers) are encrypted with AES-256-GCM using `BETTER_AUTH_SECRET` and never returned
by the API.
- **Multi-tenant isolation.** Each request builds the tool catalog from *that user's*
enabled servers and prefs β a user can never see or call another user's servers.
- **Filesystem is sandboxed.** `fs_*` tools refuse paths outside `FILESYSTEM_ROOTS`
(default `./data/workspace`).
- **Databases are read-only by default.** `pg_query` and `sqlite_query` block write
statements unless you explicitly set `PG_ALLOW_WRITE=true` / `SQLITE_ALLOW_WRITE=true`.
- `fetch_url` can be restricted to specific domains with `allowed_domains`.
- Every key-gated module is **off unless you set the key**. Nothing phones home.
- In single-user mode (no `BETTER_AUTH_SECRET`) there is **no auth** β bind to localhost
or put a reverse proxy in front. Sessions use `secure` cookies automatically when the
public URL is HTTPS.
## Development
```bash
npm run check # typecheck + web typecheck + dead-code gate + unit tests
npm run typecheck # fast type check (backend)
npm run typecheck:web # type check (React dashboard)
npm run test:unit # fast unit tests for core modules (no build needed)
npm test # builds + unit tests + the core end-to-end smoke test
npm run test:platform # platform mode: signup β tokens β per-user /mcp β isolation
npm run test:integrations # GitHub + Jira modules against a local mock API (no real credentials)
npm run dev # run the backend from source (serves the built dashboard at /)
npm run dev:web # Vite dev server for the dashboard (proxies /api + /mcp to the backend)
npm run build:web # rebuild the dashboard into public/ (served by the backend)
```
See [CONTRIBUTING.md](CONTRIBUTING.md) (how to add a module) and
[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) (how the pieces fit together).
## Project layout
```
src/
index.ts entry point: boots workstation + platform (auth, DB, dashboard)
server.ts per-request McpServer built for the authenticated user,
shared registry + per-user upstream aggregators
http.ts node:http front-end: /api/auth/*, /api/*, static UI, /mcp
(Bearer-gated in platform mode, legacy SSE bridge kept)
config.ts .env + config/servers.json loading
registry.ts mutable tool registry + module registration
platform/
auth.ts Better Auth instance (Google + GitHub, cookies)
db.ts SQLite: auth tables (auto-migrated) + servers/tokens/prefs
tokens.ts API-token mint/verify + the /mcp Bearer verifier
api.ts dashboard REST API (servers CRUD, tokens, prefs, skills,
secrets, registry import proxy)
oauth.ts OAuth 2.1 authorization server for /mcp (RFC 9728/7591:
DCR, consent, PKCE S256 β mcw_ bearer)
serverConfig.ts encrypted server-row codec (shared by core + REST)
toolsearch.ts ephemeral BM25 tool index + module synonym table
descli.ts tool-description quality linter
serverConfig β platform/serverConfig.ts server-row β runtime codec
skills.ts loads skills/*.md (frontmatter) into the skills hub
crypto.ts AES-256-GCM secret encryption + SHA-256 token hashing
proxy/
upstream.ts v2 client connection to one stdio/HTTP MCP server, namespacing
aggregator.ts connect-all / list-all / route-calls across upstreams
builtins/ time, uuid, fetch, memory, filesystem, knowledge, github,
jira, search, postgres, sqlite, notion, slack, crypto, hn,
weather (+ the skills module)
skills/ *.md β the skills hub library (one markdown file per skill)
public/
index.html built React dashboard (emitted by `npm run build:web`)
assets/ hashed JS/CSS bundles (React + Astryx + Tailwind)
web/ the dashboard source β React 19 + Vite + Tailwind CSS v4
(layout utilities only), Meta's Astryx design system
(@astryxdesign/core + theme-neutral, forced dark),
lucide-react (icons)
src/
App.tsx root: session gate + view router + toast viewport
main.tsx Astryx Theme provider (neutral theme, dark mode)
lib/api.ts REST client + types
lib/store.tsx app state: session, data, routing, toasts
lib/catalog.ts MCP Directory catalog + connect-guide client configs
components/ Shell (AppShell + TopNav + SideNav), ui primitives
views/ Auth, Dashboard, Directory, Connect, Servers, Tokens,
Credentials, Modules, Skills, Settings
registry/
server.json official MCP Registry entry (publish-ready)
server.schema.json vendored registry schema (offline validation)
scripts/
smoke.mjs core end-to-end test (npm test)
smoke-platform.mjs platform-mode end-to-end test (npm run test:platform)
smoke-ghjira.mjs GitHub + Jira mock-API test
test-upstream.mjs tiny stdio MCP server used by the platform test
tests/
*.test.ts unit tests for core modules (npm run test:unit) β
utils/config/ratelimit/audit, run via node --test + tsx
docs/
ARCHITECTURE.md how the pieces fit together
```
## Roadmap ideas
- MRTR (Multi Round-Trip Requests) β tools that ask the user to confirm mid-call
(e.g. before creating a GitHub issue), via `input_required` results
- Per-user keys for built-in modules (currently server-level env keys are shared)
- Tasks extension for long-running agent work
- Resource + prompt aggregation from upstreams (currently tools only)
This server cannot be deployed
Maintenance
ActivityMaintained
ResponsivenessNo issues