@uteamup/mcp-server 1.0.0-beta.7 → 1.0.0-beta.8
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/README.md +7 -0
- package/index.js +2 -2
- package/lib/config.js +161 -0
- package/lib/logger.js +170 -0
- package/lib/metrics.js +201 -0
- package/lib/retry.js +150 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -6,6 +6,13 @@
|
|
|
6
6
|
[](https://opensource.org/licenses/MIT)
|
|
7
7
|
[](https://nodejs.org)
|
|
8
8
|
|
|
9
|
+
## Links
|
|
10
|
+
|
|
11
|
+
- **📦 [npm Package](https://www.npmjs.com/package/@uteamup/mcp-server)** - Install with `npx -y @uteamup/mcp-server`
|
|
12
|
+
- **📖 [Documentation](https://docs.uteamup.com/mcp)** - Full MCP integration guide
|
|
13
|
+
- **🐛 [Issues](https://github.com/uteamup/mcp-server/issues)** - Report bugs and request features
|
|
14
|
+
- **💬 [Support](mailto:support@uteamup.com)** - Get help
|
|
15
|
+
|
|
9
16
|
## What is this?
|
|
10
17
|
|
|
11
18
|
This package enables AI assistants like Claude Desktop and Claude Code to connect to your UteamUP CMMS system using the Model Context Protocol (MCP). It handles OAuth 2.0 authentication automatically, so you can manage work orders, assets, inventory, and more through natural conversation.
|
package/index.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* UteamUP MCP Proxy - Production-ready OAuth handler with enhanced debugging
|
|
5
|
-
* Version: 1.0.0-beta.
|
|
5
|
+
* Version: 1.0.0-beta.8
|
|
6
6
|
*
|
|
7
7
|
* Features:
|
|
8
8
|
* - OAuth 2.0 Authorization Code + PKCE authentication
|
|
@@ -22,7 +22,7 @@ const { startTimer, recordOAuthAttempt, recordRequest, recordCacheHit, recordCac
|
|
|
22
22
|
const { retryWithBackoff } = require('./lib/retry');
|
|
23
23
|
|
|
24
24
|
// Package version
|
|
25
|
-
const VERSION = '1.0.0-beta.
|
|
25
|
+
const VERSION = '1.0.0-beta.8';
|
|
26
26
|
|
|
27
27
|
// CLI argument handling
|
|
28
28
|
const args = process.argv.slice(2);
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration validation using Zod
|
|
3
|
+
* Validates environment variables and provides type-safe configuration
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { z } = require('zod');
|
|
7
|
+
|
|
8
|
+
// Configuration schema
|
|
9
|
+
const configSchema = z.object({
|
|
10
|
+
// Required credentials
|
|
11
|
+
apiKey: z.string()
|
|
12
|
+
.length(32, 'API Key must be exactly 32 characters')
|
|
13
|
+
.describe('MCP-enabled API key from UteamUP'),
|
|
14
|
+
|
|
15
|
+
secret: z.string()
|
|
16
|
+
.min(64, 'Secret must be at least 64 characters')
|
|
17
|
+
.describe('API key secret from UteamUP'),
|
|
18
|
+
|
|
19
|
+
// Optional configuration
|
|
20
|
+
baseUrl: z.string()
|
|
21
|
+
.url('Base URL must be a valid URL')
|
|
22
|
+
.default('https://api.uteamup.com')
|
|
23
|
+
.describe('API endpoint URL'),
|
|
24
|
+
|
|
25
|
+
// Logging configuration
|
|
26
|
+
logLevel: z.enum(['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'])
|
|
27
|
+
.default('INFO')
|
|
28
|
+
.describe('Logging level'),
|
|
29
|
+
|
|
30
|
+
logFormat: z.enum(['text', 'json'])
|
|
31
|
+
.default('text')
|
|
32
|
+
.describe('Log output format'),
|
|
33
|
+
|
|
34
|
+
// Legacy debug flag (for backward compatibility)
|
|
35
|
+
debug: z.boolean()
|
|
36
|
+
.default(false)
|
|
37
|
+
.describe('Legacy debug mode'),
|
|
38
|
+
|
|
39
|
+
// Request configuration
|
|
40
|
+
requestTimeout: z.number()
|
|
41
|
+
.int()
|
|
42
|
+
.min(1000)
|
|
43
|
+
.max(300000) // 5 minutes max
|
|
44
|
+
.default(30000)
|
|
45
|
+
.describe('Request timeout in milliseconds'),
|
|
46
|
+
|
|
47
|
+
maxRetries: z.number()
|
|
48
|
+
.int()
|
|
49
|
+
.min(0)
|
|
50
|
+
.max(10)
|
|
51
|
+
.default(3)
|
|
52
|
+
.describe('Maximum retry attempts for network errors'),
|
|
53
|
+
|
|
54
|
+
// Token management
|
|
55
|
+
tokenCacheDir: z.string()
|
|
56
|
+
.optional()
|
|
57
|
+
.describe('Optional persistent token cache directory'),
|
|
58
|
+
|
|
59
|
+
tokenRefreshMargin: z.number()
|
|
60
|
+
.int()
|
|
61
|
+
.min(0)
|
|
62
|
+
.max(3600) // 1 hour max
|
|
63
|
+
.default(300) // 5 minutes
|
|
64
|
+
.describe('Token refresh margin in seconds')
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Loads and validates configuration from environment variables
|
|
69
|
+
* @returns {object} Validated configuration object
|
|
70
|
+
* @throws {Error} If configuration is invalid
|
|
71
|
+
*/
|
|
72
|
+
function loadConfig() {
|
|
73
|
+
// Parse environment variables
|
|
74
|
+
const rawConfig = {
|
|
75
|
+
apiKey: process.env.UTEAMUP_API_KEY,
|
|
76
|
+
secret: process.env.UTEAMUP_SECRET,
|
|
77
|
+
baseUrl: process.env.UTEAMUP_API_BASE_URL || 'https://api.uteamup.com',
|
|
78
|
+
logLevel: (process.env.UTEAMUP_LOG_LEVEL || 'INFO').toUpperCase(),
|
|
79
|
+
logFormat: (process.env.UTEAMUP_LOG_FORMAT || 'text').toLowerCase(),
|
|
80
|
+
debug: process.env.UTEAMUP_DEBUG === '1' || process.env.UTEAMUP_DEBUG === 'true',
|
|
81
|
+
requestTimeout: parseInt(process.env.UTEAMUP_REQUEST_TIMEOUT || '30000', 10),
|
|
82
|
+
maxRetries: parseInt(process.env.UTEAMUP_MAX_RETRIES || '3', 10),
|
|
83
|
+
tokenCacheDir: process.env.UTEAMUP_TOKEN_CACHE_DIR,
|
|
84
|
+
tokenRefreshMargin: parseInt(process.env.UTEAMUP_TOKEN_REFRESH_MARGIN || '300', 10)
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// If debug mode is enabled, upgrade log level to DEBUG
|
|
88
|
+
if (rawConfig.debug && rawConfig.logLevel === 'INFO') {
|
|
89
|
+
rawConfig.logLevel = 'DEBUG';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
// Validate configuration
|
|
94
|
+
const config = configSchema.parse(rawConfig);
|
|
95
|
+
return config;
|
|
96
|
+
} catch (error) {
|
|
97
|
+
if (error instanceof z.ZodError) {
|
|
98
|
+
// Format validation errors nicely
|
|
99
|
+
const errors = error.errors.map(err => {
|
|
100
|
+
const path = err.path.join('.');
|
|
101
|
+
return ` - ${path}: ${err.message}`;
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
throw new Error(
|
|
105
|
+
'Configuration validation failed:\n' +
|
|
106
|
+
errors.join('\n') +
|
|
107
|
+
'\n\nRequired environment variables:\n' +
|
|
108
|
+
' UTEAMUP_API_KEY - Your MCP-enabled API key (32 characters)\n' +
|
|
109
|
+
' UTEAMUP_SECRET - Your API key secret (64+ characters)\n\n' +
|
|
110
|
+
'Optional environment variables:\n' +
|
|
111
|
+
' UTEAMUP_API_BASE_URL - API endpoint (default: https://api.uteamup.com)\n' +
|
|
112
|
+
' UTEAMUP_LOG_LEVEL - Log level (default: INFO)\n' +
|
|
113
|
+
' UTEAMUP_LOG_FORMAT - Log format (default: text)\n' +
|
|
114
|
+
' UTEAMUP_DEBUG - Enable debug mode (1 or true)\n' +
|
|
115
|
+
' UTEAMUP_REQUEST_TIMEOUT - Request timeout in ms (default: 30000)\n' +
|
|
116
|
+
' UTEAMUP_MAX_RETRIES - Max retry attempts (default: 3)\n' +
|
|
117
|
+
' UTEAMUP_TOKEN_CACHE_DIR - Persistent token cache directory\n' +
|
|
118
|
+
' UTEAMUP_TOKEN_REFRESH_MARGIN - Token refresh margin in seconds (default: 300)'
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Validates configuration without throwing (returns validation result)
|
|
127
|
+
* @returns {{success: boolean, config?: object, error?: string}} Validation result
|
|
128
|
+
*/
|
|
129
|
+
function validateConfig() {
|
|
130
|
+
try {
|
|
131
|
+
const config = loadConfig();
|
|
132
|
+
return { success: true, config };
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return { success: false, error: error.message };
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Prints configuration summary (with secrets redacted)
|
|
140
|
+
* @param {object} config - Configuration object
|
|
141
|
+
*/
|
|
142
|
+
function printConfigSummary(config) {
|
|
143
|
+
console.error('[MCP Proxy] Configuration:');
|
|
144
|
+
console.error(` Base URL: ${config.baseUrl}`);
|
|
145
|
+
console.error(` API Key: ${config.apiKey.substring(0, 8)}...`);
|
|
146
|
+
console.error(` Log Level: ${config.logLevel}`);
|
|
147
|
+
console.error(` Log Format: ${config.logFormat}`);
|
|
148
|
+
console.error(` Request Timeout: ${config.requestTimeout}ms`);
|
|
149
|
+
console.error(` Max Retries: ${config.maxRetries}`);
|
|
150
|
+
console.error(` Token Refresh Margin: ${config.tokenRefreshMargin}s`);
|
|
151
|
+
if (config.tokenCacheDir) {
|
|
152
|
+
console.error(` Token Cache Dir: ${config.tokenCacheDir}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
loadConfig,
|
|
158
|
+
validateConfig,
|
|
159
|
+
printConfigSummary,
|
|
160
|
+
configSchema
|
|
161
|
+
};
|
package/lib/logger.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logging module using Pino
|
|
3
|
+
* Provides production-ready logging with levels, formatting, and redaction
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const pino = require('pino');
|
|
7
|
+
|
|
8
|
+
// Log levels mapping
|
|
9
|
+
const LOG_LEVELS = {
|
|
10
|
+
TRACE: 10,
|
|
11
|
+
DEBUG: 20,
|
|
12
|
+
INFO: 30,
|
|
13
|
+
WARN: 40,
|
|
14
|
+
ERROR: 50
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Get log level from environment (default: INFO)
|
|
18
|
+
const logLevel = process.env.UTEAMUP_LOG_LEVEL?.toUpperCase() || 'INFO';
|
|
19
|
+
const logFormat = process.env.UTEAMUP_LOG_FORMAT?.toLowerCase() || 'text';
|
|
20
|
+
|
|
21
|
+
// Legacy UTEAMUP_DEBUG support (maps to DEBUG level)
|
|
22
|
+
const effectiveLevel = process.env.UTEAMUP_DEBUG === '1' || process.env.UTEAMUP_DEBUG === 'true'
|
|
23
|
+
? 'DEBUG'
|
|
24
|
+
: logLevel;
|
|
25
|
+
|
|
26
|
+
// Pino configuration
|
|
27
|
+
const pinoOptions = {
|
|
28
|
+
level: (LOG_LEVELS[effectiveLevel] !== undefined ? effectiveLevel : 'INFO').toLowerCase(),
|
|
29
|
+
// Use stderr for all logs (stdout reserved for JSON-RPC protocol)
|
|
30
|
+
browser: { write: (msg) => process.stderr.write(msg) },
|
|
31
|
+
|
|
32
|
+
// Redact sensitive fields
|
|
33
|
+
redact: {
|
|
34
|
+
paths: [
|
|
35
|
+
'Authorization',
|
|
36
|
+
'client_secret',
|
|
37
|
+
'code_verifier',
|
|
38
|
+
'access_token',
|
|
39
|
+
'accessToken',
|
|
40
|
+
'bearer',
|
|
41
|
+
'*.Authorization',
|
|
42
|
+
'*.client_secret',
|
|
43
|
+
'*.code_verifier',
|
|
44
|
+
'*.access_token',
|
|
45
|
+
'*.accessToken'
|
|
46
|
+
],
|
|
47
|
+
remove: true
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// Production: JSON format, Development: pretty-printed
|
|
51
|
+
transport: logFormat === 'json' || process.env.NODE_ENV === 'production'
|
|
52
|
+
? undefined
|
|
53
|
+
: {
|
|
54
|
+
target: 'pino-pretty',
|
|
55
|
+
options: {
|
|
56
|
+
colorize: true,
|
|
57
|
+
translateTime: 'HH:MM:ss.l',
|
|
58
|
+
ignore: 'pid,hostname',
|
|
59
|
+
messageFormat: '[MCP Proxy] {msg}'
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Create logger instance
|
|
65
|
+
const logger = pino(pinoOptions, pino.destination({ dest: 2 })); // 2 = stderr
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Redacts sensitive information from objects for logging
|
|
69
|
+
* @param {any} data - Data to redact
|
|
70
|
+
* @returns {any} Redacted data
|
|
71
|
+
*/
|
|
72
|
+
function redactSensitive(data) {
|
|
73
|
+
if (!data) return data;
|
|
74
|
+
if (typeof data !== 'object') return data;
|
|
75
|
+
|
|
76
|
+
const sensitiveKeys = [
|
|
77
|
+
'authorization',
|
|
78
|
+
'client_secret',
|
|
79
|
+
'code_verifier',
|
|
80
|
+
'access_token',
|
|
81
|
+
'accesstoken',
|
|
82
|
+
'bearer',
|
|
83
|
+
'password',
|
|
84
|
+
'secret'
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const redacted = Array.isArray(data) ? [...data] : { ...data };
|
|
88
|
+
|
|
89
|
+
for (const key in redacted) {
|
|
90
|
+
const lowerKey = key.toLowerCase();
|
|
91
|
+
|
|
92
|
+
if (sensitiveKeys.some(sk => lowerKey.includes(sk))) {
|
|
93
|
+
redacted[key] = '[REDACTED]';
|
|
94
|
+
} else if (typeof redacted[key] === 'object' && redacted[key] !== null) {
|
|
95
|
+
redacted[key] = redactSensitive(redacted[key]);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return redacted;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Creates a child logger with additional context
|
|
104
|
+
* @param {object} context - Context to add to all logs
|
|
105
|
+
* @returns {object} Child logger
|
|
106
|
+
*/
|
|
107
|
+
function createChildLogger(context) {
|
|
108
|
+
return logger.child(context);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Trace-level logging (most verbose)
|
|
113
|
+
* @param {string} message - Log message
|
|
114
|
+
* @param {object} context - Additional context
|
|
115
|
+
*/
|
|
116
|
+
function trace(message, context = {}) {
|
|
117
|
+
logger.trace(redactSensitive(context), message);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Debug-level logging
|
|
122
|
+
* @param {string} message - Log message
|
|
123
|
+
* @param {object} context - Additional context
|
|
124
|
+
*/
|
|
125
|
+
function debug(message, context = {}) {
|
|
126
|
+
logger.debug(redactSensitive(context), message);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Info-level logging
|
|
131
|
+
* @param {string} message - Log message
|
|
132
|
+
* @param {object} context - Additional context
|
|
133
|
+
*/
|
|
134
|
+
function info(message, context = {}) {
|
|
135
|
+
logger.info(redactSensitive(context), message);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Warning-level logging
|
|
140
|
+
* @param {string} message - Log message
|
|
141
|
+
* @param {object} context - Additional context
|
|
142
|
+
*/
|
|
143
|
+
function warn(message, context = {}) {
|
|
144
|
+
logger.warn(redactSensitive(context), message);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Error-level logging
|
|
149
|
+
* @param {string} message - Log message
|
|
150
|
+
* @param {object|Error} contextOrError - Additional context or Error object
|
|
151
|
+
*/
|
|
152
|
+
function error(message, contextOrError = {}) {
|
|
153
|
+
if (contextOrError instanceof Error) {
|
|
154
|
+
logger.error({ err: contextOrError }, message);
|
|
155
|
+
} else {
|
|
156
|
+
logger.error(redactSensitive(contextOrError), message);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
module.exports = {
|
|
161
|
+
logger,
|
|
162
|
+
createChildLogger,
|
|
163
|
+
redactSensitive,
|
|
164
|
+
trace,
|
|
165
|
+
debug,
|
|
166
|
+
info,
|
|
167
|
+
warn,
|
|
168
|
+
error,
|
|
169
|
+
LOG_LEVELS
|
|
170
|
+
};
|
package/lib/metrics.js
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Performance monitoring and metrics tracking
|
|
3
|
+
* Tracks timing, cache hits/misses, and other performance metrics
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { debug, info } = require('./logger');
|
|
7
|
+
|
|
8
|
+
// In-memory metrics storage
|
|
9
|
+
const metrics = {
|
|
10
|
+
oauth: {
|
|
11
|
+
totalAttempts: 0,
|
|
12
|
+
successCount: 0,
|
|
13
|
+
failureCount: 0,
|
|
14
|
+
totalDurationMs: 0,
|
|
15
|
+
avgDurationMs: 0
|
|
16
|
+
},
|
|
17
|
+
requests: {
|
|
18
|
+
totalCount: 0,
|
|
19
|
+
successCount: 0,
|
|
20
|
+
errorCount: 0,
|
|
21
|
+
totalDurationMs: 0,
|
|
22
|
+
avgDurationMs: 0
|
|
23
|
+
},
|
|
24
|
+
tokenCache: {
|
|
25
|
+
hits: 0,
|
|
26
|
+
misses: 0,
|
|
27
|
+
hitRate: 0
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Starts a timer and returns a function to stop it
|
|
33
|
+
* @param {string} label - Timer label for logging
|
|
34
|
+
* @returns {Function} Function to stop the timer and return elapsed time
|
|
35
|
+
*/
|
|
36
|
+
function startTimer(label) {
|
|
37
|
+
const startTime = Date.now();
|
|
38
|
+
|
|
39
|
+
return function stopTimer() {
|
|
40
|
+
const elapsed = Date.now() - startTime;
|
|
41
|
+
debug(`Timer [${label}] completed`, { durationMs: elapsed });
|
|
42
|
+
return elapsed;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Records OAuth flow metrics
|
|
48
|
+
* @param {boolean} success - Whether OAuth succeeded
|
|
49
|
+
* @param {number} durationMs - Duration in milliseconds
|
|
50
|
+
*/
|
|
51
|
+
function recordOAuthAttempt(success, durationMs) {
|
|
52
|
+
metrics.oauth.totalAttempts++;
|
|
53
|
+
|
|
54
|
+
if (success) {
|
|
55
|
+
metrics.oauth.successCount++;
|
|
56
|
+
} else {
|
|
57
|
+
metrics.oauth.failureCount++;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
metrics.oauth.totalDurationMs += durationMs;
|
|
61
|
+
metrics.oauth.avgDurationMs = Math.round(
|
|
62
|
+
metrics.oauth.totalDurationMs / metrics.oauth.totalAttempts
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
info('OAuth attempt recorded', {
|
|
66
|
+
success,
|
|
67
|
+
durationMs,
|
|
68
|
+
avgDurationMs: metrics.oauth.avgDurationMs,
|
|
69
|
+
successRate: (metrics.oauth.successCount / metrics.oauth.totalAttempts * 100).toFixed(2) + '%'
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Records MCP request metrics
|
|
75
|
+
* @param {boolean} success - Whether request succeeded
|
|
76
|
+
* @param {number} durationMs - Duration in milliseconds
|
|
77
|
+
* @param {string} method - Request method
|
|
78
|
+
*/
|
|
79
|
+
function recordRequest(success, durationMs, method) {
|
|
80
|
+
metrics.requests.totalCount++;
|
|
81
|
+
|
|
82
|
+
if (success) {
|
|
83
|
+
metrics.requests.successCount++;
|
|
84
|
+
} else {
|
|
85
|
+
metrics.requests.errorCount++;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
metrics.requests.totalDurationMs += durationMs;
|
|
89
|
+
metrics.requests.avgDurationMs = Math.round(
|
|
90
|
+
metrics.requests.totalDurationMs / metrics.requests.totalCount
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
debug('Request recorded', {
|
|
94
|
+
method,
|
|
95
|
+
success,
|
|
96
|
+
durationMs,
|
|
97
|
+
avgDurationMs: metrics.requests.avgDurationMs,
|
|
98
|
+
successRate: (metrics.requests.successCount / metrics.requests.totalCount * 100).toFixed(2) + '%'
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Records token cache hit
|
|
104
|
+
*/
|
|
105
|
+
function recordCacheHit() {
|
|
106
|
+
metrics.tokenCache.hits++;
|
|
107
|
+
updateCacheHitRate();
|
|
108
|
+
debug('Token cache hit', {
|
|
109
|
+
hits: metrics.tokenCache.hits,
|
|
110
|
+
hitRate: metrics.tokenCache.hitRate
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Records token cache miss
|
|
116
|
+
*/
|
|
117
|
+
function recordCacheMiss() {
|
|
118
|
+
metrics.tokenCache.misses++;
|
|
119
|
+
updateCacheHitRate();
|
|
120
|
+
debug('Token cache miss', {
|
|
121
|
+
misses: metrics.tokenCache.misses,
|
|
122
|
+
hitRate: metrics.tokenCache.hitRate
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Updates token cache hit rate
|
|
128
|
+
*/
|
|
129
|
+
function updateCacheHitRate() {
|
|
130
|
+
const total = metrics.tokenCache.hits + metrics.tokenCache.misses;
|
|
131
|
+
metrics.tokenCache.hitRate = total > 0
|
|
132
|
+
? parseFloat((metrics.tokenCache.hits / total * 100).toFixed(2))
|
|
133
|
+
: 0;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Gets current metrics snapshot
|
|
138
|
+
* @returns {object} Current metrics
|
|
139
|
+
*/
|
|
140
|
+
function getMetrics() {
|
|
141
|
+
return JSON.parse(JSON.stringify(metrics)); // Deep copy
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Resets all metrics (useful for testing)
|
|
146
|
+
*/
|
|
147
|
+
function resetMetrics() {
|
|
148
|
+
metrics.oauth.totalAttempts = 0;
|
|
149
|
+
metrics.oauth.successCount = 0;
|
|
150
|
+
metrics.oauth.failureCount = 0;
|
|
151
|
+
metrics.oauth.totalDurationMs = 0;
|
|
152
|
+
metrics.oauth.avgDurationMs = 0;
|
|
153
|
+
|
|
154
|
+
metrics.requests.totalCount = 0;
|
|
155
|
+
metrics.requests.successCount = 0;
|
|
156
|
+
metrics.requests.errorCount = 0;
|
|
157
|
+
metrics.requests.totalDurationMs = 0;
|
|
158
|
+
metrics.requests.avgDurationMs = 0;
|
|
159
|
+
|
|
160
|
+
metrics.tokenCache.hits = 0;
|
|
161
|
+
metrics.tokenCache.misses = 0;
|
|
162
|
+
metrics.tokenCache.hitRate = 0;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Prints metrics summary
|
|
167
|
+
*/
|
|
168
|
+
function printMetrics() {
|
|
169
|
+
info('Performance Metrics Summary', {
|
|
170
|
+
oauth: {
|
|
171
|
+
attempts: metrics.oauth.totalAttempts,
|
|
172
|
+
successRate: metrics.oauth.totalAttempts > 0
|
|
173
|
+
? (metrics.oauth.successCount / metrics.oauth.totalAttempts * 100).toFixed(2) + '%'
|
|
174
|
+
: 'N/A',
|
|
175
|
+
avgDurationMs: metrics.oauth.avgDurationMs
|
|
176
|
+
},
|
|
177
|
+
requests: {
|
|
178
|
+
total: metrics.requests.totalCount,
|
|
179
|
+
successRate: metrics.requests.totalCount > 0
|
|
180
|
+
? (metrics.requests.successCount / metrics.requests.totalCount * 100).toFixed(2) + '%'
|
|
181
|
+
: 'N/A',
|
|
182
|
+
avgDurationMs: metrics.requests.avgDurationMs
|
|
183
|
+
},
|
|
184
|
+
tokenCache: {
|
|
185
|
+
hits: metrics.tokenCache.hits,
|
|
186
|
+
misses: metrics.tokenCache.misses,
|
|
187
|
+
hitRate: metrics.tokenCache.hitRate + '%'
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = {
|
|
193
|
+
startTimer,
|
|
194
|
+
recordOAuthAttempt,
|
|
195
|
+
recordRequest,
|
|
196
|
+
recordCacheHit,
|
|
197
|
+
recordCacheMiss,
|
|
198
|
+
getMetrics,
|
|
199
|
+
resetMetrics,
|
|
200
|
+
printMetrics
|
|
201
|
+
};
|
package/lib/retry.js
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry logic with exponential backoff
|
|
3
|
+
* Provides resilient error recovery for transient failures
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const { warn, error } = require('./logger');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Sleep utility
|
|
10
|
+
* @param {number} ms - Milliseconds to sleep
|
|
11
|
+
* @returns {Promise<void>}
|
|
12
|
+
*/
|
|
13
|
+
function sleep(ms) {
|
|
14
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Determines if an error is retryable
|
|
19
|
+
* @param {Error} err - Error to check
|
|
20
|
+
* @returns {boolean} Whether error is retryable
|
|
21
|
+
*/
|
|
22
|
+
function isRetryableError(err) {
|
|
23
|
+
if (!err) return false;
|
|
24
|
+
|
|
25
|
+
// Network errors are retryable
|
|
26
|
+
const networkErrors = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN'];
|
|
27
|
+
if (networkErrors.includes(err.code)) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Timeout errors are retryable
|
|
32
|
+
if (err.message && err.message.toLowerCase().includes('timeout')) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// HTTP 429 (rate limiting) is retryable
|
|
37
|
+
if (err.statusCode === 429 || err.status === 429) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// HTTP 5xx errors are retryable
|
|
42
|
+
if (err.statusCode >= 500 && err.statusCode < 600) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (err.status >= 500 && err.status < 600) {
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Not retryable
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Calculates exponential backoff delay
|
|
55
|
+
* @param {number} attempt - Current attempt number (1-indexed)
|
|
56
|
+
* @param {number} baseDelay - Base delay in ms (default: 1000)
|
|
57
|
+
* @param {number} maxDelay - Maximum delay in ms (default: 10000)
|
|
58
|
+
* @returns {number} Delay in milliseconds
|
|
59
|
+
*/
|
|
60
|
+
function calculateBackoff(attempt, baseDelay = 1000, maxDelay = 10000) {
|
|
61
|
+
const delay = Math.min(baseDelay * Math.pow(2, attempt - 1), maxDelay);
|
|
62
|
+
// Add jitter (±20%) to prevent thundering herd
|
|
63
|
+
const jitter = delay * 0.2 * (Math.random() * 2 - 1);
|
|
64
|
+
return Math.floor(delay + jitter);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Retries a function with exponential backoff
|
|
69
|
+
* @param {Function} fn - Async function to retry
|
|
70
|
+
* @param {object} options - Retry options
|
|
71
|
+
* @param {number} options.maxRetries - Maximum retry attempts (default: 3)
|
|
72
|
+
* @param {number} options.baseDelay - Base delay in ms (default: 1000)
|
|
73
|
+
* @param {number} options.maxDelay - Maximum delay in ms (default: 10000)
|
|
74
|
+
* @param {Function} options.shouldRetry - Custom function to determine if error is retryable
|
|
75
|
+
* @param {string} options.operationName - Name of operation for logging
|
|
76
|
+
* @returns {Promise<any>} Result of successful function call
|
|
77
|
+
* @throws {Error} Last error if all retries fail
|
|
78
|
+
*/
|
|
79
|
+
async function retryWithBackoff(fn, options = {}) {
|
|
80
|
+
const {
|
|
81
|
+
maxRetries = 3,
|
|
82
|
+
baseDelay = 1000,
|
|
83
|
+
maxDelay = 10000,
|
|
84
|
+
shouldRetry = isRetryableError,
|
|
85
|
+
operationName = 'operation'
|
|
86
|
+
} = options;
|
|
87
|
+
|
|
88
|
+
let lastError;
|
|
89
|
+
|
|
90
|
+
for (let attempt = 1; attempt <= maxRetries + 1; attempt++) {
|
|
91
|
+
try {
|
|
92
|
+
const result = await fn();
|
|
93
|
+
return result;
|
|
94
|
+
} catch (err) {
|
|
95
|
+
lastError = err;
|
|
96
|
+
|
|
97
|
+
// If this was the last attempt, throw the error
|
|
98
|
+
if (attempt > maxRetries) {
|
|
99
|
+
error(`${operationName} failed after ${maxRetries} retries`, { error: err.message });
|
|
100
|
+
throw err;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Check if error is retryable
|
|
104
|
+
if (!shouldRetry(err)) {
|
|
105
|
+
error(`${operationName} failed with non-retryable error`, { error: err.message });
|
|
106
|
+
throw err;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Calculate backoff delay
|
|
110
|
+
const delay = calculateBackoff(attempt, baseDelay, maxDelay);
|
|
111
|
+
|
|
112
|
+
warn(`${operationName} failed (attempt ${attempt}/${maxRetries + 1}), retrying in ${delay}ms`, {
|
|
113
|
+
error: err.message,
|
|
114
|
+
attempt,
|
|
115
|
+
maxRetries,
|
|
116
|
+
delayMs: delay
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Wait before retrying
|
|
120
|
+
await sleep(delay);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Should never reach here, but just in case
|
|
125
|
+
throw lastError;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Retries a function with a simple fixed delay
|
|
130
|
+
* @param {Function} fn - Async function to retry
|
|
131
|
+
* @param {number} maxRetries - Maximum retry attempts
|
|
132
|
+
* @param {number} delayMs - Fixed delay between retries in ms
|
|
133
|
+
* @returns {Promise<any>} Result of successful function call
|
|
134
|
+
* @throws {Error} Last error if all retries fail
|
|
135
|
+
*/
|
|
136
|
+
async function retryWithFixedDelay(fn, maxRetries = 3, delayMs = 1000) {
|
|
137
|
+
return retryWithBackoff(fn, {
|
|
138
|
+
maxRetries,
|
|
139
|
+
baseDelay: delayMs,
|
|
140
|
+
maxDelay: delayMs // Same as base = fixed delay
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = {
|
|
145
|
+
sleep,
|
|
146
|
+
isRetryableError,
|
|
147
|
+
calculateBackoff,
|
|
148
|
+
retryWithBackoff,
|
|
149
|
+
retryWithFixedDelay
|
|
150
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uteamup/mcp-server",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.8",
|
|
4
4
|
"description": "MCP server client for UteamUP - enables Claude Desktop/Code integration with automatic OAuth 2.0 authentication",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
},
|
|
58
58
|
"files": [
|
|
59
59
|
"index.js",
|
|
60
|
+
"lib/",
|
|
60
61
|
"README.md",
|
|
61
62
|
"LICENSE",
|
|
62
63
|
"CHANGELOG.md"
|