import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { ResponseFormat, WFWorkflow } from "./types.js";
import { CHARACTER_LIMIT, ICON_COLORS, ICON_GLYPHS } from "./constants.js";
import { ACTION_CATEGORIES, getActionByIdentifier, searchActions } from "./data/actions.js";
import {
generateShortcut, createTextAction, createAlertAction, createNotificationAction,
createHTTPAction, createIfBlock, createRepeatBlock, createRepeatEachBlock,
createMenuBlock, createSetVariableAction, createGetVariableAction,
createCommentAction, createDictionaryAction, plistToWorkflow, ActionInput, ShortcutInput
} from "./services/shortcut-builder.js";
import { analyzeShortcut } from "./services/shortcut-analyzer.js";
import { validateShortcut, formatValidationResult } from "./services/shortcut-validator.js";
const readOnly = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false };
const text = (t: string) => ({ content: [{ type: "text" as const, text: t }] });
const json = (o: unknown) => text(JSON.stringify(o, null, 2));
const err = (t: string) => ({ ...text(t), isError: true });
export function createServer(): McpServer {
const server = new McpServer({ name: "apple-shortcuts-mcp-server", version: "1.0.0" });
server.registerTool("shortcuts_list_categories", {
description: "List all action categories",
inputSchema: z.object({ response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.MARKDOWN) }),
annotations: readOnly
}, async ({ response_format }) => {
const cats = ACTION_CATEGORIES.map(c => ({ name: c.name, description: c.description, count: c.actions.length }));
if (response_format === ResponseFormat.JSON) return json({ categories: cats });
return text(cats.map(c => `## ${c.name}\n${c.description}\nActions: ${c.count}`).join("\n\n"));
});
server.registerTool("shortcuts_search_actions", {
description: "Search actions by name/description",
inputSchema: z.object({
query: z.string().min(1), category: z.string().optional(),
limit: z.number().default(20), response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.MARKDOWN)
}),
annotations: readOnly
}, async ({ query, category, limit, response_format }) => {
let results = searchActions(query);
if (category) results = results.filter(a => a.category.toLowerCase() === category.toLowerCase());
results = results.slice(0, limit);
if (!results.length) return text(`No actions found for '${query}'`);
if (response_format === ResponseFormat.JSON) return json({ total: results.length, actions: results });
return text(results.map(a => `## ${a.name}\n\`${a.identifier}\`\n${a.description}`).join("\n\n"));
});
server.registerTool("shortcuts_get_action", {
description: "Get action details",
inputSchema: z.object({ identifier: z.string(), response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.MARKDOWN) }),
annotations: readOnly
}, async ({ identifier, response_format }) => {
const action = getActionByIdentifier(identifier);
if (!action) return text(`Action '${identifier}' not found`);
if (response_format === ResponseFormat.JSON) return json(action);
const params = action.parameters.map(p => `- ${p.key} (${p.type})${p.required ? " *" : ""}`).join("\n");
return text(`# ${action.name}\n\`${action.identifier}\`\n\n${action.description}\n\n${params}`);
});
server.registerTool("shortcuts_generate", {
description: "Generate .shortcut file from actions",
inputSchema: z.object({
name: z.string(), actions: z.array(z.object({ identifier: z.string(), parameters: z.record(z.unknown()).optional() })).min(1),
icon_color: z.string().optional(), icon_glyph: z.string().optional(),
output_format: z.enum(["plist", "json", "both"]).default("plist")
}),
annotations: readOnly
}, async ({ name, actions, icon_color, icon_glyph, output_format }) => {
const input: ShortcutInput = { name, actions: actions as ActionInput[], icon: { color: icon_color as keyof typeof ICON_COLORS, glyph: icon_glyph as keyof typeof ICON_GLYPHS } };
const { workflow, plist } = generateShortcut(input);
const output = output_format === "json" ? JSON.stringify(workflow, null, 2) : output_format === "both" ? JSON.stringify({ workflow, plist }, null, 2) : plist;
if (output.length > CHARACTER_LIMIT) return err("Output too large");
return text(`# ${name}\n\n\`\`\`xml\n${output}\n\`\`\``);
});
server.registerTool("shortcuts_analyze", {
description: "Analyze shortcut complexity and patterns",
inputSchema: z.object({ plist_content: z.string(), name: z.string().default("Untitled"), response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.MARKDOWN) }),
annotations: readOnly
}, async ({ plist_content, name, response_format }) => {
let workflow: WFWorkflow;
try { workflow = plistToWorkflow(plist_content); } catch (e) { return err(`Parse error: ${e}`); }
const a = analyzeShortcut(workflow, name);
if (response_format === ResponseFormat.JSON) return json(a);
return text(`# ${a.name}\nActions: ${a.actionCount}\nComplexity: ${a.complexity}\nRuntime: ${a.estimatedRuntime}`);
});
server.registerTool("shortcuts_validate", {
description: "Validate shortcut structure",
inputSchema: z.object({ plist_content: z.string(), response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.MARKDOWN) }),
annotations: readOnly
}, async ({ plist_content, response_format }) => {
let workflow: WFWorkflow;
try { workflow = plistToWorkflow(plist_content); } catch (e) { return err(`Parse error: ${e}`); }
const result = validateShortcut(workflow);
return response_format === ResponseFormat.JSON ? json(result) : text(formatValidationResult(result));
});
server.registerTool("shortcuts_create_block", {
description: "Create control flow blocks (if/repeat/menu)",
inputSchema: z.object({
block_type: z.enum(["if", "repeat", "repeat_each", "menu"]),
condition: z.enum(["is", "is not", "contains", "does not contain", "begins with", "ends with", "is greater than", "is less than", "is between"]).optional(),
compare_value: z.union([z.string(), z.number()]).optional(),
count: z.number().optional(), prompt: z.string().optional(), menu_items: z.array(z.string()).optional()
}),
annotations: readOnly
}, async ({ block_type, condition, compare_value, count, prompt, menu_items }) => {
if (block_type === "if") {
if (!condition) return err("condition required");
return json({ type: "if", ...createIfBlock(condition, compare_value) });
}
if (block_type === "repeat") {
if (!count) return err("count required");
return json({ type: "repeat", ...createRepeatBlock(count) });
}
if (block_type === "repeat_each") return json({ type: "repeat_each", ...createRepeatEachBlock() });
if (!menu_items?.length) return err("menu_items required");
return json({ type: "menu", ...createMenuBlock(prompt || "Choose", menu_items) });
});
server.registerTool("shortcuts_create_action", {
description: "Create common actions",
inputSchema: z.object({
action_type: z.enum(["text", "alert", "notification", "http_request", "set_variable", "get_variable", "comment", "dictionary"]),
text: z.string().optional(), title: z.string().optional(), message: z.string().optional(),
show_cancel: z.boolean().optional(), play_sound: z.boolean().optional(),
url: z.string().optional(), method: z.enum(["GET", "POST", "PUT", "DELETE", "PATCH"]).optional(),
headers: z.record(z.string()).optional(), json_body: z.record(z.unknown()).optional(),
variable_name: z.string().optional(), dictionary_items: z.record(z.unknown()).optional()
}),
annotations: readOnly
}, async (p) => {
const actions: Record<string, () => unknown> = {
text: () => p.text ? createTextAction(p.text) : null,
alert: () => p.title ? createAlertAction(p.title, p.message, p.show_cancel) : null,
notification: () => p.message ? createNotificationAction(p.message, p.title, p.play_sound) : null,
http_request: () => p.url ? createHTTPAction(p.url, p.method || "GET", p.headers, p.json_body as Record<string, unknown>) : null,
set_variable: () => p.variable_name ? createSetVariableAction(p.variable_name) : null,
get_variable: () => p.variable_name ? createGetVariableAction(p.variable_name) : null,
comment: () => p.text ? createCommentAction(p.text) : null,
dictionary: () => p.dictionary_items ? createDictionaryAction(p.dictionary_items as Record<string, unknown>) : null
};
const action = actions[p.action_type]?.();
return action ? json(action) : err("Missing required parameter");
});
server.registerTool("shortcuts_list_icons", {
description: "List icon colors and glyphs",
inputSchema: z.object({ type: z.enum(["colors", "glyphs", "both"]).default("both") }),
annotations: readOnly
}, async ({ type }) => {
const result: Record<string, string[]> = {};
if (type !== "glyphs") result.colors = Object.keys(ICON_COLORS);
if (type !== "colors") result.glyphs = Object.keys(ICON_GLYPHS);
return json(result);
});
return server;
}