Skip to content

Commit f7ae222

Browse files
BunsDevclaude
andcommitted
refactor: split mcp/remote.mjs into oauth + session + sse
The 253-line remote.mjs had four overlapping concerns: the public HTTP/SSE dispatcher, persistent session tracking, OAuth credentials, and SSE/legacy fallback. Split into: - remote.mjs (74) — public surface (queryRemoteMcpTools, callRemoteMcpTool, readRemoteMcpResource) + postRemoteMcp dispatcher - remote-session.mjs (54) — remoteMcpSessions map, initializeRemoteMcpSession, rememberRemoteMcpSession, remoteMcpSessionKey, remoteMcpHeaders (composes oauth + session) - remote-oauth.mjs (55) — oauthMcpHeaders, readMcpOauthCredential, refreshMcpOauthToken, hasExplicitAuthorizationHeader, mcpOauthCredentialPath - remote-sse.mjs (82) — postLegacySseMcp, discoverLegacySseEndpoint, parseLegacySseEndpoint, resolveRemoteMcpUrl, parseRemoteMcpResponse Dep graph is one-way: remote → {session, oauth, sse}; session → oauth; sse → {session, oauth}. No cycles. Public exports unchanged; tests pass 315/315. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 76250dd commit f7ae222

4 files changed

Lines changed: 200 additions & 188 deletions

File tree

src/mcp/remote-oauth.mjs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
4+
5+
export function oauthMcpHeaders(serverName = '') {
6+
const credential = readMcpOauthCredential(serverName);
7+
return credential.accessToken || credential.access_token ? { Authorization: `Bearer ${credential.accessToken ?? credential.access_token}` } : {};
8+
}
9+
10+
function readMcpOauthCredential(serverName = '') {
11+
if (!serverName) return {};
12+
try {
13+
return JSON.parse(readFileSync(mcpOauthCredentialPath(serverName), 'utf8'));
14+
} catch {
15+
return {};
16+
}
17+
}
18+
19+
export async function refreshMcpOauthToken(serverName = '', config = {}) {
20+
if (hasExplicitAuthorizationHeader(config)) return false;
21+
const credential = readMcpOauthCredential(serverName);
22+
const refreshToken = credential.refreshToken ?? credential.refresh_token;
23+
const tokenUrl = credential.tokenUrl ?? credential.token_url;
24+
if (!refreshToken || !tokenUrl) return false;
25+
const response = await fetch(tokenUrl, {
26+
method: 'POST',
27+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
28+
body: new URLSearchParams({
29+
grant_type: 'refresh_token',
30+
refresh_token: refreshToken,
31+
...(credential.clientId || credential.client_id ? { client_id: credential.clientId ?? credential.client_id } : {}),
32+
...(credential.clientSecret || credential.client_secret ? { client_secret: credential.clientSecret ?? credential.client_secret } : {}),
33+
}),
34+
});
35+
if (!response.ok) return false;
36+
const token = await response.json();
37+
const nextCredential = {
38+
...credential,
39+
accessToken: token.access_token ?? token.accessToken ?? credential.accessToken,
40+
refreshToken: token.refresh_token ?? token.refreshToken ?? credential.refreshToken,
41+
...(token.expires_in || token.expiresIn ? { expiresAt: Date.now() + Number(token.expires_in ?? token.expiresIn) * 1000 } : {}),
42+
};
43+
const credentialPath = mcpOauthCredentialPath(serverName);
44+
mkdirSync(path.dirname(credentialPath), { recursive: true, mode: 0o700 });
45+
writeFileSync(credentialPath, `${JSON.stringify(nextCredential, null, 2)}\n`, { mode: 0o600 });
46+
return Boolean(nextCredential.accessToken);
47+
}
48+
49+
function hasExplicitAuthorizationHeader(config = {}) {
50+
return Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization');
51+
}
52+
53+
function mcpOauthCredentialPath(serverName) {
54+
return path.join(os.homedir(), '.coven-code', 'oauth', `${serverName}.json`);
55+
}

