How to Build an MCP Server: A Practical Guide

The code in an MCP server is close to trivial. Instantiate a server object, register a function, attach a transport, done — a working local server is a few dozen lines and one dependency. If that were the whole job, this page would be a code snippet and a sign-off.

The job that actually takes time is writing a vocabulary. Your tool names, your descriptions and your argument schemas are the only thing a model ever sees of your system. It does not read your source, your database, your README or your intentions. It reads a list of short strings and decides, in one shot, which one matches what a person just asked for in plain English. Most servers that "don't work" are servers whose plumbing is perfect and whose vocabulary is ambiguous.

So this guide runs in that order: the tool surface first, then the transport fork, then real TypeScript and Python you can paste and run, then the raw JSON-RPC frames for when something breaks, then the parts nobody enjoys — long-running jobs, key issuance and revocation, error channels, packaging — and finally the cases where building an MCP server is the wrong answer. If you want the conceptual grounding before the build, What Is MCP? Model Context Protocol Explained sets it up. This page assumes you have decided to write one.

On this pageWhat Your Server Is Responsible For, and What It Never TouchesWrite the Tool List on Paper Before You Open an EditorThe Transport Fork: Subprocess or URLTools, Resources and Prompts — Why Tools Carry Almost EverythingA TypeScript Server You Can Paste and RunThe Same Thing in Python, Where the Docstring Is the ProductReading the Wire When Something BreaksJobs That Outlive a Single Tool CallPutting It on the InternetIssuing Keys, and the Revoke Button You Will NeedTreating Your Own Output as UntrustedTwo Error Channels, and Which One the Model ReadsThree Ways to Test, Cheapest FirstMaking Installation a Copy-PasteWhen a Script Beats a ServerFirst-Build Mistakes That Look Like Protocol BugsThe Pre-Ship Checklist

What Your Server Is Responsible For, and What It Never Touches

An MCP server answers exactly two kinds of question: what can you do? and do this one thing. The first answer is a list of tool definitions. The second is a result. The rest of the specification exists so those two exchanges survive process boundaries, vendor differences and version drift.

Three words appear constantly in the docs and they are not interchangeable. The host is the application a person is looking at — a coding agent, a desktop app, an IDE, a chat product. The host runs one client per connected server; the client owns the connection and the message framing. The server is your code. Messages between client and server are JSON-RPC 2.0 in both directions.

Notice what is missing from that chain: your server has no model in it, no API key for a model, and no ability to send a prompt anywhere. The host owns the model. You expose capability; the host decides when to spend a turn on you. That is why one adapter written once works in every MCP-capable application without you shipping a plugin per vendor.

  a person
     |
     v
  host application  (coding agent, desktop app, IDE, web chat)
     |
     +-- client #1 --- stdio, spawned subprocess ----> local server
     |
     +-- client #2 --- streamable HTTP over TLS -----> your server
                                                          |
              JSON-RPC 2.0 in both directions             v
                                                    your REST API,
                                                    database, queue

The protocol revision is a date string, offered by the client and confirmed by the server during the handshake, so a client built in one quarter and a server built in another still settle on a shared revision. Check the current published revision before you depend on anything recently added.

Write the Tool List on Paper Before You Open an Editor

Imagine handing your system to a competent contractor who has amnesia at the start of every conversation, cannot ask you clarifying questions, and only ever sees an index card per capability. That is the actual interface. Design the index cards first.

Six rules that survive contact with real agents:

A concrete enum from a shipped server: ClipSpeedAI's list_templates tool returns the caption-style templates, and the ids are a fixed set — karaoke, hormozi, beasty, fire, youshaei, cinematic — passed back as captionStyle. Because the values are enumerated rather than described in prose, a model that has never seen the product still cannot produce an invalid style. Anywhere your system has a closed set, that set belongs in the schema.

MCP Tool Design: Writing Tools an Agent Can Actually Use takes this further, into response formatting and how to tell whether a description is pulling its weight.

The Transport Fork: Subprocess or URL

