@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: '
|
|
8
|
-
+ '
|
|
9
|
-
+ '
|
|
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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
?
|
|
35
|
-
:
|
|
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
|
-
? (
|
|
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
|
|
45
|
-
: `\n(${matched.length} project(s)${
|
|
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 () => {
|
package/dist/tools/search.js
CHANGED
|
@@ -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: '
|
|
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(
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|