One gateway. Many Model Context Protocol (MCP) servers. One address for every agent.
MCP Flow is one process that serves three things on one port. It serves the
MCP gateway at /mcp. It serves an admin web user interface (UI). It serves a
health endpoint at /health. The Python package and the console command keep
the name mcpflow.
The gateway runs child MCP servers as subprocesses. It also proxies remote MCP servers. A failed child does not stop the gateway or another child. A change to a child takes effect without a process restart.
agent ──┐ ┌── uvx <python package> (namespace a)
agent ──┼── Bearer token ──► /mcp ─┼── npx <npm package> (namespace b)
agent ──┘ MCP Flow ├── https://remote/mcp (namespace c)
│ └── mcpflow_* built-in admin tools
/login / /marketplace /tokens /api
- An agent connects to
/mcpwith one bearer token. The gateway publishes every visible tool of every running child as{namespace}_{tool}. A muted tool stays hidden. - The admin adds, edits, mutes, and restarts children in the web UI, over the
Representational State Transfer (REST) application programming interface
(API) at
/api, or with the built-inmcpflow_*MCP tools. - The gateway keeps its persistent state under one data directory. See "Files under
DATA_DIR".
Select a picture to open it at full size.
GET / returns a page shell with no tools. The page then loads the tool tree
from /tools/tree. The tree reads one stored tool list for each running child
and waits for no child. Thus a slow child never delays the page.
The gateway keeps the tool list of each running child. When a list is older than
10 seconds, a load of the tree or a control answer starts a background refresh.
The refresh also marks a dead running child as failed. While a child starts
or a refresh runs, the tree polls every 3 seconds. GET /api/tools and
mcpflow_list_tools read the same stored lists.
When the session has ended, a request from the page gets 401 with an
HX-Redirect header to /login?next=<page>. The htmx library then loads the
full sign-in page, not a sign-in form inside the tool tree. A plain browser
GET gets 303 to /login.
- Screens
- Install
- Set the admin password
- Run the server
- Run with Docker
- Environment variables
- Files under
DATA_DIR - Add a server
- Import from mcpmarket
- API tokens
- Multiple users
- Connect an agent
- Admin API
- Admin MCP tools
- Secrets
- Inline source servers
- Development
- License
Install the package with pip. The package needs Python 3.12 or newer. The package
needs fastmcp 4.1.0 or newer, and older than 5. Pip installs it for you. The
source is at https://github.com/bilgili/mcpflow.
pip install mcpflowThe process needs an admin password. Create a password hash first.
mcpflow hash-passwordThe command prints one line that starts with pbkdf2_sha256$. Set the line as
ADMIN_PASSWORD_HASH. You can also set the clear-text ADMIN_PASSWORD instead,
but the hash is safer.
ADMIN_PASSWORD_HASH or ADMIN_PASSWORD owns the password of the user admin.
The process sets that password from the environment at every start. To change it,
change the environment variable and restart. See Multiple users.
Start the gateway with serve. The flags override the environment variables.
export ADMIN_PASSWORD_HASH='pbkdf2_sha256$...'
mcpflow serve --host 127.0.0.1 --port 8000 --data-dir ./dataOpen http://127.0.0.1:8000/login. Enter the user admin and the admin password.
An empty user name means admin.
Copy the example environment file and set the values.
cp .env.example .env
# set ADMIN_PASSWORD_HASH and SECRET_KEY in .env
docker compose up -dThe compose file builds the image, maps port 8000, and mounts a named volume
at /data. Open http://localhost:8000/login. Leave the user name empty, or enter admin.
The named volume is mcpflow-data.
To build the image from a local wheel, run make docker-build-local. The
command builds the wheel and passes it to Docker through a named build context.
| Name | Default | Purpose |
|---|---|---|
DATA_DIR |
./data (Docker: /data) |
registry, tokens, logs, caches |
HOST |
127.0.0.1 (Docker: 0.0.0.0) |
bind address |
PORT |
8000 |
bind port |
ADMIN_PASSWORD |
unset | clear-text password of the user admin; hashed in memory at start |
ADMIN_PASSWORD_HASH |
unset | password hash of the user admin; output of mcpflow hash-password; wins over ADMIN_PASSWORD |
SECRET_KEY |
unset | cookie signing key; else DATA_DIR/secret_key |
COOKIE_SECURE |
0 |
set 1 behind HTTPS |
SESSION_TTL_SECONDS |
604800 |
session lifetime |
CHILD_START_TIMEOUT |
60 |
probe timeout in seconds |
CHILD_CACHE_TTL |
300 |
seconds a child component list serves lookups by name |
LOG_LEVEL |
INFO |
process log level |
PUBLIC_URL |
unset | externally reachable base URL; else the request host |
MAX_USER_SERVERS |
20 |
maximum count of servers that one non-admin user owns; 0 allows none; the admin has no limit |
MAX_USER_TOKENS |
10 |
maximum count of tokens that one non-admin user holds; 0 allows none; an admin mint has no limit |
UV_CACHE_DIR |
Docker: /data/cache/uv |
uvx cache |
npm_config_cache |
Docker: /data/cache/npm |
npx cache |
A child's cache_ttl in servers.json or the form overrides it; 0 disables the cache for that child.
A lower MAX_USER_SERVERS or MAX_USER_TOKENS removes no existing server or token. The process starts with the existing files. The next add or mint of that user fails until the count is below the limit. A token that an admin mints for a user counts toward the limit of that user. A value that is not an integer, or that is below 0, stops the start with an error.
Behind a reverse proxy, forward the original Host header, with the public
port when it is not 80 or 443. The gateway checks the Origin header of every
form POST, sign-in and setup sign-in included. When Origin is present and
its host differs from Host, the gateway answers 403 cross-origin request rejected. An Origin of null also fails. A POST with no Origin passes.
The check skips /mcp and /api/, because they take a bearer token and no
cookie. The match uses whole path segments. /mcp/x is skipped. /mcpfoo is
not.
The process keeps all state under one data directory.
servers.json: the persisted list of admin-owned child servers.user_servers.json: the child servers that users own, with the owner and the approved revision. See Multiple users.tokens.json: the API tokens of the useradmin. The file holds only the SHA-256 digest of each token.user_tokens.json: the API tokens of every other user. The file holds only the SHA-256 digest of each token.users.json: the users and the groups. The admin role is membership of the groupadmins.rules.json: the grant rules.secret_key: the cookie signing key. The process creates it on first start with mode0600.logs/{ref}.log: the stderr of one child. The process truncates it on each start. The process deletes it when the secrets of the child change and when the child is removed.servers/{ref}/: the inline source files of one child (see Inline source servers). Directories are mode0700, files0600.creds/{ref}/:client.jsonandtoken.jsonfor an OAuth child. Files are mode0600.cache/uvandcache/npm: the package caches in Docker.
{ref} is the server reference. For an admin-owned server it is the namespace.
For a user-owned server it is <namespace>@<owner>.
Open / and select Add server. The add window opens over the dashboard
and has three tabs.
- Python: run a Python MCP server with
uvx <package>. Enter the package and arguments. - npm: run a Node MCP server with
npx -y <package>. Enter the package and arguments. - JSON: paste a raw MCP config block. The form reads a
mcpServersobject or a bare name map.
The add form supports two more kinds through import. A remote server proxies
an HTTP or SSE URL. A custom server runs any command.
The Python and npm tabs accept an optional Source. It names where to
install the package from: a git URL, a github: shorthand, or an absolute
directory path. It must start with git+https://, https://, github:, or
/. Only the Python and npm kinds accept a source.
With a source, the gateway runs uvx --from <source> <package> or
npx -y --package=<source> <bin>. On the npm tab, Package is then the bin
name, not the npm package name.
# Python from a git repo
source: git+https://github.com/owner/repo
package: my-tool
# npm from a github shorthand
source: github:owner/repo
package: my-bin
A source may embed a credential for a private repo, for example
git+https://oauth2:<token>@gitlab.example/owner/repo. The gateway stores the
raw URL in servers.json (mode 0600). The dashboard, the server window,
every REST API response, and every admin MCP tool result show the redacted form
git+https://***@gitlab.example/owner/repo. See Secrets.
The mcpmarket tab posts a listing URL. The importer fetches the page and pre-fills the add form from the first server that it finds. The admin always confirms before the registry changes.
The importer accepts only https URLs on the allowed hosts mcpmarket.com,
www.mcpmarket.com, github.com, and raw.githubusercontent.com. It does not
follow redirects.
The import can fail. mcpmarket.com can answer 429. The page can carry no MCP
config. On a failure the UI shows the error and a JSON paste field. Paste the
server config and confirm.
An MCP client authenticates to /mcp with a bearer token. Open /tokens to
manage the tokens.
- Enter a name and a scope, then select Create token. The page shows the
token once. It starts with
mcpflow_. Copy it now. The server stores only the SHA-256 digest. - Select revoke to delete a token. The next request with that token fails.
A token has one scope. An mcp token authenticates /mcp only. An admin
token authenticates /api and /mcp. An admin session on /mcp also sees
the built-in admin tools (see Admin MCP tools below). The scope selector
defaults to mcp. A token belongs to one user. An admin token works only while
its user holds the admin role.
Send the token in the Authorization header.
Authorization: Bearer mcpflow_<token>
The gateway publishes every tool of a running child as {namespace}_{tool}.
The gateway serves more than one user. The admin creates users and groups, and
writes grant rules that allow or deny a server or a tool. A user sees only the
servers and tools that the rules allow, plus the servers that the user owns. A
non-admin user never sees a mcpflow_* tool.
- The user
adminalways exists and always holds the admin role. The environment owns its password. See Set the admin password. - Each token belongs to one user. A non-admin user creates an
mcptoken for themself on/tokens. See API tokens. - A non-admin user proposes a server on the dashboard. The server stays pending
until the admin approves it on
/approvals. The registry accepts only pinned code from a user: an exact package version, or a git source with a full commit hash. /apiand themcpflow_*tools stay admin-only.- The admin manages users, groups, and grant rules on
/security. The page has three sections: Users, Groups, and Rules./users,/groups, and/rulesredirect to it. - A group list shows as tags. The × on a tag removes one user from one group. A
list of more than three shows the first three and a
+N morecontrol. Edit user and Edit members open a picker. Delete of a user or a group asks for confirmation. - The admin never sets a password. A new user is pending until the user
enters a one-time setup code on
/login/setupand chooses a password. The code ends after 7 days. A user changes the password on/settings. - Reset password makes the user pending and gives a new setup code. It ends
every session and revokes every token of that user.
POST /api/usersandPOST /api/users/{id}/setup-codereturn the code once, insetup_code. - An admin writes every server. A non-admin user writes only the servers that the user owns. Only an admin approves a server or mutes a tool.
- The gateway checks the acting user again just before each write. A write
gets
403when the user was deleted, or lost the admin role, since the request began. A refused write changes nothing. - An approved user server runs as the gateway process and can read
DATA_DIR. Read the residual risks before you approve one. - An upgrade needs no manual step. It signs every browser out once. A downgrade fails closed.
- For how the gateway masks secrets, see Secrets.
The full design, the resolution order of the rules, the pinned forms, the risks, and the upgrade notes are in docs/multi-user-access.md.
Every client needs the same two facts: the URL of the gateway and one bearer
token. MCP Flow speaks streamable HTTP at /mcp. It is one server to the
client, however many children it runs behind that address.
Create a token at /tokens first. That page prints the two blocks below with
your real host and token already filled in, so copy from there rather than
retyping.
URL https://mcp.example/mcp
Header Authorization: Bearer mcpflow_<token>
Run one command. It writes the server into the Claude Code config for you.
claude mcp add --transport http mcpflow https://mcp.example/mcp --header "Authorization: Bearer mcpflow_<token>"Check it with claude mcp list. To share the server with a repository instead
of your user account, add --scope project; Claude Code then writes .mcp.json
beside your code, so use a token you are willing to commit, or none at all.
Most clients take the same JSON. Paste this into the client's MCP config file:
{
"mcpServers": {
"mcpflow": {
"type": "http",
"url": "https://mcp.example/mcp",
"headers": {
"Authorization": "Bearer mcpflow_<token>"
}
}
}
}Where that file lives differs by client:
| Client | Config file |
|---|---|
| Claude Code | ~/.claude.json, or .mcp.json in a project |
| Cursor | ~/.cursor/mcp.json, or .cursor/mcp.json in a project |
| Windsurf | ~/.codeium/windsurf/mcp_config.json |
| Claude Desktop | claude_desktop_config.json in the app's support directory |
Two clients differ from the block above. VS Code names the top-level key
servers, not mcpServers, and reads .vscode/mcp.json in a workspace.
Codex CLI keeps its servers in ~/.codex/config.toml as
[mcp_servers.mcpflow] rather than in JSON.
These formats move. If a client rejects the block, check that client's own
documentation for the current key names; the URL and the Authorization header
are the parts that come from MCP Flow.
Ask the agent to list its tools. Every tool MCP Flow publishes carries its
namespace as a prefix, so a time server appears as time_get_current_time.
If the agent sees nothing, confirm the child reports running on the dashboard
and that the namespace is not muted.
An admin token additionally exposes the built-in mcpflow_* tools, so the
agent can add and restart servers itself. See "Admin MCP tools" below.
The gateway serves a JSON REST API under /api. A script manages the same
servers, tools, tokens, users, groups, grant rules, and approvals as the HTML
pages. Every route needs an admin
token in the Authorization header. The session cookie does not authenticate
/api. An mcp token authenticates /mcp only; an admin token
authenticates /api and /mcp. Create an admin token on /tokens.
| Method | Path | Body | Result |
|---|---|---|---|
| GET | /api/servers |
— | list the children |
| POST | /api/servers |
ServerSpec fields |
add a child, 201 |
| GET | /api/servers/{ns} |
— | one child |
| PUT | /api/servers/{ns} |
ServerSpec fields |
replace a child |
| DELETE | /api/servers/{ns} |
— | remove a child, 204 |
| POST | /api/servers/{ns}/enable |
— | enable a child |
| POST | /api/servers/{ns}/disable |
— | disable a child |
| POST | /api/servers/{ns}/restart |
— | restart a child |
| GET | /api/servers/{ns}/log?lines=N |
— | the log tail as text |
| GET | /api/tools |
— | the tool list |
| PUT | /api/visibility/root |
{"muted": bool} |
mute or show all tools |
| PUT | /api/visibility/namespaces/{ns} |
{"muted": bool} |
mute one namespace |
| PUT | /api/visibility/namespaces/{ns}/tools |
{"tool": str, "muted": bool} |
mute one tool |
| GET | /api/tokens |
— | list the tokens |
| POST | /api/tokens |
{"name": str, "scope"?: "mcp"|"admin", "user_id"?: str} |
create a token, 201 |
| DELETE | /api/tokens/{id} |
— | revoke a token, 204 |
| POST | /api/servers/{ns}/approve |
{"revision": str} |
approve a pending user server |
| GET | /api/approvals |
— | list the pending servers |
| GET | /api/users |
— | list the users |
| POST | /api/users |
{"id": str, "name"?: str, "groups"?: [str]} |
create a pending user; the answer has setup_code, 201 |
| PUT | /api/users/{id} |
{"groups"?: [str], "admin"?: bool} |
change the groups or the admin role |
| POST | /api/users/{id}/setup-code |
— | reset the password; the answer has a new setup_code |
| DELETE | /api/users/{id} |
— | delete a user, 204 |
| GET | /api/groups |
— | list the groups and their members |
| POST | /api/groups |
{"name": str} |
create a group, 201 |
| DELETE | /api/groups/{name} |
— | delete a group, 204 |
| GET | /api/rules |
— | list the grant rules |
| POST | /api/rules |
{"subject_kind": str, "subject": str, "level": str, "effect": str, "server"?: str, "tool"?: str} |
create a grant rule, 201 |
| DELETE | /api/rules/{id} |
— | delete a grant rule, 204 |
In the server routes, {ns} is a server reference. The reference of an
admin-owned server is <namespace>. The reference of a user-owned server is
<namespace>@<owner>. The admin creates a user without a password; the user
sets one with the setup code. See Multiple users.
A PUT /api/servers/{ns} is a full replacement. An omitted field takes the
ServerSpec default, so an omitted enabled starts a stopped child. Send
"enabled": false to keep it stopped.
An error answers with a JSON body {"error": "<message>"}. A bad request is
400. An unknown namespace or token is 404. A missing or wrong-scope token
is 401.
List the servers:
curl -H "Authorization: Bearer mcpflow_<admin token>" http://localhost:8000/api/servers
The gateway mounts one built-in MCP server under the namespace mcpflow. Its
tools manage the gateway over /mcp, so an agent that speaks only MCP adds a
child, reads a log, or unmutes a tool without the REST API. The tools publish
as mcpflow_<tool>. An admin token is required on /mcp to see and call them.
An mcp session never lists or calls a mcpflow_* tool. A muted root hides the
child tools but never a mcpflow_* tool, so an admin session can always unmute.
The namespace mcpflow and every namespace that starts with mcpflow_ are reserved.
A child cannot take one, so no child tool name shadows an admin tool.
| Tool | Purpose |
|---|---|
mcpflow_list_servers |
list the children |
mcpflow_get_server |
one child |
mcpflow_add_server |
add a child from a spec and start it |
mcpflow_update_server |
replace a child spec; the argument names the child |
mcpflow_remove_server |
remove a child |
mcpflow_enable_server |
enable a child |
mcpflow_disable_server |
disable a child |
mcpflow_restart_server |
restart a child |
mcpflow_server_log |
the log tail as text, capped at 1000 lines |
mcpflow_list_tools |
the tool list with visibility |
mcpflow_set_root_muted |
mute or show all child tools |
mcpflow_set_namespace_muted |
mute one namespace |
mcpflow_set_tool_muted |
mute one child tool |
mcpflow_import_from_url |
fetch candidate specs from a URL; adds nothing |
mcpflow_write_source |
write inline source files for a child (see Inline source servers) |
mcpflow_import_from_url returns candidates only. The agent confirms each one
with mcpflow_add_server. No tool mints a token; tokens stay on the REST API and
the tokens page.
Call a tool with an MCP client that sends the admin token. For example, with
the FastMCP client:
from fastmcp import Client
from fastmcp.client.transports import StreamableHttpTransport
transport = StreamableHttpTransport(
"http://localhost:8000/mcp",
headers={"Authorization": "Bearer mcpflow_<admin token>"},
)
async with Client(transport) as c:
servers = await c.call_tool("mcpflow_list_servers", {})A child can hold a secret in four fields: env, headers, url, and
source. The gateway keeps the raw values in servers.json (mode 0600) and
starts each child with the raw values. No read surface shows a raw value.
The dashboard, the server window, the REST API, and the admin MCP tools show a masked copy of each child.
| Field | Masked form |
|---|---|
env, headers |
Each key stays. Each non-empty value becomes •••. An empty value stays empty. |
url |
The user information and each non-empty query value become •••. The scheme, host, path, query keys, and fragment stay. |
source |
The user information becomes ***. |
The gateway masks every value, not only the values of keys that look secret.
Thus LOG_LEVEL=debug also reads as LOG_LEVEL=•••. Read a non-secret value
in servers.json, or write a new value.
The JSON import preview shows the pasted values unchanged, because they are not stored yet.
A client can read a child, change one field, and send the whole record back.
- Send
•••for a key that the child already has. The gateway keeps the stored value. - Send a new value. The gateway replaces the stored value.
- Send the
urlexactly as a read shows it. The gateway keeps the storedurl. - Send the
sourceexactly as a read shows it. The gateway keeps the storedsource.
The gateway refuses a write that still holds a mask after this merge. The
answer is 400 on /api and a tool error on /mcp. The message names the
field and the key, never a value. These writes are refused:
•••under a key that the child does not have, for example after a rename.- A value that contains the mask, for example
Bearer •••. - A
urlwith a changed host that keeps a masked query value. - A new child copied from a read record (
mcpflow_add_serverorPOST /api/servers). - A percent-encoded mask in a
urlor asource.
Send the real value in each of these cases.
last_errorand the child log (/servers/{ns}/log,/api/servers/{ns}/log,mcpflow_server_log) mask every secret of the child with•••. This includes the decoded forms of aurlorsourcecredential.- The process log masks the secrets of every child that the process started.
- The access log masks the
codeandstatequery values of the OAuth callback.
argsis not masked. Put a secret inenvorheaders, not inargs.- The path of a
urlis not masked. Put a credential in a header when the provider accepts one. - A secret shorter than 4 characters is not masked in logs and errors.
An agent can write a small MCP server as source files and run it, with no git
host and no shell access. The flow is three steps over /mcp with an admin
token:
mcpflow_write_source(namespace, files)writesfilesunderDATA_DIR/servers/<namespace>/and returnspath.mcpflow_add_serverwith kindpythonornpm, apackage, andsourceset to the returnedpath. The gateway runsuvx --from <path> <package>ornpx -y --package=<path> <bin>.- The child's tools appear on
/mcp.
files maps a relative POSIX path to file text. Limits: at most 200 files,
512 KiB per file, 1 MiB total. A key may not be absolute, hold a . or ..
segment, a backslash, or a NUL, and no key may be a directory prefix of
another. A bad key returns a ToolError that names the key.
A write replaces the whole tree at once: a file absent from the new files is
gone. restarted in the result is true when the write bounced an existing
running or failed child onto the new files. It is false for a starting
child (call mcpflow_restart_server once it settles), a stopped child
(enabled owns the process), and a namespace with no child yet (call
mcpflow_add_server next). If a call fails at the restart step, the files are
already written; call mcpflow_restart_server.
A child's source must point at its own directory. mcpflow_remove_server
deletes the directory; mcpflow_update_server to a git source and
mcpflow_disable_server keep it.
The project uses uv and pytest. Run the tests from the repository root.
uv sync --extra dev
make testThe project designs each change with OpenSpec. The development branch ci
holds the specs under openspec/; the main branch holds the code and the
documentation only.
The changelog is CHANGELOG.md.
The logo lives at src/mcpflow/static/logo.svg. The web UI serves the same
file at /static/logo.svg.
MIT. See LICENSE.
MCP Flow runs third-party MCP servers as child processes and proxies remote ones. Each of those carries its own license; this one covers MCP Flow itself.