@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,81 @@
|
|
|
1
|
+
/** The tool whose whole job is to load the rules - never nudge on it. */
|
|
2
|
+
export const SESSION_START_TOOL = 'orboto_session_start';
|
|
3
|
+
/**
|
|
4
|
+
* The one-time reminder. English, ASCII-only, no em/en-dashes so it
|
|
5
|
+
* survives every client. Prepended as a leading text block only -
|
|
6
|
+
* structuredContent and the tool's own content are left untouched.
|
|
7
|
+
*/
|
|
8
|
+
export const SESSION_START_NUDGE = 'NOTE: you have not loaded this workspace\'s binding operating rules yet. ' +
|
|
9
|
+
'Call `orboto_session_start` now - it returns the rules you must follow ' +
|
|
10
|
+
'plus your in-progress work.';
|
|
11
|
+
/** Fresh per-session (HTTP) / per-process (stdio) nudge state. `gateEnabled`
|
|
12
|
+
* comes from the workspace config fetched at server build (default off). */
|
|
13
|
+
export function createNudgeState(gateEnabled = false) {
|
|
14
|
+
return { firstToolCallSeen: false, sessionStartRan: false, gateEnabled };
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Advance the state for one dispatch and report whether this dispatch
|
|
18
|
+
* should carry the nudge. True ONLY for the first tool call of the
|
|
19
|
+
* session when that call is not `orboto_session_start`. Every later call
|
|
20
|
+
* - and the session_start-first case - returns false. Idempotent after
|
|
21
|
+
* the first call because the flag is already set.
|
|
22
|
+
*/
|
|
23
|
+
export function shouldNudge(state, toolName) {
|
|
24
|
+
if (state.firstToolCallSeen)
|
|
25
|
+
return false;
|
|
26
|
+
state.firstToolCallSeen = true;
|
|
27
|
+
return toolName !== SESSION_START_TOOL;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Return a copy of the result with the nudge prepended as a leading text
|
|
31
|
+
* block. Does not mutate the input, does not touch `structuredContent`,
|
|
32
|
+
* and preserves `isError` and every existing content block.
|
|
33
|
+
*/
|
|
34
|
+
export function prependNudge(result) {
|
|
35
|
+
return {
|
|
36
|
+
...result,
|
|
37
|
+
content: [
|
|
38
|
+
{ type: 'text', text: SESSION_START_NUDGE },
|
|
39
|
+
...(result.content ?? []),
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* ORB-1471 - the HARD session-start gate message. Unlike the soft nudge
|
|
45
|
+
* (which rides along on the tool's real result), the gate REFUSES the tool
|
|
46
|
+
* call outright and returns this instead. English, ASCII-only.
|
|
47
|
+
*/
|
|
48
|
+
export const SESSION_START_GATE_MESSAGE = 'This workspace requires you to load its binding operating rules before any ' +
|
|
49
|
+
'other action. Call `orboto_session_start` now - it returns the rules you ' +
|
|
50
|
+
'must follow plus your in-progress work - then retry this call.';
|
|
51
|
+
/**
|
|
52
|
+
* ORB-1471 - advance the gate state for one dispatch and report whether this
|
|
53
|
+
* dispatch must be REFUSED (returned an instructive error without running the
|
|
54
|
+
* handler).
|
|
55
|
+
*
|
|
56
|
+
* Semantics when the gate is enabled: every tool call other than
|
|
57
|
+
* `orboto_session_start` is refused until `orboto_session_start` has run once
|
|
58
|
+
* this session. Calling `orboto_session_start` marks the session unlocked
|
|
59
|
+
* (and is itself never gated). When the gate is disabled, nothing is ever
|
|
60
|
+
* refused (returns false) - default behaviour is unchanged.
|
|
61
|
+
*
|
|
62
|
+
* Idempotent bookkeeping: running `orboto_session_start` flips
|
|
63
|
+
* `sessionStartRan` so all later calls pass.
|
|
64
|
+
*/
|
|
65
|
+
export function shouldGate(state, toolName) {
|
|
66
|
+
if (toolName === SESSION_START_TOOL) {
|
|
67
|
+
// The rule-loading tool always runs, and running it unlocks the session.
|
|
68
|
+
state.sessionStartRan = true;
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
if (!state.gateEnabled)
|
|
72
|
+
return false;
|
|
73
|
+
return !state.sessionStartRan;
|
|
74
|
+
}
|
|
75
|
+
/** ORB-1471 - the instructive refusal result the gate returns. */
|
|
76
|
+
export function gateResult() {
|
|
77
|
+
return {
|
|
78
|
+
isError: true,
|
|
79
|
+
content: [{ type: 'text', text: SESSION_START_GATE_MESSAGE }],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1331 - session-start nudge state machine + prepend helper.
|
|
3
|
+
*
|
|
4
|
+
* Unit-level proof of the semantics the transport tests then exercise
|
|
5
|
+
* end-to-end: fire once, only on the first tool call, never when that
|
|
6
|
+
* call is orboto_session_start, and never mutate structuredContent.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect } from 'vitest';
|
|
9
|
+
import { SESSION_START_TOOL, SESSION_START_NUDGE, SESSION_START_GATE_MESSAGE, createNudgeState, shouldNudge, shouldGate, gateResult, prependNudge, } from './session-nudge.js';
|
|
10
|
+
describe('shouldNudge (ORB-1331)', () => {
|
|
11
|
+
it('nudges on the first tool call when it is not session_start, then never again', () => {
|
|
12
|
+
const state = createNudgeState();
|
|
13
|
+
expect(shouldNudge(state, 'orboto_list_projects')).toBe(true);
|
|
14
|
+
// Every subsequent call is clean regardless of tool.
|
|
15
|
+
expect(shouldNudge(state, 'orboto_get_ticket')).toBe(false);
|
|
16
|
+
expect(shouldNudge(state, 'orboto_list_projects')).toBe(false);
|
|
17
|
+
expect(shouldNudge(state, SESSION_START_TOOL)).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
it('never nudges when the first call IS session_start', () => {
|
|
20
|
+
const state = createNudgeState();
|
|
21
|
+
expect(shouldNudge(state, SESSION_START_TOOL)).toBe(false);
|
|
22
|
+
// and does not resurface on later calls either.
|
|
23
|
+
expect(shouldNudge(state, 'orboto_list_projects')).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
it('isolates state between sessions/processes (each fresh state nudges once)', () => {
|
|
26
|
+
const a = createNudgeState();
|
|
27
|
+
const b = createNudgeState();
|
|
28
|
+
expect(shouldNudge(a, 'orboto_list_projects')).toBe(true);
|
|
29
|
+
// b is untouched by a - its own first call still nudges.
|
|
30
|
+
expect(shouldNudge(b, 'orboto_list_projects')).toBe(true);
|
|
31
|
+
expect(shouldNudge(a, 'orboto_list_projects')).toBe(false);
|
|
32
|
+
expect(shouldNudge(b, 'orboto_list_projects')).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe('shouldGate (ORB-1471)', () => {
|
|
36
|
+
it('never gates when the gate is disabled (default), whatever the tool', () => {
|
|
37
|
+
const state = createNudgeState(); // gateEnabled defaults false
|
|
38
|
+
expect(shouldGate(state, 'orboto_list_projects')).toBe(false);
|
|
39
|
+
expect(shouldGate(state, 'orboto_create_ticket')).toBe(false);
|
|
40
|
+
expect(shouldGate(state, SESSION_START_TOOL)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
it('when enabled, refuses every non-session-start tool until session_start runs', () => {
|
|
43
|
+
const state = createNudgeState(true);
|
|
44
|
+
// Pre session_start: every other tool is gated.
|
|
45
|
+
expect(shouldGate(state, 'orboto_list_projects')).toBe(true);
|
|
46
|
+
expect(shouldGate(state, 'orboto_create_ticket')).toBe(true);
|
|
47
|
+
// session_start itself is never gated AND unlocks the session.
|
|
48
|
+
expect(shouldGate(state, SESSION_START_TOOL)).toBe(false);
|
|
49
|
+
// Post session_start: everything passes.
|
|
50
|
+
expect(shouldGate(state, 'orboto_list_projects')).toBe(false);
|
|
51
|
+
expect(shouldGate(state, 'orboto_create_ticket')).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
it('session_start as the very first call unlocks immediately (one-call cost avoided)', () => {
|
|
54
|
+
const state = createNudgeState(true);
|
|
55
|
+
expect(shouldGate(state, SESSION_START_TOOL)).toBe(false);
|
|
56
|
+
expect(shouldGate(state, 'orboto_get_ticket')).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
it('gateResult is an instructive isError result pointing at session_start', () => {
|
|
59
|
+
const r = gateResult();
|
|
60
|
+
expect(r.isError).toBe(true);
|
|
61
|
+
expect(r.content[0]).toEqual({ type: 'text', text: SESSION_START_GATE_MESSAGE });
|
|
62
|
+
});
|
|
63
|
+
it('gate message is English, ASCII-only, and free of em/en-dashes', () => {
|
|
64
|
+
// eslint-disable-next-line no-control-regex
|
|
65
|
+
expect(/^[\x00-\x7F]*$/.test(SESSION_START_GATE_MESSAGE)).toBe(true);
|
|
66
|
+
expect(SESSION_START_GATE_MESSAGE).not.toMatch(/[\u2013\u2014]/);
|
|
67
|
+
expect(SESSION_START_GATE_MESSAGE).toContain('orboto_session_start');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe('SESSION_START_NUDGE text (ORB-1331)', () => {
|
|
71
|
+
it('is English, ASCII-only, and free of em/en-dashes', () => {
|
|
72
|
+
// eslint-disable-next-line no-control-regex
|
|
73
|
+
expect(/^[\x00-\x7F]*$/.test(SESSION_START_NUDGE)).toBe(true);
|
|
74
|
+
expect(SESSION_START_NUDGE).not.toMatch(/[\u2013\u2014]/); // en/em dash
|
|
75
|
+
expect(SESSION_START_NUDGE).toContain('orboto_session_start');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe('prependNudge (ORB-1331)', () => {
|
|
79
|
+
it('prepends the nudge as a leading text block without touching structuredContent or isError', () => {
|
|
80
|
+
const original = {
|
|
81
|
+
content: [{ type: 'text', text: 'tool output' }],
|
|
82
|
+
structuredContent: { projects: [], total: 0 },
|
|
83
|
+
};
|
|
84
|
+
const out = prependNudge(original);
|
|
85
|
+
expect(out.content).toHaveLength(2);
|
|
86
|
+
expect(out.content[0]).toEqual({ type: 'text', text: SESSION_START_NUDGE });
|
|
87
|
+
expect(out.content[1]).toEqual({ type: 'text', text: 'tool output' });
|
|
88
|
+
// structuredContent consumers are unaffected - same reference-equal payload.
|
|
89
|
+
expect(out.structuredContent).toBe(original.structuredContent);
|
|
90
|
+
// input is not mutated.
|
|
91
|
+
expect(original.content).toHaveLength(1);
|
|
92
|
+
});
|
|
93
|
+
it('preserves isError on an error result', () => {
|
|
94
|
+
const errResult = { isError: true, content: [{ type: 'text', text: 'boom' }] };
|
|
95
|
+
const out = prependNudge(errResult);
|
|
96
|
+
expect(out.isError).toBe(true);
|
|
97
|
+
expect(out.content[0]).toEqual({ type: 'text', text: SESSION_START_NUDGE });
|
|
98
|
+
expect(out.content[1]).toEqual({ type: 'text', text: 'boom' });
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1331 - end-to-end transport proof of the session-start nudge.
|
|
3
|
+
*
|
|
4
|
+
* Drives the REAL server (buildOrbotoMcpServer → registerWithMetrics →
|
|
5
|
+
* withMetrics → prependNudge) over an in-memory MCP transport with a
|
|
6
|
+
* genuine MCP Client, so the whole JSON-RPC dispatch path is exercised,
|
|
7
|
+
* not just the helper in isolation.
|
|
8
|
+
*
|
|
9
|
+
* Transport semantics both reduce to the lifetime of the per-build nudge
|
|
10
|
+
* state:
|
|
11
|
+
* - HTTP: the transport calls buildOrbotoMcpServer once PER SESSION, so
|
|
12
|
+
* each session is an independent server instance with its own state.
|
|
13
|
+
* - stdio: buildOrbotoMcpServer is called once PER PROCESS, so a single
|
|
14
|
+
* server instance's state is process-local.
|
|
15
|
+
* We model a "session"/"process" as one built server + one connected
|
|
16
|
+
* client, and assert the nudge fires exactly once on the first non-
|
|
17
|
+
* session_start call and never when the first call is session_start.
|
|
18
|
+
*/
|
|
19
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
20
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
21
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
22
|
+
import { buildOrbotoMcpServer } from './server.js';
|
|
23
|
+
import { SESSION_START_NUDGE } from './session-nudge.js';
|
|
24
|
+
// Every API call the tools make resolves to a benign empty payload so
|
|
25
|
+
// the handlers succeed without a live backend. list_projects → [] →
|
|
26
|
+
// "no projects" text + valid structuredContent; session_start's sub-
|
|
27
|
+
// calls are all .catch-guarded so [] is harmless.
|
|
28
|
+
function mockApi() {
|
|
29
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => ({
|
|
30
|
+
ok: true,
|
|
31
|
+
status: 200,
|
|
32
|
+
statusText: 'OK',
|
|
33
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
34
|
+
json: async () => [],
|
|
35
|
+
text: async () => '[]',
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
beforeEach(() => { vi.restoreAllMocks(); mockApi(); });
|
|
39
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
40
|
+
/** Build a fresh server ("session"/"process") + a connected MCP client. */
|
|
41
|
+
async function connectSession() {
|
|
42
|
+
const server = await buildOrbotoMcpServer({ baseUrl: 'http://api.invalid', apiKey: 'orb_test' });
|
|
43
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
44
|
+
const client = new Client({ name: 'test-client', version: '0.0.0' });
|
|
45
|
+
await Promise.all([
|
|
46
|
+
server.connect(serverTransport),
|
|
47
|
+
client.connect(clientTransport),
|
|
48
|
+
]);
|
|
49
|
+
return client;
|
|
50
|
+
}
|
|
51
|
+
/** First text block of a tool result. */
|
|
52
|
+
function firstText(res) {
|
|
53
|
+
const content = res.content;
|
|
54
|
+
return content[0]?.text ?? '';
|
|
55
|
+
}
|
|
56
|
+
describe('ORB-1331 - HTTP transport (per-session nudge)', () => {
|
|
57
|
+
it('prepends the nudge exactly once when the first call is orboto_list_projects; the second call is clean', async () => {
|
|
58
|
+
const client = await connectSession();
|
|
59
|
+
const first = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
|
|
60
|
+
expect(firstText(first)).toContain(SESSION_START_NUDGE);
|
|
61
|
+
// structuredContent must be untouched by the text-block prepend.
|
|
62
|
+
expect(first.structuredContent).toMatchObject({ projects: [], total: 0 });
|
|
63
|
+
const second = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
|
|
64
|
+
expect(firstText(second)).not.toContain(SESSION_START_NUDGE);
|
|
65
|
+
});
|
|
66
|
+
it('never nudges a session whose first call is orboto_session_start', async () => {
|
|
67
|
+
const client = await connectSession();
|
|
68
|
+
const first = await client.callTool({ name: 'orboto_session_start', arguments: {} });
|
|
69
|
+
expect(firstText(first)).not.toContain(SESSION_START_NUDGE);
|
|
70
|
+
const second = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
|
|
71
|
+
expect(firstText(second)).not.toContain(SESSION_START_NUDGE);
|
|
72
|
+
});
|
|
73
|
+
it('isolates the nudge per session - a second, independent session nudges on its own first call', async () => {
|
|
74
|
+
const a = await connectSession();
|
|
75
|
+
const b = await connectSession();
|
|
76
|
+
const aFirst = await a.callTool({ name: 'orboto_list_projects', arguments: {} });
|
|
77
|
+
expect(firstText(aFirst)).toContain(SESSION_START_NUDGE);
|
|
78
|
+
// b is a distinct built server → distinct state → still nudges.
|
|
79
|
+
const bFirst = await b.callTool({ name: 'orboto_list_projects', arguments: {} });
|
|
80
|
+
expect(firstText(bFirst)).toContain(SESSION_START_NUDGE);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
describe('ORB-1331 - stdio transport (process-local nudge)', () => {
|
|
84
|
+
it('fires once for the process: first non-session_start call nudged, all later calls clean', async () => {
|
|
85
|
+
// stdio builds one server per process → one nudge state reused for
|
|
86
|
+
// every call on this single connected client.
|
|
87
|
+
const client = await connectSession();
|
|
88
|
+
const first = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
|
|
89
|
+
expect(firstText(first)).toContain(SESSION_START_NUDGE);
|
|
90
|
+
const second = await client.callTool({ name: 'orboto_session_start', arguments: {} });
|
|
91
|
+
expect(firstText(second)).not.toContain(SESSION_START_NUDGE);
|
|
92
|
+
const third = await client.callTool({ name: 'orboto_list_projects', arguments: {} });
|
|
93
|
+
expect(firstText(third)).not.toContain(SESSION_START_NUDGE);
|
|
94
|
+
});
|
|
95
|
+
it('a process whose first call is session_start never sees the nudge', async () => {
|
|
96
|
+
const client = await connectSession();
|
|
97
|
+
const first = await client.callTool({ name: 'orboto_session_start', arguments: {} });
|
|
98
|
+
expect(firstText(first)).not.toContain(SESSION_START_NUDGE);
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1669 - safety-annotation ratchet for the MCP tool surface.
|
|
3
|
+
*
|
|
4
|
+
* MCP clients use `annotations` to decide whether a tool call needs a
|
|
5
|
+
* confirmation step. The hints DEFAULT badly for us: per the MCP spec a
|
|
6
|
+
* tool with no annotations is read-only=false / destructive=true /
|
|
7
|
+
* idempotent=false - so an unannotated read looks dangerous, and (worse,
|
|
8
|
+
* because it is the silent direction) a write annotated only with
|
|
9
|
+
* `{ readOnlyHint: false }` still reports destructive=true. Before this
|
|
10
|
+
* ticket 90 of 168 tools carried nothing at all and `orboto_bulk_close`
|
|
11
|
+
* was indistinguishable from `orboto_get_ticket`.
|
|
12
|
+
*
|
|
13
|
+
* This test enumerates the tools exactly as a client sees them -
|
|
14
|
+
* `tools/list` over an in-memory transport, not a source scan - and
|
|
15
|
+
* fails the build on a new tool that skips its annotations. There is no
|
|
16
|
+
* allowlist on purpose: unlike the dark-mode / i18n ratchets there is no
|
|
17
|
+
* legitimate "sanctioned exception", every tool can answer these
|
|
18
|
+
* questions.
|
|
19
|
+
*
|
|
20
|
+
* THE CONVENTION (apply it when you add a tool):
|
|
21
|
+
*
|
|
22
|
+
* readOnlyHint: true - performs no writes at all.
|
|
23
|
+
* destructiveHint: true - the call can DELETE or REMOVE a row, DISCARD
|
|
24
|
+
* content, take over state owned by someone
|
|
25
|
+
* else, or mutate many tickets at once. If ANY
|
|
26
|
+
* argument combination can do that, the tool is
|
|
27
|
+
* destructive - the hint is per-tool and cannot
|
|
28
|
+
* be conditioned on a flag (this is why
|
|
29
|
+
* `orboto_claim` is destructive: `sole=true`
|
|
30
|
+
* strips every other assignee).
|
|
31
|
+
* destructiveHint: false- purely additive, or a reversible single-entity
|
|
32
|
+
* field/state update.
|
|
33
|
+
* idempotentHint: true - repeating the call with identical arguments
|
|
34
|
+
* has no further effect.
|
|
35
|
+
*/
|
|
36
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
37
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
38
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
39
|
+
import { buildOrbotoMcpServer } from './server.js';
|
|
40
|
+
/** Anthropic's connector review caps the human-readable tool title. It is
|
|
41
|
+
* also just good UI hygiene - a title that long is a description. */
|
|
42
|
+
const TITLE_MAX = 64;
|
|
43
|
+
let tools;
|
|
44
|
+
beforeAll(async () => {
|
|
45
|
+
// Port 1 is never listened on -> the connect-time /agent-instructions
|
|
46
|
+
// fetch fails immediately and the server falls back to its built-in
|
|
47
|
+
// rules. No network, no fixture server, no timeout.
|
|
48
|
+
// ORB-1520 made the CURATED manifest the default; the annotation contract
|
|
49
|
+
// covers every registered tool, so this ratchet lists the FULL toolset.
|
|
50
|
+
const server = await buildOrbotoMcpServer({ baseUrl: 'http://127.0.0.1:1', apiKey: 'orb_test', toolset: 'full' });
|
|
51
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
52
|
+
const client = new Client({ name: 'annotation-ratchet', version: '0' });
|
|
53
|
+
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
|
54
|
+
tools = (await client.listTools()).tools;
|
|
55
|
+
await client.close();
|
|
56
|
+
});
|
|
57
|
+
describe('MCP tool safety annotations (ORB-1669)', () => {
|
|
58
|
+
it('enumerates the full tool surface', () => {
|
|
59
|
+
// Guards the guard: if the in-memory handshake ever silently returns
|
|
60
|
+
// nothing, every expectation below would vacuously pass.
|
|
61
|
+
expect(tools.length).toBeGreaterThan(150);
|
|
62
|
+
});
|
|
63
|
+
it('every tool declares annotations with an explicit readOnlyHint', () => {
|
|
64
|
+
const offenders = tools
|
|
65
|
+
.filter((t) => typeof t.annotations?.readOnlyHint !== 'boolean')
|
|
66
|
+
.map((t) => t.name);
|
|
67
|
+
expect(offenders, `Tools missing an explicit \`readOnlyHint\`: ${offenders.join(', ')}.\n` +
|
|
68
|
+
'Add an `annotations` block to the tool config in apps/mcp/src/tools/ - see the convention at the top of this file.').toEqual([]);
|
|
69
|
+
});
|
|
70
|
+
it('every write tool declares an explicit destructiveHint', () => {
|
|
71
|
+
// The load-bearing one. `{ readOnlyHint: false }` alone leaves
|
|
72
|
+
// destructiveHint defaulting to TRUE, so a harmless write advertises
|
|
73
|
+
// itself as destructive and the signal stops meaning anything.
|
|
74
|
+
const offenders = tools
|
|
75
|
+
.filter((t) => t.annotations?.readOnlyHint === false)
|
|
76
|
+
.filter((t) => typeof t.annotations?.destructiveHint !== 'boolean')
|
|
77
|
+
.map((t) => t.name);
|
|
78
|
+
expect(offenders, `Write tools missing an explicit \`destructiveHint\`: ${offenders.join(', ')}.\n` +
|
|
79
|
+
'A write with no destructiveHint is reported to clients as destructive (spec default). Set it deliberately, true or false.').toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
it('read-only tools never claim to be destructive', () => {
|
|
82
|
+
const offenders = tools
|
|
83
|
+
.filter((t) => t.annotations?.readOnlyHint === true && t.annotations?.destructiveHint === true)
|
|
84
|
+
.map((t) => t.name);
|
|
85
|
+
expect(offenders, `Contradictory annotations on: ${offenders.join(', ')}`).toEqual([]);
|
|
86
|
+
});
|
|
87
|
+
it(`every tool has a human-readable title of at most ${TITLE_MAX} characters`, () => {
|
|
88
|
+
const missing = tools.filter((t) => !(t.title ?? t.annotations?.title)).map((t) => t.name);
|
|
89
|
+
expect(missing, `Tools with no title: ${missing.join(', ')}`).toEqual([]);
|
|
90
|
+
const tooLong = tools
|
|
91
|
+
.map((t) => ({ name: t.name, title: (t.title ?? t.annotations?.title) }))
|
|
92
|
+
.filter((t) => t.title.length > TITLE_MAX)
|
|
93
|
+
.map((t) => `${t.name} (${t.title.length}: "${t.title}")`);
|
|
94
|
+
expect(tooLong, `Titles over ${TITLE_MAX} characters: ${tooLong.join('; ')}.\n` +
|
|
95
|
+
'Shorten the `title` - the long form belongs in `description`.').toEqual([]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1741 - the manifest diet (epic ORB-1691).
|
|
3
|
+
*
|
|
4
|
+
* Every tool description in the manifest is STANDING context cost: an
|
|
5
|
+
* eager-loading client pays it on connect, every session. The curated
|
|
6
|
+
* toolset (ORB-1520) cut the tool COUNT; this module cuts the per-tool
|
|
7
|
+
* TEXT. At registration time (with-metrics.ts) the full description is
|
|
8
|
+
* captured here and the wire manifest carries only a one-sentence
|
|
9
|
+
* summary; `orboto_help { tool }` serves the full guidance lazily - the
|
|
10
|
+
* same deferred-docs pattern ToolSearch uses for deferred tools.
|
|
11
|
+
*
|
|
12
|
+
* The full texts stay where they always lived - in each tool file's
|
|
13
|
+
* `description` - so authors keep writing complete guidance and nothing
|
|
14
|
+
* is lost: the summary is DERIVED (first sentence, or a hand-written
|
|
15
|
+
* override for the few whose first sentence overruns the cap).
|
|
16
|
+
*/
|
|
17
|
+
/** Soft cap for a wire description; the ratchet in manifest-size.test.ts
|
|
18
|
+
* enforces the aggregate outcome. */
|
|
19
|
+
export const SUMMARY_MAX_CHARS = 220;
|
|
20
|
+
/**
|
|
21
|
+
* Hand-written one-liners for tools whose first sentence exceeds the cap
|
|
22
|
+
* (measured 2026-08-28: 6 of 172). Keep each under SUMMARY_MAX_CHARS and
|
|
23
|
+
* name the key inputs.
|
|
24
|
+
*/
|
|
25
|
+
const SUMMARY_OVERRIDES = {
|
|
26
|
+
orboto_create_full_backup: 'Start a full-workspace backup job (all projects, users, config, attachments); returns the job id to poll via orboto_list_backups.',
|
|
27
|
+
orboto_get_ticket: 'Fetch one ticket by key (ORB-42) or UUID: full detail incl. description, status, assignees, checklists, dependencies and comments.',
|
|
28
|
+
orboto_requirements_spec: 'Generate a structured requirements spec for a project or milestone from its tickets (scope, actors, functional + non-functional requirements).',
|
|
29
|
+
orboto_update_doc_space: 'Update a doc space\'s name, description, icon, project binding or access mode (open/restricted) by space id.',
|
|
30
|
+
orboto_search_docs: 'Full-text search over doc/wiki pages (query, optional space or project filter); returns matching pages with snippets.',
|
|
31
|
+
orboto_critical_path: 'Compute the dependency-based critical path for a project or milestone: the blocking chain of tickets that determines the earliest finish.',
|
|
32
|
+
};
|
|
33
|
+
/** Runtime registry: tool name -> full guidance text, captured at
|
|
34
|
+
* registration. Module-global on purpose - the HTTP transport builds one
|
|
35
|
+
* server per session but the docs are identical, so re-capture is an
|
|
36
|
+
* idempotent Map.set. */
|
|
37
|
+
const toolDocs = new Map();
|
|
38
|
+
export function captureToolDoc(toolName, fullDescription) {
|
|
39
|
+
toolDocs.set(toolName, fullDescription);
|
|
40
|
+
}
|
|
41
|
+
export function getToolDoc(toolName) {
|
|
42
|
+
return toolDocs.get(toolName);
|
|
43
|
+
}
|
|
44
|
+
export function listToolDocNames() {
|
|
45
|
+
return [...toolDocs.keys()].sort();
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* One-sentence wire summary: the override when one exists, else the
|
|
49
|
+
* first sentence (sentence-end followed by whitespace/EOL). A first
|
|
50
|
+
* sentence still over the cap falls back to a word-boundary cut - the
|
|
51
|
+
* summary must never silently exceed what the ratchet budgets for.
|
|
52
|
+
*/
|
|
53
|
+
export function summarizeToolDescription(toolName, full) {
|
|
54
|
+
const override = SUMMARY_OVERRIDES[toolName];
|
|
55
|
+
if (override)
|
|
56
|
+
return override;
|
|
57
|
+
const match = full.match(/^[\s\S]*?[.!?](?=\s|$)/);
|
|
58
|
+
let first = (match ? match[0] : full).trim();
|
|
59
|
+
if (first.length > SUMMARY_MAX_CHARS) {
|
|
60
|
+
const cut = first.slice(0, SUMMARY_MAX_CHARS - 3);
|
|
61
|
+
first = `${cut.slice(0, cut.lastIndexOf(' '))}...`;
|
|
62
|
+
}
|
|
63
|
+
return first;
|
|
64
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1741 - manifest diet: one-line wire descriptions + lazy full docs.
|
|
3
|
+
*
|
|
4
|
+
* Pins (1) the summarizer's three paths (override, first sentence,
|
|
5
|
+
* word-boundary fallback), (2) that the REAL manifest ships only
|
|
6
|
+
* summaries while orboto_help returns the full captured guidance for
|
|
7
|
+
* every registered tool - the "no guidance lost" acceptance criterion,
|
|
8
|
+
* verified against the live registration path, not fixtures.
|
|
9
|
+
*/
|
|
10
|
+
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
|
11
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
12
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
13
|
+
import { buildOrbotoMcpServer } from './server.js';
|
|
14
|
+
import { summarizeToolDescription, captureToolDoc, getToolDoc, SUMMARY_MAX_CHARS, } from './tool-docs.js';
|
|
15
|
+
import { makeHelpHandler } from './tools/help.js';
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline test'));
|
|
19
|
+
delete process.env.ORBOTO_MCP_TOOLSET;
|
|
20
|
+
});
|
|
21
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
22
|
+
describe('summarizeToolDescription', () => {
|
|
23
|
+
it('takes the first sentence', () => {
|
|
24
|
+
expect(summarizeToolDescription('orboto_x', 'Does the thing. And much, much more follows here.'))
|
|
25
|
+
.toBe('Does the thing.');
|
|
26
|
+
});
|
|
27
|
+
it('uses the hand-written override when one exists', () => {
|
|
28
|
+
const s = summarizeToolDescription('orboto_get_ticket', 'A very long first sentence that would otherwise be cut mid-flow because it enumerates every field the response carries and never stops');
|
|
29
|
+
expect(s).toContain('Fetch one ticket');
|
|
30
|
+
expect(s.length).toBeLessThanOrEqual(SUMMARY_MAX_CHARS);
|
|
31
|
+
});
|
|
32
|
+
it('falls back to a word-boundary cut when the first sentence overruns the cap', () => {
|
|
33
|
+
const long = `${'word '.repeat(80)}end.`;
|
|
34
|
+
const s = summarizeToolDescription('orboto_unknown_tool', long);
|
|
35
|
+
expect(s.length).toBeLessThanOrEqual(SUMMARY_MAX_CHARS);
|
|
36
|
+
expect(s.endsWith('...')).toBe(true);
|
|
37
|
+
expect(s).not.toMatch(/\swor\.\.\.$/); // never cuts inside a word
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
describe('orboto_help handler', () => {
|
|
41
|
+
it('returns the captured full text, resolves the orboto_ prefix, and lists known tools on a miss', async () => {
|
|
42
|
+
captureToolDoc('orboto_demo_tool', 'Short summary. Long tail of guidance with warnings and workflows.');
|
|
43
|
+
const handler = makeHelpHandler();
|
|
44
|
+
const hit = await handler({ tool: 'orboto_demo_tool' });
|
|
45
|
+
expect(hit.structuredContent).toMatchObject({
|
|
46
|
+
tool: 'orboto_demo_tool',
|
|
47
|
+
guidance: expect.stringContaining('Long tail of guidance'),
|
|
48
|
+
});
|
|
49
|
+
const prefixed = await handler({ tool: 'demo_tool' });
|
|
50
|
+
expect(prefixed.structuredContent).toMatchObject({ tool: 'orboto_demo_tool' });
|
|
51
|
+
const miss = await handler({ tool: 'orboto_nope' });
|
|
52
|
+
expect(miss.content[0].text).toContain('No guidance registered');
|
|
53
|
+
expect(miss.content[0].text).toContain('orboto_demo_tool');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
describe('live manifest vs help registry (no guidance lost)', () => {
|
|
57
|
+
it('every tool in the full manifest has a one-line wire description and full docs behind orboto_help', async () => {
|
|
58
|
+
const server = await buildOrbotoMcpServer({
|
|
59
|
+
baseUrl: 'https://orboto.example.com', apiKey: 'orb_test', toolset: 'full',
|
|
60
|
+
});
|
|
61
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
62
|
+
const client = new Client({ name: 'diet-check', version: '0.0.0' });
|
|
63
|
+
await Promise.all([server.connect(st), client.connect(ct)]);
|
|
64
|
+
try {
|
|
65
|
+
const { tools } = await client.listTools({});
|
|
66
|
+
expect(tools.length).toBeGreaterThan(150);
|
|
67
|
+
for (const t of tools) {
|
|
68
|
+
const wire = t.description ?? '';
|
|
69
|
+
// Acceptance: no registered wire description exceeds the cap.
|
|
70
|
+
expect(wire.length, `${t.name} wire description too long`).toBeLessThanOrEqual(SUMMARY_MAX_CHARS);
|
|
71
|
+
// The full text is behind the registry and STARTS where the
|
|
72
|
+
// summary came from (override tools aside, whose full text is
|
|
73
|
+
// still captured verbatim).
|
|
74
|
+
const full = getToolDoc(t.name);
|
|
75
|
+
expect(full, `${t.name} missing from the help registry`).toBeTruthy();
|
|
76
|
+
expect(summarizeToolDescription(t.name, full)).toBe(wire);
|
|
77
|
+
}
|
|
78
|
+
// Spot-check the measured worst offender end to end via the tool.
|
|
79
|
+
const res = await client.callTool({ name: 'orboto_help', arguments: { tool: 'orboto_create_ticket' } });
|
|
80
|
+
// First block can be the ORB-1331 session-start nudge - join all.
|
|
81
|
+
const text = res.content.map((c) => c.text ?? '').join('\n');
|
|
82
|
+
expect(text.length).toBeGreaterThan(1000); // the old 2.9k essay, intact
|
|
83
|
+
expect(text).toContain('Duplicate-detection safety-net');
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
await client.close();
|
|
87
|
+
await server.close();
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
});
|