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.



7
8
9
10
11
# File 'lib/jirametrics/mcp_server.rb', line 7

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



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

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



138
139
140
141
# File 'lib/jirametrics/mcp_server.rb', line 138

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)


143
144
145
146
147
148
149
150
# File 'lib/jirametrics/mcp_server.rb', line 143

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)


152
153
154
155
156
157
158
159
160
161
162
163
164
# File 'lib/jirametrics/mcp_server.rb', line 152

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



64
65
66
67
68
69
# File 'lib/jirametrics/mcp_server.rb', line 64

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



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

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



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

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



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'lib/jirametrics/mcp_server.rb', line 13

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