Skip to content

mcp-valid-json

MCP configuration must use valid syntax and a host-readable server structure

Severity error
Autofix -
Since v0.1.0
Category MCP (Model Context Protocol)

Why

MCP (Model Context Protocol) configuration must use syntax and a server map the host can actually read. Standalone files and manifests use JSON; GitHub Copilot custom agents embed the same server shape in YAML frontmatter. Invalid syntax or the wrong structure means no MCP servers load, and tools that depend on them silently fail.

Which key, which file

The server map has two spellings, and each host reads exactly one:

File Key Wrapper required?
.mcp.json mcpServers No — see below
plugin manifests mcpServers Yes
.cursor/mcp.json mcpServers Yes
.vscode/mcp.json servers Yes
opencode.json, opencode.jsonc mcp, or mcp.servers in 2.0 Yes
.github/agents/**/*.md (cloud or shared) mcp-servers Yes, in YAML frontmatter
mcp_config.json (Google Antigravity) mcpServers Yes

A file using the other host's key is reported as such — the servers are present but will not load. VS Code's documented siblings inputs and sandbox are not servers and are left alone. OpenCode accepts both of its own layouts at once and this rule reads both, but the shape of an OpenCode server is checked elsewhere — see below.

GitHub Copilot custom-agent YAML accepts type: local as the local-process spelling of stdio; both require a non-empty string command.

Ecosystem-specific MCP schemas are validated by their dedicated rules: - Agent Plugins mcp.json is validated by agent-plugin-mcp-valid. - OpenCode opencode.json configuration is validated by opencode-config-valid. - Google Antigravity mcp_config.json is validated by antigravity-mcp-valid, whose dialect spells a remote server serverUrl and accepts a server with no connection field at all.

A standalone .mcp.json accepts a wrapperless map as well: a file whose top level is the server map itself, with no mcpServers key, is valid and is not reported. Everywhere else the wrapper is the only form. Cursor and VS Code document one shape each, so a bare map there loads nothing and is reported; in a plugin manifest the servers live under the manifest's own mcpServers field, and bare keys beside it are ordinary manifest data that neither the host nor this rule reads as servers.

{"my-server": {"command": "node", "args": ["server.js"]}}

Transport is inferred when a server does not declare type: a command means stdio, and a bare url means a remote server. Declaring type explicitly overrides the inference, and an unknown value is reported. GitHub Copilot agent YAML may spell the local transport local instead.

Credential values do not belong in committed MCP configuration. The rule reports structured secrets in env and headers mappings and rejects URL userinfo such as https://user:token@example.com without copying the credential into its diagnostic. Use host-supported environment substitution or a clearly recognizable placeholder instead.

Examples

Bad — an unknown transport, which no host can connect over:

{"mcpServers": {"my-server": {"type": "gopher", "command": "x"}}}

An empty command is a narrower case: the key is present, so the presence-only check passes it in a Claude-family file. The editor files (.cursor/mcp.json, .vscode/mcp.json), Codex-only plugins, and every Grok plugin surface require the value to name something spawnable: a non-empty command string or url. Grok's surfaces are its manifest's mcpServers path and inline map, which only Grok reads whatever else claims the directory, and a Grok-only plugin's conventional .mcp.json.

Good:

{
  "mcpServers": {
    "my-server": {
      "command": "npx",
      "args": ["my-server"]
    }
  }
}

Good in a GitHub Copilot custom agent:

---
description: Reviews changes using repository metadata
mcp-servers:
  repository:
    type: local
    command: node
    args: [scripts/repository-server.js]
---

How to fix

Fix the JSON or YAML syntax error, or move the servers under the key this host reads (see the table above). Each stdio/local server needs a command and each remote server a url; when type is declared, the field must match it.

Inside an OpenAI Codex-only plugin (Codex-claimed, with neither a .claude-plugin marker nor a Claude marketplace listing — either one counts as a Claude declaration), command and url must also be non-empty strings — Codex resolves servers through the manifest, and an empty value produces a server that silently never starts. A plugin that ships both manifests keeps the Claude requirements on its conventional .mcp.json, where presence alone satisfies the rule.

Grok Build requires the same, and on one more file: a path its manifest names in mcpServers is Grok's whatever else claims the directory, so a dual-manifest plugin's declared file is held to it too. Grok also refuses a document carrying a duplicated key or a bare NaN/Infinity, which is reported as invalid JSON rather than as a field's type.

Avoid naming a server after one of Claude Code's built-in servers (workspace, claude-in-chrome, computer-use, Claude Preview, Claude Browser) — those names are reserved and a user server that shadows one is ignored. The reserved names apply wherever Claude Code reads the file, which includes the repository-root .mcp.json of a repository that is also a Grok plugin.

Configuration

rules:
  mcp-valid-json:
    enabled: true  # true | false | auto
    severity: error
Parameter Description Default
additional-placeholders Extra case-insensitive substrings that mark a generic credential value as a placeholder (suppressing the violation) []

Run skillsaw explain mcp-valid-json to see this documentation and the rule's effective configuration in your terminal.