@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.
Files changed (159) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +123 -0
  3. package/dist/event-bridge.js +250 -0
  4. package/dist/event-bridge.test.js +165 -0
  5. package/dist/http-transport-binding.test.js +151 -0
  6. package/dist/http-transport.js +608 -0
  7. package/dist/http-transport.test.js +408 -0
  8. package/dist/index.js +128 -0
  9. package/dist/input-schema.js +293 -0
  10. package/dist/input-schema.test.js +198 -0
  11. package/dist/instructions-budget.test.js +34 -0
  12. package/dist/manifest-size.test.js +192 -0
  13. package/dist/mcp-instrument.js +24 -0
  14. package/dist/oauth-bootstrap.js +413 -0
  15. package/dist/oauth-bootstrap.test.js +212 -0
  16. package/dist/orboto-client.js +250 -0
  17. package/dist/orboto-client.test.js +141 -0
  18. package/dist/prompts.js +219 -0
  19. package/dist/prompts.test.js +79 -0
  20. package/dist/protect-text-usage.test.js +38 -0
  21. package/dist/resources.js +260 -0
  22. package/dist/resources.test.js +191 -0
  23. package/dist/response-budget.js +515 -0
  24. package/dist/response-budget.test.js +343 -0
  25. package/dist/server.js +547 -0
  26. package/dist/session-nudge.js +81 -0
  27. package/dist/session-nudge.test.js +100 -0
  28. package/dist/session-nudge.transport.test.js +100 -0
  29. package/dist/tool-annotations.test.js +97 -0
  30. package/dist/tool-docs.js +64 -0
  31. package/dist/tool-docs.test.js +90 -0
  32. package/dist/tool-parameters.test.js +145 -0
  33. package/dist/tools/absence-writes.js +93 -0
  34. package/dist/tools/absence-writes.test.js +106 -0
  35. package/dist/tools/admin-translations.js +83 -0
  36. package/dist/tools/admin-writes.js +174 -0
  37. package/dist/tools/admin-writes.test.js +120 -0
  38. package/dist/tools/agent-coordination.js +160 -0
  39. package/dist/tools/agent-coordination.test.js +156 -0
  40. package/dist/tools/agent-drift.js +79 -0
  41. package/dist/tools/agent-drift.test.js +87 -0
  42. package/dist/tools/agent-instructions.js +172 -0
  43. package/dist/tools/agent-instructions.test.js +124 -0
  44. package/dist/tools/agent-messages.js +75 -0
  45. package/dist/tools/ai-status.js +55 -0
  46. package/dist/tools/ai-status.test.js +43 -0
  47. package/dist/tools/ai-usage.js +68 -0
  48. package/dist/tools/analytics.js +72 -0
  49. package/dist/tools/analytics.test.js +71 -0
  50. package/dist/tools/api-call.js +72 -0
  51. package/dist/tools/api-call.test.js +104 -0
  52. package/dist/tools/api-search.js +77 -0
  53. package/dist/tools/api-search.test.js +90 -0
  54. package/dist/tools/approvals.js +68 -0
  55. package/dist/tools/approvals.test.js +48 -0
  56. package/dist/tools/attach.js +108 -0
  57. package/dist/tools/attach.test.js +117 -0
  58. package/dist/tools/backup.js +93 -0
  59. package/dist/tools/backup.test.js +108 -0
  60. package/dist/tools/bulk-create.js +213 -0
  61. package/dist/tools/bulk-create.test.js +135 -0
  62. package/dist/tools/bulk-writes.js +339 -0
  63. package/dist/tools/bulk-writes.test.js +239 -0
  64. package/dist/tools/check-similar.js +84 -0
  65. package/dist/tools/check-similar.test.js +101 -0
  66. package/dist/tools/checklist-writes.js +202 -0
  67. package/dist/tools/checklist-writes.test.js +184 -0
  68. package/dist/tools/claim.js +236 -0
  69. package/dist/tools/claim.test.js +207 -0
  70. package/dist/tools/critical-path.js +83 -0
  71. package/dist/tools/critical-path.test.js +114 -0
  72. package/dist/tools/cross-project-links.ee.js +124 -0
  73. package/dist/tools/cross-project-links.ee.test.js +163 -0
  74. package/dist/tools/customer-report.js +88 -0
  75. package/dist/tools/customer-report.test.js +55 -0
  76. package/dist/tools/doc-attachments.js +184 -0
  77. package/dist/tools/doc-attachments.test.js +136 -0
  78. package/dist/tools/doc-comments.js +228 -0
  79. package/dist/tools/doc-comments.test.js +192 -0
  80. package/dist/tools/doc-edits.js +229 -0
  81. package/dist/tools/doc-edits.test.js +218 -0
  82. package/dist/tools/doc-export.js +87 -0
  83. package/dist/tools/doc-export.test.js +109 -0
  84. package/dist/tools/doc-revisions.js +133 -0
  85. package/dist/tools/doc-revisions.test.js +103 -0
  86. package/dist/tools/docs-ai.js +171 -0
  87. package/dist/tools/docs-ai.test.js +160 -0
  88. package/dist/tools/docs.js +557 -0
  89. package/dist/tools/docs.test.js +365 -0
  90. package/dist/tools/embedding-status.js +100 -0
  91. package/dist/tools/embedding-status.test.js +86 -0
  92. package/dist/tools/free-busy.js +49 -0
  93. package/dist/tools/free-busy.test.js +46 -0
  94. package/dist/tools/get-checklists.js +72 -0
  95. package/dist/tools/get-project-primer.js +58 -0
  96. package/dist/tools/get-project.js +77 -0
  97. package/dist/tools/get-ticket.js +321 -0
  98. package/dist/tools/get-timer.js +62 -0
  99. package/dist/tools/help.js +44 -0
  100. package/dist/tools/identity.js +60 -0
  101. package/dist/tools/identity.test.js +67 -0
  102. package/dist/tools/list-git-app-installations.js +45 -0
  103. package/dist/tools/list-projects.js +72 -0
  104. package/dist/tools/list-projects.test.js +67 -0
  105. package/dist/tools/list-tickets.js +86 -0
  106. package/dist/tools/milestones-crud.test.js +154 -0
  107. package/dist/tools/milestones.js +279 -0
  108. package/dist/tools/milestones.test.js +60 -0
  109. package/dist/tools/my-tickets.js +65 -0
  110. package/dist/tools/personal-facts.js +72 -0
  111. package/dist/tools/personal-facts.test.js +45 -0
  112. package/dist/tools/phase-b-tools.test.js +530 -0
  113. package/dist/tools/portfolio.js +56 -0
  114. package/dist/tools/primer-facts.js +346 -0
  115. package/dist/tools/primer-facts.test.js +353 -0
  116. package/dist/tools/project-listings.js +116 -0
  117. package/dist/tools/project-listings.test.js +106 -0
  118. package/dist/tools/query.js +65 -0
  119. package/dist/tools/query.test.js +128 -0
  120. package/dist/tools/raci.js +108 -0
  121. package/dist/tools/raci.test.js +70 -0
  122. package/dist/tools/requirements-spec.js +89 -0
  123. package/dist/tools/requirements-spec.test.js +62 -0
  124. package/dist/tools/response-expand.js +104 -0
  125. package/dist/tools/review-policy.js +105 -0
  126. package/dist/tools/review-policy.test.js +71 -0
  127. package/dist/tools/search.js +78 -0
  128. package/dist/tools/session-start.js +435 -0
  129. package/dist/tools/session-start.test.js +568 -0
  130. package/dist/tools/set-parent.js +76 -0
  131. package/dist/tools/set-parent.test.js +115 -0
  132. package/dist/tools/shared.js +226 -0
  133. package/dist/tools/shared.test.js +169 -0
  134. package/dist/tools/similar-projection.js +30 -0
  135. package/dist/tools/ticket-attachments.js +131 -0
  136. package/dist/tools/ticket-attachments.test.js +106 -0
  137. package/dist/tools/ticket-schedules.js +112 -0
  138. package/dist/tools/ticket-schedules.test.js +102 -0
  139. package/dist/tools/ticket-writes.js +778 -0
  140. package/dist/tools/ticket-writes.test.js +643 -0
  141. package/dist/tools/time-writes.js +238 -0
  142. package/dist/tools/time-writes.test.js +138 -0
  143. package/dist/tools/update-project.js +165 -0
  144. package/dist/tools/update-project.test.js +172 -0
  145. package/dist/tools/wiki.js +185 -0
  146. package/dist/tools/wiki.test.js +104 -0
  147. package/dist/tools/work-sessions-next.test.js +150 -0
  148. package/dist/tools/work-sessions-start.test.js +174 -0
  149. package/dist/tools/work-sessions.js +709 -0
  150. package/dist/tools/work-sessions.test.js +358 -0
  151. package/dist/toolset.js +149 -0
  152. package/dist/toolset.test.js +150 -0
  153. package/dist/version-guard.test.js +51 -0
  154. package/dist/version.js +28 -0
  155. package/dist/with-metrics-mail-nudge.test.js +41 -0
  156. package/dist/with-metrics-output-schema.test.js +91 -0
  157. package/dist/with-metrics.js +211 -0
  158. package/dist/with-metrics.test.js +230 -0
  159. package/package.json +66 -0
