koalr-mcp 0.2.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -1,6 +1,6 @@
1
1
  const API_URL = process.env['KOALR_API_URL'] ?? 'https://api.koalr.com';
2
- const API_KEY = process.env['KOALR_API_KEY'] ?? '';
3
- export async function apiGet(path, params) {
2
+ const DEFAULT_API_KEY = process.env['KOALR_API_KEY'] ?? '';
3
+ export async function apiGet(path, params, apiKey) {
4
4
  const url = new URL(`${API_URL}${path}`);
5
5
  if (params) {
6
6
  Object.entries(params).forEach(([k, v]) => {
@@ -11,7 +11,7 @@ export async function apiGet(path, params) {
11
11
  }
12
12
  const res = await fetch(url.toString(), {
13
13
  headers: {
14
- Authorization: `Bearer ${API_KEY}`,
14
+ Authorization: `Bearer ${apiKey ?? DEFAULT_API_KEY}`,
15
15
  'Content-Type': 'application/json',
16
16
  },
17
17
  });
@@ -21,12 +21,12 @@ export async function apiGet(path, params) {
21
21
  }
22
22
  return res.json();
23
23
  }
24
- export async function apiPost(path, body) {
24
+ export async function apiPost(path, body, apiKey) {
25
25
  const url = new URL(`${API_URL}${path}`);
26
26
  const res = await fetch(url.toString(), {
27
27
  method: 'POST',
28
28
  headers: {
29
- Authorization: `Bearer ${API_KEY}`,
29
+ Authorization: `Bearer ${apiKey ?? DEFAULT_API_KEY}`,
30
30
  'Content-Type': 'application/json',
31
31
  },
32
32
  body: JSON.stringify(body),
package/dist/index.js CHANGED
@@ -17,23 +17,44 @@ async function startHttp() {
17
17
  const transports = new Map();
18
18
  const httpServer = createHttpServer(async (req, res) => {
19
19
  const url = new URL(req.url ?? '/', `http://localhost:${port}`);
20
- // Health check
20
+ // CORS — required for browser-based MCP clients (Claude.ai web)
21
+ res.setHeader('Access-Control-Allow-Origin', '*');
22
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
23
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Mcp-Session-Id');
24
+ res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
25
+ if (req.method === 'OPTIONS') {
26
+ res.writeHead(204);
27
+ res.end();
28
+ return;
29
+ }
30
+ // Health check (no auth required)
21
31
  if (req.method === 'GET' && url.pathname === '/health') {
22
32
  res.writeHead(200, { 'Content-Type': 'application/json' });
23
33
  res.end(JSON.stringify({ status: 'ok', server: 'koalr-mcp' }));
24
34
  return;
25
35
  }
26
- // MCP endpoint
27
- if (url.pathname === '/mcp') {
36
+ // Route: /mcp?apiKey=koalr_xxx — Smithery gateway style (query param)
37
+ // Route: /{apiKey}/mcp path-based per-user endpoint
38
+ // Route: /mcp — env-var key (local/self-hosted)
39
+ let sessionApiKey;
40
+ const pathParts = url.pathname.split('/').filter(Boolean);
41
+ const isMcpEndpoint = (pathParts.length === 1 && pathParts[0] === 'mcp') ||
42
+ (pathParts.length === 2 && pathParts[1] === 'mcp');
43
+ // API key precedence: query param > path segment > env var
44
+ if (url.searchParams.has('apiKey')) {
45
+ sessionApiKey = url.searchParams.get('apiKey') ?? undefined;
46
+ }
47
+ else if (pathParts.length === 2 && pathParts[1] === 'mcp') {
48
+ sessionApiKey = pathParts[0];
49
+ }
50
+ if (isMcpEndpoint) {
28
51
  if (req.method === 'POST') {
29
- // Check for existing session or create new one
30
52
  const sessionId = req.headers['mcp-session-id'] ?? undefined;
31
53
  let transport;
32
54
  if (sessionId && transports.has(sessionId)) {
33
55
  transport = transports.get(sessionId);
34
56
  }
35
57
  else if (!sessionId) {
36
- // New session
37
58
  const newSessionId = randomUUID();
38
59
  transport = new StreamableHTTPServerTransport({
39
60
  sessionIdGenerator: () => newSessionId,
@@ -42,7 +63,7 @@ async function startHttp() {
42
63
  transport.onclose = () => {
43
64
  transports.delete(newSessionId);
44
65
  };
45
- const mcpServer = createServer();
66
+ const mcpServer = createServer(sessionApiKey);
46
67
  await mcpServer.connect(transport);
47
68
  }
48
69
  if (!transport) {
@@ -70,6 +91,7 @@ async function startHttp() {
70
91
  });
71
92
  httpServer.listen(port, () => {
72
93
  process.stderr.write(`Koalr MCP server running on http://localhost:${port}/mcp\n`);
94
+ process.stderr.write(`Hosted mode: http://localhost:${port}/{apiKey}/mcp\n`);
73
95
  });
74
96
  }
75
97
  if (mode === 'http') {
package/dist/server.js CHANGED
@@ -10,22 +10,22 @@ import { registerIncidentTools } from './tools/incidents.js';
10
10
  import { registerAiAdoptionTools } from './tools/ai-adoption.js';
11
11
  import { registerOrganizationTools } from './tools/organization.js';
12
12
  import { registerDeployRiskTools } from './tools/deploy-risk.js';
13
- export function createServer() {
13
+ export function createServer(apiKey) {
14
14
  const server = new McpServer({
15
15
  name: 'koalr',
16
- version: '0.1.0',
16
+ version: '0.3.0',
17
17
  });
18
- // Register all tool groups
19
- registerDoraTools(server);
20
- registerPullRequestTools(server);
21
- registerTeamTools(server);
22
- registerRepositoryTools(server);
23
- registerDeveloperTools(server);
24
- registerSearchTools(server);
25
- registerCoverageTools(server);
26
- registerIncidentTools(server);
27
- registerAiAdoptionTools(server);
28
- registerOrganizationTools(server);
29
- registerDeployRiskTools(server);
18
+ // Register all tool groups (apiKey is threaded through for hosted HTTP mode)
19
+ registerDoraTools(server, apiKey);
20
+ registerPullRequestTools(server, apiKey);
21
+ registerTeamTools(server, apiKey);
22
+ registerRepositoryTools(server, apiKey);
23
+ registerDeveloperTools(server, apiKey);
24
+ registerSearchTools(server, apiKey);
25
+ registerCoverageTools(server, apiKey);
26
+ registerIncidentTools(server, apiKey);
27
+ registerAiAdoptionTools(server, apiKey);
28
+ registerOrganizationTools(server, apiKey);
29
+ registerDeployRiskTools(server, apiKey);
30
30
  return server;
31
31
  }
@@ -1,8 +1,8 @@
1
1
  import { z } from 'zod/v3';
2
2
  import { apiGet, formatResult } from '../client.js';
3
- export function registerAiAdoptionTools(server) {
4
- server.tool('get_ai_adoption_summary', 'Get AI coding tool adoption metrics including GitHub Copilot acceptance rate, Cursor active users, AI-generated code percentage, and suggestions per developer. Use this to understand how the team is using AI coding assistants.', {}, async () => {
5
- const data = await apiGet('/api/v1/copilot');
3
+ export function registerAiAdoptionTools(server, apiKey) {
4
+ server.tool('get_ai_adoption_summary', 'Get AI coding tool adoption metrics including GitHub Copilot acceptance rate, Cursor active users, AI-generated code percentage, and suggestions per developer. Use this to understand how the team is using AI coding assistants.', {}, { readOnlyHint: true }, async () => {
5
+ const data = await apiGet('/api/v1/copilot', undefined, apiKey);
6
6
  return { content: [{ type: 'text', text: formatResult(data) }] };
7
7
  });
8
8
  server.tool('get_ai_adoption_trend', 'Get the trend of AI tool adoption over time showing weekly active users, acceptance rates, and code attribution percentages. Use this to track whether AI tool adoption is growing or declining.', {
@@ -10,11 +10,11 @@ export function registerAiAdoptionTools(server) {
10
10
  .number()
11
11
  .optional()
12
12
  .describe('Number of days to look back for trend data (default: 90)'),
13
- }, async ({ days }) => {
13
+ }, { readOnlyHint: true }, async ({ days }) => {
14
14
  const params = {};
15
15
  if (days)
16
16
  params['days'] = String(days);
17
- const data = await apiGet('/ai-analytics/trend', params);
17
+ const data = await apiGet('/ai-analytics/trend', params, apiKey);
18
18
  return { content: [{ type: 'text', text: formatResult(data) }] };
19
19
  });
20
20
  }
@@ -1,16 +1,16 @@
1
1
  import { z } from 'zod/v3';
2
2
  import { apiGet, formatResult } from '../client.js';
3
- export function registerCoverageTools(server) {
3
+ export function registerCoverageTools(server, apiKey) {
4
4
  server.tool('get_coverage_summary', 'Get test coverage summary per repository showing overall coverage percentage, lines covered, and coverage trend. Use this to understand testing health and identify repositories with low coverage.', {
5
5
  repositoryId: z
6
6
  .string()
7
7
  .optional()
8
8
  .describe('Filter to a specific repository by ID. Omit to see all repositories.'),
9
- }, async ({ repositoryId }) => {
9
+ }, { readOnlyHint: true }, async ({ repositoryId }) => {
10
10
  const params = {};
11
11
  if (repositoryId)
12
12
  params['repositoryId'] = repositoryId;
13
- const data = await apiGet('/coverage', params);
13
+ const data = await apiGet('/coverage', params, apiKey);
14
14
  return { content: [{ type: 'text', text: formatResult(data) }] };
15
15
  });
16
16
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod/v3';
2
2
  import { apiPost, formatResult } from '../client.js';
3
- export function registerDeployRiskTools(server) {
3
+ export function registerDeployRiskTools(server, apiKey) {
4
4
  server.tool('score_pr_for_deploy_risk', 'Score a specific pull request for deployment risk using Koalr\'s 36-signal model. Returns a 0-100 risk score with a detailed factor breakdown covering change entropy, DDL migration detection, author file expertise, PR size, CODEOWNERS violations, and more. Use this to answer "How risky is this PR?" or "Should we merge this before the release?".', {
5
5
  owner: z.string().describe('GitHub repository owner (org or user). Example: "acme".'),
6
6
  repo: z.string().describe('Repository name. Example: "api-service".'),
@@ -24,7 +24,7 @@ export function registerDeployRiskTools(server) {
24
24
  .boolean()
25
25
  .optional()
26
26
  .describe('Whether the PR has at least one approving review.'),
27
- }, async ({ owner, repo, prNumber, sha, title, body, authorLogin, additions, deletions, changedFiles, files, hasReview, hasApproval, }) => {
27
+ }, { readOnlyHint: true }, async ({ owner, repo, prNumber, sha, title, body, authorLogin, additions, deletions, changedFiles, files, hasReview, hasApproval, }) => {
28
28
  const data = await apiPost('/api/v1/pr-risk/score', {
29
29
  repo: `${owner}/${repo}`,
30
30
  prNumber,
@@ -38,7 +38,7 @@ export function registerDeployRiskTools(server) {
38
38
  files: files ?? [],
39
39
  hasReview: hasReview ?? false,
40
40
  hasApproval: hasApproval ?? false,
41
- });
41
+ }, apiKey);
42
42
  return { content: [{ type: 'text', text: formatResult(data) }] };
43
43
  });
44
44
  }
@@ -1,10 +1,10 @@
1
1
  import { z } from 'zod/v3';
2
2
  import { apiGet, formatResult } from '../client.js';
3
- export function registerDeveloperTools(server) {
3
+ export function registerDeveloperTools(server, apiKey) {
4
4
  server.tool('get_developer', "Get metrics for an individual developer including their PR throughput, review activity, average cycle time, and code contributions. Use this when asked about a specific engineer's activity or productivity.", {
5
5
  login: z.string().describe('GitHub username / login of the developer (e.g. "jsmith")'),
6
- }, async ({ login }) => {
7
- const data = await apiGet('/developer-profiles', { login });
6
+ }, { readOnlyHint: true }, async ({ login }) => {
7
+ const data = await apiGet('/developer-profiles', { login }, apiKey);
8
8
  return { content: [{ type: 'text', text: formatResult(data) }] };
9
9
  });
10
10
  server.tool('list_top_contributors', 'List the most active contributors ranked by commits and PRs merged over a time window. Use this to identify key contributors, bus-factor risks, or to recognize top performers.', {
@@ -13,13 +13,13 @@ export function registerDeveloperTools(server) {
13
13
  .number()
14
14
  .optional()
15
15
  .describe('Number of contributors to return (default: 10, max: 50)'),
16
- }, async ({ days, limit }) => {
16
+ }, { readOnlyHint: true }, async ({ days, limit }) => {
17
17
  const params = {};
18
18
  if (days)
19
19
  params['days'] = String(days);
20
20
  if (limit)
21
21
  params['limit'] = String(limit);
22
- const data = await apiGet('/developer-profiles/top-contributors', params);
22
+ const data = await apiGet('/developer-profiles/top-contributors', params, apiKey);
23
23
  return { content: [{ type: 'text', text: formatResult(data) }] };
24
24
  });
25
25
  }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod/v3';
2
2
  import { apiGet, formatResult } from '../client.js';
3
- export function registerDoraTools(server) {
3
+ export function registerDoraTools(server, apiKey) {
4
4
  server.tool('get_dora_summary', 'Get DORA metrics (deploy frequency, lead time for changes, change failure rate, MTTR) for the organization or a specific team. Use this to understand overall engineering delivery performance and reliability.', {
5
5
  teamId: z.string().optional().describe('Team ID to filter metrics for a specific team'),
6
6
  from: z
@@ -11,7 +11,7 @@ export function registerDoraTools(server) {
11
11
  .string()
12
12
  .optional()
13
13
  .describe('End date as ISO string (e.g. 2024-01-31). Defaults to today.'),
14
- }, async ({ teamId, from, to }) => {
14
+ }, { readOnlyHint: true }, async ({ teamId, from, to }) => {
15
15
  const params = {};
16
16
  if (teamId)
17
17
  params['teamId'] = teamId;
@@ -19,7 +19,7 @@ export function registerDoraTools(server) {
19
19
  params['from'] = from;
20
20
  if (to)
21
21
  params['to'] = to;
22
- const data = await apiGet('/api/v1/metrics/dora', params);
22
+ const data = await apiGet('/api/v1/metrics/dora', params, apiKey);
23
23
  return { content: [{ type: 'text', text: formatResult(data) }] };
24
24
  });
25
25
  server.tool('get_dora_trend', 'Get DORA metric trend over time to see how deployment frequency, lead time, change failure rate, or MTTR has changed week over week. Useful for spotting regressions or improvements.', {
@@ -31,13 +31,13 @@ export function registerDoraTools(server) {
31
31
  .optional()
32
32
  .describe('Number of days to look back (default: 90). Max recommended: 365.'),
33
33
  teamId: z.string().optional().describe('Team ID to filter metrics for a specific team'),
34
- }, async ({ metric, days, teamId }) => {
34
+ }, { readOnlyHint: true }, async ({ metric, days, teamId }) => {
35
35
  const params = { metric };
36
36
  if (days)
37
37
  params['days'] = String(days);
38
38
  if (teamId)
39
39
  params['teamId'] = teamId;
40
- const data = await apiGet('/api/v1/metrics/dora/trend', params);
40
+ const data = await apiGet('/api/v1/metrics/dora/trend', params, apiKey);
41
41
  return { content: [{ type: 'text', text: formatResult(data) }] };
42
42
  });
43
43
  }
@@ -1,13 +1,13 @@
1
1
  import { z } from 'zod/v3';
2
2
  import { apiGet, formatResult } from '../client.js';
3
- export function registerIncidentTools(server) {
3
+ export function registerIncidentTools(server, apiKey) {
4
4
  server.tool('list_recent_incidents', 'List recent production incidents from PagerDuty or OpsGenie with their severity, MTTR (mean time to recovery), and affected services. Use this to understand reliability posture or investigate a recent outage.', {
5
5
  days: z
6
6
  .number()
7
7
  .optional()
8
8
  .describe('Number of days to look back for incidents (default: 30)'),
9
9
  limit: z.number().optional().describe('Maximum number of incidents to return (default: 20)'),
10
- }, async ({ days, limit }) => {
10
+ }, { readOnlyHint: true }, async ({ days, limit }) => {
11
11
  const params = {};
12
12
  if (days) {
13
13
  const from = new Date(Date.now() - days * 86400000).toISOString();
@@ -15,7 +15,7 @@ export function registerIncidentTools(server) {
15
15
  }
16
16
  if (limit)
17
17
  params['take'] = String(limit);
18
- const data = await apiGet('/api/v1/incidents', params);
18
+ const data = await apiGet('/api/v1/incidents', params, apiKey);
19
19
  return { content: [{ type: 'text', text: formatResult(data) }] };
20
20
  });
21
21
  }
@@ -1,11 +1,11 @@
1
1
  import { z } from 'zod/v3';
2
2
  import { apiGet, formatResult } from '../client.js';
3
- export function registerOrganizationTools(server) {
4
- server.tool('get_org_health', 'Get a comprehensive organization health snapshot: DORA performance tier (Elite/High/Medium/Low), cycle time percentile vs industry benchmarks, test coverage percentage, number of active teams, and incident rate. Use this as the first tool to get a high-level picture of engineering health.', {}, async () => {
3
+ export function registerOrganizationTools(server, apiKey) {
4
+ server.tool('get_org_health', 'Get a comprehensive organization health snapshot: DORA performance tier (Elite/High/Medium/Low), cycle time percentile vs industry benchmarks, test coverage percentage, number of active teams, and incident rate. Use this as the first tool to get a high-level picture of engineering health.', {}, { readOnlyHint: true }, async () => {
5
5
  const [doraData, flowData, teamsData] = await Promise.all([
6
- apiGet('/api/v1/metrics/dora'),
7
- apiGet('/api/v1/flow'),
8
- apiGet('/api/v1/teams'),
6
+ apiGet('/api/v1/metrics/dora', undefined, apiKey),
7
+ apiGet('/api/v1/flow', undefined, apiKey),
8
+ apiGet('/api/v1/teams', undefined, apiKey),
9
9
  ]);
10
10
  const result = {
11
11
  dora: doraData,
@@ -20,11 +20,11 @@ export function registerOrganizationTools(server) {
20
20
  .string()
21
21
  .optional()
22
22
  .describe('Team ID to filter well-being data. Omit for org-wide summary.'),
23
- }, async ({ teamId }) => {
23
+ }, { readOnlyHint: true }, async ({ teamId }) => {
24
24
  const params = {};
25
25
  if (teamId)
26
26
  params['teamId'] = teamId;
27
- const data = await apiGet('/wellbeing/summary', params);
27
+ const data = await apiGet('/wellbeing/summary', params, apiKey);
28
28
  return { content: [{ type: 'text', text: formatResult(data) }] };
29
29
  });
30
30
  }
@@ -1,10 +1,10 @@
1
1
  import { z } from 'zod/v3';
2
2
  import { apiGet, formatResult } from '../client.js';
3
- export function registerPullRequestTools(server) {
3
+ export function registerPullRequestTools(server, apiKey) {
4
4
  server.tool('get_pr_summary', 'Get pull request metrics including cycle time (time from first commit to merge), throughput (PRs merged per week), review health (time to first review, reviewer distribution), and PR size trends. Use this to assess code review efficiency.', {
5
5
  teamId: z.string().optional().describe('Team ID to filter to a specific team'),
6
6
  days: z.number().optional().describe('Number of days to look back (default: 30)'),
7
- }, async ({ teamId, days }) => {
7
+ }, { readOnlyHint: true }, async ({ teamId, days }) => {
8
8
  const params = {};
9
9
  if (teamId)
10
10
  params['teamId'] = teamId;
@@ -12,7 +12,7 @@ export function registerPullRequestTools(server) {
12
12
  const from = new Date(Date.now() - days * 86400000).toISOString();
13
13
  params['from'] = from;
14
14
  }
15
- const data = await apiGet('/api/v1/review-metrics', params);
15
+ const data = await apiGet('/api/v1/review-metrics', params, apiKey);
16
16
  return { content: [{ type: 'text', text: formatResult(data) }] };
17
17
  });
18
18
  server.tool('get_open_prs', 'List currently open pull requests with their age in hours, size (additions + deletions), and reviewer assignments. Use this to identify stale or large PRs that may be blocking the team.', {
@@ -21,21 +21,21 @@ export function registerPullRequestTools(server) {
21
21
  .boolean()
22
22
  .optional()
23
23
  .describe('If true, only return PRs that are large (>500 lines), old (>72h open), or have no reviewers'),
24
- }, async ({ repositoryId, highRiskOnly }) => {
24
+ }, { readOnlyHint: true }, async ({ repositoryId, highRiskOnly }) => {
25
25
  const params = { state: 'open' };
26
26
  if (repositoryId)
27
27
  params['repositoryId'] = repositoryId;
28
28
  if (highRiskOnly)
29
29
  params['highRiskOnly'] = 'true';
30
- const data = await apiGet('/api/v1/pull-requests', params);
30
+ const data = await apiGet('/api/v1/pull-requests', params, apiKey);
31
31
  return { content: [{ type: 'text', text: formatResult(data) }] };
32
32
  });
33
- server.tool('get_at_risk_prs', 'Get pull requests at risk of becoming long-running or blocked. These are PRs that have been open for more than 3 days, have no reviews, or are very large. Use this to prompt engineering leads to take action on blocked work.', {}, async () => {
33
+ server.tool('get_at_risk_prs', 'Get pull requests at risk of becoming long-running or blocked. These are PRs that have been open for more than 3 days, have no reviews, or are very large. Use this to prompt engineering leads to take action on blocked work.', {}, { readOnlyHint: true }, async () => {
34
34
  const params = {
35
35
  state: 'open',
36
36
  highRiskOnly: 'true',
37
37
  };
38
- const data = await apiGet('/api/v1/pull-requests', params);
38
+ const data = await apiGet('/api/v1/pull-requests', params, apiKey);
39
39
  return { content: [{ type: 'text', text: formatResult(data) }] };
40
40
  });
41
41
  }
@@ -1,24 +1,24 @@
1
1
  import { z } from 'zod/v3';
2
2
  import { apiGet, formatResult } from '../client.js';
3
- export function registerRepositoryTools(server) {
3
+ export function registerRepositoryTools(server, apiKey) {
4
4
  server.tool('list_repositories', 'List all repositories connected to Koalr with their health scores, activity levels, and basic stats. Use this to get an overview of the codebase landscape or find repository IDs for filtering.', {
5
5
  limit: z
6
6
  .number()
7
7
  .optional()
8
8
  .describe('Maximum number of repositories to return (default: 50, max: 200)'),
9
- }, async ({ limit }) => {
9
+ }, { readOnlyHint: true }, async ({ limit }) => {
10
10
  const params = {};
11
11
  if (limit)
12
12
  params['limit'] = String(limit);
13
- const data = await apiGet('/repositories', params);
13
+ const data = await apiGet('/repositories', params, apiKey);
14
14
  return { content: [{ type: 'text', text: formatResult(data) }] };
15
15
  });
16
16
  server.tool('get_repository', 'Get detailed metrics for a specific repository including deployment frequency, PR cycle time, contributor count, and code health indicators. Use this when asked about a specific codebase.', {
17
17
  name: z
18
18
  .string()
19
19
  .describe('Repository name (e.g. "my-service") or full name with owner (e.g. "org/my-service")'),
20
- }, async ({ name }) => {
21
- const data = await apiGet('/repositories', { name });
20
+ }, { readOnlyHint: true }, async ({ name }) => {
21
+ const data = await apiGet('/repositories', { name }, apiKey);
22
22
  return { content: [{ type: 'text', text: formatResult(data) }] };
23
23
  });
24
24
  }
@@ -1,17 +1,17 @@
1
1
  import { z } from 'zod/v3';
2
2
  import { apiGet, formatResult } from '../client.js';
3
- export function registerSearchTools(server) {
3
+ export function registerSearchTools(server, apiKey) {
4
4
  server.tool('search', 'Search across all Koalr entities: developers (by name or GitHub login), repositories (by name), pull requests (by title or branch), and teams (by name). Use this when you need to find an entity before using a more specific tool.', {
5
5
  query: z.string().describe('Search query text (e.g. "payments", "alice", "auth-service")'),
6
6
  type: z
7
7
  .enum(['developer', 'repository', 'pr', 'team'])
8
8
  .optional()
9
9
  .describe('Limit results to a specific entity type. Omit to search all types.'),
10
- }, async ({ query, type }) => {
10
+ }, { readOnlyHint: true }, async ({ query, type }) => {
11
11
  const params = { q: query };
12
12
  if (type)
13
13
  params['type'] = type;
14
- const data = await apiGet('/search', params);
14
+ const data = await apiGet('/search', params, apiKey);
15
15
  return { content: [{ type: 'text', text: formatResult(data) }] };
16
16
  });
17
17
  }
@@ -1,16 +1,16 @@
1
1
  import { z } from 'zod/v3';
2
2
  import { apiGet, formatResult } from '../client.js';
3
- export function registerTeamTools(server) {
4
- server.tool('list_teams', 'List all engineering teams in the organization with their member counts and slugs. Use this to discover team IDs needed for filtering other metrics tools.', {}, async () => {
5
- const data = await apiGet('/api/v1/teams');
3
+ export function registerTeamTools(server, apiKey) {
4
+ server.tool('list_teams', 'List all engineering teams in the organization with their member counts and slugs. Use this to discover team IDs needed for filtering other metrics tools.', {}, { readOnlyHint: true }, async () => {
5
+ const data = await apiGet('/api/v1/teams', undefined, apiKey);
6
6
  return { content: [{ type: 'text', text: formatResult(data) }] };
7
7
  });
8
8
  server.tool('get_team', "Get details and metrics for a specific team including DORA performance, cycle time, and member count. Use this when asked about a specific team's engineering health.", {
9
9
  teamId: z.string().describe('The team ID (use list_teams to find team IDs)'),
10
- }, async ({ teamId }) => {
10
+ }, { readOnlyHint: true }, async ({ teamId }) => {
11
11
  const [doraData, flowData] = await Promise.all([
12
- apiGet('/api/v1/metrics/dora', { teamId }),
13
- apiGet('/api/v1/flow', { teamId }),
12
+ apiGet('/api/v1/metrics/dora', { teamId }, apiKey),
13
+ apiGet('/api/v1/flow', { teamId }, apiKey),
14
14
  ]);
15
15
  const result = {
16
16
  teamId,
@@ -21,8 +21,8 @@ export function registerTeamTools(server) {
21
21
  });
22
22
  server.tool('list_team_members', 'List all members of a specific team with their GitHub logins and roles. Use this to understand team composition or find developer logins for the get_developer tool.', {
23
23
  teamId: z.string().describe('The team ID (use list_teams to find team IDs)'),
24
- }, async ({ teamId }) => {
25
- const data = await apiGet(`/teams/${teamId}/members`);
24
+ }, { readOnlyHint: true }, async ({ teamId }) => {
25
+ const data = await apiGet(`/teams/${teamId}/members`, undefined, apiKey);
26
26
  return { content: [{ type: 'text', text: formatResult(data) }] };
27
27
  });
28
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koalr-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.2",
4
4
  "description": "Koalr MCP server — connect engineering metrics to AI agents",
5
5
  "type": "module",
6
6
  "license": "MIT",