@orboto/mcp 0.184.1 → 0.185.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.
@@ -0,0 +1,62 @@
1
+ import { expect, it } from 'vitest';
2
+ import { cp, mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
3
+ import { createServer } from 'node:http';
4
+ import { spawn } from 'node:child_process';
5
+ import { tmpdir } from 'node:os';
6
+ import { join } from 'node:path';
7
+ it('real wrapper preserves piped diff text and version-prefixed check/record values', async () => {
8
+ const dir = await mkdtemp(join(tmpdir(), 'orboto-1964-verify-temp-'));
9
+ const fingerprint = 'sha256-diff-v2:' + 'a'.repeat(64);
10
+ const calls = [];
11
+ const server = createServer(async (req, res) => {
12
+ let raw = '';
13
+ for await (const chunk of req)
14
+ raw += chunk.toString();
15
+ const url = req.url ?? '';
16
+ if (req.method === 'POST')
17
+ calls.push({ url, body: JSON.parse(raw) });
18
+ res.setHeader('Content-Type', 'application/json');
19
+ if (url === '/projects')
20
+ res.end(JSON.stringify([{ id: 'p', key: 'ORB' }]));
21
+ else if (url.includes('/tickets/by-key/'))
22
+ res.end(JSON.stringify({ id: 't', projectId: 'p', ticketKey: 'ORB-1964' }));
23
+ else
24
+ res.end(JSON.stringify({ fingerprint, algo: 'sha256-diff-v2' }));
25
+ });
26
+ try {
27
+ await mkdir(join(dir, 'skills/orboto'), { recursive: true });
28
+ await cp(new URL('../../../../skills/orboto/scripts', import.meta.url), join(dir, 'skills/orboto/scripts'), { recursive: true });
29
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
30
+ const address = server.address();
31
+ if (!address || typeof address === 'string')
32
+ throw new Error('Fixture address missing');
33
+ const origin = `http://127.0.0.1:${address.port}`;
34
+ const guard = join(dir, 'network.mjs');
35
+ await writeFile(guard, `const original = globalThis.fetch; globalThis.fetch = (input, init) => { if (new URL(input).origin !== ${JSON.stringify(origin)}) throw new Error('Fixture refused nonlocal request'); return original(input, init); };`);
36
+ const run = (args, stdin = '') => new Promise((ok, no) => {
37
+ const child = spawn(process.execPath, ['--import', guard, join(dir, 'skills/orboto/scripts/orboto.mjs'), ...args], {
38
+ cwd: dir, env: { ...process.env, ORBOTO_PROFILE: '', ORBOTO_BASE_URL: origin, ORBOTO_TOKEN: 'orb_fixture', ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' },
39
+ timeout: 20_000,
40
+ });
41
+ let out = '', err = '';
42
+ child.stdout.on('data', (chunk) => { out += chunk.toString(); });
43
+ child.stderr.on('data', (chunk) => { err += chunk.toString(); });
44
+ child.on('error', no);
45
+ child.on('close', (code) => code === 0 ? ok(out) : no(new Error(`Wrapper exit ${code}: ${err}`)));
46
+ child.stdin.end(stdin);
47
+ });
48
+ const diff = 'diff --git a/a b/a\r\n-old\r\n+ new \r\n+\r\n';
49
+ expect(await run(['review-fingerprint'], diff)).toContain(fingerprint);
50
+ expect(calls[0]).toEqual({ url: '/review-policy/fingerprint', body: { diff } });
51
+ expect(await run(['review-check', 'ORB-1964', '--fingerprint', fingerprint])).toContain(fingerprint);
52
+ expect(await run(['review-approve', 'ORB-1964', '--fingerprint', fingerprint])).toContain(fingerprint);
53
+ expect(calls.slice(1).map((call) => call.body.fingerprint)).toEqual([fingerprint, fingerprint]);
54
+ expect(calls[2].body.decision).toBe('approved');
55
+ }
56
+ finally {
57
+ server.closeAllConnections();
58
+ if (server.listening)
59
+ await new Promise((ok, no) => server.close((err) => err ? no(err) : ok()));
60
+ await rm(dir, { recursive: true, force: true });
61
+ }
62
+ }, 60_000);
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ORB-1615 - structural review policy tools.
3
3
  *
4
- * - orboto_review_fingerprint - normalize + hash raw diff text into a
4
+ * - orboto_review_fingerprint - hash exact raw diff text into a
5
5
  * canonical fingerprint (+ real size
6
6
  * metrics), server-side (one algorithm,
7
7
  * not one per client).
@@ -23,7 +23,7 @@ import { resolveTicketByKey } from './shared.js';
23
23
  // ---------------------------------------------------------------------------
24
24
  export const reviewFingerprintToolConfig = {
25
25
  title: 'Compute a canonical diff fingerprint',
26
- description: 'Normalize + hash raw diff text (e.g. `git diff` output) into a canonical fingerprint, server-side - so every agent hashes the same way instead of each fudging its own. Whitespace-only / blank-line-only edits and file ORDER are invisible to the hash (an approval survives a reflow); any real content change, a permission/mode change, or an added/removed/renamed file is NOT (an approval never survives those). Also returns real size metrics (filesChanged/linesAdded/linesRemoved/paths) derived from the same diff. Use the returned `fingerprint` with `orboto_review_policy_check` and `orboto_review_approval_record`.',
26
+ description: 'Hash the exact submitted diff text server-side with sha256-diff-v2. Every text change, including indentation, blank lines, binary blob identities, mode/rename metadata and file order, changes the fingerprint. This is submitted-text identity, NOT independent repository attestation. Returns size/path metrics too. Preserve the complete opaque version-prefixed `fingerprint` for `orboto_review_policy_check` and `orboto_review_approval_record`; legacy v1 approvals remain history only and must be reviewed again.',
27
27
  inputSchema: z.object({
28
28
  diff: z.string().min(1).describe('Raw unified diff text, e.g. the output of `git diff` / `git diff --no-color`.'),
29
29
  }).shape,
@@ -76,7 +76,7 @@ export function makeReviewPolicyCheckHandler(client) {
76
76
  // ---------------------------------------------------------------------------
77
77
  export const reviewApprovalRecordToolConfig = {
78
78
  title: 'Record a review decision against a diff fingerprint',
79
- description: 'Record your review verdict (approve/reject) against a ticket\'s diff fingerprint (from orboto_review_fingerprint). A recorded APPROVAL is reusable: orboto_review_policy_check reports it as valid for the SAME fingerprint, letting a later finish/close skip re-review - until the diff changes, which produces a different fingerprint and naturally stops matching. Requires ticket:record_review_approval.',
79
+ description: 'Record your review verdict (approve/reject) against the complete version-prefixed fingerprint from orboto_review_fingerprint. A current-algorithm approval is reusable only for identical submitted diff text; even whitespace or file-order changes require a fresh review. Legacy/unversioned records remain history only. This advisory review ledger does not itself enforce ticket closure. Requires ticket:record_review_approval.',
80
80
  inputSchema: z.object({
81
81
  ticketKey: z.string().min(3).describe('Ticket key, e.g. ORB-42.'),
82
82
  fingerprint: z.string().min(8).describe('From orboto_review_fingerprint.'),
@@ -21,14 +21,17 @@ function stub(responses) {
21
21
  const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
22
22
  const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
23
23
  const TICKET = { id: 't1', projectId: 'p1', ticketKey: 'ACME-1', ticketNumber: 1, title: 'Bug', description: null, status: 'IN_PROGRESS', statusName: 'In Progress', statusCategory: 'in_progress', type: 'bug', priority: 'normal', estimatedTimeMinutes: 0, dueDate: null, isPrivate: false };
24
+ const FINGERPRINT = 'sha256-diff-v2:' + 'a'.repeat(64);
24
25
  describe('orboto_review_fingerprint', () => {
25
26
  it('computes a fingerprint + size metrics from raw diff text', async () => {
26
- const calls = stub([{ json: { fingerprint: 'abc123', algo: 'sha256-diff-v1', filesChanged: 1, linesAdded: 1, linesRemoved: 1, paths: ['src/foo.ts'] } }]);
27
- const res = await makeReviewFingerprintHandler(client)({ diff: 'diff --git a/src/foo.ts b/src/foo.ts\n-a\n+b\n' });
27
+ const calls = stub([{ json: { fingerprint: FINGERPRINT, algo: 'sha256-diff-v2', filesChanged: 1, linesAdded: 1, linesRemoved: 1, paths: ['src/foo.ts'] } }]);
28
+ const diff = 'diff --git a/src/foo.ts b/src/foo.ts\r\n-a\r\n+ b \r\n+\r\n';
29
+ const res = await makeReviewFingerprintHandler(client)({ diff });
28
30
  expect(calls[0].url).toContain('/review-policy/fingerprint');
29
31
  expect(calls[0].method).toBe('POST');
30
- expect(res.structuredContent?.fingerprint).toBe('abc123');
31
- expect(res.content[0].text).toContain('abc123');
32
+ expect(calls[0].body).toEqual({ diff });
33
+ expect(res.structuredContent).toMatchObject({ fingerprint: FINGERPRINT, algo: 'sha256-diff-v2' });
34
+ expect(res.content[0].text).toContain(FINGERPRINT);
32
35
  });
33
36
  });
34
37
  describe('orboto_review_policy_check', () => {
@@ -38,10 +41,10 @@ describe('orboto_review_policy_check', () => {
38
41
  fingerprintChecked: true, hasValidApproval: false, latestApproval: null,
39
42
  };
40
43
  const calls = stub([{ json: PROJ }, { json: TICKET }, { json: decision }]);
41
- const res = await makeReviewPolicyCheckHandler(client)({ ticketKey: 'ACME-1', fingerprint: 'abc123' });
44
+ const res = await makeReviewPolicyCheckHandler(client)({ ticketKey: 'ACME-1', fingerprint: FINGERPRINT });
42
45
  const check = calls.find((c) => c.method === 'POST');
43
46
  expect(check?.url).toContain('/projects/p1/tickets/t1/review-policy/check');
44
- expect(check?.body).toMatchObject({ fingerprint: 'abc123' });
47
+ expect(check?.body).toMatchObject({ fingerprint: FINGERPRINT });
45
48
  expect(res.structuredContent).toMatchObject({ riskLevel: 'required', hasValidApproval: false });
46
49
  expect(res.content[0].text).toContain('required');
47
50
  expect(res.content[0].text).toContain('No valid approval');
@@ -58,11 +61,11 @@ describe('orboto_review_policy_check', () => {
58
61
  });
59
62
  describe('orboto_review_approval_record', () => {
60
63
  it('records an approval against a fingerprint', async () => {
61
- const approval = { id: 'ra1', ticketId: 't1', fingerprint: 'abc123', decision: 'approved', reviewerLabel: 'reviewer@orboto.test', note: null, revokedAt: null, createdAt: '2026-01-01T00:00:00Z' };
64
+ const approval = { id: 'ra1', ticketId: 't1', fingerprint: FINGERPRINT, decision: 'approved', reviewerLabel: 'reviewer@orboto.test', note: null, revokedAt: null, createdAt: '2026-01-01T00:00:00Z' };
62
65
  const calls = stub([{ json: PROJ }, { json: TICKET }, { json: approval }]);
63
- const res = await makeReviewApprovalRecordHandler(client)({ ticketKey: 'ACME-1', fingerprint: 'abc123', decision: 'approved', note: 'looks good' });
66
+ const res = await makeReviewApprovalRecordHandler(client)({ ticketKey: 'ACME-1', fingerprint: FINGERPRINT, decision: 'approved', note: 'looks good' });
64
67
  const record = calls.find((c) => c.method === 'POST' && c.url.includes('review-approvals'));
65
- expect(record?.body).toMatchObject({ fingerprint: 'abc123', decision: 'approved', note: 'looks good' });
68
+ expect(record?.body).toMatchObject({ fingerprint: FINGERPRINT, decision: 'approved', note: 'looks good' });
66
69
  // ticketKey must NOT leak into the request body sent to the API - the
67
70
  // route resolves the ticket from the URL, not the payload.
68
71
  expect(record?.body).not.toHaveProperty('ticketKey');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.184.1",
3
+ "version": "0.185.0",
4
4
  "description": "Model Context Protocol server for orboto - exposes tickets, projects, milestones, time, docs, primer-facts, alerts and absences as MCP tools for Claude Desktop / Cursor / Copilot Chat / any MCP-aware AI client.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,7 +37,7 @@
37
37
  "vitest": "^1.6.1"
38
38
  },
39
39
  "engines": {
40
- "node": ">=20"
40
+ "node": "^22.20.0 || ^24.0.0"
41
41
  },
42
42
  "keywords": [
43
43
  "orboto",