Skip to article
Browse chapters

Skills and MCP servers

Two ways to add to what bingo can do: procedures written down as files, and tools from another process.

Two ways in

Skills are procedures written as prose. A SKILL.md on disk becomes a /name command a person types, a tool the model can call, and a line in the system prompt saying it exists.

MCP servers are tools from another process. Configured servers are dialled in the background and their tools are offered to the model like any other — untrusted, so the permission gate asks about every call.

Both arrive the same way. Registration is synchronous and does no I/O, so neither plugin registers a fixed set: each contributes a source that answers from whatever it has now, and answering with nothing is never wrong (ADR-0009). A skill saved mid-session is in the next completion; a server that is still dialling has no tools yet, and that is not an error.

Where a skill lives

Three layers, most important first:

  1. ~/.bingo/skills/<name>/SKILL.md — yours, in every project.
  2. .bingo/skills/<name>/SKILL.md at every level from the working directory up to the filesystem root, nearest first — so a package speaks before the repository around it.
  3. The guide skill bundled in the binary, which any disk skill of the same name overrides.

Walking up from the working directory is what actually happens, rather than finding a git root: no git process is spawned, and a directory outside a repository has skills too. A directory that is an ancestor of itself in two layers is read once.

The directory’s name is the skill’s name unless the frontmatter says otherwise. The library re-reads a layer when any path it watched changes size or modification time, so saving a skill takes effect without restarting.

What a SKILL.md is

Frontmatter is optional, and it counts only when --- is the file’s first line. Six fields are read:

fieldmeaning
namewhat the skill answers to; the directory name when absent
descriptionwhat it is for; the body’s first line when absent
argument-hintwhat to show beside the name while completing, e.g. [issue-number]
argumentsnames for the positional arguments, in order
allowed-toolsread and recorded, never enforced
modelread and recorded, never enforced

arguments may be written either way — arguments: issue branch and arguments: [issue, branch] say the same thing. Any other key is ignored rather than refused, so a file carrying fields bingo does not read still works.

Everything after the frontmatter is the body, and the body is a prompt.

---
name: deploy
description: Ship a branch to an environment, with the checks that must pass first.
argument-hint: <environment>
arguments: environment
---

Deploy to $environment.

Before you start, run the test suite and stop if anything fails.
The deploy script is at ${BINGO_SKILL_DIR}/deploy.sh.

Three ways one surfaces

As a command. Each skill is its own /name, in the skill family, with the argument hint beside it in the / dropdown. It is not instant: a skill is a prompt, so it opens a turn and waits for the one that is running. Running it produces a Prompt outcome — the expanded body becomes what the turn is asked.

/deploy staging

As a tool. The model calls Skill with a name and optional arguments, and gets that skill’s instructions back as the result — so a body costs context only when it is wanted. Skill is read-only and trusted, and its permission subject is the skill’s name, which means a rule addresses it by name:

{ "permissions": { "deny": ["Skill(deploy)"] } }

Naming a skill that does not exist is answered with the ones that do, rather than with a failure.

As a line in the system prompt. A contributor lists every available skill as - name — description, under a short preamble saying that calling Skill is how to reach one and that a person types /<name> for the same thing. Names and descriptions only; a description longer than 250 characters is cut, because past that it is not a description. The block is absent when there are no skills.

Arguments

Substitution is one left-to-right pass, so a value that itself contains $1 is inserted as text and never expanded again.

placeholderstands for
$ARGUMENTSeverything typed after the name
$1 … $9the whitespace-separated words of it, 1-based
$namethe word at the position arguments: declared for that name
${BINGO_SKILL_DIR}the skill’s own directory

An indexed placeholder with no word at its position is left as written; a named one becomes empty. Anything else beginning with $ is not a placeholder and is left alone, and $ARGUMENTS[0] is left whole rather than half-expanded. Arguments that no placeholder asked for are not dropped — they are appended as a final ARGUMENTS: <text> line.

