@meldocio/mcp-stdio-proxy 1.0.28 → 1.1.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.
@@ -14,7 +14,7 @@
14
14
  "source": "github",
15
15
  "repo": "meldoc-io/mcp-stdio-proxy"
16
16
  },
17
- "version": "1.0.28",
17
+ "version": "1.1.0",
18
18
  "description": "Connect Claude Desktop, Claude Code, and other MCP clients to your Meldoc documentation workspace. Read, search, create, and update your documentation directly from AI conversations.",
19
19
  "author": {
20
20
  "name": "Meldoc",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "meldoc-mcp",
3
- "version": "1.0.28",
3
+ "version": "1.1.0",
4
4
  "description": "Connect Claude Desktop, Claude Code, and other MCP clients to your Meldoc documentation workspace. Read, search, create, and update your documentation directly from AI conversations through the Model Context Protocol.",
5
5
  "author": {
6
6
  "name": "Meldoc",
package/README.md CHANGED
@@ -377,14 +377,14 @@ After setup, Claude gets access to the following capabilities:
377
377
  ### 📖 Working with documents
378
378
 
379
379
  - **`docs_list`** - Show list of all documents in the project
380
- - **`docs_get`** - Get content of a specific document
380
+ - **`docs_get`** - Get content of a document (by UUID or alias)
381
381
  - **`docs_tree`** - Show document structure (tree)
382
382
  - **`docs_search`** - Find documents by text
383
383
  - **`docs_create`** - Create a new document (requires permissions)
384
384
  - **`docs_update`** - Update a document (requires permissions)
385
385
  - **`docs_delete`** - Delete a document (requires permissions)
386
- - **`docs_links`** - Show all links from a document
387
- - **`docs_backlinks`** - Show all documents that link to this one
386
+ - **`docs_links`** - Show all links from a document (by UUID or alias)
387
+ - **`docs_backlinks`** - Show all documents that link to this one (by UUID or alias)
388
388
 
389
389
  ### 📁 Working with projects
390
390
 
package/bin/cli.js CHANGED
@@ -67,7 +67,8 @@ async function main() {
67
67
  handleUninstall();
68
68
  } else if (command === 'auth') {
69
69
  if (subcommand === 'login') {
70
- await handleAuthLogin();
70
+ const usePkce = args.includes('--pkce');
71
+ await handleAuthLogin({ pkce: usePkce });
71
72
  } else if (subcommand === 'status') {
72
73
  await handleAuthStatus();
73
74
  } else if (subcommand === 'logout') {
@@ -3,208 +3,252 @@
3
3
  /**
4
4
  * Meldoc MCP Stdio Proxy
5
5
  *
6
- * Thin proxy that handles MCP protocol and forwards requests to Meldoc API.
7
- * Most logic has been extracted to lib/ modules for better maintainability.
6
+ * Thin stdio Streamable HTTP proxy implementing MCP transport (spec 2025-03-26).
7
+ * Forwards all MCP protocol messages to the server-side MCP at /mcp.
8
+ * Handles a small set of local tools (auth_status, auth_login_instructions,
9
+ * set_workspace, get_workspace) directly.
8
10
  */
9
11
 
10
- const { validateRequest } = require('../lib/protocol/json-rpc');
11
- const { sendError } = require('../lib/protocol/json-rpc');
12
- const { JSON_RPC_ERROR_CODES } = require('../lib/protocol/error-codes');
13
- const { handleLocalMethod } = require('../lib/mcp/handlers');
14
- const { handleToolsCall } = require('../lib/mcp/tools-call');
15
- const { makeBackendRequest } = require('../lib/http/client');
16
- const { handleBackendResponse } = require('../lib/http/error-handler');
17
- const { LOG_LEVELS } = require('../lib/core/constants');
18
-
19
- // Check for CLI commands first
12
+ // Check for CLI commands first (auth, config, install, etc.)
20
13
  const args = process.argv.slice(2);
21
14
  if (args.length > 0) {
22
- const command = args[0];
23
- if (command === 'auth' || command === 'config' || command === 'install' ||
24
- command === 'uninstall' || command === 'help' || command === '--help' || command === '-h') {
15
+ const cmd = args[0];
16
+ if (cmd === 'auth' || cmd === 'config' || cmd === 'install' ||
17
+ cmd === 'uninstall' || cmd === 'help' || cmd === '--help' || cmd === '-h') {
25
18
  require('./cli');
26
19
  return;
27
20
  }
28
21
  }
29
22
 
30
- /**
31
- * Get log level from environment
32
- */
33
- function getLogLevel() {
23
+ const { StreamableHTTPProxy } = require('../lib/http/proxy');
24
+ const { handleToolsCall, isLocalTool } = require('../lib/mcp/tools-call');
25
+ const { handlePing, isNotification } = require('../lib/mcp/handlers');
26
+ const { getLocalToolsList } = require('../lib/protocol/tools-schema');
27
+ const { getAccessToken } = require('../lib/core/auth');
28
+ const { getApiUrl, LOG_LEVELS } = require('../lib/core/constants');
29
+
30
+ let pkg;
31
+ try { pkg = require('../package.json'); } catch (e) { pkg = { name: '@meldocio/mcp-stdio-proxy', version: '1.0.0' }; }
32
+
33
+ // Log level
34
+ const LOG_LEVEL = (() => {
34
35
  const level = (process.env.LOG_LEVEL || 'ERROR').toUpperCase();
35
36
  return LOG_LEVELS[level] !== undefined ? LOG_LEVELS[level] : LOG_LEVELS.ERROR;
36
- }
37
+ })();
37
38
 
38
- const LOG_LEVEL = getLogLevel();
39
-
40
- /**
41
- * Log message to stderr
42
- */
43
39
  function log(level, message) {
44
40
  if (LOG_LEVEL >= level) {
45
- const levelName = Object.keys(LOG_LEVELS)[level] || 'UNKNOWN';
46
- process.stderr.write(`[${levelName}] ${message}\n`);
41
+ process.stderr.write(`[${Object.keys(LOG_LEVELS)[level] || 'UNKNOWN'}] ${message}\n`);
47
42
  }
48
43
  }
49
44
 
45
+ // Create the Streamable HTTP proxy
46
+ const proxy = new StreamableHTTPProxy(
47
+ getApiUrl() + '/mcp',
48
+ async () => {
49
+ const tokenInfo = await getAccessToken();
50
+ return tokenInfo ? tokenInfo.token : null;
51
+ }
52
+ )
53
+
50
54
  /**
51
- * Process single request to backend
55
+ * Inject local tools into a tools/list response from the server.
56
+ * @param {string} responseText - Raw JSON-RPC response text from server
57
+ * @returns {string} Modified response with local tools prepended
52
58
  */
53
- async function processSingleRequest(request) {
59
+ function injectLocalTools(responseText) {
54
60
  try {
55
- const response = await makeBackendRequest(request);
56
- handleBackendResponse(response, request);
57
- } catch (error) {
58
- // Handle request errors (network, timeout, etc.)
59
- if (error.code === 'ECONNABORTED') {
60
- sendError(request.id, JSON_RPC_ERROR_CODES.INTERNAL_ERROR,
61
- `Request timeout after ${error.timeout || 25000}ms`, {
62
- code: 'TIMEOUT'
63
- });
64
- } else {
65
- sendError(request.id, JSON_RPC_ERROR_CODES.INTERNAL_ERROR,
66
- `Failed to communicate with backend: ${error.message}`, {
67
- code: 'BACKEND_ERROR'
68
- });
61
+ const parsed = JSON.parse(responseText);
62
+ if (parsed.result && Array.isArray(parsed.result.tools)) {
63
+ const localTools = getLocalToolsList();
64
+ const serverToolNames = new Set(parsed.result.tools.map(t => t.name));
65
+ // Prepend local tools that aren't already in the server list
66
+ const toInject = localTools.filter(t => !serverToolNames.has(t.name));
67
+ parsed.result.tools = [...toInject, ...parsed.result.tools];
68
+ return JSON.stringify(parsed);
69
69
  }
70
+ } catch (e) {
71
+ // If parsing fails, return original text unchanged
70
72
  }
73
+ return responseText;
71
74
  }
72
75
 
73
76
  /**
74
- * Handle a JSON-RPC request
77
+ * Handle a single parsed JSON-RPC request.
78
+ * @param {Object} request
75
79
  */
76
80
  async function handleRequest(request) {
77
- if (!request) return;
81
+ if (!request || typeof request !== 'object') return;
78
82
 
79
- try {
80
- // Handle batch requests
81
- if (Array.isArray(request)) {
82
- for (const req of request) {
83
- if (req) {
84
- try {
85
- await handleSingleRequest(req);
86
- } catch (error) {
87
- log(LOG_LEVELS.ERROR, `Error processing batch request: ${error.message || 'Unknown error'}`);
88
- if (req && req.id !== undefined && req.id !== null) {
89
- sendError(req.id, JSON_RPC_ERROR_CODES.INTERNAL_ERROR,
90
- `Error processing request: ${error.message || 'Unknown error'}`, {
91
- code: 'INTERNAL_ERROR'
92
- });
93
- }
94
- }
95
- }
96
- }
97
- return;
98
- }
83
+ const { method, id } = request;
99
84
 
100
- // Handle single request
101
- await handleSingleRequest(request);
102
- } catch (error) {
103
- log(LOG_LEVELS.ERROR, `Unexpected error in handleRequest: ${error.message || 'Unknown error'}`);
104
- if (request && request.id !== undefined && request.id !== null) {
105
- sendError(request.id, JSON_RPC_ERROR_CODES.INTERNAL_ERROR,
106
- `Error processing request: ${error.message || 'Unknown error'}`, {
107
- code: 'INTERNAL_ERROR'
108
- });
85
+ // Basic validation: method is required
86
+ if (!method) {
87
+ if (id != null) {
88
+ process.stdout.write(JSON.stringify({
89
+ jsonrpc: '2.0', id,
90
+ error: { code: -32600, message: 'Invalid Request: missing method' }
91
+ }) + '\n');
109
92
  }
93
+ return;
110
94
  }
111
- }
112
95
 
113
- /**
114
- * Handle a single JSON-RPC request
115
- */
116
- async function handleSingleRequest(request) {
117
- // Validate request
118
- const validation = validateRequest(request);
119
- if (!validation.valid) {
120
- sendError(request.id, JSON_RPC_ERROR_CODES.INVALID_REQUEST, validation.error);
96
+ // Notifications require no response
97
+ if (isNotification(method)) return;
98
+
99
+ // ping is handled locally (fast keep-alive)
100
+ if (method === 'ping') {
101
+ handlePing(request);
121
102
  return;
122
103
  }
123
104
 
124
- const method = request.method;
105
+ // tools/call: check if it's a local tool first
106
+ if (method === 'tools/call') {
107
+ const toolName = request.params && request.params.name;
108
+ if (isLocalTool(toolName)) {
109
+ await handleToolsCall(request);
110
+ return;
111
+ }
112
+ }
125
113
 
126
- // Handle local MCP methods (initialize, ping, tools/list, etc.)
127
- if (handleLocalMethod(request)) {
128
- return; // Handled locally
114
+ // initialize: handled locally so Claude Desktop gets a fast response
115
+ // even if the server is temporarily unavailable.
116
+ // The server session is initialized lazily on the first forwarded request.
117
+ if (method === 'initialize') {
118
+ process.stdout.write(JSON.stringify({
119
+ jsonrpc: '2.0',
120
+ id,
121
+ result: {
122
+ protocolVersion: '2025-06-18',
123
+ capabilities: { tools: {} },
124
+ serverInfo: { name: pkg.name, version: pkg.version }
125
+ }
126
+ }) + '\n');
127
+ return;
129
128
  }
130
129
 
131
- // Handle tools/call (may be local or proxied)
132
- if (method === 'tools/call') {
133
- const handled = await handleToolsCall(request);
134
- if (handled) {
135
- return; // Handled locally
130
+ // tools/list: forward to server and inject local tools
131
+ if (method === 'tools/list') {
132
+ // Intercept stdout temporarily to inject local tools
133
+ const origWrite = process.stdout.write.bind(process.stdout);
134
+ let captured = '';
135
+ let done = false;
136
+
137
+ process.stdout.write = (chunk) => {
138
+ if (!done) {
139
+ captured += chunk.toString();
140
+ // Check if we have a complete line
141
+ const lines = captured.split('\n');
142
+ if (lines.length > 1) {
143
+ done = true;
144
+ process.stdout.write = origWrite;
145
+ const modified = injectLocalTools(lines[0]);
146
+ origWrite(modified + '\n');
147
+ // Write any remaining lines
148
+ for (let i = 1; i < lines.length - 1; i++) {
149
+ if (lines[i]) origWrite(lines[i] + '\n');
150
+ }
151
+ }
152
+ } else {
153
+ origWrite(chunk);
154
+ }
155
+ return true;
156
+ };
157
+
158
+ try {
159
+ await proxy.forwardAndWrite(request);
160
+ } finally {
161
+ if (!done) {
162
+ process.stdout.write = origWrite;
163
+ if (captured.trim()) {
164
+ origWrite(injectLocalTools(captured.trim()) + '\n');
165
+ }
166
+ }
136
167
  }
137
- // Fall through to proxy to backend
168
+ return;
138
169
  }
139
170
 
140
- // Forward all other methods to backend
141
- await processSingleRequest(request);
171
+ // Everything else: forward to server
172
+ await proxy.forwardAndWrite(request);
142
173
  }
143
174
 
144
175
  /**
145
- * Handle a single line from stdin
176
+ * Handle a raw JSON line from stdin.
146
177
  */
147
- function handleLine(line) {
148
- if (!line || !line.trim()) return;
178
+ async function handleLine(line) {
179
+ if (!line.trim()) return;
149
180
 
181
+ let request;
150
182
  try {
151
- const request = JSON.parse(line);
152
- handleRequest(request);
153
- } catch (parseError) {
154
- log(LOG_LEVELS.ERROR, `Parse error: ${parseError.message}`);
183
+ request = JSON.parse(line);
184
+ } catch (e) {
185
+ log(LOG_LEVELS.ERROR, 'Parse error: ' + e.message);
186
+ return;
187
+ }
188
+
189
+ try {
190
+ if (Array.isArray(request)) {
191
+ // Batch requests
192
+ for (const req of request) {
193
+ try {
194
+ await handleRequest(req);
195
+ } catch (err) {
196
+ log(LOG_LEVELS.ERROR, 'Batch request error: ' + err.message);
197
+ if (req && req.id != null) {
198
+ process.stdout.write(JSON.stringify({
199
+ jsonrpc: '2.0', id: req.id,
200
+ error: { code: -32603, message: err.message }
201
+ }) + '\n');
202
+ }
203
+ }
204
+ }
205
+ } else {
206
+ await handleRequest(request);
207
+ }
208
+ } catch (err) {
209
+ log(LOG_LEVELS.ERROR, 'Unexpected error: ' + err.message);
210
+ if (request && request.id != null) {
211
+ process.stdout.write(JSON.stringify({
212
+ jsonrpc: '2.0', id: request.id,
213
+ error: { code: -32603, message: err.message }
214
+ }) + '\n');
215
+ }
155
216
  }
156
217
  }
157
218
 
158
- /**
159
- * Setup stdin/stdout handling
160
- */
219
+ // Stdin/stdout setup
161
220
  let buffer = '';
162
221
 
163
222
  process.stdin.setEncoding('utf8');
223
+
164
224
  process.stdin.on('data', (chunk) => {
165
225
  buffer += chunk;
166
226
  const lines = buffer.split('\n');
167
227
  buffer = lines.pop() || '';
168
-
169
228
  for (const line of lines) {
170
- const trimmed = line.trim();
171
- if (trimmed) {
172
- handleLine(trimmed);
173
- }
229
+ if (line.trim()) handleLine(line.trim());
174
230
  }
175
231
  });
176
232
 
177
233
  process.stdin.on('end', () => {
178
- if (buffer.trim()) {
179
- handleLine(buffer.trim());
180
- }
234
+ if (buffer.trim()) handleLine(buffer.trim());
181
235
  });
182
236
 
183
- process.stdin.on('error', (error) => {
184
- // Silently handle stdin errors - normal when Claude Desktop closes connection
185
- });
237
+ process.stdin.on('error', () => {});
186
238
 
187
- process.stdout.on('error', (error) => {
188
- // Exit gracefully on EPIPE (stdout closed)
189
- if (error.code === 'EPIPE') {
190
- process.exit(0);
191
- }
239
+ process.stdout.on('error', (err) => {
240
+ if (err.code === 'EPIPE') process.exit(0);
192
241
  });
193
242
 
194
- /**
195
- * Graceful shutdown handling
196
- */
197
- let isShuttingDown = false;
198
-
199
- function gracefulShutdown(signal) {
200
- if (isShuttingDown) return;
201
- isShuttingDown = true;
202
- log(LOG_LEVELS.INFO, `Received ${signal}, shutting down gracefully...`);
243
+ // Graceful shutdown
244
+ let shuttingDown = false;
245
+ function shutdown(signal) {
246
+ if (shuttingDown) return;
247
+ shuttingDown = true;
248
+ log(LOG_LEVELS.INFO, `Received ${signal}, shutting down`);
203
249
  setTimeout(() => process.exit(0), 100);
204
250
  }
251
+ process.on('SIGINT', () => shutdown('SIGINT'));
252
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
205
253
 
206
- process.on('SIGINT', () => gracefulShutdown('SIGINT'));
207
- process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
208
-
209
- // Start message
210
254
  log(LOG_LEVELS.DEBUG, 'Meldoc MCP Stdio Proxy started');
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  const { interactiveLogin } = require('../core/device-flow');
8
+ const { pkceLogin } = require('../core/oauth-pkce');
8
9
  const { deleteCredentials } = require('../core/credentials');
9
10
  const { getAuthStatus, getAccessToken } = require('../core/auth');
10
11
  const { setWorkspaceAlias, getWorkspaceAlias } = require('../core/config');
@@ -24,16 +25,26 @@ const APP_URL = getAppUrl();
24
25
 
25
26
  /**
26
27
  * Handle auth login command
28
+ * @param {Object} options
29
+ * @param {boolean} options.pkce - Use OAuth 2.1 PKCE flow instead of device flow
27
30
  */
28
- async function handleAuthLogin() {
31
+ async function handleAuthLogin(options = {}) {
29
32
  try {
30
- await interactiveLogin({
31
- autoOpen: true,
32
- showQR: false,
33
- timeout: 120000,
34
- apiBaseUrl: API_URL,
35
- appUrl: APP_URL
36
- });
33
+ if (options.pkce) {
34
+ await pkceLogin({
35
+ apiBaseUrl: API_URL,
36
+ appUrl: APP_URL,
37
+ timeout: 120000
38
+ });
39
+ } else {
40
+ await interactiveLogin({
41
+ autoOpen: true,
42
+ showQR: false,
43
+ timeout: 120000,
44
+ apiBaseUrl: API_URL,
45
+ appUrl: APP_URL
46
+ });
47
+ }
37
48
  process.exit(0);
38
49
  } catch (error) {
39
50
  process.exit(1);
package/lib/core/auth.js CHANGED
@@ -2,6 +2,7 @@ const axios = require('axios');
2
2
  const https = require('https');
3
3
  const { readCredentials, writeCredentials, isTokenExpiredOrExpiringSoon, isTokenExpired } = require('./credentials');
4
4
  const { getApiUrl } = require('./constants');
5
+ const { refreshOAuthToken } = require('./oauth-pkce');
5
6
 
6
7
  /**
7
8
  * Get access token with priority:
@@ -57,7 +58,9 @@ async function getAccessToken() {
57
58
  }
58
59
 
59
60
  /**
60
- * Refresh access token using refresh token
61
+ * Refresh access token using the appropriate method based on auth_method.
62
+ * - device flow (default): POST /api/auth/refresh
63
+ * - pkce: POST /mcp/oauth/token with grant_type=refresh_token
61
64
  * @param {Object} credentials - Current credentials object
62
65
  * @returns {Promise<Object|null>} Updated credentials or null if refresh failed
63
66
  */
@@ -65,32 +68,48 @@ async function refreshToken(credentials) {
65
68
  if (!credentials || !credentials.tokens || !credentials.tokens.refreshToken) {
66
69
  return null;
67
70
  }
68
-
71
+
69
72
  const apiBaseUrl = credentials.apiBaseUrl || getApiUrl();
70
-
73
+
71
74
  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
75
+ let newTokens;
76
+
77
+ if (credentials.auth_method === 'pkce') {
78
+ // OAuth 2.1 PKCE refresh (token rotation — server issues new refresh token)
79
+ newTokens = await refreshOAuthToken(
80
+ apiBaseUrl,
81
+ credentials.tokens.refreshToken,
82
+ credentials.client_id
83
+ );
84
+ } else {
85
+ // Device flow refresh
86
+ const response = await axios.post(`${apiBaseUrl}/api/auth/refresh`, {
87
+ refreshToken: credentials.tokens.refreshToken
88
+ }, {
89
+ timeout: 10000,
90
+ httpsAgent: new https.Agent({ keepAlive: true })
91
+ });
92
+ newTokens = {
93
+ accessToken: response.data.accessToken,
94
+ refreshToken: response.data.refreshToken || credentials.tokens.refreshToken,
95
+ expiresAt: response.data.expiresAt
96
+ };
97
+ }
98
+
80
99
  const updatedCredentials = {
81
100
  ...credentials,
82
101
  tokens: {
83
- accessToken: response.data.accessToken,
84
- accessExpiresAt: response.data.expiresAt,
85
- refreshToken: response.data.refreshToken || credentials.tokens.refreshToken
102
+ accessToken: newTokens.accessToken,
103
+ accessExpiresAt: newTokens.expiresAt || newTokens.accessExpiresAt,
104
+ refreshToken: newTokens.refreshToken
86
105
  },
87
106
  updatedAt: new Date().toISOString()
88
107
  };
89
-
108
+
90
109
  writeCredentials(updatedCredentials);
91
110
  return updatedCredentials;
92
111
  } catch (error) {
93
- // Refresh failed - delete credentials
112
+ // Refresh failed - delete credentials so user must re-login
94
113
  const { deleteCredentials } = require('./credentials');
95
114
  deleteCredentials();
96
115
  return null;