@hellocoop/admin-mcp
Version:
Model Context Protocol (MCP) for Hellō Admin API.
271 lines (241 loc) • 9.41 kB
JavaScript
// Admin API client for MCP server
// Handles HTTP communication with Hello Admin API
import { HELLO_ADMIN } from './config.js';
import { apiLogInfo, apiLogError, getLogContext } from './log.js';
import { createMCPContent } from './utils.js';
export class AdminAPIClient {
constructor(authManager) {
this.authManager = authManager;
}
/**
* Make a REST call to the Admin API using built-in fetch
* @param {string} method - HTTP method (GET, POST, PUT, DELETE)
* @param {string} path - API path
* @param {Object} data - Request data for POST/PUT
* @param {Object} options - Additional options
* @returns {Promise<Object>} - API response data
*/
async callAdminAPI(method, path, data, options = {}) {
const { requiresAuth = true, isRetry = false } = options;
const startTime = performance.now();
// Trigger authentication if we need auth but don't have a token
if (requiresAuth && !this.authManager.getAccessToken() && this.authManager.getAuthenticationCallback()) {
try {
const token = await this.authManager.getAuthenticationCallback()();
this.authManager.setAccessToken(token);
} catch (error) {
apiLogError({
event: 'admin_api_auth_failed',
startTime,
message: `Authentication failed: ${error.message}`,
extra: { method, path, error: error.message }
});
throw new Error(`Authentication failed: ${error.message}`);
}
}
const url = HELLO_ADMIN + path;
const headers = {
...(requiresAuth && this.authManager.getAccessToken() && {
Authorization: `Bearer ${this.authManager.getAccessToken()}`
})
};
// Only add Content-Type header if we have data to send
if (data && (method.toUpperCase() === 'POST' || method.toUpperCase() === 'PUT')) {
headers['Content-Type'] = 'application/json';
}
const requestOptions = {
method: method.toUpperCase(),
headers
};
if (data && (method.toUpperCase() === 'POST' || method.toUpperCase() === 'PUT')) {
requestOptions.body = JSON.stringify(data);
}
// Enhanced logging with JWT payload information
const logExtra = {
url,
method: method.toUpperCase(),
path,
hasAuth: !!this.authManager.getAccessToken(),
hasData: !!data
};
// Add user context if available
const jwtPayload = this.authManager.getJWTPayload();
if (jwtPayload) {
logExtra.user = {
sub: jwtPayload.sub,
jti: jwtPayload.jti,
scope: jwtPayload.scope
};
}
apiLogInfo({
event: 'admin_api_call',
startTime,
message: `Admin API ${method.toUpperCase()} ${path}`,
extra: logExtra
});
// Debug level logging for request parameters
const context = getLogContext();
if (context && context.logger) {
context.logger.debug({
event: 'admin_api_call_debug',
method: method.toUpperCase(),
path,
url,
requestData: data,
headers: Object.keys(headers),
hasAuth: !!this.authManager.getAccessToken()
}, `Admin API ${method.toUpperCase()} ${path} - Request Details`);
}
try {
const response = await fetch(url, requestOptions);
const responseData = await response.json();
// Handle token expiration (401 Unauthorized)
if (response.status === 401 && requiresAuth && !isRetry && this.authManager.getAuthenticationCallback()) {
this.authManager.setAccessToken(null); // Clear expired token
try {
// Trigger new OAuth flow
const newToken = await this.authManager.getAuthenticationCallback()();
this.authManager.setAccessToken(newToken);
// Retry the original request with new token
return await this.callAdminAPI(method, path, data, { requiresAuth, isRetry: true });
} catch (authError) {
apiLogError({
event: 'admin_api_reauth_failed',
startTime,
message: `Re-authentication failed: ${authError.message}`,
extra: { method, path, error: authError.message }
});
throw new Error(`Re-authentication failed: ${authError.message}`);
}
}
// Handle HTTP error responses
if (!response.ok) {
if (response.status === 401) {
// Authentication errors - return special object for handling
const wwwAuthHeader = response.headers.get('WWW-Authenticate');
apiLogError({
event: 'admin_api_auth_error',
startTime,
message: `Admin API authentication error: ${response.status}`,
extra: { method, path, status: response.status, wwwAuthHeader }
});
return {
_httpStatus: response.status,
_httpHeaders: wwwAuthHeader ? { 'WWW-Authenticate': wwwAuthHeader } : {},
error: responseData.error || 'invalid_token'
};
} else if (response.status === 404) {
// Not found errors - throw as regular errors
apiLogError({
event: 'admin_api_not_found',
startTime,
message: `Admin API not found: ${response.status}`,
extra: { method, path, status: response.status }
});
throw new Error(`Resource not found: ${path}`);
} else {
// Other HTTP errors
apiLogError({
event: 'admin_api_error',
startTime,
message: `Admin API error: ${response.status}`,
extra: { method, path, status: response.status }
});
throw new Error(`API request failed with status ${response.status}: ${responseData.message || responseData.error || 'Unknown error'}`);
}
}
// Log successful response
apiLogInfo({
event: 'admin_api_response',
startTime,
message: `Admin API response ${response.status}`,
extra: {
method,
path,
status: response.status,
duration_ms: performance.now() - startTime
}
});
// Debug level logging for response data
if (context && context.logger) {
context.logger.debug({
event: 'admin_api_response_debug',
method: method.toUpperCase(),
path,
status: response.status,
responseData: responseData,
duration_ms: performance.now() - startTime
}, `Admin API ${method.toUpperCase()} ${path} - Response Details`);
}
return responseData;
} catch (error) {
apiLogError({
event: 'admin_api_error',
startTime,
message: `Admin API error: ${error.message}`,
extra: { method, path, error: error.message }
});
throw error;
}
}
/**
* Wrapper for callAdminAPI that returns MCP-formatted contents
* @param {string} method - HTTP method
* @param {string} path - API path
* @param {Object} data - Request data
* @param {Object} options - Additional options
* @returns {Promise<Object>} - MCP-formatted response
*/
async callAdminAPIForMCP(method, path, data, options = {}) {
const result = await this.callAdminAPI(method, path, data, options);
// Check if the result contains HTTP status information (authentication errors)
if (result && typeof result === 'object' && result._httpStatus) {
// This is an authentication error from the Admin API
const error = new Error('Authentication error');
error.httpStatus = result._httpStatus;
error.httpHeaders = result._httpHeaders || {};
error.errorData = {
error: result.error || 'authentication_failed',
error_description: result.error_description || 'Authentication failed'
};
throw error;
}
return createMCPContent(result);
}
/**
* Upload logo to Admin API
* @param {string} publisherId - Publisher ID
* @param {string} applicationId - Application ID
* @param {string} base64Data - Base64 encoded image data
* @param {string} mimeType - Image MIME type
* @returns {Promise<Object>} - Upload response with logo URL
*/
async uploadLogo(publisherId, applicationId, base64Data, mimeType) {
// Create FormData for file upload
const formData = new FormData();
// Convert base64 to blob
const binaryString = atob(base64Data);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const blob = new Blob([bytes], { type: mimeType });
// Add file to form data
formData.append('logo', blob, 'logo.png');
const url = `${HELLO_ADMIN}/api/v1/publishers/${publisherId}/applications/${applicationId}/logo`;
const headers = {
Authorization: `Bearer ${this.authManager.getAccessToken()}`
// Don't set Content-Type for FormData - let browser set it with boundary
};
const response = await fetch(url, {
method: 'POST',
headers,
body: formData
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Logo upload failed: ${response.status} ${response.statusText} - ${JSON.stringify(errorData)}`);
}
return await response.json();
}
}