How MCP Servers Work: Architecture, Wire Protocol, and Request Flow
Most explanations of the Model Context Protocol start with a definition. This one starts with a transcript, because an MCP server is easier to understand as a conversation you can read than as an architecture you have to picture. Six JSON messages open a session, ask a server what it can do, call one of its tools, and get an answer back. That is the whole system. Everything else — transports, capability flags, session headers, auth, job polling — is detail hanging off those six messages.
A server, concretely, is a program that answers two questions: what can you do? and do this, with these arguments. It runs no model, holds no conversation, and has no idea which AI is on the other end. The host application (Claude Code, Claude Desktop, an IDE, a home-grown agent) owns the model and the approval prompts. Inside the host, one MCP client per connected server does the talking. Your server sees arguments arrive and results leave, and nothing more.
This page is written for the person who is going to build one, or debug one that is misbehaving. It reads the protocol off the wire, then works outward: message shapes, the two transports, what the handshake actually negotiates, how tool definitions reach the model, how to model work that cannot finish inside one request, how identity attaches over HTTP, and where the whole approach is the wrong tool. ClipSpeedAI's own server appears throughout as the worked example, because it is a public endpoint with ten real tools you can point a client at. If the protocol itself is new to you, What Is MCP? Model Context Protocol Explained is the gentler starting point.
Read One Real Session, Top to Bottom
Read it once before anything else on this page.
// 1. client -> server : open the session
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{ "protocolVersion":"2025-06-18", "capabilities":{"roots":{"listChanged":true}}, "clientInfo":{"name":"example-host","version":"1.4.0"}}} // 2. server -> client : the version it will actually speak, plus capabilities
{"jsonrpc":"2.0","id":1,"result":{ "protocolVersion":"2025-06-18", "capabilities":{"tools":{"listChanged":true}}, "serverInfo":{"name":"clipspeed-creator-agent","version":"…"}}} // 3. client -> server : a notification. No id, no reply, session now live.
{"jsonrpc":"2.0","method":"notifications/initialized"} // 4. client -> server : what exists?
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}} // 5. server -> client : ten tools. Two shown; descriptions abridged.
{"jsonrpc":"2.0","id":2,"result":{"tools":[ {"name":"list_templates", "description":"List the caption-style templates. Pass the chosen id as captionStyle.", "inputSchema":{"type":"object","properties":{}}}, {"name":"check_clips", "description":"Get the finished, scored, captioned 9:16 vertical clips for a projectId.", "inputSchema":{"type":"object", "properties":{"projectId":{"type":"string"}}, "required":["projectId"]}}]}} // 6. client -> server : call one, and the answer
{"jsonrpc":"2.0","id":3,"method":"tools/call", "params":{"name":"list_templates","arguments":{}}} {"jsonrpc":"2.0","id":3,"result":{ "content":[{"type":"text", "text":"karaoke, hormozi, beasty, fire, youshaei, cinematic"}], "isError":false}}Those six template ids are real: karaoke, hormozi, beasty, fire, youshaei and cinematic are the values you pass as captionStyle elsewhere in the tool set. The exact way a server packages them into content is its own choice — the protocol only requires an array of content blocks.
Notice what is not in the transcript. No model, no prompt, no conversation history. The server never learns why list_templates was called. It could have been a user typing "what caption styles are there", an agent gathering options before submitting a video, or a developer poking at the endpoint. From the server's side those are indistinguishable, which is the property that makes MCP servers testable in isolation.
Who Owns What: Host, Client, and Server
Three participants, and mixing them up accounts for a large share of setup failures — people put the API key where the model can see it, or expect the server to remember the last thing the user said.
- Host — the application a human is using. It holds the model connection, the transcript, and every security decision: which calls run silently, which need a click. Claude Code, Claude Desktop and IDE assistants are hosts.
- Client — a protocol connector inside the host, one per connected server, each with its own session state and its own negotiated capabilities. The client is plumbing, not intelligence.
- Server — your program. It advertises what it can do and does it. No model access, no transcript access.
human types a sentence | +--------v-------------------------------+ | H O S T | | model + approval gate + transcript | | | | client A client B | +------|-----------------|---------------+ | stdio | HTTPS | | +---------v------+ +------v--------------------+ | local server | | remote server | | subprocess on | | your infra, one URL, | | the user's box | | many users, per-request | | reads files | | auth | +----------------+ +------------+--------------+ | your existing APIs, queues, databases, render workers
The consequence for a server author is a short list of things you do not control. You cannot make the model call a tool. You cannot read earlier turns. You cannot assume any particular client, because the same endpoint will be hit by a terminal agent, a desktop app, and something nobody has written yet. What you control is the tool surface: the names, the descriptions, the schemas, and the text you return. That is the entire lever.
Why the Wire Format Is Boring on Purpose
Everything in that transcript is JSON-RPC 2.0, and there are only three message shapes in the protocol:
- Requests carry an
idand amethod, and expect exactly one response. Messages 1, 4 and 6 above. - Responses echo the
idand carry eitherresultorerror, never both. - Notifications have a
methodbut noid. Nothing replies to them. Message 3 is one;notifications/tools/list_changedandnotifications/progressare others.
Method names are namespaced with a slash: tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get, ping. Nothing more exotic than that.
The plainness is a design choice with real payoff. There is no binary framing to decode, no schema registry to keep in sync, no code generation step between you and a working server. You can tail a log and read a session as English. You can paste a request into a text editor, change one field, and replay it. When a tool call misbehaves, the diagnostic loop is: look at the JSON that arrived, look at the JSON you sent back. Protocols that are cheap to inspect are cheap to debug, and MCP servers are overwhelmingly written by people who did not want a protocol project on their hands in the first place.
Two Transports, One Message Format
The message format is fixed; how those bytes travel is not. Two transports matter.
stdio. The host spawns your server as a child process and speaks JSON-RPC over its stdin and stdout, one object per line. stderr stays free for logging — write anything that is not a protocol message there, because a stray console.log on stdout corrupts the stream and is the single most common way a new stdio server fails. There is no port, no TLS and no auth layer, because the trust boundary is the user's machine.
Streamable HTTP. Your server is a normal web endpoint at one URL. Clients POST JSON-RPC to it. The server answers either with a plain JSON body or by upgrading the response to a Server-Sent Events stream when it wants to interleave progress notifications before the final result. Clients should advertise both by sending Accept: application/json, text/event-stream so the server can pick.
| Question | stdio | Streamable HTTP |
|---|---|---|
| Who runs the process? | The user's machine, as a subprocess | You do |
| How does a fix reach users? | They upgrade a package | You deploy once |
| How is the caller identified? | It isn't — local process trust | Bearer token or OAuth, every request |
| Where do secrets live? | Env vars in a plaintext config file | Your server, never on the client |
| Can it touch local files? | Yes, and that is the whole point | No |
| Multi-tenant? | One user per process, by construction | Yes, and you must isolate tenants yourself |
| What breaks at 2am? | PATH, node version, process crash | 401s, expired tokens, network |
An older two-endpoint HTTP+SSE arrangement predates the streamable design and still shows up in servers written early. New work should target the single-endpoint form. The choice between running locally and running as a service has consequences well beyond transport syntax, and Remote MCP vs Local MCP Servers works through them properly.
Handshake Fine Print: Versions, Sessions, and Capability Flags
Return to messages 1 and 2. Three things are being settled there, and each one bites somebody eventually.
Protocol version. Versions are date strings, not semver — 2025-06-18 is a version, 1.2.0 is not. The client proposes; the server replies with the version it will actually speak. If the server cannot honour the request it answers with the newest version it does support, and the client decides whether to continue or hang up. Over HTTP, every subsequent request carries the agreed value in an MCP-Protocol-Version header.
Session identity. A server that wants stateful sessions may hand back an Mcp-Session-Id header on the initialize response, which the client then echoes on every later request. This is why firing a lone tools/call at an HTTP endpoint with curl often fails: there is no session behind it. A request shape is not a session.
Capabilities. Each side declares what it supports, and those declarations are promises. A server advertising tools.listChanged is saying it may later emit notifications/tools/list_changed; a client that ignores that notification will keep a stale tool list until it reconnects. A client advertising roots or sampling is offering capabilities back to the server. Read the negotiated set instead of hardcoding assumptions, especially if you support more than one client.
What tools/list Returns, and What the Model Actually Receives
Message 5 in the transcript is the hinge of the whole protocol. Each entry has a name, a natural-language description, and an inputSchema written as JSON Schema. Recent revisions add an optional outputSchema, paired with a structuredContent field on results, so a caller can parse a typed object rather than scraping prose out of a text block.
Look closely at the check_clips entry. Its one required argument is projectId. That is the identifier submit_to_clipspeed hands back, and getting it right is the difference between a working two-step workflow and a stream of invalid-params errors. Identifiers are where copy-pasted documentation does the most damage, so take the name from a live tools/list rather than from anyone's blog post, including this one.
What happens next is where MCP disappears. The host takes those definitions and serializes them into the model's tool list. From the model's side there is no protocol at all — just functions with names, descriptions and argument schemas, indistinguishable from tools the host defined itself. MCP vs Function Calling: What Actually Differs is the page for that boundary; the compressed version is that function calling is the model-side interface, and MCP is the discovery and transport layer that fills it in at runtime.
Resources and Prompts, the Primitives Most Servers Skip
Tools get all the attention, but a server can expose two other things, and the difference is who decides to use them.
| Primitive | Initiated by | Shaped for | Example |
|---|---|---|---|
| Tools | The model, mid-turn | Actions with effects | Submit a video, publish a clip |
| Resources | The host or user, by attaching | Read-only content addressed by URI | A transcript, a project record |
| Prompts | The user, deliberately | Parameterised message templates | A canned workflow behind a slash command |
Most servers ship tools only, and that is a reasonable default. The case for resources is content the user wants to pull into context on purpose rather than have an agent fetch on a hunch. The case for prompts is a workflow you have already tuned and would rather not have the model reinvent each time. Both are discovered exactly like tools, via resources/list and prompts/list, and both are optional capabilities you declare in the handshake.
The Description Field Is Executable
A tool definition has two readers with nothing in common. JSON Schema validation rejects malformed calls. The description decides whether a call happens at all, and with which arguments. Treating that string as documentation is the most expensive mistake in server design, because editing it changes behaviour in production with no code deploy.
Rules that survive contact with real agents:
- Name the intent, not the implementation.
check_clipstells a model what it gets.get_job_artifacts_v2tells it nothing it can match on. - State the precondition in the description. "Get the finished clips for a projectId" implies the project exists and finished. One clause about ordering prevents an entire class of premature calls.
- Constrain in the schema, not the prose. An
enumof the six real caption template ids stops a model inventing"bold-yellow".minimum,maximumandformatdo the same work for numbers and dates. - Keep the required list short. Every required argument is one more value a model may fabricate rather than admit it lacks.
- Return terse text. Results are read by something that pays per token and gets less accurate as context fills. Ten fields it needs beats a hundred it does not.
MCP Tool Design: Writing Tools an Agent Can Actually Use goes further, including the naming problem that appears once several servers are connected at once and two of them both offer something called search.
Work That Outlives a Single Request
The protocol has no concept of a job. tools/call is a request that gets a response, and video rendering, large builds and batch analysis do not fit inside one. Holding the response open is tempting and wrong for reasons unrelated to MCP: proxies close idle connections, load balancers reap them, retries duplicate the work, and a client that drops loses the result with no way to recover it.
The durable shape is two tools. One starts the work and returns an identifier straight away. Another reports status and returns results once they exist. The agent loop handles waiting for free, because polling is just another tool call, and the user sees progress instead of a spinner.
start ------------------> { id, status: "queued" } | agent may answer the user, call another tool, or poll again — its choice, not yours | status(id) ---------------> { status: "processing", 2 of 6 done } status(id) ---------------> { status: "complete", results: [...] }Two refinements pay for themselves. Make the status response say what to do next in plain words, including a wait hint derived from your own measured completion times rather than a number copied from a page like this one — a model with no guidance polls either far too often or not at all. And make the start tool idempotent on a client-supplied key if the shape of your work allows it, because agents retry more enthusiastically than humans.
If your transport streams, you can also emit notifications/progress against a request that carried a progressToken. Treat that as presentation. The job record is the source of truth; a notification stream vanishes on reconnect and cannot be replayed.
Attaching Identity on the HTTP Path
stdio servers do not authenticate. They inherit the user's machine and read secrets from environment variables in a config file that sits in plaintext on disk. Anything that file can reach, anything running as that user can reach.
HTTP servers authenticate every single request, and a well-built one accepts two credential styles because the clients are genuinely different.
Bearer API key. The client sends Authorization: Bearer <key>. Scriptable, works in CI, and the right fit for terminal clients. ClipSpeedAI's implementation is a useful template because the details are visible. A key is minted with POST /auth/api-keys, or from Account → API & Integrations in the product UI, and comes back as csai_live_ followed by 48 hexadecimal characters — 24 random bytes, hex-encoded. Only the prefix is stored for display, so the full value is shown once at creation and cannot be retrieved afterwards; lose it and you mint another. GET /auth/api-keys lists what the server does retain: id, name, key prefix, plan, rate limit, requests today, total requests, last request time, active flag and creation date. DELETE /auth/api-keys/:id revokes a key by clearing its active flag and stamping a revocation time. Each key carries its own rate limit and its own running counters, which is what makes per-key revocation useful rather than symbolic: kill the CI key without touching the laptop key.
OAuth 2.1 with PKCE. The host opens a browser, the user consents, the host stores an access token and a refresh token and renews quietly. This is what one-click connect means in a GUI client. The MCP authorization spec composes existing RFCs — protected resource metadata for discovery, authorization server metadata, dynamic client registration — so a client can locate your authorization server and register itself without a human in the loop.
CLI / scripted GUI client -------------- ---------- mint key in the UI user clicks "connect" paste into the install cmd browser -> consent screen every POST carries host keeps access + refresh, Authorization: Bearer renews silently, sends Bearer
Reject unauthenticated requests with 401 and a WWW-Authenticate header pointing at your resource metadata. That header is how a compliant client knows to begin an OAuth flow rather than surface a failure to the user. MCP Authentication: OAuth and Bearer Keys covers the full sequence, refresh included, and what a client should do when a key is revoked mid-session.
Two Failure Classes, Two Envelopes
MCP separates failures that mean "your request was nonsense" from failures that mean "your request was fine and the world said no". Putting one in the other's envelope degrades agent behaviour in ways that are hard to trace later.
A protocol error is a malformed or impossible request: unknown method, unknown tool name, arguments that do not match the schema. Return a JSON-RPC error object with a standard code — -32700 parse error, -32601 method not found, -32602 invalid params, -32603 internal error. The client deals with this and the model may never see it.
A tool error is a well-formed call that could not succeed: the project is still rendering, the source URL is private, the account has no entitlement. Return an ordinary result with isError: true and a plain-language explanation in content. The model does see this, and can act on it — call the other tool, ask the user for a different URL, or explain the situation.
{"jsonrpc":"2.0","id":9,"result":{ "isError":true, "content":[{"type":"text","text": "No finished clips for that projectId yet — the project is still processing. Call check_clips again with the same projectId rather than submitting the video a second time."}]}}The wording above is illustrative, not a string ClipSpeedAI emits verbatim, but the shape is the point. It names the state, names the correct next action, and closes off the wrong one. Compare what a model can do with ERR_NOT_READY: guess. Write error text as an instruction to a competent stranger who cannot see your code, because that is exactly the reader.
Tracing ClipSpeedAI's Ten Tools End to End
Everything ClipSpeedAI exposes sits behind a single URL — https://api.clipspeed.ai/mcp, streamable HTTP, accepting either OAuth for GUI clients or a Bearer key for terminal ones. Ten tools, arranged around two lifecycles.
The recorded-video path starts with discover_trending, which finds the fastest-growing recent video in a niche worth turning into shorts, and deliberately searches only the last three weeks or so — an old video that already peaked is not the input you want. submit_to_clipspeed takes a video URL or file and returns a projectId. check_clips takes that same projectId back and returns the finished, scored, captioned 9:16 vertical clips, each with a title, a viral score and a download URL. creator_pack takes the same identifier and returns per-clip title suggestions, hooks and posting times. list_templates supplies the caption-style ids you pass as captionStyle. publish_to_youtube takes a projectId, optionally a specific clipId, a title and a privacy status, and defaults to private — a sensible default for a tool an agent can reach, and a good example of designing the blast radius down rather than trusting the caller.
The live path is a session lifecycle rather than a job: clip_livestream starts clipping a stream that is running now and returns a subscriptionId; check_livestream polls that subscription, where a status of monitoring means the stream is still live and still being clipped; extend_livestream keeps an active session going; stop_livestream ends it, and clips already produced stay downloadable.
recorded video live stream -------------- ----------- discover_trending clip_livestream | url | subscriptionId v v submit_to_clipspeed check_livestream (poll) | projectId | +--------> check_clips +--> extend_livestream | (poll, same id) | +--------> creator_pack v | stop_livestream v (earlier clips survive) publish_to_youtube (private by default)
Two identifiers, and they are not interchangeable: projectId threads the recorded path, subscriptionId threads the live one. That split is itself a design decision worth copying. A job you poll until it finishes and a session you hold open until you end it are different objects with different failure modes, and giving them one shared id would invite an agent to pass the wrong thing to the wrong tool.
The live tools also demonstrate a pattern that generalises well beyond video: state that expires needs an explicit extend. A session that quietly runs forever becomes a billing incident; one that dies without warning loses work. Making extend_livestream a first-class tool puts that decision in front of the agent, and therefore in front of the user, instead of burying it in a timeout constant. Livestream Clipping API: Clip While You Stream follows that workflow through from the first frame.
Access is on the same plans as the product itself: a $1 charge starts a three-day trial that converts unless cancelled, then Starter at $15, Pro at $29 or Ultra at $49 a month, with annual billing at half the monthly rate. There is one free demo, for a video under thirty minutes, and no free tier beyond it.
One Endpoint, Every Client's Config Dialog
The payoff of a standard is that a remote server does not care who is calling. There is no per-client backend, no separate build, no vendor SDK. What differs between clients is only where you type the URL and the credential.
For Claude Code that is one command, and it is worth reproducing exactly because it is the shape every other client is a variation on — a transport, a name, a URL, a header:
claude mcp add --transport http clipspeed https://api.clipspeed.ai/mcp \ --header "Authorization: Bearer <API_KEY>"
Every other client wants the same four facts through its own interface: register an HTTP (not stdio) MCP server, give it a name, point it at the endpoint, and supply the key in an Authorization: Bearer header — or run the OAuth flow instead, if the client offers one. Field names, file locations and menu paths vary by vendor and change between releases, so follow that client's own MCP documentation for the exact syntax rather than trusting a third party's copy of it. An npm package, clipspeed-mcp, also exists.
Support maturity is not uniform and it is worth being precise. Claude on the web, Claude Code, Claude Desktop and Windsurf are verified end to end. Cursor, Codex, OpenClaw and Hermes speak the same protocol and are compatible, with verification still in progress. ChatGPT depends on OpenAI's own connector rollout and is not yet verified.
Per-client walkthroughs exist for each of those. If you live in a terminal, ClipSpeedAI MCP for Claude Code: Complete Setup Guide is the shortest path from key to first clip. For the browser connector and its OAuth consent screen, see ClipSpeedAI MCP for Claude (claude.ai): Complete Setup Guide. Desktop users have ClipSpeedAI MCP for Claude Desktop: Complete Setup Guide. In editors, ClipSpeedAI MCP for Windsurf: Complete Setup Guide covers the verified path and ClipSpeedAI MCP for Cursor: Complete Setup Guide the compatible one; Claude Code vs Cursor for MCP Workflows compares the two as places to actually do the work. The remaining guides — ClipSpeedAI MCP for Codex CLI: Complete Setup Guide, then ClipSpeedAI MCP for OpenClaw: Complete Setup Guide, ClipSpeedAI MCP for Hermes Agent: Complete Setup Guide and ClipSpeedAI MCP for ChatGPT: Complete Setup Guide — track the state of each as it moves.
One practical note on inspection. After connecting, list the tools from inside the client rather than reaching for curl. A bare POST to an HTTP MCP endpoint carries no initialize and no session id, so it shows you a request shape but is not a session and may simply be rejected. The client already did the handshake; ask it what it sees.
The Token Bill for Every Tool You Expose
Tool definitions are not free and they are not paid once. Names, descriptions and full JSON Schemas are injected into the model's context on every turn of the conversation. Connect several talkative servers and a real fraction of the window is consumed before the user has typed anything.
Cost is the smaller problem. The larger one is selection accuracy: a model choosing among a large set of tools with overlapping descriptions picks wrong more often than one choosing among a handful with sharp boundaries. Nothing visibly breaks. The agent just gets subtly worse at deciding, and the symptom looks like a bad model rather than a crowded tool list.
- Ship the smallest tool set that covers the workflow. Fold near-duplicates behind an enum argument instead of exposing five variants of the same verb.
- Hold descriptions to a sentence or two plus the constraint that matters.
- Truncate long results server-side, say that you truncated, and offer a way to fetch the rest.
- Tell users to enable only the servers a given task needs. This is a real lever and almost nobody pulls it.
Cases Where You Should Not Build an MCP Server
Scoping honestly is more useful than advocacy. Several situations call for something else.
- No model in the loop. Backend-to-backend integration between two deterministic systems gains a discovery layer and a JSON-RPC envelope and nothing else. Call the REST or gRPC API. MCP vs REST API: When to Use Each lays out the decision, and Video Clipping API for Developers is the plain-HTTP route for exactly this case.
- Latency or throughput targets. Model inference sits between every pair of calls. Anything budgeted in milliseconds, or in thousands of calls per second, does not belong on this path.
- Large binary payloads. Messages are JSON. Base64-encoding video into a tool result is not a plan; return URLs and let the client fetch.
- Operations that must never fire by mistake. A model decides when to call. If a wrong invocation is unrecoverable — irreversible deletion, moving money — either do not expose it, or gate it behind a confirmation the model cannot produce on its own.
- Strict contracts across many consumers. Versioned REST with generated clients gives compile-time guarantees. An MCP contract is a schema plus a sentence, interpreted by a probabilistic caller.
- Anything needing real UI. The output surface is content blocks in a transcript.
The protocol earns its place when the caller is an agent, the arguments are derived from human intent rather than from another program, and the value comes from composing several tools — sometimes across several servers — inside one conversation.
What Changes in Your Threat Model
Exposing a server means a language model can now cause side effects in your system, using arguments partly derived from text it read somewhere else. Some of that text may have been written by someone who wanted exactly this.
- Validate twice. Schema first, business rules second. The schema is guidance aimed at the model; it is not an access control.
- Scope credentials to a purpose. A key that can clip video should not be able to change billing. Per-key limits and real revocation — an active flag the server checks, not a note in a spreadsheet — are what make this workable.
- Authorize per call, server-side. The token says who is asking; you still check on every request that this identity may touch this object. Cross-tenant leakage is the classic remote-server bug and it is usually one missing WHERE clause.
- Assume prompt injection. If a tool returns third-party content — a transcript, a page, a comment thread — that text lands in the model's context and may contain instructions. Label it as data, keep it away from anything that reads like a directive, and never let a tool result widen what the agent is permitted to do.
- Rate limit per identity. An agent in a retry loop generates traffic no human ever would.
- Log the caller with every call. When something goes wrong you will need to reconstruct which key did what, in order.
MCP Security: Scopes, Keys and Safe Tool Design treats each of these at length, including how to shape a tool set so the dangerous operations are structurally hard to reach by accident.
Mental-Model Corrections for Server Authors
Eight beliefs that show up constantly in questions, each of which quietly leads somewhere wrong.
- "The server runs the AI." It runs no inference. It is an ordinary service; the model lives in the host, possibly on another continent.
- "MCP replaces your REST API." Almost every server is a thin adapter over an API that already existed. Keep the API. Add a surface shaped for agents.
- "The server can see the conversation." It sees the arguments of calls addressed to it. Nothing else, unless the host explicitly uses a feature like sampling, which the user controls.
- "Each client needs its own integration." One endpoint plus a Bearer key or OAuth serves every compliant client. Only the config dialog differs.
- "stdio is the beginner option." Different, not lesser. A local server reads the user's files and drives local processes, which no remote server can do.
- "More tools, more capable agent." Past a point, the reverse. Selection accuracy falls and context fills with definitions nobody calls.
- "Failures should return a JSON-RPC error." Only malformed requests. Business failures belong in
isError: trueresults where the model can read and recover from them. - "Descriptions are documentation." They are runtime inputs to a decision. Changing one changes behaviour, and it does so the moment you deploy.
A Working Server With No SDK
The official SDKs handle framing, the handshake and schema plumbing, and you should use one in production. But writing the naive version once is the fastest way to internalise the transcript at the top of this page — there is no magic in it. The following stdio server is complete and dependency-free. The tool names below are invented for this walkthrough and are not ClipSpeedAI tools.
// example-server.js — run with: node example-server.js
import { createInterface } from "node:readline"; const TOOLS = [ { name: "start_task", description: "Queue a long job. Returns a taskId immediately; " + "pass that id to check_task. Does not wait for completion.", inputSchema: { type: "object", properties: { url: { type: "string", description: "Public URL of the input" } }, required: ["url"] } }, { name: "check_task", description: "Report the status of a queued job by taskId, and return " + "its results once the status is complete. Call after start_task.", inputSchema: { type: "object", properties: { taskId: { type: "string" } }, required: ["taskId"] } }
]; const send = (m) => process.stdout.write(JSON.stringify(m) + "\n");
const ok = (id, result) => send({ jsonrpc: "2.0", id, result });
const fail = (id, code, message) => send({ jsonrpc: "2.0", id, error: { code, message } }); createInterface({ input: process.stdin }).on("line", async (line) => { let msg; try { msg = JSON.parse(line); } catch { return fail(null, -32700, "Parse error"); } const { id, method, params } = msg; if (id === undefined) return; // a notification: never reply switch (method) { case "initialize": return ok(id, { protocolVersion: "2025-06-18", // the version YOU support capabilities: { tools: {} }, serverInfo: { name: "example-server", version: "0.1.0" } }); case "ping": return ok(id, {}); case "tools/list": return ok(id, { tools: TOOLS }); case "tools/call": { const { name, arguments: args } = params ?? {}; if (!TOOLS.some(t => t.name === name)) return fail(id, -32602, `Unknown tool: ${name}`); // protocol error try { const text = await run(name, args); // your logic here return ok(id, { content: [{ type: "text", text }], isError: false }); } catch (err) { return ok(id, { // tool error content: [{ type: "text", text: `${name} could not complete: ${err.message}` }], isError: true }); } } default: return fail(id, -32601, `Method not found: ${method}`); }
});Roughly fifty lines, and every concept from the transcript is visible: line framing, notifications that get no reply, the version you actually support rather than the one you were asked for, and the two error envelopes landing in different branches. Anything logged here must go to stderr; a single stray write to stdout desynchronises the stream.
In practice you would reach for an SDK. In Python the decorator form is short enough to show whole:
from mcp.server.fastmcp import FastMCP mcp = FastMCP("example-server") @mcp.tool()
def check_task(task_id: str) -> str: """Report the status of a queued job. Call after start_task. Returns the finished results once the status is complete.""" task = store.get(task_id) if task is None: return f"No task {task_id}. Start one first with start_task." if task.status != "complete": return f"status={task.status} ({task.done} of {task.total} done). Not ready yet." return "\n".join(f"{r.score:>3} {r.title} {r.url}" for r in task.results)The Node and TypeScript SDKs have changed their registration API across releases, so take the exact call signature from the version of @modelcontextprotocol/sdk you install rather than from any article. What does not change is where the effort goes: in both snippets the business logic is one line and everything else is the docstring, the argument constraints and the phrasing of the returned text. That ratio is normal, and it is why How to Build an MCP Server (Practical Guide) spends most of its length on tool design instead of transport code.
Test by piping JSON-RPC lines into the process by hand, or with an inspector, before you wire it into a host. Then connect one client, watch the raw messages, and confirm the tool list is the one you meant to publish.
What to Take Away Before You Write One
An MCP server is a JSON-RPC endpoint that answers tools/list and tools/call, over a pipe or over HTTPS, after a handshake that settles a version and a set of capabilities. That is a weekend of protocol work at most, and the SDKs remove even that. The engineering that matters sits entirely above the wire: choosing which operations to expose at all, describing them so a probabilistic caller picks correctly, modelling slow work as identifiers you can poll, returning errors that tell an agent what to do next, and authorising every call as if the caller were a stranger, because in a meaningful sense it is.
Video is a good teacher for this because the work is slow, stateful and fails in specific ways, so every one of those decisions has to be made explicitly rather than dodged. To see the same architecture driving complete pipelines rather than single calls, read AI Agent Video Automation: End-to-End Workflows. For the editing-specific patterns, MCP for Video Editing and Clipping Workflows is the closer fit. And if you are still deciding what to connect in the first place, Best MCP Servers for Video and Content Workflows surveys the field.
Frequently asked questions
- What exactly is an MCP server?
- A service that advertises tools (and optionally resources and prompts) over JSON-RPC 2.0, so that any Model Context Protocol client can discover and call them with no integration code written for that specific client.
- Does the server run a model?
- No. It executes no inference and holds no conversation. The model lives in the host application and decides which tools to call; the server receives arguments and returns content blocks.
- Why does check_clips take a projectId rather than a job id?
- Because projectId is the identifier submit_to_clipspeed returns, and the two tools have to agree. The live tools use a different identifier, subscriptionId, returned by clip_livestream. Read parameter names from a live tools/list rather than from documentation, since that is the response the client itself is working from.
- Can I test an HTTP MCP endpoint with a single curl command?
- Not reliably. A lone POST carries no initialize exchange and no session id, so a streamable-HTTP server may reject it. It is useful for seeing the shape of a request; to see the protocol working, connect a client and list the tools from inside it.
- How do stdio and HTTP servers actually differ?
- A stdio server is a subprocess on the user's machine exchanging JSON-RPC over stdin and stdout, with local file access and no network authentication. An HTTP server runs on your infrastructure at one URL, authenticates every request with OAuth or a Bearer key, serves many users, and updates for all of them when you deploy.
- How does an agent find out which tools exist?
- After the initialize handshake it calls tools/list, and the host serializes the returned names, descriptions and input schemas into the model's tool list. A server that declared the listChanged capability can push notifications/tools/list_changed when that set changes.
- What happens to work that cannot finish inside one request?
- Split it into two tools. One starts the work and returns an identifier; the other reports status and returns results when they exist. Holding a response open is fragile because proxies and load balancers close idle connections and retries duplicate the work. Progress notifications can supplement the pattern but should never be the source of truth.
- What does a ClipSpeedAI API key look like, and can it be revoked?
- Keys are csai_live_ followed by 48 hexadecimal characters. Only the prefix is stored for display, so the full key is shown once at creation and cannot be retrieved later. Revocation is real: DELETE /auth/api-keys/:id clears the key's active flag and records when it was revoked, and each key carries its own rate limit and request counters.
- Do I need a different server for each AI client?
- No, and that is the point of a standard. One endpoint serves every compliant client; only the client-side configuration differs. For ClipSpeedAI, Claude on the web, Claude Code, Claude Desktop and Windsurf are verified end to end; Cursor, Codex, OpenClaw and Hermes are compatible with verification in progress; ChatGPT depends on OpenAI's connector rollout.
- When should I write a REST API instead?
- When no model is in the loop, when you need millisecond latency or very high call volume, when payloads are large binaries, or when many consumers need a strict versioned contract with compile-time guarantees. MCP suits agent-driven callers, fuzzy arguments and multi-tool composition.