Unofficial, community-built client — not affiliated with or endorsed by Anthropic. "Claude" is a trademark of Anthropic.
┌── /think
│
├── Bash / Linux
├── Filesystem
├── LibreOffice
ResearchMesh ──┼── Playwright
├── MCP #1
├── MCP #2
├── MCP #3
└── ...
A terminal chat client for the Anthropic API that hands Claude real tools on your own Linux
machine: a shell, a file editor, a headless browser it can surf with, a persistent Python
session, desktop control, and document conversion. Ask it something and it can look it up,
read the pages, run the commands, and hand you back a finished .docx — in one conversation.
It works in both directions: it connects out to your own MCP servers, and it can itself be added to Claude Code as one, so Claude Code can hand it the jobs it can't do — see below.
18 local tools, plus whatever your MCP servers expose:
| Tool | For |
|---|---|
bash |
Shell commands as your user. Stateless — fresh subprocess each call |
str_replace_based_edit_tool |
View, create, and edit files |
web_search · web_fetch |
Anthropic's server-side search and page fetch |
memory |
A /memories store that persists across sessions — the only state that outlives the process |
computer |
Screenshots plus mouse/keyboard control of your desktop. Needs an X11 session (see below) |
browser_navigate · _links · _click · _fill · _extract · _back |
Headless Playwright — real DOM surfing: renders JavaScript, follows links, fills forms |
document_convert |
LibreOffice + pandoc. Markdown → .docx/.odt/.pdf, or any office format to any other |
python |
Persistent IPython kernel — variables survive between calls |
interactive_run |
Commands that prompt: passwords, [y/N], ssh host keys, installers, REPLs |
config_edit |
Edit YAML/TOML/JSON without destroying your comments |
sql_query |
DuckDB straight against CSV/Parquet/JSON — no import step |
trash |
Recoverable deletes instead of rm |
Claude chooses the tools and keeps working until it has an answer.
You need Linux, Python 3.11+, and an Anthropic API key — this is an API client, so a Claude subscription won't work.
MCP servers are optional. The [mcp] block in config.toml ships with
enabled = false and every server commented out, so a fresh clone runs on the 18
local tools alone. The commented entries are kept as worked examples of both entry
shapes — the addresses and paths in them are machine-specific, so replace them with
your own before uncommenting and setting enabled = true.
mcp_client.py is just a script to connect to your MCP server and pull a list of tools, be sure you change the IP address in the code.
sudo apt install python3 python3-venv python3-dev build-essential \
libreoffice pandoc python3-tk scrot
python3 -m venv ~/claude-chat-plus-more-tools
source ~/claude-chat-plus-more-tools/bin/activate
pip install -r requirements.txt
playwright install chromium # pip installs the package, not the browser
sudo playwright install-deps chromium
export ANTHROPIC_API_KEY=sk-ant-... # add to ~/.bashrc to keep it, and put your N8N API key in .bashrc as well, or you will have to rewrite code to make it elsewhere if not exporting it before runnning main.py
python main.pyThen just type. /think <message> gives Claude longer to reason on hard problems;
Ctrl-C exits and shuts everything down cleanly.
MCP servers are optional — all 18 local tools work without any of them.
Run uname -a and tell me what kernel I'm on.
What's the latest stable Python release? Cite your source.
Open news.ycombinator.com, list the top links, then open the first one and summarise it.
Load ~/data.csv and show me the five biggest rows by revenue.
Write a one-page summary of the Raft consensus algorithm as markdown,
then convert it to a .docx in ~/Documents.
Give me a Cisco IOS 17.15 config for a 9200 24-port switch: VTP client so my VLAN
database isn't overwritten, two uplinks active/standby at 1 Gbps, all 24 ports up and
ready for voice + data VLANs pushed from the VLAN server, uplink trunk on VLAN 100.
Note what I need to change for my environment, then write it to /tmp/switch.txt.
What is the airspeed velocity of an unladen swallow?
Non-secret settings live in config.toml. Secrets stay in the environment — the app does
not read a .env file.
[claude]
model = "claude-sonnet-5" # CLAUDE_MODEL overrides this
[mcp]
enabled = true # false skips every server; local tools still work
# One line per server. Add as many as you like — every reachable/launchable one
# connects and its tools join the same list Claude sees. Two entry shapes:
#
# Streamable HTTP (a server already running elsewhere):
# url the server's endpoint
# token_env names the environment variable holding that server's bearer
# token; omit it if the server needs none
#
# stdio (a local server main.py launches itself, no separate process to start
# by hand — it talks JSON-RPC over the subprocess's stdin/stdout):
# command full argv as a list, e.g. ["node", "/path/to/bin.js"]
# env optional table of extra environment variables for it
servers = [
{ name = "n8n", url = "http://192.168.2.12:5678/mcp-server/http", token_env = "N8N_MCP_TOKEN" },
{ name = "alpaca", url = "http://192.168.2.12:8000/mcp" },
{ name = "unreal", command = ["node", "$HOME/unreal-mcp/dist/bin.js"] },
]A server that's unreachable (http) or fails to launch (stdio) prints a warning and is skipped, so one being down doesn't stop the app. Tokens are never written in this file — only the name of the variable that holds them.
~, $USER, $HOME and ${ANY_VAR} are expanded in command, url and the values of
env, so the checked-in config doesn't have to name your home directory or mount point.
(env's keys are variable names and are left alone.) An undefined variable is left as
written rather than expanding to nothing, so a typo shows up in the startup warning instead
of becoming a silently wrong path. Absolute paths beyond that are still machine-specific —
those you edit by hand.
| Variable | Purpose |
|---|---|
ANTHROPIC_API_KEY |
Required |
| (per server) | Whatever each token_env names, e.g. N8N_MCP_TOKEN |
RESEARCHMESH_MCP_TOKEN |
Bearer token clients must present to mcp_server.py --transport streamable-http; unset = no auth |
CLAUDE_MODEL |
Override the model |
CLAUDE_SHOW_USAGE=1 |
Print token and prompt-cache counts per request |
CLAUDE_MEMORY_DIR |
Where memory stores /memories (default ./memories) |
CLAUDE_DISPLAY_SIZE |
Logical screen size computer reports, e.g. 1280x800 |
CLAUDE_COMPUTER_FORCE=1 |
Let computer try anyway on a Wayland session |
ResearchMesh is a client and a server at the same time. The two are independent — use either, both, or neither:
Claude Code ──delegate──▶ ResearchMesh ──▶ n8n / Unreal / Unity / …
(any MCP client) (server AND client) (its own MCP servers)
│ │ │
mcp_server.py 18 local tools [mcp] in config.toml
As a client, it connects out to MCP servers and merges their tools with its own — that's
[mcp] in Configuration above. As a server, it hands another client the
whole agent as one delegate tool, so Claude Code can offload what it structurally can't do
itself: drive GUI apps, answer password / [y/N] prompts, keep a live Python kernel between
steps, surf a real DOM, and reach ResearchMesh's own MCP servers.
claude mcp add researchmesh --scope user \
--env ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
-- "$HOME/tif-env/bin/python" /path/to/ResearchMesh/mcp_server.pyThat's it — no token, no ports, nothing to start. Claude Code launches the server itself when it needs it. Then just ask it to delegate something: "use researchmesh to take a screenshot and tell me what window is focused."
Two ways it fails, both at the first call:
ANTHROPIC_API_KEYnot set — a client passes stdio servers only a small safe subset of the environment, so exporting it in your shell isn't enough. That's what--envabove is for. The server says so at startup rather than failing cryptically later.- Wrong python — use the venv interpreter that has the dependencies, not bare
python. The client spawns this with noPATHof yours and no activated venv.
A .mcp.json ships in the repo as a working equivalent if you'd rather commit the config than
run the command.
Streamable HTTP — for clients that connect to an already-running endpoint
stdio (above) is right whenever the client launches its own server — Claude Code, Claude Desktop, most editors. Use HTTP instead to share one agent between several clients, or for a client that only speaks HTTP:
python mcp_server.py --transport streamable-http --port 8765
# point the client at http://127.0.0.1:8765/mcp--host defaults to 127.0.0.1, reachable only from this machine. --path, --port and
--json-response are there too (--json-response returns one JSON body instead of an SSE
stream).
Auth is the token_env arrangement from config.toml, pointed the other way. Set the
variable and it's required; leave it unset and the endpoint is unauthenticated, which is
allowed by design and announced at startup:
export RESEARCHMESH_MCP_TOKEN=<token> # see Tokens below
python mcp_server.py --transport streamable-http --host 0.0.0.0Clients send Authorization: Bearer <token> — exactly what a token_env entry produces, so
another ResearchMesh consumes this one with a plain config.toml line. Same token, same
variable name, set on both machines:
{ name = "desktop", url = "http://192.168.2.5:8765/mcp", token_env = "RESEARCHMESH_MCP_TOKEN" }Unauthenticated and bound off-loopback prints a warning, because at that point anyone who
can reach the port has unrestricted shell and desktop control of the machine. The token is
read from the environment, never passed as an argument, so it stays out of ps and shell
history. --token-env VAR renames the variable.
Both transports are the same server object — no separate build, no high-level-server rewrite. Under HTTP the stdout guard is skipped (fd 1 isn't the wire there) so the app's messages become ordinary service logs, line-buffered so a redirected log fills in live rather than on exit. Running the stdio form by hand just waits on stdin, which is a healthy stdio server behaving normally.
Tokens — generating one, and where it actually has to live
Only needed for --transport streamable-http. Under stdio there's no port and nothing to
authenticate.
Generate one with the interpreter this project already requires — no openssl needed:
python -c "import secrets; print(secrets.token_urlsafe(32))"256 bits from the OS CSPRNG. There's deliberately no generate_token.py here: a file wrapping
one line of stdlib would be the same mistake as a tool wrapping a command bash could already
run.
The value lives in an environment variable; only its name goes in a file. Which file depends on how the process starts, and this is the part that catches people:
| How it starts | Where the token has to be |
|---|---|
| You, from an interactive shell | export RESEARCHMESH_MCP_TOKEN=… in ~/.bashrc |
systemd unit |
EnvironmentFile= — a unit does not read ~/.bashrc |
| Spawned by an MCP client | the env block of that server's entry in the client config |
Two things to get right:
- Never put the literal token in a committed file.
.mcp.jsonandconfig.tomlare both in git — use${RESEARCHMESH_MCP_TOKEN}andtoken_envrespectively. - One name is normally right. It's one token, and each end reads the variable from its own
environment, so both machines can call it
RESEARCHMESH_MCP_TOKEN. You only need a second name if a single machine both serves an endpoint and consumes someone else's — then one variable would have to mean two different secrets. Rename either end with--token-env VARortoken_env = "VAR".
Can you just ask ResearchMesh to set it up? Mostly. It can generate the token, append the
export to ~/.bashrc, write a systemd EnvironmentFile, and update a consuming
config.toml. It cannot set the variable in your shell — the bash tool is a fresh
subprocess per call, and a child can't alter its parent's environment anyway — so you still
need a new shell (or source ~/.bashrc) and a server restart. Tell it not to write the
literal token into anything in the repo.
- There is no approval prompt. Claude runs the commands and file edits it decides on, as
your user, with no y/n in between. Built for local development.
trashexists so deletes are at least recoverable. - It's your API key: one request can fan out into many tool calls (capped at 30 per turn).
bashforgets everything between calls —cd, exports, activated venvs. Chain with&&, or usepython, which keeps state.- Ask for files by absolute path. If Claude offers a download link instead, tell it you need the file written to disk.
- Nothing under
/tmpcan be trashed (tmpfs has no trash), so deletes there would be permanent — the tool says so rather than pretending. computerdoes not work on Wayland. It drives the screen through X11/XTEST, which Wayland compositors ignore by design, so clicks and keystrokes never reach native windows and screenshots come back blank. Check withecho $XDG_SESSION_TYPE; if it printswayland, the tool refuses up front and tells you why rather than clicking into the void. Fix it with an Xorg session orxvfb-run -s '-screen 0 1280x800x24' python main.py— details under Full setup detail. Every other tool is unaffected.- If Sonnet gets inconsistent on a complicated multi-tool request, set
modelto an Opus one. - Optional packages are imported only when a tool is used, so a missing one breaks just that tool and tells you what to install.
- If a tool reports a missing package that
requirements.txtalready lists (e.g.sql_query'sduckdb, orconfig_edit'sruamel.yaml/jsonpath-ng), that's not a docs gap — your venv just predates that line. Everything inrequirements.txtis a>=floor rather than a pin (there's no lockfile), so a venv can satisfy it and still miss a package added later. Re-runpip install -r requirements.txt; you don't need to restart the app, because each optional package is imported at the moment its tool is called. - Linting: one linter is configured,
ruff, andruff check .should pass.pyproject.tomlhas a[tool.ruff.lint]section. It adds no rules — it only switches three off, each with its reason written next to it, so a clean run is the expected baseline and any finding you do see is genuinely new: your own code, or a rule a newer ruff added. (The rule selection is left at ruff's defaults, which do shift between versions.) Ruff is not a dependency and nothing runs it for you — install it yourself if you want it. There's no[tool.black]and no.pylintrc. - Type checking:
mypy .should pass too.pyproject.tomlhas a[tool.mypy]section setting exactly one option (ignore_missing_imports, because the optional tool backings are lazily imported and legitimately absent from a bare venv); strictness stays at mypy's defaults, so unannotated function bodies aren't checked. It's worth having here because mypy checks against the packages you actually have installed, which makes it the gate that catches a dependency changing shape under you — it named every mcp 1.x → 2.x rename in one run, including the ones incore/tools.pythat the smoke test can't reach. python smoke_test.pybefore you commit. Seconds, no API key, no network, no optional packages. It checks that everything imports, that the tool registry is well-formed, that the tool count in the docs still matches the code, and thatmcp_server.pycompletes an MCP handshake. GitHub Actions runs it plusruffandmypyon every push and PR tomain(.github/workflows/ci.yml), on Python 3.11 and 3.14.- There are still no unit tests, and CI deliberately doesn't exercise the tools themselves
— that would need LibreOffice, a browser, an X11 display and real API credits. If your venv
happens to have
pylint/blackinstalled (neither is a project dependency) or the system hasshellcheck, they're safe to run by hand — expect plenty of output, since nothing is configured for them. - Two things a linter will fight you on here — worth knowing before you "fix" them.
Broad
except Exception/except BaseExceptionis the design, not sloppiness: every local tool must catch anything and return an error string rather than crash the chat loop, which is whyBLE001is switched off project-wide. And cleanup paths (shutdown,close) must not be able to fail or fail silently — narrowing one has already caused a real bug, sincezmq.ZMQErrorisn't anOSErrorand escapingshutdown()turns an ordinary Ctrl-C into a traceback. Blanket catch plus aprint()is the pattern.
Full setup detail — OS libraries, document tools, which package backs which tool
Playwright. pip installs the Python package but not the browser or its OS libraries:
playwright install chromium # the browser binary
sudo playwright install-deps chromium # OS libraries (e.g. libmanette)playwright install with no browser name fetches all three engines; this app only launches
Chromium, so the argument is worth keeping.
Document conversion. soffice (LibreOffice) handles docx/odt/xlsx/pptx/html/rtf/txt and
PDF output, each call in a throwaway user profile so two conversions can't collide on the
profile lock. pandoc handles markdown, because soffice has no dependable markdown
import; md → pdf goes through odt on the way, since pandoc's own PDF writer would need a
LaTeX engine. libreoffice-writer/-calc/-impress alone are enough if you don't want the
whole suite.
Computer use needs two apt packages that pip won't install. pip install pyautogui
succeeds without them, so the failure is misleading — the tool reports pyautogui as missing
when it is right there:
python3-tk—pyautoguipulls inmouseinfo, which importstkinterat module level. Without it,import pyautoguiraises andcomputerreturns its install hint for a package you already have.scrot—pyscreezeonly has a screenshot path ifgnome-screenshotis present (which lets it use Pillow'sImageGrab) orscrotis. With neither, capture fails on X11 even though every Python package is installed. Either works;scrotis the lighter one.
Computer use also needs X11. The computer tool synthesises input through X11/XTEST, which
Wayland compositors deliberately ignore — on a Wayland session clicks and keystrokes never
reach native windows and screenshots come back blank, so the tool refuses up front and says
so instead of failing silently. Check with echo $XDG_SESSION_TYPE. Options:
# 1. Log in to an "Xorg"/"X11" session at your display manager, or
# 2. Run the whole client inside a nested X server:
sudo apt install xvfb
xvfb-run -s '-screen 0 1280x800x24' python main.py
# 3. XWayland-only setup and you want to try regardless:
export CLAUDE_COMPUTER_FORCE=1The tool reports a fixed logical screen size (CLAUDE_DISPLAY_SIZE, default 1280x800)
and downscales every screenshot to exactly that, scaling Claude's coordinates back up to
your real resolution. That's what keeps clicks landing where Claude aims — the declared
size and the image it sees can never drift apart. Below roughly 1280x720, accuracy drops.
Memory writes to ./memories by default (CLAUDE_MEMORY_DIR to relocate). Claude sees
it as /memories; every command is confined to that directory, so a traversal path like
/memories/../../.ssh/id_rsa is rejected rather than served. It's a private scratchpad for
Claude, not a place for your project files — and it persists until you delete it.
Optional Python packages (all in requirements.txt; each is imported lazily):
| Tool | Needs |
|---|---|
python |
jupyter_client, ipykernel |
interactive_run |
pexpect |
config_edit |
ruamel.yaml (YAML), tomlkit (TOML), jsonpath-ng ($… queries); JSON needs nothing |
sql_query |
duckdb |
trash |
send2trash |
computer |
pyautogui, pillow — plus python3-tk and scrot from apt, and an X11 display (see below) |
memory |
nothing — standard library only |
To drop a tool entirely, remove its module from MODULES in core/local_tools.py.
Environment variables must be exported for the user account you launch as — main.py
calls os.getenv() directly. Put them in ~/.bashrc for interactive shells, or
~/.bash_profile / ~/.profile for login shells (e.g. SSH). Note export, and no spaces
around =; VAR = value is a bash syntax error. Then open a fresh shell or source it, and
check without revealing anything:
echo "key: ${ANTHROPIC_API_KEY:+set} token: ${N8N_MCP_TOKEN:+set}" # per your token_env namesBuilt and tested on Ubuntu 26.04 LTS (kernel 7.0.0), Python 3.14.4, Playwright 1.61.0.
pyproject.toml requires 3.11+ (the floor is tomllib, used by main.py); 3.14 is just what
it was run on. The install-deps step assumes a Debian/Ubuntu apt system.
HTTPS and TLS — for an MCP server with a self-signed or private-CA certificate
A server URL may be http:// or https://. TLS is verified by the httpx client inside
mcp_client.py, offline, against a local CA bundle — the CA is not contacted at connect time.
A publicly-signed certificate (Let's Encrypt, DigiCert, …) works with no configuration. A
self-signed or internal-CA certificate isn't in certifi, so point httpx at a bundle that
contains your CA:
export SSL_CERT_FILE=/path/to/your-ca-chain.pem # or SSL_CERT_DIR for a hashed dirTwo things that catch people out:
SSL_CERT_FILEreplaces the default trust store rather than adding to it. If the same process also needs public HTTPS hosts, concatenate:cat "$(python -m certifi)" your-ca.pem > combined-ca.pem- Your server (or its reverse proxy) must present its full chain. A missing intermediate is the most common "the cert is valid but it still won't connect" cause, and the fix is on the server — the client only needs the root.
The OS trust store (/etc/ssl/certs) does not affect this app.
Project layout and extending
main.py entrypoint — connects the MCP servers, wires Chat + REPL
mcp_client.py MCP client (stdio / SSE / Streamable HTTP)
mcp_server.py the other direction — serve this agent to an MCP client
.mcp.json example Claude Code registration for mcp_server.py
smoke_test.py fast wiring checks — no API key, no network
.github/workflows/ci.yml runs ruff + smoke_test.py on push and PR
config.toml model + MCP server list (no secrets; committed)
pyproject.toml metadata, deps, and the ruff exemptions (lint config)
requirements.txt the same deps, for `pip install -r`
CLAUDE.md architecture + conventions, for AI coding agents
core/
chat.py agentic loop, tool routing, SYSTEM_PROMPT
claude.py Anthropic SDK wrapper
local_tools.py registry of every locally-executed tool
tools.py MCP <-> Anthropic bridge
claude_learned_schemas.py bash, file editor, web_search, web_fetch
memory.py /memories store, persists across sessions
computer.py screenshots + mouse/keyboard (X11 only)
browser.py Playwright DOM surfing
documents.py LibreOffice / pandoc conversion
kernel.py persistent IPython kernel
processes.py pexpect — commands that prompt
config_edit.py comment-preserving YAML/TOML/JSON edits
data.py DuckDB queries
files.py recoverable deletes
output.py shared output trimming + image results
cli.py prompt_toolkit REPL
- Add an MCP server: add an entry under
[mcp].serversinconfig.toml— see "Configuration" above for both entry shapes (urlfor Streamable HTTP,commandfor a local stdio server main.py launches itself). Its tools appear to Claude automatically once it connects. A one-off Python stdio script can also be passed as an argument instead (python main.py path/to/server.py) without touching config.toml. - Add a local tool: write a module exposing
TOOLS,handles(name), andasync execute(name, tool_input), then add it toMODULESincore/local_tools.py. That's the only registration step. UpdateSYSTEM_PROMPTincore/chat.pytoo — it describes the tool set to Claude. - Keep the list lean. Tool-selection accuracy degrades past roughly 30–50 tools, so prefer
one tool with a mode parameter over several near-duplicates, and don't wrap a command
bashcould already run.
Check every configured server on its own with python mcp_client.py — it connects to each
in turn, lists its tools, and reports failures without starting the chat.
Optional: MCP Inspector — for debugging an MCP server (needs Node)
This project is pure Python; Node.js is not a dependency. For hand-calling the tools your MCP endpoint exposes, the MCP Inspector runs on demand with no install step:
npx @modelcontextprotocol/inspector@latestAn external debugging aid, nothing in the repo depends on it.
MIT — use it, fork it, ship it. No warranty; see the file for the full text.