Class: McpServer

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

Defined Under Namespace

Classes: AgingWorkTool, CompletedWorkTool, 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

.column_name_for(board, status_id) ⇒ Object



75
76
77
# File 'lib/jirametrics/mcp_server.rb', line 75

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

.flow_efficiency_percent(issue, end_time) ⇒ Object



142
143
144
145
# File 'lib/jirametrics/mcp_server.rb', line 142

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_stalled?(bsc, ever_blocked, ever_stalled, currently_blocked, currently_stalled) ⇒ Boolean

Returns:

  • (Boolean)


147
148
149
150
151
152
153
154
# File 'lib/jirametrics/mcp_server.rb', line 147

def self.matches_blocked_stalled?(bsc, ever_blocked, ever_stalled, currently_blocked, currently_stalled)
  return false if ever_blocked && bsc.none?(&:blocked?)
  return false if ever_stalled && bsc.none?(&:stalled?)
  return false if currently_blocked && !bsc.last&.blocked?
  return false if currently_stalled && !bsc.last&.stalled?

  true
end

.matches_history?(issue, end_time, history_field, history_value, ever_blocked, ever_stalled, currently_blocked, currently_stalled) ⇒ Boolean

Returns:

  • (Boolean)


156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/jirametrics/mcp_server.rb', line 156

def self.matches_history?(issue, end_time, history_field, history_value,
                          ever_blocked, ever_stalled, currently_blocked, currently_stalled)
  return false if history_field && history_value &&
                  issue.changes.none? { |c| c.field == history_field && c.value == history_value }

  if ever_blocked || ever_stalled || currently_blocked || currently_stalled
    bsc = issue.blocked_stalled_changes(end_time: end_time)
    return false unless matches_blocked_stalled?(bsc, ever_blocked, ever_stalled,
                                                 currently_blocked, currently_stalled)
  end

  true
end

.resolve_projects(server_context, project_filter) ⇒ Object



68
69
70
71
72
73
# File 'lib/jirametrics/mcp_server.rb', line 68

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



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
# File 'lib/jirametrics/mcp_server.rb', line 79

def self.time_per_column issue, end_time
  changes = issue.status_changes
  _, stopped = issue.started_stopped_times
  effective_end = stopped && stopped < end_time ? stopped : end_time
  board = issue.board

  result = Hash.new(0.0)

  if changes.empty?
    col = column_name_for(board, issue.status.id) || issue.status.name
    duration = effective_end - issue.created
    result[col] += duration if duration.positive?
    return result
  end

  first_change = changes.first
  initial_col = column_name_for(board, first_change.old_value_id) || first_change.old_value
  initial_duration = first_change.time - issue.created
  result[initial_col] += initial_duration if initial_duration.positive?

  changes.each_cons(2) do |prev_change, next_change|
    col = column_name_for(board, prev_change.value_id) || prev_change.value
    duration = next_change.time - prev_change.time
    result[col] += duration if duration.positive?
  end

  last_change = changes.last
  final_col = column_name_for(board, last_change.value_id) || last_change.value
  final_duration = effective_end - last_change.time
  result[final_col] += final_duration if final_duration.positive?

  result
end

.time_per_status(issue, end_time) ⇒ Object



113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/jirametrics/mcp_server.rb', line 113

def self.time_per_status 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?
    duration = effective_end - issue.created
    result[issue.status.name] += duration if duration.positive?
    return result
  end

  first_change = changes.first
  initial_duration = first_change.time - issue.created
  result[first_change.old_value] += initial_duration if initial_duration.positive?

  changes.each_cons(2) do |prev_change, next_change|
    duration = next_change.time - prev_change.time
    result[prev_change.value] += duration if duration.positive?
  end

  last_change = changes.last
  final_duration = effective_end - last_change.time
  result[last_change.value] += final_duration if final_duration.positive?

  result
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