Why This Chapter Matters
In the world of intelligent tools, providing the right information at the right time is paramount. Imagine a sophisticated AI agent trying to help with a software project; without understanding the project’s structure, dependencies, or recent changes, its advice would be generic and often useless. The Model Context Protocol (MCP) addresses this by enabling systems to exchange dynamic, structured context.
This chapter is your hands-on entry point. You’ll move from theoretical understanding to practical implementation, building an MCP client that can gather and deliver meaningful context. Mastering client development is crucial because it’s the layer responsible for observing the world and feeding that information into the MCP ecosystem, making intelligent tools truly intelligent and context-aware.
Learning Objectives
By the end of this chapter, you will be able to:
- Initialize and configure an MCP client using the official TypeScript SDK.
- Understand the core components for defining and sending structured context.
- Create and populate
ContextRecordinstances with relevant data. - Implement basic error handling for MCP client operations.
- Send context to an MCP server and interpret its responses.
- Identify best practices for structuring context data for clarity and utility.
The Role of an MCP Client
An MCP client is any application or service that interacts with the Model Context Protocol. Its primary function is to:
- Gather Context: Observe its environment, collect relevant data, and structure it according to defined schemas. This could range from system metrics, user actions, code changes, or document states.
- Send Context: Transmit this structured data (as
ContextRecords) to an MCP server, making it available to other intelligent tools or services that consume context. - Receive Context (Optional): While often a server-side concern, some clients might also subscribe to or request context from an MCP server to inform their own operations. This chapter focuses primarily on sending.
Think of an MCP client as a sensor or an observer. It translates the raw state of its local environment into a universally understandable, structured format that can be processed and acted upon by intelligent systems.
๐ Key Idea: MCP clients are the data producers, transforming raw operational data into structured, actionable context for intelligent tools.
Why Structured Context Matters
Without a defined structure, context data is just a blob of information. It’s hard to parse, validate, and use reliably. MCP emphasizes schemas to ensure that context is:
- Predictable: Consumers know what fields to expect.
- Validatable: Data conforms to expected types and formats.
- Interoperable: Different systems can understand and exchange the same context.
- Evolvable: Schemas can be versioned and extended without breaking existing consumers.
Setting Up Your Development Environment
To follow along, ensure you have Node.js (v18+) and npm/yarn installed.
First, create a new TypeScript project:
mkdir my-mcp-client
cd my-mcp-client
npm init -y
npm install typescript @types/node ts-node @modelcontextprotocol/typescript-sdk
npx tsc --init
Update your tsconfig.json to include:
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules"]
}
Create a src directory: mkdir src.
Initializing the MCP Client with TypeScript SDK
The core class for interacting with the MCP is McpClient from the @modelcontextprotocol/typescript-sdk.
McpClient Configuration
When instantiating McpClient, you’ll typically provide configuration details like the server endpoint and any necessary authentication tokens.
// src/client.ts
import { McpClient, McpClientConfig } from '@modelcontextprotocol/typescript-sdk';
/**
* Initializes and returns an MCP client instance.
* @param serverUrl The URL of the MCP server.
* @param authToken Optional authentication token for the server.
* @returns A configured McpClient instance.
*/
function initializeMcpClient(serverUrl: string, authToken?: string): McpClient {
const config: McpClientConfig = {
serverUrl: serverUrl,
// Optional: Add authentication headers if required by your MCP server
// headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {},
};
try {
const client = new McpClient(config);
console.log(`MCP Client initialized for server: ${serverUrl}`);
return client;
} catch (error) {
console.error('Failed to initialize MCP client:', error);
throw error; // Re-throw to indicate a critical setup failure
}
}
// Example usage (assuming a local MCP server for now)
const MCP_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000'; // Default for local testing
// You would obtain a real token from an auth service in a production environment
const AUTH_TOKEN = process.env.MCP_AUTH_TOKEN;
// Don't export initializeMcpClient directly if it's meant for internal use
// For this example, we'll keep it simple.
export const mcpClient = initializeMcpClient(MCP_SERVER_URL, AUTH_TOKEN);
โก Quick Note: For development, http://localhost:3000 is a common default. In production, this would be a secure https:// endpoint.
Defining and Creating Context Records
The fundamental unit of context in MCP is the ContextRecord. It encapsulates structured data, identifies its type via a schema, and provides metadata.
Core ContextRecord Properties
schemaUri: A URI identifying the schema that this context record conforms to. This is crucial for consumers to understand the data structure.data: The actual context data, conforming to theschemaUri. This is a plain JavaScript object.metadata(optional): Additional metadata about the context record itself, such as timestamps, source information, or retention policies.id(optional): A unique identifier for this specific context record. If not provided, the SDK or server might generate one.
Example: A “Project File List” Context
Let’s imagine our client monitors a local project and wants to send a list of its current files as context.
First, we need a schema. For simplicity in this chapter, we’ll use a URI that implies a schema, but in a real system, this URI would point to a discoverable JSON Schema or similar definition.
// src/context-schemas.ts
export const PROJECT_FILE_LIST_SCHEMA_URI = 'mcp://schemas/project-file-list/1.0.0';
/**
* Defines the structure for our project file list context.
* In a real scenario, this would be validated against a JSON Schema.
*/
export interface ProjectFileListContextData {
projectId: string;
rootPath: string;
files: Array<{
path: string;
size: number;
lastModified: string; // ISO 8601 string
}>;
timestamp: string; // When this context was generated
}
Now, let’s create a function to generate a ContextRecord for this:
// src/context-builder.ts
import { ContextRecord } from '@modelcontextprotocol/typescript-sdk';
import { PROJECT_FILE_LIST_SCHEMA_URI, ProjectFileListContextData } from './context-schemas';
import * as fs from 'fs';
import * as path from 'path';
/**
* Gathers project file information and creates an MCP ContextRecord.
* @param projectId The ID of the project.
* @param projectRoot The root directory of the project.
* @returns A ContextRecord containing the project file list.
*/
async function buildProjectFileListContext(projectId: string, projectRoot: string): Promise<ContextRecord<ProjectFileListContextData>> {
const files: Array<{ path: string; size: number; lastModified: string }> = [];
// Simple recursive file listing (for demonstration)
async function walkDir(currentPath: string) {
const entries = await fs.promises.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
if (entry.isDirectory()) {
await walkDir(fullPath);
} else if (entry.isFile()) {
const stats = await fs.promises.stat(fullPath);
files.push({
path: path.relative(projectRoot, fullPath), // Relative path
size: stats.size,
lastModified: stats.mtime.toISOString(),
});
}
}
}
try {
await walkDir(projectRoot);
} catch (error) {
console.error(`Error walking directory ${projectRoot}:`, error);
// Depending on the use case, you might want to send a partial context or throw
throw new Error(`Failed to build project file list: ${error}`);
}
const contextData: ProjectFileListContextData = {
projectId,
rootPath: projectRoot,
files,
timestamp: new Date().toISOString(),
};
return {
schemaUri: PROJECT_FILE_LIST_SCHEMA_URI,
data: contextData,
metadata: {
source: 'my-mcp-file-monitor-client',
// ttl: 'PT1H' // Example: Time-to-live of 1 hour for this context record
}
};
}
export { buildProjectFileListContext };
โก Real-world insight: In production, file monitoring would likely use file system watchers (e.g., fs.watch in Node.js) to detect changes incrementally, rather than full scans, for efficiency.
Sending Context to the MCP Server
Once you have a ContextRecord, sending it is a single asynchronous call to the McpClient.
// src/sender.ts
import { McpClient, ContextRecord, ContextResponse } from '@modelcontextprotocol/typescript-sdk';
import { mcpClient } from './client';
import { buildProjectFileListContext } from './context-builder';
/**
* Sends a ContextRecord to the MCP server.
* @param client The initialized McpClient instance.
* @param contextRecord The ContextRecord to send.
* @returns The ContextResponse from the server.
*/
async function sendContext(client: McpClient, contextRecord: ContextRecord): Promise<ContextResponse> {
try {
console.log(`Attempting to send context with schema: ${contextRecord.schemaUri}`);
const response: ContextResponse = await client.sendContext(contextRecord);
console.log('Context sent successfully. Server response:', response);
return response;
} catch (error) {
console.error('Failed to send context:', error);
// Depending on the error, you might want to retry, log, or alert.
throw error;
}
}
// Example usage in an async IIFE for demonstration
(async () => {
try {
const myProjectRoot = path.resolve(__dirname, '..'); // Root of this sample project
const projectContext = await buildProjectFileListContext('sample-mcp-client-project', myProjectRoot);
await sendContext(mcpClient, projectContext);
} catch (error) {
console.error('Overall context sending process failed:', error);
}
})();
โ ๏ธ What can go wrong:
- Server Unreachable: Network errors, incorrect
serverUrl. - Authentication Failure: Invalid or missing
AUTH_TOKEN. - Schema Mismatch: The
datain yourContextRecorddoes not conform to theschemaUriexpected by the server. The server might reject the record. - Rate Limiting: If the client sends too much context too quickly, the server might impose limits.
- Payload Too Large: Very large
ContextRecords might exceed server limits.
Understanding ContextResponse
When you send context, the server responds with a ContextResponse. This typically includes:
id: The unique ID assigned to the context record by the server.status: Indicates whether the context was successfully received and processed (e.g.,ACCEPTED,REJECTED,ERROR).message(optional): A human-readable message, especially useful for error or warning statuses.timestamp: When the server processed the context.
You should always inspect the status of the ContextResponse to ensure your context was accepted. A 200 OK HTTP status only means the request was received; the ContextResponse.status tells you about the context itself.
Worked Example: A Simple Build Status Reporter
Let’s create a client that reports the status of a hypothetical build process.
First, define the schema and interface:
// src/schemas/build-status.ts
export const BUILD_STATUS_SCHEMA_URI = 'mcp://schemas/build-status/1.0.0';
export interface BuildStatusContextData {
buildId: string;
projectId: string;
status: 'pending' | 'running' | 'success' | 'failed';
startTime: string; // ISO 8601
endTime?: string; // ISO 8601, if applicable
durationSeconds?: number;
logUrl?: string;
commitHash?: string;
branch?: string;
}
Next, the client code:
// src/build-reporter.ts
import { McpClient, ContextRecord } from '@modelcontextprotocol/typescript-sdk';
import { BUILD_STATUS_SCHEMA_URI, BuildStatusContextData } from './schemas/build-status';
import { mcpClient } from './client'; // Our initialized client
class BuildReporterClient {
private client: McpClient;
private projectId: string;
constructor(client: McpClient, projectId: string) {
this.client = client;
this.projectId = projectId;
}
/**
* Sends an initial 'pending' build status.
*/
async reportBuildStart(buildId: string, commitHash: string, branch: string): Promise<void> {
const contextData: BuildStatusContextData = {
buildId,
projectId: this.projectId,
status: 'pending',
startTime: new Date().toISOString(),
commitHash,
branch,
};
const record: ContextRecord<BuildStatusContextData> = {
schemaUri: BUILD_STATUS_SCHEMA_URI,
data: contextData,
metadata: { source: 'mcp-build-reporter' }
};
await this.client.sendContext(record);
console.log(`Build ${buildId} started (pending).`);
}
/**
* Updates a build status to 'running'.
*/
async reportBuildRunning(buildId: string): Promise<void> {
const contextData: BuildStatusContextData = {
buildId,
projectId: this.projectId,
status: 'running',
startTime: new Date().toISOString(), // Assuming start time is updated or already known
};
const record: ContextRecord<BuildStatusContextData> = {
schemaUri: BUILD_STATUS_SCHEMA_URI,
data: contextData,
metadata: { source: 'mcp-build-reporter' }
};
await this.client.sendContext(record);
console.log(`Build ${buildId} is now running.`);
}
/**
* Reports a final build status (success or failed).
*/
async reportBuildEnd(buildId: string, status: 'success' | 'failed', startTime: string, logUrl?: string): Promise<void> {
const endTime = new Date();
const durationSeconds = (endTime.getTime() - new Date(startTime).getTime()) / 1000;
const contextData: BuildStatusContextData = {
buildId,
projectId: this.projectId,
status,
startTime,
endTime: endTime.toISOString(),
durationSeconds,
logUrl,
};
const record: ContextRecord<BuildStatusContextData> = {
schemaUri: BUILD_STATUS_SCHEMA_URI,
data: contextData,
metadata: { source: 'mcp-build-reporter' }
};
await this.client.sendContext(record);
console.log(`Build ${buildId} finished with status: ${status}.`);
}
}
// Simulate a build process
(async () => {
const reporter = new BuildReporterClient(mcpClient, 'my-awesome-app');
const BUILD_ID = `build-${Date.now()}`;
const COMMIT_HASH = 'a1b2c3d4e5f6';
const BRANCH = 'main';
try {
await reporter.reportBuildStart(BUILD_ID, COMMIT_HASH, BRANCH);
await new Promise(resolve => setTimeout(resolve, 2000)); // Simulate build running
await reporter.reportBuildRunning(BUILD_ID);
await new Promise(resolve => setTimeout(resolve, 3000)); // Simulate more build time
const randomSuccess = Math.random() > 0.5;
if (randomSuccess) {
await reporter.reportBuildEnd(BUILD_ID, 'success', new Date().toISOString(), `http://logs.example.com/${BUILD_ID}`);
} else {
await reporter.reportBuildEnd(BUILD_ID, 'failed', new Date().toISOString(), `http://logs.example.com/${BUILD_ID}`);
}
} catch (error) {
console.error('Error in build reporting simulation:', error);
}
})();
This example demonstrates how a client can send multiple context records over time to update the state of an ongoing process.
Code Lab: Enhancing the Project File List Client
Let’s refine our project-file-list client to handle different types of files and ignore certain directories. This is a common requirement in real-world scenarios.
Task: Implement a .mcpignore file
Modify the buildProjectFileListContext function to:
- Read an optional
.mcpignorefile in the project root. - The
.mcpignorefile will contain patterns (one per line) for files or directories to exclude (similar to.gitignore). - Filter the collected files based on these patterns.
Setup
Create a file named .mcpignore in your project’s root directory (next to package.json).
Example .mcpignore:
node_modules/
dist/
*.log
.env
Guidance
- You’ll need a simple pattern matching logic. For simplicity, you can use
String.prototype.includes()or regular expressions. For true.gitignorelike behavior, consider a library likeignore. - The
walkDirfunction will need to be updated to check against the ignore patterns before adding files or recursing into directories.
Solution Sketch (do not copy-paste, try it yourself first!)
// src/context-builder.ts (updated)
// ... (imports remain the same)
import * as minimatch from 'minimatch'; // npm install minimatch @types/minimatch
// ... (PROJECT_FILE_LIST_SCHEMA_URI and ProjectFileListContextData remain the same)
async function buildProjectFileListContext(projectId: string, projectRoot: string): Promise<ContextRecord<ProjectFileListContextData>> {
const files: Array<{ path: string; size: number; lastModified: string }> = [];
const ignorePatterns: string[] = [];
const ignoreFilePath = path.join(projectRoot, '.mcpignore');
try {
const ignoreContent = await fs.promises.readFile(ignoreFilePath, 'utf-8');
ignorePatterns.push(...ignoreContent.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('#')));
} catch (error) {
if (error.code !== 'ENOENT') { // ENOENT means file not found, which is fine
console.warn(`Could not read .mcpignore file: ${error}`);
}
}
async function walkDir(currentPath: string) {
const entries = await fs.promises.readdir(currentPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentPath, entry.name);
const relativePath = path.relative(projectRoot, fullPath);
// Check if current path (file or directory) should be ignored
const shouldIgnore = ignorePatterns.some(pattern => minimatch(relativePath, pattern, { dot: true }));
if (shouldIgnore) {
// console.log(`Ignoring: ${relativePath}`);
continue;
}
if (entry.isDirectory()) {
await walkDir(fullPath);
} else if (entry.isFile()) {
const stats = await fs.promises.stat(fullPath);
files.push({
path: relativePath,
size: stats.size,
lastModified: stats.mtime.toISOString(),
});
}
}
}
try {
await walkDir(projectRoot);
} catch (error) {
console.error(`Error walking directory ${projectRoot}:`, error);
throw new Error(`Failed to build project file list: ${error}`);
}
const contextData: ProjectFileListContextData = {
projectId,
rootPath: projectRoot,
files,
timestamp: new Date().toISOString(),
};
return {
schemaUri: PROJECT_FILE_LIST_SCHEMA_URI,
data: contextData,
metadata: {
source: 'my-mcp-file-monitor-client',
}
};
}
export { buildProjectFileListContext };
Note: You would need to npm install minimatch @types/minimatch for the minimatch library.
Checkpoint
Consider the following scenario: You are building an MCP client for a code editor that sends the currently open file and cursor position as context.
- Schema Design: What fields would you include in the
ContextDatafor “Open File Context” and “Cursor Position Context”? - Client Logic: How would you ensure that the client only sends updates when the file or cursor position actually changes, to avoid unnecessary network traffic?
MCQs
What is the primary purpose of the
schemaUrifield in aContextRecord? a) To specify the unique ID of the context record. b) To define the network endpoint for sending the context. c) To identify the structure and type of thedatapayload. d) To indicate the client that generated the context.Answer: c) To identify the structure and type of the
datapayload. Explanation: TheschemaUriis critical for consumers to correctly interpret and validate thedatacontained within theContextRecord. It acts as a contract for the data’s shape.Which of the following is NOT a common reason for an MCP server to reject a
ContextRecord? a) Thedatapayload does not conform to the specifiedschemaUri. b) The client’sserverUrlis incorrectly configured. c) The client is not authorized to send context to the server. d) TheContextRecordpayload size exceeds server limits.Answer: b) The client’s
serverUrlis incorrectly configured. Explanation: If theserverUrlis incorrect, the client would likely fail to connect to the server at all, resulting in a network error on the client side before theContextRecordeven reaches the server for rejection. Options a, c, and d are all valid reasons for a server to explicitly reject a received context.When should you inspect the
statusfield within theContextResponseobject? a) Only when the HTTP response status code is4xxor5xx. b) Always, regardless of the HTTP response status code. c) Only during development, not in production. d) It is an optional field and rarely needs inspection.Answer: b) Always, regardless of the HTTP response status code. Explanation: A successful HTTP status code (e.g.,
200 OK) only indicates that the request reached the server. TheContextResponse.statusprovides crucial information about whether the context itself was accepted and processed by the MCP server, or if there were issues like schema validation failures, even if the HTTP request was technically successful.
Challenge
Challenge: Implement a “User Activity” Context Client
Design and implement an MCP client that simulates reporting user activity within a web application.
Requirements:
- Define a Schema: Create a new schema URI and TypeScript interface for
UserActivityContextData. This schema should include:userId(string)activityType(e.g.,'page_view','button_click','form_submission')timestamp(ISO 8601 string)pageUrl(string, for web activities)elementId(optional string, for button clicks/interactions)metadata(optional object, for any additional relevant data, e.g., browser, OS).
- Create an Activity Reporter Class: Build a
UserActivityReporterclass that takes theMcpClientanduserIdin its constructor. - Implement Reporting Methods:
reportPageView(pageUrl: string)reportButtonClick(pageUrl: string, elementId: string)reportFormSubmission(pageUrl: string, formName: string, formData: Record<string, any>)(ForformData, just send a simplified object, don’t worry about full serialization).
- Simulate Activity: Write a small script that instantiates your
UserActivityReporterand calls its methods to simulate a user navigating a website and performing actions. Send each activity as a separateContextRecord. - Error Handling: Ensure your reporter methods include
try/catchblocks for sending context and log any failures gracefully.
This challenge will solidify your understanding of schema design, client-side data gathering, and sending diverse context types.
Summary
This chapter walked you through the foundational steps of building an MCP client using the TypeScript SDK. You learned that clients are essential for observing real-world states and transforming them into structured ContextRecords. We covered initializing the McpClient, defining context schemas, constructing ContextRecords with relevant data and metadata, and sending them to an MCP server. Furthermore, we emphasized the importance of robust error handling and interpreting ContextResponse to ensure context delivery and acceptance. With these skills, you are now equipped to start integrating your own applications into the Model Context Protocol ecosystem.
๐ TL;DR
- MCP clients observe environments and send structured data as
ContextRecords. - The TypeScript SDK’s
McpClientis the core for client-side interaction. ContextRecords require aschemaUrito define their data structure.- Always check the
ContextResponse.statusfor successful context processing. - Implement robust error handling for network, authentication, and schema issues.
๐ง Core Flow
- Initialize
McpClient: Configure withserverUrland optional authentication. - Define Context Schema: Create a URI and TypeScript interface for your context data.
- Gather Data: Collect relevant information from your client’s environment.
- Create
ContextRecord: PopulateschemaUri,data, andmetadatafields. - Send Context: Use
client.sendContext(record)and await theContextResponse. - Handle Response: Inspect
ContextResponse.statusandmessagefor success or failure.
๐ Key Takeaway
Building an effective MCP client is about more than just sending data; it’s about meticulously structuring that data with clear schemas, understanding potential failure modes, and ensuring that the context provided is both accurate and genuinely useful for the intelligent tools that consume it.