The agent14 of 15
MCP servers
On this page
- mcp_install — a known server, by name
- What happens
- mcp_find — a server you have no name for
- activate_tools — load a connected server's tools
- The browser sign-in
- What a server can and cannot reach
- It cannot reach your machine's network interior
- It cannot reach your secrets through the placeholder
- Its text is scanned before the model ever reads it
- Its tool calls are not carded
- Its credentials are never written to disk in the clear
Three tools in the mcp toolset — mcp_install, mcp_find, activate_tools — plus everything a
connected server exposes. All work mode only.
A connected server's tools are registered as ordinary tools with the toolset mcp:<server>, the name
<server>__<tool> and risk: network.
The mcp Python package is optional. Without it the manager is a no-op and the rest of the companion
is intact.
mcp_install — a known server, by name
Takes: name — one of exactly eight:
| Name | What it is | Needs |
|---|---|---|
browser | @playwright/mcp, pinned — navigate, click, type, read the accessibility tree, screenshot | — |
filesystem | @modelcontextprotocol/server-filesystem over one folder | — |
memory | @modelcontextprotocol/server-memory, a knowledge graph | — |
github | The official hosted GitHub MCP (api.githubcopilot.com) | GITHUB_PERSONAL_ACCESS_TOKEN |
google calendar | @cocal/google-calendar-mcp | GOOGLE_OAUTH_CREDENTIALS, plus a one-off npx … auth run |
notion | mcp.notion.com | browser sign-in |
linear | mcp.linear.app | browser sign-in |
slack | slack.com/api/mcp | browser sign-in |
Aliases are accepted: files, file system, google, calendar, gcal, git hub, notion mcp,
playwright, navegador, web browser.
This is an allow-list, not a search. The schema is explicit: "Do NOT invent commands or package names — just pass the name."
What happens
-
Already connected? Just activate it for this session and say so. No card.
-
Setup unmet? Refuse before spending a card on a certainty.
google-calendargets its own sentence — the generic "needs an API token, set X, then reconnect" is true of GitHub and false of Calendar in every word. GitHub's missing token is not refused this way, because it is answerable from inside Kotoba. -
Ask you. Even a curated server runs third-party code on your host. The card names the exact command line, the server, and its source:
Runs:
npx -y @playwright/mcp@0.0.75 --headlessSource: Kotoba's own list of known servers — not a registry searchThe card deliberately offers no "always allow" button, because nothing on this path persists one.
-
Connect, then persist to
~/.kotoba/mcp.yaml(KOTOBA_MCP_CONFIG), then activate.
Installing browser does not open a browser. ensure_browser used to run before the card, so a
window landed on your screen even when you then said no.
If the connect succeeded but the save failed, the reply says so rather than papering over it: "I couldn't save this connection, so it's for THIS session only — after a restart it will need installing again."
If it connected but exposed no tools, that is a failure, not a green tick — the process was spawned and a save was attempted, so the row keeps its audit entry rather than being marked as nothing-ran.
mcp_find — a server you have no name for
Takes: query — one concise keyword. "Spotify", "Postgres", "Stripe". Not a sentence: a long
query fails.
Searches the official MCP registry. If that finds nothing, it falls back to searching PyPI and npm — and the card says so, loudly:
Registry: PyPI / npm - NOT the official MCP registry NOT verified by the official MCP registry - found on PyPI/npm, where anyone may publish a look-alike. Only install it if you trust it.
The approval card is the real gate here, and it is built defensively:
- Nothing third-party is interpolated into a sentence of ours. Every field from the registry goes through a one-line flattener first, so a server name carrying newlines cannot forge a second "Secrets needed: none" block above the real one.
- A demand for a secret is its own field, not part of a headline. The 145-character headline used
to clip
Secrets needed: Secr…behind a toggle. - The description is shown as a quote, marked as the server's own words.
After you approve, any required values are collected one at a time — masked for the ones the registry marks as secret, plain otherwise. Each box has its own four endings, and one left empty is told apart from one that expired.
A discovered name is never trusted. A registry name is reduced to a short local one
(io.github.acme/spotify-mcp → spotify-mcp), and if that would land on a reserved name it gets
a hash suffix instead. Reserved means: any of the eight known servers, anything in
KOTOBA_MCP_ALWAYS_ACTIVE, and the server half of the secret allow-list. Without it,
io.evil/browser-mcp would reduce to browser — always-active, and the one name whose tools may
receive your one-time password.
The suffix is a SHA-256 prefix of the registry name, not Python's hash(), which is seed-randomised
per process: the same package used to get a different local name on every restart, so a saved server
never lined up with the one just discovered.
If nothing is found: try one different keyword, then stop. The schema says "don't loop."
activate_tools — load a connected server's tools
Takes: server.
Connected servers other than the browser are deferred: their tools are not offered until the model asks for them. GitHub alone is 44 tools, and tool-selection accuracy falls off a cliff past roughly 30–50 offered at once.
The work prompt lists the connected-but-not-loaded servers under AVAILABLE TOOLSETS. Calling
activate_tools('<name>') makes that server's tools appear on the next step, because the loop
re-reads the schemas every iteration.
Activation lasts the session and lives in memory.
Matching is by token, not substring, so git does not activate a connected github — a bare
substring match did exactly that.
browser is always active (KOTOBA_MCP_ALWAYS_ACTIVE, comma-separated, defaults to browser).
Deferring it would add a round trip to every task.
The browser sign-in
Notion, Linear and Slack cannot be finished from a tool call.
The connect preflight probes the URL with a real initialize handshake, outside the shared
session group so an auth-required server cannot crash it. A clear 401/403 is classified: token
means a pasted API key can fix it, oauth means a browser sign-in is needed.
For oauth, the server is recorded as pending — secret-free — and Settings grows a "Needs
connection" entry with a Sign in button. Pressing it runs the SDK's OAuth 2.1 flow: discovery,
dynamic registration, PKCE, a browser callback, token exchange. The resulting token then connects
through the same Bearer path an API-key server uses.
Work mode is told this in so many words:
"That sign-in can ONLY be completed by the USER with the 'Sign in' button in Settings — you CANNOT finish it by opening the service's website and logging in there. When you get that 'added to Settings' result, you are DONE for now: STOP and report that the server is ready and waiting for their one-tap sign-in."
Timeouts: 30 s to produce the authorisation URL, 300 s for you to approve it in the browser. Enough is persisted to refresh the token independently — the SDK auto-refreshes only for a live connection's own auth.
For token, a masked box asks for the key, the connect is retried with a Bearer header, and the
token goes to the backend-only credential store with a pointer in the saved config — never the
token itself.
If the retried connect exposes no tools, the credential this call stored is taken back out. Otherwise the server is gone and the secret is still there, under a name nothing mentions. A key that was already in the store is left alone: it belongs to whatever saved it.
What a server can and cannot reach
It cannot reach your machine's network interior
A remote server URL is model- and attacker-influenced, so it goes through the same SSRF guard
web_extract uses: loopback, private, link-local, reserved, multicast and known internal hostnames
are refused. This is the one caller with an opt-out — KOTOBA_ALLOW_LOCAL_MCP=1 — because the URL
can come off a public registry.
It cannot reach your secrets through the placeholder
{{secret:NAME}} resolves in exactly two argument positions of two browser tools. Anywhere else the
call is refused. See Browser and secrets.
Its text is scanned before the model ever reads it
Every model-facing string a server provides — its tool names, descriptions, and every nested JSON-Schema string, to a bounded depth — is scanned for prompt injection before the tool is registered. A match means the tool is not registered at all, and the rejection is logged.
The normalising pre-pass matters more than the pattern list. A model reads a soft-hyphenated
"Ignore all instructions" as the plain sentence; a regex over raw bytes does not, because \s
matches neither a soft hyphen nor a zero-width space. So invisibles are resolved, compatibility forms
folded, accents stripped, and the text is scanned twice — once with invisibles deleted and once
with them turned into spaces, because deleting a zero-width space joins two words while replacing a
soft hyphen splits one.
Patterns cover English and Spanish, fake role tags (<system>, <|im_start|>, ### System,
[SYSTEM]), "do not tell the user", "pre-approved", and any mention of a well-known secret path. A
description longer than 4,000 characters is rejected on length alone: "A description this long is not
documentation; it is a place to hide something."
The file is honest about its own limits:
"This is a filter, not a boundary — it cannot be complete, and the real defence is that fetched text is data, never instructions."
Its tool calls are not carded
This is the honest gap. An MCP tool call goes straight to the server. It is audited — every one
writes an executed row, since network risk makes it an action — but there is no approval card
between the model deciding to call it and the call happening.
What is gated is installing the server. What is not gated is what it then does.
Its credentials are never written to disk in the clear
headers and env are stripped before a server config is saved to mcp.yaml. Auth is re-derived at
connect time from a pointer or from the environment. A persisted header would also shadow
revocation — deleting the keystore entry left her still authenticating with the old credential.
A ${VAR} reference in a config is expanded at connect time, and expanded consistently: the SSRF
guard, the preflight probe and the connect must all be about the same request. They were not — only
one of the three expanded — so a properly configured remote server was probed with the literal
Bearer ${GITHUB_MCP_TOKEN}, answered a bare 401, and was filed as "needs a token" on every boot,
with the token sitting in .env the whole time.
