MCP server
Connect Claude, Cursor, VS Code, Codex or any MCP client to your workspace.
https://api.median.sh/mcp
The server has two tools. median_run runs TypeScript against a typed median client, so one call can filter, join, loop and batch. median_docs returns that client’s TypeScript declarations. Admins and owners also find the URL in Settings → API under MCP.
Connect a client
claude mcp add --transport http median https://api.median.sh/mcpRun /mcp in Claude Code, pick median and sign in. Add --scope user to use it in every project.
Add a custom connector in Claude under Customize → Connectors, with the server URL. Then click Connect and sign in. On Team and Enterprise plans an owner adds the connector first, in Organization settings → Connectors.
{
"mcpServers": {
"median": { "url": "https://api.median.sh/mcp" }
}
}Use .cursor/mcp.json instead for one project. Cursor asks you to sign in when it connects.
{
"servers": {
"median": { "type": "http", "url": "https://api.median.sh/mcp" }
}
}VS Code asks you to sign in when the server starts.
codex mcp add median --url https://api.median.sh/mcp
codex mcp login medianOr add it to ~/.codex/config.toml:
[mcp_servers.median]
url = "https://api.median.sh/mcp"{
"mcpServers": {
"median": { "serverUrl": "https://api.median.sh/mcp" }
}
}Any other client that speaks streamable HTTP and OAuth works with the same URL. See Protocol.
Authorize
The client opens Median in a browser. Sign in, then click Allow on the page titled Connect and the client’s name. The page names the organization the client gets and says it acts as you.
| Fact | Value |
|---|---|
| Organization | The one open in the dashboard when you click Allow. To connect another, switch organizations in the dashboard first, or call median.account.useOrg |
| Acts as | You. Your current role is checked on every call |
| New account | A connection approved with no organization can still connect. In median_run, call median.account.createOrg({ name }) to make one, or median.account.useOrg({ org }) to pick one. Until then, other calls fail with no_organization |
| Authorization code | Expires after 10 minutes |
Scripts and CI
Send MEDIAN_KEY as a bearer token and skip the browser. A key acts as an admin of its organization. Inviting, changing roles and removing members refuse a key with needs_a_person.
curl https://api.median.sh/mcp \
-H "Authorization: Bearer $MEDIAN_KEY" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
median.account.me, createOrg and useOrg are about a person, so they need an OAuth token and refuse a key with token_required.
Tools
| Tool | Input | Returns |
|---|---|---|
median_run |
{ code: string }, required. The body of an async function with median in scope |
The returned value as JSON, then a console: section with anything logged |
median_docs |
{ topic?: string }. One namespace, or omit it for the whole reference |
TypeScript declarations |
Topics: billing, account, conversations, customers, knowledge, signals, tasks, feedback, tools, org, site, agent, integrations, webhooks, analytics, docs. An unknown topic returns an error that lists them.
A failed run comes back as a tool result with isError: true and the error message.
The median client
await freely, log with console.log, and return the value you want back. Types are stripped before the code runs, so they are not checked.
const waiting = await median.conversations.list({
status: "open",
needsHuman: true,
});
return waiting.map((c) => ({ id: c.threadId ?? c.id, subject: c.subject }));
| Namespace | Holds |
|---|---|
median.billing |
Plan, credits, invoices, metered usage, API limits and the activity log |
median.account |
Who the token is, its organizations, and the workspace’s keys |
median.conversations |
List, read, reply, note, resolve, archive, snooze, pause the AI, delete |
median.customers |
The directory, learned facts, profile refresh, reach out first, delete |
median.knowledge |
Documents, folders, search, the review queue, crawls, source syncs |
median.signals |
List, file, move, merge and accept bugs and suggestions, and their claimed commits |
median.tasks |
Cards, columns, requests and board settings |
median.feedback |
Hands over a raw note. Median files it as a bug or suggestion, or drops it as spam |
median.tools |
Approvals, manual runs, tests, switches, endpoints and tool suggestions |
median.org |
The organization, members and their roles, invitations |
median.site |
The public site’s look, custom domain and sign-in |
median.agent |
Name, personality and behavior switches |
median.integrations |
Email, Slack, Discord, GitHub, Notion and Linear settings after connecting |
median.webhooks |
List, add, update and remove endpoints |
median.analytics |
Dashboard numbers, every chart, costs, the explorer and each dataset’s fields |
median.docs |
Search Median’s documentation and read a page |
Every method calls the same backend function as its management API route, so shapes and errors match that reference. Roles apply the same way. See roles.
The same methods run outside the sandbox too. median call <method> in the CLI and POST /v1/call/{method} take the same names and arguments, and return the same values. The Median assistant works through the same list, so anything it can do, a method here can do.
A median.sh link works in place of an id in the methods of median.conversations, median.customers, median.knowledge and median.signals, and as the signalId of median.tasks.adopt:
const link = "https://median.sh/signal/q5776nawkbmazaj4qv4p26y3v98d83qc";
const { signal } = await median.signals.get({ signalId: link });
return signal.title;
The conversationId arguments of median.tools and median.billing.logs take the bare id.
Sandbox limits
The code runs in an isolated JavaScript engine with no network, no filesystem and no timers. median calls are real reads and writes.
| Limit | Value |
|---|---|
| Run time | 120 seconds, median calls included |
| Memory | 256 MB |
| Stack | 1 MB |
| Result | 400,000 characters of JSON. A larger result is replaced by a message saying how big it was |
| Text sent to the client | 120,000 characters, result and console together. Longer text is cut and marked as truncated |
| Console | 200 lines, 4,000 characters each |
| Timers | None. setTimeout is not defined, and a run left waiting on a promise that no median call will settle fails at once |
median calls |
Each counts as one API request against your rate limits. Calls started together run in parallel |
| Returning nothing | Gives null |
Anything shaped like a Median credential is replaced with [redacted] in error text.
Protocol
| Fact | Value |
|---|---|
| Transport | Streamable HTTP, stateless. POST /mcp only. GET and DELETE return 405 |
| Sessions and server stream | None |
| Batches | Not accepted. Send one JSON-RPC message per request |
| Protocol versions | 2025-06-18, 2025-03-26, 2024-11-05. Any other version is answered with 2025-06-18 |
| CORS | access-control-allow-origin: * |
| Credentials | An OAuth access token or MEDIAN_KEY, as a bearer token |
| No or dead credential | 401 with WWW-Authenticate: Bearer resource_metadata="https://api.median.sh/.well-known/oauth-protected-resource" |
| Discovery | /.well-known/oauth-protected-resource and /.well-known/oauth-authorization-server, each also with /mcp appended |
| Client registration | Dynamic, POST /oauth/register. Public clients with no secret. Names over 80 characters are cut |
| Redirect URIs | https anywhere, http on localhost or 127.0.0.1, or a native app scheme. The first 10 per client are kept |
| PKCE | Required, S256 only |
| Grants | Authorization code, refresh token, device code |
| Scope | median |
| Revocation | POST /oauth/revoke |
Sign in limits
POST /oauth/register and POST /oauth/device_authorization are limited per caller address and answer 429 with slow_down when over. See Rate limits.
Access and revocation
| Fact | Value |
|---|---|
| Access token | 8 hours |
| Refresh token | Replaced on every refresh. Expires 30 days after the last one |
| Connections list | Settings → API under MCP, for admins and owners. Each row shows the client, who approved it and when it was last used |
| Disconnect | Click Disconnect on the row. The client is signed out on its next request |
| Role change | Applies on the next call |
| Leaving the organization | Every median call fails with invalid_token |
Troubleshooting
| Message | Cause | Fix |
|---|---|---|
| This connection has no organization yet. Create or pick one first… | Approved before any organization existed | Call median.account.createOrg or median.account.useOrg in median_run |
| That token has expired. Your client should refresh and retry. | The access token is over 8 hours old | Most clients refresh on their own. Reconnect if yours does not |
| That token does not open anything. Sign in again. | The connection was disconnected or revoked | Connect again |
| That refresh token no longer works. Connect again from your MCP client. | Unused for 30 days, or disconnected | Connect again |
| <name> is no longer in <organization>, so this connection no longer works. | The person who approved it left | Connect as a current member |
| Only admins and owners can… | Your role cannot call that method | Ask an admin, or see roles |
| The result was … characters, which is too big to hand back. | The returned value is over 400,000 characters | Return less. Filter, pick fields or pass limits |
| …truncated at 120000 characters. | Result and console are over 120,000 characters | Return less, or log less |
| The run took longer than 120 seconds and was stopped. | Too much work in one run | Split it into several runs |
| The run is waiting on a promise nothing will ever settle. | The code awaited a promise with no median call behind it |
Await only median calls |