@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,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1521 - manifest-size measurement + regression ratchet (epic
|
|
3
|
+
* ORB-1517).
|
|
4
|
+
*
|
|
5
|
+
* Measures the REAL `tools/list` payload an eager-loading client pays
|
|
6
|
+
* at connect time - through an actual MCP client over an in-memory
|
|
7
|
+
* transport, so the numbers include the SDK's zod->JSON-schema
|
|
8
|
+
* conversion, exactly what Codex/Cursor/Claude Desktop receive.
|
|
9
|
+
*
|
|
10
|
+
* The curated assertion is the RATCHET (mirroring the dark-mode / i18n
|
|
11
|
+
* shrink-only checks): if someone re-registers the tail into the
|
|
12
|
+
* default manifest - or the curated set quietly balloons - this fails
|
|
13
|
+
* before a release ships the token bill. Raising CURATED_MAX_CHARS is a
|
|
14
|
+
* conscious decision to grow the default connect cost, not a formality.
|
|
15
|
+
*/
|
|
16
|
+
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
|
17
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
18
|
+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
|
|
19
|
+
import { buildOrbotoMcpServer } from './server.js';
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
vi.restoreAllMocks();
|
|
22
|
+
vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('offline test'));
|
|
23
|
+
delete process.env.ORBOTO_MCP_TOOLSET;
|
|
24
|
+
});
|
|
25
|
+
afterEach(() => { vi.restoreAllMocks(); });
|
|
26
|
+
/**
|
|
27
|
+
* Measured 2026-08-10 (ORB-1521): curated = 28 tools / 37,458 chars
|
|
28
|
+
* (~9.4k tokens); full = 171 tools / 189,672 chars (~47.4k tokens).
|
|
29
|
+
* Re-measured 2026-08-28 after the ORB-1741 manifest diet (one-line
|
|
30
|
+
* descriptions + orboto_help): curated = 29 tools / 27,870 chars
|
|
31
|
+
* (~7.0k tokens); full = 173 tools / 145,867 chars (~36.5k tokens).
|
|
32
|
+
* ORB-1694 added the two bulk tools (create + dependencies, schema-heavy
|
|
33
|
+
* by nature): curated = 31 tools / 33,615 chars - still below the
|
|
34
|
+
* pre-diet 37,458 baseline. Ceilings keep ~7-10 % headroom for honest
|
|
35
|
+
* edits - a description essay creeping back in is exactly what these
|
|
36
|
+
* must catch, so the headroom is deliberately tighter than the old
|
|
37
|
+
* 20 %. Shrink-only: raising either number is a conscious decision to
|
|
38
|
+
* grow every session's connect cost.
|
|
39
|
+
*
|
|
40
|
+
* ORB-1805, measured 2026-09-01 with the schema diet + the compact
|
|
41
|
+
* advertised `__truncation` block: minimal = 12 tools / 9,440 chars;
|
|
42
|
+
* curated = 31 tools / 27,945 chars (was 34,015); full = 175 tools /
|
|
43
|
+
* 142,366 chars (was 152,348).
|
|
44
|
+
*
|
|
45
|
+
* ORB-1669, landed 2026-09-04: every tool carries an MCP `annotations`
|
|
46
|
+
* block (readOnlyHint / destructiveHint / idempotentHint) - clients use
|
|
47
|
+
* them for confirmation UX, so they are spec conformance, not payload
|
|
48
|
+
* fat. Measured cost: minimal +688 chars (12 tools), curated +1,276,
|
|
49
|
+
* full +9,836 (175 tools, ~56 chars each). FULL_MAX_CHARS and
|
|
50
|
+
* MINIMAL_MAX_TOKENS were raised by exactly that, deliberately.
|
|
51
|
+
*/
|
|
52
|
+
const CURATED_MAX_CHARS = 30_000;
|
|
53
|
+
const FULL_MAX_CHARS = 155_000;
|
|
54
|
+
/**
|
|
55
|
+
* ORB-1805 - the estimator the ticket measured the failure with
|
|
56
|
+
* (`n_keep: 13533 >= n_ctx: 8192` in LM Studio): characters / 3.6. It is
|
|
57
|
+
* a rough tokenizer stand-in, deliberately the SAME rough number the
|
|
58
|
+
* ticket, the docs page and these ceilings all use, so the figures a
|
|
59
|
+
* reader compares are comparable.
|
|
60
|
+
*/
|
|
61
|
+
const CHARS_PER_TOKEN = 3.6;
|
|
62
|
+
const estTokens = (chars) => Math.round(chars / CHARS_PER_TOKEN);
|
|
63
|
+
/**
|
|
64
|
+
* The minimal tier's whole connect cost - tool schemas AND the
|
|
65
|
+
* instructions block - must fit a small local model's window with room
|
|
66
|
+
* left for the conversation. 3,000 was the ticket's number; 3,100 since
|
|
67
|
+
* ORB-1669 added the annotations block to every tool (+688 chars).
|
|
68
|
+
*/
|
|
69
|
+
const MINIMAL_MAX_TOKENS = 3_100;
|
|
70
|
+
/**
|
|
71
|
+
* The curated tier is ratcheted on its TOOL SCHEMAS (the instructions
|
|
72
|
+
* block has its own budget, enforced in instructions-budget.test.ts).
|
|
73
|
+
*
|
|
74
|
+
* Why this is not the ticket's aspirational 6,000: measured 2026-09-01,
|
|
75
|
+
* the curated manifest with EVERY description and title stripped out is
|
|
76
|
+
* still 17,379 chars (~4,827 tokens) - 31 tools' names, parameter names,
|
|
77
|
+
* types, enums, plus ~2,700 chars of per-tool `$schema` and `execution`
|
|
78
|
+
* keys the MCP SDK emits and we do not control. A 6,000-token ceiling
|
|
79
|
+
* would leave ~4,200 chars for all 31 tools' text combined, i.e. the
|
|
80
|
+
* one-line summaries and NOTHING else - no "Key (ORB-M3), name, or
|
|
81
|
+
* UUID", no "YYYY-MM-DD", no "Default: task". Those pay for themselves
|
|
82
|
+
* in avoided failed calls. The tier that genuinely fits a small window
|
|
83
|
+
* is `minimal`; curated is the desktop-client default, and this ceiling
|
|
84
|
+
* holds the 18 % the diet won.
|
|
85
|
+
*/
|
|
86
|
+
const CURATED_SCHEMA_MAX_TOKENS = 8_200;
|
|
87
|
+
async function measuredManifest(toolset) {
|
|
88
|
+
const server = await buildOrbotoMcpServer({
|
|
89
|
+
baseUrl: 'https://orboto.example.com',
|
|
90
|
+
apiKey: 'orb_test',
|
|
91
|
+
...(toolset ? { toolset } : {}),
|
|
92
|
+
});
|
|
93
|
+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
|
94
|
+
const client = new Client({ name: 'manifest-measure', version: '0.0.0' });
|
|
95
|
+
await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
|
|
96
|
+
try {
|
|
97
|
+
// Walk pagination defensively; the SDK serves all tools in one page
|
|
98
|
+
// today but the measurement must not silently undercount if that
|
|
99
|
+
// changes.
|
|
100
|
+
let cursor;
|
|
101
|
+
const tools = [];
|
|
102
|
+
do {
|
|
103
|
+
const page = await client.listTools(cursor ? { cursor } : {});
|
|
104
|
+
tools.push(...page.tools);
|
|
105
|
+
cursor = page.nextCursor;
|
|
106
|
+
} while (cursor);
|
|
107
|
+
// `_instructions` verified against @modelcontextprotocol/sdk 1.29.
|
|
108
|
+
// Assert non-empty at the call sites so an SDK rename fails loudly
|
|
109
|
+
// instead of scoring the block as free.
|
|
110
|
+
const instructions = String(server.server._instructions ?? '');
|
|
111
|
+
return { count: tools.length, chars: JSON.stringify(tools).length, instructionsChars: instructions.length };
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
await client.close();
|
|
115
|
+
await server.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
describe('ORB-1521 - eager-load manifest size', () => {
|
|
119
|
+
it('curated manifest stays under the ratchet ceiling', async () => {
|
|
120
|
+
const { count, chars } = await measuredManifest();
|
|
121
|
+
// eslint-disable-next-line no-console
|
|
122
|
+
console.log(`[manifest-size] curated: ${count} tools, ${chars} chars (~${estTokens(chars)} tokens)`);
|
|
123
|
+
// 31 = the ORB-1741-dieted set + the two ORB-1694 bulk tools (each
|
|
124
|
+
// REPLACES dozens of per-call responses, so they earn their slot).
|
|
125
|
+
expect(count).toBeLessThanOrEqual(32);
|
|
126
|
+
expect(chars).toBeLessThanOrEqual(CURATED_MAX_CHARS);
|
|
127
|
+
expect(estTokens(chars)).toBeLessThanOrEqual(CURATED_SCHEMA_MAX_TOKENS);
|
|
128
|
+
});
|
|
129
|
+
it('full manifest remains available and an order of magnitude larger', async () => {
|
|
130
|
+
const curated = await measuredManifest();
|
|
131
|
+
const full = await measuredManifest('full');
|
|
132
|
+
// eslint-disable-next-line no-console
|
|
133
|
+
console.log(`[manifest-size] full: ${full.count} tools, ${full.chars} chars (~${estTokens(full.chars)} tokens)`);
|
|
134
|
+
expect(full.count).toBeGreaterThan(150);
|
|
135
|
+
// ORB-1741 - the full manifest is ratcheted too: the diet's savings
|
|
136
|
+
// live mostly here (per-tool description essays), so this is where
|
|
137
|
+
// regression would land first.
|
|
138
|
+
expect(full.chars).toBeLessThanOrEqual(FULL_MAX_CHARS);
|
|
139
|
+
// The reduction claim the epic makes - keep it honest in CI.
|
|
140
|
+
expect(full.chars / curated.chars).toBeGreaterThan(3);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
/**
|
|
144
|
+
* ORB-1805 - the small-context tier.
|
|
145
|
+
*
|
|
146
|
+
* This is the assertion that keeps the tier honest: an 8k local model
|
|
147
|
+
* pays the WHOLE connect cost (schemas + instructions) before its own
|
|
148
|
+
* first turn, so both halves are measured together against one ceiling.
|
|
149
|
+
*/
|
|
150
|
+
describe('ORB-1805 - minimal manifest fits a small context window', () => {
|
|
151
|
+
it('minimal: schemas + instructions stay under 3k estimated tokens', async () => {
|
|
152
|
+
const { count, chars, instructionsChars } = await measuredManifest('minimal');
|
|
153
|
+
const total = chars + instructionsChars;
|
|
154
|
+
// eslint-disable-next-line no-console
|
|
155
|
+
console.log(`[manifest-size] minimal: ${count} tools, ${chars} chars (~${estTokens(chars)} tokens)`
|
|
156
|
+
+ ` + instructions ${instructionsChars} chars (~${estTokens(instructionsChars)} tokens)`
|
|
157
|
+
+ ` = ~${estTokens(total)} tokens`);
|
|
158
|
+
expect(count).toBe(12);
|
|
159
|
+
expect(instructionsChars).toBeGreaterThan(100); // SDK rename guard
|
|
160
|
+
expect(estTokens(total)).toBeLessThanOrEqual(MINIMAL_MAX_TOKENS);
|
|
161
|
+
});
|
|
162
|
+
it('minimal is a strict subset of curated, which is a strict subset of full', async () => {
|
|
163
|
+
const minimal = await measuredManifest('minimal');
|
|
164
|
+
const curated = await measuredManifest();
|
|
165
|
+
const full = await measuredManifest('full');
|
|
166
|
+
expect(minimal.count).toBeLessThan(curated.count);
|
|
167
|
+
expect(curated.count).toBeLessThan(full.count);
|
|
168
|
+
expect(minimal.chars).toBeLessThan(curated.chars);
|
|
169
|
+
});
|
|
170
|
+
it('minimal instructions are a CONSTANT - a long workspace rule set cannot grow them', async () => {
|
|
171
|
+
// The tier ships the head only: no workspace rules block, so a
|
|
172
|
+
// workspace with a 200-line rule set costs the same as an empty one.
|
|
173
|
+
// (The rules are not lost - orboto_session_start returns them, on
|
|
174
|
+
// the caller's budget rather than as a connect-time tax.) Curated,
|
|
175
|
+
// by contrast, embeds them up to its 4k budget - assert BOTH so this
|
|
176
|
+
// stays a real difference and not a coincidence of the fixture.
|
|
177
|
+
const offline = await measuredManifest('minimal');
|
|
178
|
+
const hugeRules = Array.from({ length: 200 }, (_, i) => `rule ${i}: ${'x'.repeat(60)}`).join('\n');
|
|
179
|
+
vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
|
180
|
+
ok: true,
|
|
181
|
+
status: 200,
|
|
182
|
+
headers: new Headers({ 'content-type': 'application/json' }),
|
|
183
|
+
json: async () => ({ instructions: hugeRules }),
|
|
184
|
+
text: async () => JSON.stringify({ instructions: hugeRules }),
|
|
185
|
+
});
|
|
186
|
+
const loaded = await measuredManifest('minimal');
|
|
187
|
+
expect(loaded.instructionsChars).toBe(offline.instructionsChars);
|
|
188
|
+
expect(loaded.instructionsChars).toBeLessThanOrEqual(1_000);
|
|
189
|
+
const curated = await measuredManifest();
|
|
190
|
+
expect(curated.instructionsChars).toBeGreaterThan(loaded.instructionsChars);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-1180 - defensive secret-redaction on anything we persist to the
|
|
3
|
+
* admin MCP panel. The API error body is workspace error text (no
|
|
4
|
+
* secrets), but the non-API error path is `err.message` from arbitrary
|
|
5
|
+
* code and could carry a token / Authorization header / creds-in-URL.
|
|
6
|
+
* Strip the obvious shapes before the log row is written.
|
|
7
|
+
*/
|
|
8
|
+
export function redactSecrets(text) {
|
|
9
|
+
return text
|
|
10
|
+
.replace(/orb_[A-Za-z0-9_-]{8,}/g, 'orb_[redacted]')
|
|
11
|
+
.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [redacted]')
|
|
12
|
+
.replace(/eyJ[A-Za-z0-9._-]{10,}/g, '[redacted-jwt]')
|
|
13
|
+
.replace(/(https?:\/\/)[^/\s:@]+:[^/\s@]+@/gi, '$1[redacted]@');
|
|
14
|
+
}
|
|
15
|
+
export async function postLogEntry(client, entry) {
|
|
16
|
+
try {
|
|
17
|
+
await client.post('/admin/mcp/instrument', entry);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Swallow - instrumentation must never fail the tool call. If the
|
|
21
|
+
// workspace is mid-restart or rate-limited, we'd rather lose a
|
|
22
|
+
// few rows than break user-visible behaviour.
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ORB-943 - OAuth bootstrap for the stdio local-proxy.
|
|
3
|
+
*
|
|
4
|
+
* The stdio proxy (`@orboto/mcp` spawned by Claude Desktop / Cursor) was
|
|
5
|
+
* PAT-only: the operator had to paste a long-lived `orb_*` key into the
|
|
6
|
+
* client config. This module gives the stdio proxy the SAME short-lived,
|
|
7
|
+
* SSO-backed OAuth path the HTTP transport already offers - a browser-assisted
|
|
8
|
+
* loopback flow (RFC 8252) so a desktop user connects without ever pasting a
|
|
9
|
+
* token. The PAT stays the documented fallback for headless service accounts
|
|
10
|
+
* (see index.ts).
|
|
11
|
+
*
|
|
12
|
+
* Design:
|
|
13
|
+
* - No new API surface. This reuses the shipped provider-side OAuth stack
|
|
14
|
+
* (discovery + DCR + PKCE + rotating refresh) exactly as an HTTP MCP client
|
|
15
|
+
* would: discover -> register -> authorize-in-browser -> exchange -> use
|
|
16
|
+
* the access token as the REST Bearer (the api's `authenticate` decorator
|
|
17
|
+
* already accepts OAuth access tokens alongside `orb_*` keys).
|
|
18
|
+
* - The refresh token is cached on disk (0600) keyed by the instance origin,
|
|
19
|
+
* so a subsequent boot refreshes silently instead of re-opening a browser.
|
|
20
|
+
* - The pure pieces (PKCE, request building, response parsing, the cache
|
|
21
|
+
* round-trip, the token-provider refresh math) are exported so they unit
|
|
22
|
+
* test without a browser or a live server. The loopback listener + browser
|
|
23
|
+
* open is the thin I/O shell.
|
|
24
|
+
*/
|
|
25
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
26
|
+
import { createServer } from 'node:http';
|
|
27
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { dirname, join } from 'node:path';
|
|
30
|
+
import { VERSION } from './version.js';
|
|
31
|
+
/** The single required capability scope + offline_access so the AS mints a
|
|
32
|
+
* refresh token we can cache (without it the connection dies after the 1h
|
|
33
|
+
* access-token TTL and forces a browser re-auth). Mirrors the HTTP client. */
|
|
34
|
+
export const BOOTSTRAP_SCOPE = 'mcp offline_access';
|
|
35
|
+
/** Skew applied when deciding whether a cached access token is still usable.
|
|
36
|
+
* A token within this window of expiry is treated as expired so we refresh
|
|
37
|
+
* proactively rather than racing a mid-request 401. */
|
|
38
|
+
export const EXPIRY_SKEW_MS = 60_000;
|
|
39
|
+
/** Generate a PKCE verifier + S256 challenge. Verifier is 32 random bytes
|
|
40
|
+
* base64url (43 chars), well within the RFC's 43-128 range. */
|
|
41
|
+
export function generatePkce() {
|
|
42
|
+
const verifier = randomBytes(32).toString('base64url');
|
|
43
|
+
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
|
44
|
+
return { verifier, challenge, method: 'S256' };
|
|
45
|
+
}
|
|
46
|
+
/** Random opaque state to bind the browser round-trip against CSRF. */
|
|
47
|
+
export function generateState() {
|
|
48
|
+
return randomBytes(16).toString('base64url');
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Origin derivation + discovery
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
/**
|
|
54
|
+
* The OAuth authorization server lives at the instance ORIGIN, while the stdio
|
|
55
|
+
* proxy is configured with the REST base (`<origin>/api`). Strip a trailing
|
|
56
|
+
* `/api` (single-host reverse-proxy layout, ORB-938) to recover the origin the
|
|
57
|
+
* well-known metadata is served from. A base that is already the bare origin is
|
|
58
|
+
* returned unchanged.
|
|
59
|
+
*/
|
|
60
|
+
export function deriveOrigin(apiBaseUrl) {
|
|
61
|
+
const trimmed = apiBaseUrl.replace(/\/+$/, '');
|
|
62
|
+
return trimmed.replace(/\/api$/, '');
|
|
63
|
+
}
|
|
64
|
+
/** Fetch + validate the RFC 8414 authorization-server metadata. */
|
|
65
|
+
export async function discoverAuthServer(origin, fetchImpl = fetch) {
|
|
66
|
+
const url = `${origin.replace(/\/+$/, '')}/.well-known/oauth-authorization-server`;
|
|
67
|
+
const res = await fetchImpl(url, { headers: { Accept: 'application/json' } });
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
throw new Error(`OAuth discovery failed: ${res.status} from ${url}`);
|
|
70
|
+
}
|
|
71
|
+
const meta = (await res.json());
|
|
72
|
+
if (!meta.authorization_endpoint || !meta.token_endpoint || !meta.registration_endpoint || !meta.issuer) {
|
|
73
|
+
throw new Error(`OAuth discovery returned incomplete metadata from ${url}`);
|
|
74
|
+
}
|
|
75
|
+
return meta;
|
|
76
|
+
}
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Dynamic Client Registration (RFC 7591)
|
|
79
|
+
// ---------------------------------------------------------------------------
|
|
80
|
+
/** Register a public client for the loopback redirect. Returns the client_id. */
|
|
81
|
+
export async function registerLoopbackClient(registrationEndpoint, redirectUri, fetchImpl = fetch) {
|
|
82
|
+
const res = await fetchImpl(registrationEndpoint, {
|
|
83
|
+
method: 'POST',
|
|
84
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
client_name: `orboto-mcp stdio proxy ${VERSION}`,
|
|
87
|
+
redirect_uris: [redirectUri],
|
|
88
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
89
|
+
response_types: ['code'],
|
|
90
|
+
token_endpoint_auth_method: 'none',
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
if (!res.ok) {
|
|
94
|
+
const body = await res.text().catch(() => '');
|
|
95
|
+
throw new Error(`OAuth client registration failed: ${res.status} ${body}`);
|
|
96
|
+
}
|
|
97
|
+
const json = (await res.json());
|
|
98
|
+
if (!json.client_id)
|
|
99
|
+
throw new Error('OAuth client registration returned no client_id');
|
|
100
|
+
return json.client_id;
|
|
101
|
+
}
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Authorize URL + token exchange
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
export function buildAuthorizeUrl(authorizationEndpoint, params) {
|
|
106
|
+
const u = new URL(authorizationEndpoint);
|
|
107
|
+
u.searchParams.set('response_type', 'code');
|
|
108
|
+
u.searchParams.set('client_id', params.clientId);
|
|
109
|
+
u.searchParams.set('redirect_uri', params.redirectUri);
|
|
110
|
+
u.searchParams.set('scope', params.scope);
|
|
111
|
+
u.searchParams.set('code_challenge', params.challenge);
|
|
112
|
+
u.searchParams.set('code_challenge_method', 'S256');
|
|
113
|
+
u.searchParams.set('state', params.state);
|
|
114
|
+
return u.toString();
|
|
115
|
+
}
|
|
116
|
+
function parseTokenResponse(json) {
|
|
117
|
+
const t = json;
|
|
118
|
+
if (!t.access_token)
|
|
119
|
+
throw new Error('OAuth token response missing access_token');
|
|
120
|
+
const expiresInMs = (typeof t.expires_in === 'number' ? t.expires_in : 3600) * 1000;
|
|
121
|
+
return {
|
|
122
|
+
accessToken: t.access_token,
|
|
123
|
+
refreshToken: t.refresh_token ?? null,
|
|
124
|
+
expiresAt: Date.now() + expiresInMs,
|
|
125
|
+
scope: t.scope ?? BOOTSTRAP_SCOPE,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export async function exchangeAuthCode(tokenEndpoint, params, fetchImpl = fetch) {
|
|
129
|
+
const res = await fetchImpl(tokenEndpoint, {
|
|
130
|
+
method: 'POST',
|
|
131
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
132
|
+
body: JSON.stringify({
|
|
133
|
+
grant_type: 'authorization_code',
|
|
134
|
+
code: params.code,
|
|
135
|
+
redirect_uri: params.redirectUri,
|
|
136
|
+
client_id: params.clientId,
|
|
137
|
+
code_verifier: params.verifier,
|
|
138
|
+
}),
|
|
139
|
+
});
|
|
140
|
+
if (!res.ok) {
|
|
141
|
+
const body = await res.text().catch(() => '');
|
|
142
|
+
throw new Error(`OAuth code exchange failed: ${res.status} ${body}`);
|
|
143
|
+
}
|
|
144
|
+
return parseTokenResponse(await res.json());
|
|
145
|
+
}
|
|
146
|
+
export async function refreshTokens(tokenEndpoint, params, fetchImpl = fetch) {
|
|
147
|
+
const res = await fetchImpl(tokenEndpoint, {
|
|
148
|
+
method: 'POST',
|
|
149
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
150
|
+
body: JSON.stringify({
|
|
151
|
+
grant_type: 'refresh_token',
|
|
152
|
+
refresh_token: params.refreshToken,
|
|
153
|
+
client_id: params.clientId,
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
if (!res.ok) {
|
|
157
|
+
const body = await res.text().catch(() => '');
|
|
158
|
+
throw new Error(`OAuth token refresh failed: ${res.status} ${body}`);
|
|
159
|
+
}
|
|
160
|
+
return parseTokenResponse(await res.json());
|
|
161
|
+
}
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Token cache (0600, keyed by origin)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
/** Cache file path. Honours ORBOTO_MCP_TOKEN_CACHE for tests / custom homes. */
|
|
166
|
+
export function tokenCachePath() {
|
|
167
|
+
const override = process.env.ORBOTO_MCP_TOKEN_CACHE;
|
|
168
|
+
if (override)
|
|
169
|
+
return override;
|
|
170
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
171
|
+
return join(base, 'orboto', 'mcp-oauth.json');
|
|
172
|
+
}
|
|
173
|
+
function readCacheFile(path) {
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return {};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
export function loadCachedGrant(origin, path = tokenCachePath()) {
|
|
182
|
+
const file = readCacheFile(path);
|
|
183
|
+
return file[origin] ?? null;
|
|
184
|
+
}
|
|
185
|
+
export function saveCachedGrant(origin, grant, path = tokenCachePath()) {
|
|
186
|
+
const file = readCacheFile(path);
|
|
187
|
+
file[origin] = grant;
|
|
188
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
189
|
+
writeFileSync(path, JSON.stringify(file, null, 2), { mode: 0o600 });
|
|
190
|
+
// writeFileSync's mode only applies on create; force 0600 on an existing file
|
|
191
|
+
// too so a cache written before this code shipped is tightened.
|
|
192
|
+
try {
|
|
193
|
+
chmodSync(path, 0o600);
|
|
194
|
+
}
|
|
195
|
+
catch { /* best-effort on platforms without chmod */ }
|
|
196
|
+
}
|
|
197
|
+
export function clearCachedGrant(origin, path = tokenCachePath()) {
|
|
198
|
+
const file = readCacheFile(path);
|
|
199
|
+
if (!(origin in file))
|
|
200
|
+
return;
|
|
201
|
+
delete file[origin];
|
|
202
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
203
|
+
writeFileSync(path, JSON.stringify(file, null, 2), { mode: 0o600 });
|
|
204
|
+
try {
|
|
205
|
+
chmodSync(path, 0o600);
|
|
206
|
+
}
|
|
207
|
+
catch { /* best-effort */ }
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Build a token provider around an initial token set + a refresh closure.
|
|
211
|
+
* Kept pure (no I/O of its own beyond the injected refresh fn) so the
|
|
212
|
+
* refresh-on-expiry logic unit tests deterministically with a fake clock.
|
|
213
|
+
*/
|
|
214
|
+
export function createTokenProvider(initial, refresh, onRefreshed, now = Date.now) {
|
|
215
|
+
let current = initial;
|
|
216
|
+
let inflight = null;
|
|
217
|
+
async function doRefresh() {
|
|
218
|
+
if (!current.refreshToken) {
|
|
219
|
+
throw new Error('OAuth session expired and no refresh token is available - reconnect the client.');
|
|
220
|
+
}
|
|
221
|
+
const next = await refresh(current.refreshToken);
|
|
222
|
+
// The AS rotates the refresh token; if a response omits it (shouldn't with
|
|
223
|
+
// offline_access) keep the prior one so the chain isn't lost.
|
|
224
|
+
current = { ...next, refreshToken: next.refreshToken ?? current.refreshToken };
|
|
225
|
+
onRefreshed?.(current);
|
|
226
|
+
return current.accessToken;
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
async getAccessToken() {
|
|
230
|
+
if (now() < current.expiresAt - EXPIRY_SKEW_MS)
|
|
231
|
+
return current.accessToken;
|
|
232
|
+
if (!inflight)
|
|
233
|
+
inflight = doRefresh().finally(() => { inflight = null; });
|
|
234
|
+
return inflight;
|
|
235
|
+
},
|
|
236
|
+
async forceRefresh() {
|
|
237
|
+
if (!inflight)
|
|
238
|
+
inflight = doRefresh().finally(() => { inflight = null; });
|
|
239
|
+
return inflight;
|
|
240
|
+
},
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
// Interactive loopback flow (the I/O shell)
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
/** Open a URL in the user's default browser. Returns false if no opener is
|
|
247
|
+
* available (headless) so the caller can print the URL for manual paste. */
|
|
248
|
+
export async function openInBrowser(url) {
|
|
249
|
+
if (process.env.ORBOTO_MCP_NO_BROWSER === '1')
|
|
250
|
+
return false;
|
|
251
|
+
const platform = process.platform;
|
|
252
|
+
const cmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
253
|
+
const args = platform === 'win32' ? ['/c', 'start', '""', url] : [url];
|
|
254
|
+
try {
|
|
255
|
+
const { spawn } = await import('node:child_process');
|
|
256
|
+
const child = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
257
|
+
child.unref();
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Run the full browser-assisted loopback authorization once: spin an ephemeral
|
|
266
|
+
* loopback listener, open the consent URL, wait for the redirect carrying the
|
|
267
|
+
* code, exchange it. Returns the fresh token set + the client_id used (so the
|
|
268
|
+
* caller can persist the grant). Logs go to stderr (stdout is the JSON-RPC
|
|
269
|
+
* channel in stdio mode).
|
|
270
|
+
*/
|
|
271
|
+
export async function runLoopbackAuthorization(opts) {
|
|
272
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
273
|
+
const log = opts.log ?? ((m) => process.stderr.write(`${m}\n`));
|
|
274
|
+
const open = opts.openBrowser ?? openInBrowser;
|
|
275
|
+
const scope = opts.scope ?? BOOTSTRAP_SCOPE;
|
|
276
|
+
const pkce = generatePkce();
|
|
277
|
+
const state = generateState();
|
|
278
|
+
return await new Promise((resolve, reject) => {
|
|
279
|
+
const server = createServer();
|
|
280
|
+
let settled = false;
|
|
281
|
+
const timeout = setTimeout(() => {
|
|
282
|
+
if (settled)
|
|
283
|
+
return;
|
|
284
|
+
settled = true;
|
|
285
|
+
server.close();
|
|
286
|
+
reject(new Error('OAuth authorization timed out waiting for the browser redirect.'));
|
|
287
|
+
}, opts.timeoutMs ?? 5 * 60_000);
|
|
288
|
+
server.on('request', async (req, res) => {
|
|
289
|
+
try {
|
|
290
|
+
const reqUrl = new URL(req.url ?? '/', 'http://127.0.0.1');
|
|
291
|
+
if (reqUrl.pathname !== '/callback') {
|
|
292
|
+
res.writeHead(404).end('Not found');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
const code = reqUrl.searchParams.get('code');
|
|
296
|
+
const returnedState = reqUrl.searchParams.get('state');
|
|
297
|
+
const err = reqUrl.searchParams.get('error');
|
|
298
|
+
if (err)
|
|
299
|
+
throw new Error(`Authorization denied: ${err}`);
|
|
300
|
+
if (!code)
|
|
301
|
+
throw new Error('Authorization callback missing code');
|
|
302
|
+
if (returnedState !== state)
|
|
303
|
+
throw new Error('Authorization state mismatch (possible CSRF)');
|
|
304
|
+
const addr = server.address();
|
|
305
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
306
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
307
|
+
const clientId = server._orbotoClientId;
|
|
308
|
+
const tokens = await exchangeAuthCode(opts.meta.token_endpoint, { clientId, code, redirectUri, verifier: pkce.verifier }, fetchImpl);
|
|
309
|
+
res.writeHead(200, { 'Content-Type': 'text/html' }).end('<!doctype html><meta charset="utf-8"><title>orboto</title>' +
|
|
310
|
+
'<body style="font-family:system-ui;padding:3rem;text-align:center">' +
|
|
311
|
+
'<h2>Connected to orboto</h2><p>You can close this tab and return to your AI client.</p></body>');
|
|
312
|
+
if (!settled) {
|
|
313
|
+
settled = true;
|
|
314
|
+
clearTimeout(timeout);
|
|
315
|
+
server.close();
|
|
316
|
+
resolve({ tokens, clientId });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' }).end(e.message);
|
|
321
|
+
if (!settled) {
|
|
322
|
+
settled = true;
|
|
323
|
+
clearTimeout(timeout);
|
|
324
|
+
server.close();
|
|
325
|
+
reject(e);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
server.on('error', (e) => {
|
|
330
|
+
if (settled)
|
|
331
|
+
return;
|
|
332
|
+
settled = true;
|
|
333
|
+
clearTimeout(timeout);
|
|
334
|
+
reject(e);
|
|
335
|
+
});
|
|
336
|
+
// Bind to an ephemeral loopback port, THEN register the client at that exact
|
|
337
|
+
// redirect_uri (the api allows any loopback port at authorize time per RFC
|
|
338
|
+
// 8252, but DCR still needs a concrete uri).
|
|
339
|
+
server.listen(0, '127.0.0.1', async () => {
|
|
340
|
+
try {
|
|
341
|
+
const addr = server.address();
|
|
342
|
+
const port = typeof addr === 'object' && addr ? addr.port : 0;
|
|
343
|
+
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
|
344
|
+
const clientId = await registerLoopbackClient(opts.meta.registration_endpoint, redirectUri, fetchImpl);
|
|
345
|
+
server._orbotoClientId = clientId;
|
|
346
|
+
const authorizeUrl = buildAuthorizeUrl(opts.meta.authorization_endpoint, {
|
|
347
|
+
clientId, redirectUri, challenge: pkce.challenge, state, scope,
|
|
348
|
+
});
|
|
349
|
+
const opened = await open(authorizeUrl);
|
|
350
|
+
if (opened) {
|
|
351
|
+
log(`[orboto-mcp] opened your browser to authorize. If it did not open, visit:\n${authorizeUrl}`);
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
log(`[orboto-mcp] open this URL in a browser to authorize:\n${authorizeUrl}`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
catch (e) {
|
|
358
|
+
if (settled)
|
|
359
|
+
return;
|
|
360
|
+
settled = true;
|
|
361
|
+
clearTimeout(timeout);
|
|
362
|
+
server.close();
|
|
363
|
+
reject(e);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
// Top-level bootstrap: cache -> refresh -> interactive, returns a provider
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
/**
|
|
372
|
+
* Resolve an OAuth token provider for the stdio proxy. Order:
|
|
373
|
+
* 1. A cached grant whose refresh token still works -> silent refresh.
|
|
374
|
+
* 2. Otherwise run the interactive browser-assisted loopback flow once.
|
|
375
|
+
* Either way the resulting grant is persisted (0600) so the next boot is
|
|
376
|
+
* silent. The returned provider auto-refreshes and re-persists on rotation.
|
|
377
|
+
*/
|
|
378
|
+
export async function bootstrapOAuth(opts) {
|
|
379
|
+
const fetchImpl = opts.fetchImpl ?? fetch;
|
|
380
|
+
const log = opts.log ?? ((m) => process.stderr.write(`${m}\n`));
|
|
381
|
+
const origin = deriveOrigin(opts.apiBaseUrl);
|
|
382
|
+
const cachePath = opts.cachePath ?? tokenCachePath();
|
|
383
|
+
const persist = (clientId, tokenEndpoint, tokens) => {
|
|
384
|
+
if (tokens.refreshToken) {
|
|
385
|
+
saveCachedGrant(origin, {
|
|
386
|
+
clientId, tokenEndpoint, refreshToken: tokens.refreshToken, scope: tokens.scope,
|
|
387
|
+
}, cachePath);
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
const cached = loadCachedGrant(origin, cachePath);
|
|
391
|
+
if (cached) {
|
|
392
|
+
try {
|
|
393
|
+
const tokens = await refreshTokens(cached.tokenEndpoint, {
|
|
394
|
+
clientId: cached.clientId, refreshToken: cached.refreshToken,
|
|
395
|
+
}, fetchImpl);
|
|
396
|
+
persist(cached.clientId, cached.tokenEndpoint, tokens);
|
|
397
|
+
log('[orboto-mcp] reconnected via cached OAuth session (no browser needed)');
|
|
398
|
+
return createTokenProvider(tokens, (rt) => refreshTokens(cached.tokenEndpoint, { clientId: cached.clientId, refreshToken: rt }, fetchImpl), (next) => persist(cached.clientId, cached.tokenEndpoint, next));
|
|
399
|
+
}
|
|
400
|
+
catch (e) {
|
|
401
|
+
// A revoked / expired refresh token drops us back to interactive.
|
|
402
|
+
log(`[orboto-mcp] cached OAuth session no longer valid (${e.message}); re-authorizing`);
|
|
403
|
+
clearCachedGrant(origin, cachePath);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const meta = await discoverAuthServer(origin, fetchImpl);
|
|
407
|
+
const { tokens, clientId } = await runLoopbackAuthorization({
|
|
408
|
+
meta, log, openBrowser: opts.openBrowser, fetchImpl,
|
|
409
|
+
});
|
|
410
|
+
persist(clientId, meta.token_endpoint, tokens);
|
|
411
|
+
log('[orboto-mcp] OAuth authorization complete');
|
|
412
|
+
return createTokenProvider(tokens, (rt) => refreshTokens(meta.token_endpoint, { clientId, refreshToken: rt }, fetchImpl), (next) => persist(clientId, meta.token_endpoint, next));
|
|
413
|
+
}
|