#!/usr/bin/env node
/**
* Oscribble MCP Server
* Provides Claude Code with tools to interact with Oscribble project data.
*/
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 { promises as fs } from "fs";
import { join } from "path";
import type { TaskNode, NotesFile, ProjectSettings, FilterStatus } from "./types.js";
import {
STORAGE_ROOT,
PROJECTS_FILE,
loadJson,
atomicWriteJson,
findTaskById,
formatTaskForDisplay,
getProjectPath,
fileExists,
} from "./utils.js";
// Initialize server
const server = new Server(
{
name: "oscribble",
version: "1.2.0",
},
{
capabilities: {
tools: {},
},
}
);
// Tool Definitions
const tools: Tool[] = [
{
name: "oscribble_list_projects",
description: "List all Oscribble projects with their names, paths, and last accessed timestamps",
inputSchema: {
type: "object",
properties: {},
required: [],
},
},
{
name: "oscribble_list_tasks",
description: "List tasks from an Oscribble project with optional status filtering",
inputSchema: {
type: "object",
properties: {
project_name: {
type: "string",
description: "Name of the project",
},
filter_status: {
type: "string",
enum: ["all", "unchecked", "checked"],
description: "Filter tasks by completion status (default: all)",
default: "all",
},
},
required: ["project_name"],
},
},
{
name: "oscribble_complete_task",
description: "Mark a task as complete in an Oscribble project",
inputSchema: {
type: "object",
properties: {
project_name: {
type: "string",
description: "Name of the project",
},
task_id: {
type: "string",
description: "UUID of the task to complete",
},
},
required: ["project_name", "task_id"],
},
},
{
name: "oscribble_uncomplete_task",
description: "Mark a task as incomplete in an Oscribble project. Optionally log a failed attempt note with details about what was tried.",
inputSchema: {
type: "object",
properties: {
project_name: {
type: "string",
description: "Name of the project",
},
task_id: {
type: "string",
description: "UUID of the task to uncomplete",
},
attempt_note: {
type: "string",
description: "Optional detailed note from Claude Code about the failed attempt. Should include: hypothesis, files changed, what was tried, and what's still broken.",
},
},
required: ["project_name", "task_id"],
},
},
{
name: "oscribble_get_task_details",
description: "Get detailed information about a specific task including metadata, notes, and blockers",
inputSchema: {
type: "object",
properties: {
project_name: {
type: "string",
description: "Name of the project",
},
task_id: {
type: "string",
description: "UUID of the task",
},
},
required: ["project_name", "task_id"],
},
},
{
name: "oscribble_add_raw_task",
description: "Add raw task text to a project (will be formatted by Oscribble on next sync)",
inputSchema: {
type: "object",
properties: {
project_name: {
type: "string",
description: "Name of the project",
},
task_text: {
type: "string",
description: "Raw task text to append",
},
},
required: ["project_name", "task_text"],
},
},
{
name: "oscribble_begin_task",
description: "Begin timing a task - records start timestamp",
inputSchema: {
type: "object",
properties: {
project_name: {
type: "string",
description: "Name of the project",
},
task_id: {
type: "string",
description: "UUID of the task to begin timing",
},
},
required: ["project_name", "task_id"],
},
},
{
name: "oscribble_complete_task_with_timing",
description: "Complete a task and calculate duration from start time",
inputSchema: {
type: "object",
properties: {
project_name: {
type: "string",
description: "Name of the project",
},
task_id: {
type: "string",
description: "UUID of the task to complete",
},
},
required: ["project_name", "task_id"],
},
},
{
name: "oscribble_update_task",
description: "Update task properties (text, priority, effort estimate, deadline, or notes)",
inputSchema: {
type: "object",
properties: {
project_name: {
type: "string",
description: "Name of the project",
},
task_id: {
type: "string",
description: "UUID of the task to update",
},
text: {
type: "string",
description: "New task text (optional)",
},
priority: {
type: "string",
enum: ["critical", "high", "medium", "low", "feature"],
description: "New priority level (optional)",
},
effort_estimate: {
type: "string",
description: "New effort estimate like '2h', '30m' (optional)",
},
deadline: {
type: "string",
description: "New deadline (optional)",
},
notes: {
type: "string",
description: "Additional notes (optional, will be appended)",
},
},
required: ["project_name", "task_id"],
},
},
{
name: "oscribble_get_unblocked_tasks",
description: "Get all tasks that are not blocked and ready to work on",
inputSchema: {
type: "object",
properties: {
project_name: {
type: "string",
description: "Name of the project",
},
},
required: ["project_name"],
},
},
{
name: "oscribble_search_tasks",
description: "Search tasks by keyword in text and notes",
inputSchema: {
type: "object",
properties: {
project_name: {
type: "string",
description: "Name of the project",
},
query: {
type: "string",
description: "Search query (case-insensitive)",
},
},
required: ["project_name", "query"],
},
},
];
// Tool Handlers
async function handleListProjects(): Promise<string> {
if (!(await fileExists(PROJECTS_FILE))) {
return "No projects found. The projects file doesn't exist yet.";
}
const projects = await loadJson<ProjectSettings[]>(PROJECTS_FILE);
if (!projects || projects.length === 0) {
return "No projects found.";
}
// Sort by last_accessed (most recent first)
const projectsSorted = projects.sort((a, b) => (b.last_accessed || 0) - (a.last_accessed || 0));
let result = "**Oscribble Projects:**\n\n";
for (const project of projectsSorted) {
result += `- **${project.name}**\n`;
result += ` - Path: \`${project.path || "N/A"}\`\n`;
result += ` - Last accessed: ${project.last_accessed || "Never"}\n\n`;
}
return result;
}
async function handleListTasks(projectName: string, filterStatus: FilterStatus = "all"): Promise<string> {
const projectPath = await getProjectPath(projectName);
const notesFile = join(projectPath, "notes.json");
if (!(await fileExists(notesFile))) {
return `No tasks found for project '${projectName}'. The notes file doesn't exist yet.`;
}
const notesData = await loadJson<NotesFile>(notesFile);
let tasks = notesData.tasks || [];
if (tasks.length === 0) {
return `No tasks found in project '${projectName}'.`;
}
// Filter tasks based on status
function shouldIncludeTask(task: TaskNode): boolean {
if (filterStatus === "all") {
return true;
}
const checked = task.checked || false;
return (filterStatus === "checked" && checked) || (filterStatus === "unchecked" && !checked);
}
function filterTasksRecursive(taskList: TaskNode[]): TaskNode[] {
const filtered: TaskNode[] = [];
for (const task of taskList) {
// Filter children first
if (task.children && task.children.length > 0) {
task.children = filterTasksRecursive(task.children);
}
// Include task if it matches or has matching children
if (shouldIncludeTask(task) || (task.children && task.children.length > 0)) {
filtered.push(task);
}
}
return filtered;
}
if (filterStatus !== "all") {
tasks = filterTasksRecursive(tasks);
}
if (tasks.length === 0) {
return `No ${filterStatus} tasks found in project '${projectName}'.`;
}
// Format tasks for display
let result = `**Tasks in '${projectName}' (filter: ${filterStatus}):**\n\n`;
for (const task of tasks) {
result += formatTaskForDisplay(task) + "\n\n";
}
return result;
}
async function handleCompleteTask(projectName: string, taskId: string, complete: boolean, attemptNote?: string): Promise<string> {
const projectPath = await getProjectPath(projectName);
const notesFile = join(projectPath, "notes.json");
if (!(await fileExists(notesFile))) {
return `Notes file not found for project '${projectName}'.`;
}
const notesData = await loadJson<NotesFile>(notesFile);
const tasks = notesData.tasks || [];
// Find the task
const result = findTaskById(tasks, taskId);
if (!result) {
return `Task with ID '${taskId}' not found in project '${projectName}'.`;
}
const [task] = result;
// Update the task
task.checked = complete;
// Handle uncompleting with attempt note
if (!complete && attemptNote) {
if (!task.metadata) {
task.metadata = {};
}
if (!task.metadata.attempts) {
task.metadata.attempts = [];
}
// Add new attempt
task.metadata.attempts.push({
timestamp: Date.now(),
note: attemptNote,
});
}
// Save atomically
await atomicWriteJson(notesFile, notesData);
// Return response with hints for failed attempts
if (!complete && attemptNote) {
const attemptCount = task.metadata?.attempts?.length || 0;
if (attemptCount === 1) {
return `ā Task uncompleted. Attempt #${attemptCount} logged.`;
} else if (attemptCount === 2) {
return `ā Task uncompleted. Attempt #${attemptCount} logged.\nš” Tip: Consider using context7 MCP to research relevant documentation for this issue.`;
} else {
return `ā ļø Task uncompleted. Attempt #${attemptCount} logged.\nšØ Multiple failures detected. Strongly recommend:\n - Use context7 MCP to research relevant documentation\n - Review all previous attempts via oscribble_get_task_details before trying again\n - Consider a fundamentally different approach`;
}
}
const status = complete ? "completed" : "uncompleted";
return `ā Task '${task.text}' ${status} successfully in project '${projectName}'.`;
}
async function handleGetTaskDetails(projectName: string, taskId: string): Promise<string> {
const projectPath = await getProjectPath(projectName);
const notesFile = join(projectPath, "notes.json");
if (!(await fileExists(notesFile))) {
return `Notes file not found for project '${projectName}'.`;
}
const notesData = await loadJson<NotesFile>(notesFile);
const tasks = notesData.tasks || [];
// Find the task
const result = findTaskById(tasks, taskId);
if (!result) {
return `Task with ID '${taskId}' not found in project '${projectName}'.`;
}
const [task] = result;
// Format detailed information
const metadata = task.metadata || {};
let details = "**Task Details:**\n\n";
details += `**ID:** \`${task.id}\`\n`;
details += `**Text:** ${task.text}\n`;
details += `**Status:** ${task.checked ? "ā Completed" : "ā Incomplete"}\n`;
if (metadata.priority) {
details += `**Priority:** ${metadata.priority.toUpperCase()}\n`;
}
if (metadata.blocked_by && metadata.blocked_by.length > 0) {
details += `**Blocked by:** ${metadata.blocked_by.join(", ")}\n`;
}
if (metadata.depends_on && metadata.depends_on.length > 0) {
details += `**Depends on:** ${metadata.depends_on.join(", ")}\n`;
}
if (metadata.related_to && metadata.related_to.length > 0) {
details += `**Related to:** ${metadata.related_to.join(", ")}\n`;
}
if (metadata.notes) {
if (Array.isArray(metadata.notes)) {
details += `**Notes:**\n${metadata.notes.map(note => ` - ${note}`).join('\n')}\n`;
} else {
details += `**Notes:** ${metadata.notes}\n`;
}
}
if (metadata.deadline) {
details += `**Deadline:** ${metadata.deadline}\n`;
}
if (metadata.effort_estimate) {
details += `**Effort Estimate:** ${metadata.effort_estimate}\n`;
}
if (metadata.tags && metadata.tags.length > 0) {
details += `**Tags:** ${metadata.tags.join(", ")}\n`;
}
if (metadata.context_files && metadata.context_files.length > 0) {
details += `\n**Context Files Analyzed (${metadata.context_files.length}):**\n`;
for (const file of metadata.context_files) {
details += ` - \`${file.path}\``;
if (file.wasGrepped && file.matchedKeywords) {
details += ` (grep: ${file.matchedKeywords.join(", ")})`;
}
details += '\n';
}
}
if (metadata.attempts && metadata.attempts.length > 0) {
details += `\nā ļø **Failed Attempts (${metadata.attempts.length}):**\n`;
for (let i = 0; i < metadata.attempts.length; i++) {
const attempt = metadata.attempts[i];
const date = new Date(attempt.timestamp);
details += `\n**Attempt #${i + 1}** (${date.toLocaleString()}):\n`;
details += `${attempt.note}\n`;
}
details += '\n';
}
const children = task.children || [];
if (children.length > 0) {
details += `\n**Children (${children.length}):**\n\n`;
for (const child of children) {
details += formatTaskForDisplay(child, 1) + "\n";
}
}
return details;
}
async function handleAddRawTask(projectName: string, taskText: string): Promise<string> {
const projectPath = await getProjectPath(projectName);
const rawFile = join(projectPath, "raw.txt");
// Ensure directory exists
await fs.mkdir(projectPath, { recursive: true });
// Append to raw.txt
let content = taskText;
if (!taskText.endsWith("\n")) {
content += "\n";
}
// Check if file exists and add newline if needed
if (await fileExists(rawFile)) {
const existingContent = await fs.readFile(rawFile, "utf-8");
if (existingContent.length > 0 && !existingContent.endsWith("\n")) {
content = "\n" + content;
}
}
await fs.appendFile(rawFile, content, "utf-8");
return `ā Added raw task to project '${projectName}'. It will be formatted next time you open Oscribble.`;
}
async function handleBeginTask(projectName: string, taskId: string): Promise<string> {
const projectPath = await getProjectPath(projectName);
const notesFile = join(projectPath, "notes.json");
if (!(await fileExists(notesFile))) {
return `Notes file not found for project '${projectName}'.`;
}
const notesData = await loadJson<NotesFile>(notesFile);
const tasks = notesData.tasks || [];
// Find the task
const result = findTaskById(tasks, taskId);
if (!result) {
return `Task with ID '${taskId}' not found in project '${projectName}'.`;
}
const [task] = result;
// Set start time (milliseconds timestamp)
const startTime = Date.now();
if (!task.metadata) {
task.metadata = {};
}
task.metadata.start_time = startTime;
// Save atomically
await atomicWriteJson(notesFile, notesData);
return `ā Started timing task: ${task.text.slice(0, 50)}... (at ${startTime})`;
}
async function handleCompleteTaskWithTiming(projectName: string, taskId: string): Promise<string> {
const projectPath = await getProjectPath(projectName);
const notesFile = join(projectPath, "notes.json");
const logFile = join(projectPath, "completion_log.json");
if (!(await fileExists(notesFile))) {
return `Notes file not found for project '${projectName}'.`;
}
const notesData = await loadJson<NotesFile>(notesFile);
const tasks = notesData.tasks || [];
// Find the task
const result = findTaskById(tasks, taskId);
if (!result) {
return `Task with ID '${taskId}' not found in project '${projectName}'.`;
}
const [task] = result;
if (!task.metadata?.start_time) {
return `Error: Task not started (use oscribble_begin_task first)`;
}
const completedAt = Date.now();
const duration = completedAt - task.metadata.start_time;
task.metadata.duration = duration;
task.checked = true;
// Load or create completion log
interface CompletionLog {
version: string;
completions: Array<{
task_id: string;
text: string;
estimated_time?: string;
actual_time: number;
completed_at: number;
}>;
}
let log: CompletionLog = { version: '1.0.0', completions: [] };
if (await fileExists(logFile)) {
log = await loadJson<CompletionLog>(logFile);
}
log.completions.push({
task_id: taskId,
text: task.text,
estimated_time: task.metadata?.effort_estimate,
actual_time: duration,
completed_at: completedAt
});
// Retention: keep last 100
if (log.completions.length > 100) {
log.completions = log.completions.slice(-100);
}
await atomicWriteJson(notesFile, notesData);
await atomicWriteJson(logFile, log);
const durationHours = duration / (1000 * 60 * 60);
const durationDisplay = durationHours < 1
? `${Math.round(duration / (1000 * 60))}m`
: `${durationHours.toFixed(1)}h`;
return `ā Completed task in ${durationDisplay}: ${task.text.slice(0, 50)}...`;
}
async function handleUpdateTask(
projectName: string,
taskId: string,
updates: {
text?: string;
priority?: string;
effort_estimate?: string;
deadline?: string;
notes?: string;
}
): Promise<string> {
const projectPath = await getProjectPath(projectName);
const notesFile = join(projectPath, "notes.json");
if (!(await fileExists(notesFile))) {
return `Notes file not found for project '${projectName}'.`;
}
const notesData = await loadJson<NotesFile>(notesFile);
const tasks = notesData.tasks || [];
const result = findTaskById(tasks, taskId);
if (!result) {
return `Task with ID '${taskId}' not found in project '${projectName}'.`;
}
const [task] = result;
const changes: string[] = [];
// Update text
if (updates.text !== undefined) {
task.text = updates.text;
changes.push(`text updated`);
}
// Update metadata fields
if (!task.metadata) {
task.metadata = {};
}
if (updates.priority !== undefined) {
task.metadata.priority = updates.priority as any;
changes.push(`priority ā ${updates.priority}`);
}
if (updates.effort_estimate !== undefined) {
task.metadata.effort_estimate = updates.effort_estimate;
changes.push(`effort ā ${updates.effort_estimate}`);
}
if (updates.deadline !== undefined) {
task.metadata.deadline = updates.deadline;
changes.push(`deadline ā ${updates.deadline}`);
}
if (updates.notes !== undefined) {
// Append to existing notes
const existingNotes = task.metadata.notes || [];
const notesArray = Array.isArray(existingNotes) ? existingNotes : [existingNotes];
task.metadata.notes = [...notesArray, updates.notes];
changes.push(`note added`);
}
await atomicWriteJson(notesFile, notesData);
return `ā Task updated (${changes.join(", ")}): ${task.text.slice(0, 50)}...`;
}
async function handleGetUnblockedTasks(projectName: string): Promise<string> {
const projectPath = await getProjectPath(projectName);
const notesFile = join(projectPath, "notes.json");
if (!(await fileExists(notesFile))) {
return `No tasks found for project '${projectName}'. The notes file doesn't exist yet.`;
}
const notesData = await loadJson<NotesFile>(notesFile);
const tasks = notesData.tasks || [];
// Recursively collect all unchecked tasks
function collectUnblockedTasks(taskList: TaskNode[]): TaskNode[] {
const unblocked: TaskNode[] = [];
for (const task of taskList) {
// Skip completed tasks
if (task.checked) {
continue;
}
// Check if task is blocked
const isBlocked = task.metadata?.blocked_by && task.metadata.blocked_by.length > 0;
if (!isBlocked) {
unblocked.push(task);
}
// Recursively check children
if (task.children && task.children.length > 0) {
unblocked.push(...collectUnblockedTasks(task.children));
}
}
return unblocked;
}
const unblockedTasks = collectUnblockedTasks(tasks);
if (unblockedTasks.length === 0) {
return `No unblocked tasks found in project '${projectName}'.`;
}
let result = `**Unblocked tasks in '${projectName}' (${unblockedTasks.length} ready to work on):**\n\n`;
for (const task of unblockedTasks) {
result += formatTaskForDisplay(task) + "\n\n";
}
return result;
}
async function handleSearchTasks(projectName: string, query: string): Promise<string> {
const projectPath = await getProjectPath(projectName);
const notesFile = join(projectPath, "notes.json");
if (!(await fileExists(notesFile))) {
return `No tasks found for project '${projectName}'. The notes file doesn't exist yet.`;
}
const notesData = await loadJson<NotesFile>(notesFile);
const tasks = notesData.tasks || [];
const queryLower = query.toLowerCase();
// Recursively search tasks
function searchTasksRecursive(taskList: TaskNode[]): TaskNode[] {
const matches: TaskNode[] = [];
for (const task of taskList) {
const textMatch = task.text.toLowerCase().includes(queryLower);
// Check notes
let notesMatch = false;
if (task.metadata?.notes) {
const notesArray = Array.isArray(task.metadata.notes)
? task.metadata.notes
: [task.metadata.notes];
notesMatch = notesArray.some(note => note.toLowerCase().includes(queryLower));
}
if (textMatch || notesMatch) {
matches.push(task);
}
// Search children
if (task.children && task.children.length > 0) {
matches.push(...searchTasksRecursive(task.children));
}
}
return matches;
}
const matchingTasks = searchTasksRecursive(tasks);
if (matchingTasks.length === 0) {
return `No tasks found matching '${query}' in project '${projectName}'.`;
}
let result = `**Search results for '${query}' in '${projectName}' (${matchingTasks.length} matches):**\n\n`;
for (const task of matchingTasks) {
result += formatTaskForDisplay(task) + "\n\n";
}
return result;
}
// Register handlers
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools };
});
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
if (!args) {
throw new Error("Missing arguments");
}
let result: string;
switch (name) {
case "oscribble_list_projects":
result = await handleListProjects();
break;
case "oscribble_list_tasks":
result = await handleListTasks(
args.project_name as string,
(args.filter_status as FilterStatus) || "all"
);
break;
case "oscribble_complete_task":
result = await handleCompleteTask(args.project_name as string, args.task_id as string, true);
break;
case "oscribble_uncomplete_task":
result = await handleCompleteTask(
args.project_name as string,
args.task_id as string,
false,
args.attempt_note as string | undefined
);
break;
case "oscribble_get_task_details":
result = await handleGetTaskDetails(args.project_name as string, args.task_id as string);
break;
case "oscribble_add_raw_task":
result = await handleAddRawTask(args.project_name as string, args.task_text as string);
break;
case "oscribble_begin_task":
result = await handleBeginTask(args.project_name as string, args.task_id as string);
break;
case "oscribble_complete_task_with_timing":
result = await handleCompleteTaskWithTiming(args.project_name as string, args.task_id as string);
break;
case "oscribble_update_task":
result = await handleUpdateTask(
args.project_name as string,
args.task_id as string,
{
text: args.text as string | undefined,
priority: args.priority as string | undefined,
effort_estimate: args.effort_estimate as string | undefined,
deadline: args.deadline as string | undefined,
notes: args.notes as string | undefined,
}
);
break;
case "oscribble_get_unblocked_tasks":
result = await handleGetUnblockedTasks(args.project_name as string);
break;
case "oscribble_search_tasks":
result = await handleSearchTasks(args.project_name as string, args.query as string);
break;
default:
throw new Error(`Unknown tool: ${name}`);
}
return {
content: [
{
type: "text",
text: result,
},
],
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
content: [
{
type: "text",
text: `Error: ${errorMessage}`,
},
],
isError: true,
};
}
});
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
}
main().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});