# Build an MCP Client: Dynamic GitHub Tool Registration

## Preface

The core value of the Model Context Protocol can be summarized in one sentence: MCP transforms the integration problem of “every Agent paired with every service” from multiplicative complexity (N × M) into additive complexity (N + M).

Agents require native tool capabilities and the ability to connect to external services. Typical external operations include querying GitHub issues, reading database records, controlling web browsers, and executing code. Before MCP emerged, connecting to any service required developers to manually write a full suite of tool functions. To integrate GitHub, teams needed to implement `list_issues`, `create_issue`, `get_repo` and other methods, alongside authentication, error handling, and serialization logic for each tool. This work had to be repeated separately for each Agent implementation. The result was bloated Agent code filled with integration logic unrelated to the core agentic workflow.

MCP, or Model Context Protocol, is an open standard released by Anthropic at the end of 2024. Its primary goal is to decouple tool providers and tool consumers. Service providers build an MCP server that exposes available tools following standard specifications. Agent developers build an MCP client to discover and invoke those exposed tools. This reduces integration overhead from “M clients multiplied by N services” down to “M clients plus N services”. This architectural shift is the key reason MCP is widely viewed as the standard for Agent tool integration.

## 1\. MCP Framework: Three Non-Negotiable Conventions

MCP is not a complex custom framework. It simply standardizes three core rules. Any client that complies with these three rules can communicate with nearly any MCP server.

1.  **Transport**: The client spawns a subprocess to launch the server. Both sides exchange messages over `stdin` / `stdout` in local deployments. Each message is a single line of JSON.
    
2.  **Protocol**: Message format follows JSON-RPC 2.0. Every request contains `method`, `params`, and `id`. Responses return the matching `id` value from the originating request.
    
3.  **Primitives**: Two core methods form the foundation: `tools/list` (query the server for available tools) and `tools/call` (execute a specified tool).
    

It is important to note that the MCP specification also defines `resources` and `prompts` primitives. Even so, most production Agent deployments only implement the `tools` branch of the protocol. This is a deliberate design choice: the standard prioritizes minimal viable functionality. It keeps complexity constrained to tool invocation rather than implementing an all-in-one protocol from the start. A fully functional client only needs to implement `initialize`, `tools/list`, and `tools/call` to operate.

## 2\. The Truth Behind “One-Line Registration”: Tools Are Discovered at Runtime, Not Hardcoded

The phrase “register a GitHub tool with one line” does **not** mean one literal line of code registers a static tool definition. Instead, it refers to writing a single registration workflow that dynamically discovers and registers all tools exposed by a target MCP server. Connecting a GitHub MCP server relies on only a small set of logic.

```plaintext
const proc = spawn('npx', ['-y', '@modelcontextprotocol/server-github'], {
  stdio: ['pipe', 'pipe', 'pipe'],
  shell: process.platform === 'win32',
  env: { ...process.env, GITHUB_TOKEN: process.env.GITHUB_TOKEN }
});

const tools = await listTools(proc)
```

The command `npx @modelcontextprotocol/server-github` starts the official GitHub MCP server. Registration works through dynamic discovery. The client exchanges JSON-RPC messages to enumerate available tools.

```plaintext
// Client to Server
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{...}}
// Server to Client
{"jsonrpc":"2.0","id":1,"result":{"serverInfo":{"name":"github"}}}
// Client notification
{"jsonrpc":"2.0","method":"notifications/initialized"}
// Client to Server
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
// Server returns tool list
{"jsonrpc":"2.0","id":2,"result":{"tools":[
  {"name":"create_issue","description":"...","inputSchema":{...}},
  {"name":"list_issues","description":"...","inputSchema":{...}},
  // dozens more tool definitions
]}}
```

This dynamic discovery mechanism marks the fundamental difference between MCP and handwritten tool implementations. The tool inventory is fetched during runtime instead of being statically defined at compile time. Developers do not need to declare GitHub’s available tools inside Agent source code. The server maintains knowledge of its own tools; calling `tools/list` retrieves the full catalog.

