hakira-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +198 -0
- package/dist/auth/credentials.d.ts +12 -0
- package/dist/auth/credentials.js +61 -0
- package/dist/auth/loopback.d.ts +26 -0
- package/dist/auth/loopback.js +178 -0
- package/dist/config.d.ts +8 -0
- package/dist/config.js +21 -0
- package/dist/git/exec.d.ts +23 -0
- package/dist/git/exec.js +75 -0
- package/dist/git/metadata.d.ts +20 -0
- package/dist/git/metadata.js +40 -0
- package/dist/git/repo-key.d.ts +7 -0
- package/dist/git/repo-key.js +91 -0
- package/dist/http/cp-client.d.ts +182 -0
- package/dist/http/cp-client.js +196 -0
- package/dist/http/errors.d.ts +28 -0
- package/dist/http/errors.js +47 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +52 -0
- package/dist/log.d.ts +5 -0
- package/dist/log.js +11 -0
- package/dist/resources/finding.d.ts +3 -0
- package/dist/resources/finding.js +24 -0
- package/dist/tools/cancel.d.ts +3 -0
- package/dist/tools/cancel.js +20 -0
- package/dist/tools/context.d.ts +9 -0
- package/dist/tools/context.js +1 -0
- package/dist/tools/get-audit-events.d.ts +3 -0
- package/dist/tools/get-audit-events.js +32 -0
- package/dist/tools/get-finding.d.ts +3 -0
- package/dist/tools/get-finding.js +15 -0
- package/dist/tools/get-findings.d.ts +3 -0
- package/dist/tools/get-findings.js +31 -0
- package/dist/tools/get-status.d.ts +3 -0
- package/dist/tools/get-status.js +13 -0
- package/dist/tools/list-audits.d.ts +3 -0
- package/dist/tools/list-audits.js +35 -0
- package/dist/tools/list-workspaces.d.ts +3 -0
- package/dist/tools/list-workspaces.js +24 -0
- package/dist/tools/resolve-run-mode.d.ts +31 -0
- package/dist/tools/resolve-run-mode.js +84 -0
- package/dist/tools/start-audit.d.ts +3 -0
- package/dist/tools/start-audit.js +109 -0
- package/dist/tools/wrap.d.ts +13 -0
- package/dist/tools/wrap.js +89 -0
- package/dist/upload/presigned.d.ts +23 -0
- package/dist/upload/presigned.js +32 -0
- package/dist/upload/zip.d.ts +22 -0
- package/dist/upload/zip.js +182 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +16 -0
- package/package.json +41 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import { withAuthRetry } from '../tools/wrap.js';
|
|
3
|
+
// hakira://finding/<id> → GET /findings/:id (00 §3 row 6, owner-scoped). The
|
|
4
|
+
// drill-down behind the summary links from get_audit_findings. A 404 / non-owner
|
|
5
|
+
// returns an error JSON body (not a crash).
|
|
6
|
+
export function registerFindingResource(server, deps) {
|
|
7
|
+
server.registerResource('finding', new ResourceTemplate('hakira://finding/{id}', { list: undefined }), { title: 'Hakira finding', mimeType: 'application/json' }, async (uri, variables) => {
|
|
8
|
+
const id = String(variables.id);
|
|
9
|
+
let payload;
|
|
10
|
+
try {
|
|
11
|
+
payload = await withAuthRetry(deps, () => deps.cp.getFinding(id));
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
payload = errorBody(err);
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify(payload) }],
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
function errorBody(err) {
|
|
22
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
23
|
+
return { error: 'not_available', message };
|
|
24
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { wrap, textResult } from './wrap.js';
|
|
3
|
+
export function registerCancel(server, deps) {
|
|
4
|
+
server.registerTool('cancel_audit', {
|
|
5
|
+
title: 'Cancel a running Hakira audit',
|
|
6
|
+
description: 'Request cancellation of a running audit. On success returns the audit\'s REAL status: ' +
|
|
7
|
+
'"canceled" if it stopped, or "ready"/"error" if it had already finished on its own ' +
|
|
8
|
+
'(both fine — nothing more to do). "provisioning" means it was not stoppable yet; ' +
|
|
9
|
+
'call cancel_audit again shortly. ' +
|
|
10
|
+
'On error: "not_cancelable" means this audit was not started through MCP (a web-app chat) — ' +
|
|
11
|
+
'do NOT retry, tell the user to stop it from the Hakira web app. "cancel_failed" means the ' +
|
|
12
|
+
'audit may STILL be running and still spending credits — retry, or tell the user to stop it ' +
|
|
13
|
+
'in the web app. Never report a cancel_failed audit as stopped. ' +
|
|
14
|
+
'NOTE: cancellation is not an instant hard stop — in-flight work may accrue a little more ' +
|
|
15
|
+
'spend until the agent halts. Do not present it as immediate.',
|
|
16
|
+
inputSchema: {
|
|
17
|
+
audit_id: z.string().describe('The audit_id to cancel.'),
|
|
18
|
+
},
|
|
19
|
+
}, async (args) => wrap(deps, async () => textResult(await deps.cp.cancelAudit(args.audit_id))));
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CpClient } from '../http/cp-client.js';
|
|
2
|
+
/** Dependencies every tool handler is built with (injectable for tests). */
|
|
3
|
+
export interface ToolDeps {
|
|
4
|
+
cp: CpClient;
|
|
5
|
+
resolveToken: () => Promise<string>;
|
|
6
|
+
clearCredentials: () => void;
|
|
7
|
+
/** The coding agent's project root (usually process.cwd()). */
|
|
8
|
+
root: string;
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { wrap, textResult } from './wrap.js';
|
|
3
|
+
export function registerGetEvents(server, deps) {
|
|
4
|
+
server.registerTool('get_audit_events', {
|
|
5
|
+
title: 'Read the live transcript of a Hakira audit',
|
|
6
|
+
description: 'Free. Activity trail of what Hakira has done / is doing: coalesced assistant text, collapsed tool runs ' +
|
|
7
|
+
'(e.g. Read ×N with sample paths), progress, findings. No tool result bodies — narrate from events, do not dump the JSON. ' +
|
|
8
|
+
'Use when the user asks to check an audit. Omit `after` for the latest page; pass `after: next_after` to continue. ' +
|
|
9
|
+
'Use get_audit_findings for full finding bodies; get_audit_status for coarse status/cost.',
|
|
10
|
+
inputSchema: {
|
|
11
|
+
audit_id: z.string().describe('The audit_id returned by start_audit.'),
|
|
12
|
+
// Keep schemas simple — Cursor silently drops tools whose JSON Schema
|
|
13
|
+
// includes Zod's Number.MAX_SAFE_INTEGER maximum from .int().nonnegative().
|
|
14
|
+
after: z
|
|
15
|
+
.number()
|
|
16
|
+
.optional()
|
|
17
|
+
.describe('Forward cursor (event id). Omit for the latest page (tail).'),
|
|
18
|
+
limit: z
|
|
19
|
+
.number()
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Max raw events per page (default 20, max 100).'),
|
|
22
|
+
},
|
|
23
|
+
}, async (args) => wrap(deps, async () => {
|
|
24
|
+
const after = typeof args.after === 'number' && Number.isFinite(args.after) && args.after >= 0
|
|
25
|
+
? Math.floor(args.after)
|
|
26
|
+
: undefined;
|
|
27
|
+
const limit = typeof args.limit === 'number' && Number.isFinite(args.limit)
|
|
28
|
+
? Math.max(1, Math.min(100, Math.floor(args.limit)))
|
|
29
|
+
: undefined;
|
|
30
|
+
return textResult(await deps.cp.getAuditEvents(args.audit_id, { after, limit }));
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { wrap, textResult } from './wrap.js';
|
|
3
|
+
export function registerGetFinding(server, deps) {
|
|
4
|
+
server.registerTool('get_finding', {
|
|
5
|
+
title: 'Read full detail for one Hakira finding',
|
|
6
|
+
description: 'Free. Full finding body: description, evidence, recommendation (plus title/severity/target). ' +
|
|
7
|
+
'REQUIRED when the user asks what a finding is about, how to fix it, or for critical/high detail. ' +
|
|
8
|
+
'Do not invent detail from get_audit_findings titles alone — call this with the finding id.',
|
|
9
|
+
inputSchema: {
|
|
10
|
+
finding_id: z
|
|
11
|
+
.string()
|
|
12
|
+
.describe('Finding id from get_audit_findings (or the id in hakira://finding/<id>).'),
|
|
13
|
+
},
|
|
14
|
+
}, async (args) => wrap(deps, async () => textResult(await deps.cp.getFinding(args.finding_id))));
|
|
15
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { wrap, textResult } from './wrap.js';
|
|
3
|
+
const AGENT_INSTRUCTION = 'These are SUMMARIES only (title/severity/target) — not enough to explain or fix. ' +
|
|
4
|
+
'When the user asks what findings are about, for details, or for critical/high: ' +
|
|
5
|
+
'call get_finding for EACH relevant finding_id (or read hakira://finding/<id>). ' +
|
|
6
|
+
'Do not invent description/evidence/recommendation from titles.';
|
|
7
|
+
export function registerGetFindings(server, deps) {
|
|
8
|
+
server.registerTool('get_audit_findings', {
|
|
9
|
+
title: 'List findings for a Hakira audit (summaries)',
|
|
10
|
+
description: 'Free. Summary list only: id, title, severity, category, target. ' +
|
|
11
|
+
'Does NOT include description, evidence, or recommendation. ' +
|
|
12
|
+
'When the user asks what findings are about, how to fix them, or for critical/high detail: ' +
|
|
13
|
+
'you MUST then call get_finding for each relevant id (prefer min_severity=critical or high first). ' +
|
|
14
|
+
'Optionally filter with min_severity.',
|
|
15
|
+
inputSchema: {
|
|
16
|
+
audit_id: z.string().describe('The audit_id from start_audit / list_audits.'),
|
|
17
|
+
min_severity: z
|
|
18
|
+
.string()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe('Minimum severity to include: low | medium | high | critical.'),
|
|
21
|
+
},
|
|
22
|
+
}, async (args) => wrap(deps, async () => {
|
|
23
|
+
const findings = await deps.cp.getFindings(args.audit_id, {
|
|
24
|
+
min_severity: args.min_severity,
|
|
25
|
+
});
|
|
26
|
+
return textResult({
|
|
27
|
+
...findings,
|
|
28
|
+
agent_instruction: AGENT_INSTRUCTION,
|
|
29
|
+
});
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { wrap, textResult } from './wrap.js';
|
|
3
|
+
export function registerGetStatus(server, deps) {
|
|
4
|
+
server.registerTool('get_audit_status', {
|
|
5
|
+
title: 'Check the status of a Hakira audit',
|
|
6
|
+
description: 'Poll a running audit. Returns { status, phase?, findings_count, cost_so_far_usd, poll_after_ms }. ' +
|
|
7
|
+
'Statuses progress queued → provisioning → running → ready (or canceled / error). ' +
|
|
8
|
+
'When non-terminal, wait `poll_after_ms` before polling again.',
|
|
9
|
+
inputSchema: {
|
|
10
|
+
audit_id: z.string().describe('The audit_id returned by start_audit.'),
|
|
11
|
+
},
|
|
12
|
+
}, async (args) => wrap(deps, async () => textResult(await deps.cp.getStatus(args.audit_id))));
|
|
13
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { wrap, textResult } from './wrap.js';
|
|
3
|
+
import { resolveRepoKey } from '../git/repo-key.js';
|
|
4
|
+
export function registerListAudits(server, deps) {
|
|
5
|
+
server.registerTool('list_audits', {
|
|
6
|
+
title: 'List recent Hakira audits and sessions',
|
|
7
|
+
description: 'Free. List recent audits/sessions across your Hakira account (most recent first), with status, ' +
|
|
8
|
+
'workspace, origin (mcp|ui|gate), findings count and cost. `current_workspace_id` / `is_current` ' +
|
|
9
|
+
'mark the workspace bound to this local project. Optional `workspace_id` filters to one workspace. ' +
|
|
10
|
+
'Use get_audit_events / get_audit_status / get_audit_findings with any owned audit_id.',
|
|
11
|
+
inputSchema: {
|
|
12
|
+
workspace_id: z
|
|
13
|
+
.string()
|
|
14
|
+
.optional()
|
|
15
|
+
.describe('If set, only list sessions for this workspace. Omit for all workspaces.'),
|
|
16
|
+
limit: z.number().optional().describe('Max audits to return (default 20, max 100).'),
|
|
17
|
+
},
|
|
18
|
+
}, async (args) => wrap(deps, async () => {
|
|
19
|
+
await deps.resolveToken();
|
|
20
|
+
const repoKey = resolveRepoKey(deps.root);
|
|
21
|
+
const bind = await deps.cp.bindWorkspace(repoKey);
|
|
22
|
+
const current = bind.workspace_id;
|
|
23
|
+
const { audits } = await deps.cp.listAudits({
|
|
24
|
+
...(args.workspace_id ? { workspace_id: args.workspace_id } : {}),
|
|
25
|
+
limit: args.limit,
|
|
26
|
+
});
|
|
27
|
+
return textResult({
|
|
28
|
+
current_workspace_id: current,
|
|
29
|
+
audits: audits.map((a) => ({
|
|
30
|
+
...a,
|
|
31
|
+
is_current: a.workspace_id === current,
|
|
32
|
+
})),
|
|
33
|
+
});
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { wrap, textResult } from './wrap.js';
|
|
2
|
+
import { resolveRepoKey } from '../git/repo-key.js';
|
|
3
|
+
export function registerListWorkspaces(server, deps) {
|
|
4
|
+
server.registerTool('list_workspaces', {
|
|
5
|
+
title: 'List all Hakira workspaces',
|
|
6
|
+
description: 'Free. List every workspace on your Hakira account. `current_workspace_id` / `is_current` mark ' +
|
|
7
|
+
'the workspace bound to this local project (via git remote / .hakira/mcp.json). Use list_audits ' +
|
|
8
|
+
'to see sessions; start_audit always runs on the current project workspace.',
|
|
9
|
+
inputSchema: {},
|
|
10
|
+
}, async () => wrap(deps, async () => {
|
|
11
|
+
await deps.resolveToken();
|
|
12
|
+
const repoKey = resolveRepoKey(deps.root);
|
|
13
|
+
const bind = await deps.cp.bindWorkspace(repoKey);
|
|
14
|
+
const current = bind.workspace_id;
|
|
15
|
+
const { workspaces } = await deps.cp.listWorkspaces();
|
|
16
|
+
return textResult({
|
|
17
|
+
current_workspace_id: current,
|
|
18
|
+
workspaces: workspaces.map((w) => ({
|
|
19
|
+
...w,
|
|
20
|
+
is_current: w.workspace_id === current,
|
|
21
|
+
})),
|
|
22
|
+
});
|
|
23
|
+
}));
|
|
24
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { ElicitRequestFormParams, ElicitResult } from '@modelcontextprotocol/sdk/types.js';
|
|
3
|
+
export type RunMode = 'background' | 'wait';
|
|
4
|
+
/** User declined/cancelled the background-vs-wait elicitation before spend. */
|
|
5
|
+
export declare class RunModeCancelledError extends Error {
|
|
6
|
+
constructor(message?: string);
|
|
7
|
+
}
|
|
8
|
+
export declare const AGENT_INSTRUCTION: Record<RunMode, string>;
|
|
9
|
+
export declare function modeResultFields(mode: RunMode): {
|
|
10
|
+
mode: RunMode;
|
|
11
|
+
do_not_poll: boolean;
|
|
12
|
+
agent_instruction: string;
|
|
13
|
+
};
|
|
14
|
+
/** Injectable surface so unit tests do not need a live MCP transport. */
|
|
15
|
+
export interface RunModeElicitor {
|
|
16
|
+
getClientCapabilities(): {
|
|
17
|
+
elicitation?: unknown;
|
|
18
|
+
} | undefined;
|
|
19
|
+
elicitInput(params: ElicitRequestFormParams): Promise<ElicitResult>;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Resolve background vs wait for start_audit.
|
|
23
|
+
* - Explicit `mode` wins (skip elicitation).
|
|
24
|
+
* - Else, if the client supports elicitation, ask the user (decline/cancel aborts).
|
|
25
|
+
* - Else (or elicit failure): default to background.
|
|
26
|
+
*/
|
|
27
|
+
export declare function resolveRunMode(opts: {
|
|
28
|
+
mode?: RunMode;
|
|
29
|
+
server?: McpServer;
|
|
30
|
+
elicitor?: RunModeElicitor;
|
|
31
|
+
}): Promise<RunMode>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { log } from '../log.js';
|
|
2
|
+
/** User declined/cancelled the background-vs-wait elicitation before spend. */
|
|
3
|
+
export class RunModeCancelledError extends Error {
|
|
4
|
+
constructor(message = 'Audit cancelled — choose background or wait to start.') {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'RunModeCancelledError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export const AGENT_INSTRUCTION = {
|
|
10
|
+
background: 'Do NOT poll get_audit_status in a loop. Tell the user the audit_id and that they can ask later for status/findings. If they ask to check what Hakira is doing, call get_audit_events (omit after for the latest page). Continue the conversation.',
|
|
11
|
+
wait: 'Poll get_audit_status until ready (or canceled/error), waiting poll_after_ms between calls, then call get_audit_findings. Optionally call get_audit_events if the user asks what Hakira is doing mid-run.',
|
|
12
|
+
};
|
|
13
|
+
export function modeResultFields(mode) {
|
|
14
|
+
return {
|
|
15
|
+
mode,
|
|
16
|
+
do_not_poll: mode === 'background',
|
|
17
|
+
agent_instruction: AGENT_INSTRUCTION[mode],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
const ELICIT_MESSAGE = 'How should this Hakira audit run? Background keeps this chat free (you can ask for status later). Wait blocks until findings are ready.';
|
|
21
|
+
const ELICIT_SCHEMA = {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
mode: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
title: 'Run mode',
|
|
27
|
+
description: 'background = keep chatting; wait = poll this turn until ready',
|
|
28
|
+
enum: ['background', 'wait'],
|
|
29
|
+
default: 'background',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
required: ['mode'],
|
|
33
|
+
};
|
|
34
|
+
function elicitorFromServer(server) {
|
|
35
|
+
return {
|
|
36
|
+
getClientCapabilities: () => server.server.getClientCapabilities(),
|
|
37
|
+
elicitInput: (params) => server.server.elicitInput(params),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function parseAcceptedMode(content) {
|
|
41
|
+
const raw = content?.mode;
|
|
42
|
+
if (raw === 'background' || raw === 'wait')
|
|
43
|
+
return raw;
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve background vs wait for start_audit.
|
|
48
|
+
* - Explicit `mode` wins (skip elicitation).
|
|
49
|
+
* - Else, if the client supports elicitation, ask the user (decline/cancel aborts).
|
|
50
|
+
* - Else (or elicit failure): default to background.
|
|
51
|
+
*/
|
|
52
|
+
export async function resolveRunMode(opts) {
|
|
53
|
+
if (opts.mode === 'background' || opts.mode === 'wait')
|
|
54
|
+
return opts.mode;
|
|
55
|
+
const elicitor = opts.elicitor ?? (opts.server ? elicitorFromServer(opts.server) : undefined);
|
|
56
|
+
if (!elicitor?.getClientCapabilities()?.elicitation) {
|
|
57
|
+
return 'background';
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const result = await elicitor.elicitInput({
|
|
61
|
+
mode: 'form',
|
|
62
|
+
message: ELICIT_MESSAGE,
|
|
63
|
+
requestedSchema: ELICIT_SCHEMA,
|
|
64
|
+
});
|
|
65
|
+
if (result.action === 'decline' || result.action === 'cancel') {
|
|
66
|
+
throw new RunModeCancelledError();
|
|
67
|
+
}
|
|
68
|
+
if (result.action === 'accept') {
|
|
69
|
+
const chosen = parseAcceptedMode(result.content);
|
|
70
|
+
if (chosen)
|
|
71
|
+
return chosen;
|
|
72
|
+
log.warn('elicitation accept missing mode — defaulting to background');
|
|
73
|
+
return 'background';
|
|
74
|
+
}
|
|
75
|
+
log.warn(`unexpected elicitation action ${String(result.action)} — defaulting to background`);
|
|
76
|
+
return 'background';
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err instanceof RunModeCancelledError)
|
|
80
|
+
throw err;
|
|
81
|
+
log.warn('elicitation failed — defaulting to background', err instanceof Error ? err.message : err);
|
|
82
|
+
return 'background';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { wrap, textResult } from './wrap.js';
|
|
3
|
+
import { CpError } from '../http/errors.js';
|
|
4
|
+
import { resolveRepoKey } from '../git/repo-key.js';
|
|
5
|
+
import { captureGitMetadata, computeChangedFiles, buildTargetGist } from '../git/metadata.js';
|
|
6
|
+
import { buildZip } from '../upload/zip.js';
|
|
7
|
+
import { upload } from '../upload/presigned.js';
|
|
8
|
+
import { config } from '../config.js';
|
|
9
|
+
import { log } from '../log.js';
|
|
10
|
+
import { modeResultFields, resolveRunMode } from './resolve-run-mode.js';
|
|
11
|
+
// The one paid tool. Composes S2/S4/S5/S6/S7 into git → zip → upload → POST
|
|
12
|
+
// /mcp/audits, returning a fast handle. Mode (background|wait) is chosen via
|
|
13
|
+
// MCP elicitation when the host supports it, else defaults to background.
|
|
14
|
+
const DESCRIPTION = [
|
|
15
|
+
'Start a Hakira cloud security audit of the current project.',
|
|
16
|
+
'',
|
|
17
|
+
'Spends credits: this is the only Hakira tool that does. `scope:"full"` (the default) is a',
|
|
18
|
+
'whole-project deep audit; `scope:"diff"` audits only the locally-changed files (shipped as a',
|
|
19
|
+
'prioritization hint) and costs a small fraction of a full audit. Optionally target a running',
|
|
20
|
+
'deployment with `url`.',
|
|
21
|
+
'',
|
|
22
|
+
'Returns immediately with { audit_id, status, mode, do_not_poll, agent_instruction }.',
|
|
23
|
+
'',
|
|
24
|
+
'IMPORTANT — `mode` argument:',
|
|
25
|
+
'- Do NOT pass `mode` unless the user explicitly said "background" or "wait".',
|
|
26
|
+
'- Omit `mode` so the host can elicit background vs wait (or default to background).',
|
|
27
|
+
'- Inventing `mode:"wait"` blocks the chat; inventing `mode:"background"` skips the user prompt.',
|
|
28
|
+
'When elicitation runs and the user cancels, the audit does not start.',
|
|
29
|
+
'',
|
|
30
|
+
'ALWAYS follow `agent_instruction` / `do_not_poll` in the result:',
|
|
31
|
+
'- background: do NOT poll; tell the user the audit_id; they can ask later for status/findings.',
|
|
32
|
+
'- wait: poll `get_audit_status` until `ready` (stop on canceled/error), then `get_audit_findings`.',
|
|
33
|
+
].join('\n');
|
|
34
|
+
export function registerStartAudit(server, deps) {
|
|
35
|
+
server.registerTool('start_audit', {
|
|
36
|
+
title: 'Start a Hakira cloud security audit',
|
|
37
|
+
description: DESCRIPTION,
|
|
38
|
+
inputSchema: {
|
|
39
|
+
scope: z
|
|
40
|
+
.enum(['full', 'diff'])
|
|
41
|
+
.optional()
|
|
42
|
+
.describe('`full` = whole-project deep audit (default). `diff` = prioritize changed files.'),
|
|
43
|
+
ref: z.string().optional().describe('Optional git ref (branch/tag/commit) to audit instead of the working tree.'),
|
|
44
|
+
url: z.string().optional().describe('Optional running target URL for dynamic testing alongside the source.'),
|
|
45
|
+
focus: z.string().optional().describe('Optional free-text focus, e.g. "auth flows" or "the payment webhook".'),
|
|
46
|
+
path: z.string().optional().describe('Optional sub-path within the repo to concentrate on.'),
|
|
47
|
+
mode: z
|
|
48
|
+
.enum(['background', 'wait'])
|
|
49
|
+
.optional()
|
|
50
|
+
.describe('ONLY if the user explicitly requested it. Omit otherwise so elicitation (or background default) can choose. Never invent wait/background.'),
|
|
51
|
+
},
|
|
52
|
+
}, async (args) => wrap(deps, async () => {
|
|
53
|
+
// Force auth (login / headless check) BEFORE the expensive zip+upload.
|
|
54
|
+
await deps.resolveToken();
|
|
55
|
+
const repoKey = resolveRepoKey(deps.root);
|
|
56
|
+
const meta = captureGitMetadata(deps.root, args.ref);
|
|
57
|
+
const bind = await deps.cp.bindWorkspace(repoKey);
|
|
58
|
+
if (bind.status === 'error') {
|
|
59
|
+
throw new CpError(409, {
|
|
60
|
+
error: 'workspace_needs_repair',
|
|
61
|
+
workspace_id: bind.workspace_id,
|
|
62
|
+
message: 'Workspace needs repair — cannot start an audit until it is repaired.',
|
|
63
|
+
}, 'Workspace needs repair');
|
|
64
|
+
}
|
|
65
|
+
// Paywall (P0): credits are purchased before the first audit. Fail fast
|
|
66
|
+
// HERE — before zip/upload/provision — so a broke user is sent straight to
|
|
67
|
+
// top-up instead of uploading a repo + booting a container that would be
|
|
68
|
+
// rejected server-side anyway (POST /mcp/audits enforces the same gate).
|
|
69
|
+
if (bind.balance_credits != null && bind.balance_credits <= 0) {
|
|
70
|
+
const url = bind.buy_credits_url ?? `${config.webUrl}/billing`;
|
|
71
|
+
throw new CpError(402, {
|
|
72
|
+
error: 'payment_required',
|
|
73
|
+
buy_credits_url: url,
|
|
74
|
+
message: `Insufficient credits — add credits at ${url} to start an audit.`,
|
|
75
|
+
}, 'Insufficient credits');
|
|
76
|
+
}
|
|
77
|
+
// HITL mode choice — after spend gates, before zip/upload so cancel is free.
|
|
78
|
+
const mode = await resolveRunMode({ server, mode: args.mode });
|
|
79
|
+
const scope = args.scope ?? 'full';
|
|
80
|
+
const changedFiles = scope === 'diff' ? computeChangedFiles(deps.root, { ref: args.ref }) : undefined;
|
|
81
|
+
const { buffer, manifest, excluded } = buildZip(deps.root, { ref: args.ref });
|
|
82
|
+
log.info(manifest);
|
|
83
|
+
const { r2_key } = await upload(deps.cp, bind.workspace_id, buffer, {
|
|
84
|
+
firstBind: !bind.bootstrapped,
|
|
85
|
+
});
|
|
86
|
+
const target = {
|
|
87
|
+
ref: args.ref,
|
|
88
|
+
url: args.url,
|
|
89
|
+
scope,
|
|
90
|
+
focus: args.focus,
|
|
91
|
+
path: args.path,
|
|
92
|
+
commit_sha: meta.commit_sha,
|
|
93
|
+
is_dirty: meta.is_dirty,
|
|
94
|
+
remote_url: meta.remote_url,
|
|
95
|
+
changed_files: changedFiles,
|
|
96
|
+
target_gist: buildTargetGist(meta, scope, changedFiles ?? []),
|
|
97
|
+
};
|
|
98
|
+
const res = await deps.cp.startAudit({ workspace_id: bind.workspace_id, r2_key, target });
|
|
99
|
+
// Include the manifest + excluded secret hits so the agent can relay what
|
|
100
|
+
// left the machine; full_project_estimate_usd is present only for full scope.
|
|
101
|
+
return textResult({
|
|
102
|
+
...res,
|
|
103
|
+
scope,
|
|
104
|
+
...modeResultFields(mode),
|
|
105
|
+
manifest,
|
|
106
|
+
excluded_from_upload: excluded,
|
|
107
|
+
});
|
|
108
|
+
}));
|
|
109
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import type { ToolDeps } from './context.js';
|
|
3
|
+
export declare function textResult(payload: unknown): CallToolResult;
|
|
4
|
+
export declare function errorResult(message: string): CallToolResult;
|
|
5
|
+
/**
|
|
6
|
+
* Run `fn`, and on a `CpUnauthorizedError` clear the cached credential and retry
|
|
7
|
+
* exactly ONCE. The second failure (or any non-401) propagates to the caller,
|
|
8
|
+
* which maps it to its own shape. The single definition of the auth-retry
|
|
9
|
+
* contract — shared by tool handlers (`wrap`) and the finding resource.
|
|
10
|
+
*/
|
|
11
|
+
export declare function withAuthRetry<T>(deps: Pick<ToolDeps, 'clearCredentials'>, fn: () => Promise<T>): Promise<T>;
|
|
12
|
+
/** Run a handler body with the uniform auth-retry + error mapping. */
|
|
13
|
+
export declare function wrap(deps: ToolDeps, fn: () => Promise<CallToolResult>): Promise<CallToolResult>;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { CpError, CpUnauthorizedError } from '../http/errors.js';
|
|
2
|
+
import { AuthorizationRequiredError } from '../auth/loopback.js';
|
|
3
|
+
import { RunModeCancelledError } from './resolve-run-mode.js';
|
|
4
|
+
import { log } from '../log.js';
|
|
5
|
+
// Uniform error surfacing for every tool handler (ticket S3). A CpError becomes a
|
|
6
|
+
// human-readable isError result; a 401 clears the cache + re-authorizes ONCE then
|
|
7
|
+
// retries; a second 401 is a clean error (no loop); a headless AuthorizationRequired
|
|
8
|
+
// is surfaced as the structured authorization_required shape (never hangs).
|
|
9
|
+
export function textResult(payload) {
|
|
10
|
+
const text = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|
11
|
+
return { content: [{ type: 'text', text }] };
|
|
12
|
+
}
|
|
13
|
+
export function errorResult(message) {
|
|
14
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Run `fn`, and on a `CpUnauthorizedError` clear the cached credential and retry
|
|
18
|
+
* exactly ONCE. The second failure (or any non-401) propagates to the caller,
|
|
19
|
+
* which maps it to its own shape. The single definition of the auth-retry
|
|
20
|
+
* contract — shared by tool handlers (`wrap`) and the finding resource.
|
|
21
|
+
*/
|
|
22
|
+
export async function withAuthRetry(deps, fn) {
|
|
23
|
+
try {
|
|
24
|
+
return await fn();
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
if (err instanceof CpUnauthorizedError) {
|
|
28
|
+
log.warn('401 from CP — clearing cached credential and re-authorizing once');
|
|
29
|
+
deps.clearCredentials();
|
|
30
|
+
return await fn(); // a second failure propagates
|
|
31
|
+
}
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Run a handler body with the uniform auth-retry + error mapping. */
|
|
36
|
+
export async function wrap(deps, fn) {
|
|
37
|
+
try {
|
|
38
|
+
return await withAuthRetry(deps, fn);
|
|
39
|
+
}
|
|
40
|
+
catch (err) {
|
|
41
|
+
// A CpUnauthorizedError here is necessarily the post-retry failure — a
|
|
42
|
+
// first-attempt 401 is always retried inside withAuthRetry.
|
|
43
|
+
return mapError(err, err instanceof CpUnauthorizedError);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function mapError(err, afterRetry) {
|
|
47
|
+
if (err instanceof RunModeCancelledError) {
|
|
48
|
+
return errorResult(err.message);
|
|
49
|
+
}
|
|
50
|
+
if (err instanceof AuthorizationRequiredError) {
|
|
51
|
+
return {
|
|
52
|
+
content: [
|
|
53
|
+
{
|
|
54
|
+
type: 'text',
|
|
55
|
+
text: JSON.stringify({
|
|
56
|
+
error: 'authorization_required',
|
|
57
|
+
authorize_url: err.authorize_url,
|
|
58
|
+
message: err.message,
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
isError: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (err instanceof CpUnauthorizedError) {
|
|
66
|
+
return errorResult(afterRetry
|
|
67
|
+
? 'Authentication failed after re-authorization. Check HAKIRA_TOKEN, or run `hakira-mcp logout` and retry.'
|
|
68
|
+
: err.message);
|
|
69
|
+
}
|
|
70
|
+
if (err instanceof CpError) {
|
|
71
|
+
return errorResult(cpErrorText(err));
|
|
72
|
+
}
|
|
73
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
74
|
+
return errorResult(`hakira-mcp error: ${message}`);
|
|
75
|
+
}
|
|
76
|
+
/** Ensure the human-readable message carries the structured id fields (00 §7). */
|
|
77
|
+
function cpErrorText(err) {
|
|
78
|
+
let text = err.message;
|
|
79
|
+
if (err.error === 'audit_already_running' && err.audit_id && !text.includes(err.audit_id)) {
|
|
80
|
+
text += ` (audit_id: ${err.audit_id})`;
|
|
81
|
+
}
|
|
82
|
+
if (err.error === 'workspace_needs_repair' && err.workspace_id && !text.includes(err.workspace_id)) {
|
|
83
|
+
text += ` (workspace_id: ${err.workspace_id})`;
|
|
84
|
+
}
|
|
85
|
+
if (err.error === 'payment_required' && err.buy_credits_url && !text.includes(err.buy_credits_url)) {
|
|
86
|
+
text += ` Add credits: ${err.buy_credits_url}`;
|
|
87
|
+
}
|
|
88
|
+
return text;
|
|
89
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { CpClient } from '../http/cp-client.js';
|
|
2
|
+
export interface UploadOptions {
|
|
3
|
+
firstBind: boolean;
|
|
4
|
+
excludeFolders?: string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Upload the ZIP and return the R2 key `start_audit` passes to POST /mcp/audits.
|
|
8
|
+
*
|
|
9
|
+
* On `firstBind` this uploads TWICE (00 §3 note / G5): once to drive the one-shot
|
|
10
|
+
* `/bootstrap` (consumed + deleted after AR extracts), then again for the scan's
|
|
11
|
+
* re-sync — the returned key is always a FRESH, unconsumed object.
|
|
12
|
+
*
|
|
13
|
+
* NON-BLOCKING (Option B): we do NOT wait for the first-bind bootstrap to finish.
|
|
14
|
+
* `completeUpload` enqueues it and returns; `start_audit` then POSTs to CP, which
|
|
15
|
+
* ACCEPTS the audit while the workspace is still provisioning and drives it via
|
|
16
|
+
* the deferred-kickoff worker once bootstrap completes. Honoring `start_audit`'s
|
|
17
|
+
* "returns immediately" promise is the whole point — the agent polls
|
|
18
|
+
* `get_audit_status` through provisioning → running → ready instead of the tool
|
|
19
|
+
* call blocking for the multi-minute provision window.
|
|
20
|
+
*/
|
|
21
|
+
export declare function upload(cp: CpClient, wsId: string, buffer: Buffer, opts: UploadOptions): Promise<{
|
|
22
|
+
r2_key: string;
|
|
23
|
+
}>;
|