@orboto/mcp 0.186.0 → 0.187.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.
@@ -63,7 +63,9 @@ afterEach(() => { vi.restoreAllMocks(); });
63
63
  // (286 chars); the remaining ~4.2k chars are input/output schemas and the
64
64
  // per-tool keys the SDK emits. Decision by the operator, 2026-09-06.
65
65
  const CURATED_MAX_CHARS = 37_500;
66
- const FULL_MAX_CHARS = 155_000;
66
+ // ORB-2000 - orboto_draft_customer_reply joins the full tier (measured
67
+ // 155,669 chars with it); ceiling re-pinned with ~6 % headroom.
68
+ const FULL_MAX_CHARS = 165_000;
67
69
  /**
68
70
  * ORB-1805 - the estimator the ticket measured the failure with
69
71
  * (`n_keep: 13533 >= n_ctx: 8192` in LM Studio): characters / 3.6. It is
package/dist/server.js CHANGED
@@ -23,6 +23,7 @@ import { registerWithMetrics } from './with-metrics.js';
23
23
  import { resolveToolset, toolInToolset } from './toolset.js';
24
24
  import { createNudgeState } from './session-nudge.js';
25
25
  import { aiStatusToolConfig, makeAiStatusHandler } from './tools/ai-status.js';
26
+ import { draftCustomerReplyToolConfig, makeDraftCustomerReplyHandler } from './tools/customer-draft.js';
26
27
  import { embeddingStatusToolConfig, makeEmbeddingStatusHandler } from './tools/embedding-status.js';
27
28
  import { aiUsageToolConfig, makeAiUsageHandler } from './tools/ai-usage.js';
28
29
  import { sessionStartToolConfig, makeSessionStartHandler } from './tools/session-start.js';
@@ -264,6 +265,7 @@ export async function buildOrbotoMcpServer(opts) {
264
265
  // Tools - alphabetical-ish by concept. Each tool file owns its
265
266
  // input/output schema; the server just glues names to handlers.
266
267
  reg('orboto_ai_status', aiStatusToolConfig, makeAiStatusHandler(client));
268
+ reg('orboto_draft_customer_reply', draftCustomerReplyToolConfig, makeDraftCustomerReplyHandler(client)); // ORB-2000
267
269
  reg('orboto_embedding_status', embeddingStatusToolConfig, makeEmbeddingStatusHandler(client));
268
270
  reg('orboto_ai_usage', aiUsageToolConfig, makeAiUsageHandler(client));
269
271
  // ORB-1093 - session-start / post-compact re-orientation digest.
@@ -0,0 +1,41 @@
1
+ /**
2
+ * ORB-2000 - orboto_draft_customer_reply: a customer-facing DRAFT for a
3
+ * ticket, built server-side from customer-safe facts only (ticket key,
4
+ * title, status, due date, customer summary, non-internal comments, the
5
+ * caller's ask) - never from the internal description or internal
6
+ * comments. Returns the draft plus the facts-used list and what was left
7
+ * out. It never posts: the agent shows the draft and posts it only with
8
+ * orboto_comment after the user approved the text.
9
+ */
10
+ import { z } from 'zod';
11
+ import { resolveTicketByKey } from './shared.js';
12
+ export const draftCustomerReplyToolConfig = {
13
+ title: 'Draft a customer-facing reply for a ticket',
14
+ description: 'Draft a reply or status update for a CUSTOMER / external reader about a ticket (by key), built server-side from customer-safe facts only (key, title, status, due date, customer summary, non-internal comments, your `ask`) - never from internal notes. Returns the draft, the facts used and what was excluded. Never posts: show it to the user, then `orboto_comment` only after approval. Use it for every customer-facing text.',
15
+ inputSchema: z.object({
16
+ ticketKey: z.string().describe('Ticket key like "ORB-42".'),
17
+ ask: z.string().min(3).max(1000).describe('What the reply should do, in the user\'s words, e.g. "tell them the fix ships with the next release" or "ask for the browser version".'),
18
+ }).shape,
19
+ outputSchema: z.object({
20
+ draft: z.string(),
21
+ factsUsed: z.array(z.object({ source: z.string(), text: z.string() })),
22
+ excluded: z.object({ internalComments: z.number(), internalDescription: z.boolean() }),
23
+ }).shape,
24
+ annotations: { readOnlyHint: true, idempotentHint: false },
25
+ };
26
+ export function makeDraftCustomerReplyHandler(client) {
27
+ return async ({ ticketKey, ask }) => {
28
+ const ticket = await resolveTicketByKey(client, ticketKey);
29
+ const res = await client.post('/ai/draft-customer-reply', { ticketId: ticket.id, ask });
30
+ const lines = [`Draft for ${ticketKey} (not posted - show it to the user first):`, '', res.draft.trim(), '', 'Facts used:'];
31
+ for (const f of res.factsUsed)
32
+ lines.push(` - ${f.source}: ${f.text.length > 160 ? f.text.slice(0, 160) + '...' : f.text}`);
33
+ if (res.excluded.internalComments > 0 || res.excluded.internalDescription) {
34
+ lines.push('', `Left out on purpose: ${res.excluded.internalComments} internal comment(s)${res.excluded.internalDescription ? ' and the internal description' : ''}.`);
35
+ }
36
+ return {
37
+ content: [{ type: 'text', text: lines.join('\n') }],
38
+ structuredContent: { draft: res.draft, factsUsed: res.factsUsed, excluded: res.excluded },
39
+ };
40
+ };
41
+ }
@@ -0,0 +1,37 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { OrbotoClient } from '../orboto-client.js';
3
+ import { makeDraftCustomerReplyHandler } from './customer-draft.js';
4
+ beforeEach(() => { vi.restoreAllMocks(); });
5
+ afterEach(() => { vi.restoreAllMocks(); });
6
+ function stubJSON(responses) {
7
+ const calls = [];
8
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
9
+ calls.push({ url: url.toString(), method: init?.method ?? 'GET', body: typeof init?.body === 'string' ? JSON.parse(init.body) : init?.body });
10
+ const r = responses.shift();
11
+ if (!r)
12
+ throw new Error('unexpected extra fetch');
13
+ return { ok: r.ok ?? true, status: r.status ?? 200, statusText: 'OK', json: async () => ('json' in r ? r.json : {}), text: async () => '' };
14
+ });
15
+ return calls;
16
+ }
17
+ const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
18
+ describe('orboto_draft_customer_reply (ORB-2000)', () => {
19
+ it('resolves the ticket key, POSTs the ask and renders draft + facts + exclusions', async () => {
20
+ const calls = stubJSON([
21
+ { json: { id: 'p1', key: 'ORB', name: 'orboto' } },
22
+ { json: { id: 't1', ticketKey: 'ORB-42', title: 'Blank page after payment' } },
23
+ { json: {
24
+ draft: 'Thanks for the report - the blank page after payment is confirmed. We will follow up on the timeline.',
25
+ factsUsed: [{ source: 'ticket.title', text: 'Blank page after payment' }, { source: 'user.ask', text: 'confirm it' }],
26
+ excluded: { internalComments: 2, internalDescription: true },
27
+ } },
28
+ ]);
29
+ const res = await makeDraftCustomerReplyHandler(client)({ ticketKey: 'ORB-42', ask: 'confirm it' });
30
+ expect(calls[2]).toMatchObject({ method: 'POST', url: 'https://orboto.example.com/ai/draft-customer-reply', body: { ticketId: 't1', ask: 'confirm it' } });
31
+ const text = res.content[0].text;
32
+ expect(text).toContain('not posted');
33
+ expect(text).toContain('ticket.title');
34
+ expect(text).toContain('2 internal comment(s) and the internal description');
35
+ expect(res.structuredContent).toMatchObject({ excluded: { internalComments: 2, internalDescription: true } });
36
+ });
37
+ });
@@ -53,6 +53,8 @@ export function makeAskDocsHandler(client) {
53
53
  lines.push(` [${c.index}] ${spaceTag}${c.title} ${c.link}`);
54
54
  }
