codeant-cli 0.5.8 → 0.5.10

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/src/mcp/server.js CHANGED
@@ -8,18 +8,18 @@ import { runRepos } from '../commands/scans/repos.js';
8
8
  import { runHistory } from '../commands/scans/history.js';
9
9
  import { runGet } from '../commands/scans/get.js';
10
10
  import { runResults } from '../commands/scans/results.js';
11
- import { runDismissed } from '../commands/scans/dismissed.js';
11
+ import { runDismissed, runOverrides } from '../commands/scans/dismissed.js';
12
12
  import { runStartScan } from '../commands/scans/start-scan.js';
13
13
  import { runReviewHeadless } from '../reviewHeadless.js';
14
14
  import * as scm from '../scm/index.js';
15
- import { isAlreadyLoggedIn, runLoginFlow } from '../utils/loginFlow.js';
16
- import { getConfigValue } from '../utils/config.js';
15
+ import { awaitLoginCompletion, isAlreadyLoggedIn, startLoginFlow } from '../utils/loginFlow.js';
17
16
  import { runHotlistGet, runHotlistList } from '../hotlist/client.js';
18
17
  import { logoutCodeAnt } from '../utils/logout.js';
19
18
  import { runApiRequest } from '../commands/api/request.js';
20
19
  import { runOrganizationAntipatterns } from '../findings/antipatterns.js';
21
20
  import { runCloudFindingGet, runCloudFindings, runCloudHistory } from '../findings/cloud.js';
22
21
  import { runPentestHistory, runPentestIssues, runPentestReport } from '../findings/pentest.js';
22
+ import { maxResultChars, shapeCloudFindings, shapeCloudHistory, shapePentestHistory, shapeRepos, tooLarge } from './shape.js';
23
23
 
24
24
  const require = createRequire(import.meta.url);
25
25
  const pkg = require('../../package.json');
@@ -34,28 +34,48 @@ function isReadOnly() {
34
34
  return v !== '0' && v.toLowerCase() !== 'false';
35
35
  }
36
36
 
37
- function ok(value) {
38
- return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
37
+ const INSTRUCTIONS = `CodeAnt AI tools for repository scans, organization-wide security findings, cloud security, pentests, pull requests, and local code review.
38
+
39
+ - Start with codeant_scans_orgs. When it lists more than one connection, pass org and service to the Hotlist, anti-pattern, cloud, pentest, and API tools.
40
+ - Repository findings: codeant_scans_repos (use full_name) -> codeant_scans_history or codeant_scans_get -> codeant_scans_results, once per repository. Parallel calls are safe.
41
+ - Prioritized findings across the organization: codeant_hotlist_list, then codeant_hotlist_get for one finding.
42
+ - Cloud security: codeant_cloud_scan_history -> codeant_cloud_findings_list (pass status "FAIL" to skip passing checks) -> codeant_cloud_finding_get. Pentests: codeant_pentest_history -> codeant_pentest_issues or codeant_pentest_report.
43
+ - List tools return one page with a total. Page with limit and offset (cursor and next_cursor for the Hotlist), and narrow with filters instead of requesting everything. Results over the size limit are refused with a hint.
44
+ - codeant_review_local and the pull request tools work on the git repository in this server's working directory. Outside a repository, pass name and remote to the pull request tools; local review is unavailable.
45
+ - Pull request and comment tools call GitHub, GitLab, Bitbucket, or Azure DevOps directly and need a token for that provider.
46
+ - On an authentication error ("Missing API key", "Invalid API key", or access denied), call codeant_login with force: true (a configured token may be stale), show the returned loginUrl to the user, and after they finish signing in call codeant_login again without force to confirm.`;
47
+
48
+ function textResult(text, hint) {
49
+ if (text.length > maxResultChars()) return fail(tooLarge(text.length, hint));
50
+ return { content: [{ type: 'text', text }] };
51
+ }
52
+
53
+ function ok(value, hint) {
54
+ return textResult(JSON.stringify(value), hint);
39
55
  }
40
56
 
41
57
  function fail(err) {
42
- const message = err instanceof Error ? err.message : String(err);
58
+ const body = err instanceof Error ? { error: err.message } : err && typeof err === 'object' ? err : { error: String(err) };
43
59
  return {
44
60
  isError: true,
45
- content: [{ type: 'text', text: JSON.stringify({ error: message }, null, 2) }],
61
+ content: [{ type: 'text', text: JSON.stringify(body) }],
46
62
  };
47
63
  }
48
64
 
65
+ function maskToken(token) {
66
+ return token ? `${token.slice(0, 8)}…` : null;
67
+ }
68
+
49
69
  function resolveRepoOpts(input) {
50
70
  const remote = input.remote || scm.detectRemote();
51
71
  const name = input.name || scm.detectRepoName();
52
- const defaultBranch = input.defaultBranch || scm.detectDefaultBranch();
53
72
  if (!remote) throw new Error('Could not detect remote. Pass `remote` (github|gitlab|bitbucket|azure).');
54
73
  if (!name) throw new Error('Could not detect repo name. Pass `name` (owner/repo).');
55
- return { ...input, remote, name, defaultBranch };
74
+ return { ...input, remote, name };
56
75
  }
57
76
 
58
- // Capture stdout from a function that writes JSON to stdout (used for `scans results` and `scans start-scan`).
77
+ // Capture stdout from a function that writes JSON to stdout (only `scans start-scan`, a write tool).
78
+ // It swaps the global stdout writer, so overlapping calls must not use it.
59
79
  async function captureStdout(fn) {
60
80
  const chunks = [];
61
81
  const origWrite = process.stdout.write.bind(process.stdout);
@@ -71,22 +91,25 @@ async function captureStdout(fn) {
71
91
  return chunks.join('');
72
92
  }
73
93
 
74
- async function ensureAuthenticated() {
75
- const envToken = process.env.CODEANT_API_TOKEN;
76
- if (envToken && envToken.trim()) return;
77
- if (isAlreadyLoggedIn()) {
78
- process.env.CODEANT_API_TOKEN = getConfigValue('apiKeyV2');
79
- return;
80
- }
94
+ function hasEnvToken() {
95
+ return !!process.env.CODEANT_API_TOKEN?.trim();
96
+ }
81
97
 
98
+ // The saved token is read from ~/.codeant/config.json on every request, so a
99
+ // `codeant login` in another terminal takes effect without restarting the server.
100
+ function warnIfUnauthenticated() {
101
+ if (hasEnvToken() || isAlreadyLoggedIn()) return;
82
102
  console.error('[codeant-mcp] No API token configured. Call the codeant_login tool to sign in, or set CODEANT_API_TOKEN.');
83
103
  }
84
104
 
85
- export async function startMcpServer() {
86
- await ensureAuthenticated();
105
+ export function createMcpServer() {
106
+ warnIfUnauthenticated();
87
107
 
88
- const server = new McpServer({ name: 'codeant', version: pkg.version });
108
+ const server = new McpServer({ name: 'codeant', version: pkg.version }, { instructions: INSTRUCTIONS });
89
109
  const readOnly = isReadOnly();
110
+ let login = null;
111
+ // codeant_login calls run one at a time so concurrent calls share one sign-in.
112
+ let loginQueue = Promise.resolve();
90
113
 
91
114
  // ─── Scans: discovery ────────────────────────────────────────────────────
92
115
  server.registerTool(
@@ -106,14 +129,18 @@ export async function startMcpServer() {
106
129
  'codeant_scans_repos',
107
130
  {
108
131
  title: 'List repositories in a CodeAnt org',
109
- description: 'List repositories connected to CodeAnt for a given organization. Use this to enumerate repos before fanning out org-wide queries (e.g. "secrets across all repos"). If `org` is omitted and the user has exactly one org, it is auto-picked.',
132
+ description: 'List repositories connected to CodeAnt for a given organization, most recently pushed first. Use this to enumerate repos before fanning out org-wide queries (e.g. "secrets across all repos"). Returns one page of slim records (full_name, default_branch, language, pushed_at, ...) with total and next_offset. If `org` is omitted and the user has exactly one org, it is auto-picked.',
110
133
  inputSchema: {
111
134
  org: z.string().optional().describe('Organization name. Optional when only one org is authenticated.'),
135
+ search: z.string().optional().describe('Case-insensitive substring match on the repository name.'),
136
+ limit: z.number().int().positive().max(1000).optional().describe('Max repositories returned (default 100).'),
137
+ offset: z.number().int().nonnegative().optional().describe('Pagination offset (default 0).'),
138
+ full: z.boolean().optional().describe('Return the complete provider records instead of slim ones. Default false.'),
112
139
  },
113
140
  annotations: READ,
114
141
  },
115
- async ({ org }) => {
116
- try { return ok(await runRepos({ org })); } catch (err) { return fail(err); }
142
+ async ({ org, ...shape }) => {
143
+ try { return ok(shapeRepos(await runRepos({ org }), shape), 'Use search, or a smaller limit with offset.'); } catch (err) { return fail(err); }
117
144
  }
118
145
  );
119
146
 
@@ -164,41 +191,37 @@ export async function startMcpServer() {
164
191
  repo: z.string().describe('Repository in owner/repo form.'),
165
192
  scan: z.string().optional().describe('Specific commit SHA.'),
166
193
  branch: z.string().optional().describe('Resolve the latest scan on this branch.'),
167
- types: z.string().optional().describe('Comma-separated types: sast,sca,secrets,iac,dead_code,sbom,anti_patterns,docstring,complex_functions,all (default "all").'),
194
+ types: z.string().optional().describe('Comma-separated types: sast,sca,secrets,iac,dead_code,duplicate_code,sbom,anti_patterns,docstring,complex_functions,all (default "all").'),
168
195
  severity: z.string().optional().describe('Comma-separated severities (e.g. "critical,high").'),
169
196
  path: z.string().optional().describe('File path glob filter.'),
170
197
  check: z.string().optional().describe('Filter by check ID or name (regex).'),
171
- filterDismissed: z.boolean().optional().describe('Exclude dismissed findings (default false).'),
172
- includeFalsePositives: z.boolean().optional().describe('Include false positives (default true).'),
198
+ filterDismissed: z.boolean().optional().describe('Exclude dismissed findings (default false). When false, dismissed findings carry metadata.dismissed.'),
199
+ includeFalsePositives: z.boolean().optional().describe('Include false positives, including user-marked ones (default true). When true, they carry metadata.false_positive.'),
173
200
  fields: z.string().optional().describe('Project findings to a subset of fields (comma-separated).'),
174
- limit: z.number().int().positive().max(500).optional().describe('Max findings per page (default 100).'),
201
+ limit: z.number().int().positive().max(500).optional().describe('Max findings per page (default 50).'),
175
202
  offset: z.number().int().nonnegative().optional().describe('Pagination offset (default 0).'),
176
203
  },
177
204
  annotations: READ,
178
205
  },
179
206
  async (input) => {
180
207
  try {
181
- const text = await captureStdout(() =>
182
- runResults({
183
- repo: input.repo,
184
- scan: input.scan,
185
- branch: input.branch,
186
- types: input.types ?? 'all',
187
- severity: input.severity,
188
- path: input.path,
189
- check: input.check,
190
- filterDismissed: input.filterDismissed ?? false,
191
- includeFalsePositives: input.includeFalsePositives ?? true,
192
- format: 'json',
193
- output: undefined,
194
- fields: input.fields,
195
- limit: input.limit ?? 100,
196
- offset: input.offset ?? 0,
197
- failFast: false,
198
- })
199
- );
200
- // runResults already emits JSON; pass it through unparsed to preserve shape.
201
- return { content: [{ type: 'text', text: text || '{}' }] };
208
+ const envelope = await runResults({
209
+ repo: input.repo,
210
+ scan: input.scan,
211
+ branch: input.branch,
212
+ types: input.types ?? 'all',
213
+ severity: input.severity,
214
+ path: input.path,
215
+ check: input.check,
216
+ filterDismissed: input.filterDismissed ?? false,
217
+ includeFalsePositives: input.includeFalsePositives ?? true,
218
+ fields: input.fields,
219
+ limit: input.limit ?? 50,
220
+ offset: input.offset ?? 0,
221
+ failFast: false,
222
+ returnEnvelope: true,
223
+ });
224
+ return ok(envelope, 'Use a smaller limit with offset, filter by types, severity, path, or check, or project fields.');
202
225
  } catch (err) {
203
226
  return fail(err);
204
227
  }
@@ -212,7 +235,7 @@ export async function startMcpServer() {
212
235
  description: 'List dismissed alerts (false positives, accepted risk, etc.) for a repository. Useful when triaging to avoid re-surfacing already-handled findings.',
213
236
  inputSchema: {
214
237
  repo: z.string().describe('Repository in owner/repo form.'),
215
- analysisType: z.enum(['security', 'secrets']).optional().describe('Analysis type (default "security").'),
238
+ analysisType: z.enum(['security', 'sast', 'secrets', 'sca', 'iac', 'antipatterns', 'anti_patterns', 'docstring', 'complex_functions', 'dead_code', 'duplicate_code']).optional().describe('Analysis type (default "security"; sast and anti_patterns are aliases).'),
216
239
  },
217
240
  annotations: READ,
218
241
  },
@@ -221,6 +244,22 @@ export async function startMcpServer() {
221
244
  }
222
245
  );
223
246
 
247
+ server.registerTool(
248
+ 'codeant_scans_overrides',
249
+ {
250
+ title: 'List user issue overrides',
251
+ description: 'List per-finding overrides users set in the CodeAnt app for a repository: Mark/Unmark false positive (security, iac), secrets confidence, and Change Severity (security, sca). Results from codeant_scans_results already apply them; use this to explain why a finding is hidden or re-rated.',
252
+ inputSchema: {
253
+ repo: z.string().describe('Repository in owner/repo form.'),
254
+ analysisType: z.enum(['security', 'secrets', 'iac', 'sca']).optional().describe('Analysis type (default "security").'),
255
+ },
256
+ annotations: READ,
257
+ },
258
+ async ({ repo, analysisType }) => {
259
+ try { return ok(await runOverrides({ repo, analysisType: analysisType ?? 'security' })); } catch (err) { return fail(err); }
260
+ }
261
+ );
262
+
224
263
  // ─── Organization Hotlist findings (read-only) ──────────────────────────
225
264
  server.registerTool(
226
265
  'codeant_hotlist_list',
@@ -238,15 +277,17 @@ export async function startMcpServer() {
238
277
  ticketStatuses: z.array(z.enum(['created', 'not_created'])).optional(),
239
278
  compliance: z.array(z.string()).optional(),
240
279
  validation: z.array(z.enum(['exploit_confirmed'])).optional(),
241
- limit: z.number().int().positive().max(100).optional(),
242
- cursor: z.string().optional(),
280
+ limit: z.number().int().positive().max(100).optional().describe('Page size (default 10).'),
281
+ cursor: z.string().optional().describe('next_cursor from a previous page.'),
243
282
  all: z.boolean().optional().describe('Fetch every matching page. Default false.'),
244
283
  maxWaitSeconds: z.number().int().nonnegative().max(600).optional(),
245
284
  },
246
285
  annotations: READ,
247
286
  },
248
287
  async (input) => {
249
- try { return ok(await runHotlistList(input)); } catch (err) { return fail(err); }
288
+ try {
289
+ return ok(await runHotlistList({ ...input, limit: input.limit ?? 10 }), 'Page with limit and cursor instead of all, or add filters such as severities or types.');
290
+ } catch (err) { return fail(err); }
250
291
  }
251
292
  );
252
293
 
@@ -279,14 +320,16 @@ export async function startMcpServer() {
279
320
  service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(),
280
321
  providerBaseUrl: z.string().url().optional(),
281
322
  repos: z.array(z.string()).optional().describe('Repositories in owner/repo form. Omit to query every repository.'),
282
- limit: z.union([z.literal(25), z.literal(100), z.literal(500)]).optional().describe('Page size (default 500).'),
323
+ limit: z.union([z.literal(25), z.literal(100), z.literal(500)]).optional().describe('Page size (default 25).'),
283
324
  offset: z.number().int().nonnegative().optional().describe('Start offset, a multiple of limit (default 0).'),
284
325
  all: z.boolean().optional().describe('Fetch every page from offset on instead of one page.'),
285
326
  },
286
327
  annotations: READ,
287
328
  },
288
329
  async (input) => {
289
- try { return ok(await runOrganizationAntipatterns(input)); } catch (err) { return fail(err); }
330
+ try {
331
+ return ok(await runOrganizationAntipatterns({ ...input, limit: input.limit ?? 25 }), 'Page with limit and offset instead of all, or pass fewer repos.');
332
+ } catch (err) { return fail(err); }
290
333
  }
291
334
  );
292
335
 
@@ -302,11 +345,16 @@ export async function startMcpServer() {
302
345
  provider: z.enum(['aws', 'azure', 'gcp', 'all']).optional().describe('Default all.'),
303
346
  kind: z.enum(['cspm', 'vm', 'container']).optional().describe('Default cspm.'),
304
347
  latest: z.boolean().optional().describe('Return latest scans instead of complete history. CSPM only.'),
348
+ limit: z.number().int().positive().max(200).optional().describe('Max scans returned per provider, newest first (default 10).'),
349
+ offset: z.number().int().nonnegative().optional().describe('Pagination offset within each provider (default 0).'),
350
+ full: z.boolean().optional().describe('Keep per-service, compliance, and region rollups on each scan. Default false.'),
305
351
  },
306
352
  annotations: READ,
307
353
  },
308
- async (input) => {
309
- try { return ok(await runCloudHistory(input)); } catch (err) { return fail(err); }
354
+ async ({ limit, offset, full, ...input }) => {
355
+ try {
356
+ return ok(shapeCloudHistory(await runCloudHistory(input), { limit: limit ?? 10, offset: offset ?? 0, full }), 'Pass a single provider, a smaller limit, or latest: true.');
357
+ } catch (err) { return fail(err); }
310
358
  }
311
359
  );
312
360
 
@@ -314,7 +362,7 @@ export async function startMcpServer() {
314
362
  'codeant_cloud_findings_list',
315
363
  {
316
364
  title: 'List cloud security findings',
317
- description: 'Fetch findings for one AWS, Azure, or GCP CSPM, VM, or container scan. VM and container results are paged (limit/offset/all) and carry total.',
365
+ description: 'Fetch findings for one AWS, Azure, or GCP CSPM, VM, or container scan. Results are paged (limit/offset/all) and carry total; pass status "FAIL" to skip passing CSPM checks. Compliance mappings are omitted unless full is true.',
318
366
  inputSchema: {
319
367
  org: z.string().optional(),
320
368
  service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(),
@@ -332,14 +380,22 @@ export async function startMcpServer() {
332
380
  subscriptionId: z.string().optional(),
333
381
  exploitAttemptedOnly: z.boolean().optional(),
334
382
  minDaysUnused: z.number().int().nonnegative().optional(),
335
- limit: z.union([z.literal(25), z.literal(100), z.literal(500)]).optional().describe('Page size (default 500).'),
383
+ limit: z.union([z.literal(25), z.literal(100), z.literal(500)]).optional().describe('Page size (default 25).'),
336
384
  offset: z.number().int().nonnegative().optional().describe('Start offset, a multiple of limit (default 0).'),
337
385
  all: z.boolean().optional().describe('Fetch every page from offset on instead of one page.'),
386
+ full: z.boolean().optional().describe('Keep compliance mappings on each finding. Default false.'),
338
387
  },
339
388
  annotations: READ,
340
389
  },
341
- async (input) => {
342
- try { return ok(await runCloudFindings(input)); } catch (err) { return fail(err); }
390
+ async ({ full, ...input }) => {
391
+ try {
392
+ const limit = input.limit ?? 25;
393
+ const result = await runCloudFindings({ ...input, limit });
394
+ return ok(
395
+ shapeCloudFindings(result, { kind: input.kind ?? 'cspm', limit, offset: input.offset ?? 0, all: input.all, full }),
396
+ 'Filter by status (for example "FAIL"), severity, or cloudService, or page with limit and offset instead of all.',
397
+ );
398
+ } catch (err) { return fail(err); }
343
399
  }
344
400
  );
345
401
 
@@ -372,16 +428,21 @@ export async function startMcpServer() {
372
428
  'codeant_pentest_history',
373
429
  {
374
430
  title: 'List pentest engagements',
375
- description: 'List every pentest engagement visible in the CodeAnt Pentesting UI, including status and finding counts.',
431
+ description: 'List pentest engagements visible in the CodeAnt Pentesting UI, newest first, including status and finding counts. Returns one page with total and next_offset.',
376
432
  inputSchema: {
377
433
  org: z.string().optional(),
378
434
  service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(),
379
435
  providerBaseUrl: z.string().url().optional(),
436
+ limit: z.number().int().positive().max(500).optional().describe('Max engagements returned (default 25).'),
437
+ offset: z.number().int().nonnegative().optional().describe('Pagination offset (default 0).'),
438
+ full: z.boolean().optional().describe('Keep credit and billing details on each engagement. Default false.'),
380
439
  },
381
440
  annotations: READ,
382
441
  },
383
- async (input) => {
384
- try { return ok(await runPentestHistory(input)); } catch (err) { return fail(err); }
442
+ async ({ limit, offset, full, ...input }) => {
443
+ try {
444
+ return ok(shapePentestHistory(await runPentestHistory(input), { limit: limit ?? 25, offset: offset ?? 0, full }), 'Use a smaller limit with offset.');
445
+ } catch (err) { return fail(err); }
385
446
  }
386
447
  );
387
448
 
@@ -429,7 +490,7 @@ export async function startMcpServer() {
429
490
  'codeant_api_get',
430
491
  {
431
492
  title: 'Call a CodeAnt GET API',
432
- description: 'Call any authenticated GET endpoint on the configured CodeAnt API host. The path must be relative (for example /extension/scans2/validate); absolute URLs are rejected.',
493
+ description: 'Call any authenticated GET endpoint on the configured CodeAnt API host, for read APIs no dedicated tool covers. The path must be relative and start with a single "/"; absolute URLs are rejected. Returns { ok, status, tenant, data }; non-2xx responses come back with ok: false.',
433
494
  inputSchema: {
434
495
  path: z.string().startsWith('/'),
435
496
  org: z.string().optional().describe('Organization name. Required when the login has multiple matching connections.'),
@@ -454,12 +515,11 @@ export async function startMcpServer() {
454
515
  inputSchema: {
455
516
  name: z.string().optional().describe('Repository in owner/repo form. Auto-detected if omitted.'),
456
517
  remote: z.enum(['github', 'gitlab', 'bitbucket', 'azure']).optional().describe('Auto-detected if omitted.'),
457
- defaultBranch: z.string().optional(),
458
- sourceBranch: z.string().optional(),
459
- author: z.string().optional().describe('Filter by author login (fuzzy).'),
518
+ sourceBranch: z.string().optional().describe('Source branch. Exact match, except substring on Bitbucket.'),
519
+ author: z.string().optional().describe('Author. Substring of the login on GitHub and Bitbucket, exact username on GitLab, identity ID on Azure DevOps.'),
460
520
  state: z.enum(['open', 'closed']).optional().describe('Default "open".'),
461
- limit: z.number().int().positive().max(100).optional(),
462
- offset: z.number().int().nonnegative().optional(),
521
+ limit: z.number().int().positive().max(100).optional().describe('Max results (default 20).'),
522
+ offset: z.number().int().nonnegative().optional().describe('Pagination offset, rounded down to a multiple of limit on GitHub and GitLab; ignored on Bitbucket and Azure DevOps.'),
463
523
  },
464
524
  annotations: READ,
465
525
  },
@@ -470,7 +530,6 @@ export async function startMcpServer() {
470
530
  await scm.listPullRequests({
471
531
  name: opts.name,
472
532
  remote: opts.remote,
473
- defaultBranch: opts.defaultBranch,
474
533
  sourceBranch: opts.sourceBranch,
475
534
  authorLogin: opts.author,
476
535
  state: opts.state ?? 'open',
@@ -486,12 +545,11 @@ export async function startMcpServer() {
486
545
  'codeant_pr_get',
487
546
  {
488
547
  title: 'Get pull request details',
489
- description: 'Fetch detailed information for a single PR/MR including review analysis.',
548
+ description: 'Fetch one PR/MR: provider metadata (title, state, branches, author, dates) and reviewer approval states.',
490
549
  inputSchema: {
491
550
  prNumber: z.number().int().positive(),
492
551
  name: z.string().optional(),
493
552
  remote: z.enum(['github', 'gitlab', 'bitbucket', 'azure']).optional(),
494
- defaultBranch: z.string().optional(),
495
553
  },
496
554
  annotations: READ,
497
555
  },
@@ -502,7 +560,6 @@ export async function startMcpServer() {
502
560
  await scm.getPullRequest({
503
561
  name: opts.name,
504
562
  remote: opts.remote,
505
- defaultBranch: opts.defaultBranch,
506
563
  prNumber: input.prNumber,
507
564
  })
508
565
  );
@@ -514,14 +571,12 @@ export async function startMcpServer() {
514
571
  'codeant_pr_comments',
515
572
  {
516
573
  title: 'List PR comments',
517
- description: 'List comments on a PR/MR with optional filters (CodeAnt-authored only, resolved/unresolved, date range).',
574
+ description: 'List comments on a PR/MR, optionally only CodeAnt-authored ones or those in a date range. GitLab, Bitbucket, and Azure DevOps comments carry a resolved flag; GitHub comments do not.',
518
575
  inputSchema: {
519
576
  prNumber: z.number().int().positive(),
520
577
  name: z.string().optional(),
521
578
  remote: z.enum(['github', 'gitlab', 'bitbucket', 'azure']).optional(),
522
- defaultBranch: z.string().optional(),
523
579
  codeantGenerated: z.boolean().optional().describe('Only return comments authored by CodeAnt.'),
524
- addressed: z.boolean().optional().describe('Filter by addressed/resolved status.'),
525
580
  createdAfter: z.string().optional().describe('ISO 8601.'),
526
581
  createdBefore: z.string().optional().describe('ISO 8601.'),
527
582
  },
@@ -534,10 +589,8 @@ export async function startMcpServer() {
534
589
  await scm.listPullRequestComments({
535
590
  name: opts.name,
536
591
  remote: opts.remote,
537
- defaultBranch: opts.defaultBranch,
538
592
  prNumber: input.prNumber,
539
593
  codeantGenerated: input.codeantGenerated,
540
- addressed: input.addressed,
541
594
  createdAfter: input.createdAfter,
542
595
  createdBefore: input.createdBefore,
543
596
  })
@@ -550,14 +603,12 @@ export async function startMcpServer() {
550
603
  'codeant_comments_search',
551
604
  {
552
605
  title: 'Search CodeAnt review comments',
553
- description: 'Search across CodeAnt review comments by free-text query. Returns matching comments with repo, PR, and file context.',
606
+ description: 'Case-insensitive text search over review comments on the 10 most recently updated PRs/MRs of one repository (open ones only on Bitbucket and Azure DevOps). Matches comments from every author; each result has isCodeantComment, prNumber, path, and line.',
554
607
  inputSchema: {
555
608
  query: z.string(),
556
609
  name: z.string().optional(),
557
610
  remote: z.enum(['github', 'gitlab', 'bitbucket', 'azure']).optional(),
558
- limit: z.number().int().positive().max(50).optional(),
559
- includeAddressed: z.boolean().optional(),
560
- createdAfter: z.string().optional().describe('ISO 8601.'),
611
+ limit: z.number().int().positive().max(50).optional().describe('Max matching comments returned (default 10).'),
561
612
  },
562
613
  annotations: READ,
563
614
  },
@@ -570,8 +621,6 @@ export async function startMcpServer() {
570
621
  remote: opts.remote,
571
622
  query: input.query,
572
623
  limit: input.limit ?? 10,
573
- includeAddressed: input.includeAddressed ?? false,
574
- createdAfter: input.createdAfter,
575
624
  })
576
625
  );
577
626
  } catch (err) { return fail(err); }
@@ -583,7 +632,7 @@ export async function startMcpServer() {
583
632
  'codeant_review_local',
584
633
  {
585
634
  title: 'Review local working-copy changes',
586
- description: 'Run a CodeAnt AI review on local working-copy changes and return the findings as JSON. Does not modify files — pair with editor tools to apply fixes. Use this for "review my changes" / "check my staged files" prompts.',
635
+ description: 'Run a CodeAnt AI review on local working-copy changes and return the findings as JSON. Reviews the git repository in the server\'s working directory, so it needs a client that starts the server inside the project. Does not modify files — pair with editor tools to apply fixes. Use this for "review my changes" / "check my staged files" prompts.',
587
636
  inputSchema: {
588
637
  scope: z
589
638
  .enum(['all', 'uncommitted', 'staged-only', 'committed', 'last-commit', 'last-n-commits', 'base-branch', 'base-commit'])
@@ -610,32 +659,79 @@ export async function startMcpServer() {
610
659
  onProgress: () => {},
611
660
  onFilesReady: () => {},
612
661
  });
613
- return ok(result);
662
+ return result?.error ? fail(result) : ok(result);
614
663
  } catch (err) { return fail(err); }
615
664
  }
616
665
  );
617
666
 
618
667
  // ─── Auth (always registered — login is needed even in read-only mode) ───
668
+ // Sign-in runs in the background so the login URL reaches the user right away,
669
+ // even when no browser can be opened (SSH, containers).
670
+ async function beginLogin() {
671
+ const controller = new AbortController();
672
+ const { token, loginUrl, pollUrl, browserOpened } = await startLoginFlow();
673
+ const attempt = { status: 'pending', loginUrl, browserOpened, controller };
674
+ awaitLoginCompletion({ token, pollUrl, signal: controller.signal })
675
+ .then(() => {
676
+ // A cancelled login (logout or a newer sign-in aborted this one) must not
677
+ // restore its token, even if the in-flight poll had already succeeded.
678
+ if (controller.signal.aborted) {
679
+ attempt.status = 'aborted';
680
+ return;
681
+ }
682
+ attempt.status = 'success';
683
+ attempt.token = token;
684
+ // An explicit CODEANT_API_TOKEN would otherwise keep taking precedence over the new login.
685
+ if (hasEnvToken()) process.env.CODEANT_API_TOKEN = token;
686
+ })
687
+ .catch((err) => {
688
+ attempt.status = controller.signal.aborted ? 'aborted' : 'failed';
689
+ attempt.error = err.message;
690
+ });
691
+ return attempt;
692
+ }
693
+
694
+ function pendingLogin(attempt) {
695
+ return {
696
+ status: 'pending',
697
+ loginUrl: attempt.loginUrl,
698
+ browserOpened: attempt.browserOpened,
699
+ message: attempt.browserOpened
700
+ ? 'A browser window opened for CodeAnt sign-in. If it did not appear, open loginUrl. After signing in, call codeant_login again to confirm; sign-in is detected within about 10 seconds.'
701
+ : 'Open loginUrl in a browser to sign in to CodeAnt, then call codeant_login again to confirm; sign-in is detected within about 10 seconds. The link expires in 10 minutes.',
702
+ };
703
+ }
704
+
705
+ async function handleLogin({ force }) {
706
+ try {
707
+ if (login && !force) {
708
+ if (login.status === 'pending') return ok(pendingLogin(login));
709
+ const finished = login;
710
+ login = null;
711
+ if (finished.status === 'success') return ok({ status: 'success', token: maskToken(finished.token) });
712
+ return fail(new Error(`${finished.error} Call codeant_login again to start a new sign-in.`));
713
+ }
714
+ if (!force && (hasEnvToken() || isAlreadyLoggedIn())) return ok({ alreadyLoggedIn: true });
715
+ login?.controller.abort();
716
+ login = await beginLogin();
717
+ return ok(pendingLogin(login));
718
+ } catch (err) { return fail(err); }
719
+ }
720
+
619
721
  server.registerTool(
620
722
  'codeant_login',
621
723
  {
622
724
  title: 'Sign in to CodeAnt AI',
623
- description: 'Opens the configured CodeAnt dashboard in the user\'s browser and waits up to 10 minutes for them to complete sign-in. Tell the user to check their browser and finish the flow there. On success the API token is saved to ~/.codeant/config.json (apiKeyV2) and set on the running MCP process, so subsequent tool calls are authenticated without restart. Returns { alreadyLoggedIn: true } immediately if a token is already configured, unless `force` is true.',
725
+ description: 'Start browser sign-in to CodeAnt AI. Returns immediately with { status: "pending", loginUrl, browserOpened }: show loginUrl to the user (the browser may not open, for example over SSH), then call codeant_login again after they finish to get { status: "success" }. The link expires after 10 minutes. The token is saved to ~/.codeant/config.json (apiKeyV2), which the CodeAnt CLI shares, and later calls use it without a restart. Returns { alreadyLoggedIn: true } if a token is already configured; pass force: true when tools reject that token.',
624
726
  inputSchema: {
625
- force: z.boolean().optional().describe('Re-authenticate even if a token is already configured. Default false.'),
727
+ force: z.boolean().optional().describe('Start a new sign-in even if a token is already configured, for example when tools reject it as invalid. Default false.'),
626
728
  },
627
729
  annotations: { ...WRITE_NON_DESTRUCTIVE, idempotentHint: true },
628
730
  },
629
- async ({ force }) => {
630
- try {
631
- const envToken = process.env.CODEANT_API_TOKEN;
632
- if (!force && ((envToken && envToken.trim()) || isAlreadyLoggedIn())) {
633
- return ok({ alreadyLoggedIn: true });
634
- }
635
- const { token, loginUrl } = await runLoginFlow();
636
- const masked = token ? `${token.slice(0, 8)}…` : null;
637
- return ok({ status: 'success', loginUrl, token: masked });
638
- } catch (err) { return fail(err); }
731
+ ({ force }) => {
732
+ const run = loginQueue.then(() => handleLogin({ force }));
733
+ loginQueue = run.catch(() => {});
734
+ return run;
639
735
  }
640
736
  );
641
737
 
@@ -643,12 +739,14 @@ export async function startMcpServer() {
643
739
  'codeant_logout',
644
740
  {
645
741
  title: 'Sign out of CodeAnt AI',
646
- description: 'Clears the saved API token from ~/.codeant/config.json and unsets CODEANT_API_TOKEN on the running MCP process. Returns { wasLoggedIn: false } immediately if no token was configured.',
742
+ description: 'Revoke the API token on the server, clear it from ~/.codeant/config.json (which also signs out the CodeAnt CLI), unset CODEANT_API_TOKEN on the running MCP process, and cancel a pending codeant_login. Returns { wasLoggedIn: false } immediately if no token was configured.',
647
743
  inputSchema: {},
648
744
  annotations: { ...WRITE_NON_DESTRUCTIVE, idempotentHint: true },
649
745
  },
650
746
  async () => {
651
747
  try {
748
+ login?.controller.abort();
749
+ login = null;
652
750
  const result = await logoutCodeAnt();
653
751
  return ok({
654
752
  ...result,
@@ -699,7 +797,7 @@ export async function startMcpServer() {
699
797
  async (input) => {
700
798
  try {
701
799
  const text = await captureStdout(() => runStartScan(input));
702
- return { content: [{ type: 'text', text: text || '{}' }] };
800
+ return textResult(text || '{}');
703
801
  } catch (err) { return fail(err); }
704
802
  }
705
803
  );
@@ -737,6 +835,11 @@ export async function startMcpServer() {
737
835
  );
738
836
  }
739
837
 
838
+ return server;
839
+ }
840
+
841
+ export async function startMcpServer() {
842
+ const server = createMcpServer();
740
843
  const transport = new StdioServerTransport();
741
844
  await server.connect(transport);
742
845
  }