Skip to content

Commit af182ac

Browse files
authored
Merge pull request #28 from darylrobbins/feat/reference-url-and-integration-tests
2 parents 13c2926 + fe8de8c commit af182ac

17 files changed

Lines changed: 1484 additions & 651 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ node_modules
22
dist
33
.roo
44
.claude
5+
.worktrees

CLAUDE.md

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,17 @@ npm run build # Verify the build works
7373
- **`constants.ts`**: Shared AI constants and type definitions
7474
- **`base/`**: Base classes and utilities for tool development
7575
- **`DevonThinkTool.ts`**: Base class providing standardized tool creation with helper functions
76+
- **`tests/integration/`**: Vitest integration tests that run against a live DEVONthink instance
77+
- **`helpers.ts`**: Shared test utilities (`jxa`, `createTestRecord`, `deleteRecord`, `sleep`, `getTestContext`)
78+
- **`setup.ts`**: Global setup — creates a temporary database and `.eml` test file
79+
- **`vitest.integration.config.ts`**: Vitest config for integration tests (run with `npx vitest --config tests/integration/vitest.integration.config.ts`)
80+
- **`connectivity.test.ts`**: Verifies DEVONthink is running and accessible
81+
- **`crud.test.ts`**: Create, read, update, delete operations
82+
- **`identification.test.ts`**: UUID lookup, referenceURL lookup, email `.eml` import, `lookupRecord` URL handling, search by name
83+
- **`organization.test.ts`**: Group content listing, record moving, tagging
84+
- **`transformation.test.ts`**: Record conversion between formats
85+
- **`network.test.ts`**: URL-based record creation
86+
- **`ai.test.ts`**: AI-powered tool tests
7687
- **`src/utils/`**: Utility functions
7788
- **`escapeString.ts`**: Provides safe string escaping for JXA script interpolation
7889
- **`jxaHelpers.ts`**: JXA helper functions including version detection
@@ -89,7 +100,7 @@ The MCP server currently provides the following tools:
89100
5. **`get_record_properties`** - Get detailed metadata and properties for records
90101
6. **`get_record_by_identifier`** - Get a record using either UUID or ID+Database combination (recommended for specific record lookup)
91102
7. **`search`** - Perform text-based searches with various comparison options (now returns both ID and UUID)
92-
8. **`lookup_record`** - Look up records by filename, path, URL, tags, comment, or content hash (exact matches only, no wildcards)
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)
93104
9. **`create_from_url`** - Create records from web URLs in multiple formats
94105
10. **`get_open_databases`** - Get a list of all currently open databases
95106
11. **`current_database`** - Get information about the currently active database
@@ -308,6 +319,14 @@ Refer to `docs/devonthink-javascript-2.md` for comprehensive documentation of av
308319

309320
## Recent Improvements
310321

322+
### URL Lookup Fix and Integration Tests (2025-09)
323+
- Fixed `lookup_record` to handle `x-devonthink-item://` URLs with percent-encoded identifiers (e.g., email message-IDs)
324+
- Previously, these URLs were passed directly to `lookupRecordsWithURL` which searches the `url` property, not `referenceURL` — returning 0 results
325+
- The fix detects `x-devonthink-item://` prefix, decodes the percent-encoded identifier, and uses `getRecordWithUuid` instead
326+
- Also decodes percent-encoded regular URLs before passing to `lookupRecordsWithURL`
327+
- Added comprehensive Vitest integration test suite (`tests/integration/`) covering: connectivity, CRUD, identification, organization, transformation, network, and AI tools
328+
- Integration tests run against a live DEVONthink instance using a temporary database
329+
311330
### AI-Powered Tools (2025-08)
312331
- Added comprehensive AI integration leveraging DEVONthink's native AI capabilities
313332
- New `ask_ai_about_documents` tool for AI-powered document analysis and Q&A

package.json

