Skip to content

Commit a55bbbd

Browse files
committed
Add importFile tool
1 parent fde81fb commit a55bbbd

6 files changed

Lines changed: 285 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 33 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
- Repo: dvcrn/mcp-server-devonthink
12
# Copilot Instructions
23

34
- This project uses [Vitest](https://vitest.dev/) for testing.
@@ -44,6 +45,7 @@ npm run build # Verify the build works
4445
- **`src/tools/`**: Directory containing all tool implementations
4546
- **`isRunning.ts`**: Defines the `is_running` tool, which checks if DEVONthink is active
4647
- **`createRecord.ts`**: Creates new records in DEVONthink
48+
- **`importFile.ts`**: Imports existing files or folders into DEVONthink from a POSIX path or file URL
4749
- **`deleteRecord.ts`**: Deletes records from DEVONthink
4850
- **`moveRecord.ts`**: Moves records between groups
4951
- **`getRecordProperties.ts`**: Retrieves detailed properties and metadata for records
@@ -95,31 +97,32 @@ The MCP server currently provides the following tools:
9597

9698
1. **`is_running`** - Check if DEVONthink is running
9799
2. **`create_record`** - Create new records (notes, bookmarks, groups) with specified properties
98-
3. **`delete_record`** - Delete records by ID, name, or path
99-
4. **`move_record`** - Move records between groups
100-
5. **`get_record_properties`** - Get detailed metadata and properties for records
101-
6. **`get_record_by_identifier`** - Get a record using either UUID or ID+Database combination (recommended for specific record lookup)
102-
7. **`search`** - Perform text-based searches with various comparison options (now returns both ID and UUID)
103-
8. **`lookup_record`** - Look up records by filename, path, URL, tags, comment, or content hash (exact matches only, no wildcards). Supports `x-devonthink-item://` URLs with percent-encoded identifiers (e.g., email message-IDs)
104-
9. **`create_from_url`** - Create records from web URLs in multiple formats
105-
10. **`get_open_databases`** - Get a list of all currently open databases
106-
11. **`current_database`** - Get information about the currently active database
107-
12. **`selected_records`** - Get information about currently selected records in DEVONthink
108-
13. **`list_group_content`** - Lists the content of a specific group
109-
14. **`get_record_content`** - Gets the content of a specific record
110-
15. **`rename_record`** - Renames a specific record
111-
16. **`add_tags`** - Adds tags to a specific record
112-
17. **`remove_tags`** - Removes tags from a specific record
113-
18. **`classify`** - Get AI-powered suggestions for organizing records
114-
19. **`compare`** - Find similar records or compare two specific records
115-
20. **`replicate_record`** - Replicate records within the same database (creates linked references)
116-
21. **`duplicate_record`** - Duplicate records to any database (creates independent copies)
117-
22. **`convert_record`** - Convert records to different formats (plain text, rich text, markdown, HTML, PDF, etc.)
118-
23. **`update_record_content`** - Update the content of existing records while preserving UUID and metadata
119-
24. **`ask_ai_about_documents`** - Ask AI questions about specific documents for analysis, comparison, or extraction
120-
25. **`check_ai_health`** - Check if DEVONthink's AI services are available and working properly
121-
26. **`create_summary_document`** - Create AI-generated summaries from multiple documents
122-
27. **`get_ai_tool_documentation`** - Get detailed documentation for AI tools including examples and use cases
100+
3. **`import_file`** - Import an existing file or folder into DEVONthink from a POSIX path or file URL
101+
4. **`delete_record`** - Delete records by ID, name, or path
102+
5. **`move_record`** - Move records between groups
103+
6. **`get_record_properties`** - Get detailed metadata and properties for records
104+
7. **`get_record_by_identifier`** - Get a record using either UUID or ID+Database combination (recommended for specific record lookup)
105+
8. **`search`** - Perform text-based searches with various comparison options (now returns both ID and UUID)
106+
9. **`lookup_record`** - Look up records by filename, path, URL, tags, comment, or content hash (exact matches only, no wildcards). Supports `x-devonthink-item://` URLs with percent-encoded identifiers (e.g., email message-IDs)
107+
10. **`create_from_url`** - Create records from web URLs in multiple formats
108+
11. **`get_open_databases`** - Get a list of all currently open databases
109+
12. **`current_database`** - Get information about the currently active database
110+
13. **`selected_records`** - Get information about currently selected records in DEVONthink
111+
14. **`list_group_content`** - Lists the content of a specific group
112+
15. **`get_record_content`** - Gets the content of a specific record
113+
16. **`rename_record`** - Renames a specific record
114+
17. **`add_tags`** - Adds tags to a specific record
115+
18. **`remove_tags`** - Removes tags from a specific record
116+
19. **`classify`** - Get AI-powered suggestions for organizing records
117+
20. **`compare`** - Find similar records or compare two specific records
118+
21. **`replicate_record`** - Replicate records within the same database (creates linked references)
119+
22. **`duplicate_record`** - Duplicate records to any database (creates independent copies)
120+
23. **`convert_record`** - Convert records to different formats (plain text, rich text, markdown, HTML, PDF, etc.)
121+
24. **`update_record_content`** - Update the content of existing records while preserving UUID and metadata
122+
25. **`ask_ai_about_documents`** - Ask AI questions about specific documents for analysis, comparison, or extraction
123+
26. **`check_ai_health`** - Check if DEVONthink's AI services are available and working properly
124+
27. **`create_summary_document`** - Create AI-generated summaries from multiple documents
125+
28. **`get_ai_tool_documentation`** - Get detailed documentation for AI tools including examples and use cases
123126

124127
## Adding New Tools
125128

@@ -319,6 +322,11 @@ Refer to `docs/devonthink-javascript-2.md` for comprehensive documentation of av
319322

320323
## Recent Improvements
321324

325+
### File Import Tool (2026-04)
326+
- Added `import_file` to import existing files or folders with DEVONthink's native `import path` AppleScript/JXA command
327+
- The tool defaults to the global Inbox when no destination is provided and accepts a destination group UUID or database override for explicit placement
328+
- This is distinct from `create_record`, which creates DEVONthink-native records and does not ingest an existing file from disk
329+
322330
### URL Lookup Fix and Integration Tests (2025-09)
323331
- Fixed `lookup_record` to handle `x-devonthink-item://` URLs with percent-encoded identifiers (e.g., email message-IDs)
324332
- Previously, these URLs were passed directly to `lookupRecordsWithURL` which searches the `url` property, not `referenceURL` — returning 0 results

mise.toml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[tasks.build]
2+
run = "npm run build"
3+
description = "Build the project"
4+
5+
[tasks.test]
6+
run = "npm test"
7+
description = "Run unit tests"
8+
9+
[tasks.type_check]
10+
run = "npm run type-check"
11+
description = "Run TypeScript type checking"
12+
13+
[tasks.format]
14+
run = "npm run format"
15+
description = "Format the codebase with Biome"
16+
17+
[tasks.format_check]
18+
run = "npm run format:check"
19+
description = "Check formatting with Biome"

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "mcp-server-devonthink",
3-
"version": "1.7.2",
3+
"version": "1.8.0",
44
"description": "MCP server that provides access to DEVONthink",
55
"license": "MIT",
66
"author": "David Mohl <git@d.sh>",

src/devonthink.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
} from "@modelcontextprotocol/sdk/types.js";
1212
import { isRunningTool } from "./tools/isRunning.js";
1313
import { createRecordTool } from "./tools/createRecord.js";
14+
import { importFileTool } from "./tools/importFile.js";
1415
import { deleteRecordTool } from "./tools/deleteRecord.js";
1516
import { moveRecordTool } from "./tools/moveRecord.js";
1617
import { getRecordPropertiesTool } from "./tools/getRecordProperties.js";
@@ -56,6 +57,7 @@ export const createServer = async () => {
5657
const tools: Tool[] = [
5758
isRunningTool,
5859
createRecordTool,
60+
importFileTool,
5961
deleteRecordTool,
6062
moveRecordTool,
6163
getRecordPropertiesTool,

src/tools/importFile.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { z } from "zod";
2+
import { zodToJsonSchema } from "zod-to-json-schema";
3+
import { Tool, ToolSchema } from "@modelcontextprotocol/sdk/types.js";
4+
import { executeJxa } from "../applescript/execute.js";
5+
import { escapeStringForJXA, isJXASafeString } from "../utils/escapeString.js";
6+
import { getDatabaseHelper } from "../utils/jxaHelpers.js";
7+
8+
const ToolInputSchema = ToolSchema.shape.inputSchema;
9+
type ToolInput = z.infer<typeof ToolInputSchema>;
10+
11+
const ImportFileSchema = z
12+
.object({
13+
filePath: z
14+
.string()
15+
.describe("POSIX file path or file URL of the file or folder to import"),
16+
name: z.string().optional().describe("Custom name for the imported record (optional)"),
17+
parentGroupUuid: z
18+
.string()
19+
.optional()
20+
.describe(
21+
"UUID of the destination group (optional, overrides the default Inbox destination)",
22+
),
23+
databaseName: z
24+
.string()
25+
.optional()
26+
.describe(
27+
"Database to import into when parentGroupUuid is not provided (optional, defaults to global Inbox when omitted)",
28+
),
29+
})
30+
.strict();
31+
32+
type ImportFileInput = z.infer<typeof ImportFileSchema>;
33+
34+
interface ImportFileResult {
35+
success: boolean;
36+
error?: string;
37+
recordId?: number;
38+
name?: string;
39+
path?: string;
40+
location?: string;
41+
uuid?: string;
42+
referenceURL?: string;
43+
indexed?: boolean;
44+
}
45+
46+
const importFile = async (input: ImportFileInput): Promise<ImportFileResult> => {
47+
const { filePath, name, parentGroupUuid, databaseName } = input;
48+
49+
if (!isJXASafeString(filePath)) {
50+
return { success: false, error: "File path contains invalid characters" };
51+
}
52+
if (name && !isJXASafeString(name)) {
53+
return { success: false, error: "Name contains invalid characters" };
54+
}
55+
if (parentGroupUuid && !isJXASafeString(parentGroupUuid)) {
56+
return { success: false, error: "Parent group UUID contains invalid characters" };
57+
}
58+
if (databaseName && !isJXASafeString(databaseName)) {
59+
return { success: false, error: "Database name contains invalid characters" };
60+
}
61+
62+
const script = `
63+
(() => {
64+
const theApp = Application("DEVONthink");
65+
theApp.includeStandardAdditions = true;
66+
67+
${getDatabaseHelper}
68+
69+
try {
70+
const pFilePath = "${escapeStringForJXA(filePath)}";
71+
const pName = ${name ? `"${escapeStringForJXA(name)}"` : "null"};
72+
const pParentGroupUuid = ${parentGroupUuid ? `"${escapeStringForJXA(parentGroupUuid)}"` : "null"};
73+
const pDatabaseName = ${databaseName ? `"${escapeStringForJXA(databaseName)}"` : "null"};
74+
75+
let destinationGroup = null;
76+
77+
if (pParentGroupUuid) {
78+
destinationGroup = theApp.getRecordWithUuid(pParentGroupUuid);
79+
if (!destinationGroup) {
80+
throw new Error("Parent group with UUID not found: " + pParentGroupUuid);
81+
}
82+
83+
const destinationType = destinationGroup.recordType();
84+
if (destinationType !== "group" && destinationType !== "smart group") {
85+
throw new Error("Destination is not a group. Record type: " + destinationType);
86+
}
87+
} else if (pDatabaseName) {
88+
const targetDatabase = getDatabase(theApp, pDatabaseName);
89+
destinationGroup = targetDatabase.incomingGroup();
90+
if (!destinationGroup) {
91+
destinationGroup = targetDatabase.root();
92+
}
93+
if (!destinationGroup) {
94+
throw new Error("No destination group available for database: " + targetDatabase.name());
95+
}
96+
} else {
97+
const inboxDatabase = theApp.inbox();
98+
if (!inboxDatabase) {
99+
throw new Error("Global inbox database not available");
100+
}
101+
destinationGroup = inboxDatabase.root();
102+
if (!destinationGroup) {
103+
throw new Error("Global inbox root not available");
104+
}
105+
}
106+
107+
const options = {};
108+
options["to"] = destinationGroup;
109+
if (pName) {
110+
options["name"] = pName;
111+
}
112+
113+
const imported = theApp.importPath(pFilePath, options);
114+
if (!imported || !imported.exists()) {
115+
throw new Error("Import failed for path: " + pFilePath);
116+
}
117+
118+
const response = {};
119+
response["success"] = true;
120+
response["recordId"] = imported.id();
121+
response["name"] = imported.name();
122+
response["path"] = imported.path();
123+
response["location"] = imported.location();
124+
response["uuid"] = imported.uuid();
125+
response["referenceURL"] = imported.referenceURL();
126+
response["indexed"] = imported.indexed();
127+
return JSON.stringify(response);
128+
} catch (error) {
129+
const errorResponse = {};
130+
errorResponse["success"] = false;
131+
errorResponse["error"] = error.toString();
132+
return JSON.stringify(errorResponse);
133+
}
134+
})();
135+
`;
136+
137+
return await executeJxa<ImportFileResult>(script);
138+
};
139+
140+
export const importFileTool: Tool = {
141+
name: "import_file",
142+
description:
143+
'Import an existing file or folder from a POSIX path or file URL into DEVONthink. Defaults to the global Inbox when no destination is provided.\n\nExample:\n{\n "filePath": "/Users/david/Documents/report.pdf"\n}',
144+
inputSchema: zodToJsonSchema(ImportFileSchema) as ToolInput,
145+
run: importFile,
146+
};

tests/tools/importFile.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const { executeJxaMock } = vi.hoisted(() => ({
4+
executeJxaMock: vi.fn(),
5+
}));
6+
7+
vi.mock("../../src/applescript/execute.js", () => ({
8+
executeJxa: executeJxaMock,
9+
}));
10+
11+
import { importFileTool } from "../../src/tools/importFile.js";
12+
13+
describe("importFileTool", () => {
14+
beforeEach(() => {
15+
executeJxaMock.mockReset();
16+
});
17+
18+
it("builds an importPath JXA script and returns the executor result", async () => {
19+
const mockResult = {
20+
success: true,
21+
recordId: 42,
22+
name: "Imported Record",
23+
uuid: "1234-5678",
24+
};
25+
executeJxaMock.mockResolvedValue(mockResult);
26+
27+
const result = await importFileTool.run?.({
28+
filePath: "/tmp/test document.txt",
29+
name: "Renamed Import",
30+
databaseName: "Inbox",
31+
});
32+
33+
expect(result).toEqual(mockResult);
34+
expect(executeJxaMock).toHaveBeenCalledTimes(1);
35+
36+
const [script] = executeJxaMock.mock.calls[0];
37+
expect(script).toContain('const pFilePath = "/tmp/test document.txt";');
38+
expect(script).toContain('const pName = "Renamed Import";');
39+
expect(script).toContain('const pDatabaseName = "Inbox";');
40+
expect(script).toContain("destinationGroup = targetDatabase.incomingGroup();");
41+
expect(script).toContain("const imported = theApp.importPath(pFilePath, options);");
42+
});
43+
44+
it("defaults to the global inbox when no destination is provided", async () => {
45+
executeJxaMock.mockResolvedValue({ success: true });
46+
47+
await importFileTool.run?.({
48+
filePath: "/tmp/probe.txt",
49+
});
50+
51+
const [script] = executeJxaMock.mock.calls[0];
52+
expect(script).toContain("const inboxDatabase = theApp.inbox();");
53+
expect(script).toContain('throw new Error("Global inbox database not available");');
54+
expect(script).toContain("destinationGroup = inboxDatabase.root();");
55+
});
56+
57+
it("uses an explicit parent group when parentGroupUuid is provided", async () => {
58+
executeJxaMock.mockResolvedValue({ success: true });
59+
60+
await importFileTool.run?.({
61+
filePath: "/tmp/probe.txt",
62+
parentGroupUuid: "ABC-123",
63+
});
64+
65+
const [script] = executeJxaMock.mock.calls[0];
66+
expect(script).toContain('const pParentGroupUuid = "ABC-123";');
67+
expect(script).toContain("destinationGroup = theApp.getRecordWithUuid(pParentGroupUuid);");
68+
expect(script).toContain(
69+
'throw new Error("Parent group with UUID not found: " + pParentGroupUuid);',
70+
);
71+
});
72+
73+
it("rejects invalid file paths before invoking JXA", async () => {
74+
const result = await importFileTool.run?.({
75+
filePath: "bad\u0000path",
76+
});
77+
78+
expect(result).toEqual({
79+
success: false,
80+
error: "File path contains invalid characters",
81+
});
82+
expect(executeJxaMock).not.toHaveBeenCalled();
83+
});
84+
});

0 commit comments

Comments
 (0)