Connect an Agent to MCP

ជំពូកនេះមិនទាន់ត្រូវបានបកប្រែជាភាសាខ្មែរនៅឡើយទេ — សូមអានជាភាសាអង់គ្លេសខាងក្រោម។

Table of Contents

  1. The Client Side
  2. Reading McpClient
  3. From MCP Tools to Agent Tools
  4. The run-agent CLI
  5. Two Servers at Once
  6. Tool-Name Collisions
  7. Timeouts and a Hanging Server
  8. A Server That Crashes Mid-Run
  9. A Server That Fails to Start
  10. List Tools Every Run, or Cache?
  11. Descriptions Cost Tokens
  12. Observability
  13. What Real Clients Add
  14. Recipe: The Client Modules
  15. Try It Yourself
  16. Quick Review: Common Questions

1. The Client Side

In Build an MCP Server you wrote servers. Now you write the other half: the part that starts a server, talks to it, and hands its tools to the agent.

In MCP words, the program that wants the tools is the host (here: our agent). Inside the host lives one client per server. Each client keeps one connection. Our host is src/run-agent.js, and our client is the McpClient class.

The big idea of this chapter is small: the agent loop does not change at all. In The Agent Loop runAgent takes an array of tool objects, each with a name, a description, a schema and a run function. An MCP tool is the same object, except its run sends a message to another process instead of doing the work itself. Once the tools look the same, the loop cannot tell the difference.

You will need a mock running. Use any free port; this chapter uses 8910:

PORT=8910 node mock-llm.js
# output
# mock LLM listening on http://localhost:8910  (api key: test-key)

In the other terminal, point your code at it:

export LLM_BASE_URL=http://localhost:8910

2. Reading McpClient

The full file is in the recipe (section 14). Here is how it works, in the order the code runs.

McpClient.connect({ command, args, env, timeoutMs }) starts the server with child_process.spawn. The three stdio entries mean: stdin and stdout are pipes to us (the protocol), and stderr is inherited, so the server's log lines appear in our terminal. That is why you see MCP server ready (stdio) in the outputs below.

Then it does the handshake from chapter 6: send initialize with a protocol version and clientInfo, wait for the reply, and send the notifications/initialized notification. After that the connection is ready.

request(method, params) is the core. It takes the next id, stores { resolve, reject, timer } in a Map called pending, and writes one JSON line to the child's stdin. It returns a Promise. When a line arrives on stdout, the reader parses it, looks up the id in pending, and settles that Promise. This is how many requests can be in flight at once, and why order does not matter.

Three things protect you here:

  • A timeout per request (5 seconds unless you set timeoutMs). If the server never answers, the Promise rejects with timeout waiting for <method> and the entry is removed.
  • An exit handler. If the child process ends, every waiting request is rejected with server exited. Without this, a crash would leave Promises hanging forever.
  • Lenient parsing. A stdout line that is not JSON is ignored (the try/catch around JSON.parse). This forgives servers that print stray text.

notify writes a line with no id. listTools and callTool are thin wrappers: callTool joins all text items from the content array and returns { text, isError }. close ends stdin and kills the child.

3. From MCP Tools to Agent Tools

mcpTools(client, { prefix }) is the bridge. For each tool the server lists, it builds a normal defineTool object:

MCP tool fieldAgent tool field
nameprefix + name
descriptiondescription
inputSchemaparameters
(calling it)run, which sends tools/call

The run function handles the two failure kinds from the last chapter:

  • If the server returns a tool error (isError: true), run throws an Error with the message. runToolCall catches it and returns Error: <message> to the model, so the model can read it and recover. (We strip a leading Error: first so the prefix does not double.)
  • If the request itself fails (a protocol error, a timeout, a dead server), callTool throws and the same path applies. The model sees Error: <what happened>.

Notice the prefix: the name the model sees is prefix + name, but run still calls the server with the original t.name. The prefix exists only on our side. The server never learns about it.

4. The run-agent CLI

Now the host. src/run-agent.js is a command-line program: give it a task, and it connects to the servers named in mcp.config.json (the same shape hosts use; see the last chapter), runs the agent, prints a trace, and shuts everything down.

{
  "mcpServers": {
    "demo": { "command": "node", "args": ["src/demo-server.js"] },
    "text": { "command": "node", "args": ["src/text-server.js"] }
  }
}

