mcp servers: giving ai agents real tools
ai agents are only as useful as the tools they can call. an llm that can reason about a gitlab repo but can’t actually read files, check ci status, or create branches is just a fancy summariser.
the model context protocol (mcp) solves this by standardising how ai clients discover and call external tools. instead of writing custom integrations for every ai client, you write one mcp server and every compatible client — claude, cursor, cline — can use it.
this is what i built for the gitlab mcp server project.
what mcp actually is
mcp is a json-rpc protocol that defines three primitives:
- tools — functions the llm can call (e.g.,
create_branch,get_merge_request) - resources — data the llm can read (e.g., a file’s contents, a project’s readme)
- prompts — reusable prompt templates the client can present to the user
the server exposes a tools/list endpoint. when an ai client starts a session, it calls this endpoint to discover what tools are available. from then on, the llm can call any tool by name with structured arguments.
architecture
the server has three layers:
client (claude / cursor)
↕ mcp protocol (json-rpc over stdio or http)
mcp server
├── tool handlers (40+ operations)
├── service layer (business logic)
└── api client (gitlab rest api)
the tool handlers are thin — they validate arguments, call the service layer, and return structured responses. the service layer talks to gitlab’s rest api and handles pagination, error mapping, and rate limiting.
pydantic models at every boundary: the tool input arguments are validated before reaching the service layer, and gitlab api responses are parsed into typed schemas before being returned to the client.
defining a tool
from mcp.server import Server
from mcp.types import Tool, TextContent
import mcp.types as types
server = Server("gitlab-mcp")
@server.list_tools()
async def list_tools() -> list[Tool]:
return [
Tool(
name="get_file_contents",
description="get the contents of a file from a gitlab repository",
inputSchema={
"type": "object",
"properties": {
"project_id": {"type": "string"},
"file_path": {"type": "string"},
"ref": {"type": "string", "default": "main"},
},
"required": ["project_id", "file_path"],
},
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
if name == "get_file_contents":
result = await gitlab_service.get_file(
arguments["project_id"],
arguments["file_path"],
arguments.get("ref", "main"),
)
return [TextContent(type="text", text=result)]
the tool description matters more than you’d expect. the llm reads it to decide when to call the tool. vague descriptions lead to the wrong tool being called; overly long descriptions bloat the context window. aim for one sentence that’s specific about what the tool does and when to use it.
what the agent can do
with the server running, an ai client can have a conversation like:
“check if there are any failing ci jobs on the main branch and open an issue summarising the failures”
the client will call list_pipeline_jobs, filter for failed jobs, then call create_issue with a structured summary. the whole thing happens without the user writing a single api call.
what surprised me
tool discovery overhead: the client fetches the full tool list at the start of every session. at 40+ tools with detailed schemas, this is non-trivial token usage. i added a --minimal mode that exposes only the 10 most-used tools for latency-sensitive sessions.
error propagation: when a gitlab api call fails, you have two choices — raise an exception (the client sees a tool error) or return a structured error response (the llm can reason about it). i return structured errors. the llm can then decide to retry, ask the user for clarification, or skip the step.
the gemini agent: the bundled agent.py uses gemini’s function calling to drive the mcp tools autonomously. it’s surprisingly capable at multi-step tasks like “set up branch protection on all projects in this group” — iterates through projects, checks current protection rules, and applies changes where needed.
the server is open source at github.com/samueljayasingh/Gitlab-MCP. works with claude desktop, cursor, and cline out of the box.