src/mcp/remote-session.mjs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { oauthMcpHeaders } from './remote-oauth.mjs';
2+
3+
export const remoteMcpSessions = new Map();
4+
5+
export function remoteMcpHeaders(config = {}, accept, serverName = '') {
6+
return {
7+
'content-type': 'application/json',
8+
accept,
9+
...oauthMcpHeaders(serverName),
10+
...remoteMcpSessionHeader(config, serverName),
11+
...(config.headers ?? {}),
12+
};
13+
}
14+
15+
export async function initializeRemoteMcpSession(config = {}, serverName = '', signal) {
16+
const response = await fetch(config.url, {
17+
method: 'POST',
18+
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
19+
body: JSON.stringify({
20+
jsonrpc: '2.0',
21+
id: 0,
22+
method: 'initialize',
23+
params: {
24+
protocolVersion: '2025-06-18',
25+
capabilities: {},
26+
clientInfo: { name: 'coven-code', version: '0.0.0' },
27+
},
28+
}),
29+
signal,
30+
});
31+
rememberRemoteMcpSession(config, serverName, response);
32+
if (!response.ok || !remoteMcpSessions.has(remoteMcpSessionKey(config, serverName))) return false;
33+
await fetch(config.url, {
34+
method: 'POST',
35+
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
36+
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
37+
signal,
38+
});
39+
return true;
40+
}
41+
42+
export function rememberRemoteMcpSession(config = {}, serverName = '', response) {
43+
const sessionId = response.headers.get('mcp-session-id');
44+
if (sessionId) remoteMcpSessions.set(remoteMcpSessionKey(config, serverName), sessionId);
45+
}
46+
47+
function remoteMcpSessionHeader(config = {}, serverName = '') {
48+
const sessionId = remoteMcpSessions.get(remoteMcpSessionKey(config, serverName));
49+
return sessionId ? { 'Mcp-Session-Id': sessionId } : {};
50+
}
51+
52+
export function remoteMcpSessionKey(config = {}, serverName = '') {
53+
return `${serverName}\n${config.url ?? ''}`;
54+
}

src/mcp/remote-sse.mjs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { refreshMcpOauthToken } from './remote-oauth.mjs';
2+
import { rememberRemoteMcpSession, remoteMcpHeaders } from './remote-session.mjs';
3+
4+
export async function postLegacySseMcp(config = {}, body, signal, serverName = '') {
5+
const endpoint = await discoverLegacySseEndpoint(config, signal, serverName);
6+
if (!endpoint) return {};
7+
const response = await fetch(endpoint, {
8+
method: 'POST',
9+
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
10+
body,
11+
signal,
12+
});
13+
rememberRemoteMcpSession(config, serverName, response);
14+
if (response.status === 401 && await refreshMcpOauthToken(serverName, config)) {
15+
const retry = await fetch(endpoint, {
16+
method: 'POST',
17+
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
18+
body,
19+
signal,
20+
});
21+
rememberRemoteMcpSession(config, serverName, retry);
22+
return parseRemoteMcpResponse(await retry.text());
23+
}
24+
return parseRemoteMcpResponse(await response.text());
25+
}
26+
27+
async function discoverLegacySseEndpoint(config = {}, signal, serverName = '') {
28+
const response = await fetch(config.url, {
29+
method: 'GET',
30+
headers: remoteMcpHeaders(config, 'text/event-stream', serverName),
31+
signal,
32+
});
33+
if (!response.ok) return '';
34+
return resolveRemoteMcpUrl(config.url, parseLegacySseEndpoint(await response.text()));
35+
}
36+
37+
function parseLegacySseEndpoint(text = '') {
38+
let event = 'message';
39+
const data = [];
40+
for (const line of text.split(/\r?\n/)) {
41+
if (!line) {
42+
if (event === 'endpoint' && data.length) return data.join('\n').trim();
43+
event = 'message';
44+
data.length = 0;
45+
continue;
46+
}
47+
if (line.startsWith(':')) continue;
48+
const separator = line.indexOf(':');
49+
const field = separator === -1 ? line : line.slice(0, separator);
50+
const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '');
51+
if (field === 'event') event = value;
52+
if (field === 'data') data.push(value);
53+
}
54+
if (event === 'endpoint' && data.length) return data.join('\n').trim();
55+
return '';
56+
}
57+
58+
function resolveRemoteMcpUrl(base, endpoint) {
59+
if (!endpoint) return '';
60+
try {
61+
return new URL(endpoint, base).href;
62+
} catch {
63+
return '';
64+
}
65+
}
66+
67+
export function parseRemoteMcpResponse(text = '') {
68+
for (const chunk of text.split(/\r?\n/).filter(Boolean)) {
69+
const line = chunk.startsWith('data:') ? chunk.slice('data:'.length).trim() : chunk.trim();
70+
if (!line || line === '[DONE]') continue;
71+
try {
72+
return JSON.parse(line);
73+
} catch {
74+
// Remote MCP servers can include diagnostic or event wrapper lines.
75+
}
76+
}
77+
try {
78+
return JSON.parse(text);
79+
} catch {
80+
return {};
81+
}
82+
}

