MCP configuration in Veyyon
This guide explains how to add, edit, and validate MCP servers for the Veyyon coding agent.
Source of truth in code:
- Runtime config types:
packages/coding-agent/src/mcp/types.ts - Config writer:
packages/coding-agent/src/mcp/config-writer.ts - Loader + validation:
packages/coding-agent/src/mcp/config.ts - Capability providers (the editor configs Veyyon also reads):
packages/coding-agent/src/discovery/ - Schema:
packages/coding-agent/src/config/mcp-schema.json
Where MCP config lives
Veyyon-native MCP config lives in exactly one file, the active profile’s agent directory:
~/.veyyon/profiles/default/agent/mcp.json~/.veyyon/profiles/<name>/agent/mcp.jsonwhen a named profile is active (see Profiles)
The native provider also reads .mcp.json beside it for compatibility, but Veyyon writes to mcp.json.
There is no project scope, and no /mcp subcommand takes a scope at all. .veyyon/mcp.json, a root
mcp.json and a root .mcp.json inside a working tree used to be loaded and used to be writable
through /mcp add --scope project; none of them is read now, and neither the option spelling nor the
plain words project and user are accepted. Both are rejected with the reason, on the text surface
as well as in the terminal: the text handler kept the scope after the terminal dropped it, defaulted
to it, and wrote a file nothing loads while reporting success. A repository is content you may not
have written, so a checked-in file must not name a server the agent connects to or a command it
spawns. Veyyon still discovers servers from other tools’ user-level configs (~/.claude.json,
~/.claude/mcp.json, ~/.cursor/mcp.json, ~/.codex/config.toml, ~/.gemini/settings.json,
opencode, windsurf, and more), always from your home directory, never from a working tree, and
/mcp list shows the file each server came from.
One project-controlled route to an MCP server remains, and it is gated rather than removed: a
project plugin registry (.veyyon/plugins/installed_plugins.json) names plugin directories, and a
plugin may ship a .mcp.json. The registry is withheld until you approve it. See
Project trust.
Profiles
Named profiles (veyyon --profile <name>, the --alias shortcut, or VEYYON_PROFILE still work) isolate MCP config. When a profile is active, mcp.json resolves to that profile’s agent directory:
- Default profile:
~/.veyyon/profiles/default/agent/mcp.json - Profile
<name>:~/.veyyon/profiles/<name>/agent/mcp.json
Discovery, the /mcp commands, and the config writer all follow the active profile, so a profile sees only its own servers, never the default profile’s ~/.veyyon/profiles/default/agent/mcp.json. Add a server to a profile by launching under it (veyyon --profile <name>) and running /mcp add, or by editing ~/.veyyon/profiles/<name>/agent/mcp.json directly.
External-tool configs (.claude/, .cursor/, etc.) are profile-independent because they belong to those tools rather than to a Veyyon profile.
MCP follows the same profile rules as the rest of Veyyon-native config; see Configuration Discovery → Profiles.
Add a schema reference
Add this line at the top of the file for editor autocomplete and validation:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {}
}
Veyyon now writes this automatically when /mcp add, /mcp enable, /mcp disable, /mcp reauth, or other config-writing flows create or update a Veyyon-managed MCP file.
File shape
Veyyon supports this top-level structure:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"server-name": {
"type": "stdio",
"command": "npx",
"args": ["-y", "some-mcp-server"]
}
},
"disabledServers": ["server-name"]
}
Top-level keys:
$schema: optional JSON Schema URL for toolingmcpServers: map of server name to server configenabledServers: user-level list that overrides a discovered server’senabled: falseflag (for example when the source config is owned by another tool such asopencode.json);disabledServersstill winsdisabledServers: user-level denylist used to turn off discovered servers by name; runtime loading reads this list from the active profile’s user MCP file (~/.veyyon/profiles/default/agent/mcp.json, or~/.veyyon/profiles/<name>/agent/mcp.jsonunder a named profile)
Server names must match ^[a-zA-Z0-9_.-]{1,100}$.
Supported server fields
Shared fields for every transport:
enabled?: boolean: skip this server whenfalsetimeout?: number: MCP request timeout in milliseconds;0disables client-side MCP timeoutsauth?: { ... }: auth metadata used by Veyyon for OAuth/API-key flowsoauth?: { ... }: explicit OAuth client settings used during auth/reauth
Set VEYYON_MCP_TIMEOUT_MS=0 to disable the client-side timeout for every MCP server in the current process. Set it to a positive millisecond value, such as VEYYON_MCP_TIMEOUT_MS=120000, to apply one global timeout without editing each server entry.
stdio transport
stdio is the default when type is omitted.
Required:
command: string
Optional:
type?: "stdio"args?: string[]env?: Record<string, string>envPassthrough?: string[]— ambient variables to forward by nameinheritEnv?: boolean— forward the whole ambient environment, credentials includedcwd?: string
A stdio server receives a baseline of variables a program needs in order to run (PATH, HOME,
temp, locale, certificate and proxy settings, and version-manager directories; on Windows also
PATHEXT, SystemRoot, ComSpec and the ProgramFiles variants), plus env and the names
listed in envPassthrough. Every other ambient variable is withheld. inheritEnv: true disables
that bound for one server and logs a warning on each spawn. See
MCP setup.
Example:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/Users/alice/projects",
"/Users/alice/Documents"
]
}
}
}
This follows the official Filesystem MCP server package (@modelcontextprotocol/server-filesystem).
http transport
Required:
type: "http"url: string
Optional:
headers?: Record<string, string>
Example:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/"
}
}
}
This matches GitHub’s hosted GitHub MCP server endpoint.
sse transport
Required:
type: "sse"url: string
Optional:
headers?: Record<string, string>
Example:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"legacy-remote": {
"type": "sse",
"url": "https://example.com/mcp/sse"
}
}
}
sse is still supported for compatibility, but the MCP spec now prefers Streamable HTTP (type: "http") for new servers.
Stopping a stdio server
A stdio server is usually started through a wrapper: npx, uvx, docker run, or a script
in a repository. The wrapper starts the real server as a child of its own, so the process
Veyyon spawned is not the process serving tools.
Ending a server ends the whole tree. Veyyon signals every live descendant and then the
process it spawned, waits 500 ms, and repeats the wave with a hard kill if anything is still
running. The second wave re-walks the tree, so a process started during the wait is included.
The wait after the hard kill is bounded at 1.5 s, so /mcp reload, a disconnect, and session
shutdown always return. The same teardown runs when a handshake fails, including a server that
never answers initialize.
The process group is signalled only when the server leads a group of its own. On Linux the server starts in a new session, so its group holds nothing else. On macOS it stays attached so the system can prompt for file access, and Windows has no process groups; there the group is Veyyon’s own and is left alone.
A server that daemonizes — double-forks into its own session — is outside this. It outlives the session that started it and has to be stopped by hand.
Auth fields
Veyyon understands two auth-related objects.
auth
{
"type": "oauth" | "apikey",
"credentialId": "optional-stored-credential-id",
"tokenUrl": "optional-token-endpoint",
"clientId": "optional-client-id",
"clientSecret": "optional-client-secret",
"resource": "optional-mcp-resource-uri"
}
Use this when Veyyon should remember how to rehydrate credentials for a server.
You normally do not need to write this block: when Veyyon completes an OAuth flow
for an http/sse server it stores the credential under a deterministic id
derived from the active profile and server URL
(mcp_oauth:profile:<profile>:<url>), with the refresh material embedded. Any
config that points at the same URL, including a definition-only entry with no
auth block at all, resolves the active profile’s own credential automatically,
including when auth storage is backed by a shared auth broker. An explicit
credentialId is still honored when it resolves; if it points at another
profile’s row, Veyyon falls back to the profile-scoped url-keyed binding.
/mcp reauth on a definition-only entry leaves the file untouched, the
credential (refresh material included) lives entirely in the active profile’s
auth storage (local agent.db or broker), so no config file ever picks up local
auth state. An explicitly configured Authorization header always wins over the
url-keyed binding.
The binding is per profile but not per project: once a profile has authorized a
URL, any config defining a server at that URL connects with that profile’s
credential automatically. That is one reason a repository cannot define an MCP
server: a checked-in entry stating an already-authorized URL would have borrowed
the profile’s credential. Servers you add through /mcp add are yours, and the
editor configs Veyyon still reads are named by file in /mcp list.
oauth
{
"clientId": "...",
"clientSecret": "...",
"redirectUri": "...",
"callbackPort": 3334,
"callbackPath": "/oauth/callback",
"prompt": "consent"
}
Use this when the MCP server requires explicit OAuth client settings.
prompt controls the OAuth prompt parameter sent with the authorization request. By default the parameter is omitted, matching the reference MCP SDK, except when the granted scopes include offline_access: OIDC Core requires prompt=consent to issue refresh-token access, so Veyyon sends consent for those requests. Without a consent prompt, a provider with an active browser session silently re-approves the same account, making it impossible to switch accounts or workspaces when reauthorizing (e.g. to use a different Linear workspace per Veyyon profile). Set it to "" to omit the parameter for providers that reject it, or to another value the provider understands (e.g. "select_account").
Slack is the clearest current example. Slack’s MCP server is hosted at https://mcp.slack.com/mcp, uses Streamable HTTP, and requires confidential OAuth with your Slack app’s client credentials.
Example:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"slack": {
"type": "http",
"url": "https://mcp.slack.com/mcp",
"oauth": {
"clientId": "YOUR_SLACK_CLIENT_ID",
"clientSecret": "YOUR_SLACK_CLIENT_SECRET"
},
"auth": {
"type": "oauth",
"tokenUrl": "https://slack.com/api/oauth.v2.user.access",
"clientId": "YOUR_SLACK_CLIENT_ID",
"clientSecret": "YOUR_SLACK_CLIENT_SECRET"
}
}
}
}
Relevant Slack endpoints from Slack’s docs:
- MCP endpoint:
https://mcp.slack.com/mcp - Authorization endpoint:
https://slack.com/oauth/v2_user/authorize - Token endpoint:
https://slack.com/api/oauth.v2.user.access
Common copy-paste examples
Filesystem server via stdio
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"filesystem": {
"command": "npx",
"args": [
"-y",
"@modelcontextprotocol/server-filesystem",
"/absolute/path/one",
"/absolute/path/two"
]
}
}
}
GitHub hosted server via HTTP
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/"
}
}
}
GitHub local server via Docker
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"github": {
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "GITHUB_PERSONAL_ACCESS_TOKEN"
}
}
}
}
This matches GitHub’s official local Docker image ghcr.io/github/github-mcp-server.
Slack hosted server via OAuth
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"mcpServers": {
"slack": {
"type": "http",
"url": "https://mcp.slack.com/mcp",
"oauth": {
"clientId": "YOUR_SLACK_CLIENT_ID",
"clientSecret": "YOUR_SLACK_CLIENT_SECRET"
},
"auth": {
"type": "oauth",
"tokenUrl": "https://slack.com/api/oauth.v2.user.access",
"clientId": "YOUR_SLACK_CLIENT_ID",
"clientSecret": "YOUR_SLACK_CLIENT_SECRET"
}
}
}
}
Secrets and variable resolution
This is the part that usually trips people up.
Discovery-time ${...} expansion
Veyyon expands ${VAR} and ${VAR:-default} placeholders while discovering MCP configs from Veyyon-native files and standalone fallback files. Expansion applies recursively to string values in command, args, env, cwd, url, headers, auth, and oauth.
An unset variable with no default leaves the placeholder text in the value. In command, args, cwd, url and envPassthrough that text would become a program, an argument, a directory or a hostname, so the server is not started: the connection is refused with the field and the variable named, and nothing is spawned or dialled. env and headers are resolved again before connect and refuse the same way (below). In auth and oauth the placeholder is sent to the authorization server, which rejects the exchange.
A placeholder in a structural field is therefore resolved here or refused; it never reaches the server as text. To let a server read a variable itself, name it in env (or in envPassthrough) and read it from the process environment inside the server.
Example:
{
"mcpServers": {
"github": {
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"Authorization": "Bearer ${GITHUB_TOKEN}"
}
}
}
}
Pre-connect env/header resolution
Before Veyyon launches a stdio server or makes an HTTP/SSE request, it resolves stdio env values
and HTTP/SSE headers values like this:
- A value starting with
!runs as a shell command with a 10s timeout and its trimmed stdout is used. A command that fails, times out, or prints only whitespace omits that entry. ${NAME}or$NAMEreads the environment variableNAME.- A bare value shaped like an environment variable name (
GITHUB_TOKEN, upper case, digits and underscores) reads that variable too. literal:<text>is<text>, verbatim, with no lookup.- Any other value is itself.
A variable that is unset, or set to an empty string, resolves to nothing. The connection is not attempted and the error states the variable:
The header "Authorization" for https://api.example.com/mcp refers to the environment variable
GITHUB_TOKN, which is not set, so the connection was not attempted rather than sent with the
variable's own name as the value.
Earlier versions used the variable’s own name as the value in that case, so a typo was sent to the server as the credential and came back as the server’s opinion of a bad token.
Examples:
{
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"
},
"headers": {
"X-MCP-Insiders": "true"
}
}
"GITHUB_PERSONAL_ACCESS_TOKEN": "${GITHUB_PERSONAL_ACCESS_TOKEN}"→ copy from the current shell environment, and refuse to connect when it is missing"Authorization": "Bearer hardcoded-token"→ use the literal value"X-Api-Key": "literal:PROJECT_KEY"→ sendPROJECT_KEYas the value, without looking it up"Authorization": "!printf 'Bearer %s' \"$GITHUB_TOKEN\""→ build the header from a command
When a command runs again
A command’s output is cached under the command text, so the same command is executed once per session however many servers and headers use it. Three events drop that cached output and run the command again:
- A request answered with 401 or 403. The retry uses the new value.
/mcp reconnect <name>, which re-reads the credentials of that server only./mcp reload, which re-reads the credentials of every configured MCP server. Commands used outside MCP config, such as a providerapiKey, keep their cached value.
An automatic reconnect after a dropped connection reuses the cached value. A lost connection indicates nothing about the credential, and re-running a password-manager command on every reconnect means an unlock prompt for each one.
A command that fails is not retried for 30 seconds, and an invalidation does not shorten that.
When a stored credential cannot be presented
A server authorized with OAuth keeps its credential in the profile’s credential store. When that
credential exists but cannot be used, Veyyon does not connect without it. The connection fails,
/mcp list shows the failure, and the message states the state and the command that fixes it:
- The credential was rejected and cleared. Run
/mcp reauth <name>. - The access token expired and the refresh token is held by the auth broker, which this process
cannot use. Run
/mcp reauth <name>to authorize again through the broker. - The credential store could not be read or renewed. Run
/mcp reconnect <name>to retry; the credential is left alone, because a store failure indicates nothing about it.
A refresh that fails while the access token is still valid keeps connecting with that token: the refresh runs up to five minutes before expiry, so the session still works.
A server with no stored credential connects as configured. Nothing was authorized, so an unauthenticated request is what the server sees, and its own answer is what Veyyon reports.
disabledServers
disabledServers is read from the user config file (~/.veyyon/profiles/default/agent/mcp.json) when a server is discovered from any source and you want Veyyon to ignore it without editing that other tool’s config.
Example:
{
"$schema": "https://raw.githubusercontent.com/santhreal/veyyon/main/packages/coding-agent/src/config/mcp-schema.json",
"disabledServers": ["github", "slack"]
}
/mcp add vs editing JSON directly
Use /mcp add when you want guided setup.
Use direct JSON editing when:
- you need a transport or auth option the wizard does not prompt for yet
- you want to paste a server definition from another MCP client
- you want schema-backed validation in your editor
After editing, use:
/mcp reloadto rediscover and reconnect servers in the current session/mcp listto see which config file a server came from/mcp test <name>to test a single server/mcp reconnect <name>to reconnect one server without rediscovering all configs/mcp resources,/mcp prompts, and/mcp notificationsto inspect non-tool MCP capabilities
Validation rules Veyyon enforces
From validateServerConfig() in packages/coding-agent/src/mcp/config.ts:
stdiorequirescommandhttpandsserequireurl- a server cannot set both
commandandurl - unknown
typevalues are rejected
Practical implications:
- Omitting
typemeansstdio - If you paste a remote server config and forget
"type": "http", Veyyon will treat it asstdioand complain thatcommandis missing sseremains valid for compatibility, but new hosted servers should usually be configured ashttp
Discovery and precedence
Veyyon does not merge duplicate server definitions across files. Discovery providers are prioritized, and the higher-priority definition wins. Separately, disabledServers from ~/.veyyon/profiles/default/agent/mcp.json can suppress a discovered server by name.
In practice:
- prefer
~/.veyyon/profiles/default/agent/mcp.jsonwhen you want a Veyyon-specific override - keep server names unique across tools when possible
- use
disabledServersin the user config when a third-party config keeps reintroducing a server you do not want
Troubleshooting
Server "name": stdio server requires "command" field
You probably omitted type: "http" on a remote server.
Server "name": both "command" and "url" are set
Pick one transport. Veyyon treats command as stdio and url as http/sse.
/mcp add worked but the server still does not connect
The JSON is valid, but the server may still be unreachable. Use /mcp test <name> and check whether:
- the binary or Docker image exists
- required environment variables are set
- the remote URL is reachable
- the OAuth or API token is valid
The server exists in another tool’s config but not in Veyyon
Run /mcp list: it shows the file each server came from. Veyyon discovers many third-party MCP files, but it never reads a repository’s own mcp.json, .mcp.json or .veyyon/mcp.json, and a disabledServers entry in your profile’s mcp.json can suppress a discovered server by name.
A call fails with a protocol error rather than a timeout
JSON-RPC lets a server answer with "id": null when it cannot tell which request an error belongs
to. A parse error is the usual case: the server could not read the request well enough to find its
id, so it has nothing to attribute the failure to.
Veyyon surfaces that answer instead of waiting. Every call in flight on that connection fails with the server’s own code and message, for example:
MCP error -32700: Parse error
The alternative would be to ignore a reply with no request id, and then every pending call sits until its timeout and reports that the server did not answer. That is the opposite of what happened: the server answered, and told you exactly what was wrong.
A -32700 means the bytes Veyyon sent were not valid JSON to that server, so report it with the
server name and the tool you called. It is a bug in the server or in the transport, not something a
config change fixes.
References
- MCP transport spec: https://modelcontextprotocol.io/specification/2025-03-26/basic/transports
- Filesystem server package: https://www.npmjs.com/package/@modelcontextprotocol/server-filesystem
- GitHub MCP server: https://github.com/github/github-mcp-server
- Slack MCP server docs: https://docs.slack.dev/ai/slack-mcp-server/