|
| 1 | +*Do you have, like me, a bunch of Ruby scripts that make your life easier? Why not make them available to your [e-friends](https://en.wikipedia.org/wiki/AI_assistant) too?* |
| 2 | + |
| 3 | +## The Trigger |
| 4 | + |
| 5 | +A few weeks ago, I was exploring **[Temporal](https://temporal.io/)** (looks promising, worth checking out) and stumbled into a [tutorial](https://docs.temporal.io/ai-cookbook/hello-world-durable-mcp-server) about building an [**MCP server**](https://modelcontextprotocol.io/docs/learn/server-concepts). It used Python with the [**FastMCP library**](https://gofastmcp.com/getting-started/welcome). I'd been curious about **[MCP](https://modelcontextprotocol.io/docs/getting-started/intro)** for a while but kept putting it off, thinking it'd be complicated. |
| 6 | + |
| 7 | +Then I saw the example. It was **suspiciously simple**: a plain Python method, a docstring, type annotations, and a single decorator. That's it. One decorator, and suddenly your e-friend can call it. That simplicity caught me. **Could Ruby feel this natural?** |
| 8 | + |
| 9 | +<figure> |
| 10 | + <picture> |
| 11 | + <img |
| 12 | + src="/assets/img/writings/schnell_python.png" |
| 13 | + alt="This amazed me, are you amazed also?" |
| 14 | + > |
| 15 | + </picture> |
| 16 | + <figcaption> |
| 17 | + This amazed me, are you amazed also? |
| 18 | + </figcaption> |
| 19 | +</figure> |
| 20 | + |
| 21 | +--- |
| 22 | + |
| 23 | +## What's Already Out There |
| 24 | + |
| 25 | +I started looking. What did Ruby already have for MCP? |
| 26 | + |
| 27 | +- [**ruby-sdk**](https://github.com/modelcontextprotocol/ruby-sdk) — maintained by <img src="https://avatars.githubusercontent.com/u/13203?v=4" class="avatar"> [Koichi ITO (@koic)](https://github.com/koic) |
| 28 | + - Official, low-level protocol implementation |
| 29 | + - Great foundation for building on top |
| 30 | +- [**mcp-rb**](https://github.com/funwarioisii/mcp-rb) — created by <img src="https://avatars.githubusercontent.com/u/20943066?v=4" class="avatar"> [Kazuyuki Hashimoto (@funwarioisii)](https://github.com/funwarioisii) |
| 31 | + - Full-featured, supports resources and templates |
| 32 | + - Rake/Thor-like experience with tool definitions separate from code |
| 33 | +- [**fast-mcp**](https://github.com/yjacquin/fast-mcp) — created by <img src="https://avatars.githubusercontent.com/u/51963063?v=4" class="avatar"> [Yorick Jacquin (@yjacquin)](https://github.com/yjacquin) |
| 34 | + - Chained DSL doesn't quite have that Ruby feel |
| 35 | + |
| 36 | +All solid projects doing great work. But I wanted something different: **take code I've already written, add annotations, and it becomes an MCP server.** No DSL to learn. No separate tool definitions. Why write special instructions just for your e-friend when the code and its docs already say everything that needs saying? |
| 37 | + |
| 38 | +## The Idea |
| 39 | + |
| 40 | +Then it clicked. Ruby's had method documentation for decades — [**RDoc**](https://ruby.github.io/rdoc/) and [**YARD**](https://yardoc.org/). I already write `@param` types and `@return` descriptions. That metadata is already there. |
| 41 | + |
| 42 | +What if one tag — just `@mcp.tool` — could turn a documented method into something your e-friend can call? No new syntax. No separation between "tool definition" and "actual code." Just annotated Ruby. |
| 43 | + |
| 44 | +Bonus: LLMs love generating YARD comments. I usually delete them. **Finally, a reason to keep them** — turns out e-friends benefit from the same docs you do. |
| 45 | + |
| 46 | +## Building It |
| 47 | + |
| 48 | +The result is what I currently call [**SchnellMCP**](https://github.com/RubyElders/schnellmcp). Plain Ruby methods + YARD docs = (also) MCP tools. Here's a simple example: |
| 49 | + |
| 50 | +```ruby |
| 51 | +require 'bundler/inline' |
| 52 | + |
| 53 | +gemfile do |
| 54 | + source 'https://gem.coop' |
| 55 | + gem 'schnellmcp', git: 'https://github.com/RubyElders/schnellmcp' |
| 56 | +end |
| 57 | + |
| 58 | +require 'schnellmcp' |
| 59 | + |
| 60 | +# Add two numbers |
| 61 | +# |
| 62 | +# @param a [Integer] First number |
| 63 | +# @param b [Integer] Second number |
| 64 | +# |
| 65 | +# @return [Integer] Sum of a and b |
| 66 | +# |
| 67 | +# @mcp.tool |
| 68 | +def add(a, b) |
| 69 | + a + b |
| 70 | +end |
| 71 | + |
| 72 | +# Start the MCP server if called directly |
| 73 | +SchnellMCP::Server.run(__FILE__) if __FILE__ == $0 |
| 74 | +``` |
| 75 | + |
| 76 | +Standard YARD documentation. One `@mcp.tool` tag. Done. The MCP tool description and parameter types come straight from the docs. Type casting is automatic. |
| 77 | + |
| 78 | +Code that feels like Ruby, not "MCP framework code." That's what I was after. |
| 79 | + |
| 80 | +## Daily Use |
| 81 | + |
| 82 | +I've been working on a C++ project — not my strongest language, so LLMs help a lot. The usual flow: write code, compile, test, repeat. But C++ build state is a minefield. What's the current state of the build? Is my latest change actually included? Did cache invalidation properly detect my file changed during compilation? Is the build folder properly bootstrapped and up to date with the latest CMake configuration? These questions sound silly until you've wasted an hour debugging code that wasn't even running. |
| 83 | + |
| 84 | +Classic example: add a log line to debug something, run the code, line doesn't appear. Now what? Was the file rebuilt at all? Do I need to rebuild and try again? Clear the cache and start fresh just to be sure? Without knowing the history and current state, it's genuinely hard to tell. Sound familiar? |
| 85 | + |
| 86 | +I wrapped the whole thing into a Ruby script — one command that ensures every run starts from a predictable state. Build folder, cache, compilation — handled, every time. No more build archaeology before getting to the actual problem. Sometimes I trigger it myself, sometimes it's part of a larger script. No more guessing, no more side-quests. |
| 87 | + |
| 88 | +And then with one `@mcp.tool` annotation, the same script became available to my e-friend too. Same tool, same predictable state — now my e-friend doesn't get sidetracked on build archaeology either, and can focus on what actually matters. |
| 89 | + |
| 90 | +One plain Ruby file covers all three use cases naturally: CLI for quick manual runs, MCP server for the e-friends, and a regular `require` when another script needs it. No extra framework, no duplication, just Ruby. Here's a partial example — just the preset listing task — to show the idea: |
| 91 | + |
| 92 | +```ruby |
| 93 | +require 'bundler/inline' |
| 94 | + |
| 95 | +gemfile do |
| 96 | + source 'https://gem.coop' |
| 97 | + gem 'schnellmcp', git: 'https://github.com/RubyElders/schnellmcp' |
| 98 | +end |
| 99 | + |
| 100 | +require 'schnellmcp' |
| 101 | +require 'json' |
| 102 | + |
| 103 | +# List available CMake presets |
| 104 | +# |
| 105 | +# @param path [String] Path to directory containing CMakePresets.json |
| 106 | +# |
| 107 | +# @return [Array<String>] List of available preset names |
| 108 | +# |
| 109 | +# @mcp.tool |
| 110 | +def list_cmake_presets(path = ".") |
| 111 | + presets_file = File.join(path, "CMakePresets.json") |
| 112 | + data = JSON.parse(File.read(presets_file)) |
| 113 | + data["configurePresets"].map { |preset| preset["name"] } |
| 114 | +end |
| 115 | + |
| 116 | +# Run based on how the script was invoked |
| 117 | +if __FILE__ == $0 |
| 118 | + if ARGV[0] == "server" |
| 119 | + # MCP server mode for e-friends |
| 120 | + SchnellMCP::Server.run(__FILE__) |
| 121 | + else |
| 122 | + # CLI mode for humans |
| 123 | + puts list_cmake_presets(".") |
| 124 | + end |
| 125 | +end |
| 126 | +``` |
| 127 | + |
| 128 | +One file, three modes: |
| 129 | + |
| 130 | +- `ruby cmake.rb server` — MCP server for my e-friend |
| 131 | +- `ruby cmake.rb` — CLI for quick checks |
| 132 | +- `require_relative 'cmake'` — library for other scripts |
| 133 | + |
| 134 | +Same code, three ways to use it. Plain Ruby throughout — testable, composable, nothing special. |
| 135 | + |
| 136 | +The CMake example captures the whole idea: I wrote the script because I needed clarity. My e-friend needed that same clarity. One tool, useful for everyone — including my e-friend. |
| 137 | + |
| 138 | +## What's Next? |
| 139 | + |
| 140 | +**SchnellMCP is a prototype** — single files only, no dependency parsing. Just enough to work with [GitHub Copilot](https://github.com/features/copilot), which is what I use. And I've been running it daily for months, steadily adding more tools as I find scripts worth exposing. |
| 141 | + |
| 142 | +It would probably deserve a proper backend at some point — like the aforementioned [ruby-sdk](https://github.com/modelcontextprotocol/ruby-sdk) for solid protocol handling — but even with the current minimal and hacky code it still fully works, and honestly I haven't found a reason to rush that yet. |
| 143 | + |
| 144 | +Check it out at [**github.com/RubyElders/schnellmcp**](https://github.com/RubyElders/schnellmcp) — and if you have a drawer full of little Ruby scripts, you're probably closer to an MCP server than you think. |
| 145 | + |
| 146 | +**Have your own stories of Ruby tools and e-friend integration? Come say hi on** [Mastodon](https://ruby.social/@rubyelders), [Bluesky](https://rubyelders.bsky.social) **or** [Twitter/X](https://x.com/RubyElders) **— and above all, good luck educating your e-friends with your own dev habits. 🤖** |
0 commit comments