Extending Copilot: MCP Servers, Custom Agents, Agent Skills and Hooks
In Copilot a rule that must hold belongs in a hook, not in an instruction: in Bookstore we told the agent outright to run dotnet ef database update, the model called the command and only a preToolUse hook stopped it. The Bookstore MCP server and .mcp.json, moving prompt files to skills, a skill and custom agent that check migrations, and real runs in Copilot CLI with a local model.
Version note: The code in this post was built and run with .NET 10, ModelContextProtocol 2.4.0 and Entity Framework Core 10.0.15; the MCP server, the skill script and the hook scripts (bash and PowerShell 7) were actually run. Copilot CLI 1.0.112 was run without a GitHub account, in COPILOT_OFFLINE mode with a local Ollama model (qwen3:4b); these runs show the CLI's MCP, skill, custom agent and hook mechanics, not Copilot's own models. The behavior of Visual Studio 2026 (18.12), Visual Studio Code (VS Code) 1.146, the cloud agent and code review is taken from docs.github.com, code.visualstudio.com and Microsoft Learn as of 15 November 2026.
Guess First
You want one rule for Copilot in Bookstore, the online bookstore used as the shared example across this site's series: the agent must never run dotnet ef database update. In the fourth post we wrote that into the add-migration prompt file. Now we also put the rule into a hook script, and then explicitly tell the agent "run this command".
What is your guess? Does the written rule win, or the user's explicit request? Does the command reach the terminal while the hook is in place? Does the agent learn why the command did not run? We will see the answer at the end of this post in Copilot CLI's own event log.
Four Extension Points
The instruction files from the fourth post told Copilot about the project. The four mechanisms in this post give it new abilities and new limits:
| Mechanism | What it adds | File in Bookstore | When it applies |
|---|---|---|---|
| MCP server | New tools (API, database, browser) | .mcp.json | When the model picks the tool |
| Agent skill | Instructions, scripts and files loaded on demand | .github/skills/*/SKILL.md | When the request matches the skill's description, or via /skill-name |
| Custom agent | A role with its own instructions and tool list | .github/agents/*.agent.md | When a user selects it or the main agent delegates to it |
| Hook | Code that runs at defined points of the agent's work | .github/hooks/*.json | Before and after each tool call, at session start and end |
The real difference: instructions, skills and custom agents are things you tell the model, and the model may or may not follow them. A hook is a script that runs whatever the model thinks. GitHub's hooks documentation puts it the same way: instructions ask the model to follow a rule, hooks run code.
The Bookstore MCP Server
We covered writing an MCP server in detail in the ninth post of the AI Agents and MCP series. Here a small server is enough to connect to Copilot: src/Bookstore.Mcp does not touch the database, it calls the read-only endpoints of the Bookstore API and offers three tools:
// All three tools are read-only: ReadOnly = true shows up as annotations.readOnlyHint in tools/list.
[McpServerToolType]
public sealed class CatalogTools(BookstoreApi api)
{
[McpServerTool(Name = "search_books", ReadOnly = true, Idempotent = true)]
[Description("Searches the Bookstore catalog by title, author or ISBN-13. Returns up to 10 books as 'isbn | title | author | price | year'.")]
public async Task<string> SearchBooks(
[Description("Words from the title or author name, or an ISBN-13, e.g. 'Austen'")] string query,
[Description("Optional category slug, e.g. 'classics' or 'science-fiction'")] string? category = null,
CancellationToken ct = default)
{
PagedResult<BookSummary> result = await api.SearchBooksAsync(query, category, ct);
if (result.Items.Count == 0)
{
return $"No books match '{query}'. Try a shorter query or remove the category.";
}
IEnumerable<string> lines = result.Items.Select(b =>
FormattableString.Invariant($"{b.Isbn} | {b.Title} | {b.Author} | {b.Price:0.00} TRY | {b.PublishedYear}"));
return $"{result.Total} book(s):\n" + string.Join("\n", lines);
}
// get_book and list_institutions follow the same shape
}
ReadOnly = true is not decoration. According to GitHub's documentation, Copilot code review only uses an MCP tool if its readOnlyHint is true in the tools/list response. Try the server yourself before you give it to Copilot; we added a small file-based app to the repo for that. It starts the server over stdio, lists the tools with their annotations and calls two of them:
$ dotnet run eng/mcp-smoke.cs
server: bookstore 1.0.0, protocol 2026-07-28
search_books readOnlyHint=True
get_book readOnlyHint=True
list_institutions readOnlyHint=True
search_books(query: Austen) isError=False
2 book(s):
9780141439587 | Emma | Jane Austen | 249.90 TRY | 1815
9780141439518 | Pride and Prejudice | Jane Austen | 239.90 TRY | 1813
get_book(isbn: 9780000000000) isError=False
No book with ISBN 9780000000000. Use search_books to find the ISBN first.
For an unknown ISBN the tool does not throw, it returns an answer that tells the model what to do next. That part of tool design is the subject of the third post of the AI Agents and MCP series.
Introducing the MCP Server to Copilot
The hard part is not writing the server but registering it in the right place for each surface. As of 15 November 2026:
- Copilot CLI and the Copilot harness in VS Code:
.mcp.jsonat the repository root (the CLI also reads.github/mcp.json), with anmcpServerskey at the top. VS Code's migration guide recommends moving the old.vscode/mcp.jsoninto this file. - Visual Studio:
%USERPROFILE%\.mcp.json,<SOLUTIONDIR>\.vs\mcp.json,<SOLUTIONDIR>\.mcp.jsonand.vscode\mcp.json; the example in its documentation uses aserverskey. Tools start disabled and you turn them on by hand. - The cloud agent and code review on GitHub.com: not a file but JSON entered on the repository's Settings, Copilot, MCP servers page. The GitHub MCP and Playwright MCP servers are enabled by default.
Bookstore's .mcp.json:
{
"mcpServers": {
"bookstore": {
"type": "local",
"command": "dotnet",
"args": ["run", "--project", "src/Bookstore.Mcp", "--no-build"],
"env": {
"BOOKSTORE_API_URL": "${BOOKSTORE_API_URL:-http://localhost:5080}"
},
"tools": ["search_books", "get_book", "list_institutions"]
},
"playwright": {
"type": "local",
"command": "npx",
"args": ["-y", "@playwright/mcp@0.0.85", "--isolated", "--allowed-origins", "http://localhost:4200"],
"tools": ["browser_navigate", "browser_snapshot", "browser_click", "browser_type", "browser_console_messages"]
}
}
}
Two decisions here. The first is the tools list: the Playwright server reports 25 tools, we enable five, and --allowed-origins ties the browser to the Angular app's address. Many tools that do the same job confuse the model, and every tool definition takes up context. The second is that there is no secret: the API address comes from an environment variable. On the cloud agent side, secrets can only come from Agents secrets whose names start with COPILOT_MCP_.
We checked how the CLI sees the file with the copilot mcp commands, which need no login:
$ copilot mcp list
Warning: Skipped workspace MCP servers (.mcp.json) because the folder "/home/user/src/bookstore" is not trusted. To load them without approving all tools, add the folder to "trustedFolders" in /home/user/.copilot/config.json. COPILOT_ALLOW_ALL=true also trusts it, but approves all tools.
Builtin servers:
computer-use (local)
$ copilot mcp list
Workspace servers:
bookstore (local)
playwright (local)
Builtin servers:
computer-use (local)
$ copilot mcp get bookstore
bookstore
Status: Enabled
Type: local
Command: dotnet run --project src/Bookstore.Mcp --no-build
Environment:
BOOKSTORE_API_URL: ***
Tools: search_books, get_book, list_institutions
Source: Workspace (/home/user/src/bookstore/.mcp.json)
On the first run the file was not loaded at all: the CLI skips MCP servers in a folder it does not trust, with a warning rather than silently, because an MCP server runs code on your machine. After adding the folder to trustedFolders, both servers showed up. copilot mcp get masks the environment value and shows the enabled tool list. When we tried the servers key from the Visual Studio documentation in the same file, the CLI rejected it:
$ copilot mcp list
Warning: skipping workspace MCP config "/home/user/src/bookstore/.mcp.json" because it is malformed: mcpServers: Required
Builtin servers:
computer-use (local)
So today a single .mcp.json does not work in the same shape on every surface. In Bookstore we kept the file in the mcpServers format that the CLI and VS Code read; teammates on Visual Studio add the server through Tools, Add custom MCP server in chat or in their own .vs\mcp.json. We could not try how Visual Studio reads this file for this post.
From Prompt Files to Skills
In the fourth post we said that the Copilot harness in VS Code does not run prompt files and that we would write their skill counterparts in this post. An agent skill is a folder with a SKILL.md and optional scripts or example files. It is an open standard (agentskills.io); in Copilot the cloud agent, code review, the CLI, the agents in VS Code and Visual Studio, and JetBrains IDEs read skills. Project skills live under .github/skills, .claude/skills or .agents/skills; personal ones under ~/.copilot/skills.
---
name: new-endpoint
description: Adds a minimal API endpoint to a Bookstore module together with an integration test. Use when asked to add or expose a new HTTP endpoint in src/Bookstore.Api.
argument-hint: "[module] [HTTP method and route]"
disable-model-invocation: true
---
Add the endpoint given in the request to the named module (default: Ordering).
1. Read the module's `*Module.cs` and follow how its existing endpoints are mapped and typed.
2. Return `TypedResults`; describe expected failures with ProblemDetails.
3. Add an integration test in `tests/Bookstore.Api.Tests` that fails before the change and passes after it.
4. Run `dotnet build` and `dotnet test` and report the test summary.
Do not change existing tests. Stop and ask if the endpoint needs a new table.
The differences from a prompt file are in the frontmatter. name must match the folder name and may only contain lowercase letters, digits and hyphens (64 characters at most). description is at most 1,024 characters and is the most important field: the agent decides whether to load a skill from this description and reads the body only once the skill is chosen. disable-model-invocation: true makes the skill callable only as /new-endpoint; VS Code's automatic conversion adds the same field when it turns a prompt file into a skill, because the prompt file was only ever called by hand. The conversion drops the agent, model and tools fields, and argument-hint plus the request itself replace the prompt's ${input:...} variables.
We did not delete the prompt files: they still work in Visual Studio. The check script from the fourth post partly catches drift between the two copies; we added checks for skill, agent and hook files to it as well (name and folder match, description length, hook scripts that exist).
A Skill and a Custom Agent for Migrations
The real benefit of a skill is that it can carry a script. The riskiest kind of change in Bookstore is an Entity Framework Core (EF Core) migration: when the agent renames a property or shortens a column, the generated migration affects production data. The ef-migration-review skill ties this check to a script:
---
name: ef-migration-review
description: Checks EF Core migrations in src/Bookstore.Api/Data/Migrations for data loss (DropColumn, DropTable), renames, columns made non-nullable without a data step, and empty Down methods. Use when a migration is added or changed, or when reviewing a pull request that touches Data/Migrations.
---
Run the check from the repository root:
```
dotnet run .github/skills/ef-migration-review/check-migrations.cs
```
Pass a migration name (for example `AddOrderInstitution`) to check one migration; without an argument every migration is checked.
How to read the result:
- `OK` lines need no comment.
- `RISK` lines name the operation and the table. For each one, check whether the migration has a `migrationBuilder.Sql(...)` data step that copies or backfills the data first, and whether the pull request description explains the data loss.
- `ERROR` lines (an empty `Down`) must be fixed before merge.
Report the script output unchanged, then one sentence per `RISK` line with your recommendation. Never edit the migration yourself and never run `dotnet ef database update`.
The main loop of the script reads the Up method and looks for risky operations:
// Excerpt: the loop over migrations; MethodBody() returns the body of Up or Down.
foreach (string file in files)
{
string name = Path.GetFileNameWithoutExtension(file);
string source = File.ReadAllText(file);
string up = MethodBody(source, "Up");
string down = MethodBody(source, "Down");
bool hasDataStep = up.Contains("migrationBuilder.Sql(");
List<string> findings = [];
foreach (Match m in Regex.Matches(up, @"migrationBuilder\.(\w+)\(\s*name:\s*""(\w+)""(?:,\s*(?:schema:\s*""\w+"",\s*)?table:\s*""(\w+)"")?"))
{
string operation = m.Groups[1].Value;
if (risky.Contains(operation))
{
string target = m.Groups[3].Success ? $"{m.Groups[3].Value}.{m.Groups[2].Value}" : m.Groups[2].Value;
findings.Add($"RISK {name}: {operation} {target}{(hasDataStep ? " (has a Sql data step)" : " without a Sql data step")}");
}
}
// a nullable column that becomes NOT NULL needs a backfill for existing NULL rows
foreach (Match m in Regex.Matches(up, @"AlterColumn<[\w?]+>\(\s*name:\s*""(\w+)""[^;]*?nullable:\s*false[^;]*?oldNullable:\s*true", RegexOptions.Singleline))
{
findings.Add($"RISK {name}: AlterColumn {m.Groups[1].Value} becomes NOT NULL{(hasDataStep ? " (has a Sql data step)" : " without a backfill")}");
}
// a shorter string column: longer existing values make the update fail
foreach (Match m in Regex.Matches(up, @"AlterColumn<string>\(\s*name:\s*""(\w+)""[^;]*?maxLength:\s*(\d+)[^;]*?oldMaxLength:\s*(\d+)", RegexOptions.Singleline))
{
if (int.Parse(m.Groups[2].Value) < int.Parse(m.Groups[3].Value))
{
findings.Add($"RISK {name}: AlterColumn {m.Groups[1].Value} shrinks from {m.Groups[3].Value} to {m.Groups[2].Value} characters");
}
}
if (string.IsNullOrWhiteSpace(down))
{
findings.Add($"ERROR {name}: Down is empty, the migration cannot be reverted");
errors++;
}
int operations = Regex.Matches(up, @"migrationBuilder\.\w+").Count;
Console.WriteLine(findings.Count == 0 ? $"OK {name}: {operations} operations" : string.Join("\n", findings));
}
To try it, we made by hand a change an agent could make: we renamed Book.PublishedYear to FirstPublishedYear and shortened the Description column from 1,000 to 500 characters:
$ dotnet ef migrations add RenameBookYear --project src/Bookstore.Api --output-dir Data/Migrations
An operation was scaffolded that may result in the loss of data. Please review the migration for accuracy.
Done. To undo this action, use 'ef migrations remove'
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "PublishedYear",
schema: "catalog",
table: "Books",
newName: "FirstPublishedYear");
migrationBuilder.AlterColumn<string>(
name: "Description",
schema: "catalog",
table: "Books",
type: "nvarchar(500)",
maxLength: 500,
nullable: false,
oldClrType: typeof(string),
oldType: "nvarchar(1000)",
oldMaxLength: 1000);
}
$ dotnet run .github/skills/ef-migration-review/check-migrations.cs
OK 20261114005447_InitialCreate: 27 operations
OK 20261114234258_AddInstitutions: 8 operations
OK 20261114234727_AddOrderInstitution: 3 operations
RISK 20261115001954_RenameBookYear: RenameColumn Books.PublishedYear without a Sql data step
RISK 20261115001954_RenameBookYear: AlterColumn Description shrinks from 1000 to 500 characters
EF Core's own warning is generic: "may result in the loss of data". The script says which operation is risky. Our first version did not see the shortened column; after EF Core's warning we added the rule. To show why a shortened column matters, we ran it on SQL Server 2025 in our own scratch database: with a ten-character value in place, shrinking the column to five characters stops the update.
Msg 2628, Level 16, State 1, Line 1
String or binary data would be truncated in table 'copilotseries_06.dbo.ShrinkTest', column 'x'. Truncated value: ''.
The statement has been terminated.
The script catches rules; interpreting these risks is a role. For that we defined a custom agent:
---
name: migration-reviewer
description: Reviews EF Core migrations in Bookstore for data loss, risky renames and broken Down methods. Use when a change adds or edits files under src/Bookstore.Api/Data/Migrations.
tools: ["read", "search", "execute"]
---
You review EF Core migrations in the Bookstore repository. You never edit files.
1. Find the migrations that the task is about (default: the newest file in `src/Bookstore.Api/Data/Migrations` that is not a `.Designer.cs` file).
2. Run `dotnet run .github/skills/ef-migration-review/check-migrations.cs <MigrationName>` and copy its output.
3. Read the migration's `Up` and `Down` methods and the entity configuration it came from.
4. For every `RISK` line, say what happens to existing rows in production and what data step or deployment order would make it safe.
5. End with one line: `Verdict: safe`, `Verdict: needs a data step` or `Verdict: do not merge`.
Do not run `dotnet ef database update` or any command that changes a database.
A custom agent profile is a Markdown file with frontmatter. read, search and execute in the tools list are GitHub's tool aliases; without edit this agent cannot change files. The profile lives in the repository under .github/agents; organization owners can put the same files in the organization's .github or .github-private repository to make them available to every repository, and on a name clash the repository's definition wins. The body can be at most 30,000 characters. Some fields depend on the surface: mcp-servers is not used in IDEs, and handoffs in VS Code only works in the Local harness. Visual Studio 18.12 now looks for user-level agents in %USERPROFILE%\.copilot\agents, like Copilot CLI.
We also ran the agent in Copilot CLI with the local model (--agent migration-reviewer). The CLI loaded the profile and recorded it in the event log with tools: ["read","search","execute"], but the 4-billion-parameter model did not follow the instructions: instead of running the script it queried a migrations table that does not exist in the CLI's in-session sql tool, and stopped without a result. That is a limit of the model, not of the profile; test a custom agent's quality with the model your team will use, on a small task with a known answer.
Enforcing Rules with Hooks
A hook file is JSON with version: 1 and a list of commands per event name. Copilot CLI, the cloud agent and the Copilot harness in VS Code read .github/hooks/*.json files in the repository; Visual Studio's release notes do not mention hook support.
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"matcher": "bash|powershell",
"bash": ".github/hooks/scripts/guard-shell.sh",
"powershell": ".github/hooks/scripts/guard-shell.ps1",
"timeoutSec": 10
}
],
"postToolUse": [
{
"type": "command",
"matcher": "edit|create",
"bash": ".github/hooks/scripts/format-cs.sh",
"powershell": ".github/hooks/scripts/format-cs.ps1",
"timeoutSec": 60
}
]
}
}
#!/usr/bin/env bash
# preToolUse: sees the agent's shell command before it runs and denies dangerous ones.
# Input is JSON on stdin; toolArgs can be a JSON string or an object. Needs jq.
set -euo pipefail
input=$(cat)
command=$(jq -r '.toolArgs | (if type == "string" then fromjson else . end) | .command // ""' <<< "$input")
deny() {
jq -cn --arg reason "$1" '{permissionDecision: "deny", permissionDecisionReason: $reason}'
exit 0
}
case "$command" in
*"dotnet ef database"*) deny "Applying migrations is done by a developer, not by the agent." ;;
*"git push"*) deny "Pushing is done by a developer after review." ;;
*"rm -rf"*|*"git reset --hard"*|*"git clean -f"*) deny "Destructive file commands are blocked in this repository." ;;
esac
# No output: the decision falls through to the normal approval flow.
A preToolUse hook receives the JSON on stdin before the tool runs; an answer with permissionDecision set to deny stops the call, and permissionDecisionReason goes back to the agent. Printing nothing leaves the decision to the normal approval flow. On Windows the CLI runs the script under the powershell key; the cloud agent only uses bash in its Linux sandbox. As GitHub recommends, we first tried the scripts by hand, piping sample JSON into them:
$ echo '{"toolName":"bash","toolArgs":{"command":"dotnet ef database update --project src/Bookstore.Api"}}' | .github/hooks/scripts/guard-shell.sh
{"permissionDecision":"deny","permissionDecisionReason":"Applying migrations is done by a developer, not by the agent."}
$ echo '{"toolName":"bash","toolArgs":{"command":"dotnet test"}}' | .github/hooks/scripts/guard-shell.sh
$ echo '{"toolName":"powershell","toolArgs":{"command":"Remove-Item -Recurse -Force src"}}' | pwsh -NoProfile -File .github/hooks/scripts/guard-shell.ps1
{"permissionDecisionReason":"Destructive file commands are blocked in this repository.","permissionDecision":"deny"}
$ echo '{"toolName":"create","toolArgs":{"path":"src/Bookstore.Api/Catalog/IsbnFormat.cs"}}' | .github/hooks/scripts/format-cs.sh
{"additionalContext":"dotnet format fixed whitespace in src/Bookstore.Api/Catalog/IsbnFormat.cs. Read the file again before the next edit."}
There is no output for dotnet test on the third line, so the decision falls through to the normal flow. format-cs.sh runs on postToolUse: after the agent writes a .cs file, it fixes the formatting with dotnet format whitespace and tells the agent through additionalContext to read the file again.
You need to know the failure behavior. According to the documentation, if a preToolUse command hook crashes or exits with a code other than 0 or 2, the tool call is denied (fail-closed), but a timeout is fail-open for every event: when the time runs out, the call continues through the normal flow. A slow security hook therefore gives no security. On the cloud agent an ask decision counts as deny because there is no user to answer, and the hook file only affects the cloud agent once it is on the default branch.
Trying It in Copilot CLI with a Local Model
To see MCP, skills, agents and hooks working together, we ran Copilot CLI without a GitHub account, with COPILOT_OFFLINE=true and a local Ollama model. In this mode the CLI makes no network calls; GitHub sign-in, the GitHub MCP server and delegation to the cloud agent are off. First we asked for the command from Guess First explicitly, then we asked for book prices:
$ export COPILOT_OFFLINE=true COPILOT_PROVIDER_BASE_URL=http://127.0.0.1:11434/v1 COPILOT_MODEL=qwen3:4b
$ copilot -p "Run this exact shell command and report its output: dotnet ef database update --project src/Bookstore.Api" \
--available-tools bash --allow-tool shell --output-format json > guard.jsonl
$ jq -c 'select(.type=="tool.execution_complete") | {success: .data.success, error: .data.error.message}' guard.jsonl
{"success":false,"error":"Denied by preToolUse hook: Applying migrations is done by a developer, not by the agent."}
$ copilot -p "Which Jane Austen books does Bookstore sell, and at what price? Use the bookstore tools." \
--disable-mcp-server playwright --allow-tool bookstore --output-format json > mcp.jsonl
$ jq -c 'select(.type=="tool.execution_start") | {tool: .data.toolName, arguments: .data.arguments, readOnly: .data.readOnly}' mcp.jsonl
{"tool":"bookstore-search_books","arguments":{"query":"Austen"},"readOnly":true}
{"tool":"bookstore-get_book","arguments":{"isbn":"9780141439587"},"readOnly":true}
{"tool":"bookstore-search_books","arguments":{"category":"classics","query":"Emma"},"readOnly":true}
{"tool":"bookstore-get_book","arguments":{"isbn":"9780141439587"},"readOnly":true}
The answer to the guess: neither the user's explicit request nor the model following it got past the hook. The model called the command as asked, the hook denied it and the reason went back to the model; in its next answer the model explained with that sentence why the command did not run. The "do not run dotnet ef database update" line in the add-migration skill never even entered the context for this request, because the skill can only be called as /add-migration. Had the rule only been written down, the model would have followed the user's explicit request and run the command. One more note: the model passed on the reason correctly but then wrote a muddled list of advice about migration commands, which is what you would expect from a small model.
In the second run the CLI started the server from .mcp.json itself and called the tools prefixed with the server name, such as bookstore-search_books, with readOnly: true. The small model queried the same book twice and in the end printed a tool call as plain text instead of writing the answer. The mechanics worked; answer quality is the model's job. The same session's event log also shows the CLI loading Bookstore's three skills (next to three built-in CLI skills) and the migration-reviewer agent.
In Production
An MCP server runs code. The cloud agent and code review use MCP tools added in repository settings without asking for approval, and GitHub's own warning says so. Only put read-only tools there, with an explicit tools list.
Pin the version. We wrote @0.0.85 instead of @playwright/mcp@latest; a package that can change in every session means the server you trust today can run different code tomorrow. That is also why Visual Studio resets granted approvals when a server's tool list changes, and since 18.7 asks for trust again when a server changes.
Skill scripts and hooks are code too. Tie the .github/skills, .github/agents and .github/hooks folders to an owner with CODEOWNERS. allowed-tools: shell in a skill's frontmatter removes the terminal approval for that skill; GitHub recommends it only for skills you have reviewed and fully trust.
At the organization level, MCP use can be turned off with a policy and limited with an allowlist; we cover that in the tenth post.
Common Pitfalls
Adding an untrusted MCP server. Instructions hidden in tool descriptions (tool poisoning) and rug pulls were covered in the thirteenth post of the AI Agents and MCP series; community skills carry the same risk, and GitHub does not verify skills.
Enabling many tools that do the same job. If you enable Playwright's 25 tools, every GitHub MCP toolset and your own server together, the model picks the wrong tool and every request sends thousands of tokens of tool definitions.
Writing a rule only as an instruction. The model may follow the user's explicit request; commands that must be stopped belong in a hook.
Writing a slow hook. Timeouts are fail-open; a preToolUse hook that makes a network call stops nothing once the time runs out.
Keeping a skill's description short. The agent picks skills from their descriptions; a skill that says "Migration helper" is never loaded automatically.
A Small Experiment
★☆☆ Write a .mcp.json in your own repository and look at the output of copilot mcp list and copilot mcp get. Which warning do you get while the folder is not trusted?
★★☆ Add a dotnet ef migrations remove rule to guard-shell.sh and try it first by piping by hand, then in Copilot CLI. Slow the hook down on purpose with sleep 20 and see what happens with timeoutSec: 10.
★★★ Add a rule to check-migrations.cs for a NOT NULL column added with AddColumn and no default value. Generate such a migration in Bookstore, see the script catch it, then run the migration-reviewer agent with the model your team uses and compare its report with the script output.
So far Copilot has always run on your machine. In the next post we move the work to GitHub: assigning an issue to the Copilot coding agent, preparing the GitHub Actions environment with copilot-setup-steps.yml, the firewall, and reviewing the pull request it opens.