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:
~/.bingo/skills/<name>/SKILL.md— yours, in every project..bingo/skills/<name>/SKILL.mdat every level from the working directory up to the filesystem root, nearest first — so a package speaks before the repository around it.- The
guideskill 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:
| field | meaning |
|---|---|
name | what the skill answers to; the directory name when absent |
description | what it is for; the body’s first line when absent |
argument-hint | what to show beside the name while completing, e.g. [issue-number] |
arguments | names for the positional arguments, in order |
allowed-tools | read and recorded, never enforced |
model | read 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.
| placeholder | stands for |
|---|---|
$ARGUMENTS | everything typed after the name |
$1 … $9 | the whitespace-separated words of it, 1-based |
$name | the 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.