Lines changed: 44 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,46 @@
11
{
2-
"name": "mcp-server-devonthink",
3-
"version": "1.7.1",
4-
"description": "MCP server that provides access to DEVONthink",
5-
"license": "MIT",
6-
"author": "David Mohl <git@d.sh>",
7-
"homepage": "https://github.com/dvcrn/mcp-server-devonthink",
8-
"repository": {
9-
"type": "git",
10-
"url": "git+https://github.com/dvcrn/mcp-server-devonthink.git"
11-
},
12-
"bugs": "https://github.com/dvcrn/mcp-server-devonthink/issues",
13-
"type": "module",
14-
"bin": {
15-
"mcp-server-devonthink": "dist/index.js"
16-
},
17-
"files": [
18-
"dist"
19-
],
20-
"scripts": {
21-
"build": "tsc && shx chmod +x dist/index.js",
22-
"prepare": "npm run build",
23-
"watch": "tsc --watch",
24-
"format": "biome format --write .",
25-
"lint:fix": "biome lint --fix",
26-
"start": "node dist/index.js",
27-
"type-check": "tsc --noEmit",
28-
"test": "vitest run",
29-
"format:check": "biome format ."
30-
},
31-
"dependencies": {
32-
"@modelcontextprotocol/sdk": "1.0.1",
33-
"express": "^4.21.1",
34-
"zod": "^3.23.8",
35-
"zod-to-json-schema": "^3.23.5"
36-
},
37-
"devDependencies": {
38-
"@biomejs/biome": "^2.2.3",
39-
"@types/express": "^5.0.0",
40-
"shx": "^0.3.4",
41-
"typescript": "^5.6.2",
42-
"vitest": "^3.2.0"
43-
},
44-
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
2+
"name": "mcp-server-devonthink",
3+
"version": "1.7.1",
4+
"description": "MCP server that provides access to DEVONthink",
5+
"license": "MIT",
6+
"author": "David Mohl <git@d.sh>",
7+
"homepage": "https://github.com/dvcrn/mcp-server-devonthink",
8+
"repository": {
9+
"type": "git",
10+
"url": "git+https://github.com/dvcrn/mcp-server-devonthink.git"
11+
},
12+
"bugs": "https://github.com/dvcrn/mcp-server-devonthink/issues",
13+
"type": "module",
14+
"bin": {
15+
"mcp-server-devonthink": "dist/index.js"
16+
},
17+
"files": [
18+
"dist"
19+
],
20+
"scripts": {
21+
"build": "tsc && shx chmod +x dist/index.js",
22+
"prepare": "npm run build",
23+
"watch": "tsc --watch",
24+
"format": "biome format --write .",
25+
"lint:fix": "biome lint --fix",
26+
"start": "node dist/index.js",
27+
"type-check": "tsc --noEmit",
28+
"test": "vitest run",
29+
"format:check": "biome format .",
30+
"test:integration": "vitest run --config tests/integration/vitest.integration.config.ts"
31+
},
32+
"dependencies": {
33+
"@modelcontextprotocol/sdk": "1.0.1",
34+
"express": "^4.21.1",
35+
"zod": "^3.23.8",
36+
"zod-to-json-schema": "^3.23.5"
37+
},
38+
"devDependencies": {
39+
"@biomejs/biome": "^2.2.3",
40+
"@types/express": "^5.0.0",
41+
"shx": "^0.3.4",
42+
"typescript": "^5.6.2",
43+
"vitest": "^3.2.0"
44+
},
45+
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
4546
}

src/tools/getRecordByIdentifier.ts

Lines changed: 120 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,22 @@ const GetRecordByIdentifierSchema = z
1313
uuid: z.string().optional().describe("UUID of the record"),
1414
id: z.number().optional().describe("ID of the record (requires databaseName)"),
1515
databaseName: z.string().optional().describe("Database name (required with id)"),
16+
referenceURL: z
17+
.string()
18+
.optional()
19+
.describe(
20+
"A x-devonthink-item:// URL. Works for all record types including imported emails which use non-UUID identifiers.",
21+
),
1622
})
1723
.strict()
1824
.refine(
1925
(data) =>
20-
data.uuid !== undefined || (data.id !== undefined && data.databaseName !== undefined),
26+
data.referenceURL !== undefined ||
27+
data.uuid !== undefined ||
28+
(data.id !== undefined && data.databaseName !== undefined),
2129
{
22-
message: "Either UUID alone, or ID with databaseName must be provided",
30+
message:
31+
"Either referenceURL alone, UUID alone, or ID with databaseName must be provided",
2332
},
2433
);
2534

