Module: Woods::MCP::Server

Defined in:
lib/woods/mcp/server.rb

Overview

Builds an MCP::Server with up to 29 tools, 2 resources, and 2 resource templates for querying Woods extraction output, managing pipelines, and collecting feedback. 14 tools are always registered; 15 more register conditionally based on wiring: 5 operator tools, 4 feedback tools, 4 snapshot tools, 1 session_trace tool, 1 Notion sync tool.

All tools are defined inline via closures over an IndexReader instance. No Rails required at runtime — reads JSON files from disk.

Examples:

server = Woods::MCP::Server.build(index_dir: "/path/to/output")
transport = MCP::Server::Transports::StdioTransport.new(server)
transport.open

Class Method Summary collapse

Class Method Details

.build(index_dir:, retriever: nil, operator: nil, feedback_store: nil, snapshot_store: nil, bootstrap_state: nil, response_format: nil, warmup: true, retriever_reloader: nil) ⇒ MCP::Server

Build a configured MCP::Server with all tools and resources.

Parameters:

  • index_dir (String)

    Path to extraction output directory

  • retriever (Woods::Retriever, nil) (defaults to: nil)

    Optional retriever for semantic search

  • operator (Hash, nil) (defaults to: nil)

    Optional operator config with :status_reporter, :error_escalator, :pipeline_guard, :pipeline_lock

  • feedback_store (Woods::Feedback::Store, nil) (defaults to: nil)

    Optional feedback store

  • bootstrap_state (Woods::MCP::BootstrapState, nil) (defaults to: nil)

    Optional state from the bootstrap flow. When provided, woods_status reports the hydrated/degraded/failed lifecycle plus the reason so operators can diagnose “why is semantic search disabled” without reading the Ruby source. Nil just means the caller didn’t go through Bootstrapper.

  • warmup (Boolean) (defaults to: true)

    Pre-populate the index reader’s caches during build, shifting first-tool-call latency to startup. Default: true. Pass false for tests or when startup time matters more than first-query latency.

Returns:

  • (MCP::Server)

    Configured server ready for transport



46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
# File 'lib/woods/mcp/server.rb', line 46

