/**
* Quick Tasks MCP App UI
*
* Demonstrates bidirectional communication between embedded UI and AI chat host.
*/
import { App } from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import "./styles.css";
import { PostMessageTransport } from "@modelcontextprotocol/ext-apps";
interface Task {
id: string;
text: string;
completed: boolean;
createdAt: string;
}
interface TasksResponse {
tasks: Task[];
task?: Task;
deleted?: Task;
error?: string;
}
// DOM elements
const taskListEl = document.getElementById("task-list")!;
const newTaskInput = document.getElementById("new-task-input") as HTMLInputElement;
const addTaskBtn = document.getElementById("add-task-btn")!;
const refreshBtn = document.getElementById("refresh-btn")!;
const taskCountEl = document.getElementById("task-count")!;
const completedCountEl = document.getElementById("completed-count")!;
// State
let tasks: Task[] = [];
// Create MCP App instance
const app = new App({ name: "Quick Tasks", version: "1.0.0" });
// Parse tool result to extract tasks data
function parseResult(result: CallToolResult): TasksResponse | null {
const textContent = result.content?.find((c) => c.type === "text");
if (!textContent || textContent.type !== "text") return null;
try {
return JSON.parse(textContent.text);
} catch {
return null;
}
}
// Render the task list
function renderTasks() {
if (tasks.length === 0) {
taskListEl.innerHTML = '<p class="empty">No tasks yet. Add one above!</p>';
} else {
taskListEl.innerHTML = tasks
.map(
(task) => `
<div class="task ${task.completed ? "completed" : ""}" data-id="${task.id}">
<button class="toggle-btn" title="Toggle completion">
${task.completed ? "✓" : "○"}
</button>
<span class="task-text">${escapeHtml(task.text)}</span>
<button class="delete-btn" title="Delete task">×</button>
</div>
`
)
.join("");
// Add event listeners for toggle and delete
taskListEl.querySelectorAll(".toggle-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
const taskEl = (e.target as HTMLElement).closest(".task") as HTMLElement;
toggleTask(taskEl.dataset.id!);
});
});
taskListEl.querySelectorAll(".delete-btn").forEach((btn) => {
btn.addEventListener("click", (e) => {
const taskEl = (e.target as HTMLElement).closest(".task") as HTMLElement;
deleteTask(taskEl.dataset.id!);
});
});
}
// Update stats
const completed = tasks.filter((t) => t.completed).length;
taskCountEl.textContent = `${tasks.length} task${tasks.length !== 1 ? "s" : ""}`;
completedCountEl.textContent = `${completed} completed`;
}
function escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
// Fetch tasks from server
async function fetchTasks() {
taskListEl.innerHTML = '<p class="loading">Loading...</p>';
try {
const result = await app.callServerTool({ name: "get-tasks", arguments: {} });
const data = parseResult(result);
if (data?.tasks) {
tasks = data.tasks;
renderTasks();
}
} catch (err) {
taskListEl.innerHTML = '<p class="error">Failed to load tasks</p>';
console.error("[APP] Error fetching tasks:", err);
}
}
// Add a new task
async function addTask() {
const text = newTaskInput.value.trim();
if (!text) return;
newTaskInput.disabled = true;
addTaskBtn.textContent = "Adding...";
try {
const result = await app.callServerTool({ name: "add-task", arguments: { text } });
const data = parseResult(result);
if (data?.tasks) {
tasks = data.tasks;
renderTasks();
newTaskInput.value = "";
// Notify the chat that a task was added
await app.sendMessage({
role: "user",
content: [{ type: "text", text: `Added task: "${text}"` }],
});
}
} catch (err) {
console.error("[APP] Error adding task:", err);
} finally {
newTaskInput.disabled = false;
addTaskBtn.textContent = "Add";
newTaskInput.focus();
}
}
// Toggle task completion
async function toggleTask(id: string) {
const task = tasks.find((t) => t.id === id);
if (!task) return;
// Optimistic update
task.completed = !task.completed;
renderTasks();
try {
const result = await app.callServerTool({ name: "toggle-task", arguments: { id } });
const data = parseResult(result);
if (data?.tasks) {
tasks = data.tasks;
renderTasks();
// Notify the chat
const status = data.task?.completed ? "completed" : "uncompleted";
await app.sendMessage({
role: "user",
content: [{ type: "text", text: `Task ${status}: "${data.task?.text}"` }],
});
}
} catch (err) {
// Revert on error
task.completed = !task.completed;
renderTasks();
console.error("[APP] Error toggling task:", err);
}
}
// Delete a task
async function deleteTask(id: string) {
const task = tasks.find((t) => t.id === id);
if (!task) return;
// Optimistic update
tasks = tasks.filter((t) => t.id !== id);
renderTasks();
try {
const result = await app.callServerTool({ name: "delete-task", arguments: { id } });
const data = parseResult(result);
if (data?.tasks) {
tasks = data.tasks;
renderTasks();
// Notify the chat
await app.sendMessage({
role: "user",
content: [{ type: "text", text: `Deleted task: "${data.deleted?.text}"` }],
});
}
} catch (err) {
// Revert on error by refetching
await fetchTasks();
console.error("[APP] Error deleting task:", err);
}
}
// Event handlers
app.ontoolinput = (params) => {
console.log("[APP] Tool input received:", params);
};
app.ontoolresult = (result) => {
console.log("[APP] Tool result received:", result);
const data = parseResult(result);
if (data?.tasks) {
tasks = data.tasks;
renderTasks();
}
};
app.onerror = (err) => {
console.error("[APP] Error:", err);
};
app.onteardown = async () => {
console.log("[APP] Teardown");
return {};
};
// UI event listeners
addTaskBtn.addEventListener("click", addTask);
newTaskInput.addEventListener("keypress", (e) => {
if (e.key === "Enter") addTask();
});
refreshBtn.addEventListener("click", fetchTasks);
// Connect to host and load initial data
app.connect(new PostMessageTransport(window.parent));
fetchTasks();