@orboto/mcp 0.178.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.
- package/LICENSE.md +21 -0
- package/README.md +123 -0
- package/dist/event-bridge.js +250 -0
- package/dist/event-bridge.test.js +165 -0
- package/dist/http-transport-binding.test.js +151 -0
- package/dist/http-transport.js +608 -0
- package/dist/http-transport.test.js +408 -0
- package/dist/index.js +128 -0
- package/dist/input-schema.js +293 -0
- package/dist/input-schema.test.js +198 -0
- package/dist/instructions-budget.test.js +34 -0
- package/dist/manifest-size.test.js +192 -0
- package/dist/mcp-instrument.js +24 -0
- package/dist/oauth-bootstrap.js +413 -0
- package/dist/oauth-bootstrap.test.js +212 -0
- package/dist/orboto-client.js +250 -0
- package/dist/orboto-client.test.js +141 -0
- package/dist/prompts.js +219 -0
- package/dist/prompts.test.js +79 -0
- package/dist/protect-text-usage.test.js +38 -0
- package/dist/resources.js +260 -0
- package/dist/resources.test.js +191 -0
- package/dist/response-budget.js +515 -0
- package/dist/response-budget.test.js +343 -0
- package/dist/server.js +547 -0
- package/dist/session-nudge.js +81 -0
- package/dist/session-nudge.test.js +100 -0
- package/dist/session-nudge.transport.test.js +100 -0
- package/dist/tool-annotations.test.js +97 -0
- package/dist/tool-docs.js +64 -0
- package/dist/tool-docs.test.js +90 -0
- package/dist/tool-parameters.test.js +145 -0
- package/dist/tools/absence-writes.js +93 -0
- package/dist/tools/absence-writes.test.js +106 -0
- package/dist/tools/admin-translations.js +83 -0
- package/dist/tools/admin-writes.js +174 -0
- package/dist/tools/admin-writes.test.js +120 -0
- package/dist/tools/agent-coordination.js +160 -0
- package/dist/tools/agent-coordination.test.js +156 -0
- package/dist/tools/agent-drift.js +79 -0
- package/dist/tools/agent-drift.test.js +87 -0
- package/dist/tools/agent-instructions.js +172 -0
- package/dist/tools/agent-instructions.test.js +124 -0
- package/dist/tools/agent-messages.js +75 -0
- package/dist/tools/ai-status.js +55 -0
- package/dist/tools/ai-status.test.js +43 -0
- package/dist/tools/ai-usage.js +68 -0
- package/dist/tools/analytics.js +72 -0
- package/dist/tools/analytics.test.js +71 -0
- package/dist/tools/api-call.js +72 -0
- package/dist/tools/api-call.test.js +104 -0
- package/dist/tools/api-search.js +77 -0
- package/dist/tools/api-search.test.js +90 -0
- package/dist/tools/approvals.js +68 -0
- package/dist/tools/approvals.test.js +48 -0
- package/dist/tools/attach.js +108 -0
- package/dist/tools/attach.test.js +117 -0
- package/dist/tools/backup.js +93 -0
- package/dist/tools/backup.test.js +108 -0
- package/dist/tools/bulk-create.js +213 -0
- package/dist/tools/bulk-create.test.js +135 -0
- package/dist/tools/bulk-writes.js +339 -0
- package/dist/tools/bulk-writes.test.js +239 -0
- package/dist/tools/check-similar.js +84 -0
- package/dist/tools/check-similar.test.js +101 -0
- package/dist/tools/checklist-writes.js +202 -0
- package/dist/tools/checklist-writes.test.js +184 -0
- package/dist/tools/claim.js +236 -0
- package/dist/tools/claim.test.js +207 -0
- package/dist/tools/critical-path.js +83 -0
- package/dist/tools/critical-path.test.js +114 -0
- package/dist/tools/cross-project-links.ee.js +124 -0
- package/dist/tools/cross-project-links.ee.test.js +163 -0
- package/dist/tools/customer-report.js +88 -0
- package/dist/tools/customer-report.test.js +55 -0
- package/dist/tools/doc-attachments.js +184 -0
- package/dist/tools/doc-attachments.test.js +136 -0
- package/dist/tools/doc-comments.js +228 -0
- package/dist/tools/doc-comments.test.js +192 -0
- package/dist/tools/doc-edits.js +229 -0
- package/dist/tools/doc-edits.test.js +218 -0
- package/dist/tools/doc-export.js +87 -0
- package/dist/tools/doc-export.test.js +109 -0
- package/dist/tools/doc-revisions.js +133 -0
- package/dist/tools/doc-revisions.test.js +103 -0
- package/dist/tools/docs-ai.js +171 -0
- package/dist/tools/docs-ai.test.js +160 -0
- package/dist/tools/docs.js +557 -0
- package/dist/tools/docs.test.js +365 -0
- package/dist/tools/embedding-status.js +100 -0
- package/dist/tools/embedding-status.test.js +86 -0
- package/dist/tools/free-busy.js +49 -0
- package/dist/tools/free-busy.test.js +46 -0
- package/dist/tools/get-checklists.js +72 -0
- package/dist/tools/get-project-primer.js +58 -0
- package/dist/tools/get-project.js +77 -0
- package/dist/tools/get-ticket.js +321 -0
- package/dist/tools/get-timer.js +62 -0
- package/dist/tools/help.js +44 -0
- package/dist/tools/identity.js +60 -0
- package/dist/tools/identity.test.js +67 -0
- package/dist/tools/list-git-app-installations.js +45 -0
- package/dist/tools/list-projects.js +72 -0
- package/dist/tools/list-projects.test.js +67 -0
- package/dist/tools/list-tickets.js +86 -0
- package/dist/tools/milestones-crud.test.js +154 -0
- package/dist/tools/milestones.js +279 -0
- package/dist/tools/milestones.test.js +60 -0
- package/dist/tools/my-tickets.js +65 -0
- package/dist/tools/personal-facts.js +72 -0
- package/dist/tools/personal-facts.test.js +45 -0
- package/dist/tools/phase-b-tools.test.js +530 -0
- package/dist/tools/portfolio.js +56 -0
- package/dist/tools/primer-facts.js +346 -0
- package/dist/tools/primer-facts.test.js +353 -0
- package/dist/tools/project-listings.js +116 -0
- package/dist/tools/project-listings.test.js +106 -0
- package/dist/tools/query.js +65 -0
- package/dist/tools/query.test.js +128 -0
- package/dist/tools/raci.js +108 -0
- package/dist/tools/raci.test.js +70 -0
- package/dist/tools/requirements-spec.js +89 -0
- package/dist/tools/requirements-spec.test.js +62 -0
- package/dist/tools/response-expand.js +104 -0
- package/dist/tools/review-policy.js +105 -0
- package/dist/tools/review-policy.test.js +71 -0
- package/dist/tools/search.js +78 -0
- package/dist/tools/session-start.js +435 -0
- package/dist/tools/session-start.test.js +568 -0
- package/dist/tools/set-parent.js +76 -0
- package/dist/tools/set-parent.test.js +115 -0
- package/dist/tools/shared.js +226 -0
- package/dist/tools/shared.test.js +169 -0
- package/dist/tools/similar-projection.js +30 -0
- package/dist/tools/ticket-attachments.js +131 -0
- package/dist/tools/ticket-attachments.test.js +106 -0
- package/dist/tools/ticket-schedules.js +112 -0
- package/dist/tools/ticket-schedules.test.js +102 -0
- package/dist/tools/ticket-writes.js +778 -0
- package/dist/tools/ticket-writes.test.js +643 -0
- package/dist/tools/time-writes.js +238 -0
- package/dist/tools/time-writes.test.js +138 -0
- package/dist/tools/update-project.js +165 -0
- package/dist/tools/update-project.test.js +172 -0
- package/dist/tools/wiki.js +185 -0
- package/dist/tools/wiki.test.js +104 -0
- package/dist/tools/work-sessions-next.test.js +150 -0
- package/dist/tools/work-sessions-start.test.js +174 -0
- package/dist/tools/work-sessions.js +709 -0
- package/dist/tools/work-sessions.test.js +358 -0
- package/dist/toolset.js +149 -0
- package/dist/toolset.test.js +150 -0
- package/dist/version-guard.test.js +51 -0
- package/dist/version.js +28 -0
- package/dist/with-metrics-mail-nudge.test.js +41 -0
- package/dist/with-metrics-output-schema.test.js +91 -0
- package/dist/with-metrics.js +211 -0
- package/dist/with-metrics.test.js +230 -0
- package/package.json +66 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-799 - identity / debug tools.
|
|
3
|
+
*
|
|
4
|
+
* `orboto_whoami` - return the authenticated principal as `{id, email,
|
|
5
|
+
* fullName, isBot}`. Mirrors `orboto.mjs whoami`. The wrapper hits the
|
|
6
|
+
* existing `/users/me` route directly; we do the same so a stale-token
|
|
7
|
+
* 401 surfaces the same way it does for every other tool.
|
|
8
|
+
*
|
|
9
|
+
* Useful for debugging "which API key is this session actually using?",
|
|
10
|
+
* which is the most common confusion when an agent has multiple MCP
|
|
11
|
+
* configs (`claude-desktop`, `cursor`, `gemini-cli`) wired to different
|
|
12
|
+
* `orb_*` keys.
|
|
13
|
+
*/
|
|
14
|
+
import { z } from 'zod';
|
|
15
|
+
export const whoamiToolConfig = {
|
|
16
|
+
title: 'Show the authenticated MCP principal',
|
|
17
|
+
description: 'Return the authenticated user record `{id, email, fullName, isBot, workspaceLocale}` corresponding to the API key this MCP server is running with. Useful for debugging which credential / bot identity is in use when multiple MCP configs are wired to different `orb_*` keys. **`workspaceLocale` (ORB-989)** is the language the workspace expects tickets / comments / docs in - check it before a mass-create so you write in the right language from the start instead of relying on the after-the-fact language-mismatch warning.',
|
|
18
|
+
inputSchema: z.object({}).shape,
|
|
19
|
+
outputSchema: z.object({
|
|
20
|
+
id: z.string(),
|
|
21
|
+
email: z.string(),
|
|
22
|
+
fullName: z.string().nullable(),
|
|
23
|
+
isBot: z.boolean(),
|
|
24
|
+
workspaceLocale: z.string().nullable(),
|
|
25
|
+
// ORB-1671 - both identities of a delegated act-as connection.
|
|
26
|
+
actingAuthorizedBy: z.object({ id: z.string(), email: z.string(), fullName: z.string() }).nullable(),
|
|
27
|
+
}).shape,
|
|
28
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
29
|
+
};
|
|
30
|
+
export function makeWhoamiHandler(client) {
|
|
31
|
+
return async () => {
|
|
32
|
+
const me = await client.get('/users/me');
|
|
33
|
+
const isBot = me.isBot === true;
|
|
34
|
+
const workspaceLocale = me.workspaceLocale ?? null;
|
|
35
|
+
const lines = [
|
|
36
|
+
`${me.fullName ?? '(no name)'} <${me.email}>`,
|
|
37
|
+
` id: ${me.id}`,
|
|
38
|
+
` bot: ${isBot ? 'yes' : 'no'}`,
|
|
39
|
+
];
|
|
40
|
+
if (workspaceLocale) {
|
|
41
|
+
lines.push(` workspace language: ${workspaceLocale} (write tickets in this language)`);
|
|
42
|
+
}
|
|
43
|
+
// ORB-1671 - an act-as connection reports BOTH identities so the agent
|
|
44
|
+
// (and a human debugging it) can see who it acts as and who authorised it.
|
|
45
|
+
if (me.actingAuthorizedBy) {
|
|
46
|
+
lines.push(` acting as this bot, authorised by: ${me.actingAuthorizedBy.fullName} <${me.actingAuthorizedBy.email}>`);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
50
|
+
structuredContent: {
|
|
51
|
+
id: me.id,
|
|
52
|
+
email: me.email,
|
|
53
|
+
fullName: me.fullName ?? null,
|
|
54
|
+
isBot,
|
|
55
|
+
workspaceLocale,
|
|
56
|
+
actingAuthorizedBy: me.actingAuthorizedBy ?? null,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-799 - `orboto_whoami` unit tests.
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
|
|
6
|
+
import { makeWhoamiHandler } from './identity.js';
|
|
7
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
8
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
9
|
+
function stub(responses) {
|
|
10
|
+
const calls = [];
|
|
11
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
12
|
+
calls.push({ url: url.toString(), method: init?.method ?? 'GET' });
|
|
13
|
+
const r = responses.shift();
|
|
14
|
+
if (!r)
|
|
15
|
+
throw new Error(`unexpected extra fetch`);
|
|
16
|
+
return {
|
|
17
|
+
ok: r.ok ?? true,
|
|
18
|
+
status: r.status ?? 200,
|
|
19
|
+
statusText: 'OK',
|
|
20
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
21
|
+
text: async () => '',
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
return calls;
|
|
25
|
+
}
|
|
26
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
27
|
+
describe('orboto_whoami', () => {
|
|
28
|
+
it('returns the authenticated user shape from /users/me', async () => {
|
|
29
|
+
const calls = stub([
|
|
30
|
+
{ json: { id: 'u1', email: 'agent-e@orboto.io', fullName: 'Claude Agent E', isBot: true, workspaceLocale: 'en' } },
|
|
31
|
+
]);
|
|
32
|
+
const res = await makeWhoamiHandler(client)();
|
|
33
|
+
expect(calls[0].url).toBe('https://orboto.example.com/users/me');
|
|
34
|
+
expect(calls[0].method).toBe('GET');
|
|
35
|
+
expect(res.structuredContent).toEqual({
|
|
36
|
+
id: 'u1',
|
|
37
|
+
email: 'agent-e@orboto.io',
|
|
38
|
+
fullName: 'Claude Agent E',
|
|
39
|
+
isBot: true,
|
|
40
|
+
workspaceLocale: 'en',
|
|
41
|
+
actingAuthorizedBy: null,
|
|
42
|
+
});
|
|
43
|
+
expect(res.content[0].text).toContain('agent-e@orboto.io');
|
|
44
|
+
});
|
|
45
|
+
it('surfaces workspaceLocale in the text output when present (ORB-989)', async () => {
|
|
46
|
+
stub([{ json: { id: 'u4', email: 'agent@orboto.io', fullName: 'Agent', isBot: true, workspaceLocale: 'de' } }]);
|
|
47
|
+
const res = await makeWhoamiHandler(client)();
|
|
48
|
+
expect(res.content[0].text).toContain('workspace language: de');
|
|
49
|
+
expect(res.structuredContent).toMatchObject({ workspaceLocale: 'de' });
|
|
50
|
+
});
|
|
51
|
+
it('handles missing fullName + missing isBot flag (treats as not-a-bot)', async () => {
|
|
52
|
+
stub([{ json: { id: 'u2', email: 'human@orboto.io' } }]);
|
|
53
|
+
const res = await makeWhoamiHandler(client)();
|
|
54
|
+
expect(res.structuredContent).toMatchObject({
|
|
55
|
+
id: 'u2',
|
|
56
|
+
email: 'human@orboto.io',
|
|
57
|
+
fullName: null,
|
|
58
|
+
isBot: false,
|
|
59
|
+
workspaceLocale: null,
|
|
60
|
+
actingAuthorizedBy: null,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
it('surfaces a 401 from the API as OrbotoApiError', async () => {
|
|
64
|
+
stub([{ ok: false, status: 401, json: { error: 'invalid token' } }]);
|
|
65
|
+
await expect(makeWhoamiHandler(client)()).rejects.toBeInstanceOf(OrbotoApiError);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-320 - `orboto_list_git_app_installations`.
|
|
3
|
+
*
|
|
4
|
+
* Returns every GitHub App installation orboto knows about. Wraps
|
|
5
|
+
* `GET /admin/git-app-installations` (super-admin only on the API
|
|
6
|
+
* side; a 403 here means the caller's API key isn't super-admin).
|
|
7
|
+
*
|
|
8
|
+
* Useful for agents triaging "is my repo connected?" questions
|
|
9
|
+
* without paging through Project Settings → Git in every project.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
export const listGitAppInstallationsToolConfig = {
|
|
13
|
+
title: 'List GitHub App installations',
|
|
14
|
+
description: 'Return every GitHub App installation orboto knows about (across all projects). Requires super-admin on the API side. Each row carries the org/user the App is installed on, when it was installed, and whether it is currently suspended.',
|
|
15
|
+
inputSchema: z.object({}).shape,
|
|
16
|
+
outputSchema: z.object({
|
|
17
|
+
installations: z.array(z.object({
|
|
18
|
+
provider: z.string(),
|
|
19
|
+
accountLogin: z.string(),
|
|
20
|
+
accountType: z.string(),
|
|
21
|
+
installedAt: z.string(),
|
|
22
|
+
suspended: z.boolean(),
|
|
23
|
+
})),
|
|
24
|
+
}).shape,
|
|
25
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
26
|
+
};
|
|
27
|
+
export function makeListGitAppInstallationsHandler(client) {
|
|
28
|
+
return async () => {
|
|
29
|
+
const rows = await client.get('/admin/git-app-installations');
|
|
30
|
+
const out = rows.map((r) => ({
|
|
31
|
+
provider: r.provider,
|
|
32
|
+
accountLogin: r.accountLogin,
|
|
33
|
+
accountType: r.accountType,
|
|
34
|
+
installedAt: r.installedAt,
|
|
35
|
+
suspended: r.suspendedAt !== null,
|
|
36
|
+
}));
|
|
37
|
+
const text = out.length === 0
|
|
38
|
+
? 'No GitHub App installations registered.'
|
|
39
|
+
: out.map((r) => `- ${r.accountLogin} (${r.accountType}) - installed ${r.installedAt}${r.suspended ? ' - SUSPENDED' : ''}`).join('\n');
|
|
40
|
+
return {
|
|
41
|
+
content: [{ type: 'text', text }],
|
|
42
|
+
structuredContent: { installations: out },
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-244 Phase A reference tool - `orboto_list_projects`.
|
|
3
|
+
*
|
|
4
|
+
* Maps to `GET /projects` in the orboto API, which already filters by
|
|
5
|
+
* the caller's visibility via the PBAC cascade. The MCP server is a
|
|
6
|
+
* transport adapter - it doesn't re-implement the ACL.
|
|
7
|
+
*
|
|
8
|
+
* Rest of the read-tool suite follows the same shape (see
|
|
9
|
+
* ticket ORB-244 Phase B). Keeping this first tool narrow on
|
|
10
|
+
* purpose - it's the one we wire + verify end-to-end before
|
|
11
|
+
* scaling out.
|
|
12
|
+
*/
|
|
13
|
+
import { z } from 'zod';
|
|
14
|
+
export const listProjectsToolConfig = {
|
|
15
|
+
title: 'List projects',
|
|
16
|
+
description: 'Return projects the authenticated user can see (key, name, status). Useful first step of a workflow. '
|
|
17
|
+
+ 'If you are after one project, pass `query` to filter by key/name instead of pulling the whole list - '
|
|
18
|
+
+ 'and note you can usually use a project key directly with other tools without listing at all. '
|
|
19
|
+
+ 'When the result says it is partial, refine with `query` rather than re-calling.',
|
|
20
|
+
// ORB-1109 - query + limit so a small-context agent (self-hosted /
|
|
21
|
+
// local 32k models over MCP) can narrow instead of ingesting every
|
|
22
|
+
// project.
|
|
23
|
+
inputSchema: z.object({
|
|
24
|
+
query: z.string().optional().describe('Substring matched against key or name.'),
|
|
25
|
+
limit: z.number().int().min(1).max(200).optional().describe('Max projects. Default 50.'),
|
|
26
|
+
}).shape,
|
|
27
|
+
// Well-formed output schema so MCP clients that honour it can
|
|
28
|
+
// surface structured data. Adding fields is safe; removing them is a
|
|
29
|
+
// breaking change for pinned client configs.
|
|
30
|
+
outputSchema: z.object({
|
|
31
|
+
projects: z.array(z.object({
|
|
32
|
+
// ORB-1179 - uuid alongside the key for downstream write tools.
|
|
33
|
+
id: z.string(),
|
|
34
|
+
key: z.string(),
|
|
35
|
+
name: z.string(),
|
|
36
|
+
status: z.string(),
|
|
37
|
+
description: z.string().nullable(),
|
|
38
|
+
})),
|
|
39
|
+
total: z.number(),
|
|
40
|
+
totalProjects: z.number(),
|
|
41
|
+
query: z.string().nullable(),
|
|
42
|
+
}).shape,
|
|
43
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
44
|
+
};
|
|
45
|
+
export function makeListProjectsHandler(client) {
|
|
46
|
+
return async ({ query, limit } = {}) => {
|
|
47
|
+
const projects = await client.get('/projects');
|
|
48
|
+
const q = (query ?? '').trim().toLowerCase();
|
|
49
|
+
const matched = q
|
|
50
|
+
? projects.filter((p) => `${p.key} ${p.name}`.toLowerCase().includes(q))
|
|
51
|
+
: projects;
|
|
52
|
+
const cap = Math.min(limit ?? 50, 200);
|
|
53
|
+
const shown = matched.slice(0, cap);
|
|
54
|
+
const rows = shown.map((p) => ({ id: p.id, key: p.key, name: p.name, status: p.status, description: p.description }));
|
|
55
|
+
// Text content is what the model reads; structured content is what
|
|
56
|
+
// the client UI renders. The text stays description-free (compact)
|
|
57
|
+
// and ends with a count line so a small model knows whether it has
|
|
58
|
+
// the whole set.
|
|
59
|
+
const lines = rows.map((r) => `- ${r.key} - ${r.name} (${r.status})`);
|
|
60
|
+
const partial = shown.length < matched.length;
|
|
61
|
+
const footer = matched.length === 0
|
|
62
|
+
? (q ? `No projects match "${q}".` : 'No projects visible to this user.')
|
|
63
|
+
: partial
|
|
64
|
+
? `\nShowing first ${shown.length} of ${matched.length} match(es) (of ${projects.length} total) - pass a narrower query to filter.`
|
|
65
|
+
: `\n(${matched.length} project(s)${q ? ` matching "${q}"` : ''}, complete.)`;
|
|
66
|
+
const text = matched.length === 0 ? footer : lines.join('\n') + footer;
|
|
67
|
+
return {
|
|
68
|
+
content: [{ type: 'text', text }],
|
|
69
|
+
structuredContent: { projects: rows, total: matched.length, totalProjects: projects.length, query: q || null },
|
|
70
|
+
};
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-244 Phase A - reference test for `orboto_list_projects` tool
|
|
3
|
+
* mapping. Confirms that the tool handler:
|
|
4
|
+
* - calls `GET /projects`
|
|
5
|
+
* - produces both a text block (for the model) and structured content
|
|
6
|
+
* - trims the REST response to the tool-schema shape
|
|
7
|
+
*
|
|
8
|
+
* Each future read-tool (Phase B) should follow this test shape.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import { OrbotoClient } from '../orboto-client.js';
|
|
12
|
+
import { makeListProjectsHandler } from './list-projects.js';
|
|
13
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
14
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
15
|
+
function mockFetch(json) {
|
|
16
|
+
return vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
17
|
+
ok: true, status: 200, statusText: 'OK',
|
|
18
|
+
json: async () => json,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
describe('tools/list-projects', () => {
|
|
22
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_test' });
|
|
23
|
+
it('emits a human-readable text block + structured content', async () => {
|
|
24
|
+
mockFetch([
|
|
25
|
+
{ id: 'p1', key: 'ACME', name: 'Acme', description: 'Customer portal', status: 'active' },
|
|
26
|
+
{ id: 'p2', key: 'TOOL', name: 'Internal Tools', description: null, status: 'draft' },
|
|
27
|
+
]);
|
|
28
|
+
const result = await makeListProjectsHandler(client)();
|
|
29
|
+
expect(result.content[0]).toEqual({
|
|
30
|
+
type: 'text',
|
|
31
|
+
text: '- ACME - Acme (active)\n- TOOL - Internal Tools (draft)\n(2 project(s), complete.)',
|
|
32
|
+
});
|
|
33
|
+
expect(result.structuredContent).toEqual({
|
|
34
|
+
projects: [
|
|
35
|
+
// ORB-1179 - uuid surfaced alongside the key
|
|
36
|
+
{ id: 'p1', key: 'ACME', name: 'Acme', status: 'active', description: 'Customer portal' },
|
|
37
|
+
{ id: 'p2', key: 'TOOL', name: 'Internal Tools', status: 'draft', description: null },
|
|
38
|
+
],
|
|
39
|
+
total: 2,
|
|
40
|
+
totalProjects: 2,
|
|
41
|
+
query: null,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
it('ORB-1109: filters by query and reports match count vs total', async () => {
|
|
45
|
+
mockFetch([
|
|
46
|
+
{ id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' },
|
|
47
|
+
{ id: 'p2', key: '10M', name: 'Math World', description: null, status: 'active' },
|
|
48
|
+
{ id: 'p3', key: 'TOOL', name: 'Internal Tools', description: null, status: 'draft' },
|
|
49
|
+
]);
|
|
50
|
+
const result = await makeListProjectsHandler(client)({ query: '10m' });
|
|
51
|
+
expect(result.content[0]).toEqual({ type: 'text', text: '- 10M - Math World (active)\n(1 project(s) matching "10m", complete.)' });
|
|
52
|
+
expect(result.structuredContent).toMatchObject({ total: 1, totalProjects: 3, query: '10m' });
|
|
53
|
+
});
|
|
54
|
+
it('ORB-1109: caps with limit and signals the list is partial', async () => {
|
|
55
|
+
mockFetch(Array.from({ length: 5 }, (_, i) => ({ id: `p${i}`, key: `P${i}`, name: `Proj ${i}`, description: null, status: 'active' })));
|
|
56
|
+
const result = await makeListProjectsHandler(client)({ limit: 2 });
|
|
57
|
+
const text = result.content[0].text;
|
|
58
|
+
expect(text).toContain('Showing first 2 of 5 match(es)');
|
|
59
|
+
expect(result.structuredContent).toMatchObject({ total: 5, totalProjects: 5 });
|
|
60
|
+
});
|
|
61
|
+
it('renders an empty-state text block when the API returns no rows', async () => {
|
|
62
|
+
mockFetch([]);
|
|
63
|
+
const result = await makeListProjectsHandler(client)();
|
|
64
|
+
expect(result.content[0]).toEqual({ type: 'text', text: 'No projects visible to this user.' });
|
|
65
|
+
expect(result.structuredContent).toMatchObject({ projects: [], total: 0, totalProjects: 0 });
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-244 Phase B - `orboto_list_tickets`.
|
|
3
|
+
*
|
|
4
|
+
* Lists tickets in a project with optional filters. The API endpoint
|
|
5
|
+
* (`GET /projects/:id/tickets`) supports cursor pagination; this
|
|
6
|
+
* tool returns the first page (50 by default) because an MCP tool
|
|
7
|
+
* call wants to fit inside a single model response - a power user
|
|
8
|
+
* who wants more paginates via more-specific filters instead.
|
|
9
|
+
*/
|
|
10
|
+
import { z } from 'zod';
|
|
11
|
+
import { resolveProjectByKey, resolveTicketByKey, ticketLine, agentTicketListRow } from './shared.js';
|
|
12
|
+
import { resolveMilestoneByNameOrId } from './milestones.js';
|
|
13
|
+
export const listTicketsToolConfig = {
|
|
14
|
+
title: 'List tickets',
|
|
15
|
+
description: 'List tickets in a project, optionally filtered by status category, milestone name, or assignee email. Returns up to 50 tickets per call.',
|
|
16
|
+
inputSchema: z.object({
|
|
17
|
+
projectKey: z.string().min(1).describe('Project key (e.g. "ACME").'),
|
|
18
|
+
statusCategory: z
|
|
19
|
+
.enum(['todo', 'in_progress', 'in_review', 'done', 'wont_fix'])
|
|
20
|
+
.optional()
|
|
21
|
+
.describe('Filter to one workflow category. Omit for all.'),
|
|
22
|
+
milestone: z
|
|
23
|
+
.string()
|
|
24
|
+
.optional()
|
|
25
|
+
.describe('Key (ORB-M3), name, or UUID. Omit for all, backlog included.'),
|
|
26
|
+
assigneeEmail: z
|
|
27
|
+
.string()
|
|
28
|
+
.optional()
|
|
29
|
+
.describe('Project-member email. Omit for all, unassigned included.'),
|
|
30
|
+
parentTicketKey: z
|
|
31
|
+
.string()
|
|
32
|
+
.optional()
|
|
33
|
+
.describe('Only children of this ticket - walks an epic.'),
|
|
34
|
+
limit: z.number().int().min(1).max(50).default(25).describe('Max rows to return.'),
|
|
35
|
+
verbose: z.boolean().default(false).describe('true = full rows; default is the decision fields only.'),
|
|
36
|
+
}).shape,
|
|
37
|
+
annotations: { readOnlyHint: true, idempotentHint: true },
|
|
38
|
+
};
|
|
39
|
+
export function makeListTicketsHandler(client) {
|
|
40
|
+
return async (input) => {
|
|
41
|
+
const project = await resolveProjectByKey(client, input.projectKey);
|
|
42
|
+
const qs = new URLSearchParams();
|
|
43
|
+
qs.set('limit', String(input.limit ?? 25));
|
|
44
|
+
if (input.statusCategory)
|
|
45
|
+
qs.set('statusCategory', input.statusCategory);
|
|
46
|
+
// Milestone + assignee need a UUID on the API; resolve them here
|
|
47
|
+
// from the project payload instead of forcing the caller to paste
|
|
48
|
+
// a UUID.
|
|
49
|
+
if (input.milestone) {
|
|
50
|
+
// ORB-1696 - shared resolver: key (ORB-M3), name or UUID, ambiguous
|
|
51
|
+
// name -> explicit error. Matches create_ticket/set_milestone/OQL.
|
|
52
|
+
const m = await resolveMilestoneByNameOrId(client, project.id, input.milestone);
|
|
53
|
+
qs.set('milestoneId', m.id);
|
|
54
|
+
}
|
|
55
|
+
if (input.assigneeEmail) {
|
|
56
|
+
// Members endpoint returns `{userId, user: {email, ...}, role: {...}}`;
|
|
57
|
+
// we need to peek inside `user` to match by email.
|
|
58
|
+
const members = await client.get(`/projects/${project.id}/members`);
|
|
59
|
+
const member = members.find((x) => x.user.email.toLowerCase() === input.assigneeEmail.toLowerCase());
|
|
60
|
+
if (!member)
|
|
61
|
+
throw new Error(`No project member with email "${input.assigneeEmail}".`);
|
|
62
|
+
qs.set('assigneeId', member.userId);
|
|
63
|
+
}
|
|
64
|
+
if (input.parentTicketKey) {
|
|
65
|
+
const parent = await resolveTicketByKey(client, input.parentTicketKey);
|
|
66
|
+
qs.set('parentTicketId', parent.id);
|
|
67
|
+
}
|
|
68
|
+
const page = await client.get(`/projects/${project.id}/tickets?${qs}`);
|
|
69
|
+
const text = page.items.length === 0
|
|
70
|
+
? `No tickets in project ${project.key} matching the filters.`
|
|
71
|
+
: page.items.map((t) => `- ${ticketLine(t)}`).join('\n');
|
|
72
|
+
const moreHint = page.nextCursor
|
|
73
|
+
? `\n\n(${page.items.length} shown; more exist - narrow the filters to see them.)`
|
|
74
|
+
: '';
|
|
75
|
+
return {
|
|
76
|
+
content: [{ type: 'text', text: text + moreHint }],
|
|
77
|
+
structuredContent: {
|
|
78
|
+
project: { key: project.key },
|
|
79
|
+
count: page.items.length,
|
|
80
|
+
hasMore: !!page.nextCursor,
|
|
81
|
+
// ORB-1699 - shared lean row; verbose restores uuid/labels/minutes.
|
|
82
|
+
tickets: page.items.map((t) => agentTicketListRow(t, input.verbose ?? false)),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-799 - milestone CRUD additions to `milestones.ts`.
|
|
3
|
+
*
|
|
4
|
+
* `list_milestones` + `get_milestone` already have coverage in
|
|
5
|
+
* `phase-b-tools.test.ts`. This file covers the new write surface:
|
|
6
|
+
* create, close (incl. archive), update.
|
|
7
|
+
*/
|
|
8
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { OrbotoApiError, OrbotoClient } from '../orboto-client.js';
|
|
10
|
+
import { makeCreateMilestoneHandler, makeCloseMilestoneHandler, makeUpdateMilestoneHandler, } from './milestones.js';
|
|
11
|
+
beforeEach(() => { vi.restoreAllMocks(); });
|
|
12
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
13
|
+
function stub(responses) {
|
|
14
|
+
const calls = [];
|
|
15
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async (url, init) => {
|
|
16
|
+
calls.push({
|
|
17
|
+
url: url.toString(),
|
|
18
|
+
method: init?.method ?? 'GET',
|
|
19
|
+
body: init?.body ? JSON.parse(init.body) : undefined,
|
|
20
|
+
});
|
|
21
|
+
const r = responses.shift();
|
|
22
|
+
if (!r)
|
|
23
|
+
throw new Error(`unexpected extra fetch`);
|
|
24
|
+
return {
|
|
25
|
+
ok: r.ok ?? true,
|
|
26
|
+
status: r.status ?? 200,
|
|
27
|
+
statusText: 'OK',
|
|
28
|
+
json: async () => ('json' in r ? r.json : {}),
|
|
29
|
+
text: async () => '',
|
|
30
|
+
};
|
|
31
|
+
});
|
|
32
|
+
return calls;
|
|
33
|
+
}
|
|
34
|
+
const client = new OrbotoClient({ baseUrl: 'https://orboto.example.com', apiKey: 'orb_x' });
|
|
35
|
+
const PROJ = { id: 'p1', key: 'ACME', name: 'Acme', description: null, status: 'active' };
|
|
36
|
+
const MILESTONE = {
|
|
37
|
+
id: 'm1', projectId: 'p1', name: 'v1.0', status: 'active',
|
|
38
|
+
startDate: null, endDate: null, isPrivate: false,
|
|
39
|
+
};
|
|
40
|
+
describe('orboto_create_milestone', () => {
|
|
41
|
+
it('POSTs name only (dates omitted) when no dates supplied (ORB-1825)', async () => {
|
|
42
|
+
const calls = stub([
|
|
43
|
+
{ json: PROJ },
|
|
44
|
+
{ json: { ...MILESTONE, name: 'v2.0' } },
|
|
45
|
+
]);
|
|
46
|
+
const res = await makeCreateMilestoneHandler(client)({
|
|
47
|
+
projectKey: 'ACME', name: 'v2.0',
|
|
48
|
+
});
|
|
49
|
+
// ORB-1825 - startDate/endDate are nullable().optional() on the API's
|
|
50
|
+
// create body now; an absent key means the same thing as null, so the
|
|
51
|
+
// tool no longer forces `?? null` and the wire body simply omits them.
|
|
52
|
+
expect(calls[1]).toMatchObject({
|
|
53
|
+
method: 'POST',
|
|
54
|
+
url: 'https://orboto.example.com/projects/p1/milestones',
|
|
55
|
+
body: { name: 'v2.0', isPrivate: false },
|
|
56
|
+
});
|
|
57
|
+
expect(calls[1].body).not.toHaveProperty('startDate');
|
|
58
|
+
expect(calls[1].body).not.toHaveProperty('endDate');
|
|
59
|
+
expect(res.structuredContent).toMatchObject({ name: 'v2.0', projectKey: 'ACME' });
|
|
60
|
+
});
|
|
61
|
+
it('passes dates + isPrivate through verbatim', async () => {
|
|
62
|
+
const calls = stub([
|
|
63
|
+
{ json: PROJ },
|
|
64
|
+
{ json: { ...MILESTONE, name: 'Q3', startDate: '2026-07-01', endDate: '2026-09-30', isPrivate: true } },
|
|
65
|
+
]);
|
|
66
|
+
await makeCreateMilestoneHandler(client)({
|
|
67
|
+
projectKey: 'ACME', name: 'Q3', startDate: '2026-07-01', endDate: '2026-09-30', isPrivate: true,
|
|
68
|
+
});
|
|
69
|
+
expect(calls[1].body).toEqual({
|
|
70
|
+
name: 'Q3',
|
|
71
|
+
startDate: '2026-07-01',
|
|
72
|
+
endDate: '2026-09-30',
|
|
73
|
+
isPrivate: true,
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
it('surfaces a 403 on POST as OrbotoApiError', async () => {
|
|
77
|
+
stub([
|
|
78
|
+
{ json: PROJ },
|
|
79
|
+
{ ok: false, status: 403, json: { error: 'forbidden' } },
|
|
80
|
+
]);
|
|
81
|
+
await expect(makeCreateMilestoneHandler(client)({ projectKey: 'ACME', name: 'v1' })).rejects.toBeInstanceOf(OrbotoApiError);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('orboto_close_milestone', () => {
|
|
85
|
+
it('looks up by name including closed milestones, then PATCHes status=completed', async () => {
|
|
86
|
+
const calls = stub([
|
|
87
|
+
{ json: PROJ },
|
|
88
|
+
{ json: [MILESTONE] }, // includeClosed=true lookup
|
|
89
|
+
{ json: { ...MILESTONE, status: 'completed' } },
|
|
90
|
+
]);
|
|
91
|
+
const res = await makeCloseMilestoneHandler(client)({
|
|
92
|
+
projectKey: 'ACME', milestone: 'v1.0',
|
|
93
|
+
});
|
|
94
|
+
expect(calls[1].url).toContain('includeClosed=true');
|
|
95
|
+
expect(calls[2]).toMatchObject({
|
|
96
|
+
method: 'PATCH',
|
|
97
|
+
body: { status: 'completed' },
|
|
98
|
+
});
|
|
99
|
+
expect(res.structuredContent).toMatchObject({ status: 'completed' });
|
|
100
|
+
});
|
|
101
|
+
it('archive=true PATCHes status=archived', async () => {
|
|
102
|
+
const calls = stub([
|
|
103
|
+
{ json: PROJ },
|
|
104
|
+
{ json: [MILESTONE] },
|
|
105
|
+
{ json: { ...MILESTONE, status: 'archived' } },
|
|
106
|
+
]);
|
|
107
|
+
await makeCloseMilestoneHandler(client)({
|
|
108
|
+
projectKey: 'ACME', milestone: 'v1.0', archive: true,
|
|
109
|
+
});
|
|
110
|
+
expect(calls[2].body).toEqual({ status: 'archived' });
|
|
111
|
+
});
|
|
112
|
+
it('looks up by UUID when the input matches the UUID regex', async () => {
|
|
113
|
+
const calls = stub([
|
|
114
|
+
{ json: PROJ },
|
|
115
|
+
{ json: [MILESTONE, { ...MILESTONE, id: '11111111-2222-3333-4444-555555555555', name: 'other' }] },
|
|
116
|
+
{ json: { ...MILESTONE, status: 'completed' } },
|
|
117
|
+
]);
|
|
118
|
+
await makeCloseMilestoneHandler(client)({
|
|
119
|
+
projectKey: 'ACME', milestone: '11111111-2222-3333-4444-555555555555',
|
|
120
|
+
});
|
|
121
|
+
// PATCH URL should hit the UUID, not the first row.
|
|
122
|
+
expect(calls[2].url).toContain('/milestones/11111111-2222-3333-4444-555555555555');
|
|
123
|
+
});
|
|
124
|
+
it('throws a clear error when milestone is not found', async () => {
|
|
125
|
+
stub([
|
|
126
|
+
{ json: PROJ },
|
|
127
|
+
{ json: [] },
|
|
128
|
+
]);
|
|
129
|
+
await expect(makeCloseMilestoneHandler(client)({ projectKey: 'ACME', milestone: 'ghost' })).rejects.toThrow(/Milestone "ghost" not found/);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
describe('orboto_update_milestone', () => {
|
|
133
|
+
it('PATCHes only the supplied fields', async () => {
|
|
134
|
+
const calls = stub([
|
|
135
|
+
{ json: PROJ },
|
|
136
|
+
{ json: [MILESTONE] },
|
|
137
|
+
{ json: { ...MILESTONE, name: 'v1.0.1' } },
|
|
138
|
+
]);
|
|
139
|
+
await makeUpdateMilestoneHandler(client)({
|
|
140
|
+
projectKey: 'ACME', milestone: 'v1.0', patch: { name: 'v1.0.1' },
|
|
141
|
+
});
|
|
142
|
+
expect(calls[2].body).toEqual({ name: 'v1.0.1' });
|
|
143
|
+
});
|
|
144
|
+
it('surfaces a 403 on PATCH', async () => {
|
|
145
|
+
stub([
|
|
146
|
+
{ json: PROJ },
|
|
147
|
+
{ json: [MILESTONE] },
|
|
148
|
+
{ ok: false, status: 403, json: { error: 'forbidden' } },
|
|
149
|
+
]);
|
|
150
|
+
await expect(makeUpdateMilestoneHandler(client)({
|
|
151
|
+
projectKey: 'ACME', milestone: 'v1.0', patch: { isPrivate: true },
|
|
152
|
+
})).rejects.toBeInstanceOf(OrbotoApiError);
|
|
153
|
+
});
|
|
154
|
+
});
|