Class: McpServer

Inherits:
Object
  • Object
show all
Defined in:
lib/jirametrics/mcp_server.rb

Defined Under Namespace

Classes: AgingWorkTool, CompletedWorkTool, HistoryFilter, ListProjectsTool, NotYetStartedTool, StatusTimeAnalysisTool

Constant Summary collapse

HISTORY_FILTER_SCHEMA =
{
  history_field: {
    type: 'string',
    description: 'When combined with history_value, only return issues where this field ever had that value ' \
                 '(e.g. "priority", "status"). Both history_field and history_value must be provided together.'
  },
  history_value: {
    type: 'string',
    description: 'The value to look for in the change history of history_field (e.g. "Highest", "Done").'
  },
  ever_blocked: {
    type: 'boolean',
    description: 'When true, only return issues that were ever blocked. Blocked includes flagged items, ' \
                 'issues in blocked statuses, and blocking issue links.'
  },
  ever_stalled: {
    type: 'boolean',
    description: 'When true, only return issues that were ever stalled. Stalled means the issue sat ' \
                 'inactive for longer than the stalled threshold, or entered a stalled status.'
  },
  currently_blocked: {
    type: 'boolean',
    description: 'When true, only return issues that are currently blocked (as of the data end date).'
  },
  currently_stalled: {
    type: 'boolean',
    description: 'When true, only return issues that are currently stalled (as of the data end date).'
  }
}.freeze
ALIASES =

Alternative tool names used by AI agents other than Claude. Each entry maps an alias name to the canonical tool class it delegates to. The alias inherits the canonical tool's schema and call behaviour automatically. To add a new alias, append one line: 'alias_name' => CanonicalToolClass

{
  'board_list' => ListProjectsTool
}.freeze

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(projects:, aggregates: {}, timezone_offset: '+00:00') ⇒ McpServer

Returns a new instance of McpServer.



11
12
13
14
15
# File 'lib/jirametrics/mcp_server.rb', line 11

def initialize projects:, aggregates: {}, timezone_offset: '+00:00'
  @projects = projects
  @aggregates = aggregates
  @timezone_offset = timezone_offset
end

Class Method Details

.accumulate_spans(issue, end_time) ⇒ Object

Walks an issue's status timeline as spans (created -> first change, between consecutive changes, last change -> effective end) and sums each span's duration under a bucket name. The bucket for a span is chosen by the block, given the (status_name, status_id) that describe the status held during that span: for the opening span that's the first change's old value, and thereafter the change's value.



142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
# File 'lib/jirametrics/mcp_server.rb', line 142

def self.accumulate_spans issue, end_time
  changes = issue.status_changes
  _, stopped = issue.started_stopped_times
  effective_end = stopped && stopped < end_time ? stopped : end_time
  result = Hash.new(0.0)

  if changes.empty?
    add_span(result, yield(issue.status.name, issue.status.id), effective_end - issue.created)
    return result
  end

  first_change = changes.first
  add_span(result, yield(first_change.old_value, first_change.old_value_id), first_change.time - issue.created)

  changes.each_cons(2) do |prev_change, next_change|
    add_span(result, yield(prev_change.value, prev_change.value_id), next_change.time - prev_change.time)
  end

  last_change = changes.last
  add_span(result, yield(last_change.value, last_change.value_id), effective_end - last_change.time)
  result
end

.add_span(result, name, duration) ⇒ Object



165
166
167
# File 'lib/jirametrics/mcp_server.rb', line 165

def self.add_span result, name, duration
  result[name] += duration if duration.positive?
end

.collect_rows(server_context, project) ⇒ Object

Collects the non-nil rows a block returns for each allowed issue (the row-oriented tools).



114
115
116
117
118
119
120
121
# File 'lib/jirametrics/mcp_server.rb', line 114

def self.collect_rows server_context, project
  rows = []
  each_allowed_issue(server_context, project) do |issue, project_name, project_data|
    row = yield issue, project_name, project_data
    rows << row if row
  end
  rows
end

.column_name_for(board, status_id) ⇒ Object



96
97
98
# File 'lib/jirametrics/mcp_server.rb', line 96

def self.column_name_for board, status_id
  board.visible_columns.find { |c| c.status_ids.include?(status_id) }&.name
end

.each_allowed_issue(server_context, project) ⇒ Object

The shared iteration spine of every query tool: resolve the project filter, skip disallowed projects, and yield each surviving issue with its project name and data.



102
103
104
105
106
107
108
109
110
111
# File 'lib/jirametrics/mcp_server.rb', line 102

def self.each_allowed_issue server_context, project
  allowed_projects = resolve_projects(server_context, project)
  server_context[:projects].each do |project_name, project_data|
    next if allowed_projects && !allowed_projects.include?(project_name)

    project_data[:issues].each do |issue|
      yield issue, project_name, project_data
    end
  end
