@orboto/mcp 0.181.1 → 0.182.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.
@@ -107,8 +107,8 @@ export class OrbotoClient {
107
107
  return res;
108
108
  }
109
109
  /** GET a JSON endpoint. Throws `OrbotoApiError` on non-2xx. */
110
- async get(path) {
111
- const res = await this.authedFetch(this.fullUrl(path), { method: 'GET' });
110
+ async get(path, options = {}) {
111
+ const res = await this.authedFetch(this.fullUrl(path), { method: 'GET', signal: options.signal });
112
112
  return (await res.json());
113
113
  }
114
114
  /** POST a JSON body. */
@@ -0,0 +1,113 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+ import { cp, mkdtemp, mkdir, readFile, 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 { dirname, join, resolve } from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
9
+ const cleanup = [];
10
+ afterEach(async () => { for (const fn of cleanup.splice(0).reverse())
11
+ await fn(); });
12
+ async function fixture() {
13
+ const dir = await mkdtemp(join(tmpdir(), 'orboto-rules-wrapper-'));
14
+ cleanup.push(() => rm(dir, { recursive: true, force: true }));
15
+ await mkdir(join(dir, 'skills/orboto'), { recursive: true });
16
+ await cp(join(root, 'skills/orboto/scripts'), join(dir, 'skills/orboto/scripts'), { recursive: true });
17
+ const cache = join(dir, 'skills/orboto/.rules-hash');
18
+ await writeFile(cache, 'keep-hash\n');
19
+ let rules = '{}', next = '{}', status = 200, ruleReads = 0;
20
+ const server = createServer((req, res) => {
21
+ res.setHeader('Content-Type', 'application/json');
22
+ if (req.url?.startsWith('/agent-instructions')) {
23
+ ruleReads++;
24
+ res.statusCode = status;
25
+ res.end(rules);
26
+ }
27
+ else if (req.url?.startsWith('/work-sessions/next'))
28
+ res.end(next);
29
+ else if (req.url?.startsWith('/users/me/assigned-tickets'))
30
+ res.end('{"items":[]}');
31
+ else if (req.url?.startsWith('/users/me'))
32
+ res.end('{"id":"me","email":"fixture@example.test"}');
33
+ else
34
+ res.end('null');
35
+ });
36
+ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
37
+ cleanup.push(() => new Promise((ok, no) => server.close((err) => err ? no(err) : ok())));
38
+ const address = server.address();
39
+ if (!address || typeof address === 'string')
40
+ throw new Error('fixture address missing');
41
+ const origin = `http://127.0.0.1:${address.port}`;
42
+ const guard = join(dir, 'fixture-network.mjs');
43
+ await writeFile(guard, `const original = globalThis.fetch; globalThis.fetch = (input, init) => { if (new URL(input).origin !== ${JSON.stringify(origin)}) throw new Error('Fixture refused a nonlocal request'); return original(input, init); };`);
44
+ const run = (args = ['session-start', '--force-rules']) => new Promise((ok, no) => {
45
+ const child = spawn(process.execPath, ['--import', guard, join(dir, 'skills/orboto/scripts/orboto.mjs'), ...args], {
46
+ cwd: dir, env: { ...process.env, ORBOTO_PROFILE: '', ORBOTO_BASE_URL: `http://127.0.0.1:${address.port}`, ORBOTO_TOKEN: 'orb_fixture', ORBOTO_DAEMON: '', ORBOTO_AGENT_SESSION: 'fixture' },
47
+ stdio: ['ignore', 'pipe', 'pipe'],
48
+ });
49
+ let out = '', err = '';
50
+ child.stdout.on('data', (chunk) => { out += chunk.toString(); });
51
+ child.stderr.on('data', (chunk) => { err += chunk.toString(); });
52
+ child.on('error', no);
53
+ child.on('close', (code) => ok({ code, out, err }));
54
+ });
55
+ return { run, cache, ruleReads: () => ruleReads, set: (body, code = 200) => { rules = body; status = code; }, setNext: (body) => { next = body; } };
56
+ }
57
+ describe('wrapper required rules through the real CLI', () => {
58
+ it('does not turn malformed work-next responses into an idle result', async () => {
59
+ const f = await fixture();
60
+ for (const body of ['{}', 'null', '[]', '{"reserved":null}', '{"reserved":""}', '<html>unavailable</html>']) {
61
+ f.setNext(body);
62
+ const result = await f.run(['work-next', 'ORB']);
63
+ expect(result.code).toBe(1);
64
+ expect(result.out).not.toContain('Nothing ready');
65
+ expect(await readFile(f.cache, 'utf8')).toBe('keep-hash\n');
66
+ }
67
+ f.setNext('{"reserved":null,"reason":"none-matching","retryAfterSeconds":null,"earliestFreeAt":null,"candidatesConsidered":0,"landedIdle":[]}');
68
+ expect((await f.run(['work-next', 'ORB'])).code).toBe(3);
69
+ f.setNext(JSON.stringify({ reserved: {
70
+ session: { id: 'session', role: 'implementation', leaseUntil: 'later' },
71
+ ticket: { ticketKey: 'ORB-42', title: 'fixture' }, reused: true,
72
+ rulesHash: 'keep-hash', rulesUnchanged: true,
73
+ primer: { markdown: '', totalTokens: 0 }, checklists: [], dependencies: { blocks: [], blockedBy: [] }, gitHealth: [], siblingSessions: [],
74
+ }, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [] }));
75
+ const ack = await f.run(['work-next', 'ORB']);
76
+ expect(ack.code).toBe(0);
77
+ expect(ack.out).toContain('not currently in your context');
78
+ expect(ack.out).toContain('session-start --force-rules');
79
+ expect(ack.out).not.toContain('keep following what you already loaded');
80
+ });
81
+ it('does not silently omit an explicitly requested init rules snapshot', async () => {
82
+ const f = await fixture();
83
+ f.set('{}');
84
+ const result = await f.run(['init', '--with-rules']);
85
+ expect(result.code).toBe(1);
86
+ expect(result.err).toContain('Required agent rules');
87
+ expect(f.ruleReads()).toBe(1);
88
+ });
89
+ it('fails closed without poisoning the cache, then recovers with a valid empty ruleset', async () => {
90
+ const f = await fixture();
91
+ for (const body of ['{}', 'null', '[]', '{"instructions":', '<html>secret-token</html>', '{"instructions":"","rulesHash":null}', '{"rulesHash":"keep-hash","rulesUnchanged":true}']) {
92
+ f.set(body);
93
+ const result = await f.run();
94
+ expect(result.code).toBe(1);
95
+ expect(result.out).not.toContain('no workspace rules');
96
+ expect(result.err).toContain('Required agent rules');
97
+ expect(result.err).not.toContain('secret-token');
98
+ expect(await readFile(f.cache, 'utf8')).toBe('keep-hash\n');
99
+ }
100
+ for (const status of [401, 403, 500, 503]) {
101
+ f.set('{"error":"secret-token"}', status);
102
+ const result = await f.run();
103
+ expect(result.code).toBe(1);
104
+ expect(result.err).toContain(`HTTP ${status}`);
105
+ expect(result.err).not.toContain('secret-token');
106
+ }
107
+ f.set('{"instructions":"","rulesHash":"empty","requireSessionStart":true}');
108
+ const result = await f.run();
109
+ expect(result.code).toBe(0);
110
+ expect(result.out).toContain('no workspace rules configured');
111
+ expect((await readFile(f.cache, 'utf8')).trim()).toBe('empty');
112
+ });
113
+ });
@@ -0,0 +1,92 @@
1
+ import { OrbotoApiError } from './orboto-client.js';
2
+ /** Fixed diagnostics only: neither rule text nor upstream bodies belong in logs. */
3
+ export class RequiredRulesError extends Error {
4
+ reason;
5
+ status;
6
+ errorKey = 'errors.agent_rules.unavailable';
7
+ constructor(reason, status) {
8
+ super(`Required agent rules could not be loaded (${reason}${status ? ` ${status}` : ''}). Do not proceed without the rules. Check connectivity and authentication, then retry session-start with forceRules.`);
9
+ this.reason = reason;
10
+ this.status = status;
11
+ this.name = 'RequiredRulesError';
12
+ }
13
+ }
14
+ function record(value) {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ }
17
+ /** Validate receipts before caching their hash or claiming delivery succeeded. */
18
+ export function validateRulesReceipt(value, knownHash, textField = 'instructions') {
19
+ if (!record(value) || typeof value.rulesHash !== 'string' || !value.rulesHash
20
+ || ('rulesUnchanged' in value && typeof value.rulesUnchanged !== 'boolean')) {
21
+ throw new RequiredRulesError('invalid_response');
22
+ }
23
+ if (value.rulesUnchanged === true) {
24
+ if (!knownHash || value.rulesHash !== knownHash)
25
+ throw new RequiredRulesError('unbound_ack');
26
+ }
27
+ else if (typeof value[textField] !== 'string') {
28
+ throw new RequiredRulesError('invalid_response');
29
+ }
30
+ if (('requireSessionStart' in value && typeof value.requireSessionStart !== 'boolean')
31
+ || ('rulesChars' in value && (!Number.isInteger(value.rulesChars) || Number(value.rulesChars) < 0))
32
+ || ('rulesIndex' in value && (!Array.isArray(value.rulesIndex) || value.rulesIndex.some((row) => !record(row)
33
+ || typeof row.title !== 'string' || !Number.isInteger(row.chars) || Number(row.chars) < 0)))) {
34
+ throw new RequiredRulesError('invalid_response');
35
+ }
36
+ }
37
+ export function validateNextWorkEnvelope(value, peek = false) {
38
+ const integer = (n) => Number.isInteger(n) && Number(n) >= 0;
39
+ const candidate = record(value) ? value.candidate : undefined;
40
+ const hasCandidate = candidate !== undefined && candidate !== null;
41
+ if (!record(value) || !('reserved' in value) || (value.reserved !== null && !record(value.reserved))
42
+ || (peek && value.reserved !== null)
43
+ || (hasCandidate && (!peek || value.reserved !== null || value.reason !== null
44
+ || !record(candidate) || typeof candidate.ticketId !== 'string' || !candidate.ticketId
45
+ || (candidate.ticketKey !== null && typeof candidate.ticketKey !== 'string')
46
+ || typeof candidate.title !== 'string' || (candidate.priority !== null && typeof candidate.priority !== 'string')))
47
+ || (value.reserved === null
48
+ ? !hasCandidate && (typeof value.reason !== 'string' || !['all-blocked', 'all-leased', 'none-matching', 'autonomy_paused', 'lane_paused', 'lane_limit_reached'].includes(value.reason))
49
+ : value.reason !== null)
50
+ || !integer(value.candidatesConsidered)
51
+ || (value.retryAfterSeconds !== null && !integer(value.retryAfterSeconds))
52
+ || (value.earliestFreeAt !== null && typeof value.earliestFreeAt !== 'string')
53
+ || !Array.isArray(value.landedIdle)
54
+ || value.landedIdle.some((row) => !record(row) || typeof row.ticketId !== 'string' || !row.ticketId
55
+ || (row.ticketKey !== null && typeof row.ticketKey !== 'string') || typeof row.title !== 'string'
56
+ || typeof row.lastActivityAt !== 'string' || !integer(row.idleWorkingDays) || !integer(row.commitCount))) {
57
+ throw new RequiredRulesError('invalid_response');
58
+ }
59
+ }
60
+ /** One bounded, required read shared by session-start, resources and connect. */
61
+ export async function loadRequiredRules(client, path, knownHash) {
62
+ let value;
63
+ const controller = new AbortController();
64
+ let deadline;
65
+ const timeout = new Promise((_resolve, reject) => {
66
+ deadline = setTimeout(() => {
67
+ controller.abort();
68
+ reject(new RequiredRulesError('timeout'));
69
+ }, 10_000);
70
+ });
71
+ try {
72
+ // Race the whole operation: OAuth token/keychain acquisition can stall
73
+ // before fetch starts and therefore cannot observe fetch's abort signal.
74
+ value = await Promise.race([client.get(path, { signal: controller.signal }), timeout]);
75
+ }
76
+ catch (error) {
77
+ if (error instanceof RequiredRulesError)
78
+ throw error;
79
+ if (error instanceof OrbotoApiError)
80
+ throw new RequiredRulesError('http', error.status);
81
+ if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError'))
82
+ throw new RequiredRulesError('timeout');
83
+ if (error instanceof SyntaxError)
84
+ throw new RequiredRulesError('invalid_response');
85
+ throw new RequiredRulesError('transport');
86
+ }
87
+ finally {
88
+ clearTimeout(deadline);
89
+ }
90
+ validateRulesReceipt(value, knownHash);
91
+ return value;
92
+ }
@@ -0,0 +1,110 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { OrbotoApiError, OrbotoClient } from './orboto-client.js';
3
+ import { loadRequiredRules, RequiredRulesError, validateRulesReceipt } from './required-rules.js';
4
+ import { makeSessionStartHandler } from './tools/session-start.js';
5
+ import { makeWorkNextHandler } from './tools/work-sessions.js';
6
+ import { createNudgeState, SESSION_START_TOOL } from './session-nudge.js';
7
+ import { withMetrics } from './with-metrics.js';
8
+ afterEach(() => { vi.restoreAllMocks(); });
9
+ const makeClient = () => new OrbotoClient({ baseUrl: 'https://fixture.invalid', apiKey: 'orb_fixture' });
10
+ const full = { instructions: ' exact rules\n', rulesHash: 'hash', requireSessionStart: true };
11
+ describe('required rule delivery', () => {
12
+ it.each([null, {}, [], { reserved: null }, { reserved: '' }, { reserved: null, reason: ['none-matching'] }])('does not turn an incomplete dispatch response into idle %#', async (body) => {
13
+ const client = makeClient();
14
+ vi.spyOn(client, 'post').mockResolvedValue(body);
15
+ await expect(makeWorkNextHandler(client)({ projectKey: 'ORB' })).rejects.toBeInstanceOf(RequiredRulesError);
16
+ });
17
+ it.each([null, [], {}, 'HTML secret', { rulesHash: 'hash' }, { instructions: '' },
18
+ { instructions: null, rulesHash: 'hash' }, { ...full, rulesUnchanged: null },
19
+ { ...full, rulesIndex: [{}] }, { ...full, rulesChars: -1 }, { ...full, requireSessionStart: 'false' },
20
+ ])('rejects incomplete successful response %# without leaking it', async (body) => {
21
+ const client = makeClient();
22
+ vi.spyOn(client, 'get').mockResolvedValue(body);
23
+ await expect(loadRequiredRules(client, '/agent-instructions')).rejects.toMatchObject({ reason: 'invalid_response' });
24
+ });
25
+ it.each([new Error('orb_secret network detail'), new DOMException('secret', 'TimeoutError'),
26
+ new SyntaxError('secret invalid JSON'), ...[401, 403, 500, 503].map((s) => new OrbotoApiError(s, 'secret body', 'https://secret.invalid')),
27
+ ])('reports transport/auth/server failures safely %#', async (failure) => {
28
+ const client = makeClient();
29
+ vi.spyOn(client, 'get').mockRejectedValue(failure);
30
+ const error = await loadRequiredRules(client, '/agent-instructions').catch((e) => e);
31
+ expect(error).toBeInstanceOf(RequiredRulesError);
32
+ expect(String(error)).not.toContain('secret');
33
+ expect(String(error).length).toBeLessThan(512);
34
+ });
35
+ it('preserves exact full text, valid empty rules and bound acknowledgements', async () => {
36
+ const client = makeClient();
37
+ const get = vi.spyOn(client, 'get').mockResolvedValueOnce(full).mockResolvedValueOnce({ ...full, instructions: '' });
38
+ expect(await loadRequiredRules(client, '/agent-instructions')).toEqual(full);
39
+ expect((await loadRequiredRules(client, '/agent-instructions')).instructions).toBe('');
40
+ expect(get.mock.calls[0][1]?.signal).toBeInstanceOf(AbortSignal);
41
+ expect(() => validateRulesReceipt({ rulesHash: 'hash', rulesUnchanged: true }, 'hash')).not.toThrow();
42
+ expect(() => validateRulesReceipt({ rulesHash: 'hash', rulesUnchanged: true })).toThrow(RequiredRulesError);
43
+ expect(() => validateRulesReceipt({ rulesHash: 'other', rulesUnchanged: true }, 'hash')).toThrow(RequiredRulesError);
44
+ });
45
+ it.each([false, true])('failed session-start rulesOnly=%s stays gated and recovery succeeds without a poisoned ack', async (rulesOnly) => {
46
+ const client = makeClient();
47
+ let broken = true;
48
+ const paths = [];
49
+ vi.spyOn(client, 'post').mockResolvedValue({});
50
+ vi.spyOn(client, 'get').mockImplementation(async (path) => {
51
+ if (path.startsWith('/agent-instructions')) {
52
+ paths.push(path);
53
+ if (broken)
54
+ throw new Error('secret transport');
55
+ return full;
56
+ }
57
+ if (path.includes('assigned-tickets'))
58
+ return { items: [] };
59
+ if (path.includes('/messages'))
60
+ return { messages: [] };
61
+ return null;
62
+ });
63
+ const state = createNudgeState(true);
64
+ const session = withMetrics(client, SESSION_START_TOOL, undefined, makeSessionStartHandler(client), state);
65
+ const write = vi.fn(async () => ({ content: [] }));
66
+ const other = withMetrics(client, 'orboto_create_ticket', undefined, write, state);
67
+ const failed = await session({ rulesOnly, forceRules: true });
68
+ expect(failed.isError).toBe(true);
69
+ expect(failed.structuredContent).toMatchObject({ errorKey: 'errors.agent_rules.unavailable' });
70
+ expect(state.sessionStartRan).toBe(false);
71
+ expect((await other({})).isError).toBe(true);
72
+ expect(write).not.toHaveBeenCalled();
73
+ broken = false;
74
+ expect((await session({ rulesOnly, forceRules: true })).isError).not.toBe(true);
75
+ expect(state.sessionStartRan).toBe(true);
76
+ await other({});
77
+ expect(write).toHaveBeenCalledOnce();
78
+ expect(paths.every((path) => !path.includes('knownRulesHash'))).toBe(true);
79
+ broken = true;
80
+ expect((await session({ rulesOnly, forceRules: true })).isError).toBe(true);
81
+ expect(state.sessionStartRan).toBe(false);
82
+ });
83
+ it.each(['token', 'refresh', 'fetch'])('bounds the complete operation including a stalled %s', async (stage) => {
84
+ vi.useFakeTimers();
85
+ try {
86
+ const never = () => new Promise(() => { });
87
+ const client = new OrbotoClient({ baseUrl: 'https://fixture.invalid', tokenProvider: {
88
+ getAccessToken: stage === 'token' ? never : async () => 'fixture',
89
+ forceRefresh: never,
90
+ } });
91
+ let signal;
92
+ const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
93
+ signal = init?.signal;
94
+ if (stage === 'refresh')
95
+ return new Response('', { status: 401 });
96
+ return new Promise(() => { });
97
+ });
98
+ const rejected = expect(loadRequiredRules(client, '/agent-instructions')).rejects.toMatchObject({ reason: 'timeout' });
99
+ await vi.advanceTimersByTimeAsync(10_000);
100
+ await rejected;
101
+ if (stage === 'token')
102
+ expect(fetch).not.toHaveBeenCalled();
103
+ else
104
+ expect(signal?.aborted).toBe(true);
105
+ }
106
+ finally {
107
+ vi.useRealTimers();
108
+ }
109
+ });
110
+ });
package/dist/resources.js CHANGED
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
28
28
  import { OrbotoApiError } from './orboto-client.js';
