MCP explained with code
So you are curios about how Model Context Protocol works as a stand-alone client and server, and as a hub to provide context for LLM models to do their magic? Then read on.
In this post, I am going to write about following few points:
- Why MCP?
- How does it work?
- What makes it worth learning and excited about?
Disclaimer: This blog post is not about making a claim of its merit only without its trade offs, which is a blog post for another day and another time. So take everything you read here with a grain of salt.
Why MCP?
LLMs needs context to narrow its pattern recognitions so it can provide more contextual help for the domain you are engaging them with. MCP provides a plug and play way to do just that. I am not going to repeat the excellent documentation from its web site here but I'd like to drive the point a bit further expanding upon their USB-C analogy. USB-C is a universal standard (interface to be exact) that every device manufactures uses so their device can be plugged into a USB port of a computer. Once a device (think of it as a MCP Server) connects to USB-C port, it exchanges information with the host about its capabilities using a USB-C subsystem of your PC (think of it as a MCP client) so you can communicate with the device. MCP client/server behaves just like that and we are ready to the next section to see the actual code so you can run to convince yourself.
How does it work?
To understand any concept, I usually take it apart to its smallest logical units to see them in actions. Below are all you need to run a stand-alone MCP client and server in TypeScript:
// Core bits of Server here. See full code: // https://github.com/oneness/ts-mcp-client-server/blob/main/src/server.ts class MCPServer { ... // omitted boiler plate code private setupToolHandlers() { // Handle list_tools requests this.server.setRequestHandler(ListToolsRequestSchema, async (): Promise<ListToolsResult> => { return { tools: [ { name: "say_hello", description: "Says hello to a person", inputSchema: { type: "object", properties: { name: { type: "string", description: "The name of the person to greet", }, }, required: ["name"], }, } as Tool, { name: "get_time", description: "Gets the current time", inputSchema: { type: "object", properties: {}, }, } as Tool, ], }; }); // Handle call_tool requests this.server.setRequestHandler(CallToolRequestSchema, async (request): Promise<CallToolResult> => { const { name, arguments: args } = request.params; switch (name) { case "say_hello": const personName = args?.name || "World"; return { content: [ { type: "text", text: `Hello, ${personName}! This is a greeting from the MCP server.`, }, ], }; case "get_time": return { content: [ { type: "text", text: `Current time: ${new Date().toISOString()}`, }, ], }; default: throw new Error(`Unknown tool: ${name}`); } }); } ... // omitted } // Client // https://github.com/oneness/ts-mcp-client-server/blob/main/src/client.ts class MCPClient { ... // omitted async listTools() { try { const response = await this.client.listTools() as ListToolsResult; console.log("Available tools:"); response.tools.forEach((tool) => { console.log(`- ${tool.name}: ${tool.description}`); }); return response.tools; } catch (error) { console.error("Error listing tools:", error); return []; } } async callTool(name: string, args: any = {}) { try { const response = await this.client.callTool({ name, arguments: args, }) as CallToolResult; console.log(`Tool '${name}' response:`); response.content.forEach((content) => { if (content.type === "text") { console.log(content.text); } }); return response; } catch (error) { console.error(`Error calling tool '${name}':`, error); } } ... //omitted }
As you have noticed, there are few interfaces (functions with args and return schema ) that you need to implement so client and server can speak to each other using Request/Response format that they understand (JSON2.0 RPC if you are curious).
Now you understand how a stand-alone MCP client and server communicates with each other (I highly recommend you clone the repo and run `npm run mcp` to see it in action), Let us see the actual LLM chat that uses MCP client to talk to LLM by providing MCP server capabilities as a context to it. LLM can use the structured data (MCP capabilities) to determine what MCP server tool it can use to answer user's request, which in turn MCP client uses to execute the tool. Then the tool response is provided back to LLM so it can formulate a final response back to the user. Here is the simple ASCII diagram that visualizes the flow I am talking about:
+----------+ +------------+ +-----------------+ +-----+ | User | | MCP Client | | MCP Server Tool | | LLM | +----------+ +------------+ +-----------------+ +-----+ | | | | | | 0. Connect & Get | | | | Capabilities | | | +----------------->| | | |<-----------------+ | | | (Capabilities now known by Client) | | | | | | | 1. Setup System | | | | Prompt w/ MCP | | | | Capabilities | | | +------------------------------------>| | | | | | | | (LLM is now | | | | aware of tools) | | | | | | 2. Chat Req. | | | +---------------->| | | | | | | | | 3. User Query | | | | (subsequent) | | | +------------------------------------>| | | | | | | | 4. Process Query | | | | & Context | | | | | | | |<-----------------| (Tool identified?) | | | 5. Tool Exec. | | | | Request | | |<------------------------------------| | | | | | | 6. Execute Tool | | | +----------------->| | | | | | | |<-----------------+ | | | 7. Tool Output | | | | | | | | 8. Tool Output | | | +------------------------------------>| | | | | | | | | | | | | | |<------------------------------------| | | | 9. Final Response| |<----------------+ | | | 10. Chat Resp. | | |
Here is the code that shows above flow in action:
// Omitted for brevity. See below link for details // https://github.com/oneness/ts-mcp-client-server/blob/main/src/llm.ts#L69 async processMessage(userMessage: string): Promise<string> { console.log(`\nš¤ Processing: "${userMessage}"`); // Add user message to conversation this.conversationHistory.push({ role: "user", content: userMessage }); try { // Prepare tools for Claude const tools = this.convertMCPToolsToAnthropicFormat(); // Get Claude's response const response = await this.anthropic.messages.create({ model: "claude-3-5-sonnet-20241022", max_tokens: 1000, system: this.systemPrompt, messages: this.conversationHistory, tools: tools.length > 0 ? tools : undefined, }); console.log(`š§ Claude response:`, JSON.stringify(response, null, 2)); // Process the response let finalResponse = ""; const toolResults: MCPToolResult[] = []; // Handle different content types for (const content of response.content) { if (content.type === 'text') { finalResponse += content.text; } else if (content.type === 'tool_use') { console.log(`š§ Claude wants to use tool: ${content.name} with args:`, content.input); // Call the MCP tool const mcpResult = await this.mcpClient.callTool(content.name, content.input); let toolResultText = "No result"; if (mcpResult && mcpResult.content) { toolResultText = mcpResult.content .filter(c => c.type === 'text') .map(c => c.text) .join('\n'); } toolResults.push({ tool: content.name, result: toolResultText }); // Add tool result to conversation for Claude's next response this.conversationHistory.push({ role: "assistant", content: response.content }); this.conversationHistory.push({ role: "user", content: [ { type: "tool_result", tool_use_id: content.id, content: toolResultText } ] }); } } // If we used tools, get Claude's final response incorporating the results if (toolResults.length > 0) { console.log(`š Tool results:`, toolResults); const finalCompletion = await this.anthropic.messages.create({ model: "claude-3-5-sonnet-20241022", max_tokens: 1000, system: this.systemPrompt, messages: this.conversationHistory, tools: tools.length > 0 ? tools : undefined, }); // Extract text from final response finalResponse = ""; for (const content of finalCompletion.content) { if (content.type === 'text') { finalResponse += content.text; } } this.conversationHistory.push({ role: "assistant", content: finalCompletion.content }); } else { // No tools were used, add the response to history this.conversationHistory.push({ role: "assistant", content: response.content }); } return finalResponse; } catch (error) { console.error('Error calling Claude:', error); return "I'm sorry, I encountered an error processing your request."; } }
What makes it worth learning and excited about?
If you have been following LLM landscape, you might have come to a realization that most of us (unless you are an AI researcher) are in the business of providing the most accurate and up to date context to the foundational LLM models. MCP unifies the way LLM models, Consumer Applications (clients) and Resource Providers (servers) communicates thus reducing M*N integration issues to M+N, which is worth learning, implementing and being excited about. Even without the context of LLM, I hope more data or service providers exposes their system capabilities by implementing MCP server contracts. That would dramatically reduce so much wasted time spent on ad-hoc integration API glue code.
Hope you learned a thing or two about MCP. Keep learning and have fun!
Nix: Better way for fun and profit
Nix is started in 2003 as a research project aimed to solve the problem of reliable software deployment. The PhD thesis titled The Purely Functional Software Deployment Model proposed a novel way of building software where the final artifact is purely dependent on the inputs to the build system, which is a pure function in a mathematical sense. Regardless of where you are in your nix journey, I can't recommend this paper (thesis) enough. It is very approachable and worth a read so you learn from first principle of what, why and how about Nix.
Nix is a software build and management system that can replace traditional package managers, build environments and configuration tools. Due to the inherent complexity of the problem domain nix is designed to solve and its long history, it has pretty steep learning curve but not unsurmountable. One of the common point of confusions is how the term `Nix` is used in documentations, tutorials and blogosphere. So let me clarify few terminologies that often gets overloaded.
- Nix: Unless otherwise fully qualified, I use it to mean the software build and management system.
- Nix CLI: The nix command line client that one uses to interact with nix.
- Nix DSL: The domain specific language that nix uses to generate a software package. I would like to see everyone start using it to mean the nix language.
- Flakes: A Nix DSL with number of conventions that is designed to ease the configuration and discoverability of software packaging lifecycle.
- NixOS: The final artifact, which is happened to be a Linux Operating System that is generated by feeding Nix DSL to nix. I will not be covering it in this blog post.
After few false starts and restarts, below are what I believe to be better ways for getting started, using nix for fun and profit.
Installation
I have a following bash script to install a specific version so I can have control over which version to install, what features enable and disable.
#!/usr/bin/env bash set -Eeuo pipefail VERSION='2.28.1' # replace it with the latest version URL="https://releases.nixos.org/nix/nix-${VERSION}/install" MY_CONF="$HOME/.dotfiles/nix/nix.conf" sh <(curl --location "${URL}") \ --daemon \ --no-channel-add \ --nix-extra-conf-file ${MY_CONF} # conf file has this content experimental-features = nix-command flakes
The `–no-channel-add` and the extra conf file needs some explanation. Nix called a remote url a channel that gets automatically installed, where nix uses to retrieve package definitions (Nix DSL) to manage packages. It introduces a state, which is currently installed channel url that is outside of Nix DSL, thus defeating the purpose of reproducibility. It is considered legacy feature and not needed by flakes, an experimental feature already widely adopted by the community. So I highly recommend enabling flakes and additional commands to interact with it.
Using for fun and sanity
Every project depends on existing software that is beyond your control. Nix DSL enables you to declaratively specify your projects dependencies, a repo or a tar-ball down to the file digest of its content, which is what gives nix superpowers of being a deterministic and reproducible package manager. This means that if your inputs stays the same, nix guarantees that it produces the exact same output regardless of when and where. Below is a flake that pulls in latest version of Clojure into your project.
{ # optional attribute description = "My awesome Clojure/ClojureScript project"; # required attribute inputs = { # nix dsl fns useful for writing flakes flake-utils.url = "github:numtide/flake-utils/v1.0.0"; # Pins state of the packages to a specific commit sha pinnedPkgs.url = "github:NixOS/nixpkgs/c46290747b2aaf090f48a478270feb858837bf11"; }; # required attribute outputs = { self, flake-utils, pinnedPkgs }@inputs : flake-utils.lib.eachDefaultSystem (system: let pinnedSysPkgs = inputs.pinnedPkgs.legacyPackages.${system}; in { devShells.default = pinnedSysPkgs.mkShell { packages = [ pinnedSysPkgs.clojure ]; # commands to run in the development interactive shell shellHook = '' echo To get Clojure REPL, Run: echo clojure echo To get ClojureScript REPL, Run: echo clj -Sdeps \'{:deps {org.clojure/clojurescript {:mvn/version "1.11.132"}}}\' -M -m cljs.main --repl ''; }; packages = { docker = pinnedSysPkgs.dockerTools.buildLayeredImage { name = "My awesome Clj docker image built by nix"; tag = "latest"; contents = [pinnedSysPkgs.clojure]; }; }; }); }
Do not worry too much about not understanding above nix dsl code. The most important thing to know is that it is nix dsl referred to as a flake that specifies its inputs and outputs declaratively. Save above code as `flake.nix`, which is a convention, then run `nix develop` to get an interactive shell with Clojure in your path. Nix can do way more than this. However, I recommend you just start with solving project dependencies problem. Above flake gives you following benefits:
- Ability to pin the exact versions of your project dependencies.
- Cross platform development environment that works both in MacOS and various flavors of Linux.
- Determinate and reproducible development environment that eliminates "it works on my machine" tooling issues.
One important thing to notice here is the way I chose to reference the url inputs of the flake. I deliberately used tags or commit sha to prevent the state of the urls (thus the state of the nix DSL) change under me, which defeats the purpose of having a determinate and reproducible way to get a development environment. I have following bash script that prints available tags and corresponding commit hash:
git_tag_sha () { repo="$1" echo "********************************************************" echo "Available release and commit sha for pinning are:" echo "********************************************************" printf "\033[1m%-12s %s\033[0m\n" "release" "commit sha" curl -s https://github.com/$repo/tags | grep -oP 'href="\K[^"]*(releases/tag|nixpkgs/commit)[^"]*' | awk -F '/' 'NR%2{tag=$NF; next} {printf "%-12s %s\n", tag, $NF}' echo echo "****************************************************************************" echo "Please replace the commit sha of following line to pin pkgs to a commit sha: " echo "pinnedPkgs.url = github:$repo/<commit>" echo "****************************************************************************" echo } # You can run it like this: git_tag_sha "NixOS/nixpkgs"
Profiting in CI/CD and production
This is probably one of the most frictionless and rewarding outcome of using nix. Nix is designed to solve the problem of software deployment after all but the wholesale adoption in production might prove to be too much for the final gain. To spare yourself countless hours of frustration, I highly recommend you start with using it to build docker image if you happened to use docker and Kubernetes. Nix has superb built-in support for making the smallest possible docker image otherwise impossible. Above flake already includes `docker` image as one of its packages output. Here is how you build and load the docker image:
nix build .#docker # the image will be in ./result docker load < ./result # to get it ready to be deployed
It is a declarative way (using the power of Nix DSL compared to using series commands in YAML file) to deterministically reproduce layered Docker image that saves time and money in your DevOps journey. Have fun and enjoy!
Google Bard and Emacs
After reading a Google blog post on Bard's increasing ability for reasoning about source code, I thought I would give it a try. The issue is that not like OpenAI, Bard currently does not have an http API that I can use via curl. I googled around and came across the `bard-rs` project here: https://github.com/Alfex4936/Bard-rs. So I followed the excellent instruction to get set up using bard from command line and its is pretty solid. I used following Elisp to use `bard-rs` from Emacs' compilation buffer here:
(defun kcompilation-start (cmd name &optional mode) (let* ((compile-command nil) (compilation-save-buffers-predicate 'ignore) (compilation-buffer (compilation-start cmd (if (equal mode 'read-only) nil t) (lambda (m) (or (when (boundp 'name) (format "*%s*" name)) (buffer-name)))))) (when current-prefix-arg (with-current-buffer compilation-buffer (switch-to-prev-buffer (get-buffer-window (current-buffer))))) (message (format "Running %s in %s ..." cmd name)))) (defun kprompt-bard (&optional p) "Prompts for input to send it to `bard` using `bard-rs` in *bard-prompt* buffer. If mark-active, uses the text in the region as the prompt" (interactive "P") (let* ((bs "bard-prompt") (bname (format "*%s*" bs)) (bname (if (get-buffer bname) bname (progn (kcompilation-start "bard-rs -e ~/.env" bs) bname))) (prompt (if mark-active (replace-regexp-in-string "\n" "" (buffer-substring-no-properties (region-beginning) (region-end))) (read-string "AI Chat Prompt: ")))) (with-current-buffer (pop-to-buffer bname) (when p (end-of-buffer) (insert "!reset") (comint-send-input) (end-of-buffer) (insert prompt) (comint-send-input)) (when (not p) (end-of-buffer) (insert prompt) (comint-send-input)))))
You can bind `kprompt-bard` to any key of your choice and start interacting with Google bard from the comfort of Emacs' buffer.
AI or not to AI
1913 Webster dictionary gives following definition to Artificial Intelligence: Artificial - 1. Made or contrived by art; produced or modified by human skill and labor, in opposition to natural; 2. Feigned; fictitious; assumed; affected; not genuine. 3. Artful; cunning; crafty. 4. Cultivated; not indigenous; not of spontaneous growth; Intelligence - 1. The act or state of knowing; the exercise of the understanding. 2. The capacity to know or understand; readiness of comprehension; the intellect, as a gift or an endowment. 3. Knowledge imparted or acquired, whether by study, research, or experience; general information. Specifically; (Mil.) Information about an enemy or potential enemy, his capacities, and intentions.
Let us read and re-read above definitions and give it a few minutes to sink in. With our skilled labor, we have managed to produce a very powerful fictitious software that can understand, produce and reason about human generated artifacts such as language, images and videos. Our ability to make it more scalable, more accurate and more controllable is just a matter of time considering what we have is already pretty powerful enough. My following interaction with ChatGPT 3.5 is already impressive:
[ME]: Explain, then add tests and simplify if possible: (defun kprompt (&optional p) (interactive "P") (let* ((bname (if p "*org-ai-on-region*" "*scratch*")) (directive "Explain, then add tests and simplify if possible:\n") (prompt (if p (buffer-substring (region-beginning) (region-end)) (read-string "AI Chat Prompt: ")))) (with-current-buffer (pop-to-buffer bname) (when p (erase-buffer) (org-mode) (setq truncate-lines nil) (setq word-wrap t) (org-ai--insert-created-timestamp) (insert "\n") (insert "#+begin_ai\n" "[ME]: " directive prompt "\n" "#+end_ai\n") (forward-line -1) (org-ai-complete-block)) (when (not p) (beginning-of-buffer) (open-line 2) (org-ai-prompt prompt))))) [AI]: This function takes an optional argument, p, which determines whether the user will be prompted to enter a text or whether the function will use the text highlighted in the buffer. If p is provided, it will create a new buffer with the name "*org-ai-on-region*" and insert the highlighted text from the current buffer along with a directive. This new buffer buffer will be formatted using org-mode to allow for better organization of comprehension text. Finally, it will call org-ai-complete-block to add some AI-generated text to the prompt. If p is not provided, it will prompt the user to enter text and then call org-ai-prompt org-ai-prompt to add some AI-generated text to the prompt. Tests: Input: (kprompt) Expected Output: AI Chat Prompt: Input: (kprompt t) Expected Output: *org-ai-on-region* buffer created with prompt Input: (Highlight "This is a test") (kprompt t) Expected output: *org-ai-on-region* buffer created with "This is a test" text and AI-generated text. Simplified Code: The code appears to be relatively straightforward and efficient. No simplification is needed.
It understood and reasoned about a piece of code (simple for now but does not matter as it can get pretty powerful in no time) despite the fact that it has never seen above code before in its entirety (I am pretty sure about that since it is a private code and this is the only time I am making the code public). This degree of intelligence codified into a software that a few powerful cooperation currently dominates should be something that keeps every software engineers awake at night. Not because of its inherent danger or tremendous productivity boosting ability as folks on the opposite side of spectrum of current AI debate claimed, but because of the very fact that every aspect of human lives will be effected by a such a powerful code like ChatGPT whether we like it or not, and we need to do whatever we can to ensure it is used for the good of humanity in general. It is created by humans and should serve humans. Make no mistake about it. Powerful software systems like that is already used by big cooperations and rouge states to cajole people into a state of self censorship if not into a state of heedlessness of its future implication. Social media, powerful tracking and image recognition systems are already pervasive in the lives of millions of people that are being controlled by dictators all around the world (and it is being exported very actively in the name of economic progress) to socially engineer people's behaviors that benefits their agenda in the name of social and economic progress at the very expense of destroying anyone or anything that is deemed as an obstacle.
As a software engineer who have seen the worst of what bad actors can do with such a powerful systems, I am calling out to all of my fellow engineers to start thinking about what kind of world we would like our kids to inherit from us regardless of where you are, who you are and what is your geopolitical affiliation is. The wave is already there, and it takes all of us to make sure we are not being social engineered out of our humanity. I believe in the power of our humanity to make AI to work for us not the other way around. I registered the domain www.codeforhumanrights.org few years ago and this might be a good time to start putting it to a good use. If you are reading this and feel the need to start doing something, reach out to me via ktuman at acm dot org.
Atomic commits made easy
Code complexity is something we all deal with in our daily work. There are many tools to helps us manage it. One of the most important one is to make incremental changes where each change is about one and one context alone , which is a great definition of an atomic commit. I do not think I need to convince you about its benefits any further than what I already have alluded to above, which is worth repeating here: It helps us contain complexity within our code base. In pursuit of making it easy for me to do atomic commits, I settled down following workflow:
- Separate changes by its effects. If a change is immutable, that is to say it is simple refactor or restructure that does not change the existing behavior, it should be in one commit.
- If changes are mutable, that is to say it changes existing behavior, group them further by their logical context where the context is about one and one thing alone. This is really crucial since we would like to make sure every commit can stand on its own and does not depend on later commits. This gives us the linear append only change that we can easily keep track of. This might sound a bit strange to you, but it means that you should not commit a not finished work at least not push up stream. That also does not mean that you should not commit your work as often as possible but if you do commit and end up violating above convention, you should amend/squash your commits.
Having armed with above convention, I incorporated following tools to help me to make atomic commits easy:
I am not going to repeat what the excellent blog talked about above tools here, but it is worth checking it out, and I highly recommend it. If you happen to use Emacs, here is how you add it to your config:
;; clone above repo in to ~/repos and eval following code (load-file "~/repos/commit-patch/commit-patch-buffer.el") (eval-after-load 'diff-mode '(require 'commit-patch-buffer nil 'noerror))
With above configuration, you can M-x vc-diff a file (vc-root-diff for whole project) then kill, split or edit the resulting hunks using diff mode's built-in commands and to then hit C-c C-c to commit the patch. Later if you realized that your commit is not atomic, you can make further changes and amend previous commit by C-c C-C (note the upper case C).