Save that as mcp.config.json in your agentlab folder. The full CLI is in the recipe. The design choices are worth reading:

  1. One bad server does not stop the run. Each connect is in its own try/catch. A failure is reported on stderr and skipped, and the agent runs with the tools that are available.
  2. Every tool gets a server__ prefix (two underscores), taken from the config key. add from the demo server becomes demo__add.
  3. finally always closes the clients. A crash, a failed model call or a normal finish all end with close() on each client, so no child process is left running.
  4. Exit codes mean something. 0 for a finished task, 2 if the agent hit its step limit, 1 for a failure like a model that cannot be reached.
  5. It exits explicitly with process.exit. Section 9 shows why.

The mock model picks tools by word overlap, so tool names matter even more than usual: demo__add shares the word "add" with the task.

5. Two Servers at Once

Run a task that needs both servers. The task asks the demo server to add, then the text server to count words:

node src/run-agent.js 'add 2 and 3, then count the words in "one two three four"'
# output
# MCP server ready (stdio)
# [mcp] demo: connected, 5 tools
# MCP server ready (stdio)
# [mcp] text: connected, 2 tools
# step 1  call    demo__add {"a":2,"b":3}
# step 1  result  5
# step 2  call    text__word_count {"text":"one two three four"}
# step 2  result  4 words
# step 3  answer  Here is what I found:
# 1. 5
# 2. 4 words
# --- done in 3 steps, 166 tokens

The [mcp] and MCP server ready lines are on stderr. The step lines are on stdout. The token count is a rough estimate from the mock and can differ on your machine, but the calls and results are stable.

Remember the mock is rule-based: it splits the task at "then", picks the tool whose words overlap most with each step, and pulls arguments out with simple patterns. A real model reads the tool list and decides far better. What you are testing here is the wiring: two processes, seven tools, one loop.

Another run, with a note. Add 2>/dev/null to hide the stderr lines:

node src/run-agent.js 'get the weather in Siem Reap, then save a note called "trip" with "bring umbrella"' 2>/dev/null
# output
# step 1  call    demo__get_weather {"city":"Siem Reap"}
# step 1  result  Siem Reap: 31C, light rain
# step 2  call    demo__save_note {"title":"trip","content":"bring umbrella"}
# step 2  result  Saved note "trip".
# step 3  answer  Here is what I found:
# 1. Siem Reap: 31C, light rain
# 2. Saved note "trip".
# --- done in 3 steps, 216 tokens

One thing to know: the demo server keeps notes in memory. Every run starts a fresh server process, so a note saved in one run is gone in the next. For notes that last, a tool must write to a file or a database (chapter 5).

When no tool fits, the agent says so:

node src/run-agent.js 'make me a sandwich' 2>/dev/null
# output
# step 1  answer  I don't have a tool that can do that.
# --- done in 1 steps, 32 tokens

6. Tool-Name Collisions

What if two servers both offer a tool called save_note? Tool lookup in runToolCall is by name, and find returns the first match. The second tool becomes unreachable, and the model cannot tell. This script starts two copies of the demo server and merges their tools without a prefix:

import { McpClient, mcpTools } from "./src/mcp-client.js";
import { toApiTools } from "./src/tools.js";

const a = await McpClient.connect({ command: "node", args: ["src/demo-server.js"] });
const b = await McpClient.connect({ command: "node", args: ["src/demo-server.js"] });

// Without a prefix, both servers expose a tool called "save_note".
const merged = [...(await mcpTools(a)), ...(await mcpTools(b))];
console.log(merged.map((t) => t.name).join(", "));

// runToolCall looks tools up by name, so the first match always wins.
const winner = merged.find((t) => t.name === "save_note");
await winner.run({ title: "x", content: "y" });
console.log("server a notes:", (await a.callTool("list_notes")).text);
console.log("server b notes:", (await b.callTool("list_notes")).text);

// With a prefix every name is unique.
const safe = [...(await mcpTools(a, { prefix: "a__" })), ...(await mcpTools(b, { prefix: "b__" }))];
console.log(new Set(safe.map((t) => t.name)).size, "unique names of", safe.length);

// What do the descriptions cost? Roughly 4 characters per token.
const json = JSON.stringify(toApiTools(safe));
console.log(`tool definitions: ${json.length} characters, about ${Math.ceil(json.length / 4)} tokens per model call`);

