@orboto/mcp 0.230.8 → 0.230.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -75,6 +75,7 @@ import { checkToolConfig, makeCheckHandler, uncheckToolConfig, makeUncheckHandle
75
75
  import { listUsersToolConfig, makeListUsersHandler, getAuditLogToolConfig, makeGetAuditLogHandler, triggerBackupToolConfig, makeTriggerBackupHandler, } from './tools/admin-writes.js';
76
76
  import { freeBusyToolConfig, makeFreeBusyHandler } from './tools/free-busy.js';
77
77
  import { createFullBackupToolConfig, makeCreateFullBackupHandler, listBackupsToolConfig, makeListBackupsHandler, downloadBackupToolConfig, makeDownloadBackupHandler, } from './tools/backup.js';
78
+ import { importNumberingToolConfig, makeImportNumberingHandler, importApplyToolConfig, makeImportApplyHandler, } from './tools/import-numbering.js';
78
79
  import { primerFactListToolConfig, makePrimerFactListHandler, primerFactAddToolConfig, makePrimerFactAddHandler, primerFactUpdateToolConfig, makePrimerFactUpdateHandler, primerFactSupersedeToolConfig, makePrimerFactSupersedeHandler, primerFactVerifyToolConfig, makePrimerFactVerifyHandler, primerFactDeleteToolConfig, makePrimerFactDeleteHandler, } from './tools/primer-facts.js';
79
80
  import { whoamiToolConfig, makeWhoamiHandler } from './tools/identity.js';
80
81
  import { claimToolConfig, makeClaimHandler, unclaimToolConfig, makeUnclaimHandler, } from './tools/claim.js';
@@ -332,6 +333,8 @@ export async function buildOrbotoMcpServer(opts) {
332
333
  reg('orboto_create_full_backup', createFullBackupToolConfig, makeCreateFullBackupHandler(client));
333
334
  reg('orboto_list_backups', listBackupsToolConfig, makeListBackupsHandler(client));
334
335
  reg('orboto_download_backup', downloadBackupToolConfig, makeDownloadBackupHandler(client));
336
+ reg('orboto_import_numbering', importNumberingToolConfig, makeImportNumberingHandler(client));
337
+ reg('orboto_import_apply', importApplyToolConfig, makeImportApplyHandler(client));
335
338
  reg('orboto_primer_fact_list', primerFactListToolConfig, makePrimerFactListHandler(client));
336
339
  reg('orboto_primer_fact_add', primerFactAddToolConfig, makePrimerFactAddHandler(client));
337
340
  reg('orboto_primer_fact_update', primerFactUpdateToolConfig, makePrimerFactUpdateHandler(client));
@@ -95,6 +95,7 @@ export function makeGetTicketHandler(client) {
95
95
  } : null,
96
96
  childCount: children.length,
97
97
  webUrl: full.webUrl ?? null,
98
+ ...(full.sourceSystem || full.sourceKey ? { source: { system: full.sourceSystem ?? null, key: full.sourceKey ?? null, id: full.sourceId ?? null } } : {}),
98
99
  blockedBy: {
99
100
  openCount: full.blockedByOpenCount ?? 0,
100
101
  tickets: (full.blockedByOpen ?? []).map(dependencyEdge),
@@ -221,6 +222,7 @@ function formatTicket(ticket, inc, comments, commentTotal, ticketKey, checklists
221
222
  ? `Labels: ${ticket.labels.map((l) => l.name).join(', ')}`
222
223
  : null,
223
224
  ticket.branchName ? `Branch: ${ticket.branchName}` : null,
225
+ ticket.sourceSystem || ticket.sourceKey ? `Source: ${[ticket.sourceSystem, ticket.sourceKey].filter(Boolean).join(' ')}` : null,
224
226
  ticket.webUrl ? `Link: ${ticket.webUrl}` : null,
225
227
  formatDependencySummary('Blocked by', ticket.blockedByOpenCount, ticket.blockedByOpen),
226
228
  formatDependencySummary('Blocks', ticket.blocksOpenCount, ticket.blocksOpen),
@@ -0,0 +1,156 @@
1
+ /**
2
+ * ORB-2676 - import numbering: the plan of kept and renumbered source tickets and the apply with its double merge confirmation.
3
+ */
4
+ import { z } from 'zod';
5
+ import { OrbotoApiError } from '../orboto-client.js';
6
+ const MERGE_NOT_CONFIRMED = 'errors.admin_import.merge_not_confirmed';
7
+ const NUMBERING_LIST_CAP = 30;
8
+ const TITLE_CHARS = 50;
9
+ const clip = (value, max) => (value.length > max ? `${value.slice(0, max - 3)}...` : value);
10
+ const moreLine = (shown, total, what) => total > shown ? [`... ${total - shown} more ${what} not listed (${total} in all)`] : [];
11
+ export function renumberedLine(r) {
12
+ const from = r.sourceKey ?? `#${r.sourceNumber}`;
13
+ const holder = r.reason === 'number_taken' && r.existingTicketKey
14
+ ? `; ${r.existingTicketKey}${r.existingTicketTitle ? ` "${clip(r.existingTicketTitle, TITLE_CHARS)}"` : ''}`
15
+ : '';
16
+ return `${from} -> ${r.ticketKey} (${r.reason}${holder})`;
17
+ }
18
+ function renumberedPayload(rows) {
19
+ return rows.map((r) => ({
20
+ from: r.sourceKey ?? `#${r.sourceNumber}`,
21
+ to: r.ticketKey,
22
+ reason: r.reason,
23
+ ...(r.existingTicketKey ? { existing: r.existingTicketKey } : {}),
24
+ }));
25
+ }
26
+ export function numberingResult(plan, token) {
27
+ const renumbered = Array.isArray(plan.renumbered) ? plan.renumbered : [];
28
+ const shown = renumbered.slice(0, NUMBERING_LIST_CAP);
29
+ const target = plan.projectKey ?? '(no key yet)';
30
+ const lines = [
31
+ `Import numbering for staged import ${token}: target ${target}${plan.sourceProjectKey ? `, source project ${plan.sourceProjectKey}` : ''}.`,
32
+ plan.merge
33
+ ? `MERGE: ${target} already has ${plan.existingTickets} tickets. Applying needs confirmMerge: true AND confirmProjectKey: "${target}" typed by the user (orboto_import_apply).`
34
+ : 'Not a merge: the target has no tickets yet.',
35
+ `Keep their source number: ${plan.preserved}. No source number (fresh numbers after the highest): ${plan.withoutSourceNumber}. Renumbered: ${renumbered.length}. Next free number: ${plan.nextTicketNumber}.`,
36
+ ...(plan.projectKeyIssue ? [`The source key ${plan.sourceProjectKey ?? ''} cannot become the project key: ${plan.projectKeyIssue === 'taken' ? 'another project holds it' : 'not 2-10 letters or digits'}.`] : []),
37
+ ];
38
+ if (renumbered.length > 0) {
39
+ lines.push('Renumbered tickets:', ...shown.map(renumberedLine), ...moreLine(shown.length, renumbered.length, 'renumbered tickets'));
40
+ if (renumbered.length > shown.length)
41
+ lines.push(`The complete list: \`orboto import numbering ${token}\` in the CLI, or GET /admin/import/jobs/${token}/numbering through orboto_api_call.`);
42
+ }
43
+ return {
44
+ content: [{ type: 'text', text: lines.join('\n') }],
45
+ structuredContent: {
46
+ projectKey: plan.projectKey,
47
+ sourceProjectKey: plan.sourceProjectKey,
48
+ projectKeyIssue: plan.projectKeyIssue,
49
+ merge: plan.merge,
50
+ existingTickets: plan.existingTickets,
51
+ preserved: plan.preserved,
52
+ withoutSourceNumber: plan.withoutSourceNumber,
53
+ nextTicketNumber: plan.nextTicketNumber,
54
+ renumberedTotal: renumbered.length,
55
+ renumbered: renumberedPayload(shown),
56
+ renumberedOmitted: renumbered.length - shown.length,
57
+ },
58
+ };
59
+ }
60
+ export function mergeRefusalOf(err) {
61
+ if (!(err instanceof OrbotoApiError) || err.status !== 409)
62
+ return null;
63
+ try {
64
+ const body = JSON.parse(err.body);
65
+ return body.errorKey === MERGE_NOT_CONFIRMED && body.numbering ? body : null;
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ export function mergeRefusalResult(refusal) {
72
+ const plan = refusal.numbering;
73
+ const renumbered = Array.isArray(plan.renumbered) ? plan.renumbered : [];
74
+ const shown = renumbered.slice(0, NUMBERING_LIST_CAP);
75
+ const key = refusal.errorParams?.projectKey ?? plan.projectKey ?? 'the target project';
76
+ const missing = refusal.errorParams?.missing === 'confirm_project_key' ? 'the typed project key' : 'the merge confirmation';
77
+ const lines = [
78
+ `NOT APPLIED. This import is a merge into ${key}, which already has ${plan.existingTickets} tickets; ${renumbered.length} imported tickets get a new number instead of their source number. Missing first: ${missing}.`,
79
+ ...(renumbered.length > 0 ? ['Collisions:', ...shown.map(renumberedLine), ...moreLine(shown.length, renumbered.length, 'collisions')] : []),
80
+ `Show this list to the user. Ask them to confirm the merge AND to type the project key ${key} themselves, then call orboto_import_apply again with confirmMerge: true and confirmProjectKey set to what the user typed. Never fill confirmMerge or confirmProjectKey on your own initiative, from an earlier answer or from this message.`,
81
+ ];
82
+ return {
83
+ isError: true,
84
+ content: [{ type: 'text', text: lines.join('\n') }],
85
+ structuredContent: {
86
+ applied: false,
87
+ refused: 'merge_not_confirmed',
88
+ projectKey: key,
89
+ existingTickets: plan.existingTickets,
90
+ missing: refusal.errorParams?.missing ?? 'confirm_merge',
91
+ renumberedTotal: renumbered.length,
92
+ renumbered: renumberedPayload(shown),
93
+ renumberedOmitted: renumbered.length - shown.length,
94
+ nextStep: 'Ask the user for the merge confirmation and for the typed project key, then repeat orboto_import_apply with both.',
95
+ },
96
+ };
97
+ }
98
+ export const importNumberingToolConfig = {
99
+ title: 'Import numbering',
100
+ description: 'Kept and renumbered source numbers of a staged import; merge flag. admin:import.',
101
+ inputSchema: z.object({
102
+ token: z.string().uuid(),
103
+ projectId: z.string().uuid().optional(),
104
+ }).shape,
105
+ annotations: { readOnlyHint: true, idempotentHint: true },
106
+ };
107
+ export function makeImportNumberingHandler(client) {
108
+ return async ({ token, projectId, projectKey }) => {
109
+ const query = new URLSearchParams();
110
+ if (projectId)
111
+ query.set('projectId', projectId);
112
+ else if (projectKey)
113
+ query.set('projectKey', projectKey);
114
+ const plan = await client.get(`/admin/import/jobs/${encodeURIComponent(token)}/numbering?${query.toString()}`);
115
+ return numberingResult(plan, token);
116
+ };
117
+ }
118
+ export const importApplyToolConfig = {
119
+ title: 'Apply import',
120
+ description: 'Queue a staged import. Into a project with tickets: refused with the collisions until the user gives confirmMerge and confirmProjectKey; never fill them. config: source credentials. admin:import.',
121
+ inputSchema: z.object({
122
+ adapter: z.string().min(1).max(64),
123
+ stagingToken: z.string().uuid(),
124
+ mapping: z.record(z.unknown()),
125
+ projectKey: z.string().trim().max(10).optional(),
126
+ confirmMerge: z.boolean().optional(),
127
+ confirmProjectKey: z.string().trim().max(10).optional(),
128
+ config: z.record(z.unknown()).optional(),
129
+ }).shape,
130
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
131
+ };
132
+ export function makeImportApplyHandler(client) {
133
+ return async (args) => {
134
+ const { adapter, stagingToken, mapping, projectKey, confirmMerge, confirmProjectKey, config } = args;
135
+ try {
136
+ const job = await client.post(`/admin/import/${encodeURIComponent(adapter)}/apply-async`, {
137
+ stagingToken,
138
+ mapping,
139
+ ...(projectKey ? { projectKey } : {}),
140
+ ...(confirmMerge !== undefined ? { confirmMerge } : {}),
141
+ ...(confirmProjectKey ? { confirmProjectKey } : {}),
142
+ ...(config ? { config } : {}),
143
+ });
144
+ return {
145
+ content: [{ type: 'text', text: `Import ${job.status} (job ${job.jobId ?? 'none'}, token ${job.token}). Follow it with GET /admin/import/jobs/${job.token} through orboto_api_call; the finished result carries the numbering report.` }],
146
+ structuredContent: { applied: true, jobId: job.jobId, token: job.token, status: job.status },
147
+ };
148
+ }
149
+ catch (err) {
150
+ const refusal = mergeRefusalOf(err);
151
+ if (refusal)
152
+ return mergeRefusalResult(refusal);
153
+ throw err;
154
+ }
155
+ };
156
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * ORB-2676 - orboto_import_numbering and orboto_import_apply.
3
+ */
4
+ import { describe, expect, it, vi } from 'vitest';
5
+ import { OrbotoApiError } from '../orboto-client.js';
6
+ import { applyResponseBudget, budgetFor, measureHalves } from '../response-budget.js';
7
+ import { toolInToolset } from '../toolset.js';
8
+ import { makeImportApplyHandler, makeImportNumberingHandler, mergeRefusalOf, renumberedLine, } from './import-numbering.js';
9
+ const TOKEN = '3f2b6c1e-8a4d-4e5f-9b7a-1c2d3e4f5a6b';
10
+ const PROJECT_ID = '9b7a1c2d-3e4f-4a6b-8c1d-2e3f4a5b6c7d';
11
+ const textOf = (result) => result.content[0].text;
12
+ function ticket(n, reason = 'number_taken') {
13
+ return {
14
+ sourceId: `10${n}`,
15
+ sourceKey: `PROJ-${n}`,
16
+ sourceNumber: n,
17
+ reason,
18
+ existingTicketKey: reason === 'number_taken' ? `KEY-${n}` : null,
19
+ existingTicketTitle: reason === 'number_taken' ? `Existing ticket number ${n}` : null,
20
+ ticketNumber: 100 + n,
21
+ ticketKey: `KEY-${100 + n}`,
22
+ };
23
+ }
24
+ function plan(overrides = {}) {
25
+ return {
26
+ projectKey: 'KEY',
27
+ sourceProjectKey: 'PROJ',
28
+ projectKeyIssue: null,
29
+ merge: true,
30
+ existingTickets: 12,
31
+ preserved: 40,
32
+ withoutSourceNumber: 2,
33
+ renumbered: [ticket(2), ticket(5, 'duplicate_source_number')],
34
+ nextTicketNumber: 160,
35
+ ...overrides,
36
+ };
37
+ }
38
+ function refusal(overrides = {}) {
39
+ const numbering = plan();
40
+ return {
41
+ error: 'This import merges into KEY',
42
+ errorKey: 'errors.admin_import.merge_not_confirmed',
43
+ errorParams: { projectKey: 'KEY', existingTickets: 12, renumbered: numbering.renumbered.length, missing: 'confirm_merge' },
44
+ numbering,
45
+ ...overrides,
46
+ };
47
+ }
48
+ const conflict = (body) => new OrbotoApiError(409, typeof body === 'string' ? body : JSON.stringify(body), 'https://orboto.example.com/admin/import/jira/apply-async');
49
+ describe('orboto_import_numbering', () => {
50
+ it('reads the numbering route with the project id and prints counts plus one line per renumbered ticket', async () => {
51
+ const get = vi.fn().mockResolvedValue(plan());
52
+ const result = await makeImportNumberingHandler({ get })({ token: TOKEN, projectId: PROJECT_ID });
53
+ expect(get).toHaveBeenCalledWith(`/admin/import/jobs/${TOKEN}/numbering?projectId=${PROJECT_ID}`);
54
+ const text = textOf(result);
55
+ expect(text).toContain('MERGE: KEY already has 12 tickets');
56
+ expect(text).toContain('confirmProjectKey: "KEY" typed by the user');
57
+ expect(text).toContain('Keep their source number: 40. No source number (fresh numbers after the highest): 2. Renumbered: 2. Next free number: 160.');
58
+ expect(text).toContain('PROJ-2 -> KEY-102 (number_taken; KEY-2 "Existing ticket number 2")');
59
+ expect(text).toContain('PROJ-5 -> KEY-105 (duplicate_source_number)');
60
+ expect(result.structuredContent).toMatchObject({
61
+ merge: true, existingTickets: 12, preserved: 40, renumberedTotal: 2, renumberedOmitted: 0,
62
+ renumbered: [{ from: 'PROJ-2', to: 'KEY-102', reason: 'number_taken', existing: 'KEY-2' }, { from: 'PROJ-5', to: 'KEY-105', reason: 'duplicate_source_number' }],
63
+ });
64
+ });
65
+ it('sends the proposed key of a new project, says it is no merge and names an unusable source key', async () => {
66
+ const get = vi.fn().mockResolvedValue(plan({ merge: false, existingTickets: 0, renumbered: [], projectKey: 'NEWK', projectKeyIssue: 'taken' }));
67
+ const result = await makeImportNumberingHandler({ get })({ token: TOKEN, projectKey: 'NEWK' });
68
+ expect(get).toHaveBeenCalledWith(`/admin/import/jobs/${TOKEN}/numbering?projectKey=NEWK`);
69
+ expect(textOf(result)).toContain('Not a merge');
70
+ expect(textOf(result)).toContain('cannot become the project key: another project holds it');
71
+ expect(textOf(result)).not.toContain('Renumbered tickets');
72
+ });
73
+ it('falls back to a source number line for a ticket without a source key', () => {
74
+ expect(renumberedLine({ ...ticket(7), sourceKey: null })).toBe('#7 -> KEY-107 (number_taken; KEY-7 "Existing ticket number 7")');
75
+ });
76
+ it('lists the first 30 renumbered tickets and says explicitly how many it left out, inside the default budget', async () => {
77
+ const many = Array.from({ length: 2000 }, (_, i) => ticket(i + 1));
78
+ const get = vi.fn().mockResolvedValue(plan({ renumbered: many }));
79
+ const result = await makeImportNumberingHandler({ get })({ token: TOKEN });
80
+ expect(get).toHaveBeenCalledWith(`/admin/import/jobs/${TOKEN}/numbering?`);
81
+ expect(textOf(result)).toContain('... 1970 more renumbered tickets not listed (2000 in all)');
82
+ expect(textOf(result)).toContain(`orboto import numbering ${TOKEN}`);
83
+ expect(result.structuredContent).toMatchObject({ renumberedTotal: 2000, renumberedOmitted: 1970 });
84
+ expect(result.structuredContent.renumbered).toHaveLength(30);
85
+ const { textChars, structuredChars } = measureHalves(result);
86
+ expect(Math.max(textChars, structuredChars)).toBeLessThanOrEqual(budgetFor('orboto_import_numbering', {}));
87
+ const budgeted = applyResponseBudget('orboto_import_numbering', result, {});
88
+ expect(budgeted.truncatedChars).toBe(0);
89
+ });
90
+ it('is cut explicitly by the central budget when the answer is still too long', () => {
91
+ const long = Array.from({ length: 30 }, (_, i) => ({ ...ticket(i + 1), sourceKey: `PROJ-${'K'.repeat(400)}-${i}` }));
92
+ const get = vi.fn().mockResolvedValue(plan({ renumbered: long }));
93
+ return makeImportNumberingHandler({ get })({ token: TOKEN }).then((result) => {
94
+ const budgeted = applyResponseBudget('orboto_import_numbering', result, {});
95
+ expect(budgeted.responseChars).toBeLessThanOrEqual(budgetFor('orboto_import_numbering', {}) + 700);
96
+ expect(budgeted.handle).toBeTruthy();
97
+ });
98
+ });
99
+ });
100
+ describe('orboto_import_apply', () => {
101
+ const mapping = { projectId: PROJECT_ID, statusMap: { Open: 'todo' } };
102
+ it('queues the apply with the confirmations and the credentials only when given', async () => {
103
+ const post = vi.fn().mockResolvedValue({ jobId: 'job-1', token: TOKEN, status: 'queued' });
104
+ const handler = makeImportApplyHandler({ post });
105
+ const plain = await handler({ adapter: 'jira', stagingToken: TOKEN, mapping });
106
+ expect(post).toHaveBeenLastCalledWith('/admin/import/jira/apply-async', { stagingToken: TOKEN, mapping });
107
+ expect(textOf(plain)).toContain(`Import queued (job job-1, token ${TOKEN}`);
108
+ expect(plain.structuredContent).toEqual({ applied: true, jobId: 'job-1', token: TOKEN, status: 'queued' });
109
+ expect(plain.isError).toBeUndefined();
110
+ await handler({
111
+ adapter: 'jira', stagingToken: TOKEN, mapping, projectKey: 'NEWK', confirmMerge: true, confirmProjectKey: 'KEY', config: { baseUrl: 'https://jira.example.com', apiToken: 'secret' },
112
+ });
113
+ expect(post).toHaveBeenLastCalledWith('/admin/import/jira/apply-async', {
114
+ stagingToken: TOKEN, mapping, projectKey: 'NEWK', confirmMerge: true, confirmProjectKey: 'KEY', config: { baseUrl: 'https://jira.example.com', apiToken: 'secret' },
115
+ });
116
+ });
117
+ it('turns the 409 merge refusal into an error result that lists the collisions and asks the user for both confirmations', async () => {
118
+ const post = vi.fn().mockRejectedValue(conflict(refusal()));
119
+ const result = await makeImportApplyHandler({ post })({ adapter: 'jira', stagingToken: TOKEN, mapping });
120
+ expect(result.isError).toBe(true);
121
+ const text = textOf(result);
122
+ expect(text).toContain('NOT APPLIED');
123
+ expect(text).toContain('merge into KEY, which already has 12 tickets; 2 imported tickets get a new number');
124
+ expect(text).toContain('Missing first: the merge confirmation');
125
+ expect(text).toContain('PROJ-2 -> KEY-102 (number_taken; KEY-2 "Existing ticket number 2")');
126
+ expect(text).toContain('Ask them to confirm the merge AND to type the project key KEY themselves');
127
+ expect(text).toContain('Never fill confirmMerge or confirmProjectKey on your own initiative');
128
+ expect(result.structuredContent).toMatchObject({
129
+ applied: false, refused: 'merge_not_confirmed', projectKey: 'KEY', existingTickets: 12, missing: 'confirm_merge', renumberedTotal: 2,
130
+ });
131
+ });
132
+ it('names the typed project key as the missing confirmation when the merge was confirmed alone', async () => {
133
+ const body = refusal({ errorParams: { projectKey: 'KEY', existingTickets: 12, renumbered: 2, missing: 'confirm_project_key' } });
134
+ const post = vi.fn().mockRejectedValue(conflict(body));
135
+ const result = await makeImportApplyHandler({ post })({ adapter: 'jira', stagingToken: TOKEN, mapping, confirmMerge: true });
136
+ expect(textOf(result)).toContain('Missing first: the typed project key');
137
+ expect(result.structuredContent).toMatchObject({ missing: 'confirm_project_key' });
138
+ });
139
+ it('rethrows every other failure, including a 409 that is not the merge refusal', async () => {
140
+ const handler = (err) => makeImportApplyHandler({ post: vi.fn().mockRejectedValue(err) })({ adapter: 'jira', stagingToken: TOKEN, mapping });
141
+ const forbidden = new OrbotoApiError(403, '{"error":"no"}', 'u');
142
+ await expect(handler(forbidden)).rejects.toBe(forbidden);
143
+ const other = conflict({ error: 'busy', errorKey: 'errors.system.import_already_running' });
144
+ await expect(handler(other)).rejects.toBe(other);
145
+ const garbled = conflict('not json');
146
+ await expect(handler(garbled)).rejects.toBe(garbled);
147
+ const plain = new Error('network');
148
+ await expect(handler(plain)).rejects.toBe(plain);
149
+ });
150
+ it('recognises only the merge refusal with a numbering plan', () => {
151
+ expect(mergeRefusalOf(conflict(refusal()))?.errorParams?.projectKey).toBe('KEY');
152
+ expect(mergeRefusalOf(conflict({ errorKey: 'errors.admin_import.merge_not_confirmed' }))).toBeNull();
153
+ expect(mergeRefusalOf(new OrbotoApiError(400, JSON.stringify(refusal()), 'u'))).toBeNull();
154
+ expect(mergeRefusalOf(new Error('x'))).toBeNull();
155
+ });
156
+ });
157
+ describe('toolsets', () => {
158
+ it('keeps the two import tools out of the curated, minimal and channel sets', () => {
159
+ for (const name of ['orboto_import_numbering', 'orboto_import_apply']) {
160
+ expect(toolInToolset(name, 'full')).toBe(true);
161
+ expect(toolInToolset(name, 'curated')).toBe(false);
162
+ expect(toolInToolset(name, 'minimal')).toBe(false);
163
+ expect(toolInToolset(name, 'channel')).toBe(false);
164
+ }
165
+ });
166
+ });
@@ -323,6 +323,29 @@ describe('orboto_get_ticket', () => {
323
323
  const sc = res.structuredContent;
324
324
  expect(sc.waitingForGitIngestion).toBe(true);
325
325
  });
326
+ it('ORB-2676: an imported ticket shows its source system and key; a native ticket shows no Source line', async () => {
327
+ const row = { id: 't1', projectId: 'p1', ticketKey: 'ACME-280', title: 'Imported', status: 'TODO', statusName: 'To Do', statusCategory: 'todo', type: 'task', priority: 'normal' };
328
+ stub([
329
+ { json: PROJ },
330
+ { json: { ...row, gitActivityCount: 0, parentTicketId: null } },
331
+ { json: { ...row, sourceSystem: 'jira', sourceKey: 'PROJ-280', sourceId: '10280' } },
332
+ NO_CHILDREN,
333
+ { json: [] },
334
+ ]);
335
+ const imported = await makeGetTicketHandler(client)({ ticketKey: 'ACME-280' });
336
+ expect(imported.content[0].text).toContain('Source: jira PROJ-280');
337
+ expect(imported.structuredContent.source).toEqual({ system: 'jira', key: 'PROJ-280', id: '10280' });
338
+ stub([
339
+ { json: PROJ },
340
+ { json: { ...row, gitActivityCount: 0, parentTicketId: null } },
341
+ { json: { ...row, sourceSystem: null, sourceKey: null, sourceId: null } },
342
+ NO_CHILDREN,
343
+ { json: [] },
344
+ ]);
345
+ const native = await makeGetTicketHandler(client)({ ticketKey: 'ACME-280' });
346
+ expect(native.content[0].text).not.toContain('Source:');
347
+ expect(native.structuredContent.source).toBeUndefined();
348
+ });
326
349
  it('include: ["checklistItems"] reads effectiveCompleted (not done) + surfaces linked-ticket suffix', async () => {
327
350
  stub([
328
351
  { json: PROJ },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.230.8",
3
+ "version": "0.230.9",
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",