Two differences from the dialect this borrows from are worth knowing: $N is 1-based here, and \$1 does not escape a placeholder.

Configuring an MCP server

Two settings keys, both claimed by the mcp plugin: mcpServers, merged by name, and disabledMcpServers, which accumulates across layers. A disabled server is never dialled.

{
  "mcpServers": {
    "files": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/srv/data"]
    },
    "issues": {
      "type": "http",
      "url": "https://mcp.example.com/v1",
      "headers": { "Authorization": "Bearer …" }
    }
  },
  "disabledMcpServers": ["issues"]
}

Two transports, told apart by type, which defaults to stdio:

  • stdio — a child process speaking the protocol on its stdin and stdout: command, args, env, cwd.
  • http — a streamable-HTTP endpoint: url, headers.

An entry is checked once, when it is read, and never carried around half understood. A field belonging to the other transport is a startup failure — url and headers belong to an http server, command, args and env belong to a stdio server — as is a missing command or url, and as is any key the schema does not know. A server that silently never dials is worse than one that says why.

The values under env and headers are where a person keeps their tokens, so they are never printed: only the names they were given.

--mcp-config

bingo --mcp-config ./bundle.json "read the open issues"

A JSON file whose mcpServers are added for this run — a host’s bundle. Only that key is taken from it; anything else in the file is left alone. It becomes a settings layer above the files and below the command line, so a bundle adds to the servers your settings already name rather than replacing them.

A path that does not exist is refused with --mcp-config: <path> does not exist, and a file with no mcpServers key with --mcp-config: <path> has no mcpServers.

How a server’s tools reach the model

The plugin contributes a tool source, and start puts one dial per enabled server on its own task and returns at once. Each dial has a five-second connect timeout. Whatever has landed by the time a turn begins is that turn’s tool set.

That means the first turn of a session may run before a slow server has answered. It is deliberate: the alternative is a session that will not start until the slowest server on the list has replied.

A server’s tools reach the model as mcp__<server>__<tool>. Both names are copied exactly as written, so a server or a tool whose own name contains __ still reaches the rule written for it. The catalogue entry carries the server it came from.

They gate as untrusted

Nothing a server says about itself is believed. An MCP tool wears the fail-closed default traits — not read-only, not concurrency-safe, not trusted — so the gate asks about every call whatever readOnlyHint claimed. This is the same stance every tool from outside the binary gets, including cross-process plugins.

The permission grammar addresses them at either grain:

{
  "permissions": {
    "allow": ["mcp__files__read_file"],
    "deny":  ["mcp__issues"]
  }
}

mcp__server covers every tool of that server; mcp__server__tool covers one. See settings and permissions.

/mcp

An instant command — it reads the table and starts dials, and touches nothing a running turn is using, so the tool set a turn already gathered stays as it was.

With no arguments it answers a table of server, status and tools, where the status is connecting, connected, disabled, or failed: with the reason. Three verbs change things:

/mcp reconnect <server>
/mcp enable <server>
/mcp disable <server>

Each answers the moment it has started something, not when it has finished — a handshake takes seconds, and a command that waited for one would be a command that hangs. What it did shows up in the next /mcp. Naming a server that is not configured says which ones are.

Where a server’s noise goes

A child’s stderr goes to ~/.bingo/data/logs/mcp-<server>.log, never to the terminal. Left alone it would inherit the terminal and paint over a full-screen surface, which never redraws its scrollback.

Which one to reach for

A skill is the right shape when the knowledge is a procedure — what to do, in what order, and how to check it worked — and bingo already has the tools to carry it out. It costs nothing but a line in the prompt until it is invoked.

An MCP server is the right shape when bingo needs a capability it does not have: another system’s data, another team’s API. If what you want is a bingo-native tool, command or hook rather than an MCP server, the cross-process plugin bridge is the other road, and it carries views and completions that MCP has no shape for.