MCP servers
Mount any Model Context Protocol server as a tool group. Skawld launches the server as a child process (stdio) or connects over Streamable HTTP, lists its tools, and exposes each one to the agent under an mcp__<server>__<tool> name.
import { Agent } from "@skawld/agent-sdk"; const agent = new Agent({ provider, model: "claude-opus-4-5", mcpServers: { postgres: { type: "stdio", command: "uvx", args: ["mcp-server-postgres", process.env.DATABASE_URL!], }, }, });
#Configuring servers
Pass mcpServers on Agent as a record keyed by server name. Each entry is either an McpStdioServerConfig (spawn a child process and talk over stdio) or an McpHttpServerConfig (connect to a remote server over Streamable HTTP). The shape mirrors the Claude Agent SDK’s mcpServers option so existing configs port across without translation.
#Stdio servers
Stdio is the default transport. The server is spawned as a child process; JSON-RPC flows over its stdin/stdout. Child stderr is inherited so the server’s diagnostics appear on your console and a chatty server can never fill an unread pipe and block.
| Field | Type | Purpose |
|---|---|---|
type | "stdio" (optional) | Discriminator. Omit it and stdio is assumed; include it for symmetry with HTTP configs. |
command | string | Executable to spawn. Resolved against PATH — pass an absolute path if you want to avoid that. |
args | string[] (optional) | Arguments passed to command. Defaults to []. |
env | Record<string, string> (optional) | Extra environment variables, merged over the parent process env. Keys you set here win against the inherited value. |
Three idiomatic stdio launchers — Python via uvx, Node via npx, and a local script — look like this:
mcpServers: { // Python MCP server pulled and run by uvx postgres: { command: "uvx", args: ["mcp-server-postgres"], env: { DATABASE_URL: process.env.DATABASE_URL! }, }, // Node MCP server, downloaded on demand by npx filesystem: { type: "stdio", command: "npx", args: ["-y", "@modelcontextprotocol/server-filesystem", "/srv/data"], }, // Your own local MCP server, run by an interpreter you control internal: { command: "/usr/bin/python3", args: ["./tools/mcp_server.py"], }, },
Secrets belong in env, not args.
Arguments are visible to anyone who can ps the host;
environment variables given to a child process are not. Pass
DATABASE_URL, OPENAI_API_KEY, etc. via
env and let the MCP server read them.
#HTTP servers
HTTP is Streamable HTTP — the modern MCP transport that multiplexes JSON-RPC over a single HTTP connection. Use it for hosted MCP servers or anything running outside your process.
| Field | Type | Purpose |
|---|---|---|
type | "http" | Required discriminator. |
url | string | Server endpoint URL. Parsed with the platform URL constructor — invalid URLs fail at connect time. |
headers | Record<string, string> (optional) | Static headers sent with every request. Use for Authorization, tenant ids, or anything else the server expects on each call. |
mcpServers: { docs: { type: "http", url: "https://mcp.example.com/v1", headers: { Authorization: `Bearer ${process.env.MCP_TOKEN!}`, "X-Tenant": "acme", }, }, },
#Choosing a transport
Two questions decide it: where does the server run, and who owns its credentials?
| Use stdio when… | Use HTTP when… |
|---|---|
| The server is a local binary or script you can spawn — file system, local databases, ad-hoc Python/Node tools. | The server is hosted (yours or a vendor’s) and shared across many agents, or its dependencies are too heavy to spawn per-process. |
Secrets live in your process environment and you want to hand them to the child via env. | Auth is a token you pass over the wire as a header — the agent never sees the underlying credentials. |
| You want full lifecycle control: the process dies when the agent closes. | The server has its own lifecycle (deploy, scale, monitor) and you only want a client connection. |
#Multiple servers
Configure as many servers as you want — they connect in parallel and their tools merge into the same registry. The config keys become the namespace prefix, so duplicate tool names across servers never collide.
import { Agent } from "@skawld/agent-sdk"; const agent = new Agent({ provider, model: "claude-opus-4-5", mcpServers: { postgres: { command: "uvx", args: ["mcp-server-postgres"], env: { DATABASE_URL: process.env.DATABASE_URL! }, }, github: { type: "http", url: "https://mcp.github.com", headers: { Authorization: `Bearer ${process.env.GITHUB_TOKEN!}` }, }, }, });
#Connection lifecycle
MCP servers do not connect when you construct the Agent. They connect lazily on the first agent.session() call and the connect attempt is memoized — concurrent session() calls share one in-flight attempt rather than racing.
On agent.close() every client is closed; stdio child processes are terminated. close() is idempotent.
Connect is fail-fast. If any configured server
fails to connect or fails its initial listTools call, every
server that had already opened is torn down and
connectMcpServers throws a ConfigError
aggregating the failures. A partial set is never returned. The memoized
attempt is cleared on failure, so the next agent.session()
call will retry from scratch.
Per-call errors after a successful connect are handled differently: a tool invocation that throws is captured and returned as a tool result with is_error: true, so the model sees the failure and can react rather than crashing the run.
#Tool naming
Every MCP tool is registered under the fully-qualified name mcp__<server>__<tool>. The server segment is your config key; the tool segment is the name the MCP server reports in its own manifest. The fixed mcp__ prefix guarantees MCP tools never collide with skawld built-ins (Read, Bash, etc.).
Both segments are normalised: any character outside [a-zA-Z0-9_-] is replaced with a single underscore. So a config key of my server.v2 becomes my_server_v2 and a tool named fetch:url becomes fetch_url.
| Config key | MCP tool name | Exposed as |
|---|---|---|
postgres | query | mcp__postgres__query |
github | list-issues | mcp__github__list-issues |
my server.v2 | fetch:url | mcp__my_server_v2__fetch_url |
Collision detection. If two config keys normalise to the
same string (e.g. my.server and my-server both
become my_server), connectMcpServers throws a
ConfigError before any process is spawned. Rename one key.
#Permissions
MCP tools flow through the same permission engine as every other tool, matched by their fully-qualified name. To allow or deny a specific MCP tool, use the mcp__<server>__<tool> form in a ToolRule. See Permissions for rule semantics and the canUseTool callback.
const agent = new Agent({ provider, model: "claude-opus-4-5", mcpServers: { postgres: { command: "uvx", args: ["mcp-server-postgres"] }, }, permissions: { rules: [ // Pre-approve read-only queries { kind: "tool", tool: "mcp__postgres__query", decision: "allow" }, // Block the destructive one outright { kind: "tool", tool: "mcp__postgres__exec", decision: "deny" }, ], }, });
#How MCP tools behave
Each MCP tool is wrapped as a skawld Tool at connect time. The wrapper preserves the server’s name, description, and JSON input schema verbatim — the MCP server owns its own validation, so skawld only checks that input is an object and passes it through.
| Property | Derived from |
|---|---|
scope | "read" when the MCP tool sets the readOnlyHint annotation; "write" otherwise. |
parallelSafe | Same signal — read-only MCP tools can run in parallel; the rest are serialised by the scheduler. |
input_schema | The MCP tool’s inputSchema (properties + required) passed through unchanged. |
| Result content | MCP text blocks become skawld text; image blocks become base64 image blocks. Audio and embedded-resource blocks degrade to a text placeholder. |
Tools only, for now. Skawld mounts MCP tools.
MCP resources and prompts are not exposed to the agent today — only what
the server returns from listTools is wired in.