@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
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
|
+
});
|