@meldocio/mcp-stdio-proxy 1.0.7 → 1.0.10
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/lib/auth.js +142 -0
- package/lib/config.js +68 -0
- package/lib/constants.js +31 -0
- package/lib/credentials.js +96 -0
- package/lib/device-flow.js +263 -0
- package/lib/logger.js +105 -0
- package/lib/workspace.js +76 -0
- package/package.json +5 -2
package/lib/auth.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const { readCredentials, writeCredentials, isTokenExpiredOrExpiringSoon, isTokenExpired } = require('./credentials');
|
|
4
|
+
const { getApiUrl } = require('./constants');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Get access token with priority:
|
|
8
|
+
* 1. MELDOC_ACCESS_TOKEN (environment variable)
|
|
9
|
+
* 2. credentials.json (user session, with refresh if needed)
|
|
10
|
+
* 3. MELDOC_MCP_TOKEN (integration token)
|
|
11
|
+
* @returns {Promise<{token: string, type: 'env'|'user_session'|'integration'|null}>}
|
|
12
|
+
*/
|
|
13
|
+
async function getAccessToken() {
|
|
14
|
+
// Priority 1: MELDOC_ACCESS_TOKEN environment variable
|
|
15
|
+
if (process.env.MELDOC_ACCESS_TOKEN) {
|
|
16
|
+
return {
|
|
17
|
+
token: process.env.MELDOC_ACCESS_TOKEN,
|
|
18
|
+
type: 'env'
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Priority 2: credentials.json (user session)
|
|
23
|
+
const credentials = readCredentials();
|
|
24
|
+
if (credentials && credentials.type === 'user_session') {
|
|
25
|
+
// Check if token needs refresh
|
|
26
|
+
if (isTokenExpiredOrExpiringSoon(credentials)) {
|
|
27
|
+
try {
|
|
28
|
+
const refreshed = await refreshToken(credentials);
|
|
29
|
+
if (refreshed) {
|
|
30
|
+
return {
|
|
31
|
+
token: refreshed.tokens.accessToken,
|
|
32
|
+
type: 'user_session'
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
} catch (error) {
|
|
36
|
+
// Refresh failed - credentials might be invalid
|
|
37
|
+
// Return null to fall back to next priority
|
|
38
|
+
}
|
|
39
|
+
} else {
|
|
40
|
+
return {
|
|
41
|
+
token: credentials.tokens.accessToken,
|
|
42
|
+
type: 'user_session'
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Priority 3: MELDOC_MCP_TOKEN (integration token)
|
|
48
|
+
if (process.env.MELDOC_MCP_TOKEN) {
|
|
49
|
+
return {
|
|
50
|
+
token: process.env.MELDOC_MCP_TOKEN,
|
|
51
|
+
type: 'integration'
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// No token found
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Refresh access token using refresh token
|
|
61
|
+
* @param {Object} credentials - Current credentials object
|
|
62
|
+
* @returns {Promise<Object|null>} Updated credentials or null if refresh failed
|
|
63
|
+
*/
|
|
64
|
+
async function refreshToken(credentials) {
|
|
65
|
+
if (!credentials || !credentials.tokens || !credentials.tokens.refreshToken) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const apiBaseUrl = credentials.apiBaseUrl || getApiUrl();
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const response = await axios.post(`${apiBaseUrl}/api/auth/refresh`, {
|
|
73
|
+
refreshToken: credentials.tokens.refreshToken
|
|
74
|
+
}, {
|
|
75
|
+
timeout: 10000,
|
|
76
|
+
httpsAgent: new https.Agent({ keepAlive: true })
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Update credentials with new tokens
|
|
80
|
+
const updatedCredentials = {
|
|
81
|
+
...credentials,
|
|
82
|
+
tokens: {
|
|
83
|
+
accessToken: response.data.accessToken,
|
|
84
|
+
accessExpiresAt: response.data.expiresAt,
|
|
85
|
+
refreshToken: response.data.refreshToken || credentials.tokens.refreshToken
|
|
86
|
+
},
|
|
87
|
+
updatedAt: new Date().toISOString()
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
writeCredentials(updatedCredentials);
|
|
91
|
+
return updatedCredentials;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
// Refresh failed - delete credentials
|
|
94
|
+
const { deleteCredentials } = require('./credentials');
|
|
95
|
+
deleteCredentials();
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if user is authenticated
|
|
102
|
+
* @returns {Promise<boolean>} True if authenticated
|
|
103
|
+
*/
|
|
104
|
+
async function isAuthenticated() {
|
|
105
|
+
const tokenInfo = await getAccessToken();
|
|
106
|
+
return tokenInfo !== null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get auth status information
|
|
111
|
+
* @returns {Promise<Object|null>} Auth status with user info or null if not authenticated
|
|
112
|
+
*/
|
|
113
|
+
async function getAuthStatus() {
|
|
114
|
+
const tokenInfo = await getAccessToken();
|
|
115
|
+
if (!tokenInfo) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (tokenInfo.type === 'user_session') {
|
|
120
|
+
const credentials = readCredentials();
|
|
121
|
+
if (credentials && credentials.user) {
|
|
122
|
+
return {
|
|
123
|
+
authenticated: true,
|
|
124
|
+
type: 'user_session',
|
|
125
|
+
user: credentials.user,
|
|
126
|
+
expiresAt: credentials.tokens?.accessExpiresAt || null
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
authenticated: true,
|
|
133
|
+
type: tokenInfo.type
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
getAccessToken,
|
|
139
|
+
refreshToken,
|
|
140
|
+
isAuthenticated,
|
|
141
|
+
getAuthStatus
|
|
142
|
+
};
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const CONFIG_PATH = path.join(os.homedir(), '.meldoc', 'config.json');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read config from ~/.meldoc/config.json
|
|
9
|
+
* @returns {Object|null} Config object or null if file doesn't exist
|
|
10
|
+
*/
|
|
11
|
+
function readConfig() {
|
|
12
|
+
try {
|
|
13
|
+
if (!fs.existsSync(CONFIG_PATH)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const content = fs.readFileSync(CONFIG_PATH, 'utf8');
|
|
17
|
+
return JSON.parse(content);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Write config to ~/.meldoc/config.json
|
|
25
|
+
* Sets file permissions to 0644 (read for all, write for owner)
|
|
26
|
+
* @param {Object} config - Config object
|
|
27
|
+
*/
|
|
28
|
+
function writeConfig(config) {
|
|
29
|
+
const dir = path.dirname(CONFIG_PATH);
|
|
30
|
+
|
|
31
|
+
// Create .meldoc directory if it doesn't exist
|
|
32
|
+
if (!fs.existsSync(dir)) {
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Write config file
|
|
37
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', {
|
|
38
|
+
encoding: 'utf8',
|
|
39
|
+
mode: 0o644 // 0644 - read for all, write for owner
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Get workspace alias from config
|
|
45
|
+
* @returns {string|null} Workspace alias or null
|
|
46
|
+
*/
|
|
47
|
+
function getWorkspaceAlias() {
|
|
48
|
+
const config = readConfig();
|
|
49
|
+
return config?.workspaceAlias || null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Set workspace alias in config
|
|
54
|
+
* @param {string} alias - Workspace alias
|
|
55
|
+
*/
|
|
56
|
+
function setWorkspaceAlias(alias) {
|
|
57
|
+
const config = readConfig() || {};
|
|
58
|
+
config.workspaceAlias = alias;
|
|
59
|
+
writeConfig(config);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
readConfig,
|
|
64
|
+
writeConfig,
|
|
65
|
+
getWorkspaceAlias,
|
|
66
|
+
setWorkspaceAlias,
|
|
67
|
+
CONFIG_PATH
|
|
68
|
+
};
|
package/lib/constants.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default configuration constants
|
|
3
|
+
* Production values used when environment variables are not set
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Production API URL
|
|
7
|
+
const DEFAULT_API_URL = 'https://api.meldoc.io';
|
|
8
|
+
|
|
9
|
+
// Production App URL (frontend)
|
|
10
|
+
const DEFAULT_APP_URL = 'https://app.meldoc.io';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get API URL from environment or use production default
|
|
14
|
+
*/
|
|
15
|
+
function getApiUrl() {
|
|
16
|
+
return process.env.MELDOC_API_URL || DEFAULT_API_URL;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Get App URL from environment or use production default
|
|
21
|
+
*/
|
|
22
|
+
function getAppUrl() {
|
|
23
|
+
return process.env.MELDOC_APP_URL || DEFAULT_APP_URL;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = {
|
|
27
|
+
DEFAULT_API_URL,
|
|
28
|
+
DEFAULT_APP_URL,
|
|
29
|
+
getApiUrl,
|
|
30
|
+
getAppUrl
|
|
31
|
+
};
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const os = require('os');
|
|
4
|
+
|
|
5
|
+
const CREDENTIALS_PATH = path.join(os.homedir(), '.meldoc', 'credentials.json');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Read credentials from ~/.meldoc/credentials.json
|
|
9
|
+
* @returns {Object|null} Credentials object or null if file doesn't exist
|
|
10
|
+
*/
|
|
11
|
+
function readCredentials() {
|
|
12
|
+
try {
|
|
13
|
+
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const content = fs.readFileSync(CREDENTIALS_PATH, 'utf8');
|
|
17
|
+
return JSON.parse(content);
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Write credentials to ~/.meldoc/credentials.json
|
|
25
|
+
* Sets file permissions to 0600 (read/write only for owner)
|
|
26
|
+
* @param {Object} credentials - Credentials object
|
|
27
|
+
*/
|
|
28
|
+
function writeCredentials(credentials) {
|
|
29
|
+
const dir = path.dirname(CREDENTIALS_PATH);
|
|
30
|
+
|
|
31
|
+
// Create .meldoc directory if it doesn't exist
|
|
32
|
+
if (!fs.existsSync(dir)) {
|
|
33
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Write credentials file
|
|
37
|
+
fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(credentials, null, 2) + '\n', {
|
|
38
|
+
encoding: 'utf8',
|
|
39
|
+
mode: 0o600 // 0600 - read/write only for owner
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Delete credentials file
|
|
45
|
+
*/
|
|
46
|
+
function deleteCredentials() {
|
|
47
|
+
try {
|
|
48
|
+
if (fs.existsSync(CREDENTIALS_PATH)) {
|
|
49
|
+
fs.unlinkSync(CREDENTIALS_PATH);
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// Ignore errors
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Check if access token is expired or expires soon (within 5 minutes)
|
|
58
|
+
* @param {Object} credentials - Credentials object
|
|
59
|
+
* @returns {boolean} True if token is expired or expires soon
|
|
60
|
+
*/
|
|
61
|
+
function isTokenExpiredOrExpiringSoon(credentials) {
|
|
62
|
+
if (!credentials || !credentials.tokens || !credentials.tokens.accessExpiresAt) {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const expiresAt = new Date(credentials.tokens.accessExpiresAt);
|
|
67
|
+
const now = new Date();
|
|
68
|
+
const fiveMinutes = 5 * 60 * 1000; // 5 minutes in milliseconds
|
|
69
|
+
|
|
70
|
+
return expiresAt.getTime() <= (now.getTime() + fiveMinutes);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if access token is expired
|
|
75
|
+
* @param {Object} credentials - Credentials object
|
|
76
|
+
* @returns {boolean} True if token is expired
|
|
77
|
+
*/
|
|
78
|
+
function isTokenExpired(credentials) {
|
|
79
|
+
if (!credentials || !credentials.tokens || !credentials.tokens.accessExpiresAt) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const expiresAt = new Date(credentials.tokens.accessExpiresAt);
|
|
84
|
+
const now = new Date();
|
|
85
|
+
|
|
86
|
+
return expiresAt.getTime() <= now.getTime();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
readCredentials,
|
|
91
|
+
writeCredentials,
|
|
92
|
+
deleteCredentials,
|
|
93
|
+
isTokenExpiredOrExpiringSoon,
|
|
94
|
+
isTokenExpired,
|
|
95
|
+
CREDENTIALS_PATH
|
|
96
|
+
};
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
const axios = require('axios');
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const { writeCredentials } = require('./credentials');
|
|
4
|
+
const { getApiUrl, getAppUrl } = require('./constants');
|
|
5
|
+
const logger = require('./logger');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Start device flow authentication
|
|
9
|
+
* @param {string} apiBaseUrl - API base URL
|
|
10
|
+
* @returns {Promise<Object>} Device flow response with deviceCode, userCode, verificationUrl, expiresIn, interval
|
|
11
|
+
*/
|
|
12
|
+
async function startDeviceFlow(apiBaseUrl = null) {
|
|
13
|
+
const url = apiBaseUrl || getApiUrl();
|
|
14
|
+
try {
|
|
15
|
+
// Server may expect snake_case, but we'll send camelCase and let server handle it
|
|
16
|
+
const response = await axios.post(`${url}/api/auth/device/start`, {
|
|
17
|
+
client: 'mcp-stdio-proxy',
|
|
18
|
+
client_version: '1.0.0'
|
|
19
|
+
}, {
|
|
20
|
+
timeout: 10000,
|
|
21
|
+
httpsAgent: new https.Agent({ keepAlive: true })
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const data = response.data;
|
|
25
|
+
|
|
26
|
+
// Normalize response: support both camelCase and snake_case
|
|
27
|
+
const normalized = {
|
|
28
|
+
deviceCode: data.deviceCode || data.device_code,
|
|
29
|
+
userCode: data.userCode || data.user_code,
|
|
30
|
+
verificationUrl: data.verificationUrl || data.verification_url,
|
|
31
|
+
expiresIn: data.expiresIn || data.expires_in,
|
|
32
|
+
interval: data.interval
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// Validate response
|
|
36
|
+
if (!normalized.deviceCode || !normalized.userCode || !normalized.verificationUrl) {
|
|
37
|
+
throw new Error(`Invalid response from server: missing required fields. Response: ${JSON.stringify(data)}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!normalized.expiresIn || !normalized.interval) {
|
|
41
|
+
throw new Error(`Invalid response from server: missing expiresIn or interval. Response: ${JSON.stringify(data)}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return normalized;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (error.response) {
|
|
47
|
+
// Server responded with error status
|
|
48
|
+
const status = error.response.status;
|
|
49
|
+
const errorData = error.response.data;
|
|
50
|
+
const errorMessage = errorData?.error?.message || errorData?.message || `HTTP ${status}: ${error.response.statusText}`;
|
|
51
|
+
throw new Error(`Device flow start failed: ${errorMessage}`);
|
|
52
|
+
} else if (error.request) {
|
|
53
|
+
// Request was made but no response received
|
|
54
|
+
throw new Error(`Device flow start failed: No response from server at ${url}`);
|
|
55
|
+
} else {
|
|
56
|
+
// Other errors
|
|
57
|
+
throw new Error(`Device flow start failed: ${error.message}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Poll device flow for approval
|
|
64
|
+
* @param {string} deviceCode - Device code from startDeviceFlow
|
|
65
|
+
* @param {string} apiBaseUrl - API base URL
|
|
66
|
+
* @returns {Promise<Object>} Poll response with status and tokens if approved
|
|
67
|
+
*/
|
|
68
|
+
async function pollDeviceFlow(deviceCode, apiBaseUrl = null) {
|
|
69
|
+
const url = apiBaseUrl || getApiUrl();
|
|
70
|
+
try {
|
|
71
|
+
// Server expects snake_case: device_code
|
|
72
|
+
const response = await axios.post(`${url}/api/auth/device/poll`, {
|
|
73
|
+
device_code: deviceCode
|
|
74
|
+
}, {
|
|
75
|
+
timeout: 10000,
|
|
76
|
+
httpsAgent: new https.Agent({ keepAlive: true })
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const data = response.data;
|
|
80
|
+
|
|
81
|
+
// Debug logging - only log if LOG_LEVEL is DEBUG
|
|
82
|
+
if (process.env.LOG_LEVEL === 'DEBUG') {
|
|
83
|
+
logger.debug(`Poll response keys: ${Object.keys(data).join(', ')}`);
|
|
84
|
+
logger.debug(`Poll response: ${JSON.stringify(data, null, 2)}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Validate response has status field
|
|
88
|
+
if (!data.status) {
|
|
89
|
+
throw new Error(`Invalid poll response: missing status field. Response: ${JSON.stringify(data)}`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Check if data is nested in auth_response (server format)
|
|
93
|
+
const authData = data.auth_response || data.authResponse || data;
|
|
94
|
+
|
|
95
|
+
// refresh_token can be in root or in auth_response
|
|
96
|
+
const refreshToken = data.refresh_token || data.refreshToken ||
|
|
97
|
+
authData.refreshToken || authData.refresh_token || null;
|
|
98
|
+
|
|
99
|
+
// Normalize response: support both camelCase and snake_case
|
|
100
|
+
// Check all possible field name variations
|
|
101
|
+
const normalized = {
|
|
102
|
+
status: data.status,
|
|
103
|
+
accessToken: authData.accessToken || authData.access_token || null,
|
|
104
|
+
expiresAt: authData.expiresAt || authData.expires_at || null,
|
|
105
|
+
refreshToken: refreshToken,
|
|
106
|
+
user: authData.user || null
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Debug logging - only log if LOG_LEVEL is DEBUG
|
|
110
|
+
if (process.env.LOG_LEVEL === 'DEBUG') {
|
|
111
|
+
logger.debug(`Normalized response: ${JSON.stringify({
|
|
112
|
+
...normalized,
|
|
113
|
+
accessToken: normalized.accessToken ? '[REDACTED]' : null
|
|
114
|
+
}, null, 2)}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Validate normalized response for approved status
|
|
118
|
+
if (normalized.status === 'approved' && !normalized.accessToken) {
|
|
119
|
+
throw new Error(`Invalid approved response: missing accessToken. Raw response: ${JSON.stringify(data)}, Normalized: ${JSON.stringify(normalized)}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return normalized;
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error.response) {
|
|
125
|
+
// Server responded with error status
|
|
126
|
+
const status = error.response.status;
|
|
127
|
+
const errorData = error.response.data;
|
|
128
|
+
const errorMessage = errorData?.error?.message || errorData?.message || `HTTP ${status}: ${error.response.statusText}`;
|
|
129
|
+
throw new Error(`Device flow poll failed: ${errorMessage}`);
|
|
130
|
+
} else if (error.request) {
|
|
131
|
+
// Request was made but no response received
|
|
132
|
+
throw new Error(`Device flow poll failed: No response from server at ${url}`);
|
|
133
|
+
} else {
|
|
134
|
+
// Other errors (including our validation errors)
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Perform device flow login
|
|
142
|
+
* @param {Function} onCodeDisplay - Callback to display code to user (url, code)
|
|
143
|
+
* @param {Function} onStatusChange - Optional callback for status changes
|
|
144
|
+
* @param {string} apiBaseUrl - API base URL
|
|
145
|
+
* @returns {Promise<Object>} Credentials object
|
|
146
|
+
*/
|
|
147
|
+
async function deviceFlowLogin(onCodeDisplay, onStatusChange = null, apiBaseUrl = null, appUrl = null) {
|
|
148
|
+
const url = apiBaseUrl || getApiUrl();
|
|
149
|
+
const frontendUrl = appUrl || getAppUrl();
|
|
150
|
+
|
|
151
|
+
// Step 1: Start device flow (already normalized in startDeviceFlow)
|
|
152
|
+
const startResponse = await startDeviceFlow(url);
|
|
153
|
+
const { deviceCode, userCode, verificationUrl, expiresIn, interval } = startResponse;
|
|
154
|
+
|
|
155
|
+
// Step 2: Display code to user
|
|
156
|
+
// Use verificationUrl from server, but replace base URL if MELDOC_APP_URL is set
|
|
157
|
+
let displayUrl = verificationUrl;
|
|
158
|
+
|
|
159
|
+
// Check if verificationUrl already contains code in query parameter
|
|
160
|
+
let urlHasCode = false;
|
|
161
|
+
try {
|
|
162
|
+
const urlObj = new URL(verificationUrl);
|
|
163
|
+
if (urlObj.searchParams.has('code')) {
|
|
164
|
+
urlHasCode = true;
|
|
165
|
+
}
|
|
166
|
+
} catch (e) {
|
|
167
|
+
// URL parsing failed, continue
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (process.env.MELDOC_APP_URL || appUrl) {
|
|
171
|
+
// Replace the base URL in verificationUrl with the configured app URL
|
|
172
|
+
try {
|
|
173
|
+
const urlObj = new URL(verificationUrl);
|
|
174
|
+
const appUrlObj = new URL(frontendUrl);
|
|
175
|
+
// Keep the path and query from verificationUrl, but use app URL origin
|
|
176
|
+
displayUrl = `${appUrlObj.origin}${urlObj.pathname}${urlObj.search}`;
|
|
177
|
+
} catch (e) {
|
|
178
|
+
// If URL parsing fails, use verificationUrl as is
|
|
179
|
+
displayUrl = verificationUrl;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// If URL doesn't have code, add it as path parameter
|
|
184
|
+
let fullUrl = displayUrl;
|
|
185
|
+
if (!urlHasCode) {
|
|
186
|
+
// Add code as path parameter (format: /device/CODE)
|
|
187
|
+
if (displayUrl.endsWith('/')) {
|
|
188
|
+
fullUrl = `${displayUrl}${userCode}`;
|
|
189
|
+
} else {
|
|
190
|
+
fullUrl = `${displayUrl}/${userCode}`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
onCodeDisplay(fullUrl, userCode);
|
|
195
|
+
|
|
196
|
+
// Step 3: Poll for approval
|
|
197
|
+
const startTime = Date.now();
|
|
198
|
+
const expiresAt = startTime + (expiresIn * 1000);
|
|
199
|
+
|
|
200
|
+
while (Date.now() < expiresAt) {
|
|
201
|
+
await new Promise(resolve => setTimeout(resolve, interval * 1000));
|
|
202
|
+
|
|
203
|
+
const pollResponse = await pollDeviceFlow(deviceCode, url);
|
|
204
|
+
|
|
205
|
+
if (pollResponse.status === 'approved') {
|
|
206
|
+
// Validate that we have required fields
|
|
207
|
+
if (!pollResponse.accessToken) {
|
|
208
|
+
throw new Error(`Invalid poll response: missing accessToken. Response: ${JSON.stringify(pollResponse)}`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Step 4: Save credentials
|
|
212
|
+
// Note: refreshToken is optional - if server doesn't provide it, auto-refresh won't work
|
|
213
|
+
const credentials = {
|
|
214
|
+
type: 'user_session',
|
|
215
|
+
apiBaseUrl: url,
|
|
216
|
+
user: pollResponse.user,
|
|
217
|
+
tokens: {
|
|
218
|
+
accessToken: pollResponse.accessToken,
|
|
219
|
+
accessExpiresAt: pollResponse.expiresAt || new Date(Date.now() + 3600000).toISOString(), // Default 1 hour if missing
|
|
220
|
+
refreshToken: pollResponse.refreshToken || null // Optional - server may not provide refreshToken
|
|
221
|
+
},
|
|
222
|
+
updatedAt: new Date().toISOString()
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Note: refreshToken is optional, but if provided, auto-refresh will work
|
|
226
|
+
|
|
227
|
+
// Debug logging
|
|
228
|
+
if (process.env.LOG_LEVEL === 'DEBUG') {
|
|
229
|
+
logger.debug(`Saving credentials: ${JSON.stringify({
|
|
230
|
+
...credentials,
|
|
231
|
+
tokens: { ...credentials.tokens, accessToken: credentials.tokens.accessToken ? '[REDACTED]' : null }
|
|
232
|
+
}, null, 2)}`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
writeCredentials(credentials);
|
|
236
|
+
|
|
237
|
+
if (onStatusChange) {
|
|
238
|
+
onStatusChange('approved');
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return credentials;
|
|
242
|
+
} else if (pollResponse.status === 'denied') {
|
|
243
|
+
if (onStatusChange) {
|
|
244
|
+
onStatusChange('denied');
|
|
245
|
+
}
|
|
246
|
+
throw new Error('Login denied by user');
|
|
247
|
+
} else if (pollResponse.status === 'expired') {
|
|
248
|
+
if (onStatusChange) {
|
|
249
|
+
onStatusChange('expired');
|
|
250
|
+
}
|
|
251
|
+
throw new Error('Device code expired');
|
|
252
|
+
}
|
|
253
|
+
// status === 'pending' - continue polling
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
throw new Error('Device code expired');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
module.exports = {
|
|
260
|
+
startDeviceFlow,
|
|
261
|
+
pollDeviceFlow,
|
|
262
|
+
deviceFlowLogin
|
|
263
|
+
};
|
package/lib/logger.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Beautiful console logger with colors and emojis
|
|
5
|
+
*/
|
|
6
|
+
const logger = {
|
|
7
|
+
/**
|
|
8
|
+
* Success message (green)
|
|
9
|
+
*/
|
|
10
|
+
success: (message) => {
|
|
11
|
+
console.log(chalk.green('✓'), chalk.greenBright(message));
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Error message (red)
|
|
16
|
+
*/
|
|
17
|
+
error: (message) => {
|
|
18
|
+
console.error(chalk.red('✗'), chalk.redBright(message));
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Warning message (yellow)
|
|
23
|
+
*/
|
|
24
|
+
warn: (message) => {
|
|
25
|
+
console.warn(chalk.yellow('⚠'), chalk.yellowBright(message));
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Info message (blue)
|
|
30
|
+
*/
|
|
31
|
+
info: (message) => {
|
|
32
|
+
console.log(chalk.blue('ℹ'), chalk.blueBright(message));
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Debug message (gray)
|
|
37
|
+
*/
|
|
38
|
+
debug: (message) => {
|
|
39
|
+
console.error(chalk.gray('🔍'), chalk.gray(message));
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Log message with custom prefix
|
|
44
|
+
*/
|
|
45
|
+
log: (message) => {
|
|
46
|
+
console.log(message);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Highlighted text (cyan)
|
|
51
|
+
*/
|
|
52
|
+
highlight: (text) => {
|
|
53
|
+
return chalk.cyan.bold(text);
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* URL formatting
|
|
58
|
+
*/
|
|
59
|
+
url: (url) => {
|
|
60
|
+
return chalk.cyan.underline(url);
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Code formatting
|
|
65
|
+
*/
|
|
66
|
+
code: (code) => {
|
|
67
|
+
return chalk.bgGray.white.bold(` ${code} `);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Label formatting
|
|
72
|
+
*/
|
|
73
|
+
label: (label) => {
|
|
74
|
+
return chalk.gray(label);
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Value formatting
|
|
79
|
+
*/
|
|
80
|
+
value: (value) => {
|
|
81
|
+
return chalk.white.bold(value);
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Section header
|
|
86
|
+
*/
|
|
87
|
+
section: (title) => {
|
|
88
|
+
console.log('\n' + chalk.bold.cyan('━'.repeat(50)));
|
|
89
|
+
console.log(chalk.bold.cyan(' ' + title));
|
|
90
|
+
console.log(chalk.bold.cyan('━'.repeat(50)) + '\n');
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* List item
|
|
95
|
+
*/
|
|
96
|
+
item: (text, value = null) => {
|
|
97
|
+
if (value !== null) {
|
|
98
|
+
console.log(chalk.gray(' •'), chalk.white(text), chalk.gray('→'), logger.value(value));
|
|
99
|
+
} else {
|
|
100
|
+
console.log(chalk.gray(' •'), chalk.white(text));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
module.exports = logger;
|
package/lib/workspace.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const yaml = require('js-yaml');
|
|
4
|
+
const { getWorkspaceAlias: getConfigWorkspaceAlias } = require('./config');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Find .meldoc.yml file in current directory or parent directories
|
|
8
|
+
* @param {string} startDir - Starting directory
|
|
9
|
+
* @returns {string|null} Path to .meldoc.yml or null if not found
|
|
10
|
+
*/
|
|
11
|
+
function findRepoConfig(startDir = process.cwd()) {
|
|
12
|
+
let currentDir = path.resolve(startDir);
|
|
13
|
+
const root = path.parse(currentDir).root;
|
|
14
|
+
|
|
15
|
+
while (currentDir !== root) {
|
|
16
|
+
const configPath = path.join(currentDir, '.meldoc.yml');
|
|
17
|
+
if (fs.existsSync(configPath)) {
|
|
18
|
+
return configPath;
|
|
19
|
+
}
|
|
20
|
+
currentDir = path.dirname(currentDir);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read workspace alias from .meldoc.yml
|
|
28
|
+
* @param {string} configPath - Path to .meldoc.yml
|
|
29
|
+
* @returns {string|null} Workspace alias or null
|
|
30
|
+
*/
|
|
31
|
+
function readRepoConfig(configPath) {
|
|
32
|
+
try {
|
|
33
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
34
|
+
const config = yaml.load(content);
|
|
35
|
+
return config?.context?.workspace || null;
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolve workspace alias for request
|
|
43
|
+
* Priority:
|
|
44
|
+
* 1. Repo config (.meldoc.yml) - optional, can be skipped
|
|
45
|
+
* 2. Global config (~/.meldoc/config.json)
|
|
46
|
+
* 3. No header (let server choose automatically)
|
|
47
|
+
* @param {boolean} useRepoConfig - Whether to check repo config (optional)
|
|
48
|
+
* @returns {string|null} Workspace alias or null
|
|
49
|
+
*/
|
|
50
|
+
function resolveWorkspaceAlias(useRepoConfig = true) {
|
|
51
|
+
// Step 1: Repo config (optional)
|
|
52
|
+
if (useRepoConfig) {
|
|
53
|
+
const repoConfigPath = findRepoConfig();
|
|
54
|
+
if (repoConfigPath) {
|
|
55
|
+
const repoWorkspace = readRepoConfig(repoConfigPath);
|
|
56
|
+
if (repoWorkspace) {
|
|
57
|
+
return repoWorkspace;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Step 2: Global config
|
|
63
|
+
const configWorkspace = getConfigWorkspaceAlias();
|
|
64
|
+
if (configWorkspace) {
|
|
65
|
+
return configWorkspace;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Step 3: No workspace alias
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = {
|
|
73
|
+
findRepoConfig,
|
|
74
|
+
readRepoConfig,
|
|
75
|
+
resolveWorkspaceAlias
|
|
76
|
+
};
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meldocio/mcp-stdio-proxy",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.10",
|
|
4
4
|
"description": "MCP stdio proxy for meldoc - connects Claude Desktop to meldoc MCP API",
|
|
5
5
|
"bin": {
|
|
6
6
|
"meldoc-mcp": "bin/meldoc-mcp-proxy.js"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"bin/**",
|
|
10
|
+
"lib/**",
|
|
10
11
|
"README.md",
|
|
11
12
|
"LICENSE"
|
|
12
13
|
],
|
|
@@ -32,6 +33,8 @@
|
|
|
32
33
|
},
|
|
33
34
|
"homepage": "https://github.com/meldoc/mcp-stdio-proxy#readme",
|
|
34
35
|
"dependencies": {
|
|
35
|
-
"axios": "^1.6.0"
|
|
36
|
+
"axios": "^1.6.0",
|
|
37
|
+
"chalk": "^4.1.2",
|
|
38
|
+
"js-yaml": "^4.1.1"
|
|
36
39
|
}
|
|
37
40
|
}
|