def build(index_dir:, retriever: nil, operator: nil, feedback_store: nil, snapshot_store: nil,
          bootstrap_state: nil, response_format: nil, warmup: true, retriever_reloader: nil)
  reader = IndexReader.new(index_dir)
  reader.warmup! if warmup
  config = Woods.configuration
  format = response_format || (config.respond_to?(:context_format) ? config.context_format : nil) || :markdown
  renderer = ToolResponseRenderer.for(format)
  resources = build_resources
  resource_templates = build_resource_templates

  # Lambda captured by all tool blocks for building responses.
  respond = method(:text_response)
  respond_err = method(:error_response)
  op_missing = lambda do |tool|
    error_response(
      'Pipeline operator is not configured. Pass `operator:` to Woods::MCP::Server.build ' \
      'or use Woods::MCP::Bootstrapper to wire StatusReporter, ErrorEscalator, and PipelineGuard.',
      code: :not_configured, config_key: 'operator',
      doc_link: 'docs/OPERATOR_GUIDE.md', tool: tool
    )
  end
  fb_missing = lambda do |tool|
    error_response(
      'Feedback store is not configured. Pass `feedback_store:` to Woods::MCP::Server.build ' \
      'to enable retrieval feedback capture.',
      code: :not_configured, config_key: 'feedback_store',
      doc_link: 'docs/FEEDBACK_STORE.md', tool: tool
    )
  end
  snap_missing = lambda do |tool|
    error_response(
      'Snapshot store is not configured. Set `enable_snapshots: true` in Woods.configure ' \
      'and pass `snapshot_store:` to Woods::MCP::Server.build.',
      code: :not_configured, config_key: 'enable_snapshots',
      doc_link: 'docs/TEMPORAL_SNAPSHOTS.md', tool: tool
    )
  end

  server = ::MCP::Server.new(
    name: 'woods',
    version: Woods::VERSION,
    resources: resources,
    resource_templates: resource_templates
  )

  define_lookup_tool(server, reader, respond, respond_err, renderer)
  define_search_tool(server, reader, respond, respond_err, renderer)
  define_traversal_tool(server, reader, respond, renderer,
                        name: 'dependencies',
                        description: 'Traverse forward dependencies of a unit (what it depends on). Returns a BFS tree with depth.',
                        reader_method: :traverse_dependencies,
                        render_key: :dependencies)
  define_traversal_tool(server, reader, respond, renderer,
                        name: 'dependents',
                        description: 'Traverse reverse dependencies of a unit (what depends on it). Returns a BFS tree with depth.',
                        reader_method: :traverse_dependents,
                        render_key: :dependents)
  define_structure_tool(server, reader, respond, renderer)
  define_graph_analysis_tool(server, reader, respond, renderer)
  define_domain_clusters_tool(server, reader, respond, renderer)
  define_pagerank_tool(server, reader, respond, renderer)
  define_framework_tool(server, reader, respond, renderer)
  define_recent_changes_tool(server, reader, respond, renderer)
  define_reload_tool(server, reader, respond, retriever_reloader)
  define_retrieve_tool(server, retriever, respond, respond_err)
  define_trace_flow_tool(server, reader, index_dir, respond, respond_err, renderer)
  # Conditionally register collaborator-dependent tools. Historically
  # all 15 stubs were registered unconditionally and returned
  # isError: true when the wiring was missing — that added token
  # noise to every LLM turn's tool catalog and invited the model to
  # try tools guaranteed to fail. Only register when the collaborator
  # is wired, so tools/list reflects what the server can actually do.
  define_session_trace_tool(server, reader, respond, respond_err) if session_tracer_wired?
  define_operator_tools(server, operator, respond, respond_err, op_missing) if operator
  define_feedback_tools(server, feedback_store, respond, respond_err, fb_missing) if feedback_store
  define_snapshot_tools(server, snapshot_store, respond, respond_err, snap_missing) if snapshot_store
  define_notion_sync_tool(server, reader, index_dir, respond, respond_err) if notion_wired?
  define_woods_status_tool(server, reader, retriever, index_dir, bootstrap_state, respond)
  register_resource_handler(server, reader)

  server
end

.build_status(reader:, retriever:, index_dir:, bootstrap_state: nil) ⇒ Object

Build the woods_status payload. Exposed at module level so specs (and future console/unified-server entry points) can assemble the same shape without reaching through the MCP::Server internals.

features.embedding_model / features.embedding_provider / features.vector_store prefer the ResolvedConfig captured at embed time (bootstrap_state.resolved_config, which is read back from woods.json) over Woods.configuration, whose defaults can contradict the actual provider in use. Without this, operators debugging “wrong provider” see status claiming embedding_model: “text-embedding-3-small” next to embedding_provider: “ollama” and reasonably distrust every field.



1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
# File 'lib/woods/mcp/server.rb', line 1409

def build_status(reader:, retriever:, index_dir:, bootstrap_state: nil)
  manifest = safe_manifest(reader)
  extracted_at = manifest && manifest['extracted_at']
  staleness = staleness_seconds(extracted_at)
  # Tolerate a nil Woods.configuration — specs that reset it between
  # runs can leave a transient nil window, and build_status should
  # still produce a readable payload during that window.
  config = Woods.configuration || Woods::Configuration.new
  resolved = bootstrap_state&.resolved_config

  {
    ready: manifest && !manifest['counts'].to_h.empty?,
    server: {
      name: 'woods',
      version: Woods::VERSION,
      index_dir: index_dir.to_s
    },
    index: index_section(manifest, extracted_at, staleness, index_dir),
    retriever: {
      configured: !retriever.nil?,
      class: retriever&.class&.name
    },
    bootstrap: bootstrap_state&.to_h,
    features: features_from(config, resolved)
  }
end