This choice determines your auth model, your deployment story and your debugging tools, so settle it before the first commit. Two standard transports exist.

stdio. The client spawns your program as a child process and speaks JSON-RPC over stdin and stdout. There is no port, no TLS and no in-protocol auth, because the process boundary is the boundary — your code runs as the user, with the user's files and the user's credentials. The cost is distribution: the user's machine needs a working runtime and your package.

Streamable HTTP. Your server is a web service at a URL. The client POSTs JSON-RPC to a single endpoint and receives either a JSON body or a Server-Sent Events stream when the server wants to push progress or initiate messages. This is what a hosted, multi-user, authenticated server looks like.

stdioStreamable HTTP
Runs onThe user's machineYour infrastructure
IdentityWhoever is logged inWhatever the token says
Secrets live inThe client's config fileYour server; the client holds only a token
Shipping a fixEvery user upgradesYou deploy once
Local filesNaturalOut of reach
Per-user limits and auditAwkwardBuilt in
Typical breakageWrong runtime version, PATH, bad installExpired token, rate limit, your outage

The heuristic: does the work require something that only exists on the user's laptop? Then stdio. Does it wrap a service you already run and bill for? Then HTTP. Remote MCP vs Local MCP Servers works through the awkward middle, including setups where a local process fronts a hosted API.

Tools, Resources and Prompts — Why Tools Carry Almost Everything

The specification defines three server-side primitives, distinguished by who decides to use them.

PrimitiveDriven byGood for
toolsThe model, on its ownActions and computations — start a job, look something up, publish
resourcesThe host applicationRead-only material addressed by URI, attached as context
promptsThe user, deliberatelyTemplates surfaced as slash commands or menu entries

There are client-side primitives you can call back into as well: sampling (ask the host to run a model completion for you), roots (ask which directories you are scoped to) and elicitation (ask the user for a value you are missing mid-call). Support for these is uneven across hosts, so treat them as enhancements, not foundations.

Ship tools only in v1. A tools-only server behaves identically everywhere, which means bug reports are about your logic instead of about somebody's host version. Add resources and prompts once you know which hosts your users actually run.

A TypeScript Server You Can Paste and Run

A complete stdio server with two tools — one that starts work, one that checks on it. Zod shapes generate the JSON Schema, so the advertised schema and the runtime validation cannot drift apart.

// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({ name: "clip-tools", version: "1.0.0" });
const API = "https://example.internal/v1";
const auth = () => ({ authorization: `Bearer ${process.env.CLIP_API_KEY}` });

server.registerTool(
  "queue_clip_job",
  {
    title: "Queue a recorded video for clipping",
    description:
      "Start processing a recorded video URL into short vertical clips. " +
      "Returns a job_id right away and does the work in the background. " +
      "Follow up with get_clip_job using that exact job_id. " +
      "Do not call this for a stream that is currently live.",
    inputSchema: {
      url: z.string().url().describe("Publicly reachable URL of the source video"),
      max_clips: z.number().int().min(1).max(20).default(5)
        .describe("Upper bound on clips returned"),
      caption_style: z
        .enum(["karaoke", "hormozi", "beasty", "fire", "youshaei", "cinematic"])
        .default("karaoke")
        .describe("Caption template id"),
      aspect: z.enum(["9:16", "1:1", "16:9"]).default("9:16")
    }
  },
  async ({ url, max_clips, caption_style, aspect }) => {
    const res = await fetch(`${API}/jobs`, {
      method: "POST",
      headers: { "content-type": "application/json", ...auth() },
      body: JSON.stringify({ url, max_clips, caption_style, aspect })
    });

    if (!res.ok) {
      return {
        isError: true,
        content: [{
          type: "text",
          text: res.status === 403
            ? "Rejected: the source URL is not publicly readable (HTTP 403). " +
              "Ask the user for a public link, or have them upload the file first."
            : `Submission failed with HTTP ${res.status}. This looks ` +
              `${res.status >= 500 ? "temporary — retrying is reasonable" : "permanent — do not retry"}.`
        }]
      };
    }

    const { job_id } = await res.json();
    return {
      content: [{
        type: "text",
        text: `Queued. Processing runs in the background. ` +
              `Call get_clip_job with job_id="${job_id}" to collect the clips.`
      }]
    };
  }
);

