These days, agents are everywhere - in demos, product launches, research papers, landing pages of pretty much every tech company, and every other developer thread on the internet.
But "agent" has quickly become one of those overloaded words that can mean anything from a simple tool-calling loop to a fully autonomous coding system.
In this, we are going to ignore the hype and build one from first principles, piece by piece, without using any framework, so we can see what an agent actually is under the hood, and how it works.
Expected time needed: between 30 minutes to 1 hour.
Steps
- Step 1: Start with one plain chat completion
- Step 2: Add config and CLI input
- Step 3: Turn one request into a real loop
- Step 4: Add two tiny built-in tools first
- Step 5: Execute tool calls
- Step 6: Add budgets and stopping conditions
- Step 7: Add SQLite persistence
- Step 8: Add resume support
- Step 9: Add MCP, which is where this gets powerful
- Step 10: Why this becomes a coding agent
- Steps summary
- The finished implementation
- Example
config.jsonwith MCP servers - An important thing to keep in mind
- How to run it
- Live Demo Recording
- Final takeaway
By the end, we will have a concrete mental model for two things:
- how an agentic loop works in general
- how a coding agent can be built on top of that loop
What "agentic loop" actually means
A normal LLM app does this:
- send prompt
- get answer
- stop
An agentic loop does this instead:
- send prompt plus a list of available tools
- let the model decide whether it needs a tool
- execute the requested tool call in our own code
- give the tool result back to the model
- repeat until the model stops asking for tools
That repeat cycle is the loop. The model chooses, and our runtime executes the chosen tool(s).
What we are building
We are not building a toy chatbot that answers once and exits. We are building a loop that:
- keeps conversation state across turns
- supports tool calls, including multi-step tool chains
- records messages and usage metrics in SQLite
- can resume a previous session by ID
- can use both built-in tools and MCP tools side by side
That last point matters a lot. The built-in tools are just a teaching step - they make the mechanics easy to see because we can read the tool definition and its handler in the same file. The real power is MCP. Once the loop speaks MCP, the agent can discover and use capabilities from external tool servers declared in config.json, all without changing the core loop. This is one of the most powerful features since this is what gives the agentic loop all it's powers to do anything, including, but not limited to, the coding superpowers.
Step 1: Start with one plain chat completion
Before we talk about agents, let us start with the simplest thing that actually works.
Create a file called src/index.js. We are going to need a raw HTTP function because the whole project uses zero runtime dependencies at this stage (we will bring in exactly one package much later, when we actually need it).
'use strict';
const http = require('http');
const https = require('https');
function httpPost(url, headers, bodyObj) {
return new Promise((resolve, reject) => {
const payload = JSON.stringify(bodyObj);
const lib = url.startsWith('https') ? https : http;
const u = new URL(url);
const req = lib.request({
hostname: u.hostname,
port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: u.pathname + u.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
...headers,
},
}, (res) => {
const chunks = [];
res.on('data', (c) => chunks.push(c));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
if (res.statusCode < 200 || res.statusCode >= 300) {
return reject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 300)}`));
}
resolve(JSON.parse(raw));
});
});
req.on('error', reject);
req.write(payload);
req.end();
});
}
async function chatCompletions(baseURL, apiKey, body) {
return httpPost(
`${baseURL.replace(/\/$/, '')}/chat/completions`,
{ Authorization: `Bearer ${apiKey}` },
body
);
}
Nothing clever there. Raw Node.js HTTP, a JSON body, a Bearer token. Now add a main() to make it runnable:
async function main() {
const baseURL = process.env.BASE_URL || 'https://api.openai.com/v1';
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
throw new Error('Set OPENAI_API_KEY first.');
}
const resp = await chatCompletions(baseURL, apiKey, {
model: 'gpt-4o',
messages: [
{ role: 'user', content: 'What is an agentic loop?' }
],
});
console.log(resp.choices[0].message.content);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
Run it and you get a response. Valid LLM app. Not an agent.
Why not? No loop. No tool execution. No growing transcript. The conversation starts and ends in a single round-trip. That is the specific thing we are going to fix, step by step.
One thing worth noticing: the code uses the raw
http/httpsmodules instead offetchoraxios. You could absolutely usefetchhere (it is built into Node 18+). The raw approach was chosen because it makes the mechanics explicit and introduces zero dependencies. In a production codebase you would probably just reach forfetchor your HTTP client of choice.
Step 2: Add config and CLI input
Hardcoding credentials and model names is fine for ten minutes and annoying after that. Let us add:
config.jsonwith all tunables- CLI prompt input so we do not have to edit the file every time
- an optional system prompt
Add these imports at the top:
const fs = require('fs');
const path = require('path');
Then add loadConfig():
function loadConfig() {
const argv = process.argv.slice(2);
let configPath = path.resolve('./config.json');
const promptTokens = [];
for (let i = 0; i < argv.length; i++) {
if ((argv[i] === '--config' || argv[i] === '-c') && argv[i + 1]) {
configPath = path.resolve(argv[++i]);
} else {
promptTokens.push(argv[i]);
}
}
const file = fs.existsSync(configPath)
? JSON.parse(fs.readFileSync(configPath, 'utf8'))
: {};
const cfg = {
model: file.model || process.env.MODEL || 'gpt-4o',
baseURL: (file.baseURL || process.env.BASE_URL || 'https://api.openai.com/v1').replace(/\/$/, ''),
apiKey: file.apiKey || process.env.OPENAI_API_KEY || process.env.API_KEY || '',
maxAgenticLoops: Number(file.maxAgenticLoops ?? process.env.MAX_AGENTIC_LOOPS ?? 10),
maxTotalInputTokens: Number(file.maxTotalInputTokens ?? process.env.MAX_TOTAL_INPUT_TOKENS ?? 100000),
maxTotalOutputTokens: Number(file.maxTotalOutputTokens ?? process.env.MAX_TOTAL_OUTPUT_TOKENS ?? 20000),
sqliteDbPath: fileConfig.sqliteDbPath || process.env.SQLITE_DB_PATH || './agentic-loop.db',
systemPrompt: file.systemPrompt || process.env.SYSTEM_PROMPT || null,
mcpServers: file.mcpServers || file.mcpServer || {},
};
const userPrompt = promptTokens.join(' ').trim();
if (!cfg.apiKey) {
throw new Error('No API key. Set config.apiKey or OPENAI_API_KEY.');
}
if (!userPrompt) {
throw new Error('Usage: node src/index.js "your prompt"');
}
return { cfg, userPrompt };
}
Notice the config layering: config.json wins, then environment variables, then hardcoded defaults. This is a common pattern that keeps things flexible without being complicated.
Now update main() to use it:
async function main() {
const { cfg, userPrompt } = loadConfig();
const messages = [];
if (cfg.systemPrompt) {
messages.push({ role: 'system', content: cfg.systemPrompt });
}
messages.push({ role: 'user', content: userPrompt });
const resp = await chatCompletions(cfg.baseURL, cfg.apiKey, {
model: cfg.model,
messages,
});
console.log(resp.choices[0].message.content || '');
}
Create a config.json alongside the file:
{
"model": "gpt-4o",
"baseURL": "https://api.openai.com/v1",
"apiKey": "sk-...",
"maxAgenticLoops": 10,
"maxTotalInputTokens": 100000,
"maxTotalOutputTokens": 20000,
"systemPrompt": "You are a helpful coding assistant.",
"mcpServers": {}
}
Still not agentic. But now the app is configurable, usable from the command line, and ready to grow.
Step 3: Turn one request into a real loop
This is the first big shift.
Instead of one request and stop, we keep a messages array and send the whole thing back on every round. The model gets to see:
- the original user request
- its own previous replies
- tool requests it made
- the outputs of those tool requests
That growing transcript is the memory of the loop. Nothing is forgotten between rounds - it is all in the array.
Replace the one-shot logic in main() with this:
let finalText = null;
let loopCount = 0;
while (loopCount < cfg.maxAgenticLoops) {
loopCount++;
const resp = await chatCompletions(cfg.baseURL, cfg.apiKey, {
model: cfg.model,
messages,
});
const choice = resp.choices?.[0];
if (!choice) {
throw new Error('No choices in API response');
}
const msg = choice.message;
if (msg.content) {
finalText = msg.content;
}
messages.push({
role: 'assistant',
content: msg.content || null,
tool_calls: msg.tool_calls,
});
const toolCalls = msg.tool_calls || [];
if (toolCalls.length === 0) {
break;
}
// We will execute tool calls in the next step.
}
console.log(finalText || '[No text response generated]');
This is now an agentic loop skeleton. It loops. It maintains state. It stops when the model has nothing more to do. The only thing missing is the part where we actually execute the tool calls the model requests - that comes next.
At this point the loop has no tools to offer the model, so
tool_callswill always be empty and the loop will always exit after exactly one round. That is fine. The shape is right and we are about to fill in the interesting part.
Step 4: Add two tiny built-in tools first
Before connecting to external MCP servers, we will start with tools that live right here in the same file. That removes all network and process complexity so we can focus on the contract itself:
- the model sees a tool definition (name, description, parameters schema)
- the model returns a tool call with arguments
- our runtime executes it
- we append the result and loop back
Let us add two tools: get_current_time (no args, dead simple) and read_text_file (one arg, does real I/O).
function getInternalTools() {
return [
{
name: 'get_current_time',
description: 'Return the current server time in ISO 8601 format.',
parameters: {
type: 'object',
properties: {},
required: [],
},
handler: async () => new Date().toISOString(),
},
{
name: 'read_text_file',
description: 'Read a UTF-8 text file from local disk using an absolute or relative path.',
parameters: {
type: 'object',
properties: {
filePath: {
type: 'string',
description: 'Absolute or relative path to the text file to read',
},
},
required: ['filePath'],
},
handler: async (args) => {
const rawPath = String(args?.filePath || '').trim();
if (!rawPath) {
throw new Error('filePath is required');
}
const resolvedPath = path.resolve(rawPath);
if (!fs.existsSync(resolvedPath)) {
throw new Error(`File not found: ${resolvedPath}`);
}
const stat = fs.statSync(resolvedPath);
if (!stat.isFile()) {
throw new Error(`Not a file: ${resolvedPath}`);
}
return fs.readFileSync(resolvedPath, 'utf8');
},
},
];
}
Now convert them into the format the OpenAI API expects, and build a dispatch table:
function internalToOpenAITool(tool) {
return {
type: 'function',
function: {
name: tool.name,
description: tool.description || '',
parameters: tool.parameters || { type: 'object', properties: {}, required: [] },
},
};
}
function bootInternalTools() {
const toolMap = {};
const tools = [];
for (const tool of getInternalTools()) {
tools.push(internalToOpenAITool(tool));
toolMap[tool.name] = {
kind: 'internal',
description: tool.description || '',
execute: tool.handler,
};
}
return { toolMap, tools };
}
Two different things are happening here and both matter:
toolsis the list we hand to the model. It is pure schema - name, description, parameter shapes. No implementation.toolMapis our private dispatch table. It is keyed by tool name and holds the actual executor function.
The model never sees the executor. It only sees the schema. Your runtime keeps the implementation. That separation is not just good design - it is required. The model is running in a datacenter somewhere. It literally cannot call your function. It can only tell you what it wants called, and you run it on its behalf.
A natural extension here: you could add a
write_text_filetool, arun_shell_commandtool, alist_directorytool. With just those four you already have a capable coding assistant. The loop does not change - only the tool list grows.
Step 5: Execute tool calls
Now we wire those tools into the loop.
At the start of main(), boot the internal tools:
const internal = bootInternalTools();
const toolMap = { ...internal.toolMap };
const tools = [...internal.tools];
Include the tool list in every request body:
const resp = await chatCompletions(cfg.baseURL, cfg.apiKey, {
model: cfg.model,
messages,
...(tools.length ? { tools } : {}),
});
The spread is just a clean way to omit the tools field entirely when you have none. Some APIs are finicky about receiving an empty array.
Now the core of the loop - actually running what the model asked for:
const toolCalls = msg.tool_calls || [];
if (!toolCalls.length) {
break;
}
for (const tc of toolCalls) {
const fnName = tc.function?.name ?? '?';
let args = {};
try {
args = JSON.parse(tc.function?.arguments || '{}');
} catch {}
const binding = toolMap[fnName];
let resultText;
if (!binding) {
resultText = `Error: no handler found for tool "${fnName}"`;
} else {
try {
resultText = await binding.execute(args);
} catch (e) {
resultText = `Error: ${e.message}`;
}
}
messages.push({
role: 'tool',
tool_call_id: tc.id,
content: resultText,
name: fnName,
});
}
This is the heart of the loop. Everything else is supporting structure.
If you only understand one thing from this whole walkthrough, make it this pattern:
- the model asks for a tool by name with arguments
- your code looks up that name in
toolMap - your code runs the executor with the provided arguments
- your code appends a
role: "tool"message with the result and the matchingtool_call_id - the model sees that output on the next round and continues from there
Notice also that errors are handled gracefully: instead of crashing, we send the error message back as the tool result. The model can then decide what to do - retry with different arguments, explain the problem to the user, or try something else. Crashing the process is almost never the right call here.
Step 6: Add budgets and stopping conditions
Agent loops need guardrails. Without them, a loop can run for too many rounds, spend more tokens than you intended, or get caught in a cycle where the model keeps making tool calls that go nowhere.
Add a token budget tracker:
class TokenBudget {
constructor(maxIn, maxOut) {
this.maxIn = maxIn;
this.maxOut = maxOut;
this.totalIn = 0;
this.totalOut = 0;
this.totalCached = 0;
}
record(usage) {
if (!usage) {
return { prompt_tokens: 0, completion_tokens: 0, cached_tokens: 0 };
}
const pt = usage.prompt_tokens ?? usage.input_tokens ?? 0;
const ct = usage.completion_tokens ?? usage.output_tokens ?? 0;
const cached = usage.prompt_tokens_details?.cached_tokens ?? 0;
this.totalIn += pt;
this.totalOut += ct;
this.totalCached += cached;
return { prompt_tokens: pt, completion_tokens: ct, cached_tokens: cached };
}
check() {
if (this.totalIn > this.maxIn) {
throw new Error(`Input token budget exceeded: ${this.totalIn} > ${this.maxIn}`);
}
if (this.totalOut > this.maxOut) {
throw new Error(`Output token budget exceeded: ${this.totalOut} > ${this.maxOut}`);
}
}
}
Use it after each model response:
const budget = new TokenBudget(cfg.maxTotalInputTokens, cfg.maxTotalOutputTokens);
// inside the loop, after each response:
const metrics = budget.record(resp.usage);
budget.check();
Notice that the token accounting handles both OpenAI naming (prompt_tokens, completion_tokens) and Anthropic naming (input_tokens, output_tokens). Because the loop is built against any OpenAI-compatible API, this small normalization step makes it portable across providers.
Beyond the token budget, the loop should also stop when:
tool_callsis empty - the model has nothing more to doloopCount >= cfg.maxAgenticLoops- hard ceiling on iterationsfinish_reason === "length"- the model's response was truncated, continuing would be unreliable
These are simple controls but they genuinely matter in production. A loop without them is a loop you will eventually regret.
Step 7: Add SQLite persistence
The loop now works correctly, but every run is ephemeral. When the process exits, the conversation is gone. Let us fix that by recording everything to SQLite.
We want three tables:
sessions- one row per conversationmessages- every message in every session, in orderiterations- per-round token usage for cost tracking and debugging
This is where the only external dependency comes in. Install it now:
npm install better-sqlite3
Add the imports:
const { randomUUID } = require('crypto');
const Database = require('better-sqlite3');
Add table setup:
function initDB(db) {
db.exec(`
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
role TEXT,
content TEXT,
tool_calls TEXT,
tool_call_id TEXT,
name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(session_id) REFERENCES sessions(id)
);
CREATE TABLE IF NOT EXISTS iterations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT,
loop_index INTEGER,
prompt_tokens INTEGER,
completion_tokens INTEGER,
cached_tokens INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(session_id) REFERENCES sessions(id)
);
`);
}
Add a message save helper:
function saveMessage(db, sessionId, msg) {
const stmt = db.prepare(
'INSERT INTO messages (session_id, role, content, tool_calls, tool_call_id, name) VALUES (?, ?, ?, ?, ?, ?)'
);
stmt.run(
sessionId,
msg.role,
msg.content !== undefined && msg.content !== null
? (typeof msg.content === 'object' ? JSON.stringify(msg.content) : msg.content)
: null,
msg.tool_calls ? JSON.stringify(msg.tool_calls) : null,
msg.tool_call_id || null,
msg.name || null
);
}
Then in main():
- open
config.sqliteDbPath(default:agentic-loop.db) (SQLite creates the file automatically) - call
initDB(db)to ensure tables exist - generate a UUID session ID at the start of each new run
- call
saveMessage()for every system, user, assistant, and tool message
With that in place, we have real history now. We can open the database with any SQLite client and inspect exactly what happened in every run, including which tools were called with what arguments and what they returned.
Step 8: Add resume support
Resume, exactly what the word means, allows us to resume a past conversation. We can close the terminal, come back tomorrow, and pick up exactly where we left off.
All that work, all those tool calls, all that cost, need not be repeated again. We simply continue with the next task, building upon past conversations... exactly how you do it chatgpt or claude code or other tools, cli or web, both.
Update loadConfig() to parse a --resume flag:
let resumeSessionId = null;
for (let i = 0; i < argv.length; i++) {
if ((argv[i] === '--config' || argv[i] === '-c') && argv[i + 1]) {
configPath = path.resolve(argv[++i]);
} else if (argv[i] === '--resume' && argv[i + 1]) {
resumeSessionId = argv[++i];
} else {
promptTokens.push(argv[i]);
}
}
Return it alongside the rest:
return { cfg, userPrompt, resumeSessionId };
Then in main(), branch on whether we are resuming:
- if
resumeSessionIdis set, load all messages for that session from SQLite into themessagesarray - if a new prompt was also given on the command line, append it as a new
usermessage - if no new prompt was given, just re-enter the loop with the loaded history (useful if the last run was cut short)
- if
resumeSessionIdis not set, generate a fresh UUID and start clean
That is enough to continue previous work without replaying anything manually. The model picks up from the loaded transcript exactly as if the conversation never paused.
Step 9: Add MCP, which is where this gets powerful
Up to now the tools lived inside our own file. That was useful for learning, but the more powerful pattern is:
- keep the same agent loop exactly as it is
- keep the same tool call contract
- plug in external tool servers through MCP
Our loop does not need to know how any tool works internally. It just needs to:
- connect to MCP servers defined in
config.json - ask each server which tools it exposes
- present those tools to the model alongside the built-ins
- dispatch calls back to the right MCP client when the model requests them
This project supports both the standard transport types:
- stdio - the server runs as a child process, communicates over stdin/stdout using newline-delimited JSON-RPC. This is the most common type. Tools like the official
@modelcontextprotocol/server-filesystemwork this way. - SSE / Streamable HTTP - the server is an HTTP service. The client first tries the newer Streamable HTTP protocol (a single POST endpoint), and falls back to legacy SSE if that does not work. This is what remote or hosted MCP servers use.
The connection logic for both lives in src/mcp.js, inside StdioMcpClient and SseMcpClient. The bootMcpServers() function reads config.mcpServers, figures out which transport to use based on whether the entry has a command or a url, connects, and calls tools/list to discover what each server can do.
The conversion step: MCP tool schema to OpenAI tool schema
When an MCP server exposes a tool, we convert its schema into the format the model expects:
function mcpToOpenAITool(serverName, tool) {
return {
type: 'function',
function: {
name: `${serverName}__${tool.name}`,
description: tool.description || '',
parameters: tool.inputSchema || { type: 'object', properties: {}, required: [] },
},
};
}
The serverName__toolName naming convention is the key design choice. If we have a server called filesystem with a tool called read_file, it becomes filesystem__read_file. This namespacing is how the runtime knows which MCP client to dispatch to when the model calls the tool. Without it, two different servers could have tools with the same name and we would have no way to route correctly.
Merging built-in and MCP tools into one registry
The runtime keeps a single unified map:
const internal = bootInternalTools();
const mcp = await bootMcpServers(cfg.mcpServers);
const toolMap = { ...internal.toolMap, ...mcp.toolMap };
const tools = [...internal.tools, ...mcp.tools];
To the model, all tools look the same. To the runtime, each entry in toolMap has a kind field that says whether it is 'internal' or 'mcp', and for MCP tools it holds a reference to the client that can execute it. The loop dispatches through binding.execute(args) regardless - the routing is invisible at the call site.
This is the architectural payoff: built-in tools are the first teaching layer. MCP tools are the scalable layer. The loop itself does not care which one it is calling.
Step 10: Why this becomes a coding agent
At a high level, a coding agent is just an agentic loop with coding-relevant tools attached.
If the toolset includes things like:
- read file / write file
- search code (grep, semantic search)
- run tests or shell commands
- inspect logs
- call external development services (GitHub, CI systems, issue trackers)
then the same loop that would otherwise be a generic assistant starts behaving like a coding agent. It can read our code, modify files, run tests, inspect what broke, and iterate - all within a single session.
The loop is not the coding-specific part. The tool ecosystem is.
This is another reason MCP is such a strong idea here. It decouples the loop from the capabilities. The loop stays generic. The capabilities are pluggable by configuration.
Steps summary
By now the whole system should look like this in our head:
- load
config.jsonand parse CLI args - open SQLite, create tables if needed
- boot built-in tools, build
toolMapandtoolslist - connect MCP servers from config, discover their tools, merge into the same
toolMapandtoolslist - build initial
messagesarray (system prompt + user message, or load from DB if resuming) - enter the loop:
- call the model with
messagesandtools - record token usage against the budget
- append the assistant message to
messagesand save to DB - if no tool calls, break
- for each tool call: look up in
toolMap, execute, appendrole: "tool"message, save to DB - repeat
- call the model with
- close MCP clients, close DB
- print the final answer and the session ID for resuming
That is a real agentic loop. Not a framework. Not an abstraction. Just a well-structured while-loop with a dispatch table.
The finished implementation
The complete implementation now uses a small entrypoint plus a few focused modules: src/index.js, src/agentic-loop.js, src/mcp.js, src/http.js, src/config.js, src/db.js, src/tools.js, src/token-budget.js, and src/logger.js. It is the same design described above, but with added polish and clearer file boundaries.
Reading through src/agentic-loop.js after following these steps should feel familiar. Every major piece still maps directly to one of the steps above, with helpers moved into small supporting files.
Example config.json with MCP servers
Here is a more interesting config that connects two real tool servers:
{
"model": "google/gemini-3.1-flash-lite-preview",
"baseURL": "https://openrouter.ai/api/v1",
"apiKey": "<sk-or-v1- ... YOUR LLM PROVIDER KEY>",
"maxAgenticLoops": 20,
"maxTotalInputTokens": 500000,
"maxTotalOutputTokens": 200000,
"sqliteDbPath": "./agentic-loop.db",
"systemPrompt": "You are a helpful assistant. Use available tools when needed.",
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "./"]
},
"fetch": {
"command": "uvx",
"args": ["mcp-server-fetch"],
"env": {}
}
}
}
filesystemis a stdio server that gives the agent read and write access to./.fetchmcp server gives your agent the ability to make external http calls.
An important point: the loop does not change when you add more servers. Only configuration changes. This means the same agentic code can be extended to perform any number of functions by simply extending it with mcp servers.
An important thing to keep in mind
- All these tool definitions become part of input tokens, irrespective of how many of them are actually used, thus, adding to costs.
- General pattern to tackle this is by using a wrapper mcp-server, that exposes at least 2 tools, and then LLM follows a 2-step approach:
- First, LLM uses the exposed
searchtool to find needed tool to complete the task, - And then, LLM actually calls the needed tool, using a second exposed tool: the
calltool, to run that searched tool, while passing-in the required arguments.
- First, LLM uses the exposed
- For learning how such a wrapper MCP server might work, you can refer to another open-source project: one-mcp
How to run it
Start a new session:
node src/index.js "Read README.md and explain what this project does."
Resume a previous session:
Session id is printed at the end of the program. Keep it around if you want to continue the conversation later.
node src/index.js --resume <session_id> "Continue from where you left off and now, <YOUR CONTINUATION PROMPT>."
Use a different config file:
node src/index.js --config ./my-config.json "What tools are available to you?"
Live Demo Recording
Final takeaway
If you strip away all the noise, an agentic loop is not mysterious. It's just a cycle:
- send state
- let the model choose actions
- execute those actions outside the model
- append results
- repeat until no-tool-call response from LLM
The MCP integration is what turns the same loop into a genuinely capable system. Plug in a filesystem server and it can read and write files. Plug in a GitHub server and it can inspect issues and pull requests. Plug in a database server and it can query your schema. The loop does not change. The capabilities grow by configuration.
Similarly, you can write your own tools as well... either by using the mcp sdk and expose them through stdin/sse/http, or, just hardcode them into your agentic code. If you are just testing or playing around, prefer the hardcoding way in your agentic codebase itself for simplicity. But, for any production level setup, or once you are past the initial learning, prefer o go by the MCP route.