Kotoba

Where to start

What Kotoba isWho she is and where she runs, in one page
InstallingOne package, two commands
First runA key, a model, her first words
The approval gateHow she asks before she acts
The two voice modesLocal voice, or the agent tunnel
The soul fileChange who she is
RoadmapWhat grows next, and what was cut on purpose

Or a section

↑↓ move openesc close124 pages
All pages

The agent14 of 15

MCP servers

About 8 minutes to read

On this page

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:

NameWhat it isNeeds
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
githubThe official hosted GitHub MCP (api.githubcopilot.com)GITHUB_PERSONAL_ACCESS_TOKEN
google calendar@cocal/google-calendar-mcpGOOGLE_OAUTH_CREDENTIALS, plus a one-off npx … auth run
notionmcp.notion.combrowser sign-in
linearmcp.linear.appbrowser sign-in
slackslack.com/api/mcpbrowser 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

  1. Already connected? Just activate it for this session and say so. No card.

  2. Setup unmet? Refuse before spending a card on a certainty. google-calendar gets 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.

  3. 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 --headless Source: Kotoba's own list of known servers — not a registry search

    The card deliberately offers no "always allow" button, because nothing on this path persists one.

  4. 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: queryone 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-mcpspotify-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 "Ig­nore 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.