end

.flow_efficiency_percent(issue, end_time) ⇒ Object



178
179
180
181
# File 'lib/jirametrics/mcp_server.rb', line 178

def self.flow_efficiency_percent issue, end_time
  active_time, total_time = issue.flow_efficiency_numbers(end_time: end_time)
  total_time.positive? ? (active_time / total_time * 100).round(1) : nil
end

.matches_blocked?(bsc, filter) ⇒ Boolean

Returns:

  • (Boolean)


183
184
185
186
187
188
# File 'lib/jirametrics/mcp_server.rb', line 183

def self.matches_blocked?(bsc, filter)
  return false if filter.ever_blocked && bsc.none?(&:blocked?)
  return false if filter.currently_blocked && !bsc.last&.blocked?

  true
end

.matches_blocked_stalled?(bsc, filter) ⇒ Boolean

Returns:

  • (Boolean)


197
198
199
# File 'lib/jirametrics/mcp_server.rb', line 197

def self.matches_blocked_stalled?(bsc, filter)
  matches_blocked?(bsc, filter) && matches_stalled?(bsc, filter)
end

.matches_current_state?(issue, current_status, current_column) ⇒ Boolean

Shared current-state filter for the aging and not-yet-started tools.

Returns:

  • (Boolean)


131
132
133
134
135
136
# File 'lib/jirametrics/mcp_server.rb', line 131

def self.matches_current_state? issue, current_status, current_column
  return false if current_status && issue.status.name != current_status
  return false if current_column && column_name_for(issue.board, issue.status.id) != current_column

  true
end

.matches_history?(issue, end_time, filter) ⇒ Boolean

Returns:

  • (Boolean)


201
202
203
204
205
206
207
# File 'lib/jirametrics/mcp_server.rb', line 201

def self.matches_history?(issue, end_time, filter)
  return false if filter.history? && !filter.matches_change?(issue.changes)
  return false if filter.blocked_stalled? &&
                  !matches_blocked_stalled?(issue.blocked_stalled_changes(end_time: end_time), filter)

  true
end

.matches_stalled?(bsc, filter) ⇒ Boolean

Returns:

  • (Boolean)


190
191
192
193
194
195
# File 'lib/jirametrics/mcp_server.rb', line 190

def self.matches_stalled?(bsc, filter)
  return false if filter.ever_stalled && bsc.none?(&:stalled?)
  return false if filter.currently_stalled && !bsc.last&.stalled?

  true
end

.render_rows(rows, empty:, &block) ⇒ Object

Renders already-sorted rows as a text Response: the empty-message when there are none, otherwise each row formatted by the block and newline-joined.



125
126
127
128
# File 'lib/jirametrics/mcp_server.rb', line 125

def self.render_rows rows, empty:, &block
  text = rows.empty? ? empty : rows.map(&block).join("\n")
  MCP::Tool::Response.new([{ type: 'text', text: text }])
end

.resolve_projects(server_context, project_filter) ⇒ Object



89
90
91
92
93
94
# File 'lib/jirametrics/mcp_server.rb', line 89

def self.resolve_projects server_context, project_filter
  return nil if project_filter.nil?

  aggregates = server_context[:aggregates] || {}
  aggregates[project_filter] || [project_filter]
end

.time_per_column(issue, end_time) ⇒ Object



169
170
171
172
# File 'lib/jirametrics/mcp_server.rb', line 169

def self.time_per_column issue, end_time
  board = issue.board
  accumulate_spans(issue, end_time) { |name, id| column_name_for(board, id) || name }
end

.time_per_status(issue, end_time) ⇒ Object



174
175
176
# File 'lib/jirametrics/mcp_server.rb', line 174

def self.time_per_status issue, end_time
  accumulate_spans(issue, end_time) { |name, _id| name }
end

Instance Method Details

#runObject



17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
# File 'lib/jirametrics/mcp_server.rb', line 17

def run
  canonical_tools = [ListProjectsTool, AgingWorkTool, CompletedWorkTool, NotYetStartedTool, StatusTimeAnalysisTool]
  alias_tools = ALIASES.map do |alias_name, canonical|
    schema = canonical.input_schema
    Class.new(canonical) do
      tool_name alias_name
      input_schema schema
    end
  end

  server = MCP::Server.new(
    name: 'jirametrics',
    version: Gem.loaded_specs['jirametrics']&.version&.to_s || '0.0.0',
    tools: canonical_tools + alias_tools,
    server_context: { projects: @projects, aggregates: @aggregates, timezone_offset: @timezone_offset }
  )

  transport = MCP::Server::Transports::StdioTransport.new(server)
  transport.open
end