55
55
  }
56
+ if (res.abstained)
57
+ lines.push('', '(abstained: no claim in the answer could be tied to a retrieved doc - treat as "not in the docs", do not guess)');
56
58
  lines.push('', `(retrieval mode: ${res.mode})`);
57
59
  return {
58
60
  content: [{ type: 'text', text: lines.join('\n') }],
@@ -60,6 +62,7 @@ export function makeAskDocsHandler(client) {
60
62
  answer: res.answer,
61
63
  citations: res.citations,
62
64
  mode: res.mode,
65
+ abstained: res.abstained ?? false,
63
66
  },
64
67
  };
65
68
  };
@@ -45,6 +45,7 @@ describe('orboto_ask_docs', () => {
45
45
  { index: 1, title: 'Queue worker', link: '/docs/queue', spaceName: 'Runbooks' },
46
46
  ],
47
47
  mode: 'rag',
48
+ abstained: false,
48
49
  } },
49
50
  ]);
50
51
  const res = await makeAskDocsHandler(client)({
@@ -65,8 +66,15 @@ describe('orboto_ask_docs', () => {
65
66
  expect(res.structuredContent).toMatchObject({
66
67
  answer: 'The retry backoff is 200ms × 2^n.',
67
68
  mode: 'rag',
69
+ abstained: false,
68
70
  });
69
71
  });
72
+ it('ORB-1999 - marks an abstained answer in text and structured content', async () => {
73
+ stubJSON([{ json: { answer: "I couldn't find that in the sources provided.", citations: [], mode: 'empty', abstained: true } }]);
74
+ const res = await makeAskDocsHandler(client)({ question: 'What is the office wifi password?' });
75
+ expect(res.content[0].text).toContain('(abstained:');
76
+ expect(res.structuredContent).toMatchObject({ abstained: true, mode: 'empty' });
77
+ });
70
78
  it('surfaces a 400 (AI not configured) as OrbotoApiError', async () => {
71
79
  stubJSON([
72
80
  { ok: false, status: 400, json: { error: 'ai_provider_not_configured' } },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.186.0",
3
+ "version": "0.187.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",