Class: Esp::McpServer
- Inherits:
-
Object
- Object
- Esp::McpServer
- Defined in:
- lib/esp/mcp_server.rb
Overview
Model Context Protocol server over stdio, so AI assistants (Claude Code, Claude Desktop, …) can author, build, lint, and query a mod project as native tools. It is a thin shell over Esp::Operations — the same service layer the HTTP API uses — so every tool returns the same payload the CLI ‘–json` mode and `esp serve` already emit.
Transport is the MCP stdio convention: newline-delimited JSON-RPC 2.0, one message per line, no embedded newlines. Requests carry an ‘id` and get a response; notifications (no `id`) get none. The protocol stream owns stdout, so all diagnostics go to stderr.
Operation errors (missing mod, no index, bad source) come back as a tool result with ‘isError: true` — the model sees the message and can recover. Only malformed protocol traffic (bad JSON, unknown method, unknown tool) uses a JSON-RPC error response.
Alongside tools, the server exposes read-only resources (‘resources/list` + `resources/read`): the command-tree introspection and the narrative docs, so an agent can pull context without a tool call.
Defined Under Namespace
Classes: ProtocolError
Constant Summary collapse
- PROTOCOL_VERSION =
'2024-11-05'.freeze
- TOOLS =
Each tool maps an MCP tool name to an Esp::Operations method plus the JSON Schema the client uses to build calls. ‘arguments` arrive as a string-keyed hash, exactly the shape Operations expects.
[ { name: 'version', description: 'Print the esp toolchain version.', input_schema: { type: 'object', properties: {}, additionalProperties: false }, op: :version }, { name: 'commands', description: 'Discover the full esp command tree — names, usage, and options.', input_schema: { type: 'object', properties: {}, additionalProperties: false }, op: :commands }, { name: 'open_project', description: 'Point the server at a project directory. Validates the path + the project ' \ 'marker\'s game id; every subsequent op runs against this root until a new ' \ 'open_project request or an explicit `root:` overrides it.', input_schema: { type: 'object', properties: { root: { type: 'string', description: 'Absolute path to a directory (esp init creates one).' } }, required: ['root'], additionalProperties: false }, op: :open_project }, { name: 'active_project', description: 'The project currently active on the server (root + game). Returns nulls if no ' \ 'open_project has happened yet.', input_schema: { type: 'object', properties: {}, additionalProperties: false }, op: :active_project }, { name: 'projects_recent', description: 'Recently-opened projects, newest-first. Persisted per user (ESP_DATA_DIR).', input_schema: { type: 'object', properties: {}, additionalProperties: false }, op: :projects_recent }, { name: 'projects_new', description: 'Scaffold a brand-new project (git init + .esp/project.json + first mod) under ' \ "the user's mods_home. Same flow ESPresso's \"New Project\" runs.", input_schema: { type: 'object', properties: { project: { type: 'string', description: 'Project name (becomes the directory name).' }, mod: { type: 'string', description: 'Name of the first mod to scaffold inside it.' }, game: { type: 'string', description: "Game plugin id (default: 'mw')." }, format: { type: 'string', enum: %w[json rb py js mjs ts], description: 'First mod source format (default: json).' }, author: { type: 'string', description: 'Author for the scaffolded mod header.' }, description: { type: 'string', description: 'Description for the scaffolded mod header.' } }, required: %w[project mod], additionalProperties: false }, op: :projects_new }, { name: 'build', description: 'Build mods/<MOD> to dist/<MOD>[.locale].esp via tes3conv.', input_schema: { type: 'object', properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' }, locale: { type: 'string', description: 'Optional locale; output becomes <MOD>.<locale>.esp.' } }, required: ['mod'], additionalProperties: false }, op: :build }, { name: 'build_all', description: 'Build every mod under mods/ to dist/. Returns one result per mod.', input_schema: { type: 'object', properties: { locale: { type: 'string', description: 'Optional locale applied to every build.' } }, additionalProperties: false }, op: :build_all }, { name: 'unpack', description: 'Import an existing plugin (.esp/.esm/.omwaddon) into mods/<NAME>/<NAME>.json.', input_schema: { type: 'object', properties: { plugin: { type: 'string', description: 'Plugin path or installed plugin name (Fargoth.esp).' }, name: { type: 'string', description: 'Mod folder name (default: plugin basename).' }, config: { type: 'string', description: 'openmw.cfg to resolve a bare name against (optional).' } }, required: ['plugin'], additionalProperties: false }, op: :unpack }, { name: 'install', description: 'Make a built dist/<MOD>.esp available to the game. Default: register with ' \ 'openmw.cfg. Set copy_to or to_data_files to also copy the plugin into the ' \ 'vanilla Data Files dir for the original engine.', input_schema: { type: 'object', properties: { mod: { type: 'string', description: 'Mod name; built .esp must exist in dist/.' }, copy_to: { type: 'string', description: 'Also copy the .esp into this directory (original engine).' }, to_data_files: { type: 'boolean', description: 'Also copy to the auto-detected Morrowind Data Files dir.' }, register_openmw: { type: 'boolean', description: 'Register with openmw.cfg (default true).' } }, required: ['mod'], additionalProperties: false }, op: :install }, { name: 'plugins_list', description: 'List plugins OpenMW has installed (from openmw.cfg) with active flag + load order.', input_schema: { type: 'object', properties: { config: { type: 'string', description: 'Path to openmw.cfg (optional).' } }, additionalProperties: false }, op: :plugins_list }, { name: 'i18n_check', description: 'Report missing/orphan i18n keys per locale for a mod (vs. the default en catalogue).', input_schema: { type: 'object', properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' } }, required: ['mod'], additionalProperties: false }, op: :i18n_check }, { name: 'lint', description: 'Check a mod for dangling refs and missing-master issues against the reference index.', input_schema: { type: 'object', properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' } }, required: ['mod'], additionalProperties: false }, op: :lint }, { name: 'scaffold', description: 'Create a new mod folder under mods/<MOD>/ with a starter source file and README.', input_schema: { type: 'object', properties: { mod: { type: 'string', description: 'Mod name to create.' }, format: { type: 'string', enum: %w[json rb py js mjs ts], description: 'Format; default json.' }, author: { type: 'string', description: 'Author name (default: git config user.name).' }, description: { type: 'string', description: 'Plugin description.' }, force: { type: 'boolean', description: 'Overwrite an existing mod folder.' } }, required: ['mod'], additionalProperties: false }, op: :scaffold }, { name: 'extract_scripts', description: "Hoist each Script record's inline text into mods/<MOD>/scripts/<id>.mwscript.", input_schema: { type: 'object', properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' } }, required: ['mod'], additionalProperties: false }, op: :extract_scripts }, { name: 'refs_find', description: 'Search the vanilla reference index — substring match on id + name by default.', input_schema: { type: 'object', properties: { q: { type: 'string', description: 'Substring to match against record id and name.' }, type: { type: 'string', description: 'Filter by record type (e.g. Npc, Cell, Script).' }, like: { type: 'string', description: "SQL LIKE pattern on id (e.g. 'Fargoth%')." }, exact: { type: 'boolean', description: 'Match q as an exact id instead of a substring.' }, show: { type: 'boolean', description: 'Return the full JSON record for each match.' }, limit: { type: 'integer', description: 'Max rows to return (default 100).' } }, additionalProperties: false }, op: :refs_find }, { name: 'records_read', description: "Read a mod's records as structured data (works for any source format).", input_schema: { type: 'object', properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' } }, required: ['mod'], additionalProperties: false }, op: :records_read }, { name: 'record_write', description: "Insert/update one record in a mod's .json source, keyed by type+id (JSON only).", input_schema: { type: 'object', properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' }, record: { type: 'object', description: 'TES3 record; needs "type" (+ "id" unless Header).' } }, required: %w[mod record], additionalProperties: false }, op: :record_write }, { name: 'dialogue_write', description: "Author dialogue from a JSON spec into a mod's .json source (data-driven Dialogue " \ 'DSL). A topic is replaced wholesale, so re-authoring leaves no orphan info records. ' \ 'Info filters mirror the DSL (speaker/race/class/faction/cell/sex/pc_faction/pc_rank/' \ 'speaker_rank/disposition/sound/result_script/journal_index); @t:key in text for i18n.', input_schema: { type: 'object', properties: { mod: { type: 'string', description: 'Mod name (folder under mods/).' }, spec: { type: 'object', properties: { topics: { type: 'array', items: { type: 'object', properties: { name: { type: 'string' }, type: { type: 'string', enum: %w[topic journal greeting persuasion voice] }, speaker: { type: 'string' }, infos: { type: 'array', items: { type: 'object', properties: { text: { type: 'string' } } } } }, required: %w[name infos] } } }, required: ['topics'] } }, required: %w[mod spec], additionalProperties: false }, op: :dialogue_write } ].freeze
- TOOLS_BY_NAME =
TOOLS.to_h { |t| [t[:name], t] }.freeze
- DOC_PAGES =
Narrative docs exposed as resources, by slug under docs/.
[ ['walkthrough', 'Walkthrough', 'End-to-end: a plugin from empty to built.'], ['getting-started', 'Getting started', 'Install, macOS paths, wiring mw into an AI client.'], ['authoring-guide', 'Authoring guide', 'Source formats, scripts, dialogue DSL, i18n, linting.'], ['architecture', 'Architecture', 'Layered design and the roadmap.'] ].freeze
- RESOURCES =
Read-only context an agent can pull without a tool call: the command tree (same payload as the ‘commands` tool) and the narrative docs. Each :read lambda produces the resource body on demand.
([ { uri: 'mw://introspection', name: 'mw command tree', description: 'Full command tree, options, and module surface.', mime_type: 'application/json', read: -> { JSON.pretty_generate(Esp::Introspection.command_tree) } } ] + DOC_PAGES.map do |slug, name, description| { uri: "mw://docs/#{slug}", name: name, description: description, mime_type: 'text/markdown', read: -> { File.read(File.join(Esp::ROOT, 'docs', "#{slug}.md")) } } end).freeze
- RESOURCES_BY_URI =
RESOURCES.to_h { |r| [r[:uri], r] }.freeze
- PARSE_ERROR =
JSON-RPC error codes (subset of the spec we actually emit).
-32_700
- INVALID_REQUEST =
-32_600
- METHOD_NOT_FOUND =
-32_601
- INVALID_PARAMS =
-32_602
- INTERNAL_ERROR =
-32_603
- TOOL_ERRORS =
Caller-fault errors surfaced as a tool result rather than a protocol error — the shared list Operations owns.
Esp::Operations.caller_errors
Instance Method Summary collapse
-
#initialize(input: $stdin, output: $stdout) ⇒ McpServer
constructor
A new instance of McpServer.
-
#start ⇒ Object
Read JSON-RPC messages line by line until stdin closes.
Constructor Details
#initialize(input: $stdin, output: $stdout) ⇒ McpServer
Returns a new instance of McpServer.
338 339 340 341 |
# File 'lib/esp/mcp_server.rb', line 338 def initialize(input: $stdin, output: $stdout) @input = input @output = output end |
Instance Method Details
#start ⇒ Object
Read JSON-RPC messages line by line until stdin closes. Each line is a complete message; blank lines are ignored.
345 346 347 348 349 350 351 352 353 |
# File 'lib/esp/mcp_server.rb', line 345 def start @input.each_line do |line| line = line.strip next if line.empty? response = process(line) write(response) if response end end |