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";
export function createServer(): McpServer {
const server = new McpServer({
name: "apple-shortcuts-mcp-server",
version: "1.0.0"
});
const ListCategoriesInputSchema = z.object({
response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.MARKDOWN)
}).strict();
server.registerTool(
"shortcuts_list_categories",
{
title: "List Action Categories",
description: "Lists all Apple Shortcuts action categories with descriptions and action counts.",
inputSchema: ListCategoriesInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
},
async (params: z.infer<typeof ListCategoriesInputSchema>) => {
const categories = ACTION_CATEGORIES.map(cat => ({
name: cat.name,
description: cat.description,
actionCount: cat.actions.length
}));
if (params.response_format === ResponseFormat.JSON) {
return { content: [{ type: "text", text: JSON.stringify({ categories }, null, 2) }] };
}
const lines = ["# Action Categories", ""];
for (const cat of categories) {
lines.push(`## ${cat.name}`, cat.description, `- Actions: ${cat.actionCount}`, "");
}
return { content: [{ type: "text", text: lines.join("\n") }] };
}
);
const SearchActionsInputSchema = z.object({
query: z.string().min(1).max(200),
category: z.string().optional(),
limit: z.number().int().min(1).max(100).default(20),
response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.MARKDOWN)
}).strict();
server.registerTool(
"shortcuts_search_actions",
{
title: "Search Actions",
description: "Search for actions by name, description, or identifier. Optionally filter by category.",
inputSchema: SearchActionsInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
},
async (params: z.infer<typeof SearchActionsInputSchema>) => {
let results = searchActions(params.query);
if (params.category) {
results = results.filter(a => a.category.toLowerCase() === params.category!.toLowerCase());
}
results = results.slice(0, params.limit);
if (results.length === 0) {
return { content: [{ type: "text", text: `No actions found matching '${params.query}'` }] };
}
if (params.response_format === ResponseFormat.JSON) {
return {
content: [{
type: "text",
text: JSON.stringify({ total: results.length, actions: results }, null, 2)
}]
};
}
const lines = [`# Results: '${params.query}'`, "", `Found ${results.length} actions:`, ""];
for (const action of results) {
lines.push(`## ${action.name}`, `\`${action.identifier}\``, action.description);
if (action.parameters.length > 0) {
lines.push("Parameters:");
for (const p of action.parameters) {
lines.push(` - ${p.key} (${p.type})${p.required ? " *" : ""}`);
}
}
lines.push("");
}
return { content: [{ type: "text", text: lines.join("\n") }] };
}
);
const GetActionInputSchema = z.object({
identifier: z.string().min(1),
response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.MARKDOWN)
}).strict();
server.registerTool(
"shortcuts_get_action",
{
title: "Get Action Details",
description: "Get detailed info about a specific action including all parameters.",
inputSchema: GetActionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
},
async (params: z.infer<typeof GetActionInputSchema>) => {
const action = getActionByIdentifier(params.identifier);
if (!action) {
return { content: [{ type: "text", text: `Action '${params.identifier}' not found.` }] };
}
if (params.response_format === ResponseFormat.JSON) {
return { content: [{ type: "text", text: JSON.stringify(action, null, 2) }] };
}
const lines = [`# ${action.name}`, "", `\`${action.identifier}\``, "", action.description, ""];
if (action.outputType) lines.push(`Output: ${action.outputType}`, "");
if (action.parameters.length > 0) {
lines.push("## Parameters", "");
for (const p of action.parameters) {
lines.push(`### ${p.name}`, `- Key: \`${p.key}\``, `- Type: ${p.type}`, `- Required: ${p.required}`);
if (p.defaultValue !== undefined) lines.push(`- Default: ${JSON.stringify(p.defaultValue)}`);
if (p.enumValues) lines.push(`- Values: ${p.enumValues.join(", ")}`);
lines.push("");
}
}
return { content: [{ type: "text", text: lines.join("\n") }] };
}
);
const GenerateShortcutInputSchema = z.object({
name: z.string().min(1).max(200),
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")
}).strict();
server.registerTool(
"shortcuts_generate",
{
title: "Generate Shortcut",
description: "Generate a .shortcut file from a list of actions. Output can be saved and imported into Shortcuts app.",
inputSchema: GenerateShortcutInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
},
async (params: z.infer<typeof GenerateShortcutInputSchema>) => {
const input: ShortcutInput = {
name: params.name,
actions: params.actions as ActionInput[],
icon: {
color: params.icon_color as keyof typeof ICON_COLORS | undefined,
glyph: params.icon_glyph as keyof typeof ICON_GLYPHS | undefined
}
};
const { workflow, plist: plistContent } = generateShortcut(input);
let output: string;
if (params.output_format === "json") {
output = JSON.stringify(workflow, null, 2);
} else if (params.output_format === "both") {
output = JSON.stringify({ workflow, plist: plistContent }, null, 2);
} else {
output = plistContent;
}
if (output.length > CHARACTER_LIMIT) {
return { content: [{ type: "text", text: "Output too large. Reduce actions or use json format." }], isError: true };
}
return {
content: [{
type: "text",
text: `# ${params.name}\n\nActions: ${params.actions.length}\n\nSave as .shortcut file:\n\n\`\`\`xml\n${output}\n\`\`\``
}]
};
}
);
const AnalyzeShortcutInputSchema = z.object({
plist_content: z.string().min(1),
name: z.string().default("Untitled"),
response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.MARKDOWN)
}).strict();
server.registerTool(
"shortcuts_analyze",
{
title: "Analyze Shortcut",
description: "Analyze a shortcut for complexity, patterns, permissions, and suggestions.",
inputSchema: AnalyzeShortcutInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
},
async (params: z.infer<typeof AnalyzeShortcutInputSchema>) => {
let workflow: WFWorkflow;
try {
workflow = plistToWorkflow(params.plist_content);
} catch (error) {
return { content: [{ type: "text", text: `Parse error: ${error}` }], isError: true };
}
const analysis = analyzeShortcut(workflow, params.name);
if (params.response_format === ResponseFormat.JSON) {
return { content: [{ type: "text", text: JSON.stringify(analysis, null, 2) }] };
}
const lines = [
`# ${analysis.name}`, "",
`Actions: ${analysis.actionCount}`,
`Complexity: ${analysis.complexity}`,
`Runtime: ${analysis.estimatedRuntime}`, "",
"## Categories",
...Object.entries(analysis.categories).map(([c, n]) => `- ${c}: ${n}`), ""
];
if (analysis.permissions.length > 0) {
lines.push("## Permissions", ...analysis.permissions.map(p => `- ${p}`), "");
}
if (analysis.patterns.length > 0) {
lines.push("## Patterns", ...analysis.patterns.map(p => `- ${p}`), "");
}
if (analysis.suggestions.length > 0) {
lines.push("## Suggestions", ...analysis.suggestions.map(s => `- ${s}`), "");
}
return { content: [{ type: "text", text: lines.join("\n") }] };
}
);
const ValidateShortcutInputSchema = z.object({
plist_content: z.string().min(1),
response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.MARKDOWN)
}).strict();
server.registerTool(
"shortcuts_validate",
{
title: "Validate Shortcut",
description: "Validate shortcut structure, UUIDs, references, and control flow blocks.",
inputSchema: ValidateShortcutInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
},
async (params: z.infer<typeof ValidateShortcutInputSchema>) => {
let workflow: WFWorkflow;
try {
workflow = plistToWorkflow(params.plist_content);
} catch (error) {
return { content: [{ type: "text", text: `Parse error: ${error}` }], isError: true };
}
const result = validateShortcut(workflow);
if (params.response_format === ResponseFormat.JSON) {
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
return { content: [{ type: "text", text: formatValidationResult(result) }] };
}
);
const CreateActionBlockInputSchema = 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().int().min(1).optional(),
prompt: z.string().optional(),
menu_items: z.array(z.string()).optional(),
response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.JSON)
}).strict();
server.registerTool(
"shortcuts_create_block",
{
title: "Create Action Block",
description: "Create control flow blocks (If, Repeat, Menu) with proper GroupingIdentifiers.",
inputSchema: CreateActionBlockInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
},
async (params: z.infer<typeof CreateActionBlockInputSchema>) => {
let result: Record<string, unknown>;
switch (params.block_type) {
case "if": {
if (!params.condition) {
return { content: [{ type: "text", text: "condition required for If blocks" }], isError: true };
}
const block = createIfBlock(params.condition, params.compare_value);
result = { type: "if", groupId: block.groupId, actions: block };
break;
}
case "repeat": {
if (!params.count) {
return { content: [{ type: "text", text: "count required for Repeat blocks" }], isError: true };
}
const block = createRepeatBlock(params.count);
result = { type: "repeat", groupId: block.groupId, actions: block };
break;
}
case "repeat_each": {
const block = createRepeatEachBlock();
result = { type: "repeat_each", groupId: block.groupId, actions: block };
break;
}
case "menu": {
if (!params.menu_items?.length) {
return { content: [{ type: "text", text: "menu_items required for Menu blocks" }], isError: true };
}
const block = createMenuBlock(params.prompt || "Choose", params.menu_items);
result = { type: "menu", groupId: block.groupId, actions: block };
break;
}
}
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
);
const CreateCommonActionInputSchema = 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(),
response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.JSON)
}).strict();
server.registerTool(
"shortcuts_create_action",
{
title: "Create Common Action",
description: "Create common actions (text, alert, http request, variables, etc.) with proper structure.",
inputSchema: CreateCommonActionInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
},
async (params: z.infer<typeof CreateCommonActionInputSchema>) => {
let action: ReturnType<typeof createTextAction>;
switch (params.action_type) {
case "text":
if (!params.text) return { content: [{ type: "text", text: "text required" }], isError: true };
action = createTextAction(params.text);
break;
case "alert":
if (!params.title) return { content: [{ type: "text", text: "title required" }], isError: true };
action = createAlertAction(params.title, params.message, params.show_cancel);
break;
case "notification":
if (!params.message) return { content: [{ type: "text", text: "message required" }], isError: true };
action = createNotificationAction(params.message, params.title, params.play_sound);
break;
case "http_request":
if (!params.url) return { content: [{ type: "text", text: "url required" }], isError: true };
action = createHTTPAction(params.url, params.method || "GET", params.headers, params.json_body as Record<string, unknown>);
break;
case "set_variable":
if (!params.variable_name) return { content: [{ type: "text", text: "variable_name required" }], isError: true };
action = createSetVariableAction(params.variable_name);
break;
case "get_variable":
if (!params.variable_name) return { content: [{ type: "text", text: "variable_name required" }], isError: true };
action = createGetVariableAction(params.variable_name);
break;
case "comment":
if (!params.text) return { content: [{ type: "text", text: "text required" }], isError: true };
action = createCommentAction(params.text);
break;
case "dictionary":
if (!params.dictionary_items) return { content: [{ type: "text", text: "dictionary_items required" }], isError: true };
action = createDictionaryAction(params.dictionary_items as Record<string, unknown>);
break;
}
return { content: [{ type: "text", text: JSON.stringify(action, null, 2) }] };
}
);
const ListIconOptionsInputSchema = z.object({
type: z.enum(["colors", "glyphs", "both"]).default("both"),
response_format: z.nativeEnum(ResponseFormat).default(ResponseFormat.MARKDOWN)
}).strict();
server.registerTool(
"shortcuts_list_icons",
{
title: "List Icon Options",
description: "List available icon colors and glyphs for shortcuts.",
inputSchema: ListIconOptionsInputSchema,
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }
},
async (params: z.infer<typeof ListIconOptionsInputSchema>) => {
const result: Record<string, string[]> = {};
if (params.type === "colors" || params.type === "both") result.colors = Object.keys(ICON_COLORS);
if (params.type === "glyphs" || params.type === "both") result.glyphs = Object.keys(ICON_GLYPHS);
if (params.response_format === ResponseFormat.JSON) {
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
}
const lines = ["# Icon Options", ""];
if (result.colors) lines.push("## Colors", result.colors.join(", "), "");
if (result.glyphs) lines.push("## Glyphs", result.glyphs.join(", "), "");
return { content: [{ type: "text", text: lines.join("\n") }] };
}
);
return server;
}