#!/usr/bin/env node
/**
* Content Plan Builder - MCP Server
*
* An MCP server that generates Asana project plans from arbitrary content.
*/
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
type Tool
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
import type { ContentPlanInput, ContentPlanOptions } from './types.js';
import { generatePlan, previewPlan } from './plan-generator.js';
import { createAsanaProject, validateAsanaCredentials } from './asana-client.js';
// Tool definition
const PLAN_FROM_CONTENT_TOOL: Tool = {
name: 'plan_from_content',
description: `Generate Asana project plans from arbitrary content (text, PDF, DOCX, transcripts).
This tool accepts various input types and uses AI to create comprehensive project plans with:
- 5 top-level tasks with 5-10 subtasks each
- Time estimates and role assignments
- Task dependencies
- SMART goals and key data points
Operations:
- generate: Create a plan from inputs
- preview: Preview plan without creating in Asana
- create: Generate plan and create project in Asana
Call without parameters for interactive discovery.`,
inputSchema: {
type: 'object',
properties: {
operation: {
type: 'string',
enum: ['generate', 'preview', 'create'],
description: 'Operation to perform'
},
inputs: {
type: 'array',
description: 'Array of content inputs',
items: {
type: 'object',
properties: {
type: {
type: 'string',
enum: ['text', 'file', 'transcript', 'meeting_notes'],
description: 'Type of input'
},
content: {
type: 'string',
description: 'Text content or file path'
},
fileName: {
type: 'string',
description: 'Original filename (for file inputs)'
}
},
required: ['type', 'content']
}
},
knowledgeBaseContext: {
type: 'string',
description: 'Additional context to include in generation'
},
projectName: {
type: 'string',
description: 'Name for the generated project'
},
creativity: {
type: 'string',
enum: ['conservative', 'balanced', 'expansive'],
description: 'Creativity level for AI generation'
},
outputFormat: {
type: 'string',
enum: ['detailed', 'compact', 'both'],
description: 'Output format preference'
},
asanaAccessToken: {
type: 'string',
description: 'Asana personal access token (for create operation)'
},
asanaTeamGid: {
type: 'string',
description: 'Asana team GID for new project'
},
targetProjectGid: {
type: 'string',
description: 'Add to existing Asana project instead of creating new'
}
}
}
};
// Input validation schema
const PlanFromContentInputSchema = z.object({
operation: z.enum(['generate', 'preview', 'create']).optional(),
inputs: z.array(z.object({
type: z.enum(['text', 'file', 'transcript', 'meeting_notes']),
content: z.string(),
fileName: z.string().optional(),
mimeType: z.string().optional()
})).optional(),
knowledgeBaseContext: z.string().optional(),
projectName: z.string().optional(),
creativity: z.enum(['conservative', 'balanced', 'expansive']).optional(),
outputFormat: z.enum(['detailed', 'compact', 'both']).optional(),
asanaAccessToken: z.string().optional(),
asanaTeamGid: z.string().optional(),
targetProjectGid: z.string().optional()
});
/**
* Handle the plan_from_content tool call
*/
async function handlePlanFromContent(args: unknown): Promise<string> {
// Parse and validate input
const parsed = PlanFromContentInputSchema.safeParse(args);
if (!parsed.success) {
return JSON.stringify({
error: 'Invalid input',
details: parsed.error.errors
}, null, 2);
}
const input = parsed.data;
// Interactive discovery if no operation specified
if (!input.operation) {
return getInteractiveHelp();
}
// Validate inputs are provided for generation
if (!input.inputs || input.inputs.length === 0) {
return JSON.stringify({
error: 'No inputs provided',
message: 'Please provide at least one input with type and content',
example: {
operation: 'generate',
inputs: [
{ type: 'text', content: 'Your meeting notes or project requirements here...' }
],
projectName: 'My Project Plan'
}
}, null, 2);
}
// Build options
const options: ContentPlanOptions = {
inputs: input.inputs as ContentPlanInput[],
knowledgeBaseContext: input.knowledgeBaseContext,
projectName: input.projectName,
creativity: input.creativity,
outputFormat: input.outputFormat || 'both',
createInAsana: input.operation === 'create',
asanaAccessToken: input.asanaAccessToken,
asanaTeamGid: input.asanaTeamGid,
targetProjectGid: input.targetProjectGid
};
switch (input.operation) {
case 'generate':
case 'preview': {
const result = input.operation === 'generate'
? await generatePlan(options)
: await previewPlan(options);
return JSON.stringify(result, null, 2);
}
case 'create': {
// Validate Asana credentials
if (!input.asanaAccessToken) {
return JSON.stringify({
error: 'Asana access token required',
message: 'Please provide asanaAccessToken for create operation'
}, null, 2);
}
if (!input.asanaTeamGid && !input.targetProjectGid) {
return JSON.stringify({
error: 'Asana team or project required',
message: 'Please provide asanaTeamGid (for new project) or targetProjectGid (for existing project)'
}, null, 2);
}
// Validate token
const isValid = await validateAsanaCredentials(input.asanaAccessToken);
if (!isValid) {
return JSON.stringify({
error: 'Invalid Asana credentials',
message: 'The provided Asana access token is invalid or expired'
}, null, 2);
}
// Generate plan first
const planResult = await generatePlan(options);
if (planResult.status === 'error' || !planResult.plan) {
return JSON.stringify(planResult, null, 2);
}
// Create in Asana
const asanaResult = await createAsanaProject(
planResult.plan,
input.asanaAccessToken,
input.asanaTeamGid || '',
input.projectName,
input.targetProjectGid
);
return JSON.stringify({
...planResult,
asanaResult
}, null, 2);
}
default:
return JSON.stringify({
error: 'Unknown operation',
validOperations: ['generate', 'preview', 'create']
}, null, 2);
}
}
/**
* Get interactive help text
*/
function getInteractiveHelp(): string {
return JSON.stringify({
tool: 'plan_from_content',
description: 'Generate Asana project plans from arbitrary content',
operations: {
generate: 'Create a project plan from inputs',
preview: 'Preview plan without saving',
create: 'Generate plan and create in Asana'
},
inputTypes: {
text: 'Raw text content',
file: 'File path to PDF, DOCX, TXT, MD, HTML, CSV, or JSON',
transcript: 'Meeting transcript text',
meeting_notes: 'Meeting notes text'
},
creativityLevels: {
conservative: 'Stick closely to source content',
balanced: 'Include implied tasks (default)',
expansive: 'Infer additional supporting tasks'
},
examples: [
{
description: 'Generate plan from text',
call: {
operation: 'generate',
inputs: [{ type: 'text', content: 'Project requirements...' }],
projectName: 'Q1 Website Redesign'
}
},
{
description: 'Generate from file',
call: {
operation: 'generate',
inputs: [{ type: 'file', content: '/path/to/requirements.pdf' }],
creativity: 'expansive'
}
},
{
description: 'Create in Asana',
call: {
operation: 'create',
inputs: [{ type: 'transcript', content: 'Meeting transcript...' }],
projectName: 'Sprint Planning',
asanaAccessToken: 'your-token',
asanaTeamGid: 'team-gid'
}
}
]
}, null, 2);
}
/**
* Main server initialization
*/
async function main(): Promise<void> {
const server = new Server(
{
name: 'content-plan-builder',
version: '1.0.0'
},
{
capabilities: {
tools: {}
}
}
);
// List tools handler
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: [PLAN_FROM_CONTENT_TOOL]
}));
// Call tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
if (name !== 'plan_from_content') {
return {
content: [
{
type: 'text',
text: JSON.stringify({ error: `Unknown tool: ${name}` })
}
]
};
}
try {
const result = await handlePlanFromContent(args);
return {
content: [
{
type: 'text',
text: result
}
]
};
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
return {
content: [
{
type: 'text',
text: JSON.stringify({ error: message })
}
],
isError: true
};
}
});
// Start server
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Content Plan Builder MCP server started');
}
// Run server
main().catch((error) => {
console.error('Failed to start server:', error);
process.exit(1);
});