29
+ import { loadRequiredRules } from './required-rules.js';
29
30
  import { resolveProjectByKey, resolveTicketByKey } from './tools/shared.js';
30
31
  export function registerOrbotoResources(server, client) {
31
32
  // -------------------------------------------------------------------------
@@ -41,8 +42,8 @@ export function registerOrbotoResources(server, client) {
41
42
  description: 'The complete, assembled binding rules you must follow as an agent in this workspace - cap-independent (the MCP instructions block may be truncated; this resource is not). orboto_session_start returns the same rules plus your in-progress work.',
42
43
  mimeType: 'text/markdown',
43
44
  }, async (uri) => {
44
- const res = await client.get('/agent-instructions').catch(() => ({ instructions: '' }));
45
- const rules = res?.instructions?.trim() || '(no workspace rules configured)';
45
+ const res = await loadRequiredRules(client, '/agent-instructions');
46
+ const rules = res.instructions || '(no workspace rules configured)';
46
47
  return {
47
48
  contents: [{ uri: uri.href, mimeType: 'text/markdown', text: `# orboto workspace agent rules\n\n${rules}` }],
48
49
  };
@@ -171,8 +171,18 @@ describe('orboto:// search resource', () => {
171
171
  });
172
172
  });
173
173
  describe('orboto:// rules resource (ORB-1177)', () => {
174
+ it.each([null, {}, [], { instructions: '' }])('refuses unavailable or malformed rules %#', async (body) => {
175
+ stub([{ json: body }]);
176
+ const handler = getTemplateHandler(buildServerWithResources(), 'rules');
177
+ await expect(handler(new URL('orboto://rules'), {})).rejects.toThrow('Required agent rules');
178
+ });
179
+ it('refuses a failed fetch instead of returning an empty placeholder', async () => {
180
+ stub([{ ok: false, status: 503 }]);
181
+ const handler = getTemplateHandler(buildServerWithResources(), 'rules');
182
+ await expect(handler(new URL('orboto://rules'), {})).rejects.toThrow('Required agent rules');
183
+ });
174
184
  it('returns the complete assembled workspace rules', async () => {
175
- stub([{ json: { instructions: 'RULE 1: ticket-first.\nRULE 2: one commit per ticket.' } }]);
185
+ stub([{ json: { instructions: 'RULE 1: ticket-first.\nRULE 2: one commit per ticket.', rulesHash: 'fixture' } }]);
176
186
  const server = buildServerWithResources();
177
187
  const handler = getTemplateHandler(server, 'rules');
178
188
  const out = await handler(new URL('orboto://rules'), {});
@@ -182,7 +192,7 @@ describe('orboto:// rules resource (ORB-1177)', () => {
182
192
  expect(text).toContain('RULE 2: one commit per ticket.');
183
193
  });
184
194
  it('degrades to a placeholder when no rules are configured', async () => {
185
- stub([{ json: { instructions: '' } }]);
195
+ stub([{ json: { instructions: '', rulesHash: 'empty' } }]);
186
196
  const server = buildServerWithResources();
187
197
  const handler = getTemplateHandler(server, 'rules');
188
198
  const out = await handler(new URL('orboto://rules'), {});
package/dist/server.js CHANGED
@@ -26,6 +26,7 @@ import { aiStatusToolConfig, makeAiStatusHandler } from './tools/ai-status.js';
26
26
  import { embeddingStatusToolConfig, makeEmbeddingStatusHandler } from './tools/embedding-status.js';
27
27
  import { aiUsageToolConfig, makeAiUsageHandler } from './tools/ai-usage.js';
28
28
  import { sessionStartToolConfig, makeSessionStartHandler } from './tools/session-start.js';
29
+ import { loadRequiredRules } from './required-rules.js';
29
30
  import { responseExpandToolConfig, makeResponseExpandHandler } from './tools/response-expand.js';
30
31
  import { helpToolConfig, makeHelpHandler } from './tools/help.js';
31
32
  import { apiSearchToolConfig, makeApiSearchHandler } from './tools/api-search.js';
@@ -171,14 +172,13 @@ export async function buildOrbotoMcpServer(opts) {
171
172
  // ORB-1090 - fetch the workspace's configurable working-rules at
172
173
  // connect so admin edits propagate to every new MCP session. The
173
174
  // instructions block is the one place every MCP client reliably sees
174
- // the rules (clients don't read the repo's CLAUDE.md). Best-effort:
175
- // fall back to the built-in rules if the instance predates ORB-1086
176
- // or the fetch fails.
175
+ // the rules (clients don't read the repo's CLAUDE.md). If unavailable,
176
+ // keep the connection usable for recovery but gate all other tools.
177
177
  let workingRules = FALLBACK_WORKING_RULES;
178
178
  // ORB-1471 - the same connect-time fetch also reports whether this
179
179
  // workspace requires `orboto_session_start` before any other tool call
180
- // (the hard gate). Default off; a fetch failure keeps it off.
181
- let requireSessionStart = false;
180
+ // (the hard gate). Unknown policy must never silently disable the gate.
181
+ let requireSessionStart = true;
182
182
  try {
183
183
  // ORB-1753 - stdio deployments can declare their profile via
184
184
  // ORBOTO_AGENT_KIND / ORBOTO_MODEL_TIER; api-key standing profiles
@@ -186,13 +186,13 @@ export async function buildOrbotoMcpServer(opts) {
186
186
  const connectParams = new URLSearchParams();
187
187
  applyAgentProfile(connectParams);
188
188
  const connectQs = connectParams.toString();
189
- const res = await client.get(`/agent-instructions${connectQs ? `?${connectQs}` : ''}`);
189
+ const res = await loadRequiredRules(client, `/agent-instructions${connectQs ? `?${connectQs}` : ''}`);
190
190
  if (res?.instructions?.trim())
191
191
  workingRules = res.instructions.trim();
192
- requireSessionStart = res?.requireSessionStart === true;
192
+ requireSessionStart = res.requireSessionStart ?? true;
193
193
  }
194
194
  catch {
195
- // keep the fallback
195
+ workingRules = 'Workspace rules are unavailable. Built-in hints are not a substitute. Call orboto_session_start with forceRules and retry after connectivity or authentication recovers.';
196
196
  }
197
197
  const server = new McpServer({ name: 'orboto', version: VERSION }, {
198
198
  // ORB-940 - advertise resources/subscribe so MCP-aware clients
@@ -49,29 +49,32 @@ export const SESSION_START_GATE_MESSAGE = 'This workspace requires you to load i
49
49
  'other action. Call `orboto_session_start` now - it returns the rules you ' +
50
50
  'must follow plus your in-progress work - then retry this call.';
51
51
  /**
52
- * ORB-1471 - advance the gate state for one dispatch and report whether this
52
+ * Advance the gate state for one dispatch and report whether this
53
53
  * dispatch must be REFUSED (returned an instructive error without running the
54
54
  * handler).
55
55
  *
56
56
  * Semantics when the gate is enabled: every tool call other than
57
57
  * `orboto_session_start` is refused until `orboto_session_start` has run once
58
- * this session. Calling `orboto_session_start` marks the session unlocked
58
+ * this session. A rule refresh locks the session until its result succeeds
59
59
  * (and is itself never gated). When the gate is disabled, nothing is ever
60
60
  * refused (returns false) - default behaviour is unchanged.
61
61
  *
62
- * Idempotent bookkeeping: running `orboto_session_start` flips
63
- * `sessionStartRan` so all later calls pass.
62
+ * The metrics wrapper records successful completion separately; dispatch
63
+ * alone is not evidence of rule delivery.
64
64
  */
65
65
  export function shouldGate(state, toolName) {
66
66
  if (toolName === SESSION_START_TOOL) {
67
- // The rule-loading tool always runs, and running it unlocks the session.
68
- state.sessionStartRan = true;
67
+ state.sessionStartRan = false;
69
68
  return false;
70
69
  }
71
70
  if (!state.gateEnabled)
72
71
  return false;
73
72
  return !state.sessionStartRan;
74
73
  }
74
+ export function recordSessionStartResult(state, toolName, success) {
75
+ if (toolName === SESSION_START_TOOL)
76
+ state.sessionStartRan = success;
77
+ }
75
78
  /** ORB-1471 - the instructive refusal result the gate returns. */
76
79
  export function gateResult() {
77
80
  return {
@@ -6,7 +6,7 @@
6
6
  * call is orboto_session_start, and never mutate structuredContent.
7
7
  */
8
8
  import { describe, it, expect } from 'vitest';
9
- import { SESSION_START_TOOL, SESSION_START_NUDGE, SESSION_START_GATE_MESSAGE, createNudgeState, shouldNudge, shouldGate, gateResult, prependNudge, } from './session-nudge.js';
9
+ import { SESSION_START_TOOL, SESSION_START_NUDGE, SESSION_START_GATE_MESSAGE, createNudgeState, shouldNudge, shouldGate, recordSessionStartResult, gateResult, prependNudge, } from './session-nudge.js';
10
10
  describe('shouldNudge (ORB-1331)', () => {
11
11
  it('nudges on the first tool call when it is not session_start, then never again', () => {
12
12
  const state = createNudgeState();
@@ -44,15 +44,20 @@ describe('shouldGate (ORB-1471)', () => {
44
44
  // Pre session_start: every other tool is gated.
45
45
  expect(shouldGate(state, 'orboto_list_projects')).toBe(true);
46
46
  expect(shouldGate(state, 'orboto_create_ticket')).toBe(true);
47
- // session_start itself is never gated AND unlocks the session.
47
+ // Dispatch is not proof of delivery; only successful completion unlocks.
48
48
  expect(shouldGate(state, SESSION_START_TOOL)).toBe(false);
49
+ expect(shouldGate(state, 'orboto_list_projects')).toBe(true);
50
+ recordSessionStartResult(state, SESSION_START_TOOL, true);
49
51
  // Post session_start: everything passes.
50
52
  expect(shouldGate(state, 'orboto_list_projects')).toBe(false);
51
53
  expect(shouldGate(state, 'orboto_create_ticket')).toBe(false);
52
54
  });
53
- it('session_start as the very first call unlocks immediately (one-call cost avoided)', () => {
55
+ it('a failed refresh keeps the gate closed and a successful retry unlocks', () => {
54
56
  const state = createNudgeState(true);
55
57
  expect(shouldGate(state, SESSION_START_TOOL)).toBe(false);
58
+ recordSessionStartResult(state, SESSION_START_TOOL, false);
59
+ expect(shouldGate(state, 'orboto_get_ticket')).toBe(true);
60
+ recordSessionStartResult(state, SESSION_START_TOOL, true);
56
61
  expect(shouldGate(state, 'orboto_get_ticket')).toBe(false);
57
62
  });
58
63
  it('gateResult is an instructive isError result pointing at session_start', () => {
@@ -24,14 +24,14 @@ import { SESSION_START_NUDGE } from './session-nudge.js';
24
24
  // Every API call the tools make resolves to a benign empty payload so
25
25
  // the handlers succeed without a live backend. list_projects → [] →
26
26
  // "no projects" text + valid structuredContent; session_start's sub-
27
- // calls are all .catch-guarded so [] is harmless.
27
+ // rules read is required and carries an explicit valid empty ruleset.
28
28
  function mockApi() {
29
- vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
29
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url) => ({
30
30
  ok: true,
31
31
  status: 200,
32
32
  statusText: 'OK',
33
33
  headers: new Headers({ 'content-type': 'application/json' }),
34
- json: async () => [],
34
+ json: async () => String(url).includes('/agent-instructions') ? { instructions: '', rulesHash: 'empty', requireSessionStart: false } : [],
35
35
  text: async () => '[]',
36
36
  }));
37
37
  }
@@ -54,6 +54,24 @@ function firstText(res) {
54
54
  return content[0]?.text ?? '';
55
55
  }
56
56
  describe('ORB-1331 - HTTP transport (per-session nudge)', () => {
57
+ it('keeps an unavailable connect policy gated until rules are loaded successfully', async () => {
58
+ vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('secret offline'));
59
+ const client = await connectSession();
60
+ try {
61
+ const blocked = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
62
+ expect(blocked.isError).toBe(true);
63
+ const failed = await client.callTool({ name: 'orboto_session_start', arguments: { rulesOnly: true } });
64
+ expect(failed.isError).toBe(true);
65
+ expect(firstText(failed)).not.toContain('secret');
66
+ mockApi();
67
+ const recovered = await client.callTool({ name: 'orboto_session_start', arguments: { rulesOnly: true } });
68
+ expect(recovered.isError).not.toBe(true);
69
+ expect((await client.callTool({ name: 'orboto_list_projects', arguments: {} })).isError).not.toBe(true);
70
+ }
71
+ finally {
72
+ await client.close();
73
+ }
74
+ });
57
75
  it('prepends the nudge exactly once when the first call is orboto_list_projects; the second call is clean', async () => {
58
76
  const client = await connectSession();
59
77
  const first = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
@@ -41,6 +41,12 @@ import { buildOrbotoMcpServer } from './server.js';
41
41
  * also just good UI hygiene - a title that long is a description. */
42
42
  const TITLE_MAX = 64;
43
43
  let tools;
44
+ function unsafeTakeoverTools(list) {
45
+ return list
46
+ .filter((tool) => Object.prototype.hasOwnProperty.call(tool.inputSchema?.properties ?? {}, 'takeover'))
47
+ .filter((tool) => tool.annotations?.readOnlyHint !== false || tool.annotations?.destructiveHint !== true)
48
+ .map((tool) => tool.name);
49
+ }
44
50
  beforeAll(async () => {
45
51
  // Port 1 is never listened on -> the connect-time /agent-instructions
46
52
  // fetch fails immediately and the server falls back to its built-in
@@ -84,6 +90,22 @@ describe('MCP tool safety annotations (ORB-1669)', () => {
84
90
  .map((t) => t.name);
85
91
  expect(offenders, `Contradictory annotations on: ${offenders.join(', ')}`).toEqual([]);
86
92
  });
93
+ it('every takeover-capable tool is a destructive write for all argument combinations', () => {
94
+ const capable = tools.filter((tool) => Object.prototype.hasOwnProperty.call(tool.inputSchema?.properties ?? {}, 'takeover'));
95
+ // Pin the actual siblings so losing schema discovery cannot pass vacuously.
96
+ expect(capable.map((tool) => tool.name)).toEqual(expect.arrayContaining(['orboto_work_start', 'orboto_work_session_start']));
97
+ expect(unsafeTakeoverTools(tools)).toEqual([]);
98
+ // Reservation without a takeover path is not an account-state takeover.
99
+ expect(tools.find((tool) => tool.name === 'orboto_work_next')?.annotations).toMatchObject({ readOnlyHint: false, destructiveHint: false });
100
+ });
101
+ it('the takeover ratchet catches missing, false and contradictory hints on future tools', () => {
102
+ const future = { name: 'future_takeover', inputSchema: { properties: { takeover: { type: 'boolean' } } } };
103
+ for (const annotations of [undefined, { readOnlyHint: false }, { readOnlyHint: false, destructiveHint: false }, { readOnlyHint: true, destructiveHint: true }]) {
104
+ expect(unsafeTakeoverTools([{ ...future, annotations }])).toEqual(['future_takeover']);
105
+ }
106
+ expect(unsafeTakeoverTools([{ ...future, annotations: { readOnlyHint: false, destructiveHint: true } }])).toEqual([]);
107
+ expect(unsafeTakeoverTools([{ name: 'ordinary_read', annotations: { readOnlyHint: true } }])).toEqual([]);
108
+ });
87
109
  it(`every tool has a human-readable title of at most ${TITLE_MAX} characters`, () => {
88
110
  const missing = tools.filter((t) => !(t.title ?? t.annotations?.title)).map((t) => t.name);
89
111
  expect(missing, `Tools with no title: ${missing.join(', ')}`).toEqual([]);
@@ -55,6 +55,7 @@ describe('orboto_help handler', () => {
55
55
  });
56
56
  describe('live manifest vs help registry (no guidance lost)', () => {
57
57
  it('every tool in the full manifest has a one-line wire description and full docs behind orboto_help', async () => {
58
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(JSON.stringify({ instructions: '', rulesHash: 'empty', requireSessionStart: false }), { status: 200 }));
58
59
  const server = await buildOrbotoMcpServer({
59
60
  baseUrl: 'https://orboto.example.com', apiKey: 'orb_test', toolset: 'full',
60
61
  });
@@ -0,0 +1,10 @@
1
+ // Shared rendering vocabulary for session-start and work-session bundles.
2
+ export const GIT_HEALTH_REASON_TEXT = {
3
+ connection_inactive: 'connection is deactivated',
4
+ app_installation_suspended: 'GitHub App installation is suspended',
5
+ oauth_token_expired: 'OAuth token expired with no refresh path',
6
+ history_backfill_error: 'last history backfill failed',
7
+ awaiting_first_event: 'webhook installed but has never delivered an event',
8
+ outbound_unreachable: 'orboto cannot reach the provider',
9
+ delivery_failing: 'webhook deliveries from the provider are not arriving',
10
+ };
@@ -50,6 +50,8 @@
50
50
  import { z } from 'zod';
51
51
  import { resolveTicketByKey, applyAgentProfile } from './shared.js';
52
52
  import { PROTECT_TEXT_META, storePayload } from '../response-budget.js';
53
+ import { loadRequiredRules } from '../required-rules.js';
54
+ import { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
53
55
  export const sessionStartToolConfig = {
54
56
  title: 'Load the rules you must follow + re-orient',
55
57
  description: 'THE canonical way to LOAD the binding workspace rules you must follow as an agent. Run it as your FIRST action in a session and immediately AFTER any context compaction. Returns a one-line-per-rule INDEX of the binding working-rules plus their hash (or a compact "unchanged" ack on a repeat call within the same connection) - read the index, then call this tool again with `rulesOnly: true` to read the full text of the rules whenever you do not already hold that exact hash, and expand before acting on any rule whose title touches what you are about to do. Also returns your in-progress tickets - each flagged LANDED, IDLE when it has a linked commit but has not moved for days, i.e. finished work you never handed to review - your running timer, and a warning if a project\'s git connection looks unhealthy (commit ingestion may be stalled). Pass `ticketKey` to also get a one-shot bundle for that ticket: project primer, the full ticket with dependencies + checklists, that project\'s git health, and any other agent sessions currently on it - replacing several separate calls. (Do NOT use orboto_list_agent_instructions to read the rules - that tool MANAGES/edits rule blocks for admins; this one is what you read to know how to work.) Read-only; no side effects. '
@@ -72,18 +74,6 @@ export const sessionStartToolConfig = {
72
74
  }).shape,
73
75
  annotations: { readOnlyHint: true, idempotentHint: true },
74
76
  };
75
- const GIT_HEALTH_REASON_TEXT = {
76
- connection_inactive: 'connection is deactivated',
77
- app_installation_suspended: 'GitHub App installation is suspended',
78
- oauth_token_expired: 'OAuth token expired with no refresh path',
79
- history_backfill_error: 'last history backfill failed',
80
- // ORB-1638
81
- awaiting_first_event: 'webhook installed but has never delivered an event',
82
- outbound_unreachable: 'orboto cannot reach the provider',
83
- // ORB-1785 - two derivations now: the provider reporting a failed
84
- // delivery, and the probe inferring one from commits that never arrived.
85
- delivery_failing: 'webhook deliveries from the provider are not arriving',
86
- };
87
77
  // Cap how many distinct projects we probe for git health - a session's
88
78
  // in-progress work is capped at 20 tickets already, so this rarely
89
79
  // exceeds a handful, but bound it defensively so a pathological account
@@ -241,10 +231,10 @@ export function makeSessionStartHandler(client) {
241
231
  // rules from the API instead, costs one call, and skips the whole rest
242
232
  // of the digest. Protected in both halves: it IS the rule text.
243
233
  if (input.rulesOnly) {
244
- const rules = await client.get(rulesPath).catch(() => ({}));
234
+ const rules = await loadRequiredRules(client, rulesPath);
245
235
  if (rules.rulesHash)
246
236
  lastKnownRulesHash = rules.rulesHash;
247
- const text = rules.instructions?.trim() ?? '';
237
+ const text = rules.instructions ?? '';
248
238
  return {
249
239
  _meta: { [PROTECT_TEXT_META]: true },
250
240
  content: [{
@@ -267,7 +257,7 @@ export function makeSessionStartHandler(client) {
267
257
  }
268
258
  const [me, rules, assigned, timer, inboxRaw] = await Promise.all([
269
259
  client.get('/users/me').catch(() => null),
270
- client.get(rulesPath).catch(() => ({})),
260
+ loadRequiredRules(client, rulesPath, rulesParams.get('knownRulesHash') ?? undefined),
271
261
  // ORB-1330 - a re-orientation briefing must only list OPEN work.
272
262
  // Filter to in_progress + in_review so DONE tickets can't pose as
273
263
  // "what you're working on" at the moment the agent has the least
@@ -282,8 +272,6 @@ export function makeSessionStartHandler(client) {
282
272
  .catch(() => []),
283
273
  ]);
284
274
  const pendingMessages = inboxRaw.map((m) => ({ id: m.id, fromUserId: m.fromUserId, kind: m.kind, subject: m.subject, createdAt: m.createdAt }));
285
- if (rules.rulesHash)
286
- lastKnownRulesHash = rules.rulesHash;
287
275
  const tickets = Array.isArray(assigned) ? assigned : (assigned?.items ?? []);
288
276
  // ORB-1605 - git-connection health for every project the caller has
289
277
  // open work in right now. Cheap, computed, read-only (see
@@ -326,7 +314,7 @@ export function makeSessionStartHandler(client) {
326
314
  // index - one line per rule + the hash + a handle; the default.
327
315
  // full - the text inline (forceRules, or an API too old to send
328
316
  // an index - never leave a caller without its rules).
329
- const rulesText = rules.instructions?.trim() ?? '';
317
+ const rulesText = rules.instructions ?? '';
330
318
  const rulesIndex = Array.isArray(rules.rulesIndex)
331
319
  ? rules.rulesIndex.map((e) => e?.title).filter((t) => typeof t === 'string' && t.length > 0)
332
320
  : [];
@@ -391,7 +379,7 @@ export function makeSessionStartHandler(client) {
391
379
  if (bundle)
392
380
  lines.push(...bundle.lines);
393
381
  lines.push('', 'Re-run this after any context compaction to re-sync.');
394
- return {
382
+ const result = {
395
383
  // ORB-1818 - when the full rule text IS the payload, neither half
396
384
  // may be cut; see PROTECT_TEXT_META. The index answer carries no
397
385
  // rule text and is budgeted like any other response.
@@ -431,5 +419,7 @@ export function makeSessionStartHandler(client) {
431
419
  ...(bundle ? { ticketBundle: bundle.structured } : {}),
432
420
  },
433
421
  };
422
+ lastKnownRulesHash = rules.rulesHash;
423
+ return result;
434
424
  };
435
425
  }
@@ -44,7 +44,7 @@ describe('orboto_session_start (ORB-1093)', () => {
44
44
  const calls = stubByPath({
45
45
  '/users/me/assigned-tickets': { items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Progress' }] },
46
46
  '/users/me': { email: 'dev@x.io', fullName: 'Dev', workspaceLocale: 'en' },
47
- '/agent-instructions': { instructions: 'claim -> commit -> close' },
47
+ '/agent-instructions': { instructions: 'claim -> commit -> close', rulesHash: 'fixture' },
48
48
  '/time/timer': { ticketId: 't1', ticketKey: 'ORB-42', startedAt: '2026-06-15T10:00:00Z' },
49
49
  });
50
50
  const res = await makeSessionStartHandler(client)();
@@ -65,7 +65,7 @@ describe('orboto_session_start (ORB-1093)', () => {
65
65
  stubByPath({
66
66
  '/users/me/assigned-tickets': { items: [] },
67
67
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
68
- '/agent-instructions': { instructions: 'rules here' },
68
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
69
69
  '/time/timer': {},
70
70
  });
71
71
  const res = await makeSessionStartHandler(client)();
@@ -82,7 +82,7 @@ describe('orboto_session_start (ORB-1093)', () => {
82
82
  items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
83
83
  },
84
84
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
85
- '/agent-instructions': { instructions: 'rules here' },
85
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
86
86
  '/time/timer': {},
87
87
  '/projects/proj-1/git-health': {
88
88
  connections: [{
@@ -109,7 +109,7 @@ describe('orboto_session_start (ORB-1093)', () => {
109
109
  items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
110
110
  },
111
111
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
112
- '/agent-instructions': { instructions: 'rules here' },
112
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
113
113
  '/time/timer': {},
114
114
  '/projects/proj-1/git-health': {
115
115
  connections: [{
@@ -298,7 +298,7 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
298
298
  items: [{ ticketKey: 'ORB-42', title: 'Wire it up', statusName: 'In Review', projectId: 'proj-1' }],
299
299
  },
300
300
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
301
- '/agent-instructions': { instructions: 'rules here' },
301
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
302
302
  '/time/timer': {},
303
303
  '/projects/proj-1/git-health': {
304
304
  connections: [{
@@ -332,7 +332,7 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
332
332
  ],
333
333
  },
334
334
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
335
- '/agent-instructions': { instructions: 'rules here' },
335
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
336
336
  '/time/timer': {},
337
337
  '/v1/agent/presence': [],
338
338
  });
@@ -355,7 +355,7 @@ describe('orboto_session_start - context cost (ORB-1697)', () => {
355
355
  ],
356
356
  },
357
357
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
358
- '/agent-instructions': { instructions: 'rules here' },
358
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
359
359
  '/time/timer': {},
360
360
  });
361
361
  const res = await makeSessionStartHandler(client)();
@@ -383,7 +383,7 @@ describe('orboto_session_start - a 200 with an unexpected body never kills the d
383
383
  '/projects/proj-1/tickets/by-key/42': { id: 'tick-1', projectId: 'proj-1', ticketKey: 'ORB-42', title: 'Bug' },
384
384
  '/users/me/assigned-tickets': { items: [] },
385
385
  '/users/me': { email: 'dev@x.io', fullName: 'Dev' },
386
- '/agent-instructions': { instructions: 'rules here' },
386
+ '/agent-instructions': { instructions: 'rules here', rulesHash: 'fixture' },
387
387
  });
388
388
  const res = await makeSessionStartHandler(client)({ ticketKey: 'ORB-42' });
389
389
  const text = res.content[0].text;
@@ -428,19 +428,20 @@ export function makeDeleteTicketHandler(client) {
428
428
  };
429
429
  }
430
430
  export const commentToolConfig = {
431
- title: 'Post a comment on a ticket',
432
- description: 'Append a comment. Supports Markdown. `isInternal=true` hides the comment from external/guest users (use for implementation chatter the customer shouldn\'t see).',
431
+ title: 'Post a comment',
432
+ description: 'Post Markdown. isInternal=true hides it from guests. Attachment drafts are claimed atomically with the comment; never use public ticket uploads for private comments.',
433
433
  inputSchema: z.object({
434
434
  ticketKey: z.string().min(3),
435
435
  text: z.string().min(1),
436
- isInternal: z.boolean().optional().describe('Default: false (visible to all members + guests).'),
436
+ isInternal: z.boolean().optional().describe('Default false: guests can read.'),
437
+ attachmentDraftIds: z.array(z.string().uuid()).max(20).optional().describe('Private /attachment-drafts/tickets/:ticketId uploads only.'),
437
438
  }).shape,
438
439
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
439
440
  };
440
441
  export function makeCommentHandler(client) {
441
- return async ({ ticketKey, text, isInternal }) => {
442
+ return async ({ ticketKey, text, isInternal, attachmentDraftIds }) => {
442
443
  const ticket = await resolveTicketByKey(client, ticketKey);
443
- const created = await client.post(`/tickets/${ticket.id}/comments`, { content: text, isInternal: isInternal ?? false });
444
+ const created = await client.post(`/tickets/${ticket.id}/comments`, { content: text, isInternal: isInternal ?? false, ...(attachmentDraftIds ? { attachmentDraftIds } : {}) });
444
445
  return {
445
446
  content: [{
446
447
  type: 'text',
@@ -395,6 +395,12 @@ describe('orboto_delete_ticket', () => {
395
395
  });
396
396
  });
397
397
  describe('orboto_comment', () => {
398
+ it('forwards private upload claims without dropping the internal flag', async () => {
399
+ const calls = stub([{ json: PROJ }, { json: TICKET }, { json: { id: 'c1', content: 'private image', isInternal: true, createdAt: 'now' } }]);
400
+ const ids = ['aaaaaaaa-aaaa-4aaa-aaaa-aaaaaaaaaaaa'];
401
+ await makeCommentHandler(client)({ ticketKey: 'ACME-1', text: 'private image', isInternal: true, attachmentDraftIds: ids });
402
+ expect(calls[2].body).toEqual({ content: 'private image', isInternal: true, attachmentDraftIds: ids });
403
+ });
398
404
  it('posts a regular (non-internal) comment by default', async () => {
399
405
  const calls = stub([
400
406
  { json: PROJ },
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { z } from 'zod';
8
8
  import { mcpInstanceToken } from './shared.js';
9
+ import { validateNextWorkEnvelope, validateRulesReceipt } from '../required-rules.js';
9
10
  import { GIT_HEALTH_REASON_TEXT, ResourceClaimShape } from './work-sessions-shared.js';
10
11
  /** ORB-1799 - rendered identically on every exit (reserved, empty, paused):
11
12
  * an agent that just pulled fresh work is exactly the one about to forget
@@ -61,6 +62,7 @@ export function makeWorkNextHandler(client) {
61
62
  agentSessionToken: token,
62
63
  ...(lastKnownRulesHash ? { knownRulesHash: lastKnownRulesHash } : {}),
63
64
  });
65
+ validateNextWorkEnvelope(res, args.peek);
64
66
  if (!res.reserved) {
65
67
  // ORB-1930 - a peek that found work: name it, reserve nothing.
66
68
  if (args.peek && res.candidate) {
@@ -123,8 +125,7 @@ export function makeWorkNextHandler(client) {
123
125
  };
124
126
  }
125
127
  const r = res.reserved;
126
- if (r.rulesHash)
127
- lastKnownRulesHash = r.rulesHash;
128
+ validateRulesReceipt(r, lastKnownRulesHash, 'rules');
128
129
  const lines = [
129
130
  r.reused
130
131
  ? `Renewed your existing ${r.session.role} session on ${r.ticket.ticketKey ?? r.session.ticketId}.`
@@ -138,7 +139,7 @@ export function makeWorkNextHandler(client) {
138
139
  }
139
140
  lines.push('', '## Working rules');
140
141
  if (r.rulesUnchanged) {
141
- lines.push(`Unchanged since your last call on this connection (hash ${r.rulesHash}) - keep following what you already loaded.`);
142
+ lines.push(`Unchanged since your last call on this connection (hash ${r.rulesHash}). If these rules are not currently in your context, call orboto_session_start with rulesOnly: true before proceeding.`);
142
143
  }
143
144
  else {
144
145
  lines.push(r.rules?.trim() || '(no workspace rules configured)');
@@ -179,6 +180,7 @@ export function makeWorkNextHandler(client) {
179
180
  ? '(none)'
180
181
  : r.siblingSessions.map((s) => ` - [${s.role}] ${s.userFullName ?? s.userEmail ?? 'unknown'} - lease until ${s.leaseUntil}`).join('\n'));
181
182
  lines.push(...landedIdleLines(res.landedIdle));
183
+ lastKnownRulesHash = r.rulesHash;
182
184
  return {
183
185
  content: [{ type: 'text', text: lines.join('\n') }],
184
186
  structuredContent: {
@@ -65,8 +65,31 @@ const RESERVED = {
65
65
  siblingSessions: [],
66
66
  };
67
67
  describe('orboto_work_next', () => {
68
+ const peekResponse = {
69
+ reserved: null, reason: null, retryAfterSeconds: null, earliestFreeAt: null,
70
+ candidatesConsidered: 1, landedIdle: [],
71
+ candidate: { ticketId: 't1', ticketKey: 'ACME-42', title: 'Ready', priority: null },
72
+ };
73
+ it('validates a peek candidate without claiming rules delivery or a reservation', async () => {
74
+ const calls = stub([{ json: peekResponse }, { json: peekResponse }]);
75
+ const handler = makeWorkNextHandler(client);
76
+ const result = await handler({ projectKey: 'ACME', peek: true });
77
+ expect(result.structuredContent).toMatchObject({ reserved: null, candidate: peekResponse.candidate });
78
+ expect(calls[0].body).toMatchObject({ peek: true });
79
+ expect(calls[0].body).not.toHaveProperty('knownRulesHash');
80
+ await expect(handler({ projectKey: 'ACME' })).rejects.toMatchObject({ reason: 'invalid_response' });
81
+ });
82
+ it.each([
83
+ { candidate: {} }, { candidate: { ticketId: '', ticketKey: null, title: '', priority: null } },
84
+ { candidate: { ticketId: 't1', title: 'Ready', priority: null } },
85
+ { candidate: { ticketId: 't1', ticketKey: null, title: 42, priority: null } },
86
+ { reason: 'none-matching' }, { reserved: RESERVED }, { candidatesConsidered: -1 },
87
+ ])('rejects malformed or contradictory peek responses %#', async (patch) => {
88
+ stub([{ json: { ...peekResponse, ...patch } }]);
89
+ await expect(makeWorkNextHandler(client)({ projectKey: 'ACME', peek: true })).rejects.toMatchObject({ reason: 'invalid_response' });
90
+ });
68
91
  it('reserves the winning candidate and renders the full bundle, sending projectKey straight through', async () => {
69
- const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 3 } }]);
92
+ const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 3, landedIdle: [] } }]);
70
93
  const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
71
94
  expect(calls).toHaveLength(1);
72
95
  expect(calls[0].method).toBe('POST');
@@ -87,25 +110,28 @@ describe('orboto_work_next', () => {
87
110
  });
88
111
  it('caches the rules hash across calls and renders "Renewed" on a reused session', async () => {
89
112
  const handler = makeWorkNextHandler(client);
90
- stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1 } }]);
113
+ stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [] } }]);
91
114
  await handler({ projectKey: 'ACME' });
92
115
  const calls2 = stub([{
93
116
  json: {
94
117
  reserved: { ...RESERVED, reused: true, rulesUnchanged: true, rules: undefined },
95
- reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1,
118
+ reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [],
96
119
  },
97
120
  }]);
98
121
  const res2 = await handler({ projectKey: 'ACME' });
99
122
  expect(calls2[0].body?.knownRulesHash).toBe('abc123');
100
123
  const text2 = res2.content[0].text;
101
124
  expect(text2).toContain('Unchanged since your last call');
125
+ expect(text2).toContain('not currently in your context');
126
+ expect(text2).toContain('rulesOnly: true');
127
+ expect(text2).not.toContain('keep following what you already loaded');
102
128
  expect(text2).toContain('Renewed your existing');
103
129
  });
104
130
  it('renders a structured empty result with a retry hint (all-leased)', async () => {
105
131
  stub([{
106
132
  json: {
107
133
  reserved: null, reason: 'all-leased', retryAfterSeconds: 420,
108
- earliestFreeAt: '2026-07-24T10:15:00Z', candidatesConsidered: 4,
134
+ earliestFreeAt: '2026-07-24T10:15:00Z', candidatesConsidered: 4, landedIdle: [],
109
135
  },
110
136
  }]);
111
137
  const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
@@ -124,7 +150,7 @@ describe('orboto_work_next', () => {
124
150
  stub([{
125
151
  json: {
126
152
  reserved: null, reason: 'none-matching', retryAfterSeconds: null,
127
- earliestFreeAt: null, candidatesConsidered: 0,
153
+ earliestFreeAt: null, candidatesConsidered: 0, landedIdle: [],
128
154
  },
129
155
  }]);
130
156
  const res = await makeWorkNextHandler(client)({ projectKey: 'ACME' });
@@ -134,7 +160,7 @@ describe('orboto_work_next', () => {
134
160
  expect(text).not.toContain('Retry in');
135
161
  });
136
162
  it('forwards role, leaseSeconds, and resourceClaims to the API', async () => {
137
- const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1 } }]);
163
+ const calls = stub([{ json: { reserved: RESERVED, reason: null, retryAfterSeconds: null, earliestFreeAt: null, candidatesConsidered: 1, landedIdle: [] } }]);
138
164
  await makeWorkNextHandler(client)({
139
165
  projectKey: 'ACME',
140
166
  role: 'review',
@@ -14,9 +14,4 @@ export const ResourceClaimShape = {
14
14
  /** Shared 409-body reader: the endpoint's body carries EITHER `holder`
15
15
  * (a lease conflict) OR `claimConflicts` (a resource-claim conflict) -
16
16
  * never both, since the lease is checked before claims are applied. */
17
- export const GIT_HEALTH_REASON_TEXT = {
18
- connection_inactive: 'connection is deactivated',
19
- app_installation_suspended: 'GitHub App installation is suspended',
20
- oauth_token_expired: 'OAuth token expired with no refresh path',
21
- history_backfill_error: 'last history backfill failed',
22
- };
17
+ export { GIT_HEALTH_REASON_TEXT } from './git-health-reasons.js';
@@ -122,6 +122,9 @@ describe('orboto_work_start', () => {
122
122
  expect(post2.body?.knownRulesHash).toBe('abc123');
123
123
  const text2 = res2.content[0].text;
124
124
  expect(text2).toContain('Unchanged since your last work-start');
125
+ expect(text2).toContain('not currently in your context');
126
+ expect(text2).toContain('rulesOnly: true');
127
+ expect(text2).not.toContain('keep following what you already loaded');
125
128
  expect(text2).toContain('Renewed your existing');
126
129
  });
127
130
  it('surfaces the lease holder on a 409 instead of throwing', async () => {
@@ -18,6 +18,8 @@
18
18
  import { z } from 'zod';
19
19
  import { OrbotoApiError } from '../orboto-client.js';
20
20
  import { mcpInstanceToken, resolveTicketByKey } from './shared.js';
21
+ import { validateRulesReceipt } from '../required-rules.js';
22
+ export { makeWorkNextHandler, workNextToolConfig } from './work-next.js';
21
23
  import { GIT_HEALTH_REASON_TEXT, ResourceClaimShape, } from './work-sessions-shared.js';
22
24
  function parseClaimConflicts(err) {
23
25
  try {
@@ -68,7 +70,8 @@ export const workStartToolConfig = {
68
70
  onConflict: z.enum(['reject', 'queue']).optional()
69
71
  .describe('Only matters when `resourceClaims` is set. Default `reject`.'),
70
72
  }).shape,
71
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
73
+ // takeover=true cancels another holder; hints describe the worst allowed call.
74
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
72
75
  };
73
76
  export function makeWorkStartHandler(client) {
74
77
  // Per-connection rules-hash cache, mirrored from session-start.ts's
@@ -96,8 +99,7 @@ export function makeWorkStartHandler(client) {
96
99
  agentSessionToken: token,
97
100
  ...(lastKnownRulesHash ? { knownRulesHash: lastKnownRulesHash } : {}),
98
101
  });
99
- if (res.rulesHash)
100
- lastKnownRulesHash = res.rulesHash;
102
+ validateRulesReceipt(res, lastKnownRulesHash, 'rules');
101
103
  const lines = [
102
104
  res.reused
103
105
  ? `Renewed your existing ${res.session.role} session on ${args.ticketKey}.`
@@ -114,7 +116,7 @@ export function makeWorkStartHandler(client) {
114
116
  }
115
117
  lines.push('', '## Working rules');
116
118
  if (res.rulesUnchanged) {
117
- lines.push(`Unchanged since your last work-start on this connection (hash ${res.rulesHash}) - keep following what you already loaded.`);
119
+ lines.push(`Unchanged since your last work-start on this connection (hash ${res.rulesHash}). If these rules are not currently in your context, call orboto_session_start with rulesOnly: true before proceeding.`);
118
120
  }
119
121
  else {
120
122
  lines.push(res.rules?.trim() || '(no workspace rules configured)');
@@ -156,6 +158,7 @@ export function makeWorkStartHandler(client) {
156
158
  lines.push(res.siblingSessions.length === 0
157
159
  ? '(none)'
158
160
  : res.siblingSessions.map((s) => ` - [${s.role}] ${s.userFullName ?? s.userEmail ?? 'unknown'} - lease until ${s.leaseUntil}`).join('\n'));
161
+ lastKnownRulesHash = res.rulesHash;
159
162
  return {
160
163
  content: [{ type: 'text', text: lines.join('\n') }],
161
164
  structuredContent: {
@@ -236,7 +239,8 @@ export const workSessionStartToolConfig = {
236
239
  onConflict: z.enum(['reject', 'queue']).optional()
237
240
  .describe('Only matters when `resourceClaims` is set. Default `reject`: a conflicting write claim fails the WHOLE call with the conflicting holder(s) named - if this call would have created a brand-new session, that session is rolled back rather than left holding the lease without its claims. `queue`: the conflicting claim is accepted as `state: "waiting"` instead of failing; it is promoted automatically once the conflict clears (release, finish, or the next orboto_work_sessions read).'),
238
241
  }).shape,
239
- annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true },
242
+ // The optional takeover can displace someone else's active session.
243
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
240
244
  };
241
245
  export function makeWorkSessionStartHandler(client) {
242
246
  return async (args, extra) => {
@@ -1,5 +1,6 @@
1
1
  import { OrbotoApiError } from './orboto-client.js';
2
- import { shouldNudge, prependNudge, shouldGate, gateResult } from './session-nudge.js';
2
+ import { shouldNudge, prependNudge, shouldGate, gateResult, recordSessionStartResult } from './session-nudge.js';
3
+ import { RequiredRulesError } from './required-rules.js';
3
4
  import { applyResponseBudget, TruncationBlockAdvertisedSchema } from './response-budget.js';
4
5
  import { buildStrictInputSchema, isRawShape } from './input-schema.js';
5
6
  import { captureToolDoc, summarizeToolDescription } from './tool-docs.js';
@@ -62,8 +63,7 @@ nudge) {
62
63
  // regardless of its outcome. Applied to the returned result below.
63
64
  const wantsNudge = nudge ? shouldNudge(nudge, toolName) : false;
64
65
  // ORB-1471 - HARD session-start gate. When the workspace requires it,
65
- // refuse every tool call until `orboto_session_start` has run (this also
66
- // flips the "ran" flag when the tool IS session_start). The gate message
66
+ // refuse every tool call until `orboto_session_start` succeeds. The gate message
67
67
  // supersedes the soft nudge, so we return it directly without running the
68
68
  // handler. Disabled by default => `shouldGate` returns false and nothing
69
69
  // below changes.
@@ -92,6 +92,8 @@ nudge) {
92
92
  // as success=false in the log so dashboards reflect actual
93
93
  // user-visible failures.
94
94
  const isError = result.isError === true;
95
+ if (nudge)
96
+ recordSessionStartResult(nudge, toolName, !isError);
95
97
  void postLogEntry(client, {
96
98
  toolName,
97
99
  durationMs: Date.now() - start,
@@ -113,6 +115,12 @@ nudge) {
113
115
  }
114
116
  catch (err) {
115
117
  const durationMs = Date.now() - start;
118
+ if (nudge)
119
+ recordSessionStartResult(nudge, toolName, false);
120
+ if (err instanceof RequiredRulesError) {
121
+ void postLogEntry(client, { toolName, durationMs, success: false, statusCode: err.status, errorMessage: err.message, clientHint });
122
+ return { isError: true, content: [{ type: 'text', text: err.message }], structuredContent: { errorKey: err.errorKey, reason: err.reason, ...(err.status ? { status: err.status } : {}) } };
123
+ }
116
124
  // ORB-1174 - an OrbotoApiError carries a real HTTP status + message.
117
125
  // Return it as a structured isError result so the agent + human see
118
126
  // WHY (401 vs 404 vs 500) and can self-correct, instead of the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.181.1",
3
+ "version": "0.182.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",