server.registerTool(
  "get_clip_job",
  {
    title: "Check a clipping job and fetch finished clips",
    description:
      "Return the status of a job started by queue_clip_job, plus any finished " +
      "clips with their titles and download URLs. Cheap and safe to call again " +
      "if the job is still running.",
    inputSchema: { job_id: z.string().describe("Identifier returned by queue_clip_job") }
  },
  async ({ job_id }) => {
    const res = await fetch(`${API}/jobs/${encodeURIComponent(job_id)}`, { headers: auth() });
    const job = await res.json();

    if (job.state !== "done") {
      return { content: [{ type: "text", text: `Still ${job.state}. Check again shortly.` }] };
    }
    const lines = job.clips.map(
      (c, i) => `${i + 1}. "${c.title}" (${c.duration_s}s) ${c.download_url}`
    );
    return { content: [{ type: "text", text: `${job.clips.length} clips ready:\n${lines.join("\n")}` }] };
  }
);

await server.connect(new StdioServerTransport());

Four details in there are load-bearing and none of them are about MCP. The description names an exclusion (not for live streams). The 403 branch tells the model what to do next instead of what went wrong. The generic branch labels the failure transient or permanent so retry behaviour is a decision, not a reflex. And the success text repeats the argument name job_id that the second tool expects, spelled identically.

The Same Thing in Python, Where the Docstring Is the Product

FastMCP derives the schema from type hints and the tool description from the docstring, which makes short servers very short — and makes a lazy docstring a shipped defect.

import os
from typing import Literal

import httpx
from mcp.server.fastmcp import FastMCP

mcp = FastMCP("clip-tools")
API = "https://example.internal/v1"
HEADERS = {"authorization": f"Bearer {os.environ['CLIP_API_KEY']}"}

CaptionStyle = Literal["karaoke", "hormozi", "beasty", "fire", "youshaei", "cinematic"]


@mcp.tool()
async def queue_clip_job(
    url: str,
    max_clips: int = 5,
    caption_style: CaptionStyle = "karaoke",
    aspect: Literal["9:16", "1:1", "16:9"] = "9:16",
) -> str:
    """Start processing a recorded video URL into short vertical clips.

    Returns a job_id right away; the work happens in the background.
    Follow up with get_clip_job using that exact job_id.
    Do not call this for a stream that is currently live.
    """
    async with httpx.AsyncClient(timeout=30) as c:
        r = await c.post(
            f"{API}/jobs",
            headers=HEADERS,
            json={
                "url": url,
                "max_clips": max_clips,
                "caption_style": caption_style,
                "aspect": aspect,
            },
        )
    if r.status_code == 403:
        return ("Rejected: the source URL is not publicly readable (HTTP 403). "
                "Ask for a public link, or have the file uploaded first.")
    if r.status_code >= 400:
        kind = "temporary" if r.status_code >= 500 else "permanent"
        return f"Submission failed with HTTP {r.status_code} ({kind})."

    job_id = r.json()["job_id"]
    return (f'Queued. Call get_clip_job with job_id="{job_id}" '
            "to collect the clips.")


if __name__ == "__main__":
    mcp.run()                                    # stdio
    # mcp.run(transport="streamable-http")       # same tools, served over HTTP

That commented last line is the entire transport switch. Everything that makes a remote server hard — identity, limits, uptime, buffering — sits outside the SDK, which is the subject of the next few sections.

Reading the Wire When Something Breaks

You never implement these frames by hand. You will read them at 1 a.m., which is a different skill and worth ten minutes now.