This drastically cuts the marginal cost for integrating new services: the work reduces to spawning a subprocess. If the server publisher adds new tools in an update, no code changes are required on the Agent side. The next `tools/list` call automatically picks up newly added tool functions.

## 3\. Three Common Pitfalls When Handwriting an MCP Client

The official specification is well documented, but implementing a fully correct MCP client requires handling three subtle, high-impact issues.

### Pitfall 1: Match responses by request ID; avoid sequential request handling

JSON-RPC responses arrive asynchronously and may arrive out of order, especially over HTTP transport. The client must maintain a table of pending requests, matching incoming responses back to requests using their `id` field.

```plaintext
const pending = new Map<number, { resolve; reject }>();
let requestId = 0;

function send(method, params) {
  return new Promise((resolve, reject) => {
    const id = ++requestId;
    const timer = setTimeout(() => reject(new Error(`timeout: ${method}`)), 15000);
    pending.set(id, {
      resolve: (v) => { clearTimeout(timer); resolve(v); },
      reject: (e) => { clearTimeout(timer); reject(e); }
    });
    proc.stdin.write(JSON.stringify({jsonrpc: '2.0', id, method, params}) + '\n')
  })
}

readline.createInterface({ input: proc.stdout }).on('line', (line) => {
  const msg = JSON.parse(line)
  const p = pending.get(msg.id)
  if (p) { pending.delete(msg.id); msg.error ? p.reject(msg.error) : p.resolve(msg.result) }
})
```

This pending map plus ID matching logic is the core of the client implementation. Some developers attempt simple synchronous “send one, wait for one” logic. This approach may appear functional over ordered `stdio` streams, but it fails immediately when switching to HTTP transport or running parallel tool invocations. Matching by `id` is standard practice for JSON-RPC and should not be omitted for convenience.

### Pitfall 2: Enable `shell: true` on Windows

```plaintext
shell: process.platform === 'win32'
```

Commands like `npx` and `pnpm` run as `.cmd` batch scripts on Windows. Without enabling the shell flag, the spawn API cannot directly execute `.cmd` files and throws an `ENOENT` error.

This flag comes with tradeoffs. It solves script execution issues, but untrusted user input passed into command arguments creates command injection risks. The safe usage precondition is strict whitelisting: only fixed command and argument values are permitted, such as a static `npx` call paired with a fixed server package name. Never concatenate user input into the command string. Skipping this security consideration can lead to critical vulnerabilities.

### Pitfall 3: Forward environment variables

Developers must forward environment variables including secrets such as `GITHUB_TOKEN` to the spawned subprocess using `{ ...process.env, yourEnv }`. If environment forwarding is omitted, the server process cannot access the token. The `tools/list` call may succeed, but every `tools/call` request returns a 401 or 403 error, and debugging this failure can consume significant time.

## 4\. Two Critical Tradeoffs: Transport Options and Accurate Timeout Handling

### Tradeoff 1: Stdio Subprocess versus Streamable HTTP

MCP supports two primary transport layers: local `stdio` subprocess transport and remote Streamable HTTP transport. Local Agent deployments commonly use `stdio` for practical reasons:

*   **Stdio Subprocess**: The server lifecycle is tied to the Agent process. No open ports, no network exposure, and minimal access control work. This is the most secure default for local execution.
    
*   **Streamable HTTP**: Enables remote deployment and sharing of a single server instance across multiple clients. It requires managing URLs, ports, and authentication, adding operational complexity.
    

The selection is determined by deployment environment. For single-machine local Agents, `stdio` is the default choice. If the server needs to be hosted centrally and shared across a team, Streamable HTTP is the better option. The JSON-RPC ID matching logic remains identical; only the transport layer is replaced.

### Tradeoff 2: Detect process exit, avoid false timeout errors

A common debugging trap occurs when the server subprocess crashes. The stdout stream stops emitting responses, and pending promises sit indefinitely in the pending map until the 15-second timeout triggers. The resulting error message reports a “request timeout”, even though the true root cause is a crashed process. This misleads engineers during troubleshooting.

The solution listens to the subprocess `exit` event. When the process terminates, the client rejects all pending requests and attaches the real error context, including the last 500 characters of `stderr` logs.

