@timesheet/mcp
Version:
Model Context Protocol server for Timesheet API
434 lines • 16.5 kB
JavaScript
/**
* MCP Apps Helper Functions
* Utilities for formatting tool responses with MCP Apps metadata (SEP-1865)
*
* Uses the standardized MCP Apps schema:
* - URI scheme: ui://timesheet/<component>.html
* - MIME type: text/html;profile=mcp-app
* - Metadata: _meta.ui.* (with OpenAI compat keys retained)
*/
import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
export { RESOURCE_MIME_TYPE };
const RESOURCE_URI_PREFIX = 'ui://timesheet';
/**
* Get the resource URI for a component
*/
export function getComponentResourceUri(componentName) {
return `${RESOURCE_URI_PREFIX}/${componentName}.html`;
}
// Get the component server base URL from environment or use ngrok URL
export function getComponentBaseUrl() {
return process.env.COMPONENT_BASE_URL || process.env.NGROK_URL || 'http://localhost:3000';
}
/**
* Add MCP Apps component metadata to a tool response
*/
export function addComponentMetadata(response, componentName, widgetDescription) {
const resourceUri = getComponentResourceUri(componentName);
const result = {
...response,
_meta: {
...(response._meta || {}),
// MCP Apps standard metadata
ui: {
resourceUri,
csp: { connectDomains: [], resourceDomains: [] },
prefersBorder: false,
visibility: ['model', 'app'],
},
// Keep OpenAI-specific keys for ChatGPT backward compatibility
'openai/widgetDescription': widgetDescription,
'openai/toolInvocation/invoking': componentName,
'openai/toolInvocation/invoked': componentName,
},
};
// Debug logging
console.error(`[MCP App] Component metadata for ${componentName}:`);
console.error(` - Resource URI: ${resourceUri}`);
console.error(` - Widget Description: ${widgetDescription}`);
return result;
}
/**
* Format timer response with component
*/
export function formatTimerResponse(timerData, profile, settings) {
// Build text content for non-widget MCP clients
let textContent = `Timer status: ${timerData.status}`;
if (timerData.projectTitle) {
textContent += `\nProject: ${timerData.projectTitle}`;
}
if (timerData.description) {
textContent += `\nDescription: ${timerData.description}`;
}
if (timerData.duration !== undefined) {
const hours = timerData.hours || 0;
const minutes = timerData.minutes || 0;
textContent += `\nDuration: ${hours}h ${minutes}m`;
}
return addComponentMetadata({
content: [
{
type: 'text',
text: textContent,
},
],
structuredContent: {
...timerData,
profile,
settings,
},
}, 'TimerWidget', 'Interactive timer display showing current status, duration, and controls to pause, resume, or stop the timer');
}
/**
* Format project list response with component
*/
export function formatProjectListResponse(projects, totalCount, queryParams, profile, settings) {
// Build text content for non-widget MCP clients
const projectList = projects
.map((p) => {
let line = `- ${p.title}`;
if (p.description) {
line += ` - ${p.description}`;
}
if (p.archived) {
line += ' [Archived]';
}
return line;
})
.join('\n');
const textContent = `Found ${totalCount} project${totalCount !== 1 ? 's' : ''}:\n\n${projectList}`;
return addComponentMetadata({
content: [
{
type: 'text',
text: textContent,
},
],
structuredContent: {
projects,
totalCount,
queryParams,
profile,
settings,
},
}, 'ProjectList', `List of ${totalCount} projects with color-coded indicators and clickable start buttons for each active project`);
}
/**
* Format project card response with component
*/
export function formatProjectCardResponse(project) {
// Build text content for non-widget MCP clients
let textContent = `Project: ${project.title || 'Untitled'}`;
if (project.description) {
textContent += `\nDescription: ${project.description}`;
}
if (project.archived) {
textContent += `\nStatus: Archived`;
}
return addComponentMetadata({
content: [
{
type: 'text',
text: textContent,
},
],
structuredContent: project,
}, 'ProjectCard', `Project card displaying details for "${project.title || 'project'}" including description and status`);
}
/**
* Format task list response with component
*/
export function formatTaskListResponse(tasks, queryParams, profile, settings) {
// Build text content for non-widget MCP clients
const taskList = tasks
.map((t) => {
const hours = t.hours || 0;
const minutes = t.minutes || 0;
let line = `- ${t.description || 'No description'} (${hours}h ${minutes}m)`;
if (t.projectTitle) {
line += ` - ${t.projectTitle}`;
}
if (t.billable) {
line += ' [Billable]';
}
return line;
})
.join('\n');
const textContent = `Found ${tasks.length} time entr${tasks.length !== 1 ? 'ies' : 'y'}:\n\n${taskList}`;
return addComponentMetadata({
content: [
{
type: 'text',
text: textContent,
},
],
structuredContent: {
tasks,
queryParams,
profile,
settings,
},
}, 'TaskList', `List of ${tasks.length} time entries grouped by date, showing project details, durations, tags, and billable status`);
}
/**
* Format task card response with component
*/
export function formatTaskCardResponse(task) {
const duration = task.duration || 0;
const hours = Math.floor(duration / 3600);
const minutes = Math.floor((duration % 3600) / 60);
return addComponentMetadata({
content: [
{
type: 'text',
text: `Task: ${task.description || 'No description'} (${hours}h ${minutes}m)${task.project?.title ? ` - ${task.project.title}` : ''}`,
},
],
structuredContent: task,
}, 'TaskCard', `Time entry card showing "${task.description || 'task'}" with ${hours}h ${minutes}m duration${task.project?.title ? ` on ${task.project.title}` : ''}`);
}
/**
* Format statistics response with component
*/
export function formatStatisticsResponse(stats, profile, settings) {
// Build detailed text content for non-widget MCP clients
const lines = [];
if (stats.startDate && stats.endDate) {
lines.push(`Period: ${stats.startDate} to ${stats.endDate}`);
}
const billablePct = stats.totalHours > 0
? Math.round((stats.billableHours / stats.totalHours) * 100)
: 0;
lines.push(`Total: ${stats.totalHours.toFixed(1)}h | Billable: ${stats.billableHours.toFixed(1)}h (${billablePct}%) | Tasks: ${stats.totalTasks ?? 0}`);
if (stats.totalBreakHours > 0) {
lines.push(`Breaks: ${stats.totalBreakHours.toFixed(1)}h`);
}
if (stats.projectBreakdown && stats.projectBreakdown.length > 0) {
lines.push('');
lines.push('Project Breakdown:');
for (const p of stats.projectBreakdown) {
lines.push(` - ${p.projectTitle}: ${p.hours.toFixed(1)}h (${p.percentage}%, ${p.taskCount} tasks)`);
}
}
if (stats.dailyHours && stats.dailyHours.length > 0) {
lines.push('');
lines.push('Daily Hours:');
for (const d of stats.dailyHours.slice(0, 14)) {
lines.push(` - ${d.date}: ${d.hours.toFixed(1)}h`);
}
if (stats.dailyHours.length > 14) {
lines.push(` ... and ${stats.dailyHours.length - 14} more days`);
}
}
const textContent = lines.join('\n');
return addComponentMetadata({
content: [
{
type: 'text',
text: textContent,
},
],
structuredContent: {
...stats,
profile,
settings,
},
}, 'Statistics', `Time tracking statistics dashboard showing ${stats.totalHours.toFixed(1)}h total (${stats.billableHours.toFixed(1)}h billable) across ${stats.totalTasks ?? 0} tasks with project breakdowns and ${stats.weeklyHours ? 'weekly' : 'daily'} charts`);
}
/**
* Format export template list response with component
*/
export function formatExportTemplateListResponse(templates, totalCount) {
// Build text content for non-widget MCP clients
const templateList = templates
.slice(0, 10)
.map((t) => {
let line = `- ${t.name}`;
if (t.format) {
line += ` [${t.format.toUpperCase()}]`;
}
if (t.summarize) {
line += ' (summarized)';
}
return line;
})
.join('\n');
const textContent = `Found ${totalCount} export template${totalCount !== 1 ? 's' : ''}:\n\n${templateList || 'No templates found'}${totalCount > 10 ? '\n...and more' : ''}`;
return addComponentMetadata({
content: [
{
type: 'text',
text: textContent,
},
],
structuredContent: {
templates,
totalCount,
},
}, 'ExportWidget', `Export widget with ${totalCount} template${totalCount !== 1 ? 's' : ''} available for generating timesheet exports`);
}
/**
* Get component metadata for tool definition (not response)
*/
export function getComponentMetadataForTool(componentName) {
return {
ui: {
resourceUri: getComponentResourceUri(componentName),
visibility: ['model', 'app'],
},
};
}
/**
* Get static widget description for resource metadata
* These are generic descriptions that apply to the widget regardless of data
*/
export function getStaticWidgetDescription(componentName) {
const descriptions = {
TimerWidget: 'Interactive timer widget displaying current timer status, elapsed duration, and controls to pause, resume, or stop time tracking',
ProjectList: 'Interactive list of projects with color-coded indicators, descriptions, and clickable start buttons to begin time tracking',
ProjectCard: 'Detailed project card showing project information, description, team, and status',
TaskList: 'Comprehensive time entries list grouped by date, showing project details, descriptions, durations, tags, and billable status',
TaskCard: 'Individual time entry card displaying task details, duration, project association, and billing information',
Statistics: 'Time tracking statistics dashboard with total hours, billable hours, project breakdowns with progress bars, and daily time charts',
ExportWidget: 'Interactive export widget with template selector, date range inputs, quick date presets, and generate button to create timesheet exports',
};
return descriptions[componentName] || `Interactive ${componentName} widget for time tracking`;
}
/**
* Get the MCP server's public URL (for OAuth resource identifier)
*/
export function getMcpServerUrl() {
return process.env.MCP_SERVER_URL || process.env.COMPONENT_BASE_URL || process.env.NGROK_URL || 'http://localhost:3000';
}
/**
* Get the Timesheet API base URL
*/
export function getApiBaseUrl() {
return process.env.TIMESHEET_API_URL || 'https://api.timesheet.io';
}
/**
* OAuth 2.1 authorization metadata for MCP Initialize response
* This tells ChatGPT how to authenticate with this MCP server
*/
export function getOAuthMetadata() {
const apiBaseUrl = getApiBaseUrl();
const mcpServerUrl = getMcpServerUrl();
return {
// OAuth 2.1 method identifier
method: 'oauth2',
// Protected resource metadata for this MCP server
resource: mcpServerUrl,
// Authorization server metadata location
authorization_servers: [apiBaseUrl],
// Direct endpoints for convenience
authorization_endpoint: `${apiBaseUrl}/oauth2/auth`,
token_endpoint: `${apiBaseUrl}/oauth2/token`,
registration_endpoint: `${apiBaseUrl}/oauth2/register`,
// Well-known discovery endpoints
metadata_uri: `${apiBaseUrl}/.well-known/oauth-authorization-server`,
protected_resource_metadata_uri: `${mcpServerUrl}/.well-known/oauth-protected-resource`,
};
}
/**
* Protected Resource Metadata (RFC 9728)
* This describes this MCP server as an OAuth 2.1 protected resource
* ChatGPT fetches this to discover how to authenticate
*/
export function getProtectedResourceMetadata() {
const apiBaseUrl = getApiBaseUrl();
const mcpServerUrl = getMcpServerUrl();
return {
// The resource identifier (this MCP server)
resource: mcpServerUrl,
// Authorization servers that can issue tokens for this resource
authorization_servers: [apiBaseUrl],
// Supported scopes (optional - Timesheet uses data-level permissions)
scopes_supported: ['openid', 'profile'],
// How Bearer tokens can be transmitted
bearer_methods_supported: ['header'],
// Documentation link
resource_documentation: 'https://docs.timesheet.io/mcp',
};
}
/**
* Authorization Server Metadata (RFC 8414)
* This describes the OAuth 2.1 authorization server capabilities
* ChatGPT fetches this to discover endpoints and supported features
*/
export function getAuthorizationServerMetadata() {
const apiBaseUrl = getApiBaseUrl();
return {
// Issuer identifier (authorization server URL)
issuer: apiBaseUrl,
// OAuth 2.1 endpoints
authorization_endpoint: `${apiBaseUrl}/oauth2/auth`,
token_endpoint: `${apiBaseUrl}/oauth2/token`,
// Dynamic Client Registration (DCR) endpoint
registration_endpoint: `${apiBaseUrl}/oauth2/register`,
// PKCE support - REQUIRED for ChatGPT
code_challenge_methods_supported: ['S256'],
// Supported grant types
grant_types_supported: ['authorization_code', 'refresh_token'],
// Supported response types
response_types_supported: ['code'],
// Token endpoint authentication methods
token_endpoint_auth_methods_supported: ['client_secret_basic', 'client_secret_post'],
// Supported scopes
scopes_supported: ['openid', 'profile', 'offline_access'],
// Service documentation
service_documentation: 'https://docs.timesheet.io/api/oauth',
};
}
/**
* Generate WWW-Authenticate header value for 401 responses
* Compliant with RFC 6750 (Bearer Token Usage)
*/
export function getWWWAuthenticateHeader(error, errorDescription) {
const apiBaseUrl = getApiBaseUrl();
const mcpServerUrl = getMcpServerUrl();
let header = `Bearer realm="${mcpServerUrl}"`;
// Add authorization server hint for discovery
header += `, authorization_uri="${apiBaseUrl}/oauth2/auth"`;
if (error) {
header += `, error="${error}"`;
}
if (errorDescription) {
header += `, error_description="${errorDescription}"`;
}
return header;
}
/**
* Extract Bearer token from Authorization header
* Returns null if no valid Bearer token found
*/
export function extractBearerToken(authorizationHeader) {
if (!authorizationHeader) {
return null;
}
// Check for Bearer scheme (case-insensitive per RFC 6750)
const match = authorizationHeader.match(/^Bearer\s+(.+)$/i);
if (!match) {
return null;
}
return match[1];
}
/**
* Check if a token looks like a valid JWT (basic format check)
*/
export function isJwtToken(token) {
// JWT has 3 base64url-encoded parts separated by dots
const parts = token.split('.');
if (parts.length !== 3) {
return false;
}
// Each part should be non-empty and base64url-ish
return parts.every(part => part.length > 0 && /^[A-Za-z0-9_-]+$/.test(part));
}
/**
* Check if a token looks like a Timesheet API key
*/
export function isApiKeyToken(token) {
// Timesheet API keys have format: ts_{prefix}.{secret}
return /^ts_[a-zA-Z0-9]+\.[a-zA-Z0-9]+$/.test(token);
}
//# sourceMappingURL=mcp-app-helpers.js.map