Class: Esp::McpServer

Inherits:
Object
  • Object
show all
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

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

#startObject

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