a.close(); b.close();
node collide.js 2>/dev/null
# output
# get_weather, add, save_note, read_note, list_notes, get_weather, add, save_note, read_note, list_notes
# server a notes: x
# server b notes: (no notes yet)
# 10 unique names of 10
# tool definitions: 2059 characters, about 515 tokens per model call

Read the output. The merged list has every name twice. The save went to server a every time, and server b is never reached. This is a quiet bug: nothing crashed, and no error was shown.

With the prefix (a__save_note, b__save_note) every name is unique (10 unique names of 10), and the model can choose which server to use. Also note the last line, which section 11 uses.

This is not only a problem of your own servers. When you install servers from other people, two of them may well use the same common names like search or read_file. A prefix per server is cheap insurance. Some hosts apply a similar prefix for you. Check how yours behaves.

7. Timeouts and a Hanging Server

A server can be slow, or can hang forever on one call. Without a timeout your whole agent freezes with it. Here is a server with two bad tools (src/flaky-server.js). slow_lookup never answers. crash_now kills the process:

import { createMcpServer, serveStdio } from "./mcp-server.js";
import { defineTool } from "./tools.js";

const tools = [
  defineTool({
    name: "crash_now",
    description: "Crash the server on purpose",
    run: () => process.exit(1),
  }),
  defineTool({
    name: "slow_lookup",
    description: "Look up a record slowly (never answers)",
    run: () => new Promise(() => {}), // never settles: simulates a hung server
  }),
];
serveStdio(createMcpServer(tools, { name: "flaky-server" }));

Add it to a config with a short timeout. The timeoutMs field is our own extension, read by run-agent.js and not part of the usual host format:

{
  "mcpServers": {
    "text": { "command": "node", "args": ["src/text-server.js"] },
    "flaky": { "command": "node", "args": ["src/flaky-server.js"], "timeoutMs": 1000 }
  }
}

Save that as flaky.config.json, then ask for the hanging tool:

MCP_CONFIG=flaky.config.json node src/run-agent.js 'look up a record, then count the words in "a b c"' 2>/dev/null
# output
# step 1  call    flaky__slow_lookup {}
# step 1  result  (error) Error: timeout waiting for tools/call
# step 2  answer  I could not finish: Error: timeout waiting for tools/call
# --- done in 2 steps, 109 tokens

After one second the request timed out, the error went back to the model as text, and the loop ended instead of freezing. Two honest notes:

  • The mock gave up after the error ("I could not finish"). A real model might try a different tool or ask you something. What it does is not predictable.
  • A timeout does not stop the server's work. The server may still be stuck inside that call. Our client has no cancel message (real clients can send one; see section 13). If a hung server matters, the agent should close that client and move on, or restart it.

Pick timeouts by tool: a weather lookup should answer in seconds; a big report may need minutes. The default of 5 seconds in our code is a starting point, not a rule.

8. A Server That Crashes Mid-Run

The exit handler from section 2 is for this case. crash_now calls process.exit(1) while a request is waiting:

MCP_CONFIG=flaky.config.json node src/run-agent.js 'crash the server, then count the words in "a b c"' 2>/dev/null
# output
# step 1  call    flaky__crash_now {}
# step 1  result  (error) Error: server exited
# step 2  answer  I could not finish: Error: server exited
# --- done in 2 steps, 98 tokens

The waiting request was rejected at once with server exited, instead of waiting five seconds for a timeout. The model got a readable error. Compare with a client without the handler: the Promise would never settle, and the agent would wait forever.

What should you do next after a crash? Our CLI does nothing: later calls to that server will fail. A production client would restart the server and retry only calls that are safe to repeat (a read, not "send payment"). Retrying a write after a crash can do it twice, because you do not know whether the first one finished. This is the idempotency idea from Designing Good Tools.

9. A Server That Fails to Start

Two more ways a server can fail, both before the agent even begins.

It starts but never answers initialize. This is src/silent-server.js:

// Starts, but never answers "initialize".
process.stdin.resume();

Put it in a config with a short timeout next to the working text server:

