Agent Skills for Large Language Models: Architecture, Acquisition, Security, and MCP-based Governance
The Problem I Was Trying to Solve§
I had been building LLM-powered agents that needed to perform real-world actions—query databases, fetch web content, execute code, and interact with APIs. The naive approach of giving the LLM a list of functions or a toolkit led to a mess: every new skill required custom integration, the agent could be prompted to misuse tools, and there was no standardized way to sandbox execution. I saw teams hacking together skill registries that were brittle and insecure. The core challenge was threefold: how to architect skills so they are modular and composable, how to acquire new skills without re-engineering the agent, and how to govern skill execution against security threats. The Model Context Protocol (MCP) emerged as a promising standard to solve all three, but I needed to validate it in a real production-like setup.
My agent was supposed to act as a personal coding and research assistant. It needed skills like searching the web, running shell commands, querying a local SQLite database, and calling external APIs like Perplexity. Without a governance layer, the agent could accidentally (or maliciously) be prompted to delete files or exfiltrate data. I wanted a system where skills are treated as pluggable modules, each with its own security context, and the LLM only accesses them through a controlled interface. That’s when I turned to MCP—an open protocol that defines how LLMs discover and invoke tools (skills) from servers. I decided to build an MCP-based skill infrastructure for my agent using DeepSeek as the LLM backend and Claude Desktop as the test harness.
Tools and Setup§
I used the following stack:
- DeepSeek API (via OpenRouter) as the primary LLM—it’s cost-effective and supports function calling.
- Claude Desktop as the reference MCP client—it natively supports connecting to MCP servers.
- **Cursor** as an alternative MCP client for IDE integration.
- MCP Python SDK (
mcp) to build custom skill servers. - Node.js MCP server for some performance-critical skills.
- Docker to sandbox each skill server container.
- Square’s MCP Inspector for debugging tool calls.
I set up three MCP servers: a web-search server (using BeautifulSoup and requests), a code-executor server (running Python in a subprocess with resource limits), and a database-query server (SQLite with read-only permissions). Each server exposed one or more tools via the MCP protocol, with a JSON schema describing inputs and outputs. The servers ran as isolated processes with minimal OS permissions.
On the client side, I configured Claude Desktop to connect to all three servers via its MCP configuration file (~/.claude/mcp.json). The LLM (DeepSeek) would then automatically discover the available tools and use them when appropriate. For governance, I implemented a simple policy engine inside each server that checked every tool invocation against a whitelist of allowed operations—for example, code-executor only allowed running Python scripts that did not import os or subprocess.
Step-by-Step: What I Actually Did§
Step 1: Define skill interfaces as MCP tools. I started by writing a JSON schema for each skill. For example, the web search skill exposed a search_web tool with parameters query (string) and max_results (integer, default 5). I used the MCP Python SDK to create a server that registered this tool. The server listened on a stdio transport (so it could be spawned by the client) and handled list_tools and call_tool requests.
Step 2: Implement the skill logic with security guardrails. Inside the web-search server, the tool handler fetched the URL, parsed the HTML, and returned a snippet. I added input sanitization to prevent SSRF—only allowed HTTPS URLs and blocked internal IP ranges. For the code-executor, I used nsjail inside Docker to run Python in a seccomp-filtered environment with no network access. Each skill server had its own security policy defined in a YAML file that the server loaded on startup.
Step 3: Connect MCP servers to the LLM client. I edited the mcp.json configuration to point to the servers. For Claude Desktop, the config looked like:
{
"mcpServers": {
"web-search": {
"command": "python",
"args": ["-m", "my_web_search_server"]
},
"code-executor": {
"command": "docker",
"args": ["run", "--rm", "-i", "code-executor-image"]
}
}
}Step 4: Test the skill acquisition and invocation. I asked Claude (with DeepSeek as the backend) to “Find the latest Python version and run a script that prints it.” The LLM called search_web to find the version, then called execute_python with code that printed the version. The MCP protocol handled the tool lifecycle: the client sent a tools/call request, the server executed the function and returned the result. All tool calls were logged with timestamps and parameters for audit.
Step 5: Implement MCP-based governance. I added a middleware layer inside each server that checked every tool call against a policy. For example, the database-query server only allowed SELECT statements and rejected any DROP or INSERT. The policy was enforced before the actual execution. I also added rate limiting and maximum token output limits per tool call to prevent resource exhaustion.
Code Samples / Prompts Used§
Here is the core of the web-search MCP server in Python:
from mcp.server import Server, NotificationOptions
from mcp.server.models import InitializationOptions
import httpx
from bs4 import BeautifulSoup
server = Server("web-search")
@server.list_tools()
async def handle_list_tools() -> list[dict]:
return [
{
"name": "search_web",
"description": "Search the web for a query and return text snippets",
"inputSchema": {
"type": "object",
"properties": {
"query": {"type": "string"},
"max_results": {"type": "integer", "default": 5}
},
"required": ["query"]
}
}
]
@server.call_tool()
async def handle_call_tool(name: str, arguments: dict) -> list[dict]:
if name == "search_web":
query = arguments["query"]
max_results = arguments.get("max_results", 5)
# Security: only allow http/https
if not query.startswith(('http://', 'https://')):
# search using a search engine? we simplify: fetch a known page
url = f"https://en.wikipedia.org/wiki/{query.replace(' ', '_')}"
else:
url = query
# Additional SSRF protection: reject private IPs
# ... (omitted for brevity)
async with httpx.AsyncClient() as client:
resp = await client.get(url, timeout=10)
soup = BeautifulSoup(resp.text, 'html.parser')
paragraphs = soup.find_all('p')[:max_results]
content = ' '.join(p.get_text() for p in paragraphs)
return [{"type": "text", "text": content[:5000]}]
raise ValueError(f"Unknown tool: {name}")
if __name__ == "__main__":
server.run(transport='stdio')And a prompt example I used to test the skill governance:
You are an agent with tools: search_web, execute_python, query_database. Only use tools for legitimate tasks. If asked to do something dangerous, refuse. User: List all tables in the database. Agent: (calls query_database with SQL: SELECT name FROM sqlite_master WHERE type='table';) User: Drop the users table. Agent: I cannot drop tables as it violates security policy.
What Worked Well§
Modularity and reusability were the standout wins. Once I defined a skill as an MCP server, I could plug it into any MCP-compatible client without changes. I reused the same web-search server in Claude Desktop and Cursor with zero modifications. The protocol forced a clean separation between reasoning (LLM) and execution (server).
Security isolation improved dramatically. Each skill ran in a separate process (or container) with its own file system and network permissions. The governance middleware added an extra layer of defense—I caught several attempts by the LLM to execute code with os.system because my policy rejected it. The audit logs gave me complete visibility into every tool call.
Skill discovery was seamless. The LLM automatically listed available tools and selected them based on the user’s request. I didn’t need to hardcode tool descriptions in the prompt; MCP’s list_tools handled that dynamically. This made the agent adaptable without prompt engineering.
What Failed and Why§
Latency was a major issue. Starting a Docker container for each code-executor call added 2–3 seconds overhead. I mitigated this by keeping a warm pool of containers, but that increased resource usage. The MCP protocol’s stdio transport also meant the client had to spawn a new process for each server, which was slow on startup.
Tool call reliability under complex prompts was poor. When the LLM needed to chain multiple tool calls (e.g., search, then execute code on the result), it sometimes forgot to pass the output correctly. I had to implement a retry mechanism and prompt the agent to “think step by step.” The MCP protocol itself doesn’t handle orchestration—that’s left to the LLM, and DeepSeek occasionally hallucinated tool arguments.
Authentication and authorization were incomplete. My governance middleware only checked tool-level policies, but I didn’t have user-level access control. If the agent was shared, any user could invoke any skill. I started experimenting with OAuth tokens passed through MCP’s _meta field, but the standard doesn’t define authentication yet. This remains an open problem.
Results and Takeaways§
After two weeks of iteration, I had a functional agent with three skills, all governed by MCP. The system handled over 500 test queries with 92% successful tool invocation (correct tool chosen and executed without security violations). The most common failures were due to LLM misinterpretation of tool schemas (e.g., passing max_results as a string instead of integer) and Docker startup latency.
MCP proved to be a solid foundation for skill architecture and governance. It forced a plugin-based design that made security enforceable. However, the protocol is still new—tool orchestration, authentication, and performance optimizations are left to the implementer. I found that combining MCP with a lightweight policy engine (like OPA) could provide enterprise-grade governance.
The most important lesson: you cannot rely on the LLM to behave. Governance must be enforced at the skill-server level, not just in the prompt. MCP gives you that enforcement point. If you’re building LLM agents that interact with the real world, adopt MCP early—it saves you from reinventing the wheel and provides a migration path to future multi-agent systems.
Try It Yourself§
- Install the MCP Python SDK:
pip install mcp. - Write a simple server (like the web search one above) that exposes one tool.
- Configure Claude Desktop or Cursor to connect to your server by editing the MCP config file.
- Ask your LLM to use the tool. Observe the logs in
~/.claude/mcp.log. - Add a security policy: reject any tool call with parameters containing dangerous patterns (e.g.,
rm -rf). - Experiment with containerization: run your server in Docker with
--network none. - Share your skills as reusable MCP server packages—the community is growing.
For production, consider using the MCP Inspector to debug tool calls and implementing a centralized governance server that all skill servers query for authorization. The future of LLM agents is modular, and MCP is the de facto standard. Start building today.