client                                          server
  |-- initialize (protocolVersion, capabilities) -->|
  |<- result: capabilities, serverInfo -------------|
  |-- notifications/initialized ------------------->|
  |                                                 |
  |-- tools/list ---------------------------------->|
  |<- [{ name, description, inputSchema }, ...] ----|
  |                                                 |
  |-- tools/call { name, arguments } -------------->|
  |<- { content: [{ type: "text", text }] } --------|

Against an HTTP server you can drive that sequence with curl. Send both content types in Accept, because the server may answer with a single JSON body or open an SSE stream.

curl -sS https://api.example.com/mcp -i \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-06-18",
      "capabilities": {},
      "clientInfo": { "name": "curl", "version": "0.0.1" }
    }
  }'

The protocolVersion above is an example value — substitute the current published specification revision when you run this.

If the server is session-based it returns an Mcp-Session-Id response header; echo that header on every later request or you will be handed a fresh, empty session each time. Then send notifications/initialized, then tools/list, then tools/call.

That sequence is also the fastest triage tool you have. If curl lists your tools and a host shows none, the fault is in the host's configuration or your auth handling — not in your tool code, and no amount of rewriting handlers will fix it. How MCP Servers Work: Architecture and Request Flow follows one call end to end if you want the fuller trace.

Jobs That Outlive a Single Tool Call

Tutorials return in milliseconds. Real integrations frequently do not, and this is where server shape stops being cosmetic. Video is a clean case: rendering several clips from a long recording legitimately takes minutes, and a livestream has no end time at all, so no single blocking call can ever be correct.

ClipSpeedAI's server, ten tools, is a worked instance. Recorded video splits along the obvious seam: submit_to_clipspeed drops a URL or file in, and check_clips returns the finished, scored, captioned 9:16 clips for a project, each with a title, a viral score and a download URL. Live streams get a whole lifecycle instead of one call — clip_livestream opens a session and hands back a subscription id, check_livestream polls it (status monitoring means the stream is still live and still being clipped), extend_livestream keeps an active session going, and stop_livestream ends it while keeping every clip already made. Around those sit discover_trending, which looks for the fastest-growing recent video in a niche and searches only the last few weeks of uploads, plus list_templates, creator_pack for per-clip titles, hooks and posting times, and publish_to_youtube.

submit_to_clipspeed(url)  --> projectId   [returns a handle, not the clips]
        |
        |   rendering happens server-side
        v
check_clips(projectId)    --> still working   [agent waits, calls again]
check_clips(projectId)    --> clips: title, viral score, download URL

clip_livestream(url)      --> subscriptionId
check_livestream(id)      --> monitoring, N clips so far
extend_livestream(id)     --> session continues
stop_livestream(id)       --> session ends, existing clips kept

Four rules generalise from that shape:

  1. The starting tool returns a handle, not a result. Holding a call open for minutes gets you a client timeout and a model that has lost the thread of what it was doing.
  2. The status tool is separate, cheap and idempotent. It will be called repeatedly by design. Make that free.
  3. Every state transition gets its own verb. An agent reasons about stop reliably. It does not reliably infer that update(status="stopped") means the same thing.
  4. Starts must tolerate retries. Make them idempotent or accept an idempotency key, because agents retry and a duplicated submit costs your user real money.

For the same operations seen from the consumer side rather than the builder's, Livestream Clipping API: Clip While You Stream covers the live path. AI Clipping API: Programmatic Short-Form Video shows the equivalent without any protocol in the middle.

Putting It on the Internet

Switching transport is a line. Operating the result is a project. The failure modes that actually bite:

Publishing a remote endpoint also means signing up for an availability promise you may not have thought about. Every host that connects will report your outage as "the tool is broken," and the person filing that report has no way to tell your bad deploy from their bad network. Health checks, structured request logs keyed by identity, and a visible status signal should exist before the first external user, not after the first incident.

Issuing Keys, and the Revoke Button You Will Need

stdio servers read credentials from environment variables in the client's config. Remote servers need genuine authorization, and two patterns cover nearly everything.

