Standalone Node/TypeScript MCP server that exposes DreamFactory's control-plane System API
(/api/v2/system/*) as MCP tools. Built so an LLM can administer a DreamFactory instance
(create services, define roles, mint API keys, inspect admins/environment) over natural language.
This server is distinct from:
df-mcp-server(PHP) — the DreamFactory package that registers themcp(data-plane) andsystem_mcp(this server) service types, provides OAuth 2.1 and proxies to the daemons.df-mcp-server/daemon— the data-plane Node daemon (tables / schema / procs / files).df-ai-backend— Express MCP server for data-plane CRUD using a per-API session manager.
It runs in two modes at once, on the same port:
| Mode | Caller | Route | Body |
|---|---|---|---|
| Proxied | df-mcp-server (PHP McpDaemonClient) for a system_mcp service |
/mcp/{service} |
PHP envelope { _mcpPayload, _mcpConfig, _mcpAvailableServices } |
| Direct | df-ai-assistant, any MCP client with a DF session token |
/mcp |
bare JSON-RPC |
| Method | Path | Purpose |
|---|---|---|
GET |
/health, /ping |
Liveness: { status, service, version, tools, mode: "stateful", active_sessions, dreamfactory_url } |
POST |
/mcp, /mcp/:service |
MCP JSON-RPC requests (Streamable HTTP transport). Accepts bare JSON-RPC or the PHP envelope. |
GET |
/mcp, /mcp/:service |
SSE stream for server-to-client notifications. Requires Mcp-Session-Id. |
DELETE |
/mcp, /mcp/:service |
Explicit MCP session termination. Requires Mcp-Session-Id. |
:service is the DreamFactory service name the PHP proxy routed through; it is only used in logs.
Default port: 3700 (override with PORT).
Request headers (read per request; the session token / API key / trace id are re-bound on
every call, X-Mcp-Base-Url is bound at initialize only):
| Header | Meaning |
|---|---|
X-DreamFactory-Session-Token |
DF session token of the OAuth'd user. Required for tool calls. |
X-DreamFactory-API-Key |
Optional DF API key (app_id on the service). Forwarded verbatim. |
X-Mcp-Base-Url |
DF base URL ending in /api/v2. Overrides DREAMFACTORY_URL for that session. Trusted only from callers that passed the MCP_INTERNAL_KEY gate, or whose origin is on the allowlist (DREAMFACTORY_URL origin + MCP_ALLOWED_BASE_URLS); otherwise ignored with a warning. Bound at initialize, never rebound. |
X-DreamFactory-Trace-Id |
Optional trace id, forwarded back on every DF call. |
X-Mcp-Internal-Key |
Required when MCP_INTERNAL_KEY is set (else 403 {code:-32001}). Compared in constant time. |
X-Mcp-One-Shot |
1 on initialize marks the session single-use: it is closed as soon as the first non-lifecycle JSON-RPC request (e.g. tools/call) has been answered. Intended for the PHP rpcStateless() bridge (POST /api/v2/{svc}/rpc), which never sends DELETE. |
X-Mcp-Config |
GET/DELETE only: JSON service config (POST carries it in the envelope). |
Mcp-Session-Id, Last-Event-ID, Content-Type, Accept |
Standard Streamable HTTP headers, passed through. |
POST body envelope:
{
"_mcpPayload": { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "...": "..." } },
"_mcpConfig": { "disabled_tools": ["delete_service"], "custom_tools": [], "app_id": null },
"_mcpAvailableServices": []
}_mcpPayloadis the JSON-RPC message;isInitializeRequestis evaluated on it._mcpConfig.disabled_tools— tool names that are not registered for that session (unknown names ignored).custom_toolsis ignored (this server has no custom-tool runtime)._mcpAvailableServicesis ignored (System API tools do not depend on data services).
Responses are whatever the MCP SDK's Streamable HTTP transport produces (application/json
or text/event-stream), plus Mcp-Session-Id on initialize. The PHP client streams SSE
through untouched.
Session errors (all POST/GET/DELETE routes):
| Status | Meaning |
|---|---|
400 {code:-32000} |
No Mcp-Session-Id and the body is not an initialize request (POST), or the header is missing (GET/DELETE). |
404 {code:-32001, message:"Session not found"} |
Mcp-Session-Id is unknown — evicted after SESSION_TTL_SECONDS, closed by DELETE/one-shot, or the daemon restarted. Clients must re-initialize (this is the Streamable HTTP spec behaviour and what the MCP SDK client keys on). |
403 {code:-32001} |
MCP_INTERNAL_KEY set and header missing/wrong. |
POST /mcp with X-DreamFactory-Session-Token (or Authorization: Bearer <token>) and,
after initialize, Mcp-Session-Id. No envelope. This contract is unchanged from 0.1.0
and is covered by tests/smoke.test.ts.
Every tool call needs a DreamFactory session token, bound to the MCP session on initialize
and refreshed on every subsequent request. If no token is bound at tool-call time, the tool
returns an authentication required error. Tools execute with exactly the DreamFactory role
of that token — non-admin tokens cannot administer the instance. Tokens and keys are never logged.
| Env var | Default | Purpose |
|---|---|---|
MCP_SYSTEM_DAEMON_PORT (or PORT) |
3700 |
HTTP listen port. The prefixed name wins, so a generic PORT on the DreamFactory host doesn't move the daemon. |
MCP_SYSTEM_DAEMON_HOST (or HOST) |
127.0.0.1 |
HTTP listen address. The Docker image sets 0.0.0.0. |
DREAMFACTORY_URL |
http://127.0.0.1/api/v2 |
Default DreamFactory base URL (no trailing slash required). Overridden per session by X-Mcp-Base-Url. The Docker image sets http://web/api/v2. |
MCP_TRUST_LOOPBACK |
true |
When the daemon listens on loopback, local callers may set X-Mcp-Base-Url without the internal key (see Trust boundary). false requires the key or the allowlist even locally. |
MCP_INTERNAL_KEY |
(unset) | Shared secret. When set, every /mcp* request must send a matching X-Mcp-Internal-Key. Set the same value as MCP_INTERNAL_KEY in the DreamFactory .env. |
SESSION_TTL_SECONDS |
1800 |
Idle MCP sessions older than this are closed and their auth context dropped (0 disables). Requests with an evicted id get 404 Session not found. |
MCP_ALLOWED_BASE_URLS |
(unset) | Comma-separated extra origins that untrusted callers may name in X-Mcp-Base-Url (the DREAMFACTORY_URL origin is always allowed). Not needed when MCP_INTERNAL_KEY is set. |
MCP_EXPOSE_API_KEYS |
false |
true disables API-key masking (app keys in list_apps, get_app and call_system_api, and api_key in service configs), so full keys are sent to the LLM. Leave unset in production; see Secret masking. |
MCP_EXPOSE_SECRETS |
false |
true disables masking of other secrets (license key, passwords, client secrets, tokens, private lookups) in every tool response. Leave unset in production; see Secret masking. |
Anything that can reach this port can send a DreamFactory session token and have the daemon act with it — that is by design (the token is the authorisation). What the daemon must not allow is a caller redirecting where that token is sent. Therefore:
X-Mcp-Base-Urlis honoured only from callers that presented the correctX-Mcp-Internal-Key(the DreamFactory PHP proxy), from local callers when the daemon listens on loopback (the default deployment next to DreamFactory, where only processes on that host can reach it;MCP_TRUST_LOOPBACK=falseturns this off), or from callers whose origin is on the allowlist. Everyone else getsDREAMFACTORY_URL. SetMCP_INTERNAL_KEYwhenever the daemon listens on a network interface.- The base URL is fixed at
initialize; knowing a liveMcp-Session-Iddoes not let a caller rebind that session's base URL. call_system_apivalidates the resolved URL:system/../db/_table/x,%2e%2e, backslashes, absolute URLs and embedded?/#are all rejected before any request is made.- Keep the daemon on loopback (the default) or a private Docker network; it has no user-facing
auth of its own. If a same-host reverse proxy forwards outside traffic to it, set
MCP_TRUST_LOOPBACK=falseandMCP_INTERNAL_KEY.
In the DreamFactory .env (df-mcp-server >= 1.4 / DF 7.7.x):
MCP_SYSTEM_DAEMON_ENABLED=true
MCP_SYSTEM_DAEMON_URL=http://127.0.0.1:3700 # the default; http://df-system-mcp:3700 for a sidecar
MCP_INTERNAL_KEY=change-me # must match this daemon; recommended for a sidecar
Then create a service of type System API MCP Server (system_mcp), e.g. sysmcp, and point
an MCP client at https://<df-host>/mcp/sysmcp (OAuth 2.1 with dynamic client registration) or
call POST /api/v2/sysmcp/rpc with a DF session token.
18 tools across seven families. See src/tools/ for full descriptions and zod schemas.
The canonical list is TOOL_NAMES in src/tools/index.ts (the DreamFactory admin UI mirrors it).
- Services (5):
list_services,get_service,create_service,update_service,delete_service - Meta (3):
list_service_types,get_service_type_schema,get_environment - Roles (4):
list_roles,create_role,get_role,update_role - Apps / API keys (3):
list_apps,create_app,get_app(keys masked except oncreate_app, see below) - Admins (1):
list_admins - Access audit (1):
get_access_audit— read-only; last used / last denied per app, role or user, withnever_used,stale,disabled_but_attemptedandrole_unreferencedflags. See below. - Escape hatch (1):
call_system_api— anysystem/*oruser/*path the dedicated tools miss.
Any of these can be hidden per DreamFactory service via disabled_tools in the service config.
Wraps GET /api/v2/system/access_usage, which requires df-system 0.7.0 or later on the
DreamFactory side. Older versions answer 404; the tool turns that into an error telling the model
the feature needs an upgrade. A 403 (restricted admin without system/access_usage access) becomes
a permission error.
| Argument | Default | Sent to DreamFactory as |
|---|---|---|
subject |
app |
subject=app|role|user |
stale_days |
90 |
stale_days (integer ≥ 1) |
only_flagged |
false |
not sent. The tool keeps only rows where never_used, stale, disabled_but_attempted or role_unreferenced is true, and adds meta.only_flagged and meta.unfiltered_count. |
| (always) | include_never_used=true |
The description tells the model to recommend disabling (is_active=false) before deleting, and that
never_used only means no traffic has been recorded since meta.tracking_started_at (the earliest
recorded activity; null when nothing has been recorded yet). last_service / last_status describe
the last use (any request not rejected with 401/403); a 401/403 only moves last_denied_at.
meta.ledger_available says whether requests_30d / top_services are populated.
Tool results go to an LLM, and from there into provider logs and DreamFactory's prompt logs, so credentials are masked by default.
Secrets in every response. DreamFactory returns more than API keys: system/environment includes
the platform license_key, and service configs include credentials that DreamFactory itself doesn't
mask (for example SMTP and Active Directory passwords, or an MCP service's oauth_client_secret).
Every tool response replaces properties whose names look like secrets with DreamFactory's own
protection mask, "**********", at any depth:
- names containing
password,passphrase,passcode,secret,token,private_key,api_key(orapikey),access_key,license_key,app_key,encryption_key,credentials,connection_stringordsn, plurals too (camelCase names count too, e.g.clientSecret). A barekeyisn't matched: DreamFactory uses it for key/value descriptors and AWS access key IDs, and the type-aware list below covers configs whose secret is namedkey(Snowflake) - except descriptive names such as
token_endpoint,token_ttl,password_policyorsecret_type, and type names in descriptor records (at least two values are type names), such as the environment's login payload{ "email": "string", "password": "string", "remember_me": "bool" }; a real value in such a record is still masked valueon any record whosenamelooks like a credential: Authorization, Proxy-Authorization, Cookie, Set-Cookie, X-API-Key, or a name containingauth,token,secret,pass,key,session,credential,bearer, orsigat a word start. This covers RWSconfig.headers/config.parametersentries such as{ "name": "Authorization", "value": "Basic ..." }or anapi_keyquery parameter, whileAccept,limitand the like stay readable. Records withprivate: true(private lookups) havevaluemasked too.- curl options (RWS
config.options, keyedCURLOPT_X,Xor by number) that carry credentials (USERPWD, PROXYUSERPWD, PASSWORD, KEYPASSWD, XOAUTH2_BEARER, COOKIE, POSTFIELDS, LOGIN_OPTIONS), and credential lines in HTTPHEADER / PROXYHEADER ("Authorization: **********") - a password inside any URL, in place:
http://user:**********@proxy:3128 - only non-empty strings, objects and arrays are replaced;
null, numbers and booleans pass through
Type-aware fields. Names don't reveal every credential (a push service's certificate, say), so
df-mcp-server also sends the daemon each installed service type's secret config fields, built from
DreamFactory's model metadata: fields the type encrypts or protects, and fields its config schema
types as a password or certificate (username and account_name stay readable). They arrive in the
proxy envelope as _mcpSecretFields:
{ "gcm": { "secret": ["api_key", "certificate"], "maps": [] }, "nodejs": { "secret": [], "maps": ["config"] } }On any record with that type, the secret fields of its config are masked. Keys of maps fields
(user-named key/value maps such as a script service's config) are masked when they look like
credentials (STRIPE_KEY, DB_PASSWORD). The list only adds masking; a client connecting to the
daemon directly, without df-mcp-server, gets the name rules above.
list_service_types and get_service_type_schema return type metadata unmasked. It describes fields
and holds no instance values, so masking could only corrupt it (the key descriptor of key/value fields,
a placeholder default URL). call_system_api masks every response, system/service_type included.
Write requests drop a property set to exactly "**********" directly on the body or directly in
config, so sending back a config read through this server leaves the stored secret unchanged. A mask
anywhere deeper, or inside a longer string, is refused with a tool error instead: DreamFactory stores
RWS headers, parameters and options as a whole, so the mask would be saved as the value. Send
those with real values, or leave them out of the request to keep them. To rotate a credential, send the
new value.
Set MCP_EXPOSE_SECRETS=true to turn this masking off.
App API keys. In every list_apps, get_app and call_system_api response,
each object property named api_key, at any depth (single records, { resource: [...] } lists,
records nested via related= such as app_by_role_id), is rewritten as:
{ "api_key": null, "api_key_hint": "…5a88" }api_key_hintis"…"plus the key's last 4 characters. Keys shorter than 16 characters get a bare"…"; a null or empty key getsapi_key_hint: null.create_appis the exception: it returns the real new key once, because the caller needs it.api_keyanywhere else (service configs such as gcm or rackspace) gets the secret mask"**********".- Set
MCP_EXPOSE_API_KEYS=trueto turn API-key masking off.
npm install
npm run build # tsc → build/ (NODE_OPTIONS=--max-old-space-size=4096 if tsc OOMs)
npm run dev # tsx src/index.ts
npm test # runs every tests/*.test.ts (smoke + proxy contract)build/ is committed: DreamFactory installs this package with composer and runs
node build/index.js without a TypeScript toolchain. Run npm run build and commit the
result with every src/ change; CI fails when build/ is stale.
Composer installs this package next to df-mcp-server (vendor/dreamfactory/df-system-mcp-server).
df-mcp-server's scripts/start-system-daemon.sh (or start-system-daemon-win.ps1) installs the
production dependencies on first run and starts node build/index.js on 127.0.0.1:3700,
calling DreamFactory back on http://127.0.0.1/api/v2. The DreamFactory Docker image starts it
next to the data daemon; on a VM, run the script from a systemd unit. No .env change is
needed: df-mcp-server already defaults MCP_SYSTEM_DAEMON_URL to http://127.0.0.1:3700.
docker build -t df-system-mcp .
docker run --rm -p 3700:3700 \
-e DREAMFACTORY_URL=http://web/api/v2 \
--network dreamfactory_default \
df-system-mcpOr with the bundled example compose file (joins the dreamfactory_default network):
docker compose -f docker-compose.example.yml up -d --buildA sidecar listens on a network interface, so set MCP_INTERNAL_KEY on both sides and
MCP_SYSTEM_DAEMON_BASE_URL=http://web in DreamFactory.
npm ci --omit=dev
scripts/start-daemon.sh # 127.0.0.1:3700, DreamFactory at http://127.0.0.1/api/v2The orchestrator is an MCP client. Use any MCP SDK that speaks Streamable HTTP, point it at
http://df-system-mcp:3700/mcp, set the auth header on every HTTP request, and invoke tools
by name. The expected response shape per tool is:
{
"content": [
{ "type": "text", "text": "<json-encoded DreamFactory response>" }
],
"isError": false
}Errors set isError: true and embed { error, status, operation, details } in the text payload.
See CHANGELOG.md for release notes.