Module: Html2rss::MCP::Contract

Defined in:
lib/html2rss/mcp/contract.rb

Overview

Published MCP contract: strategy enum, input/output schemas, listing annotations, and the single compact JSON envelope response.

Defined Under Namespace

Classes: UnpublishedRequestError

Constant Summary collapse

STRATEGIES =

Published MCP request strategies (excludes local_file).

%w[auto default httpx botasaurus].freeze
MIGRATION_STRATEGIES =

Accepted migration strategies retained for backwards compatibility.

%w[faraday].freeze
ALL_ACCEPTED_STRATEGIES =

Complete set of strategies accepted by assert_published_request!.

(STRATEGIES + MIGRATION_STRATEGIES).freeze
MCP_CONTRACT_VERSION =

Bump when tool names, required inputs, or envelope semantics change (independent of gem VERSION).

2
URL_PROPERTY =

JSON Schema property for a source page URL.

{
  type: 'string',
  format: 'uri',
  description: 'Source page URL'
}.freeze
STRATEGY_PROPERTY =

JSON Schema property for scrape/capture strategy.

{
  type: 'string',
  enum: STRATEGIES,
  default: 'auto',
  description: 'Request strategy (auto runs default → botasaurus fallback chain)'
}.freeze
INSPECT_STRATEGY_PROPERTY =

JSON Schema property for inspect strategy (auto stays on default).

STRATEGY_PROPERTY.merge(
  description: 'Request strategy (auto uses default for cheap diagnostics; pin botasaurus when needed)'
).freeze
XOR_ONE_OF =

JSON Schema oneOf requiring exactly one of config or yaml.

[
  { required: %w[config], not: { required: %w[yaml] } }.freeze,
  { required: %w[yaml], not: { required: %w[config] } }.freeze
].freeze
CONFIG_XOR_PROPERTIES =

JSON Schema properties for the config/yaml XOR pair.

{
  config: {
    type: 'object',
    description: 'Feed configuration hash with channel and selectors (XOR yaml)'
  }.freeze,
  yaml: {
    type: 'string',
    pattern: '\\S',
    description: 'Feed configuration YAML string (XOR config)'
  }.freeze
}.freeze
CONFIG_XOR_SCHEMA =

Input schema for validate (config XOR yaml).

{
  type: 'object',
  properties: CONFIG_XOR_PROPERTIES,
  oneOf: XOR_ONE_OF
}.freeze
APPLY_INPUT_SCHEMA =

Input schema for apply (required URL plus config XOR yaml).

{
  type: 'object',
  properties: { url: URL_PROPERTY, **CONFIG_XOR_PROPERTIES }.freeze,
  required: %w[url],
  oneOf: XOR_ONE_OF
}.freeze
TEST_INPUT_SCHEMA =

Input schema for test.

{
  type: 'object',
  properties: {
    **CONFIG_XOR_PROPERTIES,
    min_items: { type: 'integer', description: 'Minimum required items (default: 1)', default: 1 },
    strict_quality: {
      type: 'boolean',
      description: 'Fail when ship-quality audit thresholds are exceeded (default: false)',
      default: false
    },
    compare_enhance: {
      type: 'boolean',
      description: 'Diagnostic: compare extraction with enhance on vs off on cached HTML (default: false)',
      default: false
    },
    strategy: STRATEGY_PROPERTY
  }.freeze,
  oneOf: XOR_ONE_OF
}.freeze
SCRAPE_INPUT_SCHEMA =

Input schema for scrape.

{
  type: 'object',
  properties: {
    url: URL_PROPERTY,
    strategy: STRATEGY_PROPERTY,
    limit: { type: 'integer', description: 'Max articles to keep (default 25)', default: 25 },
    items_selector: { type: 'string', description: 'Optional CSS selector hint for items' }
  }.freeze,
  required: %w[url]
}.freeze
INSPECT_INPUT_SCHEMA =

Input schema for inspect.

{
  type: 'object',
  properties: { url: URL_PROPERTY, strategy: INSPECT_STRATEGY_PROPERTY }.freeze,
  required: %w[url]
}.freeze
RECON_INPUT_SCHEMA =

Input schema for recon.

INSPECT_INPUT_SCHEMA
CAPTURE_INPUT_SCHEMA =

Input schema for capture.

{
  type: 'object',
  properties: {
    url: URL_PROPERTY,
    strategy: STRATEGY_PROPERTY,
    items_selector: { type: 'string', description: 'Optional CSS selector hint for items' },
    force: { type: 'boolean', description: 'Bypass native feed check', default: false },
    topics: {
      type: 'array',
      items: { type: 'string' },
      description: 'Directory topics override'
    }.freeze,
    title: { type: 'string', description: 'Channel title override' },
    summary: { type: 'string', description: 'Directory summary override' },
    enhance: { type: 'boolean', description: 'Force enhance on or off' },
    limit: { type: 'integer', description: 'Max articles to keep' },
    max_redirects: { type: 'integer', description: 'Optional redirect limit override' },
    max_requests: { type: 'integer', description: 'Optional request budget override' }
  }.freeze,
  required: %w[url]
}.freeze
BATCH_SCRAPE_INPUT_SCHEMA =

Input schema for batch_scrape.

batch_urls_input_schema(
  urls_description: 'List of page URLs to scrape (1..25)',
  strategy_property: STRATEGY_PROPERTY,
  extra_properties: {
    limit: { type: 'integer', description: 'Max articles per URL to keep (default 10)', default: 10 }
  }
).freeze
BATCH_INSPECT_INPUT_SCHEMA =

Input schema for batch_inspect.

