@orboto/mcp 0.203.0 → 0.204.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.
@@ -4,13 +4,13 @@
4
4
  import { z } from 'zod';
5
5
  export const listProjectsToolConfig = {
6
6
  title: 'List projects',
7
- description: 'Return projects the authenticated user can see (key, name, status). Useful first step of a workflow. '
8
- + 'If you are after one project, pass `query` to filter by key/name instead of pulling the whole list - '
9
- + 'and note you can usually use a project key directly with other tools without listing at all. '
10
- + 'When the result says it is partial, refine with `query` rather than re-calling.',
7
+ description: 'Projects the user can see. To find one, pass `search` (key, name, description, customer) '
8
+ + 'instead of pulling the whole list; a project key usually works directly with other tools. '
9
+ + 'When the result is partial, refine rather than re-call.',
11
10
  inputSchema: z.object({
12
- query: z.string().optional().describe('Substring matched against key or name.'),
13
- limit: z.number().int().min(1).max(200).optional().describe('Max projects. Default 50.'),
11
+ search: z.string().optional().describe('Key, name, description, customer.'),
12
+ query: z.string().optional().describe('Key/name substring.'),
13
+ limit: z.number().int().min(1).max(200).optional().describe('Default 50.'),
14
14
  }).shape,
15
15
  outputSchema: z.object({
16
16
  projects: z.array(z.object({
@@ -27,22 +27,28 @@ export const listProjectsToolConfig = {
27
27
  annotations: { readOnlyHint: true, idempotentHint: true },
28
28
  };
29
29
  export function makeListProjectsHandler(client) {
30
- return async ({ query, limit } = {}) => {
31
- const projects = await client.get('/projects');
30
+ return async ({ search, query, limit } = {}) => {
31
+ const s = (search ?? '').trim();
32
+ const [projects, found] = await Promise.all([
33
+ client.get('/projects'),
34
+ s ? client.get(`/projects?search=${encodeURIComponent(s)}`) : Promise.resolve(null),
35
+ ]);
32
36
  const q = (query ?? '').trim().toLowerCase();
37
+ const base = found ?? projects;
33
38
  const matched = q
34
- ? projects.filter((p) => `${p.key} ${p.name}`.toLowerCase().includes(q))
35
- : projects;
39
+ ? base.filter((p) => `${p.key} ${p.name}`.toLowerCase().includes(q))
40
+ : base;
41
+ const term = [s, q].filter(Boolean).map((v) => `"${v}"`).join(' and ');
36
42
  const cap = Math.min(limit ?? 50, 200);
37
43
  const shown = matched.slice(0, cap);
38
44
  const rows = shown.map((p) => ({ id: p.id, key: p.key, name: p.name, status: p.status, description: p.description }));
39
45
  const lines = rows.map((r) => `- ${r.key} - ${r.name} (${r.status})`);
40
46
  const partial = shown.length < matched.length;
41
47
  const footer = matched.length === 0
42
- ? (q ? `No projects match "${q}".` : 'No projects visible to this user.')
48
+ ? (term ? `No projects match ${term}.` : 'No projects visible to this user.')
43
49
  : partial
44
- ? `\nShowing first ${shown.length} of ${matched.length} match(es) (of ${projects.length} total) - pass a narrower query to filter.`
45
- : `\n(${matched.length} project(s)${q ? ` matching "${q}"` : ''}, complete.)`;
50
+ ? `\nShowing first ${shown.length} of ${matched.length} match(es) (of ${projects.length} total) - pass a narrower search to filter.`
51
+ : `\n(${matched.length} project(s)${term ? ` matching ${term}` : ''}, complete.)`;
46
52
  const text = matched.length === 0 ? footer : lines.join('\n') + footer;
47
53
  return {
48
54
  content: [{ type: 'text', text }],
@@ -57,6 +57,27 @@ describe('tools/list-projects', () => {
57
57
  expect(text).toContain('Showing first 2 of 5 match(es)');
58
58
  expect(result.structuredContent).toMatchObject({ total: 5, totalProjects: 5 });
59
59
  });
60
+ it('ORB-2203: search asks the API with ?search= and still counts every visible project', async () => {
61
+ const all = [
62
+ { id: 'p1', key: 'ACME', name: 'Acme', description: 'Customer portal', status: 'active' },
63
+ { id: 'p2', key: 'TOOL', name: 'Internal Tools', description: null, status: 'draft' },
64
+ ];
65
+ const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation(async (input) => {
66
+ const url = String(input);
67
+ const body = url.includes('search=') ? [all[0]] : all;
68
+ return { ok: true, status: 200, statusText: 'OK', json: async () => body };
69
+ });
70
+ const result = await makeListProjectsHandler(client)({ search: ' portal ' });
71
+ const urls = fetchSpy.mock.calls.map(([input]) => String(input));
72
+ expect(urls.some((u) => u.endsWith('/projects?search=portal'))).toBe(true);
73
+ expect(result.content[0]).toEqual({ type: 'text', text: '- ACME - Acme (active)\n(1 project(s) matching "portal", complete.)' });
74
+ expect(result.structuredContent).toMatchObject({ total: 1, totalProjects: 2, query: null });
75
+ });
76
+ it('ORB-2203: without search only the plain list is fetched', async () => {
77
+ const fetchSpy = mockFetch([]);
78
+ await makeListProjectsHandler(client)({ query: 'x' });
79
+ expect(fetchSpy.mock.calls.map(([input]) => String(input)).every((u) => !u.includes('search='))).toBe(true);
80
+ });
60
81
  it('renders an empty-state text block when the API returns no rows', async () => {
61
82
  mockFetch([]);
62
83
  const result = await makeListProjectsHandler(client)();
@@ -490,6 +490,27 @@ describe('orboto_search', () => {
490
490
  expect(sc.total).toBe(42);
491
491
  expect(sc.hasMore).toBe(true);
492
492
  });
493
+ it('ORB-2201: passes the jump types and renders project and milestone hits by key', async () => {
494
+ const calls = stub([
495
+ {
496
+ json: {
497
+ items: [
498
+ { type: 'project', id: 'p1', key: 'ACME', projectKey: 'ACME', title: 'Acme', excerpt: '', projectId: 'p1', projectName: 'Acme', spaceId: null, spaceName: null, url: '/projects/acme', rank: 2000 },
499
+ { type: 'milestone', id: 'm1', key: 'ACME-M1', projectKey: 'ACME', title: 'Launch', excerpt: '', projectId: 'p1', projectName: 'Acme', spaceId: null, spaceName: null, url: '/projects/acme?milestoneIds=m1', rank: 200 },
500
+ ],
501
+ nextCursor: null,
502
+ total: 2,
503
+ },
504
+ },
505
+ ]);
506
+ const res = await makeSearchHandler(client)({ query: 'acme', types: ['project', 'milestone'] });
507
+ expect(calls[0]).toContain('types=project%2Cmilestone');
508
+ const text = res.content[0].text;
509
+ expect(text).toContain('- [PROJECT ACME] Acme');
510
+ expect(text).toContain('- [MILESTONE ACME-M1 · Acme] Launch');
511
+ const sc = res.structuredContent;
512
+ expect(sc.hits.map((h) => [h.key, h.projectKey])).toEqual([['ACME', 'ACME'], ['ACME-M1', 'ACME']]);
513
+ });
493
514
  });
494
515
  describe('doc tools', () => {
495
516
  it('list: uses DocSpace.type to label scope, not a joined projectName', async () => {
@@ -5,13 +5,13 @@
5
5
  */
6
6
  import { z } from 'zod';
7
7
  import { resolveProjectByKey } from './shared.js';
8
+ const SEARCH_TYPES = ['ticket', 'comment', 'doc', 'project', 'milestone', 'space', 'customer'];
8
9
  export const searchToolConfig = {
9
10
  title: 'Search across orboto',
10
- description: 'Full-text search across tickets, comments, and docs. Honours the caller\'s visibility - private tickets and internal comments never appear unless the caller can already see them. Recall (ORB-1695): when the strict all-terms pass finds nothing, the server automatically retries with an OR-relaxed pass plus semantic (embedding) recall - the response\'s `pass` field says which pass produced the hits, and relaxed-pass hits deserve a skeptical read (they matched SOME terms or only the meaning, not all terms). Still prefer a single distinctive STABLE token (file/component/error-string fragment like "AdminCodesPage") and search the SYMPTOM, not your intended fix.',
11
+ description: 'Search tickets, comments and docs; `types` adds projects, milestones, spaces, customers. Content matches full text, jump types match key and name; `types` defaults to ticket, comment, doc. Honours the caller\'s visibility - private tickets and internal comments never appear unless the caller can already see them. Recall (ORB-1695): when the strict all-terms pass finds nothing, the server automatically retries with an OR-relaxed pass plus semantic (embedding) recall - the response\'s `pass` field says which pass produced the hits, and relaxed-pass hits deserve a skeptical read (they matched SOME terms or only the meaning, not all terms). Still prefer a single distinctive STABLE token (file/component/error-string fragment like "AdminCodesPage") and search the SYMPTOM, not your intended fix.',
11
12
  inputSchema: z.object({
12
13
  query: z.string().min(1).describe('Search terms, e.g. "queue worker retry".'),
13
- types: z.array(z.enum(['ticket', 'comment', 'doc'])).optional()
14
- .describe('Restrict to entity types. Omit for all.'),
14
+ types: z.array(z.enum(SEARCH_TYPES)).optional(),
15
15
  projectKey: z.string().optional().describe('Restrict to one project.'),
16
16
  limit: z.number().int().min(1).max(50).default(15),
17
17
  }).shape,
@@ -39,9 +39,10 @@ export function makeSearchHandler(client) {
39
39
  ? `No hits for "${input.query}".`
40
40
  : `Hits${headerHint}${relaxedNote}:\n\n` + res.items.map((h) => {
41
41
  const tag = h.type.toUpperCase();
42
- const ident = h.ticketKey ?? h.id;
43
- const project = h.projectName ? ` · ${h.projectName}` : '';
44
- return `- [${tag} ${ident}${project}] ${h.title}\n ${h.excerpt}`;
42
+ const ident = h.ticketKey ?? h.key ?? h.id;
43
+ const project = h.projectName && h.type !== 'project' ? ` · ${h.projectName}` : '';
44
+ const excerpt = h.excerpt ? `\n ${h.excerpt}` : '';
45
+ return `- [${tag} ${ident}${project}] ${h.title}${excerpt}`;
45
46
  }).join('\n\n');
46
47
  return {
47
48
  content: [{ type: 'text', text }],
@@ -56,6 +57,8 @@ export function makeSearchHandler(client) {
56
57
  title: h.title,
57
58
  excerpt: h.excerpt,
58
59
  ticketKey: h.ticketKey ?? null,
60
+ key: h.key ?? h.ticketKey ?? null,
61
+ projectKey: h.projectKey ?? null,
59
62
  projectName: h.projectName,
60
63
  spaceName: h.spaceName,
61
64
  url: h.url,
@@ -186,7 +186,7 @@ function preflightResult(preflight, projectKey) {
186
186
  preflight.ok
187
187
  ? `✓ Pre-flight clean for ${projectKey} - re-send the same arguments without dryRun to create the ticket.`
188
188
  : `⛔ Pre-flight BLOCKED for ${projectKey} (${preflight.blockedBy.join(', ')}) - nothing was created. Fix the payload and run the pre-flight again.`,
189
- ` ${icon[preflight.verdicts.language.severity]} language: ${preflight.verdicts.language.message}`,
189
+ ` ${icon[preflight.verdicts.language.severity]} language: ${preflight.verdicts.language.message}${preflight.verdicts.language.hint ? ` Keep it only on purpose: ${preflight.verdicts.language.hint.query}.` : ''}`,
190
190
  ` ${icon[preflight.verdicts.type.severity]} type: ${preflight.verdicts.type.message}`,
191
191
  ` ${icon[preflight.verdicts.labels.severity]} labels: ${preflight.verdicts.labels.message}`,
192
192
  ` ${icon[preflight.verdicts.duplicates.severity]} duplicates: ${preflight.verdicts.duplicates.message}`,
@@ -257,12 +257,13 @@ function languageBlockResult(err, verb) {
257
257
  if (!parsed.languageWarning)
258
258
  return null;
259
259
  const lw = parsed.languageWarning;
260
+ const retry = `retry the same call with ${parsed.hint?.query ?? 'allowLanguageMismatch=true'}`;
260
261
  const text = `⛔ ${verb} blocked - strict ticket-language enforcement is on.\n` +
261
262
  `This content reads as "${lw.detected}" but the workspace language is "${lw.expected}".\n` +
262
- `Rewrite it in ${lw.expected.toUpperCase()}, or - only if you are sure the language is intentional - retry the same call with allowLanguageMismatch=true.`;
263
+ `Rewrite it in ${lw.expected.toUpperCase()}, or - only if you are sure the language is intentional - ${retry}.`;
263
264
  return {
264
265
  content: [{ type: 'text', text }],
265
- structuredContent: { blocked: true, languageWarning: lw },
266
+ structuredContent: { blocked: true, languageWarning: lw, ...(parsed.hint ? { hint: parsed.hint } : {}) },
266
267
  isError: true,
267
268
  };
268
269
  }
@@ -228,6 +228,25 @@ describe('orboto_create_ticket', () => {
228
228
  expect(sc.blocked).toBe(true);
229
229
  expect(sc.languageWarning.severity).toBe('block');
230
230
  });
231
+ it('reads the way out from the hint, never from the sentence written for a person (ORB-2200)', async () => {
232
+ const blockBody = JSON.stringify({
233
+ error: 'This ticket looks like it is written in a different language (de) than this project expects (en). If the language is intentional, you can keep the ticket as it is.',
234
+ errorKey: 'errors.tickets.language_mismatch',
235
+ languageWarning: { code: 'language_mismatch', severity: 'block', detected: 'de', expected: 'en' },
236
+ hint: { query: 'allowLanguageMismatch=true' },
237
+ });
238
+ vi.spyOn(globalThis, 'fetch').mockImplementation(async (_url, init) => {
239
+ if ((init?.method ?? 'GET') === 'GET') {
240
+ return { ok: true, status: 200, statusText: 'OK', json: async () => PROJ, text: async () => '' };
241
+ }
242
+ return { ok: false, status: 422, statusText: 'Unprocessable', json: async () => ({}), text: async () => blockBody };
243
+ });
244
+ const res = await makeCreateTicketHandler(client)({ projectKey: 'ACME', title: 'Authentifizierung schlägt fehl bei externen Nutzern' });
245
+ const text = res.content[0].text;
246
+ expect(text).toContain('retry the same call with allowLanguageMismatch=true');
247
+ expect(text).not.toContain('keep the ticket as it is');
248
+ expect(res.structuredContent.hint).toEqual({ query: 'allowLanguageMismatch=true' });
249
+ });
231
250
  it('passes allowLanguageMismatch as a query param when the override is set (ORB-990)', async () => {
232
251
  let createUrl = '';
233
252
  vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orboto/mcp",
3
- "version": "0.203.0",
3
+ "version": "0.204.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",