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 +32 -17
- package/package.json +1 -1
- package/src/commands/scans/results.js +3 -1
- package/src/mcp/server.js +182 -95
- package/src/mcp/shape.js +83 -0
- package/src/scm/azure/client.js +1 -1
- package/src/utils/loginFlow.js +25 -19
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 (
|
|
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
|
|
50
|
-
| `CODEANT_API_URL` | API base URL. Override for self-hosted. | `https://
|
|
51
|
-
| `
|
|
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
|
|
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
|
|
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
|
|
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.**
|
|
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 |
|
|
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
|
@@ -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,
|
|
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
|
-
|
|
38
|
-
|
|
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
|
|
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(
|
|
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
|
|
74
|
+
return { ...input, remote, name };
|
|
56
75
|
}
|
|
57
76
|
|
|
58
|
-
// Capture stdout from a function that writes JSON to stdout (
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
86
|
-
|
|
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
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
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 {
|
|
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.
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
474
|
-
|
|
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
|
|
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
|
|
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: '
|
|
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: '
|
|
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('
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
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: '
|
|
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
|
|
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
|
}
|
package/src/mcp/shape.js
ADDED
|
@@ -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
|
+
}
|
package/src/scm/azure/client.js
CHANGED
|
@@ -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
|
|
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);
|
package/src/utils/loginFlow.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
-
}
|