This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import process from 'node:process';
|
||||
|
||||
const runtimeUrl = process.env.SHD_MCP_RUNTIME_URL || process.env.MCP_RUNTIME_URL || '';
|
||||
const accessToken = process.env.SHD_MCP_ACCESS_TOKEN || process.env.MCP_ACCESS_TOKEN || '';
|
||||
const requireAuth = process.argv.includes('--require-auth');
|
||||
const jsonOutput = process.argv.includes('--json');
|
||||
|
||||
if (!runtimeUrl) {
|
||||
fail('Set SHD_MCP_RUNTIME_URL to the approved MCP endpoint; no endpoint is assumed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const mcpUrl = new URL(runtimeUrl);
|
||||
const origin = mcpUrl.origin;
|
||||
const checks = [];
|
||||
|
||||
function fail(message) {
|
||||
console.error(`MCP runtime smoke: ERROR: ${message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function addCheck(name, ok, detail = '') {
|
||||
checks.push({ name, ok, detail });
|
||||
if (!ok) throw new Error(`${name}${detail ? `: ${detail}` : ''}`);
|
||||
}
|
||||
|
||||
function safeErrorBody(value) {
|
||||
if (!value) return '';
|
||||
return String(value).replace(/Bearer\s+[^\s]+/gi, 'Bearer [redacted]').replace(/(access_token|refresh_token|code|client_secret|cookie)=?[^&\s]*/gi, '$1=[redacted]').slice(0, 240);
|
||||
}
|
||||
|
||||
async function request(url, options = {}) {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
|
||||
...(options.headers || {}),
|
||||
},
|
||||
});
|
||||
const text = await response.text();
|
||||
let body = null;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { body = null; }
|
||||
return { response, body, text };
|
||||
}
|
||||
|
||||
async function expectJson(url, name, expectedStatus = 200) {
|
||||
const result = await request(url);
|
||||
addCheck(`${name} HTTP`, result.response.status === expectedStatus, `received ${result.response.status}`);
|
||||
addCheck(`${name} JSON`, result.body && typeof result.body === 'object', safeErrorBody(result.text));
|
||||
return result.body;
|
||||
}
|
||||
|
||||
async function rpc(method, params, id, sessionId) {
|
||||
const headers = { Accept: 'application/json, text/event-stream' };
|
||||
if (sessionId) headers['MCP-Session-Id'] = sessionId;
|
||||
const result = await request(mcpUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id, method, params }),
|
||||
});
|
||||
addCheck(`${method} HTTP`, result.response.ok, `received ${result.response.status}`);
|
||||
addCheck(`${method} JSON`, result.body && typeof result.body === 'object', safeErrorBody(result.text));
|
||||
if (result.body?.error) throw new Error(`${method}: MCP returned an error`);
|
||||
return { result: result.body?.result || {}, sessionId: result.response.headers.get('MCP-Session-Id') || sessionId };
|
||||
}
|
||||
|
||||
async function notifyRpc(method, params, sessionId) {
|
||||
const headers = { Accept: 'application/json, text/event-stream' };
|
||||
if (sessionId) headers['MCP-Session-Id'] = sessionId;
|
||||
const result = await request(mcpUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ jsonrpc: '2.0', method, params }),
|
||||
});
|
||||
addCheck(`${method} HTTP`, result.response.ok, `received ${result.response.status}`);
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const authorization = await expectJson(`${origin}/.well-known/oauth-authorization-server`, 'OAuth authorization metadata');
|
||||
addCheck('OAuth issuer', typeof authorization.issuer === 'string' && authorization.issuer.length > 0);
|
||||
addCheck('OAuth authorization endpoint', typeof authorization.authorization_endpoint === 'string');
|
||||
addCheck('OAuth token endpoint', typeof authorization.token_endpoint === 'string');
|
||||
addCheck('OAuth PKCE S256', Array.isArray(authorization.code_challenge_methods_supported) && authorization.code_challenge_methods_supported.includes('S256'));
|
||||
const protectedResource = await expectJson(`${origin}/.well-known/oauth-protected-resource/mcp`, 'OAuth protected-resource metadata');
|
||||
addCheck('Protected resource', typeof protectedResource.resource === 'string' && protectedResource.resource.startsWith(origin));
|
||||
addCheck('Bearer header support', Array.isArray(protectedResource.bearer_methods_supported) && protectedResource.bearer_methods_supported.includes('header'));
|
||||
|
||||
if (!accessToken) {
|
||||
addCheck('Authenticated MCP checks', !requireAuth, 'SHD_MCP_ACCESS_TOKEN is not set');
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionId;
|
||||
const initialized = await rpc('initialize', {
|
||||
protocolVersion: '2025-11-25',
|
||||
capabilities: {},
|
||||
clientInfo: { name: 'shd-mcp-plugin-runtime-smoke', version: '1.0.0' },
|
||||
}, 1);
|
||||
sessionId = initialized.sessionId;
|
||||
addCheck('MCP protocol', initialized.result.protocolVersion === '2025-11-25');
|
||||
await notifyRpc('notifications/initialized', {}, sessionId);
|
||||
const tools = (await rpc('tools/list', {}, 2, sessionId)).result.tools || [];
|
||||
const toolNames = new Set(tools.map((tool) => tool.name));
|
||||
for (const name of ['shd_list_projects', 'shd_render_projects_widget', 'shd_render_documents_widget', 'shd_render_tasks_widget', 'shd_render_finance_widget', 'shd_render_crm_widget', 'shd_render_discussions_widget', 'shd_render_notifications_widget', 'shd_render_scheduling_widget', 'shd_render_inventory_widget', 'shd_render_agents_widget', 'shd_render_status_page_widget', 'shd_render_project_db_widget', 'shd_render_notes_widget', 'shd_render_project_overview_widget', 'shd_render_files_widget', 'shd_render_proposals_widget', 'shd_render_terms_widget', 'shd_render_activity_widget', 'shd_render_organizations_widget', 'shd_render_gitea_widget', 'shd_render_schemes_widget', 'shd_render_task_kanban_widget', 'shd_render_team_workload_widget', 'shd_render_project_timeline_widget', 'shd_render_crm_funnel_widget', 'shd_render_finance_dashboard_widget', 'shd_render_proposal_approvals_widget', 'shd_render_documents_completeness_widget', 'shd_render_agents_health_widget', 'shd_render_schedule_calendar_widget', 'shd_render_acl_matrix_widget', 'shd_render_inventory_warnings_widget', 'shd_render_gitea_board_widget', 'shd_render_schemes_progress_widget', 'shd_render_notes_tree_widget', 'shd_render_project_db_preview_widget']) {
|
||||
addCheck(`Tool ${name}`, toolNames.has(name));
|
||||
}
|
||||
for (const [name, items] of [
|
||||
['shd_render_projects_widget', { projects: [{ id: 1, code: 'runtime-smoke', display_name: 'Runtime smoke', status: 'check', deadline: null }] }],
|
||||
['shd_render_documents_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'check', date: null, value: null }] }],
|
||||
['shd_render_tasks_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'check', date: null, value: null }] }],
|
||||
['shd_render_finance_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'check', date: null, value: null }] }],
|
||||
['shd_render_crm_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'check', date: null, value: null }] }],
|
||||
['shd_render_discussions_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'open', date: null, value: null }] }],
|
||||
['shd_render_notifications_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'unread', date: null, value: null }] }],
|
||||
['shd_render_scheduling_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'scheduled', date: null, value: null }] }],
|
||||
['shd_render_inventory_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'available', date: null, value: null }] }],
|
||||
['shd_render_agents_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'online', date: null, value: null }] }],
|
||||
['shd_render_status_page_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'up', date: null, value: '100%' }] }],
|
||||
['shd_render_project_db_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'ready', date: null, value: null }] }],
|
||||
['shd_render_notes_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'published', date: null, value: null }] }],
|
||||
['shd_render_project_overview_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'ready', date: null, value: null }] }],
|
||||
['shd_render_files_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'available', date: null, value: null }] }],
|
||||
['shd_render_proposals_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'draft', date: null, value: null }] }],
|
||||
['shd_render_terms_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'ready', date: null, value: null }] }],
|
||||
['shd_render_activity_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'new', date: null, value: null }] }],
|
||||
['shd_render_organizations_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'active', date: null, value: null }] }],
|
||||
['shd_render_gitea_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'open', date: null, value: null }] }],
|
||||
['shd_render_schemes_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'ready', date: null, value: null }] }],
|
||||
['shd_render_task_kanban_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'todo', date: null, value: null }] }],
|
||||
['shd_render_team_workload_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'normal', date: null, value: '8' }] }],
|
||||
['shd_render_project_timeline_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'planned', date: null, value: null }] }],
|
||||
['shd_render_crm_funnel_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'qualified', date: null, value: null }] }],
|
||||
['shd_render_finance_dashboard_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'draft', date: null, value: '100' }] }],
|
||||
['shd_render_proposal_approvals_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'pending', date: null, value: null }] }],
|
||||
['shd_render_documents_completeness_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'complete', date: null, value: '100' }] }],
|
||||
['shd_render_agents_health_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'online', date: null, value: null }] }],
|
||||
['shd_render_schedule_calendar_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'scheduled', date: null, value: null }] }],
|
||||
['shd_render_acl_matrix_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'member', date: null, value: null }] }],
|
||||
['shd_render_inventory_warnings_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'low_stock', date: null, value: '1' }] }],
|
||||
['shd_render_gitea_board_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'open', date: null, value: null }] }],
|
||||
['shd_render_schemes_progress_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'in_progress', date: null, value: '50' }] }],
|
||||
['shd_render_notes_tree_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'active', date: null, value: null }] }],
|
||||
['shd_render_project_db_preview_widget', { items: [{ id: 'runtime-smoke', title: 'Runtime smoke', status: 'ready', date: null, value: null }] }],
|
||||
]) {
|
||||
const render = await rpc('tools/call', { name, arguments: items }, 20 + checks.length, sessionId);
|
||||
const output = render.result.structuredContent;
|
||||
addCheck(`${name} output`, output?.ok === true && Array.isArray(output.data));
|
||||
}
|
||||
const resources = (await rpc('resources/list', {}, 3, sessionId)).result.resources || [];
|
||||
for (const uri of ['ui://shd/active-projects/v1.html', 'ui://shd/documents/v1.html', 'ui://shd/tasks/v1.html', 'ui://shd/finance/v1.html', 'ui://shd/crm/v1.html', 'ui://shd/discussions/v1.html', 'ui://shd/notifications/v1.html', 'ui://shd/scheduling/v1.html', 'ui://shd/inventory/v1.html', 'ui://shd/agents/v1.html', 'ui://shd/status-page/v1.html', 'ui://shd/project-db/v1.html', 'ui://shd/notes/v1.html', 'ui://shd/project-overview/v1.html', 'ui://shd/files/v1.html', 'ui://shd/proposals/v1.html', 'ui://shd/terms/v1.html', 'ui://shd/activity/v1.html', 'ui://shd/organizations/v1.html', 'ui://shd/gitea/v1.html', 'ui://shd/schemes/v1.html', 'ui://shd/task-kanban/v1.html', 'ui://shd/team-workload/v1.html', 'ui://shd/project-timeline/v1.html', 'ui://shd/crm-funnel/v1.html', 'ui://shd/finance-dashboard/v1.html', 'ui://shd/proposal-approvals/v1.html', 'ui://shd/documents-completeness/v1.html', 'ui://shd/agents-health/v1.html', 'ui://shd/schedule-calendar/v1.html', 'ui://shd/acl-matrix/v1.html', 'ui://shd/inventory-warnings/v1.html', 'ui://shd/gitea-board/v1.html', 'ui://shd/schemes-progress/v1.html', 'ui://shd/notes-tree/v1.html', 'ui://shd/project-db-preview/v1.html']) {
|
||||
addCheck(`Resource ${uri}`, resources.some((resource) => resource.uri === uri));
|
||||
const resourceIndex = resources.findIndex((resource) => resource.uri === uri);
|
||||
if (resourceIndex < 0) continue;
|
||||
const content = (await rpc('resources/read', { uri }, 10 + resourceIndex, sessionId)).result.contents?.[0];
|
||||
addCheck(`${uri} MIME`, content?.mimeType === 'text/html;profile=mcp-app');
|
||||
addCheck(`${uri} bridge`, ['ui/initialize', 'ui/notifications/initialized', 'tools/call'].every((marker) => content?.text?.includes(marker)));
|
||||
}
|
||||
addCheck('Authenticated MCP resource checks', true);
|
||||
}
|
||||
|
||||
try {
|
||||
await run();
|
||||
const result = { status: 'ok', authenticated: Boolean(accessToken), checks };
|
||||
if (jsonOutput) console.log(JSON.stringify(result, null, 2));
|
||||
else {
|
||||
console.log(`MCP runtime smoke: ok (${checks.length} checks; authenticated=${Boolean(accessToken)})`);
|
||||
for (const check of checks) console.log(`- ${check.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'unknown error';
|
||||
if (jsonOutput) console.log(JSON.stringify({ status: 'error', authenticated: Boolean(accessToken), checks, error: message }, null, 2));
|
||||
else fail(message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
Reference in New Issue
Block a user