|
1 | | -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; |
2 | | -import os from 'node:os'; |
3 | | -import path from 'node:path'; |
4 | 1 | 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'; |
7 | 11 |
|
8 | 12 | export async function queryRemoteMcpTools(config = {}, serverName = '') { |
9 | 13 | const message = await postRemoteMcp(config, 'tools/list', {}, serverName); |
@@ -68,186 +72,3 @@ async function postRemoteMcp(config = {}, method, params, serverName = '') { |
68 | 72 | clearTimeout(timeout); |
69 | 73 | } |
70 | 74 | } |
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