#1887·graphiti

mcp_server: allow api_key to be sourced from a file or a callable, not only an inline literal or an env var

Author: v8etaCreated Sep 12, 2026Updated Sep 12, 2026

Feature description

LLMConfig.api_key and the embedder equivalent are typed str | None in mcp_server/src/config/schema.py, and every string in the config is passed through _expand_env_vars(), which substitutes ${VAR} / ${VAR:default}. So there are exactly two ways to supply the key:

  1. an inline literal in config.yaml
  2. an environment variable, referenced as ${VAR}

Both are awkward when the key is a credential rather than a constant, and neither supports a key that changes while the server is running.

Why the env-var route is not the safe option it appears to be

The natural reading is "put the literal in the environment and keep the config clean". In practice an environment variable is captured by anything that reads container metadata — docker inspect, container-management UIs, monitoring agents that snapshot container state. On one self-hosted deployment this was measured concretely: two such tools between them had accumulated 425 copies of the key in their own state, which were then included in that deployment's routine offsite backups. Moving the value out of the environment and into the rendered config file was a net improvement, which is an uncomfortable place to end up.

So today the least-bad option is a literal secret sitting in a config file, and the config file is usually templated by a deployment tool, which means the secret has to be plumbed through that tool as well.

Proposal

Primary: support a file-based source, e.g. api_key_file, read at load time:

yaml
providers:
  openai:
    api_key_file: /run/secrets/llm_api_key
    api_url: ${OPENAI_API_URL:https://api.openai.com/v1}

This is the conventional pattern for containerised deployments (_FILE suffixed variables, Docker/Kubernetes secrets, systemd credentials) and keeps the value out of both the environment and the config file. It is a small change: resolve the file in the config layer and hand the resulting string to the existing api_key path, so nothing downstream needs to know.

Secondary: support a callable / token provider, resolved per request rather than once at construction.

This matters for anyone pointing api_url at an OpenAI-compatible gateway that issues short-lived tokens — an increasingly common pattern, since a gateway that brokers the real provider key wants to hand out scoped, expiring credentials rather than long-lived ones. Right now the only way to use such a gateway is to ask it for a non-expiring token, which defeats the point of it issuing tokens at all. The MCP server is a long-running process, so a token minted at startup has to outlive the process.

Note the obvious workaround does not work: a custom httpx client or default_headers fixes headers at construction and cannot refresh. This is spelled out in langchain-ai/langchain#38661, which asks for the same capability on ChatAnthropic because ChatOpenAI already has it.

Prior art for the shape:

  • openai-python supports token providers with a configurable refresh buffer on its Azure AD and workload-identity paths, and a refresh callback for Bedrock — but not on the generic OpenAI-compatible path
  • AzureChatOpenAI exposes azure_ad_token_provider
  • ChatOpenAI accepts a callable for api_key

Relationship to existing issues

This is adjacent to the custom-endpoint cluster (#1616, #1744, #1874) in that all of them concern deployments pointing graphiti at something other than api.openai.com, but it is a distinct gap: those are about which API dialect is used against a custom api_url, this is about where the credential comes from.

Scope

The file-based source alone would resolve the at-rest problem and is independently useful, so it is worth treating as the smaller, separable half. The callable is the larger change and only matters for gateway-issued expiring credentials.

Happy to test either against a real deployment.