package/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright © 2026 Daniel Schemann
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a
6
+ copy of this software and associated documentation files (the "Software"),
7
+ to deal in the Software without restriction, including without limitation
8
+ the rights to use, copy, modify, merge, publish, distribute, sublicense,
9
+ and/or sell copies of the Software, and to permit persons to whom the
10
+ Software is furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included
13
+ in all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
16
+ OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
18
+ THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
19
+ OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
20
+ ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
21
+ OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,123 @@
1
+ # @orboto/mcp
2
+
3
+ Model Context Protocol server for [orboto](https://github.com/orboto/orboto) - a self-hosted ticket and project management platform. Connect Claude Desktop, Cursor, GitHub Copilot Chat, or any MCP-aware AI client and operate orboto as a structured set of tools.
4
+
5
+ 131 tools cover tickets, projects, milestones, time tracking, documents, primer facts, alerts and absences. Every call respects the caller's permission set - what the API key behind the MCP session cannot do in the web UI, the MCP session cannot do either.
6
+
7
+ ## Quickstart
8
+
9
+ There are two ways to authenticate the stdio proxy:
10
+
11
+ - **OAuth login (recommended for people).** Omit `ORBOTO_API_KEY`. On first use the proxy opens your browser to the orboto login (which is your SSO login when SSO is configured), you approve once, and it keeps a short-lived, self-refreshing session cached on disk. Nothing to paste, nothing long-lived.
12
+ - **API key (service accounts / CI).** Set `ORBOTO_API_KEY` to an `orb_…` key from **Profile → API keys → Generate**. Best for headless machines with no browser.
13
+
14
+ ### Claude Desktop - OAuth login (no token)
15
+
16
+ Add to `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS) or `%APPDATA%/Claude/claude_desktop_config.json` (Windows):
17
+
18
+ ```json
19
+ {
20
+ "mcpServers": {
21
+ "orboto": {
22
+ "command": "npx",
23
+ "args": ["-y", "@orboto/mcp"],
24
+ "env": {
25
+ "ORBOTO_API_URL": "https://orboto.example.com/api"
26
+ }
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ The first launch opens your browser to authorize. The refresh token is cached at `~/.config/orboto/mcp-oauth.json` (mode `0600`), so subsequent launches reconnect silently. On a headless host with no browser, the proxy prints the authorization URL to stderr for manual paste, or fall back to the API-key form below.
33
+
34
+ ### Claude Desktop - API key (service accounts)
35
+
36
+ ```json
37
+ {
38
+ "mcpServers": {
39
+ "orboto": {
40
+ "command": "npx",
41
+ "args": ["-y", "@orboto/mcp"],
42
+ "env": {
43
+ "ORBOTO_API_URL": "https://orboto.example.com/api",
44
+ "ORBOTO_API_KEY": "orb_…"
45
+ }
46
+ }
47
+ }
48
+ }
49
+ ```
50
+
51
+ ### Cursor / VS Code
52
+
53
+ Same shape under `.cursor/mcp.json` or `.vscode/mcp.json` in your workspace root (or the user-level equivalent).
54
+
55
+ ### Verify
56
+
57
+ Ask the AI: **"List my orboto projects."** The model picks `orboto_list_projects`, the call goes through MCP → REST API → back, and your projects come back as a list. If you see "no projects matched" but you know you're a member of some, the auth is working - re-check who the API key belongs to in **Profile → API keys**.
58
+
59
+ ## Self-Hosted-Inline mode (HTTP)
60
+
61
+ If your orboto host runs the bundled MCP container (default on the official `docker-compose.yml`), every client on your network can use it directly via Streamable HTTP - no `npx` and no per-laptop install:
62
+
63
+ ```json
64
+ {
65
+ "mcpServers": {
66
+ "orboto": {
67
+ "type": "streamable-http",
68
+ "url": "https://orboto.example.com/mcp",
69
+ "headers": {
70
+ "Authorization": "Bearer orb_…"
71
+ }
72
+ }
73
+ }
74
+ }
75
+ ```
76
+
77
+ The `/mcp` path on the main host is proxied to the bundled MCP container - no separate subdomain or cert needed. Both modes (stdio via npm, HTTP via host) hit the same tool surface.
78
+
79
+ > **stdio-first CLIs (e.g. Codex):** prefer the stdio proxy (`npx @orboto/mcp`) over an HTTP+OAuth URL. Some CLIs handle the OAuth session refresh and session-expiry re-init poorly and can lose their tools after a while or after a server deploy; the stdio proxy avoids that churn. Claude Desktop, Cursor, and VS Code Copilot are known-good over HTTP+OAuth.
80
+
81
+ **Codex CLI** reads TOML from `~/.codex/config.toml` - add an `[mcp_servers.orboto]` table pointing at the stdio proxy:
82
+
83
+ ```toml
84
+ [mcp_servers.orboto]
85
+ command = "npx"
86
+ args = ["-y", "@orboto/mcp"]
87
+ env = { ORBOTO_API_URL = "https://orboto.example.com/api", ORBOTO_API_KEY = "orb_…", ORBOTO_MCP_CLIENT = "codex" }
88
+ ```
89
+
90
+ Omit `ORBOTO_API_KEY` to use OAuth login instead. `codex mcp add` writes the same block from the CLI.
91
+
92
+ ## Environment
93
+
94
+ | Variable | Required | Effect |
95
+ |---|---|---|
96
+ | `ORBOTO_API_URL` | yes | Base URL of the orboto REST API. Typically `https://<your-host>/api`. No trailing slash. |
97
+ | `ORBOTO_API_KEY` | no | API key starting with `orb_…`. When set, the proxy authenticates with it (service-account path). When omitted, the stdio proxy bootstraps via OAuth (browser login). |
98
+ | `ORBOTO_AUTH` | no | `pat` or `oauth`. Defaults to `pat` when `ORBOTO_API_KEY` is set, else `oauth`. Force `oauth` to run the browser login even with a key present. |
99
+ | `ORBOTO_MCP_TOKEN_CACHE` | no | Override the OAuth token cache path (default `~/.config/orboto/mcp-oauth.json`). |
100
+ | `ORBOTO_MCP_NO_BROWSER` | no | Set to `1` to print the authorization URL instead of auto-opening a browser (headless hosts). |
101
+ | `ORBOTO_MCP_TRANSPORT` | no | `stdio` (default) or `http`. Stdio is the right choice for `npx`-launched clients. |
102
+ | `ORBOTO_MCP_TOOLSET` | no | `minimal`, `curated` (default) or `full`. Curated is the measured high-frequency set (31 tools, ~7.8k tokens of schemas at connect) - the whole REST API stays reachable via `orboto_api_search` + `orboto_api_call`. `full` registers every named tool (175, ~39.5k tokens). `minimal` is the daily loop only (12 tools, ~2.8k tokens including the instructions block) for local models with an 8k/16k context window. HTTP clients can pick per connection instead: `?toolset=minimal` on the `/mcp` URL or an `x-orboto-toolset: minimal` header. |
103
+ | `ORBOTO_MCP_PORT` | no | Listen port when transport is `http`. Default `3100`. |
104
+ | `ORBOTO_MCP_CLIENT` | no | User-agent suffix that lands in the API audit log. Useful for filtering audit rows by client. |
105
+
106
+ ## Version compatibility
107
+
108
+ Each `@orboto/mcp` release matches a specific orboto API version - the tag on this package is the same as the `v*` tag on the source orboto repository. Tool schemas evolve with the API, so:
109
+
110
+ - **Pin to your server's version** for reproducible setups: `npx @orboto/mcp@0.109.0` (or whichever your server is on).
111
+ - **`@latest`** if you always run your orboto host on the most recent release. On a mismatch (package newer than server), some newer tools may 404 at call time - but the older tools keep working.
112
+
113
+ The MCP server logs the server version it talks to on startup, so you'll see the alignment in your client's MCP logs.
114
+
115
+ ## License
116
+
117
+ MIT. See [LICENSE.md](./LICENSE.md).
118
+
119
+ ## Source + issues
120
+
121
+ This repository ([`orboto/orboto-mcp`](https://github.com/orboto/orboto-mcp)) is a public subtree-mirror of the `apps/mcp/` directory from the main orboto monorepo (private). Code changes land in the parent repo and are mirrored here on every tag push.
122
+
123
+ **File issues here**: <https://github.com/orboto/orboto-mcp/issues>. Bug reports, feature requests, and questions about `@orboto/mcp` belong on this repo - that's where triage happens. Pull requests against the mirror are closed since the code is read-only; please open a discussion on the issue instead and the fix lands in the parent repo.
@@ -0,0 +1,250 @@
1
+ const HEARTBEAT_GRACE_MS = 90_000; // 3× the server's 30s ping
2
+ const OVERFLOW_THRESHOLD = 100;
3
+ const RECONNECT_DELAY_MS = 2_000;
4
+ /** Map a raw event to the set of `orboto://` URIs that should be
5
+ * notified. Returns an empty array for events that don't map to any
6
+ * subscribed resource shape (no-op). */
7
+ export function eventToUris(event) {
8
+ // Ticket-level events. Include the ticket URI (when the payload
9
+ // carries a ticketKey) AND the project URI so a project-scoped
10
+ // subscriber learns about activity inside it.
11
+ if (event.type === 'ticket:created' || event.type === 'ticket:updated' || event.type === 'ticket:deleted'
12
+ || event.type === 'ticket:activity'
13
+ || event.type === 'comment:created' || event.type === 'comment:updated' || event.type === 'comment:deleted'
14
+ || event.type === 'checklist:created' || event.type === 'checklist:updated' || event.type === 'checklist:deleted'
15
+ || event.type === 'checklist-item:created' || event.type === 'checklist-item:updated' || event.type === 'checklist-item:deleted') {
16
+ const uris = [];
17
+ const key = event.payload?.ticketKey;
18
+ if (key)
19
+ uris.push(`orboto://ticket/${key}`);
20
+ // The MCP bridge doesn't know each project's key; the project URI
21
+ // form uses the projectKey which isn't on the wire. We fall back
22
+ // to projectId, which the resource handler accepts via
23
+ // resolveProjectByKey's lookup path.
24
+ if (event.projectId)
25
+ uris.push(`orboto://project/${event.projectId}`);
26
+ // ORB-962 - hand-off "wake on close". When a ticket:updated
27
+ // payload's statusCategory is `done`, emit an additional URI
28
+ // dedicated to close-events so subscribers don't have to filter
29
+ // every ticket:updated client-side.
30
+ if (event.type === 'ticket:updated' && key && event.payload?.statusCategory === 'done') {
31
+ uris.push(`orboto://handoff/closed/${key}`);
32
+ }
33
+ return uris;
34
+ }
35
+ if (event.type === 'project:bulk-activity' || event.type === 'member:joined' || event.type === 'member:removed') {
36
+ return event.projectId ? [`orboto://project/${event.projectId}`] : [];
37
+ }
38
+ // ORB-1616 - dispatcher wake-on-unblock. Project-scoped (not per-ticket
39
+ // like `handoff/closed`) because a worker pool doesn't know in advance
40
+ // which ticket will become ready next - it subscribes to the PROJECT's
41
+ // ready channel and pulls via `orboto_work_next` on push. The
42
+ // notification carries no payload (the resource-subscription protocol
43
+ // never does) - `ticket.ready` is the webhook channel that carries the
44
+ // actual ticket key for external consumers.
45
+ if (event.type === 'ticket:ready') {
46
+ return event.projectId ? [`orboto://ready/${event.projectId}`] : [];
47
+ }
48
+ // ORB-1616 - escalation push: collision / failed checks / review
49
+ // rejection / missing decision / git failure. Project-scoped, same
50
+ // reasoning as `ready` above.
51
+ if (event.type === 'agent_escalation:raised') {
52
+ return event.projectId ? [`orboto://escalation/${event.projectId}`] : [];
53
+ }
54
+ if (event.type === 'doc:updated') {
55
+ return event.docId ? [`orboto://doc/${event.docId}`] : [];
56
+ }
57
+ if (event.type === 'timer:changed') {
58
+ return [`orboto://timer`];
59
+ }
60
+ // ORB-964 - agent broadcast. Map to the scope-specific URI so
61
+ // subscribers can register interest in just the scope they care
62
+ // about. For workspace scope, scopeId is the empty string.
63
+ if (event.type === 'agent_broadcast:posted') {
64
+ const p = event.payload;
65
+ if (p?.scopeType) {
66
+ const scopeId = p.scopeId ?? '';
67
+ return [`orboto://broadcast/${p.scopeType}/${scopeId}`];
68
+ }
69
+ return [];
70
+ }
71
+ // ORB-970 - quorum lifecycle.
72
+ if (event.type === 'agent_quorum:opened' || event.type === 'agent_quorum:approved') {
73
+ const p = event.payload;
74
+ return p?.topicKey ? [`orboto://quorum/${p.topicKey}`] : [];
75
+ }
76
+ // ORB-706 - mention real-time push. Every notification row firing
77
+ // for the calling user surfaces on `orboto://user/me/notifications`.
78
+ // The API-side SSE bridge already filters notification:new events
79
+ // to only deliver them to the matching user's session, so this
80
+ // URI is naturally per-user-scoped.
81
+ if (event.type === 'notification:new') {
82
+ return [`orboto://user/me/notifications`];
83
+ }
84
+ // restore:progress / system-task:* are user-scoped infrastructure
85
+ // events that don't map to a public resource URI today. The MCP
86
+ // bridge ignores them - the user gets them via the in-app UI.
87
+ return [];
88
+ }
89
+ export class EventBridge {
90
+ opts;
91
+ abort = new AbortController();
92
+ overflow = 0;
93
+ closed = false;
94
+ retryTimer = null;
95
+ log;
96
+ constructor(opts) {
97
+ this.opts = opts;
98
+ this.log = opts.log ?? ((msg) => { try {
99
+ process.stderr.write(`[orboto-mcp-bridge] ${msg}\n`);
100
+ }
101
+ catch { /* ignore */ } });
102
+ }
103
+ /** Open the SSE stream and start forwarding. Resolves immediately;
104
+ * the actual forwarding happens on the returned background loop. */
105
+ start() {
106
+ void this.loop();
107
+ }
108
+ /** Stop the bridge. Safe to call multiple times. */
109
+ close() {
110
+ this.closed = true;
111
+ if (this.retryTimer) {
112
+ clearTimeout(this.retryTimer);
113
+ this.retryTimer = null;
114
+ }
115
+ try {
116
+ this.abort.abort();
117
+ }
118
+ catch { /* ignore */ }
119
+ }
120
+ async loop() {
121
+ while (!this.closed) {
122
+ try {
123
+ await this.consume();
124
+ }
125
+ catch (err) {
126
+ if (this.closed)
127
+ return;
128
+ const reason = err instanceof Error ? err.message : String(err);
129
+ this.log(`stream closed: ${reason} - retrying in ${RECONNECT_DELAY_MS}ms`);
130
+ }
131
+ if (this.closed)
132
+ return;
133
+ await new Promise((resolve) => {
134
+ this.retryTimer = setTimeout(resolve, RECONNECT_DELAY_MS);
135
+ });
136
+ }
137
+ }
138
+ async consume() {
139
+ const fetchFn = this.opts.fetchFn ?? fetch;
140
+ // Fresh abort controller per attempt so close() during a retry
141
+ // window doesn't leak the previous signal.
142
+ this.abort = new AbortController();
143
+ const url = `${this.opts.baseUrl.replace(/\/$/, '')}/sse/mcp-events`;
144
+ // ORB-1470 - resolve the current bearer per connect so a rotated OAuth
145
+ // access token is picked up on the next reconnect instead of pinning the
146
+ // stream to the session's creation-time token.
147
+ const bearer = this.opts.tokenProvider
148
+ ? await this.opts.tokenProvider.getAccessToken()
149
+ : (this.opts.apiKey ?? '');
150
+ const res = await fetchFn(url, {
151
+ method: 'GET',
152
+ headers: {
153
+ Authorization: `Bearer ${bearer}`,
154
+ Accept: 'text/event-stream',
155
+ },
156
+ signal: this.abort.signal,
157
+ });
158
+ if (!res.ok || !res.body) {
159
+ throw new Error(`SSE bridge handshake failed (status=${res.status})`);
160
+ }
161
+ this.log(`SSE bridge connected → ${url}`);
162
+ const reader = res.body.getReader();
163
+ const decoder = new TextDecoder();
164
+ let buf = '';
165
+ let lastFrame = Date.now();
166
+ const heartbeat = setInterval(() => {
167
+ // If the server's ping stops arriving the underlying TCP
168
+ // connection might be dead silent (Coolify / NAT timeout). Force
169
+ // a reconnect so we don't sit forever on a zombie stream.
170
+ if (Date.now() - lastFrame > HEARTBEAT_GRACE_MS) {
171
+ try {
172
+ this.abort.abort();
173
+ }
174
+ catch { /* ignore */ }
175
+ }
176
+ }, 30_000);
177
+ try {
178
+ // Read until the stream ends, the request was aborted, or the
179
+ // process is shutting down.
180
+ // eslint-disable-next-line no-constant-condition
181
+ while (true) {
182
+ const { done, value } = await reader.read();
183
+ if (done)
184
+ break;
185
+ lastFrame = Date.now();
186
+ buf += decoder.decode(value, { stream: true });
187
+ // SSE frames are split by '\n\n'. We handle both 'data: ...'
188
+ // and ': ping' (comment / heartbeat) lines; the latter become
189
+ // a no-op event.
190
+ let idx;
191
+ // eslint-disable-next-line no-cond-assign
192
+ while ((idx = buf.indexOf('\n\n')) !== -1) {
193
+ const frame = buf.slice(0, idx);
194
+ buf = buf.slice(idx + 2);
195
+ const line = frame.split('\n').find((l) => l.startsWith('data: '));
196
+ if (!line)
197
+ continue;
198
+ const json = line.slice(6);
199
+ let event;
200
+ try {
201
+ event = JSON.parse(json);
202
+ }
203
+ catch {
204
+ continue;
205
+ }
206
+ await this.dispatch(event);
207
+ }
208
+ }
209
+ }
210
+ finally {
211
+ clearInterval(heartbeat);
212
+ try {
213
+ reader.releaseLock();
214
+ }
215
+ catch { /* ignore */ }
216
+ }
217
+ }
218
+ async dispatch(event) {
219
+ const uris = eventToUris(event);
220
+ if (uris.length === 0)
221
+ return;
222
+ const matched = [];
223
+ for (const uri of uris) {
224
+ if (this.opts.subscriptions.has(uri))
225
+ matched.push(uri);
226
+ }
227
+ if (matched.length === 0)
228
+ return;
229
+ if (this.overflow >= OVERFLOW_THRESHOLD) {
230
+ try {
231
+ await this.opts.mcp.server.sendResourceListChanged();
232
+ }
233
+ catch (err) {
234
+ this.log(`sendResourceListChanged failed: ${err.message}`);
235
+ }
236
+ this.overflow = 0;
237
+ return;
238
+ }
239
+ for (const uri of matched) {
240
+ try {
241
+ await this.opts.mcp.server.sendResourceUpdated({ uri });
242
+ this.log(`pushed resources/updated → ${uri} (event=${event.type})`);
243
+ this.overflow += 1;
244
+ }
245
+ catch (err) {
246
+ this.log(`sendResourceUpdated(${uri}) failed: ${err.message}`);
247
+ }
248
+ }
249
+ }
250
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * ORB-940 - EventBridge unit tests.
3
+ *
4
+ * Three concerns:
5
+ * 1. Event → URI mapping (pure function, no I/O)
6
+ * 2. Dispatch behaviour - subscribed URIs trigger
7
+ * sendResourceUpdated, unsubscribed do not
8
+ * 3. Backpressure - > 100 forwarded events → one
9
+ * sendResourceListChanged instead of more updates
10
+ *
11
+ * The actual SSE consumption path is exercised via a streaming
12
+ * mock fetch; the response body is a ReadableStream that yields
13
+ * one frame per chunk, then closes.
14
+ */
15
+ import { describe, expect, it, vi } from 'vitest';
16
+ import { EventBridge, eventToUris } from './event-bridge.js';
17
+ function mockMcp() {
18
+ const sendResourceUpdated = vi.fn().mockResolvedValue(undefined);
19
+ const sendResourceListChanged = vi.fn().mockResolvedValue(undefined);
20
+ return {
21
+ server: { sendResourceUpdated, sendResourceListChanged },
22
+ sendResourceUpdated,
23
+ sendResourceListChanged,
24
+ };
25
+ }
26
+ /** Build a Response with a body that emits the given SSE frames in
27
+ * order, then closes. Each `frame` becomes `data: <json>\n\n`. */
28
+ function mockSseResponse(frames) {
29
+ const encoder = new TextEncoder();
30
+ const stream = new ReadableStream({
31
+ start(controller) {
32
+ for (const frame of frames) {
33
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(frame)}\n\n`));
34
+ }
35
+ controller.close();
36
+ },
37
+ });
38
+ return new Response(stream, {
39
+ status: 200,
40
+ headers: { 'content-type': 'text/event-stream' },
41
+ });
42
+ }
43
+ describe('eventToUris', () => {
44
+ it('maps ticket:updated to both ticket and project URIs', () => {
45
+ const uris = eventToUris({
46
+ type: 'ticket:updated',
47
+ projectId: 'p1',
48
+ payload: { ticketKey: 'ORB-42' },
49
+ });
50
+ expect(uris).toEqual(['orboto://ticket/ORB-42', 'orboto://project/p1']);
51
+ });
52
+ it('maps comment events to the ticket URI when ticketKey is present', () => {
53
+ const uris = eventToUris({
54
+ type: 'comment:created',
55
+ projectId: 'p1',
56
+ payload: { ticketKey: 'ORB-99' },
57
+ });
58
+ expect(uris).toEqual(['orboto://ticket/ORB-99', 'orboto://project/p1']);
59
+ });
60
+ it('maps doc:updated to a doc URI', () => {
61
+ expect(eventToUris({ type: 'doc:updated', docId: 'd1' })).toEqual(['orboto://doc/d1']);
62
+ });
63
+ it('maps timer:changed to the singleton timer URI', () => {
64
+ expect(eventToUris({ type: 'timer:changed', userId: 'u1' })).toEqual(['orboto://timer']);
65
+ });
66
+ it('maps notification:new to the calling user\'s notifications resource (ORB-706)', () => {
67
+ // The mcp-events SSE handler already filters notification:new
68
+ // to only deliver to the matching user; the URI is per-user
69
+ // scoped by construction.
70
+ expect(eventToUris({ type: 'notification:new', userId: 'u1' })).toEqual(['orboto://user/me/notifications']);
71
+ });
72
+ it('returns [] for restore-progress / system-task events (infrastructure-only)', () => {
73
+ expect(eventToUris({ type: 'restore:progress', userId: 'u1' })).toEqual([]);
74
+ });
75
+ it('emits orboto://handoff/closed/<key> on a ticket:updated to done (ORB-962)', () => {
76
+ const uris = eventToUris({
77
+ type: 'ticket:updated',
78
+ projectId: 'p1',
79
+ payload: { ticketKey: 'ORB-42', statusCategory: 'done' },
80
+ });
81
+ expect(uris).toContain('orboto://ticket/ORB-42');
82
+ expect(uris).toContain('orboto://handoff/closed/ORB-42');
83
+ });
84
+ it('does NOT emit handoff URI when a ticket:updated keeps a non-done status', () => {
85
+ const uris = eventToUris({
86
+ type: 'ticket:updated',
87
+ projectId: 'p1',
88
+ payload: { ticketKey: 'ORB-42', statusCategory: 'in_progress' },
89
+ });
90
+ expect(uris).not.toContain('orboto://handoff/closed/ORB-42');
91
+ });
92
+ });
93
+ describe('EventBridge.dispatch', () => {
94
+ it('forwards subscribed URIs and ignores the rest', async () => {
95
+ const mcp = mockMcp();
96
+ const subscriptions = new Set(['orboto://ticket/ORB-42']);
97
+ const fetchFn = vi.fn().mockResolvedValueOnce(mockSseResponse([
98
+ { type: 'ticket:updated', projectId: 'p1', payload: { ticketKey: 'ORB-42' } },
99
+ { type: 'ticket:updated', projectId: 'p1', payload: { ticketKey: 'ORB-99' } },
100
+ ]));
101
+ const bridge = new EventBridge({
102
+ baseUrl: 'https://orboto.example.com',
103
+ apiKey: 'orb_x',
104
+ mcp,
105
+ subscriptions,
106
+ fetchFn: fetchFn,
107
+ log: () => { },
108
+ });
109
+ bridge.start();
110
+ // Give the microtask loop a couple of ticks to drain.
111
+ await new Promise((r) => setTimeout(r, 50));
112
+ bridge.close();
113
+ expect(mcp.sendResourceUpdated).toHaveBeenCalledTimes(1);
114
+ expect(mcp.sendResourceUpdated).toHaveBeenCalledWith({ uri: 'orboto://ticket/ORB-42' });
115
+ });
116
+ it('falls back to sendResourceListChanged after the overflow threshold', async () => {
117
+ const mcp = mockMcp();
118
+ const subscriptions = new Set(['orboto://timer']);
119
+ // 101 timer events - one more than the threshold so the 101st
120
+ // round trips through the list_changed path.
121
+ const frames = Array.from({ length: 101 }, () => ({ type: 'timer:changed', userId: 'u1' }));
122
+ const fetchFn = vi.fn().mockResolvedValueOnce(mockSseResponse(frames));
123
+ const bridge = new EventBridge({
124
+ baseUrl: 'https://orboto.example.com',
125
+ apiKey: 'orb_x',
126
+ mcp,
127
+ subscriptions,
128
+ fetchFn: fetchFn,
129
+ log: () => { },
130
+ });
131
+ bridge.start();
132
+ await new Promise((r) => setTimeout(r, 100));
133
+ bridge.close();
134
+ expect(mcp.sendResourceUpdated).toHaveBeenCalledTimes(100);
135
+ expect(mcp.sendResourceListChanged).toHaveBeenCalledTimes(1);
136
+ });
137
+ it('survives malformed frames without aborting the stream', async () => {
138
+ const mcp = mockMcp();
139
+ const subscriptions = new Set(['orboto://ticket/ORB-42']);
140
+ // Inject one bad frame between two good ones - the bad frame is
141
+ // a non-JSON 'data:' line, which the bridge should skip.
142
+ const encoder = new TextEncoder();
143
+ const stream = new ReadableStream({
144
+ start(controller) {
145
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ticket:updated', projectId: 'p1', payload: { ticketKey: 'ORB-42' } })}\n\n`));
146
+ controller.enqueue(encoder.encode('data: not-json\n\n'));
147
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'ticket:updated', projectId: 'p1', payload: { ticketKey: 'ORB-42' } })}\n\n`));
148
+ controller.close();
149
+ },
150
+ });
151
+ const fetchFn = vi.fn().mockResolvedValueOnce(new Response(stream, { status: 200 }));
152
+ const bridge = new EventBridge({
153
+ baseUrl: 'https://orboto.example.com',
154
+ apiKey: 'orb_x',
155
+ mcp,
156
+ subscriptions,
157
+ fetchFn: fetchFn,
158
+ log: () => { },
159
+ });
160
+ bridge.start();
161
+ await new Promise((r) => setTimeout(r, 50));
162
+ bridge.close();
163
+ expect(mcp.sendResourceUpdated).toHaveBeenCalledTimes(2);
164
+ });
165
+ });