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.
Files changed (53) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +198 -0
  3. package/dist/auth/credentials.d.ts +12 -0
  4. package/dist/auth/credentials.js +61 -0
  5. package/dist/auth/loopback.d.ts +26 -0
  6. package/dist/auth/loopback.js +178 -0
  7. package/dist/config.d.ts +8 -0
  8. package/dist/config.js +21 -0
  9. package/dist/git/exec.d.ts +23 -0
  10. package/dist/git/exec.js +75 -0
  11. package/dist/git/metadata.d.ts +20 -0
  12. package/dist/git/metadata.js +40 -0
  13. package/dist/git/repo-key.d.ts +7 -0
  14. package/dist/git/repo-key.js +91 -0
  15. package/dist/http/cp-client.d.ts +182 -0
  16. package/dist/http/cp-client.js +196 -0
  17. package/dist/http/errors.d.ts +28 -0
  18. package/dist/http/errors.js +47 -0
  19. package/dist/index.d.ts +5 -0
  20. package/dist/index.js +52 -0
  21. package/dist/log.d.ts +5 -0
  22. package/dist/log.js +11 -0
  23. package/dist/resources/finding.d.ts +3 -0
  24. package/dist/resources/finding.js +24 -0
  25. package/dist/tools/cancel.d.ts +3 -0
  26. package/dist/tools/cancel.js +20 -0
  27. package/dist/tools/context.d.ts +9 -0
  28. package/dist/tools/context.js +1 -0
  29. package/dist/tools/get-audit-events.d.ts +3 -0
  30. package/dist/tools/get-audit-events.js +32 -0
  31. package/dist/tools/get-finding.d.ts +3 -0
  32. package/dist/tools/get-finding.js +15 -0
  33. package/dist/tools/get-findings.d.ts +3 -0
  34. package/dist/tools/get-findings.js +31 -0
  35. package/dist/tools/get-status.d.ts +3 -0
  36. package/dist/tools/get-status.js +13 -0
  37. package/dist/tools/list-audits.d.ts +3 -0
  38. package/dist/tools/list-audits.js +35 -0
  39. package/dist/tools/list-workspaces.d.ts +3 -0
  40. package/dist/tools/list-workspaces.js +24 -0
  41. package/dist/tools/resolve-run-mode.d.ts +31 -0
  42. package/dist/tools/resolve-run-mode.js +84 -0
  43. package/dist/tools/start-audit.d.ts +3 -0
  44. package/dist/tools/start-audit.js +109 -0
  45. package/dist/tools/wrap.d.ts +13 -0
  46. package/dist/tools/wrap.js +89 -0
  47. package/dist/upload/presigned.d.ts +23 -0
  48. package/dist/upload/presigned.js +32 -0
  49. package/dist/upload/zip.d.ts +22 -0
  50. package/dist/upload/zip.js +182 -0
  51. package/dist/version.d.ts +1 -0
  52. package/dist/version.js +16 -0
  53. package/package.json +41 -0
