Skip to content
bilgiliPublic

About

MCP Flow: one-process MCP gateway with an admin web UI for the homelab.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

MCP Flow

MCP Flow

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.

How it works

  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 /mcp with 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-in mcpflow_* MCP tools.
  • The gateway keeps its persistent state under one data directory. See "Files under DATA_DIR".

Screens

Select a picture to open it at full size.

The dashboard with one group per server
Dashboard. One collapsible group per registered server.
An expanded group showing the server row and the tool table
An expanded group. The run command, then every tool it publishes.
The server window open over the dashboard
Server window. Edit, restart, disable, remove, and read the log.
The add-server window on the Python tab
Add a server. By Python package, by npm package, or by URL.
The add-server window on the JSON tab
Paste a config. An mcpServers block from another client fills the fields.
The marketplace grid
Marketplace. Ready-made entries; a connected one shows its namespace.
A marketplace entry detail
Marketplace entry. The record it writes and the command it runs as.
The tokens page
Tokens. Create a bearer token and copy the client config.

How the dashboard loads

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.

Contents

Install

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 mcpflow

Set the admin password

The process needs an admin password. Create a password hash first.

mcpflow hash-password

The 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.

Run the server

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 ./data

Open http://127.0.0.1:8000/login. Enter the user admin and the admin password. An empty user name means admin.

Run with Docker

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 -d

The 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.

Environment variables

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.

Files under DATA_DIR

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 user admin. 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 group admins.
  • rules.json: the grant rules.
  • secret_key: the cookie signing key. The process creates it on first start with mode 0600.
  • 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 mode 0700, files 0600.
  • creds/{ref}/: client.json and token.json for an OAuth child. Files are mode 0600.
  • cache/uv and cache/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>.

Add a server

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 mcpServers object 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.

Source: run a package from a git repo or a local directory

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.

Import from mcpmarket

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.

API tokens

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}.

Multiple users

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 admin always 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 mcp token 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.
  • /api and the mcpflow_* 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 /rules redirect 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 more control. 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/setup and 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/users and POST /api/users/{id}/setup-code return the code once, in setup_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 403 when 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.

Connect an agent

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>

Claude Code

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.

Any client that reads an mcpServers block

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.

Check the connection

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.

Admin API

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

Admin MCP tools

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", {})

Secrets

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.

What a read shows

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.

Write a masked record back

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 url exactly as a read shows it. The gateway keeps the stored url.
  • Send the source exactly as a read shows it. The gateway keeps the stored source.

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 url with a changed host that keeps a masked query value.
  • A new child copied from a read record (mcpflow_add_server or POST /api/servers).
  • A percent-encoded mask in a url or a source.

Send the real value in each of these cases.

Logs and errors

  • last_error and 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 a url or source credential.
  • The process log masks the secrets of every child that the process started.
  • The access log masks the code and state query values of the OAuth callback.

Limits

  • args is not masked. Put a secret in env or headers, not in args.
  • The path of a url is 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.

Inline source servers

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:

  1. mcpflow_write_source(namespace, files) writes files under DATA_DIR/servers/<namespace>/ and returns path.
  2. mcpflow_add_server with kind python or npm, a package, and source set to the returned path. The gateway runs uvx --from <path> <package> or npx -y --package=<path> <bin>.
  3. 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.

Development

The project uses uv and pytest. Run the tests from the repository root.

uv sync --extra dev
make test

The 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.

License

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.

About

MCP Flow: one-process MCP gateway with an admin web UI for the homelab.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages