codeant-cli 0.5.9 → 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/mcp.md CHANGED
@@ -11,7 +11,7 @@ The CodeAnt CLI ships an MCP (Model Context Protocol) server that exposes CodeAn
11
11
  | Name | Read/Write | What it does |
12
12
  |------|------------|--------------|
13
13
  | `codeant_scans_orgs` | read | List authenticated CodeAnt orgs. |
14
- | `codeant_scans_repos` | read | List repos in an org. |
14
+ | `codeant_scans_repos` | read | List repos in an org (slim records, paged: `search`, `limit`, `offset`, `full`). |
15
15
  | `codeant_scans_history` | read | Recent scan runs for a repo. |
16
16
  | `codeant_scans_get` | read | Severity/category summary for one scan. |
17
17
  | `codeant_scans_results` | read | Full findings (SAST, SCA, secrets, IaC, …) for one scan. |
@@ -20,10 +20,10 @@ The CodeAnt CLI ships an MCP (Model Context Protocol) server that exposes CodeAn
20
20
  | `codeant_hotlist_list` | read | Prioritized organization-wide Hotlist findings with stable IDs. |
21
21
  | `codeant_hotlist_get` | read | One complete Hotlist finding by stable ID. |
22
22
  | `codeant_findings_antipatterns` | read | Anti-pattern findings across selected or all organization repos (paged: `limit`, `offset`, `all`). |
23
- | `codeant_cloud_scan_history` | read | AWS/Azure/GCP CSPM, VM, or container scan history. |
24
- | `codeant_cloud_findings_list` | read | Findings for one CSPM, VM, or container scan (VM/container paged: `limit`, `offset`, `all`). |
23
+ | `codeant_cloud_scan_history` | read | AWS/Azure/GCP CSPM, VM, or container scan history (newest first, 10 per provider by default and up to 200, paged with `limit`/`offset`, without per-service rollups unless `full`). |
24
+ | `codeant_cloud_findings_list` | read | Findings for one CSPM, VM, or container scan (paged: `limit`, `offset`, `all`; compliance mappings only with `full`). |
25
25
  | `codeant_cloud_finding_get` | read | Full detail for one cloud finding UID. |
26
- | `codeant_pentest_history` | read | Pentest engagement history. |
26
+ | `codeant_pentest_history` | read | Pentest engagement history (paged: `limit`, `offset`, `full`). |
27
27
  | `codeant_pentest_issues` | read | All available issues for a pentest engagement. |
28
28
  | `codeant_pentest_report` | read | Full pentest customer report. |
29
29
  | `codeant_api_get` | read | Authenticated GET request to any relative CodeAnt app API path, with exact org/provider context. |
@@ -31,7 +31,9 @@ The CodeAnt CLI ships an MCP (Model Context Protocol) server that exposes CodeAn
31
31
  | `codeant_pr_get` | read | Detail for a PR/MR. |
32
32
  | `codeant_pr_comments` | read | Comments on a PR, filtered. |
33
33
  | `codeant_comments_search` | read | Free-text search across CodeAnt review comments. |
34
- | `codeant_review_local` | read | Run a CodeAnt review on local working-copy changes. |
34
+ | `codeant_review_local` | read | Run a CodeAnt review on local working-copy changes in the server's working directory. |
35
+ | `codeant_login` | auth | Start browser sign-in; returns the sign-in link immediately. Call again to confirm. |
36
+ | `codeant_logout` | auth | Revoke and clear the saved token (shared with the CLI). |
35
37
  | `codeant_scans_start` | **write** | Trigger a new scan. Gated. |
36
38
  | `codeant_pr_resolve` | **write** | Resolve a PR conversation thread. Gated. |
37
39
  | `codeant_api_request` | **write** | Authenticated POST/PUT/PATCH/DELETE request to a relative CodeAnt app API path, with exact org/provider context. Gated. |
@@ -40,17 +42,24 @@ Write tools are only registered when `CODEANT_READ_ONLY=0`. Default = read-only.
40
42
 
41
43
  For complete finding coverage, examples, tenant/provider selection, and response details, see the [CodeAnt findings documentation](https://docs.codeant.ai/cli/findings).
42
44
 
43
- Every tool carries MCP annotations (`title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) so the client can decide whether to auto-approve calls.
45
+ Every tool carries MCP annotations (`title`, `readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) so the client can decide whether to auto-approve calls. The server also sends `instructions` (discovery order, paging, auth recovery) in its `initialize` result.
46
+
47
+ Results are compact JSON. Any result over `CODEANT_MCP_MAX_RESULT_CHARS` characters is refused with an error and a narrowing hint; [src/mcp/shape.js](src/mcp/shape.js) holds the MCP-only projections and page defaults. CLI commands keep their full output.
48
+
49
+ `codeant_login` runs sign-in in the background: the first call returns `{ status: "pending", loginUrl, browserOpened }`, and the next call returns `{ status: "success" }` once the user finishes. The token is read from `~/.codeant/config.json` on every request, so CLI logins and logouts apply to a running server.
44
50
 
45
51
  ## Configuration (env vars)
46
52
 
47
53
  | Var | Purpose | Default |
48
54
  |-----|---------|---------|
49
- | `CODEANT_API_TOKEN` | API token (CodeAnt account → settings → API). Required. | — |
50
- | `CODEANT_API_URL` | API base URL. Override for self-hosted. | `https://api.codeant.ai` |
51
- | `CODEANT_READ_ONLY` | `1` to hide write tools, `0` to expose them. | `1` |
55
+ | `CODEANT_API_TOKEN` | API token. Optional: without it the server uses the token saved by `codeant login` or `codeant_login`. | — |
56
+ | `CODEANT_API_URL` | API base URL. Override for self-hosted. | `https://service.codeant.ai` |
57
+ | `CODEANT_DASHBOARD_URL` | Web app URL used by `codeant_login`. Needed on self-hosted base URLs. | detected |
58
+ | `CODEANT_READ_ONLY` | `0` or `false` exposes write tools; anything else hides them. | read-only |
59
+ | `CODEANT_TELEMETRY_DISABLED` | `1` or `true` stops usage events (PostHog, tied to the API token). | enabled |
60
+ | `CODEANT_MCP_MAX_RESULT_CHARS` | Largest tool result, in characters, before the server refuses it with a hint. | `80000` |
52
61
 
53
- The CLI also reads these from `~/.codeant/config.json` when not set in env. For MCP usage prefer env — it keeps per-client config explicit.
62
+ The token, base URL, and dashboard URL fall back to `~/.codeant/config.json` when not set in env. PR tools also need an SCM token (`GITHUB_TOKEN`, `GITLAB_TOKEN`, `BITBUCKET_TOKEN`, `AZURE_DEVOPS_TOKEN`) and, for Azure DevOps, `AZURE_DEVOPS_ORG_URL`.
54
63
 
55
64
  ---
56
65
 
@@ -109,10 +118,13 @@ npm run mcpb:build
109
118
  open -a Claude dist/codeant.mcpb
110
119
  ```
111
120
 
112
- Claude pops an install dialog asking for the `user_config` fields:
113
- - **CodeAnt API token** (sensitive, required)
114
- - **API base URL** (defaults to `https://api.codeant.ai`)
121
+ Claude pops an install dialog asking for the `user_config` fields (all optional):
122
+ - **CodeAnt API token** (sensitive; leave blank and call `codeant_login` instead)
123
+ - **API base URL** (defaults to `https://api.codeant.ai`, which the CLI maps to `https://service.codeant.ai`)
124
+ - **Dashboard URL** (self-hosted only; used by `codeant_login`)
115
125
  - **Read-only mode** (defaults to on)
126
+ - **Disable telemetry** (defaults to off)
127
+ - SCM tokens and self-hosted SCM URLs for the PR tools
116
128
 
117
129
  After install, manage it in **Settings → Connectors**.
118
130
 
@@ -162,7 +174,7 @@ cd dist/mcpb-stage
162
174
  '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'; sleep 1) | node server/index.js
163
175
  ```
164
176
 
165
- Expect 23 tools in the `tools/list` response (or 26 if `CODEANT_READ_ONLY=0`).
177
+ Expect 24 tools in the `tools/list` response (or 27 if `CODEANT_READ_ONLY=0`). `tests/mcp/server.test.js` checks that this list matches the manifest's `tools`.
166
178
 
167
179
  ### Bumping the version
168
180
 
@@ -191,9 +203,9 @@ CodeAnt's MCP server uses stdio + a packaged bundle, so the submission route is
191
203
 
192
204
  Reviewer notes worth preparing:
193
205
 
194
- - **Auth model.** CodeAnt uses a user-scoped API token entered into `user_config.api_token`. The token never leaves the user's machine — the bundle talks to `api.codeant.ai` (or the user's self-hosted URL) directly. No third-party OAuth flow needed.
206
+ - **Auth model.** Users sign in with the `codeant_login` tool (browser sign-in, token saved to `~/.codeant/config.json`) or paste a token into `user_config.api_token`. The token is stored only on the user's machine (`~/.codeant/config.json`) and is sent only to the CodeAnt API (or the user's self-hosted URL) as a Bearer token — never to any third party. No third-party OAuth flow needed.
195
207
  - **Sandbox creds.** Email support@codeant.ai for a reviewer sandbox token; paste it into the submission form's reviewer-notes field along with an org slug that has scans + PRs to browse.
196
- - **Write tools.** Gated behind `user_config.read_only` (defaults to on). Reviewers can toggle off to test `codeant_scans_start` / `codeant_pr_resolve`.
208
+ - **Write tools.** Gated behind `user_config.read_only` (defaults to on). Reviewers can toggle off to test `codeant_scans_start` / `codeant_pr_resolve` / `codeant_api_request`.
197
209
 
198
210
  ---
199
211
 
@@ -204,5 +216,8 @@ Reviewer notes worth preparing:
204
216
  | Server fails to start in Claude Desktop | `~/Library/Logs/Claude/mcp-server-codeant.log` |
205
217
  | Tool calls return errors | Same log — server stderr is captured there. |
206
218
  | Tool not listed at all | Check `CODEANT_READ_ONLY` — write tools are hidden when set to `1`. |
207
- | Auth errors on every tool | Confirm `CODEANT_API_TOKEN` is set in the MCP env (not just in `~/.codeant/config.json`). |
219
+ | Auth errors on every tool | Call `codeant_login`, or run `codeant login` in a terminal; the server reads `~/.codeant/config.json` on every request. An explicit `CODEANT_API_TOKEN` in the MCP env takes precedence, so check it is current. |
220
+ | `codeant_login` fails on a self-hosted instance | Set `CODEANT_DASHBOARD_URL` (or the MCPB **Dashboard URL** setting, or `codeant set-dashboard-url`). |
221
+ | A tool returns "Result too large" | Follow the hint (smaller `limit`, filters, `fields`), or raise `CODEANT_MCP_MAX_RESULT_CHARS` if your client accepts larger results. |
222
+ | `codeant_review_local` returns "Could not find a .git directory" | The client started the server outside a git repository (Claude Desktop does). Use a project-scoped client such as Claude Code. |
208
223
  | MCPB install dialog never appears | Open `.mcpb` with `open -a Claude dist/codeant.mcpb` to force the desktop app to handle it. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeant-cli",
3
- "version": "0.5.9",
3
+ "version": "0.5.10",
4
4
  "description": "Code review CLI tool",
5
5
  "type": "module",
6
6
  "repository": {
@@ -30,6 +30,7 @@ export async function runResults(opts = {}) {
30
30
  limit = 100,
31
31
  offset = 0,
32
32
  failFast = false,
33
+ returnEnvelope = false,
33
34
  } = opts;
34
35
 
35
36
  if (!repo) {
@@ -145,7 +146,8 @@ export async function runResults(opts = {}) {
145
146
  });
146
147
  }
147
148
 
148
- // 10. Render + emit
149
+ // 10. Render + emit (MCP callers take the envelope instead of stdout)
150
+ if (returnEnvelope) return envelope;
149
151
  const rendered = formatter.render(envelope);
150
152
  emit(rendered, outputPath, envelope.findings.length);
151
153
  }
package/src/mcp/server.js CHANGED
@@ -12,14 +12,14 @@ 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
 
@@ -171,34 +198,30 @@ export async function startMcpServer() {
171
198
  filterDismissed: z.boolean().optional().describe('Exclude dismissed findings (default false). When false, dismissed findings carry metadata.dismissed.'),
172
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
  }
@@ -254,15 +277,17 @@ export async function startMcpServer() {
254
277
  ticketStatuses: z.array(z.enum(['created', 'not_created'])).optional(),
255
278
  compliance: z.array(z.string()).optional(),
256
279
  validation: z.array(z.enum(['exploit_confirmed'])).optional(),
257
- limit: z.number().int().positive().max(100).optional(),
258
- 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.'),
259
282
  all: z.boolean().optional().describe('Fetch every matching page. Default false.'),
260
283
  maxWaitSeconds: z.number().int().nonnegative().max(600).optional(),
261
284
  },
262
285
  annotations: READ,
263
286
  },
264
287
  async (input) => {
265
- 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); }
266
291
  }
267
292
  );
268
293
 
@@ -295,14 +320,16 @@ export async function startMcpServer() {
295
320
  service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(),
296
321
  providerBaseUrl: z.string().url().optional(),
297
322
  repos: z.array(z.string()).optional().describe('Repositories in owner/repo form. Omit to query every repository.'),
298
- 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).'),
299
324
  offset: z.number().int().nonnegative().optional().describe('Start offset, a multiple of limit (default 0).'),
300
325
  all: z.boolean().optional().describe('Fetch every page from offset on instead of one page.'),
301
326
  },
302
327
  annotations: READ,
303
328
  },
304
329
  async (input) => {
305
- 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); }
306
333
  }
307
334
  );
308
335
 
@@ -318,11 +345,16 @@ export async function startMcpServer() {
318
345
  provider: z.enum(['aws', 'azure', 'gcp', 'all']).optional().describe('Default all.'),
319
346
  kind: z.enum(['cspm', 'vm', 'container']).optional().describe('Default cspm.'),
320
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.'),
321
351
  },
322
352
  annotations: READ,
323
353
  },
324
- async (input) => {
325
- 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); }
326
358
  }
327
359
  );
328
360
 
@@ -330,7 +362,7 @@ export async function startMcpServer() {
330
362
  'codeant_cloud_findings_list',
331
363
  {
332
364
  title: 'List cloud security findings',
333
- 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.',
334
366
  inputSchema: {
335
367
  org: z.string().optional(),
336
368
  service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(),
@@ -348,14 +380,22 @@ export async function startMcpServer() {
348
380
  subscriptionId: z.string().optional(),
349
381
  exploitAttemptedOnly: z.boolean().optional(),
350
382
  minDaysUnused: z.number().int().nonnegative().optional(),
351
- 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).'),
352
384
  offset: z.number().int().nonnegative().optional().describe('Start offset, a multiple of limit (default 0).'),
353
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.'),
354
387
  },
355
388
  annotations: READ,
356
389
  },
357
- async (input) => {
358
- 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); }
359
399
  }
360
400
  );
361
401
 
@@ -388,16 +428,21 @@ export async function startMcpServer() {
388
428
  'codeant_pentest_history',
389
429
  {
390
430
  title: 'List pentest engagements',
391
- 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.',
392
432
  inputSchema: {
393
433
  org: z.string().optional(),
394
434
  service: z.enum(['github', 'gitlab', 'bitbucket', 'azuredevops']).optional(),
395
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.'),
396
439
  },
397
440
  annotations: READ,
398
441
  },
399
- async (input) => {
400
- 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); }
401
446
  }
402
447
  );
403
448
 
@@ -445,7 +490,7 @@ export async function startMcpServer() {
445
490
  'codeant_api_get',
446
491
  {
447
492
  title: 'Call a CodeAnt GET API',
448
- 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.',
449
494
  inputSchema: {
450
495
  path: z.string().startsWith('/'),
451
496
  org: z.string().optional().describe('Organization name. Required when the login has multiple matching connections.'),
@@ -470,12 +515,11 @@ export async function startMcpServer() {
470
515
  inputSchema: {
471
516
  name: z.string().optional().describe('Repository in owner/repo form. Auto-detected if omitted.'),
472
517
  remote: z.enum(['github', 'gitlab', 'bitbucket', 'azure']).optional().describe('Auto-detected if omitted.'),
473
- defaultBranch: z.string().optional(),
474
- sourceBranch: z.string().optional(),
475
- 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.'),
476
520
  state: z.enum(['open', 'closed']).optional().describe('Default "open".'),
477
- limit: z.number().int().positive().max(100).optional(),
478
- 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.'),
479
523
  },
480
524
  annotations: READ,
481
525
  },
@@ -486,7 +530,6 @@ export async function startMcpServer() {
486
530
  await scm.listPullRequests({
487
531
  name: opts.name,
488
532
  remote: opts.remote,
489
- defaultBranch: opts.defaultBranch,
490
533
  sourceBranch: opts.sourceBranch,
491
534
  authorLogin: opts.author,
492
535
  state: opts.state ?? 'open',
@@ -502,12 +545,11 @@ export async function startMcpServer() {
502
545
  'codeant_pr_get',
503
546
  {
504
547
  title: 'Get pull request details',
505
- 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.',
506
549
  inputSchema: {
507
550
  prNumber: z.number().int().positive(),
508
551
  name: z.string().optional(),
509
552
  remote: z.enum(['github', 'gitlab', 'bitbucket', 'azure']).optional(),
510
- defaultBranch: z.string().optional(),
511
553
  },
512
554
  annotations: READ,
513
555
  },
@@ -518,7 +560,6 @@ export async function startMcpServer() {
518
560
  await scm.getPullRequest({
519
561
  name: opts.name,
520
562
  remote: opts.remote,
521
- defaultBranch: opts.defaultBranch,
522
563
  prNumber: input.prNumber,
523
564
  })
524
565
  );
@@ -530,14 +571,12 @@ export async function startMcpServer() {
530
571
  'codeant_pr_comments',
531
572
  {
532
573
  title: 'List PR comments',
533
- 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.',
534
575
  inputSchema: {
535
576
  prNumber: z.number().int().positive(),
536
577
  name: z.string().optional(),
537
578
  remote: z.enum(['github', 'gitlab', 'bitbucket', 'azure']).optional(),
538
- defaultBranch: z.string().optional(),
539
579
  codeantGenerated: z.boolean().optional().describe('Only return comments authored by CodeAnt.'),
540
- addressed: z.boolean().optional().describe('Filter by addressed/resolved status.'),
541
580
  createdAfter: z.string().optional().describe('ISO 8601.'),
542
581
  createdBefore: z.string().optional().describe('ISO 8601.'),
543
582
  },
@@ -550,10 +589,8 @@ export async function startMcpServer() {
550
589
  await scm.listPullRequestComments({
551
590
  name: opts.name,
552
591
  remote: opts.remote,
553
- defaultBranch: opts.defaultBranch,
554
592
  prNumber: input.prNumber,
555
593
  codeantGenerated: input.codeantGenerated,
556
- addressed: input.addressed,
557
594
  createdAfter: input.createdAfter,
558
595
  createdBefore: input.createdBefore,
559
596
  })
@@ -566,14 +603,12 @@ export async function startMcpServer() {
566
603
  'codeant_comments_search',
567
604
  {
568
605
  title: 'Search CodeAnt review comments',
569
- 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.',
570
607
  inputSchema: {
571
608
  query: z.string(),
572
609
  name: z.string().optional(),
573
610
  remote: z.enum(['github', 'gitlab', 'bitbucket', 'azure']).optional(),
574
- limit: z.number().int().positive().max(50).optional(),
575
- includeAddressed: z.boolean().optional(),
576
- createdAfter: z.string().optional().describe('ISO 8601.'),
611
+ limit: z.number().int().positive().max(50).optional().describe('Max matching comments returned (default 10).'),
577
612
  },
578
613
  annotations: READ,
579
614
  },
@@ -586,8 +621,6 @@ export async function startMcpServer() {
586
621
  remote: opts.remote,
587
622
  query: input.query,
588
623
  limit: input.limit ?? 10,
589
- includeAddressed: input.includeAddressed ?? false,
590
- createdAfter: input.createdAfter,
591
624
  })
592
625
  );
593
626
  } catch (err) { return fail(err); }
@@ -599,7 +632,7 @@ export async function startMcpServer() {
599
632
  'codeant_review_local',
600
633
  {
601
634
  title: 'Review local working-copy changes',
602
- 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.',
603
636
  inputSchema: {
604
637
  scope: z
605
638
  .enum(['all', 'uncommitted', 'staged-only', 'committed', 'last-commit', 'last-n-commits', 'base-branch', 'base-commit'])
@@ -626,32 +659,79 @@ export async function startMcpServer() {
626
659
  onProgress: () => {},
627
660
  onFilesReady: () => {},
628
661
  });
629
- return ok(result);
662
+ return result?.error ? fail(result) : ok(result);
630
663
  } catch (err) { return fail(err); }
631
664
  }
632
665
  );
633
666
 
634
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
+
635
721
  server.registerTool(
636
722
  'codeant_login',
637
723
  {
638
724
  title: 'Sign in to CodeAnt AI',
639
- 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.',
640
726
  inputSchema: {
641
- 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.'),
642
728
  },
643
729
  annotations: { ...WRITE_NON_DESTRUCTIVE, idempotentHint: true },
644
730
  },
645
- async ({ force }) => {
646
- try {
647
- const envToken = process.env.CODEANT_API_TOKEN;
648
- if (!force && ((envToken && envToken.trim()) || isAlreadyLoggedIn())) {
649
- return ok({ alreadyLoggedIn: true });
650
- }
651
- const { token, loginUrl } = await runLoginFlow();
652
- const masked = token ? `${token.slice(0, 8)}…` : null;
653
- return ok({ status: 'success', loginUrl, token: masked });
654
- } catch (err) { return fail(err); }
731
+ ({ force }) => {
732
+ const run = loginQueue.then(() => handleLogin({ force }));
733
+ loginQueue = run.catch(() => {});
734
+ return run;
655
735
  }
656
736
  );
657
737
 
@@ -659,12 +739,14 @@ export async function startMcpServer() {
659
739
  'codeant_logout',
660
740
  {
661
741
  title: 'Sign out of CodeAnt AI',
662
- 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.',
663
743
  inputSchema: {},
664
744
  annotations: { ...WRITE_NON_DESTRUCTIVE, idempotentHint: true },
665
745
  },
666
746
  async () => {
667
747
  try {
748
+ login?.controller.abort();
749
+ login = null;
668
750
  const result = await logoutCodeAnt();
669
751
  return ok({
670
752
  ...result,
@@ -715,7 +797,7 @@ export async function startMcpServer() {
715
797
  async (input) => {
716
798
  try {
717
799
  const text = await captureStdout(() => runStartScan(input));
718
- return { content: [{ type: 'text', text: text || '{}' }] };
800
+ return textResult(text || '{}');
719
801
  } catch (err) { return fail(err); }
720
802
  }
721
803
  );
@@ -753,6 +835,11 @@ export async function startMcpServer() {
753
835
  );
754
836
  }
755
837
 
838
+ return server;
839
+ }
840
+
841
+ export async function startMcpServer() {
842
+ const server = createMcpServer();
756
843
  const transport = new StdioServerTransport();
757
844
  await server.connect(transport);
758
845
  }
@@ -0,0 +1,83 @@
1
+ // Result shaping for MCP clients, which cap how much tool output they accept.
2
+ // CLI commands keep returning the full payloads; only MCP responses are trimmed.
3
+
4
+ const DEFAULT_MAX_RESULT_CHARS = 80_000;
5
+
6
+ const REPO_FIELDS = ['full_name', 'name', 'private', 'visibility', 'default_branch', 'language', 'description', 'archived', 'pushed_at'];
7
+ const CLOUD_SCAN_DETAIL = ['per_service', 'service_rollup', 'compliance_rollup', 'regions'];
8
+
9
+ export function maxResultChars() {
10
+ const configured = Number(process.env.CODEANT_MCP_MAX_RESULT_CHARS);
11
+ return Number.isInteger(configured) && configured > 0 ? configured : DEFAULT_MAX_RESULT_CHARS;
12
+ }
13
+
14
+ function page(items, { limit, offset = 0 }) {
15
+ const slice = items.slice(offset, offset + limit);
16
+ const end = offset + slice.length;
17
+ return { items: slice, total: items.length, offset, limit, next_offset: end < items.length ? end : null };
18
+ }
19
+
20
+ function pick(record, fields) {
21
+ const picked = {};
22
+ for (const field of fields) if (field in record) picked[field] = record[field];
23
+ return Object.keys(picked).length ? picked : record;
24
+ }
25
+
26
+ function omit(record, fields) {
27
+ const kept = { ...record };
28
+ for (const field of fields) delete kept[field];
29
+ return kept;
30
+ }
31
+
32
+ export function shapeRepos(result, { search, limit = 100, offset = 0, full = false } = {}) {
33
+ let repos = result.repos || [];
34
+ if (search) {
35
+ const query = search.toLowerCase();
36
+ repos = repos.filter((repo) => String(repo.full_name || repo.name || '').toLowerCase().includes(query));
37
+ }
38
+ const { items, ...pagination } = page(repos, { limit, offset });
39
+ return { org: result.org, ...pagination, repos: full ? items : items.map((repo) => pick(repo, REPO_FIELDS)) };
40
+ }
41
+
42
+ export function shapePentestHistory(result, { limit = 25, offset = 0, full = false } = {}) {
43
+ const { history = [], ...rest } = result;
44
+ const { items, ...pagination } = page(history, { limit, offset });
45
+ return { ...rest, ...pagination, history: full ? items : items.map((entry) => omit(entry, ['credit_unlock'])) };
46
+ }
47
+
48
+ function shapeCloudScans(result, { limit, offset, full }) {
49
+ if (!Array.isArray(result?.scans)) return result;
50
+ const { items, ...pagination } = page(result.scans, { limit, offset });
51
+ return {
52
+ ...result,
53
+ ...pagination,
54
+ total: result.total ?? pagination.total,
55
+ scans: full ? items : items.map((scan) => omit(scan, CLOUD_SCAN_DETAIL)),
56
+ };
57
+ }
58
+
59
+ export function shapeCloudHistory(result, { limit = 10, offset = 0, full = false } = {}) {
60
+ if (!result.providers) return shapeCloudScans(result, { limit, offset, full });
61
+ const providers = Object.fromEntries(
62
+ Object.entries(result.providers).map(([provider, value]) => [provider, shapeCloudScans(value, { limit, offset, full })]),
63
+ );
64
+ return { ...result, providers };
65
+ }
66
+
67
+ // CSPM findings come back unpaged (thousands per scan); VM/container results are
68
+ // already paged by the API, so only the per-finding detail is trimmed for them.
69
+ export function shapeCloudFindings(result, { kind = 'cspm', limit = 25, offset = 0, all = false, full = false } = {}) {
70
+ const { findings = [], ...rest } = result;
71
+ const trim = (items) => (full ? items : items.map((finding) => omit(finding, ['compliance'])));
72
+ if (kind !== 'cspm') return { ...rest, findings: trim(findings) };
73
+ const { items, ...pagination } = page(findings, { limit: all ? findings.length : limit, offset });
74
+ return { ...rest, ...pagination, total: rest.total ?? pagination.total, findings: trim(items) };
75
+ }
76
+
77
+ export function tooLarge(length, hint) {
78
+ const max = maxResultChars();
79
+ return {
80
+ error: `Result too large: ${length} characters (about ${Math.round(length / 4)} tokens), over the ${max}-character limit for one tool result.`,
81
+ hint: `${hint || 'Narrow the request with limit/offset, filters, or fields where the tool supports them.'} Set CODEANT_MCP_MAX_RESULT_CHARS to raise the limit.`,
82
+ };
83
+ }
@@ -7,7 +7,7 @@ export function getConnection() {
7
7
  if (!token) throw new Error('Azure DevOps token not found. Set AZURE_DEVOPS_TOKEN env var or run `codeant set-token azure <token>`.');
8
8
 
9
9
  const orgUrl = process.env.AZURE_DEVOPS_ORG_URL || getConfigValue('azureDevOpsOrgUrl');
10
- if (!orgUrl) throw new Error('Azure DevOps org URL not found. Set AZURE_DEVOPS_ORG_URL env var or run `codeant set-azure-org <url>`.');
10
+ if (!orgUrl) throw new Error('Azure DevOps org URL not found. Set the AZURE_DEVOPS_ORG_URL env var (for example https://dev.azure.com/<org>) or add azureDevOpsOrgUrl to ~/.codeant/config.json.');
11
11
 
12
12
  const authHandler = azdev.getPersonalAccessTokenHandler(token);
13
13
  return new azdev.WebApi(orgUrl, authHandler);
@@ -4,6 +4,7 @@ import { getBaseUrl, getDashboardUrl } from './baseUrl.js';
4
4
 
5
5
  const DEFAULT_POLL_INTERVAL = 10_000;
6
6
  const DEFAULT_TIMEOUT = 10 * 60 * 1000;
7
+ const BROWSER_SPAWN_TIMEOUT = 2_000;
7
8
 
8
9
  export function isAlreadyLoggedIn() {
9
10
  return !!getConfigValue('apiKeyV2');
@@ -16,16 +17,24 @@ export async function startLoginFlow() {
16
17
  const loginUrl = `${dashboardUrl}?ideLoginToken=${token}`;
17
18
  const pollUrl = `${baseUrl}/extension/login/status?apiKey=${token}`;
18
19
 
19
- let browserOpened = false;
20
+ const browserOpened = await openBrowser(loginUrl);
21
+ return { token, loginUrl, pollUrl, browserOpened };
22
+ }
23
+
24
+ // `open` returns the launcher process without an 'error' listener, so a missing
25
+ // launcher (SSH, containers) would otherwise crash the MCP server.
26
+ async function openBrowser(url) {
20
27
  try {
21
28
  const { default: open } = await import('open');
22
- await open(loginUrl);
23
- browserOpened = true;
29
+ const child = await open(url);
30
+ return await new Promise((resolve) => {
31
+ child.once('error', () => resolve(false));
32
+ child.once('spawn', () => resolve(true));
33
+ setTimeout(() => resolve(true), BROWSER_SPAWN_TIMEOUT).unref?.();
34
+ });
24
35
  } catch {
25
- // Caller can fall back to printing loginUrl.
36
+ return false;
26
37
  }
27
-
28
- return { token, loginUrl, pollUrl, browserOpened };
29
38
  }
30
39
 
31
40
  export async function awaitLoginCompletion({
@@ -47,29 +56,26 @@ export async function awaitLoginCompletion({
47
56
  try {
48
57
  const response = await fetch(pollUrl);
49
58
  const data = await response.json();
50
- if (data.status === 'yes') {
59
+ // Do not save the token if the login was aborted while this poll was in flight.
60
+ if (data.status === 'yes' && !signal?.aborted) {
51
61
  setConfigValue('apiKeyV2', token);
52
- process.env.CODEANT_API_TOKEN = token;
53
62
  return { ok: true, token };
54
63
  }
55
64
  } catch {
56
65
  // Network blip — keep polling.
57
66
  }
58
67
 
68
+ if (signal?.aborted) throw new Error('Login aborted');
69
+
59
70
  const remaining = deadline - Date.now();
60
71
  if (remaining <= 0) break;
61
- await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, remaining)));
72
+ await new Promise((resolve) => {
73
+ const onAbort = () => { clearTimeout(timer); resolve(); };
74
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', onAbort); resolve(); }, Math.min(pollIntervalMs, remaining));
75
+ signal?.addEventListener('abort', onAbort, { once: true });
76
+ });
62
77
  }
63
78
 
79
+ if (signal?.aborted) throw new Error('Login aborted');
64
80
  throw new Error('Login timed out. Please try again.');
65
81
  }
66
-
67
- export async function runLoginFlow({
68
- pollIntervalMs = DEFAULT_POLL_INTERVAL,
69
- timeoutMs = DEFAULT_TIMEOUT,
70
- signal,
71
- } = {}) {
72
- const { token, loginUrl, pollUrl, browserOpened } = await startLoginFlow();
73
- const result = await awaitLoginCompletion({ token, pollUrl, pollIntervalMs, timeoutMs, signal });
74
- return { ...result, loginUrl, browserOpened };
75
- }