@@ -37,6 +46,7 @@ interface RecordResult {
3746
recordType: string;
3847
kind: string;
3948
database: string;
49+
referenceURL: string;
4050
creationDate?: string;
4151
modificationDate?: string;
4252
tags?: string[];
@@ -47,7 +57,7 @@ interface RecordResult {
4757
}
4858

4959
const getRecordByIdentifier = async (input: GetRecordByIdentifierInput): Promise<RecordResult> => {
50-
const { uuid, id, databaseName } = input;
60+
const { uuid, id, databaseName, referenceURL } = input;
5161

5262
// Validate string inputs
5363
if (uuid && !isJXASafeString(uuid)) {
@@ -56,90 +66,151 @@ const getRecordByIdentifier = async (input: GetRecordByIdentifierInput): Promise
5666
if (databaseName && !isJXASafeString(databaseName)) {
5767
return { success: false, error: "Database name contains invalid characters" };
5868
}
69+
if (referenceURL && !isJXASafeString(referenceURL)) {
70+
return { success: false, error: "Reference URL contains invalid characters" };
71+
}
72+
if (id !== undefined && typeof id !== "number") {
73+
return { success: false, error: "ID must be a number" };
74+
}
5975

6076
const script = `
6177
(() => {
6278
const theApp = Application("DEVONthink");
6379
theApp.includeStandardAdditions = true;
64-
80+
6581
// Inject helper functions
6682
${getRecordLookupHelpers()}
6783
${getDatabaseHelper}
68-
84+
6985
try {
7086
let targetRecord;
7187
let targetDatabase;
7288
let lookupResult;
73-
74-
if (${uuid ? `"${escapeStringForJXA(uuid)}"` : "null"}) {
89+
90+
if (${referenceURL ? `"${escapeStringForJXA(referenceURL)}"` : "null"}) {
91+
// Reference URL lookup (x-devonthink-item:// URLs)
92+
const refURL = ${referenceURL ? `"${escapeStringForJXA(referenceURL)}"` : "null"};
93+
const prefix = "x-devonthink-item://";
94+
95+
// Extract the identifier part after the prefix
96+
const identifier = refURL.startsWith(prefix) ? refURL.substring(prefix.length) : refURL;
97+
98+
// Check if it looks like a UUID (hex digits and hyphens)
99+
const uuidPattern = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
100+
101+
if (uuidPattern.test(identifier)) {
102+
// Fast path: standard UUID format
103+
targetRecord = theApp.getRecordWithUuid(identifier.toUpperCase());
104+
}
105+
106+
if (!targetRecord) {
107+
// Non-UUID format (e.g. imported emails with message-ID-based UUIDs).
108+
// DEVONthink stores the URL-decoded identifier as the record's UUID,
109+
// so decode and try a direct UUID lookup first.
110+
try {
111+
const decoded = decodeURIComponent(identifier);
112+
if (decoded !== identifier) {
113+
targetRecord = theApp.getRecordWithUuid(decoded);
114+
}
115+
} catch (e) {
116+
// Invalid percent-encoding — skip
117+
}
118+
}
119+
120+
if (!targetRecord) {
121+
// Fall back to lookupRecordsWithURL across all open databases.
122+
// Omitting the "in" option searches globally.
123+
const results = theApp.lookupRecordsWithURL(refURL);
124+
if (results && results.length > 0) {
125+
// lookupRecordsWithURL matches the url property;
126+
// verify by checking referenceURL
127+
for (let j = 0; j < results.length; j++) {
128+
if (results[j].referenceURL() === refURL) {
129+
targetRecord = results[j];
130+
break;
131+
}
132+
}
133+
}
134+
}
135+
136+
if (!targetRecord) {
137+
return JSON.stringify({
138+
success: false,
139+
error: "Record not found for reference URL: " + refURL
140+
});
141+
}
142+
143+
targetDatabase = targetRecord.database();
144+
145+
} else if (${uuid ? `"${escapeStringForJXA(uuid)}"` : "null"}) {
75146
// UUID lookup - globally unique
76-
const lookupOptions = {
77-
uuid: ${uuid ? `"${escapeStringForJXA(uuid)}"` : "null"}
78-
};
79-
147+
const lookupOptions = {};
148+
lookupOptions["uuid"] = ${uuid ? `"${escapeStringForJXA(uuid)}"` : "null"};
149+
80150
lookupResult = getRecord(theApp, lookupOptions);
81-
151+
82152
if (!lookupResult.record) {
83153
return JSON.stringify({
84154
success: false,
85155
error: "Record with UUID " + (${uuid ? `"${escapeStringForJXA(uuid)}"` : "null"} || "unknown") + " not found"
86156
});
87157
}
88-
158+
89159
targetRecord = lookupResult.record;
90160
// Get the database of the record
91161
targetDatabase = targetRecord.database();
92-
93-
} else if (${id !== undefined ? id : "null"} && ${databaseName ? `"${escapeStringForJXA(databaseName)}"` : "null"}) {
162+
163+
} else if (${formatValueForJXA(id)} !== null && ${databaseName ? `"${escapeStringForJXA(databaseName)}"` : "null"}) {
94164
// ID + Database lookup
95165
targetDatabase = getDatabase(theApp, ${databaseName ? `"${escapeStringForJXA(databaseName)}"` : "null"});
96-
97-
const lookupOptions = {
98-
id: ${id},
99-
database: targetDatabase
100-
};
101-
166+
167+
const lookupOptions = {};
168+
lookupOptions["id"] = ${formatValueForJXA(id)};
169+
lookupOptions["database"] = targetDatabase;
170+
102171
lookupResult = getRecord(theApp, lookupOptions);
103-
172+
104173
if (!lookupResult.record) {
105174
return JSON.stringify({
106175
success: false,
107-
error: "Record with ID " + ${id} + " not found in database '" + (${databaseName ? `"${escapeStringForJXA(databaseName)}"` : "null"} || "unknown") + "'"
176+
error: "Record with ID " + ${formatValueForJXA(id)} + " not found in database '" + (${databaseName ? `"${escapeStringForJXA(databaseName)}"` : "null"} || "unknown") + "'"
108177
});
109178
}
110-
179+
111180
targetRecord = lookupResult.record;
112181
}
113-
182+
114183
// Extract record properties
115-
const record = {
116-
id: targetRecord.id(),
117-
uuid: targetRecord.uuid(),
118-
name: targetRecord.name(),
119-
path: targetRecord.path(),
120-
location: targetRecord.location(),
121-
recordType: targetRecord.recordType(),
122-
kind: targetRecord.kind(),
123-
database: targetDatabase.name(),
124-
creationDate: targetRecord.creationDate() ? targetRecord.creationDate().toString() : null,
125-
modificationDate: targetRecord.modificationDate() ? targetRecord.modificationDate().toString() : null,
126-
tags: targetRecord.tags(),
127-
size: targetRecord.size()
128-
};
129-
184+
const record = {};
185+
record["id"] = targetRecord.id();
186+
record["uuid"] = targetRecord.uuid();
187+
record["name"] = targetRecord.name();
188+
record["path"] = targetRecord.path();
189+
record["location"] = targetRecord.location();
190+
record["recordType"] = targetRecord.recordType();
191+
record["kind"] = targetRecord.kind();
192+
record["database"] = targetDatabase.name();
193+
record["referenceURL"] = targetRecord.referenceURL();
194+
record["creationDate"] = targetRecord.creationDate() ? targetRecord.creationDate().toString() : null;
195+
record["modificationDate"] = targetRecord.modificationDate() ? targetRecord.modificationDate().toString() : null;
196+
record["tags"] = targetRecord.tags();
197+
record["size"] = targetRecord.size();
198+
130199
// Add optional properties if available
131-
if (targetRecord.url && targetRecord.url()) {
132-
record.url = targetRecord.url();
133-
}
134-
if (targetRecord.comment && targetRecord.comment()) {
135-
record.comment = targetRecord.comment();
136-
}
137-
200+
try {
201+
const recordUrl = targetRecord.url();
202+
if (recordUrl) record["url"] = recordUrl;
203+
} catch (e) {}
204+
try {
205+
const recordComment = targetRecord.comment();
206+
if (recordComment) record["comment"] = recordComment;
207+
} catch (e) {}
208+
138209
return JSON.stringify({
139210
success: true,
140211
record: record
141212
});
142-
213+
143214
} catch (error) {
144215
return JSON.stringify({
145216
success: false,
@@ -155,7 +226,7 @@ const getRecordByIdentifier = async (input: GetRecordByIdentifierInput): Promise
155226
export const getRecordByIdentifierTool: Tool = {
156227
name: "get_record_by_identifier",
157228
description:
158-
'Get a DEVONthink record using its UUID or ID.\n\nExample (UUID):\n{\n "uuid": "1234-5678-90AB-CDEF"\n}\n\nExample (ID):\n{\n "id": 12345,\n "databaseName": "MyDatabase"\n}',
229+
'Get a DEVONthink record using its UUID, ID, or x-devonthink-item:// reference URL.\n\nExample (Reference URL):\n{\n "referenceURL": "x-devonthink-item://1234-5678-90AB-CDEF"\n}\n\nExample (Reference URL - email):\n{\n "referenceURL": "x-devonthink-item://message:%3Cfoo@bar.com%3E"\n}\n\nExample (UUID):\n{\n "uuid": "1234-5678-90AB-CDEF"\n}\n\nExample (ID):\n{\n "id": 12345,\n "databaseName": "MyDatabase"\n}',
159230
inputSchema: zodToJsonSchema(GetRecordByIdentifierSchema) as ToolInput,
160231
run: getRecordByIdentifier,
161232
};

0 commit comments

Comments
 (0)