Bearer keys. The user generates a key in your product and pastes it into a header. It is simple, scriptable and correct for CLI clients, CI jobs and cron. The price is a long-lived secret sitting in a plaintext config file, which makes scoping, rotation and revocation your responsibility rather than an optional extra.

OAuth. The host opens a browser, the user consents, the client holds a refreshable token. Better consent UX in graphical clients and better revocation, at the cost of running or delegating an authorization server and exposing the metadata endpoints the authorization spec expects.

Supporting both against one identity is common. ClipSpeedAI's endpoint at https://api.clipspeed.ai/mcp does exactly that: OAuth for GUI clients — one-click in claude.ai — and a Bearer key for CLI clients, with some GUI clients still using a config file holding a static key. The CLI registration is a single command:

claude mcp add --transport http clipspeed https://api.clipspeed.ai/mcp \
  --header "Authorization: Bearer <API_KEY>"

Its key design is worth copying wholesale, because each decision solves a problem you will otherwise hit in month two. Keys are minted at POST /auth/api-keys (in the UI: Account → API & Integrations → Generate API Key) and look like csai_live_ followed by 48 hex characters, from 24 random bytes. The recognisable prefix means a leaked key is greppable in logs and identifiable in a pasted screenshot. Only the prefix — the first 18 characters plus an ellipsis — is stored for display, so the full secret is shown once at creation and cannot be retrieved afterwards, including by you. GET /auth/api-keys lists each key with its name, prefix, plan, rate limit, requests today, total requests, last request time, active flag and creation date. DELETE /auth/api-keys/:id flips the key inactive and stamps a revocation time.

That last endpoint is the one most first servers skip, and it is the one you need on your worst day. A key that cannot be revoked without a database migration is a key you will end up revoking with a database migration, at 2 a.m., for every user at once. Per-key rate limits and request counters belong in the same table for the same reason: when one agent enters a retry loop, you want to throttle one key, not your whole endpoint.

MCP Authentication: OAuth and Bearer Keys goes into token scoping, refresh behaviour and what to do when a host has not implemented the OAuth flow yet.

Treating Your Own Output as Untrusted

An MCP server is a remote execution surface driven by something that can be talked into things. That reframing changes several defaults.

Threat modelling for multi-tenant servers, including the confused-deputy problem when you hold credentials on a user's behalf, is the subject of MCP Security: Scopes, Keys and Safe Tool Design.

Two Error Channels, and Which One the Model Reads

There are two ways to fail and they reach different audiences. Confusing them is why some servers are impossible for an agent to recover from.

Protocol errors are JSON-RPC errors: unknown method, malformed frame, arguments that fail schema validation. These are addressed to the client. The model may never see them, so they are useless as a way to steer behaviour.

Tool errors are successful responses carrying isError: true and readable text. These are addressed to the model, and they are your only chance to change what happens next. "Upload failed" ends the attempt. "Upload failed: the URL returned 403. Provide a publicly reachable link, or upload the file first" produces a correct second attempt.

Habits worth building in from the first commit:

Three Ways to Test, Cheapest First

Unit tests on the handlers. Your tool functions are ordinary functions taking ordinary arguments. Test them with no transport at all. This catches most logic bugs in milliseconds and needs no protocol knowledge.

MCP Inspector. The official interactive debugger connects to a server and lets you enumerate and invoke tools by hand while watching the raw JSON in both directions:

# local stdio server
npx @modelcontextprotocol/inspector node ./server.js

# remote HTTP server: start the inspector, then enter the URL
# and your Authorization header in the UI
npx @modelcontextprotocol/inspector

A real host, given a real task. This is the only test that examines your vocabulary rather than your plumbing. Connect the server to an actual client and describe an outcome in plain language without naming any tool. If the model picks the wrong one or invents an argument, the defect is in your descriptions and schemas — the model behaved reasonably given what you told it. Rewrite the text, not the handler. Claude Code vs Cursor for MCP Workflows compares what that iteration loop feels like in each environment.

One trap eats an evening the first time: on stdio, stdout is the protocol channel. A single stray console.log or print corrupts the JSON stream and the client disconnects with a parse error that points nowhere near the offending line. Send every log line to stderr, and audit your dependencies for the same sin.