batch_urls_input_schema(
  urls_description: 'List of page URLs to inspect (1..25)',
  strategy_property: INSPECT_STRATEGY_PROPERTY
).freeze
BATCH_RECON_INPUT_SCHEMA =

Input schema for batch_recon.

BATCH_INSPECT_INPUT_SCHEMA
ANNOTATIONS_OPEN_WORLD =

Tool annotations for open-world read-only tools.

{
  read_only_hint: true,
  destructive_hint: false,
  idempotent_hint: true,
  open_world_hint: true
}.freeze
ANNOTATIONS_VALIDATE =

Tool annotations for validate (closed world).

ANNOTATIONS_OPEN_WORLD.merge(open_world_hint: false).freeze
TITLES =

Human titles for tools/list.

{
  scrape: 'Scrape',
  inspect: 'Inspect',
  recon: 'Recon',
  batch_scrape: 'Batch scrape',
  batch_inspect: 'Batch inspect',
  batch_recon: 'Batch recon',
  capture: 'Capture',
  validate: 'Validate',
  apply: 'Apply',
  test: 'Test'
}.freeze
CATALOG_ENTRY_LINE =

This constant is part of a private API. You should avoid using this constant if possible, as it may be removed or be changed in the future.

lambda do |entry|
  schema = entry.fetch(:input_schema)
  required = Array(schema[:required]).sort.join(',')
  one_of = Array(schema[:oneOf]).map { |branch| Array(branch[:required]).sort.join('+') }.sort.join('|')
  [entry.fetch(:name), required, one_of].reject(&:empty?).join(':')
end.freeze

Class Method Summary collapse

Class Method Details

.assert_published_request!(config) ⇒ void

This method returns an undefined value.

Rejects unpublished MCP request adapters so apply/validate cannot File.read arbitrary paths. CLI and Config still allow local_file.

Parameters:

  • config (Hash)

Raises:



286
287
288
289
290
291
292
293
294
295
# File 'lib/html2rss/mcp/contract.rb', line 286

def assert_published_request!(config)
  strategy = config[:strategy]
  unless strategy.nil? || ALL_ACCEPTED_STRATEGIES.include?(strategy.to_s)
    raise UnpublishedRequestError,
          "MCP does not accept strategy #{strategy} (published: #{STRATEGIES.join(', ')})"
  end
  return unless config.dig(:request, :local_file_path)

  raise UnpublishedRequestError, 'MCP does not accept request.local_file_path'
end

.batch_urls_input_schema(urls_description:, strategy_property:, extra_properties: {}) ⇒ Hash

Shared JSON Schema for batch URL tools (+batch_scrape+, batch_inspect, batch_recon).

Parameters:

  • urls_description (String)

    description for the urls array property

  • strategy_property (Hash)

    strategy JSON Schema property

  • extra_properties (Hash) (defaults to: {})

    additional tool-specific properties (e.g. limit on scrape)

Returns:

  • (Hash)


152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
# File 'lib/html2rss/mcp/contract.rb', line 152

def self.batch_urls_input_schema(urls_description:, strategy_property:, extra_properties: {}) # rubocop:disable Metrics/MethodLength
  {
    type: 'object',
    properties: {
      urls: {
        type: 'array',
        items: URL_PROPERTY,
        minItems: 1,
        maxItems: 25,
        description: urls_description
      }.freeze,
      strategy: strategy_property,
      concurrency: {
        type: 'integer',
        description: 'Max parallel worker threads (1..10, default: 5)',
        default: 5
      },
      **extra_properties
    }.freeze,
    required: %w[urls]
  }
end

.catalog_fingerprintString

Stable fingerprint of published tools, required keys, and oneOf branches. Clients compare against a cached tools/list to detect stale catalogs. Bump MCP_CONTRACT_VERSION for envelope or breaking wire semantics only.

Returns:

  • (String)

    16-char hex digest prefix



241
242
243
244
# File 'lib/html2rss/mcp/contract.rb', line 241

def catalog_fingerprint
  lines = Server::Tools::TOOLS.sort_by { |entry| entry.fetch(:name) }.map(&CATALOG_ENTRY_LINE)
  Digest::SHA256.hexdigest(lines.join("\n")).slice(0, 16)
end

.catalog_toolsArray<String>

Canonical MCP tool names in alphabetical order (same set as tools/list).

Returns:

  • (Array<String>)


231
232
233
# File 'lib/html2rss/mcp/contract.rb', line 231

def catalog_tools
  Server::Tools::TOOLS.map { |entry| entry.fetch(:name) }.sort
end

.output_schemaHash

Envelope JSON Schema. Built lazily so Zeitwerk can load Contract before Outcome.

Returns:

  • (Hash)


250
251
252
253
254
255
256
257
258
259
260
261
262
# File 'lib/html2rss/mcp/contract.rb', line 250

def output_schema # rubocop:disable Metrics/MethodLength -- schema document is one hash
  {
    type: 'object',
    additionalProperties: false,
    required: %w[ok next_step guidance payload],
    properties: {
      ok: { type: 'boolean' },
      next_step: { type: 'string', enum: Outcome::NextStep::NAMES.map(&:to_s) },
      guidance: { type: 'string' },
      payload: { type: 'object' }
    }
  }
end

.response(outcome) ⇒ ::MCP::Tool::Response

One envelope Hash, one compact JSON body, no _meta.

Parameters:

Returns:

  • (::MCP::Tool::Response)


269
270
271
272
273
274
275
276
# File 'lib/html2rss/mcp/contract.rb', line 269

def response(outcome)
  wire = outcome.to_h
  ::MCP::Tool::Response.new(
    [{ type: 'text', text: JSON.generate(wire) }],
    error: !outcome.ok,
    structured_content: wire
  )
end