· 8 min read
Build a Custom MCP Server for Cline Without Creating Another Fragile Internal Platform
By W. Petrova
- tools
Build a custom MCP server for Cline when your team repeatedly performs one bounded operation that Cline cannot do safely or consistently with its built-in tools. Don’t build one to give the agent “access to everything”; build it to make one workflow—such as looking up a CI build, checking a feature flag, or opening a sanitized incident summary—predictable.
The practical first version is a local stdio server with one read-only tool and a five-second timeout. It is small enough to debug in a terminal, doesn’t require exposing a new HTTP endpoint, and lets you discover whether the tool is actually useful before you make it shared infrastructure.
Pick a job that has a hard boundary
The best custom MCP tools are wrappers around work your developers already do by copying an ID from one system into another: “what happened to build 4812?”, “which environment has this flag enabled?”, or “is this deploy approved?” They take a small input, call one owned system, and return a compact answer that Cline can use in the next step.
Avoid a first tool named run_internal_api or execute_query. Those are not tools; they are permission systems disguised as convenience. They force the model to compose requests you did not anticipate, make authorization fuzzy, and turn every debugging task into an audit problem.
- Good first tool:
get_build_status({ buildId })returns status, commit SHA, failing job names, and a build URL. - Good first tool:
get_feature_flag({ key, environment })returns a flag’s state and targeting summary, with no mutation path. - Bad first tool:
query_database({ sql }), even if you promise it will only hit a replica. - Bad first tool:
call_service({ path, method, body }), because its actual interface is your entire internal API.
This is also where custom MCP servers are bad. They are a poor replacement for Cline’s normal file, shell, and Git capabilities. If your tool mostly runs git log, greps the repository, or invokes a common package script, you are adding code, credentials, and a failure mode to solve a problem Cline already has a tool for.
Start with a local stdio server
Create a directory beside the service or automation it wraps. For a TypeScript server using the current MCP TypeScript SDK, install the server package, Zod for input validation, and tsx for a no-build local loop:
mkdir cline-release-status-mcp && cd cline-release-status-mcp
npm init -y
npm pkg set type=module
npm i @modelcontextprotocol/server zod
npm i -D typescript tsx @types/node
mkdir srcPut this in src/index.ts. The API base URL and token belong in the process environment, not in a prompt, tool argument, or committed config file. The regular expression is intentionally boring: the server accepts a build identifier, not a path, URL, filter language, or arbitrary JSON blob.
import { McpServer } from "@modelcontextprotocol/server";
import { serveStdio } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";
function createServer() {
const server = new McpServer(
{ name: "release-status", version: "0.1.0" },
{ instructions: "Use get_build_status only when a build ID is known." }
);
server.registerTool(
"get_build_status",
{
description: "Get the status and failed jobs for one CI build ID.",
inputSchema: z.object({
buildId: z.string().regex(/^[A-Za-z0-9_-]{1,64}$/)
})
},
async ({ buildId }) => {
const baseUrl = process.env.CI_API_BASE;
const token = process.env.CI_TOKEN;
if (!baseUrl || !token) {
return {
content: [{ type: "text", text: "CI_API_BASE or CI_TOKEN is not configured." }],
isError: true
};
}
try {
const response = await fetch(
`${baseUrl}/builds/${encodeURIComponent(buildId)}`,
{
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(5_000)
}
);
if (!response.ok) {
return {
content: [{ type: "text", text: `CI returned HTTP ${response.status}.` }],
isError: true
};
}
const build = await response.json();
return {
content: [{
type: "text",
text: JSON.stringify({
id: build.id,
status: build.status,
commit: build.commit,
failedJobs: build.failedJobs ?? [],
url: build.url
}, null, 2)
}]
};
} catch (error) {
return {
content: [{ type: "text", text: `Build lookup failed: ${String(error)}` }],
isError: true
};
}
}
);
return server;
}
void serveStdio(createServer);
console.error("release-status MCP server running on stdio");Two details prevent a surprising number of dead connections. First, use serveStdio with a server factory in the current SDK rather than copying an older StdioServerTransport example from a blog post. Second, stdout is the MCP protocol channel. A stray console.log can corrupt it, so send startup and debug output to stderr with console.error.
Test the tool before Cline sees it
Do not make Cline your test runner for an MCP server. Run the MCP Inspector first, with the same environment variables that Cline will inherit. This command lists the tools without involving your editor or agent session:
CI_API_BASE=https://ci.internal.example \
CI_TOKEN=replace-me \
npx @modelcontextprotocol/inspector --cli \
npx tsx src/index.ts \
--method tools/listThen call the tool with a known build ID. You are checking four things: the server stays running, input validation rejects junk, an upstream 401 produces a useful error, and the response contains only the fields the agent needs. If the raw response is 300 KB of job logs, fix that here. Don’t ask the model to summarize an accidental data dump on every call.
CI_API_BASE=https://ci.internal.example \
CI_TOKEN=replace-me \
npx @modelcontextprotocol/inspector --cli \
npx tsx src/index.ts \
--method tools/call \
--tool-name get_build_status \
--tool-arg buildId=4812Add it to Cline, then leave approval on
Cline’s CLI can open an add-server wizard with the command already filled in. From the MCP project directory, run:
cline mcp install release-status -- npx tsx src/index.tsThe wizard requires a TTY and will collect the remaining connection details before saving the server. In the Cline extension, you can also open the MCP Servers panel and edit its MCP settings directly; project-level Cline configuration supports an .cline/mcp.json file. For a shared remote server, set the transport explicitly to Streamable HTTP—Cline documents it as the recommended remote transport, while an omitted type can fall back to legacy SSE behavior.
Keep tool approval enabled at first. A read-only tool can still leak data, incur a costly query, or behave differently from its description after an upstream API changes. Cline supports auto-approval lists, but treat auto-approval as the last step: add it only after you have inspected real calls, confirmed the server enforces authorization itself, and kept the input surface narrow.
Make the server operable before making it shared
Once more than one developer uses it, the server needs the same basics as any internal integration: a clear owner, a minimal credential scope, documented environment variables, timeout behavior, and a release path. If you move it to HTTP, add authentication and deploy it like a production service; a hosted MCP endpoint is not magically safer because the caller is an agent.
Also write the tool description as if it were an API contract, because it is. Say exactly what the tool can retrieve, what identifiers it accepts, its maximum result size, and what it must never do. A vague description such as “helps with releases” gives Cline nothing useful to select on. Get status and failed jobs for one CI build ID gives it a clear reason to call the tool—and a clear reason not to.