MCP from Scratch: Build a Local Server and Wire It into a LangChain Agent
MCP Local Invocation (stdio) Complete Analysis
1. Why MCP is Needed
Problems with Previous Tools
In tool.mjs and mini-cursor.mjs, tools were written directly inside the Agent code:
// Tool definition and Agent in the same file
const readFileTool = tool(async ({ filePath }) => {
const content = await fs.readFile(filePath, 'utf-8');
return content;
}, { name: 'read_file', ... });
const tools = [readFileTool];
const modelWithTools = model.bindTools(tools);
This brings three problems:
- Cannot be reused across projects: For a different project, the tool code has to be copied and rewritten.
- Cannot be cross-language: A tool written in Python cannot be called by a Node project.
- Tight coupling: The tool developer must understand the Agent code, and the Agent developer must understand the tool details.
What MCP Solves
MCP (Model Context Protocol) extracts Tools from the Agent and makes them independent processes, communicating via a standard protocol:
Before: After:
┌──────────────┐ ┌──────────┐ ┌──────────────┐
│ Agent │ │ Agent │ │ MCP Server │
│ + LLM │ │ + LLM │←───→│ + Tools │
│ + Tools │ └──────────┘ └──────────────┘
└──────────────┘ Decoupled! Reusable! Cross-language!
2. Two Files, Two Roles
There are two files under mcp-demo/src/, one providing the service, one consuming it:
langchain-mcp-test.mjs (Parent Process / Agent / MCP Client)
│
│ child_process.spawn('node', ['my-mcp-server.mjs'])
│ Communicate via stdin/stdout pipes
│
▼
my-mcp-server.mjs (Child Process / MCP Server)
│
│ Provides: query_user tool + docs://guide resource
Exactly the same relationship as an API Server and API Client—one is the backend service, the other is the frontend caller.
3. my-mcp-server.mjs — Writing an MCP Server
3.1 Create a Server Instance
const server = new McpServer({
name: 'my-mcp-server',
version: '1.0.0'
});
This is a fixed step. McpServer is a class provided by the SDK. Instantiate one to register tools and resources on it.
name: Choose a name yourself, identifies this Server. Used when configuring the Agent side.version: Version number, arbitrary.
3.2 Register a Tool
server.registerTool(
'query_user', // ① Tool name (the name the LLM sees)
{ // ② Description object
description: 'Query user information in the database. Input a user ID, return the user\'s detailed info (name, email, role)',
inputSchema: z.object({
userId: z.string().describe('User ID, e.g.: 001, 002, 003')
})
},
async ({ userId }) => { // ③ Handler function (parameters extracted by the LLM are passed in)
const user = database.users[userId];
if (!user) {
return {
content: [
{ type: 'text', text: `User ID ${userId} does not exist. Available IDs: 001, 002, 003` }
]
};
}
return {
content: [
{
type: 'text',
text: `User ${user.id} info: Name: ${user.name}, Email: ${user.email}, Role: ${user.role}`
}
]
};
}
);
Three parameters broken down:
| Parameter | What it is | Who uses it |
|---|---|---|
'query_user' |
Tool name | The LLM sees this name and decides whether to call it |
{ description, inputSchema } |
Description + param constraints | The LLM judges the purpose based on description, fills params based on schema |
async ({ userId }) => {...} |
The function that does the actual work | Code execution, userId is automatically extracted by the LLM from the user's words |
Return format is fixed:
return {
content: [
{ type: 'text', text: 'Text content returned to the LLM' }
]
};
content is an array, usually with only one item [0]. The protocol is designed as an array to support multiple content segments (e.g., returning text + image simultaneously), but in most cases, one item is enough.
3.3 Register a Resource
server.registerResource(
'User Guide', // ① Human-readable name
'docs://guide', // ② URI, the Agent reads via this address
{ // ③ Metadata
description: 'MCP Server User Guide',
mimeType: 'text/plain' // Tells the Agent this is plain text
},
async () => { // ④ Function returning the resource content
return {
contents: [
{
uri: 'docs://guide',
mimeType: 'text/plain',
text: `
MCP Server User Guide
Features: Provides tools like user query.
Usage: In MCP Clients like Cursor, converse in natural language, and Cursor will automatically call the corresponding tools.
`
}
]
};
}
);
Four parameters:
| Parameter | Meaning |
|---|---|
'User Guide' |
Display name |
'docs://guide' |
URI, choose yourself. docs:// is just a naming convention, not a real URL. app://help works too |
{ description, mimeType } |
Description + format specification |
async () => {...} |
Returns resource content, same format as tool returns |
The role of resources: Acts as a "manual" for the LLM—tells the LLM what capabilities this Server has and how to use them. After being read on the Agent side, it is injected as a SystemMessage, so the LLM can see it.
3.4 Start the Server
const transport = new StdioServerTransport();
await server.connect(transport);
Two fixed steps: Create transport channel → Bind and start.
StdioServerTransport is the transport method for stdio mode—the Agent communicates with it via stdin/stdout pipes. For remote MCP, replace this with StreamableHTTPServerTransport, the Server logic doesn't need to change.
4. langchain-mcp-test.mjs — Agent Side Invocation
4.1 Connect to MCP Server
const mcpClient = new MultiServerMCPClient({
mcpServers: {
'my-mcp-server': {
command: 'node',
args: ['C:\\Users\\rog\\Desktop\\work space\\xll_ai\\ai\\agent_in_action\\mcp-demo\\src\\my-mcp-server.mjs']
}
}
});
Fixed format:
{
'Server Name': {
command: 'What to launch with', // node / python / npx
args: ['Path or parameters'] // Arguments passed to command
}
}
Essentially child_process.spawn(command, args). LangChain spawns the child process and connects stdio under the hood. Same across languages:
{ command: 'node', args: ['./server.mjs'] } // Node
{ command: 'python', args: ['./server.py'] } // Python
{ command: 'npx', args: ['-y', '@amap/mcp-server'] } // npx, auto-download npm package
npx -y package_name: Auto-download + run, no need to npm install first. -y skips confirmation.
4.2 Get Tools and Resources
const tools = await mcpClient.getTools(); // Get tool list from all MCP Servers
const res = await mcpClient.listResources(); // Get resource list from all MCP Servers
getTools() and listResources() are fixed methods of MultiServerMCPClient, cannot be renamed.
Compared to the Tool phase:
// Before: Manually define, manually stuff into an array
const readFileTool = tool(...)
const tools = [readFileTool]
// Now: One line automatically gets all tools back
const tools = await mcpClient.getTools()
No matter how many MCP Servers are connected, getTools() merges all tools into one array for you.
4.3 Read Resource Content
let resourceContent = '';
for (const [serverName, resources] of Object.entries(res)) {
for (const resource of resources) {
const content = await mcpClient.readResource(serverName, resource.uri);
resourceContent += content[0].text;
}
}
Layer-by-layer breakdown:
The structure of res is an object:
res = {
'my-mcp-server': [
{ uri: 'docs://guide', name: 'User Guide', mimeType: 'text/plain' }
]
// If there are multiple Servers, there will be more keys
}
First layer loop: Object.entries(res) breaks the object into [serverName, resourceArray]
// Round 1:
serverName = 'my-mcp-server'
resources = [{ uri: 'docs://guide', name: 'User Guide', ... }]
Second layer loop: Iterate over each resource under the current Server
// Round 1:
resource = { uri: 'docs://guide', name: 'User Guide', mimeType: 'text/plain' }
Read resource:
const content = await mcpClient.readResource(serverName, resource.uri);
// ↑ ↑
// From which Server Read which resource
Two parameters: "whose house to go to, which thing to get."
Get the first text segment:
resourceContent += content[0].text;
// ↑ ↑ ↑
// Append First item Text content
readResource returns an array (same format as tool returns), resources usually have only one item, take [0].text.
Result after full iteration:
resourceContent = "MCP Server User Guide\nFeatures: Provides tools like user query.\nUsage: In Cursor..."
All text content from all resources concatenated together.
4.4 Inject as SystemMessage into LLM
const messages = [
new SystemMessage(resourceContent), // MCP resource content → Role knowledge
new HumanMessage(query) // User task
];
Resource content becomes a SystemMessage. The LLM sees it and knows: "Oh, I have a tool called query_user, used to look up user info, parameter is userId."
4.5 Bind Tools + ReAct Loop
const modelWithTools = model.bindTools(tools);
async function runAgentWithTools(query, maxIterations = 30) {
const messages = [
new SystemMessage(resourceContent),
new HumanMessage(query)
];
for (let i = 0; i < maxIterations; i++) {
console.log(chalk.bgGreen(`Waiting for AI thinking, round ${i}....`));
const response = await modelWithTools.invoke(messages);
messages.push(response);
// No tool_calls → LLM answered directly → End
if (!response.tool_calls || response.tool_calls.length === 0) {
console.log(`\n AI Final Reply: \n ${response.content}`);
return response.content;
}
// Has tool_calls → LLM wants to call tools
console.log(chalk.bgBlue(`Detected ${response.tool_calls.length} tool calls`));
console.log(chalk.bgBlue(`Tool calls: ${response.tool_calls.map(t => t.name).join(', ')}`));
for (const toolCall of response.tool_calls) {
// Find the matching one in the tool list by name
const foundTool = tools.find(t => t.name === toolCall.name);
if (foundTool) {
// Actually execute the tool
const toolResult = await foundTool.invoke(toolCall.args);
// Push result back into messages, must carry tool_call_id
messages.push(new ToolMessage({
content: toolResult,
tool_call_id: toolCall.id
}));
}
}
}
// Fallback: loop reached limit without ending, return last message content
return messages[messages.length - 1].content;
}
Complete execution flow:
Round 1 invoke:
messages = [SystemMessage(resource content), HumanMessage("Look up user 002")]
LLM sees resource → knows there is a query_user tool
LLM decides to look up user → returns tool_calls: [{ name: 'query_user', args: { userId: '002' }, id: 'call_001' }]
↓
Iterate tool_calls → find("query_user") → found → invoke({ userId: '002' })
↓
push ToolMessage({ content: "User 002: Guangguang...", tool_call_id: 'call_001' })
↓
Round 2 invoke:
messages = [SystemMessage, HumanMessage, AIMessage(tool_calls), ToolMessage(user info)]
LLM sees tool result → no more to call → returns content: "User 002 is Guangguang..."
↓
Detected no tool_calls → return "User 002 is Guangguang..."
4.6 Close Connection
await mcpClient.close();
Close all MCP connections, kill child processes, release resources. Must be called, otherwise child processes hang after the script finishes.
5. Core Concept Walkthrough
5.1 Variable Names vs Method Names
const mcpClient = new MultiServerMCPClient({...}) // mcpClient is chosen by you
await mcpClient.getTools() // getTools is fixed by the framework
await mcpClient.listResources() // listResources is fixed by the framework
await mcpClient.readResource(...) // readResource is fixed by the framework
await mcpClient.close() // close is fixed by the framework
Rule: Before the dot is your own name, after the dot is fixed by the framework.
5.2 Full Tool Call Decision Process
User says "Look up user 002"
→ invoke(messages)
→ LLM cloud decision: Need to call query_user, userId = "002"
→ Returns { tool_calls: [{ name: 'query_user', args: { userId: '002' }, id: 'call_001' }] }
→ find("query_user") finds a match in tools
→ tool.invoke({ userId: '002' }) executes
→ ToolMessage({ content: result, tool_call_id: 'call_001' })
→ invoke again, LLM sees result, replies to user
The decision is made by the LLM, the code only executes. On that invoke line, the LLM completes the reasoning in the cloud.
5.3 Why tool_call_id is Important
The LLM might call multiple tools at once:
tool_calls: [
{ name: 'read_file', args: {...}, id: 'call_001' },
{ name: 'write_file', args: {...}, id: 'call_002' },
]
Each ToolMessage must carry the corresponding id, so the LLM knows "this result corresponds to call_001 or call_002". Just like sending WeChat messages to three people at once, each reply must be marked which one it's for.
5.4 Resource Content Reading Chain
Server: registerResource('User Guide', 'docs://guide', ...)
→ Agent: listResources() gets [{ uri: 'docs://guide', name: 'User Guide' }]
→ Agent: readResource('my-mcp-server', 'docs://guide')
→ Server: Returns { contents: [{ text: 'MCP Server User Guide...' }] }
→ Agent: content[0].text extracts text → concatenates into resourceContent
→ new SystemMessage(resourceContent) → LLM sees
5.5 Two Layers of Judgment in the ReAct Loop
// Outer for loop: Controls max rounds
for (let i = 0; i < maxIterations; i++) {
// Inner judgment: Does this round need to call tools?
if (!response.tool_calls || response.tool_calls.length === 0) {
return response.content; // No → End
}
// Yes → Execute one by one
for (const toolCall of response.tool_calls) { ... }
}
// Reached maxIterations → Fallback return
return messages[messages.length - 1].content;
6. Core Value of MCP
| Tool Phase | MCP Server Phase | |
|---|---|---|
| Where tools live | Inside Agent code | Independent process, independent file |
| How to call | Function call | MCP standard protocol (stdio) |
| Reusable in other projects? | No, must rewrite | Yes, one line command+args config |
| Cross-language | No | Java/Python/Rust can all write MCP Server |
| Who maintains | Agent developer | Tool developer independently |
| Can have multiple? | Manually assemble array | MultiServerMCPClient auto-merge |
In one sentence: Let tools exist independently from the Agent, any Agent can connect and use them, regardless of language or location.
7. Review Checklist
- Can write the 4 fixed steps to create an MCP Server
- Can write the fixed format for the Agent side to connect to an MCP Server (
command + args) - Understand the purpose and return value structure of
getTools()/listResources()/readResource() - Can explain what the two-layer loop for reading resources is doing
- Understand why
tool_call_idmust be carried - Can draw the complete flowchart of the ReAct loop
- Can state the 4 advantages of MCP over raw Tools