Making Installation a Copy-Paste

Install friction determines adoption more than capability does. Someone evaluating your server gives it a few minutes.

For local servers, publish to npm or PyPI with an executable entry point so nobody has to clone a repository. A bin entry lets configs reference a package name instead of an absolute path that is wrong on every machine but yours:

{
  "mcpServers": {
    "your-server": {
      "command": "npx",
      "args": ["-y", "your-mcp-server@latest"],
      "env": { "YOUR_API_KEY": "..." }
    }
  }
}

That is a shape many hosts use for a local server, not a universal schema. File name and location differ per client, and remote entries vary more: some clients expect a URL entry with headers, others still want a local command that fronts the remote endpoint, and key names are not consistent. Do not print a remote config block for a client you have not tested — follow that client's own MCP documentation and let the per-client guides carry the specifics. ClipSpeedAI MCP for Claude Code: Complete Setup Guide is the CLI case. ClipSpeedAI MCP for Cursor: Complete Setup Guide handles an editor. For the desktop app, see ClipSpeedAI MCP for Claude Desktop: Complete Setup Guide, and ClipSpeedAI MCP for Windsurf: Complete Setup Guide covers that IDE.

For a remote server the install is a URL plus a credential, and an npm package can exist alongside it — ClipSpeedAI publishes clipspeed-mcp (v1.0.0, released 2026-07-10) as well as the hosted endpoint.

Be exact about support tiers in your own documentation, because "we verified this end to end" and "it speaks the protocol and we expect it to work" are different promises and blurring them generates support load. ClipSpeedAI splits its client list three ways: verified end-to-end (Claude on claude.ai, Claude Code, Claude Desktop, Windsurf); protocol-compatible with verification still in progress (Cursor, Codex, OpenClaw, Hermes); and vendor-gated rollout, unverified (ChatGPT). That third category is worth naming out loud whenever availability depends on somebody else's release schedule rather than your code.

When a Script Beats a Server

MCP is not a general-purpose API layer, and building one where it does not belong produces something slower and less reliable than what it replaced. Honest limits:

Decision criteria for the first of those boundaries are laid out in MCP vs REST API: When to Use Each. The distinction from a model's own built-in mechanism is covered separately in MCP vs Function Calling: What Actually Differs. Condensed: build a server when a person states an outcome in prose and something has to choose the steps.

First-Build Mistakes That Look Like Protocol Bugs

Every item here has been reported at least once as "MCP is broken." None of them are.

The Pre-Ship Checklist

  1. Every description states what the tool does, when to use it, when not to, and what comes back.
  2. Every argument has a type, a constraint where a closed set or range exists, and a one-line description.
  3. Long operations return a handle immediately, and the status tool's argument name matches that handle exactly.
  4. Starting operations are idempotent or accept an idempotency key.
  5. Errors are readable sentences that say whether a retry is worth attempting.
  6. Every outbound call has a hard timeout; no tool call holds a connection for minutes.
  7. On stdio, nothing but protocol frames reaches stdout — dependencies included.
  8. Secrets come from the environment or a token, never from source, and never reach a log.
  9. Keys can be listed and revoked through an endpoint, not a migration, and are rate-limited per identity.
  10. Public or destructive actions default to the safe setting and require a distinct scope.
  11. A real host, handed a plain-language task with no tool names mentioned, picks the right tool on the first attempt.

The last line is the only one that predicts whether anyone keeps your server installed, and it is the one most builds skip. For a sense of how finished servers present themselves once they clear that bar, Best MCP Servers for Video and Content Workflows is a useful survey. Longer chains — discovery, clipping, packaging, publishing — are traced in AI Agent Video Automation: End-to-End Workflows.

If you want to study a running remote server before building your own, ClipSpeedAI's endpoint is live and its ten tools are documented; a $1 charge starts a 3-day trial, and there is a single free demo for a video under 30 minutes if you only want to watch one round-trip go by. Reading someone else's tool descriptions with an eye for the exclusions they wrote is a faster education than another minimal example.