```plaintext
let stderrBuf = '';
proc.stderr.on('data', (d) => { stderrBuf += d.toString() });
proc.on('exit', (code) => {
  if (code === 0) return;
  const err = new Error(`server exited (code=${code}): ${stderrBuf.trim().slice(-500)}`)
  for (const p of pending.values()) p.reject(err)
  pending.clear()
})
```

Adding this small block of code returns precise failure context. Engineers can immediately diagnose whether the failure stems from missing tokens, version incompatibility, or permission issues. Timeout should be treated as a fallback, not the primary diagnostic signal. Always expose the true root cause rather than forcing developers to guess based on generic timeout messages.

## 5\. Building the Tool Registry: Prefixing for Namespace Safety and Lazy Loading to Save Tokens

The `tools/list` response can return dozens of tool definitions. These definitions cannot be directly injected wholesale into the Agent’s tool table. Two processing steps are mandatory during registration.

### 1\. Add prefixes to prevent name collision

Each tool name should be rewritten in the format `mcp_<server>_<tool>`.

```plaintext
for (const tool of tools) {
  registry.register({
    name: `${serverName}_${tool.name}`,
    description: `[MCP:${serverName}] ${tool.description}`,
    parameters: tool.inputSchema,
    executor: (input) => client.callTool(tool.name, input),
  })
}
```

Prefixing solves namespace conflicts. Multiple servers can expose tools with identical names. GitHub and GitLab both implement `create_issue`. Without prefixing, later registered tools overwrite previous entries, and the model cannot distinguish between them. The prefix acts as namespace isolation and provides clear provenance hints visible to the LLM, for example `mcp_github_list_issues`.

### 2\. Lazy load tool schemas to reduce token consumption

Each tool’s `inputSchema` is a JSON Schema object, which adds substantial token overhead if loaded all at once. When dozens of tools are available, embedding all schemas inside the prompt increases context usage dramatically.

The standard mitigation strategy is lazy loading. The prompt only stores brief summaries of available tools. The full JSON schema is fetched only when the model selects that specific tool for invocation. This follows the same design philosophy as external memory on-demand retrieval and context token estimation. It avoids bloating the prompt with unused definitions; lazy loading becomes mandatory once the tool catalog grows large.

## 6\. MCP as an Enhancement, Not a Hard Requirement

One final architectural consideration: wrap MCP connection logic inside `try/catch` blocks. If MCP connection fails, log a warning and continue execution, rather than crashing the entire Agent process.

```plaintext
try {
  await connectMCP()
} catch (err) {
  console.log("⚠️ MCP connection failed, skipped:", err.message)
}
```

MCP servers depend on external prerequisites: `npx` installation, valid `GITHUB_TOKEN`, and network reachability. If any dependency fails, the Agent should not halt completely. It retains core local capabilities including file access, RAG workflows, and shell operations.

The design principle is graceful degradation. External capabilities can fail independently, and the Agent continues running with remaining available functions. MCP extends the Agent boundary, but it is not a mandatory foundation. The Agent itself retains judgment over which tools to invoke. This separation is a critical engineering dividing line for production-grade Agent systems.

For production deployments running multiple LLM endpoints and tool services, teams can streamline authentication and routing with an API gateway. 4sapi serves this role to manage unified traffic for model and tool API calls.

## Conclusion

The full MCP client workflow can be summarized concisely: Spawn a standard MCP server subprocess, send and receive JSON-RPC messages with ID matching, call `tools/list` to discover tools, register tools with namespace prefixes, implement lazy loading, and enable graceful failure handling.

The greatest value of MCP lies not in code reduction, but in reworking the integration math. Once a single MCP client is implemented, it can discover all available MCP servers worldwide. When a developer publishes one new MCP server, every compatible Agent can immediately use its tools. Tool definitions shift from static hardcoded declarations to dynamically discoverable runtime assets. This standardization drastically cuts the long tail of Agent-service integration work.

International access: [https://4sapi.com](https://4sapi.com)

Domestic access: [https://4sapi.cn](https://4sapi.cn)
