settings.json
The engine’s one configuration file. One shape, read from at most two places, merged per key. This page is the whole file — every key, every default, and every error it can raise.
- The two scopes
- The whole file
sddai— per-plugin defaultsai.mcpServersenv— the gate on${env:VAR}defaultFoldersToIgnoreScanning- Every error the
sdd:section can raise
The two scopes
| Scope | Where | Applies to |
|---|---|---|
| Workspace | <workspace root>/.local-workflows/settings.json — committed, reviewed in PRs |
everyone on this repository or workspace |
| Profile | ~/.local-workflows/settings.json |
you, in every workspace |
Merged per key, workspace wins. A personal MCP server declared in
your profile follows you into every repository; a team server with the
same name shadows it. For ai.mcpServers the merge unit is one
server: an entry is taken whole, never merged field by field — half
a server declaration from each scope is not a server anybody wrote.
Every field has a default and both files are optional. A fresh install writes nothing and works.
A malformed file fails loudly and names itself — the error opens with the file’s full path. Plain JSON: no comments, no trailing commas. The one silent path is absence — a scope that declares nothing is the normal case.
The whole file
{
"sdd": {
"style": "kiro",
"source": "ado",
"checkpoints": "required",
"stages": {
"design": { "model": "claude-opus-5", "thinking": "high" }
}
},
"ai": {
"ai@1": { "model": "auto" },
"mcpServers": {
"ado": {
"command": "npx",
"args": ["-y", "@azure-devops/mcp@2.9.0", "contoso",
"--authentication", "azcli"],
"allowedTools": ["wit_work_item", "wit_query"]
}
}
},
"env": {
"allowed": ["ADO_PAT"],
"denied": []
},
"defaultFoldersToIgnoreScanning": ["node_modules", "dist"]
}
Four top-level keys, and no others. An unknown one is an error, not
tolerance: a misspelled env that silently gates nothing is a security
setting that looks applied and is not.
| Key | What it is |
|---|---|
sdd |
everything spec-driven development reads |
ai |
per-plugin default args, plus MCP servers |
env |
what ${env:VAR} may read |
defaultFoldersToIgnoreScanning |
folders the workspace scan skips when there is no .gitignore |
Not in this file: where specs live (fixed — see below), the
workspace-root pointer (localWorkflows.workspaceRoot, kept in the
.code-workspace — see
Workspaces), and anything
about a run — params, vars and env belong to the file being run.
sdd
It never reaches an AI. The extension reads it and hands each phase only the values that phase needs. Model choice, style id, and source settings stay in the engine.
| Key | Values | Default |
|---|---|---|
sdd.style |
kiro, spec-kit, or a style you wrote |
kiro |
sdd.source |
manual | ado | gh |
unset — + New Spec asks each time |
sdd.checkpoints |
required | optional | none |
required |
sdd.uses |
a plugin reference | ai@1 |
sdd.stages.<id> |
plugin args, for one phase | — |
sdd: is a closed set. Those five keys and no others — a sixth is a
hard error naming the valid ones, because a misspelled setting that
silently does nothing is worse than one that refuses to load. Plugin
args do not go here; they go under ai."ai@1" or sdd.stages.<id>.
sdd.style
The process: its phases, its gates, its prompts, and the spec types in
the + New Spec menu. kiro and spec-kit ship; your own works the same
way.
Where specs live
.local-workflows/specs under the workspace root — always, and it is
not configurable. There is no specRoot setting, in this file or in the
editor’s; an earlier build had both, both are gone, and writing
sdd.specRoot is now an error naming the five keys that are valid.
In a plain folder that path is the repository’s own
.local-workflows/specs. With a .code-workspace open it is the
.local-workflows/specs of whichever folder
localWorkflows.workspaceRoot names — by default the folder the
.code-workspace file sits in. One feature spanning five repositories
is one spec, not five.
sdd.source
Where the work item behind a spec comes from: written by hand
(manual), Azure DevOps (ado), or GitHub (gh).
This is the one key with no default on purpose. Unset means the file does not say, and + New Spec asks each time — which is the difference between a team that has standardised and one that has not.
sdd.checkpoints
Whether the plan gets “run the tests and stop” barriers between groups of tasks. Read by the tasks prompt when the plan is written; nothing else branches on it.
sdd.uses — what runs the AI phases
Names the plugin, ai@1 by default. Nobody writes it until a second AI
plugin exists. The plugin’s args come from ai."<plugin ref>" — the
lookup is by the reference sdd.uses names, so a team that points it at
a different plugin gets that plugin’s defaults.
sdd.stages: — one phase, different settings
Overrides keyed by phase id — the same ids format.house uses in a
style.
{
"sdd": {
"stages": {
"design": { "thinking": "high" }
}
},
"ai": {
"ai@1": { "model": "auto" }
}
}
The merge is per key, not per block: design above gets
thinking: high and keeps model: auto. Naming one setting never
silently drops the others. A stage may also override uses: to run a
different plugin entirely.
ai — per-plugin defaults
Every key under ai: that is not mcpServers is a plugin
reference, spelled exactly as a task would spell it, holding that
plugin’s default args everywhere it runs:
{
"ai": {
"ai@1": { "model": "auto" }
}
}
A workflow file’s own plugins: entry, an SDD stage entry, or a task’s
own args: win over it — one key at a time, not one block at a time.
Two defaults are worth knowing:
model: auto— the provider picks. A pinned model name goes stale, and a team that has not formed an opinion should not be made to hold one.providerhas no default here —ai@1’s own manifest defaults it toghcp. Writing it here again would leak it to a phase that overridesuses:to a plugin that never declared it.
ai.mcpServers
MCP servers for every ai@1 session, in the standard mcpServers entry
shape every MCP README shows — copied unedited, plus three engine keys:
{
"ai": {
"mcpServers": {
"ado": {
"command": "npx",
"args": ["-y", "@azure-devops/mcp@2.9.0", "contoso"],
"env": { "ADO_PAT": "${env:ADO_PAT}" },
"allowedTools": ["wit_work_item", "wit_query"],
"deniedTools": ["wit_work_item_write"]
}
}
}
}
| Key | ||
|---|---|---|
command |
string |
the executable for a stdio server. Write it portably — npx, not npx.cmd |
args |
array |
|
url |
string |
the endpoint for an http server |
type |
string |
local or http. Inferred from command/url when absent |
env |
object |
environment for the server process. ${env:VAR} is expanded under the gate |
headers |
object |
|
timeout |
number |
|
disabled |
boolean |
true skips the server entirely |
allowedTools |
array |
only these tools. Absent means every tool |
deniedTools |
array |
never these tools. A name in both lists is denied — deny wins |
The last three are the engine’s own and are stripped before the runtime
ever sees the server: allowedTools becomes the SDK’s per-server
allowlist, deniedTools session-level exclusions under their wire names.
Wire names. An MCP tool’s wire name is <serverKey>-<toolName> — the
server declared as ado above surfaces wit_work_item as
ado-wit_work_item. allowedTools and deniedTools take the bare
name and the engine adds the prefix; a task writing its own
availableTools:/excludedTools: must write the full wire name itself,
and one that writes the bare name there matches nothing, silently.
command is made runnable for you
On Windows there is no npx — there is npx.cmd, a batch shim, and
process creation does not consult PATHEXT. So the declaration stays
portable and the engine makes it run: a command with no file extension is
resolved through the shell on Windows. A command that already carries
one — node.exe, an absolute path — is left exactly as written.
It is a fourth source, not a replacement
The Copilot CLI’s own discovery files still apply:
| File | Scope |
|---|---|
~/.copilot/mcp-config.json |
every session on this machine |
.mcp.json in the workspace |
this repository — commit it |
.github/mcp.json in the workspace |
same, GitHub’s preferred spot |
ai.mcpServers is layered on top of those explicitly. The difference
worth caring about is that it is the only one the extension can show you,
toggle for you, and merge across scopes.
The editor’s own mcp.json — user-level or .vscode/mcp.json — is
not among the CLI’s files and is not bridged across. A server that
answers in the chat panel says nothing about a task.
A mcpServers: block in a workflow’s or a task’s args: is not
supported. It is deleted before the session is created, from every
direction — MCP servers are machine setup, not workflow definition.
A server signed in once stays signed in
A server that authenticates over OAuth has nobody to ask during a run — no window belongs to the task. Tokens are kept where the runtime keeps them rather than discarded with the session, so one interactive sign-in holds for every task after it.
The MCP Servers view
The sidebar’s MCP Servers section is a view over these two files,
collapsed by default and hidden entirely until a scope declares a server.
Every server both scopes declare shows up, labelled with which file
declared it. Ticking a checkbox writes disabled, allowedTools or
deniedTools back into that same file, so what the panel shows and
what a session resolves can never be two different truths.
| Command | |
|---|---|
| Local Workflows: Start MCP Server (Discover Tools) | spawn (or reach) the server, handshake, list its tools, disconnect |
| Local Workflows: Reload MCP Servers | re-read both files |
| Local Workflows: Open MCP Config | open the file that declared the selected server |
Start does not leave anything running. The Copilot CLI spawns its own server per session, so a probed server is not a process your tasks talk to — discovery is the honest version of “start”.
env — the gate on ${env:VAR}
${env:VAR} inside any string value of an MCP declaration reads the
machine’s environment — but only if something a reviewer can see grants
the name. A committed settings file arrives with the repository, so
nothing reads your environment by default.
Three grants, one veto:
| Granted by | |
|---|---|
env.allowed |
this file — reviewed in the same PR as the servers that use it |
the workflow’s own env: block, and .env values |
the file the task lives in. Its value also wins over the machine’s |
allowedEnv: on the ai@1 task |
the workflow’s author, seen by its reviewer |
env.denied |
always wins. A name written here is unreadable no matter what any repository says |
A name nothing grants fails the task with an error saying how to grant it — rather than expanding to an empty string and starting a server that cannot authenticate.
Names only, never values. Nothing here ever holds a secret.
defaultFoldersToIgnoreScanning
Folders the workspace scan skips when the repository has no .gitignore
to say so itself. Names, not globs. With a .gitignore, the scan follows
that instead and this is not consulted.
Declaring it replaces the built-in list for that scope rather than adding to it. The built-in list is:
node_modules .git .vscode .idea TestResults
bin obj dist out coverage build target
logs tmp temp
Every error the sdd: section can raise
All raised when the file is read, not in the middle of a run:
| The file says | The error |
|---|---|
| anything that is not valid JSON | '<path>' is not valid JSON: ... Plain JSON only - no comments. |
a top-level key that is not sdd, ai, env or defaultFoldersToIgnoreScanning |
'<path>' has an unknown setting '<key>'. Valid sections: ... |
a key under sdd: that is not one of the five |
settings.json has an unknown key 'sdd.<key>'. Valid: style, source, checkpoints, uses, stages. |
sdd.style: or sdd.uses: that is not text |
settings.json 'sdd.style' must be text. |
a sdd.source: or sdd.checkpoints: value not in its list |
settings.json has 'sdd.source: x' - expected one of 'manual', 'ado', 'gh'. |
sdd.stages: that is not a mapping |
settings.json 'sdd.stages' must be a mapping of stage id to settings. |
a sdd.stages.<id> entry that is not a mapping |
settings.json 'sdd.stages.<id>' must be a mapping. |
A key inside a sdd.stages.<id> entry is not checked here — those
are plugin args by design, and the plugin validates its own. The JSON
schema the extension contributes for settings.json is what flags a
genuinely misplaced key while you type.