Frequently asked questions

What is the shortest path to a server that actually runs?
Install the TypeScript or Python SDK, register one tool with a name, a description and an argument schema, attach a stdio transport, and point a client at the command. That is roughly 30 lines. Verify it with `npx @modelcontextprotocol/inspector node ./server.js` before you wire it into a host, so that a failure has only one possible cause.
Do I have to implement JSON-RPC myself?
No. The official SDKs handle framing, the initialize handshake, capability negotiation, schema generation from your types, and both transports. You write handlers. Learning to read the frames is still worth the ten minutes, because it is how you tell a server bug apart from a host configuration problem — curl the endpoint, and if tools/list answers correctly the fault is on the client side.
stdio or streamable HTTP?
Choose stdio when the work needs the user's local files or local credentials, or when you would rather not operate infrastructure. Choose streamable HTTP when you are wrapping a service you already run, need per-user identity and audit trails, or want to ship fixes without asking every user to upgrade a package. Remote MCP vs Local MCP Servers covers the hybrid arrangements.
How do I handle work that takes minutes?
Split the tool in two. One starts the work and returns an identifier immediately; a second, cheap tool reports status and returns results when they exist. Holding a call open causes client timeouts and loses the model's thread. ClipSpeedAI's submit_to_clipspeed and check_clips pair is this shape, and its clip_livestream, check_livestream, extend_livestream and stop_livestream set extends it to sessions with no natural end.
How many tools is too many?
Enough to cover the workflow, few enough that no two are easy to confuse. Every definition is re-sent each turn and competes for attention, so the cost is paid continuously while the benefit is occasional. Around a dozen clearly distinct tools is a comfortable target; well past that, fold variants behind an enum argument rather than minting new tools.
Should a remote server use bearer keys or OAuth?
Bearer keys are simplest and right for CLI clients and automation, at the cost of a long-lived secret in a config file. OAuth gives better consent UX in graphical clients and cleaner revocation, at the cost of running or delegating an authorization server. Supporting both against one identity is normal — ClipSpeedAI uses OAuth for GUI clients, one-click in claude.ai, and a bearer key for CLI clients.
What should an API key for my server look like?
Give it a recognisable prefix so a leak is greppable, generate the secret from a cryptographic random source, store only enough to display, and expose list and revoke endpoints from day one. ClipSpeedAI is a usable template: keys are csai_live_ plus 48 hex characters from 24 random bytes, only the first 18 characters are retained for display so the full key is shown once at creation, and DELETE /auth/api-keys/:id marks a key inactive with a revocation timestamp.
Why does the model keep choosing the wrong tool?
Nearly always because two descriptions overlap, or because a description explains what the tool is instead of when to reach for it. The fix is text, not code: state the trigger condition, state the exclusion in plain words, replace free-form strings with enums, then retest by giving a host a task in natural language without naming any tool.
Can something other than an AI chat client drive my server?
Yes. It is JSON-RPC over stdio or HTTP, so a CI job, a shell script or your own agent runtime can call it. Whether it should is a separate question — if the order of calls is fixed and known, calling the underlying API directly is simpler and easier to test. MCP vs REST API: When to Use Each lays out that trade-off in detail.

Related reading

ClipSpeedAI MCP on claude.ai: The No-Config ConnectorClipSpeedAI MCP for Claude Code: Terminal Setup, Keys and Tool ReferenceClipSpeedAI MCP for Claude Desktop: Complete Setup GuideClipSpeedAI MCP in Windsurf: Bearer Key Setup for CascadeClipSpeedAI MCP in Cursor: Connect the Clipping Engine to Your Editor AgentClipSpeedAI MCP for Codex CLI: HTTP Setup and Tool ReferenceClipSpeedAI MCP for OpenClaw: Complete Setup GuideClipSpeedAI MCP on Hermes Agent: Connect and Verify
Start clipping for $1 →