vrchat-mcp
Allows interaction with the VRChat API, providing tools for managing worlds, avatars, users, groups, instances, and store products, including uploading files and images, handling two-factor authentication, and subscribing to real-time events.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@vrchat-mcpsearch for the world 'Midnight Bar' and show its stats"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
vrchat-mcp
An MCP server for the VRChat API. All 297 operations from the OpenAPI spec, generated at build time, plus hand-written tools for the things a single endpoint cannot do: two-factor login, file uploads, viewing images, and the event pipeline.
Runs locally over stdio, as a subprocess of Claude Code or Claude Desktop. Read-only until you say otherwise.
Built on Bun, the official MCP TypeScript SDK v2, and the official
vrchat JavaScript SDK. Tools come from
the VRChat OpenAPI specification and are
committed, so the surface tracks upstream instead of rotting against it.
Cheatsheet
bun install && bun link # `vrchat-mcp` is now on PATH
cp .env.example .env # fill in username, password, contact
claude mcp add vrchat -- vrchat-mcpMinimum .env:
VRCHAT_USERNAME=you
VRCHAT_PASSWORD=hunter2
VRCHAT_CONTACT=you@your-domain.tld # must be real, VRChat 403s generic agentsI want to | Do this |
Enable creating and editing |
|
Enable deleting and moderating | add |
Enable spending balance | add |
Only expose storefront tools |
|
Expose everything |
|
Find out why a tool is missing | call |
Stop huge payloads eating context |
|
See an image |
|
Upload a picture |
|
Fix a login stuck on a new network | open the emailed link, then |
Tool names carry their origin. Two underscores means generated from the spec
(vrchat__getCurrentUser), so the name is searchable in VRChat's own docs. One underscore
means this server wrote it (vrchat_authStatus).
Hand-written tools, in full:
Tool | Does |
| Login state, rate limiter, and which tool groups are hidden by which env var |
| Answers a parked login with the code the user read out |
| Restarts a login after a new-network email link is opened |
| Clears the stored session |
| Downloads a VRChat image and returns it as a viewable image |
| Runs VRChat's four-step upload for non-image files |
| Uploads an image and attaches it to a store product |
| Events since a cursor |
| Blocks until the next matching event |
| Full-text search over stored event history |
| Socket state and per-type retention |
The last four appear only with VRCHAT_MCP_WEBSOCKET=1.
Related MCP server: Portals MCP
Install
bun link puts a vrchat-mcp executable on your PATH, so nothing downstream needs to know
where the checkout lives.
bun install
bun link # from the repo rootRegister it by name:
claude mcp add vrchat -- vrchat-mcpOr for Claude Desktop, in claude_desktop_config.json:
{
"mcpServers": {
"vrchat": {
"command": "vrchat-mcp"
}
}
}That is the whole config. Credentials come from the repo's .env, so they do not have to be
repeated here, though anything you put in an env block wins. Remove the command with
bun unlink.
If you would rather not put anything on your PATH, point at the entry file with an absolute path. The server is spawned from an arbitrary working directory, so a relative one will not do.
claude mcp add vrchat -- bun run /abs/path/to/vrchat-mcp/src/index.tsThe contact requirement
VRChat rejects generic User-Agents with a 403. VRCHAT_CONTACT goes into the descriptive
User-Agent the SDK sends on every request, API and WebSocket alike, and it is effectively
mandatory.
The value has to be real. The SDK refuses any contact containing @example.com, so the obvious
placeholder is the one value guaranteed to fail. The server reports that as a configuration
error on the first tool call rather than letting it surface as a mysterious 403.
Configuration
Three layers, highest priority first. A project can set its own options without repeating your credentials.
Real environment variables, including an MCP client's
envblock.envin the directory the command runs from, which Bun loads automatically.envat the repo root
So a project that wants storefront tools only, using credentials you already configured, needs one line next to it:
# ~/my-project/.env
VRCHAT_MCP_TAGS=storeVariable | Default | Effect |
| none | Account username or email |
| none | Account password |
| none | Base32 TOTP secret. Set it and login never prompts |
| none | Contact string in the User-Agent. Effectively required |
| all | Tags to register. |
| off | Creating and editing |
| off | Deleting and moderating. Needs the write gate too |
| off | Spending balance. Needs the write gate too |
| off | Admin operations. Independent of the write gate |
| 20 | Requests per second. |
| 30000 | How long a call waits behind the limiter before giving up |
| off | Opens the event pipeline and registers the event tools |
| low-noise set | Event types to subscribe to. Replaces the default, does not extend it |
| 1000 | Events kept per type. Per-type overrides: |
| 30d | Age ceiling. |
| project | Event database path |
| project | Session file path |
| none | HTTP or HTTPS proxy for API and WebSocket traffic |
| 300000 | How long a parked login waits for a code |
| off | Opts into the live test suite |
Booleans accept 1 or true, case-insensitive.
Where state lives
State is per project. Run the server inside a project and its session and event history live in
that project's .vrchat-mcp/. The project root is found by walking up from the working
directory for a .git, package.json, deno.json, pyproject.toml or go.mod, so launching
from a subdirectory reaches the same state instead of stranding a second session a level down.
The directory hides itself from version control: vrchat-mcp writes a .gitignore containing
* inside it on creation, so the session file, which is an auth credential, is protected
without the host project needing a rule for it.
Each project therefore logs in separately, and the first call in a new project may ask for a 2FA code. To share one login everywhere, point every install at the same file:
VRCHAT_MCP_SESSION=/abs/path/to/shared/session.jsonSafety gates
The server starts read-only. 150 of the 297 operations register by default. Nothing that writes, deletes, spends or moderates appears until you ask for it.
Class | What it covers | Needs | Examples |
| Every | nothing |
|
|
|
|
|
| Every |
|
|
| Buying, and Tilia/KYC/payout paths |
|
|
| Admin and account lifecycle |
|
|
Destructive and money layer on top of writes, so enabling writes grants exactly the ability to create and edit, never to delete or to spend. Admin stands alone and is implied by nothing: letting an agent edit your own content must never also let it delete the account.
What you are opting into:
ALLOW_WRITESlets an agent create and change things you own. Reversible, mostly by hand.ALLOW_DESTRUCTIVE_WRITESadds the calls with no undo. Deletes, bans, kicks, closing instances, wiping user persistence.ALLOW_PURCHASESlets an agent spend real balance.purchaseProductListingis a live transaction. Do not set this because a tool listing looked incomplete.ALLOW_ADMINexposesdeleteUseramong others. Most of these 403 on a normal account, butdeleteUseris the one that must never be an accident.
Gated operations stay in the generated table either way, so coverage remains 1:1 with the spec
and the denylist is reviewable in the diff. MCP annotations (readOnlyHint, destructiveHint)
are set as well, so clients that surface them can prompt.
Which tools am I missing?
A gated tool is simply absent, which reads as "VRChat cannot do this" rather than "this server was told not to". That mistake has already been made in the wild: an agent reported the economy API as read-only when the write tools existed and were merely behind a flag.
vrchat_authStatus closes the gap. It reports every tag and safety class, how many operations
each holds, how many are currently exposed, and the exact .env change that would expose the
rest.
{
"availability": {
"toolsRegistered": 12,
"toolsHidden": 285,
"tagFilter": ["store"],
"kinds": { "write": { "enabled": false, "hidden": 88 } },
"nextSteps": [
"88 `write` operations are hidden. Ask the user to set VRCHAT_MCP_ALLOW_WRITES=1 ..."
]
}
}Call it before concluding something is unsupported.
Choosing which tools to expose
VRCHAT_MCP_TAGS selects tags. Unset registers everything, and everything says so
explicitly, which is easier than deleting a key from a JSON config. all and * work too.
VRCHAT_MCP_TAGS=everything # all 297 operations
VRCHAT_MCP_TAGS=store # just the storefront, 19 operations
VRCHAT_MCP_TAGS=store,users,worlds # matches any of the threeSpec tags: authentication, avatars, calendar, economy, favorites, files, friends,
groups, instances, inventory, invite, jams, miscellaneous, notifications,
playermoderation, prints, props, users, worlds. Plus store, which this server adds.
A tag matching nothing is warned about on stderr at startup and reported by
vrchat_authStatus. Without that, a typo like stores registers no generated tools and looks
exactly like a broken server.
Logging in
Login is lazy. Nothing authenticates at startup, so tools/list works with no credentials at
all and the server stays inspectable. The first tool call that needs a session triggers the
login.
With VRCHAT_TOTP_SECRET set, that is the whole story. No prompts, ever.
Without it, VRChat emails a code and the call comes back parked rather than hanging:
vrchat__getCurrentUser
-> Login paused: VRChat emailed a code. Ask the user for it, call
vrchat_submitTwoFactorCode { requestId: 'a1b2c3d4', code: '……' },
then retry the original call.Answer it with vrchat_submitTwoFactorCode, then retry. The session persists, so this happens
once per project until it expires.
Logging in from a new network
Changing proxy, VPN or ISP triggers a check that is not a two-factor code, and the two look alike enough to waste real time. VRChat answers with one of these:
401 It looks like you're logging in from somewhere new! Check your email for a message from VRChat.
429 Logging in from too many places? Check your email for verification linkBoth mean the same thing, and neither is what it looks like. The email holds a link, not a
six-digit code, so vrchat_submitTwoFactorCode cannot help. The login takes two rounds:
A tool call fails with one of those messages
The user opens the link in the email
Call
vrchat_retryLogin. VRChat sends the actual code only on this second attemptThe user reads the code out, call
vrchat_submitTwoFactorCodeRetry the original tool
The 429 is an auth challenge wearing a rate-limit status, so the local limiter ignores it.
Waiting does not clear it, and every extra attempt costs one of the account's limited session
slots, which is what produces the 429 in the first place. A failed login is cached for 30
seconds so a burst of tool calls cannot become a burst of login attempts. vrchat_retryLogin
clears that cache, because by then the user has done the thing the failure was waiting on.
Parallel calls during login
Agents fan tools out at once, and on a cold start they all land on an unauthenticated client.
One call drives the login. The others wait up to three seconds and then return login_pending
rather than blocking, so a slow login stalls one tool call instead of every tool call, and a
login that parks on a code raises one prompt instead of several.
_responseKeys
Every tool takes _responseKeys, and every tool returns the raw upstream payload by default.
There is no server-side curation, because a hand-picked field list guesses what matters, is
wrong for whoever needed the other field, and has to be maintained for 297 operations against a
spec that moves. The agent knows what it wants on this call. It should say so.
A World object is roughly 4 KB. Narrowing usually cuts that by more than half.
Pattern | Selects |
| the whole payload, byte for byte |
| those top-level fields |
| a nested path |
|
|
| that field from every element of |
| everything below each element |
| excludes, and combines with |
Projection keeps the shape. Objects stay nested, arrays keep their order and length, so a path learned on one call still works on the next.
Discovery matters more than the projection. An agent cannot ask for keys it does not know
exist, and a silently empty result would make this design worse than trimming. So a path that
matches nothing comes back as _unmatched, alongside _availableKeys listing what was
actually there. Array element keys are named as *.id, *.name, the form that works as a
_responseKeys entry.
["*"] returns the input by reference, so the raw path is provably lossless and nothing is
ever hidden.
Viewing images
vrchat_getImage downloads a VRChat image and returns it as an image block, so the model can
look at it rather than report a URL.
{ "name": "vrchat_getImage",
"arguments": { "url": "https://api.vrchat.cloud/api/1/file/file_.../1/256" } }Pass any imageUrl or thumbnailImageUrl from a user, world, avatar, print or product, or
pass fileId and let the tool build the URL. savePath also writes the bytes to disk.
Prefer a URL ending in /256 or /512 when one exists. The image is carried as base64, so a
full-size texture costs a lot of context and buys no extra detail. Anything over 4 MB is
refused; raise maxBytes if you mean it.
The tool only fetches VRChat-hosted images, and only api.vrchat.cloud is ever sent your
session cookie. A tool that fetches a caller-supplied URL while holding a session is a
request-forgery primitive unless it is constrained, and a cookie sent to a CDN is a cookie
given away.
Uploading files
Pass a local file path. The server runs on your machine and reads the file itself, so file contents never enter the conversation. Inlining a 2 MB PNG as base64 would cost roughly 2.7 MB of tool arguments, more than everything else in the call combined.
When there is no file on disk, for instance an image the agent just produced, the same argument takes the bytes inline:
{ "file": { "data": "iVBORw0KGgo...", "mimeType": "image/png", "filename": "icon.png" } }A data: URI works in the string position too, so
"file": "data:image/png;base64,iVBORw0..." is equivalent. filename is optional and is
invented from the MIME type when omitted, because VRChat rejects an upload it cannot name.
Prefer a path whenever one exists: inline costs about 1.33 bytes of tool argument per byte of
file, and that comes out of the same context budget as everything else.
Eight operations take a file directly, one call each:
Tool | Field | For |
|
| Icons, gallery, emoji, stickers, product images ( |
|
| Prints |
|
| Profile icons |
|
| Gallery |
|
| Replacing a print's image |
|
| Invite photos |
|
| Invite requests |
|
| Invite responses |
{ "name": "vrchat__uploadImage",
"arguments": { "file": "C:/Users/me/Pictures/icon.png", "tag": "icon" } }The result names the bytes that were sent, and which form they arrived in, which is the only way to tell a successful upload of the right file from a successful upload of the wrong one:
{ "uploaded": [
{ "field": "file", "name": "icon.png", "bytes": 48211, "type": "image/png", "source": "path" }
],
"result": { "id": "file_...", "name": "icon.png" } }For everything else, vrchat_uploadFile runs VRChat's four-step sequence (create the record,
request a presigned URL, transfer the bytes, finish) and returns the completed file record. Use
it for asset bundles and unity packages. The bytes go straight to VRChat's storage provider
with a plain request, deliberately not through the API client, because that client attaches
your session cookie to everything it sends and the storage host is a third party.
Uploads are writes, so all of this needs VRCHAT_MCP_ALLOW_WRITES=1. Files are capped at
100 MB, and an empty file is refused before it reaches VRChat, which would otherwise store a
broken record. If vrchat_uploadFile fails partway it names the file record it created, so you
can inspect it with vrchat__getFile and remove it with vrchat__deleteFile.
Running a store
Managing a storefront is an ordinary write, not a money operation. Creating a product,
renaming it, changing its image, publishing or unpublishing a listing: none of these spend or
earn anything, so they need only VRCHAT_MCP_ALLOW_WRITES=1. The money gate is for buying
and for the payment processor.
VRCHAT_MCP_TAGS=store
VRCHAT_MCP_ALLOW_WRITES=1Setting a product image takes one call:
{ "name": "vrchat_setProductImage",
"arguments": { "productId": "prod_...", "file": "/abs/path/cover.png" } }That uploads with tag: "product" and attaches the returned file id as the product's
imageId. By hand it is vrchat__uploadImage with tag: "product" or "listinggallery",
then vrchat__updateProduct with the id it returns.
One thing VRChat itself does not allow: a listing exposes only active for editing, so its
price, title and description cannot be changed after creation. Delete the listing and create a
new one. The name, description and image live on the product and are editable through
vrchat__updateProduct.
Pagination
One page per call. Paginated tools default to 25 results and echo back a nextOffset to
continue with. There is no internal pagination loop, deliberately: a hidden auto-paginate would
burn the request budget and a large slice of context inside what looks to the agent like a
single call.
A short page means the end. VRChat reports no total, so that is the only reliable signal.
WebSocket events
Off by default, because an idle always-on socket burns a session slot. Set
VRCHAT_MCP_WEBSOCKET=1 to open it and register the four vrchat_events* tools.
VRCHAT_MCP_WS_EVENTS picks which types to subscribe to, replacing rather than extending the
default set of notification, notification-v2, economy-update, friend-online,
friend-offline and instance-queue-ready.
Pipeline messages are double-encoded: the content field is stringified JSON needing a second
parse, except for see-notification and hide-notification, which carry bare ids. All of that
is normalised once at ingest, so no tool ever hands you a JSON string inside JSON. The SDK's
own socket silently drops those two message types, which is one reason this server does not use
it. The other is that it takes no proxy.
Retention is per type
History goes to SQLite at .vrchat-mcp/events.db, and it keeps 1000 events per event type,
not 1000 in total. A chatty type like friend-location can never evict a rare, valuable one
like economy-update, which a single global cap would do within minutes.
VRCHAT_MCP_HISTORY=1000,friend-location:200,economy-update:5000
VRCHAT_MCP_HISTORY_MAX_AGE=7dAn age ceiling runs alongside the count cap, and whichever bites first wins. Count alone lets a
rarely-fired type sit on months-old events that read as current. Age alone lets a burst blow up
the database. vrchat_eventsStatus reports which limit is currently binding, per type, so the
window is legible instead of silent.
History survives restarts, so vrchat_eventsSearch can answer what happened while you were
away. A live-only buffer cannot.
Proxy
VRCHAT_MCP_PROXY routes traffic through an HTTP or HTTPS proxy, with optional user:pass@
credentials.
VRCHAT_MCP_PROXY=http://127.0.0.1:8080
VRCHAT_MCP_PROXY=https://user:pass@proxy.internal:8443SOCKS is not supported. Bun's fetch rejects socks5:// outright, so a SOCKS URL fails at
startup with a configuration error naming the limitation rather than half-working. Front it
with a local HTTP proxy instead.
The proxy covers both API and WebSocket traffic. The two go through different mechanisms, and the failure mode of getting only half of it right is a server that looks proxied while leaking its real IP on the event stream.
If the proxy cannot be reached, calls fail with a clear error. The server never silently falls back to a direct connection, because for anyone using this for IP separation that is the worst possible outcome. The proxy URL is never logged, since it may embed credentials.
Development
bun link # install the vrchat-mcp command on PATH
bun unlink # remove it
bun run generate # regenerate tools from the latest upstream spec
bun run generate --offline # regenerate from the committed snapshot, no network
bun test # offline suite
bun run test:live # live suite, needs VRCHAT_LIVE_TESTS=1
bun run inspect # MCP Inspector against this server
bun run typecheck # tsc --noEmitbun run generate fetches vrchatapi/specification at main, bundles it, and writes the
bundled spec plus spec/VERSION.json (upstream SHA, timestamp, content hash) alongside the
regenerated src/generated/operations.ts. Both are committed, so every regeneration produces
two reviewable diffs, the spec change and the tool change it caused, and a bad upstream commit
is revertible rather than load-bearing. --offline reproduces the output from the committed
snapshot byte for byte with no network at all.
src/generated/operations.ts is generated. Do not edit it by hand.
Ten operationIds have no matching method in the VRChat SDK, because the spec moves faster than the client library. Those route through a raw-request fallback on the same client, so cookies, User-Agent, proxy and rate limiting still apply, and 1:1 coverage stays true instead of quietly becoming a lie. Codegen prints the list on every run.
stdout is the JSON-RPC channel. All logging goes to stderr, and one stray console.log
corrupts the protocol stream.
Testing
bun test is the offline suite: codegen output, gating, the rate limiter against a fake clock,
history retention and search, projection, error mapping, upload path handling. No network, no
credentials, no account. This is what runs by default.
bun run test:live hits a real account, opted into with VRCHAT_LIVE_TESTS=1 and skipped
otherwise. Rules it holds itself to:
Reads and creator-owned writes only. It hard-refuses anything classified
moneyoradmin, before a client is even constructed. A test suite must not be able to spend money.Every write cleans up after itself and is tagged so stray artifacts are identifiable in-game.
It routes through the same limiter as production and stays small. A run that trips VRChat's throttling is worse than no run.
Assertions are on shape and status, never volatile content. Friend counts and world listings move between runs.
Use a dedicated account where you can. Credentials come from
.envonly.
Security
.envand.vrchat-mcp/are gitignored, and.vrchat-mcp/also ignores itself from within so it stays hidden inside other projects..vrchat-mcp/session.jsonis an auth credential, a valid session cookie. Treat it like a password. Deleting it, or callingvrchat_logout, forces a fresh login.2FA codes, passwords, TOTP secrets and proxy URLs are never logged, including to stderr.
Your session cookie goes to
api.vrchat.cloudand nowhere else. Uploads to VRChat's storage provider and image fetches from its CDN deliberately bypass the authenticated client.Errors come back as structured results carrying status, VRChat's own message, and an actionable hint. Raw exceptions and stack traces never reach the transcript.
stdio only, local only. No HTTP transport, no multi-user credential isolation. This server is for one account on one machine.
Project layout
scripts/generate-tools.ts # build-time codegen: spec -> src/generated/operations.ts
spec/openapi.bundled.json # committed snapshot of the upstream spec
spec/VERSION.json # upstream SHA + fetch timestamp + content hash
src/config.ts # the entire env surface, read once
src/types.ts # shared contracts
src/generated/operations.ts # committed, generated, 297 entries, do not edit
src/vrchat/client.ts # lazily-authed VRChat client, proxy, 2FA sniffing
src/vrchat/twofactor.ts # pending-code broker
src/vrchat/ratelimit.ts # token bucket + global 429 backoff
src/vrchat/events.ts # websocket client + waiter registry
src/vrchat/history.ts # bun:sqlite event store, per-type retention + FTS5 search
src/tools/auth.ts # authStatus / submitTwoFactorCode / retryLogin / logout
src/tools/images.ts # getImage
src/tools/upload.ts # uploadFile / setProductImage
src/tools/events.ts # eventsRecent / eventsWait / eventsSearch / eventsStatus
src/registry.ts # gating, registration, the one shared handler
src/project.ts # _responseKeys path projection
src/upload.ts # local path -> File, with size and type guards
src/errors.ts # HTTP status -> structured tool error with hint
src/index.ts # serveStdio entry point
tests/ # offline suite; tests/live/ is the opt-in live suite
docs/PLAN.md # design document
PROGRESS.md # build status and verified SDK behaviourLicense
See LICENSE.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Servers
- AlicenseNot gradedqualityCmaintenanceEnables remote control of Lovense toys through Claude using natural language commands. Supports vibration patterns, presets, and intensity control from any device via Cloudflare Workers.4Apache 2.0
- AlicenseNot gradedqualityNot gradedmaintenanceEnables Claude to design and build interactive 3D games within the Portals virtual platform through direct API integration. It facilitates automated asset placement, interaction logic configuration, and quest management using natural language commands.4
- AlicenseAqualityDmaintenanceConnects Claude to Open WebUI, enabling chat management, RAG knowledge bases, files, functions, and prompts directly from Claude.26252MIT
- AlicenseNot gradedqualityAmaintenanceBridges any OpenAPI 3.x REST API to Claude Code by automatically generating one tool per endpoint from your spec, with full argument validation and auth support.18MIT
Related MCP Connectors
WHOOP recovery, strain, sleep and workouts in Claude via official WHOOP OAuth. Free, open source.
Hosted MCP server connecting claude.ai, ChatGPT and other AI apps to your own computer
Garmin data in Claude & ChatGPT via the Garmin Health API. OAuth sign-in, no password sharing.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/TheArmagan/vrchat-mcp'
If you have feedback or need assistance with the MCP directory API, please join our Discord server