跪拜 Guibai
← Back to the summary

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:

  1. Cannot be reused across projects: For a different project, the tool code has to be copied and rewritten.
  2. Cannot be cross-language: A tool written in Python cannot be called by a Node project.
  3. 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.

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