src/mcp/remote.mjs

Lines changed: 9 additions & 188 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
1-
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2-
import os from 'node:os';
3-
import path from 'node:path';
41
import { parseMcpCallOutput, parseMcpResourceOutput } from './parsers.mjs';
5-
6-
const remoteMcpSessions = new Map();
2+
import { refreshMcpOauthToken } from './remote-oauth.mjs';
3+
import {
4+
initializeRemoteMcpSession,
5+
rememberRemoteMcpSession,
6+
remoteMcpHeaders,
7+
remoteMcpSessionKey,
8+
remoteMcpSessions,
9+
} from './remote-session.mjs';
10+
import { parseRemoteMcpResponse, postLegacySseMcp } from './remote-sse.mjs';
711

812
export async function queryRemoteMcpTools(config = {}, serverName = '') {
913
const message = await postRemoteMcp(config, 'tools/list', {}, serverName);
@@ -68,186 +72,3 @@ async function postRemoteMcp(config = {}, method, params, serverName = '') {
6872
clearTimeout(timeout);
6973
}
7074
}
71-
72-
async function postLegacySseMcp(config = {}, body, signal, serverName = '') {
73-
const endpoint = await discoverLegacySseEndpoint(config, signal, serverName);
74-
if (!endpoint) return {};
75-
const response = await fetch(endpoint, {
76-
method: 'POST',
77-
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
78-
body,
79-
signal,
80-
});
81-
rememberRemoteMcpSession(config, serverName, response);
82-
if (response.status === 401 && await refreshMcpOauthToken(serverName, config)) {
83-
const retry = await fetch(endpoint, {
84-
method: 'POST',
85-
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
86-
body,
87-
signal,
88-
});
89-
rememberRemoteMcpSession(config, serverName, retry);
90-
return parseRemoteMcpResponse(await retry.text());
91-
}
92-
return parseRemoteMcpResponse(await response.text());
93-
}
94-
95-
async function discoverLegacySseEndpoint(config = {}, signal, serverName = '') {
96-
const response = await fetch(config.url, {
97-
method: 'GET',
98-
headers: remoteMcpHeaders(config, 'text/event-stream', serverName),
99-
signal,
100-
});
101-
if (!response.ok) return '';
102-
return resolveRemoteMcpUrl(config.url, parseLegacySseEndpoint(await response.text()));
103-
}
104-
105-
function remoteMcpHeaders(config = {}, accept, serverName = '') {
106-
return {
107-
'content-type': 'application/json',
108-
accept,
109-
...oauthMcpHeaders(serverName),
110-
...remoteMcpSessionHeader(config, serverName),
111-
...(config.headers ?? {}),
112-
};
113-
}
114-
115-
async function initializeRemoteMcpSession(config = {}, serverName = '', signal) {
116-
const response = await fetch(config.url, {
117-
method: 'POST',
118-
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
119-
body: JSON.stringify({
120-
jsonrpc: '2.0',
121-
id: 0,
122-
method: 'initialize',
123-
params: {
124-
protocolVersion: '2025-06-18',
125-
capabilities: {},
126-
clientInfo: { name: 'coven-code', version: '0.0.0' },
127-
},
128-
}),
129-
signal,
130-
});
131-
rememberRemoteMcpSession(config, serverName, response);
132-
if (!response.ok || !remoteMcpSessions.has(remoteMcpSessionKey(config, serverName))) return false;
133-
await fetch(config.url, {
134-
method: 'POST',
135-
headers: remoteMcpHeaders(config, 'application/json, text/event-stream', serverName),
136-
body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' }),
137-
signal,
138-
});
139-
return true;
140-
}
141-
142-
function rememberRemoteMcpSession(config = {}, serverName = '', response) {
143-
const sessionId = response.headers.get('mcp-session-id');
144-
if (sessionId) remoteMcpSessions.set(remoteMcpSessionKey(config, serverName), sessionId);
145-
}
146-
147-
function remoteMcpSessionHeader(config = {}, serverName = '') {
148-
const sessionId = remoteMcpSessions.get(remoteMcpSessionKey(config, serverName));
149-
return sessionId ? { 'Mcp-Session-Id': sessionId } : {};
150-
}
151-
152-
function remoteMcpSessionKey(config = {}, serverName = '') {
153-
return `${serverName}\n${config.url ?? ''}`;
154-
}
155-
156-
function oauthMcpHeaders(serverName = '') {
157-
const credential = readMcpOauthCredential(serverName);
158-
return credential.accessToken || credential.access_token ? { Authorization: `Bearer ${credential.accessToken ?? credential.access_token}` } : {};
159-
}
160-
161-
function readMcpOauthCredential(serverName = '') {
162-
if (!serverName) return {};
163-
try {
164-
return JSON.parse(readFileSync(mcpOauthCredentialPath(serverName), 'utf8'));
165-
} catch {
166-
return {};
167-
}
168-
}
169-
170-
async function refreshMcpOauthToken(serverName = '', config = {}) {
171-
if (hasExplicitAuthorizationHeader(config)) return false;
172-
const credential = readMcpOauthCredential(serverName);
173-
const refreshToken = credential.refreshToken ?? credential.refresh_token;
174-
const tokenUrl = credential.tokenUrl ?? credential.token_url;
175-
if (!refreshToken || !tokenUrl) return false;
176-
const response = await fetch(tokenUrl, {
177-
method: 'POST',
178-
headers: { 'content-type': 'application/x-www-form-urlencoded' },
179-
body: new URLSearchParams({
180-
grant_type: 'refresh_token',
181-
refresh_token: refreshToken,
182-
...(credential.clientId || credential.client_id ? { client_id: credential.clientId ?? credential.client_id } : {}),
183-
...(credential.clientSecret || credential.client_secret ? { client_secret: credential.clientSecret ?? credential.client_secret } : {}),
184-
}),
185-
});
186-
if (!response.ok) return false;
187-
const token = await response.json();
188-
const nextCredential = {
189-
...credential,
190-
accessToken: token.access_token ?? token.accessToken ?? credential.accessToken,
191-
refreshToken: token.refresh_token ?? token.refreshToken ?? credential.refreshToken,
192-
...(token.expires_in || token.expiresIn ? { expiresAt: Date.now() + Number(token.expires_in ?? token.expiresIn) * 1000 } : {}),
193-
};
194-
const credentialPath = mcpOauthCredentialPath(serverName);
195-
mkdirSync(path.dirname(credentialPath), { recursive: true, mode: 0o700 });
196-
writeFileSync(credentialPath, `${JSON.stringify(nextCredential, null, 2)}\n`, { mode: 0o600 });
197-
return Boolean(nextCredential.accessToken);
198-
}
199-
200-
function hasExplicitAuthorizationHeader(config = {}) {
201-
return Object.keys(config.headers ?? {}).some((key) => key.toLowerCase() === 'authorization');
202-
}
203-
204-
function mcpOauthCredentialPath(serverName) {
205-
return path.join(os.homedir(), '.coven-code', 'oauth', `${serverName}.json`);
206-
}
207-
208-
function parseLegacySseEndpoint(text = '') {
209-
let event = 'message';
210-
const data = [];
211-
for (const line of text.split(/\r?\n/)) {
212-
if (!line) {
213-
if (event === 'endpoint' && data.length) return data.join('\n').trim();
214-
event = 'message';
215-
data.length = 0;
216-
continue;
217-
}
218-
if (line.startsWith(':')) continue;
219-
const separator = line.indexOf(':');
220-
const field = separator === -1 ? line : line.slice(0, separator);
221-
const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '');
222-
if (field === 'event') event = value;
223-
if (field === 'data') data.push(value);
224-
}
225-
if (event === 'endpoint' && data.length) return data.join('\n').trim();
226-
return '';
227-
}
228-
229-
function resolveRemoteMcpUrl(base, endpoint) {
230-
if (!endpoint) return '';
231-
try {
232-
return new URL(endpoint, base).href;
233-
} catch {
234-
return '';
235-
}
236-
}
237-
238-
function parseRemoteMcpResponse(text = '') {
239-
for (const chunk of text.split(/\r?\n/).filter(Boolean)) {
240-
const line = chunk.startsWith('data:') ? chunk.slice('data:'.length).trim() : chunk.trim();
241-
if (!line || line === '[DONE]') continue;
242-
try {
243-
return JSON.parse(line);
244-
} catch {
245-
// Remote MCP servers can include diagnostic or event wrapper lines.
246-
}
247-
}
248-
try {
249-
return JSON.parse(text);
250-
} catch {
251-
return {};
252-
}
253-
}

0 commit comments

Comments
 (0)