{
  "mcpServers": {
    "text": { "command": "node", "args": ["src/text-server.js"] },
    "silent": { "command": "node", "args": ["src/silent-server.js"], "timeoutMs": 800 }
  }
}
MCP_CONFIG=bad.config.json node src/run-agent.js 'count the words in "a b c"'
# output
# MCP server ready (stdio)
# [mcp] text: connected, 2 tools
# [mcp] silent: FAILED (timeout waiting for initialize), skipping
# step 1  call    text__word_count {"text":"a b c"}
# step 1  result  3 words
# step 2  answer  Result: 3 words
# --- done in 2 steps, 82 tokens

The run still worked with the good server. But here is a gap in our small client: when initialize times out, connect throws and never kills the child process, and we do not hold a reference to it. The silent process stays alive and would keep our program from exiting. That is the reason run-agent.js ends with an explicit process.exit. When our process ends, the child's stdin closes, and this little server quits too. A server that ignores a closed stdin could be left behind, so a production client must kill the child itself on a failed start. Exercise 3 asks you to fix it.

The command does not exist. Try a config where command is a program that is not installed:

MCP_CONFIG=missing.config.json node src/run-agent.js 'count the words in "a b c"'
# output
# node:events:497
#       throw er; // Unhandled 'error' event
#       ^
#
# Error: spawn no-such-program ENOENT
#     ... (rest of the stack trace)

This one is not handled: spawn reports the problem as an error event on the child, and our client has no listener, so Node throws. This is the second gap in our learning client, and the fix is a few lines (exercise 3 again). We show it rather than hide it, because real config files often have a typo in command, and this is exactly the message you will meet. If you see ENOENT and spawn, the program named in command is not found on your PATH.

10. List Tools Every Run, or Cache?

Our CLI calls tools/list on every run. Is that wasteful? Measure it:

import { McpClient } from "./src/mcp-client.js";
const t0 = performance.now();
const c = await McpClient.connect({ command: "node", args: ["src/demo-server.js"] });
const t1 = performance.now();
await c.listTools();
const t2 = performance.now();
console.log(`start + initialize: ${Math.round(t1 - t0)} ms, tools/list: ${Math.round(t2 - t1)} ms`);
c.close();
node timing.js 2>/dev/null
# output
# start + initialize: 21 ms, tools/list: 0 ms

Your numbers will differ (they depend on your machine; these are typical). Starting the process dominates, and the listing itself is almost free. For local stdio servers, list on every run: it is simple and always correct.

Caching the tool list helps when listing is slow (a remote server) or you keep an agent alive for a long time. The risk is staleness: if the server adds, removes or renames a tool and you cache the old list, the model calls things that no longer exist. The protocol has a way for servers to announce changes (section 13), and a careful client re-lists when it hears one.

A middle path that is easy: list once when the agent session starts, and reuse the list for every step in that session. Do not list again for each model call.

11. Descriptions Cost Tokens

Back to the last line of the collision demo: tool definitions: 2059 characters, about 515 tokens per model call. Those are the ten tools of two demo servers, and the number is a rough estimate (4 characters per token is a rule of thumb, not exact).

Here is why it matters. Every request to the model carries the whole tools array. From Context Windows and Memory, you know that tokens are the unit of both cost and limit. A tool list is a fixed fee on every step of every run. Ten tools cost around five hundred tokens each call here, and a run with eight steps pays it eight times.

Servers you install from others can be far chattier: long descriptions, huge schemas, dozens of tools. Practical habits:

  • Keep descriptions to one or two clear sentences. (This is also what helps the model choose.)
  • Connect only the servers a task needs, not every server you own.
  • Filter the merged list. You can filter before calling runAgent: tools.filter(t => allowed.has(t.name)). Chapter 9 turns this into an allowlist helper, which is also a safety measure.
  • Many tools can confuse a model. This is common experience, not a law; test with your own model and tasks.

12. Observability

When an agent does something strange, you need to see what happened. The trace in run-agent.js prints each call, each result and the answer. That is the minimum. Extra habits for MCP:

  • Keep server stderr. We set stdio: ["pipe", "pipe", "inherit"] so server logs show up. If you redirect stderr to a file, you will have a record of what each server said.
  • Print which server handled a call. The prefix already shows it: text__word_count.
  • Time each call. A slow tool is easy to blame on the model. Wrap client.callTool with performance.now() and log the milliseconds.
  • Log errors as errors. The (error) marker in our trace comes from isError. Count them over time.
  • Do not log secrets. Tool arguments and results can contain private data. Decide what to keep before you start storing traces (chapter 10).