@@ -0,0 +1,40 @@
1
+ import { tryGit } from './exec.js';
2
+ /** The user's REAL git state — `commit_sha` is HEAD (or the resolved `ref`). */
3
+ export function captureGitMetadata(root, ref) {
4
+ // `--verify` + the userRef channel: exactly one revision, never an option.
5
+ const commit_sha = tryGit(root, ['rev-parse', '--verify'], ref ?? 'HEAD');
6
+ const refName = ref ?? tryGit(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
7
+ const porcelain = tryGit(root, ['status', '--porcelain']);
8
+ return {
9
+ commit_sha,
10
+ ref: refName,
11
+ is_dirty: porcelain !== null && porcelain.length > 0,
12
+ remote_url: tryGit(root, ['remote', 'get-url', 'origin']),
13
+ };
14
+ }
15
+ /**
16
+ * Changed-file list for `scope:'diff'`. A WORKING-TREE diff vs the merge-base
17
+ * with the default branch — so it naturally includes uncommitted edits to
18
+ * tracked files (the point; sidesteps Semgrep's dirty-tree limit, spec §9).
19
+ * Shipped as a prioritization HINT, not a hard allowlist (00 §3 row 8).
20
+ */
21
+ export function computeChangedFiles(root, opts = {}) {
22
+ const base = opts.base ?? tryGit(root, ['merge-base', 'HEAD', 'origin/HEAD']) ?? opts.ref ?? 'HEAD';
23
+ const out = tryGit(root, ['diff', '--name-only'], base);
24
+ if (!out)
25
+ return [];
26
+ return out
27
+ .split('\n')
28
+ .map((s) => s.trim())
29
+ .filter(Boolean);
30
+ }
31
+ /** Human summary for sessions.target_gist (00 §5). */
32
+ export function buildTargetGist(meta, scope, changedFiles) {
33
+ const sha = meta.commit_sha ? meta.commit_sha.slice(0, 7) : 'working-tree';
34
+ if (scope === 'diff') {
35
+ const n = changedFiles.length;
36
+ const branch = meta.ref ?? 'HEAD';
37
+ return `diff vs ${branch} @ ${sha} (${n} file${n === 1 ? '' : 's'})`;
38
+ }
39
+ return `full audit @ ${sha}`;
40
+ }
@@ -0,0 +1,7 @@
1
+ /** Light, STABLE normalization — applied identically on every call. */
2
+ export declare function normalizeRepoKey(url: string): string;
3
+ /**
4
+ * 1. git remote origin URL → that URL is the repo_key.
5
+ * 2. else a stable uuid persisted in a locally-excluded `.hakira/mcp.json`.
6
+ */
7
+ export declare function resolveRepoKey(projectRoot: string): string;
@@ -0,0 +1,91 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'node:fs';
2
+ import { isAbsolute, join, resolve } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { tryGit } from './exec.js';
5
+ import { log } from '../log.js';
6
+ // repo_key derivation (ticket S5). The authoritative binding is server-side
7
+ // (00 §3 row 7 get-or-create on (user_id, repo_key)); this is the local id source.
8
+ /** Light, STABLE normalization — applied identically on every call. */
9
+ export function normalizeRepoKey(url) {
10
+ let s = url.trim();
11
+ s = s.replace(/\/+$/, ''); // trailing slash(es)
12
+ s = s.replace(/\.git$/, ''); // trailing .git
13
+ return s;
14
+ }
15
+ /**
16
+ * 1. git remote origin URL → that URL is the repo_key.
17
+ * 2. else a stable uuid persisted in a locally-excluded `.hakira/mcp.json`.
18
+ */
19
+ export function resolveRepoKey(projectRoot) {
20
+ const origin = tryGit(projectRoot, ['remote', 'get-url', 'origin']);
21
+ if (origin && origin.length > 0)
22
+ return normalizeRepoKey(origin);
23
+ // Fallback: local uuid. Ensure `.hakira/` is ignored FIRST — a committed
24
+ // repo_key collides across teammates (workspaces are user-scoped, spec §7/G9).
25
+ ensureLocallyExcluded(projectRoot);
26
+ const dir = join(projectRoot, '.hakira');
27
+ const file = join(dir, 'mcp.json');
28
+ if (existsSync(file)) {
29
+ try {
30
+ const j = JSON.parse(readFileSync(file, 'utf8'));
31
+ if (typeof j.repo_key === 'string' && j.repo_key.length > 0)
32
+ return j.repo_key;
33
+ }
34
+ catch {
35
+ /* fall through and regenerate */
36
+ }
37
+ }
38
+ const id = randomUUID();
39
+ mkdirSync(dir, { recursive: true });
40
+ writeFileSync(file, JSON.stringify({ repo_key: id }, null, 2) + '\n');
41
+ return id;
42
+ }
43
+ const EXCLUDE_RULE = '# Hakira MCP local id\n.hakira/\n';
44
+ /**
45
+ * Ignore `.hakira/` via `$GIT_DIR/info/exclude` — NOT the project's `.gitignore`.
46
+ *
47
+ * `.gitignore` is tracked: writing to it shows up in the user's `git status` as
48
+ * a change they did not make and can land in a commit or a PR. `info/exclude`
49
+ * has the same effect, is never tracked, and is invisible to `git status` (F9).
50
+ *
51
+ * Best-effort throughout: a non-repo folder is a clean no-op, and any write
52
+ * failure is a warning — never a crash of the MCP server.
53
+ */
54
+ function ensureLocallyExcluded(projectRoot) {
55
+ try {
56
+ const gitDir = resolveGitCommonDir(projectRoot);
57
+ if (!gitDir)
58
+ return; // plain directory, not a repo — nothing to exclude
59
+ const exclude = join(gitDir, 'info', 'exclude');
60
+ if (existsSync(exclude)) {
61
+ const current = readFileSync(exclude, 'utf8');
62
+ const lines = current.split('\n').map((l) => l.trim());
63
+ if (lines.includes('.hakira/') || lines.includes('.hakira'))
64
+ return; // already there
65
+ appendFileSync(exclude, (current.endsWith('\n') || current.length === 0 ? '' : '\n') + EXCLUDE_RULE);
66
+ }
67
+ else {
68
+ mkdirSync(join(gitDir, 'info'), { recursive: true });
69
+ writeFileSync(exclude, EXCLUDE_RULE);
70
+ }
71
+ }
72
+ catch (err) {
73
+ log.warn('could not exclude .hakira/ via .git/info/exclude', err);
74
+ }
75
+ }
76
+ /**
77
+ * The repo's COMMON git dir, absolute — or null when `projectRoot` is not a repo.
78
+ *
79
+ * Asking git rather than looking for a `.git` directory handles the cases where
80
+ * `.git` is a FILE (linked worktrees, submodules) or absent entirely. The common
81
+ * dir is the right target: git resolves `info/exclude` against it, so the rule
82
+ * applies in every linked worktree too. `--git-common-dir` predates our floor but
83
+ * an ancient git would echo it back verbatim — hence the `--git-dir` fallback.
84
+ */
85
+ function resolveGitCommonDir(projectRoot) {
86
+ const common = tryGit(projectRoot, ['rev-parse', '--git-common-dir']);
87
+ const raw = common && !common.startsWith('-') ? common : tryGit(projectRoot, ['rev-parse', '--git-dir']);
88
+ if (!raw || raw.startsWith('-'))
89
+ return null;
90
+ return isAbsolute(raw) ? raw : resolve(projectRoot, raw); // git reports it relative to cwd
91
+ }
@@ -0,0 +1,182 @@
1
+ export interface BindWorkspaceResult {
2
+ workspace_id: string;
3
+ status: string;
4
+ bootstrapped: boolean;
5
+ estimate_status: string | null;
6
+ created: boolean;
7
+ /** Live credit balance — lets start_audit fail fast before zip/upload/provision. */
8
+ balance_credits?: number;
9
+ /** Server-built top-up link (correct app host) for the fail-fast paywall message. */
10
+ buy_credits_url?: string;
11
+ }
12
+ export interface UploadUrlResult {
13
+ uploadUrl: string;
14
+ key: string;
15
+ filename: string;
16
+ contentType: string;
17
+ }
18
+ export interface AuditTarget {
19
+ ref?: string;
20
+ url?: string;
21
+ scope: 'full' | 'diff';
22
+ focus?: string;
23
+ path?: string;
24
+ commit_sha?: string | null;
25
+ is_dirty?: boolean;
26
+ remote_url?: string | null;
27
+ changed_files?: string[];
28
+ target_gist: string;
29
+ }
30
+ export interface StartAuditBody {
31
+ workspace_id: string;
32
+ r2_key?: string;
33
+ target: AuditTarget;
34
+ }
35
+ export interface StartAuditResult {
36
+ audit_id: string;
37
+ status: string;
38
+ poll_after_ms?: number;
39
+ workspace_id: string;
40
+ target_gist: string;
41
+ full_project_estimate_usd?: number;
42
+ }
43
+ export interface AuditStatus {
44
+ audit_id: string;
45
+ status: string;
46
+ phase?: string;
47
+ findings_count: number;
48
+ cost_so_far_usd: number;
49
+ /** Full-project cost ceiling — present only for full-scope audits once computed. */
50
+ full_project_estimate_usd?: number;
51
+ poll_after_ms?: number;
52
+ error?: string;
53
+ }
54
+ /** Projected ChatEvent item from GET /mcp/audits/:id/events. */
55
+ export type AuditEventItem = {
56
+ id: number;
57
+ ts: number;
58
+ type: string;
59
+ [key: string]: unknown;
60
+ };
61
+ export interface AuditEventsPage {
62
+ audit_id: string;
63
+ status: string;
64
+ events: AuditEventItem[];
65
+ next_after: number;
66
+ has_more: boolean;
67
+ poll_after_ms?: number;
68
+ }
69
+ export interface FindingSummary {
70
+ id: string;
71
+ title: string;
72
+ severity: string;
73
+ category?: string;
74
+ target?: string;
75
+ tier?: string;
76
+ commit_sha?: string;
77
+ resource?: {
78
+ uri: string;
79
+ };
80
+ }
81
+ export interface AuditFindings {
82
+ audit_id: string;
83
+ status: string;
84
+ commit_sha?: string;
85
+ is_dirty?: boolean;
86
+ ref?: string;
87
+ findings: FindingSummary[];
88
+ }
89
+ export interface AuditListItem {
90
+ audit_id: string;
91
+ status: string;
92
+ created_at: number;
93
+ target_gist: string | null;
94
+ ref?: string | null;
95
+ commit_sha?: string | null;
96
+ findings_count: number;
97
+ cost_usd: number;
98
+ workspace_id: string;
99
+ workspace_name?: string;
100
+ origin?: string | null;
101
+ /** Full-project cost ceiling — present only for full-scope audits once computed. */
102
+ full_project_estimate_usd?: number;
103
+ /** Set by the MCP tool after local repo bind (not from CP). */
104
+ is_current?: boolean;
105
+ }
106
+ export interface WorkspaceListItem {
107
+ workspace_id: string;
108
+ name: string;
109
+ status: string;
110
+ repo_key?: string;
111
+ /** Set by the MCP tool after local repo bind (not from CP). */
112
+ is_current?: boolean;
113
+ }
114
+ export interface Finding {
115
+ id: string;
116
+ sessionId: string;
117
+ workspaceId: string;
118
+ title: string;
119
+ severity: string;
120
+ category?: string;
121
+ target?: string;
122
+ description?: string;
123
+ evidence?: string;
124
+ recommendation?: string;
125
+ createdAt: number;
126
+ source?: string;
127
+ tier?: string;
128
+ commitSha?: string;
129
+ prNumber?: number;
130
+ }
131
+ type TokenResolver = () => Promise<string>;
132
+ export declare class CpClient {
133
+ private readonly baseUrl;
134
+ private readonly getToken;
135
+ constructor(baseUrl: string, getToken: TokenResolver);
136
+ bindWorkspace(repoKey: string, name?: string): Promise<BindWorkspaceResult>;
137
+ getUploadUrl(wsId: string, args: {
138
+ size: number;
139
+ contentType: 'application/zip';
140
+ }): Promise<UploadUrlResult>;
141
+ completeUpload(wsId: string, args: {
142
+ key: string;
143
+ filename: string;
144
+ excludeFolders?: string[];
145
+ }): Promise<void>;
146
+ startAudit(body: StartAuditBody): Promise<StartAuditResult>;
147
+ getStatus(id: string): Promise<AuditStatus>;
148
+ getAuditEvents(id: string, opts?: {
149
+ after?: number;
150
+ limit?: number;
151
+ }): Promise<AuditEventsPage>;
152
+ getFindings(id: string, opts?: {
153
+ min_severity?: string;
154
+ }): Promise<AuditFindings>;
155
+ listWorkspaces(): Promise<{
156
+ workspaces: WorkspaceListItem[];
157
+ }>;
158
+ listAudits(opts?: {
159
+ workspace_id?: string;
160
+ limit?: number;
161
+ }): Promise<{
162
+ audits: AuditListItem[];
163
+ }>;
164
+ /**
165
+ * A 200 does NOT imply we cancelled. The handler reports the audit's REAL
166
+ * status, so `status` is `canceled` only when it actually stopped something;
167
+ * an already-finished audit comes back `ready`/`canceled`/`error`, and a
168
+ * deferred audit whose drive the poller already grabbed comes back
169
+ * `provisioning` (cancel again once its session row appears).
170
+ * The failure paths are errors, not 200s: 400 `not_cancelable` (not an
171
+ * MCP-origin session — do not retry) and 409 `cancel_failed` (may still be
172
+ * running) both surface through CpError.
173
+ */
174
+ cancelAudit(id: string): Promise<{
175
+ audit_id: string;
176
+ status: 'canceled' | 'ready' | 'error' | 'provisioning';
177
+ }>;
178
+ getFinding(id: string): Promise<Finding>;
179
+ putUpload(uploadUrl: string, buffer: Buffer): Promise<void>;
180
+ private request;
181
+ }
182
+ export {};
@@ -0,0 +1,196 @@
1
+ import { CpError, CpUnauthorizedError } from './errors.js';
2
+ import { log } from '../log.js';
3
+ export class CpClient {
4
+ baseUrl;
5
+ getToken;
6
+ constructor(baseUrl, getToken) {
7
+ this.baseUrl = baseUrl;
8
+ this.getToken = getToken;
9
+ }
10
+ // ── row 7 — POST /mcp/workspaces (get-or-create by repo_key) ──────────────
11
+ bindWorkspace(repoKey, name) {
12
+ // get-or-create by repo_key → idempotent, safe to retry.
13
+ return this.request('POST', '/mcp/workspaces', { body: { repo_key: repoKey, name }, idempotent: true });
14
+ }
15
+ // ── row 14 — POST /workspaces/:id/upload-url ──────────────────────────────
16
+ getUploadUrl(wsId, args) {
17
+ // Mints a fresh presigned URL/key with no side effects → idempotent. Retries
18
+ // are bounded (≤3) and the mint is rate-limited to 10/min/user, so worst-case
19
+ // first-bind (2 mints × 3) stays under budget.
20
+ return this.request('POST', `/workspaces/${enc(wsId)}/upload-url`, {
21
+ body: { size: args.size, contentType: args.contentType },
22
+ idempotent: true,
23
+ });
24
+ }
25
+ // ── row 15 — POST /workspaces/:id/upload-complete (first-bind bootstrap) ──
26
+ async completeUpload(wsId, args) {
27
+ await this.request('POST', `/workspaces/${enc(wsId)}/upload-complete`, {
28
+ body: { key: args.key, filename: args.filename, excludeFolders: args.excludeFolders },
29
+ expectJson: false,
30
+ });
31
+ }
32
+ // ── row 8 — POST /mcp/audits ──────────────────────────────────────────────
33
+ startAudit(body) {
34
+ return this.request('POST', '/mcp/audits', { body });
35
+ }
36
+ // ── row 9 — GET /mcp/audits/:id ───────────────────────────────────────────
37
+ getStatus(id) {
38
+ return this.request('GET', `/mcp/audits/${enc(id)}`);
39
+ }
40
+ // ── GET /mcp/audits/:id/events — projected transcript for "check the audit"
41
+ getAuditEvents(id, opts = {}) {
42
+ return this.request('GET', `/mcp/audits/${enc(id)}/events`, {
43
+ query: { after: opts.after, limit: opts.limit },
44
+ });
45
+ }
46
+ // ── row 10 — GET /mcp/audits/:id/findings ─────────────────────────────────
47
+ getFindings(id, opts = {}) {
48
+ return this.request('GET', `/mcp/audits/${enc(id)}/findings`, {
49
+ query: { min_severity: opts.min_severity },
50
+ });
51
+ }
52
+ // ── GET /mcp/workspaces — owner list (all workspaces) ─────────────────────
53
+ listWorkspaces() {
54
+ return this.request('GET', '/mcp/workspaces');
55
+ }
56
+ // ── row 11 — GET /mcp/audits ──────────────────────────────────────────────
57
+ listAudits(opts = {}) {
58
+ return this.request('GET', '/mcp/audits', {
59
+ query: { workspace_id: opts.workspace_id, limit: opts.limit },
60
+ });
61
+ }
62
+ // ── row 12 — POST /mcp/audits/:id/cancel ──────────────────────────────────
63
+ /**
64
+ * A 200 does NOT imply we cancelled. The handler reports the audit's REAL
65
+ * status, so `status` is `canceled` only when it actually stopped something;
66
+ * an already-finished audit comes back `ready`/`canceled`/`error`, and a
67
+ * deferred audit whose drive the poller already grabbed comes back
68
+ * `provisioning` (cancel again once its session row appears).
69
+ * The failure paths are errors, not 200s: 400 `not_cancelable` (not an
70
+ * MCP-origin session — do not retry) and 409 `cancel_failed` (may still be
71
+ * running) both surface through CpError.
72
+ */
73
+ cancelAudit(id) {
74
+ return this.request('POST', `/mcp/audits/${enc(id)}/cancel`);
75
+ }
76
+ // ── row 6 — GET /findings/:id (owner-scoped) ──────────────────────────────
77
+ getFinding(id) {
78
+ return this.request('GET', `/findings/${enc(id)}`);
79
+ }
80
+ // ── raw PUT of the ZIP buffer to a presigned (or local no-R2) URL. NOT
81
+ // CP-authed — no bearer (00 §3 note under the table; ticket S7 step 2). ──
82
+ async putUpload(uploadUrl, buffer) {
83
+ // Hand fetch a standalone ArrayBuffer — a member of both the DOM and undici
84
+ // `BodyInit` unions (a Node Buffer / generic Uint8Array is not, under TS 5.9).
85
+ const body = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
86
+ // Idempotent: same key, full-object PUT, last-write-wins — safe to retry on a
87
+ // transient network blip (the classic ECONNRESET/socket-hang-up on a fresh
88
+ // cross-host TLS connection streaming the whole ZIP).
89
+ const res = await fetchWithRetry(uploadUrl, { method: 'PUT', body, headers: { 'content-type': 'application/zip' } }, { retry: true, label: 'Upload PUT' });
90
+ if (!res.ok) {
91
+ throw new CpError(res.status, {}, `Upload PUT failed (${res.status} ${res.statusText})`);
92
+ }
93
+ }
94
+ // ── shared request core: bearer inject + structured error mapping ─────────
95
+ async request(method, path, opts = {}) {
96
+ const url = new URL(this.baseUrl + path);
97
+ if (opts.query) {
98
+ for (const [k, v] of Object.entries(opts.query)) {
99
+ if (v !== undefined && v !== null)
100
+ url.searchParams.set(k, String(v));
101
+ }
102
+ }
103
+ const token = await this.getToken();
104
+ const headers = { authorization: `Bearer ${token}` };
105
+ let body;
106
+ if (opts.body !== undefined) {
107
+ headers['content-type'] = 'application/json';
108
+ body = JSON.stringify(opts.body);
109
+ }
110
+ const res = await fetchWithRetry(url, { method, headers, body }, {
111
+ retry: method === 'GET' || opts.idempotent === true,
112
+ label: `${method} ${path}`,
113
+ });
114
+ if (!res.ok) {
115
+ const parsed = await safeParseJson(res);
116
+ if (res.status === 401)
117
+ throw new CpUnauthorizedError(res.status, parsed);
118
+ throw new CpError(res.status, parsed, `${method} ${path} failed (${res.status} ${res.statusText})`);
119
+ }
120
+ if (opts.expectJson === false)
121
+ return undefined;
122
+ // Some 2xx (202) may have no body.
123
+ const text = await res.text();
124
+ if (!text)
125
+ return undefined;
126
+ return JSON.parse(text);
127
+ }
128
+ }
129
+ function enc(s) {
130
+ return encodeURIComponent(s);
131
+ }
132
+ // ── network resilience ──────────────────────────────────────────────────────
133
+ // A Node/undici network failure rejects `fetch` with a generic
134
+ // `TypeError: fetch failed` whose real reason (ECONNRESET / ETIMEDOUT / ENOTFOUND
135
+ // / "socket hang up") lives on `.cause` — which the old code discarded, so the
136
+ // agent only ever saw the opaque "fetch failed". We (a) annotate the thrown error
137
+ // with the cause so it's diagnostic, and (b) retry idempotent calls a bounded
138
+ // number of times with backoff so a single transient blip on the multi-hop
139
+ // first-bind sequence (esp. the two full-buffer R2 PUTs) doesn't fail the audit.
140
+ const MAX_ATTEMPTS = 3;
141
+ const BASE_DELAY_MS = 500;
142
+ async function fetchWithRetry(url, init, opts) {
143
+ for (let attempt = 1;; attempt++) {
144
+ try {
145
+ const res = await fetch(url, init);
146
+ // Transient gateway failures on an idempotent call are worth one more try.
147
+ if (opts.retry && attempt < MAX_ATTEMPTS && (res.status === 502 || res.status === 503 || res.status === 504)) {
148
+ log.warn(`${opts.label} → ${res.status} — retry ${attempt}/${MAX_ATTEMPTS - 1}`);
149
+ await sleep(BASE_DELAY_MS * 2 ** (attempt - 1));
150
+ continue;
151
+ }
152
+ return res;
153
+ }
154
+ catch (err) {
155
+ const detail = describeNetworkError(err);
156
+ if (opts.retry && attempt < MAX_ATTEMPTS) {
157
+ log.warn(`${opts.label} network error (${detail}) — retry ${attempt}/${MAX_ATTEMPTS - 1}`);
158
+ await sleep(BASE_DELAY_MS * 2 ** (attempt - 1));
159
+ continue;
160
+ }
161
+ // Re-throw with the underlying cause folded into the message so the tool
162
+ // layer (wrap.ts catch-all) surfaces something actionable, and keep `cause`
163
+ // attached for anyone reading the stack.
164
+ throw new Error(`${opts.label} failed: ${detail}`, { cause: err });
165
+ }
166
+ }
167
+ }
168
+ /** Pull the useful reason out of an undici `TypeError: fetch failed`. */
169
+ function describeNetworkError(err) {
170
+ if (err instanceof Error) {
171
+ const cause = err.cause;
172
+ if (cause instanceof Error) {
173
+ const code = cause.code;
174
+ return code ? `${code}: ${cause.message}` : cause.message;
175
+ }
176
+ if (cause !== undefined && cause !== null)
177
+ return String(cause);
178
+ return err.message;
179
+ }
180
+ return String(err);
181
+ }
182
+ function sleep(ms) {
183
+ return new Promise((resolve) => setTimeout(resolve, ms));
184
+ }
185
+ async function safeParseJson(res) {
186
+ try {
187
+ const text = await res.text();
188
+ if (!text)
189
+ return {};
190
+ const parsed = JSON.parse(text);
191
+ return typeof parsed === 'object' && parsed !== null ? parsed : { message: text };
192
+ }
193
+ catch {
194
+ return {};
195
+ }
196
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A structured, non-2xx Control-Plane response. Preserves the CP error `code`
3
+ * plus the known extra fields the client surfaces verbatim (00 §7):
4
+ * - audit_already_running → carries `audit_id` + `status`
5
+ * - workspace_needs_repair → carries `workspace_id` + `detail`
6
+ */
7
+ export declare class CpError extends Error {
8
+ readonly httpStatus: number;
9
+ /** CP error code, e.g. "audit_already_running" | "workspace_needs_repair". */
10
+ readonly error?: string;
11
+ readonly audit_id?: string;
12
+ readonly workspace_id?: string;
13
+ readonly detail?: string;
14
+ /** The in-flight audit's MCP status (audit_already_running body). */
15
+ readonly runningStatus?: string;
16
+ /** Top-up link (payment_required body) — where the user adds credits. */
17
+ readonly buy_credits_url?: string;
18
+ /** Full parsed body, for any field not promoted above. */
19
+ readonly data: Record<string, unknown>;
20
+ constructor(httpStatus: number, body: Record<string, unknown>, fallbackMessage: string);
21
+ }
22
+ /**
23
+ * A 401 from CP. The tool wrapper special-cases this: clear the cached
24
+ * credential + re-authorize ONCE, then retry the call (00 §7 / ticket S3).
25
+ */
26
+ export declare class CpUnauthorizedError extends CpError {
27
+ constructor(httpStatus: number, body: Record<string, unknown>);
28
+ }
@@ -0,0 +1,47 @@
1
+ // Typed errors thrown by CpClient. The tool layer (tools/wrap.ts) maps these to
2
+ // MCP `isError:true` results carrying the human-readable message (00 §7).
3
+ /**
4
+ * A structured, non-2xx Control-Plane response. Preserves the CP error `code`
5
+ * plus the known extra fields the client surfaces verbatim (00 §7):
6
+ * - audit_already_running → carries `audit_id` + `status`
7
+ * - workspace_needs_repair → carries `workspace_id` + `detail`
8
+ */
9
+ export class CpError extends Error {
10
+ httpStatus;
11
+ /** CP error code, e.g. "audit_already_running" | "workspace_needs_repair". */
12
+ error;
13
+ audit_id;
14
+ workspace_id;
15
+ detail;
16
+ /** The in-flight audit's MCP status (audit_already_running body). */
17
+ runningStatus;
18
+ /** Top-up link (payment_required body) — where the user adds credits. */
19
+ buy_credits_url;
20
+ /** Full parsed body, for any field not promoted above. */
21
+ data;
22
+ constructor(httpStatus, body, fallbackMessage) {
23
+ const message = typeof body?.message === 'string' && body.message.length > 0
24
+ ? body.message
25
+ : fallbackMessage;
26
+ super(message);
27
+ this.name = 'CpError';
28
+ this.httpStatus = httpStatus;
29
+ this.error = typeof body?.error === 'string' ? body.error : undefined;
30
+ this.audit_id = typeof body?.audit_id === 'string' ? body.audit_id : undefined;
31
+ this.workspace_id = typeof body?.workspace_id === 'string' ? body.workspace_id : undefined;
32
+ this.detail = typeof body?.detail === 'string' ? body.detail : undefined;
33
+ this.runningStatus = typeof body?.status === 'string' ? body.status : undefined;
34
+ this.buy_credits_url = typeof body?.buy_credits_url === 'string' ? body.buy_credits_url : undefined;
35
+ this.data = body ?? {};
36
+ }
37
+ }
38
+ /**
39
+ * A 401 from CP. The tool wrapper special-cases this: clear the cached
40
+ * credential + re-authorize ONCE, then retry the call (00 §7 / ticket S3).
41
+ */
42
+ export class CpUnauthorizedError extends CpError {
43
+ constructor(httpStatus, body) {
44
+ super(httpStatus, body, 'Unauthorized');
45
+ this.name = 'CpUnauthorizedError';
46
+ }
47
+ }
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import type { ToolDeps } from './tools/context.js';
4
+ /** Build the fully-registered MCP server (shared by main() + tests). */
5
+ export declare function buildServer(deps: ToolDeps): McpServer;
package/dist/index.js ADDED
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
+ import { config } from './config.js';
5
+ import { log } from './log.js';
6
+ import { version } from './version.js';
7
+ import { CpClient } from './http/cp-client.js';
8
+ import { resolveToken, clearCredentials, logout } from './auth/credentials.js';
9
+ import { registerStartAudit } from './tools/start-audit.js';
10
+ import { registerGetStatus } from './tools/get-status.js';
11
+ import { registerGetFindings } from './tools/get-findings.js';
12
+ import { registerGetFinding } from './tools/get-finding.js';
13
+ import { registerGetEvents } from './tools/get-audit-events.js';
14
+ import { registerListAudits } from './tools/list-audits.js';
15
+ import { registerListWorkspaces } from './tools/list-workspaces.js';
16
+ import { registerCancel } from './tools/cancel.js';
17
+ import { registerFindingResource } from './resources/finding.js';
18
+ /** Build the fully-registered MCP server (shared by main() + tests). */
19
+ export function buildServer(deps) {
20
+ const server = new McpServer({ name: 'hakira', version });
21
+ registerStartAudit(server, deps);
22
+ registerGetStatus(server, deps);
23
+ registerGetFindings(server, deps);
24
+ registerGetFinding(server, deps);
25
+ registerGetEvents(server, deps);
26
+ // estimate_audit intentionally not registered: the free pre-check dead-ended
27
+ // agents on fresh repos (null estimate → never paid). The full-project ceiling
28
+ // now rides on start_audit / get_audit_status / list_audits (full scope only).
29
+ registerListAudits(server, deps);
30
+ registerListWorkspaces(server, deps);
31
+ registerCancel(server, deps);
32
+ registerFindingResource(server, deps);
33
+ return server;
34
+ }
35
+ async function main() {
36
+ // Subcommands MUST run before connecting the stdio transport.
37
+ if (process.argv[2] === 'logout') {
38
+ logout();
39
+ process.exit(0);
40
+ }
41
+ const cp = new CpClient(config.apiUrl, resolveToken);
42
+ const deps = { cp, resolveToken, clearCredentials, root: process.cwd() };
43
+ const server = buildServer(deps);
44
+ const transport = new StdioServerTransport();
45
+ await server.connect(transport);
46
+ // stderr only — stdout is the JSON-RPC channel.
47
+ log.info(`hakira-mcp v${version} connected (api=${config.apiUrl})`);
48
+ }
49
+ main().catch((err) => {
50
+ log.error('fatal', err);
51
+ process.exit(1);
52
+ });
package/dist/log.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export declare const log: {
2
+ info: (...a: unknown[]) => void;
3
+ warn: (...a: unknown[]) => void;
4
+ error: (...a: unknown[]) => void;
5
+ };
package/dist/log.js ADDED
@@ -0,0 +1,11 @@
1
+ // stderr-only logger. stdout is the JSON-RPC channel for the stdio transport —
2
+ // ANY `console.log` / `process.stdout.write` corrupts the protocol frame and the
3
+ // MCP client shows "disconnected" (00 §7, gotcha G2). Everything logs to stderr,
4
+ // which Claude Code also captures at ~/Library/Logs/Claude/mcp*.log.
5
+ //
6
+ // `console.log` is BANNED in this package. Use `log.*` everywhere.
7
+ export const log = {
8
+ info: (...a) => console.error('[hakira-mcp]', ...a),
9
+ warn: (...a) => console.error('[hakira-mcp] WARN', ...a),
10
+ error: (...a) => console.error('[hakira-mcp] ERROR', ...a),
11
+ };