apple-notes-mcp 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +522 -0
- package/build/index.js +289 -0
- package/build/index.test.js +446 -0
- package/build/services/appleNotesManager.js +720 -0
- package/build/services/appleNotesManager.test.js +684 -0
- package/build/types.js +13 -0
- package/build/utils/applescript.js +141 -0
- package/build/utils/applescript.test.js +129 -0
- package/package.json +70 -0
package/build/types.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Type Definitions for Apple Notes MCP Server
|
|
3
|
+
*
|
|
4
|
+
* This module contains all TypeScript interfaces and types used throughout
|
|
5
|
+
* the Apple Notes MCP server. These types model:
|
|
6
|
+
*
|
|
7
|
+
* - Apple Notes data structures (notes, folders, accounts)
|
|
8
|
+
* - AppleScript execution results
|
|
9
|
+
* - MCP tool parameters
|
|
10
|
+
*
|
|
11
|
+
* @module types
|
|
12
|
+
*/
|
|
13
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AppleScript Execution Utilities
|
|
3
|
+
*
|
|
4
|
+
* This module provides a safe interface for executing AppleScript commands
|
|
5
|
+
* on macOS. It handles script execution, error capture, and result parsing.
|
|
6
|
+
*
|
|
7
|
+
* @module utils/applescript
|
|
8
|
+
*/
|
|
9
|
+
import { execSync } from "child_process";
|
|
10
|
+
/**
|
|
11
|
+
* Maximum execution time for AppleScript commands in milliseconds.
|
|
12
|
+
* Apple Notes operations are typically fast, but complex searches
|
|
13
|
+
* or operations on large note collections may take longer.
|
|
14
|
+
*/
|
|
15
|
+
const EXECUTION_TIMEOUT_MS = 10000;
|
|
16
|
+
/**
|
|
17
|
+
* Escapes a string for safe inclusion in a shell command.
|
|
18
|
+
*
|
|
19
|
+
* When passing AppleScript to osascript via shell, we need to handle
|
|
20
|
+
* the interaction between shell quoting and AppleScript string literals.
|
|
21
|
+
* This function escapes single quotes since we wrap the script in single quotes.
|
|
22
|
+
*
|
|
23
|
+
* @param script - The raw AppleScript code
|
|
24
|
+
* @returns Shell-safe version of the script
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* // Input: tell app "Notes" to get note "Rob's Note"
|
|
28
|
+
* // Output: tell app "Notes" to get note "Rob'\''s Note"
|
|
29
|
+
*/
|
|
30
|
+
function escapeForShell(script) {
|
|
31
|
+
// Replace single quotes with: end quote, escaped quote, start quote
|
|
32
|
+
// This is the standard shell escaping pattern for single-quoted strings
|
|
33
|
+
return script.replace(/'/g, "'\\''");
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Parses error output from osascript to extract meaningful error messages.
|
|
37
|
+
*
|
|
38
|
+
* osascript errors typically include execution error numbers and descriptions.
|
|
39
|
+
* This function attempts to extract the human-readable portion.
|
|
40
|
+
*
|
|
41
|
+
* @param errorOutput - Raw error string from execSync
|
|
42
|
+
* @returns Cleaned error message
|
|
43
|
+
*/
|
|
44
|
+
function parseErrorMessage(errorOutput) {
|
|
45
|
+
// Check for common AppleScript error patterns
|
|
46
|
+
const executionError = errorOutput.match(/execution error: (.+?)(?:\s*\(-?\d+\))?$/m);
|
|
47
|
+
if (executionError) {
|
|
48
|
+
return executionError[1].trim();
|
|
49
|
+
}
|
|
50
|
+
// Check for "not found" type errors
|
|
51
|
+
const notFoundError = errorOutput.match(/Can't get (.+?)\./);
|
|
52
|
+
if (notFoundError) {
|
|
53
|
+
return `Not found: ${notFoundError[1]}`;
|
|
54
|
+
}
|
|
55
|
+
// Return cleaned version of original error
|
|
56
|
+
return errorOutput.trim() || "Unknown AppleScript error";
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Executes an AppleScript command and returns a structured result.
|
|
60
|
+
*
|
|
61
|
+
* This function serves as the bridge between TypeScript and macOS AppleScript.
|
|
62
|
+
* It handles the complexity of shell escaping, execution, and error handling
|
|
63
|
+
* so that calling code can work with clean TypeScript interfaces.
|
|
64
|
+
*
|
|
65
|
+
* The script is executed synchronously via the `osascript` command-line tool.
|
|
66
|
+
* Multi-line scripts are supported and preserved (important for AppleScript
|
|
67
|
+
* tell blocks and repeat loops).
|
|
68
|
+
*
|
|
69
|
+
* @param script - The AppleScript code to execute
|
|
70
|
+
* @returns A result object with success status and output or error message
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* ```typescript
|
|
74
|
+
* const result = executeAppleScript(`
|
|
75
|
+
* tell application "Notes"
|
|
76
|
+
* get name of every note
|
|
77
|
+
* end tell
|
|
78
|
+
* `);
|
|
79
|
+
*
|
|
80
|
+
* if (result.success) {
|
|
81
|
+
* console.log("Notes:", result.output);
|
|
82
|
+
* } else {
|
|
83
|
+
* console.error("Failed:", result.error);
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
86
|
+
*/
|
|
87
|
+
export function executeAppleScript(script) {
|
|
88
|
+
// Validate input - empty scripts are likely programmer errors
|
|
89
|
+
if (!script || !script.trim()) {
|
|
90
|
+
return {
|
|
91
|
+
success: false,
|
|
92
|
+
output: "",
|
|
93
|
+
error: "Cannot execute empty AppleScript",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// Prepare the script:
|
|
97
|
+
// 1. Trim leading/trailing whitespace (cosmetic)
|
|
98
|
+
// 2. Preserve internal newlines (required for AppleScript syntax)
|
|
99
|
+
// 3. Escape for shell execution
|
|
100
|
+
const preparedScript = escapeForShell(script.trim());
|
|
101
|
+
// Build the osascript command
|
|
102
|
+
// We use single quotes to wrap the script, which is why we escape
|
|
103
|
+
// single quotes within the script itself
|
|
104
|
+
const command = `osascript -e '${preparedScript}'`;
|
|
105
|
+
try {
|
|
106
|
+
// Execute synchronously - MCP tools are inherently synchronous
|
|
107
|
+
// and Apple Notes operations are fast enough that async isn't needed
|
|
108
|
+
const output = execSync(command, {
|
|
109
|
+
encoding: "utf8",
|
|
110
|
+
timeout: EXECUTION_TIMEOUT_MS,
|
|
111
|
+
// Capture stderr separately to get error details
|
|
112
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
success: true,
|
|
116
|
+
output: output.trim(),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
// execSync throws on non-zero exit codes
|
|
121
|
+
// The error object contains stderr output with AppleScript error details
|
|
122
|
+
let errorMessage;
|
|
123
|
+
if (error instanceof Error) {
|
|
124
|
+
// Node's ExecException includes stderr in the message
|
|
125
|
+
errorMessage = parseErrorMessage(error.message);
|
|
126
|
+
}
|
|
127
|
+
else if (typeof error === "string") {
|
|
128
|
+
errorMessage = parseErrorMessage(error);
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
errorMessage = "AppleScript execution failed with unknown error";
|
|
132
|
+
}
|
|
133
|
+
// Log for debugging (MCP servers typically run in terminal)
|
|
134
|
+
console.error(`AppleScript error: ${errorMessage}`);
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
output: "",
|
|
138
|
+
error: errorMessage,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for AppleScript execution utilities
|
|
3
|
+
*
|
|
4
|
+
* These tests mock the child_process.execSync function to avoid
|
|
5
|
+
* requiring actual AppleScript execution during testing.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
8
|
+
import { execSync } from "child_process";
|
|
9
|
+
import { executeAppleScript } from "./applescript.js";
|
|
10
|
+
// Mock the child_process module
|
|
11
|
+
vi.mock("child_process", () => ({
|
|
12
|
+
execSync: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
const mockExecSync = vi.mocked(execSync);
|
|
15
|
+
describe("executeAppleScript", () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.clearAllMocks();
|
|
18
|
+
});
|
|
19
|
+
describe("successful execution", () => {
|
|
20
|
+
it("returns success result with trimmed output", () => {
|
|
21
|
+
// Arrange: Mock a successful AppleScript execution
|
|
22
|
+
mockExecSync.mockReturnValue(" Note Title \n");
|
|
23
|
+
// Act: Execute a simple script
|
|
24
|
+
const result = executeAppleScript('tell app "Notes" to get name of note 1');
|
|
25
|
+
// Assert: Output should be trimmed
|
|
26
|
+
expect(result.success).toBe(true);
|
|
27
|
+
expect(result.output).toBe("Note Title");
|
|
28
|
+
expect(result.error).toBeUndefined();
|
|
29
|
+
});
|
|
30
|
+
it("preserves newlines within the script for AppleScript syntax", () => {
|
|
31
|
+
mockExecSync.mockReturnValue("success");
|
|
32
|
+
// Multi-line AppleScript with tell blocks
|
|
33
|
+
const script = `
|
|
34
|
+
tell application "Notes"
|
|
35
|
+
tell account "iCloud"
|
|
36
|
+
get notes
|
|
37
|
+
end tell
|
|
38
|
+
end tell
|
|
39
|
+
`;
|
|
40
|
+
executeAppleScript(script);
|
|
41
|
+
// Verify the script was passed with newlines preserved
|
|
42
|
+
const calledCommand = mockExecSync.mock.calls[0][0];
|
|
43
|
+
expect(calledCommand).toContain("tell application");
|
|
44
|
+
expect(calledCommand).toContain("end tell");
|
|
45
|
+
});
|
|
46
|
+
it("escapes single quotes in the script for shell safety", () => {
|
|
47
|
+
mockExecSync.mockReturnValue("content");
|
|
48
|
+
// Script containing a single quote (e.g., in a note title)
|
|
49
|
+
executeAppleScript('get note "Rob\'s Notes"');
|
|
50
|
+
// Verify the quote was escaped for shell
|
|
51
|
+
const calledCommand = mockExecSync.mock.calls[0][0];
|
|
52
|
+
expect(calledCommand).toContain("Rob'\\''s");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
describe("error handling", () => {
|
|
56
|
+
it("returns error result when execution fails", () => {
|
|
57
|
+
// Arrange: Mock an AppleScript execution failure
|
|
58
|
+
mockExecSync.mockImplementation(() => {
|
|
59
|
+
throw new Error("execution error: Can't get note. (-1728)");
|
|
60
|
+
});
|
|
61
|
+
// Act: Try to execute a script that will fail
|
|
62
|
+
const result = executeAppleScript('get note "Nonexistent"');
|
|
63
|
+
// Assert: Should return structured error
|
|
64
|
+
expect(result.success).toBe(false);
|
|
65
|
+
expect(result.output).toBe("");
|
|
66
|
+
expect(result.error).toBeDefined();
|
|
67
|
+
});
|
|
68
|
+
it("parses execution error messages cleanly", () => {
|
|
69
|
+
mockExecSync.mockImplementation(() => {
|
|
70
|
+
throw new Error("execution error: Note not found (-1728)");
|
|
71
|
+
});
|
|
72
|
+
const result = executeAppleScript("get note 1");
|
|
73
|
+
// Should extract the meaningful part of the error
|
|
74
|
+
expect(result.error).toBe("Note not found");
|
|
75
|
+
});
|
|
76
|
+
it("handles 'not found' error patterns", () => {
|
|
77
|
+
mockExecSync.mockImplementation(() => {
|
|
78
|
+
throw new Error('Can\'t get note "Missing".');
|
|
79
|
+
});
|
|
80
|
+
const result = executeAppleScript('get note "Missing"');
|
|
81
|
+
expect(result.error).toContain("Not found");
|
|
82
|
+
});
|
|
83
|
+
it("handles non-Error exceptions gracefully", () => {
|
|
84
|
+
mockExecSync.mockImplementation(() => {
|
|
85
|
+
throw "string error"; // Some code throws strings
|
|
86
|
+
});
|
|
87
|
+
const result = executeAppleScript("some script");
|
|
88
|
+
expect(result.success).toBe(false);
|
|
89
|
+
expect(result.error).toBe("string error");
|
|
90
|
+
});
|
|
91
|
+
it("handles unknown error types", () => {
|
|
92
|
+
mockExecSync.mockImplementation(() => {
|
|
93
|
+
throw { weird: "object" }; // Unusual but possible
|
|
94
|
+
});
|
|
95
|
+
const result = executeAppleScript("some script");
|
|
96
|
+
expect(result.success).toBe(false);
|
|
97
|
+
expect(result.error).toBe("AppleScript execution failed with unknown error");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
describe("input validation", () => {
|
|
101
|
+
it("returns error for empty script", () => {
|
|
102
|
+
const result = executeAppleScript("");
|
|
103
|
+
expect(result.success).toBe(false);
|
|
104
|
+
expect(result.error).toBe("Cannot execute empty AppleScript");
|
|
105
|
+
expect(mockExecSync).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
107
|
+
it("returns error for whitespace-only script", () => {
|
|
108
|
+
const result = executeAppleScript(" \n\t ");
|
|
109
|
+
expect(result.success).toBe(false);
|
|
110
|
+
expect(result.error).toBe("Cannot execute empty AppleScript");
|
|
111
|
+
expect(mockExecSync).not.toHaveBeenCalled();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
describe("execution options", () => {
|
|
115
|
+
it("uses correct timeout setting", () => {
|
|
116
|
+
mockExecSync.mockReturnValue("ok");
|
|
117
|
+
executeAppleScript("test");
|
|
118
|
+
const options = mockExecSync.mock.calls[0][1];
|
|
119
|
+
expect(options.timeout).toBe(10000); // 10 second timeout
|
|
120
|
+
});
|
|
121
|
+
it("uses UTF-8 encoding for output", () => {
|
|
122
|
+
mockExecSync.mockReturnValue("日本語テスト");
|
|
123
|
+
const result = executeAppleScript("test");
|
|
124
|
+
expect(result.output).toBe("日本語テスト");
|
|
125
|
+
const options = mockExecSync.mock.calls[0][1];
|
|
126
|
+
expect(options.encoding).toBe("utf8");
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "apple-notes-mcp",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "build/index.js",
|
|
7
|
+
"types": "build/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"build",
|
|
10
|
+
"README.md",
|
|
11
|
+
"LICENSE"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsc && tsc-alias",
|
|
15
|
+
"start": "node build/index.js",
|
|
16
|
+
"dev": "tsc --watch",
|
|
17
|
+
"test": "vitest run",
|
|
18
|
+
"test:watch": "vitest",
|
|
19
|
+
"lint": "ESLINT_USE_FLAT_CONFIG=false eslint src --ext .ts",
|
|
20
|
+
"lint:fix": "ESLINT_USE_FLAT_CONFIG=false eslint src --ext .ts --fix",
|
|
21
|
+
"format": "prettier --write src",
|
|
22
|
+
"format:check": "prettier --check src",
|
|
23
|
+
"typecheck": "tsc --noEmit",
|
|
24
|
+
"prepublishOnly": "npm run lint && npm run test && npm run build"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"mcp",
|
|
28
|
+
"apple-notes",
|
|
29
|
+
"claude",
|
|
30
|
+
"ai",
|
|
31
|
+
"applescript",
|
|
32
|
+
"macos",
|
|
33
|
+
"notes",
|
|
34
|
+
"model-context-protocol"
|
|
35
|
+
],
|
|
36
|
+
"author": "Rob Sweet <rob@superiortech.io>",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "git+https://github.com/sweetrb/mcp-apple-notes.git"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/sweetrb/mcp-apple-notes#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/sweetrb/mcp-apple-notes/issues"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=20.0.0"
|
|
48
|
+
},
|
|
49
|
+
"os": [
|
|
50
|
+
"darwin"
|
|
51
|
+
],
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"@modelcontextprotocol/sdk": "1.4.1",
|
|
54
|
+
"zod": "^3.22.4"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^20.0.0",
|
|
58
|
+
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
59
|
+
"@typescript-eslint/parser": "^8.0.0",
|
|
60
|
+
"eslint": "^9.0.0",
|
|
61
|
+
"prettier": "^3.0.0",
|
|
62
|
+
"tsc-alias": "^1.8.10",
|
|
63
|
+
"tsconfig-paths": "^4.2.0",
|
|
64
|
+
"typescript": "^5.0.0",
|
|
65
|
+
"vitest": "^2.0.0"
|
|
66
|
+
},
|
|
67
|
+
"volta": {
|
|
68
|
+
"node": "22.13.1"
|
|
69
|
+
}
|
|
70
|
+
}
|