Skip to main content

MCP servers as tools

An agent node often wants tools that live elsewhere — GitHub, a database, a file system — behind a Model Context Protocol server. ilmek's @ilmek/mcp / Ilmek.Mcp connects to one and exposes its tools; mekik's agent wrappers give each call the same treatment as a server tool: a tool_call trace, exactly-once across an interrupt/resume, and optional human approval — keyed by the exposed tool name (github__search).

The other direction — your graph as an MCP tool for other agents — is Serving → MCP server.

TypeScript — withMcpTools​

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { McpToolbox } from "@ilmek/mcp";
import { withMekikTools, withMcpTools, runAgent } from "@mekik/langchain";

const github = await McpToolbox.connect(client, { name: "github" }); // any SDK Client fits, structurally

.node("agent", async (state, ctx) => {
const tools = [
...withMekikTools(ctx, serverTools, policy),
...withMcpTools(ctx, github, { github__create_issue: { approve: true } }), // destructive → ask first
];
return { reply: await runAgent(ctx, model(), { system: SYSTEM, input: state.input, tools }) };
})

Signature:

function withMcpTools(
ctx: Context<any>,
toolbox: McpToolboxLike, // @ilmek/mcp's McpToolbox, or anything with { name, tools(), invoke() }
policy?: ToolPolicyMap, // keyed by exposed tool name
options?: WithMekikToolsOptions,
): StructuredToolInterface[];

Each tool keeps the server's name, description and JSON Schema. The observation the model reads is the result's text (or its structuredContent, serialized, when there is no text); a result the server flagged isError comes back as Error from <tool>: … — an observation the loop can route around, not a crash. withMcpTools calls the toolbox's raw invoke, not its journaled call, because withMekikTools already journals every wrapped tool under lc:<name>.

.NET — two ways in​

With the official SDK. ModelContextProtocol's McpClientTool already is an AIFunction, so the client's tools go straight into MekikTools.Wrap:

var client = await McpClientFactory.CreateAsync(transport);
var tools = MekikTools.Wrap(ctx, await client.ListToolsAsync(), new()
{
["create_issue"] = new ToolPolicy { Approve = new ApproveSpec() },
});

With Ilmek.Mcp's toolbox (or any tool list plus an invoker), McpFunctions.Wrap builds the functions and wraps them the same way:

using Ilmek.Mcp;
using Mekik.Agents;

var github = await McpToolbox.ConnectAsync(new SdkClient(client), new() { Name = "github" });

var tools = MekikTools.Wrap(ctx, serverFunctions, policies)
.Concat(McpFunctions.Wrap(ctx,
github.Tools().Select(t => new RemoteToolInfo { Name = t.Name, Description = t.Description, InputSchema = t.InputSchema }),
async (name, args, ct) =>
{
var r = await github.InvokeAsync(name, args, ct);
return new RemoteToolResult { Text = r.Text, Structured = r.Structured, IsError = r.IsError };
},
new() { ["github__create_issue"] = new ToolPolicy { Approve = new ApproveSpec() } }))
.ToList();

Signature:

static IReadOnlyList<AIFunction> Wrap(
IContext ctx,
IEnumerable<RemoteToolInfo> tools, // { Name, Description?, InputSchema }
RemoteToolInvoker invoke, // (name, arguments, ct) => Task<RemoteToolResult { Text, Structured?, IsError }>
IReadOnlyDictionary<string, ToolPolicy>? policies = null,
ToolPolicy? defaultPolicy = null);

The observation rules are the TypeScript ones: text, or serialized structured content, or Error from <tool>: ….

What the human sees​

An MCP tool call renders exactly like a server tool call: a tool_call frame running → completed/error, upserted by a replay-stable id, so a chativa transcript shows github__search — completed and a resume pass re-traces the same entry instead of duplicating it. An approve policy pauses the graph with a Run github__create_issue? interrupt carrying the arguments; a decline is an observation (The user declined to run …), never an execution.

Graphs as data​

For a stored graph spec, @ilmek/mcp's mcp_tool and mcp_resource node types call a server by name without any model in the loop; the call is journaled under mcp:<server>:<tool> and — because it runs inside the node, not through an agent wrapper — surfaces no tool_call frame unless you wrap it in mekik.tool.