13. What Real Clients Add

We built the smallest useful client. Real ones add features we only name here. Check the current specification and your SDK for details:

  • Reconnect and restart of crashed servers, with backoff.
  • Authorization for remote servers, and an HTTP-based transport.
  • Notifications from the server, such as "the tool list changed", and a way to cancel a request in progress.
  • Version negotiation when client and server speak different protocol versions.
  • Resources, prompts and sampling, which we skipped.
  • User interface for approving tool calls, which chapter 9 builds a small version of.

None of this changes the idea you just built. A real host is this client, plus engineering for the bad days.

14. Recipe: The Client Modules

src/mcp-client.js

import { spawn } from "node:child_process";
import readline from "node:readline";
import { defineTool } from "./tools.js";

// A minimal MCP client that launches a server as a child process.
export class McpClient {
  constructor(child) {
    this.child = child;
    this.nextId = 1;
    this.pending = new Map(); // id -> { resolve, reject, timer }
    readline.createInterface({ input: child.stdout }).on("line", (line) => {
      let msg;
      try { msg = JSON.parse(line); } catch { return; } // ignore non-protocol output
      const waiter = this.pending.get(msg.id);
      if (!waiter) return;
      this.pending.delete(msg.id);
      clearTimeout(waiter.timer);
      if (msg.error) waiter.reject(new Error(`${msg.error.message} (code ${msg.error.code})`));
      else waiter.resolve(msg.result);
    });
    child.on("exit", () => {
      for (const w of this.pending.values()) w.reject(new Error("server exited"));
      this.pending.clear();
    });
  }

  static async connect({ command, args = [], env, timeoutMs = 5000 }) {
    const child = spawn(command, args, { stdio: ["pipe", "pipe", "inherit"], env: { ...process.env, ...env } });
    const client = new McpClient(child);
    client.timeoutMs = timeoutMs;
    client.serverInfo = (await client.request("initialize", {
      protocolVersion: "2024-11-05",
      capabilities: {},
      clientInfo: { name: "agentlab-client", version: "1.0.0" },
    })).serverInfo;
    client.notify("notifications/initialized");
    return client;
  }

  request(method, params = {}) {
    const id = this.nextId++;
    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pending.delete(id);
        reject(new Error(`timeout waiting for ${method}`));
      }, this.timeoutMs ?? 5000);
      this.pending.set(id, { resolve, reject, timer });
      this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
    });
  }

  notify(method, params) {
    this.child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, ...(params && { params }) }) + "\n");
  }

  async listTools() {
    return (await this.request("tools/list")).tools;
  }

  async callTool(name, args = {}) {
    const result = await this.request("tools/call", { name, arguments: args });
    const text = result.content.filter((c) => c.type === "text").map((c) => c.text).join("\n");
    return { text, isError: Boolean(result.isError) };
  }

  close() {
    this.child.stdin.end();
    this.child.kill();
  }
}

// Turn a server's tools into the same tool objects our agent already uses.
export async function mcpTools(client, { prefix = "" } = {}) {
  return (await client.listTools()).map((t) =>
    defineTool({
      name: prefix + t.name,
      description: t.description ?? "",
      parameters: t.inputSchema,
      run: async (args) => {
        const { text, isError } = await client.callTool(t.name, args);
        if (isError) throw new Error(text.replace(/^Error: /, ""));
        return text;
      },
    }),
  );
}

src/run-agent.js

import { readFile } from "node:fs/promises";
import { McpClient, mcpTools } from "./mcp-client.js";
import { runAgent } from "./agent.js";

const task = process.argv.slice(2).join(" ");
if (!task) {
  console.error('Usage: node src/run-agent.js "your task"');
  process.exit(1);
}

const configPath = process.env.MCP_CONFIG ?? "mcp.config.json";
const { mcpServers = {} } = JSON.parse(await readFile(configPath, "utf8"));

const clients = [];
const tools = [];
let exitCode = 0;
try {
  // Connect to every server. One bad server should not stop the others.
  for (const [name, spec] of Object.entries(mcpServers)) {
    try {
      const client = await McpClient.connect({ ...spec, timeoutMs: spec.timeoutMs ?? 5000 });
      clients.push(client);
      const found = await mcpTools(client, { prefix: `${name}__` });
      tools.push(...found);
      console.error(`[mcp] ${name}: connected, ${found.length} tools`);
    } catch (err) {
      console.error(`[mcp] ${name}: FAILED (${err.message}), skipping`);
    }
  }

  const result = await runAgent({
    task,
    tools,
    model: process.env.LLM_MODEL,
    onEvent: (e) => {
      if (e.type === "tool_call") console.log(`step ${e.step}  call    ${e.name} ${e.args}`);
      if (e.type === "tool_result") console.log(`step ${e.step}  result  ${e.isError ? "(error) " : ""}${e.content}`);
      if (e.type === "answer") console.log(`step ${e.step}  answer  ${e.text}`);
    },
  });
  console.log(`--- ${result.stopReason} in ${result.steps} steps, ${result.usage.total_tokens} tokens`);
  if (result.stopReason !== "done") exitCode = 2;
} catch (err) {
  console.error(`run failed: ${err.message}`);
  exitCode = 1;
} finally {
  for (const c of clients) c.close(); // always stop the child processes
}
// Exit explicitly: a server that failed to start may still hold the process open.
process.exit(exitCode);

mcp.config.json

{
  "mcpServers": {
    "demo": { "command": "node", "args": ["src/demo-server.js"] },
    "text": { "command": "node", "args": ["src/text-server.js"] }
  }
}

Usage:

node src/run-agent.js "your task here"
MCP_CONFIG=other.config.json node src/run-agent.js "task"   # another config
LLM_MODEL=mock-sloppy node src/run-agent.js "task"          # another model

15. Try It Yourself

  1. Add a third server entry for src/noisy-server.js from the last chapter. Does McpClient survive its stray DEBUG lines? Why?
  2. Make run-agent.js print how many milliseconds each tool call took. Which of your two servers is faster?
  3. Fix the two gaps from section 9. In McpClient.connect, add child.on("error", ...) so a missing command becomes a rejected Promise, and kill the child if initialize fails. Test with missing.config.json and bad.config.json.
  4. Add --only demo to the CLI so it connects to just one server. Compare the token counts with and without the second server.
  5. Add a per-call timeout override: if a config entry has "timeoutMs": 200, confirm that slow_lookup fails after about 200 ms.
  6. Write a test that starts text-server.js with McpClient, calls word_count, and asserts on the result. Use node --test.

16. Quick Review: Common Questions

Questions beginners often ask about this chapter. Try answering each one yourself first, then click the question to open the answer.

Does the model talk to the MCP server directly?

No. The model only produces a tool call as text. Your host program reads it, sends tools/call to the right server, and puts the answer back in the conversation. The model never touches the server or the network.

Why did you prefix the tool names?

To avoid collisions. Two servers can easily have a tool with the same name, and the loop finds tools by name. The prefix is added by our host and removed before the call, so the server never knows about it.

What if a server is slow or hangs?

Set a timeout, so one tool cannot freeze the whole agent. Return the failure to the model as an error, and decide whether to retry, switch tools or stop. A timeout does not cancel the work in the server, so consider restarting it.

Is it okay to connect lots of MCP servers?

Technically yes, but each tool adds tokens to every request and can make the model's choice harder. It also grows the set of programs that run with your permissions. Connect what the task needs.

Do I need MCP to build an agent?

No. Plain function calling, as in the earlier chapters, works without it. MCP helps when you want to reuse tool servers across applications, or use servers other people wrote, without writing glue code for each.

Does MCP make tool calls safe?

No. It standardises how tools are found and called. It does not decide whether a call is a good idea. The checks, approvals and sandboxes in chapter 9 are still your job.

Why does our client ignore lines that are not JSON?

To be forgiving of servers that print stray text on stdout. It is convenient for learning but it can also hide real mistakes. Other clients may be stricter, so fix such servers rather than depend on it.

Where to go next. Your agent can now use tools from other programs. That is power, so next comes the safety chapter: Safety, Permissions and Sandboxing shows how text in a tool result can hijack an agent, and how to lock it down.

ទិញកាហ្វេជូនខ្ញុំ

ចូលចិត្តមេរៀននេះមែនទេ? សូមពិចារណាទិញកាហ្វេជូនខ្ញុំមួយកែវ ដើម្បីគាំទ្រការបង្កើតមាតិកាឥតគិតថ្លៃបន្ថែមទៀត!